JavaScript-代码整洁指南-全-

JavaScript 代码整洁指南(全)

原文:zh.annas-archive.org/md5/EBCF13D1CBE3CB1395B520B840516EFC

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JavaScript 是一种不拘小节但优雅的语言,它发现自己处于历史上最大的软件转变的中心。它现在是用于在最普遍的平台上提供用户体验的主要编程语言:网络。

这一巨大的责任意味着 JavaScript 语言不得不在不断变化的需求中迅速成熟起来。对于新兴的 JavaScript 程序员或 Web 开发人员来说,这些变化意味着语言及其生态系统变得越来越复杂。如今,可用的框架和库的数量之多令人不知所措,即使对于在这个行业工作多年的人也是如此。

本书的任务是剥开世界放在这种语言上的混乱层和概念,揭示其潜在的本质,并考虑如何利用它来编写可靠和可维护的代码,重点放在可用性上。我们将从放大并以非常基本的方式考虑为什么我们甚至编写代码开始。我们将发现我们编写的代码并不是独立存在的。我们将探讨我们的代码如何在很大和很小的方面极大地影响我们的用户和其他程序员,并讨论我们如何满足他们的各种需求。

除了对清洁编码原则的基本探索,我们还将深入研究 JavaScript 本身,引导您了解语言,从其最基本的语法到更抽象的设计模式和约定。我们还将探讨如何以最清洁的方式记录和测试我们的代码。您应该对 JavaScript 语言有扎实的掌握,并对清洁代码有敏锐的感觉。

这本书适合谁?

这本书适合所有对提高他们的 JavaScript 技能感兴趣的人。无论您是业余爱好者还是专业人士,本书都有一些方面对您有价值。在技术知识方面,本书假定读者有一些先前的编程经验,至少对 JavaScript 本身有一些经验。从这本书中获益最多的读者是那些在 JavaScript 中已经编程了几个月或几年,但一直感到被其复杂性压倒,并不确定如何编写干净且无 bug 的 JavaScript 的人。

本书涵盖的内容

第一章,“设定场景”,要求您考虑我们为什么编写代码,并探讨了我们通过代码传达意图的多种方式。本章为您提供了一个坚实的基础,您可以在此基础上建立和调整对清洁代码的理解。

第二章,“清洁代码的原则”,使用真实的 JavaScript 示例来探讨清洁代码的四个原则:可靠性、效率、可维护性和可用性。这些重要的原则为本书的其余部分奠定了基础。

第三章,“清洁代码的敌人”,揭示了一些臭名昭著的清洁代码的敌人。这些是导致不洁净代码泛滥的力量和动态,例如自我中心的编程、糟糕的度量和货物崇拜。

第四章,“SOLID 和其他原则”,探讨了著名的 SOLID 原则,并通过将它们与函数式编程原则、迪米特法则和抽象原则联系在一起,揭示了它们更深层的含义。

第五章,“命名是困难的”,讨论了编程中最具挑战性的方面之一:命名事物。它提出了一些命名的具体挑战,并将基础命名理论与现实命名问题和解决方案联系在一起。

第六章,“基本类型和内置类型”,开始深入探讨 JavaScript。本章详细介绍了 JavaScript 程序员可用的基本类型和内置类型,警告常见陷阱并分享最佳实践。

第七章,“动态类型”,讨论了 JavaScript 的动态特性,并介绍了与此相关的一些挑战。它解释了我们如何清晰地检测和转换各种类型(通过显式转换或隐式强制转换)。

第八章,“运算符”,详细介绍了 JavaScript 中可用的运算符,讨论了它们的行为和挑战。这包括对每个运算符的详细说明,以及示例、陷阱和最佳实践。

第九章,“语法和作用域的部分”,提供了对语言的更宏观的视角,突出了更广泛的语法和可用的构造,如语句、表达式、块和作用域。

第十章,“控制流”,广泛涵盖了控制流的概念,突出了命令式和声明式编程形式之间的关键区别。然后探讨了如何通过利用控制移动机制(如调用、返回、产出、抛出等)在 JavaScript 中清晰地控制流。

第十一章,“设计模式”,广泛探讨了 JavaScript 中一些更流行的设计模式。它描述了 MVC 和 MVVM 等主要架构设计模式,以及更模块化的设计模式,如构造器模式、类模式、原型模式和揭示模块模式。

第十二章,“现实世界的挑战”,探讨了 JavaScript 生态系统中一些更现实的问题领域,并考虑了如何清晰地处理这些问题。涵盖的主题包括 DOM 和单页面应用程序、依赖管理和安全性(XSS、CSRF 等)。

第十三章,“测试的景观”,描述了测试软件的广泛概念,以及如何将这些概念应用到 JavaScript 中。它特别探讨了单元测试、集成测试、端到端测试和 TDD。

第十四章,“编写清晰的测试”,进一步深入探讨了测试领域,建议您以完全清晰、直观、代表问题领域和概念上分层的方式编写断言和测试套件。

第十五章,“更干净代码的工具”,简要考虑了几种可用的工具和开发流程,可以极大地帮助我们编写和维护清洁的代码。包括诸如代码检查、格式化、源代码控制和持续集成等主题。

第十六章,“记录你的代码”,揭示了文档编写的独特挑战。本章要求您考虑所有可用的文档媒介,并要求您考虑如何理解和满足可能希望使用或维护我们的代码的个人的需求和问题。

第十七章,“其他人的代码”,探讨了在我们的 JavaScript 项目中选择、理解和利用第三方代码的挑战(如第三方库、框架和实用工具)。它还讨论了允许我们以清晰和最小侵入方式与第三方代码进行交互的封装方法。

第十八章,“沟通和倡导”,探讨了在编写和交付干净软件时固有的更广泛的基于项目和人际关系的挑战。这包括对以下内容的详细调查:规划和设置要求、沟通策略以及识别问题和推动变革。

第十九章,“案例研究”,通过对 JavaScript 项目的开发进行逐步讲解,包括客户端和服务器端的部分。本章将本书中的原则汇集起来,并通过让您接触到一个真实的问题领域和可用解决方案的开发来加以证实。

为了充分利用本书

为了更好地利用本书,有必要对 JavaScript 语言有基本的了解,并且至少有一种 JavaScript 使用平台的经验。例如,这可能包括浏览器或 Node.js。

为了执行本书中分享的代码片段,您有几种选择:

  • 创建一个包含<script>的 HTML 文件,您可以在其中放置任何您想要测试的 JavaScript 代码。为了观察可视化输出,您可以使用alert()console.log()。为了查看通过console.log()输出的值,您可以打开浏览器的开发工具。

  • 直接打开任何现代浏览器的开发工具,并直接在 JavaScript 控制台中输入 JavaScript 表达式和语句。在 Chrome 浏览器中执行此操作的指南可以在这里找到:developers.google.com/web/tools/chrome-devtools/console/javascript

  • 创建一个test.js文件,并通过 Node.js 运行它,或者使用 Node.js REPL 来交互式地测试不同的 JavaScript 语句和表达式。有关开始使用 Node.js 的全面指南可以在这里找到:nodejs.org/en/docs/guides/getting-started-guide/

浏览器开发工具可在所有现代浏览器中访问。快捷键如下:

  • 在 Chrome 中Windows 上按 Ctrl + Shift + JmacOS 上按 CMD + Shift + J

  • 在 Firefox 中Windows 和 Linux 上按 Ctrl + Shift + I 或 F12macOS 上按 CMD + OPTION + I

  • 在 IE 中Windows 上按 F12

建议您按照自己的步调阅读本书,并在发现某个主题难以理解时,在网上进行额外的研究和探索。一些特别有用的资源包括以下内容:

随着您在书中的进展,书中的内容会变得越来越详细,因此在后面的章节中放慢步伐是很自然的。这对于第 6-12 章尤其如此,这些章节非常详细地介绍了 JavaScript 语言本身的特性。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本的软件解压或提取文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Code-in-JavaScript。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在**github.com/PacktPublishing/**上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789957648_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:“我们找到一个名为shipping_address_validator的公开可用的包,并决定使用它。”

代码块设置如下:

function validatePostalCode(code) {
  return /^[0-9]{5}(?:-[0-9]{4})?$/.test(code);
}

任何命令行输入或输出都以以下方式编写:

npm install --save react react-dom

粗体:表示一个新术语,一个重要的词,或者屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“对于我们的案例研究,植物名称只以它们的拉丁全名存在,其中包括一个科(例如,Acanthaceae)。”

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一部分:清洁代码到底是什么?

在这一部分,我们将讨论代码的目的以及其原则,如清晰度和可维护性。我们还将涵盖命名事物的广泛挑战,以及一些有价值的问题和需要注意的危险。

这一部分包括以下章节:

  • 第一章,设定场景

  • 第二章,清洁代码的原则

  • 第三章,清洁代码的敌人

  • 第四章,SOLID 和其他原则

  • 第五章,命名事物很难

第一章:设定场景

JavaScript 是由 Brendan Eich 于 1995 年创建的,旨在成为一种“粘合语言”。它旨在帮助网页设计师和业余爱好者轻松地操纵和从他们的 HTML 中派生行为。JavaScript 能够通过 DOM API 实现这一点,DOM API 是浏览器提供的一组接口,可以访问 HTML 的解析表示。不久之后,DHTML成为流行术语,指的是 JavaScript 实现的更动态的用户界面:从动画按钮状态到客户端表单验证等各种功能。最终出现了 Ajax,它实现了客户端服务器之间的通信。这为潜在应用程序开辟了一个巨大的可能性。以前纯粹是文档领域的网络,现在正在成为一个处理器和内存密集型应用程序的强大平台:

1995 年,没有人能预测 JavaScript 有一天会被用来构建复杂的 Web 应用程序,编程机器人,查询数据库,为照片处理软件编写插件,并成为现存最受欢迎的服务器运行时之一,Node.js 的背后。

1997 年,JavaScript 在创立后不久由 Ecma International 标准化,以 ECMAScript 的名字,它仍在 TC39 委员会的频繁变更中。语言的最新版本根据发布年份命名,比如 ECMAScript 2020(ES2020)。

由于其不断增长的功能,JavaScript 吸引了一个充满激情的社区,推动了它的增长和普及。由于它的相当受欢迎,现在有无数种不同的方法可以在 JavaScript 中完成相同的事情。有成千上万的流行框架、库和实用程序。语言本身也在不断变化,以应对其应用程序日益增长的需求。这带来了一个巨大的挑战:在所有这些变化中,当被推拉到不同的方向时,我们如何知道如何编写最佳的代码?我们应该使用哪些框架?我们应该采用什么约定?我们应该如何测试我们的代码?我们应该如何构建明智的抽象?

为了回答这些问题,我们需要简要回顾基础知识。这就是本章的目的。我们将讨论以下内容:

  • 代码的真正目的是什么

  • 我们的用户是谁,他们有什么问题

  • 为人类编写代码意味着什么

我们为什么写代码

简单来说,我们知道编程是关于指导计算机,但我们在指导它们做什么?以及为了什么目的?代码还有什么其他用途?

我们可以广泛地说,代码是解决问题的一种方式。通过编写代码,我们表达了一个复杂的任务或一系列操作,将它们浓缩成一个可以被用户轻松利用的单一过程。因此,我们可以说代码是问题领域的表达。我们甚至可以说它是一种沟通方式,一种传达信息和意图的方式。了解代码是一个具有许多互补目的的复杂事物,比如解决问题和沟通,将使我们能够充分发挥其潜力。让我们深入探讨一下这种复杂性,探索我们所说的代码作为传达意图的方法是什么意思。

代码的意图

我们经常认为代码只是计算机执行的一系列指令。但在很多方面,这忽略了我们写代码时所做的真正魔力。当我们传达指令时,我们在向世界表达我们的意图;我们在说“这些是我想发生的事情”。

人类一直以来都在传达指令。一个简单的烹饪食谱就是一个例子:

切大约 300 克黄油(小方块!)

取 185 克黑巧克力

在平底锅上用黄油融化

打破半打鸡蛋,最好是大个的

将它们与几杯糖混合在一起

像这样的指令对人类来说很容易理解,但您会注意到它们没有严格的规范。计量单位不一致,标点和措辞也不一致。一些指令非常模棱两可,因此对于以前没有做过饭的人来说,容易产生误解:

  • 什么构成一个大鸡蛋?

  • 何时应该考虑黄油完全融化?

  • 深色巧克力应该有多深?

  • 小方块黄油有多小?

  • 在锅上是什么意思?

人类通常可以通过他们的主动性和经验来应对这种模棱两可,但机器并不那么擅长。机器必须被指示具有足够的具体性来执行每一步。我们希望向机器传达的是我们的意图,也就是请做这件事,但由于机器的性质,我们必须非常具体。值得庆幸的是,我们选择如何编写这些指令取决于我们;有许多编程语言和方法,几乎所有这些方法都是为了让人类以更轻松的方式传达他们的意图而创建的。

人类能力和计算能力之间的距离迅速缩小。机器学习、自然语言处理和高度专业化的程序的出现意味着机器在能够执行的指令类型上更加灵活。然而,代码将继续有用一段时间,因为它使我们能够以一种高度具体和标准化的方式进行沟通。通过这种高度的具体性和一致性,我们可以更有信心地相信我们的指令每次都会按照预期执行。

谁是用户?

在考虑用户时,没有关于编程的有意义的对话。用户,无论他们是其他程序员还是 UI 的最终用户,都是我们所做的核心。

让我们想象一下,我们的任务是验证网站上用户输入的送货地址。这个特定的网站向世界各地的医院销售药物。我们有点匆忙,宁愿使用别人已经实施的东西。我们找到了一个名为shipping_address_validator的公开可用的包,并决定使用它。

如果我们花时间检查包中的代码,在其邮政编码验证文件中,我们会看到这样:

function validatePostalCode(code) {
  return /^[0-9]{5}(?:-[0-9]{4})?$/.test(code);
}

这个validatePostalCode函数碰巧使用了正则表达式(也称为RegExp和 regex),用斜杠分隔,以定义要与字符串匹配的字符模式。您可以在第六章中阅读更多关于这些构造的内容,原始和内置类型

不幸的是,由于我们的匆忙,我们没有质疑shipping_address_validator包的功能。我们假设它做了罐头上说的那样。发布代码到生产环境后一周,我们收到了一个错误报告,说一些用户无法输入他们的地址信息。我们查看代码后,惊恐地意识到它只验证美国的邮政编码,而不是所有国家的邮政编码(例如,它无法在英国邮政编码上运行,比如 GR82 5JY)。

通过这一系列不幸的事件,这段代码现在负责阻止成千上万的全球客户的重要药物发货。幸运的是,修复它并不需要太长时间。

暂且不论谁对这一失误负责,我想提出以下问题:这段代码的用户是谁?

  • 我们,程序员,决定使用shipping_address_validator包?

  • 那些试图输入他们地址的无意的客户?

  • 在医院等待他们药物的患者?

这个问题没有明确的答案。当代码中出现错误时,我们可以看到可能会产生巨大的不幸的下游影响。原始程序包的程序员是否应该关心所有这些下游依赖关系?当聘请一名管子工来修理水槽上的水龙头时,他们只应该考虑水龙头本身的功能,还是倾倒进入其中的水槽?

当我们编写代码时,我们正在定义一个隐含的规范。这个规范通过它的名称、配置选项、输入和输出来传达。使用我们代码的任何人都有权期望它按照规范工作,所以我们越明确越好。如果我们正在编写只验证美国邮政编码的代码,那么我们应该相应地命名它。当人们在我们的代码之上创建软件时,我们无法控制他们如何使用它。但我们可以明确地传达关于它的信息,确保其功能清晰且符合预期。

重要的是要考虑我们代码的所有用例,想象它可能被使用的方式以及人类对它的期望,包括程序员和最终用户。我们对什么负责或负有责任是值得讨论的,这既是一个法律问题,也是一个技术问题。但我们的用户是谁的问题完全取决于我们。根据我的经验,更好的程序员会考虑到所有用户,意识到他们编写的软件并不是在真空中存在的。

问题是什么?

我们已经谈到了用户在编程中的重要性,以及如果我们希望有帮助他们的希望,我们必须首先了解他们希望做什么。

只有通过了解问题,我们才能开始组装我们的代码必须满足的要求。在探索问题时,有必要问自己以下问题:

  • 用户遇到了什么问题?

  • 他们目前是如何执行这项任务的?

  • 有哪些现有解决方案,它们是如何工作的?

当我们完全了解了问题后,我们就可以开始构思、规划和编写代码来解决它。在每一步,我们通常在不知不觉中会以对我们有意义的方式对问题进行建模。我们思考问题的方式将对我们最终创建的解决方案产生重大影响。我们创建的问题模型将决定我们最终编写的代码。

问题的模型是什么? 模型或概念模型是描述事物运作方式的图表或表示。我们在不知不觉中一直在创建和调整模型。随着时间的推移,随着您对问题领域的了解增加,您的模型将得到改进,以更好地符合现实。

让我们想象一下,我们负责为学生设计一个笔记应用,并且被要求为用户表达的以下问题创建一个解决方案:

“我有很多学习笔记,所以发现很难对它们进行组织。具体来说,当试图找到有关某个主题的笔记时,我会尝试使用搜索功能,但很少能找到我要找的内容,因为我并不总是能回忆起我写的具体文字。”

我们已经决定这需要对软件进行更改,因为我们已经从其他用户那里听到了类似的事情。因此,我们坐下来尝试想出各种想法,看看我们如何改进笔记的组织。我们可以探索一些选项:

  • 分类:将为分类创建一个分层文件夹结构。有关长颈鹿的笔记可能存在于学习/动物学下。分类可以通过手动或搜索轻松导航。

  • 标签:将能够使用一个或多个单词或短语对笔记进行标记。有关长颈鹿的笔记可能会被标记为哺乳动物长颈。标签可以通过手动或搜索轻松导航。

  • 链接:引入一个链接功能,使笔记可以链接到其他相关的笔记。例如,关于长颈鹿的笔记可能会被链接到另一篇笔记,比如长颈动物

每个解决方案都有其利弊,也有可能实现它们的组合。显而易见的一点是,这些解决方案将极大地影响用户最终使用应用程序的方式。我们可以想象,用户接触到这些解决方案后,会在他们的脑海中形成“记笔记”的模型:

  • 类别:我写的笔记在我的分类层次结构中有它们的位置

  • 标签:我写的笔记涉及许多不同的事情

  • 链接:我写的笔记与我写的其他笔记相关

在这个例子中,我们正在开发一个 UI,因此我们与应用程序的最终用户非常接近。然而,问题建模适用于我们所做的所有工作。如果我们为笔记保存创建一个纯粹的 REST API,那么将需要做出完全相同的考虑。Web 程序员在决定其他人最终采用的模型方面起着关键作用。我们不应该轻视这一责任。

真正理解问题领域

典型的失败点通常是对问题的误解。如果我们不了解用户真正想要实现什么,也没有收到所有的需求,那么我们将不可避免地保留问题的错误模型,从而实施错误的解决方案。

想象一下,在水壶发明之前的某个时刻发生了这种情况:

  • 苏珊娜(工程师):马特,我们被要求设计一个用户可以煮水的容器

  • 马修(工程师):明白了;我会创建一个完全符合要求的容器

马修没有提出任何问题,立即开始工作,对能够将自己的创造力发挥出来感到兴奋。一天后,他想出了以下装置:

很明显,马修忘记了一个关键组成部分。在匆忙中,他没有停下来向苏珊娜询问有关用户或问题的更多信息,因此没有考虑到用户可能需要以某种方式拿起热气腾腾的容器。在收到反馈后,他自然而然地为水壶设计并引入了一个手柄:

然而,这完全没有必要发生。想象一下,将这种水壶情景推广到跨越多个月的大型软件项目的复杂性和长度。想象一下在这样的误解中涉及的头痛和不必要的痛苦。解决问题的关键在于首先正确和完整地理解问题。如果没有这一点,我们甚至在开始之前就会失败。这在设计大型项目中很重要,但也在实现最小的 JavaScript 实用程序和组件中很重要。事实上,在我们编写的每一行代码中,如果我们不首先了解问题领域,我们都将完全有可能失败。

问题领域不仅包括用户遇到的问题,还包括通过我们可用的技术来满足他们需求的问题。因此,例如,在浏览器中编写 JavaScript 的问题领域包括 HTTP 的复杂性,浏览器对象模型,DOM,CSS 以及其他一系列细节。一个优秀的 JavaScript 程序员不仅必须精通这些技术,还必须理解用户遇到的新问题领域。

为人类编写代码

整本书都致力于教你如何在 JavaScript 中编写干净的代码。在接下来的章节中,我们将详细讨论几乎语言中的每个构造。首先,我们需要确定几个重要的观点,在我们考虑为人类编写干净代码意味着什么时,这些观点将非常重要。

沟通意图

我们可以说,为人类编写代码在广义上是关于意图的清晰度。而为机器编写代码在广义上是关于功能性。当然,这些需求会交叉,但是区分这种差异是至关重要的。如果我们只为机器编写代码,只关注功能,忘记了人类的受众,我们就能看到这种区别。这里有一个例子:

function chb(d,m,y) {
  return new Date(y,m-1,d)-new Date / 6e4 * 70;
}

你明白这段代码在做什么吗?你可能能够解释这段代码在做什么,但它的意图——它的真正含义——几乎不可能被理解。

如果我们清楚地表达我们的意图,那么前面的代码看起来会像这样:

const AVG_HEART_RATE_PER_MILLISECOND = 70 / 60000;

function calculateHeartBeatsSinceBirth(birthDay, birthMonth, birthYear) {

 const birthMonthIndex = birthMonth - 1;
 const birthDate = new Date(birthYear, birthMonthIndex, birthDay);
 const currentDate = new Date();

 return (currentDate - birthDate) / AVG_HEART_RATE_PER_MILLISECOND;

}

从前面的代码中,我们可以看出这个函数的意图是计算自出生以来心脏跳动的次数。这两段代码之间在功能上没有区别。然而,后一段代码更好地传达了程序员的意图,因此更容易理解和维护。

我们编写的代码主要是为了人类。你可能正在构建一个宣传网站,编写一个 Web 应用程序,或者为框架制作一个复杂的实用函数。所有这些都是为人类而做的:那些作为我们代码驱动的 GUI 的最终用户,或者那些使用我们抽象和接口的程序员。程序员的业务是帮助这些人。

即使你只是为自己编写代码,没有任何可能被其他人以任何方式使用,如果你写出清晰的代码,你未来的自己会感谢你。

可读性

当我们编写代码时,考虑人类大脑如何消化它是至关重要的。其他程序员会扫视你的代码,阅读相关部分,试图对其内部运作有一个运行的理解。可读性是他们必须克服的第一个障碍。如果他们无法阅读和认知地导航你写的代码,那么他们将更难使用它。这将严重限制你的代码的效用和价值。

根据我的经验,程序员不太喜欢以审美设计的方式思考代码,但是最好的程序员会欣赏到这些概念是内在联系的。我们代码的设计在呈现或视觉意义上与其架构设计一样重要。设计最终是关于以最佳方式为用户提供目的的创造。对于我们的同行程序员,这个目的是理解。因此,我们必须设计我们的代码来实现这个目的。

机器纯粹关心规范,并会轻松地将有效的代码解析成其部分。然而,人类更加复杂。我们在机器擅长的领域能力较弱,这也是它们存在的原因,但我们在机器可能失败的领域也很有技巧。我们高度进化的大脑,在其众多才能中,已经变得非常擅长发现模式和不一致之处。我们依赖差异或对比来集中我们的注意力。如果一个模式没有被遵循,那么对我们的大脑来说就会产生更多的工作。举个不一致的例子,看看这段代码:

var TheName='James' ;
 var City     =   'London'
var    hobby = 'Photography',job='Programming'

你可能不喜欢看这段代码。它的混乱让人分心,似乎没有遵循任何特定的模式。命名和间距是不一致的。我们的大脑在这方面很吃力,因此阅读代码,对其有一个完整的理解,变得更加认知昂贵。

我们可以重构前面的代码,使其更加一致,如下所示:

var name = 'James';
var city = 'London';
var hobby = 'Photography';
var job = 'Programming';

在这里,我们使用了单一的命名模式,并在每个语句中采用了一致的语法和间距。

或者,也许我们想在单个var声明中声明所有变量,并对齐赋值(=)运算符,使所有值沿着相同的垂直轴开始:

var name  = 'James',
    city  = 'London',
    hobby = 'Photography',
    job   = 'Programming';

你会注意到这些不同的风格非常主观。有些人喜欢一种方式,其他人喜欢另一种方式。这都没问题。我并没有说哪种方法更优越。相反,我指出,如果我们关心为人类编写代码,那么我们应该首先关心其可读性和表现,而一致性是其中的关键部分。

有意义的抽象

当我们编写代码时,我们不断使用和创建抽象。抽象是当我们将复杂性简化后提供对该复杂性的访问时发生的。通过这样做,我们使人们能够利用这种复杂性,而无需完全理解它。这个想法支撑着大多数现代技术:

JavaScript,像许多其他高级语言一样,提供了一种抽象,使我们不必担心计算机的运行细节。例如,我们可以忽略内存分配的问题。即使我们必须对硬件的限制敏感,特别是在移动设备上,我们很少会考虑它。语言不要求我们这样做。

浏览器也是一个著名的抽象。它提供了一个图形用户界面,抽象掉了 HTTP 通信和 HTML 渲染等许多细节。用户可以轻松地浏览互联网,而无需担心这些机制。

在本书的后续章节中,我们将学习更多关于如何打造良好抽象的知识。目前,可以说:在你写的每一行代码中,你都在使用、创建和传达抽象。

抽象的塔

抽象的塔是一种看待技术复杂性的方式。在基础层,我们有计算中依赖的硬件机制,如 CPU 中的晶体管和 RAM 中的存储单元。在上面,我们有集成电路。再上面,有机器码、汇编语言和操作系统。再上面,有几层,有浏览器和其 JavaScript 运行时。每一层都将复杂性抽象化,以便上面的层可以在不费太多力气的情况下利用这种复杂性:

当我们为浏览器编写 JavaScript 时,我们已经在一个非常高的抽象塔上操作。这座塔越高,操作起来就越不稳定。我们依赖于每个部分都按预期工作。这是一个脆弱的系统。

当我们考虑我们的用户时,抽象的塔是一个有用的比喻。当我们编写代码时,我们正在为这座塔增添东西,一层又一层地建造。我们的用户总是位于这座塔的上方,利用我们精心打造的机制来实现他们自己的目标。这些用户可能是利用我们的代码的其他程序员,为系统增加更多的抽象层。或者,我们的用户可能是软件的最终用户,通常坐在塔顶,通过简化的图形用户界面利用其庞大的复杂性。

干净代码的层次

在本书的下一部分中,我们将以本章讨论的基本概念为基础,并用我们自己的抽象来构建;这些抽象是我们在软件行业中用来谈论编写干净代码意味着什么的抽象。

如果我们说我们的软件是可靠的或可用的,那么我们正在运用抽象概念。这些概念必须被深入挖掘。在后面的章节中,我们还将剖析 JavaScript 的内部,看看处理支撑我们程序的语法的各个部分意味着什么。到本书结束时,我们应该能够说我们对从单独可读的代码行到设计良好且可靠的架构的多个层次的干净代码有完整的了解。

摘要

在这一章中,我们已经为自己打下了良好的基础,探索了支撑我们所有编写的代码的基本原理。我们已经讨论了我们的代码如何表达意图,以及为了构建这种意图,我们必须对用户需求和我们所涉及的问题领域有深刻的理解。我们还探讨了如何编写对人类清晰易读的代码,以及如何创建清晰的抽象,为用户提供利用复杂性的能力。

在下一章中,我们将以清晰代码的具体原则:可靠性、效率、可维护性和可用性,来进一步构建这一基础。这些原则将为我们提供重要的视角,因为我们将继续研究 JavaScript 的许多方面,以及我们如何运用它来服务于清晰的代码。

第二章:清洁代码的原则

在上一章中,我们讨论了代码开头的目的:为用户解决问题。我们讨论了迎合机器和人的困难。我们提醒自己,写代码的核心是传达意图。

在本章中,我们将从这些基础中得出四个核心原则,这些原则在创建软件时是必要考虑的。这些原则是可靠性、效率、可维护性和可用性。一个好的软件可以说具有所有这些品质。一个糟糕的软件可以说没有一个。然而,这些原则并不是规则。相反,将它们视为您可以查看代码的透镜是有用的。对于每个原则,我们将通过类比和 JavaScript 示例的混合来发现它的重要性。您应该能够从本章中学会将这些原则应用到您的代码中。

具体来说,我们将涵盖以下原则:

  • 可靠性

  • 效率

  • 可维护性

  • 可用性

可靠性

可靠性是一个良好软件系统的核心支柱。没有可靠性,技术的实用性很快就会消失,使我们陷入一种可能更好不使用它的境地。技术的整个目的可能会被不可靠性所破坏。

然而,可靠性不仅仅是大型和复杂软件系统的特征。每一行代码都可以构建成不可靠或可靠的。但是这是什么意思呢?可靠性这个词指的是可靠的质量。编写可以让人们依赖的代码是什么意思呢?通过定义三个不同的特质来帮助定义可靠性:可靠性是正确、稳定和有弹性的质量

正确性

正确的代码是符合一组期望和要求的代码。如果我编写一个函数来验证电子邮件地址,那么期望是该函数可以使用各种类型的电子邮件地址进行调用,并正确地确定它们的有效性或无效性,如下所示:

isValidEmail('someone@example.org');     // => true
isValidEmail('foo+bar_baz@example.org'); // => true
isValidEmail('john@thecompany.$$$');     // => false
isValidEmail('this is not an email');    // => false

要编写正确的代码,我们必须首先了解要求是什么。要求是我们对代码行为的正式期望。对于先前的电子邮件验证函数的情况,我们可能有以下要求:

  • 当传递有效的电子邮件地址作为第一个参数时,该函数将返回true

  • 否则,该函数将返回false

然而,第一个要求是模棱两可的。我们需要弄清楚电子邮件地址甚至是什么意思才能有效。电子邮件地址看起来是一个简单的格式;然而,实际上有许多边缘情况和奇怪的有效表现。例如,根据 RFC 5322 规范,以下电子邮件地址在技术上是有效的:

  • admin@mailserver1

  • example@s.example

  • john..doe@example.org

要知道我们的函数是否应该完全符合 RFC 规范,我们首先需要了解它的真正用例。它是电子邮件客户端软件的实用程序,还是可能在社交媒体网站的用户注册中使用?在后一种情况下,我们可能希望将更奇特的电子邮件地址视为无效,类似于之前列出的那些。我们甚至可能希望 ping 一下域名的邮件服务器来确认其存在。关键是要弄清楚确切的要求将为我们提供正确的含义。

顺便说一句,自己编写电子邮件验证函数是非常不明智的,因为有许多边缘情况需要考虑。这突显了我们追求可靠性时需要考虑的一个重要问题;通常,我们可以通过使用现有的经过验证的开源库和实用程序来实现最高级别的可靠性。在第十七章中,其他人的代码,我们将详细讨论选择第三方代码的过程以及需要注意的事项。

我们编码的要求应该始终直接源自我们的代码将如何使用。从用户及其问题开始非常重要;从那里,我们可以建立一组清晰的要求,可以进行独立测试。测试我们的代码是必要的,这样我们就可以确认,对自己和利益相关者来说,我们的代码是否满足所有不同的要求。

通过以前的电子邮件地址验证示例,一组良好的测试将包括许多电子邮件地址的变化,确保所有边缘情况都得到充分考虑。在第十三章中,测试的景观,我们将更详细地讨论测试。然而,现在,简单地反思正确性的重要性以及我们可以建立和确认的方式就足够了:

  • 了解正在解决的问题以及用户的需求

  • 将您的要求细化,直到清楚明确需要什么

  • 测试您的代码以验证其正确性

稳定性

稳定性是我们在所有技术中都希望具备的特征。没有稳定性,事情就会变得不稳定;我们会不确定事情是否会在任何时刻发生故障。稳定性最好由现实世界技术的一个常见例子来说明。比较这两座桥:

[来自 Unsplash 的照片/由 Zach Lezniewicz/由 Jacalyn Beales]

它们在技术上都是正确的桥梁。然而,其中一座之前曾遭受损坏,并且已经用一块简单的木板修复。你会相信哪座桥安全地运送一百人?可能是右边的那座。它牢固地固定在那里,有护栏,而且关键的是,没有可以掉下去的缝隙。

在代码中,我们可以说稳定性是关于在不同有效输入和情况下持续正确的行为。浏览器中的 JavaScript 特别容易出现这种失败。它必须在多种条件下运行,包括不同的硬件、操作系统、屏幕尺寸,而且通常在具有不同功能的浏览器中。

如果我们编写的代码严重依赖于某些条件,那么当这些条件不存在时,它可能会变得笨拙和不可靠。例如,如果我们设计和实现网络应用程序的布局,可能会忘记考虑并适应小于 1,024 像素宽的屏幕尺寸,导致以下混乱:

这是一个不稳定的例子。当某个环境因素不同时,无法依赖网络应用程序提供其正确的行为。在移动设备使用不断增加的世界中,屏幕尺寸小于 1,024 像素的情况是完全可能和合理的;这是我们网络应用程序的绝对有效用例,而未能适应它会对用户依赖它的能力产生负面影响。

稳定性是通过充分了解代码可能暴露的所有不同有效输入和情况来获得的。与正确性类似,稳定性最好通过一组测试来确认,这些测试将代码暴露给各种输入和情况。

弹性

弹性是关于避免失败的。稳定性主要关注预期输入,而弹性关注的是当您的代码暴露于意外或非例行输入时会发生什么。软件系统中的弹性也被称为容错,有时会以冗余应急措施的形式讨论。从根本上讲,所有这些都是为了实现同样的目标——最小化失败的影响。

对于关键系统,生命取决于持续功能的情况,通常会在系统中建立各种应急措施。如果出现故障或故障,系统可以利用其应急措施隔离和容忍该故障。

在为航天飞机建造飞行控制系统时,美国国家航空航天局(NASA)通过使用一组同步冗余的机器来为系统构建了弹性。如果一个机器由于意外情况或错误而失败,那么另一个机器将接管。回到地球上,我们在医院中建立了备用发电机,如果电力网断电,备用发电机将立即启动。同样,一些城市交通网络在火车不运行的情况下会受益于替代公交车服务的应急措施。

这些庞大而复杂的系统似乎与 JavaScript 的世界相去甚远。但通常情况下,我们也在不知不觉中经常考虑并在我们的代码库中实现弹性。我们实现这一点的一种方式是通过优雅降级。当我们为浏览器环境编写 JavaScript 时,我们有一些关键的期望:

  • JavaScript 将通过 HTTP 正确传递

  • JavaScript 的版本得到浏览器支持

  • JavaScript 没有被广告拦截器或其他附加组件阻止

  • 浏览器通常没有禁用 JavaScript

如果这些条件中的任何一个不成立,用户可能面临完全无法使用的网站或 Web 应用程序。缓解这些问题的方法是考虑优雅降级。优雅降级涉及应用程序的一些方面降级到仍然可以使用的状态,即使在面对意外故障时仍然对用户有用。

优雅降级通常以一个简单的扶梯来说明:

[照片来自 Unsplash,由 Teemu Laukkarinen 拍摄]

当扶梯正常运行时,它会通过一组由强大的齿轮系统和电动机驱动的移动金属台阶传送人们。如果系统由于任何原因失败,那么扶梯将保持静止,就像一般的楼梯一样。因此,可以说扶梯具有弹性,因为即使发生意外故障,它们仍然可用。用户仍然可以通过扶梯上下移动,尽管可能需要更长的时间。

在编写 JavaScript 时,我们可以通过检测我们依赖的功能并仅在可用时使用它们来为我们的代码构建弹性。例如,我可能希望向用户播放 MP3 音频。为了实现这一点,我将使用 HTML5 音频元素。然而,在这之前,我将检测浏览器是否支持 MP3 音频。如果不支持,我可以通知用户并指引他们阅读音频的转录:

function detectAudioMP3Support() {
 const audio = document.createElement('audio');
 const canPlayMP3 = audio.canPlayType &&
 audio.canPlayType('audio/mpeg; codecs="mp3"')
 return canPlayMP3 === 'probably';
}

function playAudio() {
 if (detectAudioMP3Support()) {
 // Code to play the audio
 // ...
 } else {
 // Code to display link to transcript
 // ...
 }
}

上述代码使用 HTMLMediaElement 的canPlayType方法来识别支持。我们将这一点抽象成一个detectAudioMP3Support函数,然后调用它来决定我们是否继续播放音频,或者显示音频的转录。显示音频的转录是一种优雅降级,因为它仍然允许用户在无法播放音频的情况下获得一些效用。

重要的是要注意,仅仅进行功能检测本身并不是优雅降级。如果我检测到 MP3 支持,但如果不可用则默默失败,那就不会有太大作用。然而,为我们的用户激活替代路径——在这种情况下,启用音频的转录阅读——是优雅降级和对故障的弹性的完美例子。

将弹性构建到软件中有一些奇怪之处。通过考虑和适应潜在的意外故障状态,我们实际上是在使这些故障状态变得可预期。这使我们的软件更加稳定和更加可用。随着时间的推移,我们曾经需要对其进行弹性处理的问题现在将成为软件稳定性的日常部分。

韧性是编写清晰、可靠代码的重要组成部分。从根本上说,我们编写代码是为了解决用户的问题。如果我们的代码能够容忍和适应边缘情况、意想不到的情况和意外的输入,那么它将更有效地实现这一目的。

效率

我们生活在一个资源有限的世界中。为了编写最佳的代码,我们需要考虑到这种稀缺性。因此,在设计和实现我们的想法时,我们应该着眼于效率。

在本节中,我们将通过示例探讨效率的不同方面,并将它们与 JavaScript 的世界联系起来。希望您能对效率不仅仅是快速的概念有所了解,而是包含许多间接影响,从经济到生态的方方面面。

时间

时间是我们一直关注的一个关键稀缺资源。时间是一种重要的资源,我们应该只在经过考虑后才使用。在编程世界中,我们应该寻求优化在任何给定任务上花费的时间或 CPU 周期的数量。这是为了迎合我们的最终用户,因为他们自己的时间有限,但也是为了谨慎使用有限且昂贵的硬件。

在 JavaScript 中,几乎任何函数都有更高效和更低效的编写方式。例如,这个函数,它删除数组中的重复字符串:

function removeDuplicateStrings(array) {

  const outputArray = [];

  array.forEach((arrayItem, index) => {

    // Check if the same item exists ahead of where we are.
    // If it does, then don't add it to the output.
    if (array.indexOf(arrayItem, index + 1) === -1) {
      outputArray.push(arrayItem);
    }

  });

  return outputArray;
}

这段代码是可靠的,满足要求,并且在大多数情况下都是完全可以的。但它做了不必要的工作。在数组的每次迭代中,它都会重新遍历整个数组,从当前索引开始发现是否存在重复值。这种方法可能看起来有点直观,但是很浪费。

我们可以不再检查整个输入数组,而是只需检查现有的输出数组是否包含特定值。如果输出数组已经包含该值,那么我们就知道不需要再添加它。这是我们稍微优化过的if条件:

// Check if the same item exists already in the output array.
// If it doesn't, then we can add it:
if (outputArray.indexOf(arrayItem) === -1) {
  outputArray.push(arrayItem);
}

还有其他优化的方法,取决于有多少个唯一值,以及输入数组的大小。例如,我们可以将找到的值存储为对象中的键(HashMap方法),这在某些情况下可以减少查找时间。

要谨慎对代码进行微观优化。它们可能并不总是值得成本。相反,首先衡量代码的性能,然后解决真正存在的性能瓶颈。

花费太长时间在一个任务上可能会对用户执行任务的能力产生重大影响。在本章的后面,我们将讨论可用性的原则,但现在重要的是要注意,时间效率不仅在原则上很重要;它之所以重要是因为在规模上,这些通常微小的效率努力可能对可用性产生巨大的积极影响。

空间

空间是一种稀缺资源,与事物的大小有关。数据在网络和机器之间穿梭,在 RAM 中临时存储,可能以硬盘或固态驱动器(HDD、SSD)的形式保存到永久存储中。作为效率的倡导者,我们只对完成给定任务所需的空间感兴趣,其中一部分是以最有效的方式使用可用空间,并且只在有必要时移动数据。

由于 JavaScript 语言的高级特性和通常构建的应用程序,我们很少需要考虑临时 RAM 使用或永久存储。然而,JavaScript 在性能敏感的环境中已经取得了显著进展,例如数据库基础设施和 HTTP 中间件,因此这些问题现在更加相关。

此外,客户端应用程序的需求在浏览器和本地环境中都大大增加。这些应用程序的复杂性意味着我们必须时刻保持警惕,考虑如何优化服务器、用户设备和日益复杂的网络上的内存和带宽使用。我们在 Web 应用程序中吸收的带宽将直接影响用户等待应用程序可用的时间。

首次渲染时间是我们在开发 Web 应用程序前端时感兴趣的常见指标。通过谨慎使用大型资源并不阻塞初始加载时间来优化这一点。

时间空间效率是紧密相连的,两者直接影响彼此。效率的总体主题是只做必要的事情,避免浪费,并节约可用资源。

效率的影响

时间和空间效率在软件本身和更广泛的世界中负责许多其他效应,两者都直接影响另一个。没有优化是孤立存在的。在一个领域节省的资源将总是在其他领域产生连锁效应。同样,任何不必要的成本通常会在后续产生瓶颈和问题。

有太多这些效应可以列举,但在软件世界中一些最明显的效应包括以下内容:

  • 电力消耗的生态效应(例如气候变化)

  • 使用慢软件所带来的认知负担(例如分散注意力和烦恼)

  • 用户设备的电池寿命,因此他们选择优先考虑的任务

我们在做出选择时,始终要考虑我们所做选择的连锁效应,无论是为了效率还是其他要求。我们所创造的一切都不是孤立存在的。

可维护性

可维护性是指可以对您的代码进行适当更改的容易程度。与机动车不同,代码通常不需要例行维护来避免生锈等问题,但它仍然需要不时修复。对其功能的更改也经常是必要的,特别是在积极开发时。我们所工作的大部分代码也正在被其他人积极开发。这种共享所有权在很大程度上依赖于可维护性的原则。

使代码易于维护不应该成为一个次要的优先事项。这与代码满足的任何其他要求一样重要。在第一章中,我们谈到了考虑用户是多么重要。如果我们不考虑维护和更改我们的代码的人也是我们的用户,那就是虚伪的。他们希望使用我们创建的东西来实现某种目的;因此,他们是我们的用户。因此,我们需要考虑如何最好地满足他们的需求。

在接下来的部分中,我们将探讨可维护性的两个方面:适应性和熟悉度。

适应性

可以说,最好的维护是不需要发生的维护。适应性是指您的代码适应和适应不同需求和环境的能力。代码不能无限适应。代码的本质是为特定目的而制定的;解决用户的特定问题。我们可以并且应该在我们的代码中提供一定程度的配置,以满足不同的需求,但我们无法预见所有可能性。最终,可能需要有新需求的人来进行更改底层代码。

如果我们创建一个显示图片轮播(幻灯片放映)的 JavaScript 组件,很明显可以想象用户会想要配置显示的特定图片。例如,我们还可以有一个配置选项来启用或禁用轮播的淡入淡出行为。我们的完整配置选项可能如下所示:

  • (数组) images:您希望在轮播中显示的图像 URL

  • (布尔值) fadeEffectEnabled:是否在图像之间进行淡入淡出

  • (数字) imageTimeout:单个图像显示的毫秒数

  • (布尔值) cycleEnabled:是否保持幻灯片重复播放

这些配置选项定义了我们的组件的适应程度。通过使用这些选项,可以以多种不同的方式使用它。如果用户希望它以这些选项无法实现的方式行为,那么他们可能希望通过修改底层代码来改变其行为。

当需要对底层代码进行更改时,重要的是能够尽可能轻松地进行更改。可能会导致麻烦的两个有害特征是脆弱性和僵化:

  • 脆弱性是在尝试更改时变得脆弱的特征。如果更改代码的某个区域以进行错误修复或添加功能,并且它影响了代码库中另一个部分中的几个看似无关的事物,那么我们可以说代码是脆弱的

  • 僵化是指难以轻松适应变化的特征。如果需要更改某个行为,理想情况下,我们应该只需要在一个地方进行更改。但如果我们不得不到处重写代码才能实现那个变化,那么我们可以说代码是僵化的

脆弱性和僵化通常是较大代码库的症状,其中模块之间存在许多相互依赖。这就是为什么我们说模块化如此重要。模块化是指将关注点分离到代码的不同区域,以减少交织的代码路径。

有各种原则和设计模式可以用来实现模块化。这些在第四章中讨论,SOLID 和其他原则,并且在第十一章中有更多的代码示例,设计模式。即使在这个早期阶段,问问自己:我可以以什么方式实现模块化?

努力避免脆弱性和僵化是一个很好的目标,将使我们的代码更容易适应变化,但对于维护者来说,代码库最关键的方面是可理解性。也就是说,它可以被理解的程度。如果维护者不理解,甚至无法开始进行更改。事实上,在晦涩和令人困惑的代码库中,有时甚至无法确定是否需要进行更改。这就是为什么我们现在将探讨熟悉度作为可维护性的一个方面。通过使用熟悉的约定和直观的模式,我们可以帮助确保我们的维护者之间有高水平的理解。

熟悉度

熟悉是一种美好的感觉。这是一种让你感到舒适的感觉,因为你知道发生了什么,因为你以前见过。这是我们应该希望在所有可能遇到我们代码的维护者身上产生的感觉。

想象一下,你是一个技术娴熟的技师。你打开一辆旧车的引擎盖。你期望各种组件都能以各自的位置可见。你擅长识别特定的组件,即使不用移动东西,你也能看到组件是如何连接在一起的。

进行了一些小的修改;也许车主之前安装了涡轮增压发动机或修改了齿轮比,但总的来说,你会发现一切都基本在应该的位置上。对于你这个技师来说,进行改变将会非常简单:

[Unsplash image (Public Domain) by Hosea Georgeson]

在这个例子中,所有的东西都在预期的指定位置。即使汽车在许多方面有所不同,它们的基本功能是相同的,因此布局和设计对于技师来说是熟悉的。

当我们考虑软件时,它并不那么不同。我们最终创建的大多数软件在许多方面都类似于其他软件。例如,大多数网络应用程序都会有用户注册、登录和更改名称的方式。大多数软件,无论问题领域如何,都会有创建、读取、更新和删除(CRUD)的概念。这构成了持久存储的著名动词。大多数软件可以被认为是坐落在持久存储之上的花哨中间件。因此,即使我们可能认为所有软件应用都非常不同,它们的基本原理通常是非常相似的。因此,我们应该不难编写满足打开引擎盖的技工的代码。

为了使技工的工作尽可能简单,我们需要首先关注我们的代码的熟悉度。这并不简单,因为不同的东西对不同的人来说是熟悉的,但总的来说,我们可以从以下指南中获得启示:

  • 不要偏离常见的设计模式

  • 在语法和表现上保持一致

  • 为陌生的问题领域提供清晰度

最后一点提到了陌生的问题领域。这是你作为程序员在每个你工作的代码库中都需要考虑的事情。要分辨某物是否可以被视为陌生的,你可以问自己:另一个行业的程序员是否能够在很少的介绍下理解这个?

可用性

尽管可维护性主要是关于迎合其他程序员,但可用性是关于迎合所有用户,无论他们是谁。我们可以说有两大类用户参与我们的服务:

  • 希望通过接口(GUI、API 等)运用我们代码的人。

  • 希望对我们的代码进行更改以完成新任务或修复错误的人

可用性是关于使我们的代码以及它所启用的函数和交互对于所有用户尽可能有用和易于使用。所有的代码都是至少针对一个用例编写的,因此根据它实现这一目的的程度来评判代码是公平的。然而,可用性不仅仅是关于满足用户需求;它是关于创造能够让用户以最小的麻烦、时间和认知努力实现他们目标的体验。

无论是在网络上创建用户界面还是深度嵌入的服务器基础设施,可用性都是至关重要的,即使它们很少见光。在这两种情况下,我们都在为用户提供服务,因此我们必须关心可用性。

看一下这个函数的签名,试着分辨你会如何使用它:

function checkIsNewYear(
  configuration,
  filter,
  formatter,
  MDY,
  SMH
) {...}

这个函数是我曾经工作过的一个代码库中的真实函数签名。它没有文档,其中的代码是混乱的。它被用来计算给定时间是否可以被视为新年,并决定何时向用户显示新年快乐的消息。然而,它的使用方式或工作原理非常不清楚。发现这个函数时我可能会有一些开放性问题,如下:

  • 配置是什么,这样一个简单的函数中可以配置什么?

  • 据推测,SMH 是秒、分钟和小时,但它预期是什么样的值?一个对象吗?

  • 据推测,MDY 是月、日和年,但它预期是什么样的值?一个对象吗?

  • 这个函数比较传递的日期是哪一年,以判断它是否是新年

  • 假设在表面上的新年中任何日期都可以工作,还是只有比如说 1 月 1 日?

  • 为什么有过滤器格式化程序参数,它们是什么作用?它们是可选的吗?

  • 这个函数返回什么?一个布尔值吗?格式化程序参数似乎不是这样。

  • 为什么我不能只传递一个日期对象而不是单独的日期组件?

该函数可能会按要求执行,但是,正如你所看到的,它并不是非常易用。要弄清楚它的工作原理需要花费大量时间和认知努力。要完全弄清楚它,我们必须研究其在代码其他部分的用法,并尝试解密其中的混乱。作为这个函数的用户,我个人会觉得整个过程非常痛苦。

如果说有什么,易用性就是要避免这种痛苦和负担。作为程序员,我们参与创建抽象来简化复杂的任务,但前面的所有代码所实现的只是对一个简单问题的进一步复杂化。

用户故事

易用性是指某物在特定目的下易于使用的程度。其目的由对问题的一个清晰的模型和一组明确的要求定义。表达这些目的的一个有用的技术是通过用户故事,这是 Scrum 和敏捷方法论所著名的。用户故事通常采用以下形式:

作为{角色},我想要{愿望},以便{目的}...

以下是一些用户故事的示例,如果我们设计一个联系人应用程序,你会期望看到这些类型的用户故事:

  • 作为用户,我想要添加一个新的联系人,以便我以后可以从我的联系人列表中回忆起该联系人

  • 作为用户,我想要删除一个联系人,以便我将不再在我的联系人列表中看到该联系人

  • 作为用户,我想要通过他们的姓氏轻松找到一个联系人,以便我可以联系他们

用户故事有助于定义你所满足的目的,并有助于集中精力关注用户的视角。无论你是创建一个五行函数还是一个一万行的系统,规划你的用户故事总是值得的。

直观设计

直观地设计某物意味着设计它,使用户不必花费认知努力来弄清楚它的工作原理。直观设计的核心理念是它只是工作

当我们编写代码时,我们参与了它的设计,它的大体架构,它的功能和逐行语法。所有这些都是设计的重要部分。使用直观的设计模式对于编写可用的代码至关重要。所有用户都熟悉一组在他们的抽象层次上使用的模式。以下是一些例子:

  • 在 GUI 中:使用X按钮表示退出程序或进程

  • 在代码中:以is开头的函数或方法表示布尔返回值

  • 在 GUI 中:使用绿色表示肯定操作,红色表示否定操作

  • 在代码中:大写常量,例如,VARIABLE_NAME

  • 在 GUI 中:使用软盘图标表示保存的概念

这些是许多用户在使用软件时携带的假设和期望。利用这些假设意味着你的代码和它所促成的交互可以更容易使用。

无障碍

无障碍是易用性中的一个关键原则,它强调满足所有用户的重要性,而不考虑他们的能力和环境。易用性往往关注用户,好像他们是一个单一的实体。我们通常对用户做出具体的假设,赋予他们一组特征和能力,这些特征和能力可能并不反映现实。然而,无障碍是关于真正将要使用你所创建的任何东西的用户。这些真正的用户是一个多样化的个体群体,可能有各种不同。当我们谈论软件的无障碍时,我们通常关注直接影响一个人使用该软件能力的差异。这些可能包括以下内容:

  • 学习障碍或不同,如阅读障碍。

  • 身体残疾。例如,手部活动能力受限或失明。

  • 自闭症和 ADHD 等发育障碍。

  • 移动性、经济或基础设施的限制导致技术的获取减少。

除此之外,还有许多其他差异涵盖了人类存在的方方面面,因此我们应该随时准备根据我们的用户遇到的新需求和差异进行学习和适应。

我们致力于在服务器和浏览器上创建 Web 应用程序。作为 JavaScript 程序员,我们与为最终用户提供的界面非常接近。因此,我们必须对 Web 上的可访问性有很好的把握。这包括对 W3C 发布的《Web 内容可访问性指南》(WCAG 2.0)的了解,其中包括以下规定:

  • 为任何非文本内容提供文本替代方案(指南 1.1)

  • 从键盘上使所有功能可用(指南 2.1)

  • 使文本内容可读和可理解(指南 3.1)

可访问性不仅仅是关于非程序员最终用户。正如前面提到的,我们应该将其他程序员也视为我们的用户,就像 GUI 或其他 Web 界面的最终用户一样。重要的是我们要迎合其他程序员。一些程序员是盲人或部分视障。一些程序员有学习或认知困难。并非所有程序员都在最新和最快的硬件上工作。也并非所有程序员都理解你可能认为理所当然的所有事情。在我们编写的代码中考虑所有这些事情是很重要的。

现在完成了这一章,你可能会感到被原则、原则和指南的数量所压倒。事情可能看起来很复杂,但如果我们遵循一个简单的规则——始终关注用户,那么就不会复杂。还要记住,可能会在你的代码上工作的其他程序员也是你的用户。

作为程序员,我们处于一个位置,拥有前所未有的力量,可以帮助定义人们在执行各种任务时的行为。最初在 Twitter、Google 或 Microsoft 工作的程序员可能没有预料到他们的代码会运行多少次。他们可能最初无法想象他们的代码会影响多少人。我们应该始终对这种力量保持谦卑,并努力对我们服务的所有用户和他们试图执行的各种任务负责。如果你从本章中得到一件事,我希望就是:在你写的每一行代码中,都谦卑地考虑用户。

总结

在本章中,我们探讨了可靠性、效率、可维护性和可用性的重要原则。通过这些原则作为我们审视代码库的透镜,可以确保我们更有可能编写更干净的代码。在本章中学到的最重要的一点是,始终考虑代码中的人。用户可能是坐在 GUI 另一侧的人,也可能是使用我们的 API 的其他程序员。无论如何,始终意识到这个人是至关重要的。

在下一章中,我们将继续研究干净代码的基本特征,例如要注意的敌人,如模仿式编程和自我。

第三章:清洁代码的敌人

到目前为止,我们应该已经对我们所说的清洁代码有了一个相当清晰的认识。在上一章中,我们探讨了可靠性、效率、可维护性和可用性的原则。这些原则共同引导我们朝着更清洁的代码方向前进,但是如果我们不小心,仍然可能会遇到问题。在本章中,我们将探讨清洁代码的敌人:可能阻止我们编写可靠、高效、可维护或可用的代码的因素。

这些敌人都不应被视为的敌人;相反,它们应被视为清洁代码的煽动者。我们需要全面看待这些潜在有害因素,并在我们的代码库、团队和工作场所中留意它们。

具体来说,本章我们将涵盖以下敌人:

  • 敌人#1 - JavaScript

  • 敌人#2 - 管理

  • 敌人#3 - Self

  • 敌人#4 - 货物崇拜

敌人#1 - JavaScript

最糟糕的 JavaScript 特性也可以说是它最好的特性。它是一种非常普遍的语言,不得不以非常快的速度增长和适应。语言本身及其在浏览器中的位置促成了这种普及性。

JavaScript 是一种非常富有表现力和多样化的语言,从 Lisp 和 Scheme 中获得了功能上的灵感,从 Self 中获得了原型继承,并且具有类似于 Java 的 C 样式的语法。它是一种具有多种范式的语言。无论您想以经典的面向对象方式、原型方式还是完全功能方式进行编程,JavaScript 都可以胜任。JavaScript 的灵活性以及其在更广泛的 Web 堆栈中的位置也使其非常适合初学者。您可以立即开始使用它,并且这正是 Brendan Eich 最初的意图。它旨在让设计师和程序员都能轻松上手,为他们提供编写曾经是单一用途平台的浏览器脚本的能力。然而,曾经不起眼的浏览器现在已经发展成一个非常广泛和复杂的互补抽象集合。

JavaScript 本身的增长以及其在客户端和服务器端(以及其他领域)的广泛应用意味着该语言已经被推向和拉向了成千上万个不同的方向。大量的框架、库、分支语言(例如 CoffeeScript)、语言扩展(例如 JSX)、编译器、构建工具和其他抽象已经涌现并试图以新的独特方式利用 JavaScript。这些工具共同构成了 JavaScript 的景观,这是一个非常丰富和多样化的景观。有无数种方法来做同样的事情,因此我们几乎无法希望做任何事情都正确。这就是为什么我说 JavaScript 的普及性既是它自己的最大敌人,也是它自己的最大优势。

在本书中,我们将探讨基础概念,这些概念将教会我们对清洁代码的本质进行批判性思考,并允许我们在不总是很好地满足代码清洁度的语言和环境中编写清洁代码。如果使用得当,JavaScript 将以其高效性和表现力让您感到惊讶,并且经过时间和努力,它可以在可靠性和可维护性方面与任何其他语言相媲美。

敌人#2 - 管理

清洁代码与培养它的过程和原则一样重要。无论我们的代码在孤立环境中有多完美和美丽,它通常是作为项目的一部分编写的,与团队一起,并由可犯错误的人和可犯错误的流程管理。只有通过看到和理解这些缺陷,我们才能希望预防或避免它们。

如今,我们都在承担更具挑战性的工作。JavaScript 仅限于普通的宣传手册网站已经成为历史。Web 的创造者们被要求构建更加雄心勃勃的项目。随着技术抽象塔不断增长,这些项目的复杂性只会增加。因此,如果我们真的要写出干净的代码,我们必须广泛考虑这种复杂性。我们必须超越我们的代码库,考虑我们所在团队和组织的背景。

将管理视为敌人可能会暗示经理们本身有过错,但事实并非如此。我们将在本节中发现,是个人文化实践使得发布干净代码变得困难。其中包括发布压力、糟糕的度量标准和缺乏所有权。

发布压力

通常情况下,由于截止日期或其他管理规定的压力,发布代码的压力是软件世界中一个经常存在且不好的力量。对外部利益相关者或经理来说,截止日期是一件好事;它似乎提供了确定性和问责制,但对于项目中工作的人来说,它可能只会被视为强加的不受欢迎的妥协。有时,做出的第一个妥协就是代码质量的妥协。这并不是故意发生的,而是将完成优先于质量的自然结果。

在这种情况下,利益相关者是指依赖于您工作成果的任何个人或组织。通常的利益相关者包括项目经理、同一组织内的其他团队、外部客户和用户。

当有发布压力时,代码质量可能会慢慢下降。其中包括以下几点:

  • 文档:当开发人员赶时间时,他们将无法花足够的时间来确保他们的代码及其 API 被正确记录。现有的文档将逐渐荒废。

  • 架构:开发人员将开始专注于他们需要进行的最必要的更改,忽视代码的更大架构结构以及它们之间的相互关系。依赖关系将变得混乱,架构将随着时间的推移而分裂,最终形成混乱的代码。

  • 一致性:无论是在架构上还是在语法上,一致性都将开始受到影响。多个不同的开发人员,可能被隔离在一起,被迫以最快的方式构建东西。无意中,他们可能忽视了沟通和建立标准,导致一致性减少。

  • 测试:编写测试通常需要时间,调整测试以适应新需求也需要时间。现有的测试可能会被禁用或删除。新的测试不会被编写,因为根本没有时间。

  • 最佳实践:当时间紧张时,开发人员将开始在他们的代码中采取捷径,而不是花费必要的时间和精力来确保他们的软件适合其目的。他们会绕过最佳实践,而选择快速和拼凑在一起的解决方案。在 Web 上,这往往会导致 UI 的可访问性和可用性降低。

当截止日期开始逼近时,上述项目通常会首先被搁置。如果我们不小心,我们可能会遇到以下二阶效应:

  • Bugginess:在缺乏测试和文档的情况下,代码的架构基础受到威胁,不稳定和有缺陷的代码将开始成为常态。许多这些错误可能会在质量保证过程中被捕捉到,但还有许多其他错误会出现在用户面前。代码及其 API 和 UI 的脆弱性将增加,给用户带来更大的负担。

  • 不满意的用户:由于出现在用户面前的错误数量增加,软件的可用性降低,他们的生产力和幸福感也会降低。他们可能会开始避开或放弃该平台,寻找更高质量的替代品。

  • 疲惫的开发者:疲惫的开发者,不得不不断放弃他们最好的原则,会开始感到疲惫。他们可能会对继续在团队中工作感到沮丧。面临心理健康和一般满足感受到威胁,他们会开始离开。

所有这些影响如果持续时间足够长,就会汇聚在一起,导致项目失败。因此,解决这种鲁莽高速的根本压力是至关重要的。迅速交付代码的压力通常是由那些对软件项目长期退化缺乏深刻了解的力量所发起的。这种缺乏了解可能部分是因为他们与自己决策的长期影响隔离开来。他们可能会认为,一旦交付并得到利益相关者的批准,问题就解决了。但正如我们所知,快速交付的代码满足了即时需求,并不意味着它符合良好的质量水平。低质量的代码可能会产生许多负面的连锁效应,这些效应只有在实施后的几周或几个月后才会完全意识到。几个月后,利益相关者可能会发现自己对减速和质量下降感到恼火,却没有意识到最初施加压力的是他们导致了这一切。

解决这一混乱局面的关键妥协在于交付时间技术债务之间。技术债务会随着时间的推移而积累。它描述了需要解决以保持代码库健康和良好运行状态的赤字。这可能包括修复 bug、编写测试、重构旧模块,或者集成工具以提高代码质量。从根本上说,技术债务是所有工作,理想情况下应该是自然开发周期的一部分,但由于时间限制,被推迟到以后。还有其他因素决定了技术债务的增加,但时间是最重要的因素。不偿还我们的技术债务是确保代码衰退和项目最终失败的一种方法。

在项目管理方面,有无数的建议和流程可以利用。我不会在这里详细介绍它们,但我会分享一些启发式方法,以确保代码库的健康:

  • 不要在没有测试的情况下发布功能或修复 bug。没有测试,可能随时会发生回归。测试是一种防御技术,可以确保我们的代码持续正确。

  • 经常偿还技术债务。可能每周一次,或者每两周一次,尝试让每个人都处理技术债务,即任何被认为能增加代码健康的工作。

  • 定期与利益相关者沟通,表达与代码和项目健康相关的限制和成本。不要过度承诺交付,也不要低估问题。

作为开发者,我们并不总是能控制项目管理的方式。尽管如此,我们应该始终感到自如地提出关注并倡导促进代码整洁的流程。第十八章,沟通和倡导,详细介绍了我们如何做到这一点。

糟糕的指标

世界上似乎没有哪个行业能逃脱指标的束缚。对于衡量事物的狂热迷恋既是一种像邪教一样的迷恋,也是一种产生必要的反省和改变的真正需求。在软件工程领域,我们对这种需求并不陌生。作为程序员,我们对能够为我们提供对代码洞察的指标非常感兴趣:

  • 有多少 bug?

  • 这段代码运行需要多长时间?

  • 我的测试覆盖率有多高?

然而,经理和其他利益相关者通常会怀有自己的利益和指标。其中最臭名昭著的是试图衡量开发者产出或生产力的指标:

  • 有多少行代码或提交?

  • 我们发布了多少功能?

  • 我们写了多少行文档?

如果出于正确的原因提出这些问题,那么这些都是很好的问题。例如,代码行数可以作为一个有用的度量,如果我们将其用作讨论是否重构特定类/实用程序的复杂性的代理。但许多度量完全脱离了它们试图衡量的事物。

非技术经理或利益相关者可能会认为编写一定数量的代码应该总是需要相同的时间。当曾经一天写 200 行代码的开发人员最近花了 10 天才提交了 10 行代码时,他们可能会感到困惑。当然,他们的困惑表明他们对编程过程及其混乱复杂性的理解存在严重误解。但这些误解很普遍,所以我们需要对它们保持警惕。

解决糟糕度量的明确方法是推动并创建更好的度量。要创建好的度量,了解我们试图回答的基本问题是至关重要的,然后集思广益地想出回答这个问题的方法。让我们看一个例子:

问题 糟糕的度量 为什么糟糕 更好的度量或方法
我们是否在高效工作? 代码行数/提交 一个程序员可能需要很多天来解决一个只需要一行更改的关键错误。 询问开发人员并探索是什么拖慢了他们的工作效率;进行团队回顾,发现改进的领域。
我们是否为用户提供了价值? 已发布功能数量 用户可能会从更少但质量更高的功能中获得更多好处。 建立度量或 A/B 实验来判断哪些功能被使用和受欢迎。专注于每个功能的质量。
我们是否在编写有用的文档? 文档行数 开发人员可能最终只会记录他们熟悉的事物,而不是最需要记录的代码区域。 创建一个跟踪文档使用情况的指标。通过询问开发人员来确定哪些代码区域的文档不足。
我们是否有一个经过良好测试的代码库? 测试覆盖率 如果它只衡量某些代码行是否被调用,那么它可能会被一些非常广泛的集成测试所欺骗。 结合传统的测试覆盖率和其他度量。跟踪经常出现 bug 的回归区域。
我们的代码库是否有 bug? bug 数量 一个代码库可能在一个几乎没有使用的应用程序区域中有很多 bug。某些区域的 bug 可能没有被报告。 不要计算 bug 数量;而是专注于并衡量用户和开发人员的满意度。根据 bug 对用户的影响来优先处理 bug。

组织或团队内对糟糕的度量的执着可能导致优化了错误的事物。更关心写更多代码行数的开发人员可能对其代码的基本质量不太感兴趣。被迫发布更多功能的开发人员可能会妥协最佳实践和清晰的代码,优化速度和交付。

确保我们跟踪的任何度量都受到现实的制约,并且我们不仅仅根据这些度量来判断成功是非常重要的。特别是当你看到度量与我们的清晰代码原则相对立时要特别小心。随着时间的推移,如果一个度量被过于雄心勃勃地追求,它最终可能会破坏它试图衡量的事物。这是通过一种被称为古德哈特定律的效应来实现的:

“当一个度量成为目标时,它就不再是一个好的度量。”

  • Marilyn Strathern

缺乏所有权

所有权是健康代码库的关键原则,它依赖于个人对其代码健康状况的利益。这里的所有权并不意味着一段代码属于一个人,其他人不能在其上工作。相反,它意味着一段代码是由一个人或一组人培育的,其持续的健康和可靠性是一个关键的优先事项。

缺乏所有权可能会导致以下方式中的清洁代码的关键原则受损:

  • 可靠性:随着不知不觉地引入脆弱性的新变化,代码的正确性和稳定性可能会随着时间的推移而衰退。代码的持续稳定性没有得到监控或关注。

  • 效率:没有人直接测量或观察代码,基本假设是它只是有效的。随着时间的推移,其效率可能会下降。

  • 可维护性:许多非所有者进行迅速和轻率的更改可能导致非连贯的架构,从而使长期维护变得更加困难。

  • 可用性:没有人会考虑或监控代码的文档和一般可用性,导致其衰退,最终导致软件变得复杂和使用起来繁琐。

正确应用的所有权可以从根本上改变前述原则的衰退:

  • 可靠性:代码的正确性和持续稳定性将得到关注和监控

  • 效率:代码将被持续地测量和评估效率

  • 可维护性:代码将保持其架构和语法的独特视角

  • 可用性:文档将不断更新,代码的可用性将是一个持续关注的问题

从根本上讲,所有权是关于个人或团队对代码的持续关注。为了实现这一点,需要一定程度的自我或自豪感。个人或团队必须对代码的持续健康有一定的利益。通常是组织或管理文化导致了健康或不健康的所有权水平,因此,再次,正确沟通和倡导过程和动态对我们程序员来说是至关重要的,这将使我们能够确保我们的代码的整洁和健康。

缺乏所有权也会导致更严重和意想不到的后果。由于对工作缺乏自豪感和监护责任感,程序员更容易出现疲劳,因为他们无法实现对工作的自豪感和自我价值感。由于没有所有权,团队成员可能无法在任何一个领域培养高水平的理解,这意味着团队或组织的整体知识会受到影响,每个人只能以一种非常肤浅或粗略的方式理解代码库。

小心所有权中的自我过多!自我是一种脆弱的特质。总是存在“过度所有权”的风险,这可能导致顽固和防御性文化,使“内部人”不允许“外部人”进行更改,并且强烈的以自我为中心的观点泛滥。要小心。记住可用性和可维护性的关键原则。这将引导您对那些希望使用您的代码或对其进行更改的人表现出善良和开放的态度。

敌人#3 - 自我

程序员作为创作者,永远在向世界展示他们对事物应该是什么样的版本,因此几乎不可能不时地对我们的工作感到自豪。如果不加以控制,这很容易演变成我们编写代码来给人留下深刻印象,提升自己的优越感,而不考虑我们正在编写的代码是否可维护或可用。但是,如果我们的自然自我不能得到发展,那么我们就不会对自己的工作感到自豪,也不会倾向于在我们所做的事情上培养卓越。因此,在编程中,就像生活的其他领域一样,关键是保持自我平衡,保留其好的部分,而不让其坏的部分影响太多。

在这种情况下,“自我”是指我们的自我;我们如何认同自己以及我们如何在世界上表达自己。所有程序员都有自我,它对他们编写的代码产生了许多影响。

炫耀语法

作为一个年轻的程序员,我经常发现我的自我占了上风。我不敢说这是一个普遍的真理。这只是我的经验。每当我发现一个新的 JavaScript 特异功能时,我会尝试在我的下一段代码中加以利用。

其中一个例子是使用位运算符来实现向下取整的效果。传统上,要对数字进行向下取整,即将数字四舍五入到最接近的整数,你会使用语言提供的原生方法:

Math.floor(65.7); // => 65

然而,当时,我更喜欢使用位运算符来实现相同的结果:

~~65.7; // => 65
0|65.7; // => 65

这里发生了什么?位运算符(包括~&|等)用于改变操作数的位,但作为副作用,它们首先会将它们的操作数转换为 32 位整数。这意味着它们会丢弃小数部分。为了利用这种隐式转换为整数而不改变整数值,我们可以执行双重位反转,例如使用双波浪号(~~)。这实质上是反转操作数的所有位,然后再次反转。我们也可以执行与零的位或运算(0|...),这将始终返回非零操作数的位,从而通过利用副作用(整数转换)而不改变基础值来产生相同的效果。

至关重要的是要注意,这种副作用在负数的情况下并不与Math.floor的向下取整行为功能匹配。请注意以下两个表达式的区别:

Math.floor(-25.6); // => 26
~~(-25.6);         // => 25

这些神秘技术的吸引力很容易理解。它们的使用似乎表明了对语言的高水平理解,这非常吸引人的自我。这类似于使用不必要的长或复杂的词来表达简单的想法:说起来很有趣,但对听众来说很难理解。

这样的技术通常会导致代码的可维护性降低。我们的代码的维护者不应该被期望理解很少使用的运算符的内部工作原理,并且应该能够相信我们不会轻率地利用语言内部的副作用来实现可以通过更熟悉和明显的方法清晰地实现的结果。

复杂或罕见的语法通常是自我代码的载体。另一个例子是错误使用逻辑运算符来指定控制流:

function showNotification(message) {
  hasUserEnabledNotifications() && (
    new Notification(message).show() &&
    logNotificationShown(message)
  );
}

前面的代码可以更常规、更清晰地表达为一个IF语句:

function showNotification(message) {
  if (hasUserEnabledNotifications()) {
    new Notification(message).show();
    logNotificationShown(message);
  }
}

这样更清晰,更熟悉,更易读,适合更多的人群。

有人认为我们应该能够自由地利用整个语言的全部功能,利用其所有的特异功能和副作用来编写更简洁、更高效的代码。如果我们的唯一目标是编写能够工作的代码,这是一个很好的态度。但编写干净的代码是关于采取审慎的方法,使用能够提供更多可读性的技术,并避免那些相反的技术。

还要记住,从根本上说,代码是关于传达意图的。沟通既关乎听众也关乎说话者。自我代码往往在这方面表现不佳;它将你的代码熟悉度限制在少数精通相同知识的精英之中。这并非理想。我们应该始终考虑到将不得不阅读、使用和维护我们代码的人们的多样知识和能力。这种关注应该优先于我们的自我。

固执的观点

代码很少是孤立编写的;我们经常与他人合作将项目变为现实。因此,清晰的代码取决于你的方法和整个团队的方法。持续拥有代码库的团队不断决定他们将用来实现目标的工具、约定和抽象。因此,团队成员必须能够良好沟通并分享观点,将这些观点塑造成明确的结果。有时,妥协是必要的。而妥协往往会伤及自尊。

JavaScript 及其工具容易受到强烈意见的影响。随着时间的推移,我们每个人都会在不同的方法中获得经验,并且通常通过辛勤劳动和痛苦,最终形成一套我们认为最好的方法的信念。然而,这些信念可能并不总是与我们的同事相匹配。当存在分歧时,解决的路径是不清晰的。没有解决,团队和代码库可能会分裂,造成更多的损害。

想象一下亚当和苏珊之间的以下情景:

亚当:我们应该使用 Foo 测试框架;它更可靠,而且更好。

苏珊:不,我们一定要使用 Baz;它更优秀,而且有着成熟的记录。

这种分歧可能有很多不同的解决方法。例如,我们可以建议两个人都提出自己的观点,并继续辩论各种测试框架的优点。这可能会解决问题。但同样,也可能不会。争论可能会持续下去,造成两个人之间的裂痕,并使代码库处于一种没有明确选择测试框架的状态。在这种情况下,解决的路径并不总是清晰的,但清楚的是,如果牵涉到不妥协的自尊心,解决的可能性就会降低。如果亚当和苏珊都能开始看到彼此的观点,拓宽自己的视野,摆脱自己的观点,那么解决的路径就会变得更清晰。

冒名顶替综合症

自尊心作为一种脆弱的特质,也影响着我们对自己能力和观点的信仰。毫无疑问,对自己的信仰是编程中创造和解决问题的关键。尤其在技术行业,冒名顶替综合症似乎是一种普遍现象。冒名顶替综合症的特征是一种感觉,即自己是一个冒名顶替者——你在某种程度上不适合或不够胜任你所担任的角色,而你觉得周围的其他人要能力更强。

可以说,软件行业中冒名顶替综合症的普遍存在是由于固有的复杂性和专业知识的丰富性。我们最多只能希望在相对狭窄的领域拥有高水平的熟练程度,但永远不会在所有领域都有专业知识。在日常工作中,我们时刻意识到自己不知道的所有事情,这可以理解地造成一种焦虑和对自己谦卑能力的不信任。这种感觉有时会导致压力、疏远和对自己能力的不信任。

这可能会产生以下负面结果:

  • 缺乏果断: 对自己能力的信念不足可能导致在决定代码架构时信心水平较低;不知道该选择哪条路线往往意味着采取默认路线,这特别容易形成迷信。

  • 缺乏大胆: 缺乏果断可能导致更少的冒险和更少的大胆决策,但有时需要做出这样的决定来推动项目或代码库的进展。例如,选择更可靠的 UI 或测试框架可能是一个巨大而大胆的风险,考虑到重构的成本,但可以导致代码健康的整体改善。

  • 缺乏沟通:对自己的观点和技能缺乏信心可能导致较少重要的沟通发生,例如程序员与项目利益相关者之间的沟通。这里的沟通并不意味着外向或健谈,而是识别关键问题并对其有足够的信心以提倡变革。

编程是一种传达意图的行为,也就是说,以某种方式向世界表达我们认为事物应该运作的方式。这本身就是一种大胆的行动和一种我们不应该视为理所当然的技能。如果你正在阅读这篇文章,并担心自己可能缺乏特定的特质或能力,我提供以下建议:地球上没有人是完全有能力的。每个人都有自己的优点和缺点。正是每个人的多样性和他们不同的能力将决定项目和代码库的成功。即使你感到自己是个骗子,也要承认这种感觉是自然的,而且尽管如此,你所能提供的远远超出你的想象。

敌人#4 - 模仿行为

在 20 世纪初,人们观察到一些美拉尼西亚文化会进行模仿西方技术和行为的仪式,比如用木头和黏土建造跑道和控制塔。他们这样做是希望物质财富,比如食物,会被送到他们那里。这些奇怪的仪式出现是因为他们之前观察到货物是通过西方飞机送来的,错误地得出结论认为是跑道本身召唤了货物。

现在,在编程中,我们使用术语“模仿行为”或“模仿”来广泛描述复制模式和行为,而不完全理解它们真正的目的和功能。当程序员在网上搜索解决方案,并复制并粘贴他们找到的第一段代码,而不考虑其可靠性或安全性时,他们正在进行模仿行为,试图通过使用在其他上下文中似乎负责这个任务的代码来完成某个任务。

模仿行为通常包括以下过程:

  1. 人处于一个略微陌生的技术环境中

    • 人看到他们希望模仿的效果
    • 人复制似乎产生所需效果的代码

这种行为在组织和技术上都可能发生。程序员有时被要求将他们很少了解的不同技术依赖关系联系在一起,通常会别无选择,只能进行模仿。而组织通常没有时间考虑所有的基本原则,往往最终会从其他组织中模仿流行的行为和流程。

- 模仿代码

为了说明模仿行为,让我们想象一个程序员的任务是向他们的 Node.js 服务器添加一个新的 HTTP GET 路由。他们需要添加/about_us路由。他们打开routes.js文件,在其中的众多行中找到以下代码:

app.use('/admin', (req, res, next) => {
  const admin = await attemptLoadAdminSection(req, res); 
  if (admin) {
    next();
  } else {
    res.status(403).end('You are not authorized');
  }
});

这段代码碰巧使用了一个 Node.js 框架:Express。不幸的是,程序员对 Express API 并不很熟悉。他们看到前面的代码,并试图为自己的目的模仿它:

app.use('/about_us', (req, res, next) => {
  attemptLoadAboutSection(req, res);
  next();
});

不幸的是,正如你可能已经注意到的,这位程序员已经犯了模仿的行为。他们复制了用于将流量引导到管理员部分的代码,并假设他们应该使用类似的代码来将流量引导到关于页面。

他们在这样做时错过了一些事情:

  • 管理员路由实际上是中间件,用于阻止未经授权的用户访问/admin

  • app.use()方法应该只用于中间件,而不是用于直接的 GET 路由。

  • 调用next()只有中间件才会感兴趣

如果程序员花时间阅读 Express 文档,他们会发现正确的方法更接近以下内容:

app.get('/about_us', (req, res) => {
  loadAboutSection(res);
});

这只是一个非常简短的例子。货物崇拜的行为通常更加复杂。它可能不涉及直接复制代码,而可能只涉及模式或语法的微妙复制。我们可能会对前面的例子摇头,确信自己永远不会做这样的事情,但我们很可能已经以不那么明显的方式做了。

参与项目的程序员通常会合理地继承现有代码库的命名、语法和空白符约定。他们可能会在不经意间这样做,自然地反映和符合现有范例,而不是在每一步都应用他们的批判性技能。这并不一定是负面的:这是对约定和表现一致性的明智维护。这些都是重要的品质。但同样地,盲目地复制这些东西往往会导致冗余代码的无谓增加,或者更糟糕的是,由于对代码的误解而产生负面影响。

想象一下,你是一名初学者程序员,你想要在以下略微奇怪的对象中添加一个hobby字段:

const person = {
  "name": ("James"),
  "location": ("London")
};

很容易想象,当您添加新字段时,您可能倾向于复制现有的语法:

const person = {
  "name": ("James"),
  "location": ("London"),
  "hobby": ("kayaking")
};

这对于第一次尝试者来说是完全合理的事情。他们处于一个陌生的环境中,看到了他们希望模仿的效果,于是采用了产生这种效果的模式。即使是有经验的人也可以理解这种行为,他们希望对代码进行最小必要的改动,而不影响其周围环境。

这段代码并没有明显的错误。它是可用的。然而,如果我们要编写最大程度上可维护和高效的代码,那么我们应该采用更广泛接受和常规的约定和语法。因此,在这种情况下,前述代码存在两个具体问题:

  • 将每个键名都用双引号括起来(不必要!)

  • 将每个值都用括号括起来(不必要!)

没有进行货物崇拜的文件版本可能如下所示:

const person = {
  name: "James",
  location: "London",
  hobby: "kayaking"
};

然而,这个文件和对象可能会继续存在数月甚至数年。没有人会质疑或挑战它的语法,因为他们会认为它一定有它的原因。遵循已建立的做事方式会带来舒适和便利。挑战它通常更容易。这种形式的货物崇拜是更隐匿的类型,它给项目和团队引入了很多惯性。我们盲目地采用做法,而不质疑它们的持续有效性和适用性。

模仿工具和库

就像代码可以被盲目地复制一样,工具也可以。作为 JavaScript 程序员,我们接触到一个快速变化的工具和库的景观。每个月都会发布一个新的实用程序或工具。围绕一些工具产生的兴奋和夸大其词为货物崇拜的爆发创造了肥沃的土壤。程序员可能开始使用这些新工具,相信它们的价值,而没有充分了解它们或正确考虑它们是否适合手头的项目。工具可能被公司或经理指定,非程序员和程序员可能会根据工具的流行度或新颖性发表意见,而不考虑它实际上是如何工作的,或者它与当前方法有何不同。

货物崇拜中的“崇拜”往往是一种非常有说服力的力量,告诉我们,如果我们只是使用这种方法或工具,所有问题都将得到解决。自然地,这很少发生。我们可能最终只是用新问题交换了我们当前的问题。因此,在决定使用工具时,无论是框架、库还是任何第三方抽象或服务,我们都应该始终采用深思熟虑的方法,问自己以下关键问题:

  • 适用性:它是否是解决手头问题的最合适的工具?

  • 可靠性:它是否可靠地工作,而且将继续如此?

  • 可用性:它是否简单易用并且有良好的文档?

  • 兼容性:它是否与现有的代码库很好地集成?

  • 适应性:它是否适应我们不断变化的需求?

为了避免装运崇拜,我们应该尽量避免轶事和道听途说,而更倾向于详细的比较分析,通过比较和对比各种可能性来找到最合适的方案。

总结

在本章中,我们对一些最普遍的对清晰代码的“敌人”有了一定的了解。我们讨论了 JavaScript 本身是一种语言,当被错误使用时,会导致不清晰的代码。我们还探讨了团队和个人的陷阱。我们了解到,清晰的代码不仅仅是代码的特征,而是一种必须在整个组织和我们自己的思想中培养的文化。

在下一章中,我们将探讨一些众所周知和一些不太为人知的清晰代码原则,并将我们迄今所学的内容整合到一些具体的 JavaScript 抽象中。

第四章:SOLID 和其他原则

软件世界充斥着原则和首字母缩略词。关于我们应该如何编写代码有许多坚定和根深蒂固的想法。所有这些原则的数量之多可能令人不知所措,特别难以知道在设计抽象时应该选择哪条道路。JavaScript 能够适应许多不同的范式是它作为一种编程语言的优势之一,但这也可能使我们的工作更加困难。JavaScript 程序员需要实现自己的范式。

在希望使事情变得不那么复杂的这一章中,我们将介绍各种众所周知的原则,并将它们分解,以便我们可以看到它们的基本意图。我们将探讨这些原则如何与我们已经讨论过的清晰代码的原则相关联,使我们能够做出自己的明智决定,以便在追求清晰代码时使用什么方法。

我们将涵盖面向对象和函数式编程原则。通过探索这些原则范围,我们将能够为自己制定一张指导思想的地图,这将使我们能够批判性地思考如何在我们所从事的任何范式中编写清晰的代码。

在本章中,我们将涵盖以下主题:

  • 迪米特法则LoD

  • SOLID

  • 抽象原则

  • 函数式编程原则

迪米特法则

在我们深入探讨 SOLID 领域之前,探索一个不太知名的原则是很有用的,即所谓的迪米特法则,或者最少知识原则。这个所谓的法则有三个核心思想:

  • 一个单元应该只对其他单元有有限的了解

  • 一个单元只应该和它的直接朋友交谈

  • 一个单元不应该和陌生人交谈

你可能会想知道一个单元与陌生人交谈是什么意思。在这种情况下,一个单元是一个特定的编码抽象:可能是一个函数、一个模块或一个类。这里的交谈意味着接口,比如调用另一个模块的代码或让另一个模块调用你的代码。

这是一个非常有用且简单的法则,可以应用于我们所有的编程,无论是编写一行代码还是设计整个架构。然而,它经常被遗忘或忽视。

让我们以在商店购物的简单行为为例。我们可以用顾客店主的抽象来表达这种互动:

class Customer {}
class Shopkeeper {}

我们还可以说顾客类有一个钱包,他们在里面存放他们的钱:

class Customer {
  constructor() {
    this.wallet = new CustomerWallet();
  }
}

class CustomerWallet {
  constructor() {
    this.amount = 0;
  }
  addMoney(deposit) {
    this.amount += deposit;
  }
  takeMoney(debit) {
    this.amount -= debit;
  }
}

店主顾客之间的简化交互版本可能是这样的:

class Shopkeeper {
  processPurchase(product, customer) {
    const price = product.price();
    customer.wallet.takeMoney(price);
    // ...
  }
}

这看起来可能没问题,但让我们考虑一下这种互动的现实生活类比。店主从顾客口袋里拿走钱包,然后打开钱包,拿走所需的金额,而不以任何方式直接与顾客互动。

很明显,这在现实生活中永远不会是一种社交上合适的互动,但至关重要的是,店主正在做出超出他们权限范围之外的假设。顾客可能希望使用不同的支付方式,或者甚至可能没有钱包。顾客的支付方式是他们自己的事情。这就是我们所说的只和朋友交谈:你只应该与你应该了解的抽象进行接口。这里的店主不应该(也不会)了解顾客的钱包,因此不应该与之交谈

接受这些教训,我们可以按照以下方式编写一个更清晰的抽象:

class Shopkeeper {
  processPurchase(product, customer) {
    const price = product.price();
    customer.requestPayment(price);
    // ...
  }
}

现在看起来更合理了。店主直接与顾客交谈。顾客反过来将他们的顾客钱包实例交谈,取回所需的金额,然后交给店主。

我们很可能都写过一些违反了迪米特法则的代码。当然,我们编写的代码并不总是像商店老板和顾客之间的互动那样刻意或整洁,但迪米特法则仍然适用。我们可以通过一个典型的 JavaScript 代码来进一步说明这一点,该代码负责通过文档对象模型(DOM)向用户显示消息:

function displayHappyBirthday(name) {
 const container = document.createElement('div');
 container.className = 'message birthday-message';
 container.appendChild(
   document.createTextNode(`Happy Birthday ${name}!`)
 );
 document.body.appendChild(container);
}

这是典型和惯用的前端 JavaScript。要在文档中显示生日消息,我们首先自己构建字符串,并将其放入文本节点,然后将其附加到具有messagebirthday-message类的<div>元素中。然后,我们将这个 DOM 树附加到文档中,以便用户查看。

DOM 是一组 API,使我们能够与解析的 HTML 文档进行交互,通常在浏览器中。DOM 作为一个术语,也用来描述由此解析过程生成的节点树。因此,DOM 树可以从给定的 HTML 文档中派生,但我们也可以构建自己的 DOM 树并自由地操纵它们。

前面的代码是否遵守了迪米特法则?我们的抽象,displayHappyBirthday函数,关注的是生日快乐消息的概念,并且直接与 DOM 进行交互。然而,DOM 并不是它的朋友。DOM 是一个实现细节——一个陌生人——在生日快乐消息的概念中。生日快乐消息的机制不应该需要了解 DOM。因此,适当的做法是构建另一个抽象来连接这两个陌生人:

function displayMessage(message, className) {
  const container = document.createElement('div');
  container.className = `message ${className}`;
  container.appendChild(
    document.createTextNode(message)
  );
  document.body.appendChild(container);
}

在这里,我们有一个更通用的displayMessage函数,直接与 DOM 进行交互。我们的displayHappyBirthday函数可以被改变,使其纯粹与这个displayMessage抽象进行交互:

function displayHappyBirthday(name) {
  return displayMessage(
    `Happy Birthday ${name}!`,
    'birthday-message'
  );
}

现在可以说这段代码与displayMessage的实现更松散地耦合在一起。我们以后可以决定改变我们用来显示消息的确切机制,而不需要改变displayHappyBirthday函数。因此,我们增强了代码的可维护性。通过概括一个常见的功能——显示消息,我们还可以使未来的功能更加无缝——例如,显示新年快乐的消息:

function displayHappyNewYear(name) {
  return displayMessage(
    `Happy New Year! ${name}`,
    'happy-new-year-message'
  );
}

迪米特法则,本质上关注的是我们认为应该与其他抽象进行接口的抽象。它并不提供关于朋友陌生人是什么,或者一个单元只能有有限的其他单元知识的指导。这个法则挑战我们为自己定义这些术语,以及我们正在构建的抽象。我们有责任停下来考虑我们的抽象是如何进行接口的,也许我们应该以不同的方式设计它们。

我选择首先写关于这个原则,因为我觉得它是最值得记住和最普遍有用的编写干净代码和干净抽象的工具。

接下来,我们将讨论 SOLID 和其他原则,它们各自以不同的方式补充了迪米特法则。

SOLID

SOLID 是一组常用的原则,用于构建单个模块或更大的架构。具体来说,它是一个缩写,代表五个特定的面向对象编程设计原则:

  • 单一职责原则(SRP)

  • 开闭原则

  • 里氏替换原则

  • 接口隔离原则

  • 依赖倒置原则

不必记住这些名字甚至是缩写本身,但这些原则背后的思想是有用的。在本节中,我们将探讨每个原则以及 JavaScript 示例。需要注意的是,虽然 SOLID 主要与面向对象编程有关,但其背后有更深层次的真理,无论你的编程范式如何,都是有用的。

单一职责原则

当我们编写代码时,我们不断地构建抽象;在这样做时,我们对构建正确的抽象、以正确的方式划分感兴趣。SRP 通过查看它们的责任来帮助我们弄清楚如何划分这些抽象。

在这种情况下,责任指的是您的抽象所涵盖的目的和关注领域。验证电话号码的函数可以说具有单一责任。然而,同时验证和规范这些带有国家代码的号码的函数可以说具有两个责任。SRP 会告诉我们需要将该抽象拆分为两个独立的抽象。

SRP 的目标是得到高度内聚的代码。内聚性是指抽象的各个部分在某种方式上都功能统一,它们可以说都共同工作以实现抽象的目的。关于识别单一责任的一个有用问题是:您的抽象设计有多少个原因需要更改

我们可以通过一个例子来探讨这个问题。假设我们的任务是构建一个小型日历应用程序。最初,我们可能想象这里有两个不同的抽象:

class Calendar {}
class Event {}

“事件”类可以说包含有关事件的时间和元信息,“日历”类可以说包含事件。基本的起始前提是您可以向“日历”实例添加和删除一个或多个“事件”实例。在这里,我们表达了用于向“日历”添加和删除事件的方法:

class Calendar {
  addEvent(event) {...}
  removeEvent(event) {...}
}

随着时间的推移,我们必须向我们的“日历”添加各种其他功能,例如检索特定日期内的事件的方法,以及以各种格式导出事件的方法:

class Calendar {

  addEvent(event) {...}
  removeEvent(event) {...}
  getEventsBetween(stateDate, endDate) {...}

  setTimeOfEvent(event, startTime, endTime) {...}
  setTitleOfEvent(event, title) {...}

  exportFilteredEventsToXML(filter) {...}
  exportFilteredEventsToJSON(filter) {...}

}

即使没有实现,您也可以看到所有这些方法的添加已经创建了一个更加复杂的类。从技术上讲,所有这些方法都与日历的功能相关,因此有理由让它们保留在一个抽象中,但是如果我们回到我们提出的问题——*我们的抽象设计有多少个原因需要更改?*我们可以看到“日历”类现在有很多可能的原因:

  • 事件上定义的时间可能需要更改

  • 事件上定义的标题的方式可能需要更改

  • 搜索事件的方式可能需要更改

  • XML 模式可能需要更改

  • JSON 模式可能需要更改

鉴于潜在变更的不同原因的数量,将变更分割为更合适的抽象是有意义的。例如,特定事件的时间和标题设置方法(setTimeOfEventsetTitleOfEvent)绝对应该在“事件”类本身内部,因为它们与“事件”类的目的高度相关:包含有关特定事件的详细信息。导出到 JSON 和 XML 的方法也应该移动,可能移到一个专门负责导出逻辑的类中。以下代码显示了我们所做的更改:

class Event {
  setTime(startTime, endTime) {...}
  setTitle(title) {...}
}

class Calendar {
  addEvent(event) {...}
  removeEvent(event) {...}
  getEventsBetween(stateDate, endDate) {...}
}

class CalendarExporter {
  exportFilteredEventsToXML(filter) {...}
  exportFilteredEventsToJSON(filter) {...}
}

希望你能看到,我们每个抽象都内部紧凑,并且每个抽象都比如果所有功能都仅驻留在“日历”类中要更紧凑地封装其责任。

SRP 不仅仅是关于创建易于使用和维护的抽象,它还允许我们编写更专注于其主要目的的代码。以这种方式更加专注使我们更清晰地优化和测试我们的代码单元,这有利于我们代码库的可靠性和效率。由 SRP 指导的内聚抽象的正确划分可能是您改进代码清晰度的最重要方式之一。

开闭原则

开闭原则(OCP)陈述如下:

*软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭

-梅耶,伯特兰(1988 年)

在构建抽象时,我们应该使它们能够开放以便其他开发人员可以构建其行为,使抽象适应他们的需求。 在这种情况下,扩展最好被认为是一个广义术语,包括所有类型的适应。 如果一个模块或函数的行为不符合我们的要求,最好能够使其适应我们的需求,而无需修改它或创建我们自己的替代品。

考虑我们的日历应用程序中的Event类和renderNotification方法:

class Event {

  renderNotification() {
    return `
      You have an event occurring in
      ${this.calcMinutesUntil()} minutes!
    `;
  }

  // ...

}

我们可能希望有一种单独的事件类型,它会渲染一个以“紧急!”开头的通知,以确保用户更加关注它。 实现这种适应的最简单方法是通过继承Event类:

class ImportantEvent extends Event {
  renderNotification() {
    return `Urgent! ${super.renderNotification()}`;
  }
}

我们通过覆盖renderNotification方法来给我们紧急消息加上前缀,并调用超类的renderNotification来填充通知字符串的其余部分。 在这里,通过继承,我们已经实现了扩展,使Event类适应我们的需求。

继承只是实现扩展的一种方式。 我们可以采取各种其他方法。 一种可能性是,在最初实现Event时,我们预见到需要自定义通知字符串,并实现了一种配置renderCustomNotifcation函数的方法:

class Event {

  renderNotification() {
    const defaultNotification = `
      You have an event occurring in
      ${this.calcMinutesUntil()} minutes!
    `;
    return (
      this.config.renderCustomNotification
        ? this.config.renderCustomNotification(defaultNotification)
        : defaultNotification
    );
  }

  // ...

}

这段代码假设有一个config对象可用。 我们可以选择调用renderCustomNotification并传递默认通知字符串。 如果没有配置,则仍然使用默认通知字符串。 这与继承方法有根本的不同,因为Event类本身规定了可能的扩展。

通过配置提供适应性意味着用户无需担心在扩展类时所需的内部实现知识。 适应的路径变得简化:

new Event({
  title: 'Doctor Appointment',
  config: {
    renderCustomNotification: defaultNotification => {
      return `Urgent! ${defaultNotifcation}`;
    }
  }
});

这种方法要求您的实现能够预见其最可能的适应性,并且这些适应性可预测地内部化到抽象本身。 但是,不可能预见所有需求,即使我们试图这样做,我们可能最终会创建一个如此复杂和庞大的配置,以至于用户和维护人员会受到影响。 因此,在这里需要取得平衡:适应性是一件好事,但我们也必须平衡它与一个有限目的的专注和连贯的抽象。

里斯科夫替换原则

里斯科夫替换原则规定类型应能够被其子类型替换,而不会改变程序的可靠性。 这在表面上是一个晦涩的原则,因此值得用现实世界的类比来解释它。

许多现实世界的技术创新都具有替代的特点。 沃尔沃 XC90 是一种汽车,福特福克斯也是。 两者都提供了我们期望的汽车的常见接口。 对于我们作为这些车辆的人类用户,我们可以假设它们各自的操作方式都继承自一种常见的车辆操作模式,比如有方向盘,门,刹车踏板等。

人类的假设是,这两种车型是超类型 car的子类型,因此作为人类,我可以依赖于它们各自从超类型(汽车)继承的方面。 另一种表达里斯科夫替换原则的方式是:类型的使用者只应关注操作它所需的最不具体的类型。 举个例子,如果我们要在代码中编写一个Driver抽象,我们希望它通常与所有Car抽象进行接口,而不是编写依赖于特定车型(如沃尔沃 XC90)的特定代码。

为了使里氏替换原则更具体一些,让我们回到我们的Calendar应用程序示例中。在前一节关于开闭原则的部分,我们探讨了如何通过继承将Event类扩展为新的ImportantEvent类:

class ImportantEvent extends Event {
  renderNotification() {
    return `Urgent! ${super.renderNotification()}`;
  }
}

我们这样做的假设是,我们的Calendar类及其实现不会关心事件是Events还是Events的子类。我们期望它会对待它们一样。例如,Calendar类可能有一个notifyUpcomingEvents方法,它会遍历所有即将发生的事件,并在每个事件上调用renderNotification

class Calendar {

  getEventsWithinMinutes(minutes) {
    return this.events.filter(event => {
      return event.startsWithinMinutes(minutes);
    });
  }

  notifiyUpcomingEvents() {
    this.getEventsWithinMinutes(10).forEach(event => {
      this.sendNotification(
        event.renderNotification()
      );
    });
  }

  // ...
}

关键的是,Calendar的实现并不考虑它正在处理的Event实例的类型。事实上,前面的代码(并未涵盖整个实现)只规定了事件对象必须具有startsWithinMinutes方法和renderNotification方法。

与里氏替换原则相关的是我们已经讨论过的一个概念:最少知识原则(LoD),它驱使我们问:这个抽象需要多少信息才能实现其目的?对于Calendar来说,它只需要规定事件具有它将使用的方法和属性。没有理由让它做出更多的考虑。Calendar的实现现在不仅可以处理事件的子类,还可以处理任何提供所需属性和方法的对象。

接口隔离原则

接口隔离原则关注的是保持接口高度内聚,只从事一个任务或一组高度相关的任务。它规定不应该强迫客户端依赖它们不使用的方法

这个原则在精神上类似于单一责任原则:它的目标是确保您创建专注且高度内聚的抽象,只关注一个责任领域。但它不是让您考虑责任本身的概念,而是让您看待您正在创建的接口,并考虑它们是否适当地隔离。

考虑一个地方政府办公室。他们有一张纸质表格(让我们称之为 1A 表),用于更改个人信息。这是一张存在了 50 多年的表格。通过这张表格,当地公民可以更改他们的一些信息,包括但不限于以下内容:

  • 姓名变更

  • 婚姻状况变更

  • 地址变更

  • 地方税折扣状态变更(学生/老年人)

  • 残疾状态变更

正如你可以想象的那样,这是一张非常复杂和密集的表格,有许多独立的部分,公民必须确保填写正确。我们都可能接触过政府文书工作的官僚复杂性,如下所示:

1A 表提供了一组接口,用于地方政府办公室提供的各种更改功能。接口隔离原则要求我们考虑这张表格是否强迫其客户端依赖他们不使用的方法。在这个上下文中,客户端是表格的用户,也就是公民,而方法是表格提供的所有可用功能:注册姓名更改、地址更改等等。

显而易见,1A 表单并没有很好地遵循接口隔离原则。如果我们重新设计它,我们可能会将其服务的各个功能分离成独立的表单。我们可以通过使用我们在本章开头学到的东西来做到这一点:最少信息原则(LoD),它向我们提出一个非常简单的问题:每个抽象(例如,更改地址)需要的最少信息是什么?然后我们可以选择只在每个表单中包含完成其功能所需的内容:

将纸质表单中必要的字段分离出来,可能看起来很明显,但程序员在其编码抽象中经常忽视有效地执行这一点。接口隔离原则提醒我们正确地将抽象分离成独立和内部一致的接口的重要性。这样做有以下好处:

  • 增强可靠性:拥有真正解耦的正确隔离接口使得代码更易于测试和验证,从而有助于其长期的可靠性和稳定性。

  • 增强可维护性:拥有分离的接口意味着对一个接口的更改不需要影响其他接口。正如我们在 1A 表单的布局中所看到的,位置和可用空间严重依赖于表单的每个部分。然而,一旦我们解耦了这些依赖关系,我们就可以自由地维护和更改每一个部分,而不用担心其他部分。

  • 增强可用性:拥有根据目的和功能分离的接口意味着用户能够以更少的时间和认知努力理解和浏览接口。用户是我们接口的消费者,因此最依赖接口清晰地划分。

依赖倒置原则

依赖倒置原则陈述如下:

  • 高层模块不应该依赖于低层模块。两者都应该依赖于抽象(即接口)

  • 抽象不应该依赖于细节。细节(如具体实现)应该依赖于抽象

第一点可能会让你想起 LoD。它在很大程度上谈论了相同的概念:高级与低级的分离。

我们的抽象应该被分离(解耦),以便我们可以在以后轻松更改低级实现细节,而无需重构所有代码。依赖倒置原则在其第二点中建议我们通过中间抽象来实现这一点,通过这些中间抽象,高级模块可以与低级细节进行接口。这些中间抽象有时被称为适配器,因为它们适应了低级抽象,以便高级抽象可以使用。

为什么叫做依赖倒置?高级模块最初可能依赖于低级模块。在提供面向对象编程概念(如抽象类和接口)的语言中,比如 Java,可以说低级模块最终可能依赖于接口,因为接口提供了低级模块实现的脚手架。高级模块也依赖于这个接口,以便可以利用低级模块。我们可以看到这里依赖关系是倒置的,以便高级和低级模块都依赖于同一个接口。

再次考虑我们的“日历”应用程序,假设我们想要实现一种方法来检索固定位置周围发生的事件。我们可以选择实现一个类似这样的方法:

class Calendar {

  getEventsAtLocation(targetLocation, kilometerRadius) {

    const geocoder = new GeoCoder();
    const distanceCalc = new DistanceCalculator();

    return this.events.filter(event => {

      const eventLocation = event.location.address
        ? geocoder.geocode(event.location.address)
        : event.location.coords;

      return distanceCalc.haversineFormulaDistance(
        eventLocation,
        targetLocation
      ) <= kilometerRadius / 1000;

    });

  }

  // ... 

}

getEventsAtLocation方法负责检索距离给定位置一定半径(以公里为单位)内的事件。如您所见,它使用GeoCoder类和DistanceCalculator类来实现其目的。

Calendar类是一个高级抽象,涉及日历及其事件的广泛概念。然而,getEventsAtLocation方法包含了许多与位置相关的细节,更多地是低级关注。这里的Calendar类关注于在DistanceCalculator上使用哪个公式以及计算中使用的测量单位。例如,您可以看到kilometerRadius参数必须除以1000才能得到以米为单位的距离,然后与haversineFormulaDistance方法返回的距离进行比较。

所有这些细节都不应该是高级抽象,如Calendar类的业务。依赖倒置原则要求我们考虑如何将这些关注点抽象到一个中间抽象中,作为高级和低级之间的桥梁。我们可以通过一个新类EventLocationCalculator来实现这一点:

const distanceCalculator = new DistanceCalculator();
const geocoder = new GeoCoder();
const METRES_IN_KM = 1000;

class EventLocationCalculator {
  constructor(event) {
    this.event = event;
  }

  getCoords() {
    return this.event.location.address
      ? geocoder.geocode(this.event.location.address)
      : this.event.location.coords
  }

  calculateDistanceInKilometers(targetLocation) {
    return distanceCalculator.haversineFormulaDistance(
      this.getCoords(),
      targetLocation
    ) / METRES_IN_KM;
  }
}

然后,Event类可以在其自己的isEventWithinRadiusOf方法中利用这个类。以下代码展示了这一示例:

class Event {

  constructor() {
    // ...
    this.locationCalculator = new EventLocationCalculator();
  }

  isEventWithinRadiusOf(targetLocation, kilometerRadius) {
    return locationCalculator.calculateDistanceInKilometers(
      targetLocation
    ) <= kilometerRadius;
  }

  // ...

}

因此,Calendar类只需要关注Event实例具有isEventWithinRadiusOf方法这一事实。它不需要任何信息,也不对确定距离的具体实现做出规定;这些细节留给我们的低级EventLocationCalculator类:

class Calendar {

  getEventsAtLocation(targetLocation, kilometerRadius) {
    return this.events.filter(event => {
      return event.isEventWithinRadiusOf(
        targetLocation,
        kilometerRadius
      );
    });
  }

  // ...

}

依赖倒置原则类似于与抽象界面分离原则相关的其他原则,但它特别关注依赖关系以及这些依赖关系的指向。当我们设计和构建抽象时,我们隐含地建立了一个依赖图。例如,如果我们要绘制出我们得到的实现的依赖关系,那么它看起来会像这样:

绘制此类依赖图非常有用。它们是探索代码真正复杂性的有用方式,通常可以突出可能改进的领域。最重要的是,它们让我们观察到我们的低级实现(细节)在何处,如果有的话,影响了我们的高级抽象。只有当我们看到这种情况时,我们才能加以纠正。因此,在阅读本书及以后的过程中,始终要在脑海中有一个依赖关系图;这将有助于引导您编写更脱耦、更可靠的代码。

依赖倒置原则是 SOLID 首字母缩写中的最后一个,而 SOLID,正如我们所见,主要关注于我们构建抽象的方式。我们将要介绍的下一个原则将绑定我们迄今为止所涵盖的大部分内容,因为它就是抽象本身的原则。如果我们从本章中记住的唯一一件事是什么,那么我们至少应该记住抽象原则

抽象原则

在第一章中,我们介绍了抽象塔的概念,以及每个抽象都是隐藏复杂性的简化杠杆的想法。编程中的抽象原则陈述如下:

实现应该与接口分离。

实现是抽象的复杂底层:它所隐藏的部分。接口是简化的顶层。这就是为什么我们说抽象是隐藏复杂性的简化杠杆。创建将实现与接口分离到恰到好处的抽象的技艺并不像看起来那么简单。因此,编程世界为我们提供了两个警告:

  • 不要重复自己DRY):这是一个警告,告诉我们要避免编写重复我们已经编写的代码。如果你发现自己不得不重复自己,那么这表明你未能抽象出某些东西,或者抽象得不够。

  • 你不会需要它YAGNI):也被称为保持简单,愚蠢!KISS),这个警告告诉我们要警惕过度抽象不需要被抽象的代码。这与 DRY 截然相反,并提醒我们除非有必要(如果我们开始重复自己,也许),我们不应尝试抽象。

在这两个警告之间,中间某处,就是完美的抽象。设计抽象,使其尽可能简单和有用,是一种平衡。一方面,我们可以说我们有过度抽象(DRY 警告我们要注意这一点),另一方面,我们有过度抽象(YAGNI 警告我们要注意这一点)。

过度抽象

过度抽象是指已经移除或替换了太多复杂性,以至于底层复杂性变得难以利用。过度抽象的风险在于,我们要么过度简化以求简单,要么添加新的不必要的复杂性,使抽象的用户感到困惑。

例如,假设我们需要一个画廊抽象,我们希望在网站和各种移动应用程序上都能使用它来显示画廊。根据平台的不同,画廊将使用可用的接口来生成布局。在网页上,它将生成 HTML 和 DOM,但在移动应用程序上,它将使用各种可用的本机 UI SDK。抽象为我们提供了所有这些跨平台复杂性的控制杆。

我们对画廊的最初要求非常简单:

  • 能够显示一个或多个图像

  • 能够在图像旁显示标题

  • 能够控制单个图像的尺寸

外部团队为我们创建了一个画廊组件。我们打开文档,看到它有以下示例代码,向我们展示如何创建一个包含两张图片的画廊:

const gallery = new GalleryComponent(
  [
    new GalleryComponentImage(
      new GalleryComponentImage.PathOfImage('JPEG', '/foo/images/Picture1.jpg'),
      new GalleryComponentImage.Options({
        imageDimensionWidth: { unit: 'px', amount: 200 },
        imageDimensionHeight: { unit: 'px', amount: 150 },
        customStyleStrings: ['border::yellow::1px']
      }),
      [
        new GalleryComponentImage.SubBorderCaptionElementWithText({
          content: { e: 'paragraph', t: 'The caption for this employee' }
        })
      ]
    }),
    new GalleryComponentImage(
      new GalleryComponentImage.PathOfImage('JPEG', '/foo/images/Picture2.jpg'),
      new GalleryComponentImage.Options({
        imageDimensionWidth: { unit: 'px', amount: 200 },
        imageDimensionHeight: { unit: 'px', amount: 150 },
        customStyleStrings: ['border::yellow::1px']
      }),
      [
        new GalleryComponentImage.SubBorderCaptionElementWithText({
          content: { e: 'paragraph', t: 'The caption for this employee' }
        })
      ]
    })
  ]
);

这个接口对于只显示几张图片的基本目的来说似乎非常复杂。考虑到我们简单的需求,我们可以说前面的接口是过度抽象的证据:它没有简化底层复杂性,而是引入了一整套我们甚至不需要的新的复杂性和各种功能。它在技术上满足了我们的要求,但我们必须在其复杂的领域中导航才能实现我们想要的东西。

这样的抽象,编码了新的复杂性并规定了自己的特性和命名约定,有可能不仅无法减少复杂性,还可能增加复杂性!抽象不应增加复杂性;这与抽象的整个目的背道而驰。

请记住,适当的抽象级别取决于上下文。对于您的用例来说可能是过度抽象的,对于另一个用例来说可能是不足抽象的。F1 赛车手对引擎的抽象级别与福特福克斯车手不同。抽象,像许多清洁代码概念一样,取决于受众和用户。

过度抽象还可以奇怪地采用过度简化的形式,其中对底层复杂性的控制杆并不向我们开放。我们的GalleryComponent接口的过度简化版本可能如下所示:

const gallery = new GalleryComponent(
  '/foo/images/PictureOne.jpg',
  '/foo/images/PictureTwo.jpg'
);

这种最小接口可能看起来与以前的代码完全相反,在某些方面是这样,但奇怪的是,它也是过度抽象的一个例子。记住,抽象是指通过接口提供对底层复杂性的杠杆。在这种情况下,这个杠杆太简单了,只提供了非常有限的复杂性,我们希望利用这种复杂性。它不允许我们添加标题或控制图像尺寸;它只允许我们列出一组图像,什么都不能做。

通过前面两个例子,你已经看到过度抽象可以有两种不同的风格:一种是过于复杂,一种是过于简化。这两种都是不受欢迎的。

低抽象

如果过度抽象是指过多的复杂性已被移除或替换,那么低抽象是指过少的复杂性已被移除或替换。这导致用户需要关注底层复杂性。想象一下,你有一辆汽车,你必须在没有方向盘或仪表盘的情况下驾驶它。你必须直接通过引擎来控制它,用你的双手拉动杠杆和转动油腻的齿轮,同时注意路况。我们可以说这辆车有一种低抽象的控制方法。

我们探讨了我们的画廊组件的过度抽象版本,那么让我们看看低抽象版本可能是什么样子:

const gallery = new GalleryComponent({
  web: [
    () => {
      const el = document.createElement('div');
      el.className = 'gallery-container';
      return el;
    },
    {
      data: [
        `<img src="/foo/images/PictureOne.jpg" width=200 height=150 />
         <span>The caption</span>`,
        `<img src="/foo/images/PictureTwo.jpg" width=200 height=150 />
         <span>The caption</span>`
       ]
    }
  ],
  android: [
    (view, galleryPrepData) => {
      view.setHasFixedSize(true);
      view.setLayoutManager(new GridLayoutManager(getApplicationContext(),2));
      return new MyAdapter(getApplicationContext(), galleryPrepData());
    },
    {
      data: [
        ['/foo/images/PictureOne.jpg', 200, 150, 'The Caption']
        ['/foo/images/PictureTwo.jpg', 200, 150, 'The Caption']
      ]
    }
  ]
});

这个GalleryComponent的版本似乎在强迫我们定义特定于 Web 的 HTML 和特定于 Android 的代码。理想情况下,我们依赖于抽象来隐藏这种复杂性,给我们一个简化的接口来利用它,但它没有做到。在这里,编写特定于平台的代码的复杂性在这里没有得到足够的抽象,因此我们可以说这是一个低抽象的例子。

从前面的代码中,你也可以看到我们被迫重复我们图像的源 URL 和标题文本。这应该提醒我们之前探讨过的一个警告:DRY,它表明我们没有充分抽象出某些东西。

如果我们留意那些被迫重复自己的领域,那么我们可以希望构建更好的抽象。但要注意,低抽象并不总是显而易见。

可以说各种抽象都是泄漏的抽象,因为它们通过它们的接口向上泄漏它们的一部分复杂性。前面的代码就是一个例子:我们可以说它正在向上泄漏其跨平台复杂性的实现细节。

平衡的抽象

根据我们对低抽象和过度抽象的了解,我们可以说平衡的抽象是坐落在这两种不受欢迎的对立面之间的抽象。创建平衡的抽象的技巧既是一门艺术,也是一门科学,需要我们对问题领域和用户的能力和意图有很好的理解。通过运用本章中的许多原则和警告,我们可以希望在我们的代码构建中保持平衡。对于GalleryComponent的上一个例子,我们应该再次探索抽象的要求:

  • 显示一个或多个图像的能力

  • 显示图像旁边的标题的能力

  • 控制各个图像的尺寸的能力

这些,我们可以说,是我们必须提供给底层跨平台复杂性的杠杆。我们的抽象应该只旨在暴露这些杠杆,而不是其他不必要的复杂性。以下是这种抽象的一个例子:

const gallery = new GalleryComponent([
  {
    src: '/foo/images/PictureOne.jpg',
    caption: 'The Caption',
    width: 200,
    height: 150
  },
  {
    src: '/foo/images/PictureTwo.jpg',
    caption: 'The Caption',
    width: 200,
    height: 150
  },
]);

通过这个接口,我们可以定义一个或多个图像,设置它们的尺寸,并为每个图像定义标题。它满足了所有的要求,而不会引入新的复杂性或从底层实现中泄漏复杂性。因此,这是一个平衡的抽象。

功能性编程原则

JavaScript 允许我们以多种不同的方式进行编程。到目前为止,在本书中分享的许多示例更倾向于面向对象编程,它主要使用对象来表达问题领域。函数式编程不同之处在于,它主要使用纯函数和不可变数据来表达问题领域。

所有编程范式都广泛关注同一件事:使表达问题领域更容易,作为程序员传达我们的意图,并容纳有用和可用的抽象的创建。我们从一种范式中采纳的最佳原则可能仍然适用于另一种范式,因此要采取开放的态度!

通过探索一个示例,最容易观察和讨论面向对象编程和函数式编程之间的差异。假设我们希望构建一个机制,以便我们可以从服务器获取分页数据。为了以面向对象的方式实现这一点,我们可能会创建一个PaginatedDataFetcher类:

// An OOP approach

class PaginatedDataFetcher {

  constructor(endpoint, startingPage) {
    this.endpoint = endpoint;
    this.nextPage = startingPage || 1;
  }

  getNextPage() {
    const response = fetch(
      `/api/${this.endpoint}/${this.nextPage}`
    );
    this.nextPage++;
    return fetched;
  }

}

下面是一个使用PaginatedDataFetcher类的示例:

const pageFetcher = new PaginatedDataFetcher('account_data', 30);

await pageFetcher.getNextPage(); // => Fetches /api/account_data/30
await pageFetcher.getNextPage(); // => Fetches /api/account_data/31
await pageFetcher.getNextPage(); // => Fetches /api/account_data/32

如您所见,每次调用getNextPage时,我们都会检索下一页的数据。getNextPage方法依赖于其对象的记忆状态,endpointnextPage,以了解下一个要请求的 URL。

状态可以被视为任何程序或代码片段的基础记忆数据,其结果或效果是由此派生的。汽车的状态可能意味着它的当前保养情况,燃油和机油水平等。同样,运行程序的状态是它从中派生功能的基础数据。

函数式编程与面向对象编程有所不同,它纯粹关注函数的使用和不可变状态来实现其目标。人们在探索函数式编程时通常遇到的第一个心理障碍与状态有关,引发了诸如“我应该把状态存储在哪里?”和“如何使事物改变而无法改变状态?”等问题。我们可以通过查看分页数据获取器的函数式编程等价物来探讨这个问题。

我们创建了一个函数getPage,我们将传递一个endpoint和一个pageNumber,如下所示:

// A more functional approach

const getPage = async (endpoint, pageNumber = 1) => ({
 endpoint,
 pageNumber,
 response: await fetch(`/api/${endpoint}/${pageNumber}`)
 next: () => getPage(endpoint, pageNumber + 1)
});

调用getPage函数时,将返回一个包含来自服务器的响应以及使用的endpointpageNumber的对象。除了这些属性之外,该对象还将包含一个名为next的函数,如果调用,将通过随后调用getPage来触发另一个请求。可以按以下方式使用它:

const page1 = await getPage('account_data');
const page2 = await page1.next();
const page3 = await page2.next();
const page4 = await page3.next();

// Etc.

您会注意到,使用这种模式时,我们只需要引用最后检索到的页面,即可进行下一次请求。通过页面 2 返回的next()函数检索页面 3。通过页面 3 返回的next()函数检索页面 4。

我们的getPage函数不会改变任何数据:它只使用传递的数据来派生新数据,因此可以说它采用了不可变性。还可以说它也是一个纯函数,因为对于给定的输入参数(endpointpageNumber),它将始终返回相同的结果。getPage返回的next函数也是纯的,因为它将始终返回相同的结果:如果我调用page2.next()一百万次,它将始终获取page 3

函数纯度和不可变性是最重要的函数式概念之一,而且,值得注意的是,这些原则适用于所有编程范式。我们并不打算在这里深入探讨函数式编程,而只是涵盖其最适用的原则,以增强我们的抽象构建能力。

函数纯度

当函数的返回值仅从其输入值派生时(也称为幂等性),并且没有副作用时,可以说函数是纯的。这些特征给我们带来了以下好处:

  • 可预测性:不会对程序的其他部分产生副作用的函数是可以轻松推理的函数。如果一个函数改变了它不拥有的状态,可能会在代码的其他部分创建级联的变化,这可能会非常复杂,从而产生维护和可靠性问题。

  • 可测试性:纯函数总是在给定相同的输入时返回相同的结果,因此非常容易验证。纯函数可能变得复杂,但如果保持纯净,它们将始终易于测试。

幂等性是指在提供特定输入时总是得出相同结果的特性。因此,幂等函数是高度确定性的。幂等函数可能仍然具有副作用,因此它可能并不总是一个纯函数,但从抽象用户的角度来看,幂等性是非常可取的,因为这意味着我们总是知道可以期望什么。

在面向对象编程中,对象上的方法通常不能说是纯的,因为它们会改变状态(在对象上)或在相同的输入参数下返回不同的结果。例如,考虑以下Adder类:

class Adder {
  constructor() {
    this.total = 0;
  }
  add(n) {
    return this.total += n;
  }
}

const adder = new Adder();
adder.add(10); // => 10
adder.add(10); // => 20
adder.add(5);  // => 25

add方法在这里不是纯的。即使给定相同的参数,它返回不同的结果,并且具有副作用:改变它不拥有的状态(即对象的 total 属性)。我们可以很简单地创建一个功能纯粹的加法抽象:

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

add(10, 10); // => 20
add(10, 20); // => 30

这可能看起来有些牵强,但功能纯度背后的概念是,从复杂的需求中得出真正纯粹的基本原语和函数,以构造它所需的。功能纯度在这里教给我们一个一般性的教训:将功能分解为其最原始的部分,直到你拥有一个真正可测试的独立单元。然后我们可以将这些较小的单元组合成更复杂的单元,以执行更复杂的工作。

不可变性

这一章主要讨论了我们如何构建和分离抽象,但同样重要的是考虑数据在这些抽象之间传递的期望。

不可变性是指数据不应该发生变化的简单想法。这意味着,当我们初始化一个对象时,例如,我们不应该向其添加新属性或随时间更改现有属性。相反,我们应该派生一个全新的对象,只对我们自己的副本进行更改。不可变性是数据的特征,也是函数式编程的一般原则。语言也可以通过不允许已声明的变量或对象的变异来强制不可变性。JavaScript 的const就是这种强制的一个例子:

const name = 'James';
name = 'Samuel L. Jackson';
// => Uncaught TypeError: Assignment to constant variable.

知道某物是不可变的意味着我们可以放心地知道它不会改变;我们可以依赖它的特性,而不必担心程序的其他部分可能在我们不知情的情况下改变它。这在 JavaScript 的异步世界中尤为重要,数据以复杂的方式在作用域和接口之间穿梭。

与本章涵盖的许多原则一样,不可变性并不一定要严格遵循才能从中获得好处。在某些领域保持不可变性,在其他领域保持可变性,可能是一种可行的方法。想象一份官方文件在政府大楼中穿梭。每个部门都默认文件没有被意外的人任意修改;特定部门可能选择复制文件,然后对其自己的副本进行各种变更,以满足自己独特的目的。代码库与此并无二致。通过构建抽象并让它们相互依赖,我们有意地使它们能够操纵彼此的数据和功能。

总结

在本章中,我们涵盖了大量的理论和实践技能。我们涵盖了 LoD(或最少信息原则)、所有 SOLID 原则、抽象原则,以及函数式编程范式中的一些关键原则。即使你不记得所有的名字,希望你能记住每个原则所包含的基础知识和关键教训。

编程既是一门艺术,也是一门科学。它涉及在追求真正平衡的抽象时平衡所有这些原则。这些原则都不应被视为硬性规则。它们只是指导方针,将在我们的旅程中帮助我们。

在下一章中,我们将继续探讨编程中最具挑战性的一个方面,无论是在 JavaScript 中还是在其他地方:命名事物的问题。

第五章:命名事物很难

名字无处不在。它们是我们大脑抽象宇宙复杂性的方式。在软件世界中,我们总是在努力创造新的抽象来描述我们的日常现实。编程世界中的一个常见警句是命名事物很难。想出一个名字并不总是很难,但想出一个名字通常是很难的。

在之前的章节中,我们已经探讨了抽象背后的原则和理论。在本章中,我们将提供这个谜题的最后一把钥匙。一个抽象如果没有好的命名,就不能成为一个好的抽象。在我们使用的名字中,我们在提炼一个概念,而这种提炼将定义人们最终理解这个概念的方式。因此,命名事物不仅仅是提供任意标签;它是提供理解。只有通过一个好的名字,用户或其他程序员才能完全内化我们的抽象,并以全面的理解来使用它。

在本章中,我们将使用一些例子来探讨一个好名字的关键特征。我们还将讨论在 JavaScript 这样的动态类型语言中命名事物的挑战。通过本章,我们应该清楚地了解如何提出干净和描述性的名字。

具体来说,我们将涵盖以下主题:

  • 名字中有什么?

  • 命名反模式

  • 一致性和层次结构

  • 技术和考虑事项

名字中有什么?

分解一个好名字的关键元素是困难的。它似乎更像是一门艺术而不是一门科学。一个相当好的名字和一个非常好的名字之间的界限模糊不清,容易受主观意见的影响。

考虑一个负责将多个 CSS 样式应用于按钮的函数。想象一种情况,这是一个独立的函数。以下哪个名字你认为最合适?

  • styleButton

  • setStyleOfButton

  • setButtonCSS

  • stylizeButton

  • setButtonStyles

  • applyButtonCSS

你可能已经选择了你喜欢的。在阅读本书的人中,肯定会有分歧。这些分歧中的许多将根植于我们自己的偏见。我们的许多偏见将受到诸如我们说什么语言、我们之前接触过哪些编程语言以及我们花时间创建哪种类型的程序等因素的影响。我们之间存在许多差异,但是,不知何故,我们必须提出一个关于好名字或干净名字的非模糊概念。至少可以说,一个好的名字可能具有以下特征:

  • 目的:某物的用途和行为方式

  • 概念:它的核心思想以及如何思考它

  • 合同:关于它如何工作的期望

这并不能完全涵盖命名的复杂性,但是有了这三个特征,我们有了一个起点。在本节的其余部分,我们将学习这些特征如何对命名事物的过程至关重要。

目的

一个好的名字表明了目的。目的是某物做什么,或者某物什么。在函数的情况下,它的目的就是它的行为。这就是为什么函数通常以动词形式命名,比如getUsercreateAccount,而存储值的东西通常是名词,比如accountbutton

一个概括清晰目的的名字永远不需要进一步解释。它应该是不言自明的。如果一个名字需要注释来解释它的目的,那通常意味着它没有完成作为名字的工作。

某物的目的是高度上下文化的,因此将受到周围代码和该名字所在代码库区域的影响。这就是为什么通常可以使用通用名字,只要它周围有助于说明其目的的上下文。例如,比较TenancyAgreement类中的这三个方法签名:

class TenancyAgreement {

  // Option #1:
  saveSignedDocument(
    id,
    timestamp
  ) {}

  // Option #2:
  saveSignedDocument(
    documentId,
    documentTimestamp
  ) {}

  // Option #3:
  saveSignedDocument(
    tenancyAgreementSignedDocumentID,
    tenancyAgreementSignedDocumentTimestamp
  ) {}

}

当然,这是有主观性的,但大多数人会同意,当我们有一个能够很好地传达其目的的周围上下文时,我们不需要详细命名该上下文中的每个变量。考虑到这一点,我们可以说前面代码中的Option #1过于局限,可能会引起歧义,而Option #3过于冗长,因为其参数名称的一部分已经由其上下文提供。然而,Option #2,使用documentIddocumentTimestamp,是恰到好处的:它充分传达了参数的目的。这就是我们需要的。

目的对于任何名称来说绝对是至关重要的。没有描述或目的的指示,名称只是装饰而已,通常意味着我们的代码使用者只能在文档和其他代码片段之间翻找,才能弄清楚某些事情。因此,我们必须记住始终考虑我们的名称是否能很好地传达目的。

概念

一个好的名称表明概念。名称的概念指的是其背后的想法,其创建的意图,以及我们应该如何思考它。例如,一个名为relocateDeviceAccurately的函数不仅告诉我们它将做什么(它的目的),还告诉我们关于其行为周围概念的概念。从这个名称中,我们可以看到设备是可以被定位的东西,并且可以以不同的精度定位这些设备。一个相对简单的名称可以在阅读它的人的头脑中唤起丰富的概念。这是命名事物的重要力量的一部分:名称是理解的途径。

一个名称的概念,就像它的目的一样,与它存在的上下文紧密相关。上下文是我们的名称存在的共享空间。围绕我们感兴趣的名称的其他名称绝对有助于帮助我们理解它的概念。想象一下以下名称一起出现:

  • rejectedDeal

  • acceptedDeal

  • pendingDeal

  • stalledDeal

通过这些名称,我们立即理解到deal是一种至少可以有四种不同状态的东西。这暗示着这些状态是相互排斥的,不能同时适用于一项交易,尽管目前还不清楚。我们可能会假设是否有特定条件与交易是否待定或停滞有关,尽管我们不确定这些条件是什么。因此,即使存在歧义,我们已经开始建立对问题领域的丰富理解。这仅仅是通过查看名称,甚至没有阅读实现。

我们已经谈到上下文作为名称的一种共享空间。在编程术语中,我们通常说在一个区域中命名的事物占据一个单一的命名空间。命名空间可以被认为是一个地方,其中事物彼此共享一个概念区域。一些语言已经将命名空间的概念正式化为自己的语言构造(通常称为,或者简单地称为命名空间)。即使没有这样的正式语言构造,JavaScript 仍然可以通过对象等分层构造来构建命名空间,如下所示:

const app = {};
app.transactions = {};
app.transactions.dealMaking = {};
app.transactions.dealMaking.states = [
  'REJECTED_DEAL',
  'ACCEPTED_DEAL',
  'PENDING_DEAL',
  'STALLED_DEAL'
];

大多数程序员倾向于将命名空间视为一个非常正式的构造,但这并不经常是这种情况。通常,我们在编写函数时会在其中使用函数来组成暗示的命名空间,而不自知。在这种情况下,命名空间不是由对象层次结构的级别界定的,而是由我们函数的作用域界定的,如下所示:

function makeFilteredRequest(endpoint, filterFn) {
  return fetch(`/${endpoint}/`)
    .then(response => response.json())
    .then(data => data.filter(filterFn);
}

在这里,我们通过fetch向一个端点发出请求,在返回之前,我们通过利用fetch返回的 promise 来收集所需的数据。为了做到这一点,我们使用了两个then(...)处理程序。

Promise是一个原生提供的类,为处理异步操作提供了有用的抽象。通常可以通过其 then 方法来识别 promise,就像我们在前面的代码中使用的那样。在利用异步操作时,通常会使用 promise 或回调。您可以在异步控制流部分的第十章中了解更多信息。

我们的第一个then(...)处理程序将其参数命名为response,而第二个处理程序将其参数命名为data。在makeFilteredRequest的上下文之外,这些术语将非常模糊。然而,因为我们在与制作过滤请求相关的函数的暗示命名空间内,术语responsedata足以传达它们的概念。

我们的名称传达的概念,就像它们的目的一样,与它们指定的上下文密切相关,因此重要的是不仅要考虑名称本身,还要考虑其周围的一切:它所在的复杂逻辑和行为的错综复杂。所有代码都涉及一定程度的复杂性,对这种复杂性的概念理解对于能够掌握它至关重要。因此,在命名某物时,有助于问自己:*我希望他们如何理解这种复杂性?*如果您正在为其他程序员创建一个简单的接口,编写一个深度嵌入的硬件驱动程序,或者为非程序员创建一个 GUI,这是相关的。

合同

一个好的名称表示与周围抽象的其他部分的合同。通过其名称,变量可能指示它将如何使用或包含什么类型的值以及我们对其行为应有的一般期望。通常不会考虑,但是当我们命名某物时,实际上正在建立一系列隐含的期望或合同,这些期望或合同将定义人们如何理解和使用该物。以下是 JavaScript 中存在的隐含合同的一些示例:

  • is开头的变量,例如isUser,预期是布尔类型(truefalse)。

  • 全大写的变量预期是一个常量(只设置一次且不可变),例如DEFAULT_USER_EXPIRY

  • 以复数命名的变量(例如 elements)预期包含一个或多个项目的集合对象(例如数组),而以单数命名的变量(例如 element)只预期包含一个项目(不在集合中)。

  • getfindselect开头的函数通常预期会向您返回一些东西。以processbuildrun开头的函数更加模糊,可能不会这样做。

  • 属性或方法名称以下划线开头,例如_processConfig,通常意味着是内部实现或伪私有的。它们不打算公开调用。

不管我们喜欢与否,所有名称都携带着关于其值和行为的不可避免的期望。重要的是要意识到这些约定,以免意外违反其他程序员依赖的合同。当然,每个约定都会有例外,但尽管如此,我们应该尽量遵守它们。

不幸的是,并没有一个可以定义所有这些合同的规范列表。它们通常相当主观,并且将取决于代码库。尽管如此,在我们遇到这种约定的地方,我们应该遵循它们。正如我们在第二章中提到的,《清洁代码的原则》,确保熟悉性是增加代码可维护性的好方法。而确保熟悉性的最佳方法莫过于采用其他程序员已经采用的约定。

许多这些暗示的合同与类型有关,而 JavaScript,正如你可能知道的,是动态类型的。这意味着值的类型将在运行时确定,并且任何变量包含的类型可能会有所改变:

var something;
something = 1;    // a number
something = true; // a boolean
something = [];   // an array
something = {};   // an object

变量可以引用许多不同的类型这一事实意味着我们采用的名称所暗示的合同和约定更加重要。没有静态类型检查器来帮助我们。我们只能在自己和其他程序员的混乱心情中独自面对。

在本章的后面,我们将讨论匈牙利命名法,这是一种在动态类型语言中有用的命名方式。另外,值得知道的是,对于 JavaScript,有各种静态类型检查和类型注释工具可用,如果你发现处理其动态性很痛苦。这将在第十五章中进行介绍,更干净代码的工具

合同不仅因为 JavaScript 的动态类型而重要。它们在给予我们对某些值的行为以及在程序运行时可以从中期望什么方面上是基本有用的。想象一下,如果有一个名为getCurrentValue()的方法的 API 并不总是返回当前值。那将违反其暗示的合同。通过合同的视角看名字是一种很有意思的方式。很快,你将开始在各处看到合同 - 变量之间的合同,接口之间的合同,以及整个架构和系统之间的集成级别的合同。

现在我们已经讨论了一个好名称的三个特征(目的、概念、合同),我们可以开始探讨一些反模式,也就是我们应该尽量避免的命名方式。

命名反模式

与 DRY 和 YAGNI 的抽象构建警告类似,命名也有自己的警告和反模式。有许多组成糟糕名称的方式,几乎所有这些方式都可以分为三种广泛的命名反模式:不必要的短名称不必要的奇异名称不必要的长名称

名称是我们和其他人将查看我们构建的抽象的初始镜头。因此,了解如何避免创建最终只会模糊理解并为其他程序员复杂化事情的镜头是至关重要的。让我们从探讨不必要的短名称开始,以及它们如何最终极大地限制我们理解某些事物的能力。

不必要的短名称

名称太短通常使用程序特定知识或领域特定知识,这些知识可能不适用于代码的受众。一个孤独的程序员可能认为写下以下代码是合理的:

function incId(id, f) {
  for (let x = 0; x < ids.length; ++x) {
    if (ids[x].id === id && f(ids[x])) {
      ids[x].n++;
    }
  }
}

我们能够辨别出它与 ID 相关,并且其目的是有条件地增加ids数组中特定对象的n属性。因此,在功能层面上,我们可以辨别出它在做什么,但其含义和意图很难理解。程序员使用了单个字母的名称(fxn),并且还使用了缩写的函数名称(incId)。这些名称大多未能满足我们从名称中期望的基本特征:指示目的、概念和合同。我们只能通过它们的使用方式来猜测这些名称的目的和概念。用更有意义的名称重构将大大有助于这一点。

function incrementJobInstancesByIdIfFilter(id, filter) {
  for (let i = 0; i < jobs.length; i++) {
    let job = jobs[i];
    if (job.id === id && filter(job)) {
      job.nInstances++;
    }
  }
}

现在我们对情况有了更清晰的了解。被迭代的数组包含作业。函数的目的是找到具有指定 ID 的作业,并且在该作业满足指定过滤器的条件下递增作业的nInstances属性。通过这些新名称,我们已经对这个抽象有了更丰富的概念理解。我们现在明白作业是可以有任意数量实例的项目,并且当前实例的数量通过nInstances属性进行跟踪。通过名称提供的视角,我们能够更清晰地理解底层问题领域。现在,我们可以看到名称不仅仅是装饰或不必要的冗长;名称是你抽象的本质。

不必要的短名称在很多方面只是一个意义不足的名称。然而,名称的短并不一定表示问题。我们在前面的代码中使用的迭代变量i是完全可以的,因为这是一个几十年来已经确立的惯例。世界各地的程序员都理解它的概念和约定义:它只用于遍历数组,并在每个迭代阶段访问数组元素。

总的来说,除了我们的迭代变量等极少数例外情况外,避免使用短名称带来的意义不足非常重要。它们通常是匆忙或懒惰地组成的,甚至可能会让程序员感到有所成就。毕竟,能够运用晦涩的逻辑是一种自我陶醉的礼物。但正如我们所讨论的,自我陶醉并不是清晰代码的朋友。每当你感到想要使用短名称的冲动时,抵制这种冲动,花时间选择一个更有意义的名称。

不必要的异国情调的名称

自我陶醉的另一个方面是异国情调的名称的泛滥。异国情调的名称是那些不必要地吸引注意力的名称,通常在意义上是模糊或难以理解的,比如:

function deStylizeParameters(params) {
  disEntangleParams(params, p => !!p.style).obliterate();
}

这是一个表面上简单的行为,却因不必要的异国情调的名称而变得模糊。我们只需稍加努力,就可以大大提高这些抽象的可理解性:

function removeStylingFromParams(params) {
  filterParams(params, param => !!param.style).remove();
}

总的来说,名称应该是无聊的。它们不应该吸引注意力。它们应该只展示它们的简单含义,而不会让其他程序员感到*哦,原来是这个意思!哈哈,好聪明!*我们的自我可能对命名有自己的想法,但我们应该记住限制自我,纯粹考虑那些必须忍受尝试理解我们的代码和我们创建的接口的人。总的来说,以下建议将使我们走上正确的道路:

  • 避免使用普通词的花哨或更长的同义词:例如,使用killobliterate而不是delete

  • 避免不存在的词:例如,deletifyelementizededupify

  • 避免双关语或巧妙的暗示:例如,使用化学元素名称来指代 DOM 元素

过度异国情调会冒险疏远我们的受众。你可能很容易理解你采用的名称,但这并不意味着其他人也能轻松理解。程序员社区非常多样化,有许多不同的文化和语言背景。最好坚持使用描述性和无聊的名称,以便你的代码能够被尽可能多的人理解。

不必要的冗长的名称

正如我们已经发现的,不必要的短名称实际上是没有足够意义的名称。因此,不必要的长名称是一个意义过多的名称。你可能会想一个名称怎么会有太多的意义。意义是好事,但是过多的意义压缩到一个名称中只会导致混淆;例如:

documentManager.refreshAndSaveSignedAndNonPendingDocuments();

这个名称很难理解:它是在刷新和保存已签署和非挂起的文档,还是在刷新和保存既已签署又非挂起的文档?不清楚。

这个长名称给了我们一个线索,表明底层抽象是不必要地复杂。我们可以将名称分解为其组成部分,以充分了解其接口:

  • refresh (verb):文档上发生的刷新动作

  • save (verb):文档上发生的保存动作

  • signed (adjective):文档的已签署状态

  • non-pending (adjective):文档的非挂起状态

  • document (noun):文档本身

在这里我们有一些不同的事情发生。对于这么长的名称,一个很好的指导原则是重构底层抽象,以便我们只需要一个最多包含一个动词,一个形容词和一个名词的名称。例如,我们可以将我们的长名称拆分为四个不同的函数:

documentManager.refreshSignedDocuments();
documentManager.refreshNonPendingDocuments();
documentManager.saveSignedDocuments();
documentManager.saveNonPendingDocuments();

或者,如果意图是对携带多种状态(SIGNEDNON_PENDING)的文档执行操作,那么我们可以为刷新实现这样的方法(保存动作也可以类似):

documentManager.refreshDocumentsWithStates([
  documentManager.STATE_SIGNED,
  documentManager.STATE_NON_PENDING
]);

关键是,长名称是一个破碎或混乱抽象的线索。使名称更易理解通常与使抽象更易理解相辅相成。

与短名称一样,问题不在于名称本身的长度:而是长度通常所指的含义。长名称所指的是将太多的含义压缩到一个名称中,表明了混乱的抽象。

一致性和层次结构

到目前为止,我们已经谈到了名称的三个最重要的特征:目的概念合同。赋予名称这些特征的最简单的方法之一是利用一致性和层次结构。这里的一致性是指在代码的给定区域内使用相同的命名模式。另一方面,层次结构是指我们构建和组合不同代码区域以形成整体架构的方式。它们共同使我们能够为名称提供丰富的上下文,可以用来对其目的,概念和合同进行强有力的推断。

这最好通过查看虚构应用程序的 JavaScript 目录来解释。我们有一个满是文件的目录,如下所示:

app/
|-- deepClone.js
|-- deepEquality.js
|-- getParamsFromURL.js
|-- getURL.js
|-- openModal.js
|-- openModalWithTemplate.js
|-- setupAppWithCustomConfig.js
|-- setupAppWithDefaultConfig.js
|-- setURL.js
|-- ...

没有层次结构,因此我们只能从名称本身和它们似乎相关的内容中推断上下文。例如,有一个getURL和一个setURL文件,它们都可能与 URL 相关,并且可以被视为实用程序。因此,将这些占据相同层次结构的部分或共享命名空间,例如app/utils/url,将是有帮助的。我们还可以将目录结构的其他部分重构为更具上下文丰富的层次结构:

app/
|-- setup/
|   |-- defaultConfig.js
|   |-- setup.js
|-- modal/
|   |-- open.js
|   |-- openWithTemplate.js
|-- utils/
    |-- url/
    |   |-- getParams.js
    |   |-- get.js
    |   |-- set.js
    |-- obj/
        |-- deepEquality.js
        |-- deepClone.js

立即,事情变得更清晰。理解所有这些文件及其功能的认知负担现在因每个文件都有其丰富的上下文而减轻了。您还会注意到,我们已经能够简化层次结构的各个部分的名称;例如,我们已将openModal.js重命名为modal/open.js。这是使用名称层次结构的额外好处:在每个命名级别,我们可以简化和缩短名称,减少理解时间。

层次结构内的名称自然地从其所在的上下文中获得一部分含义。这意味着名称本身不需要包含所有含义。始终寻找机会为类似的抽象提供共同的上下文,以减轻理解的负担。

就像我们通过目录结构的层次结构提供了含义一样,我们也可以在代码本身中提供含义。例如,在一个函数内部,名称自然会从函数的名称本身和它在更大模块中的情境中获得很多上下文。想象一下,如果写出这样的代码会很不寻常:

function displayModalWithMessage(
  modalDisplayer_Message,
  modalDisplayer_Options
) {
  const modalDisplayer_ModalInstance = new Modal();
  modalDisplayer_ModalInstance.setMessage(modalDisplayerMessage);
  modalDisplayer_ModalInstance.setOptions(modalDisplayerOptions);
  modalDisplayer_ModalInstance.show();
  return modalDisplayer_ModalInstance;
}

函数内部的名称不必要地加上了上下文信息(比如modalDisplayer_...),而代码的读者已经可以从函数本身获取这些信息。通常,我们编写的代码会利用变量所处的位置以及从上下文中获得的含义。前面的代码更正常的写法应该是这样的:

function showModalWithMessage(message, options) {
  const modalInstance = new Modal();
  modalInstance.setMessage(message);
  modalInstance.setOptions(options);
  modalInstance.show();
  return modalInstance;
}

在之前的一章中,我们讨论了抽象原则以及模块的实现应该独立于其接口。我们可以看到这个原则在这个函数中得到了体现。函数的范围(其实现)应该完全独立(甚至无知!)于其接口。因此,可以说,modalInstance变量不需要知道它位于哪个函数中,因此前缀为modalDisplayer_...的命名技术将违反抽象原则。

考虑到抽象的层次结构是关键。层次结构不仅仅在组织上有用。它们应该理想地反映出我们代码中存在的抽象层次。更高级的抽象位于层次结构的顶部,我们进入层次结构的深处,就会变得更低级。这是一个很好的一般规则:让你的层次结构反映你的抽象

命名事物时要保持一致性,这符合这个规则。在我们的抽象的一个层次内,也就是在层次结构的一个层级内,我们应该采用常见的命名模式,这样我们代码的读者就可以轻松地浏览和理解其中的概念。例如,如果我们正在创建一个用于向数据结构添加和删除项目的接口,那么我们应该避免以不一致的方式命名类似的操作。考虑以下类的示意图:

class MyDataStructure {
  addItem() {}
  pushItems() {}
  setItemIfNotExists() {}
  // ...
}

非常令人困惑的是,这个抽象提供了三种不同的添加概念的变体:添加推送设置。实际上,这些名称都指的是同一个概念,所以我们应该采用一个常见的命名模式,比如以下方式:

class MyDataStructure {
  addItem() {}
  addItems() {}
  addItemIfNotExists() {}
  // ...
}

这个接口现在更容易理解了。使用起来更加清晰,认知负担更小。作为这个抽象的用户,我不再需要记住我应该使用addset还是push。一致性是通过避免不必要的差异而产生的特征。不一致会让人感到不适,因此它们只应该用于标示真正的功能或概念差异。

技术和考虑因素

由于 JavaScript 的不断变化,它积累了大量相互冲突的约定。其中许多约定在支持或反对方面都引起了强烈的意见。然而,我们已经就一些基本的命名约定达成了一致,这些约定或多或少地得到了全球范围内的接受:

  • 常量应该用下划线分隔的大写字母命名;例如,DEFAULT_COMPONENT_COLOR

  • 构造函数或类应该采用驼峰命名法,首字母大写;例如,MyComponent

  • 其他所有内容都应该采用驼峰命名法,首字母小写;例如,myComponentInstance

除了这些基本约定之外,命名的决定很大程度上取决于程序员的创造力和技能。你最终使用的名称将在很大程度上由你解决的问题所定义。大多数代码将继承与其接口的 API 的命名约定。例如,使用 DOM API 通常意味着你采用诸如elementattributenode之类的名称。许多流行的可用框架也会倾向于规定我们采用的名称。从你所在的生态系统中采用这样的常规范式是非常有用和必要的,但同时拥有一些基本的技术和概念也是很有用的,这样你就可以在新的和陌生的问题领域中构建出命名得体的抽象。

匈牙利命名法

JavaScript 是一种动态类型语言,这意味着值的类型将在运行时确定,并且任何变量包含的类型可能在运行时发生变化。这与静态类型语言相反,后者在编译时会警告你关于类型的使用。这意味着,作为 JavaScript 程序员,我们需要在使用类型和命名变量的方式上更加小心。

众所周知,当我们命名事物时,我们是在暗示一个约定。这个约定将定义其他程序员如何使用该事物。这就是为什么在各种语言中,匈牙利命名法非常流行的部分原因。它涉及在名称本身中包含类型注释,就像这样:

  • 我们可以使用elButtonbuttonElement,而不是button

  • 我们可以使用nAgeageNumber,而不是age

  • 我们可以使用objDetailsdetailsObject,而不是details

匈牙利命名法有以下几个原因:

  • 确定性:它为您的代码读者提供了更多关于名称目的和约定的确定性

  • 一致性:它会导致更一致的命名方式

  • 强制执行:它可能导致代码内更好地执行类型约定

然而,它也有以下缺点:

  • 运行时更改:如果底层类型在运行时被糟糕的代码更改(例如,如果函数将nAge变成字符串),那么该名称将不再有用,甚至可能误导我们。

  • 代码库的僵化:它可能导致代码库变得僵化,难以对类型进行适当的更改。重构旧代码可能变得更加繁重。

  • 缺乏含义:仅知道变量的类型并不能告诉我们它的目的、概念或约定,就像一个真正描述性的非类型变量名那样。

在 JavaScript 的领域中,我们看到匈牙利命名法在一些地方被使用:最常见的是在命名可能指向 DOM 元素的变量时。这些名称的标注通常以elHeaderheaderElheadingElement或者$header的形式出现。以美元符号为前缀的后者最著名地用在 jQuery 库中。它在那里的名声导致它成为各种其他地方的标准。例如,Chromium DevTools在元素引用和与查询 DOM 相关的方法中使用了美元前缀(例如,$$(...)被别名为document.querySelectorAll(...))。

匈牙利命名法是一种可以部分利用的东西,当你担心可能存在歧义时。例如,你可以在一个作用域内同时引用复杂类型和原始类型来使用它:

function renderArticle(name) {
  const article = Article.getByName(name);
  const title = article.getTitle();
  const strArticle = article.toString();
  // ...
}

在这里,我们有一个指向Article类实例的article变量。除此之外,我们还想使用我们文章的字符串表示。为了避免潜在的命名冲突,我们使用了一个str前缀来表示该变量是指向一个字符串值。在这样的孤立情况下,匈牙利命名法是有用的。你不需要完全使用它,但它是一个有用的工具。

命名和抽象函数

在 JavaScript 中,大多数抽象最终都会体现在函数中。即使在大型架构中,也是单个函数和方法在工作,而且在它们的构思中,一个好的抽象开始显现出来。因此,值得深入思考我们应该如何命名我们的函数以及在这样做时应该考虑哪些因素。

函数的名称通常应该使用语法中所谓的命令形式。当我们给出指示时,就是我们使用的命令形式,比如走到商店买面包停在那里

尽管我们通常在命名函数时使用命令形式,但也有例外。例如,惯例上也会在返回布尔值的函数前加上ishas;例如,isValid(...)。在创建构造函数(它们也是函数)时,我们会根据它们将生成的实例来命名;例如,RouteSpecialComponent

在编程环境中,命令形式的直接性是最容易理解和阅读的。要找到特定问题的正确命令形式,最好想象一下发布军事命令的过程,也就是说,不要拐弯抹角,准确地说明你想要发生的事情:

  • 如果你想显示提示,使用displayPrompt()

  • 如果你想要移除元素,使用removeElements()

  • 如果你想要一个在xy之间的随机数,使用generateRandomNumber(x, y)

通常,我们希望对我们的指示进行限定。如果你要给一个人下达指示,比如找到我的自行车,你可能会进一步限定这个指示,比如它是蓝色的它的前轮丢了。然而,重要的是不要让函数的名称被这些限定所拖累。以下函数就是一个例子:

findBlueBicycleWithAMissingFrontWheel();

正如我们之前提到的,一个不必要地长的名称是一个糟糕抽象的标志。当我们看到这种过度限定的情况时,我们应该退一步重新考虑。在这里,重要的是要在口语和编程语言中划清界限。在编程中,函数是抽象常见行为的方式,可以通过参数根据需要进行调整或配置。

因此,我们应该通过参数来表达蓝色丢失前轮的限定。例如,我们可以将它们表达为一个单一的对象参数,如下所示:

findBicycle({
  color: 'blue',
  frontWheel: 'missing'
});

通过将函数名称的限定部分移到其参数中,我们正在产生一个更清晰和更易理解的抽象。这不仅增加了抽象的可配置性,还为用户提供了更多的可能性。

在我们的情况下,我们可能希望让用户能够找到除自行车以外的其他对象。为了满足这一点,我们会使函数的名称更加通用(例如,findObject),并通过添加一个新的选项属性(例如,type)将限定部分移到参数中,如下所示:

findObject({
  type: 'bicycle',
  color: 'blue',
  frontWheel: 'missing'
});

在这个过程的阶段,发生了一些奇怪的事情。我们已经正确地将各种限定词移到函数的参数中,扩展了我们抽象的有用性和配置。但现在我们得到的是一个做很多事情的抽象,因此在某个时候,退一步构建更高级别的抽象来封装这些不同的行为可能是明智的。在我们的情况下,我们可以通过函数组合来实现这一点,如下所示:

const findBicycle    = config => findObject({ ...config, type: 'bicycle' });
const findSkateboard = config => findObject({ ...config, type: 'skateboard' });
const findScooter    = config => findObject({ ...config, type: 'scooter' });

首先,函数是行为的一个单元。正如 SRP 告诉我们的那样,确保它们只做一件可辨认的事情非常重要。在考虑这些事情或行为单元时,重要的是要从将使用它的人的角度考虑函数的作用。从技术上讲,我们组合的findScooter函数很可能在表面之下做了各种事情。它可能非常复杂。但在它将被使用的抽象层面上,可以说它只做了一件事,这才是重要的。

三个糟糕的名字

如果你对名字感到困惑,有一个聪明的方法可以帮助你摆脱困境。当你有一个需要命名的抽象或变量时,仔细看看它的功能或包含的内容,然后想出至少三个描述它的糟糕名字。现在不要担心你希望提供的抽象或接口;只是想象你在向一个对代码库一无所知的人描述功能。直接而描述性。

例如,假设我们嵌入在处理设置新用户名的代码库的部分。我们需要检查用户名是否与一组特定的禁止单词匹配,比如adminrootuser。我们想写一个函数来做这个,但我们不确定该选什么名字。因此,我们决定尝试三个糟糕的名字的方法。这是我们想出的名字:

  • matchUsernameAgainstForbiddenWords

  • checkForForbiddenWordConflicts

  • isUsernameReservedWord

想出三个不太完美的名字要比花很多时间试图找到完美的名字容易得多。这三个名字有多糟糕并不重要。重要的是我们至少能想出三个。现在,我们已经有了一系列可能性,我们可以自由地比较和对比我们找到的名字,并混合它们以找到最具描述性和直接表达我们函数目的的方式。在这种情况下,我们可能最终决定采用从这三个可能性中改编的名字:isUsernameForbiddenWord。如果不是因为采用了三个糟糕的名字的方法,我们就不会得到这个名字。

总结

在本章中,我们已经探讨了命名的艰难艺术。我们讨论了一个好名字的特征,即目的、概念和约定。我们通过示例讲解了如何将这些特征编织到我们的名字中,以及要避免的反模式。我们还讨论了在追求清晰抽象时层次结构和一致性的重要性。最后,我们介绍了一些有用的技术和惯例,当我们在命名事物时遇到困难时可以利用。

在下一章中,我们将终于开始深入了解 JavaScript 语言本身,并学习如何以一种能产生真正清晰代码的方式运用它的构造和语法。

第二部分:JavaScript 及其组成部分

在本节中,我们将深入研究 JavaScript 的内部和语言构造。这将使我们对如何使用 JavaScript 的最佳部分来编写清晰的代码有一个非常坚实的基础理解。

本节包括以下章节:

  • 第六章,基本类型和内置类型

  • 第七章,动态类型

  • 第八章,运算符

  • 第九章,语法和作用域的部分

  • 第十章,控制流

第六章:原始和内置类型

到目前为止,我们已经从几个不同的角度探讨了清晰代码的含义。我们探讨了我们编写的代码如何让用户通过利用抽象来处理复杂性。我们继续讨论了清晰代码的原则,如可靠性和可用性,以及在追求这些目标时需要注意的各种陷阱和挑战。

在本章中,我们将详细探讨 JavaScript 语言本身,包括更常见的语言构造和更晦涩和令人困惑的方面。我们将把我们对清晰代码的积累知识应用到语言的所有部分,并建立一个纯粹针对清晰代码创建的 JavaScript 理解。

我们将从 JavaScript 最基本的部分开始:作为任何程序的构建块的原始值。然后,我们将转向非原始值,即对象。在我们探索这些类型时,我们将通过示例揭示使每种类型独特的语义和在使用中需要避免的陷阱。我们在本章中获得的关键知识将应用在后面的章节中,以便我们真正完全地了解在 JavaScript 中编写清晰代码的含义。

在本章结束时,你应该对以下主题感到自如:

  • 原始类型

  • 对象

  • 函数

  • 数组和可迭代对象

  • 正则表达式

原始类型

在 JavaScript 中,原始类型是指任何不是对象的值,因此没有任何方法或属性。JavaScript 中有七种原始类型:

  • 数字

  • 字符串

  • 布尔

  • 未定义

  • 大整数

  • 符号

在本节中,我们将探索这些原始值之间的共同特征,并深入研究每种类型,探讨它的工作原理以及在使用中存在的潜在危险。我们将欣赏到 JavaScript 语言本身只是一组不同抽象的集合,当巧妙地使用时,可以轻松解决任何问题领域。

原始值的不可变性

所有原始值都是不可变的,这意味着你不能改变它们的值。这是它们原始性的核心部分。例如,你不能将数字值 3.14 改变为 42,或者将字符串的值更改为它的大写变体。

但我可以将字符串的值更改为它的大写变体! 如果你记得能够这样做,你现在可能会感到困惑。但这里需要做出一个重要的区分,即变量重新赋值为新的原始值是完全可能的(也可能是你记得的),而原始值的变异是不可能的。

当我们重新分配一个变量,给它一个新的值时,我们并没有改变值本身;我们只是改变了变量所引用的值,如下所示:

let name = 'simon';
let copy = name;

// Assign a new value to `name`:
name = name.toUpperCase();

// New value referred to by name:
name; // => "SIMON"

// Old value remains un-mutated:
copy; // => "simon"

请注意 copy 保持小写。原始值 simon 没有被改变;相反,通过 toUpperCase 方法派生出一个新的原始值,然后赋给之前持有小写变体的变量。

原始包装器

你会记得我们提到原始值没有方法,因为它们不是对象。那么,我们是如何能够在前面的字符串上调用 toUpperCase 的呢?那不是一个方法吗?是的,是方法。为了让我们能够访问这个方法,JavaScript 在属性访问时会将原始值包装在它们各自的包装对象中。这适用于所有原始值,除了 nullundefined

在这些被包装的时刻,原始值保持不变,但是通过它们的包装实例,可以访问属性和方法。字符串值将被包装在String实例中,而数字值将被包装在Number实例中。对于所有其他非空和非未定义的原始值也是如此。您可以自由地实例化这些包装对象:您会发现它们不再像原始值那样行为了;它们是对象,因此您可以在它们上面添加和改变属性:

const name = new String('James');

// We can add arbitrary properties, since it is an object:
// (Warning: this is an anti-pattern)
name.surname = 'Padolsey';
name.surname; // => "Padolsey"

如果您需要一个对象来添加自定义属性,最好使用一个普通对象。除了用于包装其原始值以外的任何其他内容,都是一种反模式,因为其他程序员不会预期这样做。尽管如此,观察和记住原始类型及其相应包装对象之间的差异是很有用的。

调用包装构造函数(例如NumberString等)作为常规函数具有独特的行为。它不会返回一个新的包装实例,而是会将值转换为特定类型并返回一个常规的原始值。当您需要将一种类型转换为另一种类型时,这是非常有用的:

// Cast a number to a string:
String(123); // => "123"

// Cast a string to a number
Number("2"); // => 2

// Cast a number to a boolean
Boolean(0); // => false
Boolean(1); // => true

将包装构造函数作为函数调用,就像我们在这里所做的那样,是一种有用的转换技术,尽管这不是唯一的一种。我们将在第七章中更详细地介绍类型转换和强制转换,动态类型

虚假原始值

在 JavaScript 中,布尔上下文中的所有值都将计算为truefalse。为了描述这种行为,我们通常将值称为真实或虚假。要确定值的真实性,我们可以简单地将其传递给Boolean函数:

Boolean('hi'); // => true
Boolean(0);    // => false
Boolean(42);   // => true
Boolean(0.1);  // => true
Boolean('');   // => false
Boolean(true); // => true
Boolean(null); // => false

JavaScript 中只有八个虚假值,它们都是原始类型:

  • null

  • 未定义

  • +0-0(零,一个数字)

  • false(布尔值)

  • ""(空字符串)

  • 0n(零,一个BigInt

  • NaN(不是一个数字)

因此,所有不是虚假的值都是真实的。在本章和下一章中,我们将探讨这些真实和虚假值的含义。现在,只需要知道前面的虚假值在条件或逻辑上下文中使用时会表现得像假一样。例如,当虚假值在if语句中使用时,它会像假一样行事:

if (0) {
  // This will not run. 0 is falsy.
}
if (1) {
  // This will run. 1 is truthy.
}

这些虚假值的存在意味着我们必须谨慎地检查某些条件。很容易陷入陷阱,只使用其真实性来确定存在的某个值状态。例如,假设我们需要能够检查一个人的年龄:

if (person.age) {
  processIdentity(person);
}

这是一个牵强的例子,但我们可以想象一个需要以某种方式处理个体身份的系统,也许是通过医疗应用。如果年龄恰好为 0,检查age属性的存在将不会达到预期的效果。也许系统需要适应新生儿被输入系统的可能性,但突然间因为age0而崩溃。在这种情况下,最好是预先明确,即使您不希望出现奇怪的虚假值。在这种情况下,我们可能希望检查nullundefined,因此我们应该明确这样做:

if (person.age === null || person.age === undefined) {
  processIdentity(person);
}

这段代码对age属性的可能变化更具弹性。我们也可以更符合我们的要求,仅检查我们感兴趣的特定特征,比如age属性是在特定范围内的数字。关键是在布尔上下文中,比如if语句中最好是明确的,这样您就不会遇到意外的虚假值。

数字

数字原始类型用于表示数字数据。它以双精度 64 位浮点格式(IEEE 754)存储这些数据。这里的 64 位指的是有 64 个二进制数字可用于存储信息。在 IEEE 754 标准中使用的整个 64 位格式可以分解为三个部分:

  • 数字的符号需要 1 位:表示数字是正数还是负数

  • 数字的指数需要 11 位:这告诉我们小数点的位置

  • 用于分数或有效数字的 52 位:这告诉我们整数值

浮点形式的一个副作用是,从技术上讲有两个零:正零(+0)和负零(-0)。幸运的是,在 JavaScript 中,您在检查这些值时不必明确指定。当使用严格相等运算符(+0 === -0)进行比较时,两者都将返回 true,并且都被视为假值。

从技术上讲,有 53 位可用(而不是 52)来表示整数值,因为有效数字字段的最高位位于指数字段内。这是一个重要的澄清,因为它直接影响了我们可以从 JavaScript 数字中获得多少精度。有 53 位可用于表示整数值意味着任何大于2⁵³-1的数字都被认为是不安全的。这些安全限制作为Number对象的常量可用:

  • 大于2⁵³9007199254740991Number.MAX_SAFE_INTEGER)的整数

  • 小于-2⁵³-9007199254740991Number.MIN_SAFE_INTEGER)的整数

如果我们尝试对上限进行加法,就会观察到超出这些范围的精度损失:

const max = Number.MAX_SAFE_INTEGER;
max + 1; // => 9007199254740992 (correct)
max + 2; // => 9007199254740992 (incorrect)
max + 3; // => 9007199254740994 (correct)
max + 4; // => 9007199254740996 (incorrect)
// ... etc.

在这里,我们可以看到评估的加法是不正确的。超出MAX_SAFE_INTEGER,所有数学运算都将同样不精确。

在 JavaScript 中仍然可以表示大于MAX_SAFE_INTEGER的值。可以表示多达2¹⁰²⁴Number.MAX_VALUE)的许多值,但也有许多值无法表示。因此,尝试表示超出Number.MAX_SAFE_INTEGER的数字被认为是非常不明智的。

总之,任何介于Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER之间的值都是安全的,并且将提供整数精度,而超出这些范围的值应被视为不安全。如果我们需要超出这些范围的整数,那么我们可以使用 JavaScript 的BigInt原始类型:

const max = BigInt(Number.MAX_SAFE_INTEGER)
max + 1n; // => 9007199254740992n (correct)
max + 2n; // => 9007199254740993n (correct)
max + 3n; // => 9007199254740994n (correct)
max + 4n; // => 9007199254740995n (correct)
// ... etc.

我们将在本节的后面进一步探讨BigInt原始类型。现在,只需记住始终考虑您的数字的大小以及它们是否可以完全由 JavaScript 的Number类型容纳。同样重要的是考虑小数值的精度(例如在分数中)。在 JavaScript 中表示小数时,您可能会遇到此类问题:

0.1 + 0.2; // => 0.30000000000000004

这是由浮点标准中表达分数的固有机制所致。您可以想象,如果我们有兴趣查询一个小数是否等于、大于或小于另一个值,那么使用以下代码将会非常简单:

const someValue = 0.1 + 0.2;
if (someValue === 0.3) {
  yay();
}

yay()永远不会运行。为了解决这个问题,有两个选择。第一个涉及到一个叫做 epsilon 的东西。Epsilon 是浮点数学固有的误差范围,JavaScript 使其可用作Number.EPSILON

Number.EPSILON; // => 0.0000000000000002220446049250313

这是一个非常小的数字,但如果我们希望对小数进行基本的数学运算,就必须考虑到它。如果我们希望比较两个数字,我们可以简单地将它们相互减去,并检查边际是否小于EPSILON

const someValue = 0.1 + 0.2;
if (Math.abs(someValue - 0.3) < Number.EPSILON) {
  // someValue is (effectively) equal to 0.3
}

我们可以采取的另一种方法是将我们处理的任何小数转换为由NumberBigInt类型表示的整数。因此,如果我们需要以八位小数的精度表示从01的值,那么我们可以简单地将这些值乘以100,000,000(或10⁸):

const unwieldyDecimalValue = 0.12345678;

// We can use 1e8 to express Math.pow(10, 8)
unwieldyDecimalValue * 1e8; // => 12345678

现在,我们可以自由地对这些值进行整数运算,并在完成后将它们分解为分数。需要注意的是,任何小数值超过 15 位数字都无法在 JavaScript 的Number类型中表示,因此您需要探索其他选项。JavaScript 目前没有本地的BigDecimal类型,但有许多第三方库可用来实现类似的目的(您可以轻松在网上找到这些)。

如果您发现自己需要在 JavaScript 中操作大型或非常精确的数字,或者如果您的代码涉及财务、医学或科学等敏感事项,那么完全理解您需要的精度级别以及 JavaScript 是否可以原生支持这些需求是非常重要的。

还有一个Number类型下要讨论的话题,那就是NaNNaN是一个技术上属于Number类型的原始值。它表示无法将某些东西解析为数字;例如,Number('wow')评估为NaN。由于typeof NaNnumber,我们应该以以下方式检查有效数字:

if (typeof myNumber === 'number' && !isNaN(myNumber)) {
  // Do something with your number
}

当没有预见到NaN的存在时,它可能会带来麻烦。它通常会出现在您试图将字符串转换为数字或在这种情况下隐式发生(强制转换)的地方。

我们将在下一章中更多地涵盖强制、转换和检测的主题。这将包括一个部分,我们将深入探讨NaN的复杂性,并比较全局函数isNaN()与稍有不同的Number.isNaN()。目前,重要的是要欣赏NaN是其自己独特的值,并且在 JavaScript 中奇怪地被认为是一个数字。

Number类型中封装的另一个值不是普通数字:Infinity。当您尝试进行数学运算,如除以0时,您将收到Infinity

100/0; // => Infinity

Infinity,就像NaN一样,是一个全局可用的原始值,您可以引用和检查:

100/0 === Infinity; // => true

还有-Infinity,这在技术上是一个不同的值:

100/-0; // => -Infinity
-Infinity === Infinity; // => false

Infinity,就像NaN一样,属于Number类型,因此当传递给typeof运算符时,它将被评估为"number"

typeof Infinity; // => "number"

除了Infinity-InfinityNaN之外,所有属于Number类型的值都可以被视为普通的日常数字。总的来说,对于大多数用例,Number类型非常简单易用。然而,了解它的限制是非常重要的,我们在这里涵盖了许多限制,以便您可以明智地决定何时不适合使用它。

字符串

JavaScript 中的String类型允许我们表示字符序列。它通常用于封装单词、句子、列表、HTML 和许多其他形式的文本内容。

字符串通过用单引号、双引号或反引号界定字符序列来表示:

// Single quotes:
const name = 'Titanic';

// Double quotes:
const type = "Ship";

// Template literals (back-ticks):
const report = `
  RMS Titanic was a British passenger liner that sank
  in the North Atlantic Ocean in 1912 after the ship
  struck an iceberg during her maiden voyage.
`;

只有用反引号界定的字符串,称为模板文字(或模板字符串),才能占据多行。单引号或双引号界定的字符串也可以在多行上分布,但这只能通过转义它们的不可见换行字符(使用\字符)来实现,这实际上删除了换行:

const a = "example of a \
string with escaped newline \
characters";

const b = "example of a string with escaped newline characters";

a === b; // => true

如今,模板文字被认为是首选,因为它们保留了换行,并允许我们插入任意表达式,就像这样:

const nBreadLoaves = 4;
const breadLoafCost = 2.40;

`
  I went to the market and bought ${nBreadLoaves} loaves of
  bread and it cost me ${nBreadLoaves * breadLoafCost} euros.
`

一旦您的使用超出了最简单的用例,字符串就会带来许多有趣的挑战。在表面下,这个普通的字符串掩盖了 Unicode 形式的复杂性奇迹。

Unicode 是一个行业标准,用于编码、表示和处理世界各地书写系统中使用的文本。Unicode 标准包含超过 130,000 个字符,包括所有您喜爱的表情符号。

稍微深入字符串抽象的表面,我们可以说 JavaScript 中的字符串实际上只是一系列有序的 16 位无符号整数。这些整数中的每一个都被解释为 UTF-16 代码单元。UTF-16 是 Unicode 字符集的一种编码类型。使用它,我们能够表示数十万个有效的 Unicode 代码点。这意味着我们可以通过我们的字符串来表示表情符号、许多语言和一大堆 Unicode 的奇特之处:

Unicode 代码点是一个字符(比如字母B、问号或笑脸表情符号)。我们可以通过一个或多个 UTF-16 代码单元来表示一个代码点。我们日常使用的大多数代码点只需要一个代码单元。这些被称为标量。然而,有相当多的 Unicode 代码点需要一对代码单元(称为代理对)。熊猫表情符号就是这样一个代理对的例子:

由于 UTF-16 只有 16 位可用,它必须使用一对 16 位整数来表示一些字符。自然地,如果我们使用 UTF-32 编码(有 32 位可用),那么我们将能够用一个 32 位整数来表示熊猫表情符号。

在这里,我们使用charCodeAt()来确定熊猫表情符号的单个 UTF-16 代码单元,并发现这些是 Unicode 中的第55,35756,380个十进制代码单元。由于有这么多代码单元,使用十六进制数字来表示它们更简单、更方便,因此我们可以说熊猫表情符号由代码单元U+D83DU+DC3C表示(Unicode 十六进制值通常以U+为前缀)。

除了代理对,还有另一种有用的组合类型需要了解。组合代码点可以将某些传统的非组合代码点增强为新的字符。其中的例子包括可以用重音或其他增强来增强的传统拉丁字符,比如组合波浪符:

我们选择通过 Unicode 转义序列(\u0303)来表示这个特定的组合字符。\uXXXX的格式允许我们在 JavaScript 字符串中表示U+0000U+FFFF之间的 Unicode 代码单元。

U+0000U+FFFF之间的 Unicode 范围被称为基本多文种平面BMP),包括最常用的日常字符。

我们的熊猫表情符号,正如我们已经看到的那样,是一个相当晦涩的符号。它在 BMP 上不存在,因此由两个 UTF-16 代码单元的代理对表示。我们可以通过两个 Unicode 转义序列在 JavaScript 字符串中分别表示它们:

更晦涩和古老的符号位于U+010000U+10FFFF之间的补充(或星体)平面。\uXXXX的转义格式没有足够的槽位来表示这些。星体平面内的符号需要至少五个十六进制数字来表示,因此我们必须使用更近期引入的转义序列格式\u{X}。这提供了最多六个十六进制槽位(\u{XXXXXX}),因此可以表示超过 100 万个不同的代码点。使用这种类型的转义序列,我们可以直接通过其 32 位表示(U+1F43C)来表示我们的熊猫表情符号:

新的\u{X}转义序列非常方便,使得 Unicode 比 JavaScript 更易于使用。但是还有更多复杂性需要探索。代理对和组合字符是 UTF-16 代码单元组合成单个符号的例子。此外,还有更长的序列称为图形簇。这些用于表示可以组合成一个聚合符号的代码点组合:

哇!Unicode 是一项非常了不起的工程成就,但它可能会让我们的事情变得复杂。能够以所有这些方式组合 Unicode(组合字符、代理对和图形簇)对我们来说是一个挑战。JavaScript 字符串,你可能知道,有一个length属性。这个属性返回给定字符串中代码单元的数量(即整个序列中的 16 位整数)。对于大多数字符串来说,这是直接的:

'fox'.length;   // => 3
'12345'.length; // => 5

然而,正如我们所知,我们能够组合代码单元来创建代码点,也能够组合代码点来创建图形簇。这意味着length属性,它只关注 16 位代码单元,可能会给我们带来意想不到的结果:

笑脸表情符号由两个代码单元组成,因此 JavaScript 正确告诉我们这个字符串的长度为2。但这可能不是我们期望或希望的结果。当我们处理可能使用十几个不同代码单元来表示单个符号的图形簇时,情况会更加复杂。

在 UI 中尝试仅使用其length属性截断或确定文本的宽度时要小心。由于许多 Unicode 符号可能由多个代码单元表示,仅使用length是不可靠的。

在本节中,我们探讨了 Unicode 的棘手领域。通过对它的新理解,我们现在更有能力在 JavaScript 中清晰地处理字符串。除了 Unicode 的复杂性,JavaScript 中的字符串行为相当直观,只要我们以能清晰传达意图的方式使用它们,就不应该引起太多头痛。

Boolean

JavaScript 中的Boolean原始类型用于表示truefalse。这两个极端是它唯一的值:

const isTrue = true;
const isFalse = false;

从语义上讲,布尔值用于表示现实生活或问题域的值,可以被认为是开启或关闭(01),例如,一个功能是否启用,或者用户是否超过一定年龄。这些都是布尔特征,因此适合通过布尔值来表达。我们可以使用这些值来控制程序中的控制流程:

const age = 100;
const hasLivedTo100 = age >= 100;

if (hasLivedTo100) {
  console.log('Congratulations on living to 100!');
}

Boolean原始类型,就像StringNumber一样,可以手动包装在包装实例中,如下所示:

const isTrueObj = new Boolean(true);

请注意,一旦你这样做,Boolean将会像条件语句中的任何其他对象一样行为。因此,即使包装的原始值是false,以下条件语句也会成功:

const isFalseObj = new Boolean(false);

if (isFalseObj) {
  // This will run
}

这里的Boolean实例与其原始值不等效;它只是包含其原始值。在Boolean上下文中,isFalseObj将像Boolean上下文中的任何其他对象一样,解析为true。手动包装Boolean不是特别有用的,应该在大多数程序中避免使用,因为它不符合布尔语义,可能会产生意外结果。

JavaScript 的逻辑运算符(如大于或等于(>=)或严格相等(===))返回Boolean原始值。我们将在第八章中更详细地介绍这些内容,运算符

BigInt

JavaScript 中的BigInt原始类型用于表示任意精度的整数。这意味着它可以用来表示 JavaScript 的Number类型无法精确表示的整数(大于~2⁵³)。通过在任何数字序列后缀加上n字符来声明文字 BigInt,如下所示:

100007199254740991n

BigInt能够表示任意精度的整数,这意味着你可以存储任意长度的整数。这在金融应用程序或任何需要表达和操作高精度整数的情况下特别有用。

BigInt只能对自身进行操作,因此与 JavaScript 的许多原生Math方法不兼容:

Math.abs(1n); // !! TypeError: Cannot convert a BigInt value to a number

只要两个操作数的类型相同,所有原生数学运算符都可以与BigInt一起使用:

(1n + (2n * 3n)) + 4n; // => 11n

但是,如果一个操作数是BigInt,另一个是Number,那么你将收到一个TypeError

1n + 1; // !! TypeError: Cannot mix BigInt and other types, use explicit conversions

BigInt的语义与Number类似:任何直观数值且可以表示为整数的值都可以存储在BigIntNumber中,具体取决于它所需的精度。

符号

Symbol原始类型用于表示完全独特的值。通过调用Symbol函数创建符号,如下所示:

const totallyUniqueKey = Symbol();

你可以选择向这个函数传递一个初始参数,以便为你自己的调试目的注释你的符号,但这并不是必要的:

const totallyUniqueKey = Symbol('My Special Key');

符号用作属性键,需要唯一性,或者想要在对象上存储元数据。当你使用Symbol键向对象添加属性时,它不会被普通的对象迭代方法(如for...in)迭代。对象的Symbol键只能通过Object.getOwnPropertySymbols来检索:

const thing = {};
thing.name = 'James';
thing.hobby = 'Kayaking';
thing[Symbol(999)] = 'Something else entirely';

for (let key in thing) console.log(key);
// => "name"
// => "hobby"

const symbols =
  Object.getOwnPropertySymbols(thing); // => [Symbol(999)]

thing[symbols[0]]; // => "Something else entirely"

由于Symbol键以显式但隐藏的方式存在,它们对于存储程序信息在语义上是有用的,这些信息与对象的核心数据无关,但在满足某些程序需求时很有用。例如,你可能有一个日志记录库,并希望用特定方式记录的自定义渲染函数注释特定对象。这样的需求可以很容易地通过符号来实现:

const log = thing => {
 console.log(
   thing[log.CUSTOM_RENDER] ?
     thinglog.CUSTOM_RENDER :
     thing
 );
};
log.CUSTOM_RENDER = Symbol();

class Person {
 constructor(name) {
   this.name = name;
   this[log.CUSTOM_RENDER] = () => {
     return `Person (name = ${this.name})`;
   };
 }
}

log(123); // => Logs "123"
log(new Person('Sarah')); // => Logs: "Person (name = Sarah)"
log(new Person('Wally')); // => Logs: "Person (name = Wally)"
log(new Person('Julie')); // => Logs: "Person (name = Julie)"

并不是很多日常情况下需要创建和使用新符号,但有很多情况下需要通过这些符号来规定原生行为。例如,你可以使用Symbol.iterator属性为你的对象定义一个自定义迭代器。我们将在本章后面的数组和可迭代对象部分详细介绍这一点。

null

null原始类型用于表示有意的值的缺失。它是一个只有一个值的类型:唯一的 null 值是null

null的语义与undefined有着根本的不同。undefined值用于指示未声明或未定义的内容,而null是一个明确声明的缺失值。我们通常使用null值来表示一个值要么明确尚未设置,要么由于某种原因不可用。

例如,让我们考虑一个 API,我们在其中指定与餐厅评论相关的各种属性:

setRestaurantFeatures({
  hasWifi: false,
  hasDisabledAccess: true,
  hasParking: null
});

在这种情况下,null值表示我们不知道hasParking的值。当我们有必要的信息时,我们可以将hasParking指定为truefalseBoolean),但为了表示我们对其真实值的无知,我们将其设置为null。我们也可以完全省略该值,这意味着它实际上是undefined。关键区别在于使用null总是主动进行的,而undefined是某事没有完成的结果。

如前所述,null值始终是假值,这意味着在Boolean上下文中它将始终计算为false。因此,如果我们尝试在条件语句中使用null,那么它将不会成功:

function setRestaurantFeatures(features) {
  if (features.hasParking) {
    // This will not run as hasParking is null
  }
} 

重要的是要检查我们想要的确切值,这样我们可以避免错误并有效地向阅读我们代码的人传达信息。在这种情况下,我们可能希望明确检查undefinednull,因为我们想要针对这种情况执行不同的代码,而不是针对false的情况。我们可以这样做:

if (features.hasParking !== null && features.hasParking !== undefined) {
  // hasParking is available...
} else {
  // hasParking is not set (undefined) or unavailable (null)
}

我们还可以使用抽象相等运算符(==)来与null进行比较,如果操作数是nullundefined,它将有用地评估为true

if (features.hasParking != null) {
  // hasParking is available...
} else {
  // hasParking is not set (undefined) or unavailable (null)
}

事实上,这与更明确的比较是一样的,但更加简洁。不幸的是,它并不清楚它的意图是检查nullundefined。通常我们应该更加明确,因为这样可以更有效地向其他程序员传达我们的意图。

要避免的最后一个陷阱是nulltypeof运算符。由于 JavaScript 语言的一些遗留问题,typeof null会返回"object",因此完全不可靠。

有关typeof和检测null类型的更多信息可以在第七章的动态类型中的检测部分找到。

所以,你已经知道了。null是一个足够简单的值,在干净的代码方面,只要记住两个关键点就不会出错:它只应该用来表示有意识地缺少一个值,并且最好明确地检查它(最好使用value === null)。

undefined

undefined原始类型表示某物尚未被定义或仍然未定义。与null一样,它是一个只有一个值(undefined)的类型。与null不同,undefined值不应该被明确设置,但当某物没有值时语言可能会返回它:

const coffee = {
  type: 'Flat White',
  shots: 2
};

coffee.name; // => undefined
coffee.type; // => "Flat White"

未定义最好被认为是某物的缺失。如果你发现自己希望明确地将某物设置为undefined,你应该考虑使用null代替。

重要的是要区分未定义和甚至未声明的概念。在 JavaScript 中,如果你尝试评估一个在你的范围内不存在的标识符,你会得到一个ReferenceError

thisDoesNotExist; // !! ReferenceError: thisDoesNotExist is not defined

然而,正如你已经看到的,如果你尝试评估一个对象的属性,而该属性不存在,你将不会得到任何错误。相反,它将评估为undefined

const obj = {};
obj.foo; // => undefined

然而,如果你尝试访问不存在的foo属性下的属性,你将收到一个TypeError,抱怨它无法读取一个具有undefined值的属性:

obj.foo.baz; // !! TypeError: Cannot read property 'baz' of undefined

这种行为是寻求访问undefinednull值的任何属性时总是会抛出TypeError的扩展:

(undefined).foo;  // !! TypeError: Cannot read property 'foo' of undefined

有趣的是,与null不同,undefined值不是一个字面量,而是语言提供的一个全局可用的值。在 ECMAScript 2015 及以后的版本中不可能覆盖这个全局值,但在本地(非全局)范围内定义自己的undefined标识符的值仍然是可能的:

undefined; // => undefined

function weird() {
  let undefined = 1;
  undefined; // => 1
}

这是一种反模式,因为它可能会产生非常尴尬和意想不到的结果。在比你的范围更高的范围意外设置undefined可能意味着,如果你依赖于该值,你最终可能会引用一个不是undefined的值。对undefined值的不信任在历史上意味着人们已经找到其他方法来强制在他们的范围内使undefined可用。例如,声明一个变量但不给它赋值将始终导致它的值为undefined

function scopeWithReliableUndefined() {
  let undefined;
  undefined; // => undefined
}

你还可以对任何值使用 JavaScript 的void运算符,它将始终返回realundefined值:

void 0;         // => undefined
void null;      // => undefined
void undefined; // => undefined

在你的范围内明确设置未定义意味着你可以安全地引用你的undefined值,而不必担心它已被破坏。然而,幸运的是,你可以通过使用typeof运算符来避免担心这种风险的痛苦:

if (typeof myValue === 'undefined') { ... }

即使myValue不存在,这也不会抛出ReferenceError。正如我们已经发现的那样,typeof运算符与null一样,有时我们不能总是依赖它,但当明确检查undefined时,它仍然非常有用。

避免undefined的另一种方法是通过使用 linting 工具在代码库中强制正确使用它。我们将在第十五章中介绍 linting 工具,更干净的代码的工具

总之,如果记住以下两点,可以干净地使用undefined

  • 避免直接将undefined分配给变量;您应该使用null代替

  • 始终明确检查undefined,优先使用typeof运算符

这结束了我们对 JavaScript 中原始类型的探索。现在,我们将转向非原始类型,也就是对象。

对象

在 JavaScript 中,除了原始值之外的所有内容都可以视为对象。甚至函数实际上也是专门的对象;它们唯一的区别在于它们可以被调用。然而,通常情况下,当我们使用术语对象时,我们指的是通常以花括号括起来的对象文字声明的普通对象,其中包含一组键值对:

const animal = {
  name: 'Duck',
  hobby: 'Paddling'
};

您还可以通过Object构造函数实例化对象,然后直接添加属性:

const animal = new Object();
animal.name = 'Duck';
animal.hobby = 'Paddling';

尽管它们是等效的,但在大多数情况下最好使用对象文字,因为它更简单声明和阅读,特别是如果有许多属性。它还具有一个额外的好处,即允许您创建并传递对象作为表达式,而无需事先准备。

属性名称

用于向对象添加属性(属性名称)的键内部存储为字符串。但是,当使用对象文字语法时,可以将键声明为常规标识符(即,任何您可以用作变量名的内容)、数字文字或字符串文字:

const object = {
  foo: 123,   // Using an identifier as the key
  "baz": 123, // Using a String literal as the key
  123: 123    // Using a Number literal as the key
};

最好尽可能使用标识符,因为这有助于限制您使用可以轻松访问为属性的键名。如果您使用的是不是有效标识符的字符串文字,那么您将不得不使用方括号表示法来访问它,这可能会很麻烦:

const data = {
  hobbies: ['tennis', 'kayaking'],
  'my hobbies': ['tennis', 'kayaking']
};

data.hobbies;       // Easy
data['my hobbies']; // Burdensome

您还可以使用计算属性名称(用方括号括起来)将动态命名的项添加到对象文字中:

const data = {
  ['item' + (1 + 2)]: 'foo'
};

data; // => { item3: "foo" }
data.item3; // => "foo"

正如我们之前提到的,JavaScript 中的所有非原始值在技术上都是对象。但是,还有什么使某物成为对象呢?对象允许我们将任意值分配给它们作为属性,这是原始值无法做到的。除了这一特征之外,JavaScript 中对象的定义留下了令人愉快的泛化。我们可以以许多不同的方式使用对象来适应我们正在编写的代码。许多语言将为字典或哈希映射提供语言构造。在 JavaScript 中,我们可以使用对象来满足这些需求的大部分。当我们需要存储键值对,其中键不是字符串时,通常通过对象的toString方法提供该值的字符串表示:

const me = {
  name: 'James',
  location: 'England',
  toString() {
    return [this.name, this.location].join(', ')
  }
};

me.toString(); // => "James, England"
String(me); // => "James, England"

当对象被放置在强制转换为字符串的上下文中时,将在内部调用此方法,例如通过方括号表示法进行访问或分配:

const peopleInEurope = {};

peopleInEurope[me] = true;
Object.keys(peopleInEurope); // => ["James, England"]
peopleInEurope[me]; // => true

这在历史上曾被用于允许实现数据结构,其中键实际上是非原始的(尽管对象在技术上将属性名称存储为字符串)。然而,如今更倾向于使用MapWeakMap

属性描述符

以常规方式向对象添加属性,无论是通过属性访问还是通过对象文字,属性都将具有以下隐式特征:

  • configurable:这意味着属性可以从对象中删除(如果其属性描述符可以更改)

  • enumerable:这意味着属性将对for...inObject.keys()等枚举可见

  • writable:这意味着可以通过赋值运算符(例如obj.prop = ...)更改属性的值

JavaScript 赋予你关闭这些特性的权力,但要注意,对这些特性的更改可能会使代码的行为变得模糊。例如,如果一个属性被描述为不可写,但尝试通过赋值进行写入(例如,obj.prop = 123),那么程序员将收到没有发生写入的警告。这可能会导致意外和难以找到的错误。因此,牢记将要使用你的接口的程序员的期望是至关重要的。因此,你要小心谨慎地保留属性描述符。

你可以通过原生提供的Object.defineProperty()为给定的属性定义自己的特性。在设置新属性描述符时,每个特性的默认值将为false,因此,如果希望给属性赋予configurableenumerablewritable的特性,则需要明确指定这些特性为true

const myObject = {};

Object.defineProperty(myObject, 'name', {
  writeable: false,
  configurable: false,
  enumerable: true,
  value: 'The Unchangeable Name'
});

myObject.name; // => "The Unchangeable Name"
myObject.name = 'something else'; // => (Ineffective)
myObject.name; // => "The Unchangeable Name"

delete myObject.name; // => false (Ineffective)
myObject.name; // => "The Unchangeable Name"

你也可以使用Object.defineProperties()一次描述多个属性:

const chocolate = Object.defineProperties({
  // Empty object where our described properties
  // will be placed
}, {
 name: { value: 'Chocolate', enumerable: false },
 tastes: { value: ['Bitter', 'Sweet'], enumerable: true }
});

chocolate.name; // => "Chocolate"
chocolate.tastes; // => ["Bitter", "Sweet"]

Object.keys(chocolate); // => ["tastes"]

如果尝试更改具有configurable设置为false的属性的特性,则会收到TypeError

const obj = {};

Object.defineProperty(
 obj,
 'timestamp',
 { configurable: false, value: Date.now() }
);

Object.defineProperty(
  obj,
  'timestamp',
  { configurable: true }
);
// ! TypeError: Cannot redefine property: timestamp

还可以设置自定义的 setter 和 getter。*getter 定义了在访问属性时将返回的值,而 setter 将定义在尝试对该属性进行赋值时发生的情况(即通过赋值运算符)。在希望以独特方式保存值或在赋值时对值进行过滤或处理的情况下,使用这些功能可能很有用,例如:

const data = Object.defineProperties({}, {
  name: {
    set(name) { this.normalizedName = name.toLowerCase(); },
    get() { return this.normalizedName; }
  }
});

data.name = 'MoLLy BroWn';
data.name; // => "molly brown"

由于name属性是通过defineProperties描述的,它将禁用所有默认特性,这意味着它不可枚举,不可写,也不可配置。如果我们尝试枚举它,我们会发现我们内部使用的normalizedName被找到了:

Object.keys(data); // => ["normalizedName"]

在处理属性描述符时要牢记这一点。确保你了解每个属性具有什么特性,并注意内部实现的泄漏!

值得注意的是,也可以(通常更可取)在对象文字或类定义中直接为属性定义 getter 和 setter。例如,我们可以创建一个Array的子类,添加一个last属性,该属性充当数组中最后一个元素的 getter:

class SpecialArray extends Array {
  get last() { return this[this.length - 1]; }
}

const myArray = new SpecialArray('a', 'b', 'c', 'd');
myArray.last; // => "d"
myArray.push('e');
myArray.last; // => "e"

有许多这样创造性的 getter 和 setter 的用法。但是,与configurableenumerablewritable的特性一样,重要的是要谨慎考虑你的自定义行为将如何影响你的同行程序员的期望。如果你创建的抽象或数据结构在行为上不熟悉或不可预测,那么你就为误解和错误铺平了道路。最好的方法是与语言本身的自然语义保持一致。因此,每当你要创建一个自定义 setter 或将属性描述为不可写时,请问自己程序员是否可以合理地期望它以这种方式工作。遵循一个有帮助的规则,被称为最少惊讶原则POLA)!

POLA(或最少惊讶)适用于软件设计和 UX 设计。它广泛意味着系统的给定功能或组件应该像大多数用户期望的那样行事,并且不应该过于惊讶或使人惊讶。

Map 和 WeakMap

MapWeakMap抽象能够存储键值对,其中,与常规对象不同,键可以是任何东西,包括非原始值:

const populationBySpecies = new Map();
const reindeer = { name: 'Reindeer', formalName: 'Rangifer tarandus' };

populationBySpecies.set(reindeer, 2000000);
populationBySpecies.get(reindeer); // => 2,000,000

WeakMap类似于Map,但它只保留对用作键的对象的弱引用,这意味着,如果由于在程序的其他位置进行垃圾回收而使对象不可用,那么WeakMap将停止保持它。

大多数情况下,普通对象就足够了。只有在需要键为非原始类型或者想要弱引用值时,才应该使用MapWeakMap

原型

JavaScript 是一种原型语言,继承是通过原型实现的。这可能是一个令人生畏的概念,但实际上非常简单。JavaScript 的原型行为可以描述如下:每当在对象上访问属性时,如果该属性在对象本身上不可用,JavaScript 将尝试在内部可用的[[Prototype]]属性上访问它。然后它将重复这个过程,直到找到属性或到达原型链的顶部并返回undefined

了解[[Prototype]]属性的功能将使您对语言有很大的掌握,并且会立即使 JavaScript 变得不那么令人生畏。这可能很难理解,但最终是值得的。

[[Prototype]]对象本身实际上就是一个普通对象,可以合理地附加到任何其他对象上。我们可以创建一个称为engineerPrototype的对象,并使其包含与工程师角色相关的数据和方法,例如:

const engineerPrototype = {
  type: 'Engineer',
  sayHello() {
    return `Hello, I'm ${this.name} and I'm an ${this.type}`;
  }
};

然后,我们可以将这个原型附加到另一个对象上,从而使其属性也在那里可用。为此,我们使用Object.create(),它创建一个带有硬编码[[Prototype]]的新对象:

const pandaTheEngineer = Object.create(engineerPrototype);

内部的[[Prototype]]属性不能直接设置,因此我们必须使用Object.createObject.setPrototypeOf等机制。请注意,您可能已经看到使用非标准的__proto__属性来设置[[Prototype]]的代码,但这是一个遗留特性,不应依赖它。

有了这个新创建的pandaTheEngineer对象,我们可以访问其[[Prototype]]上可用的任何属性,比如engineerPrototype

pandaTheEngineer.name = 'Panda';
pandaTheEngineer.sayHello(); // => "Hello, I'm Panda and I'm an Engineer"

我们可以通过向engineerPrototype添加新属性来说明这些对象现在是链接在一起的,并观察它如何在pandaTheEngineer上可用:

pandaTheEngineer.sayGoodbye; // => TypeError: sayGoodbye is not a function
engineerPrototype.sayGoodbye = () => 'Goodbye!';
pandaTheEngineer.sayGoodbye(); // => 'Goodbye!'

正如我们之前提到的,如果对象本身上没有可用的属性,[[Prototype]]的属性将被用于解析属性。以下代码显示了我们如何在pandaTheEngineer对象上设置自己的sayHello方法,这样一来,我们就不再可以访问[[Prototype]]上定义的sayHello方法:

pandaTheEngineer.sayHello = () => 'Yo!';
pandaTheEngineer.sayHello(); // => "Yo!"

然而,删除这个新添加的sayHello方法意味着我们再次可以访问[[Prototype]]上的sayHello方法:

delete pandaTheEngineer.sayHello;
pandaTheEngineer.sayHello(); // => // => "Hello, I'm Panda and I'm an Engineer"

为了理解发生了什么以及哪些属性来自哪个对象,我们始终可以使用Object.getPrototypeOf来检查对象的[[Prototype]]

// We can inspect its prototype:
Object.getPrototypeOf(pandaTheEngineer) === engineerPrototype; // => true

现在,我们可以通过Object.getOwnPropertyNames检查它的属性:

Object.getOwnPropertyNames(
  Object.getPrototypeOf(pandaTheEngineer)
); // => ["type", "sayHello", "sayGoodbye"]

在这里,我们可以看到[[Prototype]]对象(即engineerPrototype)提供了typesayHellosayGoodbye属性。如果我们检查pandaTheEngineer对象本身,我们会发现它只有一个name属性:

Object.getOwnPropertyNames(pandaTheEngineer); // => ["name"]

正如我们之前添加sayGoodbye方法时观察到的,我们可以随时修改该原型,并且我们的更改将对使用该原型的任何对象可用。这里是另一个这样做的例子:

// Modify the prototype object:
engineerPrototype.type = "Awesome Engineer";

// Call a method on our object (that uses the prototype):
pandaTheEngineer.sayHello(); // => "Hello, I'm Panda and I'm an Awesome Engineer"

在这里,您可以看到我们继承的sayHello方法是如何生成一个包含我们变异的类型属性(即"Awesome Engineer")的字符串。

希望您开始看到我们如何使用原型构建继承层次结构。[[Prototype]]的非常简单的机制允许我们在对象表示的问题域之间表达复杂的层次关系。这就是 JavaScript 中实现 OOP 的方式。

我们可以合理地创建另一个原型,它本身使用engineerPrototype,可能是fullStackEngineerPrototype,并且它将按预期工作,每个原型定义另一层属性解析。

JavaScript 的新类定义语法在表面之下,你可能已经习惯了,依赖于原型的这种基本机制。这可以在这里观察到:

class Engineer {
  type = 'Engineer'
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    return `Hello, I'm ${this.name} and I'm an ${this.type}`;
  }
}

const pandaTheEngineer = new Engineer();

Object.getOwnPropertyNames(pandaTheEngineer); // => ["type", "name"]

Object.getOwnPropertyNames(
  Object.getPrototypeOf(pandaTheEngineer)
); // => ["constructor", "sayHello"]

你会注意到这里有一些细微的差别。最关键的一个是,在声明类时,目前没有办法在原型对象上定义非方法属性。当我们声明type属性时,我们正在填充实例本身,所以当我们检查实例的属性时,我们得到"type""name"。然而,方法(比如sayHello)将存在于[[Prototype]]上。另一个区别是,当使用类时,我们能够声明一个constructor,它本身是[[Prototype]]上的一个方法/属性。

基本上,类定义语法(在ECMAScript 2015中引入)并没有使语言中已经存在的任何东西成为可能。它只是利用了现有的原型机制。然而,新的语法确实使一些事情变得更简单,比如使用super关键字引用超类。

在类定义存在之前,我们通常通过将我们预期的[[Prototype]]对象分配给函数的prototype属性来编写类似类的抽象,如下所示:

function Engineer(name) {
  this.name = name;
}

Engineer.prototype = {
  type: 'Engineer',
  sayHello() {
    return `Hello, I'm ${this.name} and I'm an ${this.type}`;
  }
};

当一个函数通过new运算符实例化时,如果有的话,JavaScript 将隐式地创建一个[[Prototype]]设置为函数的prototype属性的新对象。让我们尝试实例化Engineer函数:

const pandaTheEngineer = new Engineer();

检查这个结果会得到我们在原始Object.create方法中看到的相同特征:

Object.getOwnPropertyNames(pandaTheEngineer); // => ["name"]

Object.getOwnPropertyNames(
  Object.getPrototypeOf(pandaTheEngineer)
); // => ["type", "sayHello"]

总的来说,所有这些方法都是相同的,但在某些属性所在的位置上有一些细微的差别(即,它的属性是在实例本身上还是在它的[[Prototype]]上)。新的类定义语法很有用且简洁,因此现在更受欢迎,但了解原型工作的基本知识仍然很有用,因为它驱动着整个语言,包括所有的原生类型。我们可以像在前面的代码中一样检查这些原生类型:

const array = ['wow', 'an', 'array'];

Object.getOwnPropertyNames(array); // => ["0", "1", "2", "length"]

Object.getOwnPropertyNames(
  Object.getPrototypeOf(array)
); // => ["constructor", "concat", "find", "findIndex", "lastIndexOf", "pop", "push", ...]

变异原生原型是一种反模式,应该尽量避免,因为它可能会在代码库中与其他代码产生意外的冲突。由于运行时只有一个集合的原生类型可用,当你修改它们时,你正在修改当前存在的该类型的每个实例的能力。因此,最好遵守一个简单的规则:只修改你自己的原型

如果你发现自己试图修改一个原生原型,最好是创建该类型的自己的子类,并在那里添加你的功能:

class HeartArray extends Array {
  join() {
    return super.join(' ❤ ');
  }
}

const yay = new HeartArray('this', 'is', 'lovely');

yay.join(); // => "this ❤ is ❤ lovely"

在这里,我们正在创建我们自己的Array子类,称为HeartArray,以便我们可以添加我们自己专门的join方法。

何时以及如何使用对象

任何类型的对象,就像我们的原始值一样,应该只与它所代表的语义概念一起使用。将Array子类化为HeartArray的前面案例是有意义的,因为我们希望通过它来表达的数据确实类似于数组,也就是说,它是一组顺序的单词。

当我们开始将对象塑造成适合我们需求的抽象时,我们应该始终考虑其他程序员对对象的期望以及这些期望的后果。我们将在第十一章中深入探讨设计良好的抽象的微妙之处,那里我们将利用对象以多种方式来构建抽象。

本节介绍了 JavaScript 中对象的概念——它们无处不在——以及它们是如何通过原型在表面之下运作的。这些基本知识将使你更容易地使用 JavaScript,并帮助你编写更干净的代码。

函数

在 JavaScript 中,函数就像任何其他类型一样;它们可以像对象和原始类型一样传递。然而,当我们谈论大多数其他值时,我们会发现通常只有一种方式来声明它们。对象文字使用大括号声明。数组文字使用方括号分隔。然而,函数以各种文字形式出现。

在对象文字或类定义之外,可以以三种不同的方式声明函数:作为函数声明,作为函数表达式,或作为一个箭头函数表达式:

// Function Declaration
function myFunction() {}

// Function Expression
const myFunction = function () {};

// Named Function Expression
const myFunction = function myFunction() {};

// "Fat"-Arrow Function Expression
const myFunction = () => {};

然而,在对象文字中声明函数有一种更简洁的语法,称为方法定义

const things = {
  myMethod() {},
  anotherMethod() {}
};

我们需要用逗号分隔这些方法定义(就像我们必须对对象文字中定义的任何其他属性做的那样)。类定义也允许我们使用方法定义,尽管它们不需要分隔逗号:

class Thing {
  myMethod() {}
  anotherMethod() {}
}

方法只是在调用时绑定到对象的函数。这包括在类定义内部定义的函数和以任何方式分配给对象属性的函数。然而,在讨论代码时,了解人们说方法函数时的含义是有用的。然而,从根本上讲,JavaScript 的语言并不区分它们——它们在技术上都只是函数。

定义函数的各种方式都有微妙的差异,值得了解,因为典型的 JavaScript 代码库将使用大多数,如果不是所有这些风格。您将遇到的函数声明的差异类型包括以下内容:

  • 定义风格是否提升到其作用域的顶部;例如,函数声明

  • 定义风格是否创建具有自己绑定的函数(例如,this);例如,函数表达式

  • 定义风格是否创建具有自己name属性的函数;例如,函数声明

  • 定义风格是否与代码的特定区域相关;例如,方法定义

现在,我们可以更详细地讨论各种定义风格的语法。

语法上下文

函数可以存在于三种语法上下文中:

  • 作为一个声明

  • 作为一个表达式

  • 作为方法定义

语句可以被视为脚手架。例如,const X = 123是一个包含const声明和赋值的语句表达式可以被视为您放入脚手架中的值;例如,后一个语句中的123是一个表达式。在第九章中,语法和作用域的部分,我们将更详细地讨论这个主题。

函数作为语句和函数作为表达式之间的区别体现在函数表达式和函数声明上。函数声明非常独特,因为它是声明函数的唯一方式。要被视为函数声明,function name() {}的语法必须独立存在,不能用在表达式的上下文中。这可能非常令人困惑,因为你不能仅仅根据函数自身的语法来判断函数是函数声明还是函数表达式;相反,你必须看它存在的上下文:

// This is a statement, and a function declaration:
// And will therefore be hoisted:
function wow() {}

// This is a statement containing a function expression:
const wow = function wow() {};

正如我们之前提到的,函数表达式允许有一个名称,就像函数声明一样,但该名称可能与分配给函数的变量的名称不匹配。

最容易将表达式视为任何可以合法存在于赋值运算符的右侧的东西。以下所有的右侧都是合法的表达式:

foo = 123;
foo = [1,2,3];
foo = {1:2,3:4};
foo = 1 | 2 | 3;
foo = function() {};
foo = (function(){})();
foo = [function(){}, ()=>{}, function baz(){}];

函数表达式在语法上与 JavaScript 中的所有其他值一样灵活。我们将发现,函数声明是有限制的。方法定义也受限于存在于对象字面量或类定义的范围内。

函数绑定和 this

函数的绑定指的是 JavaScript 在函数体内提供的一组额外和隐式值的引用。这些绑定包括以下内容:

  • thisthis关键字指的是函数调用的执行上下文

  • super:方法或构造函数中的super关键字指的是其超类

  • new.target:此绑定告诉您函数是否是通过new运算符作为构造函数调用的

  • arguments:此绑定提供了对在调用函数时传递的参数的访问

这些绑定对所有函数都可用,除了使用箭头语法定义的函数(fn = () => {})。以这种方式定义的函数将有效地吸收父作用域的绑定(如果有的话)。每个绑定都有独特的行为和约束。我们将在以下子节中探讨这些内容。

执行上下文

this关键字通常在函数调用时确定,并且通常会解析为函数被调用的对象。它有时被称为函数的执行上下文或thisArg。这可能不直观,因为这意味着this值在调用之间可以在技术上发生变化。例如,我们可以将一个对象的方法分配给另一个对象,然后在第二个对象上调用它,并观察到它的this始终是调用它的对象:

const london = { name: 'London' };
const tokyo = { name: 'Tokyo' };

function sayMyName() {
  console.log(`My name is ${this.name}`);
}

sayMyName(); // => Logs: "My name is undefined"

london.sayMyName = sayMyName;
london.sayMyName(); // => Logs "My name is London"

tokyo.sayMyName = sayMyName;
tokyo.sayMyName(); // => Logs "My name is Tokyo"

当没有调用对象时,例如我们直接调用sayMyName时,它的假定执行上下文是代码所在的全局环境。在浏览器中,这个全局环境等同于 window 对象(提供对浏览器和文档对象模型的访问),而在 Node.js 中,this 指的是每个特定模块/文件独有的环境,其中包括该模块的 exports 等内容。

除了在全局调用函数的情况下,还有两种情况下this关键字会是除了明显的调用对象之外的东西:

  • 如果被调用的函数被定义为箭头函数,那么它将吸收所在作用域的this

  • 如果被调用的函数是构造函数,它的this值将是一个新对象,其[[Prototype]]预设为函数的原型属性

在调用或声明函数时,也有一些方法可以强制this的值。您可以使用bind(X)来创建一个新函数,其this值设置为X

const sayHelloToTokyo = sayMyName.bind(tokyo);
sayHelloToTokyo(); // => Logs "My name is Tokyo"

您还可以使用函数的callapply方法来强制任何给定调用的this值,但请注意,如果函数被调用为构造函数(即使用new关键字)或者使用箭头函数语法定义,则这将不起作用:

// Forcing the value of `this` via `.call()`:
tokyo.sayMyName.call(london); // => Logs "My name is London"

在日常函数调用中,最好避免像这样的奇怪调用技术。这些技术可能会使您的代码的读者难以理解发生了什么。使用callapplybind进行调用有许多有效的应用,但这些通常局限于较低级别的库或实用程序代码。高级逻辑应该避免使用它们。如果您发现自己在高级抽象中不得不依赖这些方法,那么您可能正在使事情变得比必要的更加复杂。

super

super 关键字有三种不同的用法:

  • super()作为直接函数调用将调用超类的构造函数(即其对象的[[Prototype]]构造函数),并且只能在构造函数中调用。它还必须在尝试访问this之前调用,因为是super()本身将启动执行上下文。

  • super.property将访问超类的属性(即[[Prototype]]),并且只能在使用方法定义语法定义的构造函数或方法中引用。

  • super.method()将调用超类的方法(即[[Prototype]]),并且只能在构造函数或使用方法定义语法定义的方法中调用。

super关键字是在语言中引入的同时,类定义和方法定义语法也一起引入的,因此它与这些结构有关。您可以在类构造函数、方法以及对象文字中的方法定义中自由使用super

const Utils {
  constructor() {
    super(); // <= I can use super here
  }
  method() {
    super.method(); // <= And here...
  }
}

const utils = {
  method() {
    return super.property; // <= And even here...
  }
};

super关键字,正如其名称所示,语义上适合引用超类,因此它的 99%有效用例将在类定义中,您希望引用被扩展的类时使用,如下所示:

const Banana extends Fruit {
  constructor() {
    super(); // Call the Fruit constructor
  }
}

以这种方式使用super是完全直观的,特别是对于习惯于其他面向对象编程语言的程序员。然而,对于精通 JavaScript 原型机制的人来说,super的实现可能会令人困惑。与this值不同,super在定义时绑定,而不是在调用时。我们已经看到了如何通过以特定方式调用方法(例如使用fn.call())来操纵this的值。您不能类似地操纵super。希望这不会以任何方式影响您,但是记住这一点也是有用的。

new.target

new.target绑定将等于当前被调用的函数,如果函数是通过new运算符调用的。我们通常使用new运算符来实例化类,在这种情况下,我们将正确地期望new.target是该类:

class Foo {
  constructor() {
    console.log(new.target === Foo);
  }
}
new Foo(); // => Logs: true

当我们希望在直接调用构造函数与通过new调用时执行某种行为时,这是有用的。一个常见的防御策略是使您的构造函数以相同的方式行为,无论是使用还是不使用new调用。这可以通过检查new.target来实现:

function Foo() {
  if (new.target !== Foo) {
    return new Foo();
  }
}

new Foo() instanceof Foo; // => true
Foo() instanceof Foo;     // => true

或者,您可能希望抛出错误以检查构造函数是否被错误调用:

function Foo() {
  if (new.target !== Foo) {
    throw new Error('Foo is a constructor: please instantiate via new Foo()');
  }
}

Foo() instanceof Foo; // !! Error: Foo is a constructor: please instantiate via new Foo()

这两个示例都被认为是new.target的直观用例。当然,也有可能根据调用模式提供完全不同的功能,但为了满足程序员的合理期望,最好避免这种行为。记住 POLA。

arguments

arguments绑定作为一个类似数组的对象提供,并且将包含给定函数调用时使用的参数。

当我们说arguments类似于数组时,我们指的是它具有length属性和从零开始索引的属性(就像普通的Array一样),但它仍然只是一个普通的Object,因此没有任何数组的内置方法可用,例如forEachreducemap

在这里,我们可以观察到参数是在给定函数的范围内提供的:

function sum() {
  arguments; // => [1, 2, 3, 4, 5] (Array-like object)
  let total = 0;
  for (let n of arguments) total += n;
  return total;
}

sum(1, 2, 3, 4, 5);

arguments绑定曾经被广泛用于访问任意(即非固定)数量的参数,尽管在语言引入了rest 参数语法(...arg)后,其实用性迅速消失。这种更新的语法可以在定义函数时使用,指示 JavaScript 将剩余参数放入一个数组中。这意味着您可以实现所有旧的arguments绑定的实用性,而且您将获得一个不仅仅是类似数组而且实际上是真正数组的值。以下是一个示例:

function sum(...numbers) {
  // We can call reduce() on our array:
  return numbers.reduce((total, n) => total + n, 0);
}

sum(1, 2, 3, 4, 5);

尽管arguments对象已经不再流行,但它仍在语言规范中,并在旧环境中工作,因此你可能仍然会在实际中看到它。大多数情况下,可以避免使用它。

函数名称

令人困惑的是,函数有名称,这些名称与我们分配给函数的变量或属性不同。函数的名称在其括号之前的语法中:

function nameOfTheFunction() {}

您可以通过其name属性访问函数的名称:

nameOfTheFunction.name; // => "nameOfTheFunction"

当您通过函数声明语法定义函数时,它将将该函数分配给同名的局部变量,这意味着我们可以像预期的那样引用该函数:

function nameOfTheFunction() {}
nameOfTheFunction; // => the function
nameOfTheFunction.name; // => "nameOfTheFunction"

方法定义也将方法分配给等于函数名称的属性名称:

function nameOfTheFunction() {}
nameOfTheFunction; // => the function
nameOfTheFunction.name; // => "nameOfTheFunction"

你可能会认为这一切看起来非常直观。的确如此。我们给函数和方法的名称本身用来指示那些东西将被分配给什么变量或属性是完全合理的。然而,奇怪的是,也可以有命名函数表达式,而这些名称并不会导致这样的分配。以下是一个例子:

const myFunction = function hullaballoo() {}

这里的const名称myFunction决定了我们将在随后的行中用来引用函数。然而,函数在技术上有一个名为"hullaballoo"的名称:

myFunction; // => the function
myFunction.name; // => "hullaballoo"

如果我们尝试通过其正式名称引用函数,将会出错:

hullaballoo; // !! ReferenceError: hullaballoo is not defined

这可能看起来很奇怪。如果函数的名称本身不用于引用函数,为什么可以给函数命名?这是一种遗留和便利的混合。命名函数表达式的一个隐藏特性是,名称实际上是可用于引用函数的,但只能在函数本身的范围内:

const myFunction = function hullaballoo() {
  hullaballoo; // => the function
};

这在您想要为某个其他函数提供一个匿名回调,但仍然能够引用您自己的回调以进行任何重复或递归调用的情况下非常有用,如下所示:

[
  ['chris', 'smith'],
  ['sarah', ['talob', 'peters']],
  ['pam', 'taylor']
].map(function capitalizeNames(item) { 
  return Array.isArray(item) ?
    item.map(capitalizeNames) :
    item.slice(0, 1).toUpperCase() + item.slice(1);
});

// => [["Chris","Smith"],["Sarah",["Talob", "Peters"]],["Pam","Taylor"]]

因此,即使命名函数表达式是一件奇怪的事情,它也有其优点。然而,在使用时,最好考虑到你的代码对于可能不了解这些特殊行为的人的清晰度。这并不意味着完全避免它,而只是在使用时更加注意代码的可读性。

函数声明

函数声明是一种提升声明。提升声明是在运行时有效地将其提升到其执行上下文的顶部,这意味着它将立即对前面的代码行可访问(看起来是它声明之前):

hoistedDeclaration(); // => Does not throw an error...

function hoistedDeclaration() {}

当然,这对于分配给变量的函数表达式是不可能的:

regularFunctionExpression();
  // => Uncaught ReferenceError:
  // => Cannot access 'regularFunctionExpression' before initialization

const regularFunctionExpression = function() {};

函数声明的变量提升行为可能会产生意想不到的结果,因此通常被认为是依赖提升的反模式。一般来说,使用函数声明是可以的,只要以程序员直观假设的方式使用。提升作为一种实践,对大多数人来说并不直观,因此最好避免使用它。

有关作用域和函数声明的变量提升发生的更多信息,请查看第九章,语法和作用域的部分,并转到作用域和声明部分。

函数表达式

函数表达式是最容易和最可预测的使用,因为它们在语法上类似于 JavaScript 中的所有其他值。您可以使用它们字面上在任何定义其他值的地方定义函数,因为它们是一种表达式。例如,观察这里,我们如何定义一个函数数组:

const arrayOfFunctions = [
  function(){},
  function(){}
];

函数表达式的常见应用是将回调传递给其他函数,以便它们可以在以后的某个时间点被调用。许多原生的Array方法,如forEach,以这种方式接受函数:

[1, 2, 3].forEach(function(value) { 
  // do something with each value
});

在这里,我们将一个函数表达式传递给forEach方法。我们没有通过将其分配给变量来命名此函数,因此它被视为匿名函数。匿名函数很有用,因为这意味着我们不需要预先将函数分配给变量以便使用它;我们可以简单地将我们的函数写入我们代码的确切位置。

函数表达式在表达方式上与箭头函数最相似。我们将发现,关键的区别在于箭头函数无法访问自己的绑定(例如thisarguments)。然而,函数表达式可以访问这些值,在某些情况下对你更有用。例如,通常需要绑定到this以便成功地与 DOM API 进行操作,例如,许多原生 DOM 方法将使用相关元素作为执行上下文调用回调和事件处理程序。此外,当定义对象或原型上需要访问当前实例的方法时,您将希望使用函数表达式。正如这里所示,使用箭头函数是不合适的:

class FooBear {
  name = 'Foo Bear';
}

FooBear.prototype.sayHello = () => `Hello I am ${this.name}`;
new FooBear().sayHello(); // => "Hello I am ";

FooBear.prototype.sayHello = function() {
  return `Hello I am ${this.name}`;
};
new FooBear().sayHello(); // => "Hello I am Foo Bear";

正如你所看到的,使用箭头函数语法阻止我们通过this访问实例,而函数表达式语法允许我们这样做。因此,尽管箭头函数在某种程度上已被更简洁的箭头函数取代,但它仍然是一个非常有用的工具。

箭头函数

在许多方面,箭头函数只是函数表达式的略微更简洁的版本,尽管它确实有一些实际上的区别。它有两种风格:

// Regular Arrow Function
const arrow = (arg1, arg2) => { return 123; };

// Concise Arrow Function
const arrow = (arg1, arg2) => 123;

正如你所看到的,简洁变体包括一个隐式返回,而常规变体,就像其他函数定义样式一样,需要你定义一个由大括号限定的常规函数体,在其中你必须明确使用return语句返回一个值。

此外,箭头函数允许您在声明只有一个参数的函数时避免使用括号。在这些情况下,您可以在箭头之前只放置参数的标识符,如下所示:

const addOne = n => n + 1;

箭头函数的简洁性在需要频繁传递函数的情况下非常有用。例如,在通过map等原生方法操作数组时很常见:

[1, 2, 3]
  .map(n => n*2)
  .map(n => `Number ${n}`);

// => ["Number 2", "Number 4", "Number 6"]

尽管箭头函数作为通常冗长的函数定义的简洁变体具有超级英雄的地位,但它也带来了自己的挑战。语言必须适应简洁常规语法变体,这意味着在尝试从简洁的箭头函数中返回对象字面量时存在一些歧义:

const giveMeAnObjectPlease = () => { name: 'Gandalf', age: 2019 };
// !! Uncaught SyntaxError: Unexpected token `:`

这种语法会让 JavaScript 解析器困惑,因为开放的大括号意味着一个常规函数体存在。因此,解析器会给出一个意外标记的错误,因为它不期望对象字面量的主体。如果我们想要从箭头函数的简洁形式返回一个对象字面量,那么我们必须笨拙地用括号将其包裹起来以消除歧义的语法:

const giveMeAnObjectPlease = () => ({ name: 'Gandalf', age: 2019 });

从功能上讲,箭头函数与函数表达式有两种不同之处:

  • 它不提供访问诸如thisarguments之类的绑定。

  • 它没有prototype属性,因此不能用作构造函数

这些差异意味着,总的来说,箭头函数通常不适合用作方法或构造函数。它们最适合用于希望将回调或处理程序传递给另一个函数的上下文中,特别是在希望保留this绑定的情况下。例如,如果我们想要在UIComponent抽象的上下文中绑定事件处理程序,我们可能希望保留this值以执行某些特定于实例的功能:

class MyUIComponent extends UIComponent {
  constructor() {
    this.bindEvents({
      onClick: () => {
        this; // <= usefully refers to the MyUIComponent instance
      }
    });
  }
}

箭头函数在这种情况下感觉最自然。然而,它的简洁性意味着在阅读过于密集的代码行时可能会产生混淆,比如下面的例子:

process(
  n=>n.filter((nCallback, compute)=>compute(()=>nCallback())
)

因此,最好以与使用任何其他构造相同的考虑和实用性来使用箭头函数:确保始终将代码的可用性和可读性放在首位,而不是非常诱人的或简洁语法的巧妙性。

立即调用函数表达式

函数表达式和箭头函数是唯一的函数定义样式,从技术上讲,它们是表达式。正如我们所见,这种特性使它们在需要将它们作为值传递给其他函数时非常有用,而无需经历赋值的过程。

正如我们之前提到的,没有赋值的函数,因此没有对其值的引用,通常被称为匿名函数,看起来像这样:

(function() {
  // I am an anonymous function
})

匿名函数的概念通过立即调用函数表达式IIFE)的概念进一步扩展。IIFE 只是一个立即调用的常规匿名函数,如下所示:

(function() {
  // I am immediately invoked
}());

注意在闭合大括号后的调用括号(也就是...())。这将调用函数,因此使前面的语法结构成为 IIEE。

IIFE 在语言本身并不是一个独特的概念。它只是社区提出的一个有用术语,用来描述立即调用函数的常见模式。这是一个有用的模式,因为它允许我们创建一个临时作用域,这意味着在其中定义的任何变量都受到该作用域的限制,不会泄漏到外部,就像我们从任何函数中期望的那样。这种立即作用域对于快速进行自包含工作而不影响父作用域非常有用。

在浏览器时代,IIFE 变得流行起来,因为最好避免污染全局命名空间。然而,如今,预编译如此流行,IIFE 的用处就不那么大了。

IIFE 的确切语法可能会有所不同。例如,如果我们使用箭头函数,那么调用括号必须放在包装的函数表达式之后:

(() => {
  // I am immediately invoked
})(); // <- () actually calls the function

无论我们使用函数表达式还是箭头函数,机制本质上都是相同的。

如果 IIFE 的概念令人困惑,那么如果我们用标识符fn替换实际函数,并想象我们之前已经将一个函数分配给了这个标识符,那么理解正在发生的事情就更简单了。在这里,我们可以这样调用fn

fn();

现在,我们可以选择用括号包裹fn引用。这对调用没有任何影响,尽管看起来可能很奇怪:

(fn)();

值得记住的是,括号只是有时需要的语法容器,以避免语法歧义。因此,所有这些从技术上讲都是等价的:

fn();
(fn)();
((fn))();

如果我们在这里用内联匿名函数替换fn引用,就不会发生什么突破性的事情。我们只是在现场表达一个内联函数,然后调用它,而不是引用现有的函数:

(function() {
  // Called immediately...
})();

我们称内联函数表达式的模式为 IIFE,但它实际上并不特别。考虑到调用括号,也就是...(),实际上并不在乎它们附加到什么上,只要它是一个函数。在调用之前的表达式可以是任何东西,只要它求值为一个函数。

IIFE 很有用,因为它们提供了作用域隔离,而无需定义一个带有名称的函数,然后稍后引用和调用它,就像我们在这里做的一样:

const initializeApp = () => {
  // Initializing...
};

initializeApp();

在浏览器中,在涉及编译和捆绑的复杂构建之前,IIFE 很有用,因为它们提供了作用域隔离,同时不会将任何名称泄漏到全局作用域。然而,如今,IIFE 很少是必要的。

有趣的是,前面代码中的initializeApp函数,可以说,通过显式名称更易读和理解。这就是为什么,即使必要,IIFE 有时被认为是不必要的混乱和花哨。有名字的函数有助于提供关于其目的和作者意图的线索。没有名称,我们的代码读者就必须承担阅读函数本身以发现其广泛目的的认知负担。因此,通常最好避免 IIFE 和类似的匿名结构,除非您有非常特定的需求。

方法定义

方法定义是在与类定义同时添加到语言中的,允许您轻松声明绑定到特定对象的方法。但它们不仅限于类定义。您也可以在对象文字中自由使用它们:

const things = {
  myFunction() {
    // ...
  }
};

在类中,您也可以以这种方式声明方法:

class Things {
  myFunction() {
    // ...
  }
}

您还可以使用传统的函数定义样式来声明您的方法,比如将函数表达式分配给一个标识符:

class Things {
  myFunction = function() {
    // ...
  };
}

然而,方法定义和其他函数定义风格之间存在一个关键区别。方法定义将始终绑定到首次定义它的对象。这在内部被称为它的[[HomeObject]]。这个主对象将确定方法在被调用时可用的super绑定。只有方法定义允许引用super,它们引用的super将始终是它们的[[HomeObject]][[Prototype]]。这意味着,如果您尝试从其他对象借用方法,您可能会惊讶地发现super不是您想要的:

class Dog {
  greet() { return 'Bark!'; }
}

class Cat {
  greet() { return 'Meow!'; }
}

class JessieTheDog extends Dog {
  greet() { return `${super.greet()} I am Jessie!`; }
}

class JessieTheCat extends Cat {
  greet() { return `${super.greet()} I am Jessie!`; }
}

在这里,我们可以观察到JessieTheCatJessieTheDog都有greet方法:

new JessieTheDog().greet(); // => "Bark! I am Jessie!"
new JessieTheCat().greet(); // => "Meow! I am Jessie!"

我们还可以观察到它们的 greet 方法以相同的方式实现。它们都返回插值字符串${super.greet()} I am Jessie!。因此,让JessieTheCatJessieTheDog借用该方法似乎是合乎逻辑的。毕竟,它们完全相同:

class JessieTheCat extends Cat {
  greet = JessieTheDog.prototype.greet
}

我们可能直觉地期望greet方法中的super指的是当前实例的超类,在JessieTheCat的情况下将是Cat。但奇怪的是,当我们调用这个借用的方法时,我们会经历一些不同的东西:

new JessieTheCat().greet(); // => "Bark! I am Jessie!"

它会叫!借用的方法令人讨厌地保留了它对原始[[HomeObject]]的绑定。

总之,方法定义是更简洁的变体,比起它们更冗长的表亲,函数声明和函数表达式。然而,它们具有一个将它们与众不同的隐式机制,可能会引起混淆。99%的时间,方法定义不会让你失望;它们会表现如预期。另外的 1%的时间,至少知道为什么您的代码表现不佳是有用的,这样您就可以探索其他选项。就像往常一样,对 JavaScript 的特殊性的了解只能帮助我们追求更清洁和更可靠的代码库。

异步函数

异步async)函数在函数关键字之前用async关键字指定。所有函数定义样式都可以以它为前缀:

// Async Function Declaration:
async function foo() {}

// Async Function Expression:
const foo = async function() {};

// Async Arrow-Function:
const foo = async () => {};

// Async Method Definition:
const obj = {
  async foo() {}
};

异步函数允许您通过提供两个关键功能轻松进行异步操作:

  • 您可以在异步函数中使用await来等待 Promise 的完成

  • 您的函数将始终返回一个 Promise,它本身可以被等待

Promise 是处理异步操作的本地提供的抽象。它可能看起来很复杂,但最好将 Promise 视为一个对象,它将在比现在更晚的时间解析或拒绝(即异步)。

传统上,在 JavaScript 中,我们必须传递回调函数,以确保我们能够响应这种异步活动:

getUserDetails('user1', function(userDetails) {
  // This callback is called asynchronously
});

然而,通过异步函数和await,我们可以更简洁地实现这一点:

const userDetails = await getUserDetails('user1');

这里的await子句将暂停当前执行,直到getUserDetails完成并解析为一个值。请注意,我们只能在自身是异步的函数中使用 await。

异步执行是一个复杂的话题,因此有一个专门的章节,即第十章,控制流。现在,有必要知道异步函数是一种特殊类型的函数,它将始终返回一个 Promise。

除了允许await子句和返回 Promise 之外,异步函数具有与所使用的相应函数定义样式相同的特性和特征。异步箭头函数,就像常规箭头函数一样,没有自己的 this 或 arguments 绑定。异步函数声明像它的非异步表亲一样被提升。基本上,异步应该被视为覆盖您已经掌握的关于不同函数定义样式的所有知识的一层。

生成器函数

我们将要介绍的最后一种函数定义样式是非常强大的生成器函数。广义上,生成器用于提供和控制一个或多个,甚至无限个项目的迭代行为。

在 JavaScript 中,生成器函数是在函数关键字后面加上一个星号来指定的:

function* myGenerator() {...}

当调用时,它们将返回一个生成器对象,该对象唯一地符合可迭代协议和迭代器协议,这意味着它们可以被自身迭代,或者可以作为对象的迭代逻辑。

可以直接跳到关于可迭代协议的部分。*当您将生成器函数视为创建迭代器或可迭代对象的便捷方式时,它会更加有意义。

生成器函数将在yield语句的位置暂停并返回一个值,这可以发生多次。在yield之后,函数在等待消费者需要其下一个值时实际上被暂停了。这最好通过一个例子来说明:

function* threeLittlePiggies() {
  yield 'This little piggy went to market.';
  yield 'This little piggy stayed home.';
  yield 'This little piggy had roast beef.';
}

const piggies = threeLittlePiggies();

piggies.next().value; // => 'This little piggy went to market.'
piggies.next().value; // => 'This little piggy stayed home.'
piggies.next().value; // => 'This little piggy had roast beef.'

piggies.next(); // => {value: undefined, done: true}

正如你所看到的,从函数返回的生成器对象具有next方法,当调用时,将返回一个带有value(指示迭代的当前值)和done属性(指示迭代/生成是否完成)的对象。这是迭代器协议,也是您可以期望所有生成器满足的约定。

生成器不仅满足迭代器协议,还满足可迭代协议,这意味着它们可以被语言结构迭代(例如for...of...spread运算符)接受:

for (let piggy of threeLittlePiggies()) console.log(piggy); 
// => Logs: "This little piggy went to market."
// => Logs: This little piggy stayed home."
// => Logs: This little piggy had roast beef."

[...threeLittlePiggies()];
// => ["This little piggy went to market", "This little piggy stayed...", "..."]

异步生成器函数也可以被指定。它们有用地将异步和生成器格式结合成一种混合形式,允许自定义异步生成逻辑,就像这样:

async function* pages(n) {
  for (let i = 1; i <= n; i++) {
    yield fetch(`/page/${i}`);
  }
};

// Fetch five pages (/page/1, /page/2, /page/3)
for await (let page of pages(3)) {
  page; // => Each of the 3 pages
};

您会注意到我们正在使用for await迭代结构来迭代我们的异步生成器。这将确保每次迭代都会在继续之前等待其结果。

生成器函数非常强大,但重要的是要了解其中的基本机制。它们不是常规函数,也不能保证完全运行。它们的实现应该考虑它们将被运行的上下文。如果您的生成器旨在用作迭代器,则它应该尊重迭代的暗示期望:它是对底层数据或生成逻辑的只读操作。虽然可能在生成器内部改变底层数据,但应该避免这样做。

数组和可迭代对象

在 JavaScript 中,数组是一种特殊的对象类型,它包含一组有序的元素。

您可以使用数组的文字语法来表示一个数组,这是一个由方括号分隔的表达式的逗号分隔列表:

const friends = ['Rachel', 'Monica', 'Ross', 'Joe', 'Phoebe', 'Chandler'];

这些逗号分隔的表达式可以是复杂或简单的,取决于我们的需要。

[
  [1, 2, 3],
  function() {},
  Symbol(),
  {
    title: 'wow',
    foo: function() {}
  }
]

数组能够包含各种值。我们对如何使用数组几乎没有什么限制。从技术上讲,数组的length由于存储为 32 位整数,因此受到约 40 亿的限制。当然,对于大多数目的来说,这应该完全没问题。

数组具有用于描述其中每个索引元素的数值属性和一个length属性来描述其中有多少元素。它们还有一组有用的方法来读取和操作其中的数据:

friends[0]; // => "Rachel"
friends[5]; // => "Chandler"
friends.length; // => 6

friends.map(name => name.toUpperCase());
// => ["RACHEL", "MONICA", "ROSS", "JOE", "PHOEBE", "CHANDLER"]

friends.join(' and ');
// => "Rachel and Monica and Ross and Joe and Phoebe and Chandler"

在历史上,数组是通过传统的for(...)while(...)循环进行迭代的,这些循环会向length递增一个计数器,以便在每次迭代时可以通过array[counter]访问当前元素,如下所示:

for (let i = 0; i < friends.length; i++) {
  // Do something with `friends[i]`
}

然而,如今更倾向于使用其他迭代方法,比如forEachfor...of

for (let friend of friends) {
  // Do something with `friend`
}

friends.forEach((friend, index) => {
  // Do something with `friend`
});

for...of的好处是可以中断,这意味着你可以在其中使用breakcontinue语句,并轻松地跳出迭代。它还可以用于任何可迭代的对象,而forEach只是一个Array方法。然而,forEach风格的迭代对于通过回调的第二个参数提供当前迭代的索引是有用的。

你使用哪种迭代方式应该由你要迭代的值和你希望在每次迭代中执行的操作决定。如今,几乎不需要使用传统的数组迭代方式,比如for(...)while(...)

类似数组的对象

大多数原生数组方法都是通用的,这意味着它们可以用于任何看起来像数组的对象。我们只需要实现一个length属性和每个索引的单独属性(从零开始索引)来实现数组的外观:

const arrayLikeThing = {
  length: 3,
  0: 'Suspiciously',
  1: 'similar to',
  2: 'an array...'
};

// We can "borrow" an array's join method by assigning 
// it to our object:
arrayLikeThing.join = [].join;

arrayLikeThing.join(' ');
// => "Suspiciously similar to an array..."

在这里,我们构建了一个类似数组的对象,然后通过借用数组的join方法(即从Array.prototype中)为其提供了自己的join方法。原生数组的join方法实现得非常通用,它不介意在对象上操作,只要该对象满足数组的约定,即提供一个length属性和相应的索引(012等)。大多数原生数组方法都是通用的。

语言本身中类似数组的对象的一个例子是我们在本章前面探讨过的arguments绑定。另一个例子是NodeList,它是从各种 DOM 选择方法返回的对象类型。如果需要,我们可以通过借用和调用数组的slice方法从这些对象中派生出真正的数组,如下所示:

const arrayLikeObject = { length: 2, 0: 'foo', 1: 'bar' };

// "Borrowing" a method from an array and forcing its
// execution context via call():
[].slice.call(arrayLikeObject);

// "Borrowing" a method explicitly from the Array.prototype
// and forcing its execution context via call():
Array.prototype.slice.call(arrayLikeObject);

然而,在argumentsNodeList对象的情况下,我们也可以依赖它们是可迭代的,这意味着我们可以使用扩展语法来派生出一个真正的数组:

// "spread" a NodeList into an Array:
[...document.querySelectorAll('div span a')];

// "spread" an arguments object into an Array:
[...arguments];

如果你发现自己需要创建一个类似数组的对象,考虑让它实现可迭代协议(我们即将探讨),以便以这种方式使用扩展语法。

Set 和 WeakSet

SetWeakSet是允许我们存储唯一对象序列的原生抽象。这与数组形成对比,数组无法保证值的唯一性。下面是一个例子:

const foundNumbersArray = [1, 2, 3, 4, 3, 2, 1];
const foundNumbersSet = new Set([1, 2, 3, 4, 3, 2, 1]);

foundNumbersArray; // => [1, 2, 3, 4, 3, 2, 1]
foundNumbersSet;   // => Set{ 1, 2, 3, 4 }

如你所见,给定给Set的值如果已经存在于Set中,将始终被忽略。

通过将可迭代的值传递给构造函数,可以初始化集合;例如,一个字符串:

new Set('wooooow'); // => Set{ 'w', 'o' }

如果你需要将Set转换为数组,你可以使用扩展语法最简单地实现这一点(因为集合本身是可迭代的):

[...foundNumbersSet]; // => [1, 2, 3, 4]

WeakSet 与之前介绍的 WeakMap 类似。它们用于以一种允许值在程序的其他部分被垃圾回收的方式弱引用值。使用集合的语义和最佳实践与使用数组的类似。建议只在需要存储唯一值序列时使用集合;否则,只需使用简单的数组。

可迭代协议

可迭代协议允许包含序列的值共享一组共同的特征,使它们可以被迭代或以类似的方式处理。

我们可以说,实现可迭代协议的对象是可迭代的。JavaScript 中的可迭代对象包括ArrayMapSetString

任何对象都可以通过简单地在属性名Symbol.iterator下提供迭代器函数来定义自己的可迭代协议(它映射到内部的@@iterator属性)。

这个迭代器函数必须通过返回一个带有next函数的对象来满足迭代器协议。当调用这个next函数时,它必须返回一个带有donevalue键的对象,指示迭代的当前值和迭代是否完成:

const validIteratorFunction = () => {
  return {
    next: () => {
      return {
        value: null, // Current value of the iteration
        done: true // Whether the iteration is completed
      };
    }
  }
};

因此,为了对此有绝对的清晰认识,有两个不同的协议:

  • 可迭代协议:通过[Symbol.iterator]实现@@iterator的任何对象都满足这个协议。原生示例包括ArrayStringSetMap

  • 迭代器协议:任何返回形式为{... next: Function}的对象的函数,其next方法在调用时返回以下形式的对象:{value: Boolean, done: ...}

为了满足可迭代协议,对象必须实现[Symbol.iterator],如下所示:

const zeroToTen = {};
zeroToTen[Symbol.iterator] = function() {
  let current = 0;
  return {
    next: function() {
      if (current > 10) return { done: true };
      return {
        done: false,
        value: current++
      };
    }
  }
};

// We can see the effect of the iterable via the spread operator:
[...zeroToTen]; // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

通过可迭代协议提供自定义迭代方法在您想要控制迭代顺序或在迭代过程中进行某种处理、过滤或生成值时非常有用。例如,在这里,我们将迭代器函数指定为生成器函数,正如您可能记得的那样,它返回一个满足迭代器和可迭代协议的生成器。这个生成器函数将为每个存储的单词产生两个变体,一个大写和一个小写:

const words = {
  values: ['CoFfee', 'ApPLE', 'Tea'],
  [Symbol.iterator]: function*() {
    for (let word of this.values) {
      yield word.toUpperCase();
      yield word.toLowerCase();
    }
  }
};

[...words]
// => ["COFFEE", "coffee", "APPLE", "apple", "TEA", "tea"]

将迭代器函数指定为生成器函数比手动实现迭代器协议要简单得多。生成器自然地满足这一约定,因此它们可以更加无缝地使用。生成器通常也更易读和简洁,并且具有实现迭代器和可迭代协议的双重好处,这意味着它们可以用于为对象添加迭代功能:

const someObject = {
  [Symbol.iterator]: function*() { yield 123; }
};

[...someObject]; // => [123]

它们本身也可以提供迭代功能:

function* someGenerator() {
  yield 123;
}

[...someGenerator()]; // => [123]

重要的是要记住,在自定义可迭代中进行的任何工作都应符合消费者的期望。迭代通常被认为是只读操作,因此在迭代过程中应避免对基础值集的突变。实现自己的可迭代可以非常强大,但也可能导致消费者对您的自定义迭代逻辑不熟悉的意外行为。

对于那些了解情况的人和那些可能是第一次体验您的接口或抽象的人来说,平衡自定义迭代的便利性是非常重要的。

RegExp

JavaScript 通过对象类型RegExp原生支持正则表达式,允许通过文字语法/foo/或直接通过构造函数(RegExp('foo'))来表达。正则表达式用于定义可以与字符串匹配或执行的字符模式。

以下是一个示例,我们从文本语料库中提取只有长单词(>=10个字符)的单词:

const string = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet odio ultrices nunc efficitur venenatis laoreet nec leo.';

string.match(/\w{10,}/g); // => ["consectetur", "adipiscing"]

正则表达式的语法和语法可能很复杂。从技术上讲,它本质上是一种完整的语言,需要多天的学习。我们无法在这里探索它的所有复杂性。然而,我们将涵盖我们通常在 JavaScript 中操作正则表达式的方式,并探讨其中的一些挑战。建议您自行进一步研究正则表达式。

正则表达式 101

正则表达式允许我们描述字符的模式。它们用于匹配和提取字符串中的值。例如,如果我们有一个包含数字(1,2,3)的字符串,正则表达式将允许我们轻松地检索它们:

const string = 'some 1 content 2 with 3 digits';
string.match(/1|2|3/g); // => ["1", "2", "3"]

正则表达式是以斜杠分隔的模式编写的,最终斜杠后面可以跟随可选标志:

/[PATTERN]/[FLAGS]

您编写的模式可以包含文字和特殊字符,这些字符共同告诉正则表达式引擎要查找什么。我们在示例中使用的正则表达式包含文字字符(即123)和管道(即|)特殊字符:

/1|2|3/g

管道特殊字符告诉正则表达式引擎,管道左侧或右侧的字符可能匹配。在最终斜杠后面的g是一个全局标志,指示引擎在字符串中全局搜索,并在找到第一个匹配项后不放弃。对我们来说,这意味着我们的正则表达式将匹配主题字符串中出现的"1""2""3"

在正则表达式中,我们可以使用特定的特殊字符作为快捷方式。[0-9]的表示法就是一个例子。它是一个字符类,将匹配从09的所有数字,这样我们就不必逐个列出所有这些数字。还有一个简写字符类\d,可以更简洁地表示这一点。因此,我们可以将我们的正则表达式缩短为以下形式:

/\d/g

对于更现实的应用,我们可以想象一种情况,我们希望匹配数字序列,比如电话号码。也许我们只希望匹配以0800开头并包含进一步46位数字的电话号码。我们可以使用以下正则表达式来实现:

/0800\d{4,6}/g

在这里,我们使用{n, n}语法,允许我们为前面的特殊字符\d表示数量。我们可以通过将其传递给测试字符串的match方法来确认我们的模式是否有效:

`
  This is a test in which exist some phone
  numbers like 0800182372 and 08009991.
`.match(
  /0800\d{4,6}/g
);
// => ["0800182372", "08009991"]

这个简短的介绍只是触及了正则表达式的表面。正则表达式的语法允许我们表达重要的复杂性,使我们能够验证字符串中是否存在特定文本,或者提取特定文本以在我们的程序中使用。

RegExp 标志

正则表达式的文字语法允许在最终的斜杠之后指定特定的标志,例如i忽略大小写)。这些标志将影响正则表达式的执行方式:

/hello/.test('hELlO');  // => false
/hello/i.test('hELlO'); // => true

在使用RegExp构造函数时,可以将标志作为第二个参数传递:

RegExp('hello').test('hELlO');      // => false
RegExp('hello', 'i').test('hELlO'); // => true

JavaScript 的正则表达式有六个可用的标志:

  • i忽略大小写标志将在匹配字母时忽略字符串的大小写(即/a/i将匹配字符串中的'a''A')。

  • g全局匹配标志将使正则表达式找到所有匹配项,而不是在找到第一个匹配项后停止。

  • m多行标志将使开始和结束锚点(即^$)标记单独行的开头和结尾,而不是整个字符串的开头和结尾。

  • sdotAll标志将使正则表达式中的点字符(通常仅匹配非换行符字符)匹配换行符字符。

  • uUnicode标志将把正则表达式中的字符序列视为单独的 Unicode 代码点,而不是代码单元。这基本上意味着您可以轻松地匹配和测试罕见或特殊符号,例如表情符号(请参阅本章中关于String类型的部分,以更全面地了解 Unicode)。

  • y粘性标志将导致所有RegExp操作尝试在lastIndex属性指定的确切索引处进行匹配,然后在匹配时改变lastIndex

正如我们所见,正则表达式也可以通过RegExp构造函数构造。这可以有用地作为构造函数或常规函数调用:无论哪种方式,你都会收到一个等同于从文字语法中派生的RegExp对象:

new RegExp('[a-z]', 'i'); // => /[a-z]/i
RegExp('[a-z]', 'i');     // => /[a-z]/i

这是一种非常独特的行为。事实上,RegExp构造函数是唯一可以作为构造函数和常规函数调用的本地提供的构造函数,在这两种情况下都返回一个新实例。你会记得,原始构造函数(如StringNumber)可以作为常规函数调用,但在作为构造函数调用时会有不同的行为。

接受 RegExp 的方法

JavaScript 提供了七种方法,可以利用正则表达式:

  • RegExp.prototype.test(String): 对传递的字符串运行正则表达式,如果找到至少一个匹配,则返回 true。如果没有找到匹配,则返回 false。

  • RegExp.prototype.exec(String): 如果正则表达式有全局(g)标志,那么exec()将从当前lastIndex返回下一个匹配(并在这样做后更新正则表达式的lastIndex);否则,它将返回正则表达式的第一个匹配(类似于String.prototype.match)。

  • String.prototype.match(RegExp): 这个String方法将返回针对字符串进行的匹配(或者如果设置了全局标志,则返回所有匹配)。

  • String.prototype.replace(RegExp, Function): 这个String方法将在每次匹配上执行传递的函数,并且对于每次匹配,用函数返回的内容替换匹配的文本。

  • String.prototype.matchAll(RegExp): 这个String方法将返回所有结果及其各自的组的迭代器。当你有一个具有各自匹配组的全局正则表达式时,这是很有用的。

  • String.prototype.search(RegExp): 这个String方法将返回第一个匹配的索引,如果没有找到匹配,则返回-1。

  • String.prototype.split(RegExp): 这个String方法将返回一个包含由提供的分隔符(可以是正则表达式)分割的字符串部分的数组。

有许多方法可供选择,但在大多数情况下,你可能会发现RegExp方法test()String方法match()replace()最有用。

以下是一些这些方法的示例。这应该让你了解每种方法可能使用的情况:

// RegExp.prototype.test
/@/.test('a@b.com'); // => true
/@/.test('aaa.com'); // => false

// RegExp.prototype.exec
const regexp = /\d+/g;
const string = '123 456 789';
regex.exec(string); // => ["123"]
regex.exec(string); // => ["456"]
regex.exec(string); // => ["789"]
regex.exec(string); // => null

// String.prototype.match
'Orders: #92838 #02812 #92833'.match(/\d+/);  // => ["92838"]
'Orders: #92838 #02812 #92833'.match(/wo+w/g); // => ["92838", "02812", "92833"]

// String.prototype.matchAll
const string = 'Orders: #92333 <fulfilled> #92835 <pending>';
const matches = [
  ...string.matchAll(/#(\d+) <(\w+)>/g)
];
matches[0][1]; // => 92333
matches[0][2]; // => fulfilled

// String.prototype.replace
'1 2 3 4'.replace(/\d/, n => `<${n}>`); // => "<1> 2 3 4'
'1 2 3 4'.replace(/\d/g, n => `<${n}>`); // => "<1> <2> <3> <4>'

// String.prototype.search
'abcdefghhijklmnop'.search(/k/); // => 11

// String.prototype.split
'time_in____a__tree'.split(/_+/); // ["time", "in", "a", "tree"]

正如你所看到的,大多数这些方法的行为都很直观。然而,围绕粘性lastIndex属性存在一些复杂性,我们现在将对此进行讨论。

RegExp 方法和 lastIndex

默认情况下,如果你的RegExp是全局的(即使用了g标志),RegExp方法(即test()exec())将在每次执行时改变RegExp对象的lastIndex属性。这些方法将尝试从当前lastIndex属性指定的索引匹配主题字符串,该属性默认为 0,然后在每次后续调用时更新lastIndex

如果你期望exec()test()对于给定的全局正则表达式和字符串始终返回相同的结果,这可能会导致意外行为:

const alphaRegex = /[a-z]+/g;

alphaRegex.exec('aaa bbb ccc'); // => ["aaa"]
alphaRegex.exec('aaa bbb ccc'); // => ["bbb"]
alphaRegex.exec('aaa bbb ccc'); // => ["ccc"]
alphaRegex.exec('aaa bbb ccc'); // => null

如果你尝试在多个字符串上执行全局正则表达式而不自己重置lastIndex,这也会导致混乱:

const alphaRegex = /[a-z]+/g;

alphaRegex.exec('monkeys laughing'); // => ["monkeys"]
alphaRegex.lastIndex; // => 7
alphaRegex.exec('birds flying'); // => ["lying"]

如你所见,在匹配了"monkeys"子字符串之后,lastIndex被更新为下一个索引(7),这意味着,在不同的字符串上执行时,正则表达式将继续之前的位置,并尝试匹配该索引之后的所有内容,在第二个字符串"birds flying"的情况下,是子字符串"lying"

通常,为了避免这些混淆,始终拥有对正则表达式的所有权是非常重要的。如果你在程序中使用RegExp方法,不要接受来自其他地方的正则表达式。此外,在每次执行之前不要尝试在不同的字符串上使用exec()test()而不重置lastIndex

const petRegex = /\b(?:dog|cat|hamster)\b/g;

// Testing multiple strings without resetting lastIndex:
petRegex.exec('lion tiger cat'); // => ["cat"]
petRegex.exec('lion tiger dog'); // => null

// Testing multiple strings with resetting lastIndex:
petRegex.exec('lion tiger cat'); // => ["cat"]
petRegex.lastIndex = 0;
petRegex.exec('lion tiger dog'); // => ["dog"]

在这里,你可以看到,如果我们不重置lastIndex,我们的正则表达式在传递给exec()方法的后续字符串上无法匹配。然而,如果我们在每次后续的exec()调用之前重置lastIndex,我们将观察到匹配。

粘性

粘性意味着正则表达式将尝试在确切的lastIndex处进行匹配,如果在该确切索引处找不到匹配,它将失败(即根据使用的方法返回nullfalse)。粘性标志(y)将强制RegExp在每次匹配时读取和改变lastIndex。传统的粘性方法,如exec()test(),如我们之前提到的,将始终这样做,但y标志将强制粘性,即使在使用非粘性方法时,如match()

const regexp = /cat|hat/y; // match 'cat' or 'hat'
const string = 'cat in a hat';

// lastIndex is always zero by default, so will
// match from the start of the string:
regexp.lastIndex; // => 0
regexp.test(string); // => ["cat"]

// lastIndex has been modified following the last
// match but will not match anything as there is
// no cat or hat at index 3:
regexp.lastIndex; // => 3
string.match(regexp); // => null

// Set lastIndex to 9 (index of "hat"):
regexp.lastIndex = 9;
string.match(regexp); // => ["hat"]

如果你正在寻找字符串或一系列字符串中特定索引处的匹配,粘性可能是有用的。然而,如果你无法完全控制lastIndex,它的行为可能会出乎意料。正如我们之前提到的,一个很好的一般规则是始终拥有对自己的RegExp对象的所有权,以便对lastIndex的任何变化只能由你的代码进行。

总结

在本章中,我们已经开始通过查看语言提供的内置类型来深入研究 JavaScript。我们探索的重点是通过清晰的代码视角来看待这些语言构造。通过这样做,我们强调了在处理语言的一些更晦涩的领域时要小心的重要性。我们发现了许多涉及使用 JavaScript 类型的恶劣边缘情况和挑战,比如浮点Number类型的精度不足以及String类型中 Unicode 的复杂性。探索语言中这些更困难的部分不仅可以避免特定的陷阱,而且可以在我们内心培养一种流利,这将极大地提高我们运用 JavaScript 服务于清晰代码的能力。

在下一章中,我们将继续增强这种流畅性。我们将更多地了解 JavaScript 的类型系统,并开始对这些类型进行操作和操纵以满足我们的需求。

第七章:动态类型化

在上一章中,我们探讨了 JavaScript 的内置值和类型,并涉及了在使用它们时涉及的一些挑战。接下来自然的步骤是探索 JavaScript 的动态系统在现实世界中是如何发挥作用的。由于 JavaScript 是一种动态类型的语言,代码中的变量在所引用的值的类型方面没有限制。这给清洁的编码者带来了巨大的挑战。由于我们的类型不确定,我们的代码可能以意想不到的方式中断,并且可能变得非常脆弱。这种脆弱性可以很简单地解释为想象一个嵌入在字符串中的数值:

const possiblyNumeric = '203.45';

在这里,我们可以看到该值是数值,但它已被包装在一个字符串文字中,因此在 JavaScript 看来,它只是一个普通的字符串。但由于 JavaScript 是动态的,我们可以自由地将这个值传递给任何函数,甚至是一个期望一个数字的函数:

setWidth('203.45');

function setWidth(width) {
  width += 20;       // Add margins
  applyWidth(width); // Apply the width
}

该函数通过+=运算符向数字添加了一个边距值。正如我们将在本章后面学到的那样,这个运算符是操作a = a + b的别名,而这里的+运算符,在任一操作数为String类型的情况下,将简单地将这两个字符串连接在一起。有趣的是,这个简单而无辜的实现细节是世界各地在不同时间发生的数百万次令人筋疲力尽的调试会话的关键。幸运的是,了解这个运算符及其确切的行为将为你节省无数个小时的痛苦和筋疲力尽,并且会牢固地铭记在你的脑海中,即避免我们已经陷入的possiblyNumeric值的陷阱的代码的重要性。

在本章中,我们将涵盖以下主题:

  • 检测

  • 转换、强制转换和转型

能够更轻松地处理我们的类型的第一个关键步骤是学习检测,即能够以最简单的方式辨别你正在处理的类型或类型的技能。

检测

检测是指确定值的类型的实践。通常,这将是为了使用确定的类型来执行特定的行为,比如回退到默认值或在误用的情况下抛出错误。

由于 JavaScript 的动态特性,检测类型是一种重要的实践,通常可以帮助其他程序员。如果你可以在某人错误地使用接口时有用地抛出错误或警告,那么对于他们来说,这意味着开发流程更加流畅和迅速。如果你可以有用地用智能默认值填充undefinednull或空值,那么它将允许你提供一个更无缝和直观的接口。

不幸的是,由于 JavaScript 中的遗留问题和设计中的一些选择,检测类型可能是具有挑战性的。使用了许多不被认为是最佳实践的不同方法。我们将在本节中讨论所有这些实践。然而,首先值得讨论一个关于检测的基本问题:你究竟想要检测什么

我们经常认为我们需要特定的类型才能执行某些操作,但由于 JavaScript 的动态特性,我们可能并不需要这样做。事实上,这样做可能导致我们创建不必要的限制性或僵化的代码。

考虑一个接受people对象数组的函数,如下所示:

registerPeopleForMarathon([
  new Person({ id: 1, name: 'Marcus Wu' }),
  new Person({ id: 2, name: 'Susan Smith' }),
  new Person({ id: 3, name: 'Sofia Polat' })
]);

在我们的registerPeopleForMarathon中,我们可能会想要实现某种检查,以确保传递的参数是预期的类型和结构:

function registerPeopleForMarathon(people) {
  if (Array.isArray(people)) {
    throw new Error('People is not an array');
  }
  for (let person in people) {
    if (!(person instanceof Person)) {
      throw new Error('Each person should be an instance of Person');
    }
    registerForMarathon(person.id, person.name);
  }
}

这些检查有必要吗?你可能倾向于说有,因为它们确保我们的代码对潜在的错误情况具有弹性(或防御性),因此更可靠。但是如果我们仔细考虑一下,我们的这些检查都不是必要的,以确保我们寻求的可靠性。我们检查的意图,大概是为了防止错误类型或结构传递给我们的函数时产生下游错误,但是如果我们仔细观察前面的代码,我们担心的类型并没有下游错误的风险。

我们进行的第一个检查是Array.isArray(people),以确定people值是否确实是一个数组。我们这样做,表面上是为了安全地遍历数组。但是,正如我们在前一章中发现的那样,for...of迭代风格并不依赖于of {...}值是一个数组。它只关心值是否可迭代。一个例子如下:

function* marathonPeopleGenerator() {
  yield new Person({ id: 1, name: 'Marcus Wu' });
  yield new Person({ id: 2, name: 'Susan Smith' });
  yield new Person({ id: 3, name: 'Sofia Polat' });
}

for (let person of marathonPeopleGenerator()) {
 console.log(person.name);
}

// Logged => "Marcus Wu"
// Logged => "Susan Smith"
// Logged => "Sofia Polat"

在这里,我们使用生成器作为我们的可迭代对象。这将像数组一样在for...of中被迭代,因此,从技术上讲,我们可以说我们的registerPeopleForMarathon函数应该接受这样的值:

// Should we allow this?
registerPeopleForMarathon(
  marathonPeopleGenerator()
);

到目前为止,我们进行的检查会拒绝这个值,因为它不是一个数组。这有意义吗?你还记得抽象原则以及我们应该关注接口而不是实现吗?从这个角度来看,可以说我们的registerPeopleForMarathon函数不需要知道传递值的类型的实现细节。它只关心值是否按照它的需求执行。在这种情况下,它需要通过for...of循环遍历值,因此任何可迭代对象都是合适的。为了检查可迭代性,我们可以使用这样的辅助函数:

function isIterable(obj) {
  return obj != null &&
 typeof obj[Symbol.iterator] === 'function';
}

isIterable([1, 2, 3]); // => true
isIterable(marathonPeopleGenerator()); // => true

另外,要考虑的是,我们目前正在检查所有person值是否是Person构造函数的实例:

// ...
if (!(person instanceof Person)) {
  throw new Error('Each person should be an instance of Person');
}

我们是否有必要以这种方式明确检查实例?相反,我们是否可以简单地检查我们希望访问的属性?也许我们需要断言的是属性不是假值(空字符串、null、undefined、零等):

// ...
if (!person || !person.name || !person.id) {
  throw new Error('Each person should have a name and id');
}

这个检查可能更符合我们真正的需求。这样的检查通常被称为鸭子类型,即如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子。我们并不总是需要检查特定类型;我们可以检查我们真正依赖的属性、方法和特征。通过这样做,我们创建的代码更加灵活。

我们的新检查,当集成到我们的函数中时,会看起来像这样:

function registerPeopleForMarathon(people) {
  if (isIterable(people)) {
    throw new Error('People is not iterable');
  }
  for (let person in people) {
    if (!person || !person.name || !person.id) {
      throw new Error('Each person should have a name and id');
    }
    registerForMarathon(person.id, person.name);
  }
}

通过使用更灵活的isIterable检查,并在我们的person对象上使用鸭子类型,我们的registerPeopleForMarathon函数现在可以被传递;例如,在这里,我们有一个生成器产生普通对象:

function* marathonPeopleGenerator() {
  yield { id: 1, name: 'Marcus Wu' };
  yield { id: 2, name: 'Susan Smith' };
  yield { id: 3, name: 'Sofia Polat' };
}

registerPeopleForMarathon(
  marathonPeopleGenerator()
);

如果我们一直坚持严格的类型检查,这种灵活性是不可能的。更严格的检查通常会创建更严格的代码,并且不必要地限制灵活性。然而,这里需要取得平衡。我们不能无限制地灵活。甚至可能严格的类型检查提供的严谨性和确定性能够确保长期更清晰的代码。但相反的情况也可能成立。灵活性与严谨性的平衡是你应该不断考虑的。

一般来说,接口的期望应该尽可能接近实现的需求。也就是说,除非检查确实能够防止我们的实现中出现错误,否则我们不应该执行检测或其他检查。过度检查可能看起来更安全,但可能只意味着未来的需求和用例更难以适应。

现在我们已经解决了为什么我们要检测事物并且暴露了一些用例的问题,我们可以开始学习 JavaScript 提供给我们的检测技术。我们将从typeof运算符开始。

typeof 运算符

当你第一次尝试在 JavaScript 中检测类型时,你通常会接触到的第一件事是typeof运算符:

typeof 1; // => number

typeof运算符接受一个操作数,位于其右侧,并将根据传递的值之一求值为八种可能的字符串值之一:

typeof 1; // => "number"
typeof ''; // => "string"
typeof {}; // => "object"
typeof function(){}; // => "function"
typeof undefined; // => "undefined"
typeof Symbol(); // => "symbol"
typeof 0n; // => "bigint"
typeof true; // => boolean

如果你的操作数是一个没有绑定的标识符,也就是一个未声明的变量,那么typeof将有用地返回"undefined",而不是像对该变量的任何其他引用一样抛出ReferenceError

typeof somethingNotYetDeclared; // => "undefined"

typeof是 JavaScript 语言中唯一执行此操作的运算符。如果尚未声明该值,那么任何其他运算符和引用值的方式都会抛出错误。

除了检测未声明的变量外,typeof在确定原始类型时真的只有用处——即使这太宽泛了,因为并非所有原始类型都是可检测的。例如,当传递给typeof时,null值将求值为一个相当无用的"object"

typeof null; // => "object"

这是 JavaScript 语言的一个不幸且无法修复的遗留问题。它可能永远不会被修复。要检查null,最好明确检查值本身:

let someValue = null;
someValue === null; // => true

typeof运算符在不是函数的不同类型的对象之间没有区别,除了函数。JavaScript 中的所有非函数对象都会返回简单的"object"

typeof [];         // => "object"
typeof RegExp(''); // => "object"
typeof {};         // => "object"

所有函数,无论是通过类定义、方法定义还是普通函数表达式声明的,都将求值为"function"

typeof () => {};          // => "function"
typeof function() {};     // => "function"
typeof class {};          // => "function"
typeof ({ foo(){} }).foo; // => "function"

如果typeof class {}求值为"function"让你感到困惑,那么请考虑我们所学到的,所有类都只是具有准备好的原型的构造函数(这将稍后确定任何生成实例的[[Prototype]])。它们没有什么特别之处。类不是 JavaScript 中的独特类型或实体。

在比较typeof的结果与给定字符串时,我们可以使用严格相等(===)或抽象相等(==)运算符。由于typeof始终返回一个字符串,我们不必担心任何差异,所以你可以选择使用严格相等还是抽象相等检查。从技术上讲,这两种方法都可以:

if (typeof 123 == 'number') {...}
if (typeof 123 === 'number') {...}

严格相等和抽象相等运算符(双等号和三等号)的行为略有不同,尽管当运算符两侧的值是相同类型时,它们的行为是相同的。请跳转到运算符部分,了解它们的区别。一般来说,最好优先使用===而不是==

总之,typeof运算符只是一个晴天朋友。我们不能在所有情况下依赖它。有时,我们需要使用其他类型检测技术。

类型检测技术

考虑到typeof运算符对于检测多种类型的不适用性,特别是对象,我们必须依赖于许多不同的方法,具体取决于我们想要检查的确切内容。有时,我们可能想要检测特征而不是类型,例如,一个对象是否是构造函数的实例,或者它只是一个普通对象。在本节中,我们将探讨一些常见的检测需求及其解决方案。

检测布尔值

布尔值检测起来非常简单。typeof运算符对truefalse的值正确地求值为"boolean"

typeof true;  // => "boolean"
typeof false; // => "boolean"

不过,我们很少会想要这样做。通常,当你接收到一个Boolean值时,你最感兴趣的是检查它的真实性而不是它的类型。

当将布尔值放置在布尔上下文中时,比如条件语句,我们隐含地依赖于它的真实性或虚假性。例如,看下面的检查:

function process(isEnabled) {
  if (isEnabled) {
    // ... do things
  }
}

这个检查并不能确定isEnabled值是否真正是布尔值。它只是检查它是否评估为真值。isEnabled可能的所有可能值是什么?是否有所有这些真值的列表?这些值几乎是无限的,所以没有列表。我们只能说关于真值的是它们不是假值。而且我们知道,只有七个假值。如果我们希望观察特定值的真假,我们总是可以通过将Boolean构造函数作为函数调用来转换为Boolean

Boolean(true); // => true
Boolean(1); // => true
Boolean(42); // => true
Boolean([]); // => true
Boolean('False'); // => true
Boolean(0.0001); // => true

在大多数情况下,对Boolean的隐式强制转换是足够的,不会对我们造成影响,但是如果我们希望绝对确定一个值既是Boolean又是特定的truefalse,我们可以使用严格相等运算符进行比较,如下所示:

if (isEnabled === true) {...}
if (isEnabled === false) {...}

由于 JavaScript 的动态特性,一些人更喜欢这种确定性,但通常并不是必要的。如果我们要检查的值显然是一个Boolean值,那么我们可以直接使用它。通常情况下,通过typeof或严格相等来检查它的类型是不必要的,除非有可能该值不是Boolean

检测数字

Number的情况下,我们可以依赖typeof运算符正确地评估为"number"

typeof 555; // => "number"

然而,在NaNInfinity-Infinity的情况下,它也会评估为"number"

typeof Infinity;  // => "number"
typeof -Infinity; // => "number"
typeof NaN;       // => "number"

因此,我们可能希望进行额外的检查,以确定一个数字不是这些值中的任何一个。幸运的是,JavaScript 为这种情况提供了本地辅助工具:

  • isFinite(n): 如果Number(n)不是Infinity-InfinityNaN,则返回true

  • isNaN(n): 如果Number(n)不是NaN,则返回true

  • Number.isNaN(n): 如果n不是NaN,则返回true

  • Number.isFinite(n): 如果n不是Infinity-InfinityNaN,则返回true

全局变量的两个变体是语言的较早部分,正如您所看到的,它们与它们的Number.*等效部分略有不同。全局的isFiniteisNaN通过Number(n)将它们的值转换为数字,而等效的Number.*方法则不这样做。这种差异的原因主要是遗留问题。

最近添加的Number.isNaNNumber.isFinite是为了实现更明确的检查而引入的,而不依赖于转换:

isNaN(NaN)   // => true
isNaN('foo') // => true

Number.isNaN(NaN);   // => true
Number.isNaN('foo'); // => false

如您所见,Number.isNaN更为严格,因为它在检查NaN之前不会将值转换为Number。对于字符串'foo',我们需要将其转换为Number(因此评估为NaN)才能通过:

const string = 'foo';
const nan = Number(string);
Number.isNaN(nan); // => true

全局的isFinite函数的工作方式也是一样的,即在检查有限性之前将其值转换为数字,而Number.isFinite方法则不进行任何转换:

isFinite(42)   // => true
isFinite('42') // => true

Number.isFinite(42);   // => true
Number.isFinite('42'); // => false

如果您确信您的值已经是一个数字,那么您可以使用更简洁的isNaNisFinite,因为它们的隐式转换对您没有影响。如果您希望 JavaScript 尝试将您的非Number值转换为Number,那么您应该再次使用isNaNisFinite。然而,如果出于某种原因您需要明确检查,那么您应该使用Number.isNaNNumber.isFinite

结合所有这些讨论过的检查,我们能够通过使用typeof结合全局的isFinite来自信地检测一个既不是NaN也不是Infinity的数字。正如我们之前提到的,isFinite将检查NaN本身,所以我们不需要额外的isNaN检查:

function isNormalNumber(n) {
  return typeof n === 'number' && isFinite(n);
}

在检测方面,你的需求应该由你的代码上下文驱动。例如,如果你嵌入在一个可以安全假定数字是有限的代码片段中,那么可能不需要检查有限数字。但如果你正在构建一个更公共的 API,那么在将这些值发送到你的内部接口之前,你可能希望进行这样的检查,以减少错误的可能性,并为你的用户提供有用和明智的错误或警告。

检测字符串

检测字符串是愉快的简单。我们只需要typeof运算符:

typeof 'hello'; // => "string"

为了检查给定String的长度,我们可以简单地使用length属性:

'hello'.length; // => 5

如果我们需要检查一个String的长度是否大于 0,我们可以通过length显式地这样做,或者依赖于长度为 0 的假值,甚至依赖于空string本身的假值:

const string = '';

Boolean(string);            // => false
Boolean(string.length);     // => false
Boolean(string.length > 0); // => false

// Since an empty String is falsy we can just check `string` directly:
if (string) { }

// Or we can be more explicit:
if (string.length) { }

// Or we can be maximally explicit:
if (string.length > 0) { }

如果我们只是检查一个值的真实性,那么我们也可能检测到所有潜在的真值,包括非零数字和对象。要完全确定你有一个String并且它不是空的,最简洁的技术如下:

if (typeof myString === 'string' && myString) {
  // ...
}

然而,仅仅空白可能并不是我们感兴趣的全部。我们可能希望检测一个字符串是否包含实际内容。在大多数情况下,实际内容String的开头开始,直到String的结尾结束,但在某些情况下,它可能嵌入在两侧的空白中。为了解决这个问题,我们可以修剪String,然后确认它是否为空:

function isNonEmptyString(string) {
  return typeof string === 'string' && string.trim().length > 0;
}

isNonEmptyString('hi');  // => true
isNonEmptyString('');    // => false
isNonEmptyString(' ');   // => false
isNonEmptyString(' \n'); // => false

请注意,我们的函数isNonEmptyString是在修剪后的字符串上使用length > 0检查,而不仅仅依赖于它作为空字符串的假值。这样我们就可以安全而自信地知道我们的isNonEmptyString函数将始终返回一个布尔值。即使在 99%的情况下,它将被用在布尔上下文中,比如if (isNonEmptyString(...)),我们仍然应该确保我们的函数具有直观和一致的约定。

逻辑AND运算符(a && b)将在其左侧为真时返回其右侧。因此,诸如typeof str === "string" && str的表达式并不总是保证返回一个布尔值。有关更多信息,请参阅第八章的运算符-逻辑运算符-逻辑 AND 运算符部分。

检测字符串是简单的,但正如我们在上一章中提到的,由于 Unicode,与它们一起工作可能是一个挑战。因此,要记住,虽然检测字符串可能会给我们一些确定性,但它并不告诉我们字符串内部的内容以及它是否是我们期望的值。如果你的检测意图是为那些使用你的接口的人提供指南或警告,你可能最好通过明确检查值的内容来服务。

检测 undefined

undefined类型可以通过引用其全局可用值直接使用严格相等运算符进行检查:

if (value === undefined) {
  // ...
}

然而,不幸的是,由于undefined可以在非全局范围内被覆盖(取决于你的精确设置和环境),这种方法可能会有问题。从历史上看,undefined可以在全局范围内被覆盖。这意味着这样的事情是可能的:

let value = void 0;  // <- actually undefined
let undefined = 123; // <- cheeky override

if (value === undefined) {
  // Does not occur
}

void运算符,正如我们将在后面探讨的那样,将一个操作数取到其右侧(void foo),并且将始终计算为undefined。因此,void 0已经成为undefined的同义词,并且作为替代是有用的。因此,如果你对undefined值没有信心,那么你可以简单地检查void 0,就像这样:

if (value === void 0) {
  // value is undefined
}

出现了各种其他方法来确保可靠的undefined值。例如,一个方法是简单地声明一个未赋值的变量(它将始终默认为undefined),然后在范围内使用它:

function myModule() {
  // My local `undefined`:
  const undef;

  void 0 === undef; // => true

  if (someValue === undef) {
    // Instead of `VALUE === undefined` I can
    // use `VALUE === undef` within this scope
  }
}

随着时间的推移,undefined值的可变性已经被锁定。ECMAScript 2015禁止了全局修改,但奇怪的是仍然允许本地修改。

值得庆幸的是,始终可以通过简单的typeof运算符来检查undefined

typeof undefined; // => "undefined"

使用typeof这种方式比依赖undefined作为字面值要少风险得多,尽管随着 linting 工具的出现,直接检查undefined通常是安全的。

我们将在第十五章中探讨 ESLint,这是一个流行的 JavaScript linting 工具,更干净代码的工具。在本地范围覆盖undefined的情况下,这绝对是一件坏事,它会友好地给我们一个警告。这样的警告可以让我们更有信心,可以安全地使用语言中以前风险较高的方面。

检测 null

正如我们所见,typeof null评估为"object"。这是语言的一个奇怪的遗留。不幸的是,这意味着我们不能依赖typeof来检测null。相反,我们必须直接比较字面值null,使用严格的相等运算符,如下所示:

if (someValue === null) {
  // someValue is null...
}

undefined不同,null在语言的任何版本和任何环境中都不能被覆盖,因此在使用上不会带来任何麻烦。

检测 null 或 undefined

到目前为止,我们已经介绍了如何独立检查undefinednull,但我们可能希望同时检查两者。例如,一个函数签名通常有一个可选参数。如果未传递该参数或明确设置为null,通常会返回到一些默认值。可以通过明确检查nullundefined来实现这一点,如下所示:

function printHello(name, message) {
  if (message === null || message === undefined) {
    // Default to a hello message:
    message = 'Hello!';
  }
  console.log(`${name} says: ${message}`);
}

通常,由于nullundefined都是假值,通过检查给定值的假值来暗示它们的存在是非常正常的:

if (!value) {
  // Value is definitely not null and definitely not undefined
}

然而,这也将检查值是否为其他假值之一(包括falseNaN,0 等)。因此,如果我们想确认一个值是否特别是nullundefined,而不是其他假值,那么我们应该坚持使用明确的变体:

if (value === null || value === undefined) //...

更简洁的是,我们可以采用抽象(非严格)相等运算符来检查nullundefined,因为它认为这些值是相等的:

if (value == null) {
  // value is either null or undefined
}

尽管这利用了通常被指责的抽象相等运算符(我们将在本章后面探讨),但这仍然是检查undefinednull的一种流行方式。这是因为它的简洁性。然而,采用这种更简洁的检查会使代码不太明显。甚至可能给人留下作者只是想检查null的印象。这种意图的模糊性应该让我们对其干净度产生怀疑。因此,在大多数情况下,我们应该选择更明确和严格的检查。

检测数组

在 JavaScript 中检测数组非常简单,因为有Array.isArray方法:

if (Array.isArray(value)) {
 // ...
}

这种方法告诉我们,传递的值是通过数组构造函数或数组文字构造的。但它不检查值的[[Prototype]],因此完全有可能(尽管不太可能)该值,尽管看起来像一个数组,但可能没有您所期望的特征。

当我们认为需要检查一个值是否是数组时,重要的是问问自己我们真正想要检测什么。也许我们可以检查我们所期望的特征,而不是类型本身。考虑我们将如何处理这个值是至关重要的。如果我们打算通过for...of循环遍历它,那么检查其可迭代性可能更适合我们,而不是检查其数组性。正如我们之前提到的,我们可以使用这样的辅助程序来做到这一点:

function isIterable(obj) {
  return obj != null &&
    typeof obj[Symbol.iterator] === 'function';
}

const foo = [1,2,3];
if (isIterable(foo)) {
  for (let f in foo) {
    console.log(f);
  }
}

// Logs: 1, 2, 3

另外,如果我们想使用特定的数组方法,比如forEachmap,那么最好通过isArray进行检查,因为这将给我们一个合理的信心,这些方法存在:

if (Array.isArray(someValue)) {
  // Using Array methods
  someValue.forEach(v => {/*...*/});
  someValue.sort((a, b) => {/*...*/});
}

如果我们倾向于非常彻底,我们还可以逐个检查特定方法,或者甚至强制将值转换为我们自己的数组,以便我们可以自由地对其进行操作,同时知道该值确实是一个数组:

const myArrayCopy = [...myArray];

请注意,通过扩展语法([...value])复制类似数组的值只有在该值可迭代时才有效。使用[...value]的一个适当的例子是在操作从 DOM API 返回的NodeLists时:

const arrayOfParagraphElements = [...document.querySelectorAll('p')];

NodeList 不是真正的Array,因此它不提供对原生数组方法的访问。因此,创建并使用一个真正的Array的副本是有用的。

总的来说,采用和依赖Array.isArray是安全的,但重要的是要考虑是否需要检查Array,是否更适合检查值是否可迭代,甚至是否具有特定的方法或属性。与所有其他检查一样,我们应该努力使我们的意图明显。如果我们使用的检查比Array.isArray更隐晦,那么最好添加注释或使用一个描述性命名的函数来抽象操作。

检测实例

要检测一个对象是否是构造函数的实例,我们可以简单地使用instanceof运算符:

const component = new Component();
component instanceof Component; 

instanceof 运算符将在第八章*,运算符*中更详细地介绍。

检测普通对象

当我们说“普通”对象时,我们通常指的是通过Object字面量或通过Object构造函数构造的对象:

const plainObject = {
  name: 'Pikachu',
  species: 'Pokémon'
};

const anotherPlainObject = new Object();
anotherPlainObject.name = 'Pikachu';
anotherPlainObject.species = 'Pokémon';

这与其他对象形成对比,比如语言本身提供的对象(例如数组)和我们通过实例化构造函数自己构造的对象(例如new Pokemon()):

function Pokemon() {}
new Pokemon(); // => A non-plain object

检测普通对象的最简单方法是询问它的[[Prototype]]。如果它的[[Prototype]]等于Object.prototype,那么我们可以说它是普通的:

function isPlainObject(object) {
  return Object.getPrototypeOf(object) === Object.prototype;
}

isPlainObject([]);            // => false
isPlainObject(123);           // => false
isPlainObject(new String);    // => false
isPlainObject(new Pokemon()); // => false

isPlainObject(new Object());  // => true
isPlainObject({});            // => true

我们为什么需要知道一个值是否是一个普通对象?例如,当创建一个接受配置对象以及更复杂的对象类型的接口或函数时,区分普通对象和非普通对象可能是有用的。

在大多数情况下,我们需要明确检测普通对象。相反,我们应该只依赖它提供给我们的接口或数据。如果我们的抽象的用户希望向我们传递一个非普通对象,但它仍然具有我们需要的属性,那么我们又有什么好抱怨的呢?

转换、强制转换和类型转换

到目前为止,我们已经学会了如何使用检测来区分 JavaScript 中的各种类型和特征。正如我们所见,当需要在出现意外或不兼容的值时提供替代值或警告时,检测是有用的。然而,处理这些值的另一个机制是:我们可以将它们从我们不希望的值转换为我们希望的值。

为了转换一个值,我们使用一种称为类型转换的机制。类型转换是有意和明确地从一种类型派生另一种类型。与类型转换相反,还有强制转换。强制转换是 JavaScript 在使用需要特定类型的运算符或语言结构时隐式和内部进行的转换过程。一个例子是将String值传递给乘法运算符。运算符将自然地将其String操作数强制转换为数字,以便它可以尝试将它们相乘:

'5' * '2'; // => 10 (Number)

强制转换隐式转换的基本机制是相同的。它们都是转换的机制。但是我们如何访问这些底层行为是关键的。如果我们明确地这样做,清晰地传达我们的意图,那么我们的代码读者将会有更好的体验。

考虑以下代码,其中包含将String转换为Number的两种不同机制:

Number('123'); // => 123
+'123'; // => 123

在这里,我们使用了两种不同的技术来强制将值从String转换为Number。当作为函数调用时,Number()构造函数将内部将传递的值转换为Number原始值。一元+运算符也会做同样的事情,尽管它可能不够清晰。强制转换甚至不够清晰,因为它经常似乎是作为某些其他操作的副作用而发生的。以下是一些此类的例子:

1 + '123'; // => "1234"
[2] * [3]; // => 6
'22' / 2;  // => 11

当操作数中的一个是字符串时,+运算符将强制转换另一个操作数为字符串,然后将它们连接在一起。当给定数组时,*运算符将在它们上调用toString(),然后将结果的String强制转换为Number,这意味着[2] * [3]等于2 * 3。此外,除法运算符在对它们进行操作之前会将它们强制转换为数字。所有这些强制行为都是隐式发生的。

强制转换显式转换之间的界限并不是一成不变的。例如,可以通过强制性的副作用明确和有意地转换类型。考虑表达式someString * 1,它可以用来将字符串强制转换为数字,使用强制转换来实现。在我们的转换中,至关重要的是我们清楚地传达我们的意图

由于强制转换是隐式发生的,它可能是许多错误和意外行为的原因。为了避免这种陷阱,我们应该始终对操作数的类型有很强的信心。然而,强制转换是完全有意的,可以帮助创建更可靠的代码库。在接口的更公共或暴露的一侧,通常会预先将类型转换为所需的类型,以防接收到的类型不正确。

在这里观察一下,我们如何明确地将haystackneedle的值都转换为String类型:

function countOccurrences(haystack, needle) {

  haystack = String(haystack);
  needle = String(needle);

  let count = 0;

  for (let i = 0; i < haystack.length; count++, i += needle.length) {
    i = haystack.indexOf(needle, i);
    if (i === -1) break;
  }

  return count;
}

countOccurrences('What apple is the best type of apple?', 'apple'); // => 2
countOccurrences('ABC ABC ABC', 'A'); // => 3

由于我们依赖于haystack字符串上的indexOf()方法,根据我们所期望的防御级别,将haystack转换为字符串是有意义的,这样我们就可以确保它具有可用的方法。将needle转换为字符串也会编码更高级别的确定性,这样我们和其他程序员就可以感到放心。

当我们正在创建可重用的实用程序、面向公众的 API 或以降低对接收到的类型的信心的方式消耗的任何接口时,预先将值转换为布尔值以防止不良类型的防御性方法是最佳的。

像 JavaScript 这样的动态类型语言被许多人视为混乱的邀请。这些人可能习惯于严格类型的语言提供的舒适和确定性。事实上,如果充分并谨慎地使用,动态语言可以使我们的代码更加深思熟虑,并且更能适应用户不断变化的需求。在本节的其余部分,我们将讨论转换为各种类型,包括我们可以利用的显式转换机制以及语言内部采用的各种强制行为。我们将首先看一下布尔转换。

转换为布尔值

JavaScript 中的所有值在转换为布尔值时,除非它们是七个假值原始值(falsenullundefined0n0""NaN),否则都将返回true

要将值转换为布尔值,我们可以简单地将该值传递给布尔构造函数,将其作为函数调用:

Boolean(0); // => false
Boolean(1); // => true

当值存在于布尔上下文中时,语言会将值强制转换为布尔值。以下是一些此类上下文的示例(每个都标有HERE):

  • if ( HERE ) {...}

  • do {...} while (HERE)

  • while (HERE) {...}

  • for (...; HERE; ...) {...}

  • [...].filter(function() { return HERE })

  • [...].some(function() { return HERE })

这个列表并不详尽。我们的值将被强制转换为布尔值的情况还有很多。通常很容易判断。如果一个语言结构或本地提供的函数或方法允许您指定两种可能的路径(也就是如果 X 那么做这个,否则做那个),那么它将在内部强制转换您表达的任何值为布尔值。

将值转换为布尔值的常见习语,除了更明确地调用Boolean()之外,还有双感叹号,即一元逻辑NOT运算符(!)重复两次:

!!1;  // => true
!![]; // => true
!!0;  // => false
!!""; // => false

两次重复逻辑NOT运算符将两次反转值的布尔表示。通过将其括起来,更容易理解双感叹号的语义:

!( !( value ) )

这实际上做了四件事:

  • 将值转换为布尔值(Boolean(value))。

  • 如果值为true,则将其变为false。如果值为false,则返回true

  • 将结果值转换为布尔值(Boolean(value))。

  • 如果值为true,则将其变为false。如果值为false,则返回true

换句话说:这做了一个逻辑非,然后又做了一个,结果是原始值本身的布尔表示。

当您创建一个必须返回布尔值的函数或方法,但处理的值不是布尔值时,显式地将值转换为布尔值是特别有用的。例如,我可能希望创建一个isNamePopulated函数,如果名称变量不是一个填充的字符串或是nullundefined,则返回false

function isNamePopulated(name) {
  return !!name;
}

如果name是一个空的Stringnullundefined,这将有助于返回false

isNamePopulated('');        // => false
isNamePopulated(null);      // => false
isNamePopulated(undefined); // => false

isNamePopulated('Sandra');  // => true

如果name是任何其他假值(例如 0),它也会偶然返回false,如果name是任何真值,它会返回true

isNamePopulated(0); // => false
isNamePopulated(1); // => true

这可能看起来完全不可取,但在这种情况下,这可能是可以接受的,因为我们已经假设name是一个Stringnullundefined,所以我们只关心函数在这些值方面是否履行了它的合同。您对此的舒适程度完全取决于您具体的实现和它提供的接口。

转换为字符串

通过调用String构造函数作为常规函数(即不作为构造函数)来实现将值转换为String

String(456); // => "456"
String(true); // => "true"
String(null); // => "null"
String(NaN); // => NaN
String([1, 2, 3]); // => "1,2,3"
String({ foo: 1 }); // => "[object Object]"
String(function(){ return 'wow' }); // => "function(){ return 'wow' }"

使用您的值调用String()是将值转换为String的最明确和清晰的方法,尽管有时会使用更简洁的模式:

'' + 1234; // => "1234"
`${1234}`; // => "1234"

这两个表达式可能看起来是等价的,对于许多值来说确实如此。但是,在内部,它们的工作方式是不同的。正如我们将在后面看到的,+运算符将通过调用其内部的ToPrimitive机制来区分给定操作数是否为String,这样操作数的valueOf(如果有)将在其toString实现之前被查询。然而,当使用模板文字(例如${value})时,任何插入的值都将直接转换为字符串(而不经过ToPrimitive)。值的valueOftoString方法提供不同的值的可能性总是存在的。看看下面的例子,它展示了如何通过定义我们自己的toStringvalueOf实现来操纵两个看似等价表达式的返回值:

const myFavoriteNumber = {
  name: 'Forty Two',
  number: 42,
  valueOf() { return number; },
  toString() { return name; }
};

`${myFavoriteNumber}`; // => "Forty Two"
'' + myFavoriteNumber; // => 42

这可能是一个罕见的情况,但仍然值得考虑。通常,我们假设我们可以轻松地将任何值可靠地转换为字符串,但情况并非总是如此。

传统上,很常见依赖于值的toString()方法并直接调用它:

(123).toString(); // => 123

但是,如果值为nullundefined,那么您将收到一个TypeError

null.toString();      // ! TypeError: Cannot read property 'toString' of null
undefined.toString(); // ! TypeError: Cannot read property 'toString' of undefined

此外,toString方法不能保证返回string。请注意,我们可以实现自己的toString方法,返回Array

({
  toString() { return ['not', 'a', 'string'] }
}).toString(); // => ["not", "a", "string"]

因此,最好总是通过非常明确和清晰的String(...)进行string转换。使用间接的强制形式、副作用或盲目依赖toString可能会产生意想不到的结果。请记住,即使您对这些机制有很好的了解并且感到舒适使用它们,也不意味着其他程序员会这样做。

转换为数字

通过调用Number构造函数作为常规函数,可以将值转换为Number

Number('10e3');     // => 10000
Number(' 4.6');     // => 4.6
Number('Infinity'); // => Infinity
Number('wat');      // => NaN
Number(false);      // => 0
Number('');         // => 0

此外,还有一元加号+运算符,它基本上做了相同的事情:

+'Infinity'; // => Infinity
+'55.66';    // => 55.66
+'foo';      // => NaN

这是将非Number转换为Number类型的唯一两种方法,但 JavaScript 还提供了其他从字符串中提取数值的技术。其中一种技术是parseInt,它是一个全局可用的原生函数,接受一个String和一个可选的radix参数(默认为base 10,即十进制)。如果第一个参数不是String,它将自然地将其转换为String,然后尝试从String中提取指定radix的第一个整数。通过这样做,您可以实现以下结果:

parseInt('1000');   // => 1000
parseInt('100', 8); // => 64 (i.e. octal to decimal)
parseInt('AA', 12); // => 130 (i.e. hexadecimal to decimal)

如果字符串以0x0X为前缀,则parseInt将假定radix16十六进制):

parseInt('0x10'); // => 16

一些浏览器和其他环境也可能将0的前缀视为八进制radix的指示符。

// (In **some** environments)
parseInt('023'); // => 19 (assumed octal -> decimal)

parseInt()还将有效地修剪String,忽略任何初始空格,并忽略String中第一个找到的整数之后的所有内容:

parseInt(' 111 222 333'); // => 111
parseInt('\t\n0xFF');     // => 255

parseInt通常不受欢迎,因为它从String中提取整数的机制是晦涩的,并且如果没有提供radix,它可能会动态选择自己的radix。如果必须使用parseInt,请谨慎使用,并充分了解其操作方式。并始终提供radix参数。

parseInt类似,还有一个原生的parseFloat函数,它将尝试从给定的String中提取float(即浮点数):

parseFloat('42.01');  // => 42.01
parseFloat('\n1e-3'); // => 0.001

parseFloat将修剪字符串,然后查找从*0^(th)*字符开始的可以被语言自然解析的最长字符集,就像可以解析数字文字一样。因此,它可以很好地处理包含可解析数字序列之外的非数字字符的字符串:

parseFloat('   123 ... rubbish here...'); // => 123

如果我们将这样的字符串传递给Number(...),将导致NaN被评估。因此,在一些罕见的情况下,parseFloat可能对您更有用。

parseFloatparseInt都会在尝试提取之前将其初始参数转换为String。因此,如果您的第一个参数是对象,则应该注意它可能如何自然地强制转换为字符串。如果您的对象实现了不同的toStringvalueOf方法,则应该期望parseIntparseFloat只使用toString(除非还实现了[Symbol.toPrimitive]())。这与Number(...)相反,后者将尝试直接将其参数转换为Number(而不是首先将其转换为String),因此将优先考虑valueOf而不是toString

const rareSituation = {
  valueOf() { return "111"; },
  toString() { return "999"; }
};

Number(rareSituation); // => 111
parseFloat(rareSituation); // => 999
parseFloat(rareSituation); // => 999

在大多数情况下,将任何值转换为Number应该通过Number或一元加号+运算符尝试。只有在需要使用它们的数值提取算法时,才应该使用parseFloatparseInt

转换为原始类型

将值转换为其原始表示形式并不是我们可以直接做的事情,但是在许多不同的情况下,语言会隐式地(即*强制性地)进行转换,比如当您尝试使用抽象相等运算符==来比较StringNumberSymbolObject的值时。在这种情况下,Object将通过一个名为ToPrimitive的内部过程转换为其原始表示形式,该过程总结如下:

  1. 如果object[Symbol.toPrimitive]存在,并且在调用时返回一个原始值,则使用它

  2. 如果object.valueOf存在,并且返回一个原始值(非Object),则使用它的返回值

  3. 如果object.toString存在,则使用它的返回值

如果我们尝试使用==进行比较,我们可以看到ToPrimitive的作用:

function toPrimitive() { return 1; }
function valueOf() { return 2; }
function toString() { return 3; }

const one = { [Symbol.toPrimitive]: toPrimitive, valueOf, toString };
const two = { valueOf, toString };
const three = { toString };

1 == one; // => true
2 == two; // => true
3 == three; // => true

如您所见,如果一个对象有所有三种方法([Symbol.toPrimitive]valueOftoString),那么将使用[Symbol.toPrimitive]。如果只有valueOftoString,那么将使用valueOf。当然,如果只有toString,那么将使用它。

如果使用String的提示调用ToPrimitive(这意味着它已被指示尝试强制转换为String而不是任何原始类型),则该过程中的*2**3*可能会交换。这种情况的一个例子是当您使用计算成员访问运算符(object[something])时,如果something是一个对象,则它将通过ToPrimitive使用String的提示转换为String,这意味着在valueOf()之前将尝试toString()。我们可以在这里看到这一点:

const object = { foo: 123 };
const something = {
  valueOf() { return 'baz'; },
  toString() { return 'foo'; }
};

object[something]; // => 123

我们在something上定义了toStringvalueOf,但只使用toString来确定在object上访问哪个属性。

如果我们没有定义自己的方法,比如valueOftoString,那么将使用我们使用的任何对象的[[Prototype]]上可用的默认方法。例如,数组的原始表示形式是由Array.prototype.toString定义的,它将简单地使用逗号作为分隔符将其元素连接在一起:

[1, 2, 3].toString(); // => "1,2,3"

所有类型都有自己本地提供的valueOftoString方法,因此,如果我们希望强制ToPrimitive内部过程使用我们自己的方法,那么我们将需要通过直接提供我们的对象的方法或从[[Prototype]]继承来覆盖本地方法。例如,如果您希望提供一个具有自己的原始转换行为的自定义数组抽象,那么您可以通过扩展Array构造函数来实现:

class CustomArray extends Array {
  toString() {
    return this.join('|');
  }
}

然后,您可以依赖于CustomArray实例以其自己独特的方式被ToPrimitive过程处理:

String(new CustomArray(1, 2, 3));    // => 1|2|3
new CustomArray(1, 2, 3) == '1|2|3'; // => true

所有运算符和本地语言结构的强制行为都会有所不同。每当您将一个值传递给期望原始类型(通常是字符串或数字)的语言结构或运算符时,它可能会通过ToPrimitive。因此,了解这个内部过程是很有用的。当我们开始详细探索 JavaScript 的所有运算符时,我们也会参考这一部分。

总结

在本章中,我们继续探索 JavaScript 的内部机制,涵盖了语言的动态特性。我们已经看到了如何检测各种类型以及强制和转换的微妙复杂性。这些主题很难掌握,但它们将是有用的。JavaScript 代码中出现的许多反模式都归结于对语言结构和机制的基本误解,因此对这些主题有深入的了解将极大地帮助我们写出干净的代码。

在下一章中,我们将继续探讨类型,通过探索 JavaScript 的运算符。你可能已经对其中许多内容有很好的了解,但由于 JavaScript 的动态特性,它们的使用有时会产生意想不到的结果。因此,下一章将全力以赴地仔细探索语言的运算符。

第八章:运算符

在前一章关于动态类型的章节中,我们探讨了类型强制转换和检测等主题;我们还涵盖了几个运算符。在本章中,我们将继续探讨 JavaScript 语言提供的每个运算符。对 JavaScript 运算符的深入理解将使我们在这种有时看起来令人困惑的语言中感到非常有力。遗憾的是,理解 JavaScript 没有捷径,但当您开始探索它的运算符时,您会看到模式出现。例如,许多乘法运算符的工作方式相似,逻辑运算符也是如此。一旦您熟悉了主要运算符,您将开始看到其中有一种优雅的复杂性。

如果你时间紧迫,将这一章视为参考可能会有所帮助。不要觉得你需要详尽地记住每个运算符行为的每个细节。

在本章中,我们将涵盖以下主题:

  • 什么是运算符?

  • 算术和数值运算符

  • 逻辑运算符

  • 比较运算符

  • 赋值运算符

  • 属性访问运算符

  • 其他运算符和语法

  • 位运算符

现在我们准备好深入研究了,我们需要问自己的第一个问题是:什么是运算符?

什么是运算符?

在 JavaScript 中,运算符是一个独立的语法部分,形成一个表达式,通常用于从一组输入(称为操作数)中推导出某些东西或计算逻辑或数学输出。

在这里,我们可以看到一个包含一个运算符(+)和两个操作数(35)的表达式:

3 + 5

任何运算符都可以说有四个特征:

  • 它的 arity:运算符接受多少个操作数

  • 它的功能:运算符如何处理它的操作数以及它的计算结果

  • 它的优先级:当与其他运算符组合使用时,运算符将如何分组

  • 它的结合性:当与相同优先级的运算符相邻时,运算符将如何行为

了解这些基本特征非常重要,因为它将极大地帮助您在 JavaScript 中使用运算符。

运算符的 arity

Arity 指的是一个运算符可以接收多少个操作数(或输入)。操作数是一个正式术语,用于指代您可以给予或传递给运算符的值。

如果我们考虑大于运算符(>),它接收两个操作数:

a > b

在这个例子中,a是它的第一个操作数(或左侧操作数)。而b是它的第二个(或右侧操作数)。由于它接收两个操作数,所以大于运算符被认为是一个二元运算符。在 JavaScript 中,我们有一元、二元和三元运算符:

// Unary operator examples (one operand)
-a
!a

// Binary operator examples (two operands)
a == b
a >= b

// Ternary operator examples (three operands)
a ? b : c

在 JavaScript 中只有一个三元运算符,即条件运算符(a ? b : c)。由于它是唯一的三元运算符,有时它被简单地称为三元运算符,而不是它的正式名称。

了解给定运算符的 arity 是至关重要的——就像知道要传递给函数多少个参数一样重要。在组合操作时,考虑我们如何传达我们的意图也很重要。由于操作可以连续出现,有时可能不清楚哪个运算符指的是哪个操作数。考虑这个令人困惑的表达式:

foo + + baz - - bar

为了避免对这样的操作产生困惑,通常将一元运算符靠近它们的操作数,并且甚至使用括号来使意图绝对清晰:

foo + (+baz) - (-bar)

与代码的所有部分一样,运算符必须小心使用,并关心将来将不得不遇到、理解和维护代码的个人或个人(包括您未来的自己)。

运算符功能

运算符的功能就是它做什么以及它的计算结果。我们将逐个讨论每个运算符,因此在这里除了一些基本的假设之外,没有太多要说的。

在 JavaScript 中,每个运算符都是独立的实体,不与其操作的操作数类型绑定。这与其他一些语言相反,在其他语言中,运算符被映射到可重写的函数,或者以某种方式附加到操作数本身。在 JavaScript 中,运算符是它们自己的语法实体,并具有不可重写的功能。但是,在某些情况下,它们的功能是可扩展的。

在使用以下类型的运算符时,语言将在内部尝试强制转换:

  • 算术运算符(即+*/-等)

  • 递增运算符(即++--

  • 位运算符(即~<<|等)

  • 计算成员访问运算符(即...[...]

  • 非严格比较运算符(即><>=<===

为了明确地覆盖这些强制转换机制,您可以为您打算用作操作数的任何对象提供valueOf()toString()[Symbol.toPrimitive]()方法:

const a = { valueOf() { return 3; } };
const b = { valueOf() { return 5; } };

a + b; // => 8
a * b; // => 15

正如我们在上一章的转换为原始值部分中所介绍的,这些方法将根据使用的确切运算符或语言构造以特定顺序调用。例如,在所有算术运算符的情况下,valueOf将在toString之前尝试。

运算符优先级和结合性

在组合使用多个运算符时,操作的顺序由两种机制定义:优先级结合性。运算符的优先级是从120的数字,并定义了一系列运算符的运行顺序。一些运算符共享相同的优先级。结合性定义了具有相同优先级的运算符将被操作的顺序(从左到右或从右到左)。

考虑以下操作:

1 + 2 * 3 / 4 - 5;

在 JavaScript 中,这些特定的数学运算符具有以下优先级:

  • 加法运算符(+)的优先级为13

  • 乘法运算符(*)的优先级为14

  • 除法运算符(/)的优先级为14

  • 减法运算符(-)的优先级为13

它们都具有从左到右的结合性。由于优先级更高的运算符首先出现,并且具有相同优先级的运算符将根据它们的结合性出现,我们可以说我们的示例操作按以下顺序进行:

  1. 乘法(具有优先级14中最左边的)

  2. 除法(具有优先级14中最左边的)

  3. 加法(具有优先级13中最左边的)

  4. 减法(具有优先级13中下一个最左边的)

如果我们要使用括号明确地对我们的操作进行分组,那么它看起来会像这样:

(
  1 +
  (
    (2 * 3)
    / 4
  )
) - 5;

每个运算符,甚至非数学运算符,都有特定的优先级和结合性。例如,typeof运算符的优先级为16。如果您将它与优先级较低的运算符结合使用,这可能会引起头痛:

typeof 1 + 2; // => "number2"

由于+运算符的优先级低于typeof,JavaScript 将在内部按以下方式运行此操作:

(typeof 1) + 2;

因此,结果是typeof 1(即"number")与2连接(产生"number2")。为了避免这种情况,我们必须使用自己的括号来强制顺序:

typeof (1 + 2); // => "number"

顺便说一句,这就是为什么您经常会看到带括号的typeoftypeof(...)),这样看起来就像是在调用函数。然而,它实际上是一个运算符,括号只是为了强制特定的操作顺序。

你可以通过阅读 ECMAScript 规范或在网上搜索“JavaScript 运算符优先级”来发现每个运算符的确切优先级。请注意,用于指示优先级的数字在 1 和 20 之间,并不是来自 ECMAScript 规范本身,而只是一种理解优先级的有用方式。

知道每个运算符的优先级和结合性并不是我们应该期望我们的同事知道的事情。假设他们知道一些基本数学运算符的优先级可能是合理的,但不应该认为他们知道更多。因此,通常需要通过使用括号来提供清晰度,即使在可能不严格需要的情况下。这在复杂的操作中尤为重要,其中有大量连续的运算符,就像这个例子中一样:

function calculateRenderedWidth(width, horizontalPadding, scale) {
  return (width + (2 * horizontalPadding)) * scale;
}

在这里,包裹(2 * horizontalPadding)的括号在技术上是不必要的,因为乘法运算符自然比加法运算符具有更高的优先级。然而,提供额外的清晰度是有用的。阅读这段代码的程序员会感激地花费更少的认知能量来辨别操作的确切顺序。然而,像许多本意良好的事情一样,这可能会走得太远。不应该包括既不提供清晰度也不强制不同操作顺序的括号。这种多余的例子可能是在额外的括号中包裹整个return表达式:

function calculateRenderedWidth(width, horizontalPadding, scale) {
  return ((width + (2 * horizontalPadding)) * scale);
}

最好避免这样做,因为如果走得太远,它可能会给代码的读者增加额外的认知负担。对于这种情况的一个很好的指导是,如果你倾向于添加额外的括号以提高清晰度,你可能应该将操作拆分成多行:

function calculateRenderedWidth(width, horizontalPadding, scale) {
  const leftAndRightPadding = 2 * horizontalPadding;
  const widthWithPadding = width + leftAndRightPadding;
  const scaledWidth = widthWithPadding * scale;
  return scaledWidth;
}

这些额外的行不仅提供了关于操作顺序的清晰度,还通过有用地将每个操作分配给一个描述性变量,提供了每个单独操作的目的。

知道每个运算符的优先级和结合性并不一定是至关重要的,但知道这些机制如何支持每个操作是非常有用的。大多数情况下,正如你所看到的,最好将操作分成自包含的行或组,以便清晰,即使我们的运算符的内部优先级或结合性并不要求这样做。最重要的是,我们必须始终考虑我们是否清楚地传达了我们代码的意图给读者。

普通的 JavaScript 程序员不会对 ECMAScript 规范有百科全书式的了解,因此,我们不应该要求这样的知识来理解我们编写的代码。

了解运算符背后的机制为我们探索 JavaScript 中的各个运算符铺平了道路。我们将从探索算术和数字运算符开始。

算术和数字运算符

JavaScript 中有八个算术或数字运算符:

  • 加法a + b

  • 减法a - b

  • 除法a / b

  • 乘法a * b

  • 取余a % b

  • 指数运算a ** b

  • 一元加+a

  • 一元减-a

算术和数字运算符通常会将它们的操作数强制转换为数字。唯一的例外是+加法运算符,如果传递了一个非数字的操作数,它将假定字符串连接的功能而不是加法。

所有这些操作的一个保证的结果是值得事先了解的。NaN的输入保证了NaN的输出:

1 + NaN; // => NaN
1 / NaN; // => NaN
1 * NaN; // => NaN
-NaN;    // => NaN
+NaN;    // => NaN
// etc.

除了这个基本假设之外,每个运算符的行为都略有不同,因此值得逐个讨论。

加法运算符

加法运算符是一个双重用途运算符:

  • 如果任一操作数是String,那么它将连接两个操作数。

  • 如果没有操作数是String,那么它将把两个操作数都作为数字相加

为了实现它的双重目的,+运算符首先需要确定你传递的操作数是否可以被视为字符串。显然,原始的String值显然是一个字符串,但对于非原始值,+运算符将尝试通过依赖我们在上一章中详细介绍的内部ToPrimitive过程将你的操作数转换为它们的原始表示。如果+操作数的ToPrimitive的输出是一个字符串,那么它将把两个操作数连接为字符串。否则,它将把它们作为数字相加。

+运算符既可以进行数字相加,也可以进行连接,这使得它相当复杂,因此我们通过几个例子来帮助我们理解。

两个操作数都是数字

解释:当两个操作数都是原始数字时,+运算符非常简单地将它们相加:

1 + 2; // => 3

两个操作数都是字符串

解释:当两个操作数都是原始字符串时,+运算符非常简单地将它们连接在一起:

'a' + 'b'; // => "ab"

一个操作数是字符串

解释:当只有一个操作数是原始字符串时,+运算符将强制转换另一个为String,然后将两个结果字符串连接在一起:

123 + 'abc'; => "123abc"
'abc' + 123; => "abc123"

一个操作数是非原始值

解释:当任一操作数是非原始时,+运算符将把它转换为原始值,然后按照新的原始表示进行操作。这里有一个例子:

[123] + 123; // => "123123"

在这种情况下,JavaScript 将通过使用[123].toString()的返回值(即"123")将[123]转换为它的原始值。由于数组的原始表示是它的String表示,+运算符将操作,就好像我们只是在做"123" + 123一样,我们知道结果是"123123"`。

结论-了解你的操作数!

在使用+运算符时,特别重要的是要知道你正在处理的操作数是什么。如果不知道,那么你的操作结果可能会出乎意料。+运算符可能是最复杂的运算符之一,因为它具有双重目的。大多数运算符都不那么复杂。接下来我们将探讨的减法运算符则幸运地简单得多。

减法运算符

减法运算符(-)就像它的名字一样。它接受两个操作数,从左操作数中减去右操作数:

555 - 100; // => 455

如果任一操作数不是数字,它将被强制转换为数字:

'5' - '3'; // => 2
'5' - 3;   // => 2
5 - '3';   // => 2

这也包括非原始类型:

[5] - [3]; // => 2

在这里,我们看到两个数组,每个数组都有一个元素,相互相减。这似乎毫无意义,直到我们想起数组的原始表示是其连接元素作为字符串,即分别是"5""3"

String([5]); // => "5"
String([3]); // => "3"

然后,它们将通过等同于以下操作的方式转换为它们的数字表示,即53

Number("5"); // => 5
Number("3"); // => 3

因此,我们得到了直观的操作5减去3,我们知道结果是2

除法运算符

除法运算符,就像减法运算符一样,接受两个它将强制转换为数字的操作数。它将用左操作数除以右操作数:

10 / 2; // => 5

这两个操作数正式称为被除数和除数(被除数/除数),并且将始终根据浮点数运算进行评估。在 JavaScript 中不存在整数除法,这意味着你的除法结果可能总是包含小数点,并且可能会受到Number.EPSILON的误差范围的影响。

当除以零时要小心,因为你可能会得到NaN(当零除以零时)或Infinity(当非零数除以零时):

10 / 0;  // => Infinity
10 / -0; // => -Infinity
0 / 0;   // => NaN

如果你的除数是Infinity,你的除法结果将始终评估为零(0-0),除非你的被除数也是Infinity,在这种情况下,你将收到NaN

1000 / Infinity; // => 0
-1000 / Infinity; // => -0
Infinity / Infinity; // => NaN

在预期除数或被除数为零、NaNInfinity的情况下,最好是谨慎处理,并在操作之前或之后明确检查这些值,如下所示:

function safeDivision(a, b) {
  const result = a / b;
  if (!isFinite(result)) {
    throw Error(`Division of ${a} by ${b} is unsafe`);
  }
  return result;
}

safeDivision(1, 0); // ! Throws "Division of 1 by 0 is unsafe"
safeDivision(6, 2); // => 3

除法的边缘情况可能看起来吓人,但在日常应用中并不经常遇到。然而,如果我们编写医疗或金融程序,那么仔细考虑我们操作的潜在错误状态就是绝对必要的。

乘法运算符

乘法运算符的行为与除法运算符类似,除了它执行乘法的明显事实之外:

5 * 25; // => 125

需要注意强制转换的影响以及其中一个操作数是NaNInfinity的情况。相当直观地,任何非零有限值乘以Infinity将始终导致Infinity(带有适当的符号):

100 * Infinity; // => Infinity
-100 * Infinity; // => -Infinity

然而,将零乘以Infinity将始终导致NaN

0 * Infinity; // => NaN
-Infinity * -0; // => NaN

除了这些情况外,大多数乘法运算符的用法都是相当直观的。

余数运算符

余数运算符(%),也称为模运算符,类似于除法运算符。它接受两个操作数:左侧的被除数和右侧的除数。它将返回隐含除法操作后的余数:

10 % 5; // => 0
10 % 4; // => 2
10 % 3; // => 1
10 % 2; // => 0

如果除数为零,被除数为Infinity,或者任一操作数为NaN,则操作将评估为NaN

Infinity % Infinity; // => NaN
Infinity % 2; // => NaN
NaN % 1; // => NaN
1000 % 0; // => NaN

如果除数为Infinity,则结果将等于被除数:

1000 % Infinity; // => 1000
0.03 % Infinity; // => 0.03

模运算符在希望知道一个数是否可以被另一个数整除的情况下非常有用,比如在希望确定整数的偶数性奇数性时:

function isEvenNumber(number) {
  return number % 2 === 0;
}

isEvenNumber(0); // => true
isEvenNumber(1); // => false
isEvenNumber(2); // => true
isEvenNumber(3); // => false

与所有其他算术运算符一样,了解操作数的强制转换方式是有用的。大多数情况下,余数运算符的用法很直观,因此除了其强制转换行为和对NaNInfinity的处理之外,你应该会发现它的行为是直观的。

指数运算符

指数运算符(**)接受两个操作数,左侧是基数,右侧是指数。它将评估为基数的指数幂:

10 ** 2; // => 100
10 ** 3; // => 1,000
10 ** 4; // => 10,000

它在功能上与使用Math.pow(a, b)操作相同,尽管更简洁。与其他算术运算一样,它将内部强制转换其操作数为Number类型,并传入任何NaNInfinity或零的操作数将导致可能意外的结果,因此你应该尽量避免这种情况。

值得一提的一个奇怪情况是,如果指数为零,那么结果将始终为1,无论基数是什么。因此,基数甚至可以是InfinityNaN或其他任何值,结果仍然是1

1000 ** 0;     // => 1
0 ** 0;        // => 1
Infinity ** 0; // => 1
NaN ** 0;      // => 1

如果操作数中有一个是NaN,则所有其他算术运算符的行为将评估为NaN,因此这里的**的行为是非常独特的。另一个独特的行为是,如果你的第一个操作数本身是一个一元操作,它将抛出SyntaxError

+2 ** 2;
// SyntaxError: Unary operator used immediately
// before exponentiation expression. Parenthesis
// must be used to disambiguate operator precedence

这是为了防止程序员的歧义。根据他们之前接触的其他语言(或严格的数学符号),他们可能期望诸如-2 ** 2的情况要么是4要么是-4。因此,在这些情况下,JavaScript 会抛出异常,因此迫使你更加明确地使用(-2) ** 2-(2 ** 2)

除了这些独特的特点外,指数运算符可以被认为与其他二元(双操作数)算术运算符类似。一如既往:要注意你的操作数类型以及它们可能被强制转换的方式!

一元加运算符

一元加运算符(+...)将其操作数转换为Number,就好像它被传递给Number(...)一样:

+'42'; // => 42
+({ valueOf() { return 42; } });

为此,我们珍爱的内部ToPrimitive过程将被使用,如上一章节中讨论的转换为原始值部分。其结果将被重新强制转换为Number,如果它不已经是Number。因此,如果ToPrimitive返回String,那么该String将被转换为Number,这意味着非数字字符串将导致NaN

+({ toString() { return 'not a number'; } }); // => NaN

自然地,如果ToPrimitive中的String可以转换为Number,那么一元+运算符将评估为:

+({ toString() { return '12345'; } }); // => 12345

当通过+强制转换数组时,更容易观察到这一点:

+['5e3']; // => 5000

// Equivalent to:
Number(String(['5e3'])); // => 5000

一元+运算符通常用于程序员希望将类似数字的对象转换为Number以便随后与其他数字操作一起使用的地方。然而,通常最好明确使用Number(...),因为这样更清楚意图是什么。

一元+运算符有时可能会与其他操作混淆。考虑以下情况:

number + +someObject

对于不熟悉一元加号或不经常看到它的人来说,这段代码可能看起来像是包含了一个错别字。我们可以潜在地将整个一元操作包装在自己的括号中,以使其更清晰:

number + (+someObject)

或者我们可以使用更清晰的Number(...)函数:

number + Number(someObject)

总之,一元+运算符是Number(...)的便捷快捷方式。它很有用,尽管在大多数情况下,我们应该更清楚地表达我们的意图。

一元减号运算符

一元减号运算符(-...)将首先将其操作数转换为Number,方式与上一节中详细介绍的一元+运算符相同,然后对其取反:

-55;    // => -55
-(-55); // => 55
-'55';  // => -55

它的使用非常简单直观,尽管与一元+一样,有用的是消除一元运算符与其二元运算符对应物相邻的情况。这些情况可能会令人困惑:

number - -otherNumber

在这些情况下,最好用括号明确表达清晰:

number - (-otherNumber)

一元减号运算符通常只与文字数字操作数一起直接使用,以指定负值。与所有其他算术运算符一样,我们应确保我们的意图清晰,并且不要用长或令人困惑的表达式使人困惑。

现在我们已经探讨了算术运算符,我们可以开始研究逻辑运算符了。

逻辑运算符

逻辑运算符通常用于构建逻辑表达式,其中表达式的结果通知某些动作或不动作。JavaScript 中有三个逻辑运算符:

  • NOT 运算符(!a

  • AND 运算符(a && b

  • OR 运算符(a || b

与大多数其他运算符一样,它们可以接受各种类型并根据需要进行强制转换。AND 和 OR 运算符不同寻常地并不总是评估为Boolean值,并且都利用一种称为短路评估的机制,只有在满足某些条件时才执行两个操作数。当我们探索每个单独的逻辑运算符时,我们将更多地了解这一点。

逻辑 NOT 运算符

NOT 运算符是一元运算符。它只接受一个操作数并将该操作数转换为其布尔表示形式,然后取反,因此真值项目变为false,假值项目变为true

!1;    // => false
!true; // => false
!'hi;  // => false

!0;    // => true
!'';   // => true
!true; // => false

在内部,NOT 运算符将执行以下操作:

  1. 将操作数转换为布尔值(Boolean(operand)

  2. 如果结果值为true,则返回false;否则返回true

如上一章节中转换为布尔值部分所讨论的,将值转换为其布尔表示形式的典型习语是双重 NOT(即!!value),因为这实际上两次颠倒了值的真实性或虚假性,并评估为Boolean。更明确且稍微更受欢迎的习语是使用Boolean(value),因为意图比!!更清晰。

由于 JavaScript 中只有七个假值,因此 NOT 运算符只能在以下七种情况下评估为true

!false;     // => true
!'';        // => true
!null;      // => true
!undefined; // => true
!NaN;       // => true
!0n;        // => true
!0;         // => true

JavaScript 对假值和真值的严格定义是令人放心的。这意味着即使有人构造了一个具有各种原始表示的对象(想象一个具有返回假值的valueOf()的对象),所有内部布尔强制转换仍然只会对七个假值返回false,而不会返回其他任何值。这意味着我们只需要担心这七个值(情况可能会更糟……)。

总的来说,逻辑非运算符的使用非常简单。它是跨编程语言具有清晰语义的众所周知的语法。因此,在最佳实践方面并没有太多需要考虑的。至少,最好避免在代码中使用太多双重否定。双重否定是指将已经带有否定意义的变量应用于非运算符,如下所示:

if (!isNotEnabled) {
  // ...
}

对于阅读您的代码的人来说,这在认知上是昂贵的,因此容易产生误解。最好使用名称明确的布尔变量名称,以便使用它们的任何逻辑操作都容易理解。在这种情况下,我们只需重新命名变量并反转操作,如下所示:

if (isEnabled) {
  // ...
}

逻辑非运算符总的来说,在布尔上下文中最有用,比如if()while(),尽管在双非!!操作中也有习惯用法。从技术上讲,它是 JavaScript 中唯一保证返回Boolean值的运算符,无论其操作数的类型如何。接下来,我们将探讨与运算符。

逻辑与运算符

JavaScript 中的逻辑与运算符(&&)接受两个操作数。如果其左侧操作数为真值,则它将评估并返回右侧操作数;否则,它将返回左侧操作数:

0 && 1; // => 0
1 && 2; // => 2

对许多人来说,它可能是一个令人困惑的运算符,因为他们错误地认为它等同于问题“A 和 B 都是真的吗?”实际上,它更类似于“如果 A 是真的,那么给我 B;否则,我会接受 A”。人们可能会假设 JavaScript 会评估两个操作数,但实际上,如果左侧操作数为真,它只会评估右侧操作数。这被称为短路评估。JavaScript 不会将操作的结果值转换为Boolean:相反,它只会将该值返回,不变。如果我们要自己实现该操作,它可能看起来像这样:

function and(a, b) {
  if (a) return b;
  return a;
}

对于简单的操作,比如使一个if(...)语句依赖于两个值都为真,&&运算符将以一种完全令人满意和预期的方式行事:

if (true && 1) {
  // Both `true` and `1` are truthy!
}

然而,&&运算符也可以以更有趣的方式使用,比如当需要返回一个值,但只有在满足某些先决条件时:

function getFavoriteDrink(user) {
  return user && user.favoriteDrink;
}

在这里,&&运算符被用在一个非布尔上下文中,其结果不会发生强制转换。在这种情况下,如果其左侧操作数为假值(即,如果user为假),那么它将返回该值;否则,它将返回右侧操作数(即,user.favoriteDrink):

getFavoriteDrink({ favoriteDrink: 'Coffee' }); // => 'Coffee'
getFavoriteDrink({ favoriteDrink: null }); // => null
getFavoriteDrink(null); // => null

getFavoriteDrink函数的行为方式符合基本约定,如果user对象可用并且该对象上出现了favoriteDrink属性,则返回favoriteDrink,尽管其实际功能有点混乱:

getFavoriteDrink({ favoriteDrink: 0 }); // => 0
getFavoriteDrink(0); // => 0
getFavoriteDrink(NaN); // => NaN

我们的getFavoriteDrink函数并不对用户或favoriteDrink的特定性质进行任何考虑;它只是盲目地屈从于&&运算符,返回其左侧或右侧的操作数。如果我们对操作数的潜在值有信心,那么这种方法可能是可以的。

重要的是要花时间考虑&&将如何评估您提供的操作数的可能方式。要考虑的是,它不能保证返回Boolean,甚至不能保证评估右侧操作数。

&&运算符,由于其短路特性,也可以用于表达控制流。假设我们希望在isFeatureEnabled布尔值为真时调用renderFeature()。传统上,我们可能会使用if语句来实现:

if (isFeatureEnabled) {
  renderFeature();
}

但我们也可以使用&&

isFeatureEnabled && renderFeature();

这种以及其他不寻常的&&用法通常不被赞同,因为它们可能会掩盖程序员的意图,并对代码的读者造成困惑,这些读者可能对 JavaScript 中&&的操作方式了解不够透彻。尽管如此,&&运算符确实非常强大,应该在适当的情况下使用。你应该自由地使用它,但始终要意识到代码的典型读者可能如何看待这个操作,并始终考虑操作可能产生的潜在值。

逻辑或运算符

JavaScript 中的逻辑或运算符(||)接受两个操作数。如果其左侧操作数为真值,则它将立即返回该值;否则,它将评估并返回右侧操作数:

0 || 1; // => 1
2 || 0; // => 2
3 || 4; // => 3

&&运算符类似,||运算符也具有灵活性,它不会将返回值转换为布尔值,并且以短路方式进行评估,这意味着只有在左侧操作数满足条件时才会评估右侧操作数,即在这种情况下,如果右侧操作数为假:

true || thisWillNotExecute();
false || thisWillExecute();

传统上,程序员可能会假设逻辑或运算符类似于问题“A 或 B 是否为真?”,但在 JavaScript 中,它更类似于:“如果 A 为假,则给我 B;否则,我会接受 A”。如果我们自己实现这个操作,它可能看起来像这样:

function or(a, b) {
  if (a) return a;
  return b;
}

&&一样,这意味着||可以灵活使用以提供控制流或有条件地评估特定表达式:

const nameOfUser = user.getName() || user.getSurname() || "Unknown";

因此,应该谨慎使用它,考虑代码读者熟悉的内容,以及考虑所有潜在的操作数和操作结果值。

比较运算符

比较运算符是一组二元运算符,始终返回从两个操作数之间的比较派生的布尔值:

  • 抽象相等(a == b

  • 抽象不相等(a != b

  • 严格相等(a === b

  • 严格不相等(a !== b

  • 大于(a > b

  • 大于或等于(a >= b

  • 小于(a < b

  • 小于或等于(a <= b

  • 实例(a instanceof b

  • 在(a in b

这些运算符每个都有稍微不同的功能和强制行为,因此逐个地了解它们是很有用的。

抽象相等和不相等

抽象相等(==)和不相等(!=)运算符在内部依赖于相同的算法,该算法负责确定两个值是否可以被视为相等。在本节中,我们的示例只会探讨==,但请放心,!=总是==的相反。

在绝大多数情况下,不建议依赖抽象相等,因为它的机制可能会产生意想不到的结果。大多数情况下,你会选择严格相等(即===!==)。

当左侧和右侧操作数都是相同类型时,机制非常简单——运算符将检查这两个操作数是否是相同的值:

100 == 100;     // => true
null == null;   // => true
'abc' == 'abc'; // => true
123n == 123n;   // => true

当两个操作数都是相同类型时,抽象相等(==)与严格相等(===)完全相同。

由于 JavaScript 中所有非原始值都是相同类型(Object),抽象相等(==)如果你尝试比较两个非原始值(两个对象)并且它们不引用完全相同的对象,将始终返回false

[123] == [123]; // => false
/123/ == /123/; // => false
({}) == ({});   // => false

然而,当两个操作数的类型不同时,例如当你比较一个Number类型和一个String类型,或者一个Object类型和一个Boolean类型时,抽象相等的确切行为将取决于操作数本身。

如果其中一个操作数是Number,另一个是String,那么a == b操作等同于以下操作:

Number(a) === Number(b)

以下是一些示例:

123 == '123';  // => true
'123' == 123;  // => true
'1e3' == 1000; // => true

请注意,正如上一章中转换为数字部分所讨论的,字符串"1e3"将被内部转换为数字1000

继续深入研究——如果==运算符的操作数之一是Boolean,那么该操作再次等同于Number(a) === Number(b)

false == ''; // => true
// Explanation: Number(false) is `0` and Number('') is `0`

true == '1'; // => true
// Explanation: Number(true) is `1` and Number('1') is `1`

true == 'hello'; // => false
// Explanation: Number(true) is `1` and Number('hello') is `NaN`

false == 'hello'; // => false
// Explanation: Number(false) is `0` and Number('hello') is `NaN`

最后,如果不满足前面的条件,并且其中一个操作数是Object(而不是原始值),那么它将比较该对象的原始表示与另一个操作数。正如上一章中讨论的那样,在转换为原始值部分,这将尝试调用[Symbol.toPrimitive]()valueOf(),然后是toString()方法来建立原始值。我们可以在这里看到它的运作方式:

new Number(1) == 1; // => true
new Number().valueOf(); // => 1
({ valueOf() { return 555; }) == 555; // => true

由于它们复杂的强制行为,最好避免使用抽象相等不相等运算符。任何阅读到充斥着这些运算符的代码的人都无法对程序的条件和控制流程有很高的信心,因为抽象相等可能会有太多奇怪的边缘情况。

如果您发现自己想要使用抽象相等,例如,当一个操作数是数字,另一个是字符串时,考虑是否使用更严格的检查的组合或明确地转换您的值以获得更清晰和更少出错的结果;例如,不要使用aNumber == aNumericString,而是使用aNumber === Number(aNumericString)

严格相等和不相等

JavaScript 中的严格相等===)和严格不相等!==)运算符是清晰代码的重要组成部分。与其抽象相等的表亲不同,它们在处理操作数的方式上提供了确定性和简单性。

===运算符只有在其两个操作数完全相同时才会返回true

1 === 1;       // => true
null === null; // => true
'hi' === 'hi'; // => true

唯一的例外是当其中一个操作数是NaN时,此时它将返回false

NaN === NaN; // => false

严格相等不会进行任何内部强制转换,因此即使您有两个可以强制转换为相同数字的原始值,它们仍将被视为不相等:

'123' === 123; // => false

对于非原始值,两个操作数必须引用完全相同的对象:

const me = { name: 'James' };
me === me; // => true
me !== me; // => false

即使对象具有相同的结构或共享其他特征,如果它不是对同一对象的引用,它将返回false。我们可以通过尝试将包装的Number实例与值为3的数值文字3进行比较来说明这一点:

new Number(3) === 3; // => false

在这种情况下,抽象相等运算符(==)将评估为 true。您可能认为将new Number(3)强制转换为3更可取,但最好在比较之前明确设置操作数,使它们具有所需的类型。因此,在包含我们希望与Number进行比较的数值的String的示例中,最好首先通过Number()明确转换它:

Number('123') === 123; // => true

建议始终使用严格相等而不是抽象相等。它在每次操作的结果中提供了更多的确定性和可靠性,并且可以让您从抽象相等所涉及的多种强制行为中解脱出来。

大于和小于

大于>)、小于<)、大于或等于>=)和小于或等于<=)运算符都以类似的方式运行。它们遵循类似于抽象相等的算法,尽管值的强制转换方式略有不同。

首先要注意的是,这些运算符的所有操作数都将首先被强制转换为它们的原始表示。接下来,如果它们的原始表示都是字符串,那么它们将被词典顺序比较。如果它们的原始表示不都是字符串,那么它们将被从它们当前的类型转换为数字,然后再进行比较。这意味着即使你的操作数中只有一个是字符串,它们都将被数值比较。

词典比较

词典比较发生在两个操作数都是字符串时,并涉及每个字符串的逐个字符比较。广义上,更大的字符串是那些在字典中出现在后面的字符串。因此,banana在词典排序中将大于apple

正如我们在第六章中发现的那样,原始和内置类型,JavaScript 使用 UTF-16 来编码字符串,因此每个代码单元都是一个 16 位整数。UTF-16 代码单元从65U+0041)到122U+007A)如下:

ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz

后面出现的字符由更大的 UTF-16 整数表示。要比较任意两个给定的代码单元,JavaScript 将简单地比较它们的整数值。比如比较BA,可能会像这样:

const intA = 'A'.charCodeAt(0); // => 65
const intB = 'B'.charCodeAt(0); // => 66
intB > intA; // => true

每个操作数字符串中的每个字符都必须进行比较。为了做到这一点,JavaScript 将逐个代码单元地进行比较。在每个字符串的每个索引处,如果代码单元不同,较大的代码单元将被认为是更大的,因此该字符串将被认为比另一个字符串更大。

"AAA" > "AAB"
"AAB" > "AAC"

如果一个操作数等于另一个操作数的前缀,那么它将始终被认为是小于,如下所示:

'coff' < 'coffee'; // => true

正如你可能已经注意到的那样,小写的英文字母占据了比大写字母更高的 UTF-16 整数。这意味着大写字母被认为比小写字母小,因此在词典排序中会出现在它的前面。

'A' < 'a'; // => true
'Z' < 'z'; // => true
'Adam' < 'adam'; // => true

你还会注意到从9196的代码单元包括标点符号,`[]^_``。这也会影响我们的词典比较。

'[' < ']'; // => true
'_' < 'a'; // => true

Unicode 往往以一种方式排列,使得任何给定语言的字符在词典排序中自然排序,以便语言字母表中的第一个符号由比后面符号更低的 16 位整数表示。例如,在这里,我们看到泰语中“鸡”的单词("ไก่")在词典排序中小于“蛋”的单词("ไข่"),因为字符在泰语字母表中出现在之前。

'ไก่' < 'ไข่'; // => true ("chicken" comes before "egg")
'ก'.charCodeAt(0); // => 3585
'ข'.charCodeAt(0); // => 3586

Unicode 的自然顺序可能并不总是产生合理的词典顺序。正如我们在上一章中学到的,复杂的符号可以通过将多个代码单元组合成组合字符对、代理对(创建代码点)或甚至是图形簇来表达。这可能会产生各种困难。一个例子是下面的情况,其中给定的符号,即带抑扬符的拉丁大写字母 A,可以通过单一的 Unicode 代码点U+00C2或通过将大写字母"A"U+0041)与组合字符 ACCEN**TU+0302)组合来表达。在符号上和语义上,它们是相同的:

'Â'; // => Â
'A\u0302'; // => Â

然而,由于U+00C2(十进制:194)在技术上大于U+0041(十进制:65),在词典比较中它将被认为是大于,即使它们在符号上和语义上是相同的。

'Â' > 'A\u0302'; // => true

有成千上万这样的潜在差异需要注意,因此如果你发现自己需要进行词典排序,要注意 JavaScript 的大于小于运算符将受到 Unicode 固有排序的限制。

数值比较

使用 JavaScript 的大于和小于运算符进行数字比较是相当直观的。如前所述,你的操作数首先会被强制转换为它们的原始表示形式,然后再次被显式地强制转换为数字。对于两个操作数都是数字的情况,结果是完全直观的:

123 < 456; // => true

对于NaNInfinity,可以做出以下断言:

Infinity > 123; // => true
Infinity >= Infinity; // => true
Infinity > Infinity; // => false

NaN >= NaN; // => false
NaN > 3; // => false
NaN < 3; // => false

如果一个操作数具有不是Number的原始表示形式,则在比较之前将其强制转换为Number。如果你意外地将Array作为>的操作数传递,那么它首先会将其强制转换为其原始表示形式,对于数组来说,它是用逗号连接的所有单个强制转换元素的String,然后尝试将其强制转换为Number

// Therefore this:
[123] < 456;

// Is equivalent to this:
Number(String([123])) < 456

由于可能发生的复杂强制转换,最好始终将相同类型的操作数传递给><>=<=

instanceof 运算符

JavaScript 中的instanceof运算符允许你检测一个对象是否是构造函数的实例:

const component = new Component();
component instanceof Component; 

此操作将遍历其左侧操作数的[[Prototype]]链,寻找特定的constructor函数。然后它将检查这个构造函数是否等于右侧操作数。

由于它会遍历[[Prototype]]链,因此它可以安全地处理多重继承:

class Super {}
class Child extends Super {}

new Super() instanceof Super; // => true
new Child() instanceof Child; // => true
new Child() instanceof Super; // => true

如果右侧操作数不是函数(即不可调用为构造函数),那么将抛出 TypeError:

1 instanceof {}; // => TypeError: Right-hand side of 'instanceof' is not callable

instanceof运算符有时在区分原生类型方面很有用,比如判断一个对象是否是数组:

[1, 2, 3] instanceof Array; // => true

然而,这种用法已经在很大程度上被Array.isArray()取代,后者通常更可靠,因为它在Array被从另一个原生上下文(例如浏览器中的帧)传递给你的罕见情况下会正确工作。

in 运算符

如果在对象中找到属性,则in运算符将返回true

'foo' in { foo: 123 }; // => true

左侧操作数将被强制转换为其原始表示形式,如果不是Symbol,它将被强制转换为String。在这里,我们可以看到Array作为左侧操作数将被强制转换为其内容的逗号分隔序列(这是数组被强制转换为原始值的本机和默认方式,感谢Array.prototype.toString):

const object = {
  'Array,coerced,into,String': 123
};

['Array', 'coerced', 'into', 'String'] in object; // => true

在 JavaScript 中,所有看似是数字的属性名称都以字符串形式存储,因此访问someArray[0]等同于someArray["0"],因此询问对象是否具有数字属性时,in也将同样考虑0"0"

'0' in [1]; // => true
0 in { '0': 'foo' }; // => true

在确定给定对象中是否存在属性时,in运算符将遍历整个[[Prototype]]链,因此对链中所有级别的可访问方法和属性都返回true

'map' in [];     // => true
'forEach' in []; // => true
'concat' in [];  // => true

这意味着如果你想区分具有属性具有自身属性的概念,你应该使用hasOwnProperty,这是从Object.prototype继承的方法,它只会检查对象本身:

['wow'].hasOwnProperty('map'); // => false
['wow'].hasOwnProperty(0);     // => true
['wow'].hasOwnProperty('0');   // => true

总的来说,最好只在你确信不会与你期望使用的属性名称和对象的[[Prototype]]链提供的属性发生冲突时才使用in。即使你只是使用普通对象,你仍然需要担心原生原型。如果它以任何方式被修改(例如通过实用程序库),那么你就不能再对你的in操作的结果有很高的信任度,因此应该使用hasOwnProperty

在旧的库代码中,甚至可能会发现选择不依赖于被查询对象的hasOwnProperty的代码,因为害怕它可能已被覆盖。相反,它将选择直接使用Object.prototype.hasOwnProperty方法,并以该对象作为其执行上下文调用它:

function cautiousHasOwnProperty(object, property) {
  return Object.prototype.hasOwnProperty.call(object, property);
}

尽管如此,这可能过于谨慎了。在大多数代码库和环境中,使用继承的hasOwnProperty是足够安全的。同样,如果你考虑了风险,in运算符通常也是足够安全的。

赋值运算符

赋值运算符将其右侧操作数的值赋给其左侧操作数,并返回新赋的值。赋值操作的左侧操作数必须始终是可分配的有效标识符或属性。例如:

value = 1;
value.property = 1;
value['property'] = 1;

此外,您还可以使用解构赋值,它使您能够将左侧操作数声明为类似对象文字或数组的结构,指定您希望分配的标识符和您希望分配的值:

[name, hobby] = ['Pikachu', 'Eating Ketchup'];
name;  // => "Pikachu"
hobby: // => "Eating Ketchup"

我们将稍后进一步探讨解构赋值。现在,重要的是要知道它,以及常规标识符(foo=...)和属性访问器(foo.baz = ...foo[baz] = ...),都可以用作赋值运算符的左侧操作数。

从技术上讲,JavaScript 中有大量的赋值运算符,因为它将常规运算符与基本赋值运算符结合起来,以在常见情况下需要改变现有变量或属性所引用的值时创建更简洁的赋值操作。JavaScript 中的赋值运算符如下:

  • 直接赋值:=

  • 加法赋值:+=

  • 减法赋值:-=

  • 乘法赋值:*=

  • 除法赋值:/=

  • 余数赋值:%=

  • 按位左移赋值:<<=

  • 按位右移赋值:>>=

  • 按位无符号右移赋值:>>>=

  • 按位与赋值:&=

  • 按位异或赋值:^=

  • 按位或赋值:|=

除了直接赋值=运算符外,所有赋值运算符都会执行=之前指示的操作。因此,在+=的情况下,+运算符将应用于左右操作数,然后将结果分配给左侧操作数。因此,考虑以下语句:

value += 5

它将等同于:

value = value + 5

对于所有其他组合类型的赋值运算符也是一样的。我们可以依靠这一点和其他已有的知识来了解这些运算符与赋值结合时的工作方式。因此,我们不需要单独探索所有这些赋值运算符的变体。

赋值通常发生在单行的上下文中。通常会看到一个赋值语句单独出现,并以分号结束:

someValue = someOtherValue;

但赋值运算符并没有隐含要求这样做。事实上,你可以在语言中任何可以嵌入任何表达式的地方嵌入赋值。例如,以下语法是完全合法的:

processStep(nextValue += currentValue);

这是进行加法和赋值,然后将结果值传递给processStep函数。这与以下代码完全等效:

nextValue += currentValue;
processStep(nextValue);

请注意这里传递给processStep的是nextValue。赋值操作表达式的结果始终是被赋的值:

let a;
(a = 1); // => 1
(a += 2); // => 3
(a *= 2); // => 6

forwhile循环的上下文中经常看到赋值的情况:

for (let i = 0, l = arr.length; i < l; i += 1) { }
//       \___/  \____________/         \____/
//         |          |                  |
//    Assignment  Assignment       Additive Assignment

这和其他赋值模式都是完全可以接受的,因为它们被广泛使用,已经成为 JavaScript 的习惯用法。但在大多数其他情况下,最好不要在其他语言结构中嵌入赋值。例如fn(a += b)这样的代码对一些人来说可能不直观,因为可能不清楚实际传递给fn()的值是什么。

在编写干净的代码方面,我们在分配值时唯一需要问自己的问题是,我们的代码的读者(包括我们自己!)是否会发现分配正在发生,以及他们是否会理解正在分配的是什么

增量和减量(前缀和后缀)运算符

这四个运算符在技术上属于赋值的范畴,但它们足够独特,值得有自己的部分:

  • 后缀增量运算符(value++

  • 后缀减量运算符(value--

  • 前缀增量运算符(++value

  • 前缀减量运算符(--value

这些将简单地增加或减少1的值。它们通常出现在迭代上下文中,例如forwhile循环中。最好将它们视为对加法和减法赋值的简洁替代方法(即value += 1value -= 1)。然而,它们有一些独特的特点值得探讨。

前缀增量/减量

前缀增量和减量运算符允许您增加或减少任何给定的值,并将评估为新增的值:

let n = 0;

++n; // => 1 (the newly incremented value)
n;   // => 1 (the newly incremented value)

--n; // => 0 (the newly decremented value)
n;   // => 0 (the newly decremented value)

++n在技术上等同于以下的加法赋值:

n += Number(n);

注意当前的n值首先被转换为Number。这就是增量和减量运算符的性质:它们严格作用于数字。因此,如果nString,那么无法成功强制转换,那么n的新增值或减量值将是NaN

let n = 'foo';
++n; // => NaN
n;   // => NaN

在这里,我们可以观察到,由于将foo强制转换为Number失败,因此对其尝试增加也失败,返回NaN

后缀增量/减量

增量和减量运算符的后缀变体与前缀变体相同,只有一个区别:后缀变体将评估为值,而不是新增/减量后的值:

let n = 0;

n++; // => 0 (the old value)
n;   // => 1 (the newly incremented value)

n--; // => 1 (the old value)
n;   // => 0 (the newly decremented value)

这是至关重要的,如果不是有意使用,可能会导致不希望的错误。增量和减量运算符通常用于在这种差异无关紧要的情况下。例如,在for (_;_;_)语句的最后一个表达式中使用时,返回值在任何地方都没有使用,因此我们在以下两种方法之间看不到任何区别:

for (let i = 0; i < array.length; i++) { ...}
for (let i = 0; i < array.length; ++i) { ...}

然而,在其他情况下,评估的值是非常关键的。例如,在下面的while循环中,++i < array.length表达式在每次迭代时都会被评估,这意味着新增的值将与array.length进行比较。如果我们将其替换为i++ < array.length,那么你将比较增量之前的值,这意味着它会少一个,因此我们会得到额外的(不需要的!)迭代。你可以在这里观察到区别:

const array = ['a', 'b', 'c'];

let i = -1;
while (++i < array.length) { console.log(i); } Logs: 0, 1, 2

let x = -1;
while (x++ < array.length) { console.log(x); } // Logs: 0, 1, 2, 3

这是相当罕见的情况,特别是在语言中提供了更现代的迭代技术。但是增量和减量运算符在其他情境中仍然非常受欢迎,因此了解它们的前缀和后缀变体之间的区别是很有用的。

解构赋值

如前所述,赋值运算符(... =)的左操作数可以指定为解构对象或数组模式,如下所示:

let position = { x: 123, y: 456 };
let { x, y } = position;
x; // => 123
y; // => 456

这些模式通常看起来像ObjectArray字面量,因为它们分别以{}[]开头和结尾。但它们有些微的不同。

在解构对象模式中,当你想要声明要分配的标识符或属性时,你必须将它放置在对象字面量中的值的位置。也就是说,{ foo: bar }通常意味着将bar分配给foo,在解构模式中,它意味着*将foo的值分配给标识符bar。它是相反的。当你希望访问的值的属性名称与你希望在本地范围内分配的名称匹配时,你可以使用更短的语法,如{ foo },如下所示:

let message = { body: 'Dear Customer...' };

// Accessing `body` and assigning to a different name (`theBody`): 
const { body: theBody } = message;
theBody; // => "Dear Customer..."

// Accessing `body` and assigning to the same name (`body`):
const { body } = message;
body; // => "Dear Customer..."

对于数组,通常用于指定值的语法槽(即[这里,这里和这里])用于指定要分配值的标识符,因此序列中的每个标识符与数组中的相同索引元素相关联:

let [a, b, c] = [1, 2, 3];
a; // => 1
b; // => 2
c; // => 3

你还可以使用剩余运算符(...foo)指示 JavaScript 将剩余的属性分配给给定的标识符。以下是在解构数组模式中使用它的示例:

let [a, b, c, ...others] = [1, 2, 3, 4, 5, 6, 7];
others; // => [4, 5, 6, 7];

以下是在解构对象模式中使用它的示例:

let { name, ...otherThings } = {
 name: 'James', hobby: 'JS', location: 'Europe'
};
name; // => "James"
otherThings; // => { hobby: "JS", location: "Europe" }

只有在提供真正增加可读性和简单性时才解构你的赋值。

解构也可以发生在涉及多层次层次结构的对象结构中:

let city = {
  suburb: {
    inhabitants: ['alice', 'steve', 'claire']
  }
};

如果我们希望提取inhabitants数组并将其赋值给同名变量,那么可以这样做:

let { suburb: { inhabitants } } = city;
inhabitants; // => ["alice", ...]

解构数组模式可以嵌套在解构对象模式中,反之亦然:

let {
  suburb: {
    inhabitants: [firstInhabitant, ...otherInhabitants]
  }
} = city;

firstInhabitant; // => "alice"
otherInhabitants: // => ["steve", "claire"]

解构赋值非常有用,可以避免像这样的冗长赋值:

let firstInhabitant = city.suburb.inhabitants[0];

但是,应该谨慎使用它,因为它有时会使阅读您的代码的人感到困惑。虽然在第一次编写时可能看起来直观,但解构赋值通常很难理清。考虑以下声明:

const [{someProperty:{someOtherProperty:[{foo:baz}]}}] = X;

这在认知上是昂贵的。也许,用传统方式表达这个逻辑会更直观:

const baz = X[0].someProperty.someOtherProperty[0].foo;

总的来说,解构赋值是 JavaScript 语言中一个令人兴奋和有用的特性,但应该以谨慎的方式使用,考虑到它可能引起混淆的可能性。

属性访问运算符

通过使用两种运算符之一来实现 JavaScript 中的属性访问:

  • 直接属性访问:obj.property

  • 计算属性访问obj[property]

直接属性访问

直接访问属性的语法是一个单独的句点字符,左侧操作数是你希望访问的对象,右侧操作数是你希望访问的属性名称:

const street = {
  name: 'Marshal St.'
};

street.name; // => "Marshal St."

右侧操作数必须是有效的 JavaScript 标识符,因此不能以数字开头,不能包含空格,并且一般情况下不能包含 JavaScript 规范中其他地方存在的任何标点符号字符。但是,你可以拥有以所谓的外来 Unicode 字符命名的属性,例如π(PI):

const myMathConstants = { π: Math.PI };
myMathConstants.π; // => 3.14...

这是一种不寻常的做法,通常只在新颖的设置中使用。然而,在嵌入了存在现有含义的合法外来符号(数学物理等)的问题域中,它可能确实有用。

计算属性访问

在无法通过直接属性访问直接访问属性的情况下,可以计算要访问的属性名称,并用方括号括起来:

someObject["somePropertyName"]

它是任何表达式的右侧操作数,这意味着你可以自由计算一些值,然后将其强制转换为字符串(如果它还不是字符串),并用作要访问的对象的属性名称:

someObject[ computeSomethingHere() ]

通常用于访问包含使它们无效的字符的属性名称,因此无法与直接属性访问运算符一起使用。这包括数字属性名称(例如在数组中找到的属性名称)、带有空格的名称或在语言中其他地方存在标点符号或关键字的名称:

object[1];
object['a property name with whitespace'];
object['{[property.name.with.odd.punctuation]}'];

最好只在没有其他选择的情况下依赖计算属性访问。如果可能直接访问属性(即object.property),那么应该优先考虑这种方式。同样,如果你正在决定对象可能包含的属性,最好使用语言内有效的标识符名称,这样可以方便直接访问。

其他运算符和语法

还有一些剩下的运算符和语法要探讨,它们不属于任何其他运算符类别:

  • 删除操作符delete VALUE

  • void 操作符void VALUE

  • new 操作符new VALUE

  • 展开语法... VALUE

  • 分组(VALUE)

  • 逗号操作符VALUE, VALUE, ...

删除操作符

delete操作符可以用来从对象中删除属性,因此它的唯一操作数通常采用属性访问器的形式,如下所示:

delete object.property;
delete object[property];

只有被视为可配置的属性才能以这种方式被删除。所有传统添加的属性默认都是可配置的,因此可以被删除:

const foo = { baz: 123; };

foo.baz;        // => 123
delete foo.baz; // => true
foo.baz;        // => undefined
'baz' in foo;   // => undefined

但是,如果属性是通过defineProperty添加的,并且configurable设置为false,那么它将无法被删除:

const foo = {};
Object.defineProperty(foo, 'baz', {
  value: 123,
  configurable: false
});

foo.baz; // => 123
delete foo.baz; // => false
foo.baz; // => 123
'baz' in foo; // => true

正如你所看到的,delete操作符根据属性是否成功删除而评估为truefalse。在成功删除后,属性不仅仅被设置为undefinednull,而是完全从对象中删除,因此通过in检查其存在性将返回false

delete操作符在技术上可以用来删除任何变量(或者内部所谓的环境记录绑定),但尝试这样做被认为是不推荐的行为,并且在严格模式下会产生SyntaxError

'use strict';
let foo = 1;
delete foo; // ! SyntaxError

delete操作符在 JavaScript 实现之间历史上存在许多不一致,尤其是在不同的浏览器之间。因此,只有在对象上删除属性的常规用法是可取的。

void 操作符

void操作符将评估为undefined,无论其操作数是什么。它的操作数可以是任何有效的引用或表达式:

void 1; // => undefined
void null; // => undefined
void [1, 2, 3]; // => undefined

它现在用途不多,尽管void 0有时被用作undefined的习语,要么是为了简洁,要么是为了避免在旧环境中undefined是一个不受信任的可变值的问题。

新操作符

new操作符用于从构造函数形成一个实例。它的右侧操作数必须是一个有效的构造函数,可以是语言提供的(例如new String())或者自己提供的:

function Thing() {} 
new Thing(); // => Instance of Thing

通过实例,我们真正的意思是一个对象,它的[[Prototype]]等于构造函数的prototype属性,并且已经作为它的this绑定传递给构造函数,以便构造函数可以完全准备好它的目的。请注意,无论我们是通过类定义还是传统语法定义构造函数,我们都可以对产生的实例做出相同的断言:

// Conventional Constructor Definition:
function Example1() {
  this.value = 123;
}

Example1.prototype.constructor === Example1; // => true
Object.getPrototypeOf(new Example1()) === Example1.prototype; // => true
new Example1().value === 123; // => true

// Class Definition:
class Example2 {
  constructor() { this.value = 123; }
}

Example2.prototype.constructor === Example2; // => true
Object.getPrototypeOf(new Example2()) === Example2.prototype; // => true
new Example2().value === 123; // => true

new操作符只关心它的右侧操作数是否可构造。这意味着它不能是由箭头函数形成的函数,就像这个例子:

const Thing = () => {};
new Thing(); // ! TypeError: Thing is not a constructor

只要你使用函数表达式或声明定义了构造函数,它就可以正常工作。如果你愿意,甚至可以实例化一个匿名内联构造函数:

const thing = new (function() {
  this.name = 'Anonymous';
});

thing.name; // => "Anonymous"

new操作符不正式需要调用括号。只有在你传递参数给构造函数时才需要包括它们:

// Both equivalent:
new Thing;
new Thing();

然而,当你希望实例化某些东西然后立即访问属性或方法时,你需要通过提供调用括号来消除歧义,然后在其后访问属性;否则,你会收到TypeError

function Component() {
  this.width = 200;
  this.height = 200;
}

new Component().width; // => 200
new Component.width; // => ! TypeError: Component.width is not a constructor
(new Component).width; // => 200

new操作符的使用通常非常简单。从语义上讲,它被理解为与实例的构造有关,因此理想情况下只应该用于这个目的。因此,假定new右侧操作数引用的任何东西都以大写字母开头并且是一个名词。这些命名约定表明它是一个构造函数,为希望使用它的任何程序员提供了有用的提示。以下是一些好的和坏的构造函数名称的示例:

// Bad (non-idiomatic) names for Constructors:
new dropdownComponent;
new the_dropdown_component;
new componentDropdown;
new CreateDropdownComponent;

// Good (idiomatic) names for Constructors:
new Dropdown;
new DropdownComponent;

正确命名构造函数至关重要。它使我们的同行程序员立即意识到特定抽象满足的合同是什么。如果我们命名一个构造函数,使其看起来像一个常规函数,那么我们的同事可能会尝试不正确地调用它,并因此遭受可能的错误。因此,利用名称传达合同的能力是完全有道理的,正如在前一章关于命名的讨论中所述(第五章,命名很难)。

展开语法

展开语法(也称为rest 语法)由三个点组成,后面跟着一个操作数表达式(...expression)。它允许在需要多个参数或多个数组元素的地方展开表达式。它在语言的五个不同领域中存在:

  • 数组文字中,形式为array = [a, b, c, ...otherArray]

  • 对象文字中,形式为object = {a, b, c, ...otherObject}

  • 函数参数列表中,形式为function(a, b,  c, ...otherArguments) {}

  • 解构数组模式中,形式为[a, b, c, ...others] = array

  • 解构对象模式中,形式为{a, b, c, ,,,otherProps} = object

函数参数列表的上下文中,展开语法必须是最后一个参数,并且表示您希望从那时起传递给函数的所有参数都被收集到一个由指定名称的单一数组中。

function addPersonWithHobbies(name, ...hobbies) {
  name; // => "Kirk"
  hobbies; // => ["Collecting Antiques", "Playing Chess", "Drinking"]
}

addPersonWithHobbies(
 'Kirk',
 'Collecting Antiques',
 'Playing Chess',
 'Drinking'
);

如果您尝试在其他参数中使用它,那么您将收到SyntaxError

function doThings(a, ...things, c, d, e) {}
// ! SyntaxError: Rest parameter must be last formal parameter

数组文字解构数组模式的上下文中,展开语法同样用于指示所引用的值应该展开。最好将这两者看作是两种相反的操作,解构和*重构:

// Deconstruction:
let [a, b, c, ...otherLetters] = ['a', 'b', 'c', 'd', 'e', 'f'];
a; // => "a"
b; // => "b"
c; // => "c"
otherLetters; // => ["d", "e", "f"]

// Reconstruction:
let reconstructedArray = [a, b, c, ...otherLetters];
reconstructedArray; // => ["a", "b", "c", "d", "e", "f"]

当在数组文字解构数组模式的上下文中使用时,展开语法必须指向可迭代的值。这不一定是一个数组。例如,字符串是可迭代的,所以下面的也可以工作:

let [...characters] = 'Hello';
characters; // => ["H", "e", "l", "l", "o"]

对象文字d**estructuring 对象模式的上下文中,展开语法同样用于将任何给定对象的所有属性展开到接收对象中。再次,我们可以将这看作是解构重构的过程:

// Deconstruction:
const {name, ...attributes} = {
  name: 'Nissan Skyline',
  engineSize: '2500cc',
  year: 2009
};
name; // => "Nissan Skyline"
attributes; // => { engineSize: "2500cc", year: 2009 }

// Reconstruction:
const skyline = {name, ...attributes};
skyline; // => { name: "Nissan Skyline", engineSize: "2500cc", year: 2009 }

在这种情况下使用展开语法的右侧值必须是一个对象或可以包装为对象的原始值(例如,NumberString)。这意味着 JavaScript 中的所有值都是允许的,除了nullundefined,我们知道这两者都不能被包装为对象:

let {...stuff} = null; // => TypeError

因此,最好只在对象上下文中使用展开语法,当您确信该值是一个对象时。

总之,正如我们所看到的,展开语法在各种不同的情况下都非常有用。它的主要优势在于它减少了提取和指定值所需的语法量。

逗号运算符

逗号运算符(a, b)接受左侧和右侧操作数,并始终计算为其右侧操作数。有时它不被认为是一个运算符,因为它在技术上不对其操作数进行操作。它也非常罕见。

逗号运算符不应与我们在声明或调用函数时用来分隔参数的逗号(例如fn(a,b,c)),在创建数组文字和对象文字时使用的逗号(例如[a, b, c]),或者在声明变量时使用的逗号(例如let a, b, c;)混淆。逗号运算符与所有这些都不同。

它最常见于for(;;)循环的迭代语句部分:

for (let i = 0; i < length; i++, x++, y++) {
  // ...
}

请注意第三个语句中发生的三次递增操作(在传统的for(;;)语句的每次迭代结束时发生),它们之间都用逗号分隔。在这种情况下,逗号仅用于确保所有这些单独的操作将在一个单一语句的上下文中发生,而不受彼此的影响。在for(;;)语句之外的常规代码中,你可能只会将它们分别放在自己的行和语句中,如下所示:

i++;
x++;
y++;

然而,由于for(;;)语法的限制,它们必须存在于一个单一的语句中,因此逗号操作符变得必要。

逗号操作符评估其右侧操作数在这种情况下并不重要,但在其他情境中可能很重要:

const processThings = () => (firstThing(), secondThing());

在这里,当调用processThings时,将首先调用firstThing,然后调用secondThing,并返回secondThing返回的任何内容。因此,它等同于以下内容:

const processThings = () => {
  firstThing();
  return secondThing();
};

在 JavaScript 中很少见到逗号操作符的使用,即使在这样的情况下,它也往往会使本来可以更清晰表达的代码变得不必要地复杂。了解它的存在和行为是有用的,但我们不应该期望它成为一个日常操作符。

分组

分组,或者用括号括起来,是通过使用常规括号((...))来实现的。这不应该被误解为其他使用括号的语法,比如函数调用(fn(...))。

分组括号可以被视为一个操作符,就像我们学过的所有其他操作符一样。它们接受一个操作数——任何形式的表达式,并且将评估其中的内容:

(1);             // => 1
([1, 2, 3]);     // => [1, 2, 3]
(false && true); // => false
((1 + 2) * 3);   // => 9
(()=>{});        // => (A function)

因为它只是评估其内容,你可能会想知道分组的目的是什么。早些时候,我们讨论了操作符优先级和结合性的概念。有时,如果你正在使用一系列操作符,并希望强制特定的操作顺序,那么唯一的方法就是将它们包裹在一个分组中,这样在与其他操作符一起使用时,它具有最高的优先级:

// The order of operations is dictated
// by each operator's precedence:
1 + 2 * 3 - 5; 

// Here, we are forcing the order:
(1 + 2) * (3 - 5);

当操作顺序不是你所期望的,或者可能对代码的读者不清晰时,使用分组是明智的。例如,有时常见的做法是将从函数返回的项目包装在一个分组中,以提供美观的容纳和清晰度:

function getComponentWidth(component) {
  return (
    component.getInnerWidth() +
    component.getLeftPadding() +
    component.getRightPadding()
  );
}

另一个明显的解决方案可能只是缩进你希望包含的项目,但这样做的问题是 JavaScript 的return语句将不知道在其自己的行之外寻找表达式或值的开始:

// WARNING: this won't work
return
  component.getInnerWidth() +
  component.getLeftPadding() +
  component.getRightPadding();

在前面的代码中,return语句在解析器观察到同一行上没有值或表达式时,会有效地用分号终止自身。这被称为自动分号插入ASI),它的存在意味着我们经常需要使用分组来明确告诉解析器我们的意图是什么:

// Clear to humans; clear to the parser:
return (
  component.getInnerWidth() +
  component.getLeftPadding() +
  component.getRightPadding()
);

总之,分组是一个用于容纳和重新排序操作的有用工具,它是一种增加表达式的清晰度和可读性的廉价且简单的方法。

按位操作符

JavaScript 有七个按位操作符。这里的按位意味着对二进制数进行操作。这些操作符很少被使用,但了解它们仍然是有用的:

  • 按位无符号右移操作符>>>

  • 按位左移操作符<<

  • 按位右移操作符>>

  • 按位或|

  • 按位与&

  • 按位异或^

  • 按位非~(一元操作符)

在 JavaScript 中,按位操作非常罕见,因为通常处理的是高级位序列,如字符串或数字。然而,值得至少对按位操作有一定的了解,这样如果遇到需要,你就能应对。

JavaScript 中的所有位运算符都将首先将它们的操作数(或者在位运算 NOT ~的情况下是单个操作数)强制转换为 32 位整数表示。这意味着,内部上,数字如250将被表现为如下:

00000000 00000000 00000000 11111010

在这种情况下,即250的最后八位包含有关数字的所有信息:

1 1 1 1 1 0 1 0
+ + + + + + + +
| | | | | | | +---> 0 * 001 = 000
| | | | | | +-----> 1 * 002 = 002
| | | | | +-------> 0 * 004 = 000
| | | | +---------> 1 * 008 = 008
| | | +-----------> 1 * 016 = 016 
| | +-------------> 1 * 032 = 032
| +---------------> 1 * 064 = 064
+-----------------> 1 * 128 = 128
=================================
                        SUM = 250

将所有位相加将得到一个十进制整数值为250

每个可用的位运算符都将对这些位进行操作并得出一个新值。例如,位 AND 操作将为每对同时处于on状态的位产生一个位值为1

const a = 250;  // 11111010
const b = 20;   // 00010100
a & b; // => 16 // 00010000

我们可以看到,从右边数起的第五位(即16)在25020中都是on,因此 AND 操作将导致只有这一位保持 on 状态。

只有在进行二进制数学运算时,才应该使用位运算符。除此之外,任何位运算符的使用(例如,用于副作用)都应该避免,因为它会极大地限制我们代码的清晰度和可理解性。

曾经在 JavaScript 中经常看到位运算符如~|的使用,因为它们在简洁地得出一个数字的整数部分方面很受欢迎(例如,~34.6789 === 34)。毫无疑问,这种方法虽然聪明且令人自豪,但却创建了难以阅读和陌生的代码。使用更明确的技术仍然更可取。在取整的情况下,使用Math.floor()是理想的。

总结

在本章中,我们详尽地介绍了 JavaScript 中可用的运算符。总的来说,过去的三章使我们对 JavaScript 语法有了非常坚实的基础理解,使我们在构建表达式时感到非常舒适。

在下一章中,我们将继续通过应用我们对类型和运算符的现有知识来探索语言的声明和控制流。我们将探讨如何使用更大的语言结构来编写清晰的代码,并将讨论这些结构中存在的许多陷阱和特殊之处。

第九章:语法和范围的部分

在本章中,我们将继续探索 JavaScript 的语法和结构。我们将深入研究表达式、语句、块、作用域和闭包的基础知识。这些是语言中不太显眼的部分。大多数程序员认为他们已经很好地掌握了诸如表达式和作用域等工作原理,但正如我们所见,我们对事物应该如何工作的直觉可能并不总是与它们真正工作的方式一致。我们将在本章学习的构造是我们程序的重要大型构建块,因此在我们探索控制流和设计模式等更抽象的概念之前,充分理解它们是非常重要的。

为什么我们现在学习这个?我们现在已经对 JavaScript 中可用的类型以及如何通过运算符操纵它们有了牢固的掌握。下一个逻辑步骤是学习句法脚手架组件,我们可以在其中放置这些类型和操作,以及这些脚手架组件的行为。这里的最终目标是对 JavaScript 有高水平的流利度,这样我们就能更好地编写清晰的代码。

在本章中,我们将涵盖以下主题:

  • 表达式、语句和块

  • 作用域和声明

表达式、语句和块

在 JavaScript 中存在三种广义的句法容器:表达式、语句和块。它们都是容器,因为它们都包含其他句法片段,并且都有值得区分的不同行为。

还有其他可以称为容器的构造,比如函数或模块,但目前我们只对你在其中找到的句法类型感兴趣。随着我们继续探索语言,我们正在从粒度运算符和表达式逐渐放大到更大更复杂的函数和程序中。

最好将程序的单个句法部分可视化为一个层次结构:

在这里,我们可以看到单个表达式(下边界)被包裹在语句中,可以是常规类型。始终将语言的这种层次结构视图放在我们的脑海中是有用的,因为这是我们的代码将被解析和理解的方式。当然,我们不需要像解析器那样看待我们的代码,但了解我们的代码将如何被解析是无可争议的有用的。

这种语言的分层视图也将帮助我们编写能够很好地传达意图给其他程序员的程序。层次结构不仅是一个句法问题,也是一个人类问题。当我们编写程序时,我们通常会在不同的抽象层面上建模问题:程序的每个部分都包含在另一个部分中,从所有这些单独的部分中,我们可以构建一个包含许多不同复杂层次的程序。

当我们探索 JavaScript 的句法部分时,值得记住程序的句法元素,它的表达式和语句,将与问题域的个别元素和层次具有自然的对称性。

表达式

表达式是最粒度的句法容器类型。我们已经在很多表达式中工作过了。甚至表达一个文字值,比如数字1,都会产生一个表达式:

1 // <= An expression containing the literal value 1

使用运算符也形成一个表达式:

'hi ' + 'there'

实际上,我们可以将运算符视为应用于表达式的东西。因此,加法运算符的语法可以这样理解:

EXPRESSION + EXPRESSION

表达式可以是一个简单的文字值或变量引用,但也可以是复杂的。以下表达式包含一系列操作,并分布在几行中:

(
  'this is part of' +
  ' ' +
  ['a', 'very', 'long', 'expression'].join(' ')
)

表达式不仅限于原始类型或简单的文字值。类定义、函数表达式、数组文字和对象文字都是可以出现在表达式上下文中的东西。知道某物是否是表达式的简单方法是问它是否可以在不引起SyntaxError的情况下放在一个group运算符(即括号)中:

(class Foo {});   // Legal Expression
(function() {});  // Legal Expression
([1, 2, 3]);      // Legal Expression
({ a: 1, b: 2 }); // Legal Expression

(if (a) {});      // ! SyntaxError (Not an Expression!)
(while (x) {});   // ! SyntaxError (Not an Expression!)

任何程序的语法构建块都涉及各种不同层次的语法结构。我们有单个值和引用:如果我们稍微放大一点,我们有表达式,如果我们放大得更远,我们有语句,现在我们将探讨这些。

语句

语句包含一个表达式,因此是另一种语法容器。了解 JavaScript 如何将表达式视为与语句不同的东西对于避免语言的各种陷阱和特殊之处非常有帮助。

语句在各种情况下形成。这些情况包括:

  • 当您用分号终止一个表达式(1 + 2;

  • 当您使用任何forwhileswitchdo..whileif构造

  • 当您通过function declarationfunction Something() {})创建函数

  • 它们是由语言的自然自动分号插入ASI)自动形成的

function declaration的语法(function name() {})将始终形成一个语句,除非它出现在表达式的上下文中,在这种情况下,它自然会成为命名函数表达式。有关这些之间微妙差异,请重新阅读第六章,原始类型和内置类型

用分号形成语句

当我们将一个表达式放在另一个表达式后面时,我们倾向于用分号终止每个单独的表达式。通过这样做,我们形成了一个语句。显式终止语句可以确保 JavaScript 解析器不必自动执行此操作。如果您不使用分号,那么解析器将通过称为ASI的过程猜测在何处插入它们。此过程依赖于我们换行的位置(即\n)。

由于ASI是自动的,它不会总是提供您期望的结果。例如,考虑以下情况,其中有一个function expression后面跟着一个意图作为group(即由括号括起来的表达式)的语法:

(function() {})
(
 [1, 2, 3]
).join(' ')

这将导致一个神秘的TypeError,显示:Cannot read property join of undefined。这是因为,从解析器的角度来看,我们正在做以下事情:

(function() {})([1, 2, 3]).join(' ')

在这里,我们创建了一个内联的匿名函数,然后立即调用它,将[1, 2, 3]数组作为我们唯一的参数传递,然后我们尝试在返回的内容上调用join方法。但是由于我们的函数返回undefined,所以那里没有join方法,因此我们会收到一个错误。这是一个罕见的情况,但是这个问题的变体偶尔会出现。避免它们的最佳方法是一致地使用分号终止作为语句意图的行,如下面的代码所示:

(function() {});
(
 [1, 2, 3]
).join(' ');

ASI也可能以其他方式影响您。一个常见的例子是当您尝试在函数内部使用return语句,并且其预期的返回值在下一行时。在这种情况下,您会得到一个令人讨厌的惊喜:

function sum(a, b) {
  return
    a + b;
}
sum(a, b); // => undefined (odd!)

JavaScript 的ASI机制将假定如果同一行上没有其他内容,return语句已经终止,因此在运行代码时,JavaScript 引擎将看到以下内容更接近:

function sum(a, b) {
  return;
  a + b;
}

要解决这个问题,我们可以将a + b放在与我们的return语句相同的行上,或者我们可以使用group运算符来包含我们缩进的表达式:

function sum(a, b) {
  return (
    a + b
  );
}

不需要了解每个 ASI 规则,但知道它的存在非常有用。与其依赖于晦涩的 ASI 规则,不如尽可能避免使用它。如果您明确地终止您的语句,那么您就不需要依赖于这些规则,也不需要依赖于您的同事知道这些规则。

如果我们将语句视为表达式的容器,那么我们可以将块视为语句的容器。在其他语言中,它们有时被称为复合语句,因为它们允许多个语句一起存在。

严格来说,块是语句。从语言设计的角度来看,这是一件有用的事情,因为它允许构成其他结构的语句可以表达为单行语句或包含多个语句的整个块,例如在if(...)for(...)结构之后。

块由用大括号界定的零个或多个语句组成:

{
  // I am inside a block
  let foo = 123;
}

块很少被用作完全孤立的代码单元(这样做的好处非常有限)。通常会在ifwhileforswitch语句中找到它们,如下所示:

while (somethingIsTrue()) {
  // This is a block
  doSomething();
}

这里while循环的{...}部分是一个块。它不是while语法的固有部分。如果愿意,我们可以完全排除该块,而是用一个常规的单行语句代替:

while (somethingIsTrue()) doSomething();

这将与使用块的版本相同,但显然如果我们打算添加更多的迭代逻辑,这将是有限制的。因此,在这种情况下通常最好预先使用块。这样做的额外好处是合法化缩进和迭代逻辑的包含。

块不仅仅是语法容器。它们还通过提供自己的作用域影响我们代码的运行时,这意味着我们可以通过constlet语句在其中声明变量。请注意这里我们如何在if块内声明一个变量以及它在该块外部不可用的情况:

if (true) {
  let me = 'here';
  me; // => "here"
}

me; // ! ReferenceError 

作用域是一个我们不应该轻视的话题。它可能很难理解,因此接下来的部分将探讨其性质和细微差别。

作用域和声明

给定变量的作用域可以被认为是程序中可以访问该变量的区域。

当我们在模块的开头(所有函数之外)声明一个变量时,我们认为这个变量应该可以被模块内的所有函数访问:

var hello = 'hi';

function a() {
  hello; // a() can "see" the hello variable
}

function b() {
  hello; // b() can "see" the hello variable
}

如果我们在函数内定义一个变量,那么我们期望所有内部函数都能访问它:

var value = 'I exist';

function doSomething() {
  value; // => "I exist"
}

我们可以在这里的doSomething函数中访问value是由于它的作用域。给定变量的作用域将取决于它是如何声明的。当您通过var声明变量时,它的潜在作用域将与通过let声明的变量不同。我们将很快介绍这些差异,但首先,了解作用域内部运作的清晰概念是很有用的。

在内部,当您声明变量时,JavaScript 将在词法环境中创建和存储该变量,该环境包含标识符到值的映射。一个典型的 JavaScript 程序可以被认为有四种类型的词法环境,如下列表所示:

  • 全局环境:只有一个,它被认为是所有其他作用域的外部作用域。它是所有其他环境(即作用域)存在的全局上下文。全局环境反映了一个全局对象,可以在浏览器中通过windowself引用,在 Node.js 中通过global引用。

  • 模块环境:为每个作为单个 Node.js 进程的一部分运行的不同 JavaScript 模块或浏览器中的每个<script type="module">创建此环境。

  • 函数环境:这个环境将对每个运行的函数产生影响,无论它是如何声明或调用的。

  • 块环境:这个环境将对程序中的每个块({...})产生影响,无论是在另一个语言构造之后,比如if(...)while(...),还是独立地放置。

如你所知,函数和块都可以存在于其他函数和块中。考虑以下代码片段,它表达了各种环境(作用域):

function setupApp(config) {

  return {
    setupUserProfileMenu() {

      if (config.isUserProfileEnabled) {

        const onDoneRendering = () => {
          console.log('Done Rendering!');
        };

        // (Do some rendering here...)
        onDoneRendering();

      }

    }
  };

}

setupApp({ isUserProfileEnabled: true }).setupUserProfileMenu();

在记录Done Rendering!的时候,我们可能期望环境的层次结构看起来像这样:

Browser Global Environment
\--> Function Environment (setupApp)
     \--> Block Environment (if block)
          \--> Function Environment (onDoneRendering)

这种环境的层次结构将在给定程序的运行时发生变化。如果一个函数运行完成,并且它的内部作用域不再被任何暴露的内部函数(称为闭包)使用,那么词法环境将被销毁。基本上,当一个作用域不再需要时,JavaScript 就可以摆脱它。

变量声明

通过var关键字后跟一个有效的标识符或形式为a = b的赋值来进行变量声明:

var foo;
var baz = 123;

我们称通过var关键字声明的事物为变量声明,但重要的是要注意,在流行的术语中,由letconst声明的声明也被认为是变量。

通过var声明的变量的作用域限制在最近的函数、模块或全局环境中,也就是说,它们不是块作用域的。在解析时,给定作用域内的变量声明将被收集,然后在执行时,这些声明的变量将被提升到它们的执行上下文的顶部,并用undefined值进行初始化。这意味着,在给定作用域内,你可以在其赋值之前访问一个变量,但它将是undefined

foo; // => undefined
var foo = 123;
foo; // => 123

执行上下文是指调用堆栈的顶部,也就是当前运行的函数、脚本或模块。这个概念只在代码运行时才能看到,并且随着程序的进行而改变。你通常可以简单地将其视为当前运行的函数(或外部模块或<script>)。var声明总是被提升到它们的执行上下文的顶部,并初始化为undefined

与通过letconst声明的变量相比,var的提升行为是相反的,如果你在它们声明之前尝试访问它们,将会产生ReferenceError

thing; // ! ReferenceError: Cannot access 'thing' before initialization
let thing = 123; 

如果你不小心,var 的提升行为可能会导致一些意想不到的结果。例如,可能会出现这样的情况,你试图引用外部作用域中存在的变量,但由于当前作用域中的变量声明被提升,你无法这样做:

var config = {};

function setupUI() {
  config; // => undefined
  var config;
}

setupUI();

在这里,内部作用域变量config的声明将被提升到其作用域的顶部,这意味着从setupUI的第一行开始,configundefined

由于变量声明被提升到它们的执行上下文的顶部,即使在块中,它们也会被提升,就好像它们是在块外部首先初始化的一样:

// This:
// (VariableDeclaration inside a Block)
if (true) {
  var value = 123;
} 

// ... Is equivalent to:
// (VariableDeclaration preceding a Block)
var value;
if (true) {
  value = 123
};

总之,变量声明创建了一个作用域限制在最近的函数、模块或全局环境中的变量。在浏览器中,没有模块环境,所以它将被作用域限制在其函数或全局作用域。变量声明将在执行之前被提升到其相应执行上下文的顶部。这可能是函数、模块(在 Node.js 中)或<script>(在浏览器中)。由于最近引入的constlet声明都是块作用域的,并且没有任何奇怪的提升行为,因此变量声明已经不再受欢迎。

Let 声明

Let 声明比 var 声明简单得多。它们将被作用域限制在它们最近的环境中(无论是块、函数、模块还是全局环境),并且没有复杂的提升行为。

它们能够作用域限定到一个块,这意味着块内部的 let 声明不会影响outer函数作用域。在下面的代码中,我们可以看到三个不同的环境(作用域),每个环境中都有一个相应的place变量:

let place = 'outer';

function foo() {
  let place = 'function';

  {
    let place = 'block';
    place; // => "block"
  }

  place; // => "function"
}

foo();
place; // => "outer"

这向我们展示了两件事:

  • 通过let声明不会覆盖或改变outer作用域中同名的变量

  • 通过let声明将允许每个作用域拥有自己的变量,对outer作用域不可见

当你在for(;;)for...infor...of结构中使用let,即使在后面的块之外,那么该let声明将被作用域限定为在块内部。这在直觉上是有意义的:当我们用 let 声明初始化一个 for 循环时,我们自然期望它们的作用域限定在 for 循环本身而不是外部。

for (let i = 0; i < 5; i++) {
  console.log(i); // Logs: 0, 1, 2, 3, 4
}
console.log(i); // ! ReferenceError: i is not defined

如果我们预期变量在以后的某个时间点会被重新赋值,那么我们应该使用let。如果不会发生新的赋值,那么我们应该优先使用const,因为它可以给我们一点额外的安心。

Const 声明

const声明具有与let相同的特性,除了一个关键的区别:通过const声明的变量是不可变的,这意味着变量不能被重新分配为不同的值:

const pluto = 'a planet';
pluto = 'a dwarf planet'; // ! TypeError: Assignment to constant variable.

重要的是要注意,这并不影响值本身的可变性。因此,如果值是任何类型的对象,那么它的所有属性将保持它们的可变性:

const pluto = { designation: 'a planet' };

// Assignment to a property:
pluto.designation = 'a dwarf planet';

// It worked! (I.e. the object is mutable)
pluto.designation; // => "a dwarf planet"

尽管const不能保护值免受所有可变性的影响,但它可以保护我们免受一些常见错误和不良实践的影响,比如重复使用一个变量来引用几个不同的概念,或者因为拼写错误而意外地重新赋值一个变量。const代码短语通常比let更安全,并且现在被认为是声明所有变量的最佳实践,除非你明确需要在声明后重新分配变量。

for...offor...in迭代结构中声明变量时,也可以自由使用const,例如在以下情况下:

for (const n of [4, 5, 6]) console.log(n);
// Logs 4, 5, 6

人们经常错误地选择在这里使用let,因为他们认为循环结构将有效地重新分配变量,使const不合适。但事实上,在for(...)中的声明将与每次迭代中的新块作用域相关联,因此const变量将在每次迭代中在这个新作用域内重新初始化。

函数声明

在作用域方面,函数声明的行为与变量声明(即var)类似。它们将作用域限定在它们最近的函数、模块或全局环境中,并且将被提升到它们各自的执行上下文的顶部。

然而,函数声明与变量声明不同,它将导致Function的实际赋值与其标识符一起被提升,这意味着在声明之前Function实际上是可用的。

myFunction(); // => "This works!"
function myFunction() { return 'This works!' }

这种行为相当隐晦,因此不建议使用,除非在调用时很明显可以确定myFunction的定义来自哪里。程序员通常期望函数的定义存在于调用它的地方之上(或者在之前的某个时间点作为依赖导入),因此可能会令人困惑。

如果我们考虑条件激活的块中可能存在函数声明的情况,那么情况会更加复杂(警告:不要这样做!):

giveMeTheBestNumber; // => (Varies depending on implementation!)
if (something) {
  function giveMeTheBestNumber() { return 76; }
} else {
  function giveMeTheBestNumber() { return 42; }
}

不幸的是,以前的 ECMAScript 版本没有规定块内的函数声明的行为。这导致各种浏览器实现选择了自己独特的处理方式。随着时间的推移,实现已经开始对齐。2015 年的 ECMAScript 规范明智地禁止了giveMeTheBestNumber函数中的任何一个值被提升。然而,声明本身仍然可以被提升,这意味着在其声明之前的行中,giveMeTheBestNumber将是undefined(类似于var),如前所述。这是在撰写本文时大多数(但不是全部)实现的普遍行为。

由于实现之间的模糊和剩余的不一致性,强烈建议您不要在块内使用函数声明。最好不要依赖它们的变量提升行为(通过引用函数声明),除非您确信这样做不会被阅读您代码的人误解。

有关由函数声明产生的函数与其他创建函数的方式(例如,函数表达式或箭头函数)有何不同的更多信息,请重新查看第六章中的函数部分。

闭包

正如我们所见,内部作用域可以访问外部作用域的变量:

function outer() {
  let thing = 123;
  function inner() {
    // I can access `thing` within here!
    thing; // => 123
  }
  inner();
}
outer();

从这里自然而然地引申出了闭包的概念。闭包是 JavaScript 如何使您能够继续访问inner函数的作用域的方式,无论何时何地调用它。

将闭包简单地视为保留的作用域是最简单的。闭包是一个随函数一起传递的包装或封闭作用域,它在调用函数时隐式地提供了对其作用域的访问。

考虑以下函数(fn),它返回另一个函数。它有自己的作用域,在其中我们声明了coolNumber变量:

function fn() {
  let coolNumber = 1;
  return function() {
    console.log(`
      I have access to ${coolNumber} 
      wherever and whenever I am called
    `);
  };
}

我们返回的内部函数可以访问coolNumber变量,这是我们所期望的。当我们调用fn()时,它的作用域被有效地保持,因此当我们最终调用inner函数时,它仍然能够访问coolNumber

以下是另一个例子,我们利用保留作用域(即闭包)继续访问本地变量,并在调用内部函数时重新分配和返回:

function valueIncrementer() {
  let currentValue = 0;
  return function() {
    return currentValue++;
  };
}

const increment = valueIncrementer();
increment(); // => 0
increment(); // => 1
increment(); // => 2

闭包的概念经常被过度复杂化,因此冒着这样做的风险,我会简单地陈述一下。闭包并不是什么奇怪的东西:它是我们应该期望作用域工作的自然延伸。所有函数都可以访问给定的作用域,因此在我们传递这些函数的初始定义之后,它们将继续访问相同的作用域,并且可以自由访问或修改该作用域内的变量。函数始终锚定在最初定义的位置,因此无论是立即调用还是在一千分钟后调用,它都将访问相同的作用域(即相同的词法环境集)。

总结

在本章中,我们继续探索 JavaScript 语言,从之前的章节放大,考虑更大的语法片段,如表达式、语句和块。这些是程序化的支撑组件,我们可以在其中放置我们之前学到的类型和操作。我们还涵盖了作用域、变量提升和闭包的复杂机制。理解这些概念如何共同工作对于理解其他人的 JavaScript 程序并构建自己的程序至关重要。

在下一章中,我们将探讨如何在 JavaScript 中控制流程。这将使我们能够以一种清晰的方式将表达式和语句编织在一起,形成更大的逻辑体。然后,我们将通过学习设计模式来探索抽象设计的艺术。虽然单独学习这些主题的过程可能看起来很艰难,但在本书结束时,您将对 JavaScript 有深入而强大的理解,这将使您能够更少地关注语言的怪异之处,更多地关注代码的清晰度。

第十章:控制流

这是我们对 JavaScript 语法的探索的最后一章。到目前为止,我们已经涵盖了它更原子的组件,包括它的许多类型、运算符、声明和语句。熟练掌握这些对于在基础级别有效地使用语言至关重要,现在允许我们退一步考虑一个更大的问题:控制程序的流程。我们将把我们学到的所有语法结合起来,编写干净和易懂的程序。

在本章中,我们将涵盖以下主题:

  • 什么是控制流?

  • 命令式与声明式编程

  • 控制的移动

  • 控制流语句

  • 处理圈复杂度

  • 异步控制流

什么是控制流?

控制流指的是表达式和语句(以及整个代码块)运行的顺序。编程在某种程度上是控制流的艺术。通过编写代码,我们指定了控制在任何单一时刻的位置。

在细粒度上,执行顺序由我们在表达式中使用的各个运算符决定。在上一章中,我们探讨了运算符的优先级和结合性,发现即使有一系列运算符,一个接一个,它们的执行顺序也由各个运算符的优先级和结合性定义,因此在表达式1 + 2 * 3中,2 * 3的操作将在加法之前发生。

在语句级别上,除了表达式外,我们以以下方式控制流程:

  • 我们可以通过按照我们希望它们发生的顺序来排列我们的语句。

  • 我们可以通过使用条件或迭代语言结构来实现,包括以下内容:

  • switch()语句

  • if()语句

  • for()语句

  • while()语句

  • do{...} while()语句

  • 我们可以通过调用函数或生成器来实现,然后从函数或生成器中返回或产出(产出返回都是将控制权交还给调用者的方式)。

最容易想象控制流程全局地作为一种光标手指,它总是指向特定的表达式或代码语句。当程序执行时,控制流将逐行向下进行,直到遇到一段语法,将重定向控制到另一段代码。如果遇到对函数的调用,那么该函数将以相同的方式执行;控制将在函数内的每一行连续进行,直到通过return语句将其返回给函数的调用者。当控制穿过程序时,它遇到的每个语言结构都将控制执行,直到它们各自完成。考虑以下简单的代码片段:

let basket = [];
for (let i = 0; i < 3; i++) {
  basket.push(
    makeEgg()
  );
}

在上述代码中采取的控制流程如下:

  1. 我们从let basket = [];开始

  2. for循环开始:let i = 0

  3. 检查i < 3(为true!):

  4. 运行makeEgg()

  5. 通过basket.push(...)推送结果

  6. i++i现在是1

  7. 检查i < 3(为true!):

  8. 运行makeEgg()

  9. 通过basket.push(...)推送结果

  10. i++i现在是2

  11. 检查i < 3(为true!):

  12. 运行makeEgg()

  13. 通过basket.push(...)推送结果

  14. i++i现在是3

  15. 检查i < 3(为false!)。

  16. 程序结束

即使对于这样一个非常简单的程序,流程也可能相当复杂且冗长。为了使我们的同行程序员受益,尽可能地减少这种复杂性是有意义的。实现这一点的方法是通过抽象。抽象某事物不会消除复杂性,但它会隐藏它,以便程序员不需要关心它。因此,在深入研究 JavaScript 中控制流的具体语言结构之前,我们将探讨控制流和抽象如何通过命令式声明式编程这两种相反的方法相互关联。

命令式与声明式编程

命令式编程关注于如何完成某事,而声明式编程关注于我们想要完成什么。很难看出它们之间的区别,所以最好用一个简单的程序来说明它们:

function getUnpaidInvoices(invoiceProvider) {
  const unpaidInvoices = [];
  const invoices = invoiceProvider.getInvoices();
  for (var i = 0; i < invoices.length; i++) {
    if (!invoices[i].isPaid) {
      unpaidInvoices.push(invoices[i]);
    }
  }
  return unpaidInvoices;
}

这个函数的问题领域将是:获取未付发票。这是函数的任务,也是我们希望在函数内部实现的目标。然而,这个特定的函数非常关注如何实现它的任务:

  • 它初始化一个空数组

  • 它初始化一个计数器

  • 它检查计数器(多次

  • 它增加了计数器(多次

这个函数的这些和其他元素与获取未付发票的问题领域毫不相关。相反,它们是我们必须经历的相当烦人的实现细节。这样的函数被称为命令式,因为它们主要关注如何

虽然命令式形式的编程忙于任务中涉及的程序低级步骤,声明式形式的编程使用抽象来避免直接控制流,更倾向于仅用问题领域本身来表达事物。以下是我们getUnpaidInvoices函数的更声明式版本:

function getUnpaidInvoices(invoiceProvider) {
  return invoiceProvider.getInvoices().filter(invoice => {
    return !invoice.isPaid;
  });
}

在这里,我们委托给Array#filter来处理初始化新数组、迭代和条件检查的具体细节。通过使用抽象,我们摆脱了传统控制流的复杂性。

这样的声明式模式已经成为现代 JavaScript 的主流。它们允许您在问题领域的层面上表达所需的逻辑,而不必担心更低层次的抽象,比如如何迭代。重要的是要看到,声明式和命令式方法都不是完全不同的。它们处于光谱的两端。在光谱的声明式一侧,您在更高层次的抽象上操作,因此不会暴露在没有这种抽象的情况下会暴露的实现细节。在光谱的命令式一侧,您在更低层次的抽象上操作,利用更低级别的命令式构造来告诉机器您想要实现的目标:

这两种方法都对我们的控制流产生影响。更命令式的方法直接说明它将一次通过数组迭代,然后有条件地推送到输出数组。更声明式的方法不会对数组如何进行迭代提出任何要求。当然,我们知道原生的Array#filterArray#map方法将独立地迭代它们的输入数组,但这不是我们在指定的内容。我们指定的只是我们的数据应该被过滤和映射的条件。数据如何进行迭代完全是Array#filterArray#map抽象的关注。

更声明式方法的好处在于它可以增加人类读者的清晰度,并使您能够更有效地对复杂的问题领域进行建模。由于您不必担心如何发生事情,您的思维可以纯粹关注您希望实现的目标。

想象一下,我们被要求有条件地执行特定的代码片段,但只有在某个功能启用时才能执行。在我们的想法中,这就是它应该工作的方式:

if (feature.isEnabled) {
  // Do the task.
}

这是我们想要编写的代码,但后来我们发现事情并不那么简单。首先,我们没有isEnabled属性可以在功能对象上使用。但是,有一个flags数组属性,当完全禁用时将包括Feature.DISABLED_FLAG

// A feature that is disabled:
feature.flags; // => [Feature.DISABLED_FLAG]

这似乎很简单。 但是然后我们发现,即使该功能没有此标志,因此似乎已启用,我们还需要检查当前时间是否与存储在feature.enabledTimeSlots中的一组时间对齐。 如果当前时间不在启用的时间段之一,则我们必须得出结论,即使具有该标志,该功能也已禁用。

这开始变得相当复杂。 除了检查disabled标志之外,我们还需要通过这些时间段来发现基于当前时间功能当前是否已启用。 因此,我们简单的if语句很快就变成了一个难以控制的混乱,具有多层控制流:

let featureIsEnabled = true;

for (let i = 0; i < feature.flags.length; i++) {
  if (feature.flags[i] === Feature.DISABLED_FLAG) {
    featureIsEnabled = false;
    break;
  }
}

if (!featureIsEnabled) {
  for (let i = 0; i < feature.enabledTimeSlots.length; i++) {
    if (feature.enabledTimeSlots[i].isNow()) {
      featureIsEnabled = true;
      break;
    }
  }
}

if (featureIsEnabled) {
  // Do the task.
}

这是不受欢迎的复杂代码。 它与我们最初想要编写的原始声明性代码相去甚远。 要理解此代码,其他程序员在扫描每个单独的构造时必须在脑海中维护featureIsEnabled的状态。 这是一段令人心烦的代码,因此更容易产生误解,错误和一般的不可靠性。

我们现在必须问自己的关键问题是:我们需要做什么才能将所有这些嵌套的控制流层次抽象出来,以便我们可以恢复我们简单的if语句?

我们最终决定将所有这些逻辑放在新创建的Feature类中的isEnabled方法中-但不仅如此! 我们决定通过委托给两个内部方法_hasDisabledFlag_isEnabledTimeSlotNow来进一步抽象逻辑。 而这些方法本身将它们的迭代逻辑委托给数组方法includes(...)filter(...)

class Feature {
  // (Other methods of the Feature class here,..)

  _hasDisabledFlag() {
    return this.flags.includes(Feature.DISABLED_FLAG);
  }

  _isEnabledTimeSlotNow() {
    return this.enabledTimeSlots.filter(ts => ts.isNow()).length;
  }

  isEnabled() {
    return !this._isDisabledFlag() && this._isEnabledTimeSlotNow();
  }
}

这些对Feature类的非常小的声明性添加使我们能够编写最初的声明性代码:

if (feature.isEnabled()) {
  // Do the task.
}

这不仅仅是一个简单抽象的练习。 这是一个减少控制流层次的练习。 我们避免了使用嵌套的iffor块的需要,减少了我们自己和其他程序员面临的认知负担,并以最干净的方式完成了最初设定的任务。

通过仔细重构和抽象我们最初混乱的控制流,我们最终得到了一组代码,其中包含了非常少的传统控制流语句(ifforswitch等)。 这并不意味着我们的代码没有控制流; 相反,它意味着控制流要么被最小化,要么被隐藏在抽象的层次下。 在使用 JavaScript 语言的本机控制流构造时,重要的是要记住它们不是您表达程序流程的唯一工具; 您可以将复杂的逻辑重定向和分割为每个处理程序程序流程的非常特定部分的抽象。

现在我们已经对控制流有了坚实的基础理解,并且知道它如何与我们对抽象的了解相融合,我们可以逐个讨论 JavaScript 的各个控制流机制,突出挑战和潜在的陷阱。

控制的移动

在 JavaScript 中,有几种控制可以从一段代码移动到另一段代码。 通常,代码将从左到右上到下进行评估,直到达到以下任何一种情况:

  • 调用(通过fn()fn` `或者new fn()调用函数)

  • Returning (通过隐式或显式的return从函数返回)

  • Yielding (通过yield从生成器中产出)

  • Breaking(通过break从循环或 switch 中断)

  • Continuing (通过continue继续迭代)

  • Throwing (通过throw抛出异常)

调用

调用以最简单的形式通过显式调用函数来发生。我们可以通过在我们知道是函数的值的左侧附上调用括号((...))来实现这一点。这个左侧的值可以是直接引用一个持有函数的变量或属性,也可以是一个求值为函数的表达式。

someFunction();
(function(){})();
someObject.someMethod();
[function(){}][0]();

要构造实例,正如我们所探讨的,你可以使用new操作符,这也是一种调用方式,尽管在零参数的情况下,它在技术上不需要调用括号:

function MyConstructor() {}

// Both equivalent:
new MyConstructor();
new MyConstructor;

在调用括号之前(在(...)的左侧)的评估的确切语法并不重要,只要它评估为一个函数即可。如果它不是函数,你就会收到 TypeError

1();     // ! TypeError: 1 is not a function
[]();    // ! TypeError: [] is not a function
'wat'(); // ! TypeError: "wat" is not a function

当调用一个函数时,JavaScript 将创建一个新的词法环境(作用域),在这个环境中,该函数将被评估,函数将成为当前的执行上下文,从当前的代码区域转移到函数的代码中。这不应该太让人感到困惑。在代码中,foo();baz();foo() 将获得控制权,并在运行完成后才将控制权交给 baz()

一个函数将以以下方式返回控制权给你:

  • 通过returning (隐式或通过显式的return语句)

  • 通过throwing(由于SyntaxErrorTypeError等隐式地或通过显式的throw语句抛出异常)

  • 通过yielding(在生成器的情况下)

调用也可能通过 JavaScript 的内部机制间接发生。例如,在上一章探讨的强制转换的情况下,诸如valueOftoStringSymbol.toPrimitive等方法可能会在各种场景下被调用。此外,JavaScript 还使你能够定义settersgetters,以便在访问或赋值给特定属性时激活你的自定义功能:

const person = {
  set name(name) {
    console.log('You are trying to set the name to', name);
  }
};

person.name = 'Leo';
// Logs: "You are trying to set the name to Leo"

在这里给name属性赋值,实际上是在调用一个函数,该函数本身可能会执行各种操作,可能会间接调用其他函数。当存在许多这样的隐式调用方式时,你可以想象给定程序的控制流可能会变得难以理解。这样的隐式机制确实有其优点,但如果我们问题领域的大部分逻辑都内嵌在这些地方,那么对同事程序员而言,这些内嵌的逻辑就不那么容易看到,因此更容易造成混淆。

返回

Returning是从函数转移控制权给其调用方。这既可以在函数内部通过显式的return语句实现,也可以在函数运行完毕时隐式地实现:

function sayHiToMe(name) {

 if (name) {
   return `Hi ${name}`;
 }

 // In the case of a truthy `name` this code is never arrived at
 // because `return` exists on a previous line:
 throw 'You do not have a name! :(';

}

sayHiToMe('James'); // => "Hi James"

在这里,你会注意到我们没有将一个 falsy 名称的暗指else条件放在自己的 else 块(else {...})中,因为这是不必要的。因为当名称为真时我们返回,所以跟在返回语句后面的任何代码都只会在暗指的else条件中执行。这种模式在执行输入预检查的函数中很常见:

function findHighestMountain(mountains) {

  if (!mountains || !mountains.length) {
    return null;
  }

  if (mountains.length === 1) {
    return mountains[0];
  }

  // Do the actual work of finding the 
  // highest mountain here...
}

正如我们在这里看到的,返回不仅用于将控制返回给调用者,还用于它的副作用:避免存在于其函数中下方行中的工作。这通常被称为提前返回,可以显著帮助减少函数的整体复杂性。

产出

产出是生成器和其调用者之间的控制转移。这是通过yield表达式实现的,该表达式可以在其右侧可选地指定一个值(产出的值)。只有在生成器函数中才能使用yield语句:

function* makeSomeNumbers() {
  yield 645;
  yield 422;
  yield 789;
}

const iterable = makeSomeNumbers();
iterable.next(); // => {value: 645, done: false}
iterable.next(); // => {value: 422, done: false}
iterable.next(); // => {value: 789, done: false}

如果你没有值就产出(yield;),那结果将和产出undefined一样。

产出将强制后续对生成器函数的调用从产出点继续评估(就好像产出没有发生过一样)。产出可以被视为暂停一个函数,有望以后回来继续执行。如果我们在连续的调用中记录生成器运行的哪一部分,我们可以看到这一点:

function* myGenerator() {
  console.log('Chunk A');
  yield;
  console.log('Chunk B');
  yield;
}

const iterable = myGenerator();

console.log('Calling first time');
iterable.next();
console.log('Done calling first time');

console.log('Calling second time');
iterable.next();
console.log('Done calling second time');

这将记录以下内容:

  • "第一次调用"

  • "块 A"

  • "第一次调用完成"

  • "第二次调用"

  • "块 B"

  • "第二次调用完成"

也可以使用普通的return;语句从生成器函数中返回。这与最终产出是一样的。也就是说,再也不会在生成器内执行任何代码了。

将产出交给了产出

产出不一定只是单向控制的转移。你可以将生成器用作数据消费者观察者。在这种情况下,当调用者通过调用iterable.next()请求下一个产出的值时,可以选择性地向这个next()方法传递一个参数。传递的任何值都将导致生成器中的yield表达式评估为该值。

这更容易通过一个例子来解释。在这里,我们创建了一个消耗数字并产出所有先前消耗数字的总和的生成器:

function* createAdder() {
  let n = 0;
  while (true) n += yield n;
}

const adder = createAdder();

adder.next(); // Initialize (kick things off!)

adder.next(100).value; // => 100
adder.next(100).value; // => 200
adder.next(150).value; // => 350

在这里,我们使用我们的yield表达式(yield n)的返回值,并在每次生成器运行时将其添加到n的现有值上。我们需要最初调用next()一次来启动这一切,因为在这之前,n += yield n表达式还没有运行,因此还没有等待next()的调用。

作为消费者使用生成器并没有很多用例,并且很可能是一种尴尬的模式,因为我们必须使用指定的next()方法来传递数据。但是,了解yield表达式的灵活性是有用的,因为你在实际应用中可能会遇到。

产出的复杂性

对于程序员来说,理解生成器内部控制流可能会变得复杂和难以理解,因为它涉及来回很多次调用者和生成器之间的交互。在任何特定点知道正在运行的确切代码可能很难确定,因此建议保持生成器的简短,并确保它们在其他方面一直保持一致——换句话说,在你的生成器内不要有太多不同的生成路径,并且通常尽量保持圈复杂度很低(如果您直接跳到处理圈复杂度部分,您可以阅读更多相关信息)。

中断

中断是从当前forwhileswitch或带标签的语句内部转移控制到该语句后面的代码。它有效地终止了该语句,阻止后续任何代码的执行。

在迭代的上下文中,是否继续或中断迭代通常由构造本身内的ConditionExpression(例如,counter < array.length)确定,或者由数据结构的长度在for..infor..of的情况下确定。然而,有时仍然可能需要提前中断迭代。

例如,如果您正在查找数据结构中的特定项(类似于在大海里找针的情况),那么一旦找到该项就停止查找是有意义的。我们通过中断来实现这一点:

for (let i = 0; i < array.length; i++) {
  if (myCriteriaIsMet(array[i]) {
    happyPath();
    break;
  }
}

从迭代中中断将立即停止并退出迭代,这意味着包含的IterationBody中的任何剩余代码将不会被执行。随后将执行IterationBody后面紧跟的代码。

break语句也用于从switch语句中退出,通常是在执行相关的case语句之后。正如我们稍后将在本章讨论的那样,switch语句将将控制转移到与传递给switch(...)的值严格相等(===)的case语句,然后运行所有该case语句之后的代码,直到出现显式的break;(或者return;yield;throw;):

switch (2) {
  case 1: console.log(1);
  case 2: console.log(2);
  case 3: console.log(3);
  case 4: console.log(4); break;
  case 5: console.log(5);
}

// Logs: 2, 3, 4

在这里,我们看到值为2将控制转移到匹配的case 2,然后 switch 体内的所有后续代码将自然运行,直到遇到break;语句。因此,我们只能看到234的日志。1的日志被避免了,因为case 1不匹配值2,而5的日志也被避免了,因为break;出现在它之前。

switch中的case不中断时,称为贯穿。在switch语句中使用的这种常见技术在你想要根据多个匹配条件执行单个操作或级联操作时是有用的(我们将在switch 语句部分更详细地介绍这个概念)。

break关键字的右侧可能有一个标签,表示switchforwhile语句。如果没有提供标签,JavaScript 将默认认为你是指当前包含的迭代或switch结构。只有当你有两个或更多可打破的结构相互嵌套时,例如在一个迭代中嵌套另一个迭代。请注意这里我们如何用outerLoop标签标记我们外部的for循环,使我们能够从内部的for循环中跳出:

outerLoop: for (let obj in objects) {
  for (let key in obj) {
    if (/* some condition */) {
      break outerLoop;
    }
  }
}

实际上,你可以跳出任何带标签的语句(即使它在迭代或switch结构之外),但你必须显式提供标签:

specificWork: {
  doSomeSpecificWork();
  if (weAreFinished) {
    break specificWork;
      // immediately exits the `specificWork: {...}` block
  }
  doOtherWork();
}

这种情况非常少见,但是确实值得了解,以防你碰到这样的代码。

最后要注意的一点是关于跳出迭代或switch语句的是,尽管我们通常使用显式的break;语句来做到这一点,但也可以通过其他控制移动的机制有效地发生,例如yieldingreturningthrowing。例如,看到使用return;跳出不仅是它本身的迭代,也是包含函数的迭代是非常常见的。

继续

Continuing是一种控制的转移,从当前语句到可能的下一个迭代的开始。它是通过一个continue语句来实现的。

continue语句在所有迭代构造中都有效,包括forwhiledo...whilefor...infor...of

这是一个有条件继续的例子,所以迭代体不会对特定项目执行,但迭代仍然会继续进行:

const numbers = [1, 2, 3];

for (const n of numbers) {
  if (n === 2) continue;
  console.log(n);
}

// Logs: 1, 3

Continuing会跳过当前迭代中continue后面的所有代码,然后继续执行接下来的自然情况。

break语句类似,在continue关键字的右侧可以选择性地加上一个标签,表示应该继续的哪个迭代构造。如果没有提供标签,JavaScript 将默认认为你是指当前迭代构造。如果你有两个或更多嵌套在一起的迭代构造,那么可能需要使用显式标签:

objectsIteration: for (let obj in objects) {
  for (let key in obj) {
    if (/* some condition */) {
      continue objectsIteration;
    }
  }
}

continue语句只会在我们原生的循环构造中起作用。如果我们希望在类似Array#forEach这样的抽象化循环结构中继续,那么通常我们会希望使用return语句(从回调返回,因此继续迭代)。

由于continuing是一种控制的移动,我们必须谨慎地考虑我们在传达意图时是否清晰。如果我们有多层循环或多个continuebreak语句,那么会给读者带来不必要的复杂性。

抛出

抛出 是控制从当前语句转移到调用堆栈上最近的包含 try...catch 语句。如果不存在这样的 try...catch 语句,则程序的执行将完全终止。抛出通常用于在特定要求或期望不满足时引发异常:

function nameToUpperCase(name) {
  if (typeof name !== 'string') {
    throw new TypeError('Name should be a string');
  }
  return name.toUpperCase();
}

要捕获这个错误,我们需要在调用堆栈的某个位置上有一个 try...catch 块,包裹住对 nameToUpperCase 函数的调用,或者调用这个函数的函数(以此类推):

let theUpperCaseName;
try {
  theUpperCaseName = nameToUpperCase(null);
} catch(e) {
  e.message; // => "Name should be a string"
}

最佳做法是抛出作为原生提供的通用 Error 构造函数的实例对象。其中有几个原生的子类构造函数 Error

  • SyntaxError:这表示发生了解析错误

  • TypeError:这表示在没有其他 Error 对象适用的情况下,操作不成功

  • ReferenceError:这表示检测到无效的引用值

  • RangeError:这表示一个不在可允许值的集合或范围内的值

  • URIError:这表示以与其定义不兼容的方式使用了 URI 处理函数

如果您误用本机 API 或产生无效语法,JavaScript 将自然将这些异常提供给您,但您也可以自己使用这些构造器为您的其他程序员提供更语义化的错误。如果以上情况都不适用,则可以直接使用Error或从中扩展出自己的专门实例,如下所示:

class NetworkError extends Error {}

async function makeDataRequest() {
  try {
    const response = await fetch('/data');
  } catch(e) {
    throw NetworkError('Cannot fetch data');
  }
  // ... (process response) ...
}

所有的 Error 实例都会包含 name 和 message 属性。根据 JavaScript 的实现,可能还会有与错误的堆栈追踪相关的其他属性。在 V8 JavaScript 引擎(用于 Chromium 和 Node.js)和 SpiderMonkey(Mozilla)中都有一个 stack 属性,提供了序列化的调用堆栈信息:

try {
  throw new Error;
} catch(e) {
  e.stack; // => "Error\n at filename.js:2:9"
}

可能会出现独特的情况,您希望抛出一个不是 Error 实例的值,从技术上讲这是完全合法的,但很少有用。最好只在真正出现错误的情况下进行抛出,并且在这种情况下,最好使用适当的 Error 对象来表示该错误。

控制流语句

现在我们已经巩固了我们对控制在高层次上是如何移动的理解,我们可以进一步探索 JavaScript 给我们控制流的特定语句和机制。我们将探讨每个语句的语法,并结合一些最佳实践和需要避免的陷阱。

如果语句

if 语句由 if 关键词 开始,后面跟着一个括号表达式,再然后是一个额外的语句:

if (ConditionExpression) Statement

ConditionExpression 可以是无限复杂的表达式,只要它真正是一个表达式:

if (true) {}
if (1 || 2 || 3) {}
if ([1, 2, 3].filter(n => n > 2).length > 0) {}

在括号表达式后面的语句可以是一个单行语句或一个 代码块,并指定了当 ConditionExpression 评估为真时应运行的代码:

// These are equivalent
if (true) { doBaz(); }
if (true) doBaz();

您传递为ConditionExpression的值将与布尔值进行比较,以确定其真实性。我们在第六章,基本和内置类型中已经恰当地介绍了真实性和虚伪性的概念,但以防万一您生疏了:在 JavaScript 中只有七个虚假值,因此,您可以传递给if语句的只有七个可能的值不会满足它:

if (false) {}
if (null) {}
if (undefined) {}
if (0n) {}
if (0) {}
if ('') {}
if (NaN) {}

if语句不满足时,它将运行一条可选的else语句,您可以在if语句后面立即指定。就像if一样,您也可以在此处使用一个

if (isLegalDrinkingAge) drink(); else leave();

// Equivalent, with Blocks:
if (isLegalDrinkingAge) {
  drink();
} else {
  leave();
}

您可以有效地链式if/else语句连接在一起,如下所示:

if (number > 5) {
  // For numbers larger than five
} else if (number < 3) {
  // For numbers less than three
} else {
  // For everything else
}

在语法上,重要的是要理解这不是自己的结构(没有像if/else/if/else结构一样的东西);它只是一个常规的if语句,然后是一个包含自己if/else对的else语句。因此,也许更准确地看待它如下所示:

if (number > 5) {
  // For numbers larger than five
} else {
  if (number < 3) {
    // For numbers less than three
  } else {
    // For everything else
  }
}

当条件有一个或两个可能的结果时,最适合使用if语句。如果有更多可能的结果,那么您可能更适合使用 switch 语句。if/else链条会变得难以操作。稍后在本章中查看处理圈复杂度部分,探索处理复杂条件逻辑的其他新颖方法。

for语句

for语句用于循环遍历一组,通常是数组或任何可迭代的结构。它有四种广义的变体:

  • 传统 for:包括以下内容:

    • 语法for (initializer; condition; incrementer) {...}

    • 用法:通常用于自定义方式在索引结构中进行迭代

  • For...in:包括以下内容:

    • 语法for (let item in object) {...}

    • 用法:用于遍历任何对象的键(通常用于纯对象

  • For...of:包括以下内容:

    • 语法for (let item of iterable) {...}

    • 用法:用于在可迭代的结构(通常是类似数组的结构)上进行迭代

您将使用的for结构的类型取决于您希望迭代的确切内容。例如,对于简单的索引和类似数组的结构,for...of结构最有用。我们将逐个讨论这些结构,以探讨其用例和潜在挑战。

传统的 for

传统的for语句用于迭代各种数据结构或概念循环场景。它包括三个表达式,用分号分隔,并且最后是一个语句,它被认为是迭代的主体

for (
  InitializerExpression;
  ConditionExpression;
  UpdateExpression
) IterationBody

每个部分的目的如下:

  • InitializerExpression初始化迭代;这将首先进行评估,并且仅进行一次。这可以是任何语句(通常包括letvar分配,但不必是)。

  • ConditionExpression检查迭代是否可以继续;在每次迭代之前,将对其进行评估和强制转换为布尔值(就像通过Boolean(...)一样),以确定下一次迭代是否会发生。这可能是任何表达式,尽管通常用于检查当前索引是否小于某个上限(通常是您正在迭代的数据结构的长度)。

  • UpdateExpression完成每次迭代,准备进行下一次迭代。这将在每次迭代结束时进行评估。这可以是任何陈述,虽然在习惯用法上最常用于增加或减少当前索引。

  • IterationBody包含实际的迭代逻辑——将在每次迭代时评估的代码。这通常是一个,但可以是一个单行语句。

使用传统的for语句循环遍历数组的代码如下:

for (let i = 0; i < array.length; i++) {
  array[i]; // => (Each `array` item)
}

如果只需要遍历常规数组或可迭代结构,则最好使用for...of。然而,如果需要对结构进行非常规索引的迭代,那么使用传统的for循环可能是合适的。

一个非常规索引结构的示例是<canvas>元素的像素数据,它形成一个包含每个像素的 RGBA(红色、绿色、蓝色和 Alpha 通道)值的数组,连续排列,如下所示:

[r, g, b, a, r, g, b, a, ...]

由于每个单独的像素占据数组的四个元素,我们需要每次迭代四个索引。传统的for循环非常适合于这种情况:

const pixelData = canvas.getContext('2d').getImageData(0, 0, 100, 100).data;

for (let i = 0; i < pixelData.length; i += 4) {
  let red = pixelData[i];
  let blue = pixelData[i + 1];
  let green = pixelData[i + 2];
  let alpha = pixelData[i + 3];
  // (do something with RGBA)
}

传统的for语句是一个被理解并习惯使用的语法结构。最好确保您使用每个部分来实现其目的。虽然可以(尽管不建议)通过将迭代的实际逻辑包含在结构的括号部分来利用其语法,但这和其他误用对人类来说可能非常难解析:

var copy = [];
for (
  let i = 0;
  i < array.length;
  copy[i] = array[i++]
); 

这里的UpdateExpression包括copy[i] = array[i++]表达式,它将复制当前索引处的数组元素,然后递增索引。后缀++运算符确保其操作数的先前值将被返回,从而保证在copy[i]上访问的索引始终等于array[i++]。这是一个巧妙但相当晦涩的语法。使用习惯用法的for结构将会更清晰,它在for(...)之后将迭代逻辑放在自己的语句中:

for (
  let i = 0;
  i < array.length;
  i++
) {
  copy[i] = array[i];
}

对于大多数程序员来说,这是一个更熟悉和易懂的代码片段。它更冗长,也许写起来不那么有趣,但最终,正如本书的初步章节中所探讨的,我们最感兴趣的是编写能清晰传达其意图的代码。

当然,这个虚构的情景,将一个数组的内容复制到另一个数组中,最好使用Array#slice方法(array.slice())来解决,但我们在这里使用它进行说明。

for...in

for...in构造用于迭代对象的一组可枚举属性名称。它具有以下语法:

for (LeftSideAssignment in Object) IterationBody

各个部分具有以下限制:

  • LeftSideAssignment可以是在每次新迭代中在IterationBody范围内评估的任何有效赋值表达式左侧,并且

  • Object可以是任何求值为(或可以被强制转换为)对象的表达式——换句话说,除了nullundefined之外的任何东西。

  • IterationBody是任何单行或块语句

for...in构造通常用于遍历普通对象的属性:

const city = { name: 'London', population: 8136000 };
for (const key in city) {
  console.log(key);
}
// Logs: "name", "population"

你可以看到我们在这里使用const key来初始化我们的key变量。除非你特别需要let的可变行为或var的不同作用域行为,否则这是首选的声明。当然,除了不声明,所有这些声明都是完全有效的:

for (let key in obj) {}
for (var key in obj) {}
for (const key in obj) {}
for (key in obj) {}

每次迭代都会创建一个新的块作用域。当你使用letconst声明时,它将作用于该迭代,而通过var声明的变量将作用于最近的执行上下文范围(函数作用域)。完全不声明也没问题,但你应该确保之前已经初始化了该标识符:

let key;
for (key in obj) {}

由于任何在赋值表达式左侧有效的东西在in的左侧也是有效的,我们也可以在这里放置一个属性引用,就像下面的例子:

let foo = {};
for (foo.key in obj) {}

这将导致foo.key在迭代进行中被赋予obj的每个键。这将是一个非常奇怪的事情,但仍然可以正确工作。

现在我们介绍了语法,可以讨论for..in的行为和用例了。如前所述,它在迭代对象的属性时非常有用。默认情况下,这将包括从对象的[[Prototype]]链继承的所有属性,但仅当它们是可枚举时:

const objectA = { isFromObjectA: true };
const objectB = { isFromObjectB: true };

Object.setPrototypeOf(objectB, objectA);

for (const prop in objectB) {
 console.log(prop);
}

// Logs: "isFromObjectB", "isFromObjectA"

正如你所看到的,对象本身的属性先于继承对象的属性进行迭代。然而,迭代的顺序不应该被依赖,因为这可能会在不同的实现之间有所不同。如果你想以特定顺序迭代一组键,最好通过Object.keys(obj)来收集键,然后像遍历数组一样对其进行迭代。

由于for...in自然会迭代继承的属性,因此在迭代体内放置附加检查以避免这些属性是传统做法:

for (const key in obj) {
  if (obj.hasOwnProperty(key)) {
    // `key` is a non-inherited (direct) property of `obj`
  }
}

当你有一个可迭代对象(比如一个数组)时,最好使用for...of,它更适合这种情况,并且性能更好。

for...of

for...of结构用于遍历可迭代对象。原生提供的可迭代对象包括StringArrayTypedArrayMapSet。在语法上,for...of具有与for...in相似的特征:

for (LeftSideAssignment in IterableObject) IterationBody

每个部分的目的如下:

  • LeftSideAssignment可以是任何在赋值表达式左侧有效的东西,并在每次新迭代中在IterationBody的范围内进行评估

  • IterableObject可以是任何评估为可迭代对象的表达式,换句话说,任何实现[Symbol.iterator]为方法的东西

  • IterationBody是任何单行或块语句

一个惯用的for...of用法可能是这样的:

const array = [1, 2, 3];

for (const i of array) {
  console.log(i);
}

// Logs: 1, 2, 3

自从引入语言以来,for...of已成为循环数组的最惯用方式,取代了先前惯用的for (var i = 0; i < array.length; i++) {...}模式。

letvarconst的作用域行为与上一节关于for...in描述的相同。建议使用const,因为它将为每次迭代初始化一个新的不可变变量。使用let并不可怕,但除非你有特定的原因需要在IterationBody内自己对变量进行变化,否则最好使用const

while语句

while语句用于运行一段代码,直到某个条件不再被满足。它的语法如下:

while (ConditionExpression) IterationBody

每个部分的目的如下:

  • ConditionExpression被评估以确定IterationBody是否应该运行。如果评估为true,那么IterationBody部分将运行。然后ConditionExpression将被重新评估,依此类推。只有当ConditionExpression评估为false时,循环才会停止。

  • IterationBody可以是单行或块语句,将根据ConditionExpression评估为true运行多次。

很少使用while进行直接迭代,因为有更适合此目的的结构(例如,for...of),但如果我们想要,可能会看起来像下面这样:

const array = ['a', 'b', 'c'];

let i = -1;
while (++i < array.length) {
  console.log(array[i]);
}

// Logs: 'a', 'b', 'c'

由于我们将i初始化为-1并使用前缀递增运算符(++i),ConditionExpression将评估为0 < array.length1 < array.length2 < array.length,和3 < array.length。自然地,最后一个检查将失败,因为3不小于array.length,这意味着while语句将停止运行其IterationBody。这意味着Body总共只会运行3次。

当迭代的限制尚不明确或以复杂的方式计算时,通常会使用while。在这种情况下,常常会看到true被直接传递给ConditionExpression以在while(...)内部强制结束迭代的手动break;语句:

while (true) {
  if (/* some custom condition */) {
    break;
  }
}

while 语句也在生成器函数的上下文中使用,如果这些生成器旨在产生无限的输出。例如,您可能希望创建一个始终产生字母表中的 下一个 字母的生成器,然后在到达 z 时循环到字母表的开头:

function *loopingAlphabet() {
 let i = 0;
 while (true) {
   yield String.fromCharCode(
     97 + (i >= 26 ? i = 0 : i++)
   );
 }
}

const alphabet = loopingAlphabet();

alphabet.next(); // => { value: "a" }
alphabet.next(); // => { value: "b" }
alphabet.next(); // => { value: "c" }
// ...
alphabet.next(); // => { value: "z" }
alphabet.next(); // => { value: "a" }
alphabet.next(); // => { value: "b" }
// ...

这种无限应用的生成器很少见,但它们确实存在,并且是使用 while(...) 的理想场所。大多数其他 while 的应用已被更简洁且更受限制的迭代方法(如 for...infor...of)取代。尽管如此,了解如何清晰地使用它还是有用的。

do...while 语句

do...while 语句类似于 while 语句,尽管它保证在执行检查之前会进行一次迭代。其语法由 do 关键字后面跟着其主体,然后是典型的带有括号的 while 表达式组成:

do IterationBody while (ConditionExpression)

每个部分的目的如下:

  • IterationBody 可以是单行语句或块语句,并将首先运行一次,然后根据 ConditionExpression 的评估结果运行多次。

  • 评估 ConditionExpression 来确定 IterationBody 是否应运行多次。如果评估为 true,则将运行 Body 部分。然后将重新评估 ConditionExpression,依此类推。只有当 ConditionExpression 评估为 false 时,循环才会停止。

虽然 do...while 语句的行为与常规的 while 语句不同,但其语义和广泛的应用仍然相同。它在需要在检查是否继续或更改迭代主题之前始终完成至少一个步骤的上下文中最有用。其中一个例子是向上的 DOM 遍历。如果您有一个 DOM 元素并希望在它及其每个 DOM 祖先上运行某些代码,那么可以像下面这样使用 do...while 语句:

do {
  // Do something with `element`
} while (element = element.parentNode);

像这样的循环将为 element 值执行其主体一次,无论 element 是什么,然后将评估赋值表达式 element = element.parentNode。这个赋值表达式将评估为其新分配的值,这意味着在 element.parentNode 为虚假值(例如 null)的情况下,do...while 将停止其迭代。

whiledo...while 语句的 ConditionExpression 部分分配值相对常见,尽管对其他程序员来说可能不太明显,因此最好只有在代码意图明显的情况下才这样做。如果前面的代码包装在一个名为 traverseDOMAncestors 的函数中,那将提供一个有用的线索。

switch 语句

switch 语句用于将控制移动到特定的内部 case 子句,该子句指定与传递给 switch(...) 的值匹配的值。它具有以下语法:

switch (SwitchExpression) SwitchBody

SwitchExpression将被评估一次,其值将通过严格相等性与SwitchBody内的 case 语句进行比较。在SwitchBody中可能有一个或多个case子句和/或一个default子句。case子句指定CaseExpression,其值将与SwitchExpression的值进行比较,其语法如下:

case CaseExpression:
  [other JavaScript statements or additional clauses]

switch语句通常用于根据特定值指定两个或多个互斥结果的选择。如果条件较少,习惯上会使用if...else结构,但为了适应更多的潜在条件,使用switch更简单:

function generateWelcomeMessage(language) {

  let welcomeMessage;

  switch (language) {
    case 'DE':
      welcomeMessage = 'Willkommen!';
      break;
    case 'FR':
      welcomeMessage = 'Bienvenue!';
      break;
    default:
      welcomeMessage = 'Welcome!';
  }

  return welcomeMessage;
}

generateWelcomeMessage('DE'); // => "Willkommen!"
generateWelcomeMessage('FR'); // => "Bienvenue!"
generateWelcomeMessage('EN'); // => "Welcome!"
generateWelcomeMessage(null); // => "Welcome!"

一旦switch机制找到适当的case,它将执行所有跟随该case语句的代码,直到switch语句的最后,或者直到遇到break语句为止。使用break语句是为了在完成所需的工作时跳出SwitchBody

中断和穿透

鉴于switch语句通常用于根据值执行特定且互不相同的代码块,习惯上在每个case语句之间使用break,以确保对于任何给定值只执行适当的代码。但有时,希望在情况之间避免中断,让SwitchBody代码继续通过多个case语句和更多。这样做被称为穿透

switch (language) {

  case 'German':
  case 'Deutsche':
  case 'DE':
    welcomeMessage = 'Willkommen!';
    break;

  case 'French':
  case: 'Francais':
  case 'FR':
    welcomeMessage = 'Bienvenue!';
    break;

  default:
    welcomeMessage = 'Welcome!';
}

在这里,你可以看到我们使用了穿透,以便'German''Deutsche''DE'的任何语言都会导致相同的代码运行welcomeMessage = 'Willkommen!'。随后,我们立即中断,以防止任何更多的SwitchBody代码运行。

遗憾的是,很容易不小心忘记奇怪的break;语句,导致意外的穿透和一个非常困惑的程序员。为了避免这种情况,我建议使用一个具有规则的检查器,该规则在这种情况下发出警告或错误,除非给定特定指令。(我们将在第十五章 更清洁代码的工具中更详细地介绍检查器。)

直接从开关返回

当你在一个函数中有一个switch语句时,有时最好的方法是简单地return预期的值,而不是依赖于break语句。例如,在generateWelcomeMessage中,我们可以简单地返回欢迎字符串。没有必要初始化变量,赋值,和在不同的情况下来回跳转:

function generateWelcomeMessage(language) {
  switch (language) {
    case 'DE':
      return 'Willkommen!';
    case 'FR':
      return 'Bienvenue!';
    default:
      return 'Welcome!';
  }
}

直接返回,这种方式可以说比在每个 case 中中断要更清晰,特别是如果每个 case 的逻辑相当简单。

case 块

通常,casedefault子句之后的代码不止占据一行。因此,习惯上将这些语句包含在一个块中,以便有一种包容性:

switch (speed) {
  case 'slow': {
    console.log('Initiating slow speed');
    car.changeSpeedTo(speed);
    car.enableUrbanCollisionControl();
  }
  case 'fast': {
    console.log('Initiating fast speed');
    car.changeSpeedTo(speed);
    car.enableSpeedLimitWarnings();
    car.enableCruiseControlOption();
  }
  case 'regular':
  default: {
    console.log('Initiating regular speed');
    car.changeSpeedTo(speed);
  }
}

这并不是严格必要的,也不会改变任何功能,但它确实为我们的代码读者提供了更多的清晰度。它还为我们引入块级变量铺平了道路,如果我们以后想引入这些变量的话。正如我们所知,在一个由{}界定的块中,我们可以使用constlet来声明仅限于该块的作用域的变量:

switch (month) {
  case 'December':
  case 'January':
  case 'February': {
    const message = 'In the UK, Spring is coming soon!';
    // ...
  }
  //...
}

在这里,我们能够声明仅限于February情况的特定变量。如果我们有大量逻辑需要隔离,这将会很有用。然而,在这个时候,我们应该考虑以其他方式对这些逻辑进行抽象。冗长的switch语句可能是难以理解的。

多变条件

经常需要在每个case中表达更复杂的条件,而不仅仅是匹配单个值。如果我们将SwitchExpression传递为true,那么我们可以在每个CaseExpression中自由表达自定义的条件逻辑,只要每个CaseExpression在成功时都求值为true

switch (true) {
  case user.role === 'admin' || user.role === 'root': {
    // ...
    break;
  }
  case user.role === 'member' && user.isActive: {
    // ...
    break;
  }
  case user.role === 'member' && user.isRecentlyInactive: {
    // ...
    break;
  }
}

这种模式允许我们表达更多多变和混合条件。你可能通常倾向于多个if/else/if/else语句,但如果你的逻辑可以在一个switch语句中表达,那么最好选择这种方式。总是应该考虑你的问题领域的特性和逻辑,并努力做出关于如何实现控制流的明智决定。在某些情况下,switch语句可能会变得更加混乱。

在下一节中,我们将介绍一些其他方法,这些方法可以用于处理不适合原生结构(如switch)的复杂和冗长逻辑。

处理圈复杂度

圈复杂度:是衡量程序代码中有多少线性独立路径的指标。

考虑一个包含多个条件检查和函数调用的简单程序:

if (a) {
 alpha();
 if (b) bravo();
 if (c) charlie();
}
if (d) delta();

即使在这段看似简单的代码中,也存在九条不同的路径。因此,根据abcd的值,可能会有九种alphabravocharliedelta的运行序列:

  • alpha()

  • alpha() 和 bravo()

  • alpha()bravo() 和 charlie()

  • alpha()bravo()charlie() 和 delta()

  • alpha()bravo() 和 delta()

  • alpha() 和 charlie()

  • alpha()charlie(),和 delta()

  • alpha() 和 delta()

  • delta()

高圈复杂度是不可取的,可能会导致以下情况:

  • 认知负荷:具有圈复杂度的代码对程序员来说可能很难理解。具有许多分支的代码不容易内化并记住,因此更难维护或更改。

  • 不可预测性:具有圈复杂度的代码可能是不可预测的,特别是在罕见情况下,例如出现了未预料的状态转换或数据底层变化。

  • 脆弱性:圈复杂的代码在面对变化时可能是脆弱的。改变一行可能会对许多其他行的功能产生不成比例的影响。

  • Bugginess:圈复杂的代码可以导致难以捉摸的错误。如果在一个单一函数中有十几个或更多的代码路径,那么维护者可能看不到所有这些,导致回归。

有工具可以量化代码库的圈复杂性。我们将在第十五章中介绍这些,更干净代码的工具。了解高圈复杂性区域可以帮助我们专注于这些区域的维护和测试。

很容易陷入一种情况,在一个单一模块中有太多不同的条件和分支,以至于没有人能够理解发生了什么。除了使用工具来帮助我们识别高复杂性区域外,我们还可以使用自己的判断和直觉。以下是一些我们可以轻松识别和避免的复杂性的例子:

  • 一个具有多个if/else/if组合的函数

  • 一个有许多子条件的if语句(即在if语句内部有许多if语句)

  • 一个switch语句,后面跟随着许多子条件的case子句

  • 在一个switch块中有很多case子句(例如,超过 20 个将是令人担忧的!)

这些并不是精确的警告,但它们应该给你一个关于你应该注意的内容的想法。当我们发现这样的复杂性时,我们应该做的第一件事是坐下来重新考虑我们的问题领域。我们能否以不同的方式描述我们的逻辑? 我们是否可以创建新的或不同的抽象?

让我们探讨一个具有较高圈复杂度的代码示例,并考虑如何以这些问题为依据来简化它。

简化条件分支乱麻

为了说明圈复杂性过高以及我们应该如何简化它,我们将重构一段代码,该代码负责从一组许可证中产生一组 ID 号码和类型:

function getIDsFromLicenses(licenses) {
  const ids = [];
  for (let i = 0; i < licenses.length; i++) {
    let license = licenses[i];
    if (license.id != null) {
      if (license.id.indexOf('c') === 0) {
        let nID = Number(license.id.slice(1));
        if (nID >= 1000000) {
          ids.push({ type: 'car', digits: nID });
        } else {
          ids.push({ type: 'car_old', digits: nID });
        }
      } else if (license.id.indexOf('h') === 0) {
        ids.push({
          type: 'hgv',
          digits: Number(license.id.slice(1))
        });
      } else if (license.id.indexOf('m') === 0) {
        ids.push({
          type: 'motorcycle',
          digits: Number(license.id.slice(1))
        });
      }
    }
  } 
  return ids;
}

此函数接受许可证的数组,然后提取这些许可证的 ID 号码(避免nullundefinedID 的情况)。我们根据 ID 中的字符确定许可证的类型。需要鉴定和提取四种类型的许可证:

  • car: 这些是c{digits}形式,其中 digits 形成一个大于或等于 1,000,000 的数字

  • car_old: 这些是c{digits}形式,其中 digits 形成一个小于 1,000,000 的数字

  • hgv: 这些是h{digits}形式的

  • motorcycle: 这些是m{digits}形式的

以下是getIDsFromLicenses函数的输入和派生输出的示例:

getIDsFromLicenses([
    { name: 'Jon Smith', id: 'c32948' },
    { name: 'Marsha Brown' },
    { name: 'Leah Oak', id: 'h109' },
    { name: 'Jim Royle', id: 'c29283928' }
]);
// Outputs:
[
  {type: "car_old", digits: 32948}
  {type: "hgv", digits: 109}
  {type: "car", digits: 29283928}
]

正如你可能已经观察到的那样,我们用于提取 ID 的代码具有相当复杂的圈复杂度。你可能认为它是完全合理的代码,而且它确实是,但它还可以更简单。我们的函数以命令式方式实现了其结果,使用大量语法来解释它希望如何完成任务,而不是它希望完成什么任务。

为了简化我们的代码,首先需要重新审视问题域。我们想要完成的任务是从输入数组中得出一组许可证 ID 类型和值。输出数组几乎与输入数组一一对应,只有许可证的id属性为假值(在这种情况下为null)的情况除外。以下是我们的输入/输出流程的示例:

[INPUT LICENSES] ==> (DERIVATION LOGIC) ==> [OUTPUT ID TYPES AND DIGITS]

从抽象地看,这似乎是使用Array#map的绝佳机会。map方法允许我们对数组中的每个元素运行一个函数,以得出包含映射值的新数组。

我们要映射的第一件事是将许可证映射到其id

ids = licenses.map(license => license.id)

我们需要处理没有id的情况。为此,我们可以对衍生的 ID 应用过滤器:

ids = ids.filter(id => id != null)

实际上,由于我们知道所有有效的 ID 都是真值,我们可以直接用Boolean作为过滤函数进行布尔检查:

ids = ids.filter(Boolean)

从中,我们将收到一个包含我们的许可证的数组,但只有那些具有真值id属性的许可证。在此之后,我们可以考虑对数据应用的下一个转换。我们想要将id值拆分为其构成部分:我们需要 ID 的初始字符(id.charAt(0)),然后我们想提取剩余的字符(数字),将它们转换为Number类型(Number(id.slice(1)))。然后我们可以将这些部分传递给另一个函数,负责从这些信息中提取正确的 ID 字段(typedigits):

ids = ids.map(id => getIDFields(
  id.charAt(0),
  Number(id.slice(1))
));

getIDFields函数需要根据 ID 的单个字符和数字确定类型,返回一个形如{ type, digits }的对象:

function getIDFields(idType, digits) {
  switch (idType) {
    case 'c': return {
      type: digits >= 1000000 ? 'car' : 'car_old',
      digits
    };
    case 'h': return { type: 'hgv', digits };
    case 'm': return { type: 'motorcycle', digits };
  }
}

由于我们将逻辑的这部分抽象给了一个独立的函数,我们可以独立观察和测试它的行为:

getIDFields('c', 1000); // => { type: "car_old", digits: 1000 }
getIDFields('c', 2000000); // => { type: "car", digits: 1000 }
getIDFields('h', 1000); // => { type: "hgv", digits: 1000 }
getIDFields('i', 1000); // => { type: "motorcycle", digits: 1000 }

将所有部分联系在一起,我们最终得到一个类似下面这样的对getIDsFromLicenses的新实现:

function getIDsFromLicenses(licenses) {
  return licenses
    .map(license => license.id)
    .filter(Boolean)
    .map(id => getIDFields(
      id.charAt(0),
      Number(id.slice(1))
    ))
}

我们在这里取得的成就是大大减少了同行程序员需要处理的圈复杂度。我们利用了Array#mapArray#filter来抽象决策和迭代逻辑。这意味着我们最终得到了一个更加声明式的实现。

你可能还注意到,我们提取了重复逻辑并将其概括化。例如,在我们最初的实现中,我们实现了许多调用来发现 ID 的第一个字符(例如,license.id.indexOf('m') === 0)。我们的新实现通过映射到已经包括第一个字符的数据结构来概括这个问题,然后我们可以通过getIDFields获得该 ID 的相关typedigits

总结来说,我们的一般重构方法包括以下考虑因素:

  • 我们以新的视角考虑了问题领域

  • 我们考虑了是否有常见的函数式或声明式习惯用法来处理我们的 I/O

  • 我们考虑了个别逻辑是否可以抽象化或分离。

现在我们的代码更容易理解,因此更容易维护和调试。它可能也更可靠和稳定,因为其各个单元可以更简单地测试,因此可以避免未来的回归。当然,由于更高程度的抽象化声明习惯和函数的增加使用,可能会导致轻微的性能下降,但这是一个非常边缘的差异,在绝大多数情况下,为了维护性和可靠性的重大益处而实施是值得的。

异步控制流

到目前为止,我们看过的大部分构造都用于同步代码,其中语句按顺序评估,每一行完成后下一行开始:

const someValue = getSomeValue();
doSomethingWithTheValue(someValue);

像这样的代码很简单。我们直观地理解这两行代码会依次运行。我们还假设这两行代码都不会花费太长时间来执行,可能只需要几个微秒或毫秒。

但是如果我们希望绑定到用户事件或获取一些远程数据会发生什么?这些事情需要时间,只有当未来事件发生时才会完成。在一个不那么友好的宇宙中,除了等待它们完成然后继续执行我们的程序之外,没有其他处理这种情况的方法:

fetchSomeData();
processFetchedData();

在这个不友好的宇宙中,fetchSomeData()将是一个阻塞的函数调用,因为它会阻塞所有其他代码的执行,直到最终完成。这意味着我们将无法执行任何其他重要任务,我们的应用程序基本上会处于停滞状态,直到任务完成,从而对用户体验产生负面影响。

幸运的是,JavaScript 给了我们一个比这更好的世界——一个可以初始化一个任务(比如获取数据),然后在任务运行时继续进行程序的其余部分的世界。这些任务被称为 异步,因为它们发生和完成的时间比 现在 晚。当它们最终完成时,JavaScript 可以帮助我们通知这一事实,调用任何依赖于该任务完成的代码。

事件循环

为了实现这一点,JavaScript 保持单线程的 事件循环。当 事件循环 开始时,它将运行我们的程序。在执行完一段代码(比如启动我们的程序的代码)后,事件循环 会等待消息(或事件),表明发生了什么(例如,网络请求已完成或浏览器 UI 事件已发生)。当它收到消息时,它将执行依赖或监听该事件的任何代码。事件循环 将再次运行该代码直到完成,然后继续等待其他消息。这个过程会一直重复下去,直到 JavaScript 程序停止(例如,通过关闭浏览器选项卡)。

事件循环 总是运行给定的代码直到完成,这意味着任何长时间运行或 阻塞 的代码都会阻止其他代码执行直到它完成。一些旧的浏览器 API 方法,如 alert()prompt() 就是你可能会遇到的阻塞函数的例子。调用这些函数将有效地阻止 JavaScript 程序的进一步执行:

alert('Hello!');
console.log('The alert has been dismissed by the user');

在这里,console.log() 在用户关闭警告对话框之前不会被评估。这可能是毫秒、分钟,甚至小时。在此期间,我们的 JavaScript 程序被停止,无法继续执行。它的 事件循环 可能正在接收事件,但直到 alert() 最终完成才会运行与这些事件相关的代码。

本机异步 API

如今,在浏览器和服务器中期望提供非阻塞异步调用本机机制的 API 是很正常的。这类 API 的常见例子包括以下内容:

  • DOM 事件 API,使得能够运行这样的代码:window.addEventListener('click', callback)

  • Node.js 文件 API,使得能够运行这样的代码:fs.readFile(path, callback)

  • 浏览器的 Fetch API,使得能够运行这样的代码:fetch().then(callback)

所有这样的接口都有共同之处:它们都提供了一种监听其完成的方式。通常,这是通过提供的回调(函数)实现的。此回调将在任务完成后的某个时刻被调用。同样,一些本机 API 返回 promises,这使得有更丰富的异步控制流机制,但基本上仍然依靠通过 Promise API 传递回调。此外,ECMAScript 2017 引入了异步函数(async function() {})和await关键字的概念,最终为 promises 提供了语言支持,这意味着异步工作的完成不再需要回调。

让我们分别探讨这些异步控制流机制。

回调

回调是提供连接到异步任务的常规方法。回调只是一个传递给另一个函数的函数,并且预计将在以后的某个时刻被调用,可能是立即,可能很快,或可能永远不会。考虑以下的requestData函数:

function requestData(path, callback) {
  // (Implementation of requestData)
}

如您所见,它将回调作为其第二个参数。在调用requestData时,回调通常会被匿名地内联传递,如下所示:

requestData('/data/123', (response) => { /* ... */ });

当然,先前声明回调是完全可以的,这样做可以增加可理解性,因为现在你的代码读者会对何时可能调用回调有所了解。请注意这里我们是如何调用我们的onResponse回调的,以明确表明期望在响应可用时(当它完成时)调用它:

function onResponse(response) {
  // Do something with the response...
}

requestData('/data/123', onResponse);

类似地,在具有多个异步状态更改的复杂 API 中,通常会看到通过对象文字批量注册命名回调:

createDropdownComponent({
  onOpen() {},
  onSelect() {},
  onClose() {},
  onHover() {} // etc.
});

回调通常会传递参数,指示已从异步工作中确定的一些重要状态。例如,Node.js 的readFile函数会用两个参数调用它的回调函数,即(可能为 null 的)错误和文件本身的(可能为 null 的)数据:

fs.readFile('/path/to/file', (error, data) => {
  if (error) {
    // Handle the error!
  } else {
    // Handle the data! (No error has occurred!)
  } 
});

您将回调传递给的函数完全控制何时调用您的回调,如何调用它以及在调用时传递了什么数据。这就是为什么有时会将回调称为控制反转。通常情况下,您控制调用哪些函数,但是当使用回调时,控制被颠倒,因此您依赖另一个函数或抽象(在某个时刻)以期望的方式调用您的回调。

回调地狱是指在代码片段中不希望存在多个嵌套回调的繁殖现象,通常用于执行一系列相互依赖的异步任务。以下是这种情况的一个示例:

requestData('/data/current-user', (userData) => {
  if (userData.preferences.twitterEnabled) {
    requestData(userData.twitterFeedURL, (twitterFeedData) => {
      renderTwitterFeed(twitterFeedData, {
        onRendered() {
          logEvent('twitterFeedRender', { userId: userData.id });
        }
      });
    });
  }
});

在这里,你可以看到我们有三个不同的回调,都出现在一个范围层次的层级结构中。我们等待 /data/current-user 的响应,然后我们可以选择地发送请求到 twitterFeedURL,最后,在 Twitter feed 渲染(renderTwitterFeed())完成后,我们最终记录了一个 "twitterFeedRender" 事件。这个最终的日志取决于前两个异步任务的完成,因此嵌套得非常深。

我们可以看到,这个嵌套深度的代码片段处在一种水平金字塔 式缩进的顶峰。这是回调地狱 的一个常见特征,因此,你可以将这些水平金字塔 的存在视为一个需要注意的事项。当然,并非所有的深缩进都是由回调引起的,但通常在嫌疑名单中排名很高:

为了避免水平金字塔 所指示的回调地狱,我们应该考虑重新思考和可能重构我们的代码。在上述情况中,记录 Twitter feed 渲染事件,我们可以,例如,有一个通用的获取和渲染 Twitter feed 数据的函数。这将简化我们程序的顶层:

requestData('/data/current-user', (userData) => {
  if (userData.preferences.twitterEnabled) {
    renderTwitterForUser(userData);
  }
});

请注意,我们在这里缩短了水平金字塔。我们现在可以自由地实现renderTwitterForUser,并将其作为一个依赖导入。即使其实现可能涉及自己的回调,它对于程序员来说仍然是整体复杂性的减少,因为它将一半的金字塔抽象为一个整洁分离的抽象。大多数回调地狱 的情况都可以通过重新设计和抽象的类似方法来解决。尽管这是一个简单的情况。对于更加交织的异步任务,可能有必要使用其他异步控制流机制。

事件订阅/发射

JavaScript 在订阅和发射事件时感觉非常自然。事件在大多数 JavaScript 程序中都非常常见,无论是处理浏览器中用户派生的事件,还是在 Node.js 中处理服务器端事件。

JavaScript 中有许多与事件相关的操作名称,因此事先了解所有这些名称是很有用的,这样我们在遇到它们时就不会感到困惑。事件是时间上的发生,将导致已订阅该事件的任何回调的调用。订阅事件有很多名称,它们都有效地意味着相同的事情:订阅注册监听绑定等。当事件发生时,订阅的回调被调用。这也有许多名称:调用调用发射触发等。被调用的实际函数也可以有各种名称:函数回调监听器处理器

从其核心来看,任何支持事件的抽象通常都会通过存储稍后要调用的回调,并使用特定的事件名称作为键,来实现这一点。我们可以想象,DOM 元素可能会将其事件侦听器存储在以下结构中:

{
  "click": [Function, Function, Function],
  "mouseover": [Function, Function],
  "mouseout": [Function]
}

任何支持事件的抽象只会简单地存储一系列稍后要调用的回调。因此,当订阅事件时,你需要同时提供你希望它调用的回调和它将与之相关联的事件名称。在 DOM 中,我们会这样做:

document,body.addEventListener('mousemove', e => {
  e; // => the Event object
});

在这里,我们看到Event对象被传递给回调函数。这是为了简洁起见,习惯上用eevt来命名。大多数提供事件 API 的抽象将向回调传递特定的与事件相关的信息。这可能以一个单独的Event对象或几个参数的形式传递。

重要的是要注意,事件真的没有单一的标准,尽管已经出现了一些惯例。通常情况下,会始终有一种方法用于注册或订阅事件,然后另一种方法用于取消订阅。以下是一个使用 Node.js 事件发射器 API 的示例,该 API 受到原生 HTTP 模块支持:

const server = http.createServer(...);

function onConnect(req, cltSocket, head) {
  // Connect to an origin server...
}

// Subscribe
server.on('connect', onConnect);

// Unsubscribe
server.off('connect', onConnect);

在这里,你可以看到on()方法用于订阅事件,而off()方法用于退订。大多数事件 API 都有类似的事件注册和取消注册方法,尽管它们可能以不同的方式实现它们。如果你正在设计自己的事件实现,那么建议确保你提供一套熟悉的方法和抽象。为此,可以从原生 DOM 事件接口或 Node.js 的事件发射器中汲取灵感。这将确保你的事件实现不会让其他程序员感到太惊讶或害怕。

尽管事件 API 本质上只是一系列在特定时间存储和调用的回调,但在设计良好的情况下仍然存在一些挑战。其中包括以下内容:

  • 确保单一事件触发时的调用顺序

  • 处理事件在其他事件正在进行中发射的情况。

  • 处理事件可以完全取消或根据回调移除的情况

  • 处理事件可能会被冒泡、传播或委托的情况(这通常是 DOM 的一个挑战)。

传播冒泡委托是在分层结构内触发事件相关的术语。在 DOM 中,由于<div>可能存在于<body>内,事件 API 规定,如果用户点击<div>,发射的事件将向上传播或冒泡,首先触发<div>上的任何click监听器,然后是<body>上的。委托是在更高层次的层次上有意地监听,例如,在<body>级别上进行监听,然后根据事件对象告诉你有关事件的target节点的信息做出相应的反应。

事件提供了比简单回调更多的可能性。因为它们允许监听多种不同的事件,并且多次监听同一个事件,任何消费代码在构建其异步控制流时都具有更大的灵活性。具有事件接口的对象可以在整个代码库中传递,并且可能被订阅多次。不同事件的性质意味着不同的异步概念或发生可以被有用地分开,以便其他程序员可以轻松地了解特定情况下会采取哪些操作:

const dropdown = new DropDown();
dropdown.on('select', () => { /*...*/ });
dropdown.on('deselect', () => { /*...*/ });
dropdown.on('hover', () => { /*...*/ });

这种透明的分离有助于在程序员的头脑中编码期望。很容易辨别每种情况下将会调用哪个函数。将其与带有内部switch语句的泛化的发生了某事事件进行比较:

// Less transparent & more burdensome:
dropdown.on('action', event => {
  switch (event.action) {
    case 'select': /*...*/; break;
    case 'deselect': /*...*/; break;
    // ...
  }
});

良好实施的事件在概念上不同的事件之间提供了很好的语义分离,因此为程序员提供了可以轻松推理的可预测的一系列异步操作。

Promise

Promise是包围潜在值概念的抽象。最容易将Promise视为一个简单的对象,该对象最终会包含一个值。Promise提供了一个接口,通过该接口可以传递回调函数,以等待最终完成值或错误。

在任何给定时间,Promise都会具有某种状态:

  • 挂起: Promise正在等待其解析(异步任务尚未完成)。

  • 已解决: Promise不再处于挂起状态,并且已经被完成或拒绝:

    • 已完成: Promise已成功,现在有一个值

    • 已拒绝: Promise已因错误而失败

可以通过Promise构造函数构造Promise,通过传递一个名为executor的函数参数(调用resolvereject函数来指示已解决值或错误)来构造Promise:

const answerToEverything = new Promise((resolve, reject) => {
   setTimeout(() => {
     resolve(42);
   }, 1000);
});

实例化的Promise具有以下方法,以便我们可以访问其更改的状态(当它从挂起转移到完成拒绝):

  • then(onFulfilled[, onRejected]): 这将在Promise上附加一个完成回调,并可选地附加一个拒绝回调。它将返回一个新的Promise对象,该对象将解析为所调用的完成或拒绝处理程序的返回值,或者如果没有处理程序,则将根据原始Promise解析。

  • catch(onRejected): 这将在Promise上附加一个拒绝回调,并将返回一个新的Promise,将解析为回调的返回值或(如果原始Promise成功)其完成值。

  • finally(onFinally): 这将在Promise上附加一个处理程序,当Promise被解决时,无论解决是完成还是拒绝,该处理程序都将被调用。

通过向then方法传递回调函数,我们可以访问answerToEverything最终解决的值:

answerToEverything.then(answer => {
  answer; // => 42
});

通过探索大多数现代浏览器支持的本机 Fetch API,我们可以说明Promise的确切性质:

const promiseOfData = fetch('/some/data?foo=bar');

fetch函数返回一个Promise,我们将其赋给我们的变量promiseOfData。然后我们可以像这样连接到请求的最终成功(或失败):

const promiseOfData = fetch('/some/data');

promiseOfData.then(
  response => {
    response; // The "fulfilled" Response
  },
  error => {
    error; // The "rejected" Error 
  }
);

也许看起来 Promise 只是比回调更啰嗦的抽象。事实上,在最简单的情况下,你可能只需传递一个完成回调和一个拒绝回调。可以说,这并没有比原始回调方法提供更有用的内容。但 Promise 可以是更多。

由于Promise只是一个常规对象,它可以像任何其他值一样在您的程序中传递,这意味着任务的最终解决不再需要与原始任务的调用站点的代码绑定。此外,每个thencatchfinally调用返回自己的Promise,我们可以连接任意数量的依赖某些原始完成的任何同步或异步任务。

例如,在fetch()的情况下,完成的Response对象提供了一个json()方法,该方法本身是异步完成并返回一个Promise。因此,要从给定资源获取实际的 JSON 数据,您需要执行以下操作:

fetch('/data/users')
  .then(response => response.json())
  .then(jsonDataOfUsers => {
    jsonDataOfUsers; // the JSON data that we got from response.json()
  });

链接then调用是一种常用的模式,用于从先前的值派生新值。给定响应,我们希望计算 JSON,而给定 JSON,我们可能希望计算其他内容:

fetch('/data/users')
  .then(response => response.json())
  .then(users => users.map(user => user.forename))
  .then(userForenames => userForenames.sort());

在这里,我们使用多个then调用来计算我们用户的排序 forenames。实际上,这里创建了四个不同的 promise,如下所示:

const promiseA = fetch('/data/users');
const promiseB = promiseA.then(response => response.json());
const promiseC = promiseB.then(users => users.map(user => user.forename))
const promiseD = promiseC.then(userForenames => userForenames.sort());

promiseA === promiseB; // => false
promiseB === promiseC; // => false
promiseC === promiseD; // => false

每个Promise只会解决为一个值。一旦它被完成拒绝,没有其他值可以取而代之。但正如我们在这里所看到的,我们可以通过简单地通过thencatchfinally注册回调来自原始Promise派生一个新的Promise。只解决一次并返回新派生的 promise 的性质意味着我们可以以许多有用的方式组合 promise。在我们的例子中,我们可以从我们的users数据Promise派生两个 promise:一个收集用户的 forenames,另一个收集他们的 surnames:

const users = fetch('/data/users').then(r => r.json());
const forenames = users.then(users => users.map(user => user.forename));
const surnames = users.then(users => users.map(user => user.surname));

然后我们可以自由地传递这些forenamessurnames promises,任何消费代码都可以随意处理它们。例如,当它们最终可用时,我们可能有一个 DOM 元素,我们想要用 forenames 填充它:

function createForenamesComponent(forenamesPromise) {

  const div = document.createElement('div');

  function render(forenames) {
    div.textContent = forenames ? forenames.join(', ') : 'Loading...';
  }

  render(null); // Initial render

  forenamesPromise.then(forenames => {
    // When we receive the forenames we want to render them:
    render(forenames);
  });

  return div; 
}

这个createForenamesComponent函数接受forenamesPromise作为参数,然后返回一个<div>元素。如您所看到的,我们最初用null调用render(),它用"loading..."文本填充 DIV 元素。一旦Promise实现了,我们就会重新渲染,用新填充的 forenames 重新渲染。

以这种方式传递 Promise 的能力使它们比回调更加灵活,并且与实现 Events API 的对象精神相似。然而,通过这些机制,有必要创建和传递函数,以便您能监听未来的事件,然后对其进行操作。如果要表达大量的异步逻辑,这可能是一个真正的挑战。代码中到处充斥着回调、事件和 Promise 的控制流可能不清晰,甚至对于熟悉特定代码库的人也是如此。即使少量独立的异步事件也可以在应用程序中产生大量的状态。程序员可能会变得非常困惑;困惑与什么时候发生什么有关。

你的程序的状态是在运行时确定的。当一个值或数据发生变化,无论多么小,都将被视为状态的改变状态通常以程序输出的形式来表达,例如 GUI 或 CLI 也可以内部保存并在稍后观察的输出中体现。

为了避免混淆,最好尽可能透明地实现与时间相关的代码,以便不会产生误解。以下是一个可能导致误解的代码示例:

userInfoLoader.init();

appStartup().then(() => {
  const userID = userInfoLoader.data.id;
  const userName = userInfoLoader.data.name;
  renderApplication(userID, userName);
});

这段代码似乎假设 appStartup() 返回的 PromiseuserInfoLoader 完成工作后总是会被执行。也许这段代码的作者碰巧知道 appStartup() 逻辑总是在 userInfoLoader 完成之后执行。也许这是一个确定性。但对于我们来说,第一次阅读这段代码,我们无法确信 appStartup() 被执行时 userInfoLoader.data 是否已被填充。最好通过更加透明的方式来控制时机,比如,从 userInfoLoader.init() 返回一个 Promise,然后在该 Promise 明确被执行时执行 appStartup()

userInfoLoader.init()
  .then(() => appStartup())
  .then(() => {
    const userID = userInfoLoader.data.id;
    const userName = userInfoLoader.data.name;
    renderApplication(userID, userName);
  });

在这里,我们安排我们的代码,使得什么动作依赖于什么其他动作,以及动作的执行顺序显而易见。仅仅使用 Promise,就像任何其他异步控制流抽象一样,并不能保证你的代码会易于理解。重要的是要始终考虑你的同行程序员的视角和他们会做出的时间假设。接下来,我们将探讨 JavaScript 的一个新添加,它为我们提供了对异步代码的本地语言支持:你将看到这些添加如何使我们能够编写更清晰的异步代码,以便清楚地说明什么时候发生什么

异步和等待

ECMAScript 2017 规范引入了一种新的概念,用 async 和 await 关键字形式添加到了 JavaScript 语言中。 async 关键字用于指定一个函数是异步的:

async function getNumber() {
  return 42;
}

这样做,实际上将函数返回的内容包装在Promise中(如果它还不是Promise的话)。所以,如果我们尝试调用这个函数,我们将收到Promise

getNumber() instanceof Promise; // => true

正如我们所了解的,我们可以通过使用then方法来订阅Promise的满足:

getNumber().then(number => {
  number; // => 42
});

与返回Promises的异步函数相协作,我们还有一个await关键字。这使我们能够等待Promise的满足(或拒绝),只需将其传递到await的右侧即可。例如,这可能是从async函数调用返回的Promise

await someAsyncFunction();

或者它可能是内联指定的Promise,像这样:

const n = await new Promise(fulfill => fulfill(123));
n; // => 123

正如你所看到的,await关键字将等待它的Promise解决,从而阻止任何后续行动,直到这种情况发生。

以下是另一个例子——一个setupFeed异步函数,它等待fetch()response.json()

async function setupFeed() {
  const response = await fetch('/data');
  const json = await response.json();
  console.log(json);
}

值得注意的是,await关键字不像alert()prompt()一样阻塞。相反,它只是暂停异步函数的执行,释放Event Loop以继续其他工作,然后,当它的Promise解决时,它将在离开的地方继续执行。记住,await只是对我们已经实现的功能的语法。如果我们想要在不使用async/await的情况下实现我们的setupFeed函数,我们可以很容易地通过恢复到将回调传递给Promise#then的旧模式来做到这一点:

function setupFeed() {
  fetch('/data').then(response => {
    return response.json()
  }).then(json => {
    console.log(json);
  });
}

注意,当我们不使用await时,代码略显笨拙和拥挤。与异步函数一起使用await可以给我们提供与常规同步代码一样令人满意的线性和程序化外观。这可以大大简化否则复杂的异步控制流程,使我们的同行程序员更清楚何时发生什么

await关键字也可用于for...of迭代结构内部。这样做将等待每个迭代的值。如果在迭代期间遇到任何Promise值,那么迭代将不会继续,直到Promise被解决为止:

const allData = [
  fetch('/data/1').then(r => r.json()),
  fetch('/data/2').then(r => r.json()),
  fetch('/data/3').then(r => r.json())
];

for await (const data of allData) {
  console.log(data);
}

// Logs data from /data/1, /data/2 and /data/3

没有Promisesawaitasync,表达这种异步过程不仅需要更多的代码,还需要更多的时间来理解。这些构造和抽象的美妙之处在于它们使我们能够忽略异步操作的实现细节,从而使我们能够纯粹地专注于表达我们的问题领域。随着我们在本书中的进展,我们将进一步探索这种抽象精神,因为我们将处理一些更大更棘手的问题领域。

总结

在本章中,我们已经完成了对 JavaScript 语言的探索,讨论了命令式和声明式语法之间的区别,探讨了如何清晰地控制流程,并学习了如何处理同步和异步上下文中的圈复杂度情况。这涉及对语言中所有迭代和条件构造的深入研究,对它们的使用进行指导,并警告反模式。

在下一章中,我们将把我们对 JavaScript 语言积累的所有知识与对真实世界设计模式和范式的探索相结合,这将帮助我们构建清晰的抽象和架构。

第三部分:构建抽象

在这一部分,我们将运用我们对清晰代码和 JavaScript 语言结构的理解,来构建清晰而连贯的 JavaScript 抽象。通过这样做,我们将学习如何使用众所周知的模式设计直观的抽象,如何思考常见的 JavaScript 问题领域,如何处理错误状态,以及如何有效地使用有时笨拙的 DOM API。

本节包括以下章节:

  • 第十一章,设计模式

  • 第十二章,现实世界的挑战

第十一章:设计模式

我们遇到的大多数问题都不是新问题。许多在我们之前的程序员已经解决了类似的问题,并通过他们的努力,各种编程模式已经出现。我们称这些为设计模式。

设计模式是我们的代码所在的有用结构、样式和模板。设计模式可以规定从代码基础的整体脚手架到用于构建表达式、函数和模块的单个语法片段的任何内容。通过构建软件,我们不断地,而且通常是不知不觉地,处于设计的过程中。正是通过这个设计过程,我们正在定义用户和维护者在接触我们代码时将经历的体验。

为了使我们适应设计师而不是程序员的视角,让我们考虑一个简单软件抽象的设计。

在本章中,我们将涵盖以下主题:

  • 设计师的视角

  • 架构设计模式

  • JavaScript 模块

  • 模块化设计模式

  • 规划和和谐

设计师的视角

为了赋予我们设计师的视角,让我们探索一个简单的问题。我们必须构建一个抽象,允许用户给我们两个字符串,一个主题字符串和一个查询字符串。然后我们必须计算主题字符串中查询字符串的计数。

因此,请考虑以下查询字符串:

"the"

并看一下以下主题字符串:

"the fox jumped over the lazy brown dog"

我们应该收到一个结果为2

对于我们作为设计师的目的,我们关心那些必须使用我们代码的人的体验。现在,我们不会担心我们的实现;我们只会考虑接口,因为主要是我们代码的接口将驱动我们的同行程序员的体验。

作为设计师,我们可能会做的第一件事是定义一个带有精心选择的名称和一组特定命名参数的函数:

function countNeedlesInHaystack(needle, haystack) { }

这个函数接受needlehaystack,并将返回Number,表示needlehaystack中的计数。我们代码的使用者会这样使用它:

countNeedlesInHaystack('abc', 'abc abc abc'); // => 3

我们使用了寻找大海捞针的流行习语来描述在另一个字符串中寻找子字符串的问题。考虑流行的习语是设计代码的关键部分,但我们必须警惕习语被误解。

代码的设计应该由我们希望解决的问题领域和我们希望展现的用户体验来定义。另一个程序员在相同的问题领域下可能会选择不同的解决方案。例如,他们可能会使用部分应用来允许以下调用语法:

needleCounter('app')('apple apple'); // => 2

或者他们可能设计了一个更冗长的语法,涉及调用Haystack构造函数并调用其count()方法,如下所示:

new Haystack('apple apple'),count('app'); // => 2

这种经典方法可以说在对象(Haystack)和count方法之间有很好的语义关系。它与我们在之前章节中探讨过的面向对象编程概念很契合。尽管如此,一些程序员可能会觉得它过于冗长。

还有可能是更具描述性的 API,其中参数在配置对象中定义(即作为唯一参数传递的普通对象文字):

countOccurancesOfNeedleInHaystack({
  haystack: 'abc abc abc',
  needle: 'abc'
}); // => 3

还有可能这种计数功能可能是更大的一组与字符串相关的实用程序的一部分,因此可以并入一个更大的自定义命名模块:

str('omg omg omg').count('omg'); // => 3

我们甚至可以考虑修改原生的String.prototype,尽管这是不可取的,以便我们在所有字符串上都有一个count方法可用:

'omg omg omg'.count('omg'); // => 3

在我们的命名约定方面,我们可能希望避免使用大海捞针的习语,而是使用更具描述性的名称,也许有较少误解的风险,如下所示:

  • searchableStringsubString

  • 查询内容

  • searchcorpus

即使在这个非常狭窄的问题域内,我们可选择的选项也是令人难以置信的。您可能对哪种方法和命名约定更优越有很多自己的强烈意见。

我们可以用许多不同的方法解决一个看似简单的问题,这表明我们需要一个决策过程。而这个过程就是软件设计。有效的软件设计利用设计模式来封装问题域,并为同行程序员提供熟悉性和易于理解。

我们探索“大海捞针”问题的意图并不是为了找到解决方案,而是为了突出软件设计的困难,并让我们的思维接触更加以用户为导向的视角。它也提醒我们,很少有一个理想的设计。

任何问题域中,一个精心选择的设计模式可以说具有两个基本特征:

  • 它很好地解决了问题:一个精心选择的设计模式将很好地适应问题域,以便我们可以轻松地表达问题的性质和其解决方案。

  • 它是熟悉和可用的:一个精心选择的设计模式对我们的同行程序员来说是熟悉的。他们会立刻明白如何使用它或对代码进行更改。

设计模式在各种情境和规模下都是有用的。我们在编写单个操作和函数时使用它们,但我们在构建整个代码库结构时也使用它们。设计模式本身是分层的。它们存在于代码库的宏观和微观层面。一个单一的代码库很容易包含许多设计模式。

在第二章中,《清洁代码的原则》,我们谈到熟悉性是一个至关重要的特征。一个汽车技师打开汽车的引擎盖时,希望看到许多熟悉的模式:从独立的电线和组件的焊接到气缸、阀门和活塞的更大的构造。他们期望找到一定的布局,如果没有,他们将不知所措,想知道如何解决他们正在尝试解决的问题。

熟悉性增加了我们解决方案的可维护性和可用性。考虑以下目录结构和显示的logger.js源代码:

我们可以观察到哪些设计模式?让我们看一些例子:

  • 使用顶层app/目录来包含所有源代码

  • 模型、视图和控制器(MVC)的存在

  • 将实用程序分离到自己的目录中(utils/

  • 文件的驼峰命名(例如,binarySearch.js

  • logger.js中使用传统模块模式(即导出一组方法的普通对象)

  • 使用... && msgs.length来确认非零(即真值)长度

  • 在文件顶部声明常量(即const ALL_LOGS_LEVEL

  • (可能还有其他的...)

设计模式不仅仅是庞大而高远的架构结构。它们可以存在于我们代码库的每个部分:目录结构、文件命名和我们代码的个别表达。在每个层面上,我们对常见模式的使用都可以增加我们表达问题域的能力,并增加我们代码对新手的熟悉度。模式存在于模式之中。

良好地使用设计模式可以对我们之前涵盖的清洁代码原则的所有要点产生有益影响——可靠性、效率、可维护性和可用性:

  • 可靠性:一个好的设计模式将适合问题域,并允许您轻松表达所需的逻辑和数据结构,而不会太复杂。您采用的设计模式的熟悉性也将使其他程序员能够轻松理解和随着时间改进代码的可靠性。

  • 效率:一个好的设计模式将使你不必过多地担心如何构建代码库或个别模块。它将使你能够花更多的时间关注问题域。精心选择的设计模式还将有助于使不同代码片段之间的接口变得简洁和易懂。

  • 可维护性:一个好的设计模式可以轻松适应。如果有规范的变化或需要修复的错误,程序员可以轻松找到需要更改/插入的区域,并进行更改而不费力。

  • 可用性:一个好的设计模式易于理解,因为它很熟悉。其他程序员可以轻松理解代码的流程,并快速做出正确的断言,了解它的工作原理以及如何使用它。一个好的设计模式还将为用户创造愉快的体验,无论是通过编程 API 还是 GUI 表达。

你可以看到,设计模式的许多有用之处只有在选择正确的模式时才能实现。我们将探讨一些流行的设计模式,并讨论它们适用的情况。这种探索应该让你对选择一个好的设计模式有一个很好的理解。

请注意:好的设计通过惯例传播,坏的设计也是如此。我们在第三章中讨论了“模仿神教”的现象,因此我们对这些类型的坏设计如何传播并不陌生,但在使用设计模式时保持警惕是很重要的。

架构设计模式

架构设计模式是我们将代码联系在一起的方式。如果我们有十几个不同的模块,那么这些模块如何相互通信就定义了我们的架构。

近年来,JavaScript 代码库中使用的架构设计模式发生了巨大变化。随着 React 和 Angular 等流行框架的不断普及,我们看到代码库采用了新的约定。这个领域仍在不断变化,所以我们不应该指望任何特定的标准很快出现。尽管如此,大多数框架往往遵循相同的广泛的架构模式。

一个流行的架构模式的例子是数据逻辑和渲染逻辑的分离。这被许多不同的 UI 框架所采用,尽管风格不同。这很可能是由于软件 UI 的传统和最终成为事实上的方法的 MVC 模式的传承。

在本节中,我们将介绍两种著名的架构设计模式,MVC 及其分支Model-View-ViewModelMVVM)。这些应该让我们意识到通常分离的关注点,并希望激励我们在创建架构时寻求类似的清晰度。

MVC

MVC 的特点是这三个概念之间的分离。MVC 架构可能涉及许多单独的模型、视图和控制器,它们共同解决一个给定的问题。这些部分可以描述如下:

  • 模型:描述数据以及业务逻辑如何改变数据。数据的变化将导致视图的变化。

  • 视图:描述模型如何呈现(其格式、布局和外观),并在需要发生动作时调用控制器,可能是响应用户事件。

  • 控制器:接受来自视图的指令,并告知模型要执行哪些操作或更改,这将影响通过视图呈现给用户的内容。

我们可以在以下图表中观察控制流:

MVC 模式为我们提供了一种分离各种关注点的方法。它规定了我们应该将业务决策逻辑(即在模型中)放在哪里,以及我们应该将关于向用户显示事物的逻辑(即在视图中)放在哪里。此外,它还给我们提供了控制器,使这两个关注点能够相互交流。MVC 所促进的分离是非常有益的,因为这意味着我们的同行程序员可以很容易地辨别在哪里进行所需的更改或修复。

MVC 最初是由 Trygve Reenskaug 在 1978 年在施乐帕克(Xerox PARC)工作时提出的。它最初的目的是支持用户直接看到和操作域信息的幻觉。当时,这是相当革命性的,但现在,作为最终用户,我们认为这样的用户界面(以及它们与数据的透明关系)是理所当然的。

MVC 的一个工作示例

为了说明 MVC 在 JavaScript 中的实现可能是什么样子,让我们构建一个非常简单的程序。它将是一个基本的可变数字应用程序,可以呈现一个简单的用户界面,用户可以在其中查看当前数字,并选择通过增加或减少其值来更新它。

首先,我们可以使用模型来实现数据的逻辑和包含:

class MutableNumberModel {
  constructor(value) {
    this.value = value;
  }
  increment() {
    this.value++;
    this.onChangeCallback();
  }
  decrement() {
    this.value--;
    this.onChangeCallback();
  }
  registerChangeCallback(onChangeCallback) {
    this.onChangeCallback = onChangeCallback;
  }
}

除了存储值本身,这个类还接受并依赖于一个名为onChangeCallback的回调函数。这个回调函数将由控制器提供,并在值更改时调用。这是必要的,以便我们可以在模型更改时启动视图的重新渲染。

接下来,我们需要构建控制器,它将作为“视图”和“模型”之间非常简单的桥梁(或粘合剂)进行操作。它注册了必要的回调函数,以便知道用户通过“视图”请求更改或“模型”的基础数据发生更改时:

class MutableNumberController {

  constructor(model, view) {

    this.model = model;
    this.view = view;

    this.model.registerChangeCallback(
      () => this.view.renderUpdate()
    );
    this.view.registerIncrementCallback(
      () => this.model.increment()
    );
    this.view.registerDecrementCallback(
      () => this.model.decrement()
    );
  }

}

我们的“视图”负责从“模型”中检索数据并将其呈现给用户。为此,它创建了一个 DOM 层次结构,其中数据将位于其中。它还在“增量”或“减量”按钮被点击时监听并升级用户事件到“控制器”:

class MutableNumberView {

  constructor(model, controller) {
    this.model = model;
    this.controller = controller;
  }

  registerIncrementCallback(onIncrementCallback) {
    this.onIncrementCallback = onIncrementCallback;
  }

  registerDecrementCallback(onDecrementCallback) {
    this.onDecrementCallback = onDecrementCallback;
  }

  renderUpdate() {
    this.numberSpan.textContent = this.model.value;
  }

  renderInitial() {

    this.container = document.createElement('div');
    this.numberSpan = document.createElement('span');
    this.incrementButton = document.createElement('button');
    this.decrementButton = document.createElement('button');

    this.incrementButton.textContent = '+';
    this.decrementButton.textContent = '-';

    this.incrementButton.onclick =
      () => this.onIncrementCallback();
    this.decrementButton.onclick =
      () => this.onDecrementCallback();

    this.container.appendChild(this.numberSpan);
    this.container.appendChild(this.incrementButton);
    this.container.appendChild(this.decrementButton);

    this.renderUpdate();

    return this.container;

  }

}

这是一个相当冗长的视图,因为我们必须手动创建其 DOM 表示。许多现代框架(React、Angular、Svelte 等)允许您使用纯 HTML 或混合语法(例如 JSX,它是 JavaScript 本身的语法扩展,允许在 JavaScript 代码中使用类似 XML 的标记)来声明性地表达您的层次结构。

这个视图有两种渲染方法:renderInitial将进行初始渲染,设置 DOM 元素,然后renderUpdate方法负责在数字更改时更新数字。

将所有这些联系在一起,我们的简单程序将初始化如下:

const model = new MutableNumberModel(5);
const view = new MutableNumberView(model);
const controller = new MutableNumberController(model, view);

document.body.appendChild(view.renderInitial());

“视图”可以访问“模型”,以便可以检索数据进行渲染。“控制器”分别提供给“模型”和“视图”,以便通过设置适当的回调将它们粘合在一起。

在用户点击+(增量)按钮的情况下,将启动以下过程:

  1. “增量按钮”的 DOM 点击事件由视图接收

  2. 视图触发其onIncrementCallback(),由控制器监听

  3. 控制器指示模型increment()

  4. 模型调用其变异回调,即onChangeCallback,由控制器监听

  5. 控制器指示视图重新渲染

也许你会想为什么我们要在控制器和模型之间进行分离。为什么视图不能直接与模型通信,反之亦然?其实可以!但如果我们这样做,我们会在视图模型中都添加更多的逻辑,从而增加更多的复杂性。我们也可以把所有东西都放在视图中,而没有模型,但你可以想象那会变得多么笨重。从根本上讲,分离的程度和数量将随着你追求的每个项目而变化。在本质上,MVC 教会我们如何将问题域与其呈现分离。我们如何使用这种分离取决于我们自己。

自 1978 年 MVC 首次被提出以来,它的许多适配版本已经出现,但其将模型视图分离的核心主题已经持续了几十年。考虑一下 React 应用程序的架构设计。它包括组件,其中包含呈现状态的逻辑,并且通常会包含几个特定领域的 reducer,它们会根据动作(例如用户点击了某些东西!)来推导状态。

这种架构看起来与传统的 MVC 非常相似:

作为一个通用的指导模式,MVC 在过去几十年中影响了无数框架和代码库的设计,并将继续如此。并非每个适配、复制或 MVC 都会遵循 1978 年提出的原始描述,但通常这些适配都会忠于将模型与视图分离以及使视图成为模型的反映(甚至是派生)的核心重要主题。

MVVM

MVVM 的精神与其祖先 MVC 相似。它规定了程序的基础业务逻辑和驱动程序的数据与数据的呈现之间严格的分离:

  • 模型:描述数据以及业务逻辑如何改变数据。数据的变化将体现在视图中。

  • 视图:描述模型的呈现方式(其结构、布局和外观),并且在需要发生动作时会调用视图模型数据绑定机制,可能是响应用户事件。

  • 视图模型:这是模型视图之间的粘合剂,并通过数据绑定机制使它们能够相互通信。这种机制在不同的实现中往往有很大的变化。

这些部分之间的关系如下图所示:

MVVM 架构在前端 JavaScript 中更受欢迎,因为它适应了需要不断更新的视图,而传统的 MVC 在后端更受欢迎,因为它很好地满足了大多数 HTTP 响应只需简单渲染一次的特性。

在 MVVM 中,视图模型视图之间的数据绑定通常使用 DOM 事件来跟踪用户意图,然后在模型上改变数据,模型再发出自己的变化事件,视图模型可以监听这些事件,从而使视图始终保持与数据的变化同步。

许多框架都会有自己的数据绑定适配。例如,Angular 允许您在 HTML 模板中指定一个名为ng-model的自定义属性,它将把用户输入元素(如<input>)与给定的数据模型绑定在一起,从而实现数据的双向流动。如果模型更新了,<input>也会更新以反映这一点,反之亦然。

MV*和软件的性质

在您作为 JavaScript 程序员的时间里,您将遇到 MVC 和 MVVM 的各种变体。作为模式,它们是无限适用的,因为它们关注的是软件系统的非常基本的原则:将数据输入系统,处理该数据,然后输出处理后的数据。我们可以选择将这些原则架构到代码库中的其他几种方式,但最终,几乎每次,我们最终都会得到一个类似 MVC(或 MVVM)的系统,它以类似的精神划分了这些关注点。

现在我们已经对如何构建代码库以及表征良好设计架构的类型有了明确的想法,我们可以探索代码库的各个部分:模块本身。

JavaScript 模块

在 JavaScript 中,模块这个词多年来发生了变化。模块曾经是任何独立且自包含的代码片段。几年前,您可能会在同一个文件中表达几个模块,就像这样:

// main.js

// The Dropdown Module
var dropdown = /* ... definition ... */;

// The Data Fetcher Module
var dataFetcher = /* ... definition ...*/;

然而,如今,module这个词倾向于指的是 ECMAScript 规范所规定的 Modules(大写M)。这些模块是通过importexport语句在代码库中导入和导出的不同文件。使用这样的模块,我们可能有一个DropdownComponent.js文件,看起来像这样:

// DropdownComponent.js
class DropdownComponent {}
export default DropdownComponent;

正如您所看到的,它使用export语句来导出其类。如果我们希望将这个类用作依赖项,我们将这样导入它:

// app.js
import DropdownComponent from './DropdownComponent.js'; 

ECMAScript 模块正在逐渐在各种环境中得到更多支持。要在浏览器中使用它们,可以提供一个类型为moduleentry脚本标签,即<script type="module" />。在 Node.js 中,目前,ES 模块仍然是一个实验性功能,因此您可以依赖旧的导入方式(const thing = require('./thing')),或者可以通过使用--experimental-modules标志并在所有 JavaScript 文件上使用.mjs扩展名来启用实验性模块

importexport语句都允许各种语法。这使您可以定义要导出或导入的名称。在只导出一个项目的情况下,惯例上使用export default [item],就像我们在DropdownComponent.js中所做的那样。这确保了模块的任何依赖项都可以导入它并根据需要命名它,就像这个例子中所示的那样:

import MyLocallyDifferentNameForDropdown from './DropdownComponent.js';

与此相反,您可以通过在花括号内声明它们并使用as关键字来明确命名您的导出项:

export { DropdownComponent as TheDropdown };

这意味着任何导入者都需要明确指定TheDropdown的名称,如下所示:

import { TheDropdown } from './DropdownComponent.js'; 

或者,您可以通过在export语句中具体声明来导出命名项,例如varconstlet、函数声明或类定义:

// things.js
export let x = 1;
export const y = 2;
export var z = 3;
export function myFunction() {}
export class MyClass {}

在导入方面,这些命名导出可以通过再次使用花括号来导入:

import { x, y, z, myFunction, MyClass } from './things.js'; 

在导入时,您还可以选择使用as关键字指定该导入的本地名称,使其本地名称与其导出的名称不同(在命名冲突的情况下,这是特别有用的):

import { MyClass as TheClass } from './things.js';
TheClass; // => The class
MyClass; // ! ReferenceError

在您的代码中,将导出聚合在一起的区域通常是惯例,这些区域提供了几个相关的抽象。例如,如果您已经组合了一个小的组件库,其中每个组件都将自己作为default导出,那么您可以有一个index.js,将所有组件一起公开:

// components/index.js
export {default as DropdownComponent} from './DropdownComponent.js';
export {default as AccordianComponent} from './AccordianComponent.js';
export {default as NavigationComponent} from './NavigationComponent.js';

在 Node.js 中,默认情况下,如果尝试导入整个目录,将导入index.js/index.mjs文件。也就是说,如果您导入'./components/',它首先会查找索引文件,如果可用,会导入它。在浏览器中,目前没有这样的约定。所有导入必须是完全限定的文件名。

我们现在可以通过在import语句中使用星号来非常方便地导入我们的整套组件:

// app.js
import * from 'components/index.js';

// Make use of the imported components:
new DropdownComponent();
new AccordianComponent();
new NavigationComponent();

在 JavaScript 中,模块存在一些额外的细微差别和复杂性,特别是考虑到 Node.js 的遗留问题,不幸的是,我们没有时间深入讨论,但到目前为止,我们所涵盖的内容应该足够让你对这个主题有一个很好的了解,以便提高生产力,并为我们探讨模块化设计模式铺平道路。

模块化设计模式

模块化设计模式是我们用来构建单个模块的结构和语法约定。我们通常会在不同的 JavaScript 模块中使用这些模式。每个不同的文件应该提供并导出一个特定的抽象。

如果您发现自己在同一个文件中多次使用这些模式,那么将它们拆分出来可能是值得的。给定代码库的目录和文件结构应该理想地反映其抽象的景观。您不应该将多个抽象塞进一个文件中。

构造函数模式

构造函数模式使用一个构造函数,然后手动填充其prototype方法和属性。这是在类定义语法存在之前,在 JavaScript 中创建经典面向对象类的传统方法。

通常,它以构造函数的定义作为函数声明开始:

function Book(title) {
  // Initialization Logic
  this.title = title;
}

然后会跟着将单独的方法分配给原型:

Book.prototype.getNumberOfPages = function() { /* ... */ };
Book.prototype.renderFrontCover: function() { /* ... */ };
Book.prototype.renderBackCover: function () { /* ... */ };

或者它将被跟随着用一个对象字面量替换整个prototype

Book.prototype = {
  getNumberOfPages: function() { /* ... */ },
  renderFrontCover: function() { /* ... */ },
  renderBackCover: function () { /* ... */ }
};

后一种方法往往更受青睐,因为它更封装和简洁。当然,现在,如果您希望使用构造函数模式,您可能会选择方法定义,因为它们占用的空间比单独的键值对少:

Book.prototype = {
  getNumberOfPages() { /* ... */ },
  renderFrontCover() { /* ... */ },
  renderBackCover () { /* ... */ }
};

构造函数的实例化将通过new关键字进行:

const myBook = new Book();

这将创建一个具有构造函数的prototype的内部[[Prototype]](也就是我们的对象,其中包含getNumberOfPagesrenderFrontCoverrenderBackCover)。

如果您对构造函数和实例化的原型机制记忆不太清楚,请重新阅读第六章,基本类型和内置类型,特别是名为原型的部分。

何时使用构造函数模式

构造函数模式在您希望有一个封装名词概念的抽象的情况下非常有用,也就是说,一个有意义的实例。例如NavigationComponentStorageDevice。构造函数模式允许您创建类似于传统面向对象编程类的抽象。因此,如果您来自经典的面向对象编程语言,那么您可以随意使用构造函数模式,以前可能使用类。

如果您不确定构造函数模式是否适用,请考虑以下问题是否属实:

  • 概念可以表达为名词吗?

  • 概念需要构造吗?

  • 概念在实例之间会有变化吗?

如果你抽象出的概念不满足以上任何标准,那么你可能需要考虑另一种模块化设计模式。一个例子是一个具有各种辅助方法的实用程序模块。这样的模块可能不需要构造,因为它本质上是一组方法,这些方法及其行为在实例之间不会有变化。

自从 JavaScript 引入类定义以来,构造函数模式已经大多不受青睐,类定义允许您以更类似于经典面向对象编程语言的方式声明类(即class X extends Y {...})。跳转到类模式部分,看看这个模式是如何运作的!

构造函数模式中的继承

要使用构造函数模式实现继承,您需要手动使您的prototype对象从父构造函数的prototype继承。

冒着过于简化的风险,我们将以Animal超类和Monkey子类的经典示例来说明这一点。这是我们对Animal的定义:

function Animal() {}
Animal.prototype = {
  isAnimal: true,
  grow() {}
};

从技术上讲,为了实现继承,我们希望创建一个具有Animal.prototype原型的[[Prototype]]的对象,然后将这个新创建的对象用作我们的子类prototype子类。最终目标是一个看起来像这样的原型树:

Object.prototype
 └── Animal.prototype
      └── Monkey.prototype

使用Object.create(ThePrototype)是创建具有给定[[Prototype]]的对象的最简单方法。在这里,我们可以使用它来扩展Animal.prototype并将结果分配给Monkey.prototype

function Monkey() {}
Monkey.prototype = Object.create(Animal.prototype);

然后我们可以自由地将方法和属性分配给这个新对象:

Monkey.prototype.isMonkey = true;
Monkey.prototype.screech = function() {};

如果我们现在尝试实例化Monkey,那么我们应该能够访问不仅其自己的方法和属性,还有我们从Animal.prototype继承的方法和属性:

new Monkey().isAnimal; // => true
new Monkey().isMonkey; // => true
typeof new Monkey().grow; // => "function"
typeof new Monkey().screech; // => "function"

记住,这只能工作是因为Monkey.prototype(也就是每个Monkey实例的[[Prototype]])本身具有Animal.prototype[[Prototype]]。而且,正如我们所知,如果在给定对象上找不到属性,那么它将在其[[Prototype]]上寻找(如果可用)。

一次设置一个原型的属性和方法可能会非常麻烦,就像在这个例子中所示的那样:

Monkey.prototype.method1 = ...;
Monkey.prototype.method2 = ...;
Monkey.prototype.method3 = ...;
Monkey.prototype.method4 = ...;

由于这一点,另一种模式已经出现,使事情变得更容易:使用Object.assign()。这允许我们以对象文字的形式批量设置属性和方法,并且意味着我们也可以利用方法定义语法:

function Monkey() {}
Monkey.prototype = Object.assign(Object.create(Animal.prototype), {
  isMonkey: true, 
  screech() {},
  groom() {}
});

Object.assign将会将其第二个(第三个、第四个等等)参数中的任何属性分配给作为第一个参数传递的对象。这为我们提供了一种更简洁的语法,用于向我们的子prototype对象添加属性。

由于较新的类定义语法,构造函数模式及其继承约定已经大大失宠,它允许以更简洁和简单的方式在 JavaScript 中利用原型继承。因此,我们将要探索的下一个内容就是类模式,它使用了这种较新的语法。

提醒:要更彻底地了解[[Prototype]](这对于理解 JavaScript 中的构造函数和类至关重要),您应该重新访问第六章中关于原型的部分,基本类型和内置类型。本章中的许多设计模式都使用了原型机制,因此将其牢记在心是很有用的。

类模式

类模式依赖于较新的类定义语法,已经在很大程度上取代了构造函数模式。它涉及创建类,类似于经典的面向对象编程语言,尽管在幕后它使用了与构造函数模式相同的原型机制。因此,可以说这只是一点额外的语法,使语言更加表达。

这是一个抽象名称概念的基本类的示例:

class Name {
  constructor(forename, surname) {
    this.forename = forename;
    this.surname = surname;
  }
  sayHello() {
   return `My name is ${this.forename} ${this.surname}`;
  }
}

通过这种语法创建类实际上是创建一个带有附加原型的构造函数,因此以下代码是完全等效的:

function Name(forename, surname) {
  this.forename = forename;
  this.surname = surname;
}

Name.prototype.sayHello = function() {
  return `My name is ${this.forename} ${this.surname}`;
};

使用类模式在美学上确实比笨重且陈旧的构造函数模式更可取,但不要被误导!在幕后,完全相同的机制正在发挥作用。

何时使用类模式

类模式,就像构造函数模式一样,在满足以下标准的自包含概念时非常有用:

  • 这个概念可以表示为一个名词

  • 这个概念需要构建

  • 这个概念将在其自身的实例之间变化

以下是一些符合这些标准并因此可以通过类模式表达的概念的示例:

  • 数据库记录(表示一条数据并允许查询和操作)

  • 待办事项组件(表示待办事项并允许其被渲染)

  • 二叉树(表示二叉树数据结构)

这样的情况通常会很明显地显露出来。如果你遇到麻烦,请考虑你的抽象的用例,并尝试编写一些消费者代码,也就是使用你的抽象的伪代码。如果它看起来合理,使用起来不会太尴尬,那么你可能已经找到了一个好的模式。

静态方法

静态方法和属性可以通过使用static关键字声明:

class Accounts {
  static allAccounts = [];
  static tallyAllAccounts() {
    // ...
  }
}

Accounts.tallyAllAccounts();
Accounts.allAccounts; // => []

这些属性和方法也可以在初始类定义之后轻松添加:

Accounts.countAccounts = () => {
  return Accounts.allAccounts.length;
};

静态方法在整个类的语义上与单个实例不同,因此在这种情况下非常有用。

公共和私有字段

要在实例上声明公共字段(即属性),您可以在类定义语法中简单地声明它:

class Rectangle {
  width = 100;
  height = 100;
}

这些字段为每个实例初始化,并且因此在实例本身上是可变的。当您需要为给定属性定义一些合理的默认值时,它们最有用。然后可以在构造函数中轻松地覆盖它们。

class Rectangle {
  width = 100;
  height = 100;

  constructor(width, height) {
    if (width && !isNaN(width)) {
      this.width = width;
    }
    if (height && !isNaN(height)) {
      this.height = height;
    }
  }
}

您还可以通过在其标识符前加上#符号来定义私有字段:

class Rectangle {
  #width = 100;
  #height = 100;

  constructor(width, height) {
    if (width && !isNaN(width)) {
      this.#width = width;
    }
    if (height && !isNaN(height)) {
      this.#height = height;
    }
  }
}

传统上,JavaScript 没有私有字段的概念,因此程序员选择用一个或多个下划线作为前缀来表示私有属性(例如,__somePropertyName)。这被理解为一种社会契约,其他程序员不会干扰这些属性(知道这样做可能会以意想不到的方式破坏事情)。

私有字段只能被类本身访问。子类无法访问:

class Super { #private = 123; }
class Sub { getPrivate() { return this.#private; } }

// !SyntaxError: Undefined private field #private:
// must be declared in an enclosing class

私有字段应该极度谨慎地使用,因为它们可能严重限制代码的可扩展性,从而增加其刚性和缺乏灵活性。如果您使用私有字段,您应该确保已经考虑了后果。也许你需要的是一个伪私有字段,实际上只是一个带有下划线前缀的字段(例如,_private)或另一个不常见的标点符号(例如,$_private)。这样做将会按照惯例确保使用您接口的其他程序员(希望)理解他们不应该公开使用该字段。如果他们这样做,那么暗示着他们可能会破坏事情。如果他们希望用自己的实现扩展您的类,那么他们可以自由地使用您的私有字段。

扩展类

类模式内的继承可以通过使用class ... extends语法来非常简单地实现:

class Animal {}
class Tiger extends Animal {}

这将确保Tiger的任何实例都将具有[[Prototype]],它本身具有Animal.prototype[[Prototype]]

Object.getPrototypeOf(new Tiger()) === Tiger.prototype;
Object.getPrototypeOf(Tiger.prototype) === Animal.prototype;

在这里,我们已经确认Tiger的每个新实例都有Tiger.prototype[[Prototype]],并且Tiger.prototype继承自Animal.prototype

混合类

传统上,扩展不仅用于创建语义子类,还用于提供方法的混入。JavaScript 没有提供原生的混入机制,因此为了实现它,您需要在定义之后增加原型,或者有效地从您的混入中继承(就好像它们是超类一样)。

通过将混入作为对象指定,然后通过方便的方法(例如Object.assign)将它们添加到类的prototype中,我们可以最简单地使用prototype来扩充我们的混入。

const fooMixin = { foo() {} };
const bazMixin = { baz() {} };

class MyClass {}
Object.assign(MyClass.prototype, fooMixin, bazMixin);

然而,这种方法不允许MyClass覆盖自己的混合方法:

// Specify MyClass with its own foo() method:
class MyClass { foo() {} }

// Apply Mixins:
Object.assign(MyClass.prototype, fooMixin, bazMixin);

// Observe that the mixins have overwritten MyClass's foo():
new MyClass().foo === fooMixin.foo; // true (not ideal)

这是预期的行为,但在某些情况下会给我们带来麻烦。因此,为了实现更通用的混合方法,我们可以探索不同的机制。我们可以使用继承来实现这一点,这最容易通过所谓的子类工厂来实现。这些本质上只是返回一个扩展指定超类的类的函数:

const fooSubclassFactory = SuperClass => {
 return class extends SuperClass {
   fooMethod1() {}
   fooMethod2() {}
 };
};

这是它在现实中可能的工作方式的一个例子:

const greetingsMixin = Super => class extends Super {
  hello() { return 'hello'; }
  hi() { return 'hi'; }
  heya() { return 'heya'; }
};

class Human {}
class Programmer extends greetingsMixin(Human) {}

new Programmer().hi(); // => "hi"

我们还可以实现一个辅助程序来组合任意数量的这些子类工厂。它可以通过构建与我们提供的mixins列表一样长的[[Prototype]]链接的链(或树)来实现:

function mixin(...mixins) {
  return mixins.reduce((base, mixin) => {
    return mixin(base);
  }, Object);
}

请注意我们的 mixin 减少的默认base类是Object。这是为了确保Object始终位于我们的继承树的顶部(并且我们不会创建无意义的中间类)。

以下是我们如何使用我们的mixin助手:首先,我们将定义我们的子类工厂(即实际的 mixin):

const alpha = Super => class extends Super { alphaMethod() {} };
const bravo = Super => class extends Super { braveMethod() {} };

然后,我们可以通过mixin助手使用这些 mixin 构造一个类定义:

class MyClass extends mixin(alpha, bravo) {
  myMethod() {}
};

这意味着结果的MyClass实例将可以访问其自己的原型(包含myMethod),alpha 的原型(包含alphaMethod)和 bravo 的原型(包含bravoMethod):

typeof new MyClass().myMethod;    // => "function"
typeof new MyClass().alphaMethod; // => "function"
typeof new MyClass().braveMethod; // => "function"

混合可能很难搞对,因此最好利用一个库或经过验证的代码来为您处理这些。您应该使用的 mixin 机制可能取决于您所寻求的确切特征。在本节中,我们看到了两个例子:一个是通过Object.assign()将方法组合成一个单一的[[Prototype]],另一个是创建一个继承树(即[[Prototypes]]链)来表示我们的 mixin 层次结构。希望您现在能更好地选择这些(或者在线提供的所有其他)中的哪一个最适合您的需求。

访问超类

使用方法定义语法定义的类中的所有函数都可以使用super绑定,它提供对超类及其属性的访问。super()函数可直接调用(将调用超类的构造函数),并且可以提供对特定方法的访问(super.methodName())。

如果您正在扩展另一个类并且正在定义自己的构造函数,则必须调用super(),并且必须在构造函数内修改实例(即this)的任何其他代码之前这样做:

class Tiger extends Animal {
  constructor() {
    super(); // I.e. Call Animal's constructor
  }
}

如果您的构造函数在修改实例后尝试调用super(),或者尝试避免调用super(),那么您将收到ReferenceError

class Tiger extends Animal {
  constructor() {
    this.someProperty = 123;
    super(); 
  }
}

new Tiger();
// ! ReferenceError: You must call the super constructor in a derived class
// before accessing 'this' or returning from the derived constructor

有关super绑定及其特殊性的详细描述,请参阅第六章,基本类型和内置类型(请参阅函数绑定部分)。

原型模式

原型模式涉及使用普通对象作为其他对象的模板。原型模式直接扩展此模板对象,而不需要通过newConstructor.prototype对象进行实例化。您可以将其视为类似于传统构造函数或类模式,但没有构造函数。

通常,您将首先创建一个对象作为您的模板。这将具有与您的抽象相关的所有方法和属性。对于inputComponent抽象,它可能如下所示:

const inputComponent = {
  name: 'Input Component',
  render() {
    return document.createElement('input');
  }
};

请注意inputComponent以小写字符开头。按照惯例,只有构造函数应以大写字母开头命名。

使用我们的inputComponent模板,我们可以使用Object.create创建(或实例化)特定的实例:

const inputA = Object.create(inputComponent);
const inputB = Object.create(inputComponent);

正如我们所学的,Object.create(thePrototype)简单地创建一个新对象,并将其内部的[[Prototype]]属性设置为thePrototype,这意味着在新对象上访问的任何属性,如果在对象本身上不可用,将在thePrototype上查找。因此,我们可以像在更传统的构造函数或类模式产生的实例上一样对待生成的对象,访问属性:

inputA.render();

为了方便起见,我们还可以在inputComponent本身上引入一个设计用于执行对象创建工作的方法:

inputComponent.extend = function() {
  return Object.create(this);
};

这意味着我们可以用稍微少一些的代码创建单独的实例:

const inputA = inputComponent.extend();
const inputB = inputComponent.extend();

如果我们希望创建其他类型的输入,那么我们可以像我们已经做的那样轻松扩展inputComponent,向生成的对象添加一些方法,然后将新对象提供给其他人扩展:

const numericalInputComponent = Object.assign(inputComponent.extend(), {
  render() {
    const input = InputComponent.render.call(this);
    input.type = 'number';
    return input;
  }
});

要覆盖特定方法并访问其父级,如你所见,我们需要直接引用并调用它(InputComponent.render.call())。你可能期望我们应该能够使用super.render(),但不幸的是,super只是指包含方法所定义的对象(主体)的[[Prototype]]。因为Object.assign()实际上是从它们的主体对象中窃取这些方法,super将指向错误的东西。

原型模式的命名相当令人困惑。正如我们所见,传统的构造函数模式和较新的类模式都涉及原型,因此你可能希望将这种模式称为对象扩展模式,甚至是无构造函数的原型继承方法。无论你决定如何称呼,这都是一种相当罕见的模式。通常更受青睐的是经典的 OOP 模式。

何时使用原型模式

原型模式在具有实例之间变化特征的抽象情景中最有用(或扩展),但不需要构造。在其核心,原型模式实际上只是指扩展机制(即通过Object.create),因此它同样可以用于任何情景,其中对象可能通过继承在语义上与其他对象相关。

想象一种需要表示三明治数据的情景。每个三明治都有一个名称,一个面包类型和三个成分槽。例如,这是 BLT 的表示:

const theBLT = {
  name: 'The BLT',
  breadType: 'Granary',
  slotA: 'Bacon',
  slotB: 'Lettuce',
  slotC: 'Tomato'
};

我们可能希望创建 BLT 的一个适应版本,重用其大部分特征,除了Tomato成分,它将被替换为Avocado。我们可以简单地克隆整个对象,通过使用Object.assigntheBLT复制所有属性到一个新对象,然后专门复制(即覆盖)slotC

const theBLA = Object.assign({}, theBLT, {
  slotC: 'Avocado'
});

但是如果 BLT 的breadType被更改了呢?让我们来看一下:

theBLT.breadType = 'Sourdough';
theBLA.breadType; // => 'Granary'

现在,theBLAtheBLT不同步。我们已经意识到,这里实际上需要的是一个继承模型,以便theBLAbreadType始终与其父三明治的breadType匹配。为了实现这一点,我们可以简单地改变我们创建theBLA的方式,使其继承自theBLT(使用原型模式):

const theBLA = Object.assign(Object.create(theBLT), {
  slotC: 'Avocado'
});

如果我们稍后更改theBLT的特征,它将有助于通过继承在theBLA中得到反映:

theBLT.breadType = 'Sourdough';
theBLA.breadType; // => 'Sourdough'

可以看到,这种无构造函数的继承模型在某些情况下是有用的。我们可以同样使用直接的类来表示这些数据,但是对于如此基本的数据来说可能有些过度。原型模式有用之处在于它提供了一个简单明确的继承机制,可以导致更少臃肿的代码(尽管同样地,如果错误应用,也可能导致更多的复杂性)。

揭示模块模式

揭示模块模式是一种用于封装一些私有逻辑然后公开公共 API 的模式。这种模式有一些改编,但通常是通过立即调用的函数表达式IIFE)来表达的,它返回一个包含公共方法和属性的对象字面量:

const myModule = (() => {
  const privateFoo = 1;
  const privateBaz = 2;

  // (Private Initialization Logic goes here)

  return {
    publicFoo() {},
    publicBaz() {}
  };
})();

IIFE 返回的任何函数将形成一个封闭环绕其各自作用域的闭包,这意味着它们将继续访问私有作用域。

一个真实世界的揭示模块的例子是这个包含渲染通知给用户逻辑的简单 DOM 组件:

const notification = (() => {

  const d = document;
  const container = d.body.appendChild(d.createElement('div'));
  const message = container.appendChild(d.createElement('p'));
  const dismissBtn = container.appendChild(d.createElement('button'));

  container.className = 'notification';

  dismissBtn.textContent = 'Dismiss!';
  dismissBtn.onclick = () => {
    container.style.display = 'none';
  };

  return {
    display(msg) {
      message.textContent = msg;
      container.style.display = 'block';
    }
  };
})();

外部作用域中的通知变量将引用 IIFE 返回的对象,这意味着我们可以访问它的公共 API:

notification.display('Hello user! Something happened!');

揭示模块模式在需要区分私有和公共部分、具有特定初始化逻辑以及由于某种原因,你的抽象不适合更面向对象的模式(类或构造函数模式)的场景中特别有用。

在类定义和#private字段存在之前,揭示模块模式是模拟真正私有性的唯一简单方法。因此,它已经有些过时。一些程序员仍然使用它,但通常只是出于审美偏好。

传统模块模式

传统模块模式通常表示为一个普通的对象文字,带有一组方法:

const timeDiffUtility = {
  minutesBetween(dateA, dateB) {},
  hoursBetween(dateA, dataB) {},
  daysBetween(dateA, dateB) {}
};

这样的模块通常还会公开特定的初始化方法,例如initializeinitsetup。或者,我们可能希望提供改变整个模块状态或配置的方法(例如setConfig):

const timeDiffUtility = {
  setConfig(config) {
    this.config = config;
  },
  minutesBetween(dateA, dateB) {},
  hoursBetween(dateA, dataB) {},
  daysBetween(dateA, dateB) {}
};

传统模块模式非常灵活,因为它只是一个普通对象。JavaScript 将函数视为一等公民(也就是说,它们就像任何其他值一样),这意味着您可以轻松地从其他地方定义的函数组合方法的对象,例如:

const log = () => console.log(this);

const library = {
  books: [],
  addBook() {},
  log // add log method
};

传统上,您可能考虑使用继承或混合模式将log方法包含在我们的库模块中,但在这里,我们只是通过引用并直接插入到我们的对象中来组合它。这种模式在 JavaScript 中为我们提供了很大的灵活性,可以灵活地重用代码。

何时使用传统模块模式

传统模块模式在任何您希望将一组相关方法或属性封装成具有共同名称的东西的情况下都很有用。它们通常用于与彼此相关的常见方法集合,例如日志记录实用程序:

const logger = {
  log(message) { /* ... */ },
  warn(message) { /* ... */ },
  error(message) { /* ... */ }
};

传统模块模式只是一个对象,因此可能根本不需要提及它。但从技术上讲,它是抽象定义的其他技术的替代方案,因此将其指定为一种模式是有用的。

单例类模式

类模式已经迅速成为创建各种类型的抽象的事实标准模式,包括单例和实用对象,因此您的类可能并不总是需要作为传统的面向对象类进行使用,包括继承和实例化。例如,我们可能希望使用类定义来设置一个实用对象,以便我们可以在构造函数中定义任何初始化逻辑,并在其方法中提供封装的外观:

const utils = new class {
  constructor() {
    this.#privateThing = 123;
    // Other initialization logic here...
  }
  utilityA() {}
  utilityB() {}
  utilityC() {}
};

utils.utilityA(); 

在这里,我们创建并立即实例化一个类。这在精神上类似于揭示模块模式,我们利用 IIFE 来封装初始化逻辑和公共 API。在这里,我们不是通过作用域(以及围绕私有变量的闭包)来实现封装,而是使用直接的构造函数来定义初始化。然后,我们使用常规实例属性和方法来定义私有变量和公共接口。

何时使用单例类模式

单例在需要一个类的唯一实例时非常有用。产生的单一实例在性质上类似于传统或揭示模块模式。它使您能够用私有变量和隐式构造逻辑封装一个抽象。单例的常见用例包括实用程序日志记录缓存全局事件总线等。

规划和和谐

决定使用哪些架构和模块化设计模式可能是一个棘手的过程,因为通常在决定的时候,项目的所有要求可能并不立即显而易见。此外,我们作为程序员并不是全知全能的。我们是有缺陷的、自我中心的,通常是充满激情的个体。如果不加以控制,这种组合可能会产生混乱的代码库,其中的设计会阻碍我们试图培养的生产力、可靠性和可维护性。要警惕这些陷阱,记住以下内容:

  • 期待变化和适应:每个软件项目都会在某个时候涉及变化。如果我们在架构和模块化设计上有远见,那么我们将能够限制未来的痛苦,但永远不要开始一个项目,认为你会创造“唯一真正的解决方案”。相反,迭代,质疑自己的判断,然后再次迭代。

  • 与其他程序员协商:与将使用您的代码的利益相关者交谈。这可能是您团队中的其他程序员,或者将使用您提供的接口的其他程序员。听取意见和数据,然后做出明智的决定。

  • 避免模仿和自我:要注意模仿和自我,要小心,如果我们不小心,我们可能会盲目地继承做事情的方式,而不是关键地考虑它们的适用性,或者我们可能会被自己的自我困住:认为某种特定的设计或方法论是完美的,只是因为这是我们个人所知道和喜爱的。

  • 偏向和谐与一致性:在设计架构时,最重要的是寻求和谐。代码库中总是可能有许多个性化定制的部分,但太多的内部差异会让维护者困惑,并导致代码库的分裂质量和可靠性。

总结

在本章中,我们探讨了 JavaScript 中设计模式的目的和应用。这涵盖了对什么是设计模式的基础性反思,以及对一些常见的模块化和架构设计模式的探索。我们已经探讨了使用 JavaScript 的原生机制(如类和原型)以及一些更新颖的机制(如揭示模块模式)声明抽象的各种方式。我们对这些模式的深入覆盖将确保在未来,我们在构建抽象时有充分的选择。

在下一章中,我们将探讨 JavaScript 程序员遇到的现实挑战,如状态管理和网络通信,并将我们对新视角的知识应用到其中。

第十二章:现实世界的挑战

JavaScript 程序员面临的许多挑战可能不在于语言本身,而在于他们的代码必须存在并与之交互的生态系统。JavaScript 通常用于 Web 的上下文中,无论是在浏览器还是服务器上,因此遇到的问题领域通常以 HTTP 和 DOM 等主题为特征。我们经常不得不应对有时似乎笨拙、不直观和复杂的框架、API 和机制。在本章中,我们将熟悉一些最常见的挑战以及我们可以使用的方法和抽象来克服它们。

我们将首先探讨 DOM 和在 JavaScript 中构建雄心勃勃的单页应用程序(SPA)时固有的挑战。然后,我们将探讨依赖管理和安全性等主题,因为这些在当今的环境中变得越来越重要。本章并不旨在详尽覆盖所有主题,而是快速深入探讨,您可能会发现这些对于在当今的 Web 平台上编写干净的 JavaScript 的重要任务是相关的。

在本章中,我们将涵盖以下主题:

  • DOM 和单页应用程序

  • 依赖管理

  • 安全性(XSS、CSRF 等)

DOM 和单页应用程序

文档对象模型(DOM)API 是浏览器提供的,允许开发人员读取和动态改变 Web 文档。在 1997 年首次引入时,它的范围非常有限,但在过去的 20 年里已经大大扩展,使我们现在可以以编程方式访问各种浏览器功能。

DOM 本身向我们展示了从给定页面的解析 HTML 中派生出的元素的层次结构。通过 API,JavaScript 可以访问这个层次结构。这个 API 允许我们选择元素,遍历元素树,并检查元素的属性和特征。以下是一个 DOM 树的示例,以及用于访问它的相应 JavaScript:

多年来,我们访问特定 DOM 节点的方式已经发生了变化,但其基本的树状结构仍然保持不变。通过访问这个结构,我们可以从元素中读取、改变它们,或者确实向元素树中添加内容。

除了 DOM API 之外,还有一系列其他由浏览器原生提供的 API,使得可以进行诸如读取 cookie、改变本地存储、设置后台任务(workers)以及操作 CSS 对象模型(CSSOM)等操作。

就在 2012 年,Web 开发人员通常只使用 JavaScript 来增强已经在页面标记中体现的体验。例如,他们可能只是为按钮添加了一个鼠标悬停状态,或者为表单字段添加了验证。这些增强被认为是一种渐进增强的类型,用户可以选择在没有 JavaScript 的情况下体验网站,但启用 JavaScript 会在某种程度上增强他们的体验。

渐进增强是一个主张功能对环境约束具有弹性的原则。它告诉我们,我们应该尽量为所有用户提供尽可能多的功能,以适应他们的环境。它通常与优雅降级的概念相配对,即软件在其依赖未满足或部分满足时仍能保持有限的功能(例如,即使在没有 JavaScript 支持的浏览器上,客户端验证的

也可以提交,这被称为优雅降级)。

然而,如今,前端部分几乎完全由 JavaScript 构建,并在单个页面中表达。这些通常被称为 SPA。与让用户自然地在网站上导航,每次操作都在浏览器中加载新页面不同,SPA 将重写当前页面的内容和当前浏览器地址。因此,SPA 依赖于用户的浏览器支持 JavaScript,可能还有其他 API。SPA 通常不会优雅地退化,尽管最佳做法(和明智之举!)是提供一系列备用方案,以便整个受众都能接收功能。

SPA 的普及可以归因于开发者体验和用户体验的提升:

  • 架构(DX):前端客户端和后端 API 层之间有更好的关注点分离。这可以导致更清晰的架构,有助于将业务逻辑与 UI 区分开来。拥有一个统一的代码库来管理渲染和动态增强可以大大简化事情。

  • 状态持久性(UX):用户可以在 Web 应用程序中导航和执行操作,而无需丢失页面状态,例如填充的输入字段或滚动位置。此外,UX 可以包括多个不同的窗格、模态框或部分,这些部分可以独立填充并且可以在采取其他操作时保持不变。

  • 性能(UX):大部分 HTTP 资源可以在用户的浏览器中加载一次,提高应用程序内进一步操作或导航的性能。也就是说,在应用程序初始加载后,任何进一步的请求都可以优化为简单的 JSON REST 响应,没有不必要的样板标记,因此浏览器花费更少的时间重新解析或重新渲染样板 HTML、CSS 和 JavaScript。

对 Web 应用程序的不断增长的需求和 SPA 的普及意味着程序员更多地依赖浏览器 API,特别是 DOM,来创建丰富和动态的体验。然而,痛苦的事实是,DOM 从未打算满足创建丰富类似桌面的体验。因此,在使 DOM 符合当前需求方面存在许多成长的痛苦。此外,还存在一个缓慢和迭代的过程,即创建能够在最初未设计用于它们的平台上实现丰富体验的框架。

当尝试将 DOM 绑定到数据时,DOM(以及浏览器 API 一般)无法满足 SPA 当前的需求,这是最明显的方式。我们现在将更深入地探讨这个话题。

DOM 绑定和协调

多个框架多年来尝试解决的一个具体挑战是将 DOM 与数据绑定起来。我们将在接下来更深入地探讨这个话题。

通过 DOM,我们可以动态创建特定元素并根据需要放置它们。然后用户可以通过与这些元素进行交互来对应用程序施加他们的意图,通常是通过输入字段和按钮。这些用户操作,我们通过 DOM 事件进行绑定,然后可能会影响底层数据的变化。这种变化需要在 DOM 中反映出来。这种来回通常被称为双向绑定。从历史上看,为了实现这一点,我们通常会手动创建 DOM 树,在元素上设置事件监听器,然后在任何底层数据(或状态)发生变化时手动改变这些 DOM 元素。

提醒状态是程序的当前情况:用户看到的一切以及支持他们所看到的一切。给定应用程序的状态可以在多个位置表示,并且这些表示可能变得不同步。我们可以想象这样一种情况,即相同的数据显示在两个地方,但不一致。

我们手动操作 DOM 的挑战在于,没有某种抽象,它就无法很好地扩展。很容易从数据中获取 DOM 树,但是将 DOM 树与数据内部的更改联系起来,并且将数据与用户导出的 DOM 更改联系起来(例如,单击按钮)是相当繁琐的事情。

DOM 协调

为了说明这一挑战,考虑一个简单的购物清单,由字符串组成的数组形式:

const shoppingList = ['Bananas', 'Apples', 'Chocolate'];

从这些数据中派生 DOM 树非常简单:

const ul = document.createElement('ul');
shoppingList.forEach(item => {
  const li = ul.appendChild(document.createElement('li'));
  li.textContent = item;
});
document.body.appendChild(ul);

这段代码将生成以下 DOM 树(并将其附加到<body>):

<ul>
  <li>Bananas</li>
  <li>Apples</li>
  <li>Chocolate</li>
</ul>

但是如果我们的数据发生了变化会发生什么呢?如果有<input>,用户可以通过它添加新项目会发生什么?为了适应这些情况,我们需要实现一个抽象来保存我们的数据,并在数据更改时引发事件(或调用回调)。此外,我们需要一种将每个单独的数据项与 DOM 节点联系起来的方法。如果第一项“香蕉”更改为“甜瓜”,那么我们应该只对 DOM 进行最小的变化以反映这种变化。在这种情况下,我们希望替换第一个<li>元素的内部文本节点的data属性(换句话说,文本节点中包含的实际文本):

shoppingList[0] = 'Melons';
ul.children[0].firstChild.data = shoppingList[0];

在抽象术语中,这种类型的更改被称为DOM 协调,涉及反映在 DOM 中对数据所做的任何更改。协调主要有三种类型:

  • 更新:如果更新现有的数据项,则应更新相应的 DOM 节点以反映更改

  • 删除:如果删除现有的数据项,则相应的 DOM 节点也应该被删除

  • 创建:如果添加了新的数据项,则应创建一个新的 DOM 节点,将其附加到实时 DOM 树的正确位置,然后将其链接为该数据项的相应 DOM 节点

DOM 协调是一个相对简单的过程。我们可以很容易地自己创建ShoppingListComponent,并具有更新/添加/删除项目的功能,但它将与数据和 DOM 的结构高度耦合。仅涉及单个更新的逻辑可能涉及,正如我们所见,特定文本节点内容的具体变化。如果我们想稍微更改 DOM 树或数据结构,那么我们必须显着重构我们的ShoppingListComponent

React 的方法

许多现代库和框架试图通过在声明性接口的背后抽象 DOM 协调过程来减轻这一过程的负担。React 就是一个很好的例子,它允许您使用其 JSX 语法在 JavaScript 中声明 DOM 树。JSX 看起来像常规的 HTML,其中添加了插值分隔符({...}),可以在其中编写常规 JavaScript 来表示数据。

在这里,我们正在创建一个组件,它生成一个简单的带有大写name<h1>问候语:

function LoudGreeting({ name }) {
  return <h1>HELLO { name.toUpperCase() } </h1>;
}

LoudGreeting组件可以这样渲染到<body>中:

ReactDOM.render(
  <LoudGreeting name="Samantha" />,
  document.body
);

然后会得到以下结果:

<body>
  <h1>HELLO SAMANTHA</h1>
</body>

我们可以以以下方式实现ShoppingList组件:

function ShoppingList({items}) {
  return (
    <ul>
    {
      items.map((item, index) => {
        return <li key={index}>{item}</li>
      })
    }
    </ul>
  );
}

然后我们可以以以下方式渲染它,通过在组件的调用中传递我们特定的购物清单项目:

ReactDOM.render(
  <ShoppingList items={["Bananas", "Apples", "Chocolate"]} />,
  document.body
);

这只是一个简单的例子,但它让我们了解了 React 的工作原理。React 真正的魔力在于它有能力根据数据的更改有选择性地重新渲染 DOM。我们可以通过在用户操作时更改数据来探索这一点。

React 和大多数其他框架为我们提供了一个直接的事件监听机制,这样我们就可以以与传统方式相同的方式监听用户事件。通过 React 的 JSX,我们可以做到以下几点:

<button
  onClick={() => {
    console.log('I am clicked!')
  }}
>Click me!</button>

在我们的购物清单问题领域中,我们想要创建一个<input />,它可以接收用户的新项目。为了实现这一点,我们可以创建一个名为ShoppingListAdder的单独组件:

function ShoppingListAdder({ onAdd }) {
  const inputRef = React.useRef();
  return (
    <form onSubmit={e => {
      e.preventDefault();
      onAdd(inputRef.current.value);
      inputRef.current.value = '';
    }}>
      <input ref={inputRef} />
      <button>Add</button>
    </form>
  );
}

在这里,我们使用了一个 React Hook(称为useRef)来给我们一个持久的引用,我们可以在组件渲染之间重复使用来引用我们的<input />

React Hooks(通常命名为use[Something])是 React 的一个相对较新的添加。它们简化了在组件渲染中保持持久状态的过程。每当我们调用ShoppingListAdder函数时,都会发生重新渲染。但是useRef()将在ShoppingListAdder中的每次调用中返回相同的引用。一个单一的 React Hook 可以被认为是 MVC 中的模型

对于我们的ShoppingListAdder组件,我们传递了一个onAdd回调,我们可以看到每当用户添加了一个新项目(换句话说,当<form>提交时)时,它就会被调用。为了使用一个新组件,我们想把它放在ShoppingList中,然后在调用onAdd时做出响应,向我们的食品列表中添加一个新项目:

function ShoppingList({items: initialItems}) {

  const [items, setItems] = React.useState(initialItems);

  return (
    <div>
      <ShoppingListAdder
        onAdd={newItem => setItems(items.concat(newItem))}
      />
      <ul> 
        {items.map((item, index) => {
          return <li key={index}>{item}</li>
        })}
      </ul>
    </div>
  );
}

正如你所看到的,我们使用了另一种称为useState的 React Hook 来持久存储我们的项目。initialItems可以被传递到我们的组件中(作为参数),但我们可以从中派生一组持久项目,可以在我们的组件重新渲染时自由地改变。这就是我们的onAdd回调所做的事情:它将一个新项目(由用户输入)添加到当前项目列表中:

调用setItems将在幕后调用我们的组件的重新渲染,导致<li>Coffee</li>被附加到实时 DOM 上。创建、更新和删除都是类似处理的。像 React 这样的抽象的美妙之处在于,你不需要把这些变化看作是 DOM 逻辑的不同部分。我们所需要做的就是从我们的一组数据中派生一个组件/DOM 树,React 将找出协调 DOM 所需的精确变化。

为了确保我们理解发生了什么,当通过 Hook 改变了一段数据(状态)时(例如,setItems(...)),React 会执行以下操作:

  1. React 重新调用组件(重新渲染)

  2. React 将从重新渲染返回的树与先前的树进行比较

  3. React 对所有更改进行了必要的细粒度变化,以反映在实时 DOM 中

其他现代框架也借鉴了这种方法。这些抽象中内置的 DOM 协调机制的一个好处是,通过它们的声明性语法,我们可以从任何给定的数据中得出一个确定性的组件树。这与命令式方法形成鲜明对比,在命令式方法中,我们必须手动选择和改变特定的 DOM 节点。声明性方法给我们带来了功能纯度,使我们能够产生确定性和幂等性的输出。

正如你可能还记得的第四章,SOLID 和其他原则功能纯度幂等性给了我们可预测的输入和输出的独立可测试单元。它们使我们能够说X 输入将始终导致 Y 输出。这种透明度在我们代码的可靠性和可理解性方面都有很大帮助。

构建大型 Web 应用程序,即使协调问题已经解决,仍然是一个挑战。给定页面中的每个组件或视图都需要填充其正确的数据并传播更改。我们将在下一步探讨这个挑战。

消息传递和数据传播

构建 Web 应用程序时,您很快就会遇到一个挑战,即让页面内的不同部分组件相互通信。在任何单一时间,您的应用程序应该表示完全相同的数据集。如果发生变化,无论是通过用户操作还是其他机制,都需要在所有适当的位置反映这种变化。

这个问题出现在不同的规模上。您可能有一个聊天应用程序,其中输入的消息需要尽快传播给所有参与者。或者您可能有一条数据需要在同一应用程序视图中表示多次,因此所有这些表示都需要保持同步。例如,如果用户在配置文件设置窗格中更改他们的名字,那么这应该合理地更新出现他们名字的应用程序中的其他位置。

在传统的非 SPA 中,保存个人信息的按钮将简单地提交一个<form>,然后页面将完全重新加载,并且从服务器发送下来的更新状态的全新标记块。在 SPA 中,情况稍微复杂。我们需要将数据提交到服务器,然后以某种方式仅更新页面的相关部分以显示新数据。

为了解决这个问题,我们必须仔细考虑应用程序中数据或状态的流动。挑战在于尽快在所有需要表示的地方反映相关数据的真相来源。为了实现这一点,我们需要一种让代码库中的不同部分相互通信的方法。在这里,我们可以使用几种范式:

  • 基于事件的范式:这意味着具有可以发出和侦听的特定全局事件(例如userProfileNameChange)。页面内的任何组件或视图都可以绑定到此事件,并通过更新其内容做出相应反应。因此,状态同时存在于许多不同的区域(在各种组件或视图之间)。

  • 基于状态的范式:这意味着具有包含用户名字的单一真相来源的全局状态对象。这个状态对象,或其中的部分,可以通过组件树递归传递,这意味着在任何更改时,整个组件树都会被新状态喂养。因此,状态是集中的,但在更改发生时传播。

如果我们考虑用户通过<input />更改他们的名字,我们可以设想以下不同的数据流路径,以便所有依赖于名字数据的组件都能得到更新:

从根本上讲,这些方法实现了相同的目标:它们将数据呈现到 DOM 中。不同的关键在于如何在应用程序中传达变化,例如名字的变化,并且数据在任何时候都驻留在哪里:

  • 基于事件的范式使数据同时存在于多个位置。因此,如果由于某种原因,其中一个位置未能绑定到该事件的变化,那么您可能会得到不同步的数据表示。

  • 基于状态的范式只有一个数据的规范表示,并有效地将其传送到相关的视图或组件,以便它们始终使用最新版本。

面向状态的范式是越来越普遍的方法,因为它使我们能够更清晰地思考我们的数据及其表示方式。我们可以说我们有数据的单一表示,并且我们从该数据派生组件(或 UI)。这是一种功能纯净的方法,因为组件实际上只是数据对给定 UI 的确定性映射。由于任何给定组件只关心其输入数据,它不需要对其所在的上下文做太多假设。例如,我们可能有一个UserInfo组件,其预期输入为四个值:

{
  forename: 'Leah',
  surname: 'Brown',
  hobby: 'Kites',
  location: 'Edinburgh'
}

由于这个组件不依赖于任何全局事件或其他上下文假设,它可以很容易地被隔离。这不仅有助于理解和可维护性,还使我们能够编写更简单的测试。UserInfo组件可以被单独提取和测试,而不会与最终所在的应用程序产生相互依赖。

React 是一个流行的框架,用于表达这种面向状态的范式,但许多其他框架也在效仿。在 React 中,结合 JSX,我们可以这样表达我们的UserInfo组件:

function UserInfo({ forename, surname, hobby, location }) {
  return (
    <div>
      <h1>User Info</h1>
      <dl>
        <dt>Forename:</dt> <dd>{forename}</dd>
        <dt>Surname:</dt>  <dd>{surname}</dd>
        <dt>Hobby:</dt>    <dd>{hobby}</dd>
        <dt>Location:</dt> <dd>{location}</dd>
      </dl>
    </div>
  );
}

在这里,我们可以看到这个组件的输出只是其输入的映射。这样简单的 I/O 案例可以很容易地进行测试和推理。这种美丽的事情可以追溯到迪米特法则LoD),我们在第四章中介绍过,SOLID 和其他原则,它告诉我们UserInfo组件不需要知道其数据来自何处或者它被使用在哪里;它只需要履行其单一责任:从其四个输入中,它只需要为我们提供一个 DOM 层次结构-干净而美丽。

在现实生活中的 Web 应用程序中,可能存在更多的复杂性,我们无法用我们的名字示例来描绘出来。然而,如果我们记住分离关注点的基础知识,并构建良好隔离和功能纯净的视图或组件,那么我们几乎没有解决不了的挑战。

前端路由

在构建 Web 应用程序时,我们可能需要突变用户在浏览器中看到的地址,以反映当前正在访问的资源。这是浏览器和 HTTP 工作的核心原则。HTTP 地址应该代表一个资源。因此,当用户希望更改他们正在查看的资源时,地址应相应地更改。

在浏览器中突变当前 URL 的唯一方法是用户通过<a href>或类似的方式导航到不同的页面。然而,当单页应用程序开始变得流行时,JavaScript 程序员需要变得有创造力。在早期,一个流行的hack是突变 URL 的哈希部分(example.org/path/#hash),这将给用户带来在传统网站上遍历的体验,其中每个导航或操作都会导致新的地址和浏览器历史记录中的新条目,从而使浏览器的后退和前进按钮可用。

谷歌的 Gmail 应用程序在 2004 年推出时,采用了突变 URL 的#hash的方法,以便浏览器的地址栏准确地表达您当前正在查看的电子邮件或视图。许多其他单页应用程序也效仿此举。

几年后,幸运的是,历史 API 进入了浏览器,并且现在是在单页应用程序中响应导航或操作时突变地址的标准。具体来说,这个 API 允许我们通过推送新的状态或替换当前状态来操纵浏览器会话历史。例如,当用户希望切换到虚构单页应用程序中的关于我们视图时,我们可以将其表达为推送到他们历史记录中的新状态,如下所示:

window.history.pushState({ view: 'About' }, 'About Us', '/about');

这将立即在浏览器中将地址更改为'/about'. 通常,调用代码还会引发相关视图的渲染。路由是指呈现新 DOM 和改变浏览器历史记录的这些组合过程的名称。具体而言,路由器负责以下内容:

  • 呈现与当前地址对应的视图、组件或页面

  • 向其他代码公开接口,以便引发导航

  • 监听用户更改的地址(popstate事件)

为了说明这些职责,我们可以为一个应用程序创建一个简单的路由器,该应用程序非常简单地在 URL 路径中显示Hello {color}!,并在该颜色的背景上方显示该颜色。因此,/red将呈现红色背景,并显示文本Hello red!。而/magenta将呈现品红色背景,并显示文本Hello magenta!

这是我们的colorRouter的实现:

const colorRouter = new class {
  constructor() {
    this.bindToUserNavigation();

    if (!window.history.state) {
      const color = window.location.pathname.replace(/^\//, '');
      window.history.replaceState({ color }, color, '/' + color);
    }

    this.render(window.history.state.color);
  }
  bindToUserNavigation() {
    window.addEventListener('popstate', event => {
      this.render(event.state.color);
    });
  }
  go(color) {
    window.history.pushState({ color }, color, '/' + color);
    this.render(color);
  }
  render(color) {
    document.title = color + '!';
    document.body.innerHTML = '';
    document.body.appendChild(
      document.createElement('h1')
    ).textContent = 'Hello ${color}!';
    document.body.style.backgroundColor = color;
  }
};

请注意,我们在这里使用了 Class Singleton 模式(如上一章介绍的)。我们的colorRouter抽象非常适合这种模式,因为我们需要特定的构造逻辑,并且希望呈现一个单一的接口。我们也可以使用Revealing Module模式。

有了这个路由器,我们可以调用colorRouter.go()并传入颜色,它会改变地址并按预期渲染:

colorRouter.go('red');
// Navigates to `/red` and renders "Hello red!"

即使在这种简单的情况下,我们的路由器也存在一些复杂性。例如,当用户通过传统浏览方式最初登陆页面时,可能通过在地址栏中输入example.org/red来实现,历史对象的状态将为空,因为我们尚未通知浏览器会话/red与状态{ color: "red" }相关联。

为了填充这个初始状态,我们需要获取当前的location.pathname/red),然后通过删除初始斜杠来从中提取颜色。您可以在colorRouter的构造函数中看到这种逻辑:

if (!window.history.state) {
  const color = window.location.pathname.replace(/^\//, '');
  window.history.replaceState({ color }, color, '/' + color);
}

对于更复杂的路径,这种逻辑可能会变得相当复杂。在典型的路由器中,许多不同模式的路径都需要被容纳。因此,通常会使用 URL 解析库来正确提取 URL 的每个部分,并允许路由器正确路由该地址。

在生产路由器中使用一个正确构建的 URL 解析库非常重要。这样的库往往可以适应 URL 中隐含的所有边缘情况,并且最好符合 URI 规范(RFC 3986)。一个例子是URI.js(在 npm 上可用作uri-js)。

多年来,出现了各种不同的路由库和大型框架中的路由抽象。它们在向程序员呈现的接口上略有不同。例如,React Router 允许您通过 JSX 语法将独立路由声明为一系列 React 组件:

function MyApplication() {
  return (
    <Router>
        <Route exact path="/" component={Home} />
        <Route path="/about/:employee" component={AboutEmployee} />
    </Router>
  );
}

Vue.js,一个不同的框架,提供了自己独特的路由抽象:

const router = new VueRouter({
  routes: [
    { path: '/', component: Home }
    { path: '/about/:employee', component: AboutEmployee }
  ]
})

您可能注意到,在这两个示例中,都指定了 URL 路径为/about/:employee。冒号后面跟着给定的标记或单词是指定路径的特定部分是动态的常见方式。通常需要动态响应包含有关特定资源的标识信息的 URL 是合理的。所有以下页面应该产生不同的内容:

  • /about/john

  • /about/mary

  • /about/nika

将这些都指定为单独的路由将是非常繁重的(对于大型数据集几乎不可能),因此路由器总是有一种方式来表达这些动态部分。URL 的分层性质通常也在路由器提供的声明性 API 中得到反映,并且通常允许我们指定要呈现响应这些分层 URL 的组件或视图的层次结构。以下是一个可以传递给 Angular 的 Router 服务的routes指定的示例(另一个流行的框架!):

const routes = [
  { path: "", redirectTo: "home", pathMatch: "full" },
  { path: "home", component: HomeComponent },
  {
    path: "about/:employee",
    component: AboutEmployeeComponent,
    children: [
      { path: "hobbies", component: EmployeeHobbyListComponent },
      { path: "hobbies/:hobby", component: EmployeeHobbyComponent }
    ]
  }
];

在这里,我们可以看到AboutEmployeeComponent附加到about/:employee路径,并且具有每个附加到hobbieshobbies/:hobby子路径的子组件。例如/about/john/hobbies/kayaking这样的地址直观地呈现AboutEmployeeComponent,并在其中呈现EmployeeHobbyComponent

您可能会观察到路由器与呈现是多么交织在一起。确实可能有独立的路由库,但更典型的是框架提供路由抽象。这使我们能够在视图、组件或小部件等框架提供的呈现 DOM 的抽象旁边指定路由。基本上,尽管在表面上不同,所有这些前端路由抽象都将实现相同的结果。

许多 JavaScript 程序员将面临的另一个现实挑战,无论他们主要是在客户端还是服务器端工作,就是依赖管理。我们将在下一节开始探讨这个问题。

依赖管理

在单个网页的上下文中加载 JavaScript 曾经很简单。我们可以简单地在文档源代码的某个地方放置一对<script>标签,然后就可以了。

多年来,我们的 JavaScript 的复杂性与用户的需求一样大幅增长。随着这一点,我们的代码库也在增长。有一段时间,自然而然地只是不断添加更多的<script>标签。然而,在某一点上,这种方法会失败。除了在每个页面加载时进行多个 HTTP 请求的负担外,这种方法还使程序员难以处理他们的依赖关系。在那些日子里,JavaScript 通常要花费时间仔细地安排<script>的位置,以便对于任何特定的脚本,它的依赖关系在它自己加载之前就已经准备好了。

以前经常看到这样的 HTML 标记:

<!-- Library Dependencies -->
<script src="/js/libs/jquery.js"></script>
<script src="/js/libs/modernizr.js"></script>

<!-- Util Dependencies -->
<script src="/js/utils/data.js"></script>
<script src="/js/utils/timer.js"></script>
<script src="/js/utils/logger.js"></script>

<!-- App Widget Dependencies -->
<script src="/js/app/widgets/Nav.js"></script>
<script src="/js/app/widgets/Tile.js"></script>
<script src="/js/app/widgets/PicTile.js"></script>
<script src="/js/app/widgets/PicTileImage.js"></script>
<script src="/js/app/widgets/SocialButtons.js"></script>

<!-- App Initialization -->
<script src="/js/app/init.js"></script>

从性能的角度来看,这种方法是昂贵的,因为浏览器必须在继续解析和呈现剩余文档之前获取每个资源。因此,HTML 文档的<head>中的大量内联脚本被认为是一种反模式,因为它们会阻止用户在相当长的时间内使用网站。即使将脚本移动到<body>的底部也不理想,因为浏览器仍然必须按顺序加载和执行它们。

可预见的是,我们日益复杂的应用程序开始超越这种方法。开发人员需要更高的性能和对脚本加载的更精细控制。幸运的是,多年来,在我们管理依赖关系、打包它们以及然后将我们的代码库提供给浏览器方面已经进行了各种改进。

在本节中,我们将探讨多年来发生的改进,并试图了解当前的最佳实践。

模块定义-过去和现在

在 2010 年之前(大约),在浏览器中加载大型和复杂的 JavaScript 代码库的方法很少达成一致。然而,开发人员很快创建了异步模块定义AMD)格式。这是对在 JavaScript 中定义模块的标准的第一次尝试。它包括声明每个模块的依赖关系的能力和异步加载机制。这是对多个内联<script>标签的缓慢和阻塞性质的巨大改进。

RequireJS 是一个支持这种格式的流行库。要使用它,您只需要在文档中放置一个单一的入口点<script>

<script data-main="scripts/main" src="scripts/require.js"></script>

这里的data-main属性将指定我们代码库的入口点,然后加载初始依赖项并初始化应用程序,如下所示:

requirejs([
  "navigationComponent",
  "chatComponent"
], function(navigationComponent, chatComponent) {
    // Initialize:
    navigationComponent.render();
    chatComponent.render();
});

然后每个依赖项将define自己和自己的依赖项,如下所示:

// The Navigation Component AMD Module
define(
  // Name of the module
  'navigationComponent',

  // Dependencies of the module
  ['utilA', 'utilB'],

  // Definition of the module returned from function
  function (utilA, utilB) {
    return /* Definition of navigationComponent */;
  }
);

这在精神上类似于 ECMAScript 规范中现在指定的模块,但 AMD 与任何特定的语言语法无关。这完全是一个由社区驱动的努力,旨在将类似模块的东西带到 JavaScript 中。

AMD 规定每个模块都在回调函数中定义,可以在其中传递依赖项,这意味着加载工具(如 RequireJS)可以异步加载所有依赖项,然后在完成后调用回调函数。这对前端 JavaScript 来说是一个重大提升,因为这意味着我们可以相当轻松地以一种减轻了编写代码过程(减少依赖项处理)并且使代码以非阻塞和更高性能方式加载到浏览器中的方式加载大量依赖图。

与 AMD 类似的时间,一个名为CommonJS的新的标准驱动的努力开始出现。这试图使require(...)语法成为各种非浏览器环境中的标准,并希望最终该语法也能在前端得到支持。以下是一个 CommonJS 模块的示例(如果您习惯于在 Node.js 中编程,这可能会很熟悉):

const navigationComponent = require('components/navigation');
const chatComponent = require('components/chat');

module.exports = function() { /* exported functionality */ };

这成为了各种非浏览器环境的标准,如 Node.js、SproutCore 和 CouchDB。还可以使用 CommonJS 编译器将您的 CommonJS 模块编译成类似 AMD 的浏览器可消耗的脚本。在此之后,大约在 2017 年,ES 模块出现了。这为我们提供了对importexport语句的语言支持,有效地解决了 JavaScript 中如何定义模块的历史挑战。

// ES Modules

import navigationComponent from 'components/navigation';
import chatComponent from 'components/chat';

export default function() { /* do stuff */ };

在 Node.js 中,这些模块的文件名后缀必须是.mjs而不是.js,这样引擎就知道要期望importexport而不是传统的 CommonJS 模块定义语法。在浏览器中,可以使用<script type="module">加载这些模块。然而,即使浏览器支持 ES 模块,在构建和捆绑 JavaScript 成传统的非模块化脚本标签仍然可能更可取。这是由于性能和跨浏览器的兼容性因素。不过不用担心:我们仍然可以在编写代码时使用 ES 模块!诸如 Babel 之类的工具可以用来将最新的 JavaScript 语法编译和捆绑成兼容多个环境的 JavaScript。通常在构建和开发过程中设置 Babel 这样的工具是很典型的。

npm 和 package.json

过去,JavaScript 社区没有可用的包管理器。相反,个人和组织通常会自行发布代码,使开发人员可以手动下载最新版本。随着 Node.js 和 npm 的引入,一切都发生了变化。最终,有一个中央的包存储库可供轻松地引入我们的项目。这不仅对于服务器端的 Node.js 项目有用,对于完全的前端项目也是如此。npm 的出现很可能是导致 JavaScript 生态系统成熟的最重要事件之一。

现在,几乎每个涉及 JavaScript 的项目都会在顶层package.json文件中设置其清单,通常至少指定名称、描述、版本和一个版本化的依赖项列表:

{
  "name": "the-fruit-lister",
  "description": "An application that lists types of fruits",
  "version": "1.0",
  "dependencies": {
    "express": "4.17.1"
  },
  "main": "app/init.js"
}

package.json中有许多可用字段,因此值得探索 npm 文档以了解所有这些字段。以下是最常见字段的概述:

  • name: 包的名称可能是最重要的事情。如果您计划将包发布到 npm,则此名称需要是唯一的。

  • description: 这是您的模块的简要描述,以帮助开发人员了解其目的。更详细的信息通常放在READMEREADME.md文件中。

  • version: 这是一个语义化版本SemVer)兼容的版本(形式为[Major].[Minor].[Patch],例如5.11.23)。

  • dependencies: 这是一个将每个依赖包名称映射到版本范围的对象。版本范围是一个字符串,其中包含一个或多个以空格分隔的描述符。依赖项也可以指定为 tarball/Git URL。

  • devDependencies: 这在功能上与dependencies相同,只是它仅用于开发过程中需要的依赖项,例如代码质量分析器和测试库。

  • main: 这可以是指向程序的主要入口点的模块 ID。例如,如果您的包名为super-utils,有人安装了它,然后执行了require("super-utils"),那么您的main模块的导出对象将被返回。

npm 假定您的包和您依赖的任何包都遵循 SemVer 的规则,该规则使用模式[Major].[Minor].[Patch](例如1.0.2)。SemVer 规定任何重大更改必须导致主要部分增加,而向后兼容的功能添加应该只导致次要部分增加,向后兼容的错误修复应该只导致补丁部分增加。完整的详细信息可以在semver.org/找到。

package.json所在的目录中运行npm install将导致 npm 下载您指定的依赖项的版本。在声明依赖项时,默认情况下,npm 会附加一个插入符(^),这意味着 npm 将选择符合指定主要版本的最新可用版本。因此,如果您指定¹.2.3,那么任何1.99.99(依此类推)之前的版本都可以被有效安装。

有几个模糊的版本范围可以使用:

  • version: 必须与version完全匹配

  • >version: 必须大于version

  • >=version: 必须大于或等于version

  • <version: 必须小于version

  • <=version: 必须小于或等于version

  • ~version: 大约等同于version(仅增加patch部分)

  • ^version: 兼容version(仅增加minor/patch部分)

  • 1.2.x: 1.2.01.2.1等,但不包括1.3.0x在这里表示任何内容)

npm 的最大问题可能是未经检查地引入新包及其功能的细粒度导致了具有非常庞大和难以控制的依赖图的项目。并不罕见的是,有些单独的包只导出一个狭窄的实用功能。例如,除了一个通用的字符串实用程序包,你可能还会发现一个特定的字符串函数作为一个独立的包,比如大写。这些包本身并不是问题,很多包都有有用的用途,但是拥有一个难以控制的依赖图可能会导致自身的问题。任何受到影响或者没有严格遵循 SemVer 规范的流行包都可能导致 JavaScript 生态系统中问题的传播,最终影响到你的项目。

为了防止错误和安全问题,强烈建议使用固定版本来指定你的依赖,并且只有在检查了各自的变更日志后才手动更新依赖。现在,一些工具可以帮助你保持依赖的最新状态,而不会牺牲安全性(例如,由 GitHub 拥有的dependabot)。

建议使用一个依赖管理系统,通过加密哈希(一个检查恶意更改的校验和)来确保下载的包的完整性,以确保你最终执行的包确实是你打算安装的包,并且在传输过程中没有被破坏或损坏。Yarn 就是这样一个系统的例子(参见yarnpkg.com)。它实际上是在 npm 之上更安全和高效的抽象。除了更安全之外,Yarn 还有一个额外的好处,就是避免不一致的包解析,这是指给定代码库的两次安装可能会导致一组不同的下载依赖(因为 npm 版本声明的可能模糊性)。这种不一致可能导致同一代码库在两个实例中表现不同(一个巨大的头痛和错误的预兆!)。Yarn 将当前的锁定依赖图和相应的版本和校验和存储在一个yarn.lock文件中,看起来像这样:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

array-flatten@1.1.1:
  version "1.1.1"
  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=

...

在这里,我们只看到一个依赖,但通常会有数百甚至数千个,因为它不仅包括你的直接依赖,还包括这些依赖的依赖。

依赖管理是一个已经有很多文章写过的话题,所以如果你上网搜索,就会发现各种意见和解决方案。从根本上讲,因为我们关心的是清晰的代码,我们应该回到我们的原则。首先,我们在我们的依赖系统和依赖本身中应该寻求的是可靠性、效率、可维护性和可用性。在依赖的上下文中,当涉及到可维护性时,我们对我们能够维护依赖的代码以及依赖的维护者能够保持依赖最新和无 bug 的能力都感兴趣。

捆绑和服务

在 JavaScript 的世界中,大约在 AMD 和 CommonJS 开始出现的同时,命令行打包工具和构建工具也开始兴起。这使我们能够将大量的依赖图捆绑成单个文件,可以通过单个<script>加载。构建工具的大量出现,比如 GruntJS 和 gulp.js,意味着我们编写的 JavaScript 可以慢慢地朝着清晰和易懂的方向发展,而不是浏览器加载的怪癖。我们还可以开始利用分支语言和子集,比如 CoffeeScript、TypeScript 和 JSX。这样的 JavaScript 适应可以很容易地编译,然后捆绑成完全可操作的 JavaScript 发送到浏览器。

我们现在所处的世界是构建和捆绑工具非常普遍的世界。有一些特定的构建工具,如 Grunt、gulp.js、webpack 和 Browserify。此外,开发人员可以轻松地使用 npm 的scripts指令来创建常见命令行指令的快捷方式。

通常,构建涉及对开发代码库进行的任何准备工作,使其能够投入生产使用。这可能包括从清理你的 CSS 到捆绑你的 JavaScript 等任何事情。特别是捆绑,涉及将大型依赖图(JavaScript 文件)编译和整合成单个 JavaScript 文件。这是必要的,这样我们才能以最高效和兼容的方式将 JavaScript 代码库提供给浏览器。捆绑工具通常会输出一个带有文件内容哈希的文件作为文件名的一部分,例如main-f522dccf1ff37b.js。然后可以将这个文件名动态或静态地插入到 HTML 中的<script>标签中,以便提供给浏览器:

<script src="/main-f522dccf1ff37b.js"></script>

在文件名中包含文件内容的哈希值可以确保浏览器始终加载更新的文件,而不依赖于先前缓存的版本。这些文件通常也会被压缩压缩涉及解析你的 JavaScript 并生成一个在功能上相同但更小的表示形式,其中已经采取了尽可能少的措施,比如缩短变量名和删除空格和换行符。这与 HTTP 压缩技术(如.gzip)结合使用,以确保从服务器到客户端的 HTTP 传输尽可能小和快。通常,你会有不同的开发生产构建,因为一些构建步骤,比如压缩,会使开发(和调试!)更加困难。

向浏览器提供捆绑的 JavaScript 通常是通过一个单独的<script>标签引用捆绑的 JavaScript 文件名来完成的,放置在你提供给浏览器的 HTML 中的某个位置。在选择方法时有几个重要的性能考虑。最重要的指标是用户从初始请求开始使用应用程序的时间。当加载 superWebApp.example.com 时,用户可能会遇到以下可能的延迟:

  • 获取资源:每个资源获取可能涉及 DNS 查找、SSL 握手和 HTTP 请求和响应周期的完成。响应通常是流式传输的,这意味着浏览器可能在完成之前开始解析响应。浏览器通常会同时发出适量的请求。

  • 解析 HTML:这涉及浏览器解析每个标记名称,并逐步构建 HTML 的 DOM 表示。遇到的一些标记会导致一个新的可获取资源被排队,比如<img src><script src><link type="stylesheet" href>

  • 解析 CSS:这涉及浏览器解析获取的每个 CSS 中的每个规则集。引用的资源,如背景图片,只有在页面上找到相应的元素时才会被获取。

  • 解析/编译 JavaScript:在获取每个 JavaScript 资源之后,其内容将被解析和编译,准备执行。

  • 应用 CSS 渲染 HTML:这将理想地只发生一次,当所有 CSS 都已加载时。如果有异步加载的 CSS 或其他美观资源(如字体或图像),那么在页面被认为完全渲染之前可能会有几次重绘/重新渲染。

  • 执行 JavaScript:根据相应的<script>的位置,一段 JavaScript 将执行,然后可能改变 DOM 或执行自己的获取。这可能会阻止其他的获取/解析/渲染发生。

通常情况下,最好在浏览器完成其他所有工作后再执行 JavaScript。然而,这并不总是理想的。一些 JavaScript 可能需要加载重要资源,因此应尽早执行,以便这些 HTTP 获取可以与浏览器的其他准备工作同时进行。

放置主要捆绑的<script>(您的main代码库)对于确定 JavaScript 何时获取、何时执行以及执行时 DOM 的状态至关重要。

以下是最流行的<script>放置位置及其各自的优势:

  • <head>中的<script src>:遇到<script>标签时,此脚本将被获取。获取和执行将按顺序进行,并且会阻止其他解析的进行。这被认为是一种不好的做法,因为它无端地阻止了文档的继续解析(因此从用户的角度增加了页面加载的延迟)。

  • <body>末尾的<script src>:遇到<script>标签时,此脚本将被获取。获取和执行将按顺序进行,并且会阻止其他解析的进行。通常,当<script><body>中的最后一件事时,解析可以被认为是基本完成的。

  • <head>中的<script src defer>:遇到<script>标签时,此脚本将被排队获取,并且此获取将与 HTML 解析同时进行,并且在浏览器方便的时间并发进行。脚本只有在整个文档解析完成后才会执行。

  • <head>中的<script src async>:遇到<script>标签时,此脚本将被排队获取,并且此获取将与 HTML 解析同时进行,并且在浏览器方便的时间并发进行。脚本的执行将在获取后立即进行,并且会阻止继续解析。

通常情况下,在<head>中使用<script defer>是可取的,因为它可以尽快获取,不会阻止解析,并且只有在解析完成后才会执行。这往往会给用户提供最快的体验,如果您提供了一个单一的捆绑脚本,并且给您的 JavaScript 一个完全解析的 DOM,它可以立即操作和渲染。

向浏览器提供 JavaScript 是一件简单的事情,事实上。只有我们需要网页应用程序快速执行以造福用户的需求才会变得复杂。日益复杂的 JavaScript 代码库会产生越来越大的捆绑包,因此加载这些捆绑包需要时间。因此,JavaScript 的加载性能很可能是您需要认真对待并花时间调查的事情。性能是一件容易被忘记但非常重要的事情。

JavaScript 生态系统中同样容易被忘记的一个话题是安全性,这就是我们现在要探讨的。

安全性

安全性是可靠代码库的重要组成部分。用户有一个内在的假设,即任何给定的软件都会按照其功能期望的方式行事,并且不会导致其数据或设备的妥协。干净的代码将安全性视为其他功能期望一样重要的要求,应该仔细履行并经过彻底测试。

由于 JavaScript 主要用于网络环境,无论是在服务器端还是客户端,它都存在安全漏洞的可能性。而浏览器实际上是沙盒化的远程代码执行工具,这意味着我们的最终用户和我们一样容易受到风险。为了保护自己和用户,我们需要对存在的各种漏洞类型以及如何对抗它们有多方面的了解。关于安全漏洞的信息非常庞大且令人生畏。我们无法希望在本书中涵盖所有内容,但希望如果我们探讨一些常见的漏洞,那么我们将更加谨慎和警觉,并且可以开始理解我们应该采取的措施类型。

跨站脚本攻击

跨站脚本攻击XSS)是一种漏洞,使攻击者能够将自己的可执行代码(通常是 JavaScript)注入到 Web 应用程序的前端,以便浏览器将其执行为受信任的代码。XSS 可以以许多方式表现,但都可以归结为两种核心类型:

  • 存储型 XSS:这涉及攻击者以某种方式将可执行代码保存在看似无害的数据中,然后将其持久化到 Web 应用程序中,然后再呈现给 Web 应用程序的其他用户。一个简单的例子是一个社交媒体网站,允许我将我的名字指定为 HTML(例如,<em>James!</em>),但没有阻止包含潜在危险的可执行 HTML,允许我指定一个名字,例如<script>alert('XSS!')...

  • 反射型 XSS:这涉及攻击者向受害者发送 URL 的同时,将可执行有效负载发送到请求中,无论是在 URL、HTTP 标头还是请求正文中。当用户登陆页面时,将执行此可执行有效负载。一个例子是反射查询回到用户的搜索页面(任何搜索页面的常见特征),但以未能转义 HTML 的方式进行,这意味着攻击者只需将其受害者发送到/search?q=<script>alert('XSS!')...

存储或反射的有效负载在页面中的呈现方式至关重要。传统上,XSS 向量仅限于未经转义的用户输入 HTML 的服务器端呈现。因此,如果 Bob 将他的社交媒体账户名称设置为<script>alert("Bob's XSS")...,那么当服务器请求 Bob 的页面时,返回的标记将包括该<script>,准备由浏览器解析和执行。然而,现在,单页应用程序和涉及客户端呈现的网站更加普遍,这意味着服务器不再因允许未经转义的 HTML 进入文档标记而受到责备,而是客户端(JavaScript 代码库)因将危险内容直接呈现到 DOM 而受到责备。因此,依赖于客户端呈现的 XSS 攻击通常被称为基于 DOM 的 XSS

XSS 有效负载可以采用各种形式。很少是一个简单的<script>标签。攻击者使用各种复杂的编码、古老的 HTML,甚至 CSS 来嵌入他们邪恶的 JavaScript。因此,从字符串中清除 XSS 并不是微不足道的,而是建议绝对不要信任用户输入的内容。

我们可以想象这样一个场景,我们的 JavaScript 代码库有一个UserProfile组件,用于呈现任何用户的名称和个人资料信息。在初始化时,该组件从一个看起来像/profile/{id}.json的 REST 端点请求其数据,返回以下 JSON:

{
  "name": "<script>alert(\"XSS...\");</script>",
  "hobby": "...",
  "profielImage": "..."
}

然后,该组件通过innerHTML将接收到的名称呈现到 DOM 中,而不转义或清理其内容:

class UserProfile extends Component {
  // ...
  render() {
    this.containerElement.innerHTML = `<h1>${this.data.name}</h1>`;
    this.containerElement.innerHTML += `<p>Other profile content...</p>`;
  }
}

所有呈现UserProfile组件的用户都有可能执行任意(可能有害的)HTML。无论任意 HTML 来自反射还是存储的来源,这都将是一个问题。

常见的 JavaScript 框架的普及使得攻击者只需在库或框架中找到漏洞,就可以攻击成千上万个不同的网站。大多数框架默认情况下都有插值机制,强制插入的数据被呈现为文本而不是 HTML。例如,React 将始终为通过 JSX 的插值定界符(花括号)插入的任何数据生成文本节点。我们可以在这里看到这种效果:

function Stuff({ msg }) {
  return <div>{msg}</div>
}

const msg = '<script>alert("Oh no!");</script>';
ReactDOM.render(<Stuff msg={msg} />, document.body);

这导致包含<script>的数据被文字直接呈现,因此<body>元素的innerHTML结果是这样的:

<div>
  <script>alert("Oh no!");</script>
</div>

因为潜在危险的 HTML 被呈现为文本,所以不会发生执行,XSS 攻击被阻止了。然而,这并不是 XSS 攻击发生的唯一方式。客户端框架通常有依赖于内联<script>标签的模板解决方案,如下所示:

<script type="text/x-template">
  <!-- VueJS Example -->
  <div class="tile" @click="check">
    <div :class="{ tile: true, active: active }"></div>
    <div class="title">{{ title }}</div>
  </div>
</script>

这是一种方便的声明模板,用于以后渲染特定组件,但这种模板通常与服务器端渲染和插值结合使用,如果攻击者可以强制服务器将危险字符串插值到模板中,则可能会导致 XSS,如下所示:

<!-- ERB (Rails) Template -->
<script type="text/x-template">
  <!-- VueJS Template -->
  <h1>Welcome <%= user.data.name %></h1>
</script>

如果user.data.name包含恶意 HTML,则我们的 JavaScript 在客户端无法阻止攻击。当我们渲染我们的代码时,甚至可能已经太迟了。

在现代 Web 应用程序中,我们必须警惕存储或反射的 XSS,在服务器和客户端上都会渲染。这是可能矢量的令人费解的组合,因此至关重要的是确保您使用一系列对策:

  • 永远不要相信用户输入的数据。最好不要允许用户输入任何 HTML。如果他们可以,那么使用 HTML 解析库并列出您信任的特定标签和属性。

  • 永远不要在 HTML 注释、<script>元素、<style>元素或应该出现 HTML 标签或属性名称的地方(例如<HERE ...><div HERE=...>)中放置不受信任的数据。如果必须这样做,请将其放在 HTML 元素中,并确保它已完全转义(例如&&amp;"&quot;)。

  • 如果将不受信任的数据插入常规(非 JavaScript)HTML 属性,则使用&#xHH;格式转义所有小于256的 ASCII 值。如果插入到常规 HTML 元素的内容中,则转义以下字符就足够了:&<>"'/

  • 避免将不受信任的数据插入 JavaScript 执行的区域,例如<script>x = 'HERE'</script><img onmouseover="x='HERE'">,但如果您绝对必须这样做,请确保数据已转义,以便它无法打破其引号或包含的 HTML。

  • 不要在<script>中嵌入 JavaScript 可读数据,而是使用 JSON 将数据传输到客户端,可以通过请求或将其嵌入到<div>(确保已完全 HTML 转义!),然后自行提取和反序列化。

  • 使用适当限制的内容安全策略CSP)(我们将在下一节中解释)。

这些对策并不详尽,因此建议您仔细阅读开放 Web 应用程序安全项目OWASP)跨站脚本攻击预防备忘单:cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html

内容安全策略

作为额外的安全措施,配置适当的 CSP 也很重要。

CSP 是一个相对较新的 HTTP 标头,在所有现代浏览器上都可用。它 是普遍支持或受尊重的,因此不应该依赖它作为我们对抗 XSS 的唯一防御。尽管如此,如果正确配置,它可以防止大多数 XSS 漏洞。不支持 CSP 的浏览器将退回到它们的同源策略的默认行为,这本身提供了一定级别的关键安全性。

同源策略是所有浏览器采用的一种重要的安全机制,它限制了文档或脚本在访问其他来源的一些资源时的能力(当它们共享相同的协议、端口和主机时,来源匹配)。这一政策意味着,例如,leah.example.org 中的 JavaScript 不能获取 alice.example.org/data.json。然而,随着 CSP 的出现,alice.example.org 可以通过禁用同源策略来表达对 leah.example.org 的信任并提供这样的访问。

Content-Security-Policy 标头允许您指定不同类型的资源允许从哪里加载。它本质上是一个浏览器将根据其验证所有传出请求的来源白名单。

它可以被指定为常规的 HTTP 标头:

Content-Security-Policy: default-src 'self'

或者可以指定为 meta 标签:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'">

值的格式是一个或多个策略指令,用分号分隔,每个策略指令以 fetch 指令开头。这些指定资源类型(例如 img-srcmedia-srcfont-src)或默认(default-src),如果它们没有单独指定,所有指令都将回退到默认。fetch 指令后面是一个或多个以空格分隔的来源,其中每个来源指定该资源类型的资源可以从哪里加载。可能的来源包括 URL、协议、'self'(指文档自己的来源)等。

以下是一些 CSP 值的示例,以及每个值的解释:

  • default-src 'self':这是最大限度的限制指令,声明只有来自文档本身相同来源的资源才能在文档内加载(无论是来自 <img><script>、XHR 还是其他任何地方)。不允许其他来源。

  • default-src 'self'; img-src cdn.example.com:这个指令声明只有来自文档本身相同来源的资源才能被加载,除了图片(例如 <img src> 和 CSS 声明的图片)可以从 cdn.example.com 来加载。

  • default-src 'self' *.trusted.example.com:这声明只有来自相同来源的资源 来自 *.trusted.example.com 的资源是有效的。

  • default-src https://bank.example.com:这声明只有来自 SSL 安全来源 https://bank.example.com 的资源才能被加载。

  • default-src *; script-src https::这声明资源可以从任何有效的 URL 加载,除了 <script src> 必须从 HTTPS URL 加载其资源的情况。

适当限制的 CSP 完全取决于您的特定 Web 应用程序、您可能正在处理的用户生成的内容的类型以及您处理的数据的敏感性。适当的 CSP 不仅可以保护您免受创建潜在的 XSS 向量(通过从潜在受损的来源加载)的威胁,还可以帮助抵消执行 XSS 漏洞。CSP 以以下特定方式防御 XSS:

  • CSP 禁用了 eval() 和其他类似的技术。这些是 XSS 的常见向量,特别是在旧版浏览器中,这些方法已被用于解析 JSON。如果您愿意,可以通过 'unsafe-eval' 源来显式启用 eval

  • CSP 禁用了内联的<script><style>标签,JavaScript 协议以及内联事件处理程序(例如<img onload="..." />)。这些都是常见的 XSS 向量。您可以通过为相关的获取指令指定unsafe-inline作为源来显式启用这些功能,但建议您从外部来源加载您的脚本和样式,以便浏览器可以根据 CSP 白名单对其来源进行验证。

  • 作为最后的努力,如果 CSP 配置良好,它可以防止当前执行的 XSS 加载自己的恶意资源或者使用被破坏的数据进行调用,从而限制其造成的损害。

子资源完整性

子资源完整性SRI)是浏览器内的一项安全功能,允许我们验证它们获取的资源是否在传递过程中没有受到任何意外的篡改或损害。这种篡改可能发生在资源提供的地方(例如,您的 CDN 被黑客攻击)或者在网络传输过程中(例如,中间人攻击)。

要验证您的脚本,您必须提供一个包含哈希算法名称(例如sha256sha384sha512)和哈希本身的完整性属性。以下是一个例子:

<script src="//cdn.example.com/foo.js" integrity="sha384-367drQif3oVsd8RI/DR8RsFbY1fJei9PN6tBnqnVMpUFw626Dlb86YfAPCk2O8ce"></script>

要生成该哈希,您可以使用 OpenSSL 的 CLI 如下:

cat FILENAME.js | openssl dgst -sha384 -binary | openssl base64 -A

除了在<script>上使用完整性属性外,您还可以在<link>上使用它来验证 CSS 样式表。要强制执行 SRI,您可以使用有用的 CSP 头部:

Content-Security-Policy: require-sri-for script; require-sri-for style;

这样做将确保任何没有完整性哈希的脚本或样式表都无法加载。一旦获取,如果提供的完整性哈希与接收到的文件的哈希不匹配,那么它将被忽略(就好像没有被获取)。使用 SRI 和 CSP 一起可以有效防御 XSS。

跨站点请求伪造

跨站点请求伪造CSRF)是指命令以 HTTP GET 或 POST 请求的形式从用户端传输,而用户并没有意识到,这是由恶意代码造成的。一个原始的例子是,如果bank.example.com的银行网站有一个 API 端点,允许已登录的用户将一定金额转账到指定的账户号码。端点可能如下所示:

POST bank.example.com/transfer?amount=5000&account=12345678

即使用户通过bank.example.com域上的会话 cookie 进行了身份验证,恶意网站仍然可以轻松地嵌入并提交<form>,将转账指向他们自己的账户,如下所示:

<form
  method="post"
  action="//bank.example.com/transfer?amount=5000&account=12345678">
</form>
<script>
  document.forms[0].submit();
</script>

无论端点使用何种 HTTP 方法,或者接受何种请求体或参数,除非确保请求来自自己的网站,否则它都容易受到 CSRF 攻击。浏览器内置的同源策略部分解决了这个问题,阻止了某些类型的请求(例如通过 XHR 进行的 JSON POST 请求或 PUT/DELETE 请求),但浏览器内部没有任何机制来防止用户无意中点击链接或提交伪造恶意 POST 请求的表单。毕竟,这些行为正是浏览器的整个目的。

由于 Web 没有内在的机制来防止 CSRF,开发人员已经提出了自己的防御机制。防止 CSRF 的一种常见机制是 CSRF 令牌(实际上应该被称为反 CSRF 令牌)。这是一个生成的密钥(随机、长且不可能被猜到),它会随着每个常规请求一起发送到客户端,同时也存储在服务器上作为用户会话数据的一部分。然后服务器将要求浏览器在任何后续的 HTTP 请求中发送该密钥,以验证每个请求的来源。因此,我们的/transfer端点现在将有第三个参数,即令牌。

POST bank.example.com/transfer?
  amount=5000&
  account=12345678&
  token=d55lv90s88x9mk...

服务器可以验证提供的令牌是否存在于用户的会话数据中。有许多库和框架可以简化此过程。还有许多基本令牌机制的适应和配置。其中一些仅会为特定时间或特定请求周期生成令牌,而其他一些则会为用户整个会话提供一个唯一的令牌。还有许多方法可以将令牌发送到客户端。最常见的方法是在响应有效负载中作为文档标记的一部分,通常以<meta>元素的形式出现在<head>中:

<head>
  <!-- ... -->
  <meta name="anti-csrf-token" content="JWhpLxPSQSoTLDXm..." />
</head>

然后,JavaScript 可以获取这些令牌,并在 JavaScript 动态生成的任何后续 GET 或 POST 请求中发送。或者在没有客户端渲染的传统网站的情况下,CSRF 令牌可以直接嵌入到<form>标记中作为隐藏的<input>,这自然地成为表单最终提交到服务器的一部分:

<form>
  <input
    type="hidden"
    name="anti-csrf-token"
    value="JWhpLxPSQSoTLDXm..." />

  <!-- Regular input fields here -->

  <input type="submit" value="Submit" />
</form>

如果您的 Web 应用程序容易受到 XSS 攻击,那么它也会天然地容易受到 CSRF 攻击,因为攻击者通常会有访问 CSRF 令牌的权限,因此能够伪装成合法请求,而服务器无法区分。因此,强大的反 CSRF 措施本身是不够的:您还必须对其他潜在漏洞采取对策。

无论您使用何种反 CSRF 措施,关键的需要是对每个对用户数据进行变异或执行命令的请求进行验证,以确保其来自 Web 应用程序本身的合法页面,而不是一些恶意构造的外部来源。为了更全面地了解 CSRF 和可用的对策,我建议阅读并充分消化OWASP 的 CSRF 预防备忘单cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

其他安全漏洞

XSS 和 CSRF 只触及了我们应该做好准备的攻击类型的表面。防御所有可能的漏洞是非常具有挑战性的,通常是不现实的,但如果我们不编写能够抵御最普遍漏洞的代码,那就是愚蠢的。对存在的漏洞类型有一个良好的一般了解可以帮助我们在编写代码时保持一般的谨慎。

正如前面所探讨的,XSS 是一种非常多样化的漏洞,有许多可能的攻击向量。但我们可以通过一种一般的方式来防御它,即通过始终正确地区分受信任和不受信任的数据。我们可以通过将不受信任的数据放置在非常特定的位置、正确转义它,并确保我们有适当限制的 CSP 来限制不受信任数据造成破坏的可能性。同样,对于 CSRF,攻击者可以以无数种方式执行它,但拥有一个坚固的反 CSRF 令牌机制将使您免受大部分攻击。在安全领域,鉴于我们有限的资源,我们所能期望的就是能够对抗大多数流行的攻击。

以下是一些其他值得注意的流行漏洞:

  • SQL 或 NoSQL 注入:任何用户提交的数据,如果未正确转义,都可能使攻击者访问您的数据并能够读取、修改或销毁数据。这类似于 XSS,因为两者都是注入攻击的形式,所以我们对其的防御又一次归结为识别不受信任的数据,然后正确转义它。

  • 身份验证/密码攻击:攻击者可以通过猜测密码、暴力破解组合或使用彩虹表(常见密码哈希的数据库)来未经授权地访问用户的帐户。一般来说,最好不要创建自己的身份验证机制,而是依赖于可信的库和框架。您应该始终确保使用安全的哈希算法(如bcrypt)。一个很好的资源是 OWASP 的密码存储备忘单cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)。

  • 依赖劫持:攻击者可以通过劫持您的依赖之一来获得对您的服务器端或前端代码库的访问权限。他们可能会获得对您依赖图中存在的 npm 包的访问权限(在网上搜索left-pad 事件),或者损害您用于存储 JavaScript 资产的 CMS 或 CDN。为了对抗这些类型的漏洞,确保您使用安全的包管理系统,如 Yarn,尝试在package.json中使用固定版本模式,始终检查更改日志,并在前端使用适当限制的 CSP,以防止任何恶意代码调用主页。

总是存在被攻击的可能性,因此我们需要将这种风险纳入我们的系统设计中。我们不能指望对这些漏洞免疫,但当它们发生时,我们可以确保我们能够快速修复它们,与受影响的用户透明沟通,并确保我们仔细考虑如何防止这些漏洞再次发生。

无论我们是为开发人员创建框架还是为非技术用户创建 UI,我们的代码使用者总是期望它能够安全地运行。这种期望越来越多地被编码到法律中(例如,在欧盟法律中的《通用数据保护条例》(GDPR)),因此认真对待并花费大量时间学习和预防是至关重要的。安全实践是另一个例子,说明干净的代码不仅仅关乎我们的语法和设计模式,还关乎我们的代码如何显著地影响我们的用户和他们的日常生活。

总结

在本章中,我们探讨了各种现实世界的挑战——任何 JavaScript 程序员在浏览器和服务器上都可能面临的话题。在 JavaScript 中编写干净的代码不仅仅是关于语言本身,还涉及到它存在的网络生态和这带来的需求。通过我们对 DOM、路由、依赖管理和安全性的探索,我们希望能够深入了解 JavaScript 经常处理的问题领域的技术细节,并对存在的许多框架、库和标准驱动的 API 有所了解,以帮助我们解决这些问题。

在下一章中,我们将深入探讨编写干净测试的艺术,这是一项至关重要的任务,不仅因为它让我们对自己的代码充满信心,还因为它确保了用户对我们软件的合理期望的可靠性。

第四部分:测试和工具

在本节中,我们将学习如何通过测试和工具来促进和捍卫更清洁的 JavaScript 代码库。具体来说,我们将学习如何编写良好的测试,以保护我们免受代码退化和不干净代码的影响。通过这样做,我们将了解各种工具和自动化流程,可以在团队环境中交付更高质量的代码。

本节包括以下章节:

  • 第十三章,测试的领域

  • 第十四章,编写清晰的测试

  • 第十五章,更清洁代码的工具

第十三章:测试的景观

在本书的开头,我们阐明了清晰代码的主要原则。其中之一是可靠性。确认可靠性的最佳方法莫过于让您的代码库持续和多样化地接受使用。这意味着真正的用户坐在您的软件前并使用它。只有通过这种类型的暴露,我们才能了解我们的代码是否真正实现了它的目的。然而,经常进行这种现实生活测试通常是不合理的,甚至可能是危险的。如果代码发生变化,用户依赖的某个功能可能出现故障或退化。为了防止这种情况,并且通常确认我们的期望是否得到满足,我们编写测试。如果没有一套好的测试,我们就 passively and arrogantly closing our eyes and hoping that nothing goes wrong。

在本章中,我们将涵盖以下主题:

  • 什么是测试?

  • 测试类型

  • 测试驱动开发TDD

什么是测试?

软件测试是一个自动化的程序,它对一段代码进行断言,然后将这些断言的成功报告给您。测试可以对任何东西进行断言,从一个单独的函数到整个功能的行为。

测试,就像我们的其他代码一样,涉及抽象和细节的层次。如果我们要抽象地测试一辆汽车,我们可能只是寻求断言以下属性:

  • 它有四个轮子

  • 它有一个方向盘

  • 它能开车

  • 它有一个工作的喇叭

显然,这对汽车工程师来说并不是一个非常有用的断言集,因为这些属性要么非常明显,要么描述不足。断言“它能开车”很重要,但如果没有额外的细节,它所表达的只是一个通用的业务目标。这类似于项目经理要求软件工程师确保用户登录门户,例如,可以让用户成功登录。工程师的工作不仅是实现用户登录门户,还要得出成功调查断言用户可以成功登录的工作测试。从通用陈述中得出好的测试并不总是容易的。

要正确地设计一个测试,我们必须将通用和抽象的要求提炼到它们的细节和非抽象的细节。例如,当我们断言我们的汽车“有一个工作的喇叭”时,我们可以这样提炼:

当驾驶员至少举起一只手并指示手按下方向盘中心 1 秒钟,汽车将发出大约 107 分贝的固定频率为 400 赫兹的响亮声音 1 秒钟。

当我们开始为我们的断言添加关键细节时,它们对我们变得有用。我们可以将它们用作实施的指南和功能的确认。即使有了这些额外的细节,我们的陈述仍然只是一个断言或要求。这些要求是软件设计中的一个有用步骤。事实上,我们应该非常不愿意在具体性达到这种程度之前开始实施软件。

例如,如果客户要求您实施一个付款表单,收集确切的要求是明智的:它应接受哪些类型的付款?还需要收集哪些其他客户信息?我们在存储这些数据时受到什么法规或约束?这些扩展的要求随后成为我们和客户将衡量完整性的标准。因此,我们可以将这些要求作为单独的测试来实施,以确认它们在软件中的存在。

一个良好的测试方法将涉及对代码库所有不同部分的测试,并将提供以下好处:

  • 证明实现:测试使我们能够向自己和利益相关者证明期望和要求得到满足。

  • 拥有 信心:测试使我们和我们的同事对我们的代码库有信心,既能正确运行,又能容纳变化而不会出现我们不知道的故障。

  • 分享 知识:测试允许我们分享关于代码部分如何运作的重要知识。在某种意义上,它们是一种文档形式。

良好的测试方法还有许多二阶效应。同事对代码库的增加信心将意味着您可以更快地进行更大的变更,从而在长远来看削减成本和痛苦。知识的共享可以使您的同事和用户更快地执行操作,更加理解,减少时间和费用的开销。证明实现目标的能力使团队和个人能够更好地向利益相关者、管理者和用户传达他们工作的价值。

现在我们已经讨论了测试的明显好处,我们可以讨论如何编写测试。每个测试的核心都是一组断言,所以我们现在将探讨我们所说的断言以及如何使用断言来编码我们的期望。

简单的断言

测试有许多工具、术语和测试范式。这么多复杂性的存在可能看起来令人生畏,但重要的是要记住,从本质上讲,测试实际上只是关于对某些东西的工作方式进行断言。

可以通过表达特定结果来以编程方式进行断言,如下例所示,要么是SUCCESS,要么是FAILURE

if (sum(100, 200) !== 300) {
  console.log('SUCCESS! :) sum() is not behaving correctly');
} else {
  console.log('FAILURE! :( sum() is behaving correctly');
}

在这里,如果我们的sum函数没有给出预期的输出,我们将收到FAILURE!的日志。我们可以通过实现一个assert函数来抽象这种成功和失败的模式,如下所示:

function assert(assertion, description) {
  if (assertion) {
    console.log('SUCCESS! ', description);
  } else {
    console.log('FAILURE! ', description);
  }
}

然后可以使用这个日志进行一系列的断言并添加描述:

assert(sum(1, 2) === 3, 'sum of 1 and 2 should be 3');
assert(sum(5, 60) === 65, 'sum of 60 and 5 should be 65');
assert(isNaN(sum(0, null)), 'sum of null and any number should be NaN');

这是任何测试框架或库的基本核心。它们都有一种机制来进行断言,并报告这些断言的成功和失败。测试库通常也提供一种机制来包装或包含相关的断言,并一起称之为测试测试用例。我们可以通过提供一个测试函数来做类似的事情,允许您传递描述和函数(包含断言):

function test(description, assertionsFn) {
  console.log(`Test: ${description}`);
  assertionsFn();
}

然后我们可以这样使用它:

test('sum() small numbers', () => {
  assert(sum(1, 2) === 3, 'sum of 1 and 2 should be 3');
  assert(sum(0, 0) === 0, 'sum of 0 and 0 should be 0');
  assert(sum(1, 8) === 9, 'sum of 1 and 8 should be 9');
});

test('sum() large numbers', () => {
  assert(
    sum(1e6, 1e10) === 10001000000,
    'sum of 1e6 and 1e10 should be 10001e6'
  );
});

运行后生成的测试日志如下:

> Test: sum() small numbers
> SUCCESS! sum of 1 and 2 should be 3
> SUCCESS! sum of 0 and 0 should be 0
> SUCCESS! sum of 1 and 8 should be 9
> Test: sum() large numbers
> SUCCESS! sum of 1e6 and 1e10 should be 10001e6

从技术角度来看,编写断言和简单测试并不太具有挑战性。为单个函数编写测试很少会很困难。然而,要编写完整的测试套件并彻底测试代码库的所有部分,我们必须利用更复杂的测试机制和方法来帮助我们。

许多移动部件

回想汽车类比,让我们想象我们面前有一辆汽车,我们希望测试它的喇叭。喇叭不是一个独立的机械部件。它嵌入在汽车内部,并且依赖于一个与其本身分离的电源。事实上,我们可能会发现,在喇叭工作之前,我们必须先通过点火启动汽车。而点火的成功本身取决于其他几个组件,包括工作的点火开关、油箱中的燃料、工作的燃油过滤器和未耗尽的电池。因此,喇叭的功能取决于许多移动部件。因此,我们对喇叭的测试不仅仅是对喇叭本身的测试,而实际上是对几乎整个汽车的测试!这并不理想。

为了解决这个问题,我们可以将喇叭连接到一个单独的电源供应上,只用于测试目的。通过这样做,我们隔离了喇叭,使测试只反映喇叭本身的功能。在测试世界中,我们使用的这个替身电源供应可能被称为存根模拟

在软件世界中,存根模拟都是一种代替真实抽象的类型,它提供适当的输出,而不执行被替换抽象的真实工作。一个例子是makeCreditCardPayment存根,它返回SUCCESS,而不创建真实的支付。这可能在测试电子商务功能的上下文中使用。

我们隔离喇叭电源的方法不幸地存在缺陷。即使我们的测试成功了,喇叭能够工作,我们也没有保证喇叭连接到汽车真正的电源时仍然能够工作。对喇叭的隔离测试仍然有用,因为它告诉我们喇叭特定电路和机制内的任何故障,但它本身是不够的。我们需要测试喇叭在实际情况下的工作情况,即依赖其他组件。在软件中,我们称这样的实际测试为集成测试端到端测试,而隔离测试通常称为单元测试。有效的测试方法将始终包括这两种类型:

在测试时隔离各个部分存在风险,可能会创建一个不真实的场景,最终你实际上并没有测试代码库的真实功能,而是测试了你的模拟的有效性。在这里,以我们的汽车类比为例,通过提供一个模拟电源来隔离喇叭,使我们能够纯粹地测试喇叭的电路和发声机制,并为我们提供了一条明确的调试路径,如果测试失败的话。但我们需要通过几个集成测试来补充这个测试,以便我们可以确信整个系统正常工作。即使我们对系统的所有部分进行了一千次单元测试,也不能保证没有测试所有这些部分的集成的工作系统。

测试类型

为了确保代码库经过了彻底的测试,我们必须进行不同类型的测试。正如前面提到的,单元测试使我们能够测试隔离的部分,而各种部分的组合可以通过集成功能端到端测试进行测试。首先了解我们谈论部分单元时的含义是很有用的。

当我们谈论代码的一个单元时,概念上有一些模糊。通常,它将是系统内具有单一职责的代码片段。当用户希望通过我们的软件执行操作时,实际上他们将激活我们代码的一系列部分,所有这些部分一起工作以给用户提供他们所需的输出。考虑一个用户可以创建和分享图像的应用程序。典型的用户体验(流程或旅程)可能涉及几个不同的步骤,所有这些步骤都涉及代码库的不同部分。用户执行的每个操作,通常在他们不知情的情况下,都将包含一系列代码操作:

  1. (用户)通过上传存储在桌面上的照片创建新图像:

  2. (代码)通过<form>上传照片

  3. (代码)将照片保存到 CDN

  4. (代码)在<canvas>中显示位图,以便应用滤镜

  5. (用户)对图像应用滤镜:

  6. (代码)通过<canvas>像素操作应用滤镜

  7. (代码)更新存储在 CDN 上的图像

  8. (代码)重新下载保存的图像

  9. (用户)与朋友分享图像:

  10. (代码)在数据库中查找用户的朋友

  11. (代码)将图像添加到每个朋友的动态中

  12. (代码)向所有朋友发送推送通知

所有这些步骤,再加上用户可能采取的所有其他步骤,可以被视为一个系统。一个经过充分测试的系统可能涉及对每个单独步骤进行单元测试,对每对步骤进行集成测试,以及对形成用户流用户旅程的每个步骤组合进行功能端到端E2E)测试。我们可以将可能需要作为系统一部分存在的测试类型可视化如下:

在这里,我们可以看到一个开始点和两个结束点,表示两个不同的用户旅程。每个点可以被视为一个单独的责任区域或单元,作为这些旅程的一部分被激活。正如您所看到的,单元测试只关注一个单一的责任区域。集成测试关注两个(或更多)相邻的整合区域。而 E2E 或功能测试关注涉及单一用户旅程的所有区域。在我们图像分享应用的前面例子中,我们可以想象我们可能有特定的单元测试,例如将照片上传到 CDN 或发送推送通知的操作,一个测试朋友数据库整合的集成测试,以及一个测试从创建到分享新图像的整个流程的 E2E 测试。这些测试方法对确保一个真正经过充分测试的系统至关重要,每种方法都有其独特的好处以及需要克服的缺点和挑战。

单元测试

正如我们在汽车类比中所描述的,单元测试是处理孤立的代码单元的测试。这通常是一个单一的函数或模块,将对代码的操作进行一个或多个简单的断言。

以下是一些单一单元测试场景的示例:

  • 您有一个Button组件,应该包含值Submit My Data,并且应该有一个btn_success类。您可以通过简单的单元测试来断言这些特征,检查生成的 DOM 元素的属性。

  • 您有一个任务调度实用程序,它将在请求的时间执行给定的操作。您可以通过给它一个在特定时间执行的任务,然后检查该任务的成功执行来断言它是否这样做。

  • 您有一个 REST API 端点/todo/list/item/{ID},它从数据库中检索特定的项目。您可以通过模拟数据库抽象(提供虚假数据),然后断言请求 URL 是否正确返回您的数据来断言该路由是否正常工作。

逐个测试代码单元的几个好处:

  • 完整性: 给定的单元通常会有一小部分明确定义的要求。因此,很容易确保您正在测试单元功能的全部范围。所有输入变化都可以很容易地进行测试。还可以测试每个单元的极限,包括通常复杂的操作细节。

  • 可报告性: 当给定的单元测试失败时,您可以很容易地辨别失败的确切性质和情况,这意味着更快地调试和修复潜在问题。这与我们将发现的集成测试形成对比,后者可能具有更通用的报告,无法指示代码中失败的确切点。

  • 理解: 单元测试是给定模块或函数的有用且独立的文档形式。单元测试的狭窄性和特定性帮助我们充分理解某些东西的工作原理,从而便于维护。当其他地方没有最新的文档时,这是特别有用的。

完整性在这里类似于流行的测试覆盖率概念。关键的区别在于,虽然覆盖率是关于最大化测试代码库中的代码量,完整性是关于最大化每个单元的覆盖率,以便表达单元的整个输入空间。作为一个度量标准,测试覆盖率只告诉我们是否进行了测试,而不告诉我们是否测试得很好。

然而,单元测试也存在挑战:

  • 正确模拟:创建正确隔离的单元测试有时意味着我们必须构建其他单元的模拟或存根,就像我们之前讨论的汽车类比一样。创建逼真的模拟并确保你没有引入新的复杂性和潜在故障有时是具有挑战性的。

  • 测试真实输入:编写提供各种真实输入的单元测试是关键,尽管这可能是具有挑战性的。很容易陷入编写看似给予信心但实际上不测试代码在生产中可能出现的情况的测试的陷阱。

  • 测试真正的单元而不是组合:如果不小心构建,单元测试可能会变得臃肿并变成集成测试。有时,一个测试在表面上看起来非常简单,但实际上取决于表面下一系列的集成。举个例子,如果我们试图在隔离其电路之前进行简单的单元测试来断言汽车喇叭的声音,那么我们将不知不觉地创建一个端到端测试。

作为最细粒度的测试类型,单元测试对于任何代码库都是至关重要的。最容易将其视为一种复式记账系统。当你进行更改时,你必须通过断言来反映这种变化。这种实现-测试循环最好是在接近的时间内进行——一个接一个地进行——可能通过 TDD,这将在后面讨论。单元测试是你确认自己真正写了你打算写的代码的方式。它提供了一定程度的确定性和可靠性,你的团队和利益相关者会非常感激。

集成测试

集成测试,顾名思义,涉及到代码的不同单元的集成。与简单的单元测试相比,集成测试将为您提供有关您的软件在生产中的运行方式的更有用的信号。在我们的汽车类比中,集成测试可能会根据它与汽车自己的电源供应的操作方式来断言喇叭的功能,而不是提供模拟电源供应。然而,它可能仍然是一个部分隔离的测试,确保它不涉及汽车内的所有组件。

以下是可能的集成测试的一些例子:

  • 你有一个Button组件,当点击时应该向列表中添加一个项目。一个可能的集成测试是在真实 DOM 中渲染组件,并检查模拟的click事件是否正确地将项目添加到列表中。这测试了Button组件、DOM 和确定何时向列表中添加项目的逻辑之间的集成。

  • 你有一个 REST API 路由/users/get/{ID},它应该从数据库中返回用户配置文件数据。一个可能的集成测试是创建一个 ID 为456的真实数据库条目,然后通过/users/get/456请求数据。这测试了 HTTP 路由抽象和数据库层之间的集成。

集成模块和测试它们的行为一起有很多优势:

  • 获得更好的覆盖率:集成测试将一个或多个集成模块作为测试对象,因此通过这样的测试,我们可以增加代码库中的“测试覆盖率”,这意味着我们正在增加代码的测试覆盖范围,从而增加我们捕捉故障的可能性。

  • 清晰地看到故障:在一定程度上模拟我们在生产中可能看到的模块集成,使我们能够看到实际发生的集成故障和失败。对这些故障的清晰视图使我们能够快速进行修复并保持可靠的系统。

  • 暴露错误的期望:集成测试使我们能够挑战在构建单个代码单元时可能做出的假设。

因此,虽然单元测试给我们提供了对特定模块和函数的输入和输出的狭窄和详细的视图,但集成测试使我们能够看到所有这些模块如何一起工作,并通过这样做,为我们提供了对集成潜在问题的视图。这是非常有用的,但编写集成测试也存在陷阱和挑战:

  • 隔离集成(避免大爆炸测试):在实施集成测试时,有时更容易避免隔离单个集成,而是只测试系统的一个大部分,所有集成都完整。这更类似于端到端测试,当然很有用,但同样重要的是也要有隔离的集成,以便您可以更精确地了解潜在的失败。

  • 真实的集成(例如,数据库服务器和客户端):在选择和隔离要测试的集成时,有时很难创建真实的情况。例如,测试您的 REST API 如何与数据库服务器集成,但是在测试目的上没有单独的数据库服务器,只有一个本地数据库服务器。这仍然是一个有见地的测试,但因为它没有模拟数据库服务器的远程性(在生产中存在),您可能会产生错误的信心。可能会有潜在的失败潜伏,未被发现。

集成测试在决定代码库的所有单独部分如何作为一个系统一起工作的关键接口和 I/O 的关键点提供了重要的洞察。集成测试通常提供了关于系统潜在故障的最明显的信号,因为它们通常运行速度快,并且在失败时非常透明(不像潜在笨重的端到端测试)。当然,集成测试只能告诉您它们封装的集成点的信息。为了更完全地对系统功能的信心,始终使用端到端测试是一个好主意。

端到端和功能测试

端到端测试是集成测试的一种更极端的形式,它不是测试模块之间的单个集成,而是测试整个系统,通常通过执行一系列在现实中会发生的操作来产生给定的结果。这些测试有时也被称为功能测试,因为它们致力于从用户的角度测试功能区域。构建良好的端到端测试使我们确信整个系统正常工作,但当与更粒度的单元和集成测试结合使用时,可以更快速和准确地识别故障。

以下是编写端到端测试的好处的简要概述:

  • 正确性和健康:端到端测试为您提供了对系统整体健康状况的清晰洞察。由于许多单独的部分将通过典型的端到端测试进行有效测试,其成功可以给您一个良好的指示,表明在生产中一切都正常。粒度单元或集成测试虽然在其自身的方式上非常有用,但无法给您这种系统洞察力。

  • 真实的效果:通过端到端测试,我们可以尝试更真实的情况,模拟我们的代码在野外运行的方式。通过模拟典型用户的流程,端到端测试可以突出显示更粒度的单元或集成测试可能无法揭示的潜在问题。例如,当存在竞争条件或其他时间问题时,只有当代码库作为一个整体系统运行时才能揭示这些问题。

  • 更全面的视角:E2E 测试为开发人员提供了系统的视角,使他们能够更准确地推断出不同模块如何共同产生工作的用户流程。当试图建立对系统操作方式的全面理解时,这是非常有价值的。与单元测试和集成测试一样,E2E 测试也可以作为一种文档形式。

然而,制作 E2E 测试也存在挑战:

  • 性能和时间成本:E2E 测试涉及激活许多个别代码片段并置身于真实环境中,因此在时间和硬件资源方面可能会非常昂贵。E2E 测试运行所需的时间可能会妨碍开发,因此团队为了避免开发周期变慢,避免 E2E 测试并不罕见。

  • 真实的步骤:在 E2E 测试中准确模拟真实生活中的情况可能是一种挑战。使用虚假或捏造的情况和数据仍然可以提供足够真实的测试,但也可能给你一种虚假的信心。由于 E2E 测试是脚本化的,不仅依赖于虚假数据是相当常见的,而且还可能以一种不真实的快速或直接的方式进行操作,错过了通过创建更人性化情况获得的可能的见解(重复:永远考虑用户)。

  • 复杂的工具:E2E 测试的目的是真实地模拟用户在实际环境中的流程。为了实现这一点,我们需要良好的工具,使我们能够建立真实的环境(例如,无头浏览器和可编写脚本的浏览器实例)。这样的工具可能存在 bug 或者使用起来很复杂,并且可能会引入另一个变量到测试过程中,导致不真实的失败(工具可能会给出关于事物是否真正工作的错误信号)。

尽管 E2E 测试很难做到完美,但它可以提供一种洞察和信心,这是仅仅通过单元测试和集成测试很难获得的。在自动化测试程序方面,E2E 测试是我们可以合理获得软件真实用户反馈的最接近方式。这是最不精细、最系统化的方式来判断我们的软件是否按照用户的期望工作,这毕竟是我们最感兴趣的。

测试驱动开发

TDD 是一种在实现之前编写测试的范式。通过这样做,我们的测试最终会影响我们实现的设计和接口。通过这样做,我们开始将测试视为不仅是一种文档形式,而且是一种规范形式。通过我们的测试,我们可以指定我们希望某些功能的工作方式,编写断言,就好像功能已经存在一样,然后我们可以逐步构建实现,使我们所有的测试最终都通过。

为了说明 TDD,让我们想象一下我们希望实现一个单词计数功能。在实现之前,我们可以开始写一些关于它如何工作的断言:

assert(
  wordCount('Lemonade and chocolate') === 3,
  '"Lemonade and chocolate" contains 3 words'
);

assert(
  wordCount('Never-ending long-term') === 2,
  'Hyphenated words count as singular words'
);

assert(
  wordCount('This,is...a(story)') === 4,
  'Punctuation is treated as word boundaries'
);

这是一个非常简单的函数,所以我们只用了三个断言来表达它的大部分功能。当然还有其他边缘情况,但我们已经拼凑出了足够的期望,可以开始实现这个函数了。这是我们的第一次尝试:

function wordCount(string) {
  return string.match(/[\w]+/g).length;
}

立即通过我们的小测试套件运行这个实现,我们收到了以下结果:

SUCCESS! "Lemonade and chocolate" contains 3 words
FAILURE! Hyphenated words count as singular words
SUCCESS! Punctuation is treated as word boundaries

连字符单词测试失败了。TDD 的本质是期望通过迭代的失败和重构来使实现与测试套件保持一致。鉴于这个特定的失败,我们可以简单地在正则表达式的字符类中添加一个连字符(在[...]分隔符之间):

function wordCount(string) {
  return string.match(/[\w-]+/g).length;
}

这产生了以下的测试日志:

SUCCESS! "Lemonade and chocolate" contains 3 words
SUCCESS! Hyphenated words count as singular words
SUCCESS! Punctuation is treated as word boundaries

成功!通过逐步迭代,尽管为了说明简化,我们已经通过 TDD 实现了一些东西。

正如你可能已经观察到的,TDD 并不是一种特定类型或风格的测试,而是一种关于何时如何为什么进行测试的范式。传统观点认为测试是事后的想法,这种观点是有限的,通常会迫使我们处于这样一种境地:我们根本没有时间编写一个好的测试套件。然而,TDD 迫使我们以一个完整的测试套件为先导,给我们带来了一些显著的好处:

  • 它指导实施

  • 它优先考虑用户

  • 它强制进行完整的测试覆盖

  • 它强制单一责任

  • 它能够快速发现问题领域

  • 它给予你即时反馈

TDD 在开始测试时是一种特别有用的范式,因为它会迫使你在实施之前退后一步,真正考虑你想要做什么。这个规划阶段对于确保我们的代码与用户期望完全一致非常有帮助。

总结

在本章中,我们介绍了测试的概念以及它与软件的关系。虽然简短和入门级,但这些基础概念对于我们以可靠性和可维护性为目标进行测试是至关重要的。测试,就像软件世界中的许多其他问题一样,可能会容易地变成一种迷信,因此保持对我们编写的测试背后的基本原理和理论的视角至关重要。测试,本质上是关于证明期望和防范故障的。我们已经讨论了单元测试、集成测试和端到端测试之间的区别,讨论了每种测试中固有的优势和挑战。

在下一章中,我们将探讨如何将这些知识应用于制定干净的测试和实际示例。具体来说,我们将介绍我们可以使用哪些措施和指导原则来确保我们的测试和其中的断言是可靠的、直观的和最大程度有用的。

第十四章:写清晰的测试

在上一章中,我们讨论了软件测试的理论和原则。我们深入探讨了单元测试、集成测试和端到端测试中固有的好处和挑战。在本章中,我们将把这些知识应用到一些现实世界的例子中。

仅仅理解测试是什么,并从商业角度看到它的优点是不够的。我们编写的测试构成了我们代码库的重要部分,因此应该以与我们编写的所有其他代码一样小心的方式来制作。我们希望编写的测试不仅能让我们对代码的预期工作方式有信心,而且它们本身也是可靠的、高效的、可维护的和可用的。我们还必须警惕编写过于复杂的测试。这样做会让我们陷入一种情况,使我们的测试增加了理解的负担,并导致代码库的整体复杂性和脆弱性增加,降低了整体生产力和满意度。

如果小心谨慎地使用,测试可以使代码库变得清晰和干净,从而使用户和同事能够以更快的速度和更高的质量进行工作。在接下来的章节中,我们将探讨编写测试时应遵循的最佳实践以及要避免的潜在陷阱。

在本章中,我们将涵盖以下主题:

  • 测试正确的事情

  • 编写直观的断言

  • 创建清晰的层次结构

  • 提供最终的清晰度

  • 创建清晰的目录结构

测试正确的事情

在编写任何测试时,无论是细粒度的单元测试还是广泛的端到端测试,最重要的考虑之一是要测试什么。完全有可能测试错误的东西;这样做会让我们对我们的代码产生错误的信心。我们可能编写了一个庞大的测试套件,然后面带微笑离开,认为我们的代码现在满足了所有期望,并且完全容错。但我们的测试套件可能并没有测试我们认为它测试的东西。也许它只测试了一些狭窄的用例,让我们暴露在许多破坏的可能性中。或者它可能以一种在现实中从未模拟的方式进行测试,导致我们的测试无法保护我们免受生产中的故障。为了防范这些可能性,我们必须了解我们真正希望测试什么。

考虑一个我们编写的函数,从任意字符串中提取指定格式的电话号码。电话号码可以是各种形式,但始终有 9 到 12 位数字:

  • 0800-144-144

  • 07792316877

  • 01263 109388

  • 111-222-333

  • 0822 888 111

这是我们当前的实现:

function extractPhoneNumbers(string) {
  return string.match(/(?:[0-9][- ]?)+/g);
}

我们决定编写一个测试来断言我们代码的正确性:

expect(
  extractPhoneNumbers('my number is 0899192032')
).toEqual([
  '0899192032'
]);

使用的断言至关重要。测试正确的事情很重要。在我们的例子中,这应该包括包含完整输入的示例字符串:包含电话号码的字符串,不包含数字的字符串,以及包含电话号码和非电话号码的字符串。仅测试正例太容易了,但实际上检查负例同样重要。在我们的场景中,负例包括没有电话号码可提取的情况,因此可能包含以下字符串:

  • "this string is just text..."

  • "this string has some numbers (012), but no phone numbers!"

  • "1 2 3 4 5 6 7 8 9"

  • "01-239-34-32-1"

  • "0800 144 323 492 348"

  • "123"

当编写这样的示例案例时,我们很快就会看到我们的实现将不得不迎合的复杂性范围。顺便说一句,这突显了采用测试驱动开发TDD)来明确定义期望的巨大优势。现在我们有了一些包含我们不希望*提取的数字的字符串的案例,我们可以将这些表达为断言,就像这样:

expect(
  extractPhoneNumbers('123')
).toEqual([/* empty */]);

目前这个测试失败了。extractPhoneNumbers('123')调用错误地返回["123"]。这是因为我们的正则表达式尚未对长度做出任何规定。我们可以很容易地进行修复:

function extractPhoneNumbers(string) {
  return string.match(/([0-9][- ]?){9,12}/g);
}

添加{9,12}部分将确保前面的组(([0-9][- ]?))只匹配 9 到 12 次,这意味着我们对extractPhoneNumbers('123')的测试现在将正确返回[](一个空数组)。如果我们对每个示例字符串重复进行这个测试和迭代过程,最终我们将得到一个正确的实现。

从这种情况中得出的关键是,我们应该寻求测试我们可能期望的所有输入。根据我们正在测试的内容,通常可以说我们编写的任何代码都将适用于有限的一组可能场景。我们希望确保我们有一组测试来分析这一系列场景。这一系列场景通常被称为给定函数或模块的输入空间输入域。如果我们将其暴露给其输入空间中的代表性各种输入,我们可以认为它经过了充分测试,这种情况下,包括具有有效电话号码和不具有有效电话号码的字符串:

不需要测试每种可能性。更重要的是测试它们的代表性样本。为此,首先要确定我们的输入空间,然后将其分成单个代表性输入,然后逐个进行测试。例如,我们需要测试电话号码"012 345 678"是否被正确识别和提取,但对同一格式的变化进行详尽测试(如"111 222 333""098 876 543"等)是没有意义的。这样做不太可能揭示代码中的任何其他错误或漏洞。但我们确实应该测试具有不同标点符号或空格的其他格式(如"111-222-333""111222333")。另外,建立可能超出预期输入空间的输入也很重要,例如无效类型和不受支持的值。

对软件需求的充分理解将使您能够产生一个经过正确实现并经过充分测试的实现。因此,在我们开始编写代码之前,我们应该始终确保我们清楚地知道我们的任务是什么。如果我们发现自己不确定完整的输入空间可能是什么,那就是一个强烈的指示,表明我们应该退一步,与利益相关者和用户交谈,并建立一套详尽的需求。再次强调,这是测试驱动的实施(TDD)的一个强大优势,因为这些需求的不足会在成本投入到无意义的实施之前被及早发现和解决。

当我们心中有了需求,并对整个输入空间有了很好的理解后,就可以开始编写我们的测试了。测试的最基本部分是其断言,因此我们要确保能够有效地制定直观的断言,以清晰地传达我们的期望。这将是接下来要讨论的内容。

编写直观的断言

任何测试的核心都是其断言。断言准确地规定了我们期望发生的事情,因此不仅要准确地制定它,而且要以一种清晰地表达我们期望的方式来制定它。

通常,单个测试会涉及多个断言。测试通常遵循以下形式:给定输入 X,我是否收到输出 Y?有时,建立Y是复杂的,可能不限于单个断言。我们可能希望内省Y,以确认它确实是期望的输出。

考虑一个名为getActiveUsers(users)的函数,它将从所有用户中仅返回活跃用户。我们可能希望对其输出进行多个断言:

const activeUsers = getActiveUsers([
  { name: 'Bob', active: false },
  { name: 'Sue', active: true },
  { name: 'Yin', active: true }
]);

assert(activeUsers.length === 2);
assert(activeUsers[0].name === 'Sue');
assert(activeUsers[1].name === 'Yin');

在这里,我们清楚地表达了对 getActiveUsers(...) 输出的期望,作为一系列断言。鉴于更全面的断言库或更复杂的代码,我们可以轻松地将其限制为一个单一的断言,但将它们分开可能更清晰。

许多测试库和实用程序提供了抽象来帮助我们进行断言。例如,流行的测试库 Jasmine 和 Jest 都提供了一个名为 expect 的函数,它提供了许多 匹配器 的接口,每个匹配器都允许我们声明值应该具有的特征,如以下示例所示:

  • expect(x).toBe(y) 断言 xy 相同

  • expect(x).toEqual(y) 断言 x 等于 y(类似于抽象相等)

  • expect(x).toBeTruthy() 断言 x 为真(或 Boolean(x) === true

  • expect(x).toThrow() 断言当作为函数调用 x 时,会抛出错误

这些匹配器的确切实现可能因库而异,提供的抽象和命名也可能不同。例如,Chai.js 提供了 expect 抽象和简化的 assert 抽象,允许您以以下方式进行断言:

assert('foo' !== 'bar', 'foo is not bar');
assert(Array.isArray([]), 'empty arrays are arrays');

制作断言时最重要的是要非常清晰。就像其他代码一样,很不幸,写一个难以理解或难以解析的断言是相当容易的。考虑以下断言:

chai.expect( someValue ).to.not.be.an('array').that.is.not.empty;

由于 Chai.js 提供的抽象,这个语句看起来像是一个可读性强、易于理解的断言。但实际上,确切地理解正在发生什么是相当困难的。让我们考虑这个语句可能正在检查以下哪一个:

  • 该项不是数组?

  • 该项不是空数组?

  • 该项的长度大于零且不是数组?

实际上,它正在检查该项既不是数组,又不为空——这意味着,如果该项是对象,它将检查它至少有一个自己的属性,如果是字符串,它将检查它的长度是否大于零。这些断言的真正基本机制被掩盖了,因此当程序员接触到这些东西时,可能会陷入一种幸福的无知状态(认为断言按照他们希望的方式工作)或痛苦的困惑状态(想知道它到底是如何工作的)。

也许我们一直想要断言的是 someValue 不仅不是数组,而且是“类似数组”,因此具有大于零的长度。因此,我们可以使用 Chai.js 的 lengthOf 方法来创建一个新的断言,以增加清晰度:

chai.expect( someValue ).to.not.be.an('array');
chai.expect( someValue ).to.have.a.lengthOf.above(0);

为了避免任何疑惑和混淆,我们可以更直接地进行断言,而不依赖于 Chai.js 的类似句子的抽象:

assert(!Array.isArray(someValue), "someValue is not an array");
assert(someValue.length > 0, "someValue has a length greater than zero");

这可能更清晰,因为它向程序员解释了正在进行的确切检查,消除了更抽象的断言风格可能引起的疑虑。

一个好的断言的关键在于它的清晰度。许多库提供了复杂和抽象的断言机制(例如通过 expect() 接口)。这些可以增加清晰度,但如果过度使用,可能会变得不太清晰。有时,我们只需要“保持简单,愚蠢”(KISS)。测试代码是最不适合使用自负或过度抽象的代码的地方。简单直接的代码每次都胜出。

现在我们已经探讨了制作直观断言的挑战,我们可以稍微“放大”一下,看看我们应该如何制作和组织包含它们的测试。下一节将介绍 层次结构 作为一个有助于通过我们的测试套件传达含义的机制。

创建清晰的层次结构

要测试任何代码库,我们可能需要编写大量的断言。从理论上讲,我们可以有一个很长的断言列表,除此之外什么也没有。然而,这样做可能会使阅读、编写和分析测试报告变得非常困难。为了避免这种混乱,测试库通常会在断言周围提供一些支撑抽象。例如,Jasmine 和 Jest 等 BDD 风格的库提供了两个支撑部分:it块和describe块。这些只是我们传递描述和回调的函数,但它们一起可以创建一个测试的分层树,使我们更容易理解发生了什么。使用这种模式来测试sum函数可能会这样做:

// A singular test or "spec":
describe('sum()', () => {
  it('adds two numbers together', () => {
    expect( sum(8, 9) ).toEqual( 17 );
  });
});

行为驱动开发BDD)是一种测试风格和方法,类似于 TDD,它强制我们先编写测试,然后再实现。然而,它更注重行为而不是实现的重要性,因为行为更容易沟通,从用户(或利益相关者)的角度来看更重要。BDD 风格的测试通常会使用诸如*描述 X»当 Z 发生时,它会执行 Y...*的语言。

非 BDD 库往往用更简单的无限嵌套的test块来包围断言组,如下所示:

test('sum()', () => {
  test('addition works correctly', () => {
    assert(sum(8, 9) == 17, '8 + 9 is equal to 17');
  });
});

正如你所看到的,BDD 风格的itdescribe术语的命名可以帮助我们为测试套件编写描述,这些描述读起来像完整的英语句子(例如描述一个苹果»它又圆又甜)。这并不是强制的,但可以帮助我们更好地描述。我们还可以无限嵌套describe块,以便我们的描述可以反映我们正在测试的事物的分层结构。因此,例如,如果我们正在测试一个名为myMathLib的数学工具,我们可以想象以下测试套件及其各种子套件和规范:

  • 描述myMathLib

  • 描述add()

  • 它可以添加两个整数

  • 它可以添加两个分数

  • 对于非数字输入,它返回NaN

  • 描述subtract()

  • 它可以减去两个整数

  • 它可以减去两个分数

  • 对于非数字输入,它返回NaN

  • 描述PI

  • 它在十五位小数处等于PI

这种层次结构自然地反映了我们正在测试的抽象的概念层次结构。测试库提供的报告将有用地反映这种层次结构。以下是Mocha测试库的一个示例输出,其中myMathLib的每个测试都成功通过:

myMathLib
  add()
    ✓ can add two integers
    ✓ can add two fractions
    ✓ returns NaN for non-numeric inputs
  subtract()
    ✓ can subtract two integers
    ✓ can subtract two fractions
    ✓ returns NaN for non-numeric inputs
  PI
    ✓ is equal to PI at fifteen decimal places

单个断言汇聚在一起形成测试。单个测试汇聚在一起形成测试套件。每个测试套件都为我们提供了关于特定单元、集成或流程(在 E2E 测试中)的清晰和信心。这些测试套件的组成对于确保我们的测试简单和可理解至关重要。我们必须花时间考虑如何表达我们正在测试的概念层次结构。我们创建的测试套件还需要直观地放置在代码库的目录结构中。这是我们接下来要探讨的内容。

提供最终的清晰度

可以说,测试的目标只是描述你所做的事情。通过描述,你被迫断言关于某事操作方式的假设真相。当这些断言被执行时,我们可以辨别出我们的描述,我们的假设的真相是否正确地反映了现实。

在描述的过程中,我们必须谨慎选择措辞,以便清晰和易于理解地表达我们的意思。测试是我们对模糊和复杂性的最后一道防线。有些代码是不可避免地复杂的,我们理想情况下应该以减少其模糊性的方式来构建它,但如果我们无法完全做到这一点,那么测试的作用就是消除任何剩余的困惑,并提供最终的清晰度。

在测试时保持清晰的关键是纯粹专注于必须阅读测试(或其记录输出)的人的视角。以下是一些特定的清晰度要点需要注意:

  • 使用测试的名称准确描述测试的内容,必要时过度描述。例如,不要说测试 Navigation 组件是否渲染,而是说测试 Navigation 组件是否正确渲染所有导航项。我们的名称也可以传达我们问题域的概念层次结构。回想一下我们在第五章的一致性和层次结构部分中所说的内容,命名是困难的

  • 使用变量作为意义的载体。在编写测试时,使用变量名过于明确或者甚至在可能不需要的地方使用变量,以充分传达你的意图是一个好主意。例如,考虑expect(value).toEqual(eulersNumber)expect(value).toEqual(2.7182818)更容易理解。

  • 使用注释来解释奇怪的行为。如果你正在测试的代码以一种意外或不直观的方式执行某些操作,那么你的测试本身可能会显得不直观。作为最后的手段,提供额外的上下文和解释是很重要的。但是要注意,不要让注释变得陈旧,而不随着代码的更新而更新。

考虑AnalogClockComponent的以下测试:

describe('AnalogClockComponent', () => {
  it('works', () => {
    const r = render(AnalogClockComponent, { time: "02:50:30" });
    expect(rendered.querySelector('.mm-h').style.transform)
      .toBe('rotate(210deg)');
    expect(rendered.querySelector('.hh-h').style.transform)
      .toBe('rotate(-30deg)');
    expect(rendered.querySelector('.ss-h').style.transform)
      .toBe('rotate(90deg)');
    expect(/\btheme-default\b/).test(rendered.className)).toBe(true);
  });
});

正如你所看到的,这个测试对特定元素的transform CSS 属性做出了几个断言。我们可能可以对这些做出一个知情的猜测,但是清晰度肯定可以得到改善。为了使其更清晰,我们可以使用更好的名称来反映我们正在测试的内容,将测试分开以代表不同的被测试概念,使用变量名来清楚地说明我们正在做断言的值,使用注释来解释任何可能不直观的事情:

describe('AnalogClockComponent', () => {

  const analogClockDOM = render(AnalogClockComponent, {
    time: "02:50:30"
  });

  const [
    hourHandTransform,
    minuteHandTransform,
    secondHandTransform
  ] = [
    analogClockDOM.querySelector('.hh-h').style.transform,
    analogClockDOM.querySelector('.mm-h').style.transform,
    analogClockDOM.querySelector('.ss-h').style.transform
  ];

  describe('Hands', () => {

    // Note: the nature of rotate/deg in CSS means that a
    // time of 03:00:00 would render its hour-hand at 0deg.

    describe('Hour', () => {
      it('Renders at -30 deg reflecting 2/12 hours', () => {
        expect(hourHandTransform).toBe('rotate(-30deg)');
      });
    });
    describe('Minute', () => {
      it('Renders at 210 deg reflecting 50/60 minutes', () => {
        expect(minuteHandTransform).toBe('rotate(210deg)');
      });
    });
    describe('Second', () => {
      it('Renders at 90deg reflecting 30/60 seconds', () => {
        expect(secondHandTransform).toBe('rotate(90deg)');
      });
    });
  });

  describe('Theme', () => {
    it('Has the default theme set', () => {
      expect(
        /\btheme-default\b/).test(analogClockDOM.className)
      ).toBe(true);
    });
  });

});

你可能会观察到更清晰的方式要长得多,但是在测试时,最好偏向于这种冗长的描述。过度描述要比不足描述好,因为在后一种情况下,你的同事们会缺乏信息,他们会摸不着头脑,可能会对功能性做出错误的猜测。当我们提供大量的清晰度和解释时,我们正在帮助更广泛的同事和用户。然而,如果我们模糊和简洁,我们特别是限制了能理解我们代码的人群,从而限制了其可维护性和可用性。

现在我们已经探讨了通过良好结构的测试套件来展示最终的清晰度,我们可以再次放大,讨论我们如何通过目录结构和文件命名约定来传达我们正在编写的测试的目的和类型。

创建清晰的目录结构

我们的测试套件通常应该限制在单个文件中,以划分出我们的程序员同事关注的领域。尽管将这些测试文件组织成更大代码库的一部分可能是一个挑战。

想象一个具有以下目录结构的小型 JavaScript 代码库:

app/
  components/
    ClockComponent.js
    GalleryComponent.js
  utilities/
    timer.js
    urlParser.js

将与特定代码相关的测试放置在靠近该代码所在位置的子目录中是相当典型的。在我们的示例代码库中,我们可以创建以下tests子目录来包含我们componentsutilities的单元测试:

app/
  components/
    ClockComponent.js
    GalleryComponent.js
    tests/
      ClockComponent.test.js
      GalleryComponent.test.js
  utilities/
    timer.js
    urlParser.js
    tests/
      timer.test.js
      urlParser.test.js

以下是一些关于约定的额外说明,正如我们现在应该知道的那样,这些约定对于增加代码库的熟悉度和直观性至关重要,因此也对整体清晰度至关重要:

  • 有时测试被称为规范(specifications)。规范通常与测试没有什么不同,尽管作为名称,在 BDD 范式中稍微更受青睐。使用你感到舒适的那个。

  • 通常会看到测试文件的后缀是.test.js.spec.js。这样你的测试运行器可以轻松识别要执行的文件,对我们的同事也是一个有用的提醒。

  • 看到测试目录的命名模式涉及下划线或其他非典型字符并不罕见,例如__tests__。这些命名模式通常用于确保这些测试不会作为主要源代码的一部分被编译或捆绑,并且可以很容易地被我们的同事辨别。

  • 端到端或集成测试更常放置在更高的层次,这暗示了它们对多个部分的依赖。很常见看到一个高级别的e2e目录(或一些改编)。有时,集成测试被单独命名并存储在高层;其他时候,它们与单元测试交错存放在代码库中。

一次又一次,层次结构在这里是关键。我们必须确保我们目录的层次结构有助于反映我们代码和问题域的概念层次结构。作为代码库中平等且重要的一部分,测试应该被小心地和适当地放置在代码库中,而不是作为事后的想法。

总结

在本章中,我们将我们对测试的理论知识应用到了构建真实、有效和清晰的测试套件的实际技艺中。我们看了一些在这样做时存在的陷阱,并且我们强调了要努力追求的重要品质,比如清晰、直观的命名和遵循惯例。

在下一章中,我们将探讨各种工具,从代码检查器到编译器,以及更多,来帮助我们编写更干净的代码!

第十五章:更干净代码的工具

我们使用的工具对我们编写代码时养成的习惯有很大影响。在编码时,就像生活中一样,我们希望养成良好的习惯,避免坏习惯。良好习惯的一个例子是编写符合语法的 JavaScript。为了帮助我们强制执行这个良好习惯,我们可以使用代码检查工具在我们的代码无效时通知我们。我们应该以这种方式考虑每个工具。它激发了什么良好习惯?它又抑制了什么坏习惯?

如果我们回顾一下我们原始的清晰代码原则(R.E.M.U),我们可以看到各种工具如何帮助我们遵守这些原则。以下是一小部分对这四个原则有帮助的工具:

  • 可靠性:测试工具、用户反馈、错误记录器、分析数据、代码检查工具、静态类型工具和语言

  • 效率:性能测量、分析数据、用户反馈、用户体验评估、生态成本(例如碳足迹

  • 可维护性:格式化程序、代码检查工具、文档生成器、自动化构建和持续集成

  • 可用性:分析数据、用户反馈、文档生成器、可访问性检查器、用户体验评估和走廊测试

激发良好习惯的工具通过增强我们的反馈循环。反馈循环是最终让您意识到需要做出改变的任何事物。也许您引入了一个导致错误日志的错误。也许您的实现不清晰,同事抱怨了。如果工具能及早捕捉到这些情况,那么它可以加快我们的反馈循环,使我们能够更快地工作并达到更高的质量水平。在下图中,我们说明了我们的反馈循环,以及它是如何在开发的每个阶段接收来自工具的信息的:

在我们的开发阶段,有许多反馈渠道。有代码检查工具告诉我们语法有问题,静态类型检查器确认我们正确使用类型,测试确认我们的期望。即使在部署后,这种反馈仍在继续。我们有错误日志指示失败,分析数据告诉我们用户行为,以及来自最终用户和其他个人的反馈,告诉我们有关故障或改进的领域。

不同的项目将以不同的方式运作。您可能是一个独立的程序员,或者是专门从事特定项目的 100 名程序员之一。无论如何,可能会有各种开发阶段,并且在每个阶段都存在反馈的可能性。工具和沟通对于有效的反馈循环至关重要。

在本章中,我们将介绍一小部分可以帮助我们养成良好习惯和积极反馈循环的工具。具体来说,我们将介绍以下内容:

  • 代码检查和格式化工具

  • 静态类型

  • 端到端测试工具

  • 自动化构建和持续集成

代码检查和格式化工具

代码检查工具是一种用于分析代码并发现错误、语法错误、风格不一致和可疑结构的工具。JavaScript 的流行代码检查工具包括 ESLintJSLintJSHint

大多数代码检查工具允许我们指定我们想要查找的错误或不一致的类型。例如,ESLint 将允许我们为给定代码库指定全局配置在根级别的 .eslintrc(或 .eslintrc.json)文件中。在其中,我们可以指定我们正在使用的语言版本,我们正在使用的功能,以及我们想要强制执行的代码检查规则。以下是一个示例 .eslintrc.json 文件:

{
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true
     }
  },
  "extends": "eslint:recommended",
  "rules": {
    "semi": "error",
    "quotes": "single"
  }
}

以下是我们配置的解释:

  • ecmaVersion:在这里,我们指定我们的代码基于 ECMAScript 6(2016)版本的 JavaScript 编写。这意味着如果 linter 发现您正在使用 ES6 特性,它不会抱怨。但是,如果您使用 ES7/8 特性,它会抱怨,这是您所期望的。

  • sourceType:这指定了我们正在使用 ES 模块(导入和导出)。

  • ecmaFeatures: 这告诉 ESLint 我们希望使用 JSX,这是一种允许我们指定类似 XML 的层次结构的语法扩展(这在像 React 这样的组件框架中被广泛使用)。

  • extends: 在这里,我们指定了一个默认的规则集"eslint:recommended",这意味着我们愿意让 ESLint 强制执行一组推荐的规则。如果没有这个,ESLint 只会执行我们指定的规则。

  • rules: 最后,我们正在配置我们希望在推荐配置之上设置的具体规则:

  • semi: 这个规则涉及到分号;在我们的覆盖中,我们指定如果缺少分号则产生错误而不是仅仅警告。

  • quotes: 这个规则涉及到引号,并指定我们希望强制使用单引号,这意味着 linter 会在我们的代码中看到双引号时发出警告。

我们可以通过编写一个故意违反规则的代码片段来尝试我们的配置:

const message = "hello"
const another = `what`

if (true) {}

如果我们在这段代码上安装并运行 ESLint(在 bash 中:> eslint example.js),那么我们将收到以下内容:

/Users/me/code/example.js
 1:7 error 'message' is assigned a value but never used 
 1:17 error Strings must use singlequote 
 1:24 error Missing semicolon
 2:7 error 'another' is assigned a value but never used
 2:17 error Strings must use singlequote
 2:23 error Missing semicolon
 4:5 error Unexpected constant condition 
 4:11 error Empty block statement

 8 problems (8 errors, 0 warnings)
 4 errors and 0 warnings potentially fixable with the `--fix` option.

这详细说明了根据我们配置的规则的所有语法错误。正如你所看到的,它详细说明了被违反的规则以及发现问题的行。ESLint 和其他 linting 工具在发现难以发现的语法错误方面非常有帮助,其中一些如果不加以处理,可能会导致将来难以调试的功能性错误。Linting 还使代码更加一致,使程序员感到熟悉,并减少认知负担,就像在一个具有许多不同语法约定的代码库中一样。

ESLint 还包括一个通过其--fix选项修复这些语法错误子集的功能,尽管您可能已经注意到只有一部分错误可以通过这种方式修复。其他错误需要手动修复。不过,值得庆幸的是,有许多更高级的工具可用来帮助我们。格式化工具,如 PrettierStandard JS,将采用我们的语法偏好并对我们的代码进行积极的更改,以确保它保持一致。这意味着程序员不必为特定的语法规则负担,也不必无休止地更改代码以响应 linters。他们可以按照自己的意愿编写代码,完成后,格式化程序将更改代码以符合约定的语法约定,或者在出现严重或无效的语法错误时警告程序员。

为了说明,让我们用默认配置在一个简单的代码片段上运行 Prettier:

function reverse( str ) {
  return ( String( str ).split( '' ).reverse().join( '' ) );
}

当在 Prettier 上运行上述代码时,我们会收到以下内容:

function reverse(str) {
  return String(str)
    .split("")
    .reverse()
    .join("");
}

正如我们所看到的,Prettier 已经删除并更改了我们的语法习惯以符合其配置的约定。换句话说,它已经将单引号换成双引号,删除了多余的括号,并对空格进行了重大更改。格式化工具的魔力在于它们帮助程序员摆脱痛苦。它们会纠正一些微小的语法习惯,让程序员可以自由地进行更重要的工作。行业的一般趋势是远离简单的 linters,转向更全面的工具,将 linting 和格式化结合在一起。

遵守哪种语法约定的决定是可配置的,完全取决于你。关于这个问题有很多坚定的观点,但最重要的原则是一致性。例如,我个人更喜欢单引号而不是双引号,但如果我在一个已经建立了双引号约定的代码库中工作,那么我会毫不犹豫地改变我的习惯。大多数时候,语法偏好只是主观的和传统的规范,所以重要的不是我们使用哪种规范,而是我们是否都遵守它。

我们已经习惯了 JavaScript 语言中的许多规范,这些规范是由其动态类型的特性引导的。例如,我们已经习惯了必须手动检查特定类型,以便在接口中提供有意义的警告或错误。对许多人来说,这些规范很难适应,他们渴望对他们使用的类型有更高的信心。因此,人们将各种静态类型工具和语言扩展引入了 JavaScript。接下来我们将探讨这些内容,同时注意这些静态类型工具如何改变或改进您的个人开发反馈循环。

静态类型

正如我们长时间探讨的那样,JavaScript 是一种动态类型语言。如果小心使用,这可能是一个巨大的好处,可以让您快速工作,并允许您的代码具有一定的灵活性,使同事能够更轻松地使用它。然而,动态类型可能会在某些情况下导致程序员的认知负担和不必要的 bug 可能性。静态类型编译语言,如 Java 或 Scala,强制程序员在声明的时候指定他们期望的类型(或者根据使用方式推断类型,以便在执行之前)。

静态类型具有以下潜在的好处:

  • 程序员可以对他们将要处理的类型有信心,因此可以对他们的值的能力和特性做出一些安全的假设,从而简化开发。

  • 代码可以在执行之前进行静态类型检查,这意味着潜在的 bug 可以轻松地被捕捉到,并且不会受到特定(和意外的)类型排列的影响。

  • 代码的维护者和用户(或其 API)有一个更清晰的期望集,并且不会猜测可能会或可能不会起作用。类型的规范本身可以作为一种文档。

尽管 JavaScript 是动态类型的,但已经有努力为 JavaScript 程序员提供静态类型系统的好处。其中两个相关的例子是 Flow 和 TypeScript:

  • Flow (flow.org/) 是 JavaScript 的静态类型检查器和语言扩展。它允许您使用自己特定的语法注释类型,尽管它不被认为是一种独立的语言。

  • TypeScript (www.typescriptlang.org/) 是由微软开发的 JavaScript 的超集语言(这意味着有效的 JavaScript 始终是有效的 TypeScript)。它是一种独立的语言,具有自己的类型注释语法。

Flow 和 TypeScript 都允许您声明正在声明的类型,可以是变量声明或函数内的参数声明。以下是一个接受productNamestring)和ratingnumber)的函数声明的示例:

function addRating(productName: string, rating: number) {
  console.log(
    `Adding rating for product ${productName} of ${rating}`
  );
}

Flow 和 TypeScript 通常允许在声明标识符后注释类型,形式为IDENTIFIER: TYPE,其中TYPE可以是numberstringboolean等。但它们在许多方面有所不同,因此重要的是要对两者进行调查。当然,Flow 和 TypeScript 以及 JavaScript 的大多数其他静态类型检查技术都需要构建编译步骤才能工作,因为它们包括语法扩展。

请注意,静态类型并不是一种灵丹妙药。我们代码的整洁程度不仅仅限于其避免与类型相关的错误和困难的能力。我们必须放大我们的视角,记得考虑用户以及他们通过我们的软件试图实现的目标。很常见看到热情的程序员迷失在他们的语法细节中,但忽略了更大的画面。因此,为了稍微改变方向,我们现在将探讨端到端测试工具,因为端到端测试对代码质量的影响可能与我们使用的类型系统或语法一样重要,甚至更重要!

端到端测试工具

在过去的几章中,我们探讨了测试的好处和类型,包括端到端测试的概述。我们通常用于构建测试套件和进行断言的测试库很少包括端到端测试功能,因此我们需要为此找到自己的工具。

端到端测试的目的是模拟用户在我们的应用程序上的行为,并在用户交互的各个阶段对应用程序的状态进行断言。通常,端到端测试将测试特定的用户流程,例如用户可以注册新帐户用户可以登录并购买产品。无论我们是在服务器端还是客户端使用 JavaScript,如果我们正在构建一个网络应用程序,进行这样的测试将是非常有益的。为此,我们需要使用一个可以人为创建用户环境的工具。在网络应用程序的情况下,用户环境是浏览器。幸运的是,有大量的工具可以模拟或运行真实(或无头**s)浏览器,我们可以通过 JavaScript 访问和控制。

无头浏览器是一个没有图形用户界面的网络浏览器。想象一下 Chrome 或 Firefox 浏览器,但没有任何可见的 UI,完全可以通过 CLI 或 JavaScript 库进行控制。无头浏览器允许我们加载我们的网络应用程序并对其进行断言,而无需无谓地消耗硬件能力来渲染 GUI(这意味着我们可以在我们自己的计算机上或在云端作为我们持续集成/部署过程的一部分来运行这些测试)。

这样一个工具的例子是Puppeteer,这是一个 Node.js 库,提供了一个控制 Chrome(或 Chromium)的 API。它可以在无头或非无头模式下运行。以下是一个示例,我们在其中打开一个页面并记录其<title>

import puppeteer from 'puppeteer';

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  const titleElement = await page.$('title');
  const title = await page.evaluate(el => el.textContent, titleElement);

  console.log('Title of example.com is ', title);

  await browser.close();
})();

Puppeteer 提供了一个高级 API,允许创建和导航浏览器页面。在这个上下文中,使用page实例,我们可以通过evaluate()方法评估特定的客户端 JavaScript。传递给此方法的任何代码将在文档的上下文中运行,并且因此可以访问 DOM 和其他浏览器 API。

这就是我们如何能够检索<title>元素的textContent属性。您可能已经注意到,Puppeteer 的 API 大部分是异步的,这意味着我们必须使用Promise#thenawait来等待每个指令的完成。这可能有些麻烦,但考虑到代码正在运行和控制整个网络浏览器,一些任务是异步的是有道理的。

端到端测试很少被接受,因为它被认为很难。虽然这种看法曾经是准确的,但现在不再是这样。有了像 Puppeteer 这样的 API,我们可以轻松地启动我们的网络应用程序,触发特定的操作,并对结果进行断言。以下是使用 Jest(一个测试库)与 Puppeteer 对https://google.com<title>元素中的文本进行断言的示例:

import puppeteer from 'puppeteer';

describe('Google.com', () => {

  let page;

  beforeAll(async () => {
      const browser = await puppeteer.launch();
      page = await browser.newPage();
      await page.goto('https://google.com');
  });

  afterAll(async () => await browser.close());

  it('has a <title> of "Google"', async () => {
    const titleElement = await page.$('title');
    const title = await page.evaluate(el => el.textContent, titleElement);
    expect(title).toBe('Google');
  });
});

获取页面、解析其 HTML,并生成我们可以进行断言的 DOM 是一个非常复杂的过程。浏览器在这方面非常有效,因此在我们的测试过程中利用它们是有意义的。毕竟,决定最终用户看到的是浏览器看到的内容。端到端测试为我们提供了对潜在故障的真实见解,现在编写或运行它们也不再困难。对于干净的代码编写者来说,它们尤其强大,因为它们让我们从更加用户导向的角度看到我们代码的可靠性。

与我们探索过的许多工具一样,端到端测试可能最好通过自动化集成到我们的开发体验中。我们现在简要探讨一下这一点。

自动化构建和持续集成

正如我们所强调的,有大量的工具可用于帮助我们编写干净的代码。这些工具通常可以通过命令行界面CLI)手动激活,有时也可以在我们的集成开发环境中激活。然而,通常情况下,将它们作为我们开发的各个阶段的一部分运行是明智的。如果使用源代码控制,那么这个过程将包括提交暂存过程,然后是推送检入过程。这些事件,与简单地对文件进行更改相结合,代表了我们的工具可以用来生成它们的输出的三个重要开发阶段。

  • 在对文件进行更改时:通常在这个阶段会发生 JavaScript(或 CSS)的转译或编译。例如,如果你正在编写包含 JSX 语言扩展(React)的 JS,那么你可能会依赖Babel来不断地将你的 JS 混合编译为有效的 ECMAScript(参见 Babel 的--watch命令标志)。当文件发生变化时,进行代码检查或其他代码格式化也很常见。

  • 在提交时:通常在提交前或提交后阶段会进行代码检查、测试或其他代码验证。这很有用,因为任何无效或损坏的代码都可以在推送之前被标记出来。在这个阶段进行资源生成或编译也并不罕见(例如,从 SASS 生成有效的 CSS,一种替代样式表语言)。

  • 在推送时:当新代码被推送到特性分支或主分支时,通常会在远程机器上发生所有过程(代码检查、测试、编译、资源生成等)。这被称为持续集成,它允许程序员在部署到生产环境之前看到他们的代码与同事的代码结合后会如何运行。用于持续集成的工具和服务的例子包括TravisCIJenkinsCircleCI

自动激活工具可以极大地简化开发,但这并不是必需的。你可以通过命令行进行代码检查、运行测试、转译 CSS,或生成压缩的资源,而无需费心自动化。然而,这样做可能会更慢,如果没有将工具标准化为一组自动化工具,那么你的团队中可能会出现工具使用不一致的情况。例如,你的同事可能总是在将 SCSS 转译为 CSS 之前运行测试,而你可能倾向于相反的方式。这可能导致不一致的 bug 和“在我的机器上可以运行”的情况。

总结

在本章中,我们已经发现了工具的用处,突出了它改进我们的反馈循环的能力,以及它如何赋予我们编写更干净代码的能力。我们还探索了一些具体的库和实用工具,让我们对存在的工具类型和以编程者的能力和习惯可以被增强的各种方式有了一些了解。我们尝试了代码检查器、格式化程序、静态类型检查器和端到端测试工具,并且我们已经看到了工具在开发的每个阶段的优点。

下一章开始我们的合作艺术和科学之旅;这对于想要编写清晰代码的人来说是至关重要的要素。我们将从探讨如何编写清晰易懂的文档开始。

第五部分:合作和变革

在这一部分,我们将涵盖与他人合作和沟通所涉及的重要技能,以及如何应对需要重构代码的情况。在这样做的过程中,我们将讨论文档编制、合作策略,以及如何识别和倡导团队、组织或社区的变革。

本节包括以下章节:

  • 第十六章,编写代码文档

  • 第十七章,其他人的代码

  • 第十八章,沟通和倡导

  • 第十九章,案例研究

第十六章:记录您的代码

文档有一个不好的名声。很难找到动力来写它,维护它是一种麻烦,多年来我们对它的接触使我们相信它是最枯燥的知识传递方法之一。然而,它不必这样!

如果我们选择完全关注用户,那么我们的文档可以简单而愉快。为此,我们必须首先考虑我们文档的用户是谁。他们想要什么?每个用户,无论是 GUI 最终用户还是其他程序员,都以某项任务为目标开始使用我们的软件。在软件和文档中,我们的责任是使他们能够尽可能少地感到痛苦和困惑地完成任务。考虑到这一点,在本章中,我们将探讨为我们构建无痛文档可能意味着什么。我们将具体涵盖以下内容:

  • 清晰文档的方面

  • 文档无处不在

  • 为非技术受众编写

清晰文档的方面

文档的目的是传达软件的功能如何使用它。我们可以将清晰文档的特点分为四个方面:清晰的文档传达了软件的概念,提供了其行为的规范,并包含了执行特定操作的说明。而且它所有这些都是以可用性为重点。通过本节的学习,我们希望能够理解在构建清晰文档时用户的重要性。

文档是大多数人不太关心的东西。它通常是一个事后想法。我在本章的任务是说服您,它可以是,也应该是,远不止如此。当我们进入这些方面时,忘记您对文档的了解-从一张白纸开始,看看您是否能得出自己的启示。

概念

清晰的文档将传达软件的基本概念。它将通过解释软件的目的的方式来做到这一点,以便潜在用户可以看到他们如何使用它。这可以被认为是文档的教育部分:阐明术语和范例,使读者能够轻松理解文档的其他部分和所描述的软件。

为了正确表达软件的概念,有必要站在用户的角度,从他们的角度看事情,并用他们的术语与他们交流:

  • 确定您的受众:他们是谁,他们的技术熟练程度如何?

  • 确定他们对问题领域的理解:他们对这个特定软件项目、API 或代码库已经了解多少?

  • 确定正确的抽象级别和最佳类比:如何以一种对他们有意义并与他们当前的知识融合良好的方式进行交流?

良好的文档编写是一个考虑用户然后为他们精心打造适当抽象的过程。您会希望注意到这与编写清晰代码的过程非常相似。实际上,两者之间几乎没有什么区别。在构建文档时,我们正在打造一个用户可以用来完成一组特定任务的工具。我们有责任以一种用户可以轻松完成最终目标而不会被软件的庞大和复杂所压倒的方式来打造它:

考虑一个花了几周时间完成的项目。这是一个名为SuperCoolTypeAnimatorJavaScriptJS)库,其他程序员可以使用它来创建字体转换。它允许他们向用户显示一块从一种字体动画到另一种字体(例如从 Helvetica 到 Times New Roman)的文本。这是一个相当复杂的代码库,可以手动计算这些转换。其复杂性的深度意味着您作为程序员已经发现了远远超出您所能想象的关于连字、衬线和路径插值的知识。在数月的沉浸在这个日益深入的问题领域之后,您很可能难以理解没有您这种程度接触的用户的观点。因此,您的文档的第一稿可能会以以下方式开始:

SuperCoolTypeAnimator 是一种 SVG 字形动画实用程序,允许创建和逐帧操纵源字形和其相应目标字形之间的过渡,并在飞行中计算适当的过渡锚点。

让我们将其与以下替代介绍进行比较:

SuperCoolTypeAnimator 是一个 JS 库,可以让您轻松地将文本的小部分从一种字体动画到另一种字体。

作为介绍,后者更广泛地可理解,并且即使是非专家用户也能立即理解库的功能。前者的介绍虽然信息丰富,但可能会导致当前和潜在用户感到困惑或疏远。我们构建的软件的整个目的是为了将复杂性抽象化,以简洁和简化的方式呈现出来。给用户带来复杂性应该是一种令人遗憾和考虑的行为:这通常是最后的选择。

我们在文档中试图传达的概念,首先是关于我们的软件如何帮助用户。为了让他们理解它如何帮助他们,我们需要以符合他们当前理解的方式来描述它。

两种介绍突出的另一个因素是它们对特殊术语的使用(如字形锚点)。使用这种领域特定的术语是一种平衡行为。如果您的用户对字体问题领域有很好的理解,那么字形字体等术语可能是合适的。可以说,对您的库感兴趣的用户很可能也了解这些概念。但是,使用过渡锚点等更微妙的术语可能有点过头。这可能是您在抽象中使用的术语,用来描述高度复杂的实现领域。这对您来说是一个有用的术语,也许对于希望对库进行更改的任何人来说也是有用的,但对库的用户来说可能不那么有用。因此,在我们的文档介绍中最好避免使用它。

规范

好的文档除了为软件提供概念外,还将提供规范,详细说明软件提供的接口的特定特征和行为。文档的这一部分详细说明了用户或程序员在使用软件时可以期望的合同。

规范理想情况下应该是撰写文档中最简单的部分,原因如下:

  • 它就在代码中:行为规范包含在代码及其测试中,通常很容易手动将此信息编写为文档。但是,如果编写起来很困难,那就表明您的代码及其接口中存在基本复杂性,可能应该作为优先事项进行修复。

  • 它可以自动生成:存在许多文档生成器,它们要么依赖于静态类型注释,要么依赖于注释注释(例如JSDoc)。这些允许您通过 CLI 或构建工具为整个接口生成文档。

  • 它遵循固定格式:规范将遵循一个简单易写的直接格式。它通常包含各个端点或方法签名的标题,以及解释每个参数的句子。

提供规范的最主要目的是回答用户可能对您的代码操作有的具体问题。

以下是一个名为removeWords的函数规范示例。

removeWords( subjectString, wordsToRemove ); 

此函数将从指定的主题字符串中删除指定的单词,并将一个新字符串返回给您。这里的单词被定义为由单词边界(\b)限定的字符串。例如,对于"I like apple juice"主题字符串和["app", "juice"]wordsToRemove,只会删除"juice",因为"app"存在于主题中,但没有被单词边界限定。以下是参数:

  • subjectString (String): 这是指定单词将从中移除的字符串。如果您没有传递String类型,那么您传递的值将被转换为String

  • wordsToRemove (Array): 这是一个包含您希望移除的单词的数组。空数组或 null 将导致没有单词被移除。

希望您能看出,这个规范纯粹是对函数行为的技术解释。它准确告诉用户他们必须提供什么参数以及他们将收到什么输出。在撰写文档规范部分时,最重要的品质是清晰和正确。要注意以下陷阱:

  • 没有足够的信息允许使用:提供关于您的实现的足够信息非常重要,这样另一个程序员,即使对您的软件一无所知,也可以开始使用它。仅仅指定参数类型是不够的。如果知识领域特别晦涩,还需要提供额外的信息。

  • 不正确或过时的信息:文档很容易过时或不正确。这就是为什么从带注释的代码自动生成文档非常常见的原因。这样,信息不正确或过时的几率会大大降低。

  • 缺乏示例:通常只列出模块、方法和参数签名,而没有提供任何示例。如果这样做,混乱和困难的几率会更高,因此提供合理的示例或将读者链接到更像教程的文档总是值得的。

规范可以说是文档中最重要的部分,因为它清晰地解释了软件相关 API 的每个部分的行为。确保在撰写文档时像编写代码一样仔细和勤奋。

指令

除了概念规范,一份干净的文档还将指导用户如何完成常见任务。这些通常被称为步骤教程操作指南食谱

主要是用户,无论是程序员还是最终用户,都关心如何从现在所在的位置到达他们想要的位置。他们想知道应该采取哪些步骤。如果没有常见用例的说明,他们将绝望地从直觉或其他文档片段中拼凑出对您的软件的了解。想象一本只详细说明了食材及其烹饪方式的烹饪书,但没有包含任何将食材按特定顺序组合的具体食谱。这将是一本难以使用的烹饪书。虽然它可能提供了高度详细的烹饪信息,但它并没有帮助用户回答他们实际的问题:

在撰写说明时,无论是视频教程还是书面步骤说明,都要考虑对用户来说最常见或最具挑战性的使用情况。与生活中的许多事情一样,您只能合理地满足大多数用户的需求,而不是所有用户。为每种可能的使用情况创建教程是不合理的。同样,从用户的角度来看,仅为最常见的使用情况提供单一教程也是不合理的。明智的做法是取得折衷,拥有一小部分教程,每个教程都表达:

  • 提前期望和先决条件:一组说明应该指明作者对读者的硬件、软件环境和能力有什么期望。它还应该说明读者在开始以下步骤之前是否需要做好任何准备。

  • 读者可以模仿的具体步骤:说明应该包括一些具体步骤,用户可以按照这些步骤达到他们期望的目标。用户在遵循这些步骤时不应该需要太多(或任何)主动性;这些步骤应该清楚而详尽地概述用户需要做什么,如果可能的话,附上代码示例。用户应该明显地知道他们已成功完成每个步骤(例如,“您现在应该收到 X 输出”)。

  • 可实现和可观察的目标:说明应该朝着用户可以观察到的目标努力。如果教程的最后一步说“由于 X 或 Y 的原因,目前无法正常工作,但通常您会期望看到 Z”,这将令人沮丧。确保您的软件以这样的方式运行,以便教程可以完成到最后,用户可以更接近他们的总体目标。

不要只告诉用户该做什么。告诉他们在每个阶段都完成了什么,以及为什么重要。也就是说,不要只告诉我在菜里放盐,告诉我为什么需要盐!

文档的教学部分可能是最具挑战性的。它要求我们扮演老师的角色,从另一个人的相对无知的位置看问题。保持专注于我们正在教授的人,也就是用户,是绝对至关重要的。这非常好地契合了我们清晰文档的最后一个方面:可用性。

可用性

可用性是清晰文档的最后一个组成部分。就像我们的软件一样,我们的文档必须关注用户及其特定需求。前面三个方面(概念规范说明)都关注内容,而可用性纯粹关乎我们表达内容的方式。当用户了解您的软件时,不要使其感到不知所措或困惑是非常重要的:

有许多方式可以使人困惑和不知所措。其中包括以下几种:

  • 内容过多:这可能会使只想执行某个具体而狭窄任务的用户感到不知所措。他们可能不明白为了实现简单任务而必须翻阅大量文档的意义。

  • 内容过少:如果用户希望做的事情没有得到充分的记录,那么他们的选择就很少。他们要么希望有社区驱动的文档,要么希望界面足够易懂,可以在没有帮助的情况下解释清楚。

  • 内部不一致:当文档的不同部分在不同时间更新时,这种情况很常见。用户会疑惑哪个文档或示例是正确的和最新的。

  • 缺乏结构:没有结构,用户无法轻松地浏览或获得对整个软件的概念理解。他们只能在细节中摸索,无法获得清晰的整体图景。在软件中,层次结构很重要,因此在我们的文档中反映这一点很重要。

  • 内容难以导航:没有良好的 UX/UI 考虑,文档可能非常难以导航。如果它不是集中的、可搜索的和可访问的,那么导航就会受到影响,用户会陷入困惑和痛苦之中。

  • 呈现不足:除了导航之外,文档中另一个至关重要的 UX 组件是其美学和排版布局。一个布局良好的文档易于阅读和学习。设计文档是完全合理的。它不应该是无尽散文的枯燥倾倒场所,而是一个美丽的教育体验!

在第二章《清洁代码的原则》中,我们详细讨论了可用性的含义。我们讨论了它不仅仅是直观设计和可访问性,还涉及对用户故事的考虑——用户希望执行的具体任务以及如何满足这些任务。文档与我们提供的任何其他界面并无二致;它必须解决用户的问题。考虑如何设计文档以满足这些示例用户故事:

  • 作为用户,我希望了解这个框架的功能以及如何将其应用到我的项目中

  • 作为用户,我希望找出如何将这个框架安装到我的 Node.js 项目中

  • 作为用户,我希望了解在使用这个框架时的最佳实践

  • 作为用户,我希望了解如何使用这个框架构建一个简单的示例应用程序

每个用户都是不同的。一些用户更喜欢阅读长篇技术文档,而另一些用户更喜欢简短的独立教程。考虑人们的不同学习风格(视觉、听觉、社交、独立等)。有些人通过长时间学习来学习;其他人通过实践来学习。

我们可以考虑为用户寻求的不同类型信息构建不同风格的文档。更加具体的信息(例如,这个特定框架的功能如何工作?)可能更适合传统的长篇文档格式,而更加指导性的信息(例如,如何使用这个框架构建一个应用程序?)可能更适合丰富的媒体(例如,视频教程)。

由于用户可能寻求的信息类型众多,以及我们正在为不同的个体用户提供服务,因此值得花费大量时间来规划、设计和执行清晰的文档。它不应该是事后的想法。

现在我们已经探讨了清晰文档的四个方面,让我们探索一下我们可以使用的各种媒介来表达我们的文档。我们不必只使用单一的沉闷、可滚动的文档:我们还有数十种其他方式可以向用户和同事提供信息和教育。

文档无处不在

如果我们慷慨地将文档定义为了解软件的一种方式,我们可以观察到存在数十种不同的文档媒介。其中许多是隐性或偶然的;其他更多是有意识地制作的,无论是软件的创建者还是围绕它聚集的专家社区:

  • 书面文档(API 规范、概念解释)

  • 解释性图片和图表(例如流程图)

  • 书面教程(步骤、食谱、如何做 X)

  • 丰富的媒体介绍和教程(视频、播客、屏幕录像)

  • 公共问答或问题(例如解释如何修复某些问题的 GitHub 问题)

  • 社区驱动的问答(例如 StackOverflow)

  • 程序员之间的独立沟通(线上或线下)

  • 聚会、会议和研讨会(所有者或社区驱动)

  • 官方支持(付费支持电话、邮件、线下会议)

  • 教育课程(例如线下或在线的 Coursera)

  • 测试(解释概念、流程和期望)

  • 良好的抽象(有助于解释概念)

  • 可读且熟悉的代码(易于理解

  • 结构和界定(目录结构,项目名称等)

  • 直观设计的界面(通过良好设计教育使用方式

  • 错误流和消息(例如* X 不起作用?尝试 Z 代替*)。

考虑所有这些媒介是如何被照顾的是值得的。当官方文档无法帮助解决用户的问题时,他们在放弃你的软件之前会探索哪些其他途径?我们如何能够尽快和流畅地将用户的困难或问题引导到解决方案?如果用户不太可能阅读整个规范文档,那么我们可以为他们创建哪些其他媒介?

为非技术受众撰写

正如我们所见,撰写文档时,有必要将所用语言适应于受众。为此,我们必须清楚地了解受众是谁,他们目前的知识水平是什么,以及他们试图实现什么。对程序员来说,与不太技术化或非技术人员沟通是一个臭名昭著的挑战。这是作为软件创作者的一个非常常见且至关重要的部分。无论是在 UX 的特定点与最终用户沟通,还是与非技术利益相关者合作,都需要根据受众调整我们的沟通方式。为此,我们应该做到以下几点:

  • 选择正确的抽象层次:找到受众完全理解的抽象层次至关重要。使用他们的角色和能力来指导你用来解释事物的类比。例如,如果你在向患者谈论一款医疗软件,你可能更愿意说请添加您的医疗信息,而不是请填写医疗档案字段

  • 避免过于技术化的术语:避免对听众毫无意义的词语。使用普通语言解释详细概念。例如,你可以谈论视觉增强,而不是CSS 修改

  • 不断获得反馈:确保通过与听众核对来确保自己被理解。不要假设人们理解你,只是因为他们没有明确表示不理解。在你的文档或软件中考虑面向用户的提示(例如,“这条消息有帮助吗?[是] [否]”)

与非技术人员沟通可能看起来是一个独特的挑战,但与任何其他人沟通并无二致。正如我们应该一直做的那样,我们只需要以他们的理解水平为准,并根据他们对问题领域的当前理解进行沟通。

总结

在本章中,我们探讨了撰写清晰文档的艰难之处,将其分解为清晰文档的四个关键方面:概念规范指导可用性。我们讨论了正确识别受众的挑战以及如何制定我们的沟通以适应他们。这种知识不仅在撰写正式文档时有用,也在我们与利益相关者以及软件与用户进行沟通时有用。

在下一章中,我们将迅速转向处理他人代码的独特挑战。当我们作为接收端需要高效工作时,如果文档可能不佳或代码不直观,会发生什么?我们将找出答案。

第十七章:其他人的代码

人类是复杂而善变的,创造出复杂而善变的东西。然而,处理其他人和他们的代码是作为程序员不可避免的一部分。无论是处理他人构建的库和框架,还是继承整个遗留代码库,挑战都是相似的。第一步应该始终是寻求对代码及其范例的理解。当我们完全理解代码时,我们可以开始以清晰的方式与之交互,使我们能够在现有工作的基础上创建新功能或进行改进。在本章中,我们将更详细地探讨这个主题,并通过清晰的代码视角考虑我们可以采取的行动,使其他人的代码处理起来不那么痛苦。

在本章中,我们将涵盖以下主题:

  • 继承代码

  • 处理第三方代码

继承代码

当我们加入一个新团队或接手一个新项目时,通常会继承大量代码。我们在这些继承的代码库中的生产力取决于我们对它们的理解。因此,在我们寻求进行第一次更改之前,我们需要在脑海中建立一个关于事物如何运作的概念模型。它不需要是详尽和完整的,但它必须使我们至少能够进行更改并准确理解这些更改可能对代码库的所有组成部分产生的影响。

探索和理解

完全理解代码基础并不是必须的,也不是必须的,但如果我们对所有相互关联部分的复杂性没有足够的理解,那么我们就会陷入陷阱。当我们相信自己已经很好地理解时,开始进行更改时,陷阱就会出现。如果不理解我们行为的全部影响,我们最终会浪费时间,实现不完善的东西,并产生意外的错误。因此,我们必须充分了解情况。为了做到这一点,我们必须首先评估我们对系统或代码基础复杂性的视图是完整还是不完整。

通常,我们看不见的事情对我们来说是完全未知的,因此我们不知道自己根本没有任何理解。这就是常见表达式我们不知道自己不知道什么所概括的。因此,在探索新的代码库时,积极主动地努力去发现和突出我们的无知领域是有帮助的。我们可以通过以下三个步骤来做到这一点:

  • 收集可用信息:与知情的同事交谈,阅读文档,使用软件,内化概念结构和层次结构,阅读源代码。

  • 做出知情的假设:用知情的假设填补你不确定的地方。如果有人告诉你应用程序有注册页面,你可以直观地假设这意味着用户注册涉及典型的个人数据字段,如姓名、电子邮件、密码等。

  • 证明或否定假设:寻求通过直接查询系统(例如编写和执行测试)或询问有经验的人(例如对代码库有经验的同事)来证明或否定你的假设。

在创建和扩展对新代码库的理解时,有一些特定的方法值得采用。这些方法包括制作流程图,内化变更的时间线,使用调试器逐步执行代码,并通过测试确认你的假设。我们将逐个探讨这些方法。

制作流程图

当我们遇到一个新的代码库时,我们几乎可以立即采用的一个有用的方法是填充一个心智图或流程图,突出显示我们知道的事情以及我们尚不确定的事情。以下是我曾经在一款医疗软件上使用的这种图表的简化示例:

正如你所看到的,我已经尝试概述了我对用户流程的当前理解,并在注释中添加了我个人在其中遇到的困惑或问题。随着我的理解的增长,我可以补充这个流程图。

人们以各种方式学习。这种视觉辅助可能对某些人更有用,但对其他人可能不那么有用。还有无数种组成这样的流程图的方式。为了达到个人理解的目的,最好使用任何对你有效的方法

寻找结构和观察历史

想象一下,你面对一个包含几种专门类型的视图组件的大型 JavaScript 应用程序代码库。我们的任务是在应用程序中的一个付款表单中添加一个新的下拉菜单。我们快速搜索代码库,并确定了许多不同的下拉菜单相关组件:

  • GenericDropdownComponent

  • DropdownDataWidget

  • EnhancedDropdownDataWidget

  • TextDropdown

  • ImageDropdown

它们的命名令人困惑,因此在进行更改或使用它们之前,我们希望更好地了解它们。为了做到这一点,我们可以打开每个组件的源代码,以确定它可能与其他组件有何关联(或者没有关联)。

最终我们发现,例如TextDropdownImageDropdown都似乎继承自GenericDropdownComponent

// TextDropdown.js
class TextDropdown extends GenericDropdownComponent {
  //...
}

// ImageDropdown.js
class ImageDropdown extends GenericDropdownComponent {

}

我们还观察到DropdownDataWidgetEnhancedDropdownDataWidget都是TextDropdown的子类。增强下拉小部件的命名可能会让我们困惑,这可能是我们在不久的将来想要更改的内容,但是,目前,我们需要屏住呼吸,只需专注于完成我们被分配的工作。

在完成遗留或不熟悉的代码库中的任务时,避免走神。许多事情可能看起来奇怪或错误,但你的任务必须始终是最重要的事情。在早期,你可能没有足够的接触代码库的经验来做出明智的更改。

通过逐个查看每个与下拉菜单相关的源文件,我们可以在不进行任何更改的情况下建立对它们的深入理解。如果代码库使用源代码控制,那么我们还可以责备每个文件,以发现最初是谁编写的以及何时编写的。这可以告诉我们事物是如何随时间变化的。在我们的情况下,我们发现了以下变更时间线:

这对我们非常有帮助。最初只有一个类(名为DropdownComponent),后来改为GenericDropdownComponent,有两个子类TextDropdownComponentImageDropdownComponent。每个都改名为TextDropdownImageDropdown。随着时间的推移,这些各种变化阐明了现在事物的原因。

当查看代码库时,我们经常会假设它是一次性创建的,并且有完整的远见;然而,正如我们的时间线所示,事实要复杂得多。代码库随着时间的推移而变化,以应对新的需求。参与代码库工作的人员也在变化,每个人都不可避免地有自己解决问题的方式。我们接受每个代码库缓慢演变的本质,将有助于我们接受它的不完美之处。

逐步查看代码

在大型应用程序中建立对单个代码片段的理解时,我们可以使用工具来调试和研究其功能。在 JavaScript 中,我们可以简单地放置一个debugger;语句,然后执行我们知道会激活特定代码的应用程序部分。然后,我们可以逐行查看代码,以回答以下问题:

  • **这段代码被调用在哪里?**对于一个抽象如何被激活的明确期望可以帮助我们在脑海中建立应用程序的顺序的模型,使我们能够更准确地判断如何修复或更改某些东西。

  • **这段代码接收了什么?**抽象接收的输入示例可以帮助我们建立一个清晰的概念,了解它的功能,以及它期望如何被接口化。这可以直接指导我们使用这个抽象。

  • **这段代码输出了什么?**观察一个抽象的输出,以及它的输入,可以让我们对它的计算方式有一个非常明确的概念,并且可以帮助我们判断我们可能希望如何使用它。

  • **这里存在什么级别的误导或复杂性?**观察复杂和高的堆栈跟踪(意味着被函数调用的函数被函数调用,无限循环...)可以表明我们在某个区域内导航和理解控制和信息流的困难。这将告诉我们,我们可能需要通过额外的文档或与知情的同事沟通来增加我们的理解。

以下是在浏览器环境中这样做的一个例子(使用 Chrome Inspector):

即使在 Node.js 中实现服务器端 JavaScript,您也可以使用 Chrome 的调试器。要做到这一点,在执行 JavaScript 时使用--inspect标志,例如,node --inspect index.js

像这样使用调试器可以为我们呈现调用堆栈堆栈跟踪,告诉我们通过代码库采取了哪条路径到达我们的debugger;语句。如果我们试图了解陌生类或模块如何适应代码库的整体情况,这将非常有帮助。

验证您的假设

扩展我们对陌生代码的了解的最佳方法之一是编写测试来确认代码的行为方式。想象一下,我们被要求维护这段晦涩的代码:

class IssuerOOIDExtractor {
  static makeExtractor(issuerInfoInterface) {
    return raw => {
      const infos = [];
      const cleansed = raw
        .replace(/[_\-%*]/g, '')
        .replace(/\bo(\d+?)\b/g, ($0, id) => {
          if (issuerInfoInterface) {
            infos.push(issuerInfoInterface.get(id));
          }
          return `[[ ${id} ]]`;
        })
        .replace(/^[\s\S]*?(\[\[.+\]\])[\s\S]*$/, '$1');
      return { raw, cleansed, data: infos };
    };
  }
}

这段代码只在几个地方使用,但各种输入是在应用程序的难以调试的区域动态生成的。此外,没有文档,绝对没有测试。这段代码到底做了什么还不太清楚,但是,当我们逐行研究代码时,我们可以开始做一些基本的假设,并将这些假设编码为断言。例如,我们可以清楚地看到makeExtractor静态函数本身返回一个函数。我们可以将这个事实规定为一个测试:

describe('IssuerOOIDExtractor.makeExtractor', () => {
  it('Creates a function (the extractor)', () => {
    expect(typeof IssuerOOIDExtractor.makeExtractor()).toBe('function');
  });
});

我们还可以看到某种正则表达式替换发生;它似乎在寻找字母o后面跟着一串数字的模式(\bo(\d+?)\b)。我们可以通过编写一个简单的断言来开始探索这个提取功能,其中我们给提取器一个匹配该模式的字符串:

const extractor = IssuerOOIDExtractor.makeExtractor();

it('Extracts a single OOID of the form oNNNN', () => {
  expect(extractor('o1234')).toEqual({
    raw: 'o1234',
    cleansed: '[[ 1234 ]]',
    data: []
  });
});

随着我们慢慢发现代码的功能,我们可以添加额外的断言。我们可能永远无法达到 100%的理解,但这没关系。在这里,我们断言提取器能够正确提取单个字符串中存在的多个 OOID:

it('Extracts multiple OOIDs of the form oNNNN', () => {
  expect(extractor('o0012 o0034 o0056 o0078')).toEqual({
    raw: 'o0012 o0034 o0056 o0078',
    cleansed: '[[ 0012 ]] [[ 0034 ]] [[ 0056 ]] [[ 0078 ]]',
    data: []
  });
});

运行这些测试时,我们观察到以下成功的结果:

 PASS ./IssuerOOIDExtractor.test.js
  IssuerOOIDExtractor.makeExtracator
    ✓ Creates a function (the extractor) (3ms)
    The extractor
      ✓ Extracts a single OOID of the form oNNNN (1ms)
      ✓ Extracts multiple OOIDs of the form oNNNN (1ms)

请注意,我们仍然不完全确定原始代码的作用。我们只是触及了表面,但这样做,我们正在建立一个有价值的理解基础,这将使我们将来更容易地与这段代码进行交互或更改。随着每个新的成功断言,我们离完整和准确地理解代码的目标更近。如果我们将这些断言作为新的测试提交,那么我们也正在提高代码库的测试覆盖率,并为将来可能同样被这段代码困惑的同事提供帮助。

现在我们已经牢固掌握了如何探索和理解继承的代码,我们现在可以研究如何对该代码进行更改

进行更改

一旦我们对代码库的某个区域有了很好的理解水平,我们就可以开始进行更改。然而,即使在这个阶段,我们也应该谨慎。我们对代码库和相关系统仍然相对较新,所以我们可能仍然不了解其中许多部分。任何更改都可能造成意想不到的影响。因此,为了继续前进,我们必须慢慢和谨慎地进行,确保我们的代码设计良好并经过充分测试。在这里,我们应该注意两种具体的方法:

  • 在陌生环境中进行孤立更改的精细手术过程

  • 通过测试确认更改

让我们逐一探讨这些。

最小侵入手术

当需要在旧的或陌生的代码库中进行更改时,可以想象自己在进行一种最小侵入手术。这样做的目的是最大化更改的积极影响,同时最小化更改本身的影响范围,确保不会对代码库的其他部分造成损害或影响过大。这样做的希望是,我们将能够产生必要的更改(优势),而不会过多地暴露自己于破坏或错误的可能性(劣势)。当我们不确定更改是否完全必要时,这也是有用的,因此我们最初只想在其上花费最少的精力。

假设我们继承了一个负责呈现单个图像的GalleryImage组件。在我们的 Web 应用程序中有许多地方使用它。任务是在资产的 URL 指示其为视频时,添加呈现视频的能力。两种 CDN URL 的类型如下:

  • https://cdn.example.org/VIDEO/{ID}

  • https://cdn.example.org/IMAGE/{ID}

正如您所看到的,图像和视频 URL 之间存在明显的区别。这为我们提供了一种在页面上呈现这些媒体的简单方法。理想情况下,我们应该实现一个名为GalleryVideo的新组件来处理这种新类型的媒体。这样的新组件将能够独特地满足视频的问题域,这显然与图像的问题域不同。至少,视频必须通过<VIDEO>元素呈现,而图像必须通过<IMG>呈现。

我们发现GalleryImage的许多用法都没有经过充分测试,有些依赖于隐晦的内部实现细节,这些细节如果要大规模辨别将会很困难(例如,如果我们想要更改所有GalleryImage的用法,进行查找和替换将会很困难)。

我们的可用选项如下:

  1. 创建一个容器GalleryAsset组件,它本身根据 CDN URL 决定是否呈现GalleryImageGalleryVideo。这将涉及替换每个当前使用GalleryImage的情况:
  • 时间估计:1-2 周

  • 代码库中的影响:显著

  • 可能出现意想不到的破坏:显著

  • 架构清洁度

  1. GalleryImage中添加一个条件,根据 CDN URL 可选择呈现<video>而不是<img>标签:
  • 时间估计:1-2 天

  • 代码库中的影响:最小

  • 可能出现意想不到的破坏:最小

  • 架构清洁度中等

在理想情况下,如果考虑代码库的长期架构,很明显创建一个新的GalleryAsset组件是最好的选择。它为我们提供了一个清晰定义的抽象,直观地满足了图片和视频的两种情况,并为我们提供了在将来添加不同资产类型(例如音频)的可能性。然而,它需要更长的时间来实现,并且带有相当大的风险。

第二个选项要简单得多。实际上,它可能只涉及以下四行更改集:

@@ -17,6 +17,10 @@ class GalleryImage {
  render() {

+   if (/\/VIDEO\//.test(this.props.url)) {
+     return <video src={this.props.url} />;
+   }
+
    return <img src={this.props.url} />

  }

这不一定是一个好的长期选择,但它给了我们一些可以立即交付给用户的东西,满足他们的需求和我们利益相关者的需求。一旦交付,我们可以计划未来的时间来完成更大的必要更改。

重申一下,最小侵入性更改的价值在于它减少了代码库在实施时间和潜在破坏方面的立即不利因素(风险)。显然,确保我们在短期利益和长期利益之间取得平衡是至关重要的。利益相关者通常会向程序员施加压力,要求他们快速实施更改,但如果没有技术 债务或协调过程,那么所有这些最小侵入性的更改可能会积聚成一个相当可怕的怪物。

为了确保我们更改的代码不太脆弱或容易出现未来的回归,最好是在更改的同时编写测试,编码我们的期望。

将更改编码为测试

我们已经探讨了如何编写测试来发现和指定当前功能,而且在之前的章节中,我们讨论了遵循测试驱动开发TDD)方法的明显好处。因此,当在一个陌生的代码库中操作时,我们应该始终通过清晰编写的测试来确认我们的更改。

在没有现有测试的情况下,与您的更改一起编写测试绝对是必要的。在代码区域编写第一个测试可能会很繁重,因为需要设置库和必要的模拟,但这绝对是值得的。

在我们之前介绍的向GalleryImage引入渲染视频功能的示例中,明智的做法是添加一个简单的测试来确认当 URL 包含"/VIDEO/"子字符串时,<VIDEO>被正确渲染。这可以防止未来的回归,并给我们带来了强大的信心,表明它按预期工作:

import { mount } from 'enzyme';
import GalleryImage from './GalleryImage';

describe('GalleryImage', () => {
  it('Renders a <VIDEO> when URL contains "/VIDEO/"', () => {
    const rendered = mount(
      <GalleryImage url="https://cdn.example.org/VIDEO/1234" />
    );
    expect(rendered.find('video')).to.have.lengthOf(1);
  });
  it('Renders a <IMG> when URL contains "/IMAGE/"', () => {
    const rendered = mount(
      <GalleryImage url="https://cdn.example.org/IMAGE/1234" />
    );
    expect(rendered.find('img')).to.have.lengthOf(1);
  });
});

这是一个相当简单的测试;然而,它完全编码了我们在进行更改后的期望。在进行小型和自包含的更改或更大的系统性更改时,通过这样的测试验证和传达我们的意图是非常有价值的。除了防止回归,它们还在立即的代码审查方面帮助我们的同事,以及在文档和整体可靠性方面帮助整个团队。因此,拥有一个团队强制执行或政策,即如果没有测试,就不能提交更改是相当正常和可取的。长期执行这一政策将使代码库产生更可靠的功能,对用户更加友好,对其他程序员更加愉快。

我们现在已经完成了关于继承代码的部分,所以你应该对如何处理这种情况有了良好的基础知识。在处理其他人的代码时,另一个挑战是选择和集成第三方代码,即库和框架。我们现在将探讨这个问题。

处理第三方代码

JavaScript 的领域充斥着各种框架和库,可以减轻实现各种功能的负担。在第十二章中,真实挑战,我们看到了在 JavaScript 项目中包含外部依赖项所涉及的困难。现代 JavaScript 生态系统在这里提供了丰富的解决方案,因此处理第三方代码的负担要比以前少得多。尽管如此,与这些代码进行接口的本质并没有真正改变。我们仍然必须希望我们选择的第三方库或框架提供直观和良好文档的接口,以及满足我们需求的功能。

在处理第三方代码时,有两个关键的过程将决定我们所获得的持续风险或收益。第一个是选择过程,我们在这个过程中选择要使用的库,第二个是我们将库集成和适应到我们的代码库中。现在我们将详细讨论这两点。

选择和理解

选择一个库或框架可能是一个冒险的决定。选择错误的库可能最终会驱动系统的大部分架构。框架尤其以此闻名,因为它们的本质决定了架构的结构和概念基础。选择错误的库然后试图更改它可能是一个相当大的工作量;这需要对应用程序中几乎每一行代码的更改。因此,认真考虑和选择第三方代码的技能至关重要:

在选择过程中,我们可以考虑一些有用的考虑因素:

  • 功能性:库或框架必须满足一组固定的功能期望。重要的是以足够详细的方式指定这些功能,以便可以量化比较不同的选项。

  • 兼容性:库或框架必须与当前代码库的工作方式大部分兼容,并且必须能够以技术简单易懂的方式集成,以便同事们能够理解。

  • 可用性:库或框架必须易于使用和理解。它应该有良好的文档和一定程度的直观性,可以在没有痛苦或困惑的情况下立即提高生产力。对于使用相关问题或疑问的考虑也属于可用性范畴。

  • 维护和安全性:库或框架应该得到维护,并且有一个清晰可信的流程来报告和解决错误,特别是那些可能具有安全影响的错误。变更日志应该是详尽的。

这里的四个标准也可以通过启发式来指导,比如项目由谁支持?有多少人在使用该项目?,或者我是否熟悉构建它的团队?。但请注意,这些只是启发式,因此并不是衡量第三方代码适用性的完美方式。

然而,即使使用这四个标准,我们可能会陷入陷阱。如果你还记得,在第三章中,清洁代码的敌人,我们讨论了最显著的自我(或自负)和物质崇拜。在选择第三方代码时,这些也是相关的。请特别注意以下内容:

  • 强烈的观点: 尽可能地与决策过程分开,并非常谨慎地对待我们的无知和偏见。程序员以他们的固执著称。在这些时刻,重要的是要从自己身上退后一步,用纯粹的逻辑来推理出我们认为最好的是什么。给每个人一个发言的机会同样很重要,根据他们自身的价值观和轶事来权衡人们的意见,而不是根据他们的资历(或其他个人特征)。

  • 流行文化: 不要被流行所左右。由于其社区的规模和狂热,很容易被流行的抽象所吸引,但再次,重要的是要退一步,考虑框架本身的优点。当然,流行可能表明易于集成和更丰富的学习资源,所以在这方面,谈论它是合理的,但要小心使用流行作为优越性的唯一指标。

  • 分析瘫痪: 有很多选择,所以有可能陷入一种似乎无法做出选择的情况,因为害怕做出错误选择。大多数情况下,这些决定是可逆的,所以做出不太理想的选择并不是世界末日。很容易陷入这样一种情况,花费大量时间来决定选择哪个框架或库,而更有效的方法是只是选择任何东西,然后根据以后的需求进行迭代或转变。

在做关于第三方库的决定时,关键是要充分意识到它们对代码库的最终影响。我们花在做决定上的时间应该与它们的潜在影响成比例。决定客户端框架用于组件渲染可能是一个相当重要的选择,因为它可能规定了代码库的一个重要部分,而例如一个小的 URL 解析实用程序并没有很大的影响,并且可以在将来轻松替换。

接下来,我们可以讨论如何集成和封装第三方代码,遵循一个知情的选择过程。

封装和适应第三方代码

选择第三方抽象,特别是框架的缺点是,你最终可能会改变你的代码库以适应抽象的作者的任意约定和设计决策。通常情况下,我们被迫说同样的语言,而不是让它们说我们的语言。确实,在许多情况下,可能是抽象的约定和设计吸引了我们,所以我们更愿意让它驱动我们的代码库的设计和性质。但在其他情况下,我们可能希望更多地受到我们选择的抽象的保护。我们可能希望在将来轻松地将它们替换为其他抽象,或者我们可能已经有一套我们更愿意使用的约定。

在这种情况下,封装这些第三方抽象并纯粹通过我们自己的抽象层来处理它们可能是有用的。这样的层通常被称为适配器

非常简单地说,适配器将提供一个我们设计的接口,然后委托给第三方抽象来完成其任务。想象一下,如果我们希望使用一个名为YOORL的 URL 解析实用程序。我们已经决定它完全符合我们的需求,并且完全符合 RFC 3986(URI 标准)。唯一的问题是它的 API 相当繁琐和冗长。

import YOORL from 'yoorl';
YOORL.parse(
  new YOORL.URL.String('http://foo.com/abc/?x=123'),
  { parseSearch: true }
).parts();

这将返回以下对象

{
  protocol: 'http',
  hostname: 'foo.com',
  pathname: '/abc',
  search: { x: 123 }
}

我们希望 API 更简单。我们认为当前 API 的长度和复杂性会使我们的代码库面临不必要的复杂性和风险(例如错误调用)。使用适配器可以让我们将这个不理想的接口封装成我们自己设计的接口。

// URLUtils.js
import YOORL from 'yoorl';
export default {
  parse(url) {
    return YOORL.parse(
      new YOORL.URL.String(url)
    ).parts();
  }
};

这意味着我们代码库中的任何模块现在都可以与这个简化的适配器进行接口,使它们与 YOORL 的不理想 API 隔离开来。

import URLUtils from './URLUtils';

URLUtils.parse('http://foo.com/abc/?x=123'); // Easy!

适配器可以被视为翻译媒介,使我们的代码库能够使用其选择的语言,而不必受到第三方库的任意和不一致的设计决策的拖累。这不仅有助于代码库的可用性和直观性,还使我们能够非常轻松地对基础第三方库进行更改,而无需改变太多代码。

总结

在本章中,我们探讨了其他人的代码这个棘手的话题。我们考虑了如何处理我们继承的遗留代码;我们如何建立对它的理解,如何进行调试和进行改变而不困难,以及如何通过良好的测试方法确认我们的改变。我们还涵盖了处理第三方代码的困难,包括如何选择它以及如何通过适配器模式以风险规避的方式与其进行接口。在本章中我们还可以谈论许多其他事情,但希望我们能够探讨的主题和原则已经让您充分了解如何以干净的代码库为目标来处理其他人的代码。

在下一章中,我们将涵盖沟通的主题。这可能看起来不相关,但对于程序员来说,沟通在我们的工作场所内部和向用户之间都是一项绝对重要的技能,没有它,干净的代码几乎不可能存在。我们将具体探讨如何规划和设定要求,如何与同事合作和沟通,以及如何在我们的项目和工作场所内推动变革。

第十八章:沟通和倡导

我们不是孤立地编写代码。我们生活在一个高度混乱的社会世界中,必须不断与其他人沟通。我们的软件本身将通过其接口成为这种沟通的一部分。此外,如果我们在团队、工作场所或社区中工作,我们将面临有效沟通的挑战。

沟通对我们的代码库产生最重要影响的方式是设定要求、提出问题和反馈。软件开发本质上是一个非常长期的反馈过程,每一次变更都是由一次沟通引发的:

在这一章中,我们将学习如何有效地与他人合作和沟通,如何规划和设定要求,一些常见的合作陷阱及其解决方案。我们还将学习如何识别和提出阻碍我们编写清晰 JavaScript 的更大问题。在整个本章中,我们希望开始意识到我们在软件开发反馈周期中的重要角色。

在本章中,我们将看到以下主题:

  • 规划和设定要求

  • 沟通策略

  • 识别问题并推动变革

规划和设定要求

最常见的沟通困难之一在于决定到底要构建什么。程序员通常会花费大量时间与经理、设计师和其他利益相关者会面,将真正的用户需求转化为可行的解决方案。理想情况下,这个过程应该很简单:*用户有问题;我们创建[解决方案]。故事结束!*不幸的是,实际情况可能要复杂得多。

即使在看似简单的项目中,也会有许多技术约束和沟通偏见,使得项目变得异常艰难。这对 JavaScript 程序员和其他程序员同样重要,因为我们现在操作的系统复杂性水平以前只有企业程序员才能使用 Java、C#或 C++。形势已经改变,因此谦卑的 JavaScript 程序员现在必须准备学习新技能,并就他们构建的系统提出新问题。

理解用户需求

确立用户需求至关重要,但往往被视为理所当然。程序员和其他项目成员通常会假设他们了解某个用户需求,而实际上并没有深入了解细节,因此有一个备用的流程是很有用的。对于每一个表面上的需求问题,我们应该确保理解以下方面:

  • 我们的用户是谁?:他们有什么特点?他们使用什么设备?

  • 他们试图做什么?:他们试图执行什么行动?他们的最终目标是什么?

  • 他们目前是如何做的?:他们目前采取了哪些步骤来实现他们的目标?目前的方法是否存在明显问题?

  • 他们以这种方式遇到了什么问题?:需要很长时间吗?认知成本高吗?使用起来困难吗?

在书的开头,我们问自己为什么要编写代码,并探讨了真正理解问题领域本质的含义。理想情况下,我们应该能够站在用户的角度,亲身体验问题领域,然后从第一手经验中制定可行的解决方案。

不幸的是,我们并不总能直接与用户交谈或亲身体验。相反,我们可能依赖项目经理和设计师等中间人。因此,我们依赖他们的沟通效果,以便将用户需求传达给我们,从而使我们能够构建正确的解决方案。

在这里,我们看到了我们用户的需求,结合技术和业务约束,流入一个构建成解决方案并进行迭代的想法。将用户需求转化为想法至关重要,反馈过程使我们能够对解决方案进行迭代和改进:

由于用户需求对开发过程至关重要,我们必须仔细考虑如何平衡这些需求和其他约束。通常不可能构建出完美的解决方案,能够很好地满足每个用户的需求。几乎每个软件,无论是作为 GUI 还是 API 呈现,都是一种折衷,平均用户得到很好的满足,不可避免地意味着边缘情况的用户只能得到部分满足。重要的是要考虑如何尽可能充分地满足尽可能多的用户需求,巧妙地平衡时间、金钱和技术能力等约束。

在了解用户需求之后,我们可以开始设计和实现系统可能运行的原型和模型。接下来我们将简要讨论这个过程。

快速原型和 PoC

软件,尤其是 Web 平台,为我们提供了快速的构建周期的好处。我们可以在很短的时间内从概念到 UI。这意味着在头脑风暴的过程中,想法可以几乎实时地变为现实。然后我们可以将这些原型放在真实用户面前,获得真实反馈,然后快速迭代,朝着最佳解决方案迈进。事实上,Web 平台的优势——HTML、CSS 和 JavaScript 的三位一体——在于它允许快速而粗糙的解决方案,并且可以轻松进行迭代,并且可以在多个平台和设备上运行:

很容易被 JavaScript 框架和库的多样性和复杂性所压倒;它们的沉重负担会迫使我们进展缓慢。这就是为什么在原型设计时,最好坚持使用你已经很了解的更简单的技术栈。如果你习惯于某个框架,或者你准备花时间学习,那么值得利用现有的骨架模板作为起点。以下是一些例子:

每个模板都提供了一个相对简单的项目模板,可以让你非常快速地设置一个新的原型。尽管每个模板中使用了多个构建工具和框架选项,但设置成本很低,因此开始解决项目的真正问题领域所需的时间非常短。当然,你也可以找到类似的骨架模板和示例应用程序,适用于服务器端 Node.js 项目、同构 Web 应用程序,甚至是机器人或硬件项目。

现在我们已经探讨了规划和需求设置的技术过程,我们可以继续探讨一些重要的沟通策略,这些策略将帮助我们在我们的代码库上与他人合作。

沟通策略

我们直觉地知道沟通对于一个有效的项目和清晰的代码库是至关重要的,然而,很常见的情况是我们发现自己处于以下情况:

  • 我们觉得自己没有被倾听到

  • 我们觉得自己没有表达清楚

  • 我们对某个主题或计划感到困惑

  • 我们感到不在圈内或被忽视

这些困难是由于文化和不良沟通习惯造成的。这不仅影响了我们工作中的士气和整体满足感,还可能对我们构建的代码库的整洁性和技术的可靠性造成巨大问题。为了培养一个干净的代码库,我们必须专注于我们采用的基础沟通实践。一套良好的沟通策略和实践对确保一个干净的代码库非常有用,特别是帮助我们做到以下几点:

  • 确保与同事良好的反馈

  • 接收正确的错误报告

  • 执行改进和修复

  • 接收用户需求和愿望

  • 宣布变化或问题

  • 就约定和标准达成一致

  • 对库和框架做出决策

但我们如何实际实现良好的沟通呢?我们在本质上偏向于我们自己社会化的沟通实践,所以改变或甚至看到我们的沟通存在问题可能是困难的。因此,识别一套沟通策略和陷阱,可以让我们重新偏向更好和更高信号的沟通,这是非常有用的。

高信号沟通是指在最小噪音的情况下压缩大量高价值或富有洞察力的信息的任何沟通。用简洁和高度客观的段落表达错误报告可能是高信号的一个例子,而用三部分的散文和夹杂着观点的表达则是低信号的一个例子。

倾听并回应

无论是在线还是线下对话,很容易陷入一个陷阱,我们最终是在互相交谈而不是互相交流。一个良好而有用的对话是参与者真正倾听彼此,而不仅仅是等待自己说话的轮到。

考虑以下人员#1人员#2之间的对话:

  • 人员#1我们应该使用 React 框架,它有着良好的记录。

  • 人员#2我同意它的记录。我们是否应该探讨其他选项,权衡它们的利弊呢?

  • 人员#1React 非常快速,文档完善,API 非常易用。我喜欢它。

这里人员#1没有注意人员#2在说什么。相反,他们只是继续他们现有的思路,重申他们对 React 框架的偏好。如果人员#1努力倾听人员#2的观点,然后具体回应他们,这将更有利于团队合作和项目的健康。将上述对话与以下对话进行比较:

  • 人员#1我们应该使用 React 框架,它有着良好的记录。

  • 人员#2我同意它的记录。我们是否应该探讨其他选项,权衡它们的利弊呢?

  • 人员#1那是个好主意,你认为我们应该考虑哪些其他框架呢?

在这里,人员#1是接受的,而不仅仅是在人员#2上面说话。这显示了一种非常需要的敏感性和对话关注。这可能看起来很明显,甚至无聊,但你可能会惊讶地发现我们经常互相打断对方,这给我们带来了什么代价。考虑在下次会议中扮演一个观察者的角色,观察人们未能正确地关注、倾听或回应的情况。你可能会对它的普遍性感到惊讶。

从用户的角度解释

在关于代码库的几乎每一次在线或离线沟通中,用户应该是最重要的事情。我们的工作目的是满足用户的期望,并为他们提供直观和功能性的用户体验。这一点很重要,无论我们的最终产品是消费者软件还是开发者 API。用户始终是我们的首要任务。然而,我们经常发现自己处于需要做出决定而不知道如何做出决定的情况;我们最终依赖直觉或自己的偏见。请考虑以下内容:

  • 当然用户应该满足我们的密码强度要求

  • 当然我们的 API 应该严格检查类型

  • 当然,我们应该为国家选择使用下拉组件

这些可能看起来是相当无可非议的声明,但我们应该始终从用户的角度来限定它们。如果我们做不到这一点,那么决定很可能站不住脚,应该受到质疑。

对于上述每一条声明,我们可以如下辩护我们的推理:

  • 当然用户应该满足我们的密码强度要求:拥有更强密码的用户将更安全地抵御暴力破解密码攻击。虽然我们作为服务方需要确保密码的安全存储,但确保密码强度是用户的责任,也符合他们的利益。

  • 当然我们的 API 应该严格检查类型:严格检查类型的 API 将确保用户获得更多关于不正确使用的警告,从而更快地达到他们的目标。

  • 当然我们应该为国家选择使用下拉组件:下拉是用户已经习惯的传统。我们也可以随时增加自动完成功能。

注意我们如何通过与用户相关的推理来扩展我们的“当然”的声明。我们很容易在周围断言事物应该如何,而实际上并没有用强有力的推理支持我们的主张。这样做可能导致毫无意义且论据不足的反对。最好始终从用户的角度推理我们的决定,这样,如果有争论,我们就是基于对用户最有利的事情进行争论,而不仅仅是最受欢迎或最坚定的观点。始终从用户的角度解释也有助于灌输一种文化,即我们和同事们始终在思考用户,无论我们是在编写深度专业的 API 还是开发通用的 GUI。

进行简短而专注的沟通

与我们编码时使用的“单一责任原则”类似,我们的沟通理想上应该一次只涉及一件事。这将极大地提高参与者之间的理解,并确保所做出的任何决定都与手头的问题有关。此外,保持会议或沟通的简短确保人们能够在整个持续时间内保持注意力。长时间的会议,就像长篇的电子邮件一样,最终会引起厌倦和烦躁。随着每个话题或话题的增加,每个问题被单独解决的机会也会大大减少。在提出问题和错误时记住这一点很重要。保持简单。

提出愚蠢的问题,提出大胆的想法

在专业环境中,有一种倾向,即假装有很高的信心和理解。这可能对知识传递有害。如果每个人都假装自己是熟练的,那么没有人会采取学习所需的谦卑立场。在我们的质疑中诚实(甚至愚蠢)是非常有价值的。如果我们是团队的新成员或者对代码库的某个区域感到困惑,重要的是提出我们真正有的问题,这样我们才能建立必要的理解,以便在我们的任务中成为高效和可靠的人。没有这种理解,我们将挣扎不已,可能会引起错误和其他问题。如果团队中的每个人都采取了假装自信的立场,团队很快就会变得无效,没有人能够解决他们的问题或困惑:

我们想要朝着的这种质疑可以称为开放性质疑;*一个过程,在这个过程中,我们尽可能地揭示我们的无知,以便在给定领域获得尽可能多的理解。类似于这样的开放性质疑,我们也可以说还有开放性构思,在这种构思中,我们尽可能地探索和揭示我们所拥有的任何想法,希望其中的一些子集是有用的。

有时,未说出口的想法是最有效的。通常,如果你觉得一个想法或问题太愚蠢或太疯狂,不值得说出来,通常最好说出来。最坏的情况(下行)是它是一个不适用或显而易见的问题或想法。但最好的情况(上行)是你要么获得了理解,要么问了很多人心中的问题(从而帮助了他们的理解),要么提出了一个极大地改变了团队效率或代码质量的想法。开放性的好处绝对值得坏处。

配对编程和 1:1s

程序员的大部分时间都被孤立地写代码所占据。对许多程序员来说,这是他们理想的情况;他们能够屏蔽掉世界的其他部分,找到流畅的生产力,以速度和流畅度编写逻辑。然而,这种孤立的风险是,代码库或系统的重要知识可能会积累在少数人的头脑中。如果不进行分发,代码库将变得越来越专业化和复杂,限制新人和同事轻松地导航。因此,有效地在程序员之间传递知识是至关重要的。

正如在书中之前讨论的,我们已经有了许多正式的方法来传递关于一段代码的知识:

  • 通过文档,以各种形式

  • 通过代码本身,包括注释

  • 通过测试,包括单元测试和端到端测试

即使这些媒介,如果构建正确,可以有效地传递知识,似乎总是需要其他东西。临时沟通的基本人类约定是一种经受时间考验的方法,仍然是最有效的方法之一。

了解新代码库的最佳方法之一是通过配对编程,这是一种活动,在这种活动中,你坐在一个经验更丰富的程序员旁边,一起合作修复错误或实现功能。对于不熟悉的程序员来说,这是特别有用的,因为他们能够从他们的编程伙伴的现有知识和经验中受益。当需要解决一个特别复杂的问题时,配对编程也是有用的。有两个或更多的大脑来解决问题可以极大地增加解决问题的能力,并限制错误的可能性。

即使不是在配对编程的情况下,通常进行问答或师生动态可能非常有用。抽出时间与拥有你所需知识的个人交谈,并向他们提出有针对性但探索性的问题通常会产生很多理解。不要低估与拥有你所需知识的人进行专注对话的力量。

识别问题和推动变革

作为程序员的重要部分是识别问题并解决它们。作为我们工作的一部分,我们使用许多不同的移动部件,其中许多将由其他团队或个人维护,因此,我们需要有效地识别和提出对我们没有完全理解的代码和系统的问题。就像我们作为程序员所做的任何事情一样,我们表达这些问题的方式必须考虑到目标受众(用户)。当我们开始将这些沟通片段视为用户体验时,我们将开始成为真正有效的沟通者。

提出错误报告

提出错误是一种技能。它可以做得很差或有效。为了说明这一点,让我们考虑 GitHub 上的两个问题。它们中的每一个都提出了相同的问题,但方式大不相同。这是第一个变体:

这是第二种变体:

作为这个代码库的维护者,你更希望收到哪个错误报告?显然是第二个。然而我们看到,一次又一次,成千上万的错误报告和对开源项目提出的问题不仅未能准确传达问题,而且用词急躁,不尊重项目所有者的时间和努力。

通常,在提出错误时,最好至少包括以下信息:

  • 问题总结:你应该简要总结正常散文中遇到的问题,以便可以快速理解和分级(可能是由不擅长诊断或修复确切问题的人)。

  • 已采取的步骤:您应该展示可以用来重现您收到的实际行为的确切代码。您的错误的读者应该能够使用您共享的代码或输入参数来重现行为。

  • 预期行为:您应该演示在给定输入的情况下您期望的行为或输出。

  • 实际行为:您应该演示您观察到的不正确的输出或行为。

以下是一个虚构的sum()函数的错误报告的示例:

  • 问题总结:sum()在给定空输入时表现不直观

  • 已采取的步骤:调用sum(null, null)

  • 预期行为:sum(null, null)应返回NaN

  • 实际行为:sum(null, null)返回0

还可以包括有关代码运行环境的信息,包括硬件和软件(例如,MacBook 2013 Retina,Chrome 版本 43.01)。提出错误的整个目的是以准确和详细的方式传达意外或不正确的行为,以便能够迅速解决。如果我们限制提供的信息量,或者直接无礼,我们大大降低了问题被解决的可能性。

除了提出问题时应采取的具体步骤外,还有一个更广泛的问题,即我们应该如何在软件或文化中推动和激发系统性变革。我们将在接下来探讨这个问题。

推动系统性变革

通常情况下,bug 被认为是一个与硬件或软件的特定技术问题。然而,我们每天面临的更大或更系统性的问题可以用文化的术语或我们在整个系统中采用的日常惯例和模式来表达。以下是一些典型 IT 咨询公司内部问题的虚构例子:

  • 我们在设计中经常使用不可访问的字体

  • 我们有一百种不同的标准来编写良好的 JavaScript

  • 我们似乎总是忘记更新第三方依赖

  • 我们没有回馈到开源社区

这些问题稍微过于宽泛或主观,无法表达为明确的bug,因此我们需要探索其他方法来展现它们并解决它们。将这样的系统性问题视为成长的机会而不是bug可能会对人们对你提出的变化有多大影响。

总的来说,创建系统性变革涉及的步骤如下:

  1. 资格:用具体例子阐明问题:找到能够展示你试图描述的问题的例子。确保这些例子清楚地显示问题,不要太复杂。以一种即使对于完全沉浸在问题领域之外的人也能理解的方式描述问题。

  2. 反馈:从他人那里收集反馈:从他人那里收集想法和建议。向他们提出开放式问题,比如你对[...]有什么看法?。接受可能不存在问题,或者你遇到的问题最好以其他方式看待。

  3. 构思:共同探讨可能的解决方案:从多个人那里收集关于可能解决方案的想法。不要试图重复造轮子。有时候最简单的解决方案也是最好的。很可能系统性问题不能仅通过技术手段解决。你可能需要考虑社会和沟通方面的解决方案。

  4. 提出:提出问题以及可能的解决方案:根据问题的性质,将其提出给适当的人。这可以通过团队会议、一对一交流或在线沟通来进行。确保以一种非对抗的方式提出问题,并专注于改进和成长。

  5. 实施:共同选择解决方案并开始工作:假设你仍然认为这个问题值得追求,你可以开始实施最优选的解决方案,可能是以一种孤立的概念验证的方式。例如,如果正在解决的问题是我们有一百种不同的标准来编写良好的 JavaScript,那么你可以开始协作实施一套标准,使用一个代码检查工具或格式化工具,一路上寻求反馈,然后慢慢更新旧代码以符合这些标准。

  6. 衡量:经常检查解决方案的成功:从他人那里获得反馈,并寻求可量化的数据来判断所选解决方案是否按预期运行。如果不是,那么考虑重新审视并探索其他解决方案。

创建系统性变革的一个陷阱是等待太久或者在解决问题时过于谨慎。从他人那里获得反馈是非常有价值的,但不必完全依赖他们的验证。有时候,人们很难超越自己的视角看到某些问题,特别是如果他们非常习惯目前的做法。与其等待他们接受你的观点,也许最好的办法是继续推进你提出的解决方案的孤立版本,并后来向他们证明其有效性。

当人们对当前的做法做出反应性的辩护时,他们通常表达的是现状偏见,这是一种情感偏见,偏好当前的事态。面对这样的反应,人们对变化往往会不太欢迎。因此,要谨慎对待他人对你提出的变化的负面反馈。

我们每天希望在我们使用的技术和系统中改变的许多事情并不容易解决。它们可能是复杂的、难以控制的,通常是多学科的问题。这些类型的问题的例子很容易在讨论论坛和围绕标准迭代的社区反馈中找到,比如 ECMAScript 语言规范。很少有一种语言的添加或更改是简单完成的。耐心、考虑和沟通都是解决这些问题并推动我们自己和我们的技术前进所需要的。

总结

在本章中,我们试图探讨技术背景下有效沟通的挑战,并广泛讨论了将问题从构思阶段转化为原型阶段所涉及的沟通过程。我们还涵盖了沟通和倡导技术变革的任务,无论是以错误报告的形式还是提出关于系统性问题的更广泛问题。程序员不仅仅是代码的作者;他们作为正在构建的系统的一部分,是迭代反馈周期中至关重要的代理人,这些反馈周期最终会产生干净的软件。了解我们在这些系统和反馈周期中扮演的重要角色是非常有力量的,并开始触及成为一名干净的 JavaScript 程序员意味着什么的核心。

在下一章中,我们将汇集迄今为止在本书中学到的一切,通过一个案例研究探索一个新的问题领域。这将结束我们对 JavaScript 中干净代码的探索。

第十九章:案例研究

在这本书中,我们已经讨论了一系列原则,几乎涵盖了 JavaScript 语言的方方面面,并且长篇大论地讨论了什么是干净的代码。这一切都是为了最终能够撰写美丽而干净的 JavaScript 代码,解决真实而具有挑战性的问题领域。然而,追求干净的代码永远不会完成;新的挑战总会出现,让我们以新的、颠覆性的方式思考我们所写的代码。

在本章中,我们将走过 JavaScript 中创建新功能的过程。这将涉及客户端和服务器端的部分,并且将迫使我们应用我们在整本书中积累的许多原则和知识。我们将要解决的具体问题是从我负责的一个真实项目中改编的,虽然我们不会涉及其实施的每一个细节,但我们将涵盖最重要的部分。完成的项目可以在以下链接的 GitHub 上查看:github.com/PacktPublishing/Clean-Code-in-JavaScript

在本章中,我们将涵盖以下主题:

  • 问题:我们将定义并探讨问题

  • 设计:我们将设计一个解决问题的用户体验和架构

  • 实施:我们将实施我们的设计

问题

我们将要解决的问题涉及到我们网页应用程序用户体验的核心部分。我们将要处理的网页应用程序是一个大型植物数据库的前端,其中有成千上万种不同的植物。除了其他功能外,它允许用户找到特定的植物并将它们添加到集合中,以便他们可以跟踪他们的异国温室和植物研究清单。如下图所示:

目前,当用户希望找到一种植物时,他们必须使用一个涉及将植物名称(全拉丁名称)输入到文本字段中,点击搜索,并收到一组结果的搜索设施,如下截图所示:

对于我们的案例研究,植物名称只存在于它们的全拉丁名称中,其中包括一个科(例如,Acanthaceae)、一个属(例如,Acanthus)和一个种(例如,Carduaceus)。这突显了满足复杂问题领域所涉及的挑战。

这样做已经足够好了,但在一些用户焦点小组和在线反馈之后,我们决定需要为用户提供更好的用户体验,让他们更快地找到他们感兴趣的植物。提出的具体问题如下:

  • 有时候我觉得查找物种很麻烦而且慢。我希望它更即时和灵活,这样我就不必一直回去修改我的查询,特别是如果我拼错了的话。

  • 通常,当我知道植物的种或属的名称时,我仍然会稍微出错,没有得到结果。然后我就不得不回去调整我的拼写或在其他地方进行搜索。

  • 我希望我在输入时能看到种和属的出现。这样我就可以更快地找到适当的植物,不浪费时间。

这里提出了一些可用性问题。我们可以将它们归纳为以下三个主题:

  • 性能:当前的搜索功能使用起来很慢且笨拙

  • 错误 更正:不得不纠正打字错误的过程令人讨厌和繁琐

  • 反馈:在输入时获得有关现有/的反馈将是有用的

任务现在变得更加清晰。我们需要改进用户体验,使用户能够以更快的方式查询植物数据库,提供更即时的反馈,并让他们在途中防止或纠正输入错误。

设计

经过一些头脑风暴,我们决定可以以相当传统的方式解决我们的问题;我们可以简单地将输入字段转换为提供自动建议下拉框的字段。这是一个模拟:

这个自动建议下拉框将具有以下特点:

  • 当输入一个术语时,它将显示一个按优先级排序的植物名称列表,这些名称包含该术语作为前缀,例如,搜索car将产生结果carnea,但不会产生encarea

  • 当通过点击、箭头(上/下)或Enter键选择一个术语时,它将运行指定的函数(以后可能用于将选定的项目添加到用户的集合中)

  • 当找不到匹配的植物名称时,用户将收到通知,例如“没有该名称的植物存在”

这些是我们组件的核心行为,为了实现它们,我们需要考虑客户端和服务器端的部分。我们的客户端将不得不向用户呈现<input>,并且当他们输入时,它将不得不动态调整建议列表。服务器将不得不为每个潜在的查询提供给客户端一个建议列表,同时考虑到结果需要快速交付。任何显著的延迟都将严重降低我们试图创建的用户体验的好处。

实施

恰好这个新的植物选择组件将是我们网页应用中第一个重要的客户端代码片段,因此,重要的是要注意我们的设计决策不仅会影响到这个特定的组件,还会影响到我们将来考虑构建的任何其他组件。

为了帮助我们实施,并考虑到将来可能的其他潜在添加,我们决定采用 JavaScript 库来辅助 DOM 的操作,并采用支持工具集,使我们能够迅速高质量地工作。在这种情况下,我们决定在客户端使用 React,使用 webpack 和 Babel 进行编译和打包,并在服务器端使用 Express 进行 HTTP 路由。

植物选择应用

正如讨论的那样,我们决定将植物选择功能构建为一个独立的应用,包括客户端(React 组件)和服务器(植物数据 API)。这种隔离程度使我们能够纯粹地专注于选择植物的问题,但这并不意味着这不能在以后集成到更大的代码库中。

我们的目录结构大致如下:

EveryPlantSelectionApp/
├── server/
│   ├── package.json
|   ├── babel.config.js
│   ├── index.js
|   └── plantData/
│       ├── plantData.js
│       ├── plantData.test.js
|       └── data.json
└── client/
    ├── package.json
    ├── webpack.config.js
    ├── babel.config.js
    ├── app/
    |   ├── index.jsx
    |   └── components/
    |       └── PlantSelectionInput/
    └── dist/
        ├── main.js (bundling target)
        └── index.html

除了为我们(程序员)减少复杂性之外,服务器和客户端的分离意味着服务器端应用(即植物选择 API)可以在必要时在自己独立的服务器上运行,而客户端可以从 CDN 静态提供,只需要服务器端的地址即可访问其 REST API。

创建 REST API

EveryPlantSelectionApp的服务器负责检索植物名称(植物),并通过简单的 REST API 使它们可用于我们的客户端代码。为此,我们可以使用express Node.js 库,它使我们能够将 HTTP 请求路由到特定的函数,轻松地向客户端提供 JSON。

这是我们服务器实现的基本框架:

import express from 'express';

const app = express();
const port = process.env.PORT || 3000;

app.get('/plants/:query', (req, res) => {
  req.params.query; // => The query
  res.json({
    fakeData: 'We can later place some real data here...'
  });
});

app.listen(
  port,
  () => console.log(`App listening on port ${port}!`)
);

正如您所看到的,我们只实现了一个路由(/plants/:query)。每当用户在<input/>中输入部分植物名称时,客户端将请求这个路由,因此用户输入Carduaceus可能会产生以下一系列请求到服务器:

GET /plants/c
GET /plants/ca
GET /plants/car
GET /plants/card
GET /plants/cardu
GET /plants/cardua
...

你可以想象这可能导致更多昂贵且可能冗余的请求,特别是如果用户输入速度很快。有可能用户在任何之前的请求完成之前就输入了cardua。因此,当我们开始实现客户端时,我们需要使用某种请求节流(或请求防抖)来确保我们只发出合理数量的请求。

请求节流是通过只允许在指定时间间隔内执行新请求来减少总请求量的行为,这意味着在五秒内跨度为 100 个请求,节流到一个间隔为一秒,将只产生五个请求。请求防抖类似,不过它不是在每个间隔上执行单个请求,而是在实际请求之前等待一定的时间,以便停止产生请求。因此,在五秒内进行 100 个请求,通过五秒的防抖,将在五秒标记处只产生一个最终请求。

为了实现/plants/端点,我们需要考虑通过超过300,000不同植物物种的名称进行匹配的最佳搜索方式。为了实现这一点,我们将使用一种特殊的内存数据结构,称为trie。这也被称为前缀树,在需要自动建议或自动完成的情况下非常常见。

Trie 是一种类似树状结构的数据结构,它将相邻的字母块存储为一系列通过分支连接的节点。它比描述起来更容易可视化,所以让我们想象一下,我们需要基于以下数据创建一个 trie:

['APPLE', 'ACORN', 'APP', 'APPLICATION']

利用这些数据,生成的 trie 可能看起来像这样:

如您所见,我们的四个单词的数据集已被表示为类似树状结构的结构,其中第一个共同的字母"A"作为根。 "CORN"后缀从这里分支出来。此外, "PP"分支(形成"APP")分支出来,然后最后的"P"分支到"L",然后分支到"E"(形成"APPLE")和"ICATION"(形成"APPLICATION")。

这可能看起来很复杂,但是鉴于这种 trie 结构,我们可以根据用户输入的初始前缀(如"APPL")轻松地通过树的节点找到所有匹配的单词("APPLE""APPLICATION")。这比任何线性搜索算法都要高效得多。对于我们的目的,给定植物名称的前缀,我们希望能够高效地显示前缀可能导致的每个植物名称。

我们的特定数据集将包括超过 300,000 种不同的植物物种,但在本案例研究中,我们将只使用Acanthaceae科的物种,大约有 8,000 种。这些可以以 JSON 的形式使用,如下所示:

[
  { id: 105,
    family: 'Acanthaceae',
    genus: 'Andrographis',
    species: 'alata' },
  { id: 106,
    family: 'Acanthaceae',
    genus: 'Justicia',
    species: 'alata' },
  { id: 107,
    family: 'Acanthaceae',
    genus: 'Pararuellia',
    species: 'alata' },
  { id: 108,
    family: 'Acanthaceae',
    genus: 'Thunbergia',
    species: 'alata' },
  // ...
]

我们将把这些数据输入到一个名为trie-search的第三方 trie 实现中。选择这个包是因为它满足我们的要求,并且似乎是一个经过充分测试和维护良好的库。

为了使 trie 按我们的期望运行,我们需要将每种植物的连接成一个字符串。这使得 trie 可以包括完全合格的植物名称(例如"Acanthaceae Pararuellia alata")和分割名称(["Acanthaceae", "Pararuellia", "alata"])。分割名称是由我们使用的 trie 实现自动生成的(意思是它通过正则表达式/\s/g在空格上分割字符串):

const trie = new TrieSearch(['name'], {
  ignoreCase: true // Make it case-insensitive
});

trie.addAll(
  data.map(({ family, genus, species, id }) => {
    return { name: family + ' ' + genus + ' ' + species, id };
  })
);

前面的代码将我们的数据集输入到 trie 中。之后,可以通过简单地将前缀字符串传递给其get(...)方法来查询它:

trie.get('laxi');

这样的查询(例如前缀laxi)将从我们的数据集中返回以下内容:

[
  { id: 203,
    name: 'Acanthaceae Acanthopale laxiflora' },
  { id: 809,
    name: 'Acanthaceae Andrographis laxiflora' },
  { id: 390,
    name: 'Acanthaceae Isoglossa laxiflora' },
  //... (many more)
]

关于我们的 REST 端点/photos/:query,它所需要做的就是返回一个 JSON 有效负载,其中包含我们从trie.get(query)获取的内容:

app.get('/plants/:query', (req, res) => {
  const queryString = req.params.query;
  if (queryString.length < 3) {
    return res.json([]);
  }
  res.json(
    trie.get(queryString)
  );
});

为了更好地分离我们的关注点,并确保我们没有混合太多不同的抽象层(可能违反了迪米特法则),我们可以将我们的 trie 数据结构和植物数据抽象到自己的模块中。我们可以称之为plantData,以传达它封装和提供对植物数据的访问的事实。它的工作方式,恰好是通过内存 trie 数据结构,不需要为其使用者所知:

// server/plantData.js

import TrieSearch from 'trie-search';
import plantData from './data.json';

const MIN_QUERY_LENGTH = 3;

const trie = new TrieSearch(['fullyQualifiedName'], {
  ignoreCase: true
});

trie.addAll(
  plantData.map(plant => {
    return {
      ...plant,
      fullyQualifiedName:
        `${plant.family} ${plant.genus} ${plant.species}`
    };
  })
);

export default {
  query(partialString) {
    if (partialString.length < MIN_QUERY_LENGTH) {
      return [];
    }
    return trie.get(partialString);
  }
};

如您所见,此模块返回一个接口,提供一个query()方法,我们的主 HTTP 路由代码可以利用它来为/plants/:query提供 JSON 结果:

//...
import plantData from './plantData';
//...
app.get('/plants/:query', (req, res) => {
  const query = req.params.query;
  res.json( plantData.query(partial) );
});

因为我们已经隔离和包含了植物查询功能,现在更容易对其进行断言。编写一些针对plantData抽象的测试将使我们对我们的 HTTP 层使用可靠的抽象具有高度的信心,最大程度地减少了 HTTP 层内部可能出现的潜在错误。

此时,由于这是我们为项目编写的第一组测试,我们将安装 Jest(npm install jest --save-dev)。有许多可用的测试框架,风格各异,但对于我们的目的来说,Jest 是合适的。

我们可以在与之直观地位于一起并命名为plantData.test.js的文件中为我们的plantData模块编写测试。

import plantData from './plantData';

describe('plantData', () => {

  describe('Family+Genus name search (Acanthaceae Thunbergia)', () => {
    it('Returns plants with family and genus of "Acanthaceae Thunbergia"', () =>{
      const results = plantData.query('Acanthaceae Thunbergia');
      expect(results.length).toBeGreaterThan(0);
      expect(
        results.filter(plant =>
          plant.family === 'Acanthaceae' &&
          plant.genus === 'Thunbergia'
        )
      ).toHaveLength(results.length);
    });
  });

});

plantData.test.js中有许多测试未在此处包含,以保持简洁;但是,您可以在 GitHub 存储库中查看它们:github.com/PacktPublishing/Clean-Code-in-JavaScript

如您所见,此测试断言Acanthaceae Thunbergia查询是否直观地返回包含这些术语的完全限定名称的植物。在我们的数据集中,这将仅包括具有Acanthaceae家族和Thunbergia属的植物,因此我们可以简单地确认结果是否符合预期。我们还可以检查部分搜索,例如Acantu Thun,是否也直观地返回任何以AcantuThun开头的家族名称的植物:

describe('Partial family & genus name search (Acantu Thun)', () => {
  it('Returns plants that have a fully-qualified name containing both "Acantu" and "Thunbe"', () => {
    const results = plantData.query('Acant Thun');
    expect(results.length).toBeGreaterThan(0);
    expect(
      results.filter(plant =>
        /\bAcant/i.test(plant.fullyQualifiedName) &&
        /\bThun/i.test(plant.fullyQualifiedName)
      )
    ).toHaveLength(results.length);
  });
});

我们通过断言每个返回结果的fullyQualifiedName是否与正则表达式/\bAcant/i/\bThun/i匹配来确认我们的期望。/i表达式表示区分大小写。这里的\b表达式表示单词边界,以便我们可以确保AcantThun子字符串出现在单词的开头,并且不嵌入在单词中。例如,想象一个名为Luathunder的植物。我们不希望我们的自动建议机制匹配这样的实例。我们只希望它匹配前缀,因为用户将输入植物家族<input />中(从每个单词的开头)。

现在我们有了经过充分测试和隔离的服务器端架构,我们可以开始转向客户端,我们将在用户输入时呈现由/plants/:query提供的植物名称。

创建客户端构建过程

我们在客户端的第一步是引入React和一个支持开发的工具集。在以前的网页开发时代,可以在没有复杂工具和构建步骤的情况下构建东西,这在某种程度上仍然是可能的。在过去,我们可以简单地创建一个 HTML 页面,内联包含任何第三方依赖项,然后开始编写我们的 JavaScript,而不必担心其他任何事情:

<body>
  ... Content
  <script src="//example.org/libraryFoo.js"></script>
  <script src="//example.org/libraryBaz.js"></script>
  <script>
    // Our JavaScript code...
  </script>
</body>

从技术上讲,我们仍然可以这样做。即使使用现代前端框架如 React,我们也可以选择将其作为<script>依赖项包含在内,然后内联编写原始 JavaScript。然而,通过这样做,我们将无法获得以下优势:

  • 更新的 JavaScript 语法(ES 2019 及更高版本):能够使用现代 JavaScript 语法,并将其编译为在所有环境/浏览器中安全使用的 JavaScript。

  • 自定义语法和语言扩展:能够使用语言扩展(如 JSX 或 FlowJS)或编译为 JavaScript 的其他语言(如 TypeScript 或 CoffeeScript)。

  • 依赖树管理:能够轻松指定您的依赖关系(例如使用import语句),并将这些自动协调和合并成一个捆绑,而无需手动操作<script>标签和版本化噩梦。

  • 性能改进:智能编译和打包可以通过减少 JavaScript 和 CSS 的整体占用空间,从而提供有意义的 HTTP 和运行时性能增益。

  • 检查器和分析器:能够在您的 JavaScript(以及您的 CSS 和 HTML)上使用检查器和其他形式的分析,让我们深入了解代码质量和潜在的错误。

从根本上说,现在 Web 应用的性质更加复杂,特别是在前端。为了创建一个自动建议组件,我们需要确保我们有一套良好的工具和构建步骤基础,以便持续开发可以无缝和简单。在设置这些东西时可能会带来一些麻烦,但从长远来看是值得的。

为了编译我们的 JavaScript(包括 React 的 JSX),我们将使用Babel,它可以将我们的 JavaScript 转换为广泛支持的常规 JavaScript 语法。要在EveryPlantSelectionApp/client中添加 Babel 作为依赖项,我们可以使用npm来安装它及其各种预设配置:

# Install babel's core dependencies:
npm install --save-dev @babel/core @babel/cli

# Install some smart presets for Babel, allowing us to not have
# to worry about which specific JS syntax we're using:
npm install --save-dev @babel/preset-env

# Install a smart preset for React (i.e. JSX) usage:
npm install --save-dev @babel/preset-react

Babel 将管理我们的 JavaScript 的编译,使其成为广泛支持的语法。但是,为了使这些文件准备好交付给浏览器,我们需要将它们捆绑成一个单一的文件,可以像这样在我们的 HTML 中单独交付:

<script src="./ourBundledJavaScript.js"></script>

为了实现这一点,我们需要使用一个打包工具,比如 webpack。Webpack 可以为我们执行以下任务:

  • 它可以通过 Babel 编译 JavaScript

  • 然后它可以协调每个模块,包括任何依赖项

  • 它可以生成一个包含所有依赖关系的单一捆绑的 JavaScript 文件

为了使用 webpack,我们需要安装几个相关的依赖项:

# Install Webpack and its CLI:
npm install --save-dev webpack webpack-cli

# Install Webpack's development server, which enables us to more easily
# develop without having to keep re-running the build process:
npm install --save-dev webpack-dev-server

# Install a couple of helpful packages that make it easier for
# Webpack to make use of Babel:
npm install --save-dev babel-loader babel-preset-react

Webpack 还需要自己的配置文件,名为webpack.config.js。在这个文件中,我们必须告诉它如何打包我们的代码,以及我们希望打包的代码输出到项目中的哪个位置:

const path = require('path');

module.exports = {
  entry: './app/index.jsx',
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/react']
          }
        }
      }
    ]
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    port: 9000
  },
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  }
};

这个配置基本上告诉 webpack 以下内容:

  • 请从EveryPlantSelectionApp/client/app/index.jsx开始

  • 请使用 Babel 来编译此模块及其以.jsx.js结尾的所有依赖项

  • 请将编译和捆绑文件输出到EveryPlantSelectionApp/client/dist/

最后,我们需要安装 React,这样我们就可以准备创建我们的植物选择组件:

npm install --save react react-dom

看起来好像这样做很多工作只是为了渲染一个基本的 UI 组件,但实际上我们已经创建了一个基础,可以容纳许多新功能,并且我们已经创建了一个构建流水线,可以更容易地将我们的开发代码库交付到生产环境。

创建组件

我们组件的工作是显示一个增强的<input>元素,当聚焦时,会根据用户输入的内容呈现一个下拉式的可用选项列表,用户可以从中选择。

作为一个原始的大纲,我们可以想象组件包含<div>,用户可以在其中输入的<input>,以及显示建议的<ol>

const PlantSelectionInput = () => {
  return (
    <div className="PlantSelectionInput">
      <input
        autoComplete="off"
        aria-autocomplete="inline"
        role="combobox" />
      <ol>
        <li>A plant name...</li>
        <li>A plant name...</li>
        <li>A plant name...</li>
      </ol>
    </div>
  );
};

<input>上的rolearia-autocomplete属性用于指示浏览器(以及任何屏幕阅读器),用户在键入时将提供一组预定义的选择。这对于可访问性至关重要。autoComplete属性用于简单地启用或禁用浏览器的默认自动完成行为。在我们的情况下,我们希望禁用它,因为我们提供了自己的自定义自动完成/建议功能。

我们只希望在<input>聚焦时显示<ol>。为了实现这一点,我们需要绑定到<input>的焦点和失焦事件,然后创建一个可以跟踪我们是否应该考虑组件打开或关闭的独立状态。我们可以称这个状态为isOpen,并根据它的布尔值有条件地渲染或不渲染<ol>

const PlantSelectionInput = () => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div className="PlantSelectionInput">
      <input
        onFocus={() => setIsOpen(true)}
        onBlur={() => setIsOpen(false)}
        autoComplete="off"
        aria-autocomplete="inline"
        role="combobox" />
      {
        isOpen &&
          <ol>
             <li>A plant name...</li>
             <li>A plant name...</li>
             <li>A plant name...</li>
          </ol>
      }
    </div>
  );
};

React 有自己关于状态管理的约定,如果你之前没有接触过,可能看起来很奇怪。const [foo, setFoo] = useState(null)代码创建了一个状态(称为foo),我们可以根据某些事件来改变它。每当这个状态改变时,React 就会知道触发相关组件的重新渲染。回到第十二章*,真实世界的挑战*,看看DOM 绑定和协调部分,以便重新了解这个主题。

接下来,我们需要绑定到<input>change事件,以便获取用户输入的内容,并触发对我们的/plants/:query端点的请求,以便确定向用户显示什么建议。然而,首先,我们希望创建一个机制,通过它可以发出请求。在 React 世界中,它建议将这种功能建模为自己的Hook。记住,按照约定,Hook 通常以use动词为前缀,我们可以将其称为usePlantLike。作为它的唯一参数,它可以接受一个query字段(用户键入的字符串),它可以返回一个带有loading字段(指示当前加载状态)和plants字段(包含建议)的对象:

// Example of calling usePlantsLike:
const {loading, plants} = usePlantsLike('Acantha');

我们的usePlantsLike的实现非常简单:

// usePlantLike.js

import {useState, useEffect} from 'react';

export default (query) => {
  const [loading, setLoading] = useState(false);
  const [plants, setPlants] = useState([]);

  useEffect(() => {
    setLoading(true);
    fetch(`/plants/${query}`)
      .then(response => response.json())
      .then(data => {
        setLoading(false);
        setPlants(data);
      });
  }, [query]);

  return { loading, plants };
};

在这里,我们使用另一种React状态管理模式useEffect(),以便在query参数发生变化时运行特定的函数。因此,如果usePlantLike接收到一个新的query参数,例如Acantha,那么加载状态将被设置为true,并且将启动一个新的fetch(),其结果将填充plants状态。这可能很难理解,但就案例研究而言,我们真正需要理解的是usePlantsLike这种抽象封装了向服务器发出/plants/:query请求的复杂性。

将渲染逻辑与数据逻辑分开是明智的。这样做可以确保良好的抽象层次和关注点分离,并将每个模块作为单一责任的领域。传统的 MVC 和 MVVM 框架有助于强制进行这种分离,而更现代的渲染库(如 React)则给了你更多的选择。因此,在这里,我们选择将数据和服务器通信逻辑隔离在一个 React Hook 中,然后由我们的组件使用。

现在,每当用户在<input>中键入内容时,我们可以使用我们的新 React Hook。为了做到这一点,我们可以绑定到它的change事件,每次触发时,获取它的value,然后将其作为query参数传递给usePlantsLike,以便为用户派生一组新的建议。然后可以在我们的<ol>容器中呈现这些建议:

const PlantSelectionInput = ({ isInitiallyOpen, value }) => {

  const inputRef = useRef();
  const [isOpen, setIsOpen] = useState(isInitiallyOpen || false);
  const [query, setQuery] = useState(value);
  const {loading, plants} = usePlantsLike(query);

  return (
    <div className="PlantSelectionInput">
      <input
        ref={inputRef}
        onFocus={() => setIsOpen(true)}
        onBlur={() => setIsOpen(false)}
        onChange={() => setQuery(inputRef.current.value)}
        autoComplete="off"
        aria-autocomplete="inline"
        role="combobox"
        value={value} />
      {
        isOpen &&
          <ol>{
            plants.map(plant =>
              <li key={plant.id}>{plant.fullyQualifiedName}</li>
            )
          }</ol>
      }
    </div>
  );
};

在这里,我们添加了一个新的状态query,通过<input>onChange处理程序设置它。然后,这个query变化将导致usePlantsLike从服务器发出一个新的请求,并用多个<li>元素填充<ol>,每个元素代表一个单独的植物名称建议。

有了这一点,我们已经完成了组件的基本实现。为了使用它,我们可以在我们的client/index.jsx入口点中呈现它:

import ReactDOM from 'react-dom';
import React from 'react';
import PlantSelectionInput from './components/PlantSelectionInput';

ReactDOM.render(
  <PlantSelectionInput />,
  document.getElementById('root')
);

此代码尝试将<PlantSelectionInput/>呈现到具有"root"ID 的元素上。如前所述,我们的捆绑工具 webpack 将自动将编译后的 JavaScript 捆绑成一个名为main.js的文件,并将其放置在dist/(即分发)目录中。这将与我们的index.html文件并排,后者将作为用户界面的入口到我们的应用程序。对于我们的目的,这只需要是一个简单的页面,用于演示PlantSelectionInput

<!DOCTYPE html>
<html>
<head>
  <title>EveryPlant Selection App</title>
  <style>
    /* our styles... */
  </style>
</head>
<body>
  <div id="root"></div>
  <script src="./main.js"></script>
</body>
</html>

我们可以在index.html中的<style>标签中放置任何相关的 CSS:

<style>
.PlantSelectionInput {
  width: 100%;
  display: flex;
  position: relative;
}
.PlantSelectionInput input {
  background: #fff;
  font-size: 1em;
  flex: 1 1;
  padding: .5em;
  outline: none;
}
/* ... more styles here ... */
</style>

在较大的项目中,最好提出一个可以很好地与许多不同组件配合使用的缩放 CSS 解决方案。在React中运行良好的示例包括CSS 模块styled components,两者都允许您定义仅针对单个组件的 CSS,避免了全局 CSS 的麻烦。

我们组件的样式并不特别具有挑战性,因为它只是一个文本项的列表。主要挑战在于确保当组件处于完全打开状态时,建议列表出现在页面上的任何其他内容之上。这可以通过相对定位<input>容器,然后绝对定位<ol>来实现,如下所示:

这结束了我们组件的实现,但我们还应该实现基本级别的测试(至少)。为了实现这一点,我们将使用 Jest,一个测试库,以及它的快照匹配功能。这将使我们能够确认我们的 React 组件生成了预期的 DOM 元素层次结构,并将保护我们免受未来的回归:

// PlantSelectionInput.test.jsx

import React from 'react';
import renderer from 'react-test-renderer';
import PlantSelectionInput from './';

describe('PlantSelectionInput', () => {

  it('Should render deterministically to its snapshot', () => {
    expect(
      renderer
        .create(<PlantSelectionInput />)
        .toJSON()
    ).toMatchSnapshot();
  });

  describe('With configured isInitiallyOpen & value properties', () => {
    it('Should render deterministically to its snapshot', () => {
      expect(
        renderer
          .create(
            <PlantSelectionInput
              isInitiallyOpen={true}
              value="Example..."
            />
          )
          .toJSON()
      ).toMatchSnapshot();
    });
  });

});

Jest 会将生成的快照保存到__snapshots__目录中,然后将任何将来执行的测试与这些保存的快照进行比较。除了这些测试,我们还可以实现常规的功能性测试,甚至是 E2E 测试,可以编码期望,比如当用户输入时,建议列表相应更新

这结束了我们对组件和案例研究的构建。如果您查看我们的 GitHub 存储库,您可以看到已完成的项目,与组件一起玩耍,自己运行测试,并且您还可以分叉存储库以进行自己的更改。

这是 GitHub 存储库的链接:github.com/PacktPublishing/Clean-Code-in-JavaScript

总结

在这最后一章中,我们通过书中积累的原则和经验,探讨了一个真实世界的问题。我们提出了用户遇到的问题,然后设计和实现了一个干净的用户体验来解决他们的问题。这包括了服务器端和客户端的部分,使我们能够从头到尾看到一个独立的 JavaScript 项目可能是什么样子。虽然我们无法涵盖每一个细节,但我希望这一章对于巩固干净代码背后的核心思想有所帮助,现在您应该更有信心编写干净的 JavaScript 代码来解决各种问题领域。我希望您能带走的一个核心原则就是:专注于用户

posted @ 2024-05-22 12:06  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报