JavaScript-编程精解-Eloquent-第四版-全-
JavaScript 编程精解(Eloquent)第四版(全)
译者:飞龙
第一章:引言
这是一本关于指导计算机的书。如今,计算机和螺丝刀一样常见,但它们要复杂得多,让它们按照你的想法运行并不总是容易的。
如果你要给计算机的任务是常见且容易理解的,比如查看你的电子邮件或充当计算器,你可以打开相应的应用程序并开始工作。但对于独特或开放式的任务,往往没有合适的应用程序。
这就是编程可能发挥作用的地方。编程是构建一个程序的行为——一组精确的指令,告诉计算机该做什么。由于计算机是愚蠢的、迂腐的动物,编程在根本上是乏味和令人沮丧的。
幸运的是,如果你能克服这一事实——甚至享受以愚蠢机器能够处理的方式进行思考的严谨——编程是有回报的。它使你能够在几秒钟内完成手动操作需要永远才能完成的事情。它是让你的计算机工具完成之前无法做到的事情的一种方式。此外,它还成为了解决难题和抽象思维的美妙游戏。
大多数编程都是使用编程语言进行的。编程语言是一种人工构造的语言,用于指导计算机。值得注意的是,我们与计算机沟通的最有效方式在很大程度上借鉴了我们彼此之间沟通的方式。像人类语言一样,计算机语言允许将单词和短语以新方式组合,使得表达不断新的概念成为可能。
曾几何时,以语言为基础的接口,比如1980年代和1990年代的BASIC和DOS提示符,是与计算机互动的主要方式。对于常规计算机使用,这些接口在很大程度上已被视觉界面取代,视觉界面更易于学习,但自由度较低。但是如果你知道在哪里寻找,语言依然存在。其中之一,JavaScript,内置于每个现代网页浏览器中,因此几乎在每个设备上都可用。
本书将努力使你对这门语言足够熟悉,以便能够用它做一些有用且有趣的事情。
关于编程
除了解释JavaScript,我还将介绍编程的基本原则。事实证明,编程是困难的。基本规则简单明了,但基于这些规则构建的程序往往变得复杂到引入自己的规则和复杂性。在某种程度上,你是在构建自己的迷宫,而你很容易在其中迷失。
阅读这本书时,你可能会感到非常沮丧。如果你是编程新手,很多新材料需要消化。这些材料会以结合的方式呈现,要求你建立额外的联系。
你必须付出必要的努力。当你努力跟上书中的内容时,不要急于得出关于自己能力的结论。你很好——你只需要坚持下去。休息一下,重新阅读一些材料,确保你阅读并理解示例程序和练习。学习是艰苦的工作,但你所学的一切都是你的,并将使进一步的学习变得更容易。
当行动变得无利可图时,收集信息;当信息变得无利可图时,休息。
—厄休拉·K·勒古恩,《黑暗的左手》
程序有许多方面。它是程序员输入的文本,是驱动计算机执行操作的指令力量,是计算机内存中的数据,同时,它也控制着在该内存上执行的操作。试图将程序与熟悉的物体进行比较的类比往往显得苍白无力。一个表面上看似合适的比较是将程序比作机器——涉及许多独立的部分,要使整个系统运作,我们必须考虑这些部分如何相互连接并对整体操作做出贡献。
计算机是一个物理机器,充当这些无形机器的宿主。计算机本身只能做一些非常简单的事情。它们之所以如此有用,是因为它们以极高的速度完成这些事情。一个程序可以巧妙地组合大量这些简单的动作,从而做出非常复杂的事情。
程序是一种思想的构建。构建是没有成本的,它是无重量的,并且在我们打字的手下容易增长。但是随着程序的增长,其复杂性也在增加。编程的技能是构建不会让程序员感到困惑的程序的技能。最好的程序是在保持易于理解的同时能够做一些有趣的事情。
一些程序员认为,这种复杂性最好通过在他们的程序中使用一小组经过充分理解的技术来管理。他们制定了严格的规则(“最佳实践”),规定程序应该具有的形式,并且他们小心翼翼地待在他们安全的小区域内。
这不仅无聊——而且无效。新问题通常需要新解决方案。编程领域年轻且仍在迅速发展,足够多样化以容纳截然不同的方法。在程序设计中有许多可怕的错误,你应该至少尝试犯一次,这样你才能理解它们。对优秀程序的感知是通过实践发展出来的,而不是通过一份规则清单来学习的。
为什么语言很重要
在计算的初始阶段,并不存在编程语言。程序的样子大致是这样的:
00110001 00000000 00000000
00110001 00000001 00000001
00110011 00000001 00000010
01010001 00001011 00000010
00100010 00000010 00001000
01000011 00000001 00000000
01000001 00000001 00000001
00010000 00000010 00000000
01100010 00000000 00000000
这是一个将数字从1加到10
当然,手动输入这些晦涩的位模式(即零和一)确实让程序员有一种强大巫师的深刻感受。这在工作满足感上一定是值得的。
上一个程序的每一行都包含一条指令。它可以用英语这样写:
-
在内存位置
0存储数字0。 -
在内存位置
1存储数字1。 -
将内存位置
1的值存储到内存位置2。 -
从内存位置
2的值中减去数字11。 -
如果内存位置
2的值是数字0,继续执行指令9。 -
将内存位置
1的值加到内存位置0。 -
将数字
1加到内存位置1的值上。 -
继续执行指令
3。 -
输出内存位置
0的值。
尽管这已经比位的混合更加可读,但仍然相当模糊。使用名称而不是数字作为指令和内存位置会有所帮助。
Set "total" to 0.
Set "count" to 1.
[loop]
Set "compare" to "count".
Subtract 11 from "compare".
If "compare" is 0, continue at [end].
Add "count" to "total".
Add 1 to "count".
Continue at [loop].
[end]
Output "total".
你能看到程序此时是如何工作的吗?前两行给两个内存位置赋予了初始值:total将用于构建计算结果,而count将跟踪我们当前查看的数字。使用compare的行可能是最令人困惑的。程序想要检查count是否等于11,以决定是否可以停止运行。因为我们的假设机器相当原始,所以它只能测试一个数字是否为零,并基于此做出决定。因此,它使用标记为compare的内存位置来计算count - 11的值,并根据该值做出决定。接下来的两行将count的值加到结果中,并在程序决定count还不是11时每次将count增加1。
这是相同程序的JavaScript版本:
let total = 0, count = 1;
while (count <= 10) {
total += count;
count += 1;
}
console.log(total);
// → 55
这个版本给我们带来了一些改进。最重要的是,我们不再需要指定程序要如何来回跳转——while结构解决了这个问题。只要给定的条件成立,它就会继续执行下面的代码块(用大括号括起来)。这个条件是count <= 10,这意味着“计数小于或等于10。”我们不再需要创建一个临时值并将其与零进行比较,这只是一个无趣的细节。编程语言的一部分强大之处在于,它们可以为我们处理这些无趣的细节。
在程序结束时,当while结构完成后,使用console.log操作来输出结果。
最后,如果我们恰好有方便的操作range和sum可用,分别用于创建一个范围内的数字集合和计算一组数字的总和,程序可能看起来像这样:
console.log(sum(range(1, 10)));
// → 55
这个故事的寓意是同一个程序可以用长短不同、可读和不可读的方式表达。程序的第一个版本极为晦涩,而最后一个版本几乎是英文:记录从1到10的数字范围的总和。(我们将在后面的章节中看到如何定义像sum和range这样的操作。)
一种好的编程语言通过让程序员以更高的层次谈论计算机必须执行的操作来帮助他们。它帮助省略细节,提供方便的构建模块(例如while和console.log),允许你定义自己的构建模块(例如sum和range),并使这些模块易于组合。
什么是JavaScript?
JavaScript于1995年推出,作为一种向Netscape Navigator浏览器的网页添加程序的方式。此后,这种语言被所有其他主要图形网页浏览器所采纳。它使现代网页应用成为可能——也就是说,这些应用可以让你直接互动,而无需每次操作都重新加载页面。JavaScript还用于更传统的网站,以提供各种形式的互动性和巧妙性。
重要的是要注意,JavaScript与名为Java的编程语言几乎没有关系。相似的名称是出于营销考虑而非明智的判断。当JavaScript被引入时,Java语言正受到强力推广并日益流行。有人认为借助这一成功的想法不错。现在我们被这个名称困住了。
在Netscape之外被采纳后,编写了一份标准文档,描述JavaScript语言应该如何工作,以便那些声称支持JavaScript的各种软件可以确保它们实际提供的是同一种语言。这被称为ECMAScript标准,以进行标准化的Ecma International组织命名。在实践中,ECMAScript和JavaScript这两个术语可以互换使用——它们是同一种语言的两个名称。
有人会说可怕的关于JavaScript的事情。这些话中很多都是事实。当我第一次被要求用JavaScript写东西时,我很快开始厌恶它。它几乎接受我输入的任何内容,但以一种完全不同于我意图的方式解读。这当然与我当时对自己在做什么毫无头绪有很大关系,但这里确实存在一个问题:JavaScript在其允许的范围上极为宽松。这个设计理念是为了让初学者更容易进行JavaScript编程。实际上,这主要让你在程序中找到问题变得更加困难,因为系统不会直接指出它们。
然而,这种灵活性也有其优点。它为更灵活语言中不可能实现的技术留出了空间,并形成了一种愉快的非正式编程风格。在正确学习这门语言并使用一段时间后,我实际上开始喜欢上JavaScript。
JavaScript经历了多个版本。ECMAScript版本3是在2000年至2010年间JavaScript崛起时得到广泛支持的版本。在此期间,版本4的雄心勃勃的开发正在进行,计划对语言进行一系列激进的改进和扩展。然而,以如此激进的方式改变一个生动且被广泛使用的语言,结果在政治上是困难的,因此版本4的工作在2008年被放弃。一个雄心较小的版本5于2009年发布,仅进行了一些不具争议性的改进。2015年,版本6发布,这是一个重要更新,包括了一些计划在版本4中的理念。从那时起,我们每年都有新的小更新。
JavaScript的演变意味着浏览器必须不断跟上。如果你使用的是较旧的浏览器,它可能不支持所有功能。语言设计者非常小心,不做任何可能破坏现有程序的更改,因此新浏览器仍然可以运行旧程序。在本书中,我使用的是2024版的JavaScript。
网络浏览器并不是唯一使用JavaScript的平台。一些数据库,例如MongoDB和CouchDB,将JavaScript作为其脚本和查询语言。多个桌面和服务器编程平台,最显著的是Node.js项目(见第二十章),提供了在浏览器外编程JavaScript的环境。
代码及其用途
代码是构成程序的文本。本书的大多数章节都包含了相当多的代码。我相信阅读代码和编写代码是学习编程不可或缺的部分。尽量不要只是匆匆浏览示例——要认真阅读并理解它们。起初这可能会很慢且令人困惑,但我保证你很快就会掌握其中的窍门。练习题也是如此。在你真正写出一个有效的解决方案之前,不要假设你理解它们。
我建议你在实际的JavaScript解释器中尝试练习题的解答。这样,你可以立即得到反馈,看看你所做的是否有效,并且我希望你会被诱惑去实验,超越练习。
在本书中运行示例代码最简单的方法——也是进行实验的方法——是在eloquentjavascript.net的在线版本中查找它。你可以单击任何代码示例进行编辑和运行,并查看它产生的输出。要进行练习,请访问eloquentjavascript.net/code,该网站提供每个编码练习的起始代码,并允许你查看解决方案。
在本书的网站之外运行本书定义的程序需要小心。许多示例可以独立运行,并且应该在任何JavaScript环境中工作。但后面的章节中的代码通常是为特定环境(浏览器或Node.js)编写的,只能在那里运行。此外,许多章节定义了更大的程序,其中的代码片段彼此依赖或依赖外部文件。网站上的沙盒提供链接到包含运行特定章节所需的所有脚本和数据文件的ZIP文件。
本书概述
本书大致分为三个部分。前12章讨论JavaScript语言。接下来的七章则讲述网页浏览器以及如何使用JavaScript对其进行编程。最后,两章专门讲解Node.js,另一个编写JavaScript的环境。本书中有五个项目章节描述了更大的示例程序,让你体验实际编程。
本书的语言部分以四个章节开始,介绍JavaScript语言的基本结构。它们讨论控制结构(例如你在本介绍中看到的while语句)、函数(编写自己的构建块)和数据结构。在这些之后,你将能够编写基本程序。接下来,第五章和第六章介绍了使用函数和对象编写更抽象代码并控制复杂性的技巧。
在第一项目章节构建一个简单的送货机器人后,本书的语言部分继续探讨错误处理和修复、正则表达式(处理文本的重要工具)、模块化(抵御复杂性的另一种手段)以及异步编程(处理耗时事件)。第二个项目章节实现了一种编程语言,结束了本书的第一部分。
本书的第二部分,第十三章到第十九章,描述了浏览器JavaScript可访问的工具。你将学习如何在屏幕上显示内容(第十四章和第十七章)、响应用户输入(第十五章)以及通过网络进行通信(第十八章)。在这一部分还有两个项目章节,构建一个平台游戏和一个像素画程序。
第二十章描述了Node.js,第二十一章则使用该工具构建一个小网站。最后,第二十二章讨论了优化JavaScript程序以提高速度时需要考虑的一些事项。
排版约定
在本书中,以等宽字体书写的文本将代表程序的元素。有时这些是自足的片段,有时它们只是引用了附近程序的一部分。程序(你已经见过一些)书写如下:
function factorial(n) {
if (n == 0) {
return 1;
} else {
return factorial(n - 1) * n;
}
}
有时,为了显示程序产生的输出,预期的输出会在其后面写出,前面加上两个斜杠和一个箭头。
console.log(factorial(8));
// → 40320
祝好运!
第一部分:语言
在机器的表面之下,程序在运行。它无需努力,自如地扩展和收缩。电子在极大的和谐中散射并重新组合。显示器上的形态不过是水面上的涟漪。其本质在无形中保持在下方。
—元马大师,《编程之书》

第二章:值、类型和运算符
在计算机的世界中,只有数据。你可以读取数据、修改数据、创建新数据——但不能提及非数据的东西。所有这些数据都以长序列的位存储,因此从根本上说是相似的。
比特是任何一种二值事物,通常被描述为零和一。在计算机内部,它们的形式可以是高电压或低电压、电信号强或弱,或者在CD表面上光亮或暗淡的点。任何离散信息都可以简化为零和一的序列,因此可以用比特表示。
例如,我们可以用比特表示数字13。这与十进制数字的工作方式相同,但我们只有2个不同的数字,而每个数字的权重从右到左增加一个2的倍数。以下是构成数字13的比特,以及它们下面显示的数字权重:
0 0 0 0 1 1 0 1
128 64 32 16 8 4 2 1
那是二进制数00001101。它的非零数字代表8、4和1,总和为13。
值
想象一下位的海洋——一个庞大的位的海洋。典型的现代计算机在其易失性数据存储(工作内存)中有超过1000亿个比特。非易失性存储(硬盘或同类设备)往往要多几个数量级。
为了能够处理如此数量的比特而不迷失,我们将它们分成表示信息块的块。在Java-Script环境中,这些块被称为值。虽然所有值都是由比特构成的,但它们扮演不同的角色。每个值都有一个确定其角色的类型。有些值是数字,有些值是文本片段,有些值是函数,等等。
要创建
本章的剩余部分介绍Java-Script程序的原子元素——也就是简单值类型和可以作用于这些值的运算符。
数字
number类型的值,毫无疑问,是数字值。在Java-Script程序中,它们的书写方式如下:
13
在程序中使用这个会导致数字13的比特模式在计算机的内存中产生。
JavaScript使用固定数量的位数,64位,来存储单个数字值。用64位可以组合的模式有限,这限制了可以表示的不同数字的数量。用N个十进制数字,你可以表示10^(N)个数字。类似地,给定64个二进制位,你可以表示2^64个不同的数字,大约是18个quintillion(后面有18个零的18)。这非常庞大。
计算机内存曾经小得多,人们通常使用8位或16位的组合来表示数字。意外的溢出小数字很容易发生——即得到一个无法适应给定位数的数字。如今,即便是口袋里的计算机内存也非常充裕,所以你可以自由使用64位,而只有在处理真正庞大的数字时才需担心溢出。
然而,并非所有小于18 quintillion的整数都能适应JavaScript数字。这些位还存储负数,所以有一位用于指示数字的符号。更大的问题是表示非整数。为此,一些位被用于存储小数点的位置。实际上,能存储的最大整数更接近9万亿(15个零)——这仍然是相当巨大的。
分数用小数点表示。
9.81
对于非常大或非常小的数字,你还可以通过添加e(代表指数)及数字的指数来使用科学记数法。
2.998e8
这就是2.998 × 10^8 = 299,800,000。
对于小于前述9万亿的整数(也称为整数)进行的计算,确保始终是精确的。遗憾的是,分数计算通常并非如此。正如π(圆周率)无法用有限的十进制数字精确表示,许多数字在仅有64位可用存储时会失去一些精度。这虽然令人遗憾,但在特定情况下才会引发实际问题。重要的是要意识到这一点,并将分数字符视为近似值,而非精确值。
算术
数字的主要操作是算术。算术运算如加法或乘法需要两个数字值,并生成一个新数字。以下是在JavaScript中的表示方式:
100 + 4 * 11
+和*符号称为运算符。第一个表示加法,第二个表示乘法。在两个值之间放置一个运算符将对这些值应用它,并生成一个新值。
这个例子是指“将4和100相加,然后将结果乘以11”,还是乘法在加法之前进行?正如你可能猜到的,乘法优先进行。与数学一样,你可以通过将加法用括号括起来来改变这一点。
(100 + 4) * 11
对于减法,有-运算符。除法可以使用/运算符。
当操作符没有括号一起出现时,它们的应用顺序由操作符的优先级决定。示例显示乘法优先于加法。/操作符与*操作符具有相同的优先级。同样,+和-也具有相同的优先级。当多个具有相同优先级的操作符并排出现时,如1 - 2 + 1,它们是从左到右应用的:(1 - 2) + 1。
不必过于担心这些优先级规则。如果有疑问,只需添加括号。
还有一个算术操作符,你可能不会立即认出来。%符号用于表示余数操作。X % Y是将X除以Y的余数。例如,314 % 100产生14,144 % 12结果为0。余数操作符的优先级与乘法和除法相同。你也会经常看到这个操作符被称为模。
特殊数字
在JavaScript中,有三个特殊值被视为数字,但它们的行为不同于正常数字。前两个是Infinity和-Infinity,表示正无穷和负无穷。Infinity - 1仍然是Infinity,依此类推。不过,不要过于相信基于无穷的计算。它在数学上并不严谨,并且很快会导致下一个特殊数字:NaN。
NaN代表“不是一个数字”,尽管它是数字类型的一个值。当你尝试计算0 / 0(零除以零)、Infinity - Infinity或其他一些没有意义的数字运算时,你会得到这个结果。
字符串
下一个基本数据类型是字符串。字符串用于表示文本。它们通过用引号括起内容来书写。
`Down on the sea`
"Lie on the ocean"
'Float on the ocean'
你可以使用反引号、双引号或单引号来标记字符串,只要字符串开头和结尾的引号匹配。
你几乎可以在引号之间放入任何内容,让JavaScript将其转换为字符串值。但有些字符比较困难。你可以想象在引号之间放置引号可能会很麻烦,因为它们看起来像是字符串的结束。换行(当你按下ENTER时得到的字符)只能在用反引号(`)引起来的字符串中包含。
为了能够在字符串中包含这些字符,使用以下表示法:在引号文本中,反斜杠(\)表示后面的字符具有特殊含义。这称为转义字符。前面有反斜杠的引号不会结束字符串,而是成为字符串的一部分。当n字符出现在反斜杠后面时,它被解释为换行。同样,反斜杠后面的t表示制表符。考虑以下字符串:
"This is the first line\nAnd this is the second"
这是该字符串中的实际文本:
This is the first line
And this is the second
当然,有时你希望字符串中的反斜杠仅仅是反斜杠,而不是特殊代码。如果两个反斜杠相连,它们将合并在一起,结果字符串值中只会剩下一个。这就是字符串“换行字符的写法是“\n。”的表达方式:
"A newline character is written like \"\\n\"."
字符串同样需要被建模为一系列位,以便能够存在于计算机中。JavaScript 这样做的方式是基于Unicode标准。该标准为几乎每个你可能需要的字符分配一个数字,包括希腊文、阿拉伯文、日文、亚美尼亚文等等。如果我们为每个字符都有一个数字,那么字符串可以用一串数字来描述。这就是 JavaScript 所做的。
不过有一个复杂性:JavaScript 的表示法使用每个字符串元素 16 位,这可以描述多达 2¹⁶ 个不同的字符。然而,Unicode 定义的字符数量超过这个限制——目前大约是其两倍。因此,一些字符,比如许多表情符号,在 JavaScript 字符串中占用两个“字符位置”。我们将在第五章中回到这个问题。
字符串不能被除、乘或减。+操作符可以用于它们,不是用于相加,而是用于连接——将两个字符串粘合在一起。以下行将产生字符串“concatenate”:
"con" + "cat" + "e" + "nate"
字符串值有多个相关的函数(方法),可以用来对它们执行其他操作。我将在第四章中详细说明这些。
用单引号或双引号写的字符串行为非常相似;唯一的区别在于你需要转义的引号类型。反引号引起来的字符串,通常称为模板字面量,可以做一些更多的技巧。除了能够跨越多行外,它们还可以嵌入其他值。
`half of 100 is ${100 / 2}`
当你在模板字面量中写入${}内的内容时,其结果将被计算、转换为字符串,并包含在该位置。这个例子产生的字符串是“100 的一半是 50”。
一元操作符
并非所有操作符都是符号。有些是用单词书写的。一个例子是typeof操作符,它会生成一个字符串值,指明你提供的值的类型。
console.log(typeof 4.5)
// → number
console.log(typeof "x")
// → string
我们将在示例代码中使用console.log来指示我们想要查看某个表达式的计算结果。(更多内容将在下一章讨论。)
本章迄今为止显示的其他操作符都作用于两个值,但typeof只接受一个。使用两个值的操作符称为二元操作符,而只接受一个的称为一元操作符。减号操作符(-)可以同时作为二元操作符和一元操作符使用。
console.log(- (10 - 2))
// → -8
布尔值
通常情况下,有一个值用来区分只有两个可能性是很有用的,比如“是”和“否”或“开”和“关”。为此,JavaScript 有一个布尔类型,它只有两个值:true和false,以这两个词书写。
比较
这是生成布尔值的一种方式:
console.log(3 > 2)
// → true
console.log(3 < 2)
// → false
和
<符号分别是“更大于”和“更小于”的传统符号。它们是二元运算符。应用它们的结果是一个布尔值,指示它们在此情况下是否成立。
字符串可以以相同的方式进行比较。
console.log("Aardvark" < "Zoroaster")
// → true
字符串的排序方式大致上是字母顺序,但并不是你在字典中预期看到的那样:大写字母总是“少于”小写字母,因此"Z" < "a",而且非字母字符(!、- 等)也包含在排序中。在比较字符串时,JavaScript 从左到右逐个比较字符的 Unicode 代码。
其他类似的运算符有>=(大于或等于)、<=(小于或等于)、==(等于)和!=(不等于)。
console.log("Garnet" != "Ruby")
// → true
console.log("Pearl" == "Amethyst")
// → false
在 JavaScript 中,唯一一个不等于自身的值是NaN(“不是一个数字”)。
console.log(NaN == NaN)
// → false
NaN用于表示无意义计算的结果,因此它不等于任何其他无意义计算的结果。
逻辑运算符
还有一些操作可以直接应用于布尔值本身。JavaScript 支持三种逻辑运算符:与、或和非。这些可以用来对布尔值进行“推理”。
&&运算符表示逻辑与。它是一个二元运算符,仅当给它的两个值都为真时,其结果才为真。
console.log(true && false)
// → false
console.log(true && true)
// → true
||运算符表示逻辑或。如果给它的任何值为真,它的结果就是true。
console.log(false || true)
// → true
console.log(false || false)
// → false
非(Not)写作感叹号(!)。它是一个一元运算符,反转给它的值——!true产生false,而!false产生true。
在将这些布尔运算符与算术和其他运算符混合时,并不总是明显需要括号。在实践中,你通常可以知道到目前为止我们看到的运算符中,||的优先级最低,然后是&&,接下来是比较运算符(>、==等),最后是其他。这种顺序的选择旨在使以下典型表达式中尽可能少地需要括号:
1 + 1 == 2 && 10 * 10 > 50
我们要看的最后一个逻辑运算符是非一元、非二元,而是三元,操作三个值。它的写法是用一个问号和一个冒号,如下所示:
console.log(true ? 1 : 2);
// → 1
console.log(false ? 1 : 2);
// → 2
这个被称为条件运算符(有时简称为三元运算符,因为它是该语言中唯一的此类运算符)。该运算符使用问号左侧的值来决定“选择”哪两个值中的一个。如果你写a ? b : c,当a为真时结果为b,否则为c。
空值
两个特殊值,写作null和undefined,用于表示缺少有意义的值。它们本身是值,但不携带任何信息。
语言中许多不产生有意义值的操作会产生undefined,仅仅因为它们必须产生某个值。
undefined和null之间的意义差异是JavaScript设计的一个意外,大多数情况下这并不重要。在你需要关注这些值的情况下,我建议将它们视为大致可互换的。
自动类型转换
在介绍中,我提到JavaScript不遗余力地接受几乎任何你给它的程序,甚至是做奇怪事情的程序。以下表达式很好地展示了这一点:
console.log(8 * null)
// → 0
console.log("5" - 1)
// → 4
console.log("5" + 1)
// → 51
console.log("five" * 2)
// → NaN
console.log(false == 0)
// → true
当运算符应用于“错误”的类型值时,JavaScript会悄悄地将该值转换为所需的类型,使用的一套规则通常不是你想要或期望的。这被称为类型强制转换。第一个表达式中的null变成0,第二个表达式中的"5"变成5(从字符串到数字)。然而在第三个表达式中,+在进行数字相加之前会尝试字符串拼接,因此1被转换为"1"(从数字到字符串)。
当某个值在明显意义上无法映射为数字(例如"five"或undefined)时,被转换为数字后会得到NaN。对NaN的进一步算术操作仍然会产生NaN,因此如果你在意想不到的地方遇到这种情况,请检查是否发生了意外的类型转换。
当使用==运算符比较相同类型的值时,结果是容易预测的:如果两个值相同,你应该得到true,除了NaN的情况。但是,当类型不同,JavaScript会使用一套复杂而混乱的规则来决定如何处理。在大多数情况下,它只是尝试将其中一个值转换为另一个值的类型。然而,当运算符两侧有null或undefined时,只有在两侧都是null或undefined之一时才会返回true。
console.log(null == undefined);
// → true
console.log(null == 0);
// → false
这种行为通常很有用。当你想测试一个值是否具有实际的值而不是null或undefined时,可以用==或!=运算符将其与null进行比较。
如果你想测试某个东西是否确切指向false值呢?像0 == false和"" == false这样的表达式也为真,因为自动类型转换。当你不希望发生任何类型转换时,还有两个额外的运算符:===和!==。第一个测试一个值是否精确等于另一个,第二个测试是否不精确等于。因此,"" === false是false,正如预期的那样。
我建议防御性地使用三字符比较运算符,以防止意外的类型转换让你困扰。但当你确信两侧的类型将相同时,使用较短的运算符没有问题。
逻辑运算符的短路
逻辑运算符&&和||以一种特殊的方式处理不同类型的值。它们会将左侧的值转换为布尔类型以决定如何操作,但根据运算符和转换结果,它们会返回原始的左侧值或右侧值。
例如,||运算符将在左侧的值可以转换为true时返回该值,否则返回右侧的值。当值为布尔值时,这具有预期效果,对其他类型的值也做类似处理。
console.log(null || "user")
// → user
console.log("Agnes" || "user")
// → Agnes
我们可以利用这个功能作为回退到默认值的一种方式。如果你有一个可能为空的值,可以在其后加上||和替代值。如果初始值可以转换为false,你将得到替代值。将字符串和数字转换为布尔值的规则规定,0、NaN和空字符串("")视为false,而其他所有值都视为true。这意味着0 || -1产生-1,"" || “!?”得“!?”。
??运算符与||类似,但仅在左侧的值为null或undefined时返回右侧的值,而不是其他可以转换为false的值。通常,这比||的行为更可取。
console.log(0 || 100);
// → 100
console.log(0 ?? 100);
// → 0
console.log(null ?? 100);
// → 100
&&运算符类似,但方向相反。当左侧的值是转换为false的东西时,它返回该值,否则返回右侧的值。
这两个运算符的另一个重要特性是,它们右侧的部分只有在必要时才会被计算。在true || X的情况下,无论X是什么——即使它是一个做一些可怕事情的程序——结果将为true,X从不被计算。false && X也是如此,它是false并将忽略X。这被称为短路评估。
条件运算符的工作方式类似。第二和第三个值中,只有被选择的那个会被计算。
摘要
在本章中,我们查看了四种类型的 JavaScript 值:数字、字符串、布尔值和未定义值。这些值可以通过输入其名称(true、null)或值(13、"abc")来创建。
你可以使用运算符组合和转换值。我们看到的二元运算符包括算术运算(+、-、*、/和%)、字符串连接(+)、比较(==、!=、===、!==、<、>、<=、>=)以及逻辑运算(&&、||、??),还有几个一元运算符(-用于取反一个数字,!用于逻辑取反,以及typeof用于查找值的类型)和一个三元运算符(?:)用于根据第三个值选择两个值中的一个。
这为你提供了足够的信息来将 JavaScript 用作口袋计算器,但仅此而已。下一章将开始将这些表达式结合成基本程序。
在我薄薄的、半透明的皮肤下,我的心脏闪耀着亮红色,他们不得不给我注射 10cc 的 JavaScript 才能让我恢复。(我对血液中的毒素反应良好。)天啊,那东西会让你的腮帮子嗓子都冒桃子!
—为何,Why’s (Poignant) Guide to Ruby

第三章:程序结构
在本章中,我们将开始做一些真正可以称之为编程的事情。我们将把对JavaScript语言的掌握扩展到名词和我们迄今为止看到的句子片段的范围,达到能够表达有意义的散文的程度。
表达式与语句
在第一章中,我们创建了值并将运算符应用于这些值以获得新值。像这样创建值是任何JavaScript程序的主要内容。但这些内容必须框架在一个更大的结构中才能有用。这就是我们在本章中要讨论的内容。
产生一个值的代码片段称为表达式。每个字面写出的值(如22或“精神分析”)都是一个表达式。括号中的表达式也是表达式,二元运算符应用于两个表达式或一元运算符应用于一个表达式也是如此。
这展示了基于语言的接口的部分美妙之处。表达式可以包含其他表达式,这与人类语言中子句嵌套的方式类似——一个子句可以包含它自己的子句,依此类推。这使我们能够构建描述任意复杂计算的表达式。
如果一个表达式对应于一个句子片段,那么JavaScript的语句对应于一个完整的句子。一个程序是一系列语句。
最简单的语句是一个表达式,后面跟着一个分号。这是一个程序:
1;
!false;
不过,这仍然是一个无用的程序。一个表达式可以满足于仅仅生成一个值,然后该值可以被外部代码使用。然而,一个语句是独立存在的,因此如果它不影响世界,那就是无用的。它可以在屏幕上显示某些内容,比如通过console.log,或者以某种方式改变机器的状态,从而影响之后的语句。这些变化被称为副作用。前一个示例中的语句仅仅生成值1和true,然后立即将它们丢弃。这对世界没有任何影响。当你运行这个程序时,没有任何可观察的事情发生。
在某些情况下,JavaScript允许你省略语句末尾的分号。在其他情况下,它必须存在,否则下一行将被视为同一语句的一部分。关于何时可以安全省略分号的规则有些复杂且容易出错。因此在本书中,所有需要分号的语句都会包含一个。我建议你也这样做,至少在你学会了更多关于省略分号的细微之处之前。
绑定
程序如何保持内部状态?它是如何记住事物的?我们已经看到如何从旧值生成新值,但这并不会改变旧值,而且新值必须立即使用,否则会再次消散。为了捕捉和保持值,JavaScript提供了一种叫做绑定或变量的东西。
let caught = 5 * 5;
这给了我们第二种语句。特殊词(关键字)let表示此句将定义一个绑定。它后面跟着绑定的名称,如果我们想立即赋值,则跟着一个=运算符和一个表达式。
这个例子创建了一个名为caught的绑定,并用它来获取通过将5与5相乘产生的数字。
在定义一个绑定后,可以将其名称用作表达式。此类表达式的值是绑定当前持有的值。这里有一个例子:
let ten = 10;
console.log(ten * ten);
// → 100
当一个绑定指向一个值时,并不意味着它永远与该值绑定。=运算符可以随时在现有绑定上使用,以将它们从当前值中断开,并指向一个新值。
let mood = "light";
console.log(mood);
// → light
mood = "dark";
console.log(mood);
// → dark
你应该把绑定想象成触手,而不是盒子。它们不包含值;它们抓住值——两个绑定可以引用同一个值。程序只能访问它仍有引用的值。当你需要记住某些东西时,你要么生长一个触手来保持它,要么将你现有的一个触手重新连接到它。
让我们看看另一个例子。为了记住路易吉还欠你的美元金额,你创建了一个绑定。当他还款$35时,你给这个绑定一个新值。
let luigisDebt = 140;
luigisDebt = luigisDebt - 35;
console.log(luigisDebt);
// → 105
当你定义一个绑定而不给它赋值时,触手没有任何东西可抓,因此它就空悬了。如果你询问一个空绑定的值,你会得到值undefined。
单个let语句可以定义多个绑定。定义必须用逗号分隔。
let one = 1, two = 2;
console.log(one + two);
// → 3
单词var和const也可以以类似于let的方式创建绑定。
var name = "Ayda";
const greeting = "Hello ";
console.log(greeting + name);
// → Hello Ayda
其中第一个,var(“变量”的缩写),是在2015年前的JavaScript中声明绑定的方式,当时let还不存在。我将在下一章中详细说明它与let的具体区别。现在,请记住,它主要执行相同的操作,但我们在本书中很少使用它,因为它在某些情况下的行为奇怪。
单词const代表“常量”。它定义了一个常量绑定,该绑定在其存在期间始终指向相同的值。这对于仅将名称赋给某个值的绑定很有用,以便你可以在以后轻松引用它。
绑定名称
绑定名称可以是一个或多个字母的任何序列。数字可以是绑定名称的一部分——例如,catch22是一个有效名称——但名称不能以数字开头。绑定名称可以包含美元符号($)或下划线(_),但不能有其他标点符号或特殊字符。
具有特殊含义的词,如let,是关键字,不能用作绑定名称。还有一些词是“保留以供未来版本使用”的,这些也不能用作绑定名称。关键字和保留字的完整列表相当长。
break case catch class const continue debugger default
delete do else enum export extends false finally for
function if implements import interface in instanceof let
new package private protected public return static super
switch this throw true try typeof var void while with yield
不必担心记住这个列表。当创建绑定产生意外的语法错误时,请检查你是否尝试定义一个保留字。
环境
在特定时间存在的绑定及其值的集合称为环境。当程序启动时,这个环境并不是空的。它总是包含作为语言标准一部分的绑定,并且大多数时候,它也包含提供与周围系统交互方式的绑定。例如,在浏览器中,有一些函数用于与当前加载的网站进行交互,以及读取鼠标和键盘输入。
函数
默认环境中提供的许多值的类型为函数。函数是包裹在值中的一段程序。这种值可以被应用以运行包裹的程序。例如,在浏览器环境中,绑定prompt持有一个函数,该函数显示一个小对话框以请求用户输入。使用方法如下:
prompt("Enter passcode");

执行一个函数称为调用、调用或应用它。你可以通过在产生函数值的表达式后加上括号来调用一个函数。通常你会直接使用持有函数的绑定的名称。括号之间的值被传递给函数内部的程序。在示例中,prompt函数使用我们提供的字符串作为在对话框中显示的文本。传递给函数的值称为参数。不同的函数可能需要不同数量或不同类型的参数。
prompt函数在现代网络编程中使用得不多,主要是因为你无法控制生成的对话框的外观,但它在玩具程序和实验中可能会很有帮助。
console.log函数
在示例中,我使用console.log输出值。大多数JavaScript系统(包括所有现代网页浏览器和Node.js)提供一个console.log函数,将其参数写入某个文本输出设备。在浏览器中,输出会出现在JavaScript控制台。这部分浏览器界面默认是隐藏的,但大多数浏览器在你按下F12或在Mac上按下COMMAND-OPTION-I时会打开它。如果这样不行,可以在菜单中查找名为开发者工具或类似的选项。
尽管绑定名称不能包含句点字符,但console.log确实有一个。这是因为console.log不是一个简单的绑定,而是一个从console绑定持有的值中检索log属性的表达式。我们将在第四章中确切了解这意味着什么。
返回值
显示对话框或在屏幕上写入文本是一个副作用。许多函数因其产生的副作用而变得有用。函数也可以产生值,在这种情况下,它们不需要有副作用才能有用。例如,函数Math.max接受任意数量的数字参数并返回最大值。
console.log(Math.max(2, 4));
// → 4
当一个函数生成一个值时,称其为返回该值。在JavaScript中,任何生成值的东西都是一个表达式,这意味着函数调用可以在更大的表达式中使用。在以下代码中,调用Math.min(与Math.max相反)作为加法表达式的一部分:
console.log(Math.min(2, 4) + 100);
// → 102
第三章将解释如何编写自己的函数。
控制流
当你的程序包含多个语句时,这些语句的执行就像一个故事,从上到下。例如,以下程序有两个语句。第一个请求用户输入一个数字,第二个在第一个之后执行,显示该数字的平方。
let theNumber = Number(prompt("Pick a number"));
console.log("Your number is the square root of " +
theNumber * theNumber);
Number函数将一个值转换为数字。我们需要这个转换,因为prompt的结果是一个字符串值,而我们想要的是一个数字。还有类似的函数叫做String和Boolean,它们将值转换为相应的类型。
这里是简单的直线控制流的示意图:

条件执行
并不是所有程序都是直线道路。例如,我们可能希望创建一条分支道路,在这种情况下,程序根据当前情况选择适当的分支。这被称为条件执行。

条件执行是在JavaScript中通过if关键字创建的。在简单的情况下,我们希望某段代码仅在某个条件成立时执行。例如,我们可能希望仅在输入实际是数字时显示输入的平方。
let theNumber = Number(prompt("Pick a number"));
if (!Number.isNaN(theNumber)) {
console.log("Your number is the square root of " +
theNumber * theNumber);
}
通过这个修改,如果你输入鹦鹉,将不会显示任何输出。
if关键字根据布尔表达式的值执行或跳过语句。决定性的表达式写在关键字后面,用括号括起来,后面跟着要执行的语句。
Number.isNaN函数是一个标准的JavaScript函数,仅当给定的参数是NaN时返回true。Number函数在你给它一个不表示有效数字的字符串时,恰好返回NaN。因此,这个条件可以理解为“除非theNumber不是一个数字,否则执行这个操作。”
在这个例子中,if后面的语句用大括号({和})包裹起来。大括号可以用来将任意数量的语句组合成一个单一的语句,称为块。在这种情况下,你也可以省略它们,因为它们只包含一条语句,但为了避免考虑它们是否需要,大多数JavaScript程序员在每个包裹的语句中都使用它们。在本书中,我们大多数情况下将遵循这个约定,偶尔会有一行代码的情况。
if (1 + 1 == 2) console.log("It's true");
// → It's true
你通常不仅会有在条件为真时执行的代码,还有处理其他情况的代码。这个替代路径在图中用第二个箭头表示。你可以使用else关键字,与if一起创建两个独立的替代执行路径。
let theNumber = Number(prompt("Pick a number"));
if (!Number.isNaN(theNumber)) {
console.log("Your number is the square root of " +
theNumber * theNumber);
} else {
console.log("Hey. Why didn't you give me a number?");
}
如果你有超过两个路径可以选择,你可以将多个if/else对组合在一起。这里有一个例子:
let num = Number(prompt("Pick a number"));
if (num < 10) {
console.log("Small");
} else if (num < 100) {
console.log("Medium");
} else {
console.log("Large");
}
程序将首先检查num是否小于10。如果是,它选择那个分支,显示“Small”,并结束。如果不是,它选择else分支,该分支本身包含一个第二个if。如果第二个条件(< 100)成立,这意味着这个数字至少是10,但低于100,显示“Medium”。如果不成立,就选择第二个也是最后的else分支。
该程序的框架大致如下:

while和do循环
考虑一个输出0到12之间所有偶数的程序。可以这样编写:
console.log(0);
console.log(2);
console.log(4);
console.log(6);
console.log(8);
console.log(10);
console.log(12);
这虽然可行,但编写程序的想法是为了减少工作量,而不是增加。如果我们需要所有小于1,000的偶数,这种方法将行不通。我们需要的是一种能多次运行代码的方法。这种控制流的形式称为循环。

循环控制流允许我们返回到程序中的某个点,并用当前的程序状态重复执行。如果我们将其与一个计数绑定结合起来,我们可以做到类似于:
let number = 0;
while (number <= 12) {
console.log(number);
number = number + 2;
}
// → 0
// → 2
// ... etcetera
以关键字while开头的语句会创建一个循环。while后面跟着一个括号中的表达式,然后是一个语句,和if类似。只要表达式产生一个转换为布尔值时为真值的值,循环就会不断进入该语句。
数字绑定演示了绑定如何跟踪程序的进展。每次循环重复时,number会获取比其先前值多2的值。在每次重复的开始,它会与数字12进行比较,以决定程序的工作是否完成。
作为一个实际上能做有用事情的例子,我们现在可以编写一个程序来计算并显示2¹⁰(2的10次方)的值。我们使用两个绑定:一个用于跟踪结果,另一个用于计数我们将这个结果乘以2的次数。循环测试第二个绑定是否已达到10,如果没有,就更新这两个绑定。
let result = 1;
let counter = 0;
while (counter < 10) {
result = result * 2;
counter = counter + 1;
}
console.log(result);
// → 1024
计数器也可以从1开始,并检查是否<= 10,但出于在第四章中显而易见的原因,习惯从0开始计数是个好主意。
请注意,JavaScript还有一个用于指数运算的运算符(2 ** 10),你可以在实际代码中使用它来计算这一点——但这会破坏示例。
do循环是一种控制结构,类似于while循环。它的不同之处在于:do循环至少会执行其主体一次,只有在第一次执行之后才开始测试是否应该停止。为了反映这一点,测试出现在循环主体之后。
let yourName;
do {
yourName = prompt("Who are you?");
} while (!yourName);
console.log("Hello " + yourName);
该程序将强制你输入一个名称。它会不断询问,直到获得一个非空字符串。使用!运算符将值转换为布尔类型后再取反,除了""之外的所有字符串都会转换为true。这意味着循环将继续,直到你提供一个非空名称。
缩进代码
在示例中,我在某些较大语句的一部分前添加了空格。这些空格不是必需的——计算机没有它们也能正常接受程序。实际上,程序中的换行也是可选的。如果你愿意,可以将程序写成一长行。
在代码块内部的缩进作用是让代码结构对人类读者更明显。在其他块内打开新块的代码中,可能很难看出一个块结束了另一个块开始了。通过适当的缩进,程序的视觉形状与其中的块的形状相对应。我喜欢每个打开的块使用两个空格,但口味各异——有些人使用四个空格,有些人使用制表符。重要的是每个新块添加相同数量的空格。
if (false != true) {
console.log("That makes sense.");
if (1 < 2) {
console.log("No surprise there.");
}
}
大多数代码编辑器程序会自动将新行缩进到适当的位置。
for循环
许多循环遵循while示例中展示的模式。首先创建一个“计数器”绑定,以跟踪循环的进度。然后是一个while循环,通常带有一个测试表达式,以检查计数器是否已达到其结束值。在循环体结束时,计数器会更新以跟踪进度。
由于这种模式非常常见,JavaScript和类似语言提供了一种稍短且更全面的形式,即for循环。
for (let number = 0; number <= 12; number = number + 2) {
console.log(number);
}
// → 0
// → 2
// ... etcetera
该程序与之前的偶数打印示例完全相同(见第30页)。唯一的变化是与循环的状态相关的所有语句都在for之后分组在一起。
for关键字后的括号必须包含两个分号。第一个分号之前的部分初始化循环,通常通过定义一个绑定来实现。第二部分是检查循环是否应该继续的表达式。最后一部分在每次迭代后更新循环的状态。在大多数情况下,这比while结构更简洁明了。
这是使用for循环而非while循环计算2¹⁰的代码:
let result = 1;
for (let counter = 0; counter < 10; counter = counter + 1) {
result = result * 2;
}
console.log(result);
// → 1024
跳出循环
使循环条件产生false不是循环结束的唯一方式。break语句的作用是立即跳出所包围的循环。其用法在以下程序中演示,该程序找出第一个大于或等于20且能被7整除的数字。
for (let current = 20; ; current = current + 1) {
if (current % 7 == 0) {
console.log(current);
break;
}
}
// → 21
使用余数(%)运算符是测试一个数字是否能被另一个数字整除的简单方法。如果能,二者相除的余数就是零。
示例中的for构造没有检查循环结束的部分。这意味着循环永远不会停止,除非执行内部的break语句。
如果你删除那个break语句,或者不小心写了一个始终为真的结束条件,你的程序将陷入*无限循环*。被困在无限循环中的程序永远不会完成运行,这通常是个坏事。
continue关键字与break类似,它会影响循环的进度。当在循环体内遇到continue时,控制权会跳出该体,并继续进行循环的下一次迭代。
简洁地更新绑定。
特别是在循环中,程序通常需要“更新”绑定,以基于该绑定的先前值保持一个值。
counter = counter + 1;
JavaScript为此提供了一个快捷方式。
counter += 1;
对于许多其他运算符,类似的快捷方式也有效,例如result *= 2用于将result加倍,或counter -= 1用于向下计数。
这使我们能够进一步缩短计数示例。
for (let number = 0; number <= 12; number += 2) {
console.log(number);
}
对于counter += 1和counter -= 1,还有更短的等效形式:counter++和counter--。
使用switch根据值调度。
代码看起来像这样并不少见:
if (x == "value1") action1();
else if (x == "value2") action2();
else if (x == "value3") action3();
else defaultAction();
有一种构造叫做switch,旨在以更直接的方式表达这样的“调度”。不幸的是,JavaScript使用的语法(它从C/Java编程语言继承而来)有些笨拙——一系列if语句可能看起来更好。以下是一个示例:
switch (prompt("What is the weather like?")) {
case "rainy":
console.log("Remember to bring an umbrella.");
break;
case "sunny":
console.log("Dress lightly.");
case "cloudy":
console.log("Go outside.");
break;
default:
console.log("Unknown weather type!");
break;
}
你可以在switch打开的块内放置任意数量的case标签。程序将从与给定switch值相对应的标签开始执行,如果没有匹配值,则从default开始。它将继续执行,甚至跨越其他标签,直到达到break语句。在某些情况下,例如示例中的“晴天”情况,可以用来在不同的情况之间共享一些代码(它建议在晴天和阴天都出去)。但要小心——很容易忘记这样的break,这将导致程序执行你不希望执行的代码。
大写。
绑定名称不能包含空格,但使用多个词清晰描述绑定所代表的内容通常是有帮助的。以下是编写包含多个单词的绑定名称的选择:
fuzzylittleturtle
fuzzy_little_turtle
FuzzyLittleTurtle
fuzzyLittleTurtle
第一种风格可能难以阅读。我比较喜欢下划线的样子,尽管这种风格有点难打。标准的JavaScript函数和大多数JavaScript程序员遵循最后一种风格——它们将每个单词的首字母大写,除了第一个。适应这样的细节并不难,而混合命名风格的代码可能会让人阅读不畅,所以我们遵循这一惯例。
在少数情况下,例如数字函数,绑定的首字母也会大写。这是为了将该函数标记为构造函数。在第六章中会明确说明构造函数是什么。现在,重要的是不要被这种明显的不一致所困扰。
注释。
通常,原始代码无法传达程序希望传达给人类读者的所有信息,或者以一种人们可能无法理解的方式传达。有时,你可能只是想在程序中包含一些相关的想法。这就是*注释*的作用。
注释是一段程序的一部分,但计算机完全忽略它。JavaScript有两种写注释的方法。要编写单行注释,可以使用两个斜杠字符(//),然后在后面写上注释文本。
let accountBalance = calculateBalance(account);
// It's a green hollow where a river sings
accountBalance.adjust();
// Madly catching white tatters in the grass.
let report = new Report();
// Where the sun on the proud mountain rings:
addToReport(accountBalance, report);
// It's a little valley, foaming like light in a glass.
//注释只到行尾。位于/*和*/之间的文本段落将完全被忽略,无论是否包含换行。这对于添加关于文件或程序块的信息块是很有用的。
/*
I first found this number scrawled on the back of an old
notebook. Since then, it has often dropped by, showing up in
phone numbers and the serial numbers of products that I've
bought. It obviously likes me, so I've decided to keep it.
*/
const myNumber = 11213;
摘要。
你现在知道程序是由语句构成的,而这些语句有时又包含更多的语句。语句往往包含表达式,而这些表达式可以由更小的表达式构建而成。
将语句一个接一个放置,构成一个从上到下执行的程序。你可以通过使用条件语句(if、else和switch)和循环语句(while、do和for)引入控制流中的干扰。
绑定可以用来将数据片段归档到一个名称下,它们对于跟踪程序状态是有用的。环境是定义的绑定集合。JavaScript系统总是将一些有用的标准绑定放入你的环境中。
函数是封装了一段程序的特殊值。你可以通过编写functionName(argument1, argument2)来调用它们。这样的函数调用是一个表达式,可能会产生一个值。
练习。
如果你不确定如何测试练习的解决方案,请参考引言。
每个练习以问题描述开始。阅读这个描述并尝试解决练习。如果遇到问题,可以考虑阅读书末的提示。你可以在*[eloquentjavascript.net/code#2](https://eloquentjavascript.net/code#2)*找到练习的完整解决方案。如果你想从练习中学到东西,我建议在你解决了练习之后,或者至少在你努力攻克它到头痛的程度后,再查看解决方案。
*循环三角形*
编写一个循环,使其调用console.log七次,以输出以下三角形:
#
##
###
####
#####
######
#######
了解如何通过在字符串后写.length来获取字符串的长度可能会很有用。
let abc = "abc";
console.log(abc.length);
// → 3
*FizzBuzz*
编写一个程序,使用console.log打印从1到100的所有数字,有两个例外。对于能被3整除的数字,打印“Fizz”代替数字,而对于能被5整除(且不能被3整除)的数字,打印“Buzz”代替。
当你实现这个功能后,修改你的程序以打印“FizzBuzz”,用于那些同时能被3和5整除的数字(同时仍然打印“Fizz”或“Buzz”用于仅能被其中一个整除的数字)。
(这实际上是一个面试问题,据说可以筛选出相当一部分程序员候选人。所以如果你解决了这个问题,你的劳动力市场价值就上升了。)
*棋盘*
编写一个程序,创建一个表示8×8网格的字符串,使用换行符分隔行。在网格的每个位置上都有一个空格或一个“#”字符。这些字符应该形成一个棋盘。
将这个字符串传递给console.log应该显示类似这样的内容:
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
当你有一个生成这个图案的程序时,定义一个绑定size = 8,并修改程序使其适用于任意大小,输出给定宽度和高度的网格。
*人们认为计算机科学是天才的艺术,但实际上情况正好相反,只是许多人在做相互积累的事情,就像一堵迷你石头墙。*
—唐纳德·克努斯

第四章:函数
函数是 JavaScript 编程中最核心的工具之一。将一段程序包装在一个值中的概念有很多用途。它为我们提供了一种结构化大型程序的方法,减少重复,将名称与子程序关联,以及将这些子程序相互隔离。
函数最明显的用途是定义新词汇。在散文中,创造新词通常被视为不良风格,但在编程中这是不可或缺的。
典型的成人英语使用者的词汇量大约有 20,000 个单词。很少有编程语言内置 20,000 个命令。而可用的词汇往往被定义得更为精确,因此在灵活性上不如人类语言。因此,我们必须引入新词,以避免过于冗长。
定义函数
函数定义是一种常规绑定,其中绑定的值是一个函数。例如,以下代码将square定义为指向一个生成给定数字平方的函数:
const square = function(x) {
return x * x;
};
console.log(square(12));
// → 144
函数是通过以关键字function开头的表达式创建的。函数有一组参数(在此例中,仅为x)和一个主体,主体包含在调用函数时要执行的语句。以这种方式创建的函数主体必须始终用大括号包裹,即使它仅由一个语句组成。
函数可以有多个参数或根本没有参数。在以下示例中,makeNoise没有列出任何参数名,而roundTo(将n四舍五入到step的最近倍数)列出了两个:
const makeNoise = function() {
console.log("Pling!");
};
makeNoise();
// → Pling!
const roundTo = function(n, step) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(23, 10));
// → 20
一些函数,比如roundTo和square,会产生一个值,而有些则不会,比如makeNoise,它唯一的结果是一个副作用。返回语句决定了函数返回的值。当控制流遇到这样的语句时,它会立即跳出当前函数,并将返回的值传递给调用该函数的代码。如果return关键字后没有表达式,函数将返回undefined。没有返回语句的函数,例如makeNoise,也同样返回undefined。
函数的参数表现得像常规绑定,但它们的初始值由函数的调用者提供,而不是函数内部的代码。
绑定与作用域
每个绑定都有一个作用域,即绑定可见的程序部分。对于在任何函数、块或模块外部定义的绑定(见第十章),作用域是整个程序——你可以在任何地方引用这些绑定。这些被称为全局绑定。
为函数参数创建的绑定或在函数内部声明的绑定只能在该函数内引用,因此它们被称为局部绑定。每次调用函数时,这些绑定的新实例都会被创建。这在函数之间提供了一些隔离——每个函数调用都在自己的小世界中运行(其局部环境),并且通常可以在不需要了解全局环境中发生的事情的情况下理解。
使用let和const声明的绑定实际上是局部于其声明所在的块,因此如果在循环内部创建了其中一个,循环前后的代码无法“看到”它
let x = 10; // Global
if (true) {
let y = 20; // Local to block
var z = 30; // Also global
}
每个作用域可以“向外”查看周围的作用域,因此在示例中的块内部可以看到x。例外情况是当多个绑定具有相同名称时——在这种情况下,代码只能看到最内层的绑定。例如,当halve函数内部的代码引用n时,它看到的是它自己的 n,而不是全局的n。
const halve = function(n) {
return n / 2;
};
let n = 10;
console.log(halve(100));
// → 50
console.log(n);
// → 10
嵌套作用域
JavaScript不仅区分全局绑定和局部绑定。块和函数可以在其他块和函数内部创建,从而产生多重局部性。
例如,这个函数——输出制作一批鹰嘴豆泥所需的成分——内部还有另一个函数:
const hummus = function(factor) {
const ingredient = function(amount, unit, name) {
let ingredientAmount = amount * factor;
if (ingredientAmount > 1) {
unit += "s";
}
console.log(`${ingredientAmount} ${unit} ${name}`);
};
ingredient(1, "can", "chickpeas");
ingredient(0.25, "cup", "tahini");
ingredient(0.25, "cup", "lemon juice");
ingredient(1, "clove", "garlic");
ingredient(2, "tablespoon", "olive oil");
ingredient(0.5, "teaspoon", "cumin");
};
成分函数内部的代码可以看到外部函数的factor绑定。但它的局部绑定,例如unit或ingredientAmount,在外部函数中不可见。
块内可见的绑定集合由该块在程序文本中的位置决定。每个局部作用域也可以看到包含它的所有局部作用域,所有作用域都可以看到全局作用域。这种绑定可见性的处理方式称为词法作用域。
函数作为值
函数绑定通常只是程序中某个特定部分的名称。这种绑定定义一次,永不改变。这使得函数和其名称之间容易混淆。
但二者是不同的。函数值可以执行其他值能够做的所有操作——你可以在任意表达式中使用它,而不仅仅是调用它。可以将函数值存储在新的绑定中,作为参数传递给一个函数,等等。类似地,持有函数的绑定仍然只是一个常规绑定,如果不是常量,它可以被分配一个新值,如下所示:
let launchMissiles = function() {
missileSystem.launch("now");
};
if (safeMode) {
launchMissiles = function() {/* do nothing */};
}
在第五章中,我们将讨论通过将函数值传递给其他函数可以做的有趣事情。
声明符号
创建函数绑定有一种稍短的方式。当在语句开始时使用function关键字时,它的工作方式是不同的。
function square(x) {
return x * x;
}
这是一个函数声明。该语句定义了绑定square,并指向给定的函数。它稍微容易写一点,并且在函数后不需要分号。
这种形式的函数定义有一个细微之处。
console.log("The future says:", future());
function future() {
return "You'll never have flying cars";
}
前面的代码可以正常工作,即使函数定义在使用它的代码下面。函数声明并不是常规自上而下控制流的一部分。它们在概念上被移到作用域的顶部,可以被该作用域内的所有代码使用。这在某些情况下很有用,因为它提供了按最清晰的方式排列代码的自由,而不必担心在使用之前定义所有函数。
箭头函数
函数还有第三种表示法,它看起来与其他两种非常不同。它使用一个箭头(=>),由一个等号和一个大于号字符组成(不要与大于或等于运算符混淆,该运算符写作>=)。
const roundTo = (n, step) => {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
箭头位于参数列表之后,并后接函数的主体。它表达了类似于“这个输入(参数)产生这个结果(主体)”的意思。
当只有一个参数名称时,可以省略参数列表周围的括号。如果主体是单个表达式而不是用大括号括起来的代码块,那么该表达式将从函数中返回。这意味着这两种对square的定义做的是同样的事情:
const square1 = (x) => { return x * x; };
const square2 = x => x * x;
当箭头函数没有任何参数时,它的参数列表只是一个空的括号。
const horn = () => {
console.log("Toot");
};
语言中同时存在箭头函数和函数表达式没有深层原因。除了一个我们将在第六章中讨论的小细节,它们做的是同样的事情。箭头函数是在 2015 年添加的,主要是为了以较少的冗长方式编写小的函数表达式。我们将在第五章中经常使用它们。
调用栈
控制在函数中的流动方式有些复杂。让我们仔细看看。以下是一个简单的程序,进行了一些函数调用:
function greet(who) {
console.log("Hello " + who);
}
greet("Harry");
console.log("Bye");
运行这个程序大致是这样的:对greet的调用使控制跳到该函数的开始(第 2 行)。该函数调用console.log,console.log接管控制,完成它的工作,然后将控制返回到第 2 行。在那里,它到达greet函数的末尾,因此返回到调用它的地方——第 4 行。之后的行再次调用console.log。在返回后,程序到达结束。
我们可以将控制流示意性地展示如下:
not in function
in greet
in console.log
in greet
not in function
in console.log
not in function
因为函数在返回时必须跳回调用它的地方,所以计算机必须记住调用发生的上下文。在一种情况下,console.log在完成时必须返回到greet函数。在另一种情况下,它返回到程序的末尾。
计算机存储这个上下文的地方是调用栈。每次调用函数时,当前上下文会被存储在这个栈的顶部。当函数返回时,它从栈中移除顶部的上下文,并使用该上下文继续执行。
存储这个栈需要计算机内存中的空间。当栈增长得太大时,计算机会出现“栈空间不足”或“递归过多”等错误消息。以下代码通过向计算机提出一个非常棘手的问题来说明这一点,这会导致两个函数之间发生无限的往返。或者说,如果计算机有无限的栈,这将是无限的。实际上,我们会耗尽空间,或者“使栈溢出”。
function chicken() {
return egg();
}
function egg() {
return chicken();
}
console.log(chicken() + " came first.");
// → ??
可选参数
以下代码是允许的,并且没有任何问题地执行:
function square(x) { return x * x; }
console.log(square(4, true, "hedgehog"));
// → 16
我们仅用一个参数定义了平方。然而,当我们用三个参数调用它时,语言并不会抱怨。它会忽略额外的参数,计算第一个参数的平方。
JavaScript对于你可以传递给函数的参数数量极为宽松。如果你传递太多,额外的参数会被忽略。如果你传递的参数太少,缺失的参数会被赋值为undefined。
这样做的缺点是,可能——甚至很可能——你会不小心向函数传递错误数量的参数。而且没有人会告诉你。好处是,你可以利用这种行为,使得一个函数可以接受不同数量的参数。例如,这个minus函数尝试模仿-运算符,可以处理一个或两个参数:
function minus(a, b) {
if (b === undefined) return -a;
else return a - b;
}
console.log(minus(10));
// → -10
console.log(minus(10, 5));
// → 5
如果在参数后写一个=运算符,后面跟一个表达式,当未提供参数时,该表达式的值将替代参数。例如,这个版本的roundTo使得它的第二个参数变为可选。如果你不提供它或传递值undefined,它将默认为1。
function roundTo(n, step = 1) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(4.5));
// → 5
console.log(roundTo(4.5, 2));
// → 4
下一章将介绍一种方法,通过这种方法,函数体可以访问它所接收到的所有参数列表。这是非常有帮助的,因为它允许函数接受任意数量的参数。例如,console.log就是这样做的,输出它所接收到的所有值。
console.log("C", "O", 2);
// → C O 2
闭包
将函数视为值的能力,加上每次调用函数时局部绑定会被重新创建的事实,提出了一个有趣的问题:当创建它们的函数调用不再活跃时,局部绑定会发生什么?
以下代码展示了这个例子。它定义了一个函数wrapValue,创建了一个局部绑定。然后返回一个可以访问并返回这个局部绑定的函数。
function wrapValue(n) {
let local = n;
return () => local;
}
let wrap1 = wrapValue(1);
let wrap2 = wrapValue(2);
console.log(wrap1());
// → 1
console.log(wrap2());
// → 2
这是允许的,并且按你所希望的那样工作——两个绑定实例仍然可以被访问。这种情况很好地展示了局部绑定是为每次调用重新创建的,不同的调用不会互相影响各自的局部绑定。
这一特性——能够引用包围作用域中特定实例的局部绑定——被称为闭包。引用周围局部作用域中绑定的函数被称为闭包。这种行为不仅让你不必担心绑定的生命周期,而且使得以某种创造性的方式使用函数值成为可能。
通过稍微修改,我们可以将之前的示例转变为一种创建可以乘以任意数值的函数的方法。
function multiplier(factor) {
return number => number * factor;
}
let twice = multiplier(2);
console.log(twice(5));
// → 10
在 wrapValue 示例中的显式局部绑定其实并不是必需的,因为参数本身就是一个局部绑定。
以这种方式思考程序需要一些练习。一个好的心理模型是将函数值视为包含其主体中的代码以及创建它时的环境。当被调用时,函数主体看到的是它创建时的环境,而不是它被调用时的环境。
在这个示例中,multiplier被调用并创建了一个环境,其中其因子参数绑定为2。它返回的函数值被存储在twice中,记住了这个环境,以便在调用时将其参数乘以2。
递归
函数
function power(base, exponent) {
if (exponent == 0) {
return 1;
} else {
return base * power(base, exponent - 1);
}
}
console.log(power(2, 3));
// → 8
这与数学家对指数运算的定义非常接近,并且可以说比我们在第二章中使用的循环更清晰地描述了这一概念。该函数多次调用自身,指数越来越小,以实现重复乘法。
然而,这种实现存在一个问题:在典型的 JavaScript 实现中,它的速度大约是使用 for 循环版本的三倍慢。运行一个简单的循环通常比多次调用函数更便宜。
关于速度与优雅之间的困境是一个有趣的话题。你可以将其视为人性化和机器友好之间的一种连续体。几乎任何程序都可以通过变得更大、更复杂来加快速度。程序员需要找到一个合适的平衡。
对于幂函数而言,一个不优雅的(循环)版本仍然相当简单且易于阅读。用递归函数替代它并没有太大意义。然而,通常情况下,程序处理的概念如此复杂,以至于为了使程序更简洁而牺牲一些效率是有益的。
过于关注效率可能会分散注意力。这又是一个使程序设计复杂化的因素,当你正在做一些已经很困难的事情时,额外需要担心的事可能会让人感到无从下手。
因此,你通常应该从编写一些正确且易于理解的代码开始。如果你担心它太慢——而实际上它通常并不慢,因为大多数代码并不会频繁执行,无法占用显著的时间——你可以在之后进行测量,并在必要时进行改进。
递归并不总是仅仅是循环的一个低效替代方案。有些问题确实用递归比用循环更容易解决。通常这些是需要探索或处理多个“分支”的问题,每个分支可能又会进一步分叉。
考虑这个难题:通过从数字 1 开始,反复添加 5 或乘以 3,可以产生一个无限集合的数字。你会如何写一个函数,给定一个数字,尝试找到这样一系列的加法和乘法来生成那个数字?例如,数字 13 可以通过先乘以 3 然后加 5 两次来得到,而数字 15 则根本无法得到。
这里有一个递归解决方案:
function findSolution(target) {
function find(current, history) {
if (current == target) {
return history;
} else if (current > target) {
return null;
} else {
return find(current + 5, `(${history} + 5)`) ??
find(current * 3, `(${history} * 3)`);
}
}
return find(1, "1");
}
console.log(findSolution(24));
// → (((1 * 3) + 5) * 3)
请注意,这个程序并不一定找到操作的最短序列。它在找到任何序列时就会满足。
如果你立刻看不懂这段代码也没关系。我们一步一步来,因为这将是一个很好的递归思维练习。
内部函数 find 实际上执行递归。它接受两个参数:当前数字和一个记录我们如何到达这个数字的字符串。如果它找到了解决方案,它会返回一个显示如何达到目标的字符串。如果从这个数字出发找不到解决方案,它会返回null。
为此,该函数执行三种操作之一。如果当前数字是目标数字,那么当前历史就是达到该目标的方式,因此会返回。如果当前数字大于目标,继续探索这个分支就没有意义,因为加法和乘法只会使数字变大,因此返回null。最后,如果我们仍然低于目标数字,函数会通过调用自身两次尝试从当前数字开始的两个可能路径,一次用于加法,一次用于乘法。如果第一次调用返回的结果不是null,那么返回它。否则,返回第二次调用的结果,无论它是否生成字符串或null。
为了更好地理解这个函数是如何产生我们所期望的效果的,让我们查看在搜索数字 13 的解决方案时所进行的所有对 find 的调用。
find(1, "1")
find(6, "(1 + 5)")
find(11, "((1 + 5) + 5)")
find(16, "(((1 + 5) + 5) + 5)")
too big
find(33, "(((1 + 5) + 5) * 3)")
too big
find(18, "((1 + 5) * 3)")
too big
find(3, "(1 * 3)")
find(8, "((1 * 3) + 5)")
find(13, "(((1 * 3) + 5) + 5)")
found!
缩进表示调用栈的深度。第一次调用 find 时,函数开始通过调用自身来探索以(1 + 5)开头的解决方案。该调用将进一步递归,以探索每个导致小于或等于目标数字的持续解决方案。由于没有找到一个正好命中的目标,因此返回null回到第一次调用。在那里,??运算符导致探索(1 * 3)的调用发生。这个搜索更幸运——它的第一个递归调用,通过又一个递归调用,找到了目标数字。最内层的调用返回一个字符串,而中间调用中的每个??运算符将该字符串传递下去,最终返回解决方案。
增长函数
函数引入程序的方式有两种或多种自然的方法。
第一个情况发生在你发现自己多次编写类似代码时。你会更希望不这样做,因为代码越多,隐藏错误的空间就越大,尝试理解程序的人需要阅读的材料也就越多。因此,你提取出重复的功能,为其找到一个好名字,并将其放入一个函数中。
第二种方法是你发现需要一些你尚未编写的功能,这听起来应该有自己的函数。你首先给函数命名,然后编写它的主体。在实际定义函数之前,你甚至可能会开始编写使用该函数的代码。
找到一个好名字为函数命名的难易程度,很好地指示了你试图封装的概念有多清晰。让我们通过一个例子来说明。
我们想写一个程序,打印两个数字:农场上牛和鸡的数量,后面跟上“Cows”和“Chickens”这两个词,并在这两个数字前面填充零,使其始终为三位数。
007 Cows
011 Chickens
这要求一个有两个参数的函数——牛的数量和鸡的数量。让我们开始编码吧。
function printFarmInventory(cows, chickens) {
let cowString = String(cows);
while (cowString.length < 3) {
cowString = "0" + cowString;
}
console.log(`${cowString} Cows`);
let chickenString = String(chickens);
while (chickenString.length < 3) {
chickenString = "0" + chickenString;
}
console.log(`${chickenString} Chickens`);
}
printFarmInventory(7, 11);
在字符串表达式后面写.length会给我们该字符串的长度。因此,while循环不断在数字字符串前面添加零,直到它们至少有三个字符长。
任务完成!但就在我们准备将代码(以及一份高额账单)发送给农民时,她打电话告诉我们她也开始养猪了,能不能请我们扩展软件,以便也能打印猪的数量?
我们当然可以。但是就在我们准备再复制粘贴那四行代码时,我们停下来重新考虑。这一定有更好的方法。下面是第一次尝试:
function printZeroPaddedWithLabel(number, label) {
let numberString = String(number);
while (numberString.length < 3) {
numberString = "0" + numberString;
}
console.log(`${numberString} ${label}`);
}
function printFarmInventory(cows, chickens, pigs) {
printZeroPaddedWithLabel(cows, "Cows");
printZeroPaddedWithLabel(chickens, "Chickens");
printZeroPaddedWithLabel(pigs, "Pigs");
}
printFarmInventory(7, 11, 3);
它成功了!但是这个名字printZeroPaddedWithLabel有点尴尬。它将打印、零填充和添加标签这三件事混为一谈,放入了一个函数中。
与其将程序中重复的部分整体提取出来,不如试着挑出一个单一的概念。
function zeroPad(number, width) {
let string = String(number);
while (string.length < width) {
string = "0" + string;
}
return string;
}
function printFarmInventory(cows, chickens, pigs) {
console.log(`${zeroPad(cows, 3)} Cows`);
console.log(`${zeroPad(chickens, 3)} Chickens`);
console.log(`${zeroPad(pigs, 3)} Pigs`);
}
printFarmInventory(7, 16, 3);
一个名字清晰明显的函数,如zeroPad,让阅读代码的人更容易理解其作用。这样的函数在比这个特定程序更多的场景中也很有用。例如,你可以使用它来帮助打印对齐整齐的数字表。
我们的函数应该有多聪明和多功能?我们可以编写任何东西,从只能将数字填充为三个字符宽的简单函数,到一个复杂的通用数字格式化系统,能够处理分数、负数、对齐小数点、用不同字符填充等等。
一个有用的原则是,除非你绝对确定需要,否则不要添加聪明的功能。为你遇到的每一项功能编写通用的“框架”可能会让人心动,但要抵制这种冲动。你不会真正完成任何工作——你会忙于编写从未使用的代码。
函数与副作用
函数大致可以分为两类:一类是由于其副作用而被调用,另一类是由于其返回值而被调用(尽管也可能同时具有副作用和返回值)。
在农场示例中,第一个辅助函数printZeroPaddedWithLabel因其副作用而被调用:它打印一行。第二个版本zeroPad因其返回值而被调用。第二个函数在更多情况下有用并非偶然。创建值的函数比直接执行副作用的函数更容易以新的方式组合。
一个纯函数是一种特定类型的值生成函数,它不仅没有副作用,而且不依赖于其他代码的副作用——例如,它不读取可能会改变值的全局绑定。一个纯函数有一个愉快的特性,即在使用相同的参数调用时,它总是产生相同的值(而且不做其他事情)。对这样的函数的调用可以用它的返回值替代,而不改变代码的含义。当你不确定一个纯函数是否正确工作时,可以通过简单地调用它来测试,如果它在那个上下文中工作,那么在任何上下文中都将工作。非纯函数往往需要更多的支架来进行测试。
不必感到羞愧,当你编写不是纯函数的函数时。副作用往往是有用的。例如,console.log的纯版本是无法编写的,但console.log是非常有用的。在某些操作中,当我们使用副作用时,表达起来也更高效。
总结
本章教你如何编写自己的函数。当作为表达式使用时,function关键字可以创建一个函数值。当作为语句使用时,它可以用于声明一个绑定,并将函数作为其值。箭头函数是创建函数的另一种方式。
// Define f to hold a function value
const f = function(a) {
console.log(a + 2);
};
// Declare g to be a function
function g(a, b) {
return a * b * 3.5;
}
// A less verbose function value
let h = a => a % 3;
理解函数的一个关键部分是理解作用域。每个代码块都会创建一个新的作用域。在给定作用域中声明的参数和绑定是局部的,外部不可见。用var声明的绑定行为有所不同——它们会进入最近的函数作用域或全局作用域。
将程序执行的任务分离成不同的函数是有帮助的。你将不必如此频繁地重复自己,函数可以通过将代码分组为执行特定任务的片段来帮助组织程序。
练习
最小值
前一章介绍了标准函数Math.min,它返回最小的参数。我们现在可以自己编写这样的函数。定义一个名为min的函数,它接受两个参数并返回它们的最小值。
递归
我们已经看到可以使用%(余数运算符)来测试一个数字是偶数还是奇数,通过使用% 2来查看它是否可以被二整除。这里还有另一种方法来定义一个正整数是偶数还是奇数:
-
零是偶数。
-
一是奇数。 -
对于任何其他数字
N,其偶性与N - 2相同。
定义一个递归函数isEven,符合这个描述。该函数应该接受一个单一参数(一个正整数)并返回一个布尔值。
在50和75上测试一下。看看它在-1上的表现。为什么?你能想到修复这个问题的方法吗?
豆子计数
你可以通过在字符串后写[N]来获取字符串中的第N个字符或字母(例如,string[2])。得到的值将是一个只包含一个字符的字符串(例如,“b”)。第一个字符的位置是0,这导致最后一个字符位于string.length - 1的位置。换句话说,一个两个字符的字符串长度为2,其字符的位置分别为0和1。
编写一个名为countBs的函数,它以字符串作为唯一参数并返回一个数字,表示字符串中大写字母B的数量。
接下来,编写一个名为countChar的函数,行为类似于countBs,但它接受一个第二个参数,表示要计数的字符(而不仅仅是计数大写字母B)。重写countBs以利用这个新函数。
在两个场合,我被问到:“请问,巴贝奇先生,如果你输入错误的数字,机器会输出正确的答案吗?” [. . .] 我无法正确理解会引发这样问题的那种思想混乱。
—查尔斯·巴贝奇,哲学家生平的片段(1864)

第五章:数据结构:对象和数组
数字、布尔值和字符串是构建数据结构的基本元素。然而,许多类型的信息需要多个元素。对象使我们能够将值(包括其他对象)分组,以构建更复杂的结构。
到目前为止,我们构建的程序受到限制,因为它们只处理简单数据类型。在本章学习了数据结构的基础知识后,你将足够了解如何开始编写有用的程序。
本章将通过一个或多或少现实的编程示例,逐步介绍相关概念。示例代码通常会基于本书前面介绍的函数和绑定进行构建。
本书的在线编码沙箱(*eloquentjavascript.net/code)提供了一种在特定章节上下文中运行代码的方法。如果你决定在其他环境中进行示例练习,请务必首先从沙箱页面下载本章的完整代码。
变成松鼠
不时,通常在晚上8点到10点之间,雅克发现自己变成了一只小毛茸茸的啮齿动物,长着蓬松的尾巴。
一方面,雅克很高兴自己并没有经典的狼人症。变成松鼠比变成狼所造成的问题要少。他不必担心意外吃掉邻居(那样就尴尬了
但是雅克更希望完全摆脱这种状态。转变的不规则发生让他怀疑它们可能是由某种因素触发的。起初,他认为只有在靠近橡树的日子才会发生这种情况。然而,避开橡树并没有解决问题。
切换到更科学的方法,雅克开始记录他每天所做的事情以及是否发生了变化。凭借这些数据,他希望缩小触发转变的条件。
他需要的第一件事是一个数据结构来存储这些信息。
数据集
要处理一块数字数据,我们首先需要找到一种在计算机内存中表示它的方法。假设我们想表示一组数字:2、3、5、7和11。
我们可以用字符串来创造性地表示——毕竟,字符串可以有任意长度,因此我们可以将大量数据放入其中,并使用"2 3 5 7 11"作为我们的表示。但这样做很尴尬。我们必须以某种方式提取数字并将其转换回数字才能访问它们。
幸运的是,JavaScript 提供了一种特定的数据类型,用于存储值的序列。它被称为数组,并以方括号中的值列表表示,值之间用逗号分隔。
let listOfNumbers = [2, 3, 5, 7, 11];
console.log(listOfNumbers[2]);
// → 5
console.log(listOfNumbers[0]);
// → 2
console.log(listOfNumbers[2 - 1]);
// → 3
获取数组内部元素的表示法也使用方括号。紧随表达式之后的一对方括号,内部包含另一个表达式,将查找左侧表达式中与方括号中给出的索引对应的元素。
数组的第一个索引是零,而不是一,因此第一个元素可以通过listOfNumbers[0]来获取。零基计数在技术中有着悠久的传统,并在某些方面很有道理,但这需要一些适应。可以将索引视为从数组开始处跳过的项数。
属性
在之前的章节中,我们见过一些表达式,比如myString.length(获取字符串的长度)和Math.max(最大函数)。这些表达式访问某个值的属性。在第一个例子中,我们访问myString中值的长度属性。在第二个例子中,我们访问Math对象中的名为max的属性(它是一个与数学相关的常量和函数的集合)。
几乎所有 JavaScript 值都有属性。例外的是null和undefined。如果你试图访问这些非值之一的属性,你会得到一个错误。
null.length;
// → TypeError: null has no properties
在 JavaScript 中访问属性的两种主要方式是使用点和方括号。value.x和value[x]都访问value上的一个属性——但不一定是同一个属性。两者的区别在于x的解释。当使用点时,点后的单词是属性的字面名称。当使用方括号时,括号内的表达式会被评估以获取属性名称。value.x获取名为"x"的value属性,而value[x]则获取名为x的变量的值,并将其转换为字符串作为属性名称。
如果你知道你感兴趣的属性名为color,那么你可以使用value.color。如果你想提取由绑定i中的值命名的属性,你可以使用value[i]。属性名称是字符串。它们可以是任何字符串,但点表示法仅对看起来像有效绑定名称的名称有效——以字母或下划线开头,仅包含字母、数字和下划线。如果你想访问名为2或John Doe的属性,你必须使用方括号:value[2]或value["John Doe"]。
数组中的元素作为数组的属性存储,使用数字作为属性名。因为你不能用点符号与数字一起使用,并且通常想要使用一个绑定来持有索引,所以你必须使用括号表示法来访问它们。
就像字符串一样,数组也有一个长度属性,用于告诉我们数组中有多少个元素。
方法
字符串和数组值除了length属性外,还包含多个保存函数值的属性。
let doh = "Doh";
console.log(typeof doh.toUpperCase);
// → function
console.log(doh.toUpperCase());
// → DOH
每个字符串都有一个toUpperCase属性。当调用时,它将返回一个副本,其中所有字母都被转换为大写。还有一个toLowerCase,反向操作。
有趣的是,尽管对toUpperCase的调用没有传递任何参数,但这个函数以某种方式可以访问字符串"Doh",这是我们调用其属性的值。你将在第六章中了解这如何运作。
包含函数的属性通常被称为它们所属值的方法,比如“toUpperCase是字符串的方法。”
这个示例演示了两种可以用来操作数组的方法。
let sequence = [1, 2, 3];
sequence.push(4);
sequence.push(5);
console.log(sequence);
// → [1, 2, 3, 4, 5]
console.log(sequence.pop());
// → 5
console.log(sequence);
// → [1, 2, 3, 4]
push方法将值添加到数组的末尾。pop方法则相反,移除数组中的最后一个值并返回它。
这些有点傻的名称是对栈操作的传统术语。在编程中,栈是一种数据结构,允许你将值推入其中并按相反顺序弹出它们,因此最后添加的东西最先被移除。栈在编程中很常见——你可能还记得上一章中的函数调用栈,它是同一思想的一个实例。
对象
回到人狼松鼠。每日日志条目可以表示为一个数组,但条目不仅仅由数字或字符串组成——每个条目需要存储一系列活动和一个布尔值,以指示雅克是否变成了松鼠。理想情况下,我们希望将这些信息组合成一个单一的值,然后将这些组合值放入日志条目的数组中。
类型为object的值是任意属性的集合。创建对象的一种方法是将花括号作为表达式使用。
let day1 = {
squirrel: false,
events: ["work", "touched tree", "pizza", "running"]
};
console.log(day1.squirrel);
// → false
console.log(day1.wolf);
// → undefined
day1.wolf = false;
console.log(day1.wolf);
// → false
在花括号内,你写一个用逗号分隔的属性列表。每个属性都有一个名称,后面跟着一个冒号和一个值。当一个对象分多行书写时,像这个示例中那样缩进有助于可读性。名称不是有效绑定名称或有效数字的属性必须加上引号。
let descriptions = {
work: "Went to work",
"touched tree": "Touched a tree"
};
这意味着花括号在 JavaScript 中有两种含义。在语句的开始,它们开始一个语句块。在其他位置,它们描述一个对象。幸运的是,几乎没有必要在语句开头使用花括号中的对象,因此这两者之间的歧义并不是问题。唯一的例外是,当你想从简写箭头函数中返回一个对象时——你不能写n => {prop: n},因为花括号会被解释为函数体。相反,你必须在对象周围加一组括号,以明确它是一个表达式。
读取一个不存在的属性将返回值undefined。
可以使用=运算符将值分配给属性表达式。如果属性已存在,这将替换该属性的值;如果不存在,则在对象上创建一个新属性。
简要回到我们的触手绑定模型——属性绑定是类似的。它们抓取值,但其他绑定和属性可能会握住那些相同的值。你可以将对象视为具有任意数量触手的章鱼,每个触手上都有一个名字。
delete运算符就像切断章鱼的触手。它是一个一元运算符,当应用于对象属性时,会从对象中移除指定属性。这并不是常见的操作,但它是可能的。
let anObject = {left: 1, right: 2};
console.log(anObject.left);
// → 1
delete anObject.left;
console.log(anObject.left);
// → undefined
console.log("left" in anObject);
// → false
console.log("right" in anObject);
// → true
二元的in运算符,当应用于字符串和对象时,会告诉你该对象是否具有该名称的属性。将属性设置为undefined和实际删除它之间的区别在于,在第一种情况下,对象仍然拥有该属性(它只是不具有非常有趣的值),而在第二种情况下,该属性不再存在,因此返回false。
要找出一个对象有哪些属性,可以使用Object.keys函数。给这个函数一个对象,它将返回一个字符串数组——对象的属性名称。
console.log(Object.keys({x: 0, y: 0, z: 2}));
// → ["x", "y", "z"]
有一个Object.assign函数,可以将一个对象的所有属性复制到另一个对象中。
let objectA = {a: 1, b: 2};
Object.assign(objectA, {b: 3, c: 4});
console.log(objectA);
// → {a: 1, b: 3, c: 4}
数组本质上是一种专门用于存储事物序列的对象。如果你评估typeof [],它会返回"object"。你可以将数组视为长长的、扁平的章鱼,所有的触手整齐排列,标记上数字。
雅克将他保持的日记表示为对象数组。
let journal = [
{events: ["work", "touched tree", "pizza",
"running", "television"],
squirrel: false},
{events: ["work", "ice cream", "cauliflower",
"lasagna", "touched tree", "brushed teeth"],
squirrel: false},
{events: ["weekend", "cycling", "break", "peanuts",
"beer"],
squirrel: true},
/* And so on... */
];
可变性
我们很快就会进入实际编程,但首先,还有一个理论部分需要理解。
我们看到对象值是可以修改的。前几章讨论的值类型,如数字、字符串和布尔值,都是不可变的——无法更改这些类型的值。你可以将它们组合并推导出新值,但当你获得一个特定的字符串值时,该值将始终保持不变。它内部的文本无法更改。如果你有一个包含"cat"的字符串,其他代码无法更改你的字符串中的某个字符使其拼成"rat"。
对象的工作方式不同。你可以更改它们的属性,导致同一个对象值在不同时间具有不同的内容。
当我们有两个数字,120和120时,我们可以将它们视为完全相同的数字,无论它们是否引用相同的物理位。对于对象来说,拥有对同一对象的两个引用与拥有两个不同的对象(它们包含相同的属性)之间是有区别的。考虑以下代码:
let object1 = {value: 10};
let object2 = object1;
let object3 = {value: 10};
console.log(object1 == object2);
// → true
console.log(object1 == object3);
// → false
object1.value = 15;
console.log(object2.value);
// → 15
console.log(object3.value);
// → 10
object1和object2绑定指向同一个对象,这就是为什么更改object1也会更改object2的值。它们被称为具有相同的身份。绑定object3指向一个不同的对象,最初包含与object1相同的属性,但过着独立的生活。
绑定可以是可变的或常量的,但这与它们的值的行为是分开的。尽管数字值不会改变,你可以使用let绑定来跟踪一个通过改变绑定指向的值而变化的数字。同样,尽管const绑定的对象本身无法更改并将继续指向同一对象,但该对象的内容可能会发生变化。
const score = {visitors: 0, home: 0};
// This is OK
score.visitors = 1;
// This isn't allowed
score = {visitors: 1, home: 1};
当你使用 JavaScript 的==运算符比较对象时,它按身份进行比较:只有当两个对象的值完全相同时,它才会返回true。比较不同的对象将返回false,即使它们具有相同的属性。JavaScript 中没有内置的“深度”比较操作,可以按内容比较对象,但你可以自己编写这个操作(这也是本章末尾的一个练习)。
《狼人日志》
雅克启动了他的 JavaScript 解释器,并设置了他记录日记所需的环境。
let journal = [];
function addEntry(events, squirrel) {
journal.push({events, squirrel});
}
注意,添加到日记中的对象看起来有点奇怪。它不是像events: events这样声明属性,而只是给出了一个属性名称:events。这是一种简写方式,意思是相同的——如果大括号表示法中的属性名称后面没有值,则它的值来自同名的绑定。
每天晚上10点——有时是第二天早晨,从书架的顶层下来的时候——雅克会记录当天的事情。
addEntry(["work", "touched tree", "pizza", "running",
"television"], false);
addEntry(["work", "ice cream", "cauliflower", "lasagna",
"touched tree", "brushed teeth"], false);
addEntry(["weekend", "cycling", "break", "peanuts",
"beer"], true);
一旦他收集到足够的数据点,他打算利用统计学找出哪些事件可能与松鼠化相关。
相关性是统计变量之间依赖关系的度量。统计变量与编程变量并不完全相同。在统计学中,通常有一组测量值,每个变量针对每个测量值进行测量。变量之间的相关性通常以一个介于–1和1之间的值表示。零相关性意味着变量之间没有关系。相关性为1表示两者完全相关——如果你知道一个,你也知道另一个。负1也意味着变量之间完全相关,但它们是相反的——当一个为真时,另一个为假。
要计算两个布尔变量之间的相关性度量,我们可以使用phi 系数(φ)。这是一个公式,其输入是一个频率表,包含不同变量组合被观察到的次数。公式的输出是一个介于–1和1之间的数字,用于描述相关性。
我们可以将吃披萨的事件放入一个频率表中,每个数字表示这种组合在我们的测量中出现的次数。

如果我们称该表为n,我们可以使用以下公式计算φ:

(如果在这一点上你放下书本,专注于对十年级数学课的可怕回忆——等等!我并不打算用无尽的神秘符号折磨你——现在就只有这个公式。即便如此,我们所做的只是把它转化为 JavaScript。)
表示法n[01]表示测量次数,其中第一个变量(松鼠性)为假(0),第二个变量(披萨)为真(1)。在披萨表中,n[01]是9。
值n[1•]指的是第一个变量为真时所有测量的总和,在示例表中是5。同样,n[•0]指的是第二个变量为假时的测量总和。
所以对于披萨表,分割线以上的部分(被除数)是1 × 76 − 4 × 9 = 40,而以下的部分(除数)是5 × 85 × 10 × 80的平方根,或者 。结果是φ ≈ 0.069,这个值很小。吃披萨似乎对变换没有影响。
计算相关性
我们可以用一个四元素数组在 JavaScript 中表示一个二乘二表([76, 9, 4, 1])。我们也可以使用其他表示方式,例如包含两个二元素数组的数组 ([[76, 9], [4, 1]]),或者属性名为"11"和"01"的对象,但平面数组简单且访问表的表达式简洁明了。我们将数组的索引解释为两位二进制数,其中最左边(最重要)的数字指的是松鼠变量,最右边(最不重要)的数字指的是事件变量。例如,二进制数10指的是Jacques变成松鼠但事件(比如"披萨")没有发生的情况。这种情况发生了四次。由于二进制10在十进制中是2,因此我们将在数组的索引2存储这个数字。
这是一个计算此类数组中φ系数的函数:
function phi(table) {
return (table[3] * table[0] - table[2] * table[1]) /
Math.sqrt((table[2] + table[3]) *
(table[0] + table[1]) *
(table[1] + table[3]) *
(table[0] + table[2]));
}
console.log(phi([76, 9, 4, 1]));
// → 0.068599434
这是φ公式直接转化为 JavaScript 的形式。Math.sqrt是平方根函数,由标准 JavaScript 环境中的Math对象提供。我们必须从表中添加两个字段以获取n[1•]这样的字段,因为行或列的总和并没有直接存储在我们的数据结构中。
Jacques记录他的日记三个月。生成的数据集可以在本章的编码沙盒中找到 (*[eloquentjavascript.net/code#4](https://eloquentjavascript.net/code#4)*),存储在JOURNAL`绑定中,并且可以下载。
要从日志中提取特定事件的二维表格,我们必须遍历所有条目,并统计该事件与松鼠变身相关的出现次数。
function tableFor(event, journal) {
let table = [0, 0, 0, 0];
for (let i = 0; i < journal.length; i++) {
let entry = journal[i], index = 0;
if (entry.events.includes(event)) index += 1;
if (entry.squirrel) index += 2;
table[index] += 1;
}
return table;
}
console.log(tableFor("pizza", JOURNAL));
// → [76, 9, 4, 1]
数组有一个includes方法,可以检查给定值是否存在于数组中。该函数利用这个方法来判断它感兴趣的事件名称是否属于某一天的事件列表。
tableFor循环的主体通过检查每个日志条目是否包含感兴趣的特定事件,以及该事件是否发生在与松鼠事件同时,来确定每个日志条目对应表格中的哪个框。然后,循环会将1加到表格中的正确框内。
现在我们拥有计算个别相关性的工具。剩下的唯一一步是为记录的每种事件类型找到相关性,并查看是否有什么特别之处。
数组循环
在tableFor函数中,有一个这样的循环:
for (let i = 0; i < JOURNAL.length; i++) {
let entry = JOURNAL[i];
// Do something with entry
}
这种循环在经典 JavaScript 中很常见——逐个遍历数组元素是一种常见操作,为此,你需要在数组的长度上运行一个计数器,并依次挑选出每个元素。
在现代 JavaScript 中,有一种更简单的方法来编写这样的循环。
for (let entry of JOURNAL) {
console.log(`${entry.events.length} events.`);
}
当for循环在变量定义后使用of这个词时,它将遍历在of后面给出的值中的元素。这不仅适用于数组,也适用于字符串和其他一些数据结构。我们将在第六章中讨论它是如何工作的。
最终分析
我们需要为数据集中发生的每种事件类型计算相关性。为此,我们首先需要找到每种事件类型。
function journalEvents(journal) {
let events = [];
for (let entry of journal) {
for (let event of entry.events) {
if (!events.includes(event)) {
events.push(event);
}
}
}
return events;
}
console.log(journalEvents(JOURNAL));
// → ["carrot", "exercise", "weekend", "bread", ...]
通过将任何不在其中的事件名称添加到事件数组中,该函数收集每种类型的事件。
使用那个函数,我们可以看到所有的相关性。
for (let event of journalEvents(JOURNAL)) {
console.log(event + ":", phi(tableFor(event, JOURNAL)));
}
// → carrot: 0.0140970969
// → exercise: 0.0685994341
// → weekend: 0.1371988681
// → bread: -0.0757554019
// → pudding: -0.0648203724
// And so on...
大多数相关性似乎接近零。吃胡萝卜、面包或布丁显然不会引发松鼠变身。变身似乎在周末更常发生。让我们过滤结果,只显示相关性大于0.1或小于-0.1的情况。
for (let event of journalEvents(JOURNAL)) {
let correlation = phi(tableFor(event, JOURNAL));
if (correlation > 0.1 || correlation < -0.1) {
console.log(event + ":", correlation);
}
}
// → weekend: 0.1371988681
// → brushed teeth: -0.3805211953
// → candy: 0.1296407447
// → work: -0.1371988681
// → spaghetti: 0.2425356250
// → reading: 0.1106828054
// → peanuts: 0.5902679812
啊哈!有两个因素的相关性明显强于其他因素。吃花生对变成松鼠的几率有很强的正面影响,而刷牙则有显著的负面影响。
有趣。让我们试试:
for (let entry of JOURNAL) {
if (entry.events.includes("peanuts") &&
!entry.events.includes("brushed teeth")) {
entry.events.push("peanut teeth");
}
}
console.log(phi(tableFor("peanut teeth", JOURNAL)));
// → 1
这是一个强烈的结果。这种现象恰恰发生在雅克吃花生而没刷牙的时候。如果他在牙齿卫生方面不是如此邋遢,他甚至可能不会注意到自己的病症。
知道这一点后,雅克完全不再吃花生,发现他的变身停止了。
但他只花了几个月就注意到,这种完全人类的生活方式缺少了一些东西。没有他的野外冒险,雅克几乎感觉不到生命的存在。他决定宁愿成为一只全职的野生动物。在森林中建造一个漂亮的小树屋,并配备一个花生酱分配器和十年的花生酱供应后,他最后一次改变形态,过上了短暂而充满活力的松鼠生活。
深入数组学
在结束这一章之前,我想向你介绍几个与对象相关的概念。我将从一些普遍有用的数组方法开始。
我们在本章早些时候看到了push和pop,它们分别在数组的末尾添加和移除元素。对应于在数组开头添加和移除元素的方法称为unshift和shift。
let todoList = [];
function remember(task) {
todoList.push(task);
}
function getTask() {
return todoList.shift();
}
function rememberUrgently(task) {
todoList.unshift(task);
}
这个程序管理一个任务队列。你通过调用remember("groceries")将任务添加到队列的末尾,当你准备好执行某个任务时,你可以调用getTask()从队列中获取(并移除)最前面的项目。rememberUrgently函数也添加一个任务,但它将任务添加到队列的前面,而不是后面。
要搜索特定值,数组提供了indexOf方法。该方法从数组的开始到结束进行搜索,并返回请求值找到时的索引——如果未找到则返回-1。要从末尾而不是从开头进行搜索,还有一个类似的方法叫做lastIndexOf。
console.log([1, 2, 3, 2, 1].indexOf(2));
// → 1
console.log([1, 2, 3, 2, 1].lastIndexOf(2));
// → 3
indexOf和lastIndexOf都接受一个可选的第二个参数,用于指示从哪里开始搜索。
另一个基本的数组方法是slice,它接受起始和结束索引,并返回一个只包含它们之间元素的数组。起始索引是包含的,结束索引是不包含的。
console.log([0, 1, 2, 3, 4].slice(2, 4));
// → [2, 3]
console.log([0, 1, 2, 3, 4].slice(2));
// → [2, 3, 4]
当没有提供结束索引时,切片将获取起始索引之后的所有元素。你也可以省略起始索引,以复制整个数组。
concat方法可用于将数组连接在一起,以创建一个新的数组,这与+运算符对字符串的作用类似。
以下示例展示了concat和slice的实际应用。它接受一个数组和一个索引,并返回一个新的数组,该数组是原始数组的副本,给定索引处的元素被移除。
function remove(array, index) {
return array.slice(0, index)
.concat(array.slice(index + 1));
}
console.log(remove(["a", "b", "c", "d", "e"], 2));
// → ["a", "b", "d", "e"]
如果你向concat传递一个不是数组的参数,该值将被当作一个单元素数组添加到新数组中。
字符串及其属性
我们可以从字符串值中读取像length和toUpperCase这样的属性。但是如果我们尝试添加一个新属性,它不会生效。
let kim = "Kim";
kim.age = 88;
console.log(kim.age);
// → undefined
字符串、数字和布尔值的值不是对象,尽管语言不会抱怨你尝试在它们上设置新属性,但实际上并不会存储这些属性。如前所述,这些值是不可变的,无法更改。
但这些类型确实有内置属性。每个字符串值都有许多方法。一些非常有用的方法是slice和indexOf,它们与数组的同名方法类似。
console.log("coconuts".slice(4, 7));
// → nut
console.log("coconut".indexOf("u"));
// → 5
一个不同之处在于字符串的indexOf可以搜索包含多个字符的字符串,而对应的数组方法仅查找单个元素。
console.log("one two three".indexOf("ee"));
// → 11
trim方法从字符串的开始和结束移除空白字符(空格、换行符、制表符及类似字符)。
console.log(" okay \n ".trim());
// → okay
上一章中的zeroPad函数也作为一个方法存在。它被称为padStart,并接受所需的长度和填充字符作为参数。
console.log(String(6).padStart(3, "0"));
// → 006
你可以通过split在每个出现的另一个字符串上拆分一个字符串,然后用join再次连接它。
let sentence = "Secretarybirds specialize in stomping";
let words = sentence.split(" ");
console.log(words);
// → ["Secretarybirds", "specialize", "in", "stomping"]
console.log(words.join(". "));
// → Secretarybirds. specialize. in. stomping
字符串可以通过repeat方法重复,它创建一个包含多个原始字符串副本的新字符串,将它们粘合在一起。
console.log("LA".repeat(3));
// → LALALA
我们已经看到字符串类型的长度属性。访问字符串中的单个字符看起来就像访问数组元素(有一个复杂性,我们将在第五章中讨论)。
let string = "abc";
console.log(string.length);
// → 3
console.log(string[1]);
// → b
剩余参数
对于一个函数接受任意数量的参数是非常有用的。例如,Math.max计算它所给的所有参数中的最大值。要编写这样的函数,你在函数最后一个参数前加上三个点,如下所示:
function max(...numbers) {
let result = -Infinity;
for (let number of numbers) {
if (number > result) result = number;
}
return result;
}
console.log(max(4, 1, 9, -2));
// → 9
当调用这样的函数时,剩余参数被绑定到一个包含所有后续参数的数组。如果它之前有其他参数,它们的值不属于那个数组。当它像max一样是唯一的参数时,它将包含所有参数。
你可以使用类似的三个点符号来调用一个带有参数数组的函数。
let numbers = [5, 1, 7];
console.log(max(...numbers));
// → 7
这会将数组“展开”到函数调用中,将其元素作为单独的参数传递。可以将这样的数组与其他参数一起包含,例如max(9, ...numbers, 2)。
方括号数组符号同样允许三点操作符将另一个数组展开到新数组中。
let words = ["never", "fully"];
console.log(["will", ...words, "understand"]);
// → ["will", "never", "fully", "understand"]
这在花括号对象中也有效,它将另一个对象的所有属性添加进来。如果一个属性被多次添加,最后添加的值将胜出。
let coordinates = {x: 10, y: 0};
console.log({...coordinates, y: 5, z: 1});
// → {x: 10, y: 5, z: 1}
Math对象
正如我们所见,Math是一个包含数字相关实用函数的集合,例如Math.max(最大值)、Math.min(最小值)和Math.sqrt(平方根)。
Math对象被用作一个容器,以分组一堆相关的功能。只有一个Math对象,它几乎从来没有作为一个值有用。相反,它提供了一个命名空间,使所有这些函数和值不必是全局绑定。
过多的全局绑定会“污染”命名空间。被占用的名称越多,你越可能意外覆盖某些现有绑定的值。例如,在你的某个程序中,你可能会想将某个东西命名为max。由于JavaScript内置的max函数安全地嵌套在Math对象中,你不必担心覆盖它。
许多语言会阻止你,或者至少会警告你,当你定义一个已经被占用的名称的绑定时。JavaScript对使用let或const声明的绑定这样做,但——反而——对标准绑定或使用var或function声明的绑定不这样做。
回到Math对象。如果你需要进行三角函数运算,Math可以提供帮助。它包含cos(余弦)、sin(正弦)和tan(正切),以及它们的反函数acos、asin和atan。数字π(圆周率)——或者至少是适合JavaScript数字的最接近的近似值——可以通过Math.PI获取。旧有的编程传统是将常量的名称全部用大写字母书写。
function randomPointOnCircle(radius) {
let angle = Math.random() * 2 * Math.PI;
return {x: radius * Math.cos(angle),
y: radius * Math.sin(angle)};
}
console.log(randomPointOnCircle(2));
// → {x: 0.3667, y: 1.966}
如果你对正弦和余弦不熟悉,别担心。我会在第十四章中解释它们的用法。
上一个例子使用了Math.random。这是一个每次调用都会返回一个在0(包含)和1(不包含)之间的新伪随机数的函数。
console.log(Math.random());
// → 0.36993729369714856
console.log(Math.random());
// → 0.727367032552138
console.log(Math.random());
// → 0.40180766698904335
尽管计算机是确定性的机器——如果给定相同的输入,它们总是以相同的方式反应——但它们可以生成看似随机的数字。为此,计算机保持一些隐藏值,每当你请求一个新的随机数时,它会对这个隐藏值进行复杂的计算以生成新值。它存储一个新值并返回从中派生出的某个数字。这样,它就可以以看似随机的方式生成不断新的、难以预测的数字。
如果我们想要一个整数随机数而不是小数,我们可以对Math.random的结果使用Math.floor(向下舍入到最接近的整数)。
console.log(Math.floor(Math.random() * 10));
// → 2
将随机数乘以10会得到一个大于或等于0并且小于10的数字。由于Math.floor向下舍入,这个表达式将以相等的概率生成0到9之间的任何数字。
还有一些函数,如Math.ceil(“向上取整”,将数字向上舍入到最接近的整数)、Math.round(舍入到最接近的整数)和Math.abs,它返回一个数的绝对值,这意味着它将负值取反,但保持正值不变。
解构
让我们暂时回到phi函数。
function phi(table) {
return (table[3] * table[0] - table[2] * table[1]) /
Math.sqrt((table[2] + table[3]) *
(table[0] + table[1]) *
(table[1] + table[3]) *
(table[0] + table[2]));
}
这个函数难以阅读的一个原因是我们有一个指向数组的绑定,但我们更希望有指向数组元素的绑定——也就是说,let n00 = table[0]等等。幸运的是,JavaScript中有一种简洁的方法可以做到这一点。
function phi([n00, n01, n10, n11]) {
return (n11 * n00 - n10 * n01) /
Math.sqrt((n10 + n11) * (n00 + n01) *
(n01 + n11) * (n00 + n10));
}
这对于使用let、var或const创建的绑定同样有效。如果你知道你正在绑定的值是一个数组,你可以使用方括号“查看”该值的内部,绑定其内容。
对于对象,可以使用花括号而不是方括号进行类似的操作。
let {name} = {name: "Faraji", age: 23};
console.log(name);
// → Faraji
请注意,如果你尝试解构null或undefined,你会得到一个错误,就像你直接尝试访问这些值的属性时一样。
可选属性访问
当你不确定某个值是否会生成对象,但仍然希望在它确实生成对象时读取其属性时,可以使用一种点表示法的变体:object?.property。
function city(object) {
return object.address?.city;
}
console.log(city({address: {city: "Toronto"}}));
// → Toronto
console.log(city({name: "Vera"}));
// → undefined
表达式a?.b与a.b的意思相同,前提是a不为null或undefined。当它为null或undefined时,则评估为undefined。这在像示例中不确定某个属性是否存在或某个变量可能持有undefined值时是非常方便的。
类似的表示法可以与方括号访问结合使用,甚至可以通过在括号或方括号前放置?.来使用函数调用。
console.log("string".notAMethod?.());
// → undefined
console.log({}.arrayProp?.[0]);
// → undefined
JSON
由于属性抓取其值而不是包含它,因此对象和数组在计算机内存中以持有地址(内存中的位置)的比特序列存储。一个包含另一个数组的数组至少由一个内存区域用于内部数组,另一个内存区域用于外部数组,后者包含(除了其他内容)表示内部数组地址的数字。
如果你想将数据保存到文件中以备后用或通过网络发送到另一台计算机,你必须以某种方式将这些内存地址的纠结转换为可以存储或发送的描述。我想你可以连同你感兴趣的值的地址一起发送整个计算机内存,但这似乎不是最佳方法。
我们可以做的是序列化数据。这意味着它被转换为一种扁平描述。一种流行的序列化格式称为JSON(发音为“杰森”),代表JavaScript对象表示法。它在网络上广泛用作数据存储和通信格式,甚至在JavaScript以外的语言中也使用。
JSON的书写方式与JavaScript的数组和对象相似,但有一些限制。所有属性名称必须用双引号括起来,且仅允许简单的数据表达式——不允许函数调用、绑定或涉及实际计算的内容。JSON中不允许注释。
日志条目在作为JSON数据表示时可能看起来像这样:
{
"squirrel": false,
"events": ["work", "touched tree", "pizza", "running"]
}
JavaScript提供了JSON.stringify和JSON.parse函数,用于在这种格式之间转换数据。第一个函数接受一个JavaScript值并返回一个JSON编码的字符串。第二个函数接受这样的字符串并将其转换为它编码的值。
let string = JSON.stringify({squirrel: false,
events: ["weekend"]});
console.log(string);
// → {"squirrel":false,"events":["weekend"]}
console.log(JSON.parse(string).events);
// → ["weekend"]
摘要
对象和数组提供了将多个值组合为单个值的方法。这允许我们将一堆相关的事物放入一个袋子中,然后拿着袋子四处走,而不是试图单独抱住所有个别事物。
大多数JavaScript值都有属性,唯一的例外是null和undefined。属性通过value.prop或value["prop"]来访问。对象通常使用名称作为其属性,并存储一组或多组固定的属性。而数组则通常包含数量不等的概念上相同的值,并使用数字(从0开始)作为其属性的名称。
数组中确实有一些命名属性,例如length和一些方法。方法是存在于属性中的函数,并且(通常)对其属性的值进行操作。
你可以使用一种特殊的for循环遍历数组:for (let element of array)。
练习
范围的总和
本书的引言提到以下内容作为计算一系列数字总和的好方法:
console.log(sum(range(1, 10)));
编写一个范围函数,该函数接受两个参数,start和end,并返回一个包含从start到包括end的所有数字的数组。
接下来,编写一个sum函数,接受一个数字数组并返回这些数字的总和。运行示例程序,查看它是否确实返回55。
作为额外作业,修改你的范围函数,使其接受一个可选的第三个参数,指示构建数组时使用的“步长”值。如果未给出步长,元素应该以1的增量增加,对应于旧行为。函数调用range(1, 10, 2)应该返回[1, 3, 5, 7, 9]。确保这也适用于负步长值,以便range(5, 2, -1)生成[5, 4, 3, 2]。
反转数组
数组有一个反转方法,通过反转元素出现的顺序来改变数组。对于这个练习,请编写两个函数:reverseArray和reverseArrayInPlace。第一个reverseArray应该以数组作为参数,并生成一个新的数组,包含相同元素但顺序相反。第二个reverseArrayInPlace应该执行反转方法所做的事情:修改作为参数传入的数组,反转其元素。两者均不得使用标准反转方法。
回想一下前一章关于副作用和纯函数的笔记,你认为哪种变体在更多情况下会有用?哪一个运行得更快?
一个列表
作为通用的值块,对象可以用来构建各种数据结构。一种常见的数据结构是列表(不要与数组混淆)。列表是一个嵌套的对象集合,第一个对象持有对第二个对象的引用,第二个对象持有对第三个对象的引用,依此类推。
let list = {
value: 1,
rest: {
value: 2,
rest: {
value: 3,
rest: null
}
}
};
结果对象形成一条链,如下图所示:

列表的一个优点是它们可以共享部分结构。例如,如果我创建两个新值{value: 0, rest: list}和{value: -1, rest: list}(其中list指的是之前定义的绑定),它们都是独立的列表,但共享组成其最后三个元素的结构。原始列表仍然是一个有效的三元素列表。
编写一个函数arrayToList,当给定[1, 2, 3]作为参数时,构建出如所示的列表结构。还要编写一个listToArray函数,它可以从列表中生成一个数组。添加辅助函数prepend,该函数接收一个元素和一个列表,并创建一个新的列表,将元素添加到输入列表的前面;以及nth,该函数接收一个列表和一个数字,并返回列表中给定位置的元素(零表示第一个元素),如果没有这样的元素则返回undefined。
如果你还没有这样做,也要编写nth的递归版本。
深度比较
==操作符通过身份比较对象,但有时你可能更希望比较它们实际属性的值。
编写一个函数deepEqual,它接收两个值,仅当它们是相同的值或是具有相同属性的对象时返回true,其中属性的值在使用deepEqual的递归调用时比较相等。
要确定值是否应该直接比较(使用===操作符)或比较其属性,你可以使用typeof操作符。如果它对两个值都返回“object”,那么你应该进行深度比较。但你必须考虑一个愚蠢的例外:由于历史原因,typeof null也会返回“object”。
当你需要遍历对象的属性进行比较时,Object.keys函数会很有用。
构建软件设计有两种方式:一种是将其简化到明显没有缺陷,另一种是将其复杂化到没有明显缺陷。
—C.A.R. Hoare,1980 ACM 图灵奖演讲

第六章:高阶函数
一个大型程序是一个高成本的程序,这不仅仅是因为构建它所需的时间。大小几乎总是意味着复杂性,而复杂性会使程序员感到困惑。困惑的程序员又会在程序中引入错误(bug)。因此,大型程序提供了大量隐藏这些错误的空间,使它们难以被发现。
让我们简要回顾一下引言中的最后两个示例程序。第一个是自包含的,长六行。
let total = 0, count = 1;
while (count <= 10) {
total += count;
count += 1;
}
console.log(total);
第二个依赖于两个外部函数,且只有一行长。
console.log(sum(range(1, 10)));
哪一个更可能包含错误?
如果我们计算求和和范围的定义的大小,第二个程序也很大——甚至比第一个还要大。但我仍然认为,它更可能是正确的。
这是因为解决方案使用了与待解决问题相对应的词汇。对一系列数字求和并不是关于循环和计数器,而是关于范围和总和。
这个词汇的定义(函数sum和range)仍然会涉及循环、计数器和其他附带的细节。但因为它们表达的概念比整个程序更简单,所以更容易正确实现。
抽象
在编程的上下文中,这类词汇通常被称为抽象。抽象使我们能够在更高(或更抽象)的层次上讨论问题,而不被无趣的细节分散注意力。
作为类比,比较这两种豌豆汤的食谱。第一个是这样的:
每人放1杯干豌豆到容器中。加水直到豌豆完全浸没。将豌豆在水中浸泡至少12小时。将豌豆从水中取出,放入烹饪锅中。每人加4杯水。盖上锅盖,让豌豆慢炖2小时。每人取半个洋葱。用刀切成小块,加入豌豆。每人取一根芹菜。用刀切成小块,加入豌豆。每人取一根胡萝卜。切成小块。用刀!加入豌豆。再煮10分钟。
而这就是第二个食谱:
每人:1杯干豌豆、4杯水、半个切碎的洋葱、一根芹菜和一根胡萝卜。
将豌豆浸泡12小时。慢炖2小时。切碎并添加蔬菜。再煮10分钟。
第二个更简短,更容易理解。但你确实需要理解一些与烹饪相关的词汇,比如浸泡、慢炖、切碎,还有,我想,蔬菜。
在编程时,我们不能依赖于所有需要的词汇都在字典中等待我们。因此,我们可能会陷入第一个食谱的模式——逐步确定计算机必须执行的精确步骤,而忽视了它们所表达的更高层次的概念。
在编程中,注意到自己在过低的抽象层次工作是一项有用的技能。
抽象重复
到目前为止,我们看到的普通函数是构建抽象的好方法。但有时它们并不够。
程序执行某个操作若干次是很常见的。你可以为此编写一个for循环,如下所示:
for (let i = 0; i < 10; i++) {
console.log(i);
}
我们能否将“做某事N次”抽象为一个函数?实际上,编写一个调用console.log N次的函数是很简单的。
function repeatLog(n) {
for (let i = 0; i < n; i++) {
console.log(i);
}
}
但如果我们想做些其他事情,而不是记录数字呢?由于“做某事”可以表示为一个函数,而函数只是值,我们可以将我们的操作作为函数值传递。
function repeat(n, action) {
for (let i = 0; i < n; i++) {
action(i);
}
}
repeat(3, console.log);
// → 0
// → 1
// → 2
我们不必传递一个预定义的函数来重复。通常,现场创建一个函数值更为简单。
let labels = [];
repeat(5, i => {
labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]
这在结构上有点像for循环——它首先描述循环的类型,然后提供一个主体。然而,主体现在写成了一个函数值,它被包裹在调用repeat的括号中。这就是为什么它必须用结束大括号和结束括号来关闭。在这种情况下,主体是一个单独的小表达式,你也可以省略大括号,将循环写在一行上。
高阶函数
操作其他函数的函数,或者通过将它们作为参数传递,或通过返回它们,被称为高阶函数。既然我们已经看到函数是常规值,那么这样的函数存在并没有什么特别之处。这个术语源于数学,在数学中,函数与其他值之间的区别更为重要。
高阶函数使我们能够对动作进行抽象,而不仅仅是值。它们有几种形式。例如,我们可以有创建新函数的函数。
function greaterThan(n) {
return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true
我们还可以有改变其他函数的函数。
function noisy(f) {
return (...args) => {
console.log("calling with", args);
let result = f(...args);
console.log("called with", args, ", returned", result);
return result;
};
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1
我们甚至可以编写提供新类型控制流的函数。
function unless(test, then) {
if (!test) then();
}
repeat(3, n => {
unless(n % 2 == 1, () => {
console.log(n, "is even");
});
});
// → 0 is even
// → 2 is even
有一个内置的数组方法forEach,它提供类似于for/of循环的高阶函数。
["A", "B"].forEach(l => console.log(l));
// → A
// → B
脚本数据集
高阶函数在数据处理方面表现出色。要处理数据,我们需要一些实际的示例数据。本章将使用一个关于脚本的数据集——如拉丁字母、西里尔字母或阿拉伯字母等书写系统。
记得Unicode吗?它是将数字分配给书写语言中每个字符的系统,来自第一章?这些字符大多与特定的脚本相关。标准包含140种不同的脚本,其中81种仍在使用中,59种是历史脚本。
尽管我只能流利地阅读拉丁字符,但我很欣赏人们用至少80种其他书写系统书写文本的事实,其中许多我甚至无法识别。例如,这里有一段泰米尔文的手写样本:

示例数据集包含有关Unicode中定义的140种脚本的一些信息。它在本章节的编码沙箱中可用(*eloquentjavascript.net/code#5),作为SCRIPTS绑定。该绑定包含一个对象数组,每个对象描述一种脚本。
{
name: "Coptic",
ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
direction: "ltr",
year: -200,
living: false,
link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}
这样的对象告诉我们脚本的名称、分配给它的Unicode范围、书写方向、(大致的)起源时间、是否仍在使用以及更多信息的链接。书写方向可以是“ltr”表示从左到右,“rtl”表示从右到左(阿拉伯语和希伯来语文本的书写方式),或者“ttb”表示从上到下(如蒙古文书写)。
ranges属性包含一个Unicode字符范围的数组,每个范围都是一个包含下限和上限的两个元素数组。这些范围内的任何字符代码都被分配给该脚本。下限是包含的(代码994是一个科普特字符),而上限是不包含的(代码1008不是)。
过滤数组
如果我们想找到数据集中仍在使用的脚本,下面的函数可能会有所帮助。它过滤掉数组中未通过测试的元素。
function filter(array, test) {
let passed = [];
for (let element of array) {
if (test(element)) {
passed.push(element);
}
}
return passed;
}
console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", ...}, ...]
该函数使用名为test的参数,一个函数值,以填补计算中的“空缺”——决定收集哪些元素的过程。
注意filter函数如何不是从现有数组中删除元素,而是构建一个新数组,仅包含通过测试的元素。这个函数是纯粹的。它不会修改传入的数组。
像forEach一样,filter是一个标准数组方法。这个例子仅定义了该函数,以展示它在内部的工作原理。从现在开始,我们将这样使用它:
console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", ...}, ...]
使用map进行转换
假设我们有一个表示脚本的对象数组,这些对象是通过某种方式过滤SCRIPTS数组而生成的。我们希望得到一个名称的数组,这样更容易检查。
map方法通过对数组的所有元素应用函数并从返回值构建一个新数组来转换数组。新数组将具有与输入数组相同的长度,但其内容将通过函数映射到新形式。
function map(array, transform) {
let mapped = [];
for (let element of array) {
mapped.push(transform(element));
}
return mapped;
}
let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", ...]
像forEach和filter一样,map也是一个标准数组方法。
使用reduce进行汇总
对数组进行的另一个常见操作是从中计算出单个值。我们反复提到的例子,求和一组数字,就是这个操作的一个实例。另一个例子是找出字符最多的脚本。
表示此模式的高阶操作称为reduce(有时也称为fold)。它通过重复从数组中提取单个元素并将其与当前值结合来构建一个值。在求和时,你将从零开始,并对每个元素将其加到总和中。
reduce的参数除了数组外,还有一个组合函数和一个起始值。这个函数比过滤和映射稍微复杂一些,所以请仔细看看:
function reduce(array, combine, start) {
let current = start;
for (let element of array) {
current = combine(current, element);
}
return current;
}
console.log(reduce([1, 2, 3, 4], (a, b) => a + b, 0));
// → 10
标准数组方法reduce,自然对应这个函数,具有额外的便利性。如果你的数组至少包含一个元素,你可以省略起始参数。该方法会将数组的第一个元素作为起始值,并从第二个元素开始减少。
console.log([1, 2, 3, 4].reduce((a, b) => a + b));
// → 10
要使用reduce(两次)来找到字符最多的脚本,我们可以写如下内容:
function characterCount(script) {
return script.ranges.reduce((count, [from, to]) => {
return count + (to - from);
}, 0);
}
console.log(SCRIPTS.reduce((a, b) => {
return characterCount(a) < characterCount(b) ? b : a;
}));
// → {name: "Han", ...}
characterCount函数通过对分配给脚本的范围进行求和来减少其大小。注意在reducer函数的参数列表中使用了解构。第二次调用reduce则利用这个功能通过不断比较两个脚本并返回较大的一个来找到最大的脚本。
汉字在 Unicode 标准中分配了超过 89,000 个字符,使其成为数据集中最大的书写系统。汉字有时用于中文、日文和韩文。这些语言共享许多字符,尽管它们通常以不同的方式书写。美国的 Unicode 联盟决定将它们视为一个书写系统,以节省字符编码。这被称为汉字统一,并且仍然让一些人感到非常愤怒。
可组合性
想想如果没有高阶函数,我们将如何编写之前的示例(寻找最大的脚本)。代码并没有变得差很多。
let biggest = null;
for (let script of SCRIPTS) {
if (biggest == null ||
characterCount(biggest) < characterCount(script)) {
biggest = script;
}
}
console.log(biggest);
// → {name: "Han", ...}
还有几个绑定,程序多了四行,但仍然非常可读。
这些函数提供的抽象在你需要组合操作时表现得尤为出色。例如,让我们编写代码来找到数据集中存活和已亡脚本的平均来源年份。
function average(array) {
return array.reduce((a, b) => a + b) / array.length;
}
console.log(Math.round(average(
SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1165
console.log(Math.round(average(
SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 204
如你所见,Unicode中的已亡脚本平均来说比存活的脚本要古老。这并不是一个特别有意义或令人惊讶的统计数据。但我希望你会同意,用来计算它的代码并不难以阅读。你可以把它看作一个管道:我们从所有脚本开始,过滤出存活(或已亡)的脚本,从中取出年份,取平均值,然后四舍五入结果。
你当然也可以将这段计算写成一个大循环。
let total = 0, count = 0;
for (let script of SCRIPTS) {
if (script.living) {
total += script.year;
count += 1;
}
}
console.log(Math.round(total / count));
// → 1165
然而,很难看出计算的内容及其方式。而且,由于中间结果并未作为一致的值表示,要将像平均数这样的内容提取到单独的函数中将会更加复杂。
在计算机实际执行的方面,这两种方法也相当不同。第一种方法在运行过滤和映射时会建立新的数组,而第二种只计算一些数字,工作量更少。通常你可以选择可读性更高的方法,但如果你在处理巨大的数组并且多次执行,那么较少抽象的风格可能值得额外的速度。
字符串和字符编码
这个数据集的一个有趣用法是确定一段文本使用了什么脚本。让我们看看一个实现这一点的程序。
记住,每种脚本都有一个与之相关的字符代码范围数组。给定一个字符代码,我们可以使用这样的函数来查找对应的脚本(如果有的话):
function characterScript(code) {
for (let script of SCRIPTS) {
if (script.ranges.some(([from, to]) => {
return code >= from && code < to;
})) {
return script;
}
}
return null;
}
console.log(characterScript(121));
// → {name: "Latin", ...}
some方法是另一个高阶函数。它接受一个测试函数,并告诉你该函数是否对数组中的任何元素返回true。
但我们如何获取字符串中的字符代码呢?
在第一章中,我提到 JavaScript 字符串被编码为一系列 16 位数字。这些被称为代码单元。最初,Unicode 字符代码应该适合这样的单元(这给你提供了稍微超过 65,000 个字符)。当变得清楚这不够时,许多人对每个字符需要使用更多内存感到抵触。为了应对这些担忧,UTF-16这种格式(JavaScript 字符串也使用该格式)被发明。它使用单个 16 位代码单元描述大多数常见字符,但对于其他字符则使用一对这样的单元。
今天,UTF-16通常被认为是一个糟糕的主意。它似乎几乎是故意设计来引发错误的。编写假装代码单元和字符是同一事物的程序非常简单。如果你的语言不使用两个单元的字符,这看起来似乎工作得很好。但一旦有人尝试用这样一个程序处理一些不太常见的汉字,就会出现问题。幸运的是,随着表情符号的出现,大家都开始使用两个单元的字符,这样处理这些问题的负担更公平地分配了。
不幸的是,对 JavaScript 字符串的明显操作,如通过length属性获取它们的长度和使用方括号访问它们的内容,只处理代码单元。
// Two emoji characters, horse and shoe
let horseShoe = "";
console.log(horseShoe.length);
// → 4
console.log(horseShoe[0]);
// → (Invalid half-character)
console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)
console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)
JavaScript的charCodeAt方法返回一个代码单元,而不是完整的字符代码。稍后添加的codePointAt方法确实给出了完整的 Unicode 字符,因此我们可以用它从字符串中获取字符。但传递给codePointAt的参数仍然是代码单元序列中的一个索引。要遍历字符串中的所有字符,我们仍然需要处理一个字符占用一个或两个代码单元的问题。
在第四章中,我提到可以在字符串上使用for/of循环。与codePointAt类似,这种循环在人们对UTF-16存在的问题有深刻认识时被引入。当你用它遍历字符串时,它会给你真实的字符,而不是代码单元。
let roseDragon = "";
for (let char of roseDragon) {
console.log(char);
}
// →
// →
如果你有一个字符(它将是一个由一个或两个代码单元组成的字符串),你可以使用codePointAt(0)来获取它的代码。
识别文本
我们有一个characterScript函数和一种正确遍历字符的方法。下一步是计算每种脚本所包含的字符数量。以下计数抽象将在这里很有用:
function countBy(items, groupName) {
let counts = [];
for (let item of items) {
let name = groupName(item);
let known = counts.find(c => c.name == name);
if (!known) {
counts.push({name, count: 1});
} else {
known.count++;
}
}
return counts;
}
console.log(countBy([1, 2, 3, 4, 5], n => n > 2));
// → [{name: false, count: 2}, {name: true, count: 3}]
countBy函数期望一个集合(任何可以用for/of循环遍历的内容)和一个为给定元素计算组名的函数。它返回一个对象数组,每个对象命名一个组,并告诉你在该组中找到的元素数量。
它使用另一个数组方法find,该方法遍历数组中的元素并返回第一个函数返回true的元素。如果没有找到这样的元素,它将返回undefined。
使用countBy,我们可以编写一个函数来告诉我们在一段文本中使用了哪些脚本。
function textScripts(text) {
let scripts = countBy(text, char => {
let script = characterScript(char.codePointAt(0));
return script ? script.name : "none";
}).filter(({name}) => name != "none");
let total = scripts.reduce((n, {count}) => n + count, 0);
if (total == 0) return "No scripts found";
return scripts.map(({name, count}) => {
return `${Math.round(count * 100 / total)}% ${name}`;
}).join(", ");
}
console.log(textScripts('英国的狗说"woof", 俄罗斯的狗说"тяв"'));
// → 61% Han, 22% Latin, 17% Cyrillic
该函数首先通过名称计数字符,使用characterScript为它们分配名称,并在字符不属于任何脚本时回退到字符串"none"。filter调用从结果数组中删除"none"的条目,因为我们对这些字符不感兴趣。
为了能够计算百分比,我们首先需要计算属于某个脚本的字符总数,可以通过reduce来计算。如果没有找到这样的字符,函数将返回一个特定字符串。否则,它会通过map将计数条目转换为可读字符串,然后用join将它们组合起来。
总结
能够将函数值传递给其他函数是 JavaScript一个非常有用的特性。它允许我们编写带有“缺口”的计算模型函数。调用这些函数的代码可以通过提供函数值来填补这些缺口。
数组提供了许多有用的高阶方法。你可以使用forEach遍历数组中的元素。filter方法返回一个新数组,只包含通过谓词函数的元素。你可以通过使用map将每个元素放入一个函数来转换数组。你可以使用reduce将数组中的所有元素组合成一个单一值。some方法测试是否有任何元素与给定的谓词函数匹配,而find则找到第一个匹配谓词的元素。
练习
扁平化
使用reduce方法与concat方法结合,将一个数组的数组“扁平化”为一个包含所有原始数组元素的单一数组。
你自己的循环
编写一个高阶函数loop,提供类似于for循环语句的功能。它应该接受一个值、一个测试函数、一个更新函数和一个主体函数。每次迭代时,它应该首先在当前循环值上运行测试函数,如果返回false,则停止。然后调用主体函数,传递当前值,最后调用更新函数以生成新值并重新开始。
在定义函数时,你可以使用常规循环来实际执行循环。
一切
数组有一个every方法,与some方法类似。当给定的函数对数组中的每个元素返回true时,该方法返回true。从某种意义上说,some是作用于数组的||运算符的一个版本,而every则像是&&运算符。
将每个实现作为一个函数,该函数接受一个数组和一个谓词函数作为参数。写两个版本,一个使用循环,另一个使用some方法。
主导书写方向
编写一个函数,用于计算文本字符串中的主导书写方向。记住,每个脚本对象都有一个direction属性,可能是“ltr”(从左到右)、“rtl”(从右到左)或“ttb”(从上到下)。
主导方向是指大多数具有相关脚本的字符的方向。前面章节中定义的characterScript和countBy函数在这里可能会很有用。
抽象数据类型通过编写一种特殊类型的程序来实现,该程序定义了可以在其上执行的操作类型。
—芭芭拉·利斯科夫,使用抽象数据类型编程

第七章:对象的秘密生活
第四章介绍了JavaScript中的对象作为持有其他数据的容器。在编程文化中,面向对象编程是一套以对象为程序组织核心原则的技术。
尽管没有人真正同意它的确切定义,面向对象编程已经塑造了许多编程语言的设计,包括JavaScript。本章描述了这些思想如何应用于JavaScript。
抽象数据类型
面向对象编程的主要思想是将对象,或者更确切地说是对象类型,作为程序组织的单元。将程序设置为多个严格分离的对象类型提供了一种思考其结构的方式,从而实施某种纪律,防止所有内容交织在一起。
这样做的方法是将对象视为电动搅拌机或其他消费电器。设计和组装搅拌机的人需要进行专业工作,要求材料科学和电力知识。他们将所有这些都隐藏在光滑的塑料外壳中,这样只想搅拌煎饼面糊的人就不必担心这些——他们只需理解搅拌机可以操作的几个旋钮。
类似地,抽象数据类型或对象类是一个子程序,可能包含任意复杂的代码,但暴露出一组有限的方法和属性,供与之合作的人使用。这允许大型程序由多个电器类型构建,限制了这些不同部分交织的程度,要求它们仅以特定方式相互作用。
如果在某个对象类中发现问题,通常可以修复或甚至完全重写,而不会影响程序的其他部分。更好的是,可能在多个不同的程序中使用对象类,避免了从头开始重建其功能的需要。你可以将JavaScript的内置数据结构,如数组和字符串,视为这样的可重用抽象数据类型。
每个抽象数据类型都有一个接口,这是外部代码可以对其执行的操作集合。超出该接口的任何细节都是封装的,视为该类型的内部内容,对程序的其他部分无关紧要。
甚至像数字这样的基本事物也可以被视为一个抽象数据类型,其接口允许我们对其进行加法、乘法、比较等操作。实际上,在经典的面向对象编程中,将单个对象作为主要组织单元的执着是有些不幸的,因为有用的功能片段通常涉及不同对象类紧密合作的情况。
方法
在JavaScript中,方法不过是持有函数值的属性。这是一个简单的方法:
function speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", speak};
whiteRabbit.speak("Oh my fur and whiskers");
// → The white rabbit says 'Oh my fur and whiskers'
hungryRabbit.speak("Got any carrots?");
// → The hungry rabbit says 'Got any carrots?'
通常,一个方法需要对其被调用的对象执行某些操作。当一个函数被作为方法调用时——作为属性查找并立即调用,如object.method()——在其主体内被称为this的绑定自动指向其被调用的对象。
你可以把这看作是以不同于常规参数的方式传递给函数的额外参数。如果你想明确提供它,可以使用函数的call方法,该方法将this值作为第一个参数,并将后续参数视为常规参数。
speak.call(whiteRabbit, "Hurry");
// → The white rabbit says 'Hurry'
由于每个函数都有自己的this绑定,其值取决于调用的方式,因此在用function关键字定义的常规函数中,你无法引用包装作用域的this。
箭头函数是不同的——它们不会绑定自己的this,但可以看到周围作用域的this绑定。因此,你可以执行如下代码,其中在局部函数内部引用了this:
let finder = {
find(array) {
return array.some(v => v == this.value);
},
value: 5
};
console.log(finder.find([4, 5]));
// → true
在对象表达式中,像find(array)这样的属性是定义方法的一种简写方式。它创建了一个名为find的属性,并将一个函数作为其值。
如果我用function关键字为某个参数编写了代码,那么这段代码将无法工作。
原型
创建一个带有说话方法的兔子对象类型的一种方法是创建一个助手函数,该函数将兔子类型作为参数,并返回一个将其作为类型属性的对象,并在其说话属性中包含我们的说话函数。
所有兔子共享相同的方法。尤其对于具有多个方法的类型,如果能够以某种方式将类型的方法集中在一个地方,而不是逐个添加到每个对象中,那就太好了。
在JavaScript中,原型是实现这一点的方法。对象可以链接到其他对象,以神奇的方式获取其他对象所具有的所有属性。使用{}表示法创建的普通对象链接到一个名为Object.prototype的对象。
let empty = {};
console.log(empty.toString);
// → function toString(){...}
console.log(empty.toString());
// → [object Object]
看起来我们只是从一个空对象中提取了一个属性。但实际上,toString是存储在Object.prototype中的方法,意味着它在大多数对象中都是可用的。
当一个对象请求它没有的属性时,将会搜索其原型。如果原型中没有该属性,则会继续搜索原型的原型,依此类推,直到找到一个没有原型的对象(Object.prototype就是这样的对象)。
console.log(Object.getPrototypeOf({}) == Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null
正如你所猜的,Object.getPrototypeOf返回一个对象的原型。
许多对象并不直接以Object.prototype作为它们的原型,而是拥有另一个提供不同默认属性集的对象。函数源自Function.prototype,数组源自Array.prototype。
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true
这样的原型对象本身也将拥有一个原型,通常是Object.prototype,这样它仍然间接提供像toString这样的函数。
你可以使用Object.create来创建一个具有特定原型的对象。
let protoRabbit = {
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
};
let blackRabbit = Object.create(protoRabbit);
blackRabbit.type = "black";
blackRabbit.speak("I am fear and darkness");
// → The black rabbit says 'I am fear and darkness'
“proto”兔子作为所有兔子共享属性的容器。个别兔子对象,如黑兔,包含仅适用于它自己的属性——在这种情况下是它的类型——并从其原型继承共享属性。
类
JavaScript的原型系统可以被解释为一种自由形式的抽象数据类型或类。类定义了一种对象的形状——它具有哪些方法和属性。这样的对象称为该类的实例。
原型对于定义所有类实例共享相同值的属性非常有用。每个实例不同的属性,例如我们的兔子的类型属性,需要直接存储在对象本身中。
要创建给定类的实例,你必须生成一个从适当原型派生的对象,但你还需要确保该对象本身具有此类实例应该具备的属性。这就是构造函数的作用。
function makeRabbit(type) {
let rabbit = Object.create(protoRabbit);
rabbit.type = type;
return rabbit;
}
JavaScript的类语法使定义这种类型的函数以及原型对象变得更容易。
class Rabbit {
constructor(type) {
this.type = type;
}
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
}
class关键字开始一个类声明,这使我们能够一起定义构造函数和一组方法。声明的花括号内可以编写任意数量的方法。这段代码的效果是定义一个名为Rabbit的绑定,持有一个运行构造函数代码的函数,并具有一个持有speak方法的原型属性。
这个函数不能像普通函数那样调用。在JavaScript中,构造函数通过在前面加上关键字new来调用。这样做会创建一个新的实例对象,其原型是来自函数的原型属性的对象,然后运行该函数,将this绑定到新对象,最后返回该对象。
let killerRabbit = new Rabbit("killer");
实际上,类是在2015年版JavaScript中引入的。任何函数都可以用作构造函数,而在2015年之前,定义类的方法是编写一个常规函数,然后操作其原型属性。
function ArchaicRabbit(type) {
this.type = type;
}
ArchaicRabbit.prototype.speak = function(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
};
let oldSchoolRabbit = new ArchaicRabbit("old school");
因此,所有非箭头函数都以一个持有空对象的原型属性开头。
按照惯例,构造函数的名称首字母大写,以便能够与其他函数轻松区分。
理解原型与构造函数之间的关联方式(通过其原型属性)以及对象拥有原型的方式(可以通过Object.getPrototypeOf找到)之间的区别非常重要。构造函数的实际原型是Function.prototype,因为构造函数是函数。构造函数的原型属性保存通过它创建的实例所使用的原型。
console.log(Object.getPrototypeOf(Rabbit) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==
Rabbit.prototype);
// → true
构造函数通常会向此添加一些每个实例的属性。也可以在类声明中直接声明属性。与方法不同,这些属性是添加到实例对象中,而不是原型中。
class Particle {
speed = 0;
constructor(position) {
this.position = position;
}
}
类似于函数,类可以在语句和表达式中使用。当作为表达式使用时,它不会定义绑定,而只是将构造函数作为值生成。你可以在类表达式中省略类名。
let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello
私有属性
类通常会定义一些内部使用的属性和方法,这些不是它们接口的一部分。这些被称为私有属性,而与之相对的是公共属性,它们是对象外部接口的一部分。
要声明一个私有方法,在其名称前加一个#号。这样的函数只能在定义它们的类声明内部调用。
class SecretiveObject {
#getSecret() {
return "I ate all the plums";
}
interrogate() {
let shallISayIt = this.#getSecret();
return "never";
}
}
当一个类没有声明构造函数时,它将自动获得一个空的构造函数。
如果你试图从类外调用#getSecret,会出现错误。它的存在完全隐藏在类声明内部。
要使用私有实例属性,必须先声明它们。常规属性可以通过简单的赋值创建,但私有属性必须在类声明中声明,才能被使用。
该类实现了一种获取小于给定最大数的随机整数的工具。它只有一个公共属性:getNumber。
class RandomSource {
#max;
constructor(max) {
this.#max = max;
}
getNumber() {
return Math.floor(Math.random() * this.#max);
}
}
重写派生属性
当你向一个对象添加属性时,无论它是否存在于原型中,该属性都会添加到对象本身。如果原型中已经有一个同名属性,那么这个属性将不再影响对象,因为它现在被对象自己的属性隐藏了。
Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log((new Rabbit("basic")).teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small
以下图示描绘了这段代码运行后的情况。兔子和对象原型作为背景存在于killerRabbit后面,未在对象本身中找到的属性可以在这里查找。

重写存在于原型中的属性是一个有用的操作。正如兔子牙齿的例子所示,重写可以用来表达更通用对象类实例中的特殊属性,同时让非特殊对象从其原型中获取标准值。
重写还用于给标准函数和数组原型提供不同于基本对象原型的toString方法。
console.log(Array.prototype.toString ==
Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2
在数组上调用toString的结果类似于调用.join(","),它在数组中的值之间放置逗号。直接在数组上调用Object.prototype.toString会生成不同的字符串。该函数并不知道数组,因此它只是将单词object和类型名称放在方括号之间。
console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]
映射
我们在前一章中看到单词映射用于通过将函数应用于元素来转换数据结构的操作。虽然令人困惑,在编程中同样的词用于一个相关但不同的概念。
映射(名词)是一种将值(键)与其他值关联的数据结构。例如,你可能想将名字映射到年龄。这是可以使用对象来实现的。
let ages = {
Boris: 39,
Liang: 22,
Júlia: 62
};
console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true
在这里,对象的属性名称是人名,属性值是他们的年龄。但我们确实没有在映射中列出任何名为toString的人。然而,由于普通对象继承自Object.prototype,看起来这个属性是存在的。
出于这个原因,使用普通对象作为映射是危险的。有几种可能的方法可以避免这个问题。首先,你可以创建没有原型的对象。如果你将null传递给Object.create,生成的对象将不继承自Object.prototype,可以安全地用作映射。
console.log("toString" in Object.create(null));
// → false
对象属性名称必须是字符串。如果你需要一个键无法轻易转换为字符串的映射——例如对象——你就无法使用对象作为映射。
幸运的是,JavaScript 提供了一个名为Map的类,正是为这个目的而编写。它存储一个映射,并允许任何类型的键。
let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);
console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false
set、get和has方法是Map对象接口的一部分。编写一个可以快速更新和搜索大量值的数据结构并不容易,但我们不必担心这个。有人为我们做了这件事,我们可以通过这个简单的接口使用他们的工作。
如果你确实有一个普通对象,出于某种原因需要将其视为映射,知道Object.keys仅返回对象的自有键,而不返回原型中的键是很有用的。作为in运算符的替代,你可以使用Object.hasOwn函数,它会忽略对象的原型。
console.log(Object.hasOwn({x: 1}, "x"));
// → true
console.log(Object.hasOwn({x: 1}, "toString"));
// → false
多态
当你在对象上调用String函数(将值转换为字符串)时,它将调用该对象的toString方法以尝试从中创建一个有意义的字符串。我提到过,某些标准原型定义了自己的toString版本,以便创建一个包含比“[object Object]”更有用信息的字符串。你也可以自己做到这一点。
Rabbit.prototype.toString = function() {
return `a ${this.type} rabbit`;
};
console.log(String(killerRabbit));
// → a killer rabbit
这是一个强大理念的简单实例。当一段代码被编写用于处理具有特定接口的对象—在这种情况下是toString方法—任何恰好支持此接口的对象都可以插入代码中并能够与其一起工作。
这种技术被称为多态。多态代码可以与不同形状的值一起工作,只要它们支持它所期望的接口。
一个广泛使用的接口示例是数组类对象,它们具有一个包含数字的length属性和每个元素的编号属性。数组和字符串都支持这个接口,还有其他各种对象,其中一些我们将在关于浏览器的章节中看到。我们在第五章中的forEach实现可以在任何提供此接口的对象上工作。实际上,Array.prototype.forEach也是如此。
Array.prototype.forEach.call({
length: 2,
0: "A",
1: "B"
}, elt => console.log(elt));
// → A
// → B
获取器、设置器和静态方法
接口通常包含普通属性,而不仅仅是方法。例如,Map对象有一个大小属性,它告诉你存储了多少个键。
这样的对象不必直接在实例中计算和存储这样的属性。即使是直接访问的属性也可能隐藏一个方法调用。这种方法称为getter,通过在对象表达式或类声明中的方法名前加上get来定义。
let varyingSize = {
get size() {
return Math.floor(Math.random() * 100);
}
};
console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49
每当有人读取这个对象的大小属性时,相关的方法就会被调用。当属性被写入时,你可以做类似的事情,使用一个setter。
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
get fahrenheit() {
return this.celsius * 1.8 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) / 1.8;
}
static fromFahrenheit(value) {
return new Temperature((value - 32) / 1.8);
}
}
let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30
Temperature类允许你以摄氏度或华氏度读取和写入温度,但内部只存储摄氏度,并在华氏度的getter和setter中自动进行摄氏度之间的转换。
有时你希望将某些属性直接附加到构造函数上,而不是附加到原型上。这种方法将无法访问类实例,但可以例如用于提供创建实例的其他方式。
在类声明内部,方法或属性前面写有static的会存储在构造函数上。例如,Temperature类允许你使用Temperature.fromFahrenheit(100)来创建一个以华氏度表示的温度。
let boil = Temperature.fromFahrenheit(212);
console.log(boil.celsius);
// → 100
符号
我在第四章中提到过,for/of循环可以遍历几种数据结构。这是多态性的另一个例子——这样的循环期望数据结构暴露特定的接口,而数组和字符串做到了。我们也可以将这个接口添加到我们自己的对象上!但在我们做到这一点之前,我们需要简要了解一下符号类型。
多个接口可以为不同的事物使用相同的属性名称是可能的。例如,在类似数组的对象上,length指的是集合中元素的数量。但描述徒步路线的对象接口可以使用length来提供路线的米数。一个对象不可能同时符合这两个接口。
一个试图成为路由和类似数组的对象(也许是为了枚举它的途径点)有些牵强,而这种问题在实践中并不常见。不过,对于像迭代协议这样的内容,语言设计者需要一种真的不会与其他属性冲突的属性。因此,在 2015 年,符号被添加到语言中。
大多数属性,包括我们迄今为止看到的所有属性,都是用字符串命名的。但也可以使用符号作为属性名。符号是通过Symbol函数创建的值。与字符串不同,新创建的符号是唯一的——你不能创建同样的符号两次。
let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(killerRabbit[sym]);
// → 55
传递给Symbol的字符串在转换为字符串时包含在内,并且可以在例如在控制台中显示时更容易识别符号。但除此之外没有其他意义——多个符号可能具有相同的名称。
符号既唯一又可用作属性名,使其适合定义可以与其他属性和平共存的接口,无论其他属性的名称是什么。
const length = Symbol("length");
Array.prototype[length] = 0;
console.log([1, 2].length);
// → 2
console.log([1, 2][length]);
// → 0
通过在属性名称周围使用方括号,可以在对象表达式和类中包含符号属性。这会导致方括号之间的表达式被求值以生成属性名称,类似于方括号属性访问表示法。
let myTrip = {
length: 2,
0: "Lankwitz",
1: "Babelsberg",
[length]: 21500
};
console.log(myTrip[length], myTrip.length);
// → 21500 2
迭代器接口
提供给for/of循环的对象预期是可迭代的。这意味着它有一个以Symbol.iterator符号命名的方法(这是一个由语言定义的符号值,存储为Symbol函数的属性)。
当被调用时,该方法应该返回一个提供第二个接口的对象,迭代器。这实际上就是进行迭代的东西。它有一个next方法,返回下一个结果。该结果应该是一个具有value属性的对象,提供下一个值(如果有的话),以及一个done属性,当没有更多结果时为true,否则为false。
请注意,next、value和done属性名是普通字符串,而不是符号。只有Symbol.iterator可能会被添加到许多不同对象中,是一个实际的符号。
我们可以直接使用这个接口。
let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}
让我们实现一个类似于第四章练习中链表的可迭代数据结构。这次我们将把列表写成一个类。
class List {
constructor(value, rest) {
this.value = value;
this.rest = rest;
}
get length() {
return 1 + (this.rest ? this.rest.length : 0);
}
static fromArray(array) {
let result = null;
for (let i = array.length - 1; i >= 0; i--) {
result = new this(array[i], result);
}
return result;
}
}
请注意,在静态方法中,这指向的是类的构造函数,而不是实例——在调用静态方法时没有实例存在。
遍历列表应该从头到尾返回所有列表元素。我们将为迭代器编写一个单独的类。
class ListIterator {
constructor(list) {
this.list = list;
}
next() {
if (this.list == null) {
return {done: true};
}
let value = this.list.value;
this.list = this.list.rest;
return {value, done: false};
}
}
该类通过更新其列表属性来跟踪迭代列表的进度,以便在返回一个值时移动到下一个列表对象,并在该列表为空(null)时报告已完成。
让我们设置List类以便可以迭代。在本书中,我会偶尔使用事后原型操作来向类添加方法,以便各个代码片段保持小且自包含。在常规程序中,如果不需要将代码拆分成小片段,则可以直接在类中声明这些方法。
List.prototype[Symbol.iterator] = function() {
return new ListIterator(this);
};
我们现在可以使用for/of循环遍历列表。
let list = List.fromArray([1, 2, 3]);
for (let element of list) {
console.log(element);
}
// → 1
// → 2
// → 3
数组表示法和函数调用中的...语法同样适用于任何可迭代对象。例如,你可以使用[...value]来创建一个包含任意可迭代对象中元素的数组。
console.log([..."PCI"]);
// → ["P", "C", "I"]
继承
想象一下,我们需要一个与之前看到的List类非常相似的列表类型,但因为我们将一直请求它的长度,所以我们不希望每次都扫描它的其他部分。相反,我们希望在每个实例中存储长度以实现高效访问。
JavaScript的原型系统使得创建一个新类成为可能,这个新类与旧类类似,但某些属性的定义不同。新类的原型源自旧原型,但为例如长度getter添加了新的定义。
在面向对象编程术语中,这被称为继承。新类从旧类继承属性和行为。
class LengthList extends List {
#length;
constructor(value, rest) {
super(value, rest);
this.#length = super.length;
}
get length() {
return this.#length;
}
}
console.log(LengthList.fromArray([1, 2, 3]).length);
// → 3
使用extends这个词表明这个类不应该直接基于默认的Object原型,而应该基于其他类。这被称为超类。派生类是子类。
要初始化一个LengthList实例,构造函数通过super关键字调用其超类的构造函数。这是必要的,因为如果这个新对象要(大致上)像一个List行为,它需要列表所具有的实例属性。
然后构造函数将列表的长度存储在一个私有属性中。如果我们在那里写this.length,类自己的getter将被调用,但这还不能
继承使我们能够在现有数据类型的基础上构建稍微不同的数据类型,工作量相对较小。它是面向对象传统的一个基本部分,与封装和多态并列。但虽然后两者现在通常被认为是很好的想法,继承则更具争议。
封装和多态可以用来分离代码片段,减少整个程序的复杂度,而继承则根本上将类联系在一起,造成更多的纠缠。当从一个类继承时,你通常需要了解它的工作原理,比简单使用它时知道的要多。继承可以是使某些类型的程序更简洁的有用工具,但它不应该是你首先使用的工具,而且你可能不应该主动寻找构建类层次结构(类的家族树)的机会。
instanceof操作符
有时了解一个对象是否是从特定类派生出来的很有用。为此,JavaScript提供了一个名为instanceof的二元操作符。
console.log(new LengthList(1, null) instanceof LengthList);
// → true
console.log(new LengthList(2, null) instanceof List);
// → true
console.log(new List(3, null) instanceof LengthList);
// → false
console.log([1] instanceof Array);
// → true
操作符能够透视继承类型,因此LengthList是List的一个实例。该操作符也可以应用于像Array这样的标准构造函数。几乎每个对象都是Object的一个实例。
摘要
对象不仅仅是持有自己的属性。它们还有原型,原型是其他对象。只要它们的原型具有该属性,它们就会表现得像拥有那个属性一样。简单对象的原型是Object.prototype。
构造函数是名称通常以大写字母开头的函数,可以与new操作符一起使用来创建新对象。新对象的原型将是构造函数的原型属性中找到的对象。您可以通过将所有给定类型的值共享的属性放入其原型来充分利用这一点。还有一种类的表示法,提供了一种清晰的方式来定义构造函数及其原型。
您可以定义getters和setters,在每次访问对象的属性时秘密调用方法。静态方法是存储在类的构造函数中的方法,而不是其原型中的方法。
instanceof操作符可以根据一个对象和一个构造函数,告诉您该对象是否是该构造函数的实例。
处理对象时一个有用的做法是为它们指定一个接口,并告诉大家应该只通过该接口与您的对象交互。构成您对象的其余细节现在被封装,隐藏在接口之后。您可以使用私有属性将对象的一部分隐藏于外部世界。
多于一种类型可以实现相同的接口。为使用接口编写的代码自动知道如何处理任何数量的提供该接口的不同对象。这被称为多态。
在实现多个仅在某些细节上有所不同的类时,将新类作为现有类的子类编写,继承其部分行为可能会很有帮助。
练习
向量类型
编写一个类Vec,表示二维空间中的向量。它接受x和y参数(数字),并将其保存到同名属性中。
给Vec原型添加两个方法,plus和minus,接受另一个向量作为参数,并返回一个新向量,该向量的x和y值是两个向量(当前向量和参数向量)之和或之差。
向原型添加一个getter属性length,用于计算向量的长度——即点(x, y)到原点(0, 0)的距离。
组
标准JavaScript环境提供了另一种数据结构,称为Set。像Map的实例一样,Set保存一组值。与Map不同的是,它不将其他值与这些值关联——它仅跟踪哪些值是该集合的一部分。一个值在集合中只能出现一次——再次添加不会有任何效果。
编写一个名为Group的类(因为Set已经被占用了)。像Set一样,它具有add、delete和has方法。它的构造函数创建一个空的组,add方法将一个值添加到组中(但仅在该值不是成员时),delete方法从组中移除其参数(如果它是成员的话),而has方法返回一个布尔值,指示其参数是否是组的成员。
使用===运算符或其他等效的方法,例如indexOf,来判断两个值是否相同。
给这个类添加一个静态from方法,该方法以可迭代对象作为参数,并创建一个包含通过迭代该对象生成的所有值的组。
可迭代组
使前一个练习中的Group类可迭代。如果你对接口的确切形式不清楚,请参考第107页上的“迭代器接口”。
如果你使用数组来表示组的成员,不要仅仅返回通过调用数组的Symbol.iterator方法创建的迭代器。这虽然可行,但违背了本练习的目的。
如果你的迭代器在迭代过程中修改组时表现得很奇怪,也是可以的。
机器是否能够思考的问题与潜艇是否能够游泳的问题一样相关。
—埃兹杰·迪克斯特拉,计算机科学的威胁

第八章:项目:一个机器人
在“项目
本章的项目是构建一个自动机,一个在虚拟世界中执行任务的小程序。我们的自动机会是一个邮件投递机器人,负责拾取和投递包裹。
Meadowfield
Meadowfield村不大。它由11个地点和14条道路组成。可以用以下道路数组进行描述:
const roads = [
"Alice's House-Bob's House", "Alice's House-Cabin",
"Alice's House-Post Office", "Bob's House-Town Hall",
"Daria's House-Ernie's House", "Daria's House-Town Hall",
"Ernie's House-Grete's House", "Grete's House-Farm",
"Grete's House-Shop", "Marketplace-Farm",
"Marketplace-Post Office", "Marketplace-Shop",
"Marketplace-Town Hall", "Shop-Town Hall"
];

村庄中的道路网络形成一个图。图是一个点(村庄中的地点)和它们之间的线(道路)的集合。这个图将是我们的机器人移动的世界。
字符串数组的处理并不容易。我们感兴趣的是从给定地点可以到达的目的地。让我们将道路列表转换为一个数据结构,该结构为每个地点提供可以从那里到达的地点。
function buildGraph(edges) {
let graph = Object.create(null);
function addEdge(from, to) {
if (from in graph) {
graph[from].push(to);
} else {
graph[from] = [to];
}
}
for (let [from, to] of edges.map(r => r.split("-"))) {
addEdge(from, to);
addEdge(to, from);
}
return graph;
}
const roadGraph = buildGraph(roads);
给定一组边,buildGraph创建一个地图对象,为每个节点存储一个连接节点的数组。它使用split方法将道路字符串(形式为Start-End)转换为包含起始和结束的两个元素数组。
任务
我们的机器人将在村庄中移动。各处有包裹,每个包裹都寄往其他地方。当机器人遇到包裹时,它会拾取并在到达目的地时投递。
自动机必须在每个时刻决定下一步该去哪里。当所有包裹都送达后,它的任务就完成了。
为了能够模拟这个过程,我们必须定义一个能够描述它的虚拟世界。该模型告诉我们机器人在哪里,以及包裹在哪里。当机器人决定移动时,我们需要更新模型以反映新情况。
如果你在考虑面向对象编程,你的第一反应可能是为世界中的各种元素定义对象:为机器人定义一个类,为包裹定义一个类,可能还有一个为地点定义的类。这样可以持有描述其当前状态的属性,比如某个地点的包裹堆,这可以在更新世界时进行更改。
这是错误的。至少,通常是如此。某物听起来像对象并不意味着它应该在你的程序中成为对象。反射性地为应用程序中的每个概念编写类,往往会让你拥有一组相互关联的对象,每个对象都有其内部的、变化的状态。这类程序通常难以理解,因此容易出错。
相反,让我们将村庄的状态简化为定义它的最小值集合。这里包括机器人的当前位置和未投递包裹的集合,每个包裹都有当前位置和目的地地址。就这些。
在这个过程中,让我们确保在机器人移动时不改变这个状态,而是为移动后的情况计算一个新状态。
class VillageState {
constructor(place, parcels) {
this.place = place;
this.parcels = parcels;
}
move(destination) {
if (!roadGraph[this.place].includes(destination)) {
return this;
} else {
let parcels = this.parcels.map(p => {
if (p.place != this.place) return p;
return {place: destination, address: p.address};
}).filter(p => p.place != p.address);
return new VillageState(destination, parcels);
}
}
}
移动方法是动作发生的地方。它首先检查从当前位置到目的地是否有道路,如果没有,则返回旧状态,因为这不是一个有效的移动。
接下来,该方法创建一个新的状态,将目的地设为机器人的新位置。它还需要创建一组新的包裹——机器人携带的(位于机器人当前位置的)包裹需要移动到新位置。而寄往新位置的包裹需要被投递——也就是说,它们需要从未投递包裹的集合中移除。调用map处理移动,调用filter处理投递。
包裹对象在移动时不会被改变,而是被重新创建。移动方法给我们一个新的村庄状态,但完全保留了旧状态。
let first = new VillageState(
"Post Office",
[{place: "Post Office", address: "Alice's House"}]
);
let next = first.move("Alice's House");
console.log(next.place);
// → Alice's House
console.log(next.parcels);
// → []
console.log(first.place);
// → Post Office
这个移动使得包裹被投递,这在下一个状态中得以反映。但初始状态仍然描述了机器人位于邮局且包裹未投递的情况。
持久数据
不会改变的数据结构称为不可变或持久。它们的行为与字符串和数字很相似,即它们始终保持原样,而不是在不同时间包含不同的内容。
在JavaScript中,几乎所有东西都可以被改变,因此处理应保持不变的值需要一些克制。有一个名为Object.freeze的函数,可以改变一个对象,使对其属性的写入被忽略。如果你想小心的话,可以使用它来确保你的对象不会被改变。冻结确实需要计算机进行一些额外的工作,而被忽视的更新与执行错误的操作几乎同样可能会让人困惑。我通常更喜欢直接告诉人们某个对象不应该被修改,并希望他们能记住。
let object = Object.freeze({value: 5});
object.value = 10;
console.log(object.value);
// → 5
我为什么要特别避免改变对象,尽管语言显然期待我这么做?因为这有助于我理解我的程序。这再次涉及复杂性管理。当我系统中的对象是固定、稳定的事物时,我可以孤立地考虑对它们的操作——从给定的起始状态移动到爱丽丝的家总是产生相同的新状态。当对象随时间变化时,这会给这种推理增加全新的复杂性维度。
对于我们在本章构建的小型系统来说,我们可以处理这点额外的复杂性。但限制我们能构建什么样系统的最重要因素是我们能理解多少。任何使你的代码更容易理解的东西,都能让你构建出更具雄心的系统。
不幸的是,尽管理解建立在持久数据结构上的系统更容易,设计一个系统,尤其是在你的编程语言没有帮助的情况下,可能会更困难。我们将在本书中寻找使用持久数据结构的机会,但我们也会使用可变数据结构。
模拟
送货机器人观察世界并决定它想朝哪个方向移动。因此,我们可以说,机器人是一个接受VillageState对象并返回附近地点名称的函数。
因为我们希望机器人能够记住事物,以便它们可以制定和执行计划,所以我们也将它们的记忆传递给它们,并允许它们返回一个新的记忆。因此,机器人返回的东西是一个包含它想要移动的方向和一个记忆值的对象,该值将在下次调用时返回给它。
function runRobot(state, robot, memory) {
for (let turn = 0;; turn++) {
if (state.parcels.length == 0) {
console.log(`Done in ${turn} turns`);
break;
}
let action = robot(state, memory);
state = state.move(action.direction);
memory = action.memory;
console.log(`Moved to ${action.direction}`);
}
}
考虑一下机器人需要做些什么才能“解决”给定的状态。它必须通过访问每个有包裹的地点来收集所有包裹,然后通过访问每个包裹的地址来送递它们,但只能在收集完包裹后进行送递。
什么是可能有效的最笨拙策略?机器人可以在每次转弯时随机走一个方向。这意味着,它很可能最终会遇到所有的包裹,并在某个时刻到达包裹应该被送达的地方。
这可能看起来是这样的:
function randomPick(array) {
let choice = Math.floor(Math.random() * array.length);
return array[choice];
}
function randomRobot(state) {
return {direction: randomPick(roadGraph[state.place])};
}
请记住,Math.random()返回一个介于0和1之间的数字——但总是小于1。将这样的数字乘以数组的长度,然后应用Math.floor,便可以得到数组的随机索引。
由于这个机器人不需要记住任何东西,它忽略了第二个参数(记住,JavaScript函数可以在没有负面影响的情况下使用额外参数调用),并在返回的对象中省略了记忆属性。
要让这个复杂的机器人开始工作,我们首先需要一种方法来创建一个带有一些包裹的新状态。一个静态方法(在这里通过直接向构造函数添加属性来编写)是放置该功能的好地方。
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph));
let place;
do {
place = randomPick(Object.keys(roadGraph));
} while (place == address);
parcels.push({place, address});
}
return new VillageState("Post Office", parcels);
};
我们不希望包裹从它们被寄往的地方发送出去。因此,当获取到一个与地址相等的地方时,do循环会持续选择新的地点。
让我们启动一个虚拟世界。
runRobot(VillageState.random(), randomRobot);
// → Moved to Marketplace
// → Moved to Town Hall
// → ...
// → Done in 63 turns
机器人送递包裹需要经过很多次转弯,因为它的规划不够充分。我们会很快解决这个问题。
邮件卡车的路线
我们应该能够比随机机器人做得更好。一个简单的改进是借鉴现实世界邮递的工作方式。如果我们找到一条经过村庄所有地点的路线,机器人可以沿着这条路线运行两次,这样它就一定能完成任务。这是一条这样的路线(从邮局出发):
const mailRoute = [
"Alice's House", "Cabin", "Alice's House", "Bob's House",
"Town Hall", "Daria's House", "Ernie's House",
"Grete's House", "Shop", "Grete's House", "Farm",
"Marketplace", "Post Office"
];
为了实现跟随路线的机器人,我们需要利用机器人的记忆。机器人将其路线的其余部分保存在记忆中,并在每次转弯时丢弃第一个元素。
function routeRobot(state, memory) {
if (memory.length == 0) {
memory = mailRoute;
}
return {direction: memory[0], memory: memory.slice(1)};
}
这个机器人已经快得多。它最多会经过26次转弯(两次13步的路线),但通常会更少。
寻路
不过,我并不认为盲目跟随固定路线是一种智能行为。如果机器人能根据实际需要做出的工作来调整自己的行为,它的工作效率将会更高。
为此,它必须能够有意识地朝着特定的包裹或需要投递包裹的地点移动。即使目标距离超过一步,这样做也需要某种寻路功能。
通过图找到一条路线的问题是一个典型的搜索问题。我们可以判断给定的解决方案(路线)是否有效,但我们不能像计算2 + 2那样直接计算出解决方案。相反,我们必须不断创建潜在的解决方案,直到找到一个有效的。
通过图的可能路线是无限的。但在从A到B寻找路线时,我们只关注从A开始的路线。我们也不关心那些访问同一地点两次的路线——因为那些绝对不是任何地方的最高效路线。这就减少了寻路器需要考虑的路线数量。
事实上,由于我们主要关注最短路线,我们希望确保先查看短路线,再查看较长的路线。一个好的方法是“从起始点生长”路线,探索每一个尚未访问的可达地点,直到找到到达目标的路线。这样,我们只会探索那些潜在有趣的路线,而且我们知道找到的第一条路线是最短的路线(如果有多条路线,它就是其中之一)。
这里有一个实现这个功能的函数:
function findRoute(graph, from, to) {
let work = [{at: from, route: []}];
for (let i = 0; i < work.length; i++) {
let {at, route} = work[i];
for (let place of graph[at]) {
if (place == to) return route.concat(place);
if (!work.some(w => w.at == place)) {
work.push({at: place, route: route.concat(place)});
}
}
}
}
探索必须按正确的顺序进行——首先到达的地方必须首先被探索。我们不能在到达一个地方后立即探索,因为那意味着从那里到达的地方也会立即被探索,依此类推,尽管可能还有其他尚未探索的更短路径。
因此,该函数保持一个工作列表。这是一个包含接下来应探索地点的数组,以及到达那里的路线。它从起始位置和一个空路线开始。
搜索过程通过取出列表中的下一个项目并进行探索来进行,这意味着它查看从该地点出发的所有道路。如果其中一条是目标,则可以返回一条完成的路线。否则,如果我们之前没有查看过这个地点,就会将一个新项目添加到列表中。如果我们之前查看过,由于我们优先查看短路线,我们要么找到了一条更长的路线,要么找到了一条正好和现有路线一样长的路线,因此我们不需要再进行探索。
你可以将其可视化为从起始位置爬出的已知路线网络,均匀向各侧扩展(但绝不会回头缠绕在一起)。一旦第一条线程到达目标位置,该线程就会被追踪回起点,从而给我们提供路线。
我们的代码没有处理工作列表上没有更多工作项的情况,因为我们知道我们的图是连通的,这意味着每个位置都可以从所有其他位置到达。我们总是能够在两点之间找到一条路线,搜索不会失败。
function goalOrientedRobot({place, parcels}, route) {
if (route.length == 0) {
let parcel = parcels[0];
if (parcel.place != place) {
route = findRoute(roadGraph, place, parcel.place);
} else {
route = findRoute(roadGraph, place, parcel.address);
}
}
return {direction: route[0], memory: route.slice(1)};
}
这个机器人使用它的内存值作为移动方向的列表,就像跟踪路线的机器人一样。每当这个列表为空时,它必须找出接下来该做什么。它取出未交付包裹中的第一个,如果那个包裹还没有被取走,就为其规划一条路线。如果包裹已经被取走,它仍然需要被交付,因此机器人会创建一条前往交付地址的路线。
这个机器人通常在大约16次回合内完成交付5个包裹的任务。这比routeRobot略好,但仍然显然不是最优的。我们将在接下来的练习中继续改进它。
练习
测量机器人
仅仅让机器人解决几个场景很难客观比较它们。也许某个机器人碰巧得到了一些较简单的任务或它擅长的任务,而另一个则没有。
编写一个compareRobots函数,接受两个机器人(及其初始内存)。它应该生成100个任务,并让这两个机器人解决每一个任务。完成后,它应输出每个机器人每个任务所用的平均步骤数。
为了公平起见,请确保将每个任务都交给两个机器人,而不是为每个机器人生成不同的任务。
机器人效率
你能写一个比目标导向机器人更快完成交付任务的机器人吗?如果你观察那个机器人的行为,它做了哪些显然愚蠢的事情?这些可以如何改进?
如果你解决了之前的练习,你可能想用你的compareRobots函数来验证你是否改善了机器人。
持久性组
在标准的JavaScript环境中,大多数数据结构并不太适合持久性使用。数组有slice和concat方法,这使我们能够轻松创建新的数组而不损坏旧的数组。但例如,Set没有用于添加或移除项以创建新集合的方法。
编写一个新的类PGroup,类似于第六章中的Group类,存储一组值。像Group一样,它具有add、delete和has方法。然而,它的add方法应该返回一个新的 PGroup实例,添加给定成员,同时保留旧实例不变。同样,delete应该创建一个没有给定成员的新实例。
这个类应该适用于任何类型的值,而不仅仅是字符串。当处理大量值时,它不必高效。
构造函数不应该是类接口的一部分(尽管你肯定希望在内部使用它)。相反,有一个空实例PGroup.empty,可以用作起始值。
你为什么只需要一个PGroup.empty值,而不是每次都创建一个新的空映射的函数?
调试的难度是编写代码难度的两倍。因此,如果你尽可能聪明地编写代码,那么,从定义上讲,你就不够聪明去调试它。
—布赖恩·肯尼汉和P.J.普劳杰,程序设计风格元素

第九章:漏洞和错误
计算机程序中的缺陷通常被称为漏洞。程序员常常觉得将它们想象成一些偶然爬入我们工作的“小东西”会让人感觉良好。但实际上,当然是我们自己把它们放进去的。
如果程序是凝结的思维,我们可以大致将漏洞分为由于思维混乱引起的漏洞和在将思维转换为代码时引入的错误。前者通常比后者更难以诊断和修复。
语言
如果计算机对我们正在尝试做的事情了解得足够多,许多错误可以被自动指出。但在这里,JavaScript的宽松性反而成了障碍。它对绑定和属性的概念模糊到几乎无法在程序运行之前捕捉到拼写错误。即便如此,它仍然允许你在没有抱怨的情况下做一些显然无意义的事情,比如计算true * "monkey"。
JavaScript会对一些事情进行抱怨。编写一个不符合语言语法的程序会立即导致计算机发出警告。其他事情,例如调用非函数的东西或在未定义值上查找属性,都会在程序尝试执行该操作时引发错误。
然而,通常情况下,你的无意义计算会仅仅产生NaN(不是一个数字)或未定义值,而程序则会愉快地继续运行,确信自己在做一些有意义的事情。这个错误只有在虚假值经过多个函数后才会显现出来。它可能根本不会触发错误,但会默默导致程序的输出错误。找到此类问题的源头可能会很困难。
查找程序中错误(漏洞)的过程称为调试。
严格模式
JavaScript可以通过启用严格模式变得稍微严格。这可以通过在文件或函数体的顶部放置字符串"use strict"来实现。以下是一个示例:
function canYouSpotTheProblem() {
"use strict";
for (counter = 0; counter < 10; counter++) {
console.log("Happy happy");
}
}
canYouSpotTheProblem();
// → ReferenceError: counter is not defined
类和模块中的代码(我们将在第十章中讨论)是自动严格的。旧的非严格行为仍然存在,只是因为某些旧代码可能依赖于此,语言设计者努力避免破坏任何现有程序。
通常情况下,当你忘记在绑定前加let时,比如示例中的counter,JavaScript会静默地创建一个全局绑定并使用它。在严格模式下,则会报告错误。这是非常有帮助的。但需要注意的是,当相关的绑定已经在作用域中存在时,这种方法将不起作用。在这种情况下,循环仍然会静默地覆盖绑定的值。
严格模式的另一个变化是,this绑定在未作为方法调用的函数中保持未定义值。当在非严格模式下进行这样的调用时,this引用全局作用域对象,该对象的属性是全局绑定。因此,如果你在严格模式下错误地调用了一个方法或构造函数,JavaScript会在尝试从this读取内容时产生错误,而不是高兴地写入全局作用域。
例如,考虑以下代码,它在没有new关键字的情况下调用构造函数,因此它的this将不引用新构造的对象:
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // Oops
console.log(name);
// → Ferdinand
对Person的虚假调用成功了,但返回了未定义的值,并创建了全局绑定名称。在严格模式下,结果是不同的。
"use strict";
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // Forgot new
// → TypeError: Cannot set property 'name' of undefined
我们会立即被告知某些地方出了问题。这很有帮助。幸运的是,使用class语法创建的构造函数如果没有new被调用时总会抱怨,即使在非严格模式下,这也减少了问题的发生。
严格模式还做了一些其他事情。它不允许给函数多个相同名称的参数,并完全移除某些有问题的语言特性(例如with语句,由于错误严重而未在本书中进一步讨论)。
简而言之,在程序顶部放置"use strict"通常不会造成伤害,反而可能帮助你发现问题。
类型
一些语言希望在运行程序之前知道所有绑定和表达式的类型。当类型以不一致的方式使用时,它们会立即告诉你。JavaScript只在实际运行程序时考虑类型,即使在那时也常常尝试隐式地将值转换为它所期望的类型,因此并没有太大帮助。
尽管如此,类型提供了一个有用的框架来讨论程序。许多错误源于对输入或输出的值类型的混淆。如果你把这些信息写下来,你就不容易混淆。
你可以在上一章的findRoute函数之前添加如下注释,以描述它的类型:
// (graph: Object, from: string, to: string) => string[]
function findRoute(graph, from, to) {
// ...
}
有多种不同的约定用于用类型注解JavaScript程序。
关于类型的一点是,它们需要引入自己的复杂性,以能够描述足够的代码以便有用。你认为返回数组中随机元素的randomPick函数的类型会是什么?你需要引入一个类型变量,T,它可以代表任何类型,以便你可以为randomPick赋予类似(T[]) → T的类型(从一个T数组到一个T的函数)。
当程序的类型已知时,计算机可以为你检查这些类型,指出在程序运行前的错误。有几种JavaScript方言为语言添加了类型并进行检查。其中最流行的是TypeScript。如果你有兴趣为你的程序增加更多严谨性,我建议你试一试。
在本书中,我们将继续使用原始的、危险的、无类型的JavaScript代码。
测试
如果语言不会在很大程度上帮助我们找到错误,我们就必须通过运行程序
手动一次又一次地这样做是个很糟糕的主意。这不仅令人烦恼,而且往往效率低下,因为每次修改时要全面测试所有内容需要花费太多时间。
计算机擅长重复性任务,而测试就是理想的重复性任务。自动化测试是编写一个测试另一个程序的程序的过程。编写测试比手动测试需要多一点工作,但一旦你完成它,你就获得了一种超能力:你只需几秒钟就能验证你的程序在你编写测试的所有情况下仍然表现正常。当你破坏了某些东西时,你会立即注意到,而不是在之后的某个时刻偶然发现。
测试通常以小型标记程序的形式出现,用于验证代码的某些方面。例如,针对(标准的,可能已经被其他人测试过的)toUpperCase方法的一组测试可能如下所示:
function test(label, body) {
if (!body()) console.log(`Failed: ${label}`);
}
test("convert Latin text to uppercase", () => {
return "hello".toUpperCase() == "HELLO";
});
test("convert Greek text to uppercase", () => {
return "Χαίρετε".toUpperCase() == "ΧΑΙΡΕΤΕ";
});
test("don't convert case-less characters", () => {
return "مرحبا".toUpperCase() == "مرحبا";
});
像这样编写测试往往会产生相当重复和笨拙的代码。幸运的是,有一些软件可以帮助你构建和运行测试集合(测试套件),通过提供一种适合表达测试的语言(以函数和方法的形式)以及在测试失败时输出有用信息。这些通常被称为测试运行器。
一些代码比其他代码更容易测试。一般来说,代码与外部对象的交互越多,设置测试上下文就越困难。前一章中展示的编程风格,使用自包含的持久值而不是可变对象,往往更容易测试。
调试
一旦你注意到程序出现了问题,因为它表现不当或产生错误,下一步就是找出是什么问题。
有时这很明显。错误信息会指向程序的特定行,如果你查看错误描述和那行代码,通常能看到问题所在。
但并不总是如此。有时触发问题的行仅仅是一个地方,在那里一个不稳定的值以无效的方式被使用。如果你在前面的章节中解决过练习,你可能已经经历过这样的情况。
以下示例程序试图将一个整数转换为给定基数(十进制、二进制等)的字符串,通过反复提取最后一位数字,然后除以该数字以去掉这位数字。但它目前产生的奇怪输出表明它存在缺陷。
function numberToString(n, base = 10) {
let result = "", sign = "";
if (n < 0) {
sign = "-";
n = -n;
}
do {
result = String(n % base) + result;
n /= base;
} while (n > 0);
return sign + result;
}
console.log(numberToString(13, 10));
// → 1.5e-3231.3e-3221.3e-3211.3e-3201.3e-3191.3e-3181.3...
即使你已经看到问题,暂时假装你没有。我们知道我们的程序出现了故障,我们想找出原因。
在这里,你必须抵制随机更改代码的冲动,以看看这样是否会改善程序。相反,思考。分析发生了什么,并提出一个可能的理论来解释它。然后进行额外的观察以测试这个理论——或者,如果你还没有理论,进行额外的观察来帮助你形成一个。
在程序中放置一些战略性的console.log调用是获取程序正在做的事情的额外信息的好方法。在这种情况下,我们希望n依次取值13、1,然后0。让我们在循环开始时写出它的值。
13
1.3
0.13
0.013
...
1.5e-323
对的。将13除以10不会产生一个整数。我们实际上想要的是n = Math.floor(n / base),而不是n /= base,这样数字才能正确“向右移动”。
使用console.log来窥探程序行为的替代方法是使用浏览器的调试器功能。浏览器具有在代码的特定行上设置断点的能力。当程序执行到包含断点的行时,它会暂停,你可以检查此时绑定的值。我不会详细说明,因为不同浏览器的调试器各不相同,但可以查看浏览器的开发者工具或在网上搜索说明。
另一种设置断点的方法是在你的程序中包含一个调试器语句(仅由该关键字组成)。如果浏览器的开发者工具处于活动状态,程序将在遇到此类语句时暂停。
错误传播
不幸的是,并不是所有问题都能由程序员预防。如果你的程序以任何方式与外部世界通信,就有可能收到格式不正确的输入、过载工作量,或者网络失败。
如果你只是为自己编程,你可以选择忽略这些问题直到它们发生。但如果你构建的东西将被其他人使用,通常希望程序比单纯崩溃做得更好。有时正确的做法是坦然接受错误输入并继续运行。在其他情况下,最好是向用户报告发生了什么错误,然后放弃。在这两种情况下,程序必须积极响应问题。
假设你有一个函数promptNumber,它询问用户一个数字并返回它。如果用户输入“橙子”,它应该返回什么?
一种选择是让它返回一个特殊值。常见的选择包括null、undefined或-1。
function promptNumber(question) {
let result = Number(prompt(question));
if (Number.isNaN(result)) return null;
else return result;
}
console.log(promptNumber("How many trees do you see?"));
现在,任何调用promptNumber的代码都必须检查是否读取到了实际的数字,如果没有,则必须以某种方式进行恢复——也许是再次询问,或者填入一个默认值。或者它可以再次返回一个特殊值给它的调用者,以指示它未能完成请求的操作。
在许多情况下,尤其是在错误常见且调用者应明确考虑这些错误时,返回一个特殊值是指示错误的好方法。然而,这也有其缺点。首先,如果函数已经可以返回每种可能的值怎么办?在这样的函数中,你需要做一些像将结果包装在对象中,以便能够区分成功和失败的事情,正如迭代器接口的下一个方法所做的那样。
function lastElement(array) {
if (array.length == 0) {
return {failed: true};
} else {
return {value: array[array.length - 1]};
}
}
返回特殊值的第二个问题是,它可能导致尴尬的代码。如果一段代码调用promptNumber 10次,它必须检查10次是否返回了null。如果它发现null的反应只是简单地返回null本身,那么调用这个函数的代码也将必须进行检查,依此类推。
异常
当一个函数无法正常进行时,我们通常希望做的就是停止我们正在做的事情,并立即跳转到一个知道如何处理该问题的地方。这就是异常处理所做的。
异常是一种机制,使得在代码遇到问题时可以引发(或抛出)异常。异常可以是任何值。引发异常有点类似于函数的超级返回:它不仅跳出当前函数,还跳出所有调用它的函数,一直返回到开始当前执行的第一个调用。这被称为展开栈。你可能还记得在第三章提到的函数调用栈。异常在这个栈中快速向下移动,抛弃它遇到的所有调用上下文。
如果异常总是直接向下快速传递到栈底,它们就没有太大用处。它们只会提供一种新颖的方式来使你的程序崩溃。它们的强大在于你可以在栈上设置“障碍”,以捕获在下落过程中发生的异常。一旦你捕获了异常,就可以处理它以解决问题,然后继续运行程序。
这里有一个例子:
function promptDirection(question) {
let result = prompt(question);
if (result.toLowerCase() == "left") return "L";
if (result.toLowerCase() == "right") return "R";
throw new Error("Invalid direction: " + result);
}
function look() {
if (promptDirection("Which way?") == "L") {
return "a house";
} else {
return "two angry bears";
}
}
try {
console.log("You see", look());
} catch (error) {
console.log("Something went wrong: " + error);
}
throw关键字用于引发异常。捕获异常是通过将一段代码包装在try块中,然后跟上关键字catch来实现的。当try块中的代码引发异常时,catch块将被评估,括号中的名称绑定到异常值上。在catch块完成后——或者如果try块没有问题地完成——程序将在整个try/catch语句下继续执行。
在这种情况下,我们使用Error构造函数创建我们的异常值。这是一个标准的 JavaScript 构造函数,用于创建一个具有消息属性的对象。Error的实例还收集了在创建异常时存在的调用栈的信息,所谓的栈追踪。这些信息存储在stack属性中,在尝试调试问题时非常有用:它告诉我们问题发生的函数以及哪些函数进行了失败的调用。
请注意,look函数完全忽略了prompt Direction可能出现错误的情况。这就是异常的重大优势:错误处理代码仅在错误发生的点和处理的点才是必要的。而中间的函数可以完全不再考虑这个问题。
好吧,几乎是这样……
异常后的清理
异常的效果是一种控制流。每个可能引发异常的动作,几乎每个函数调用和属性访问,都可能导致控制突然离开你的代码。
这意味着当代码有多个副作用时,即使其“常规”控制流看起来总会发生这些副作用,异常可能会阻止其中某些副作用的发生。
这里有一些非常糟糕的银行代码:
const accounts = {
a: 100,
b: 0,
c: 20
};
function getAccount() {
let accountName = prompt("Enter an account name");
if (!Object.hasOwn(accounts, accountName)) {
throw new Error(`No such account: ${accountName}`);
}
return accountName;
}
function transfer(from, amount) {
if (accounts[from] < amount) return;
accounts[from] -= amount;
accounts[getAccount()] += amount;
}
转账函数将一笔资金从一个指定账户转移到另一个账户,并在此过程中询问另一个账户的名称。如果给出无效的账户名称,getAccount将抛出异常。
但是转账首先从账户中移走资金,然后再调用getAccount,才将其添加到另一个账户。如果在这一点上被异常中断,资金就会消失。
这段代码本可以写得更聪明一些,例如在开始转移资金之前先调用getAccount。但这样的错误往往以更微妙的方式出现。即使是看似不会抛出异常的函数,在特殊情况下或因程序员的失误也可能会抛出异常。
解决这一问题的一种方法是减少副作用。同样,计算新值而不是更改现有数据的编程风格有助于减少问题。如果一段代码在创建新值的过程中中途停止运行,就不会破坏任何现有的数据结构,从而使恢复变得更容易。
由于这并不总是实际可行,try语句还有另一个特性:它们可以被finally块跟随,作为catch块的替代或补充。finally块表示“无论发生什么,在尝试运行try块中的代码后运行这段代码。”
function transfer(from, amount) {
if (accounts[from] < amount) return;
let progress = 0;
try {
accounts[from] -= amount;
progress = 1;
accounts[getAccount()] += amount;
progress = 2;
} finally {
if (progress == 1) {
accounts[from] += amount;
}
}
}
这个版本的函数跟踪其进度,如果在离开时发现它在创建不一致的程序状态时被中止,它会修复所造成的损害。
请注意,即使在try块中抛出异常时,finally代码仍会运行,但这并不会干扰异常。在finally块运行后,堆栈继续展开。
编写即使在意外情况下也能可靠运行的程序是很困难的。许多人根本不在意,因为异常通常是为特殊情况保留的,所以问题可能发生得非常少,以至于根本不会被注意到。这是好事还是坏事,取决于软件失败时造成的损害程度。
选择性捕获
当异常一路传递到底部而未被捕获时,它会被环境处理。这在不同环境中意味着不同的事情。在浏览器中,错误描述通常会写入 JavaScript 控制台(可以通过浏览器的工具或开发者菜单访问)。Node.js(我们将在第二十章中讨论的无浏览器 JavaScript 环境)对数据损坏更加谨慎。当发生未处理异常时,它会中止整个进程。
对于程序员的错误,通常允许错误通过是你能做的最好的选择。未处理异常是指示程序出现故障的一种合理方式,现代浏览器的 JavaScript 控制台会为你提供一些关于问题发生时调用栈上哪些函数的信息。
对于在日常使用中预期会发生的问题,崩溃并伴随未处理异常是一种糟糕的策略。
对语言的无效使用,例如引用一个不存在的绑定、在null上查找属性或调用非函数的东西,也会导致异常被抛出。这些异常也可以被捕获。
当进入catch体时,我们所知道的只是我们的try体中的某些东西导致了异常。但我们并不知道是什么造成了异常,或者是哪一个异常。
JavaScript(在一个相当明显的遗漏中)并未提供选择性捕获异常的直接支持:要么捕获所有异常,要么一个都不捕获。这使得假设你获得的异常正是你在编写catch块时所考虑的异常变得很诱人。
但这可能并非如此。某些其他假设可能被违反,或者你可能引入了导致异常的错误。以下是一个尝试持续调用promptDirection直到得到有效答案的示例:
for (;;) {
try {
let dir = promtDirection("Where?"); // ← Typo!
console.log("You chose ", dir);
break;
} catch (e) {
console.log("Not a valid direction. Try again.");
}
}
for (;;)结构是一种故意创建不会自行终止的循环的方法。我们仅在给出有效方向时才会跳出循环。不幸的是,我们拼写错误了promptDirection,这将导致“未定义变量”错误。由于catch块完全忽略了其异常值(e),假设它知道问题出在哪里,因此错误地将绑定错误视为输入不正确。这不仅导致了无限循环,还“埋没”了关于拼写错误绑定的有用错误信息。
一般来说,除非是为了“路由”异常到某处(例如,通过网络告知另一个系统我们的程序崩溃了),否则不要随意捕获异常。即便如此,也要仔细考虑你可能隐藏的信息。
我们希望捕获特定类型的异常。我们可以通过在catch块中检查捕获的异常是否是我们感兴趣的类型,如果不是,就重新抛出它。但我们如何识别异常呢?
我们可以将其消息属性与我们预期的错误消息进行比较。但这是一种不可靠的写代码方式——我们将使用旨在供人类理解的信息(消息)来做出程序决策。一旦有人更改(或翻译)消息,代码将停止工作。
相反,让我们定义一个新的错误类型,并使用instanceof来识别它。
class InputError extends Error {}
function promptDirection(question) {
let result = prompt(question);
if (result.toLowerCase() == "left") return "L";
if (result.toLowerCase() == "right") return "R";
throw new InputError("Invalid direction: " + result);
}
新的错误类扩展了Error。它没有定义自己的构造函数,这意味着它继承了Error的构造函数,该构造函数期望一个字符串消息作为参数。实际上,它什么都没有定义——这个类是空的。InputError对象的行为类似于Error对象,除了它们有一个不同的类,我们可以通过这个类来识别它们。
现在循环可以更仔细地捕捉这些错误。
for (;;) {
try {
let dir = promptDirection("Where?");
console.log("You chose ", dir);
break;
} catch (e) {
if (e instanceof InputError) {
console.log("Not a valid direction. Try again.");
} else {
throw e;
}
}
}
这将仅捕获InputError的实例,并让不相关的异常通过。如果你重新引入拼写错误,将正确报告未定义绑定错误。
断言
断言是在程序内部进行的检查,用于验证某件事情是否如预期那样。它们的使用不是为了处理在正常操作中可能出现的情况,而是为了发现程序员的错误。
例如,如果firstElement被描述为一个不应在空数组上调用的函数,我们可能会这样写:
function firstElement(array) {
if (array.length == 0) {
throw new Error("firstElement called with []");
}
return array[0];
}
现在,当你错误使用它时,这将使你的程序立刻崩溃,而不是静默地返回undefined(当读取一个不存在的数组属性时得到的结果)。这降低了此类错误被忽视的可能性,并使得发生时更容易找到它们的原因。
我不建议尝试为每种可能的错误输入编写断言。那会是一项巨大的工作,并且会导致代码非常嘈杂。你应该将断言保留给那些容易犯的错误(或者是你发现自己经常犯的错误)。
概述
编程的重要部分是发现、诊断和修复错误。如果你有一个自动化测试套件或在程序中添加断言,问题可能会变得更容易被注意到。
由程序控制之外的因素引起的问题通常应该积极规划。有时,当问题可以在本地处理时,特殊返回值是跟踪它们的好方法。否则,异常可能更为合适。
抛出异常会导致调用栈被展开,直到下一个封闭的try/catch块或者栈底。异常值将被传递给捕获它的catch块,该块应该验证它实际上是预期的异常类型,然后对其进行处理。为了帮助解决异常引起的不可预测的控制流,可以使用finally块以确保在块结束时某段代码总是执行。
练习
重试
假设你有一个函数primitiveMultiply,在20%的情况下会乘以两个数字,而在其他80%的情况下会抛出类型为MultiplicatorUnitFailure的异常。编写一个函数来封装这个笨拙的函数,持续尝试直到调用成功,之后返回结果。
确保你只处理你试图处理的异常。
锁定的盒子
考虑以下(相当人为的)对象:
const box = new class {
locked = true;
#content = [];
unlock() { this.locked = false; }
lock() { this.locked = true; }
get content() {
if (this.locked) throw new Error("Locked!");
return this.#content;
}
};
这是一个带锁的盒子。盒子里有一个数组,但只有在盒子解锁时才能访问。
编写一个名为withBoxUnlocked的函数,接受一个函数值作为参数,解锁盒子,运行该函数,然后确保在返回之前盒子再次上锁,无论参数函数是正常返回还是抛出异常。
为了额外加分,确保当调用withBoxUnlocked时,如果盒子已经解锁,盒子保持解锁状态。
一些人在面对问题时,会想“我知道,我会使用正则表达式。”现在他们有两个问题。
—杰米·扎温斯基

第十章:正则表达式
编程工具和技术以一种混乱的进化方式生存和传播。并不是最佳或最聪明的工具获胜,而是那些在正确的细分市场中足够有效或恰好与其他成功技术整合的工具。
在本章中,我将讨论这样一个工具,正则表达式。正则表达式是一种描述字符串数据模式的方法。它们形成了一种小而独立的语言,属于 JavaScript 以及许多其他语言和系统。
正则表达式既十分笨拙又极其有用。它们的语法晦涩,而 JavaScript 为它们提供的编程接口又笨重。但它们是检查和处理字符串的强大工具。正确理解正则表达式将使你成为更有效的程序员。
创建正则表达式
正则表达式是一种对象。它可以通过RegExp构造函数构造,或通过用正斜杠(/)字符括起模式来作为文字值书写。
let re1 = new RegExp("abc");
let re2 = /abc/;
这两个正则表达式对象表示相同的模式:一个a字符后跟一个b,再后跟一个c。
使用RegExp构造函数时,模式被写成普通字符串,因此反斜杠的通常规则适用。
第二种记法中,模式出现在斜杠字符之间,对反斜杠的处理
let aPlus = /A\+/;
匹配测试
正则表达式对象有许多方法。最简单的方法是test。如果你传递给它一个字符串,它将返回一个布尔值,告诉你该字符串是否包含模式的匹配。
console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false
仅由非特殊字符组成的正则表达式简单地表示该字符序列。如果abc出现在我们测试的字符串中的任何位置(不仅仅是在开始处),测试将返回true。
字符集
检查一个字符串是否包含abc也可以通过调用indexOf来完成。正则表达式之所以有用,是因为它们允许我们描述更复杂的模式。
假设我们想匹配任何数字。在正则表达式中,将一组字符放在方括号之间,使得该部分表达式匹配方括号中的任何字符。
以下两个表达式匹配所有包含数字的字符串:
console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true
在方括号内,两个字符之间的连字符(-)可以用来表示字符范围,其顺序由字符的 Unicode 编号决定。字符 0 到 9 在这个排序中彼此相邻(编码 48 到 57),因此[0-9]包含了所有数字,并且匹配任何数字。
一些常见的字符组有其自己的内置快捷方式。数字就是其中之一:\d的含义与[0-9]相同。
\d |
任何数字字符 |
|---|---|
\w |
一个字母数字字符(“单词字符”) |
\s |
任何空白字符(空格、制表符、换行符等) |
\D |
一个不是数字的字符 |
\W |
一个非字母数字字符 |
\S |
一个非空白字符 |
. |
除换行符外的任何字符 |
你可以用以下表达式匹配日期和时间格式,如01-30-2003 15:20:
let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false
那个正则表达式看起来完全糟糕,不是吗?其中一半是反斜杠,产生的背景噪声使得实际表达的模式很难被识别。稍后我们会看到这个表达式的稍微改进版本。
这些反斜杠代码也可以在方括号内使用。例如,[\d.]表示任何数字或句点字符。句点本身在方括号中失去了特殊含义。其他特殊字符也是如此,例如加号(+)。
要反转一组字符——即表达你想匹配任何除了该组中的字符外的字符——可以在开括号后写一个插入符号(^)。
let nonBinary = /[⁰¹]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true
国际字符
由于 JavaScript 最初的简单实现以及这种简单方法后来被视为标准行为,JavaScript 的正则表达式在处理不出现在英语中的字符时显得相当愚蠢。例如,在 JavaScript 的正则表达式看来,“单词字符”仅是拉丁字母表中的 26 个字符(大小写皆可)、十进制数字,以及出于某种原因的下划线字符。像é或β这样的字符,虽然绝对是单词字符,却不会匹配\w(而且会匹配大写的\W,即非单词类别)。
由于一个奇怪的历史偶然,\s(空白符)没有这个问题,它匹配 Unicode 标准认为的所有空白字符,包括不换行空格和蒙古元音分隔符等。
在正则表达式中可以使用\p来匹配 Unicode 标准赋予特定属性的所有字符。这使我们能够以更广泛的方式匹配字母。然而,由于与原始语言标准的兼容性,只有在正则表达式后面加上u字符(表示 Unicode)时,这些字符才能被识别。
\p{L} |
任何字母 |
|---|---|
\p{N} |
任何数字字符 |
\p{P} |
任何标点符号字符 |
\P{L} |
任何非字母(大写P表示反转) |
\p{Script=Hangul} |
给定脚本中的任何字符(参见 第五章) |
使用\w进行文本处理,可能需要处理非英语文本(甚至包含借用词如cliché的英语文本)是一种风险,因为它不会将像é这样的字符视为字母。尽管它们通常更冗长,但\p属性组更加稳健。
console.log(/\p{L}/u.test("α"));
// → true
console.log(/\p{L}/u.test("!"));
// → false
console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false
另一方面,如果你是为了对数字执行某些操作而匹配数字,通常确实需要\d来匹配数字,因为将任意数字字符转换为 JavaScript 数字并不是像Number这样的函数能为你做到的。
重复模式的部分
现在我们知道如何匹配单个数字。如果我们想匹配一个完整的数字——一个或多个数字的序列呢?
当你在正则表达式中在某个内容后加上加号(+)时,表示该元素可以重复多次。因此,/\d+/匹配一个或多个数字字符。
console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true
星号(*)的含义类似,但也允许模式零次匹配。后面带有星号的内容不会阻止模式匹配——如果找不到任何合适的文本匹配,它只会匹配零个实例。
问号(?)使模式的部分可选,意味着它可以出现零次或一次。在下面的示例中,u字符可以出现,但当它缺失时,模式仍然匹配:
let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true
要表示模式应该出现的精确次数,可以使用大括号。在元素后面加上{4},例如,要求它恰好出现四次。也可以通过这种方式指定范围:{2,4}意味着元素必须至少出现两次,最多四次。
这是日期和时间模式的另一个版本,允许单个和双个数字的天、月和小时。它也略微更易于解读。
let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("1-30-2003 8:45"));
// → true
使用大括号时,可以通过省略逗号后的数字来指定开放范围。例如,{5,}意味着五次或更多次。
子表达式分组
要在多个元素上同时使用*或+这样的运算符,必须使用括号。被括号包围的正则表达式的一部分在后续运算符的考虑下算作一个单一元素。
let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true
第一个和第二个+字符仅适用于boo和hoo中的第二个o。第三个+适用于整个组(hoo+),匹配一个或多个这样的序列。
示例中表达式末尾的i使该正则表达式对大小写不敏感,即使模式本身全部为小写,也允许匹配输入字符串中的大写B。
匹配和分组
test方法是匹配正则表达式的最简单方式。它只告诉你是否匹配,而没有其他信息。正则表达式还有一个exec(执行)方法,如果未找到匹配项,则返回null,否则返回一个包含匹配信息的对象。
let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8
从exec返回的对象有一个index属性,告诉我们匹配在字符串中开始的位置。除此之外,该对象看起来(实际上也是)是一个字符串数组,其中第一个元素是匹配的字符串。在前面的例子中,这就是我们要寻找的数字序列。
字符串值有一个match方法,其行为类似。
console.log("one two 100".match(/\d+/));
// → ["100"]
当正则表达式包含用括号分组的子表达式时,匹配这些组的文本也会出现在数组中。整个匹配总是第一个元素。下一个元素是第一个组(即开括号在表达式中最先出现的那个)的匹配部分,然后是第二组,以此类推。
let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]
当一个组根本没有被匹配时(例如,后面跟着问号),其在输出数组中的位置将是undefined。当一个组被多次匹配时(例如,后面跟着+),只有最后一次匹配会出现在数组中。
console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]
如果你想单纯将括号用于分组,而不希望它们出现在匹配的数组中,可以在开括号后加上?:。
console.log(/(?:na)+/.exec("banana"));
// → ["nana"]
组对于提取字符串的一部分是很有用的。如果我们不仅想验证一个字符串是否包含日期,还想提取它并构建一个表示它的对象,我们可以在数字模式周围加上括号,并直接从exec的结果中提取日期。
但首先我们将简要讨论 JavaScript 中表示日期和时间值的内置方法。
日期类
JavaScript 有一个标准的日期类用于表示日期,或者更准确地说,表示时间点。如果你仅仅使用new创建一个日期对象,你将获得当前的日期和时间。
console.log(new Date());
// → Fri Feb 02 2024 18:03:06 GMT+0100 (CET)
你也可以为特定时间创建一个对象。
console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)
JavaScript 使用一种约定,即月份编号从零开始(所以 12 月是 11),而日期编号从一开始。这让人感到困惑和愚蠢。请小心。
最后四个参数(小时、分钟、秒和毫秒)是可选的,未给定时默认为零。
时间戳以自 1970 年开始的毫秒数存储,使用 UTC 时区。这遵循了“Unix 时间”设定的约定,该约定大约在那个时候被发明。对于 1970 年之前的时间,可以使用负数。日期对象上的getTime方法返回这个数字。它的数值很大,可以想象。
console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)
如果你给Date构造函数一个单一的参数,那么该参数会被视为这样的毫秒计数。你可以通过创建一个新的Date对象并调用getTime来获取当前的毫秒计数,或者通过调用Date.now函数。
日期对象提供了getFullYear、getMonth、getDate、getHours、getMinutes和getSeconds等方法来提取它们的组成部分。除了getFullYear外,还有getYear,它返回的是年份减去 1900(如 98 或 125),这个方法大多无用。
将感兴趣的表达式部分用括号括起来后,我们现在可以从字符串创建日期对象。
function getDate(string) {
let [_, month, day, year] =
/(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
return new Date(year, month - 1, day);
}
console.log(getDate("1-30-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)
下划线(_)绑定被忽略,仅用于跳过exec返回的数组中的完整匹配元素。
边界和前瞻
不幸的是,getDate也会愉快地从字符串“100-1 -30000”中提取日期。匹配可以发生在字符串的任何地方,因此在这种情况下,它会从第二个字符开始,到倒数第二个字符结束。
如果我们想要强制匹配必须覆盖整个字符串,可以添加标记^和$。插入符号匹配输入字符串的开头,而美元符号匹配结尾。因此,/^\d+$/匹配一个完全由一个或多个数字组成的字符串,/^!/匹配任何以感叹号开头的字符串,而/x^/则不匹配任何字符串(字符串开头不能有x)。
还有一个\b标记,匹配单词边界,即一侧是单词字符而另一侧是非单词字符的位置。不幸的是,这些标记与\w使用相同的简单概念,因此并不可靠。
注意,这些边界标记并不匹配任何实际字符。它们只是确保在出现位置满足特定条件。
前瞻测试做了类似的事情。它们提供一个模式,如果输入不匹配该模式,则使匹配失败,但实际上并不向前移动匹配位置。它们是在(?=和)之间编写的。
console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null
第一个示例中的e是匹配所必需的,但不是匹配字符串的一部分。(?! )符号表示负向前瞻。只有在括号内的模式不匹配时,它才会匹配,从而导致第二个示例只匹配后面没有空格的字符。
选择模式
假设我们想知道一段文本是否不仅包含一个数字,还包含一个数字后面跟着单词pig、cow或chicken,或者它们的复数形式。
我们可以编写三个正则表达式并依次测试,但有一种更好的方法。管道字符(|)表示其左侧模式和右侧模式之间的选择。我们可以在这样的表达式中使用它:
let animalCount = /\d+ (pig|cow|chicken)s?/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pugs"));
// → false
可以使用括号来限制管道操作符适用的模式部分,你可以将多个这样的操作符并排放置,以表达两种以上选项之间的选择。
匹配机制
从概念上讲,当你使用exec或test时,正则表达式引擎会通过尝试首先从字符串的开头开始匹配表达式,然后从第二个字符开始,依此类推,直到找到匹配项或到达字符串的末尾。它将返回找到的第一个匹配项,或者根本找不到任何匹配。
为了进行实际的匹配,引擎将正则表达式视作一个流程图。这是前一个示例中牲畜表达式的图示:

如果我们能够找到一条从图左侧到右侧的路径,我们的表达式就匹配。我们在字符串中保持一个当前位置,每当我们穿过一个框时,我们会验证当前位置之后的字符串部分是否与该框匹配。
回溯
正则表达式/^([01]+b|[\da-f]+h|\d+)$/可以匹配一个后面跟着b的二进制数字、一个后面跟着h的十六进制数字(即基数 16,字母a到f表示数字 10 到 15),或者一个没有后缀字符的常规十进制数字。这是相应的图示:

在匹配这个表达式时,顶部的(二进制)分支通常会被进入,即使输入实际上并不包含二进制数字。例如,在匹配字符串“103”时,只有在字符 3 处才明确我们在错误的分支中。该字符串确实匹配表达式,只是我们当前所在的分支不匹配。
因此,匹配器回溯。进入一个分支时,它会记住当前的位置(在这种情况下,位于字符串的开始,刚好在图中的第一个边界框后面),以便在当前分支不成功时可以返回并尝试另一个分支。对于字符串“103”,在遇到字符 3 后,匹配器开始尝试十六进制数字的分支,但由于数字后没有h,因此再次失败。接着它尝试十进制数字的分支。这个分支匹配成功,最终报告了匹配结果。
匹配器在找到完整匹配后立即停止。这意味着如果多个分支可能匹配一个字符串,只有第一个(按分支在正则表达式中出现的顺序)会被使用。
回溯也发生在重复操作符如+和*上。如果你用/^.*x/来匹配“abcxe”,.*部分会首先尝试消耗整个字符串。引
可以编写会进行大量回溯的正则表达式。当一个模式可以以多种不同方式匹配输入的一部分时,就会出现这个问题。例如,如果在编写二进制数字正则表达式时感到困惑,可能会不小心写出类似/([01]+)+b/的东西。

如果
replace方法。
字符串值具有一个可以用来将字符串的一部分替换为另一个字符串的replace方法。
console.log("papa".replace("p", "m"));
// → mapa
第一个参数也可以是一个正则表达式,在这种情况下,正则表达式的第一次匹配将被替换。当在正则表达式后添加g选项(表示全局)时,字符串中的所有匹配项都会被替换,而不仅仅是第一个。
console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar
使用正则表达式与replace结合的真正强大之处在于我们可以在替换字符串中引用匹配的组。例如,假设我们有一个包含人名的大字符串,每行一个名字,格式为Lastname, Firstname。如果我们想交换这些名字并移除逗号以获得Firstname Lastname格式,我们可以使用以下代码:
console.log(
"Liskov, Barbara\nMcCarthy, John\nMilner, Robin"
.replace(/(\p{L}+), (\p{L}+)/gu, "$2 $1"));
// → Barbara Liskov
// John McCarthy
// Robin Milner
替换字符串中的$1和$2引用模式中的括号组。$1被替换为与第一个组匹配的文本,$2被替换为第二个,依此类推,直到$9。整个匹配可以通过$&引用。
可以将一个函数而不是字符串作为第二个参数传递给replace。对于每一个替换,函数将被调用,并传入匹配的组(以及整个匹配项)作为参数,其返回值将插入到新字符串中。
这是一个例子:
let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
amount = Number(amount) - 1;
if (amount == 1) { // Only one left, remove the 's'
unit = unit.slice(0, unit.length - 1);
} else if (amount == 0) {
amount = "no";
}
return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs
这段代码获取一个字符串,查找所有后跟一个字母数字单词的数字出现次数,并返回一个每个数量少一个的字符串。
(\d+)组最终作为函数的数量参数,(\p{L}+)组绑定到单位。函数将数量转换为数字——这总是有效的,因为它之前匹配了\d+——并进行一些调整,以防只有一个或零个剩余。
贪婪
我们可以使用replace来编写一个函数,从一段JavaScript代码中移除所有注释。这是第一次尝试:
function stripComments(code) {
return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 1
| 操作符前面的部分匹配两个斜杠字符后跟任意数量的非换行字符。多行注释的部分更复杂。我们使用[^](不在空字符集中的任何字符)作为匹配任意字符的方式。我们不能在这里简单地使用一个句点,因为块注释可能会在新行上继续,而句点字符无法匹配换行字符。
但是最后一行的输出似乎出错了。为什么?
表达式的[^]*部分,如我在回溯部分所述,首先会匹配尽可能多的内容。如果这导致模式的下一部分失败,匹配器会回退一个字符并从那里重新尝试。在这个例子中,匹配器首先尝试匹配字符串的其余部分,然后从那里回退。它会在回退四个字符后找到一个*/的出现,并进行匹配。这并不是我们想要的——我们的意图是匹配单个注释,而不是一直到代码的末尾去找到最后一个块注释的结束。
正因为这种行为,我们称重复运算符(+,*,?,和{})为贪婪的,意思是它们尽可能多地匹配,然后从那里回溯。如果在它们后面加上问号(+?,*?,??,{}?),它们就会变成非贪婪的,并尽量匹配尽可能少的内容,只有在剩余模式不适合较小匹配时才会匹配更多。
而这正是我们在这种情况下想要的。通过让星号匹配最小的字符范围,使我们到达一个*/,我们消耗了一个块注释,而没有更多。
function stripComments(code) {
return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1
正则表达式程序中的许多错误可以追溯到无意中使用了贪婪运算符,而非贪婪运算符会更好。当使用重复运算符时,优先选择非贪婪变体。
动态创建RegExp对象。
在某些情况下,当你编写代码时,可能不知道需要匹配的确切模式。比如说,你想在一段文本中测试用户的名字。你可以构建一个字符串,并在此基础上使用RegExp构造函数。
let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true
在创建字符串的\s部分时,我们必须使用两个反斜杠,因为我们是在正常字符串中编写它们,而不是在斜杠封闭的正则表达式中。RegExp构造函数的第二个参数包含正则表达式的选项——在这种情况下,“gi”表示全局匹配和不区分大小写。
但如果名字是“dea+hl[]rd”,因为我们的用户是一个书呆子青少年呢?这会导致一个毫无意义的正则表达式,实际上无法匹配用户的名字。
为了解决这个问题,我们可以在任何具有特殊含义的字符前添加反斜杠。
let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)", "gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true
search方法。
虽然字符串的indexOf方法不能用正则表达式调用,但还有另一种方法search,它确实需要一个正则表达式。像indexOf一样,它返回表达式找到的第一个索引,或者在未找到时返回-1。
console.log(" word".search(/\S/));
// → 2
console.log(" ".search(/\S/));
// → -1
不幸的是,没有办法指示匹配应从给定偏移量开始(就像我们可以用indexOf的第二个参数一样),这在很多情况下会非常有用。
lastIndex属性。
exec方法同样没有提供从给定位置开始搜索的方便方式。但它提供了一种不方便的方式。
正则表达式对象有属性。其中一个属性是source,它包含创建该表达式时使用的字符串。另一个属性是lastIndex,它在某些有限情况下控制下一个匹配的起始位置。
这些有限情况是正则表达式必须启用全局(g)或粘性(y)选项,并且匹配必须通过exec方法发生。再说一次,较少混淆的解决方案是允许将额外参数传递给exec,但混淆是JavaScript正则表达式接口的一个基本特征。
let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5
如果匹配成功,对exec的调用会自动更新lastIndex属性,使其指向匹配之后的位置。如果没有找到匹配,lastIndex会被重置为0,这也是新构建的正则表达式对象的初始值。
全局选项和粘性选项之间的区别在于,当启用粘性时,匹配只会在lastIndex直接开始时成功,而全局匹配则会向前搜索可以开始匹配的位置。
let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null
在对多个exec调用使用共享的正则表达式值时,这些对lastIndex属性的自动更新可能会引发问题。你的正则表达式可能会意外地从上一次调用留下的索引开始。
let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null
全局选项的另一个有趣效果是它改变了字符串上match方法的工作方式。当使用全局表达式调用时,match不会返回与exec返回的数组类似的结果,而是会找到字符串中模式的所有匹配,并返回一个包含匹配字符串的数组。
console.log("Banana".match(/an/g));
// → ["an", "an"]
因此,请谨慎使用全局正则表达式。它们必要的情况——调用replace和希望显式使用lastIndex的地方——通常是你想使用它们的唯一情况。
常见的做法是查找字符串中正则表达式的所有匹配。我们可以通过使用matchAll方法来实现。
let input = "A string with 3 numbers in it... 42 and 88.";
let matches = input.matchAll(/\d+/g);
for (let match of matches) {
console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
// Found 42 at 33
// Found 88 at 40
该方法返回一个匹配数组的数组。传递给matchAll的正则表达式必须启用g选项。
解析INI文件。
为了结束这一章,我们将看一个需要正则表达式的问题。假设我们正在编写一个程序,以自动从互联网上收集有关我们敌人的信息。(我们在这里不会实际编写那个程序,只是读取配置文件的部分。抱歉。)配置文件看起来像这样:
searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7
; Comments are preceded by a semicolon...
; Each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451
[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn
这种格式的确切规则——它是一种广泛使用的文件格式,通常称为INI文件——如下所示:
-
空行和以分号开头的行会被忽略。
-
用
[和]包裹的行开始一个新节。 -
包含字母数字标识符后跟
=字符的行会为当前节添加一个设置。 -
其他任何情况都是无效的。
我们的任务是将像这样的字符串转换为一个对象,该对象的属性包含在第一个节标题之前写的设置字符串,而子对象则用于节,其中这些子对象包含该节的设置。
由于格式必须逐行处理,将文件拆分成单独的行是一个良好的开始。我们在第四章中看到了split方法。然而,一些操作系统不仅使用换行符来分隔行,还使用回车符后跟换行符(“\r\n”)。考虑到split方法也允许使用正则表达式作为参数,我们可以使用像/\r?\n/这样的正则表达式,以便在行之间支持“\n”和“\r\n”。
function parseINI(string) {
// Start with an object to hold the top-level fields
let result = {};
let section = result;
for (let line of string.split(/\r?\n/)) {
let match;
if (match = line.match(/^(\w+)=(.*)$/)) {
section[match[1]] = match[2];
} else if (match = line.match(/^\[(.*)\]$/)) {
section = result[match[1]] = {};
} else if (!/^\s*(;|$)/.test(line)) {
throw new Error("Line '" + line + "' is not valid.");
}
};
return result;
}
console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}
代码遍历文件的行并构建一个对象。顶部的属性直接存储到该对象中,而在节中找到的属性则存储在单独的节对象中。节绑定指向当前节的对象。
有两种重要的行——节标题或属性行。当一行是常规属性时,它存储在当前节中。当它是节标题时,会创建一个新的节对象,并将节设置为指向它。
注意^和$的重复使用,以确保表达式匹配整行,而不仅仅是部分内容。省略这些将导致代码在大多数情况下工作,但对于某些输入表现得很奇怪,这可能是一个难以追踪的错误。
模式if (match = *string*.match(...))利用赋值表达式(=)的值是被赋值的事实。你通常不能确定你的match调用是否会成功,因此你只能在测试此的if语句内部访问结果对象。为了不打破愉快的else if形式的链,我们将匹配结果分配给一个绑定,并立即将该赋值作为if语句的测试。
如果一行不是节标题或属性,函数会使用表达式/^\s*(;|$)/检查它是否是注释或空行,以匹配仅包含空白或空白后跟分号的行(使该行的其余部分成为注释)。当一行不匹配任何预期形式时,函数会抛出异常。
代码单元和字符
在JavaScript正则表达式中,一个被标准化的设计错误是,默认情况下,像.或?这样的运算符是作用于代码单元(如第五章所讨论),而不是实际字符。这意味着由两个代码单元组成的字符表现得很奇怪。
console.log(/{3}/.test(""));
// → false
console.log(/<.>/.test("<>"));
// → false
console.log(/<.>/u.test("<>"));
// → true
问题是,第一行中的被视为两个代码单元,而{3}仅应用于第二个单元。类似地,点只匹配单个代码单元,而不是组成玫瑰表情的两个代码单元。
你必须在正则表达式中添加u(Unicode)选项,以使其正确处理此类字符。
console.log(/{3}/u.test(""));
// → true
摘要
正则表达式是表示字符串中模式的对象。它们使用自己的语言来表达这些模式。
/abc/ |
一串字符 |
|---|---|
/[abc]/ |
字符集中的任意字符 |
/[^abc]/ |
不在字符集中的任意字符 |
/[0-9]/ |
范围内的任意字符 |
/x+/ |
x模式的一个或多个出现 |
/x+?/ |
一个或多个出现,非贪婪 |
/x*/ |
零个或多个出现 |
/x?/ |
零个或一个出现 |
/x{2,4}/ |
两到四个出现 |
/(abc)/ |
一个分组 |
| `/a | b |
/\d/ |
任意数字字符 |
/\w/ |
一个字母数字字符(“单词字符”) |
/\s/ |
任意空白字符 |
/./ |
除换行符外的任意字符 |
/\p{L}/u |
任意字母字符 |
/^/ |
输入的开始 |
$/ |
输入的结束 |
/(?=a)/ |
前瞻测试 |
正则表达式有一个test方法用于测试给定字符串是否与其匹配。还有一个exec方法,当找到匹配时返回一个包含所有匹配组的数组。这样的数组有一个index属性,指示匹配开始的位置。
字符串具有一个匹配方法,可以将其与正则表达式进行匹配,还有一个搜索方法用于查找匹配,只返回匹配的起始位置。它们的替换方法可以将模式的匹配替换为一个替换字符串或函数。
正则表达式可以有选项,这些选项在关闭斜杠后书写。i选项使匹配不区分大小写。g选项使表达式全局,这意味着替换方法将替换所有实例,而不仅仅是第一个。y选项使表达式具有粘性,这意味着在寻找匹配时不会向前搜索并跳过字符串的一部分。u选项启用Unicode模式,允许使用\p语法,并修复了一些关于处理占用两个代码单元的字符的问题。
正则表达式是一个锐利的工具,使用起来却有些笨拙。它们极大地简化了一些任务,但在应用于复杂问题时,很快就会变得难以管理。了解如何使用它们的一部分是抵制将无法清晰表达的内容强行塞入其中的冲动。
练习
在进行这些练习时,你几乎无法避免会对某些正则表达式的莫名行为感到困惑和沮丧。有时候,将你的表达式输入到一个像[www.debuggex.com`]()的在线工具中,可以帮助你查看其可视化效果是否与你的意图一致,并尝试它对各种输入字符串的响应。
正则表达式高尔夫
代码高尔夫是一个术语,用于描述尽可能少字符表达特定程序的游戏。同样,正则表达式高尔夫是一种实践,旨在写出尽可能小的正则表达式以匹配给定模式,并且仅匹配该模式。
对于以下每一项,写一个正则表达式来测试给定模式是否出现在字符串中。正则表达式应该只匹配包含该模式的字符串。当你的表达式工作时,看看能否让它更小。
-
车和猫 -
流行和道具 -
雪貂、渡船和法拉利 -
任何以
ious结尾的单词 -
一个空白字符后跟一个句号、逗号、冒号或分号
-
一个超过六个字母的单词
-
一个不包含字母
e(或E)的单词
参考章节总结中的表格以获得帮助。用几个测试字符串测试每个解决方案。
引号风格
想象一下,你写了一篇故事,并且在整个故事中使用单引号来标记对话的片段。现在你想将所有对话的引号替换为双引号,同时保留在缩写中使用的单引号,比如aren’t。
想出一个模式来区分这两种引号的用法,并编写一个调用替换方法的代码来进行正确的替换。
数字再谈
写一个只匹配JavaScript风格数字的表达式。它必须支持在数字前的可选负号或正号,十进制点,以及指数表示法——5e-3或1E10——同样在指数前也有可选符号。另外,请注意,点前后不需要有数字,但数字不能仅仅是一个点。也就是说,.5和5.是有效的JavaScript数字,但一个孤立的点不是。
编写易于删除的代码,而不是易于扩展的代码。
—Tef,编程是可怕的

第十一章:模块
理想情况下,程序应具有清晰、简单的结构。它的工作原理容易解释,每个部分都扮演着明确的角色。
在实践中,程序是有机增长的。随着程序员识别新的需求,功能块被逐步添加。保持这样的程序结构良好需要持续的关注和努力。这项工作只有在将来、下次有人处理该程序时才会得到回报,因此很容易忽视它,让程序的各个部分深度纠缠。
这造成了两个实际问题。首先,理解一个纠缠的系统很困难。如果一切都能相互触及,那么很难孤立地看待任何特定的部分。你被迫建立对整个事物的整体理解。其次,如果你想在另一种情况下使用这样的程序的任何功能,重写它可能比试图将其从上下文中解开要容易。
“大球泥土”这个短语常用来形容这样的庞大、无结构的程序。一切都粘在一起,当你试图挑出一部分时,整个东西就会散架,你最终只会搞得一团糟。
模块化程序
模块是试图避免这些问题的一个尝试。一个模块是一段程序,指定它依赖于哪些其他部分以及它为其他模块提供哪些功能(它的接口)。
模块接口与对象接口有很多相似之处,正如我们在第六章中看到的。它们将模块的一部分公开给外部世界,而将其余部分保留为私有。
但是,一个模块为其他模块提供的接口只是故事的一部分。一个好的模块系统还要求模块指定它们使用其他模块的哪些代码。这些关系称为依赖。如果模块A使用模块B的功能,就说模块A依赖于该模块。当这些在模块内部被明确指定时,可以用来确定使用特定模块所需的其他模块,并自动加载依赖项。
当模块之间的交互方式是明确的时,系统就更像乐高,组件通过明确定义的连接器相互作用,而不像泥土那样,所有东西都混在一起。
ES模块
原始的JavaScript语言没有模块的概念。所有脚本都在同一个作用域内运行,访问在另一个脚本中定义的函数是通过引用该脚本创建的全局绑定来完成的。这积极鼓励了代码的意外、难以察觉的纠缠,并导致了不同脚本尝试使用相同绑定名称等问题。
自ECMAScript 2015以来,JavaScript支持两种不同类型的程序。脚本以旧的方式运行:它们的绑定在全局范围内定义,并且无法直接引用其他脚本。模块拥有自己的独立范围,并支持import和export关键字,这在脚本中不可用,以声明它们的依赖关系和接口。这个模块系统通常称为ES模块(ES代表ECMAScript)。
一个模块化程序由多个这样的模块组成,通过它们的导入和导出连接在一起。
以下示例模块在日期名称和数字之间转换(如Date的getDay方法返回的)。它定义了一个不属于其接口的常量,以及两个属于其接口的函数。它没有依赖关系。
const names = ["Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"];
export function dayName(number) {
return names[number];
}
export function dayNumber(name) {
return names.indexOf(name);
}
export关键字可以放在函数、类或绑定定义前,表示该绑定是模块接口的一部分。这使得其他模块能够通过导入该绑定来使用它。
import {dayName} from "./dayname.js";
let now = new Date();
console.log(`Today is ${dayName(now.getDay())}`);
// → Today is Monday
import关键字后跟花括号内的绑定名称列表,使来自另一个模块的绑定在当前模块中可用。模块由引号字符串标识。
这样的模块名称解析为实际程序的方式因平台而异。浏览器将它们视为网址,而Node.js将其解析为文件。当你运行一个模块时,它所依赖的所有其他模块——以及那些模块所依赖的模块——都会被加载,导出的绑定将对导入它们的模块可用。
import和export声明不能出现在函数、循环或其他块内。它们在模块加载时立即解析,无论模块中的代码如何执行。为了反映这一点,它们必须仅出现在外部模块体内。
模块的接口因此由一组命名绑定组成,其他依赖于该模块的模块可以访问这些绑定。导入的绑定可以通过在名称后使用as来重命名,从而赋予它们一个新的本地名称。
import {dayName as nomDeJour} from "./dayname.js";
console.log(nomDeJour(3));
// → Wednesday
模块也可以有一个名为default的特殊导出,通常用于仅导出单个绑定的模块。要定义默认导出,请在表达式、函数声明或类声明前写export default。
export default ["Winter", "Spring", "Summer", "Autumn"];
这样的绑定通过省略名称周围的花括号来导入。
import seasonNames from "./seasonname.js";
要同时导入模块中的所有绑定,可以使用import *。你提供一个名称,这个名称将绑定到一个持有所有模块导出的对象上。当你使用很多不同的导出时,这非常有用。
import * as dayName from ".dayname.js";
console.log(dayName.dayName(3));
// → Wednesday
包
将程序构建为多个独立部分,并能够单独运行其中一些部分的一个优点是,你可能能够在不同程序中使用相同的部分。
那么,如何设置这个呢?假设我想在另一个程序中使用第九章中的parseINI函数。如果很清楚这个函数的依赖(在这种情况下,没有),我可以直接把该模块复制到我的新项目中并使用。但是,如果我在代码中发现了错误,我可能会在我当时正在工作的程序中修复它,却忘了在其他程序中也修复。
一旦你开始复制代码,你会迅速发现自己在浪费时间和精力来移动副本并保持它们的更新。这就是包的用武之地。一个包是一块可以分发(复制和安装)的代码。它可能包含一个或多个模块,并包含有关其依赖于其他包的信息。包通常还会附带文档,解释它的功能,以便那些没有编写它的人也能够使用。
当在一个包中发现问题或添加新特性时,该包会被更新。现在,依赖于它的程序(也可能是包)可以复制新版本,以获得对代码所做改进的访问。
以这种方式工作需要基础设施。我们需要一个地方来存储和查找包,以及一个方便的方式来安装和升级它们。在 JavaScript 世界中,这一基础设施由NPM提供([www.npmjs.com](https://www.npmjs.com))。
NPM有两个功能:一个是你可以下载(和上传)包的在线服务,另一个是一个程序(与Node.js捆绑在一起),帮助你安装和管理这些包。
截至目前,NPM上有超过三百万个不同的包。公平地说,其中大部分是无用的。但是几乎所有有用的、公开可用的 JavaScript 包都可以在NPM上找到。例如,与我们在第九章中构建的INI文件解析器类似的一个解析器可以在包名为ini下找到。
第二十章将展示如何使用npm命令行程序在本地安装这些包。
拥有可供下载的高质量包是非常有价值的。这意味着我们通常可以避免重新发明一个已经被 100 人写过的程序,并且可以在按下几下键的情况下获得一个稳固、经过良好测试的实现。
软件复制成本低,因此一旦有人编写了它,分发给其他人就是一个高效的过程。不过,最初编写它确实是一项工作,而回应那些发现代码问题或希望提出新特性的人则需要更多的工作。
默认情况下,你拥有自己编写代码的版权,其他人只能在获得你的许可后使用它。但是,因为有些人很友善,且发布好的软件可以让你在程序员中小有名气,许多包都在允许其他人使用的许可证下发布。
NPM上的大多数代码以这种方式获得许可。一些许可证要求你在基于该包构建的代码上也以相同许可证发布。其他许可证要求较少,仅要求在分发代码时保留许可证。JavaScript 社区主要使用后者类型的许可证。在使用其他人的包时,请确保了解其许可证。
现在,我们可以使用NPM上的一个解析INI文件的模块,而不是自己编写一个。
import {parse} from "ini";
console.log(parse("x = 10\ny = 20"));
// → {x: "10", y: "20"}
CommonJS模块
在2015年之前,当 JavaScript 语言没有内置模块系统时,人们已经在 JavaScript 中构建了大型系统。为了使其可行,他们需要模块。
社区在语言之上设计了自己的即兴模块系统。这些使用函数为模块创建局部作用域,并使用常规对象来表示模块接口。
起初,人们只是手动将整个模块包裹在一个立即调用函数表达式中,以创建模块的作用域,并将接口对象分配给一个全局变量。
const weekDay = function() {
const names = ["Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"];
return {
name(number) { return names[number]; },
number(name) { return names.indexOf(name); }
};
}();
console.log(weekDay.name(weekDay.number("Sunday")));
// → Sunday
这种模块风格提供了一定程度的隔离,但并未声明依赖关系。相反,它只是将其接口放入全局作用域,并期望其依赖关系(如果有的话)也这样做。这并不是理想的。
如果我们实现自己的模块加载器,我们可以做得更好。最广泛使用的附加 JavaScript模块方法称为CommonJS模块。Node.js从一开始就使用这个模块系统(虽然现在也知道如何加载 ES模块),这是许多NPM包使用的模块系统。
CommonJS模块看起来像一个常规脚本,但它可以访问两个绑定,以便与其他模块进行交互。第一个是一个名为require的函数。当你使用依赖模块的名称调用它时,它会确保模块被加载并返回其接口。第二个是一个名为exports的对象,这是模块的接口对象。它最开始是空的,你向其添加属性以定义导出的值。
这个CommonJS示例模块提供了一个日期格式化函数。它使用了来自NPM的两个包——ordinal将数字转换为"1st"和"2nd"等字符串,而date-names则获取工作日和月份的英文名称。它导出一个单一的函数formatDate,接受一个Date对象和一个模板字符串。
模板字符串可以包含指导格式的代码,例如YYYY表示完整年份,Do表示月份的序数日。你可以给它一个像"MMMM Do YYYY"的字符串,以获得类似于"2017 年 11 月 22 日"的输出。
const ordinal = require("ordinal");
const {days, months} = require("date-names");
exports.formatDate = function(date, format) {
return format.replace(/YYYY|M(MMM)?|Do?|dddd/g, tag => {
if (tag == "YYYY") return date.getFullYear();
if (tag == "M") return date.getMonth();
if (tag == "MMMM") return months[date.getMonth()];
if (tag == "D") return date.getDate();
if (tag == "Do") return ordinal(date.getDate());
if (tag == "dddd") return days[date.getDay()];
});
};
ordinal的接口是一个单一的函数,而date-names导出的是一个包含多个内容的对象——天和月是名称的数组。解构在创建导入接口的绑定时非常方便。
模块将其接口函数添加到exports中,以便依赖于它的模块可以访问它。我们可以这样使用该模块:
const {formatDate} = require("./format-date.js");
console.log(formatDate(new Date(2017, 9, 13),
"dddd the Do"));
// → Friday the 13th
CommonJS实现了一个模块加载器,当加载模块时,将其代码封装在一个函数中(为其提供自己的局部作用域),并将require和exports绑定作为参数传递给该函数。
如果我们假设有一个 readFile 函数,可以通过名称读取文件并返回其内容,我们可以像这样定义一个简化的 require 形式:
function require(name) {
if (!(name in require.cache)) {
let code = readFile(name);
let exports = require.cache[name] = {};
let wrapper = Function("require, exports", code);
wrapper(require, exports);
}
return require.cache[name];
}
require.cache = Object.create(null);
Function 是一个内置的 JavaScript 函数,它接受一个以逗号分隔的字符串形式的参数列表和一个包含函数体的字符串,并返回一个带有这些参数和该函数体的函数值。这是一个有趣的概念——它允许程序从字符串数据中创建新的程序片段——但也是一个危险的概念,因为如果有人能欺骗你的程序将他们提供的字符串放入 Function 中,他们就能让程序执行任何他们想要的操作。
标准的 JavaScript 并没有提供像 readFile 这样的函数,但不同的 JavaScript 环境,如浏览器和 Node.js,提供了各自访问文件的方式。这个例子假装 readFile 存在。
为了避免多次加载相同的模块,require保持已加载模块的存储(缓存)。调用时,它首先检查请求的模块是否已加载,如果没有,则加载它。这涉及读取模块的代码,将其封装在一个函数中,并调用它。
通过将 require 和 exports 定义为生成的包装函数的参数(并在调用时传递适当的值),加载器确保这些绑定在模块的作用域中可用。
该系统与 ES 模块之间的一个重要区别是,ES 模块的导入在模块的脚本开始运行之前发生,而 require 是一个普通函数,在模块已经运行时调用。与导入声明不同,require 调用可以出现在函数内部,依赖项的名称可以是任何计算结果为字符串的表达式,而导入只允许普通的带引号字符串。
JavaScript 社区从 CommonJS 风格过渡到 ES 模块的过程比较缓慢且略显粗糙。幸运的是,现在大多数流行的 NPM 包都以 ES 模块的形式提供其代码,Node.js 也允许 ES 模块从 CommonJS 模块中导入。虽然 CommonJS 代码仍然会出现,但已经没有真正的理由再以这种风格编写新程序。
构建与打包
许多 JavaScript 包在技术上并不是用 JavaScript 编写的。诸如 TypeScript 之类的语言扩展,在第八章中提到的类型检查方言,被广泛使用。人们通常会在新语言特性被实际添加到运行 JavaScript 的平台之前,就开始使用计划中的新特性。为了实现这一点,他们会编译他们的代码,将其从所选的 JavaScript 方言转换为普通的 JavaScript,甚至是早期版本的 JavaScript,以便浏览器能够运行它。
在网页中包含由 200 个不同文件组成的模块化程序会产生自身的问题。如果从网络上获取一个文件需要 50 毫秒,那么加载整个程序需要 10 秒钟,或者如果你能够同时加载几个文件,可能会少一些。这是很多浪费的时间。因为获取一个大文件往往比获取许多小文件要快,网页程序员开始使用工具将他们精心拆分成模块的程序合并成一个大文件,然后再发布到网络上。这类工具被称为打包工具。
我们可以更进一步。除了文件数量,文件的大小也决定了它们在网络上传输的速度。因此,JavaScript 社区发明了压缩工具。这些工具通过自动删除注释和空格、重命名绑定以及用占用更少空间的等效代码替换代码片段,使 JavaScript 程序变得更小。
在 NPM 包中或在网页上运行的代码经历过多次转换阶段是并不少见——从现代 JavaScript 转换为历史 JavaScript,将模块合并为一个文件,以及压缩代码。在本书中我们不会详细介绍这些工具,因为它们种类繁多,流行的工具也会定期变化。只需知道这些工具的存在,并在需要时查找它们。
模块设计
结构化程序是编程中更微妙的方面之一。任何非平凡的功能都可以以多种方式组织。
良好的程序设计是主观的——涉及权衡和品味的问题。学习结构良好的设计价值的最好方法是阅读或参与大量程序,并注意什么有效,什么无效。不要假设一个痛苦的混乱是“就是这样的”。通过更多思考,你可以改善几乎所有事物的结构。
模块设计的一个方面是易用性。如果你设计的东西是为了让多个人使用——或者即使是你自己,在三个月后当你不再记得你所做的具体事情时——那么如果你的接口简单且可预测,那将会很有帮助。
这可能意味着遵循现有的约定。一个好的例子是ini包。这个模块通过提供解析和字符串化(以写入 INI 文件)函数,模仿标准 JSON 对象,并且像 JSON 一样,在字符串和普通对象之间进行转换。接口小而熟悉,使用一次后,你可能会记住如何使用它。
即使没有标准函数或广泛使用的包可以模仿,你也可以通过使用简单的数据结构并专注于单一功能来保持模块的可预测性。比如,NPM上许多INI文件解析模块提供一个直接从硬盘读取并解析文件的函数。这使得在浏览器中使用这些模块变得不可能,因为我们没有直接的文件系统访问权限,并增加了复杂性,而这本可以通过组合文件读取功能来更好地解决。
这指出了模块设计的另一个有用方面——与其他代码组合的简便性。专注于计算值的模块适用于比执行复杂操作和副作用的大模块更广泛的程序场景。一个坚持从磁盘读取文件的INI文件读取器在文件内容来自其他来源的情况下毫无用处。
相关地,有状态对象有时是有用甚至必要的,但如果可以用函数完成的事情,就应该使用函数。NPM上几个INI文件读取器提供了一种接口风格,要求你首先创建一个对象,然后将文件加载到对象中,最后使用专门的方法获取结果。这种情况在面向对象传统中很常见,而且非常糟糕。你不得不进行将对象通过不同状态移动的仪式,而不是简单地调用一个函数并继续。而且,由于数据现在被封装在专门的对象类型中,所有与之交互的代码都必须了解该类型,造成不必要的相互依赖。
经常,定义新的数据结构是不可避免的——语言标准只提供少数基本结构,许多数据类型必须比数组或映射更复杂。但当数组足够时,就用数组。
一个稍微复杂的数据结构的例子是来自第七章的图。在JavaScript中,没有单一明显的方法来表示图。在那一章中,我们使用了一个对象,其属性持有字符串数组——可从该节点到达的其他节点。
NPM上有几个不同的路径查找包,但没有一个使用这种图格式。它们通常允许图的边具有权重,即与之相关的成本或距离。这在我们的表示中是不可行的。
例如,有dijkstrajs包。一种著名的路径查找方法,与我们的findRoute函数非常相似,称为迪杰斯特拉算法,以最早将其写下的Edsger Dijkstra命名。js后缀通常被添加到包名中,以表明它们是用JavaScript编写的。这个dijkstrajs包使用类似于我们的图格式,但它使用的是属性值为数字的对象——边的权重。
如果我们想使用那个包,就必须确保我们的图以它所期望的格式存储。所有边的权重相同,因为我们的简化模型将每条道路视为具有相同成本(一个转弯)。
const {find_path} = require("dijkstrajs");
let graph = {};
for (let node of Object.keys(roadGraph)) {
let edges = graph[node] = {};
for (let dest of roadGraph[node]) {
edges[dest] = 1;
}
}
console.log(find_path(graph, "Post Office", "Cabin"));
// → ["Post Office", "Alice's House", "Cabin"]
这可能成为组合的障碍——当各种包使用不同的数据结构描述相似事物时,组合它们会很困难。因此,如果你想设计可组合性,了解其他人使用的数据结构,并在可能的情况下遵循他们的示例。
为程序设计合适的模块结构可能很困难。在你仍在探索问题的阶段,尝试不同的方案以查看什么有效时,你可能不想太过担心这个,因为保持一切有序可能会带来很大的干扰。一旦你有了感觉稳固的东西,那就是退后一步进行整理的好时机。
摘要
模块通过将代码分离为具有清晰接口和依赖关系的片段,为更大的程序提供结构。接口是模块对其他模块可见的部分,依赖关系是它所使用的其他模块。
因为JavaScript历史上没有提供模块系统,所以在其之上构建了CommonJS系统。然后在某个时刻它确实得到了内置系统,现在与CommonJS系统并存,但关系并不融洽。
包是可以独立分发的代码块。NPM是一个JavaScript包的库。你可以从中下载各种有用(和无用)的包。
练习
一个模块化机器人
这是项目从第七章创建的绑定:
roads
buildGraph
roadGraph
VillageState
runRobot
randomPick
randomRobot
mailRoute
routeRobot
findRoute
goalOrientedRobot
如果你要把那个项目写成一个模块化程序,你会创建哪些模块?哪个模块依赖于哪个其他模块,它们的接口会是什么样子?
哪些部分可能在NPM上已有预写?你更愿意使用NPM包还是自己编写?
道路模块
基于第七章的示例编写一个ES模块,该模块包含道路数组并将表示它们的图数据结构导出为roadGraph。它依赖于一个导出函数buildGraph的模块./graph.js,该函数用于构建图。此函数期望一个由两个元素数组(道路的起点和终点)组成的数组。
循环依赖
循环依赖是一种情况,其中模块A依赖于B,而B也直接或间接依赖于A。许多模块系统简单地禁止这种情况,因为无论你选择哪种加载顺序,都无法确保在运行之前每个模块的依赖关系都已加载。
CommonJS模块允许有限形式的循环依赖。只要模块在加载完成之前不相互访问对方的接口,循环依赖是可以的。
本章前面给出的require函数支持这种类型的依赖循环。你能看出它是如何处理循环的吗?
谁能静静等待泥沙沉淀?谁能保持静止直到行动的时刻?
—老子,《道德经》

第十二章:异步编程
计算机的核心部分,即执行构成我们程序的各个步骤的部分,被称为处理器。到目前为止我们看到的程序将在它们完成工作之前一直占用处理器。像处理数字的循环那样的操作执行速度几乎完全依赖于计算机的处理器和内存的速度。
但许多程序与处理器外部的事物进行交互。例如,它们可能通过计算机网络进行通信,或请求硬盘上的数据——这比从内存获取数据要慢得多。
当这种情况发生时,让处理器闲置将是一个遗憾——在此期间可能还有其他工作可以完成。这部分由你的操作系统处理,它会在多个运行中的程序之间切换处理器。但当我们希望一个单一程序在等待网络请求时仍能进展时,这并没有帮助。
异步性
在同步编程模型中,事情是一个接一个发生的。当你调用一个执行长时间运行的操作的函数时,它只有在操作完成并能返回结果时才会返回。这会在操作所需时间内停止你的程序。
异步模型允许多个事情同时发生。当你启动一个操作时,你的程序会继续运行。当操作完成时,程序会收到通知并访问结果(例如,从磁盘读取的数据)。
我们可以通过一个小例子来比较同步和异步编程:一个程序在网络上发出两个请求,然后合并结果。
在同步环境中,请求函数在完成工作之前不会返回,因此执行此任务的最简单方法是一个接一个地发出请求。这有一个缺点,即第二个请求只有在第一个请求完成后才会启动。总耗时至少是两个响应时间的总和。
在同步系统中,解决这个问题的方法是启动额外的控制线程。一个线程是另一个正在运行的程序,它的执行可能与操作系统中的其他程序交错进行——由于大多数现代计算机包含多个处理器,因此多个线程甚至可以在不同的处理器上同时运行。第二个线程可以启动第二个请求,然后两个线程等待结果返回,之后它们重新同步以合并结果。
在下图中,粗线代表程序正常运行所花费的时间,细线代表等待网络的时间。在同步模型中,网络所需的时间是特定控制线程的时间线的一部分。在异步模型中,启动网络操作允许程序继续运行,同时进行网络通信,并在完成时通知程序。
同步,单线程控制

同步,两条控制线程

异步

描述这种差异的另一种方式是,在同步模型中,等待操作完成是隐式的,而在异步模型中,它是显式的——在我们的控制之下。
异步性有双重作用。它使表达不符合直线控制模型的程序变得更容易,但它也可能使表达遵循直线的程序变得更为笨拙。我们将在本章稍后看到一些减少这种笨拙感的方法。
两大主要JavaScript编程平台——浏览器和Node.js——使可能需要一段时间的操作异步,而不是依赖线程。由于使用线程编程notoriously hard(理解一个程序的行为在它同时执行多个任务时更加困难),这通常被认为是一件好事。
回调
一种异步编程的方法是让需要等待某些事情的函数接受一个额外的参数,即回调函数。异步函数启动一个进程,设置好当进程完成时调用回调函数的条件,然后返回。
作为一个例子,setTimeout函数在Node.js和浏览器中都可用,它等待给定的毫秒数,然后调用一个函数。
setTimeout(() => console.log("Tick"), 500);
等待通常不是重要的工作,但当你需要安排某件事情在特定时间发生或检查某个操作是否比预期耗时更长时,这可以非常有用。
另一个常见异步操作的例子是从设备存储中读取文件。想象一下,你有一个函数readTextFile,它将文件的内容读取为字符串并传递给回调函数。
readTextFile("shopping_list.txt", content => {
console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas
readTextFile函数不是标准JavaScript的一部分。我们将在后面的章节中看到如何在浏览器和Node.js中读取文件。
使用回调在一系列异步操作中执行多个操作意味着你必须不断传递新的函数来处理在这些操作之后计算的继续。一个比较两个文件并生成一个布尔值,指示它们的内容是否相同的异步函数可能看起来像这样:
function compareFiles(fileA, fileB, callback) {
readTextFile(fileA, contentA => {
readTextFile(fileB, contentB => {
callback(contentA == contentB);
});
});
}
这种编程风格是可行的,但每进行一次异步操作,缩进级别就会增加,因为你进入了另一个函数。进行更复杂的操作,比如将异步操作包装在循环中,可能会变得很尴尬。
从某种意义上说,异步性是具有传染性的。任何调用异步函数的函数本身必须是异步的,使用回调或类似机制来传递结果。调用回调比简单返回值更复杂且容易出错,因此需要以这种方式结构化程序的较大部分并不好。
承诺
构建异步程序的另一种稍微不同的方法是让异步函数返回一个表示其(未来)结果的对象,而不是传递回调函数。这样,这些函数实际上返回一些有意义的东西,程序的结构与同步程序更为相似。
这就是标准类Promise的用途。承诺是表示可能尚不可用的值的收据。它提供了一个then方法,允许你注册一个在它等待的操作完成时应被调用的函数。当承诺被解析时,即其值变得可用,这些函数(可能有多个)会被调用并传入结果值。可以在已经解析的承诺上调用then——你的函数仍然会被调用。
创建承诺的最简单方法是调用Promise.resolve。这个函数确保你提供的值被包装在一个承诺中。如果它已经是一个承诺,则直接返回。否则,你将得到一个新的承诺,它立即以你的值作为结果解析。
let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15
要创建一个不会立即解析的承诺,可以使用Promise作为构造函数。它的接口有些奇怪:构造函数期望一个函数作为参数,并立即调用它,传递一个可以用来解析承诺的函数。
例如,这就是如何为readTextFile函数创建一个基于承诺的接口:
function textFile(filename) {
return new Promise(resolve => {
readTextFile(filename, text => resolve(text));
});
}
textFile("plans.txt").then(console.log);
注意,与回调风格的函数相比,这个异步函数返回了一个有意义的值——一个承诺,承诺在未来某个时刻提供文件的内容。
then方法的一个有用之处在于,它本身返回另一个承诺。这个承诺解析为回调函数返回的值,或者如果该返回值是一个承诺,则解析为该承诺所解析的值。因此,你可以将多个对then的调用“链式”连接在一起,以建立一系列异步操作。
这个函数读取一个包含文件名的文件,并返回该列表中随机文件的内容,展示了这种异步承诺管道:
function randomFile(listFile) {
return textFile(listFile)
.then(content => content.trim().split("\n"))
.then(ls => ls[Math.floor(Math.random() * ls.length)])
.then(filename => textFile(filename));
}
该函数返回这一系列then调用的结果。初始promise以字符串形式获取文件列表。第一次then调用将该字符串转换为行数组,从而产生一个新的promise。第二次then调用从中随机选择一行,产生一个返回单个文件名的第三个promise。最终的then调用读取这个文件,因此该函数的整体结果是一个返回随机文件内容的promise。
在这段代码中,前两个then调用中使用的函数返回一个常规值,该值将在函数返回时立即传递给then返回的promise。最后一次then调用返回一个promise(textFile(filename)),使其成为一个实际的异步步骤。
也可以在单个then回调中执行所有这些步骤,因为实际上只有最后一步是异步的。但那种仅执行某些同步数据转换的then包装器通常是有用的,例如,当你想返回一个生成某些异步结果处理版本的promise时。
function jsonFile(filename) {
return textFile(filename).then(JSON.parse);
}
jsonFile("package.json").then(console.log);
通常,将promise视为一种设备是有益的,它使代码可以忽略值何时到达的问题。正常值必须在我们引用它之前实际存在。承诺的值是一个可能已经存在或者可能在未来某个时刻出现的值。通过将它们与then调用连接在一起定义的基于promise的计算,随着输入变得可用而异步执行。
失败
常规的JavaScript计算可能通过抛出异常而失败。异步计算通常需要类似的机制。网络请求可能失败,文件可能不存在,或者某个属于异步计算的代码可能抛出异常。
回调风格的异步编程面临的最紧迫问题之一是,它使得确保失败正确报告给回调变得极其困难。
一个常见的约定是使用回调的第一个参数来指示操作失败,第二个参数用来传递操作成功时产生的值。
someAsyncFunction((error, value) => {
if (error) handleError(error);
else processValue(value);
});
这样的回调函数必须始终检查是否收到异常,并确保它们引起的任何问题,包括它们调用的函数抛出的异常,都被捕获并传递给正确的函数。
Promises使这变得更简单。它们可以被解决(操作成功完成)或被拒绝(失败)。解决处理程序(如通过then注册的)仅在操作成功时调用,而拒绝会传播到then返回的新promise。当处理程序抛出异常时,这会自动导致其then调用生成的promise被拒绝。如果异步操作链中的任何元素失败,整个链的结果将被标记为拒绝,并且在失败点之后不会调用任何成功处理程序。
就像解析承诺提供一个值一样,拒绝承诺也提供一个值,通常称为拒绝的原因。当处理程序函数中的异常导致拒绝时,异常值被用作原因。同样,当处理程序返回一个被拒绝的承诺时,该拒绝会流入下一个承诺。有一个Promise.reject函数,可以创建一个新的、立即被拒绝的承诺。
为了显式处理这样的拒绝,承诺有一个catch方法,用于注册在承诺被拒绝时调用的处理程序,类似于then处理程序处理正常解析的方式。它也非常像then,因为它返回一个新的承诺,该承诺在原承诺正常解析时解析为原承诺的值,而在其他情况下解析为catch处理程序的结果。如果catch处理程序抛出错误,新的承诺也会被拒绝。
作为一种简写,then也接受一个拒绝处理程序作为第二个参数,因此你可以在一次方法调用中安装两种类型的处理程序:.then(acceptHandler, rejectHandler)。
传递给Promise构造函数的函数接收第二个参数,除了resolve函数外,它可以用来拒绝新的承诺。
当我们的readTextFile函数遇到问题时,它将错误作为第二个参数传递给回调函数。我们的textFile包装器实际上应该检查该参数,以确保失败导致返回的承诺被拒绝。
function textFile(filename) {
return new Promise((resolve, reject) => {
readTextFile(filename, (text, error) => {
if (error) reject(error);
else resolve(text);
});
});
}
通过调用then和catch创建的承诺值链形成了一条管道,异步值或失败通过这条管道传递。由于这样的链是通过注册处理程序创建的,因此每个链接都有一个成功处理程序或拒绝处理程序(或两者都有)。不匹配结果类型(成功或失败)的处理程序会被忽略。匹配的处理程序会被调用,其结果决定了接下来是什么样的值——当它们返回非承诺值时为成功,当它们抛出异常时为拒绝,而当它们返回一个承诺时则为承诺的结果。
new Promise((_, reject) => reject(new Error("Fail")))
.then(value => console.log("Handler 1:", value))
.catch(reason => {
console.log("Caught failure " + reason);
return "nothing";
})
.then(value => console.log("Handler 2:", value));
// → Caught failure Error: Fail
// → Handler 2: nothing
第一个then处理程序函数没有被调用,因为在管道的那个点上,承诺持有一个拒绝。catch处理程序处理该拒绝并返回一个值,该值被传递给第二个then处理程序函数。
就像未捕获的异常由环境处理一样,JavaScript环境可以检测到承诺拒绝未被处理的情况,并将其报告为错误。
卡拉
在柏林是一个阳光明媚的日子。废弃机场的跑道上挤满了骑自行车和滑轮滑的运动员。在一个垃圾容器附近的草地上,一群乌鸦吵闹地聚在一起,试图说服一群游客放弃他们的三明治。
一只乌鸦十分显眼——一只毛发蓬乱的大雌鸟,右翅膀上有几根白色羽毛。她用一种技巧和自信吸引人们,似乎已经做了很长时间。当一位老年人被另一只乌鸦的antics分散注意力时,她悄然俯冲而下,从他手中抢走半个吃剩的面包,飞走了。
与其他看似乐于在这里消磨时间的鸟儿不同,这只大乌鸦显得目标明确。她带着战利品,径直飞向机库的屋顶,消失在通风口中。
在大楼内部,你可以听到一种奇怪的敲击声——柔和而持续。声音来自一个未完工楼梯间屋顶下的狭小空间。乌鸦坐在那里,周围是一堆偷来的零食,半打智能手机(其中几部已经开机),以及一堆电缆。她用嘴快速敲击其中一部手机的屏幕。字词正在上面出现。如果你不太了解,你可能会认为她在打字。
这只乌鸦在同伴中被称为“cāāw-krö”。但由于这些声音不适合人类的声带,我们就称她为卡拉。
卡拉是一只有些特别的乌鸦。年轻时,她对人类语言着迷,常常偷听人们的谈话,直到她很好地掌握了他们在说什么。后来,她的兴趣转向了人类技术,开始偷手机来研究。她目前的项目是学习编程。她在隐秘实验室中输入的文本实际上是一段异步JavaScript代码。
破门而入
卡拉喜欢上网。令人烦恼的是,她正在使用的手机即将耗尽预付数据。大楼内有无线网络,但需要密码才能访问。
幸运的是,大楼里的无线网络路由器已有 20 年历史,并且安全性差。经过一些研究,卡拉发现网络认证机制有一个她可以利用的漏洞。当加入网络时,设备必须发送正确的六位数密码。接入点会根据提供的密码是否正确来回复成功或失败的消息。然而,当发送部分密码(例如,仅三个数字)时,响应会根据这些数字是否为密码的正确开头而不同。发送错误的数字会立即返回失败消息。发送正确的数字时,接入点会等待更多的数字。
这使得大大加快猜测数字的速度成为可能。卡拉可以通过逐个尝试每个数字来找到第一个数字,直到找到一个不会立即返回失败的数字。得知一个数字后,她可以用同样的方法找到第二个数字,依此类推,直到她知道整个密码。
假设Carla有一个joinWifi函数。给定网络名称和密码(作为字符串),该函数尝试加入网络,返回一个如果成功则解析的Promise,如果身份验证失败则拒绝的Promise。她需要的第一件事是一个包装Promise的方法,以便在耗时过长后自动拒绝,这样如果接入点没有响应,程序就能迅速继续。
function withTimeout(promise, time) {
return new Promise((resolve, reject) => {
promise.then(resolve, reject);
setTimeout(() => reject("Timed out"), time);
});
}
这利用了Promise只能被解析或拒绝一次的事实。如果作为参数传入的Promise先解析或拒绝,那么该结果将是withTimeout返回的Promise的结果。另一方面,如果setTimeout先触发并拒绝了Promise,那么任何进一步的解析或拒绝调用都会被忽略。
为了找到整个密码,程序需要通过尝试每个数字来反复寻找下一个数字。如果身份验证成功,我们知道找到了我们要找的。如果立即失败,我们知道该数字是错误的,必须尝试下一个数字。如果请求超时,我们找到了另一个正确的数字,必须继续添加另一个数字。
因为你不能在for循环内等待一个Promise,卡拉使用一个递归函数来驱动这个过程。在每次调用中,这个函数获取当前已知的代码以及要尝试的下一个数字。根据发生的情况,它可能返回一个完成的代码,或者再次调用自己,开始破解代码的下一个位置,或用另一个数字重试。
function crackPasscode(networkID) {
function nextDigit(code, digit) {
let newCode = code + digit;
return withTimeout(joinWifi(networkID, newCode), 50)
.then(() => newCode)
.catch(failure => {
if (failure == "Timed out") {
return nextDigit(newCode, 0);
} else if (digit < 9) {
return nextDigit(code, digit + 1);
} else {
throw failure;
}
});
}
return nextDigit("", 0);
}
接入点通常在大约20毫秒内响应错误的身份验证请求,因此为了安全起见,该函数在请求超时前等待50毫秒。
crackPasscode("HANGAR 2").then(console.log);
// → 555555
Carla侧着头叹气。如果代码再难一些,她会觉得更满意。
async函数
即使有了Promise,这种异步代码依然令人厌烦。Promise通常需要以冗长且看似任意的方式连接在一起。为了创建一个异步循环,Carla被迫引入了递归函数。
破解函数实际上做的事情是完全线性的——它总是等待上一个操作完成后再开始下一个。这在同步编程模型中会非常简单地表达。
好消息是JavaScript允许你编写伪同步代码来描述异步计算。async函数隐式返回一个Promise,并且可以在其主体中以看似同步的方式等待其他Promise。
我们可以像这样重写crackPasscode:
async function crackPasscode(networkID) {
for (let code = "";;) {
for (let digit = 0;; digit++) {
let newCode = code + digit;
try {
await withTimeout(joinWifi(networkID, newCode), 50);
return newCode;
} catch (failure) {
if (failure == "Timed out") {
code = newCode;
break;
} else if (digit == 9) {
throw failure;
}
}
}
}
}
这个版本更清楚地展示了函数的双重循环结构(内循环尝试数字0到9,外循环向密码中添加数字)。
一个异步函数通过在函数关键字前加上async来标记。方法也可以通过在其名称前加上async来变为异步。当这样的函数或方法被调用时,它返回一个promise。只要函数返回某个值,该promise就会被解决。如果函数体抛出异常,promise将被拒绝。
在异步函数内部,单词await可以放在一个表达式前面,以等待promise解决,然后再继续执行函数。如果promise被拒绝,则在await点会引发异常。
这样的函数不再像常规JavaScript函数那样从开始到结束一次性运行。相反,它可以在任何有await的点被冻结,并在稍后的时间恢复执行。
对于大多数异步代码,这种记法比直接使用promises更方便。你仍然需要理解promises,因为在许多情况下你仍会直接与它们交互。但在将它们组合在一起时,async函数通常比一连串的then调用更容易编写。
生成器
函数被暂停然后重新恢复的能力并不是异步函数所独有的。JavaScript还有一个称为生成器函数的特性。这些函数类似,但没有promises。
当你用function*定义一个函数(在单词function后加上星号)时,它变成一个生成器。当你调用生成器时,它返回一个迭代器,这在第六章中我们已经看到了。
function* powers(n) {
for (let current = n;; current *= n) {
yield current;
}
}
for (let power of powers(3)) {
if (power > 50) break;
console.log(power);
}
// → 3
// → 9
// → 27
最初,当你调用powers时,函数在开始时被冻结。每次你在迭代器上调用next时,函数会运行直到遇到一个yield表达式,这会暂停它,并使得yield的值成为迭代器产生的下一个值。当函数返回时(示例中的函数从未返回),迭代器完成。
当你使用生成器函数时,编写迭代器通常要容易得多。Group类的迭代器(来自第六章的练习)可以用这个生成器来编写:
Group.prototype[Symbol.iterator] = function*() {
for (let i = 0; i < this.members.length; i++) {
yield this.members[i];
}
};
不再需要创建一个对象来保存迭代状态——生成器会在每次yield时自动保存它们的局部状态。
这样的yield表达式只能直接出现在生成器函数本身,而不能在你在其中定义的内部函数中。生成器在yield时保存的状态只是它的局部环境和它yield的那个位置。
异步函数是一种特殊类型的生成器。它在被调用时产生一个promise,当它返回(完成)时,该promise被解决,当它抛出异常时,该promise被拒绝。每当它yield(await)一个promise时,该promise的结果(值或抛出的异常)就是await表达式的结果。
一个科维德艺术项目
一天早上,卡拉被机库外面陌生的噪音吵醒。她跳到屋顶边缘,看到人们正在为某个活动做准备。周围有很多电缆,一个舞台,还有一些正在搭建的巨大黑墙。
作为一只好奇的乌鸦,卡拉仔细观察这面墙。它似乎由许多大型带玻璃前面的设备组成,并连接到电缆上。设备背面标示着LedTec SIG-5030。
一次快速的网络搜索找到了这些设备的用户手册。它们似乎是交通标志,配有可编程的琥珀色LED灯矩阵。人类的意图可能是在事件期间在上面显示某种信息。有趣的是,这些屏幕可以通过无线网络进行编程。它们是否连接到了建筑的本地网络?
网络上的每个设备都有一个IP 地址,其他设备可以用它来向其发送消息。我们在第十三章中对此进行了更多讨论。卡拉注意到她自己的手机都获得了像10.0.0.20或10.0.0.33这样的地址。尝试向所有这些地址发送消息,看看是否有一个响应手册中描述的接口,可能值得一试。
第十八章展示了如何在真实网络上发出真实请求。在这一章中,我们将使用一个名为request的简化虚拟函数进行网络通信。该函数接受两个参数——一个网络地址和一条消息,消息可以是任何可以作为JSON发送的内容——并返回一个承诺,要么解析为来自给定地址的机器的响应,要么在出现问题时拒绝。
根据手册,通过向SIG-5030标志发送内容为{"command": "display", "data": [0, 0, 3, ...]}的消息,可以改变显示的内容,其中数据为每个 LED 点提供一个数字,表示其亮度——0表示关闭,3表示最大亮度。每个标志宽50个灯,高30个灯,因此更新命令应该发送1,500个数字。
这段代码向本地网络上的所有地址发送显示更新消息,以查看哪个有效。IP 地址中的每个数字可以在0到255之间变化。在它发送的数据中,激活与网络地址最后一个数字对应的多个灯光。
for (let addr = 1; addr < 256; addr++) {
let data = [];
for (let n = 0; n < 1500; n++) {
data.push(n < addr ? 3 : 0);
}
let ip = `10.0.0.${addr}`;
request(ip, {command: "display", data})
.then(() => console.log(`Request to ${ip} accepted`))
.catch(() => {});
}
由于大多数这些地址不存在或不接受此类消息,捕捉调用确保网络错误不会使程序崩溃。所有请求立即发送,而不等待其他请求完成,以免在某些机器未响应时浪费时间。
扫描网络后,卡拉回到外面查看结果。令她高兴的是,所有屏幕的左上角都显示了一条光带。它们确实在本地网络上,并且确实接受命令。她迅速记录下每个屏幕上显示的数字。有九个屏幕,排列成三行三列。它们的网络地址如下:
const screenAddresses = [
"10.0.0.44", "10.0.0.45", "10.0.0.41",
"10.0.0.31", "10.0.0.40", "10.0.0.42",
"10.0.0.48", "10.0.0.47", "10.0.0.46"
];
现在这为各种捣蛋行为打开了可能性。她可以在墙上用巨大的字母展示“乌鸦统治,人类流口水”。但这感觉有点粗糙。相反,她计划在晚上展示一段飞翔的乌鸦视频,覆盖所有屏幕。
Carla找到了一段合适的视频剪辑,其中可以重复一秒半的镜头,以创建一个循环视频,展示乌鸦的翅膀拍打。为了适应九个屏幕(每个屏幕可以显示50*×*30像素),Carla裁剪并调整视频大小,得到一系列150*×*90的图像,每秒10帧。然后将这些图像切割成九个矩形,并进行处理,使视频中的黑暗区域(乌鸦所在处)显示明亮的光,而光亮区域(没有乌鸦)保持黑暗,这应该能产生乌鸦在黑色背景下飞翔的琥珀色效果。
她已设置clipImages变量,以保存一个帧的数组,其中每一帧由九组像素数组表示——每个屏幕一组——以所需的格式表示。
为了显示视频的单个帧,Carla需要同时向所有屏幕发送请求。但她还需要等待这些请求的结果,以便在当前帧正确发送之前不开始发送下一帧,并注意请求何时失败。
Promise有一个静态方法all,可以将一个promise数组转换为一个解析为结果数组的单一promise。这提供了一种方便的方式,使一些异步操作能够并行进行,等待它们全部完成,然后对它们的结果进行处理(或者至少等待它们以确保它们不会失败)。
function displayFrame(frame) {
return Promise.all(frame.map((data, i) => {
return request(screenAddresses[i], {
command: "display",
data
});
}));
}
这映射了帧中的图像(这是一个显示数据数组的数组),以创建一个请求promise的数组。然后它返回一个组合所有这些promise的promise。
为了能够停止正在播放的视频,该过程被封装在一个类中。这个类有一个异步的播放方法,返回一个仅在通过停止方法再次停止播放时才会解析的promise。
function wait(time) {
return new Promise(accept => setTimeout(accept, time));
}
class VideoPlayer {
constructor(frames, frameTime) {
this.frames = frames;
this.frameTime = frameTime;
this.stopped = true;
}
async play() {
this.stopped = false;
for (let i = 0; !this.stopped; i++) {
let nextFrame = wait(this.frameTime);
await displayFrame(this.frames[i % this.frames.length]);
await nextFrame;
}
}
stop() {
this.stopped = true;
}
}
wait函数将setTimeout包装在一个promise中,该promise在给定的毫秒数后解析。这对于控制播放速度非常有用。
let video = new VideoPlayer(clipImages, 100);
video.play().catch(e => {
console.log("Playback failed: " + e);
});
setTimeout(() => video.stop(), 15000);
在屏幕墙存在的整个星期,每晚,当天黑时,一个巨大的发光橙色鸟神秘地出现在上面。
事件循环
一个异步程序通过运行其主脚本开始,这通常会设置回调以便稍后调用。该主脚本以及回调会以一整块完成,不会被打断。但它们之间,程序可能会处于闲置状态,等待某些事情发生。
因此,回调并不是由调度它们的代码直接调用。如果我在一个函数中调用setTimeout,那么在回调函数被调用时,该函数将已经返回。当回调返回时,控制权不会返回到调度它的函数。
异步行为发生在它自己空的函数调用栈上。这是没有promise时,跨异步代码管理异常如此困难的原因之一。由于每个回调开始时栈几乎是空的,当它们抛出异常时,你的catch处理程序不会在栈上。
try {
setTimeout(() => {
throw new Error("Woosh");
}, 20);
} catch (e) {
// This will not run
console.log("Caught", e);
}
无论事件——如超时或传入请求——发生得多么紧密,JavaScript 环境一次只能运行一个程序。你可以把它看作是在你的程序周围运行一个大循环,称为事件循环。当没有事情可做时,该循环会暂停。但是随着事件的到来,它们会被添加到队列中,代码会一个接一个地执行。因为没有两个事情可以同时运行,运行缓慢的代码可能会延迟处理其他事件。
这个例子设置了一个超时,但随后拖延,直到超时预定的时间点之后,导致超时变得迟到。
let start = Date.now();
setTimeout(() => {
console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55
Promise总是作为一个新事件解析或拒绝。即使一个promise已经被解析,等待它也会导致你的回调在当前脚本完成后运行,而不是立即运行。
Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done
在后面的章节中,我们将看到在事件循环上运行的各种其他类型的事件。
异步错误
当你的程序同步运行时,一次性完成,除了程序自身进行的状态变化之外,没有其他状态变化。对于异步程序来说,这种情况不同——它们在执行过程中可能会有空隙,其他代码可以在这些空隙中运行。
让我们看一个例子。这是一个尝试报告数组中每个文件大小的函数,确保同时读取它们,而不是按顺序读取。
async function fileSizes(files) {
let list = "";
await Promise.all(files.map(async fileName => {
list += fileName + ": " +
(await textFile(fileName)).length + "\n";
}));
return list;
}
async fileName =>部分展示了如何通过在箭头函数前面加上async关键字来使箭头函数也变为异步。
代码乍一看并没有什么可疑之处……它对名称数组映射异步箭头函数,创建一个promises数组,然后使用Promise.all等待所有这些,才返回它们构建的列表。
但这个程序完全有问题。它总是只返回一行输出,列出读取时间最长的文件。
你能找出原因吗?
问题出在+=操作符上,它在语句开始执行时取list的当前值,然后在await完成时,将list绑定设置为该值加上添加的字符串。
但在语句开始执行和结束之间,有一个异步的空隙。映射表达式在列表中添加任何内容之前就运行,因此每个+=操作符都是从一个空字符串开始,最后在存储检索完成时,将list设置为将其行添加到空字符串的结果。
这本可以通过返回映射promises的行并在Promise.all的结果上调用join来轻松避免,而不是通过更改绑定来构建列表。像往常一样,计算新值比更改现有值更不易出错。
async function fileSizes(files) {
let lines = files.map(async fileName => {
return fileName + ": " +
(await textFile(fileName)).length;
});
return (await Promise.all(lines)).join("\n");
}
像这样的错误很容易出现,特别是在使用await时,你应该意识到代码中的漏洞所在。JavaScript的显式异步性(无论是通过回调、Promise还是await)的一大优点是,发现这些漏洞相对简单。
摘要
异步编程使得在不冻结整个程序的情况下,表达对长时间运行操作的等待成为可能。JavaScript环境通常使用回调实现这种编程风格,即在操作完成时调用的函数。事件循环会调度这些回调在合适的时候依次调用,以确保它们的执行不会重叠。
Promise使得异步编程变得更简单,Promise是代表可能在未来完成的操作的对象,而异步函数则允许你像同步程序一样编写异步程序。
练习
安静的时光
卡拉实验室附近有一台通过运动传感器激活的监控摄像头。它连接到网络并在激活时开始发送视频流。因为她不想被发现,卡拉建立了一个系统,可以注意到这种无线网络流量,并在外面有活动时在她的巢穴中打开灯,以便她知道什么时候保持安静。
她还记录了摄像头被触发的时间一段时间,并希望利用这些信息来可视化一周内哪些时段通常比较安静,哪些时段则比较繁忙。日志存储在每行包含一个时间戳数字(由Date.now()返回)的文件中。
1695709940692
1695701068331
1695701189163
camera_logs.txt文件保存了日志文件的列表。编写一个异步函数activityTable(day),该函数为给定的星期几返回一个包含 24 个数字的数组,每个数字对应一天中的每个小时,表示该小时内观察到的摄像头网络流量。星期几通过数字标识,使用Date.getDay的方法,其中星期天是 0,星期六是 6。
activityGraph函数,由沙盒提供,将这样的表汇总为一个字符串。
要读取文件,请使用前面定义的textFile函数——给定一个文件名,它返回一个解析为文件内容的Promise。记住,new Date(*timestamp*)会为该时间创建一个Date对象,该对象有getDay和getHours方法,分别返回星期几和小时。
两种类型的文件——日志文件列表和日志文件本身——每一条数据都在自己的行上,通过换行符(\n)分隔。
真实的 Promise
重写之前练习中的函数,不使用async/await,使用普通的Promise方法。
在这种风格下,使用Promise.all会比尝试对日志文件建模的循环更方便。在异步函数中,简单地在循环中使用await更为简单。如果读取文件需要一些时间,哪种方法运行所需的时间最少?
如果文件列表中的某个文件有拼写错误,导致读取失败,这个失败是如何反映到你的函数返回的Promise对象中的?
构建 Promise.all
正如我们所看到的,给定一个承诺数组,Promise.all返回一个承诺,等待数组中所有承诺完成。它然后成功,返回一个结果值数组。如果数组中的一个承诺失败,所有的承诺返回的承诺也会失败,并传递失败承诺的失败原因。
自己实现类似的功能,作为一个名为Promise_all的常规函数。
请记住,在一个承诺成功或失败后,它无法再次成功或失败,对其解析的函数的进一步调用将被忽略。这可以简化你处理承诺失败的方式。
评估器,用于确定编程语言中表达式的含义,仅仅是另一个程序。
—哈尔·阿贝尔森和杰拉尔德·萨斯曼,《计算机程序的结构与解释》

第十三章:项目:一种编程语言
构建自己的编程语言出乎意料地简单(只要你不追求太高的目标),而且非常有启发性。
我在这一章中想要展示的主要内容是,构建编程语言并没有什么神秘之处。我常常觉得某些人类发明如此聪明复杂,以至于我永远无法理解它们。但经过一点阅读和实验,它们往往显得相当平凡。
我们将构建一种名为Egg的编程语言。它将是一个小而简单的语言——但足够强大,能够表达你能想到的任何计算。它将允许基于函数的简单抽象。
解析
编程语言最明显的部分是它的语法或符号。解析器是一个读取文本并生成反映该文本中程序结构的数据结构的程序。如果文本未形成有效的程序,解析器应该指出错误。
我们的语言将拥有简单且统一的语法。在Egg中,一切都是表达式。表达式可以是绑定的名称、数字、字符串或应用。应用用于函数调用,也用于如if或while等结构。
为了保持解析器的简单性,Egg中的字符串不支持反斜杠转义之类的功能。字符串只是一个不包含双引号的字符序列,用双引号括起来。数字是一个数字字符的序列。绑定名称可以由任何非空白字符组成,并且在语法中没有特殊含义。
应用的写法与JavaScript相同,在表达式后放置括号,并在这些括号之间放入任意数量的参数,用逗号分隔。
do(define(x, 10),
if(>(x, 5),
print("large"),
print("small")))
Egg语言的统一性意味着JavaScript中的运算符(如>)在该语言中是普通绑定,与其他函数一样被应用。由于语法没有块的概念,我们需要一个do结构来表示顺序执行多个操作。
解析器将用来描述程序的数据结构由表达式对象组成,每个对象都有一个类型属性,指示它是哪种表达式,以及其他属性来描述其内容。
类型为“值”的表达式表示字面字符串或数字。它们的值属性包含它们所表示的字符串或数字值。类型为“单词”的表达式用于标识符(名称)。这样的对象有一个名称属性,作为字符串保存标识符的名称。最后,“应用”表达式代表应用。它们有一个操作符属性,指向被应用的表达式,以及一个args属性,保存一个参数表达式的数组。
前一个程序中的>(x, 5)部分将表示为:
{
type: "apply",
operator: {type: "word", name: ">"},
args: [
{type: "word", name: "x"},
{type: "value", value: 5}
]
}
这样的数据结构被称为语法树。如果你想象这些对象为点,而它们之间的链接为这些点之间的线,如下图所示,结构呈现树状形状。表达式包含其他表达式,而这些表达式又可能包含更多的表达式,这类似于树枝的分叉和再分叉的方式。

将其与我们在第九章中为配置文件格式编写的解析器进行对比,该解析器结构简单:它将输入分割成行,并逐行处理。允许每行的形式仅有几种简单的类型。
在这里,我们必须找到一种不同的方法。表达式不是按行分隔的,而且它们具有递归结构。应用表达式包含其他表达式。
幸运的是,这个问题可以通过编写一个递归的解析函数来很好地解决,反映出语言的递归特性。
我们定义了一个函数parseExpression,它接受一个字符串作为输入。它返回一个包含字符串开头表达式的数据结构的对象,以及解析完该表达式后剩余的字符串部分。在解析子表达式(例如应用的参数)时,可以再次调用该函数,从而得到参数表达式以及剩余的文本。该文本可能包含更多的参数,或者可能是结束参数列表的右括号。
这是解析器的第一部分:
function parseExpression(program) {
program = skipSpace(program);
let match, expr;
if (match = /^"([^"]*)"/.exec(program)) {
expr = {type: "value", value: match[1]};
} else if (match = /^\d+\b/.exec(program)) {
expr = {type: "value", value: Number(match[0])};
} else if (match = /^[^\s(),#"]+/.exec(program)) {
expr = {type: "word", name: match[0]};
} else {
throw new SyntaxError("Unexpected syntax: " + program);
}
return parseApply(expr, program.slice(match[0].length));
}
function skipSpace(string) {
let first = string.search(/\S/);
if (first == -1) return "";
return string.slice(first);
}
因为Egg(像JavaScript一样)允许元素之间有任意数量的空白,我们必须反复从程序字符串的开头去掉空白。skipSpace函数对此提供了帮助。
跳过任何前导空格后,parseExpression使用三个正则表达式来识别Egg支持的三种基本元素:字符串、数字和单词。解析器根据匹配的表达式构造不同类型的数据结构。如果输入不符合这三种形式之一,则不是有效表达式,解析器会抛出错误。我们在这里使用SyntaxError构造函数。这是一个由标准定义的异常类,类似于Error,但更加具体。
然后我们从程序字符串中截去匹配的部分,并将其与表达式对象一起传递给parseApply,后者检查该表达式是否为应用。如果是,它将解析一个括号内的参数列表。
function parseApply(expr, program) {
program = skipSpace(program);
if (program[0] != "(") {
return {expr: expr, rest: program};
}
program = skipSpace(program.slice(1));
expr = {type: "apply", operator: expr, args: []};
while (program[0] != ")") {
let arg = parseExpression(program);
expr.args.push(arg.expr);
program = skipSpace(arg.rest);
if (program[0] == ",") {
program = skipSpace(program.slice(1));
} else if (program[0] != ")") {
throw new SyntaxError("Expected ',' or ')'");
}
}
return parseApply(expr, program.slice(1));
}
如果程序中的下一个字符不是左括号,这就不是一个应用,parseApply返回它所给出的表达式。否则,它会跳过左括号,并为这个应用表达式创建语法树对象。然后,它递归调用parseExpression以解析每个参数,直到找到右括号。递归是间接的,通过parseApply和parseExpression相互调用。
因为应用表达式本身可以被应用(例如在multiplier(2)(1)中),所以parseApply在解析完一个应用后必须再次调用自身,以检查是否跟随另一个括号。
这就是解析Egg所需的一切。我们将其包装在一个方便的解析函数中,该函数在解析表达式后验证是否已经到达输入字符串的末尾(一个Egg程序是一个单一的表达式),这为我们提供了程序的数据结构。
function parse(program) {
let {expr, rest} = parseExpression(program);
if (skipSpace(rest).length > 0) {
throw new SyntaxError("Unexpected text after program");
}
return expr;
}
console.log(parse("+(a, 10)"));
// → {type: "apply",
// operator: {type: "word", name: "+"},
// args: [{type: "word", name: "a"},
// {type: "value", value: 10}]}
它有效!当它失败时并没有提供非常有用的信息,也没有存储每个表达式开始时的行和列,这在稍后报告错误时可能会很有帮助,但对于我们的目的来说已经足够了。
求值器
我们可以用程序的语法树做什么?当然是运行它!这就是求值器所做的。你给它一个语法树和一个将名称与值关联的作用域对象,它将评估树所代表的表达式并返回产生的值。
const specialForms = Object.create(null);
function evaluate(expr, scope) {
if (expr.type == "value") {
return expr.value;
} else if (expr.type == "word") {
if (expr.name in scope) {
return scope[expr.name];
} else {
throw new ReferenceError(
`Undefined binding: ${expr.name}`);
}
} else if (expr.type == "apply") {
let {operator, args} = expr;
if (operator.type == "word" &&
operator.name in specialForms) {
return specialFormsoperator.name;
} else {
let op = evaluate(operator, scope);
if (typeof op == "function") {
return op(...args.map(arg => evaluate(arg, scope)));
} else {
throw new TypeError("Applying a non-function.");
}
}
}
}
求值器为每种表达式类型都有代码。字面值表达式产生其值。(例如,表达式100评估为数字100。)对于绑定,我们必须检查它是否在作用域中实际定义,如果是,则获取绑定的值。
应用程序更复杂。如果它们是特殊形式,例如if,我们不评估任何内容——我们只是将参数表达式与作用域一起传递给处理此形式的函数。如果它是普通调用,我们评估运算符,验证它是一个函数,然后用评估后的参数调用它。
我们使用普通的JavaScript函数值来表示Egg的函数值。当定义特殊形式fun时我们会再回来讨论这一点。
求值的递归结构类似于解析器的结构,两者都反映了语言本身的结构。将解析器和求值器合并为一个函数并在解析过程中进行求值也是可能的,但这样拆分使程序更清晰、更灵活。
这实际上就是解释Egg所需的一切。就是这么简单。但如果不定义一些特殊形式并向环境中添加一些有用的值,你在这个语言中也做不了多少。
特殊形式
specialForms对象用于在Egg中定义特殊语法。它将词与评估这些形式的函数关联。目前它是空的。让我们添加if。
specialForms.if = (args, scope) => {
if (args.length != 3) {
throw new SyntaxError("Wrong number of args to if");
} else if (evaluate(args[0], scope) !== false) {
return evaluate(args[1], scope);
} else {
return evaluate(args[2], scope);
}
};
Egg的if构造期待恰好三个参数。它将评估第一个,如果结果不是值false,则将评估第二个。否则,评估第三个。这个if形式更类似于JavaScript的三元运算符?:而不是JavaScript的if。它是一个表达式,而不是语句,并且产生一个值——即第二或第三个参数的结果。
Egg在处理if的条件值时也与JavaScript不同。它只会将值false视为false,而不是像零或空字符串这样的东西。
我们需要将if表示为特殊形式而不是普通函数的原因是,所有函数的参数在调用函数之前都会被评估,而if应该只评估它的第二个或第三个参数,这取决于第一个参数的值。
while形式类似。
specialForms.while = (args, scope) => {
if (args.length != 2) {
throw new SyntaxError("Wrong number of args to while");
}
while (evaluate(args[0], scope) !== false) {
evaluate(args[1], scope);
}
// Since undefined does not exist in Egg, we return false,
// for lack of a meaningful result
return false;
};
另一个基本构建块是do,它从上到下执行所有参数。它的值是最后一个参数产生的值。
specialForms.do = (args, scope) => {
let value = false;
for (let arg of args) {
value = evaluate(arg, scope);
}
return value;
};
为了能够创建绑定并给它们赋予新值,我们还创建了一个叫做define的形式。它的第一个参数期望一个单词,第二个参数期望一个产生赋值给该单词的表达式。由于define和其他所有内容一样,是一个表达式,因此它必须返回一个值。我们将使它返回被赋予的值(就像JavaScript的=操作符)。
specialForms.define = (args, scope) => {
if (args.length != 2 || args[0].type != "word") {
throw new SyntaxError("Incorrect use of define");
}
let value = evaluate(args[1], scope);
scope[args[0].name] = value;
return value;
};
环境
evaluate接受的作用域是一个对象,其中的属性名对应于绑定名称,而属性值对应于这些绑定所绑定的值。让我们定义一个对象来表示全局作用域。
为了能够使用我们刚定义的if构造,我们必须能够访问布尔值。由于只有两个布尔值,我们不需要为它们提供特殊的语法。我们简单地将两个名称绑定到值true和false,并使用它们。
const topScope = Object.create(null);
topScope.true = true;
topScope.false = false;
现在我们可以评估一个简单的表达式,它对布尔值取反。
let prog = parse(`if(true, false, true)`);
console.log(evaluate(prog, topScope));
// → false
为了提供基本的算术和比较运算符,我们还会将一些函数值添加到作用域中。为了保持代码简洁,我们将使用Function在一个循环中合成一组运算符函数,而不是单独定义它们。
for (let op of ["+", "-", "*", "/", "==", "<", ">"]) {
topScope[op] = Function("a, b", `return a ${op} b;`);
}
也很有用的是有一种输出值的方法,因此我们将console.log包裹在一个函数中并将其命名为print。
topScope.print = value => {
console.log(value);
return value;
};
这给了我们足够的基本工具来编写简单的程序。下面的函数提供了一种方便的方式来解析程序并在新的作用域中运行它:
function run(program) {
return evaluate(parse(program), Object.create(topScope));
}
我们将使用对象原型链来表示嵌套作用域,以便程序可以向其局部作用域添加绑定,而不改变顶层作用域。
run(`
do(define(total, 0),
define(count, 1),
while(<(count, 11),
do(define(total, +(total, count)),
define(count, +(count, 1)))),
print(total))
`);
// → 55
这是我们之前多次看到的程序,它计算从1到10的数字之和,用Egg表达。显然,它比等效的JavaScript程序更丑陋,但对于一个实现少于150行代码的语言来说,这并不算坏。
函数
没有函数的编程语言确实是个糟糕的编程语言。幸运的是,添加一个函数构造并不困难,它将最后一个参数视为函数体,并使用之前的所有参数作为函数参数的名称。
specialForms.fun = (args, scope) => {
if (!args.length) {
throw new SyntaxError("Functions need a body");
}
let body = args[args.length - 1];
let params = args.slice(0, args.length - 1).map(expr => {
if (expr.type != "word") {
throw new SyntaxError("Parameter names must be words");
}
return expr.name;
});
return function(...args) {
if (args.length != params.length) {
throw new TypeError("Wrong number of arguments");
}
let localScope = Object.create(scope);
for (let i = 0; i < args.length; i++) {
localScope[params[i]] = args[i];
}
return evaluate(body, localScope);
};
};
Egg中的函数有自己的局部作用域。由fun形式产生的函数创建这个局部作用域,并将参数绑定添加到其中。然后,它在这个作用域中评估函数体并返回结果。
run(`
do(define(plusOne, fun(a, +(a, 1))),
print(plusOne(10)))
`);
// → 11
run(`
do(define(pow, fun(base, exp,
if(==(exp, 0),
1,
*(base, pow(base, -(exp, 1)))))),
print(pow(2, 10)))
`);
// → 1024
编译
我们构建的是一个解释器。在评估过程中,它直接作用于解析器生成的程序表示。
编译是指在解析和运行程序之间增加另一步的过程,它将程序转换为可以更高效地评估的东西,尽可能多地提前完成工作。例如,在设计良好的语言中,每次使用绑定时,引用的绑定是显而易见的,而无需实际运行程序。这可以用来避免每次访问时通过名称查找绑定,而是直接从某个预定的内存位置获取。
传统上,编译涉及将程序转换为机器代码,这是计算机处理器可以执行的原始格式。但任何将程序转换为不同表示的过程都可以视为编译。
有可能为Egg编写一种替代评估策略,首先将程序转换为JavaScript程序,使用Function来调用JavaScript编译器,并运行结果。做到这一点的话,Egg将运行得非常快,同时实现起来也相当简单。
如果你对这个主题感兴趣并愿意花一些时间去研究,我鼓励你尝试实现一个这样的编译器作为练习。
作弊
当我们定义if和while时,你可能注意到它们更多的是围绕JavaScript自身的if和while的简单封装。类似地,Egg中的值只是常规的JavaScript值。将其桥接到更原始的系统,例如处理器所理解的机器代码,需要更多的努力,但其工作方式与我们在这里所做的相似。
虽然本章中的玩具语言在JavaScript中可以做得更好,但在某些情况下,编写小语言确实有助于完成实际工作。
这种语言不必类似于典型的编程语言。例如,如果JavaScript没有提供正则表达式,你可以为正则表达式编写自己的解析器和评估器。
或者想象你正在构建一个程序,使得通过提供语言的逻辑描述可以快速创建解析器。你可以为此定义一种特定的符号表示法,并编写一个将其编译为解析器程序的编译器。
expr = number | string | name | application
number = digit+
name = letter+
string = '"' (! '"')* '"'
application = expr '(' (expr (',' expr)*)? ')'
这通常被称为领域特定语言,是一种旨在表达狭窄知识领域的语言。这种语言比通用语言更具表现力,因为它专门设计用来准确描述该领域内需要描述的内容,而不是其他任何东西。
练习
数组
通过向顶层作用域添加以下三个函数来为Egg添加数组支持:array(...values)用于构造一个包含参数值的数组,length(array)用于获取数组的长度,以及element(array, n)用于从数组中获取第n个元素。
闭包
我们定义函数的方式允许Egg中的函数引用周围的作用域,使得函数体可以使用在函数定义时可见的局部值,就像JavaScript函数一样。
下面的程序说明了这一点:函数f返回一个函数,该函数将其参数与f的参数相加,这意味着它需要访问f内部的局部作用域,以便能够使用绑定a。
run(`
do(define(f, fun(a, fun(b, +(a, b)))),
print(f(4)(5)))
`);
// → 9
返回到fun形式的定义,解释是什么机制使其起作用。
注释
如果我们能在Egg中写注释,那就太好了。例如,每当我们发现一个井号(#)时,可以将该行的其余部分视为注释并忽略它,类似于JavaScript中的//。
我们不需要对解析器进行任何重大修改来支持这个功能。我们只需将skipSpace更改为像跳过空格一样跳过注释,这样所有调用skipSpace的地方现在也将跳过注释。进行此更改。
修复作用域
目前,赋予绑定一个值的唯一方法是定义。这个构造既可以定义新的绑定,也可以为现有绑定赋予一个新值。
这种模糊性造成了问题。当你尝试给非局部绑定赋予一个新值时,最终会定义一个具有相同名称的局部绑定。一些语言本身就是这样设计的,但我总觉得这种处理作用域的方式很尴尬。
添加一个特殊的形式集,类似于定义,赋予绑定一个新值,如果在内部作用域中不存在该绑定,则更新外部作用域中的绑定。如果绑定根本未定义,则抛出ReferenceError(另一种标准错误类型)。
将作用域表示为简单对象的技术,虽然到目前为止让事情变得方便,但此时会有点妨碍你。你可能想使用Object.getPrototypeOf函数,它返回一个对象的原型。同时记得可以使用Object.hasOwn来判断给定对象是否具有某个属性。
第二部分:浏览器
万维网背后的梦想是创造一个共享信息的共同信息空间,通过信息共享进行沟通。它的普遍性至关重要:超文本链接可以指向任何内容,无论是个人的、本地的还是全球的,草稿还是经过精心打磨的。
—蒂姆·伯纳斯-李,《万维网:一个非常简短的个人历史》

第十四章:JAVASCRIPT与浏览器
本书的下一章节将讨论网页浏览器。没有浏览器,就没有JavaScript——或者即使有,也不会有人关注它。
网络技术从一开始就是去中心化的,不仅在技术上如此,在其发展方式上也是如此。各种浏览器供应商以临时和有时考虑不周的方式添加了新功能,这些功能有时被其他人采纳,最终形成标准。
这既是祝福也是诅咒。一方面,系统不被中央机构控制,而是通过不同方松散合作(或偶尔公开敌对)来改进,这是令人振奋的。另一方面,网络的发展方式杂乱无章,导致最终的系统并不完全是一个内部一致性的典范。其中一些部分令人困惑且设计糟糕。
网络与互联网
计算机网络自1950年代以来就存在。如果你在两台或多台计算机之间连接电缆,并允许它们通过这些电缆来回发送数据,你就可以做各种精彩的事情。
如果在同一栋大楼中连接两台机器可以让我们做出精彩的事情,那么连接遍布全球的机器应该会更好。实现这一愿景的技术是在1980年代开发的,结果形成的网络被称为互联网。它实现了自己的承诺。
计算机可以使用这个网络向另一台计算机发送比特。要想从这种比特发送中产生有效的通信,双方的计算机必须知道这些比特所表示的内容。任何给定比特序列的含义完全取决于它试图表达的事物类型以及所使用的编码机制。
网络协议描述了网络上的一种通信方式。存在用于发送电子邮件、获取电子邮件、共享文件,甚至控制那些被恶意软件感染的计算机的协议。
超文本传输协议(HTTP)是用于检索命名资源(信息块,如网页或图片)的协议。它规定发出请求的一方应以类似这样的行开始,命名所请求的资源及其试图使用的协议版本:
GET /index.xhtml HTTP/1.1
关于请求者如何在请求中包含更多信息,以及返回资源的另一方如何打包其内容,还有许多更多规则。我们将在第十八章中详细讨论HTTP。
大多数协议都是建立在其他协议之上的。HTTP将网络视为一个流式设备,你可以将比特放入其中,并确保它们以正确的顺序到达正确的目的地。在网络提供的原始数据发送之上提供这些保证已经是一个相当棘手的问题。
传输控制协议(TCP)是解决此问题的协议。所有连接到互联网的设备都“使用”它,大多数互联网通信都是基于此构建的。
TCP连接的工作方式如下:一台计算机必须处于等待状态,或监听,以便其他计算机开始与之通信。为了能够在同一台机器上同时监听不同类型的通信,每个监听器都有一个与之关联的数字(称为端口)。大多数协议指定了默认应使用的端口。例如,当我们希望使用SMTP协议发送电子邮件时,发送电子邮件的机器应该在端口25上进行监听。
另一台计算机可以通过使用正确的端口号连接到目标机器来建立连接。如果目标机器可以访问并在该端口上监听,则连接成功建立。监听的计算机称为服务器,而连接的计算机称为客户端。
这样的连接充当了一个双向管道,数据位可以在其中流动——两端的机器都可以向其中输入数据。一旦数据位成功传输,另一侧的机器就可以再次读取。这是一个方便的模型。可以说,TCP提供了网络的抽象。
网络
万维网(不要与整体互联网混淆)是一组允许我们在浏览器中访问网页的协议和格式。Web一词指的是这些页面可以轻松相互链接,从而连接成一个庞大的网络,用户可以在其中移动。
要成为网络的一部分,你只需将一台机器连接到互联网,并让它在端口80上使用HTTP协议进行监听,以便其他计算机可以请求文档。
网络上的每个文档都有一个统一资源定位符(URL),看起来像这样:
http://eloquentjavascript.net/13_browser.xhtml
| | | |
protocol server path
URL的第一部分告诉我们该URL使用HTTP协议(与加密的HTTP,例如https://相对)。接下来是标识我们请求文档的服务器的部分。最后是一个路径字符串,标识我们感兴趣的文档(或资源)。
连接到互联网的机器会获得一个IP 地址,这是一个可用于向该机器发送消息的数字,看起来像149.210.142.219或2001:4860:4860::8888。由于随意的数字组合很难记住且输入不便,你可以注册一个域名来指向一个地址或一组地址。我注册了eloquent javascript.net,以指向我控制的机器的IP 地址,从而可以使用该域名来提供网页。
如果你在浏览器的地址栏中输入这个URL,浏览器将尝试检索并显示该URL处的文档。首先,你的浏览器必须找出eloquentjavascript.net指的是什么地址。然后,使用HTTP协议,它将与该地址的服务器建立连接,并请求资源/13_browser.xhtml。如果一切顺利,服务器会返回一个文档,浏览器随后在你的屏幕上显示该文档。
HTML
HTML,即超文本标记语言,是用于网页的文档格式。一个 HTML 文档包含文本以及*标签*,这些标签为文本提供结构,描述诸如链接、段落和标题等内容。
一个简短的 HTML 文档可能如下所示:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
在浏览器中,这样的文档可能看起来像这样:

标签用尖括号(<和>,分别表示“小于”和“大于”)包裹,提供有关文档结构的信息。其他文本则只是纯文本。
文档以<!doctype html>开始,这告诉浏览器将页面解释为*现代* HTML,而不是过去使用的过时样式。
HTML 文档有一个头部和一个主体。头部包含有关文档的信息*about*,而主体包含文档本身。在这个例子中,头部声明该文档的标题是“My home page”,并且使用 UTF-8 编码,这是一种将 Unicode 文本编码为二进制数据的方法。文档的主体包含一个标题(<h1>,意为“标题 1”——<h2>到<h6>产生子标题)和两个段落(<p>)。
标签有多种形式。一个元素,例如主体、段落或链接,是由一个*开标签*开始的,例如<p>,并由一个*闭标签*结束,例如</p>。一些开标签,例如链接的标签(<a>),包含以name="value"形式表示的额外信息。这些称为*属性*。在这种情况下,链接的目标通过href="http://eloquentjavascript.net"指示,其中href代表“超文本引用”。
某些类型的标签不包含任何内容,因此不需要闭合。元数据标签<meta charset="utf-8">就是一个例子。
为了能够在文档的文本中包含尖括号,尽管它们在 HTML 中有特殊含义,还需要引入另一种特殊表示法。一个普通的开尖括号写作<(“小于”),而闭括号写作>(“大于”)。在 HTML 中,一个与名称或字符代码和分号(;)相连的&字符称为*实体*,它将被替换为它所编码的字符。
这类似于反斜杠在 JavaScript 字符串中的用法。由于此机制也使得&字符具有特殊含义,因此它们需要被转义为&。在用双引号包裹的属性值中,可以使用"来插入字面上的引号字符。
HTML 以一种非常容错的方式进行解析。当应该存在的标签缺失时,浏览器会自动添加它们。这个过程已经标准化,你可以依赖所有现代浏览器以相同的方式来执行。
以下文档将像之前显示的文档一样处理:
<!doctype html>
<meta charset=utf-8>
<title>My home page</title>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.
<p>I also wrote a book! Read it
<a href=http://eloquentjavascript.net>here</a>.
<html>、<head>和<body>标签完全消失。浏览器知道<meta>和<title>属于头部,而<h1>表示正文已经开始。此外,我不再明确关闭段落,因为打开新段落或结束文档会隐式关闭它们。属性值周围的引号也消失了。
本书通常会在示例中省略<html>、<head>和<body>标签,以保持简洁并避免杂乱。不过,我会关闭标签并在属性周围包含引号。
我通常还会省略doctype和charset声明。不要把这当作鼓励在 HTML 文档中去掉这些的理由。当你忘记这些时,浏览器往往会做出荒谬的事情。即使在示例中没有实际显示,doctype和charset元数据也应被视为隐式存在。
HTML 和 JavaScript
在本书的上下文中,最重要的 HTML 标签是<script>,它允许我们在文档中包含一段 JavaScript。
<h1>Testing alert</h1>
<script>alert("hello!");</script>
这样的脚本将在浏览器读取 HTML 时遇到<script>标签时立即运行。打开此页面时将弹出一个对话框——alert函数类似于prompt,它会弹出一个小窗口,但只显示消息而不要求输入。
直接在 HTML 文档中包含大型程序通常不切实际。<script>标签可以设置一个src属性,从 URL 中获取一个脚本文件(包含 JavaScript 程序的文本文件)。
<h1>Testing alert</h1>
<script src="code/hello.js"></script>
这里包含的code/hello.js文件包含相同的程序——alert("hello!")。当 HTML 页面引用其他 URL 作为其一部分时,例如图像文件或脚本,网络浏览器会立即检索它们并将其包含在页面中。
脚本标签必须始终以</script>关闭,即使它引用的是脚本文件并且不包含任何代码。如果你忘记这一点,页面的其余部分将被解释为脚本的一部分。
你可以通过给<script>标签添加type="module"属性在浏览器中加载 ES 模块(见第十章)。这样的模块可以通过在导入声明中使用相对于自身的 URL 作为模块名称来依赖其他模块。
一些属性也可以包含 JavaScript 程序。<button>标签(显示为按钮)支持onclick属性。每当按钮被点击时,属性的值将被执行。
<button onclick="alert('Boom!');">DO NOT PRESS</button>
注意,我必须在onclick属性的字符串中使用单引号,因为双引号已经用来引用整个属性。我也可以使用"来转义内部引号。
在沙盒中
从互联网上下载的程序潜在地危险。你对大多数你访问的网站背后的人知之甚少,他们并不一定有好的意图。运行恶意行为者的程序就是让你的计算机感染病毒、数据被盗和账户被黑的方式。
然而,网络的吸引力在于你可以浏览它,而不必信任你访问的所有页面。这就是为什么浏览器对JavaScript程序能做的事情限制得非常严格:它不能查看你电脑上的文件,也不能修改与其嵌入的网页无关的任何内容。
以这种方式隔离编程环境称为沙箱,其理念是程序在沙箱中无害地进行操作。但你应该想象这种沙箱是有厚钢栏杆围住的,以便在其中玩耍的程序无法真正逃脱。
沙箱技术的难点在于为程序提供足够的空间以保持其有用性,同时限制其进行任何危险操作。许多有用的功能,比如与其他服务器通信或读取剪贴板的内容,也可能被用于问题性和侵犯隐私的目的。
时不时会有人提出新的方法,绕过浏览器的限制并做一些有害的事情,从泄露小的私人信息到接管运行浏览器的整个机器。浏览器开发者们会通过修补漏洞来回应,事情又恢复正常——直到下一个问题被发现,并希望这次是公开的,而不是被某个政府机构或犯罪组织秘密利用。
兼容性和浏览器战争
在网络的早期阶段,一款名为Mosaic的浏览器主导了市场。几年后,市场的平衡转向了Netscape,随后又被微软的Internet Explorer大部分取代。在单一浏览器占据主导地位的时期,该浏览器的供应商往往觉得有权单方面为网络发明新功能。由于大多数用户使用最受欢迎的浏览器,网站便开始简单地使用这些功能——而不考虑其他浏览器。
这是一段兼容性黑暗时代,通常被称为浏览器战争。网页开发者们面临的不是一个统一的网络,而是两个或三个不兼容的平台。更糟糕的是,2003年左右使用的浏览器都有很多漏洞,当然每个浏览器的漏洞也各不相同。为网页编写代码的人们的生活非常艰难。
Mozilla Firefox是Netscape的一个非营利分支,在2000年代末挑战了Internet Explorer的市场地位。因为微软当时并不特别关注保持竞争力,Firefox从其手中夺走了大量市场份额。与此同时,谷歌推出了Chrome浏览器,苹果的Safari浏览器也开始受到欢迎,导致市场上出现了四大主要玩家,而非只有一个。
新的参与者对标准和工程实践持有更加严肃的态度,从而减少了不兼容性和漏洞。微软看到其市场份额急剧下降,开始采纳这些态度,在其取代Internet Explorer的Edge浏览器中实施。如果你今天开始学习网络开发,可以认为自己很幸运。主要浏览器的最新版本表现得相当一致,且相对较少出现漏洞。
不幸的是,随着Firefox的市场份额越来越小,而Edge在2018年仅仅成为Chrome内核的外壳,这种统一性可能再次变成单一供应商——这次是谷歌——在浏览器市场上拥有足够的控制权,将其对网络的看法强加于世界其他地方。
这一系列历史事件和偶然事故造就了我们今天所拥有的网络平台。在接下来的章节中,我们将为其编写程序。
真可惜!老调重弹!一旦你建好了房子,你会发现自己意外学到了一些你本该在开始之前就知道的东西。
—弗里德里希·尼采,超越善恶

第十五章:文档对象模型
当你打开一个网页时,浏览器检索页面的 HTML 文本并解析它,就像我们在第十二章中的解析器解析程序一样。浏览器构建文档结构的模型,并使用该模型在屏幕上绘制页面。
文档的这种表示形式是 JavaScript 程序在其沙盒中可用的玩具之一。这是一个你可以读取或修改的数据结构。它作为一个实时数据结构运作:当它被修改时,屏幕上的页面会更新以反映这些更改。
文档结构
你可以将 HTML 文档想象成一组嵌套的框。像<body>和</body>这样的标签包围其他标签,这些标签又包含其他标签或文本。以下是上一章的示例文档:
<!doctype html>
<html>
<head>
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
本页面具有以下结构:

浏览器用来表示文档的数据结构遵循这种形状。对于每个框,存在一个对象,我们可以与之交互以了解它表示什么 HTML 标签以及它包含哪些框和文本。这种表示被称为文档对象模型,简称DOM。
全局绑定文档使我们可以访问这些对象。其documentElement属性指向表示<html>标签的对象。由于每个 HTML 文档都有头和主体,因此它也具有指向这些元素的head和body属性。
树
回想一下第十二章的语法树。它们的结构与浏览器文档的结构惊人地相似。每个节点可能引用其他节点,即子节点,而这些子节点又可能有自己的子节点。这种形状是嵌套结构的典型特征,其中元素可以包含与自己相似的子元素。
当数据结构具有分支结构,没有循环(一个节点不能直接或间接包含自身)并且有一个单一、明确的根时,我们称其为树。在 DOM 的情况下,document.documentElement充当根节点。
树在计算机科学中经常出现。除了表示递归结构(如 HTML 文档或程序)外,它们还常用于维护排序的数据集,因为在树中查找或插入元素通常比在平面数组中更有效。
一个典型的树具有不同类型的节点。Egg语言的语法树有标识符、值和应用节点。应用节点可以有子节点,而标识符和值则是叶子,或没有子节点的节点。
DOM 也是如此。元素的节点代表 HTML 标签,决定文档的结构。这些节点可以有子节点。一个这样的节点的例子是document.body。这些子节点中有些可以是叶子节点,例如文本片段或注释节点。
每个 DOM 节点对象都有一个nodeType属性,其中包含一个代码(数字),用以标识节点的类型。元素的代码为1,这也被定义为常量属性Node.ELEMENT_NODE。表示文档中一段文本的文本节点的代码为3(Node.TEXT_NODE)。评论的代码为8(Node.COMMENT_NODE)。
另一种可视化文档树的方法如下:

叶子是文本节点,箭头表示节点之间的父子关系。
标准
使用晦涩的数字代码来表示节点类型并不是一个非常 JavaScript 风格的做法。本章后面我们将看到,DOM 接口的其他部分也感觉笨重且陌生。这是因为 DOM 接口并不是专为 JavaScript 设计的。相反,它试图成为一个语言中立的接口,可以在其他系统中使用——不仅仅是 HTML,还包括 XML,后者是一种具有 HTML 类似语法的通用数据格式。
这很不幸。标准通常是有用的。但在这种情况下,优势(跨语言的一致性)并不是特别令人信服。拥有一个与所使用语言良好集成的接口,比在多种语言中拥有一个熟悉的接口能节省更多时间。
作为这种不佳集成的一个例子,考虑 DOM 中元素节点拥有的childNodes属性。该属性持有一个类数组对象,具有一个length属性和用数字标记的属性以访问子节点。但它是NodeList类型的实例,而不是一个真正的数组,因此没有像slice和map这样的函数。
然后还有一些问题是由于设计不佳造成的。例如,没有办法创建一个新节点并立即添加子节点或属性。相反,你必须先创建它,然后逐个添加子节点和属性,使用副作用。与 DOM 密切互动的代码往往变得冗长、重复且难以维护。
但这些缺陷并不是致命的。由于 JavaScript 允许我们创建自己的抽象,因此可以设计改进的方法来表达我们正在执行的操作。许多用于浏览器编程的库都提供了这样的工具。
在树中移动
DOM 节点包含大量链接到其他邻近节点的信息。以下图表对此进行了说明:

尽管图表仅显示了每种类型的一个链接,但每个节点都有一个parentNode属性,指向它所属的节点(如果有的话)。同样,每个元素节点(节点类型1)都有一个childNodes属性,指向一个类数组对象,包含它的子节点。
理论上,您可以仅使用这些父节点和子节点链接在树中任意移动。但是JavaScript还为您提供了许多其他方便的链接。firstChild和lastChild属性指向第一个和最后一个子元素,或者对于没有子元素的节点,其值为null。类似地,previousSibling和nextSibling指向相邻节点,即与节点本身具有相同父级并立即在其前后出现的节点。对于第一个子节点,previousSibling将为null,对于最后一个子节点,nextSibling将为null。
还有一个children属性,类似于childNodes,但仅包含元素(类型 1)子节点,而不包括其他类型的子节点。当您不关心文本节点时,这可能非常有用。
当处理像这样的嵌套数据结构时,递归函数通常很有用。以下函数扫描文档以查找包含给定字符串的文本节点,并在找到时返回true:
function talksAbout(node, string) {
if (node.nodeType == Node.ELEMENT_NODE) {
for (let child of node.childNodes) {
if (talksAbout(child, string)) {
return true;
}
}
return false;
} else if (node.nodeType == Node.TEXT_NODE) {
return node.nodeValue.indexOf(string) > -1;
}
}
console.log(talksAbout(document.body, "book"));
// → true
文本节点的nodeValue属性保存它所表示的文本字符串。
查找元素
在父节点、子节点和兄弟节点之间导航链接通常很有用。但是,如果我们想在文档中找到特定的节点,通过从document.body开始并遵循固定的属性路径来达到这个目的是一个不好的主意。这样做会在我们的程序中固化关于文档精确结构的假设,而这个结构以后可能会更改。另一个复杂因素是,即使是节点之间的空格也会创建文本节点。示例文档的<body>标签不仅有三个子节点(<h1>和两个<p>元素),而是有七个:这三个,加上它们之间的空格及其前后。
如果我们想要获取文档中链接的href属性,我们不希望说像“获取文档主体的第六个子节点的第二个子节点”。最好的方式是说“获取文档中的第一个链接”。我们可以这样做。
let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);
所有元素节点都有一个getElementsByTagName方法,该方法收集该节点的所有后代(直接或间接子节点)中具有给定标签名的元素,并将它们作为类似数组的对象返回。
要找到一个特定的单个节点,可以给它一个id属性,然后使用document.getElementById。
<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="img/ostrich.png"></p>
<script>
let ostrich = document.getElementById("gertrude");
console.log(ostrich.src);
</script>
第三个类似的方法是getElementsByClassName,它类似于getElementsByTagName,通过元素节点的内容搜索,并检索其类属性中包含给定字符串的所有元素。
更改文档
几乎可以更改DOM数据结构的所有内容。可以通过更改父子关系来修改文档树的形状。节点具有remove方法可以从当前父节点中删除它们。要将子节点添加到元素节点中,可以使用appendChild将其放在子节点列表的末尾,或者使用insertBefore将给定的第一个参数节点插入到给定的第二个参数节点之前。
<p>One</p>
<p>Two</p>
<p>Three</p>
<script>
let paragraphs = document.body.getElementsByTagName("p");
document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>
一个节点只能在文档中存在一个位置。因此,在段落One前插入段落Three将首先从文档末尾移除它,然后插入到前面,结果将是Three/One/Two。所有将节点插入某处的操作都会作为副作用使其从当前位置移除(如果它有位置的话)。
replaceChild方法用于用另一个节点替换子节点。它接受两个节点作为参数:一个新节点和要被替换的节点。被替换的节点必须是调用该方法的元素的子节点。请注意,replaceChild和insertBefore都期望新节点作为它们的第一个参数。
创建节点
假设我们想编写一个脚本,将文档中的所有图像(<img>标签)替换为它们alt属性中包含的文本,alt属性指定图像的替代文本表示。这不仅涉及移除图像,还需要添加一个新的文本节点来替代它们。
<p>The <img src="img/cat.png" alt="Cat"> in the
<img src="img/hat.png" alt="Hat">.</p>
<p><button onclick="replaceImages()">Replace</button></p>
<script>
function replaceImages() {
let images = document.body.getElementsByTagName("img");
for (let i = images.length - 1; i >= 0; i--) {
let image = images[i];
if (image.alt) {
let text = document.createTextNode(image.alt);
image.parentNode.replaceChild(text, image);
}
}
}
</script>
给定一个字符串,createTextNode会给我们一个文本节点,我们可以将其插入到文档中以使其在屏幕上显示。
遍历图像的循环从列表的末尾开始。这是必要的,因为像getElementsByTagName(或像childNodes这样的属性)返回的节点列表是实时的。也就是说,它会随着文档的变化而更新。如果我们从前面开始,移除第一个图像会导致列表失去第一个元素,因此在循环第二次重复时(当i为 1 时),它会停止,因为集合的长度现在也变为 1。
如果你想要一个固态的节点集合,而不是一个实时的集合,你可以通过调用Array.from将集合转换为一个真正的数组。
let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]
要创建元素节点,可以使用document.createElement方法。该方法接受一个标签名并返回一个给定类型的新空节点。
以下示例定义了一个工具函数elt,该函数创建一个元素节点并将其余参数视为该节点的子节点。然后使用这个函数为引文添加归属。
<blockquote id="quote">
No book can ever be finished. While working on it we learn
just enough to find it immature the moment we turn away
from it.
</blockquote>
<script>
function elt(type, ...children) {
let node = document.createElement(type);
for (let child of children) {
if (typeof child != "string") node.appendChild(child);
else node.appendChild(document.createTextNode(child));
}
return node;
}
document.getElementById("quote").appendChild(
elt("footer", "--",
elt("strong", "Karl Popper"),
", preface to the second edition of ",
elt("em", "The Open Society and Its Enemies"),
", 1950"));
</script>
这就是生成的文档的样子:

属性
一些元素属性,比如链接的href,可以通过元素DOM对象上同名的属性访问。这是大多数常用标准属性的情况。
HTML允许你在节点上设置任何你想要的属性。这很有用,因为它允许你在文档中存储额外的信息。要读取或更改自定义属性(这些属性在常规对象属性中不可用),你必须使用getAttribute和setAttribute方法。
<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>
<script>
let paras = document.body.getElementsByTagName("p");
for (let para of Array.from(paras)) {
if (para.getAttribute("data-classified") == "secret") {
para.remove();
}
}
</script>
建议将这些虚构属性的名称以data-为前缀,以确保它们不会与其他属性冲突。
有一个常用的属性class,这是JavaScript语言中的一个关键字。由于历史原因——一些旧的JavaScript实现无法处理与关键字匹配的属性名——用于访问此属性的属性被称为className。你也可以通过getAttribute和setAttribute方法以其真实名称“class”访问它。
布局
你可能注意到不同类型的元素布局方式不同。一些元素,如段落(<p>)或标题(<h1>),占据文档的整个宽度,并在单独的行上渲染。这些被称为*块*元素。其他元素,如链接(<a>)或<strong>元素,则与其周围文本在同一行上渲染。这些元素被称为*内联*元素。
对于任何给定的文档,浏览器能够计算一个布局,根据元素的类型和内容为每个元素提供大小和位置。然后,这个布局被用于实际绘制文档。
元素的大小和位置可以通过JavaScript访问。offsetWidth和offsetHeight属性告诉你元素在*像素*中占据的空间。像素是浏览器中的基本测量单位。它传统上对应于屏幕能够绘制的最小点,但在现代显示器上,它可以绘制*非常*小的点,这可能不再是事实,并且浏览器像素可能跨越多个显示点。
类似地,clientWidth和clientHeight给你提供*内部*空间的大小,忽略边框宽度。
<p style="border: 3px solid red">
I'm boxed in
</p>
<script>
let para = document.body.getElementsByTagName("p")[0];
console.log("clientHeight:", para.clientHeight);
// → 19
console.log("offsetHeight:", para.offsetHeight);
// → 25
</script>
给段落添加边框会在其周围绘制一个矩形。

找到元素在屏幕上精确位置的最有效方法是getBoundingClientRect方法。它返回一个对象,包含顶部、底部、左侧和右侧属性,指示元素相对于屏幕左上角的边缘的像素位置。如果你想要相对于整个文档的像素位置,你必须加上当前的滚动位置,可以在pageXOffset和pageYOffset绑定中找到。
布局一个文档可能需要相当多的工作。为了提高速度,浏览器引擎不会在每次更改文档时立即重新布局,而是尽可能长时间地等待。在更改文档的JavaScript程序运行结束后,浏览器将不得不计算一个新布局,以将更改后的文档绘制到屏幕上。当程序*请求*通过读取offsetHeight属性或调用getBoundingClientRect来获取某个元素的位置或大小时,提供该信息也需要计算布局。
一个不断在读取DOM布局信息和更改DOM之间交替进行的程序会迫使进行大量布局计算,因此运行会非常缓慢。以下代码就是一个例子。它包含两个不同的程序,构建一行2,000像素宽的*X*字符,并测量每个程序所需的时间。
<p><span id="one"></span></p>
<p><span id="two"></span></p>
<script>
function time(name, action) {
let start = Date.now(); // Current time in milliseconds
action();
console.log(name, "took", Date.now() - start, "ms");
}
time("naive", () => {
let target = document.getElementById("one");
while (target.offsetWidth < 2000) {
target.appendChild(document.createTextNode("X"));
}
});
// → naive took 32 ms
time("clever", function() {
let target = document.getElementById("two");
target.appendChild(document.createTextNode("XXXXX"));
let total = Math.ceil(2000 / (target.offsetWidth / 5));
target.firstChild.nodeValue = "X".repeat(total);
});
// → clever took 1 ms
</script>
样式
我们已经看到不同的HTML元素有不同的绘制方式。有些显示为块,有些则内联。有些添加样式——<strong>会使其内容变为粗体,而<a>会使其变为蓝色并加下划线。
<img>标签如何显示图像或<a>标签如何在点击时跟随链接,与元素类型密切相关。但我们可以更改与元素关联的样式,例如文本颜色或下划线。这里是一个使用样式属性的示例:
<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>
第二个链接将显示为绿色,而不是默认链接颜色。

样式属性可以包含一个或多个*声明*,声明是属性(如颜色)后跟冒号和一个值(如绿色)。当有多个声明时,它们必须用分号分隔,如“color: red; border: none”。
文档的许多方面都可以受到样式的影响。例如,display属性控制一个元素是作为块级元素还是内联元素显示。
This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.
块标签最终会单独占据一行,因为块级元素不会与周围文本内联显示。最后一个标签根本不显示——display: none阻止元素在屏幕上显示。这是一种隐藏元素的方法。通常,这种方式比将它们完全从文档中删除更可取,因为这使得以后再次显示它们变得简单。

JavaScript代码可以通过元素的样式属性直接操作元素的样式。此属性保存一个对象,该对象具有所有可能样式属性的属性。这些属性的值是字符串,我们可以通过写入它们来更改元素样式的特定方面。
<p id="para" style="color: purple">
Nice text
</p>
<script>
let para = document.getElementById("para");
console.log(para.style.color);
para.style.color = "magenta";
</script>
一些样式属性名称包含连字符,例如font-family。由于这样的属性名称在 JavaScript 中处理起来很麻烦(你必须这样写style["font-family"]),因此此类属性在样式对象中的名称去掉了连字符,并将其后面的字母大写(style.fontFamily)。
层叠样式
HTML 的样式系统称为CSS,即层叠样式表。样式表是一组关于如何为文档中的元素添加样式的规则。它可以放在<style>标签内。
<style>
strong {
font-style: italic;
color: gray;
}
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>
名称中的层叠指的是多个此类规则结合以产生元素的最终样式。在这个例子中,<strong>标签的默认样式(使其font-weight: bold)被<style>标签中的规则覆盖,后者添加了font-style和color。
当多个规则为同一属性定义值时,最近读取的规则优先级更高并获胜。例如,如果<style>标签中的规则包含font-weight: normal,与默认的font-weight规则相矛盾,文本将显示为正常的,而不是粗体。直接应用于节点的样式属性具有最高优先级,总是获胜。
在CSS规则中,可以针对除标签名以外的其他东西。规则.abc应用于所有类属性中包含“abc”的元素。规则#xyz应用于具有 id 属性“xyz”的元素(在文档中应该是唯一的)。
.subtle {
color: gray;
font-size: 80%;
}
#header {
background: blue;
color: white;
}
/* p elements with id main and with classes a and b */
p#main.a.b {
margin-bottom: 20px;
}
优先级规则偏向于最近定义的规则,仅在规则具有相同的特异性时适用。规则的特异性是衡量其描述匹配元素的精确程度,取决于它所要求的元素方面的数量和种类(标签、类或 ID)。例如,针对p.a的规则比针对p或仅.a的规则更具特异性,因此将优先于它们。
记号p > a {...}将给定的样式应用于所有直接子元素为<p>标签的<a>标签。类似地,p a {...}将应用于所有位于<p>标签内部的<a>标签,无论它们是直接还是间接子元素。
查询选择器
在本书中我们不会频繁使用样式表。理解样式表在浏览器编程时是有帮助的,但它们足够复杂,值得单独成书。我引入选择器语法(样式表中用于确定一组样式适用哪些元素的记号)的主要原因是,我们可以使用这个相同的迷你语言作为有效的方式来查找 DOM 元素。
querySelectorAll方法在文档对象和元素节点上都被定义,它接受一个选择器字符串并返回一个包含所有匹配元素的NodeList。
<p>And if you go chasing
<span class="animal">rabbits</span></p>
<p>And you know you're going to fall</p>
<p>Tell 'em a <span class="character">hookah smoking
<span class="animal">caterpillar</span></span></p>
<p>Has given you the call</p>
<script>
function count(selector) {
return document.querySelectorAll(selector).length;
}
console.log(count("p")); // All <p> elements
// → 4
console.log(count(".animal")); // Class animal
// → 2
console.log(count("p .animal")); // Animal inside of <p>
// → 2
console.log(count("p > .animal")); // Direct child of <p>
// → 1
</script>
与getElementsByTagName等方法不同,querySelectorAll返回的对象是非活跃的。它在文档更改时不会变化。不过,它仍然不是一个真正的数组,因此如果你想将其视为数组,需要调用Array.from。
querySelector方法(没有All部分)以类似的方式工作。如果你想要特定的单个元素,这个方法很有用。它只会返回第一个匹配的元素,或者在没有匹配元素时返回null。
定位与动画
position样式属性以强大的方式影响布局。其默认值为static,意味着元素在文档中的正常位置。当设置为relative时,元素仍然占据文档中的空间,但现在可以使用top和left样式属性相对于正常位置移动它。当position设置为absolute时,元素从正常文档流中移除——即它不再占据空间,并可能与其他元素重叠。其top和left属性可以用于相对于最近的包围元素的左上角进行绝对定位(该元素的position属性不能为static),或者如果没有这样的包围元素,则相对于文档进行定位。
我们可以用这个来创建动画。以下文档显示了一只在椭圆形轨道上移动的猫的图像:
<p style="text-align: center">
<img src="img/cat.png" style="position: relative">
</p>
<script>
let cat = document.querySelector("img");
let angle = Math.PI / 2;
function animate(time, lastTime) {
if (lastTime != null) {
angle += (time - lastTime) * 0.001;
}
cat.style.top = (Math.sin(angle) * 20) + "px";
cat.style.left = (Math.cos(angle) * 200) + "px";
requestAnimationFrame(newTime => animate(newTime, time));
}
requestAnimationFrame(animate);
</script>
灰色箭头显示了图像移动的路径。

我们的图片居中显示在页面上,并设置为相对定位。我们将不断更新该图片的顶部和左侧样式以使其移动。
脚本使用requestAnimationFrame来安排animate函数在浏览器准备好重新绘制屏幕时运行。animate函数本身再次调用requestAnimationFrame以安排下一次更新。当浏览器窗口(或标签页)处于活动状态时,这将导致以每秒大约60次的速率进行更新,从而产生良好的动画效果。
如果我们在一个循环中仅更新 DOM,页面将会冻结,屏幕上什么都不会显示。浏览器在 JavaScript 程序运行时不会更新显示,也不允许与页面进行任何交互。这就是我们需要requestAnimationFrame的原因——它让浏览器知道我们暂时完成了,浏览器可以继续进行其应做的事情,例如更新屏幕和响应用户操作。
动画函数接收当前时间作为参数。为了确保猫的运动在每毫秒内是稳定的,它根据当前时间与上次函数运行时间之间的差异来确定角度变化的速度。如果它只是以固定的量每步移动角度,当例如计算机上另一个繁重的任务阻止函数运行时,运动会出现卡顿。
圆周运动是通过三角函数Math.cos和Math.sin实现的。对于那些不熟悉这些函数的人,我将简要介绍一下,因为我们在本书中会偶尔使用它们。
Math.cos和Math.sin对于查找围绕点(0, 0)半径为1的圆上的点非常有用。这两个函数将它们的参数解释为圆上的位置,其中0表示圆的最右侧点,顺时针方向直到2*π(大约6.28)使我们走完整个圆。Math.cos告诉你与给定位置对应的点的x坐标,而Math.sin则返回y坐标。大于2*π或小于0的位置(或角度)都是有效的——旋转会重复,因此a + 2*π表示与a相同的角度。
用于测量角度的单位称为弧度——一个完整的圆是2*π弧度,类似于用度数测量时为360度。常数π在JavaScript中可以用Math.PI表示。

猫动画代码保持一个计数器angle,用于表示当前动画的角度,并在每次调用animate函数时递增。然后可以利用这个角度来计算图像元素的当前位置。顶部样式是通过Math.sin计算得出,并乘以20,这是我们椭圆的垂直半径。左侧样式则基于Math.cos,并乘以200,使得椭圆的宽度远大于高度。
注意,样式通常需要单位。在这种情况下,我们必须在数字后附加“px”,以告诉浏览器我们是在以像素为单位计数(而不是厘米、“ems”或其他单位)。这一点容易被忘记。使用没有单位的数字会导致你的样式被忽略——除非这个数字是0,因为无论单位是什么,0总是意味着同样的东西。
概述
JavaScript程序可以通过一个称为DOM的数据结构检查和干扰浏览器正在显示的文档。这个数据结构代表了浏览器对文档的模型,JavaScript程序可以修改它以更改可见文档。
DOM的组织结构像一棵树,元素根据文档的结构以层级方式排列。表示元素的对象具有父节点和子节点等属性,可以用来遍历这棵树。
文档的显示方式可以通过样式进行影响,包括直接将样式附加到节点以及定义匹配某些节点的规则。样式属性有很多种,例如颜色或显示。JavaScript代码可以通过其样式属性直接操作元素的样式。
练习
构建一个表格
HTML表格是通过以下标签结构构建的:
<table>
<tr>
<th>name</th>
<th>height</th>
<th>place</th>
</tr>
<tr>
<td>Kilimanjaro</td>
<td>5895</td>
<td>Tanzania</td>
</tr>
</table>
对于每个行,<table>标签包含一个<tr>标签。在这些<tr>标签内部,我们可以放置单元格元素:可以是表头单元格(<th>)或常规单元格(<td>)。
给定一个包含名称、高度和地点属性的山脉数据集,生成一个DOM结构的表格来列出这些对象。每个键对应一列,每个对象对应一行,并在顶部有一个包含<th>元素的标题行,列出列名。
这样编写,以便列自动从对象中派生,通过提取数据中第一个对象的属性名称。
通过将生成的表格附加到具有id属性为“mountains”的元素中,显示结果表格。
一旦你实现了这一点,通过将其style.textAlign属性设置为“right”,使包含数字值的单元格右对齐。
按标签名称获取元素
document.getElementsByTagName方法返回具有给定标签名的所有子元素。实现你自己的版本作为一个函数,该函数接受一个节点和一个字符串(标签名)作为参数,并返回一个包含所有具有给定标签名的后代元素节点的数组。你的函数应遍历文档本身。它可能不使用像querySelectorAll这样的方式来完成工作。
要找到元素的标签名,可以使用其nodeName属性。但请注意,这将返回全大写的标签名。可以使用toLowerCase或toUpperCase字符串方法来进行补偿。
猫的帽子
扩展之前定义的猫的动画,使得猫和他的帽子(<img src="img/hat.png">)在椭圆的两侧绕行。
或者让帽子围绕猫转动。或者以其他有趣的方式改变动画。
为了更方便地定位多个对象,你可能需要切换到绝对定位。这意味着顶部和左侧的位置是相对于文档的左上角计算的。为了避免使用负坐标,这会导致图像移动到可见页面之外,你可以在位置值中添加一个固定的像素值。
你对自己的思想拥有掌控权——而不是外部事件。认识到这一点,你将找到力量。
—马库斯·奥勒留,《沉思录》

第十六章:处理事件
一些程序处理直接的用户输入,例如鼠标和键盘操作。这种输入无法提前以良好组织的数据结构获取——它是实时逐步到达的,程序必须在发生时对此作出反应。
事件处理程序
想象一个界面,唯一知道键盘上的某个键是否被按下的方法是读取该键的当前状态。要能够对按键反应,你必须不断读取该键的状态,以便在其再次释放之前捕捉到它。进行其他耗时的计算是危险的,因为你可能会错过一个按键。
一些原始机器以这种方式处理输入。更高级的做法是让硬件或操作系统注意到按键并将其放入队列。然后程序可以定期检查队列中的新事件,并对所找到的事件作出反应。
当然,程序必须记得查看队列,并且要经常查看,因为在按键被按下和程序注意到事件之间的任何时间都会导致软件感觉无响应。这种方法称为轮询。大多数程序员倾向于避免它。
更好的机制是让系统在事件发生时主动通知代码。浏览器通过允许我们注册函数作为特定事件的处理程序来做到这一点。
<p>Click this document to activate the handler.</p>
<script>
window.addEventListener("click", () => {
console.log("You knocked?");
});
</script>
窗口绑定是浏览器提供的一个内置对象。它代表包含文档的浏览器窗口。调用它的addEventListener方法会注册第二个参数,以便在第一个参数描述的事件发生时被调用。
事件与DOM节点
每个浏览器事件处理程序在一个上下文中注册。在前面的例子中,我们在窗口对象上调用addEventListener来注册整个窗口的处理程序。这样的一个方法也可以在DOM元素和其他类型的对象上找到。事件监听器仅在事件发生在其注册对象的上下文中时被调用。
<button>Click me</button>
<p>No handler here.</p>
<script>
let button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("Button clicked.");
});
</script>
该示例将一个处理程序附加到按钮节点上。点击按钮会导致该处理程序运行,但点击文档的其他部分则不会。
给节点一个onclick属性有类似的效果。这适用于大多数类型的事件——你可以通过名称为事件名称并在前面加上on的属性来附加处理程序。
但是一个节点只能有一个onclick属性,因此你只能通过这种方式为每个节点注册一个处理程序。addEventListener方法允许你添加任意数量的处理程序,这意味着即使元素上已经有另一个处理程序,也可以安全地添加新的处理程序。
removeEventListener方法与addEventListener的参数类似,用于移除一个处理程序。
<button>Act-once button</button>
<script>
let button = document.querySelector("button");
function once() {
console.log("Done.");
button.removeEventListener("click", once);
}
button.addEventListener("click", once);
</script>
传递给removeEventListener的函数必须是传递给addEventListener的同一个函数值。当你需要注销一个处理程序时,你会想给处理程序函数一个名称(在示例中只需一次),以便能够将相同的函数值传递给这两个方法。
事件对象
尽管我们到目前为止忽略了这一点,事件处理函数会接收一个参数:事件对象。这个对象包含关于事件的附加信息。例如,如果我们想知道哪个鼠标按钮被按下,我们可以查看事件对象的button属性。
<button>Click me any way you want</button>
<script>
let button = document.querySelector("button");
button.addEventListener("mousedown", event => {
if (event.button == 0) {
console.log("Left button");
} else if (event.button == 1) {
console.log("Middle button");
} else if (event.button == 2) {
console.log("Right button");
}
});
</script>
存储在事件对象中的信息因事件类型而异。(我们将在本章后面讨论不同的类型。)对象的type属性始终保存一个字符串,用于标识事件(例如“click”或“mousedown”)。
传播
对于大多数事件类型,在具有子节点的节点上注册的处理程序也会接收子节点发生的事件。如果段落内部的按钮被点击,段落上的事件处理程序也会看到点击事件。
但是如果段落和按钮都有处理程序,更具体的处理程序——按钮上的那个——将优先执行。事件被称为从发生的节点传播到该节点的父节点,再到文档的根节点。最后,在特定节点上注册的所有处理程序轮流执行后,注册在整个窗口上的处理程序也会有机会响应该事件。
在任何时刻,事件处理程序都可以在事件对象上调用stopPropagation方法,以防止更高层的处理程序接收该事件。这在某些情况下是有用的,例如,当你在另一个可点击元素内部有一个按钮时,你不希望按钮的点击激活外部元素的点击行为。
以下示例在按钮和周围的段落上注册“mousedown”处理程序。当用右键点击时,按钮的处理程序调用stopPropagation,这将防止段落上的处理程序运行。当用其他鼠标按钮点击按钮时,两个处理程序都会运行。
<p>A paragraph with a <button>button</button>.</p>
<script>
let para = document.querySelector("p");
let button = document.querySelector("button");
para.addEventListener("mousedown", () => {
console.log("Handler for paragraph.");
});
button.addEventListener("mousedown", event => {
console.log("Handler for button.");
if (event.button == 2) event.stopPropagation();
});
</script>
大多数事件对象都有一个指向其来源节点的target属性。你可以使用这个属性确保你不会意外处理来自不想处理的节点的传播事件。
也可以使用target属性对特定类型的事件进行广泛捕获。例如,如果你有一个包含长列表按钮的节点,注册一个点击处理程序在外部节点上可能更方便,并使用target属性来判断是否点击了某个按钮,而不是在所有按钮上注册单独的处理程序。
<button>A</button>
<button>B</button>
<button>C</button>
<script>
document.body.addEventListener("click", event => {
if (event.target.nodeName == "BUTTON") {
console.log("Clicked", event.target.textContent);
}
});
</script>
默认行为
许多事件都有默认行为。如果你点击一个链接,你将被带到链接的目标。如果你按下向下箭头,浏览器会向下滚动页面。如果你右键单击,你将获得一个上下文菜单。等等。
对于大多数类型的事件,JavaScript 事件处理程序在默认行为发生之前被调用。如果处理程序不希望发生这种正常行为,通常是因为它已经处理了事件,可以在事件对象上调用preventDefault方法。
这可以用来实现你自己的键盘快捷键或上下文菜单。它也可以用来干扰用户期望的行为。例如,这里有一个无法被点击的链接:
<a href="https://developer.mozilla.org/">MDN</a>
<script>
let link = document.querySelector("a");
link.addEventListener("click", event => {
console.log("Nope.");
event.preventDefault();
});
</script>
尽量不要在没有充分理由的情况下这样做。当预期的行为被打破时,这会让使用你页面的人感到不愉快。
根据浏览器的不同,有些事件根本无法被拦截。在 Chrome 中,例如,关闭当前标签页的键盘快捷键(CTRL-W或COMMAND-W)无法通过 JavaScript 处理。
键事件
当键盘上的一个键被按下时,你的浏览器会触发一个"keydown"事件。当它被释放时,你会得到一个"keyup"事件。
<p>This page turns violet when you hold the V key.</p>
<script>
window.addEventListener("keydown", event => {
if (event.key == "v") {
document.body.style.background = "violet";
}
});
window.addEventListener("keyup", event => {
if (event.key == "v") {
document.body.style.background = "";
}
});
</script>
尽管名称如此,"keydown"不仅在键被物理按下时触发。当一个键被按下并保持时,该事件会在每次键重复时再次触发。有时你需要对此格外小心。例如,如果你在按下键时向 DOM 添加一个按钮,并在释放键时将其移除,可能在按住键的过程中意外添加数百个按钮。
上一个示例查看事件对象的key属性,以了解该事件是关于哪个键的。该属性保存一个字符串,对于大多数键,对应于按下该键时会输入的内容。对于特殊键,如ENTER,它保存一个字符串来命名该键(在本例中为"Enter")。如果你在按下键时同时按住SHIFT,这可能也会影响键的名称——"v"变为"V",而"1"可能变为"!"(如果按SHIFT-1时你键盘上产生这样的结果)。
修饰键,如SHIFT、CTRL、ALT和META(在 Mac 上为COMMAND),生成的键事件与普通键一样。当查找键组合时,你也可以通过查看键盘和鼠标事件的shiftKey、ctrlKey、altKey和metaKey属性来了解这些键是否被按下。
<p>Press Control-Space to continue.</p>
<script>
window.addEventListener("keydown", event => {
if (event.key == " " && event.ctrlKey) {
console.log("Continuing!");
}
});
</script>
键事件来源的 DOM 节点取决于按下键时哪个元素具有焦点。大多数节点无法获得焦点,除非你给它们一个tabindex属性,但像链接、按钮和表单字段这样的元素可以获得焦点。我们将在第十八章中再次讨论表单字段。当没有特别的元素获得焦点时,document.body会作为键事件的目标节点。
当用户输入文本时,使用键事件来判断正在输入的内容是有问题的。一些平台,尤其是安卓手机上的虚拟键盘,不会触发键事件。但即使你使用的是传统键盘,有些类型的文本输入也不会简单地与按键相匹配,例如输入法编辑器(IME)软件,它用于那些脚本无法在键盘上完全适配的人,其中多个按键组合以创建字符。
为了注意到何时输入了内容,可以输入的元素,如<input>和<textarea>标签,每当用户更改其内容时会触发"input"事件。要获取实际输入的内容,最好直接从聚焦的字段中读取,我们在第十八章中讨论了这一点。
指针事件
目前有两种广泛使用的指向屏幕上事物的方式:鼠标(包括像触控板和轨迹球等起到鼠标作用的设备)和触摸屏。这些设备会产生不同类型的事件。
鼠标点击
按下鼠标按钮会触发多个事件。"mousedown"和"mouseup"事件类似于"keydown"和"keyup",分别在按钮按下和释放时触发。这些事件发生在事件发生时位于鼠标指针正下方的 DOM 节点上。
在"mouseup"事件之后,会在包含按钮按下和释放的最具体节点上触发一个"click"事件。例如,如果我在一个段落上按下鼠标按钮,然后将指针移动到另一个段落并释放按钮,"click"事件将发生在包含这两个段落的元素上。
如果两个点击发生得很接近,会触发一个"dblclick"(双击)事件,发生在第二次点击事件之后。
要获取关于鼠标事件发生位置的精确信息,可以查看其clientX和clientY属性,这些属性包含事件相对于窗口左上角的坐标(以像素为单位),或者pageX和pageY,这些是相对于整个文档左上角的坐标(当窗口滚动时可能不同)。
以下程序实现了一个原始的绘图应用程序。每次你点击文档时,它会在你的鼠标指针下添加一个点。
<style>
body {
height: 200px;
background: beige;
}
.dot {
height: 8px; width: 8px;
border-radius: 4px; /* Rounds corners */
background: teal;
position: absolute;
}
</style>
<script>
window.addEventListener("click", event => {
let dot = document.createElement("div");
dot.className = "dot";
dot.style.left = (event.pageX - 4) + "px";
dot.style.top = (event.pageY - 4) + "px";
document.body.appendChild(dot);
});
</script>
我们将在第十九章中创建一个不那么原始的绘图应用程序。
鼠标运动
每当鼠标指针移动时,都会触发"mousemove"事件。此事件可以用于跟踪鼠标的位置。这在实现某种形式的鼠标拖动功能时特别有用。
作为一个示例,以下程序显示了一个条,并设置了事件处理程序,以便在该条上向左或向右拖动时使其变窄或变宽:
<p>Drag the bar to change its width:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
let lastX; // Tracks the last observed mouse X position
let bar = document.querySelector("div");
bar.addEventListener("mousedown", event => {
if (event.button == 0) {
lastX = event.clientX;
window.addEventListener("mousemove", moved);
event.preventDefault(); // Prevent selection
}
});
function moved(event) {
if (event.buttons == 0) {
window.removeEventListener("mousemove", moved);
} else {
let dist = event.clientX - lastX;
let newWidth = Math.max(10, bar.offsetWidth + dist);
bar.style.width = newWidth + "px";
lastX = event.clientX;
}
}
</script>
最终页面看起来是这样的:

请注意,"mousemove"处理程序注册在整个窗口上。即使在调整大小时鼠标移出条的范围,只要按钮被按住,我们仍然希望更新其大小。
我们必须在鼠标按钮释放时停止调整条的大小。为此,我们可以使用buttons属性(注意复数形式),它告诉我们当前被按下的按钮。当值为0时,表示没有按钮被按下。当按钮被按下时,buttons属性的值是这些按钮代码的总和——左键的代码是1,右键是2,中间键是4。例如,当左键和右键同时按下时,buttons的值将为3。
请注意,这些代码的顺序与button使用的顺序不同,在那里中间按钮在右边按钮之前。如前所述,一致性并不是浏览器编程接口的强项。
触摸事件
我们使用的图形浏览器样式是在触摸屏较为稀少的时代,以鼠标接口为设计理念的。为了使早期触摸屏手机上的网页“工作”,这些设备的浏览器在一定程度上假装触摸事件是鼠标事件。如果你轻触屏幕,会触发“mousedown”、“mouseup”和“click”事件。
但这种错觉并不是很稳健。触摸屏的工作方式与鼠标不同:它没有多个按钮,当手指不在屏幕上时,你无法追踪手指(以模拟“mousemove”),而且允许多个手指同时在屏幕上。
鼠标事件仅在简单情况下覆盖触摸交互——如果你为按钮添加“点击”处理程序,触摸用户仍然可以使用它。但像前面示例中的可调整大小条在触摸屏上则无法工作。
触摸交互会触发特定的事件类型。当手指开始接触屏幕时,你会收到一个“touchstart”事件。当手指在触摸时移动时,会触发“touchmove”事件。最后,当手指停止接触屏幕时,你会看到一个“touchend”事件。
由于许多触摸屏可以同时检测多个手指,因此这些事件没有与之相关联的单一坐标集。相反,它们的事件对象具有一个touches属性,该属性包含一个类似数组的点对象,每个点都有自己的clientX、clientY、pageX和pageY属性。
你可以做这样的事情,在每个触摸的手指周围显示红色圆圈:
<style>
dot { position: absolute; display: block;
border: 2px solid red; border-radius: 50px;
height: 100px; width: 100px; }
</style>
<p>Touch this page</p>
<script>
function update(event) {
for (let dot; dot = document.querySelector("dot");) {
dot.remove();
}
for (let i = 0; i < event.touches.length; i++) {
let {pageX, pageY} = event.touches[i];
let dot = document.createElement("dot");
dot.style.left = (pageX - 50) + "px";
dot.style.top = (pageY - 50) + "px";
document.body.appendChild(dot);
}
}
window.addEventListener("touchstart", update);
window.addEventListener("touchmove", update);
window.addEventListener("touchend", update);
</script>
你通常会希望在触摸事件处理程序中调用preventDefault,以覆盖浏览器的默认行为(可能包括在滑动时滚动页面),并防止触发鼠标事件,对于这些事件你也可能有一个处理程序。
滚动事件
每当一个元素滚动时,都会在其上触发“scroll”事件。这有多种用途,例如了解用户当前正在查看的内容(用于禁用屏幕外动画或向你邪恶的总部发送间谍报告)或显示某种进度指示(通过突出显示部分目录或显示页码)。
以下示例在文档上方绘制一个进度条,并在你向下滚动时更新它以填满:
<style>
#progress {
border-bottom: 2px solid blue;
width: 0;
position: fixed;
top: 0; left: 0;
}
</style>
<div id="progress"></div>
<script>
// Create some content
document.body.appendChild(document.createTextNode(
"supercalifragilisticexpialidocious ".repeat(1000)));
let bar = document.querySelector("#progress");
window.addEventListener("scroll", () => {
let max = document.body.scrollHeight - innerHeight;
bar.style.width = `${(pageYOffset / max) * 100}%`;
});
</script>
将一个元素的定位设置为固定的位置与绝对位置的效果类似,但也防止其与文档的其他部分一起滚动。其效果是使我们的进度条停留在顶部。其宽度会根据当前进度进行调整。我们在设置宽度时使用%而不是px作为单位,这样元素的大小相对于页面宽度。
全局的innerHeight绑定给出了窗口的高度,我们必须从总可滚动高度中减去这一数值——当你到达文档底部时,无法继续滚动。窗口宽度还有innerWidth。通过将当前滚动位置pageYOffset除以最大滚动位置并乘以100,我们得到进度条的百分比。
在滚动事件上调用preventDefault并不会阻止滚动的发生。事实上,事件处理程序仅在滚动发生后被调用。
聚焦事件
当一个元素获得焦点时,浏览器会在其上触发“focus”事件。当它失去焦点时,该元素会收到“blur”事件。
与之前讨论的事件不同,这两个事件不会传播。父元素上的处理程序不会在子元素获得或失去焦点时被通知。
以下示例为当前具有焦点的文本字段显示帮助文本:
<p>Name: <input type="text" data-help="Your full name"></p>
<p>Age: <input type="text" data-help="Your age in years"></p>
<p id="help"></p>
<script>
let help = document.querySelector("#help");
let fields = document.querySelectorAll("input");
for (let field of Array.from(fields)) {
field.addEventListener("focus", event => {
let text = event.target.getAttribute("data-help");
help.textContent = text;
});
field.addEventListener("blur", event => {
help.textContent = "";
});
}
</script>
该截图显示了年龄字段的帮助文本:

当用户在显示文档的浏览器标签或窗口之间切换时,窗口对象会接收“focus”和“blur”事件。
加载事件
当页面加载完成时,“load”事件会在窗口和文档主体对象上触发。这通常用于安排需要整个文档构建完成后才能执行的初始化操作。请记住,<script>标签中的内容在遇到标签时会立即执行。这可能来得太早——例如,当脚本需要处理在<script>标签之后出现的文档部分时。
像图片和加载外部文件的<script>标签这样的元素也有一个“load”事件,表示它们所引用的文件已加载。与聚焦相关的事件一样,加载事件不会传播。
当你关闭页面或离开它(例如,通过点击链接)时,会触发一个“beforeunload”事件。这个事件的主要用途是防止用户通过关闭文档意外丢失工作。如果你在这个事件上阻止默认行为并将事件对象的returnValue属性设置为一个字符串,浏览器将向用户显示一个对话框,询问他们是否真的想离开页面。该对话框可能包含你的字符串,但由于一些恶意网站试图利用这些对话框来混淆人们,以使他们留在页面上观看可疑的减肥广告,大多数浏览器不再显示这些对话框。
事件与事件循环
在第十一章讨论的事件循环的上下文中,浏览器事件处理程序的行为类似于其他异步通知。它们在事件发生时被调度,但必须等待正在运行的其他脚本完成后才能获得执行机会。
事件只能在没有其他操作运行时处理,这意味着如果事件循环被其他工作占用,与页面的任何交互(通过事件发生)将会延迟,直到有时间处理它。因此,如果你调度了过多的工作,无论是使用长时间运行的事件处理程序还是大量短时间运行的事件处理程序,页面将变得缓慢和笨重。
在某些情况下,如果你确实想在后台执行一些耗时的操作而不冻结页面,浏览器提供了一种称为网络工作者的东西。工作者是一个JavaScript进程,它与主脚本并行运行,拥有自己的时间线。
想象一下,平方一个数字是一个耗时的长时间计算,我们希望在单独的线程中执行。我们可以编写一个名为code/squareworker.js的文件,它通过计算平方并发送消息返回来响应消息。
addEventListener("message", event => {
postMessage(event.data * event.data);
});
为了避免多个线程同时访问相同数据的问题,工作线程不与主脚本的环境共享它们的全局作用域或其他任何数据。相反,你必须通过发送消息来进行通信。
这段代码生成一个运行该脚本的工作者,发送几个消息,并输出响应。
let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
console.log("The worker responded:", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);
postMessage函数发送消息,这将导致接收方触发一个“message”事件。创建工作者的脚本通过Worker对象发送和接收消息,而工作者则通过直接在其全局作用域上发送和监听,与创建它的脚本进行通信。只有可以表示为JSON的值才能作为消息发送——另一方将接收到它们的副本,而不是值本身。
定时器
我们在第十一章中看到的setTimeout函数会在给定的毫秒数后调度另一个函数被调用。有时你需要取消已经调度的函数。你可以通过存储setTimeout返回的值,并在其上调用clearTimeout来实现。
let bombTimer = setTimeout(() => {
console.log("BOOM!");
}, 500);
if (Math.random() < 0.5) { // 50% chance
console.log("Defused.");
clearTimeout(bombTimer);
}
cancelAnimationFrame函数的工作方式与clearTimeout相同。对requestAnimationFrame返回的值调用它将取消该帧(假设它尚未被调用)。
一组类似的函数setInterval和clearInterval用于设置每X毫秒重复的计时器。
let ticks = 0;
let clock = setInterval(() => {
console.log("tick", ticks++);
if (ticks == 10) {
clearInterval(clock);
console.log("stop.");
}
}, 200);
防抖
某些类型的事件可能会迅速连续触发多次,例如“mousemove”和“scroll”事件。在处理这些事件时,必须小心不要执行任何耗时的操作,否则你的处理程序会占用过多时间,从而使与文档的交互感觉缓慢。
如果你确实需要在这样的处理程序中做一些复杂的事情,可以使用setTimeout来确保不会过于频繁地执行。这通常被称为防抖事件。对此有几种稍微不同的方法。
例如,假设我们想在用户输入时做出反应,但不想在每次输入事件中立即执行。用户快速输入时,我们只想等到出现暂停再处理。我们在事件处理程序中设置一个超时,而不是立即执行某个操作。我们还会清除之前的超时(如果有的话),这样当事件发生得很接近(比我们的超时延迟更近)时,前一个事件的超时将被取消。
<textarea>Type something here...</textarea>
<script>
let textarea = document.querySelector("textarea");
let timeout;
textarea.addEventListener("input", () => {
clearTimeout(timeout);
timeout = setTimeout(() => console.log("Typed!"), 500);
});
</script>
将未定义的值传递给clearTimeout或在已经触发的超时上调用它不会产生任何效果。因此,我们不需要小心何时调用它,我们可以简单地对每个事件都调用它。
如果我们希望响应之间的间隔至少有一定时间,但又想在一系列事件发生期间触发响应,我们可以使用稍微不同的模式。例如,我们可能希望通过显示当前鼠标坐标来响应“mousemove”事件,但每250毫秒才响应一次。
<script>
let scheduled = null;
window.addEventListener("mousemove", event => {
if (!scheduled) {
setTimeout(() => {
document.body.textContent =
`Mouse at ${scheduled.pageX}, ${scheduled.pageY}`;
scheduled = null;
}, 250);
}
scheduled = event;
});
</script>
总结
事件处理程序使我们能够检测和响应在网页上发生的事件。addEventListener方法用于注册这样的处理程序。
每个事件都有一个类型(“keydown”、“focus”等),用于标识它。大多数事件是在特定的DOM元素上调用,然后传播到该元素的祖先,从而允许与这些元素关联的处理程序进行处理。
当事件处理程序被调用时,它会传递一个事件对象,包含有关事件的额外信息。该对象还有允许我们停止进一步传播(stopPropagation)和防止浏览器默认处理事件(preventDefault)的方法。
按下一个键会触发“keydown”和“keyup”事件。按下鼠标按钮会触发“mousedown”、“mouseup”和“click”事件。移动鼠标会触发“mousemove”事件。触摸屏交互会导致“touchstart”、“touchmove”和“touchend”事件。
可以通过“scroll”事件检测滚动,焦点变化可以通过“focus”和“blur”事件检测。当文档加载完成时,窗口会触发一个“load”事件。
练习
气球
编写一个页面,显示一个气球(使用气球表情符号,)。当你按上箭头时,它应膨胀(增长)10%。当你按下箭头时,它应缩小(收缩)10%。
你可以通过在其父元素上设置字体大小的CSS属性(style.fontSize)来控制文本(表情符号也是文本)的大小。记得在值中包含单位,例如像素(10px)。
箭头键的关键名称是“ArrowUp”和“ArrowDown”。确保这些键只改变气球,而不会滚动页面。
一旦你完成了这项工作,添加一个功能:如果你将气球膨胀到某个大小,它将“爆炸”。在这种情况下,爆炸意味着它被替换为一个表情符号,并且事件处理程序被移除(这样你就无法再膨胀或缩小爆炸效果)。
鼠标拖尾
在JavaScript的早期,正值华丽主页大量动画图像的高峰期,人们想出了许多真正鼓舞人心的使用该语言的方法。其中之一是鼠标拖尾——一系列元素将在你移动鼠标时跟随鼠标指针。
在这个练习中,我希望你实现一个鼠标拖尾。使用绝对定位的<div>元素,固定大小和背景颜色(请参阅第241页“鼠标点击”部分的代码示例)。创建一堆这些元素,并在鼠标移动时,跟随鼠标指针显示它们。
这里有多种可能的方法。你可以根据需要简化或复杂化你的拖尾。一个简单的起始解决方案是保持固定数量的拖尾元素,并在每次发生“mousemove”事件时,将下一个元素移动到鼠标的当前位置。
标签
标签面板在用户界面中很常见。它们允许你通过选择在元素上方“突出的”多个标签中的一个来选择界面面板。
实现一个简单的标签界面。编写一个函数asTabs,该函数接受一个DOM节点,并创建一个标签界面,显示该节点的子元素。它应在节点顶部插入一个按钮元素列表,每个按钮对应一个子元素,文本来自子元素的data-tabname属性。除一个外,所有原始子元素都应隐藏(设置为display: none)。通过点击按钮可以选择当前可见的节点。
当这能正常工作时,扩展功能,使当前选中的标签的按钮样式不同,以便明显显示哪个标签被选中。
所有现实都是一场游戏。
—伊恩·班克斯,游戏玩家

第十七章:项目:一个平台游戏
我最初对计算机的迷恋,如同许多宅男孩童一样,源于电脑游戏。我被那些我可以操控的小型模拟世界吸引,故事(某种程度上)在其中展开——我想,这更多是因为我将想象投射到这些世界中,而不是因为它们实际提供的可能性。
我不希望任何人走上游戏编程的职业道路。与音乐产业一样,渴望从事这一行业的年轻人数量与实际需求之间的差异,创造了一个相当不健康的环境。但为了乐趣而编写游戏是令人愉快的。
本章将介绍一个小型平台游戏的实现。平台游戏(或称“跳跃跑动”游戏)是指玩家需要在一个通常为二维且从侧面观看的世界中移动角色,同时跳跃越过或登上物体的游戏。
游戏
我们的游戏将大致基于托马斯·帕雷夫的《深蓝》 (*[www.lessmilk.com/dark-blue/](https://www.lessmilk.com/dark-blue/)*)。我选择这个游戏是因为它既有趣又简约,并且可以在不需要过多代码的情况下构建。它的样子如下:

黑色方框代表玩家,其任务是在避开红色物体(熔岩)的同时收集黄色方框(硬币)。当所有硬币被收集后,关卡就完成了。
玩家可以使用左箭头和右箭头键走动,并可以用上箭头键跳跃。跳跃是这个游戏角色的特长。它可以达到自身高度的几倍,并且可以在空中改变方向。这可能并不完全现实,但它帮助玩家感受到对屏幕上角色的直接控制。
游戏由一个静态背景构成,背景呈网格布局,移动元素叠加在该背景上。网格上的每个格子要么是空的,要么是固体,要么是熔岩。移动元素包括玩家、硬币和某些熔岩块。这些元素的位置不受网格的限制——它们的坐标可以是小数,从而实现平滑的移动。
技术
我们将使用浏览器的DOM来显示游戏,并通过处理键盘事件来读取用户输入。
与屏幕和键盘相关的代码只是我们构建这个游戏所需工作的一小部分。由于一切看起来像是彩色方框,因此绘制非常简单:我们创建DOM元素,并使用样式为它们设置背景色、大小和位置。
我们可以将背景表示为一个表格,因为它是一个不变的方格网。可以使用绝对定位的元素叠加自由移动的元素。
在需要动画图形并对用户输入做出快速响应的游戏和其他程序中,效率非常重要。尽管DOM最初并不是为了高性能图形设计的,但它在这方面实际上表现得比你想象的要好。你在第十四章中看到了某些动画。在现代计算机上,即使我们不太关心优化,这样简单的游戏运行也很好。
在下一章中,我们将探索另一种浏览器技术,<canvas>标签,它提供了一种更传统的绘制图形的方式,以形状和像素为单位,而不是DOM元素。
级别
我们希望有一种人类可读且可编辑的方式来指定级别。由于一切都可以从网格开始,我们可以使用大字符串,其中每个字符表示一个元素——要么是背景网格的一部分,要么是移动元素。
小级别的计划可能看起来是这样的:
let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
句号表示空白,井号 (#) 字符表示墙,加号表示熔岩。玩家的起始位置是@符号。每个O字符是一个金币,而顶部的等号 (=) 是一个水平往返移动的熔岩块。
我们将支持两种额外类型的移动熔岩:管道字符 (|) 创建垂直移动的水滴,v表示*滴落*熔岩——垂直移动的熔岩,它不会前后弹跳,而是仅向下移动,当它碰到地面时跳回起始位置。
整个游戏由多个级别组成,玩家必须完成这些级别。当所有金币都被收集时,级别即被视为完成。如果玩家碰到熔岩,当前级别将恢复到其起始位置,玩家可以重新尝试。
读取级别
以下类存储一个级别对象。它的参数应该是定义级别的字符串。
class Level {
constructor(plan) {
let rows = plan.trim().split("\n").map(l => [...l]);
this.height = rows.length;
this.width = rows[0].length;
this.startActors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
let type = levelChars[ch];
if (typeof type != "string") {
let pos = new Vec(x, y);
this.startActors.push(type.create(pos, ch));
type = "empty";
}
return type;
});
});
}
}
trim方法用于去除计划字符串开头和结尾的空白。这允许我们的示例计划以换行符开头,以便所有行直接相互对齐。剩余的字符串在换行符上进行分割,每一行被展开成一个数组,从而生成字符数组。
所以行保存了一个字符的数组数组,即计划的行。我们可以从中推导出级别的宽度和高度。但我们仍然需要将移动元素与背景网格分开。我们将移动元素称为*演员*。它们将存储在一个对象数组中。背景将是一个字符串的数组数组,包含“空”、“墙”或“熔岩”等字段类型。
要创建这些数组,我们需要遍历行,然后遍历它们的内容。记住,map会将数组索引作为第二个参数传递给映射函数,这样我们就能知道给定字符的x和y坐标。游戏中的位置将以坐标对的形式存储,左上角为(0, 0),每个背景方块的高宽均为1个单位。
为了解释计划中的字符,Level构造函数使用levelChars对象,该对象为每个在关卡描述中使用的字符存储一个字符串(如果是背景类型)和一个类(如果生成一个演员)。当类型是演员类时,它的静态create方法用于创建一个对象,该对象被添加到startActors中,映射函数为这个背景方块返回“空”。
演员的位置存储为一个Vec对象。这是一个二维向量,一个具有x和y属性的对象,如第六章的练习中所示。
随着游戏的进行,演员将最终出现在不同的位置,甚至完全消失(如硬币被收集时)。我们将使用一个State类来跟踪正在运行的游戏的状态。
class State {
constructor(level, actors, status) {
this.level = level;
this.actors = actors;
this.status = status;
}
static start(level) {
return new State(level, level.startActors, "playing");
}
get player() {
return this.actors.find(a => a.type == "player");
}
}
当游戏结束时,status属性将切换为“lost”或“won”。
这再次是一个持久的数据结构——更新游戏状态会创建一个新状态并保持旧状态不变。
演员
演员对象表示我们游戏中给定移动元素(玩家、硬币或流动熔岩)的当前位置和状态。所有演员对象遵循相同的接口。它们具有size和pos属性,分别保存表示该演员的矩形的大小和左上角的坐标,以及一个更新方法。
这种更新方法用于计算在给定时间步长后它们的新状态和位置。它模拟了演员所做的事情——根据玩家的箭头键移动,以及熔岩的来回反弹——并返回一个新的、更新的演员对象。
type属性包含一个字符串,用于识别演员的类型——“player”、“coin”或“lava”。这在绘制游戏时非常有用——为演员绘制的矩形的外观基于其类型。
演员类具有一个静态create方法,由Level构造函数用于从关卡计划中的字符创建演员。它接收字符的坐标和字符本身,这是必要的,因为Lava类处理多种不同的字符。
这是我们将用于二维值的Vec类,例如演员的位置和大小。
class Vec {
constructor(x, y) {
this.x = x; this.y = y;
}
plus(other) {
return new Vec(this.x + other.x, this.y + other.y);
}
times(factor) {
return new Vec(this.x * factor, this.y * factor);
}
}
times方法通过给定的数字缩放向量。当我们需要将速度向量乘以时间间隔以获得在该时间内行驶的距离时,它将非常有用。
不同类型的演员拥有各自的类,因为它们的行为非常不同。让我们定义这些类。稍后我们将介绍它们的更新方法。
玩家类具有一个speed属性,用于存储其当前速度,以模拟动量和重力。
class Player {
constructor(pos, speed) {
this.pos = pos;
this.speed = speed;
}
get type() { return "player"; }
static create(pos) {
return new Player(pos.plus(new Vec(0, -0.5)),
new Vec(0, 0));
}
}
Player.prototype.size = new Vec(0.8, 1.5);
由于玩家的高度为一个半方块,因此其初始位置设置为在@字符出现的位置上方半个方块。这样,它的底部与出现的方块底部对齐。
size属性对于所有Player的实例都是相同的,因此我们将其存储在原型上而不是实例本身。我们可以像type一样使用getter,但这样做会每次读取属性时创建并返回一个新的Vec对象,这是浪费的。(字符串是不可变的,因此不必每次评估时重新创建它们。)
在构建熔岩角色时,我们需要根据其基础字符的不同进行对象的初始化。动态熔岩以当前速度移动,直到碰到障碍物为止。在那一点上,如果它有一个重置属性,它将跳回起始位置(滴落)。如果没有,它将反转速度并沿另一方向继续移动(弹跳)。
create方法检查Level构造函数传递的字符,并创建相应的熔岩角色。
class Lava {
constructor(pos, speed, reset) {
this.pos = pos;
this.speed = speed;
this.reset = reset;
}
get type() { return "lava"; }
static create(pos, ch) {
if (ch == "=") {
return new Lava(pos, new Vec(2, 0));
} else if (ch == "|") {
return new Lava(pos, new Vec(0, 2));
} else if (ch == "v") {
return new Lava(pos, new Vec(0, 3), pos);
}
}
}
Lava.prototype.size = new Vec(1, 1);
硬币角色相对简单。它们大多数时间只是呆在原地。但为了稍微活跃一下游戏,它们被赋予了“摇晃”,即轻微的垂直来回运动。为了跟踪这一点,硬币对象存储了一个基础位置以及一个追踪弹跳运动相位的wobble属性。这些属性共同决定了硬币的实际位置(存储在pos属性中)。
class Coin {
constructor(pos, basePos, wobble) {
this.pos = pos;
this.basePos = basePos;
this.wobble = wobble;
}
get type() { return "coin"; }
static create(pos) {
let basePos = pos.plus(new Vec(0.2, 0.1));
return new Coin(basePos, basePos,
Math.random() * Math.PI * 2);
}
}
Coin.prototype.size = new Vec(0.6, 0.6);
在第十四章中,我们看到Math.sin给出了圆上点的y坐标。随着我们沿着圆移动,该坐标在一个平滑的波形中来回移动,这使得正弦函数在建模波动运动时非常有用。
为了避免所有硬币同步上下移动的情况,每个硬币的起始相位被随机化。Math.sin波的周期,即它产生的波的宽度,是2*π*。我们将Math.random返回的值乘以该数字,以在波上为硬币赋予一个随机的起始位置。
我们现在可以定义levelChars对象,将平面字符映射到背景网格类型或角色类别。
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
这给了我们创建Level实例所需的所有部分。
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9
接下来的任务是在屏幕上显示这些关卡,并在其中模拟时间和运动。
绘制
在下一章中,我们将以不同的方式显示同一款游戏。为了实现这一点,我们将绘图逻辑放在一个接口后面,并将其作为参数传递给游戏。这样,我们可以用不同的新显示模块来使用同一个游戏程序。
游戏显示对象绘制给定的关卡和状态。我们将其构造函数传递给游戏以允许替换它。我们在本章中定义的显示类称为DOMDisplay,因为它使用DOM元素来显示关卡。
我们将使用样式表来设置组成游戏的元素的实际颜色和其他固定属性。当创建它们时,也可以直接赋值给元素的style属性,但这会产生更冗长的程序。
下面的辅助函数提供了一种简洁的方法来创建一个元素,并为其添加一些属性和子节点:
function elt(name, attrs, ...children) {
let dom = document.createElement(name);
for (let attr of Object.keys(attrs)) {
dom.setAttribute(attr, attrs[attr]);
}
for (let child of children) {
dom.appendChild(child);
}
return dom;
}
显示元素是通过给它一个父元素来创建的,应该将其附加到该父元素上,并传入一个级别对象。
class DOMDisplay {
constructor(parent, level) {
this.dom = elt("div", {class: "game"}, drawGrid(level));
this.actorLayer = null;
parent.appendChild(this.dom);
}
clear() { this.dom.remove(); }
}
级别的背景网格是一次绘制的,并且不会改变。演员在每次更新显示时都会被重新绘制,更新时会给定状态。actorLayer属性将用于跟踪持有演员的元素,以便能够轻松地移除和替换它们。
我们的坐标和大小以网格单位进行追踪,其中1的大小或距离表示一个网格块。在设置像素大小时,我们必须将这些坐标放大——在每个方块只有一个像素的情况下,游戏中的一切都会显得极其微小。比例常数表示一个单位在屏幕上占用的像素数。
const scale = 20;
function drawGrid(level) {
return elt("table", {
class: "background",
style: `width: ${level.width * scale}px`
}, ...level.rows.map(row =>
elt("tr", {style: `height: ${scale}px`},
...row.map(type => elt("td", {class: type})))
));
}
<table>元素的形式与级别的行属性结构相对应——网格的每一行都被转化为表格行(<tr>元素)。网格中的字符串用作表格单元格(<td>元素)的类名。代码使用扩展(三个点)操作符将子节点数组作为单独的参数传递给elt。
以下CSS使表格看起来像我们想要的背景:
.background { background: rgb(52, 166, 251);
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: rgb(255, 100, 100); }
.wall { background: white; }
其中一些(table-layout、border-spacing和padding)用于抑制不必要的默认行为。我们不希望表格的布局依赖于单元格的内容,也不希望单元格之间或内部有空间。
背景规则设置背景颜色。CSS允许颜色以单词(例如:白色)或使用格式如rgb(*R*, *G*, *B*)来指定,其中红、绿和蓝的颜色分量被分为从0到255的三个数字。在rgb(52, 166, 251)中,红色分量是52,绿色是166,蓝色是251。由于蓝色分量最大,结果颜色将偏蓝。在.lava规则中,第一个数字(红色)是最大的。
我们通过为每个演员创建一个DOM元素,并根据演员的属性设置该元素的位置和大小来绘制每个演员。数值必须乘以比例,以从游戏单位转换为像素。
function drawActors(actors) {
return elt("div", {}, ...actors.map(actor => {
let rect = elt("div", {class: `actor ${actor.type}`});
rect.style.width = `${actor.size.x * scale}px`;
rect.style.height = `${actor.size.y * scale}px`;
rect.style.left = `${actor.pos.x * scale}px`;
rect.style.top = `${actor.pos.y * scale}px`;
return rect;
}));
}
要给一个元素添加多个类名,我们用空格分隔类名。在以下CSS代码中,actor类为演员提供了绝对位置。它们的类型名称作为额外的类来给它们上色。我们不需要重新定义lava类,因为我们在之前定义的lava网格方块中重用了该类。
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
syncState方法用于使显示显示给定状态。它首先移除旧的演员图形(如果有的话),然后在新位置重新绘制演员。尽管尝试重用演员的DOM元素可能很诱人,但为了使其工作,我们需要大量额外的管理,以将演员与DOM元素关联,并确保在演员消失时移除元素。由于游戏中通常只有少数演员,重新绘制它们并不昂贵。
DOMDisplay.prototype.syncState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
this.scrollPlayerIntoView(state);
};
通过将当前级别的状态作为类名添加到包装器中,当游戏获胜或失败时,我们可以稍微不同地为玩家角色设置样式,添加一个仅在玩家具有特定类的祖先元素时生效的CSS规则。
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
在接触熔岩后,玩家变成深红色,暗示着灼烧。当最后一枚硬币被收集后,我们添加两个模糊的白色阴影——一个在左上方,一个在右上方——以创建白色光环效果。
我们不能假设级别总是适合视口,即我们绘制游戏的元素。这就是我们需要scrollPlayerIntoView调用的原因:它确保如果级别超出视口,我们会滚动视口,以确保玩家位于其中心附近。以下CSS为游戏的包装DOM元素设置了最大大小,并确保任何超出元素框的部分不可见。我们还为其设置了相对位置,以便其中的角色相对于级别的左上角进行定位。
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
在scrollPlayerIntoView方法中,我们找到播放器的位置并更新包装元素的滚动位置。当播放器太靠近边缘时,我们通过操作该元素的scrollLeft和scrollTop属性来改变滚动位置。
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5))
.times(scale);
if (center.x < left + margin) {
this.dom.scrollLeft = center.x - margin;
} else if (center.x > right - margin) {
this.dom.scrollLeft = center.x + margin - width;
}
if (center.y < top + margin) {
this.dom.scrollTop = center.y - margin;
} else if (center.y > bottom - margin) {
this.dom.scrollTop = center.y + margin - height;
}
};
玩家中心的寻找方式展示了我们的Vec类型上的方法如何允许以相对可读的方式编写与对象的计算。要找到角色的中心,我们将其位置(左上角)和其大小的一半相加。那是在级别坐标中的中心,但我们需要它在像素坐标中,因此我们接着将结果向量乘以我们的显示比例。
接下来,一系列检查验证玩家的位置是否在允许范围内。请注意,有时这会设置低于零或超出元素可滚动区域的无意义滚动坐标。这是可以的——DOM会将它们限制在可接受的值。将scrollLeft设置为-10会使其变为0。
虽然始终尝试将玩家滚动到视口中心会稍微简单一些,但这会产生一种相当刺耳的效果。当你跳跃时,视图会不断上下移动。在屏幕中间有一个“中立”区域,可以在不引起任何滚动的情况下自由移动,会更加愉快。
我们现在能够显示我们的微小级别。
<link rel="stylesheet" href="css/game.css">
<script>
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.syncState(State.start(simpleLevel));
</script>

当使用rel="stylesheet"的<link>标签时,它是一种将CSS文件加载到页面中的方式。文件game.css包含我们游戏所需的样式。
运动与碰撞
现在我们可以开始添加运动了。大多数这类游戏采用的基本方法是将时间分割成小步,并在每一步中,根据速度与时间步长的乘积移动角色。我们将时间以秒为单位进行测量,因此速度以每秒的单位表示。
移动物体是简单的。困难的部分是处理元素之间的相互作用。当玩家撞到墙壁或地板时,他们不应该简单地穿过它。游戏必须注意到某个运动导致一个物体撞上另一个物体,并作出相应反应。对于墙壁,运动必须被停止。当撞到硬币时,必须收集该硬币。当触碰到岩浆时,游戏应该结束。
解决一般情况是一个重大任务。你可以找到通常称为物理引擎的库,它们模拟二维或三维物体之间的交互。在这一章中,我们将采取更为谦逊的方法,仅处理矩形物体之间的碰撞,并以相对简单的方式处理它们。
在移动玩家或一块岩浆之前,我们测试运动是否会使其进入墙内。如果是这样,我们就简单地取消整个运动。对这种碰撞的反应取决于参与者的类型——玩家会停止,而岩浆块则会反弹。
这种方法要求我们的时间步长相对较小,因为这会导致运动在物体实际接触之前停止。如果时间步长(因此运动步长)过大,玩家将会悬浮在离地面明显的高度。另一种更复杂的、更好的方法是找到确切的碰撞点并移动到那里。我们将采取简单的方法,并通过确保动画以小步推进来掩盖其问题。
该方法告诉我们一个矩形(由位置和大小指定)是否接触到给定类型的网格元素。
Level.prototype.touches = function(pos, size, type) {
let xStart = Math.floor(pos.x);
let xEnd = Math.ceil(pos.x + size.x);
let yStart = Math.floor(pos.y);
let yEnd = Math.ceil(pos.y + size.y);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};
该方法通过对物体的坐标使用Math.floor和Math.ceil来计算物体重叠的网格方块集合。请记住,网格方块的大小为 1 x 1 单位。通过将盒子的边缘向上和向下取整,我们得到盒子接触的背景方块范围。

我们遍历通过四舍五入坐标找到的网格方块,当找到匹配的方块时返回true。关卡外的方块总是被视为“墙”,以确保玩家无法离开世界,并且我们不会意外尝试读取超出行数组边界的内容。
状态更新方法使用接触来判断玩家是否接触到熔岩。
State.prototype.update = function(time, keys) {
let actors = this.actors
.map(actor => actor.update(time, this, keys));
let newState = new State(this.level, actors, this.status);
if (newState.status != "playing") return newState;
let player = newState.player;
if (this.level.touches(player.pos, player.size, "lava")) {
return new State(this.level, actors, "lost");
}
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
方法接收一个时间步长和一个数据结构,告知它哪些按键被按下。它首先在所有参与者上调用更新方法,生成一个更新后的参与者数组。参与者还会获得时间步长、按键和状态,以便它们可以基于这些信息进行更新。只有玩家会实际读取按键,因为只有玩家是由键盘控制的参与者。
如果游戏已经结束,则不需要进一步处理(游戏在失败后无法获胜,反之亦然)。否则,该方法会测试玩家是否接触到背景熔岩。如果是,游戏就失败了,我们结束了。最后,如果游戏确实仍在进行中,它会检查是否有其他角色与玩家重叠。
通过重叠函数检测角色之间的重叠。它接受两个角色对象,当它们相碰时返回真——这发生在它们在 x 轴和 y 轴上都重叠时。
function overlap(actor1, actor2) {
return actor1.pos.x + actor1.size.x > actor2.pos.x &&
actor1.pos.x < actor2.pos.x + actor2.size.x &&
actor1.pos.y + actor1.size.y > actor2.pos.y &&
actor1.pos.y < actor2.pos.y + actor2.size.y;
}
如果任何角色发生重叠,其碰撞方法有机会更新状态。接触熔岩角色会将游戏状态设置为“失败”。当你触碰到硬币时,它们会消失,并在它们是关卡的最后一枚硬币时将状态设置为“胜利”。
Lava.prototype.collide = function(state) {
return new State(state.level, state.actors, "lost");
};
Coin.prototype.collide = function(state) {
let filtered = state.actors.filter(a => a != this);
let status = state.status;
if (!filtered.some(a => a.type == "coin")) status = "won";
return new State(state.level, filtered, status);
};
角色更新
角色对象的更新方法接受时间步长、状态对象和键对象作为参数。熔岩角色类型的更新方法会忽略键对象。
Lava.prototype.update = function(time, state) {
let newPos = this.pos.plus(this.speed.times(time));
if (!state.level.touches(newPos, this.size, "wall")) {
return new Lava(newPos, this.speed, this.reset);
} else if (this.reset) {
return new Lava(this.reset, this.speed, this.reset);
} else {
return new Lava(this.pos, this.speed.times(-1));
}
};
这个更新方法通过将时间步长与当前速度的乘积添加到其旧位置来计算新位置。如果没有障碍物阻挡新位置,它将移动到那里。如果有障碍物,行为将取决于熔岩块的类型——滴落的熔岩有一个重置位置,当它碰到某物时会跳回到该位置。反弹的熔岩通过将速度乘以-1来反转速度,使其开始朝相反的方向移动。
硬币使用其更新方法进行摇晃。它们忽略与网格的碰撞,因为它们只是摇晃在自己方块内。
const wobbleSpeed = 8, wobbleDist = 0.07;
Coin.prototype.update = function(time) {
let wobble = this.wobble + time * wobbleSpeed;
let wobblePos = Math.sin(wobble) * wobbleDist;
return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
this.basePos, wobble);
};
摇晃属性会递增以跟踪时间,然后用作Math.sin的参数,以找到波上的新位置。硬币的当前位置则根据其基础位置和基于此波的偏移量进行计算。
这就涉及到玩家本身。玩家的运动在每个轴上单独处理,因为碰到地面不应该阻止水平运动,而碰到墙壁不应该停止下落或跳跃运动。
const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;
Player.prototype.update = function(time, state, keys) {
let xSpeed = 0;
if (keys.ArrowLeft) xSpeed -= playerXSpeed;
if (keys.ArrowRight) xSpeed += playerXSpeed;
let pos = this.pos;
let movedX = pos.plus(new Vec(xSpeed * time, 0));
if (!state.level.touches(movedX, this.size, "wall")) {
pos = movedX;
}
let ySpeed = this.speed.y + time * gravity;
let movedY = pos.plus(new Vec(0, ySpeed * time));
if (!state.level.touches(movedY, this.size, "wall")) {
pos = movedY;
} else if (keys.ArrowUp && ySpeed > 0) {
ySpeed = -jumpSpeed;
} else {
ySpeed = 0;
}
return new Player(pos, new Vec(xSpeed, ySpeed));
};
水平运动是基于左箭头和右箭头键的状态进行计算的。当没有墙壁阻挡这个运动所创造的新位置时,就使用这个新位置。否则,保持旧位置不变。
垂直运动以类似的方式工作,但必须模拟跳跃和重力。玩家的垂直速度(ySpeed)首先会被加速,以考虑重力的影响。
我们再次检查墙壁。如果没有碰到任何墙,新位置将被使用。如果有墙壁,则有两种可能的结果。当上箭头被按下并且我们正在向下移动(这意味着我们碰到的东西在我们下面),速度会被设置为相对较大的负值。这会导致玩家跳跃。如果情况不是这样,玩家只是碰到了什么东西,速度则被设置为零。
游戏中的重力强度、跳跃速度和其他常量是通过尝试一些数字并观察哪种感觉合适来确定的。你可以尝试进行实验。
追踪键
对于这样的游戏,我们不希望键在每次按下时只生效一次。相反,我们希望它们的效果(移动玩家角色)在按住时保持有效。
我们需要设置一个键处理程序,存储左、右和上箭头键的当前状态。我们还希望对这些键调用preventDefault,以防止它们滚动页面。
以下函数在给定一个键名数组时,将返回一个跟踪这些键当前状态的对象。它为keydown和keyup事件注册事件处理程序,并在事件中的键代码存在于它所跟踪的代码集中时,更新该对象。
function trackKeys(keys) {
let down = Object.create(null);
function track(event) {
if (keys.includes(event.key)) {
down[event.key] = event.type == "keydown";
event.preventDefault();
}
}
window.addEventListener("keydown", track);
window.addEventListener("keyup", track);
return down;
}
const arrowKeys =
trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
同一个处理程序函数用于这两种事件类型。它查看事件对象的type属性,以确定键状态是应该更新为true(keydown)还是false(keyup)。
运行游戏
requestAnimationFrame函数,如我们在第十四章中看到的,提供了一种很好的方式来动画游戏。但它的接口相当原始——使用它需要我们跟踪上一次调用函数的时间,并在每一帧之后再次调用requestAnimationFrame。
让我们定义一个助手函数,将这些内容封装在一个方便的接口中,并允许我们简单地调用runAnimation,传入一个期望时间差作为参数并绘制单帧。当帧函数返回false时,动画停止。
function runAnimation(frameFunc) {
let lastTime = null;
function frame(time) {
if (lastTime != null) {
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
我将最大帧步长设置为100毫秒(十分之一秒)。当带有我们页面的浏览器标签或窗口被隐藏时,requestAnimationFrame调用将被暂停,直到标签或窗口再次显示。在这种情况下,lastTime和time之间的差值将是页面被隐藏的整个时间。一次性将游戏推进如此之多看起来会很傻,并可能导致奇怪的副作用,例如玩家掉落到地面下。
该函数还将时间步长转换为秒,这比毫秒更容易理解。
runLevel函数接受一个Level对象和一个显示构造函数,并返回一个Promise。它在document.body中显示关卡,并让用户进行游戏。当关卡结束(失败或胜利)时,runLevel等待一秒钟(让用户看到发生了什么),然后清除显示,停止动画,并将Promise解析为游戏的结束状态。
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.syncState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
游戏是一个关卡的序列。每当玩家死亡时,当前关卡会重启。当一个关卡完成时,我们会进入下一个关卡。这可以通过以下函数表达,该函数接受一个关卡计划(字符串)数组和一个显示构造函数:
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("You've won!");
}
因为我们让runLevel返回一个Promise,runGame可以使用async函数来编写,如在第十一章中所示。它返回另一个Promise,当玩家完成游戏时会被解析。
本章沙盒中的GAME_LEVELS绑定提供了一组关卡计划([eloquentjavascript.net/code#16](https://eloquentjavascript.net/code#16))。该页面将它们传递给runGame,启动实际的游戏。
<link rel="stylesheet" href="css/game.css">
<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
练习
游戏结束
平台游戏通常让玩家从有限的生命开始,每次死亡时减去一条生命。当玩家没有生命时,游戏从头开始。
调整runGame来实现生命值。让玩家从三条生命开始。每当关卡开始时,输出当前的生命值(使用console.log)。
暂停游戏
通过按ESC键使游戏能够暂停(挂起)和恢复。你可以通过更改runLevel函数来设置一个键盘事件处理程序,当按下ESC时中断或恢复动画。
runAnimation接口乍一看可能不适合这个,但如果你重新安排runLevel调用它的方式,它就适合了。
当你完成这个后,还有其他可以尝试的事情。我们注册键盘事件处理程序的方式有些问题。arrowKeys对象当前是一个全局绑定,即使没有游戏在运行,其事件处理程序也会被保留。你可以说它们在我们的系统中泄漏了。扩展trackKeys提供一种注销其处理程序的方法,然后更改runLevel以在开始时注册其处理程序,在结束时注销它们。
一个怪物
对于平台游戏来说,传统上有敌人可以通过从上方跳跃来击败。这个练习要求你将这样的角色类型添加到游戏中。
我们称这个角色为怪物。怪物只能水平移动。你可以让它们朝玩家的方向移动,像水平熔岩一样来回反弹,或者采用你想要的任何其他移动模式。这个类不需要处理下落,但应该确保怪物不会穿过墙壁。
当怪物触碰到玩家时,效果取决于玩家是否在它们的上方跳跃。你可以通过检查玩家的底部是否靠近怪物的顶部来近似判断。如果是这种情况,怪物就会消失。如果不是,游戏就结束了。
绘画是一种欺骗。
—M.C. 艾舍,由布鲁诺·恩斯特在M.C. 艾舍的魔镜中引用

第十八章:在画布上绘图
浏览器为我们提供了几种显示图形的方式。最简单的方法是使用样式来定位和着色常规DOM元素。正如上一章中的游戏所示,这可以使我们走得很远。通过向节点添加部分透明的背景图像,我们可以使它们看起来完全符合我们的要求。甚至可以使用变换样式来旋转或倾斜节点。
但是我们将使用DOM做一些它最初并未设计的事情。有些任务,例如在任意点之间绘制一条线,使用常规HTML元素来完成非常笨拙。
有两种选择。第一种是基于DOM,但使用可缩放矢量图形(SVG)而不是HTML。可以将SVG视为一种文档标记方言,专注于形状而非文本。您可以直接在HTML文档中嵌入SVG文档,或使用<img>标签将其包含。
第二种选择被称为画布。画布是一个单一的DOM元素,用于封装一幅图像。它提供了一个编程接口,可以在节点占用的空间上绘制形状。画布和SVG图像之间的主要区别在于,在SVG中,形状的原始描述被保留,因此可以随时移动或调整大小。而画布则在绘制形状后立即将其转换为像素(光栅上的彩色点),并不记住这些像素代表什么。要在画布上移动一个形状,唯一的方法是清空画布(或清空形状周围的画布部分),然后在新的位置重新绘制该形状。
SVG
本书不会详细讲解SVG,但我会简要说明它的工作原理。在本章结束时,我会回到在决定适合特定应用程序的绘图机制时必须考虑的权衡。
这是一个包含简单SVG图像的HTML文档:
<p>Normal HTML here.</p>
<svg >
<circle r="50" cx="50" cy="50" fill="red"/>
<rect x="120" y="5" width="90" height="90"
stroke="blue" fill="none"/>
</svg>
xmlns属性将元素(及其子元素)更改为不同的XML 命名空间。这个由URL标识的命名空间指定了我们当前所使用的方言。<circle>和<rect>标签在HTML中不存在,但在SVG中具有意义——它们使用其属性指定的样式和位置绘制形状。
文档的显示效果如下:

这些标签创建了DOM元素,就像HTML标签一样,脚本可以与之互动。例如,这将<circle>元素的颜色改为青色:
let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");
画布元素
画布图形可以绘制到<canvas>元素上。您可以为此类元素设置宽度和高度属性,以确定其在像素中的大小。
一个新的画布是空的,这意味着它完全透明,因此在文档中显示为空白区域。
<canvas>标签旨在允许不同的绘图样式。要访问实际的绘图接口,我们首先需要创建一个上下文,这是一个对象,其方法提供绘图接口。目前有三种广泛支持的绘图样式:“2d”用于二维图形,“webgl”通过OpenGL接口用于三维图形,以及“webgpu”,这是一个更现代和灵活的WebGL替代方案。
本书不会讨论WebGL或WebGPU——我们将专注于二维。不过,如果你对三维图形感兴趣,我鼓励你了解WebGPU。它提供了一个直接访问图形硬件的接口,并允许你高效地使用JavaScript渲染复杂场景。
你可以通过在<canvas> DOM元素上使用getContext方法创建一个上下文。
<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
let canvas = document.querySelector("canvas");
let context = canvas.getContext("2d");
context.fillStyle = "red";
context.fillRect(10, 10, 100, 50);
</script>
创建上下文对象后,示例绘制了一个宽100像素、高50像素的红色矩形,其左上角的坐标为(10, 10)。

就像在HTML(和SVG)中一样,画布使用的坐标系统将(0, 0)放在左上角,正y轴从那里向下。这意味着(10, 10)位于左上角下方和右侧10像素,例如。
线条和表面
在画布接口中,形状可以被填充,即其区域被赋予某种颜色或图案,或者可以被描边,即沿着其边缘绘制一条线。SVG使用相同的术语。
fillRect方法填充一个矩形。它首先接受矩形左上角的x和y坐标,然后是宽度,然后是高度。类似的方法strokeRect绘制矩形的轮廓。
这两种方法都不接受任何其他参数。填充的颜色、描边的粗细等,并不是通过方法的参数来确定的,正如你合理期望的那样,而是通过上下文对象的属性来确定的。
fillStyle属性控制形状的填充方式。它可以设置为指定颜色的字符串,使用CSS使用的颜色表示法。
strokeStyle属性类似地工作,但确定用于描边线的颜色。该线的宽度由lineWidth属性确定,lineWidth属性可以包含任何正数。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.strokeStyle = "blue";
cx.strokeRect(5, 5, 50, 50);
cx.lineWidth = 5;
cx.strokeRect(135, 5, 50, 50);
</script>
这段代码绘制了两个蓝色的正方形,第二个正方形使用了更粗的线条。

当没有指定宽度或高度属性时,如示例所示,画布元素的默认宽度为300像素,高度为150像素。
路径
路径是一系列线条。2D画布接口以一种特殊的方式描述这样的路径。它完全通过副作用来完成。路径不是可以存储和传递的值。相反,如果你想对路径执行某个操作,你需要通过一系列方法调用来描述它的形状。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
for (let y = 10; y < 100; y += 10) {
cx.moveTo(10, y);
cx.lineTo(90, y);
}
cx.stroke();
</script>
该示例创建了一条包含多个水平线段的路径,然后使用stroke方法对其进行描边。每个使用lineTo创建的线段都从路径的当前位置开始。该位置通常是最后一个线段的末端,除非调用了moveTo。在这种情况下,下一条线段将从传递给moveTo的位置开始。
上一个程序描述的路径看起来像这样:

填充路径时(使用fill方法),每个形状都是单独填充的。一个路径可以包含多个形状——每个moveTo动作都开始一个新的形状。但路径在被填充之前需要是闭合的(意味着起点和终点在同一位置)。如果路径尚未闭合,将从其末尾添加一条线到起点,填充由完成路径围成的形状。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(50, 10);
cx.lineTo(10, 70);
cx.lineTo(90, 70);
cx.fill();
</script>
该示例绘制一个填充三角形。请注意,三角形的两条边是显式绘制的。第三条边,从右下角返回到顶部,是隐含的,如果你描边路径,它将不会存在。

你也可以使用closePath方法通过添加一条实际的线段返回路径的起点来显式闭合路径。当描边路径时,这条线段会被绘制。
曲线
一条路径也可以包含曲线。不幸的是,这些曲线的绘制稍微复杂一些。
quadraticCurveTo方法绘制一条到给定点的曲线。为了确定线的曲率,此方法需要一个控制点和一个目标点。想象这个控制点如同吸引线条,给予它曲线。线不会经过控制点,但起点和终点的方向会使得一条直线在该方向上指向控制点。以下示例说明了这一点:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// control=(60,10) goal=(90,90)
cx.quadraticCurveTo(60, 10, 90, 90);
cx.lineTo(60, 10);
cx.closePath();
cx.stroke();
</script>
结果产生的路径看起来像这样:

我们从左到右绘制一条二次曲线,控制点为(60, 10),然后绘制两条经过该控制点并返回到线段起点的线段。结果有些类似于星际迷航的徽章。你可以看到控制点的效果:从下角出发的线段最初朝着控制点的方向延伸,然后弯曲朝向目标。
bezierCurveTo方法绘制类似的曲线。与单一控制点不同,此方法有两个——分别对应于线段的两个端点。这里有一个类似的草图来说明这种曲线的行为:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// control1=(10,10) control2=(90,10) goal=(50,90)
cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
cx.lineTo(90, 10);
cx.lineTo(10, 10);
cx.closePath();
cx.stroke();
</script>
两个控制点指定了曲线两端的方向。它们距离各自的点越远,曲线在那个方向的“膨胀”就越明显。

这样的曲线可能很难处理——并不总是清楚如何找到提供所需形状的控制点。有时你可以计算出它们,有时你只能通过反复试验找到合适的值。
弧形方法是一种沿圆边缘绘制曲线的线条。它需要一对弧心的坐标,一个半径,然后是起始角度和结束角度。
这两个最后的参数使得只绘制圆的一部分成为可能。角度以弧度为单位,而不是度数。这意味着完整圆的角度为2*π或2 * Math.PI,大约为6.28。角度从圆心右侧的点开始计数,然后顺时针方向进行。你可以使用0作为起始角度,并用大于2*π的结束角度(例如,7)来绘制一个完整的圆。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
// center=(50,50) radius=40 angle=0 to 7
cx.arc(50, 50, 40, 0, 7);
// center=(150,50) radius=40 angle=0 to 1/2 pi
cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
cx.stroke();
</script>
生成的图像从完整圆的右侧(第一次调用弧)到四分之一圆的右侧(第二次调用)。

像其他路径绘制方法一样,用弧绘制的线条与前一个路径段相连。你可以调用moveTo或开始新路径来避免这种情况。
绘制饼图
想象一下,你刚在EconomiCorp, Inc.找到了一份工作。你的第一个任务是绘制客户满意度调查结果的饼图。
结果绑定包含一个对象数组,代表调查响应。
const results = [
{name: "Satisfied", count: 1043, color: "lightblue"},
{name: "Neutral", count: 563, color: "lightgreen"},
{name: "Unsatisfied", count: 510, color: "pink"},
{name: "No comment", count: 175, color: "silver"}
];
要绘制饼图,我们绘制多个饼切片,每个切片由一个弧和一对线段连接到该弧的中心。我们可以通过将完整圆(2*π)除以总响应数来计算每个弧占用的角度,然后将这个数字(每个响应的角度)乘以选择特定选项的人数。
<canvas width="200" height="200"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let total = results.reduce((sum, {count}) => sum + count, 0);
// Start at the top
let currentAngle = -0.5 * Math.PI;
for (let result of results) {
let sliceAngle = (result.count / total) * 2 * Math.PI;
cx.beginPath();
// center=100,100, radius=100
// From current angle, clockwise by slice's angle
cx.arc(100, 100, 100,
currentAngle, currentAngle + sliceAngle);
currentAngle += sliceAngle;
cx.lineTo(100, 100);
cx.fillStyle = result.color;
cx.fill();
}
</script>
这将绘制以下图表:

但是一个没有说明切片含义的图表并没有太大帮助。我们需要一种在画布上绘制文本的方法。
文本
2D画布绘图上下文提供了fillText和strokeText方法。后者对于描边字母很有用,但通常你需要的是fillText。它将使用当前fillStyle填充给定文本的轮廓。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.font = "28px Georgia";
cx.fillStyle = "fuchsia";
cx.fillText("I can draw text, too!", 10, 50);
</script>
你可以通过font属性指定文本的大小、样式和字体。这个例子仅给出了字体大小和家族名称。也可以在字符串开头添加斜体或粗体以选择样式。
fillText和strokeText的最后两个参数提供了绘制字体的位置。默认情况下,它们表示文本字母基线的起始位置,这是一条字母“站立”的线,不计算字母中的悬挂部分,如j或p。你可以通过将textAlign属性设置为“end”或“center”来改变水平位置,通过将textBaseline设置为“top”、“middle”或“bottom”来改变垂直位置。
我们将在本章末尾的练习中回到我们的饼图,以及标记切片的问题。
图片
在计算机图形学中,通常区分矢量图形和位图图形。前者是我们在本章中所做的——通过给出形状的逻辑描述来指定图片。位图图形则不同,它不指定实际形状,而是处理像素数据(彩色点的光栅)。
drawImage方法允许我们将像素数据绘制到画布上。这些像素数据可以来自<img>元素或另一个画布。以下示例创建一个分离的<img>元素并将图像文件加载到其中。但此方法不能立即开始从该图片绘制,因为浏览器可能尚未加载它。为了解决这个问题,我们注册一个“load”事件处理程序,并在图像加载后进行绘制。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/hat.png";
img.addEventListener("load", () => {
for (let x = 10; x < 200; x += 30) {
cx.drawImage(img, x, 10);
}
});
</script>
默认情况下,drawImage会以其原始大小绘制图像。你还可以给它两个额外的参数,以指定绘制图像的宽度和高度,当它们与原始图像不同时。
当drawImage给出九个参数时,它可以用于仅绘制图像的一部分。第二到第五个参数指示源图像中应复制的矩形(x,y,宽度和高度),第六到第九个参数给出应复制到画布上的矩形。
这可以用于将多个精灵(图像元素)打包到一个单独的图像文件中,然后只绘制你需要的部分。例如,这张图片包含了一个游戏角色的多个姿势:

通过交替绘制不同的姿势,我们可以展示一个看起来像是走动角色的动画。
要在画布上动画化图像,clearRect方法非常有用。它类似于fillRect,但不是给矩形上色,而是使其透明,移除之前绘制的像素。
我们知道每个精灵、每个子图像的宽度为24像素,高度为30像素。以下代码加载图像,然后设置一个间隔(重复计时器)来绘制下一个帧:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
let cycle = 0;
setInterval(() => {
cx.clearRect(0, 0, spriteW, spriteH);
cx.drawImage(img,
// Source rectangle
cycle * spriteW, 0, spriteW, spriteH,
// Destination rectangle
0, 0, spriteW, spriteH);
cycle = (cycle + 1) % 8;
}, 120);
});
</script>
循环绑定跟踪我们在动画中的位置。对于每一帧,它会递增,然后通过使用余数运算符限制在0到7的范围内。此绑定用于计算当前姿势的精灵在图像中的x坐标。
变换
如果我们想让角色向左走而不是向右走呢?当然,我们可以绘制另一组精灵。但我们也可以指示画布反向绘制图像。
调用scale方法会使之后绘制的任何内容都进行缩放。此方法接受两个参数,一个设置水平缩放,另一个设置垂直缩放。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.scale(3, .5);
cx.beginPath();
cx.arc(50, 50, 40, 0, 7);
cx.lineWidth = 3;
cx.stroke();
</script>
由于调用了scale,圆形的宽度绘制为三倍,高度为一半。

缩放将导致绘制的图像的所有内容,包括线宽,按照指定的方式被拉伸或压缩。以负值进行缩放将翻转图片。翻转围绕点(0, 0)发生,这意味着它也会翻转坐标系统的方向。当应用水平缩放-1时,绘制在x位置100的形状将最终位于原本是位置-100的地方。
要旋转一张图片,我们不能简单地在调用drawImage之前添加cx.scale(-1, 1)。那样会将我们的图片移出画布,导致它不可见。我们可以调整传递给drawImage的坐标来弥补这个问题,将图片绘制在x位置-50而不是0。另一种解决方案是调整缩放发生的轴,不需要绘图代码了解缩放变化。
除了缩放之外,还有几种其他方法影响画布的坐标系统。你可以使用rotate方法旋转随后绘制的形状,并用translate方法移动它们。有趣且令人困惑的是,这些变换会叠加,意味着每个变换都是相对于先前的变换发生的。
如果我们平移10个水平像素两次,所有内容将向右绘制20个像素。如果我们首先将坐标系统的中心移动到(50, 50),然后按20度旋转(约0.1*π弧度),那么旋转将围绕点(50, 50)发生。

但如果我们首先旋转20度,然后按(50, 50)进行平移,平移将在旋转的坐标系统中发生,从而产生不同的方向。变换应用的顺序很重要。
要围绕给定x位置的垂直线翻转图片,我们可以做以下操作:
function flipHorizontally(context, around) {
context.translate(around, 0);
context.scale(-1, 1);
context.translate(-around, 0);
}
我们将y轴移动到希望镜像的位置,应用镜像,最后将y轴移回在镜像宇宙中的正确位置。下图解释了为什么这样有效:

这显示了在中心线两侧镜像前后的坐标系统。三角形被编号以说明每一步。如果我们在一个正的x位置绘制一个三角形,它默认会位于三角形1所在的位置。首次调用flipHorizontally会向右平移,这使我们到达三角形2。接着它会缩放,将三角形翻转到位置3。如果按给定线进行镜像,这并不是它应该在的位置。第二次平移调用修正了这一点——它“抵消”了初始平移,使三角形4正好出现在应该的位置。
我们现在可以通过围绕字符的垂直中心翻转世界,在位置(100, 0)绘制一个镜像字符。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
flipHorizontally(cx, 100 + spriteW / 2);
cx.drawImage(img, 0, 0, spriteW, spriteH,
100, 0, spriteW, spriteH);
});
</script>
存储和清除变换
变换会持续存在。我们在绘制该镜像字符后绘制的其他所有内容也会被镜像。这可能会带来不便。
可以保存当前的转换,进行一些绘制和转换,然后恢复旧的转换。这通常是需要暂时转换坐标系统的函数所应做的事情。首先,我们保存调用该函数的代码所使用的任何转换。然后,该函数进行其操作,在当前转换上添加更多转换。最后,我们恢复到最初的转换。
2D画布上下文上的save和restore方法用于管理这种转换。它们在概念上保持着一个转换状态的堆栈。当你调用save时,当前状态会被推入堆栈,而当你调用restore时,堆栈顶部的状态会被弹出并用作上下文的当前转换。你还可以调用resetTransform来完全重置转换。
下面示例中的branch函数展示了你可以用一个改变转换的函数来做什么,然后再调用一个函数(在这个例子中是它自己),该函数继续使用给定的转换进行绘制。
这个函数通过绘制一条线、将坐标系统的中心移动到线的末端,并调用自身两次来绘制一个树状形状——第一次向左旋转,然后向右旋转。每次调用都会减少绘制的分支长度,当长度降到8以下时递归停止。
<canvas width="600" height="300"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
function branch(length, angle, scale) {
cx.fillRect(0, 0, 1, length);
if (length < 8) return;
cx.save();
cx.translate(0, length);
cx.rotate(-angle);
branch(length * scale, angle, scale);
cx.rotate(2 * angle);
branch(length * scale, angle, scale);
cx.restore();
}
cx.translate(300, 0);
branch(60, 0.5, 0.8);
</script>
结果是一个简单的分形。

如果没有save和restore的调用,第二次递归调用branch将会保留第一次调用所创建的位置和旋转。它将与当前分支没有关联,而是连接到第一次调用绘制的最内层、最右侧的分支。最终形状可能也会很有趣,但它绝对不是一棵树。
回到游戏。
我们现在对画布绘制有了足够的了解,可以开始为上一章的游戏构建一个基于画布的显示系统。新的显示不再仅仅显示彩色方块。相反,我们将使用drawImage来绘制代表游戏元素的图像。
我们定义了另一种显示对象类型,称为CanvasDisplay,它支持与第十六章中的DOMDisplay相同的接口——即方法syncState和clear。
这个对象比DOMDisplay保持了更多的信息。它并不使用其DOM元素的滚动位置,而是跟踪自己的视口,这告诉我们当前正在查看关卡的哪一部分。最后,它保留了一个flipPlayer属性,以便即使玩家静止不动,它也会面朝上次移动的方向。
class CanvasDisplay {
constructor(parent, level) {
this.canvas = document.createElement("canvas");
this.canvas.width = Math.min(600, level.width * scale);
this.canvas.height = Math.min(450, level.height * scale);
parent.appendChild(this.canvas);
this.cx = this.canvas.getContext("2d");
this.flipPlayer = false;
this.viewport = {
left: 0,
top: 0,
width: this.canvas.width / scale,
height: this.canvas.height / scale
};
}
clear() {
this.canvas.remove();
}
}
syncState方法首先计算一个新的视口,然后在适当的位置绘制游戏场景。
CanvasDisplay.prototype.syncState = function(state) {
this.updateViewport(state);
this.clearDisplay(state.status);
this.drawBackground(state.level);
this.drawActors(state.actors);
};
与DOMDisplay相反,这种显示样式确实需要在每次更新时重绘背景。由于画布上的形状仅仅是像素,绘制后没有好的方法来移动它们(或删除它们)。更新画布显示的唯一方法是清除它并重新绘制场景。我们可能还会滚动,这要求背景处于不同的位置。
updateViewport方法类似于DOMDisplay的scrollPlayerIntoView方法。它检查玩家是否太靠近屏幕边缘,并在这种情况下移动视口。
CanvasDisplay.prototype.updateViewport = function(state) {
let view = this.viewport, margin = view.width / 3;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5));
if (center.x < view.left + margin) {
view.left = Math.max(center.x - margin, 0);
} else if (center.x > view.left + view.width - margin) {
view.left = Math.min(center.x + margin - view.width,
state.level.width - view.width);
}
if (center.y < view.top + margin) {
view.top = Math.max(center.y - margin, 0);
} else if (center.y > view.top + view.height - margin) {
view.top = Math.min(center.y + margin - view.height,
state.level.height - view.height);
}
};
对Math.max和Math.min的调用确保视口不会显示关卡之外的区域。Math.max(0, *x*)确保结果数字不小于零。Math.min同样保证值保持在给定的界限之内。
清除显示时,我们将根据游戏是胜利(更亮)还是失败(更暗)使用略微不同的颜色。
CanvasDisplay.prototype.clearDisplay = function(status) {
if (status == "won") {
this.cx.fillStyle = "rgb(68, 191, 255)";
} else if (status == "lost") {
this.cx.fillStyle = "rgb(44, 136, 214)";
} else {
this.cx.fillStyle = "rgb(52, 166, 251)";
}
this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height);
};
为了绘制背景,我们遍历当前视口中可见的瓦片,使用与上一章的touches方法相同的技巧。
let otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";
CanvasDisplay.prototype.drawBackground = function(level) {
let {left, top, width, height} = this.viewport;
let xStart = Math.floor(left);
let xEnd = Math.ceil(left + width);
let yStart = Math.floor(top);
let yEnd = Math.ceil(top + height);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let tile = level.rows[y][x];
if (tile == "empty") continue;
let screenX = (x - left) * scale;
let screenY = (y - top) * scale;
let tileX = tile == "lava" ? scale : 0;
this.cx.drawImage(otherSprites,
tileX, 0, scale, scale,
screenX, screenY, scale, scale);
}
}
};
非空的瓦片通过drawImage绘制。otherSprites图像包含用于除玩家之外的元素的图片。它从左到右包含墙壁瓦片、岩浆瓦片和硬币的精灵。

背景瓦片为20 x 20像素,因为我们将使用与DOMDisplay相同的比例。因此,岩浆瓦片的偏移量为20(比例绑定的值),墙壁的偏移量为0。
我们不费心等待精灵图像加载。用尚未加载的图像调用drawImage将什么也不做。因此,我们可能在图像仍在加载时无法正确绘制游戏的前几帧,但这不是一个严重的问题。由于我们持续更新屏幕,正确的场景将在加载完成后立即出现。
前面展示的行走角色将用于表示玩家。绘制它的代码需要根据玩家当前的运动选择正确的精灵和方向。前八个精灵包含行走动画。当玩家在地面上移动时,我们根据当前时间循环播放它们。我们希望每60毫秒切换帧,因此首先将时间除以60。当玩家静止时,我们绘制第九个精灵。在跳跃时(通过垂直速度不为零来识别),我们使用第十个、最右侧的精灵。
因为精灵比玩家对象稍宽——24像素而不是16像素,以便为脚和手臂留出一些空间——该方法必须按给定的量(playerXOverlap)调整x坐标和宽度。
let playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
const playerXOverlap = 4;
CanvasDisplay.prototype.drawPlayer = function(player, x, y, width, height) {
width += playerXOverlap * 2;
x -= playerXOverlap;
if (player.speed.x != 0) {
this.flipPlayer = player.speed.x < 0;
}
let tile = 8;
if (player.speed.y != 0) {
tile = 9;
} else if (player.speed.x != 0) {
tile = Math.floor(Date.now() / 60) % 8;
}
this.cx.save();
if (this.flipPlayer) {
flipHorizontally(this.cx, x + width / 2);
}
let tileX = tile * width;
this.cx.drawImage(playerSprites, tileX, 0, width, height,
x, y, width, height);
this.cx.restore();
};
drawPlayer方法由drawActors调用,负责绘制游戏中的所有角色。
CanvasDisplay.prototype.drawActors = function(actors) {
for (let actor of actors) {
let width = actor.size.x * scale;
let height = actor.size.y * scale;
let x = (actor.pos.x - this.viewport.left) * scale;
let y = (actor.pos.y - this.viewport.top) * scale;
if (actor.type == "player") {
this.drawPlayer(actor, x, y, width, height);
} else {
let tileX = (actor.type == "coin" ? 2 : 1) * scale;
this.cx.drawImage(otherSprites,
tileX, 0, width, height,
x, y, width, height);
}
}
};
在绘制非玩家对象时,我们查看其类型以找到正确精灵的偏移量。岩浆砖块位于偏移量20处,硬币精灵位于40处(两倍缩放)。
计算角色的位置时,我们必须减去视口的位置,因为我们canvas上的(0, 0)对应于视口的左上角,而不是关卡的左上角。我们也可以使用平移来实现这一点。两种方法都可行。
这就是新显示系统的总结。生成的游戏大致如下:

选择图形接口
当你需要在浏览器中生成图形时,可以选择普通HTML、SVG和canvas。没有一种在所有情况下都能完美适用的最佳方法。每种选择都有其优缺点。
普通HTML的优点在于简单。它还与文本很好地集成。SVG和canvas都允许你绘制文本,但它们不会帮助你定位文本或在文本占用多于一行时换行。在基于HTML的图像中,包含文本块要容易得多。
SVG可以用于生成在任何缩放级别下都看起来清晰的图形。与HTML不同,SVG是为绘图设计的,因此更适合此目的。
SVG和HTML都会建立一个表示图像的数据结构(DOM)。这使得在绘制后修改元素成为可能。如果你需要根据用户的操作或作为动画的一部分,反复更改大图的一小部分,在canvas中这样做可能会无谓地昂贵。DOM还允许我们在图像中的每个元素上注册鼠标事件处理程序(即使是用SVG绘制的形状)。这在canvas中是做不到的。
但canvas的像素导向方法在绘制大量微小元素时可以成为一种优势。它不建立数据结构,而是重复在同一像素表面上绘制,这使得canvas每个形状的成本更低。还有一些效果只有在canvas元素中才实用,例如逐像素渲染场景(例如,使用光线追踪器)或使用JavaScript对图像进行后处理(模糊或扭曲图像)。
在某些情况下,你可能想要结合这些技术。例如,你可以用SVG或canvas绘制图表,但通过将HTML元素放在图像上方来显示文本信息。
对于不要求高的应用程序,选择哪个接口其实并没有太大关系。本章中为我们的游戏构建的显示可以使用这三种图形技术中的任何一种来实现,因为它不需要绘制文本、处理鼠标交互或处理数量极多的元素。
总结
在本章中,我们讨论了在浏览器中绘制图形的技术,重点是<canvas>元素。
canvas节点表示文档中我们程序可以绘制的区域。这个绘制是通过使用getContext方法创建的绘图上下文对象完成的。
2D绘图接口允许我们填充和描边各种形状。上下文的fillStyle属性决定形状的填充方式。strokeStyle和lineWidth属性控制线条的绘制方式。
矩形和文本片段可以通过一个方法调用绘制。fillRect和strokeRect方法绘制矩形,而fillText和strokeText方法绘制文本。要创建自定义形状,我们必须先构建一条路径。
调用beginPath开始一条新路径。其他一些方法可以向当前路径添加线条和曲线。例如,lineTo可以添加一条直线。当路径完成时,可以使用fill方法填充或使用stroke方法描边。
将图像或另一个画布的像素移动到我们的画布上是通过drawImage方法完成的。默认情况下,此方法绘制整个源图像,但通过提供更多参数,您可以复制图像的特定区域。我们在游戏中使用这一点,通过从包含多个姿势的图像中复制游戏角色的单个姿势。
变换允许您以多种方向绘制形状。2D绘图上下文具有一个当前变换,可以通过translate、scale和rotate方法进行更改。这些将影响所有后续的绘图操作。可以使用save方法保存变换状态,并使用restore方法恢复。
在画布上显示动画时,可以使用clearRect方法在重新绘制之前清除画布的部分区域。
练习
形状
编写一个程序,在画布上绘制以下形状:
-
一个梯形(一个一侧更宽的矩形)
-
一个红色菱形(一个旋转了45度或
弧度的矩形) -
一条锯齿形的线
-
由100段直线组成的螺旋形
-
一个黄色星星

在绘制最后两个形状时,您可能想参考第十四章中关于Math.cos和Math.sin的解释,该章描述了如何使用这些函数获取圆上的坐标。
我建议为每个形状创建一个函数。将位置以及其他属性(如大小或点数)作为参数传递。另一种选择是将数字硬编码到代码中,这往往会使代码变得难以阅读和修改。
饼图
在本章早些时候,我们看到一个绘制饼图的示例程序。修改该程序,使每个类别的名称显示在表示它的切片旁边。尝试找到一种悦目的方式来自动定位这些文本,使其适用于其他数据集。您可以假设类别足够大,以留出足够的空间用于标签。
您可能需要再次使用Math.sin和Math.cos,它们在第十四章中进行了描述。
一个弹跳的球
使用我们在第十四章和第十六章中看到的requestAnimationFrame技术,绘制一个带有弹跳球的盒子。球以恒定速度移动,并在碰到盒子的边缘时反弹。
预计算镜像
变换的一个不幸之处是它们会减慢位图的绘制速度。每个像素的位置和大小都需要被变换,尽管浏览器未来可能会在变换方面变得更加智能,但目前它们确实会导致绘制位图所需时间的可测量增加。
在像我们这样的游戏中,我们只绘制一个变换后的精灵,这并不是问题。但想象一下,我们需要绘制数百个角色或成千上万的旋转粒子来自爆炸。
想出一种方法来绘制一个反转的角色,而不加载额外的图像文件,也不必每帧都进行变换的drawImage调用。
人们常常难以理解设计的一点是,除了URLs、HTTP和HTML之外,没有其他东西。没有一个中央计算机“控制”着网络,没有一个单一的网络可以使用这些协议,甚至没有任何地方的组织“运行”这个网络。网络并不是一个存在于某个“地方”的物理“东西”。它是一个可以存在信息的“空间”。
—蒂姆·伯纳斯-李

第十九章:HTTP与表单
超文本传输协议(HyperText Transfer Protocol),在第十三章中介绍,是在万维网上请求和提供数据的机制。本章更详细地描述了该协议,并解释了浏览器JavaScript如何访问它。
协议
如果你在浏览器的地址栏中输入[eloquentjavascript.net/18_http.xhtml](http://eloquentjavascript.net/18_http.xhtml),浏览器首先查找与eloquent [javascript.net](http://javascript.net)关联的服务器地址,并尝试在端口80(HTTP流量的默认端口)上打开一个TCP连接。如果服务器存在并接受连接,浏览器可能会发送类似这样的内容:
GET /18_http.xhtml HTTP/1.1
Host: eloquentjavascript.net
User-Agent: Your browser's name
然后服务器通过相同的连接进行响应。
HTTP/1.1 200 OK
Content-Length: 87320
Content-Type: text/html
Last-Modified: Fri, 13 Oct 2023 10:05:41 GMT
<!doctype html>
--snip--
浏览器获取空行后响应的部分,即它的主体(不要与HTML <body>标签混淆),并将其显示为HTML文档。
客户端发送的信息称为请求。它以这一行开始:
GET /18_http.xhtml HTTP/1.1
第一个词是请求的方法。GET意味着我们想要获取指定的资源。其他常见的方法有DELETE用于删除资源,PUT用于创建或替换资源,POST用于向其发送信息。请注意,服务器并不一定有义务执行它收到的每一个请求。如果你走到一个随机网站并告诉它删除其主页,它可能会拒绝。
方法名称之后的部分是请求应用于的资源的路径。在最简单的情况下,资源只是服务器上的一个文件,但协议并不要求它必须是。资源可以是任何可以被转移好像它是一个文件的东西。许多服务器生成的响应是动态生成的。例如,如果你打开[github.com/marijnh](https://github.com/marijnh),服务器会在其数据库中查找名为marijnh的用户,如果找到了,它会为该用户生成一个个人资料页面。
在资源路径之后,请求的第一行提到HTTP/1.1,以指示它使用的HTTP协议的版本。
实际上,许多网站使用HTTP版本2,它支持与版本1.1相同的概念,但更加复杂,因此可以更快。浏览器在与特定服务器交互时会自动切换到适当的协议版本,无论使用哪个版本,请求的结果都是相同的。由于版本1.1更加简单且更易于操作,我们将使用它来说明该协议。
服务器的响应也会以一个版本开头,后面跟着响应的状态,首先是一个三位数的状态代码,然后是一个可读的字符串。
HTTP/1.1 200 OK
以2开头的状态码表示请求成功。以4开头的代码意味着请求存在问题。最著名的HTTP状态码可能是404,表示找不到该资源。以5开头的代码表示服务器发生了错误,请求没有错。
请求或响应的第一行后面可以跟随任意数量的头部。这些是形如name: value的行,指定有关请求或响应的额外信息。这些头部是示例响应的一部分:
Content-Length: 87320
Content-Type: text/html
Last-Modified: Fri, 13 Oct 2023 10:05:41 GMT
这告诉我们响应文档的大小和类型。在这种情况下,它是一个87,320字节的HTML文档。它还告诉我们该文档最后一次修改的时间。
客户端和服务器可以自由决定在请求或响应中包含哪些头部。但其中一些对于正常工作是必要的。例如,如果响应中没有Content-Type头部,浏览器将不知道如何显示该文档。
在头部之后,请求和响应都可能包含一个空行,后面跟着一个主体,其中包含实际发送的文档。GET和DELETE请求不发送任何数据,但PUT和POST请求会发送。一些响应类型,如错误响应,也不需要主体。
浏览器和HTTP
正如我们所见,当我们在地址栏中输入URL时,浏览器会发起请求。当生成的HTML页面引用其他文件,例如图像和JavaScript文件时,它也会检索这些文件。
一个中等复杂的网站可以轻松包含10到200个资源。为了能够快速获取这些资源,浏览器会同时发起多个GET请求,而不是一个个等待响应。
HTML页面可以包含表单,允许用户填写信息并将其发送到服务器。这是一个表单的示例:
<form method="GET" action="example/message.xhtml">
<p>Name: <input type="text" name="name"></p>
<p>Message:<br><textarea name="message"></textarea></p>
<p><button type="submit">Send</button></p>
</form>
这段代码描述了一个包含两个字段的表单:一个小字段用于输入名字,一个大字段用于写消息。当你点击发送按钮时,表单被提交,这意味着其字段的内容被打包成一个HTTP请求,浏览器会导航到该请求的结果。
当<form>元素的method属性为GET(或被省略)时,表单中的信息将作为查询字符串添加到动作URL的末尾。浏览器可能会向这个URL发起请求:
GET /example/message.xhtml?name=Jean&message=Yes%3F HTTP/1.1
问号表示URL路径部分的结束和查询的开始。它后面跟随名称和值的对,分别对应于表单字段元素的name属性和这些元素的内容。一个&符号用于分隔这些对。
URL中编码的实际消息是“Yes?”,但问号被一个奇怪的代码替换。一些查询字符串中的字符必须被转义。问号(以%3F表示)就是其中之一。似乎有一种不成文的规则,即每种格式都需要自己的一种字符转义方式。这种称为URL编码的方式使用一个百分号,后跟两个十六进制(基数为16)数字来编码字符代码。在这种情况下,3F在十进制中是63,是问号字符的代码。JavaScript提供了encodeURIComponent和decodeURIComponent函数来编码和解码这种格式。
console.log(encodeURIComponent("Yes?"));
// → Yes%3F
console.log(decodeURIComponent("Yes%3F"));
// → Yes?
如果我们将之前看到的HTML表单的method属性更改为POST,提交表单时发出的HTTP请求将使用POST方法,并将查询字符串放入请求的主体中,而不是将其添加到URL中。
POST /example/message.xhtml HTTP/1.1
Content-length: 24
Content-type: application/x-www-form-urlencoded
name=Jean&message=Yes%3F
GET请求应当用于没有副作用的请求,仅仅是请求信息。例如,创建新账户或发布消息等更改服务器内容的请求,应使用其他方法,例如POST。客户端软件(例如浏览器)知道不应盲目发送POST请求,但通常会隐式发送GET请求——例如,预取用户可能很快需要的资源。
我们稍后将在本章中回到表单以及如何通过JavaScript与它们进行交互。
Fetch
浏览器JavaScript可以进行HTTP请求的接口称为fetch。
fetch("example/data.txt").then(response => {
console.log(response.status);
// → 200
console.log(response.headers.get("Content-Type"));
// → text/plain
});
调用fetch返回一个Promise,该Promise解析为一个Response对象,包含有关服务器响应的信息,例如其状态码和响应头。响应头被封装在一个类似Map的对象中,该对象将其键(头部名称)视为不区分大小写,因为头部名称不应区分大小写。这意味着headers.get("Content-Type")和headers.get("content-TYPE")将返回相同的值。
请注意,即使服务器返回了错误代码,fetch返回的Promise仍然会成功解决。如果发生网络错误或请求所针对的服务器无法找到,Promise也可能被拒绝。
fetch的第一个参数是应该请求的URL。当该URL不以协议名称(例如http:)开头时,它被视为相对的,这意味着它相对于当前文档进行解释。当它以斜杠(/)开头时,它将替换当前路径,即服务器名称之后的部分。当它没有斜杠时,当前路径中直到最后一个斜杠的部分将放在相对URL之前。
要获取响应的实际内容,您可以使用其text方法。因为初始Promise一旦接收到响应的头部就会解决,而读取响应主体可能需要更长的时间,所以这再次返回一个Promise。
fetch("example/data.txt")
.then(resp => resp.text())
.then(text => console.log(text));
// → This is the content of data.txt
一种类似的方法,称为json,返回一个解析为JSON时的值的promise,如果不是有效的JSON,则会拒绝。
默认情况下,fetch使用GET方法发出请求,并且不包含请求主体。你可以通过将带有额外选项的对象作为第二个参数传递来配置它。例如,这个请求试图删除example/data.txt:
fetch("example/data.txt", {method: "DELETE"}).then(resp => {
console.log(resp.status);
// → 405
});
405状态码表示“方法不被允许”,这是HTTP服务器表明“抱歉,我无法做到”的方式。
要为PUT或POST请求添加请求主体,可以包含一个body选项。要设置头部,可以使用headers选项。例如,这个请求包含一个Range头部,指示服务器仅返回文档的一部分。
fetch("example/data.txt", {headers: {Range: "bytes=8-19"}})
.then(resp => resp.text())
.then(console.log);
// → The content
浏览器会自动添加一些请求头,例如“Host”和服务器确定主体大小所需的那些。但是,添加你自己的头部通常是有用的,以包含诸如身份验证信息或告诉服务器你希望接收的文件格式等内容。
HTTP沙箱
在网页脚本中发出HTTP请求再次引发了安全性方面的担忧。控制脚本的人可能与其运行的计算机上的人没有相同的利益。更具体地说,如果我访问themafia.org,我不希望它的脚本能够请求mybank.com,使用我浏览器中的识别信息,并指示转移我所有的钱。
因此,浏览器通过禁止脚本向其他域(如themafia.org和mybank.com)发出HTTP请求来保护我们。
当构建希望出于合法理由访问多个域的系统时,这可能是一个烦人的问题。幸运的是,服务器可以在其响应中包含这样的头部,以明确向浏览器指示请求可以来自另一个域:
Access-Control-Allow-Origin: *
理解HTTP
当构建一个需要在浏览器中运行的JavaScript程序(客户端)与服务器上的程序(服务器端)之间进行通信的系统时,有几种不同的方式来建模这种通信。
一种常用的模型是远程过程调用。在这个模型中,通信遵循正常函数调用的模式,只不过函数实际上是在另一台机器上运行。调用它涉及向服务器发出请求,包括函数的名称和参数。对此请求的响应包含返回的值。
在考虑远程过程调用时,HTTP只是一个通信的载体,你很可能会编写一个完全隐藏它的抽象层。
另一种方法是围绕资源和HTTP方法的概念构建你的通信。你使用PUT请求而不是远程过程addUser,针对/users/larry。你不再在函数参数中编码用户的属性,而是定义一个JSON文档格式(或使用现有格式)来表示用户。然后,用于创建新资源的PUT请求的主体就是这样的文档。通过向资源的URL(例如,/users/larry)发出GET请求来获取资源,这又会返回表示该资源的文档。
这种第二种方法使得使用HTTP提供的一些特性变得更加容易,比如支持资源缓存(在客户端保留资源副本以便快速访问)。HTTP中的概念设计良好,可以为设计你的服务器接口提供一套有用的原则。
安全性和HTTPS
在互联网上传输的数据往往要经历一条漫长而危险的道路。为了到达目的地,它必须穿越从咖啡店Wi-Fi热点到各种公司和国家控制的网络。在其路线的任何点,它都可能被检查甚至被修改。
如果某些东西保持秘密很重要,比如你的电子邮件账户密码,或者它需要在传输到目的地时不被修改,比如你通过银行网站转账的账户号码,那么普通HTTP就不够安全。
安全HTTP协议用于以https://开头的URL,以一种更难以阅读和篡改的方式封装HTTP流量。在交换数据之前,客户端通过要求服务器证明其拥有浏览器识别的证书颁发机构颁发的加密证书,来验证服务器的身份。接下来,通过连接传输的所有数据都以一种应该能防止窃听和篡改的方式进行加密。
因此,当它正常工作时,HTTPS可以防止其他人冒充你想要交流的网站并且监视你的通信。它并不完美,也发生过由于伪造或盗用证书和软件故障导致HTTPS失败的各种事件,但它比普通HTTP安全得多。
表单字段
表单最初是为预JavaScript网页设计的,目的是允许网站通过HTTP请求发送用户提交的信息。这个设计假设与服务器的交互总是通过导航到新页面来进行。
然而,表单元素是DOM的一部分,就像页面的其余部分一样,表示表单字段的DOM元素支持许多其他元素所不具备的属性和事件。这使得使用JavaScript程序检查和控制这些输入字段成为可能,并且可以进行诸如向表单添加新功能或在JavaScript应用程序中将表单和字段作为构建块等操作。
一个网页表单由任意数量的输入字段组成,这些字段被分组在一个<form>标签中。HTML允许多种不同样式的字段,从简单的开关复选框到下拉菜单和文本输入字段。本书不会试图全面讨论所有字段类型,但我们将首先提供一个粗略的概述。
许多字段类型使用<input>标签。该标签的type属性用于选择字段的样式。以下是一些常用的<input>类型:
text |
单行文本字段 |
|---|---|
password |
与文本相同,但隐藏输入的文本 |
checkbox |
开/关切换开关 |
color |
一种颜色 |
date |
日历日期 |
radio |
(部分) 多选字段 |
file |
允许用户从他们的计算机中选择一个文件 |
表单字段不一定要出现在<form>标签中。你可以将它们放置在页面的任何位置。这种无表单字段不能被提交(只有整个表单可以),但在使用JavaScript响应输入时,我们通常不希望正常提交我们的字段。
<p><input type="text" value="abc"> (text)</p>
<p><input type="password" value="abc"> (password)</p>
<p><input type="checkbox" checked> (checkbox)</p>
<p><input type="color" value="orange"> (color)</p>
<p><input type="date" value="2023-10-13"> (date)</p>
<p><input type="radio" value="A" name="choice">
<input type="radio" value="B" name="choice" checked>
<input type="radio" value="C" name="choice"> (radio)</p>
<p><input type="file"> (file)</p>
使用此HTML代码创建的字段看起来是这样的:

这些元素的JavaScript接口根据元素的类型而有所不同。
多行文本字段有自己的标签<textarea>,主要是因为使用属性指定多行起始值会显得尴尬。<textarea>标签需要一个匹配的</textarea>结束标签,并使用这两个标签之间的文本作为起始文本,而不是值属性。
<textarea>
one
two
three
</textarea>
最后,<select>标签用于创建一个字段,允许用户从多个预定义选项中进行选择。
<select>
<option>Pancakes</option>
<option>Pudding</option>
<option>Ice cream</option>
</select>
这样的字段看起来是这样的:

每当表单字段的值发生变化时,它将触发一个“change”事件。
焦点
与 HTML 文档中的大多数元素不同,表单字段可以获得键盘焦点。当点击、通过TAB移动或以其他方式激活时,它们将成为当前活动元素并接收键盘输入。
因此,你只能在文本字段获得焦点时输入内容。其他字段对键盘事件的响应方式不同。例如,<select>菜单会尝试移动到包含用户输入文本的选项,并通过上下箭头键移动选择。
我们可以通过JavaScript使用focus和blur方法控制焦点。第一个方法将焦点移动到调用的DOM元素,第二个方法则移除焦点。document.activeElement的值对应于当前获得焦点的元素。
<input type="text">
<script>
document.querySelector("input").focus();
console.log(document.activeElement.tagName);
// → INPUT
document.querySelector("input").blur();
console.log(document.activeElement.tagName);
// → BODY
</script>
对于某些页面,用户期望立即与表单字段进行交互。JavaScript可以在文档加载时聚焦该字段,但HTML也提供了autofocus属性,该属性在让浏览器知道我们想要实现什么的同时产生相同的效果。这让浏览器在不适当的情况下有机会禁用这一行为,比如当用户将焦点放在其他地方时。
浏览器允许用户通过按TAB移动焦点到下一个可聚焦元素,按SHIFT-TAB返回到上一个元素。默认情况下,元素按照它们在文档中出现的顺序被访问。可以使用tabindex属性来改变这个顺序。以下示例文档将允许焦点从文本输入跳转到OK按钮,而不是先经过帮助链接:
<input type="text" tabindex=1> <a href=".">(help)</a>
<button onclick="console.log('ok')" tabindex=2>OK</button>
默认情况下,大多数类型的HTML元素不能被聚焦。你可以给任何元素添加tabindex属性使其可聚焦。tabindex为0的元素可以被聚焦而不影响聚焦顺序。
禁用字段
所有表单字段都可以通过其disabled属性被禁用。这是一个可以不带值指定的属性——只要存在,元素就会被禁用。
<button>I'm all right</button>
<button disabled>I'm out</button>
被禁用的字段无法聚焦或更改,浏览器会使其看起来呈灰色和褪色。

当一个程序正在处理由某个按钮或其他控件引起的可能需要与服务器通信并因此耗时的操作时,禁用该控件直到操作完成是个好主意。这样,当用户不耐烦再次点击时,他们不会不小心重复他们的操作。
整个表单
当一个字段包含在<form>元素中时,其DOM元素将有一个form属性链接回该表单的DOM元素。反过来,<form>元素有一个名为elements的属性,包含了内部字段的类似数组的集合。
表单字段的name属性决定了在提交表单时其值的识别方式。它也可以在访问表单的elements属性时用作属性名称,该属性既可以作为类似数组的对象(通过数字访问),也可以作为映射(通过名称访问)。
<form action="example/submit.xhtml">
Name: <input type="text" name="name"><br>
Password: <input type="password" name="password"><br>
<button type="submit">Log in</button>
</form>
<script>
let form = document.querySelector("form");
console.log(form.elements[1].type);
// → password
console.log(form.elements.password.type);
// → password
console.log(form.elements.name.form == form);
// → true
</script>
当一个具有提交类型属性的按钮被按下时,会导致表单被提交。在表单字段聚焦时按下ENTER键也会产生相同的效果。
正常提交表单意味着浏览器导航到由表单的action属性指示的页面,使用GET或POST请求。但在这之前,会触发一个“submit”事件。你可以用JavaScript处理这个事件,并通过在事件对象上调用preventDefault来阻止这种默认行为。
<form>
Value: <input type="text" name="value">
<button type="submit">Save</button>
</form>
<script>
let form = document.querySelector("form");
form.addEventListener("submit", event => {
console.log("Saving value", form.elements.value.value);
event.preventDefault();
});
</script>
在JavaScript中拦截“submit”事件有多种用途。我们可以编写代码来验证用户输入的值是否合理,并立即显示错误信息,而不是提交表单。或者,我们可以完全禁用常规的提交表单方式,如示例所示,让我们的程序处理输入,可能使用fetch将其发送到服务器而无需重新加载页面。
文本字段
由<textarea>标签或类型为文本或密码的<input>标签创建的字段共享一个公共接口。它们的DOM元素具有一个value属性,该属性作为字符串值持有当前内容。将此属性设置为另一个字符串会更改字段的内容。
文本字段的selectionStart和selectionEnd属性提供了关于光标和文本选择的信息。当没有选中任何内容时,这两个属性的值相同,指示光标的位置。例如,0表示文本的开始,10表示光标位于第10个字符之后。当字段的部分内容被选中时,这两个属性的值会不同,显示所选文本的起始和结束位置。与值一样,这些属性也可以被写入。
想象一下,你正在写一篇关于哈塞克赫姆维(Khasekhemwy),第二王朝的最后一位法老的文章,但在拼写他的名字时遇到了一些困难。以下代码连接了一个<textarea>标签,并添加了一个事件处理程序,当你按下F2时,会为你插入字符串“Khasekhemwy”。
<textarea></textarea>
<script>
let textarea = document.querySelector("textarea");
textarea.addEventListener("keydown", event => {
if (event.key == "F2") {
replaceSelection(textarea, "Khasekhemwy");
event.preventDefault();
}
});
function replaceSelection(field, word) {
let from = field.selectionStart, to = field.selectionEnd;
field.value = field.value.slice(0, from) + word +
field.value.slice(to);
// Put the cursor after the word
field.selectionStart = from + word.length;
field.selectionEnd = from + word.length;
}
</script>
replaceSelection函数将文本字段内容中当前选中的部分替换为给定的词,并将光标移动到该词后,以便用户可以继续输入。
文本字段的“change”事件并不会在每次输入时触发。而是在字段内容更改后失去焦点时触发。要立即响应文本字段中的更改,你应该注册“input”事件的处理程序,该事件在用户每次输入字符、删除文本或以其他方式操作字段内容时都会触发。
以下示例展示了一个文本字段和一个计数器,显示该字段中文本的当前长度:
<input type="text"> length: <span id="length">0</span>
<script>
let text = document.querySelector("input");
let output = document.querySelector("#length");
text.addEventListener("input", () => {
output.textContent = text.value.length;
});
</script>
复选框和单选按钮
复选框字段是一个二元切换。它的值可以通过其checked属性提取或更改,该属性持有布尔值。
<label>
<input type="checkbox" id="purple"> Make this page purple
</label>
<script>
let checkbox = document.querySelector("#purple");
checkbox.addEventListener("change", () => {
document.body.style.background =
checkbox.checked ? "mediumpurple" : "";
});
</script>
<label>标签将文档中的一部分与输入字段关联。点击标签上的任何地方将激活该字段,使其获得焦点,并在其为复选框或单选按钮时切换其值。
单选按钮类似于复选框,但它隐式地与其他具有相同name属性的单选按钮关联,以确保在任何时候只有一个可以处于激活状态。
Color:
<label>
<input type="radio" name="color" value="orange"> Orange
</label>
<label>
<input type="radio" name="color" value="lightgreen"> Green
</label>
<label>
<input type="radio" name="color" value="lightblue"> Blue
</label>
<script>
let buttons = document.querySelectorAll("[name=color]");
for (let button of Array.from(buttons)) {
button.addEventListener("change", () => {
document.body.style.background = button.value;
});
}
</script>
在传递给querySelectorAll的CSS查询中的方括号用于匹配属性。它选择name属性为“color”的元素。
选择字段
选择字段在概念上类似于单选按钮——它们也允许用户从一组选项中选择。但是,单选按钮的选项布局由我们控制,而<select>标签的外观则由浏览器决定。
选择字段也有一个更像复选框而不是单选框的变体。当给定multiple属性时,<select>标签将允许用户选择任意数量的选项,而不仅仅是单个选项。常规选择字段被绘制为下拉控件,只有在打开时才会显示非活动选项,而启用multiple的字段则同时显示多个选项,允许用户单独启用或禁用它们。
每个<option>标签都有一个值。这个值可以通过value属性定义。当没有给出该属性时,选项内的文本将作为其值。<select>元素的value属性反映当前选定的选项。然而,对于多个字段来说,这个属性并不太有意义,因为它只会给出当前所选选项中的一个的值。
<select>字段的<option>标签可以通过字段的options属性作为类数组对象访问。每个选项都有一个名为selected的属性,指示该选项当前是否被选中。该属性也可以被写入,以选择或取消选择一个选项。
此示例从多个选择字段中提取所选值,并利用这些值组成一个二进制数字。按住CTRL(或在Mac上按COMMAND)以选择多个选项。
<select multiple>
<option value="1">0001</option>
<option value="2">0010</option>
<option value="4">0100</option>
<option value="8">1000</option>
</select> = <span id="output">0</span>
<script>
let select = document.querySelector("select");
let output = document.querySelector("#output");
select.addEventListener("change", () => {
let number = 0;
for (let option of Array.from(select.options)) {
if (option.selected) {
number += Number(option.value);
}
}
output.textContent = number;
});
</script>
文件字段
文件字段最初被设计为一种通过表单从用户的计算机上传文件的方式。在现代浏览器中,它们还提供了一种从JavaScript程序读取此类文件的方法。该字段充当一种守门人。脚本不能简单地开始从用户的计算机读取私有文件,但如果用户在这样的字段中选择了一个文件,浏览器会将该操作解释为脚本可以读取该文件。
文件字段通常看起来像一个带有“选择文件”或“浏览”之类标签的按钮,旁边有所选文件的信息。
<input type="file">
<script>
let input = document.querySelector("input");
input.addEventListener("change", () => {
if (input.files.length > 0) {
let file = input.files[0];
console.log("You chose", file.name);
if (file.type) console.log("It has type", file.type);
}
});
</script>
文件字段元素的files属性是一个类数组对象(再次强调,不是真正的数组),包含在字段中选择的文件。它最初是空的。没有简单的file属性的原因在于文件字段还支持multiple属性,这使得可以同时选择多个文件。
文件中的对象具有诸如name(文件名)、size(文件的字节大小,8位的块)和type(文件的媒体类型,例如text/plain或image/jpeg)等属性。
它没有的属性是包含文件内容的属性。获取该内容的过程稍微复杂一些。由于从磁盘读取文件可能需要时间,因此接口是异步的,以避免冻结窗口。
<input type="file" multiple>
<script>
let input = document.querySelector("input");
input.addEventListener("change", () => {
for (let file of Array.from(input.files)) {
let reader = new FileReader();
reader.addEventListener("load", () => {
console.log("File", file.name, "starts with",
reader.result.slice(0, 20));
});
reader.readAsText(file);
}
});
</script>
读取文件是通过创建一个FileReader对象,为其注册一个“加载”事件处理程序,并调用其readAsText方法,同时传入我们想要读取的文件。一旦加载完成,读取器的result属性将包含文件的内容。
FileReaders在读取文件失败的任何原因时也会触发“错误”事件。错误对象本身将最终出现在阅读器的error属性中。这个接口在承诺成为语言的一部分之前设计。你可以像这样将其封装在一个承诺中:
function readFileText(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.addEventListener(
"load", () => resolve(reader.result));
reader.addEventListener(
"error", () => reject(reader.error));
reader.readAsText(file);
});
}
客户端数据存储
带有一些JavaScript的简单HTML页面可以成为“迷你应用程序”的一种很好的格式——小型辅助程序,自动化基本任务。通过将几个表单字段与事件处理程序连接,你可以完成从厘米和英寸之间转换到根据主密码和网站名称计算密码的任何事情。
当这样的应用程序需要在会话之间记住某些内容时,你不能使用JavaScript绑定——每次关闭页面时,这些绑定都会被丢弃。你可以设置一个服务器,将其连接到互联网,并让你的应用程序在那里存储某些内容(我们将在第二十章中看到如何做到这一点)。但那是很多额外的工作和复杂性。有时候,仅仅将数据保存在浏览器中就足够了。
localStorage对象可以用于以在页面重载后仍然存在的方式存储数据。该对象允许你根据名称存储字符串值。
localStorage.setItem("username", "marijn");
console.log(localStorage.getItem("username"));
// → marijn
localStorage.removeItem("username");
localStorage中的值会一直存在,直到被覆盖、使用removeItem移除,或用户清除他们的本地数据。
不同域名的网站获得不同的存储空间。这意味着由特定网站存储在localStorage中的数据,原则上只能被该网站上的脚本读取(和覆盖)。
浏览器确实对网站可以在localStorage中存储的数据大小施加限制。这种限制,加上填满用户硬盘垃圾数据并不盈利的事实,防止了该功能占用过多空间。
以下代码实现了一个粗略的记事本应用程序。它保持一组命名的笔记,并允许用户编辑笔记和创建新笔记。
Notes: <select></select> <button>Add</button><br>
<textarea style="width: 100%"></textarea>
<script>
let list = document.querySelector("select");
let note = document.querySelector("textarea");
let state;
function setState(newState) {
list.textContent = "";
for (let name of Object.keys(newState.notes)) {
let option = document.createElement("option");
option.textContent = name;
if (newState.selected == name) option.selected = true;
list.appendChild(option);
}
note.value = newState.notes[newState.selected];
localStorage.setItem("Notes", JSON.stringify(newState));
state = newState;
}
setState(JSON.parse(localStorage.getItem("Notes")) ?? {
notes: {"shopping list": "Carrots\nRaisins"},
selected: "shopping list"
});
list.addEventListener("change", () => {
setState({notes: state.notes, selected: list.value});
});
note.addEventListener("change", () => {
let {selected} = state;
setState({
notes: {...state.notes, [selected]: note.value},
selected
});
});
document.querySelector("button")
.addEventListener("click", () => {
let name = prompt("Note name");
if (name) setState({
notes: {...state.notes, [name]: ""},
selected: name
});
});
</script>
脚本从localStorage中的Notes值获取其初始状态,或者如果缺少该值,则创建一个只有购物清单的示例状态。从localStorage读取不存在的字段将返回null。将null传递给JSON.parse将使其解析字符串“null”并返回null。因此,??运算符可以用于在这种情况下提供默认值。
setState方法确保 DOM 显示给定状态,并将新状态存储到localStorage。事件处理程序调用此函数以移动到新状态。
示例中的...语法用于创建一个新的对象,该对象是旧状态.notes的克隆,但添加或覆盖了一个属性。它首先使用扩展语法添加旧对象中的属性,然后设置一个新属性。对象字面量中的方括号语法用于创建一个属性,其名称基于某个动态值。
还有一个对象,类似于localStorage,称为sessionStorage。两者之间的区别在于sessionStorage的内容在每个会话结束时会被遗忘,对于大多数浏览器而言,这意味着每当浏览器关闭时。
概要
在这一章中,我们讨论了 HTTP 协议的工作原理。客户端发送一个请求,该请求包含一个方法(通常是GET)和一个标识资源的路径。然后服务器决定如何处理该请求,并以状态码和响应体进行响应。请求和响应都可以包含提供附加信息的头。
浏览器 JavaScript 进行 HTTP 请求的接口称为fetch。发起请求的方式如下:
fetch("/18_http.xhtml").then(r => r.text()).then(text => {
console.log(`The page starts with ${text.slice(0, 15)}`);
});
浏览器通过发起GET请求来获取显示网页所需的资源。页面还可以包含表单,当表单被提交时,用户输入的信息会作为新页面请求发送。
HTML 可以表示多种类型的表单字段,如文本字段、复选框、多选字段和文件选择器。这些字段可以通过 JavaScript 进行检查和操作。字段变化时会触发“变化”事件,输入文本时会触发“输入”事件,并在具有键盘焦点时接收键盘事件。像value(对于文本和选择字段)或checked(对于复选框和单选按钮)这样的属性用于读取或设置字段的内容。
当表单被提交时,会在其上触发“提交”事件。JavaScript 处理程序可以在该事件上调用preventDefault来禁用浏览器的默认行为。表单字段元素也可以出现在表单标签之外。
当用户在文件选择字段中从本地文件系统选择一个文件时,可以使用FileReader接口在 JavaScript 程序中访问该文件的内容。
localStorage和sessionStorage对象可以用来以在页面重载时仍然保留信息的方式保存数据。第一个对象会永久保存数据(或者直到用户决定清除它),而第二个则在浏览器关闭之前保存数据。
练习
内容协商
HTTP 的一项功能称为内容协商。Accept请求头用于告诉服务器客户端希望获得哪种类型的文档。许多服务器会忽略该头,但当服务器知道有多种方式对资源进行编码时,它可以查看该头并发送客户端所偏好的格式。
URL[eloquentjavascript.net/author](https://eloquentjavascript.net/author)被配置为根据客户端请求的内容返回纯文本、HTML 或 JSON。这些格式通过标准化的媒体类型text/plain、text/html和application/json来识别。
发送请求以获取此资源的所有三种格式。使用传递给fetch的选项对象中的headers属性,将名为Accept的头设置为所需的媒体类型。
最后,尝试请求媒体类型application/rainbows+unicorns,看看会产生哪个状态码。
一个 JavaScript 工作台
构建一个接口,允许用户输入和运行 JavaScript 代码片段。
在<textarea>字段旁边放一个按钮,当按下时,使用我们在第十章中看到的Function构造函数将文本包装在一个函数中并调用它。将函数的返回值或它引发的任何错误转换为字符串并显示在文本字段下方。
康威的生命游戏
康威的生命游戏是一个简单的模拟,它在一个网格上创建人工“生命”,每个单元格要么是活的,要么是死的。在每一代(轮次)中,应用以下规则:
-
任何活细胞如果邻居少于两个或多于三个活邻居则死亡。
-
任何活细胞如果有两个或三个活邻居,则可以存活到下一代。
-
任何有恰好三个活邻居的死细胞变为活细胞。
邻居被定义为任何相邻的单元格,包括对角相邻的单元格。
请注意,这些规则是同时应用于整个网格,而不是逐个方格。这意味着邻居的计数是基于这一代开始时的情况,而在这一代中邻居单元格的变化不应影响给定单元格的新状态。
使用你认为合适的数据结构来实现这个游戏。使用Math.random最初以随机模式填充网格。将其显示为复选框字段的网格,并在旁边放置一个按钮以推进到下一代。当用户勾选或取消勾选复选框时,他们的更改应在计算下一代时考虑在内。
我看着眼前的各种颜色。我看着我的空白画布。然后,我尝试像塑造诗句的词语一样应用颜色,像塑造音乐的音符一样。
—胡安·米罗

第二十章:项目:一个像素艺术编辑器
之前章节的材料为你构建一个基本的web应用程序提供了所有必要的元素。在这一章,我们将正是这样做。
我们的应用程序将是一个像素绘制程序,允许你通过操控放大的图像视图逐个像素地修改图像,图像视图显示为一个有色方块的网格。你可以使用该程序打开图像文件,用鼠标或其他指针设备在其上涂鸦,并保存它们。这就是它的外观:

在计算机上绘画是很棒的。你不需要担心材料、技巧或天赋。你只需开始涂抹,看看最终会得到什么。
组件
应用程序的界面上方显示一个大的<canvas>元素,下面有多个表单字段。用户通过从<select>字段中选择一个工具,然后在画布上点击、触摸或拖动来绘制图像。有用于绘制单个像素或矩形的工具、填充区域的工具以及从图像中选取颜色的工具。
我们将把编辑器界面结构化为多个组件,这些对象负责DOM的一部分,并且可以包含其他组件。
应用程序的状态由当前图像、选定工具和选定颜色组成。我们将设置这样一个环境,使得状态存在于一个单一的值中,而界面组件始终根据当前状态来决定它们的外观。
为了理解这点的重要性,让我们考虑另一种选择——在整个界面中分散状态的片段。在某种程度上,这更容易编程。我们可以直接放入一个颜色字段,并在需要知道当前颜色时读取其值。
但随后我们添加了颜色选择器——一个工具,允许你点击图像以选择给定像素的颜色。为了保持颜色字段显示正确的颜色,该工具必须知道颜色字段的存在,并在每次选择新颜色时更新它。如果你再添加另一个地方使颜色可见(也许鼠标光标可以显示它),你也必须更新你的颜色更改代码,以保持同步。
实际上,这会造成一个问题,即界面中的每个部分都需要了解所有其他部分,这并不是很模块化。对于像本章中的小型应用程序,这可能不是问题。对于更大的项目,这可能会变成一个真正的噩梦。
为了原则上避免这个噩梦,我们将严格遵循数据流。有一个状态,界面是基于该状态绘制的。界面组件可以通过更新状态来响应用户的操作,此时这些组件有机会与这个新状态同步。
在实践中,每个组件被设置为在接收到新状态时,也会通知其子组件,前提是那些需要被更新。设置这个有点麻烦。使其更方便是许多浏览器编程库的主要卖点。但对于像这样的一个小应用,我们可以在没有这种基础设施的情况下完成。
对状态的更新以对象形式表示,我们称之为动作。组件可以创建这样的动作并分发它们——将其交给中心状态管理函数。该函数计算下一个状态,然后界面组件更新为这个新状态。
我们正在将运行用户界面的杂乱任务进行结构化。尽管与DOM相关的部分仍然充满了副作用,但它们由一个概念上简单的主干支撑:状态更新周期。状态决定了DOM的外观,DOM事件改变状态的唯一方式是通过向状态分发动作。
这种方法有许多变体,每种都有其自身的优点和问题,但它们的核心思想是相同的:状态变化应通过单一的、明确的通道进行,而不是随处发生。
我们的组件将是符合接口的类。它们的构造函数接受一个状态——这可能是整个应用状态或较小的值(如果不需要访问所有内容)——并利用它构建一个dom属性。这是表示组件的DOM元素。大多数构造函数还将接受一些不会随时间变化的其他值,例如它们可以用于分发动作的函数。
每个组件都有一个syncState方法,用于将其同步到新的状态值。该方法接受一个参数,即状态,其类型与构造函数的第一个参数相同。
状态
应用状态将是一个具有picture、tool和color属性的对象。picture本身是一个对象,存储图片的宽度、高度和像素内容。像素按行存储在一个单一数组中,从上到下。
class Picture {
constructor(width, height, pixels) {
this.width = width;
this.height = height;
this.pixels = pixels;
}
static empty(width, height, color) {
let pixels = new Array(width * height).fill(color);
return new Picture(width, height, pixels);
}
pixel(x, y) {
return this.pixels[x + y * this.width];
}
draw(pixels) {
let copy = this.pixels.slice();
for (let {x, y, color} of pixels) {
copy[x + y * this.width] = color;
}
return new Picture(this.width, this.height, copy);
}
}
我们希望能够将图片视为一个不可变的值,原因将在本章后面再提到。但我们有时也需要一次更新一大堆像素。为此,该类具有一个draw方法,期望接收一个更新的像素数组——包含x、y和颜色属性的对象——并使用这些像素覆盖创建一张新图片。此方法使用没有参数的slice来复制整个像素数组——切片的开始默认为0,结束默认为数组的长度。
空方法使用了我们之前未见过的两种数组功能。数组构造函数可以用一个数字调用,以创建一个给定长度的空数组。然后可以使用fill方法将该数组填充为给定的值。这些用于创建一个所有像素都具有相同颜色的数组。
颜色以字符串形式存储,包含传统的CSS颜色代码,由井号(#)后跟六个十六进制(基数16)数字组成——两个用于红色成分,两个用于绿色成分,两个用于蓝色成分。这是一种相对隐晦且不方便的书写颜色方式,但这是HTML颜色输入字段使用的格式,并且可以在画布绘制上下文的fillStyle属性中使用,因此在我们将在此程序中使用颜色的方式上,这足够实用。
黑色,所有组件均为零,写作#000000,而亮粉色看起来像#ff00ff,其中红色和蓝色成分的最大值为255,以十六进制数字(使用a到f表示数字10到15)表示为ff。
我们将允许接口以对象的形式分发动作,这些对象的属性会覆盖之前状态的属性。当用户更改颜色字段时,可以分发一个对象,如{color: field.value},从中这个更新函数可以计算出一个新的状态。
function updateState(state, action) {
return {...state, ...action};
}
这种模式中,使用对象扩展先添加现有对象的属性,然后覆盖其中一些属性,在使用不可变对象的JavaScript代码中很常见。
DOM构建
界面组件的主要功能之一是创建DOM结构。我们同样不想直接使用冗长的DOM方法,因此这里是稍微扩展版的elt函数:
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
这个版本与我们在第十六章中使用的版本之间的主要区别在于,它将属性分配给DOM节点,而不是属性值。这意味着我们不能用它来设置任意属性,但我们可以用它来设置值不是字符串的属性,例如onclick,可以设置为一个函数以注册点击事件处理程序。
这允许我们以这种方便的方式注册事件处理程序:
<body>
<script>
document.body.appendChild(elt("button", {
onclick: () => console.log("click")
}, "The button"));
</script>
</body>
画布
我们将定义的第一个组件是界面的一部分,它将图片显示为一个彩色方块的网格。这个组件负责两件事:显示一张图片并将该图片上的指针事件传递给应用程序的其余部分。
因此,我们可以将其定义为一个只知道当前图片的组件,而不是整个应用程序状态。因为它不知道整个应用程序的工作方式,所以不能直接分发动作。相反,在响应指针事件时,它调用由创建它的代码提供的回调函数,该函数将处理特定于应用程序的部分。
const scale = 10;
class PictureCanvas {
constructor(picture, pointerDown) {
this.dom = elt("canvas", {
onmousedown: event => this.mouse(event, pointerDown),
ontouchstart: event => this.touch(event, pointerDown)
});
this.syncState(picture);
}
syncState(picture) {
if (this.picture == picture) return;
this.picture = picture;
drawPicture(this.picture, this.dom, scale);
}
}
我们将每个像素绘制为10x10的方块,具体由比例常量决定。为了避免不必要的工作,组件跟踪其当前图片,仅在syncState获得新图片时进行重绘。
实际的绘制函数根据比例和图片大小设置画布的大小,并用一系列方块填充,每个方块对应一个像素。
function drawPicture(picture, canvas, scale) {
canvas.width = picture.width * scale;
canvas.height = picture.height * scale;
let cx = canvas.getContext("2d");
for (let y = 0; y < picture.height; y++) {
for (let x = 0; x < picture.width; x++) {
cx.fillStyle = picture.pixel(x, y);
cx.fillRect(x * scale, y * scale, scale, scale);
}
}
}
当鼠标左键在图片画布上被按下时,组件调用pointerDown回调,传递被点击的像素位置——以图片坐标表示。这将用于实现鼠标与图片的交互。回调可以返回另一个回调函数,以在按钮按下时,指针移动到不同的像素时收到通知。
PictureCanvas.prototype.mouse = function(downEvent, onDown) {
if (downEvent.button != 0) return;
let pos = pointerPosition(downEvent, this.dom);
let onMove = onDown(pos);
if (!onMove) return;
let move = moveEvent => {
if (moveEvent.buttons == 0) {
this.dom.removeEventListener("mousemove", move);
} else {
let newPos = pointerPosition(moveEvent, this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
}
};
this.dom.addEventListener("mousemove", move);
};
function pointerPosition(pos, domNode) {
let rect = domNode.getBoundingClientRect();
return {x: Math.floor((pos.clientX - rect.left) / scale),
y: Math.floor((pos.clientY - rect.top) / scale)};
}
由于我们知道像素的大小,并且可以使用getBoundingClientRect找到画布在屏幕上的位置,因此可以将鼠标事件坐标(clientX和clientY)转换为图片坐标。这些坐标总是向下取整,以便指向特定的像素。
对于触摸事件,我们需要做类似的事情,但使用不同的事件,并确保在“touchstart”事件上调用preventDefault以防止平移。
PictureCanvas.prototype.touch = function(startEvent, onDown) {
let pos = pointerPosition(startEvent.touches[0], this.dom);
let onMove = onDown(pos);
startEvent.preventDefault();
if (!onMove) return;
let move = moveEvent => {
let newPos = pointerPosition(moveEvent.touches[0], this.dom);
if (newPos.x == pos.x && newPos.y == pos.y) return;
pos = newPos;
onMove(newPos);
};
let end = () => {
this.dom.removeEventListener("touchmove", move);
this.dom.removeEventListener("touchend", end);
};
this.dom.addEventListener("touchmove", move);
this.dom.addEventListener("touchend", end);
};
对于触摸事件,clientX和clientY在事件对象上并不可用,但我们可以使用touches属性中第一个触摸对象的坐标。
应用程序
为了能够逐步构建应用程序,我们将主组件实现为围绕图片画布和动态工具与控件集合的外壳,并将其传递给构造函数。
控件是出现在图片下方的界面元素。它们将作为组件构造函数的数组提供。
工具用于绘制像素或填充区域。应用程序将可用工具的集合显示为一个<select>字段。当前选择的工具决定用户使用指针设备与图片互动时会发生什么。可用工具的集合作为一个对象提供,该对象将下拉字段中显示的名称映射到实现这些工具的函数。这些函数接收图片位置、当前应用程序状态和分发函数作为参数。它们可能返回一个移动处理函数,当指针移动到不同的像素时,以新的位置和当前状态作为参数被调用。
class PixelEditor {
constructor(state, config) {
let {tools, controls, dispatch} = config;
this.state = state;
this.canvas = new PictureCanvas(state.picture, pos => {
let tool = tools[this.state.tool];
let onMove = tool(pos, this.state, dispatch);
if (onMove) return pos => onMove(pos, this.state);
});
this.controls = controls.map(
Control => new Control(state, config));
this.dom = elt("div", {}, this.canvas.dom, elt("br"),
...this.controls.reduce(
(a, c) => a.concat(" ", c.dom), []));
}
syncState(state) {
this.state = state;
this.canvas.syncState(state.picture);
for (let ctrl of this.controls) ctrl.syncState(state);
}
}
传递给PictureCanvas的指针处理程序使用适当的参数调用当前选定的工具,并且如果返回一个移动处理程序,则还会调整它以接收状态。
所有控件都在this.controls中构建并存储,以便在应用程序状态变化时进行更新。对reduce的调用在控件的DOM元素之间引入空格。这样,它们看起来不会那么紧凑。
第一个控件是工具选择菜单。它创建一个<select>元素,为每个工具设置一个选项,并设置一个“change”事件处理程序,当用户选择不同工具时更新应用程序状态。
class ToolSelect {
constructor(state, {tools, dispatch}) {
this.select = elt("select", {
onchange: () => dispatch({tool: this.select.value})
}, ...Object.keys(tools).map(name => elt("option", {
selected: name == state.tool
}, name)));
this.dom = elt("label", null, " Tool: ", this.select);
}
syncState(state) { this.select.value = state.tool; }
}
通过将标签文本和字段包装在<label>元素中,我们告诉浏览器该标签属于该字段,这样你可以点击标签来聚焦该字段。
我们还需要能够更改颜色,因此让我们添加一个控制项。具有颜色类型属性的HTML<input>元素为我们提供了一个专门用于选择颜色的表单字段。这样的字段值始终是#RRGGBB格式的CSS颜色代码(红、绿和蓝组件,每种颜色两个数字)。当用户与之互动时,浏览器将显示颜色选择器界面。
根据浏览器的不同,颜色选择器可能看起来像这样:

此控件创建一个这样的区域,并将其与应用程序状态的颜色属性保持同步。
class ColorSelect {
constructor(state, {dispatch}) {
this.input = elt("input", {
type: "color",
value: state.color,
onchange: () => dispatch({color: this.input.value})
});
this.dom = elt("label", null, " Color: ", this.input);
}
syncState(state) { this.input.value = state.color; }
}
绘图工具
在我们能够绘制任何内容之前,我们需要实现控制画布上鼠标或触摸事件功能的工具。
最基本的工具是绘图工具,它将你点击或轻触的任何像素更改为当前选定的颜色。它派发一个操作,将图片更新为一个版本,其中所指向的像素被赋予当前选定的颜色。
function draw(pos, state, dispatch) {
function drawPixel({x, y}, state) {
let drawn = {x, y, color: state.color};
dispatch({picture: state.picture.draw([drawn])});
}
drawPixel(pos, state);
return drawPixel;
}
函数立即调用drawPixel函数,但也返回它,以便在用户拖动或滑动图片时,对新触摸的像素再次调用。
为了绘制更大的形状,快速创建矩形是很有用的。矩形工具在你开始拖动的点和你拖动到的点之间绘制一个矩形。
function rectangle(start, state, dispatch) {
function drawRectangle(pos) {
let xStart = Math.min(start.x, pos.x);
let yStart = Math.min(start.y, pos.y);
let xEnd = Math.max(start.x, pos.x);
let yEnd = Math.max(start.y, pos.y);
let drawn = [];
for (let y = yStart; y <= yEnd; y++) {
for (let x = xStart; x <= xEnd; x++) {
drawn.push({x, y, color: state.color});
}
}
dispatch({picture: state.picture.draw(drawn)});
}
drawRectangle(start);
return drawRectangle;
}
这个实现中的一个重要细节是,在拖动时,矩形是在原始状态下在图片上重新绘制的。这样,你可以在创建矩形时将其变大或变小,而不会在最终图片中留下中间的矩形。这是不可变图片对象有用的原因之一——稍后我们将看到另一个原因。
实现填充功能稍微复杂一些。这是一个工具,可以填充指针下的像素以及所有具有相同颜色的相邻像素。“相邻”意味着直接水平或垂直相邻,而不是对角线相邻。这张图片展示了在标记像素处使用填充工具时上色的像素集合:

有趣的是,我们将要做的方式有点像第七章中的路径查找代码。尽管那段代码在图形中搜索以找到路线,这段代码则在网格中搜索以找到所有“连接”的像素。跟踪一组分支可能路线的问题是类似的。
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0},
{dx: 0, dy: -1}, {dx: 0, dy: 1}];
function fill({x, y}, state, dispatch) {
let targetColor = state.picture.pixel(x, y);
let drawn = [{x, y, color: state.color}];
let visited = new Set();
for (let done = 0; done < drawn.length; done++) {
for (let {dx, dy} of around) {
let x = drawn[done].x + dx, y = drawn[done].y + dy;
if (x >= 0 && x < state.picture.width &&
y >= 0 && y < state.picture.height &&
!visited.has(x + "," + y) &&
state.picture.pixel(x, y) == targetColor) {
drawn.push({x, y, color: state.color});
visited.add(x + "," + y);
}
}
}
dispatch({picture: state.picture.draw(drawn)});
}
绘制的像素数组同时充当函数的工作列表。对于每个到达的像素,我们必须查看是否有任何相邻像素具有相同的颜色并且尚未被覆盖。随着新像素的添加,循环计数器落后于绘制数组的长度。它前面的任何像素仍需要被探索。当它追上长度时,就没有未探索的像素了,函数也就完成了。
最终的工具是一个颜色选择器,它允许你在图片上指向一个颜色,以将其用作当前绘图颜色。
function pick(pos, state, dispatch) {
dispatch({color: state.picture.pixel(pos.x, pos.y)});
}
保存与加载
当我们完成了我们的杰作时,我们会想把它保存下来。我们应该添加一个按钮,用于将当前图片作为图像文件下载。这个控件提供了这个按钮:
class SaveButton {
constructor(state) {
this.picture = state.picture;
this.dom = elt("button", {
onclick: () => this.save()
}, " Save");
}
save() {
let canvas = elt("canvas");
drawPicture(this.picture, canvas, 1);
let link = elt("a", {
href: canvas.toDataURL(),
download: "pixelart.png"
});
document.body.appendChild(link);
link.click();
link.remove();
}
syncState(state) { this.picture = state.picture; }
}
该组件跟踪当前图片,以便在保存时可以访问它。为了创建图像文件,它使用一个<canvas>元素,在其上绘制图片(每个像素按一比一的比例)。
canvas元素上的toDataURL方法创建一个使用data:方案的URL。与http:和https: URL不同,数据 URL在URL中包含整个资源。它们通常非常长,但它们允许我们在浏览器中创建指向任意图片的有效链接。
为了让浏览器实际下载图片,我们接着创建一个链接元素,指向这个 URL,并带有download属性。这样的链接在被点击时,会使浏览器显示文件保存对话框。我们将该链接添加到文档中,模拟一次点击,然后再将其移除。你可以用浏览器技术做很多事情,但有时候实现方式相当奇怪。
而且情况还会更糟。我们还希望能够将现有的图像文件加载到我们的应用程序中。为此,我们再次定义一个按钮组件。
class LoadButton {
constructor(_, {dispatch}) {
this.dom = elt("button", {
onclick: () => startLoad(dispatch)
}, " Load");
}
syncState() {}
}
function startLoad(dispatch) {
let input = elt("input", {
type: "file",
onchange: () => finishLoad(input.files[0], dispatch)
});
document.body.appendChild(input);
input.click();
input.remove();
}
要访问用户计算机上的文件,我们需要用户通过文件输入字段选择文件。但我们不希望加载按钮看起来像文件输入字段,因此我们在按钮点击时创建文件输入,然后假装这个文件输入被点击了。
当用户选择一个文件时,我们可以使用FileReader来访问其内容,再次以数据 URL 的形式。该 URL 可以用来创建一个<img>元素,但由于我们无法直接访问该图像中的像素,因此无法从中创建Picture对象。
function finishLoad(file, dispatch) {
if (file == null) return;
let reader = new FileReader();
reader.addEventListener("load", () => {
let image = elt("img", {
onload: () => dispatch({
picture: pictureFromImage(image)
}),
src: reader.result
});
});
reader.readAsDataURL(file);
}
为了访问像素,我们必须首先将图片绘制到<canvas>元素上。canvas上下文具有getImageData方法,允许脚本读取其像素。因此,一旦图片在canvas上,我们就可以访问它并构建一个Picture对象。
function pictureFromImage(image) {
let width = Math.min(100, image.width);
let height = Math.min(100, image.height);
let canvas = elt("canvas", {width, height});
let cx = canvas.getContext("2d");
cx.drawImage(image, 0, 0);
let pixels = [];
let {data} = cx.getImageData(0, 0, width, height);
function hex(n) {
return n.toString(16).padStart(2, "0");
}
for (let i = 0; i < data.length; i += 4) {
let [r, g, b] = data.slice(i, i + 3);
pixels.push("#" + hex(r) + hex(g) + hex(b));
}
return new Picture(width, height, pixels);
}
我们将把图像的大小限制在100*×*100像素,因为任何更大的图片在我们的显示器上看起来都会显得巨大,并可能会减慢界面速度。
getImageData返回的对象的数据属性是一个颜色分量数组。对于由参数指定的矩形中的每个像素,它包含四个值,代表像素颜色的红、绿、蓝和alpha分量,数值范围在0到255之间。alpha部分表示不透明度——当它为0时,像素完全透明,而当它为255时,像素完全不透明。对于我们的目的,我们可以忽略它。
每个组件的两个十六进制数字,如我们在颜色标记法中使用的,正好对应于0到255的范围——两个基数为16的数字可以表示16² = 256个不同的数字。数字的toString方法可以接受一个基数作为参数,因此n.toString(16)会生成一个基数为16的字符串表示。我们必须确保每个数字占用两个数字,因此十六进制辅助函数调用padStart在必要时添加前导0。
我们现在可以加载和保存了!这只剩下一个功能,我们就完成了。
撤销历史
因为编辑过程的一半是犯小错误并纠正它们,绘图程序中的一个重要功能是撤销历史。
为了能够撤销更改,我们需要存储图像的先前版本。由于图像是不可变值,这很简单。但这确实需要在应用程序状态中添加一个额外的字段。
我们将添加一个done数组来保留图像的先前版本。维护这个属性需要一个更复杂的状态更新函数,以将图像添加到数组中。
不过,我们并不想存储每个更改——只存储时间间隔一定的更改。为了做到这一点,我们需要一个第二个属性doneAt,用来跟踪我们上次在历史中存储图像的时间。
function historyUpdateState(state, action) {
if (action.undo == true) {
if (state.done.length == 0) return state;
return {
...state,
picture: state.done[0],
done: state.done.slice(1),
doneAt: 0
};
} else if (action.picture &&
state.doneAt < Date.now() - 1000) {
return {
...state,
...action,
done: [state.picture, ...state.done],
doneAt: Date.now()
};
} else {
return {...state, ...action};
}
}
当操作是撤销操作时,函数从历史记录中获取最近的图像,并将其设为当前图像。它将doneAt设置为零,以确保下一个更改将图像存回历史中,让你在需要时可以恢复到这个图像。
否则,如果操作包含新图像,而我们上次存储的时间超过了一秒(1,000毫秒),则done和doneAt属性会更新以存储之前的图像。
撤销按钮组件并没有太多功能。它在被点击时分发撤销操作,当没有可以撤销的内容时则禁用自身。
class UndoButton {
constructor(state, {dispatch}) {
this.dom = elt("button", {
onclick: () => dispatch({undo: true}),
disabled: state.done.length == 0
}, " Undo");
}
syncState(state) {
this.dom.disabled = state.done.length == 0;
}
}
让我们绘图。
为了设置应用程序,我们需要创建一个状态、一组工具、一组控件和一个调度函数。我们可以将它们传递给PixelEditor构造函数来创建主要组件。由于我们在练习中需要创建多个编辑器,我们首先定义一些绑定。
const startState = {
tool: "draw",
color: "#000000",
picture: Picture.empty(60, 30, "#f0f0f0"),
done: [],
doneAt: 0
};
const baseTools = {draw, fill, rectangle, pick};
const baseControls = [
ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton
];
function startPixelEditor({state = startState,
tools = baseTools,
controls = baseControls}) {
let app = new PixelEditor(state, {
tools,
controls,
dispatch(action) {
state = historyUpdateState(state, action);
app.syncState(state);
}
});
return app.dom;
}
在解构对象或数组时,可以在绑定名称后使用=来为绑定提供默认值,当属性缺失或为undefined时使用。startPixelEditor函数利用这一点接受一个具有多个可选属性的对象作为参数。例如,如果你不提供tools属性,tools将绑定到baseTools。
这就是我们如何在屏幕上获得实际编辑器的方式:
<div></div>
<script>
document.querySelector("div").appendChild(startPixelEditor({}));
</script>
为什么这会如此困难?
浏览器技术真是令人惊叹。它提供了一套强大的接口构建块、样式和操作它们的方法,以及检查和调试应用程序的工具。你为浏览器编写的软件可以在地球上几乎每台计算机和手机上运行。
与此同时,浏览器技术非常荒谬。你必须学习大量愚蠢的技巧和晦涩的事实才能掌握它,而它提供的默认编程模型问题重重,以至于大多数程序员宁愿用几层抽象来掩盖它,而不是直接处理它。
尽管情况确实在改善,但主要以添加更多元素来解决不足的形式进行——这创造了更多的复杂性。被一百万个网站使用的功能是无法真正替代的。即使可以替代,也很难决定用什么来替代。
技术从来不会在真空中存在——我们受限于我们的工具以及产生这些工具的社会、经济和历史因素。这可能让人感到恼火,但一般来说,努力建立对现有技术现实如何运作及其原因的良好理解,比愤怒抗争或期待另一种现实要更具生产力。
新的抽象可以是有帮助的。我在本章中使用的组件模型和数据流约定是一种粗略的形式。如前所述,有一些库试图使用户界面编程变得更愉快。在写作时,React和Svelte是流行的选择,但还有一整套这样的框架。如果你对编写网页应用感兴趣,我建议你调查一下其中的一些,以了解它们是如何运作的,以及它们提供了什么好处。
练习
我们的程序仍然有改进的空间。让我们增加一些功能作为练习。
键盘绑定
为应用程序添加键盘快捷键。工具名称的首字母选择该工具,而CTRL-Z或COMMAND-Z则激活撤销。
通过修改PixelEditor组件来做到这一点。在包裹的<div>元素中添加一个tabIndex属性值为0,以便它可以接收键盘焦点。注意,属性对应于tabindex 属性被称为tabIndex,I大写,而我们的elt函数期望属性名称。直接在该元素上注册键盘事件处理程序。这意味着你必须点击、触摸或通过标签切换到应用程序,然后才能用键盘与之交互。
请记住,键盘事件有ctrlKey和metaKey(在Mac上为COMMAND)属性,你可以使用它们来查看这些键是否被按下。
高效绘图
在绘图过程中,我们的应用程序大部分工作都发生在drawPicture中。创建一个新状态并更新其余的DOM并不太昂贵,但重绘画布上的所有像素则需要相当多的工作。
找到一种方法,通过仅重绘实际改变的像素来加速PictureCanvas的syncState方法。
请记住,drawPicture也被保存按钮使用,因此如果你更改它,请确保更改不会破坏旧的使用方式,或者创建一个不同名称的新版本。
还要注意,通过设置<canvas>元素的宽度或高度属性来更改其大小,会清除它,使其再次完全透明。
圆形
定义一个名为圆形的工具,当你拖动时绘制一个填充的圆。圆心位于拖动或触摸手势开始的点,其半径由拖动的距离决定。
适当的线条
这比前面的三个练习要复杂,需要你设计一个解决非平凡问题的方案。在开始这个练习之前,确保你有足够的时间和耐心,并且不要因为最初的失败而气馁。
在大多数浏览器中,当你选择绘图工具并快速拖动图像时,你不会得到一条闭合的线条。而是会得到带有间隙的点,因为"mousemove"或"touchmove"事件没有足够快地触发以击中每个像素。
改进绘图工具,使其绘制完整的线条。这意味着你需要让动作处理函数记住上一个位置,并将其与当前的位置连接起来。
为此,由于像素之间的距离可以是任意的,你需要编写一个通用的绘线函数。
两个像素之间的线条是一个连接的像素链,尽可能地直,从起点到终点。对角相邻的像素算作连接。倾斜的线条应该像左侧的图片,而不是右侧的图片。

最后,如果我们有一段代码可以在两个任意点之间绘制一条线,我们也可以用它来定义一个线条工具,该工具在拖动的起点和终点之间绘制一条直线。
第三部分:Node
一位学生问:“古代程序员只使用简单的机器而没有编程语言,然而他们却写出了美丽的程序。我们为什么要使用复杂的机器和编程语言?”傅子回答:“古代的建筑者只用木棍和泥土,然而他们却建造出了美丽的茅屋。”
—元马大师,编程之书

第二十一章:NODE.JS
到目前为止,我们只在一个环境中使用了JavaScript语言:浏览器。本章和下一章将简要介绍Node.js,这是一个允许你在浏览器外应用JavaScript技能的程序。通过它,你可以构建从小型命令行工具到支持动态网站的HTTP服务器的各种应用。
这些章节旨在教你Node.js使用的主要概念,并给你足够的信息来编写有用的程序。它们并不试图全面或深入地介绍该平台。
如果你想跟着本章的代码运行,你需要安装Node.js版本18或更高版本。要做到这一点,请访问[nodejs.org](https://nodejs.org)并按照你操作系统的安装说明进行操作。你还可以在那里找到Node.js的进一步文档。
背景
在构建通过网络通信的系统时,你管理输入和输出的方式——即从网络和硬盘读取和写入数据的方式——会对系统对用户或网络请求的响应速度产生很大影响。
在这样的程序中,异步编程通常是有帮助的。它允许程序同时与多个设备发送和接收数据,而无需复杂的线程管理和同步。
Node最初的构想是为了使异步编程变得简单方便。JavaScript非常适合像Node这样的系统。它是少数几种没有内置输入输出方式的编程语言之一。因此,JavaScript能够很好地适应Node对网络和文件系统编程的相当奇特的方法,而不会导致两个不一致的接口。在2009年,当Node被设计时,人们已经在浏览器中进行基于回调的编程,因此围绕该语言的社区已经习惯了异步编程风格。
node命令
当Node.js安装在系统上时,它提供了一个名为node的程序,用于运行JavaScript文件。假设你有一个名为hello.js的文件,包含以下代码:
let message = "Hello world";
console.log(message);
然后你可以像这样从命令行运行node以执行程序:
$ node hello.js
Hello world
Node中的console.log方法与浏览器中的作用类似。它输出一段文本。但在Node中,这段文本会发送到进程的标准输出流,而不是浏览器的JavaScript控制台。当从命令行运行node时,这意味着你会在终端中看到记录的值。
如果你运行node而不指定文件,它会给你一个提示,你可以在此输入JavaScript代码并立即看到结果。
$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$
进程绑定,就像控制台绑定一样,在Node中是全局可用的。它提供了多种方式来检查和操作当前程序。exit方法结束进程,并可以指定一个退出状态代码,这告诉启动Node的程序(在此情况下为命令行shell)程序是否成功完成(代码零)或遇到错误(任何其他代码)。
要查找传递给你脚本的命令行参数,你可以读取process.argv,它是一个字符串数组。请注意,它还包括node命令的名称和你的脚本名称,因此实际参数从索引2开始。如果showargv.js包含语句console.log(process.argv),你可以这样运行它:
$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]
所有标准的JavaScript全局绑定,如Array、Math和JSON,在Node的环境中也存在。与浏览器相关的功能,如document或prompt,则不存在。
模块
除了我提到的绑定,如console和process,Node在全局作用域中几乎没有其他绑定。如果你想访问内置功能,你必须请求模块系统。
Node最初使用基于require函数的CommonJS模块系统,我们在第十章中看到过。当你加载*.js文件时,它仍然会默认使用此系统。
但今天,Node也支持更现代的ES模块系统。当脚本的文件名以*.mjs结尾时,它被视为这样的模块,你可以在其中使用import和export(但不能使用require)。我们将在本章中使用ES模块。
当导入一个模块时——无论是使用require还是import——Node需要将给定的字符串解析为一个实际可以加载的文件。以/、./或../开头的名称将相对于当前模块的路径解析为文件。在这里,.表示当前目录,../表示上一级目录,/表示文件系统的根目录。如果你从文件/tmp/robot/robot.mjs请求"./graph.mjs",Node将尝试加载文件/tmp/robot/graph.mjs。
当导入的字符串看起来不是相对路径或绝对路径时,假定它指的是内置模块或安装在node_modules目录中的模块。例如,从node:fs导入将为你提供Node的内置文件系统模块。导入robot可能会尝试加载在node_modules/robot/中找到的库。通常会使用NPM安装这些库,我们稍后会回到这个话题。
让我们建立一个由两个文件组成的小项目。第一个文件叫做main.mjs,它定义了一个可以从命令行调用的脚本,用于反转字符串。
import {reverse} from "./reverse.mjs";
// Index 2 holds the first actual command line argument
let argument = process.argv[2];
console.log(reverse(argument));
文件reverse.mjs定义了一个用于反转字符串的库,可以被这个命令行工具和需要直接访问字符串反转功能的其他脚本使用。
export function reverse(string) {
return Array.from(string).reverse().join("");
}
请记住,export用于声明一个绑定是模块接口的一部分。这允许main.mjs导入并使用该函数。
现在我们可以这样调用我们的工具:
$ node main.mjs JavaScript
tpircSavaJ
使用NPM安装
NPM在第十章中介绍,是一个在线JavaScript模块库,其中许多模块是专门为Node编写的。当你在计算机上安装Node时,你还会得到npm命令,可以用来与这个库进行交互。
NPM的主要用途是下载包。我们在第十章中看到了ini包。我们可以使用NPM在我们的计算机上获取并安装该包。
$ npm install ini
added 1 package in 723ms
$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }
运行npm install后,NPM将创建一个名为node_modules的目录。该目录下将包含一个ini目录,其中包含库。你可以打开它并查看代码。当我们导入ini时,该库被加载,我们可以调用它的解析属性来解析配置文件。
默认情况下,NPM会在当前目录下安装包,而不是在中心位置。如果你习惯于其他包管理器,这可能看起来不寻常,但它有其优势——它让每个应用程序完全控制其安装的包,并更容易管理版本以及在删除应用程序时进行清理。
包文件
在运行npm install安装某个包后,你会发现当前目录下不仅有一个node_modules目录,还有一个名为package.json的文件。建议每个项目都有这样的文件。你可以手动创建它,或运行npm init。此文件包含关于项目的信息,例如其名称和版本,并列出其依赖项。
从第七章的机器人模拟,作为第十章练习中的模块化,可能会有一个这样的package.json文件:
{
"author": "Marijn Haverbeke",
"name": "eloquent-javascript-robot",
"description": "Simulation of a package-delivery robot",
"version": "1.0.0",
"main": "run.mjs",
"dependencies": {
"dijkstrajs": "¹.0.1",
"random-item": "¹.0.0"
},
"license": "ISC"
}
当你运行npm install而不指定要安装的包时,NPM将安装package.json中列出的依赖项。当你安装一个未在依赖项中列出的特定包时,NPM会将其添加到package.json中。
版本
package.json文件列出了程序自身的版本以及其依赖项的版本。版本是处理包独立演变的方式,编写的代码在某个时间点与包的版本兼容,可能在后来的修改版本中不再兼容。
NPM要求其包遵循一种称为语义版本控制的模式,该模式在版本号中编码了一些关于哪些版本是兼容(不破坏旧接口)的信息。语义版本由三个用句点分隔的数字组成,例如2.3.0。每次添加新功能时,中间的数字必须增加。每当破坏兼容性时,即现有代码使用的包可能与新版本不兼容,首个数字必须增加。
在package.json中,依赖项版本号前的插入符号(^)表示可以安装与给定数字兼容的任何版本。例如,“².3.0”意味着允许任何大于或等于2.3.0且小于3.0.0的版本。
npm命令也用于发布新包或包的新版本。如果在包含package.json文件的目录中运行npm publish,它将把JSON文件中列出的名称和版本的包发布到注册表。任何人都可以向NPM发布包——但仅限于尚未使用的包名,因为如果随机用户可以更新现有包,那就不好了。
本书不会深入探讨NPM的使用细节。请参考 www.npmjs.com 以获取更多文档和包搜索方法。
文件系统模块
Node中最常用的内置模块之一是node:fs模块,它代表“文件系统”。它导出用于处理文件和目录的函数。
例如,名为readFile的函数读取一个文件,然后调用回调函数传递文件内容。
import {readFile} from "node:fs";
readFile("file.txt", "utf8", (error, text) => {
if (error) throw error;
console.log("The file contains:", text);
});
readFile的第二个参数指示用于将文件解码为字符串的字符编码。文本可以以多种方式编码为二进制数据,但大多数现代系统使用UTF-8。除非你有理由相信使用了其他编码,否则在读取文本文件时传递“utf8”。如果不传递编码,Node会假设你对二进制数据感兴趣,并会返回一个Buffer对象,而不是字符串。这个对象类似数组,包含表示文件中字节(8位数据块)的数字。
import {readFile} from "node:fs";
readFile("file.txt", (error, buffer) => {
if (error) throw error;
console.log("The file contained", buffer.length, "bytes.",
"The first byte is:", buffer[0]);
});
一个类似的函数writeFile用于将文件写入磁盘。
import {writeFile} from "node:fs";
writeFile("graffiti.txt", "Node was here", err => {
if (err) console.log(`Failed to write file: ${err}`);
else console.log("File written.");
});
在这里不需要指定编码——writeFile会假设当它接收到一个字符串而不是Buffer对象时,应使用其默认字符编码(UTF-8)将其作为文本写出。
node:fs模块包含许多其他有用的函数:readdir会将目录中的文件作为字符串数组返回,stat会检索文件信息,rename会重命名文件,unlink会删除文件,等等。有关具体信息,请查看 nodejs.org 的文档。
这些函数中的大多数将回调函数作为最后一个参数,调用时要么带有错误(第一个参数),要么带有成功结果(第二个参数)。正如我们在第十一章中看到的,这种编程风格有缺点——最大的一个是错误处理变得冗长且容易出错。
node:fs/promises模块导出了与旧的node:fs模块大部分相同的函数,但使用了Promise而不是回调函数。
import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
.then(text => console.log("The file contains:", text));
有时你并不需要异步操作,它反而会造成困扰。node:fs中的许多函数也有同步变体,其名称后面加上Sync。比如,readFile的同步版本叫做readFileSync。
import {readFileSync} from "node:fs";
console.log("The file contains:",
readFileSync("file.txt", "utf8"));
请注意,在执行这样的同步操作时,程序会完全停止。如果它应该响应用户或网络上的其他机器,被阻塞在同步操作上可能会造成令人烦恼的延迟。
HTTP 模块
另一个核心模块叫做node:http。它提供了运行 HTTP 服务器的功能。
启动 HTTP 服务器所需的就是这些:
import {createServer} from "node:http";
let server = createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/html"});
response.write(`
<h1>Hello!</h1>
<p>You asked for <code>${request.url}</code></p>`);
response.end();
});
server.listen(8000);
console.log("Listening! (port 8000)");
如果你在自己的机器上运行这个脚本,你可以将你的 Web 浏览器指向http://localhost:8000/hello以向你的服务器发出请求。它将以一个小的 HTML 页面响应。
传递给createServer的函数在每次客户端连接到服务器时被调用。请求和响应绑定是表示传入和传出数据的对象。第一个包含有关请求的信息,例如它的url属性,告诉我们请求是发往哪个 URL 的。
当你在浏览器中打开该页面时,它会向你自己的计算机发送请求。这会导致服务器函数运行并发送回响应,你随后可以在浏览器中看到。
要向客户端发送内容,你需要在响应对象上调用方法。第一个是writeHead,它将写出响应头(见第十八章)。你需要提供状态码(在这种情况下是200,表示“OK”)和一个包含头部值的对象。示例将Content-Type头设置为通知客户端我们将返回一个 HTML 文档。
接下来,实际的响应主体(文档本身)通过response.write发送。如果你想逐块发送响应,可以多次调用此方法,例如,随着数据的可用性将数据流式传输给客户端。最后,response.end表示响应结束。
对server.listen的调用使服务器开始在8000端口等待连接。这就是为什么你必须连接到localhost:8000来与此服务器进行通信,而不是仅仅使用localhost,因为那将使用默认端口80。
当你运行这个脚本时,进程只是静静地等待。当一个脚本正在监听事件时——在这种情况下,是网络连接——node不会在达到脚本末尾时自动退出。要关闭它,请按CTRL-C。
一个真正的 Web 服务器通常比示例中的服务器做得更多——它查看请求的方法(method属性)以查看客户端正在尝试执行什么操作,并查看请求的 URL 以找出正在对哪个资源执行此操作。我们将在本章后面看到一个更高级的服务器。
node:http模块还提供了一个请求函数,可用于发起 HTTP 请求。然而,与我们在第十八章中看到的fetch相比,它使用起来要繁琐得多。幸运的是,fetch在 Node 中也作为全局绑定可用。除非你想做一些非常具体的事情,例如在数据通过网络传入时逐块处理响应文档,否则我建议使用fetch。
流
HTTP 服务器可以写入的响应对象是一个可写流对象的示例,这是 Node 中广泛使用的概念。这些对象具有write方法,可以传入一个字符串或Buffer对象,以将内容写入流中。它们的end方法关闭流,并且在关闭之前可以选择性地接收一个值以写入流。这两个方法也可以接受一个回调作为额外参数,当写入或关闭完成时会调用该回调。
可以使用来自node:fs模块的createWriteStream函数创建一个指向文件的可写流。然后,可以在生成的对象上使用write方法逐块写入文件,而不是像writeFile那样一次性写入。
可读流稍微复杂一些。HTTP 服务器回调的请求参数是一个可读流。从流中读取是通过事件处理程序而不是方法来完成的。
在 Node 中发出事件的对象有一个名为on的方法,类似于浏览器中的addEventListener方法。你给它一个事件名称和一个函数,它会注册该函数,以便在发生给定事件时调用。
可读流有data和end事件。每当数据到达时,第一种事件会被触发,而第二种事件则在流结束时被调用。该模型最适合于流式处理数据,即使整个文档尚不可用,也能立即处理。可以通过使用node:fs中的createReadStream函数将文件作为可读流进行读取。
这段代码创建了一个服务器,读取请求体并将其以全大写文本流回客户端:
import {createServer} from "node:http";
createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/plain"});
request.on("data", chunk =>
response.write(chunk.toString().toUpperCase()));
request.on("end", () => response.end());
}).listen(8000);
传递给数据处理程序的chunk值将是一个二进制Buffer。我们可以通过使用其toString方法将其解码为 UTF-8 编码的字符,从而转换为字符串。
下面的代码在启动大写服务器时运行,将向该服务器发送请求,并输出收到的响应:
fetch("http://localhost:8000/", {
method: "POST",
body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER
文件服务器
让我们结合关于 HTTP 服务器和文件系统操作的新知识,创建两者之间的桥梁:一个允许远程访问文件系统的 HTTP 服务器。这样的服务器有各种用途——它允许 Web 应用程序存储和共享数据,或者可以为一组人提供对一堆文件的共享访问。
当我们将文件视为 HTTP 资源时,HTTP 方法GET、PUT和DELETE可以分别用于读取、写入和删除文件。我们将请求中的路径解释为请求所指向的文件的路径。
我们可能不想共享整个文件系统,因此我们将这些路径解释为从服务器的工作目录开始,这就是服务器启动时所在的目录。如果我从/tmp/public/(或者在 Windows 上为C:\tmp\public\)运行服务器,那么对/file.txt的请求应该指向/tmp/public/file.txt(或C:\tmp\public\file.txt)。
我们将逐步构建程序,使用一个名为methods的对象来存储处理各种 HTTP 方法的函数。方法处理程序是异步函数,它们将请求对象作为参数,并返回一个解析为描述响应的对象的承诺。
import {createServer} from "node:http";
const methods = Object.create(null);
createServer((request, response) => {
let handler = methods[request.method] || notAllowed;
handler(request).catch(error => {
if (error.status != null) return error;
return {body: String(error), status: 500};
}).then(({body, status = 200, type = "text/plain"}) => {
response.writeHead(status, {"Content-Type": type});
if (body?.pipe) body.pipe(response);
else response.end(body);
});
}).listen(8000);
async function notAllowed(request) {
return {
status: 405,
body: `Method ${request.method} not allowed.`
};
}
这启动了一个只返回405错误响应的服务器,该代码用于指示服务器拒绝处理给定的方法。
当请求处理程序的承诺被拒绝时,catch调用将错误转换为响应对象(如果还不是),以便服务器可以发送错误响应,通知客户端处理请求失败。
响应描述的状态字段可以省略,在这种情况下,它默认为200(OK)。类型属性中的内容类型也可以省略,在这种情况下,响应被认为是纯文本。
当body的值是可读流时,它将具有一个pipe方法,我们可以用它将所有内容从可读流转发到可写流。如果不是,则假定它是null(没有主体)、字符串或缓冲区,并直接传递给响应的end方法。
为了找出哪个文件路径对应于请求 URL,urlPath函数使用内置的URL类(在浏览器中也存在)来解析 URL。该构造函数期望一个完整的 URL,而不仅仅是以斜杠开头的部分(我们从request.url中获得),因此我们提供一个虚拟域名来填充。它提取其路径名,类似于/file.txt,对其进行解码以去除%20样式的转义码,并相对于程序的工作目录解析它。
import {resolve, sep} from "node:path";
const baseDirectory = process.cwd();
function urlPath(url) {
let {pathname} = new URL(url, "http://d");
let path = resolve(decodeURIComponent(pathname).slice(1));
if (path != baseDirectory &&
!path.startsWith(baseDirectory + sep)) {
throw {status: 403, body: "Forbidden"};
}
return path;
}
一旦你设置了一个程序来接受网络请求,就必须开始担心安全性。在这种情况下,如果我们不小心,很可能会意外地将整个文件系统暴露给网络。
文件路径在 Node 中是字符串。要将这样的字符串映射到实际文件,需要进行相当复杂的解释。例如,路径可能包含../来引用父目录。一个明显的问题来源是请求类似于/../secret_file的路径。
为了避免此类问题,urlPath使用来自node:path模块的resolve函数,该函数解析相对路径。它随后验证结果是否在工作目录之下。process.cwd函数(其中cwd代表“当前工作目录”)可以用来找到这个工作目录。来自node:path包的sep绑定是系统的路径分隔符——在 Windows 上是反斜杠,在大多数其他系统上是正斜杠。当路径不以基本目录开头时,该函数会抛出一个错误响应对象,使用 HTTP 状态码指示访问该资源是被禁止的。
我们将设置GET方法,以便在读取目录时返回文件列表,并在读取常规文件时返回文件的内容。
一个棘手的问题是我们在返回文件内容时应该设置什么样的Content-Type头。由于这些文件可能是任何类型,我们的服务器不能简单地为它们全部返回相同的内容类型。NPM在这里可以再次帮助我们。mime-types包(像text/plain这样的内容类型指示符也被称为MIME类型)知道许多文件扩展名的正确类型。
在服务器脚本所在的目录中,以下npm命令安装特定版本的mime:
$ npm install mime-types@2.1.0
当请求的文件不存在时,返回的正确 HTTP 状态码是404。我们将使用stat函数,该函数查找有关文件的信息,以确定文件是否存在以及它是否是一个目录。
import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";
methods.GET = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 404, body: "File not found"};
}
if (stats.isDirectory()) {
return {body: (await readdir(path)).join("\n")};
} else {
return {body: createReadStream(path),
type: lookup(path)};
}
};
由于它必须接触磁盘,因此可能需要一些时间,stat是异步的。因为我们使用的是Promise而不是回调风格,所以它必须从node:fs/promises导入,而不是直接从node:fs导入。
当文件不存在时,stat将抛出一个带有“ENOENT”代码属性的错误对象。这些有些晦涩的、受 Unix 启发的代码是识别 Node 中错误类型的方式。
stat返回的stats对象告诉我们有关文件的许多信息,例如其大小(size属性)和修改日期(mtime属性)。在这里,我们关注的问题是它是一个目录还是一个普通文件,这由isDirectory方法告诉我们。
我们使用readdir读取目录中的文件数组并将其返回给客户端。对于普通文件,我们使用createReadStream创建一个可读流,并将其作为主体返回,同时附上mime包为文件名提供的内容类型。
处理DELETE请求的代码稍微简单一些。
import {rmdir, unlink} from "node:fs/promises";
methods.DELETE = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 204};
}
if (stats.isDirectory()) await rmdir(path);
else await unlink(path);
return {status: 204};
};
当HTTP响应不包含任何数据时,可以使用状态码204(“无内容”)来指示这一点。由于删除的响应不需要传输除操作是否成功之外的任何信息,因此在这里返回这个是合理的。
你可能会想,为什么尝试删除一个不存在的文件会返回成功状态码而不是错误。当要删除的文件不存在时,可以说请求的目标已经实现。HTTP标准鼓励我们使请求幂等,这意味着多次发出相同请求的结果与只发出一次相同。在某种意义上,如果你尝试删除已经不存在的东西,那么你试图创造的效果已经实现——那个东西不再在那里。
这是处理PUT请求的处理程序:
import {createWriteStream} from "node:fs";
function pipeStream(from, to) {
return new Promise((resolve, reject) => {
from.on("error", reject);
to.on("error", reject);
to.on("finish", resolve);
from.pipe(to);
});
}
methods.PUT = async function(request) {
let path = urlPath(request.url);
await pipeStream(request, createWriteStream(path));
return {status: 204};
};
这次我们不需要检查文件是否存在——如果存在,我们只需覆盖它。我们再次使用管道将数据从可读流移动到可写流,在这种情况下是从请求到文件。但由于管道没有返回一个Promise,我们需要编写一个包装器pipeStream,它在调用管道的结果周围创建一个Promise。
当打开文件时出现问题时,createWriteStream仍会返回一个流,但该流会触发一个“错误”事件。请求的流也可能失败,例如,如果网络中断。因此,我们将两个流的“错误”事件连接起来,以拒绝该promise。当管道操作完成时,它会关闭输出流,这会触发一个“完成”事件。这时我们可以成功解析该promise(不返回任何内容)。
服务器的完整脚本可在[eloquentjavascript.net/code/file_server.mjs](https://eloquentjavascript.net/code/file_server.mjs)上找到。你可以下载它,并在安装其依赖后,使用Node运行它来启动自己的文件服务器。当然,你也可以修改和扩展它,以解决本章的练习或进行实验。
命令行工具curl广泛用于类Unix系统(如macOS和Linux),可用于发起HTTP请求。以下会话简要测试我们的服务器。-X选项用于设置请求的方法,-d选项用于包含请求主体。
$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d CONTENT http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
CONTENT
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found
对file.txt的第一次请求失败,因为文件尚不存在。PUT请求创建了该文件,接着下一个请求成功检索了它。在通过DELETE请求删除后,文件再次消失。
总结
Node是一个精巧的小系统,使我们能够在非浏览器环境中运行JavaScript。它最初设计用于网络任务,充当网络中的一个节点,但它适用于各种脚本任务。如果编写JavaScript是你喜欢的事情,使用Node自动化任务可能会对你很有帮助。
NPM提供了你能想到的所有包(还有一些你可能从未想到的包),并允许你使用npm程序获取和安装这些包。Node内置了一些模块,包括用于处理文件系统的node:fs模块和用于运行HTTP服务器的node:http模块。
在Node中,所有的输入和输出都是异步进行的,除非你明确使用函数的同步变体,例如readFileSync。Node最初使用回调来实现异步功能,但node:fs/promises包提供了基于promise的文件系统接口。
练习
搜索工具
在Unix系统上,有一个名为grep的命令行工具,可以快速搜索文件中的正则表达式。
编写一个可以从命令行运行的Node脚本,行为类似于grep。它将第一个命令行参数视为正则表达式,后续参数视为要搜索的文件。它输出任何内容与正则表达式匹配的文件的名称。
当那样工作时,扩展它,使得当其中一个参数是目录时,它会搜索该目录及其子目录中的所有文件。
根据需要使用异步或同步文件系统函数。设置多个异步操作同时请求可能会加快速度,但效果有限,因为大多数文件系统一次只能读取一个文件。
目录创建
尽管我们文件服务器的DELETE方法能够删除目录(使用rmdir),但服务器目前不提供任何创建目录的方法。
添加对MKCOL方法(“创建集合”)的支持,该方法应通过调用node:fs模块中的mkdir来创建目录。MKCOL不是一种广泛使用的HTTP方法,但在WebDAV标准中确实存在此目的,WebDAV在HTTP上指定了一组约定,使其适合于创建文档。
网络上的公共空间
由于文件服务器可以提供任何类型的文件,并且还包含正确的 Content-Type 头,因此您可以用它来提供一个网站。考虑到该服务器允许所有人删除和替换文件,这将会创建一种有趣的网站:一个可以被每个花时间进行正确 HTTP 请求的人修改、改进和破坏的网站。
编写一个基本的 HTML 页面,其中包含一个简单的 JavaScript 文件。将文件放在文件服务器提供的目录中,并在浏览器中打开它们。
接下来,作为一个高级练习或周末项目,结合从本书中获得的所有知识,构建一个更用户友好的界面来修改网站——从网站的内部。
使用 HTML 表单编辑构成网站的文件内容,允许用户通过使用 HTTP 请求在服务器上更新它们,如第十八章所述。
首先只使单个文件可编辑。然后使用户可以选择要编辑的文件。利用我们的文件服务器在读取目录时返回文件列表的事实。
不要直接在文件服务器暴露的代码中工作,因为如果出错,您可能会损坏那里的文件。相反,将您的工作保留在公共可访问目录之外,并在测试时将其复制到那里。
如果你有知识,让他人借此点燃他们的蜡烛。
—玛格丽特·富勒

第二十二章:项目:技能分享网站
技能分享会议是一个人们聚集在一起,围绕共同兴趣进行小型非正式演讲的活动。在一次园艺技能分享会议上,有人可能会讲解如何种植芹菜。或者在一次编程技能分享小组中,你可以随意来告诉大家关于Node.js的信息。
在本项目的最后一章中,我们的目标是建立一个用于管理技能分享会议上讲座的网站。想象一下,一小群人定期在其中一位成员的办公室聚会,讨论独轮车。之前的会议组织者已搬到另一个城市,没有人主动承担这一任务。我们希望有一个系统,让参与者在没有积极组织者的情况下,自主提议和讨论讲座。
项目的完整代码可以从eloquentjavascript.net/code/skillsharing.zip下载。
设计
该项目包含一个为Node.js编写的服务器部分和一个为浏览器编写的客户端部分。服务器存储系统的数据并将其提供给客户端。同时,服务器还提供实现客户端系统的文件。
服务器保持下次会议提议的讲座列表,客户端显示此列表。每个讲座都有一个演讲者姓名、标题、摘要以及与之相关的评论数组。客户端允许用户提议新讲座(将其添加到列表中)、删除讲座并对现有讲座进行评论。每当用户进行这样的更改时,客户端会发送HTTP请求以告知服务器。

该应用程序将设置为显示当前提议的讲座及其评论的实时视图。每当有人在任何地方提交新讲座或添加评论时,所有在浏览器中打开该页面的人应立即看到变化。这带来了一些挑战——因为没有办法让网络服务器打开与客户端的连接,也没有好的方法来知道当前哪些客户端正在查看特定网站。
解决此问题的一个常见方法称为长轮询,这恰好是Node设计的动机之一。
长轮询
为了能够立即通知客户某些内容发生了变化,我们需要与该客户建立连接。由于网络浏览器通常不接受连接,并且客户常常处于会阻止此类连接的路由器后面,因此让服务器发起此连接并不实用。
我们可以安排客户端打开连接并保持连接,以便服务器在需要时可以使用它发送信息。但HTTP请求仅允许简单的信息流:客户端发送请求,服务器返回单个响应,便结束了。一个名为WebSockets的技术使得可以为任意数据交换打开连接,但正确使用这些套接字有些棘手。
在本章中,我们使用一种更简单的技术,即长轮询,客户端通过常规的HTTP请求持续向服务器请求新信息,而服务器在没有新信息时会延迟响应。
只要客户端确保始终保持一个轮询请求开放,它将能够在信息可用后快速接收来自服务器的信息。例如,如果Fatma在浏览器中打开了我们的技能共享应用程序,那么该浏览器将已经发出更新请求,并等待对此请求的响应。当Iman提交关于极限单轮车的讨论时,服务器将注意到Fatma在等待更新,并将包含新讨论的响应发送给她的挂起请求。Fatma的浏览器将接收到数据并更新屏幕以显示讨论内容。
为了防止连接超时(因缺乏活动而被中止),长轮询技术通常会为每个请求设置最大时间,超过该时间后,即使服务器没有任何报告,仍会做出响应。然后客户端可以启动新的请求。定期重新启动请求也使得这种技术更加稳健,使客户端能够从临时的连接故障或服务器问题中恢复。
一个繁忙的服务器如果使用长轮询,可能会有数千个等待请求,因此会保持许多TCP连接。Node.js可以轻松管理多个连接,而无需为每个连接创建单独的控制线程,这使其非常适合这种系统。
HTTP接口
在我们开始设计服务器或客户端之前,让我们先思考它们交互的点:用于通信的HTTP接口。
我们将使用JSON作为请求和响应主体的格式。就像在第二十章的文件服务器中,我们将尽量充分利用HTTP方法和头部。接口围绕/talks路径展开。以/talks开头的路径将用于提供静态文件——客户端系统的HTML和JavaScript代码。
对/talks的GET请求将返回如下JSON文档:
[{"title": "Unituning",
"presenter": "Jamal",
"summary": "Modifying your cycle for extra style",
"comments": []}]
创建新讨论可以通过向类似/talks/Unituning的URL发起PUT请求来实现,其中第二个斜杠后的部分是讨论的标题。PUT请求的主体应包含一个具有presenter和summary属性的JSON对象。
由于讨论标题可能包含空格和其他在URL中通常不会出现的字符,因此在构建此类URL时,标题字符串必须使用encodeURIComponent函数进行编码。
console.log("/talks/" + encodeURIComponent("How to Idle"));
// → /talks/How%20to%20Idle
创建关于闲置的讲座的请求可能看起来像这样:
PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92
{"presenter": "Maureen",
"summary": "Standing still on a unicycle"}
此类URL也支持GET请求以检索讲座的JSON表示和DELETE请求以删除讲座。
向讲座添加评论是通过向类似/talks/Unituning/comments的URL发送POST请求来完成的,JSON正文中包含author和message属性。
POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72
{"author": "Iman",
"message": "Will you talk about raising a cycle?"}
为了支持长轮询,对/talks的GET请求可以包含额外的头部,告知服务器在没有新信息可用时延迟响应。我们将使用一对通常用于管理缓存的头部:ETag和If-None-Match。
服务器可能在响应中包含ETag(“实体标签”)头部。其值是一个字符串,用于标识资源的当前版本。当客户端稍后再次请求该资源时,可以通过包含If-None-Match头部并将其值设置为该字符串来进行条件请求。如果资源未发生变化,服务器将以状态码304响应,这意味着“未修改”,告知客户端其缓存版本仍然是最新的。当标签不匹配时,服务器则正常响应。
我们需要类似这样的功能,客户端可以告知服务器它拥有的讲座列表的版本,服务器仅在该列表发生变化时作出响应。但服务器不应立即返回304响应,而是应延迟响应,仅在有新信息可用或经过一定时间后才返回。为了将长轮询请求与普通条件请求区分开,我们为它们提供了另一个头部,Prefer: wait=90,这告诉服务器客户端愿意等待最长90秒的时间以获取响应。
服务器将保持一个版本号,每次讲座发生变化时更新该版本号,并将其用作ETag值。客户端可以发出这样的请求,以便在讲座发生变化时得到通知:
GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90
(time passes)
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295
--snip--
此处描述的协议不进行任何访问控制。每个人都可以评论、修改讲座,甚至删除它们。(由于互联网上充满了流氓,将这样的系统在线放置而没有进一步的保护可能不会有好的结果。)
服务器
让我们开始构建程序的服务器端部分。本节中的代码在Node.js上运行。
路由
我们的服务器将使用Node的createServer来启动一个HTTP服务器。在处理新请求的函数中,我们必须区分我们支持的各种请求类型(由方法和路径决定)。这可以通过一长串if语句来完成,但还有更好的方法。
路由器是一个组件,帮助将请求分发到可以处理它的函数。你可以告诉路由器,例如,PUT请求的路径匹配正则表达式/^\/talks\/([^\/]+)$/(talks/后跟讲座标题)可以由特定函数处理。此外,它还可以帮助提取路径中的有意义部分(在此情况下为讲座标题),这些部分用正则表达式中的括号包裹,并将它们传递给处理函数。
NPM上有许多优秀的路由器包,但在这里我们将自己编写一个以说明原理。
这是router.mjs,我们稍后将从服务器模块中导入它:
export class Router {
constructor() {
this.routes = [];
}
add(method, url, handler) {
this.routes.push({method, url, handler});
}
async resolve(request, context) {
let {pathname} = new URL(request.url, "http://d");
for (let {method, url, handler} of this.routes) {
let match = url.exec(pathname);
if (!match || request.method != method) continue;
let parts = match.slice(1).map(decodeURIComponent);
return handler(context, ...parts, request);
}
}
}
该模块导出了Router类。路由器对象允许你使用其add方法注册特定方法和URL模式的处理程序。当使用resolve方法解析请求时,路由器会调用与请求的方法和URL匹配的处理程序,并返回其结果。
处理函数在给定的上下文值下调用resolve。我们将利用这一点使它们能够访问我们的服务器状态。此外,它们接收其正则表达式中定义的任何组的匹配字符串,以及请求对象。这些字符串必须进行URL解码,因为原始URL可能包含%20样式的编码。
服务文件
当请求与我们路由器中定义的请求类型都不匹配时,服务器必须将其解释为对public目录中某个文件的请求。可以使用第二十章中定义的文件服务器来提供此类文件,但我们既不需要也不想在文件上支持PUT和DELETE请求,并且我们希望具备支持缓存等高级功能。让我们使用NPM中一个稳健且经过充分测试的静态文件服务器。
我选择了serve-static。这不是NPM上唯一的此类服务器,但它工作良好,符合我们的目的。serve-static包导出一个可以用根目录调用的函数,以生成请求处理函数。处理函数接受服务器从node:http提供的请求和响应参数,以及第三个参数,如果没有文件与请求匹配,它将调用的一个函数。我们希望我们的服务器首先检查应该特别处理的请求,如路由器中定义的那样,因此我们将其包装在另一个函数中。
import {createServer} from "node:http";
import serveStatic from "serve-static";
function notFound(request, response) {
response.writeHead(404, "Not found");
response.end("<h1>Not found</h1>");
}
class SkillShareServer {
constructor(talks) {
this.talks = talks;
this.version = 0;
this.waiting = [];
let fileServer = serveStatic("./public");
this.server = createServer((request, response) => {
serveFromRouter(this, request, response, () => {
fileServer(request, response,
() => notFound(request, response));
});
});
}
start(port) {
this.server.listen(port);
}
stop() {
this.server.close();
}
}
serveFromRouter函数具有与fileServer相同的接口,接受(request, response, next)参数。我们可以利用这一点“链接”多个请求处理程序,允许每个处理程序处理请求或将责任传递给下一个处理程序。最终处理程序notFound仅仅响应一个“未找到”错误。
我们的serveFromRouter函数使用与前一章文件服务器类似的约定来处理响应——路由器中的处理程序返回的承诺解析为描述响应的对象。
import {Router} from "./router.mjs";
const router = new Router();
const defaultHeaders = {"Content-Type": "text/plain"};
async function serveFromRouter(server, request,
response, next) {
let resolved = await router.resolve(request, server)
.catch(error => {
if (error.status != null) return error;
return {body: String(err), status: 500};
});
if (!resolved) return next();
let {body, status = 200, headers = defaultHeaders} =
await resolved;
response.writeHead(status, headers);
response.end(body);
}
讲座作为资源
已提议的演讲存储在服务器的talks属性中,这是一个对象,其属性名称为演讲标题。我们将为我们的路由器添加一些处理程序,将其作为 HTTP 资源公开,路径为/talks/<title>。
处理GET单个演讲请求的处理程序必须查找该演讲,并以该演讲的 JSON 数据或 404 错误响应进行回应。
const talkPath = /^\/talks\/([^\/]+)$/;
router.add("GET", talkPath, async (server, title) => {
if (Object.hasOwn(server.talks, title)) {
return {body: JSON.stringify(server.talks[title]),
headers: {"Content-Type": "application/json"}};
} else {
return {status: 404, body: `No talk '${title}' found`};
}
});
删除演讲是通过将其从talks对象中移除来完成的。
router.add("DELETE", talkPath, async (server, title) => {
if (Object.hasOwn(server.talks, title)) {
delete server.talks[title];
server.updated();
}
return {status: 204};
});
updated方法(稍后我们将定义)会通知等待的长轮询请求有关更改的信息。
需要读取请求主体的一个处理程序是PUT处理程序,它用于创建新的演讲。它必须检查提供的数据是否具有字符串类型的presenter和summary属性。来自系统外部的任何数据可能都是无意义的,我们不想损坏内部数据模型或在出现错误请求时崩溃。
如果数据看起来有效,处理程序将一个表示新演讲的对象存储在talks对象中,可能会覆盖具有相同标题的现有演讲,并再次调用updated。
为了从请求流中读取主体,我们将使用来自node:stream/consumers的json函数,该函数收集流中的数据并将其解析为 JSON。这个包中还有类似的导出,称为text(用于将内容读取为字符串)和buffer(用于将其读取为二进制数据)。由于json是一个非常通用的名称,因此我们将其导入重命名为readJSON,以避免混淆。
import {json as readJSON} from "node:stream/consumers";
router.add("PUT", talkPath,
async (server, title, request) => {
let talk = await readJSON(request);
if (!talk ||
typeof talk.presenter != "string" ||
typeof talk.summary != "string") {
return {status: 400, body: "Bad talk data"};
}
server.talks[title] = {
title,
presenter: talk.presenter,
summary: talk.summary,
comments: []
};
server.updated();
return {status: 204};
});
向演讲添加评论的过程类似。我们使用readJSON获取请求的内容,验证结果数据,并在数据有效时将其存储为评论。
router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
async (server, title, request) => {
let comment = await readJSON(request);
if (!comment ||
typeof comment.author != "string" ||
typeof comment.message != "string") {
return {status: 400, body: "Bad comment data"};
} else if (Object.hasOwn(server.talks, title)) {
server.talks[title].comments.push(comment);
server.updated();
return {status: 204};
} else {
return {status: 404, body: `No talk '${title}' found`};
}
});
尝试向一个不存在的演讲添加评论将返回 404 错误。
长轮询支持
服务器最有趣的部分是处理长轮询的部分。当对/talks发起GET请求时,它可能是一个常规请求或一个长轮询请求。
我们将有多个地方需要向客户端发送一个演讲数组,因此我们首先定义一个帮助方法来构建这样的数组,并在响应中包含一个ETag头。
SkillShareServer.prototype.talkResponse = function() {
let talks = Object.keys(this.talks)
.map(title => this.talks[title]);
return {
body: JSON.stringify(talks),
headers: {"Content-Type": "application/json",
"ETag": `"${this.version}"`,
"Cache-Control": "no-store"}
};
};
处理程序本身需要查看请求头,以确认是否存在If-None-Match和Prefer头。Node 将头信息以不区分大小写的名称存储为小写形式。
router.add("GET", /^\/talks$/, async (server, request) => {
let tag = /"(.*)"/.exec(request.headers["if-none-match"]);
let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]);
if (!tag || tag[1] != server.version) {
return server.talkResponse();
} else if (!wait) {
return {status: 304};
} else {
return server.waitForChanges(Number(wait[1]));
}
});
如果没有提供标签,或者提供的标签与服务器当前版本不匹配,处理程序将响应演讲列表。如果请求是条件性的且演讲没有改变,我们会查看Prefer头,以决定是否延迟响应或立即回应。
延迟请求的回调函数存储在服务器的等待数组中,以便在发生某些事情时能够通知它们。waitForChanges方法还会立即设置一个定时器,在请求等待足够长的时间后以 304 状态进行响应。
SkillShareServer.prototype.waitForChanges = function(time) {
return new Promise(resolve => {
this.waiting.push(resolve);
setTimeout(() => {
if (!this.waiting.includes(resolve)) return;
this.waiting = this.waiting.filter(r => r != resolve);
resolve({status: 304});
}, time * 1000);
});
};
使用updated注册更改会增加版本属性并唤醒所有等待的请求。
SkillShareServer.prototype.updated = function() {
this.version++;
let response = this.talkResponse();
this.waiting.forEach(resolve => resolve(response));
this.waiting = [];
};
这就是服务器代码的全部内容。如果我们创建SkillShare Server的实例并在 8000 端口启动它,生成的 HTTP 服务器将从public子目录提供文件,并在/talks URL下提供讲座管理界面。
new SkillShareServer({}).start(8000);
客户端
技能共享网站的客户端部分由三个文件组成:一个小的 HTML 页面、一个样式表和一个 JavaScript 文件。
HTML
当直接请求与目录对应的路径时,Web 服务器通常会尝试提供名为index.xhtml的文件。我们使用的文件服务器模块serve-static支持这一约定。当对路径/发出请求时,服务器会查找文件./public/index.xhtml(./public是我们指定的根目录),并在找到时返回该文件。
因此,如果我们希望在浏览器指向我们的服务器时显示一个页面,我们应该将其放在public/index.xhtml中。这是我们的索引文件:
<!doctype html>
<meta charset="utf-8">
<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">
<h1>Skill Sharing</h1>
<script src="skillsharing_client.js"></script>
它定义了文档标题,并包含一个样式表,该样式表定义了一些样式,以确保讲座之间有一些空间。然后,它在页面顶部添加了一个标题,并加载包含客户端应用程序的脚本。
操作
应用程序状态包括讲座列表和用户的名字,我们将其存储在一个{talks, user}对象中。我们不允许用户界面直接操作状态或发送 HTTP 请求。相反,它可以发出描述用户尝试执行的操作的动作。
handleAction函数接受这样的操作并使其生效。由于我们的状态更新非常简单,状态更改在同一函数中处理。
function handleAction(state, action) {
if (action.type == "setUser") {
localStorage.setItem("userName", action.user);
return {...state, user: action.user};
} else if (action.type == "setTalks") {
return {...state, talks: action.talks};
} else if (action.type == "newTalk") {
fetchOK(talkURL(action.title), {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
presenter: state.user,
summary: action.summary
})
}).catch(reportError);
} else if (action.type == "deleteTalk") {
fetchOK(talkURL(action.talk), {method: "DELETE"})
.catch(reportError);
} else if (action.type == "newComment") {
fetchOK(talkURL(action.talk) + "/comments", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
author: state.user,
message: action.message
})
}).catch(reportError);
}
return state;
}
我们将用户的名字存储在localStorage中,以便在页面加载时能够恢复。
需要与服务器交互的操作会使用fetch发起网络请求,访问前面描述的 HTTP 接口。我们使用一个包装函数fetchOK,以确保当服务器返回错误代码时,返回的 Promise 被拒绝。
function fetchOK(url, options) {
return fetch(url, options).then(response => {
if (response.status < 400) return response;
else throw new Error(response.statusText);
});
}
这个辅助函数用于构建具有给定标题的讲座的 URL。
function talkURL(title) {
return "talks/" + encodeURIComponent(title);
}
当请求失败时,我们不希望页面就这样静止不动而没有解释。我们使用的名为reportError的函数作为捕获处理程序,向用户显示一个简单的对话框,告诉他们出了点问题。
function reportError(error) {
alert(String(error));
}
渲染组件
我们将采用类似于在第十九章中看到的方法,将应用程序分为组件。然而,由于某些组件要么从不需要更新,要么在更新时总是完全重绘,因此我们将那些定义为函数,而不是类,直接返回一个DOM节点。例如,下面是一个显示用户可以输入其姓名的字段的组件:
function renderUserField(name, dispatch) {
return elt("label", {}, "Your name: ", elt("input", {
type: "text",
value: name,
onchange(event) {
dispatch({type: "setUser", user: event.target.value});
}
}));
}
elt函数用于构建DOM元素,这是我们在第十九章中使用的函数。
一个类似的函数用于渲染讲座,其中包括评论列表和添加新评论的表单。
function renderTalk(talk, dispatch) {
return elt(
"section", {className: "talk"},
elt("h2", null, talk.title, " ", elt("button", {
type: "button",
onclick() {
dispatch({type: "deleteTalk", talk: talk.title});
}
}, "Delete")),
elt("div", null, "by ",
elt("strong", null, talk.presenter)),
elt("p", null, talk.summary),
...talk.comments.map(renderComment),
elt("form", {
onsubmit(event) {
event.preventDefault();
let form = event.target;
dispatch({type: "newComment",
talk: talk.title,
message: form.elements.comment.value});
form.reset();
}
}, elt("input", {type: "text", name: "comment"}), " ",
elt("button", {type: "submit"}, "Add comment")));
}
“提交”事件处理程序在创建newComment操作后调用form.reset以清除表单内容。
当创建中等复杂度的DOM元素时,这种编程风格开始显得相当混乱。为避免这种情况,人们通常使用模板语言,它允许你将界面作为一个HTML文件编写,并使用一些特殊标记来指示动态元素的位置。或者他们使用JSX,这是一种非标准的JavaScript方言,允许你在程序中编写非常接近HTML标签的内容,就好像它们是JavaScript表达式。上述两种方法都使用额外的工具在运行之前预处理代码,这一章我们将避免使用这些工具。
评论的渲染非常简单。
function renderComment(comment) {
return elt("p", {className: "comment"},
elt("strong", null, comment.author),
": ", comment.message);
}
最后,用户可以用来创建新讲座的表单渲染如下:
function renderTalkForm(dispatch) {
let title = elt("input", {type: "text"});
let summary = elt("input", {type: "text"});
return elt("form", {
onsubmit(event) {
event.preventDefault();
dispatch({type: "newTalk",
title: title.value,
summary: summary.value});
event.target.reset();
}
}, elt("h3", null, "Submit a Talk"),
elt("label", null, "Title: ", title),
elt("label", null, "Summary: ", summary),
elt("button", {type: "submit"}, "Submit"));
}
轮询
要启动应用程序,我们需要当前的讲座列表。由于初始加载与长轮询过程密切相关——加载时的ETag必须在轮询时使用——我们将编写一个函数,该函数持续向服务器轮询/talks,并在有新的讲座集可用时调用回调函数。
async function pollTalks(update) {
let tag = undefined;
for (;;) {
let response;
try {
response = await fetchOK("/talks", {
headers: tag && {"If-None-Match": tag,
"Prefer": "wait=90"}
});
} catch (e) {
console.log("Request failed: " + e);
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
if (response.status == 304) continue;
tag = response.headers.get("ETag");
update(await response.json());
}
}
这是一个异步函数,以便于循环和等待请求。它运行一个无限循环,在每次迭代中检索讲座列表——要么正常检索,要么如果这不是第一次请求,则包含使其成为长轮询请求的头部信息。
当请求失败时,函数会等待片刻然后重试。这样,如果你的网络连接暂时中断后又恢复,应用程序可以恢复并继续更新。通过setTimeout解决的promise是一种强制异步函数等待的方法。
当服务器返回304响应时,意味着长轮询请求超时,因此函数应立即开始下一个请求。如果响应是正常的200响应,则其主体将被读取为JSON并传递给回调函数,其ETag头部值将被存储以供下次迭代使用。
应用程序
以下组件将整个用户界面串联在一起:
class SkillShareApp {
constructor(state, dispatch) {
this.dispatch = dispatch;
this.talkDOM = elt("div", {className: "talks"});
this.dom = elt("div", null,
renderUserField(state.user, dispatch),
this.talkDOM,
renderTalkForm(dispatch));
this.syncState(state);
}
syncState(state) {
if (state.talks != this.talks) {
this.talkDOM.textContent = "";
for (let talk of state.talks) {
this.talkDOM.appendChild(
renderTalk(talk, this.dispatch));
}
this.talks = state.talks;
}
}
}
当讲座发生变化时,该组件会重新绘制所有讲座。这很简单,但也很浪费。我们将在练习中回到这个问题。
我们可以这样启动应用程序:
function runApp() {
let user = localStorage.getItem("userName") || "Anon";
let state, app;
function dispatch(action) {
state = handleAction(state, action);
app.syncState(state);
}
pollTalks(talks => {
if (!app) {
state = {user, talks};
app = new SkillShareApp(state, dispatch);
document.body.appendChild(app.dom);
} else {
dispatch({type: "setTalks", talks});
}
}).catch(reportError);
}
runApp();
如果你运行服务器并在两个浏览器窗口中打开http://localhost:8000,你会看到你在一个窗口中执行的操作会立即在另一个窗口中显示。
练习
以下练习将涉及修改本章定义的系统。为了解决这些问题,请确保你已下载代码([eloquentjavascript.net/code/skillsharing.zip](https://eloquentjavascript.net/code/skillsharing.zip)),安装了Node([nodejs.org](https://nodejs.org)),并使用npm install安装项目依赖。
磁盘持久性
技能分享服务器将其数据完全保存在内存中。这意味着当它崩溃或因任何原因重新启动时,所有讲座和评论都将丢失。
扩展服务器,使其将谈话数据存储到磁盘,并在重启时自动重新加载数据。不要担心效率——只需做最简单有效的事情。
评论字段重置
对谈话进行全面重绘效果不错,因为通常你无法区分一个DOM节点和它的相同替代品。但也有例外。如果你在一个浏览器窗口的评论字段中开始输入内容,然后在另一个窗口中给该谈话添加评论,第一个窗口中的字段将被重绘,移除其内容和焦点。
当多个人同时添加评论时,这会很烦人。你能想出解决办法吗?
重大优化来自于对高层设计的精炼,而不是单个例程。
—史蒂夫·麦康奈尔,《代码大全》

第二十三章:JAVASCRIPT和性能
在机器上运行计算机程序需要弥合编程语言与机器自身指令格式之间的差距。这可以通过编写一个解释其他程序的程序来实现,正如我们在第十一章中所做的,但通常是通过将程序编译(翻译)为机器代码来完成。
一些语言,如C和Rust编程语言,旨在表达机器擅长的那些东西。这使得它们易于高效编译。JavaScript的设计方式则截然不同,注重简单性和易用性。几乎没有它允许你表达的操作与机器的特性直接对应。这使得JavaScript的编译变得更加困难。
然而,现代JavaScript 引擎(编译和运行JavaScript的程序)确实能够以令人印象深刻的速度执行程序。可以编写的JavaScript程序,其速度仅比等效的C或Rust程序慢几倍。尽管这听起来差距很大,但较旧的JavaScript引擎(以及类似设计的语言的当代实现,如Python和Ruby)往往比C慢接近100倍。与这些语言相比,现代JavaScript的速度令人瞩目——如此之快,以至于你很少会因为性能问题而被迫切换到另一种语言。
不过,你可能需要调整代码以避免语言中较慢的方面。作为这一过程的例子,本章将通过一个对速度要求高的程序来使其更快。在这个过程中,我们将讨论JavaScript引擎如何编译你的程序。
分阶段编译
首先,你必须理解JavaScript编译器并不只是像经典编译器那样一次性编译一个程序。相反,代码在程序运行时根据需要编译和重新编译。
对于大多数语言,编译大型程序需要一段时间。这通常是可以接受的,因为程序是提前编译并以编译形式分发的。
对于JavaScript,情况有所不同。一个网站可能包含大量以文本形式检索的代码,每次打开网站时都必须编译。如果这个过程花费五分钟,用户肯定不会满意。JavaScript编译器必须能够几乎瞬间开始运行程序——即使是大型程序。
为此,这些编译器具有多种编译策略。当网站首次打开时,脚本首先以一种廉价、表面的方式编译。这并不会导致非常快的执行,但允许脚本快速启动。函数可能在第一次被调用之前根本不会被编译。
在一个典型的程序中,大多数代码只会运行少数几次(或者根本不运行)。对于程序的这些部分,廉价的编译策略就足够了——反正它们不会花费太多时间。但是,频繁调用的函数或包含大量工作循环的函数必须以不同的方式对待。在运行程序时,JavaScript引擎会观察每段代码运行的频率。当某段代码似乎可能耗费大量时间时(这通常被称为热点代码),它会用一个更高级但更慢的编译器重新编译。这种编译器会进行更多的优化以生成更快的代码。甚至可能会有超过两种编译策略,对非常热点的代码应用更昂贵的优化。
交替运行和编译代码意味着在聪明的编译器开始处理一段代码时,它已经被运行多次。这使得能够观察运行中的代码并收集有关它的信息。在本章后面,我们将看到这如何使编译器能够生成更高效的代码。
图布局
本章的示例问题再次与图有关。图的图片可以用于描述道路系统、网络、控制在计算机程序中的流动等。下图显示了一个表示南美国家和领土的图,其中边表示共享陆地边界的国家:

从图的定义推导出这样的图像被称为图布局。它涉及为每个节点分配一个位置,使得相连的节点彼此接近,同时节点不会相互拥挤。相同图的随机布局则更难以解读。

为给定图找到一个好看的布局是一个众所周知的困难问题。对于任意图,没有已知的解决方案能够可靠地做到这一点。大型、密集连接的图尤其具有挑战性。但对于某些特定类型的图,例如平面图(可以画出而不让边相互交叉),有效的方法是存在的。
为了布局一个不太复杂的小图,我们可以应用一种叫做基于力的图布局的方法。这在图的节点上运行一个简化的物理模拟,将边视为弹簧,并让节点之间相互排斥,就像带电一样。
在本章中,我们将实现一个基于力的图布局系统并观察其性能。我们可以通过反复计算作用在每个节点上的力并根据这些力移动节点来运行这样的模拟。这样的程序性能很重要,因为达到一个好看的布局可能需要很多次迭代,而每次迭代都会计算大量的力。
定义图
我们可以使用类似这样的类来表示图。每个节点都有一个编号,从0开始,并存储一组连接的节点。
class Graph {
#nodes = [];
get size() {
return this.#nodes.length;
}
addNode() {
let id = this.#nodes.length;
this.#nodes.push(new Set());
return id;
}
addEdge(nodeA, nodeB) {
this.#nodes[nodeA].add(nodeB);
this.#nodes[nodeB].add(nodeA);
}
neighbors(node) {
return this.#nodes[node];
}
}
在构建图时,你调用addNode来定义一个新节点,调用addEdge将两个节点连接在一起。与图一起工作的代码可以使用neighbors,返回一组连接的节点 ID,以读取有关边的信息。
为了表示图的布局,我们将使用之前章节中的熟悉的Vec类。图的布局是一个长度为graph.size的向量数组,为每个节点保存一个位置。
function randomLayout(graph) {
let layout = [];
for (let i = 0; i < graph.size; i++) {
layout.push(new Vec(Math.random() * 1000,
Math.random() * 1000));
}
return layout;
}
gridGraph函数构建一个只是节点的方形网格的图,这是图布局程序的一个有用测试用例。它创建size * size个节点,并将每个节点与其上方或左侧的节点连接(如果存在这样的节点)。
function gridGraph(size) {
let grid = new Graph();
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let id = grid.addNode();
if (x > 0) grid.addEdge(id, id - 1);
if (y > 0) grid.addEdge(id, id - size);
}
}
return grid;
}
这就是一个网格布局的样子:

为了让我们检查代码生成的布局,我定义了一个drawGraph函数,将图形绘制到画布上。这个函数在[eloquentjavascript.net/code/draw_layout.js](http://eloquentjavascript.net/code/draw_layout.js)的代码中定义,并且在在线沙箱中可用。
力导向布局
我们将一次移动一个节点,计算作用于当前节点的力,并立即将该节点移动到这些力的总和方向。
(理想化的)弹簧施加的力可以用胡克定律来近似,该定律指出,这个力与弹簧的静止长度和当前长度之间的差值成正比。绑定弹簧长度定义了我们边缘弹簧的静止长度。弹簧的刚度由springStrength定义,我们将通过长度差乘以该值来确定结果力。
为了模拟节点之间的排斥力,我们使用另一个物理公式,即库仑定律,它表明两个带电粒子之间的排斥力与它们之间距离的平方成反比。当两个节点几乎重叠时,平方距离很小,结果力是巨大的。随着节点远离,平方距离迅速增大,从而排斥力迅速减弱。
我们将乘以一个实验确定的常数,排斥力强度,该常数控制节点之间排斥的强度。
这个函数计算作用于节点的力的强度,因为在给定距离下存在另一个节点。它始终包括排斥力,并在节点连接时将其与弹簧的力相结合。
const springLength = 20;
const springStrength = 0.1;
const repulsionStrength = 1500;
function forceSize(distance, connected) {
let repulse = -repulsionStrength / (distance * distance);
let spring = 0;
if (connected) {
spring = (distance - springLength) * springStrength;
}
return spring + repulse;
}
节点移动的方式是通过结合所有其他节点施加在它上的力来决定的。对于每对节点,我们的函数需要知道它们之间的距离。我们可以通过减去节点的位置并计算结果向量的长度来计算这个距离。当距离小于一时,我们将其设为一,以防止除以零或非常小的数,因为这样会产生NaN值或产生巨大的力,将节点抛入太空。
使用这个距离以及节点之间连接边的存在与否,我们可以计算作用于它们之间的力的大小。要从这个大小得到力向量,我们可以将大小乘以一个标准化的分离向量。标准化一个向量意味着创建一个方向相同但长度为一的向量。我们可以通过将向量除以其自身的长度来实现。将这个值添加到节点的位置会使其朝着力的方向移动。
function forceDirected_simple(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = 0; b < graph.size; b++) {
if (a == b) continue;
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
}
}
}
我们将使用以下函数来测试我们图形布局系统的给定实现。它从随机布局开始,并运行模型三秒钟。完成后,它记录每秒处理的迭代次数。为了让我们在代码运行时有所观察,它在每100次迭代后绘制当前图形的布局。
function pause() {
return new Promise(done => setTimeout(done, 0))
}
async function runLayout(implementation, graph) {
let time = 0, iterations = 0;
let layout = randomLayout(graph);
while (time < 3000) {
let start = Date.now();
for (let i = 0; i < 100; i++) {
implementation(layout, graph);
iterations++;
}
time += Date.now() - start;
drawGraph(graph, layout);
await pause();
}
let perSecond = Math.round(iterations / (time / 1000));
console.log(`${perSecond} iterations per second`);
}
为了让浏览器有机会实际显示图形,该函数在每次绘制图形时将控制权短暂地返回给事件循环。该函数是异步的,以便能够等待超时。
我们可以运行这个第一个实现,看看需要多少时间。
runLayout(forceDirected_simple, gridGraph(12));
在我的机器上,这个版本每秒处理约1,600次迭代。这已经相当多了。但让我们看看是否可以做得更好。
避免工作
完成某件事情最快的方法就是避免去做它——或者至少部分避免。通过思考代码的作用,你通常可以发现不必要的冗余或可以更快完成的操作。
在我们示例项目的情况下,存在减少工作量的机会。每对节点之间的力计算了两次,一次是在移动第一个节点时,另一次是在移动第二个节点时。由于节点X施加于节点Y的力恰好是节点Y施加于节点X的力的相反数,因此我们不需要计算这些力两次。
函数的下一个版本将内部循环更改为只遍历当前节点之后的节点,以便每对节点仅被查看一次。在计算一对节点之间的力之后,函数更新两个节点的位置。
function forceDirected_noRepeat(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = a + 1; b < graph.size; b++) {
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
layout[b] = layout[b].minus(force);
}
}
}
除了循环结构以及调整两个节点的事实外,这个版本与之前的版本完全相同。测量这段代码显示出显著的速度提升——在Chrome上快约45%,在Firefox上快约55%。
不同的JavaScript引擎工作方式不同,可能以不同的速度运行程序。因此,在一个引擎中使代码运行更快的更改,在另一个引擎中可能无效(甚至可能有害)——甚至在同一引擎的不同版本中也是如此。这很烦人,但考虑到这些系统的复杂性,这并不意外。
如果我们仔细看看程序的运行情况,比如通过调用console.log输出大小,就会发现大多数节点对之间产生的力微乎其微,实际上并没有影响布局。具体来说,当节点未连接且相距较远时,它们之间的力几乎可以忽略不计。然而我们仍然为它们计算向量并稍微移动节点。如果我们不这样做会怎样呢?
下一个版本定义了一个距离,超过该距离的(未连接)节点将不再计算和应用力。设置该距离为175时,忽略低于0.05的力。
const skipDistance = 175;
function forceDirected_skip(layout, graph) {
for (let a = 0; a < graph.size; a++) {
for (let b = a + 1; b < graph.size; b++) {
let apart = layout[b].minus(layout[a]);
let distance = Math.max(1, apart.length);
let connected = graph.neighbors(a).has(b);
if (distance > skipDistance && !connected) continue;
let size = forceSize(distance, connected);
let force = apart.times(1 / distance).times(size);
layout[a] = layout[a].plus(force);
layout[b] = layout[b].minus(force);
}
}
}
这使得速度又提高了75%,而布局没有明显的退化。我们省了一些麻烦,结果也不错。
性能分析
通过对程序的推理,我们能够相当大幅度地加快程序的运行速度。但在微优化方面——即通过稍微不同的方式来提高速度——通常很难预测哪些更改会有帮助,哪些不会。在这种情况下,我们不能再依赖推理,我们必须观察。
我们的runLayout函数测量程序当前的运行时间。这是一个好的开始。要改善某件事,必须对其进行测量。不进行测量,你就无法知道你的更改是否达到了预期效果。
现代浏览器中的开发者工具提供了一种更好的方法来测量程序的速度。这个工具被称为性能分析器。在程序运行时,它会收集程序各个部分所用时间的信息。
如果你的浏览器有性能分析器,它将在开发者工具界面中可用,可能在一个名为“性能”的标签上。当我让Chrome记录3,000次forceDirected_skip的迭代时,性能分析器输出以下表格:
Activity Self Time Total Time
forceDirected_skip 74.0ms 82.4% 769.5ms 94.1%
Minor GC 48.2ms 5.9% 48.2ms 5.9%
Vec 44.8ms 5.5% 46.9ms 5.7%
plus 4.6ms 0.6% 5.5ms 0.7%
Optimize Code 0.1ms 0.0% 0.1ms 0.0%
这列出了耗时较长的函数(或其他任务)。对于每个函数,它报告执行该函数所花费的时间,包括毫秒和总时间的百分比。第一列只显示控制实际上在函数中的时间,而第二列包括在该函数调用的函数中花费的时间。
就性能分析而言,这个非常简单,因为程序没有很多函数。对于更复杂的程序,列表将会更长。由于耗时最长的函数显示在顶部,因此通常仍然容易找到有趣的信息。
从这个表中,我们可以看出,大部分时间花费在物理仿真函数上。这并不意外。但在第二行,我们看到了“Minor GC”。GC代表“垃圾收集”—释放程序不再使用的值所占用的内存空间的过程。第三行的时间类似,测量的是向量构造函数。这些表明程序在创建和清理Vec对象上花费了相当多的时间。
再次想象内存是一排排长长的比特。当程序启动时,它可能会接收一块空的内存,并开始逐个放入它创建的对象。但在某个时刻,空间满了,其中的一些对象不再使用。JavaScript引擎必须弄清楚哪些对象正在使用,哪些对象没有使用,以便能够重用未使用的内存块。
我们循环的每次迭代都会创建五个对象。引擎创建和回收所有这些对象的速度已经相当惊人。因为许多 JavaScript 程序创建大量对象,而管理这些对象的内存可能会导致程序变慢,因此在提高这方面的效率上花费了很多精力。
但尽管效率如此,它仍然是必须进行的工作。让我们尝试一个不创建新向量的代码版本。
function forceDirected_noVector(layout, graph) {
for (let a = 0; a < graph.size; a++) {
let posA = layout[a];
for (let b = a + 1; b < graph.size; b++) {
let posB = layout[b];
let apartX = posB.x - posA.x
let apartY = posB.y - posA.y;
let distance = Math.sqrt(apartX * apartX +
apartY * apartY);
let connected = graph.neighbors(a).has(b);
if (distance > skipDistance && !connected) continue;
let size = forceSize(distance, connected);
let forceX = (apartX / distance) * size;
let forceY = (apartY / distance) * size;
posA.x += forceX;
posA.y += forceY;
posB.x -= forceX;
posB.y -= forceY;
}
}
}
新代码更加冗长和重复,但如果我进行测量,这种改进足够大,值得在对性能敏感的代码中考虑进行这种手动对象扁平化。在Firefox和Chrome上,新版本的速度比之前的版本快了大约50%。
综合这些步骤,我们使得程序比最初版本快了大约四倍。这是一个相当大的改进。但请记住,进行这项工作仅对那些实际消耗大量时间的代码是有用的。试图立即优化所有内容只会让你变得更加缓慢,并留下大量不必要的复杂代码。
函数内联
一些向量方法(例如times)在我们看到的分析中没有出现,尽管它们被大量使用。这是因为编译器对它们进行了内联。与其让内部函数中的代码调用实际方法来相乘向量,不如将向量乘法代码直接放入函数内部,编译后的代码中没有实际的方法调用。
内联帮助代码变快的方式有很多。函数和方法在机器级别上是通过一种协议调用的,这需要将参数和返回地址(当函数返回时执行需要继续的地方)放在函数可以找到的位置。当函数调用将控制权交给程序的其他部分时,通常还需要保存一些处理器的状态,以便被调用的函数可以使用处理器而不干扰调用者仍然需要的数据。当函数被内联时,所有这些都变得不必要。
此外,一个好的编译器会尽力寻找简化其生成代码的方法。如果将函数视为可能做任何事情的黑箱,编译器就没有太多可供利用的内容。另一方面,如果它能够在分析中看到并包含函数体,可能会找到更多优化代码的机会。
例如,JavaScript引擎可以完全避免在我们的代码中创建一些向量对象。在像下面这样的表达式中,如果我们可以透视这些方法,很明显,结果向量的坐标是将force的坐标与normalized和forceSize绑定的乘积相加的结果。因此,没有必要创建由times方法生成的中间对象。
pos.plus(normalized.times(forceSize))
但是JavaScript允许我们通过操纵原型对象在任何时候替换方法。编译器如何确定这个times方法实际上是哪一个函数呢?如果之后有人更改了Vec.prototype.times中存储的值会怎样?下次运行已内联该函数的代码时,它可能会继续使用旧的定义,从而违反程序员对程序行为的假设。
这里是执行和编译交错开始发挥作用的地方。当一个热点函数被编译时,它已经运行了多次。如果在这些运行中,它总是调用同一个函数,那么尝试内联这个函数是合理的。代码是乐观地编译的,假设将来会在这里调用同一个函数。
为了处理悲观的情况,即调用了另一个函数,编译器插入了一个测试,比较被调用的函数和内联的函数。如果两者不匹配,乐观编译的代码就是错误的,JavaScript引擎必须反优化,这意味着它回退到一个不那么优化的代码版本。在之后的某个时刻,它可能会根据现在所知道的内容尝试以不同的方式再次优化。
动态类型
像graph.size这样的JavaScript表达式,从对象中获取属性,并非易事。在许多语言中,绑定是有类型的,因此当你对它们持有的值执行操作时,编译器已经知道你需要什么样的操作。在JavaScript中,只有值有类型,而一个绑定可能会持有不同类型的值。
这意味着最初编译器对代码可能尝试访问的属性了解不多,必须生成处理所有可能类型的代码。如果graph持有未定义的值,代码必须抛出一个错误。如果它持有一个字符串,则必须在String.prototype中查找size。如果它持有一个对象,则从中提取size属性的方式取决于对象的形状。依此类推。
幸运的是,尽管JavaScript不要求如此,但大多数程序中的绑定确实只有一种类型。如果编译器知道这个类型,它可以利用这些信息生成高效的代码。如果图形至今一直是Graph的一个实例,那么优化编译器可以创建内联从该类获取大小的方法的代码。
再次强调,过去观察到的事件并不能保证未来将会发生的事件。某些尚未运行的代码仍可能向我们的函数传递另一种类型的值——例如,另一种图形对象,其大小属性的工作方式不同。
这意味着编译的代码仍然需要检查其假设是否成立,并在不成立时采取适当的行动。引擎可以完全去优化,退回到未优化的函数版本。或者它可以编译一个新的函数版本,以处理新观察到的类型。
你可以通过故意破坏输入对象的一致性来观察因无法预测对象类型而导致的性能下降。例如,我们可以用一个版本替换randomLayout,该版本为每个向量添加一个随机名称的属性。
function randomLayout(graph) {
let layout = [];
for (let i = 0; i < graph.size; i++) {
let vector = new Vec(Math.random() * 1000,
Math.random() * 1000);
vector[`p${Math.floor(Math.random() * 999)}`] = true;
layout.push(vector);
}
return layout;
}
runLayout(forceDirected_noVector, gridGraph(12));
如果我们在结果图上运行我们的快速模拟代码,它在Firefox上会变得慢大约三倍,而在Chrome上则变慢五倍。现在对象类型不再一致,与向量交互的代码必须在没有事先了解对象形状的情况下查找属性,这样做的成本要高得多。
有趣的是,在运行此代码后,即使在常规的、未损坏的布局向量上运行forceDirected_noVector也变得缓慢。混乱的类型在某种程度上“毒害”了编译的代码——在某个时刻,浏览器往往会丢弃编译的代码并重新从头编译,消除这种影响。
类似的技术也用于其他非属性访问的情况。例如,+运算符的意义取决于它应用于什么类型的值。聪明的JavaScript编译器不会每次都运行处理所有这些含义的完整代码,而是会利用先前的观察构建对运算符可能应用类型的某种预期。如果它仅应用于数字,则可以生成一个更简单的机器代码来处理它。但同样,这种假设必须在每次函数运行时进行检查。
这里的教训是,如果一段代码需要快速执行,你可以通过提供一致的类型来帮助它。JavaScript引擎能够相对较好地处理少量不同类型的情况——它们会生成处理所有这些类型的代码,并且仅在看到新类型时才会进行去优化。但即便如此,生成的代码仍然比单一类型时的代码慢。
摘要
多亏了大量资金投入到网络中,以及不同浏览器之间的竞争,JavaScript编译器在其工作中表现出色:使代码运行得更快。但有时你需要稍微帮助它们,重写你的内循环,以避免更昂贵的JavaScript特性。创建更少的对象(以及数组和字符串)通常会有所帮助。
在你开始修改代码以提高速度之前,先考虑如何减少代码的工作量。优化的最大机会通常在于这个方向。
JavaScript引擎会多次编译热点代码,并利用之前执行过程中收集的信息来编译更高效的代码。为你的绑定提供一致的类型有助于实现这种类型的优化。
练习
质数
编写一个生成器(见 第十一章)primes,生成源源不断的质数。质数是大于 1 的整数,不能被任何小于它们且大于 1 的整数整除。例如,前五个质数是 2、3、5、7 和 11。现在先不考虑速度。
设置一个函数measurePrimes,使用Date.now()来测量你的质数函数找到前一万个质数所需的时间。
更快的质数
现在你有了一个测量过的测试用例,想办法让你的质数函数更快。考虑减少你需要执行的余数检查的次数。
附录:练习提示
以下提示可能会在你遇到本书中的某个练习时帮助你。它们并没有给出完整的解决方案,而是尝试帮助你自己找到解决方案。
第二章:程序结构
循环三角形
你可以从一个打印1到7的程序开始,你可以通过对本章前面给出的偶数打印示例进行一些修改来得到这个程序,其中介绍了for循环。
现在考虑数字与哈希字符字符串之间的等价性。你可以通过增加1(+= 1)从1变为2。你可以通过增加一个字符(+= “#”)从"#"变为"##"。因此,你的解决方案可以紧密跟随数字打印程序。
FizzBuzz
遍历数字显然是一个循环任务,选择要打印的内容是条件执行的问题。记住使用余数(%)运算符来检查一个数字是否能被另一个数字整除(余数为零)的技巧。
在第一个版本中,每个数字有三个可能的结果,因此你需要创建一个if/else if/else链。
程序的第二个版本有一个直接的解决方案和一个巧妙的解决方案。简单的解决方案是添加另一个条件“分支”来精确测试给定条件。对于巧妙的解决方案,构建一个包含要输出的单词或词语的字符串,如果没有单词,则打印这个单词或数字,可能通过充分利用||运算符。
棋盘
你可以通过从一个空字符串("")开始,反复添加字符来构建字符串。换行符写作\n。
要处理二维,您需要在一个循环内嵌套另一个循环。在两个循环的主体周围加上大括号,以便清楚地看到它们的开始和结束位置。尝试正确缩进这些主体。循环的顺序必须遵循我们构建字符串的顺序(逐行,从左到右,从上到下)。因此,外层循环处理行,内层循环处理行中的字符。
你需要两个绑定来跟踪你的进度。要知道在给定位置放置空格还是哈希符号,可以测试两个计数器的和是否为偶数(% 2)。
通过添加换行符来结束一行必须在这一行构建完成后进行,因此在内层循环后但在外层循环内执行此操作。
第三章:函数
最小值
如果你在正确放置大括号和括号以获得有效的函数定义时遇到困难,可以先复制本章中的一个示例并进行修改。
一个函数可以包含多个返回语句。
递归
你的函数可能看起来有些类似于本章递归findSolution示例中的内部find函数,带有if/else if/else链条来测试三种情况中的哪一种适用。最终的else对应于第三种情况,执行递归调用。每个分支都应包含一个返回语句,或以其他方式安排返回特定值。
当给定一个负数时,函数会不断递归,传递给自己一个越来越负的数字,从而越来越远离返回结果。最终它会耗尽栈空间并中止。
豆子计数
你的函数需要一个循环来查看字符串中的每个字符。它可以从零运行到比其长度小一的索引(< string.length)。如果当前索引位置的字符与函数正在查找的字符相同,则将计数变量加1。循环结束后,可以返回计数。
确保在函数中将所有绑定声明为局部变量,通过使用let或const关键字正确声明它们。
第四章:数据结构:对象和数组
范围的总和
构建数组最简单的方法是首先将绑定初始化为[](一个新的空数组),然后重复调用其push方法添加值。别忘了在函数末尾返回数组。
由于结束边界是包含的,因此你需要使用<=操作符而不是<来检查循环的结束。
步长参数可以是一个可选参数,默认(使用=操作符)为1。
让范围理解负步长值最好是通过编写两个独立的循环来实现——一个用于递增计数,一个用于递减计数——因为检查循环是否完成的比较在递减计数时需要使用>=而不是<=。
当范围的结束点小于起始点时,使用默认步长–1可能也值得考虑。这样,range(5, 2)会返回有意义的结果,而不是陷入无限循环。可以在参数的默认值中引用先前的参数。
反转数组
实现reverseArray有两种明显的方法。第一种是简单地从前到后遍历输入数组,并在新数组上使用unshift方法在其开始插入每个元素。第二种是向后遍历输入数组并使用push方法。向后遍历数组需要一个(有点尴尬的)for循环,例如(let i = array.length - 1; i >= 0; i--)。
就地反转数组更为复杂。你必须小心不要覆盖稍后需要的元素。使用reverseArray或其他方法复制整个数组(array.slice()是复制数组的好方法)有效,但这算是作弊。
诀窍是交换第一个和最后一个元素,然后第二个和倒数第二个元素,以此类推。您可以通过循环数组长度的一半来实现(使用Math.floor向下取整——在具有奇数个元素的数组中,您无需触碰中间元素),并将位置i的元素与位置array.length - 1 - i的元素进行交换。您可以使用局部绑定暂时保存其中一个元素,将该元素覆盖为其镜像,然后将局部绑定中的值放回镜像曾在的位置。
列表
从后向前构建列表更容易。因此,arrayToList可以反向遍历数组(参见前面的练习),并为每个元素向列表中添加一个对象。您可以使用局部绑定来保持迄今为止构建的列表部分,并使用像list = {value: X, rest: list}的赋值来添加一个元素。
要遍历一个列表(在listToArray和nth中),可以使用这样的for循环规范:
for (let node = list; node; node = node.rest) {}
您能看出这是如何工作的?在循环的每次迭代中,node指向当前子列表,主体可以读取其值属性以获取当前元素。在一次迭代结束时,node移动到下一个子列表。当它为null时,我们已到达列表末尾,循环结束。
nth的递归版本同样会查看列表“尾部”的越来越小的部分,同时将索引计数递减,直到它达到零,此时可以返回它所查看节点的值属性。要获取列表的零索引元素,只需取其头节点的值属性。要获取元素N + 1,您需要取该列表在其rest属性中的第N个元素。
深比较
您测试是否在处理真实对象的方法大致如下:typeof x == "object" && x != null。要小心,仅在两个参数都是对象时进行属性比较。在所有其他情况下,您可以立即返回===的结果。
使用Object.keys遍历属性。您需要测试两个对象是否具有相同的属性名称集合,以及这些属性是否具有相同的值。确保两个对象具有相同数量的属性(属性列表的长度相同)是一种方法。然后,在循环其中一个对象的属性以进行比较时,始终首先确保另一个对象实际上具有该名称的属性。如果它们具有相同数量的属性,并且一个中的所有属性也存在于另一个中,那么它们具有相同的属性名称集合。
从函数返回正确的值,最好是在发现不匹配时立即返回false,并在函数结束时返回true。
第五章:高阶函数
一切
与&&运算符类似,every方法可以在找到第一个不匹配的元素后停止评估后续元素。因此,基于循环的版本可以在遇到谓词函数返回false的元素时使用break或return跳出循环。如果循环在没有找到这样的元素的情况下运行到结束,我们就知道所有元素都匹配,应该返回true。
为了在某些基础上构建every,我们可以应用德摩根定律,该定律表明a && b等于!(!a || !b)。这可以推广到数组,在数组中,如果没有不匹配的元素,则所有元素都匹配。
主导书写方向
你的解决方案可能与textScripts示例的前半部分非常相似。你再次需要根据characterScript的标准来计算字符,然后过滤掉结果中与不感兴趣(无脚本)字符相关的部分。
使用reduce可以找到字符计数最高的方向。如果不清楚如何做,请参考本章早些时候的示例,其中使用reduce找到字符最多的脚本。
第六章:对象的秘密生活
向量类型
如果你不确定类声明的样子,请回顾一下Rabbit类示例。
向构造函数添加getter属性可以通过在方法名前加上get来实现。要计算从(0, 0)到(x, y)的距离,可以使用勾股定理,勾股定理表明我们所寻找的距离的平方等于x坐标的平方加上y坐标的平方。因此,
就是你想要的数字。Math.sqrt是计算平方根的方法,而x ** 2可以用来求一个数的平方。
组
最简单的方法是将组成员的数组存储在实例属性中。可以使用includes或indexOf方法检查给定值是否在数组中。
你的类的构造函数可以将成员集合设置为空数组。当调用add时,它必须检查给定值是否在数组中,或者添加它,可能使用push。
从数组中删除一个元素在delete中不太直接,但你可以使用filter创建一个不包含该值的新数组。别忘了用新过滤的数组版本覆盖保存成员的属性。
from方法可以使用for/of循环从可迭代对象中提取值,并调用add将它们放入新创建的组中。
可迭代组
可能值得定义一个新的类GroupIterator。迭代器实例应该有一个属性,用于跟踪当前在组中的位置。每次调用next时,它都会检查是否完成,如果没有,则跳过当前值并返回它。
Group类本身会有一个名为Symbol.iterator的方法,当调用时,它会返回该组的迭代器类的新实例。
第七章:项目:一个机器人
测量机器人
你需要编写一个runRobot函数的变体,该函数不是将事件记录到控制台,而是返回机器人完成任务所需的步骤数。
你的测量函数可以在循环中生成新状态,并计算每个机器人所需的步骤数。当它生成足够的测量值时,可以使用console.log输出每个机器人的平均值,即总步骤数除以测量值的数量。
机器人效率
goalOrientedRobot的主要限制是它一次只考虑一个包裹。它经常会在村庄中来回走动,因为它所关注的包裹恰好在地图的另一侧,即使还有其他包裹更近。
一种可能的解决方案是计算所有包裹的路线,然后选择最短的一条。如果有多条最短路线,优先选择那些去取包裹而不是送包裹的路线,可以获得更好的结果。
持久化组
表示成员值集合最方便的方法仍然是作为数组,因为数组易于复制。
当一个值被添加到组中时,你可以创建一个新组,包含添加了该值的原始数组的副本(例如,使用concat)。当一个值被删除时,你从数组中过滤掉它。
类的构造函数可以将这样的数组作为参数,并将其存储为实例的(唯一)属性。这个数组不会被更新。
要将空属性添加到构造函数中,可以将其声明为静态属性。
你只需要一个空实例,因为所有空组都是相同的,类的实例不会改变。你可以从那个单一的空组中创建许多不同的组,而不影响它。
第八章:错误和故障
重试
对原始乘法的调用应在try块中进行。对应的catch块应在异常不是MultiplicatorUnitFailure的实例时重新抛出异常,并确保在异常是实例时重试调用。
要进行重试,你可以使用一个仅在调用成功时停止的循环—如本章早些时候的示例,或者使用递归并希望不会出现过长的失败字符串,以至于溢出栈(这通常是相当安全的假设)。
锁定的盒子
这个练习需要一个finally块。你的函数应首先解锁盒子,然后在try块内部调用参数函数。之后的finally块应再次锁定盒子。
为确保我们不会在未锁定时锁定盒子,请在函数开始时检查其锁定状态,并仅在它最初被锁定时进行解锁和锁定。
第九章:正则表达式
引用风格
最明显的解决方案是仅用非字母字符替换引号,至少在一侧—像这样/\P{L}’|’\P{L}/u。但你还必须考虑行的开始和结束。
此外,您还必须确保替换内容也包含由\P{L}模式匹配的字符,以免将其丢弃。这可以通过将它们包裹在括号中并在替换字符串中包括它们的组($1, $2)来实现。未匹配的组将被替换为空。
数字再次
首先,不要忘记在句点前加上反斜杠。
匹配数字前的可选符号,以及指数前的符号,可以用[+\-]?或(\+|-|)来实现(加号、减号或无符号)。
本练习更复杂的部分是匹配“5.”和“.5”,而不匹配“.”。为此,一个好的解决方案是使用|操作符将这两种情况分开——要么是一个或多个数字后可选跟一个点和零个或多个数字,要么是一个点后跟一个或多个数字。
最后,为了使e不区分大小写,可以向正则表达式添加一个i选项,或者使用[eE]。
第十章:模块
模块化机器人
这是我会做的事情(但再次强调,没有单一的正确方法来设计一个给定模块):
用于构建道路图的代码位于graph.js模块中。因为我宁愿使用NPM中的dijkstrajs,而不是我们自己的路径查找代码,所以我们将构建dijkstrajs所期望的那种图数据。该模块导出一个单一的函数buildGraph。我会让buildGraph接受一个由两个元素数组构成的数组,而不是包含连字符的字符串,以减少模块对输入格式的依赖。
roads.js模块包含原始道路数据(道路数组)和道路图绑定。该模块依赖于./graph.js并导出道路图。
VillageState类位于state.js模块中。它依赖于./roads.js模块,因为它需要验证给定的道路是否存在。它还需要randomPick。由于这是一个三行函数,我们可以将其作为内部助手函数放入state.js模块中。但randomRobot也需要它。因此我们要么重复它,要么将其放入自己的模块。由于这个函数恰好存在于NPM的random-item包中,一个合理的解决方案是让两个模块都依赖于它。我们还可以将runRobot函数添加到这个模块中,因为它小且与状态管理密切相关。该模块导出了VillageState类和runRobot函数。
最后,机器人及其依赖的值,例如mailRoute,可以放入example-robots.js模块中,该模块依赖于./roads.js并导出机器人函数。为了使goalOrientedRobot能够进行路径寻找,这个模块还依赖于dijkstrajs。
通过将一些工作卸载到NPM模块,代码变得稍微小了一些。每个独立模块执行的任务相对简单,并且可以单独阅读。将代码划分为模块通常还会建议对程序设计的进一步改进。在这种情况下,VillageState和机器人依赖于特定的道路图似乎有些奇怪。将图作为状态构造函数的一个参数,并让机器人从状态对象中读取,这可能是个更好的主意——这减少了依赖(这总是好的),并使得在不同地图上运行模拟成为可能(这更好)。
使用我们本可以自己编写的NPM模块是个好主意吗?原则上是的——对于像路径寻找函数这样的非平凡事物,你很可能会犯错误并浪费时间自己编写。对于像random-item这样的小函数,自己编写是相对简单的。但在需要它们的地方添加它们确实会使模块变得杂乱。
然而,你也不应低估寻找合适的NPM包所涉及的工作。即使你找到了一个,它可能也不够好,或者缺少你需要的一些功能。此外,依赖于NPM包意味着你必须确保它们已安装,必须将它们与程序一起分发,并且可能需要定期升级它们。
所以这又是一个权衡,你可以根据特定包对你实际帮助的程度决定是否使用。
道路模块
由于这是一个ES模块,你必须使用import来访问图形模块。那被描述为导出一个buildGraph函数,你可以通过解构const声明从其接口对象中提取出来。
要导出roadGraph,你需要在其定义前加上关键字export。由于buildGraph接受的数据结构与roads并不完全匹配,因此必须在你的模块中拆分道路字符串。
循环依赖
诀窍在于require在开始加载模块之前将模块的接口对象添加到其缓存中。这样,如果在其运行时发出的任何require调用尝试加载它,它已经被识别,并且将返回当前接口,而不是再次开始加载模块(这最终会导致栈溢出)。
第十一章:异步编程
安静的时光
你需要将这些文件的内容转换为数组。最简单的方法是对textFile生成的字符串使用split方法。请注意,对于日志文件,这仍会给你一个字符串数组,在将它们传递给new Date之前,你必须将其转换为数字。
将所有时间点汇总到一个小时的表中,可以通过创建一个数组来完成,该数组为一天中的每个小时保存一个数字。然后可以遍历所有时间戳(遍历日志文件及每个日志文件中的数字),对于每一个,如果它发生在正确的日期,就取出发生的小时,并将对应的数字加一。
确保在对异步函数的结果进行任何操作之前使用await,否则你会得到一个Promise,而你预期的是一个字符串。
真实的承诺
编写此函数的最简单方法是使用一系列的then调用。第一个承诺是通过读取日志文件列表生成的。第一个回调可以拆分此列表,并在其上映射textFile,以获取一个承诺数组,传递给Promise.all。它可以返回Promise.all返回的对象,这样无论返回什么,都会成为第一个then的返回值。
我们现在有一个返回日志文件数组的承诺。我们可以再次在其上调用then,并将时间戳计数逻辑放在其中。类似这样的:
function activityTable(day) {
return textFile("camera_logs.txt").then(files => {
return Promise.all(files.split("\n").map(textFile));
}).then(logs => {
// Analyze...
});
}
或者,为了更好的工作调度,可以将每个文件的分析放入Promise.all中,以便可以在第一个从磁盘返回的文件上启动该工作,即使在其他文件返回之前。
function activityTable(day) {
let table = []; // Init...
return textFile("camera_logs.txt").then(files => {
return Promise.all(files.split("\n").map(name => {
return textFile(name).then(log => {
// Analyze...
});
}));
}).then(() => table);
}
这表明你结构化承诺的方式确实会对工作的调度方式产生影响。一个简单的循环加上await将使过程完全线性——它在继续之前等待每个文件加载。Promise.all使得多个任务在概念上可以同时处理,允许它们在文件仍在加载时取得进展。这可能更快,但也使得事情发生的顺序变得不那么可预测。在这种情况下,我们只是要在表中递增数字,这样做并不困难。对于其他类型的问题,这可能会更加复杂。
当列表中的某个文件不存在时,textFile返回的承诺将被拒绝。因为如果给定的任何承诺失败,Promise.all就会拒绝,所以传递给第一个then的回调的返回值也将是一个被拒绝的承诺。这使得由then返回的承诺失败,因此传递给第二个then的回调甚至不会被调用,函数返回一个被拒绝的承诺。
构建 Promise.all
传递给Promise构造函数的函数必须对给定数组中的每个承诺调用then。当其中一个成功时,需要发生两件事。结果值需要存储在结果数组的正确位置,并且我们必须检查这是否是最后一个待处理的承诺,如果是的话就完成我们自己的承诺。
后者可以通过一个计数器来完成,该计数器初始化为输入数组的长度,每当一个承诺成功时我们就减去1。当它达到0时,我们完成了。确保考虑输入数组为空的情况(因此没有承诺会被解析)。
处理失败需要一些思考,但实际上非常简单。只需将包装承诺的reject函数传递给数组中每个承诺作为catch处理程序或作为then的第二个参数,以便其中一个的失败触发整个包装承诺的拒绝。
第十二章:项目:一种编程语言
数组
最简单的方法是用JavaScript数组表示Egg数组。
添加到顶层作用域的值必须是函数。通过使用rest参数(三个点表示法),数组的定义可以是非常简单的。
闭包
我们再次借助JavaScript机制在Egg中获得等效功能。特殊形式被传递到它们被评估的本地作用域,以便它们能够在该作用域中评估其子表单。由fun返回的函数可以访问其封闭函数传递的作用域参数,并利用它在调用时创建函数的本地作用域。
这意味着本地作用域的原型将是创建函数时的作用域,这使得可以从该函数访问该作用域中的绑定。这就是实现闭包的全部内容(尽管要以实际高效的方式编译它,你需要做更多工作)。
评论
确保你的解决方案能够处理连续多个注释,且可能在它们之间或之后有空格。
正则表达式可能是解决此问题的最简单方法。编写一个匹配“空格或注释,零次或多次”的表达式。使用exec或match方法,并查看返回数组中第一个元素的长度(整个匹配)以找出需要截断多少个字符。
修复作用域
你必须一次循环遍历一个作用域,使用Object.getPrototypeOf转到下一个外部作用域。对于每个作用域,使用Object.hasOwn查找由set的第一个参数的名称属性指示的绑定是否存在于该作用域中。如果存在,将其设置为评估set的第二个参数的结果,然后返回该值。
如果到达最外层作用域(Object.getPrototypeOf返回null)而我们仍未找到绑定,则表示该绑定不存在,应抛出错误。
第十四章:文档对象模型
构建表格
你可以使用document.createElement创建新的元素节点,使用document.createTextNode创建文本节点,并使用appendChild方法将节点放入其他节点中。
你需要遍历一次键名以填充顶行,然后对数组中的每个对象再次遍历以构造数据行。要从第一个对象获取键名数组,Object.keys将会非常有用。
要将表格添加到正确的父节点,可以使用document.getElementById或document.querySelector与#mountains一起找到该节点。
按标签名获取元素
解决方案最容易用递归函数表达,类似于本章早些时候定义的talksAbout函数。
你可以递归地调用byTagname,连接结果数组以生成输出。或者,你可以创建一个内部函数,它可以递归调用自己,并且可以访问在外部函数中定义的数组绑定,以便将找到的匹配元素添加进去。别忘了从外部函数调用一次内部函数以启动这个过程。
递归函数必须检查节点类型。这里我们只关注节点类型1(Node.ELEMENT_NODE)。对于这样的节点,我们必须循环访问它们的子节点,并且对于每个子节点,检查它是否匹配查询,同时对其进行递归调用以检查其子节点。
猫的帽子
Math.cos和Math.sin以弧度测量角度,其中完整的圆为2*π。对于给定的角度,你可以通过加上这个角度的一半(即Math.PI)来获取相对角度。这对于将帽子放在轨道的另一侧是很有用的。
第十五章:处理事件
气球
你需要为keydown事件注册一个处理程序,并查看event.key以确定是按下了上箭头还是下箭头。
当前大小可以保存在一个绑定中,以便你可以基于此大小来调整新大小。定义一个更新大小的函数——同时更新绑定和DOM中气球的样式——将是有帮助的,以便你可以从事件处理程序中调用它,并可能在开始时也调用一次,以设置初始大小。
你可以通过用另一个文本节点替换文本节点(使用replaceChild)或将其父节点的textContent属性设置为一个新字符串,将气球更改为爆炸效果。
鼠标轨迹
创建元素最好用循环来完成。将它们附加到文档中以使其显示。为了能在之后访问它们以更改其位置,你需要将元素存储在一个数组中。
通过保持一个计数变量并在“mousemove”事件触发时每次加1,可以循环访问它们。然后可以使用余数运算符(% elements.length)来获取有效的数组索引,以便在给定事件中选择你想要定位的元素。
通过建模一个简单的物理系统可以实现另一个有趣的效果。仅使用“mousemove”事件来更新一对跟踪鼠标位置的绑定。然后使用requestAnimationFrame来模拟后续元素被吸引到鼠标指针位置。在每个动画步骤中,根据它们相对于指针的位置(并且,选用时,基于每个元素存储的速度)更新它们的位置。找出一个好的方法来做到这一点由你决定。
标签
你可能会遇到的一个陷阱是,不能直接将节点的childNodes属性作为标签节点的集合使用。首先,当你添加按钮时,它们也会成为子节点并最终进入这个对象,因为这是一个实时数据结构。其次,为节点之间的空白创建的文本节点也在childNodes中,但不应该有自己的标签。你可以使用children而不是childNodes来忽略文本节点。
你可以先构建一个标签数组,以便轻松访问它们。要实现按钮的样式,你可以存储包含标签面板和其按钮的对象。
我建议为更改标签编写一个单独的函数。你可以存储之前选择的标签,并仅更改需要隐藏的样式以显示新的标签,或者每次选择新标签时更新所有标签的样式。
你可能想立即调用这个函数,以使界面从第一个标签开始可见。
第十六章:项目:一个平台游戏
暂停游戏
通过从传递给runAnimation的函数返回false,可以中断动画。通过再次调用runAnimation,可以继续动画。
所以我们需要向runAnimation中提供的函数传达我们正在暂停游戏的事实。为此,你可以使用一个绑定,既可以被事件处理程序访问,也可以被那个函数访问。
在寻找注销由trackKeys注册的处理程序的方法时,记住必须传递与addEventListener传递的完全相同的函数值给removeEventListener,以成功移除处理程序。因此,在trackKeys中创建的处理程序函数值必须在注销处理程序的代码中可用。
你可以给trackKeys返回的对象添加一个属性,包含该函数值或一个直接处理注销的方法。
一个怪物
如果你想实现一种有状态的运动类型,例如弹跳,确保在演员对象中存储必要的状态——将其作为构造函数参数并添加为属性。
记住,update返回的是一个新的对象,而不是更改旧的对象。
在处理碰撞时,找到状态中的玩家state.actors,并将其位置与怪物的位置进行比较。要获得玩家的底部,你需要将其垂直尺寸加到其垂直位置上。更新状态的创建将类似于Coin的碰撞方法(移除演员)或Lava的(将状态更改为“失去”),这取决于玩家的位置。
第十七章:在画布上绘图
形状
梯形(1)的绘制最简单的方法是使用路径。选择合适的中心坐标,并在中心周围添加四个角落。
菱形(2)可以用简单的方法绘制,使用路径,或者用有趣的方法,使用旋转变换。要使用旋转,你必须应用类似于我们在flipHorizontally函数中所做的技巧。因为你想围绕矩形的中心旋转,而不是围绕点(0, 0),所以你必须先平移到那里,然后旋转,最后再平移回来。
确保在绘制任何会产生变换的形状后重置变换。
对于锯齿形(3),为每个线段写一个新的lineTo调用变得不切实际。相反,你应该使用一个循环。你可以让每次迭代绘制两个线段(先向右,然后再向左),或者只绘制一个,在这种情况下,必须使用循环索引的偶数性(% 2)来确定是向左还是向右。
你还需要为螺旋(4)使用一个循环。如果你绘制一系列点,每个点沿着螺旋中心周围的圆移动得更远,你将得到一个圆。如果在循环中,你改变放置当前点的圆的半径,并且转圈超过一次,结果就是一个螺旋。
星形(5)是由quadraticCurveTo线条构建的。你也可以用直线画一个。将一个圆分成八个部分来绘制一个八角星,或者你想要的任何数量的部分。在这些点之间画线,使它们向星的中心弯曲。使用quadraticCurveTo时,可以将中心作为控制点。
饼图
你需要调用fillText并设置上下文的textAlign和textBaseline属性,以确保文本最终出现在你想要的位置。
放置标签的合理方法是将文本放在从饼图中心到切片中间的直线上。你不想将文本直接放在饼图的一侧,而是要将文本向饼图的一侧移动一定数量的像素。
这条线的角度是currentAngle + 0.5 * sliceAngle。以下代码找到从中心起120像素的该线上的位置:
let middleAngle = currentAngle + 0.5 * sliceAngle;
let textX = Math.cos(middleAngle) * 120 + centerX;
let textY = Math.sin(middleAngle) * 120 + centerY;
对于textBaseline,在使用这种方法时,值“middle”可能是合适的。textAlign的使用取决于我们位于圆的哪一侧。在左侧时,它应该是“right”,在右侧时,它应该是“left”,以便文本远离饼图。
如果你不确定如何找出给定角度位于圆的哪一侧,可以查看第十四章中Math.cos的解释。一个角度的余弦值告诉我们它对应的 x 坐标,从而告诉我们确切位于圆的哪一侧。
一个弹跳的球
用strokeRect绘制一个矩形很简单。定义一个绑定来保持其大小,或者如果矩形的宽度和高度不同,可以定义两个绑定。要创建一个圆球,首先开始一个路径并调用arc(x, y, radius, 0, 7),这会创建一个从零到超过一个完整圆的弧。然后填充路径。
要建模球的位置和速度,可以使用第十六章中的Vec类。给它一个起始速度,最好是非纯垂直或水平的速度,并且在每一帧中将该速度乘以经过的时间。当球靠近垂直墙壁时,反转其速度中的x分量。同样,当它撞到水平墙时,反转y分量。
找到球的新位置和速度后,使用clearRect删除场景,并使用新位置重新绘制它。
预计算镜像
解决方案的关键在于我们可以在使用drawImage时将canvas元素用作源图像。可以创建一个额外的<canvas>元素,而不将其添加到文档中,并将我们的反转精灵绘制到上面一次。在绘制实际帧时,我们只需将已反转的精灵复制到主画布上。
需要一些注意,因为图像不会立即加载。我们只进行一次反转绘制,如果在图像加载之前进行绘制,它不会绘制任何内容。图像上的“load”处理程序可以用来将反转图像绘制到额外的画布上。此画布可以立即用作绘图源(在我们将字符绘制到其上之前,它将保持空白)。
第十八章:HTTP 与表单
内容协商
将你的代码基于本章之前的fetch示例。
请求一个虚假的媒体类型将返回状态码406,“不可接受”,这是服务器在无法满足Accept头时应该返回的代码。
JavaScript 工作台
使用document.querySelector或document.getElementById来访问你在HTML中定义的元素。为按钮上的“click”或“mousedown”事件添加事件处理程序,可以获取文本字段的value属性并调用Function。
确保将对Function的调用和对其结果的调用都包裹在try块中,以便捕获它产生的异常。在这种情况下,我们真的不知道要捕获什么类型的异常,因此要捕获所有异常。
输出元素的textContent属性可用于填充字符串消息。或者,如果你想保留旧内容,可以使用document.createTextNode创建一个新的文本节点并将其附加到该元素上。记得在末尾添加一个换行符,以便所有输出不会出现在同一行上。
康威的生命游戏
为了解决概念上同时发生变化的问题,尝试将一代的计算视为一个纯函数,它接收一个网格并生成一个表示下一回合的新网格。
表示矩阵可以用一个宽度×高度元素的单一数组来完成,按行存储值,因此,例如,第五行的第三个元素(使用零基索引)存储在位置4 × 宽度 + 2。你可以使用两个嵌套循环来计算活邻居,循环遍历两个维度的相邻坐标。要注意不要计算场外的单元格,并且忽略中心的单元格,我们正在计算其邻居。
确保复选框的更改在下一代中生效可以通过两种方式完成。事件处理程序可以注意到这些更改并更新当前网格以反映它们,或者你可以在计算下一轮之前从复选框中的值生成一个新的网格。
如果你选择使用事件处理程序,可能希望附加标识每个复选框对应位置的属性,以便容易找到要更改的单元格。
要绘制复选框的网格,你可以使用<table>元素(见第十四章),或者将它们全部放在同一元素中,并在行之间放置<br>(换行)元素。
第十九章:项目:像素艺术编辑器
键盘绑定
对于字母键事件,关键属性将是小写字母本身,如果没有按住SHIFT。我们这里不关心按住SHIFT的键事件。
“keydown”处理程序可以检查其事件对象,以查看它是否匹配任何快捷键。你可以自动从工具对象中获取首字母列表,这样就不必手动写出来。
当关键事件与快捷键匹配时,调用preventDefault并派发适当的动作。
高效绘制
这个练习是一个很好的例子,说明不可变数据结构如何使代码更快。因为我们同时拥有旧图像和新图像,我们可以比较它们并重新绘制仅改变颜色的像素,在大多数情况下节省超过99%的绘图工作。
你可以编写一个新函数updatePicture,或者让drawPicture接受一个额外的参数,该参数可能是未定义的或是之前的图像。对于每个像素,该函数检查在此位置是否传递了具有相同颜色的前一个图像,如果是,则跳过该像素。
由于当我们更改画布大小时,画布会被清空,因此在旧图像和新图像大小相同时,应该避免触碰其宽度和高度属性。如果它们不同,例如在加载新图像时,会发生这种情况,你可以在更改画布大小后将绑定旧图像的变量设置为null,因为在更改画布大小后不应跳过任何像素。
圆形
你可以从矩形工具中获得一些灵感。与该工具一样,当指针移动时,你希望继续在起始图像上绘制,而不是当前图像。
要确定哪些像素需要上色,你可以使用勾股定理。首先,通过对x坐标差的平方(x ** 2)与y坐标差的平方之和取平方根(Math.sqrt),计算当前指针位置和起始位置之间的距离。然后在起始位置周围的像素正方形上循环,其边长至少为半径的两倍,并对在圆的半径内的像素上色,再次使用勾股公式来计算它们与中心的距离。
确保你不要尝试上色超出图像边界的像素。
适当的线条
绘制像素化线条的问题在于它实际上是四个相似但略有不同的问题。从左到右绘制一条水平线很简单——你只需循环遍历x坐标并在每一步上色一个像素。如果线条有轻微的斜率(小于 45 度或弧度),你可以沿着斜率插值y坐标。你仍然需要每个x位置一个像素,这些像素的y位置由斜率决定。
但是一旦你的斜率超过 45 度,你需要改变处理坐标的方式。现在你需要每个y位置一个像素,因为线条上升的幅度大于向左移动的幅度。然后,当你越过 135 度时,你必须回到沿x坐标循环,但从右到左。
你实际上不需要写四个循环。因为从A到B绘制一条线与从B到A绘制一条线是一样的,你可以交换从右到左的起始和结束位置,并将它们视为从左到右。
所以你需要两个不同的循环。你的线条绘制函数的第一件事应该是检查x坐标之间的差值是否大于y坐标之间的差值。如果是,这是一条水平线;如果不是,则是一条垂直线。
确保你比较x和y差值的绝对值,你可以使用Math.abs来获得。
一旦你知道将要沿哪个轴进行循环,就可以检查起点在该轴上的坐标是否高于终点,如果有必要可以交换它们。使用解构赋值在 JavaScript 中交换两个绑定的值可以用简洁的方式实现:
[start, end] = [end, start];
然后你可以计算这条线的斜率,它决定了你在主轴上每走一步,另一个轴上的坐标变化量。有了这个,你可以沿主轴运行一个循环,同时跟踪另一个轴上对应的位置,并在每次迭代时绘制像素。确保你对非主轴坐标进行四舍五入,因为它们可能是小数,而绘制方法对小数坐标反应不佳。
第二十章:Node.js
搜索工具
你的第一个命令行参数,即正则表达式,可以在process.argv[2]中找到。输入文件在之后。你可以使用RegExp构造函数将字符串转换为正则表达式对象。
以同步方式使用readFileSync更简单,但如果你使用node:fs/promises获取返回Promise的函数并编写异步函数,代码看起来也相似。
要判断某个东西是否为目录,你可以再次使用stat(或statSync)和stats对象的isDirectory方法。
探索目录是一个分支过程。你可以通过使用递归函数或保持一个待处理的文件数组来实现这一点。要查找目录中的文件,可以调用readdir或readdirSync。注意这种奇怪的大小写——Node 的文件系统函数命名大致基于标准 Unix 函数,例如readdir,它们都是小写的,但然后它增加了一个大写的Sync。
要将通过readdir读取的文件名转换为完整路径名,你必须将其与目录名称结合起来,可以在它们之间放置node:path的sep,或者使用该包中的join函数。
目录创建
你可以使用实现DELETE方法的函数作为MKCOL方法的蓝图。当未找到文件时,尝试使用mkdir创建一个目录。当该路径上存在一个目录时,你可以返回204响应,以确保目录创建请求是幂等的。如果这里存在一个非目录文件,则返回错误代码400(“错误请求”)将是合适的。
网络上的公共空间
你可以创建一个<textarea>元素来保存正在编辑的文件内容。使用fetch的GET请求可以检索文件的当前内容。你可以使用相对 URL,例如index.xhtml,而不是http://localhost:8000/index.xhtml,来引用与运行脚本位于同一服务器上的文件。
然后,当用户点击按钮时(你可以使用<form>元素和“submit”事件),向相同的URL发送PUT请求,请求体为<textarea>的内容,以保存文件。
然后,你可以添加一个<select>元素,包含服务器顶级目录中的所有文件,通过添加<option>元素,内容是通过GET请求到URL/*返回的行。当用户选择另一个文件(字段上的“change”事件)时,脚本必须获取并显示该文件。保存文件时,使用当前选定的文件名。
第二十一章:项目:技能共享网站
磁盘持久性
我能想到的最简单的解决方案是将整个talks对象编码为JSON并使用writeFile将其写入文件。每当服务器的数据发生变化时,都会调用一个名为(updated)的方法。它可以扩展为将新数据写入磁盘。
选择一个文件名,例如./talks.json。当服务器启动时,它可以尝试使用readFile读取该文件,如果成功,服务器可以将文件的内容作为其起始数据。
评论字段重置
最好的做法可能是将演讲组件定义为一个对象,并包含一个syncState方法,这样它们可以被更新以显示修改后的演讲版本。在正常操作中,演讲变化的唯一方式是添加更多评论,因此syncState方法可以相对简单。
难点在于,当一份更改后的演讲列表出现时,我们必须将现有的DOM组件列表与新列表中的演讲进行调和——删除那些演讲已删除的组件,更新那些演讲已更改的组件。
为此,保留一个数据结构以存储演讲标题下的演讲组件可能会很有帮助,这样你就可以轻松确定给定演讲是否存在某个组件。然后你可以遍历新的演讲数组,对于每个演讲,或者同步一个现有的组件,或者创建一个新的组件。要删除已删除演讲的组件,你还需要遍历这些组件,并检查相应的演讲是否仍然存在。
第二十二章:JavaScript与性能
素数
你可以通过使用function*声明定义一个生成器,在循环中使用yield来生成数字。你会想继续检查越来越大的数字,跳过那些可以被较小数字整除的数字,返回那些不能被整除的数字。
function primes*() {
for (let n = 1;; n++) {
// ...
}
}
测试一个数字是否可以被另一个数字整除可以使用余数运算符:n % i == 0仅在n能被i整除时为真。
为了测试生成器的运行结果,你可以使用一个在一万条结果后停止的for/of循环,或者直接调用迭代器返回的next方法。
更快的素数
识别和避免这个函数中无用余数测试的方式有两种。第一种利用了这样一个事实:如果一个数字可以被另一个数字整除,那么这个因子要么是素数,要么是某个素数的倍数,因为所有的合数都是某个素数的倍数。因此,没有必要测试合数。如果函数保持一个已找到的素数数组,它只需检查这些素数,而不是检查2到下一个潜在素数之间的所有数字。
第二个技巧利用了这样的观察:如果一个数字N是A和B的乘积,那么A和B中较小的那个小于或等于N的平方根。如果我们从较小的因子开始测试,我们就不需要检查任何大于这个平方根的数字,因为如果我们的潜在素数是两个整数的乘积,那么在测试较小的那个时,我们就会注意到这一点。


浙公网安备 33010602011771号