master 097ef4522075 cached
45 files
1.4 MB
427.0k tokens
1 requests
Download .txt
Showing preview only (1,760K chars total). Download the full file or copy to clipboard to get everything.
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 解释器中执行习题代码。这样一来,你就可以马上获知代码工作情况的反馈,而且我希望读者去做更多的试验,而不仅仅局限于习题的要求。

可以在 <http://eloquentjavascript.net/> 中查阅本书的在线版本,并运行和实验本书中的代码。也可以在在线版本中点击任何代码示例来编辑、运行并查看其产生的输出。在做习题时,你可以访问 <http://eloquentjavascript.net/code/>,该网址会提供每个习题的初始代码,让你专心于解答习题。

如果想要在本书提供的沙箱以外执行本书代码,需要稍加注意。许多的示例是独立的,而且可以在任何 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
<!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 文档有头部(head)和主体(body)。头部包含了文档信息,而主体则包含文档自身。在本例中,头部将文档标题声明为`"My home page"`,并使用 UTF-8 编码,它是将 Unicode  文本编码为二进制的方式。文档的主体包含标题(`<h1>`,表示一级标题,`<h2>`到`<h6>`可以产生不同等级的子标题)和两个段落(`<p>`)。

标签有几种形式。一个元素,比如主体、段落或链接以一个起始标签(比如`<p>`)开始,并以一个闭合标签(比如`</p>`)结束。一些起始标签,比如一个链接(`<a>`),会包含一些额外信息,其形式是`name="value"`这种键值对,我们称之为属性。在本例中,使用属性`href="http://eloquentjavascript.net"`指定链接的目标,其中`href`表示“超文本链接(Hypertext Reference)”。

某些类型的标签不会包含任何元素,这种标签不需要闭合。元数据标签`<meta charset="utf-8">`就是一个例子。

> 译者注:最好还是这样闭合它们:`<meta charset="utf-8" />`。

尽管 HTML 中尖括号有特殊含义,但为了在文档的文本中包含这些符号,可以引入另外一种形式的特殊标记方法。普通文本中的起始尖括号写成`&lt;`(less than),而闭合尖括号写成`&gt;`(greater than)。在 HTML 中,我们将一个`&`字符后跟着一个单词和分号(`;`)这种写法称为一个实体,浏览器会使用实体编码对应的字符替换它们。

与之类似的是 JavaScript 字符串中反斜杠的使用。由于 HTML 中的实体机制赋予了`&`特殊含义,因此我们需要使用`&amp;`来表示一个`&`字符。在属性的值(包在双引号中)中使用`&quot;`可以插入实际的引号字符。

HTML 的解析过程容错性非常强。当应有的标签丢失时,浏览器会重新构建这些标签。标签的重新构建已经标准化,你可以认为所有现代浏览器的行为都是一致的。

下面的文件与之前版本显示效果相同:

```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`声明。这并不是鼓励大家省略它们。当你忘记它们时,浏览器往往会做出荒谬的事情。 您应该认为`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`属性的字符串中使用了单引号,这是因为我们在使用了双引号来引用整个属性。我们也可以使用`&quot;`。

## 沙箱

直接执行从因特网中下载的程序存在潜在危险。你不了解大多数的网页开发者,他们不一定都心怀善意。一旦运行某些不怀好意的人提供的程序,你的电脑可能会感染病毒,这些程序还会窃取数据会并盗走账号。

但网络的吸引力就在于你可以浏览网站,而不必要信任所有网站。这就是为什么浏览器严重限制了 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</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>
```

该页面结构如下所示。

![](img/14-1.svg)

浏览器使用与该形状对应的数据结构来表示文档。每个盒子都是一个对象,我们可以和这些对象交互,找出其中包含的盒子与文本。我们将这种表示方式称为文档对象模型(Document Object Model),或简称 DOM。

我们可以通过全局绑定`document`来访问这些对象。该对象的`documentElement`属性引用了`<html>`标签对象。由于每个 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 个子节点(`<h1>`和两个`<p>`元素),其实包含 7 个子节点:这三个节点、三个节点前后的空格、以及元素之间的空格。

因此,如果你想获取文档中某个链接的`href`属性,最好不要去获取文档`body`元素中第六个子节点的第二个子节点,而最好直接获取文档中的第一个链接,而且这样的操作确实可以实现。

```html
let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);
```

所有元素节点都包含`getElementsByTagName`方法,用于从所有后代节点中(直接或间接子节点)搜索包含给定标签名的节点,并返回一个类数组的对象。

你也可以使用`document.getElementById`来寻找包含特定`id`属性的某个节点。

```html
<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`类似,会搜索元素节点的内容并获取所有包含特定`class`属性的元素。

## 修改文档

几乎所有 DOM 数据结构中的元素都可以被修改。文档树的形状可以通过改变父子关系来修改。 节点的`remove`方法将它们从当前父节点中移除。`appendChild`方法可以添加子节点,并将其放置在子节点列表末尾,而`insertBefore`则将第一个参数表示的节点插入到第二个参数表示的节点前面。

```html
<p>One</p>
<p>Two</p>
<p>Three</p>

<script>
  let paragraphs = document.body.getElementsByTagName("p");
  document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>
```

每个节点只能存在于文档中的某一个位置。因此,如果将段落`Three`插入到段落`One`前,会将该节点从文档末尾移除并插入到文档前面,最后结果为`Three/One/Two`。所有将节点插入到某处的方法都有这种副作用——会将其从当前位置移除(如果存在的话)。

`replaceChild`方法用于将一个子节点替换为另一个子节点。该方法接受两个参数,第一个参数是新节点,第二个参数是待替换的节点。待替换的节点必须是该方法调用者的子节点。这里需要注意,`replaceChild`和`insertBefore`都将新节点作为第一个参数。

## 创建节点

假设我们要编写一个脚本,将文档中的所有图像(`<img>`标签)替换为其`alt`属性中的文本,该文本指定了图像的文字替代表示。

这不仅涉及删除图像,还涉及添加新的文本节点,并替换原有图像节点。为此我们使用`document.createTextNode`方法。

```html
<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`之类的方法返回的节点列表是动态变化的。该列表会随着文档改变还改变。若我们从列表头开始遍历,移除掉第一个图像会导致列表丢失其第一个元素,第二次循环时,因为集合的长度此时为 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
<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 editon of ",
        elt("em", "The Open Society and Its Enemies"),
        ", 1950"));
</script>
```

## 属性

我们可以通过元素的 DOM 对象的同名属性去访问元素的某些属性,比如链接的`href`属性。这仅限于最常用的标准属性。

HTML 允许你在节点上设定任何属性。这一特性非常有用,因为这样你就可以在文档中存储额外信息。你自己创建的属性不会出现在元素节点的属性中。你必须使用`getAttribute`和`setAttribute`方法来访问这些属性。

```html
<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 实现无法处理和关键字或保留字同名的属性),访问`class`的属性名为`className`。你也可以使用`getAttribute`和`setAttribute`方法,使用其实际名称`class`来访问该属性。

## 布局

你可能已经注意到不同类型的元素有不同的布局。某些元素,比如段落(`<p>`)和标题(`<h1>`)会占据整个文档的宽度,并且在独立的一行中渲染。这些元素被称为块(Block)元素。其他的元素,比如链接(`<a>`或`<strong>`元素则与周围文本在同一行中渲染。这类元素我们称之为内联(Inline)元素。

对于任意特定文档,浏览器可以根据每个元素的类型和内容计算其尺寸与位置等布局信息。接着使用布局来绘制文档。

JavaScript 中可以访问元素的尺寸与位置。

属性`offsetWidth`和`offsetHeight`给出元素的起始位置(单位是像素)。像素是浏览器中的基本测量单元。它通常对应于屏幕可以绘制的最小的点,但是在现代显示器上,可以绘制非常小的点,这可能不再适用了,并且浏览器像素可能跨越多个显示点。

同样,`clientWidth`和`clientHeight`向你提供元素内的空间大小,忽略边框宽度。

```html
<p style="border: 3px solid red">
  I'm boxed in
</p>

<script>
  let para = document.body.getElementsByTagName("p")[0];
  console.log("clientHeight:", para.clientHeight);
  console.log("offsetHeight:", para.offsetHeight);
</script>
```

`getBoundingClientRect`方法是获取屏幕中某个元素精确位置的最有效方法。该方法返回一个对象,包含`top`、`bottom`、`left`和`right`四个属性,表示元素相对于屏幕左上角的位置(单位是像素)。若你想要知道其相对于整个文档的位置,必须加上其滚动位置,你可以在`pageXOffset`和`pageYOffset`绑定中找到。

我们还需要花些力气才能完成文档的排版工作。为了加快速度,每次你改变它时,浏览器引擎不会立即重新绘制整个文档,而是尽可能等待并推迟重绘操作。当一个修改文档的 JavaScript 程序结束时,浏览器会计算新的布局,并在屏幕上显示修改过的文档。若程序通过读取`offsetHeight`和`getBoundingClientRect`这类属性获取某些元素的位置或尺寸时,为了提供正确的信息,浏览器也需要计算布局。

如果程序反复读取 DOM 布局信息或修改 DOM,会强制引发大量布局计算,导致运行非常缓慢。下面的代码展示了一个示例。该示例包含两个不同的程序,使用`X`字符构建一条线,其长度是 2000 像素,并计算每个任务的时间。

```html
<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>`时跳转的链接都和元素类型紧密相关。但元素的默认样式,比如文本的颜色、是否有下划线,都是可以改变的。这里给出使用`style`属性的示例。

```html
<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>
```

样式属性可以包含一个或多个声明,格式为属性(比如`color`)后跟着一个冒号和一个值(比如`green`)。当包含更多声明时,不同属性之间必须使用分号分隔,比如`color:red;border:none`。

文档的很多方面会受到样式的影响。例如,`display`属性控制一个元素是否显示为块元素或内联元素。

```html
This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.
```

`block`标签会结束其所在的那一行,因为块元素是不会和周围文本内联显示的。最后一个标签完全不会显示出来,因为`display:none`会阻止一个元素呈现在屏幕上。这是隐藏元素的一种方式。更好的方式是将其从文档中完全移除,因为稍后将其放回去是一件很简单的事情。

JavaScript 代码可以通过元素的`style`属性操作元素的样式。该属性保存了一个对象,对象中存储了所有可能的样式属性,这些属性的值是字符串,我们可以把字符串写入属性,修改某些方面的元素样式。

```html
<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"]`),因此在 JavaScript 中,样式对象中的属性名都移除了破折号,并将破折号之后的字母大写(`style.fontFamily`)。

## 层叠样式

我们把 HTML 的样式化系统称为 CSS,即层叠样式表(Cascading Style Sheets)。样式表是一系列规则,指出如何为文档中元素添加样式。可以在`<style>`标签中写入 CSS。

```html
<style>
  strong {
    font-style: italic;
    color: gray;
  }
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>
```

所谓层叠指的是将多条规则组合起来产生元素的最终样式。在示例中,`<strong>`标签的默认样式`font-weight:bold`,会被`<style>`标签中的规则覆盖,并为`<strong>`标签样式添加`font-style`和`color`属性。

当多条规则重复定义同一属性时,最近的规则会拥有最高的优先级。因此如果`<style>`标签中的规则包含`font-weight:normal`,违背了默认的`font-weight`规则,那么文本将会显示为普通样式,而非粗体。属性`style`中的样式会直接作用于节点,而且往往拥有最高优先级。

我们可以在 CSS 规则中使用标签名来定位标签。规则`.abc`指的是所有`class`属性中包含`abc`的元素。规则`#xyz`作用于`id`属性为`xyz`(应当在文档中唯一存在)的元素。

```css
.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;
}
```

优先级规则偏向于最近定义的规则,仅在规则特殊性相同时适用。规则的特殊性用于衡量该规则描述匹配元素时的准确性。特殊性取决于规则中的元素数量和类型(`tag`、`class`或`id`)。例如,目标规则`p.a`比目标规则`p`或`.a`更具体,因此有更高优先级。

`p>a`这种写法将样式作用于`<p>`标签的直系子节点。类似的,`p a`应用于所有的`<p>`标签中的`<a>`标签,无论是否是直系子节点。

## 查询选择器

本书不会使用太多样式表。尽管理解样式表对浏览器程序设计至关重要,想要正确解释所有浏览器支持的属性及其使用方式,可能需要两到三本书才行。

我介绍选择器语法(用在样式表中,确定样式作用的元素)的主要原因是这种微型语言同时也是一种高效的 DOM 元素查找方式。

`document`对象和元素节点中都定义了`querySelectorAll`方法,该方法接受一个选择器字符串并返回类数组对象,返回的对象中包含所有匹配的元素。

```html
<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`)与`querySelectorAll`作用相似。如果只想寻找某一个特殊元素,该方法非常有用。该方法只返回第一个匹配的元素,如果没有匹配的元素则返回`null`。

## 位置与动画

`position`样式属性是一种强大的布局方法。默认情况下,该属性值为`static`,表示元素处于文档中的默认位置。若该属性设置为`relative`,该元素在文档中依然占据空间,但此时其`top`和`left`样式属性则是相对于常规位置的偏移。若`position`设置为`absolute`,会将元素从默认文档流中移除,该元素将不再占据空间,而会与其他元素重叠。其`top`和`left`属性则是相对其最近的闭合元素的偏移,其中`position`属性的值不是`static`。如果没有任何闭合元素存在,则是相对于整个文档的偏移。

我们可以使用该属性创建一个动画。下面的文档用于显示一幅猫的图片,该图片会沿着椭圆轨迹移动。

```html
<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;
    }
    lastTime = time;
    cat.style.top = (Math.sin(angle) * 20) + "px";
    cat.style.left = (Math.cos(angle) * 200) + "px";
    requestAnimationFrame(newTime => animate(newTime, time));
  }
  requestAnimationFrame(animate);
</script>
```


我们的图像在页面中央,`position`为`relative`。为了移动这只猫,我们需要不断更新图像的`top`和`left`样式。

脚本使用`requestAnimationFrame`在每次浏览器准备重绘屏幕时调用`animate`函数。`animate`函数再次调用`requestAnimationFrame`以准备下一次更新。当浏览器窗口(或标签)激活时,更新频率大概为 60 次每秒,这种频率可以生成美观的动画。

若我们只是在循环中更新 DOM,页面会静止不动,页面上也不会显示任何东西。浏览器不会在执行 JavaScript 程序时刷新显示内容,也不允许页面上的任何交互。这就是我们需要`requestAnimationFrame`的原因,该函数用于告知浏览器 JavaScript 程序目前已经完成工作,因此浏览器可以继续执行其他任务,比如刷新屏幕,响应用户动作。

我们将动画生成函数作为参数传递给`requestAnimationFrame`。为了确保每一毫秒猫的移动是稳定的,而且动画是圆滑的,它基于一个速度,角度以这个速度改变这一次与上一次函数运行的差。如果仅仅每次走几步,猫的动作可能略显迟钝,例如,另一个在相同电脑上的繁重任务可能使得该函数零点几秒之后才会运行一次。

我们使用三角函数`Math.cos`和`Math.sin`来使猫沿着圆弧移动。你可能不太熟悉这些计算,我在这里简要介绍它们,因为你会在这本书中偶尔遇到。

`Math.cos`和`Math.sin`非常实用,我们可以利用一个 1 个弧度,计算出以点`(0,0`为圆心的圆上特定点的位置。两个函数都将参数解释为圆上的一个位置,0 表示圆上最右侧那个点,一直逆时针递增到`2π`(大概是 6.28),正好走过整个圆。`Math.cos`可以计算出圆上某一点对应的`x`坐标,而`Math.sin`则计算出`y`坐标。超过`2π`或小于 0 的位置(或角度)都是合法的。因为弧度是循环重复的,`a+2π`与`a`的角度相同。

用于测量角度的单位称为弧度 - 一个完整的圆弧是`2π`个弧度,类似于以角度度量时的 360 度。 常量`π`在 JavaScript 中为`Math.PI`。

![](img/14-4.svg)

猫的动画代码保存了一个名为`angle`的计数器,该绑定记录猫在圆上的角度,而且每当调用`animate`函数时,增加该计数器的值。我们接着使用这个角度来计算图像元素的当前位置。`top`样式是`Math.sin`的结果乘以 20,表示圆中的垂直弧度。`left`样式是 Math.cos 的结果乘以`200`,因此圆的宽度大于其高度,导致最后猫会沿着椭圆轨迹移动。

这里需要注意的是样式的值一般需要指定单位。本例中,我们在数字后添加`px`来告知浏览器以像素为计算单位(而非厘米,`ems`,或其他单位)。我们很容易遗漏这个单位。如果我们没有为样式中的数字加上单位,浏览器最后会忽略掉该样式,除非数字是 0,在这种情况下使用什么单位,其结果都是一样的。

## 本章小结

JavaScript 程序可以通过名为 DOM 的数据结构,查看并修改浏览器中显示的文档。该数据结构描述了浏览器文档模型,而 JavaScript 程序可以通过修改该数据结构来修改浏览器展示的文档。

DOM 的组织就像树一样,DOM 根据文档结构来层次化地排布元素。描述元素的对象包含很多属性,比如`parentNode`和`childNodes`这两个属性可以用来遍历 DOM 树。

我们可以通过样式来改变文档的显示方式,可以直接在节点上附上样式,也可以编写匹配节点的规则。样式包含许多不同的属性,比如`color`和`display`。JavaScript 代码可以直接通过节点的`style`属性操作元素的样式。

## 习题

### 创建一张表

HTML 表格使用以下标签结构构建:

```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>`)。

给定一个山的数据集,一个包含`name`,`height`和`place`属性的对象数组,为枚举对象的表格生成 DOM 结构。 每个键应该有一列,每个对象有一行,外加一个顶部带有`<th>`元素的标题行,列出列名。

编写这个程序,以便通过获取数据中第一个对象的属性名称,从对象自动产生列。

将所得表格添加到`id`属性为`"mountains"`的元素,以便它在文档中可见。

当你完成后,将元素的`style.textAlign`属性设置为`right`,将包含数值的单元格右对齐。

```html
<h1>Mountains</h1>

<div id="mountains"></div>

<script>
  const MOUNTAINS = [
    {name: "Kilimanjaro", height: 5895, place: "Tanzania"},
    {name: "Everest", height: 8848, place: "Nepal"},
    {name: "Mount Fuji", height: 3776, place: "Japan"},
    {name: "Vaalserberg", height: 323, place: "Netherlands"},
    {name: "Denali", height: 6168, place: "United States"},
    {name: "Popocatepetl", height: 5465, place: "Mexico"},
    {name: "Mont Blanc", height: 4808, place: "Italy/France"}
  ];

  // Your code here
</script>
```

### 通过标签名获取元素

`document.getElementsByTagName`方法返回带有特定标签名称的所有子元素。实现该函数,这里注意是函数不是方法。该函数的参数是一个节点和字符串(标签名称),并返回一个数组,该数组包含所有带有特定标签名称的所有后代元素节点。

你可以使用`nodeName`属性从 DOM 元素中获取标签名称。但这里需要注意,使用`tagName`获取的标签名称是全大写形式。可以使用字符串的`toLowerCase`或`toUpperCase`来解决这个问题。

```html
<h1>Heading with a <span>span</span> element.</h1>
<p>A paragraph with <span>one</span>, <span>two</span>
  spans.</p>

<script>
  function byTagName(node, tagName) {
    // Your code here.
  }

  console.log(byTagName(document.body, "h1").length);
  // → 1
  console.log(byTagName(document.body, "span").length);
  // → 3
  let para = document.querySelector("p");
  console.log(byTagName(para, "span").length);
  // → 2
</script>
```

### 猫的帽子

扩展一下之前定义的用来绘制猫的动画函数,让猫和它的帽子沿着椭圆形轨道边(帽子永远在猫的对面)移动。

你也可以尝试让帽子环绕着猫移动,或修改成其他有趣的动画。

为了便于定位多个对象,一个比较好的方法是使用绝对(`absolute`)定位。这就意味着`top`和`left`属性是相对于文档左上角的坐标。你可以简单地在坐标上加上一个固定数字,以避免出现负的坐标,它会使图像移出可见页面。

```html
<style>body { min-height: 200px }</style>
<img src="img/cat.png" id="cat" style="position: absolute">
<img src="img/hat.png" id="hat" style="position: absolute">

<script>
  let cat = document.querySelector("#cat");
  let hat = document.querySelector("#hat");

  let angle = 0;
  let lastTime = null;
  function animate(time) {
    if (lastTime != null) angle += (time - lastTime) * 0.001;
    lastTime = time;
    cat.style.top = (Math.sin(angle) * 40 + 40) + "px";
    cat.style.left = (Math.cos(angle) * 200 + 230) + "px";

    // Your extensions here.

    requestAnimationFrame(animate);
  }
 requestAnimationFrame(animate);
</script>
```


================================================
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
<p>Click this document to activate the handler.</p>
<script>
  window.addEventListener("click", () => {
    console.log("You knocked?");
  });
</script>
```

`window`绑定指向浏览器提供的内置对象。 它代表包含文档的浏览器窗口。 调用它的`addEventListener`方法注册第二个参数,以便在第一个参数描述的事件发生时调用它。

## 事件与 DOM 节点

每个浏览器事件处理器被注册在上下文中。在为整个窗口注册处理器之前,我们在`window`对象上调用了`addEventListener`。 这种方法也可以在 DOM 元素和一些其他类型的对象上找到。 仅当事件发生在其注册对象的上下文中时,才调用事件监听器。

```html
<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`的参数调用。

```html
<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`的完全相同的函数值。 因此,要注销一个处理其,您需要为该函数提供一个名称(在本例中为`once`),以便能够将相同的函数值传递给这两个方法。

## 事件对象

虽然目前为止我们忽略了它,事件处理器函数作为对象传递:事件(Event)对象。这个对象持有事件的额外信息。例如,如果我们想知道哪个鼠标按键被按下,我们可以查看事件对象的which属性。

```html
<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`,调度段落上的事件处理器执行。当点击鼠标其他键时,两个处理器都会执行。

```html
<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`属性来区分用户按下了哪个按钮,而不是为每个按钮都注册独立的事件处理器。

```html
<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`事件对象的方法。

你可以实现你自己的键盘快捷键或交互式菜单。你也可以干扰用户期望的行为。例如,这里实现一个无法跳转的链接。

```html
<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"`事件。

```html
<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`属性来判断这些键是否被按下。

```html
<p>Press Ctrl-Space to continue.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("Continuing!");
    }
  });
</script>
```

按键事件发生的 DOM 节点取决于按下按键时具有焦点的元素。 大多数节点不能拥有焦点,除非你给他们一个`tabindex`属性,但像链接,按钮和表单字段可以。 我们将在第 18 章中回顾表单字段。 当没有特别的焦点时,`document.body`充当按键事件的目标节点。

当用户键入文本时,使用按键事件来确定正在键入的内容是有问题的。 某些平台,尤其是 Android 手机上的虚拟键盘,不会触发按键事件。 但即使你有一个老式键盘,某些类型的文本输入也不能直接匹配按键,例如其脚本不适合键盘的人所使用的 IME(“输入法编辑器”)软件 ,其中组合多个热键来创建字符。

要注意什么时候输入了内容,每当用户更改其内容时,可以键入的元素(例如`<input>`和`<textarea>`标签)触发`"input"`事件。为了获得输入的实际内容,最好直接从焦点字段中读取它。 第 18 章将展示如何实现。

## 指针事件

目前有两种广泛使用的方式,用于指向屏幕上的东西:鼠标(包括类似鼠标的设备,如触摸板和轨迹球)和触摸屏。 它们产生不同类型的事件。

## 鼠标点击

点击鼠标按键会触发一系列事件。`"mousedown"`事件和`"mouseup"`事件类似于`"keydown"`和`"keyup"`事件,当鼠标按钮按下或释放时触发。当事件发生时,由鼠标指针下方的 DOM 节点触发事件。

在`mouseup`事件后,包含鼠标按下与释放的特定节点会触发`"click"`事件。例如,如果我在一个段落上按下鼠标,移动到另一个段落上释放鼠标,`"click"`事件会发生在包含这两个段落的元素上。

若两次点击事件触发时机接近,则在第二次点击事件之后,也会触发`"dbclick"`(双击,double-click)事件。

为了获得鼠标事件触发的精确信息,你可以查看事件中的`clientX`和`clientY`属性,包含了事件相对于窗口左上角的坐标(以像素为单位)。或`pageX`和`pageY`,它们相对于整个文档的左上角(当窗口被滚动时可能不同)。

下面的代码实现了简单的绘图程序。每次点击文档时,会在鼠标指针下添加一个点。还有一个稍微优化的绘图程序,请参见第 19 章。

```html
<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* rounds corners */
    background: blue;
    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"`事件。该事件可用于跟踪鼠标位置。当实现某些形式的鼠标拖拽功能时,该事件非常有用。

举一个例子,下面的程序展示一条栏,并设置一个事件处理器,当向左拖动这个栏时,会使其变窄,若向右拖动则变宽。

```html
<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`属性(注意复数形式),它告诉我们当前按下的按键。 当它为零时,没有按下按键。 当按键被按住时,其值是这些按键的代码总和 - 左键代码为 1,右键为 2,中键为 4。 这样,您可以通过获取`buttons`的剩余值及其代码,来检查是否按下了给定按键。

请注意,这些代码的顺序与`button`使用的顺序不同,中键位于右键之前。 如前所述,一致性并不是浏览器编程接口的强项。

## 触摸事件

我们使用的图形浏览器的风格,是考虑到鼠标界面的情况下而设计的,那个时候触摸屏非常罕见。 为了使网络在早期的触摸屏手机上“工作”,在某种程度上,这些设备的浏览器假装触摸事件是鼠标事件。 如果你点击你的屏幕,你会得到`'mousedown'`,`'mouseup'`和`'click'`事件。

但是这种错觉不是很健壮。 触摸屏与鼠标的工作方式不同:它没有多个按钮,当手指不在屏幕上时不能跟踪手指(来模拟`"mousemove"`),并且允许多个手指同时在屏幕上。

鼠标事件只涵盖了简单情况下的触摸交互 - 如果您为按钮添加`"click"`处理器,触摸用户仍然可以使用它。 但是像上一个示例中的可调整大小的栏在触摸屏上不起作用。

触摸交互触发了特定的事件类型。 当手指开始触摸屏幕时,您会看到`'touchstart'`事件。 当它在触摸中移动时,触发`"touchmove"`事件。 最后,当它停止触摸屏幕时,您会看到`"touchend"`事件。

由于许多触摸屏可以同时检测多个手指,这些事件没有与其关联的一组坐标。 相反,它们的事件对象拥有`touches`属性,它拥有一个类数组对象,每个对象都有自己的`clientX`,`clientY`,`pageX`和`pageY`属性。

你可以这样,在每个触摸手指周围显示红色圆圈。

```html
<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`事件。该事件用处极多,比如知道用户当前查看的元素(禁用用户视线以外的动画,或向邪恶的指挥部发送监视报告),或展示一些滚动的迹象(通过高亮表格的部分内容,或显示页码)。

以下示例在文档上方绘制一个进度条,并在您向下滚动时更新它来填充:

```html
<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>
```

将元素的`position`属性指定为`fixed`时,其行为和`absolute`很像,但可以防止在文档滚动时期跟着文档一起滚动。其效果是让我们的进度条呆在最顶上。 改变其宽度来指示当前进度。 在设置宽度时,我们使用`%`而不是`px`作为单位,使元素的大小相对于页面宽度。

`innerHeight`全局绑定是窗口高度,我们必须要减去滚动条的高度。你点击文档底部的时候是无法继续滚动的。对于窗口高度来说,也存在`innerWidth`。使用`pageYOffset`(当前滚动位置)除以最大滚动位置,并乘以 100,就可以得到进度条长度。

调用滚动事件的`preventDefault`无法阻止滚动。实际上,事件处理器是在进行滚动之后才触发的。

## 焦点事件

当元素获得焦点时,浏览器会触发其上的`focus`事件。当失去焦点时,元素会获得`blur`事件。

与前文讨论的事件不同,这两个事件不会传播。子元素获得或失去焦点时,不会激活父元素的处理器。

下面的示例中,文本域在拥有焦点时会显示帮助文本。

```html
<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`事件。

## 加载事件

当界面结束装载时,会触发窗口对象和文档`body`对象的`"load"`事件。该事件通常用于在当整个文档构建完成时,进行初始化。请记住`<script>标`签的内容是一遇到就执行的。这可能太早了,比如有时脚本需要处理在`<script>`标签后出现的内容。

诸如`image`或`script`这类会装载外部文件的标签都有`load`事件,指示其引用文件装载完毕。类似于焦点事件,装载事件是不会传播的。

当页面关闭或跳转(比如跳转到一个链接)时,会触发`beforeunload`事件。该事件用于防止用户突然关闭文档而丢失工作结果。你无法使用`preventDefault`方法阻止页面卸载。它通过从处理器返回非空值来完成。当你这样做时,浏览器会通过显示一个对话框,询问用户是否关闭页面的对话框中。该机制确保用户可以离开,即使在那些想要留住用户,强制用户看广告的恶意页面上,也是这样。

## 事件和事件循环

在事件循环的上下文中,如第 11 章中所述,浏览器事件处理器的行为,类似于其他异步通知。 它们是在事件发生时调度的,但在它们有机会运行之前,必须等待其他正在运行的脚本完成。

仅当没有别的事情正在运行时,才能处理事件,这个事实意味着,如果事件循环与其他工作捆绑在一起,任何页面交互(通过事件发生)都将延迟,直到有时间处理它为止。 因此,如果您安排了太多工作,无论是长时间运行的事件处理器还是大量短时间运行的工作,该页面都会变得缓慢且麻烦。

如果您想在背后做一些耗时的事情而不会冻结页面,浏览器会提供一些名为 Web Worker 的东西。 Web Worker 是一个 JavaScript 过程,与主脚本一起在自己的时间线上运行。

想象一下,计算一个数字的平方运算是一个重量级的,长期运行的计算,我们希望在一个单独的线程中执行。 我们可以编写一个名为`code/squareworker.js`的文件,通过计算平方并发回消息来响应消息:

```js
addEventListener("message", event => {
  postMessage(event.data * event.data);
});
```

为了避免多线程触及相同数据的问题,Web Worker 不会将其全局作用域或任何其他数据与主脚本的环境共享。 相反,你必须通过来回发送消息与他们沟通。

此代码会生成一个运行该脚本的 Web Worker,向其发送几条消息并输出响应。

```js
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`对象收发消息,而`worker`则直接向其全局作用域发送消息,或监听其消息。只有可以表示为 JSON 的值可以作为消息发送 - 另一方将接收它们的副本,而不是值本身。

## 定时器

我们在第 11 章中看到了`setTimeout`函数。 它会在给定的毫秒数之后,调度另一个函数在稍后调用。

有时读者需要取消调度的函数。可以存储`setTimeout`的返回值,并将作为参数调用`clearTimeout`。

```html
let bombTimer = setTimeout(() => {
  console.log("BOOM!");
}, 500);

if (Math.random() < 0.5) { // 50% chance
  console.log("Defused.");
  clearTimeout(bombTimer);
}
```

函数`cancelAnimationFrame`作用与`clearTimeout`相同,使用`requestAnimationFrame`的返回值调用该函数,可以取消帧(假定函数还没有被调用)。

还有`setInterval`和`clearInterval`这种相似的函数,用于设置计时器,每隔一定毫秒数重复执行一次。

```html
let ticks = 0;
let clock = setInterval(() => {
  console.log("tick", ticks++);
  if (ticks == 10) {
    clearInterval(clock);
    console.log("stop.");
  }
}, 200);
```

## 降频

某些类型的事件可能会连续、迅速触发多次(例如`mousemove`和`scroll`事件)。处理这类事件时,你必须小心谨慎,防止处理任务耗时过长,否则处理器会占据过多事件,导致用户与文档交互变得非常慢。

若你需要在这类处理器中编写一些重要任务,可以使用`setTimeout`来确保不会频繁进行这些任务。我们通常称之为“事件降频(Debounce)”。有许多方法可以完成该任务。

在第一个示例中,当用户输入某些字符时,我们想要有所反应,但我们不想在每个按键事件中立即处理该任务。当用户输入过快时,我们希望暂停一下然后进行处理。我们不是立即在事件处理器中执行动作,而是设置一个定时器。我们也会清除上一次的定时器(如果有),因此当两个事件触发间隔过短(比定时器延时短),就会取消上一次事件设置的定时器。

```html
<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>
```

将`undefined`传递给`clearTimeout`或在一个已结束的定时器上调用`clearTimeout`是没有效果的。因此,我们不需要关心何时调用该方法,只需要每个事件中都这样做即可。

如果我们想要保证每次响应之间至少间隔一段时间,但不希望每次事件发生时都重置定时器,而是在一连串事件连续发生时能够定时触发响应,那么我们可以使用一个略有区别的方法来解决问题。例如,我们想要响应`"mousemove"`事件来显示当前鼠标坐标,但频率只有 250ms。

```html
<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>
```

## 本章小结

事件处理器可以检测并响应发生在我们的 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
<p>&#x1f4a5;</p>
<script>
  // Your code here
</script>
```

### 鼠标轨迹

在 JavaScript 早期,有许多主页都会在页面上使用大量的动画,人们想出了许多该语言的创造性用法。

其中一种是“鼠标踪迹”,也就是一系列的元素,随着你在页面上移动鼠标,它会跟着你的鼠标指针。

在本习题中实现鼠标轨迹的功能。使用绝对定位、固定尺寸的`<div>`元素,背景为黑色(请参考鼠标点击一节中的示例)。创建一系列此类元素,当鼠标移动时,伴随鼠标指针显示它们。

有许多方案可以实现我们所需的功能。你可以根据你的需要实现简单的或复杂的方法。简单的解决方案是保存固定鼠标的轨迹元素并循环使用它们,每次`mousemove`事件触发时将下一个元素移动到鼠标当前位置。

```html
<style>
  .trail { /* className for the trail elements */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // Your code here.
</script>
```

### 选项卡

选项卡面板广泛用于用户界面。它支持用户通过选择元素上方的很多突出的选项卡来选择一个面板。

本习题中,你必须实现一个简单的选项卡界面。编写`asTabs`函数,接受一个 DOM 节点并创建选项卡界面来展现该节点的子元素。该函数应该在顶层节点中插入大量`<button>`元素,与每个子元素一一对应,按钮文本从子节点的`data-tabname`中获取。除了显示一个初始子节点,其他子节点都应该隐藏(将`display`样式设置成`none`),并通过点击按钮来选择当前显示的节点。

当它生效时将其扩展,为当前选中的选项卡,将按钮的样式设为不同的,以便明确选择了哪个选项卡。

```html
<tab-panel>
  <div data-tabname="one">Tab one</div>
  <div data-tabname="two">Tab two</div>
  <div data-tabname="three">Tab three</div>
</tab-panel>
<script>
  function asTabs(node) {
    // Your code here.
  }
  asTabs(document.querySelector("tab-panel"));
</script>
```


================================================
FILE: 16.md
================================================
# 十六、项目:平台游戏

> 原文:[Project: A Platform Game](https://eloquentjavascript.net/16_game.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/)

> 所有现实都是游戏。
> 
> Iain Banks,《The Player of Games》

![](img/16-0.jpg)

我最初对电脑的痴迷,就像许多小孩一样,与电脑游戏有关。我沉迷在那个计算机所模拟出的小小世界中,我可以操纵这个世界,我同时也沉迷在那些尚未展开的故事之中。但我沉迷其中并不是因为游戏实际描述的故事,而是因为我可以充分发挥我的想象力,去构思故事的发展。

我并不希望任何人把编写游戏作为自己的事业。就像音乐产业中,那些希望加入这个行业的热忱年轻人与实际的人才需求之间存在巨大的鸿沟,也因此产生了一个极不健康的就业环境。不过,把编写游戏作为乐趣还是相当不错的。

本章将会介绍如何实现一个小型平台游戏。平台游戏(或者叫作“跳爬”游戏)要求玩家操纵一个角色在世界中移动,这种游戏往往是二维的,而且采用单一侧面作为观察视角,玩家可以来回跳跃。

## 游戏

我们游戏大致基于由 Thomas Palef 开发的 [Dark Blue](http://www.lessmilk.com/games/10)。我之所以选择了这个游戏,是因为这个游戏既有趣又简单,而且不需要编写大量代码。该游戏看起来如下页图所示。

![](img/16-1.png)

黑色的方块表示玩家,玩家任务是收集黄色的方块(硬币),同时避免碰到红色素材(“岩浆”)。当玩家收集完所有硬币后就可以过关。

玩家可以使用左右方向键移动,并使用上方向键跳跃。跳跃正是这个游戏角色的特长。玩家可以跳跃到数倍于自己身高的地方,也可以在半空中改变方向。虽然这样不切实际,但这有助于玩家感觉自己在直接控制屏幕上那个自己的化身。

该游戏包含一个固定的背景,使用网格方式进行布局,可可移动元素则覆盖在背景之上。网格中的元素可能是空气、固体或岩浆。可可移动元素是玩家、硬币或者某一块岩浆。这些元素的位置不限于网格,它们的坐标可以是分数,允许平滑运动。

## 实现技术

我们会使用浏览器的 DOM 来展示游戏界面,我们会通过处理按键事件来读取用户输入。

与屏幕和键盘相关的代码只是实现游戏代码中的很小一部分。由于所有元素都只是彩色方块,因此绘制方法并不复杂。我们为每个元素创建对应的 DOM 元素,并使用样式来为其指定背景颜色、尺寸和位置。

由于背景是由不会改变的方块组成的网格,因此我们可以使用表格来展示背景。自由可移动元素可以使用绝对定位元素来覆盖。

游戏和某些程序应该在不产生明显延迟的情况下绘制动画并响应用户输入,性能是非常重要的。尽管 DOM 最初并非为高性能绘图而设计,但实际上 DOM 的性能表现得比我们想象中要好得多。读者已经在第 13 章中看过一些动画,在现代机器中,即使我们不怎么考虑性能优化,像这种简单的游戏也可以流畅运行。

在下一章中,我们会研究另一种浏览器技术 —— `<canvas>`标签。该标签提供了一种更为传统的图像绘制方式,直接处理形状和像素而非 DOM 元素。

## 关卡

我们需要一种人类可读的、可编辑的方法来指定关卡。因为一切最开始都可以在网格,所以我们可以使用大型字符串,其中每个字符代表一个元素,要么是背景网格的一部分,要么是可移动元素。

小型关卡的平面图可能是这样的:

```js
var simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
```

句号是空的位置,井号(`#`)字符是墙,加号是岩浆。玩家的起始位置是 AT 符号(`@`)。每个`O`字符都是一枚硬币,等号(`=`)是一块来回水平移动的岩浆块。

我们支持两种额外的可移动岩浆:管道符号(`|`)表示垂直移动的岩浆块,而`v`表示下落的岩浆块 —— 这种岩浆块也是垂直移动,但不会来回弹跳,只会向下移动,直到遇到地面才会直接回到其起始位置。

整个游戏包含了许多关卡,玩家必须完成所有关卡。每关的过关条件是玩家需要收集所有硬币。如果玩家碰到岩浆,当前关卡会恢复初始状态,而玩家可以再次尝试过关。

## 读取关卡

下面的类存储了关卡对象。它的参数应该是定义关卡的字符串。

```js
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") return type;
        this.startActors.push(
          type.create(new Vec(x, y), ch));
        return "empty";
      });
    });
  }
}
```

`trim`方法用于移除平面图字符串起始和终止处的空白。这允许我们的示例平面图以换行开始,以便所有行都在彼此的正下方。其余的字符串由换行符拆分,每一行扩展到一个数组中,生成了字符数组。

因此,`rows`包含字符数组、平面图的行。我们可以从中得出水平宽度和高度。但是我们仍然必须将可移动元素与背景网格分开。我们将其称为角色(Actor)。它们将存储在一个对象数组中。背景将是字符串的数组的数组,持有字段类型,如`"empty"`,`"wall"`,或`"lava"`。

为了创建这些数组,我们在行上映射,然后在它们的内容上进行映射。请记住,`map`将数组索引作为第二个参数传递给映射函数,它告诉我们给定字符的`x`和`y`坐标。游戏中的位置将存储为一对坐标,左上角为`0, 0`,并且每个背景方块为 1 单位高和宽。

为了解释平面图中的字符,`Level`构造器使用`levelChars`对象,它将背景元素映射为字符串,角色字符映射为类。当`type`是一个角色类时,它的`create`静态方法用于创建一个对象,该对象被添加到`startActors`,映射函数为这个背景方块返回`"empty"`。

角色的位置存储为一个`Vec`对象,它是二维向量,一个具有`x`和`y`属性的对象,像第六章一样。

当游戏运行时,角色将停在不同的地方,甚至完全消失(就像硬币被收集时)。我们将使用一个`State`类来跟踪正在运行的游戏的状态。

```js
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"`。

这又是一个持久性数据结构,更新游戏状态会创建新状态,并使旧状态保持完整。

## 角色

角色对象表示,游戏中给定可移动元素的当前位置和状态。所有的角色对象都遵循相同的接口。它们的`pos`属性保存元素的左上角坐标,它们的`size`属性保存其大小。

然后,他们有`update`方法,用于计算给定时间步长之后,他们的新状态和位置。它模拟了角色所做的事情:响应箭头键并且移动,因岩浆而来回弹跳,并返回新的更新后的角色对象。

`type`属性包含一个字符串,该字符串指定了角色类型:`"player"`,`"coin"`或者`"lava"`。这在绘制游戏时是有用的,为角色绘制的矩形的外观基于其类型。

角色类有一个静态的`create`方法,它由`Level`构造器使用,用于从关卡平面图中的字符中,创建一个角色。它接受字符本身及其坐标,这是必需的,因为`Lava`类处理几个不同的字符。

这是我们将用于二维值的`Vec`类,例如角色的位置和大小。

```js
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`方法用给定的数字来缩放向量。当我们需要将速度向量乘时间间隔,来获得那个时间的行走距离时,这就有用了。

不同类型的角色拥有他们自己的类,因为他们的行为非常不同。让我们定义这些类。稍后我们将看看他们的`update`方法。

玩家类拥有`speed`属性,存储了当前速度,来模拟动量和重力。

```js
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`的读取器,但是每次读取属性时,都会创建并返回一个新的`Vec`对象,这将是浪费的。(字符串是不可变的,不必在每次求值时重新创建。)

构造`Lava`角色时,我们需要根据它所基于的字符来初始化对象。动态岩浆以其当前速度移动,直到它碰到障碍物。这个时候,如果它拥有`reset`属性,它会跳回到它的起始位置(滴落)。如果没有,它会反转它的速度并以另一个方向继续(弹跳)。

`create`方法查看`Level`构造器传递的字符,并创建适当的岩浆角色。

```js
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);
```

`Coin`对象相对简单,大多时候只需要待在原地即可。但为了使游戏更加有趣,我们让硬币轻微摇晃,也就是会在垂直方向上小幅度来回移动。每个硬币对象都存储了其基本位置,同时使用`wobble`属性跟踪图像跳动幅度。这两个属性同时决定了硬币的实际位置(存储在`pos`属性中)。

```js
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`坐标。因为我们沿着圆移动,因此`y`坐标会以平滑的波浪形式来回移动,正弦函数在实现波浪形移动中非常实用。

为了避免出现所有硬币同时上下移动,每个硬币的初始阶段都是随机的。由`Math.sin`产生的波长是`2π`。我们可以将`Math.random`的返回值乘以`2π`,计算出硬币波形轨迹的初始位置。

现在我们可以定义`levelChars`对象,它将平面图字符映射为背景网格类型,或角色类。

```js
const levelChars = {
  ".": "empty", "#": "wall", "+": "lava",
  "@": Player, "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};
```

这给了我们创建`Level`实例所需的所有部件。

```js
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9
```

上面一段代码的任务是将特定关卡显示在屏幕上,并构建关卡中的时间与动作。

## 成为负担的封装

本章中大多数代码并没有过多考虑封装。首先,封装需要耗费额外精力。封装使得程序变得更加庞大,而且会引入额外的概念和接口。我尽量将程序的体积控制在较小的范围之内,避免读者因为代码过于庞大而走神。

其次,游戏中的大量元素是紧密耦合在一起的,如果其中一个元素行为改变,其他的元素很有可能也会发生变化。我们需要根据游戏的工作细节来为元素之间设计大量接口。这使得接口的效果不是很好。每当你改变系统中的某一部分时,由于其他部分的接口可能没有考虑到新的情况,因此你需要关心这一修改是否会影响到其他部分的代码。

系统中的某些分割点可以通过严格的接口对系统进行合理的划分,但某些分割点则不是如此。尝试去封装某些本没有合理边界的代码必然会导致浪费大量精力。当你犯下这种大错之际,你就会注意到你的接口变得庞大臃肿,而且随着程序不断演化,你需要频繁修改这些接口。

我们会封装的一部分代码是绘图子系统。其原因是我们会在下一章中使用另一种方式来展示相同的游戏。通过将绘图代码隐藏在接口之后,我们可以在下一章中使用相同的游戏程序,只需要插入新的显示模块即可。

## 绘图

我们通过定义一个“显示器”对象来封装绘图代码,该对象显示指定关卡,以及状态。本章定义的显示器类型名为`DOMDisplay`,因为该类型使用简单的 DOM 元素来显示关卡。

我们会使用样式表来设定实际的颜色以及其他构建游戏中所需的固定的属性。创建这些属性时,我们可以直接对元素的`style`属性进行赋值,但这会使得游戏代码变得冗长。

下面的帮助函数提供了一种简洁的方法,来创建元素并赋予它一些属性和子节点:

```js
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;
}
```

我们创建显示器对象时需要指定其父元素,显示器将会创建在该父元素上,同时还需指定一个关卡对象。

```js
class DOMDisplay {
  constructor(parent, level) {
    this.dom = elt("div", {class: "game"}, drawGrid(level));
    this.actorLayer = null;
    parent.appendChild(this.dom);
  }

  clear() { this.dom.remove(); }
}
```

由于关卡的背景网格不会改变,因此只需要绘制一次即可。角色则需要在每次刷新显示时进行重绘。`drawFame`需要使用`actorLayer`属性来跟踪已保存角色的动作,因此我们可以轻松移除或替换这些角色。

我们的坐标和尺寸以网格单元为单位跟踪,也就是说尺寸或距离中的 1 单元表示一个单元格。在设置像素级尺寸时,我们需要将坐标按比例放大,如果游戏中的所有元素只占据一个方格中的一个像素,那将是多么可笑。而`scale`绑定会给出一个单元格在屏幕上实际占据的像素数目。

```js
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>`元素来绘制背景。这非常符合关卡中`grid`属性的结构。网格中的每一行对应表格中的一行(`<tr>`元素)。网格中的每个字符串对应表格单元格(`<td>`)元素的类型名。扩展(三点)运算符用于将子节点数组作为单独的参数传给`elt`。

下面的 CSS 使表格看起来像我们想要的背景:

```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;              }
```

其中某些属性(border-spacing和padding)用于取消一些我们不想保留的表格默认行为。我们不希望在单元格之间或单元格内部填充多余的空白。

其中`background`规则用于设置背景颜色。CSS中可以使用两种方式来指定颜色,一种方法是使用单词(`white`),另一种方法是使用形如`rgb(R,G,B)`的格式,其中`R`表示颜色中的红色成分,`G`表示绿色成分,`B`表示蓝色成分,每个数字范围均为 0 到 255。因此在`rgb(52,166,251)`中,红色成分为 52,绿色为 166,而蓝色是 251。由于蓝色成分数值最大,因此最后的颜色会偏向蓝色。而你可以看到`.lava`规则中,第一个数字(红色)是最大的。

我们绘制每个角色时需要创建其对应的 DOM 元素,并根据角色属性来设置元素坐标与尺寸。这些值都需要与`scale`相乘,以将游戏中的尺寸单位转换为像素。

```js
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`类会赋予角色一个绝对坐标。我们将角色的类型名称作为额外的 CSS 类来设置这些元素的颜色。我们并没有再次定义`lava`类,因为我们可以直接复用前文为岩浆单元格定义的规则。

```css
.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }
```

`setState`方法用于使显示器显示给定的状态。它首先删除旧角色的图形,如果有的话,然后在他们的新位置上重新绘制角色。试图将 DOM 元素重用于角色,可能很吸引人,但是为了使它有效,我们需要大量的附加记录,来关联角色和 DOM 元素,并确保在角色消失时删除元素。因为游戏中通常只有少数角色,重新绘制它们开销并不大。

```js
DOMDisplay.prototype.setState = 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 规则,指定祖先节点包含特定类的`player`元素的样式即可。

```css
.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
```

在遇到岩浆之后,玩家的颜色应该变成深红色,暗示着角色被烧焦了。当玩家收集完最后一枚硬币时,我们添加两个模糊的白色阴影来创建白色的光环效果,其中一个在左上角,一个在右上角。

我们无法假定关卡总是符合视口尺寸,它是我们在其中绘制游戏的元素。所以我们需要调用`scrollPlayerIntoView`来确保如果关卡在视口范围之外,我们可以滚动视口,确保玩家靠近视口的中央位置。下面的 CSS 样式为包装器的DOM元素设置了一个最大尺寸,以确保任何超出视口的元素都是不可见的。我们可以将外部元素的`position`设置为`relative`,因此该元素中的角色总是相对于关卡的左上角进行定位。

```css
.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}
```

在`scrollPlayerIntoView`方法中,我们找出玩家的位置并更新其包装器元素的滚动坐标。我们可以通过操作元素的`scrollLeft`和`scrollTop`属性,当玩家接近视口边界时修改滚动坐标。

```js
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`类型来写出相对可读的计算代码。为了找出玩家的中心位置,我们需要将左上角位置坐标加上其尺寸的一半。计算结果就是关卡坐标的中心位置。但是我们需要将结果向量乘以显示比例,以将坐标转换成像素级坐标。

接下来,我们对玩家的坐标进行一系列检测,确保其位置不会超出合法范围。这里需要注意的是这段代码有时候依然会设置无意义的滚动坐标,比如小于 0 的值或超出元素滚动区域的值。这是没问题的。DOM 会将其修改为可接受的值。如果我们将`scrollLeft`设置为`–10`,DOM 会将其修改为 0。

最简单的做法是每次重绘时都滚动视口,确保玩家总是在视口中央。但这种做法会导致画面剧烈晃动,当你跳跃时,视图会不断上下移动。比较合理的做法是在屏幕中央设置一个“中央区域”,玩家在这个区域内部移动时我们不会滚动视口。

我们现在能够显示小型关卡。

```html
<link rel="stylesheet" href="css/game.css">

<script>
  let simpleLevel = new Level(simpleLevelPlan);
  let display = new DOMDisplay(document.body, simpleLevel);
  display.setState(State.start(simpleLevel));
</script>
```

我们可以在`link`标签中使用`rel="stylesheet"`,将一个 CSS 文件加载到页面中。文件`game.css`包含了我们的游戏所需的样式。

## 动作与冲突

现在我们是时候来添加一些动作了。这是游戏中最令人着迷的一部分。实现动作的最基本的方案(也是大多数游戏采用的)是将时间划分为一个个时间段,根据角色的每一步速度和时间长度,将元素移动一段距离。我们将以秒为单位测量时间,所以速度以单元每秒来表示。

移动东西非常简单。比较困难的一部分是处理元素之间的相互作用。当玩家撞到墙壁或者地板时,不可能简单地直接穿越过去。游戏必须注意特定的动作会导致两个对象产生碰撞,并需要采取相应措施。如果玩家遇到墙壁,则必须停下来,如果遇到硬币则必须将其收集起来。

想要解决通常情况下的碰撞问题是件艰巨任务。你可以找到一些我们称之为物理引擎的库,这些库会在二维或三维空间中模拟物理对象的相互作用。我们在本章中采用更合适的方案:只处理矩形物体之间的碰撞,并采用最简单的方案进行处理。

在移动角色或岩浆块时,我们需要测试元素是否会移动到墙里面。如果会的话,我们只要取消整个动作即可。而对动作的反应则取决于移动元素类型。如果是玩家则停下来,如果是岩浆块则反弹回去。

这种方法需要保证每一步之间的时间间隔足够短,确保能够在对象实际碰撞之前取消动作。如果时间间隔太大,玩家最后会悬浮在离地面很高的地方。另一种方法明显更好但更加复杂,即寻找到精确的碰撞点并将元素移动到那个位置。我们会采取最简单的方案,并确保减少动画之间的时间间隔,以掩盖其问题。

该方法用于判断某个矩形(通过位置与尺寸限定)是否会碰到给定类型的网格。

```js
Level.prototype.touches = function(pos, size, type) {
  var xStart = Math.floor(pos.x);
  var xEnd = Math.ceil(pos.x + size.x);
  var yStart = Math.floor(pos.y);
  var yEnd = Math.ceil(pos.y + size.y);

  for (var y = yStart; y < yEnd; y++) {
    for (var 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`,来计算与身体重叠的网格方块集合。记住网格方块的大小是`1x1`个单位。通过将盒子的边上下颠倒,我们得到盒子接触的背景方块的范围。

![](img/16-2.svg)

我们通过查找坐标遍历网格方块,并在找到匹配的方块时返回`true`。关卡之外的方块总是被当作`"wall"`,来确保玩家不能离开这个世界,并且我们不会意外地尝试,在我们的“`rows`数组的边界之外读取。

状态的`update`方法使用`touches`来判断玩家是否接触岩浆。

```js
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;
};
```

它接受时间步长和一个数据结构,告诉它按下了哪些键。它所做的第一件事是调用所有角色的`update`方法,生成一组更新后的角色。角色也得到时间步长,按键,和状态,以便他们可以根据这些来更新。只有玩家才会读取按键,因为这是唯一由键盘控制的角色。

如果游戏已经结束,就不需要再做任何处理(游戏不能在输之后赢,反之亦然)。否则,该方法测试玩家是否接触背景岩浆。如果是这样的话,游戏就输了,我们就完了。最后,如果游戏实际上还在继续,它会查看其他玩家是否与玩家重叠。

`overlap`函数检测角色之间的重叠。它需要两个角色对象,当它们触碰时返回`true`,当它们沿`X`轴和`Y`轴重叠时,就是这种情况。

```js
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;
}
```

如果任何角色重叠了,它的`collide`方法有机会更新状态。触碰岩浆角色将游戏状态设置为`"lost"`,当你碰到硬币时,硬币就会消失,当这是最后一枚硬币时,状态就变成了`"won"`。

```js
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);
};
```

## 角色的更新

角色对象的`update`方法接受时间步长、状态对象和`keys`对象作为参数。`Lava`角色类型忽略`keys`对象。

```js
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));
  }
};
```

它通过将时间步长乘上当前速度,并将其加到其旧位置,来计算新的位置。如果新的位置上没有障碍,它移动到那里。如果有障碍物,其行为取决于岩浆块的类型:滴落岩浆具有`reset`位置,当它碰到某物时,它会跳回去。跳跃岩浆将其速度乘以`-1`,从而开始向相反的方向移动。

硬币使用它们的`act`方法来晃动。他们忽略了网格的碰撞,因为它们只是在它们自己的方块内部晃动。

```js
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);
};
```

递增`wobble`属性来跟踪时间,然后用作`Math.sin`的参数,来找到波上的新位置。然后,根据其基本位置和基于波的偏移,计算硬币的当前位置。

还剩下玩家本身。玩家的运动对于每和轴单独处理,因为碰到地板不应阻止水平运动,碰到墙壁不应停止下降或跳跃运动。

```js
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"`事件,当事件对应的按键代码存在于其存储的按键代码集合中时,就更新对象。

```js
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”)。

## 运行游戏

我们在第十四章中看到的`requestAnimationFrames`函数是一种产生游戏动画的好方法。但该函数的接口有点过于原始。该函数要求我们跟踪上次调用函数的时间,并在每一帧后再次调用`requestAnimationFrame`方法。

我们这里定义一个辅助函数来将这部分烦人的代码包装到一个名为`runAnimation`的简单接口中,我们只需向其传递一个函数即可,该函数的参数是一个时间间隔,并用于绘制一帧图像。当帧函数返回`false`时,整个动画停止。

```js
function runAnimation(frameFunc) {
  let lastTime = null;
  function frame(time) {
    let stop = false;
    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`。`runLevel`函数(在`document.body`中)显示关卡,并使得用户通过该节点操作游戏。当关卡结束时(或胜或负),`runLevel`会多等一秒(让用户看看发生了什么),清除关卡,并停止动画,如果我们指定了`andThen`函数,则`runLevel`会以关卡状态为参数调用该函数。

```js
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.setState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        resolve(state.status);
        return false;
      }
    });
  });
}
```

一个游戏是一个关卡序列。每当玩家死亡时就重新开始当前关卡。当完成关卡后,我们切换到下一关。我们可以使用下面的函数来完成该任务,该函数的参数为一个关卡平面图(字符串)数组和显示对象的构造器。

```js
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`,当玩家完成游戏时得到解析。


在[本章的沙盒](https://eloquentjavascript.net/code#16)的`GAME_LEVELS`绑定中,有一组可用的关卡平面图。这个页面将它们提供给`runGame`,启动实际的游戏:

```html
<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>
```

## 习题

### 游戏结束

按照惯例,平台游戏中玩家一开始会有有限数量的生命,每死亡一次就扣去一条生命。当玩家生命耗尽时,游戏就从头开始了。

调整`runGame`来实现生命机制。玩家一开始会有 3 条生命。每次启动时输出当前生命数量(使用`console.log`)。

```html
<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runGame function. Modify it...
  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!");
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```

### 暂停游戏

现在实现一个功能 —— 当用户按下 ESC 键时可以暂停或继续游戏。

我们可以修改`runLevel`函数,使用另一个键盘事件处理器来实现在玩家按下 ESC 键的时候中断或恢复动画。

乍看起来,`runAnimation`无法完成该任务,但如果我们使用`runLevel`来重新安排调度策略,也是可以实现的。

当你完成该功能后,可以尝试加入另一个功能。我们现在注册键盘事件处理器的方法多少有点问题。现在`arrows`对象是一个全局绑定,即使游戏没有运行时,事件处理器也是有效的。我们称之为系统泄露。请扩展`tracKeys`,提供一种方法来注销事件处理器,接着修改`runLevel`在启动游戏时注册事件处理器,并在游戏结束后注销事件处理器。

```html
<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runLevel function. Modify this...
  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.setState(state);
        if (state.status == "playing") {
          return true;
        } else if (ending > 0) {
          ending -= time;
          return true;
        } else {
          display.clear();
          resolve(state.status);
          return false;
        }
      });
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
```

### 怪物

它是传统的平台游戏,里面有敌人,你可以跳到它顶上来打败它。这个练习要求你把这种角色类型添加到游戏中。

我们称之为怪物。怪物只能水平移动。你可以让它们朝着玩家的方向移动,或者像水平岩浆一样来回跳动,或者拥有你想要的任何运动模式。这个类不必处理掉落,但是它应该确保怪物不会穿过墙壁。

当怪物接触玩家时,效果取决于玩家是否跳到它们顶上。你可以通过检查玩家的底部是否接近怪物的顶部来近似它。如果是这样的话,怪物就消失了。如果没有,游戏就输了。

```html
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
  <script>
    // Complete the constructor, update, and collide methods
    class Monster {
      constructor(pos, /* ... */) {}

      get type() { return "monster"; }

      static create(pos) {
        return new Monster(pos.plus(new Vec(0, -1)));
      }

      update(time, state) {}

      collide(state) {}
    }

    Monster.prototype.size = new Vec(1.2, 2);

    levelChars["M"] = Monster;

    runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
  </script>
</body>
```


================================================
FILE: 17.md
================================================
## 十七、在画布上绘图

> 原文:[Drawing on Canvas](https://eloquentjavascript.net/17_canvas.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/)

> 绘图就是欺骗。
> 
> M.C. Escher,由 Bruno Ernst 在《The Magic Mirror of M.C. Escher》中引用

![](img/17-0.jpg)

浏览器为我们提供了多种绘图方式。最简单的方式是用样式来规定普通 DOM 对象的位置和颜色。就像在上一章中那个游戏展示的,我们可以使用这种方式实现很多功能。我们可以为节点添加半透明的背景图片,来获得我们希望的节点外观。我们也可以使用`transform`样式来旋转或倾斜节点。

但是,在一些场景中,使用 DOM 并不符合我们的设计初衷。比如我们很难使用普通的 HTML 元素画出任意两点之间的线段这类图形。

这里有两种解决办法。第一种方法基于 DOM,但使用可缩放矢量图形(SVG,Scalable Vector Graphics)代替 HTML。我们可以将 SVG 看成文档标记方言,专用于描述图形而非文字。你可以在 HTML 文档中嵌入 SVG,还可以在`<img>`标签中引用它。

我们将第二种方法称为画布(canvas)。画布是一个能够封装图片的 DOM 元素。它提供了在空白的`html`节点上绘制图形的编程接口。SVG 与画布的最主要区别在于 SVG 保存了对于图像的基本信息的描述,我们可以随时移动或修改图像。

另外,画布在绘制图像的同时会把图像转换成像素(在栅格中的具有颜色的点)并且不会保存这些像素表示的内容。唯一的移动图形的方法就是清空画布(或者围绕着图形的部分画布)并在新的位置重画图形。

## SVG

本书不会深入研究 SVG 的细节,但是我会简单地解释其工作原理。在本章的结尾,我会再次来讨论,对于某个具体的应用来说,我们应该如何权衡利弊选择一种绘图方式。

这是一个带有简单的 SVG 图片的 HTML 文档。

```html
<p>Normal HTML here.</p>
<svg xmlns="http://www.w3.org/2000/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`定义的命名空间,规定了我们当前使用的语言。在 HTML 中不存在`<circle>`与`<rect>`标签,但这些标签在 SVG 中是有意义的,你可以通过这些标签的属性来绘制图像并指定样式与位置。

和 HTML 标签一样,这些标签会创建 DOM 元素,脚本可以和它们交互。例如,下面的代码可以把`<circle>`元素的颜色替换为青色。

```html
let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");
```

## `canvas`元素

我们可以在`<canvas>`元素中绘制画布图形。你可以通过设置`width`与`height`属性来确定画布尺寸(单位为像素)。

新的画布是空的,意味着它是完全透明的,看起来就像文档中的空白区域一样。

`<canvas>`标签允许多种不同风格的绘图。要获取真正的绘图接口,首先我们要创建一个能够提供绘图接口的方法的上下文(context)。目前有两种得到广泛支持的绘图接口:用于绘制二维图形的`"2d"`与通过openGL接口绘制三维图形的`"webgl"`。

本书只讨论二维图形,而不讨论 WebGL。但是如果你对三维图形感兴趣,我强烈建议大家自行深入研究 WebGL。它提供了非常简单的现代图形硬件接口,同时你也可以使用 JavaScript 来高效地渲染非常复杂的场景。

您可以用`getContext`方法在`<canvas>` DOM 元素上创建一个上下文。

```html
<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>
```

在创建完`context`对象之后,作为示例,我们画出一个红色矩形。该矩形宽 100 像素,高 50 像素,它的左上点坐标为(10,10)。

与 HTML(或者 SVG)相同,画布使用的坐标系统将`(0,0)`放置在左上角,并且`y`轴向下增长。所以`(10,10)`是相对于左上角向下并向右各偏移 10 像素的位置。

## 直线和平面

我们可以使用画布接口填充图形,也就是赋予某个区域一个固定的填充颜色或填充模式。我们也可以描边,也就是沿着图形的边沿画出线段。SVG 也使用了相同的技术。

`fillRect`方法可以填充一个矩形。他的输入为矩形框左上角的第一个`x`和`y`坐标,然后是它的宽和高。相似地,`strokeRect`方法可以画出一个矩形的外框。

两个方法都不需要其他任何参数。填充的颜色以及轮廓的粗细等等都不能由方法的参数决定(像你的合理预期一样),而是由上下文对象的属性决定。

设置`fillStyle`参数控制图形的填充方式。我们可以将其设置为描述颜色的字符串,使用 CSS 所用的颜色表示法。

`strokeStyle`属性的作用很相似,但是它用于规定轮廓线的颜色。线条的宽度由`lineWidth`属性决定。`lineWidth`的值都为正值。

```html
<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>
```

当没有设置`width`或者`height`参数时,正如示例一样,画布元素的默认宽度为 300 像素,默认高度为 150 像素。

## 路径

路径是线段的序列。2D `canvas`接口使用一种奇特的方式来描述这样的路径。路径的绘制都是间接完成的。我们无法将路径保存为可以后续修改并传递的值。如果你想修改路径,必须要调用多个方法来描述他的形状。

```html
<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`,下一条线段会从`moveTo`指定的位置开始。

当使用`fill`方法填充一个路径时,我们需要分别填充这些图形。一个路径可以包含多个图形,每个`moveTo`都会创建一个新的图形。但是在填充之前我们需要封闭路径(路径的起始节点与终止节点必须是同一个点)。如果一个路径尚未封闭,会出现一条从终点到起点的线段,然后才会填充整个封闭图形。

```html
<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`方法绘制到某一个点的曲线。为了确定一条线段的曲率,需要设定一个控制点以及一个目标点。设想这个控制点会吸引这条线段,使其成为曲线。线段不会穿过控制点。但是,它起点与终点的方向会与两个点到控制点的方向平行。见下例:

```html
<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)`,然后画出两条穿过控制点并且回到线段起点的线段。绘制的结果类似一个星际迷航的图章。你可以观察到控制点的效果:从下端的角落里发出的线段朝向控制点并向他们的目标点弯曲。

`bezierCurve`(贝塞尔曲线)方法可以绘制一种类似的曲线。不同的是贝塞尔曲线需要两个控制点而不是一个,线段的每一个端点都需要一个控制点。下面是描述贝塞尔曲线的简单示例。

```html
<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>
```

两个控制点规定了曲线两个端点的方向。两个控制点相对两个端点的距离越远,曲线就会越向这个方向凸出。

由于我们没有明确的方法,来找出我们希望绘制图形所对应的控制点,所以这种曲线还是很难操控。有时候你可以通过计算得到他们,而有时候你只能通过不断的尝试来找到合适的值。

`arc`方法是一种沿着圆的边缘绘制曲线的方法。 它需要弧的中心的一对坐标,半径,然后是起始和终止角度。


我们可以使用最后两个参数画出部分圆。角度是通过弧度来测量的,而不是度数。这意味着一个完整的圆拥有`2π`的弧度,或者`2*Math.PI`(大约为 6.28)的弧度。弧度从圆心右边的点开始并以顺时针的方向计数。你可以以 0 起始并以一个比`2π`大的数值(比如 7)作为终止值,画出一个完整的圆。

```html
<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 ½π
  cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
  cx.stroke();
</script>
```

上面这段代码绘制出的图形包含了一条从完整圆(第一次调用`arc`)的右侧到四分之一圆(第二次调用`arc`)的左侧的直线。`arc`与其他绘制路径的方法一样,会自动连接到上一个路径上。你可以调用`moveTo`或者开启一个新的路径来避免这种情况。

## 绘制饼状图

设想你刚刚从 EconomiCorp 获得了一份工作,并且你的第一个任务是画出一个描述其用户满意度调查结果的饼状图。`results`绑定包含了一个表示调查结果的对象的数组。

```js
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π`)分割成以调查结果数量为单位的若干份,然后乘以做出相应选择的用户的个数来计算每个圆弧的角度。

```html
<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 画布的`context`对象提供了`fillText`方法和`strokeText`方法。第二个方法可以用于绘制字母轮廓,但通常情况下我们需要的是`fillText`方法。该方法使用当前的`fillColor`来填充特定文字的轮廓。

```html
<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`属性来设定文字的大小,样式和字体。本例给出了一个字体的大小和字体族名称。也可以添加`italic`或者`bold`来选择样式。

传递给`fillText`和`strokeText`的后两个参数用于指定绘制文字的位置。默认情况下,这个位置指定了文字的字符基线(`baseline`)的起始位置,我们可以将其假想为字符所站立的位置,基线不考虑`j`或`p`字母中那些向下突出的部分。你可以设置`textAlign`属性(`end`或`center`)来改变起始点的水平位置,也可以设置`textBaseline`属性(`top`、`middle`或`bottom`)来设置基线的竖直位置。

在本章末尾的练习中,我们会回顾饼状图,并解决给饼状图分片标注的问题。

## 图像

计算机图形学领域经常将矢量图形和位图图形分开来讨论。本章一直在讨论第一种图形,即通过对图形的逻辑描述来绘图。而位图则相反,不需要设置实际图形,而是通过处理像素数据来绘制图像(光栅化的着色点)。

我们可以使用`drawImage`方法在画布上绘制像素值。此处的像素数值可以来自`<img>`元素,或者来自其他的画布。下例创建了一个独立的`<img>`元素,并且加载了一张图像文件。但我们无法马上使用该图片进行绘制,因为浏览器可能还没有完成图片的获取操作。为了处理这个问题,我们在图像元素上注册一个`"load"`事件处理程序并且在图片加载完之后开始绘制。

```html
<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`函数传入 9 个参数,我们可以用其绘制出一张图片的某一部分。第二个到第五个参数表示需要拷贝的源图片中的矩形区域(`x`,`y`坐标,宽度和高度),同时第六个到第九个参数给出了需要拷贝到的目标矩形的位置(在画布上)。

![](img/17-1.png)

该方法可以用于在单个图像文件中放入多个精灵(图像单元)并画出你需要的部分。

我们可以改变绘制的人物造型,来展现一段看似人物在走动的动画。

`clearRect`方法可以帮助我们在画布上绘制动画。该方法类似于`fillRect`方法,但是不同的是`clearRect`方法会将目标矩形透明化,并移除掉之前绘制的像素值,而不是着色。

我们知道每个精灵和每个子画面的宽度都是 24 像素,高度都是 30 像素。下面的代码装载了一幅图片并设置定时器(会重复触发的定时器)来定时绘制下一帧。

```html
<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>
```

`cycle`绑定用于记录角色在动画图像中的位置。每显示一帧,我们都要将`cycle`加 1,并通过取余数确保`cycle`的值在 0~7 这个范围内。我们随后使用该绑定计算精灵当前形象在图片中的`x`坐标。

## 变换

但是,如果我们希望角色可以向左走而不是向右走该怎么办?诚然,我们可以绘制另一组精灵,但我们也可以使用另一种方式在画布上绘图。

我们可以调用`scale`方法来缩放之后绘制的任何元素。该方法接受两个输入参数,第一个参数是水平缩放比例,第二个参数是竖直缩放比例。

```html
<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`,因此圆形长度变为原来的 3 倍,高度变为原来的一半。`scale`可以调整图像所有特征,包括线宽、预定拉伸或压缩。如果将缩放值设置为负值,可以将图像翻转。由于翻转发生在坐标`(0,0)`处,这意味着也会同时反转坐标系的方向。当水平缩放 –1 时,在`x`坐标为 100 的位置画出的图形会绘制在缩放之前`x`坐标为 –100 的位置。

为了翻转一张图片,只是在`drawImage`之前添加`cx.scale(–1,–1)`是没用的,因为这样会将我们的图片移出到画布之外,导致图片不可见。为了避免这个问题,我们还需要调整传递给`drawImage`的坐标,将绘制图形的`x`坐标改为 –50 而不是 0。另一个解决方案是在缩放时调整坐标轴,这样代码就不需要知道整个画布的缩放的改变。

除了`scale`方法还有一些其他方法可以影响画布里坐标系统的方法。你可以使用`rotate`方法旋转绘制完的图形,也可以使用`translate`方法移动图形。毕竟有趣但也容易引起误解的是这些变换以栈的方式工作,也就是说每个变换都会作用于前一个变换的结果之上。

如果我们沿水平方向将画布平移两次,每次移动 10 像素,那么所有的图形都会在右方 20 像素的位置重新绘制。如果我们先把坐标系的原点移动到`(50, 50)`的位置,然后旋转 20 度(大约`0.1π`弧度),此次的旋转会围绕点`(50,50)`进行。

![](img/17-2.svg)

但是如果我们先旋转 20 度,然后平移原点到`(50,50)`,此次的平移会发生在已经旋转过的坐标系中,因此会有不同的方向。变换发生顺序会影响最后的结果。

我们可以使用下面的代码,在指定的`x`坐标处竖直反转一张图片。

```html
function flipHorizontally(context, around) {
  context.translate(around, 0);
  context.scale(-1, 1);
  context.translate(-around, 0);
}
```

我们先把`y`轴移动到我们希望镜像所在的位置,然后进行镜像翻转,最后把`y`轴移动到被翻转的坐标系当中相应的位置。下面的图片解释了以上代码是如何工作的:

![](img/17-3.svg)

上图显示了通过中线进行镜像翻转前后的坐标系。对三角形编号来说明每一步。如果我们在`x`坐标为正值的位置绘制一个三角形,默认情况下它会出现在图中三角形 1 的位置。调用`filpHorizontally`首先做一个向右的平移,得到三角形 2。然后将其翻转到三角形 3 的位置。这不是它的根据给定的中线翻转之后应该在的最终位置。第二次调用`translate`方法解决了这个问题。它“去除”了最初的平移的效果,并且使三角形 4 变成我们希望的效果。

我们可以沿着特征的竖直中心线翻转整个坐标系,这样就可以画出位置为`(100,0)`处的镜像特征。

```html
<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`方法将堆栈顶部的状态弹出,并将该状态作为当前`context`对象的状态。

下面示例中的`branch`函数首先修改变换状态,然后调用其他函数(本例中就是该函数自身)继续在特定变换状态中进行绘图。

这个方法通过画出一条线段,并把坐标系的中心移动到线段的端点,然后调用自身两次,先向左旋转,接着向右旋转,来画出一个类似树一样的图形。每次调用都会减少所画分支的长度,当长度小于 8 的时候递归结束。

```html
<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`,支持第 14 章中的`DOMDisplay`的相同接口,也就是`setState`方法与`clear`方法。

这个对象需要比`DOMDisplay`多保存一些信息。该对象不仅需要使用 DOM 元素的滚动位置,还需要追踪自己的视口(viewport)。视口会告诉我们目前处于哪个关卡。最后,该对象会保存一个`filpPlayer`属性,确保即便玩家站立不动时,它面朝的方向也会与上次移动所面向的方向一致。

```js
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();
  }
}
```

`setState`方法首先计算一个新的视口,然后在适当的位置绘制游戏场景。

```js
CanvasDisplay.prototype.setState = function(state) {
  this.updateViewport(state);
  this.clearDisplay(state.status);
  this.drawBackground(state.level);
  this.drawActors(state.actors);
};
```

与`DOMDisplay`相反,这种显示风格确实必须在每次更新时重新绘制背景。 因为画布上的形状只是像素,所以在我们绘制它们之后,没有什么好方法来移动它们(或将它们移除)。 更新画布显示的唯一方法,是清除它并重新绘制场景。 我们也可能发生了滚动,这要求背景处于不同的位置。

`updateViewport`方法与`DOMDisplay`的`scrollPlayerintoView`方法相似。它检查玩家是否过于接近屏幕的边缘,并且当这种情况发生时移动视口。

```js
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(x,0)`保证了结果数值不会小于 0。同样地,`Math.min`保证了数值保持在给定范围内。

在清空图像时,我们依据游戏是获胜(明亮的颜色)还是失败(灰暗的颜色)来使用不同的颜色。

```js
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`方法中的相同技巧,遍历在当前视口中可见的所有瓦片。

```js
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`包含了描述除了玩家之外需要用到的图片。它包含了从左到右的墙上的瓦片,火山岩瓦片以及精灵硬币。

![](img/17-4.png)

背景瓦片是`20×20`像素的,因为我们将要用到`DOMDisplay`中的相同比例。因此,火山岩瓦片的偏移是 20,墙面的偏移是 0。

我们不需要等待精灵图片加载完成。调用`drawImage`时使用一幅并未加载完毕的图片不会有任何效果。因为图片仍然在加载当中,我们可能无法正确地画出游戏的前几帧。但是这不是一个严重的问题,因为我们持续更新荧幕,正确的场景会在加载完毕之后立即出现。

前面展示过的走路的特征将会被用来代替玩家。绘制它的代码需要根据玩家的当前动作选择正确的动作和方向。前 8 个子画面包含一个走路的动画。当玩家沿着地板移动时,我们根据当前时间把他围起来。我们希望每 60 毫秒切换一次帧,所以时间先除以 60。当玩家站立不动时,我们画出第九张子画面。当竖直方向的速度不为 0,从而被判断为跳跃时,我们使用第 10 张,也是最右边的子画面。

因为子画面宽度为 24 像素而不是 16 像素,会稍微比玩家的对象宽,这时为了腾出脚和手的空间,该方法需要根据某个给定的值(`playerXOverlap`)调整`x`坐标的值以及宽度值。

```js
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`方法调用,该方法负责画出游戏中的所有角色。

```js
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 的地方(放大了两倍)。

当计算角色的位置时,我们需要减掉视口的位置,因为`(0,0)`在我们的画布坐标系中代表着视口层面的左上角,而不是该关卡的左上角。我们也可以使用`translate`方法,这样可以作用于所有元素。

这个文档将新的显示屏插入`runGame`中:

```html
<body>
  <script>
    runGame(GAME_LEVELS, CanvasDisplay);
  </script>
</body>
```

## 选择图像接口

所以当你需要在浏览器中绘图时,你都可以选择纯粹的 HTML、SVG 或画布。没有唯一的最适合的且在所有动画中都是最好的方法。每个选择都有它的利与弊。

单纯的 HTML 的优点是简单。它也可以很好地与文字集成使用。SVG 与画布都可以允许你绘制文字,但是它们不会只通过一行代码来帮助你放置`text`或者包装它,在一个基于 HTML 的图像中,包含文本块更加简单。

SVG 可以被用来制造可以任意缩放而仍然清晰的图像。与 HTML 相反,它实际上是为绘图而设计的,因此更适合于此目的。

SVG 与 HTML 都会构建一个新的数据结构(DOM),它表示你的图片。这使得在绘制元素之后对其进行修改更为可能。如果你需要重复的修改在一张大图片中的一小部分,来对用户的动作进行响应或者作为动画的一部分时,在画布里做这件事情将会极其的昂贵。DOM 也可以允许我们在图片上的每一个元素(甚至在 SVG 画出的图形上)注册鼠标事件的处理器。在画布里则实现不了。

但是画布的基于像素的方法在需要绘制大量的微小元素时会有优势。它不会构建新的数据结构而是仅仅重复的在同一个像素上绘制,这使得画布在每个图形上拥有更低的消耗。

有一些效果,像在逐像素的渲染一个场景(比如,使用光线追踪)或者使用 javaScript 对一张图片进行后加工(虚化或者扭曲),只能通过基于像素的技术来进行真实的处理。在某些情况下,你可能想要将这些技术整合起来使用。比如,你可能用 SVG 或者画布画出一个图形,但是通过将一个 HTML 元素放在图片的顶端来展示像素信息。

对于一些要求低的程序来说,选择哪个接口并没有什么太大的区别。因为不需要绘制文字,处理鼠标交互或者处理大量的元素。我们在本章为游戏构建的显示屏,可以通过使用三种图像技术中的任意一种来实现。

## 本章小结

在本章中,我们讨论了在浏览器中绘制图形的技术,重点关注了`<canvas>`元素。

一个`canvas`节点代表了我们的程序可以绘制在文档中的一片区域。这个绘图动作是通过一个由`getContext`方法创建的绘图上下文对象完成的。

2D 绘图接口允许我们填充或者拉伸各种各样的图形。这个上下文的`fillStyle`属性决定了图形的填充方式。`strokeStyle`和`lineWidth`属性用来控制线条的绘制方式。

矩形与文字可以通过使用一个简单的方法调用来绘制。采用`fillRect`和`strokeRect`方法绘制矩形,同时采用`fillText`和`strokeText`方法绘制文字。要创建一个自定义的图形,我们必须首先建立一个路径。

调用`beginPath`会创建一个新的路径。很多其他的方法可以向当前的路径添加线条和曲线。比如,`lineTo`方法可以添加一条直线。当一条路径画完时,它可以被`fill`方法填充或者被`stroke`方法勾勒轮廓。

从一张图片或者另一个画布上移动像素到我们的画布上可以用`drawImage`方法实现。默认情况下,这个方法绘制了整个原图像,但是通过给它更多的参数,你可以拷贝一张图片的某一个特定的区域。我们在游戏中使用了这项技术,从包括许多动作的图像中拷贝出游戏角色的单个独立动作。

图形变换允许你向多个方向绘制图片。2D 绘制上下文拥有一个当前的可以通过`translate`、`scale`与`rotate`进行变换。这些会影响所有的后续的绘制操作。一个变换的状态可以通过`save`方法来保存,通过`restore`方法来恢复。

在一个画布上展示动画时,`clearRect`方法可以用来在重绘之前清除画布的某一部分。

## 习题

### 形状

编写一个程序,在画布上画出下面的图形。

1.  一个梯形(一个在一边比较长的矩形)

2.  一个红色的钻石(一个矩形旋转45度角)

3.  一个锯齿线

4.  一个由 100 条直线线段构成的螺旋

5.  一个黄色的星星

![](img/17-5.png)

当绘制最后两个图形时,你可以参考第 14 章中的`Math.cos`和`Math.sin`的解释,它描述了如何使用这两个函数获得圆上的坐标。

建议你为每一个图形创建一个方法,传入坐标信息,以及其他的一些参数,比如大小或者点的数量。另一种方法,可以在你的代码中硬编码,会使得你的代码变得难以阅读和修改。

```html
<canvas width="600" height="200"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");

  // Your code here.
</script>
```

### 饼状图

在本章的前部分,我们看到一个绘制饼状图的样例程序。修改这个程序,使得每个部分的名字可以被显示在相应的切片旁边。试着找到一个合适的方法来自动放置这些文字,同时也可以适用于其他数据。你可以假设分类大到足以为标签留出空间。

你可能还会需要`Math.sin`和`Math.cos`方法,像第 14 章描述的一样。

```html
<canvas width="600" height="300"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  let currentAngle = -0.5 * Math.PI;
  let centerX = 300, centerY = 150;

  // 在此循环中添加绘制切片标签的代码
  for (let result of results) {
    let sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.arc(centerX, centerY, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(centerX, centerY);
    cx.fillStyle = result.color;
    cx.fill();
  }
</script>
```


### 弹力球

使用在第 14 章和第 16 章出现的`requestAnimationFrame`方法画出一个装有弹力球的盒子。这个球匀速运动并且当撞到盒子的边缘的时候反弹。

```html
<canvas width="400" height="400"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");

  let lastTime = null;
  function frame(time) {
    if (lastTime != null) {
      updateAnimation(Math.min(100, time - lastTime) / 1000);
    }
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  function updateAnimation(step) {
    // Your code here.
  }
</script>
```

### 预处理镜像

当进行图形变换时,绘制位图图像会很慢。每个像素的位置和大小都必须进行变换,尽管将来浏览器可能会更加聪明,但这会导致绘制位图所需的时间显着增加。

在一个像我们这样的只绘制一个简单的子画面图像变换的游戏中,这个不是问题。但是如果我们需要绘制成百上千的角色或者爆炸产生的旋转粒子时,这将会成为一个问题。

思考一种方法来允许我们不需要加载更多的图片文件就可以画出一个倒置的角色,并且不需要在每一帧调用`drawImage`方法。


================================================
FILE: 18.md
================================================
## 十八、HTTP 和表单

> 原文:[HTTP and Forms](http://eloquentjavascript.net/18_http.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/)

> 通信在实质上必须是无状态的,从客户端到服务器的每个请求都必须包含理解请求所需的所有信息,并且不能利用服务器上存储的任何上下文。
> 
> Roy Fielding,《Architectural Styles and the Design of Network-based Software Architectures》

![](img/18-0.jpg)

我们曾在第 13 章中提到过超文本传输协议(HTTP),万维网中通过该协议进行数据请求和传输。在本章中会对该协议进行详细介绍,并解释浏览器中 JavaScript 访问 HTTP 的方式。

## 协议

当你在浏览器地址栏中输入`eloquentjavascript.net/18_http.html`时,浏览器会首先找到和`eloquentjavascript.net`相关的服务器的地址,然后尝试通过 80 端口建立 TCP 连接,其中 80 端口是 HTTP 的默认通信端口。如果该服务器存在并且接受了该连接,浏览器可能发送如下内容。

```http
GET /18_http.html HTTP/1.1
Host: eloquentjavascript.net
User-Agent: Your browser's name
```

然后服务器会通过同一个链接返回如下内容。

```http
HTTP/1.1 200 OK
Content-Length: 65585
Content-Type: text/html
Last-Modified: Mon, 08 Jan 2018 10:29:45 GMT

<!doctype html>
... the rest of the document
```

浏览器会选取空行之后的响应部分,也就是正文(不要与 HTML `<body>`标签混淆),并将其显示为 HTML 文档。

由客户端发出的信息叫作请求。请求的第一行如下。

```http
GET /17_http.html HTTP/1.1
```

请求中的第一个单词是请求方法。`GET`表示我们希望得到一个我们指定的资源。其他常用方式还有`DELETE`,用于删除一个资源;`PUT`用于替换资源;`POST`用于发送消息。需要注意的是服务器并不需要处理所有收到的请求。如果你随机访问一个网站并请求删除主页,服务器很有可能会拒绝你的请求。

方法名后的请求部分是所请求的资源的路径。在最简单的情况下,一个资源只是服务器中的一个文件。不过,协议并没有要求资源一定是实际文件。一个资源可以是任何可以像文件一样传输的东西。很多服务器会实时地生成这些资源。例如,如果你打开`github.com/marijnh`,服务器会在数据库中寻找名为`marijnjh`的用户,如果找到了则会为该用户的生成介绍页面。

请求的第一行中位于资源路径后面的`HTTP/1.1`用来表明所使用的 HTTP 协议的版本。

在实践中,许多网站使用 HTTP v2,它支持与版本 1.1 相同的概念,但是要复杂得多,因此速度更快。 浏览器在与给定服务器通信时,会自动切换到适当的协议版本,并且无论使用哪个版本,请求的结果都是相同的。 由于 1.1 版更直接,更易于使用,因此我们将专注于此。

服务器的响应也是以版本号开始的。版本号后面是响应状态,首先是一个三位的状态码,然后是一个可读的字符串。

```http
HTTP/1.1 200 OK
```

以 2 开头的状态码表示请求成功。以 4 开头的状态码表示请求中有错误。404 是最著名的 HTTP 状态码了,表示找不到资源。以 5 开头的状态码表示服务器端出现了问题,而请求没有问题。

请求或响应的第一行后可能会有任意个协议头,多个形如`name: value`的行表明了和请求或响应相关的更多信息。这些是示例响应中的头信息。

```http
Content-Length: 65585
Content-Type: text/html
Last-Modified: Thu, 04 Jan 2018 14:05:30 GMT
```

这些信息说明了响应文档的大小和类型。在这个例子中,响应是一个 65585 字节的 HTML 文档,同时也说明了该文档最后的更改时间。

多数大多数协议头,客户端或服务器可以自由决定需要在请求或响应中包含的协议头,不过也有一些协议头是必需的。例如,指明主机名的`Host`头在请求中是必须的,因为一个服务器可能在一个 IP 地址下有多个主机名服务,如果没有`Host`头,服务器则无法判断客户端尝试请求哪个主机。

请求和响应可能都会在协议头后包含一个空行,后面则是消息体,包含所发送的数据。`GET`和`DELETE`请求不单独发送任何数据,但`PUT`和`POST`请求则会。同样地,一些响应类型(如错误响应)不需要有消息体。

## 浏览器和 HTTP

正如上例所示,当我们在浏览器地址栏输入一个 URL 后浏览器会发送一个请求。当 HTML 页面中包含有其他的文件,例如图片和 JavaScript 文件时,浏览器也会一并获取这些资源。

一个较为复杂的网站通常都会有 10 到 200 个不等的资源。为了可以很快地取得这些资源,浏览器会同时发送多个`GET`请求,而不是一次等待一个请求。此类文档都是通过`GET`方法来获取的。

HTML页面可能包含表单,用户可以在表单中填入一些信息然后由浏览器将其发送到服务器。如下是一个表单的例子。

```html
<form method="GET" action="example/message.html">
  <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`(或省略)时,表单中的信息将作为查询字符串添加到`action` URL 的末尾。 浏览器可能会向此 URL 发出请求:

```http
GET /example/message.html?name=Jean&message=Yes%3F HTTP/1.1
```

问号表示路径的末尾和查询字符串的起始。后面是多个名称和值,这些名称和值分别对应`form`输入字段中的`name`属性和这些元素的内容。`&`字符用来分隔不同的名称对。

在这个 URL 中,经过编码的消息实际原本是`"Yes?"`,只不过浏览器用奇怪的代码替换了问号。我们必须替换掉请求字符串中的一些字符。使用`%3F`替换的问号就是其中之一。这样看,似乎有一个不成文的规定,每种格式都会有自己的转义字符。这里的编码格式叫作 URL 编码,使用一个百分号和16进制的数字来对字符进行编码。在这个例子中,3F(十进制为 63)是问号字符的编码。JavaScript 提供了`encodeURIComponent`和`decodeURIComponent`函数来按照这种格式进行编码和解码。

```js
console.log(encodeURIComponent("Yes?"));
// → Yes%3F
console.log(decodeURIComponent("Yes%3F"));
// → Yes?
```

如果我们将本例 HTML 表单中的`method`属性更改为`POST`,则浏览器会使用`POST`方法发送该表单,并将请求字符串放到请求正文中,而不是添加到 URL 中。

```http
POST /example/message.html 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 可以通过`fetch`接口生成 HTTP 请求。 由于它比较新,所以它很方便地使用了`Promise`(这在浏览器接口中很少见)。

```js
fetch("example/data.txt").then(response => {
  console.log(response.status);
  // → 200
  console.log(response.headers.get("Content-Type"));
  // → text/plain
});
```

调用`fetch`返回一个`Promise`,它解析为一个`Response`对象,该对象包含服务器响应的信息,例如状态码和协议头。 协议头被封装在类`Map`的对象中,该对象不区分键(协议头名称)的大小写,因为协议头名称不应区分大小写。 这意味着`header.get("Content-Type")`和`headers.get("content-TYPE")`将返回相同的值。

请注意,即使服务器使用错误代码进行响应,由`fetch`返回的`Promise`也会成功解析。 如果存在网络错误或找不到请求的服务器,它也可能被拒绝。

`fetch`的第一个参数是请求的 URL。 当该 URL 不以协议名称(例如`http:`)开头时,它被视为相对路径,这意味着它解释为相对于当前文档的路径。 当它以斜线(`/`)开始时,它将替换当前路径,即服务器名称后面的部分。 否则,当前路径直到并包括最后一个斜杠的部分,放在相对 URL 前面。

为了获取响应的实际内容,可以使用其`text`方法。 由于初始`Promise`在收到响应头文件后立即解析,并且读取响应正文可能需要一段时间,这又会返回一个`Promise`。

```js
fetch("example/data.txt")
  .then(resp => resp.text())
  .then(text => console.log(text));
// → This is the content of data.txt
```

有一种类似的方法,名为`json`,它返回一个`Promise`,它将解析为,将正文解析为 JSON 时得到的值,或者不是有效的 JSON,则被拒绝。

默认情况下,`fetch`使用`GET`方法发出请求,并且不包含请求正文。 你可以通过传递一个带有额外选项的对象作为第二个参数,来进行不同的配置。 例如,这个请求试图删除`example/data.txt`。

```js
fetch("example/data.txt", {method: "DELETE"}).then(resp => {
  console.log(resp.status);
  // → 405
});
```

405 状态码意味着“方法不允许”,这是 HTTP 服务器说“我不能这样做”的方式。

为了添加一个请求正文,你可以包含`body`选项。 为了设置标题,存在`headers`选项。 例如,这个请求包含`Range`协议,它指示服务器只返回一部分响应。

```js
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 请求来保护我们。

在构建希望因合法原因访问多个域的系统时,这可能是一个恼人的问题。 幸运的是,服务器可以在响应中包含这样的协议头,来明确地向浏览器表明,请求可以来自另一个域:

```http
Access-Control-Allow-Origin: *
```

## 运用 HTTP

当构建一个需要让浏览器(客户端)的 JavaScript 程序和服务器端的程序进行通信的系统时,有一些不同的方式可以实现这个功能。

一个常用的方法是远程过程调用,通信遵从正常的方法调用方式,不过调用的方法实际运行在另一台机器中。调用包括向服务器发送包含方法名和参数的请求。响应的结果则包括函数的返回值。

当考虑远程过程调用时,HTTP 只是通信的载体,并且你很可能会写一个抽象层来隐藏细节。

另一个方法是使用一些资源和 HTTP 方法来建立自己的通信。不同于远程调用方法`addUser`,你需要发送一个`PUT`请求到`users/larry`,不同于将用户属性进行编码后作为参数传递,你定义了一个 JSON 文档格式(或使用一种已有的格式)来展示一个用户。`PUT`请求的正文则只是这样的一个用来建立新资源的文档。由`GET`方法获取的资源则是自愿的 URL(例如,`/users/larry`),该 URL 返回代表这个资源的文档。

第二种方法使用了 HTTP 的一些特性,所以使得整体更简洁。例如对于资源缓存的支持(在客户端存一份副本用于快速访问)。HTTP 中使用的概念设计良好,可以提供一组有用的原则来设计服务器接口。

## 安全和 HTTPS

通过互联网传播的数据,往往走过漫长而危险的道路。 为了到达目的地,它必须跳过任何东西,从咖啡店的 Wi-Fi 到由各个公司和国家管理的网络。 在它的路线上的任何位置,它都可能被探测或者甚至被修改。

如果对某件事保密是重要的,例如你的电子邮件帐户的密码,或者它到达目的地而未经修改是重要的,例如帐户号码,你使用它在银行网站上转账,纯 HTTP 就不够好了。

安全的 HTTP 协议,其 URL 以`https://`开头,是一种难以阅读和篡改的,HTTP 流量的封装方式。 在交换数据之前,客户端证实该服务器是它所声称的东西,通过要求它证明,它具有由浏览器承认的证书机构所颁发的证书。 接下来,通过连接传输的所有数据,都将以某种方式加密,它应该防止窃听和篡改。

因此,当 HTTPS 正常工作时,它可以阻止某人冒充你想要与之通话的网站,以及某人窥探你的通信。 这并不完美,由于伪造或被盗的证书和损坏的软件,存在各种 HTTPS 失败的事故,但它比纯 HTTP 更安全。

## 表单字段

表单最初是为 JavaScript 之前的网页设计的,允许网站通过 HTTP 请求发送用户提交的信息。 这种设计假定与服务器的交互,总是通过导航到新页面实现。

但是它们的元素是 DOM 的一部分,就像页面的其他部分一样,并且表示表单字段的 DOM 元素,支持许多其他元素上不存在的属性和事件。 这些使其可以使用 JavaScript 程序检查和控制这些输入字段,以及可以执行一些操作,例如向表单添加新功能,或在 JavaScript 应用程序中使用表单和字段作为积木。

一个网页表单在其`<form>`标签中包含若干个输入字段。HTML 允许多个的不同风格的输入字段,从简单的开关选择框到下拉菜单和进行输入的字段。本书不会全面的讨论每一个输入字段类型,不过我们会先大概讲述一下。

很多字段类型都使用`<input>`标签。标签的`type`属性用来选择字段的种类,下面是一些常用的`<input>`类型。

+   `text`:一个单行的文本输入框。

+   `password`:和`text`相同但隐藏了输入内容。

+   `checkbox`:一个复选框。

+   `radio`:一个多选择字段中的一个单选框。

+   `file`:允许用户从本机选择文件上传。

表单字段并不一定要出现在`<form>`标签中。你可以把表单字段放置在一个页面的任何地方。但这样不带表单的字段不能被提交(一个完整的表单才可以),当需要和 JavaScript 进行响应时,我们通常也不希望按常规的方式提交表单。

```html
<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="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>
```

这些元素的 JavaScript 接口和元素类型不同。

多行文本输入框有其自己的标签`<textarea>`,这样做是因为通过一个属性来声明一个多行初始值会十分奇怪。`<textarea>`要求有一个相匹配的`</textarea>`结束标签并使用标签之间的文本作为初始值,而不是使用`value`属性存储文本。

```html
<textarea>
one
two
three
</textarea>
```

`<select>`标签用来创造一个可以让用户从一些提前设定好的选项中进行选择的字段。

```html
<select>
  <option>Pancakes</option>
  <option>Pudding</option>
  <option>Ice cream</option>
</select>
```

当一个表单字段中的内容更改时会触发`change`事件。

## 聚焦

不同于 HTML 文档中的其他元素,表单字段可以获取键盘焦点。当点击或以某种方式激活时,他们会成为激活的元素,并接受键盘的输入。

因此,只有获得焦点时,你才能输入文本字段。 其他字段对键盘事件的响应不同。 例如,`<select>`菜单尝试移动到包含用户输入文本的选项,并通过向上和向下移动其选项来响应箭头键。

我们可以通过使用 JavaScript 的`focus`和`blur`方法来控制聚焦。第一个会聚焦到某一个 DOM 元素,第二个则使其失焦。在`document.activeElement`中的值会关联到当前聚焦的元素。

```html
<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 键来切换焦点。通过`tabindex`属性可以改变元素接受焦点的顺序。后面的例子会让焦点从文本输入框跳转到 OK 按钮而不是到帮助链接。

```html
<input type="text" tabindex=1> <a href=".">(help)</a>
<button onclick="console.log('ok')" tabindex=2>OK</button>
```

默认情况下,多数的 HTML 元素不能拥有焦点。但是可以通过添加`tabindex`属性使任何元素可聚焦。`tabindex`为 -1 使 TAB 键跳过元素,即使它通常是可聚焦的。

## 禁用字段

所有的表单字段都可以通过其`disable`属性来禁用。它是一个可以被指定为没有值的属性 - 事实上它出现在所有禁用的元素中。

```html
<button>I'm all right</button>
<button disabled>I'm out</button>
```

禁用的字段不能拥有焦点或更改,浏览器使它们变成灰色。

当一个程序在处理一些由按键或其他控制方式出发的事件,并且这些事件可能要求和服务器的通信时,将元素禁用直到动作完成可能是一个很好的方法。按照这用方式,当用户失去耐心并且再次点击时,不会意外的重复这一动作。

## 作为整体的表单

当一个字段被包含在`<form>`元素中时,其 DOM 元素会有一个`form`属性指向`form`的 DOM 元素。`<form>`元素则会有一个叫作`elements`属性,包含一个类似于数据的集合,其中包含全部的字段。

一个表单字段的`name`属性会决定在`form`提交时其内容的辨别方式。同时在获取`form`的`elements`属性时也可以作为一种属性名,所以`elements`属性既可以像数组(由编号来访问)一样使用也可以像映射一样访问(通过名字访问)。

```html
<form action="example/submit.html">
  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>
```

`type`属性为`submit`的按钮在点击时,会提交表单。在一个`form`拥有焦点时,点击`enter`键也会有同样的效果。

通常在提交一个表单时,浏览器会将页面导航到`form`的`action`属性指明的页面,使用`GET`或`POST`请求。但是在这些发生之前,`"submit"`事件会被触发。这个事件可以由 JavaScript 处理,并且处理器可以通过调用事件对象的`preventDefault`来禁用默认行为。

```html
<form action="example/submit.html">
  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`将其发送到服务器而不重新加载页面。

## 文本字段

由`type`属性为`text`或`password`的`<input>`标签和`textarea`标签组成的字段有相同的接口。其 DOM 元素都有一个`value`属性,保存了为字符串格式的当前内容。将这个属性更改为另一个值将改变字段的内容。

文本字段`selectionStart`和`selectEnd`属性包含光标和所选文字的信息。当没有选中文字时,这两个属性的值相同,表明当前光标的信息。例如,0 表示文本的开始,10 表示光标在第十个字符之后。当一部分字段被选中时,这两个属性值会不同,表明选中文字开始位置和结束位置。

和正常的值一样,这些属性也可以被更改。

想象你正在编写关于 Knaseknemwy 的文章,但是名字拼写有一些问题,后续代码将`<textarea>`标签和一个事件处理器关联起来,当点击`F2`时,插入 Knaseknemwy。

```html
<textarea></textarea>
<script>
  let textarea = document.querySelector("textarea");
  textarea.addEventListener("keydown", event => {
    // The key code for F2 happens to be 113
    if (event.keyCode == 113) {
      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`事件注册一个处理器,每当用户有输入或更改时就被触发。

下面的例子展示一个文本字段和一个展示字段中的文字的当前长度的计数器。

```html
<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`属性来获取和更改。

```html
<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`属性,隐式关联其他几个单选框,保证只能选择其中一个。

```html
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>`标签将允许用户选择任意数量的选项,而不仅仅是一个选项。 在大多数浏览器中,这会显示与正常的选择字段不同的效果,后者通常显示为下拉控件,仅在你打开它时才显示选项。

每一个`<option>`选项会有一个值,这个值可以通过`value`属性来定义。如果没有提供,选项内的文本将作为其值。`<select>`的`value`属性反映了当前的选中项。对于一个多选字段,这个属性用处不太大因为该属性只会给出一个选中项。

`<select>`字段的`<option>`标签可以通过一个类似于数组对象的`options`属性访问到。每个选项会有一个叫作`selected`的属性,来表明这个选项当前是否被选中。这个属性可以用来被设定选中或不选中。

这个例子会从多选字段中取出选中的数值,并使用这些数值构造一个二进制数字。按住`CTRL`(或 Mac 的`COMMAND`键)来选择多个选项。

```html
<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 程序中读取文件。该字段则作为一个看门人角色。脚本不能简单地直接从用户的电脑中读取文件,但是如果用户在这个字段中选择了一个文件,浏览器会将这个行为解释为脚本,便可以访问该文件。

一个文本字段是一个类似于“选择文件”或“浏览”标签的按钮,后面跟着所选文件的信息。

```html
<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`属性。有时文本字段可以上传多个文件,这使得同时选择多个文件变为可能。

`files`对象中的对象有`name`(文件名)、`size`(文件大小,单位为字节),和`type`(文件的媒体类型,如`text/plain`,`image/jpeg`)等属性。

而`files`属性中不包含文件内容的属性。获取这个内容会比较复杂。由于从硬盘中读取文件会需要一些时间,接口必须是异步的,来避免文档的无响应问题。

```html
<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`对象实现的,注册一个`load`事件处理器,然后调用`readAsText`方法,传入我们希望读取的文件,一旦载入完成,`reader`的`result`属性内容就是文件内容。

`FileReader`对象还会在读取文件失败时触发`error`事件。错误对象本身会存在`reader`的`error`属性中。这个接口是在`Promise`成为语言的一部分之前设计的。 你可以把它包装在`Promise`中,像这样:

```js
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 绑定因为每当页面关闭时这些值就会丢失。你可以搭建一个服务器,连接到因特网,将一些服务数据存储到其中。在第20章中将会介绍如何实现这些,当然这需要很多的工作,也有一定的复杂度。有时只要将数据存储在浏览器中即可。

`localStorage`对象可以用于保存数据,它在页面重新加载后还存在。这个对象允许你将字符串存储在某个名字(也是字符串)下,下面是具体示例。

```js
localStorage.setItem("username", "marijn");
console.log(localStorage.getItem("username"));
// → marijn
localStorage.removeItem("username");
```

一个在`localStorage`中的值会保留到其被重写时,它也可以通过`removeItem`来清除,或者由用户清除本地数据。

不同字段名的站点的数据会存在不同的地方。这也表明原则上由`localStorage`存储的数据只可以由相同站点的脚本编辑。

浏览器的确限制一个站点可以存储的`localStorage`的数据大小。这种限制,以及用垃圾填满人们的硬盘并不是真正有利可图的事实,防止该特性占用太多空间。

下面的代码实现了一个粗糙的笔记应用。程序将用户的笔记保存为一个对象,将笔记的标题和内容字符串相关联。对象被编码为 JSON 格式并存储在`localStorage`中。用户可以从`<select>`选择字段中选择笔记并在`<textarea>`中编辑笔记,并可以通过点击一个按钮来添加笔记。

```html
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", () => {
    setState({
      notes: Object.assign({}, state.notes,
                           {[state.selected]: note.value}),
      selected: state.selected
    });
  });

  document.querySelector("button")
    .addEventListener("click", () => {
      let name = prompt("Note name");
      if (name) setState({
        notes: Object.assign({}, state.notes, {[name]: ""}),
        selected: name
      });
    });
</script>
```

脚本从存储在`localStorage`中的`"Notes"`值来获取它的初始状态,如果其中没有值,它会创建示例状态,仅仅带有一个购物列表。从`localStorage`中读取不存在的字段会返回`null`。

`setState`方法确保 DOM 显示给定的状态,并将新状态存储到`localStorage`。 事件处理器调用这个函数来移动到一个新状态。

在这个例子中使用`Object.assign`,是为了创建一个新的对象,它是旧的`state.notes`的一个克隆,但是添加或覆盖了一个属性。 `Object.assign`选取第一个参数,向其添加所有更多参数的所有属性。 因此,向它提供一个空对象会使它填充一个新对象。 第三个参数中的方括号表示法,用于创建名称基于某个动态值的属性。

还有另一个和`localStorage`很相似的对象叫作`sessionStorage`。这两个对象之间的区别在于`sessionStorage`的内容会在每次会话结束时丢失,而对于多数浏览器来说,会话会在浏览器关闭时结束。

## 本章小结

在本章中,我们讨论了 HTTP 协议的工作原理。 客户端发送一个请求,该请求包含一个方法(通常是`GET`)和一个标识资源的路径。 然后服务器决定如何处理请求,并用状态码和响应正文进行响应。 请求和响应都可能包含提供附加信息的协议头。

浏览器 JavaScript 可以通过`fetch`接口生成 HTTP 请求。 像这样生成请求:

```js
fetch("/18_http.html").then(r => r.text()).then(text => {
  console.log(`The page starts with ${text.slice(0, 15)}`);
});
```

浏览器生成`GET`请求来获取显示网页所需的资源。 页面也可能包含表单,这些表单允许在提交表单时,用户输入的信息发送为新页面的请求。

HTML可以表示多种表单字段,例如文本字段、选择框、多选字段和文件选取。

这些字段可以用 JavaScript 进行控制和读取。内容改变时会触发`change`事件,文本有输入时会触发`input`事件,键盘获得焦点时触发键盘事件。 例如`"value"`(用于文本和选择字段)或`"checked"`(用于复选框和单选按钮)的属性,用于读取或设置字段的内容。

当一个表单被提交时,会触发其`submit`事件,JavaScript 处理器可以通过调用`preventDefault`来禁用默认的提交事件。表单字段的元素不一定需要被包装在`<form>`标签中。

当用户在一个文件选择字段中选择了本机中的一个文件时,可以用`FileReader`接口来在 JavaScript 中获取文件内容。

`localStorage`和`sessionStorage`对象可以用来保存页面重载后依旧保留的信息。第一个会永久保留数据(直到用户决定清除),第二个则会保存到浏览器关闭时。

## 习题

### 内容协商

HTTP 可以做的事情之一就是内容协商。 `Accept`请求头用于告诉服务器,客户端想要获得什么类型的文档。 许多服务器忽略这个协议头,但是当一个服务器知道各种编码资源的方式时,它可以查看这个协议头,并发送客户端首选的格式。

URL `eloquentjavascript.net/author`配置为响应明文,HTML 或 JSON,具体取决于客户端要求的内容。 这些格式由标准化的媒体类型`"text/plain"`,`"text/html"`和`"application/json"`标识。

发送请求来获取此资源的所有三种格式。 使用传递给`fetch`的`options`对象中的`headers`属性,将名为`Accept`的协议头设置为所需的媒体类型。

最后,请尝试请求媒体类型`"application/rainbows+unicorns"`,并查看产生的状态码。

```js
// Your code here.
```

### JavaScript 工作台

构建一个接口,允许用户输入和运行一段 JavaScript 代码。

在`<textarea>`字段旁边放置一个按钮,当按下该按钮时,使用我们在第 10 章中看到的`Function`构造器,将文本包装到一个函数中并调用它。 将函数的返回值或其引发的任何错误转换为字符串,并将其显示在文本字段下。

```html
<textarea id="code">return "hi";</textarea>
<button id="button">Run</button>
<pre id="output"></pre>

<script>
  // Your code here.
</script>
```

### Conway 的生命游戏

Conway 的生命游戏是一个简单的在网格中模拟生命的游戏,每一个细胞都可以生存或灭亡。对于每一代(回合),都要遵循以下规则:

+   任何细胞,周围有少于两个或多于三个的活着的邻居,都会死亡。

+   任意细胞,拥有两个或三个的活着的邻居,可以生存到下一代。

+   任何死去的细胞,周围有三个活着的邻居,可以再次复活。

任意一个相连的细胞都可以称为邻居,包括对角相连。

注意这些规则要立刻应用于整个网格,而不是一次一个网格。这表明邻居的数目由开始的一代决定,并且邻居在每一代时发生的变化不应该影响给定细胞新的状态。

使用任何一个你认为合适的数据结构来实现这个游戏。使用`Math.random`来随机的生成开始状态。将其展示为一个选择框组成的网格和一个生成下一代的按钮。当用户选中或取消选中一个选择框时,其变化应该影响下一代的计算。

```html
<div id="grid"></div>
<button id="next">Next generation</button>

<script>
  // Your code here.
</script>
```


================================================
FILE: 19.md
================================================
# 十九、项目:像素艺术编辑器

> 原文:[Project: A Pixel Art Editor](http://eloquentjavascript.net/19_paint.html)
> 
> 译者:[飞龙](https://github.com/wizardforcel)
> 
> 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
> 
> 自豪地采用[谷歌翻译](https://translate.google.cn/)

> 我看着眼前的许多颜色。 我看着我的空白画布。 然后,我尝试使用颜色,就像形成诗歌的词语,就像塑造音乐的音符。
> 
> Joan Miro

![](img/19-0.jpg)

前面几章的内容为你提供了构建基本的 Web 应用所需的所有元素。 在本章中,我们将实现一个。

我们的应用将是像素绘图程序,你可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 你可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:

![](img/19-1.png)

在电脑上绘画很棒。 你不需要担心材料,技能或天赋。 你只需要开始涂画。

## 组件

应用的界面在顶部显示大的`<canvas>`元素,在它下面有许多表单字段。 用户通过从`<select>`字段中选择工具,然后单击,触摸或拖动画布来绘制图片。 有用于绘制单个像素或矩形,填充区域以及从图片中选取颜色的工具。

我们将编辑器界面构建为多个组件和对象,负责 DOM 的一部分,并可能在其中包含其他组件。

应用的状态由当前图片,所选工具和所选颜色组成。 我们将建立一些东西,以便状态存在于单一的值中,并且界面组件总是基于当前状态下他们看上去的样子。

为了明白为什么这很重要,让我们考虑替代方案:将状态片段分配给整个界面。 直到某个时期,这更容易编写。 我们可以放入颜色字段,并在需要知道当前颜色时读取其值。

但是,我们添加了颜色选择器。它是一种工具,可让你单击图片来选择给定像素的颜色。 为了保持颜色字段显示正确的颜色,该工具必须知道它存在,并在每次选择新颜色时对其进行更新。 如果你添加了另一个让颜色可见的地方(也许鼠标光标可以显示它),你必须更新你的改变颜色的代码来保持同步。

实际上,这会让你遇到一个问题,即界面的每个部分都需要知道所有其他部分,它们并不是非常模块化的。 对于本章中的小应用,这可能不成问题。 对于更大的项目,它可能变成真正的噩梦。

所以为了在原则上避免这种噩梦,我们将对数据流非常严格。 存在一个状态,界面根据该状态绘制。 界面组件可以通过更新状态来响应用户动作,此时组件有机会与新的状态进行同步。

在实践中,每个组件的建立,都是为了在给定一个新的状态时,它还会通知它的子组件,只要这些组件需要更新。 建立这个有点麻烦。 让这个更方便是许多浏览器编程库的主要卖点。 但对于像这样的小应用,我们可以在没有这种基础设施的情况下完成。

状态更新表示为对象,我们将其称为动作。 组件可以创建这样的动作并分派它们 - 将它们给予中央状态管理函数。 该函数计算下一个状态,之后界面组件将自己更新为这个新状态。

我们正在执行一个混乱的任务,运行一个用户界面并对其应用一些结构。 尽管与 DOM 相关的部分仍然充满了副作用,但它们由一个概念上简单的主干支撑 - 状态更新循环。 状态决定了 DOM 的外观,而 DOM 事件可以改变状态的唯一方法,是向状态分派动作。

这种方法有许多变种,每个变种都有自己的好处和问题,但它们的中心思想是一样的:状态变化应该通过明确定义的渠道,而不是遍布整个地方。

我们的组件将是与界面一致的类。 他们的构造器被赋予一个状态,它可能是整个应用状态,或者如果它不需要访问所有东西,是一些较小的值,并使用它构建一个`dom`属性,也就是表示组件的 DOM。 大多数构造器还会接受一些其他值,这些值不会随着时间而改变,例如它们可用于分派操作的函数。

每个组件都有一个`setState`方法,用于将其同步到新的状态值。 该方法接受一个参数,该参数的类型与构造器的第一个参数的类型相同。

## 状态

应用状态将是一个带有图片,工具和颜色属性的对象。 图片本身就是一个对象,存储图片的宽度,高度和像素内容。 像素逐行存储在一个数组中,方式与第 6 章中的矩阵类相同,按行存储,从上到下。

```js
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`和`color`属性的对象)的数组,并创建一个覆盖这些像素的新图像。 此方法使用不带参数的`slice`来复制整个像素数组 - 切片的起始位置默认为 0,结束位置为数组的长度。

`empty `方法使用我们以前没有见过的两个数组功能。 可以使用数字调用`Array`构造器来创建给定长度的空数组。 然后`fill`方法可以用于使用给定值填充数组。 这些用于创建一个数组,所有像素具有相同颜色。

颜色存储为字符串,包含传统 CSS 颜色代码 - 一个井号(`#`),后跟六个十六进制数字,两个用于红色分量,两个用于绿色分量,两个用于蓝色分量。这是一种有点神秘而不方便的颜色编写方法,但它是 HTML 颜色输入字段使用的格式,并且可以在`canva`s绘图上下文的`fillColor`属性中使用,所以对于我们在程序中使用颜色的方式,它足够实用。

所有分量都为零的黑色写成`"#000000"`,亮粉色看起来像`#ff00ff"`,其中红色和蓝色分量的最大值为 255,以十六进制数字写为`ff`(`a`到`f`用作数字 10 到 15)。

我们将允许界面将动作分派为对象,它是属性覆盖先前状态的属性。当用户改变颜色字段时,颜色字段可以分派像`{color: field.value}`这样的对象,从这个对象可以计算出一个新的状态。

```js
function updateState(state, action) {
  return Object.assign({}, state, action);
}
```

这是相当麻烦的模式,其中`Object.assign`用于首先将状态属性添加到空对象,然后使用来自动作的属性覆盖其中的一些属性,这在使用不可变对象的 JavaScript 代码中很常见。 一个更方便的表示法处于标准化的最后阶段,也就是在对象表达式中使用三点运算符来包含另一个对象的所有属性。 有了这个补充,你可以写出`{...state, ...action}`。 在撰写本文时,这还不适用于所有浏览器。

## DOM 的构建

界面组件做的主要事情之一是创建 DOM 结构。 我们再也不想直接使用冗长的 DOM 方法,所以这里是`elt`函数的一个稍微扩展的版本。

```js
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;
}
```

这个版本与我们在第 16 章中使用的版本之间的主要区别在于,它将属性(property)分配给 DOM 节点,而不是属性(attribute)。 这意味着我们不能用它来设置任意属性(attribute),但是我们可以用它来设置值不是字符串的属性(property),比如`onclick`,可以将它设置为一个函数,来注册点击事件处理器。

这允许这种注册事件处理器的方式:

```js
<body>
  <script>
    document.body.appendChild(elt("button", {
      onclick: () => console.log("click")
    }, "The button"));
  </script>
</body>
```

## 画布

我们要定义的第一个组件是界面的一部分,它将图片显示为彩色框的网格。 该组件负责两件事:显示图片并将该图片上的指针事件传给应用的其余部分。

因此,我们可以将其定义为仅了解当前图片,而不是整个应用状态的组件。 因为它不知道整个应用是如何工作的,所以不能直接发送操作。 相反,当响应指针事件时,它会调用创建它的代码提供的回调函数,该函数将处理应用的特定部分。

```js
const scale = 10;

class PictureCanvas {
  constructor(picture, pointerDown) {
    this.dom = elt("canvas", {
      onmousedown: event => this.mouse(event, pointerDown),
      ontouchstart: event => this.touch(event, pointerDown)
    });
    drawPicture(picture, this.dom, scale);
  }
  setState(picture) {
    if (this.picture == picture) return;
    this.picture = picture;
    drawPicture(this.picture, this.dom, scale);
  }
}
```

我们将每个像素绘制成一个`10x10`的正方形,由比例常数决定。 为了避免不必要的工作,该组件会跟踪其当前图片,并且仅当将`setState`赋予新图片时才会重绘。

实际的绘图功能根据比例和图片大小设置画布大小,并用一系列正方形填充它,每个像素一个。

```js
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`回调函数,提供被点击图片坐标的像素位置。 这将用于实现鼠标与图片的交互。 回调函数可能会返回另一个回调函数,以便在按下按钮并且将指针移动到另一个像素时得到通知。

```js
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`以防止滑动。

```js
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>`字段。 当前选择的工具决定了,当用户使用指针设备与图片交互时,发生的事情。 它们作为一个对象而提供,该对象将出现在下拉字段中的名称,映射到实现这些工具的函数。 这个函数接受图片位置,当前应用状态和`dispatch`函数作为参数。 它们可能会返回一个移动处理器,当指针移动到另一个像素时,使用新位置和当前状态调用该函数。

```js
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), []));
  }
  setState(state) {
    this.state = state;
    this.canvas.setState(state.picture);
    for (let ctrl of this.controls) ctrl.setState(state);
  }
}
```

指定给`PictureCanvas`的指针处理器,使用适当的参数调用当前选定的工具,如果返回了移动处理器,使其也接收状态。

所有控件在`this.controls`中构造并存储,以便在应用状态更改时更新它们。 `reduce`的调用会在控件的 DOM 元素之间引入空格。 这样他们看起来并不那么密集。

第一个控件是工具选择菜单。 它创建`<select>`元素,每个工具带有一个选项,并设置`"change"`事件处理器,用于在用户选择不同的工具时更新应用状态。

```js
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);
  }
  setState(state) { this.select.value = state.tool; }
}
```

通过将标签文本和字段包装在`<label>`元素中,我们告诉浏览器该标签属于该字段,例如,你可以点击标签来聚焦该字段。

我们还需要能够改变颜色 - 所以让我们添加一个控件。 `type`属性为颜色的 HTML `<input>`元素为我们提供了专门用于选择颜色的表单字段。 这种字段的值始终是`"#RRGGBB"`格式(红色,绿色和蓝色分量,每种颜色两位数字)的 CSS 颜色代码。 当用户与它交互时,浏览器将显示一个颜色选择器界面。

该控件创建这样一个字段,并将其连接起来,与应用状态的`color`属性保持同步。

```js
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);
  }
  setState(state) { this.input.value = state.color; }
}
```

## 绘图工具

在我们绘制任何东西之前,我们需要实现一些工具,来控制画布上的鼠标或触摸事件的功能。

最基本的工具是绘图工具,它可以将你点击或轻触的任何像素,更改为当前选定的颜色。 它分派一个动作,将图片更新为一个版本,其中所指的像素赋为当前选定的颜色。

```js
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`函数,但也会返回它,以便在用户在图片上拖动或滑动时,再次为新的所触摸的像素调用。

为了绘制较大的形状,可以快速创建矩形。 矩形工具在开始拖动的点和拖动到的点之间画一个矩形。

```js
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;
}
```

此实现中的一个重要细节是,拖动时,矩形将从原始状态重新绘制在图片上。 这样,你可以在创建矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的原因之一 - 稍后我们会看到另一个原因。

实现洪水填充涉及更多东西。 这是一个工具,填充和指针下的像素,和颜色相同的所有相邻像素。 “相邻”是指水平或垂直直接相邻,而不是对角线。 此图片表明,在标记像素处使用填充工具时,着色的一组像素:

![](img/19-2.svg)

有趣的是,我们的实现方式看起来有点像第 7 章中的寻路代码。那个代码搜索图来查找路线,但这个代码搜索网格来查找所有“连通”的像素。 跟踪一组可能的路线的问题是类似的。

```js
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}];
  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 &&
          state.picture.pixel(x, y) == targetColor &&
          !drawn.some(p => p.x == x && p.y == y)) {
        drawn.push({x, y, color: state.color});
      }
    }
  }
  dispatch({picture: state.picture.draw(drawn)});
}
```

绘制完成的像素的数组可以兼作函数的工作列表。 对于每个到达的像素,我们必须看看任何相邻的像素是否颜色相同,并且尚未覆盖。 随着新像素的添加,循环计数器落后于绘制完成的数组的长度。 任何前面的像素仍然需要探索。 当它赶上长度时,没有剩下未探测的像素,并且该函数就完成了。

最终的工具是一个颜色选择器,它允许你指定图片中的颜色,来将其用作当前的绘图颜色。

```js
function pick(pos, state, dispatch) {
  dispatch({color: state.picture.pixel(pos.x, pos.y)});
}
```

我们现在可以测试我们的应用了!

```html
<div></div>
<script>
  let state = {
    tool: "draw",
    color: "#000000",
    picture: Picture.empty(60, 30, "#f0f0f0")
  };
  let app = new PixelEditor(state, {
    tools: {draw, fill, rectangle, pick},
    controls: [ToolSelect, ColorSelect],
    dispatch(action) {
      state = updateState(state, action);
      app.setState(state);
    }
  });
  document.querySelector("div").appendChild(app.dom);
</script>
```

## 保存和加载

当我们画出我们的杰作时,我们会想要保存它以备后用。 我们应该添加一个按钮,用于将当前图片下载为图片文件。 这个控件提供了这个按钮:

```js
class SaveButton {
  constructor(state) {
    this.picture = state.picture;
    this.dom = elt("button", {
      onclick: () => this.save()
    }, "\u{1f4be} 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();
  }
  setState(state) { this.picture = state.picture; }
}
```

组件会跟踪当前图片,以便在保存时可以访问它。 为了创建图像文件,它使用`<canvas>`元素来绘制图片(一比一的像素比例)。

`canvas`元素上的`toDataURL`方法创建一个以`data:`开头的 URL。 与`http:`和`https:`的 URL 不同,数据 URL 在 URL 中包含整个资源。 它们通常很长,但它们允许我们在浏览器中,创建任意图片的可用链接。

为了让浏览器真正下载图片,我们将创建一个链接元素,指向此 URL 并具有`download`属性。 点击这些链接后,浏览器将显示一个文件保存对话框。 我们将该链接添加到文档,模拟点击它,然后再将其删除。

你可以使用浏览器技术做很多事情,但有时候做这件事的方式很奇怪。

并且情况变得更糟了。 我们也希望能够将现有的图像文件加载到我们的应用中。 为此,我们再次定义一个按钮组件。

```js
class LoadButton {
  constructor(_, {dispatch}) {
    this.dom = elt("button", {
      onclick: () => startLoad(dispatch)
    }, "\u{1f4c1} Load");
  }
  setState() {}
}

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`对象。

```js
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`方法,允许脚本读取其像素。 所以一旦图片在画布上,我们就可以访问它并构建一个`Picture`对象。

```js
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`返回的对象的`data`属性,是一个颜色分量的数组。 对于由参数指定的矩形中的每个像素,它包含四个值,分别表示像素颜色的红色,绿色,蓝色和 alpha 分量,数字介于 0 和 255 之间。alpha 分量表示不透明度 - 当它是零时像素是完全透明的,当它是 255 时,它是完全不透明的。出于我们的目的,我们可以忽略它。

在我们的颜色符号中,为每个分量使用的两个十六进制数字,正好对应于 0 到 255 的范围 - 两个十六进制数字可以表示`16**2 = 256`个不同的数字。 数字的`toString`方法可以传入进制作为参数,所以`n.toString(16)`将产生十六进制的字符串表示。我们必须确保每个数字都占用两位数,所以十六进制的辅助函数调用`padStart`,在必要时添加前导零。

我们现在可以加载并保存了! 在完成之前剩下一个功能。

## 撤销历史

编辑过程的一半是犯了小错误,并再次纠正它们。 因此,绘图程序中的一个非常重要的功能是撤消历史。

为了能够撤销更改,我们需要存储以前版本的图片。 由于这是一个不可变的值,这很容易。 但它确实需要应用状态中的额外字段。

我们将添加`done`数组来保留图片的以前版本。 维护这个属性需要更复杂的状态更新函数,它将图片添加到数组中。

但我们不希望存储每一个更改,而是一定时间量之后的更改。 为此,我们需要第二个属性`doneAt`,跟踪我们上次在历史中存储图片的时间。

```js
function historyUpdateState(state, action) {
  if (action.undo == true) {
    if (state.done.length == 0) return state;
    return Object.assign({}, state, {
      picture: state.done[0],
      done: state.done.slice(1),
      doneAt: 0
    });
  } else if (action.picture &&
             state.doneAt < Date.now() - 1000) {
    return Object.assign({}, state, action, {
      done: [state.picture, ...state.done],
      doneAt: Date.now()
    });
  } else {
    return Object.assign({}, state, action);
  }
}
```

当动作是撤消动作时,该函数将从历史中获取最近的图片,并生成当前图片。

或者,如果动作包含新图片,并且上次存储东西的时间超过了一秒(1000 毫秒),会更新`done`和`doneAt`属性来存储上一张图片。

撤消按钮组件不会做太多事情。 它在点击时分派撤消操作,并在没有任何可以撤销的东西时禁用自身。

```js
class UndoButton {
  constructor(state, {dispatch}) {
    this.dom = elt("button", {
      onclick: () => dispatch({undo: true}),
      disabled: state.done.length == 0
    }, "⮪ Undo");
  }
  setState(state) {
    this.dom.disabled = state.done.length == 0;
  }
}
```

## 让我们绘图吧

为了建立应用,我们需要创建一个状态,一组工具,一组控件和一个分派函数。 我们可以将它们传递给`PixelEditor`构造器来创建主要组件。 由于我们需要在练习中创建多个编辑器,因此我们首先定义一些绑定。

```js
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.setState(state);
    }
  });
  return app.dom;
}
```

解构对象或数组时,可以在绑定名称后面使用`=`,来为绑定指定默认值,该属性在缺失或未定义时使用。 `startPixelEditor`函数利用它来接受一个对象,包含许多可选属性作为参数。 例如,如果你未提供`tools`属性,则`tools`将绑定到`baseTools`。

这就是我们在屏幕上获得实际的编辑器的方式:

```js
<div></div>
<script>
  document.querySelector("div")
    .appendChild(startPixelEditor({}));
</script>
```

来吧,画一些东西。 我会等着你。

## 为什么这个很困难

浏览器技术是惊人的。 它提供了一组强大的界面积木,排版和操作方法,以及检查和调试应用的工具。 你为浏览器编写的软件可以在几乎所有电脑和手机上运行。

与此同时,浏览器技术是荒谬的。 你必须学习大量愚蠢的技巧和难懂的事实才能掌握它,而它提供的默认编程模型非常棘手,大多数程序员喜欢用几层抽象来封装它,而不是直接处理它。

虽然情况肯定有所改善,但它以增加更多元素来解决缺点的方式,改善了它 - 也创造了更多复杂性。 数百万个网站使用的特性无法真正被取代。 即使可能,也很难决定它应该由什么取代。

技术从不存在于真空中 - 我们受到我们的工具,以及产生它们的社会,经济和历史因素的制约。 这可能很烦人,但通常更加有效的是,试图理解现有的技术现实如何发挥作用,以及为什么它是这样 - 而不是对抗它,或者转向另一个现实。

新的抽象可能会有所帮助。 我在本章中使用的组件模型和数据流约定,是一种粗糙的抽象。 如前所述,有些库试图使用户界面编程更愉快。 在编写本文时,React 和 Angular 是主流选择,但是这样的框架带有整个全家桶。 如果你对编写 Web 应用感兴趣,我建议调查其中的一些内容,来了解它们的原理,以及它们提供的好处。

## 练习

我们的程序还有提升空间。让我们添加一些更多特性作为练习。

### 键盘绑定

将键盘快捷键添加到应用。 工具名称的第一个字母用于选择工具,而`control-Z`或`command-Z`激活撤消工作。

通过修改`PixelEditor`组件来实现它。 为`<div>`元素包装添加`tabIndex`属性 0,以便它可以接收键盘焦点。 请注意,与`tabindex`属性对应的属性称为`tabIndex`,`I`大写,我们的`elt`函数需要属性名称。 直接在该元素上注册键盘事件处理器。 这意味着你必须先单击,触摸或按下 TAB 选择应用,然后才能使用键盘与其交互。

请记住,键盘事件具有`ctrlKey`和`metaKey`(用于 Mac 上的`Command`键)属性,你可以使用它们查看这些键是否被按下。

```html
<div></div>
<script>
  // The original PixelEditor class. Extend the constructor.
  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, dispatch);
        }
      });
      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), []));
    }
    setState(state) {
      this.state = state;
      this.canvas.setState(state.picture);
      for (let ctrl of this.controls) ctrl.setState(state);
    }
  }

  document.querySelector("div")
    .appendChild(startPixelEditor({}));
</script>
```

### 高效绘图

绘图过程中,我们的应用所做的大部分工作都发生在`drawPicture`中。 创建一个新状态并更新 DOM 的其余部分的开销并不是很大,但重新绘制画布上的所有像素是相当大的工作量。

找到一种方法,通过重新绘制实际更改的像素,使`PictureCanvas`的`setState`方法更快。

请记住,`drawPicture`也由保存按钮使用,所以如果你更改它,请确保更改不会破坏旧用途,或者使用不同名称创建新版本。

另请注意,通过设置其`width`或`height`属性来更改`<canvas>`元素的大小,将清除它,使其再次完全透明。

```html
<div></div>
<script>
  // Change this method
  PictureCanvas.prototype.setState = function(picture) {
    if (this.picture == picture) return;
    this.picture = picture;
    drawPicture(this.picture, this.dom, scale);
  };

  // You may want to use or change this as well
  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);
      }
    }
  }

  document.querySelector("div")
    .appendChild(startPixelEditor({}));
</script>
```

### 圆

定义一个名为`circle`的工具,当你拖动时绘制一个实心圆。 圆的中心位于拖动或触摸手势开始的位置,其半径由拖动的距离决定。

```html
<div></div>
<script>
  function circle(pos, state, dispatch) {
    // Your code here
  }

  let dom = startPixelEditor({
    tools: Object.assign({}, baseTools, {circle})
  });
  document.querySelector("div").appendChild(dom);
</script>
```

### 合适的直线

这是比前两个更高级的练习,它将要求你设计一个有意义的问题的解决方案。 在开始这个练习之前,确保你有充足的时间和耐心,并且不要因最初的失败而感到气馁。

在大多数浏览器上,当你选择绘图工具并快速在图片上拖动时,你不会得到一条闭合直线。 相反,由于`"mousemove"`或`"touchmove"`事件没有快到足以命中每个像素,因此你会得到一些点,在它们之间有空隙。

改进绘制工具,使其绘制完整的直线。 这意味着你必须使移动处理器记住前一个位置,并将其连接到当前位置。

为此,由于像素可以是任意距离,所以你必须编写一个通用的直线绘制函数。

两个像素之间的直线是连接像素的链条,从起点到终点尽可能直。对角线相邻的像素也算作连接。 所以斜线应该看起来像左边的图片,而不是右边的图片。

![](img/19-3.svg)

如果我们有了代码,它在两个任意点间绘制一条直线,我们不妨继续,并使用它来定义`line`工具,它在拖动的起点和终点之间绘制一条直线。

```js
<div></div>
<script>
  // The old draw tool. Rewrite this.
  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;
  }

  function line(pos, state, dispatch) {
    // Your code here
  }

  let dom = startPixelEditor({
    tools: {draw, line, fill, rectangle, pick}
  });
  document.querySelector("div").appendChild(dom);
</script>
```


================================================
FILE: 2.md
================================================
## 二、程序结构

> 原文:[Program Structure](http://eloquentjavascript.net/02_program_structure.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/)

> And my heart glows bright red under my filmy, translucent skin and they have to administer 10cc of JavaScript to get me to come back. (I respond well to toxins in the blood.) Man, that stuff will kick the peaches right out your gills!
> 
> why,《Why's (Poignant) Guide to Ruby》

![](img/2-0.jpg)

在本章中,我们开始做一些实际上称为编程的事情。 我们将扩展我们对 JavaScript 语言的掌控,超出我们目前所看到的名词和句子片断,直到我们可以表达有意义的散文。

## 表达式和语句

在第 1 章中,我们为它们创建了值,并应用了运算符来获得新的值。 像这样创建值是任何 JavaScript 程序的主要内容。 但是,这种东西必须在更大的结构中构建,才能发挥作用。 这就是我们接下来要做的。

我们把产生值的操作的代码片段称为表达式。按照字面含义编写的值(比如`22`或`"psychoanalysis"`)都是一个表达式。而括号当中的表达式、使用二元运算符连接的表达式或使用一元运算符的表达式,仍然都是表达式。

这展示了一部分基于语言的接口之美。 表达式可以包含其他表达式,其方式非常类似于人类语言的从句嵌套 - 从句可以包含它自己的从句,依此类推。 这允许我们构建描述任意复杂计算的表达式。

如果一个表达式对应一个句子片段,则 JavaScript 语句对应于一个完整的句子。 一个程序是一列语句。

最简单的一条语句由一个表达式和其后的分号组成。比如这就是一个程序:

```js
1;
!false;
```

不过,这是一个无用的程序。 表达式可以仅仅满足于产生一个值,然后可以由闭合的代码使用。 一个声明是独立存在的,所以它只有在影响到世界的时候才会成立。 它可以在屏幕上显示某些东西 - 这可以改变世界 - 或者它可以改变机器的内部状态,从而影响后面的语句。 这些变化被称为副作用。 前面例子中的语句仅仅产生值`1`和`true`,然后立即将它们扔掉。 这给世界没有留下什么印象。 当你运行这个程序时,什么都不会发生。

在某些情况下,JavaScript 允许您在语句结尾处省略分号。 在其他情况下,它必须在那里,否则下一行将被视为同一语句的一部分。 何时可以安全省略它的规则有点复杂且容易出错。 所以在本书中,每一个需要分号的语句都会有分号。 至少在你更了解省略分号的细节之前,我建议你也这样做。

## 绑定

程序如何保持内部状态? 它如何记住东西? 我们已经看到如何从旧值中产生新值,但这并没有改变旧值,新值必须立即使用,否则将会再度消失。 为了捕获和保存值,JavaScript 提供了一种称为绑定或变量的东西:

```js
let caught = 5 * 5;
```

这是第二种语句。 关键字(keyword)`let`表示这个句子打算定义一个绑定。 它后面跟着绑定的名称,如果我们想立即给它一个值,使用`=`运算符和一个表达式。

前面的语句创建一个名为`caught`的绑定,并用它来捕获乘以`5 * 5`所产生的数字。

在定义绑定之后,它的名称可以用作表达式。 这种表达式的值是绑定当前所持有的值。 这是一个例子:

```js
let ten = 10;
console.log(ten * ten);
// → 100
```

当绑定指向某个值时,并不意味着它永远与该值绑定。 可以在现有的绑定上随时使用`=`运算符,将它们与当前值断开连接,并让它们指向一个新值:

```js
var mood = "light";
console.log(mood);
// → light
mood = "dark";
console.log(mood);
// → dark
```

你应该将绑定想象为触手,而不是盒子。 他们不包含值; 他们捕获值 - 两个绑定可以引用相同的值。 程序只能访问它还在引用的值。 当你需要记住某些东西时,你需要长出一个触手来捕获它,或者你重新贴上你现有的触手之一。

我们来看另一个例子。 为了记住 Luigi 欠你的美元数量,你需要创建一个绑定。 然后当他还你 35 美元时,你赋予这个绑定一个新值:

```js
let luigisDebt = 140;
luigisDebt = luigisDebt - 35;
console.log(luigisDebt);
// → 105
```

当你定义一个绑定而没有给它一个值时,触手没有任何东西可以捕获,所以它只能捕获空气。 如果你请求一个空绑定的值,你会得到`undefined`值。

一个`let`语句可以同时定义多个绑定,定义必需用逗号分隔。

```js
let one = 1, two = 2;
console.log(one + two);
// → 3
```

`var`和`const`这两个词也可以用来创建绑定,类似于`let`。

```js
var name = "Ayda";
const greeting = "Hello ";
console.log(greeting + name);
// → Hello Ayda
```

第一个`var`(“variable”的简写)是 JavaScript 2015 之前声明绑定的方式。 我们在下一章中,会讲到它与`let`的确切的不同之处。 现在,请记住它大部分都做同样的事情,但我们很少在本书中使用它,因为它有一些令人困惑的特性。

`const`这个词代表常量。 它定义了一个不变的绑定,只要它存在,它就指向相同的值。 这对于一些绑定很有用,它们向值提供一个名词,以便之后可以很容易地引用它。

## 绑定名称

绑定名称可以是任何单词。 数字可以是绑定名称的一部分,例如`catch22`是一个有效的名称,但名称不能以数字开头。 绑定名称可能包含美元符号(`$`)或下划线(`_`),但不包含其他标点符号或特殊字符。

具有特殊含义的词,如`let`,是关键字,它们不能用作绑定名称。 在未来的 JavaScript 版本中还有一些“保留供使用”的单词,它们也不能用作绑定名称。 关键字和保留字的完整列表相当长:

```
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`包含一函数,个显示一个小对话框,请求用户输入。 它是这样使用的:

```js
prompt("Enter passcode");
```

![](img/2-1.png)

执行一个函数被称为调用,或应用它(invoke,call,apply)。您可以通过在生成函数值的表达式之后放置括号来调用函数。 通常你会直接使用持有该函数的绑定名称。 括号之间的值被赋予函数内部的程序。 在这个例子中,`prompt`函数使用我们提供的字符串作为文本来显示在对话框中。 赋予函数的值称为参数。 不同的函数可能需要不同的数量或不同类型的参数。

`prompt`函数在现代 Web 编程中用处不大,主要是因为你无法控制所得对话框的外观,但可以在玩具程序和实验中有所帮助。

## `console.log`函数

在例子中,我使用`console.log`来输出值。 大多数 JavaScript 系统(包括所有现代 Web 浏览器和 Node.js)都提供了`console.log`函数,将其参数写入一个文本输出设备。 在浏览器中,输出出现在 JavaScript 控制台中。 浏览器界面的这一部分在默认情况下是隐藏的,但大多数浏览器在您按 F12 或在 Mac 上按 Command-Option-I 时打开它。 如果这不起作用,请在菜单中搜索名为“开发人员工具”或类似的项目。

> 在英文版页面上运行示例(或自己的代码)时,会在示例之后显示`console.log`输出,而不是在浏览器的 JavaScript 控制台中显示。

```js
let x = 30;
console.log("the value of x is", x);
// → the value of x is 30
```

尽管绑定名称不能包含句号字符,但是`console.log`确实拥有。 这是因为`console.log`不是一个简单的绑定。 它实际上是一个表达式,它从`console`绑定所持有的值中检索`log`属性。 我们将在第 4 章中弄清楚这意味着什么。

## 返回值

显示对话框或将文字写入屏幕是一个副作用。 由于它们产生的副作用,很多函数都很有用。 函数也可能产生值,在这种情况下,他们不需要有副作用就有用。 例如,函数`Math.max`可以接受任意数量的参数并返回最大值。

```js
console.log(Math.max(2, 4));
// → 4
```

当一个函数产生一个值时,它被称为返回该值。 任何产生值的东西都是 JavaScript 中的表达式,这意味着可以在较大的表达式中使用函数调用。 在这里,`Math.min`的调用(与`Math.max`相反)用作加法表达式的一部分:

```js
console.log(Math.min(2, 4) + 100);
// → 102
```

我们会在下一章当中讲解如何编写自定义函数。

## 控制流

当你的程序包含多个语句时,这些语句就像是一个故事一样从上到下执行。 这个示例程序有两个语句。 第一个要求用户输入一个数字,第二个在第一个之后执行,显示该数字的平方。

```js
let theNumber = Number(prompt("Pick a number"));
console.log("Your number is the square root of " +
            theNumber * theNumber);
```


`Number`函数将一个值转换为一个数字。 我们需要这种转换,因为`prompt`的结果是一个字符串值,我们需要一个数字。 有类似的函数叫做`String`和`Boolean`,它们将值转换为这些类型。

以下是直线控制流程的相当简单的示意图:

![](img/2-2.svg)

## 条件执行

并非所有的程序都是直路。 例如,我们可能想创建一条分叉路,在那里该程序根据当前的情况采取适当的分支。 这被称为条件执行。

![](img/2-3.svg)

在 JavaScript 中,条件执行使用`if`关键字创建。 在简单的情况下,当且仅当某些条件成立时,我们才希望执行一些代码。 例如,仅当输入实际上是一个数字时,我们可能打算显示输入的平方。

```js
let theNumber = Number(prom
Download .txt
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
Condensed preview — 45 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,790K chars).
[
  {
    "path": ".gitignore",
    "chars": 16,
    "preview": "_book\nThumbs.db\n"
  },
  {
    "path": "0.md",
    "chars": 7841,
    "preview": "# 零、前言\n\n> 原文:[Introduction](https://eloquentjavascript.net/00_intro.html)\n> \n> 译者:[飞龙](https://github.com/wizardforcel)\n"
  },
  {
    "path": "1.md",
    "chars": 8994,
    "preview": "## 一、值,类型和运算符\n\n> 原文:[Values, Types, and Operators](http://eloquentjavascript.net/01_values.html)\n> \n> 译者:[飞龙](https://gi"
  },
  {
    "path": "10.md",
    "chars": 11391,
    "preview": "# 十、模块\n\n> 原文:[Modules](http://eloquentjavascript.net/10_modules.html)\n> \n> 译者:[飞龙](https://github.com/wizardforcel)\n> \n>"
  },
  {
    "path": "11.md",
    "chars": 20699,
    "preview": "# 十一、异步编程\n\n> 原文:[Asynchronous Programming](http://eloquentjavascript.net/11_async.html)\n> \n> 译者:[飞龙](https://github.com/"
  },
  {
    "path": "12.md",
    "chars": 13383,
    "preview": "## 十二、项目:编程语言\n\n> 原文:[Project: A Programming Language](https://eloquentjavascript.net/12_language.html)\n> \n> 译者:[飞龙](http"
  },
  {
    "path": "13.md",
    "chars": 7247,
    "preview": "## 十三、浏览器中的 JavaScript\n\n> 原文:[JavaScript and the Browser](https://eloquentjavascript.net/13_browser.html)\n> \n> 译者:[飞龙](h"
  },
  {
    "path": "14.md",
    "chars": 18402,
    "preview": "## 十四、文档对象模型\n\n> 原文:[The Document Object Model](https://eloquentjavascript.net/14_dom.html)\n> \n> 译者:[飞龙](https://github.c"
  },
  {
    "path": "15.md",
    "chars": 16804,
    "preview": "## 十五、处理事件\n\n> 原文:[Handling Events](https://eloquentjavascript.net/15_event.html)\n> \n> 译者:[飞龙](https://github.com/wizardf"
  },
  {
    "path": "16.md",
    "chars": 23543,
    "preview": "# 十六、项目:平台游戏\n\n> 原文:[Project: A Platform Game](https://eloquentjavascript.net/16_game.html)\n> \n> 译者:[飞龙](https://github.c"
  },
  {
    "path": "17.md",
    "chars": 21808,
    "preview": "## 十七、在画布上绘图\n\n> 原文:[Drawing on Canvas](https://eloquentjavascript.net/17_canvas.html)\n> \n> 译者:[飞龙](https://github.com/wi"
  },
  {
    "path": "18.md",
    "chars": 21230,
    "preview": "## 十八、HTTP 和表单\n\n> 原文:[HTTP and Forms](http://eloquentjavascript.net/18_http.html)\n> \n> 译者:[飞龙](https://github.com/wizard"
  },
  {
    "path": "19.md",
    "chars": 21606,
    "preview": "# 十九、项目:像素艺术编辑器\n\n> 原文:[Project: A Pixel Art Editor](http://eloquentjavascript.net/19_paint.html)\n> \n> 译者:[飞龙](https://gi"
  },
  {
    "path": "2.md",
    "chars": 13785,
    "preview": "## 二、程序结构\n\n> 原文:[Program Structure](http://eloquentjavascript.net/02_program_structure.html)\n> \n> 译者:[飞龙](https://github"
  },
  {
    "path": "20.md",
    "chars": 18229,
    "preview": "# 二十、Node.js\n\n> 原文:[Node.js](https://eloquentjavascript.net/20_node.html)\n> \n> 译者:[飞龙](https://github.com/wizardforcel)\n"
  },
  {
    "path": "21.md",
    "chars": 18514,
    "preview": "# 二十一、项目:技能分享网站\n\n> 原文:[Project: Skill-Sharing Website](http://eloquentjavascript.net/21_skillsharing.html)\n> \n> 译者:[飞龙]("
  },
  {
    "path": "3.md",
    "chars": 14284,
    "preview": "## 三、函数\n\n> 原文:[Functions](https://eloquentjavascript.net/03_functions.html)\n> \n> 译者:[飞龙](https://github.com/wizardforcel"
  },
  {
    "path": "4.md",
    "chars": 21946,
    "preview": "## 四、数据结构:对象和数组\n\n> 原文:[Data Structures: Objects and Arrays](http://eloquentjavascript.net/04_data.html)\n> \n> 译者:[飞龙](htt"
  },
  {
    "path": "5.md",
    "chars": 12982,
    "preview": "## 五、高阶函数\n\n> 原文:[Higher-Order Functions](http://eloquentjavascript.net/05_higher_order.html)\n> \n> 译者:[飞龙](https://github"
  },
  {
    "path": "6.md",
    "chars": 17925,
    "preview": "## 六、对象的秘密\n\n> 原文:[The Secret Life of Objects](http://eloquentjavascript.net/06_object.html)\n> \n> 译者:[飞龙](https://github."
  },
  {
    "path": "7.md",
    "chars": 10024,
    "preview": "# 七、项目:机器人\n\n> 原文:[Project: A Robot](http://eloquentjavascript.net/07_robot.html)\n> \n> 译者:[飞龙](https://github.com/wizardf"
  },
  {
    "path": "8.md",
    "chars": 13711,
    "preview": "## 八、Bug 和错误\n\n> 原文:[Bugs and Errors](http://eloquentjavascript.net/08_error.html)\n> \n> 译者:[飞龙](https://github.com/wizard"
  },
  {
    "path": "9.md",
    "chars": 21650,
    "preview": "## 九、正则表达式\n\n> 原文:[Regular Expressions](https://eloquentjavascript.net/09_regexp.html)\n> \n> 译者:[飞龙](https://github.com/wi"
  },
  {
    "path": "README.md",
    "chars": 748,
    "preview": "# JavaScript 编程精解 中文第三版\n\n原书:[Eloquent JavaScript 3rd edition](http://eloquentjavascript.net/)\n\n译者:[飞龙](https://github.co"
  },
  {
    "path": "SUMMARY.md",
    "chars": 485,
    "preview": "+ [JavaScript 编程精解 中文第三版](README.md)\n+ [零、前言](0.md)\n+ [一、值,类型和运算符](1.md)\n+ [二、程序结构](2.md)\n+ [三、函数](3.md)\n+ [四、数据结构:对象和数组"
  },
  {
    "path": "diff-en/2ech0-3ech0.diff",
    "chars": 34918,
    "preview": "diff --git a/2e0.md b/3e0.md\nindex 4c2afa2..778a30e 100644\n--- a/2e0.md\n+++ b/3e0.md\n@@ -1,46 +1,48 @@\n # Introduction\n "
  },
  {
    "path": "diff-en/2ech1-3ech1.diff",
    "chars": 30187,
    "preview": "diff --git a/2ech1.md b/3ech1.md\nindex 33d8537..57e5b20 100644\n--- a/2ech1.md\n+++ b/3ech1.md\n@@ -4,28 +4,26 @@\n > \n > &l"
  },
  {
    "path": "diff-en/2ech11-3ech12.diff",
    "chars": 43481,
    "preview": "diff --git a/2ech11.md b/3ech12.md\nindex 4a9b140..3167c30 100644\n--- a/2ech11.md\n+++ b/3ech12.md\n@@ -1,26 +1,22 @@\n-# Ch"
  },
  {
    "path": "diff-en/2ech12-3ech13.diff",
    "chars": 25308,
    "preview": "diff --git a/2ech12.md b/3ech13.md\nindex e9028ff..3565983 100644\n--- a/2ech12.md\n+++ b/3ech13.md\n@@ -1,30 +1,36 @@\n-# Ch"
  },
  {
    "path": "diff-en/2ech13-3ech14.diff",
    "chars": 63673,
    "preview": "diff --git a/2ech13.md b/3ech14.md\nindex 2f8209b..6081ee9 100644\n--- a/2ech13.md\n+++ b/3ech14.md\n@@ -1,12 +1,16 @@\n-# Ch"
  },
  {
    "path": "diff-en/2ech14-3ech15.diff",
    "chars": 61239,
    "preview": "diff --git a/2ech14.md b/3ech15.md\nindex 311ca63..8bb4de0 100644\n--- a/2ech14.md\n+++ b/3ech15.md\n@@ -1,57 +1,59 @@\n-# Ch"
  },
  {
    "path": "diff-en/2ech15-3ech16.diff",
    "chars": 79486,
    "preview": "diff --git a/2ech15.md b/3ech16.md\nindex 1007a10..560b459 100644\n--- a/2ech15.md\n+++ b/3ech16.md\n@@ -1,196 +1,246 @@\n-# "
  },
  {
    "path": "diff-en/2ech16-3ech17.diff",
    "chars": 64214,
    "preview": "diff --git a/2ech16.md b/3ech17.md\nindex b3a1f00..4814dc7 100644\n--- a/2ech16.md\n+++ b/3ech17.md\n@@ -1,20 +1,20 @@\n-# Ch"
  },
  {
    "path": "diff-en/2ech17-3ech18a.diff",
    "chars": 47365,
    "preview": "diff --git a/2ech17.md b/3ech18a.md\nindex 3cea885..07ae257 100644\n--- a/2ech17.md\n+++ b/3ech18a.md\n@@ -1,17 +1,17 @@\n-# "
  },
  {
    "path": "diff-en/2ech18-3ech18b.diff",
    "chars": 46079,
    "preview": "diff --git a/2ech18.md b/3ech18b.md\nindex 83a169e..a26468a 100644\n--- a/2ech18.md\n+++ b/3ech18b.md\n@@ -1,19 +1,10 @@\n-# "
  },
  {
    "path": "diff-en/2ech2-3ech2.diff",
    "chars": 56492,
    "preview": "diff --git a/2ech2.md b/3ech2.md\nindex 645a197..80cf5d8 100644\n--- a/2ech2.md\n+++ b/3ech2.md\n@@ -4,17 +4,17 @@\n > \n > &l"
  },
  {
    "path": "diff-en/2ech20-3ech20.diff",
    "chars": 80056,
    "preview": "diff --git a/2ech20.md b/3ech20.md\nindex 2d2405b..5629452 100644\n--- a/2ech20.md\n+++ b/3ech20.md\n@@ -4,48 +4,28 @@\n > \n "
  },
  {
    "path": "diff-en/2ech21-3ech21.diff",
    "chars": 74117,
    "preview": "diff --git a/2ech21.md b/3ech21.md\nindex 060c8aa..f79f1fa 100644\n--- a/2ech21.md\n+++ b/3ech21.md\n@@ -1,61 +1,60 @@\n # Ch"
  },
  {
    "path": "diff-en/2ech3-3ech3.diff",
    "chars": 57294,
    "preview": "diff --git a/2ech3.md b/3ech3.md\nindex e6afba9..4dd4357 100644\n--- a/2ech3.md\n+++ b/3ech3.md\n@@ -4,18 +4,18 @@\n > \n > &l"
  },
  {
    "path": "diff-en/2ech4-3ech4.diff",
    "chars": 84104,
    "preview": "diff --git a/2ech4.md b/3ech4.md\nindex 351db88..a3967fd 100644\n--- a/2ech4.md\n+++ b/3ech4.md\n@@ -4,110 +4,111 @@\n > \n > "
  },
  {
    "path": "diff-en/2ech5-3ech5.diff",
    "chars": 53908,
    "preview": "diff --git a/2ech5.md b/3ech5.md\nindex 7d19300..3a03670 100644\n--- a/2ech5.md\n+++ b/3ech5.md\n@@ -1,6 +1,6 @@\n # Chapter "
  },
  {
    "path": "diff-en/2ech6-3ech6.diff",
    "chars": 71698,
    "preview": "diff --git a/2ech6.md b/3ech6.md\nindex cc81bf0..9f69b59 100644\n--- a/2ech6.md\n+++ b/3ech6.md\n@@ -1,90 +1,96 @@\n # Chapte"
  },
  {
    "path": "diff-en/2ech8-3ech8.diff",
    "chars": 52317,
    "preview": "diff --git a/2ech8.md b/3ech8.md\nindex bcf3eba..7d57bed 100644\n--- a/2ech8.md\n+++ b/3ech8.md\n@@ -1,28 +1,20 @@\n-# Chapte"
  },
  {
    "path": "diff-en/2ech9-3ech9.diff",
    "chars": 56912,
    "preview": "diff --git a/2ech9.md b/3ech9.md\nindex cbbeb4e..7a67f2e 100644\n--- a/2ech9.md\n+++ b/3ech9.md\n@@ -4,37 +4,35 @@\n > \n > &l"
  },
  {
    "path": "styles/ebook.css",
    "chars": 5513,
    "preview": "/* GitHub stylesheet for MarkdownPad (http://markdownpad.com) */\n/* Author: Nicolas Hery - http://nicolashery.com */\n/* "
  }
]

About this extraction

This page contains the full source code of the wizardforcel/eloquent-js-3e-zh GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 45 files (1.4 MB), approximately 427.0k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!