JavaScript-的乐趣-全-

JavaScript 的乐趣(全)

原文:The Joy of JavaScript

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我以传统、学术的方式学习编程。我所就读的大学主要基于以类为中心的语言,如 Java、C++和 C#。当我从这些课程毕业时,我的大脑被训练成认为类是设计程序的最佳(也许甚至是唯一)方式,而其他任何方式都是一种亵渎。

多年后,像世界上任何其他开发者一样,我偶然遇到了 JavaScript——我应该说是 jQuery,因为在当时,jQuery 就是 JavaScript。JavaScript 与我所学的几乎所有内容都截然相反。我在编程的每一个基本方面都遇到了挑战,包括表示领域模型、将行为和数据封装到逻辑模块中,以及处理事件和异步函数。我对自己的说,好吧,让我们使用 jQuery 和一系列第三方库来“修复语言”,并忘记这一切。

但 JavaScript 并不需要修复:我需要修复的是自己。我知道我无法摆脱它。因为 JavaScript 几乎无处不在,所以迟早我会再次遇到它,所以我决定更深入地探索它。了解其原型机制和闭包机制让我真正理解了面向对象编程的含义。当艾伦·凯在 21 世纪初发明这个术语时,他希望将诸如消息传递(对象向其他对象传递或发送消息)、封装(仅暴露必要的部分)和动态链接(在运行时通过名称解析对象的属性)等概念结合起来。与我所学的所有其他语言不同,JavaScript 将这些原则深深植根于其设计之中;更重要的是,这些概念对开发者来说易于访问。这时我才突然意识到,我终于理解了编程。

带着新发现的动力,我继续学习 JavaScript,发现了高阶函数,这让我对函数式编程和可组合软件有了新的认识。突然之间,编程不再令人沮丧;它成了一种乐趣。这种认识推动了我的职业生涯,使我成为《JavaScript 函数式编程》(Manning,2016)、《RxJS 实战》(Manning,2017)和现在《JavaScript 的乐趣》(Manning,2021)等书籍的作者。

这本书是为那些像我一样,有幸了解诸如闭包、原型和高级函数等惊人特性的开发者而写的。他们希望将这些特性提升到下一个层次,以便每天都能享受使用 JavaScript 的乐趣。《JavaScript 的乐趣》展示了这门语言本身所能提供的,而不需要任何第三方库和框架。由于围绕 JavaScript 的主题数量众多,这本书并没有花太多时间深入探讨基本概念(但提供了很好的资源);它也不是编写 ECMAScript 2019、2020 等的指南。相反,它为你提供了对激动人心的主题、趋势和技术近距离的观察,这将使你能够掌握你可能不知道存在的语言领域。作为额外的好处,这本书还介绍了可能在几年内加入语言的一些新特性。了解这些提案是有价值的,这样你就能理解语言的发展方向以及它是如何演变的。

写这本书帮助我度过了这些不确定和焦虑的时光,我真诚地希望你在阅读它时能找到与我写作时同样的快乐。在撰写这份手稿的过程中,我对编程有了全新的认识,我希望它也能为你带来同样的收获。

致谢

这本书花费了很多精力。但我相信,所有这些努力都造就了一本优秀的书籍,它很好地补充了现有的内容,我希望你们也会这样认为。

我想感谢很多人在我成长过程中给予的帮助。

首先,我想感谢我的妻子,安娜。你一直支持我,总是耐心地照顾一切(包括我们两个了不起的儿子,卢克和马修),在我努力完成这本书的过程中。你总是让我相信我能完成它。我爱你。

接下来,我想感谢我在 Manning 的长期、无瑕疵的编辑,弗朗西斯·莱夫科维茨。感谢你与我合作,一路指导我,使写作过程变得愉快(更不用说还能忍受了)。你对这本书质量的承诺使得它对每一位读者来说都变得更好。还要感谢所有在 Manning 与我一起参与书籍生产和推广的其他同事:迪尔德丽·希姆和梅洛迪·多拉布。这确实是一个团队的努力。

我还要感谢在本书开发过程中不同阶段抽出时间阅读我的手稿并提供宝贵反馈的审阅者:Al Pezewski、Alberto Ciarlanti、Amit Lamba、Birnou Sébarte、Daniel Posey、Daniel Bretoi、Dary Merckens、Dennis Reil、Didier Garcia、Edwin Kwok、Foster Haines、Francesco Strazzullo、Gabriel Wu、Jacob Romero、Joe Justesen、Jon Guenther、Julien Pohie、Kevin Norman D. Kapchan、Kimberly Winston-Jackson、Konstantinos Leimonis、Jahred Love、Lora Vardarova、Matteo Gildone、Miranda Whurr、Nate Clark、Pietro Maffi、Rance Shields、Ray Booysen、Richard Michaels、Sachin Singhi、Satej Kumar Sahu、Srihari Sridharan、Ubaldo Pescatore 和 Víctor M. Pérez。

特别感谢 Cypress.io 工程副总裁 Gleb Bahmutov (twitter.com/bahmutov) 和 James Sinclair (twitter.com/jrsinclair) 对本书和代码的深刻审阅。Gleb 和 James 是了不起的 JavaScript 拥护者,我强烈推荐您关注他们的工作。还要感谢我的兄弟 Carlos,他抽出时间阅读本书并提供了许多重要且建设性的反馈。

最后,我站在巨人的肩膀上。感谢 Kyle Simpson 在他的书系列《You Don’t Know JS》(github.com/getify/You-Dont-Know-JS)中用正确的方式教我们 JavaScript,还要感谢 Eric Elliot 对可组合软件的独特见解(leanpub.com/composingsoftware)。还要感谢 Brendan Eich,没有他 JavaScript 就不会存在。您让工作变得如此快乐!

关于这本书

JavaScript 疲劳感是真实存在的——不仅因为程序员有超过一百万个 NPM 包可供选择,而且他们也有同样多的在线资源可以用来获取信息和指导。本书《JavaScript 的乐趣》旨在综合 JavaScript 语言中最前沿和最激动人心的进展,帮助您超越基础,达到下一个层次。本书首先比较和对比了突出 JavaScript 原型链和动态链接的对象建模技术。然后,它聚焦于高阶函数以及如何利用 JavaScript 的函数式编程能力来构建真正可组合的软件——这一主题以某种方式贯穿了整本书。接下来,本书分别探讨了使用模块和元编程进行静态和动态关注点分离。本书以讨论有效处理数据流异步流的方式结束。我希望这四部分旅程能帮助将疲劳转化为快乐。

谁应该阅读这本书

《JavaScript 的乐趣》是为已经具备坚实基础的专业开发者编写的,他们希望将知识面扩展到语言的其它部分——包括他们可能见过或听说过但从未有机会或时间学习的内容。尽管本书针对中级到高级开发者,但它温和地介绍了最困难的一些主题,因此也适合对 JavaScript 充满热情并愿意补充知识的初学者。

本书是如何组织的

每一章都为本书的目标做出贡献,即展示如何从简单、可组合的部件构建软件。章节被分为四个部分,每个部分从不同的角度审视 JavaScript:对象、函数、代码和数据。本书的四个部分涵盖了九个章节,应按顺序阅读,因为每个章节和部分都是建立在之前的一个基础之上的。为了给您一个更全面的了解,以下是每个部分的总结。

第一部分:对象

第一部分阐明了 JavaScript 的对象系统。对类声明的语法支持为您提供了一个干净、简单的方法来在领域模型中建立继承关系,以便您可以利用适当的数据封装并创建高度内聚、结构良好的领域。尽管添加了许多添加面向类特性的进步(如类、私有属性和继承),JavaScript 远非面向类语言;实际上,恰恰相反。底层原型机制使得 JavaScript 的对象系统极具可塑性、多功能性,并且比其他语言更加动态。

本部分向您介绍了帮助您构建和实例化领域模型实体的技术,并理解每种方法的优缺点。在本部分中,您将了解到尽管类在 JavaScript 中有其位置,但它们并不是建模对象的唯一方式,而且肯定不能反映 JavaScript 的工作方式。要精通这门语言,您必须理解对象系统是如何工作的。

第二部分:函数

在第一部分定义了我们的对象形状之后,在第二部分中,我们通过使用纯函数和组合来连接它们。函数为《JavaScript 的乐趣》增添了乐趣。JavaScript 拥有强大的函数式编程能力,使得函数成为计算的主要单元。信不信由你,函数一直是 JavaScript 最强大的部分。语言对高阶函数的支持是编写模块化、可组合和可维护软件的关键。

在本部分,您将学习如何使用以不可变性、纯度和代数数据类型(ADT)为中心的函数式编程(FP)原则来推动您应用程序的业务逻辑。您将函数置于设计的前端,以利用 JavaScript 最强大的功能:高阶函数。到这一点,您将精通现代 JavaScript 习惯用法,并且您将窥视到一些新的语言特性,如管道和绑定运算符。

第三部分:代码

在 JavaScript 中,一个缺乏的领域是标准化的、官方的模块系统。多年来,许多尝试都试图解决这个问题,但没有任何一种方法能够在客户端/服务器平台上很好地工作。通过借鉴所有这些尝试中的最佳之处,JavaScript 引入了 ECMAScript 模块(ESM)系统,也称为 ES6 模块。这个静态模块系统允许 JavaScript 运行时执行许多优化,并使构建工具更智能,使它们能够内省和分析您的代码结构,以创建最优化分布。

模块不是唯一分离可重用代码片段的方法。此外,您还将了解 JavaScript 的标准 API,这些 API 可以动态地连接到您的数据,称为 ProxyReflect。这些 API 允许您像专业人士一样分离关注点,并动态引入跨切面策略,如跟踪、日志记录和性能计数器,而无需修改或污染您的主应用程序逻辑。

第四部分:数据

网络发展迅速。现代软件架构的现实是,如今一切都是分布式的,以 API 驱动。作为网络的语言,JavaScript 拥有强大的构造、API 和语法来满足您的异步和流式编程需求。在本部分,您将了解承诺、async/await、异步迭代器、异步生成器、可观察流等等。

关于代码

本书包含许多源代码示例,无论是编号列表还是与普通文本内联。在两种情况下,源代码都使用 fixed-width font like this 格式化,以清楚地将其与普通文本区分开来。

在解析代码时,以下是一些需要注意的事项。

为了检查变量或对象属性的内容,本书经常将变量或属性引用后跟一个内联注释(//)来显示其值。在这些情况下,(//)表示等于或返回。以下是一个示例:

proxy.foo; // 'bar'

当从构造函数的原型中引用一个属性时,例如在 Function.prototype.call 中,使用 # 符号代替原型引用,如下所示:Function#call.

所有代码示例都假设 JavaScript 处于严格模式 ("use strict";)。如果您不知道这个术语的含义,请访问 mng.bz/goaE

有时使用箭头函数来提高可读性,特别是对于旨在匿名或适合单行的函数。

为了使代码示例简短,本书经常省略不必要的细节,例如之前已显示的代码或读者无需太多努力就能理解的代码。在这些情况下,行内注释后面跟着省略号(//...)

在许多情况下,原始源代码已被重新格式化;我已添加换行并重新整理缩进,以适应书中的可用页面空间。

伴随本书的代码与实际编码相符,但旨在简单,避免额外的复杂性。它的唯一目的是教学,因此不打算在生产系统中直接使用。

任何使用非官方未来语言部分的代码,都使用 Babel 将其转换为标准 JavaScript。本书不涵盖 Babel,但附录 A 提供了一个简要介绍。

本书示例的源代码托管在公共 GitHub 仓库中,并可从出版社网站 www.manning.com/books/the-joy-of-javascript 下载。要运行每一章的列表,你有两种选择:下载并安装 Node.js v14 或更高版本以在本地运行代码,或者使用提供的最小 Docker 配置(配置了一个带有 Node.js 14 的虚拟环境,并包含所有必需的项目配置)。如果你不想或不能升级你的环境,Docker 很方便。Docker 沙盒确保所有代码都能在没有你的系统配置或甚至你使用的操作系统的情况下运行。你可以在 www.docker.com/products/docker-desktop 为你的特定操作系统注册并下载 Docker 引擎。

其他在线资源

关于作者

Atencio 路易斯·阿滕西奥 (@luijar) 是佛罗里达州劳德代尔堡 Citrix Systems 的主云工程师。他拥有计算机科学学士和硕士学位,现在全职使用 JavaScript 和 Java 开发和架构云网络应用程序。路易斯积极参与社区活动,并在全球会议和当地聚会中多次发表演讲。当他不在编码时,他写了一个专注于软件工程的开发者博客 (medium.com/@luijar)。他还为 PHPArch 和 DZone 撰写了多篇文章。路易斯还是《JavaScript 函数式编程》(Manning,2016 年)的作者,以及《RxJS 实战》(Manning,2017 年)的合著者。

关于封面插图

《JavaScript 之乐》封面的插图上写着“Groenlandais”,即格陵兰人。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757-1810)的作品集,名为《不同国家的服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔收藏中的丰富多样性生动地提醒我们,200 年前世界的城镇和地区在文化上有多么不同。他们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

自那以后,我们的着装方式已经改变,而当时区域间的多样性,如此丰富,现在已经逐渐消失。现在很难区分不同大陆的居民,更不用说不同的城镇、地区或国家了。也许我们用文化多样性换取了更加丰富多彩的个人生活——当然,是更加丰富多彩和快节奏的技术生活。

在难以区分一本计算机书和另一本计算机书的今天,曼宁通过基于两百年前丰富多样的区域生活,并由格拉塞·德·圣索沃尔的图画使之重现的书封面,庆祝了计算机行业的创新精神和主动性。

1 重新加载 JavaScript

本章涵盖

  • 评估日常编码的关键方面:对象、函数、代码和数据

  • 比较基于原型和委托的对象模型

  • 理解函数和类型的可组合性

  • 通过模块化和元编程实现关注点的清晰分离

  • 使用承诺和流编程创建单向数据管道

  • 介绍示例区块链应用程序

任何可以用 JavaScript 编写的应用程序,最终都会用 JavaScript 编写。

——杰夫·阿特伍德

现在是成为一名 JavaScript 开发者的大好时机。今天的 JavaScript 开发者可以编写几乎在任何地方运行的代码,从平板电脑、智能手机、桌面、机器人到云平台,再到物联网,如烤箱、冰箱、恒温器,甚至太空服!此外,我们还可以编程应用程序堆栈的所有层,从客户端和服务器,一直到数据库。世界尽在掌握之中。

尽管 JavaScript 非常受欢迎,但大多数开发者——甚至那些每天使用它的人——仍然难以决定如何编写新的应用程序。大多数时候,你或你的组织将有一个预先安排的选择框架,这为你提供了一个良好的起点。但即使在这些情况下,框架也只能带你走这么远;业务领域逻辑(以纯 JavaScript 编码完成)始终是等式中最困难、最不确定的部分——你不能简单地扔一个库来解决这个问题。在这些情况下,了解语言的语法、它提供的功能以及它支持的模式是非常重要的。

大多数通用编程语言通常都有一种推荐的方式来解决某一类问题。例如,Java 和类似的语言坚持使用类来表示你的业务模型。然而,JavaScript 却提供了许多可供考虑的选项:函数、对象字面量、创建 API,甚至类。因为 JavaScript 不仅将网络粘合在一起,而且在浏览器、移动设备,甚至在服务器上,它不断进化以满足不同开发者社区的需求,并应对这些(有时相反)环境提出的新挑战。这些挑战的例子包括管理来自用户点击按钮到执行底层文件 I/O 的异步数据,以及将复杂的业务逻辑分解成简单、可维护的模块,这些模块可以在客户端和服务器之间共享和使用。这些问题是独特的。

此外,当我们大规模使用 JavaScript 时,我们还需要关注如何从适当的抽象中实例化对象,以匹配我们的推理方式,将复杂算法分解成更简单、可重用的函数,并处理可能无限的数据流。所有这些任务都需要良好的设计技能,以便代码易于推理和维护。

这就是《JavaScript 的乐趣》这本书的用武之地。本书的目的是帮助你识别和使用语言的不同特性,以便你成为一个全面发展的 JavaScript 专业人士,了解专家开发者是如何使用 JavaScript 的。本书涵盖的主题将为你提供足够的信息,让你能够专注于并掌握今天和明天需要应对的挑战。本书还将为你准备使用一些可能在未来的几年内加入语言的新特性,包括管道和绑定操作符、抛出表达式和可观察对象。我的目标是让你成为一个更好的、更高效的程序员,这样你就可以用更少的资源做更多的事情。在阅读了几章之后——当然,在本书结束时——你应该能够写出比你现在写的更简洁、更优雅的代码。简而言之,你将从本书中收获一批新的工具和技术,用于更有效、更高效地编程,无论你是编写前端还是后端代码。

许多年前,JavaScript 开发并没有特别与“快乐”联系在一起。例如,管理深层对象层次结构或打包你的应用程序为能在不同环境中工作的模块是件麻烦事。实现跨平台、跨供应商兼容的代码的问题,加上工具支持的缺乏,让许多开发者对必须为生计编写或维护 JavaScript 代码的想法感到厌恶。但情况已经改变;事实上,正好相反。

幸运的是,我们现在正处于 JavaScript 开发的现代时期,这意味着几件事情:

  • 首先,我们可以通过一个名为 TC39 的明确、快速的任务组来密切监控 JavaScript 的稳步发展,该任务组每年都会推出新的语言特性,所有这些都在公开和透明中进行。这既带来了兴奋,也带来了焦虑,因为它不可避免地迫使你重新思考或放弃旧习惯,为即将到来的事物做好准备。并不是所有开发者都能很好地接受变化或保持开放的心态,但我希望你能。

  • 其次,复制粘贴编程的时代已经离我们远去,与之一起消失的还有将“Script”作为名称的耻辱感,仿佛在描述一种低级语言。这种观点在多年前是普遍存在的,但现在已经不再是这样了。JavaScript 生态系统是其中最充满活力和前沿的生态系统之一,如今,JavaScript 开发者已成为行业内薪酬最高的专业人士之一。

  • 最后,认为 JavaScript 开发者就是 jQuery、React、Angular、Vue、Svelte 或<命名你的框架>开发者的这种误解正在逐渐消失。你是一个 JavaScript 开发者——仅此而已。决定使用这些框架或库中的任何一个,是你的选择。通过使用良好的实践,并学习如何正确使用 JavaScript 提供的广泛工具集,纯 JavaScript 已经足够强大,让你的创造力自由发挥,并为任何类型的项目做出贡献。

为了让您了解 JavaScript 编程的现在和未来,本书在功能、反射和响应式等最流行的范式背景下探讨了这门语言,并描述了如何在每个范式内与关键编码元素一起工作。本书围绕解决大多数编程问题的四个主题组织:对象、函数、代码和数据。在这些主题中,您将学习如何使用适当的对象模型来设计您的业务领域,如何结合函数并将这些对象转换为所需的输出,如何有效地模块化您的应用程序,以及如何管理通过您的应用程序流动的数据,无论这些数据是同步的还是异步的。

如您从涵盖的主题范围中可以看到,这本书不是为 JavaScript 新手或初学者准备的。本书假设您已经具备一些专业经验,并对基础知识(如变量、循环、对象、函数、作用域和闭包)有很强的掌握,并且您已经经历过实现和配置 JavaScript 程序以及设置像 Babel 或 TypeScript 这样的转译器的练习。

只有当语言具有一致、稳定的特性和语法演变来解决这些问题时,现代 JavaScript 开发才成为可能。

1.1 JavaScript 的演变

多年来,JavaScript 的演变一直停滞不前。为了更清楚地说明问题,JavaScript 的规范语言 ECMAScript 自 2009 年 12 月以来在主要 JavaScript 引擎中一直停滞在 3.1 版本。这个版本后来被更名为更为人所知的 ECMAScript 5,或简称 ES5。我们等了近六年的痛苦时光——确切地说,从 2015 年 6 月开始——才看到语言有任何进展。在技术领域,六年是一个很长的时间;即使是自动取款机也会更快地更新。

在这段时间里,一个名为 TC39 的标准委员会(github.com/tc39),在诸如 OpenJS Foundation(openjsf.org)等机构的帮助下,诞生了 ECMAScript 2015,也称为 ES6。这次变革是自 JavaScript 诞生以来最大的飞跃。在这个版本中最重要的特性(es6-features.org)包括类、箭头函数、Promise、模板字符串、块级作用域变量、默认参数、元编程支持、解构赋值和模块。除了所有这些最需要的语言特性之外,最重要的变化是 JavaScript 的演变转向了每年一次的发布节奏,这使得语言能够快速迭代并更早地解决问题和不足。为了帮助您跟踪我们的位置,ES6 指的是 ECMAScript 2015,ES7 指的是 ECMAScript 2016,依此类推。这些增量发布对于平台供应商来说更容易采用和管理,比大型、单一的发布更容易。

TC39 由领先网络公司的成员组成,他们也将继续发展 ECMAScript,这是一种旨在标准化 JavaScript 的规范语言,在国际上被称为 ISO/IEC 16262,简称 ECMA262。(我知道有很多缩写,但我希望您已经抓住了要点。)TC39 也是一个平台,通过参与 IRC 频道和邮件列表,以及通过发现和帮助记录现有提案中的问题,让整个社区能够对语言的发展方向提供一些意见。如果您快速查看 TC39 的 GitHub 网站上的语言提案,您可以看到每个提案都要经过一系列阶段。这些阶段在 GitHub 上有很好的记录,所以我会在这里为您总结:

  • 第 0 阶段(草稿阶段)——这个阶段是非正式的,提案可以采取任何形式,因此任何人都可以为语言的进一步发展做出贡献。要添加您的意见,您必须是 TC39 的成员或在 ECMA 国际注册。如果您感兴趣,请随时在tc39.github.io/agreements/contributor注册。注册后,您可以通过 es-discuss 邮件列表提出您的想法。您还可以关注esdiscuss.org上的讨论。

  • 第 1 阶段(提案阶段)——在提出一个草稿之后,TC39 的成员必须支持您的添加,以将其推进到下一阶段。TC39 成员必须解释添加的理由,并描述它实现后的行为和外观。

  • 第 2 阶段(草案阶段)——提案得到完全规范,并被认为是实验性的。如果它达到这个阶段,委员会期望该特性最终会进入语言中。

  • 第 3 阶段(候选阶段)——在这个阶段,解决方案被认为是完整的,并已获得批准。此阶段之后的变更很少,通常仅在实施和大量使用后进行的关键发现时才会进行。您可以使用这个阶段的特性。在经过一段适当的部署期后,该新增特性可以安全地提升到第 4 阶段。

  • 第 4 阶段(完成阶段)——第 4 阶段是最终阶段。如果一个提案达到这个阶段,它就可以被纳入正式的 ECMAScript 标准规范。

这股健康的新提案流非常重要,这样 JavaScript 才能跟上当今应用开发实践的需求。除了讨论只有 JavaScript 才能实现的酷炫技术和范式之外,这本书还向您介绍了一些将在不久的将来永远改变我们编写 JavaScript 方式的提案,其中一些我在本章中会简要提及。以下是它们在本书其余部分出现的顺序:

在本章剩余的大部分内容中,我将介绍本书的四个主要主题,以便你理解整体情况并看到这些主题是如何相互关联的。我将从对象开始。

1.2 对象

对象不过是一个指向其他内存位置的内存引用。在核心上,JavaScript 是一种面向对象的语言,并且有多种方式在 JavaScript 中定义对象及其之间的关系。在这本书中,我们将探讨许多定义对象的方法。

对于一次性使用,例如,简单的对象字面量可能是最好且最快的方法。当需要将多个数据项组合起来传递给函数或从函数返回时,对象字面量就派上用场了。然而,当你需要创建具有相同形状的多个对象时,最好使用像Object.create这样的创建型 API 作为对象的工厂。你也可以结合new关键字使用自己的函数作为对象工厂。同样,类在近年来也变得流行,并且行为方式非常相似。但如果对象仅仅是其他对象的引用(链接),JavaScript 也允许你通过使用扩展运算符或像Object.assign这样的 API 将多个小对象合并成一个大的对象。

无论你采取哪种方法,你通常都需要共享数据和一组方法以避免代码重复。JavaScript 使用两种核心机制:属性解析机制和原型。这些机制是相互交织的。JavaScript 使用对象的内部原型引用作为在属性解析期间导航对象层次结构的路径,这发生在你查询对象的属性或调用方法时。假设你有图 1.1 所示的继承配置。

图片

图 1.1 一个简单的原型层次结构,其中Student对象继承自Person对象

在这里,从Student构造的对象继承自从Person构造的对象,这意味着所有Student实例都可以使用在Person中定义的数据和方法。这种关系被称为差异继承,因为随着对象图的变长,每个对象都会借用其上层的形状,并通过新的行为来区分自己(变得更加专业化)。

从图 1.1 可以看出,在Student对象上调用enroll方法会立即触发所需属性,因为它是局部于对象的,但调用getAddress则使用 JavaScript 的属性查找机制沿着原型层次结构向上遍历。这种方法的缺点是,当你的对象图变得非常复杂时,对基级对象的任何更改都会在所有派生对象中引起连锁反应,甚至在运行时也是如此。这种情况被称为原型污染,它是困扰大型 JavaScript 应用的严重问题。

由于原型是 JavaScript 中对象的内部实现细节,从调用者的角度来看,Student API 是一个具有四个属性的门面:firstNamelastNamegetAddressenroll。同样,我们可以通过组合描述PersonStudent的对象字面量来获得相同的形状。这种方法是对图 1.1 中配置的轻微扭曲,但非常重要。请看图 1.2。

图 1.2

图 1.2 中,两个具体对象(StudentPerson)合并成一个全新的对象,其中包含从组合对象分配的所有属性。尽管这里没有使用原型继承,但箭头的方向与图 1.1 中保持一致,以传达两种方法类似的心理模型。

在图 1.2 中,主要的不同之处在于我们用copy操作替换了原型引用,以表示我们实际上正在将StudentPerson的所有属性复制(实际上,是分配)到一个空对象中。因此,我们不是链接对象,而是创建了具有相同形状的独立对象。从调用者的角度来看,这些对象完全相同,你仍然可以受益于代码重用。在这种情况下,StudentPerson不是构造函数或工厂;它们是简单的混入(对象的一部分)。尽管这种方法在一定程度上可以避免意外的下游更改和原型污染,但缺点是每个新创建的对象都会在内存中添加一个新副本的属性,使得内存占用稍微大一些。

如你所知,计算机科学中的大多数事物都是权衡的结果。在这里,你以原型的高效内存使用方法为代价,换取了易于维护和重用性。在第二章和第三章中,我们将详细讨论这些以及其他模式,以及实现它们的代码。

如果对象是 JavaScript 的织物,那么函数就是用来将碎片缝合在一起的针线。JavaScript 函数是语言中最强大的部分,我们将在第 1.3 节中讨论。

1.3 函数

函数实现了您应用程序的业务逻辑,并驱动其状态(例如内存中所有对象内的数据)达到期望的结果。在根本层面上,你可以从两种方式来考虑函数:

  • 在过程式或命令式思维模式中,函数不过是一组共同执行的语句集合,用于组织和避免代码模式重复。面向对象范式继承了过程式编程,因此它也是一个修改对象的语句或命令序列。读者应熟悉这种方法。

  • 另一方面,你可以通过函数式编程(FP)的视角来考虑函数作为表达式。在这个世界的观点中,函数代表像乐高积木一样组装的不可变计算。

图 1.3 展示了一个使用过程式风格的、复杂度较低的假设程序的流程图。我们被训练成像计算机一样思考,并以这种方式绘制数据流。但正如你将在接下来的几个图中看到(图 1.4 和 1.5),当你使用正确的技术时,你可以将甚至是最复杂的程序简化为一系列流畅的表达式序列。

图 1.3:一个假设的程序描述,展示了if/else条件和循环

你应该编写可维护的、声明式的代码,让你的用户和队友能够理解,并让计算机解析和优化代码以供其自身理解。FP 在这方面有很大帮助。你可能听说过或读到 React 允许你“函数式地”构建 UI,或者 Redux 促进不可变状态管理。你有没有想过所有这些概念从何而来?你可以通过利用高阶函数来函数式地编码。在 JavaScript 中,一个函数是一个能够携带或链接到其词法作用域(也称为其闭包或背包)中的变量,并且你可以将其作为参数传递并作为回调返回的对象。这是语言的一个基本部分,自从 JavaScript 诞生以来就存在,具有无限的潜力来设计代码。

FP 将计算和数据表达为纯函数的组合。这些函数在每次调用时不会改变系统的状态,而是产生一个新的状态;它们是不可变的。使用 FP 编码将防止许多错误——你不需要费心去绕过的错误——并产生你可以多年后查看且更容易推理的代码。这个特性本身并不是 JavaScript 固有的,但它补充了使用 JavaScript 高阶函数的编码。

第四章将教会你足够的 FP 知识,以显著影响你日常编码的方式。它通过分解(将复杂问题分解成小而可管理的块)和组合(将这些部分重新组合在一起)的练习。从抽象的角度来看,编码思维模式如图 1.4 所示。

图片

图 1.4 FP 程序倾向于将大问题分解成更小的任务,并作为这些小任务的组合来解决。

不言而喻,你可能会倾向于创建许多更简单的函数,这些函数只对某些输入有效,并仅基于这个输入产生输出。当你能够将复杂问题分解成多个函数表示时,你会使用诸如 currying 和 composition 等技术将这些函数重新组合起来。你可以使用函数来抽象任何类型的逻辑——如条件执行、循环甚至错误处理——以创建类似于图 1.5 的信息管道。

图片

图 1.5 组合允许你构建函数管道,其中一个函数的输出成为下一个函数的输入。

我们很幸运使用一种能够给我们这种支持的编程语言。在语言的未来版本中,图 1.5 可能可以直接使用新的管道操作符(|>) 语法在 JavaScript 中编码,你将在第四章中了解到这一点:

const output = [input data] |> command1 |> command2 |> ... |> command6;

就像 UNIX shell 一样,这个操作符允许你将一个函数的输出“管道”到下一个函数的输入。假设你创建了一个split函数,用于通过空格将字符串拆分成一个数组,以及一个count函数,用于返回数组的长度。以下是一行有效的 JavaScript 代码:

'1 2 3' |> split |> count;  // 3

此外,图 1.3 和图 1.5 之间最明显的区别之一是条件逻辑(菱形形状)的去除。这是如何实现的?第五章介绍了一个称为代数数据类型(ADT)的概念。在我们的情况下,类型意味着具有某种形状的对象,而不是静态类型,这在其他语言社区中通常是指静态类型。鉴于关于 JavaScript 静态类型系统(如 TypeScript 和 Flow)有很多讨论,本书在附录 B 中花了一些时间讨论 JavaScript 中的静态类型。

ADTs 现在在许多编程语言和库中很常见,作为解决常见问题(如数据验证、错误处理、空值检查和 I/O)的优雅解决方案。事实上,JavaScript 自己的可选链、管道、承诺和 nullish 合并操作符,以及Array.prototype{map, flatMap} API(本书中均有讨论),都是受到这些代数类型灵感的启发。

之前,我们讨论了函数的组合。组合如何应用于自定义数据对象?你会了解到ArraymapflatMap方法在概念上适用于比数组更多的情况。它们是一组普遍接受的接口的一部分,你可以实现这些接口来使任何对象表现得像函数;我们将这种对象称为函子或单子。JavaScript 的Array具有类似函子的行为,而你一直在使用这种模式来转换数组,却没有意识到这一点。这种编码风格是本书涵盖的许多主题的基础,所以我会在这里花点时间来讲解它。

假设你已经声明了一些函子F(可能是Array)并给它提供了一些输入数据。函子以其特定的map实现而闻名,这样你就可以转换F内部封装的数据。图 1.6 展示了将函数按顺序(映射)应用到字符串上的逐步视图。

图 1-6

图 1.6 一个使用map转换其内部数据的函子对象(F)。函子是无副作用的,因为每次应用map都会产生F的新实例,而原始实例保持不变。

map是一个合约,可以应用于满足函子要求的任何F。当处理你想要在不同独立对象上一致应用的功能时,你可能倾向于使用Function.prototype.bind来设置接收函数调用的目标对象。随着 JavaScript 新绑定的操作符(::)语法的出现,这个过程变得更加简单。以下是一个虚构的例子:

const { map } = Functor;

(new F('aabbcc'))
    :: map(unique)
    :: map(join)
    :: map(toUpper); // 'ABC'

在许多用例中,函子都是有用的,所以让我们专注于验证中的条件逻辑问题。在第五章中,我们将从头开始实现自己的 ADT,以抽象if/else逻辑,例如“如果数据有效,执行 X;否则,执行 Y”。尽管数据流遵循图 1.5 的声明性食谱样范式,但内部将执行适当的分支逻辑,这取决于验证检查的结果。换句话说,如果验证检查的结果是成功的,回调函数将使用封装的输入执行;否则,它将被忽略。这两种代码流在图 1.7 中显示。

图 1-7

图 1.7 一个实现条件逻辑模型的互斥(OR)分支的 ADT。在分支的Yes(成功)一侧,所有映射操作都针对 ADT 内部包含的数据执行。否则,在No(失败)一侧,所有操作都将跳过。在两种情况下,数据都从开始到结束按顺序流动。

现在了解 ADT 将为你准备好语言的发展方向。早期的提案包括模式匹配等特性,这对于更函数式的 Alt-JS 编程语言如 Elm 来说是合适的。由于这个提议在写作时还处于早期草案形式,本书没有涵盖模式匹配。

现在您已经学习了面向对象和函数式技术来建模您的业务领域,第 1.4 节向您介绍了 JavaScript 的官方模块系统,称为 ECMAScript 模块(ESM),以帮助您以最佳方式组织和交付您的代码。

1.4 代码

第六章重点介绍如何使用 ESM 在您的应用程序中导入和导出代码。这个功能的主要目标是标准化代码在平台无关的方式下共享和使用。ESM 取代了 JavaScript 早期尝试的标准模块系统,如 AMD、UMD,甚至最终是 CommonJS。ESM 使用静态模块格式,构建/捆绑工具可以使用它来通过分析项目的静态布局及其依赖关系来应用大量的代码优化。这种格式对于减少通过网络发送到远程服务器或直接发送到浏览器的捆绑代码的大小特别有用。

JavaScript 可以使用其模块系统来加载从单个函数到大型单体类的大小不等的模块。当您使用像 exportimport 这样的经过良好建立、经过测试的关键字时,创建模块化和可重用的代码是直接的。

能够在不影响其他区域的情况下更改您的代码是模块化的基石。关注点的分离不仅适用于全局项目结构,也适用于运行中的代码——这是第七章的主题。

第七章通过利用 JavaScript 的元编程能力来讨论关注点的分离。使用 JavaScript 符号和 API,如 ProxyReflect 来启用反射和内省,您可以保持代码干净并专注于当前的问题。我们将使用这些 API 来创建我们自己的方法装饰器,以动态注入跨切面行为(如日志记录、跟踪和性能计数器),否则这些行为会弄乱您的业务逻辑,仅在需要时才这样做。

作为简单的例子,假设在调试和故障排除过程中,您想在应用程序的一些关键对象上打开任何属性访问(读取属性内容或调用方法)的日志记录。您希望在不修改任何一行代码的情况下完成此操作,并在完成后能够关闭它。通过放置正确的工具,您可以创建动态代理,装饰您选择的对象,并拦截或捕获对该对象的任何调用以编织新的功能。这个简单的例子在图 1.8 中展示。

图 1-8

图 1.8 动态地围绕某些目标对象创建代理对象。任何属性访问(foo)都会被代理对象捕获,因此您可以注入任何想要的代码。在这个简单的例子中,代理对象捕获了对 foo 的访问,并动态地将其返回值更改为 'bar'

现在想象一下,在你想优化的代码区域之前和之后使用性能计数器,或者使用可以在打印到屏幕或日志文件之前破坏或混淆对象中敏感字符串的全局安全策略。这些方法装饰器在大量用例中变得很有用。我们将在第七章中探讨如何做这些事情。

现在你已经有了对象、函数和代码的适当组织,剩下要做的就是管理流经它的数据。

1.5 数据

因为 JavaScript 作为网络(包括服务器和客户端)的关键语言,它需要处理各种形状和大小的数据。数据可以同步到达(来自本地内存)或异步到达(来自世界上的任何地方)。它可能一次性到达(单个对象),按顺序到达(数组),或者分块到达(流)。JavaScript 引擎在高级上依赖于一个具有回调队列和事件循环的架构,该架构可以连续以并发方式执行代码,而不会停止主线程。

毫无疑问,使用 Promise 作为抽象时间和数据局部性的模式,使得对异步代码进行推理变得更加简单。Promise是一个表现最终值的函数对象,其 API 与 ADT 有很多相似之处。在你的脑海中,你可以用map/flatMap替换thenPromise可以处于几种状态之一,如图 1.9 所示,其中最明显的是'fulfilled''rejected'

图片 1-9

图 1.9 新的Promise对象及其所有可能的状态

如你所见,Promise 还模拟了两个代码分支。这两个分支将你的逻辑向前推进,以执行你的业务逻辑达到期望的结果('fulfilled')或产生某种错误消息('rejected')。Promise 是可组合的类型,就像 ADT 和函数一样,你可以创建一系列的顺序逻辑链来攻击涉及异步数据源和复杂问题,并在过程中从适当的错误处理中受益(图 1.10)。

图片 1-10

图 1.10 Promise对象如何链接形成新的Promise对象。在这个过程中,相同的模式重复出现。成功和拒绝情况都会导致返回一个新的Promise对象。

在第八章中,我们讨论了 Promise 和async/await语法,这对于有更命令式或过程式背景的开发者来说很有吸引力,但与 Promise 的行为语义相同。使用async/await,你有编写看起来像暂停并等待执行命令的代码的视觉优势(例如,使用 HTTP 请求获取数据),但在幕后,一切都是 Promise 与底层事件循环架构的交互。在第八章中,我们还探讨了在 Promise 之上实现的主题,如异步迭代器和异步生成器。

Promises 模型单个异步值,但异步生成器允许你在一段时间内交付可能无限的数据序列。异步生成器是理解流的好心理模型,流是在一段时间内的事件序列,如图 1.11 所示。

图片

图 1.11 一个简单的流,有三个事件,由一些时间单位分隔

在浏览器和 Node.js 中都有实现标准的Stream API 来读写流。例如,Node.js 中的文件 I/O 流和浏览器中的 Fetch API。尽管如此,鉴于我们每天处理的数据类型的多样性,我们理想情况下应该使用单个 API 来抽象这些数据类型,并使用相同的计算模型。这种方法对框架和库的作者都有吸引力,因为它允许他们提供一个一致的接口。幸运的是,JavaScript 提出了Observable API 作为解决方案。

每当你看到Observable对象时,你应该从图 1.11 的角度去思考。JavaScript 将Observable内置到语言中,旨在标准化使用像 RxJS 这样的库所能做的令人惊叹的事情。有了可观察者,你可以订阅来自任何来源的事件:一个简单的函数、一个数组、一个事件发射器(如 DOM)、一个 HTTP 请求、promises、生成器,甚至是 WebSockets。这个想法是你可以将每份数据视为时间中的一个事件,并使用一组一致的运算符(可观察函数)来处理它。像函数、promises 和 ADTs 一样,可观察者是可组合的。你看到了一个模式吗?这个模式不是巧合;它是现代软件的编码模式,并且大多数语言都在越来越多地采用这种模式。因此,你也可以通过调用一系列可组合的运算符来创建链或管道,这些运算符在或转换通过可观察对象流动的数据,以同步或异步的方式处理数据,随着时间向前传播。图 1.12 显示了源可观察对象是如何通过某个运算符(可能是map?)进行转换,从而产生一个新的可观察对象。

图片

图 1.12 一个源Observable对象(第一个长箭头)将所有事件管道输入到一个运算符函数中并进行了转换。所有新值都是通过一个新的Observable发出的。

在第九章中,我们将创建自己的小运算符库。这些可管道的运算符本身就是高阶函数,而你提供给它们的函数则编码了你的特定业务逻辑。正如我在本章开头所说,并且你将在整本书中反复看到,高阶函数是 JavaScript 最强大的特性。

我希望这个概述听起来很吸引人。目前,我将讨论保持在较高水平,但每个随后的章节都将深入许多细节和代码。在这本书中,您不仅将接触到新的尖端技术,还将看到它们在更现代类型的应用程序中的实现。在我们深入所有这些巧妙的话题之前,让我向您介绍我们将在这本书中构建的示例应用。第 1.6 节有助于为所有您将看到的代码设定背景。

1.6 示例应用:区块链

根据我的经验,大多数编程书籍使用琐碎的例子,通常是数字或 foo/bar,来展示技术的特定功能。虽然这些例子因为假设零领域知识而有效,但缺点是您会感到困惑,不知道它们如何适应更复杂、更现实的应用。

如果您在过去几年里没有躲在石头下,您一定看到了很多关于区块链和加密货币的炒作,它们已经席卷了世界。许多分析师认为,区块链是未来几年需要学习的重要技术之一。如今,区块链无处不在且普遍存在,熟悉它们将为您的工具箱增添一项无价的技能——更不用说区块链本身很酷了。当然,教授这项技术并不简单,但这个应用故意保持小而简单,以便纳入本书。无需任何预热,也不需要任何背景知识。您自己的热情和动力,以及一些 JavaScript 背景,是唯一的先决条件。

在这本书中,我们将从头开始构建一个简单、直观的区块链协议的部分,以说明我们如何将现代 JavaScript 技术应用于实际问题。由于重点是教授 JavaScript,因此教授区块链纯粹是教学性质的,远非生产就绪的实现。尽管如此,您将接触到区块链世界的一些有趣技术,例如不可变性、哈希、挖矿和工作量证明。为了探索 JavaScript 功能的广泛范围,我们将找到创造性的方法,尽可能地将尽可能多的功能和技巧融入到这个小型、人为构建的示例应用中。

为了给您提供一些背景信息,区块链是一种由一系列记录(称为区块)组成的数据库,这些记录可以按某种时间顺序存储任何类型的数据。与传统数据库不同,区块是不可变的记录;您永远无法更改区块的内容,只能添加新的区块。

区块通过密码学方式相互链接。与链表中的指针或引用不同,没有连接一个区块到下一个区块的指针或引用。相反,每个区块包含一个密码学安全的哈希(如 SHA-256),新块的哈希值依赖于前一个块的哈希值,从而形成链。由于每个区块都是从前一个块的哈希值中哈希出来的,这个链天生具有防篡改的特性。即使操纵所有区块历史中任何一笔交易的任何一个属性,也会导致不同的哈希值,从而使整个链无效。这就是为什么区块链数据结构不仅被金融软件所青睐,还被用于安全的文档存储解决方案、在线投票和其他行业领域的主要原因之一。

计算区块哈希的过程将所有这些哈希值一直追溯到链中的第一个区块,即创世区块,或高度为 0 的区块。在现实生活中,区块链要复杂得多。然而,为了本书的目的,我们可以将其想象为一个顺序数据结构,其中每个区块存储了最近发生的交易。这种简化的结构在图 1.13 中展示。

图 1-13

图 1.13 展示了区块链的一个简单表示,其中每个区块存储了前一个区块的哈希值。这个哈希值用于计算当前区块的自身哈希值,从而有效地将这些区块连接成链。

如图中所示,每个区块由一个区块头组成,这是与每个区块关联的元数据。区块头的一部分是一个字段,previousHash,它存储了前一个区块的哈希值。除了元数据外,每个区块可能还包含一个有效载荷,这通常是一组交易。最新的区块包含了在创建时链上待处理的最新交易。

交易看起来像典型的银行交易,形式为“A 向 B 转了 X 金额的资金”,其中 A 和 B 是识别交易中各方的加密公钥。由于区块链包含了历史上发生的所有交易,在比特币等数字货币的世界里,它被称为公共账本。与你的银行账户不同,它是私密的,而比特币这样的区块链是公开的。你可能正在想,“我的银行负责验证每一笔交易,那么谁验证这些交易呢?”通过称为挖矿的过程,你将在第八章中了解到,你可以验证存储在区块中的所有交易,以及存储在历史中的所有交易。挖矿是一个资源密集型的过程。在挖矿进行时,交易被认为是待处理的。所有这些待处理的交易组合形成了下一个区块的数据有效载荷。当区块被添加到区块链中时,交易就完成了。

当支撑资源稀缺且难以寻找、提取或“挖掘”时,例如黄金、钻石或石油,加密货币就会获得货币价值。计算机可以使用它们强大的处理器或算术逻辑单元来执行高速数学运算,解决数学问题,这被称为工作量证明。我们将在第七章中查看proofOfWork函数的实现细节。

考虑一个交易是如何被添加并由区块链协议安全验证的例子。假设路克用 10 个比特币在安娜的咖啡馆买咖啡。用户通过他们的数字钱包被识别。支付过程触发逻辑以新待处理交易的形式转移资金。待处理交易集存储在区块中;然后该区块被挖掘并添加到链中以进行验证。如果所有验证检查都良好,则称交易已完成。矿工运行这个昂贵的证明工作计算的动力是,有挖掘奖励。这个协议在图 1.14 中总结。

图片 1-14

图 1.14 路克在安娜的咖啡店用他的数字钱包买咖啡。支付过程创建了一个新的待处理交易。在一段时间后,矿工们竞争验证这个交易。然后,包括路克的支付在内的所有发生的交易都被添加到链中的一个区块中,支付完成。

考虑到这些概念,图 1.15 展示了在这个序列中涉及的所有对象的简单图示。

图片 1-15

图 1.15 在我们的简单区块链应用程序中起作用的领域层的主要对象

随着我们进入章节,我们将详细说明所有这些对象以及将它们联系在一起的业务逻辑。具体来说,我们将实现代码以验证整个链,计算特定用户的比特币余额(钱包账户),执行一个简单的证明工作算法,并将一个区块挖掘到区块链中。

代码在哪里可以找到

本书代码在 GitHub 上免费提供(github.com/JoyOfJavaScript/joj)。该存储库包含已解决的区块链应用程序以及所有以单元测试形式呈现的代码列表。重要的是要提到,本书中的所有代码示例都假设严格模式("use strict";),这是编写 JavaScript 的推荐方式。

严格模式是有益的,因为它禁止了一些语言中的不良部分,例如使用臭名昭著的with语句,对一个变量调用delete(如果 JavaScript 也禁止对对象属性调用delete会更好),以及使用一些新保留的关键字(例如interface)。严格模式还将一些被动错误转换为完全的异常。

仓库还包括 Babel 配置文件,用于转换一些将改变你未来 JavaScript 编码的非标准提案。Babel 在章节中没有涉及,但你可以在附录 A 中了解更多关于它的信息。

要运行每一章的代码示例,你有两种选择:下载并安装 Node.js v14 或更高版本以在本地运行代码,或者使用一个最小的 Docker 配置,该配置配置了一个带有 Node.js 14 的虚拟环境,并包含所有必需的项目配置。如果你不想或不能升级你的环境,Docker 非常方便。Docker 沙盒确保所有代码都能在没有你的系统配置或甚至你使用的操作系统的情况下正常工作。从www.docker.com/products/docker-desktop注册并下载适用于你特定操作系统的 Docker 引擎非常简单。

访问 GitHub 项目的 README.md 文件,获取有关如何开始的说明。

这本书对其中一些思想和新概念的 10,000 英尺高的介绍只是触及了表面。到结束时,你会发现 JavaScript 拥有你需要的所有表达力,让你在编写精简且干净的代码时,让你的无限创造力和想象力自由驰骋。

因此,欢迎加入我们。我相信,当你开始这段是 JavaScript 乐趣的旅程时,你会觉得这本书既有趣又引人入胜!

摘要

  • JavaScript 有两个重要的特性,使其与其他语言区分开来:基于原型的对象模型和高级函数。你可以以系统化的方式结合这些特性,以构建强大而优雅的代码。

  • TC39,ECMAScript 的标准机构,致力于每年向 JavaScript 发布新功能。现在,我们有一个社区驱动的流程来根据 ECMA 标准发展 JavaScript,以及快速修复任何缺陷。在这本书中,你将学习如何使用绑定和管道操作符,使用可观测者进行编码,以及使用许多其他新功能,所有这些功能都源于这个过程。

  • JavaScript 的动态对象模型使其能够利用动态对象扩展,通过混合组合而不是原型继承来使用混合组合。

  • 抽象应该通过将想法简化到其基本概念来使代码更加具体或精细。ADTs(抽象数据类型)可以优化代码分支、错误处理、空值检查和其他编程任务。

  • 函数式编程(FP)使用诸如函数组合等技术,使你的代码更加精简和声明式。

  • JavaScript 是少数几种从一开始就支持异步编程的语言之一。它随着async/await的出现而进行了改进,这完全抽象了代码的异步性质。

  • 可观测者使用流编程模型,为任何类型的数据源——同步的、异步的、单值或无限的——提供一致的全景视图。

第一部分:对象

探索是什么让 JavaScript 编程变得愉悦的旅程,始于其对象系统。对象是你所做的一切以及你将能够用该语言做到的一切的基础。对象设计用于理解你的领域以及所有组成部分如何相互关联并传递消息。然而,尽管对象系统非常强大,但确定要使用哪些模式可能会相当有压力——这项任务似乎每个人都在以略微不同的方式完成。我们可以根据两组模式进行分类:原型和委托。本部分为您概述了每种模式及其优势。

在第二章中,我们首先回顾 JavaScript 的原型机制。你用 JavaScript 做的任何有意义的事情都需要你理解原型层次结构是如何工作的以及 JavaScript 运行时是如何解析属性的。原型继承通过使用Object API、构造函数和类来构建对象,使得一些优秀的面向对象技术得以实现。

原型继承可能会导致属于同一层次的对象之间紧密耦合,这往往会随着时间的推移而变得脆弱。第三章将教授你一些对抗这种情况的技术。这些技术本质上是组合性的,明确地标示对象链接,使你的对象模型更容易维护。在本章中,你将了解诸如 OLOO(Objects Linked to Other Objects,与其他对象链接的对象)和 mixins 等技术。

为了教学目的,在本部分中,我们将结合使用这些技术来构建我们的区块链领域模型的基础框架。

2 基于继承的对象建模

本章涵盖

  • 原型继承、构造函数和类

  • JavaScript 的属性解析机制

  • “原型继承”的矛盾

  • JavaScript 中类的优缺点

“仅仅在前面加上‘原型’来区分 JavaScript 中实际上几乎相反的行为,却留下了近二十年的泥潭般的混乱。”

——凯尔·辛普森

除了几个原始类型外,JavaScript 中的所有内容都是对象。然而,对于我们日常处理的东西,对象仍然是语言中最令人畏惧、最难正确使用的一部分。我听到的最常见问题是“我应该如何编写原型链来关联 X、Y 和 Z?”你读过的每一篇文章或书籍都略有不同,而且出于某种原因,即使是经验丰富的开发者有时也需要通过搜索引擎重新学习这个过程。原因有两个:一方面,需要大量的样板代码,另一方面,我们对继承和原型这两个术语感到困惑。

继承是一种强大的代码复用模式,我们将在本书中利用它,但我们的原型理解不应该仅限于创建父子关系。继承只是原型众多应用之一(而且确实非常重要),但原型可以做得更多。由于 JavaScript 开发者中很大一部分来自面向类的语言,为了确保他们能够无缝过渡,ECMAScript 2015 决定将类作为语言的第一等公民。对类的支持演变成了一堆支持私有和静态属性的新功能。JavaScript 的历史再次被试图让它看起来像 Java 的尝试所玷污。所有这些语法并不是所有 JavaScript 纯粹主义者都欢迎的,因为它掩盖了 JavaScript 伟大对象系统的底层机制。

不管是好是坏,大量的领域建模已经转向使用类的一站式设置,而不是直接原型配置的不必要样板代码。然而,了解 JavaScript 的对象系统是如何工作的是非常重要的。在本章中,我们将讨论两种使用原型功能来建模继承关系的模式:构造函数和 ECMAScript 2015 类。这两种模式都通过 JavaScript 的内部原型引用和属性解析机制提供了共享数据和行为的优势。

让我们从回顾你很可能已经看过很多次的基于基本原型继承的配置开始。

2.1 回顾原型继承

JavaScript 从名为 Self 的语言中借用了原型。原型机制是 JavaScript 能够成为面向对象语言的重要组成部分;没有它,你将无法向复杂网络中更高层次的对象发送消息(即继承)。

在本节中,我们将回顾设置基本原型链所需的代码,并为学习 JavaScript 的属性解析机制打下基础,这是 JavaScript 访问对象的核心机制。

根据你的经验,你可能已经尝试过对象及其原型,所以我会直接进入一些代码。我们将首先查看的 API 是建立类似父子关系的

 Object.create(proto[,propertiesObject]);

此 API 创建了一个链接到原型的新对象,并可选择地附带一组新的属性定义。现在,我们将只关注第一个参数(proto),如下面的列表所示。

列表 2.1 使用Object.create从原型创建对象

const proto = { 
   sender: 'luis@tjoj.com',
};
const child = Object.create(proto);       ❶
child.recipient = 'luke@tjoj.com';

child.sender;     // 'luis@tjoj.com'
child.recipient;  // 'luke@tjoj.com'

❶ 使用Object.create根据父对象(proto)配置一个新的子对象。内部,子对象有一个引用指向父对象,以便访问其任何属性。另一种方法是调用Object.setPrototypeOf(child, proto) API。

使用此代码,可以从子对象访问父对象(proto)的任何属性。这里没有发生任何有趣的事情,让我们做一些更有意义的事情,并模拟我们的第一个区块链概念,一笔交易。

一笔交易代表了一定商品(如货币)从发送者到接收者的交换。在区块链世界中,交易看起来几乎与传统银行系统一模一样。首先,我们将把senderrecipient设置为简单的电子邮件地址,将funds设为一定数量的虚拟比特币作为我们的货币形式。在我们的例子中,Luke 使用他的数字钱包中的比特币从 Ana 的咖啡馆购买咖啡,如图 2.1 所示。

图 2.1 一笔交易对象捕捉了发送者(Luke)向接收者(Ana)发送比特币以购买咖啡的详细信息。

让我们开始构建这个例子。列表 2.2 使用Object.create在两个对象moneyTransactiontransaction之间建立原型配置,并添加了对funds的支持。在现实世界中,你会发现一些对这个设置的轻微变化,但总体思想始终相同。

列表 2.2 通过基本原型设置链接的交易对象

const transaction = {                                           ❶
   sender: 'luis@tjoj.com',
   recipient: 'luke@tjoj.com'
};

const moneyTransaction = Object.create(transaction);            ❷
moneyTransaction.funds = 0.0;
moneyTransaction.addFunds = function addFunds(funds = 0) {      ❸
  this.funds += Number(funds);
}

moneyTransaction.addFunds(10.0);
moneyTransaction.funds; // 10.0

❶ 从中派生其他对象的原型对象——一个普通对象,而不是某种抽象蓝图

❷ 从原型创建一个派生对象

❸ 向子对象添加新方法。在声明中重复函数名称有助于构建更丰富的堆栈跟踪。

让我们检查我们的假设在下一个列表中是否仍然有效。

列表 2.3 检查新的交易对象

Object.getPrototypeOf(moneyTransaction) === transaction; // true     ❶
moneyTransaction.sender;  // 'luis@tjoj.com'                         ❷
moneyTransaction.funds;   // 10       

❶ 检查原型链接是否已建立

❷ 验证从子对象中可以访问继承属性

让我们进一步解析列表 2.2。原型对象(transaction)实际上是一个任意的对象字面量,我们将用它来分组常见的属性。正如你所看到的,原型是可以在任何时间(甚至是在运行时)进行操作的对象,而不是在形成继承关联时凭空创建的。这个事实很重要,我们将在第 2.3 节讨论它为什么重要。

这是关于这段代码的另一种看法,使用了 Object.create 的第二个参数,它接收一个数据描述符的对象:

const moneyTransaction = Object.create(transaction, {
  funds: {    
    value: 0.0,
    enumerable: true, 
    writable: true,
    configurable: false    
  }
});

第二个参数让我们能够精细控制这个新创建的对象属性的行为:

  • 可枚举的 —— 控制属性是否可以被枚举或查看(例如,当你将对象传递给 console.log 时,使用 Object.keys 枚举键),或者是否被 Object.assign 所看到(我们将在第三章中再次讨论这个话题)。

  • 可配置的 —— 控制是否允许使用 delete 关键字删除对象的属性,或者是否可以重新配置字段的属性描述符。删除属性会改变对象的形状,并使你的代码更加不可预测,这就是为什么我更喜欢使用这个属性的默认值(false)或者从数据描述符中省略它。

  • 可写的 —— 控制你是否可以重新分配这个字段的值,从而使其赋值不可变。

当你直接在对象上使用点符号创建属性,就像列表 2.2 中那样,这个行为等同于使用所有设置都设置为 true 的描述符定义一个属性。通常,大多数开发者不会去麻烦使用数据描述符,但当你编写供他人使用的库和框架时,它们可能会很有用,比如隐藏某些字段或使某些字段不可变。数据描述符有助于强制执行某些设计原则,并清楚地传达你的 API 的工作方式。我们将在第四章中回到不可变性和它为什么重要的这个问题。

正如你所看到的,Object.create 提供了一种简单、优雅的方式来从共享原型创建对象,并建立了适当的继承链接以解决属性查找。

2.1.1 属性解析过程

不讨论 JavaScript 的原型机制,不讨论其属性查找机制,这是在 JavaScript 中实现面向对象模式背后的最重要的概念。根据 ECMAScript 规范,一个称为 [[Prototype]] 的内部引用(在对象中通过 __proto__ 属性访问)由 Object.create 配置,并将 moneyTransactiontransaction 链接起来,如图 2.2 所示。这就是我们能够正确解析 moneyTransaction.sender'luis@tjoj.com' 的唯一原因,如图 2.2 所示。

图 2.2 内部引用[[Prototype]]以单向方式将一个对象(moneyTransaction)链接到另一个对象(transaction),最终结束于Object.prototype

此图通过原型链指出了对象之间的关系,这指导 JavaScript 引擎通过特定的键来查找属性。我将更详细地解释这个过程。当请求成员字段时,JavaScript 引擎首先在调用对象中查找该属性。如果 JavaScript 在那里找不到该属性,它会在[[Prototype]]中查找。属性sendermoneyTransaction中未声明,但它仍然成功解析。为什么?在moneyTransaction中的任何属性访问或方法调用都会沿着原型链向上传递,继续到transaction,直到找到该属性并返回它。但如果找不到呢?查找过程将继续进行,最终终止在空对象字面量{}(也称为Object.prototype)。如果解析失败,值属性的运算结果是undefined,函数值属性的运算结果是TypeError

在幕后,你可以将隐藏的__proto__属性视为允许你遍历链的桥梁。当我们使用原型来实现继承,这是最常见的场景时,我们说属性解析“向上”移动到继承链。

你不应该直接在你的应用程序中使用__proto__,因为它是为了让 JavaScript 引擎内部使用而设计的。假设在用户代码中暴露出来,它可能看起来是这样的:

const moneyTransaction = {
  __proto__: transaction,
  funds: 0.0,
  addFunds: function addFunds(funds = 0) {
    this.funds += Number(funds);
    return this;
  }
}

注意:__proto__的使用多年来一直是激烈争论的主题,并且目前正被弃用。它在 ECMAScript 2015 中被标准化为仅作为遗留功能,以便网络浏览器和其他 JavaScript 运行时能够保持兼容性。请不要直接使用它(尽管你可能会在书中看到用于教学目的的使用),因为它可能在一段时间后停止工作。如果你需要操作这个字段,推荐使用的 API 是Object.getPrototypeOfObject.setPrototypeOf。你还可以直接在对象上调用Object#isPrototypeOf方法。

关于符号的使用,当从构造函数的原型中引用一个属性时,例如Object.prototype.isPrototypeOf,在这本书中,我们使用#符号代替:Object#isPrototypeOf

图 2.2 看起来很简单,但处理长而复杂的对象图时可能会变得复杂。我不会深入探讨这些特定的用例,以保持讨论集中在对象构造技术上,但你可以在以下侧边栏中找到更多相关信息。

理解 JavaScript 对象的特性

在这本书中,我们将使用良好形成的简单对象层次结构,因此我们不会详细讨论原型链断裂或对象中的属性被覆盖时可能出错的事情。JavaScript 对象的内部和外部可能很容易占据整本书。事实上,Kyle Simpson 的一个令人惊叹的书系《你不知道的 JavaScript》(github.com/getify/You-Dont-Know-JS/tree/1st-ed)详细描述了这个例子。该系列深入探讨了操作对象链的细微差别,提供了许多关于行为委托(我们将在第三章学习)的好建议和最佳实践,并揭穿了对象创建和原型机制背后的神话。这个系列对我来说是一个巨大的灵感,并且极大地影响了今天我编写 JavaScript 的方式。它应该在每个 JavaScript 开发者的书架上。

现在我们已经回顾了 JavaScript 中的基本原型设置,让我们讨论为什么使用重载术语继承来描述 JavaScript 的面向对象模型在本质上是不准确的。

2.1.2 差分继承

差分继承,其中派生对象保留对其派生对象的引用,在原型语言中很常见。在 JavaScript 中,差分继承被称为 [[Prototype]]。相比之下,在基于类的继承中,派生对象从其自身类以及所有派生类复制所有状态和行为。关键的区别是复制与链接。

尽管这个术语听起来有点令人畏惧,但差分继承是一个简单的概念,指的是扩展行为如何将派生对象与其链接的通用父对象区分开来。如果你把 JavaScript 对象想象成一个动态的属性包,那么差异化意味着向另一个包添加属性并将两个包链接起来。正如你在图 2.2 中所看到的,因为原型解析机制从调用对象单向流向其链接的对象(等等),任何新派生的对象都旨在通过新行为与父对象区分开来。新行为包括添加新属性或甚至覆盖来自链接对象的现有属性(称为覆盖)。我在这本书中不涉及覆盖,但你可以在 mng.bz/OEmR 上了解更多信息。

考虑另一种场景,其中我们将通用的 transaction 对象扩展以定义 hashTransaction。此对象通过添加一个用于计算其自身哈希值的函数(calculateHash)来与父对象区分开来。从高层次来看,哈希化是使用对象的状态生成一个唯一的字符串值,就像 JSON.stringify 所做的那样,但我们只需要针对值,而不是整个对象的结构。此哈希值在工业界有许多用途,例如从哈希表或字典中快速插入/检索,以及数据完整性检查。

在区块链的世界里,哈希通常用作transactionId,以唯一标识发生的一定交易。为了简单起见,我们将在下一个列表中从简单的(不安全的)哈希函数开始。

列表 2.4 使用基本的哈希计算创建hashTransaction

const hashTransaction = Object.create(transaction);

hashTransaction.calculateHash = function calculateHash() {      ❶
    const data = [this.sender, this.recipient].join('');        ❷
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;                                             ❸
}

hashTransaction.calculateHash(); // 237572532174000400

❶ 添加一个计算自身哈希的方法

❷ 成为哈希算法输入的属性

❸ 使用指数运算符平方哈希值

采用另一种方法,你也可以使用Object.setPrototypeOf来区分子对象。假设你想要从hashTransaction扩展moneyTransaction。所有相同的机制都适用:

const moneyTransaction = Object.setPrototypeOf({}, hashTransaction);
moneyTransaction.funds = 0.0;
moneyTransaction.addFunds = function addFunds(funds = 0) { 
   this.funds += Number(funds); 
};
moneyTransaction.addFunds(10);
moneyTransaction.calculateHash(); // 237572532174000400
moneyTransaction.funds;     // 10
moneyTransaction.sender;    // 'luis@tjoj.com'
moneyTransaction.recipient; // 'luke@tjoj.com'

现在我们已经回顾了一些涉及简单对象字面量的例子,创建具有不同数据的新的交易就更有用了。第 2.2 节跳入使用构造函数。

2.2 构造函数

构造函数(也称为对象构造函数模式)多年来一直是 JavaScript 中构建对象的方法。尽管对象字面量提供了一种简洁的方式来定义单个对象,但当需要创建数百个形状相同的对象时,这种方法并不适用。在这种情况下,构造函数充当一个模板,用于初始化填充了不同数据的对象。你可能熟悉这个模式,但本节讨论了一些你可能之前未曾遇到的高级技术。

2.2.1 函数作为模板

使用函数而不是直接的对象字面量来构建对象可以让你的模型更好地进化,因为你对对象的构建有更多的控制。函数允许你向调用者导出一个外观,其中更改不一定需要传播到调用代码。对象初始化的细节,例如强制执行任何先决条件,都适当地隐藏在构造函数内部。

例如,以下代码片段从不透露关于HashTransaction形状的任何不必要的细节,或者可能在实例化过程中发生的任何操作。封装始终是一个好的选择:

const tx = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');

这个基本的设计决策使你的代码更不易破碎且更易于维护,因此在大多数情况下,使用函数来构建对象是首选的方法。

按照惯例,构造函数的名称首字母大写,以表示一种穷人的类。让我们从列表 2.4 中的用例开始,使用构造函数进行重构(列表 2.5)。这里有几个选择。将所有属性添加到这个新对象中,以便对象从另一个对象继承属性的最简单方法是;不需要依赖于原型链。因为你的对象是动态创建的(当函数被调用时),你需要将这些属性(装满袋子)打包到每个构造函数调用中的单个对象上下文(this)中。

列表 2.5 使用构造函数模式构建和链接对象

function Transaction(sender, recipient) {                            ❶
    this.sender = sender;
    this.recipient = recipient;
}

function HashTransaction(sender, recipient) {
    if (!new.target) {                                               ❷
        return new HashTransaction(sender, recipient);
    }
    Transaction.call(this, sender, recipient);                       ❸

    this.calculateHash = function calculateHash() {                  ❹
       //...
    }
}

const tx = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');    ❺
tx.calculateHash();  // 237572532174000400
tx.sender;           // 'luis@tjoj.com'

❶ 基础构造函数

❷ 检测子对象实例化时是否省略了新关键字,并修复调用。这一行帮助那些忘记写 new 的开发者。我将在第 2.2.2 节中回到这个话题。

❸ 调用父构造函数以将任何父成员属性初始化到当前对象上下文中

❹ 为创建的每个实例添加一个新的 calculateHash 方法

❺ 使用新关键字实例化新对象。新关键字是必需的,以便将新创建的对象作为 this 上下文传递。

通过使用函数,你可以轻松地实例化任意数量的 HashTransaction 对象,它们都包含在 Transaction 中定义的属性。需要注意的是,你需要使用 new 关键字调用函数,以确保上下文(this)被正确初始化。

这些对象不共享任何属性的引用。你直接在 HashTransaction 的上下文(this 变量)上定义了 calculateHash,例如,为 HashTransaction 的每个实例添加一个新的 calculateHash 属性。换句话说,如果你创建了两个实例,你会看到相同方法的两个副本:

const tx1 = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');
const tx2 = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');

tx1.calculateHash === tx2.calculateHash; // false

为了解决这个问题,你需要配置原型链接的设置方式,以便在创建新对象时。

2.2.2 通过构造函数和原型共享属性

使用构造函数的一个有趣方面是,对于每个构造函数 F,JavaScript 会自动创建对象 F.prototype

HashTransaction.prototype; // HashTransaction {}

此对象被添加以促进代码共享和重用,尤其是在方法方面,其中不需要定义多个副本。因此,更优的方法是将 calculateHash 添加到 HashTransactionprototype 中,使其在所有 HashTransaction 实例之间共享,例如:

HashTransaction.prototype.calculateHash = function calculateHash() {
   //...
}

通过这种轻微的变化,这两个属性指向相同的内存位置:

tx1.calculateHash === tx2.calculateHash; // true

同样适用于添加到 Transaction.prototype 的任何方法。假设你添加了一个名为 displayTransaction 的新方法,你希望所有对象都共享:

Transaction.prototype.displayTransaction = function displayTransaction() {
    return `Transaction from ${this.sender} to ${this.recipient}`;
} 

当代码设置好时,调用它会产生一个 TypeError,表示 JavaScript 引擎尝试解析该属性,但无法解析:

TypeError: tx.displayTransaction is not a function

这个错误是预期的,因为你没有配置原型链:

Transaction.prototype.isPrototypeOf(tx); // false

你可以轻松地解决这个问题。和之前一样,你可以使用 Object.create。下面的列表显示了完整的原型配置。

列表 2.6 使用构造函数模式配置原型链

function Transaction(sender, recipient) {  
    this.sender = sender;
    this.recipient = recipient;
}
Transaction.prototype.displayTransaction = function displayTransaction() {
    return `Transaction from ${this.sender} to ${this.recipient}`;
} 

function HashTransaction(sender, recipient) {
    if (!new.target) { 
        return new HashTransaction(sender, recipient);
    }
    Transaction.call(this, sender, recipient);      
}

HashTransaction.prototype.calculateHash = function calculateHash() { 
    const data = [this.sender, this.recipient].join(''); 
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;
}

HashTransaction.prototype = Object.create(Transaction.prototype);    ❶
HashTransaction.prototype.constructor = HashTransaction;             ❷

const tx = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');
const tx2 = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');
Transaction.prototype.isPrototypeOf(tx); // true
tx.calculateHash === tx2.calculateHash;  // true
tx.displayTransaction === tx2.displayTransaction; // true
tx.__proto__.__proto__; 
// Transaction { displayTransaction: [Function: displayTransaction] }

❶ 为查找机制链接原型,以便在需要从 Transaction.prototype 解析属性时工作

❷ 修复或设置构造函数值。没有这一行,tx 将是一个 Transaction 对象或从 Transaction 构造而来。

从调用者的角度来看,无论你是将所有属性打包成一个单独的对象,还是使用原型解析,这两段代码的行为和调用方式都是完全相同的。在内部,内存中的对象布局是不同的,但是强大的、高效的 JavaScript 引擎将其抽象化。图 2.3 展示了列表 2.6 的内部工作原理。

图片

图 2.3:根据列表 2.6 实例化 tx,以及所有原型链接和构造函数引用的完整图景。在 JavaScript 中,构造函数函数在用 new 关键字实例化时会自动获得 prototype 属性的引用。用 [[Prototype]] 标注的导航表示对象之间的内部 __proto__ 链接。

虽然构造函数函数比传统的对象字面量更复杂、更强大,但使用这种模式的缺点是它会泄露 JavaScript 原型机制的大量内部结构,因为你需要处理原型配置的细节。如果你没有完美地写出所有内容,你就有可能出现奇怪和意外的行为。

__proto__prototype 的区别

阅读示例代码,你已经遇到了对两个属性的引用:__proto__prototype。正如我之前所说的,__proto__ 是不被推荐的,但 prototype 不是。如果你想知道它们之间的区别,__proto__ 是在查找链中用于解析方法的对象,而 prototype 是在用 new 创建对象时用于构建 __proto__ 的对象。

如前所述,始终记得用 new 调用构造函数。许多开发者会忘记这一点。再次强调,使用 new 关键字与函数隐式地设置新创建的对象中 this 的指向。这项任务一直是个麻烦,因为忘记写它会改变结果对象的作用域,因此我们需要包含我之前突出显示的防御性代码:

if (!new.target) {
   return new HashTransaction(sender, recipient);
}

老手可能还记得,在 ECMAScript 2015 之前的工作区(workaround)是将以下列表中的控制代码插入到每个构造函数体中。

列表 2.7:在 ECMAScript 2015 之前检查正确构造函数调用的方法

if (!(this instanceof HashTransaction)) { 
     return new HashTransaction(sender, recipient);
}

让我们看看如果你没有这样做会发生什么。假设你没有写前面的控制代码,而是让它保持原样:

function HashTransaction(sender, recipient) {
   Transaction.call(this, sender, recipient);
}

然后你尝试创建一个新的实例:

const instance = HashTransaction('luis@tjoj.com', 'luke@tjoj.com');

哎呀!这段代码抛出了一个 TypeError,因为隐式的 this 上下文是 undefined。错误信息暗示了设置 undefined 的成员属性,但没有指出实际的用户错误:

TypeError: Cannot set property 'sender' of undefined

这里,开发者忘记在函数调用前写上 new

const instance = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com');

现在我们来看一个不同、更微妙的陷阱。假设我们想让事务也有一个描述性的名称:

function HashTransaction(name, sender, recipient) {
    Transaction.call(this, sender, recipient);
    this.name = name;
}

HashTransaction.prototype = Object.create(Transaction);

现在创建一个新的实例:

  const instance = new HashTransaction(
    'Coffee purchase', 
    'luis@tjoj.com', 
    'luke@tjoj.com'
  );

咔嚓!又发生了一个类型错误。这次,错误甚至更加隐晦,并且并非在所有 JavaScript 引擎中都会发生:

  TypeError: Cannot assign to read only property 'name' of object 
  '[object Object]'

你能找到问题吗?别担心,我会让你节省时间。问题是忘记正确链接原型。代码应该是这样的

HashTransaction.prototype = Object.create(Transaction.prototype);

再次,每次手动编写这段代码都是痛苦的,导致不同的行为,这些行为很容易逃出你的注意或任何你使用的 linting 工具。

减少样板代码

使用 Node.js 的util库,你可以减少一些样板代码,从而避免犯一些错误。而不是明确地编写原型增强语句

HashTransaction.prototype = Object.create(Transaction.prototype);

你可以使用util.inherits来完成同样的任务,避免你再次犯同样的错误:

require('util').inherits(HashTransaction, Transaction);

然而,如果你阅读文档,你会发现 Node.js 社区不鼓励这种做法,而是倾向于使用classextends,表明使用原型调用inherits是“语义上不兼容的”。你不说!早些时候,我简要地提到了原型和类不兼容的事实。第 2.3 节将详细评估这个主题。

使用构造函数和new来创建新实例的想法,就是我们今天所知道的伪类模型。随着 ECMAScript 2015 的出现,这个模型已经被一个更熟悉、更精简的面向类模型所取代,同时也解决了所需的样板代码量。实际上,使用类时,在调用构造函数时忘记写new会生成一个清晰的错误,就像这个Transaction类的例子一样:

const tx = Transaction(...);

TypeError: Class constructor Transaction cannot be invoked without 'new'

第 2.3 节探讨了类的好处,以及伴随它们的一些新提议。

2.3 基于类的继承

在本节中,我们将继续讨论类和原型二分法。接下来,我们将探讨类的心理模型如何使表示继承层次结构更简单,以及提供清理和平滑构造函数复杂样板代码的语法优势。

我们被训练去认为,唯一的形式的面向对象是通过类,但这并不是事实。面向类并不等同于面向对象,JavaScript 在类出现之前就已经是一种面向对象的语言。

类的引入是为了解决一个特定的问题,即为了使基于继承的领域建模更容易,特别是对于来自像 Java、C#和 TypeScript 这样的面向类语言的开发者来说。所有原型引用的冗余和样板代码都必须被移除。理想情况下,TC39 应该以一种保持与 JavaScript 起源兼容的方式来做这件事,但社区强烈要求熟悉的类样式的结构。

在像 Java 这样的语言中,类是计算的基本单位。每个对象都从某个类派生,这个类提供了在实例化过程中填充数据并在内存中分配的模板。在这段时间里,一个类的所有成员属性,以及任何继承的属性,都会被复制到一个新对象中,并在构造时填充。

然而,如你在第 2.2 节所学,JavaScript 中的原型工作方式不同。原型是良好形成的、具体的对象,它们在声明时(对象字面量)或作为调用函数(构造函数)的副产品被创建,而不是通过涉及某些无生命的蓝图或模板的单独实例化过程。实际上,你可以在将其添加到任何继承链之前,像使用任何其他对象一样使用原型对象。

记住,区分 JavaScript 与像 Java 这样的语言的关键因素是 JavaScript 通过链接而不是复制从链中的更高层对象。在第三章中,我们将讨论高度依赖链接和委派的模式。

在类方面,继承是通过 classextends 关键字配置的。尽管继承在语法上与直接原型引用大相径庭,但它是对构造函数(伪经典模型)的语法糖,实现了相同的功能。例如,将以下类

class Transaction {  
  constructor(sender, recipient, funds = 0.0) {
    this.sender = sender;
    this.recipient = recipient;
    this.funds = Number(funds);
  }
  displayTransaction() {
      return `Transaction from ${this.sender} to ${this.recipient}               
         for ${this.funds}`;
   }
}

相当于

function Transaction(sender, recipient, funds = 0.0) {      
    this.sender = sender;
    this.recipient = recipient;
    this.funds = Number(funds);
}

Transaction.prototype.displayTransaction = function displayTransaction() {
     return `Transaction from ${this.sender} to ${this.recipient} 
         for ${this.funds}`;
}

与基于类的语言相比,另一个巨大的不同之处在于 JavaScript 对象可以访问在子对象实例化之后声明的父级属性。从某些基对象继承的属性在所有子对象实例之间共享,因此对其的任何更改都会动态地影响到所有实例,这可能会导致不希望看到的、难以追踪的行为。这种强大而危险的机制导致了众所周知的原型污染问题。封装当然有帮助,这就是为什么在 2.2.1 节中讨论的将函数导出以构建你的对象比直接导出对象字面量本身要好得多。同样,导出类也有同样的好处。

让我们更具体地看看类的好处和坏处。为此,我们将再次重构 Transaction,这次使用类,并添加一些代码,以便在本书的其余部分中实现实际应用。如列表 2.8 所示,funds 现在是 Transaction 的一个属性,我们添加了对计算交易费用的支持,这是常见的银行任务。

为了说明类如何让你轻松设置原型链,让我们重构 TransactionHashTransaction。我还会借此机会展示一些关于私有类字段(mng.bz/YqVB)和静态字段(mng.bz/5jgB)的新语法建议,你可能不太熟悉。

列表 2.8 使用类定义的 TransactionHashTransaction 对象

class Transaction {
   sender = '';                                           ❶
   recipient = '';                                        ❶
   funds = 0.0;
   #feePercent = 0.6;                                     ❷

   constructor(sender, recipient, funds = 0.0) {
      this.sender = sender;
      this.recipient = recipient;
      this.funds = Number(funds);
   }

   displayTransaction() {
      return `Transaction from ${this.sender} to ${this.recipient}           
        for ${this.funds}`;
   }

   get netTotal() {
     return  Transaction.#precisionRound(this.funds * this.#feePercent, 2);
   }

   static #precisionRound(number, precision) {            ❷
      const factor = Math.pow(10, precision); 
      return Math.round(number * factor) / factor;
   }
}

class HashTransaction extends Transaction {               ❸
   transactionId;
   constructor(sender, recipient, funds = 0.0) {
      super(sender, recipient, funds);                    ❹
      this.transactionId = this.calculateHash();
   }        
   calculateHash() {
     const data = [
         this.sender,
         this.recipient,
         this.funds
       ].join('');
     let hash = 0, i = 0;
     while (i < data.length) {
         hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
     }
     return hash**2;
   }

   displayTransaction() {      
      return `${this.transactionId}: ${super.displayTransaction()}`;
   }
 }

const tx = new HashTransaction('luis@tjoj.com', 'luke@tjoj.com', 10);
tx.displayTransaction(); 

// Prints:
// 64284210552842720: Transaction from luis@tjoj.com to luke@tjoj.com for 10

❶ 声明此类公共字段并使用默认值。我建议使用默认值,因为它们有助于代码编辑器为你执行基本的类型提示。

❷ 使用静态私有字段和方法声明

❸ 原型设置被干净利落地隐藏在 classextends 的使用背后。

❹ 使用关键字super来调用父构造函数。当你重写构造函数时,你必须记得在第一行调用带有必要参数的super构造函数。

从表面上看,列表 2.8 中进行的重构看起来干净、简洁、优雅。我大胆地稍微修饰了一下代码,添加了对需要封装的变量的私有访问,以及几个用于验证的私有静态函数。正如你所知,这些函数由所有实例共享,为我们提供了真正的私有访问控制。因此,从类外部查询私有字段会抛出SyntaxError

tx.#feePercent; // SyntaxError

值得指出的是,使用带有哈希(#)修饰符的前缀的私有字段和私有方法功能。这个特性对于在类中实现适当的封装至关重要,你本可以使用模块和闭包与模块模式(如列表 2.9 所示)来实现这一点。(我将在第六章重新讨论这个模式。)同样,私有字段和特权方法通过利用类内部存在的闭包或词法作用域来模拟——这是幕后一个函数。

列表 2.9 使用模块模式实现的交易对象

const Transaction = (function() { 

   const feePercent = 0.6;                                   ❶

   function precisionRound(number, precision) {
      const factor = Math.pow(10, precision);
      return Math.round(number * factor) / factor;
   }

   return {                                                  ❷
      construct: function(sender, recipient, funds = 0.0) {
         this.sender = sender;
         this.recipient = recipient;
         this.funds = Number(funds);
         return this;
      },
      netTotal: function() {
         return precisionRound(this.funds * feePercent, 2);
      }
   } 
})();

const coffee = Transaction.construct('luke@tjoj.com', 'ana@tjoj.com', 2.5);
coffee.netTotal(); // 1.5

❶ 私有变量和/或特权函数

❷ 公共变量和/或函数

在类中,私有状态仅对类本身作用域内的方法可见——也称为特权。此外,如static #precisionRound这样的静态方法不会不必要地泄露给外部用户——这在常规构造函数或甚至模块模式中都是一件麻烦事。

回顾列表 2.8,你在代码片段中看到对prototype属性的引用了吗?没有!类有一个定义良好的结构,这使得它们非常适合抽象出平凡的原型细节,因此它们更不容易出错。此外,它们还提供了将数据和行为以连贯方式组合的语法优势。此外,classextends实际上为我们提供了完美的甜点,使得第三方库如 Prototype 的extend、Lodash 的_.extend或甚至 Node 的util.inherits变得过时。图 2.4 展示了这个新的、简化的心智模型。

图 2.4 构建HashTransaction类及其祖先TransactionHashTransaction的实例将继承父类中存在的所有公共字段。使用classextends可以正确设置原型链,以便有效地进行属性查找,并且构造函数引用完美对齐。

这个图与图 2.3 有些相似,但为了达到相同的原型配置,大大减少了艺术品的数量。基本相似性是有意为之,因为类在 JavaScript 背后就像函数一样工作。然而,与图 2.3 最明显的区别是,类中的所有属性(字段和方法)都自动成为对象的原型的一部分,可以通过内部的__proto__对象访问。你没有像构造函数那样的选择。你牺牲了这种灵活性以换取更多的结构。

创建新实例看起来像之前的伪经典方法(因此得名),没有变化:

const tx = new HashTransaction(
   'luis@tjoj.com', 
   'luke@tjoj.com', 
   10
);
tx.transactionId; // 197994095955825630

从表面上看,这段代码看起来干净紧凑。与构造函数相比,类更易于处理,这是毫无疑问的。但重要的是要意识到,你只是在原型上添加了一个熟悉的界面,以便能够将继承视为面向类语言中已完成的那样。

本章介绍了两种对象构造模式:构造函数和类。两者都以继承为中心,无论哪种方式,你都需要明确配置子对象(或类)与父对象(或类)之间的关系。第三章采用不同的方法,提出了将思维模式从继承转移到行为委托和链接的模式。

摘要

  • JavaScript 提供了许多构建对象的选择,包括原型继承、构造函数和类。

  • 原型继承这个短语是自相矛盾的,因为共享链接的原型对象的概念与类继承模型相矛盾,在类继承模型中,实例获得继承数据的副本。

  • 构造函数一直是 JavaScript 中模仿类概念的标准机制。

  • 类简化了原型配置的细节,对于新手或来自其他基于类语言的开发者来说,类已经成为 JavaScript 开发者的首选选择。

  • 类语法可能会模糊你对 JavaScript 原型继承机制的理解。类是有用的,但请记住,JavaScript 与其他你可能见过或使用过的基于类的语言不同。

3 链接,组合对象模型

本章涵盖

  • 理解与其他对象链接的对象(OLOO)的行为委托模式

  • 通过组合类和混入(mixins)来实现动态扩展

  • 使用Object.assign和扩展运算符来构建新对象

类继承在 JavaScript 中很少(可能永远)是最好的方法。

—埃里克·埃利奥特

在第二章中,我们探讨了创建原型链以模拟继承所需的一些基本结构,以及类如何简化这一过程。记住,使用继承的目的是为了提高复用性。现在我们将继续讨论组装你的对象,以实现相同级别的代码复用,但这种方式不需要你从继承的角度去思考。

第一种技术,由凯尔·辛普森发现,被称为与其他对象链接的对象(OLOO),它依赖于Object.create来创建构成你的领域模型的对象之间的关联。这种技术具有类的基本简洁性,同时正确地设置了原型链。这个模式很有趣,因为它允许你将领域模型视为一组对等对象,它们相互委托以完成工作。

第二种方法基于组合捕获一小组行为(称为混入)以创建一个更丰富的模型,正如埃里克·埃利奥特、道格拉斯·克罗克福德和其他 JavaScript 专家的作品中所充分展示的那样。在这种情况下,而不是从长原型链中获取属性,混入允许你将各种独立的行为和/或数据集成到一个单一的对象中。在 JavaScript 之外,这种技术的良好例子是一个 CSS 预处理器,如 Sass。你可以使用@mixin来分组重复的样式表信息并将其应用于许多规则集。在许多情况下,这种技术比@extends更受欢迎。

在本章中,我们将讨论对象之间的链接是显式(直接在代码中设置)还是隐式(通过 JavaScript 的运行时连接)。在深入研究本节中提到的模式之前,理解这些类型的链接非常重要。

3.1 对象链接类型

在 JavaScript 中,你可以以两种方式关联对象:隐式和显式。这两种类型的关联都允许一个对象向另一个对象发送消息(即委托),但它们的行为略有不同。图 3.1 展示了这种差异。

图 3.1 显式委托通过直接按名称知道的一个属性发生。隐式委托通过 JavaScript 的内部__proto__属性链式查找过程发生。

让我们从隐式(暗示)链接开始。

3.1.1  隐式

隐式链接只在内部已知——换句话说,在代码中不可见。在 JavaScript 中,通过使用 [[Prototype]] 内部引用来委托行为的对象链接可以被认为是隐式的,因为运行时会代表你使用它将消息发送到其他对象(在继承的情况下,向上链),作为属性解析的一部分,如下一个列表所示。

列表 3.1 FooUpperCaseFormatter 之间的隐式引用

const Foo = Object.create(UpperCaseFormatter);
Foo.saySomething = function saySomething(msg) {
  console.log(this.format(msg));                    ❶
}
Foo.saySomething('hello'); // Prints HELLO

❶ 格式通过原型链解析。

形成的关联是一种对象 A 通过“是”关系委托给 B 的关联,并且使用相同的对象上下文(this)来访问完整的行为集。在这个例子中,我们说 Foo “是” UpperCaseFormatter

隐式链接或委托是原型语言中访问属性和行为的基本方法。这种方法被我们迄今为止讨论的所有对象构造模式(类和构造函数)所使用,也被 OLOO 和混入(我们将在 3.2 和 3.5 节中分别讨论)所使用。

3.1.2 显式

另一方面,当链接已知且在代码中明显设置时,对象会通过显式链接,可能通过公共或私有属性来实现。我在书中没有涵盖这种技术,但查看一个简单的示例进行比较是很重要的,如下一个列表所示。

列表 3.2 FooUpperCaseFormatter 之间的显式链接

const UpperCaseFormatter = {
  format: function(msg) {
    return msg.toUpperCase();
  } 
};

const Foo = {
  formatter: UpperCaseFormatter,          ❶
  saySomething: function print(msg) {
    console.log(this.formatter !== null 
        ? this.formatter.format(msg) 
        : msg
    );
  }
};

Foo.saySomething('hello'); // Prints HELLO

❶ 显式地将一个对象传递给另一个

再次,如果我们对这些关系进行标记,当关系是显式的,我们说某个对象 A 通过“使用”标签委托给 B,也称为对象组合。在这种情况下,Foo 使用 UpperCaseFormatter 来执行其工作,并且这两个对象有不同的生命周期。在这个配置中,检查 this.formatter !== null 是合理的。从视觉上看,你可以看到显式的关系,因为 UpperCaseFormatter 的属性是通过通过已知引用(formatter)代理访问的,这在代码中是显式类型化的。

在隐式链接的情况下,两个对象的生命周期交织在一起,因为 UpperCaseFormatter 的属性将通过 this 访问;运行时通过 __proto__ 解析这些属性是理解的。

现在你已经理解了这种基本差异,让我们从使用隐式链接来实现行为委托的模式开始。

3.2 OLOO

OLOO 模式由凯尔·辛普森在他的书籍系列《你不知道的 JavaScript》(在第二章中提及)以及他有趣且详尽的视频系列“深入 JavaScript 基础”中提出(frontendmasters.com/courses/javascript-foundations)。这个模式值得研究,因为它改变了我们看待对象之间父子关系可视化的思维方式。OLOO 对差分继承的看法与类的心智模型不同,因为它不认为子对象是从基对象派生出来的。相反,它认为对象是相互链接的同伴,通过传递消息来委托功能。所有与继承相关的术语都消失了,我们不再说一个对象继承自另一个对象;我们说它链接到另一个对象,这是一个更简单的模型,更容易理解。

此外,OLOO 保留了语言的良好部分,同时摒弃了欺骗性的基于类的设计和构造函数模式的复杂原型配置。OLOO 仍然使用 [[Prototype]],但这个机制被巧妙地隐藏在 Object.create 之后,为设计对象提供了一个更简单的用户模型。如果你要查看 Object.create 的内部机制(mng.bz/zxnB),你会看到构造函数模式的最小实现,如下一列表所示。

列表 3.3 Object.create 的内部机制

Object.create = function(proto, propertiesObject) {
    if (typeof proto !== 'object' && typeof proto !== 'function') {
       throw new TypeError('Object prototype may only be an Object: ' +
          proto);
    }

    function F() {}           ❶
    F.prototype = proto;      ❷
    return new F();           ❸
};

❶ 创建一个新的多余的构造函数,F

❷ 设置构造函数的原型

❸ 返回调用 new 关键字的新对象

既然我们知道 Object.create 为我们处理了样板代码,那么让我们充分利用它,并使用它来连接所有对象的链。我将从展示这个模式的一些组件的简单示例开始。在这个代码片段中,我们将开始玩转区块链数据结构的概念。区块链是一个存储连续元素的对象,称为块:

const MyStore = {
   init(element) { 
      this.length = 0;
      this.push(element);
   },
   push(b) {
      this[this.length] = b;
      return ++this.length;
   }
}

const Blockchain = Object.create(MyStore);

const chain = Object.create(Blockchain);
chain.init(createGenesisBlock);
chain.push(new Block(...));
chain.length; // 2

在这个例子中,我们首先将对象 MyStoreBlockchain 链接起来;然后我们将对象 chain(我们认为它是具有所有功能的实际实例对象)与 Blockchain 链接起来。在 MyStore 的定义中,init 初始化方法负责典型的对象构造器逻辑,设置新实例的属性。正如你从前面的代码片段中看到的,chain 正确地将其同伴的属性 initpushlength 委托出去。

OLOO(对象链接和对象初始化)的另一个有趣方面是,在调用Object.create(Blockchain)之后,所有的链接都在内存中创建。Blockchain通过原型链知道initpush,但对象尚未初始化,因为init尚未被调用。此时,对象的形状已经在内存中实例化,但实际的数据初始化发生在调用init时,这启动了整个过程,填充了链中的第一个区块,并将一个可用的对象返回给调用者。正如你所见,对象被正确地链接:

MyStore.isPrototypeOf(Blockchain); // true
chain.__proto__.init // [Function: init]

你可以将init视为具有类构造函数的一些职责。但与同时执行构建和初始化的类构造函数不同,OLOO 将这两个动作作为不同的步骤分开。将声明与使用分离,允许你定义并懒加载实际的物体表示(例如模板)作为一个一等对象,类似于传递类定义。然后,你可以在需要时仅用其完整的数据集初始化这个懒加载的最小对象。

这种模式类似于建造者模式(en.wikipedia.org/wiki/Builder_pattern),在面向对象设计中使用得很多。

但如果你希望流畅地内联调用这两个步骤,你可以通过从init方法返回this来实现:

const MyStore = {
   init(element) { 
      this.length = 0;
      this.push(element);
      return this;
   },
   //...
}

const Blockchain = Object.create(MyStore);

const chain = Object.create(Blockchain).init(createGenesisBlock);
chain.push(new Block(...));
chain.length; // 2

与构造函数和类相比,OLOO 在原型继承上的依赖性更加可控和隐蔽。当MyStore.isPrototypeOf(Blockchain)true时,你无法意外地改变所有初始化对象的形状,从而保护你免受原型污染。实际上,MyStoreBlockchain根本不是构造函数,因此它们没有prototype属性来做这件事:

MyStore.prototype;    // undefined
Blockchain.prototype; // undefined

现在你已经在一个简单场景中看到了这个模式,让我们用这个同样的想法重构Transaction。接下来的列表显示了一个简单的 OLOO 实现;列表 3.5 显示了完整的实现。

列表 3.4 HashTransaction的简单对象链接

const Transaction = { 
  init(sender, recipient, funds = 0.0) {                   ❶
    this.sender = sender;
    this.recipient = recipient;
    this.funds = Number(funds);
    return this;                                           ❷
  },
  displayTransaction() {
    return `Transaction from ${this.sender} to ${this.recipient} for 
      ${this.funds}`;
  }
}

const HashTransaction = Object.create(Transaction);    

HashTransaction.calculateHash = function calculateHash() {
    const data = [this.sender, this.recipient, this.funds].join('');
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2; 
}

const tx = Object.create(HashTransaction)
    .init('luis@tjoj.com', 'luke@tjoj.com', 10);           ❸

tx.sender;      // 'luis@tjoj.com'
tx.recipient;   // 'luke@tjoj.com'
tx.calculateHash(); // 64284210552842720
tx.displayTransaction();                                   ❹

init方法与类构造函数完全等价(仅是一种约定;你可以使用任何你喜欢的名称)。

❷ 由于对象是直接返回的,导致此对象的属性包含在专用对象内部

❸ 使用Object.create构建新对象,并将原型链接与对象初始化很好地分离

❹ 此方法通过原型链调用。

到目前为止,一切看起来都很直接。与我们添加的MyStore示例相比,我们只是为每个对象添加了更多一点的功能。图 3.2 显示了对象的结构以及它们之间的链接。

图 3.2 三种对象对之间的链接表面视图

图 3.2 显示,你可以建立对象之间的隐式链接,同时移除使用类和(在更大程度上)构造函数时可能需要编写的原型样板代码。下面的列表展示了完整的 Transaction 对象及其所有功能。

列表 3.5 使用行为委托(OLOO)建模 Transaction

const Transaction = {                                            ❶
   init(sender, recipient, funds = 0.0) {                        ❷
     const _feePercent = 0.6;                                    ❸

     this.sender = sender;
     this.recipient = recipient;
     this.funds = Number(funds);

     this.netTotal = function() {
       return _precisionRound(this.funds * _feePercent, 2);
     }

     function _precisionRound(number, precision) {               ❸
       const factor = Math.pow(10, precision);
       return Math.round(number * factor) / factor;
     }
     return this;                                                ❹
   },

   displayTransaction() {
     return `Transaction from ${this.sender} to ${this.recipient} 
       for ${this.funds}`;
   }
} 

const HashTransaction = Object.create(Transaction)               ❺

HashTransaction.init = function HashTransaction(                 ❻
   sender, recipient, funds
  ) {      
    Transaction.init.call(this, sender, recipient, funds);       ❼
    this.transactionId = this.calculateHash();
    return this;
}

HashTransaction.calculateHash = function calculateHash() {
    // same as before...
}

❶ 整个链基于简单对象,Transaction 位于层次结构的底部。

❷ 初始化函数类似于类构造函数。此外,使用函数关键字是故意的,以确立 this 的正确行为。

❸ 私有属性被很好地封装在对象的闭包中,只允许特权方法访问它们。

❹ 因为我是使用普通函数作为构造函数,所以没有隐含的 this,因此我们需要自己返回它。

❺ 正确使用 JavaScript 的 Object.create 可以创建使用 [[Prototype]] 的隐式委托链接。

❻ 初始化函数类似于类构造函数。此外,使用函数关键字是故意的,以确立 this 的正确行为。

❼ 等同于在子类构造函数中使用 super。

这段代码与列表 3.4 中的代码相同,但为 HashTransaction 添加了更多的初始化逻辑,以清楚地将初始化与实例化分开。图 3.3 显示了一个更完整的图。

图片

图 3.3 实现了 OLOO 模式的完整实现,以实现 HashTransaction,链接到 Transaction 以委托基本功能

如图 3.3 所示,init 函数很好地封装了其词法作用域内的任何私有状态(类似于模块模式),并通过 this 仅暴露所需的内容。你可以利用这个机会定义任何作为对象构建一部分所需的私有函数,并且不会有任何内容泄露给调用者。此外,你还可以看到对象上下文(this)从上一个 init 块到下一个上游直到父对象的可视化管理。这不是为了创建原型链接(因为 Object.create 会为你做这个),而是为了初始化整个链直到基对象。

到目前为止,你已经学习了关于对象建模的技术,这些技术共享使用原型解析机制和隐式链接到其他对象的观念,无论这种委托是向上还是沿着链。但是所有这些技术都缺乏从多个对象共享行为的能力,因为原型建立了一个单一、单向的路径,其中属性是动态解析的。这些情况在软件中并不常见,但一旦发生,原型链就不足以正确地模拟它们。以一个从自然界中提取的简单例子来说明。两栖动物是具有水生和陆生特性的动物。如果你要绘制出AmphibianAquaticTerrestrial对象,你如何以原型方式建模这种关系,使得Amphibian链接到AquaticAnimalTerrestrialAnimal?可能是多重继承?

你根本不需要使用继承。让我们探索另一种依赖对象组合的软件构建模式。重要的是要说明,当我提到对象的组合时,我并不是指那个同名已知的面向对象模式。这个模式要简单得多。在这里,我指的是通过粘合小的单个部分或特性来组装一个丰富的对象的能力——在结构意义上的组合。JavaScript 的对象模型是独一无二的,它让我们能够执行这个任务,而要使用的 API 是Object.assign。第 3.3 节首先讨论了这个 API,然后展示了如何使用它来组合对象。你将在第 3.4 节中使用这个 API 来实现混入。

3.3 理解 Object.assign

软件发展迅速,需求变化剧烈。原型对象模型的问题在于,随着时间的推移,它们往往会变得过于僵化和脆弱,这使得在保证不会破坏其他东西的情况下,很难对模型进行大的改动。在层次结构中不当处理基类可能会导致在整个模型中产生连锁反应——这个问题被称为脆弱基类问题。你可以使用模式通过物理复制属性到派生对象来最小化这个问题。

状态复制而非状态共享并不是一个新概念。为什么不通过传递它们所需的所有属性的副本来构建对象呢?在 JavaScript 中,对象是属性的动态集合,这个过程很简单。JavaScript 允许你粘合各种属性(称为部分对象)的各个部分,以创建一个完整的、功能丰富的对象,就像将几个袋子的内容倒入一个更大的袋子中一样。这个过程不是通过链接到原型来完成的,而是通过整合或合并更简单、单个对象的副本。

除了实例化之外,从用户的角度来看,使用这种方式构建的对象与前面列出的方法感觉没有区别;表面上对象的形状是相同的。然而,从代码推理的角度来看,这个过程却是根本不同的。在接下来的几节中,我们将探讨支持此过程的 JavaScript API 以及一些幕后特性,这些特性使得这一切成为可能。

3.3.1 暴露 Object.assign

您可以使用 Object.assign 来合并各种对象的属性。在本节中,我们将深入讨论如何使用此 API。Object.assign 是您工具箱中的一个很好的瑞士军刀,它在几个用例中都很有用。假设您正在开发几个 API 响应的混合体,并希望将响应体作为单个 JSON 提供出来。另一个常见任务是通过对一个对象的新空对象 {} 分配属性来执行浅克隆。许多接受配置对象作为参数的库使用 Object.assign 来提供默认值。在下面的列表中,函数 doSomething 接收一个 config 对象,允许用户为此函数执行的假设逻辑指定设置。

列表 3.6 使用 Object.assign 实现具有默认值的选项

function doSomething(config = {}) {
  config = Object.assign(
    {
      foo: 'foo',
      bar: 'bar',        ❶
      baz: 'baz'
    }, config);

   console.log(`Using config ${config.foo}, ${config.bar}, ${config.bar}`);
}

doSomething();               // Prints Using config foo, bar, bar
doSomething({foo: 'hello'}); // Prints Using config hello, bar, bar

❶ 配置默认值

通过合并用户提供的对象和默认值,很容易获得所需的 config. Object.assign 将一个或多个对象拥有的可枚举属性(由 Object#hasOwnProperty 定义)复制到目标对象中,并返回目标对象。下面的列表展示了简单示例。

列表 3.7 使用 Object.assign 将两个对象合并到新对象中

const a = {
  a: 'a'            ❶
};
const b = {
  b: 'b'            ❶
};

Object.assign({}, a, b);  //{ a: 'a', b: 'b' }

❶ 这些属性是可枚举的,并且属于对象,因此它们会被复制。

在这种情况下,所有相关的对象都具有 enumerable: true 的属性,这意味着 Object.assign 将扫描并复制它们。

现在考虑一个不可枚举的属性,它将被跳过:

const a = {
  a: 'a'
};

const b = {};
Object.defineProperty(b, 'b', {
  value: 'b',
  enumerable: false
});

Object.assign({}, a, b);  //{ a: 'a' }

您可以通过迭代对象,使用 for...in 这样的结构来找到可枚举属性,这些属性具有 enumerable: true。您可以在定义点控制这个元属性以及另外三个(writableconfigurablevalue)。回想第二章,包含这四个属性的元对象被称为属性或数据描述符。

根据 Object#hasOwnProperty,自有属性指的是直接在源对象中找到的属性,而不是通过其原型可访问的属性。在下面的代码片段中,继承属性 parent 从未分配给目标对象:

const parent = {
  parent: 'parent'
};

const c = Object.create(parent);
c.c = 'c';

Object.assign({}, c);  // { c: 'c' }

现在考虑一个具有相同名称的属性,对象正在合并。在这种情况下,规则是列表中右侧的对象覆盖左侧对象的属性集。这里有一个简单的用例:

const a = {a: 'a'};const b = {b: 'b'};Object.assign({}, a, b); const c = {a: 'ca', c: 'c'};Object.assign({}, a, b, c);

这条规则很重要,我将在 3.4.2 节中再次提到它。

在这一点上,你可能认为Object.assign只是从右到左直接复制属性。嗯,并不总是这样。定义和赋值之间有一个微妙的不同。

3.3.2 赋值与定义的区别

将值赋给现有属性并不定义属性的行为方式,就像Object.defineProperty所做的那样。赋值只有在为不存在的属性进行赋值时才会回退到定义属性。因此,对于新属性,JavaScript 使用规范中概述的[[DefineOwnProperty]]内部过程,而对于现有属性,它使用[[Set]],这不会改变属性的元属性,就像我们第一个例子中的doSomething(见以下列表)所发生的那样。

列表 3.8 使用Object.assign为新和现有属性赋值

function doSomething(config = {}) {
  config = Object.assign(
    {
      foo: 'foo',
      bar: 'bar',           ❶
      baz: 'baz'
    }, config);

   console.log(`Using config ${config.foo}, ${config.bar}, ${config.bar}`);
}

doSomething({foo: 'hello'});

❶ 将foo设置为'hello',而barbaz是在Object.assign过程中新定义的。

大多数时候,这种区别没有影响,但有时会有。让我们通过另一个例子来探索这种区别:

const Transaction = { 
    sender: 'luis@tjoj.com' 
};
Object.assign(Transaction, { sender: 'luke@tjoj.com' }); 

前面的调用按预期工作,sender被设置为'luke@tjoj.com'。但如果sender不是一个字符串属性,而是一个 setter 方法呢?根据规范,Object.assign会对现有属性键调用[[Set]]元操作。考虑下一个列表中的场景。

列表 3.9 Object.assign在遇到相同属性名时调用[[Set]]

const Transaction = { 
   _sender: 'luis@tjoj.com', 

   get sender() { 
      return this._sender; 
   }, 
   set sender(newEmail) { 
      this._sender = Transaction.validateEmail(newEmail);
   } 
}; 

const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|   ❶
(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9] ❶
+\.)+[a-zA-Z]{2,}))$/;                                                        ❶

Transaction.validateEmail = function validateEmail(email) {
  if (EMAIL_REGEX.test(email.toLowerCase())) {
    return email;
  }
  throw new Error(`Invalid email ${email}`);
};

Object.assign(Transaction, { sender: 'invalid@email' }); // Error!            ❷

❶ 匹配有效电子邮件地址的正则表达式

❷ 使用无效电子邮件地址发送输入

在这里,sender被视为一个现有属性,并通过 JavaScript 的内部[[Set]]属性进行处理,当电子邮件地址格式错误时,setter 逻辑会执行并失败。

现在你已经了解了这个内置 API 的基本工作原理,让我们用它来支持我们最后的对象构造模式:混入。

3.4 使用混入组合组装对象

组合或组装对象的思路与之前看到的方法略有不同。使用原型,你链接到一个单一的对象以共享其状态和行为,但使用混入,你复制了多个独立切片对象的精细粒度部分,这些切片共同代表了对象的全部 API。本节教你如何使用Object.assign来实现一种称为混入的连接对象扩展技术。

将混入(mixins)想象成给你的冰淇淋添加风味或给你的三明治加配料;每一次新的添加都会给整体风味带来变化,但不会压倒它。在我们深入到Transaction的最终版本之前,让我们研究一个简单的用例。考虑以下这些简单的对象字面量:

const HasBread = {
  bread: 'Wheat',
  toasted: true 
};

const HasToppings = {
  sauce: 'Ranch'
};

const HasTomatoes = {
  tomatoe: 'Cherry',
  cut: 'diced' 
};

const HasMeat = {
  meat: 'Chicken',
  term: 'Grilled'
};

我们的 Sandwich 对象可以通过连接以下任何部分或全部来创建:

const Sandwich = (size = '6', unit = 'in') => 
   Object.assign({
    size, unit
  }, 
  HasBread,
  HasToppings,
  HasTomatoes, 
  HasMeat
);

const footLong = Sandwich(1, 'ft');
footLong.tomatoe; // 'Cherry'

更简洁地说,您可以利用扩展运算符:

const Sandwich = (size = '6', unit = 'in') => ({
  size, unit,
  ...HasBread,
  ...HasToppings,
  ...HasTomatoes, 
  ...HasMeat
});

类似于 HasBread 的混入对象本身并不提供太多价值,但它可以用来增强某些目标对象——在这个例子中是 Sandwich。简要回顾一下 OLOO 模式,你可能在派生对象构造函数执行期间添加父对象的属性(方法和数据)时瞥见了它。这个过程在链接对象图的每一层都会重复。实际上,为了简化使用 Object.assign 作为定义对象关系手段的过渡,可以考虑对 OLOO 示例进行轻微的调整,将对象链接步骤(const HashTransaction = Object.create(Transaction))和在此新对象上定义新属性结合起来,如下所示。

列表 3.10 使用 Object.assign 实现的 OLOO

const Transaction = { 
  init(sender, recipient, funds = 0.0) {    
    this.sender = sender;
    this.recipient = recipient;
    this.funds = Number(funds);
    return this;
  },
  displayTransaction() {
    return `Transaction from ${this.sender} to ${this.recipient} for   
       ${this.funds}`;
  }
}

const HashTransaction = Object.assign(       ❶
  Object.create(Transaction),
  {
      calculateHash() {
        const data = [this.sender, this.recipient, this.funds].join('');
        let hash = 0, i = 0;
        while (i < data.length) {
          hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
        }
        return hash**2; 
    }
  }
);

❶ 通过使用 Object.assign 分配定义 HashTransaction 的属性

混入是一个具有简单行为的对象,通常由一个或两个方法组成。在这种情况下,有一个方法,calculateHash,我们将在 3.5.1 节中将其重构为其自己的对象。混入越简单,就越容易在代码的许多部分中组合和重用。混入应该具有狭窄的焦点,远小于类。它们可以捕获单一责任或甚至是一部分责任。只要混入是自维持的并且拥有执行其任务所需的一切,看起来不完整是可以接受的。

注意:本书仅对混入模式进行了表面讨论。有关混入模式的更多信息,请参阅埃里克·埃利奥特(Eric Elliot)的《Composing Software》(leanpub.com/composingsoftware)。

对象组合促进创建对象之间的 HAS-A 或 USES-A 而不是 IS-A 关系。因此,您不是隐式地将对象委派给其父对象,而是绕过继承,直接将所有需要的属性复制到目标对象。您可以想象这个过程类似于将继承层次结构压缩成单个对象字面量。由于您是在对象定义之后添加新属性,这个过程被称为动态或连接性对象扩展。

混入可能听起来有些复杂,但这种模式已经被广泛使用了一段时间。我提到了它在 CSS 预处理器中的使用,但 JavaScript 本身也有其他用途。在浏览器中,由全局window对象灌输的行为部分是通过WindowOrWorkerGlobalScope混入实现的。同样,浏览器事件是通过WindowEventHandlers混入处理的。这些混入用于在浏览器中使用的全局对象(window)以及 Web Worker API(self)之间分组一组常见的属性。当然,浏览器为您预先混合了这些代码,因此您不必这样做,但考虑一个更明显的例子。如果您曾经使用过流行的 Mocha 和 Chai 单元测试库,您可能知道您可以通过动态注入新行为来扩展它们的功能,使用

chai.use(function (_chai, utils) { 
   // ... 
});

方法名(use)是合适的。许多其他第三方库已经利用了这一特性。例如,为了简化使用承诺的测试,你可以通过 chai-as-promised (www.npmjs.com/package/chai-as-promised)库扩展 Chai:

chai.use(chaiAsPromised);

动态连接体现了组合原则:将简单的对象组合成复杂的对象,这正是我们在这里所实现的。

回到Transaction,我们将使用第二章开始定义的类作为其核心结构,并通过混入来借用共享的代码模块。你首先会注意到calculateHash的定义不再属于类声明的一部分;它被移动到了一个名为HasHash的对象中。将calculateHash分离成独立的模块将使得为我们的领域中的其他类(如BlockBlockchain)添加哈希行为变得更加容易。正如列表 3.11 所示,通过一个函数,我们可以根据需要使用参数来配置哈希行为,例如指定作为哈希过程一部分的对象字段。

注意:对于这些混入(mixins),因为我们返回一个新的对象,我们将使用箭头函数来节省一些打字。常规函数声明同样可以很好地工作。

列表 3.11 定义HasHash混入

const HasHash = keys => ({
  calculateHash() {
    const data = keys.map(f => this[f]).join('');      ❶
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;   
  }
});

❶ 从指定的属性键值创建一个字符串

HasHash是一个由函数表达式包裹的混入,因此混入部分是这个对象字面量:

{
  calculateHash() {
    const data = keys.map(f => this[f]).join('');
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;   
  }
});

为了完整性,如果我们替换 OLOO 示例(列表 3.10)中的这段代码,我们将在下一个列表中看到结果。

列表 3.12 使用HasHash混入的Transaction的 OLOO 模式

const Transaction = { 
  init(sender, recipient, funds = 0.0) { 
    this.sender = sender;
    this.recipient = recipient;
    this.funds = Number(funds);
    return this; 
  },
  displayTransaction() {
    return `Transaction from ${this.sender} to ${this.recipient} 
       for ${this.funds}`;
  }
}

const HashTransaction = Object.assign(
  Object.create(Transaction),
  HasHash(['sender', 'recipient', 'funds'])     ❶
);

❶ 复制由调用HasHash返回的混入的属性。通过使用函数,可以简单地指定混入可以访问的对象状态中的哪些属性作为计算对象哈希值的一部分。

列表 3.13 显示了 Transaction 的最终版本(类 + 混合)。此代码将所有混合集成到类原型引用中,以便 Transaction 的所有实例都可以使用相同的哈希功能。所有混合的 Object.assign 调用都在最后进行。

列表 3.13 使用混合连接的 Transaction 对象

class Transaction {
   transactionId = '';                                         ❶
   timestamp = Date.now();
   #feePercent = 0.6;

  constructor(sender, recipient, funds = 0.0, description = 'Generic') {
    this.sender = sender;        
    this.recipient = recipient;  
    this.funds = Number(funds);
    this.description = description;
    this.transactionId = this.calculateHash();
  }

  displayTransaction() {
    return `Transaction ${this.description} from ${this.sender} to 
       ${this.recipient} for ${this.funds}`;
  }

  get netTotal() {
     return  Transaction.#precisionRound(
        this.funds * this.#feePercent, 2);
  }

  static #precisionRound(number, precision) {
     const factor = Math.pow(10, precision);
     return Math.round(number * factor) / factor;
  }
}

Object.assign(                                                ❷
   Transaction.prototype,
   HasHash(['timestamp', 'sender', 'recipient', 'funds']),
   HasSignature(['sender', 'recipient', 'funds']),            ❸
   HasValidation()                                            ❹
)

❶ 通过调用 calculateHash 在构造函数中设置 transactionId,动态分配给此实例的原型,并可供所有实例使用。

❷ 使用 Object.assign 将构成事务的对象粘合在一起(或包含)

❸ HasSignature 混合处理签名生成和验证。

❹ HasValidation 为任何对象(将在第五章讨论)分组常见的验证任务。

注意:使用类(如 Transaction)作为动态混合扩展的目标对象是在你的编码中最可能遇到的情况,因为类的流行。但你可以使用混合与之前讨论的任何构造模式一起使用。

你可能会在像 PHP 这样的编程语言中找到这种模式,也称为特性(www.php.net/manual/en/language.oop5.traits.php)。当你使用类时,所有属性都添加到类的原型引用中。因此,我们使用 Object.assign 动态扩展类的原型,以避免每次需要新的交易时都要重复分配混合的逻辑。

此外,从内存效率的角度来看,增强原型对象导致所有事务对象都具有相同的属性集。实例化新的 Transaction 看起来与第二章相同:

const tx = new Transaction('luis@tjoj.com', 'luke@tjoj.com', 10);
tx.transactionId; // 241936169696765470

重要的是要注意,尽管这个版本的 Transaction 与之前的版本不同,但它通过以下方式保留了最佳部分:

  • 使用类方便的语法来优雅地分组和封装所有相关的交易细节。

  • 从其他混合中集成可重用部分以实现最大程度的代码重用。

  • 将对象定义(如 Transaction 类)和混合配置从实例化中分离,类似于 OLOO。

  • 通过跳过不可枚举和非所有者属性(因为它使用 Object.assign)仅集成混合对象的直接、公共接口。

  • 允许混合封装它们自己的隐藏属性和方法,这些属性和方法只能通过其公共 API 使用,但不成为整体对象的一部分,并且无法从类中访问。反之亦然:混合没有访问类内部声明的任何私有(#)属性。只有它们的公共接口进行通信,这防止了更紧密的耦合形式。我将在 3.5.1 节中回到这个话题。

  • 避免深层和繁琐的原型配置,使对象更扁平,因此更易于使用和与其他代码部分结合。

现在你已经知道了混合如何集成到更大的对象中,让我们评估混合的结构。

3.4.1 混合体的解剖结构

在本节中,我们将讨论在我们区块链应用中使用的混合体的形状。Transaction使用了两个重要的扩展,实现了支撑区块链技术的两个主要加密概念:哈希(HasHash)和数字签名(HasSignature)。我们当前的HasHash版本仍然不产生加密安全的哈希。我们需要改进这个算法,但我们将逻辑的细节留到第四章,现在只关注其形状。当我们有了公共接口和连接的调用,替换算法就变得简单了。

列表 3.14 和 3.15 显示了HasHashHasSignature的更新结构。

列表 3.14 HasHash混合体

const DEFAULT_ALGO_SHA256 = 'SHA256';      ❶
const DEFAULT_ENCODING_HEX = 'hex';        ❶

const HasHash = (
  keys,
  options = { algorithm: DEFAULT_ALGO_SHA256, 
              encoding:  DEFAULT_ENCODING_HEX }
) => ({
  calculateHash () {
    //...   
  }
})

❶ 传递给配置哈希过程的默认选项。在这里,我们使用 SHA256 算法和十六进制编码。

因为HasHash接受一个表示参与计算哈希的属性的键列表,它可以与任何目标对象一起工作。以下是一个例子:

const hashable = Object.assign(
   { foo: 'foo', bar: 'bar' }, 
   HasHash(['foo', 'bar'])
);

hashable.calculateHash();  // '1610053822743955500'

回到封装,假设混合体是其自己的模块(第六章),任何在混合体函数作用域之外的数据(如DEFAULT_ALGO_SHA256)实际上是私有的和自包含的,因为它是混合体函数闭包的一部分。

与此类似的结构,下一个列表包含了HasSignature的骨架。这个混合体包含更多的行为。

列表 3.15 HasSignature混合体

const DEFAULT_ENCODING_HEX = 'hex';
const DEFAULT_SIGN_ALGO = 'RSA-SHA256';

const HasSignature = (
  keys,
  options = {
    algorithm: DEFAULT_SIGN_ALGO,
    encoding: DEFAULT_ENCODING_HEX
  }
) => ({

  generateSignature(privateKey) {
    //...

  },
  verifySignature(publicKey, signature) {
    //...
  }
});

这些方法的主体处理使用 Node.js 的crypto模块对对象的内含内容进行签名,以及读取和验证公私钥对,这些内容我们在这本书中不涉及。你可以自由地访问代码库以了解内部细节。不过,请记住,在现实世界的开放分布式账本中,公钥是识别用户钱包对其他世界的方式。从调用者的角度来看,下一个列表显示了如何使用HasSignature

列表 3.16 使用HasSignature对对象的内含内容进行签名

const signable = Object.assign(
   { foo: 'foo', bar: 'bar' }, 
   HasSignature(['foo', 'bar'])
);
const publicKey = fs.readFileSync('test-public.pem', 'utf8');
const privateKey = fs.readFileSync('test-private.pem', 'utf8');

const signature = signable.generateSignature(privateKey);        ❶

signable.verifySignature(publicKey, signature); // true          ❷

❶ 使用私钥对对象的数据进行签名

❷ 你可以使用相应的公钥来验证签名的正确性。

你已经看到了HasHashHasSignature的例子。我在第五章中介绍了HasValidation(另一个混合体)及其内部逻辑。请注意,我命名这些混合体的目的是清楚地表明正在发生组合,明确地建立了与目标对象的 HAS-A 关系,如图 3.4 所示。

图 3.4 当使用组合时,构建对象的方式是通过粘合其他独立对象:HasHashHasSignatureHasValidation。每个对象的属性被混合成一个单一来源,从用户的角度来看,形成一个单一对象。

图 3.4 展示了对象组合的理论或概念视图。实际上讲,在目标对象形成后,Transaction类对调用者看起来就像图 3.5。

图 3.5

图 3.5 对象赋值后的Transaction形状

可能看起来,通过混入的组合,我们可以获得类似于多重继承的东西——这是一个有争议的软件话题。如果你对这个话题做过一些研究,你可能会遇到“死亡钻石”问题。这个问题指的是当一个类扩展多个类,每个类都声明了相同的方法时存在的歧义。像 Scala 这样的语言,通过使用称为线性化的技术来克服这个问题。在 3.4.2 节中,我们将看到 JavaScript 是如何解决这个问题。

3.4.2 多重继承与线性化

通常来说,与传统的继承方案相比,混入(mixins)有两个主要优点:

  • 混入通过允许开发者从几个独立的对象中自由地重用方法集,而不是从一个对象中,从而减少了单继承的一些限制。

  • Object.assign使用的算法消除了多重继承引起的歧义,并使这个过程变得可预测。

第一点是直接由原型链机制产生的结果,因为一个对象与其原型之间是一对一对应的关系。连接(concatenation)克服了这种限制,因为你可以自由地将所需数量的对象组合成一个单一的对象。

第二点更为复杂。混入如何解决臭名昭著的钻石问题?这个问题的前提是容易理解的:一个子类 C 从两个父类 B1 和 B2 扩展,而这两个类又依次从基类 A 扩展。这个问题在面向类的语言中更为常见,这些语言将父类的关系标记为 IS-A。从这个角度来看,一个类如何成为两种不同事物的模板?再次考虑动物分类学示例(图 3.6)。在底层,你可能有一个类Animal,以及子类TerrestrialAnimalAquaticAnimal

图 3.6

图 3.6 多重继承的经典钻石问题。假设一个类可能从多个其他类扩展,而这些类具有冲突的方法签名,那么在运行时将调用哪个方法?

首先,让我们来点生物学知识:两栖动物,如青蛙、蟾蜍和蝾螈,最初是幼虫,有鳃来在水下呼吸,后来成熟为成体,拥有肺来呼吸空气。有些两栖动物甚至依赖它们的皮肤作为次要的呼吸方式。一个类Amphibian从这两个类中扩展出来是很有道理的。但当调用frog.breathe时,它将选择哪个实现?在软件中,我们不能把答案留给大自然。

如你所预期,我们可以使用混入来模拟这种类型的对象:

const TerrestrialAnimal = {   
   walk() {
      ...
   },
   breathe() {
     return 'Using my lungs to breathe';
   }
};

const AquaticAnimal = {
   swim() {
      ...
   },
   breathe() {
     return 'Using my gills to breathe';
   }
};

const Amphibian = name => Object.assign(
   { 
     name 
   }, 
   AquaticAnimal, 
   TerrestrialAnimal
);

const frog = Amphibian('Frog');

frog.walk();

frog.swim();

回到原始问题,如果青蛙调用breathe,它使用的是哪种实现?看起来我们进入了一个菱形情况。但是Object.assign的规则消除了这种歧义,因为它是可预测的:总是优先选择最后添加的对象的属性。你可以通过将死亡菱形折叠成一条直线(因此,线性化)来想象这种情况,按照有序的顺序。线性化菱形问题看起来就像图 3.7。

图片

图 3.7 将线性化应用于多重继承情况

Object.assign的实现方式允许发生相同的行为。在幕后,实现方式就像图 3.8。

图片

图 3.8 使用Object.assign的机制,该机制负责建立源对象分配给目标对象的可预测顺序,可以实现多重继承。

现在如果你在frog对象上调用breathe,你总是得到预期的结果,选择TerrestrialAnimal作为实现:

frog.breathe(); // 'Using my lungs to breathe'

3.4.3 使用 Object.assign 和扩展运算符组合对象

这种方式合并对象如此常见,以至于自 ECMAScript 2018 以来,我们可以进一步简化这项技术。我们不必直接使用Object.assign API,我们有语言支持来完成类似的事情,使用扩展运算符对对象进行操作。这个运算符提供了一个紧凑、惯用的语法,以不可变的方式复制对象的状态。

在 3.3 节中,我简要提到了一些Object.assign有用的例子。扩展运算符在这些情况下也同等有效。考虑对某个对象obj执行浅克隆的例子:

const clone = { ...obj };

这个例子与

const toad = Object.assign({}, obj);

我们可以使用扩展运算符来创建对象模板:

const toad = { ...frog, name: 'Toad' };

在一行中,我们复制了frog的所有自有属性,并覆盖了名称属性,从而得到一个新的对象,称为toad。从实际的角度来看,Object.assign和扩展运算符有类似的使用,唯一的例外是扩展运算符产生一个新的对象,而不是分配给现有的对象。在大多数情况下,这个例外并不重要,但如果我们选择使用扩展运算符与Transaction类一起直接增强prototype,代码会因错误而失败。所以

Transaction.prototype = {
  ...HasHash(['timestamp', 'sender', 'recipient', 'funds']),
  ...HasSignature(['sender', 'recipient', 'funds']),
  ...HasValidation()
}

在严格模式下会抛出错误:

TypeError: Cannot assign to read only property 'prototype' of function 'class Transaction... 

虽然这两种模式都允许你通过组合其他对象来创建对象,但这种细微的差别足以让我们在我们的应用程序中继续使用Object.assign。在 3.5 节中,我们使用这种模式来完成我们的领域模型的主要类。

3.5 将共享混合应用到多个对象

现在你已经很好地理解了动态对象连接,为了看到代码重用的好处,我们将将其应用于应用程序的其他部分。在本节中,你将看到我们迄今为止创建的混合如何应用于不仅仅是Transaction。为了在领域层保持一致性,并且因为你更有可能遇到类,我将使用类来建模BlockchainBlockWallet的概念。在列表 3.12 中,我展示了如何使用混合与 OLOO。这两种模式都使用隐式链接,所以你应该能够不费吹灰之力将此代码移植到 OLOO 风格。

首先,让我们在下一个列表中定义具有相似结构的Blockchain类。

列表 3.17 使用混合的Blockchain定义

class Blockchain {

  #blocks = new Map();

  constructor(genesis = createGenesisBlock()) { 
     this.#blocks.set(genesis.hash, genesis);
  }

  height() {
     return this.#blocks.size;
  }

  lookup(hash) {
    const h = hash;
    if (this.#blocks.has(h)) {
      return this.#blocks.get(h);
    }
    throw new Error(`Block with hash ${h} not found!`);
  }

  push(newBlock) {     
     this.#blocks.set(newBlock.hash, newBlock);
     return newBlock;
     }  
}

function createGenesisBlock(previousHash = '0'.repeat(64)) {
  //...
}

Object.assign(Blockchain.prototype, HasValidation());       ❶

❶ 与Transaction一样,扩展了区块链的验证功能。(验证逻辑的完整实现将在第五章中介绍。)

区块链存储区块,而区块又存储交易。列表 3.17 显示了Block的基本类声明,我们将随着进展逐步填充它。这个类最重要的任务是使用其前一个哈希来管理交易集合和哈希计算。使区块链篡改可检测的是,每个区块的哈希都依赖于所有前一个区块的哈希,从创世区块开始。所以如果区块被篡改,你只需要重新计算其哈希,并与原始哈希比较以检测不当行为。下一个列表显示了Block如何也混合了HasHash

列表 3.18 Block定义

class Block {
  #blockchain;

  constructor(index, previousHash, data = []) {
    this.index = index;
    this.data = data;                                           ❶
    this.previousHash = previousHash;                           ❷
    this.timestamp = Date.now();
    this.hash = this.calculateHash();                           ❷
  }

  set blockchain(b) { 
    this.#blockchain = b;
    return this;
  }

  isGenesis() {
    return this.previousHash === '0'.repeat(64);
  }
}

Object.assign(
   Block.prototype,
   HasHash(['index', 'timestamp', 'previousHash', 'data']),     ❸
   HasValidation() 
);

❶ 一个区块的数据字段可以包含在挖掘新区块或挖掘后添加到链中的交易。

❷ 每个区块总是包含其前一个区块的哈希(这建立了链)。

HasHash扩展了Block的哈希功能。

到目前为止,我们已经构建了应用程序领域层的大部分骨架。随着你继续阅读,你将了解更多关于 JavaScript 和编写区块链编程的知识,我们将继续添加此代码的更详细细节。作为额外参考,图 3.9 显示了迄今为止我们创建的对象和共享的混合。

图 3-9

图 3.9 展示了主要对象及其各自的混合。如您所见,混合结构旨在共享。

此外,为了使区块链教学更简单,我尝试通过使用电子邮件地址来识别交易的发送者和接收者,从而避免了一些密码学主题。在现实世界中,电子邮件对于公共账本来说过于个人化,在公共账本中,用户信息始终需要保持安全。交易以密码学安全的公钥形式存储senderreceiver地址。当您访问 GitHub 上的区块链应用程序源代码时,您会看到使用密钥而不是电子邮件。此信息标识了每个用户的数字钱包,如下所示。将钱包想象成您的个人银行移动应用程序。

列表 3.19 Wallet对象

class Wallet {
  constructor(publicKey, privateKey) {
    this.publicKey = publicKey
    this.privateKey = privateKey
  }
  get address() {
    return this.publicKey
  }
  balance(ledger) {
    //...              ❶
  }
}

❶ 详细内容推迟到第四章

图 3.10 显示了区块、交易、钱包和区块链之间基本交互。

图 3.10 展示了我们简单区块链应用程序中起主要作用的对象。我没有显示Money,这是一个描述金额和货币的价值对象。

在本章中,我们探讨了两种更多的对象构造模式:OLOO(也称为简单对象链接)和连接性对象扩展(也称为混入)。与第二章中回顾的技术不同,这些替代方案为您在建模对象时提供了更多的灵活性。

然而,总有缺点。JavaScript 引擎高度优化利用[[Prototype]]机制的过程。当我们通过使用混入而不是对象层次结构(这更喜欢更多的状态复制,并且对脆弱的基本对象或原型污染更具弹性)进行轻微偏离时,我们创建了一个稍微更大的组合内存占用,因为我们有更多的对象在内存中。我们通过将类原型扩展到新实例而不是直接将混入扩展到新实例来缓解这种情况,如下一个列表所示。

列表 3.20 将混入分配给Transaction的单个实例

Object.assign( 
   new Transaction(...),                                      ❶
   HasHash(['timestamp', 'sender', 'recipient', 'funds']), 
   HasSignature(['sender', 'recipient', 'funds']), 
   HasValidation()
)

❶ 每次都创建一个新的对象,并复制所有方法

使用此代码,每次需要新的交易时,您都必须重复此复杂构造函数调用。在大多数或所有情况下,考虑到大多数应用程序的性能瓶颈发生在 I/O 受限的调用(数据库、网络、文件系统等),复制状态是可以忽略不计的。然而,在那些您可能需要数百个此类对象的罕见情况下,关注这种情况是很重要的。

另一个需要注意的问题是混入对其嵌入的目标对象的隐式假设。您可能在我们讨论 3.5 节中的HasHash内部代码时看到了这个假设。下一个列表再次显示了这段代码。

列表 3.21 HasHash混入

const HasHash = keys => ({
  calculateHash() {
    const data = keys.map(f => this[f]).join('');       ❶
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;   
  }
});

❶ 在混入和整个对象之间创建了一个隐式依赖

如您所见,this 是整个对象及其混入之间的粘合剂。混入与目标对象的公共接口绑定得非常紧密,当目标对象进一步扩展且混入代码开始变化时,可能会变得脆弱。从光学角度来看,很难看到被耦合对象的形状。您需要导航到所有混入这种行为的对象,才能理解代码是否适用于所有这些对象。

关于是否使用基于链接的模型与基于继承的模型,并没有一条硬性规则。与所有软件一样,答案取决于您要解决的问题类型、团队的专长以及您的领域模型有多复杂。然而,与其他语言不同的是,JavaScript 给您提供了选择。

现在我们已经深入探讨了 JavaScript 的对象模型,是时候开始讨论函数了。关于 JavaScript 的一个有趣的事实是,函数在该语言中也是对象(即一等函数)。在第四章中,您将学习如何利用一等函数的优势,并了解它们如何使函数式编程成为可能。

概述

  • JavaScript 通过隐式链接和混入提供行为委托,以组合方式构建对象。

  • 行为委托是 JavaScript 中建模对象的自然方式。它使用 JavaScript 查找过程中存在的隐式委托机制和原型链。

  • 对象连接提供了一种基于结构化对象组合的简单方法,允许您通过附加(嵌入)来自其他独立对象的行为来构建对象。

  • 您可以使用混入动态地扩展对象(或类),并倾向于结构化组合而非继承。

  • 混入通过混入线性化机制解决了多重继承的问题。

  • JavaScript 通过使用扩展运算符为 Object.assign 提供了快捷方式,尽管 Object.assign 和扩展运算符不可互换。

第二部分. 函数

第二部分将第一部分中定义的对象赋予生命。函数是启动任何 JavaScript 应用程序齿轮的钥匙。不幸的是,大多数 JavaScript 开发者没有充分利用 JavaScript 函数的强大功能。通过利用函数在系统中也是对象的事实,你将开始欣赏 JavaScript 的乐趣。第二部分还介绍了将改变你结构 JavaScript 代码的新语法:管道和绑定操作符。

第四章首先教你如何以函数式的方式使用 JavaScript。你将学会将问题分解成小的任务,每个任务由一个函数表示,并将它们重新组合起来。为了实现这一功能,高阶函数允许你传递、返回和动态调用函数。你将学习如何使用部分应用(柯里化)函数来预先烘焙或配置你将组装到组合链中的函数。第四章还包括了 JavaScript 提议的管道操作符的预览,这将把像 Elixir 和 F#这样的函数式语言的强大功能带到 JavaScript 中。

我说过函数是对象,但第五章反转了这个定义,教你将对象视为像函数一样行为。在这里,你将了解在许多语言平台上变得普遍的一种模式:代数数据类型(ADT)。ADT 是一个具有简单、专用行为的对象。你可以使用 ADT 以组合、流畅的方式表示常见任务(数据验证、错误处理、空值检查等)。你将能够像调用函数一样轻松地执行对象——这是一个称为函子的概念。实际上,你一直在不知不觉中使用函子。数组和承诺(在第四部分讨论)可能是 JavaScript 中最常用的函子,你将学习它们各自的 API 中哪些部分使它们表现得像函子,甚至是单子。酷的是,你将能够提取这个 API 并将 JavaScript 提议的绑定操作符语法应用于任何类型的对象。

4 编写可组合、纯函数代码

本章节涵盖

  • 将命令式编码重构为声明式、函数式风格

  • 掌握 JavaScript 的高阶函数

  • 介绍纯函数和不可变性

  • 将纯逻辑与柯里化和组合结合

  • 使用无点风格提高代码的可读性和结构

  • 使用管道操作符创建原生函数链

如果你想知道明天主流编程语言中会有哪些特性,那么现在就看看函数式编程语言吧

——西蒙·佩顿·琼斯

如果对象是 JavaScript 的织物,那么函数就是用来缝合这些片段的针。我们可以在 JavaScript 中使用函数来描述对象集合(类、构造函数等),也可以用来实现业务逻辑,推动我们应用程序状态的机器。函数在 JavaScript 中如此普遍、灵活和强大,是因为它们也是对象:

Function.prototype.__proto__.constructor === Object;

JavaScript 具有高阶或一等函数,这意味着你可以将它们作为参数传递给另一个函数,或者作为返回值。通过高阶函数,你可以将复杂的软件模式简化为一小部分函数,使 JavaScript 比其他主流语言(如 Java 和 C#)更加简洁。

在第三章中,我们探讨了对象如何通过 OLOO(对象链接对象)在一定程度上组合其结构,通过混入(mixins)则更加充分。高阶函数也可以组合——不是结构上,而是通过连接在一起,作为回调传递来表示计算逻辑的序列。高阶函数是 JavaScript 最强大的特性,了解它们的最佳方式是通过函数式编程(FP)范式。

函数式编程(FP)是一股不可忽视的力量。如今,几乎不可能不提到 FP 的奇妙之处来谈论 JavaScript。我认为 JavaScript 之所以能够持续繁荣,得益于其对 FP 的支持,这也是多年前吸引我转向 JavaScript 的原因之一。虽然从理论上讲,FP 是一种老式思想,但它最近在 JavaScript 编码和应用设计中变得无处不在。好的例子包括 Underscore 和 Lodash 这样的库用于数据处理,React 和 Hooks 用于构建现代 UI,以及 Redux 和 RxJS 用于状态管理。实际上,如果你查看 2019 年 JavaScript 状态调查结果中最常用的实用库(2019.stateofjs.com/other-tools/#utilities),你会发现 Lodash、Underscore、RxJS 和 Ramda 排名靠前。所有这些库都增强了 JavaScript 的函数式能力。

从根本上讲,函数式编程(FP)提倡一种与更常见的结构化或命令式方式不同的解决问题的方法,这是我们所有人都习惯的方式。理解函数式编程需要掌握 JavaScript 的主要计算单元,这始终是函数。任何类型的对象定义都试图将数据(实例字段)与处理这些数据的逻辑(方法)关联起来。对象在粗粒度级别上进行组合,正如你在第三章中学到的。另一方面,函数更明显地分离数据(参数)和逻辑(函数体),并在更细粒度、更低的级别上进行组合。函数式程序由一组接收输入并使用这些数据产生结果的函数组成。

在本章中,我们将选取我们区块链应用的两个重要部分,通过使用函数式编程(FP)来改进它们。目标不是对应用进行完全的重设计。相反,我们将保持简单,采取更宽容的方法,将面向对象(OO)和函数式编程(FP)范式的好处结合起来——即混合模型。你将了解到,尽管命令式和函数式编程在基本原则上存在分歧,但在处理应用的不同部分时,你可以从它们的优势中受益。为了帮助你过渡到函数式编程的编码方式,我们将可视化一个命令式程序是如何转换为函数式程序的(第 4.2 节)。

除非你是专家,否则我建议你从本章中介绍的函数式编程的基本知识开始慢慢学习,然后找到将函数式编程嵌入到你的应用中的方法。以后,你想要在你的代码中走多远,这取决于你。核心业务逻辑通常是这种思维方式的好候选者。我们将通过将一些命令式代码重构为函数式代码的练习,让你感受到这两种方法的比较。

几年来,平台团队,包括 JavaScript,都在向他们的编程语言添加更多功能以支持函数式风格,这已经成为一个明显的趋势。例如,ECMAScript 2019(又称 ES10)添加了Array.prototype.flatflatMap,这对于以函数式方式使用数据结构至关重要。截至本文撰写时,TC39 的功能路线图上有一系列受函数式编程启发的提案正在上升,这将影响你未来几年编写 JavaScript 代码的方式。因此,现在了解这种范式将为你未来的学习做好准备。在这本书中,我们将探讨

在我们理解为什么这些功能如此重要之前,我们还有很长的路要走。理解这些功能始于理解函数式编程。

4.1 什么是函数式编程?

在本节中,我将提供一个合适的 FP 定义。首先,我会给你一个简短的例子,并概述一些函数式代码的基本特性。许多人将 FP 与数组 API mapreducefilter 相关联。你可能多次见过这些 API 的实际应用。让我们从一个快速示例开始,以唤醒你的记忆:确定数组中所有区块对象是否有效。对于这个例子,你可以假设可以跳过验证创世区块,并且所有区块都有一个 isValid 方法。通过使用数组 API 实现这个逻辑将类似于下面的列表。

列表 4.1 结合 mapfilterreduce

const arr = [b1, b2, b3];

arr
   .filter(b => !b.isGenesis())         ❶
   .map(b => b.isValid())               ❷
   .reduce((a, b) => a && b, true);     ❸

❶ 跳过创世区块(始终假设是有效的)

❷ 通过对每个区块调用 isValid,将区块数组转换为布尔值数组

❸ 对所有布尔值进行逻辑与操作,从 true 开始,以获得最终结果

如你所知,这些数组 API 被设计成高阶函数,这意味着它们要么接受一个回调函数,要么返回一个,并将大部分逻辑委托给提供的回调函数。你可能之前写过类似的代码,但从未从函数式编程的角度考虑过。一个具有 FP 意识的程序员总是更喜欢以这种方式编写由高阶函数驱动的代码。

除了几乎用函数做任何事情的趋势之外,FP 程序的重要特性还包括不可变性。在列表 4.1 中,尽管 arr 正在被映射和过滤,但原始的 arr 引用保持完整:

console.log(arr); // [b1, b2, b3]

不可变代码避免了由于意外更改应用程序状态而产生的错误,尤其是在处理可以在任意时间点运行的异步函数时。你知道 reversesort 方法会就地修改数组吗?如果你将原始数组对象传递给程序的其他部分会发生什么?现在结果是不可预测的。

一个总是返回可预测结果的不可变函数,给定一组参数,被称为纯函数,根据这个定义,我们来到了 FP 的定义:

函数式编程是艺术性地组合高阶函数,以纯粹的方式推进程序状态。

到目前为止,我已经简要地谈到了组合和纯代码。函数式编程将这些理念推向了实用的极致。现在,我将展开 FP 定义的关键部分:

  • 如你所知,高阶函数是可以接收函数作为参数或产生另一个函数作为返回值的函数。在 FP 中,你几乎可以用函数做任何事情,你的程序就变成了一个由组合连接起来的函数大集合。

  • 纯函数完全基于接收到的输入参数集来计算其结果。它们不会产生副作用——也就是说,它们不依赖于访问任何外部或全局共享的状态,这使得程序更可预测且易于推理,因为你不必跟踪意外的状态变化。

FP 开发者使用函数来表示任何类型的数据。

4.1.1 函数作为数据

你可以使用函数以表达式的形式表示数据。表达式可以立即评估以产生一个值,或者作为回调传递到代码的其他部分,以便在需要时评估。以下是一些示例:

  • 声明一个常量值:

    const fortyTwo = () => 42;
    

    正如常规常量一样,你可以将表达式赋给变量或将其作为函数参数传递。

  • 反射相同的值,也称为恒等函数:

    const identity = a => a;
    
  • 创建新对象或实现任意业务逻辑:

    const BitcoinService = ledger => { ... };
    

    这个函数被称为工厂函数,它总是产生一个新的对象。

  • 封装、私有数据(闭包):

    const add => a => b => a + b;  
    

    a 作为外部函数的闭包的一部分存储,并在整个表达式评估时在内部函数中引用:

    add(2)(3) === 5
    

注意:这里使用的箭头函数符号在语法上方便嵌入流畅的方法链中,例如当你使用 mapfilterreduce 等进行编码或需要一行表达式时。尽管这一章经常使用这种符号,因为它设计简洁,但常规函数语法同样适用于所有这些例子。

所有这些表达式(除了第一个)接收输入并返回输出。纯函数的返回值始终是输入的一个因子(除非它始终是一个常数);否则,这意味着你以某种方式打开了通往副作用和不纯代码的大门。

列表 4.2 展示了一个简单、直观的示例,说明了如何将这些包含计算或数据的表达式组合为高阶函数。代码仅在条件允许的情况下尝试执行一些数学运算;否则,它返回一个默认值。

列表 4.2 组合高阶函数

const notNull = a => a !== null;                           ❶
const square = a => a ** 2;

const safeOperation = (operation, guard, recover) =>       ❷
  input => guard(input, operation) || recover();

const onlyIf = validator => (input, operation) =>          ❸
  validator(input) ? operation(input) : NaN;

const orElse = identity;                                   ❹

const safeSquare = safeOperation(                          ❺
    square, 
    onlyIf(notNull),
    orElse(fortyTwo)
);

safeSquare(2); // 4
safeSquare(null); // 42

❶ 检查一个值是否不为空

❷ 执行安全操作;否则,调用恢复函数

❸ 仅当验证函数返回 true 时运行操作;否则,返回 NaN

❹ 使用恒等函数作为恢复,别名为 orElse

❺ 如果输入不为空,则计算数字的平方;否则,使用值 42 进行恢复

如果你查看 safeSquare 的结构,你会发现它由一些函数组成,这些函数清楚地传达了程序的意图。其中一些函数只携带数据(orElse);其他函数执行计算(square);一些函数两者兼具(onlyIf)。这个列表为你提供了一个很好的首次了解以函数方式编写的代码。

4.1.2 函数式方法

正如俗话所说,少即是多。函数式范式施加的限制旨在不是减少你可以做的事情,而是赋予你力量。在本节中,你将了解一组有助于你以函数式方式编码的指南。在第 4.3.2 节中,你将学习如何使用这些指南来解决任何类型的问题。

FP 程序员总是带着一套规则在心中编码。这些规则可能需要一些时间来适应,但随着实践会变得自然而然。然而,学习它们将非常值得你的时间,因为最终你会得到更可预测且更容易维护的代码。

在 JavaScript 中,函数式方法涉及这四条简单的规则:

  • 函数必须始终返回一个值,并且(除了一些例外情况)至少声明一个参数。

  • 函数运行前后应用程序的可观察状态不会改变;它是不可变的且无副作用的。每次都会创建一个新的状态。

  • 函数执行其工作所需的一切都必须通过参数传递或从其周围的父函数(闭包)继承,前提是父函数遵守相同的规则。

  • 调用相同输入的函数必须始终产生相同的输出。这个规则导致了一个被称为引用透明性的原则,该原则指出,一个表达式及其对应的值可以互换而不改变代码的行为。

通过这些简单的规则,我们可以从你的代码中移除副作用和突变,这些都是导致错误的主要原因之一。当一个函数遵守所有这些规则时,它被称为纯函数。听起来足够简单吗?换句话说,FP 是将遵循这些规则的函数组合起来以推进程序状态到最终结果的艺术。

根据这些规则,如何将打印到控制台这样的操作视为纯函数呢?它不是。超出其作用域的函数,在这种情况下执行 I/O,是有效果的——也就是说,它们会产生副作用。副作用还可以包括在其作用域之外读取/写入变量的函数、访问文件系统、写入网络套接字、依赖于随机方法如Math.random等。任何使函数结果不可预测的事物都被视为 FP 世界中的不良实践。

但是,当我们无法触及改变程序状态的那些事物时,如何从函数式编码中获得有用的东西呢?确实,处理不可变代码需要不同的思维方式,在某些情况下,需要以不同的方式处理问题,这是最难的部分。在 FP 中,对象不应该被直接操作和更改。对对象的更改意味着总是创建一个新的对象,类似于版本控制,其中每个更改,即使在同一行,也会产生一个新的提交 ID。至于读取文件、打印到控制台或其他任何实际的实际任务,我们需要学会以实际的方式处理这些情况。

到目前为止,我们一直在高层次上讨论 FP。为了使这次讨论更加具体,第 4.2 节比较了函数式和命令式代码。

4.2 函数式与命令式一览

为了让你开始理解这个范式转变,最好的办法是解决几个问题。我们将在本章中快速概述实现这一转变所需的技术,以便你能够全面了解在 JavaScript 中使用 FP(函数式编程)的过程。

本章我们要解决的第一个问题是如何以函数式的方式实现计算哈希的逻辑。在这里,我们将用安全的HasHash混合逻辑实现来替换不安全的算法。这个实现将为我们第二个示例提供一个良好的热身,该示例涉及通过仅使用纯函数来计算用户的数字钱包余额。在后一个练习中,我们将看到命令式代码到函数式的完全重构。余额计算涉及处理公共账本中的所有区块,并统计所有涉及特定用户的交易。如果用户作为收款人出现,我们增加资金;否则,我们减少资金。

为了提供一个比较框架,我们第二个问题的命令式版本看起来可能如下所示:

function computeBalance(address) {
   let balance = Money.zero();
   for (const block of ledger) {
      if (!block.isGenesis()) {
         for (const tx of block.data) {
            if (tx.sender === address) {
               balance = balance.minus(tx.funds);
            }
            else {
               balance = balance.plus(tx.funds);
            }
         }
      }
   }
   return balance.round();
}

你将学习如何将这个版本过渡到更函数式的风格,如下所示:

const computeBalance = address =>
  compose( 
    Money.round,
    reduce(Money.sum, Money.zero()),
    map(balanceOf(address)), 
    flatMap(prop('data')),
    filter( 
      compose( 
        not,
        prop('isGenesis')
      )
    ),
    Array.from
  );

你可能想知道这两个程序是否相同:第一个版本有循环和条件语句,而第二个版本没有。令人震惊的是,这两个程序是相同的。你可能认识这个代码块中的一些结构,比如mapfilter,但这个代码是如何工作的可能并不清楚,尤其是因为控制和数据流与命令式对应物相反。例如,舍入指令出现在顶部而不是底部。

再次审视 FP 风格,你可能也会想知道总计数在哪里进行。比较图 4.1 和图 4.2,以查看命令式和函数式方法的不同控制和数据流。

图片

列表 4.1 计算区块链中用户总余额的逻辑的命令式控制流程

命令式方法(图 4.1)不仅描述了状态的变化,还描述了这种变化是如何通过所有控制结构(循环、条件、变量赋值等)中的数据流产生的。另一方面,函数式方法(图 4.2)模型了一个单向状态转换流,隐藏了复杂的控制细节;它展示了获取最终结果所需的步骤,而不需要所有不必要的冗余。此外,正如之前提到的,每个步骤都是不可变的,这允许你集中精力在任何一步上,而不必担心其他部分(图 4.2)。

图片

列表 4.2 计算区块链中用户总余额的逻辑的函数式控制流程

图 4.2 模拟了声明式流程。将这个图想象成以食谱的形式总结命令式版本的要点。声明式代码是按照其将被读取的方式来编写的。为你的用户和同事编写代码,而不是为机器编写,这就是编译器的用途。

声明式语言的一个好例子是 SQL。在 SQL 中,声明式编程的美丽之处在于它关注你试图完成的事情,而不是如何完成,因此诸如代码拆分、循环和状态管理等平凡细节都被隐藏在各自的步骤中。拥抱函数式编程最困难的部分是放弃你的旧方法和命令式偏见。一旦你越过这条线,你就会开始看到你的代码的结构、可读性和可维护性如何提高,尤其是在 JavaScript 中,它给你以多种方式修改数据的自由。我们很幸运,JavaScript 允许我们以这种方式编写代码,我们应该充分利用这一点。

要拥抱函数式编程心态,你必须理解下一节中讨论的函数组合。

4.3 组合:函数式方法

通常来说,组合发生在数据结合形成类似数据或同一类型的数据时;它保留了类型。对象融合成新的对象(就像第三章中的混入),函数组合成新的函数(就像本章中的函数)。当混入创建新对象时,这个过程被称为粗粒度、结构化组合。本节将教你如何在函数级别组装代码,称为细粒度或低级别组合。

函数组合是函数式编程的骨架,它是你安排和组装整个代码的指导原则。尽管 JavaScript 不强制执行任何限制,但组合在函数遵循第 4.1.2 节中提到的纯度规则时最为有效。

在本节中,我们将实现 HasHash 混入的业务逻辑。首先,你将学习如何将我们在第二章开始使用的命令式 calculateHash 方法转换为更函数式的方式。我们将使用这个方法来填充我们在第三章开始构建的框架实现。其次,你将学习组合如何帮助你绕过具有副作用代码。这种能力很重要,因为在日常活动中,你通常需要将纯代码与有副作用的代码混合。

理解函数组合的最佳方式是从简单开始,只使用两个函数,因为这样同样的逻辑可以扩展到任意数量的函数。所以,给定函数 fg,你可以以某种方式对它们进行排序,使得第一个函数的输出成为第二个函数的输入,就像二进制管道一样,如图 4.3 所示。

图 4.3

列表 4.3 组合的高级图。箭头的方向很重要。组合是从右到左工作的。所以,在 fg 组合时,g 接收初始输入参数。然后 g 的输出作为 f 的输入。最后,f 的结果成为整个操作的结果。

在代码中,组合可以通过 f(g(args)) 简洁地表示。由于 JavaScript 会立即求值,它会尝试立即评估任何带有括号的前置变量的任何变量。如果你想表达两个函数的组合并将其赋给一个变量名,你可以将一个函数包裹在这个表达式周围。让我们称这个表达式为 compose(见下一条列表)。

列表 4.3 两个函数的组合

const compose = (...args) => f(g(...args));          ❶

❶ 使用 JavaScript 的扩展运算符来支持任意数量的参数

然而,列表 4.3 假设 fg 是存在于 compose 上下文之外的函数。我们知道这种情况是一个副作用。相反,将 fg 作为输入参数,并使用内函数的闭包,这样这段代码就可以与任何你提供的两个函数一起工作。闭包是与高阶函数一起工作得非常好的重要特性;我将在第 4.4 节中回顾它们。

让我们再次用另一个函数包裹这个表达式,并在 compose 旁边调用它 compose2

const compose2 = (f, g) => (...args) => f(g(...args))

这段代码更加灵活。因为 compose2 接受函数作为参数并返回一个函数,所以它是一个高阶函数。此外,请注意 compose2 从右到左(fg 之后)评估函数,以符合函数组合的数学定义。这里有一个更具体的例子:

const count = arr => arr.length;
const split = str => str.split(/\s+/);

你可以直接组合这些函数:

const countWords = str => count(split(str));

当你使用 compose2 时,相同的表达式变为

const countWords = str => compose2(count, split)(str);

这里有一个小技巧。因为你可以直接将函数赋给变量,所以每次你在表达式的左右两边重复输入参数时,你可以取消它,使你的代码更加紧凑:

const countWords = compose2(count, split);

countWords('functional programming');  // 2

图 4.4 展示了从图 4.3 的流程。

列表 4.4 分割后的顺序执行计数

compose2 比直接调用更优越,因为它能够将序列中涉及的函数的声明与其评估分离。这个概念类似于 OLOO,它允许你实例化一个可用的对象集并在需要时初始化这些对象。通过捕获传入的函数(fg)作为变量,我们可以将任何执行延迟到调用者提供输入参数时。这个过程被称为惰性求值。换句话说,表达式

compose2(count, split);

本身是由两个其他函数(就像由两个混合器组成的对象)组成的函数。然而,这个函数不会在调用者评估它之前运行;它在那里处于休眠状态。compose2 允许你从几个简单的函数中创建一个复杂、可用的表达式,并在需要时给它一个名字(countWords)以在其他代码部分中使用。让我们在下一节中更详细地说明这个例子;我们将处理一个稍微更现实的问题,它涉及到副作用。

4.3.1 与副作用一起工作

异常处理、记录到文件以及发起 HTTP 请求是我们每天都要处理的任务之一。所有这些任务都以某种方式涉及副作用,而且无法避免。在函数式风格的应用程序中处理副作用的方法是将它们隔离并从我们的主要应用程序逻辑中推离。这样,我们可以保持应用程序的重要业务逻辑纯净且不可变,然后使用组合将所有部分重新组合在一起。

要了解如何将纯代码与非纯代码分开,让我们处理另一个任务:计算文本文件中的单词数。假设文件包含单词“the quick brown fox jumps over the lazy dog。”为了简单起见,让我们使用 Node.js 内置的同步版本文件系统 API,如下一列表所示。

列表 4.4 计算文本文件中单词数的命令式函数

function countWordsInFile(file) {
   const fileBuffer = fs.readFileSync(file);
   const wordsString = fileBuffer.toString();
   const wordsInArray = wordsString.split(/\s+/);
   return wordsInArray.length;
}

countWordsInFile('sample.txt'); // 9

列表 4.4 简单但包含了几个步骤。正如在 calculateHash 中,你可以识别出四个清晰的任务:读取原始文件,将原始二进制解码为字符串,将字符串拆分为单词,以及计数单词。使用 compose 安排这些任务应该看起来像图 4.5。

图 4.5

列表 4.5 countWordsInFile 的逻辑源自其他单一职责函数(如 readdecodesplitcount)的组合逻辑。

首先,将每个任务表示为其自己的表达式。你之前看到了 countsplit;下一列表显示了其他两个任务。

列表 4.5 支持使用 countWordsInFile 的辅助函数

    const decode = buffer => buffer.toString();

    const read = fs.readFileSync;           ❶

❶ 创建别名以缩短文件系统 API 调用

在列表 4.5 中,我们给每个变量指定了一个特定的名字,使程序易于理解。在命令式代码中,变量名用于描述执行一个或一系列语句的输出(如果有),但这些变量名并不描述计算它们的过程。你必须解析代码以了解这个过程。当你推动更声明式风格时,函数名表明了每个步骤要做什么。让我们一步步实现这一点。直接调用可能看起来像这样:

const countWordsInFile = file => count(split(decode(read(file))));

你可以看到所有变量赋值都被移除了。但我们可以同意,随着复杂性的增加,这种代码风格可能会变得难以控制。让我们使用 compose2 来解决这个问题:

const countWordsInFile = compose2(
   compose2(count, split),
   compose2(decode, read)
);

每个 compose2 段可以表示为其自己的微模块,如图 4.6 所示。

图 4.6

列表 4.6 使用 compose2 改进的 countWordsInFile

但是等等——我们已经有了一个名为的抽象,可以处理 compose2(count,split)。这个抽象叫做 countWords`。让我们将其插入:

const countWordsInFile = compose2(
   countWords,
   compose2(decode, read)
);

你之所以可以像乐高积木一样交换代码片段,是因为引用透明性。换句话说,表达式是纯的,不依赖于任何全局或共享状态。两个表达式的结果将是相同的,因此不会改变程序的结果。插入这个抽象是更好的,但我们还可以做得更好。

对于更复杂的逻辑,你可能认为将函数成对分组需要输入很多。为了简化代码,最好有一个可以处理任意数量函数或函数数组的 compose2 版本。让我们重新利用 compose 并使用 Array#reducecompose2 结合,将两个函数的组合扩展到任意数量的函数。这种技术与一次组装一簇乐高积木而不是单个积木相似。

Array#reduce 中,reducer 是一个回调函数,它将你的数据累积或折叠成一个单一值。如果你不熟悉 reduce 的工作原理,这里有一个简单的例子。考虑一个函数 sum,它作为添加数字列表的 reducer:

const sum = (a,b) => a + b;

[1,2,3,4,5,6].reduce(sum);  // 21

Reducer 会将当前累积的结果 a 与下一个元素 b 相加,从数组的第一个元素开始。

同样地,compose2compose 的 reducer:

const compose = (...fns) => fns.reduce(compose2);

在这种情况下,reducer 一次取两个函数并将它们组合(相加),创建另一个函数。这个函数会被记住,然后在下一次迭代中与下一个函数组合,依此类推,最终得到一个由用户提供的所有函数组成的组合函数。函数组合是从右到左工作的,最右边的函数接收调用点的输入参数并启动整个过程。按照这个顺序,reduce 将数组中的所有函数折叠到声明中的第一个函数,这与函数组合的定义非常吻合。

现在,让我们在下一个列表中使用这项技术,将前面代码片段中显示的嵌套调用剥离开,形成一个更加精简、单向的流程。

列表 4.6 使用 compose 实现的 countWordsInFile

const countWordsInFile = compose(
    count,
    split,
    decode,
    read
);

countWordsInFile('sample.txt'); // 9

这段代码一开始看起来像伪代码,不是吗?如果你比较列表 4.4 和 4.6,你会发现后者是前者的一种基本框架;它是声明式的!这种编码风格也被称为无点式,我们将在第 4.6 节中讨论。

使用的模块化程度(细粒度函数级别或粗粒度模块,包含多个函数)取决于你;你可以随心所欲地进行组合(见图 4.7)。

图片 4-7

列表 4.7 可组合软件的结构。一个程序通过组合其他子程序来实现,这些子程序可以是函数那么小,也可以是另一个程序那么大。每个模块(考虑模块 N)都使用组合,最终到达组装单个函数的阶段。

无论你是组合简单的函数还是具有函数接口的整个代码模块,组装可组合代码的简单性都不会改变。(我们将在第六章中讨论导入和使用模块。)

现在你已经理解了组合模式,让我们用它来分解并简化我们的区块链应用的哈希逻辑。

4.3.2 复杂代码的分解

在第二章和第三章中,我们开始为我们的交易类创建一个名为 calculateHash 的哈希方法。这个哈希算法,或者它生成的摘要字符串,是不安全的并且容易发生冲突。在加密货币世界中,这种情况是不可接受的,所以让我们改进它。你会看到更函数式的设计如何使你能够轻松地用更安全的算法(使用 Node.js 的 crypto 模块)替换不安全的算法。以下是该代码的最后一个版本,供参考:

const HasHash = keys => ({ 
  calculateHash() {
    const data = keys.map(f => this[f]).join('');
    let hash = 0, i = 0;
    while (i < data.length) {
      hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
    }
    return hash**2;   
  }
});

使用第 4.1.2 节中概述的四个规则,这个函数/方法是否是纯的?在回答这个问题之前,让我们先进行一点逆向工程。首先,让我们将函数分解为其主要部分;然后我们将逐个分析每个部分。当我们将函数拆分时,我们将通过使用更函数式的方法将其重新组合。经过大量的练习,你会在这个过程中变得更熟练,这将成为你的第二天性。

calculateHash 执行两个主要任务,分为两个方法:

  • 从键集合中组装数据:

    function assemble(keys) {
       return keys.map(f => this[f]).join('');       
    }
    
  • 从这些数据中计算摘要或密文:

    function computeCipher(data) {
       let hash = 0, i = 0;
       while (i < data.length) {
          hash = ((hash << 5) - hash + data.charCodeAt(i++)) << 0;
       }
       return hash**2;
    }
    

这种思维过程本身是有益的,因为较小的函数比较大的函数更容易推理,你可以将这种思维过程深入到你认为合理的程度。现在来回答手头的问题:这两个方法是否是纯的?信不信由你,computeCipher 从实用角度来看是纯的,而 assemble 则不是。原因是 assemble 在尝试从 this 中读取属性时对其上下文做出了假设——这是我们在第三章中强调的混入的潜在缺点。使用独立的函数声明时,this 与函数绑定,而不是与周围的对象绑定。为了解决这个问题,我们可以使用 JavaScript 的动态绑定:

const HasHash = keys => ({
  calculateHash() {
    return compose2(computeCipher, assemble.bind(this, keys))();
  }
});

bind的调用将修正this引用,使其指向我们想要读取属性的外围对象。这段代码看起来更好,但依赖于这种类型的绑定可能会使其难以理解。此外,请记住,对环境的假设是一个副作用,我们在calculateHash中仍然有这个副作用。换句话说,推断状态的函数更难处理,因为其自身的行为依赖于外部因素。因此,您永远不会在纯 FP 代码库中看到对外部变量的引用,包括this。另一方面,明确其所需数据的函数是自文档化的,因此更易于使用和维护。

让我们将assemble改为一个明确其合同的功能,它接受用于散列过程的键集合以及要散列的对象:

function assemble(keys, obj) {
  return keys.map(f => obj[f]).join('');       
}

通过不做出任何假设,这个通用的独立函数完全脱离了其周围的类或对象上下文。在混合模型中,面向对象(OO)与函数式编程(FP)之间的微妙界限是我们将类的方法下的代码解耦或提取出来,并将其移动到一个或多个纯函数中。这种从可变、有状态组件到不可变组件的分离或隔离将帮助您避免对数据做出假设,并在合适的地方使用 FP。

让我们回到computeCipher,这是散列过程的核心。之前我提到,在按部就班的函数式编程中,是不允许有变化的。然而,在实践中,只要状态变化不会从函数的作用域中扩散或泄露,我们会接受使代码更容易实现的方案。在这种情况下,所有的变化都保持在本地,因此代码是可以接受的。

尽管如此,computeCipher并没有真正体现函数式精神;它仍然有点像过程式编程。通过将computeCipher视为其自身的微观环境,您可以看到其逻辑仍然依赖于设置和改变变量,如循环计数器i和累积的hash。您有改进的空间。使用mapreduce等 API 处理列表和数组很简单,但当你需要以迭代方式跟踪和重用状态时,递归是实现目标的最佳方式。接下来的列表显示了如何将while循环重构为递归函数。

列表 4.7 将computeCipher重构为递归函数

function computeCipher(data, i = 0, hash = 0) {
  if(i >= data.length) {
    return hash ** 2;
  }
  return computeCipher(         ❶
    data,
    i + 1,
    ((hash << 5) - hash + data.charCodeAt(i)) << 0
    );
}

❶ 在每次迭代中递归调用自身,并将更新的 hash 作为输入参数,以避免在原地分配和更改数据

这个函数使我们回到了四个主要的 FP 规则,没有任何权衡,以下是我们的收获:

  • 我们使用了 JavaScript 的默认参数语法来正确地捕获初始状态。

  • 我们消除了所有变量的重新赋值。

  • 我们创建了每个分支都产生返回值的表达式。

现在我们有了这两个更小、更简单的函数,我们可以将它们组合起来计算交易对象的密文:

calculateHash() {   
   return compose2(computeCipher, assemble(keys, this))();
}

但等等——我们有一个问题。compose2期望一个函数,但assemble运行时却得到了一个字符串,所以这段代码无法运行。让我们使用延迟求值对assemble进行一些小的调整,将其转换为一个接受键并返回一个准备接收调用对象的函数的高阶函数,利用闭包:

function assemble(keys) {
  return function(obj) {
     return keys.map(f => obj[f]).join('');
  }  
}

这个小小的调整就足以让我们达到更函数式的方法:

const HasHash = keys => ({
  calculateHash() {
    return compose2(computeCipher, assemble(keys))(this); 
  }
});

从本质上讲,我们对assemble所做的操作是将一个 2-arity(两个参数)函数转换为两个单-arity(单个参数)函数——这是称为 curried 函数评估的技术背后的前提。

4.4 Currying 和闭包

Currying 是一种技术,当函数需要多个参数时,它可以帮助你组合函数。它依赖于 JavaScript 对闭包的惊人支持。在本节中,我们将从对闭包的快速回顾开始,然后转向 curried 函数应用。

你可能熟悉闭包,这是 JavaScript 工作方式的核心。实际上,它们是 JavaScript 最吸引人的特性之一。为了使讨论集中,我不会深入探讨闭包,但会提供一些细节,以防你对它们不熟悉。

闭包是围绕函数创建的另一种作用域或上下文形式,允许函数引用周围的变量。当函数被调用时,JavaScript 保留对函数局部和全局词法环境变量的引用——即围绕此函数的所有语法上声明的变量。在规范中,内部引用[[Scope]]将一个函数与其闭包链接起来。在其他书籍和在线资源中,你可能看到使用术语“背包”来描述这种链接。我说“围绕”而不是“之前”,是因为提升的变量和函数也是函数闭包的一部分。以下列表提供了一个示例。

列表 4.8 使用闭包的作用域基础

const global = 'global';
  function outer() {
    const outer = 'outer';
    function inner() {
      const inner = 'inner'
      console.log(inner, outer, global);      ❶
    }
    console.log(outer, global);               ❷
    inner();
  }
outer();

❶ 打印内部外部全局

❷ 打印外部全局

你可以在图 4.8 中可视化这个例子。

列表 4.8 JavaScript 中的闭包机制允许任何函数引用其词法环境。最内层的函数可以访问其外部作用域(外部+全局)中的所有状态,而外部作用域可以访问其周围全局作用域中的所有内容。

JavaScript 允许你完全自由地访问从函数声明的应用状态的一部分,这意味着全局作用域以及任何在函数周围按词法出现的任何外部变量。本质上,闭包使所有这些状态都成为隐式函数参数。虽然有时访问这些变量确实很方便,但它也可能导致难以维护的代码。从理论上讲,函数式编程(FP)认为访问函数周围任何状态都是副作用;毕竟,我们是在外部访问。然而,在实践中,只要闭包是有限制的、作用域狭窄的,并且更重要的是,不会在包围的函数之外引起任何可观察的变化,使用闭包是允许的。使用闭包是编写 JavaScript 代码的方式,我们应该充分利用它们。闭包使 JavaScript 中的一些强大模式成为可能,柯里化就是其中之一。

4.4.1 柯里化函数应用

一个将参数列表展开为逐步、单一、嵌套的单参数函数的函数被称为柯里化。下面的列表展示了手动柯里化的一个简单示例。

列表 4.9 将 add 作为单独的单参数函数进行评估

const add = x => y => x + y;

const addThreeTo = add(3);        ❶

addThreeTo(7); // 10              ❷

❶ 加法操作只有在最后一个变量被绑定时才会发生。

❷ 绑定表达式,函数执行。

add 一次性接收 xy 参数不同,代码接受它们作为单独的函数,这些函数按顺序被调用。更正式地说,柯里化是将多参数函数(或 N 元函数)转换为评估为 N 个一元(元数 1)函数的过程。直到提供了整个参数列表并且所有函数都已评估,柯里化函数始终返回下一个函数。如果你退一步想,你可以看到柯里化是组合的另一种形式:你正在将一个复杂函数评估为多个简单函数。因为 add 接受两个参数 xy,所以它被评估为两个单参数函数:

add(3)(7); // 10

回到我们的单词计数示例,让我们使用这种手动柯里化技术来在解码文件 I/O 的结果二进制缓冲区时获得更多的灵活性。正如 decode 现在所做的那样,缓冲区的 toString 方法假设 UTF-8 编码:

const decode = buffer => buffer.toString();

大多数时候,这个方法是你想要的。但如果我们需要 ASCII 编码,灵活性会更好。与其重构 decode 以接受另一个参数,不如在中间嵌入另一个函数来捕获编码参数(带有自己的默认参数):

const decode = (encoding = 'utf8') => buffer => buffer.toString(encoding);

现在,我们可以调用一次 decode 来部分柯里化/设置编码参数,并将结果(剩余)函数作为如下组合表达式插入:

const countWordsInFile = compose(
    count,
    split,
    decode('utf8'),
    read
);

这段代码的声明式质量得到了进一步的提升,因为你不仅可以看到构成解决方案的步骤,还可以看到每个步骤中这些函数的属性或配置。

让我们继续以函数式的方式计算安全对象哈希的工作。考虑一个名为prop的辅助函数,再次手动 curry:

const prop = name => obj => obj[name] && 
   isFunction(obj[name]) ? obj[name].call(obj) : obj[name];

使用辅助函数isFunction

const isFunction = f =>
         f 
      && typeof f === 'function' 
      && Object.prototype.toString.call(f) === '[object Function]';

prop可以通过名称从任何对象中访问属性。你可以部分绑定name参数,创建一个具有name在其闭包中的函数,然后接受从中提取命名属性的对象。考虑这个简单的例子:

const transaction = {
   sender: 'luis@tjoj.com',
   recipient: 'luke@tjoj.com',
   funds: 10.0
};

const getFunds = prop('funds');
getFunds(transaction); // 10.0

你也可以创建一个函数,通过映射prop到一个键的数组上,将多个属性提取到一个数组中:

const props = (...names) => obj => names.map(n => prop(n)(obj));

const data = props('sender', 'recipient', 'funds');

data(transaction); // ['luis@tjoj.com', 'luke@tjoj.com', 10.0]

在单个对象上调用prop并不像在对象集合上调用它那样令人兴奋。给定一个包含三个交易对象(分别有资金 10.0、12.5 和 20.0)的数组,你可以将prop映射到它上面:

[tx1, tx2, tx3].map(prop('funds')); // [10.0, 12.5, 20.0]

[tx1, tx2, tx3].map(prop('calculateHash')); 

// [64284210552842720, 1340988549712360000, 64284226272528960]

在这段代码中,高阶函数prop('funds')直到map使用它才产生结果,这是很方便的。但当函数变得更加复杂时,使用扩展箭头语法编写的函数的尴尬记法变得难以阅读,更不用说多函数评估——add(x)(y)——是繁琐的。你可以使用curry函数自动化将手动展开成多个函数的过程。

curry自动化了我们迄今为止所进行的手动 curry 过程,将多个参数的函数转换为多个单参数嵌套函数。因此,像add这样的函数

const add = a => b => a + b;

可以写成

const add = curry((a, b) => a + b);

curry的奇妙之处在于它动态地改变了函数的评估方式,并平滑了部分传递参数所需的语法。你可以分步骤地调用add,如add(3)(7),或者更理想的是同时调用add(3,7)

注意:在理论上,currying 是一种更严格的偏应用形式,它要求返回的函数每次只接受一个参数。在偏应用中,返回的函数可以接受一个或多个参数。

compose一样,你可以从任何 FP 库(如 Ramda、underscore.js 等)导入curry,但研究其实现很有趣;它使用了大量的现代 JavaScript 惯用语法(如 rest 和 spread 操作符)来操作函数对象。它还使用了一点点反射来动态确定函数的长度(这个话题我将在第七章中再次提及)。

为了保持纯 FP 精神,避免循环和变量的重新赋值,你可以非常优雅地将curry实现为一个递归的箭头函数。你也可能找到采用更命令式、迭代方法的版本:

const curry = fn => (...args1) =>
  args1.length === fn.length
    ? fn(...args1)
    : (...args2) => {
        const args = [...args1, ...args2]
        return args.length >= fn.length ? fn(...args) : curry(fn)(...args)
      };

下面的列表展示了如何使用curry来增强propprops

列表 4.10 propprops的 curry 版本

const prop = curry((name, obj) =>                                         ❶
  obj[name] && isFunction(obj[name]) ? obj[name].call(obj) : obj[name]
);

const props = curry((names, obj) => names.map(n => prop(n, obj)));       ❷

❶ 内部,curry 添加了运行时支持,将(name, a)对重写为部分评估的参数 name => a => ...

❷ 我们不再使用 varargs ...name 参数,因为这只能作为最后一个(或唯一的)参数。

不幸的是,并非所有内容都可以 curry 化

尽管curry非常神奇且强大,但在 JavaScript 中,当它依赖于诸如可变参数或具有默认值的参数等特性时,curry可能会改变函数预期的行为,存在一些边缘情况。

在列表 4.11 中,为了让propscurry一起工作,我们需要将...names改为一个普通的、非可变参数names,它允许作为第一个参数出现。可变参数始终需要出现在函数签名末尾。

另一个需要注意的更微妙的问题是默认参数。查看本节中curry的实现,你可以看到它依赖于Function.length。在 JavaScript 中,这个属性有点棘手,因为它不会计算具有默认值的函数,如下面的代码片段所示:

const add = (a, b) => a + b;         // add.length = 2 
const add = (a = 0, b = 0) => a + b; // add.length = 0

再次强调,curry要求你在评估之前满足函数的所有参数。直到那时,curry会继续返回带有剩余待传递参数的部分应用函数。这种情况也防止了运行具有未满足或undefined参数的函数。正如我之前所说的,add(3)返回一个函数给调用者,但add(3,7)会立即评估为10。无法调用具有未满足参数集的函数,这真是太好了!

在 curried 函数中,参数的顺序很重要。通常,我们在面向对象的代码中不会过多关注顺序。但在 FP 中,参数顺序至关重要,因为它在很大程度上依赖于部分应用。在本章中展示的所有 curried 函数中,请注意参数的排列是为了从部分评估中受益。因此,最好将最静态、固定的参数放在前面,并允许最后的参数是更动态的调用特定参数,就像prop的定义一样:

const prop = curry((name, obj) => 
  obj[name] && isFunction(obj[name]) ? obj[name].call(obj) : obj[name]
);

最后一个参数obj被留作未限制(自由)的,这样你就可以在映射数组时自由地从任何对象中提取特定字段,例如。给定交易tx1tx2,分别代表 10 美元和 12.50 美元,你可以创建一个新的函数fundsOf,它具有部分绑定的funds属性键。现在你可以将此函数应用于具有该键的任何对象,甚至可以将此函数映射到类似对象的数组中:

const fundsOf = prop('funds');
fundsOf(tx1); // 10.0
fundsOf(tx2); // 12.5

现在你已经了解了 currying 和组合,你可以将它们结合起来,在HasHash中的calculateHash逻辑创建一个函数式版本。单独来看,currycompose提供了很多价值,但结合起来,它们更加强大。

4.4.2 curry 和组合动态搭档

本章的一个目标是为我们的HasHash混入中的calculateHash生成一个更安全的哈希摘要。到目前为止,我们使用computeCipher的递归定义处于这个阶段:

const HasHash = () => ({
   calculateHash() {
      return compose(computeCipher, assemble)(this); 
   }
});
function computeCipher(data, i = 0, hash = 0) {
  if(i >= data.length) {
    return hash ** 2 
  }
  return computeCipher(
          data,
          i + 1,
          ((hash << 5) - hash + data.charCodeAt(i)) << 0
        );
}
const assemble = ({ sender, recipient, funds })  
      => [sender, recipient, funds].join('');

HasHash 了解其周围的环境(即由 this 引用的交易对象),但函数保持纯净且无副作用。通过构建这些小的纯逻辑岛屿,我们可以将这些代码放一边,减轻跟踪所有发生的事情的心理负担。

但我们还没有完成。现在每个函数都是独立的,让我们进一步改进代码,使 HasHash 更安全且适用于其他区块链域对象。我们将进行两项额外的更改:

  • HasHash 与任何对象集成。这次更改涉及重构 assemble 以接受用于哈希的对象部分数组,这使我们能够在将 HasHash 分配给其他类时具有额外的灵活性。这次更改的部分也涉及将调用映射到 JSON.stringify,以确保任何提供的对象(原始或非原始)都转换为它的字符串表示。JSON.stringify 是确保我们从任何类型的数据中获取字符串的好方法,并且它工作得很好,前提是对象不是特别长:

    const assemble = (...pieces) => pieces.map(JSON.stringify).join('');
    

    这行代码创建了一个包含必要对象数据的字符串,用于向哈希代码播种。以下是一个示例:

    const keys = ['sender', 'recipient', 'funds'];
    const transaction = {
       sender: 'luis@tjoj.com',
       recipient: 'luke@tjoj.com',
       funds: 10   
    };
    
    assemble(keys.map(k => transaction[k]));
    
    // ["luis@tjoj.com","luke@tjoj.com",10]
    
  • 实现一个更安全的哈希。让我们使用 Node.js 的 crypto 模块。此模块为您提供使用广泛采用的算法生成哈希的选项,例如 SHA-2,以及不同的输出编码,例如十六进制:

    const computeCipher = (options, data) =>
      require('crypto')
         .createHash(options.algorithm)
         .update(data)
         .digest(options.encoding);
    

下一个列表显示了创建简单对象 SHA-256 表示的示例。

列表 4.11 从对象的 内容计算 SHA-256 值

computeCipher(
    {
        algorithm: 'SHA256',       ❶
        encoding:  'hex'           ❷
    },
    JSON.stringify({
        sender: 'luis@tjoj.com',
        recipient: 'luke@tjoj.com',
        funds: 10
    })
   ); // '04a635cf3f19a6dcc30ca7b63b9a1a6a1c42a9820002788781abae9bec666902' 

❶ SHA-2 是一组安全的加密哈希函数。字符串越长,安全性越高。在这种情况下,我使用的是 SHA0256,它在业界得到了广泛的应用

❷ 返回一个十六进制编码的字符串,而不是二进制缓冲区,这使得输出更易于阅读

哈希计算必须是可靠和可预测的;给定相同的输入,它必须产生相同的输出。如果您还记得我们的起始指南,可预测性方便地指向引用透明性的原则。现在我们只剩下 compose 这两个:

compose(computeCipher, assemble);

但这里有一个问题。你能看出为什么这段代码不会工作吗?computeCipher 不是一个单参数的函数。以这种方式调用此函数会将 assemble 的输出传递到 options 部分,并将 undefined 传递给 data,这将破坏整个流程。我们可以通过部分配置 computeCipher 以产生一个函数,该函数被插入到 compose 中来解决这种不匹配。首先,将 curry 添加到函数定义中:

const computeCipher = curry((options, data) =>
  require('crypto')
     .createHash(options.algorithm)
     .update(data)
     .digest(options.encoding));

然后分步调用 computeCipher,就像您对 addprop 所做的那样:

compose(
   computeCipher({
        algorithm: 'SHA256',
        encoding:  'hex'
    }),
   assemble);

下一个列表将所有内容组合在一起。

列表 4.12 HasHash 的最终实现

const HasHash = (
  keys,
  options = { 
       algorithm: 'SHA256', 
       encoding:  'hex' 
    }
) => ({
  calculateHash() {
     return compose(
      computeCipher(options),
      assemble,
      props(keys)
    )(this);           ❶
  }
});

❶ 当它是您自己的代码时,传递此方法效果很好,但当你使用第三方库时,最好只发送所需数据的副本。

列表 4.9 在calculateHash的实现中使用curry进行组合。每个框上方的标签显示了完整的函数签名。computeCipherprops已被 curry 化并部分应用。所有函数的最右侧参数(或唯一参数)用于接收链中一个函数的输入。

图 4.9 展示了使用简单的交易对象字面量来表示数据流:

    const hashTransaction = Object.assign(
      {
        sender: 'luis@tjoj.com',
        recipient: 'luke@tjoj.com',
        funds: 10
      },
      HasHash(['sender', 'recipient', 'funds'])
    );

    hashTransaction.calculateHash(); 

    // '04a635cf3f19a6dcc30ca7b63b9a1a6a1c42a9820002788781abae9bec666902'

我们使用了currycompose来驱动calculateHash的执行,这些被称为函数组合子——组合其他函数的函数。组合子没有自己的特殊逻辑;它们要么作用于函数的结构本身,要么协调它们的执行。curry操作参数,以便它们可以逐个评估。同样,compose负责将一个函数的输出传递给下一个函数的输入。所有这些控制流都在这段代码中被抽象化:

compose(
    computeCipher(options),
    assemble,
    props(keys)
  )(this);

this引用的对象进入组合链,而在另一边,你得到它的哈希值。在这里传递原始上下文(this)是安全的,因为所有这些函数都是无副作用的。但当你或其他人更改它们,或者当你与其他你不知道的函数集成时会发生什么?在 JavaScript 中,对象值是通过引用存储的。当你将对象传递给函数时,传递的是引用的值,而不是对象本身(与原始数据类型不同)。你可以通过发送一个副本来防止任何不希望的突变,并使你的代码更具可预测性。这种情况也是输入对象仅使用所需数据的好机会。为此,让我们使用Object.fromEntries API。这个 API 允许你将任何键/值对的可迭代对象(ArrayMap等)转换为对象。HasHash已经有了要哈希的数据的键列表,因此很容易构建一个只包含该数据的对象:

calculateHash() {
    const objToHash = Object.fromEntries(
       keys.map(k => [k, prop(k, this)])
    );
    return compose(
      computeCipher(options),
      assemble,
      props(keys)
    )(objToHash);
  }

通过这个片段,我们已经完成了HasHash混合。正如你所见,它不是一个完全纯净的对象,因为它依赖于全局的this存在,它指向HasHash被分配的对象的原型。但我们可以称它为混合型,因为它依赖于纯函数来完成其工作。

我们仍然需要处理其他编码示例:从区块链交易中计算用户的总余额。在迄今为止看到的代码片段中,我们始终使用常规浮点数来表示funds金额。我这样做是为了保持简单。在现实世界中,funds总是有两个组成部分:数值和货币单位(如$10.0 或฿10.0)。为了表示货币,让我们创建一个名为Money的对象,设计为不可变。

4.5 使用不可变对象

纯洁性的概念不仅限于函数,也扩展到了对象。到目前为止,我们区块链应用的主要实体(如BlockTransaction等)是可变的,这意味着你可以在实例化后轻松更改对象的属性。我们做出这个设计决策是为了允许这些对象在更新时重新计算它们的哈希值,并且如果需要,允许对象通过混入或传统的基于类的继承动态扩展。我们也可以选择使对象不可变,这种设计有许多很好的用例,其中之一我们将在本节中探讨。

考虑一个名为Money的对象,它代表正在交易的货币金额。Money是一个具有永久价值的对象:一角硬币始终等于一角硬币。其身份由金额和货币名称给出。如果你将一角硬币改为五分硬币,从概念上讲,那将是一个新的Money实体。想想看改变日期值意味着什么。这不是一个不同时间点的值吗?在笛卡尔平面上改变一个点,那又是另一个点。在工业界广泛使用的其他一些适合这种设计类型的例子包括DateTimePointLineMoneyDecimal

const coord2_3 = Point(2, 3);
coord2_3.x = 4; // No longer the same point!

不可变对象在工业界是一个已知的模式。在第二章中,我们讨论了如何使用数据描述符在创建时定义不可变字段,Object.create允许你这样做。一般来说,不可变对象是指你不能设置任何字段的那些对象。其数据描述符对于所有字段都有writable: false。基于此的一个流行模式被称为值对象模式。值对象在创建时是不可变的,并且有一些字段用于描述身份和比较。与纯函数强加的规则类似,以下是一些在决定是否使用此模式时需要记住的指南:

  • 没有全局身份 —— 无法通过某种 ID 获取值对象的实例。相反,事务通过其哈希值(transactionId)进行全局标识。同样,一个Block通过索引值或账本中的位置来标识。

  • 不可修改 —— 当对象被实例化后,你无法更改其任何属性。这样做会导致创建一个新的对象或产生错误。

  • 不可扩展 —— 你不能动态地向(混入)此对象添加属性,也不能从它中移除属性,并且不能从中派生新的对象(继承)。

  • 定义自己的等价性 —— 由于没有唯一的 ID,实现一个能够根据其属性比较两个值对象的equals方法是有益的。

  • 覆盖toStringvalueOf——值对象需要无缝地以字符串或原始值的形式表示。覆盖如toStringvalueOf这样的方法会影响对象在数学符号旁边的行为或与字符串连接时的行为。

对于我们的区块链,我们将 Transactionfunds 字段表示为 Money 对象。在我们查看内部细节之前,让我们看看它是如何被使用的:

Money('B|', 1.0);  // represents 1 Bitcoin
Money('$', 1.0);  // represents 1 US Dollar
Money.zero('$');  // bankruptcy!

让我们通过使用一个简单的函数而不是类来实现 Money。与类构造函数不同,普通函数可以进行柯里化。因此,我们可以做如下事情:

const USD = Money('$');

USD(3.0);          // $ 3
USD(3.0).amount;   // 3
USD(7.0).currency; // $
[USD(3.0), USD(7.0)].map(prop('amount')).reduce(add, 0); // 10 

此外,Money 支持一些关键操作,如加法、减法、四舍五入,以及(最重要的是)实现某种相等性。为什么相等性很重要?如果没有描述其身份的字段,值对象只能通过其属性进行比较:

USD(3.0).plus(USD(7.0)).equals(USD(10));  // true

Money 的情况下,我们不感兴趣使用继承或任何前几章中提到的实例化模式,这些模式旨在构建复杂对象;使用一个简单的函数返回一个对象字面量就足够了。这种模式也被称为函数工厂。

为了实现“封闭修改和扩展”的保证,我们可以分别使用 JavaScript 内置的 Object.freezeObject.seal 方法。这两种方法都很容易组合。下一个列表显示了 Money 的实现细节。

列表 4.13 Money 值对象的详细信息

const BTC = 'B|';

const Money = curry((currency, amount) =>
  compose(                                                 ❶
    Object.seal,
    Object.freeze
  )(Object.assign(Object.create(null),                     ❷
    {
      amount,
      currency,
      equals: other => currency === other.currency &&
          amount === other.amount,
      round: (precision = 2) => 
          Money(currency, precisionRound(amount, precision)),
      minus: m => Money(currency, amount - m.amount),
      plus: m => Money(currency, amount + m.amount),
      times: m => Money(currency, amount * m.amount),
      compareTo: other => amount - other.amount,
      asNegative: () => Money(currency, amount * -1),
      valueOf: () => precisionRound(amount, 2),            ❸
      toString: () => `${currency}${amount}`            
    }
  ))
);

// Zero
Money.zero = (currency = BTC) => Money(currency, 0);       ❹

// Static helper functions
Money.sum = (m1, m2) => m1.add(m2);

Money.subtract = (m1, m2) => m1.minus(m2);

Money.multiply = (m1, m2) => m1.times(m2);

function precisionRound(number, precision) {
  const factor = Math.pow(10, precision);
  return Math.round(number * factor) / factor;
}

❶ 新对象首先被冻结,然后被密封。

❷ 使用 Object.assign 和 Object.create(null) 创建一个没有任何原型引用的值对象,使其更接近系统中的真实值

❸ 重写 Object#valueOf 和 Object#toString 以帮助 JavaScript 的类型强制转换

❹ 在对象字面量定义外部实现以创建静态方法

列表 4.13 中的代码包含了大量的功能,因为我们试图得到一个表现得像原始类型一样的对象。除了使对象不可变和封闭扩展之外,我们通过使用 Object.assign(第三章)在单步中从“null 对象”原型 Object.create(null) 实例化它。Money 不会自动继承 Object.prototype 的任何成员字段(如 toStringvalueOf),特别是这个情况下不适用:Object#isPrototypeOf。缺点是,你必须负责正确实现这些属性。

因此,我们将实现 toStringvalueOf 以如下方式工作:

const five = Money('USD', 5);
console.log(five.toString()); // USD 5

valueOf 方法有点更有趣。与 toString 不同,你不需要直接调用 valueOf。当对象被期望以原始类型行为,尤其是在数值上下文中时,JavaScript 会自动调用它。当对象与数学符号相邻时,我们可以将 Money 降级(强制)为 Number。然后,如果需要,你可以用适当的货币单位将那个结果包装回 Money

five * 2;    // 10
five + five  // 10
Money('USD', five + five).toString() // USD 10

现在,让我们看看将 Object.sealObject.freeze 应用到这些对象上的效果。我们使用了 compose 来按顺序应用每个方法。Object.freeze 阻止你更改其任何属性(记住我们假设严格模式),如下所示:

const threeDollars = USD(3.0);
threeDollars.amount = 5;

// TypeError: Cannot assign to read only property 'amount' of object '[object Object]'

Object.isFrozen(threeDollars); // true

此外,Object.seal 阻止客户端扩展它:

threeDollars.goBankrupt = true;
// TypeError: Cannot add property goBankrupt, object is not extensible

delete threeDollars.plus;
// TypeError: Cannot delete property 'plus' of [object Object]  

Object.isSealed(threeDollars); // true

最后,请注意,Moneyplusminus等)中的所有方法都使用写时复制语义,返回新对象而不是改变现有对象,并接受我们之前讨论的纯度原则。

纯对象操作

在函数式编程中,有一种在对象内部操作数据的方法称为镜头,这样你就不必自己实现写时复制版本。

镜头允许你针对对象中的特定属性或路径进行定位,这样你就可以以可组合和不可变的方式对其进行更改。幕后,它使用写时复制,但所有操作都是自动完成的。你可以通过探索 Ramda 镜头 API(ramdajs.com/docs/#lens)来了解更多关于这项技术的信息。

到目前为止,你已经学会了如何通过组合和柯里化来构建函数式代码。原始数据可以随意操作和修改(因为它设计上是不可变的),而自定义数据对象则需要更多的关注。在后一种情况下,在适当的时候使用值对象或向代码传递数据副本可以帮助避免许多讨厌的 bug。

在掌握所有基础知识之后,让我们通过一种称为无点编码的范式来改进我们的代码设计——这是实现计算用户数字钱包余额逻辑所需最后一块拼图。

4.6 无点编码

无点编码是采用声明式编程的副产品。你可以使用无点编码而不必使用 FP。但是,因为无点编码的全部都是为了提高代码的可读性并使其更容易解析,所以 FP 强加的保证进一步推动了这一目标。

学习无点风格是有益的,因为它允许开发者无需深入了解内部结构就能一眼看懂你的代码。无点编码指的是一种函数定义不明确标识它们接收的参数(或点)的风格;它们隐式(默示)地通过程序的流程传递,通常在currycompose的帮助下。移除这种杂乱通常可以揭示一个更简洁的代码结构,人们可以轻松地通过视觉解析。通过能够看到森林而不是树木,你可以发现可能由不良逻辑或对需求做出不良假设而引发的高级 bug。

由于 JavaScript 有柯里化和一等函数,我们可以将一个函数赋给一个变量,将这个命名的变量用作compose的参数,从而有效地创建我们代码的可执行轮廓。在本节中,你将了解使用无点风格在 JavaScript 中的好处,例如

  • 通过减少语法噪声来提高代码的可读性

  • 使组合更清晰和简洁

  • 避免引入不必要的参数名称

  • 构建描述构成你的应用程序的动作和任务的词汇表

在我们开始学习这种风格之前,将其与非无点代码进行比较是有帮助的。让我们快速回顾一下:

function countWordsInFile(file) {
   const fileBuffer = fs.readFileSync(file);
   const wordsString = fileBuffer.toString();
   const wordsInArray = wordsString.split(/\s+/);
   return wordsInArray.length;
}

要了解这个函数的高级功能,你必须阅读每个语句并追踪流程。函数中的每个语句都详细描述了每个函数是如何被调用以及每个函数在调用点接收的参数(或点)。

另一方面,使用compose消除了不必要的开销,并专注于创建更高层次逻辑表示所需的高级步骤。当我们之前将 FP 应用于countWordsInFile时,我们得到了以下代码:

const countWordsInFile = compose(
    count,
    split,
    decode('utf8'),
    read
);

这个程序是无点代码的。注意,这个程序中没有告诉你如何调用countWordsInFile。你看到的所有东西只是这个函数的结构或涉及哪些步骤。因为函数的签名(以及所有嵌套函数)缺失,你可能觉得这种风格对不熟悉这些函数的人来说代码有点难以理解。这一点是有效的,我见过它如何使使用调试器变得有点更具挑战性。但对于熟悉这种风格并且熟悉调试器的人来说,无点代码使组合更加清晰,并允许你将高级步骤视为即插即用的组件。

无点代码的一个常见类比是乐高积木。当你从远处观察一个乐高结构时,你看不到将所有东西连接在一起的销钉,但你仍然能欣赏到整体结构。如果你再次查看countWordsInFile的命令式版本,这里的“销钉”指的是连接一个语句到下一个语句的中间变量名。

假设你正在处理另一个任务,比如从 JSON 文件中计数序列化的块数据数组。从高层次来看,你应该能够看到这段代码的结构与前面的代码片段相似。唯一的区别是,你将处理解析元素数组,而不是处理空格分隔的单词。纯函数很容易替换,因为它们除了自己的参数之外不依赖于任何外部数据。countBlocksInFile的实现简单地将split替换为JSON.parse

const countBlocksInFile = compose(
    count,
    JSON.parse,
    decode('utf8'),
    read
);

再次,这种交换是显而易见的,因为无点代码清理了传递函数和参数的过程,让你能够专注于手头的任务,比如将一个红色乐高积木换成绿色乐高积木。此时应该很明显,countBlocksInFile是另一个可以进一步组合(固定)的乐高捆绑(模块)。你可以从这个基本思想(图 4.10)构建整个复杂的应用程序。

图片

列表 4.10:构建整个应用程序的函数组合

所有这些乐高积木都成为了你的应用程序乐高套件中的分类法,或者说词汇表。

现在你已经了解了如何通过使用组合和柯里化来结构化你的代码,让我们来处理一个更复杂的需求式到函数式的转换,这涉及到计算用户的数字钱包余额。

4.7 需求式到函数式的转换

在第三章,我看了Wallet类的骨架(列表 3.9),但故意省略了balance方法。下面是这段代码的再次展示:

class Wallet {
  constructor(publicKey, privateKey) {
    this.publicKey = publicKey
    this.privateKey = privateKey
  }
  get address() {
    return this.publicKey
  }
  balance(ledger) {
    //... 
  }
}

现在我们已经准备好填充复杂的逻辑细节。为了计算用户的余额,给定一个区块链(账本)对象参数,我们需要累计自账本开始以来为该用户挖掘的所有区块中的所有交易。我们可以省略创世区块,因为我们知道它不包含我们感兴趣的数据。

让我们再次以命令式思维来审视这个问题,并将其与函数式思维进行比较。这次,我们将使用我们迄今为止所学到的所有技巧。算法可以看起来像以下列表。

列表 4.14 计算总余额的命令式算法

balance(ledger) {
   let balance = Money.zero();
   for (const block of ledger) {                     ❶
      if (!block.isGenesis()) {
         for (const tx of block.data) {
            if (tx.sender === this.address) {
               balance = balance.minus(tx.funds);    ❷
            }
            else {
               balance = balance.plus(tx.funds);     ❷
            }
         }
      }
   }
   return balance.round();
}

❶ 账本(ledger)是一个区块链对象,遍历它将返回每个区块。你将在第七章学习如何使任何对象可迭代。现在,你可以安全地假设它是一个区块的数组。

❷ 如果用户是交易的发送者,我们将折扣交易中的金额;否则,我们将它添加到累计余额中。

将此算法与 FP 指南进行比较,你可以看到它涉及到遍历区块链数据结构,这意味着你需要在你遍历所有区块以及每个区块的交易时,保持余额的累计计数。在每次迭代中,有很多分支来适应不同的条件——你可以称之为命令式的“灾难金字塔”。让我们回顾一下图 4.11 中的流程。

图 4-11

列表 4.11 计算区块链中用户总余额的逻辑的命令式流程控制

所有菱形框代表分支逻辑,嵌套在代表循环的循环箭头中。可以说,这个图并不容易解析;它代表了一小段代码。

此外,列表 4.14 在引用this以访问钱包属性和每次迭代重新分配balance的方式中存在副作用。通过使用混合(FP + OO)方法重构此代码涉及

  • 将数据显式作为函数参数,而不是隐式地

  • 将循环和嵌套条件转换为使用mapfilter的流畅数据转换

  • 使用不可变的reduce操作去除变量重新分配

最佳做法是将逻辑提取到它自己的函数中,使其无副作用,并且让balance内部委托给它,并带上所有初始数据。我们可以称这个新方法为computeBalance

balance(ledger) {
   return computeBalance(this.address, ledger);
}

有理由从我们熟悉的方便的数组基本操作开始:mapfilterreduce。以下代码代表了相同的算法,功能上受到启发。图 4.12 显示了新的流程将看起来是什么样子。

在列表 4.15 中展示的函数式方法中有一个小的注意事项,可能会有些令人困惑。因为数据处理流程涉及到处理区块的数组,以及每个区块内的交易数组,我们被迫处理一个数组数组。为了简化问题,当你遇到这个问题时,最好的办法是将这些结构展平。你将在第五章中了解到这种情况在函数式代码中很常见。现在,我们将使用Array#flat来展平嵌套结构。

列表 4.15 使用 FP 在Wallet中计算余额

function computeBalance(address, ledger) {
   return Array.from(ledger)                   ❶
    .filter(not(prop('isGenesis')))            ❷
    .map(prop('data'))                         ❸
    .flat()                                    ❹
    .map(balanceOf(address))                   ❺
    .reduce(Money.sum, Money.zero())           ❻
    .round();                                  ❽
}

Array.from将任何可迭代对象转换为数组

❷ 使用notprop实用函数调用isGenesis(检查代码仓库以获取实现)

❸ 从链中读取交易(数据)集合

❹ 上一步骤生成的数组数组被展平成一个单一数组。

❺ 从每个交易中计算此用户的余额

❻ 使用Money.sum函数作为 reducer,从Money.zero开始,并总计总数

❽ 在结果上调用Money.round方法

在很大程度上,你可以看到这个算法是命令式逻辑的再世,但利用了数组中的高阶函数来连接每个转换的部分,彻底改变了数据的流程。此外,我们通过像reduce(Money.sum, Money.zero())这样的代码执行加法,这也说明了函数式程序往往表现出的数学性质。

图片

列表 4.12 计算区块链中用户总余额的逻辑的函数式流程控制

为了完整性,这里提供了在函数式版本中使用的balanceOf函数,现在作为一个 lambda 表达式实现,它将交易中的用户 ID 映射到正或负货币值,这取决于该用户是发送者还是接收者:

const balanceOf = curry((addr, tx) =>
    Money.sum(
      tx.recipient === addr ? tx.funds : Money.zero(),
      tx.sender === addr ? tx.funds.asNegative() : Money.zero()
    );
  )

如果你再次查看列表 4.15,你可能会看到最复杂的步骤是对在数据处理流程中构建的数组数组结构上的flat调用。这里有一个简单的例子,以便你可以看到它是如何剥离嵌套数组的:

[['a'], ['b'], ['c']].flat()      // ['a', 'b', 'c']

因为算法在之前执行了一个map操作,所以有一个捷径:直接使用Array#flatMap方法。我们将在第五章中更详细地回顾mapflatMap,但就现在而言,我们将按顺序介绍它们,以便了解computeBalance是如何工作的。正如你所见,flat是直观的,但flatMap究竟是什么呢?在函数式程序中,map后面跟着flat的组合经常出现,以至于将这两个方法的组合作为单个方法是有意义的。函数式社区中达成一致的名字是flatMap。前面的代码简化到下一个列表。

列表 4.16 使用 mapfilterreducecomputeBalance

function computeBalance(address, ledger) {
   return Array.from(ledger) 
    .filter(not(prop('isGenesis'))) 
    .flatMap(prop('data'))                ❶
    .map(balanceOf(address)) 
    .reduce(Money.sum, Money.zero()) 
    .round();
}

❶ 用 flatMap 替换 map 然后用 flat

使用这些 API,你可以解决几乎任何你需要处理的数组处理任务,甚至可能从你的代码中移除循环。这种能力是 JavaScript 中处理集合的未来。

这个列表是一个很好的停止点,但为了乐趣,让我们再进一步。在函数式程序中,通常不使用点符号来调用函数序列(map(...).filter(...).reduce(...)),因为这种符号假设有一个对象将这些操作串联起来。相反,使用 compose 的帮助,使用提取的、柯里化的函数形式来贯穿整个数据流,将对象传递给每个调用(无假设),使其无参数化!

让我们慢慢来介绍这个技术。当将方法提取为其自己的函数时,将实例对象作为最后一个参数放置,并柯里化该函数。以下列表显示了如何提取 Array#map(f``)

列表 4.17 以柯里化形式提取 map

const map = curry((f, arr) => arr.map(f));      ❶

❶ 数组 arr 是最后一个参数。

要在链中嵌入 map,部分应用第一个参数,即映射函数:

map(balanceOf(address));

动态数组(arr)参数不是直接提供的,因为 compose 会以无参数化的方式为你完成。大多数第三方 FP 库都携带这些辅助函数(mapfilterreduce 以及更多)的柯里化形式。下一列表中显示的代码的更函数式版本利用了细粒度、无参数化的设计。无参数化设计需要一个固定的 address 参数,而 ledger 在调用位置提供。

列表 4.18 computeBalance 的无参数版本

const computeBalance = address =>
  compose(                              ❶
    Money.round,
    reduce(Money.sum, Money.zero()),    ❷
    map(balanceOf(address)),            ❷
    flatMap(prop('data')),              ❷
    filter(                             ❷
      compose(                          ❸
        not,
        prop('isGenesis')
      )
    ),
    Array.from
  );

const computeBalanceForSender123 = computeBalance('sender123');
computeBalanceForSender123(ledger); 

❶ 使用 compose,逻辑从右到左读取。

❷ 使用等价的 Array#filter 方法的柯里化提取形式

❸ 使用嵌套组合来使代码更加模块化

第三方 FP 库

函数式语言,如 Haskell 和 F#,具有内置的本地操作符来实现许多这些技术。在本章中,你被介绍了 composecurry 操作符。通常,你不会手动编写这些操作符。相反,你会导入第三方库,如 Ramda (ramdajs.com)、Crocks ( crocks.dev)、Lodash (lodash.com) 或 UnderscoreJS (underscorejs.org) 来导入许多这些实用函数。你可以在代码库中找到本书中使用的函数(mng.bz/pVy2)。

FP 需要一点时间来适应,但这样思考会让你走上编写高度模块化、易于维护、更简洁和更可靠的代码的道路。实际上,它可以帮助你避免潜在的 bug,因为 JavaScript 给你提供了几乎可以突变任何事物的完全自由,使你的开发体验变得更加愉快。

将数据扇出到像这样的纯函数是一个令人信服的想法,随着 JavaScript 继续拥抱更多的函数式特性,并使创建函数链成为语言的本能部分,这个想法将变得越来越突出。

4.8 本地函数链

现在你已经了解了基本的函数式编程概念,你在学习即将到来的新特性方面又迈出一步。在本章中,你学习了(以及其他内容)如何使用 compose 创建函数链。这个操作符最显著的特点是数据流的方向是相反的,这可能会让一些人感到震惊。幸运的是,有一个解决方案:pipe 操作符。pipecompose 的近亲,它按照自然从左到右的顺序处理函数。pipe 的构建与 compose 完全相同。唯一的区别是使用 reduceRight 而不是 reduce

const pipe = (...fns) => fns.reduceRight(compose2);

这个操作符的灵感来源于基于 UNIX 的程序如何将数据管道传输到另一个程序。使用 pipe,你可以重新排列逻辑来计算余额,如下所示:

pipe(
   Array.from,
   filter(
      pipe(
        prop('isGenesis')
        not,        
      )
    ),    
    flatMap(prop('data')),
    map(balanceOf(address)),
    reduce(Money.sum, Money.zero()),
    Money.round
  );

你也会在大多数 JavaScript FP 库中找到 pipe,例如 Ramda 和 Crocks。如果你喜欢这种方式思考,JavaScript 为你准备了一个惊喜。介绍管道操作符:|> (github.com/tc39/proposal-pipeline-operator)。这个操作符灵感来源于像 Elixir 和 F# 这样的函数式语言。这个本地操作符允许你以单向数据流到右的方式调用一系列函数,而无需任何特殊的第三方库。

现在开始学习这个新特性非常重要,因为当它成为官方特性时,它将彻底改变我们编写代码的方式。以下是一个例子:

'1 2 3' |> split |> count;  // 3

你甚至可以将它与 lambda 表达式混合:

'1 2 3' |> split |> count |> (x => x ** 2); // 9

现在想象一下,如果你有一些常见的数组方法被提取成柯里化函数形式,就像这些函数式实用库所提供的那样。你将能够编写如下代码:

ledger 
  |> Array.from 
  |> filter(b => (b |> prop('isGenesis') |> not)) 
  |> flatMap(prop('data')) 
  |> map(balanceOf(address)) 
  |> reduce(Money.sum, Money.zero()) 
  |> Money.round;

这个例子是一种更简洁、更符合习惯的代码设计方式,它与 curry 非常搭配,用于声明性、无点设计。关于现在如何实验这个操作符的详细信息,请参阅附录 A。

关于 JavaScript 中的函数式编程的更多信息

JavaScript 对函数式编程范式的支持是一个巨大的主题。在这本书中,我将只涵盖足够的函数式编程知识,以让你了解它如何引领 JavaScript 的未来,同时帮助你变得更加熟练,用更少的代码做更多的事情。如果你想要更多关于函数式编程和更广泛主题的信息,你可以在我的 2016 年出版的《JavaScript 函数式编程》一书中详细了解它们(mng.bz/0mMN)。

从现在开始,如果有人问 JavaScript 是面向对象还是函数式,就说“是!”前几章强调了 JavaScript 的面向对象特性。这一章专注于函数式编程以及组合纯、高阶函数的艺术。纯函数基于其输入保证一致和可预测的结果。它们的目的通过它们的签名清楚地描述,正如埃里克·埃文斯在《领域驱动设计》(Addison-Wesley Professional, 2013)中所说,“一个纯函数是一个意图揭示的接口。”

尽管函数式范式有许多好处,但它不必是一个全有或全无的过程。我故意在区块链应用程序中使用了混合风格,面向对象和函数式概念交织在一起。你不仅可以利用原型对象模型,还可以创建封装你的关键业务逻辑的、高度可测试和可移植的函数模块。函数式编程帮助你编写更健壮且无错误的代码,尤其是在像 JavaScript 这样的语言中,几乎一切都是可变的。我们将在第五章中回到使用不可变性的编码。

摘要

  • JavaScript 的高阶函数是我们实现函数式代码的手段。

  • 以函数式风格编写的代码是声明性的、可组合的、惰性的,并且易于推理。

  • 纯函数的组合是任何函数式代码库的基石。

  • 转向函数式思维模式需要一种不同的解决问题的方法,即通过将代码分解成细粒度行为。

  • 惰性编程允许你延迟计算,而使用curry,你可以通过使用任何阶数(参数数量)的函数来创建可组合的软件。

  • 了解 FP 原则将为你提供所需的竞争优势,以便开始使用未来几年将可用的新的 JavaScript 功能。

  • 使用管道操作符(|>)使得实现无点函数链变得极其简单且符合惯例。

5 高阶组合

本章涵盖

  • 使用 mapflatMap 安全地转换数组和对象

  • 使用代数数据类型的可组合设计模式

  • 编写 Validation 数据类型以删除复杂的分支逻辑

  • 使用新的绑定运算符(::)链式使用 ADT

抽象的目的是不是模糊,而是创建一个新的语义层,在其中可以绝对精确。

—埃德加·迪杰斯特拉

在第四章中,你学习了当使用 compose 链接函数的输入和输出,数据通过它们传递时,函数组合如何导致流畅、紧凑和声明式代码。组合有很多好处,因为它使用了 JavaScript 最强大的功能,正如我多次说过的:高阶函数。使用函数,你可以在最低的抽象单元上实现低级组合。但高阶组合也存在于对象组合的方式中。使对象组合与函数一样强大是我们将在第 5.3 节中讨论的关键思想。

本章中你将学习的可组合对象模式被称为代数数据类型(ADT)模式。ADT 是一个具有特定、众所周知接口的对象,它允许类似 compose 的抽象来链式连接多个 ADT。与任何类一样,ADTs 也可以包含或存储其他对象,但它们比类简单得多,因为它们仅模拟一个单一的概念,例如验证、错误处理、空值检查或序列。由于你已经学习了函数组合,你可以看到在没有任何失败且所有函数的输入和输出都定义良好的世界中,组合函数非常简单。但是,当你还需要验证数据并利用此模式捕获异常时,情况就不同了。学习使用 ADTs 对于此目的非常有用,因为它们提供了简洁的 API,允许你从更简单的部分构建整个程序——这是组合的核心。

抽时间反思一下我们在第四章中解决的问题。例如,给定一个用户的数字钱包地址,我们计算了区块链中的比特币总额。但是,如果提供的地址是 null 会发生什么?嗯,在这种情况下,程序会失败,因为我们没有添加任何针对这种可能性的保护措施。

更普遍地,我们如何处理在组合序列中流动的无效数据(nullundefined)?compose 的语法没有给你多少空间在函数之间插入命令式条件验证语句。与其在每个函数中添加常见的验证逻辑,不如像以下列表所示提取它。

列表 5.1 在每个组合函数之前嵌入验证检查

compose(f3, validate, f2, validate, f1, validate);

function validate(data) {
   if(data !== null) {       
      return data;                                          ❶
   }
   throw new TypeError(`Received invalid data ${data}`);    ❷
}

❶ 将数据传递给链中的下一个函数。

❷ 否则,以错误退出。

然而,这个过程不起作用,因为

  • 它是重复的。

  • 你失去了验证发生的上下文,这意味着在失败的情况下,你无法应用特定的规则或退出并给出适当的验证消息。

  • 抛出异常会产生副作用。

关于最后一点,如果第一个函数未能产生有用的结果,那么其余的流程可能不应该继续进行。当你与可能抛出异常的第三方代码一起工作时,也会发生同样的事情。在命令式世界中,你会添加 try/catch 保护。但是,再次强调,try/catch 并不是你可以轻易插入 compose 的东西;它是 FP 和 OO 之间的阻抗不匹配,你将不得不与 FP 作斗争,以保持事情线性化和无点式。看看下一个列表。

列表 5.2 将 try/catchcompose 混合的不便

compose(
  c => {
    try {
      return f3(c);       ❶
    }
    catch(e) {
      handleError(e);
    }
  }, 
  b => {
    try {
      return f2(b);       ❶
    }
    catch(e) {
      handleError(e);
    }
  },
  a => {
    try {
      return f1(a);       ❶
    }
    catch(e) {
      handleError(e);
    }
  }
)

❶ 因为每个函数都被命令式错误处理代码所包围,所以你无法利用声明性和无点式风格(第四章)。

这种糟糕的设计是错误地结合范式的结果。我们可能更喜欢以一种减轻副作用的方式处理这个任务,而不是突然抛出错误。为了解决这个问题,我们需要添加必要的护栏或包装器,以控制函数及其验证操作执行的上下文,同时保持事情分离、紧凑和声明性。这听起来是不是要求很高?如果没有必要的技巧,确实如此。本章教授了这些技巧,所有这些技巧都围绕高阶函数展开,并辅以一些更多的 FP 原则。

ADT 提供了一个众所周知、通用的 API,它促进了可组合性。你会了解到 map 接口(与 Array#map 的精神相同)表明一个特定的对象表现得像函子。同样,flatMap 接口表明一个对象表现得像单子。我们很快就会解开这两个术语。这两个接口都允许 ADT 与其他对象进行组合。

注意:术语“函子”和“单子”起源于范畴论,但你不需要理解数学就能在实践中学以致用。

在教授了一些基础知识之后,本章将逐步深入到从头创建 ADT,解决验证或检查块、事务甚至整个区块链数据结构内容背后的复杂性。到那时,你将理解像这样的代码做什么:

Validation.of(block)
  .flatMap(checkLength(64))
  .flatMap(checkTampering)
  .flatMap(checkDifficulty)
  .flatMap(checkLinkage(previousHash))
  .flatMap(checkTimestamps(previousTimestamp));

此代码解决了我们之前提出的所有问题,尽管具体做法可能还不明显。代码消除了重复,没有产生额外的副作用,而且(最好的是)是声明性和无点式的。这种类型的抽象“验证”在验证逻辑周围创建了一个封闭的上下文。让我们首先理解我们所说的封闭上下文是什么意思。

5.1 在数据类型上封闭

当我们编写函数时,理想的情况是假设完美的应用状态。也就是说,所有进入和离开我们函数的数据始终是正确和有效的,并且我们系统中的对象没有nullundefined值。这种状态将允许我们减少数据检查的样板代码。遗憾的是,这种情况永远不会发生。或者,我们可以考虑使用某种抽象来包装函数,该抽象始终检查任何类型的不合法数据。在本节中,我们将创建一个简单的抽象,以开始熟悉本书中展示的模式。在第 5.5 节中,我们将在此基础上构建一个实际的 ADT。

当我们将业务逻辑与数据验证、错误处理或日志记录等辅助工作交织在一起时,函数可能会变得复杂。我们可以将这些关注点视为与当前任务无关,但它们是工作应用程序的重要部分。其他任务可能包括处理异常或将日志记录到文件中。我们将这些任务称为效果。

注意:效果不要与副作用混淆。副作用可能是一种效果,如在这个上下文中使用的效果,但效果更多的是一种任意任务。

让我们关注这些效果中的一个:数据验证。假设你正在编写一个小算法,使用一系列函数。在每一步,你都想确保每个函数接收到的参数是有效的(非空、大于零、非空等)。这些对于确保算法从实用角度来说是重要的,但不是算法本身的必要部分。而不是在每个函数中添加混乱,你可以以某种形式将这个效果抽象化。

假设这个假设的算法有三个步骤:f1f2f3。你已经在列表 5.1 中的代码中看到了交织发生的情况:

compose(f3, validate, f2, validate, f1, validate);

在每个函数运行之前都会进行数据验证。让我们改进这段代码以消除重复。使用第四章的教训,我们将使用接受要执行的函数和以 curried 形式提供的数据作为输入的高阶函数来包装这些函数(封闭它们)。高阶函数擅长将一些代码转换为可调用形式。这种方法将允许validate根据提供的数据的有效性来决定是否应用函数。

为了说明一个可能的解决方案,让我们通过仅关注调用函数,并假设null检查成功(非空)来使我们的问题更加具体。考虑一个如applyIfNotNull的函数:

function applyIfNotNull(fn) {
  return data => {
     if (data !== null)  {
       return data;
     }
     throw new Error(`Received invalid data: ${data}`);
  }
}

compose(applyIfNotNull(f3), applyIfNotNull(f2), applyIfNotNull(f1));

如你所见,在每次函数调用周围都会重复 null 检查。因为applyIfNotNull是手动 curried 的,所以我们可以通过映射到构成你的业务逻辑的函数上,如下一列表所示来消除重复。

列表 5.3 使用map将多个函数应用于compose

compose(...[f3, f2, f1].map(applyIfNotNull));        ❶

❶ 将 applyIfNotNull 应用于每个函数,然后将结果数组作为参数展开传递给 compose

这一步使我们的代码更接近于声明式、表达式导向的代码,而不是命令式分支逻辑,但我们仍然需要考虑两个项目:

  • 在现实世界中,null检查并不是我们需要的唯一验证形式。我们需要支持更多种类的逻辑。

  • 我们使用异常,它本身就是一个副作用,以戏剧性的方式跳出逻辑。

我们需要从函数级别提升抽象级别,到某种形式上下文数据结构,它可以以某种方式跟踪验证结果,并相应地应用函数。一种方法是用封装数据的包装器对象,将应用效果到这些数据作为其业务逻辑的一部分,就像applyIfNotNull之前所做的那样,以不可变的方式执行,而不泄露副作用。

在 JavaScript 中,可能最简单的容器数据结构是Array,在其全面的方法集中,有一些我们可以用于此类抽象的方法:

  • 一个静态函数用于使用值构造新容器——对于一个类C,这个函数通常被称为C.ofC.unit。这个函数,类似于Array.of,也被称作类型提升函数,因为它允许你将一些类型变量带入你将要执行操作的环境中。将一些对象提升并放入一个盒子是一个很好的类比。

  • 一个转换这些数据的函数——这种转换通常通过对象上的特定契约的map方法来完成。map是所有实例共享的,因此它应该在原型级别(C.prototype.map)上定义。

  • 一个从容器中提取结果的函数——这个函数的实现是特定的。对于数组,你可以使用类似Array#pop的东西。

任何超出此协议的内容都取决于你为特定包装器需要的额外逻辑。

在我们开始实现自己的包装器之前,让我们继续使用数组来表示封装和不可变性。这种做法将帮助我们熟悉 ADT 使用的编码模式。

将值包装在某种容器中,例如数组字面量,可以在多个值上自动提供流畅编码能力,而不仅仅是单个值。为了讨论方便,让我们专注于一个值。考虑这个例子。给定一个字符串,假设你想删除重复字符并将最终字符串转换为大写。例如,对于输入"aabbcc",结果应该是"ABC"

足够简单。正如你所知,将一系列计算应用于元素数组的最有效方式是通过map,这是一个无状态方法,因此你永远不会改变调用它的原始数组或其元素。这种情况满足不可变性的要求。此外,我们需要方法将值放入数组,然后提取数据。为此任务,我们可以分别使用提升操作Array.ofArray#pop,如下一列表所示。

列表 5.4 在数组上映射函数

const unique = letters => Array.from(new Set(letters));      ❶
const join = arr => arr.join('');
const toUpper = str => str.toUpperCase();

const letters = ['aabbcc']
   .map(unique)  // [['a', 'b', 'c']]
   .map(join)    // ['abc']
   .map(toUpper) // ['ABC']
   .pop();                                                   ❷

letters; // 'ABC'

❶ 使用 Set 的能力,它接受一个可迭代对象,来移除重复项

❷ 也可以使用 Array.prototype.shift 或 [0]

通过直接使用数组字面量,JavaScript 给我们一些语法上的改进(以盒子的形式):

['aabbcc']
   .map(unique)
   .map(join)
   .map(toUpper)
   .pop(); // 'ABC'

如果你想要更加精确一些,可以使用 Array.of 作为泛型构造函数:

Array.of('aabbcc')
   .map(unique)
   .map(join)
   .map(toUpper)
   .pop(); // 'ABC'

注意:当你构造一个新数组时,使用 newArray 构造函数并不是最佳做法。这个函数的行为不可预测,取决于使用的类型。例如,new Array('aabbcc') 创建了一个包含单个元素 ['aabbcc'] 的数组,正如我们所期望的。但 new Array(3) 创建了一个包含三个空槽的空数组:[, , ,]Array.of API 修正了这种情况,但在大多数情况下,最简单的方法是直接使用数组字面量表示法:['aabbcc'].

使用数组产生的容器类似于我们所说的身份上下文。这个术语来源于简单而流行的 identity 函数 (const identity = a => a),你可以在第四章中了解到这个函数。这个函数在函数式程序中常用,并回显它所给出的值。在函数式编程(FP)中,身份意味着某些值保持不变。

同样,一个身份上下文不会有自己的计算逻辑。它包装一个单一值,并且不会进行任何额外的处理,除了你在映射函数中提供的;它对数据没有影响。我们说它是无上下文的,或者无副作用的。

让我们进一步探讨数组示例。一种在 JavaScript 中轻松实现 Id 类的方法是扩展 Array,如列表 5.5 所示。这个例子仅用于说明 map 操作符如何泛型地应用于包含单个值的简单容器。通常,我不推荐从标准类型扩展(猴子补丁);这个例子是为了教学目的,其用途将在后面变得清晰。

列表 5.5 通过扩展 Array 实现无上下文容器

class Id extends Array {
   constructor(value) {
      super(1);                 ❶
      this.fill(value);
   }
 }

 Id.of('aabbcc')                ❷
   .map(unique)
   .map(join)
   .map(toUpper)
   .pop(); // 'ABC'             ❸

❶ 初始化底层数组的大小为 1,因为我们只需要包装一个值

❷ 继承 Array.of 作为类型提升函数

❸ 继承 Array#pop 从容器中提取值

Id[](空数组)都是封闭上下文的例子。尽管这个例子可能看起来并不激动人心,但这里还有更多值得注意的地方。具体来说,Id

  • 使数据转换 API 流畅,其中每个阶段都执行一个可预测的转换,朝着最终结果前进,就像装配线一样。

  • 提供了一定程度的数据封装。

  • 以不可变的方式执行所有操作,因为过程的每个阶段都会返回一个带有新值的新容器。映射函数可以将Id内部的数据转换为任何形状,只要它推进我们的逻辑,朝着最终结果前进。我们说映射函数是从ab的任何函数(ab是任何对象),它将容器从Id(a)转换为新的Id(b)

从概念上讲,使用容器编程比喻性地类似于装配线或铁路,如图 5.1 所示。

图 5.1 使用容器进行装配式计算。在这种情况下,线的每一步都映射不同的转换,沿途创建新的中间结果,直到达到所需的产品。

注意:在 5.5.4 节中,你会看到使用包装器实现验证(这是一个二元操作)会导致两条路径或铁路。

最后这一点,即通过应用函数创建新容器,是保证映射函数纯净最重要的因素。记住,纯净是使你的代码易于推理的关键成分。以Arraysortreverse为例,它们会就地修改原始对象。这些 API 使用起来更困难,因为它们可能导致意外的行为。另一方面,像 5.2 节中提到的不可变 API 要安全得多。

5.2 新数组 API:

Array#{flat, flatMap}是强大的、包罗万象的 JavaScript Array对象的两个主要新增功能。你在第四章的结尾简要看到了这些方法的使用。这两个方法都允许你轻松地管理多维数组:

[['aa'], ['bb'], ['cc']].flat();      // ['aa', 'bb', 'cc']
[[2], [3], [4]].flatMap(x => x ** 2); // [4, 9, 16] 

与所有最近添加的Array方法一样,这些操作都是不可变的;它们不会改变原始对象,而是创建新的。让我们从flat开始。

5.2.1 Array.prototype.flat

Array#flat允许你在不打破你简洁流畅的模式的情况下处理多个数组维度。以下是一些示例:

[['aa'], ['bb'], ['cc']].flat().map(toUpper);   // ['AA', 'BB', 'CC']

该方法甚至有内置的智能来跳过嵌套的非数组对象。数组中的空槽位保持不变:

[['aa'], , ['bb'], , ['cc']].flat().map(toUpper);   // ['AA', 'BB', 'CC']

关于flat的一个有趣的事实是,你可以折叠无限深度的结构:

[[[[['down here!']]]]].flat(Infinity); // ['down here!']

flat还允许你与自身返回数组的函数一起工作。回想一下列表 5.4 中的unique,它接受一个字符串并返回一个包含所有字母但不包含重复字母的数组。例如,将unique映射到['aa', 'bb', 'cc']上会产生一个嵌套结构[['a'], ['b'], ['c']],我们可以在最后轻松地将其展平:

const unique = letters => Array.from(new Set([...letters]));

['aa', 'bb', 'cc'].map(unique).flat(); // ['a', 'b', 'c']

由于mapflat经常一起使用,JavaScript 提供了一个同时处理这两个方法的 API。

5.2.2 Array.prototype.flatMap

map然后flat的序列在日常编码中经常被使用。例如,你可能需要遍历链中的所有块,然后遍历每个块内的所有交易。幸运的是,一个名为flatMap的快捷方式同时调用这两个操作,如下所示。

列表 5.6 flatMap的基本使用

['aa', 'bb', 'cc'].flatMap(unique);       ❶

// ['a', 'b', 'c']

unique返回一个数组。flatMap在将回调函数映射到所有元素之后,运行内置的扁平化逻辑,而不是产生嵌套数组。

我们都把mapflatMap理解为允许你将回调函数应用于数组的操作。然而,从概念上讲,这些操作超越了数组。如果你已经阅读了第四章并理解了函数式编程的基本原理,这种形式的代码应该看起来像是一个熟悉的模式:

Id.of('aabbcc') 
  .map(unique)
  .map(join)
  .map(toUpper);

你会说这段代码看起来像是组合吗?事实上,以下代码产生的结果('ABC')与上一个相同:

const uniqueUpperCaseOf = compose(toUpper, join, unique)

uniqueUpperCaseOf('aabbcc') // 'ABC'

mapcompose是如何得到相同结果的?与compose相比,除了微小的语法差异外,mapflatMap代表上下文组合,在很大程度上是等价的。

5.3 map/compose 对应关系

在 5.1 节中,我说过map允许对象(如Id和其他)应用函数。这个陈述同样适用于flatMap。在本节中,你将了解到在根本层面上,这两个操作符的行为类似于compose,因此本质上,使用map不过是函数组合,这巩固了函数式编程的思维方式。

从技术角度讲,这种等价性也很重要,因为你可以获得我们在第四章中提到的使用compose的所有好处,但现在应用到了对象上。让我们通过将map定义为compose来演示这种等价性:

Function.prototype.map = function (f) {
  return compose( 
      f,
      this
   );
};

现在所有函数都自动继承map。使用它再次揭示了两者之间的紧密对应关系:

 compose(toUpper, join, unique)('aabbcc'); // 'ABC'
 unique.map(join).map(toUpper) ('aabbcc'); // 'ABC'

这种对应关系告诉我们,所有通过函数组合获得的好处都可以轻松地应用于复合类型。在我们的简单用例中,map是一个接口,允许ArrayId组合函数,它还将允许你使用的任何 ADT 组合函数。

现在你已经看到了这些概念是如何交织在一起的,让我们定义一个通用的map接口,允许任何实现它的对象组合在一起。

5.4 通用契约

在本章前面的例子中,你可能已经注意到 mapflatMap 保留了相同的调用者类型。对于数组,两者都返回新的数组;对于函数,两者都返回新的函数。这个事实不能被理所当然地接受。它是 map 接口的核心部分,被普遍接受,并允许你的对象与其他函数库(如 Ramda ramdajs.com)或 Crocks crocks.dev)一起工作。在本节中,你将了解一些关于函子和单子等模式背后的理论以及它们在 JavaScript 中的实现方式。

Fantasy-land

JavaScript 中函子和单子如何工作的协议,在很大程度上,遵循了 fantasy-land 规范中提出的规则(github.com/fantasyland/fantasy-land)。这份文档非常详尽,我强烈建议你花时间理解它,如果你想要成为一名严肃的 FP 程序员的话。这一章肯定能给你一个良好的开端。

关于 ADT 的完整理论非常广泛,最好在专门介绍函数式编程或抽象代数的书籍中找到。在这里,我将涵盖足够的内容,以便你能够解锁使可组合软件成为可能的 FP 模式,从函子开始。

5.4.1 函子

一本专注于使用 JavaScript 和函数式编程(FP)享受编程乐趣的书籍,如果没有适当剂量的函子,那就不是完整的。因为函子通过依赖高阶函数进行数据转换,从而最能体现语言的优势。

函子是任何可以映射或正确实现映射接口的东西(例如对象)。例如,JavaScript 中的数组几乎就是函子,map 方法使得编程风格优于常规的 for 循环,并且更不容易出错。正如你在第 5.3 节中学到的,compose 也能使函数成为函子,所以你已经在不知不觉中使用了它们很多次。

为了使一个对象表现得像函子,它需要遵循两个简单的规则,这些规则源于 map/compose 等价性(第 5.3 节)。为了简单起见,我将再次使用数组来展示规则:

  • 标识符 — 将恒等函数映射到容器上会产生一个相同类型的新容器,这也是 map 应该无副作用的良好指标:

    ['aa','bb','cc'].map(identity);  // ['aa', 'bb', 'cc'] 
    
  • 组合 — 组合两个或多个函数,例如 fg 之后,等同于先映射 g 再映射 f。这两个陈述都是等价的,如下所示:

    ['aa','bb','cc'].map(
          compose(
            toUpper,
            join,
            unique
         )
      );
    

    ['aa','bb','cc']
        .map(unique)
        .map(join)
        .map(toUpper);
    

注意,我一直在使用“等价”这个词来表示。这样做是为了避免引起你习惯的其他形式的等价,例如双等号运算符(==),它松散地等同于类型转换,以及三等号(===),它在值和类型上是一个严格的等价。在这里,“等价”意味着引用透明;如果你用一个表达式替换其值,程序的意义或结果不会改变。

实现一个函子涉及定义 map 并遵循这些简单规则,以及创建一个封闭上下文的合约,如第 5.1 节中描述的,例如实现一个类型提升函数 F.of,以及从容器中提取值的机制,例如 get 方法。正如你在第三章中学到的,将可重用接口应用于任何对象的最佳方式是使用混入。让我们重构 Id(如下一列表所示),以利用 Functor 混入的优势。

列表 5.7 带最小上下文界面的 Id

class Id {
   #val; 
   constructor(value) {
      this.#val = value;
   }

   static of(value) {          ❶
      return new Id(value);
   }

   get() {                     ❷
      return this.#val;
   }
}

❶ 类型提升函数

❷ 获取器以从容器中提取值

Functor 是一个混入,它公开了一个 map 方法,如下一列表所示。map 是一个高阶函数,它将给定的函数 f 应用到包装的值上,并将结果存储在相同的容器中,就像 Array#map

列表 5.8 Functor 混入

const Functor = {
  map(f = identity) {                              ❶
    return this.constructor.of(f(this.get()));     ❷
  }
}

map 接受一个回调函数来应用,默认使用身份函数作为参数

❷ 将回调函数应用于值,并使用通用类型提升函数将结果包装在相同容器的新的实例中

由于函子合约必须保留封装结构,我们可以通过调用它的容器实例来找出它,并通过使用 this.constructor.of 来调用其静态类型提升构造函数。由于我们使用的是类,这使得这个过程变得简单,因为它配置了 constructor 属性,并使其易于发现。现在让我们像在第三章中处理 Transaction 一样扩展 Id

Object.assign(Id.prototype, Functor);

一切都像以前一样继续工作。Array#mapFunctormap 具有相同的合约,因此我们可以以相同的方式使用它,如下一列表所示。

列表 5.9 使用 Id 函子进行顺序数据处理

Id.of('aabbcc')
   .map(unique)   // Id(['a', 'b', 'c'])       ❶
   .map(join)     // Id(['abc'])
   .map(toUpper)  // Id('ABC')
   .get();        // 'ABC'

❶ 从 Id 映射返回新的 Id 对象

让我们可视化函子的内部工作原理,就像打开容器以将其值暴露给映射函数,然后将其重新包装在新的容器中,如图 5.2 所示。

图 5.2 函数在容器上的映射

如果你再次浏览列表 5.9,你会注意到它是相当通用的。除了一个值提取方法的实现(Id#getArray#pop)之外,所有内容都遵循通用的函子合约。

函子让你将一个简单的函数映射到包装的值上,并将其放回相同类型的新容器中。前端开发者可能已经认识到 jQuery 对象的行为就像一个函子。jQuery 也是一个函子,并且是第一个推广这种编码风格的 JavaScript 库之一(api.jquery.com/jquery.map)。

现在我们来看一个稍微不同的情况。如果你映射一个返回容器的函数,比如映射返回 Id 对象的函数,会发生什么?根据你在本章中学到的关于数组的知识,像 flatMap 这样的操作符被设计来解决这个问题。为了理解为什么,我们将研究 monads。

5.4.2 Monads

Monads 被设计用来处理组合返回容器的操作。组合返回 Id 的函数可能导致一个 Id 内部嵌套另一个 Id;当你组合 Array 时,你会得到一个多维数组,依此类推。你明白了。

一个对象通过实现函子规范和 flatMap 合约以及它自己的简单协议成为 monad。原因是我们将需要 map 返回包装数据的函数。假设这个代码片段中的每个函数都返回一个 Id

Id.of('aabbcc')
   .map(unique)   
   .map(join)     
   .map(toUpper)  
   .get();     

结果将类似于图 5.3。

图 5.3 映射包含包装值的 Id 函数

Monads 将链式计算序列提升到下一个层次,这样你就可以组合使用相同容器或其他容器的函数。我们现在将坚持使用相同的容器,因为这种模式在实践中是最常见的。为了参考,fantasy-land 的条目在 github.com/fantasyland/fantasy-land#monad。考虑一个 monad M 和在 5.4.2 节中定义的等价性:

  • 左单位性 — 类型提升某个值 a 然后使用函数 f 调用 flatMap 应该产生与直接使用 a 调用 f 相同的结果。在代码中,这两个表达式是等价的:

    M.of(a).flatMap(f)  and   f(a)
    

    让我们用简单的数组展示左单位性:

    const f = x => Array.of(x**2);
    Array.of(2).flatMap(f); // [4]
    f(2);  //[4]
    
  • 右单位性 — 给定一个 monad 实例,使用类型提升构造函数调用 flatMap 应该产生一个等价的 monad。给定一个 monad 实例 m,下一列表中的代码片段是等价的。

    列表 5.10 Array 示例,说明右单位性

    m.flatMap(M.of))  and   m
    
    Array.of(2).flatMap(x => Array.of(x));  // [2]      ❶
    Array.of(2); // [2]
    

    ❶ 因为 JavaScript 中 Array.of 的实现有多个参数,所以我们不能通过名称传递函数到 flatMap;相反,我们必须使用函数 x => Array.of(x)。

  • 结合律 — 由于数字在加法下是结合的,因此 monads 在组合下也是结合的。你以括号括起来的方式赋予表达式的优先级不会改变最终结果。给定一个 monad 实例 m 和函数 fg,以下表达式是等价的:

    m.flatMap(f).flatMap(g) and m.flatMap(a => f(a).flatMap(g))
    
    const f = x => [x ** 2];
    const g = x => [x * 3];
    Array.of(2).flatMap(f).flatMap(g);   // [12]
    Array.of(2).flatMap(a => f(a).flatMap(g)); // [12]
    

当你处理单个值时,map-then-flat 的操作也可以理解为仅返回映射函数的结果,从而忽略最外层。以下列表中的 Monad 混合实现了这个任务。

列表 5.11 Monad 混合

const Monad = {
  flatMap(f) { 
    return this.map(f).get();       ❶
  },
  chain(f) {                        ❷
    return this.flatMap(f);
  },
  bind(f) {                         ❷
    return this.flatMap(f);
  }
};

❶ 忽略额外的包装层并假设类型是函子

❷ 你也可能发现这个方法被称为 bind 或 chain。

这个例子告诉我们,当我们具体处理 JavaScript 数组时,flatMap 比手动调用 mapflat 更有效率。从 CPU 周期和内存占用角度来看,这个程序更有效率。记住,每次调用 mapflat 都会创建一个新的数组,所以一次性将它们组合起来可以防止额外的开销。

方法融合

数组以 monadic 方式行为的事实具有许多优点。组合律不仅是 functors 和 monads 行为的核心部分,也是容器类型方法融合(也称为快捷融合)的性能提升。本质上,你可以使用 compose 将多个对 map 的调用融合或组合成一次执行。看看用于此协议的示例:

['aa','bb','cc'].map(
      compose(
        toUpper,
        join,
        unique
     )
  );

['aa','bb','cc']
      .map(unique)
      .map(join)
      .map(toUpper);

这两个片段生成相同的输出,但第一个片段只使用了四分之一的空间。compose 避免了对 map 的多次调用,每次调用都会在内存中创建另一个数组的副本。对于这么大的数组,性能的提升是可以忽略不计的。但如果我们处理大量数据,方法融合可以帮我们避免内存不足的问题。例如,Lodash (lodash.com) 这样的库,它使用懒计算,可以分析一个结合了 mapfilter 等调用的表达式,然后将它们融合在一起。

为了完整性,考虑一个使用 Id 的类似示例:

const square = x => Id.of(x).map(a => a ** 2);
const times3 = x => Id.of(x).map(a => a * 3);
Id.of(2)
   .flatMap(square)
   .flatMap(times3)
   .get(); // 12

Id.of(2)
   .flatMap(a => square(a).flatMap(times3))
   .get(); // 12

为了让这段代码能够正常工作,我们将再次通过将 monadic 行为整合到 mixin 中来扩展 Id

Object.assign(Id.prototype, Functor, Monad);

模拟类型类

我认为这里使用 mixins 作为这些 API 的实现策略还有一个原因:它们在 Scala 和 Haskell 等函数式语言中建模了一个等效的概念。类型类允许你定义一个通用接口,以便任何对象都可以符合某种行为。例如,FunctorMonad 可以通过很少的工作使任何类型表现出 monadic 行为。

我们还可以继续优化这个例子。之前,我提到 monads 也是一种 functors。根据那个定义,将 Functor mixin 组合到 Monad mixin 中是有意义的。考虑下一个列表中的 Monad 定义。

列表 5.12 定义 MonadFunctor

const Monad = Object.assign({}, Functor, {
  flatMap(f) {
     return this.map(f).get();      ❶
  }
  //...                             ❷
});

❶ 在 Functor 中引用 map

❷ 省略了其他方法别名

这个例子展示了组合的灵活性和多功能性。现在你可以通过其完整契约将其类型变成一个 Functor 或一个完整的 Monad

Object.assign(Id.prototype, Monad);

到目前为止,你已经以基本的方式学习了 functors 和 monads 的定义。尽管这些规则可能感觉是人为的、限制性的,但它们为你提供了巨大的结构——与使用 Array#{map, filter, reduce} 而不是直接的 for 循环和 if 条件所得到的相同结构。

Functor 和 monad 是通用的接口(协议),可以通用地插入到应用程序的许多部分。遵守它们,任何第三方代码或应用程序的其他部分,如果实现或支持这些类型,将确切知道如何与之协同工作。一个很好的例子是 Promise 对象,你可能很熟悉。在某种程度上,Promise 是基于 functors 和 monads 构建的;用 then 替换 mapflatMap,许多第 5.4 节中讨论的规则都适用。我们将在第八章中更详细地研究 Promise。

Monad 模式并不容易理解,但重要的是现在就开始学习它们;这些模式在现代软件开发中越来越多地出现,你不想被卷入一个墨西哥卷饼(youtu.be/dkZFtimgAcM);你想要做好准备。

在实践中,你可能永远不会需要在自己的应用程序中实现 Id。我这样做是为了向你展示这个模式的工作原理以及如何通过添加 FunctorMonad 混合来赋予这个类强大的可组合行为。真正的价值将来自更精致、更智能的类型,它们在 mapflatMap 方法中具有自己的计算逻辑。在第 5.1 节中,我定义了一个封闭上下文,并展示了它如何模拟铁路驱动的数据处理方法。在掌握基础知识之后,我们将进一步提高标准,并创建我们自己的 ADT 来实现上下文验证。

5.5 使用高阶函数进行上下文验证

ADT 仅仅是一个不可变复合数据结构,其中包含其他类型。实践中大多数 ADT 都实现了 monad 协约,就像 Id 容器一样。

在我们深入探讨之前,值得指出的是,ADT 与抽象数据类型(简称 ADT)是不同的模式:一个应该包含相同类型对象的集合,例如 ArraySetStackQueue(尽管 JavaScript 不强制执行此规则)。另一方面,代数数据类型模式可以且通常包含不同类型。名称中的代数部分来自数学协议中的恒等性、可组合性和结合性(第 5.4 节)。

在本节中,我们将学习 ADT 的基础知识,并了解如何使用 Functor 和 Monad 合约以可组合的方式解决上下文数据验证。

5.5.1 ADT 类型种类

ADT 在强类型世界中更为突出,其中类型信息使代码更加明确和严谨。(有关在 JavaScript 中使用类型的更多信息,请参阅附录 B。)但即使没有类型信息,我们仍然可以做很多事情。本节将探讨两种最常见的 ADT 类型:RecordChoice。这两个都封装了一些有用的编码模式。

Record

记录类型是一个包含固定数量(通常是原始类型)的操作数的复合类型。这类似于数据库记录,其中模式描述了它可以持有的类型,并且具有固定长度。一些 JavaScript 库,如 Immutable.js,提供了一个可以导入和使用的 Record 类型。记录最常见的例子是不可变的 Pair

const Pair = (left, right) => 
  compose(Object.seal, Object.freeze)({
    left,
    right,
    toString: () => `Pair [${left}, ${right}]`
  });

Pair 是一个具有 2 个基数的不变对象,可以用来关联两块信息(任何类型),例如用户名和密码、公钥和私钥、文件名和访问模式,甚至是在映射数据结构中的键/值条目。Pair 是一个泛型记录类型,其中 Money(在第四章中引入)或类似 Point(x, y) 的类型可以派生出实现。

Pair 的最佳用途是在函数中同时返回两个相关值。最常见的是,使用简单的数组字面量或甚至简单的对象字面量来表示基本的 Pair。然后你可以利用解构来访问每个字段。你可以有如下内容

const [id, block] = blockchain.lookUp(hash);

const {username, password} = getCredentials(user);

很不幸,这两种方法并不奏效,因为它们是可变的,并且由于数组是泛型的,它并没有传达其值之间的任何关系。记录在语义上传达了其值之间的逻辑与关系,意味着它们必须存在或一起有意义(例如用户名和密码)。

记录也被称为产品或元组。一对是 2-元组,一个三元组是 3-元组,以此类推到 n-元组。JavaScript 没有原生的元组或记录概念,但一个早期的提案可能会在不久的将来将其引入 JavaScript(github.com/tc39/proposal-record-tuple)。

5.5.2 选择

与记录强制逻辑与不同,选择表示操作数或它接受的值之间的逻辑或关系。选择也被称为区分联合或求和类型。与记录一样,选择类型可以持有多个值,但在任何给定时间点只使用其中一个。一个简单的类比是使用 JavaScript 的 null 合并和 nullish 合并运算符。考虑下一个列表中的简单示例。

列表 5.13 Null 和 nullish 合并运算符

const hash = precomputedHash || calculateHash(data);      ❶

const hash = precomputedHash ?? calculateHash(data);      ❷

❶ 当左侧包含一个假值时评估右侧

❷ 仅当左侧为 null 时评估右侧

在 null 合并运算符的情况下,如果 precomputedHash 不是一个假值(null、undefined、空字符串、0 等),则此表达式评估 || 运算符的左侧;否则,它评估为右侧。我建议你使用 nullish(或 nullary)合并运算符(??),因为它仅在 precomputedHashnullundefined 时评估右侧,这通常是你的意图。

选择类型常用于涉及数据检查、数据验证或错误处理的情况。原因在于选择模型了互斥分支,如成功/失败、有效/无效和 ok/错误。这些情况是简单的二元(基数 2)用例,我们错误地倾向于滥用布尔值。

例如,如果你有 TypeScript 的经验,枚举类型或实用类型是表示可能处于多种状态之一的对象的常见方式。考虑以下定义:

type Color = 'Red' | 'Blue';

仅使用最小工具,你也可以扩展 JavaScript 以使用类型(附录 B)并以相同的方式使用枚举。另一个类比是 switch 语句,它通常用于调用具有多个可能状态的条件逻辑。有两个情况时,选择类型的逻辑可能看起来像这样:

switch(value) {
  case A:
    // code block
    break;
  case B:
    // code block
    break;
  default:
    // code block
}

使用这种结构,考虑以下列表中的假设用例,该用例检查某个值的有效性。

列表 5.14 使用 switch 语句根据一个条件执行操作

switch(isValid(value)) {
  case 'Success':
    return doWork(value);
  case 'Failure':
    logError(...);            ❶
  default:
    return getDefault();
}

❶ 故意跳过 return/break 以返回默认值

你可以将选择 ADT 视为始终跟踪多个互斥状态并根据情况做出反应。验证数据与此类似,你只能有两种可能的结果:成功或失败。

5.5.3 使用 Validation 单子建模成功和失败

数据验证是常见的编程任务,通常涉及大量的代码拆分(if/else/switch 语句),这些语句经常被复制并分散在应用程序的几个部分中。我们都非常清楚,带有大量条件语句的代码会变得混乱,难以抽象,更不用说难以阅读和维护了。本节将教你如何实现和使用单子来解决这个问题,同时保持你的代码易于阅读、模块化,并且(最重要的是)可组合。

通常,在实现 ADT 时,你必须考虑两个维度。一个是类型(记录或选择),另一个是需要组合的级别(函子或单子)。在本节中,我们将创建一个具有单子行为的 Validation 对象作为选择类型,以便我们可以将单个验证操作的序列组合在一起。最终,我们将完成 HasValidation 混合逻辑的实现,并使用它以一致的方式对区块链中的每个元素运行验证代码。

Validation 模型了其计算上下文由两个状态组成,SuccessFailure,如图 5.4 所示。

图 5.4 Validation 类型的结构。Validation 提供了 SuccessFailure 的选择,永远不会同时两者都有。

我们将允许 Success 分支在活动时对包含的值应用函数(如 doWork)。实际上,这项工作听起来像是 mapflatMap 的工作。否则,在验证失败的情况下,我们将跳过调用逻辑并传播遇到的错误。当需要将复杂操作序列中发生的错误向上冒泡时,这种模式很有用——这是 if/else 和甚至 try/catch 块都难以处理的。

此外,想想你写了多少次返回布尔值的验证函数。我知道我写过,但这是一个坏习惯。通过从你的函数中返回 Validation 对象,你直接强迫用户正确处理 SuccessFailure 情况,而不是测试布尔值。另一个明显的优点是,你的函数变得自文档化,或者用 Edsger Dijkstra 的话说,“更精确。”在 JavaScript 中,这个优点很重要,因为文档通常缺乏,你需要跟踪代码以查看抛出了哪些异常或任何特殊的错误值,例如 nullundefined。提前承认某些操作可能会失败要好得多,而不是让你的用户猜测在调用特定函数时是否可能发生错误。我们都可以同意,返回 false 并没有向调用者传达有关哪个操作或数据无效的任何信息;没有上下文。

最后,验证过程的一个良好品质是它们能够快速失败。因为组合链连接了一个函数的输入和输出,当已经发现错误时,让函数运行无效数据是没有意义的。短路是有意义的。

让我们看看一个假设的区块链示例,该示例涉及验证一个区块是否被篡改。通过重新计算区块的哈希并与其自身进行比较,这个检查很容易完成:

const checkTampering = block =>
  block.hash === block.calculateHash() 
     ? Success.of(block) 
     : Failure.of('Block hash is invalid');

const block = new Block(1, '123456789', ['some data'], 1);
checkTampering(block).isSuccess; // true

block.data = ['data compromised']; 
checkTampering(block).isFailure; // true

此函数检查特定条件,并返回包含正确值的 Success 包装器或包含错误信息的 Failure 对象。此函数不关心错误如何向前传播或作为更长组合链的一部分如何工作;它专注于自己的任务。

SuccessFailure 分支都是 Validation 组合的一部分,并且是驱动一系列验证到达最终结果或错误的中心抽象。SuccessFailure 是封闭的上下文,将操作成功或失败作为应用程序的一等公民进行建模。以下列表使用一个类来实现这种行为。

列表 5.15 父 Validation

class Validation {
  #val;                                                               ❶
  constructor(value) {
    this.#val = value;
    if (![Success.name, Failure.name].includes(new.target.name)) {    ❷
      throw new TypeError(
        `Can't directly instantiate a Validation. 
            Please use constructor Validation.of`
      );
    }
  }

  get() {                                                            ❸
    return this.#val;
  }
  static of(value) {                                                 ❹
    return Validation.Success(value);
  }

  static Success(a) {
    return Success.of(a);
  }

  static Failure(error) {
    return Failure.of(error);
  }

  get isSuccess() {                                                  ❺
    return false;
  }

  get isFailure() {                                                  ❺
    return false;
  }

  getOrElse(defaultVal) {                                            ❻
    return this.isSuccess ? this.#val: defaultVal;
  }

  toString() {
    return `${this.constructor.name} (${this.#val})`;
  }
}

❶ 值是私有的且只读的

❷ 防止直接实例化,有效地使 Validation 成为抽象类,并强制通过其变体类型(Success 和 Failure)访问其行为

❸ 从容器中读取值

❹ 泛型类型提升函数,返回 Success 的新实例

❺ 在运行时查询容器的类型;默认设置为 false 以表示没有分支是激活的

❻ 提供了一种通用的恢复方式

从这个类派生出具体的变体选择:SuccessFailureSuccess路径就是我们所说的快乐路径,所以它与父类没有太多区别。Failure覆盖了一些继承方法的某些行为,如下一列表所示。

列表 5.16 ValidationSuccessFailure分支

class Success extends Validation {
  static of(a) {
    return new Success(a);;
  }

  get isSuccess() {
    return true;                                                    ❶
  }
}

class Failure extends Validation {
  get isFailure() {                                                 ❶
    return true;
  }

  static of(b) {
    return new Failure(b);
  }
  get() {                                                           ❷
    throw new TypeError(`Can't extract the value of a Failure`);
  }
}

❶ 覆盖容器的类型

❷ 在失败时直接调用 get 被认为是编程错误。

使用类与对象来建模 ADT

我选择使用类来设计Validation,因为这正是大多数 JavaScript 开发者所使用的。尽管如此,第二章和第三章中涵盖的任何模式(构造函数、OLOO、混合)都可以同样工作。为了比较,这里是一个Validation ADT 的 OLOO 版本:

    const Validation = {
      init: function(value) {
        this.isSuccess = false;
        this.isFailure = false;
        this.getOrElse = defaultVal => this.isSuccess ? value : defaultVal;
        this.toString = () => `Validation (${value})`;
        this.get = () => value;
        return this;
      }
    };

    const Success = Object.create(Validation);
    Success.of = function of(value) {
      this.init(value);
      this.isSuccess = true;
      this.toString = () => `Success (${value})`;
      return this;
    };

    const Failure = Object.create(Validation);
    Failure.of = function of(errorMsg) {
      this.init(errorMsg);
      this.get = () => 
         throw new TypeError(`Can't extract the value of a Failure`);
      this.toString = () => `Failure (${errorMsg})`;
      return this;
    };

如您所见,通过不将基于类的设计强加到 JavaScript 中,OLOO 通过移除私有变量的特殊语法、抽象类行为的强制执行以及所有面向类的继承思维模型(classextends)的需求,大大减少了代码的复杂性,同时保持了所有相同的功能。

这可能不是立即显而易见的,但鉴于Validation一次只能持有单个值(有效值或错误),它比记录类型更节省内存,记录类型将所有值存储在元组中。

现在我们已经实现了基本部分,让我们看看它们是如何工作的。

5.5.4 与单子组合

为了展示这种类型的作用,让我们将countBlocksInFile(在第四章开始)重构为一个也具有验证功能的函数:

const countBlocksInFile = compose(
    count,
    JSON.parse,
    decode('utf8'),
    read
);

这个函数从文件读取到二进制缓冲区,将此缓冲区解码为 UTF-8 字符串,解析此字符串到块数组,并最终进行计数。但这个函数缺少一个重要的部分:检查文件是否存在。如果文件不存在,read将抛出异常。同样,如果没有有效的Buffer对象,decode将抛出异常,依此类推。

因为关键点在于文件是否存在,我们可以使用Validation来抽象这个分界点。让我们在下一列表中重构read函数,使其返回一个Validation实例。

列表 5.17 创建返回Validation结果的read版本

const read = f =>
   fs.existsSync(f) 
     ? Success.of(fs.readFileSync(f))                 ❶
     : Failure.of(`File ${f} does not exist!`);       ❷

❶ 这里的条件表达式模拟了逻辑 OR 以决定要遵循哪个分支。

❷ 数组很有用,这样您就可以连接多个错误消息。

现在这段代码是这样工作的:

read('chain.txt'); // Success(<Buffer>)
read('foo.txt');   // Failure('File foo.txt does not exist!')

执行其余代码是将每个转换映射到每个Success容器,就像任何泛型函子一样。Functor混合足够使用:

Object.assign(Success.prototype, Functor);

通过这样做,我们得到了将验证分支和共享数据紧密耦合与将共享行为扩展为松耦合的乐高式 mixins 的好处。由于map/compose等价性,这段代码在mapcompose下工作得一样好。再次看到这个表面上微不足道的等价性对深层次的影响:

const countBlocksInFile = f =>
   read(f)
     .map(decode('utf8')) 
     .map(JSON.parse)
     .map(count);

countBlocksInFile('foo.txt');  // Success(<number>)

此外,对于选择 ADT 来说,将函数映射到一边并在另一边跳过(无操作)是一个常见的模式。Validation在成功分支上执行,我们将它称为右侧,也称为右偏。在先前的例子中,我们将Success(右侧)做成一个 functor。我们还需要考虑Failure分支,以便在读取错误的情况下忽略或跳过任何组合操作。为此,我们可以创建一个与常规Functor具有相同形状的NoopFunctor mixin,如下所示。

列表 5.18 在失败情况下使用NoopFunctor以跳过调用映射函数

const NoopFunctor = {
  map() {
    return this;         ❶
  }
}

❶ 因为数据保持不变,所以返回相同的对象给调用者是有意义的。

让我们将NoopFunctor分配给Failure(左侧):

Object.assign(
  Failure.prototype,
  NoopFunctor
);

在我们考虑了两种情况类之后,执行流程看起来就像图 5.5 所示。

图 5-5

图 5.5 详细展示了在Validation类型上映射函数的组合执行。当操作成功时,容器允许每个操作映射到包装的数据;否则,跳转到恢复替代方案。

首先,让我们看看一个简单的失败分支的例子,这样你就可以理解这个对象是如何处理错误的。

假设你想使用Validation来抽象检查空对象。首先,创建一个实现分支逻辑的函数(fromNullable):

const fromNullable = value => 
   (value === null)
      ? Failure.of('Expected non-null value')
      : Success.of(value);

这种抽象帮助我们应用函数到数据上,而无需担心数据是否已定义,如下面的列表所示。

列表 5.19 使用fromNullable处理有效的字符串

fromNullable('joj').map(toUpper).toString() // 'Success (JOJ)'

fromNullable(null).map(toUpper).toString()           ❶
// 'Failure (Expected non-null value)'

❶ map 的底层实现是 NoopFunctor,当数据无效时忽略操作

为了在现实世界中展示这种类型,让我们在我们的区块链应用程序中使用它。验证或验证区块链的内容是这项技术如此重要的一个部分,以至于公司专门实施这个协议的这部分。当然,我们在这本书中实现的是一个大大简化的版本。除了checkTampering(如第 5.5.3 节中实现)之外,我们还必须确保块在链中的位置正确(checkIndex)以及它们的时间戳大于或等于前一个的时间戳(checkTimestamp)。这两个简单规则看起来是这样的:

const checkTimestamps = curry((previousBlockTimestamp, block) =>
  block.timestamp >= previousBlockTimestamp 
     ? Success.of(block) 
     : Failure.of(`Block timestamps out of order`)
);

const checkIndex = curry((previousBlockIndex, block) =>
  previousBlockIndex < block.index 
     ? Success.of(block) 
     : Failure.of(`Block out of order`)
);

我们将使用柯里化来简化这些函数的应用。注意,我们定义了这些函数为柯里化函数(见第四章),以便将它们作为验证序列组合。因为区块是链式连接的,要比较一个区块,你需要加载前一个区块(在创世区块之后)。从任何区块开始,这个过程总是很简单,因为每个区块都有一个指向前一个区块哈希的引用,以及指向它所属链的引用,如下一个列表所示。

列表 5.20 BlockisValid方法

class Block {
   //... omitting for brevity

   isValid() {
      const {
         index: previousBlockIndex,                        ❶
         timestamp: previousBlockTimestamp
       } = this.#blockchain.lookUp(this.previousHash);

      const validateTimestamps =                           ❷
         checkTimestamps(previousBlockTimestamp, this);

      const validateIndex =                                ❷
         checkIndex(previousBlockIndex, this);

      const validateTampering = checkTampering(this);      ❷

      return validateTimestamps.isSuccess && 
             validateIndex.isSuccess      && 
             validateTampering.isSuccess;

   }
}

const ledger = new Blockchain();
let block = new Block(ledger.height() + 1, ledger.top.hash, ['some data']);
block = ledger.push(block);
block.isValid(); // true

block.data = ['data compromised'];                         ❸
block.isValid(); // false                                  ❹

❶ 使用解构并更改变量名

❷ 这些函数返回成功或失败。

❸ 改变了区块的数据

❹ 验证检查因篡改检查失败

如果我们想要返回一个布尔结果,这个代码看起来是完美的,因为它缺少运行操作的任何上下文。然而,在这里,最好的方法是将isValid返回一个Validation对象而不是布尔值。这样,如果我们想要验证整个区块链数据结构(而不是一个区块),我们可以将所有的Validation对象组合在一起,在失败的情况下,我们可以精确地报告错误是什么。

5.5.5 使用 Validation 进行高阶组合

正如我们在 5.4.2 节中对Id所做的那样,让我们赋予Validation与其他相同类型对象组合的能力,并形成链。为此任务,我们可以附加Monadmixin,它包括Functor以及flatMap其他返回Validation的函数的能力。对于Success分支,我们可以分配Monadmixin:

Object.assign(Success.prototype, Monad);

对于Failure,我们可以应用无逻辑的NoopMonad

const NoopMonad = {
  flatMap(f) {
    return this;
  },
  chain(f) {
    return this.flatMap(f);
  },
  bind(f) {
    return this.flatMap(f);
  }
}

然后扩展Failure类似:

Object.assign(
  Failure.prototype,  
  NoopMonad
);

下一个列表展示了如何将区块对象类型提升到Validation。然后 ADT 接管,你所需要做的就是链式连接验证规则,就像构建一个小型规则引擎。

列表 5.21 使用flatMap进行Block验证

class Block {
   ...

   isValid() {
      const {
         index: previousBlockIndex,  
         timestamp: previousBlockTimestamp
       } = this.#blockchain.lookUp(this.previousHash);

       return Validation.of(this)
         .flatMap(checkTimestamps(previousBlockTimestamp))    ❶
         .flatMap(checkIndex(previousBlockIndex))             ❶
         .flatMap(checkTampering);                            ❶
   }
}

❶ 柯里化形式是有用的,这样块对象(动态参数)就可以传递到每个flatMap调用中。

如你所见,这个实现看起来比之前的实现更加优雅,并且是混合 OOP/FP 实现的一个很好的例子,使用了 mixins。为了确保从 OO 到 FP 的更严格过渡,你可以选择传递对象的冻结版本:

return Validation.of(Object.freeze(this))
   .flatMap(checkTimestamps(previousBlockTimestamp))
   .flatMap(checkIndex(previousBlockIndex)) 
   .flatMap(checkTampering);

这个算法的一个快乐路径运行看起来像图 5.6。

图 5-6

图 5.6 使用flatMap组合的不同返回Validation的检查函数的顺序应用

这个新的isValid版本输出的是Validation结果而不是布尔值,所以在Success情况下使用它看起来是这样的:

block.isValid().isSuccess;  // true

如果任何检查失败,Validation会智能地跳过其余部分:

block.isValid().isFailure;  // true
block.isValid().toString(); // 'Failure (Block hash is invalid)'

也许你认为虽然这段代码看起来更简洁,但每次都必须调用 flatMap 有点冗长。为什么不尝试更无点的方法呢?在第四章中,你学习了如何使用 composecurry 创建无点函数组合。你还可以在 JavaScript 中使用单子实现无点风格。为了理解这如何工作,首先我们需要实现一个名为 composeMcompose 的不同变体,它使用 flatMap 来控制数据流。

5.5.6 使用单子的无点编程

无点编程的优点是使复杂逻辑在高级别上更容易阅读。为了允许使用单子进行无点编程,我们需要构建更多的管道。在第 5.3 节中,你学习了如何通过映射一个函数到另一个函数来组合两个函数,这个想法,以下也是正确的:

const compose2 = (f, g) => g.map(f);

再次,你可以通过使用 reduce 来扩展此代码以与函数列表(而不仅仅是两个)一起工作:

const compose = (...fns) => fns.reduce(compose2);

通过类似的推理,存在一种对应关系,使得 flatMap 成为组合两个返回单子的函数的基础——在我们的例子中,是返回 Validation 的函数。使用代码来展示这种对应关系遵循了第四章中 compose 的构建过程以及 compose/map 等价性(第 5.3 节)。为了简洁起见,我将省略细节,只关注重要的部分。一个与单子一起工作的 compose2 的替代方案,称为 composeM2,其实现方式如下:

const composeM2 = (f, g) => (...args) => g(...args).flatMap(f);

如你所见,这种实现与基于 mapcompose2 实现类似。因为管道中的函数返回单子实例,所以使用 flatMap 来应用函数并自动扁平化容器。最后,为了支持超过两个函数,我们进行类似的缩减:

const composeM = (...Ms) => Ms.reduce(composeM2);

不要担心理解所有细节或每次都必须重新实现此代码。功能 JavaScript 库已经支持这两种类型的组合;一个在比另一个更高的抽象级别上工作。其核心思想是,你使用 compose 来按顺序执行返回未包装(简单)值的函数,而使用 composeM 来执行返回包装值(单子)的函数。这就是为什么前者基于 map,而后者基于 flatMap

现在我们已经定义了 composeM,让我们来使用它。composeM 组织并链接每个函数的逻辑,而 Validation 指导整个操作的整体结果。有了适当的护栏,你可以在自动内置数据验证的过程中按顺序执行复杂的代码链,如下一列表所示。

列表 5.22 使用 composeMBlock 验证

class Block {
   //...

   isValid() {
      const {
         index: previousBlockIndex,  
         timestamp: previousBlockTimestamp
       } = this.#blockchain.lookUp(this.previousHash);

       return composeM(
          checkTimestamps(previousBlockTimestamp),
          checkIndex(previousBlockIndex),
          checkTampering,
          Validation.of
       )(Object.freeze(this));
   }
}

没有必要再次测试这个逻辑;你可以根据之前的示例自己来做。如果你检查组成部分的结构,你会再次看到它保留了无点性质,即所有函数参数(除了柯里化的参数)都没有直接指定。

到目前为止,你已经学到了足够多的函数式编程概念,可以开始在日常任务中利用这种范式。单子不是一个容易理解的概念,许多书籍和文章以不同的方式教授它,但当你理解幕后的一切都是关于map(你从处理数组中理解了它)时,事情开始变得清晰起来。

让我们继续我们的区块链验证场景。为了回顾,与常规数据结构不同,区块链不能被篡改或以任何方式更改。更改一个区块将涉及重新计算链中所有后续区块的哈希值。这种限制还确保没有人能够主导并重新创建整个链的历史,这防止了臭名昭著的 51%攻击,因为没有任何单一实体能够利用所有必要的计算能力的 51%来运行所有这些计算:

  • 块的顺序是否正确?

  • 时间戳是否按正确的顺序排列?

  • 链的完整性是否完好无损,并且每个区块是否指向正确的上一个区块?(为了检查,我们查看区块的previousHash是否等于它之前区块的hash属性。)

  • 区块的数据是否被篡改?(为了检查,我们重新计算每个区块的哈希值。如果区块内部有任何变化,该区块的哈希值将发生变化。)

  • 哈希的长度是否正确(本书中只使用 64 字符长度的哈希)?

class Block {
   ...

   isValid() {
      const {
         index: previousBlockIndex,
         timestamp: previousBlockTimestamp
       } = this.#blockchain.lookUp(this.previousHash);

       return composeM(
            checkTampering,
            checkDifficulty,
            checkLinkage(previousBlockHash),
            checkLength(64),
            checkTimestamps(previousBlockTimestamp),
            checkIndex(previousBlockIndex),
            Validation.of
       )(Object.freeze(this));
}

列表 5.22 中的代码仅执行了三个验证规则。观察当扩展到更多规则时,这个代码片段是如何发光的。你可以通过本书附带的代码来跟踪每个规则的实现。这里要关注的是算法的结构化和可读性。如果你想添加更多规则,你永远不会牺牲可读性。使用单子进行无点编码确实类似于流水线式的嵌入式规则引擎。这种将分号换成逗号的交易是为什么单子也被称为可编程逗号。

到目前为止,我们只能验证单个区块实例。幸运的是,因为我们的代码是可组合的,我们可以验证所需的所有数量的对象。区块链存储数十亿个对象。你可以想象验证数十亿这些区块所涉及的工作。正如你所知,区块包含交易,这些交易也需要验证。对于这本书,简单地将区块链视为一系列列表(包含交易的区块)是很容易的,但现实中它是一个树。这个树的所有元素都需要垂直遍历和验证。任何单个错误都应导致整个过程停止(快速失败)。

5.5.7 简化复杂的数据结构

在本节中,你将了解到拥有一个定义良好、可组合的接口可以让你轻松地减少复杂的数据结构。

为了验证区块链的所有对象(区块链对象本身、所有区块和所有交易),我们将使用 HasValidation 混合并将它分配给所有相关的对象。这个混合所实现的逻辑用于遍历区块链的任何对象并验证其结构,如图 5.7 所示。

图 5.7

图 5.7 从 Blockchain 对象本身开始遍历区块链的对象,向下移动到每个区块的交易。你可以将这个例程想象成一个树状结构,其中 Blockchain 对象本身是根节点,区块是第二级节点,交易是树的叶子。

HasValidation 为对象增加了一个新的 API:validate。此外,HasValidation 要求区块链的每个元素都声明一个 isValid 方法(返回类型为 Validation 的对象,当然)以知道如何检查自身。这个接口是所需的最小接口。

isValid 负责实现与所讨论对象相关的所有业务规则,正如你在 5.5.6 节中看到的 Block 一样。该算法使用 validate 递归,并设计为从树中的任何节点开始验证区块链。

在我概述步骤之前,让我为你准备你将在列表 5.23 中看到的代码。因为我们处理的是树状结构,所以我们将使用递归遍历所有节点。算法的第一部分涉及使用展开操作符列举对象。一些内置对象已经是可迭代的。我们可以这样列举一个映射中的所有值:

const map = new Map([[0, 'foo'], [1, 'bar']]);

[...map.values()]; // ['foo', 'bar'])
[...map.keys()];   // [0, 1])

使用 JavaScript,你可以定义在自定义对象中 spread 的行为,这通过一个称为 Symbol.iterator 的特殊属性类型来实现。你可能玩过或阅读过使用符号的代码,这些符号非常强大。我还没有介绍符号或展示迭代是如何工作的;我将在第七章详细介绍这些主题。现在,当你看到这个惯用语 [...obj] 时,请将其视为返回对象的数组表示,假设为 obj.toArray()

这里是主要步骤:

  1. 将对象列举到数组中。在区块链的情况下,这一步返回包含所有区块的数组。对于区块,它返回所有交易。

  2. 从对象的 isValid 开始,减少每个对象的 isValid 结果数组。

  3. 对每个对象调用 validate,然后返回到步骤 1。

  4. 如果所有元素都通过验证,则返回 Validation.Success;否则,返回 Validation.Failure

列表 5.23 结合了我们一直在学习的技巧,例如 flatMapreduce,以确定链中的所有元素是否有效。记得在第四章中,reduce 是一种思考组合的方式,通过将其与 flatMap 配对,你实现了对象的组合,而不是函数的组合。在每一步中,算法会展开正在验证的对象(例如,一个块会返回其交易列表);然后它使用 map 将对象列表转换为 Validation 对象列表。最后,它使用 flatMap 作为归约函数,将结果折叠成一个单一的 Validation 对象。递归地,所有层级都折叠到最终的 validationResult 累加器变量中。

列表 5.23 HasValidation 混合器

const HasValidation = () => ({
    validate() {   
       return [...this]                                             ❶
        .reduce((validationResult, nextItem) =>                     ❷
             validationResult.flatMap(() => nextItem.validate()),
          this.isValid()                                            ❸
         );
    }
});

❶ 调用对象的 [Symbol.iterator] 通过扩展运算符遍历其状态

❷ 将所有验证调用的结果归约为一个

❸ 从检查当前讨论对象的结果开始

在列表 5.23 中发生的一切,这个算法简洁而紧凑。现在你已经看到了代码,我将从归约的角度再次解释它,因为递归有时很难理解。从概念上讲,你可以将验证整个数据结构视为以某种方式将其简化为单个值。这就是在这里发生的事情。reduce 允许你指定一个起始值:开始 validate 的对象的检查。从那时起,你开始组合一系列验证对象,一个接一个。然后 flatMap 在遍历树的过程中将所有层级折叠成一个单一的值。

可能会吸引你注意的一件事是传递给 flatMap 的箭头函数。因为我们不关心当前通过所有检查的对象是哪一个,只关心它确实通过了,所以我们丢弃了输入参数。Validation 正在为我们做繁重的任务,跟踪错误详情。如果它检测到错误,底层类型会从内部将 Success 切换到 Failure,记录错误,并跳过其余的操作(在失败上执行扁平映射)。

列表 5.23 的缺点是它在区块链的每个分支中都会在内存中创建一个数组。然而,这段代码对于拥有数十亿对象的现实世界区块链来说是无法扩展的。在第九章中,我们将使用一种编程模型来解决这个问题,该模型允许我们处理无限量的数据。

记得在第三章中,BlockchainBlockTransaction 都实现了这个混合器。现在你知道它是如何工作的。以下是将这个混合器附加到我们领域主要类中的代码片段:

Object.assign(
   Blockchain.prototype, 
   HasValidation()
);

Object.assign(
  Block.prototype,
  HasHash(['index', 'timestamp', 'previousHash', 'data']),
  HasValidation()
);

Object.assign(
  Transaction.prototype,
  HasHash(['timestamp', 'sender', 'recipient', 'funds', 'description']),
  HasSignature(['sender', 'recipient', 'funds']),
  HasValidation()
);

现在你已经理解了 monads 的工作原理,你已经获得了一个通用词汇表,它允许你轻松地与其他任何理解它的代码集成,包括第三方代码,并像形状相似的代码一样进行组合。

5.5.8 第三方集成

如果我们使用的所有 API 都使用相同的协议,我们的工作作为开发者将会容易得多。幸运的是,通用的 funtor 和 monad 接口已经建立并广为人知,并且在功能 JavaScript 库中无处不在,但并非所有软件的方面都是如此。在本章中,我简要提到了 Ramda 和 Crocks 作为好的功能库。你可能也看到过、听说过,甚至使用过 Underscore.js 或 Lodash。这些库是下载量最大的 NPM 库之一。

例如,Ramda 通过遵循 fantasy-land 定义,也使用 funtor 和 monad 的语言。以下列表显示了 Validation 如何无缝集成到 Ramda 中。

列表 5.24 将自定义 Validation 对象与 FP 库 Ramda 集成

const R = require('ramda');
const { chain: flatMap, map } = R;                 ❶

const notZero = num => (num !== 0 
   ? Success.of(num) 
   : Failure.of('Number zero not allowed')
);

const notNaN = num => (!Number.isNaN(num) 
   ? Success.of(num) 
   : Failure.of('NaN')
   );

const divideBy = curry((denominator, numerator) => 
    numerator / denominator
);

const safeDivideBy = denominator =>
   compose(
      map(divideBy(denominator)),
      flatMap(notZero),
      flatMap(notNaN),                             ❷
      Success.of
);

const halve = safeDivideBy(2);
halve(16); // Success(8)
halve(0);  // 'Failure(Number zero not allowed)'
halve(0).getOrElse(0); // 0                        ❸

❶ chain 是 flatMap 的别名。Ramda 称之为 chain。

❷ Ramda 如果存在,将委托给 funtor 的 flatMap。

❸ 展示了验证的默认值功能

从 Ramda 导入的 map 和 flatMap 函数版本将委托给对象的 mapflatMap,如果存在。这种集成之所以可能,是因为我们遵守了两者都要求的通用契约。

在底层,这里使用的 map 版本与我们在 5.8 列表中展示的 Functor 混合中实现的 map 版本略有不同:

const Functor = {
  map(f = identity) { 
    return this.constructor.of(f(this.get()));  
  }     
}

通用第三方库,如 Ramda,通过提供按顺序排列参数的柯里化、独立函数来促进组合。这些函数的签名类似于 function map(fn, F),其中 funtor F 是被组合和链式连接的对象。我们决定使用混合对象,因此在我们的情况下,F 是隐含的,变成了 this

或者,我们可以使用 JavaScript 的动态上下文绑定能力来创建我们自己的提取形式,以便我们可以达到与独立实现相似的经验。

5.6 使用方法提取和动态绑定进行高阶组合

到目前为止,我们已经通过分配适当的混合将我们的对象变成了 funtor 和 monad。这不是使用 Functor 模式唯一的方法。在本节中,你将学习如何提取一个 map 方法作为一个函数,你可以使用动态绑定将其应用于任何类型的 funtor。

你已经知道,JavaScript 通过使用原型方法 bindcallapply 可以轻松地更改函数绑定到的环境或上下文。给定 Functor 混合,我们可以通过简单的解构赋值来提取 map 方法:

const { map } = Functor;

使用 Function#call,我们可以在任何 funtor 上调用它,如下所示:

map.call(Success.of(2), x => x * 2); // Success(4)

这个例子接近于 Ramda 随附的通用 map 函数实现,只是参数顺序相反。以这种方式调用方法可能会有些冗长,尤其是如果你想将它们组合在一起。假设你想将 Success.of(2) 与一个平方其值的函数组合起来。让我们跟随这个简单的例子:

map.call(map.call(Success.of(2), x => x * 2), x => x ** 2); // Success(16)

这种编写代码的风格无法扩展,因为随着我们的逻辑变得更加复杂,代码解析起来也更困难。让我们尝试通过一个简单的抽象来使其平滑:

const apply = curry((fn, F) => map.call(F, fn));

这个高阶函数既解决了参数顺序问题,又使用柯里化使组合更好,这与从外部库中获得的结果非常接近:

apply(x => x * 2)(Success.of(2)); // Success.of(4)

现在,我们可以在不需要 composeM 的情况下 compose 函子:

compose(
   apply(x => x ** 2), 
   apply(x => x * 2)
)(Success.of(2)); // Success(16)

注意:关于 apply,单调的另一个扩展,称为应用单调,建立在函子之上,提供了一种将函数直接应用于容器的接口,类似于我们在这里所做的那样。方法名通常是 apapply。应用单调超出了本书的范围,但你可以在 mng.bz/OE9o 上了解更多关于它们的信息。

这些改进是好的,但能够直接使用我们提取的映射函数流畅地处理 ADT 会更好,而不需要任何额外的东西。这个问题的解决方案正在开发中,本着在 JavaScript 中找乐子的精神,我将向你介绍新提出的绑定运算符 (gitub.com/tc39/ proposal-bind-operator)。

这个提议引入了 :: 原生运算符,它结合方法提取执行 this 绑定(就像 Function#{bind,call,apply} API 一样)。它有两种形式:二进制和单目。

在二进制形式(obj :: method)中,绑定对象在方法之前指定:

Success.of(2)::map(x => x * 2); // Success(4)

就像我们对管道运算符所做的那样,我们现在可以轻松地对 ADT 上的一个序列的函子/单调变换进行调用:

Success.of(2)::map(x => x * 2)::map(x => x ** 2); // Success(16)

这是第 5.5.8 节中 Ramda 代码片段的重构版本,它消除了对这个库的依赖:

const { flatMap } = Monad;

const safeDivideBy = denominator => numerator => 
    Success.of(numerator) 
       :: flatMap(notNaN) 
       :: flatMap(notZero)
       :: map(divideBy(denominator));

const halve = safeDivideBy(2);

halve(16); // Success(8)

你也可能在单目形式(::obj.method)中找到绑定运算符。假设你想要传递一个正确绑定的 console.log 方法作为回调函数,你可以使用 ::console.log,如下所示:

Success.of(numerator)::map(::console.log);

或者作为绑定方法:

const MyLogger = {
   defaultLogger: ::console.log
};

这里还有一个例子:

class SafeIncrementor {
    constructor(by) {
        this.by = by;
    }
    inc(val) {
        if (val <= (Number.MAX_SAFE_INTEGER - this.by)) {
          return val + this.by;
        }
        return Number.MAX_SAFE_INTEGER;
    }
}

SafeIncrementor 用于安全地向整数添加,而不会导致溢出或错误表示。如果你想在数字数组上运行此操作,你必须设置正确的绑定上下文,以便增量器记住 this.by 指的是什么,使用 Function#bind

const incrementor = new SafeIncrementor(2);

[1, 2, 3].map(incrementor.inc.bind(incrementor)); // [3, 4, 5]

使用单目形式的绑定运算符,此代码简化为:

 [1, 2, 3].map(::incrementor.inc); // [3, 4, 5]

这两种选项的工作方式几乎相同。绑定运算符创建了一个绑定到运算符左侧或右侧对象的绑定函数。有关使用 Babel 启用此功能的详细信息,请参阅附录 A。

在本章中,我们通过编写自己的 ADT(抽象数据类型)来探索了 functor 和 monad 模式。Validation不是唯一的 ADT;还有许多其他 ADT。实际上,Haskell 程序完全在 I/O 单子上下文中执行,这样您就可以以无副作用的简单方式写入标准输出。ADT 是简单而强大的工具。它们允许您以可组合的方式表示日常常见任务。阅读本章后,您将知道永远不要裸用您的数据。当您想要以更稳健的方式应用函数时,尝试将它们包裹在容器内。

在本章中,我们从零开始实现了Validation,但许多 ADT 在实际应用中广泛使用。它们变得如此普遍,以至于您可以在用户库和框架中找到它们。其中一些甚至演变成了语言的新增功能。JavaScript 将通过Promise(最初是一个库,现在是原生 API)然后是Observable(第九章)来满足这两种情况。尽管Promises 不遵循相同的通用接口,但它们的行为就像一个接口,您将在第八章中了解到。

通过理解 ADT 的基本概念,您对这些 API 的工作原理有了更牢固的掌握。可组合的软件不仅涉及代码,还涉及平台。JavaScript 的模块系统重修是组合的圣杯,也是第六章的主题。

摘要

  • 将裸数据包裹在可映射的容器内提供了良好的封装和不可变性。

  • JavaScript 为其原始数据类型提供了一个类似对象的门面,以保持它们的不可变性。

  • Array#{flat, flatMap}是两个新的 API,允许您以流畅、可组合的方式处理多维数组。

  • mapcompose具有深层的语义等价性。

  • ADT 可以根据它可以支持多少值(记录或选择)以及可组合性水平(函子或单子)来分类。

  • 函子是实现map接口的对象。单子是实现flatMap接口的对象。两者都支持一组必须遵循的数学灵感协议。

  • Validation 是一个复合选择 ADT,用于建模SuccessFailure条件。

  • compose用于常规函数组合,composeM用于单子、高阶组合。

  • 通过实现 Functor 和 Monad 的通用协议,您可以轻松且无缝地将代码与第三方 FP 库集成。

  • 使用新提出的管道操作符以流畅的方式运行一系列单子变换。

第三部分. 代码

现在你已经设置了你的对象和函数,你应该如何和在哪里放置它们?第三部分探讨了如何组织应用程序的组件并利用逻辑分离的优势。到目前为止,这项任务从未容易过,因为它要求你深入到客户端代码的模块系统中,这些模块系统与后端的模块管理方式不同。如果你期望任何共享、通用逻辑的模块可以从浏览器轻松过渡到服务器,那么请重新考虑。这种过渡之前是不可能的,但 JavaScript 的官方、标准模块系统(ECMAScript Modules,ESM)的出现改变了这一切。

第六章向你介绍了 JavaScript 的官方模块系统:ECMAScript 模块(ESM)。ESM 基于早期技术痛苦学到的教训,旨在在客户端和服务器环境中一致地标准化代码的导入和导出方式。

使用模块的全部目的在于在不影响其他部分的情况下更改应用程序的一部分,这个过程也被称为关注点分离。第七章将这一原则铭记于心,并将其与元编程技术相结合,这样你就可以将常见的功能(如日志和性能计数器)提升并转移到独立的模块中,然后动态地将它们连接到应用程序的不同部分——仅在你需要的时候和地方。你将学习如何使用符号创建静态钩子,然后如何使用动态代理创建动态、可撤销(开启/关闭)的钩子。

6 ECMAScript 模块

本章涵盖了

  • 评估程序性模块模式

  • 审查立即执行的函数表达式(IIFEs)

  • 介绍 ECMAScript 模块语法和新的 .mjs 扩展

  • 比较动态和静态模块系统

  • 使用树摇动和死代码消除

作用域对程序员来说就像氧气一样。它无处不在。你通常甚至都不会去想它。但是当它被污染时……你会窒息的。

—大卫·赫尔曼(有效 JavaScript)

JavaScript 开发的世界正在疯狂变化,其中变化最大的部分是它的模块系统。

在现代应用程序开发中,我们理所当然地接受了模块编程的概念。将我们的应用程序分解为不同的文件然后重新组合的做法已经成为了我们的第二天性。我们不是为了避免手指因不断滚动而长水泡;我们这样做是为了可以分别推理和演进我们应用程序的不同部分,而不用担心会破坏其他部分。

你可能听说过认知负荷这个术语,用来指代一个人在任何时刻可以持有的信息量。过多的信息——例如所有变量的状态、所有组件的行为以及所有潜在副作用——会导致认知过载。计算机可以轻松地每秒跟踪数百万个操作或状态变化,但人类不能。一个科学事实是,人类可以在短期记忆中同时存储大约七个物品。(想想一个小缓存。)这就是为什么我们需要将我们的代码细分为子程序、模块或函数,这样我们就可以单独检查每个元素,并减少我们同时接受的信息量。

正如我在第一章所说的,现代 JavaScript 开发的时代已经到来。每种主要的编程语言都必须有良好的模块支持,但直到最近,JavaScript 并没有。在第四章和第五章中,你学习了将复杂代码分解为函数,并通过组合重新组装的过程。这个过程是函数/对象级别的模块化。本章介绍了文件级别的模块化,使用原生 JavaScript 关键字。

我们首先简要概述了今天的模块解决方案的格局,然后转向讨论 JavaScript 的新官方标准:ECMAScript 模块(ESM),也称为 ECMA262 模块,它始于 ECMAScript 2015 模块。与早期的模块系统不同,ESM 通过使用静态依赖定义增强了 JavaScript 语法,这有四个显著的好处:

  • 提高了你在应用程序之间共享代码的体验

  • 使工具如静态代码分析、死代码消除和树摇动变得更加高效

  • 统一了服务器和客户端的模块系统,解决了根据平台需要不同模块系统的大问题

  • 优化了编译器分析代码的方式

注意:重要的是要提到,这本书不涵盖如何打包 JavaScript 代码或如何使用 NPM 和 Yarn 等包管理器交付它。此外,由于存在许多 JavaScript 编译器,我不涵盖任何与 ESM 相关的具体编译器优化。

我们将首先回顾今天的 JavaScript 模块化景观,以便您了解 ESM 解决了什么问题以及为什么我们有幸拥有它。

6.1 过去的状况

模块化编程在其他语言社区中已经是一个主流概念多年,但在 JavaScript 中并不是。即使今天,管理依赖关系并构建可以在许多环境中统一运行的代码仍然具有挑战性。记住,JavaScript 处于一个独特的位置,支持服务器和客户端(浏览器)环境,这两种环境在本质上非常不同。

如果您正在开发客户端应用程序,您可能已经不得不处理一些复杂的构建工具,这些工具可以将您所有的独立脚本合并成一个单一的包。最终,应用程序作为一个无休止的文件运行,以创造模块化的错觉。为了理解 ESM 的动机以及为什么它如此重要,花点时间了解 JavaScript 和模块的当前状况以及我们是如何到达这里的,这会有所帮助。

如果您已经编写 JavaScript 代码多年,您可能还记得 JavaScript 由于缺乏适当的模块系统而面临了大量的反对和批评。毫无疑问,这种限制给网络开发者带来了最大的痛苦。没有模块,任何相当规模的代码库很快就会陷入全局的命名变量和函数冲突。当多个开发者共同开发同一个应用程序时,这种冲突会加剧。您可能会惊讶地发现,在成千上万行代码中存在许多名为 arr 的数组、名为 fn 的函数和名为 str 的字符串,它们在全球范围内都可能以不可预测的顺序发生冲突。需要紧急采取措施。

关于 JavaScript 模块的意见是热烈的且多样化的。在过去,任何试图规范它的尝试都给这种混乱增加了另一个变量。随着时间的推移,出于必要性,出现了关于模块规范的不同的思想流派。最显著的是异步模块定义 (AMD) 和通用 JS (CJS)。这两个概念都是通向(长期期待)正式标准 ECMAScript 模块 (ESM) (github.com/nodejs/modules) 的垫脚石。图 6.1 展示了 JavaScript 模块系统演变的简要近似时间线。

图 6-1

图 6.1 JavaScript 模块系统的演变概述,从早期浏览器中的简单内联脚本到官方的 ESM。所有日期均为近似值。

AMD 和 CJS 有不同的设计目标。后者是同步的,用于文件 I/O 快速的服务器;前者是异步的,用于文件访问较慢的浏览器。尽管浏览器端存在争议,但 AMD 通过极大地简化大型、客户端 JavaScript 应用的依赖管理,尤其是在与 RequireJS (requirejs.org)脚本加载器库结合使用时,取得了最大的进展。AMD 是单页应用(SPA)架构成为可能的原因之一。SPA 不仅包含布局,还包含大量业务逻辑加载到浏览器中。结合 Web 2.0 技术如 AJAX,整个应用都被放入了浏览器中。

尽管如此,仍然没有形成标准。这种缺乏共识推动了另一个提案的诞生,该提案试图统一和标准化模块系统。通用模块定义(UMD)应运而生,同时还有一个名为 SystemJS 的模块加载器 API(github.com/systemjs/systemjs),它可以在客户端和服务器上运行。尽管阅读 UMD 构建的模块复杂且繁琐(因为它涉及大量的条件逻辑来支持任何模块风格和环境),但这一标准是一个福音,因为它允许插件和库作者针对一个可以在客户端和服务器上运行的单一格式。

经过多年的深思熟虑,ESM 成为了 JavaScript 模块系统的终极选择。ESM 是一个平台无关的、标准化的 JavaScript 模块系统,它既适用于服务器也适用于浏览器,最终取代了 CJS 以及其他所有模块格式。目前,ESM 是官方标准;所有平台供应商都开始采用它,所有库作者也开始使用它。这一过程将是一个缓慢的过程,需要我们大家共同努力。

在这些正式提案出现之前,JavaScript 开发者们正努力创建令人惊叹的网站。那么我们当时是如何模块化应用的,什么被认为是模块呢?为了避免将所有内容都做成全局脚本,开发者们发明了巧妙的模式和命名方案,甚至使用对象和函数内部的作用域作为伪命名空间来避免全局上下文中的名称冲突。我们将在第 6.2 节中探讨这些模式。

6.2 模块模式

即使没有模块规范,我们也有多种方法在 JavaScript 中实现模块化。在 JavaScript 有任何模块系统之前,所有代码都生活在全局空间中,这证明非常难以维护。代码被分成了不同的脚本文件。开发者必须发挥创意来组织他们的代码,并提供一些抽象全局数据的方法,以创建避免与其他运行脚本发生名称冲突的作用域——并且每天都要设法回家吃晚饭。JavaScript 的基本作用域机制一直是,并将始终是函数作用域,因此依赖函数来创建隔离的代码作用域,在那里你可以封装数据和行为,是完全合理的。

在本节中,我们将回顾一些在模块成为语言核心部分之前,由于纯粹的需要而出现的临时模块化编程模式:

  • 对象命名空间

  • 立即调用的函数表达式(IIFEs)

  • IIFE 混合

  • 工厂函数

这些模式值得回顾,因为它们至今仍然有效,非常适合小型应用程序和脚本,尤其是如果你针对的是任何旧浏览器,如 Internet Explorer 11。

6.2.1 对象命名空间

对象命名空间是在工具如 AMD 存在之前,为了将简单的脚本扩展成完整的应用程序而出现的。因为浏览器本身不进行任何依赖管理,所以你包含文件(通过<script />标签)的顺序很重要。

开发者养成了这样的习惯:首先加载他们需要的任何第三方库(如 jQuery、Prototype 等),然后加载依赖于这些库的应用特定代码。主要问题是,除了 iframe 和 web workers 之外,脚本都在同一个全局浏览器域中运行。(我将在第七章中简要讨论域。)没有属性封装,一个文件中的全局变量、类或函数会与从不同文件加载的相同名称的变量冲突。这些问题很难调试,尤其是在发生冲突时,浏览器没有给出任何提示或警告。

注意:现在你可以通过使用script标签的async HTML 5 属性来异步加载,这使得这个问题变得更糟。

解决这个问题的方法之一是在全局对象下创建人工命名空间,使用对象字面量来分组你的代码并唯一标识变量。实际上,现在已停用的 Yahoo!用户界面(YUI)库广泛使用了这种模式。例如,一个名为Transaction的类可以在许多项目和库中定义,因为它适用于无数领域。为了避免在多次声明此名称时出错,你需要规范地定义Transaction。对于 Node.js,这个定义可能看起来像下面的列表。

列表 6.1 使用全局对象命名空间定义Transaction对象

global.BlockchainApp.domain.Transaction = {};       ❶

❶ 在浏览器中,你使用 window 而不是 global。

注意:记住,在 Node.js 文件或模块内部,global 是隐含的全局对象,类似于浏览器中的 window 对象。

你在第二章中看到了 Transaction 构造函数,我将在下一个列表中重复它,现在它定义在某个任意的对象命名空间下,我称之为 BlockchainApp。这个对象的所有属性可以或多或少地匹配你应用程序的静态目录结构。

列表 6.2 使用对象命名空间

let BlockchainApp = global.BlockchainApp || {};                  ❶
BlockchainApp.domain = {};                                       ❷
BlockchainApp.domain.Transaction = (function Transaction() {     ❸

   const feePercent = 0.6;                                       ❹

   function precisionRound(number, precision) {
      const factor = Math.pow(10, precision);
      return Math.round(number * factor) / factor;
   }

   return {                                                      ❺
      construct: function(sender, recipient, funds = 0.0) {
         this.sender = sender;
         this.recipient = recipient;
         this.funds = Number(funds);
         return this;
      },
      netTotal: function() {
         return precisionRound(this.funds * feePercent, 2);
      }
   } 
})();                                                            ❸

❶ 如果不存在,通过查询 global 定义 BlockchainApp 对象

❷ 在 BlockchainApp 中定义了一个新的(嵌套)对象命名空间,称为 domain

❸ 使用立即调用的函数模式称为 IIFE(在第 6.2.2 节中讨论)。

❹ 函数作用域内封装的私有变量和/或特权函数

❺ 公开变量和/或函数暴露给调用者

或者,你可以使用内联类表达式(见下一个列表)。类本质上就是函数,所以这种语法不应该让你感到惊讶。

列表 6.3 在对象命名空间中定义类表达式

let BlockchainApp = global.BlockchainApp || {};
BlockchainApp.domain = {};
BlockchainApp.domain.Transaction = class {         ❶
   #feePercent = 0.6;
   constructor(sender, recipient, funds = 0.0) {
     this.sender = sender;  
     this.recipient = recipient;
     this.funds = Number(funds);   
     this.timestamp = Date.now(); 
   }

   static #precisionRound(number, precision) {
     const factor = Math.pow(10, precision);
     return Math.round(number * factor) / factor;
   }

   netTotal() {
       return BlockchainApp.domain.Transaction.precisionRound
           (this.funds * this.#feePercent, 2);
   }
} 

❶ 使用类表达式在 BlockchainApp.domain 命名空间内定义一个新的 Transaction 类

使用这个替代方案,你可以通过始终指定类表达式的规范路径来实例化一个新的交易,这旨在减少任何冲突的可能性,比如你决定使用的第三方银行库:

const tx = new BlockchainApp.domain.Transaction(...);

注意:另一种常见的技术是使用你公司的反向 URL 表示法。如果你在 MyCompany 工作,表示法可能看起来像这样:

const tx = new com.mycompany.BlockchainApp.domain.Transaction(...);

类提供了对私有数据的极大支持,但在类出现之前,封装状态最流行的模式是 IIFE。

6.2.2 立即调用的函数表达式 (IIFEs)

立即调用的函数表达式 (IIFEs),你可能已经知道它是模块模式,利用 JavaScript 的函数作用域来容纳变量和函数,并提供对外部世界的封装。正如你可能已经知道的,函数是“立即调用的”,因为未命名的函数(括号内)在最后被评估,这给了你机会暴露你想要的,隐藏你不想要的,就像类一样。

列表 6.4 展示了如何创建一个不泄露任何私有数据的 Transaction IIFE 作为对象命名空间。在这个代码片段中,所有变量声明(无论作用域修饰符是 varconst 还是 let)和函数(如 calculateHash)都存在,并且只在这个周围函数内部可见。

列表 6.4 使用 IIFE

(function Transaction(namespace) {

    const VERSION = '1.0';                                      ❶
    namespace.domain = {};                                      ❷

    namespace.domain.Transaction = class {                      ❸
       #feePercent = 0.6;

       constructor(sender, recipient, funds = 0.0) {
          this.sender = sender;
          this.recipient = recipient;
          this.funds = Number(funds);
          this.timestamp = Date.now();
          this.transactionId = calculateHash (                  ❹
            [this.sender, this.recipient, this.funds].join()
          );  
       }

         ...
       }

    function calculateHash(data) {                              ❺
     ...
    }    
})(global.BlockchainApp || (global.BlockchainApp = {}));        ❻

❶ 在模块作用域中定义的私有属性

❷ 创建嵌套的 domain 命名空间

❸ 公开的 Transaction 类

❹ 可以访问稍后定义的 calculateHash 私有方法

❺ 私有方法。函数定义自动提升到周围函数作用域的顶部。

❻ 检查全局是否存在 Blockchain,如果需要,则创建空的 global.BlockchainApp 对象命名空间以使用

这个函数在声明时立即执行,因此 Transaction 是即时创建的。你可以像以前一样实例化它:

const tx = new BlockchainApp.domain.Transaction(...);

在 ECMAScript 2015 类出现之前,IIFEs 是最受欢迎的模式之一,并且至今仍在使用。事实上,许多开发者和 JavaScript 纯主义者更喜欢它们而不是类。值得注意的是,将变量和对象放在局部作用域中可以使属性解析机制(在第二章中讨论)更快,因为 JavaScript 总是先检查局部作用域,然后才是全局作用域。最后,当与对象命名空间结合使用时,IIFEs 允许你在不同的命名空间中组织你的模块,这在中等规模的应用程序中是必须的。

函数是多才多艺的,以至于你可以增强它们的上下文,以安全地在你的领域中定义混合。因为我们已经在第三章的第 6.2.3 节中探讨了混合,第 6.2.4 节将探讨这些遗留解决方案如何与它们集成。

6.2.3 IIFE 混合

记得我们在第三章和第四章讨论和定义的混合对象吗?我们也可以使用立即执行函数表达式(IIFEs)来实现 HasHash。为此,我们可以利用 JavaScript 的上下文感知函数操作符 Function#callFunction#apply 来动态设置对象上下文,以便在调用位置扩展(通过 this 引用)。增强过程被封装在函数中,与其他代码部分充分隔离。

列表 6.4 展示了与第四章中学习的 HasHash 混合的重新哈希(无意中用了双关语)。与先前的技术类似,我们使用一个函数来创建一个私有边界,围绕我们想要模块化的代码。在列表 6.5 中,使用箭头函数符号是非常有意的。"calculateHash" 是一个箭头函数,这样 this 就指向增强的对象,即传递给 HasHash.call 的上下文对象或环境。

注意:正如你所知,箭头函数不提供自己的 this 绑定;它们从其周围的词法上下文中借用 this

HasHash 接受一组键,这些键用于在哈希过程中识别要使用的属性。下一列表的最后一部分展示了如何增强在全局 BlockchainApp 命名空间下创建的 TransactionBlock 类。

列表 6.5 使用 IIFE 的 HasHash 混合

const HasHash = global.HasHash || function HasHash(keys) {

    const DEFAULT_ALGO_SHA256 = 'SHA256';
    const DEFAULT_ENCODING_HEX = 'hex';
    const options = { 
       algorithm: DEFAULT_ALGO_SHA256, 
       encoding:  DEFAULT_ENCODING_HEX 
    };

    this.calculateHash = () =>  {                        ❶
       const objToHash = Object.fromEntries(
          new Map(keys.map(k => [k, prop(k, this)]))
       );
       return compose(
         computeCipher(options),
         assemble,
         props(keys)
       )(objToHash);
    };
}

HasHash.call(
   global.BlockchainApp.domain.Transaction.prototype, 
   ['timestamp', 'sender', 'recipient', 'funds']         ❷
);

HasHash.call(
   global.BlockchainApp.domain.Block.prototype, 
   ['index', 'timestamp', 'previousHash', 'data']        ❷
);

❶ 这映射到对象的原型并添加了 calculateHash 方法。

❷ 每种类型的对象的哈希值包含一组不同的键。

6.2.4 工厂函数

工厂函数是任何总是返回新对象的函数。你在第四章中 Money 的实现中看到了这个模式的例子。通过工厂创建对象有两个重要的好处:

  • 在实例化时,你可以跳过使用 new 关键字。

  • 你不必依赖 this 来访问实例数据。相反,你可以使用围绕对象形成的闭包来实现数据隐私。

作为另一个例子,让我们向我们的区块链应用程序中引入一个新的对象。BitcoinService 处理区块链领域的多个部分之间的交互,例如转账和挖掘交易。服务通常是无状态的对象,将业务逻辑组织起来,协调您领域多个实体的工作。由于服务对象不携带任何数据,是无状态的,所以我们不需要担心使它们不可变。列表 6.6 展示了使用工厂函数的 BitcoinService 的形状。

列表 6.6 通过工厂函数构建的 BitcoinService 对象

function BitcoinService(ledger) {                         ❶
  const network = new Wallet(                             ❶
      Key('public.pem'), 
      Key('private.pem')
  );
   async function mineNewBlockIntoChain(newBlock) {       ❷

      //... omitted for now
   }

   async function minePendingTransactions(rewardAddress,  
      proofOfWorkDifficulty = 2) {                        ❸

      //... omitted for now
   }

   function transferFunds(walletA, walletB, funds, 
       description, transferFee = 0.02) {                 ❹

      //... omitted for now
   }

   function serializeLedger(delimeter = ';') {            ❺

      //... omitted for now
   }

   function calculateBalanceOfWallet(address) {

      //... omitted for now
   }

   return {
     mineNewBlockIntoChain,    
     minePendingTransactions,  
     calculateBalanceOfWallet, 
     transferFunds,            
     serializeLedger           
  };
}

❶ 账本和网络都成为返回对象的闭包的一部分,并被所有函数使用。

❷ 将新块挖掘到链中。该函数的正文在第八章中展示。

❸ 将交易挖掘到新块中

❹ 在两个用户(数字钱包)之间转账

❺ 将账本序列化为由提供的分隔符分隔的 JSON 对象字符串缓冲区

您可以获取一个新的服务对象并像这样使用它:

const service = BitcoinService(blockchain);

service.transferFunds(luke, ana, Money('B|',5),  
    'Transfer 5 btc from Luke to Ana');

使用工厂函数方法,私有数据(如 network)仅存在于函数的作用域内,就像 IIFE 一样。由于在定义时封闭了该数据,因此始终可以从新对象 API 内部访问私有数据。此外,不需要依赖 this 允许我们将服务方法作为高阶函数传递,而无需担心任何 this 绑定。考虑以下 transferFunds API,它具有以下签名:

function transferFunds(userA, userB, funds, description, 
    transferFee = 0.02)

假设您想运行一批转账,所有转账都具有相同的默认转账费用:

const transfers = [
  [luke, ana, Money('B|',5.0), 'Transfer 5 btc from Luke to Ana'],
  [ana, luke, Money('B|',2.5), 'Transfer 2.5 btc from Ana to Luke'],
  [ana, matt, Money('B|',10.0), 'Transfer 10 btc from Ana to Matthew'],
  [matt, luke, Money('B|',20.0), 'Transfer 20 btc from Matthew to Luke']
];

function runBatchTransfers(transfers, batchOperation) {
   transfers.forEach(transferData => batchOperation(...transferData))    
}

您可以直接从对象中提取方法作为函数,使用解构赋值,并将该方法用作批处理操作,如下所示。

列表 6.7 使用 transferFunds 的提取形式作为回调函数

const { transferFunds } = service;

runBatchTransfers(transfers, transferFunds);       ❶

❶ 将服务方法作为高阶函数传递。所有封闭的数据仍然可以通过方法的作用域访问和获取。

如果 BitcoinService 被定义为类设计的一部分,您将被迫显式设置上下文对象,这并不直接,使用 Function#bind

runBatch(transfers, service.transferFunds.bind(service));

或者新的绑定运算符(第五章):

runBatch(transfers, ::service.transferFunds);

总体而言,这四种技术——对象命名空间、IIFEs、IIFE 混合和工厂函数——因其使用 JavaScript 最小规范语言的子集而具有优雅、简单的美感。尽管这些模式在行业中仍然很普遍,但缺点是我们需要确保所有模块都正确定义,并且具有适当的封装和暴露级别。一个好的模块系统应该为我们处理这些任务。

在 6.3 节中,我们从程序性模式转向语言级别的模块系统。从高层次来看,这些系统可以分为静态或动态。了解这些差异很重要,因为 ESM 由于其静态语法而与其他所有系统不同。

6.3 静态与动态模块系统

动态模块系统是一种在程序中管理依赖关系和指定模块暴露和消耗的内容的系统。这项任务涉及自己编写代码或使用第三方模块加载器。第 6.2 节中讨论的技术属于这一类别,因此模块的规范和定义(它暴露什么以及它隐藏什么)是在代码运行时在内存中创建的。你可以对动态模块做一些技巧,例如启用条件访问以包含模块或模块的部分。例如,包括 CommonJS API、AMD 兼容的 RequireJS 库、SystemJS 库和 Angular 的依赖注入机制。

动态模块与新的 ESM 等静态格式有很大不同。另一方面,静态模块系统通过使用原生语言语法(特别是importexport关键字)来定义模块的合约。这种差异非常重要,需要理解。首先,JavaScript 从未有过静态模块定义,这使得它们对于大多数开发者来说是一片未知领域。此外,静态定义具有某些优势;它们允许 JavaScript 运行时预取或预加载模块,并允许你通过删除永远不会执行的代码来构建工具以优化应用程序的包大小。

表 6.1 展示了通过第 6.2 节中讨论的方法加载Transaction类。最明显的区别是动态模块系统使用常规的 JavaScript 函数,而静态系统使用importexport

表 6.1 使用不同模块系统加载Transaction(继续)

系统 类型 示例
CommonJS 动态 const Transaction = require('./domain/Transaction.js');
RequireJS 动态
requirejs(['domain/Transaction.js'], Transaction => {

 //... use Transaction

});

|

ESM 静态 import Transaction from './domain/Transaction.js';

每个加载调用的工作原理现在并不重要;重要的是你看到了所使用的语法的差异。在 ESM 中,你使用一个import语句来抽象这个过程,而不是通过函数调用遍历文件系统来加载新的代码模块。需要注意的是,在静态系统中,import语句必须出现在文件顶部。这一要求也存在于大多数其他语言中,不应被视为限制,原因如下:将这些语句静态化并在顶部清晰地定义有助于编译器和工具提前映射应用程序的结构。此外,你可以运行执行更好的静态代码分析、死代码消除甚至摇树优化(tree-shaking)的工具,这些内容我将在第 6.5 节中简要介绍。

在静态模块系统中,另一个明显的差异是使用的绑定类型。在 CJS 中,模块是普通对象引用。通过 require 函数导入对象与从任何其他函数调用中获取对象没有区别。对象的形状由模块文件中 module.exports 分配的属性决定。以下是如何使用 CJS 导入第五章中创建的 Validation 的示例:

const {Success, Failure} = require('./lib/fp/data/Validation.js');

一个更常见的例子是从 Node.js 的 filesystem fs 内置模块导入:

const { exists, readFileSync } = require('fs');

相反,ESM 模块利用了更原生和声明性的语法。对 API 的访问看起来仍然有些像常规对象,但这只是为了与语言的思维模型保持一致,并利用 CJS 紧凑方法的成功。以下是用 ESM 重写的先前的示例:

import { Success, Failure } from './lib/fp/data/Validation.js';
import { exists, readFileSync } from 'fs';

从外观上看,这些方法看起来和感觉相同,但有一个细微的差别:ESM 使用不可变的实时代码绑定,而不是对象的常规可变副本。下一列表展示了用于说明这种差异的简单费用计算器 CJS 模块。

列表 6.8 使用 CJS 定义的 calculator.js 模块

let feePercent = 0.6;

exports.feePercent = feePercent;

exports.netTotal = function(funds) {
  return precisionRound(funds * feePercent, 2);
}

exports.setFeePercent = function(newPercent) {
  feePercent = newPercent;
}

function precisionRound(number, precision) {     ❶
  const factor = Math.pow(10, precision);
  return Math.round(number * factor) / factor;
}

❶ 函数是模块私有的

请密切注意以下列表中每个语句的结果。

列表 6.9 将 calculator.js 作为 CJS 模块使用

let { feePercent, netTotal, setFeePercent } = require('./calculator.js'); 

feePercent;   // 0.6
netTotal(10); // 6
feePercent = 0.7;                                  ❶
feePercent;   // 0.7
netTotal(10); // 6                                 ❷
setFeePercent(0.7);                                ❸
netTotal(10); //7                                  ❹
require('./calculator.js').feePercent; // 0.6      ❺

❶ 重置本地定义的变量的值

❷ 使用原始模块的 0.6 值

❸ 将模块内的值设置为 0.7

❹ 正在使用新的 feePercent

❺ 保留原始值

正如你所见,将 feePercent 赋值为 0.7 改变了你本地导出的引用副本,但不会改变模块内部的引用,这可能是你预期的结果。在 ESM 中,导出的属性与模块内部的属性是连接(绑定)的。同样地,在模块内部更改导出的绑定也会改变外部某处的绑定;它是双向绑定的。实时绑定有很多好的用途,但它们确实可能导致混淆。我的建议是尽可能避免重新分配导出引用。请查看下一列表中的代码示例。

列表 6.10 将 calculator.js 作为 ESM 模块使用

import { feePercent, netTotal, setFeePercent }  from './calculator.js';

feePercent;   // 0.6
netTotal(10); // 6
feePercent = 0.7;       ❶
netTotal(10); // 6
setFeePercent(0.7);     ❷
netTotal(10); // 7      ❸
feePercent;  // 0.7     ❹

❶ 抛出错误,指出 feePercent 是只读的。从客户端看,值是不可变的。

❷ 通过 API 将模块内的值设置为 0.7

❸ 正在使用新的 feePercent

❹ feePercent 反映了新的实时值。

正如你所见,CJS 和 ESM 有略微不同的行为。按照设计,大多数差异都是在幕后发生的,以简化 ESM 的采用。从实际的角度来看,ESM 的工作方式与 CJS 类似,即几乎每个文件都被视为一个模块,每个模块都有自己的局部作用域,你可以在其中安全地存储代码和数据(类似于在 IIFE 下创建的函数作用域)。如果你已经使用过 CJS,那么 ESM 不应该是一个巨大的范式转变。

JavaScript 的未来在于 ESM,它最终将取代任何其他模块格式,并与现有的模块格式进行交互。何时会发生这一点尚不确定,因为 Node.js(例如)需要支持 CJS 一段时间以提供向后兼容性,并使过渡顺利进行。

现在,不再拖延,也不必回顾过去,让我们直接进入 ESM。

6.4 ESM 基础

在本节中,你将了解 ESM 的基础知识以及它在代码中的应用。具体来说,你将学习如何编写模块路径标识符,以及使用 importexport 关键字的变体来暴露和消费模块所需的语法。

ESM 是在 TC39 中设计的,作为一个声明式模块系统,旨在统一客户端和服务器端的依赖关系管理。从 Node.js 12 开始,你可以通过激活一个实验性标志(—experimental-modules)来实验性地使用 ESM,在 Node.js 14 中则无需标志。Node.js 将扩展名为 .js 或 .mjs(第 6.4.4 节)的文件视为模块。

ESM 标准化了一个单一的模块格式,它吸取了 CJS 和 AMD 格式的经验。这种标准化类似于多年前 Universal Module Definition (github.com/umdjs/umd) 项目所设定的目标,并取得了一些成功。问题是上述所有提到的模块格式从未完全标准化。在 ESM 中,你得到了两者的最佳之处:同步的实时绑定语句以及动态的异步 API。ESM 还保留了 CJS 所使用的简洁语法,这种语法经受住了时间的考验。

在我们深入探讨这个主题之前,有一点非常重要的事情需要记住,那就是 ESM 模块会自动进入严格模式,无需你显式地编写。

JavaScript 模块不过是一个文件或目录,它通过一些特殊的语义被指定为远程(浏览器)或本地文件系统(服务器)。ESM 使这些指定符与这两个环境兼容。不幸的是,这个限制意味着我们在服务器上会有一个不太灵活的模块系统,因为我们失去了在服务器端已经习惯的无扩展名指定符。从积极的一面来看,ESM 致力于实现一个真正的通用格式,这有助于长期的技术,如服务器端渲染和构建同构应用程序。

首先,让我们回顾一下导入和导出模块的语法,从路径指定符开始。

6.4.1 路径指定符

ESM(模块化系统)的一个重要设计目标是与浏览器保持兼容,以确保所有环境中都真正保证有一个模块格式。与 CJS 不同,ESM 中的所有模块指定符都必须是有效的 URI,这意味着(对于 Node.js 来说很遗憾)没有无扩展名的指定符或目录模块。除了裸指定符(例如'ramda')之外,如果 JavaScript 模块文件有扩展名,那么必须显式地将该扩展名添加到导入指定符中,以便正确解析。(我们之前可以省略它。)以下列表更符合常规浏览器<script>包含的方式。

列表 6.11 使用 ESM 的路径指定符

import Transaction from './Transaction.js';  
import Transaction from '../Transaction.js';
import { curry } from '/lib/fp/combinators.js';
import { curry } from 'https://my.example.com/lib/fp/combinators.js';      ❶

❶ 仅在浏览器环境中有效;在 Node.js 中不受支持

备注:值得指出的是,在浏览器中,与 Node.js 不同,文件扩展名不会告诉浏览器如何解析模块作为 JavaScript 代码。这是通过适当的 MIME 类型(text/javascript)来完成的,并且显示文件是否通过<script type='module'>包含,如下所示

<script type="module" 
   src=" https://my.example.com/lib/fp/combinators.js">
</script>

如果你使用相对路径,你必须以./../开头。以下代码生成一个模块未找到错误(与 CJS 兼容)。以下两个片段都不会作为有效的 URI 通过:

import Transaction from 'Transaction.js';
import Transaction from 'lib/fp/combinators.js';

另一个缺点是,你无法像在 CJS 中那样在 Node.js 中执行目录导入。在 CJS 中,一个文件夹中有一个index.js或适当的package.json文件,让你可以隐式地导入文件夹,而不需要附加指定符的index.js部分。由于 CJS 是为服务器制作的,它内置了智能来检测和自动完成index.js部分,就像默认情况下 Web 服务器从文件夹中提供index.html一样。遗憾的是,由于相同的规则需要适用于客户端和服务器,这种行为没有延续到 ESM。

在第 6.4.2 节和第 6.4.3 节中,我们将探讨 ESM 的两个主要功能:导出和导入。

6.4.2 导出

export语句用于暴露模块的接口或 API,在 CJS 中相当于module.exports。一个模块被定义为单个文件,可能包含一个或多个类和函数。默认情况下,文件中的所有内容都是私有的。(将模块文件视为一个空的 IIFE 可能会有所帮助。)你需要通过export关键字声明要暴露的内容。为了简洁,我不会涵盖所有可能的export组合。有关所有可能组合的完整列表,请访问mng.bz/YqeK。以下各节中描述的组合用于示例应用程序。

单值(默认)

到目前为止,在本书中展示领域类的一部分时,我故意省略了它们如何映射到文件系统——换句话说,就是使用import语句来获取类的语句。在导出方面,通常的做法是将类变成它们自己的模块。你有选择。你可以一步导出一个单个类

export default class Transaction {
   // ...
}

或者分两步:

class Transaction {
   // ...
}

export default Transaction;

单值导出通常是导出代码供他人消费的首选方式。在 Node.js 具有原生类支持之前,一个类会被转换成它自己的立即执行函数表达式(IIFE)函数。想想看,模块文件内部 export 声明之外的数据对调用者来说是完全隐藏的。其语义与 IIFE 类似,既好又一致。你可以想象一个大的 IIFE 函数,其主体是整个模块代码,这给了你声明变量、函数或其他仅对模块代码本身可访问的类的机会。例如,我们在 HasHash 中使用了这种技术来声明顶层常量:

const DEFAULT_ALGO_SHA256 = 'SHA256'; 
const DEFAULT_ENCODING_HEX = 'hex';

const HasHash = (
  keys,
  options = { algorithm: DEFAULT_ALGO_SHA256, 
              encoding: DEFAULT_ENCODING_HEX 
            }
) => ({
  // ...
});

export default HasHash;

使用 default 关键字允许你导出单个数据片段。你也可以从一个模块中导出多个值。

多值

多值 export 是创建实用模块的优雅方式。这在 BlockTransaction 中的验证函数中使用。因为你已经看到了这些函数,我将向你展示 export 语法,并省略每个方法的主体:

export const checkDifficulty = block => //...

export const checkLinkage = curry((previousBlockHash, block) =>
  // ...
);

export const checkGenesis = block => // ...

export const checkIndex = curry((previousBlockIndex, block) =>
  // ...
);

基于 standalone 函数多值导出的 API 模块有另一个巨大的好处:它们促使你以纯度为中心进行编写。因为你永远不知道函数将在什么上下文中执行,你不能假设或依赖任何共享或闭包状态。此外,除了使用工厂函数之外,创建 BitcoinService 对象的另一种方式是暴露单个纯函数,这些函数在函数参数中提前声明它们所需的所有数据。你不需要从函数的闭包中继承 ledgernetwork,你需要将它们作为实际参数:

export async function mineNewBlockIntoChain(ledger, newBlock) {
   //...
},

export async function minePendingTransactions(ledger, network, 
       rewardAddress, proofOfWorkDifficulty = 2) {
   //...
},

export function transferFunds(ledger, network, 
       walletA, walletB, funds, description) {
   //...
}

代理

一个模块可以导出并绕过另一个模块的绑定,充当代理。你可以通过使用 export ... from 语句来完成这个任务。在我们的例子中,我们可以使用这个语句将所有单个域模块(包括 BlockTransaction)组合在一个名为 domain.js 的单个模块文件中:

export { default as Block } from './domain/Block.js'
export { default as Transaction } from './domain/Transaction.js'
export { default as Blockchain } from './domain/Blockchain.js'

import 不同,你可以在模块的任何一行 export。没有规则强制放置位置。

相反,导出的代码是通过 import 语句由客户端或其他模块消费的。

6.4.3 导入

为了消费一个 API,你必须导入所需的功能,你可以作为一个整体或部分进行导入。你有许多从模块中导入的方法,可以在 mn.bz/Gx4R 找到完整的参考指南。以下部分描述了一些最常见的技术。

单值导入

要从默认 export 中导入单个对象,你可以使用

import Block from './Block.js';

这是 simplest 的情况,但你也可以请求模块的组件。

多值导入

你可以拆分单个模块的片段。下面的列表展示了如何进行多值导入。

列表 6.12 validation.js 的多值导入

import { checkIndex, checkLinkage } from './block/validations.js';      ❶

❶ 大括号表示我们正在进入模块。

注意,尽管列表 6.12 中的代码片段表明正在发生解构,但与 CJS 一起使用时并非如此。以下是一些主要区别:

  • 导入始终与其导出(活绑定)相关联,而解构则创建对象的本地副本。因为 CJS 导入对象的副本,所以解构是副本的副本。

  • 你不能在 import 语句内部进行嵌套解构。以下代码将无法工作:

    import {foo: {bar}} from './foo.js';
    
  • 属性重命名(别名)的语法不同,如下一列表所示。

列表 6.13 CJS 和 ESM 中的属性重命名

// CJS
const { foo: newFoo } = require('./foo.js');     ❶

// ESM
import {foo as newFoo} from './foo.js';          ❷

❶ CJS:将 foo 重命名为 newFoo

❷ ESM:不重命名,但创建一个指向已绑定 foo 属性的别名 newFoo

你还可以通过使用通配符(别名)导入将多值导出 API 组合成单个对象命名空间:

import * as ValidationsUtil from './shared/validations.js';

ValidationsUtil.checkTampering(...);

使用包管理器(NPM)

与原生模块一样,使用 ESM 导入第三方模块是通过裸路径进行的,没有路径分隔符或扩展名。正如预期的那样,包名需要与 node_modules 内的目录名匹配。然后,通过其伴随的 package.json 中的 main 属性确定入口点或要加载的模块。以下是一个示例:

import { map } from 'rxjs/operators';

动态导入

如果你已经在 Node.js 中编码了一段时间,你可能已经习惯了使用 CJS 条件性地加载模块。与需要在文件开头声明依赖项的 ESM 不同,CJS 允许你从任何地方 require 模块。在实际应用中,这种情况经常发生的一个用例是使用包含全局设置或功能标志的模块,以缓慢地向客户推出新代码。以下列表显示了一个示例。

列表 6.14 使用 CJS 动态加载模块

const useNewAlgorithm = FeatureFlags.check('USE_NEW_ALGORITHM', false);

let { computeBalance } = require('./wallet/compute_balance.js');

if (useNewAlgorithm) {
    computeBalance = 
        require('./wallet/compute_balance2.js').computeBalance;
}

return computeBalance(...);

根据全局 USE_NEW_ALGORITHM 设置的状态,应用程序可能会决定使用传统的 compute_balance 模块或开始使用一个新的模块。这种技术看起来很方便,但第一次需要库或文件时,JavaScript 运行时需要中断其进程以访问文件系统。因为模块是单例的,所以这种情况只会发生在第一次加载库时。之后,模块会在本地缓存(ESM 和 CJS 都支持这种行为)。同样,第二个 require 语句在缓存模块之前会阻塞主线程访问文件系统。

遵循非阻塞代码的精神,文件系统访问应该异步进行。ESM 规范纠正了这个问题,并提供了基于承诺的异步 import 版本,它与浏览器内部加载代码的方式保持一致。import 函数获取、实例化和评估所有请求的模块的依赖项,并返回一个命名空间对象,其 default 属性引用请求模块的 (export) default API,以及与模块的其他导出匹配的属性。我们将在第八章回到 JavaScript 的异步特性,但现在我将向你展示 import 的工作原理,以及它与模块系统相关的内容。对列表 6.14 中的代码进行重构看起来是这样的:

const useNewAlgorithm = FeatureFlags.check('USE_NEW_ALGORITHM', false);

let computeBalanceModule = await import(
    './ domain/wallet/compute_balance.js'
);

if (useNewAlgorithm) {
   computeBalanceModule = await 
      import('./domain/wallet/compute_balance2.js');
}

const {computeBalance} = computeBalanceModule;

return computeBalance(...);

顺便说一下,这个代码片段使用了一个称为顶层等待(top-level-await)的功能,该功能仅在 ESM 中受支持。你将在第八章中了解更多关于这个功能的内容。基本前提是你可以直接使用 await 来触发异步操作(在这种情况下是加载脚本),而无需显式编写 async 函数。

ESM 规范的另一个重要部分是引入了一个新的扩展名:.mjs。

6.4.4 新的扩展名

要指导 Node.js 使用 ESM 加载模块,你有两种选择:

  • 你可以将 package.json 的 type 字段设置为 "module"。此选项通过动态查找离你提供的 .js 文件最近的 package.json 文件来实现,从当前目录开始,然后是其父目录,依此类推。如果 JavaScript 无法确定类型,则使用 CJS。

  • 使用新的文件扩展名 (.mjs) 来识别 JavaScript 模块文件。这个扩展名在过渡到 ESM 的过程中将非常有用。同样,.cjs 文件将强制使用 CommonJS。

鼓励包和库的作者在他们的 package.json 文件中提供类型字段,以使他们的代码更清晰、文档更完善。此外,现代浏览器支持 <script type='module'> 以匹配这一新行为。

尽管许多人认为 .mjs 扩展名不够吸引人,但它有先例。例如,React 使用 .jsx 来声明 HTML 组件,我们也都使用 .json 作为存储纯文本 JSON 数据的约定。浏览器不太关注文件扩展名;它们主要关注 MIME 类型(可执行脚本的 text/javascript 或数据导入的 application/json)。我认为 .mjs 是在 JavaScript 应用程序赶上 ESM 之前的过渡路线;然后 .js 将占主导地位。

尽管如此,以下是使用 .mjs 的具体好处:

  • 向后兼容性没有问题。因为扩展是新的,所以从一开始就强制实施某些属性(例如,在所有模块上强制使用严格模式)是简单的。

  • 该扩展有助于弃用非浏览器友好的模块环境变量,例如__dirname__filenamemoduleexports。此外,你将无法在具有.mjs 扩展名的文件上使用require,反之亦然。(在.cjs 文件上使用import。)

  • 新的扩展功能传达了目的性,这与现有的模块格式(AMD、CJS 和 UMD)有明显的区别。

  • 编译器不需要额外的指令或解析(因此没有性能损失)来处理和准备/优化 JavaScript 文件以供模块使用。

  • 对模块文件有特殊的处理。现在你可以使用模块作用域的元属性,例如import.meta。目前,该对象只包含模块的 URL 或完整路径,但以后可以添加更多功能。url属性将取代全局的__dirname__filename全局变量。下一列表中的示例使用了import.meta

    列表 6.15 打印import.meta的内容

    console.log(import.meta);         ❶
    
    // { url: "file:///home/user/../src/blockchain/domain/Transaction.js" }
    

    ❶ 在 Transaction.js 内部

  • 工具体验得到改善。在诸如重构、语法高亮、代码补全和可视化等方面,IDE 可以更加直观。静态代码分析器和代码检查器可以提供更好的启发式方法和指导。

支持这个新的扩展并逐步淘汰现有的模块系统不会一夜之间发生。数百万个包和大量工具需要开始这个过程。当 ESM 随着新包和新包的更新开始缓慢流入时,我们将能够享受到 ESM 的好处。这些好处远远超出了代码本身。第 6.5 节描述了工具如何利用这种模块格式的静态特性。

6.5 ESM 对工具的好处

ESM 的静态、声明性结构有许多好处。一个明显的优点是良好的 IDE 对静态代码检查的支持。其他重要的好处包括死代码消除和树摇、更快的属性查找和类型友好性,这些内容将在以下章节中讨论。

6.5.1 死代码消除和树摇

简而言之,死代码是指在任何代码路径中都无法运行的代码。工具可以通过仔细检查代码的静态结构并追踪可能的执行路径来识别死代码。最明显的形式是已被注释掉的代码。自然地,将这段代码发送到浏览器或远程 Node.js 服务器是没有意义的,因此转换器和构建工具通常会将其移除。你还可以在函数的return语句之后出现的不可达行中找到死代码。这种情况有时出现在依赖于自动分号插入(mng.bz/QmDj)的代码中。其他不那么明显的情况包括未使用的局部变量和结果从未在其他地方使用的函数调用。

在服务器上,模块系统是文件系统的反映。在浏览器上,情况并不完全相同,但 ESM 旨在缩小这一差距。对于大型 SPA,你需要一个捆绑/构建策略。而不是通过网络请求成千上万的文件(每个文件都是一个模块),在构建时(同时,你也可以压缩它们)将它们捆绑成一个单一的负载是有意义的。

我在这本书中没有涵盖像 Browserify、Webpack 和 Rollup 这样的构建工具,但我强烈建议你研究它们所有,并为你的项目选择最好的工具。构建工具是使用 JavaScript 编码的必要部分。这些工具的中心任务是映射整个依赖树到一个或两个入口点(index.js、main.js、app.js 等)。构建工具擅长检测整个模块或模块的一部分从未被使用,然后忽略它们。因此,依赖树中未使用的模块实际上被认为是死去的,并通过摇树优化被丢弃。除了减少认知负担外,尽可能地将代码模块化而不是将所有内容打包到一个文件中也是良好的实践。

ESM 的静态结构施加了一些限制,这些限制简化了摇树优化:

  • 你只能在顶层导入模块,永远不要在条件语句内部导入。

  • 你不能在import语句中使用变量或函数。

构建工具可以依赖于匹配的exportimport语句集来映射所有未使用的模块,并如图 6.2 所示将它们移除。

图 6.2 捆绑工具可以使用应用程序的静态结构通过识别任何未使用的代码模块并将它们从打包的应用程序文件中移除来实现摇树优化。

没有这些保证,消除未使用部分将变得复杂。以 CJS 为例,你可以动态地require模块并将这些调用散布在代码的任何地方,这使得确定要删除什么和保留什么变得更加困难。

此外,使用 ESM 时,当你查看生成的捆绑代码时,根据工具的不同,你可能会在移除foo模块时看到这样的注释:

// unused export foo

一个好的建议是尽可能地将你的模块设计成松耦合和内部紧密耦合的,以方便分析你的代码。一些构建工具甚至提供了额外的支持,以识别一个函数是否是纯函数以及其结果是否被使用,然后可以安全地移除该调用,这是使用函数式风格编码的一个很好的好处。回想一下第四章,纯函数没有副作用或使用共享状态,所以一个结果未被使用的纯函数对你的应用程序没有任何贡献。检测一个函数是否是纯函数且无副作用不是一个容易解决的问题,所以你可以通过在纯函数调用前编写一些元数据来帮助工具:

/*#__PURE__*/checkTampering(...)

你可以在构建过程中运行支持此符号的某些插件。一个例子是库 Terser (github.com/terser-js/terser),它寻找这些PURE预处理器,并确定是否根据函数的结果是否被使用来将它们分类为死代码。

Block中调用的checkTampering是一个纯函数,例如。它是我们第五章讨论的验证逻辑的一部分。这里再次呈现,并带有纯元注释:

class Block {
   ...

   isValid() {
      const {
         index: previousBlockIndex,  
         timestamp: previousBlockTimestamp
       } = this.#blockchain.lookUp(this.previousHash);

       return composeM(
            /*#__PURE__*/checkTampering,
            /*#__PURE__*/checkDifficulty,
            /*#__PURE__*/checkLinkage(previousBlockHash),
            /*#__PURE__*/checkLength(64),
            /*#__PURE__*/checkTimestamps(previousBlockTimestamp),            
            /*#__PURE__*/checkIndex(previousBlockIndex),
            Validation.of
       )(Object.freeze(this));
}

如果我们将checkTampering从组合中移除,Terser 可以轻松找到它并将其标记为消除——这是由于纯函数为你提供的保证。

ESM 还有从导入代码中更快地查找属性的优势。

6.5.2 更快的属性查找

使用静态结构的好处之一涉及在导入的模块上调用属性。在 CJS 中,require API 返回一个常规 JavaScript 对象,其中每个函数调用都通过标准的 JavaScript 属性解析过程(在第二章中描述):

const lib = require('fs');

fs.readFile(...); 

尽管这种技术保持了心理模型的一致性,但它比 ESM 慢。ESM 的静态结构允许 JavaScript 运行时向前看并静态解析命名属性查找。这个过程在内部发生,代码看起来几乎相同:

import * as fs from 'fs'; 

fs.readFile(...); 

此外,了解模块的静态结构允许 IDE 提供有用的提示,以检查是否存在或拼写错误的命名属性。这种好处在静态类型语言中一直存在。

6.5.3 类型友好性

JavaScript 可能在(漫长的)未来准备好引入可能的可选类型系统。ESM 正在铺路,因为只有在类型定义在静态上提前已知的情况下,才能进行静态类型检查。我们已经在 TypeScript 或甚至像 Flow 这样的扩展库中有了参考实现。一些提议包括numberstringsymbol的类型,不同大小的intfloats,以及新的概念如enumany。尽管类型信息还相距甚远,但关于其可能的样子已经有一些共识。更多信息,请参阅附录 B。

到目前为止,我已经涵盖了足够的 ESM 语法,让你开始。但由于 ESM 是语言的一个重大补充,我跳过了许多你应该在跃迁之前熟悉的语法和技术细节。ESM 规范还支持程序性加载器 API,你可以用它来配置模块的解析和加载方式,例如。更多信息,请访问nodejs.org/api/esm.html

在 JavaScript 中,导入的模块表现得像对象,你可以像变量一样传递它们。你可以将这个概念称为“模块作为数据”。这种一致性很重要,因为我们在第七章进入了元编程的领域。

摘要

  • 你可以使用对象和函数命名空间等模式,以及立即执行函数表达式(IIFEs),在 JavaScript 中实现模块化,而无需任何模块系统。

  • 动态模块系统使用第三方或原生库在运行时管理依赖项。静态模块系统利用新的语言语法,可以在编译时优化依赖项管理。

  • ESM 是由 TC39 任务组开发的一个静态模块系统,旨在统一客户端和服务器 JavaScript 环境的模块需求,并取代所有现有的模块格式。

  • ESM 提供了许多好处,例如死代码消除、摇树优化、更快的属性查找、变量检查,以及与(可能的)未来类型系统的兼容性。

  • ESM 的一个显著特点是引入了新的文件扩展名 .mjs,以便编译器可以增强表现为模块的 JavaScript 文件。

7 沉迷于元编程

本章涵盖

  • 使用元编程和反射应用跨功能行为

  • 使用符号在应用程序的不同领域之间创建互操作性

  • 使用符号增强 JavaScript 的内部功能

  • 理解 Proxy/Reflect API 的基础知识

  • 使用装饰器增强方法的执行

  • 使用 throw 表达式提案进行更精简的错误处理

程序文本只是程序的一种表示。程序不是文本……我们需要一种不同的方式来存储和处理我们的程序。

——Sergey Dmitriev,JetBrains 的总裁和联合创始人

想象一下英特尔这样的公司,它制造 CPU 芯片。为了自动化大量重复性任务,公司编写程序让机器人制造芯片——我们称之为编程任务。然后,为了满足更高的行业需求,它编写程序让工厂制造制造芯片的机器人——我们称之为元编程。

我希望到现在为止,你已经对 JavaScript 上瘾了。我知道我确实如此。在我们旅途中涵盖的所有主题的副产品中,我们揭示了一些有趣的二分法。其中之一是“函数作为数据”(第四章):将最终值表达为执行某个函数的想法。我们在第六章中把这一概念提升到了另一个层次,即“模块作为数据”,指的是 JavaScript 将模块作为可以传递给应用程序其他部分的数据的绑定对象。

在本章中,我将介绍另一个二分法:“代码作为数据”。这个二分法指的是元编程的概念:使用代码来自动化代码或以某种方式修改或改变代码的行为。正如它对英特尔这样的公司一样,元编程有许多应用,例如自动化重复性任务或动态插入代码来处理正交设计问题,如日志记录、跟踪和跟踪性能指标,仅举几例。

本章从 Symbol 原始数据类型开始,展示您如何使用它来引导执行流程并影响低级系统操作,例如对象如何被展开或迭代,或者当对象出现在某些数学符号旁边时会发生什么。JavaScript 给您一些控制来调整此数据类型的工作方式。您将了解到您可以使用 JavaScript 符号以多种方式定义特殊对象属性,以及注入静态钩子。

元编程也与动态概念如反射和内省密切相关,这些概念发生在计算机程序将其自己的指令集作为原始运行时数据对待/观察时。在这方面,你将使用 ProxyReflect JavaScript API 通过挂钩对象和函数的动态结构来改变代码的运行时行为。想想你需要添加性能计时器或跟踪日志来测量或跟踪函数的执行,但不得不永远忍受那段代码的时候。代理非常适合以模块化的方式增强和增强对象,而不会使源代码变得杂乱。ProxyReflect API 在框架或库开发中更频繁地使用,但你将学习如何在你的代码中利用它们。

在你沉迷于这些特性之前,让我们从一些日常编码中发生的简单元编程示例开始。

7.1 JavaScript 中元编程的常见用途

当在 JavaScript 的上下文中谈论代码作为数据时,人们可能会立即将其与在代码中编写代码或使用变量来连接和/或替换代码语句联系起来。下面的列表显示了你可以使用 eval 做的事情:

列表 7.1 使用 eval 的简单示例

eval(
  `
   const add = (x, y) => x + y; 
   const r = add(3, 4); 
   console.log(r);       ❶
  `
);

❶ 将 7 打印到控制台

在严格模式下,eval 期望以原始字符串字面量(数据作为代码)的形式提供代码,并在其自己的环境中执行它。此刻,你的脑海中应该响起警报。你可以想象 eval 可以是一个非常危险和不安全的操作,可以说在当今时代被认为是多余的。

数据作为代码的另一个例子是 JavaScript 对象表示法(JSON)文本,它是对代码的字符串表示,可以直接在语言中作为对象理解。实际上,使用 ECMAScript 模块(ESM)时,你可以直接将 JSON 文件作为代码导入,而无需进行任何特殊解析,如下所示:

import libConfig from './mylib/package.json';

还要考虑计算属性名,它允许你创建一个从任何评估为字符串的表达式中得到的键。我们在第四章中使用了这个概念来支持 propprops 方法。这里有一个简单的例子:

const propName = 'foo';
const identity = x => x;

const obj = {
  bar: 10,
  [identity(propName)]: 20
};

obj.foo; // 20

当内省对象的架构时,也会发生元编程。最重要的用例是 JavaScript 的自身鸭子类型,其中对象的“类型”完全由其形状决定,使用 Object.getOwnPropertyNamesObject.getPrototypeOfObject.getOwnPropertyDescriptorsObject .getOwnPropertySymbols 等方法。这里有一个简单的例子:

const proto = {
  foo: 'bar',
  baz: function() {
    return 'baz';
  },
  [Symbol('private')]: 'privateData'
};

const obj = Object.create(proto);
obj.qux = 'qux';

Object.getOwnPropertyNames(obj); // [ 'qux' ]

Object.getPrototypeOf(obj); 
// { foo: 'bar', baz: [Function: baz], [Symbol(private)]: 'privateData' }
Object.getOwnPropertyDescriptors(obj); 
// {
//  qux: {
//    value: 'qux',
//    writable: true,
//    enumerable: true,
//    configurable: true
//  }
//}

Object.getOwnPropertySymbols(proto); // [ Symbol(private) ]

即使是函数,作为其他对象,也对其形状和内容有一定的认识。当你使用 Function#toString 打印表示函数签名和主体的字符串时,你可以看到这种认识:

add.toString(); // '(x, y) => x + y'

你可以将这种文本表示传递给一个可以理解函数做什么并相应行动的解析器,或者在必要时向其中注入更多指令。

函数的一个更有用的属性是Function#length。考虑我们在第四章中实现curry函数组合器的方式,使用length来确定声明了具有多少参数的 curried 函数,并确定要部分评估多少内部函数。

注意:由于 JavaScript 使得将数据用作代码变得简单,JavaScript 具有一些同构语言的特点。如果你喜欢,这个主题值得你自己去研究。同构语言反映了代码的语法作为数据的语法。例如,Lisp(列表编程)程序是以列表的形式编写的,这些列表可以反馈到另一个(或相同的)Lisp 程序中。所有 JSON 文本都被认为是有效的 JavaScript(github.com/tc39/proposal-json-superset),但并非所有 JavaScript 代码都可以被理解为 JSON,所以它不是完全的镜像。有趣的是,JavaScript 受到了语言 Scheme 的启发,而 Scheme 是一种同构 Lisp 方言。

这些任务是一些基本任务的例子,其中存在某种形式的元编程。但与 JavaScript 相比,还有更多超出表面之下的内容,尤其是在你开始利用特殊符号来注释代码的静态结构时。

7.2 JavaScript 符号

符号是语言中微妙而强大的特性,主要用于库和框架的开发。在正确的位置定义它们,只需付出小小的努力,对象就能发光,承担新的角色和新的行为。你可以使用符号在对象之间建立行为契约,以保持数据私密和保密,并增强 JavaScript 运行时处理对象的方式。在我们深入所有这些主题之前,让我们花些时间了解它们是什么以及如何创建它们。

首先要知道的是,与任何新的 API 不同,Symbol是一个真正的内置原始数据类型(如数字、字符串或布尔值)。

typeof Symbol('My Symbol'); // 'symbol'

Symbol代表一个动态的、匿名的、唯一的值。与数字或字符串不同,符号没有字面语法,你永远不能将它们序列化为字符串。它们遵循函数工厂模式(类似于Money),这意味着你不需要使用new来创建一个新的。相反,你通过调用Symbol函数来创建一个符号,该函数在幕后生成一个唯一的值。下面的列表显示了代码片段。

列表 7.2 符号的基本用法

const symA = Symbol('My Symbol');           ❶
const symB = Symbol('My Symbol');

symA == symB;      // false
symB.toString();   // Symbol('My Symbol')
symB.description;  // 'My Symbol' 

❶ 因为符号隐藏了它们的唯一值,你可以提供一个可选的描述,这只用于调试和日志记录目的。这个字符串不影响底层唯一的值或查找过程。

因为符号代表一个唯一值,它主要用作无碰撞的对象属性,就像使用计算属性名语法 obj[symbol] 的动态字符串键。在底层,JavaScript 将符号的唯一值映射到一个唯一对象键,你只能在你拥有符号引用的情况下检索它。以下列表展示了几个简单的用例。

列表 7.3 使用符号作为属性键

const obj = {};
const symFoo = Symbol('foo');

obj['foo']  = 'bar';                        ❶
obj[symFoo] = 'baz';                        ❷

obj.foo;     // 'bar'                       ❸
obj[symFoo]; // 'baz'                       ❸
obj[Symbol('foo')] !== 'baz'; // true       ❹

❶ 添加属性 foo

❷ 添加一个描述为 foo 的符号属性

❸ foo 和 Symbol('foo') 映射到不同的键。

❹ 你不能引用 Symbol('foo'),这将创建一个新的符号。

按设计,符号不能通过常规方式被发现。因此,使用 for..infor..ofObject.keysObject.getOwnPropertyNames 遍历对象不会起作用,这主要是出于向后兼容性的原因。唯一的方法是通过显式调用 Object.getOwnPropertySymbols 来进行内省:

for(const s of Object.getOwnPropertySymbols(obj)) {
  console.log(s.toString());
}

即使如此,这种技术也只提供了每个符号的“视图”。没有实际的符号引用,你仍然无法访问属性值。相比之下,当你展开一个对象并使用 Object.assign 时,符号引用会被复制。这种区别虽然微妙但很重要。与其他原始数据类型不同,obj 的克隆不是复制值,而是复制符号引用本身——相同的符号,而不是一个副本。请看以下示例:

const clone = {...obj};

obj[symFoo] === clone[symFoo]; // true

如第三章所述,这些操作依赖于将 enumerable 数据描述符设置为 true。如果你想有更多的隐私,你可以通过使用 Object.defineProperty 将此描述符设置为 false

到目前为止,我们还没有处理符号的具体用法——只是基础知识。在我们查看一些有趣的例子之前,了解符号是如何以及在哪里被创建的是非常重要的。

7.3 符号注册表

理解注册表将帮助你了解符号是如何以及在哪里被创建和使用的。当一个符号被创建时,它会在 JavaScript 运行时内部生成一个新、唯一且不透明的值。这些值会自动添加到不同的注册表中——本地或全局,这取决于符号是如何被创建的。使用 Symbol 构造函数,你针对本地注册表,而使用静态方法如 Symbol.for,你针对全局注册表,这是跨领域可访问的。

将注册表想象成内存中的一种映射数据结构,它允许你通过键来检索对象,就像 JavaScript 自身的 Map 一样,这有助于理解。让我们从本地注册表开始。

7.3.1 本地注册表

要定位本地注册表,你需要调用工厂函数:

const symFoo = Symbol('foo');

此函数将 Symbol('foo') 生成值添加到本地注册表中,无论您是从全局变量作用域还是从模块内部创建此符号。请记住,您只能在拥有引用它的变量时才能访问和使用符号。如果您在模块(或函数)内部声明 symFoo,则该变量仅在模块的(或函数的)作用域内可见,并且调用者只能通过您从模块(或函数)导出 symFoo(或从函数返回它)来访问它。然而,在这些所有情况下,都在使用本地注册表。

下一个列表展示了创建本地符号并从模块导出绑定的示例。

列表 7.4 导出/导入 Symbol 对象的引用

export const sym = Symbol('Local registry - module scope');        ❶

...

import { sym } from './someModule.js';

global.sym = Symbol('Local registry - global scope');              ❷
global.sym.toString(); // 'Symbol(Local registry - global scope)' 
sym.toString();        // 'Symbol(Local registry - module scope)'

❶ 在 someModule.js 中

❷ sym 和 global.sym 指向两个不同的变量。

7.3.2 节展示了全局注册表是如何起作用的。

7.3.2 全局注册表

全局注册表是一个在整个运行时中可用的内部结构。Symbol API 提供了与该注册表交互的静态方法,例如使用 Symbol.keyFor 查找符号。在本地注册表中创建的任何符号都无法使用此 API 访问。查看以下列表中的代码。

列表 7.5 使用全局注册表无法访问的本地符号

const symFoo = Symbol('foo');                   ❶
global.symFoo = Symbol('foo');                  ❶
Symbol.keyFor(symFoo);         // undefined     ❷
Symbol.keyFor(global.symFoo);  // undefined     ❷

❶ 使用本地注册表

❷ 找不到任何一个

这段代码一开始可能看起来相当不直观。访问本地注册表不需要特殊的 API。您将符号变量当作其他任何变量一样对待。但是,当您想要使用运行时范围内的注册表在应用程序的许多部分之间共享符号时,您需要特殊的 API。

静态方法 Symbol.keyForSymbol.for 被设计用来与 JavaScript 运行时内部的全球符号注册表交互。下一个列表展示了我们如何调整列表 7.5 中的代码片段以针对此注册表。

列表 7.6 与全局注册表交互

export const globalSym = Symbol.for('GlobalSymbol');     ❶

...

import { globalSym } from './someModule.js';

const symFoo = Symbol.for('foo');                        ❷
Symbol.keyFor(symFoo);    // 'foo'                       ❸
Symbol.keyFor(globalSym); // 'GlobalSymbol'              ❸

❶ 在 someModule.js 中的全局注册符号

❷ 在当前作用域中的全局注册符号

❸ 找到两个键

全局符号具有超越代码领域的额外特性。您可能不熟悉这个术语。以下是 ECMAScript 规范如何描述领域:

在评估之前,所有 ECMAScript 代码都必须与一个领域相关联。从概念上讲,一个领域包括一组固有对象、一个 ECMAScript 全局环境、在该全局环境范围内加载的所有 ECMAScript 代码,以及其他相关的状态和资源。

换句话说,领域是与在浏览器、模块、iframe 或甚至工作脚本中运行的脚本关联的环境(变量和资源集)。每个模块在自己的领域中运行;每个 iframe 有自己的窗口和自己的领域;与局部符号不同,全局符号可以在这些领域中访问,如图 7.1 所示。

图 7.1 本地和全局注册表的范围

如列表 7.6 所示,你可以使用Symbol.for(key)来创建这些符号。如果key尚未在注册表中,JavaScript 将创建一个新的符号,并将其在全球范围内以该键归档。然后你可以在应用程序的任何其他地方查找它。如果符号尚未在全局注册表中定义,该 API 返回undefined

现在你已经了解了符号的工作原理,第 7.4 节展示了它们的一些实际应用。

7.4 符号的实际应用

符号有许多实际应用。在接下来的章节中,我们将讨论如何使用它们来实现隐藏属性,并使对象与你的应用程序的其他部分进行互操作。

7.4.1 隐藏属性

符号提供了一种将属性附加到对象(数据或函数)的不同方式,因为这些属性键在运行时保证是冲突-free、碰撞-free 和唯一的。但这并不意味着你应该用它们作为所有属性的键,因为正如第 7.3 节所讨论的,符号的访问规则使得它们不方便被提取。

由于这个原因,人们认为可以使用符号来模拟私有属性,因为用户需要访问符号引用本身,你可以控制(隐藏)在相关的模块或类内部,如下面的列表所示。

列表 7.7 使用符号实现私有、隐藏属性

const _count = Symbol('count');                ❶

class Counter {
   constructor(count) {
      Object.defineProperty(this, _count, {    ❷
          enumerable: false,      
          writable: true
      });
      this[_count] = count;
    }
    inc(by = 1) {                              ❸
      return this[_count] += by;
    }
    dec(by = 1) {                              ❸
        return this[_count] -= by;
    }
}

❶ 这个值永远不会被导出,因此它被保留在模块内部作为私有。

❷ 使用Object.defineProperty使内部属性不可枚举

❸ 通过指定数量增加/减少对象的内部计数属性

在这个类外部,没有方法可以访问内部的count属性:

const counter = new Counter(1);
counter._count;               // undefined
counter.count;                // undefined
counter[Symbol('count')];     // undefined
counter[Symbol.for('count')]; // undefined

不幸的是,这个解决方案有一个缺点:符号很容易通过反射 API,如Reflect.ownKeysObject.getOwnPropertySymbols被发现。因此,它们并不是真正的私有。为什么不使用符号来公开访问并帮助你的代码不同领域(即不同的模块)之间的互操作性呢?这种用途对它们来说要好得多。

有一种方法可以建立一些跨域的属性集,这与接口在静态类型、基于类的语言中的作用类似(附录 B)。换句话说,符号可以用来在其他代码部分之间创建互操作性合约。

7.4.2 互操作性

例如,第三方库可以使用一个对象可以引用的符号,并遵守库强加的某些约定。符号是理想的互操作性元数据值。在第二章中,你了解到设置自己的原型逻辑是一个容易出错的进程。这里再次提出一个问题:

function HashTransaction(name, sender, recipient) {
    Transaction.call(this, sender, recipient);
    this.name = name;
}

HashTransaction.prototype = Object.create(Transaction);

记住,问题是忘记使用Transactionprototype属性。它应该是Object.create(Transaction.prototype)。否则,创建一个新实例会导致奇怪且令人困惑的错误:

  TypeError: Cannot assign to read only property 'name' of object 
  '[object Object]'

这个错误发生是因为代码试图修改不可写的Function .name属性。在符号存在之前,你必须使用普通属性来表示所有元数据,例如在这个例子中函数的名称。一个更好的替代方案是使用不可写的符号,这样在函数上添加name属性就永远不会引起冲突。使用符号,函数的名称可以设置如下:

HashTransaction[Symbol('name')] = 'HashTransaction';

符号可以通过防止代码意外破坏 API 合同或对象的内部工作来使对象更具可扩展性。例如,如果 JavaScript 中的每个对象都有一个Symbol('name')属性,那么任何对象的toString方法都可以轻松地以一致的方式使用它来增强其字符串表示,尤其是在混淆代码的堆栈跟踪中。(在第 7.5 节中,你将了解执行此任务的知名符号。)

此外,库作者可以使用符号强制用户遵守库强加的约定。以下几节将展示从区块链应用程序中提取的几个实际示例。

控制协议

让我们来看一个使用符号定义控制协议的例子。正如你所知,协议是一种惯例(合同),它定义了某种行为在语言中的规则。这种行为需要是唯一的,并且绝不能与其他语言特性冲突。符号非常适合这类任务。

我们即将讨论的例子直接来自我们的区块链应用程序。这个概念被称为工作量证明。

为什么工作量证明很重要?

要使某些资产获得价值,它必须既稀缺又难以提取或获得。资源的价值也遵循供需规则。例如,石油和石油产品获得价值,因为它们是非可再生资源,提取成本高昂。黄金、银和钻石也是如此,它们需要昂贵的采矿过程。同样,截至本文撰写时,比特币的上限约为 2100 万,这看起来是一个很大的数字,但与其他货币形式相比却相当稀缺。

以获得新块为目的的比特币“挖掘”或工作量证明过程在能源使用方面可能相当昂贵。尽管算法易于理解,但即使使用今天的计算能力,运行它也是耗时的。这个谜题涉及到找到一个满足某些条件的块的加密哈希值。哈希值应该难以找到但易于验证。我们将实施的唯一条件是计算出的哈希字符串必须以任意数量的前导零开始,由block.difficulty属性给出。

下一个列表显示了工作量证明函数,并介绍了一个新的提案,即抛出表达式(github.com/tc39/proposal-throw-expressions),这将使错误处理代码更加精简。

列表 7.8 工作量证明算法(proof_of_work.js)

function proofOfWork(block = 
     throw new Error('Provide a non-null block object!')) {         ❶
  const hashPrefix = ''.padStart(block ?.difficulty ?? 2, '0');     ❷
  do {
    block.nonce += 1;                                               ❸
    block.hash = block.calculateHash();                             ❹
  } while (!block.hash.toString().startsWith(hashPrefix));          ❺
  return block;
}

❶ 使用抛出表达式作为默认参数,如果提供的块未定义则抛出异常

padStart 用于填充或填充当前字符串,它是作为 ECMAScript 2017 更新的一部分添加到 JavaScript 中的。如果难度设置为 null 或缺失,则默认使用难度值为 2。

❸ 在每次迭代时增加 nonce 值

❹ 重新计算块的哈希

❺ 测试新的哈希是否包含前导零字符串

在我们讨论算法之前,让我们花一点时间来谈谈列表 7.8 中使用的 throw 表达式。throw 表达式可以被赋值,就像一个值或表达式(函数)。如果没有它,代替默认参数抛出异常的唯一方法是将异常包裹在一个函数内部。换句话说,你需要在允许 throw 的某个地方创建一个块上下文({})。有了这个新特性,抛出异常就像一等公民一样工作,就像任何其他对象一样,并且显著减少了所需的输入量。你将以多种方式抛出异常,包括参数初始化(如这里使用);一行箭头函数、条件语句和开关语句;甚至逻辑运算符的评估,所有这些都不需要块作用域。有关启用此功能的详细信息,请参阅附录 A。

列表 7.8 是一种暴力算法,因为大多数工作量证明函数都是。proofOfWork 将循环并计算块的哈希,直到它以给定的 hashPrefix 开头,该 hashPrefix 由大小为 block.difficulty 的零字符串创建。显然,难度值越高,找到该哈希就越困难。在现实世界中,第一个解决这个谜题的矿工将兑换采矿奖励,这就是矿工被激励投资和消耗专用采矿基础设施的原因。因为每个迭代中块的日期是恒定的,所以哈希值总是相同的,所以你需要为它计算一个 nonce 值。(nonce 是“一次使用”的术语。)你以某种方式更改 nonce 来区分哈希计算之间的块数据。如果你还记得 Block 的定义,nonce 是我们提供给 HasHash 的属性之一:

 Object.assign(
   Block.prototype,
   HasHash(['index', 'timestamp', 'previousHash', 'nonce', 'data']),
   HasValidation()
);

这与符号有什么关系?记住,区块链是一个大型、分布式协议。在任何时间点,矿工可能正在运行软件的任何版本,因此需要谨慎地做出更改,并及时推出。为了使增强功能或甚至错误修复更容易应用于大型比特币网络,你必须跟踪版本,并且代码必须相应地进行分叉。在现实世界中,块包含元数据,用于跟踪软件的版本。以下列表显示了之前为了简洁而省略的 Block 类的另一部分。

列表 7.9 Block 类内部的版本属性实现为符号

const VERSION = '1.0';

class Block {
  ...

  get [Symbol.for('version')]() {       ❶
     return VERSION;
  }
}

❶ 将软件版本注册为全局符号

通过在每个区块上标记一个全局版本符号,您可以保留与之前版本区块链软件持久化的区块的向后兼容性。符号让您能够以无缝和互操作的方式控制这种兼容性。

几年前,Node.js 在其console.log的实现中尝试做类似的事情,检查提供的对象上的inspect方法,并在它可用时使用它。这个特性很笨拙,因为它很容易与您意外实现的自己的inspect方法冲突,导致console.log的行为出乎意料。因此,这个特性已被弃用。如果当时有符号的话,情况可能就不同了。

假设我们想要推出软件的新版本,增强proofOfWork使其更具挑战性且更难计算。列表 7.10 显示了BitcoinServicemineNewBlockIntoChain方法,它使用全局符号注册表来读取实现Block的软件的版本,以决定如何路由工作量证明背后的逻辑。我们可以使用动态import来加载正确的proofOfWork函数。

虽然我还没有涵盖async/await的所有细节,但您应该能够理解下一个列表,因为我已经在第六章中处理了类似用例时介绍了动态import

async function mineNewBlockIntoChain(newBlock) {       
    let proofOfWorkModule;
    switch (newBlock[Symbol.for('version')]) { 
      case '2.0': {
        proofOfWorkModule = 
            await import('./bitcoinservice/proof_of_work2.js');
        break;
      }
      default: case '1.0':
        proofOfWorkModule = 
            await import('./bitcoinservice/proof_of_work.js');
        break;
    }
    const { proofOfWork } = proofOfWorkModule;

    return ledger.push(
      await proofOfWork(newBlock)
    );
}

在这里使用符号比在每个区块中添加一个常规的version属性要好得多,这是在 ECMAScript 2015 之前您必须做的事情。这种技术可以保护您的 API 用户免受意外破坏合约的风险,即添加他们自己的或修改运行时version

列表 7.11 增强型工作量证明算法(proof_of_work2.js)

function proofOfWork(block) {
  const hashPrefix = ''.padStart(block.difficulty, '0');
  do {
    block.nonce += nextNonce();                     ❶
    block.hash = block.calculateHash();
  } while (!block.hash.toString().startsWith(hashPrefix));
  return block;
}

function nextNonce() {
  return randomInt(1, 10) + Date.now();
}

function randomInt(min, max) {
  return Math.floor(Math.random() * (max - min)) + min
}

❶ 而不是每次增加 1,通过一个随机数进行增加

在现实世界中,随着区块变得更加稀缺,难度参数算法增加,计算哈希变得更为困难。工作量证明是比特币转账过程中的一步。在第八章中,我们将看到将新块挖掘到链中涉及的整个过程及其带来的奖励。

7.4.3 节探讨了符号的另一个实际例子,这次涉及到函数。

下一个列表以正确的方式做事,向负责返回其自身 JSON 表示的Block类添加了另一个全局符号属性。

序列化是将对象从一种表示形式转换为另一种表示形式的过程。最常见的一个例子是将内存中的对象转换为文件(序列化),以及从文件中恢复到内存(反序列化或活化)。由于不同的对象可能需要控制它们的序列化方式,因此实现一个允许它们进行这种控制的序列化函数是一个好主意。

以下列表显示了一个增强型工作量证明实现,它使用伪随机的nonce值而不是在每次迭代中增加它。

列表 7.10 挖掘新块

列表 7.12 Symbol(toJson),用于创建对象的 JSON 表示形式

class Block {
   ...
  [Symbol.for('toJson')]() {                  ❶
    return JSON.stringify({
        index: this.index,
        previousHash: this.previousHash,
        hash: this.hash,
        timestamp: this.timestamp,
        dataCount: this.data?.length ?? 0,    ❷
        data: this.data.map(toJson),          ❸
        version: VERSION
      }
    );
  }
}

❶ 使用全局符号,以便可以从其他模块中读取

❷ 使用可选链操作符与空值合并操作符,这两个操作符都是作为 ECMAScript 2020 规范的一部分添加的

❸ 使用 toJson 辅助函数(如列表 7.14 所示)转换数据内容

这个 JSON 表示形式是区块数据的定制、总结版本。使用这个函数,每次您需要 JSON 时,都可以从应用程序的任何地方(甚至跨领域)咨询这个符号。将区块链序列化为 JSON 使用了一个名为 toJson 的辅助函数来检查这个符号。下面的列表显示了在 BitcoinService.serializeLedger 中序列化的代码。

列表 7.13 将账本序列化为 JSON 字符串列表

import { buffer, join, toArray, toJson } from '~util/helpers.js';
...
function serializeLedger(delimeter = ';') {
   return ledger |> toArray |> join(toJson, delimeter) |> buffer;      ❶
}

❶ 使用管道操作符运行一系列函数,假设管道功能已启用(附录 A)

我已经自由地结合了您在前几章中学到的许多概念。其中最引人注目的是将逻辑分解成函数,并将这些函数柯里化以使它们更容易组合(或管道化)。如您所见,我使用了管道操作符来组合这个逻辑,并将数据作为原始缓冲区返回,可以将其写入文件或通过网络发送,从而有效地将副作用从主逻辑中分离出来。下一个列表显示了这些辅助函数的代码。

列表 7.14 用于将整个区块链对象序列化为缓冲区的辅助函数

import { curry, isFunction } from './fp/combinators.js';

export const toArray = a => [...a];                          ❶

export const toJson = obj => {                               ❷
    return isFunction(obj[Symbol.for('toJson')])
        ? obj[Symbol.for('toJson')]()
        : JSON.stringify(obj);
}

export const join = curry((serializer, delimeter, arr) 
       => arr.map(serializer).join(delimeter));              ❸

export const buffer = str => Buffer.from(str, 'utf8');       ❹

❶ 将任何对象展开为数组

❷ 将任何对象转换为 JSON 字符串。如果对象实现了 Symbol('toJson'),则代码使用该对象的 JSON 字符串表示形式;否则,它默认使用 JSON.stringify。

❸ 辅助函数,将序列化函数应用于数组的元素,并使用提供的分隔符连接数组

❹ 将任何字符串转换为 UTF-8 缓冲区对象

如您所见,toJson 首先检查对象的元属性,看是否有任何全局 JSON 转换符号;如果没有,它将回退到对所有字段的 JSON.stringify。其余的辅助函数都是您在某些时候见过且易于理解的。

回想第四章的内容,pipecompose 的逆操作。或者,您也可以按照列表 7.13 中的这种方式编写逻辑,前提是您实现了或导入了 compose 组合函数:

 return compose(
          buffer, 
          join(toJson), 
          toArray)(ledger); 

自定义符号,如 Symbol.for('toJson')Symbol.for('version'),在整个应用程序中都是已知的。这种符号的使用范围广泛且具有说服力,以至于 JavaScript 自带了一套知名的系统符号,您可以使用这些符号来弯曲 JavaScript 的运行时行为以满足您的需求。第 7.5 节探讨了这些符号。

7.5 已知符号

在你的应用程序中,你可以使用符号来增强一些关键过程,同样,你也可以使用 JavaScript 中广为人知的符号作为一个内省机制,以钩入核心 JavaScript 特性并创建一些强大的行为。这些符号是特殊的,旨在针对 JavaScript 运行时的自身行为,而任何你声明的自定义符号只能增强用户代码。

已知的符号作为 Symbol API 的静态属性可用。在本节中,我们将简要探讨

  • @@toStringTag

  • @@isConcatSpreadable

  • @@species

  • @@toPrimitive

  • @@iterator

备注:为了简单和便于文档,已知的 Symbol.<name> 常常缩写为 @@<name>。例如,Symbol.iterator@@iterator,而 Symbol.toPrimitive@@toPrimitive

7.5.1 @@toStringTag

很快,你将尝试通过调用 toString 将对象记录到控制台,但只会得到臭名昭著的(且毫无意义的)消息 '[object Object]'。幸运的是,我们现在有一个符号可以钩入这种行为。JavaScript 会检查你是否在自己的对象中重写了 toString,如果没有,它将使用对象的 toString 方法,该方法内部会钩入一个名为 Symbol.toStringTag 的符号。我建议将此符号添加到没有或不需要定义 toString 的类或对象中,因为它将有助于你在调试和故障排除过程中。

这里有一些变体,第一个使用计算属性语法,主要用于对象字面量中,第二个使用计算获取器语法,主要用于类内部:

function BitcoinService(ledger) {
  //...
  return {
    [Symbol.toStringTag]: 'BitcoinService',
    mineNewBlockIntoChain,
    calculateBalanceOfWallet,
    minePendingTransactions,
    transferFunds,
    serializeLedger
  };
}

class Block {
   //...
    get [Symbol.toStringTag]() {
       return 'Block';
    }
}

现在 toString 有更多一点信息:

const service = BitcoinService();
service.toString(); // '[object BitcoinService]')

对于使用类和伪经典构造函数(第二章)构建的对象,为了避免硬编码,你可以使用更通用的

get [Symbol.toStringTag]() {
   return this.constructor.name;
}

@@toStringTag 也用于错误处理。例如,考虑将其添加到 Money

const Money = curry((currency, amount) =>
  compose(
    Object.seal,
    Object.freeze
  )({
    amount,
    currency,
    //...

    [Symbol.toStringTag]: `Money(${currency} ${amount})`
  })
)

如果你尝试修改 Money('USD', 5),JavaScript 会抛出以下错误,使用 toStringTag 来增强错误信息:

TypeError: Cannot assign to read only property 'amount' of object '[object Money(USD 5)]'

7.5.2 @@isConcatSpreadable

这个符号用于控制 Array#concat 的内部行为。你期望从这个表达式中得到什么结果?

[a].concat([b])

你期望得到 [['a'], ['b']] 还是 ['a', 'b']?大多数情况下,你希望得到后者。这正是发生的情况。当连接对象时,concat 会确定其任何参数是否是“可展开的”。换句话说,它试图解包并展平目标对象的所有元素,使用与展开操作符类似的语义。以下是一个简单的示例,展示了该操作符的效果:

const letters = ['a', 'b'];
const numbers = [1, 2];
letters.concat(numbers);  // ["a", "b", 1, 2]
letters[Symbol.isConcatSpreadable] = false;
letters.concat(numbers); // Array ["a", "b", Array [1, 2]] 

然而,在某些情况下,你可能不希望有默认行为。考虑将记录类型,如 Pair,实现为一个简单的数组:

class Pair extends Array {
    constructor(left, right) {
      super()
      this.push(left);
      this.push(right);
    }
}

对于 Pair,你不想在与其他 Pair 连接时默认展开其元素,因为这样你会失去正确的两个元素分组:

const numbers = new Pair(1, 2);
const letters = new Pair('a', 'b');

numbers.concat(letters) // Array [1, 2, 'a', 'b']

在这种情况下,你想要的是一对对的集合。如果你关闭 Symbol.isConcatSpreadable 开关,一切都会按预期工作:

class Pair extends Array {
    constructor(left, right) {
      super();
      this.push(left);
      this.push(right);
    }

    get [Symbol.isConcatSpreadable]() {
      return false;
    }
}

numbers.concat(letters); // Array [Array [1, 2], Array ['a', 'b']]

到目前为止所描述的符号连接到一些表面的行为;其他则更深入地探索了 JavaScript API 的各个角落。第 7.5.3 节探讨了 Symbol.species

7.5.3 @@species

Symbol.species 是一个巧妙的艺术品,用于在操作某些原始对象后控制结果或派生对象的构造函数。以下几节将探讨此符号的两个用例:信息隐藏和记录操作封闭。

信息隐藏

你可以使用 Symbol.species 将派生类型降级为基类型,以避免暴露不必要的实现细节。考虑以下列表中的简单用例。

列表 7.15 使用 @@species 使 EvenNumbers 成为 Array

class EvenNumbers extends Array {
  constructor(...nums) {
    super();
    nums.filter(n => n % 2 === 0).forEach(n => this.push(n));     ❶
  }
  static get [Symbol.species]() {                                 ❷
     return Array;
  } 
}

new EvenNumbers(1, 2, 3, 4, 5, 6); // [2, 4, 6]

❶ 排除奇数被推入此数组

❷ 在任何映射操作之后隐藏派生类

到目前为止,此对象创建了一个 EvenNumberArray 的实例,正如你所期望的。但这个数据结构是从 EvenNumber 构造的事实,在初始化后对 API 的用户来说并不重要,因为 Array 已经足够好了。通过添加 @@species 元符号,在映射这个数组之后,你会看到类型被降级为 Array,并且之后用于所有操作(someeveryfilter),实际上隐藏了原始对象。以下是一个例子:

const result = evens.map(x => x ** 2);
result instanceof Array;       // true
result instanceof EvenNumbers; // false

除了 Array 之外,Promise 等类型以及 MapSet 等数据结构都支持此功能。默认情况下,@@species 指向它们的默认构造函数:

Array[Symbol.species] === Array
Map[Symbol.species] === Map        
RegExp[Symbol.species] === RegExp  
Promise[Symbol.species] === Promise
Set[Symbol.species] === Set  

这里还有一个例子,这次使用承诺。假设在用户执行某些操作后,你希望在一段时间过去后启动一个任务。(通常,你不应该扩展内置类型,但为了教学目的,我会在这里破例。)在第一个延迟操作运行后,每个后续操作都应该像标准承诺一样表现。考虑以下列表中所示的 DelayedPromise 类。

列表 7.16 将 DelayedPromise 作为 Promise 的子类进行派生

class DelayedPromise extends Promise {
   constructor(executor, seconds = 0) {       ❶
      super((resolve, reject) => {
         setTimeout(() => {
            executor(resolve, reject);
         }, seconds * 1_000); 
      })
   }

   static get [Symbol.species]() {            ❷
      return Promise;  
   }
}

❶ 创建一个承诺,其初始执行延迟由提供的秒数

❷ 隐藏派生类,以便后续对 then 的调用不会延迟

你可以像包装任何其他承诺一样包装任何异步任务,如下所示。

列表 7.17 使用 DelayedPromise

const p = new DelayedPromise((resolve) => {
      resolve(10);                            ❶
}, 3);

p.then(num => num ** 2)                       ❷
 .then(console.log);                          ❸
//Prints 100 after 3 seconds

❶ 在三秒后返回数字 10

❷ 平方最终返回的数字

❸ 使用绑定运算符传入正确绑定的控制台对象的日志函数引用

记录操作封闭

这里还有一个例子,其中 @@species 在应用程序中非常有用,尤其是在函数式编程领域。让我们回到第五章中实现的 Functor 混合,它实现了通用的 map 协议:

const Functor = {
  map(f = identity) {
    return this.constructor.of(f(this.get()));
  }
}

记住,泛函对 map 有特殊要求:它必须保留映射的类型结构。Array#map 应返回一个新的 ArrayValidation#map 应返回一个新的 Validation,依此类推。你可以使用 @@species 来保证并记录泛函封闭你期望的类型的事实——帮助保留物种,可以说。这是实现者的责任,当存在此符号时必须尊重它。数组使用此符号,我们也可以将其添加到 Validation 中,如下所示。

列表 7.18 @@speciesValidation 类中的实现

static get[Symbol.species]() {
  return this;                     ❶
}

❶ 在静态上下文中,指代周围的类

然后,我们可以增强 Functor 以在默认到对象构造函数之前挂钩到 @@species,如下所示列表。

列表 7.19 检查在泛函上映射函数时的 @@species 内容

const Functor = {
  map(f = identity) {
    const C = getSpeciesConstructor(this);            ❶
    return C.of(f(this.get()));
  }
}

function getSpeciesConstructor(original) {
  if (original[Symbol.species]) {
    return original[Symbol.species]();
  }
  if (original.constructor[Symbol.species]) {
    return original.constructor[Symbol.species]();
  }
  return original.constructor;                        ❷
}

❶ 首先查看物种函数值符号以决定派生对象类型

❷ 如果没有定义 @@species 符号,则回退到使用构造函数

这段代码的结果是

Validation.Success.of(2).map(x => x ** 2); // Success(4)

7.5.4 @@toPrimitive

当 JavaScript 将某些对象转换为(或强制转换为)原始值,例如字符串或数字时(例如,当你将对象放在加号(+)旁边或将其连接到字符串时),它会查询此符号。JavaScript 已经为其内部强制转换算法(称为抽象操作)定义了明确的规则,该规则名为 ToPrimitive。

Symbol.toPrimitive 定制了这种行为。这个值函数属性接受一个参数 hint,它可以是字符串值 numberstringdefault。在许多方面,这种操作等同于覆盖 Object#valueOfObject#toString(在第四章中讨论),除了额外的提示功能,这允许你更智能地处理这个过程。实际上,当 @@toPrimitive 未定义且 JavaScript 需要将对象强制转换为有意义的值时,这两个方法都会被检查。对于字符串和数字,一般规则如下:

  • hint 是数字时,JavaScript 尝试使用 valueOf

  • hint 是字符串时,JavaScript 尝试使用 toString

在实现 @@toPrimitive 时,我们应该尽量保持这些规则的一致性。一个经典的例子是 Date 对象。当 Date 对象被 hint 为作为字符串时,使用其 toString 表示形式。如果对象被 hint 为数字,则使用其数值表示形式(从纪元起的秒数):

const today = new Date();

'Today is: ' + today; 
// Today is: Thu Oct 31 2019 14:02:29 GMT+0000 (Coordinated Universal Time)

+today; // 1572530549275

让我们回到我们的 EvenNumbers 示例,向该类添加此符号,并实现当请求数字时对数组中的所有数字求和或在字符串上下文中创建数组的逗号分隔值(CSV)字符串表示形式,如下所示。

列表 7.20 在类 EvenNumbers 中定义 @@toPrimitive

class EvenNumbers extends Array {
  constructor(...nums) {
    super();
    nums.filter(n => n % 2 === 0).forEach(n => this.push(n));
  }

  static get [Symbol.species]() {
    return Array;
  }

  Symbol.toPrimitive {
    switch (hint) {
      case 'string':
        return `[${this.join(', ')}]`;     ❶
      case 'number':
      default:
        return this.reduce(add);           ❷
    }
  }
}

❶ 返回此数组的字符串表示形式(仅显示偶数)

❷ 通过将所有偶数相加,返回该数组的单个数字表示。

你也可以将@@toPrimitive视为一种将某些容器展开或展开为其原始值的方法。下一个列表将此元符号添加到Validation

列表 7.21 向Validation类添加@@toPrimitive以提取其值。

class Validation {
  #val;

  //... 

  get() {
    return this.#val;
  }

  Symbol.toPrimitive {       ❶
    return this.get();
  }
}

❶ 当 Validation 实例处于原始位置时,JavaScript 运行时会自动折叠容器。

现在,你可以以更少的摩擦使用这些容器,因为 JavaScript 会为你处理展开,如下面的列表所示。

列表 7.22 利用与Validation对象一起使用的@@toPrimitive

'The Joy of ' + Success.of('JavaScript'); // 'The Joy of JavaScript'

function validate(input) {
   return input 
      ? Success.of(input) 
      : Failure.of(`Expected valid result, got: ${input}`);
}

validate(10) + 5;    // 15                                                ❶
validate(null) + 5;  // "Error: Can't extract the value of a Failure"     ❷

❶ 加法运算符导致 Validation.Succes 对象处于原始位置。它自动展开包含其值的容器,值为 10。

❷ 加法运算符会导致 Validation.Failure 错误地展开并抛出错误。

值对象也是使用此符号的好机会。例如,在Money中,我们可以使用此符号直接返回数值部分,使数学运算更容易且更透明,如下一个列表所示。

列表 7.23 在Money中使用@@toPrimitive返回其数值部分。

const Money = curry((currency, amount) =>
  compose(
    Object.seal,
    Object.freeze
  )({
    amount,
    currency,

    ...

    [Symbol.toPrimitive]: () => precisionRound(amount, 2);
  })
)

const five = Money('USD', 5);
five * 2;    // 10                ❶
five + five; // 10                ❶

❶ 两个算术运算符会将 Money 对象展开以执行数值运算。

本书所涵盖的最后一个著名的符号,也是迄今为止最有用的,是@@iterator

7.5.5 @@iterator

大多数基于类的语言都有支持某种形式的IterableEnumerable接口的标准库。实现此接口的类必须遵守一个合同,该合同传达了在遍历某些集合时如何提供数据。JavaScript 的响应是Symbol.iterator,它类似于这些接口之一,并用于挂钩对象在作为for...of循环的主题、由扩展运算符消费或甚至解构时的行为机制。

如你所料,JavaScript 的所有抽象数据类型都已经实现了@@iterator,从数组开始:

Array.prototype[Symbol.iterator]();  // Object [Array Iterator] {}

数组是一个明显的选择。字符串呢?你可以将字符串视为字符数组。扩展它、解构它或遍历它,如下一个列表所示。

列表 7.24 将字符串枚举为字符数组。

[...'JoJS'];  // [ 'J', 'o', 'J', 'S' ]         ❶

const [first, ...rest] = 'JoJS';                ❷
first;   // 'J'
rest;    // ['o', 'J', 'S' ]

const str = 'JoJS'[Symbol.iterator]();          ❸
str.next();  // { value: 'J', done: false }
str.next();  // { value: 'o', done: false }
str.next();  // { value: 'J', done: false }
str.next();  // { value: 'S', done: false }
str.next();  // { value: undefined, done: true }

❶ 扩展运算符。

❷ 解构数组。

❸ 手动迭代。

同样,当Blockchain通过for循环或扩展运算符处理时,无缝地提供所有区块是有意义的。毕竟,区块链是一系列区块的集合。Blockchain将所有其区块存储需求委托给Map的私有实例字段(第三章)。以下列表显示了相关细节。

列表 7.25 使用@@iterator进行区块链操作。

class Blockchain {

  #blocks = new Map();

  constructor(genesis = createGenesisBlock()) { 
     this.#blocks.set(genesis.hash, genesis);           ❶
  }

  push(newBlock) {     
     this.#blocks.set(newBlock.hash, newBlock);
     return newBlock;
  }  

  //...

  [Symbol.iterator]() {
    return this.blocks.values()[Symbol.iterator]();     ❶
  }
}

❶ 委托给 Map#values 返回的迭代器对象。

Map 也是一个可迭代的对象,因此在对 Map 对象调用 values 时,会以数组的形式返回值(不包含键),这个数组是按设计可迭代的,这意味着我们可以轻松地让 Blockchain@@iterator 符号委托给它,如列表 7.25 所示。对于 Block 来说,也是如此,它将 data 中包含的项目(在这种情况下是每个 Transaction 对象)交付出来,如以下列表所示。

列表 7.26 在 Block 中实现 @@iterator 以枚举所有交易

class Block {

  //...  
  constructor(index, previousHash, data = []) {
    this.index = index;
    this.data  = data;  
    this.previousHash = previousHash;
    this.timestamp = Date.now();
    this.hash = this.calculateHash();
  }

  //...

  [Symbol.iterator]() {                      ❶
    return this.data[Symbol.iterator]();
  }
}

❶ 当 Block 对象被展开或循环遍历时自动交付交易

要读取一个区块中的所有交易,遍历它:

for (const transaction of block) {
  console.log(transaction.hash);
}

在我们的设计中,Transaction 是一个终端/叶对象,遍历它没有任何好处。因此,如果您的 API 用户尝试遍历它,您可以允许 JavaScript 突然出错,或者您可以自己操作迭代器,以优雅且无声地处理这种情况,通过返回对象 {done: true}

class Transaction {
   // ...

   [Symbol.iterator]() {
      return {
        next: () => ({ done: true })
      }
   }
}

迭代器协议

JavaScript 有一个定义良好的迭代器协议,它向运行时传达下一个值是什么以及迭代何时结束。该对象的形状如下所示:

{value: <nextValue>, done: <isFinished?>}

JavaScript 中的迭代器(和生成器)工作方式相同。我们将在第八章深入研究生成器,在第九章深入研究异步生成器。

此外,@@iteratorHasValidation 中验证算法的核心部分,我们在第五章中实现它,该算法依赖于遍历整个区块链结构。以下是该代码的再次展示(列表 7.27)。

列表 7.27 HasValidation 混合

const HasValidation = () => ({
    validate() {   
       return [...this]                               ❶
        .reduce((validationResult, nextItem) => 
             validationResult.flatMap(() => nextItem.validate()),
          this.isValid()
         );
    }
})

❶ 调用正在验证的对象的 Symbol.iterator 属性

现在你已经知道 @@iterator 连接到 for..of 循环和展开操作的行为,你可以设计一个比列表 7.27 中的算法更节省内存的解决方案。目前,validate 在执行时会在内存中创建新的数组:[...this]。这段代码无法扩展到大型数据结构。相反,你可以使用更传统的 for 循环直接遍历对象,如以下列表所示。

列表 7.28 将 validate 重构为使用 for 循环以利用 @@iterator

const HasValidation = () => ({            ❶
    validate() {
       let result = model.isValid();
       for (const element of model) {
          result = validateModel(element);
          if (result.isFailure) {
             break;
          }
       }
       return result;
    }
})

❶ 调用 Blockchain、Block 和 Transaction 的内部 @@iterator 属性

你可以用 @@iterator 做很多事情。从数组扩展或依赖的数据结构是自然的选择,但你还可以做更多,特别是当你将这些结构与生成器结合使用时。Generator 是从生成器函数返回的对象,并遵循相同的迭代器协议。@@iterator 是一个函数值属性,生成器可以优雅地实现它。以下列表展示了 Pair 对象的一个变体,它使用生成器在解构赋值期间 yield leftright 属性。

列表 7.29 使用生成器返回 Pairleftright 元素

const Pair = (left, right) => ({
      left,
      right,
      equals: otherPair => left === otherPair.left && 
                           right === otherPair.right,
      [Symbol.iterator]: function* () {                    ❶
        yield left;                                        ❷
        yield right;
      }
   });

const p = Pair(20, 30);
const [left, right] = p;
left;  // 20
right; // 30
[...p]; // [20, 30]

❶ 函数*符号标识一个生成器函数。

yield 关键字与常规函数中的 return 等效。

再次强调,现在不必过于担心生成器在幕后是如何工作的。你需要理解的是,函数内部的 yield 调用与调用返回的迭代器对象的 next 方法类似。幕后,JavaScript 正在为你处理这项任务。我将在第八章中更详细地介绍这个话题。

总结一下众所周知的符号,这里有一个 Pair 实现了所有这些符号,以及我们的自定义 [Symbol.for('toJson')]

const Pair = (left, right) => ({
      left,
      right,
      equals: otherPair => left === otherPair.left && 
                           right === otherPair.right,
      [Symbol.toStringTag]: 'Pair',
      [Symbol.species]: () => Pair,
      [Symbol.iterator]: function* () {
        yield left;
        yield right;
      },
      [Symbol.toPrimitive]: hint => {
        switch (hint) {
          case 'number':
            return left + right;
          case 'string':
            return `Pair [${left}, ${right}]`;
          default:
            return [left, right];
        }
      },
      [Symbol.for('toJson')]: () => ({
        type: 'Pair',
        left,
        right
      })
    });

const p = Pair(20, 30);
+p;           // 50
p.toString(); // '[object Pair]'
`${p}`;       // 'Pair [20, 30]'

const p2 = p[Symbol.species]()(20, 30);
p.equals(p2);  // true

通常,你不会加载包含所有可能符号的对象;它们的真正力量来自于使用那些真正影响你代码全局性的符号来消除重复的来源。这些例子仅用于教学目的。

你可以钩入本章未讨论的许多符号。以下代码

Object.getOwnPropertyNames(Symbol)
   .filter(p => typeof Symbol[p] === 'symbol')
   .filter(s => 
      ![
          'toStringTag', 
          'isConcatSpreadable', 
          'species', 
          'toPrimitive', 
          'iterator'
       ]
        .includes(s));

返回

  [ 
     'asyncIterator',
     'hasInstance',
     'match',
     'replace',
     'search',
     'split',
     'unscopables' 
  ]

我将在第八章中介绍 @@asyncIterator

正如你所见,符号允许你创建静态钩子,你可以使用这些钩子来对你的代码行为应用固定的增强。但如果你需要在运行时打开或关闭某些功能呢?在第 7.6 节中,我们将关注其他动态钩入运行代码的 JavaScript API。

7.6 动态内省和编织

到目前为止讨论的技术属于静态内省的范畴。你创建了标记(也称为符号),你可以或 JavaScript 运行时可以使用这些标记来改变运行代码的行为。然而,这种技术要求你直接将符号作为对象的属性添加。对于知名符号提供的扩展功能,这是唯一的选择。但是,当你考虑任何自定义符号时,从语法上修改对象的形状可能显得有些侵入性。让我们考虑另一个选项。

在本节中,你将了解一种涉及通过动态内省外部改变代码行为的技术。在这个过程中,你将学习如何使用这项技术来整合跨切面逻辑,如日志/跟踪和性能,甚至智能对象的实现。

JavaScript 使得在运行时操纵和改变对象的形状和结构变得容易。但特殊的 API 允许你钩入调用方法或访问属性的触发事件。为了理解这里的动机,思考一下流行的、广泛使用的代理设计模式(图 7.2)会有所帮助。

图 7.2 代理模式使用一个对象(代理)代表目标对象进行操作。当获取属性时,如果它在代理中找到该属性,代码就会使用该属性;否则,它会咨询目标对象。代理有很多用途,包括日志记录、缓存和伪装。

如图 7.2 所示,代理是一个包装器,它被客户端调用以访问真实内部对象。代理对象篡夺了一个对象的接口,并完全控制了其访问和使用方式。代理在接口网络通信和文件系统等方面被广泛使用。最值得注意的是,代理在应用程序代码中用于实现缓存层或可能是一个集中式日志系统。

而不是要求你每次都自己编写代理代码框架,JavaScript 将这种模式视为一等 API:ProxyReflect。这两个 API 一起允许你实现动态内省,以便以非侵入的方式在运行时编织或注入代码。这种解决方案是最佳的,因为它使你的应用程序与可注入的代码分离。在某种程度上,这种解决方案类似于通过混入(第三章)的动态扩展,除了动态扩展发生在对象构造期间,而动态编织发生在对象使用期间。

在 7.6.1 节中,我们使用动态内省将性能计数器和日志语句编织到代码的重要部分,而不触及它们的实现,从Proxy API 开始。

7.6.1 代理对象

代理有许多实际用途,例如拦截、跟踪和性能分析。Proxy对象是一种可以拦截或捕获对目标对象属性访问的对象。当一个对象使用getset或方法调用时,JavaScript 的内部[[Get]]和[[Set]]机制分别执行。你可以使用代理在你的对象中设置陷阱,以挂钩到这些内部操作。

代理允许创建具有主机对象可用的完整行为范围的对象。换句话说,它们看起来和表现就像普通对象一样,因此与符号不同,它们没有额外的属性。

关于代理,首先需要了解的是处理程序对象,它设置了对主机对象的陷阱。你可以拦截对象上的几乎所有操作,甚至继承属性。

注意:你可以对一个对象应用许多陷阱。本书中不涵盖所有陷阱——只涵盖最有用的。要获取完整列表,请访问mng.bz/zxlX

让我们从以下示例开始,该示例展示了用于跟踪或记录任何属性和方法访问的跟踪器代理对象,从get([[Get]])陷阱开始:

const traceLogHandler = {
  get(target, key) {
    console.log(`${dateFormat(new Date())} [TRACE] Calling: ${key}`);
    return target[key]; 
  }
}

function dateFormat(date) {
  return ((date.getMonth() > 8) 
    ? (date.getMonth() + 1) 
    : ('0' + (date.getMonth() + 1))) + '/' + 
        ((date.getDate() > 9) 
            ? date.getDate() 
            : ('0' + date.getDate())) + '/' + date.getFullYear();
}

如您所见,在创建日志条目后,处理程序通过返回target[key]访问的原始属性的引用来允许默认行为发生。为了看到这种行为的作用,考虑以下对象:

const credentials =  {
  username: '@luijar',
  password: 'Som3thingR@ndom',
  login: () => {
    console.log('Logging in...');
  }
};

创建此对象的代理版本很简单:

const credentials$Proxy = new Proxy(credentials, traceLogHandler);

语句

credentials$Proxy.login();   // Prints 'Logging in...'
credentials$Proxy.username;  // '@luijar'
credentials$Proxy.password;  // 'Som3thingR@ndom'

打印以下日志:

11/06/2019 [TRACE] Calling:  login
11/06/2019 [TRACE] Calling:  username
11/06/2019 [TRACE] Calling:  password

我之前说过,代理允许你拦截任何东西,我的意思是任何东西,甚至符号。所以尝试记录对象本身(不是通过调用toString)会触发幕后的一些符号。此代码

console.log(credentials$Proxy);

打印

11/06/2019 [TRACE] Calling:  Symbol(Symbol.toStringTag)
11/06/2019 [TRACE] Calling:  Symbol(Symbol.iterator)

动态编织发生时,API 对象本身没有任何知识,这是一个理想的关注点分离。我们可以更加有创意。假设我们想要混淆并隐藏任何敏感信息(如密码)不被作为纯文本读取。考虑以下列表中的处理程序,它拦截 gethas

列表 7.30 passwordObfuscatorHandler 代理处理程序

const passwordObfuscatorHandler = {
  get(target, key) {
    if(key === 'password' || key === 'pwd') {
      return '\u2022'.repeat(randomInt(5, 10));        ❶
    }
    return target[key];
  },
  has(target, key) {
    if(key === 'password' || key === 'pwd') {
      return false;
    }
    return true;
  }
}

❶ U+2022 是 Unicode 中用于项目符号字符(•)的编码。

现在从凭证中读取密码会返回混淆后的值:

credentials$Proxy.password;  // '•••••'

使用 in 操作符检查密码字段会触发 has 陷阱:

'password' in credentials$Proxy; // false

不幸的是,你似乎失去了之前拥有的跟踪行为。因为代理封装了一个对象(并且自身是普通对象),你可以在代理之上应用代理。换句话说,代理可以组合。组合代理允许你实现渐进增强或装饰技术:

const credentials$Proxy =   
    new Proxy(
      new Proxy(credentials, passwordObfuscatorHandler), 
    traceLogHandler);

credentials$Proxy.password; // '•••••'
                            // 11/06/2019 [TRACE] Calling:  password

你在第四章中学到的 FP 原则也适用于这里。你可以将这些嵌套的代理对象转换成一个优雅的从右到左 compose 管道。考虑以下辅助函数:

const weave = curry((handler, target) => new Proxy(target, handler));

weave 接收一个处理程序并等待你提供主机对象,这可以是凭证或凭证代理。让我们部分应用两个处理程序函数,一个用于日志跟踪,另一个用于自动密码混淆:

const tracer = weave(traceLogHandler);
const obfuscator = weave(passwordObfuscatorHandler);

按正确的顺序组合函数以在打印前混淆:

const credentials$Proxy = compose(tracer, obfuscator)(credentials);

credentials$Proxy.password; // '•••••'
                            // 11/06/2019 [TRACE] Calling:  password

你还可以使用更自然的从左到右的管道操作符(前提是启用了管道)。看看代码变得多么简洁和简短?

const credentials$Proxy = credentials |> obfuscator |> tracer;

看到核心原则如何应用于各种场景真是太好了。在这种情况下,通过结合元编程、函数式和面向对象范式,我们得到了最佳的实施方案。

在 7.6.2 节中,我们查看代理处理程序的镜像 API:Reflect

7.6.2 Reflect API

ReflectProxy 的补充 API,你可以用它动态地调用对象的可拦截属性。例如,你可以使用 Reflect.apply 来调用一个函数。可以说,你也可以使用语言的旧部分,如 Function#{call,apply}ReflectProxy 有类似的形状,但它为这些情况提供了一个更简洁、更上下文相关、更易于理解的 API,这使得 Reflect 成为代表 Proxy 对象执行操作的一种更自然和合理的方式。

Reflect 将最有用的内部对象方法打包成一个简单易用的 API。换句话说,所有由代理处理程序提供的 getsethas 以及其他方法在这里都是可用的。你还可以使用 Reflect 来揭示关于对象的其他内部行为,例如属性是否已定义或设置操作是否成功。你不能通过常规的反射查询(如 Object.{getPrototypeOf, getOwnPropertyDescriptors, 和 getOwnPropertySymbols})获得这些信息。

Reflect 暴露的一些内部行为的一个例子是 Reflect.defineProperty,它返回一个布尔值,表示属性是否成功创建。相比之下,Object.defineProperty 仅返回传递给函数的对象。Reflect.defineProperty 由于这个原因更有用。

下面的列表中的示例代码利用布尔结果在对象上定义一个新属性。

列表 7.31 使用 Reflect.defineProperty 创建属性

const obj = {};

if(Reflect.defineProperty(obj, Symbol.for('version'), {       ❶
  value: '1.0',
  enumerable: true
})){
   console.log(obj);  // { [Symbol(version)]: '1.0' } 
}

❶ 返回 true 表示属性已成功添加。

再次强调,由于 Reflect 的 API 与所有陷阱的代理处理器的 API 相匹配,它自然适合作为代理陷阱中的默认行为。例如,passwordObfuscatorHandler 的 [[Get]] 陷阱可以重构为如下所示。

列表 7.32 passwordObfuscatorHandler 的 [[Get]] 陷阱

const passwordObfuscatorHandler = {
  get(target, key) {
    if(key === 'password' || key === 'pwd') {
      return '\u2022'.repeat(randomInt(5, 10));  
    }
    return Reflect.get(target, key);       ❶
  }
}

❶ 使用 Reflect.get(target, key) 而不是 target[key]

此外,这个 API 兼容性意味着如果你不需要显式声明所有参数,你不必每次都这样做。让我们稍微清理一下 traceLogHandler

const traceLogHandler = {
  get(...args) {
    console.log(`${dateFormat(new Date())} [TRACE] Calling: ${args[1]}`);
    return Reflect.get(...args);
  }
}

7.6.3 节讨论了此功能的一些有趣和实用的用途。

7.6.3 其他用例

在本节中,你将了解区块链应用中动态代理的一些有趣用例,从知道在散列属性发生变化时自动重新散列自己的智能区块开始。然后你将使用代理来衡量区块链 validate 函数的性能。

自动散列的区块

回想一下,一个区块在实例化时会计算自己的哈希值:

const block = new Block(1, '123', []); 

block.hash;     
// '0632572a23d22e7e963ab4fe643af1a3a77cf11a242346352a1ad0ebc3fb0b73'

哈希值唯一标识一个区块,但如果某些恶意行为者更改或篡改区块数据,它可能会失去同步,这就是为什么区块链的验证算法如此重要的原因。理想情况下,如果一个区块的属性值发生变化(例如添加新交易或其 nonce 增加),我们应该重新散列它。要实现这种行为而不使用代理,你需要为所有可变的、散列的属性定义显式的设置器,并在每个属性变化时调用 this.calculateHash。感兴趣的属性有 indextimestamppreviousHashnoncedata。你可以想象这个过程需要多少重复的代码。

将这种动态行为统一起来就是代理的全部意义。从单一位置实现这种开/关行为也是一个优点。让我们从创建代理处理器开始,如下面的列表所示。

列表 7.33 实现 autoHashHandler 代理处理器

const autoHashHandler = (...props) => ({
    set(hashable, key) {
        if (props.includes(key) && !isFunction(hashable[key])) {
          Reflect.set(...arguments);                   ❶
          const newHash = Reflect.apply(               ❷
             hashable['calculateHash'], hashable, []
          ); 
          Reflect.set(hashable, 'hash', newHash);
          return true;
       }
    }
 })

❶ 执行默认的设置行为。通常,最好避免使用参数,但在这个情况下,参数使代码更短。

❷ Reflect.apply 在代理的目标对象上调用 calculateHash。

在这种情况下,我们使用一个函数返回一个处理器,该处理器监视我们想要的属性,如下一个列表所示。

列表 7.34 使用 autoHashHandler 自动重新散列发生变化的对象

const smartBlock = new Proxy(block, 
   autoHashHandler('index', 'timestamp', 'previousHash', 'nonce', 'data')
);

smartBlock.data = ['foo'];       ❶
smartBlock.hash;  
// e78720807565004265b2e90ae097d856dad7ad34ae1edd94a1edd839d54fa839

❶ 这个[[Set]]操作调用 calculateHash 并更新区块的哈希值。

当你构建区块对象时,使区块自动可哈希是一个很好的属性,但你要确保一旦区块被挖入链中,就立即撤销这种行为。(检查哈希是验证区块链防篡改性质的一部分。)

使用可撤销代理测量性能

在区块链的世界里,最重要且耗时的一项操作是从创世区块到最新挖矿区块验证整个链数据结构。你可以想象验证包含数百万个区块、每个区块有数百或数千笔交易的账本有多么复杂。捕捉和监控链的validate方法性能可能至关重要,但你不想让这段代码散布在应用程序代码中。此外,记住validate是通过HasValidate混入扩展的,所以在那里添加代码意味着不仅要测量区块链的验证时间,还要测量每个区块的验证时间,而这并不是我们需要的。为了收集这些指标,我们将使用 Node.js 的process.hrtime API。我们将在下一列表中定义代理处理程序。

列表 7.35 定义perfCountHandler代理处理程序对象

const perfCountHandler = (...names) => {
  return {
    get(target, key) {
      if (names.includes(key)) {
        const start = process.hrtime().bigint();         ❶
        const result = Reflect.get(target, key);
        const end = process.hrtime(start).bigint();
        console.info(`Execution time took ${end - start} nanoseconds`);
        return result;
      }
      return Reflect.get(target, key);
    }
  }
}

❶ 使用 BigInt 表示任意精度的整数

process.hrtime是一个高精度 API,它以纳秒为单位捕捉时间,使用名为BigInt的新 ECMAScript2020 原始类型,它可以执行任意精度算术,并防止在操作超过 253 - 1(Number在 JavaScript 中可以表示的最大值)的整数值时出现任何问题。

我们使用此处理程序来实例化我们的账本对象代理。但由于性能计数器应在运行时可切换(开启/关闭),我们不会使用普通代理,而是将使用可撤销代理。可撤销代理只不过是一个具有revoke方法的普通对象:

const chain$RevocableProxy = Proxy.revocable(new Blockchain(), 
    perfCountHandler('validate'));

const ledger = chain$RevocableProxy.proxy;

在添加了几块和几笔交易之后,在调用ledger.validate的末尾,控制台会打印出类似以下内容:

Execution time took 2460802 nanoseconds

你可以不将此值打印到控制台,而是将其发送到特殊的记录器以监控你的区块链性能。完成后,调用chain$RevocableProxy.revoke来关闭并从目标区块链对象中移除所有陷阱。让我提醒你,这个特性的美妙之处在于,无论它是否开启,对象都不会知道最初安装了任何陷阱。

一种称为方法装饰器的技术集中在相同的思想上。在第 7.7 节中,我们将看到如何使用 JavaScript 的 Proxy API 来模拟这种技术。

7.7 实现方法装饰器

方法装饰器帮助你将横切(正交)代码从业务逻辑中分离和模块化。类似于代理,方法装饰器可以拦截方法调用,并在方法调用前后运行(装饰)代码,这对于验证先决条件和增强方法的返回值非常有用。

为了说明目的,让我们回到我们简单的 Counter 示例:

class Counter {
   constructor(count) {
      this[_count] = count;
   }
   inc(by = 0) {
      return this[_count] += by;
   }
   dec(by = 0) {
      return this[_count] -= by;
   }
}

我们将编写一个装饰器规范,作为一个对象字面量,描述在装饰方法运行前后要执行的操作或函数,以及要装饰的方法的名称。以下是这个对象的形状:

const decorator = {
   actions: {
      before: [function],
      after: [function]
   },
   methods: [],
}

before 操作预处理方法参数,而 after 操作后处理返回值。如果你想要绕过或传递任何 beforeafter 操作,可以使用(在第四章中讨论的)identity 函数作为良好的占位符。

下一个列表创建了一个名为 validation 的自定义装饰器,它捕获以下用例:“验证传递给 Counter 对象上的 incdec 函数调用的函数参数。”

列表 7.36 定义具有 beforeafter 行为的自定义装饰器对象

const validation = {
   actions: {     
      before: checkLimit,     ❶
      after: identity         ❷
   },
   methods: ['inc', 'dec']    ❸
}

❶ 应用 checkLimit 以强制执行先决条件

❷ 在运行后不改变方法的返回值

❸ 同时装饰 inc 和 dec 方法

在这里,我们将在方法运行之前应用自定义行为,并使用一个透传函数(identity)作为 after 操作。checkLimit 确保传入的数字是有效的正整数;否则,它将抛出异常。同样,我们将使用 throw 表达式语法将函数编写为单个箭头函数:

const { isFinite, isInteger } = Number;

const checkLimit = (value = 1) => 
   (isFinite(value) && isInteger(value) && value >= 0) 
      ? value 
      : throw new RangeError('Expected a positive number');

为了将所有这些代码连接起来,我们使用 Proxy/Reflect API 通过 get 陷阱创建我们的操作绑定。挑战在于 get 不允许你访问方法调用的实际参数;正如你所知,它给出的是 target[key] 中的方法引用。因此,我们将不得不使用一个高阶函数来返回一个包装后的函数调用。这个技巧是受 mng.bz/0m7l 启发的。让我们在下一个列表中定义我们的操作绑定。

列表 7.37 将装饰器应用于代理对象的主要逻辑

const decorate = (decorator, obj) => new Proxy(obj, {
    get(target, key) {
        if (!decorator.methods.includes(key)) {
          return Reflect.get(...arguments);
        }
        const methodRef = target[key];                                    ❶
        return (...capturedArgs) => {                                     ❷
          const newArgs =                                                 ❸
              decorator.actions?.before.call(target, ...capturedArgs);
          const result = methodRef.call(target, ...[newArgs]);            ❹
          return decorator.actions?.after.call(target, result) ;          ❺
       };
    }
 })

❶ 保存方法属性引用以供以后使用

❷ 返回一个包装后的方法引用,我们可以用它来捕获参数

❸ 应用 before 操作

❹ 执行原始方法

❺ 执行原始方法

现在你可以看到应用了装饰器后对象的行为:

const counter$Proxy = decorate(validation, new Counter(3));
counter$Proxy.inc();   // 4
counter$Proxy.inc(3);  // 7
counter$Proxy.inc(-3); // RangeError

你可以从这个例子中看到,当 checkLimit 发现传递给它的值是负数时,它会突然中断 inc 操作。图 7.3 强调了装饰器增强的客户端 API 之间的交互。

图 7.3 对 counter$Proxy.inc 的调用被拦截并使用 beforeafter 操作进行包装。方法 argument (2) 通过 checkLimit 进行验证,并被允许通过到 Counter 的目标对象。其结果(identity(3))在返回过程中由 identity 函数回显,并可供调用者使用。然而,如果 checkLimit 检测到无效值,则会向调用者返回一个 RangeError

装饰器对于移除旁路代码并保持业务逻辑清晰极为有用。很容易看出,您还可以将日志记录、密码混淆和性能计数器等用例作为“之前”或“之后”的建议进行重构。

事实上,一个关于静态装饰器的提案(github.com/tc39/proposal-decorators)使用原生语法来自动化我们在这里所做的大部分工作。这些装饰器将具有 TypeScript 装饰器、Java 注解或 C# 属性的外观和感觉。例如,您可以用 @trace@perf@before@after 注解一个方法,并将所有包装代码模块化并从函数代码本身移开。静态装饰器是一个值得关注的特性;它们将极大地改变应用程序和框架开发的游戏规则。这个特性在 TypeScript Angular 框架中被广泛使用。

注意:尽管您可以通过使用反射(无论是通过符号还是代理)来做无数的事情,但请务必谨慎行事。过度使用反射是存在的,您不希望您的队友在调试代码时花费数小时来弄清楚为什么他们的代码没有按预期行为,从语法上讲。一个好的经验法则是重复。当您发现自己反复在整个代码库中编写相同或类似的代码时,这是一个很好的迹象,表明您可以深入到内省和/或代码编织中,对其进行重构和模块化。

摘要

  • 元编程是使用编程语言本身来影响新行为或自动化代码的艺术。它可以通过符号静态实现,或通过代码编织动态实现。

  • Symbol 原始数据类型用于创建唯一、无碰撞的对象属性。

  • 符号通过防止代码意外破坏 API 协议或对象的内部工作来使对象可扩展。

  • 您可以使用符号来创建静态钩子,这些钩子可以用来改变代码在基本操作(如循环、原始转换和打印)方面的行为。

  • JavaScript 随带原生的反射 API,如 ProxyReflect。这些 API 允许您动态地将代码编织到对象的运行时表示中,而不会污染它们的接口。

  • JavaScript 的反射 API 使得开发方法装饰器变得容易,这些装饰器允许您实现跨切面行为,并将代码中的重复源模块化。

第四部分. 数据

到目前为止,你已经有一个由对象和函数相互连接的架构。剩下要做的就是打开水龙头,让数据流入。就像你的将水引入房子的传统 PVC 管道系统一样,你的应用程序需要连接起来,将输入转换为所需的输出。当需要准备可能在任何时间点到达的数据时,连接一切的复杂性就出现了,而不仅仅是当你期望它时。换句话说,你不知道何时有人会打开水龙头,但你需要为他们会打开水龙头做好准备。

第八章教你如何使用承诺和直接的语言语法(async/await)驯服异步逻辑。承诺的优点是它们是可组合的对象;你可以连续进行两个或三个异步调用,而无需担心陷入回调地狱。作为网络的语言,异步执行如此普遍,以至于我们内置了语言语法来创建具有与承诺类似语义的async感知函数。

但模拟一个连接型应用程序的最好方法是组装 PVC 管道——即虚拟地组装。第九章移除了异步逻辑的复杂性,以使竞争场地保持平衡。在本章的最后,我们探讨了流范式,以组装功能性的、声明性的、组合性的和流畅的数据流,以处理异步和同步数据。这个主题单独理解并不容易;你需要借鉴前八章的教训。为此,JavaScript 正在引入一个包含Observable对象的提案,这将允许你以一致的方式处理数据流。你可能过去有机会使用过像 RxJS 这样的库,可能是直接使用,或者通过像 Angular 这样的框架。这个 API 将这个库的基本组件直接引入 JavaScript。

8 线性异步流程

本章涵盖

  • 检查基本的 Node.js 架构

  • 使用 JavaScript Promise API

  • 组装承诺链以模拟复杂的异步流程

  • 使用async/await和异步迭代器

超过十年,先知们已经提出论点,认为单个计算机的组织已经达到极限,并且只有通过多个计算机的互联才能取得真正重大的进步。

—Gene Amdahl

正如 Amdahl 所预测的,网络是一个庞大、分布式、互联的网络,我们使用的语言必须通过提供适当的抽象来应对挑战,这些抽象有助于编程这个不断演变和变化着的网络。在网络上编程与在本地服务器上编程不同,因为你无法假设数据的位置。它是在本地存储、缓存、内联网,还是在数百万英里之外?因此,JavaScript 的主要设计目标之一是它需要具有强大的异步数据操作抽象。

JavaScript 开发者已经习惯了回调模式:“这里有一些代码。去做其他事情(时间)然后完成时再调用它。”虽然这种模式让我们维持了一段时间(并且仍然如此),但它也提出了困难和独特的挑战,尤其是在大规模编程和增加复杂性时。一个常见的例子是我们需要协调事件(如按钮点击和鼠标移动)与异步操作(如将对象写入数据库)。很明显,回调在执行超过两个或三个异步调用时无法扩展。也许你已经听说过“灾难金字塔”或“回调地狱”这个术语。

对于这本书,我们假设你已经熟悉回调模式,因此我们不会深入探讨其优缺点。最重要的是解决方案。我们是否有创建一个所有 JavaScript 开发者都可以一致使用的回调抽象的方法——可能是一个具有良好定义的 API,例如代数数据类型(ADT;第五章)?从寻找这个解决方案的过程中,Promise API 诞生了,并且它已经成为表示大多数异步编程任务的一种非常流行的选择。事实上,现在几乎所有具有任何异步逻辑的新 API、库和框架几乎都表示为承诺。

本章从对常见 JavaScript 引擎架构的简要回顾开始,该架构在高级别上具有任务队列和事件循环的特点。快速理解这一架构对于理解异步代码如何工作以提供并发处理非常重要。然后我们转向 Promise API,为 JavaScript 的async/await特性打下基础。使用这个 API,你可以以线性、同步的方式表示异步过程,类似于过程式编程。Promise 让你可以思考手头的问题,而无需担心任务何时完成或数据存储在哪里。接下来,你将学习如何利用 Promise 的可组合性和强大的组合子来链接复杂的异步逻辑。最后,你将回顾动态导入语句(在第六章中简要提及),并查看包括顶层 await 和异步迭代在内的功能。

在大多数编程语言中谈论异步编程而不提及线程是很困难的。JavaScript 的情况并非如此。异步编程之所以简单,是因为 JavaScript 提供了一个单线程模型,同时利用了底层平台(浏览器或 Node.js)的多线程能力。单线程模型不是一种劣势,而是一种祝福。我们将首先窥视这一架构。

8.1 概观架构

你可能对 Node.js 和大多数 JavaScript 引擎在幕后的工作方式有所了解。在不深入任何特定运行时实现(如 V8、Chakra 或 Spidermonkey)的细节的情况下,重要的是要给你一个关于典型 JavaScript 引擎底层工作方式的概览。典型的 JavaScript 架构是

  • 事件驱动 —— JavaScript 引擎使用事件循环来持续监控任务队列,也称为回调队列。当检测到任务时,事件循环将任务从队列中取出并运行至完成。

  • 单线程 —— JavaScript 为开发者提供了一个单线程模型。没有标准、语言级别的线程 API 来创建新线程。

  • 异步 —— 所有现代 JavaScript 引擎都使用多个线程(由内部工作池管理),这样你就可以在不阻塞主线程的情况下执行非阻塞 I/O 操作。

图 8.1 展示了事件循环是这个架构的核心。事件循环的每一次心跳或 tick 都会选择并运行一个新的任务或任务的一部分。

图 8.1

图 8.1 JavaScript 的事件驱动、异步架构。核心是事件循环(一个半无限循环),这是 JavaScript 中处理并发行为的抽象。事件循环负责调度新的异步任务并将它们分配给池中可用的任何线程。当任务完成时,引擎触发操作的回调函数以将控制权交还给用户。

Node.js 的引擎在抽象多个异步操作的执行方面做得很好,使得它们看起来是同时运行的。在幕后,事件循环执行快速的调度和交换,使用自己的线程与操作系统的内核 API 或浏览器线程架构进行原生交互,并执行管理线程池中线程(称为工作者)的所有必要记录。轮询循环是无限的,但并不总是处于旋转状态;否则,它将非常消耗资源。当有感兴趣的事件或操作,如按钮点击、文件读取和网络套接字时,它才开始计时。当出现任务时,事件循环将其从任务队列中出队,并安排它运行。每个任务都会运行到完成,并调用提供的回调函数将控制权交还给用户,并返回结果(如果有)。这个过程就像时钟一样工作(字面意思),然而用户并不知道这一切正在发生。表面上,JavaScript 并没有泄露或暴露任何与线程相关的代码。

此外,线程可以通过服务器端或客户端的本地 API(DOM、AJAX、套接字、定时器等)或任何实现本地扩展的第三方库内部创建。作为 JavaScript 开发者,我们有幸拥有这项技术为我们移除这种复杂性。更不用说,我们手头上有简单的 API,它们在我们和引擎之间增加了更多的抽象层。Promise 是抵御回调地狱的第一道防线,也是简化异步流程的垫脚石。

8.2 作为承诺的 JavaScript

Promise 的创建是为了解决在回调函数中嵌套回调函数的日益复杂问题,目的是将这些调用扁平化成一个单一的、流畅的表达式。在本节中,你将学习 Promise API 如何简化异步编程的思维方式。掌握这个 API 非常重要,因为它是 async/await 和相关特性的基础。

简而言之,Promise 对象封装了一些最终(待计算)的值,就像一个普通函数一样。它能够传递一个单一的对象,无论这个对象是一个简单的原始值还是一个复杂的数组。Promise 与回调函数类似,它们清晰地传达了“去做某事;然后(在某个时间点)去做另一件事”的信息,这使得它们成为一对一替换的理想选择。以下列表展示了如何实例化一个新的 Promise 对象。

列表 8.1 实例化一个新的 Promise 对象

const someFutureValue = new Promise((resolve, reject) => {      ❶
   const value = // do work...
   if(value === null) {
     reject(new Error('Ooops!'));
   }
   resolve(value);
});

someFutureValue.then(doSomethingElseWithThatValue);             ❷

❶ Promise 也依赖于一个回调函数。这个函数被称为执行器。

then 方法允许你将多个 Promise 串联起来。

与回调一样,Promise 利用了 JavaScript 架构的全部能力。实际上,对于引擎来说,不应该有任何区别。通常,任何提供了某种形式的回调函数的事件(如鼠标事件、HTTP 请求、Promise 等)都可以使用 JavaScript 的事件循环。有时,对于简单的操作(如setTimeout),可能会使用直接的非阻塞系统调用,但这是一种引擎特定的优化。传递给Promise构造函数的函数称为执行器函数。执行器在没有阻塞主代码的情况下运行,事件循环通过巧妙地将异步块的时间片与主代码交织在一起来决定如何安排工作。图 8.2 描述了这一过程。

图 8.2

图 8.2 节点.js 架构处理异步任务的一个简化视图。事件循环将这些任务分割成时间片,使得代码永远不会阻塞,从而提供了并行的假象。

现今的每种编程语言都支持类似的 API,有时被称为任务或未来。其基本思想如下:

doSomething().then(doSomethingElse);

这些任务将运行到完成,可能需要任意长的时间。Promise#then方法清楚地表明 Promise 是在抽象时间(或延迟)。Promise 允许你以简单的方式(用普通的英语来说)处理时间,这样你就可以专注于解决真正的业务问题。有助于将它们视为异步调用的时间限制指令分隔符,就像分号在同步语句中所做的那样:

doSomething(); doSomethingElse();

Promise 是涉及等待或阻塞操作(如 I/O 或 HTTP 请求)的理想返回包装器。实际上,Node.js 的 fs 库已经从使用同步 API 逐渐发展到使用回调,最后到返回 Promise——这是随着时间的推移采用此模式的一个很好的例子。你可以找到同步和异步 API 来访问文件系统。

让我们通过一个简单的例子来展示这种演变过程,从同步方法开始:

fs.readFileSync('blocks.txt');

这种方法应该是你最不希望选择的方法(Node.js 团队通过明确将其标记为Sync来表示这一点),因为它会暂停主线程。阻塞与扩展相反,并且与 JavaScript 的事件驱动、单线程特性相悖。请谨慎使用,或者仅用于简单的单次脚本。其次是默认的回调版本:

fs.readFile('blocks.txt', (err, data) => {
  if (err) throw err;
  console.log(data);
});

此 API 使用 JavaScript 的内部调度器,使得代码在readFile调用上永远不会停止。当数据准备好时,提供的回调函数会被触发,并带有实际的文件内容。

在回调和完全基于 Promise 的文件系统库之间有一个中间步骤,是一个名为util.promisify的工具,它将基于回调的函数适配为使用 Promise:

import util from 'util';

const read = util.promisify(fs.readFile);

read('blocks.txt').then(fileBuffer => console.log(fileBuffer.length));

但有一个限制,这个实用工具与错误优先的回调一起工作,这是一种在许多 JavaScript API 中普遍存在的模式,表明回调应该是右偏的,错误状态映射到左参数,成功状态映射到右参数。(你在第五章中学习了有偏 API。)与Validation和其他单子(monads)一样,延续分支(Functor.map应用到的分支)始终在右边。然而,这种相似性是巧合的。正如你将在第 8.2.2 节中看到的,承诺(promises)和 ADT 之间有一个强烈的联系。

最后,最好的方法是使用内置的承诺化替代库来访问文件系统,在 Node.js 中作为单独的命名空间fs.promises提供:

import fs from 'fs';

const fsp = fs.promises;

fsp.readFile('blocks.txt').then(
    fileBuffer => console.log(fileBuffer.length));

不可否认,这个版本比基于回调的方法更加流畅,因为代码不再显得嵌套。使用单个异步操作,改进可能并不明显,但想想那些涉及三个或四个异步调用的更密集的任务。

现在你已经看到了使用承诺(promises)如何改进 API,让我们更深入地探讨为什么这个 API 在 JavaScript 世界中如此震撼人心。之前我说过,承诺(promises)封装了在某个任意时间要计算的价值。然而,这个抽象的美妙之处在于它模糊了数据所在的位置。

8.2.1 数据局部性原则

一般而言,数据局部性原则是指将数据移动到某个计算发生的地方附近,或者相反。数据越接近,它移动到期望目的地就越快,无论是通过系统总线还是通过互联网。数据与计算单元之间的不同距离,例如,是你在 CPU 架构中甚至在你的 JavaScript 应用程序中有不同级别缓存的原因。承诺(promises)允许我们无论数据位于何处(本地或远程)或计算需要多长时间(两秒、两分钟或两小时),都能使用相同的编程模型。这段代码可以读取文件,无论它位于服务器上的本地位置还是世界某个遥远的地点:

fsp.readFile('blocks.txt').then(
    fileBuffer => console.log(fileBuffer.length));

我们可以说承诺(promises)是延迟的伪装,而数据局部性不会影响你的编程模型。当我们讨论第九章中的可观察者(observables)时,我们会回到这个想法。此外,建模成功或错误状态的想法并非巧合。你还记得第五章中Validation ADT 的SuccessFailure吗?

8.2.2 承诺(promises)是代数性的吗?

在第五章,我们研究了 ADT 及其作为编程工具的重要性,它可以帮助我们将某些类型的问题组合起来。当我们考虑时间是一个效果时,它们在建模异步任务时也非常有效。在本节中,你将看到 ADT 的设计如何帮助你通过自动将 ADT 的所有组合性优势转移到异步代码中来理解承诺(promises)。

首先,让我们谈谈 Promise 的工作原理。当一个 Promise 对象被声明时,它立即开始其工作(执行函数)并将内部状态设置为挂起:

const p = new Promise(resolve => {
   setTimeout(() => {
     resolve('Done');
   }, 3000);  
});

console.log(p); // Promise { <pending> }

Promise 解决(在这种情况下,在 3 秒后),将只有两种可能的状态:已履行(带有值解决)或拒绝(带有错误拒绝)。图 8.3 捕捉了 Promise 对象的所有可能状态。

图 8.3 单个 Promise 对象可能的生命周期状态

如果你回想起第五章学到的内容,Promise 并没有多少不同之处于 Validation。事实上,你几乎可以将它们的图叠加起来,如图 8.4 所示。

图 8.4 Validation 类型的结构。Validation 提供了 SuccessFailure 的选择——永远不会同时两者。与 Promise 一样,计算在 Success 分支上继续。

验证也模拟了二进制状态。它假设在初始化时带有值时为 Success,然后根据映射到它的操作的结果进行切换。你可以假设 Promise 也会发生类似的事情:它们开始时是挂起或已履行,然后根据传递给 Promise#then 的每个反应或执行函数发生什么而切换。同样,如果 Validation 达到 Failure 状态,错误将被记录,操作链将中断,就像 Promise#catch 一样。

从 ADT(抽象数据类型)的角度来看这个例子,我们可以推断出 Promise 是一个封闭的上下文,拥有足够的内部管道来抽象时间的影响。Promise 遵循 Promise/A+ 规范(promisesaplus.com),目标是标准化它们,并使它们在所有 JavaScript 引擎之间互操作。

对于任何 ADT C,如果你将 Promise#then 视为 C.map,将 Promise.resolve 视为 C.of,许多 ADT 的通用属性仍然保持不变,甚至包括可组合性!唯一的小问题是 Promise#then 是左偏的,因此它将已履行(成功)回调定义为左参数,将错误回调定义为右参数。这样做的原因是可用性,因为大多数人只有在链式调用多个 Promise 时才会使用已履行回调,并在链的末尾使用单个 Promise#catch 函数来处理链中任何点发生的任何错误。

我将简要说明一些使 Promise 类似 ADT 的属性。下面的列表显示了在示例中使用的某些辅助函数。

列表 8.2 在后续代码示例和图中使用的辅助函数

const unique = letters => Array.from(new Set([...letters]));    ❶
const join = arr => arr.join('');                               ❷
const toUpper = str => str.toUpperCase();                       ❸

❶ 将字母字符串中的重复项移除,例如 “aabb” -> “ab”

❷ 将数组连接成一个字符串

❸ 将给定字符串的所有字符转换为大写

为了证明 Promise 可以像任何 ADT 一样工作并可以进行推理,这里有一些我们在第五章讨论的通用属性,这次使用 Promise

  • 身份——在Promise上执行恒等函数会产生另一个具有相同值的Promise。以下表达式

    Promise.resolve('aa').then(identity);
    

    Promise.resolve('aa');
    

    是等价的。两者都产生

    Promise { 'aa' }
    
  • 组合——两个或更多函数的组合,如fg之后,等价于先应用g然后应用f。以下语句

    Promise.resolve('aabbcc')
           .then(unique)
           .then(join)
           .then(toUpper);
    

    Promise.resolve('aabbcc')
           .then(compose(toUpper, join, unique));
    

    是等价的。两者都产生

    Promise { 'ABC' }
    

所以如果Promise#then类似于Functor.map,那么类似于Monad.flatMap的方法是哪个?正如你可能已经注意到的,Promise#then允许你返回未包装的值以及Promise包装的值;它处理两者。因此,Promise#thenFunctor.mapMonad.flatMap的组合,其扁平化逻辑在幕后处理。以下列表展示了这两种场景的使用案例。

列表 8.3 Promise#then自动扁平化嵌套的Promise

Promise.resolve('aa')
   .then(value => {
      return `${value}bb`                    ❶
   })
   .then(value => {
     return Promise.resolve(`${value}cc`)    ❷
   }); // Promise { 'aabbcc' } 

❶ 处理简单值

❷ 处理包装值

我们可以得出承诺是代数或单子的结论吗?从理论角度来看,它们不是,因为承诺没有所有数学属性。事实上,承诺不遵循我们期望从 ADT 得到的幻想之地规范(第五章)。但幸运的是,在表面上,承诺以相同的方式工作,我们可以利用这种可靠的编程模型,它具有低门槛,允许我们组装(组合)承诺链。

8.2.3 流畅链式调用

一个承诺链(promise chain)就像任何 ADT(抽象数据类型)一样工作,是通过连续调用返回的Promise对象的Promise#thenPromise#catch来创建的。这个过程在图 8.5 中展示。

图 8.5 展示了承诺链的执行细节。每个Promise对象最初都是待定的,并根据执行器回调的结果改变状态。结果是包装在一个新的待定Promise中。(图表灵感来源于mng.bz/Xdx6。)

每个执行器都会返回一个新的待定承诺(pending promise),其状态会根据其自身执行器的结果而改变。如果成功,则满足的值会传递到链中的下一个承诺,依此类推,直到返回一个非Promise对象。如果你这么想,这个过程听起来很像组合。

让我们看看成功和错误操作执行的不同场景,从一个完全链接链的简单场景开始。

完全链接链

以下列表展示了传递三个仅在先前的承诺成功后执行的响应函数的例子。在每一步,都会隐式地创建新的Promise对象。

列表 8.4 完全链接的承诺链

Promise.resolve('aabbcc')
    .then(unique)            ❶
    .then(join)              ❶
    .then(toUpper);          ❶

❶ 执行器仅在先前的承诺得到满足时被调用。

与 ADT 类似,承诺模拟了传送带或铁路方法的数据操作。每个操作执行一个新的数据转换步骤,并返回一个新的待定承诺,等待其处理函数的结果。如果函数成功应用,它将解决为已履行。您在图 8.5 中看到了详细的流程。为了使下一个用例简单,我将说明每个步骤的承诺的最终状态。图 8.6 描述了这种流程。

图片

图 8.6 显示,承诺链允许你将数据视为一个单向、前向的传送带,其中每一步都应用不同的数据转换。

列表 8.4 表示一个具有单个结果的链,而列表 8.5 则不是。

破裂的链

下一个列表显示了一个永远不会链接到任何其他对象的Promise对象。

列表 8.5 破裂的承诺链

const p = Promise.resolve('aabbcc');
p.then(unique);  // ['a','b','c']   ❶
p.then(join);    // Error           ❶
p.then(toUpper); // 'AABBCC'        ❶

❶ 当 p 按此顺序履行时,所有执行器都会被调用并接收相同的输入。此代码产生三个承诺:两个已履行和一个被拒绝。

在这种情况下,创建了三个不同的、不连续的Promise对象,它们都没有链接到其他对象。这段代码会导致运行时错误,这很可能是意外的。图 8.7 显示了错误和每个结果承诺中存储的值。

图片

图 8.7 这种方法不会形成链:这是一个错误。代码向同一个Promise对象添加了多个处理程序,每个处理程序对原始数据应用一个转换,并得到三个不同且意外的结果。

列表 8.5 中显示的示例是一个常见的错误。在这种情况下,uniquejointoUpper都接收'aabbcc'作为输入,这并不是程序员可能想要的结果。发生的情况是,Promise对象被传递了三个不同的反应函数,然后按顺序对相同的输入值执行它们。不仅结果不正确,而且其中一个承诺因TypeError而出错。让我们看看如果我们将错误处理程序附加到失败的承诺会发生什么(图 8.8)。

图片

图 8.8 恢复失败的承诺并返回一个默认的空值。这种方法返回另一个立即解决的待定承诺,其值为空。

如您所料,Promise#catch处理函数只会应用于隔离的Promise对象并恢复,但另一个可能会轻易失败。当任务涉及多个异步操作时,通常可以看到嵌套的承诺。

嵌套链

假设你正在处理某个远程数据存储,你想要获取特定用户的购物车项并返回一个单一的对象作为响应。为此,你需要合并两个端点的数据并将两个响应结合起来。最佳选择是使用承诺组合器,我们将在第 5.3 节中探讨。另一种方法是嵌套承诺。

确实,Promise 是为了避免编写嵌套回调而设计的,而是使用扁平链。然而,Promise 比回调更好的原因是,一个正确嵌套的 Promise 仍然是一个单一的链式 Promise,尽管有缩进。正如下一个列表所示,这种心理模型更容易推理。

列表 8.6 嵌套的 Promise

const concat = arr1 => arr2 => arr1.concat(arr2);

Promise.resolve('aabbcc')
  .then(unique)
  .then(abc => 
    Promise.resolve('ddeeff')       ❶
       .then(unique)
       .then(def => abc.concat(def))       
  )
  .then(join)
  .then(toUpper); // 'ABCDEF' 

❶ 嵌套链的链接

正如你所见,甚至返回一个嵌套的 Promise 也会与主链连接,如图 8.9 所示。

图 8.9 包含嵌套 Promise 的流程。返回的嵌套 Promise 将其自身与源 Promise 对象链接起来,模拟线性流程。

在嵌套中真正的挑战是处理错误。你如何决定结构化你的代码取决于你如何计划你的数据和/或错误传播。数据通过使用 Promise#then 来传播。错误通过使用 Promise#then(, onRejected)Promise#catch 来传播。

哪个更好:catch 还是 then

每种方法都有优点和缺点,并且两者工作方式略有不同。不过,一般来说,Promise#catch 似乎更受欢迎,并且也更熟悉来自不同背景的开发者,例如 Java。然而,除非你在每个 Promise#then 后添加 Promise#catch,否则链中的任何错误都将由下游的 Promise#catch 块处理,你将不知道哪个处理程序导致了它。使用 Promise#then 确实可以让你对错误发生的位置(前一个)有更多的控制,但牺牲了语法上的流畅性。尽管如此,两种方法都遵循相同的链式规则,因为它们都返回新的挂起 Promise。

无论如何,JavaScript 是首选的语言,所以使用最适合你和你编码偏好的方法。然而,在这本书中,我们将坚持使用 Promise#catch,因为它也与观察者中存在的错误处理下游模式相一致(第九章)。

困难的部分是确定你附加反应函数的根 Promise 对象。为了回到我们的简单用例,让我们在下一个列表中故意让链中的某个函数失败。

列表 8.7 完全链接的链带有错误

Promise.resolve('aabbcc')   
   .then(unique)
   .then(() => throw new Error('Ooops!'))
   .then(join)                                        ❶
   .then(toUpper)                                     ❶
   .catch(({message}) => console.error(message));     ❷

❶ 跳过的

❷ 捕获处理程序接收错误对象并打印 Ooops!

第三行的错误触发了下游的 Promise#catch 调用的拒绝处理程序,有效地跳过了 jointoUpper 步骤。

下一个列表展示了使用嵌套 Promise 和错误的示例。

列表 8.8 带有错误的嵌套 Promise 链

Promise.resolve('aabbcc')
  .then(unique)
  .then(data => {
    Promise.resolve(data)
       .then(join)
       .then(() => throw new Error('Nested Ooops!'))     ❶
  })
  .then(toUpper)                                         ❷
  .catch(({message}) => console.error(message));

❶ 嵌套的 Promise 因错误而失败但未处理

❷ 在未定义的错误上抛出属性访问

在这种情况下,你预计嵌套链将加入主链,并在最后打印“嵌套 Ooops!”。你能找到阻止这种情况发生的错误吗?没错:开发者忘记返回嵌套承诺以正确地嵌入链中。现在这个嵌套承诺本质上是一个新的流氓挂起承诺(图 8.10)。

图 8.10 因为开发者忘记返回 Promise 对象,嵌套承诺自行运行,其结果或错误(视情况而定)永远不会加入主承诺链。

这种结果通常发生在作者忘记返回嵌套的承诺对象,或者想要使用箭头函数但错误地使用了花括号。下面的列表修复了这个问题。

列表 8.9 重新加入嵌套链以正确处理错误

Promise.resolve('aabbcc')
  .then(unique)
  .then(data =>                                        ❶
    Promise.resolve(data)
       .then(join)
       .then(() => throw new Error('Nested Ooops!'))  
   )
  .then(toUpper)
  .catch(({message}) => console.error(message))        ❷

❶ 移除了括号以创建箭头函数

❷ 打印“嵌套 Ooops!”到控制台

现在嵌套承诺已正确嵌入链中,数据(或错误,在这种情况下)按预期传播,打印出“嵌套 Ooops!”(图 8.11)。

图 8.11 有效地修复了错误,简化了链。现在每个结果/错误都被处理并计入。

承诺的设计在 Promise#catch 调用上也是流畅的。这种技术对于使用默认值恢复错误很有用。考虑以下列表中的简单修复。

列表 8.10 使用默认值恢复错误

Promise.resolve('aabbcc')
  .then(unique)
  .then(data =>               ❶
    Promise.resolve(data)
       .then(join)
       .then(() => throw new Error('Inside Ooops!'))
       .catch(error => {
         console.error(`Catch inside: ${error.message}`)
         return 'ERROR'
       })
   )
  .then(toUpper)
  .then(::console.log)
  .catch(({message}) => console.error(message));

❶ 当你使用箭头函数时,返回语句是隐式的。

重要的是要提到,你的承诺链必须能够处理错误情况。如果它们无法做到这一点,JavaScript 引擎会发出警告(在撰写本文时)。你可能已经在你的控制台中看到过这条消息:

UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). 

DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

这条消息告诉你的事,在内部,JavaScript 引擎正在为你处理错误,并以优雅的方式失败。正如你所预期的,你不会想让这种情况永远继续下去,所以如果你现在看到这个警告,你可能遗漏了一些错误处理代码,最好的办法是立即修复这个问题。

最后(没有打趣的意思),你可以使用 Promise#finally 方法结束承诺链。正如你所期望的,this 回调与 try 块之后的 finally 块具有相同的语义结构。无论承诺是成功还是失败,回调都会在承诺解决后执行,如下面的列表所示。

列表 8.11 使用 Promise#finally 的承诺链

Promise.resolve('aabbcc')
  .then(unique)  
  .then(join)
  .then(toUpper)
  .then(console.log)        ❶
  .finally(() => {
    console.log('Done')     ❷
  });

❶ 打印 'ABC'

❷ 无论承诺的状态如何,总是打印 'Done'

如你所见,操纵承诺链需要仔细地穿针引线和连接承诺对象。如果你需要嵌套一个承诺来执行额外的异步逻辑,请记住将其连接回主线路。

8.2.4 野外的承诺

本节提供了一些现实世界的例子。第一个例子使用承诺化的文件系统 API 来计算保存到文件中的所有块。为此任务,我们将在下一列表中编写一个名为 countBlocksInFile 的函数。

列表 8.12 在文件中计算所有块的数量

function countBlocksInFile(file) {
   return fsp.access(file, fs.constants.F_OK | fs.constants.R_OK)
      .then(() => {                                                 ❶
         return fsp.readFile(file)
      })
      .then(decode('utf-8'))
      .then(tokenize(';'))
      .then(count)
      .catch(error => {
          throw new Error(`File ${file} does not exist or you have 
             no read permissions. Details: ${error.message}`)
       });
}

countBlocksInFile('blocks.txt')
   .then(result => {      
      result // 3
   });

❶ fsp.access 不产生值。如果访问被允许,它将解析;否则,它将拒绝。

这里有一个另一个现实世界的例子,它将挖掘新块并加入链的复杂逻辑实现。这段代码很复杂,因为它混合了同步和异步代码,涉及几个嵌套的异步操作:一个长时间运行的挖矿操作和一个动态的 import 来读取挖矿奖励设置。这个服务函数在 BitcoinService 中实现。

列表 8.13 中的代码显示了区块链协议的一个关键部分——当然是一个简化的版本。它突出了矿工为了获得任何奖励需要做的广泛工作。本质上,矿工将新块挖入链中。这个挖矿过程还运行了工作量证明算法。在成功挖矿后,矿工将收集之前作为挂起交易存储的所有奖励。在块被插入后,矿工从开始到结束验证整个区块链结构。所有这些任务都会在一个单独的矿工节点上运行,该节点有自己的整个区块链树的副本。在我们的例子中,区块链服务负责创建一个新的奖励交易,并将该交易作为挂起交易放回链中,以供下一个矿工加入。所有这些操作可能需要不同长度的时间,因此使用承诺来平滑所有这些操作并保持一个平坦、易于推理的结构是有益的。

列表 8.13 在链中挖矿一个块

  function minePendingTransactions(rewardAddress, 
       proofOfWorkDifficulty = 2) {

    const newBlock = new Block(ledger.height() + 1, ledger.top.hash,
      ledger.pendingTransactions, proofOfWorkDifficulty);

    return mineNewBlockIntoChain(newBlock)                     ❶
      .then(:: ledger.validate)                                ❷
      .then(validation => {
        if (validation.isSuccess) {
          return import('../../common/settings.js')            ❸
            .then(({ MINING_REWARD }) => {                     ❹
              const fee =
                Math.abs(
                  ledger.pendingTransactions
                    .filter(tx => tx.amount() < 0)
                    .map(tx => tx.amount())
                    .reduce((a, b) => a + b, 0)
                ) *
                ledger.pendingTransactions.length *            ❺
                0.02;

              const reward = new Transaction(
                network.address, rewardAddress,                ❻
                Money.sum(Money('B|', fee), MINING_REWARD), 
                'Mining Reward');
              reward.signTransaction(network.privateKey);

              ledger.pendingTransactions = [reward];           ❼

              return ledger;
            })
        }
        else {
          new Error(`Chain validation failed ${validation.toString()}`);
        }
      })
      .catch(({ message }) => console.error(message));
  }

❶ 将新块挖入链中:我们的第一个异步操作。

❷ 验证整个链。与 fs.access 类似,成功的验证会导致承诺解析。失败的验证会转化为下流拒绝。捕获块接收错误并记录它。有关绑定操作器的更多信息,请参阅附录 A。

❸ 动态导入设置。动态导入使用承诺。这个新的嵌套异步操作被链回到现有的更大的链中。

❹ 解构 MINING_REWARD 设置。这个值被区块链系统用来插入一个奖励矿工的交易。这个奖励在下一个块被添加到链中时生效。

❺ 更多交易意味着更多奖励。

❷ 服务创建一个新的奖励交易。

❻ 清除所有挂起的交易并将奖励放入链中以激励下一个矿工。

尽管复杂,但在这个阶段,链式方法应该对你来说很熟悉,因为我们从第五章开始就一直在讨论 ADT,并且一直在构建操作序列。当你能够将Promise#thenFunctor.mapMonad.flatMap相关联时,一切都会更有意义。将正确的抽象应用于手头的问题会使你的代码更简洁、更健壮,这也是为什么承诺优于回调的原因。

到目前为止,我已经介绍了单文件承诺链。通常,你可能需要同时处理多个任务。也许你正在将多个 HTTP 调用中的数据混合,或者从多个文件中读取。这种情况会导致承诺链中出现分支。

8.3 API 回顾:承诺组合子

函数组合子(composecurry)接受函数并返回一个函数,承诺组合子接受一个或多个承诺并返回一个单一的承诺。正如你所知,ECMAScript 2015 附带两个极其有用的静态操作:Promise.allPromise.race。在本节中,我将回顾这两个 API,并介绍两个新的组合子,它们有助于解决额外的用例:Promise.allSettledPromise.any。这些组合子对于将复杂的异步流程简化为简单的线性链非常有用,尤其是在需要从多个远程源组合数据时。为了更好地说明这些技术,我们需要找到一个可以用来测试这些 API 的长运行操作。

让我暂停一下,设置代码示例。在第七章(列表 7.8)中,我展示了一个简单的工作量证明函数。这里再次展示:

function proofOfWork (block = 
     throw new Error('Provide a non-null block object!')) {
  const hashPrefix = ''.padStart(block ?.difficulty ?? 2, '0'); 
  do {
    block.nonce += 1; 
    block.hash = block.calculateHash();  
  } while (!block.hash.toString().startsWith(hashPrefix)); 
  return block;
}

此函数使用暴力重新计算给定块的哈希值,直到其值以提供的前缀开始。在每次迭代中,块的nonce属性都会更新并纳入哈希过程。这个操作可能立即发生,也可能需要几秒钟才能完成,具体取决于hashPrefix的长度以及正在哈希的数据的性质。再次强调,使用承诺意味着我们不必担心这个操作。

我们即将看到的示例以异步方式调用工作量证明函数,使用一个名为proofOfWorkAsync的新函数。为了模拟真正的并发,我们可以使用实现 Worker Threads API 的特殊 Node.js 库(nodejs.org/api/worker_threads.html)。当然,这些库不是 JavaScript 语言的一部分。JavaScript 的内存模型是单线程的,正如本章开头所讨论的。相反,这些库使用低级 OS 线程进程,并通过一个称为Worker(线程)的抽象来并行执行 JavaScript。

worker_threads模块可以帮助你在服务器上解决这个问题,它与浏览器中的 Web Workers API 类似。这个函数看起来如下所示。

列表 8.14 使用 Worker Threads API 的工作量证明包装器

import { Worker } from 'worker_threads';
...
function proofOfWorkAsync(block) {
  return new Promise((resolve, reject) => {                          ❶
    const worker = new Worker(<path-to-proof-of-work-script>.js, {
      workerData: toJson(block)                                      ❷
    });
    worker.on('message', resolve);                                   ❸
    worker.on('error', reject);                                      ❹
    worker.on('exit', code => {
      if (code !== 0)
        reject(new Error(`Worker stopped with exit code ${code}`));
    });
  });
}

❶ 使用承诺包装工作执行

❷ 通过使用 toJson 辅助函数将序列化的 JSON 区块数据传递给工作量证明脚本,该函数连接到对象的 Symbol.for('toJson')(见第七章)

❸ 处理从脚本返回的消息作为解析

❹ 处理拒绝错误

现在,让我们看看工作脚本代码。这个脚本加载、调用工作量证明函数,并将结果发送回调用脚本。从调用者的角度来看,从工作开始到“消息”或“错误/退出”事件最终触发的时间隐藏在承诺中,有效地从等式中消除了时间的概念。

工作脚本很简单;它反序列化传递给它的 JSON 区块字符串消息,然后使用它创建一个 Block 对象,这是 proofOfWork 所需要的。最后,结果被发送回主线程,如下一个列表所示。

列表 8.15 网页工作线程逻辑

import {
    parentPort, workerData
} from 'worker_threads';
import Block from '../../Block.js';
import proofOfWork from './proof_of_work.js';

const blockData = JSON.parse(workerData);       ❶

const block = new Block(blockData.index, blockData.previousHash, 
     blockData.data, blockData.difficulty);
proofOfWork(block);                             ❷

parentPort.postMessage(block);                  ❸

❶ 反序列化 JSON 表示

❷ 运行工作量证明算法

❸ 将哈希过的区块数据发送回主线程

并行处理超出了本书的范围,但主要思想是实例化一个 Worker,它有一个指向执行某些并行任务的脚本的句柄。然后你使用消息传递将数据(在这种情况下,是哈希过的区块对象)发送回主线程。

你即将看到的示例依赖于运行 proofOfWokAsync,传递具有不同难度设置的区块。因为我们不感兴趣于形成一个区块链来跟踪交易和所有工作,我们可以直接使用 Block API。此外,我们还将使用几个辅助函数,一个用于生成随机哈希以填充新区块的 previousHash 构造函数参数,另一个用于在预定时间后模拟拒绝,如下面的列表所示。

列表 8.16 下一个 async 示例中使用的辅助函数

function randomId() {
  return crypto.randomBytes(16).toString('hex');
}

function rejectAfter(seconds) {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Operation rejected after ${seconds} seconds`))
    }, seconds * 1_000);                                                ❶
  });
}

❶ 此代码使用数字分隔符,使长数字更易于阅读,使用视觉分隔符在数字组之间进行分隔。

由于我们使用承诺来封装这个过程,调用者不知道操作是如何或在哪里进行的;它是位置无关的。

让我们开始回顾承诺组合器,从 Promise.all 开始。

8.3.1 Promise.all

你可以使用 Promise.all 以并发方式安排多个独立操作,并在所有操作完成后收集单个结果。当需要将来自不同 API 的数据合并为单个对象时,这种技术非常有用,可以利用 Node.js 的内部多线程机制(在第 8.1 节中讨论)。下一个列表显示了一个示例。

列表 8.17 使用 Promise.all 组合承诺

Promise.all([
   proofOfWorkAsync(new Block(1, randomId(), ['a', 'b', 'c'], 1), 500),
   proofOfWorkAsync(new Block(2, randomId(), [1, 2, 3], 2), 1000)
  ])
  .then(([blockDiff2, blockDiff3]) => {             ❶
     blockDiff2.hash?.startsWith('0');  // true
     blockDiff3.hash?.startsWith('00'); // true
  });

❶ 返回一个数组,其顺序与输入数组相同

从高层次来看,这段代码看起来很像分叉-合并模型:它“同时”启动所有任务,等待它们完成,然后将它们合并成一个单一的总结果。在拒绝的情况下,它使用第一个拒绝的承诺进行拒绝。

而不是等待所有承诺都完成,假设你只对第一个成功的操作感兴趣。在这种情况下,你可以使用 Promise.race

8.3.2 Promise.race

此方法返回一个承诺,其中包含第一个承诺的结果,无论是履行还是拒绝,以及该承诺的值或原因。Promise.race 解决有趣的问题。假设你正在实现一个具有高度可用 API 后端或分布式缓存的 Web 前端——这是现代云部署中常见的情况。你有一个位于美国东部地区的 API 后端和一个位于美国西部地区的后端。你可以使用 Promise.race 同时从这两个地区获取数据。延迟最低的地区获胜。这种情况可以保证你的后端在用户在全国漫游时的性能一致性。

让我们使用这个 API 在下一个列表中同时比较两个块的哈希。

列表 8.18 使用 Promise.race 组合承诺

Promise.race([
   proofOfWorkAsync(new Block(1, randomId(), ['a', 'b', 'c'], 1)),
   proofOfWorkAsync(new Block(2, randomId(), [1, 2, 3], 3))
  ])
  .then(blockWinner => {                             ❶
     blockWinner.hash?.startsWith('0');  // true
     blockWinner.index;                  // 1
  });

❶ 返回单个结果

如你所预期,难度值较小的块赢得了比赛。Promise.all 在任何承诺被拒绝时短路,而 Promise.race 在任何承诺解决时短路。相比之下,Promise.allSettledPromise.any 对错误的敏感性较低,允许你提供更好的错误处理。你可以在 8.3.3 节中看到这些组合器的作用。

8.3.3 Promise.allSettled

使用 Promise.all 的缺点是,如果提供的任何承诺被拒绝,则承诺将拒绝。如果你试图加载数据以渲染应用程序的多个部分,一个失败意味着你必须在所有部分中显示错误消息。如果你不希望这样,也许你只想在数据获取操作失败的部分显示错误。

作为 ECMAScript 2020 的一部分,Promise.allSettled 返回一个在所有给定的承诺都履行或拒绝(解决)后解决的 Promise。结果是描述每个承诺结果的特殊对象数组。每个结果对象都有一个 status 属性(已履行或已拒绝)和一个 value 属性,其中包含已履行结果的数组,如果适用的话。

让我们使用这个 API 在下一个列表中,使用一个履行和一个拒绝的承诺来向你展示它如何与 Promise.all 区别。

列表 8.19 使用 Promise.allSettled 组合承诺

Promise.allSettled([
   proofOfWorkAsync(block),
   rejectAfter(2)                                                         ❶
  ]);
  .then(results => {
     results.length; // 2
     results[0].status; // 'fulfilled'                                    ❷
     results[0].value.index; // 1                                         ❷

     results[1].status; // 'rejected'                                     ❸
     results[1].reason.message;// 'Operation rejected after 2 seconds'    ❸
   });

❶ 使用 setTimeout 在两秒后调用 reject

❷ 第一个结果包含哈希块。

❸ 第二个结果对象包含拒绝的结果。

到目前为止,你可能已经使用 Promise.all 来同时加载多个数据片段。Promise.allSettled 是一个更好的替代方案,因为失败不会损害整个承诺结果;它不会短路。最后,还有 Promise.any

8.3.4 Promise.allSettled

这种方法与 Promise.all 相反。如果传入的任何承诺被履行,无论是否有拒绝,结果承诺都会以该承诺的值履行。当你只关心承诺是否从集合中解决,而想忽略任何失败时,这个 API 是有益的。Promise.any 在所有承诺都拒绝时返回一个拒绝的承诺,如下一个列表所示。

列表 8.20 使用 Promise.any 组合承诺

return Promise.any([
   Promise.reject(new Error('Error 1')),
   Promise.reject(new Error('Error 2'))
  ])
  .catch(aggregateError => {     
     aggregateError.errors.length; // 2
  })

你可能会认为这个 API 的行为很像 Promise.race。小的细微差别在于它返回第一个已解析的值(如果有的话),而 Promise.race 返回第一个已解决的(解析/拒绝)值。一个需要注意的问题是返回值。如果任何承诺成功,你应该期望 then 方法执行结果。然而,如果所有承诺都拒绝,Promise#thenPromise#catch 块上返回一个新的 Error 类型,称为 AggregatedError,它包含所有失败的数组。

到目前为止,你已经学会了如何实例化承诺,形成链,并组合多个承诺的结果。掌握这些技术对于设计性能良好、更好的响应式应用程序至关重要。但是,如果承诺使异步编程变得如此简单,为什么不将它们从 API 提升到编程语言语法呢?

第 8.4 节将讨论转向 async/await 语法,这是一种语言特性,允许你完成到目前为止所学到的事情。

8.4 简化 async

async/await 功能旨在在语言级别上模糊同步和异步编程的界限。这一特性吸引了喜欢命令式编程风格的开发者,他们使用单独的语句解决问题,而不是一个长的 then 表达式序列。async/await 还借鉴了 try/catch/finally 的心理模型,以平滑处理 then(...).catch(...) .finally(...) 逻辑。以下是一个示例:

async function fetchData() {
  const a = await callEndpointA();
  const b = await callEndpointB();
  return {
    a, b
  };
}

承诺是 JavaScript 的 async/await 功能的构建块之一。从可用性的角度来看,你可以将这两个功能视为以相同的方式工作。像承诺一样,async 函数通过事件循环以单独的顺序运行,返回一个隐式的 Promise 作为其结果,你可以使用 Promise#thenawait

要理解这种编码方式,重构 countBlocksInFile 函数。目前,这个函数返回一个 Promise 对象,调用者预计将通过 then 方法处理结果。以下是该函数:

function countBlocksInFile(file) {
   return fsp.access(file, fs.constants.F_OK | fs.constants.R_OK)
      .then(() => {  
         return fsp.readFile(file);
      })
      .then(decode('utf-8'))
      .then(tokenize(';'))
      .then(count)
      .catch(error => {
          throw new Error(`File ${file} does not exist or you have 
             no read permissions. Details: ${error.message}`);
       });
}

你可以重构函数,以系统地利用 async/await。以下是步骤:

  1. async 添加到函数签名中。这一步将 Promise 对象的返回值传递给调用者,并使函数自我文档化(总是好事)。

  2. Promise#catch 移动到它自己的 try/catch 块中,该块覆盖整个异步逻辑。

  3. 将每个Promise#then步骤转换为await语句,并将成功函数的输入作为一个显式的局部变量。本质上,你将 Promise 链解耦成单独的命令式语句。

下一个列表显示了转换后的函数看起来如何。

列表 8.21 使用async/await在 blocks.txt 中计数块

const fsp = fs.promises;

async function countBlocksInFile(file) {                                ❶
   try {
      await fsp.access(file, fs.constants.F_OK | fs.constants.R_OK);    ❷
      const data = await fsp.readFile(file);                            ❸
      const decodedData = decode('utf8', data);
      const blocks = tokenize(';', decodedData);
      return count(blocks);  
   }
   catch(e) {                                                           ❹
      throw new Error(`File ${file} does not exist or you have 
         no read permissions. Details: ${e.message}`);
   }   
}

const result = await countBlocksInFile('blocks.txt'); 
result; // 3

❶ 表示一个异步函数,该函数在底层返回一个 Promise(在函数体中使用 await 所必需的)。

❷ 测试用户对指定路径的权限。如果用户无法访问文件或文件不存在,则 Promise 将失败。

❸ 所有 await 调用在幕后都使用 Promise,因此尽管代码看起来像是在 I/O 上阻塞,但实际上一切都是异步的。

❹ 任何 await 调用(Promise)的拒绝都会跳入 catch 块。

图 8.12 显示,当 await 表达式的输出连接到下一个作为输入时,数据流就像 Promise 链一样。

图 8.12

图 8.12 async/await遵循与 Promise 相同的链式规则。

从技术上讲,countBlocksInFile与之前的工作方式相同。你甚至可以将新语法与Promise API 混合,一切都会以相同的方式工作:

countBlocksInFile('blocks.txt')
   .then(::console.log); // Prints 3

为了澄清,函数签名中的async关键字充当类型定义。它是给调用者和编译器的提示,表明这个函数需要特殊处理,并将返回一个Promise。此外,关键字await可能具有欺骗性。这个关键字在许多语言中已经标准化,并且从语义角度来看是有意义的。但从技术角度来看,没有任何“等待”或“阻塞”。

如前所述,async/await将异步代码转换为同步,这使得它对喜欢命令式风格的读者来说更冗长、更易于阅读。但这个语法与 Promise 有相同的注意事项,即在你引入async调用之后,每个导致它的调用点都需要await。这种缺点很容易被忽视,因为代码看起来像是一个同步函数。错误也是如此。如果你忘记在await调用中包裹try/catch,拒绝就很容易被忽视。如果你忘记写await,你会看到底层的Promise包装的返回值而不是自由值。

虽然async/await促进了更命令式的编程风格,但 JavaScript 仍然足够灵活,你可以以函数式的方式使用它。例如,你可以使用管道操作符来组合异步调用,如下所示:

const blocks = path.join(process.cwd(), 'resources', 'blocks.txt') 
    |> (await countBlocksInFile)

在这种情况下,结果路径字符串被输入到countBlocksInFile并使用await。结果,正如预期的那样,是一个async值,我们可以使用另一个await来展开它以提取其值:

await blocks; // 3

在我们迄今为止的简单示例中,我们处理的是可以轻松加载到内存中的小文件。如果你需要找到特定的块对象,简单地将整个文件读入内存并处理那里的对象即可。在现实世界中,这种解决方案并不总是可扩展的,尤其是在处理大文件或在可用内存非常低的设备上。更好的方法是流式传输并以小块的形式迭代文件。第 8.5 节展示了async/await如何解决这个问题。

8.5 异步迭代

尽管像fsp.readFile这样的 API 简单方便,但这些 API 无法扩展到更大的文件,因为它们试图同时将整个文件内容加载到内存中。对于服务器上的小文件,你可以大部分情况下避免这种情况。但在浏览器中,尤其是在内存容量减少的移动设备上,这种做法是一种反模式。在这些情况下,你需要像移动窗口一样遍历或迭代文件,以便只加载文件的一部分。然而,你面临一个困境:读取文件是异步的,而迭代是同步的。我们如何协调这两个操作?

在本节中,你将了解异步迭代器,它们提供了一种优雅的方式来处理大量数据,无论这些数据位于何处。这种心理模型就像迭代一个本地数组一样简单。

第七章以简单的迭代器结束。回想一下,你可以通过实现众所周知的@@iterator符号来使任何对象可迭代。此方法返回一个具有以下形状的Iterator对象:

{value: <nextValue>, done: <isFinished?>}

JavaScript 运行时会挂钩到这个符号并消耗这些对象,直到done返回true。在同步世界中,CPU 以预期的顺序控制数据流,因此{value, done}对的值在正确的时间是已知的。不幸的是,迭代器和循环并不是为异步操作设计的,因此我们需要一点额外的帮助,如下面的示例所示:

function delay(value, time) {
   return new Promise(resolve => {
      setTimeout(resolve, time, value);
   });
}

for (const p of [delay('a', 500), delay('b', 100), delay('c', 200)]) {
   p.then(::console.log);
}

按照正常的循环协议,输出应该是'a''b',然后是'c'。相反,它是'b''c',然后是'a'。我们必须通知 JavaScript 运行时,它需要等待同步于正在迭代的值的延迟。一种方法是将承诺序列视为一个组合。记住,组合类似于减少。你可以将Promise对象数组减少为一个单一的承诺链。reduce将一组元素聚合为单个元素,从一个任意初始对象开始。在这种情况下,我们可以从一个空的、已解决的Promise对象开始,并使用它来附加减少的承诺集,形成一个单一的链。这种方法有效地强制执行预期的顺序。

下一个列表展示了如何用单个表达式执行此任务。

列表 8.22 减少一个承诺数组

[delay('a', 500), delay('b', 100), delay('c', 200)]
   .reduce(
      (chain, next) => chain.then(() => next).then(:: console.log),     ❶
      Promise.resolve()                                                 ❷
    );

❶ 减法函数将链式承诺对象连接到下一个并打印值。

❷ 初始对象,它成为 reducer 链中的第一个对象

现在代码按正确的顺序打印出预期的 'a''b',然后是 'c'。这里使用 reduce 的方式非常优雅和简洁,但如果你不理解承诺链或 reduce 的工作原理(这两者都在本书中介绍),它可能会显得晦涩难懂。让我们用 async 迭代来糖衣这个逻辑,就像以下列表中的传统 for...of 循环一样。

列表 8.23 使用 async 迭代处理承诺数组

for await (const value of                                       ❶
      [delay('a', 500), delay('b', 100), delay('c', 200)]) {
   console.log(value);
}

❶ 注意循环条件前使用了 await

reduce 帮助我们建立异步循环的工作模型,如图 8.13 所示。

图 8.13 for...of 顺序处理任务集合并保留它们的顺序。

这个图看起来很熟悉。循环前的 await 关键字解决每个承诺,使循环变量指向它内部包装的值。这种语法计算的结果与使用 reduce 的结果相同,因为它负责在迭代行为中按顺序展开和执行异步操作。异步迭代显著简化了涉及处理输入流、对异步任务进行排序等问题。

例如,让我们重新设计我们的 countBlocksInFile 用例,该用例将整个文件读入内存,改为使用异步迭代,以便它可以扩展到任何大小的文件。列表 8.24 比列表 8.21 要复杂一些,但它非常值得研究,因为这个函数可以处理更大的文件。循环体内的大多数复杂性都源于必须处理分块读取的各个块对象的完整性,并确定一个块的结束和另一个块的开始。

列表 8.24 在任何大小的文件中计数块

import fs from 'fs';

async function countBlocksInFile(file) {
   try {
      await fsp.access(file, fs.constants.F_OK | fs.constants.R_OK);

      const dataStream = fs.createReadStream(file,                       ❶
         { encoding: 'utf8', highWaterMark: 64 });                       ❷

      let previousDecodedData = '';
      let totalBlocks = 0;

      for await (const chunk of dataStream) {                            ❸
         previousDecodedData += chunk;
         let separatorIndex;
         while ((separatorIndex = previousDecodedData.indexOf(';')) >= 0) {
            const decodedData = 
                  previousDecodedData.slice(0, separatorIndex + 1);      ❹

            const blocks = tokenize(';', decodedData)
                  .filter(str => str.length > 0);

            totalBlocks += count(blocks);

            previousDecodedData = 
                  previousDecodedData.slice(separatorIndex + 1);         ❺
         }         
      }
      if (previousDecodedData.length > 0) {
         totalBlocks += 1;
      }
      return totalBlocks;
   }
   catch (e) {
      console.error(`Error processing file: ${e.message}`);
      return 0;
   }
}

❶ 不是读取整个文件,而是创建一个流,这样你就可以按“highWaterMark”大小读取数据块。

❷ 在这个例子中,highWaterMark 设置为 64 字节,以便数据以小块的形式交付。

❸ 遍历流,读取下一个原始文本块

❹ 处理块分隔符(如果存在),以获得干净的块行

❺ 在读取到最后一个分隔符后开始下一行,以避免读取不完整的块数据

async/await 给你自由,可以专注于手头问题的逻辑,而无需担心异步编程的复杂性。

虽然承诺确实是一种更功能化、流畅的方法,但 async/ await 通过数据的自动包装和展开,使我们回到了命令式范式。与 Validation 这样的 ADT 相比,async 等同于 Success.ofawait 类似于 Validation.map(或 Promise#then),而 Promise#catch 模拟了 Failure 状态。

在列表 8.24 中,你看到了对象 dataStream 是如何异步迭代的。你可能想知道如何使你自己的对象异步可迭代。在第七章中,我们讨论了 @@iterator 符号如何允许你展开和枚举自定义对象中的元素。同样,当使用 for...of 并带有 await 时,会执行 @@asyncIterator 符号,就像之前一样,如下面的列表所示。

列表 8.25 使用 Node.js 流对象进行 async 迭代

for await (const chunk of dataStream) {       ❶
  //...
}

❶ 调用 dataStream 的函数值属性 asyncIterator

dataStream 拥有一个名为 Symbol.asyncIterator 的函数值符号属性。截至本文撰写时,没有原生的 JavaScript API 使用此符号,但 Node.js 自带了一些用于文件系统流和 HTTP 处理的库。正如你所预期的那样,循环调用点的 await 必须与迭代器本身返回的 async 值(一个承诺)相匹配。你所学到的关于 Iterator 的所有内容都适用,只是有一点小的例外,即对 next 的调用必须返回一个 {value, done} 对象,该对象被一个 Promise 包裹。下面的列表展示了这样一个简单的例子。

列表 8.26 使用延迟发出值的 Iterator 对象

function delayedIterator(tasks) {
   return {
      next: function () {
         if (tasks.length) {
            const [value, time] = tasks.shift();                   ❶
            return new Promise(resolve => {                        ❷
               setTimeout(resolve, time, { value, done: false });
            });
         } else {
            return Promise.resolve({
               done: true                                          ❸
            });
         }
      }
   };
}

❶ 从列表中移除第一个任务。一个任务不过是一个在未来有超时值的值。

❷ 返回一个承诺,该承诺封装了一个 {value, done} 迭代器元组

❸ 表示迭代器应该停止,因为没有更多的任务要执行

首先直接看到这个迭代器的使用是有帮助的:

const tasks = [
   ['a', 500],
   ['b', 100],
   ['c', 200]
];

const it = delayedIterator(tasks);

await it.next().then(({ value, done }) => {
   value; // 'a'
   done;  // false
});

运行 it.next() 两次以打印任务 'b''c',顺序如下。最后,最后一次调用发出 done 值:

await it.next().then(({ value, done }) => {
   value; // undefined
   done;  // true
});

使用 Symbol.asyncIterator,我们得到相同的结果,如下面的代码所示。

列表 8.27 使用 @@asyncIterator 钩入 async 迭代

const delayedIterable = {
  [Symbol.asyncIterator]: delayedIterator
};
for await (const value of asyncIterable) {     ❶
   console.log(value);   
}

❶ 内部调用 @@asyncIterator

你还可以通过将 next 设为一个 async 函数来进一步提高事物的层次:

function delayedIterator(tasks) {
   return {
      next: async function () {
         if (tasks.length) {
            const [value, time] = tasks.shift();
            return await delay(value, time);
         } else {
            return Promise.resolve({
               done: true
            });
         }
      }
   };
}

当你完全控制(并理解)你对象的迭代行为时,你可以做到的事情没有极限,尤其是在你有物理限制,如带宽和内存量时,这些限制分别在慢速网络和移动设备上出现。第九章进一步探讨了如何将生成器(及其 async 对应物)与迭代器协议相结合。你不仅可以模拟有限的数据量,还可以模拟可能无限的数据流。

到目前为止,我们已经讨论了如何通过 Promise API 和 async/await 直接处理异步任务。大部分讨论集中在数据如何通过承诺链向前传播。在本章中,我没有过多地谈论错误处理,主要是因为规则几乎与典型命令式代码的规则相同,我们讨论的许多关于 Promise#then 的内容也适用于 Promise#catch,这是承诺的一个很好的设计特性。

8.6 顶层 await

通常,每个 await 都必须与 async 匹配,但有一个例外。某些场景要求您首先发起一个调用来加载(await)某些异步内容。例如,动态加载模块和依赖项,如国际化/语言包或数据库连接句柄。

通过常规 async/await 语法,如果您想在脚本启动时开始异步任务,您必须使用一个函数创建 async 上下文,然后立即调用它。以下是一个示例:

const main = async () => {
  await import(...);
}

main();

在第六章中,我们讨论了立即调用的函数表达式(IIFEs),这是一种直接同时执行函数声明和执行的模式。同样地,我们可以通过使用立即调用的异步函数表达式(IIAFE)来缩短前面的代码:

(async () => {
  await import(...);
})();

顶级 await 清理了这段代码,这样您就可以在不需要显式创建 async 函数的情况下使用 await 在任务上。幕后,您有一个大的 async 函数用于整个模块:

await import(...);

在第六章中,您看到了一个从动态加载代码的模块中进行的依赖项回退示例:

const useNewAlgorithm = FeatureFlags.check('USE_NEW_ALGORITHM', false);

let { default: computeBalance } = await import(
    '@joj/blockchain/domain/wallet/compute_balance.js'
);

if (useNewAlgorithm) {
   computeBalance = (await 
      import('@joj/blockchain/domain/wallet/compute_balance2.js')).default;
}

return computeBalance(...);

顶级 await 是为了与 ECMAScript 模块无缝配合,因此这是一个开始采用该模块格式的另一个好理由。这是可以理解的,因为顶级 await 需要特殊支持来为您自动创建异步上下文。(有助于将整个模块视为一个大的 async 函数。)如果模块 A 导入模块 B,并且 B 包含一个或多个 await 调用,A 需要等待 B 完成执行后才能执行自己的代码。自然地,在评估过程的临界阶段存在阻塞和等待的担忧。(如果您想了解此过程的详细细节,可以阅读更多关于它们的信息,请参阅mng.bz/9Mwo。)然而,正如您所期望的,这里有一些优化,因此仅与依赖模块发生的阻塞不会影响加载其他同级依赖项。事件循环架构适当地安排这些任务,并将控制权交回主线程以继续加载其他代码,就像处理任何异步任务一样。尽管如此,开发者教育是关键。与动态 import 一样,仅在绝对必要时才使用这些功能。

正如你所见,我们已经朝着从代码中移除延迟或时间问题迈出了很长的路。我们开始于现有的回调,然后过渡到 API 驱动的解决方案(承诺),最后通过async/await看到了语言级模型的改进。总的来说,这些技术行为上是等效的,并且都与 JavaScript 的非阻塞、事件驱动哲学保持一致。记住,编程是三维的:数据、行为和时间。当数据位于我们编写的行为或逻辑附近时,应用程序表现得最好。但在这个现代、分布式的世界中,这个问题几乎不是你需要解决的问题。没有宿主语言的适当支持,编程可以迅速变得难以控制。承诺(或async/await)将这些维度(数据、行为和时间)合并,这样我们就可以将代码视为二维的,从方程中去除时间。

然而,承诺有一些缺点。首先,你不能以标准方式取消承诺的执行。或者你应该这样做吗?毕竟,承诺的意图是保持不变。也许FutureTask会是更好的名字。尽管如此,第三方库使用某种形式的内部取消令牌,但还没有成为标准。TC39 委员会寻求一种通用的取消机制,该机制可以应用于承诺之外的更多场景。你可以在github.com/tc39/proposal-cancellation找到更多信息。

对于我来说,最大的问题是承诺(async/await)不是懒加载的。换句话说,无论链中是否有处理函数,承诺执行器都会运行。另一个问题是承诺被设计为提供单一结果;它们只能成功或失败一次。承诺确定后,你需要创建一个新的承诺来请求或轮询更多数据。有许多 API 用例可以在不要求你明确请求更多数据的情况下向你的处理代码提供或推送值。这种模式被称为流,这是一种方便且优雅的范式,用于处理像 WebSockets、文件 I/O、HTTP 和用户界面事件(点击、鼠标移动等)这样的东西。第九章将异步状态管理提升到了新的水平。

摘要

  • JavaScript 基于单线程、事件驱动的架构,旨在扩展规模。

  • Promise是一种标准、几乎代数的数据类型,用作异步任务的一种抽象或包装,以提供一种一致且位置无关的编程模型。承诺让你摆脱了对延迟和数据本地性的担忧,这样你就可以专注于手头的任务。

  • 承诺重用了回调和事件循环的机制,但具有更好的可读性。

  • ECMAScript 2020 通过添加Promise.allSettledPromise.anyPromise API 的表面进行了增强。这两个可组合的操作符允许你同时处理多个任务。

  • async/await 提供了一种更熟悉、命令式的方法来处理承诺,将承诺链转换为一系列语句。

  • 异步迭代器引入了一个名为 @@asyncIterator 的新符号。您可以使用此符号赋予任何对象循环和异步发出其数据的特性。Node.js 在其 HTTP 和文件系统模块等中使用此符号。

  • 顶层 await 利用 ESM 系统自动在模块脚本上创建一个包含的 async 上下文,在此上下文中,您可以无需显式创建 async 函数即可发出任意数量的 await 调用。

9 流编程

本章涵盖

  • 回顾迭代器/可迭代协议

  • 使用生成器表示随时间变化的值序列

  • 回顾推送/拉模型以及基于流的编程

  • 使用可观察者创建声明式、异步的推送流

一个 Observable 是一个接受观察者并返回函数的函数。没有更多,也没有更少。如果你写一个接受观察者并返回函数的函数,它是异步的还是同步的?都不是。它是一个函数。

——本·莱斯

这最后一章汇集了本书中涵盖的最重要的技术,包括整体的可组合软件、函数式编程、混入扩展以及反射和异步编程。在这里,你将了解它们如何结合在一起来支持一种称为流编程的计算模型。流提供了一种抽象,使我们能够重用单个计算模型来处理任何类型的数据源,无论其大小如何。想象一下构建一个实时数据应用,比如一个聊天小部件。你可以设置长轮询定期从服务器拉取消息,可能使用你开发的基于Promise的 API。不幸的是,Promise 一次只能传递一个值,所以你可能会收到一个或两个消息对象(或者如果你有一个健谈的群组,可能成千上万个),这会导致错误,因为你已经超过了单次请求中可以传输的数据量。最好的策略是设置一个推送解决方案;当有新消息时,你的应用会收到通知,并且一次或分批接收消息。使用流编程为你提供了适当的抽象级别,以一致的 API 处理这些用例。

我们都以一种或另一种方式在编码中使用流,而没有意识到这一点。任何硬件组件之间的数据流,例如从内存到处理器或磁盘驱动器,都可以被视为流。实际上,这些输入和输出流被用来分别读取和写入。尽管这已经熟悉很多年了,但我们从未真正考虑过连接我们的应用程序或连接多个应用程序的组件之间的状态是流。

想想你每天编写的 JavaScript 代码。大多数情况下,处理状态的传统方式是使用拉模型。拉发生在客户端发起对所需数据的请求时。这个过程在从数据库或文件读取或查询 API 时异步发生。它也可以在调用函数或遍历内存中的某些数据结构时同步发生。

另一方面是推送模型。在推送中,客户端代码不再请求数据;服务器将数据发送给你。推送交换可能从一个初始的拉取开始,但一旦客户端和服务器接口达成一致,数据就可以在可用时流向客户端——就像你的聊天应用中的新消息一样。你可能听说过发布/订阅模型,这是一种用于这些类型问题的架构。一个简单的、有用的类比是考虑一个在单个请求期间被多次调用的回调函数。

推送技术可以使你的应用程序更加敏捷和响应。我将在第 9.3 节中再次提到这个关于响应性的概念,因为它非常重要。一些想到的推送示例包括服务器发送事件(SSE)、WebSocket 和 DOM 的事件监听器。想象一下,如果你不是注册事件处理器,而是需要设置一个定时器来查看按钮的状态何时变为点击状态。或者假设,如果你不是在新消息到来时收到通知,你需要明确地点击刷新按钮来下载新消息。我们不再是 90 年代了。当你知道数据在某处可用时,你可以发出一个命令来读取它,但当你不知道时会发生什么?为那些你不知道何时会变得可用的数据(如果它真的会)设置轮询是尴尬的,更不用说低效了。

在第七章和第八章中,我介绍了迭代器的概念。在这里,我将从新的角度继续讨论这个主题:它是如何与生成器函数结合来表示数据流的。生成器允许你控制从可迭代对象(数组、映射、对象等)输出的数据同步流。此外,你还可以模拟可以即时计算的可迭代数据。

我们将继续构建这些课程,并转换到异步迭代器(简称 async iterables)和异步生成器,它们用于计算随时间推移的值序列。异步迭代器代表推送流,是读取大量异步数据(数据库、文件系统或 HTTP)的一种高效、最优和内存友好的方式,可以分块读取。

虽然推送范式有时难以理解,但你会发现,使用你迄今为止一直在学习的相同的 JavaScript 结构会使推送更加容易接近。我认为你会发现流模式非常有趣,并且编写起来很愉快,所以我会以查看一个将数据无关、响应式编程引入 JavaScript 的新 API 来结束这一章:Observable。Observables 提供了一个单一的 API 界面来管理数据流,独立于数据是如何生成的以及其大小。

首先,让我们谈谈 JavaScript 中的可迭代(Iterable)和迭代器(Iterator)协议。

9.1 迭代器和迭代器

简而言之,可迭代对象是一个其元素(或属性)可以被枚举或遍历的对象。正如你在第七章和第八章中学到的,可迭代对象定义了自己的 Symbol.iterator,用于控制这些元素如何传递给调用者。迭代器是描述迭代机制结构的模式或协议。语言可以自由定义自己的机制来实现这一点。在 JavaScript 中,迭代是标准化的。以下几节将详细检查这些协议。

9.1.1 可迭代协议

可迭代协议允许你在对象出现在 for...of 构造或与扩展运算符一起使用时自定义其迭代行为。JavaScript 有内置的可迭代对象,如 ArrayMapSet。字符串也可以作为一个字符数组进行迭代。

注意:尽管 WeakSetWeakMap 有相似的名字,但它们不是可迭代的(尽管它们在构造函数中接受可迭代对象)。实际上,它们都没有扩展其非弱版本(SetMap 分别)。这些 API 解决了某些有趣的问题,但我在这本书中没有涉及它们。

一个可迭代对象(或其原型链上的任何对象)必须实现值函数 Symbol.iterator。在这个函数内部,this 指的是正在迭代的对象,这样你就可以完全访问其内部状态,并决定在迭代过程中发送什么内容。关于可迭代对象的一个有趣的事实是,Symbol.iterator 可以是一个简单的函数或生成器函数。(关于这个主题的更多信息,请参阅第 9.2 节。)

没有迭代器,可迭代本身并没有什么作用。

9.1.2 迭代器协议

迭代器是当需要迭代行为时向语言运行时提供的合约。JavaScript 期望你向一个对象提供一个 next 方法。此方法返回至少包含两个属性的对象:

  • done(布尔值)——指示是否还有更多元素。false 的值告诉 JavaScript 运行时继续循环。

  • value(任何类型)——包含绑定到循环变量的值。当 done 等于 true 时,此值被忽略,序列终止。

如果 Symbol.iterator 返回的对象不遵守此协议,则被视为格式不正确,并引发 TypeError——这是 JavaScript 强制执行此特定协议的方式。

9.1.3 示例

本节展示了可迭代对象的示例,从我们自己的 Block 类开始。正如你所知,这个类接受一个数据对象数组,这些对象可以是 Transaction 对象或存储在链上的任何其他类型的对象:

class Block {

  index = 0;
  constructor(index, previousHash, data = [], difficulty = 0) {
    this.index = index;
    this.previousHash = previousHash;
    this.data = data;
    this.nonce = 0;
    this.difficulty = difficulty;
    this.timestamp = Date.now();
    this.hash = this.calculateHash();
  }

  //...

  [Symbol.iterator]() {
    return this.data[Symbol.iterator]();
  }
}

如果我们创建了一个包含交易列表的块,使用 for...of 枚举时,会钩入特殊符号以传递每个交易:

for (const transaction of block) {
   // do something with transaction
}

我们应用程序的所有主要模型对象(BlockchainBlockTransaction)都是可迭代的。这个事实使得在 HasValidation 混合中创建一个通用验证方法变得简单,该混合扩展了所有这些对象以具有相同的接口。在第五章中,使用的算法包括 flatMapreduce,但它创建了额外的数组,因为验证逻辑流经区块链的元素。迭代器遍历已经存在于内存中的结构。而且,我们不必遍历所有元素来找出是否发生了失败。当我们找到第一个失败时,我们可以提前退出算法。再次查看以下代码片段:

const HasValidation = () => ({
  validate() {
    return validateModel(this);
  }
});

function validateModel(model) {
  let result = model.isValid();
  for (const element of model) {
    result = validateModel(element);
    if (result.isFailure) {
      break;
    }
  }
  return result;
}

此实现依赖于模型对象实现 Symbol.iterator。在我们的例子中,逻辑很简单,因为对象委托给其内部数据结构的迭代器。为了了解协议是如何工作的,让我们通过迭代器模式实现一个随机数生成器,如下一个列表所示。

列表 9.1 使用迭代器的随机数生成器

function randomNumberIterator(size = 1) {

  function nextRandomInteger(min) {                              ❶
    return function(max) {
      return Math.floor(Math.random() * (max - min)) + min;
    };
  }

  const numbers = Array(size)                                    ❷
     .fill(1)
     .map(min => nextRandomInteger(min)(Number.MAX_SAFE_INTEGER));

  return {
    next() {
      if(numbers.length === 0) {
        return {done: true};                                     ❸
      }
      return {value: numbers.shift(), done: false};              ❹
    }
  };
}

let it = randomNumberIterator(3);
console.log(it.next().value);  // 1334873261721158             ❺
console.log(it.next().value);  // 6969972402572387             ❺
console.log(it.next().value);  // 3915714888608040             ❺
console.log(it.next().done);   // true

❶ 计算下一个随机整数的内部辅助函数

❷ 创建一个有大小数组和填充随机数

❸ 表示序列的结束

❹ 表示还有更多数字要枚举

❺ 每次产生不同的数字

注意,randomNumberIterator 返回的对象符合迭代器模式(如通过 next 的声明可以看出),但它本身并不是可迭代的。为了使其成为可迭代的,我们可以在下一个列表中添加 Symbol.iterator

列表 9.2 使用 @@iterator 使对象可迭代

...
return {
   [Symbol.iterator]() {
      return this;            ❶
   },
   next() {
      if(numbers.length == 0) {
        return {done: true}; 
      }
      return {value: numbers.shift(), done: false};
   }
}

❶ 因为对象已经实现了 next 方法,所以只需返回自身,使其既是迭代器又是可迭代对象。

现在,你可以从与 for...of 的无缝集成中受益:

for(const num of randomNumberIterator(3)) {
  console.log(num)
}

这种技术非常强大,因为迭代器协议是无数据相关的;你可以用它来实现任何类型的迭代。你可以用 for...of 的简单性来表示目录遍历、图/树数据结构、字典或任何自定义集合对象。

可迭代/迭代器对在 JavaScript 中无处不在,控制着对象如何与扩展运算符一起使用:

[...randomNumberIterator(3)];

// [ 6035653145325066, 7827953689861025, 1325390150299500 ]

原生、内置类型也是可迭代的。以下列表显示字符串在数组、映射和集合中表现相同。(你明白了。)

列表 9.3 实现 @@iterator 的字符串

"Joy of JavaScript"[Symbol.iterator]; // [Function: [Symbol.iterator]]

for(const letter of "Joy of JavaScript") {
  console.log(letter);                         ❶
}

❶ 将所有 17 个字符记录到控制台

现在,让我们说实话:你可能从未听说过随机数迭代器,但你听说过随机数生成器。它们之间有区别吗?你将在第 9.2 节中找到答案,该节涵盖了生成器。

9.2 生成器

生成器是一种特殊的函数类型。通常,当函数返回时,语言运行时会将该函数从当前栈帧中弹出,释放为其局部上下文分配的任何存储。生成器函数以相同的方式工作,但有一个细微的区别:它的上下文似乎会保留并恢复以返回更多值。在本节中,我们将回顾生成器函数是什么,如何使用它们创建可以从空中发送新值的可迭代对象,以及如何使用它们创建异步可迭代对象。

9.2.1 返回或yield

生成器是一个迭代器的工厂函数。首先,你可以通过在 function 关键字后放置一个星号 (*) 来定义一个生成器函数

function* sayIt() {
  return 'The Joy of JavaScript!'; 
}

或者在一个方法名之前:

class SomeClass {
  * sayIt() {
     return 'The Joy of JavaScript!';
   }
}

这些函数并不太有用,但足以展示生成器看起来像任何常规函数。那么特殊语法是什么呢?返回值中有一个转折。运行这个函数看看你会得到什么:

sayIt(); // Object [Generator] {}

如你所见,这个特殊语法通过一个名为 Generator 的对象增强了返回值,而不是像常规函数那样是一个字符串。从语法上讲,这个过程与 async 函数增强(或包装)值在 Promise 中的方式类似。

就像简单的 randomNumberIterator 示例一样,Generator 本身就是一个可迭代对象和一个迭代器;它实现了这两个协议。因此,为了提取其值,我们需要调用 next

sayIt().next(); // { value: 'The Joy of JavaScript!', done: true }

现在,你可以识别迭代器协议的形状了。然而,仅仅使用一个只包含一个值(done: true)的迭代器并不那么有趣。function* 语法存在是为了你可以通过一个称为yield的过程产生多个值。考虑这个变体:

function* sayIt() {
  yield 'The';
  yield 'Joy';
  yield 'of'; 
  yield 'JavaScript!';  
}

const it = sayIt();
it.next(); // { value: 'The', done: false }
it.next(); // { value: 'Joy', done: false } 
     ...

注意 目前,没有对使用 lambda 语法生成器函数的支持。这种不支持可能看起来是设计上的缺陷,但事实并非如此:lambda 表达式实际上意味着是简单的表达式,大多数都是一行代码。生成器函数如此简单的情况很少见。然而,有一个提议要包括对生成器箭头函数的支持:mng.bz/yYWq

当然,因为 Generator 实现了 Symbol.iterator,你可以将其放入一个 for...of 表达式中:

for(const message of sayIt()) {
  console.log(message);
}

总结来说,生成器不过是一种创建迭代器的简单方法。生成器和迭代器无缝工作。代码看起来像是在多次调用同一个函数,并且以某种方式从上次停止的地方继续,但实际上它只是一个函数。幕后,你正在消耗函数返回的可迭代对象,而 yield 将新值推入迭代器。

9.2.2 创建可迭代对象

在本节中,你将看到如何将可迭代对象集成到区块链应用程序的领域模型中。例如,你可以向 Blockchain 添加一个生成器辅助函数,它可以发出你想要的任何数量的完全配置的空块。你可以使用这个函数来创建任何大小的链,也许可以用它们进行测试和运行模拟。

下一个列表定义了一个简单的 newBlock 生成器。在这个阶段,Blockchain 类有些复杂,所以我只会展示相关的部分。

列表 9.4 自定义生成器函数

class Blockchain {
  #blocks = new Map();

  ...

  * newBlock() {                       ❶
      while (true) {                   ❷
         const block = new Block(
          this.height(), 
          this.top.hash, 
          this.pendingTransactions
         );
         yield this.push(block);       ❸
     }
  }
}

❶ 在函数方法上使用生成器语法

❷ 看起来像无限循环但实际上不是。生成器函数能够在 yield 上“暂停”其执行,因此运行时不会无限执行。

❸ 将新块推送到链中并返回它

调用者代码调用 newBlock 20 次以生成 20 个新块,使得链的总高度达到 21(记得计算第一个创世块),如下所示。

列表 9.5 使用生成器创建任意数量的新块

const chain = new Blockchain();
let i = 0;
for (const block of chain.newBlock()) {
   if (i >= 19) {                        ❶
      break;
   }
   i++;
}
chain.height(); // 21                    ❷

❶ 创建 20 个块后停止

❷ 20 个新块加上创世块共 21 个

此外,生成器和迭代器之间的无缝集成使得使用扩展运算符及其对应的结构化赋值法成为从任何自定义对象中读取属性的一种简洁、惯用的方式。我们可以在第五章中实现的代数数据类型(ADT)如 Validation 上实现原始的模式匹配表达式。首先,让我们使 Validation 可迭代,并使用生成器分别返回其 FailureSuccess 分支。这个 ADT 倾向于右侧,因此 Success 分支来自对 yield 的第二次调用;否则,你可以反转这个顺序。Symbol.iterator 的实现如下:

class Validation {
  #val;

  ...

  *[Symbol.iterator]() {
    yield this.isFailure ? Failure.of(this.#val) : undefined;
    yield this.isSuccess ? Success.of(this.#val) : undefined; 
  }
}

选择 ADT,如 Validation,一次只能激活一个分支并省略其他分支。那些结构化赋值语句看起来像下一个列表。

列表 9.6 使用结构化赋值提取成功和错误状态

const [, right] = Success.of(2);                              ❶

right.isSuccess;  // true
right.get();      // 2

const [left,] = Failure.of(new Error('Error occurred!'));     ❷

left.isFailure;    // true
left.getOrElse(5); // 5

❶ 忽略左侧结果的解构赋值

❷ 忽略右侧结果的解构赋值

考虑这两个分支的一些简单用例。假设你正在调用某个验证函数。对于 Failure 情况,你可以使用带有默认值的解构赋值作为调用 left.getOrElse(5) 的替代方案:

const isNotEmpty = val => val !== null && val !== undefined ?
    Success.of(val) : Failure.of('Value is empty');

const [left, right = Success.of('default')] = isNotEmpty(null);

left.isFailure; // true
right.get();    // 'default'

如你所见,迭代器和生成器(连同符号)解锁了惯用的编码模式。当你需要控制对象如何发出其自己的属性时,这些功能使你的代码更具表现力和易于阅读。

由于 JavaScript 通过使 next 返回承诺来支持异步迭代器,它也支持异步生成器,我们将在下一节讨论。

9.2.3 异步生成器

异步生成器就像一个普通生成器,除了它不会产生值,而是产生异步解决的承诺。因此,异步生成器对于与基于 Promise 的 API 一起工作非常有用,这些 API 允许你以块的形式异步读取数据,例如浏览器中的 fetch API(顺便说一下,这是一个混合型)或 Node.js 内置的“流”库。在下一个示例中,你将看到使用普通异步函数和使用异步生成器函数之间的区别。

要获得异步生成器,将本章中涵盖的所有关键字组合成一个单独的函数签名:

async function* someAsyncGen() {

}

生成器返回的结果是承诺,所以你需要使用for await ...of语法来消费它。下一条列表显示了一个使用异步迭代的函数。

列表 9.7 使用异步迭代来计数文件中的区块

async function countBlocksInFile(file) {
   try {
      await fsp.access(file, fs.constants.F_OK | fs.constants.R_OK);

      const dataStream = fs.createReadStream(file, 
         { encoding: 'utf8', highWaterMark: 64 });

      let previousDecodedData = '';
      let totalBlocks = 0;

      for await (const chunk of dataStream) {       ❶
         previousDecodedData += chunk;
         let separatorIndex;
         while ((separatorIndex = previousDecodedData.indexOf(';')) >= 0) {
            const decodedData = 
                  previousDecodedData.slice(0, separatorIndex + 1); 

            const blocks = tokenize(';', decodedData)
                  .filter(str => str.length > 0);

            totalBlocks += count(blocks);

            previousDecodedData = 
                  previousDecodedData.slice(separatorIndex + 1);
         }         
      }
      if (previousDecodedData.length > 0) {
          totalBlocks += 1;
      }
      return totalBlocks;
   }
   catch (e) { 
      console.error(`Error processing file: ${e.message}`);
      return 0;
   }
}

❶ dataStream 是一个异步可迭代对象,这意味着每个数据块都是一个迭代协议形状的值,由承诺包装。

这个函数返回从文件中读取的区块数量。一个更有帮助、更有用的函数会返回区块对象本身,这样你就可以做更多不仅仅是计数的事情。也许你可以验证整个区块集合。

下面的列表显示了稍微重构后的版本,去除了计数部分。它还使用了一个生成器,它产生每个描述区块的 JSON 对象。让我们称这个函数为generateBlocksFromFile

列表 9.8 异步生成器,发送从文件中读取的区块

async function* generateBlocksFromFile(file) {                         ❶
   try {
      await fsp.access(file, fs.constants.F_OK | fs.constants.R_OK);

      const dataStream = fs.createReadStream(file,
           { encoding: 'utf8', highWaterMark: 64 });

      let previousDecodedData = '';

      for await (const chunk of dataStream) {
         previousDecodedData += chunk;
         let separatorIndex;
         while ((separatorIndex = previousDecodedData.indexOf(';')) >= 0) {

            const decodedData = previousDecodedData.slice(0, 
               separatorIndex + 1);

            const blocks = tokenize(';', decodedData)
                .filter(str => str.length > 0)
                .map(str => str.replace(';', ''));

             for (const block of blocks) {
                yield JSON.parse(block);                               ❷
             }
             previousDecodedData = previousDecodedData.slice(
                 separatorIndex + 1);
         }
      }
      if (previousDecodedData.length > 0) {        
         yield JSON.parse(previousDecodedData);                        ❷
      }
   }
   catch (e) {
      console.error(`Error processing file: ${e.message}`);
      throw e;
   }
}

❶ async function*创建一个异步迭代器。

❷ 产生所有解析的区块作为对象

现在计数逻辑变得极其简单,如下一条列表所示。

列表 9.9 使用generateBlocksFromFile作为异步迭代器

let result = 0;
for await (const block of generateBlocksFromFile('blocks.txt')) {      ❶
   console.log('Counting block', block.hash);
   result++;
}
result; // 3

❶ 每次调用生成器都会从文件流中提取一个新的区块对象。

再次强调,这种变化的优点在于我们不仅能计数,还可以在生成过程中验证每个区块。这个过程是高效的,因为我们不需要一次性读取整个文件,而是将其作为移动的数据窗口进行处理。以这种方式处理数据被称为流。

假设现在我们想使用这个函数来验证链中的所有区块。Blockchain在构建时会创建自己的创世区块,所以我们将首先跳过第一个到达的区块。接下来,我们将区块的 JSON 对象表示转换为添加到链中的Block对象;这个过程称为活化。验证逻辑检查区块是否在链中正确定位。最后,它调用从HasValidation混合的validate。我在仓库中使用的测试文件(blocks.txt)有三个区块,所以我们将只验证剩下的两个。下一条列表中的所有逻辑都在图 9.1 中展示。

列表 9.10 验证从文件生成的区块流

   let validBlocks = 0;
   const chain = new Blockchain();             ❶
   let skippedGenesis = false
   for await (const blockData of generateBlocksFromFile('blocks.txt')) {
      if (!skippedGenesis) { 
         skippedGenesis = true;
         continue;                             ❷
      }
      const block = 
        new Block(blockData.index, chain.top.hash, blockData.data,      
            blockData.difficulty);

      chain.push(block);                       ❸

      if (block.validate().isFailure) {        ❹
         continue;
      }
      validBlocks++
   }

   console.log(validBlocks) // 2

❶ 每个区块的验证假设这些区块是链的一部分。

❷ 跳过测试文件中的第一个区块,因为它来自不同的链实例的创世区块

❸ 区块需要被推入链中进行验证。

❹ 使用 HasValidation#validate 验证每个区块

图 9.1 中的图示以高层次捕捉了这一流程。如图所示,有两个生成器函数正在工作。第一个函数调用fs.createReadStream,第二个函数调用generateBlocksFromFile,它使用第一个函数来传递自己的数据。

图 9.1 两个生成器函数。一个读取文件并产生数据块。另一个处理每个原始块,从它们中创建Block对象,验证每个对象,并统计结果。

在本节中,我们继续在第八章的基础上增加更多异步功能。我们讨论了如何使用(async)迭代器协议、符号和生成器函数来创建可迭代对象。这些对象在请求时可以枚举其状态,当它们成为简单for循环的主题时。

注意:值得提到的是,我们还没有讨论涉及生成器的其他用例,例如将值推送到生成器或将生成器函数作为参数。生成器是函数,因此你可以从一个函数返回一个生成器或接受一个生成器作为参数。这些技术可以用来解决超出本书范围的一些复杂问题。查看此链接以了解这些其他用例:mng.bz/j45p

异步生成器函数可以异步产生值,就像发射事件一样。当一个事件源按顺序发送大量值时,这个序列也被称为流。

9.3 使用数据流

StringArrayMap和 WebSocket 事件序列的对象有什么共同之处?在典型的编程任务中,并不多。然而,当我们谈论数据流时,在根本层面上,这些类型的对象可以以相同的方式处理。事实上,这种一致的编程模型允许你在数据源之间工作,使得流变得必要。一些现实世界的例子,其中流非常有用包括

  • 将多个异步数据源(REST API、WebSockets、存储、DOM 事件等)作为一个单一流程进行交互

  • 创建一个管道,对通过它的数据进行不同的转换

  • 创建一个广播频道,其中多个组件可以通知特定事件

在这种情况下,每个交互都是异步的,并且是同一流程的一部分,使用异步生成器来包装每个动作是一项艰巨的任务,回调模式也无法很好地扩展到这种复杂程度。

在本节中,我们将学习流的基本知识以及表示它们的 API。JavaScript 的Observable API 提供了必要的接口来构建处理来自任何基于推送的数据源的优秀反应抽象。在本节结束时,你将了解如何将对象转换为流,以便通过Observable对象的链来管理其数据。你将理解任何复杂的数据源都可以被抽象并处理,就像它是一个简单的事件集合一样。

9.3.1 什么是流?

要理解这个概念,你必须首先了解数据是如何到达或被应用程序消费的。一般来说,数据是推或拉。在这两种情况下,你都有一个生产者(创建数据)和一个消费者(订阅该数据)。

在拉系统中,生产者(可能只是一个函数)不知道何时或如何需要数据。因此,消费者必须从(或调用)生产者那里拉取。另一方面,在推系统中,生产者控制事件发送的时间(例如,点击按钮),而消费者(订阅者)不知道何时会收到该事件。我们说消费者是对事件做出反应(图 9.2)。

图 9-2

图 9.2 拉和推的区别。在拉模式下,消费者必须始终主动发起数据请求。在推模式下,当消费者订阅后,生产者会在数据可用时发送数据,直到消费者取消订阅或没有更多数据可发送。

表 9.1 总结了拉、推以及在这些情况下通常使用的 JavaScript 特性。

表 9.1 处理推和拉数据的 JavaScript 特性

单个 多个
拉取 函数 Symbol.iterator
推送 Promise Symbol.asyncIterator

表 9.1 中的拉技术简单易懂。拉发生在函数被调用或迭代器的next被多次调用时。相比之下,一个简单的推场景发生在异步值用Promise对象表示时。在这里,承诺(生产者)控制事件何时被发射,将此逻辑从消费者抽象出来。消费者变成了传递给next的处理函数。我们可以说该函数订阅了承诺。例如,如果承诺设置了一个三秒的定时器来解析,那么它的值将在事件循环处理最后一个事件后的三秒被发射。生产者知道并控制发射这个值。

在表 9.1 中,一个承诺(promise)代表一个单独的推(push),而异步生成器(async generator)可以使用不同的时间函数发射多个值。承诺和异步生成器完全控制这些事件发射的速率。for await...of 循环在幕后作为这两个数据源的永久订阅。你可以想象异步生成器作为生产者,将在其方便的时候发射其值,而异步迭代器作为消费者。回想一下列表 9.8 是如何设置数据流的:

const dataStream = fs.createReadStream(file, 
    { encoding: 'utf8', highWaterMark: 64 });

之后,代码使用for await...of来消费这个数据流。

现在,让我们通过流来提高抽象级别。流解决了相同的问题,但以一种更容易推理的方式。流是一系列一个或无限多个数据片段,称为事件。在这个上下文中,事件这个词不仅仅指鼠标拖动或按钮点击;它被用来泛指任何数据。事件是某个源(事件发射器、生成器、列表等)在一段时间内发出的某个值(同步或异步),并由订阅者或观察者处理。

由于流是随时间变化的值序列,它们可以很好地封装任何生产者,表现为单个字符串甚至复杂的异步生成器。在消费端,我们可以通过使用名为“订阅”的对象的for await...of来抽象它们。订阅类似于迭代器,你可以多次调用它们以通知数据可用,例如调用next。在使用流时,我们谈论的是订阅者,而不是消费者。为了便于构建这种抽象,让我们在下一节通过使用数组创建一个可流对象来热身。

9.3.2 实现可流数组

现在你已经了解了流的基本知识,让我们回到列表 9.8 中声明数据流的那个部分,并更仔细地研究它:

const dataStream = fs.createReadStream(file,
    { encoding: 'utf8', highWaterMark: 64 });

Node.js API fs.createReadStream返回一个fs.ReadStream对象,它反过来又扩展了stream.Readable

Node.js 流

流模块是一个相对较新的内置 Node.js 库,用于处理数据的读写流。流对象配备了Symbol.asyncIterator属性,使得消费其数据变得简单。你可以在nodejs.org/api/stream.html找到更多关于这个库的信息。

如果你查看这个 API 文档,有两个属性对于本章的目的来说很突出:一个on方法,它触发'data'事件,以及Symbol.asyncIterator方法。以下是一个示例:

dataStream.on('data', chunk => {
  console.log(`Received ${chunk.length} bytes of data.`);
});

此接口表明此对象既是异步可迭代的,也是EventEmitter。我在这本书中没有涵盖事件发射器,但我会快速回顾基础知识,以支持本章的示例。EventEmitter是一个 API,允许你将某些对象的创建与其使用分离——这是一种基本的发布/订阅形式。以下列表显示了一个示例。

列表 9.11 EventEmitter的基本使用

const myEmitter = new EventEmitter();

myEmitter.on('some_event', () => {        ❶
  console.log('An event occurred!');
});

myEmitter.emit('event');                  ❷

❶ 数据的消费者(订阅者)。这个过程类似于处理,比如一个 onClick 事件。

❷ 数据的生产者

通过结合 EventEmitterSymbol.asyncIterator,我们可以实现真正的推送解决方案。在这种情况下,发射器是一种很好的技术,可以将处理推送新数据的方法(如 push)与处理此数据订阅者的方法(如 subscribe)分开。例如,数组是拉取数据结构,因为它们有函数和属性来拉取其数据(indexOf 和索引分别)以及实现 Symbol.iterator 以拉取多个值(参见表 9.1)。如果你想对新值(称为反应的过程)运行一些代码,你必须设置某种类型的长时间轮询解决方案,在时间间隔内查看数组的状态,这不是最优的解决方案。为了提高效率,让我们反转这个流程。而不是挑数据,我们将订阅它,这样它就会在我们有新值时通知我们(称为通知的过程)。

让我们通过配置一个内部 EventEmitter 来扩展 Array,该 EventEmitter 在每次添加新值时触发一个事件。考虑一个名为 PushArray 的类,它公开两个新方法以启用订阅:subscribeunsubscribesubscribe 方法接受一个实现 next(value) 方法的对象,如下一列表所示。

列表 9.12 Array 的子类,当新元素被推入时触发事件

class PushArray extends Array {

    static EVENT_NAME = 'new_value';

    #eventEmitter = new EventEmitter();

    constructor(...values) {
       super(...values);
    }

    push(value) {
       this.#eventEmitter.emit(PushArray.EVENT_NAME, value);       ❶
       return super.push(value);
    }
    subscribe({ next }) {                                          ❷
       this.#eventEmitter.on(PushArray.EVENT_NAME, value => {
           next(value)                                             ❸
       });
    }

    unsubscribe() {                                                ❹
        this.#eventEmitter.removeAllListeners(PushArray.EVENT_NAME);
    }
}

const pushArray = new PushArray(1, 2, 3);

pushArray.subscribe({ 
    next(value) {
        console.log('New value:', value)
        // do something with value
    }
});
pushArray.push(4);                                                 ❺
pushArray.push(5);                                                 ❺

pushArray.unsubscribe();                                           ❻

pushArray.push(6);                                                 ❼

❶ 发射被推入的新值

❷ 使用解构从传入的对象中提取 next 方法

❸ 当发射器触发新值时,它会被推送到订阅者。

❹ 移除所有订阅者。任何进一步的推送事件都不会被发射。

❺ 将 'New value: 4' 和 'New value: 4' 打印到控制台。数组现在有 1,2,3,4, 5。

❻ 从推送数组对象取消订阅

❼ 订阅者不会收到事件通知。数组现在有 1,2,3,4,5, 6。

让我们仔细检查这个例子中的 subscribe 调用。订阅者的概念是流范式中的核心,它始终需要两个参与者:生产者和订阅者。当数字 4 被推入数组时,事件发射器触发并立即通知订阅者(图 9.3)。

图 9.3 具有生产者和订阅者的推送对象的基本流程

subscribe 调用接受一个形状如列表 9.13 所示的对象。

列表 9.13 接受具有 next 方法的对象的订阅者

{
   next: function (value) {           ❶
       // do something with value
   }
} 

❶ 使用属性语法而不是缩写语法,因为它更具有描述性

这个对象被称为观察者,并且方法名为 next 并非巧合。观察者不仅与 Iterable/Iterator 协议相匹配,还与推送生成器背后的协议相匹配,我在本书中省略了这部分内容以保持讨论简洁。如果你更深入地关注这个话题,你会了解到生成器不仅可以产生值,还可以允许你推送值。如果你想了解更多关于这个话题的信息,请点击以下链接:mng.bz/WdOw

因此,具有next(value)方法的观察者形状的唯一目的是保持此协议,使基于流的编程过渡流畅。表示流的 JavaScript API 称为Observable

9.4 欢迎新的本地原生:可观察流

在撰写本文时,一个缓慢推进的提案可能会显著改变我们日常编码的方式(github.com/tc39/proposal-observable)。有些人说它已经改变了我们使用第三方、以流为导向的库的方式,RxJS 就是我最喜欢的。这个项目已经深入到 Angular、Vue、React、Redux 和其他 Web 社区。

在本节中,我们将讨论Observable API 的当前状态。此 API 支持响应式流范式,它为任何数据类型和大小创建了一个抽象层,无论机制是推送还是拉取,数据是否同步或异步到达。

如果你曾经使用过 RxJS 进行过 Angular 和 React 等框架或 Redux 等状态管理库的工作,那么你可能已经使用过响应式编程。如果没有,从高层次来看,可观察流具有以下两个特性,我将在以下示例中加以阐述:

  • 数据传播 — 数据传播自然遵循发布/订阅模型。你确定一个发布者(称为源),它可以是生成器或简单的数组。数据流以单一方向传播或流动,直到到达订阅者。在这个过程中,你可以应用业务逻辑,根据你的需求转换数据。

  • 声明式、懒加载管道 — 你可以静态地表示流的执行,无论发布者还是订阅者,就像在应用程序中的任何其他对象一样传递它。与承诺不同,可观察对象是懒加载的,所以直到订阅者订阅,什么都不会运行。

可观察流可能难以理解;它们需要强大的 JavaScript 技能和对可组合代码价值的深刻理解。幸运的是,我在这本书中涵盖了所有这些主题(以及更多),在讨论如何使用此 API 时,我会重用你已学到的很多内容。

以下列表总结了这些概念(如果你跳过了任何内容,我强烈建议你回到涵盖它们的章节去阅读它们):

  • 可观察流是组合对象,因此你可以将它们组合或从现有对象中创建新的可观察流。你将创建一个混合器,它扩展了Observable原型的基功能。对象组合和混合器扩展在第三章中介绍。

  • 可观察流操作符是纯函数、可组合的、柯里化的函数。任何副作用都应该由订阅者执行。纯函数、组合和柯里化在第四章中解释。

  • Observable API 的设计借鉴了 ADT 的设计,特别是在其使用map方面。第五章展示了如何设计自己的 ADT 以及如何实现通用协议,如Functor.mapMonad.flatMap

  • 规范的一部分定义了一个新的函数值内置符号,称为Symbol.observable。实现此特殊符号的对象可以传递给Observable.from构造函数。自定义符号的实现和使用内置符号在第七章中介绍。

  • 可观察对象模拟了一个单向、线性的数据流。第八章讨论了如何使用承诺创建链以简化并展平异步流程,并使它们更容易推理。

在掌握所有基础概念之后,让我们在下一节深入探讨可观察流。

9.4.1 什么是可观察的?

在本节中,我们将学习什么是Observable,并解开其主要组件。作为一个简单的例子,考虑下一列表中的代码片段。

列表 9.14 创建和订阅Observable

const obs$ = Observable.of('The', 'Joy', 'of', 'JavaScript');

const subs = obs$.subscribe({
   next: ::console.log            ❶
});

subs.unsubscribe();

❶ 将每个单词记录到控制台,并使用绑定操作符的单值形式传递一个正确绑定的 console.log 函数

你能猜到会发生什么吗?这个列表是使用可观察者的最简单示例。注意这段代码与PushArray类的相似之处。如果你猜到它会将每个单词打印到控制台,你就猜对了!但你如何得出这个结论?你做了哪些假设?

Observable对象旨在模拟一个懒加载、单向、基于推送的数据源(如流)。

备注:值得注意的是,可观察者与称为 Web Streams(streams.spec.whatwg.org)的技术不同。尽管这些技术有一些相同的目标,但可观察者提供了一个 API 来包装任何数据源,这可以是 Web Stream,但不必是。

你可以将数据想象成一条从某个源头流向目的地的河流。这条河流流动的管道或上下文是Observable。在旅途中,河流的流向、速度和温度会发生变化,直到到达目的地。这些转折点被称为操作符,我还没有展示它们。

如果没有人在另一端接收数据,那么传输任何数据都没有意义,这就是为什么可观察者是懒加载的,并等待调用subscribe来启动流程。

Observable构造函数Observable.of提升一个可迭代对象,并返回一个具有下一列表中所示形状的Subscription对象。

列表 9.15 Subscription声明一个从流中取消订阅的方法

const Subscription = {
   unsubscribe () {         ❶
  //...                     ❷
   }
}

❶ 可以在任何时候取消订阅(流)

❷ 此函数的主体由数据的生产者提供。

这个简单的接口声明了一个单一的unsubscribe方法。这个方法的逻辑是针对数据生成方式的特定。例如,如果数据是通过setInterval间歇性地发送的,那么unsubscribe会负责清除间隔。

在河的另一边,Observer对象比普通迭代器复杂一些,但行为方式非常相似。下一个列表显示了合同。

列表 9.16 Observer对象的结构

const Observer = {
   next(event) {      ❶
  //...
   },
   error(e) {         ❷
  //...       
   },
   complete() {       ❸
  //...       
   }
}

❶ 接收流中的每个事件

❷ 当在可观察对象中某处发生异常时触发

❸ 当没有更多值要发出时调用;在错误情况下不调用

ObservableObserverSubscription共同构成了 TC39 正在标准化的骨架框架。例如,RxJS 之类的库扩展了这个框架,以提供编程工具包来处理流擅长处理的任务类型。在下一节中,我们将使用这个接口来实现更多使用可观察对象的示例。

9.4.2 创建自定义可观察对象

静态构造函数Observable.{of, from}可以用来包装或提升大多数 JavaScript 内置数据类型(如字符串和数组)或另一个Observable。这个接口是一个基本的接口。从这里,你可以直接实例化一个新的空Observable来定义你自己的自定义流。这种技术用于你想要包装,比如说,一个 DOM 事件监听器并通过Observable API 发出事件的情况。也许你已经创建了一些EventListener对象,想要将它们组合起来。下一个列表显示了一个每秒发出随机数的Observable以及处理每个事件的订阅者。

列表 9.17 使用可观察对象发出随机数

function newRandom(min, max) {                             ❶
   return Math.floor(Math.random() * (max - min)) + min;
}

const randomNum$ = new Observable(observer => {            ❷
   const _id = setInterval(() => {
      observer.next(newRandom(1, 10));
   }, 1_000);

   return() => {                                           ❸
      clearInterval(_id);
   };
})

const subs = randomNum$
    .subscribe({
       next(number) {
          console.log('New random number:', number);
       },
       complete() {
          console.log('Stream ended');
       }
    });

 // some time later...

 subs.unsubscribe(); 

❶ 返回介于 min 和 max 之间的随机数

❷ 使用新关键字实例化一个带有自定义观察者的Observable

❸ 一个订阅函数。当调用subs.unsubscribe时执行此函数的主体。

在这段代码片段中,randomNum$最初持有等待订阅者的惰性Observable对象。Observable构造函数尚未开始执行。此外,你可能已经注意到了变量名末尾使用的美元符号($)。这里没有使用jQuery$是一个约定,表示这个变量持有流。稍后,对subscribe的调用启动了流,以便新的随机数打印到控制台。这个过程无限进行,直到客户端调用unsubscribe。所谓的宝石图已经成为一种流行的展示事件如何通过可观察对象发出的方式,如图 9.4 所示。

图片

图 9.4 可观察物的单向流被描绘为箭头。你可以将生产者和订阅者(未显示但暗示)分别想象在左侧和右侧。事件(宝石)在可观察物中移动。

每个弹珠表示在一段时间内发生的事件——在这种情况下,每秒发出一个新的随机数。在每秒一个随机数的地方,你可以有诸如鼠标坐标、数组元素的枚举、HTTP 响应的块、目录遍历中的文件名、按键等事件。

在撰写本文时,可观察的规范仅定义了ObservableSubscriber的规则和骨架。在现实世界中,你需要更多。没有像 RxJS 这样的库,你几乎无法做什么。你需要能够操作数据的函数。这些函数被称为操作符。

9.4.3 构建自己的响应式工具包

当数据开始通过流流动时,操作符允许你在数据到达订阅者之前处理该流。操作符代表曲折和转弯。当前的提案没有定义任何内置的操作符集,但它确实定义了关于可观察物的两个重要规则,我们必须遵守:惰性和组合。操作符扩展了可观察物并捕获了应用程序的业务逻辑。在本节中,我们将创建自己的迷你 RxJS 库,并学习如何实现我们自己的自定义操作符,这些操作符扩展了Observable原型。如果你遵循 GitHub 中的代码,所有操作符都将定义在一个名为rx.js的模块中。

我们将设计这些操作符的方式将与 ADT 的模式和原则一致。将ObservableValidation进行比较,你可以看到一个静态提升操作符称为Observable.of(类似于Validation.of)。尽管Observable没有声明任何除subscribe之外的方法,但提案清楚地表明可观察者是可组合的对象。你还记得第四章中讨论的map/compose对应关系吗?还有什么比map操作符更可组合的吗?按照设计,这个高阶函数使我们能够转换通过可观察管道流动的数据。你可以使用这个函数为每个事件添加时间戳,从事件对象中删除字段,动态计算新字段,等等。

map 操作符

map操作符将给定的函数fn应用于可观察源发出的每个值。这种行为与任何 ADT 和简单数组的行为相匹配。我在这本书中详细讨论了map,所以我不打算回顾它所遵循的法律。让我们直接进入正题,实现Observablemap版本。

记住第五章的内容,map总是返回派生构造函数的新副本。对于Observable,你需要确保源(调用可观察对象的观察者)和新的观察者之间是链接的,以便一个的next可以输入到另一个的next中。思考一下这个概念:这又是函数组合,其中一个函数的返回值连接到下一个函数的输入。这种链接创建了数据传播,每个操作符的目标是允许数据从生产者向下流到消费者。

让我们定义map为一个独立操作符,并将其绑定到Observable.prototype,以便在下一个列表中启用 ADT 的流畅模式。

列表 9.18 自定义map操作符

const map = curry(                                  ❶
    (fn, stream) =>
        new Observable(observer => {                ❷
            const subs = stream.subscribe({         ❸
               next(value) {
                  try {
                     observer.next(fn(value));      ❹
                  }
                  catch (err) {
                     observer.error(err);
                  }
               },
               error(e) {
                  observer.error(e);                ❺
               },
               complete() {
                  observer.complete();              ❻
               }
        });
        return () => subs.unsubscribe();            ❼
    });
);

❶ 使用柯里化将映射函数部分绑定到任何流。柯里化将简化操作符的设计,使其可以作为独立使用以及实例方法。

map是结构保持和不可变的,因此它返回一个新的Observable,其订阅与源相关联。

❸ 订阅到源流

❹ 根据 map 的定义,将给定的函数应用于源可观察对象发出的每个值,并通知观察者任何错误

❺ 将源可观察对象发生的任何错误传播到下游

❻ 从源可观察对象发出完成事件

❼ 返回此订阅的 SubscriptionFunction,以便在下游取消订阅取消所有中间流的可观察对象

从操作符函数的角度来看,生产者是它之前的流对象,而订阅者是传入的观察者对象(带有next方法)。每个操作符都像map一样,创建一个新的Observable,该Observable通过Observer订阅前一个Observable,构建下游链。每个事件都会通过调用观察者的next方法沿路传播,直到达到最终的观察者:订阅者。对于error和最终的complete事件,情况也是一样的。相比之下,unsubscribe的调用会向上冒泡,取消链中的每个可观察对象。

map作为一个独立函数的做法仅仅是一个设计决策,这类似于你在 RxJS 等项目中所看到的样子。这个决策给了你使用map作为独立函数或方法的灵活性,这正是 RxJS 最新版本导出的方式。

下一个列表显示了一个简单的用例,它通过使用独立的map版本,对每个由流发出的数字应用square函数。

列表 9.19 使用可观察对象映射数字序列上的函数

import { map } from './rx.js';

const square = num => num ** 2;

map(square, Observable.of(1, 2, 3))
   .subscribe({
      next(number) {
        console.log(number);          ❶
      },
      complete() {
        console.log('Stream ended');
      }
});

❶ 打印 1、4 和 9,然后是"Stream ended"

图 9.5 中的宝石图说明了这个概念。

阅读宝石图

水晶图与反应式扩展(Rx)社区紧密相连,用于解释操作符的工作原理。我们将使用这个符号集的一个小子集。为了理解本书的目的,需要了解水晶图的一个唯一组件,那就是水晶代表一个事件或数据片段。水平箭头代表时间,水晶之间的空间代表发射之间的时间,可以是同步的(立即)或异步的。操作符函数(大矩形)作用于特定的水晶,并在需要时产生一个新的水晶,放置在新的时间线箭头上。如果操作是同步的,它映射到相同的时间点;否则,它根据操作符向前移动时间。这里的map操作符是瞬时的,例如,而像delay(未介绍)这样的操作符可以将事件延迟一段时间。如果您想了解更多关于这个工具的信息,可以在rxmarbles.commng.bz/8NzB找到好的资源。

图片

图 9.5 这个例子展示了创建一个包含数字 1、2 和 3 的可观察对象。按照原样,这段代码会同步发射这些数字。水晶之间的空间是为了可视化而添加的。

基于这个例子,下一个列表和图 9.6 展示了流的组合性。

图片

图 9.6 两个操作符的组合。显示了三个可观察对象(箭头)的实例:源可观察对象和两个操作符。每个操作符订阅前一个流并创建一个新的可观察对象。

列表 9.20 组合可观察对象

const add = curry((x, y) => x + y);

const subs = map(square, map(add(1), Observable.of(1, 2, 3)))     ❶
   .subscribe({
         next(number) {
           console.log(number);                                   ❷
         },
         complete() {
           console.log('Stream ended');
         }
   });

❶ 组合两次map调用

❷ 打印 4、9 和 16,然后输出"Stream ended"

现在您已经了解了操作符的设计和可视化方式,让我们继续介绍另一个操作符:filter

过滤操作符

在通过map之后,filter应该很简单。像Array#filter一样,这个操作符根据谓词函数的布尔结果选择哪些值被传播。下一个列表显示了实现。

列表 9.21 自定义filter操作符

const filter = curry(
    (predicate, stream) =>
        new Observable(observer => {
            const subs = stream.subscribe({
                next(value) {
                    if (predicate(value)) {       ❶
                        observer.next(value);
                    }
                },
                error(e) {
                    observer.error(e);
                },
                complete() {
                    observer.complete();
                }
            })
            return () => subs.unsubscribe();
        });
);

❶ 如果谓词返回一个真值结果,则保留该值;否则,事件不会被发射。

如您所见,大部分领域特定逻辑都位于观察者的next方法中,将结果传播到链中的下一个操作符,依此类推。下一节将跳到reduce以完成三联组。

减少(reduce)操作符

reduce操作符将源可观察对象发出的所有值折叠或减少为单个值,当源完成时发出。结果是一个只发出单个值的可观察对象,如下面的列表所示。

列表 9.22 自定义reduce操作符

const reduce = curry(
    (accumulator, initialValue, stream) => {
        let result = initialValue ?? {};                    ❶
        return new Observable(observer => {
            const subs = stream.subscribe({
                next(value) {
                    result = accumulator(result, value);    ❷
                },
                error(e) {
                    observer.error(e);
                },
                complete() {
                    observer.next(result);                  ❸
                    observer.complete();                    ❸
                }
            })
            return () => subs.unsubscribe();
        });
    };
);

❶ 当initialValue为 null 或 undefined 时创建一个新对象

❷ 应用累加器回调,类似于Array#reduce

❸ 发射累积的结果,并发送完整的信号以结束流

跳过操作符

skip 操作符允许你忽略来自源可观察对象的前 X 个事件。下一个列表显示了该操作符的实现。

列表 9.23 自定义 skip 操作符

const skip = curry(
    (count, stream) => {
        let skipped = 0;
        return new Observable(observer => {
            const subs = stream.subscribe({
                next(value) {
                   if (skipped++ >= count) {
                     observer.next(value);
                   }
                },
                error(e) {
                    observer.error(e);
                },
                complete() {
                    observer.complete();
                }
            })
            return () => subs.unsubscribe();
        });
    }
);

到目前为止,我们已经添加了 mapfilterreduceskip 操作符。信不信由你,有了这些操作符,我们可以处理广泛的编程任务。以下是一个展示它们一起使用的示例:

import { filter, map, reduce, skip } from './rx.js';
const obs = Observable.of(1, 2, 3, 4);

reduce(add, 0,
    map(square,
        filter(isEven,
          skip(1, obs)
        )
    )
 )
.subscribe({
    next(value) {
       assert.equal(value, 20);
    },
    complete() {
       done();
    }
  });

你可以看到这些操作符的可组合性。当你构建复杂的链时,这种布局很难解析。通常,像 RxJS 这样的功能齐全的响应式库具有一个 pipe 操作符,这使得编写所有操作符变得简单。另一种选择是使用点符号来流畅地编写这些链,类似于我们在承诺链上链式调用 then 方法。为此,我们需要扩展内置的 Observable 对象。

9.4.4 可观察混合扩展

让我们再次使用第三章中提到的可串联混合扩展技术,它允许我们通过新功能扩展任何对象。首先,我们将从这些操作符创建一个小的工具包模块作为对象混合,命名为 ReactiveExtensions,如下一个列表所示。

列表 9.24 定义我们迷你 rxjs 工具包的形状

export const ReactiveExtensions = {                      ❶
    filter(predicate) { 
        return filter(predicate, this);                  ❷
    },
    map(fn) {
        return map(fn, this);                            ❷
    },
    skip(count) {
        return skip(count, this);                        ❷
    },
    reduce(accumulator, initialValue = {}) {             ❷
        return reduce(accumulator, initialValue, this);
    }
}

❶ 作为 rx.js 模块的一部分导出

❷ 指的是在同一模块内创建的独立版本

现在扩展是一个简单的原型扩展,类似于 Blockchain 和其他模型对象。Object.assign 再次伸出援手:

Object.assign(Observable.prototype, ReactiveExtensions);

警告:再次提醒,在猴子补丁 JavaScript 内置类型时要小心,因为这样做会使你的代码更难移植、升级或重用。如果你出于任何原因仍然热衷于这样做,请编写所需的属性存在检查,以免破坏升级。

为了乐趣,让我们使用响应式扩展来创建一个可观察的链。列表 9.25 从有限数字集合中创建一个简单的流;每个数字都是一个事件。

如你所见,事件逐个通过管道向下流动。在这个过程中,这些链式组合的操作符操纵数据,并形成一个链,其中一个操作符订阅前一个操作符的可观察对象。

列表 9.25 使用可观察操作符操作数字序列

Observable.of(1, 2, 3, 4)
   .skip(1)                   ❶
   .filter(isEven)            ❷
   .map(square)               ❸
   .reduce(add, 0)            ❹
   .subscribe({
      next: :: console.log    ❺
   })

❶ 跳过第一个元素,即 1

❷ 测试数字是否为偶数,如果是,则让它通过。在这种情况下,2 和 4 通过。

❸ 计算每个数字的平方(分别是 4 和 16)

❹ 将所有事件相加(20)

❺ 将 20 打印到控制台

图 9.7 阐述了事件如何通过管道流动。

图 9.7

图 9.7 四个操作符的组合,数据(弹珠)根据这些操作符的应用而变化。每一步,都会创建一个新的可观察对象,并且观察者被连接。

使用组合是流的存在质量,这使得您可以将每个操作符内部发生的多个内部订阅链式连接起来,并将它们作为一个单独的订阅对象来管理。

现在您已经看到了如何连接多个操作符(图 9.7),让我们回顾一下数据如何单向向下流动。如果您将每个操作符视为一个黑盒,您会看到尽管数据向下流动,但订阅对象向上流动,从最后的subscribe调用开始,一直向上到源(初始的Observable对象)。这个最后的subscribe调用启动了一切,并通知源开始发出事件。图 9.8 解释了事件的顺序。

图片

图 9.8 一系列可观察对象。数据在观察者调用下一个的next(...)时向下流动,而订阅从最后的subscribe()调用开始向上流动。步骤编号显示最后调用subscribe()如何导致所有操作符向上内部订阅彼此,并通知源可观察对象开始向下发送事件。

到目前为止,我们一直在处理数组,它们是相对简单的事件源。但是,当我们开始处理异步、可能无限的数据源,如异步生成器时,情况就变得复杂了。在Observable.of(1,2,3)代替之前,它充当了我们之前展示的操作的流源,我们可以有一个生成器,在调用组合可观察对象的subscribe方法后,开始将值沿着链向下传递。流的无限性质在原则上与生成器的性质相似,即直到生成器返回,函数将无限期地继续yield项。每次yield调用都会依次调用观察者的next;最后,return(隐式或显式)调用complete

因此,生成器在一段时间内产生事件,并决定推送多少数据,可观察对象代表您的业务逻辑,而订阅者消费通过可观察对象流动的结果事件值。使用生成器是体验如何订阅可能无限的数据源(如 DOM 事件监听器或从 WebSocket 接收消息)的好方法。使用可观察对象,处理来自这些数据源的事件将看起来完全相同。

9.4.5 使用生成器表示推送流

生成器为使用流编程提供了有趣的机会,因为您可以生成任意数量的数据,并一次性或分块地将它们馈送到可观察对象中。之前,我们处理了将值数组提升到可观察对象的示例。现在,我们需要能够将生成器函数提升到可观察对象中。为了引入生成器函数,我们将创建一个简单、自制的构造函数。

列表 9.26 定义了一个新的静态函数,Observable.fromGenerator。这个函数接受一个普通生成器或异步生成器。我们将使用 Node.js 的 stream.Readable API 来抽象生成器函数,以保持一致的行为。这个 API 是理想的,因为它内部使用事件发射器来在数据可用时触发事件。当生成器产生新值时,Readable 会触发一个事件,并将其推送到任何正在监听的事件订阅者。我们将创建数据与结束事件以及 nextcomplete 观察者方法之间的一对一映射。使用第五章中引入的绑定操作符语法使得这种映射既优雅又简洁,因为你可以直接将绑定方法作为命名函数作为回调传递给这些事件。

列表 9.26 从生成器构造 Observables

Object.defineProperty(Observable, 'fromGenerator', {
   value(generator) {
      return new Observable(observer => {
         Readable
            .from(generator)                      ❶
            .on('data', :: observer.next)         ❷
            .on('end',  :: observer.complete);
         });
      },
   enumerable: false,
   writable: false,
   configurable: false                            ❸
});

❶ 从生成器对象实例化一个 Readable 流

❷ 直接将事件值传递给绑定观察者的 next 方法

❸ 当流结束(生成器返回)时,通知观察者流已完成

让我们将这个新的构造函数通过下面的示例列表来实际应用。

列表 9.27 使用生成器函数初始化可观察对象

function* words() {
   yield 'The';
   yield 'Joy';
   yield 'of';
   yield 'JavaScript';
}

Observable.fromGenerator(words())
  .subscribe({
     next: :: console.log,         
});

如果 words() 是一个异步生成器(async function* words),这段代码将完全以相同的方式工作。时间是流的潜流,如果事件由秒或纳秒分隔,编程模型是相同的。

现在我们有一个静态构造器操作符,可以将任何生成器函数和一些处理事件的操作符提升。让我们用这些工具解决一个更复杂的例子。回到列表 9.8,我们编写了验证从文件中读取的块流代码。这是那段代码的再次展示:

let validBlocks = 0;
const chain = new Blockchain() ;
let skippedGenesis = false;
for await (const blockData of generateBlocksFromFile('blocks.txt')) {
   if (!skippedGenesis) { 
      skippedGenesis = true;
      continue;
   }
   const block = new Block(
      blockData.index, 
      chain.top.hash, 
      blockData.data, 
      blockData.difficulty
   );  
   chain.push(block);

   if (block.validate().isFailure) { 
      continue;
   }
   validBlocks++;
}

使用我们迄今为止构建的小型响应式扩展工具包,我们可以处理这个相当复杂的过程式逻辑,并利用可观察对象促进的声明式、函数式 API。当你比较这两个列表时,你会看到代码可读性的显著提高。列表 9.28 做了以下关键更改:

  • 使用 skip 重构循环开头的跳过逻辑

  • 将块创建逻辑移动到不同的函数中,并使用 map 来调用它

  • 使用 filter 仅发射有效的块

列表 9.28 使用可观察对象验证块流

const chain = new Blockchain();

// helper functions
const validateBlock = block => block.validate();
const isSuccess = validation => validation.isSuccess;
const boolToInt = bool => bool ? 1 : 0;

const addBlockToChain = curry((chain, blockData) => {
   const block = new Block(
        blockData.index,
        chain.top.hash, 
        blockData.data, 
        blockData.difficulty
   )
   return chain.push(block);
});

// main logic

const validBlocks$ = 
   Observable.fromGenerator(generateBlocksFromFile('blocks.txt'))
    .skip(1)                                                        ❶
    .map(addBlockToChain(chain))                                    ❷
    .map(validateBlock)                                             ❸
    .filter(prop('isSuccess'))                                      ❹
    .map(compose(boolToInt, isSuccess))                             ❺
    .reduce(add, 0);                                                ❻

validBlocks$.subscribe({
       next(validBlocks) {
          if (validBlocks === chain.height() - 1) { 
             console.log('All blocks are valid!');
          }
          else {
             console.log('Detected validation error in blocks.txt');
          }         
       },
       error(error) {
          console.error(error.message);
       },
       complete() {
          console.log('Done validating all blocks!');                 
       }
 });

❶ 跳过第一个块(创世块)

❷ 将块添加到链中(用于验证算法)

❸ 验证块

❹ 仅保留有效的块

❺ 将成功的验证结果映射为一个数字(成功 = 1 | 失败 = 0),以便在下一步中累加。

❻ 添加总有效块数

列表 9.28 从生成器中实例化源可观察对象validBlocks$。这个变量持有你程序的规范。我使用“规范”这个词,因为可观察对象将你的意图声明性地捕获到一个尚未执行逻辑的对象中。它首先跳过创世块;然后映射几个用于验证的业务逻辑函数;最后,只计算Validation成功返回的块。这种逻辑更容易解析,是声明性的,无参数的,而且你的代码比以前更模块化。此外,你还可以通过Observererror方法免费获得错误处理。

我们甚至可以进一步优化这段代码。你能找到优化点在哪里吗?如果你还记得map/compose等价性,你会记得你可以通过使用compose将多个对map的调用融合成一个单一的调用。为了简洁,我将只展示Observable的声明:

Observable.fromGenerator(generateBlocksFromFile('blocks.txt'))
   .skip(1)
   .map(compose(validateBlock, addBlockToChain(chain)))
   .filter(prop('isSuccess'))
   .map(compose(boolToInt, isSuccess))
   .reduce(add, 0)
   .subscribe({...})

这个版本更容易可视化。图 9.9 展示了如何将复杂算法转换为逐步应用函数。

图 9.9 使用流验证块

此外,我提到过可观察对象有内置的错误处理机制。为了完整性,这里有一个简单的错误处理示例。当你的管道中任何地方发生异常时,错误对象会向下传播到最后一个观察者并触发error方法:

const toUpper = word => word.toUpperCase()

function* words() {
    yield 'The'
    yield 'Joy';
    yield 'of';
    yield 42;
}

Observable.fromGenerator(words())
    .map(toUpper)
    .subscribe({
      next: :: console.log,
      error: ({ message }) => { assert.equal('word.toUpperCase is not a 
         function', message) }
    })

如你从第五章回忆的那样,ADTs 在验证失败时也会跳过业务逻辑,这样你就可以从单个位置处理错误,就像下一个列表所示。

列表 9.29 在Validation.Failure上跳过映射函数

const fromNullable = value => 
   (typeof value === 'undefined' || value === null)
      ? Failure.of('Expected non-null value')
      : Success.of(value);

fromNullable('j').map(toUpper).toString();        ❶

fromNullable(null).map(toUpper).toString();       ❷

❶ 返回 'Success (J)'

❷ 跳过 toUpperCase 函数并返回 'Failure (Expected non-null value)'

再次强调,可观察对象和 ADTs 的比较非常奇特,这就是为什么在了解可观察对象之前理解 ADTs 如此重要的原因。

让我们再谈谈操作符。像 RxJS 这样的库将为你的大多数(如果不是所有)需求提供一系列操作符。在我使用它的这些年里,我很少需要添加自己的操作符,但了解库以这种方式是可扩展的总是好的。这些操作符被设计为接受可观察对象作为输入并返回新的可观察对象,这就是为什么它们被称为可管道操作符。

9.4.6 可管道操作符

可观察对象操作符也被称为可管道函数——这些函数接受一个可观察对象作为输入并返回另一个可观察对象。你已经看到了这些函数是如何通过组合和流畅链式调用执行的。在不久的将来,你将能够使用管道语法(|>)在纯、惯用、普通的 JavaScript 中本地合成可观察对象,而不需要任何组合函数的帮助,就像下一个列表所示。

列表 9.30 使用新的管道操作符组合可观察对象

import { filter, map, reduce, skip } from './rx.js';

Observable.of(1, 2, 3, 4) 
    |> skip(1) 
    |> filter(isEven) 
    |> map(square) 
    |> reduce(add, 0) 
    |> ($ => $.subscribe({ next: :: console.log }));      ❶

❶ 将值 20 打印到控制台

现在,我们真正进入了超流状态!但是等等。如果数据是异步的,会发生什么?模型会崩溃吗?绝对不会。这段代码

async function* words() {
   yield 'Start';
   yield 'The';
   yield 'Joy';
   yield 'of';
   yield 'JavaScript';
}
Observable.fromGenerator(words()) 
   |> skip(1) 
   |> map(toUpper) 
   |> ($ => $.subscribe({ next: :: console.log, complete: done }));

打印

THE
JOY
OF
JAVASCRIPT

在这种情况下,可观察对象和承诺提供了正确级别的抽象,您可以像处理不存在的时间或延迟一样处理它们。这段代码打印了正确的结果,并且按照正确的顺序打印。

或者,您可以使用绑定(::)语法从我们的反应扩展方法目录中提取方法。这里再次使用 map 的那个对象:

const ReactiveExtensions = {
    ...
    map(fn) {
       return map(fn, this);
    }
    ...
}

使用绑定运算符,我们可以控制 this 的绑定,就像我们处理虚拟方法一样。下一个列表显示了与列表 9.30 相同的程序。

列表 9.31 使用新绑定运算符组合可观察量

const { skip, map, filter, reduce } = ReactiveExtensions;
const subs = Observable.of(1, 2, 3, 4) 
   :: skip(1)                            ❶
   :: filter(isEven)                     ❷
   :: map(square)                        ❷
   :: reduce(add, 0);                    ❷
subs.subscribe({
   next(value) {
      console.log(value);                ❸
   }   
});

this 指向源可观察量 Observable.of(1, 2, 3, 4)

❷ 每个函数的 this 将设置为前一个可观察源。

❸ 打印 20 到控制台

在这段代码片段中,Observable.of(...) 成为 skip 中的 this 引用,在 filter 中创建一个新的 this 引用,依此类推。很容易看出如何将元素集合或生成函数转换为流,但对于自定义对象又该如何处理呢?

9.4.7 对象流化

在第八章中,您学习了可以通过实现内置的 Symbol.iteratorSymbol.asyncIterator 分别创建可迭代对象或异步可迭代对象。这些符号允许对象通过 for...of 循环进行枚举。如果您能够以类似的方式处理对象,使其像 Observable 一样处理,那就太好了。这种能力将使我们能够将程序中的任何自定义对象视为可观察对象,并享受我一直在描述的所有优秀功能,例如组合、强大的运算符、声明式 API 和内置错误处理。

结果表明我们可以。TC39 可观察规范建议添加另一个已知函数值符号:Symbol.observable(简称 @@observable)。其语义与其他符号一致。这个新符号与 Observable.from 一起工作,将任何需要解释为可观察对象的自定义对象提升。该符号遵循以下规则:

  • 如果对象定义了 Symbol.observable,则 Observable.from 返回调用该方法的返回值。如果返回值不是 Observable 的实例,则将其包装为一个实例。

  • 如果 Observable.from 找不到特殊符号,则将参数解释为可迭代对象,当调用 subscribe 时,迭代值将同步传递——这是一个合理的后备方案。

我现在将展示几个示例,首先是向下一个列表中的自定义 Pair 对象添加 Symbol.observable。我将省略其他符号和属性。

列表 9.32 向 Pair 添加 @@observable

const Pair = (left, right) => ({
   left,
   right,
   [Symbol.observable]() {
       return new Observable(observer => {
          observer.next(left);
          observer.next(right);
          observer.complete();
       });
    }
});
Observable.from(Pair(20, 30))         ❶
   .subscribe({
      next(value) {
         console.log('Pair element: ', value);
      }
   });

❶ 因为 Pair 的 @@observable 属性返回一个可观察对象,所以源成为调用它的结果。它打印 Pair 元素:20 和 Pair 元素:30

Blockchain 也可以成为区块的流。每当链中添加新区块时,它就会推入流中,并且任何订阅者都会收到通知。下一列表显示了与 9.3.2 节中的 PushArray 示例类似的配置。

列表 9.33 区块链中的流式传输区块

class Blockchain {
  blocks = new Map();
  blockPushEmitter = new EventEmitter();
  constructor(genesis = createGenesisBlock()) {
    this.top = genesis;
    this.blocks.set(genesis.hash, genesis);
    this.timestamp = Date.now();
    this.pendingTransactions = [];
  }

  push(newBlock) {
    newBlock.blockchain = this;
    this.blocks.set(newBlock.hash, newBlock);
    this.blockPushEmitter.emit(EVENT_NAME, newBlock);       ❶
    this.top = newBlock;
    return this.top;
  }

  //...

  [Symbol.observable]() {
    return new Observable(observer => {
      for (const block of this) {                           ❷
        observer.next(block);
      }
      this.blockPushEmitter.on(EVENT_NAME, block => {
        console.log('Emitting a new block: ', block.hash);
        observer.next(block);                               ❸
      });
    });
  }
}

❶ 通知监听器有新的区块

❷ 调用 Blockchain 的 @@iterator 来枚举当前区块列表

❸ 接收到新区块时,将其推入流

关于这个逻辑需要注意的一点是,它永远不会调用 observer.complete。它是无限的。当订阅者不再想接收新数据时,需要 unsubscribe,如下一列表所示。

列表 9.34 订阅和取消订阅响应式的 Blockchain 对象

const chain = new Blockchain();
chain.push(new Block(chain.height() + 1, chain.top.hash, []));
chain.push(new Block(chain.height() + 1, chain.top.hash, []));

const subs = Observable.from(chain)                                ❶
   .subscribe({
       next(block) {
          console.log('Received block: ', block.hash);
          if (block.validate().isSuccess) {
            console.log('Block is valid');
          }
          else {
            console.log('Block is invalid');
          }
       }
   });

// ... later in time

chain.push(new Block(chain.height() + 1, chain.top.hash, []));     ❷
subs.unsubscribe();                                                ❸
chain.height(); // 4

❶ 直接将区块链对象传递给构造函数

❷ 之后推送第三个区块,它将打印其哈希到控制台

❸ 需要取消订阅以最终化流

如果你运行这段代码,输出应该看起来像以下这样:

Received block b81e08daa89a92cc4edd995fe704fe2c5e16205eff2fc470d7ace8a1372e7de4
Block is valid
Received block 352f29c2d159437621ab37658c0624e6a7b1aed30ca3e17848bc9be1de036cfd
Block is valid
Received block 93ff8219d77be5110fa61978c0b5f77c6c8ece96dd3bba2dc6c3c4b731a724e7
Block is valid
Emitting a new block:  07a68467a3a5652f387c1be5b63159e7d1a068517070e3f4b66e5311e44796e4
Received block 07a68467a3a5652f387c1be5b63159e7d1a068517070e3f4b66e5311e44796e4
Block is valid

假设这次你将一个无效索引的第四个区块推入链中:

chain.push(new Block(-1, chain.top.hash, []));

然后,你会看到

Emitting a new block:  c3cc935840c71aa533c46ed7c3bfc5fc81e55519c7e52e0849afe091423bf5e0
Received block c3cc935840c71aa533c46ed7c3bfc5fc81e55519c7e52e0849afe091423bf5e0
Block is invalid

允许 Blockchain 被视为流,这会给你自动的响应式能力,这意味着你可以连接应用程序的其他部分,当链中添加新区块时,它们会订阅以接收通知。这个例子是一个简单的实现,但它并不远离现实世界的场景,在区块链网络中的其他服务器(节点)可以订阅以接收当任何对等节点挖掘新区块时的推送通知。

我们添加了大量代码来使 Blockchain 类变得响应式。好消息是,大多数这种行为依赖于符号,这使得我们可以将这段代码提取到一个单独的模块中,并使用元编程技术(第七章)通过挂钩这些符号来增强对象。这个模块可以作为代理使任何可迭代的对象变得响应式。

9.4.8 动态流化

在第七章,我们使用 Proxy 实现了一个智能区块——一个当其任何字段发生变化时自动重新计算其哈希的区块。在本节中,我们将使用类似的技术使 Blockchain 成为响应式数据结构,而不需要添加一行代码。代码很复杂,所以我将其分成了几个函数。以下列表显示了整体布局。

列表 9.35 reactive 函数的高级结构

const reactivize = obj => {

    implementsPush (obj) 
        || throw new TypeError('Object does 
              not implement a push protocol');

    const emitter = new EventEmitter();

    const pushProxy = //... defined next

    const observable = //... defined next

    return Object.assign(pushProxy, observable);
}

我们不需要将所有可观察的脚手架添加到 Blockchain 中,我们可以定义自己的 Push 代理来在运行时注入这种行为,并保持事物分离。代理处理对象需要对象声明一个 push 方法,Blockchain 就是这样做的。这段代码可以使任何可推送的数据结构变得响应式:

function implementsPush(obj) {
    return obj
        && Symbol.iterator in Object(obj)
        && typeof obj['push'] === 'function'
        && typeof obj[Symbol.iterator] === 'function';
}

接下来,让我们在下一列表中实现 pushProxy。这个代理将拦截对 push 的任何调用,并自动增强其行为以发出传入的值。

列表 9.36 使用代理对象拦截对 push 的调用

const ON_EVENT = 'on';
const END_EVENT = 'end';

const pushProxy = new Proxy(obj, {
   get(...args) {                                                        ❶
       const [target, key] = args;                                       ❶
       if (key === 'push') {
          const methodRef = target[key];
          return (...capturedArgs) => {
             const result = methodRef.call(target, ...[capturedArgs]);   ❷
             emitter.emit(ON_EVENT, ...capturedArgs);                    ❸
             return result;
          }
       }
       return Reflect.get(...args);
    }
 });

❶ 扩展运算符用于捕获所有参数。第一个元素是目标对象,第二个是属性键。

❷ 正常执行推送并捕获其结果

❸ 发出推送的对象

定义了push行为后,最后一项任务是实现可观察逻辑。这个逻辑监听推送事件并通知其订阅者。此外,每次实例化这个可观察量时,它都会发出(重放)数据结构当前拥有的任何对象。在列表 9.37 中,我大胆地添加了一些日志,使用了新的控制台 API console.groupconsole.groupEnd,我认为这将使追踪数据流更容易。我自己也为此任务奋斗过,尤其是在复杂且相互交织的管道中,所以额外的日志很有帮助。

列表 9.37 实现了@@observable以使任何对象表现得像流

const LOG_LABEL = `IN-STREAM`;
const LOG_LABEL_INNER = `${LOG_LABEL}:push`;

const observable = {
   [Symbol.observable]() {                                      ❶
      return new Observable(observer => {
          console.group(LOG_LABEL);                             ❷
          emitter.on(ON_EVENT, newValue => {
             console.group(LOG_LABEL_INNER);                    ❸
             console.log('Emitting new value: ', newValue);
             observer.next(newValue);
             console.groupEnd(LOG_LABEL_INNER);                 ❸
          })
          emitter.on(END_EVENT, () => {
             observer.complete();
          })
          for (const value of obj) {
             observer.next(value);
          }
          return () => {
             console.groupEnd(LOG_LABEL);                       ❹
             emitter.removeAllListeners(ON_EVENT, END_EVENT);
          };
      });
   }
}

❶ 声明 Symbol.observable,这样你可以传递这个对象与 Observable.from 一起使用

❷ 在整个流的过程中创建一个外部日志组标签。此组内的任何日志都旨在提高可见性。

❸ 创建一个内部缩进级别,以处理不同级别的每个推送序列

❹ 在整个流的过程中创建一个外部日志组标签。此组内的任何日志都旨在提高可见性。

现在我们有了所需的组件,让我们将它们组合起来。结果是带有Symbol.observable的代理对象,这样Observable API 就可以与之交互。由于Object.assign也会复制符号,让我们使用它:

return Object.assign(pushProxy, observable);

简单的部分是将具有推送/迭代行为的对象流化。例如ArrayBlockchain。为了保持简单,让我们使用一个数组:

const arr$ = reactivize([1, 2, 3, 4, 5]);

const subs = Observable.from(arr$)
   .filter(isEven)
   .map(square)
   .subscribe({
      next(value) {
         console.log('Received new value', value);
         count += value;
      }
   });

//... later in time

arr$.push(6);

subs.unsubscribe();

这段代码流程易于理解。如果你要在isEvensquare中添加日志语句,这个程序的输出将类似于这样(日志增强有助于我们阅读输出):

IN-STREAM
  Is even: 1
  Is even: 2
  Squaring: 2
  Received new value 4
  Is even: 3
  Is even: 4
  Squaring: 4
  Received new value 16
  Is even: 5

  //... later in time

  IN-STREAM:push
    Emitting new value:  6
    Is even: 6
    Squaring: 6
    Received new value 36

使用这个函数,我们可以用更简洁的Blockchain类写出之前的相同代码:

const chain$ = reactivize(chain);

Observable.from(chain$)
   .subscribe({
       next(block) {
          console.log('Received block: ', block.hash);
          if (block.validate().isSuccess) {
            console.log('Block is valid');
          }
          else {
            console.log('Block is invalid');
          }
       }
   });

本章涵盖了本书对可观察量的讨论范围。目标是让你对这种编程模型有一个初步的了解,毫无疑问,这将改变你编写 JavaScript 应用程序的方式。JavaScript 的事件循环操作方式使我们能够无缝地从数组到迭代器、生成器、异步生成器,现在再到可观察量,从而为语言创造出一个完美的架构。

想深入了解流和可观察量吗?

在前面的代码片段中实现的行为被称为冷观察者。当观察者内部产生元素时,观察者被称为冷。在这种情况下,观察者将重新播放所有事件给新的订阅者。相比之下,热观察者发生在数据在观察者外部产生时,例如来自 WebSocket。在这种情况下,没有额外的基础设施和代码,将无法重新播放已传输的数据包。

如果你想要深入了解这个主题,请查看 Paul P. Daniels 和 Luis Atencio 所著的*RxJS in Action (mng.bz/E21j),由 Manning 出版社,2017 年出版)。这本书从理论和实践的角度讨论了流和观察者,使用 RxJS 5 来展示这些概念。

在本章中,我在整本书中向你展示的技术汇聚在一起,从函数式风格的柯里化和组合到 ADT,再到可迭代器和生成器,以及时间的抽象。所有这些技术都引导我们到一个非常适合观察者的编程模型。将观察者纳入 ECMAScript 将允许平台、框架和应用程序共享基于推送的流协议。

9.5 总结思考

本书呈现了一系列 JavaScript 主题,但只是触及了你可以用这门语言做什么的表面,以及未来可能发生的事情。我希望你在本学到的主题能引导和激发你探索不同的解决问题的方式,但始终在使用的范式框架内保持一致。明智地运用这些技术,并使用适合工作的正确工具。出于教学目的,我在同一个应用程序中展示了大量的技术、模式和范式。这种方法纯粹是教学性的。我期望你挑选出适合你正在构建的应用程序类型和最适合你正在解决的问题的技术。

漫步在记忆的长河中,我们首先检查了 JavaScript 的对象和原型继承模型。你可以利用这个面向对象系统来创建捕获应用程序状态的对象。然后你学习了函数式编程如何帮助你以纯净和可组合的方式实现业务逻辑。通过减少突变和副作用,并利用闭包和高阶函数的力量,你可以摆脱甚至最好的业务逻辑测试中可能出现的讨厌的 bug。

在这两个基础建立之后,你学习了如何通过使用函数性和正交架构将代码组织成细粒度、可重用的模块。你还设定了清晰的边界,将跨切逻辑(日志记录、跟踪、策略等)与元编程分开。

最后,在查看数据和函数之后,你解决了另一个维度:时间。数据可以以多种形式从不同的位置到达。使用承诺和观察者的异步编程可以消除数据局部性,并简化你处理不同类型数据的方式,使用一致的 API 和编程模型。

重要的是要认识到,JavaScript 作为网络语言具有独特的挑战。它不仅需要与开发者想要的现代编程习惯保持相关,而且还需要继续作为整个网络编程的标准化机构。这两个力量往往是相互矛盾的,将每个可能的 API 原生化地添加到语言中并不合理。虽然使用新而闪亮的功能令人兴奋,但我们需要在创新性与不使通过网络下载的核心模块膨胀之间取得平衡。仍然强烈倾向于一个规范、裸 JavaScript 语言,它有一个小内核,你可以将 API 如PromiseProxyReflect甚至Observable插入其中。我们将不得不等待看看 JavaScript 标准库是否会继续增长,以及是否有一个模块化内核正在开发中,这样你就可以下载或导入你需要的 JavaScript 部分。

我们的旅程在这里结束。在结束之际,我想敦促你们每个人资助你最喜欢的 NPM 库或为其做出贡献。我们现在比以往任何时候都更依赖开源,开源是创新的主要途径。JavaScript 本身也在公开环境中不断发展。开源是经过检验和测试的新想法得以实现的地方。例如,ECMAScript 模块、承诺和观察者都是从开源库中起源,后来成为官方标准的。

25 岁时,JavaScript 每年都在重新构想,并重新装备以应对现代应用开发的挑战。根据像 Douglas Crockford 这样的专家的说法,最初是一种典型的面向对象语言,现在被归类为 lambda 语言。云是极限。如果所有的赌注都放在桌面上,我会继续押注 JavaScript 及其未来。我希望阅读这本书给你带来的快乐和我写作它时一样!

摘要

  • 迭代器对象有一个next方法,它返回一个具有valuedone属性的对象。value包含迭代中的下一个元素,而done是停止迭代过程的控制开关。

  • 异步迭代器的行为与普通迭代器相同,除了next返回一个具有相同形状的结果的Promise {value, done}

  • 要构建自定义可枚举对象,你可以实现Symbol.iterator。你还可以定义Symbol.asyncIterator以异步枚举你的对象的部分。

  • 生成器是一种特殊的函数,可以产生一系列值而不是单个值——迭代器的工厂。生成器函数通过星号(*)标识。

  • 生成器函数返回一个实现了迭代协议的 Generator 对象,这意味着你可以通过使用 for...of 循环来消费它。

  • 正常生成器和异步生成器之间的区别在于生成的值被一个 Promise 包裹。要消费异步生成器,你可以使用 for await...of 循环。

  • 流是随时间发出值的序列。任何东西都可以成为流,比如单个值、数组或生成器函数。任何可迭代的都可以被建模为流。

  • 新的 Observable API 提出使基于流的、响应式编程更容易。

  • 可观察者是基于推送的、声明式的流。它们的编程模型基于发布/订阅。可观察者对序列中的数据类型以及数据是同步还是异步无关紧要;编程模型是相同的。

  • 你可以通过实现一个函数值的 Symbol.observable 属性来创建和增强你自己的可观察对象。

附录 A. 配置 Babel

Babel 是一个负责将高级 JavaScript 或未来 JavaScript 转换为在您的平台(无论是浏览器还是 Node.js)上标准运行的 JavaScript 版本的 JavaScript 到 JavaScript 转译器。本书介绍了一些仍处于起步阶段的提案。为了使这种新语法能够工作,我们首先必须使用 Babel 将其转换为 Docker 容器内运行的版本的标准 JavaScript(Node.js 14)。您也可以将其转译为您的平台版本。代码遵循的标准(如 ECMAScript 2020)与您的浏览器或服务器支持的标准之间的差距越大,Babel 转译代码时需要做的工作就越多。

要在您的项目中配置 Babel,您必须安装必要的依赖项。本书使用 Babel 7。以下是项目 package.json 的一部分:

  "devDependencies": {
    "@babel/cli": "⁷.10.1",
    "@babel/core": "⁷.10.2",
    "@babel/node": "⁷.10.1",
    "@babel/plugin-proposal-class-properties": "⁷.10.1",
    "@babel/plugin-proposal-function-bind": "⁷.10.1",
    "@babel/plugin-proposal-numeric-separator": "⁷.10.1",
    "@babel/plugin-proposal-pipeline-operator": "⁷.10.1",
    "@babel/plugin-proposal-throw-expressions": "⁷.10.1",
    "@babel/preset-env": "⁷.10.2",
    "@babel/preset-flow": "⁷.10.1",
    "@babel/register": "⁷.10.1",
  }

在安装必要的依赖项之后,配置 Babel 最简单的方法是通过项目级别的 .babelrc 文件:

{
  "presets": [
    [
      "@babel/preset-env",
      {        
        "modules": false,
        "targets": {
          "node": "current"
        },
        "debug": true
      }      
    ]
  ],
  "plugins": [
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-proposal-numeric-separator",
    "@babel/plugin-proposal-function-bind",
    "@babel/plugin-proposal-throw-expressions",
    ["@babel/plugin-proposal-pipeline-operator", { "proposal": "minimal" }]
  ],
  "sourceMaps": "both",
  "comments": true,
  "highlightCode": true
}

当 Babel 配置完成后,您可以通过使用 babel-cli 来运行它。例如,要将所有文件转译到 dist 目录,您可以运行

babel src --out-dir dist --keep-file-extension --copy-files

附录 B. 强类型 JavaScript

当我们回顾这本书中关于 JavaScript 的所有内容时,很难想象还有什么遗漏。但网络是一个活生生的、有呼吸的生物,我们可以期待 JavaScript 在未来几年继续进化。信不信由你,我跳过了许多重要的话题,以便这本书能握在你的手中(或放在你的移动设备上)。但我感觉其中有一个话题很重要,至少在附录中讨论一下:JavaScript 的类型。

在编程世界中,一直存在着在强类型和弱类型语言之间进行史诗般的斗争。如果你已经读到这儿,你已经做出了那个决定。类型系统有很多种风味。这个光谱包括强类型、静态类型、弱类型、可选类型、动态类型以及许多其他变体。选择一种类型而不是另一种类型的原因是什么?你可以问十个人,得到十个不同的答案。尽管 JavaScript 始终是动态类型的,但随着像 TypeScript、Elm、PureScript 和 Reason 这样的语言继续获得动力,这个话题已经引起了更多的关注。这又把 JavaScript 置于何地?

幸运的是,你不必切换到另一种语言;你可以使用可插拔的类型扩展,这在工业界广泛使用,尤其是在 React 的 PropTypes 特性中。

拥有类型系统是有价值的,因为它有助于防止某些类别的错误。计算机在解析结构化数据方面比人类更有效,类型是提供必要结构的限制或边界。通过移除将变量分配给任何你能想象的东西的自由,计算机可以在你甚至输入npm start之前更有效地完成其工作。将类型想象成用你的智能吸尘器关闭房屋某个区域的虚拟墙壁;这样设备清洁得更好。

这个附录教你一些关于第三方、可插拔的 Flow 类型系统(flow.org)的特性,如果你想在继续使用 JavaScript 的同时,它是一个 TypeScript 的替代品。你将了解类型检查器可以为 JavaScript 项目带来的好处以及如何在代码中注释各种对象。尽管我使用 Flow 作为类型的一个参考实现,但库本身并不重要;重要的是概念。Flow 提供的特定类型注释看起来与 TypeScript 提供的类似,以及你可以在一些早期的 TC39 strawman 提案中找到的类型注释。你在这里学到的很可能会与 JavaScript 决定采纳的任何即将到来的提案兼容。

对于一本关于 JavaScript 的书来说,谈论静态类型有点不常见,但毕竟这本书不是一本常规的 JavaScript 书。

B.1 首先,是什么?

我们需要一点历史背景。JavaScript 被认为是动态类型语言的典范。所以你会发现,多年前几乎在 JavaScript 中实现了一个类型系统。ECMAScript 4 提案(mng.bz/NYe7)为基于 ECMAScript 的语言定义了一个类型系统,JavaScript 可以自然地采用。如果你浏览一下文档,你会发现其中许多内容与附录中的内容相似。然而,由于(包括 Macromedia、Netscape 和 Microsoft 在内)当时的大玩家之间缺乏共识,这个类型系统从未正式发布。

但对话并没有就此结束。在一段时间内,其他大型网络公司一直在尝试通过创建编译到 JavaScript 的新语言或通过推动开源库来扩展 JavaScript 语法(例如,使用元编程等注释),这些注释可以被类型检查工具验证,从而在标准化委员会之外尝试这个功能。

如你所知,JavaScript 是一种弱类型、动态类型的语言。现在让我们增加一个维度:可选类型(也称为可插拔类型)。让我们来分解以下内容:

  • 弱类型—指的是编程语言根据使用情况隐式转换数据类型的能力。这是一种乐观的方法,用来推测开发者试图做什么。以下是一些例子(看看你是否能猜出最后一个):

    !!'false' + 0      // 1
    2 * true + false   // 2
    
    !null + 1 + !!true // ?
    
  • 动态类型—变量类型在运行时而不是在编译时强制执行。同一个变量可以持有不同类型的值。以下是一个例子:

    let foo = 4
    foo = function() {}
    
    foo = [4]
    
  • 可选(可插拔)类型—一个可插拔的类型系统是一组你可以绑定到可选类型检查器的元注释。简单来说,如果你选择不使用,类型系统不应该妨碍你;它是纯可选的。此外,你应该能够在需要的地方逐步添加类型信息。可选类型不是全有或全无的交易。B.3 节讨论了你可以使用的一些类型注释。

JavaScript 的类型系统是动态和弱的,伴随着你熟悉的几个原始类型:stringbooleannumbersymbolnullfunctionundefined。不要忘记对 null 的奇特处理,它解析为 object。这些原始类型不定义任何属性。所以你可能想知道这个操作是如何可能的:3.14159 .toFixed(2)。调用一个方法是因为你可以使用它们相应的对象包装器—StringNumber 等—直接或间接地与这些类型一起工作。通过 间接地,我指的是以 '0'.repeat(64) 编写的代码会自动包装(装箱)并转换为 new String('0').repeat(64),这确实让你可以调用方法。

使用这个基本类型集,我们已经能够构建无限多的应用程序。这些类型由系统提供;在 JavaScript 中,你无法定义自己的自定义数据类型。然而,你可以通过叠加一个类型系统来解决这个问题。

在本附录中,你将使用 Facebook 的 Flow 库。像 Babel 一样,Flow 可以集成到你的开发工具链中。这个库易于安装和运行,所以我不想让你感到无聊,细节我就不展开了。相反,我将专注于概念,首先描述类型为你的 JavaScript 代码带来的好处。

B.2 静态类型 JavaScript 的优缺点

在本节中,我将简要介绍使用类型编程的一些一般性优点。目标不是深入探讨这个主题;许多其他书籍都做了更彻底的工作。相反,目标是让你了解使用类型编写企业级应用程序的优点,以及类型如何适应现代 JavaScript 开发环境。

我相信我们都在某个时刻问过自己,静态类型是否优于动态类型。这场辩论可能平分秋色。重要的是要提到,良好的编码实践可以走得很远;通过遵循最佳实践并正确使用语言,即使没有任何类型的提示,你也可以编写易于阅读和推理的 JavaScript 代码。

毫无疑问,类型信息非常有价值,因为它为你提供了代码正确性——衡量你的代码遵循你设计的协议和接口的程度。(例如,所有输入和输出类型是否兼容,你的对象是否有正确的形状?)类型的重要性源于它们能够限制并使你的代码更加严格和结构化。这些特性在 JavaScript 中尤其有用,因为你可以自由地做任何事。正如 Reginald Braithwaite 巧妙地所说,“JavaScript 的强大之处在于你可以做任何事情。弱点在于你将会。”

为了设定基调,接下来的列表展示了我们的带有类型信息的证明工作算法。

列表 B.1 带类型信息的证明工作代码

const proofOfWork = (block: Block): Block => {                          ❶
   const hashPrefix: string = ''.padStart(block.difficulty, '0');       ❷
   do {
      block.nonce += nextNonce();
      block.hash = block.calculateHash();
   } while (!block.hash.toString().startsWith(hashPrefix));
   return block;
}

function nextNonce(): number {
   return randomInt(1, 10) + Date.now();
}

function randomInt(min: number, max: number): number {
   return Math.floor(Math.random() * (max - min)) + min;

}

❶ proofOfWork 是一个类型为 Block => Block 的函数。

❷ hashPrefix 是一个类型为 string 的变量。

注意列表 B.1 中所有变量和函数签名前面的“: <type>”标签。函数及其返回值应该与输入参数进行类型匹配。在这种情况下,proofOfWork是一个接受Block对象并返回Block对象的函数——简而言之,一个从BlockBlockBlock => Block的函数。通过有一个清晰的合约,你可以为你的 API 应该如何使用建立正确的预期。从技术上讲,你可以在代码流中早期捕捉到很多潜在的错误。尽管这个例子对于简单的脚本或快速原型代码来说可能有些过度,但随着代码规模的增加和重构变得更加复杂,类型系统的优势是显而易见的。此外,IDE 可以提供智能建议和检查,这可以使你更有信心并提高生产力。

类型的另一个好处是编译器可以追踪并查找函数输入和输出的不一致性。所以,如果你因为重大重构而更改某些函数的合约,你将立即收到任何错误的通知,而无需运行该函数。编译器帮助你捕捉到由于类型强制而产生的隐藏 bug。你的代码可能因为某些狡猾的强制规则(例如将字符串转换为数字或真值布尔结果)而表现得好像在本地工作,但很可能会在生产级使用模式中失败。到那时,修复任何问题都太晚了。

多项研究和调查表明,类型信息至少可以减少 15%的 bug 数量。事实上,静态类型编程语言 Elm 声称从未有过运行时执行错误。(顺便说一句,Elm 编译成 JavaScript。)

然而,你用 JavaScript 编码并不意味着你不关心类型。你总是需要知道你正在处理的变量的类型,以确定如何使用它,这会给你的已经超负荷的大脑带来不必要的负担。我们 JavaScript 开发者被迫编写大量的测试,尽可能覆盖代码的各个路径,以便从在脑海中携带整个应用程序结构中解放出来。

注意:为了澄清,类型系统不是良好编写的测试的替代品。

类型在快速原型脚本之外也能发光。对于大型企业开发来说,它们增加的感知打字时间可能远小于你用来编写注释解释函数如何使用的时间。就像测试一样,类型帮助你记录代码。

下面是一个列表,列出了从类型检查中获得的一些好处,不分先后顺序:

  • 自文档化 —类型指导开发,甚至可以通过允许 IDE 推断更多关于你的代码的信息来使你更有效率。例如,如果你看到一个名为 str 的变量,它是字符串还是可观察的流?除非你有完整的上下文或打开代码,否则你永远不会知道。你可以使用 JSDoc 添加文档,这有助于 IDE 获得一些指导,但它有限,并且没有进行检查。

  • 结构化、严格的代码 —类型是应用程序的蓝图。JavaScript 的对象系统因其灵活性和可塑性而臭名昭著,允许你在运行时向对象添加属性或从对象中删除属性。这个特性使得代码难以推理,因为你必须跟踪对象何时可能改变状态。通过提前定义对象的形状来减轻认知负担。这种做法会让你在处理对象属性时三思而后行,如果你确实处理不当,类型检查器可能会警告你。

  • 避免 API 误用 —因为你可以检查输入和输出,所以你可以避免误用或滥用 API。Flow 默认为 JavaScript 核心 API 提供类型定义。没有它,当你想创建大小为 2 的数组时,new Array("2") 这样的微妙错误会被接受,而实际上应该是 new Array(2)

    此外,类型系统可以防止你调用比声明接受的参数更少的函数。例如,对 Math.pow(2) 的检查失败,因为你缺少第二个指数参数。

  • 不变量的自动检查 —一个 不变量 是一个在对象的整个生命周期中必须始终为真的断言。一个例子是“一个区块的难度值不能超过 4。”你必须在构造函数中每次都编写代码来检查这个不变量,或者使用类型系统来帮你检查。

  • 更有信心的重构 —类型可以确保在移动或更改代码结构时不会违反合约。

  • 改进的性能 —类型帮助你编写在 V8 等一些 JavaScript 引擎中更容易优化的代码。正如你所知,JavaScript 允许你调用你想要的任意数量的参数。如果你使用类型,情况就不同了。原因是类型检查器施加的限制确保函数保持单态性(保证有一个输入类型)或至少多态性(两个到四个输入类型)。编译器在特定函数上必须考虑的输入类型变化越少,你就能更好地利用 JavaScript 引擎内现有的快速内联缓存来生成最佳性能。优化一个类型相同的数组(例如所有都是字符串)的存储比优化包含两种或三种类型的数组要容易得多。

  • 减少运行时执行错误的可能性 —类型可以帮助你避免一类通常表现为 TypeErrorReferenceError 的错误。这些错误可能未经检测就进入生产系统,并且难以调试。表 B.1 总结了一些这些问题。

表 B.1 可通过类型检查避免的错误。所有这些错误都可以在开发期间而不是在运行时捕获。

描述 代码 运行时错误 类型检查
调用一个 undefined 属性 let foo = undefined; foo(); TypeError: foo is not a function 不能调用 foo,因为 undefined 不是一个函数
使用无效的 LHS 值 function foo() {} if(foo() = 'bar') {} ReferenceError: Invalid left side in assignment 赋值左侧无效
null 读取属性 let someVal = null; console.log(someVal.foo); TypeError: Cannot read property 'foo' of null 不能获取 someVal.foo,因为 null 中缺少属性 foo
null 上设置属性 let someVal = null; someVal.foo = 1; TypeError: Cannot set property 'foo' of null TypeError: Cannot set property 'foo' of null

如前所述,Elm 语言声称静态类型是它没有运行时执行错误的原因之一。对于 JavaScript 来说,情况并非如此,但至少你可以看到,大量错误是可以预防的。

为了辩论的目的,以下是使用类型的一些缺点:

  • 学习曲线陡峭 —在动态语言中,一些概念可以用简单的代码表达。向函数添加类型信息可能令人望而却步,因为要使这些信息有用,你需要捕获函数(如 curry)可以处理的输入和输出的变化。这项任务需要高级的类型系统理解。此外,对于主要依赖于其词法作用域(闭包)中存在的数据的函数类型,这些类型并不非常有用。

  • 不可移植 —目前,JavaScript 没有定义任何正式的类型系统提案。这样的提案可能在遥远的未来成为现实,但我们离它还很远。尽管一些领先工具展示了关于外观和感觉的一些共识,但类型系统仍然是供应商特定的。

  • 错误报告不佳 —一些类型错误可能难以理解且难以追踪,尤其是在实现高级类型签名时。

现在你已经了解了添加类型的利弊,让我们来看看 Flow 的类型注释。

B.3 类型注释

类型是编译时元数据,可以描述运行时值。尽管 Flow 能够通过分析你的代码来推断变量的类型,但在关键位置进行注释仍然很有帮助,以便进行更深入的分析。

Flow 是完整且广泛的,它提供了各种类型注解,其中我将讨论一些。Flow 编译器分析顶部带有//@flow指令注释的文件。在 Flow 检查您的文件后,如果一切看起来都很好,您需要使用另一个库或任何转译器(如 Babel)来删除这些注解(因为它们目前不是有效的 JavaScript)。

我无法涵盖 Flow 中可用的众多类型注解,但以下六种在日常编码中经常使用:

  • 类类型

  • 接口类型

  • 对象类型

  • 函数类型

  • 泛型类型

  • 联合类型

B.3.1 类类型

在其他静态类型、面向对象的语言中,类既作为值也作为类型操作。以下是一个示例:

class Block {
  //...
}

let block: Block = new Block(...);

这种类型的表示称为命名类型。您还可以对类内部的方法和字段进行类型注解,这是您获得最大好处的地方。下一个列表显示了Block类的示例。为了演示目的,我省略了一些部分。

列表 B.2 带有类型信息的Block

class Block {
   index: number = 0;
   previousHash: string;
   timestamp: number;
   difficulty: Difficulty;      ❶
   data: Array<mixed>;          ❷
   nonce: number;

   constructor(index: number, previousHash: string, 
            data: Array<mixed> = [], difficulty: Difficulty = 0) {

      // ...
   }

   isGenesis(): boolean {
      //...
   }

   //...
}

❶ 使用我自定义的名为“难度”的类型

❷ “mixed”可以用作可以包含任何类型对象的数组的占位符。

类型系统将确保此类属性被正确使用并分配给正确的值。该行

const block: Block = new Block(1, '0'.repeat(64), ['a', 'b', 'c'], 2);

是有效的,而这一种会发出类型警告:

const block: Block = new Block('1', '0'.repeat(64), ['a', 'b', 'c'], 2);

Cannot call Block with '1' bound to index because string is incompatible with number.

如您所见,类型检查器阻止我在构造函数中使用string代替number。像Block这样的类自然成为类型,并由 Flow 相应处理。在 B.3.2 节中,我们将查看接口类型。

B.3.2 接口类型

接口就像一个类,但应用范围更广且没有实现。接口捕获了一组可重用的属性,多个类可以实现。记住第七章中,我们的主要模型对象(BlockTransactionBlockchain)实现了一个自定义的[Symbol('toJson')]属性作为自定义 JSON 序列化的钩子。然而,这个检查是在运行时发生的,并且没有任何东西会在您运行算法之前验证对象是否实现了此协议。接口是解决这个问题的更好方案。让我们为第七章中使用符号的相同解决方案建模,这次使用接口,如下一个列表所示。

列表 B.3 使用接口而不是符号

interface Serializable {
   toJson(): string
}

class Block implements Serializable
   toJson() {                           ❶
     // ... 
   }
}

Block必须提供从其继承的接口方法的实现。

未提供实现会导致以下类型错误:

Cannot implement Serializable with Block because property toJson is missing in Block but exists in Serializable.

除了类和接口之外,对象字面量也可以进行类型注解。

B.3.3 对象类型

如你之前所见,你可以将对象分配给其类的类型。另一种选择是在对象创建时描述结构或形状。这种类型的声明适用于对象字面量。回想一下第四章,Money是一个构造函数,它返回一个具有currencyamountequalstoStringplusminus等属性的对象。我们可以以下述方式定义该数据结构:

type MoneyObj = {
    currency: string,
    amount: number,
    equals: boolean,
    round: MoneyData,
    minus: MoneyData,
    plus: MoneyData
}

const money: MoneyObj = Money('₿', 0.1);
money.amount;  // 0.1
money.locale;
Cannot get money.locale because property locale is missing in MoneyObj

MoneyObj描述了调用Money函数构造函数后生成的对象的形状。此外,如果你误输了属性的名称,类型检查器会立即告诉你:

money.equls();

你也不会介意这个有用的提示:

Cannot call money.equls because property equls (did you mean equals?) is missing in MoneyObj.

如你所知,Money是一个函数对象,这意味着你需要描述输入以及输出。这种类型被称为函数类型。

B.3.4 函数类型

函数类型声明的基本结构与箭头函数类似。它描述了输入类型,然后是粗箭头(=>),然后是返回类型:

(...input) => output

Money的情况下,构造函数接受currencyamount。如果你要检查其类型签名,它看起来会是这样:

type Money = (currency: string, amount: number) => MoneyObj

这里有一个来自第五章功能编程主题的更有趣的例子。回想一下,Validation.Success集成了Functor混入,这意味着它具有将函数映射到它的能力。这个简单的例子可能会唤起你的记忆:

const two: Success<number> = Success.of(2);
success.map(squared).get(); // 4

下一个列表显示了Success的简化类型定义,其中包括Functor.map

列表 B.4 静态类型化Success函子

class Success<T> {
    static of: (T) => Success<T>;      ❶

    isSuccess: true;
    isFailure: false;

    get: (void) => T;
    map: <Z>(T => Z) => Success<Z>;    ❷
}

❶ 一元类型构造函数

map是结构保持的。它接受一个函数并返回相同类型的实例(在这种情况下是Success)。

注意:在本附录中,我使用了一个简单具体的map类型定义。从理论上讲,map应该为所有类型(也称为高阶类型,HKT)进行泛型定义,这在像 Haskell 这样的函数式语言中使用。HKT 需要强大的类型系统,并且超出了 Flow 和类似 JavaScript 库所能做到的。

你可能会注意到一些奇怪的注释被包含在比较运算符中,例如<T>。这些泛型多态类型在软件中很常见,尤其是在处理数据结构和代数数据类型(ADT)模式时。注意map接受一个函数(T => Z)并返回Success

如果你向map提供除函数之外的其他任何内容,类型检查器会向你发出警告:

const fn = 'foo';
success.map(fn).get();

Cannot call success.map with fn bound to the first parameter because string is incompatible with function type.

类型系统从其右侧值推断出fn是一个string。这段代码捕捉到我试图将那个值用作函数。

B.3.5 泛型类型

泛型编程非常强大,它允许任何未知类型(通常称为T)作为某些算法的参数。你编写的算法可能涉及使用不同类型的数据结构,如集合或 ADT,作为处理的数据类型的容器。接受类型参数的数据结构被称为参数化类型。

通常,当你编写 JavaScript(或任何语言,无论如何),你应该坚持最佳实践来指导你的编码工作。一个例子是创建数组。根据定义,数组应该是一个同类型项的索引集合。但没有什么可以阻止你插入不同类型的元素(除了编写处理此数组的代码的可怕努力)。这样的数组是有效的 JavaScript:

['foo', null, block, {}, 2, Symbol('bar')]

我不喜欢看到这个函数映射到这个数组上,因为它可能包含大量的if/else条件,试图处理 JavaScript 中已知的所有类型。这种数组的类型签名将是Array<mixed>,它是无界的,接受语言中可用的任何类型混合。类型是关于限制的,在这种情况下,限制是有益的。

良好的 JavaScript 开发者很少在同一个数组对象中混合类型,除非可能是为了创建对象的成对。大多数时候,我们坚持使用相同的类型。你如何强制执行这种做法?你可以设定你接受的类型边界。你可以定义一个字符串数组

const strs: Array<string> = ['foo', 'bar', 'baz'];

或者是一个Block对象的数组:

const strs: Array<Block> = [genesis, block1, block2];

然而,有时你不知道你将接收到的项的类型,但你想从类型安全中受益。假设我们称这个类型为T。让我们通过一个 ADT 的例子来讨论。一个可以提升任何类型的Validation容器可以定义为Validation<T>,成功分支也会继承这个类型(Success<T>):

class Success<T> {
    static of: (T) => Success<T>;

    isSuccess: true;
    isFailure: false;

    get: (void) => T;
    map: <Z>(T => Z) => Success<Z>; 
}

注意,我正在将类型参数传递给ofgetmap等关键 API。这种技术将类型检查添加到这个 API 的每个方面。以下是一个示例:

const two: Success<number> = Success.of('foo');

Cannot assign Success.of(...) to two because string is incompatible with number

展开容器也会进行检查:

const two: Success<string> = Success.of('2');
Math.pow(two.get(), 2);

Cannot call Math.pow with two.get() bound to x because string is incompatible with number.

这里有一个示例,展示了在映射函数上的类型违规:

const success: Success<number> = Success.of(2);
success.map(x => x.toUpperCase()).get();

Cannot call x.toUpperCase because property toUpperCase is missing in Number.

注意:你可能已经注意到,类型检查器有时会使用类型的原始名称(number)或包装版本(Number)。这种情况发生在违反涉及访问属性时。因为原始类型没有属性,JavaScript 会在调用方法(如toUpperCase())之前自动包装原始类型。在这种情况下,Number包装类型没有声明该函数。

此外,类型检查器可以通过捕获这些参数中的类型信息并将其应用于代码的流程来执行深度检查。在map的情况下

map: <Z>(T => Z) => Success<Z>

在此签名中绑定两个类型参数:TZT 是容器值的类型——在本例中是 numberZ 存储结果,并从你的代码结构中推断出来。在这种情况下,类型检查器看到 toUpperCase() 是一个不在 Number 类型形状中出现的函数,并相应地警告你。

为了展示类型检查器的范围,假设你再次进行 map 调用。第一次 map 调用时的环境会传递到第二次调用。Z 会将其类型发送到 T,并且结果会被 Z 再次捕获。以下是一个示例:

success.map(x => x.toString()).map(x => x ** 2);

Cannot perform arithmetic operation because string is not a number.

图 B.1 追踪了推理过程。

图 B.1 map 调用的序列,追踪类型流以找到在字符串上执行的不合法操作(指数运算)

如图 B.1 所示,在第一次 map 调用将值转换为字符串后,第二次调用失败,因为在尝试执行算术运算之前,x 被绑定到 string

泛型编程的另一个令人信服的使用案例是流。为基于流的代码提供类型语义,你将获得可观察者的链式操作能力,以及应用于你的业务逻辑的代码正确性。在我们查看示例之前,作为一个有趣的小练习,让我们使用迄今为止学到的注解来描述流的主要接口。因为 Observable 类已经加载,我们将使用在接口前加 I. 的约定。因此我们得到 IObservableObserverISubscription,使用混合的泛型接口、对象和函数类型:

type Observer<T> = {
   next: (value: T) => void,
   error?: (error: Error) => void,
   complete?: () => void
}

interface IObservable<T> {
   skip(count: number): IObservable<T>;
   filter(predicate: T => boolean): IObservable<T>;
   map<Z>(fn: T => Z): IObservable<Z>;
   reduce<Z>(acc: (
         accumulator: Z,
         value: T,
         index?: number,
         array?: Array<T>
      ) => Z, startwith?: T): IObservable<T>;
      subscribe(observer: Observer<T>): ISubscription;
}

interface ISubscription {
    unsubscribe(): void;
}

当变量具有 IObservable 类型的信息时,不再需要遵循 $ 后缀约定。毫无疑问,你正在处理一个可观察者。在这个例子中,绑定到 number 的类型 T 流过整个 Observable 声明,类型检查器可以在管道的每一步测试它:

const numbers: IObservable<number> = Observable.of(1, 2, 3, 4);

numbers.skip(1)
    .filter(isEven)
    .map(square)
    .reduce(add, 0)
    .subscribe({
        next: :: console.log
    });

类型系统分析调用序列,并检查可观察操作符与你的业务逻辑之间的兼容性。假设你无意中向操作符传递了一个不兼容的函数:

const toUpper = x => x.toUpperCase();

//...

numbers.skip(1)
       .filter(isEven)
       .map(toUpper);

Cannot call x.toUpperCase because property toUpperCase is missing in Number

你可以创建另一个类似于图 B.2 的流程图。我将讨论的最后一个类型注解与我们第五章学到的内容相关。

图 B.2 追踪类型流通过可观察流。调用 toUpper 导致违规,因为预期的事件类型是 number

B.3.6 联合类型

联合类型或选择类型(如Validation)的签名定义了一个类型,该类型一次可以处于有限集合中的某个状态。你可能知道这种类型为枚举或enumBlock类声明了一个类型为Difficulty的参数。这种类型是一个number,它控制工作量证明算法需要花费的努力量。例如,难度值为5时,完成工作量证明可能需要数小时,甚至数天。你肯定想控制可能值的范围。

要描述此类型可以采取的可能值,使用逻辑OR|)运算符,表示联合:

type Difficulty = 0 | 1 | 2 | 3;

0的值会关闭工作量证明并立即完成。当你将旋钮调得更高时,proofOfWork需要更多的努力来运行。

另一个常见的用例是表示日志级别如下:

type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';

枚举与switch语句无缝配合,如以下列表所示。

列表 B.5 使用枚举和switch语句的不同日志级别

const level: LogLevel = getLogLevel()

switch (level) {
  case 'DEBUG':
    //...
    break
  case 'INFO':
    //...
    break
  case 'WARN':
    //...
    break
  case 'ERROR':
    //...
    break; 
                ❶
}

❶ 由于你可以保证这个变量不会处于其他任何状态,因此不需要默认条款。

我们还可以在自定义对象上使用联合类型。在第五章中,我们实现了Validation ADT,它是一个具有两个分支的分离联合:SuccessFailure。像Difficulty一样,Validation对象只能处于这两种状态之一。我们可以用相同的方式表示这种条件。以下是包含其两个分支的Validation

class Success<T>  {
    static of: (T) => Success<T>;

    isSuccess: true;
    isFailure: false;

    get: (void) => T;

    map: <Z>(T => Z) => Success<Z>;
}

class Failure {
    isSuccess: false;
    isFailure: true;

    get: (void) => Error;

    map: (any => any) => Failure;
}

type Validation<T> = Success<T> | Failure;

如您所见,Failure情况是一个更简单的类型,因为它不打算携带值,这就是为什么我使用关键字<any>来表示不需要任何类型信息。

联合运算符正在模拟代码的两个分离分支(图 B.3)。

图片

图 B.3 联合类型注解模型了一个描述两个分离控制流的逻辑OR

你可以直接或间接地在我们的Block类中使用此类型:

class Block {

   // ...

   isValid(): Validation<Block> {

      //...
   }
}

这种变化是等效的,并且以相同的方式工作:

class Block {

      // ...

   isValid(): Success<Block> | Failure {

      //...
   }
}

根据我的经验,具有类、接口、对象、函数、泛型和联合的类型在日常代码中最为常见,并且很可能会被任何未来的提案首先包括。但你可以使用许多其他类型注解。如果你对此技术感兴趣,我鼓励你自行阅读相关内容。一篇不错的入门文章可以在code.sgo.to/proposal-optional-types找到。了解签名是什么以及它们的意义对于有效地沟通函数如何与你的其他代码一起使用和组合仍然很重要。好消息是类型信息不是全有或全无的途径。随着你对它们越来越熟悉,你可以逐步开始添加类型。

JavaScript 社区在这方面一直非常活跃,从第三方扩展库如 Flow 到替代 JS 语言如 Elm 和 PureScript,无所不包。如果你对此感兴趣,一些处于 0 阶段的早期提案重新点燃了在 ECMAScript 4 时代中断的类型讨论:

如果你想要了解更多信息,请随意深入研究这些提案。

posted @ 2025-11-14 20:41  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报