Repository: wizardforcel/eloquent-js-3e-zh Branch: master Commit: 097ef4522075 Files: 45 Total size: 1.4 MB Directory structure: gitextract_r4670zmn/ ├── .gitignore ├── 0.md ├── 1.md ├── 10.md ├── 11.md ├── 12.md ├── 13.md ├── 14.md ├── 15.md ├── 16.md ├── 17.md ├── 18.md ├── 19.md ├── 2.md ├── 20.md ├── 21.md ├── 3.md ├── 4.md ├── 5.md ├── 6.md ├── 7.md ├── 8.md ├── 9.md ├── README.md ├── SUMMARY.md ├── diff-en/ │ ├── 2ech0-3ech0.diff │ ├── 2ech1-3ech1.diff │ ├── 2ech11-3ech12.diff │ ├── 2ech12-3ech13.diff │ ├── 2ech13-3ech14.diff │ ├── 2ech14-3ech15.diff │ ├── 2ech15-3ech16.diff │ ├── 2ech16-3ech17.diff │ ├── 2ech17-3ech18a.diff │ ├── 2ech18-3ech18b.diff │ ├── 2ech2-3ech2.diff │ ├── 2ech20-3ech20.diff │ ├── 2ech21-3ech21.diff │ ├── 2ech3-3ech3.diff │ ├── 2ech4-3ech4.diff │ ├── 2ech5-3ech5.diff │ ├── 2ech6-3ech6.diff │ ├── 2ech8-3ech8.diff │ └── 2ech9-3ech9.diff └── styles/ └── ebook.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ _book Thumbs.db ================================================ FILE: 0.md ================================================ # 零、前言 > 原文:[Introduction](https://eloquentjavascript.net/00_intro.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > We think we are creating the system for our own purposes. We believe we are making it in our own image... But the computer is not really like us. It is a projection of a very slim part of ourselves: that portion devoted to logic, order, rule, and clarity. > Ellen Ullman,《Close to the Machine: Technophilia and its Discontents》 ![](img/0-0.jpg) 这是一本关于指导电脑的书。时至今日,计算机就像螺丝刀一样随处可见,但相比于螺丝刀而言,计算机更复杂一些,并且,让他们做你想让他们做的事情,并不总是那么容易。 如果你让计算机执行的任务是常见的,易于理解的任务,例如向你显示你的电子邮件,或像计算器一样工作,则可以打开相应的应用并开始工作。 但对于独特的或开放式的任务,应用可能不存在。 这就是编程可能出现的地方。编程是构建一个程序的行为 - 它是一组精确的指令,告诉计算机做什么。 由于计算机是愚蠢的,迂腐的野兽,编程从根本上是乏味和令人沮丧的。 幸运的是,如果你可以克服这个事实,并且甚至可以享受愚蠢机器可以处理的严谨思维,那么编程可以是非常有益的。 它可以让你在几秒钟内完成手动操作。 这是一种方法,让你的电脑工具去做它以前做不到的事情。 它也提供了抽象思维的优秀练习。 大多数编程都是用编程语言完成的。 编程语言是一种人工构建的语言,用于指导计算机。 有趣的是,我们发现与电脑沟通的最有效的方式,与我们彼此沟通的方式相差太大。 与人类语言一样,计算机语言可以以新的方式组合词语和词组,从而可以表达新的概念。 在某种程度上,基于语言的界面,例如 80 年代和 90 年代的 BASIC 和 DOS 提示符,是与计算机交互的主要方法。 这些已经在很大程度上被视觉界面取代,这些视觉界面更容易学习,但提供更少的自由。 计算机语言仍然存在,如果你知道在哪里看到。 每种现代 Web 浏览器都内置了一种这样的语言,即 JavaScript,因此几乎可以在所有设备上使用。 本书将试图让你足够了解这门语言,从而完成有用和有趣的东西。 ## 关于程序设计 除了讲解 JavaScript 之外,本书也会介绍一些程序设计的基本原则。程序设计还是比较复杂的。编程的基本规则简单清晰,但在这些基本规则之上构建的程序却容易变得复杂,导致程序产生了自己的规则和复杂性。即便程序是按照你自己的思路去构建的,你也有可能迷失在代码之间。 在阅读本书时,你有可能会觉得书中的概念难以理解。如果你刚刚开始学习编程,那么你估计还有不少东西需要掌握呢。如果你想将所学知识融会贯通,那么就需要去多参考和学习一些资料。 是否付出必要的努力完全取决于你自己。当你阅读本书的时候发现任何难点,千万不要轻易就对自己的能力下结论。只要能坚持下去,你就是好样的。稍做休息,复习一下所学的知识点,始终确保自己阅读并理解了示例程序和相关的练习。学习是一项艰巨的任务,但你掌握的所有知识都属于你自己,而且今后的学习道路会愈加轻松。 > 当行动无利可图时,就收集信息;当信息无利可图时,就休息。 > Ursula K. Le Guin,《The Left Hand of Darkness》 一个程序有很多含义:它是开发人员编写的一段文本、计算机执行的一段指令集合、计算机内存当中的数据以及控制内存中数据的操作集合。我们通常很难将程序与我们日常生活中熟悉的事物进行对比。有一种表面上比较恰当的比喻,即将程序视作包含许多组件的机器,为了让机器正常工作,这些组件通过内部通信来实现整个机器的正常运转。 计算机是一台物理机器,充当这些非物质机器的载体。计算机本身并不能实现多么复杂的功能,但计算机之所以有用是因为它们的运算速度非常快。而程序的作用就是将这些看似简单的动作组合起来,然后实现复杂的功能。 程序是思想的结晶。编写程序不需要什么物质投入,它很轻量级,通过我们的双手创造。 但如果不稍加注意,程序的体积和复杂度就会失去控制,甚至代码的编写者也会感到迷惑。在可控的范围内编写程序是编程过程中首要解决的问题。当程序运行时,一切都是那么美好。编程的精粹就在于如何更好地控制复杂度。质量高的程序的复杂度都不会太高。 很多开发人员认为,控制程序复杂度的最好方法就是避免使用不熟悉的技术。他们制定了严格的规则(“最佳实践”),并小心翼翼地呆在他们安全区内。 这不仅无聊,而且也是无效的。新问题往往需要新的解决方案。编程领域还很年轻,仍然在迅速发展,并且多样到足以为各种不同的方法留出空间。在程序设计中有许多可怕的错误,你应该继续犯错,以便你能理解它们。好的程序看起来是什么样的感觉,是在实践中发展的,而不是从一系列规则中学到的。 ## 为什么编程语言重要 在计算技术发展伊始,并没有编程语言这个概念。程序看起来就像这样: ``` 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 之和,并打印出结果:`1+2+...+10=55`。该程序可以运行在一个简单的机器上。在早期计算机上编程时,我们需要在正确的位置设置大量开关阵列,或在纸带上穿孔并将纸带输入计算机中。你可以想象这个过程是多么冗长乏味且易于出错。即便是编写非常简单的程序,也需要有经验的人耗费很大精力才能完成。编写复杂的程序则更是难上加难。 当然了,手动输入这些晦涩难懂的位序列(1 和 0)来编写程序的确能让程序员感到很有成就感,而且能给你的职业带来极大的满足感。 在上面的程序中,每行都包含一条指令。我们可以用中文来描述这些指令: 1. 将数字 0 存储在内存地址中的位置 0。 2. 将数字 1 存储在内存地址的位置 1。 3. 将内存地址的位置 1 中的值存储在内存地址的位置 2。 4. 将内存地址的位置 2 中的值减去数字 11。 5. 如果内存地址的位置 2 中的值是 0,则跳转到指令 9。 6. 将内存地址的位置 1 中的值加到内存地址的位置 0。 7. 将内存地址的位置 1 中的值加上数字 1。 8. 跳转到指令 3。 9. 输出内存地址的位置 0 中的值。 虽说这已经比一大堆位序列要好读了许多,但仍然不清晰。使用名称而不是数字用于指令和存储位置有所帮助: ``` Set “total” to 0. Set “count” to 1. [loop] Set “compare” to “count”. Subtract 11 from “compare”. If “compare” is zero, continue at [end]. Add “count” to “total”. Add 1 to “count”. Continue at [loop]. [end] Output “total”. ``` 现在你能看出该程序是如何工作的吗?前两行代码初始化两个内存位置的值:`total`用于保存累加计算结果,而`count`则用于记录当前数字。你可能觉得`compare`的那行代码看起来有些奇怪。程序想根据`count`是否等于 11 来决定是否应该停止运行。因为我们的机器相当原始,所以只能测试一个数字是否为 0,并根据它做出决策。因此程序用名为`compare`的内存位置存放`count–11`的值,并根据该值是否为 0 决定是否跳转。接下来两行将`count`的值累加到结果上,并将`count`加 1,直到`count`等于`11`为止。 下面使用 JavaScript 重新编写了上面的程序: ```js let total = 0, count = 1; while (count <= 10) { total += count; count += 1; } console.log(total); // → 55 ``` 这个版本的程序得到了一些改进。更为重要的是,我们再也不需要指定程序如何来回跳转了,而是由`while`结构负责完成这个任务。只要我们给予的条件成立,`while`语句就会不停地执行其下方的语句块(包裹在大括号中)。而我们给予的条件是`count<=10`,意思是“`count`小于等于 10”。我们再也不需要创建临时的值并将其与 0 比较,那样的代码十分烦琐。编程语言的一项职责就是,能够帮助我们处理这些烦琐无趣的逻辑。 在程序的结尾,也就是`while`语句结束后,我们使用`console.log`操作来输出结果。 最后,我们恰好有`range`和`sum`这类方便的操作。下面代码中的`range`函数用于创建数字集合,`sum`函数用于计算数字集合之和: ```js console.log(sum(range(1, 10))); // → 55 ``` 我们可以从这里了解到,同一个程序的长度可长可短,可读性可高可低。第一个版本的程序晦涩难懂,而最后一个版本的程序则接近于人类语言的表达方式:将 1~10 范围内的数字之和记录下来(我们会在后面的章节中详细介绍如何编写`sum`和`range`这样的函数)。 优秀的编程语言可以为开发人员提供更高层次的抽象,使用类似于人类语言的方式来与计算机进行交互。它有助于省略细节,提供便捷的积木(比如`while`和`console.log`),允许你定义自己的积木(比如`sum`和`range`函数),并使这些积木易于编写。。 ## 什么是 JavaScript JavaScript 诞生于 1995 年。起初,Netscape Navigator 浏览器将其运用在网页上添加程序。自此以后,各类主流图形网页浏览器均采用了 JavaScript。JavaScript 使得现代网页应用程序成为可能 —— 使用 JavaScript 可以直接与用户交互,从而避免每一个动作都需要重新载入页面。但有许多传统网站也会使用 JavaScript 来提供实时交互以及更加智能的表单功能。 JavaScript 其实和名为Java的程序设计语言没有任何关系。起了这么一个相似的名字完全是市场考虑使然,这并非是一个明智的决定。当 JavaScript 出现时,Java 语言已在市场上得到大力推广且拥有了极高人气,因此某些人觉得依附于 Java 的成功是个不错的主意。而我们现在已经无法摆脱这个名字了。 在 JavaScript 被广泛采用之后,ECMA 国际制订了一份标准文档来描述 JavaScript 的工作行为,以便所有声称支持 JavaScript 的软件都使用同一种语言。标准化完成后,该标准被称为 ECMAScript 标准。实际上,术语 ECMAScript 和 JavaScript 可以交换使用。它们不过是同一种语言的两个名字而已。 许多人会说 JavaScript 语言的坏话。这其中有很多这样的言论都是正确的。当被要求第一次使用 JavaScript 编写代码时,我当时就觉得这门语言难以驾驭。JavaScript 接受我输入的任何代码,但是又使用和我的想法完全不同的方式来解释代码。由于我没有任何线索知道我之前做了什么,因此我需要做出更多工作,但这也就存在一个实际问题:我们可以自由使用 JavaScript,而这种自由却几乎没有限度。这种设计其实是希望初学者更容易使用 JavaScript 编写程序。但实际上,系统不会指出我们错在何处,因此从程序中找出问题变得更加棘手。 但这种自由性也有其优势,许多技术在更为严格的语言中不可能实现,而在 JavaScript 中则留下了实现的余地,正如你看到的那样(比如第十章),有些优势可以弥补 JavaScript 的一些缺点。在正确地学习 JavaScript 并使用它工作了一段时间后,我真正喜欢上了 JavaScript。 JavaScript 版本众多。大约在 2000~2010 年间,这正是 JavaScript 飞速发展的时期,浏览器支持最多的是 ECMAScript 3。在此期间,ECMA 着手制定 ECMAScript 4,这是一个雄心勃勃的版本,ECMA 计划在这个版本中加入许多彻底的改进与扩展。但由于 ECMAScript 3 被广泛使用,这种过于激进的修改必然会遭遇重重阻碍,最后 ECMA 不得不于 2008 年放弃了版本 4 的制定。这就产生了不那么雄心勃勃的版本 5,这只是一些没有争议的改进,出现在 2009 年。 然后版本 6 在 2015 年诞生,这是一个重大的更新,其中包括计划用于版本 4 的一些想法。从那以后,每年都会有新的更新。 语言不断发展的事实意味着,浏览器必须不断跟上,如果你使用的是较老的浏览器,它可能不支持每个特性。 语言设计师会注意,不要做任何可能破坏现有程序的改变,所以新的浏览器仍然可以运行旧的程序。 在本书中,我使用的是 2017 版的 JavaScript。 Web 浏览器并不是唯一一个可以运行 JavaScript 的平台。有些数据库,比如 MongoDB 和 CouchDB,也使用 JavaScript 作为脚本语言和查询语言。一些桌面和服务器开发的平台,特别是 Node.js 项目(第二十章介绍),为浏览器以外的 JavaScript 编程提供了一个环境。 ## 代码及相关工作 代码是程序的文本内容。本书多数章节都介绍了大量代码。我相信阅读代码和编写代码是学习编程不可或缺的部分。尝试不要仅仅看一眼示例,而应该认真阅读并理解每个示例。刚开始使用这种方式可能会速度较慢并为代码所困惑,但我坚信你很快就可以熟能生巧。对待习题的方法也应该一样。除非你确实已经编写代码解决了问题,否则不要假设你已经理解了问题。 建议读者应尝试在实际的 JavaScript 解释器中执行习题代码。这样一来,你就可以马上获知代码工作情况的反馈,而且我希望读者去做更多的试验,而不仅仅局限于习题的要求。 可以在 中查阅本书的在线版本,并运行和实验本书中的代码。也可以在在线版本中点击任何代码示例来编辑、运行并查看其产生的输出。在做习题时,你可以访问 ,该网址会提供每个习题的初始代码,让你专心于解答习题。 如果想要在本书提供的沙箱以外执行本书代码,需要稍加注意。许多的示例是独立的,而且可以在任何 JavaScript 环境下运行。但后续章节的代码大多数都是为特定环境(浏览器或者 Node.js)编写的,而且只能在这些特定环境下执行代码。此外,许多章节定义了更大的程序,这些章节中出现的代码片段会互相依赖或是依赖于一些外部文件。本书网站的沙箱提供了 zip 压缩文件的链接,该文件包含了所有运行特定章节代码所需的脚本和数据文件。 ## 本书概览 本书包括三个部分。前十二章讨论 JavaScript 语言本身的一些特性。接下来的 8 章讨论网页浏览器和 JavaScript 在网页编程中的实践。最后两章专门讲解另一个使用 JavaScript 编程的环境 —— Node.js。 纵观本书,共有 5 个项目实战章,用于讲解规模较大的示例程序,你可以通过这些章来仔细品味真实的编程过程。根据项目出现次序,我们会陆续构建递送机器人(7)、程序设计语言(12)、平台游戏(16)、像素绘图程序(19)和一个动态网站(21)。 本书介绍编程语言时,首先使用4章来介绍 JavaScript 语言的基本结构,包括第二章控制结构(比如在本前言中看到的`while`单词)、第三章函数(编写你自己的积木)和第四章数据结构。此后你就可以编写简单的程序了。接下来,第五章和第六章介绍函数和对象的运用技术,以编写更加抽象的代码并以此来控制复杂度。 介绍完第一个项目实战(7)之后,将会继续讲解语言部分,例如第八章错误处理和 bug 修复、第九章正则表达式(处理文本数据的重要工具)、第十章模块化(解决复杂度的问题)以及第十一章异步编程(处理需要时间的事件)。第二个项目实战章节(12)则是对本书第一部分的总结。 第二部分(第十三章到第十九章),阐述了浏览器 JavaScript 中的一些工具。你将会学到在屏幕上显示某些元素的方法(第十四章与第十七章),响应用户输入的方法(第十五章)和通过网络通信的方法(第十八章)。这部分又有两个项目实战章节。 此后,第二十章阐述 Node.js,而第二十一章使用该工具构建一个简单的网页系统。 ## 本书版式约定 本书中存在大量代码,程序(包括你迄今为止看到的一些示例)代码的字体如下所示: ```js function factorial(n) { if (n == 0) { return 1; } else { return factorial(n - 1) * n; } } ``` 为了展示程序产生的输出,本书常在代码后编写代码期望输出,输出结果前会加上两个反斜杠和一个箭头。 ```js console.log(factorial(8)); // → 40320 ``` 祝好运! ================================================ FILE: 1.md ================================================ ## 一、值,类型和运算符 > 原文:[Values, Types, and Operators](http://eloquentjavascript.net/01_values.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > 在机器的表面之下,程序在运转。 它不费力就可以扩大和缩小。 在和谐的关系中,电子散开并重新聚合。 监视器上的表格只是水面上的涟漪。 本质隐藏在下面。 > > Master Yuan-Ma,《The Book of Programming》 ![](img/1-0.jpg) 计算机世界里只有数据。 你可以读取数据,修改数据,创建新数据 - 但不能提及不是数据的东西。 所有这些数据都以位的长序列存储,因此基本相似。 位是任何类型的二值的东西,通常描述为零和一。 在计算机内部,他们有一些形式,例如高电荷或低电荷,强信号或弱信号,或 CD 表面上的亮斑点或暗斑点。 任何一段离散信息都可以简化为零和一的序列,从而以位表示。 例如,我们可以用位来表示数字 13。 它的原理与十进制数字相同,但不是 10 个不同的数字,而只有 2 个,每个数字的权重从右到左增加 2 倍。 以下是组成数字 13 的位,下方显示数字的权重: ``` 0 0 0 0 1 1 0 1 128 64 32 16 8 4 2 1 ``` 因此,这就是二进制数`00001101`,或者`8+4+1`,即 13。 ## 值 想象一下位之海 - 一片它们的海洋。 典型的现代计算机的易失性数据存储器(工作存储器)中,有超过 300 亿位。非易失性存储(硬盘或等价物)往往还有几个数量级。 为了能够在不丢失的情况下,处理这些数量的数据,我们必须将它们分成代表信息片段的块。 在 JavaScript 环境中,这些块称为值。 虽然所有值都是由位构成的,但他们起到不同的作用,每个值都有一个决定其作用的类型。 有些值是数字,有些值是文本片段,有些值是函数,等等。 要创建一个值,你只需要调用它的名字。 这很方便。 你不必为你的值收集建筑材料或为其付费。 你只需要调用它,然后刷的一下,你就有了它。 当然,它们并不是真正凭空创造的。 每个值都必须存储在某个地方,如果你想同时使用大量的值,则可能会耗尽内存。 幸运的是,只有同时需要它们时,这才是一个问题。 只要你不再使用值,它就会消失,留下它的一部分作为下一代值的建筑材料。 本章将会介绍 JavaScript 程序当中的基本元素,包括简单的值类型以及值运算符。 ## 数字 数字(`Number`)类型的值即数字值。在 JavaScript 中写成如下形式: ```js 13 ``` 在程序中使用这个值的时候,就会将数字 13 以位序列的方式存放在计算机的内存当中。 JavaScript使用固定数量的位(64 位)来存储单个数字值。 你可以用 64 位创造很多模式,这意味着可以表示的不同数值是有限的。 对于`N`个十进制数字,可以表示的数值数量是`10^N`。 与之类似,给定 64 个二进制数字,你可以表示`2^64`个不同的数字,大约 18 亿亿(18 后面有 18 个零)。太多了。 过去计算机内存很小,人们倾向于使用一组 8 位或 16 位来表示他们的数字。 这么小的数字很容易意外地溢出,最终得到的数字不能放在给定的位数中。 今天,即使是装在口袋里的电脑也有足够的内存,所以你可以自由使用 64 位的块,只有在处理真正的天文数字时才需要担心溢出。 不过,并非所有 18 亿亿以下的整数都能放在 JavaScript 数值中。 这些位也存储负数,所以一位用于表示数字的符号。 一个更大的问题是,也必须表示非整数。 为此,一些位用于存储小数点的位置。 可以存储的实际最大整数更多地在 9000 万亿(15 个零)的范围内 - 这仍然相当多。 使用小数点来表示分数。 ```js 9.81 ``` 对于非常大或非常小的数字,你也可以通过输入`e`(表示指数),后面跟着指数来使用科学记数法: ```js 2.998e8 ``` 即`2.998 * 10^8 = 299,800,000`。 当计算小于前文当中提到的 9000 万亿的整数时,其计算结果会十分精确,不过在计算小数的时候精度却不高。正如(`pi`)无法使用有限个数的十进制数字表示一样,在使用 64 位来存储分数时也同样会丢失一些精度。虽说如此,但这类丢失精度只会在一些特殊情况下才会出现问题。因此我们需要注意在处理分数时,将其视为近似值,而非精确值。 ### 算术 与数字密切相关的就是算术。比如,加法或者乘法之类的算术运算会使用两个数值,并产生一个新的数字。JavaScript 中的算术运算如下所示: ```js 100 + 4 * 11 ``` 我们把`+`和`*`符号称为运算符。第一个符号表示加法,第二个符号表示乘法。将一个运算符放在两个值之间,该运算符将会使用其旁边的两个值产生一个新值。 但是这个例子的意思是“将 4 和 100 相加,并将结果乘 11”,还是是在加法之前计算乘法? 正如你可能猜到的那样,乘法首先计算。 但是和数学一样,你可以通过将加法包在圆括号中来改变它: ```js (100 + 4) * 11 ``` `–`运算符表示减法,`/`运算符则表示除法。 在运算符同时出现,并且没有括号的情况下,其运算顺序根据运算符优先级确定。示例中的乘法运算符优先级高于加法。而`/`运算符和`*`运算符优先级相同,`+`运算符和`–`运算符优先级也相同。当多个具有相同优先级的运算符相邻出现时,运算从左向右执行,比如`1–2+1`的运算顺序是`(1–2)+1`。 你无需担心这些运算符的优先级规则,不确定的时候只需要添加括号即可。 还有一个算术运算符,你可能无法立即认出。 `%`符号用于表示取余操作。 `X % Y`是`Y`除`X`的余数。 例如,`314 % 100`产生`14`,`144 % 12`产生`0`。 余数的优先级与乘法和除法的优先级相同。 你还经常会看到这个运算符被称为模运算符。 ### 特殊数字 在 JavaScript 中有三个特殊的值,它们虽然是数字,但看起来却跟一般的数字不太一样。 前两个是`Infinity`和`-Infinity`,它们代表正无穷和负无穷。 “无穷减一”仍然是“无穷”,依此类推。 尽管如此,不要过分信任基于无穷大的计算。 它在数学上不合理,并且很快导致我们的下一个特殊数字:`NaN`。 `NaN`代表“不是数字”,即使它是数字类型的值。 例如,当你尝试计算`0/0`(零除零),`Infinity - Infinity`或任何其他数字操作,它不会产生有意义的结果时,你将得到此结果。 ## 字符串 下一个基本数据类型是字符串(`String`)。 字符串用于表示文本。 它们是用引号括起来的: ```js `Down on the sea` "Lie on the ocean" 'Float on the ocean' ``` 只要字符串开头和结尾的引号匹配,就可以使用单引号,双引号或反引号来标记字符串。 几乎所有的东西都可以放在引号之间,并且 JavaScript 会从中提取字符串值。 但少数字符更难。 你可能难以想象,如何在引号之间加引号。 当使用反引号(`` ` ``)引用字符串时,换行符(当你按回车键时获得的字符)可能会被包含,而无需转义。 若要将这些字符存入字符串,需要使用下列规则:当反斜杠(`\`)出现在引号之间的文本中时,表示紧跟在其后的字符具有特殊含义,我们将其称之为转义符。当引号紧跟在反斜杠后时,并不意味着字符串结束,而表示这个引号是字符串的一部分。当字符`n`出现在反斜杠后时,JavaScript 将其解释成换行符。以此类推,`\t`表示制表符,我们来看看下面这个字符串: ```js "This is the first line\nAnd this is the second" ``` 该字符串实际表示的文本是: ``` This is the first line And this is the second ``` 当然,在某些情况下,你希望字符串中的反斜杠只是反斜杠,而不是特殊代码。 如果两个反斜杠写在一起,它们将合并,并且只有一个将留在结果字符串值中。 这就是字符串“`A newline character is written like "\n".`”的表示方式: ```js "A newline character is written like \"\\n\"." ``` 字符串也必须建模为一系列位,以便能够存在于计算机内部。 JavaScript 执行此操作的方式基于 Unicode 标准。 该标准为你几乎需要的每个字符分配一个数字,包括来自希腊语,阿拉伯语,日语,亚美尼亚语,以及其他的字符。 如果我们为每个字符分配一个数字,则可以用一系列数字来描述一个字符串。 这就是 JavaScript 所做的。 但是有一个复杂的问题:JavaScript 的表示为每个字符串元素使用 16 位,它可以描述多达 2 的 16 次方个不同的字符。 但是,Unicode 定义的字符多于此 - 大约是此处的两倍。 所以有些字符,比如许多 emoji,在 JavaScript 字符串中占据了两个“字符位置”。 我们将在第 5 章中回来讨论。 我们不能将除法,乘法或减法运算符用于字符串,但是`+`运算符却可以。这种情况下,运算符并不表示加法,而是连接操作:将两个字符串连接到一起。以下语句可以产生字符串`"concatenate"`: ```js "con" + "cat" + "e" + "nate" ``` 字符串值有许多相关的函数(方法),可用于对它们执行其他操作。 我们将在第 4 章中回来讨论。 用单引号或双引号编写的字符串的行为非常相似 - 唯一的区别是需要在其中转义哪种类型的引号。 反引号字符串,通常称为模板字面值,可以实现更多的技巧。 除了能够跨越行之外,它们还可以嵌入其他值。 ```js `half of 100 is ${100 / 2}` ``` 当你在模板字面值中的`$ {}`中写入内容时,将计算其结果,转换为字符串并包含在该位置。 这个例子产生`"half of 100 is 50"`。 ## 一元运算符 并非所有的运算符都是用符号来表示,还有一些运算符是用单词表示的。比如`typeof`运算符,会产生一个字符串的值,内容是给定值的具体类型。 ```js console.log(typeof 4.5) // → number console.log(typeof "x") // → string ``` 我们将在示例代码中使用`console.log`,来表示我们希望看到求值结果。更多内容请见下一章。 我们所见过的绝大多数运算符都使用两个值进行操作,而`typeof`仅接受一个值进行操作。使用两个值的运算符称为二元运算符,而使用一个值的则称为一元运算符。减号运算符既可用作一元运算符,也可用作二元运算符。 ```js console.log(- (10 - 2)) // → -8 ``` ## 布尔值 拥有一个值,它能区分两种可能性,通常是有用的,例如“是”和“否”或“开”和“关”。 为此,JavaScript 拥有布尔(`Boolean`)类型,它有两个值:`true`和`false`,它们就写成这些单词。 ### 比较 一种产生布尔值的方法如下所示: ```js console.log(3 > 2) // → true console.log(3 < 2) // → false ``` `>`和`<`符号分别表示“大于”和“小于”。这两个符号是二元运算符,通过该运算符返回的结果是一个布尔值,表示其运算是否为真。 我们可以使用相同的方法比较字符串。 ```js console.log("Aardvark" < "Zoroaster") // → true ``` 字符串排序的方式大致是字典序,但不真正是你期望从字典中看到的那样:大写字母总是比小写字母“小”,所以`"Z"<"a"`,非字母字符(`!`,`-`等)也包含在排序中。 比较字符串时,JavaScript 从左向右遍历字符,逐个比较 Unicode 代码。 其他类似的运算符则包括`>=`(大于等于),`<=`(小于等于),`==`(等于)和`!=`(不等于)。 ```js console.log("Apple" == "Orange") // → false ``` 在 JavaScript 中,只有一个值不等于其自身,那就是`NaN`(Not a Number,非数值)。 ```js console.log(NaN == NaN) // → false ``` `NaN`用于表示非法运算的结果,正因如此,不同的非法运算结果也不会相等。 ### 逻辑运算符 还有一些运算符可以应用于布尔值上。JavaScript 支持三种逻辑运算符:与(and),或(or)和非(not)。这些运算符可以用于推理布尔值。 `&&`运算符表示逻辑与,该运算符是二元运算符,只有当赋给它的两个值均为`true`时其结果才是真。 ```js console.log(true && false) // → false console.log(true && true) // → true ``` `||`运算符表示逻辑或。当两个值中任意一个为`true`时,结果就为真。 ```js console.log(false || true) // → true console.log(false || false) // → false ``` 感叹号(`!`)表示逻辑非,该运算符是一元运算符,用于反转给定的值,比如`!true`的结果是`false`,而`!false`结果是`true`。 在混合使用布尔运算符和其他运算符的情况下,总是很难确定什么时候需要使用括号。实际上,只要熟悉了目前为止我们介绍的运算符,这个问题就不难解决了。`||`优先级最低,其次是`&&`,接着是比较运算符(`>`,`==`等),最后是其他运算符。基于这些优先级顺序,我们在一般情况下最好还是尽量少用括号,比如说: ```js 1 + 1 == 2 && 10 * 10 > 50 ``` 现在我们来讨论最后一个逻辑运算符,它既不属于一元运算符,也不属于二元运算符,而是三元运算符(同时操作三个值)。该运算符由一个问号和冒号组成,如下所示。 ```js console.log(true ? 1 : 2); // → 1 console.log(false ? 1 : 2); // → 2 ``` 这个被称为条件运算符(或者有时候只是三元运算符,因为它是该语言中唯一的这样的运算符)。 问号左侧的值“挑选”另外两个值中的一个。 当它为真,它选择中间的值,当它为假,则是右边的值。 ## 空值 有两个特殊值,写成`null`和`undefined`,用于表示不存在有意义的值。 它们本身就是值,但它们没有任何信息。 在 JavaScript 语言中,有许多操作都会产生无意义的值(我们会在后面的内容中看到实例),这些操作会得到`undefined`的结果仅仅只是因为每个操作都必须产生一个值。 `undefined`和`null`之间的意义差异是 JavaScript 设计的一个意外,大多数时候它并不重要。 在你实际上不得不关注这些值的情况下,我建议将它们视为几乎可互换的。 ## 自动类型转换 在引言中,我提到 JavaScript 会尽可能接受几乎所有你给他的程序,甚至是那些做些奇怪事情的程序。 以下表达式很好地证明了这一点: ```js 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`,所以如果你发现自己在一个意想不到的地方得到了它,需要寻找意外的类型转换。 当相同类型的值之间使用`==`符号进行比较时,其运算结果很好预测:除了`NaN`这种情况,只要两个值相同,则返回`true`。但如果类型不同,JavaScript 则会使用一套复杂难懂的规则来确定输出结果。在绝大多数情况下,JavaScript 只是将其中一个值转换成另一个值的类型。但如果运算符两侧存在`null`或`undefined`,那么只有两侧均为`null`或`undefined`时结果才为`true`。 ```js console.log(null == undefined); // → true console.log(null == 0); // → false ``` 这种行为通常很有用。 当你想测试一个值是否具有真值而不是`null`或`undefined`时,你可以用`==`(或`!=`)运算符将它与`null`进行比较。 但是如果你想测试某些东西是否严格为“false”呢? 字符串和数字转换为布尔值的规则表明,`0`,`NaN`和空字符串(`""`)计为`false`,而其他所有值都计为`true`。 因此,像`'0 == false'`和`"" == false`这样的表达式也是真的。 当你不希望发生自动类型转换时,还有两个额外的运算符:`===`和`!==`。 第一个测试是否严格等于另一个值,第二个测试它是否不严格相等。 所以`"" === false`如预期那样是错误的。 我建议使用三字符比较运算符来防止意外类型转换的发生,避免作茧自缚。但如果比较运算符两侧的值类型是相同的,那么使用较短的运算符也没有问题。 ### 逻辑运算符的短路特性 逻辑运算符`&&`和`||`以一种特殊的方式处理不同类型的值。 他们会将其左侧的值转换为布尔型,来决定要做什么,但根据运算符和转换结果,它们将返回原始的左侧值或右侧值。 例如,当左侧值可以转换为`true`时,`||`运算符会返回它,否则返回右侧值。 当值为布尔值时,这具有预期的效果,并且对其他类型的值做类似的操作。 ```js console.log(null || "user") // → user console.log("Agnes" || "user") // → Agnes ``` 我们可以此功能用作回落到默认值的方式。 如果你的一个值可能是空的,你可以把`||`和备选值放在它之后。 如果初始值可以转换为`false`,那么你将得到备选值。 `&&`运算符工作方式与其相似但不相同。当左侧的值可以被转换成`false`时,`&&`运算符会返回左侧值,否则返回右侧值。 这两个运算符的另一个重要特性是,只在必要时求解其右侧的部分。 在`true || X`的情况下,不管`X`是什么 - 即使它是一个执行某些恶意操作的程序片段,结果都是`true`,并且`X`永远不会求值。 `false && X`也是一样,它是`false`的,并且忽略`X`。 这称为短路求值。 条件运算符以类似的方式工作。 在第二个和第三个值中,只有被选中的值才会求值。 ## 本章小结 在本章中,我们介绍了 JavaScript 的四种类型的值:数字,字符串,布尔值和未定义值。 通过输入值的名称(`true`,`null`)或值(`13`,`"abc"`)就可以创建它们。你还可以通过运算符来对值进行合并和转换操作。本章已经介绍了算术二元运算符(`+`,`–`,`*`,`/`和`%`),字符串连接符(`+`),比较运算符(`==`,`!=`,`===`,`!==`,`<`,`>`,`<=`和`>=`),逻辑运算符(`&&`和`||`)和一些一元运算符(`–`表示负数,`!`表示逻辑非,`typeof`用于查询值的类型)。 这为你提供了足够的信息,将 JavaScript 用作便携式计算器,但并不多。 下一章将开始将这些表达式绑定到基本程序中。 ================================================ FILE: 10.md ================================================ # 十、模块 > 原文:[Modules](http://eloquentjavascript.net/10_modules.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > 编写易于删除,而不是易于扩展的代码。 > > Tef,《Programming is Terrible》 ![](img/10-0.jpg) 理想的程序拥有清晰的结构。 它的工作方式很容易解释,每个部分都起到明确的作用。 典型的真实程序会有机地增长。 新功能随着新需求的出现而增加。 构建和维护结构是额外的工作,只有在下一次有人参与该计划时,才会得到回报。 所以它易于忽视,并让程序的各个部分变得深深地纠缠在一起。 这导致了两个实际问题。 首先,这样的系统难以理解。 如果一切都可以接触到一切其它东西,那么很难单独观察任何给定的片段。 你不得不全面理解整个东西。 其次,如果你想在另一个场景中,使用这种程序的任何功能,比起试图从它的上下文中将它分离出来,重写它可能要容易。 术语“大泥球”通常用于这种大型,无结构的程序。 一切都粘在一起,当你试图挑选出一段代码时,整个东西就会分崩离析,你的手会变脏。 ## 模块 模块试图避免这些问题。 模块是一个程序片段,规定了它依赖的其他部分,以及它为其他模块提供的功能(它的接口)。 模块接口与对象接口有许多共同之处,我们在第 6 章中看到。它们向外部世界提供模块的一部分,并使其余部分保持私有。 通过限制模块彼此交互的方式,系统变得更像积木,其中的组件通过明确定义的连接器进行交互,而不像泥浆一样,一切都混在一起。 模块之间的关系称为依赖关系。 当一个模块需要另一个模块的片段时,就说它依赖于这个模块。 当模块中明确规定了这个事实时,它可以用于确定,需要哪些其他模块才能使用给定的模块,并自动加载依赖关系。 为了以这种方式分离模块,每个模块需要它自己的私有作用域。 将你的 JavaScript 代码放入不同的文件,不能满足这些要求。 这些文件仍然共享相同的全局命名空间。 他们可以有意或无意干扰彼此的绑定。 依赖性结构仍不清楚。 我们将在本章后面看到,我们可以做得更好。 合适的模块结构可能难以为程序设计。 在你还在探索这个问题的阶段,尝试不同的事情来看看什么是可行的,你可能不想过多担心它,因为这可能让你分心。 一旦你有一些感觉可靠的东西,现在是后退一步并组织它的好时机。 ## 包 从单独的片段中构建一个程序,并实际上能够独立运行这些片段的一个优点是,你可能能够在不同的程序中应用相同的部分。 但如何实现呢? 假设我想在另一个程序中使用第 9 章中的`parseINI`函数。 如果清楚该函数依赖什么(在这种情况下什么都没有),我可以将所有必要的代码复制到我的新项目中并使用它。 但是,如果我在代码中发现错误,我可能会在当时正在使用的任何程序中将其修复,并忘记在其他程序中修复它。 一旦你开始复制代码,你很快就会发现,自己在浪费时间和精力来到处复制并使他们保持最新。 这就是包的登场时机。包是可分发(复制和安装)的一大块代码。 它可能包含一个或多个模块,并且具有关于它依赖于哪些其他包的信息。 一个包通常还附带说明它做什么的文档,以便那些不编写它的人仍然可以使用它。 在包中发现问题或添加新功能时,会将包更新。 现在依赖它的程序(也可能是包)可以升级到新版本。 以这种方式工作需要基础设施。 我们需要一个地方来存储和查找包,以及一个便利方式来安装和升级它们。 在 JavaScript 世界中,这个基础结构由 [NPM](https://npmjs.org) 提供。 NPM 是两个东西:可下载(和上传)包的在线服务,以及可帮助你安装和管理它们的程序(与 Node.js 捆绑在一起)。 在撰写本文时,NPM 上有超过 50 万个不同的包。 其中很大一部分是垃圾,我应该提一下,但几乎所有有用的公开包都可以在那里找到。 例如,一个 INI 文件解析器,类似于我们在第 9 章中构建的那个,可以在包名称`ini`下找到。 第 20 章将介绍如何使用`npm`命令行程序在局部安装这些包。 使优质的包可供下载是非常有价值的。 这意味着我们通常可以避免重新创建一百人之前写过的程序,并在按下几个键时得到一个可靠,充分测试的实现。 软件的复制很便宜,所以一旦有人编写它,分发给其他人是一个高效的过程。但首先把它写出来是工作量,回应在代码中发现问题的人,或者想要提出新功能的人,是更大的工作量。 默认情况下,你拥有你编写的代码的版权,其他人只有经过你的许可才能使用它。但是因为有些人不错,而且由于发布好的软件可以使你在程序员中出名,所以许多包都会在许可证下发布,明确允许其他人使用它。 NPM 上的大多数代码都以这种方式授权。某些许可证要求你还要在相同许可证下发布基于那个包构建的代码。其他要求不高,只是要求在分发代码时保留许可证。 JavaScript 社区主要使用后一种许可证。使用其他人的包时,请确保你留意了他们的许可证。 ## 即兴的模块 2015 年之前,JavaScript 语言没有内置的模块系统。 然而,尽管人们已经用 JavaScript 构建了十多年的大型系统,他们需要模块。 所以他们在语言之上设计了自己的模块系统。 你可以使用 JavaScript 函数创建局部作用域,并使用对象来表示模块接口。 这是一个模块,用于日期名称和数字之间的转换(由`Date`的`getDay`方法返回)。 它的接口由`weekDay.name`和`weekDay.number`组成,它将局部绑定名称隐藏在立即调用的函数表达式的作用域内。 ```js 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 ``` 这种风格的模块在一定程度上提供了隔离,但它不声明依赖关系。 相反,它只是将其接口放入全局范围,并希望它的依赖关系(如果有的话)也这样做。 很长时间以来,这是 Web 编程中使用的主要方法,但现在它几乎已经过时。 如果我们想让依赖关系成为代码的一部分,我们必须控制依赖关系的加载。 实现它需要能够将字符串执行为代码。 JavaScript 可以做到这一点。 ## 将数据执行为代码 有几种方法可以将数据(代码的字符串)作为当前程序的一部分运行。 最明显的方法是特殊运算符`eval`,它将在当前作用域内执行一个字符串。 这通常是一个坏主意,因为它破坏了作用域通常拥有的一些属性,比如易于预测给定名称所引用的绑定。 ```js const x = 1; function evalAndReturnX(code) { eval(code); return x; } console.log(evalAndReturnX("var x = 2")); // → 2 console.log(x); // → 1 ``` 将数据解释为代码的不太可怕的方法,是使用`Function`构造器。 它有两个参数:一个包含逗号分隔的参数名称列表的字符串,和一个包含函数体的字符串。 它将代码封装在一个函数值中,以便它获得自己的作用域,并且不会对其他作用域做出奇怪的事情。 ```py let plusOne = Function("n", "return n + 1;"); console.log(plusOne(4)); // → 5 ``` 这正是我们需要的模块系统。 我们可以将模块的代码包装在一个函数中,并将该函数的作用域用作模块作用域。 ## CommonJS 用于连接 JavaScript 模块的最广泛的方法称为 CommonJS 模块。 Node.js 使用它,并且是 NPM 上大多数包使用的系统。 CommonJS 模块的主要概念是称为`require`的函数。 当你使用依赖项的模块名称调用这个函数时,它会确保该模块已加载并返回其接口。 由于加载器将模块代码封装在一个函数中,模块自动得到它们自己的局部作用域。 他们所要做的就是,调用`require`来访问它们的依赖关系,并将它们的接口放在绑定到`exports`的对象中。 此示例模块提供了日期格式化功能。 它使用 NPM的两个包,`ordinal`用于将数字转换为字符串,如`"1st"`和`"2nd"`,以及`date-names`用于获取星期和月份的英文名称。 它导出函数`formatDate`,它接受一个`Date`对象和一个模板字符串。 模板字符串可包含指明格式的代码,如`YYYY`用于全年,`Do`用于每月的序数日。 你可以给它一个像`"MMMM Do YYYY"`这样的字符串,来获得像`"November 22nd 2017"`这样的输出。 ```js 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`导出包含多个东西的对象 - `days`和`months`是名称数组。 为导入的接口创建绑定时,解构是非常方便的。 该模块将其接口函数添加到`exports`,以便依赖它的模块可以访问它。 我们可以像这样使用模块: ```js const {formatDate} = require("./format-date"); console.log(formatDate(new Date(2017, 9, 13), "dddd the Do")); // → Friday the 13th ``` 我们可以用最简单的形式定义`require`,如下所示: ```js require.cache = Object.create(null); function require(name) { if (!(name in require.cache)) { let code = readFile(name); let module = {exports: {}}; require.cache[name] = module; let wrapper = Function("require, exports, module", code); wrapper(require, module.exports, module); } return require.cache[name].exports; } ``` 在这段代码中,`readFile`是一个构造函数,它读取一个文件并将其内容作为字符串返回。标准的 JavaScript 没有提供这样的功能,但是不同的 JavaScript 环境(如浏览器和 Node.js)提供了自己的访问文件的方式。这个例子只是假设`readFile`存在。 为了避免多次加载相同的模块,`require`需要保存(缓存)已经加载的模块。被调用时,它首先检查所请求的模块是否已加载,如果没有,则加载它。这涉及到读取模块的代码,将其包装在一个函数中,然后调用它。 我们之前看到的`ordinal`包的接口不是一个对象,而是一个函数。 CommonJS 模块的特点是,尽管模块系统会为你创建一个空的接口对象(绑定到`exports`),但你可以通过覆盖`module.exports`来替换它。许多模块都这么做,以便导出单个值而不是接口对象。 通过将`require`,`exports`和`module`定义为生成的包装函数的参数(并在调用它时传递适当的值),加载器确保这些绑定在模块的作用域中可用。 提供给`require`的字符串翻译为实际的文件名或网址的方式,在不同系统有所不同。 当它以`"./"`或`"../"`开头时,它通常被解释为相对于当前模块的文件名。 所以`"./format-date"`就是在同一个目录中,名为`format-date.js`的文件。 当名称不是相对的时,Node.js 将按照该名称查找已安装的包。 在本章的示例代码中,我们将把这些名称解释为 NPM 包的引用。 我们将在第 20 章详细介绍如何安装和使用 NPM 模块。 现在,我们不用编写自己的 INI 文件解析器,而是使用 NPM 中的某个: ```js const {parse} = require("ini"); console.log(parse("x = 10\ny = 20")); // → {x: "10", y: "20"} ``` ## ECMAScript 模块 CommonJS 模块很好用,并且与 NPM 一起,使 JavaScript 社区开始大规模共享代码。 但他们仍然是个简单粗暴的黑魔法。 例如,表示法有点笨拙 - 添加到`exports`的内容在局部作用域中不可用。 而且因为`require`是一个正常的函数调用,接受任何类型的参数,而不仅仅是字符串字面值,所以在不运行代码就很难确定模块的依赖关系。 这就是 2015 年的 JavaScript 标准引入了自己的不同模块系统的原因。 它通常被称为 ES 模块,其中 ES 代表 ECMAScript。 依赖和接口的主要概念保持不变,但细节不同。 首先,表示法现在已整合到该语言中。 你不用调用函数来访问依赖关系,而是使用特殊的`import`关键字。 ```js import ordinal from "ordinal"; import {days, months} from "date-names"; export function formatDate(date, format) { /* ... */ } ``` 同样,`export`关键字用于导出东西。 它可以出现在函数,类或绑定定义(`let`,`const`或`var`)的前面。 ES 模块的接口不是单个值,而是一组命名绑定。 前面的模块将`formatDate`绑定到一个函数。 从另一个模块导入时,导入绑定而不是值,这意味着导出模块可以随时更改绑定的值,导入它的模块将看到其新值。 当有一个名为`default`的绑定时,它将被视为模块的主要导出值。 如果你在示例中导入了一个类似于`ordinal`的模块,而没有绑定名称周围的大括号,则会获得其默认绑定。 除了默认绑定之外,这些模块仍然可以以不同名称导出其他绑定。 为了创建默认导出,可以在表达式,函数声明或类声明之前编写`export default`。 ```js export default ["Winter", "Spring", "Summer", "Autumn"]; ``` 可以使用单词`as`重命名导入的绑定。 ```js import {days as dayNames} from "date-names"; console.log(dayNames.length); // → 7 ``` 另一个重要的区别是,ES 模块的导入发生在模块的脚本开始运行之前。 这意味着`import`声明可能不会出现在函数或块中,并且依赖项的名称只能是带引号的字符串,而不是任意的表达式。 在撰写本文时,JavaScript 社区正在采用这种模块风格。 但这是一个缓慢的过程。 在规定格式之后,花了几年的时间,浏览器和 Node.js 才开始支持它。 虽然他们现在几乎都支持它,但这种支持仍然存在问题,这些模块如何通过 NPM 分发的讨论仍在进行中。 许多项目使用 ES 模块编写,然后在发布时自动转换为其他格式。 我们正处于并行使用两个不同模块系统的过渡时期,并且能够读写任何一种之中的代码都很有用。 ## 构建和打包 事实上,从技术上来说,许多 JavaScript 项目都不是用 JavaScript 编写的。有一些扩展被广泛使用,例如第 8 章中提到的类型检查方言。很久以前,在语言的某个计划性扩展添加到实际运行 JavaScript 的平台之前,人们就开始使用它了。 为此,他们编译他们的代码,将其从他们选择的 JavaScript 方言翻译成普通的旧式 JavaScript,甚至是过去的 JavaScript 版本,以便旧版浏览器可以运行它。 在网页中包含由 200 个不同文件组成的模块化程序,会产生它自己的问题。如果通过网络获取单个文件需要 50 毫秒,则加载整个程序需要 10 秒,或者如果可以同时加载多个文件,则可能需要一半。这浪费了很多时间。因为抓取一个大文件往往比抓取很多小文件要快,所以 Web 程序员已经开始使用工具,将它们发布到 Web 之前,将他们(费力分割成模块)的程序回滚成单个大文件。这些工具被称为打包器。 我们可以再深入一点。 除了文件的数量之外,文件的大小也决定了它们可以通过网络传输的速度。 因此,JavaScript 社区发明了压缩器。 通过自动删除注释和空白,重命名绑定以及用占用更少空间的等效代码替换代码段,这些工具使 JavaScript 程序变得更小。 因此,你在 NPM 包中找到的代码,或运行在网页上的代码,经历了多个转换阶段 - 从现代 JavaScript 转换为历史 JavaScript,从 ES 模块格式转换为 CommonJS,打包并压缩。 我们不会在本书中详细介绍这些工具,因为它们往往很无聊,并且变化很快。 请注意,你运行的 JavaScript 代码通常不是编写的代码。 ## 模块设计 使程序结构化是编程的一个微妙的方面。 任何有价值的功能都可以用各种方式建模。 良好的程序设计是主观的 - 涉及到权衡和品味问题。 了解结构良好的设计的价值的最好方法,是阅读或处理大量程序,并注意哪些是有效的,哪些不是。 不要认为一个痛苦的混乱就是“它本来的方式”。 通过多加思考,你可以改善几乎所有事物的结构。 模块设计的一个方面是易用性。 如果你正在设计一些旨在由多人使用,或者甚至是你自己的东西,在三个月之内,当你记不住你所做的细节时,如果你的接口简单且可预测,这会有所帮助。 这可能意味着遵循现有的惯例。 `ini`包是一个很好的例子。 此模块模仿标准 JSON 对象,通过提供`parse`和`stringify`(用于编写 INI 文件)函数,就像 JSON 一样,在字符串和普通对象之间进行转换。 所以接口很小且很熟悉,在你使用过一次后,你可能会记得如何使用它。 即使没有能模仿的标准函数或广泛使用的包,你也可以通过使用简单的数据结构,并执行单一的重点事项,来保持模块的可预测性。 例如,NPM 上的许多 INI 文件解析模块,提供了直接从硬盘读取文件并解析它的功能。 这使得在浏览器中不可能使用这些模块,因为我们没有文件系统的直接访问权,并且增加了复杂性,通过组合模块与某些文件读取功能,可以更好地解决它。 这指向了模块设计的另一个有用的方面 - 一些代码可以轻易与其他代码组合。比起执行带有副作用的复杂操作的更大的模块,计算值的核心模块适用于范围更广的程序。坚持从磁盘读取文件的 INI 文件读取器, 在文件内容来自其他来源的场景中是无用的。 与之相关,有状态的对象有时甚至是有用的,但是如果某件事可以用一个函数完成,就用一个函数。 NPM 上的几个 INI​​ 文件读取器提供了一种接口风格,需要你先创建一个对象,然后将该文件加载到对象中,最后使用特定方法来获取结果。这种类型的东西在面向对象的传统中很常见,而且很糟糕。你不能调用单个函数来完成,你必须执行仪式,在各种状态中移动对象。而且由于数据现在封装在一个特定的对象类型中,与它交互的所有代码都必须知道该类型,从而产生不必要的相互依赖关系。 通常,定义新的数据结构是不可避免的 - 只有少数非常基本的数据结构由语言标准提供,并且许多类型的数据一定比数组或映射更复杂。 但是当数组足够时,使用数组。 一个稍微复杂的数据结构的示例是第 7 章的图。JavaScript 中没有一种明显的表示图的方式。 在那一章中,我们使用了一个对象,其属性保存了字符串数组 - 可以从某个节点到达的其他节点。 NPM 上有几种不同的寻路包,但他们都没有使用这种图的格式。 它们通常允许图的边带有权重,它是与其相关的成本或距离,这在我们的表示中是不可能的。 例如,存在`dijkstrajs`包。 一种著名的寻路方法,与我们的`findRoute`函数非常相似,它被称为迪科斯特拉(Dijkstra)算法,以首先编写它的艾兹格尔·迪科斯特拉(Edsger Dijkstra)命名。 `js`后缀通常会添加到包名称中,以表明它们用 JavaScript 编写。 这个`dijkstrajs`包使用类似于我们的图的格式,但是它不使用数组,而是使用对象,它的属性值是数字 - 边的权重。 所以如果我们想要使用这个包,我们必须确保我们的图以它期望的格式存储。 所有边的权重都相同,因为我们的简化模型将每条道路视为具有相同的成本(一个回合)。 ```js 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 包的仓库。 你可以从上面下载各种有用的(和无用的)包。 ## 练习 ### 模块化机器人 这些是第 7 章的项目所创建的约束: ``` roads buildGraph roadGraph VillageState runRobot randomPick randomRobot mailRoute routeRobot findRoute goalOrientedRobot ``` 如果你要将该项目编写为模块化程序,你会创建哪些模块? 哪个模块依赖于哪个模块,以及它们的接口是什么样的? 哪些片段可能在 NPM 上找到? 你愿意使用 NPM 包还是自己编写? ### `roads`模块 根据第 7 章中的示例编写 CommonJS 模块,该模块包含道路数组,并将表示它们的图数据结构导出为`roadGraph`。 它应该依赖于一个模块`./graph`,它导出一个函数`buildGraph`,用于构建图。 该函数接受包含两个元素的数组(道路的起点和终点)。 ```js // Add dependencies and exports 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" ]; ``` ### 循环依赖 循环依赖是一种情况,其中模块 A 依赖于 B,并且 B 也直接或间接依赖于 A。许多模块系统完全禁止这种情况,因为无论你选择何种顺序来加载此类模块,都无法确保每个模块的依赖关系在它运行之前加载。 CommonJS 模块允许有限形式的循环依赖。 只要这些模块不会替换它们的默认`exports`对象,并且在完成加载之后才能访问对方的接口,循环依赖就没有问题。 本章前面给出的`require`函数支持这种类型的循环依赖。 你能看到它如何处理循环吗? 当一个循环中的某个模块替代其默认`exports`对象时,会出现什么问题? ================================================ FILE: 11.md ================================================ # 十一、异步编程 > 原文:[Asynchronous Programming](http://eloquentjavascript.net/11_async.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > 孰能浊以澄?静之徐清; > > 孰能安以久?动之徐生。 > > 老子,《道德经》 计算机的核心部分称为处理器,它执行构成我们程序的各个步骤。 到目前为止,我们看到的程序都是让处理器忙碌,直到他们完成工作。 处理数字的循环之类的东西,几乎完全取决于处理器的速度。 但是许多程序与处理器之外的东西交互。 例如,他们可能通过计算机网络进行通信或从硬盘请求数据 - 这比从内存获取数据要慢很多。 当发生这种事情时,让处理器处于闲置状态是可耻的 - 在此期间可以做一些其他工作。 某种程度上,它由你的操作系统处理,它将在多个正在运行的程序之间切换处理器。 但是,我们希望单个程序在等待网络请求时能做一些事情,这并没有什么帮助。 ## 异步 在同步编程模型中,一次只发生一件事。 当你调用执行长时间操作的函数时,它只会在操作完成时返回,并且可以返回结果。 这会在你执行操作的时候停止你的程序。 异步模型允许同时发生多个事件。 当你开始一个动作时,你的程序会继续运行。 当动作结束时,程序会收到通知并访问结果(例如从磁盘读取的数据)。 我们可以使用一个小例子来比较同步和异步编程:一个从网络获取两个资源然后合并结果的程序。 在同步环境中,只有在请求函数完成工作后,它才返回,执行此任务的最简单方法是逐个创建请求。 这有一个缺点,仅当第一个请求完成时,第二个请求才会启动。 所花费的总时间至少是两个响应时间的总和。 在同步系统中解决这个问题的方法是启动额外的控制线程。 线程是另一个正在运行的程序,它的执行可能会交叉在操作系统与其他程序当中 - 因为大多数现代计算机都包含多个处理器,所以多个线程甚至可能同时运行在不同的处理器上。 第二个线程可以启动第二个请求,然后两个线程等待它们的结果返回,之后它们重新同步来组合它们的结果。 在下图中,粗线表示程序正常花费运行的时间,细线表示等待网络所花费的时间。 在同步模型中,网络所花费的时间是给定控制线程的时间线的一部分。 在异步模型中,从概念上讲,启动网络操作会导致时间轴中出现分裂。 启动该动作的程序将继续运行,并且该动作将与其同时发生,并在程序结束时通知该程序。 ![](img/11-1.svg) 另一种描述差异的方式是,等待动作完成在同步模型中是隐式的,而在异步模型中,在我们的控制之下,它是显式的。 异步性是个双刃剑。 它可以生成不适合直线控制模型的程序,但它也可以使直线控制的程序更加笨拙。 本章后面我们会看到一些方法来解决这种笨拙。 两种重要的 JavaScript 编程平台(浏览器和 Node.js)都可能需要一段时间的异步操作,而不是依赖线程。 由于使用线程进行编程非常困难(理解程序在同时执行多个事情时所做的事情要困难得多),这通常被认为是一件好事。 ## 乌鸦科技 大多数人都知道乌鸦非常聪明。 他们可以使用工具,提前计划,记住事情,甚至可以互相沟通这些事情。 大多数人不知道的是,他们能够做一些事情,并且对我们隐藏得很好。我听说一个有声望的(但也有点古怪的)专家 corvids 认为,乌鸦技术并不落后于人类的技术,并且正在迎头赶上。 例如,许多乌鸦文明能够构建计算设备。 这些并不是电子的,就像人类的计算设备一样,但是它们操作微小昆虫的行动,这种昆虫是与白蚁密切相关的物种,它与乌鸦形成了共生关系。 鸟类为它们提供食物,对之对应,昆虫建立并操作复杂的殖民地,在其内部的生物的帮助下进行计算。 这些殖民地通常位于大而久远的鸟巢中。 鸟类和昆虫一起工作,建立一个球形粘土结构的网络,隐藏在巢的树枝之间,昆虫在其中生活和工作。 为了与其他设备通信,这些机器使用光信号。 鸟类在特殊的通讯茎中嵌入反光材料片段,昆虫校准这些反光材料将光线反射到另一个鸟巢,将数据编码为一系列快速闪光。 这意味着只有具有完整视觉连接的巢才能沟通。 我们的朋友 corvid 专家已经绘制了 Rhône 河畔的 Hières-sur-Amby 村的乌鸦鸟巢网络。 这张地图显示了鸟巢及其连接。 在一个令人震惊的趋同进化的例子中,乌鸦计算机运行 JavaScript。 在本章中,我们将为他们编写一些基本的网络函数。 ![](img/11-2.png) ## 回调 异步编程的一种方法是使执行慢动作的函数接受额外的参数,即回调函数。动作开始,当它结束时,使用结果调用回调函数。 例如,在 Node.js 和浏览器中都可用的`setTimeout`函数,等待给定的毫秒数(一秒为一千毫秒),然后调用一个函数。 ```js setTimeout(() => console.log("Tick"), 500); ``` 等待通常不是一种非常重要的工作,但在做一些事情时,例如更新动画或检查某件事是否花费比给定时间更长的时间,可能很有用。 使用回调在一行中执行多个异步操作,意味着你必须不断传递新函数来处理操作之后的计算延续。 大多数乌鸦鸟巢计算机都有一个长期的数据存储器,其中的信息刻在小树枝上,以便以后可以检索。雕刻或查找一段数据需要一些时间,所以长期存储的接口是异步的,并使用回调函数。 存储器按照名称存储 JSON 编码的数据片段。乌鸦可以存储它隐藏食物的地方的信息,其名称为`"food caches"`,它可以包含指向其他数据片段的名称数组,描述实际的缓存。为了在 Big Oak 鸟巢的存储器中查找食物缓存,乌鸦可以运行这样的代码: ```js import {bigOak} from "./crow-tech"; bigOak.readStorage("food caches", caches => { let firstCache = caches[0]; bigOak.readStorage(firstCache, info => { console.log(info); }); }); ``` (所有绑定名称和字符串都已从乌鸦语翻译成英语。) 这种编程风格是可行的,但缩进级别随着每个异步操作而增加,因为你最终会在另一个函数中。 做更复杂的事情,比如同时运行多个动作,会变得有点笨拙。 乌鸦鸟巢计算机为使用请求-响应对进行通信而构建。 这意味着一个鸟巢向另一个鸟巢发送消息,然后它立即返回一个消息,确认收到,并可能包括对消息中提出的问题的回复。 每条消息都标有一个类型,它决定了它的处理方式。 我们的代码可以为特定的请求类型定义处理器,并且当这样的请求到达时,调用处理器来产生响应。 `"./crow-tech"`模块所导出的接口为通信提供基于回调的函数。 鸟巢拥有`send`方法来发送请求。 它接受目标鸟巢的名称,请求的类型和请求的内容作为它的前三个参数,以及一个用于调用的函数,作为其第四个和最后一个参数,当响应到达时调用。 ```js bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM", () => console.log("Note delivered.")); ``` 但为了使鸟巢能够接收该请求,我们首先必须定义名为`"note"`的请求类型。 处理请求的代码不仅要在这台鸟巢计算机上运行,而且还要运行在所有可以接收此类消息的鸟巢上。 我们只假定一只乌鸦飞过去,并将我们的处理器代码安装在所有的鸟巢中。 ```js import {defineRequestType} from "./crow-tech"; defineRequestType("note", (nest, content, source, done) => { console.log(`${nest.name} received note: ${content}`); done(); }); ``` `defineRequestType`函数定义了一种新的请求类型。该示例添加了对`"note"`请求的支持,它只是向给定的鸟巢发送备注。我们的实现调用`console.log`,以便我们可以验证请求到达。鸟巢有`name`属性,保存他们的名字。 给`handler`的第四个参数done,是一个回调函数,它在完成请求时必须调用。如果我们使用了处理器的返回值作为响应值,那么这意味着请求处理器本身不能执行异步操作。执行异步工作的函数通常会在完成工作之前返回,安排回调函数在完成时调用。所以我们需要一些异步机制 - 在这种情况下是另一个回调函数 - 在响应可用时发出信号。 某种程度上,异步性是传染的。任何调用异步的函数的函数,本身都必须是异步的,使用回调或类似的机制来传递其结果。调用回调函数比简单地返回一个值更容易出错,所以以这种方式构建程序的较大部分并不是很好。 ## `Promise` 当这些概念可以用值表示时,处理抽象概念通常更容易。 在异步操作的情况下,你不需要安排将来某个时候调用的函数,而是返回一个代表这个未来事件的对象。 这是标准类`Promise`的用途。 `Promise`是一种异步行为,可以在某个时刻完成并产生一个值。 当值可用时,它能够通知任何感兴趣的人。 创建`Promise`的最简单方法是调用`Promise.resolve`。 这个函数确保你给它的值包含在一个`Promise`中。 如果它已经是`Promise`,那么仅仅返回它 - 否则,你会得到一个新的`Promise`,并使用你的值立即结束。 ```js let fifteen = Promise.resolve(15); fifteen.then(value => console.log(`Got ${value}`)); // → Got 15 ``` 为了获得`Promise`的结果,可以使用它的`then`方法。 它注册了一个回调函数,当`Promise`解析并产生一个值时被调用。 你可以将多个回调添加到单个`Promise`中,即使在`Promise`解析(完成)后添加它们,它们也会被调用。 但那不是`then`方法所做的一切。 它返回另一个`Promise`,它解析处理器函数返回的值,或者如果返回`Promise`,则等待该`Promise`,然后解析为结果。 将`Promise`视为一种手段,将值转化为异步现实,是有用处的。 一个正常的值就在那里。promised 的值是未来可能存在或可能出现的值。 根据`Promise`定义的计算对这些包装值起作用,并在值可用时异步执行。 为了创建`Promise`,你可以将`Promise`用作构造器。 它有一个有点奇怪的接口 - 构造器接受一个函数作为参数,它会立即调用,并传递一个函数来解析这个`Promise`。 它以这种方式工作,而不是使用`resolve`方法,这样只有创建`Promise`的代码才能解析它。 这就是为`readStorage`函数创建基于`Promise`的接口的方式。 ```js function storage(nest, name) { return new Promise(resolve => { nest.readStorage(name, result => resolve(result)); }); } storage(bigOak, "enemies") .then(value => console.log("Got", value)); ``` 这个异步函数返回一个有意义的值。 这是`Promise`的主要优点 - 它们简化了异步函数的使用。 基于`Promise`的函数不需要传递回调,而是类似于常规函数:它们将输入作为参数并返回它们的输出。 唯一的区别是输出可能还不可用。 ## 故障 > 译者注:这段如果有配套代码会更容易理解,但是没有,所以凑合看吧。 常规的 JavaScript 计算可能会因抛出异常而失败。 异步计算经常需要类似的东西。 网络请求可能会失败,或者作为异步计算的一部分的某些代码,可能会引发异常。 异步编程的回调风格中最紧迫的问题之一是,确保将故障正确地报告给回调函数,是非常困难的。 一个广泛使用的约定是,回调函数的第一个参数用于指示操作失败,第二个参数包含操作成功时生成的值。 这种回调函数必须始终检查它们是否收到异常,并确保它们引起的任何问题,包括它们调用的函数所抛出的异常,都会被捕获并提供给正确的函数。 `Promise`使这更容易。可以解决它们(操作成功完成)或拒绝(故障)。只有在操作成功时,才会调用解析处理器(使用`then`注册),并且拒绝会自动传播给由`then`返回的新`Promise`。当一个处理器抛出一个异常时,这会自动使`then`调用产生的`Promise`被拒绝。因此,如果异步操作链中的任何元素失败,则整个链的结果被标记为拒绝,并且不会调用失败位置之后的任何常规处理器。 就像`Promise`的解析提供了一个值,拒绝它也提供了一个值,通常称为拒绝的原因。当处理器中的异常导致拒绝时,异常值将用作原因。同样,当处理器返回被拒绝的`Promise`时,拒绝流入下一个`Promise`。`Promise.reject`函数会创建一个新的,立即被拒绝的`Promise`。 为了明确地处理这种拒绝,`Promise`有一个`catch`方法,用于注册一个处理器,当`Promise`被拒绝时被调用,类似于处理器处理正常解析的方式。 这也非常类似于`then`,因为它返回一个新的`Promise`,如果它正常解析,它将解析原始`Promise`的值,否则返回`catch`处理器的结果。 如果`catch`处理器抛出一个错误,新的`Promise`也被拒绝。 作为简写,`then`还接受拒绝处理器作为第二个参数,因此你可以在单个方法调用中,装配这两种的处理器。 传递给`Promise`构造器的函数接收第二个参数,并与解析函数一起使用,它可以用来拒绝新的`Promise`。 通过调用`then`和`catch`创建的`Promise`值的链条,可以看作异步值或失败沿着它移动的流水线。 由于这种链条通过注册处理器来创建,因此每个链条都有一个成功处理器或与其关联的拒绝处理器(或两者都有)。 不匹配结果类型(成功或失败)的处理器将被忽略。 但是那些匹配的对象被调用,并且它们的结果决定了下一次会出现什么样的值 -- 返回非`Promise`值时成功,当它抛出异常时拒绝,并且当它返回其中一个时是`Promise`的结果。 就像环境处理未捕获的异常一样,JavaScript 环境可以检测未处理`Promise`拒绝的时候,并将其报告为错误。 ## 网络是困难的 偶尔,乌鸦的镜像系统没有足够的光线来传输信号,或者有些东西阻挡了信号的路径。 信号可能发送了,但从未收到。 事实上,这只会导致提供给`send`的回调永远不会被调用,这可能会导致程序停止,而不会注意到问题。 如果在没有得到回应的特定时间段内,请求会超时并报告故障,那就很好。 通常情况下,传输故障是随机事故,例如汽车的前灯会干扰光信号,只需重试请求就可以使其成功。 所以,当我们处理它时,让我们的请求函数在放弃之前自动重试发送请求几次。 而且,既然我们已经确定`Promise`是一件好事,我们也会让我们的请求函数返回一个`Promise`。 对于他们可以表达的内容,回调和`Promise`是等同的。 基于回调的函数可以打包,来公开基于`Promise`的接口,反之亦然。 即使请求及其响应已成功传递,响应也可能表明失败 - 例如,如果请求尝试使用未定义的请求类型或处理器,会引发错误。 为了支持这个,`send`和`defineRequestType`遵循前面提到的惯例,其中传递给回调的第一个参数是故障原因,如果有的话,第二个参数是实际结果。 这些可以由我们的包装翻译成`Promise`的解析和拒绝。 ```js class Timeout extends Error {} function request(nest, target, type, content) { return new Promise((resolve, reject) => { let done = false; function attempt(n) { nest.send(target, type, content, (failed, value) => { done = true; if (failed) reject(failed); else resolve(value); }); setTimeout(() => { if (done) return; else if (n < 3) attempt(n + 1); else reject(new Timeout("Timed out")); }, 250); } attempt(1); }); } ``` 因为`Promise`只能解析(或拒绝)一次,所以这个是有效的。 第一次调用`resolve`或`reject`会决定`Promise`的结果,并且任何进一步的调用(例如请求结束后到达的超时,或在另一个请求结束后返回的请求)都将被忽略。 为了构建异步循环,对于重试,我们需要使用递归函数 - 常规循环不允许我们停止并等待异步操作。 `attempt`函数尝试发送请求一次。 它还设置了超时,如果 250 毫秒后没有响应返回,则开始下一次尝试,或者如果这是第四次尝试,则以`Timeout`实例为理由拒绝该`Promise`。 每四分之一秒重试一次,一秒钟后没有响应就放弃,这绝对是任意的。 甚至有可能,如果请求确实过来了,但处理器花费了更长时间,请求将被多次传递。 我们会编写我们的处理器,并记住这个问题 - 重复的消息应该是无害的。 总的来说,我们现在不会建立一个世界级的,强大的网络。 但没关系 - 在计算方面,乌鸦没有很高的预期。 为了完全隔离我们自己的回调,我们将继续,并为`defineRequestType`定义一个包装器,它允许处理器返回一个`Promise`或明确的值,并且连接到我们的回调。 ```js function requestType(name, handler) { defineRequestType(name, (nest, content, source, callback) => { try { Promise.resolve(handler(nest, content, source)) .then(response => callback(null, response), failure => callback(failure)); } catch (exception) { callback(exception); } }); } ``` 如果处理器返回的值还不是`Promise`,`Promise.resolve`用于将转换为`Promise`。 请注意,处理器的调用必须包装在`try`块中,以确保直接引发的任何异常都会被提供给回调函数。 这很好地说明了使用原始回调正确处理错误的难度 - 很容易忘记正确处理类似的异常,如果不这样做,故障将无法报告给正确的回调。`Promise`使其大部分是自动的,因此不易出错。 ## `Promise`的集合 每台鸟巢计算机在其`neighbors`属性中,都保存了传输距离内的其他鸟巢的数组。 为了检查当前哪些可以访问,你可以编写一个函数,尝试向每个鸟巢发送一个`"ping"`请求(一个简单地请求响应的请求),并查看哪些返回了。 在处理同时运行的`Promise`集合时,`Promise.all`函数可能很有用。 它返回一个`Promise`,等待数组中的所有`Promise`解析,然后解析这些`Promise`产生的值的数组(与原始数组的顺序相同)。 如果任何`Promise`被拒绝,`Promise.all`的结果本身被拒绝。 ```js requestType("ping", () => "pong"); function availableNeighbors(nest) { let requests = nest.neighbors.map(neighbor => { return request(nest, neighbor, "ping") .then(() => true, () => false); }); return Promise.all(requests).then(result => { return nest.neighbors.filter((_, i) => result[i]); }); } ``` 当一个邻居不可用时,我们不希望整个组合`Promise`失败,因为那时我们仍然不知道任何事情。 因此,在邻居集合上映射一个函数,将它们变成请求`Promise`,并附加处理器,这些处理器使成功的请求产生`true`,拒绝的产生`false`。 在组合`Promise`的处理器中,`filter`用于从`neighbors`数组中删除对应值为`false`的元素。 这利用了一个事实,`filter`将当前元素的数组索引作为其过滤函数的第二个参数(`map`,`some`和类似的高阶数组方法也一样)。 ## 网络泛洪 鸟巢仅仅可以邻居通信的事实,极大地减少了这个网络的实用性。 为了将信息广播到整个网络,一种解决方案是设置一种自动转发给邻居的请求。 然后这些邻居转发给它们的邻居,直到整个网络收到这个消息。 ```js import {everywhere} from "./crow-tech"; everywhere(nest => { nest.state.gossip = []; }); function sendGossip(nest, message, exceptFor = null) { nest.state.gossip.push(message); for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "gossip", message); } } requestType("gossip", (nest, message, source) => { if (nest.state.gossip.includes(message)) return; console.log(`${nest.name} received gossip '${ message}' from ${source}`); sendGossip(nest, message, source); }); ``` 为了避免永远在网络上发送相同的消息,每个鸟巢都保留一组已经看到的闲话字符串。 为了定义这个数组,我们使用`everywhere`函数(它在每个鸟巢上运行代码)向鸟巢的状态对象添加一个属性,这是我们将保存鸟巢局部状态的地方。 当一个鸟巢收到一个重复的闲话消息,它会忽略它。每个人都盲目重新发送这些消息时,这很可能发生。 但是当它收到一条新消息时,它会兴奋地告诉它的所有邻居,除了发送消息的那个邻居。 这将导致一条新的闲话通过网络传播,如在水中的墨水一样。 即使一些连接目前不工作,如果有一条通往指定鸟巢的替代路线,闲话将通过那里到达它。 这种网络通信方式称为泛洪 - 它用一条信息充满网络,直到所有节点都拥有它。 我们可以调用`sendGossip`看看村子里的消息流。 ```js sendGossip(bigOak, "Kids with airgun in the park"); ``` ## 消息路由 如果给定节点想要与其他单个节点通信,泛洪不是一种非常有效的方法。 特别是当网络很大时,这会导致大量无用的数据传输。 另一种方法是为消息设置节点到节点的传输方式,直到它们到达目的地。 这样做的困难在于,它需要网络布局的知识。 为了向远方的鸟巢发送请求,有必要知道哪个邻近的鸟巢更靠近其目的地。 以错误的方向发送它不会有太大好处。 由于每个鸟巢只知道它的直接邻居,因此它没有计算路线所需的信息。 我们必须以某种方式,将这些连接的信息传播给所有鸟巢。 当放弃或建造新的鸟巢时,最好是允许它随时间改变的方式。 我们可以再次使用泛洪,但不检查给定的消息是否已经收到,而是检查对于给定鸟巢来说,邻居的新集合,是否匹配我们拥有的当前集合。 ```js requestType("connections", (nest, {name, neighbors}, source) => { let connections = nest.state.connections; if (JSON.stringify(connections.get(name)) == JSON.stringify(neighbors)) return; connections.set(name, neighbors); broadcastConnections(nest, name, source); }); function broadcastConnections(nest, name, exceptFor = null) { for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "connections", { name, neighbors: nest.state.connections.get(name) }); } } everywhere(nest => { nest.state.connections = new Map; nest.state.connections.set(nest.name, nest.neighbors); broadcastConnections(nest, nest.name); }); ``` 该比较使用`JSON.stringify`,因为对象或数组上的`==`只有在两者完全相同时才返回`true`,这不是我们这里所需的。 比较 JSON 字符串是比较其内容的一种简单而有效的方式。 节点立即开始广播它们的连接,它们应该立即为每个鸟巢提供当前网络图的映射,除非有一些鸟巢完全无法到达。 你可以用图做的事情,就是找到里面的路径,就像我们在第 7 章中看到的那样。如果我们有一条通往消息目的地的路线,我们知道将它发送到哪个方向。 这个`findRoute`函数非常类似于第 7 章中的`findRoute`,它搜索到达网络中给定节点的路线。 但不是返回整个路线,而是返回下一步。 下一个鸟巢将使用它的有关网络的当前信息,来决定将消息发送到哪里。 ```js function findRoute(from, to, connections) { let work = [{at: from, via: null}]; for (let i = 0; i < work.length; i++) { let {at, via} = work[i]; for (let next of connections.get(at) || []) { if (next == to) return via; if (!work.some(w => w.at == next)) { work.push({at: next, via: via || next}); } } } return null; } ``` 现在我们可以建立一个可以发送长途信息的函数。 如果该消息被发送给直接邻居,它将照常发送。 如果不是,则将其封装在一个对象中,并使用`"route"`请求类型,将其发送到更接近目标的邻居,这将导致该邻居重复相同的行为。 ```js function routeRequest(nest, target, type, content) { if (nest.neighbors.includes(target)) { return request(nest, target, type, content); } else { let via = findRoute(nest.name, target, nest.state.connections); if (!via) throw new Error(`No route to ${target}`); return request(nest, via, "route", {target, type, content}); } } requestType("route", (nest, {target, type, content}) => { return routeRequest(nest, target, type, content); }); ``` 我们现在可以将消息发送到教堂塔楼的鸟巢中,它的距离有四跳。 ```js routeRequest(bigOak, "Church Tower", "note", "Incoming jackdaws!"); ``` 我们已经在原始通信系统的基础上构建了几层功能,来使其便于使用。 这是一个(尽管是简化的)真实计算机网络工作原理的很好的模型。 计算机网络的一个显着特点是它们不可靠 - 建立在它们之上的抽象可以提供帮助,但是不能抽象出网络故障。所以网络编程通常关于预测和处理故障。 ## `async`函数 为了存储重要信息,据了解乌鸦在鸟巢中复制它。 这样,当一只鹰摧毁一个鸟巢时,信息不会丢失。 为了检索它自己的存储器中没有的信息,鸟巢计算机可能会询问网络中其他随机鸟巢,直到找到一个鸟巢计算机。 ```js requestType("storage", (nest, name) => storage(nest, name)); function findInStorage(nest, name) { return storage(nest, name).then(found => { if (found != null) return found; else return findInRemoteStorage(nest, name); }); } function network(nest) { return Array.from(nest.state.connections.keys()); } function findInRemoteStorage(nest, name) { let sources = network(nest).filter(n => n != nest.name); function next() { if (sources.length == 0) { return Promise.reject(new Error("Not found")); } else { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); return routeRequest(nest, source, "storage", name) .then(value => value != null ? value : next(), next); } } return next(); } ``` 因为`connections `是一个`Map`,`Object.keys`不起作用。 它有一个`key`方法,但是它返回一个迭代器而不是数组。 可以使用`Array.from`函数将迭代器(或可迭代对象)转换为数组。 即使使用`Promise`,这是一些相当笨拙的代码。 多个异步操作以不清晰的方式链接在一起。 我们再次需要一个递归函数(`next`)来建模鸟巢上的遍历。 代码实际上做的事情是完全线性的 - 在开始下一个动作之前,它总是等待先前的动作完成。 在同步编程模型中,表达会更简单。 好消息是 JavaScript 允许你编写伪同步代码。 异步函数是一种隐式返回`Promise`的函数,它可以在其主体中,以看起来同步的方式等待其他`Promise`。 我们可以像这样重写`findInStorage`: ```js async function findInStorage(nest, name) { let local = await storage(nest, name); if (local != null) return local; let sources = network(nest).filter(n => n != nest.name); while (sources.length > 0) { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); try { let found = await routeRequest(nest, source, "storage", name); if (found != null) return found; } catch (_) {} } throw new Error("Not found"); } ``` 异步函数由`function`关键字之前的`async`标记。 方法也可以通过在名称前面编写`async`来做成异步的。 当调用这样的函数或方法时,它返回一个`Promise`。 只要主体返回了某些东西,这个`Promise`就解析了。 如果它抛出异常,则`Promise`被拒绝。 ```js findInStorage(bigOak, "events on 2017-12-21") .then(console.log); ``` 在异步函数内部,`await`这个词可以放在表达式的前面,等待解`Promise`被解析,然后才能继续执行函数。 这样的函数不再像常规的 JavaScript 函数一样,从头到尾运行。 相反,它可以在有任何带有`await`的地方冻结,并在稍后恢复。 对于有意义的异步代码,这种标记通常比直接使用`Promise`更方便。即使你需要做一些不适合同步模型的东西,比如同时执行多个动作,也很容易将`await`和直接使用`Promise`结合起来。 ## 生成器 函数暂停然后再次恢复的能力,不是异步函数所独有的。 JavaScript 也有一个称为生成器函数的特性。 这些都是相似的,但没有`Promise`。 当用`function*`定义一个函数(在函数后面加星号)时,它就成为一个生成器。 当你调用一个生成器时,它将返回一个迭代器,我们在第 6 章已经看到了它。 ```js 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`表达式,该表达式会暂停它,并使得产生的值成为由迭代器产生的下一个值。 当函数返回时(示例中的那个永远不会),迭代器就结束了。 使用生成器函数时,编写迭代器通常要容易得多。 可以用这个生成器编写`group`类的迭代器(来自第 6 章的练习): ```js Group.prototype[Symbol.iterator] = function*() { for (let i = 0; i < this.members.length; i++) { yield this.members[i]; } }; ``` 不再需要创建一个对象来保存迭代状态 - 生成器每次`yield`时都会自动保存其本地状态。 这样的`yield`表达式可能仅仅直接出现在生成器函数本身中,而不是在你定义的内部函数中。 生成器在返回(`yield`)时保存的状态,只是它的本地环境和它`yield`的位置。 异步函数是一种特殊的生成器。 它在调用时会产生一个`Promise`,当它返回(完成)时被解析,并在抛出异常时被拒绝。 每当它`yield`(`await`)一个`Promise`时,该`Promise`的结果(值或抛出的异常)就是`await`表达式的结果。 ## 事件循环 异步程序是逐片段执行的。 每个片段可能会启动一些操作,并调度代码在操作完成或失败时执行。 在这些片段之间,该程序处于空闲状态,等待下一个动作。 所以回调函数不会直接被调度它们的代码调用。 如果我从一个函数中调用`setTimeout`,那么在调用回调函数时该函数已经返回。 当回调返回时,控制权不会回到调度它的函数。 异步行为发生在它自己的空函数调用堆栈上。 这是没有`Promise`的情况下,在异步代码之间管理异常很难的原因之一。 由于每个回调函数都是以几乎为空的堆栈开始,因此当它们抛出一个异常时,你的`catch`处理程序不会在堆栈中。 ```js try { setTimeout(() => { throw new Error("Woosh"); }, 20); } catch (_) { // This will not run console.log("Caught!"); } ``` 无论事件发生多么紧密(例如超时或传入请求),JavaScript 环境一次只能运行一个程序。 你可以把它看作在程序周围运行一个大循环,称为事件循环。 当没有什么可以做的时候,那个循环就会停止。 但随着事件来临,它们被添加到队列中,并且它们的代码被逐个执行。 由于没有两件事同时运行,运行缓慢的代码可能会延迟其他事件的处理。 这个例子设置了一个超时,但是之后占用时间,直到超时的预定时间点,导致超时延迟。 ```js 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`,等待它会导致你的回调在当前脚本完成后运行,而不是立即执行。 ```js Promise.resolve("Done").then(console.log); console.log("Me first!"); // → Me first! // → Done ``` 在后面的章节中,我们将看到在事件循环中运行的,各种其他类型的事件。 ## 异步的 bug 当你的程序同步运行时,除了那些程序本身所做的外,没有发生任何状态变化。 对于异步程序,这是不同的 - 它们在执行期间可能会有空白,这个时候其他代码可以运行。 我们来看一个例子。 我们乌鸦的爱好之一是计算整个村庄每年孵化的雏鸡数量。 鸟巢将这一数量存储在他们的存储器中。 下面的代码尝试枚举给定年份的所有鸟巢的计数。 ```js function anyStorage(nest, source, name) { if (source == nest.name) return storage(nest, name); else return routeRequest(nest, source, "storage", name); } async function chicks(nest, year) { let list = ""; await Promise.all(network(nest).map(async name => { list += `${name}: ${ await anyStorage(nest, name, `chicks in ${year}`) }\n`; })); return list; } ``` `async name =>`部分展示了,通过将单词`async`放在它们前面,也可以使箭头函数变成异步的。 代码不会立即看上去有问题......它将异步箭头函数映射到鸟巢集合上,创建一组`Promise`,然后使用`Promise.all`,在返回它们构建的列表之前等待所有`Promise`。 但它有严重问题。 它总是只返回一行输出,列出响应最慢的鸟巢。 ```js chicks(bigOak, 2017).then(console.log); ``` 你能解释为什么吗? 问题在于`+=`操作符,它在语句开始执行时接受`list`的当前值,然后当`await`结束时,将`list`绑定设为该值加上新增的字符串。 但是在语句开始执行的时间和它完成的时间之间存在一个异步间隔。 `map`表达式在任何内容添加到列表之前运行,因此每个`+ =`操作符都以一个空字符串开始,并在存储检索完成时结束,将`list`设置为单行列表 - 向空字符串添加那行的结果。 通过从映射的`Promise`中返回行,并对`Promise.all`的结果调用`join`,可以轻松避免这种情况,而不是通过更改绑定来构建列表。 像往常一样,计算新值比改变现有值的错误更少。 ```js async function chicks(nest, year) { let lines = network(nest).map(async name => { return name + ": " + await anyStorage(nest, name, `chicks in ${year}`); }); return (await Promise.all(lines)).join("\n"); } ``` 像这样的错误很容易做出来,特别是在使用`await`时,你应该知道代码中的间隔在哪里出现。 JavaScript 的显式异步性(无论是通过回调,`Promise`还是`await`)的一个优点是,发现这些间隔相对容易。 ## 总结 异步编程可以表示等待长时间运行的动作,而不需要在这些动作期间冻结程序。 JavaScript 环境通常使用回调函数来实现这种编程风格,这些函数在动作完成时被调用。 事件循环调度这样的回调,使其在适当的时候依次被调用,以便它们的执行不会重叠。 `Promise`和异步函数使异步编程更容易。`Promise`是一个对象,代表将来可能完成的操作。并且,异步函数使你可以像编写同步程序一样编写异步程序。 ## 练习 ### 跟踪手术刀 村里的乌鸦拥有一把老式的手术刀,他们偶尔会用于特殊的任务 - 比如说,切开纱门或包装。 为了能够快速追踪到手术刀,每次将手术刀移动到另一个鸟巢时,将一个条目添加到拥有它和拿走它的鸟巢的存储器中,名称为`"scalpel"`,值为新的位置。 这意味着找到手术刀就是跟踪存储器条目的痕迹,直到你发现一个鸟巢指向它本身。 编写一个异步函数`locateScalpel`,它从它运行的鸟巢开始。 你可以使用之前定义的`anyStorage`函数,来访问任意鸟巢中的存储器。 手术刀已经移动了很长时间,你可能会认为每个鸟巢的数据存储器中都有一个`"scalpel"`条目。 接下来,再次写入相同的函数,而不使用`async`和`await`。 在两个版本中,请求故障是否正确显示为拒绝? 如何实现? ```js async function locateScalpel(nest) { // Your code here. } function locateScalpel2(nest) { // Your code here. } locateScalpel(bigOak).then(console.log); // → Butcher Shop ``` ### 构建`Promise.all` 给定`Promise`的数组,`Promise.all`返回一个`Promise`,等待数组中的所有`Promise`完成。 然后它成功,产生结果值的数组。 如果数组中的一个`Promise`失败,这个`Promise`也失败,故障原因来自那个失败的`Promise`。 自己实现一个名为`Promise_all`的常规函数。 请记住,在`Promise`成功或失败后,它不能再次成功或失败,并且解析它的函数的进一步调用将被忽略。 这可以简化你处理`Promise`的故障的方式。 ```js function Promise_all(promises) { return new Promise((resolve, reject) => { // Your code here. }); } // Test code. Promise_all([]).then(array => { console.log("This should be []:", array); }); function soon(val) { return new Promise(resolve => { setTimeout(() => resolve(val), Math.random() * 500); }); } Promise_all([soon(1), soon(2), soon(3)]).then(array => { console.log("This should be [1, 2, 3]:", array); }); Promise_all([soon(1), Promise.reject("X"), soon(3)]) .then(array => { console.log("We should not get here"); }) .catch(error => { if (error != "X") { console.log("Unexpected failure:", error); } }); ``` ================================================ FILE: 12.md ================================================ ## 十二、项目:编程语言 > 原文:[Project: A Programming Language](https://eloquentjavascript.net/12_language.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > 确定编程语言中的表达式含义的求值器只是另一个程序。 > > Hal Abelson 和 Gerald Sussman,《计算机程序的构造和解释》 ![](img/12-0.jpg) 构建你自己的编程语言不仅简单(只要你的要求不要太高就好),而且对人富有启发。 希望通过本章的介绍,你能发现构建自己的编程语言其实并不是什么难事。我经常感到某些人的想法聪明无比,而且十分复杂,以至于我都不能完全理解。不过经过一段时间的阅读和实验,我就发现它们其实也并没有想象中那么复杂。 我们将创造一门名为 Egg 的编程语言。这是一门小巧而简单的语言,但是足够强大到能描述你所能想到的任何计算。它允许基于函数的简单抽象。 ## 解析 程序设计语言中最直观的部分就是语法(syntax)或符号。解析器是一种程序,负责读入文本片段(包含程序的文本),并产生一系列与程序结构对应的数据结构。若文本不是一个合法程序,解析器应该指出错误。 我们的语言语法简单,而且具有一致性。Egg 中一切都是表达式。表达式可以是绑定名称、数字,或应用(application)。不仅函数调用属于应用,而且`if`和`while`之类的语言构造也属于应用。 为了确保解析器的简单性,Egg 中的字符串不支持反斜杠转义符之类的元素。字符串只是简单的字符序列(不包括双引号),并使用双引号包围起来。数值是数字序列。绑定名由任何非空白字符组成,并且在语法中不具有特殊含义。 应用的书写方式与 JavaScript 中一样,也是在一个表达式后添加一对括号,括号中可以包含任意数量的参数,参数之间使用逗号分隔。 ```egg do(define(x, 10), if(>(x, 5), print("large"), print("small"))) ``` Egg 语言的一致性体现在:JavaScript 中的所有运算符(比如`>`)在 Egg 中都是绑定,但是可以像其他函数一样调用。由于语法中没有语句块的概念,因此我们需要使用`do`结构来表示多个表达式的序列。 解析器的数据结构用于描述由表达式对象组成的程序,每个对象都包含一个表示表达式类型的`type`属性,除此以外还有其他描述对象内容的属性。 类型为`"value"`的表达式表示字符串和数字。它们的`value`属性包含对应的字符串和数字值。类型为`"word"`的表达式用于标识符(名称)。这类对象以字符串形式将标识符名称保存在`name`属性中。最后,类型为`"apply"`的表达式表示应用。该类型的对象有一个`operator`属性,指向其操作的表达式,还有一个`args`属性,持有参数表达式的数组。 上面代码中`> (x, 5)`这部分可以表达成如下形式: ```js { type: "apply", operator: {type: "word", name: ">"}, args: [ {type: "word", name: "x"}, {type: "value", value: 5} ] } ``` 我们将这样一个数据结构称为表达式树。如果你将对象想象成点,将对象之间的连接想象成点之间的线,这个数据结构将会变成树形。表达式中还会包含其他表达式,被包含的表达式接着又会包含更多表达式,这类似于树的分支重复分裂的方式。 ![](img/12-1.svg) 我们将这个解析器与我们第 9 章中编写的配置文件格式解析器进行对比,第 9 章中的解析器结构很简单:将输入文件划分成行,并逐行处理。而且每一行只有几种简单的语法形式。 我们必须使用不同方法来解决这里的问题。Egg 中并没有表达式按行分隔,而且表达式之间还有递归结构。应用表达式包含其他表达式。 所幸我们可以使用递归的方式编写一个解析器函数,并优雅地解决该问题,这反映了语言自身就是递归的。 我们定义了一个函数`parseExpression`,该函数接受一个字符串,并返回一个对象,包含了字符串起始位置处的表达式与解析表达式后剩余的字符串。当解析子表达式时(比如应用的参数),可以再次调用该函数,返回参数表达式和剩余字符串。剩余的字符串可以包含更多参数,也有可以是一个表示参数列表结束的右括号。 这里给出部分解析器代码。 ```js 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`作为异常构造器,这是另一种标准错误类型,因为它更具体 - 它也是在尝试运行无效的 JavaScript 程序时,抛出的错误类型。 接下来,我们从程序字符串中删去匹配的部分,将剩余的字符串和表达式对象一起传递给`parseApply`函数。该函数检查表达式是否是一个应用,如果是应用则解析带括号的参数列表。 ```js 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 所需的全部代码。我们使用`parse`函数来包装`parseExpression`,在解析完表达式之后验证输入是否到达结尾(一个 Egg 程序是一个表达式),遇到输入结尾后会返回整个程序对应的数据结构。 ```js 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}]} ``` 程序可以正常工作了!当表达式解析失败时,解析函数不会输出任何有用的信息,也不会存储出错的行号与列号,而这些信息都有助于之后的错误报告。但考虑到我们的目的,这门语言目前已经足够优秀了。 ## 求值器(evaluator) 在有了一个程序的语法树之后,我们该做什么呢?当然是执行程序了!而这就是求值器的功能。我们将语法树和作用域对象传递给求值器,执行器就会求解语法树中的表达式,然后返回整个过程的结果。 ```js 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 specialForms[operator.name](expr.args, scope); } 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`时,我们再回过头来看这个问题。 `evaluate`的递归结构类似于解析器的结构。两者都反映了语言自身的结构。我们也可以将解析器和求值器集成到一起,在解析的同时求解表达式,但将其分离为两个阶段使得程序更易于理解。 这就是解释 Egg 所需的全部代码。这段代码非常简单,但如果不定义一些特殊的格式,或向环境中添加一些有用的值,你无法使用该语言完成很多工作。 ## 特殊形式 `specialForms`对象用于定义 Egg 中的特殊语法。该对象将单词和求解这种形式的函数关联起来。目前该对象为空,现在让我们添加`if`。 ```js 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`语句需要三个参数。Egg 会求解第一个参数,若结果不是`false`,则求解第二个参数,否则求解第三个参数。相较于 JavaScript 中的`if`语句,Egg 的`if`形式更类似于 JavaScript 中的`?:`运算符。这是一条表达式,而非语句,它会产生一个值,即第二个或第三个参数的结果。 Egg 和 JavaScript 在处理条件值时也有些差异。Egg 不会将 0 或空字符串作为假,只有当值确实为`false`时,测试结果才为假。 我们之所以需要将`if`表达为特殊形式,而非普通函数,是因为函数的所有参数需要在函数调用前求值完毕,而`if`则只应该根据第一个参数的值,确定求解第二个还是第三个参数。`while`的形式也是类似的。 ```js 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`,会自顶向下执行其所有参数。整个`do`表达式的值是最后一个参数的值。 ```js specialForms.do = (args, scope) => { let value = false; for (let arg of args) { value = evaluate(arg, scope); } return value; }; ``` 我们还需要创建名为`define`的形式,来创建绑定对绑定赋值。`define`的第一个参数是一个单词,第二个参数是一个会产生值的表达式,并将第二个参数的计算结果赋值给第一个参数。由于`define`也是个表达式,因此必须返回一个值。我们则规定`define`应该将我们赋予绑定的值返回(就像 JavaScript 中的`=`运算符一样)。 ```js 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`两个名称与其值绑定即可。 ```js const topScope = Object.create(null); topScope.true = true; topScope.false = false; ``` 我们现在可以求解一个简单的表达式来对布尔值求反。 ```js let prog = parse(`if(true, false, true)`); console.log(evaluate(prog, topScope)); // → false ``` 为了提供基本的算术和比较运算符,我们也添加一些函数值到作用域中。为了确保代码短小,我们在循环中使用` Function`来合成一批运算符,而不是分别定义所有运算符。 ```js for (let op of ["+", "-", "*", "/", "==", "<", ">"]) { topScope[op] = Function("a, b", `return a ${op} b;`); } ``` 输出也是一个实用的功能,因此我们将`console.log`包装在一个函数中,并称之为`print`。 ```js topScope.print = value => { console.log(value); return value; }; ``` 这样一来我们就有足够的基本工具来编写简单的程序了。下面的函数提供了一个便利的方式来编写并运行程序。它创建一个新的环境对象,并解析执行我们赋予它的单个程序。 ```js function run(program) { return evaluate(parse(program), Object.create(topScope)); } ``` 我们将使用对象原型链来表示嵌套的作用域,以便程序可以在不改变顶级作用域的情况下,向其局部作用域添加绑定。 ```js 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 行代码的程序来说已经很不错了。 ## 函数 每个功能强大的编程语言都应该具有函数这个特性。 幸运的是我们可以很容易地添加一个`fun`语言构造,`fun`将最后一个参数当作函数体,将之前的所有名称用作函数参数。 ```js 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() { if (arguments.length != params.length) { throw new TypeError("Wrong number of arguments"); } let localScope = Object.create(scope); for (let i = 0; i < arguments.length; i++) { localScope[params[i]] = arguments[i]; } return evaluate(body, localScope); }; }; ``` Egg 中的函数可以获得它们自己的局部作用域。 `fun`形式产生的函数创建这个局部作用域,并将参数绑定添加到它。 然后求解此范围内的函数体并返回结果。 ```js 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 编写一个可供选择的求值策略,首先使用`Function`,调用 JavaScript 编译器编译代码,将 Egg 程序转换成 JavaScript 程序,接着执行编译结果。若能正确实现该功能,可以使得 Egg 运行的非常快,而且实现这种编译器确实非常简单。 如果读者对该话题感兴趣,愿意花费一些时间在这上面,建议你尝试实现一个编译器作为练习。 ## 站在别人的肩膀上 我们定义`if`和`while`的时候,你可能注意到他们封装得或多或少和 JavaScript 自身的`if`、`while`有点像。同样的,Egg 中的值也就是 JavaScript 中的值。 如果读者比较一下两种 Egg 的实现方式,一种是基于 JavaScrip t之上,另一种是直接使用机器提供的功能构建程序设计语言,会发现第二种方案需要大量工作才能完成,而且非常复杂。不管怎么说,本章的内容就是想让读者对编程语言的运行方式有一个基本的了解。 当需要完成一些任务时,相比于自己完成所有工作,借助于别人提供的功能是一种更高效的方式。虽然在本章中我们编写的语言就像玩具一样,十分简单,而且无论在什么情况下这门语言都无法与 JavaScript 相提并论。但在某些应用场景中,编写一门微型语言可以帮助我们更好地完成工作。 这些语言不需要像传统的程序设计语言。例如,若 JavaScript 没有正则表达式,你可以为正则表达式编写自己的解析器和求值器。 或者想象一下你在构建一个巨大的机械恐龙,需要编程实现恐龙的行为。JavaScript 可能不是实现该功能的最高效方式,你可以选择一种语言作为替代,如下所示: ```egg behavior walk perform when destination ahead actions move left-foot move right-foot behavior attack perform when Godzilla in-view actions fire laser-eyes launch arm-rockets ``` 这通常被称为领域特定语言(Domain-specific Language),一种为表达极为有限的知识领域而量身定制的语言。它可以准确描述其领域中需要表达的事物,而没有多余元素。这种语言比通用语言更具表现力。 ## 习题 ### 数组 在 Egg 中支持数组需要将以下三个函数添加到顶级作用域:`array(...values)`用于构造一个包含参数值的数组,`length(array)`用于获取数组长度,`element(array, n)`用于获取数组中的第`n`个元素。 ```js // Modify these definitions... topScope.array = "..."; topScope.length = "..."; topScope.element = "..."; run(` do(define(sum, fun(array, do(define(i, 0), define(sum, 0), while(<(i, length(array)), do(define(sum, +(sum, element(array, i))), define(i, +(i, 1)))), sum))), print(sum(array(1, 2, 3)))) `); // → 6 ``` ### 闭包 我们定义`fun`的方式允许函数引用其周围环境,就像 JavaScript 函数一样,函数体可以使用在定义该函数时可以访问的所有局部绑定。 下面的程序展示了该特性:函数f返回一个函数,该函数将其参数和f的参数相加,这意味着为了使用绑定a,该函数需要能够访问f中的局部作用域。 ```js run(` do(define(f, fun(a, fun(b, +(a, b)))), print(f(4)(5))) `); // → 9 ``` 回顾一下fun形式的定义,解释一下该机制的工作原理。 ### 注释 如果我们可以在 Egg 中编写注释就太好了。例如,无论何时,只要出现了井号(`#`),我们都将该行剩余部分当成注释,并忽略之,就类似于 JavaScript 中的`//`。 解析器并不需要为支持该特性进行大幅修改。我们只需要修改`skipSpace`来像跳过空白符号一样跳过注释即可,此时调用`skipSpace`时不仅会跳过空白符号,还会跳过注释。修改代码,实现这样的功能。 ```js // This is the old skipSpace. Modify it... function skipSpace(string) { let first = string.search(/\S/); if (first == -1) return ""; return string.slice(first); } console.log(parse("# hello\nx")); // → {type: "word", name: "x"} console.log(parse("a # one\n # two\n()")); // → {type: "apply", // operator: {type: "word", name: "a"}, // args: []} ``` ### 修复作用域 目前绑定赋值的唯一方法是`define`。该语言构造可以同时实现定义绑定和将一个新的值赋予已存在的绑定。 这种歧义性引发了一个问题。当你尝试为一个非局部绑定赋予新值时,你最后会定义一个局部绑定并替换掉原来的同名绑定。一些语言的工作方式正和这种设计一样,但是我总是认为这是一种笨拙的作用域处理方式。 添加一个类似于`define`的特殊形式`set`,该语句会赋予一个绑定新值,若绑定不存在于内部作用域,则更新其外部作用域相应绑定的值。若绑定没有定义,则抛出`ReferenceError`(另一个标准错误类型)。 我们目前采取的技术是使用简单的对象来表示作用域对象,处理目前的任务非常方便,此时我们需要更进一步。你可以使用`Object.getPrototypeOf`函数来获取对象原型。同时也要记住,我们的作用域对象并未继承`Object.prototype`,因此若想调用`hasOwnProperty`,需要使用下面这个略显复杂的表达式。 ```js Object.prototype.hasOwnProperty.call(scope, name); ``` ```js specialForms.set = (args, scope) => { // Your code here. }; run(` do(define(x, 4), define(setx, fun(val, set(x, val))), setx(50), print(x)) `); // → 50 run(`set(quux, true)`); // → Some kind of ReferenceError ``` ================================================ FILE: 13.md ================================================ ## 十三、浏览器中的 JavaScript > 原文:[JavaScript and the Browser](https://eloquentjavascript.net/13_browser.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > Web 背后的梦想是公共信息空间,其中我们通过共享信息进行交流。 其普遍性至关重要:超文本链接可指向任何东西,无论是个人的,本地的还是全球的,无论是草稿还是高度润色的。 > > Douglas Crockford,《JavaScript 编程语言》(视频讲座) ![](img/13-0.jpg) 本书接下来的章节将会介绍 Web 浏览器。可以说,没有浏览器,就没有 JavaScript。就算有,估计也不会有多少人去关心这门编程语言。 Web 技术自出现伊始,其演变方式和技术上就是以分散的方式发展的。许多浏览器厂商专门为其开发新的功能,有时这些新功能被大众采纳,有时这些功能被其他功能所代替,最终形成了一套标准。 这种发展模式是把双刃剑。一方面,不会有一个集中式的组织来管理技术的演进,取而代之的是一个包含多方利益集团的松散协作架构(偶尔会出现对立)。另一方面,互联网这种无计划的发展方式所开发出来的系统,其内部很难实现一致性。事实上,它的一些部分令人疑惑,并且毫无设计。 ## 网络和 Internet 计算机网络出现在 20 世纪 50 年代。如果在两台或多台计算机之间铺设电缆,那么你可以通过这些电缆互相收发数据,并实现一些神奇的功能。 如果通过连接同一个建筑中的两台机器就可以实现一些神奇的功能,那么如果可以连接全世界的机器,就可以完成更伟大的工作了。20 世纪 80 年代,人们开发了相关技术来实现这个愿景,我们将其产生的网络称为 Internet。而 Internet 的表现名副其实。 计算机可以使用这种网络向其他计算机发送位数据。为了在传输位数据的基础上,实现计算机之间的有效通信,网络两端的机器必须知道这些位所表达的实际含义。对于给定的位序列,其含义完全取决于位序列描述的信息类型与使用的编码机制。 网络协议描述了一种网络通信方式。网络协议非常多,其中包括邮件发送、邮件收取和邮件共享,甚至连病毒软件感染控制计算机都有相应的协议。 例如,HTTP(超文本传输协议,Hypertext Transfer Protocol)是用于检索命名资源(信息块,如网页或图片)的协议。 它指定发出请求的一方应该以这样的一行开始,命名资源和它正在尝试使用的协议的版本。 ``` GET /index.html HTTP/1.1 ``` 有很多规则,关于请求者在请求中包含更多信息的方式,以及另一方返回资源并打包其内容的方式。 我们将在第 18 章中更详细地观察 HTTP。 大多数协议都建立在其他协议之上。 HTTP 将网络视为一种流式设备,您可以将位放入这些设备,并使其按正确的顺序到达正确的目的地。 我们在第 11 章]中看到,确保这些事情已经是一个相当困难的问题。 TCP(传输控制协议,Transmission Control Protocol)就可以帮助我们解决该问题。所有连接到互联网的设备都会使用到这种协议,而多数互联网通信都构建在这种协议之上。 TCP 连接的工作方式是一台电脑必须等待或者监听,而另一台电脑则开始与之通信。一台机器为了同时监听不同类型的通信信息,会为每个监听器分配一个与之关联的数字(我们称之为端口)。大多数协议都指定了默认使用的端口。例如,当我们向使用 SMTP 协议发送一封邮件时,我们需要通过一台机器来发送邮件,而发送邮件的机器需要监听端口 25。 随后另一台机器连接到使用了正确端口号的目标机器上。如果可以连接到目标机器,而且目标机器在监听对应端口,则说明连接创建成功。负责监听的计算机名为服务器,而连接服务器的计算机名为客户端。 我们可以将该连接看成双向管道,位可以在其中流动,也就是说两端的机器都可以向连接中写入数据。当成功传输完这些位数据后,双方都可以读取另一端传来的数据。TCP 是一个非常便利的模型。我们可以说TCP就是一种网络的抽象。 ## Web 万维网(World Wide Web,不要将其与 Internet 混淆)是包含一系列协议和格式的集合,允许我们通过浏览器访问网页。词组中的 Web 指的是这些页面可以轻松地链接其他网页,因此最后可以连接成一张巨大的网,用户可以在网络中浏览。 你只需将一台计算机连接到 Internet 并使用 HTTP 监听 80 端口,就可以成为 Web 的一部分。其他计算机可以通过网络,并使用 HTTP 协议获取其他计算机上的文件。 网络中的每个文件都能通过 URL(统一资源定位符,Universal Resource Locator)访问,如下所示: ``` http://eloquentjavascript.net/13_browser.html | | | | protocol server path ``` 该地址的第一部分告诉我们 URL 使用的是 HTTP 协议(加密的 HTTP 连接则使用`https://`来表示)。第二部分指的是获取文件的服务器地址。第三部分是我们想要获取的具体文件(或资源)的路径。 连接到互联网的机器获得一个 IP 地址,该地址是一个数字,可用于将消息发送到该机器的,类似于`"149.210.142.219"`或`"2001:4860:4860::8888"`。 但是或多或少的随机数字列表很难记住,而且输入起来很笨拙,所以你可以为一个特定的地址或一组地址注册一个域名。 我注册了`eloquentjavascript.net`,来指向我控制的机器的 IP 地址,因此可以使用该域名来提供网页。 如果你在浏览器地址栏中输入上面提到的 URL,浏览器会尝试获取并显示该 URL 对应的文档。首先,你的浏览器需要找出域名`eloquentjavascript.net`指向的地址。然后使用 HTTP 协议,连接到该地址处的服务器,并请求`/13_browser.html`这个资源。如果一切顺利,服务器会发回一个文档,然后您的浏览器将显示在屏幕上。 ## HTML HTML,即超文本标记语言(Hypertext Markup Language),是在网页中得到广泛使用的文档格式。HTML 文档不仅包含文本,还包含了标签,用于说明文本结构,描述了诸如链接、段落、标题之类的元素。 一个简短的 HTML 文档如下所示: ```html My home page

My home page

Hello, I am Marijn and this is my home page.

I also wrote a book! Read it here.

``` 标签包裹在尖括号之间(`<`和`>`,小于和大于号),提供关于文档结构的信息。其他文本则是纯文本。 文档以``开头,告诉浏览器将页面解释为现代 HTML,以别于过去使用的各种方言。 HTML 文档有头部(head)和主体(body)。头部包含了文档信息,而主体则包含文档自身。在本例中,头部将文档标题声明为`"My home page"`,并使用 UTF-8 编码,它是将 Unicode 文本编码为二进制的方式。文档的主体包含标题(`

`,表示一级标题,`

`到`

`可以产生不同等级的子标题)和两个段落(`

`)。 标签有几种形式。一个元素,比如主体、段落或链接以一个起始标签(比如`

`)开始,并以一个闭合标签(比如`

`)结束。一些起始标签,比如一个链接(``),会包含一些额外信息,其形式是`name="value"`这种键值对,我们称之为属性。在本例中,使用属性`href="http://eloquentjavascript.net"`指定链接的目标,其中`href`表示“超文本链接(Hypertext Reference)”。 某些类型的标签不会包含任何元素,这种标签不需要闭合。元数据标签``就是一个例子。 > 译者注:最好还是这样闭合它们:``。 尽管 HTML 中尖括号有特殊含义,但为了在文档的文本中包含这些符号,可以引入另外一种形式的特殊标记方法。普通文本中的起始尖括号写成`<`(less than),而闭合尖括号写成`>`(greater than)。在 HTML 中,我们将一个`&`字符后跟着一个单词和分号(`;`)这种写法称为一个实体,浏览器会使用实体编码对应的字符替换它们。 与之类似的是 JavaScript 字符串中反斜杠的使用。由于 HTML 中的实体机制赋予了`&`特殊含义,因此我们需要使用`&`来表示一个`&`字符。在属性的值(包在双引号中)中使用`"`可以插入实际的引号字符。 HTML 的解析过程容错性非常强。当应有的标签丢失时,浏览器会重新构建这些标签。标签的重新构建已经标准化,你可以认为所有现代浏览器的行为都是一致的。 下面的文件与之前版本显示效果相同: ```html My home page

My home page

Hello, I am Marijn and this is my home page.

I also wrote a book! Read it here. ``` ``、``和``标签可以完全丢弃。浏览器知道``和``属于头部,而`<h1>`属于主体。此外,我再也不用明确关闭某个段落,因为新段落开始或文档结束时,浏览器会隐式关闭段落标签。目标链接两边的引号也可以丢弃。 本书的示例通常都会省略`<html>`、`<head>`和`<body>`标签,以保持源代码简短,避免太过杂乱。但我会明确关闭所有标签并在属性两旁包含引号。 本书也会经常忽略`doctype`和`charset`声明。这并不是鼓励大家省略它们。当你忘记它们时,浏览器往往会做出荒谬的事情。 您应该认为`doctype`和`charset`元数据隐式出现在示例中,即使它们没有实际显示在文本中。 ## HTML 和 JavaScript 对于本书来说,最重要的一个 HTML 标签是`<script>`。该标签允许我们在文档中包含一段 JavaScript 代码。 ```html <h1>Testing alert</h1> <script>alert("hello!");</script> ``` 当浏览器在读取 HTML 时,一旦遇到`<script>`标签就会执行该代码。这个页面在打开时会弹出一个对话框 - `alert`函数类似`prompt`,因为它弹出一个小窗口,但只显示一条消息而不请求输入。 在 HTML 文档中包含大程序是不切实际的。`<script>`标签可以指定一个`src`属性,从一个 URL 获取脚本文件(包含 JavaScript 程序的文本文件)。 ```html <h1>Testing alert</h1> <script src="code/hello.js"></script> ``` 这里包含的文件`code/hello.js`是和上文中相同的一段程序,`alert("hello")`。当一个页面将其他 URL 引用为自身的一部分时(比如图像文件或脚本),网页浏览器将会立即获取这些资源并将其包含在页面中。 即使`script`标签引用了一个文本文件,且并未包含任何代码,你也必须使用`</script>`来闭合标签。如果你忘记了这点,浏览器会将剩余的页面会作为脚本的一部分进行解析。 你可以在浏览器中加载ES模块(参见第 10 章),向脚本标签提供`type ="module"`属性。 这些模块可以依赖于其他模块,通过将相对于自己的 URL 用作`import`声明中的模块名称。 某些属性也可以包含 JavaScript 程序。下面展示的`<button>`标签(显示一个按钮)有一个`onclick`属性。该属性的值将在点击按钮时运行。 ```html <button onclick="alert('Boom!');">DO NOT PRESS</button> ``` 需要注意的是,我们在`onclick`属性的字符串中使用了单引号,这是因为我们在使用了双引号来引用整个属性。我们也可以使用`"`。 ## 沙箱 直接执行从因特网中下载的程序存在潜在危险。你不了解大多数的网页开发者,他们不一定都心怀善意。一旦运行某些不怀好意的人提供的程序,你的电脑可能会感染病毒,这些程序还会窃取数据会并盗走账号。 但网络的吸引力就在于你可以浏览网站,而不必要信任所有网站。这就是为什么浏览器严重限制了 JavaScript 程序的能力—— JavaScript 无法查看电脑中的任何文件,也无法修改与其所在页面无关的数据。 我们将这种隔离程序运行环境的技术称为沙箱。以该思想编写的程序在沙箱中运行,不会对计算机造成任何伤害。但是你应该想象,这种特殊的沙箱上面有一个厚钢筋笼子,所以在其中运行的程序实际上不会出去。 实现沙箱的难点是:一方面我们要给予程序一定的自由使得程序能有实际用处,但又要限制程序,防止其执行危险的行为。许多实用功能(比如与服务器通信或从剪贴板读取内容)也会存在问题,有些侵入者可以利用这些功能来侵入你的计算机。 时不时会有一些人想到新方法,突破浏览器的限制,并对你的机器造成伤害,从窃取少量的私人信息到掌握执行浏览器的整个机器。浏览器开发者的对策是修补漏洞,然后一切都恢复正常。直到下一次问题被发现并广为传播之前,某些政府或秘密组织可以私下利用这些漏洞。 ## 兼容性与浏览器之争 在 Web 技术发展的早期,一款名为 Mosaic 的浏览器统治了整个市场。几年之后,这种平衡被 Netscape 公司打破,随后又被微软的 Internet Explorer 排挤出市场。无论什么时候,当一款浏览器统治了整个市场,浏览器供应商就会觉得他们有权利单方面为网络研发新的特性。由于大多数人都使用相同的浏览器,因此网站会开始使用这些独有特性,也就不再考虑其他浏览器的兼容性问题了。 这是兼容性的黑暗时代,我们通常称之为浏览器之争。网络开发者总是为缺乏统一的 Web 标准,而需要去考虑两到三种互不兼容的平台而感到烦恼。让事情变得更糟糕的是 2003 年左右使用的浏览器充满了漏洞,当然不同浏览器的漏洞都不一样。网页编写者的生活颇为艰辛。 Mozilla Firefox,作为 Netscape 浏览器的非盈利性分支,在20世纪初末期开始挑战 Internet Explorer 的霸主地位。因为当时微软并未特别关心与其竞争,导致 Firefox 迅速占领了很大的市场份额。与此同时,Google 发布了它的 Chrome 浏览器,而 Apple 的 Safari 也得到普及,导致现在成为四个主要选手的竞争,而非一家独大。 新的参与者对标准有着更认真的态度,和更好的工程实践,为我们减少了不兼容性和错误。 微软看到其市场份额极速下降,在其 Edge 浏览器中采取了这些态度,取代了 Internet Explorer。 如果您今天开始学习网络开发,请认为自己是幸运的。 主流浏览器的最新版本行为非常一致,并且错误相对较少。 这并不是说就没有问题了。某些使用网络的人,出于惰性或公司政策,被迫使用旧版本的浏览器。直到这些浏览器完全退出市场之前,为旧版本浏览器编写网站仍需要掌握很多不常见的特性,了解旧浏览器的缺陷和特殊之处。本书不会讨论这些特殊的特性,而着眼于介绍现代且健全的网络程序设计风格。 ================================================ FILE: 14.md ================================================ ## 十四、文档对象模型 > 原文:[The Document Object Model](https://eloquentjavascript.net/14_dom.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > Too bad! Same old story! Once you've finished building your house you notice you've accidentally learned something that you really should have known—before you started. > > Friedrich Nietzsche,《Beyond Good and Evil》 ![](img/14-0.jpg) 当你在浏览器中打开网页时,浏览器会接收网页的 HTML 文本并进行解析,其解析方式与第 11 章中介绍的解析器非常相似。浏览器构建文档结构的模型,并使用该模型在屏幕上绘制页面。 JavaScript 在其沙箱中提供了将文本转换成文档对象模型的功能。它是你可以读取或者修改的数据结构。模型是一个所见即所得的数据结构,改变模型会使得屏幕上的页面产生相应变化。 ## 文档结构 你可以将 HTML 文件想象成一系列嵌套的箱子。诸如`<body>`和`</body>`之类的标签会将其他标签包围起来,而包含在内部的标签也可以包含其他的标签和文本。这里给出上一章中已经介绍过的示例文件。 ```html <!doctype html> <html> <head> <title>My home page

My home page

Hello, I am Marijn and this is my home page.

I also wrote a book! Read it here.

``` 该页面结构如下所示。 ![](img/14-1.svg) 浏览器使用与该形状对应的数据结构来表示文档。每个盒子都是一个对象,我们可以和这些对象交互,找出其中包含的盒子与文本。我们将这种表示方式称为文档对象模型(Document Object Model),或简称 DOM。 我们可以通过全局绑定`document`来访问这些对象。该对象的`documentElement`属性引用了``标签对象。由于每个 HTML 文档都有一个头部和一个主体,它还具有`head`和`body`属性,指向这些元素。 ## 树 回想一下第 12 章中提到的语法树。其结构与浏览器文档的结构极为相似。每个节点使用`children`引用其他节点,而每个子节点又有各自的`children`。其形状是一种典型的嵌套结构,每个元素可以包含与其自身相似的子元素。 如果一个数据结构有分支结构,而且没有任何环路(一个节点不能直接或间接包含自身),并且有一个单一、定义明确的“根节点”,那么我们将这种数据结构称之为树。就 DOM 来讲,`document.documentElement`就是其根节点。 在计算机科学中,树的应用极为广泛。除了表现诸如 HTML 文档或程序之类的递归结构,树还可以用于维持数据的有序集合,因为在树中寻找或插入一个节点往往比在数组中更高效。 一棵典型的树有不同类型的节点。Egg 语言的语法树有标识符、值和应用节点。应用节点常常包含子节点,而标识符、值则是叶子节点,也就是没有子节点的节点。 DOM中也是一样。元素(表示 HTML 标签)的节点用于确定文档结构。这些节点可以包含子节点。这类节点中的一个例子是`document.body`。其中一些子节点可以是叶子节点,比如文本片段或注释。 每个 DOM 节点对象都包含`nodeType`属性,该属性包含一个标识节点类型的代码(数字)。元素的值为 1,DOM 也将该值定义成一个常量属性`document.ELEMENT_NODE`。文本节点(表示文档中的一段文本)代码为 3(`document.TEXT_NODE`)。注释的代码为 8(`document.COMMENT_NODE`)。 因此我们可以使用另一种方法来表示文档树: ![](img/14-2.svg) 叶子节点是文本节点,而箭头则指出了节点之间的父子关系。 ## 标准 并非只有 JavaScript 会使用数字代码来表示节点类型。本章随后将会展示其他的 DOM 接口,你可能会觉得这些接口有些奇怪。这是因为 DOM 并不是为 JavaScript 而设计的,它尝试成为一组语言中立的接口,确保也可用于其他系统中,不只是 HTML,还有 XML。XML 是一种通用数据格式,语法与 HTML 相近。 这就比较糟糕了。一般情况下标准都是非常易于使用的。但在这里其优势(跨语言的一致性)并不明显。相较于为不同语言提供类似的接口,如果能够将接口与开发者使用的语言进行适当集成,可以为开发者节省大量时间。 我们举例来说明一下集成问题。比如 DOM 中每个元素都有`childNodes`属性。该属性是一个类数组对象,有`length`属性,也可以使用数字标签访问对应的子节点。但该属性是`NodeList`类型的实例,而不是真正的数组,因此该类型没有诸如`slice`和`map`之类的方法。 有些问题是由不好的设计导致的。例如,我们无法在创建新的节点的同时立即为其添加子节点和属性。相反,你首先需要创建节点,然后使用副作用,将子节点和属性逐个添加到节点中。大量使用 DOM 的代码通常较长、重复和丑陋。 但这些问题并非无法改善。因为 JavaScript 允许我们构建自己的抽象,可以设计改进方式来表达你正在执行的操作。 许多用于浏览器编程的库都附带这些工具。 ## 沿着树移动 DOM 节点包含了许多指向相邻节点的链接。下面的图表展示了这一点。 ![](img/14-3.svg) 尽管图表中每种类型的节点只显示出一条链接,但每个节点都有`parentNode`属性,指向一个节点,它是这个节点的一部分。类似的,每个元素节点(节点类型为 1)均包含`childNodes`属性,该属性指向一个类数组对象,用于保存其子节点。 理论上,你可以通过父子之间的链接移动到树中的任何地方。但 JavaScript 也提供了一些更加方便的额外链接。`firstChild`属性和`lastChild`属性分别指向第一个子节点和最后一个子节点,若没有子节点则值为`null`。类似的,`previousSibling`和`nextSibling`指向相邻节点,分别指向拥有相同父亲的前一个节点和后一个节点。对于第一个子节点,`previousSibling`是`null`,而最后一个子节点的`nextSibling`则是`null`。 也存在`children`属性,它就像`childNodes`,但只包含元素(类型为 1)子节点,而不包含其他类型的子节点。 当你对文本节点不感兴趣时,这可能很有用。 处理像这样的嵌套数据结构时,递归函数通常很有用。 以下函数在文档中扫描包含给定字符串的文本节点,并在找到一个时返回`true`: ```html function talksAbout(node, string) { if (node.nodeType == document.ELEMENT_NODE) { for (let i = 0; i < node.childNodes.length; i++) { if (talksAbout(node.childNodes[i], string)) { return true; } } return false; } else if (node.nodeType == document.TEXT_NODE) { return node.nodeValue.indexOf(string) > -1; } } console.log(talksAbout(document.body, "book")); // → true ``` 因为`childNodes`不是真正的数组,所以我们不能用`for/of`来遍历它,并且必须使用普通的`for`循环遍历索引范围。 文本节点的`nodeValue`属性保存它所表示的文本字符串。 ## 查找元素 使用父节点、子节点和兄弟节点之间的连接遍历节点确实非常实用。但是如果我们只想查找文档中的特定节点,那么从`document.body`开始盲目沿着硬编码的链接路径查找节点并非良策。如果程序通过树结构定位节点,就需要依赖于文档的具体结构,而文档结构随后可能发生变化。另一个复杂的因素是 DOM 会为不同节点之间的空白字符创建对应的文本节点。例如示例文档中的`body`标签不止包含 3 个子节点(`

`和两个`

`元素),其实包含 7 个子节点:这三个节点、三个节点前后的空格、以及元素之间的空格。 因此,如果你想获取文档中某个链接的`href`属性,最好不要去获取文档`body`元素中第六个子节点的第二个子节点,而最好直接获取文档中的第一个链接,而且这样的操作确实可以实现。 ```html let link = document.body.getElementsByTagName("a")[0]; console.log(link.href); ``` 所有元素节点都包含`getElementsByTagName`方法,用于从所有后代节点中(直接或间接子节点)搜索包含给定标签名的节点,并返回一个类数组的对象。 你也可以使用`document.getElementById`来寻找包含特定`id`属性的某个节点。 ```html

My ostrich Gertrude:

``` 第三个类似的方法是`getElementsByClassName`,它与`getElementsByTagName`类似,会搜索元素节点的内容并获取所有包含特定`class`属性的元素。 ## 修改文档 几乎所有 DOM 数据结构中的元素都可以被修改。文档树的形状可以通过改变父子关系来修改。 节点的`remove`方法将它们从当前父节点中移除。`appendChild`方法可以添加子节点,并将其放置在子节点列表末尾,而`insertBefore`则将第一个参数表示的节点插入到第二个参数表示的节点前面。 ```html

One

Two

Three

``` 每个节点只能存在于文档中的某一个位置。因此,如果将段落`Three`插入到段落`One`前,会将该节点从文档末尾移除并插入到文档前面,最后结果为`Three/One/Two`。所有将节点插入到某处的方法都有这种副作用——会将其从当前位置移除(如果存在的话)。 `replaceChild`方法用于将一个子节点替换为另一个子节点。该方法接受两个参数,第一个参数是新节点,第二个参数是待替换的节点。待替换的节点必须是该方法调用者的子节点。这里需要注意,`replaceChild`和`insertBefore`都将新节点作为第一个参数。 ## 创建节点 假设我们要编写一个脚本,将文档中的所有图像(``标签)替换为其`alt`属性中的文本,该文本指定了图像的文字替代表示。 这不仅涉及删除图像,还涉及添加新的文本节点,并替换原有图像节点。为此我们使用`document.createTextNode`方法。 ```html

The Cat in the Hat.

``` 给定一个字符串,`createTextNode`为我们提供了一个文本节点,我们可以将它插入到文档中,来使其显示在屏幕上。 该循环从列表末尾开始遍历图像。我们必须这样反向遍历列表,因为`getElementsByTagName`之类的方法返回的节点列表是动态变化的。该列表会随着文档改变还改变。若我们从列表头开始遍历,移除掉第一个图像会导致列表丢失其第一个元素,第二次循环时,因为集合的长度此时为 1,而`i`也为 1,所以循环会停止。 如果你想要获得一个固定的节点集合,可以使用数组的`Array.from`方法将其转换成实际数组。 ```html 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`工具,用于创建一个新的元素节点,并将其剩余参数当作该节点的子节点。接着使用该函数为引用添加来源信息。 ```html
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.
``` ## 属性 我们可以通过元素的 DOM 对象的同名属性去访问元素的某些属性,比如链接的`href`属性。这仅限于最常用的标准属性。 HTML 允许你在节点上设定任何属性。这一特性非常有用,因为这样你就可以在文档中存储额外信息。你自己创建的属性不会出现在元素节点的属性中。你必须使用`getAttribute`和`setAttribute`方法来访问这些属性。 ```html

The launch code is 00000000.

I have two feet.

``` 建议为这些组合属性的名称添加`data-`前缀,来确保它们不与任何其他属性发生冲突。 这里有一个常用的属性:`class`。该属性是 JavaScript 中的保留字。因为某些历史原因(某些旧版本的 JavaScript 实现无法处理和关键字或保留字同名的属性),访问`class`的属性名为`className`。你也可以使用`getAttribute`和`setAttribute`方法,使用其实际名称`class`来访问该属性。 ## 布局 你可能已经注意到不同类型的元素有不同的布局。某些元素,比如段落(`

`)和标题(`

`)会占据整个文档的宽度,并且在独立的一行中渲染。这些元素被称为块(Block)元素。其他的元素,比如链接(``或``元素则与周围文本在同一行中渲染。这类元素我们称之为内联(Inline)元素。 对于任意特定文档,浏览器可以根据每个元素的类型和内容计算其尺寸与位置等布局信息。接着使用布局来绘制文档。 JavaScript 中可以访问元素的尺寸与位置。 属性`offsetWidth`和`offsetHeight`给出元素的起始位置(单位是像素)。像素是浏览器中的基本测量单元。它通常对应于屏幕可以绘制的最小的点,但是在现代显示器上,可以绘制非常小的点,这可能不再适用了,并且浏览器像素可能跨越多个显示点。 同样,`clientWidth`和`clientHeight`向你提供元素内的空间大小,忽略边框宽度。 ```html

I'm boxed in

``` `getBoundingClientRect`方法是获取屏幕中某个元素精确位置的最有效方法。该方法返回一个对象,包含`top`、`bottom`、`left`和`right`四个属性,表示元素相对于屏幕左上角的位置(单位是像素)。若你想要知道其相对于整个文档的位置,必须加上其滚动位置,你可以在`pageXOffset`和`pageYOffset`绑定中找到。 我们还需要花些力气才能完成文档的排版工作。为了加快速度,每次你改变它时,浏览器引擎不会立即重新绘制整个文档,而是尽可能等待并推迟重绘操作。当一个修改文档的 JavaScript 程序结束时,浏览器会计算新的布局,并在屏幕上显示修改过的文档。若程序通过读取`offsetHeight`和`getBoundingClientRect`这类属性获取某些元素的位置或尺寸时,为了提供正确的信息,浏览器也需要计算布局。 如果程序反复读取 DOM 布局信息或修改 DOM,会强制引发大量布局计算,导致运行非常缓慢。下面的代码展示了一个示例。该示例包含两个不同的程序,使用`X`字符构建一条线,其长度是 2000 像素,并计算每个任务的时间。 ```html

``` ## 样式 我们看到了不同的 HTML 元素的绘制是不同的。一些元素显示为块,一些则是以内联方式显示。我们还可以添加一些样式,比如使用``加粗内容,或使用`
`使内容变成蓝色,并添加下划线。 ``标签显示图片的方式或点击标签``时跳转的链接都和元素类型紧密相关。但元素的默认样式,比如文本的颜色、是否有下划线,都是可以改变的。这里给出使用`style`属性的示例。 ```html

Normal link

Green link

``` 样式属性可以包含一个或多个声明,格式为属性(比如`color`)后跟着一个冒号和一个值(比如`green`)。当包含更多声明时,不同属性之间必须使用分号分隔,比如`color:red;border:none`。 文档的很多方面会受到样式的影响。例如,`display`属性控制一个元素是否显示为块元素或内联元素。 ```html This text is displayed inline, as a block, and not at all. ``` `block`标签会结束其所在的那一行,因为块元素是不会和周围文本内联显示的。最后一个标签完全不会显示出来,因为`display:none`会阻止一个元素呈现在屏幕上。这是隐藏元素的一种方式。更好的方式是将其从文档中完全移除,因为稍后将其放回去是一件很简单的事情。 JavaScript 代码可以通过元素的`style`属性操作元素的样式。该属性保存了一个对象,对象中存储了所有可能的样式属性,这些属性的值是字符串,我们可以把字符串写入属性,修改某些方面的元素样式。 ```html

Nice text

``` 一些样式属性名包含破折号,比如`font-family`。由于这些属性的命名不适合在 JavaScript 中使用(你必须写成`style["font-family"]`),因此在 JavaScript 中,样式对象中的属性名都移除了破折号,并将破折号之后的字母大写(`style.fontFamily`)。 ## 层叠样式 我们把 HTML 的样式化系统称为 CSS,即层叠样式表(Cascading Style Sheets)。样式表是一系列规则,指出如何为文档中元素添加样式。可以在`

Now strong text is italic and gray.

``` 所谓层叠指的是将多条规则组合起来产生元素的最终样式。在示例中,``标签的默认样式`font-weight:bold`,会被` ``` ================================================ FILE: 15.md ================================================ ## 十五、处理事件 > 原文:[Handling Events](https://eloquentjavascript.net/15_event.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > > 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) > 你对你的大脑拥有控制权,而不是外部事件。认识到这一点,你就找到了力量。 > > 马可·奥勒留,《沉思录》 ![](img/15-0.jpg) 有些程序处理用户的直接输入,比如鼠标和键盘动作。这种输入方式不是组织整齐的数据结构 - 它是一次一个地,实时地出现的,并且期望程序在发生时作出响应。 ## 事件处理器 想象一下,有一个接口,若想知道键盘上是否有一个键是否被按下,唯一的方法是读取那个按键的当前状态。为了能够响应按键动作,你需要不断读取键盘状态,以在按键被释放之前捕捉到按下状态。这种方法在执行时间密集计算时非常危险,因为你可能错过按键事件。 一些原始机器可以像那样处理输入。有一种更进一步的方法,硬件或操作系统发现按键时间并将其放入队列中。程序可以周期性地检查队列,等待新事件并在发现事件时进行响应。 当然,程序必须记得监视队列,并经常做这种事,因为任何时候,按键被按下和程序发现事件之间都会使得软件反应迟钝。该方法被称为轮询。大多数程序员更希望避免这种方法。 一个更好的机制是,系统在发生事件时主动通知我们的代码。浏览器实现了这种特性,支持我们将函数注册为特定事件的处理器。 ```html

Click this document to activate the handler.

``` `window`绑定指向浏览器提供的内置对象。 它代表包含文档的浏览器窗口。 调用它的`addEventListener`方法注册第二个参数,以便在第一个参数描述的事件发生时调用它。 ## 事件与 DOM 节点 每个浏览器事件处理器被注册在上下文中。在为整个窗口注册处理器之前,我们在`window`对象上调用了`addEventListener`。 这种方法也可以在 DOM 元素和一些其他类型的对象上找到。 仅当事件发生在其注册对象的上下文中时,才调用事件监听器。 ```html

No handler here.

``` 示例代码中将处理器附加到按钮节点上。因此,点击按钮时会触发并执行处理器,而点击文档的其他部分则没有反应。 向节点提供`onclick`属性也有类似效果。这适用于大多数类型的事件 - 您可以为属性附加处理器,属性名称为前面带有`on`的事件名称。 但是一个节点只能有一个`onclick`属性,所以你只能用这种方式为每个节点注册一个处理器。 `addEventListener`方法允许您添加任意数量的处理器,因此即使元素上已经存在另一个处理器,添加处理器也是安全的。 `removeEventListener`方法将删除一个处理器,使用类似于`addEventListener`的参数调用。 ```html ``` 赋予`removeEventListener`的函数必须是赋予`addEventListener`的完全相同的函数值。 因此,要注销一个处理其,您需要为该函数提供一个名称(在本例中为`once`),以便能够将相同的函数值传递给这两个方法。 ## 事件对象 虽然目前为止我们忽略了它,事件处理器函数作为对象传递:事件(Event)对象。这个对象持有事件的额外信息。例如,如果我们想知道哪个鼠标按键被按下,我们可以查看事件对象的which属性。 ```html ``` 存储在各种类型事件对象中的信息是有差别的。随后本章将会讨论许多类型的事件。对象的`type`属性一般持有一个字符串,表示事件(例如`"click"`和`"mousedown"`)。 ## 传播 对于大多数事件类型,在具有子节点的节点上注册的处理器,也将接收发生在子节点中的事件。若点击一个段落中的按钮,段落的事件处理器也会收到点击事件。 但若段落和按钮都有事件处理器,则先执行最特殊的事件处理器(按钮的事件处理器)。也就是说事件向外传播,从触发事件的节点到其父节点,最后直到文档根节点。最后,当某个特定节点上注册的所有事件处理器按其顺序全部执行完毕后,窗口对象的事件处理器才有机会响应事件。 事件处理器任何时候都可以调用事件对象的`stopPropagation`方法,阻止事件进一步传播。该方法有时很实用,例如,你将一个按钮放在另一个可点击元素中,但你不希望点击该按钮会激活外部元素的点击行为。 下面的示例代码将`mousedown`处理器注册到按钮和其外部的段落节点上。在按钮上点击鼠标右键,按钮的处理器会调用`stopPropagation`,调度段落上的事件处理器执行。当点击鼠标其他键时,两个处理器都会执行。 ```html

A paragraph with a .

``` 大多数事件对象都有`target`属性,指的是事件来源节点。你可以根据该属性防止无意中处理了传播自其他节点的事件。 我们也可以使用`target`属性来创建出特定类型事件的处理网络。例如,如果一个节点中包含了很长的按钮列表,比较方便的处理方式是在外部节点上注册一个点击事件处理器,并根据事件的`target`属性来区分用户按下了哪个按钮,而不是为每个按钮都注册独立的事件处理器。 ```html ``` ## 默认动作 大多数事件都有与其关联的默认动作。若点击链接,就会跳转到链接目标。若点击向下的箭头,浏览器会向下翻页。若右击鼠标,可以得到一个上下文菜单等。 对于大多数类型的事件,JavaScript 事件处理器会在默认行为发生之前调用。若事件处理器不希望执行默认行为(通常是因为已经处理了该事件),会调用`preventDefault`事件对象的方法。 你可以实现你自己的键盘快捷键或交互式菜单。你也可以干扰用户期望的行为。例如,这里实现一个无法跳转的链接。 ```html MDN ``` 除非你有非常充足的理由,否则不要这样做。当预期的行为被打破时,使用你的页面的人会感到不快。 在有些浏览器中,你完全无法拦截某些事件。比如在 Chrome 中,关闭键盘快捷键(`CTRL-W`或`COMMAND-W`)无法由 JavaScript 处理。 ## 按键事件 当按下键盘上的按键时,浏览器会触发`"keydown"`事件。当松开按键时,会触发`"keyup"`事件。 ```html

This page turns violet when you hold the V key.

``` 尽管从`keydown`这个事件名上看应该是物理按键按下时触发,但当持续按下某个按键时,会循环触发该事件。有时,你想谨慎对待它。例如,如果您在按下某个按键时向 DOM 添加按钮,并且在释放按键时再次将其删除,则可能会在按住某个按键的时间过长时,意外添加数百个按钮。 该示例查看了事件对象的`key`属性,来查看事件关于哪个键。 该属性包含一个字符串,对于大多数键,它对应于按下该键时将键入的内容。 对于像`Enter`这样的特殊键,它包含一个用于命名键的字符串(在本例中为`"Enter"`)。 如果你按住一个键的同时按住`Shift`键,这也可能影响键的名称 - `"v"`变为`"V"`,`"1"`可能变成`"!"`,这是按下`Shift-1`键 在键盘上产生的东西。 诸如`shift`、`ctrl`、`alt`和`meta`(Mac 上的`command`)之类的修饰按键会像普通按键一样产生事件。但在查找组合键时,你也可以查看键盘和鼠标事件的`shiftKey`、`ctrlKey`、`altKey`和`metaKey`属性来判断这些键是否被按下。 ```html

Press Ctrl-Space to continue.

``` 按键事件发生的 DOM 节点取决于按下按键时具有焦点的元素。 大多数节点不能拥有焦点,除非你给他们一个`tabindex`属性,但像链接,按钮和表单字段可以。 我们将在第 18 章中回顾表单字段。 当没有特别的焦点时,`document.body`充当按键事件的目标节点。 当用户键入文本时,使用按键事件来确定正在键入的内容是有问题的。 某些平台,尤其是 Android 手机上的虚拟键盘,不会触发按键事件。 但即使你有一个老式键盘,某些类型的文本输入也不能直接匹配按键,例如其脚本不适合键盘的人所使用的 IME(“输入法编辑器”)软件 ,其中组合多个热键来创建字符。 要注意什么时候输入了内容,每当用户更改其内容时,可以键入的元素(例如``和` ``` 将`undefined`传递给`clearTimeout`或在一个已结束的定时器上调用`clearTimeout`是没有效果的。因此,我们不需要关心何时调用该方法,只需要每个事件中都这样做即可。 如果我们想要保证每次响应之间至少间隔一段时间,但不希望每次事件发生时都重置定时器,而是在一连串事件连续发生时能够定时触发响应,那么我们可以使用一个略有区别的方法来解决问题。例如,我们想要响应`"mousemove"`事件来显示当前鼠标坐标,但频率只有 250ms。 ```html ``` ## 本章小结 事件处理器可以检测并响应发生在我们的 Web 页面上的事件。`addEventListener`方法用于注册处理器。 每个事件都有标识事件的类型(`keydown`、`focus`等)。大多数方法都会在特定 DOM 元素上调用,接着向其父节点传播,允许每个父元素的处理器都能处理这些事件。 JavaScript 调用事件处理器时,会传递一个包含事件额外信息的事件对象。该对象也有方法支持停止进一步传播(`stopPropagation`),也支持阻止浏览器执行事件的默认处理器(`preventDefault`)。 按下键盘按键时会触发`keydown`和`keyup`事件。按下鼠标按钮时,会触发`mousedown`、`mouseup`和`click`事件。移动鼠标会触发`mousemove`事件。触摸屏交互会导致`"touchstart"`,`"touchmove"`和`"touchend"`事件。 我们可以通过`scroll`事件监测滚动行为,可以通过`focus`和`blur`事件监控焦点改变。当文档完成加载后,会触发窗口的`load`事件。 ## 习题 ### 气球 编写一个显示气球的页面(使用气球 emoji,`\ud83c\udf88`)。 当你按下上箭头时,它应该变大(膨胀)10%,而当你按下下箭头时,它应该缩小(放气)10%。 您可以通过在其父元素上设置`font-size` CSS 属性(`style.fontSize`)来控制文本大小(emoji 是文本)。 请记住在该值中包含一个单位,例如像素(`10px`)。 箭头键的键名是`"ArrowUp"`和`"ArrowDown"`。确保按键只更改气球,而不滚动页面。 实现了之后,添加一个功能,如果你将气球吹过一定的尺寸,它就会爆炸。 在这种情况下,爆炸意味着将其替换为“爆炸 emoji,`\ud83d\udca5`”,并且移除事件处理器(以便您不能使爆炸变大变小)。 ```html

💥

``` ### 鼠标轨迹 在 JavaScript 早期,有许多主页都会在页面上使用大量的动画,人们想出了许多该语言的创造性用法。 其中一种是“鼠标踪迹”,也就是一系列的元素,随着你在页面上移动鼠标,它会跟着你的鼠标指针。 在本习题中实现鼠标轨迹的功能。使用绝对定位、固定尺寸的`
`元素,背景为黑色(请参考鼠标点击一节中的示例)。创建一系列此类元素,当鼠标移动时,伴随鼠标指针显示它们。 有许多方案可以实现我们所需的功能。你可以根据你的需要实现简单的或复杂的方法。简单的解决方案是保存固定鼠标的轨迹元素并循环使用它们,每次`mousemove`事件触发时将下一个元素移动到鼠标当前位置。 ```html ``` ### 选项卡 选项卡面板广泛用于用户界面。它支持用户通过选择元素上方的很多突出的选项卡来选择一个面板。 本习题中,你必须实现一个简单的选项卡界面。编写`asTabs`函数,接受一个 DOM 节点并创建选项卡界面来展现该节点的子元素。该函数应该在顶层节点中插入大量`