JavaScript-代码整洁指南-全-
JavaScript 代码整洁指南(全)
原文:
zh.annas-archive.org/md5/EBCF13D1CBE3CB1395B520B840516EFC
译者:飞龙
前言
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 + J,macOS 上按 CMD + Shift + J
在 Firefox 中:Windows 和 Linux 上按 Ctrl + Shift + I 或 F12,macOS 上按 CMD + OPTION + I
在 IE 中:Windows 上按 F12
建议您按照自己的步调阅读本书,并在发现某个主题难以理解时,在网上进行额外的研究和探索。一些特别有用的资源包括以下内容:
Mozilla 开发者网络:
developer.mozilla.org/en-US/docs/Web/JavaScript
ECMAScript 语言规范:
www.ecma-international.org/publications/standards/Ecma-262.htm
随着您在书中的进展,书中的内容会变得越来越详细,因此在后面的章节中放慢步伐是很自然的。这对于第 6-12 章尤其如此,这些章节非常详细地介绍了 JavaScript 语言本身的特性。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
在www.packt.com上登录或注册。
选择“支持”选项卡。
点击“代码下载”。
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本的软件解压或提取文件夹:
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 世纪初,人们观察到一些美拉尼西亚文化会进行模仿西方技术和行为的仪式,比如用木头和黏土建造跑道和控制塔。他们这样做是希望物质财富,比如食物,会被送到他们那里。这些奇怪的仪式出现是因为他们之前观察到货物是通过西方飞机送来的,错误地得出结论认为是跑道本身召唤了货物。
现在,在编程中,我们使用术语“模仿行为”或“模仿”来广泛描述复制模式和行为,而不完全理解它们真正的目的和功能。当程序员在网上搜索解决方案,并复制并粘贴他们找到的第一段代码,而不考虑其可靠性或安全性时,他们正在进行模仿行为,试图通过使用在其他上下文中似乎负责这个任务的代码来完成某个任务。
模仿行为通常包括以下过程:
人处于一个略微陌生的技术环境中
-
- 人看到他们希望模仿的效果
-
- 人复制似乎产生所需效果的代码
这种行为在组织和技术上都可能发生。程序员有时被要求将他们很少了解的不同技术依赖关系联系在一起,通常会别无选择,只能进行模仿。而组织通常没有时间考虑所有的基本原则,往往最终会从其他组织中模仿流行的行为和流程。
- 模仿代码
为了说明模仿行为,让我们想象一个程序员的任务是向他们的 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。要在文档中显示生日
消息,我们首先自己构建字符串,并将其放入文本节点,然后将其附加到具有message
和birthday-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 模式可能需要更改
鉴于潜在变更的不同原因的数量,将变更分割为更合适的抽象是有意义的。例如,特定事件的时间和标题设置方法(setTimeOfEvent
,setTitleOfEvent
)绝对应该在“事件”类本身内部,因为它们与“事件”类的目的高度相关:包含有关特定事件的详细信息。导出到 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
方法依赖于其对象的记忆状态,endpoint
和nextPage
,以了解下一个要请求的 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
函数时,将返回一个包含来自服务器的响应以及使用的endpoint
和pageNumber
的对象。除了这些属性之外,该对象还将包含一个名为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
函数不会改变任何数据:它只使用传递的数据来派生新数据,因此可以说它采用了不可变性。还可以说它也是一个纯函数,因为对于给定的输入参数(endpoint
和pageNumber
),它将始终返回相同的结果。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
你可能已经选择了你喜欢的。在阅读本书的人中,肯定会有分歧。这些分歧中的许多将根植于我们自己的偏见。我们的许多偏见将受到诸如我们说什么语言、我们之前接触过哪些编程语言以及我们花时间创建哪种类型的程序等因素的影响。我们之间存在许多差异,但是,不知何故,我们必须提出一个关于好名字或干净名字的非模糊概念。至少可以说,一个好的名字可能具有以下特征:
目的:某物的用途和行为方式
概念:它的核心思想以及如何思考它
合同:关于它如何工作的期望
这并不能完全涵盖命名的复杂性,但是有了这三个特征,我们有了一个起点。在本节的其余部分,我们将学习这些特征如何对命名事物的过程至关重要。
目的
一个好的名字表明了目的。目的是某物做什么,或者某物是什么。在函数的情况下,它的目的就是它的行为。这就是为什么函数通常以动词形式命名,比如getUser
或createAccount
,而存储值的东西通常是名词,比如account或button。
一个概括清晰目的的名字永远不需要进一步解释。它应该是不言自明的。如果一个名字需要注释来解释它的目的,那通常意味着它没有完成作为名字的工作。
某物的目的是高度上下文化的,因此将受到周围代码和该名字所在代码库区域的影响。这就是为什么通常可以使用通用名字,只要它周围有助于说明其目的的上下文。例如,比较TenancyAgreement
类中的这三个方法签名:
class TenancyAgreement {
// Option #1:
saveSignedDocument(
id,
timestamp
) {}
// Option #2:
saveSignedDocument(
documentId,
documentTimestamp
) {}
// Option #3:
saveSignedDocument(
tenancyAgreementSignedDocumentID,
tenancyAgreementSignedDocumentTimestamp
) {}
}
当然,这是有主观性的,但大多数人会同意,当我们有一个能够很好地传达其目的的周围上下文时,我们不需要详细命名该上下文中的每个变量。考虑到这一点,我们可以说前面代码中的Option #1
过于局限,可能会引起歧义,而Option #3
过于冗长,因为其参数名称的一部分已经由其上下文提供。然而,Option #2
,使用documentId
和documentTimestamp
,是恰到好处的:它充分传达了参数的目的。这就是我们需要的。
目的对于任何名称来说绝对是至关重要的。没有描述或目的的指示,名称只是装饰而已,通常意味着我们的代码使用者只能在文档和其他代码片段之间翻找,才能弄清楚某些事情。因此,我们必须记住始终考虑我们的名称是否能很好地传达目的。
概念
一个好的名称表明概念。名称的概念指的是其背后的想法,其创建的意图,以及我们应该如何思考它。例如,一个名为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
的上下文之外,这些术语将非常模糊。然而,因为我们在与制作过滤请求相关的函数的暗示命名空间内,术语response和data足以传达它们的概念。
我们的名称传达的概念,就像它们的目的一样,与它们指定的上下文密切相关,因此重要的是不仅要考虑名称本身,还要考虑其周围的一切:它所在的复杂逻辑和行为的错综复杂。所有代码都涉及一定程度的复杂性,对这种复杂性的概念理解对于能够掌握它至关重要。因此,在命名某物时,有助于问自己:*我希望他们如何理解这种复杂性?*如果您正在为其他程序员创建一个简单的接口,编写一个深度嵌入的硬件驱动程序,或者为非程序员创建一个 GUI,这是相关的。
合同
一个好的名称表示与周围抽象的其他部分的合同。通过其名称,变量可能指示它将如何使用或包含什么类型的值以及我们对其行为应有的一般期望。通常不会考虑,但是当我们命名某物时,实际上正在建立一系列隐含的期望或合同,这些期望或合同将定义人们如何理解和使用该物。以下是 JavaScript 中存在的隐含合同的一些示例:
以is开头的变量,例如
isUser
,预期是布尔类型(true
或false
)。全大写的变量预期是一个常量(只设置一次且不可变),例如
DEFAULT_USER_EXPIRY
。以复数命名的变量(例如 elements)预期包含一个或多个项目的集合对象(例如数组),而以单数命名的变量(例如 element)只预期包含一个项目(不在集合中)。
以
get
、find
或select
开头的函数通常预期会向您返回一些东西。以process
、build
或run
开头的函数更加模糊,可能不会这样做。属性或方法名称以下划线开头,例如
_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
属性。因此,在功能层面上,我们可以辨别出它在做什么,但其含义和意图很难理解。程序员使用了单个字母的名称(f
,x
,n
),并且还使用了缩写的函数名称(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();
}
总的来说,名称应该是无聊的。它们不应该吸引注意力。它们应该只展示它们的简单含义,而不会让其他程序员感到*哦,原来是这个意思!或哈哈,好聪明!*我们的自我可能对命名有自己的想法,但我们应该记住限制自我,纯粹考虑那些必须忍受尝试理解我们的代码和我们创建的接口的人。总的来说,以下建议将使我们走上正确的道路:
避免使用普通词的花哨或更长的同义词:例如,使用
kill
或obliterate
而不是delete
避免不存在的词:例如,
deletify
,elementize
或dedupify
避免双关语或巧妙的暗示:例如,使用化学元素名称来指代 DOM 元素
过度异国情调会冒险疏远我们的受众。你可能很容易理解你采用的名称,但这并不意味着其他人也能轻松理解。程序员社区非常多样化,有许多不同的文化和语言背景。最好坚持使用描述性和无聊的名称,以便你的代码能够被尽可能多的人理解。
不必要的冗长的名称
正如我们已经发现的,不必要的短名称实际上是没有足够意义的名称。因此,不必要的长名称是一个意义过多的名称。你可能会想一个名称怎么会有太多的意义。意义是好事,但是过多的意义压缩到一个名称中只会导致混淆;例如:
documentManager.refreshAndSaveSignedAndNonPendingDocuments();
这个名称很难理解:它是在刷新和保存已签署和非挂起的文档,还是在刷新和保存既已签署又非挂起的文档?不清楚。
这个长名称给了我们一个线索,表明底层抽象是不必要地复杂。我们可以将名称分解为其组成部分,以充分了解其接口:
refresh (verb):文档上发生的刷新动作
save (verb):文档上发生的保存动作
signed (adjective):文档的已签署状态
non-pending (adjective):文档的非挂起状态
document (noun):文档本身
在这里我们有一些不同的事情发生。对于这么长的名称,一个很好的指导原则是重构底层抽象,以便我们只需要一个最多包含一个动词,一个形容词和一个名词的名称。例如,我们可以将我们的长名称拆分为四个不同的函数:
documentManager.refreshSignedDocuments();
documentManager.refreshNonPendingDocuments();
documentManager.saveSignedDocuments();
documentManager.saveNonPendingDocuments();
或者,如果意图是对携带多种状态(SIGNED
和NON_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() {}
// ...
}
这个接口现在更容易理解了。使用起来更加清晰,认知负担更小。作为这个抽象的用户,我不再需要记住我应该使用add、set还是push。一致性是通过避免不必要的差异而产生的特征。不一致会让人感到不适,因此它们只应该用于标示真正的功能或概念差异。
技术和考虑因素
由于 JavaScript 的不断变化,它积累了大量相互冲突的约定。其中许多约定在支持或反对方面都引起了强烈的意见。然而,我们已经就一些基本的命名约定达成了一致,这些约定或多或少地得到了全球范围内的接受:
常量应该用下划线分隔的大写字母命名;例如,
DEFAULT_COMPONENT_COLOR
构造函数或类应该采用驼峰命名法,首字母大写;例如,
MyComponent
其他所有内容都应该采用驼峰命名法,首字母小写;例如,
myComponentInstance
除了这些基本约定之外,命名的决定很大程度上取决于程序员的创造力和技能。你最终使用的名称将在很大程度上由你解决的问题所定义。大多数代码将继承与其接口的 API 的命名约定。例如,使用 DOM API 通常意味着你采用诸如element、attribute和node之类的名称。许多流行的可用框架也会倾向于规定我们采用的名称。从你所在的生态系统中采用这样的常规范式是非常有用和必要的,但同时拥有一些基本的技术和概念也是很有用的,这样你就可以在新的和陌生的问题领域中构建出命名得体的抽象。
匈牙利命名法
JavaScript 是一种动态类型语言,这意味着值的类型将在运行时确定,并且任何变量包含的类型可能在运行时发生变化。这与静态类型语言相反,后者在编译时会警告你关于类型的使用。这意味着,作为 JavaScript 程序员,我们需要在使用类型和命名变量的方式上更加小心。
众所周知,当我们命名事物时,我们是在暗示一个约定。这个约定将定义其他程序员如何使用该事物。这就是为什么在各种语言中,匈牙利命名法非常流行的部分原因。它涉及在名称本身中包含类型注释,就像这样:
我们可以使用
elButton
或buttonElement
,而不是button
我们可以使用
nAge
或ageNumber
,而不是age
我们可以使用
objDetails
或detailsObject
,而不是details
匈牙利命名法有以下几个原因:
确定性:它为您的代码读者提供了更多关于名称目的和约定的确定性
一致性:它会导致更一致的命名方式
强制执行:它可能导致代码内更好地执行类型约定
然而,它也有以下缺点:
运行时更改:如果底层类型在运行时被糟糕的代码更改(例如,如果函数将
nAge
变成字符串),那么该名称将不再有用,甚至可能误导我们。代码库的僵化:它可能导致代码库变得僵化,难以对类型进行适当的更改。重构旧代码可能变得更加繁重。
缺乏含义:仅知道变量的类型并不能告诉我们它的目的、概念或约定,就像一个真正描述性的非类型变量名那样。
在 JavaScript 的领域中,我们看到匈牙利命名法在一些地方被使用:最常见的是在命名可能指向 DOM 元素的变量时。这些名称的标注通常以elHeader
、headerEl
、headingElement
或者$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 中,大多数抽象最终都会体现在函数中。即使在大型架构中,也是单个函数和方法在工作,而且在它们的构思中,一个好的抽象开始显现出来。因此,值得深入思考我们应该如何命名我们的函数以及在这样做时应该考虑哪些因素。
函数的名称通常应该使用语法中所谓的命令形式。当我们给出指示时,就是我们使用的命令形式,比如走到商店,买面包,停在那里。
尽管我们通常在命名函数时使用命令形式,但也有例外。例如,惯例上也会在返回布尔值的函数前加上is或has;例如,isValid(...)
。在创建构造函数(它们也是函数)时,我们会根据它们将生成的实例来命名;例如,Route
或SpecialComponent
。
在编程环境中,命令形式的直接性是最容易理解和阅读的。要找到特定问题的正确命令形式,最好想象一下发布军事命令的过程,也就是说,不要拐弯抹角,准确地说明你想要发生的事情:
如果你想显示提示,使用
displayPrompt()
如果你想要移除元素,使用
removeElements()
如果你想要一个在
x
和y
之间的随机数,使用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
函数很可能在表面之下做了各种事情。它可能非常复杂。但在它将被使用的抽象层面上,可以说它只做了一件事,这才是重要的。
三个糟糕的名字
如果你对名字感到困惑,有一个聪明的方法可以帮助你摆脱困境。当你有一个需要命名的抽象或变量时,仔细看看它的功能或包含的内容,然后想出至少三个描述它的糟糕名字。现在不要担心你希望提供的抽象或接口;只是想象你在向一个对代码库一无所知的人描述功能。直接而描述性。
例如,假设我们嵌入在处理设置新用户名的代码库的部分。我们需要检查用户名是否与一组特定的禁止单词匹配,比如admin
、root
或user
。我们想写一个函数来做这个,但我们不确定该选什么名字。因此,我们决定尝试三个糟糕的名字的方法。这是我们想出的名字:
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 在属性访问时会将原始值包装在它们各自的包装对象中。这适用于所有原始值,除了 null
和 undefined
。
在这些被包装的时刻,原始值保持不变,但是通过它们的包装实例,可以访问属性和方法。字符串值将被包装在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"
如果您需要一个对象来添加自定义属性,最好使用一个普通对象。除了用于包装其原始值以外的任何其他内容,都是一种反模式,因为其他程序员不会预期这样做。尽管如此,观察和记住原始类型及其相应包装对象之间的差异是很有用的。
调用包装构造函数(例如Number
,String
等)作为常规函数具有独特的行为。它不会返回一个新的包装实例,而是会将值转换为特定类型并返回一个常规的原始值。当您需要将一种类型转换为另一种类型时,这是非常有用的:
// 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 中,布尔上下文中的所有值都将计算为true
或false
。为了描述这种行为,我们通常将值称为真实或虚假。要确定值的真实性,我们可以简单地将其传递给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
属性的存在将不会达到预期的效果。也许系统需要适应新生儿被输入系统的可能性,但突然间因为age
是0
而崩溃。在这种情况下,最好是预先明确,即使您不希望出现奇怪的虚假值。在这种情况下,我们可能希望检查null
或undefined
,因此我们应该明确这样做:
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⁵³
或9007199254740991
(Number.MAX_SAFE_INTEGER
)的整数小于
-2⁵³
或-9007199254740991
(Number.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_INTEGER
和Number.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
}
我们可以采取的另一种方法是将我们处理的任何小数转换为由Number
或BigInt
类型表示的整数。因此,如果我们需要以八位小数的精度表示从0
到1
的值,那么我们可以简单地将这些值乘以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
类型下要讨论的话题,那就是NaN
。NaN
是一个技术上属于Number
类型的原始值。它表示无法将某些东西解析为数字;例如,Number('wow')
评估为NaN
。由于typeof NaN
是number
,我们应该以以下方式检查有效数字:
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
、-Infinity
和NaN
之外,所有属于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,357和56,380个十进制代码单元。由于有这么多代码单元,使用十六进制数字来表示它们更简单、更方便,因此我们可以说熊猫表情符号由代码单元U+D83D
和U+DC3C
表示(Unicode 十六进制值通常以U+
为前缀)。
除了代理对,还有另一种有用的组合类型需要了解。组合代码点可以将某些传统的非组合代码点增强为新的字符。其中的例子包括可以用重音或其他增强来增强的传统拉丁字符,比如组合波浪符:
我们选择通过 Unicode 转义序列(\u0303
)来表示这个特定的组合字符。\uXXXX
的格式允许我们在 JavaScript 字符串中表示U+0000
到U+FFFF
之间的 Unicode 代码单元。
U+0000
到U+FFFF
之间的 Unicode 范围被称为基本多文种平面(BMP),包括最常用的日常字符。
我们的熊猫表情符号,正如我们已经看到的那样,是一个相当晦涩的符号。它在 BMP 上不存在,因此由两个 UTF-16 代码单元的代理对表示。我们可以通过两个 Unicode 转义序列在 JavaScript 字符串中分别表示它们:
更晦涩和古老的符号位于U+010000
和U+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
原始类型用于表示true
或false
。这两个极端是它唯一的值:
const isTrue = true;
const isFalse = false;
从语义上讲,布尔值用于表示现实生活或问题域的值,可以被认为是开启或关闭(0
或1
),例如,一个功能是否启用,或者用户是否超过一定年龄。这些都是布尔特征,因此适合通过布尔值来表达。我们可以使用这些值来控制程序中的控制流程:
const age = 100;
const hasLivedTo100 = age >= 100;
if (hasLivedTo100) {
console.log('Congratulations on living to 100!');
}
Boolean
原始类型,就像String
和Number
一样,可以手动包装在包装实例中,如下所示:
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
类似:任何直观数值且可以表示为整数的值都可以存储在BigInt
或Number
中,具体取决于它所需的精度。
符号
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
指定为true
或false
(Boolean
),但为了表示我们对其真实值的无知,我们将其设置为null
。我们也可以完全省略该值,这意味着它实际上是undefined
。关键区别在于使用null
总是主动进行的,而undefined
是某事没有完成的结果。
如前所述,null
值始终是假值,这意味着在Boolean
上下文中它将始终计算为false
。因此,如果我们尝试在条件语句中使用null
,那么它将不会成功:
function setRestaurantFeatures(features) {
if (features.hasParking) {
// This will not run as hasParking is null
}
}
重要的是要检查我们想要的确切值,这样我们可以避免错误并有效地向阅读我们代码的人传达信息。在这种情况下,我们可能希望明确检查undefined
和null
,因为我们想要针对这种情况执行不同的代码,而不是针对false
的情况。我们可以这样做:
if (features.hasParking !== null && features.hasParking !== undefined) {
// hasParking is available...
} else {
// hasParking is not set (undefined) or unavailable (null)
}
我们还可以使用抽象相等运算符(==
)来与null
进行比较,如果操作数是null
或undefined
,它将有用地评估为true
:
if (features.hasParking != null) {
// hasParking is available...
} else {
// hasParking is not set (undefined) or unavailable (null)
}
事实上,这与更明确的比较是一样的,但更加简洁。不幸的是,它并不清楚它的意图是检查null
和undefined
。通常我们应该更加明确,因为这样可以更有效地向其他程序员传达我们的意图。
要避免的最后一个陷阱是null
的typeof
运算符。由于 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
这种行为是寻求访问undefined
或null
值的任何属性时总是会抛出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
运算符,它将始终返回real
的undefined
值:
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
这在历史上曾被用于允许实现数据结构,其中键实际上是非原始的(尽管对象在技术上将属性名称存储为字符串)。然而,如今更倾向于使用Map
或WeakMap
。
属性描述符
以常规方式向对象添加属性,无论是通过属性访问还是通过对象文字,属性都将具有以下隐式特征:
configurable
:这意味着属性可以从对象中删除(如果其属性描述符可以更改)enumerable
:这意味着属性将对for...in
和Object.keys()
等枚举可见writable
:这意味着可以通过赋值运算符(例如obj.prop = ...
)更改属性的值
JavaScript 赋予你关闭这些特性的权力,但要注意,对这些特性的更改可能会使代码的行为变得模糊。例如,如果一个属性被描述为不可写,但尝试通过赋值进行写入(例如,obj.prop = 123
),那么程序员将收到没有发生写入的警告。这可能会导致意外和难以找到的错误。因此,牢记将要使用你的接口的程序员的期望是至关重要的。因此,你要小心谨慎地保留属性描述符。
你可以通过原生提供的Object.defineProperty()
为给定的属性定义自己的特性。在设置新属性描述符时,每个特性的默认值将为false
,因此,如果希望给属性赋予configurable
、enumerable
或writable
的特性,则需要明确指定这些特性为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 的用法。但是,与configurable
、enumerable
和writable
的特性一样,重要的是要谨慎考虑你的自定义行为将如何影响你的同行程序员的期望。如果你创建的抽象或数据结构在行为上不熟悉或不可预测,那么你就为误解和错误铺平了道路。最好的方法是与语言本身的自然语义保持一致。因此,每当你要创建一个自定义 setter 或将属性描述为不可写时,请问自己程序员是否可以合理地期望它以这种方式工作。遵循一个有帮助的规则,被称为最少惊讶原则(POLA)!
POLA(或最少惊讶)适用于软件设计和 UX 设计。它广泛意味着系统的给定功能或组件应该像大多数用户期望的那样行事,并且不应该过于惊讶或使人惊讶。
Map 和 WeakMap
Map
和WeakMap
抽象能够存储键值对,其中,与常规对象不同,键可以是任何东西,包括非原始值:
const populationBySpecies = new Map();
const reindeer = { name: 'Reindeer', formalName: 'Rangifer tarandus' };
populationBySpecies.set(reindeer, 2000000);
populationBySpecies.get(reindeer); // => 2,000,000
WeakMap
类似于Map
,但它只保留对用作键的对象的弱引用,这意味着,如果由于在程序的其他位置进行垃圾回收而使对象不可用,那么WeakMap
将停止保持它。
大多数情况下,普通对象就足够了。只有在需要键为非原始类型或者想要弱引用值时,才应该使用Map
或WeakMap
。
原型
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.create
和Object.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
)提供了type
、sayHello
和sayGoodbye
属性。如果我们检查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 在函数体内提供的一组额外和隐式值的引用。这些绑定包括以下内容:
this
:this
关键字指的是函数调用的执行上下文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"
您还可以使用函数的call
和apply
方法来强制任何给定调用的this
值,但请注意,如果函数被调用为构造函数(即使用new
关键字)或者使用箭头函数语法定义,则这将不起作用:
// Forcing the value of `this` via `.call()`:
tokyo.sayMyName.call(london); // => Logs "My name is London"
在日常函数调用中,最好避免像这样的奇怪调用技术。这些技术可能会使您的代码的读者难以理解发生了什么。使用call
、apply
或bind
进行调用有许多有效的应用,但这些通常局限于较低级别的库或实用程序代码。高级逻辑应该避免使用它们。如果您发现自己在高级抽象中不得不依赖这些方法,那么您可能正在使事情变得比必要的更加复杂。
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
,因此没有任何数组的内置方法可用,例如forEach
、reduce
和map
。
在这里,我们可以观察到参数是在给定函数的范围内提供的:
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
方法。我们没有通过将其分配给变量来命名此函数,因此它被视为匿名函数。匿名函数很有用,因为这意味着我们不需要预先将函数分配给变量以便使用它;我们可以简单地将我们的函数写入我们代码的确切位置。
函数表达式在表达方式上与箭头函数最相似。我们将发现,关键的区别在于箭头函数无法访问自己的绑定(例如this
或arguments
)。然而,函数表达式可以访问这些值,在某些情况下对你更有用。例如,通常需要绑定到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 });
从功能上讲,箭头函数与函数表达式有两种不同之处:
它不提供访问诸如
this
或arguments
之类的绑定。它没有
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!`; }
}
在这里,我们可以观察到JessieTheCat
和JessieTheDog
都有greet
方法:
new JessieTheDog().greet(); // => "Bark! I am Jessie!"
new JessieTheCat().greet(); // => "Meow! I am Jessie!"
我们还可以观察到它们的 greet 方法以相同的方式实现。它们都返回插值字符串${super.greet()} I am Jessie!
。因此,让JessieTheCat
从JessieTheDog
借用该方法似乎是合乎逻辑的。毕竟,它们完全相同:
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]`
}
然而,如今更倾向于使用其他迭代方法,比如forEach
或for...of
:
for (let friend of friends) {
// Do something with `friend`
}
friends.forEach((friend, index) => {
// Do something with `friend`
});
for...of
的好处是可以中断,这意味着你可以在其中使用break
和continue
语句,并轻松地跳出迭代。它还可以用于任何可迭代的对象,而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
属性和相应的索引(0
,1
,2
等)。大多数原生数组方法都是通用的。
语言本身中类似数组的对象的一个例子是我们在本章前面探讨过的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);
然而,在arguments
或NodeList
对象的情况下,我们也可以依赖它们是可迭代的,这意味着我们可以使用扩展语法来派生出一个真正的数组:
// "spread" a NodeList into an Array:
[...document.querySelectorAll('div span a')];
// "spread" an arguments object into an Array:
[...arguments];
如果你发现自己需要创建一个类似数组的对象,考虑让它实现可迭代协议(我们即将探讨),以便以这种方式使用扩展语法。
Set 和 WeakSet
Set
和WeakSet
是允许我们存储唯一对象序列的原生抽象。这与数组形成对比,数组无法保证值的唯一性。下面是一个例子:
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 中的可迭代对象包括Array
、Map
、Set
和String
。
任何对象都可以通过简单地在属性名Symbol.iterator
下提供迭代器函数来定义自己的可迭代协议(它映射到内部的@@iterator
属性)。
这个迭代器函数必须通过返回一个带有next
函数的对象来满足迭代器协议。当调用这个next
函数时,它必须返回一个带有done
和value
键的对象,指示迭代的当前值和迭代是否完成:
const validIteratorFunction = () => {
return {
next: () => {
return {
value: null, // Current value of the iteration
done: true // Whether the iteration is completed
};
}
}
};
因此,为了对此有绝对的清晰认识,有两个不同的协议:
可迭代协议:通过
[Symbol.iterator]
实现@@iterator
的任何对象都满足这个协议。原生示例包括Array
、String
、Set
和Map
。迭代器协议:任何返回形式为
{... 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]
您编写的模式可以包含文字和特殊字符,这些字符共同告诉正则表达式引擎要查找什么。我们在示例中使用的正则表达式包含文字字符(即1
、2
、3
)和管道(即|
)特殊字符:
/1|2|3/g
管道特殊字符告诉正则表达式引擎,管道左侧或右侧的字符可能匹配。在最终斜杠后面的g
是一个全局标志,指示引擎在字符串中全局搜索,并在找到第一个匹配项后不放弃。对我们来说,这意味着我们的正则表达式将匹配主题字符串中出现的"1"
、"2"
或"3"
。
在正则表达式中,我们可以使用特定的特殊字符作为快捷方式。[0-9]
的表示法就是一个例子。它是一个字符类,将匹配从0
到9
的所有数字,这样我们就不必逐个列出所有这些数字。还有一个简写字符类\d
,可以更简洁地表示这一点。因此,我们可以将我们的正则表达式缩短为以下形式:
/\d/g
对于更现实的应用,我们可以想象一种情况,我们希望匹配数字序列,比如电话号码。也许我们只希望匹配以0800
开头并包含进一步4
到6
位数字的电话号码。我们可以使用以下正则表达式来实现:
/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
:多行标志将使开始和结束锚点(即^
和$
)标记单独行的开头和结尾,而不是整个字符串的开头和结尾。s
:dotAll标志将使正则表达式中的点字符(通常仅匹配非换行符字符)匹配换行符字符。u
:Unicode标志将把正则表达式中的字符序列视为单独的 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
构造函数是唯一可以作为构造函数和常规函数调用的本地提供的构造函数,在这两种情况下都返回一个新实例。你会记得,原始构造函数(如String
和Number
)可以作为常规函数调用,但在作为构造函数调用时会有不同的行为。
接受 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
处进行匹配,如果在该确切索引处找不到匹配,它将失败(即根据使用的方法返回null
或false
)。粘性标志(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 的动态特性,检测类型是一种重要的实践,通常可以帮助其他程序员。如果你可以在某人错误地使用接口时有用地抛出错误或警告,那么对于他们来说,这意味着开发流程更加流畅和迅速。如果你可以有用地用智能默认值填充undefined
、null
或空值,那么它将允许你提供一个更无缝和直观的接口。
不幸的是,由于 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
运算符对true
和false
的值正确地求值为"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
又是特定的true
或false
,我们可以使用严格相等运算符进行比较,如下所示:
if (isEnabled === true) {...}
if (isEnabled === false) {...}
由于 JavaScript 的动态特性,一些人更喜欢这种确定性,但通常并不是必要的。如果我们要检查的值显然是一个Boolean
值,那么我们可以直接使用它。通常情况下,通过typeof
或严格相等来检查它的类型是不必要的,除非有可能该值不是Boolean
。
检测数字
在Number
的情况下,我们可以依赖typeof
运算符正确地评估为"number"
:
typeof 555; // => "number"
然而,在NaN
、Infinity
和-Infinity
的情况下,它也会评估为"number"
:
typeof Infinity; // => "number"
typeof -Infinity; // => "number"
typeof NaN; // => "number"
因此,我们可能希望进行额外的检查,以确定一个数字不是这些值中的任何一个。幸运的是,JavaScript 为这种情况提供了本地辅助工具:
isFinite(n)
: 如果Number(n)
不是Infinity
、-Infinity
或NaN
,则返回true
isNaN(n)
: 如果Number(n)
不是NaN
,则返回true
Number.isNaN(n)
: 如果n
不是NaN
,则返回true
Number.isFinite(n)
: 如果n
不是Infinity
、-Infinity
或NaN
,则返回true
全局变量的两个变体是语言的较早部分,正如您所看到的,它们与它们的Number.*
等效部分略有不同。全局的isFinite
和isNaN
通过Number(n)
将它们的值转换为数字,而等效的Number.*
方法则不这样做。这种差异的原因主要是遗留问题。
最近添加的Number.isNaN
和Number.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
如果您确信您的值已经是一个数字,那么您可以使用更简洁的isNaN
和isFinite
,因为它们的隐式转换对您没有影响。如果您希望 JavaScript 尝试将您的非Number
值转换为Number
,那么您应该再次使用isNaN
和isFinite
。然而,如果出于某种原因您需要明确检查,那么您应该使用Number.isNaN
和Number.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
到目前为止,我们已经介绍了如何独立检查undefined
和null
,但我们可能希望同时检查两者。例如,一个函数签名通常有一个可选参数。如果未传递该参数或明确设置为null
,通常会返回到一些默认值。可以通过明确检查null
和undefined
来实现这一点,如下所示:
function printHello(name, message) {
if (message === null || message === undefined) {
// Default to a hello message:
message = 'Hello!';
}
console.log(`${name} says: ${message}`);
}
通常,由于null
和undefined
都是假值,通过检查给定值的假值来暗示它们的存在是非常正常的:
if (!value) {
// Value is definitely not null and definitely not undefined
}
然而,这也将检查值是否为其他假值之一(包括false
,NaN
,0 等)。因此,如果我们想确认一个值是否特别是null
或undefined
,而不是其他假值,那么我们应该坚持使用明确的变体:
if (value === null || value === undefined) //...
更简洁的是,我们可以采用抽象(非严格)相等运算符来检查null
或undefined
,因为它认为这些值是相等的:
if (value == null) {
// value is either null or undefined
}
尽管这利用了通常被指责的抽象相等运算符(我们将在本章后面探讨),但这仍然是检查undefined
和null
的一种流行方式。这是因为它的简洁性。然而,采用这种更简洁的检查会使代码不太明显。甚至可能给人留下作者只是想检查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
另外,如果我们想使用特定的数组方法,比如forEach
或map
,那么最好通过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
,它可以用来将字符串强制转换为数字,使用强制转换来实现。在我们的转换中,至关重要的是我们清楚地传达我们的意图。
由于强制转换是隐式发生的,它可能是许多错误和意外行为的原因。为了避免这种陷阱,我们应该始终对操作数的类型有很强的信心。然而,强制转换是完全有意的,可以帮助创建更可靠的代码库。在接口的更公共或暴露的一侧,通常会预先将类型转换为所需的类型,以防接收到的类型不正确。
在这里观察一下,我们如何明确地将haystack
和needle
的值都转换为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 中的所有值在转换为布尔值时,除非它们是七个假值原始值(false
、null
、undefined
、0n
、0
、""
和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
函数,如果名称变量不是一个填充的字符串或是null
或undefined
,则返回false
:
function isNamePopulated(name) {
return !!name;
}
如果name
是一个空的String
、null
或undefined
,这将有助于返回false
:
isNamePopulated(''); // => false
isNamePopulated(null); // => false
isNamePopulated(undefined); // => false
isNamePopulated('Sandra'); // => true
如果name
是任何其他假值(例如 0),它也会偶然返回false
,如果name
是任何真值,它会返回true
:
isNamePopulated(0); // => false
isNamePopulated(1); // => true
这可能看起来完全不可取,但在这种情况下,这可能是可以接受的,因为我们已经假设name
是一个String
、null
或undefined
,所以我们只关心函数在这些值方面是否履行了它的合同。您对此的舒适程度完全取决于您具体的实现和它提供的接口。
转换为字符串
通过调用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
)。值的valueOf
和toString
方法提供不同的值的可能性总是存在的。看看下面的例子,它展示了如何通过定义我们自己的toString
和valueOf
实现来操纵两个看似等价表达式的返回值:
const myFavoriteNumber = {
name: 'Forty Two',
number: 42,
valueOf() { return number; },
toString() { return name; }
};
`${myFavoriteNumber}`; // => "Forty Two"
'' + myFavoriteNumber; // => 42
这可能是一个罕见的情况,但仍然值得考虑。通常,我们假设我们可以轻松地将任何值可靠地转换为字符串,但情况并非总是如此。
传统上,很常见依赖于值的toString()
方法并直接调用它:
(123).toString(); // => 123
但是,如果值为null
或undefined
,那么您将收到一个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)
如果字符串以0x
或0X
为前缀,则parseInt
将假定radix
为16
(十六进制):
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
可能对您更有用。
parseFloat
和parseInt
都会在尝试提取之前将其初始参数转换为String
。因此,如果您的第一个参数是对象,则应该注意它可能如何自然地强制转换为字符串。如果您的对象实现了不同的toString
和valueOf
方法,则应该期望parseInt
和parseFloat
只使用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
或一元加号+
运算符尝试。只有在需要使用它们的数值提取算法时,才应该使用parseFloat
或parseInt
。
转换为原始类型
将值转换为其原始表示形式并不是我们可以直接做的事情,但是在许多不同的情况下,语言会隐式地(即*强制性地)进行转换,比如当您尝试使用抽象相等运算符==
来比较String
,Number
或Symbol
与Object
的值时。在这种情况下,Object
将通过一个名为ToPrimitive
的内部过程转换为其原始表示形式,该过程总结如下:
如果
object[Symbol.toPrimitive]
存在,并且在调用时返回一个原始值,则使用它如果
object.valueOf
存在,并且返回一个原始值(非Object
),则使用它的返回值如果
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]
,valueOf
和toString
),那么将使用[Symbol.toPrimitive]
。如果只有valueOf
和toString
,那么将使用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
上定义了toString
和valueOf
,但只使用toString
来确定在object
上访问哪个属性。
如果我们没有定义自己的方法,比如valueOf
和toString
,那么将使用我们使用的任何对象的[[Prototype]]
上可用的默认方法。例如,数组的原始表示形式是由Array.prototype.toString
定义的,它将简单地使用逗号作为分隔符将其元素连接在一起:
[1, 2, 3].toString(); // => "1,2,3"
所有类型都有自己本地提供的valueOf
和toString
方法,因此,如果我们希望强制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 中,运算符是一个独立的语法部分,形成一个表达式,通常用于从一组输入(称为操作数)中推导出某些东西或计算逻辑或数学输出。
在这里,我们可以看到一个包含一个运算符(+
)和两个操作数(3
和5
)的表达式:
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
之前尝试。
运算符优先级和结合性
在组合使用多个运算符时,操作的顺序由两种机制定义:优先级和结合性。运算符的优先级是从1
到20
的数字,并定义了一系列运算符的运行顺序。一些运算符共享相同的优先级。结合性定义了具有相同优先级的运算符将被操作的顺序(从左到右或从右到左)。
考虑以下操作:
1 + 2 * 3 / 4 - 5;
在 JavaScript 中,这些特定的数学运算符具有以下优先级:
加法运算符(
+
)的优先级为13
乘法运算符(
*
)的优先级为14
除法运算符(
/
)的优先级为14
减法运算符(
-
)的优先级为13
它们都具有从左到右的结合性。由于优先级更高的运算符首先出现,并且具有相同优先级的运算符将根据它们的结合性出现,我们可以说我们的示例操作按以下顺序进行:
乘法(具有优先级
14
中最左边的)除法(具有优先级
14
中最左边的)加法(具有优先级
13
中最左边的)减法(具有优先级
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"
顺便说一句,这就是为什么您经常会看到带括号的typeof
(typeof(...)
),这样看起来就像是在调用函数。然而,它实际上是一个运算符,括号只是为了强制特定的操作顺序。
你可以通过阅读 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"
然后,它们将通过等同于以下操作的方式转换为它们的数字表示,即5
和3
:
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
在预期除数或被除数为零、NaN
或Infinity
的情况下,最好是谨慎处理,并在操作之前或之后明确检查这些值,如下所示:
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
需要注意强制转换的影响以及其中一个操作数是NaN
或Infinity
的情况。相当直观地,任何非零有限值乘以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
与所有其他算术运算符一样,了解操作数的强制转换方式是有用的。大多数情况下,余数运算符的用法很直观,因此除了其强制转换行为和对NaN
和Infinity
的处理之外,你应该会发现它的行为是直观的。
指数运算符
指数运算符(**
)接受两个操作数,左侧是基数,右侧是指数。它将评估为基数的指数幂:
10 ** 2; // => 100
10 ** 3; // => 1,000
10 ** 4; // => 10,000
它在功能上与使用Math.pow(a, b)
操作相同,尽管更简洁。与其他算术运算一样,它将内部强制转换其操作数为Number
类型,并传入任何NaN
、Infinity
或零的操作数将导致可能意外的结果,因此你应该尽量避免这种情况。
值得一提的一个奇怪情况是,如果指数为零,那么结果将始终为1
,无论基数是什么。因此,基数甚至可以是Infinity
、NaN
或其他任何值,结果仍然是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 运算符将执行以下操作:
将操作数转换为布尔值(
Boolean(operand)
)如果结果值为
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 代码单元从65
(U+0041
)到122
(U+007A
)如下:
ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz
后面出现的字符由更大的 UTF-16 整数表示。要比较任意两个给定的代码单元,JavaScript 将简单地比较它们的整数值。比如比较B
和A
,可能会像这样:
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
你还会注意到从91
到96
的代码单元包括标点符号,`[]^_``。这也会影响我们的词典比较。
'[' < ']'; // => 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**T(U+0302
)组合来表达。在符号上和语义上,它们是相同的:
'Â'; // => Â
'A\u0302'; // => Â
然而,由于U+00C2
(十进制:194
)在技术上大于U+0041
(十进制:65
),在词典比较中它将被认为是大于,即使它们在符号上和语义上是相同的。
'Â' > 'A\u0302'; // => true
有成千上万这样的潜在差异需要注意,因此如果你发现自己需要进行词典排序,要注意 JavaScript 的大于和小于运算符将受到 Unicode 固有排序的限制。
数值比较
使用 JavaScript 的大于和小于运算符进行数字比较是相当直观的。如前所述,你的操作数首先会被强制转换为它们的原始表示形式,然后再次被显式地强制转换为数字。对于两个操作数都是数字的情况,结果是完全直观的:
123 < 456; // => true
对于NaN
和Infinity
,可以做出以下断言:
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
在for
和while
循环的上下文中经常看到赋值的情况:
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
的值。它们通常出现在迭代上下文中,例如for
或while
循环中。最好将它们视为对加法和减法赋值的简洁替代方法(即value += 1
或value -= 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
。这就是增量和减量运算符的性质:它们严格作用于数字。因此,如果n
是String
,那么无法成功强制转换,那么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
这些模式通常看起来像Object
或Array
字面量,因为它们分别以{}
和[]
开头和结尾。但它们有些微的不同。
在解构对象模式中,当你想要声明要分配的标识符或属性时,你必须将它放置在对象字面量中的值的位置。也就是说,{ 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
操作符根据属性是否成功删除而评估为true
或false
。在成功删除后,属性不仅仅被设置为undefined
或null
,而是完全从对象中删除,因此通过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 }
在这种情况下使用展开语法的右侧值必须是一个对象或可以包装为对象的原始值(例如,Number
或String
)。这意味着 JavaScript 中的所有值都是允许的,除了null
和undefined
,我们知道这两者都不能被包装为对象:
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
)在250
和20
中都是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;
)当您使用任何
for
、while
、switch
、do..while
或if
构造当您通过function declaration(
function 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;
}
块很少被用作完全孤立的代码单元(这样做的好处非常有限)。通常会在if
、while
、for
和switch
语句中找到它们,如下所示:
while (somethingIsTrue()) {
// This is a block
doSomething();
}
这里while
循环的{...}
部分是一个块。它不是while
语法的固有部分。如果愿意,我们可以完全排除该块,而是用一个常规的单行语句代替:
while (somethingIsTrue()) doSomething();
这将与使用块的版本相同,但显然如果我们打算添加更多的迭代逻辑,这将是有限制的。因此,在这种情况下通常最好预先使用块。这样做的额外好处是合法化缩进和迭代逻辑的包含。
块不仅仅是语法容器。它们还通过提供自己的作用域影响我们代码的运行时,这意味着我们可以通过const
和let
语句在其中声明变量。请注意这里我们如何在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 程序可以被认为有四种类型的词法环境,如下列表所示:
全局环境:只有一个,它被认为是所有其他作用域的外部作用域。它是所有其他环境(即作用域)存在的全局上下文。全局环境反映了一个全局对象,可以在浏览器中通过
window
或self
引用,在 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
关键字声明的事物为变量声明,但重要的是要注意,在流行的术语中,由let
和const
声明的声明也被认为是变量。
通过var
声明的变量的作用域限制在最近的函数、模块或全局环境中,也就是说,它们不是块作用域的。在解析时,给定作用域内的变量声明将被收集,然后在执行时,这些声明的变量将被提升到它们的执行上下文的顶部,并用undefined
值进行初始化。这意味着,在给定作用域内,你可以在其赋值之前访问一个变量,但它将是undefined
:
foo; // => undefined
var foo = 123;
foo; // => 123
执行上下文是指调用堆栈的顶部,也就是当前运行的函数、脚本或模块。这个概念只在代码运行时才能看到,并且随着程序的进行而改变。你通常可以简单地将其视为当前运行的函数(或外部模块或<script>
)。var
声明总是被提升到它们的执行上下文的顶部,并初始化为undefined
。
与通过let
和const
声明的变量相比,var
的提升行为是相反的,如果你在它们声明之前尝试访问它们,将会产生ReferenceError
:
thing; // ! ReferenceError: Cannot access 'thing' before initialization
let thing = 123;
如果你不小心,var 的提升行为可能会导致一些意想不到的结果。例如,可能会出现这样的情况,你试图引用外部作用域中存在的变量,但由于当前作用域中的变量声明被提升,你无法这样做:
var config = {};
function setupUI() {
config; // => undefined
var config;
}
setupUI();
在这里,内部作用域变量config
的声明将被提升到其作用域的顶部,这意味着从setupUI
的第一行开始,config
是undefined
。
由于变量声明被提升到它们的执行上下文的顶部,即使在块中,它们也会被提升,就好像它们是在块外部首先初始化的一样:
// 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>
(在浏览器中)。由于最近引入的const
和let
声明都是块作用域的,并且没有任何奇怪的提升行为,因此变量声明已经不再受欢迎。
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...in
或for...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...of
和for...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()
);
}
在上述代码中采取的控制流程如下:
我们从
let basket = [];
开始for
循环开始:let i = 0
检查
i < 3
(为true
!):运行
makeEgg()
通过
basket.push(...)
推送结果i++
(i
现在是1
)检查
i < 3
(为true
!):运行
makeEgg()
通过
basket.push(...)
推送结果i++
(i
现在是2
)检查
i < 3
(为true
!):运行
makeEgg()
通过
basket.push(...)
推送结果i++
(i
现在是3
)检查
i < 3
(为false
!)。程序结束
即使对于这样一个非常简单的程序,流程也可能相当复杂且冗长。为了使我们的同行程序员受益,尽可能地减少这种复杂性是有意义的。实现这一点的方法是通过抽象。抽象某事物不会消除复杂性,但它会隐藏它,以便程序员不需要关心它。因此,在深入研究 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#filter
和Array#map
方法将独立地迭代它们的输入数组,但这不是我们在指定的内容。我们指定的只是我们的数据应该被过滤和映射的条件。数据如何进行迭代完全是Array#filter
和Array#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.
}
这不仅仅是一个简单抽象的练习。 这是一个减少控制流层次的练习。 我们避免了使用嵌套的if
和for
块的需要,减少了我们自己和其他程序员面临的认知负担,并以最干净的方式完成了最初设定的任务。
通过仔细重构和抽象我们最初混乱的控制流,我们最终得到了一组代码,其中包含了非常少的传统控制流语句(if
,for
,switch
等)。 这并不意味着我们的代码没有控制流; 相反,它意味着控制流要么被最小化,要么被隐藏在抽象的层次下。 在使用 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(由于
SyntaxError
,TypeError
等隐式地或通过显式的throw
语句抛出异常)通过yielding(在生成器的情况下)
调用也可能通过 JavaScript 的内部机制间接发生。例如,在上一章探讨的强制转换的情况下,诸如valueOf
、toString
或Symbol.toPrimitive
等方法可能会在各种场景下被调用。此外,JavaScript 还使你能够定义setters和getters,以便在访问或赋值给特定属性时激活你的自定义功能:
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
表达式的灵活性是有用的,因为你在实际应用中可能会遇到。
产出的复杂性
对于程序员来说,理解生成器内部控制流可能会变得复杂和难以理解,因为它涉及来回很多次调用者和生成器之间的交互。在任何特定点知道正在运行的确切代码可能很难确定,因此建议保持生成器的简短,并确保它们在其他方面一直保持一致——换句话说,在你的生成器内不要有太多不同的生成路径,并且通常尽量保持圈复杂度很低(如果您直接跳到处理圈复杂度部分,您可以阅读更多相关信息)。
中断
中断是从当前for
、while
、switch
或带标签的语句内部转移控制到该语句后面的代码。它有效地终止了该语句,阻止后续任何代码的执行。
在迭代的上下文中,是否继续或中断迭代通常由构造本身内的ConditionExpression
(例如,counter < array.length
)确定,或者由数据结构的长度在for..in
和for..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;
语句。因此,我们只能看到2
、3
和4
的日志。1
的日志被避免了,因为case 1
不匹配值2
,而5
的日志也被避免了,因为break;
出现在它之前。
当switch
中的case
不中断时,称为贯穿。在switch
语句中使用的这种常见技术在你想要根据多个匹配条件执行单个操作或级联操作时是有用的(我们将在switch 语句部分更详细地介绍这个概念)。
在break
关键字的右侧可能有一个标签,表示switch
、for
或while
语句。如果没有提供标签,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;
语句来做到这一点,但也可以通过其他控制移动的机制有效地发生,例如yielding、returning或throwing。例如,看到使用return;
来跳出不仅是它本身的迭代,也是包含函数的迭代是非常常见的。
继续
Continuing是一种控制的转移,从当前语句到可能的下一个迭代的开始。它是通过一个continue
语句来实现的。
continue
语句在所有迭代构造中都有效,包括for
、while
、do...while
、for...in
和for...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是一种控制的移动,我们必须谨慎地考虑我们在传达意图时是否清晰。如果我们有多层循环或多个continue
或break
语句,那么会给读者带来不必要的复杂性。
抛出
抛出 是控制从当前语句转移到调用堆栈上最近的包含 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
初始化迭代;这将首先进行评估,并且仅进行一次。这可以是任何语句(通常包括let
或var
分配,但不必是)。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
可以是任何求值为(或可以被强制转换为)对象的表达式——换句话说,除了null
或undefined
之外的任何东西。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) {}
每次迭代都会创建一个新的块作用域。当你使用let
或const
声明时,它将作用于该迭代,而通过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
结构用于遍历可迭代对象。原生提供的可迭代对象包括String
,Array
,TypedArray
,Map
和Set
。在语法上,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++) {...}
模式。
let
,var
和const
的作用域行为与上一节关于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.length
,1 < array.length
,2 < 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...in
和 for...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
将停止其迭代。
在 while
或 do...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 块
通常,case
或default
子句之后的代码不止占据一行。因此,习惯上将这些语句包含在一个块中,以便有一种包容性:
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);
}
}
这并不是严格必要的,也不会改变任何功能,但它确实为我们的代码读者提供了更多的清晰度。它还为我们引入块级变量铺平了道路,如果我们以后想引入这些变量的话。正如我们所知,在一个由{
和}
界定的块中,我们可以使用const
和let
来声明仅限于该块的作用域的变量:
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();
即使在这段看似简单的代码中,也存在九条不同的路径。因此,根据a
、b
、c
和d
的值,可能会有九种alpha
、bravo
、charlie
和delta
的运行序列:
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 号码(避免null
或undefined
ID 的情况)。我们根据 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 字段(type
和digits
):
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#map
和Array#filter
来抽象决策和迭代逻辑。这意味着我们最终得到了一个更加声明式的实现。
你可能还注意到,我们提取了重复逻辑并将其概括化。例如,在我们最初的实现中,我们实现了许多调用来发现 ID 的第一个字符(例如,license.id.indexOf('m') === 0
)。我们的新实现通过映射到已经包括第一个字符的数据结构来概括这个问题,然后我们可以通过getIDFields
获得该 ID 的相关type
和digits
。
总结来说,我们的一般重构方法包括以下考虑因素:
我们以新的视角考虑了问题领域
我们考虑了是否有常见的函数式或声明式习惯用法来处理我们的 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
对象被传递给回调函数。这是为了简洁起见,习惯上用e
或evt
来命名。大多数提供事件 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的函数参数(调用resolve
或reject
函数来指示已解决值或错误)来构造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只是一个常规对象,它可以像任何其他值一样在您的程序中传递,这意味着任务的最终解决不再需要与原始任务的调用站点的代码绑定。此外,每个then
、catch
或finally
调用返回自己的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只会解决为一个值。一旦它被完成或拒绝,没有其他值可以取而代之。但正如我们在这里所看到的,我们可以通过简单地通过then
、catch
或finally
注册回调来自原始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));
然后我们可以自由地传递这些forenames
和surnames
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
函数接受forenames
Promise作为参数,然后返回一个<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()
返回的 Promise 在 userInfoLoader
完成工作后总是会被执行。也许这段代码的作者碰巧知道 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
没有Promises或await
和async
,表达这种异步过程不仅需要更多的代码,还需要更多的时间来理解。这些构造和抽象的美妙之处在于它们使我们能够忽略异步操作的实现细节,从而使我们能够纯粹地专注于表达我们的问题领域。随着我们在本书中的进展,我们将进一步探索这种抽象精神,因为我们将处理一些更大更棘手的问题领域。
总结
在本章中,我们已经完成了对 JavaScript 语言的探索,讨论了命令式和声明式语法之间的区别,探讨了如何清晰地控制流程,并学习了如何处理同步和异步上下文中的圈复杂度情况。这涉及对语言中所有迭代和条件构造的深入研究,对它们的使用进行指导,并警告反模式。
在下一章中,我们将把我们对 JavaScript 语言积累的所有知识与对真实世界设计模式和范式的探索相结合,这将帮助我们构建清晰的抽象和架构。
第三部分:构建抽象
在这一部分,我们将运用我们对清晰代码和 JavaScript 语言结构的理解,来构建清晰而连贯的 JavaScript 抽象。通过这样做,我们将学习如何使用众所周知的模式设计直观的抽象,如何思考常见的 JavaScript 问题领域,如何处理错误状态,以及如何有效地使用有时笨拙的 DOM API。
本节包括以下章节:
第十一章,设计模式
第十二章,现实世界的挑战
第十一章:设计模式
我们遇到的大多数问题都不是新问题。许多在我们之前的程序员已经解决了类似的问题,并通过他们的努力,各种编程模式已经出现。我们称这些为设计模式。
设计模式是我们的代码所在的有用结构、样式和模板。设计模式可以规定从代码基础的整体脚手架到用于构建表达式、函数和模块的单个语法片段的任何内容。通过构建软件,我们不断地,而且通常是不知不觉地,处于设计的过程中。正是通过这个设计过程,我们正在定义用户和维护者在接触我们代码时将经历的体验。
为了使我们适应设计师而不是程序员的视角,让我们考虑一个简单软件抽象的设计。
在本章中,我们将涵盖以下主题:
设计师的视角
架构设计模式
JavaScript 模块
模块化设计模式
规划和和谐
设计师的视角
为了赋予我们设计师的视角,让我们探索一个简单的问题。我们必须构建一个抽象,允许用户给我们两个字符串,一个主题字符串和一个查询字符串。然后我们必须计算主题字符串中查询字符串的计数。
因此,请考虑以下查询字符串:
"the"
并看一下以下主题字符串:
"the fox jumped over the lazy brown dog"
我们应该收到一个结果为2
。
对于我们作为设计师的目的,我们关心那些必须使用我们代码的人的体验。现在,我们不会担心我们的实现;我们只会考虑接口,因为主要是我们代码的接口将驱动我们的同行程序员的体验。
作为设计师,我们可能会做的第一件事是定义一个带有精心选择的名称和一组特定命名参数的函数:
function countNeedlesInHaystack(needle, haystack) { }
这个函数接受needle
和haystack
,并将返回Number
,表示needle
在haystack
中的计数。我们代码的使用者会这样使用它:
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
在我们的命名约定方面,我们可能希望避免使用大海捞针的习语,而是使用更具描述性的名称,也许有较少误解的风险,如下所示:
searchableString
和subString
查询
和内容
search
和corpus
即使在这个非常狭窄的问题域内,我们可选择的选项也是令人难以置信的。您可能对哪种方法和命名约定更优越有很多自己的强烈意见。
我们可以用许多不同的方法解决一个看似简单的问题,这表明我们需要一个决策过程。而这个过程就是软件设计。有效的软件设计利用设计模式来封装问题域,并为同行程序员提供熟悉性和易于理解。
我们探索“大海捞针”问题的意图并不是为了找到解决方案,而是为了突出软件设计的困难,并让我们的思维接触更加以用户为导向的视角。它也提醒我们,很少有一个理想的设计。
任何问题域中,一个精心选择的设计模式可以说具有两个基本特征:
它很好地解决了问题:一个精心选择的设计模式将很好地适应问题域,以便我们可以轻松地表达问题的性质和其解决方案。
它是熟悉和可用的:一个精心选择的设计模式对我们的同行程序员来说是熟悉的。他们会立刻明白如何使用它或对代码进行更改。
设计模式在各种情境和规模下都是有用的。我们在编写单个操作和函数时使用它们,但我们在构建整个代码库结构时也使用它们。设计模式本身是分层的。它们存在于代码库的宏观和微观层面。一个单一的代码库很容易包含许多设计模式。
在第二章中,《清洁代码的原则》,我们谈到熟悉性是一个至关重要的特征。一个汽车技师打开汽车的引擎盖时,希望看到许多熟悉的模式:从独立的电线和组件的焊接到气缸、阀门和活塞的更大的构造。他们期望找到一定的布局,如果没有,他们将不知所措,想知道如何解决他们正在尝试解决的问题。
熟悉性增加了我们解决方案的可维护性和可用性。考虑以下目录结构和显示的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-ViewModel(MVVM)。这些应该让我们意识到通常分离的关注点,并希望激励我们在创建架构时寻求类似的清晰度。
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());
“视图”可以访问“模型”,以便可以检索数据进行渲染。“控制器”分别提供给“模型”和“视图”,以便通过设置适当的回调将它们粘合在一起。
在用户点击+
(增量)按钮的情况下,将启动以下过程:
“增量按钮”的 DOM 点击事件由视图接收
视图触发其
onIncrementCallback()
,由控制器监听控制器指示模型
increment()
模型调用其变异回调,即
onChangeCallback
,由控制器监听控制器指示视图重新渲染
也许你会想为什么我们要在控制器和模型之间进行分离。为什么视图不能直接与模型通信,反之亦然?其实可以!但如果我们这样做,我们会在视图和模型中都添加更多的逻辑,从而增加更多的复杂性。我们也可以把所有东西都放在视图中,而没有模型,但你可以想象那会变得多么笨重。从根本上讲,分离的程度和数量将随着你追求的每个项目而变化。在本质上,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)。这些模块是通过import
和export
语句在代码库中导入和导出的不同文件。使用这样的模块,我们可能有一个DropdownComponent.js
文件,看起来像这样:
// DropdownComponent.js
class DropdownComponent {}
export default DropdownComponent;
正如您所看到的,它使用export
语句来导出其类。如果我们希望将这个类用作依赖项,我们将这样导入它:
// app.js
import DropdownComponent from './DropdownComponent.js';
ECMAScript 模块正在逐渐在各种环境中得到更多支持。要在浏览器中使用它们,可以提供一个类型为module的entry脚本标签,即<script type="module" />
。在 Node.js 中,目前,ES 模块仍然是一个实验性功能,因此您可以依赖旧的导入方式(const thing = require('./thing')
),或者可以通过使用--experimental-modules
标志并在所有 JavaScript 文件上使用.mjs
扩展名来启用实验性模块。
import
和export
语句都允许各种语法。这使您可以定义要导出或导入的名称。在只导出一个项目的情况下,惯例上使用export default [item]
,就像我们在DropdownComponent.js
中所做的那样。这确保了模块的任何依赖项都可以导入它并根据需要命名它,就像这个例子中所示的那样:
import MyLocallyDifferentNameForDropdown from './DropdownComponent.js';
与此相反,您可以通过在花括号内声明它们并使用as
关键字来明确命名您的导出项:
export { DropdownComponent as TheDropdown };
这意味着任何导入者都需要明确指定TheDropdown
的名称,如下所示:
import { TheDropdown } from './DropdownComponent.js';
或者,您可以通过在export
语句中具体声明来导出命名项,例如var
、const
、let
、函数声明或类定义:
// 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]]
(也就是我们的对象,其中包含getNumberOfPages
、renderFrontCover
和renderBackCover
)。
如果您对构造函数和实例化的原型机制记忆不太清楚,请重新阅读第六章,基本类型和内置类型,特别是名为原型的部分。
何时使用构造函数模式
构造函数模式在您希望有一个封装名词概念的抽象的情况下非常有用,也就是说,一个有意义的实例。例如NavigationComponent
或StorageDevice
。构造函数模式允许您创建类似于传统面向对象编程类的抽象。因此,如果您来自经典的面向对象编程语言,那么您可以随意使用构造函数模式,以前可能使用类。
如果您不确定构造函数模式是否适用,请考虑以下问题是否属实:
概念可以表达为名词吗?
概念需要构造吗?
概念在实例之间会有变化吗?
如果你抽象出的概念不满足以上任何标准,那么你可能需要考虑另一种模块化设计模式。一个例子是一个具有各种辅助方法的实用程序模块。这样的模块可能不需要构造,因为它本质上是一组方法,这些方法及其行为在实例之间不会有变化。
自从 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
绑定及其特殊性的详细描述,请参阅第六章,基本类型和内置类型(请参阅函数绑定部分)。
原型模式
原型模式涉及使用普通对象作为其他对象的模板。原型模式直接扩展此模板对象,而不需要通过new
或Constructor.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.assign
从theBLT
复制所有属性到一个新对象,然后专门复制(即覆盖)slotC
:
const theBLA = Object.assign({}, theBLT, {
slotC: 'Avocado'
});
但是如果 BLT 的breadType
被更改了呢?让我们来看一下:
theBLT.breadType = 'Sourdough';
theBLA.breadType; // => 'Granary'
现在,theBLA
与theBLT
不同步。我们已经意识到,这里实际上需要的是一个继承模型,以便theBLA
的breadType
始终与其父三明治的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) {}
};
这样的模块通常还会公开特定的初始化方法,例如initialize
、init
或setup
。或者,我们可能希望提供改变整个模块状态或配置的方法(例如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 支持的浏览器上,客户端验证的