JavaScript-速成课-全-

JavaScript 速成课(全)

原文:zh.annas-archive.org/md5/6a4346e9e03fdb00885748ac4606a68e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我清晰地记得第一次“编写”JavaScript 的情景。我当时一直在玩网页设计,复制粘贴了一些代码到网站上,当鼠标悬停在页面的某些部分时,会出现一些奇怪的效果。我完全不知道这些代码在做什么,但通过反复试验,我成功地让它工作了。

JavaScript 是一种非常宽容的语言——有时甚至到有些过度——这使得它成为我和无数人入门编程时的一个非常温和的选择。许多人选择 JavaScript 作为第一个编程语言,就是因为它入门简单:它就在你的网页浏览器里等着你!

本书适合谁阅读?

本书的目标是尽可能快速地让你开始编写真正的 JavaScript 代码,教授所有基础知识,而不纠结于语言的复杂性。我不期望你有任何编程经验。如果你已经习惯使用电脑进行日常任务,比如浏览互联网和编辑文档,那么你已经具备了开始学习的所有条件。

JavaScript 速成课程是为所有年龄段的个人编写的,旨在通过动手实例和项目帮助学习者独立学习 JavaScript。也许你想转行做计算机编程,或者你希望将编程作为一种爱好。也许你曾经使用过其他编程语言,现在需要掌握 JavaScript。如果你是老师,并且在寻找一种简单的方式来向学生介绍编程,JavaScript 速成课程也是一个很好的选择。

为什么选择 JavaScript?

编程语言有成百上千种,但有几个方面使 JavaScript 与众不同。最重要的是它与网页浏览器的关系,例如 Google Chrome、Safari、Microsoft Edge 和 Firefox。几乎每个网页浏览器都能运行 JavaScript,这意味着你用 JavaScript 编写的代码可以在任何有网页浏览器的计算机上运行。无需安装任何额外的特殊软件。几乎所有智能手机的网页浏览器也能运行 JavaScript,所以你现在可能已经拥有一款支持 JavaScript 的浏览器。

由于与网页浏览器的关系,JavaScript 是网页开发中一个极其重要的部分。如果一个网站包含动态、互动的功能,那它很可能是用 JavaScript 创建的。例如,YouTube 使用 JavaScript 在你悬停视频缩略图时显示预览,Threads 使用 JavaScript 在你滚动页面时加载更多帖子,而 Amazon 使用 JavaScript 来驱动其“Look Inside”功能。

除了在网页浏览器中的应用,JavaScript 还在网站的后端广泛使用,后端是指在服务器上运行的代码部分,用于向用户提供内容(与直接在用户设备上运行的前端代码相对)。这是通过一种名为 Node.js 的技术实现的。许多顶级网站使用 Node.js 后端,让你可以使用相同的语言进行前端和后端开发,甚至在两者之间共享代码。

最终,JavaScript 已成为一种非常流行的脚本语言,广泛应用于各种场景,从 Photoshop(你可以自动化图像处理)到 Gmail(你可以添加自动化功能来整理电子邮件)。有了 JavaScript 的基础知识,你就可以任意操控这些应用!

尽管该语言在这些领域都很有用,但本书将专注于基于浏览器的 JavaScript。之所以如此,有几个原因。首先,正如我之前提到的,运行 JavaScript 在浏览器中的一个巨大优势是,你无需安装任何特别的东西就可以开始。我不想让本书从一章关于在你电脑上安装 Node.js 的冗长内容开始——这章内容一写完就可能会过时。其次,虽然几乎所有网站都使用 JavaScript 进行前端开发,但 JavaScript 只是许多编写后端代码的可能语言之一。浏览器无疑是学习 JavaScript 最具普适性的环境。

在你完成本书并掌握一定的 JavaScript 经验后,我仍然强烈推荐你去了解 Node.js 和其他 JavaScript 的应用。你应该将本书视为一个跳板:作为你 JavaScript 学习的起点,而非终点。有关完成本书后该如何继续学习的更多信息,请参见后记。

你可以期待学到什么?

本书将教授你基于浏览器的 JavaScript。除了学习 JavaScript 语言本身,你还将学习一些能帮助你掌握任何编程语言的技能,比如如何思考问题和如何结构化程序。你将建立起一套编程知识的基础,这些知识将伴随你在职业和个人编码道路上的成长。

本书的第一部分介绍了语言的基础知识。我在安排语言概念和特性的顺序时非常用心,确保每一个新概念都在前一个概念的基础上构建,从不引入没有扎实基础的内容。以下是你将在本部分找到的内容:

第一章:入门    展示了如何在网页浏览器和文本编辑器中编写你第一行 JavaScript 代码。

第二章:基础    介绍了 JavaScript 程序的基本组成部分,如表达式、语句和变量,并解释了如何使用简单的数据类型表示数字、文本和真/假值。

第三章: 复合数据类型    讨论数组和对象,它们可以将多个数据项组合成更有意义的集合。

第四章: 条件语句与循环    教你如何使用控制结构为程序添加逻辑,使程序能够做出决策并重复执行代码段。

第五章: 函数    教你如何通过函数创建可重用的代码块。

第六章: 类    帮助你通过类和面向对象编程原则为代码添加更多结构。

第二部分讨论了如何使用 JavaScript 与网页浏览器进行交互。本节探讨了创建互动网页应用程序的重要技术:

第七章: HTML、DOM 和 CSS    解释了如何使用超文本标记语言(HTML)编写网页,并通过文档对象模型(DOM)使用 JavaScript 修改网页内容。你还将学习如何使用层叠样式表(CSS)为网页应用基本的样式。

第八章: 基于事件的编程    展示如何根据用户行为(如鼠标点击和键盘按键)触发 JavaScript 代码。

第九章: Canvas 元素    教你如何使用 JavaScript 和 Canvas API 在浏览器中绘制图形和动画。

最后,第三部分将帮助你将第一部分和第二部分学到的技能应用到一系列项目中。这些项目之间没有依赖关系,你可以按照任何顺序进行,也可以只选择自己感兴趣的项目。我建议尽可能完成所有项目,因为每个项目都引入了一些有价值的通用编程概念。每个项目跨越两章:

项目 1: 创建一个游戏    带你一步步制作经典的 Atari 游戏 Pong。这个项目将帮助你运用 Canvas API 的技能,并整合你学到的关于数据结构、条件语句和函数的基础知识。在第十章中完成游戏开发后,第十一章将教你如何使用类和面向对象设计原则重构游戏代码。

项目 2: 制作音乐    探索如何使用 JavaScript 制作音乐。第十二章解释了如何使用 Web 音频 API 和一个名为 Tone.js 的库生成声音。然后,第十三章将你学到的知识整合起来,创作一首歌曲。完成这个项目后,你不仅能创作自己的音乐,还能积累使用复杂第三方库的经验。

项目 3: 数据可视化    通过流行的 D3 库带你进入数据可视化的世界。第十四章介绍了 D3 和可缩放矢量图形(SVG)的基础知识,SVG 是浏览器中绘图的一个替代方案,替代了 Canvas API。然后,在第十五章,你将构建一个应用程序,动态可视化从互联网上加载的数据。这个项目展示了如何通过第三方 API 请求数据,这是一个重要的编程技能。

在线资源

本书包含了多个动手练习,帮助你实践所学内容。我鼓励你自己尝试所有的练习,但如果你遇到困难,或者只是想检查答案,解决方案可以在网上找到,网址是https://codepen.io/collection/ZMjYLO。在那里你还可以找到本书项目的完整可下载代码文件。

若要获取有关本书的更新和其他信息,请访问 No Starch Press 网站,网址是https://nostarch.com/javascript-crash-course

第一章:1 开始使用

在本章中,你将开始编写你的第一段 JavaScript 代码。首先,你将学习如何直接在网页浏览器中输入代码,而无需安装任何专业的软件。这种方法非常适合快速测试简单的代码序列。接下来,你将看到如何在一个单独的文本编辑器程序中编写 JavaScript,这在代码变得更复杂时更加合适。本书中我们将使用这两种方法来编写和执行 JavaScript 代码,因此本章将为你未来的内容做准备。

使用 JavaScript 控制台

运行 JavaScript 代码的最快方式是通过 JavaScript 控制台。这是大多数网页浏览器中的一个界面,允许你输入单独的代码行并立即查看结果。我们将使用 Google Chrome 控制台,这是最流行的浏览器。如果你还没有安装 Chrome,可以从 https://www.google.com/chrome 下载并安装它。

安装好 Chrome 后,按照以下步骤访问 JavaScript 控制台:

1.  打开 Chrome 并在地址栏中输入 about:blank,这会将你带到一个空白网页。

在 Windows 或 Linux 上,按 CTRL-SHIFT-J,或者在使用 macOS 时按 OPTION-COMMAND-J。

现在,你应该能看到 JavaScript 控制台,包括一个 > 提示符,你可以在其中输入代码。点击控制台内的区域,将光标放在提示符旁边。

控制台应该类似于 图 1-1,它可能会显示在空白网页旁边,而不是在下面,这取决于你的浏览器设置。

图 1-1:Google Chrome 的 JavaScript 控制台

当你学习一种新的编程语言时,通常的做法是通过编写一段代码来显示“Hello, world!”消息。让我们试试看!在控制台中输入以下内容:

**alert("Hello, world!");**

在本书中,当我要求你在 JavaScript 控制台中输入代码时,我会将代码以粗体显示。如果代码在控制台中产生任何输出,我会将其直接显示在你的输入下方,而不是以粗体显示。

当你准备好运行已输入的代码时,按 ENTER。你应该会看到一个对话框在浏览器中出现,显示消息“Hello, world!”,如 图 1-2 所示。

图 1-2:Hello, world!

你刚刚使用了 JavaScript 的 alert 函数,它会在对话框中弹出文本。函数是执行某项特定任务的代码——在这个例子中,任务是显示一个对话框。函数可以接受参数,这些参数帮助指定任务的执行方式。alert 函数接受一个参数:需要显示的文本。在这里,我们提供了“Hello, world!”作为参数。你将在第五章中了解更多关于函数的内容。

点击确定关闭对话框并返回控制台。然后,为运行你的第一段 JavaScript 代码而自豪。

使用文本编辑器

JavaScript 控制台适合测试少量的代码,但对于本书后面将要处理的较大项目,它就不太适用了。对于这些项目,使用文本编辑器——一个专门用于编写和编辑代码文件的程序,显得更为实用。在本节中,我们将使用文本编辑器创建一个类似的“Hello, world!”程序。

对于本书,我推荐使用微软的 Visual Studio Code 文本编辑器(简称 VS Code)。它可以免费在 Windows、macOS 和 Linux 上使用。请访问https://code.visualstudio.com,并按照网站上的指示下载和安装编辑器。

安装完 VS Code 后,在你的计算机上创建一个名为javascript_work的目录,用于保存你在本书中编写的代码文件。然后,按照以下步骤准备好编写代码:

1.  打开 VS Code。

2.  通过选择文件新建文件来创建一个新文件。

3.  系统应该会提示你命名新文件。输入hello.html

4.  接下来,系统应该会提示你选择新文件的保存位置。选择你刚刚创建的javascript_work目录,并点击创建文件

5.  现在你应该看到一个可以编辑新文件的界面。

这个.html扩展名表明这是一个 HTML 文件。HTML 是一种标记语言,用于描述网页的内容。运行 JavaScript 代码的一种方法是将其包含在 HTML 文件中,然后在网页浏览器中打开该 HTML 文件。这正是我们将要做的。将清单 1-1 中的内容输入到你的新文件中,确保与原文完全一致。

<html><body><script>
alert("Hello from hello.html!");
</script></body></html> 

清单 1-1:在文件中编写 JavaScript hello.html

在你输入的过程中,可能会注意到 VS Code 尝试预测你正在输入的内容。刚开始时这可能会让人困惑,但一旦习惯了,你可能会觉得它很有帮助。有时这些预测会自动插入,而对于其他情况,你则需要按 TAB 键来插入它们。

列表 1-1 的第一行和最后一行是 HTML 代码,嵌入 JavaScript 到 HTML 文件中所需的最基本内容。我们将在第七章中详细探讨 HTML。现在,你需要知道的是,它包含了标签,用来标识网页的不同组件。对于我们的目的来说,最重要的是第一行末尾的标签。当你在浏览器中加载这个文件时,这些标签之间的内容(列表 1-1 中的第二行)将被解释为 JavaScript。

该文件的 JavaScript 部分是:

alert("Hello from hello.html!");

这里我们使用了 alert 函数,就像之前在控制台中那样。此次我们提供了不同的消息“Hello from hello.html!”,在对话框中显示出来。

当你输入完代码后,保存文件。现在你准备好在 Chrome 中打开文件,查看 JavaScript 代码的效果。请按照以下步骤操作:

在 Chrome 中打开一个新标签页。

在 Windows 或 Linux 中按 CTRL-O,或者在 macOS 中按 COMMAND-O,打开“打开文件”对话框。

找到你的hello.html文件,选择它,然后点击打开

你现在应该能看到一个弹出对话框,显示“Hello from hello.html!”消息,如图 1-3 所示。

图 1-3:来自 hello.html!

浏览器识别了 HTML 文件中标签之间的代码作为 JavaScript 并执行了该代码,导致弹出对话框的出现。如果你没有看到对话框弹出,请仔细检查你的hello.html文件中的代码,确保它与列表 1-1 完全一致。

摘要

在这一章中,你学习了两种编写和执行 JavaScript 代码的不同方法。首先,你在 Chrome 网页浏览器的 JavaScript 控制台中输入了代码。你将在接下来的章节中使用这种技巧来测试简短的代码片段,学习语言的基础知识。接着,你使用文本编辑器将 JavaScript 嵌入 HTML 文件,然后在 Chrome 中打开该文件运行代码。在后续章节中,你将使用这种方法开发更复杂的项目。

第二章:10 PONG

在这个第一个项目中,你将使用 JavaScript 重新创建第一个街机视频游戏之一:Atari 经典的PongPong是一个简单的游戏,但它将教会你一些游戏设计的重要方面:游戏循环、玩家输入、碰撞检测和记分。我们甚至会使用一些基本的人工智能来编程计算机对手。

游戏

Pong于 1972 年开发,并在同年作为一款极为成功的街机游戏机发布。这是一个非常基础的游戏,类似乒乓球,由一个球和两个挡板组成,挡板分别位于屏幕的左右两侧,玩家可以上下移动它们。如果球碰到屏幕的顶部或底部边缘,它会反弹回来,但如果碰到左右边缘,对方玩家得分。球会正常地从挡板反弹,除非它碰到挡板的顶部或底部边缘,在这种情况下反弹的角度会发生变化。

在这一章中,我们将制作一个属于自己的Pong版本,我们将其称为Tennjs(就像Tennis,但加了JS,明白了吗?)。在我们的游戏中,左侧的挡板将由计算机控制,右侧的挡板将由人类玩家控制。在原版游戏中,挡板是通过旋转拨盘控制的,但在我们的版本中,我们将使用鼠标。计算机不会尝试预测球会在哪里反弹,而是始终尝试与球的垂直位置保持一致。为了给人类玩家提供机会,我们会对计算机移动挡板的速度设定一个上限。

设置

我们将通过设置项目的文件结构并创建一个用于显示游戏的画布来开始。和往常一样,项目将需要一个 HTML 文件和一个 JavaScript 文件。我们从 HTML 文件开始。创建一个名为tennjs的目录,并在该目录中创建一个名为index.html的文件。然后输入清单 10-1 所示的内容。

<!DOCTYPE html>
<html>
  <head>
    <title>Tennjs</title>
  </head>
  <body>
    <canvas id="canvas" width="300" height="300"></canvas>
    <script src="script.js"></script>
  </body>
</html> 

清单 10-1:我们的游戏的 index.html 文件

这几乎与我们在第九章中创建的 HTML 文件完全相同,因此应该不会有意外。body 元素包含一个 canvas 元素,我们将在其中绘制游戏,还有一个 script 元素,引用了script.js文件,我们的游戏代码将在其中编写。

接下来,我们将编写一些 JavaScript 代码来设置画布。创建script.js文件,并输入清单 10-2 所示的代码。

let canvas = document.querySelector("#canvas");
let ctx = canvas.getContext("2d");
let width = canvas.width;
let height = canvas.height;

ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height); 

清单 10-2:在 script.js 中设置画布

这段代码应该也很熟悉。我们首先通过 document.querySelector 获取对画布的引用,并获得画布的绘图上下文。然后,我们将画布的宽度和高度保存到名为 widthheight 的变量中,以便在代码中方便访问。最后,我们将填充样式设置为黑色,并绘制一个与画布大小相同的黑色方块。这样,画布看起来就像是有一个黑色背景。

在浏览器中打开 index.html,你应该会看到类似于 图 10-1 的内容。

图 10-1:我们的黑色方块

现在我们有了一个空白的黑色画布,可以开始创建我们的游戏了。

接下来,我们将绘制球。将 清单 10-3 中的代码添加到 script.js 的末尾。

`--snip--`
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);

const BALL_SIZE = 5;
❶ let ballPosition = {x: 20, y: 30};

ctx.fillStyle = "white";
ctx.fillRect(ballPosition.x, ballPosition.y, BALL_SIZE, BALL_SIZE); 

清单 10-3:绘制球

这段代码使用 fillRect 方法将球绘制为一个位于画布左上角的小白色方块。就像原版的 Pong 游戏一样,球是方形的而不是圆形的。这种设计给游戏带来了复古感,同时也简化了检测球是否与墙壁或挡板碰撞的任务。球的大小保存在一个名为 BALL_SIZE 的常量中。我们使用全大写的“真常量”风格命名这个标识符,因为球的大小在程序运行过程中不会改变。如果我们仅仅在调用 fillRect 方法绘制球时直接使用值 5,而不是常量 BALL_SIZE,也能实现同样的效果,但随着程序的进行,我们会多次引用球的大小。给球的大小命名将使得需要了解球大小的代码更容易理解。这个方法的另一个好处是,如果我们后来改变主意,决定让球变大或变小,那么我们只需要更新代码中的一个地方:BALL_SIZE 常量的声明。

我们使用一个包含球的 x 和 y 坐标的对象来跟踪球的位置,该对象是通过对象字面量 ❶ 创建的。在 第九章中,我们为正在绘制的圆形使用了单独的 x 和 y 坐标变量,但将这两个变量作为一个对象存储在一起会更整洁,尤其是因为这个程序会变得更长且更复杂。

刷新 index.html,你应该能看到白色的球出现在画布的左上角,如 图 10-2 所示。

图 10-2:球

球现在是静止的,但很快我们将编写代码使其移动。

重构

接下来,我们将进行一个简单的重构。重构是软件开发中的一个术语,指的是在不改变代码行为的情况下修改代码,通常是为了使代码更易于理解或更新。随着项目代码的复杂性增加,重构有助于保持代码的组织性。

在这种情况下,我知道我们需要多次绘制到画布上,而不仅仅是一次。事实上,我们最终希望每 30 毫秒重新绘制一次画布,以使游戏呈现运动的效果。为了更容易实现这一点,我们将重构,使所有当前的绘图代码成为名为 draw 的函数的一部分。这样,我们可以在任何时候调用 draw 函数来重新绘制画布。

用清单 10-4 中的更改更新script.js

let canvas = document.querySelector("#canvas");
let ctx = canvas.getContext("2d");
let width = canvas.width;
let height = canvas.height;

const BALL_SIZE = 5;
let ballPosition = {x: 20, y: 30};

❶ function draw() {
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, width, height);

  ctx.fillStyle = "white";
  ctx.fillRect(ballPosition.x, ballPosition.y, BALL_SIZE, BALL_SIZE);
}

❷ draw(); 

清单 10-4:重构绘图代码

唯一的变化是将所有绘图代码组织成一个名为 draw ❶的单一函数,然后立即调用它 ❷。由于这是重构,因此程序的行为并没有实际变化。你可以刷新index.html来确认一切看起来仍然如之前一样。

游戏循环

几乎所有的游戏都包含一个游戏循环,它协调每一帧游戏中必须发生的一切。游戏循环与我们在第九章中看到的动画循环类似,但有一些额外的逻辑。以下是大多数游戏中游戏循环的一般结构:

1.  清除画布

2.  绘制图像

3.  获取玩家输入

4.  更新状态

5.  检查碰撞

6.  等待短暂时间

7.  重复

获取并处理玩家(或玩家们)的输入是将游戏与动画区分开的主要特征。碰撞检测是大多数游戏中的另一个重要方面:检查游戏中两个物体何时相遇并作出相应反应。碰撞检测可以阻止你穿过墙壁或开车撞上另一辆车——在这个案例中,它会让小球从墙壁和挡板上弹回。除了玩家输入和碰撞检测元素外,游戏循环中的步骤与动画循环大致相同:我们清除画布,绘制图像,更新游戏状态以将物体移动到新位置,暂停,然后重复。

我们不会试图一次性写完整个游戏循环,而是逐步构建它。用清单 10-5 中的内容更新script.js,这将成为我们游戏中游戏循环的起始部分。此代码移动小球(即更新小球的状态),重绘画布,暂停,然后重复。

`--snip--`
const BALL_SIZE = 5;
let ballPosition = {x: 20, y: 30};

❶ let xSpeed = 4;
let ySpeed = 2;

function draw() {
 ctx.fillStyle = "black";
 ctx.fillRect(0, 0, width, height);

 ctx.fillStyle = "white";
 ctx.fillRect(ballPosition.x, ballPosition.y, BALL_SIZE, BALL_SIZE);
}

❷ function update() {
  ballPosition.x += xSpeed;
  ballPosition.y += ySpeed;
}

❸ function gameLoop() {
  draw();
  update();

    // Call this function again after a timeout
  setTimeout(gameLoop, 30);
}

❹ gameLoop(); 

清单 10-5:游戏循环

这里的第一个变化是初始化两个新变量 ❶,xSpeedySpeed。我们将使用这两个变量来控制球的水平和垂直速度。新的 update 函数 ❷ 使用这两个变量来更新球的位置。每帧,球将沿 x 轴移动 xSpeed 像素,沿 y 轴移动 ySpeed 像素。这两个变量的初始值分别为 4 和 2,因此每帧球会向右移动 4 像素,向下移动 2 像素。

gameLoop 函数 ❸ 调用 draw 函数,然后是 update 函数。接着它调用 setTimeout(gameLoop, 30),这样 gameLoop 函数将在 30 毫秒后再次被调用。这几乎与我们在第九章中使用的 setInterval 技术完全相同。你可能还记得,setTimeout 只会在超时后调用一次函数,而 setInterval 会反复调用其函数。我们这里使用 setTimeout 是为了能更好地控制是否继续循环;稍后我们将添加一些条件逻辑,决定是否继续调用 setTimeout 或结束游戏。

注意上面 setTimeout 调用之前以两个斜杠(//)开头的那一行。这是一个注释,是嵌入在程序文件中的个人备注(或其他阅读代码的人备注)。当 JavaScript 程序执行时,任何在 // 后面的文本都会被忽略(// 前面的部分仍然会被作为 JavaScript 代码执行)。因此,你可以像这样使用注释来解释代码的工作原理、突出重要功能,或者记录需要完成的事项,而不会影响程序的功能。

在脚本的末尾,我们调用 gameLoop 函数 ❹ 来启动游戏。由于 gameLoop 目前以 setTimeout 结束,结果是 gameLoop 每 30 毫秒会被重复调用一次。重新加载页面后,你应该能看到球向下和向右移动,和第九章中的动画类似。

反弹

在上一节中,你已经让球开始移动,但它只是飞出了画布的边缘。接下来你将学习如何使它以合适的角度从画布边缘反弹——我们的第一个碰撞检测代码。用清单 10-6 中的代码更新 script.js,这段代码为我们的游戏添加了一个 checkCollision 函数。

`--snip--`
function update() {
 ballPosition.x += xSpeed;
 ballPosition.y += ySpeed;
}

function checkCollision() {
❶ let ball = {
    left: ballPosition.x,
    right: ballPosition.x + BALL_SIZE,
    top: ballPosition.y,
    bottom: ballPosition.y + BALL_SIZE
  }

❷ if (ball.left < 0 || ball.right > width) {
    xSpeed = -xSpeed;
  }
❸ if (ball.top < 0 || ball.bottom > height) {
    ySpeed = -ySpeed;
  }
}

function gameLoop() {
 draw();
 update();
❹ checkCollision();

 // Call this function again after a timeout
 setTimeout(gameLoop, 30);
}

gameLoop(); 

清单 10-6:墙面碰撞检测

新函数 checkCollision 用于检查球是否与画布的四面墙发生碰撞。如果发生碰撞,它会根据需要更新 xSpeed 或 ySpeed,使球反弹回墙壁。首先,我们需要计算球的各个边缘的位置。我们需要知道球的左边缘、右边缘、上边缘和下边缘的位置,以确定这些边缘是否超出了游戏区域的边界。我们将这些值放在一个名为 ball ❶ 的对象中,这个对象包含了 left、right、top 和 bottom 属性。确定球的左边缘和上边缘很简单:它们分别是 ballPosition.x 和 ballPosition.y。为了得到右边缘和下边缘,我们需要将 BALL_SIZE 加到 ballPosition.x 和 ballPosition.y 上。这是前面提到的那种情况,利用常量 BALL_SIZE 来获取球的尺寸会非常有用。

接下来,我们进行实际的碰撞检测。如果球的左边缘小于 0 或球的右边缘大于画布的宽度❷,我们就知道球已经撞到左墙或右墙。在这两种情况下,数学操作是一样的:xSpeed 的新值应该是当前值的相反数(也就是值被取反)。例如,当球第一次撞到右边缘时,xSpeed 会从 4 变为 -4。与此同时,ySpeed 保持不变。因此,球会以相同的速度继续向下移动,但现在它向左移动,而不是向右移动。

当球的顶部与上墙碰撞,或球的底部与下墙碰撞时,都会发生相同的检查❸。在这两种情况下,我们会改变 ySpeed 的符号,当球撞到顶部时,ySpeed 从 2 变为 -2,或者当球撞到底部时,ySpeed 从 -2 变为 2。

唯一的其他代码更改是将对 checkCollision 的调用添加到 gameLoop 函数 ❹ 中。现在,当你刷新 index.html 时,应该能看到球在游戏区域内不断反弹。

如果你一直在关注,可能已经注意到,球本不应该反弹到左右墙上。一旦我们有了可移动的挡板,我们会修改碰撞检测代码,使球只会在挡板或上下墙上反弹,同时对于左右墙的碰撞,我们会得分。

挡板

我们接下来的任务是绘制两个挡板。为此,我们首先引入一些新的常量,来确定挡板的尺寸及其相对于画布两侧的水平位置,以及一些定义其垂直位置的变量。(挡板只能上下移动,不能左右移动,因此只需要定义它们的垂直位置作为变量。)根据清单 10-7 中的更改,更新 script.js 文件。

`--snip--`
let xSpeed = 4;
let ySpeed = 2;

const PADDLE_WIDTH = 5;
const PADDLE_HEIGHT = 20;
const PADDLE_OFFSET = 10;

let leftPaddleTop = 10;
let rightPaddleTop = 30;

function draw() {
`--snip--` 

清单 10-7:定义挡板

首先,我们设置定义球拍的常量。PADDLE_WIDTH 和 PADDLE_HEIGHT 定义了两个球拍的宽度为 5 像素,高度为 20 像素。PADDLE_OFFSET 指的是球拍与游戏区域左右边缘的距离。

变量 leftPaddleTop 和 rightPaddleTop 定义了每个球拍顶部的当前垂直位置。最终,leftPaddleTop 将通过我们编写的函数由计算机控制,跟随球的位置,而 rightPaddleTop 将在玩家移动鼠标时更新。现在,我们只是将这两个值分别设置为 10 和 30。

接下来,我们更新绘制函数,使用我们刚刚定义的信息来显示球拍。我还在代码中添加了注释,明确每一步绘制函数的作用。按照清单 10-8 所示修改代码。

`--snip--`
function draw() {
  // Fill the canvas with black
 ctx.fillStyle = "black";
 ctx.fillRect(0, 0, width, height);

  // Everything else will be white
 ctx.fillStyle = "white";

  // Draw the ball
 ctx.fillRect(ballPosition.x, ballPosition.y, BALL_SIZE, BALL_SIZE);

  // Draw the paddles
❶ ctx.fillRect(
    PADDLE_OFFSET,
    leftPaddleTop,
    PADDLE_WIDTH,
    PADDLE_HEIGHT
  );
❷ ctx.fillRect(
    width - PADDLE_WIDTH - PADDLE_OFFSET,
    rightPaddleTop,
 PADDLE_WIDTH,
    PADDLE_HEIGHT
  );
}

function update() {
`--snip--` 

清单 10-8:绘制球拍

除了帮助文档化程序的额外注释外,新的代码包含了两次调用 fillRect,一个用于绘制左边的球拍❶,另一个用于右边的球拍❷。由于标识符太长,我将参数分成了多行。记住,fillRect 的参数是 x、y、宽度和高度,其中 x 和 y 是矩形左上角的坐标。左边球拍的 x 坐标是 PADDLE_OFFSET,因为我们用它表示球拍与画布左边缘的距离,而左边球拍的 y 坐标就是 leftPaddleTop。宽度和高度参数是 PADDLE_WIDTH 和 PADDLE_HEIGHT 常量。

右边的球拍绘制稍微复杂一些:为了获取球拍左上角的 x 坐标,我们需要用画布的宽度减去球拍的宽度和球拍从右边缘的偏移量。给定画布的宽度为 500,球拍宽度和偏移量都为 10,这意味着右边球拍的 x 坐标是 480。

当你刷新index.html时,除了弹跳的球之外,你应该能看到两个球拍,如图 10-3 所示。

图 10-3:球拍和球

请注意,球现在会直接穿过球拍,因为我们还没有为球拍设置碰撞检测。我们稍后会在本节中讲解如何设置。

通过玩家输入移动球拍

球拍是在由变量 leftPaddleTop 和 rightPaddleTop 给定的垂直位置绘制的,因此为了让球拍上下移动,我们只需要更新这些变量的值。目前我们只关心右边的球拍,它将由玩家控制。

为了让玩家控制右侧挡板,我们将在 script.js 中添加一个事件处理程序来监听 mousemove 事件。列表 10-9 显示了如何实现。

`--snip--`
let leftPaddleTop = 10;
let rightPaddleTop = 30;

document.addEventListener("mousemove", e => {
  rightPaddleTop = e.y - canvas.offsetTop;
});

function draw() {
`--snip--` 

列表 10-9:添加事件处理程序以移动右侧挡板

这段代码遵循了你在第八章中首次看到的事件处理模式。我们使用 document.addEventListener 来检测鼠标移动。当检测到鼠标移动时,事件处理函数会根据鼠标移动事件的 y 坐标 (e.y) 更新 rightPaddleTop 的值。这个 y 坐标是相对于页面顶部的,而不是相对于画布顶部,因此我们需要从 y 坐标中减去 canvas.offsetTop(从画布顶部到页面顶部的距离)。这样,分配给 rightPaddleTop 的值将基于鼠标到画布顶部的距离,挡板将准确地跟随鼠标。

刷新 index.html,你应该会看到右侧挡板随着鼠标上下移动而垂直移动。图 10-4 显示了应该是什么样子。

图 10-4:右侧挡板随着鼠标移动

我们的游戏现在正式变得具有互动性!玩家可以完全控制右侧挡板的位置。

检测挡板碰撞

下一步是为挡板添加碰撞检测。我们需要知道球是否击中了挡板,如果是的话,要让球适当地从挡板反弹。这需要很多代码,所以我会将其拆分成几个列表来展示。

我们需要做的第一件事是创建定义两个挡板四个边缘的对象,就像我们在列表 10-6 中为球所做的那样。这些更改在列表 10-10 中显示。

`--snip--`
function checkCollision() {
 let ball = {
 left: ballPosition.x,
 right: ballPosition.x + BALL_SIZE,
 top: ballPosition.y,
 bottom: ballPosition.y + BALL_SIZE
 }

  let leftPaddle = {
    left: PADDLE_OFFSET,
    right: PADDLE_OFFSET + PADDLE_WIDTH,
    top: leftPaddleTop,
    bottom: leftPaddleTop + PADDLE_HEIGHT
  };

   let rightPaddle = {
    left: width - PADDLE_WIDTH - PADDLE_OFFSET,
    right: width - PADDLE_OFFSET,
    top: rightPaddleTop,
    bottom: rightPaddleTop + PADDLE_HEIGHT
  };

 if (ball.left < 0 || ball.right > width) {
`--snip--` 

列表 10-10:定义挡板的边缘

leftPaddle 和 rightPaddle 对象分别包含它们各自挡板的四个边缘属性:left、right、top 和 bottom。就像在列表 10-8 中一样,确定右侧挡板的边缘位置需要一些额外的数学计算,因为我们必须考虑到画布的宽度、挡板的偏移量和挡板的宽度。

接下来,我们需要一个函数,命名为 checkPaddleCollision,该函数接受球对象和一个挡板对象,并在球与该挡板相交时返回 true。该函数的定义在列表 10-11 中显示。

`--snip--`
function update() {
 ballPosition.x += xSpeed;
 ballPosition.y += ySpeed;
}

function checkPaddleCollision(ball, paddle) {
  // Check if the paddle and ball overlap vertically and horizontally
  return (
    ball.left   < paddle.right &&
    ball.right  > paddle.left &&
    ball.top    < paddle.bottom &&
    ball.bottom > paddle.top
  );
}

function checkCollision() {
`--snip--` 

列表 10-11:checkPaddleCollision 函数

这个函数会与之前定义的球和每个球拍对象一起被调用。它使用一个较长的布尔表达式,由四个子表达式组成,这四个子表达式通过 && 运算符连接在一起,因此只有当所有四个子表达式都为真时,才会返回 true。(我为每个子表达式添加了空格,使得操作数垂直对齐;这样做只是为了让代码更易读。)用英文描述,子表达式如下:

  1. 球的左边缘必须位于球拍的右边缘的左侧。

  2. 球的右边缘必须位于球拍的左边缘的右侧。

  3. 球的上边缘必须位于球拍的下边缘之上。

  4. 球的下边缘必须位于球拍的上边缘之下。

如果前两个条件为真,表示球在水平方向上相交;如果最后两个条件为真,表示球在垂直方向上相交。只有当四个条件都为真时,球才真正与球拍相交。为了说明这一点,请参见图 10-5。

该图展示了我们可能需要检查的四种情况。在所有这些情况下,球拍的边界为:{left: 10, right: 15, top: 5, bottom: 25}。

在图 10-5(a)中,球的边界为{left: 20, right: 25, top: 30, bottom: 35}。在这种情况下,ball.left < paddle.right 为假(球的左边并没有位于球拍右边的左侧),但 ball.right > paddle.left 为真。同样,ball.top < paddle.bottom 为假,而 ball.bottom > paddle.top 为真。球既没有在垂直方向上与球拍相交,也没有在水平方向上与球拍相交。

在图 10-5(b)中,球的边界为{left: 20, right: 25, top: 22, bottom: 27}。这一次,ball.top < paddle.bottom 和 ball.bottom > paddle.top 都为真,这意味着球在垂直方向上与球拍相交,但在水平方向上没有相交。

在图 10-5(c)中,球的边界为{left: 13, right: 18, top: 30, bottom: 35}。在这种情况下,球在水平方向上与球拍相交,但在垂直方向上没有相交。

最后,在图 10-5(d)中,球的边界为{left: 13, right: 18, top: 22, bottom: 27}。现在,球在水平方向和垂直方向上都与球拍相交。四个子表达式都为真,因此检查 PaddleCollision 返回 true。

图 10-5:碰撞检测条件

现在是时候在 checkCollision 函数内部实际调用 checkPaddleCollision 函数了,每个球拍调用一次,并处理函数返回 true 的情况。你可以在代码清单 10-12 中找到这个代码。

`--snip--`
 let rightPaddle = {
 left: width - PADDLE_WIDTH - PADDLE_OFFSET,
 right: width - PADDLE_OFFSET,
 top: rightPaddleTop,
 bottom: rightPaddleTop + PADDLE_HEIGHT
 };

  if (checkPaddleCollision(ball, leftPaddle)) {
    // Left paddle collision happened
  ❶ xSpeed = Math.abs(xSpeed);
  }

  if (checkPaddleCollision(ball, rightPaddle)) {
    // Right paddle collision happened
  ❷ xSpeed = -Math.abs(xSpeed);
  }

 if (ball.left < 0 || ball.right > width) {
 xSpeed = -xSpeed;
 }
 if (ball.top < 0 || ball.bottom > height) {
 ySpeed = -ySpeed;
 }
}
`--snip--` 

代码清单 10-12:检查球拍碰撞

记住,checkPaddleCollision 接受一个表示球的对象和一个表示球拍的对象,并且如果两者相交则返回 true。如果 checkPaddleCollision(ball, leftPaddle) 返回 true,我们将 xSpeed 设置为 Math.abs(xSpeed) ❶,这样会将它设置为 4,因为在我们的游戏中 xSpeed 只有两个值:4(向右移动时)或 -4(向左移动时)。

你可能会想,为什么我们没有像之前在垂直墙壁碰撞代码中那样仅仅取反 xSpeed。使用绝对值是一个小技巧,可以避免多次碰撞,这样球就不会在球拍“内部”来回弹跳。如果球恰好在球拍的末端碰撞,它可能会被弹回,但下一帧也会与同一个球拍发生碰撞。如果我们取反 xSpeed,那么球就会一直弹跳。通过强制更新后的 xSpeed 为正数,我们可以确保与左侧球拍的碰撞总是使球向右弹跳。

接下来,我们对右侧球拍做同样的处理。在这种情况下,如果发生碰撞,我们将 xSpeed 更新为 -Math.abs(xSpeed) ❷,这实际上是 -4,意味着球将向左弹跳。

再次刷新 index.html,然后试着用鼠标移动右侧球拍,让球击中它。现在你应该可以看到球与球拍发生弹跳了!此时,球仍然可以安全地从侧墙反弹,但我们很快会修复这个问题。

在球拍边缘附近弹跳

我在本章开头提到过,在 Pong 中,你可以通过在球拍的顶部或底部附近击打球来改变球的弹跳角度。现在我们将实现这个功能。首先,我们会添加一个新的函数,名为 adjustAngle,紧接在 checkCollision 之前。它检查球是否靠近球拍的顶部或底部,如果是,就更新 ySpeed。请参考 清单 10-13 查看代码。

`--snip--`
function adjustAngle(distanceFromTop, distanceFromBottom) {
❶ if (distanceFromTop < 0) {
    // If ball hit near top of paddle, reduce ySpeed
    ySpeed -= 0.5;
❷ } else if (distanceFromBottom < 0) {
    // If ball hit near bottom of paddle, increase ySpeed
    ySpeed += 0.5;
  }
}

function checkCollision() {
`--snip--` 

清单 10-13: 调整弹跳角度

adjustAngle 函数有两个参数,distanceFromTopdistanceFromBottom。这两个参数分别表示球的顶部到球拍顶部的距离,以及球拍底部到球底部的距离。该函数首先检查 distanceFromTop 是否小于 0 ❶。如果是,这意味着在碰撞时球的顶部边缘位于球拍的顶部边缘之上,这就是我们定义球接近球拍顶部的方式。在这种情况下,我们从 ySpeed 中减去 0.5。如果球在接近球拍顶部时向下移动屏幕,那么 ySpeed 为正数,因此减去 0.5 会减少垂直速度。例如,在游戏开始时,ySpeed 为 2。如果你调整球拍使得球击中顶部,弹跳后 ySpeed 会变为 1.5,有效地减少了弹跳的角度。然而,如果球向上移动屏幕,那么 ySpeed 为负数。在这种情况下,球拍顶部附近的碰撞后减去 0.5 会增加球的垂直速度。例如,ySpeed 为 -2 时,碰撞后会变为 -2.5。

如果球击中球拍的底部附近 ❷,情况则相反。在这种情况下,我们将 ySpeed 增加 0.5,如果球向下移动屏幕,则增加垂直速度;如果球向上移动屏幕,则减慢垂直速度。

接下来,我们需要更新 checkCollision 函数,将新的 adjustAngle 函数作为两个球拍碰撞检测逻辑的一部分来调用。Listing 10-14 显示了相关的修改。

`--snip--`
 let rightPaddle = {
 left: width - PADDLE_WIDTH - PADDLE_OFFSET,
 right: width - PADDLE_OFFSET,
 top: rightPaddleTop,
 bottom: rightPaddleTop + PADDLE_HEIGHT
  };

 if (checkPaddleCollision(ball, leftPaddle)) {
 // Left paddle collision happened
    let distanceFromTop = ball.top - leftPaddle.top;
    let distanceFromBottom = leftPaddle.bottom - ball.bottom;
    adjustAngle(distanceFromTop, distanceFromBottom);
 xSpeed = Math.abs(xSpeed);
 }

 if (checkPaddleCollision(ball, rightPaddle)) {
 // Right paddle collision happened
    let distanceFromTop = ball.top - rightPaddle.top;
    let distanceFromBottom = rightPaddle.bottom - ball.bottom;
    adjustAngle(distanceFromTop, distanceFromBottom);
 xSpeed = -Math.abs(xSpeed);
 }
`--snip--` 

Listing 10-14: 调用 adjustAngle

在每个球拍的 if 语句内,我们声明了 distanceFromTopdistanceFromBottom,这些是 adjustAngle 函数所需的参数。然后我们像以前一样,在更新 xSpeed 之前调用 adjustAngle

现在尝试一下游戏,看看你能否将球击中球拍的边缘附近!

得分

游戏通常在你可以赢或输的时候更有趣。在 Pong 中,当你击中对方球拍后方的墙壁时,你会得分。发生这种情况时,球会重置到起始位置并恢复速度,进入下一轮游戏。我们将在本节中处理这一部分,但首先,为了跟踪得分,我们需要创建一些新的变量。使用 Listing 10-15 中的代码更新 script.js 文件。

`--snip--`
let leftPaddleTop = 10;
let rightPaddleTop = 30;

let leftScore = 0;
let rightScore = 0;

document.addEventListener("mousemove", e => {
 rightPaddleTop = e.y - canvas.offsetTop;
});
`--snip--` 

Listing 10-15: 用于跟踪得分的变量

这里我们声明了两个新变量,leftScorerightScore,并将它们的初始值设置为 0。稍后我们会添加逻辑,当得分时递增这两个变量。

接下来,我们将在 draw 函数的末尾添加显示得分的代码。按照 Listing 10-16 中的方式更新该函数。

`--snip--`
 ctx.fillRect(
 width - PADDLE_WIDTH - PADDLE_OFFSET,
 rightPaddleTop,
 PADDLE_WIDTH,
 PADDLE_HEIGHT
 );

  // Draw scores
  ctx.font = "30px monospace";
  ctx.textAlign = "left";
  ctx.fillText(leftScore.toString(), 50, 50);
  ctx.textAlign = "right";
  ctx.fillText(rightScore.toString(), width - 50, 50);
}

function update() {
`--snip--` 

Listing 10-16: 绘制得分

这段代码使用了一些我们尚未见过的新 canvas 属性和方法。首先,我们使用 ctx.font 来设置即将绘制的文本的字体。这类似于 CSS 中的 font 声明。在这种情况下,我们将字体设置为 30 像素高,并使用等宽字体样式。等宽字体意味着每个字符占用相同的宽度,通常用于代码,如本书中的代码列表。它看起来像这样。有许多等宽字体,但由于操作系统可能安装了不同的字体,我们仅提供通用的字体样式(monospace),这意味着操作系统应使用该字体样式的默认字体。在大多数操作系统中,Courier 或 Courier New 是默认的等宽字体。

接下来,我们使用 ctx.textAlign 来设置文本的对齐方式。我们选择“左”对齐,但因为我们希望这仅应用于左侧得分,所以在绘制右侧得分之前,我们将对齐方式改为“右”。这样,如果得分变为两位数,数字将朝屏幕中间延伸,保持视觉平衡。

为了显示左侧得分,我们使用 ctx.fillText 方法。这个方法有三个参数:要绘制的文本,以及绘制文本的 x 和 y 坐标。第一个参数必须是一个字符串,因此我们调用 leftScore 的 toString 方法,将其从数字转换为字符串。我们使用 50 作为 x 和 y 坐标,将文本放置在画布的左上角附近。

注意

fillText 的 x 坐标参数的含义取决于文本的对齐方式。对于左对齐文本,x 坐标指定文本的左边缘;而对于右对齐文本,x 坐标指定文本的右边缘。

右侧得分的处理与左侧得分类似:我们设置文本对齐方式,然后调用 fillText 来显示得分。这次我们将 x 坐标设置为 width - 50,因此它会出现在距离右侧与左侧得分距离相同的位置。

刷新index.html时,你应该能看到初始得分渲染出来,如图 10-6 所示。

图 10-6:显示得分

现在我们需要处理球撞击侧墙的情况。球不再反弹,应该增加适当的得分,并且将球重置为原始速度和位置。首先我们进行一次重构,编写一个重置球的函数。这还需要对球的速度和位置变量的处理进行一些更改。示例 10-17 展示了这些更改。

`--snip--`
const BALL_SIZE = 5;
❶ let ballPosition;

let xSpeed;
let ySpeed;

function initBall() {
❷ ballPosition = {x: 20, y: 30};
  xSpeed = 4;
  ySpeed = 2;
}

const PADDLE_WIDTH = 5;
`--snip--` 

示例 10-17:initBall 函数

在这里,我们将球的状态变量(ball Position、xSpeed 和 ySpeed)的声明与这些变量的初始化分开。例如,ballPosition 在程序的顶层声明 ❶,但在新的 initBall 函数中初始化 ❷(即“初始化球”)。xSpeed 和 ySpeed 也如此。这样一来,我们可以通过调用 initBall,轻松地将球重置到初始位置和速度,而不必在程序中到处复制粘贴球的状态变量的值。特别地,我们现在可以在程序开始时调用 initBall 来首次设置球,并且也可以在球撞到左右墙时随时调用它,将球重置到原始状态。

请注意,我们不能在 initBall 函数内部同时声明初始化球的状态变量——例如,在函数内部放置 let ballPosition = {x: 20, y: 30};——因为 let 关键字会在当前作用域内定义一个新变量,在这种情况下就是 initBall 的主体。因此,这些变量只能在 initBall 函数内部使用。我们希望这些变量在整个程序中都能使用,因此我们在程序的顶层声明它们,而不是在任何函数的主体内声明。然而,因为我们希望多次初始化这些变量,所以我们在 initBall 函数中为它们赋值,该函数可以被多次调用。

接下来,我们需要修改 checkCollision 函数中的碰撞检测代码,当球撞到左右墙时,增加得分并重置球。列表 10-18 展示了如何操作。

`--snip--`
 if (checkPaddleCollision(ball, rightPaddle)) {
 // Right paddle collision happened
 let distanceFromTop = ball.top - rightPaddle.top;
 let distanceFromBottom = rightPaddle.bottom - ball.bottom;
 adjustAngle(distanceFromTop, distanceFromBottom);
 xSpeed = -Math.abs(xSpeed);
  }

❶ if (ball.left < 0) {
    rightScore++;
    initBall();
  }
❷ if (ball.right > width) {
    leftScore++;
    initBall();
  }

 if (ball.top < 0 || ball.bottom > height) {
 ySpeed = -ySpeed;
 }
}
`--snip--` 

列表 10-18:墙壁碰撞得分

以前,我们在一个 if 语句中检查左右墙的碰撞,并让球反弹,但现在我们需要单独处理左右墙,因为根据撞到哪面墙,不同的玩家得分。因此,我们将 if 语句拆分成两个。如果球撞到左墙 ❶,rightScore 会增加,并通过调用新的 initBall 函数重置球。如果球撞到右墙 ❷,leftScore 会增加,并重置球。与上下墙的碰撞逻辑保持不变。

最后,由于我们已将球的状态变量初始化移动到 initBall 函数中,我们需要在游戏循环开始之前调用该函数,以便首次设置球。在script.js的底部向下滚动,并按列表 10-19 所示更新代码,在调用 gameLoop 之前添加对 initBall 的调用。

`--snip--`
function gameLoop() {
 draw();
 update();
  checkCollision();

 // Call this function again after a timeout
 setTimeout(gameLoop, 30);
}

initBall();
gameLoop(); 

列表 10-19:首次调用 initBall

现在,当你刷新 index.html 时,你应该能看到分数在球击中侧墙时递增,并且球在击中侧墙后应该恢复到其原始速度和位置。当然,现在很容易打败计算机,因为它还没有移动它的滑块!

计算机控制

现在让我们给这个游戏增加一些挑战吧!我们希望计算机控制的对手移动左侧的滑块并尝试击打球。实现这一点有多种方法,但在我们简单的方案中,计算机将始终尝试匹配球的当前位置。计算机的逻辑将非常简单:

  • 如果球的顶部在滑块的顶部之上,向上移动滑块。

  • 如果球的底部在滑块的底部之下,向下移动滑块。

  • 否则,不做任何操作。

使用这种方法,如果计算机可以以任何速度移动,那么它将永远不会错过。由于这对我们人类来说没有乐趣,我们将为计算机设置一个速度限制。示例 10-20 展示了如何做。

let canvas = document.querySelector("#canvas");
let ctx = canvas.getContext("2d");
let width = canvas.width;
let height = canvas.height;

const MAX_COMPUTER_SPEED = 2;

const BALL_SIZE = 5;
`--snip--` 

示例 10-20:限制计算机的速度

我们将计算机的速度限制声明为一个常量,MAX_COMPUTER_SPEED。通过将其设置为 2,我们表示计算机在每帧游戏中不能移动比 2 像素更多的滑块。

接下来,我们将定义一个名为 followBall 的函数,它应用一些非常基础的人工智能来移动计算机的滑块。新函数在示例 10-21 中展示。将它添加到你的代码中,放在 draw 函数和 update 函数之间。

`--snip--`
function followBall() {
❶ let ball = {
    top: ballPosition.y,
    bottom: ballPosition.y + BALL_SIZE
  };
❷ let leftPaddle = {
    top: leftPaddleTop,
    bottom: leftPaddleTop + PADDLE_HEIGHT
  };

❸ if (ball.top < leftPaddle.top) {
    leftPaddleTop -= MAX_COMPUTER_SPEED;
❹ } else if (ball.bottom > leftPaddle.bottom) {
    leftPaddleTop += MAX_COMPUTER_SPEED;
  }
}

function update() {
 ballPosition.x += xSpeed;
 ballPosition.y += ySpeed;
❺ followBall();
}

`--snip--` 

示例 10-21:计算机控制的滑块

在 followBall 函数中,我们定义了表示球 ❶ 和左侧滑块 ❷ 的对象,每个对象都有表示其上下界限的 top 和 bottom 属性。然后,我们通过两个 if 语句实现滑块的移动逻辑。如果球的顶部在滑块的顶部之上 ❸,我们通过从 leftPaddleTop 中减去 MAX_COMPUTER_SPEED 来向上移动滑块。同样,如果球的底部在滑块的底部之下 ❹,我们通过向 leftPaddleTop 添加 MAX_COMPUTER_SPEED 来向下移动滑块。

我们在 update 函数中调用了新的 followBall 函数 ❺。这样,移动左侧滑块就成为了每次游戏循环迭代时更新游戏状态的过程的一部分。

重新加载页面,看看你能不能在计算机面前得分!

游戏结束

创建游戏的最后一步是让游戏可以获胜(或失败)。为了做到这一点,我们必须添加某种游戏结束的条件,并在此时停止游戏循环。在这种情况下,我们将在其中一个玩家达到 10 分时停止游戏循环,然后显示“游戏结束”的文字。

首先,我们需要声明一个变量来跟踪游戏是否结束。我们将使用这个变量来决定是否继续重复执行 gameLoop 函数。清单 10-22 展示了需要做出的修改。

`--snip--`
let leftScore = 0;
let rightScore = 0;
❶ let gameOver = false;

document.addEventListener("mousemove", e => {
--snip--

function checkCollision() {
--snip--
 if (ball.right > width) {
 leftScore++;
 initBall();
 }
❷ if (leftScore > 9 || rightScore > 9) {
    gameOver = true;
 }
 if (ball.top < 0 || ball.bottom > height) {
 ySpeed = -ySpeed;
 }
}
`--snip--` 

清单 10-22:添加 gameOver 变量

script.js的顶部,我们声明了一个名为 gameOver 的变量,用于记录游戏是否结束 ❶。我们将其初始化为 false,这样游戏在开始之前不会结束。然后,在 checkCollision 函数中,我们检查是否有任何一个得分超过了 9 ❷。如果是这样,我们将 gameOver 设置为 true。这个检查可以在任何地方进行,但我们在 checkCollision 中进行,以将增加得分的逻辑和检查得分的逻辑放在一起。

接下来,我们将添加一个函数来写入“GAME OVER”文本,并修改游戏循环,使其在 gameOver 为 true 时结束。清单 10-23 展示了如何操作。

`--snip--`
 if (ball.top < 0 || ball.bottom > height) {
 ySpeed = -ySpeed;
 }
}

❶ function drawGameOver() {
  ctx.fillStyle = "white";
  ctx.font = "30px monospace";
  ctx.textAlign = "center";
  ctx.fillText("GAME OVER", width / 2, height / 2);
}

function gameLoop() {
 draw();
 update();
 checkCollision();
❷ if (gameOver) {
    draw();
    drawGameOver();
❸} else {
    // Call this function again after a timeout
    setTimeout(gameLoop, 30);
  }
} 

清单 10-23:结束游戏

我们在 checkCollision 函数 ❶之后定义了 drawGameOver 函数。它将“GAME OVER”文本绘制到画布中央,字体为大号白色。为了将文本居中放置,我们将文本对齐方式设置为“center”,并使用画布宽度和高度的一半作为文本的 x 和 y 坐标。(在居中对齐的情况下,x 坐标表示文本的水平中点。)

在 gameLoop 函数中,我们将 setTimeout 的调用包装在一个条件语句中,该语句检查 gameOver 变量的值。如果为 true ❷,游戏结束,因此我们调用 draw 和 drawGameOver 函数。(draw 函数是用来显示最终得分的;否则,获胜的玩家将仍然停留在九分。)如果 gameOver 为 false ❸,游戏可以继续:我们像之前一样使用 setTimeout,在 30 毫秒后再次调用 gameLoop,继续循环。

一旦 gameOver 变为 true 并且游戏循环结束,游戏实际上就停止了。在“GAME OVER”文本之后,不会再绘制其他内容——至少,直到页面刷新并且程序从头开始重新启动。现在就刷新index.html,看看你能否打败电脑!一旦你们中的任何一个得分超过九分,你应该会看到“GAME OVER”文本,如图 10-7 所示。

图 10-7:游戏结束

我希望你能打败电脑,但如果没有也不用担心——这个游戏确实很难。这里有一些可以让你自己轻松些的建议:

  • 增加 gameLoop 中帧之间的时间。

  • 增加挡板的高度。

  • 降低电脑的最大速度。

  • 让击中挡板的边缘变得更容易。

  • 增加击中挡板边缘时 ySpeed 的效果。

现在你已经有了一个可用的游戏,你可以进行任何你想要的修改。如果你已经是一个Pong高手,可能想让游戏变得更难一些;下面的练习提供了一些建议。你还可以尝试自定义外观,或更改画布的大小——现在是你的游戏了。

完整代码

为了方便你,列表 10-24 显示了完整的script.js文件。

let canvas = document.querySelector("#canvas");
let ctx = canvas.getContext("2d");
let width = canvas.width;
let height = canvas.height;

const MAX_COMPUTER_SPEED = 2;

const BALL_SIZE = 5;
let ballPosition;

let xSpeed;
let ySpeed;

function initBall() {
  ballPosition = {x: 20, y: 30};
  xSpeed = 4;
  ySpeed = 2;
}

const PADDLE_WIDTH = 5;
const PADDLE_HEIGHT = 20;
const PADDLE_OFFSET = 10;

let leftPaddleTop = 10;
let rightPaddleTop = 30;

let leftScore = 0;
let rightScore = 0;
let gameOver = false;

document.addEventListener("mousemove", e => {
  rightPaddleTop = e.y - canvas.offsetTop;
});

function draw() {
  // Fill the canvas with black
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, width, height);

  // Everything else will be white
  ctx.fillStyle = "white";

  // Draw the ball
  ctx.fillRect(ballPosition.x, ballPosition.y, BALL_SIZE, BALL_SIZE);

  // Draw the paddles
  ctx.fillRect(
    PADDLE_OFFSET,
    leftPaddleTop,
    PADDLE_WIDTH,
    PADDLE_HEIGHT
 );
  ctx.fillRect(
    width - PADDLE_WIDTH - PADDLE_OFFSET,
    rightPaddleTop,
    PADDLE_WIDTH,
    PADDLE_HEIGHT
  );

  // Draw scores
  ctx.font = "30px monospace";
  ctx.textAlign = "left";
  ctx.fillText(leftScore.toString(), 50, 50);
  ctx.textAlign = "right";
  ctx.fillText(rightScore.toString(), width - 50, 50);
}

function followBall() {
  let ball = {
    top: ballPosition.y,
    bottom: ballPosition.y + BALL_SIZE
  };
  let leftPaddle = {
    top: leftPaddleTop,
    bottom: leftPaddleTop + PADDLE_HEIGHT
  };

  if (ball.top < leftPaddle.top) {
    leftPaddleTop -= MAX_COMPUTER_SPEED;
  } else if (ball.bottom > leftPaddle.bottom) {
    leftPaddleTop += MAX_COMPUTER_SPEED;
  }
}

function update() {
  ballPosition.x += xSpeed;
  ballPosition.y += ySpeed;
  followBall();
}

function checkPaddleCollision(ball, paddle) {
  // Check if the paddle and ball overlap vertically and horizontally
  return (
    ball.left   < paddle.right &&
    ball.right  > paddle.left &&
    ball.top    < paddle.bottom &&
    ball.bottom > paddle.top
  );
}

function adjustAngle(distanceFromTop, distanceFromBottom) {
  if (distanceFromTop < 0) {
    // If ball hit near top of paddle, reduce ySpeed
    ySpeed -= 0.5;
  } else if (distanceFromBottom < 0) {
    // If ball hit near bottom of paddle, increase ySpeed
 ySpeed += 0.5;
  }
}

function checkCollision() {
  let ball = {
    left: ballPosition.x,
    right: ballPosition.x + BALL_SIZE,
    top: ballPosition.y,
    bottom: ballPosition.y + BALL_SIZE
  }

  let leftPaddle = {
    left: PADDLE_OFFSET,
    right: PADDLE_OFFSET + PADDLE_WIDTH,
    top: leftPaddleTop,
    bottom: leftPaddleTop + PADDLE_HEIGHT
  };

  let rightPaddle = {
    left: width - PADDLE_WIDTH - PADDLE_OFFSET,
    right: width - PADDLE_OFFSET,
    top: rightPaddleTop,
    bottom: rightPaddleTop + PADDLE_HEIGHT
  };

  if (checkPaddleCollision(ball, leftPaddle)) {
    // Left paddle collision happened
    let distanceFromTop = ball.top - leftPaddle.top;
    let distanceFromBottom = leftPaddle.bottom - ball.bottom;
    adjustAngle(distanceFromTop, distanceFromBottom);
    xSpeed = Math.abs(xSpeed);
  }

  if (checkPaddleCollision(ball, rightPaddle)) {
    // Right paddle collision happened
    let distanceFromTop = ball.top - rightPaddle.top;
    let distanceFromBottom = rightPaddle.bottom - ball.bottom;
    adjustAngle(distanceFromTop, distanceFromBottom);
    xSpeed = -Math.abs(xSpeed);
  }

  if (ball.left < 0) {
    rightScore++;
    initBall();
  }
  if (ball.right > width) {
    leftScore++;
    initBall();
  }
  if (leftScore > 9 || rightScore > 9) {
    gameOver = true;
  }
 if (ball.top < 0 || ball.bottom > height) {
    ySpeed = -ySpeed;
    }
}

function drawGameOver() {
  ctx.fillStyle = "white";
  ctx.font = "30px monospace";
  ctx.textAlign = "center";
  ctx.fillText("GAME OVER", width / 2, height / 2);
}

function gameLoop() {
  draw();
  update();
  checkCollision();

  if (gameOver) {
    draw();
    drawGameOver();
  } else {
    // Call this function again after a timeout
    setTimeout(gameLoop, 30);
  }
}

initBall();
gameLoop(); 

列表 10-24:完整代码

总结

在本章中,你从零开始创建了一个完整的游戏。游戏循环、碰撞检测和渲染的基础知识是广泛适用的,因此凭借你在这里获得的知识,你可以开始创建各种 2D 游戏。例如,你可以尝试实现自己的BreakoutSnake版本。如果你在逻辑上需要一些帮助,网上有很多教程可以跟随。玩得开心!

第三章:11 面向对象的 PONG

在上一章中,我们构建了自己的Pong游戏版本。早些时候,在第六章中,你学习了 JavaScript 中的类和面向对象编程。你可能会想,为什么我们在Pong的实现中没有使用任何类。主要原因是我想让游戏代码尽可能简单,不加入任何不必要的概念,以便更容易理解实际游戏在做什么。然而,随着程序变得越来越大和复杂,给它们添加更多结构是很有帮助的,而一种常见的做法就是使用面向对象编程。

为了帮助你更好地理解如何以面向对象的风格设计软件,在本章中我们将演示一个面向对象版本的Pong。游戏的逻辑不会有任何变化,但代码的结构和组织方式将会不同。例如,处理球的代码将全部放在一个名为 Ball 的类中。我们将使用这个类来跟踪球的位置,并确定当球撞到墙壁或挡板时应该如何反弹。类似地,处理挡板的所有代码将放在一个名为 Paddle 的类中。通过让 Ball 和 Paddle 类继承自一个共享的父类,我们可以轻松地共享适用于球和挡板的公共代码。

本章将探索面向对象的Pong程序的一般结构,但我们不会深入到每一行代码的细节;你应该已经从上一章对它的工作原理有了相当好的理解。考虑到这一点,我们将不会逐步构建游戏,而是按照顺序逐节地讲解完整的代码。由于这个原因,代码在你输入完整之前不会正确运行或真正执行任何操作。但在我们进入代码之前,让我们首先更广泛地看一下如何设计面向对象的计算机程序。

面向对象设计

以面向对象的方式编写代码通过将代码组织成表示程序各个方面的类,给计算机程序增加了结构。这种结构使得其他程序员(甚至以后版本的你自己)更容易理解你的代码如何工作。面向对象设计技术的完整阐述超出了本书的范围,但在本节中,我们将探讨一些面向对象编程的核心关键原则。

面向对象设计的一个重要初步步骤是对你的领域或程序的世界进行建模。程序中有哪些不同的元素,它们需要做什么,以及它们如何相互关联和交互?在这个例子中,领域是游戏Pong,游戏中有几个可见的元素:球、球拍和得分。虽然有两个球拍,但它们的行为大致相同,因此我们可以创建一个 Paddle 类并实例化两个自定义对象。同时,球足够独特,值得有一个自己的类。我们还需要建模这些元素如何交互。例如,如何建模球与球拍的碰撞?这段代码必须放在某个地方。正如你将看到的,在我的设计中,我决定这段代码应该放在 Ball 类中。换句话说,球应该“知道”在与球拍和墙壁碰撞时如何反弹。

面向对象编程的另一个重要概念是封装。这意味着将类的内部细节隐藏起来,仅提供一个简单的接口供程序与类进行交互。封装这些细节使得我们可以在不影响程序其他部分的情况下,轻松地修改这些细节。例如,Ball 类不需要向程序的其他部分暴露它的速度或位置。如果我们决定改变速度的表示方式(例如,使用角度和速度代替 xSpeed 和 ySpeed),那么我们不需要修改程序的其他部分。

注意

从技术上讲,xSpeed 和 ySpeed 将可以在 Ball 类外部访问,但我们不会访问它们,因此我们可以将其视为封装的细节。JavaScript 确实有一种声明属性为私有的方式,意味着这些属性不能在类外部访问,但在撰写本文时,这是一个新特性,并且并非所有浏览器都支持。

面向对象编程的一个关键概念是多态,即如果一个方法期望接收某个类的对象,那么它也可以接收该类子类实例的对象。例如,在这一章中,你将看到一个 Entity 类,它有一个 draw 方法以及两个子类:Paddle 和 Ball。符合多态原则,任何使用 draw 方法的代码应该能够接收任何类型的 Entity 作为参数,而不需要关心我们传入的是 Ball 还是 Paddle。

最终,面向对象设计更多的是一种艺术,而非科学,而且有很多不同的实现方式。你应该将本章中的设计视为解决问题的一种可能方式,而不是“唯一正确的做法”。记住这一点后,让我们深入了解我们的面向对象Pong代码。

文件结构

面向对象版本的 Pong 的 HTML 与上一章完全相同,但 JavaScript 完全不同。如果你愿意,可以复制 tennjs 目录,删除 script.js 文件,并根据以下各节中的代码创建一个新的 script.js 文件。或者,你也可以直接删除现有 tennjs 目录中 script.js 文件的所有代码,并用新的面向对象代码替换它。不管哪种方式,更新后的 script.js 文件将由一系列类声明组成,之后是一些额外的代码来启动游戏。我们将依次查看每一部分代码。

GameView 类

我们将声明的第一个类叫做 GameView。这个类负责玩家对游戏的视图,即游戏的显示方式。由于游戏使用画布进行渲染,GameView 类负责管理画布和绘图上下文。该类还负责将球和挡板等元素绘制到画布上,并显示“GAME OVER”文本。请参见清单 11-1 中的代码。

class GameView {
❶ constructor() {
    let canvas = document.querySelector("#canvas");
    this.ctx = canvas.getContext("2d");
 this.width = canvas.width;
    this.height = canvas.height;
    this.offsetTop = canvas.offsetTop;
  }

❷ draw(…entities) {
    // Fill the canvas with black
    this.ctx.fillStyle = "black";
    this.ctx.fillRect(0, 0, this.width, this.height);

  ❸ entities.forEach(entity => entity.draw(this.ctx));
    }

❹ drawScores(scores) {
    this.ctx.fillStyle = "white";
    this.ctx.font = "30px monospace";
    this.ctx.textAlign = "left";
    this.ctx.fillText(scores.leftScore.toString(), 50, 50);
    this.ctx.textAlign = "right";
    this.ctx.fillText(scores.rightScore.toString(), this.width - 50, 50);
  }

❺ drawGameOver() {
    this.ctx.fillStyle = "white";
    this.ctx.font = "30px monospace";
    this.ctx.textAlign = "center";
    this.ctx.fillText("GAME OVER", this.width / 2, this.height / 2);
  }
} 

清单 11-1:GameView 类

GameView 构造函数 ❶ 获取对画布及其绘制上下文的引用,并将其分别保存为名为 canvas 和 ctx 的属性。它还存储了一些绘图所需的值:画布的宽度和高度,以及画布相对于浏览器视口顶部的偏移量。

draw 方法 ❷ 使用了在第五章中介绍的剩余参数。通过这种方式,你可以传递多个参数给 draw,所有的参数将被收集到一个名为 entities 的数组中。每个参数都是表示游戏元素的对象:球和两个挡板。该方法首先绘制一个黑色矩形来清空画布,然后遍历元素数组,依次调用每个元素的 draw 方法 ❸,并将绘制上下文作为参数传递。只有当传递给 GameView.draw 的每个对象都有自己的 draw 方法时,这种方式才有效;我们将在下一节看到如何实现这一点。GameView 上的 draw 方法负责在每次游戏循环时将内容绘制到画布上,但它将实际绘制游戏元素的责任委托给表示这些元素的对象。实际上,游戏中的每个元素都“知道”如何绘制自己,而 GameView.draw 只是协调这些调用。

drawScores 方法 ❹ 接受一个包含两个分数的对象,并将它们绘制到画布上。这与上一章的得分绘制代码非常相似。主要区别在于,它不再依赖全局变量来获取画布的宽度,而是通过引用 this.width 来使用 GameView 类中的宽度属性。

drawGameOver 方法❺也与上一章中的相应函数大致相同,但它从 GameView 获取宽度和高度,而不是从全局变量获取。

游戏元素

接下来,我们将实现表示三种主要游戏元素的类:两个挡板和球。我们将从一个名为 Entity 的超类开始,它将作为 Paddle 和 Ball 子类的父类。Entity 类存在的目的是共享挡板和球的通用代码。这包括跟踪元素的大小和位置、计算元素的边界以进行碰撞检测,以及绘制元素。由于所有游戏元素都是矩形,因此无论是挡板还是球,这些代码都是相同的。这展示了面向对象编程的美妙之处:我们可以在超类中编写所有通用代码,然后让子类继承它。

示例 11-2 包含了 Entity 类的代码。

class Entity {
❶ constructor(x, y, width, height) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
    }

❷ boundingBox() {
    return {
      left: this.x,
      right: this.x + this.width,
      top: this.y,
      bottom: this.y + this.height
    };
  }

❸ draw(ctx) {
    ctx.fillStyle = "white";
    ctx.fillRect(this.x, this.y, this.width, this.height);
  }
} 

示例 11-2:Entity 类

Entity 构造函数❶接受表示实体左上角的 x 和 y 坐标,以及表示实体大小的宽度和高度。这些值会作为属性保存。

boundingBox 方法❷返回一个对象,包含实体的左、右、上、下边界。在上一章中,我们为每个实体在 checkCollision 函数中手动创建了这些对象。Entity 超类为我们提供了一种方便的方法,可以将这种常见的计算方式推广到球和挡板。

draw 方法❸接受一个绘图上下文,并使用构造函数中定义的属性绘制一个白色矩形。传递到 GameView 上的 draw 方法的对象都将是 Entity 的子类,而 Entity 类中的 draw 方法将为每个项调用。

Paddles 类

Paddle 类继承自 Entity 类。在示例 11-3 中声明。

class Paddle extends Entity {
❶ static WIDTH = 5;
  static HEIGHT = 20
  static OFFSET = 10;

❷ constructor(x, y) {
    super(x, y, Paddle.WIDTH, Paddle.HEIGHT);
  }
} 

示例 11-3:Paddle 类

这个类包含三个静态属性,这些属性是分配给类本身的,而不是类的单个实例。静态属性的值将在所有类实例之间共享。在这个例子中,尽管每个 Paddle 实例需要自己的 x 和 y 坐标,但每个 Paddle 对象应该具有相同的宽度、高度,以及与画布左右边缘的相同偏移。因此,我们将这些值定义为静态属性 WIDTH、HEIGHT 和 OFFSET,它们对应于上一章中的 PADDLE_WIDTH、PADDLE_HEIGHT 和 PADDLE_OFFSET 常量。

注意

在类中没有直接的方法来定义静态常量,这就是为什么上一章中的常量现在技术上变成了变量。它们的名称都是大写字母,表示它们应该作为常量来处理。

你可以使用 static 关键字声明静态属性。例如,我们使用 static WIDTH = 5 ❶ 声明了 WIDTH 静态属性。静态属性通过点表示法访问,就像实例的属性一样,不同的是你在点的左边使用类名,而不是 this 或实例的名称。例如,Paddle.WIDTH 访问 WIDTH 静态属性。

Paddle 构造函数 ❷ 只有两个参数:x 和 y。它使用 super 调用父类(Entity)的构造函数,并将 x 和 y 参数以及 Paddle.WIDTH 作为宽度参数,Paddle.HEIGHT 作为高度参数传递。

Ball 类

接下来是 Ball 类。它和 Paddle 类类似,都是继承自 Entity,但 Ball 有自己的逻辑来根据速度更新位置,并进行碰撞检测。清单 11-4 显示了该类代码的第一部分。

class Ball extends Entity {
❶ static SIZE = 5;

❷ constructor() {
    super(0, 0, Ball.SIZE, Ball.SIZE);
  ❸ this.init();
  }

❹ init() {
    this.x = 20;
    this.y = 30;
    this.xSpeed = 4;
    this.ySpeed = 2;
  }

❺ update() {
    this.x += this.xSpeed;
    this.y += this.ySpeed;
    }

❻ adjustAngle(distanceFromTop, distanceFromBottom) {
    if (distanceFromTop < 0) {
      // If ball hit near top of paddle, reduce ySpeed
      this.ySpeed -= 0.5;
    } else if (distanceFromBottom < 0) {
      // If ball hit near bottom of paddle, increase ySpeed
      this.ySpeed += 0.5;
    }
  } 

清单 11-4:Ball 类的开头

这个类有一个静态属性叫 SIZE,定义了球的宽度和高度 ❶。接下来是它的构造函数方法 ❷。和 Paddle 构造函数一样,Ball 构造函数首先做的事情是调用父类 Entity 的构造函数,这次传递 0 作为 x 和 y 参数,Ball.SIZE 作为宽度和高度参数。0 只是占位符;实际上,球每次都会从相同的位置开始(20,30)。这个定位由 Ball 类的 init 方法处理,它在构造函数中第一次被调用 ❸。init 方法本身用于设置球的初始位置和速度 ❹,就像上一章中的 initBall 函数一样。每当球需要重置为初始位置(得分后),这个方法会被调用。

接下来的方法 update 使用球的当前速度来更新其 x 和 y 位置 ❺。接着是 adjustAngle 方法 ❻,它等同于上一章中描述的 adjustAngle 函数。根据球与挡板碰撞的位置,它改变球的垂直速度(反弹角度)。

Ball 类的定义在清单 11-5 中继续,包含了碰撞检测的方法。

class Ball extends Entity {
--snip--
  checkPaddleCollision(paddle, xSpeedAfterBounce) {
  ❶ let ballBox = this.boundingBox();
    let paddleBox = paddle.boundingBox();

    // Check if the ball and paddle overlap vertically and horizontally
  ❷ let collisionOccurred = (
      ballBox.left< paddleBox.right &&
      ballBox.right  > paddleBox.left &&
      ballBox.top< paddleBox.bottom &&
      ballBox.bottom > paddleBox.top
    );

    if (collisionOccurred) {
      let distanceFromTop = ballBox.top - paddleBox.top;
      let distanceFromBottom = paddleBox.bottom - ballBox.bottom;
    ❸ this.adjustAngle(distanceFromTop, distanceFromBottom);
    ❹ this.xSpeed = xSpeedAfterBounce;
    }
  }

  checkWallCollision(width, height, scores) {
    let ballBox = this.boundingBox();

    // Hit left wall
  ❺ if (ballBox.left < 0) {
      scores.rightScore++;
      this.init();
    }
    // Hit right wall
  ❻ if (ballBox.right > width) {
      scores.leftScore++;
      this.init();
    }
    // Hit top or bottom walls
    if (ballBox.top < 0 || ballBox.bottom > height) {
    ❼ this.ySpeed = -this.ySpeed;
    }
  }
} 

清单 11-5:Ball 类的其余部分

checkPaddleCollision 方法与上一章的 checkCollision 和 checkPaddleCollision 函数有一些重叠。该方法接受两个参数:表示其中一个球拍的对象和 xSpeedAfterBounce。后者表示如果发生球拍反弹,我们应将 xSpeed 设置为的新值,并允许我们配置球是否应该总是从左球拍反弹到右侧,或从右球拍反弹到左侧。与上一章一样,我们要求球与左球拍碰撞时向右弹回,反之亦然,以避免球在“球拍内部”反弹的奇怪情况。

我们使用父类 Entity 中的 boundingBox 方法来获取球和球拍的边界框 ❶,并将它们分别存储为 ballBox 和 paddleBox。接下来,我们比较不同的边界框边缘,判断球和球拍之间是否发生了碰撞,并将结果保存在布尔变量 collisionOccurred 中 ❷。如果 collisionOccurred 为 true,我们调用 adjustAngle 方法,并根据边界框计算出的适当距离 ❸,然后将球的 xSpeed 设置为 xSpeedAfterBounce ❹。

最后,checkWallCollision 方法检查球与墙壁之间是否发生了碰撞。它接受游戏区域的宽度和高度以及表示得分的对象作为参数。如果球击中左墙 ❺或右墙 ❻,则相应的得分会增加,并通过调用 init 方法重置球。如果球击中上下墙,它会弹回 ❼。

得分和计算机类

得分类是一个简单的容器,用于跟踪当前的得分。计算机类包含用于跟踪球的逻辑。这两个类的代码在列表 11-6 中。

class Scores {
❶ constructor() {
    this.leftScore = 0;
    this.rightScore = 0;
   }
}

class Computer {
❷ static followBall(paddle, ball) {
    const MAX_SPEED = 2;
    let ballBox = ball.boundingBox();
    let paddleBox = paddle.boundingBox();

    if (ballBox.top < paddleBox.top) {
      paddle.y -= MAX_SPEED;
    } else if (ballBox.bottom > paddleBox.bottom) {
      paddle.y += MAX_SPEED;
    }
  }
} 

列表 11-6:得分和计算机类

得分构造函数 ❶将左右玩家的得分初始化为 0。我们本可以仅使用一个普通对象来表示得分,但使用类能让代码结构更加一致。

计算机类有一个名为 followBall 的方法,用于根据球的位置更新左侧球拍的位置。这是一个静态方法,意味着它不需要类的实例来调用。我们通过使用 static 关键字 ❷将其声明为静态方法,类似于声明静态属性。静态方法通过类名而不是实例名来调用,像这样:Computer.followBall(leftPaddle, ball)。

注意

当某个类的实例需要存储特定的属性时,我们会创建该类的实例。计算机类没有任何属性,所以我们不需要为其创建实例。由于计算机类从未被实例化,它也不需要构造函数。

我们本可以轻松地创建一个独立的函数来移动左侧挡板,但和 Scores 类一样,将代码保持在 Computer 类内有助于保持一致性。

Game 类

我们最终来到了 Game 类,这是所有其他类(如果适用)被实例化并且被拼接在一起、协调工作的地方。请参见列表 11-7 查看代码的第一部分。

class Game {
  constructor() {
    this.gameView = new GameView();
    this.ball = new Ball();
  ❶ this.leftPaddle = new Paddle(Paddle.OFFSET, 10);
  ❷ this.rightPaddle = new Paddle(
      this.gameView.width - Paddle.OFFSET - Paddle.WIDTH,
      30
    );

  ❸ this.scores = new Scores();
    this.gameOver = false;

  ❹ document.addEventListener("mousemove", e => {
    this.rightPaddle.y = e.y - this.gameView.offsetTop;
    });
  }

  draw() {
  ❺ this.gameView.draw(
      this.ball,
      this.leftPaddle,
      this.rightPaddle
    );

  ❻ this.gameView.drawScores(this.scores);
  } 

列表 11-7:Game 类的第一部分

Game 构造函数首先实例化了 GameView、Ball 和 Paddle 类。leftPaddle 实例通过 Paddle.OFFSET 来设置其 x 坐标 ❶。rightPaddle 则通过 Paddle.OFFSET、Paddle.WIDTH 和 this.gameView.width 来确定其 x 坐标 ❷,这与我们在上一章计算右边挡板位置的方式类似。

在一个类内部实例化其他类是面向对象代码中的常见特性。这种技术被称为组合,因为我们在其他实例内部组合实例。

接下来,Game 构造函数实例化了 Scores ❸并将 gameOver 布尔值设置为 false。最后,它设置了一个 mousemove 事件监听器 ❹,当用户移动鼠标时更新右侧挡板的位置。在类构造函数中设置的事件监听器与我们在本书中看到的其他事件监听器一样:只要应用程序运行,它就会一直有效,并在检测到事件时触发其处理函数。

构造函数之后是 Game 类的 draw 方法,它负责绘制游戏的所有视觉元素。首先,该方法调用 this.gameView.draw ❺,传递了三个主要游戏元素:this.ball、this.leftPaddle 和 this.rightPaddle。这是对我们在列表 11-1 中看到的 GameView 类的 draw 方法的调用,它接收可变数量的对象作为参数,并对每个对象调用 draw 方法。最终的结果是,game.draw 调用 gameView.draw,进而调用 ball.draw、leftPaddle.draw 和 rightPaddle.draw。这个过程看起来有点绕,但你会发现面向对象代码中经常会有类似的情况,保持代码在逻辑上合适的位置有时需要绕过一些复杂的步骤。在这个例子中,game.draw 负责知道哪些对象需要绘制(因为 Game 类跟踪了所有的游戏元素);gameView.draw 负责绘制上下文、清空画布,并调用各个元素的 draw 方法;而每个游戏元素的 draw 方法则负责知道如何绘制自身。

在绘制所有实体之后,draw 方法调用了 this.gameView.drawScores,并传递了 this.scores 对象 ❻。

Game 类在列表 11-8 中继续实现其剩余的方法。

class Game {
--snip--
  checkCollision() {
    this.ball.checkPaddleCollision(this.leftPaddle,
                                 ❶ Math.abs(this.ball.xSpeed));
    this.ball.checkPaddleCollision(this.rightPaddle,
                                 ❷ -Math.abs(this.ball.xSpeed));

  ❸ this.ball.checkWallCollision(
      this.gameView.width,
      this.gameView.height,
      this.scores
    );

  ❹ if (this.scores.leftScore > 9 || this.scores.rightScore > 9) {
      this.gameOver = true;
    }
  }

❺ update() {
    this.ball.update();
    Computer.followBall(this.leftPaddle, this.ball);
  }

❻ loop() {
    this.draw();
    this.update();
    this.checkCollision();

  ❼ if (this.gameOver) {
      this.draw();
      this.gameView.drawGameOver();
    } else {
      // Call this method again after a timeout
    ❽ setTimeout(() => this.loop(), 30);
    }
  }
} 

列表 11-8:Game 类的其余部分

Game 类的 checkCollision 方法协调所有的碰撞检测逻辑。首先,它调用球的 checkPaddleCollision 方法两次,以检查球与每个挡板之间的碰撞。回顾清单 11-5,这个方法接受两个参数:一个 Paddle 对象和一个新的、反弹后的 xSpeed 值。对于左侧挡板,我们知道我们希望球向右反弹,因此我们通过取当前 xSpeed 的 Math.abs 值来使新的 xSpeed 为正❶。对于右侧挡板,我们希望球向左反弹,因此我们通过取 Math.abs(xSpeed)的结果的负值来使新的 xSpeed 为负❷。

接下来,checkCollision 方法调用 ball.checkWallCollision 来处理墙壁碰撞❸。这个方法接受宽度和高度(因为 Ball 对象不知道游戏区域有多大)以及得分(如果撞到侧墙,就可以增加得分)。最后,方法检查是否有任何一个得分超过了阈值❹,如果超过,则将 this.gameOver 设置为 true。

Game 对象的 update 方法❺控制游戏循环每次重复时状态的变化。它调用球的 update 方法来移动球,然后通过 Computer.followBall 静态方法根据球的新位置告诉计算机移动左侧挡板。

Game 类的最后一个方法 loop 定义了游戏循环❻。我们按顺序调用 this.draw、this.update 和 this.checkCollision。然后,我们检查 this.gameOver 是否为 true。如果是❼,我们再次调用 draw 以渲染最终得分,并调用 gameView.drawGameOver 渲染“GAME OVER”文本。否则,我们使用 setTimeout 在 30 毫秒后再次调用 loop 方法❽,继续游戏。

开始游戏

我们需要做的最后一件事是通过实例化 Game 类并启动游戏循环来开始游戏,如清单 11-9 所示。

let game = new Game();
game.loop(); 

清单 11-9:开始游戏

我们必须在程序的顶层创建 Game 类的实例,而不是在任何类定义内部。所有其他所需的对象都是由 Game 类的构造函数实例化的,因此创建一个 Game 对象会自动创建所有其他对象。我们也可以让 Game 构造函数调用 loop 方法,以便在 Game 类实例化时就开始游戏。然而,将第一次调用 game.loop 放在程序的顶层可以更容易地看到游戏何时开始。

有了这个最终的清单,我们现在拥有了面向对象版本的游戏的所有代码!只要你按顺序输入所有代码,现在应该能正常运行,并且游戏玩法应与前一章的版本完全相同。

总结

在本章中,你创建了一个面向对象版本的Pong程序,并在此过程中学习了一些面向对象软件设计的策略。前一章中的游戏逻辑没有变化;只有代码的组织方式不同。根据你的偏好和面向对象代码的经验,你可能会发现这两种版本中的某一种更容易阅读和理解。

面向对象设计是一个复杂的领域,通常需要大量的实践才能将程序分解成各自独立且合理的对象。即使在这个简单的游戏中,你也可以用许多不同的方式将游戏的组件拆分成对象和方法。例如,你可能会认为 GameView 类是不必要的,Game 类本身就可以跟踪画布,从而避免复杂的绘制调用层层嵌套。最重要的是,以一种对你和其他程序员都易于理解和修改的方式来组织你的代码。

第四章:12 生成声音

现在是时候做点完全不同的事情了!在下一个项目中,你将使用 JavaScript 和 Web Audio API 创建一首歌曲。你还将学习一些关于声音合成的通用技巧,以及电子音乐是如何制作的。

本章将介绍 Web Audio API 以及基于它构建的 JavaScript 库 Tone.js。这将是你第一次接触广阔的第三方 JavaScript 库世界,它们是一些预先编写好的代码集合,可以帮助你简化复杂的任务。与 Web Audio API 相比,Tone.js 提高了抽象层次,让你能够以更自然的方式思考和实现音乐概念。掌握其工作原理后,在第十三章中,你将运用所学的知识制作一首歌曲,你可以自定义甚至重写它。

Web Audio API

本节内容介绍 Web Audio API 的基础知识,它提供了一种使用 JavaScript 在浏览器中创建和操控声音的方法。Google Chrome 在 2011 年引入了 Web Audio API,随后它被发布为 W3C 标准(W3C,或称万维网联盟,是一个制定 Web 标准的组织)。使用 Web Audio API 时,你需要创建节点并将它们连接在一起。每个节点表示声音的某个方面——一个节点可能生成基本音调,第二个节点可能设置其音量,第三个节点可能为音调应用效果,例如混响或失真,依此类推。通过这种方式,你可以制作几乎任何你想要的声音。

设置

一如既往,我们从一个简单的 HTML 文件开始。该文件将允许用户播放 Web Audio API 生成的声音。创建一个名为music的新目录,并将示例 12-1 中的内容放入一个名为index.html的新文件中。

<!DOCTYPE html>
<html>
  <head>
    <title>Music</title>
  </head>
  <body>
  ❶ <button id="play">Play</button>
  ❷ <p id="playing" style="display: none">Playing</p>
    <script src="script.js"></script>
  </body>
</html> 

示例 12-1: 用于探索 Web Audio API 的 index.html 文件

这个示例创建了两个可视化元素:一个播放按钮 ❶ 和一个包含文本“正在播放” ❷ 的段落。该段落使用了内联样式属性,这使我们能够直接在 HTML 文件中向元素添加 CSS 声明。在这种情况下,我们将 display 设置为 none,从而隐藏该元素。稍后,我们将使用 JavaScript 删除该样式,并在音频播放时显示该元素。

接下来,我们将开始编写 JavaScript 代码。在许多浏览器中,包括谷歌 Chrome,Web Audio API 在用户与页面交互之前不会播放任何声音。我们使用播放按钮作为交互元素,点击该按钮将触发我们的音频代码。因为我们只需要按钮被点击一次,所以点击后我们将隐藏按钮。

在与 HTML 文件相同的目录下创建script.js文件,并添加 Listing 12-2 中显示的内容。该代码隐藏了播放按钮,并在用户点击按钮时显示“播放中”文本。请注意,我们现在还没有编写任何 Web Audio API 的代码——这只是设置按钮的部分。

❶ let play = document.querySelector("#play");
let playing = document.querySelector("#playing");
❷ play.addEventListener("click", () => {
  // Hide this button
  play.style = "display: none";
  playing.style = " ";
}); 

Listing 12-2:在鼠标点击时切换元素的可见性

首先,我们使用 document.querySelector 方法 ❶ 获取两个元素的引用。然后,我们为播放按钮添加点击事件监听器 ❷。当用户点击按钮时,我们的事件监听器会将 display: none 内联样式属性添加到按钮上,并将段落的内联样式设置为空字符串,从而有效地移除在 HTML 文件中设置的内联样式。此代码的最终效果是,点击播放按钮会隐藏按钮并显示段落。这样做有两个目的:一是让用户知道音乐应该开始播放了,二是移除播放按钮,防止再次点击。

使用 Web Audio API 生成音调

在完成设置后,我们现在可以编写一些 Web Audio API 的代码。首先,我们将生成一个单一的音调,相当于“你好,世界!”的音频。生成音调的代码显示在 Listing 12-3 中。如前所述,音频不会播放,除非由用户事件触发,例如鼠标点击,因此所有音频代码都放在点击事件处理程序中。

`--snip--`
play.addEventListener("click", () => {
 // Hide this button
 play.style = "display: none";
 playing.style = " ";

❶ let audioCtx = new AudioContext();

❷ let oscNode = audioCtx.createOscillator();
  oscNode.frequency.value = 440;

❸ let gainNode = audioCtx.createGain();
  gainNode.gain.value = 0.5;

❹ oscNode.connect(gainNode);
  gainNode.connect(audioCtx.destination);

❺ oscNode.start(audioCtx.currentTime);
  oscNode.stop(audioCtx.currentTime + 2);
}); 

Listing 12-3:使用 Web Audio API 播放单一音调

我们首先做的是创建音频上下文 ❶。这是我们与 Web Audio API 交互的对象,类似于画布元素的绘图上下文。接下来,我们创建第一个节点,一个振荡器 ❷。在电子学和信号处理术语中,振荡器是一个生成信号的设备,该信号会按照规律的模式反复上下波动。Web Audio API 振荡器输出的默认波形是正弦波,如图 12-1 所示。当波形振荡足够快,并且与扬声器连接时,它会产生可听的音调。在这个例子中,我们将频率设置为 440 赫兹(Hz),即每秒 440 次循环。换句话说,振荡器输出的信号在每秒内会从 0 到 1 再到 –1,然后返回 0,共经历 440 次变化。这意味着波形的一个周期持续 1/440 秒,约为 2.27 毫秒。我这里使用 440 Hz,因为它是调音时常用的标准参考音高,对应的是中央 C 上的 A 音。

图 12-1:一个正弦波的周期

接下来,我们创建一个增益节点 ❸ 并将其值设置为 0.5。在信号处理术语中,增益指的是信号幅度的增减,或其数值范围的变化。实际上,增益就像是音量控制。增益为 2 时,幅度加倍,声音变得更大;增益为 0.5 时,幅度减半,声音变得更小;而增益为 1(增益节点的默认值)则对幅度没有影响。对来自图 12-1 的正弦波应用 0.5 的增益,将产生一个最大值为 0.5、最小值为 –0.5 的正弦波,如图 12-2 所示。

图 12-2:图 12-1 中的正弦波,应用了 0.5 的增益

到目前为止,我们有两个节点:一个振荡器节点和一个增益节点。为了将增益应用于振荡器的信号,我们需要将这些节点连接在一起。我们使用振荡器节点的连接方法 ❹,将振荡器节点的输出连接到增益节点的输入。然后,为了能够听到结果,我们将增益节点的输出连接到主输出,这可以通过音频上下文中的 ctx.destination 获取。这些连接意味着振荡器信号经过增益节点后传递到输出,最终会传送到你的耳机或扬声器,如果声音已开启的话。图 12-3 展示了这些连接。

图 12-3:节点的图示

振荡器节点实际上在我们告诉它之前并不会创建信号。为了做到这一点,我们调用振荡器的 start 方法,并将 audioCtx.currentTime 作为参数传递 ❺。currentTime 属性对应于音频上下文已激活的秒数。通过将 audioCtx.currentTime 传递给 start 方法,我们告诉振荡器立即开始播放。然后我们调用 stop 方法,将 audioCtx.currentTime + 2 传递给它。这告诉振荡器在开始播放两秒后停止。

这段代码的效果是,当你在浏览器中加载 index.html 页面并点击播放按钮时,应该播放一个 440 Hz 的音调,持续两秒钟。如果没有声音,请确保你的计算机和浏览器的声音已启用——例如,通过播放 YouTube 视频。如果仍然没有声音,请检查控制台,确保没有错误。

你可能会想,为什么要为一个非常简单的示例写这么多代码,没错,你的想法是对的!Web Audio API 功能强大,但你必须在非常低的层次上使用极其基本的构建块。为了简化,接下来我们将把焦点转向一个流行的高级音频库——Tone.js。

Tone.js 库

Tone.js 库建立在 Web Audio API 之上。它的设计目的是简化使用 API 创建音乐的过程。例如,Tone.js 让你可以使用电子乐器并控制音量,而不必手动调节振荡器和增益节点。你可以使用音符名称代替频率,也可以用小节和拍子代替使用秒来控制事件的发生时间。

Tone.js 网站 https://tonejs.github.io 提供了安装和使用该库的详细信息。最简单的方式是使用托管在内容分发网络(CDN)上的预构建文件,例如 https://unpkg.com,这就是我们在这里所做的。通过这种方式,您只需通过 HTML 文件中的脚本元素直接引用 URL 即可访问该库。只要在工作时能连接互联网,就无需下载库的副本。

使用 Tone.js 生成音调

让我们使用 Tone.js 库重新创建 Web Audio API “Hello, world!” 示例。除了添加一个新的脚本标签用于库之外,其他 HTML 保持不变,如 示例 12-4 所示。

`--snip--`
 <p id="playing" style="display: none">Playing</p>
    <script src="https://unpkg.com/tone@14.7.77/build/Tone.js"></script>
 <script src="script.js"></script>
 </body>
</html> 

示例 12-4:在 index.html 文件中包含 Tone.js

我们将新脚本元素的 src 设置为包含完整 Tone.js 库的 unpkg.com 文件,该库作为一个单独的 JavaScript 文件。

接下来,我们将编写 JavaScript 代码。由于 Tone.js 底层使用的是 Web Audio API,我们仍然面临一个限制,即需要用户输入来开始播放音频。因此,我们仍然需要点击事件处理程序,但 script.js 中的其他部分将发生变化。Listing 12-5 显示了更新后的 JavaScript 文件。

`--snip--`
play.addEventListener("click", () => {
 // Hide this button
 play.style = "display: none";
 playing.style = " ";

  Tone.start();

  let synth = new Tone.Synth({
    oscillator: {type: "sine"},
    envelope: {attack: 0, decay: 0, sustain: 1, release: 0},
 volume: -6 
 }).toDestination();

  synth.triggerAttackRelease("A4", 2, 0);
}); 

Listing 12-5: 使用 Tone.js 播放单一音调

我们需要做的第一件事是调用 Tone.start。这将触发 Tone.js 库在点击事件处理程序中启动,确保浏览器允许播放音频。接下来,我们创建一个新的 Tone.Synth 对象。Synthsynthesizer(合成器)的缩写,是一种电子乐器,通常带有键盘,可以生成(合成)各种声音。Tone.Synth 是这种乐器的简化代码版本。

Tone.Synth 构造函数接受一个对象作为参数,允许我们配置合成器的各个方面。在这种情况下,我们告诉合成器使用一个生成正弦波的振荡器。我们还给合成器设置了一个简单的振幅包络和 -6 的音量。我将在接下来的章节中解释这些设置的含义,但现在,这些设置是我们需要的,以匹配 Listing 12-3 中的 Web Audio API 振荡器。构造函数之后,我们链式调用 toDestination 方法,将合成器的输出连接到音频上下文的输出。

最后,我们告诉合成器使用它的 triggerAttackRelease 方法播放一个音符。这个方法需要音符名称、时长和播放时间。我们传入 "A4" 作为音符名称,它等于 440 Hz,并告诉它播放两秒钟,从现在开始。重新加载浏览器并点击播放按钮后,你应该能听到和运行 Listing 12-3 时相同的声音。

如你所见,使用 Tone.js 库简化了通过 Web Audio API 制作音乐的过程。你不需要为声音的不同方面(如音高、增益等)创建单独的节点,所有内容都统一在一个 Synth 对象下。如果你有任何音乐方面的知识,你会发现该库使用的概念比 API 更接近你的理解,例如使用音符名称而不是频率。随着你对 Tone.js 的了解增加,你将看到更多类似的例子。

理解 Tone.Synth 选项

让我们仔细看看我们在 Listing 12-5 中传递给 Tone.Synth 构造函数的对象。第一个属性,oscillator,定义了生成信号的振荡器选项。在这种情况下,我们只是设置振荡器的类型为正弦波,使用 type 属性。

下一个属性定义了振幅包络的选项,振幅包络决定了音符在其持续时间内音量的变化。大多数合成器,无论硬件还是软件,都允许你配置振幅包络。最常见的包络类型是ADSR 包络,即攻击、衰减、保持、释放的缩写。攻击是指从音符被触发(例如,当你按下合成器上的一个键)到音符达到最大音量之间的时间。衰减是指从攻击结束到音符进入保持阶段之间的时间。保持是一个增益值,定义了音符在攻击和衰减之后,在按键保持按下时音符将保持的音量。通常,这个值是包络攻击部分所达到的最大音量的某个比例。释放定义了当释放按键后,音符的振幅降到零所需的时间。图 12-4 以图形方式展示了这些不同的值。

图 12-4:ADSR 包络的各个部分

ADSR 包络是许多现实世界乐器工作原理的近似。例如,当你拉小提琴弓时,音符需要一些时间才能达到最大音量——也就是说,它有一个较长的攻击时间。相比之下,当你按下钢琴的一个键时,攻击时间非常短。类似地,当你停止拉小提琴弓时,弦的振动需要一点时间才能停止,而钢琴音符的释放则更为立即。合成的 ADSR 包络仍然相当简化——它们并不是现实乐器的完美模拟——但它们为原本可能只是单调音调的声音添加了很多表现力。

话虽如此,我们用于合成器的 ADSR 包络还是非常单调。我们将攻击、衰减和释放值设置为 0,保持值设置为 1,这意味着音调在整个持续时间内都保持最大音量。这与我们在清单 12-3 中使用简单的 Web 音频 API 振荡器所做的相匹配,这也是为什么生成的音调听起来如此合成的原因之一。

合成器选项对象的最后一个属性,volume,设置了合成器的总体音量,单位为分贝(dB)。分贝是一种表示增益的替代方式,在某些方面它们比其他方式更符合我们对增益的理解。0 分贝的设置相当于增益为 1(音量没有变化),–6 分贝相当于增益为 0.5,即音量减半,–12 分贝对应于增益为 0.25,即音量的四分之一,依此类推;每增加 +6 分贝,音量翻倍,每减少 –6 分贝,音量减半。我们的耳朵对声音之间的相对音量敏感,因此每次音量减半或翻倍时,我们感觉它像是以固定的量上升或下降。这“固定量”是增加或减少的固定分贝数,这也是为什么分贝在设置音量时可能更容易使用。在这种情况下,我们传递了 –6 dB,以匹配清单 12-3 中的 0.5 增益。

现在你知道了有哪些选项,让我们试着玩一玩它们!首先,我们将修改振荡器的类型。目前振荡器设置为生成正弦波,但我们将改为生成方波。图 12-5 展示了方波一个周期的波形。

图 12-5:方波

请注意,方波的幅度值之间有突变的过渡,而不像正弦波那样有平滑的曲线。切换到方波振荡器的代码更改显示在清单 12-6 中。

`--snip--`
 let synth = new Tone.Synth({
    oscillator: {type: "square"},
 envelope: {attack: 0, decay: 0, sustain: 1, release: 0},
 volume: -6 
 }).toDestination();
`--snip--` 

清单 12-6:将振荡器类型更改为方波

当你在浏览器中重新加载代码时,你应该能听到一个非常不同的音调。方波比正弦波更响亮、更明亮。你还可以尝试一些其他的振荡器类型,如“triangle”(三角波)和“sawtooth”(锯齿波)。图 12-6 展示了这两种波形。

图 12-6:锯齿波和三角波

想一想这些其他振荡器类型与“正弦波”和“方波”有什么不同。每种振荡器独特的声音被称为它的音色音质

接下来,让我们尝试改变包络。我们故意在清单 12-5 中使用了一个非常基础的包络,以匹配清单 12-3 中的 Web 音频 API 示例,该示例没有包络。现在我们将这些值设置为听起来更有音乐感的值,如清单 12-7 所示。

`--snip--`
 let synth = new Tone.Synth({
 oscillator: { type: "square" },
    envelope: {attack: 0.8, decay: 0.3, sustain: 0.8, release: 1},
 volume: -6 
 }).toDestination();
`--snip--` 

清单 12-7:将振荡器类型更改为方波

攻击、衰减和释放的数值都是以秒为单位,而持续时间则是一个介于 0 和 1 之间的数值,表示要持续的音量水平。这里我们将攻击设置为 0.8 秒,衰减为 0.3 秒,持续时间为 0.8,释放时间为 1 秒。当你重新加载页面并播放声音时,你应该会听到音符逐渐达到最大音量,然后稍微减小。两秒后,音符被释放并在一秒钟内逐渐消失。

最后的参数是音量。如我之前所解释的,每次减少 6 dB,音量就会减半,而增加 6 dB 时,音量会翻倍。可以尝试一些不同的数值,例如–12、–18 或–24。你也可以反向调整,直到 0 dB。

顺序播放多个音符

我们的合成器当前只播放一个音符,但我们可以轻松播放更多音符。Tone.js 中的音符频率可以用赫兹(Hz)表示,也可以用音符名称表示,像我们在清单 12-5 中做的那样。这些音符名称对应于键盘上的键,如图 12-7 所示。

图 12-7:键盘上的音符名称

C4 被称为中音 C,它位于大多数钢琴键盘的中央。每个音阶的 C 到上方的 B 都有一个编号。例如,图 12-7 中最左边的键是 C3,而它上方的一个八度就是 C4。如前所述,440 Hz 对应 A4,它是 C4 上的 A。黑键被称为变音,它们比左侧的键高一个半音,或者比右侧的键低一个半音。例如,C4 右侧的黑键可以叫做 C♯4 或 D♭4(♯是升音的符号,表示比左边的音高一个半音,而♭是降音的符号,表示比右边的音低一个半音)。在 Tone.js 中书写音符名称时,我们使用井号(#)表示升音,字母 b 表示降音。

注意

B 和 C 之间、E 和 F 之间没有黑键,因为这两个音符之间只有一个半音的距离。

我们将从 A3 到 A4 播放一个大调音阶,包括 A3、B3、C♯4、D4、E4、F♯4、G♯4 和 A4。更新你的script.js,将清单 12-8 中的代码包含进去,实现这个音阶。

`--snip--`
 let synth = new Tone.Synth({
 oscillator: {type: "square"},
 ❶ envelope: { attack: 0.1, decay: 0.3, sustain: 0.8, release: 0.1 },
 volume: -6
 }).toDestination();

  synth.triggerAttackRelease("A3", 0.9, 0);
  synth.triggerAttackRelease("B3", 0.9, 1);
  synth.triggerAttackRelease("C#4", 0.9, 2);
  synth.triggerAttackRelease("D4", 0.9, 3);
  synth.triggerAttackRelease("E4", 0.9, 4);
  synth.triggerAttackRelease("F#4", 0.9, 5);
  synth.triggerAttackRelease("G#4", 0.9, 6);
  synth.triggerAttackRelease("A4", 0.9, 7);
}); 

清单 12-8:播放音阶

这与清单 12-5 非常相似,不同之处在于我们依次触发多个音符。请注意,我们已更新了包络线,使其具有较短的攻击和释放时间❶。尤其是释放时间需要更短,以免每个音符的结束与下一个音符的开始重叠。

正如我之前提到的,triggerAttackRelease 的第二个参数是音符的持续时间,单位为秒,第三个参数是播放音符的时间,也是秒。第一个音符 A3 的持续时间为 0.9 秒,从时间零开始(也就是立即播放)。0.1 秒的释放发生在 0.9 秒的持续时间之后,所以每个音符总共会播放 1 秒。下一个音符 B3 有相同的持续时间,但第三个参数 1 意味着它会比第一个音符晚 1 秒开始。第三个音符被设置为比第一个音符晚 2 秒开始,依此类推。把这个代码放到浏览器中运行,你应该会听到一个 A 大调音阶的单个八度。

同时演奏多个音符

我们到目前为止使用的合成器是一个单音合成器,意味着它一次只能播放一个音符。为了同时演奏多个音符,我们需要创建一个多音合成器。在示例 12-9 中,我们更新了代码,创建了一个新的多音合成器,并同时播放两个或三个音符。

`--snip--`
 Tone.start();

  let synth = new Tone.PolySynth(
    Tone.Synth,
    {
      oscillator: { type: "square" },
      envelope: { attack: 0.1, decay: 0.3, sustain: 0.8, release: 0.1 },
      volume: -6
    }
  ).toDestination();

  synth.triggerAttackRelease(["A3", "C#4"], 0.9, 0);
  synth.triggerAttackRelease(["B3", "D4"], 0.9, 1);
 synth.triggerAttackRelease(["C#4", "E4"], 0.9, 2);
  synth.triggerAttackRelease(["D4", "F#4"], 0.9, 3);
  synth.triggerAttackRelease(["E4", "G#4"], 0.9, 4);
  synth.triggerAttackRelease(["F#4", "A4"], 0.9, 5);
  synth.triggerAttackRelease(["G#4", "B4"], 0.9, 6);
  synth.triggerAttackRelease(["E4", "A4", "C#5"], 1.9, 7);
}); 

示例 12-9:创建和演奏一个多音合成器

在这里,我们调用 new Tone.PolySynth 而不是 new Tone.Synth 来创建一个多音合成器对象。Tone.PolySynth 构造函数接受两个参数:一个单音合成器(在本例中是 Tone.Synth)和一个对象,其中包含通常传递给该单音合成器构造函数的选项(在本例中,是我们在示例 12-8 中传递给 Tone.Synth 构造函数的相同选项)。然后,polySynth 会创建多个具有指定设置的单音合成器,从而使它能够同时播放多个音符。

接下来,我们演奏相同的音阶,但加入了额外的同时音符。这是通过将音符名称数组传递给 triggerAttackRelease 方法实现的,而不是传递单个音符名称——例如,我们传递数组["A3", "C#4"]来同时演奏 A3 和 C♯4。默认情况下,你可以通过多音合成器播放最多 32 个音符。

当你播放这个例子时,你应该听到一个和声音阶,最后有一个漂亮的大三和弦。

Tone.js Transport

现在你已经学会了如何演奏音符,让我们来看一下如何创作歌曲。尽管你可以像在前几个例子中演奏音阶时那样,通过指定每个音符的时间来编程一整首歌,但这种方式很快会变得枯燥乏味。幸运的是,Tone.js 有一个叫做transport的概念,它让编写歌曲变得更加容易。transport 会跟踪歌曲当前的位置,按小节和拍子来衡量。这使得你可以以音乐直观的方式安排音符在歌曲中的某些时刻播放。transport 还允许你拥有循环的音符序列,这些音符在 transport 上的某个点开始播放,并重复直到你告诉它们停止。

西方音乐通常围绕小节和节拍构建,最常见的是每小节有四拍。音乐的速度以每分钟节拍数(BPM)来表示,在我们的示例中,我们将使用 Tone.js 的默认 BPM 值 120,这意味着每个节拍持续 0.5 秒。节拍也被称为四分音符(因为每小节有四拍,一拍是小节的四分之一)。八分音符的时值是四分音符的一半,十六分音符的时值是八分音符的一半,因此每个四分音符包含四个十六分音符。

transport 中的位置表示为由冒号分隔的三个数字字符串,类似“0:0:0”。这三个数字分别对应当前的小节号、该小节内的当前节拍和该节拍内的当前十六分音符。所有的计数都是从零开始的。例如,“0:0:0”表示第一小节的开始,“1:1:0”表示第二小节的第二拍,而“6:3:2”表示第七小节第四拍的第三个十六分音符。我们将这些字符串称为bars:quarters:sixteenths 表示法

Tone.Loop

Tone.js 的 transport 为我们提供了一种简单的方法来定义音乐循环,包括它们的开始和结束时间。其中最简单的是 Tone.Loop,它定义了一种持续不断地产生新音符的方法。我们通过每个四分音符重复播放一个音符,持续四个小节来尝试这一点。修改script.js,加入 Listing 12-10 中的代码。

`--snip--`
 Tone.start();

❶ let synth = new Tone.Synth().toDestination();

❷ let loop = new Tone.Loop(time => {
  ❸ synth.triggerAttackRelease("C4", "16n", time);
  }, "4n");

❹ loop.start("0:0:0");
  loop.stop("4:0:0");

  Tone.Transport.start();
}); 

Listing 12-10: 循环

我们从创建一个简单的合成器开始❶。注意,我们并没有传递一个对象来定义振荡器、包络或音量选项,因此合成器将使用库的默认设置创建。接下来,我们创建了 Tone.Loop 的新实例❷,它的构造函数有两个参数。第一个参数是一个需要时间值的函数,第二个参数是一个表示调用第一个参数中函数的频率的持续时间。在这种情况下,我们传递了字符串“4n”作为第二个参数,这是 Tone.js 表示四分音符的符号(“4n”是“1/4 note”的简写)。这意味着循环会在每个节拍时重复。

注意

在我们之前的列表中,我们传递了表示持续时间的数字,这些数字表示秒数。使用类似“4n”表示四分音符或“16n”表示十六分音符的音符时值的优势在于,如果我们改变 BPM,它们会自动缩放。例如,将 BPM 加倍会将每个四分音符的持续时间减半。

我们传递给 Tone.Loop 的回调函数的主体调用了合成器上的 triggerAttackRelease 方法,播放 C4 音符,时值为十六分音符 ❸。triggerAttackRelease 方法的第三个参数 time 表示播放音符的时间。每次 Tone.Loop 调用回调函数时,它都会提供一个新的时间值,并根据传输位置填充它。

最后,我们对 Tone.Loop 构造函数返回的循环调用 start 和 stop 方法 ❹,并传递我们希望这个循环开始和停止的时间,随后调用 Tone.Transport.start,开始从头播放传输。我们从 "0:0:0"(第一小节的开始)开始,直到 "4:0:0"(第五小节的开始)停止,这意味着这个片段将持续四个完整的小节,每个小节四拍。我们的循环在每一拍上重复,播放一个音符,因此我们将播放 16 个音符。试着重新加载页面看看!你可以像这样使用音乐家的技巧来数小节和拍子:“二三四,二三四,二三四,二三四。”请注意,Tone.js 不会在时间位置 "4:0:0" 播放第十七个音符,因为循环的结束不包括该时刻。

示例 12-11 显示了一种替代方式,用来创建我们在 示例 12-10 中编写的相同循环。这次我们将 start 和 stop 方法直接链到 Tone.Loop 构造函数上。

`--snip--`
 let synth = new Tone.Synth().toDestination();

  new Tone.Loop(time => {
    synth.triggerAttackRelease("C4", "16n", time);
  }, "4n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

示例 12-11: 使用更少的代码行进行循环

使用这种表示法,我们无需创建一个变量来保存 Tone.Loop 对象,并且通过链式调用 start 和 stop 方法,节省了几行代码。在本节的其余部分,我们将使用这种模式。

Tone.Loop 基本,但也非常灵活。你可以在回调中运行任何任意代码,因此你可以做的不仅仅是重复播放相同的音符。例如,你可以选择每次播放一个新的随机音符。示例 12-12 展示了如何通过随机播放五声音阶(或五个音符的音阶)中的音符来生成一段简短的音乐(我在这里选择五声音阶,因为五声音阶中的任何音符组合往往都很和谐)。

`--snip--`
 Tone.start();

❶ let synth = new Tone.PolySynth(
    Tone.Synth,
    {
      oscillator: { type: "triangle" },
      volume: -9
    }
  ).toDestination();

❷ let notes = ["C4", "D4", "E4", "G4", "A4", "C5"];

  new Tone.Loop(time => {
    for (let i = 0; i < 3; i++) {
    ❸ if (Math.random() < 0.5) {
      ❹ let note = notes[Math.floor(Math.random() * notes.length)];
         synth.triggerAttackRelease(note, "32n", time);
      }
    }
  }, "8n").start("0:0:0").stop("8:0:0");

 Tone.Transport.start();
}); 

示例 12-12: 使用 Tone.Loop 生成随机音乐

对于这个示例,我们切换到一个多音色合成器❶,这样我们就可以同时演奏多个音符。音符数组包含一个 C 大调五声音阶的一个八度,包括下一个八度的 C❷。在 Tone.Loop 的回调函数中,我们使用一个 for 循环来执行一些代码三次。每次循环时,我们调用Math.random()❸,它返回一个 0 到 1 之间的随机数,用来决定是否播放一个音符。如果值小于 0.5,我们播放一个音符。否则,跳过这个音符。音符的名称通过在音符数组中随机选择一个索引来确定,使用代码Math.floor(Math.random() * notes.length)❹。

Tone.Loop 对象每隔一个八分音符("8n")调用这段代码,持续八小节(从"0:0:0"到"8:0:0")。所有这些的效果是,每个八分音符时,最多会从数组中播放三个音符(没有唯一性的保证,所以相同的音符可能会被播放两次或三次,导致这个音符更响亮)。对于每个音符,它有一半的机会被播放,所以总体上,在任何给定的八分音符上,没有音符被播放的机会是八分之一。

Tone.Sequence

在这一节中,我们将介绍另一个 Tone.js 的辅助工具,叫做 Tone.Sequence。它允许你提供一个音符名称列表,这些音符将在固定的时间间隔内被安排播放。你可以根据需要重复整个序列。作为示例,我们将创建一个四个音符的重复模式:一个 G4,后面跟着三个 C4。使用 Listing 12-13 中的代码更新script.js

`--snip--`
 Tone.start();

  let synth = new Tone.Synth().toDestination();

  new Tone.Sequence(❶ (time, note) => {
    synth.triggerAttackRelease(note, "16n", time);
  }, ❷ ["G4", "C4", "C4", "C4"], ❸ "4n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

Listing 12-13: 使用 Tone.Sequence 创建一个重复的序列

这与我们的第一个 Tone.Loop 示例非常相似(Listing 12-10),但有两个重要的变化。首先,回调函数接受两个参数,timenote❶,而不是一个time参数。其次,在回调函数之后有一个额外的参数,它包含一个音符列表❷。每次调用回调时,下一个音符会作为note参数传递。它将不断循环播放列表中的音符,直到停止。Tone.Sequence 的第三个参数给出了每次回调之间的持续时间❸。在这种情况下,我们使用了"4n",意味着每次四分音符都会播放一个新的音符。

当你运行这个示例时,你应该会听到一个模式演奏 4 小节,每小节 4 拍,总共 16 个音符。如果我们手动写出所有调用synth.triggerAttackRelease的代码,而不是依赖 Tone.Sequence 来自动化它们,它们应该是这样的:

synth.triggerAttackRelease("G4", "16n", "0:0:0");
synth.triggerAttackRelease("C4", "16n", "0:1:0");
synth.triggerAttackRelease("C4", "16n", "0:2:0");
synth.triggerAttackRelease("C4", "16n", "0:3:0");
synth.triggerAttackRelease("G4", "16n", "1:0:0");
synth.triggerAttackRelease("C4", "16n", "1:1:0");
`--snip--` 

在这里,我只是将音符和时间参数替换为回调函数前六次调用时的实际值。注意,由于我们在回调之间使用了 "4n" 作为时值,第二个数字在小节:拍:十六分音符的表示法中是递增的。(但在实践中,Tone.Sequence 传递的时间是以秒为单位,而不是使用小节:拍:十六分音符的表示法。)

如果你希望有一个包含一些静默间隙(音乐术语中的休止符)的序列,你可以在音符名称数组中使用 null 代替音符名称。通过修改 script.js,并使用 清单 12-14 中的代码,可以看到这个效果。

`--snip--`
 new Tone.Sequence((time, note) => {
 synth.triggerAttackRelease(note, "16n", time);
  }, ["C4", null, "B3", "C4", "G3", "A3", null, "B3"], "8n")
    .start("0:0:0")
    .stop("4:0:0");

 Tone.Transport.start();
}); 

清单 12-14:使用 null 添加休止符

现在我们有了一个更长的音符序列,其中插入了一些空值来在序列中插入暂停。我们还将时值从 "4n" 改为 "8n",这意味着音符的播放速度将是之前的两倍。当你播放这个更新后的示例时,你应该能听到一个更有趣的音符序列,包括一些休止符。

Tone.Part

我们将要看的最后一个传输辅助工具是 Tone.Part。它是最灵活的辅助工具,因为它允许我们精确指定每个音符的播放时间。使用 Tone.Part 时,我们传递的是一个时间/音符对的数组,而不是音符名称数组。例如, [["0:0:0", "C4"], ["0:1:0", "D4"], ["0:1:2", "E4"]] 将会在指定的三个时间播放 C4、D4 和 E4 这三个音符。这样,与 Tone.Loop 和 Tone.Sequence 不同,音符不必在相等的时间间隔内播放。此外,Tone.Part 默认不会循环,因此数组中的音符序列只会播放一次。请查看 清单 12-15 中的代码示例。

`--snip--`
 Tone.start();
❶ let synth = new Tone.PolySynth(Tone.Synth).toDestination();

  new Tone.Part((time, note) => {
  synth.triggerAttackRelease(note, "16n", time);
  }, [
    ["0:0:0", ❷ ["C3", "E4"]],
    ["0:0:3", "D4"],
    ["0:1:0", "C4"],
    ["0:1:2", "D4"],
    ["0:2:0", ["E3", "E4"]],
    ["0:2:2", "E4"],
    ["0:3:0", "E4"],
    ["1:0:0", ["G3", "D4"]],
    ["1:0:2", "D4"],
    ["1:1:0", "D4"],
    ["1:2:0", ["E3", "E4"]],
    ["1:2:2", "G4"],
    ["1:3:0", "G4"]
❸]).start("0:0:0");

 Tone.Transport.start();
}); 

清单 12-15:使用 Tone.Part 播放旋律

我们在这里做的第一个改变是合成器 ❶。这次我们回到使用多音合成器,这样我们可以同时演奏多个音符。除了合成器不同外,回调函数的主体保持不变。我们仍然调用 synth.triggerAttackRelease 并传递音符和时间参数,这些参数会由 Tone.Part 自动填充。接下来是时间/音符对的数组。你可能会注意到其中一些音符本身是数组;例如,列表中的第一个“音符”是 ["C3", "E4"] ❷。这一对音符将原样传递给 triggerAttackRelease 方法,并会同时播放两个音符,就像我们其他的多音合成器示例一样。

最后,我们调用 .start("0:0:0") ❸,这会立即播放这一部分。如果我们使用 .start("1:0:0"),例如,那么旋律将在一个小节的暂停后开始。每对时间/音符的时间是相对于传递给 start 方法的时间的。

当你播放这个例子时,你应该会听到《玛丽有只小羊》的开头部分。

制作鼓声

大多数电子音乐都有某种鼓点。用来制作鼓点的鼓声可以来自音频文件,也可以通过合成产生。我们这里将使用后者技术。鼓点的核心由三个组件组成:低音鼓(“轰”声)、军鼓(“啪”声)和踩镲(“嘀”声)。在这一部分,你将学习合成这些声音的技巧。

踩镲合成

现实中的踩镲由两块相对的镲片组成。上面的镲片连接到一个踏板,鼓手可以通过踏板让镲片接触或分开。我们这里要模拟的是闭合(镲片接触)的声音。当你用鼓棒击打闭合的踩镲时,它们会发出一个高频噪声,迅速消失。

我们通过使用另一种合成器,NoiseSynth,来生成白噪声,而不是带有音高的音符,从而近似这个效果。在信号处理中,白噪声是一种所有频率上都有相等强度成分的随机信号。我们会给 NoiseSynth 一个幅度包络,模拟用棒子击打踩镲的突然起音。最后,我们将噪声通过一个滤波器—一个允许某些频率通过,同时降低其他频率的设备—来去除低频,使声音听起来更高、更像镲片。

首先,我们将设置 NoiseSynth 和包络,并以循环的方式播放踩镲声音。使用 Listing 12-16 中的代码更新你的script.js

`--snip--`
 Tone.start();

❶ let hiHat = new Tone.NoiseSynth({
    envelope: {
      attack: 0.001, decay: 0.1, sustain: 0, release: 0
    },
    volume: -6
  }).toDestination();

❷ new Tone.Loop(time => {
    hiHat.triggerAttackRelease("16n", time);
  }, "8n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

Listing 12-16: 踩镲声音的初步实现

我们创建一个新的 NoiseSynth ❶,传入一个幅度包络和–6 dB 的音量。这个包络有一个非常短的起音(1/1000 秒)和一个较长的衰减(1/10 秒),这模拟了打击踩镲时的幅度包络。由于延音和释放都设置为 0,声音会在初始的起音和衰减之后立即结束(0.001 + 0.1 秒)。特别地,延音为 0 意味着声音会在其最大音量的 0%时保持,因此即使音符的持续时间更长,在起音和衰减之后你也不会听到任何声音。

接下来,我们使用 Tone.Loop 播放一个连续的八分音符踩镲声,持续四小节 ❷。注意,NoiseSynth 的 triggerAttackRelease 方法不接受音符名称,因为噪声没有特定的音高。你只需要指定持续时间和音符应播放的时间。

当你播放这个例子时,你应该会听到一连串的踩镲声音。它听起来还不太好,因为我们还没有添加滤波器。我们将在 Listing 12-17 中进行这个操作。

`--snip--`
 Tone.start();

❶ let hiHatFilter = new Tone.Filter(15000, "bandpass").toDestination();

 let hiHat = new Tone.NoiseSynth({
 envelope: {
 attack: 0.001, decay: 0.1, sustain: 0, release: 0
 },
 volume: -6
❷}).connect(hiHatFilter);

 new Tone.Loop(time => {
`--snip--` 

Listing 12-17: 对踩镲声音应用滤波器

首先,我们使用 Tone.Filter 创建一个 带通滤波器 ❶。这种滤波器只允许通过(“传递”)你选择的频率附近的频率。在本例中,我们告诉滤波器传递大约 15,000 Hz 或 15 kHz 频率范围的信号,同时消除其他频率。人耳的听觉范围大约是 20 Hz 到 20 kHz,因此我们的滤波器只让非常高频的噪声通过。

在 清单 12-16 中,我们使用了 toDestination() 将 NoiseSynth 直接连接到输出。在 清单 12-17 中,我们则将滤波器连接到输出 ❶,然后将合成器连接到滤波器 ❷。这意味着合成器的声音在输出到扬声器或耳机之前会通过滤波器处理。因此,当你播放这个示例时,你应该听到相同的 hi-hat 声音,但仅限于高频,这听起来更像真实的 hi-hat。

军鼓合成

在本节中,我们将合成一个军鼓。军鼓有一系列的钢丝(称为 军鼓丝)靠在底部的鼓膜上,当鼓被击打时,这些钢丝会撞击鼓膜发出声音。这使得军鼓的声音相对复杂,由一些噪声和一些更具音调的声音组成。为了模拟这一点,我们将使用两个独立的声音源:一个噪声合成器和一个固定频率的常规合成器。两者都将有一个短暂的振幅包络,以产生打击乐的感觉,我们还将通过带通滤波器传递声音的噪声成分,以使军鼓的声音低于 hi-hat。我们将创建一个新的 Snare 类来封装这些细节,如 清单 12-18 所示。

`--snip--`
 new Tone.Loop(time => {
 hiHat.triggerAttackRelease("16n", time);
 }, "8n").start("0:0:0").stop("4:0:0");

  class Snare {
    constructor() {
    ❶ this.noiseFilter = new Tone.Filter(5000, "bandpass").toDestination();
    ❷ this.noiseSynth = new Tone.NoiseSynth({
        envelope: {
          attack: 0.001, decay: 0.1, sustain: 0, release: 0
        },
        volume: -12
      }).connect(this.noiseFilter);

 ❸ this.synth = new Tone.Synth({
        envelope: {
          attack: 0.0001, decay: 0.1, sustain: 0, release: 0
        },
        oscillator: {type: "sine"},
        volume: -12
      }).toDestination();
    }

  ❹ triggerAttackRelease(duration, when) {
      this.noiseSynth.triggerAttackRelease(duration, when);
      this.synth.triggerAttackRelease("G3", duration, when);
     }
    }

❺ let snare = new Snare();

❻ new Tone.Loop(time => {
    snare.triggerAttackRelease("16n", time);
  }, "2n").start("0:1:0").stop("4:0:0");

 Tone.Transport.start();
}); 

清单 12-18:合成军鼓

从高层次来看,Snare 类有两个方法,构造函数和触发器 AttackRelease。构造函数创建一个滤波器和两个合成器。触发器 AttackRelease 方法调用两个合成器上的 triggerAttackRelease 方法,以同时播放它们。

在构造函数中,我们首先创建滤波器 ❶ 和噪声合成器 ❷。这与我们创建 hi-hat 时非常相似,不同之处在于我们使用 5,000 Hz 的频率来进行带通滤波,以反映军鼓较低的声音。接下来,我们创建音调合成器 ❸,它使用与噪声合成器类似的振幅包络,但攻击时间更短,以模拟军鼓的声音(在真实的军鼓中,军鼓丝是通过鼓皮的振动触发的,因此它们的声音会稍微滞后于鼓声)。该合成器配置为正弦波振荡器。由于我们将同时播放这两个合成器,我们给每个合成器的音量设置为 -12,这样整体音量与 hi-hat 类似。

triggerAttackRelease 方法 ❹ 只接受持续时间和触发时间参数。这些参数会传递给底层合成器的 triggerAttackRelease 方法。当我们触发带音高的合成器时,我们给它一个 "G3" 的音符名,这个音符是我决定用来调小军鼓的音高。加入带音高的合成器非常微妙,但能让鼓声听起来更为真实。

接下来,我们实例化类 ❺,最后创建一个新的 Tone.Loop 对象 ❻。这个循环是高帽子循环的四倍长("2n" 而不是 "8n",即半音符而不是八分音符),并且在一个四分音符后开始。这意味着每小节的第二拍和第四拍会有小军鼓的击打。当你播放这个例子时,你应该能听到每个八分音符有高帽子,每两个四分音符有小军鼓。

踢鼓合成

最后需要合成的鼓声是踢鼓。踢鼓比小军鼓大得多,并且没有像小军鼓那样的颤动声,使其声音更加嘈杂。踢鼓的声音相当复杂,但幸运的是,Tone.js 有一个名为 MembraneSynth 的合成器,它能够很好地模拟踢鼓的声音。这个合成器使用一个常规振荡器,并在短时间内降低其频率,设置正确时最终听起来非常像踢鼓。列表 12-19 展示了如何做到这一点。

`--snip--`
 new Tone.Loop(time => {
 snare.triggerAttackRelease("16n", time);
 }, "2n").start("0:1:0").stop("4:0:0");

  let kick = new Tone.MembraneSynth({
  ❶ pitchDecay: 0.02,
    octaves: 6,
    volume: -9
  }).toDestination();

❷ new Tone.Loop(time => {
    kick.triggerAttackRelease(50, "16n", time);
  }, "2n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

列表 12-19:合成踢鼓

MembraneSynth 的选项包括 pitchDecay ❶,它指定频率变化的速度,以秒为单位,并且包括 octaves,它指定在这段时间内频率下降多少个八度。在我们的循环 ❷ 中,我们以 50 Hz 的频率触发合成器。这个循环与小军鼓循环有相同的 "2n" 持续时间,但从零时刻开始,这意味着踢鼓和小军鼓的声音将每四分音符交替一次,呈现经典摇滚鼓点。当你播放这个例子时,你可能会认出它是很多歌曲的基本鼓点模式。

混响

混响(即 reverberation)是一种效果,使得音乐听起来像是在一个房间或更大的封闭空间内播放。真实世界的声音在房间的墙壁上反弹时产生的随机回声,便是这种混响效果的来源。混响使得每个声音在消失时需要一点时间,这会让我们的鼓声听起来更真实。我们可以通过 Tone.Reverb 添加混响,正如你在列表 12-20 中看到的那样。

`--snip--`
 Tone.start();

  let reverb = new Tone.Reverb({
    decay: 1,
 wet: 0.3
  }).toDestination();

  let hiHatFilter = new Tone.Filter(15000, "bandpass").connect(reverb);

 let hiHat = new Tone.NoiseSynth({
 envelope: {
 attack: 0.001, decay: 0.1, sustain: 0, release: 0
 },
 volume: -6
 }).connect(hiHatFilter);

 new Tone.Loop(time => {
 hiHat.triggerAttackRelease("16n", time);
 }, "8n").start("0:0:0").stop("4:0:0");

 class Snare {
 constructor() {
      this.noiseFilter = new Tone.Filter(5000, "bandpass").connect(reverb);
 this.noiseSynth = new Tone.NoiseSynth({
 envelope: {
 attack: 0.001, decay: 0.1, sustain: 0, release: 0
 },
 volume: -12
 }).connect(this.noiseFilter);

 this.synth = new Tone.Synth({
 envelope: {
 attack: 0.0001, decay: 0.1, sustain: 0, release: 0
 },
 oscillator: {type: "sine"},
 volume: -12
      }).connect(reverb);
 }

 triggerAttackRelease(duration, when) {
 this.noiseSynth.triggerAttackRelease(duration, when);
 this.synth.triggerAttackRelease("G3", duration, when);
 }
 }

 let snare = new Snare();

 new Tone.Loop(time => {
 snare.triggerAttackRelease("16n", time);
 }, "2n").start("0:1:0").stop("4:0:0");

 let kick = new Tone.MembraneSynth({
 pitchDecay: 0.02,
 octaves: 6,
 volume: -9
  }).connect(reverb);

 new Tone.Loop(time => {
 kick.triggerAttackRelease(50, "16n", time);
 }, "2n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

列表 12-20:添加混响

首先,我们创建我们的混响效果。衰减设置描述了声音停止后混响将持续多久(以秒为单位)。这个数值越高,效果就越有回音。wet 设置指定了通过的混响声音与原始声音的比例。在这种情况下,0.3 表示该效果的输出将是 30% 的混响和 70% 的原始声音。wet 设置越高,混响效果就越突出。

清单 12-20 中的其他更改将所有的 toDestination() 替换为 connect(reverb)。这样,所有的鼓声都将通过混响效果,然后将混响效果发送到输出。当你播放这个示例时,鼓声应该听起来像是在一个房间里演奏的。你可以通过增加 wet 的值(例如设置为 0.6)或者增加 Tone.Reverb 设置中的衰减来使效果更加明显。

鼓循环

现在我们已经设置了鼓声,接下来希望能有一种更简单的方式来触发它们。理想情况下,我们希望通过编写如下内容来创建一个鼓点模式:

kick:  x…x…
snare: ..x…x.
hiHat: xxxxxxxx 

然后,我们可以让 JavaScript 处理将这些符号转换成 Tone.js 能理解的代码。在这里,每个 x 代表一个音符,每个点(.)代表一个停顿,每一列代表一个八分音符。例如,在第一个八分音符中,低音鼓和高帽一起演奏,在第二个八分音符中只有高帽演奏,在第三个八分音符中军鼓和高帽一起演奏,以此类推。这里显示的模式与我们在前面章节中构建的鼓点一致。

为了实现这一点,我们将编写一个辅助函数,将一串 x 和点转换成 Tone.Sequence 传输助手可以使用的值数组。回想一下,Tone.Sequence 接受一个音符名称的数组并重复按顺序播放它们,使用 null 表示休止符。我们的函数应该将点转换为 null,同时保留 x 不变。

注意

由于鼓声没有音符名称,任何字符串实际上都可以表示 Tone.Sequence 的鼓击(我们只是为了方便使用 x)。重要的是它不能为 null。

清单 12-21 显示了此函数的定义。将其添加到你的 script.js 文件中,放在当前的鼓声代码之前。

`--snip--`
 Tone.start();

  // Converts a string to an array of notes or null.
  // Dots in the string become nulls in the array and are silent.
  function mkSequence(pattern) {
    return pattern.split(" ").map(value => {
      if (value == ".") {
        return null;
      } else {
        return value;
      }
    });
  }

 let reverb = new Tone.Reverb({
`--snip--` 

清单 12-21: mkSequence 辅助函数

mkSequence 函数接受一个像 "x…x…" 这样的字符串,并将其转换为一个字符串和 null 的数组,例如 ["x", null, null, null, "x", null, null, null],这是我们为 Tone.Sequence 所需要的格式。它使用 split 方法将字符串拆分为单个字符的数组,并使用 map 方法通过为每个字符调用一个函数来创建一个新数组。如果字符是 ".",则将其替换为 null;否则,它保持字符不变。

接下来,我们将创建传递给此函数的字符串,正如 Listing 12-22 所示。在 mkSequence 函数定义后添加此代码。

`--snip--`
  }

  let drumPattern = {
    kick:  "x…x…",
    snare: "..x…x.",
    hiHat: "xxxxxxxx",
  };

  let reverb = new Tone.Reverb({
`--snip--` 

Listing 12-22: 定义 drumPattern

我们将三个字符串存储在一个名为 drumPattern 的对象中,以保持它们的组织性。我添加了空格来对齐字符串,这样更容易看到模式。

最后,我们将使用 helper 和 Tone.Sequence 来替代我们之前的三个 Tone.Loop 调用,正如 Listing 12-23 所示。

--`snip`--
 }).connect(hiHatFilter);

  new Tone.Sequence(time => {
    hiHat.triggerAttackRelease("16n", time);
  }, mkSequence(drumPattern.hiHat), "8n").start("0:0:0").stop("4:0:0");

  class Snare {
--snip--
 let snare = new Snare();

   new Tone.Sequence(time => {
    snare.triggerAttackRelease("16n", time);
  }, mkSequence(drumPattern.snare), "8n").start("0:0:0").stop("4:0:0");

 let kick = new Tone.MembraneSynth({
 pitchDecay: 0.02,
 octaves: 6,
 volume: -9
 }).connect(reverb);

   new Tone.Sequence(time => {
    kick.triggerAttackRelease(50, "16n", time);
  }, mkSequence(drumPattern.kick), "8n").start("0:0:0").stop("4:0:0");

 Tone.Transport.start();
}); 

Listing 12-23: 使用 mkSequence 与 Tone.Sequence 替代 Tone.Loop

在这里,我们用新的 Tone.Sequence 调用替换了每一个 Tone.Loop 调用。在每种情况下,我们调用 mkSequence,传递我们 drumPattern 对象中的一个字符串,这将创建一个包含 x 和 null 的数组。此调用的结果被传递给 Tone.Sequence 助手,我们用它来触发相应的鼓声。同样,Tone.Sequence 会将任何字符串(如"x")解释为适合鼓点的音符名称,而 null 则表示静音。Tone.Sequence 的最后一个参数"8n"表示鼓点模式字符串中的每个点或 x 代表一个八分音符。

如果你现在重新加载页面,你应该能听到和之前相同的鼓点。这看起来可能是为了得到相同的输出而做了很多工作,但现在我们有了更多的灵活性来编写不同的鼓点模式,并且可以根据需要轻松修改它们。试着在 drumPattern 中的字符串中加入一些额外的军鼓或低音鼓音符,看看效果如何。

与样本合作

电子音乐的一个重要部分是采样:使用现有音频片段来构建一首新的音乐。一种常见的技巧是通过修改样本的播放速度来改变其音高,这样一个样本就可以用于多个音符。如果你曾经加速某人声音的录音,让他们听起来像松鼠一样高亢,或者减慢速度让他们听起来像巨人一样低沉,那就是相同的原理。

Tone.js 通过 Tone.Sampler 工具使得处理样本变得容易。这个工具的作用类似于我们之前看到的合成器,因为它也有一个 triggerAttackRelease 方法,可以让你在某个时间播放一个特定的音符。不同之处在于,它不是使用振荡器或噪音发生器作为音源,而是播放一个音频文件的片段,可能会根据需要调整音高。

为了避免版权问题,我从一个免费的在线样本数据库https://freesound.org获取了一些样本。我已经将它们重新上传到 Amazon S3(简单存储服务),以便你可以直接从代码中访问它们,而不需要下载(如果你想了解技术细节,这些文件位于一个公开的 S3 桶中,并且启用了 CORS 头部,允许从任何来源访问)。这些样本是三种不同的喇叭音符,分别位于以下 URL:

如果你将这些 URL 输入到浏览器中,样本应该会自动播放。

让我们看看如何将这些样本加载到一个新的 Tone.Sampler 对象中。Tone.js 允许你从外部 URL 加载所有样本,比如我们的三个 S3 URL,我们在列表 12-24 中实现了这一点。将新的采样器代码插入到 script.js 的末尾。

`--snip--`
 new Tone.Sequence(time => {
 kick.triggerAttackRelease(50, "16n", time);
 }, mkSequence(drumPattern.kick), "8n").start("0:0:0").stop("4:0:0");

  // Samples from freesound.org:
  // https://freesound.org/people/MTG/sounds/357432/
  // https://freesound.org/people/MTG/sounds/357336/
  // https://freesound.org/people/MTG/sounds/357546/
  const sampler = new Tone.Sampler({
    urls: {
      "C5": "trumpet-c5.mp3",
      "D5": "trumpet-d5.mp3",
      "F5": "trumpet-f5.mp3"
    },
    baseUrl: "https://skilldrick-jscc.s3.us-west-2.amazonaws.com/",
    attack: 0,
    release: 1,
    volume: -24,
    onload: () => {
      sampler.triggerAttackRelease(["C5", "E5", "G5"], "1n", 0);
    }
  }).toDestination();

 Tone.Transport.start();
}); 

列表 12-24:创建采样器

我们通过将一个配置对象传递给 Tone.Sampler 构造函数来创建采样器。在这个例子中,配置对象包含五个属性。第一个属性 urls 包含一个对象,将音符名称映射到文件名。例如,我们说音符名称 C5 对应文件名 trumpet-c5.mp3。接下来,baseUrl 定义了所有 URL 的共享前缀,这样我们就不需要为每个样本编写完整的 URL。所有的 URL 都在同一个 S3 桶中,因此我们可以使用它作为基本 URL,然后在 urls 中仅提供文件名。

采样器乐器在播放样本时不会应用完整的 ADSR 包络,但它确实允许你设置攻击(渐入速度)和释放(渐出速度)。我们使用即时攻击(因为样本已经有自己的攻击),并设置长达一秒的释放。我们还将音量设置为 –24 dB,这样采样器不会太响。最后,onload 属性让我们能够指定在所有样本下载完成后发生的事情。在这个例子中,我们调用 triggerAttackRelease 来播放一个三音符和弦。请注意,Tone.Sampler 默认是多音的,所以它可以同时播放多个样本。

当你播放这个示例时,你仍然会听到鼓声。一旦样本加载完成,你还应该听到由小号采样器演奏的 C 大调和弦。有一点有趣的是,虽然我们提供了 C5 音符的样本,但我们没有提供 E5 或 G5 音符的样本,这是 C 大调和弦中的其他音符。当我们告诉采样器播放这些音符时,它会选择最接近的提供样本,并通过改变播放速度来调整音高。例如,最接近 G5 的样本是 F5,所以这个样本会稍微加速播放,听起来就像 G5 一样。只要我们要播放的音符离提供的样本不太远,它就会听起来正常。然而,如果我们推得太远,结果就不会那么真实。例如,尝试通过将音符设置为 C6、E6 和 G6 来提高一个八度,它们现在会开始听起来有些可笑。而且,因为样本播放速度是原来的两倍,所以它们的持续时间是原来的一半,因此不会持续整个小节(因为它们播放得更快,高音的音符会更早结束)。你也可以尝试将音符设置为 C4、E4 和 G4。这次持续时间就不是问题了,因为样本播放速度变慢以将音高调低,但音符仍然听起来不那么真实。

总结

在这一章中,你学习了如何使用 Web Audio API 制作声音和音乐,并且你看到通过使用像 Tone.js 这样的库,可以通过隐藏许多底层细节来让你的工作变得更加轻松。你还学习了许多使用 Tone.js 库进行声音合成和采样的技巧。如果有些音乐细节你没有完全理解,不用担心。这里最重要的是熟悉使用一个新的 JavaScript API 和库。在下一章中,我们将把这些知识付诸实践,编写一首实际的歌曲,使用本章中创建的乐器!

第五章:13 写作 一首 歌曲

现在你已经学到了足够的 Tone.js 基础和声音合成的知识,能够写出一首简单的歌曲。我们的歌曲将由几个乐器组成:上一章中开发的鼓,喇叭采样器,两个不同的合成贝斯部分,以及另一个合成器上演奏的和弦。

组织结构

我们的歌曲将重用上一章中的许多代码,但我们会对其进行重组,使得跟踪歌曲的构建过程更加容易。index.html 文件将与第十二章中的完全相同,但我们将从头开始创建一个新的 script.js 文件,并将其组织成四个逻辑部分:

乐器    用于实例化和设置乐器

序列化    用于创建循环播放的音符序列

歌曲    用于安排每个序列的开始和结束

事件处理    处理启动歌曲播放的点击事件的代码

我们将用多行注释来标记这四个部分,以便更容易导航 script.js 文件。清单 13-1 显示了这些注释的样子。你现在可以按照这个顺序将它们添加到文件中。

/////////////////
// Instruments //
/////////////////

////////////////
// Sequencing //
////////////////

//////////
// Song //
//////////

////////////////////
// Event Handling //
//////////////////// 

清单 13-1:标明 script.js的主要部分的注释

在本章中,当我们构建歌曲时,我会告诉你将每个新代码片段添加到特定部分的末尾。这些注释将帮助你快速找到新代码应放置的位置。

事件处理

让我们从编写 script.js 中的事件处理部分开始。这段代码几乎与我们在上一章开头编写的代码相同:它创建了一个点击事件监听器,在用户点击按钮时切换播放按钮和“正在播放”段落的样式,并调用必要的 Tone.js 方法来开始播放歌曲。将清单 13-2 中的内容输入到代码的事件处理部分。

`--snip--`
////////////////////
// Event Handling //
////////////////////

let play = document.querySelector("#play");
let playing = document.querySelector("#playing");

play.addEventListener("click", () => {
  // Hide this button
  play.style = "display: none";
  playing.style = " ";

 Tone.start();

  // Modify this to start playback at a different part of the song
❶ Tone.Transport.position = "0:0:0";
  Tone.Transport.start();
}); 

清单 13-2:事件处理代码

Feynman 学习方法相比,这段代码的一个重要区别是,我们使用 Tone.Transport.position 在调用 Tone.Transport.start ❶之前设置传输的起始位置。在这里,我们将起始位置设置为"0:0:0",这是默认值,因此这行代码严格来说并不是必须的。然而,包含这行代码可以方便你在添加新元素时修改起始位置,这样就不必每次都听完整首歌曲。例如,如果你想跳过前 20 小节,你可以将 Tone.Transport.position 的值更改为"20:0:0"。

与上一章不同,所有创建乐器和序列的代码都放在事件处理程序之外。所有这些代码都可以在用户按下播放按钮之前执行。只有 Tone.start 调用必须放在事件处理程序内,歌曲才能正常工作。如果我们愿意,甚至可以将 Tone.Transport 的相关代码移到事件处理程序之外,但在 Tone.start 之后执行这些代码更自然。

制作鼓点

现在,让我们创建伴随歌曲的鼓点。我们将使用上一章中创建的相同的踩镲、小军鼓和低音鼓声。首先,我们将声明这些乐器,如 Listing 13-3 所示。将这段代码添加到script.js的乐器部分。

/////////////////
// Instruments //
/////////////////

❶ function mkDrums() {
  let reverb = new Tone.Reverb({
    decay: 1,
    wet: 0.3
  }).toDestination();

  let hiHatFilter = new Tone.Filter(15000, "bandpass").connect(reverb);

  let hiHat = new Tone.NoiseSynth({
    envelope: {
      attack: 0.001, decay: 0.1, sustain: 0, release: 0
    },
    volume: -6
  }).connect(hiHatFilter);

 class Snare {
    constructor() {
      this.noiseFilter = new Tone.Filter(5000, "bandpass").connect(reverb);
      this.noiseSynth = new Tone.NoiseSynth({
        envelope: {
          attack: 0.001, decay: 0.1, sustain: 0, release: 0
        },
        volume: -12
      }).connect(this.noiseFilter);

      this.synth = new Tone.Synth({
        envelope: {
          attack: 0.0001, decay: 0.1, sustain: 0, release: 0
        },
        oscillator: {type: "sine"},
        volume: -12
      }).connect(reverb);
    }

    triggerAttackRelease(duration, when) {
      this.noiseSynth.triggerAttackRelease(duration, when);
      this.synth.triggerAttackRelease("G3", duration, when);
    }
  }

  let snare = new Snare();

  let kick = new Tone.MembraneSynth({
    pitchDecay: 0.02,
    octaves: 6,
    volume: -9
  }).connect(reverb);

❷ return {hiHat, snare, kick};
}

let drums = mkDrums();
`--snip--` 

Listing 13-3: 声明鼓组

这段代码与我们在上一章编写的代码相同,但为了保持代码的组织性,我将所有的鼓组设置代码,包括混响效果,移动到一个名为 mkDrums(“制作鼓组”)的单独函数中❶。这个函数返回一个包含三种鼓的对象❷。我们使用了一种新的语法来创建这个对象,叫做对象字面量简写语法。使用这种简写语法,我们不需要输入{hiHat: hiHat, snare: snare, kick: kick},而只需输入{hiHat, snare, kick}。这种写法只有在属性名与变量名相同的情况下才有效。

现在我们已经声明了鼓组,接下来我们将创建实际的鼓点模式。我们将使用在上一章中开发的相同的单小节模式,每个八分音符上都有踩镲声,低音鼓和小军鼓声每个四分音符交替出现。将 Listing 13-4 添加到代码的序列部分。

`--snip--`
////////////////
// Sequencing //
////////////////

// Converts a string to an array of notes or nulls.
// Dots in the string become nulls in the array and are silent.
❶ function mkSequence(pattern) {
  return pattern.split(" ").map(value => {
    if (value == ".") {
      return null;
    } else {
      return value;
    }
  });
}

❷ let drumPattern = {
  kick:  "x…x…",
  snare: "..x…x.",
  hiHat: "xxxxxxxx",
};

let hiHatSequence = new Tone.Sequence(time => {
  drums.hiHat.triggerAttackRelease("16n", time);
}, mkSequence(drumPattern.hiHat), "8n");

let snareSequence = new Tone.Sequence(time => {
  drums.snare.triggerAttackRelease("16n", time);
}, mkSequence(drumPattern.snare), "8n");

let kickSequence = new Tone.Sequence(time => {
    drums.kick.triggerAttackRelease(50, "16n", time);
}, mkSequence(drumPattern.kick), "8n");
`--snip--` 

Listing 13-4: 鼓点序列

再次强调,这段代码与我们在第十二章中编写的代码相同。我们从一个辅助函数 mkSequence ❶开始,它接收一个由 x 和点组成的模式,并将其转换为 Tone.Sequence 可以使用的音符信息。然后,我们将想要的模式存储在 drumPattern 对象❷中,并使用 Tone.Sequence 为每个乐器生成序列。

创建鼓点所剩下的工作就是安排序列循环播放,持续大部分歌曲时长,如列表 13-5 所示。将这段代码添加到 script.js 文件的 Song 部分。

`--snip--`
//////////
// Song //
//////////

hiHatSequence.start("0:0:0").stop("44:0:0");
snareSequence.start("0:0:0").stop("44:0:0");
kickSequence.start("0:0:0").stop("44:0:0");
`--snip--` 

列表 13-5:安排鼓点序列

在这里,我们告诉鼓在歌曲的开始处启动并持续演奏 44 小节。加载页面并点击 Play,你应该会听到和之前一样的鼓声,但会持续更长时间。当你听腻了时,可以重新加载页面停止鼓声的播放。

添加低音线

接下来,我们将添加几个低音合成器,并让它们演奏两个独立的低音线。首先,我们通过将 列表 13-6 中的代码添加到 Instruments 部分的末尾(紧接着 Sequencing 部分之前)来创建这些合成器。

`--snip--`
let lowBass = new Tone.FMSynth({
  oscillator: {
  ❶ type: "triangle"
  },
  envelope: {
    attack: 0.0001, decay: 0.5, sustain: 0.3, release: 0.1
  },
  volume: -3
}).toDestination();

let highBass = new Tone.FMSynth({
  oscillator: {
  ❷ type: "square"
  },
  envelope: {
    attack: 0.0001, decay: 0.1, sustain: 0.3, release: 0.1
  },
  volume: -9
}).toDestination();
`--snip--` 

列表 13-6:创建低音乐器

在这里,我们声明了两个低音乐器,分别叫做 lowBass 和 highBass。它们都使用了一种我们尚未见过的合成器,叫做 FMSynth。FM频率调制(Frequency Modulation)的缩写,FM 合成 涉及使用一个振荡器来调制或修改另一个振荡器的频率。这种合成方式比单一振荡器产生的声音更丰富,也非常适合作为低音合成器。Tone.FMSynth 中有很多可以调整的参数(例如,调制的程度、两个振荡器之间的频率关系、振荡器的波形等等),但我们将主要使用默认值。我们所做的仅仅是设置振荡器类型("triangle" 为 "lowBass" ❶ 和 "square" 为 highBass ❷),以及包络和音量。

为了生成低音序列,我们将采用一种与当前的 mkSequence 辅助函数略有不同的技术。这个辅助函数非常适合用于像鼓这样的场景,在那里你只需要一个字符来决定一个音符是否被演奏,但它不适用于低音线,我们需要提供音符名称,而这些名称至少有两个字符(如 C3 或 F#4)。我们可能选择的一种表示序列的方式可以是这样的:

"C3|  |  |C3|  |  |G2|B2"

垂直的管道字符用于分隔,每对管道之间是我们想要演奏的音符或空格,空格表示沉默。(这里写出的序列是 Ben E. King 的《Stand by Me》中的低音线的开头。)

列表 13-7 给出了 mkPipeSequence 的定义,我们将用它来为低音线编排序列。它接收像《Stand by Me》中的字符串,并将其转换成音符名称和空值的数组。将此函数插入到 script.js 的 Sequencing 部分,紧接着 mkSequence 的定义。

`--snip--`
// Converts a string to an array of notes or nulls.
// Spaces between pipes in the string become nulls in the array and are silent.
function mkPipeSequence(pattern) {
  return pattern.split("|").map(value => {
  ❶ if (value.trim() == " ") {
      return null;
    } else {
      return value;
    }
  });
}
`--snip--` 

列表 13-7:mkPipeSequence 函数

这个函数使用 split("|")通过管道符分割字符串。以“Stand by Me”为例,这将返回数组["C3", " ", " ", "C3", " ", " ", "G2", "B2"]。然后我们对这些值进行映射。trim 方法❶会去除字符串两端的任何空白,因此" ".trim()会返回"",一个空字符串。我们将返回数组中的所有空字符串替换为 null,并将音符名称原样传递,最终返回值为["C3", null, null, "C3", null, null, "G2", "B2"]。

接下来,我们要为两个低音线创建实际的序列(这里我们不会借用“Stand by Me”)。将清单 13-8 中的代码添加到 Sequencing 部分的末尾。

`--snip--`
let lowBassSequence = new Tone.Sequence((time, note) => {
  lowBass.triggerAttackRelease(note, "16n", time, 0.6);
}, mkPipeSequence("G2|  |  |G2|G2|  |  |  "), "8n");

let highBassSequence = new Tone.Sequence((time, note) => {
  highBass.triggerAttackRelease(note, "16n", time, 0.3);
}, mkPipeSequence("G3|F3|E3|D3|G2|D3|G3|D3"), "8n");
`--snip--` 

清单 13-8:低音序列

这里有两个低音部分:低音部分每小节播放三个八分音符,而高音部分则连续播放八分音符。

最后,我们需要将这些序列与传输进行调度,如清单 13-9 所示。此代码应添加到 Song 部分的末尾。

`--snip--`
lowBassSequence.start("0:0:0").stop("47:3:0");
highBassSequence.start("4:0:0").stop("47:3:0");
`--snip--` 

清单 13-9:调度低音序列

低音序列从一开始就启动,高音序列在四小节后开始。两者都将继续循环,直到 48 小节中途为止。这样,低音部分将在鼓声停止后继续几小节。

如果你现在刷新页面并点击播放,你将听到歌曲的开头!我们不仅有鼓和低音,还有一些非常基础的结构,第二个低音部分在四小节后加入,而鼓声在低音之前结束。当前歌曲中,最后的低音独奏是最具戏剧性的部分。要单独听这一部分,你可以在代码的事件处理部分修改 Tone.Transport.Position 的值。如果将其设置为“40:0:0”并重新加载,你将跳到歌曲的最后八小节。

添加和弦

接下来,我们将为歌曲添加一些和弦。这首歌将有两个独立的和弦序列,我们将为歌曲中的不同时间安排它们,以增加结构性和多样性。

首先,我们需要创建播放和弦的乐器。相关代码在清单 13-10 中;将其插入到乐器部分的末尾。

`--snip--`
let chordSynth = new Tone.PolySynth(Tone.Synth, {
  oscillator: {
    type: "triangle"
  },
  volume: -12
}).toDestination();
`--snip--` 

清单 13-10:和弦合成器

我们需要一个 PolySynth,因为该乐器将同时播放多个音符(这就是和弦)。PolySynth 基于常规的 Synth,使用默认的振幅包络和三角波振荡器。

接下来,我们将为和弦创建编排代码。与其每次想要在序列中播放一个和弦时手动写出它,我们将创建一些命名的和弦,然后使用这些和弦名称创建序列。将列表 13-11 中的代码插入到编排部分的末尾。

`--snip--`
❶ let chords = {
  1: ["D4", "G4"],
  2: ["E4", "G4"],
  3: ["C4", "E4", "G4"],
  4: ["B3", "F4", "G4"],
};

❷ function playChord(time, chordName) {
❸ let notes = chords[chordName];
  chordSynth.triggerAttackRelease(notes, "16n", time, 0.6);
}

❹ let chordSequence1 = new Tone.Sequence((time, chordName) => {
  playChord(time, chordName);
}, mkSequence("1…2…3..4…31…2…3..4.343"), "8n");

❺ let chordSequence2 = new Tone.Sequence((time, chordName) => {
  playChord(time, chordName);
}, mkSequence("3…2…4..1.213"), "8n"); 
`--snip--` 

列表 13-11:和弦编排

我们首先创建一个名为 chords 的对象,包含我们将要编排的四个和弦 ❶。我们可以给它们任何名字,但为了简化,我使用数字 1、2、3 和 4 来表示这些和弦(不过请注意,由于这些是对象的键,数字会被当作字符串处理)。每个和弦编号对应一个音符名称的数组,这是 PolySynth 所需的格式。这两个和弦序列只是这四个和弦的不同排列。

接下来是一个用于播放和弦的辅助函数 ❷。这个 playChord 函数接受播放和弦的时间和和弦的名称(作为字符串,取值为 1 到 4 中的一个)。然后,它在 chords 对象中查找并提取由给定和弦名称键入的音符数组 ❸。函数的最后会调用 triggerAttackRelease 方法来触发和弦合成器,传入音符名称的数组。由于它是 PolySynth,我们的和弦合成器可以同时演奏和弦中的所有音符。

最后,我们创建了两个序列,分别叫做 chordSequence1 ❹和 chordSequence2 ❺。这两个序列的回调函数都是我们的 playChord 函数。我们还使用了之前用于编排鼓点的 mkSequence 辅助函数,但在这里,字符串中的值要么是点(静默),要么是和弦名称。与低音线不同,mkSequence 在这里可以正常工作,因为每个和弦名称都是一个单独的字符,我们有 playChord 函数来将和弦名称重新解释为音高。与鼓点一样,我们将"8n"作为最后一个参数传递给 Tone.Sequence,这意味着每个点或和弦名称代表一个八分音符。第一个序列有 32 个八分音符长,或者 4 小节。第二个序列有 16 个八分音符长,或者 2 小节。

现在我们将真正安排这些序列与时间轨道的同步。将列表 13-12 中的代码添加到 Song 部分的末尾。

`--snip--`
chordSequence1.start("8:0:0").stop("24:0:0");
chordSequence2.start("24:0:0").stop("32:0:0");
chordSequence1.start("32:0:0").stop("40:0:0");
`--snip--` 

列表 13-12:安排和弦序列

第一个序列在 8 小节后开始播放,并持续播放到第 24 小节结束,这时已经播放了 16 小节,或者说是第一个序列的四个完整循环。接下来,第二个序列接管,持续播放到第 32 小节;这时播放了 8 小节,或者是第二个序列的四个完整循环。最后,第一个序列重新回归,播放到第 40 小节;这也是 8 小节,或者是第一个序列的两个完整循环。

尝试刷新浏览器并再次聆听歌曲。确保在事件处理程序中将 Tone.Transport.position 设置为 "0:0:0" 来从头开始播放。如果你不想等八小节才能进入和弦,设置为 "8:0:0" 就能从和弦开始播放。

播放旋律

现在我们已经有了鼓、贝斯和和弦,我们的歌曲唯一缺少的就是旋律。我们将使用在上一章创建的小号采样器,并使用 Tone.Part 来安排音符,通过它我们可以轻松地分别安排旋律中每个音符的时机。

首先,我们会创建采样器,就像在第十二章中做的那样。将列表 13-13 中的代码添加到乐器部分的末尾。

`--snip--`
// Samples from freesound.org:
// https://freesound.org/people/MTG/sounds/357432/
// https://freesound.org/people/MTG/sounds/357336/
// https://freesound.org/people/MTG/sounds/357546/
let sampler = new Tone.Sampler({
  urls: {
    "C5": "trumpet-c5.mp3", 
    "D5": "trumpet-d5.mp3", 
    "F5": "trumpet-f5.mp3" 
  },
  baseUrl: "https://skilldrick-jscc.s3.us-west-2.amazonaws.com/",
  attack: 0,
  release: 1,
  volume: -24
}).toDestination();
`--snip--` 

列表 13-13:声明小号采样器

在这里,我们创建了一个 Tone.Sampler 乐器,使用与上一章相同的三个样本。不过,请注意,我们不再使用采样器的 onload 属性来告诉它在样本下载完成后应该做什么。这有点偷懒,但我知道小号不会在歌曲开始时演奏,我依赖于在它们进入时样本已经下载完成。正确的做法是隐藏播放按钮,直到样本下载完成,但那样会增加项目的复杂性。

列表 13-14 展示了为旋律安排音符的代码。将此代码添加到安排部分的末尾。

`--snip--`
let trumpetPart = new Tone.Part((time, note) => {
  sampler.triggerAttackRelease(note, "1n", time);
}, [
  ["0:0:0", "G5"],
  ["0:2:0", "C5"],
  ["1:0:0", "G5"],

  ["2:0:0", "D5"],
  ["2:2:0", "C5"],
  ["3:0:0", "B4"],

  ["4:0:0", "G5"],
  ["4:2:0", "C5"],
  ["5:0:0", "G5"],

  ["6:0:0", "D5"],
  ["6:2:0", "C5"],
  ["7:0:0", "B4"],
  ["7:2:0", "D5"],

  ["8:0:0", "C5"],
  ["8:2:0", "E5"],
 ["9:0:0", "F5"],
  ["9:2:0", "D5"],

  ["10:0:0", "C5"],
  ["10:2:0", "E5"],
  ["11:0:0", "D5"],

  ["12:0:0", "C5"],
  ["12:2:0", "E5"],
  ["13:0:0", "F5"],
  ["13:2:0", "D5"],

  ["14:0:0", "C5"],
  ["14:2:0", "E5"],
  ["15:0:0", ["B4", "G5"]]
]);
`--snip--` 

列表 13-14:旋律音符安排

提醒一下,Tone.Part 构造函数接受两个参数:一个用于播放每个时间/音符对的回调函数和一个时间/音符对的列表。在这里,回调函数会为每个时间/音符对在小号采样器上播放一个长音符("1n",即一个完整的小节)。第一个音符在 "0:0:0" 播放,第二个音符则在两拍后播放,即 "0:2:0"。由于音符大约四拍长,它们会重叠——我故意这么做,以增加旋律的趣味性。

这首曲子还不会播放,因为我们还没有指定何时播放。即使每个音符都有一个时间,这些时间是相对于部分开始安排的时间而言的。为了安排这个部分,我们只需在歌曲部分的末尾添加一些代码,如列表 13-15 所示。

`--snip--`
trumpetPart.start("16:0:0");
`--snip--` 

列表 13-15:安排小号部分

与我们到目前为止安排的序列不同,这一部分没有循环,因此不需要停止时间。我们告诉 Tone.js 在 16 小节后开始小号部分,这意味着该部分中的所有时间都相对于"16:0:0"。我们可以将两个时间加在一起,得到每个音符的实际安排时间(例如,"4:2:0" + "16:0:0" 就是 "20:2:0")。

现在你可以听完整首歌曲了!在刷新页面之前,别忘了将 Tone.Transport.position 重置为 "0:0:0"。

完整代码

我们已经在文件中各个地方添加了代码,所以如果你把某些内容弄混了,或者只是想看看最终效果,清单 13-16 给出了script.js的完整内容。

/////////////////
// Instruments //
/////////////////

function mkDrums() {
  let reverb = new Tone.Reverb({
    decay: 1,
    wet: 0.3
  }).toDestination();

  let hiHatFilter = new Tone.Filter(15000, "bandpass").connect(reverb);

  let hiHat = new Tone.NoiseSynth({
    envelope: {
      attack: 0.001, decay: 0.1, sustain: 0, release: 0
    },
    volume: -6
  }).connect(hiHatFilter);

  class Snare {
    constructor() {
      this.noiseFilter = new Tone.Filter(5000, "bandpass").connect(reverb);
      this.noiseSynth = new Tone.NoiseSynth({
        envelope: {
          attack: 0.001, decay: 0.1, sustain: 0, release: 0
        },
        volume: -12
 }).connect(this.noiseFilter);

      this.synth = new Tone.Synth({
        envelope: {
          attack: 0.0001, decay: 0.1, sustain: 0, release: 0
        },
        oscillator: {type: "sine"},
        volume: -12
      }).connect(reverb);
    }

    triggerAttackRelease(duration, when) {
      this.noiseSynth.triggerAttackRelease(duration, when);
      this.synth.triggerAttackRelease("G3", duration, when);
    }
  }

  let snare = new Snare();

  let kick = new Tone.MembraneSynth({
    pitchDecay: 0.02,
    octaves: 6,
    volume: -9
  }).connect(reverb);

  return {hiHat, snare, kick};
}

let drums = mkDrums();

let lowBass = new Tone.FMSynth({
  oscillator: {
    type: "triangle"
  },
  envelope: {
    attack: 0.0001, decay: 0.5, sustain: 0.3, release: 0.1
  },
  volume: -3
}).toDestination();

let highBass = new Tone.FMSynth({
  oscillator: {
    type: "square"
  },
  envelope: {
    attack: 0.0001, decay: 0.1, sustain: 0.3, release: 0.1
  },
  volume: -9
}).toDestination();

let chordSynth = new Tone.PolySynth(Tone.Synth, {
  oscillator: {
    type: "triangle"
  },
 volume: -12
}).toDestination();

// Samples from freesound.org:
// https://freesound.org/people/MTG/sounds/357432/
// https://freesound.org/people/MTG/sounds/357336/
// https://freesound.org/people/MTG/sounds/357546/
let sampler = new Tone.Sampler({
  urls: {
    "C5": "trumpet-c5.mp3", 
    "D5": "trumpet-d5.mp3", 
    "F5": "trumpet-f5.mp3" 
  },
  baseUrl: "https://skilldrick-jscc.s3.us-west-2.amazonaws.com/",
  attack: 0,
  release: 1,
  volume: -24
}).toDestination();

////////////////
// Sequencing //
////////////////

// Converts a string to an array of notes or nulls.
// Dots in the string become nulls in the array and are silent.
function mkSequence(pattern) {
  return pattern.split(" ").map(value => {
    if (value == ".") {
      return null;
    } else {
      return value;
    }
  });
}

// Converts a string to an array of notes or nulls.
// Spaces between pipes in the string become nulls in the array and are silent.
function mkPipeSequence(pattern) {
  return pattern.split("|").map(value => {
    if (value.trim() == " ") {
      return null;
    } else {
      return value;
    }
  });
}

let drumPattern = {
  kick:  "x…x…",
  snare: "..x…x.",
  hiHat: "xxxxxxxx",
};

let hiHatSequence = new Tone.Sequence(time => {
  drums.hiHat.triggerAttackRelease("16n", time);
}, mkSequence(drumPattern.hiHat), "8n");

let snareSequence = new Tone.Sequence(time => {
  drums.snare.triggerAttackRelease("16n", time);
}, mkSequence(drumPattern.snare), "8n");

let kickSequence = new Tone.Sequence(time => {
  drums.kick.triggerAttackRelease(50, "16n", time);
}, mkSequence(drumPattern.kick), "8n");

let lowBassSequence = new Tone.Sequence((time, note) => {
  lowBass.triggerAttackRelease(note, "16n", time, 0.6);
}, mkPipeSequence("G2|  |  |G2|G2|  |  |  "), "8n");

let highBassSequence = new Tone.Sequence((time, note) => {
  highBass.triggerAttackRelease(note, "16n", time, 0.3);
}, mkPipeSequence("G3|F3|E3|D3|G2|D3|G3|D3"), "8n");

let chords = {
  1: ["D4", "G4"],
  2: ["E4", "G4"],
  3: ["C4", "E4", "G4"],
  4: ["B3", "F4", "G4"],
};

function playChord(time, chordName) {
  let notes = chords[chordName];
  chordSynth.triggerAttackRelease(notes, "16n", time, 0.6);
}

let chordSequence1 = new Tone.Sequence((time, chordName) => {
  playChord(time, chordName);
}, mkSequence("1…2…3..4…31…2…3..4.343"), "8n");

let chordSequence2 = new Tone.Sequence((time, chordName) => {
  playChord(time, chordName);
}, mkSequence("3…2…4..1.213"), "8n");

let trumpetPart = new Tone.Part((time, note) => {
  sampler.triggerAttackRelease(note, "1n", time);
}, [
  ["0:0:0", "G5"],
  ["0:2:0", "C5"],
  ["1:0:0", "G5"],

  ["2:0:0", "D5"],
  ["2:2:0", "C5"],
  ["3:0:0", "B4"],

  ["4:0:0", "G5"],
  ["4:2:0", "C5"],
  ["5:0:0", "G5"],

 ["6:0:0", "D5"],
  ["6:2:0", "C5"],
  ["7:0:0", "B4"],
  ["7:2:0", "D5"],

  ["8:0:0", "C5"],
  ["8:2:0", "E5"],
  ["9:0:0", "F5"],
  ["9:2:0", "D5"],

  ["10:0:0", "C5"],
  ["10:2:0", "E5"],
  ["11:0:0", "D5"],

  ["12:0:0", "C5"],
  ["12:2:0", "E5"],
  ["13:0:0", "F5"],
  ["13:2:0", "D5"],

  ["14:0:0", "C5"],
  ["14:2:0", "E5"],
  ["15:0:0", ["B4", "G5"]]
]);

//////////
// Song //
//////////

hiHatSequence.start("0:0:0").stop("44:0:0");
snareSequence.start("0:0:0").stop("44:0:0");
kickSequence.start("0:0:0").stop("44:0:0");

lowBassSequence.start("0:0:0").stop("47:3:0");
highBassSequence.start("4:0:0").stop("47:3:0");

chordSequence1.start("8:0:0").stop("24:0:0");
chordSequence2.start("24:0:0").stop("32:0:0");
chordSequence1.start("32:0:0").stop("40:0:0");

trumpetPart.start("16:0:0");

////////////////////
// Event Handling //
////////////////////

let play = document.querySelector("#play");
let playing = document.querySelector("#playing");

play.addEventListener("click", () => {
  // Hide this button
  play.style = "display: none";
  playing.style = " ";

  Tone.start();

 // Modify this to start playback at a different part of the song
  Tone.Transport.position = "0:0:0";
  Tone.Transport.start();
}); 

清单 13-16:完整代码

总结

在这一章中,你用 JavaScript 编写了一首歌!现在你已经习惯使用 Tone.js,你可以用它来创作自己的歌曲。另一个有趣的尝试是算法音乐,在这种方式下,你不是写出固定的歌曲,而是编写代码,每次运行时都会随机生成新的音乐。一种简单的尝试方法是列出一组听起来不错的和弦,然后随机选择某个和弦在某个特定的节拍上演奏(你可以使用 Tone.Loop 来实现这一点,就像我们在清单 12-12 中所做的那样)。

第六章:14 介绍 D3 库

当今世界充满了数据,但没有以某种方式将数据可视化,原始数据基本上是无法理解的。数据可视化可以非常简单,比如维基百科上的一张图表,显示某个特定城市每个月的平均温度;也可以非常复杂,比如新闻机构制作的动画信息图,展示数万美国人的收入流动性。无论复杂程度如何,数据可视化总是有潜力为我们提供更深入的洞察。

在这个项目中,你将学习使用一个强大的 JavaScript 库,叫做 D3.js(简称 D3),它将使你能够在浏览器中创建各种数据可视化。使用 JavaScript 制作数据可视化的好处在于,它们可以是动态的和交互式的。动态意味着可视化可以随着时间变化;例如,当新数据到来时,可以更新。交互式意味着用户可以操作可视化,例如,通过点击显示某个特定方面的更多细节。此外,由于你是自己编写代码来制作可视化,因此可以自由地根据需要自定义它们。

本章将向你介绍使用 D3 的基础知识,为下一章做准备,在下一章中,你将通过从外部 API 加载数据来创建一个交互式可视化。D3 主要使用一种叫做可扩展矢量图形(SVG)的网页图形技术,因此在深入 D3 之前,我们将从 SVG 的速成课程开始。

SVG 图形格式

SVG 是一种使用点、线和曲线来定义图像的方式,而不是使用像素。这些图像被称为矢量图形。因为你定义的是图像的形状,而不是单独的像素,所以你可以自由缩放或放大 SVG 图像,而不会出现像素化现象(因此有了名称中的可扩展部分)。

SVG 基于可扩展标记语言(XML),这是一种用于存储数据的语言,类似于 HTML,它依赖于带有开始和结束标签的嵌套元素结构。SVG 的 XML 看起来类似于 HTML,但它有自己的一套标签,直接对应于视觉元素(而 HTML 中的标签则用于定义结构和内容)。SVG 文件可以是独立的 XML 文件,但也可以使用 HTML 的 svg 元素将 SVG 嵌入 HTML 文件中,从而便于将 SVG 图形添加到网页中。

与 Canvas API 在 Web 上渲染互动图形相比,SVG 的一个优势是,SVG 图形的每个元素都由 Web 页面上的 DOM 元素表示,这意味着你可以使用 CSS 对其进行样式设置,并使用 JavaScript 添加事件处理程序来响应鼠标事件,如点击或悬停。另一方面,基于 Canvas 的图形渲染速度更快,因此需要高帧率的应用程序,如游戏,通常会使用 Canvas API 而非 SVG。

让我们编写第一个 SVG。创建一个名为svg的新目录,并在该目录中创建一个包含列表 14-1 内容的index.html文件。我们将在这个 HTML 文件中嵌入我们的 SVG。同时,在相同目录下创建两个空文件,分别命名为style.cssscript.js——我们稍后会在准备好为 SVG 添加样式并使其具互动性时填充这些文件。

<!DOCTYPE html>
<html>
  <head>
    <title>SVG</title>
    <link rel="stylesheet" href="style.css">
 </head>
  <body>
  ❶ <svg width="600" height="600"></svg>

    <script src="script.js"></script>
  </body>
</html> 

列表 14-1:用于探索 SVG 的 index.html 文件

列表 14-1 中的代码遵循我们的标准 HTML 模板,并添加了一个空的 svg 元素❶。该 svg 元素的宽度和高度被设置为 600 像素。当你在浏览器中加载该页面时,它应该是空白的,因为我们还没有在 SVG 中添加内容。

现在让我们添加一些图形。我们将在 svg 元素中添加一个矩形和一些文本,如列表 14-2 所示。

`--snip--`
 <body>
    <svg width="600" height="600">
    ❶ <rect width="95" height="20" x="5" y="5"
        stroke="red" fill="none"></rect>
    ❷ <text x="10" y="20" font-family="sans-serif">Hello, SVG!</text>
    </svg>

<script src="script.js"></script>
`--snip--` 

列表 14-2:将图形添加到 svg 元素

所有位于标签之间的内容都是 SVG XML,它有自己的一套标签名称。在这个示例中,我们使用了 rect❶和 text❷元素。rect 元素根据通过元素属性设置的规范绘制矩形。我们将宽度和高度分别设置为 95 像素和 20 像素,并将其 x 和 y 坐标(矩形左上角的位置)设置为(5, 5)。我们使用 stroke 属性将轮廓设置为红色,并将填充颜色设置为无(默认填充颜色为黑色)。rect 元素不包含任何内容,因此起始标签后立即跟随结束标签。

类似地,我们使用文本元素将文本插入到图形中。文本元素也使用 x 和 y 属性来设置其位置,但在这种情况下,它们指的是文本的基线起始位置。在排版中,基线是沿大多数字母底部延伸的隐形线,排除像pg这类有下行部分的字母。默认情况下,x 属性给出文本起始位置的水平坐标。我们使用 font-family 属性将文本的字体设置为默认的无衬线字体。文本元素的内容是实际会被绘制的文本,在此案例中为“Hello, SVG!”。

重新加载页面后,你应该看到这段文本被一个红色边框的矩形包围,如 图 14-1 所示。

图 14-1: 我们的第一个 SVG 绘图

尝试放大页面(在 Windows 或 Linux 上使用 CTRL-+,在 macOS 上使用 COMMAND-+)。即使你缩放图像,矩形和文本应该依然清晰。

分组元素

你可以通过将多个 SVG 元素嵌套在 g(代表)元素内,将它们组合在一起。这很有用,因为在 g 元素本身设置的任何属性都会应用到所有子元素上。为了演示,更新你的 svg 元素内容,如 清单 14-3 所示。

`--snip--`
<svg width="600" height="600">
❶ <g font-family="sans-serif" fill="blue">
    <text x="0" y="20">Always</text>
    <text x="0" y="40">Be</text>
    <text x="0" y="60">Coding</text>
  </g>
</svg>
`--snip--` 

清单 14-3: 使用 g 元素分组元素

在这个例子中,我们创建了一个包含三个子文本元素的组,每个子元素包含一个单独的单词。文本元素的 x 坐标相同,但 y 坐标不同,因此这些单词会垂直堆叠并左对齐。父级 g 元素的属性(字体和填充 ❶)会应用于组中的所有子元素。重新加载页面,你应该看到所有三个单词都变成蓝色并且使用无衬线字体。

使用 g 元素创建分组还可以让你对组内的所有子元素应用变换。SVG 支持几种变换,包括平移、旋转、缩放和倾斜。我们将使用平移(translate)来将所有元素按固定的距离移动。通过以下更改更新 index.html 中的开头 g 元素标签:

`--snip--`
 <g transform="translate(100, 50)" font-family="sans-serif" fill="blue">
`--snip--` 

transform 属性接受一个由空格分隔的变换列表。在这里,我们传递了一个变换:translate(100, 50)。这表示将组内的所有元素沿 x 轴移动 100 像素,并沿 y 轴向下移动 50 像素。

我们还可以通过在平移变换后添加缩放变换来调整分组的大小:

`--snip--`
  <g transform="translate(100, 50) scale(2, 3) "font-family="sans-serif" fill="blue">
`--snip--` 

在平移之后,元素将水平缩放 2 倍,垂直缩放 3 倍,如 图 14-2 所示。

图 14-2: 变换后的分组元素

所有的变换都是相对于原点 (0, 0) 的,除非之前的平移操作已经移动了原点。这意味着缩放不仅影响元素的大小,还会影响元素的位置。例如,当你将一个左上角为 (10, 10),右下角为 (30, 30) 的正方形缩放 2 倍时,新的角落坐标将是 (20, 20) 和 (60, 60)。相对于原点的 x 和 y 坐标都会被放大两倍。

绘制圆形

你可以使用 circle 元素绘制一个 SVG 圆形。属性 cx 和 cy 设置圆形中心的坐标,r 设置半径。为了尝试,替换 svg 元素的内容为列表 14-4 中的代码。

`--snip--`
<svg width="600" height="600">
❶ <circle fill="#faa0a0" r="100" cx="124" cy="130"></circle>

❷ <g stroke="#944e30" stroke-width="3">
    <rect width="8" height="100" x="120" y="90" fill="#e1704d"></rect>
    <circle fill="#acd270" r="18" cx="124" cy="150"></circle>
    <circle fill="#fdfce2" r="18" cx="124" cy="120"></circle>
    <circle fill="#f8c9dc" r="18" cx="124" cy="90"></circle>
  </g>
</svg>
`--snip--` 

列表 14-4:绘制圆形

在这个例子中,我们使用了新的圆形元素,以及 g 和 rect 元素。第一个圆圈 ❶ 的填充颜色是 #faa0a0,即鲑鱼粉色,半径为 100 像素,中心坐标为(124,130)。请注意,我们在这里使用的是十六进制颜色——请查看下一页的“十六进制颜色”框以了解更多。接下来,我们使用一个组 ❷ 为矩形和三个较小的圆圈应用标准的描边颜色(栗色)和宽度(3 像素),填充颜色分别为绿色、黄色和玫瑰色。所有这些效果形成了一个可爱的插图,展示了日本的花见团子(在樱花季节非常流行的一种甜点),如图 14-3 所示。

图 14-3:使用 SVG 圆形绘制花见团子的插图

请注意,元素声明的顺序决定了它们绘制的顺序。这三个小圆圈是从下到上声明的,所以在重叠的地方,上面的圆圈会覆盖在下面的圆圈上。同样,由于大圆圈首先声明,它被视为其余插图的背景。

定义路径

path 元素是所有 SVG 元素中最强大的,它允许你通过在不同的点之间绘制直线或曲线(“路径”)来创建自定义形状。path 元素的 d 属性(数据的缩写)是一个包含路径定义的字符串,路径定义是路径命令的列表。这个字符串的语法被优化为尽可能紧凑,因此复杂的路径可以用相对较短的字符串表示。这对计算机来说很好,但对人类来说不好;不要指望这些字符串容易阅读。

在下一个例子中,我们将使用 path 元素重新创建 HTML5 标志,从外部盾牌形状开始。将 index.html 中 svg 元素的内容替换为列表 14-5 中的代码。

`--snip--`
<svg width="600" height="600">
  <path fill="#e44d26" d="M 0 0 H 182 L 165 185 L 90 206 L 17 185 Z"/>
  <path fill="#f16529" d="M 91 15 H 165 L 151 173 L 91 190 Z"/>
</svg>
`--snip--` 

列表 14-5:绘制 HTML5 标志盾牌

在深入研究路径定义之前,先了解一下结果应该是什么样子会有所帮助。重新加载页面,你应该能看到图 14-4 所示的盾牌设计。

图 14-4:HTML5 标志盾牌

这个设计由两个路径组成,一个用于较深的主盾牌形状,另一个用于盾牌形状右半部分的亮部。让我们来看一下较暗部分的路径定义:

M 0 0 H 182 L 165 185 L 90 206 L 17 185 Z

这里有六个指令:

  • M 0 0

  • H 182

  • L 165 185

  • L 90 206

  • L 17 185

  • Z

可以把这些命令看作是让一个虚拟的笔在屏幕上移动以绘制线条。M 命令接受一个位置作为 (x, y) 坐标对,并将笔移动到该位置,而不绘制任何内容。H 命令接受一个 x 坐标,并从当前笔的位置绘制一条水平线到该 x 值。L 命令接受一个 (x, y) 坐标对,并从当前位置绘制一条线到那个位置。最后,Z 命令闭合路径,从当前的位置画一条线回到路径的起点。用英语来描述,路径中的命令是,“移动到 (0, 0),绘制一条水平线到 (182, 0),绘制一条线到 (165, 185),绘制一条线到 (90, 206),绘制一条线到 (17, 185),然后绘制一条线回到 (0, 0) 来闭合路径。”第二条路径使用相同的技巧来绘制盾牌上的内侧高光,使用了不同的填充颜色。

这些命令使用 绝对位置 来定义要移动到的点,精确的 x 和 y 坐标。然而,每个命令都有一个替代版本,使用相对位置,这意味着下一个点是相对于当前笔的位置来定义的。绝对命令都使用大写字母,而相对命令则使用相同的字母,但小写。例如,我们刚才看到的路径定义可以用相对路径命令重写,像这样:

m 0 0 h 182 l -17 185 l -75 21 l -73 -21 z

在这种情况下,移动命令是相同的,因为没有之前的位置可以作为参考。命令 h 182 表示从当前的位置向右绘制 182 单位的水平线。命令 l -17 185 表示从当前位置向左绘制 17 单位并向下 185 单位,依此类推。Z 和 z 命令做的是同样的事情,它们仅仅为了完整性而出现在 SVG 规范中。

事实上,这个相对路径定义可以写得更紧凑:

m0 0h182l-17 185-75 21-73-21z

空格仅在避免两个数字之间的歧义时才需要,但在 SVG 路径中是可选的。由于有负数的存在,我们能够去除几乎所有的空格。此外,如果相同的命令连续多次使用,可以只写一次命令,然后继续提供数字。例如,l-17 185-75 21-73-21 是 l -17 185 l -75 21 l -73 -21 的简写版本。

注意

SvgPathEditor (yqnn.github.io/svg-path-editor/) 是一个非常有用的工具,用于实验和操作路径,并进行绝对和相对命令之间的转换(这也是我在这里用来转换这两种形式的工具)。SVG 有几个额外的路径命令,主要用于绘制各种类型的曲线。我们在这里不深入讨论这些内容,但你可以在 MDN 上找到完整的列表,网址是 developer.mozilla.org/SVG

现在你已经理解了路径定义的工作原理,我们可以添加更多路径来填充完整的 HTML5 标志。按照清单 14-6 中的内容更新 svg 元素的内容(尽管如果你决定这太多打字了,我也不会因此看低你!)。

`--snip--`
<svg width="600" height="600">
 <path fill="#e44d26" d="M 0 0 H 182 L 165 185 L 90 206 L 17 185 Z"/>
 <path fill="#f16529" d="M 91 15 H 165 L 151 173 L 91 190 Z"/>
  <path fill="#ebebeb" d="m 34 38 h 57 v 23 h -32 l 2 24 h 30 v 23 h -51 z"/>
  <path fill="#ebebeb" d="m 41 118 h 23 l 2 18 l 25 7 v 24 l -47 -13 z"/>
  <path fill="#fff" d="m 148 38 h -57 v 23 h 55 z"/>
  <path fill="#fff" d="m 143 85 h -52 v 23 h 28 l -3 30 l -25 5 v 24 l 47 -13 z"/>
</svg>
`--snip--` 

清单 14-6:完成 HTML5 标志

我在这里使用了相对路径命令,部分是为了变化,部分是因为相对路径的数字更小,形成了更短的代码行。重新加载页面时,你应该能看到完整的 HTML5 标志,如图 14-5 所示。两个填充颜色为 #ebebeb(浅灰色)的路径绘制了数字 5 左侧的两部分,而两个填充颜色为 #fff(白色)的路径绘制了数字 5 右侧的两部分。

图 14-5:完整的 HTML5 标志

一般来说,当你创建数据可视化时,不需要手动输入路径定义。D3 会为你生成它们。不过,理解语法仍然有帮助,这样在调试时你能知道发生了什么。

使用 CSS 样式化元素

当你将 SVG 嵌入到 HTML 文件中时,每个 SVG 元素都会成为 DOM 的一部分,因此可以使用 CSS 进行样式化。为了演示这个原理,我们将绘制一些 SVG 形状并为它们都指定类名。将 svg 元素的内容替换为清单 14-7 中的代码。

`--snip--`
<svg width="600" height="600">
  <circle class="boring" r="40" cx="50" cy="50"></circle>
  <rect class="boring" x="120" y="10" width="80" height="80"></rect>
  <path class="boring" d="M 230 90 l 40 -80 l 40 80 z"></path>"

  <circle class="fun" r="40" cx="50" cy="180"></circle>
  <rect class="fun" x="120" y="140" width="80" height="80"></rect>
  <path class="fun" d="M 230 220 l 40 -80 l 40 80 z"></path>"
</svg>
`--snip--` 

清单 14-7:带类名的一些 SVG 元素

在这里,我们绘制了一个圆形、一个方形和一个三角形,然后又绘制了一个圆形、方形和三角形。注意,三角形是通过路径绘制的——并没有像 rect 或 circle 那样专门的三角形元素。前三个形状的类名为 boring,后面三个的类名为 fun。重新加载页面时,你应该能看到两行三种形状,所有形状都有相同的默认黑色填充,如图 14-6 所示。

图 14-6:未样式化的 SVG 形状

现在我们将为形状添加样式。因为它们都有类名,所以我们可以像选择 HTML 元素一样在 CSS 中选择它们。将清单 14-8 中的代码添加到你的style.css文件中。

.boring {
  fill: none;
  stroke: black;
  stroke-width: 3px;
}

.fun {
  fill: hotpink;
  stroke: greenyellow;
  stroke-width: 5px;
  stroke-dasharray: 10,5;
  stroke-linejoin: round;
} 

清单 14-8:形状的样式

在这个清单中,我们为两个类指定了不同的样式:.boring 获得一个简单的黑色轮廓,.fun 获得粉色填充和一条粗的绿色-黄色虚线轮廓。请注意,样式化 SVG 元素的属性名称与 HTML 元素不同。例如,HTML 元素使用 background-color 和 border-color,而 SVG 元素使用 fill 和 stroke。

值得注意的是,你也可以直接将这些样式作为属性应用到 index.html 文件中的 SVG 元素。使用 CSS 的优势有两个:首先,它意味着所有的样式信息都集中在一个地方,便于更新;其次,要以相同的方式样式化多个元素,你只需为每个元素添加一个类名,而不必将所有属性从一个元素复制到另一个元素。

重新加载页面后,你应该注意到你的形状现在有了一些样式,如图 14-7 所示。

图 14-7:带样式的 SVG 形状

也可以在 SVG 元素上使用伪类,如 :hover。将清单 14-9 中的代码添加到 style.css 文件的末尾以尝试此功能。

`--snip--`
.fun:hover {
  fill: greenyellow;
  stroke: hotpink;
} 

清单 14-9:添加悬停效果

在这里,当鼠标悬停在 .fun 元素上时,我们交换了填充和描边的颜色。重新加载页面,亲自查看吧!

这是 SVG 相对于 Canvas API 的一个重大优势:浏览器了解 SVG 元素,知道例如鼠标何时悬停在它们上面。与此相比,Canvas 中浏览器只知道某些颜色的像素已经被绘制出来,任何鼠标悬停效果都必须在 JavaScript 中显式编码。

使用 JavaScript 添加交互性

我们可以使用 JavaScript 为 SVG 元素添加交互性,就像使用 CSS 为它们设置样式一样。之所以可以实现这一点,是因为嵌入在 HTML 中的每个 SVG 元素都会成为 DOM 的一部分。首先,我们将编写一个脚本来选择这些元素,并将它们打印到控制台,以便复习 JavaScript DOM 方法。将清单 14-10 中的代码添加到当前空白的 script.js 文件中。

document.querySelectorAll(".fun").forEach(element => {
  console.log(element);
}); 

清单 14-10:选择 .fun 元素

在这个清单中,我们使用 querySelectorAll 方法来选择所有具有类名 fun 的元素。然后我们使用 forEach 方法遍历这些选中的元素并将它们打印到控制台。运行此代码时,你应该会看到三个元素在控制台中分别打印出来。如果你将鼠标悬停在控制台中的每个元素上,该元素也会在网页上高亮显示。

现在我们可以添加一些交互功能。清单 14-11 中的 script.js 代码修改后,当你点击某个元素时,该元素将向右移动;而当你按住 SHIFT 键并点击时,该元素将向左移动。

document.querySelectorAll(".fun").forEach(element => {
❶ element.setAttribute("data-offset", 0);

❷ element.addEventListener("click", event => {
  ❸ let offset = Number(event.target.getAttribute("data-offset"));

    if (event.shiftKey) {
      offset -=5;
    } else {
      offset +=5;
    }

  ❹ event.target.setAttribute("data-offset", offset);
  ❺ event.target.setAttribute("transform", `translate(${offset}, 0)`);
  });
}); 

清单 14-11:点击时移动 SVG 元素

在 forEach 方法调用内部,我们对每个元素做了两件事。首先,我们在每个元素上设置一个叫做数据属性的东西。数据属性是用于在 DOM 中存储数据的 HTML 或 SVG 属性,它们的名称都以"data-"字符串开头。具体来说,我们创建了 data-offset 数据属性,利用它来跟踪如何定位每个元素,并将其值设置为 0 ❶。请注意,DOM 属性始终以字符串形式存储,因此数字 0 将被转换为字符串"0"。

接下来,我们为每个元素附加一个点击事件处理器 ❷。处理器的第一件事是使用 getAttribute 提取被点击元素的 data-offset 属性,并将其值存储在变量 offset 中 ❸。被点击的元素可以通过事件对象上的 target 属性访问。请注意,我们在这里使用 Number 函数将字符串转换为数字。该处理器第一次被调用时,变量 offset 将被设置为 0,因为那是我们在 data-offset 属性中存储的初始值 ❶。

我们使用事件的 shiftKey 属性来判断鼠标点击时是否按下了 SHIFT 键。如果按下了,我们从 offset 中减去 5。否则,我们将 offset 加上 5。然后,我们使用 setAttribute 将更新后的值赋给 data-offset 属性 ❹。最后,我们再次使用 setAttribute 方法,但这一次是设置 transform SVG 属性 ❺。正如你在本章前面看到的,我们可以使用 transform 通过某个距离平移元素,使用字符串 translate(x, y)。在这里,我们将平移的 x 值设置为 offset 的值,平移的 y 值设置为 0。这意味着如果 offset 是正值,元素将向右移动,如果它是负值,元素将向左移动。

当你重新加载页面时,彩色的 SVG 元素应该会在你点击它们时移动。如果你右键点击其中一个元素并选择“检查”,你将看到该元素出现在元素面板中。随着你在浏览器视口中点击不同的元素,你应该会看到元素面板中的 data-offset 和 transform 属性更新。

D3 库

现在你已经了解了 SVG 的基础知识,可以开始学习 D3 库,它结合了 SVG 和 JavaScript 来创建数据可视化。D3,即数据驱动文档,让你能够创建内容由数据驱动的文档。它通过一种叫做数据绑定的技术实现这一点,在这种技术中,想要可视化的底层数据的各个部分与页面上的各个元素进行链接。这样,如果数据发生变化,元素也会随之变化。你将在本节后面看到它是如何工作的。

设置

我们将创建一组新的文件来探索 D3。创建一个名为 data 的新目录,包含一个空的 script.js 文件和一个包含 清单 14-12 内容的 index.html 文件。

<!DOCTYPE html>
<html>
  <head>
    <title>Data</title>
  </head>
  <body>
  ❶ <svg width="600" height="600">
      <circle cx="50" cy="50" r="10"></circle>
      <circle cx="100" cy="50" r="10"></circle>
      <circle cx="150" cy="50" r="10"></circle>
    </svg>

  ❷ <script src="https://unpkg.com/d3@7.4.4/dist/d3.js"></script>
    <script src="script.js"></script>
  </body>
</html> 

清单 14-12:一个新的 index.html 用于与 D3 一起使用

首先我们创建一个 svg 元素❶并绘制三个圆形。然后我们使用一个脚本元素链接到托管在 https://unpkg.com ❷ 上的 D3 库副本,和我们在音乐项目中使用 Tone.js 的方式类似。现在你就可以在你的 script.js 文件中使用 D3 的代码了。当你加载页面时,应该会看到三个黑色圆形。很快我们将使用 D3 来操作这些圆形。

选择

D3 的基本构建块之一是 选择,它是一种选择一组元素的方法,以便可以对这些元素应用某些操作。让我们使用 D3 选择三个 SVG 圆形并将它们的填充颜色更改为热粉色。将 清单 14-13 中的代码添加到 script.js

d3.selectAll("circle").attr("fill", "hotpink");

清单 14-13:选择圆形

d3.selectAll 方法接受一个 CSS 选择器,这里是元素名称 circle,并返回一个 D3 选择,你可以在其上链式调用更多方法。那些链式调用将应用于所有符合选择器的元素。在这里,我们将选择中的每个元素的 "fill" 属性设置为 "hotpink"。当你重新加载页面时,你应该会看到黑色圆形现在变成了粉色。

在更新选择中的元素时,也可以使用函数而不是值。当你这样做时,函数会被调用并返回一个值,这个返回值将作为更新这些元素的值。这使你能够动态地修改元素。更新 script.js 中的代码,应用 清单 14-14 中的更改,以查看它是如何工作的。

d3
  .selectAll("circle")
  .attr("fill", "hotpink")
❶ .attr("r", (d, i) => 10 + i * 5); 

清单 14-14:使用函数计算值

对于像这样的长方法链,通常会将代码拆分成多行以提高可读性。如前所述,我们选择了所有圆形并将其填充颜色设置为热粉色,但这一次我们还更新了每个圆形的半径❶。这里用于生成值的函数有两个参数,d 和 i。我们将在下一节中介绍 d 参数,它是 datum(数据项)的缩写。i,作为 index(索引)的缩写,是选择中元素的索引(第一个圆形的索引为 0,第二个为 1,依此类推)。我们使用代码 10 + i * 5 来根据索引编号为每个圆形设置不同的半径。具体来说,这些圆形的半径将是 10、15 和 20。当你重新加载页面时,你应该会看到这三个圆形的大小现在都不同了。

注意

像 .attr 这样的 D3 选择修改方法返回的是选择本身。这让我们可以继续链接修改方法,正如我们在 清单 14-14 中通过两个 .attr 调用所做的那样。

如果你想选择单个元素而不是一组元素,请使用 d3.select 方法,而不是 d3.selectAll。例如,要将 h1 元素插入到 HTML 的 body 元素中,你可以将 清单 14-15 中的代码添加到 script.js 文件的末尾。

`--snip--`
d3
  .select("body")
  .insert("h1", "svg")
  .text("Hello, D3!"); 

清单 14-15:使用 select 选择单个元素

在这个示例中,我们首先选择 body 元素。然后我们在此选择上调用 insert,传入两个参数,"h1" 和 "svg"。第一个参数是要插入的元素类型,第二个是要插入该元素之前的元素。insert 方法返回一个新的选择,包含插入的元素,text 方法则向该选择中的元素(在这个案例中是单个 h1 元素)添加文本内容。当你重新加载页面时,你应该会看到在 SVG 元素上方有一个标题,文本为 “Hello, D3!” 这个示例还说明了 D3 选择可以同时应用于 HTML 和 SVG 元素。

数据绑定

或许 D3 最重要的特性是它的数据绑定概念。在基于 D3 的应用程序中,你将有一些数据需要可视化。每个数据单元,称为 datum,将绑定到页面上的一个元素(通常是 SVG 元素)。你使用 datum 来设置绑定元素的某个属性,从而使元素在视觉上反映出该 datum。

首先,我们来看如何将数据绑定到现有的 SVG 元素。保留 index.html 中的圆圈,但将 script.js 的内容替换为 清单 14-16 中的代码。

let numbers = [3, 2, 1];

d3
  .selectAll("circle")
❶ .data(numbers)
  .attr("r", (d, i) => d * 5); 

清单 14-16:将数据绑定到我们的圆圈

我们首先创建一个数字数组用作数据。然后我们创建一个所有圆圈元素的选择。data 方法 ❶ 将数字数组一个个绑定到圆圈选择上,因此第一个圆圈元素绑定了值 3,第二个绑定了 2,第三个绑定了 1。最后,我们使用 attr 方法根据绑定的数据设置每个圆圈的半径为计算值。如你在前一节中所见,如果你使用函数而不是值来设置属性,该函数将被调用,以便为选择中的每个元素计算该值。函数的 d 参数对应于绑定到当前元素的 datum。

当你重新加载页面时,你应该看到三个从左到右逐渐变小的黑色圆圈。为了确认一切按预期工作,右键单击第一个圆圈并选择检查,以在元素面板中显示该元素。你应该看到它的 r 属性设置为 15,这正是我们从 d * 5 中期望的值,其中 d 为 3。

你还可以使用检查工具直接查看元素上设置的数据,这对于调试非常有帮助,尤其是在你的数据比简单的数字更复杂时。你所需要的只是对该元素的引用,这可以通过 Chrome 控制台轻松获取。再次右键点击第一个圆形并选择Inspect。你应该能看到类似于 Figure 14-8 的内容。

Figure 14-8: 在元素面板中选择圆形元素

在选中行的末尾,你应该看到文本== $0。这表明一个对圆形元素的引用存储在名为$0 的全局变量下。为了验证这一点,打开 JavaScript 控制台并输入$0:

**$0**
<circle cx="50" cy="50" r="15"></circle> 

控制台打印了你选中的圆形元素,表明$0 确实是该元素的引用。现在,你已经得到了这个引用,可以通过 data 属性查看绑定到它的数据:

**$0.__data__**
3 

这告诉你,圆形绑定到了值 3,这是我们数组中的第一个数字,正如我们预期的那样。$0 始终引用当前选中的元素,因此如果你右键点击并检查另一个圆形,再次在控制台输入$0.data 将会给出绑定到那个圆形的数据。

数据连接

你并不总是能准确知道数据的长度,因此始终准备好恰当数量的 SVG 元素来绑定到你的数据上是困难的。D3 通过join的概念解决了这个问题。在 D3 中,你使用 join 来添加或移除必要的元素,以匹配绑定的数据。

我们可以通过在 Listing 14-16 的基础上添加一个 join 来扩展我们的示例,以便根据数字数组的长度,动态地添加或移除 SVG 圆形元素。请按照 Listing 14-17 中的示例更新script.js文件。

❶ let numbers = [3, 2, 1, 2, 3];

d3
❷ .select("svg")
 .selectAll("circle")
 .data(numbers)
❸ .join("circle")
  .attr("r", (d, i) => d * 5); 

Listing 14-17: 加入额外的元素

在这里,我们创建了一个更长的数字数组❶。我们还添加了一行代码来选择 svg 元素❷,然后再选择其中的圆形元素。这是必要的,因为 D3 需要添加新的圆形元素,它需要知道将它们添加到哪个包含元素中。最后,我们调用了 join 方法❸。这个方法接受从选择中添加或移除元素的名称,以匹配数据。在这种情况下,我们是在说,如果 svg 元素中没有足够的圆形元素来匹配数据中的所有项,那么 D3 应该添加更多的元素(或者如果元素太多,D3 应该移除一些)。

如果重新加载页面,你会发现结果可能没有你预期的那样。所有新生成的圆形元素都出现在绘图区的左上角。这是因为这些新圆形元素没有设置 cxcy 属性,而最初在 index.html 中定义的三个圆形元素则有这些属性。为了解决这个问题,我们需要使用 D3 设置这两个属性,具体代码见 Listing 14-18。

let numbers = [3, 2, 1, 2, 3];

d3
 .select("svg")
 .selectAll("circle")
 .data(numbers)
 .join("circle")
  .attr("cx", (d, i) => (i + 1) * 50)
  .attr("cy", 50)
 .attr("r", (d, i) => d * 5); 

Listing 14-18: 设置 cx 和 cy 属性

cx 属性是基于数据的索引计算的。第一个元素应位于 50,第二个元素应位于 100,以此类推。计算公式 (i + 1) * 50 给出了正确的值。由于所有圆形在一条直线上,cy 属性只是一个常量值。现在当你重新加载页面时,你应该能看到五个圆形排成一行。

注意

如前所述,当元素过多时,你可以使用相同的 join 技术来移除它们。如果你将数字数组更改为仅包含两个元素并重新加载页面,你会看到只有两个圆形。

既然我们使用 D3 的 join 方法根据数据需要动态创建 SVG 元素,就没有必要在 HTML 文件中创建它们了。请按 Listing 14-19 所示修改 index.html,删除所有圆形元素,然后重新加载页面。

`--snip--`
 <body>
    <svg width="600" height="600"></svg>

 <script src="https://unpkg.com/d3@7.4.4/dist/d3.js"></script>
 <script src="script.js"></script>
 </body>
</html> 

Listing 14-19: 移除圆形元素

一切仍然应该正常工作,因为 join 方法会添加所需的所有圆形元素。请注意,script.js 中的 .selectAll("circle") 这一行仍然是必须的,尽管第一次调用时没有圆形元素可供选择,join 才能正确工作。

实时更新

如果基础数据发生变化,我们需要再次执行 join 来更新可视化效果。为此,我们将所有的数据绑定和 join 代码移到一个独立的函数中,这样就可以根据需要调用。我们可以通过向页面添加一些按钮来测试这个功能,这些按钮允许我们向数字数组的开头或末尾添加随机值,或从数组中删除数字。请按 Listing 14-20 所示更新 index.html

`--snip--`
 <body>
    <div>
      <button id="prepend">Prepend</button>
      <button id="append">Append</button>
      <button id="drop">Drop</button>
    </div>

 <svg width="600" height="600"></svg>
`--snip--` 

Listing 14-20: 向 index.html 添加按钮

重新加载页面后,你应该能看到页面顶部的三个新按钮。接下来,我们将把更新可视化效果的代码移到一个独立的函数中。请用 Listing 14-21 中的内容替换 script.js 中的代码。

let numbers = [3, 2, 1];

❶ function update(data) {
  d3
    .select("svg")
    .selectAll("circle")
  ❷ .data(data)
    .join("circle")
    .attr("cx", (d, i) => (i + 1) * 50)
    .attr("cy", 50)
 .attr("r", (d, i) => d * 5);
}

❸ update(numbers); 

Listing 14-21: 将更新代码移入独立函数

这里没有功能上的变化——我们只是创建了一个更新函数来为我们执行 SVG 更新❶,然后调用它❸。请注意,我们将数据(函数的参数)传递给了.data方法❷,而不是直接传递数字数组。

接下来,我们将添加处理按钮点击的代码,这些代码将会在数字数组的开头或末尾插入一个 1 到 5 之间的随机浮动数字,或者移除数组中的最后一个元素。将 Listing 14-22 中的代码添加到script.js的末尾。

`--snip--`
update(numbers);

❶ function getRandomNumber() {
  return 1 + Math.random() * 4;
}

❷ d3.select("#append").on("click", () => {
  numbers.push(getRandomNumber());
  update(numbers);
});

❸ d3.select("#prepend").on("click", () => {
  numbers.unshift(getRandomNumber());
  update(numbers);
});

❹ d3.select("#drop").on("click", () => {
  numbers.pop();
  update(numbers);
}); 

Listing 14-22: 按钮点击时的更新

首先,我们声明一个用于生成随机数的辅助函数❶,因为有两个地方需要这样做。然后我们声明三个按钮的事件处理器。请注意,我们没有像本书之前所做的那样使用常规的 DOM API 方法来添加点击事件处理器,而是使用d3.select来选择按钮,并使用on方法添加事件处理器。常规的 DOM API 方法也能工作,但使用 D3 方法更简洁,并且与此文件中的其他 D3 代码更加一致。

第一个事件处理器通过点击“Append”按钮触发❷:它将一个随机数推入数字数组的末尾,然后我们调用更新函数重新绘制可视化,添加一个额外的圆圈。第二个事件处理器通过点击“Prepend”按钮触发,它会将一个随机数插入到数字数组的开头❸。第三个事件处理器通过点击“Drop”按钮触发;它从数组中弹出最后一个数字❹。在每个操作之后,我们也会调用更新函数。

重新加载页面并尝试不同的按钮。你应该会看到元素根据需要被添加和移除。

过渡与关键函数

不同于每次数据变化时突然更新 D3 可视化,你可以使用过渡来允许元素在变化时动画其属性。过渡是 D3 中的一个有用特性,因为如果做得正确,它们可以让你看到数据是如何演变的。让我们在更新函数中添加一个过渡,看看它是如何工作的。做出 Listing 14-23 所示的更改。

`--snip--`
function update(data) {
 d3
 .select("svg")
 .selectAll("circle")
 .data(data)
    .join("circle")
    .transition()
    .duration(500)
 .attr("cx", (d, i) => (i + 1) * 50)
 .attr("cy", 50)
 .attr("r", (d, i) => d * 5);
}
`--snip--` 

Listing 14-23: 添加过渡

在这样的链式调用中,transition方法意味着每个后续的属性变化都会从当前值动画过渡到新值。duration方法设置动画的时长(以毫秒为单位)。这意味着每个圆圈的位置和半径将花费半秒(500 毫秒)时间,从当前值过渡到新值。新的圆圈从每个属性的默认值 0 开始,因此它们会从 SVG 的左上角过渡进来。

不幸的是,由于我们编写的更新函数,动画的效果可能不会像你想要的那样令人满意。重新加载页面并点击Prepend几次。你应该会看到一些奇怪的行为。你可能期待现有的圆圈向右移动,为左侧添加的新圆圈腾出空间。但实际上,现有的圆圈似乎都在原地调整大小,而一个新的圆圈从左上角飞入,并出现在现有圆圈的右侧。通过这种动画,其实很难看出元素是被插入到左边的。相反,动画给人一种元素是被附加到右边的感觉,并且所有元素都在调整大小。而点击 Append 按钮则会做正确的事情:一个新元素会动画显示并出现在行的末尾,而现有元素没有变化。

这里的问题是,当 D3 使用新的数据数组更新现有选择时,它使用一种称为按索引连接的默认模式。这意味着数组中的第一个项与选择中的第一个元素连接(在本例中是最左边的圆圈),数组中的第二个项与选择中的第二个元素连接,依此类推。如果数组中的项比现有的 SVG 元素多,新的元素会被添加到末尾。因此,当你点击 Prepend 并将新数字添加到数据数组的开始时,行中的每个圆圈都会重新绑定到一个新的数据项。行中的第一个圆圈会绑定到添加到数组开头的新数字,因此它会看起来被调整大小。第二个圆圈会绑定到原来数组中的第一个数字,因此它也会看起来被调整大小,依此类推。最后,由于现在的数据项比 SVG 元素多一个,新的圆圈会在行的末尾创建并添加。

使动画更加直观的解决方案是帮助 D3 理解数据数组中每个元素的身份。我们不再假设数组中的每个索引总是与选择中的相同索引对应,而是提供 D3 所称的关键函数。关键函数使我们能够指定每个数据项的某些特征,以唯一标识它。这样,即使新数据被添加,现有数据仍然绑定到相同的 SVG 元素,而不管数据的顺序如何。

关键函数作为可选的第二个参数传递给数据方法。列表 14-24 展示了更新函数所需的更改。

`--snip--`
 .selectAll("circle")
  .data(data, d => d)
 .join("circle")
`--snip--` 

列表 14-24: 添加关键函数

这里的关键函数 d => d 表示给定一个数据项时,数据项本身就是唯一标识符。在这个例子中,我们只是使用原始数字,所以数字的值就相当于我们能得到的“唯一”标识符。通常,你会处理更复杂的数据,并且可以使用键函数来暴露一个实际上是唯一的标识符。例如,如果每个数据项都是一个表示员工的对象,并且每个员工有一个唯一的 employeeId 属性,那么你可以使用像 d => d.employeeId 这样的键函数。

重新加载页面并点击 Prepend。你应该会看到所有圆形元素向右滑动,以容纳新加入的元素。这是因为 D3 现在知道当数组发生变化时,新的数组项应该映射到选择中的哪个元素。

高级连接

D3 的 join 方法提供了额外的选项,让你可以更好地控制可视化在数据变化时的响应。当 D3 将新数据与现有选择进行连接时,一些元素可能会被更新,一些可能会被添加(对于没有现有元素的新数据项),而一些元素可能会被删除。在我们的案例中,我们看到点击 Prepend 不仅会添加新元素,还会通过将其他元素向右移动来更新所有元素。与此同时,点击 Drop 会删除最后一个元素。在 D3 的术语中,添加新元素叫做 enter,删除现有元素叫做 exit,而修改现有元素叫做 update

你可以通过传递三个函数来定制 join 方法,这些函数会针对这三种可能的元素变化进行调用。这样,你就能指定三种不同的行为:一种是进入元素时的行为,一种是更新元素时的行为,另一种是退出元素时的行为。要测试这个功能,请按照 清单 14-25 中所示的方式修改你的 update 函数。开始时,这些变化会产生与前一个清单中的简单 join 方法相同的行为。

`--snip--`
 .data(data, d => d)
  .join(
    enter => enter.append("circle"),
    update => update,
    exit => exit.remove()
  )
 .transition()
`--snip--` 

清单 14-25:带有 enter、update 和 exit 函数的 join 方法

这个更高级的 join 方法版本需要传入三个函数。第一个函数有一个名为 enter 的参数,这是一个临时占位符的选择集,用于表示每个进入的元素。为了获得与简单的 .join("circle") 版本相同的行为,我们只需使用 append 方法将一个圆形元素添加到每个 enter 占位符中。请注意,enter 占位符本身不是 DOM 中的元素。它们只是 D3 给你提供一个位置来附加新进入的元素,直到它们被添加到 DOM 中。例如,如果有五个新的元素需要进入,那么 enter.append("circle") 会创建五个新的圆形元素并将它们放入 svg 元素中。

第二个函数有一个名为 update 的单一参数,它是一个包含所有已绑定数据项的现有元素的选择集。为了得到与之前相同的行为,我们只需返回未更改的选择集。

第三个函数有一个名为 exit 的单一参数,它是一个包含所有应该被移除的元素的选择集,因为这些元素不再有对应的数据项。为了得到与之前相同的行为,我们在选择集上调用 remove 方法,它会将每个退出的元素从 DOM 中移除。

当你重新加载页面时,你应该看到与之前相同的行为;到目前为止,这个变化不会对功能产生任何影响。不过,既然我们已经让它正常工作了,我们可以重新设计动画,增加一些细节。目前,更新选择中的元素的右移动画是可以的,但进入的元素现在是从左上角飞入,而退出的元素只是消失了。我们不妨改成这样:让进入的元素从正确的位置增长到位,而退出的元素则从当前位置缩小至消失。实现这种行为的更改可以参见 Listing 14-26。

`--snip--`
function update(data) {
 d3
 .select("svg")
 .selectAll("circle")
 .data(data, d => d)
 .join(
      enter => enter
      ❶ .append("circle")
        .attr("cx", (d, i) => (i + 1) * 50)
        .attr("cy", 50)
        .transition()
        .duration(500)
      ❷ .attr("r", (d, i) => d * 5),
      update => update
        .transition()
        .duration(500)
      ❸ .attr("cx", (d, i) => (i + 1) * 50),
      exit => exit
        .transition()
        .duration(500)
      ❹ .attr("r", 0)
      ❺ .remove()
    );
}

update(numbers); 
`--snip--` 

Listing 14-26: 细化动画

在这段更新的代码中,我们将所有过渡都移到了各自的 enter、update 和 exit 函数中,而不是为所有元素调用一个单一的 transition 方法。enter 函数首先添加一个圆形元素❶,然后立即设置它的位置(即 cx 和 cy 属性),但不设置半径。一旦位置设置好,我们使用 transition 方法将半径从零(默认值)动画过渡到根据数据项计算的值❷。这里的顺序非常重要:任何在调用 transition 之前的操作会立即发生,而在调用 transition 之后的操作会进行动画过渡。这意味着任何新的圆形元素会立即出现在正确的位置,且大小的变化(从零到期望的半径)会进行动画过渡。这比之前的版本看起来更自然,后者是所有三个属性从零动画过渡过来,导致圆形元素从角落飞入。

update 函数只需要对圆形元素❸的 cx 属性进行动画,以将其滑动到更新后的位置。其他属性对于现有元素应该保持不变。最后,exit 函数将圆形的半径动画过渡回零❹,然后再将其移除❺。如果在调用 transition 后调用 remove 方法,如这里所示,那么实际的元素移除将在动画完成后进行。

当你重新加载页面时,你应该看到新的改进动画:新元素在合适的位置扩展,移除的元素则缩小消失。

创建条形图

现在你已经学习了 D3 的基础知识,让我们在一个小项目中应用它们:创建一个条形图,展示文本框中字符的频率。每当输入或粘贴新的文本时,条形图都会更新。创建这个可视化图表将帮助你练习数据连接,教你一些新技术,比如绘制坐标轴来为数据提供上下文,同时为下章的更大项目做好准备。

设置

首先,创建一个名为 frequency 的新目录,并添加空的 script.jsstyle.css 文件。然后创建一个 index.html 文件,并将 清单 14-27 中的代码添加进去。

<!DOCTYPE html>
<html>
  <head>
    <title>Character Frequency</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <div>
    ❶ <textarea rows="5" cols="70"></textarea>
    </div>
 <script src="https://unpkg.com/d3@7.4.4/dist/d3.js"></script>
    <script src="script.js"></script>
  </body>
</html> 

清单 14-27:字符频率项目的 index.html 文件

这个 HTML 文档遵循了本书中一直使用的模式。唯一新增的元素是 textarea 元素 ❶,它创建了一个多行文本输入框。rows 和 cols 属性设置了文本区域的行数和宽度(以固定宽度字符为单位)。

请注意,文档中没有 svg 元素。我们将通过 JavaScript 来创建它。这是因为我们需要多次引用 svg 元素的宽度和高度来确定可视化中元素的位置,因此在 JavaScript 中定义这些参数比在 HTML 文件中定义更为合理。由于我们将定义宽度和高度在 JavaScript 中,因此也可以直接在 JavaScript 中创建 svg 元素。我们现在就来做这件事。将 清单 14-28 中的代码添加到 script.js 中。

const width = 600;
const height = 600;

// Add an svg element to the page
let svg = d3
  .select("body")
  .append("svg")
  .attr("width", width)
  .attr("height", height); 

清单 14-28:使用 JavaScript 创建 svg 元素

我们首先声明常量来表示 svg 元素的宽度和高度。然后,使用 D3 选择 body 元素,并将一个 svg 元素添加到其中,同时设置宽度和高度属性。我们将创建元素的结果保存到变量 svg 中,因为之后我们还会用到它。

计算字符频率

接下来,我们将添加读取文本区域中内容并计算每个字符出现次数的代码。这将生成可视化所需的底层数据。每次文本更改时,我们都需要更新数据并重新绘制图表。不过现在,我们只需要读取文本,计算字符频率,并将结果输出到控制台。将 清单 14-29 中的代码添加到 script.js 的末尾。

`--snip--`
d3.select("textarea").on(❶ "input", e => {
❷ let frequencies = {};

❸ e.target.value.split(" ").forEach(char => {
    let currentCount = frequencies[char] || 0;
 frequencies[char] = currentCount + 1;
  });

  console.log(frequencies);
}); 

清单 14-29:计算字符频率

输入事件 ❶ 在文本区域内容发生变化时触发,无论是打字、删除、粘贴,还是其他操作。在事件处理函数的第一步,我们初始化一个新对象来跟踪字符频率 ❷。这个频率对象将字符作为键,字符出现的次数作为值。接着,我们获取事件的目标(文本区域),获取其值(文本内容),并将其拆分为单独的字符 ❸。对于每个字符,我们确定该字符的当前计数,如果该字符之前没有出现过,则默认为 0。然后我们将计数加 1,并将新的计数值存储回对象中。所有字符计数完成后,我们将频率对象记录到控制台,以便检查一切是否按预期工作。请注意,我们每次文本内容发生变化时都会重新计算频率对象,而不是仅仅尝试跟踪添加或删除的字符。这使得处理多个字符同时添加或删除的情况变得更加容易,例如当文本被粘贴到文本框中时。

加载页面后,你应该能看到文本区域(svg 元素不可见,但如果想检查它是否存在,可以在“元素”面板中查看)。当你在文本区域中输入文字时,应该能看到每次键入时都会将对象记录到控制台,每次记录的对象都包含文本区域中字符的频率。例如,如果你输入单词hello,在键入最后一个o之后,你将得到如下对象:

{"h": 1, "e": 1, "l": 2, "o": 1}

一个包含所有字符及其频率的单一对象对于控制台日志输出非常有效,但我们要在 D3 渲染时使用的是一个对象数组,每个对象描述一个字符及其相关的频率。这样,数组中的每一项就会是绑定到柱状图中一根柱子的单一数据项。为了让图表更易于阅读,数组应该按字符字母顺序排序。继续使用单词hello,我们需要类似下面的内容,而不是之前显示的对象:

[
  {"char": "e", "count": 1},
  {"char": "h", "count": 1},
  {"char": "l", "count": 2},
  {"char": "o", "count": 1}
] 

为了将数据转换为这种数组格式,请按照列表 14-30 中所示修改script.js的结尾部分。

`--snip--`
 frequencies[char] = currentCount + 1;
 });

❶ let data = Object.entries(frequencies).map(pair => {
    return {char: pair[0], count: pair[1]};
  });

❷ data.sort((a, b) => d3.ascending(a.char, b.char));

  console.log(data);
}); 

列表 14-30:将频率数据转换为数组

首先,我们使用 Object.entries 将频率对象转换为一个包含两个元素数组的数组 ❶,其中第一个元素是键,第二个元素是值。我们将这个数组映射为一个对象数组,其中键存储在 char 属性下,值存储在 count 属性下。接下来,我们希望按字符排序数据。sort 方法 ❷ 通过对每一对元素 a 和 b 应用比较函数,确定 a 是否应该排在 b 后面,反之亦然,从而对数组中的元素进行排序。在这里,我们使用 d3.ascending 比较函数,传入 a.charb.char,意味着数组会根据每个对象的 char 属性按字母升序排序。

刷新页面后,您应该能看到文本区域中的文本变化时,这个新的数据数组作为日志被记录。

绘制条形图

现在我们已经将数据转换为所需的格式,可以将其渲染为条形图。我们暂时从一个基本的、简单的渲染开始,即简单地创建与字符频率成比例的宽度的 SVG rect 元素。然后我们将逐步改进,创建一个更具信息量的可视化。请按照清单 14-31 中的更改,修改 script.js

`--snip--`
// Add an svg element to the page
let svg = d3
 .select("body")
 .append("svg")
 .attr("width", width)
 .attr("height", height);

❶ function update(data) {
  svg
    .selectAll("rect")
    .data(data)
  ❷ .join("rect")
    .attr("width", (d, i) => d.count * 5)
    .attr("height", 10)
    .attr("x", 20)
 .attr("y", (d, i) => i * 20);
}

d3.select("textarea").on("input", e => {
--snip--

 data.sort((a, b) => d3.ascending(a.char, b.char));

❸ update(data);
}); 

清单 14-31:定义更新函数

在这里,我们声明了一个更新函数 ❶,每当文本发生变化 ❸ 时,该函数都会被调用。该函数根据我们之前学到的模式创建、更新或删除渲染数据所需的 SVG 元素,使用 data 方法将数据绑定到选择项,并使用简单版本的 join 方法连接必要的元素。

join 方法返回一个包含所有当前 rect 元素的选择集 ❷,包括刚刚添加的元素。现在,每个 rect 都与一个数据项绑定,该数据项表示单个字符及该字符的出现次数。我们设置了适当的 widthheightxy 属性,以创建一个水平的条形图。width 属性设置为字符计数的 5 倍,因此每新增一个字符,条形图的宽度就会增加 5 像素。height 属性设置为常数值 10(所有条形图的高度相同),x 属性设置为常数值 20(所有条形图都从 SVG 元素左侧的 20 像素处开始)。y 属性设置为数据项索引的 20 倍,这意味着每 20 像素就会出现一根条形图,条形图之间的间隔为 10 像素。

刷新页面并在文本区域输入单词 hello。当您输入每个字母时,您应该看到条形出现在 SVG 元素中,或在更新后显示类似于图 14-9 的内容。

图 14-9:基本条形图

到目前为止,一切顺利,但我们仍然有一些问题要解决。这里有两个主要问题。首先,没有坐标轴或标签,我们不知道每个条形代表什么字符,也不知道条形的宽度对应什么。其次,条形的宽度和高度没有自动缩放,这意味着目前我们最多只能有 30 个不同的字符,每个字符的计数是 116,超过这个限制条形就无法在 600×600 像素的 SVG 元素中显示。幸运的是,这两个问题都可以通过使用 D3 轻松解决。

缩放条形

在 D3 中,scale(比例尺)是将某些数据值转换为视觉值的一种方式。例如,之前我们将字符频率图中条形的宽度设置为数据值的五倍,这是一种简单的缩放方式。在这种情况下,我们手动设置了缩放因子,但 D3 也可以根据数据值的最小值和最大值(即domain,区间)以及显示值的最小值和最大值(即range,范围)自动确定缩放。

例如,假设你在绘制一个人的年龄图表。你的数据值范围是从 0 到 105,而渲染这些值的空间范围是从 SVG 的左侧起 30 到 330 像素。因此,你的区间是[0, 105],你的范围是[30, 330]。数据区间中的 0 值映射到视觉范围中的 30,而 105 值映射到 330。请参见图 14-10 以获取这种映射的视觉表示。

图 14-10:将[0, 105]区间的值缩放到[30, 330]的范围

D3 缩放的美妙之处在于,缩放因子可以根据区间的变化动态变化。这样,当前的最大数据值总是能够映射到完整的视觉范围,即使最大数据值发生了变化。为了实现这种动态缩放,我们需要跟踪所有数据中的最大计数值,并使用它作为区间的上限。因此,如果最大计数值增加,未达到最大计数的条形会相应地缩小,而最大计数的条形将继续占据完整的水平范围。例如,假设我们条形的视觉范围是[0, 500],并且我们有以下数据:

[
  {"char": "a", "count": 1},
  {"char": "b", "count": 1},
  {"char": "c", "count": 2}
] 

我们数据的区间是[0, 2]。 "c"条的宽度为 500 单位,"a"和"b"条的宽度各为 250 单位。如果我们再添加两个"c"条,"c"条的宽度仍为 500 单位,但现在"a"和"b"条的宽度将各自缩小为 125 单位。

现在让我们实现动态水平缩放。修改你的脚本,参考清单 14-32 中的代码。

`--snip--`
// Add an svg element to the page
let svg = d3
 .select("body")
 .append("svg")
 .attr("width", width)
 .attr("height", height);

let margin = {top: 20, right: 10, bottom: 20, left: 50};

function update(data) {
❶ let xScale = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.count)])
    .range([margin.left, width - margin.right]);

 svg
 .selectAll("rect")
 .data(data)
 .join("rect")
  ❷ .attr("width", (d, i) => xScale(d.count) - xScale(0))
 .attr("height", 10)
  ❸ .attr("x", xScale(0))
 .attr("y", (d, i) => i * 20);
}
`--snip--` 

清单 14-32:为条形宽度创建比例尺

首先,在更新函数定义之前,我们创建一个对象,描述我们的条形图图表的边距。这些值表示图表主体距离 SVG 元素边缘的距离。如图 14-11 所示,当时机到来时,我们将使用这些边距来确定绘制条形图和坐标轴的位置。

图 14-11: 边距如何用来定位 SVG 元素中的图表(虚线)

在更新函数内部,我们使用 d3.scaleLinear 方法创建一个比例尺❶。这意味着输入值线性映射到输出值(例如,不是对数映射)。我们将数据领域设置为从 0 到最大计数,使用 D3 的 max 助手。这个助手接受一个数据数组和一个返回数据项值的函数,然后返回最大值。在这里,它返回的是最大计数值。范围设置为从 margin.left 到 width - margin.right,给出最长条形图右侧的位置。

scaleLinear 助手提供了一个函数,它将数据领域映射到视觉范围,我们将其分配给变量 xScale。(如第五章所讨论,高阶函数可以返回另一个函数,scaleLinear 就是这样做的。)我们修改宽度属性设置,调用 xScale 函数,并传入每个数据项的计数值❷。在这里,xScale(0)给出条形图左侧的水平位置,对应于数据领域值 0,xScale(d.count)给出条形图右侧的水平位置。为了得到条形图的宽度,我们需要从 xScale(d.count)中减去 xScale(0),因为宽度只是条形图左侧和右侧之间的距离。这将根据每个数据项的计数值和最大计数值,给出适当的条形图宽度。我们将条形图的 x 属性设置为 xScale(0),以确保左边距❸。

重新加载页面并开始在文本区域输入内容。当你第一次输入字符时,单个条形图会出现,宽度最大。试着在文本区域输入abccc;你会看到,随着更多 c 的添加,第一个和第二个条形图(对应 a 和 b)会变小。

现在让我们为条形图的高度创建一个比例尺,以充分利用 svg 元素的垂直空间。条形图一开始会很高,但随着新字符的添加,它会变得越来越矮,以容纳更多的条形图。请根据清单 14-33 进行更改。

`--snip--`
function update(data) {
 let xScale = d3.scaleLinear()
 .domain([0, d3.max(data, d => d.count)])
 .range([margin.left, width - margin.right]);

❶ let yScale = d3.scaleBand()
    .domain(data.map(d => d.char))
    .range([margin.top, height - margin.bottom])
    .padding(0.5);

 svg
 .selectAll("rect")
 .data(data)
 .join("rect")
 .attr("width", (d, i) => xScale(d.count) - xScale(0))
  ❷ .attr("height", yScale.bandwidth())
 .attr("x", xScale(0))
  ❸ .attr("y", (d, i) => yScale(d.char));
}
`--snip--` 

清单 14-33: 缩放条形图的高度

为了创建条形的高度刻度,我们使用 d3.scaleBand 辅助函数❶。这让我们能够创建一组均匀分布的带状区域。此处的域略有不同,因为它不再是给定最小值和最大值的数组,而是包含了所有值的完整集合。例如,如果文本区域的内容是单词hello,那么 y 轴刻度的域将是["e", "h", "l", "o"](记住我们按照字母顺序对数据进行排序)。这将映射到四个均匀分布的条形。

此处的范围从 margin.top 到 height - margin.bottom,这给出了条形将存在的 y 值范围(第一个条形会在顶部,最后一个条形会在底部)。padding 值定义了条形之间的间隔大小,基于可用空间:0 意味着它们尽可能高并且会接触在一起,而 0.5 意味着条形将占据一半的可用空间。

使用 scaleBand 创建的刻度还具有一个 bandwidth 方法,它返回带状区域的缩放大小,我们可以用它来设置条形的高度❷。(该方法称为 bandwidth,假设条形是垂直方向的,而我们的条形是水平方向的。)为了获取条形的 y 属性,我们将 d.char 传递给 yScale 函数❸,因为该刻度的域是数据中存在的所有字符。

重新加载页面并在文本区域中输入一些文本。你输入的第一个字符会导致一个高大的黑色条形出现,但对于每个唯一字符,你输入的新条形会被添加,现有条形的高度将减少以腾出空间。图 14-12 展示了这个可视化效果应该是什么样的。

图 14-12:我们的条形图在两个维度上进行了缩放

尝试输入不同的文本,感受数据变化时条形如何更新。接下来,我们将添加带标签的坐标轴,随着缩放的变化,这些标签也会更新,以显示数据的实际范围。这样会更容易理解图表。

添加带标签的坐标轴

D3 的坐标轴辅助工具可以让你在图表的边缘绘制坐标轴。D3 中的坐标轴包括一条水平或垂直的线,并且在线的垂直方向上绘制小的刻度线和每个刻度的值,正如图 14-13 所示。坐标轴让你能够看到数据域中的值。

图 14-13:数字 0 到 8 的坐标轴

坐标轴与刻度紧密相关,实际上你需要一个刻度来创建一个坐标轴。例如,图 14-13 中的坐标轴宽度为 540 像素,包含从 0 到 8 的数字。这个坐标轴是通过一个范围为[0, 8]、值域为[0, 540]的刻度创建的。

要绘制坐标轴,首先需要定义一个 g 元素来容纳坐标轴元素。然后,使用 D3 的坐标轴辅助函数之一创建一个坐标轴生成器对象,最后使用生成器对象将坐标轴元素绘制到 g 元素中。

我们的图表将有两个坐标轴:顶部坐标轴用于显示计数值,左侧坐标轴用于显示字符值。首先,我们将添加 g 元素,如 Listing 14-34 所示。

`--snip--`
let margin = {top: 20, right: 10, bottom: 20, left: 50};

// Top axis container
❶ let topContainer = svg
  .append("g")
  .attr("id", "top")
  .attr("transform", ❷ `translate(0, ${margin.top})`);

// Left axis container
❸ let leftContainer = svg
  .append("g")
  .attr("id", "left")
  .attr("transform", ❹ `translate(${margin.left}, 0)`);

function update(data) {
`--snip--` 

Listing 14-34: 添加 g 元素来容纳顶部和左侧坐标轴

我们通过将 g 元素附加到 svg 元素并给它一个 id 为"top"来创建顶部坐标轴容器❶。因为我们将 xScale 的范围定义为[margin.left, width - margin.right],这也会定义坐标轴的视觉范围。然而,xScale 并不知道垂直定位,因此我们需要将其平移到 margin.top ❷的位置。我们将元素选择存储在一个名为 topContainer 的变量中,以便在稍后绘制坐标轴时引用它。左侧坐标轴容器的创建类似❸,但这次我们需要将其平移到右边,即 margin.left ❹,因为 yScale 并不知道水平定位。

现在我们已经有了容器,可以开始绘制坐标轴。请根据 Listing 14-35 中所示的更改更新函数。

`--snip--`
 let yScale = d3.scaleBand()
 .domain(data.map(d => d.char))
 .range([margin.top, height - margin.bottom])
 .padding(0.5);

  let topAxis = d3.axisTop(xScale);

  let leftAxis = d3.axisLeft(yScale);

  topContainer
    .call(topAxis);

  leftContainer
    .call(leftAxis);

  svg
 .selectAll("rect")
 .data(data)
 .join("rect")
`--snip--` 

Listing 14-35: 绘制坐标轴

在这里,我们调用 d3.axisTop,传递 xScale,以及 d3.axisLeft,传递 yScale。这为我们提供了两个坐标轴生成器,topAxis 和 leftAxis。坐标轴生成器接受元素的选择并将坐标轴绘制到该元素中。然而,我们并没有将选择传递给坐标轴生成器,而是将生成器本身传递给一个名为 call 的 D3 方法。当此方法与选择链式调用时(如此处的 topContainer 或 leftContainer),它会在当前选择上调用提供的函数。因此,编写 topContainer .call(topAxis);等同于编写 topAxis(topContainer);,两者都绘制条形图的顶部坐标轴。使用 call 更为惯用,这使得可以更容易地将其他方法链式调用到该语句中。

重新加载页面并在文本区域输入一些文本。你将看到坐标轴,如 Figure 14-14 所示。

Figure 14-14: 带有坐标轴的条形图

如果你在网页检查器中检查坐标轴,你会发现它们由 g、path、text 和 line 元素组成。line 元素类似于 path 元素,但它仅定义起始和结束点,通过 x1、x2、y1 和 y2 属性。这些属性在 SVG 规范中默认为 0,这通常可以很好地满足绘制坐标轴的需求,因此你会注意到在检查器中许多 line 属性并未显式设置。

现在,顶部轴有两个地方有点问题。首先,正如你在 Figure 14-14 中看到的,标签包括带有小数点的数字,比如 2.5,但我们只关心整数(你不能有半个字符)。所以,我们需要找到一种方法,仅渲染整数,也就是整数值。第二,如果你输入 15 个相同字符的字符串(例如,aaaaaaaaaaaaaaa),那么标签将只显示从 0 到 14 的偶数,并且不会有 15 的标签,如 Figure 14-15 所示。随着最大计数的增加,尤其是超过 30 时,你会继续看到这个问题,因为刻度会切换到 5 的倍数。

Figure 14-15: 最大计数为 15 时的顶部轴

我们在这里更希望的是,让领域扩展到 16,以便使轴看起来更美观。幸运的是,第二个问题很容易解决。D3 比例尺有一个很好的方法,可以将它们的领域扩展到下一个“圆整”数字,这在这种情况下意味着下一个会绘制刻度的数字。Listing 14-36 展示了如何实现这个方法。

`--snip--`
 let xScale = d3.scaleLinear()
 .domain([0, d3.max(data, d => d.count)])
    .range([margin.left, width - margin.right])
    .nice();
`--snip--` 

Listing 14-36: 使我们的 x 比例尺变得“美观”

当你重新加载页面并再次输入 15 个相同的字符时,你会看到轴现在扩展到了 16。仅渲染整数需要稍微多一些的努力。这里的基本方法是获取刻度值,将它们过滤为仅整数,然后将这些刻度值设置到轴上。此外,我们还希望更改数字渲染方式,排除小数点,因此我们渲染的是 1,而不是 1.0。 这些变化在 Listing 14-37 中显示。

`--snip--`
 let yScale = d3.scaleBand()
 .domain(data.map(d => d.char))
 .range([margin.top, height - margin.bottom])
 .padding(0.5);

❶ let topAxisTicks = xScale.ticks()
    .filter(tick => Number.isInteger(tick));

  let topAxis = d3.axisTop(xScale)
  ❷ .tickValues(topAxisTicks)
  ❸ .tickFormat(d3.format("d"));

 let leftAxis = d3.axisLeft(yScale); 
`--snip--` 

Listing 14-37: 渲染顶部轴上的整数刻度

首先,我们需要获取刻度值,它们可以通过 xScale 生成器上的 ticks 方法获取❶。然后,我们使用 Number.isInteger 过滤刻度,得到整数值。这样可以将类似[0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]的数组转换为[0, 1, 2, 3, 4]。接下来,我们使用 tickValues 方法将过滤后的刻度值设置到顶部轴上❷。最后,我们使用 tickFormat 方法设置数字的渲染格式❸。该方法接受一个格式化函数,用来格式化每个刻度值。在这种情况下,d3.format("d")返回一个格式化函数,格式化时不会带小数点。

重新加载页面并再次输入一些文本,你应该看到渲染的整数不带小数点。

使用 CSS 和正则表达式进行样式设置

接下来,我们将通过一些 CSS 样式改善图表的外观。为了更好地区分字符类型,我们将根据字符是小写字母、大写字母、数字还是其他字符来为条形图赋予不同的颜色。为此,我们需要一个能够区分这些字符类型的函数。该函数将使用正则表达式,它是一种在文本字符串中指定模式并判断其他字符串是否匹配这些模式的方式。

注意

JavaScript 的正则表达式功能非常强大,但我们仅会考虑本项目所需的功能。想要了解更多,可以访问网站 www.regular-expressions.info或在 MDN 上搜索 “regular expressions”。

JavaScript 有一种正则表达式字面量语法,由斜杠分隔。例如,/hi/ 是一个正则表达式字面量,匹配任何包含字符序列 hi 的字符串。hi 可以出现在字符串的任何位置。例如,正则表达式 /hi/ 会匹配单词 hitherChickensushi。你可以通过添加特殊字符来更精确地定义正则表达式的模式。例如,插入一个插入符号 (^) 表示字符序列应出现在字符串的开头,因此 /^hi/ 匹配任何以 hi 开头的字符串。类似地,插入一个美元符号 (\() 表示字符序列应出现在字符串的末尾,因此 /hi\)/ 匹配任何以 hi 结尾的字符串。

你可以使用正则表达式的 test 方法来查看某个特定的字符串是否与其匹配。以下是一些 JavaScript 控制台中的示例:

**/^hi/.test("hi there");**
true
**/****^hi/.test("Chicken");**
false 

字符串 "hi there" 通过了测试,因为 hi 出现在字符串的开头,而 "Chicken" 没有通过。

你可以同时使用 ^ 和 $ 来创建一个必须完全匹配字符串的正则表达式。例如,/^hi$/ 只会匹配字符串 "hi" 而不会匹配其他任何内容,如下所示:

**/****^hi$/.test("hi");**
true
**/^hi$/.test("him");**
false 

要匹配一系列字符而不是单个字符,可以使用方括号和连字符来描述范围。例如,/[a-z]/ 匹配任何从 az 的小写字符。正则表达式/[1][a-z]$/ 匹配一个包含大写字母后跟小写字母的字符串,并且不包含其他字符。在控制台中尝试一下:

**/^[A-Z][a-z]$/.test("Hi");**
true
**/^[A-Z][a-z]$/.test("iH");**
false
**/^[A-Z][a-z]$/.test("Hip");**
false 

对于这个项目,我们需要三个正则表达式:/[a-z]$/(匹配单个小写字母),/^[A-Z]$/(匹配单个大写字母),和/[0-9]$/(匹配单个数字)。如果一个字符不匹配任何一个表达式,我们就知道它是其他类型的字符,比如空格或标点符号。请参见清单 14-38,它展示了新的 getClass 函数,该函数使用这些正则表达式为给定字符的条形图选择 CSS 类名。将这个函数添加到script.js文件中,放在更新函数之前。

`--snip--`
// Left axis container
let leftContainer = svg
 .append("g")
 .attr("id", "left")
 .attr("transform", `translate(${margin.left}, 0)`);

function getClass(char) {
  if (/^[a-z]$/.test(char)) {
    return "lower";
  } else if (/^[A-Z]$/.test(char)) {
    return "upper";
  } else if (/^[0-9]$/.test(char)) {
    return "number";
  } else {
    return "other";
  }
}

function update(data) {
`--snip--` 

清单 14-38:getClass 函数

这个函数将字符与提供的正则表达式进行匹配,并返回相应的类名:"lower"(小写字母)、"upper"(大写字母)、"number"(数字)或"other"(其他)。接下来,我们将更新渲染代码,使用这个函数为每个 rect 元素设置类名,如清单 14-39 所示。

`--snip--`
 svg
 .selectAll("rect")
 .data(data)
 .join("rect")
 .attr("width", (d, i) => xScale(d.count) – xScale(0))
 .attr("height", yScale.bandwidth())
 .attr("x", xScale(0))
 .attr("y", (d, i) => yScale(d.char))
    .attr("class", (d, i) => getClass(d.char));
}
`--snip--` 

清单 14-39:根据字符应用类名

现在,每个 rect 元素都会根据该元素数据中的字符来设置类名。最后一步是编写 CSS,为每个类名分配不同的填充颜色。将清单 14-40 中的 CSS 代码添加到style.css文件中。

.lower {
  fill: purple;
}

.upper {
  fill: orangered;
}

.number {
  fill: green;
}

.other {
  fill: #555;
} 

清单 14-40:为不同类别定义样式

现在,当你重新加载页面并输入一些不同的字符时,你应该会看到类似于图 14-16 的效果。

图 14-16:颜色编码条形图

条形图应根据输入的字符类型分配不同的颜色。

清理数据

通常,在将数据集可视化之前,需要通过修正其中的错误或不规则性来清理数据。例如,目前条形图方法的一个问题是,不同的空白字符显示为不同的条形图,每个条形图都有一个不可见的标签(因为标签文本只是空白)。这些空白字符包括空格、换行符、制表符和其他可以通过不同键盘组合输入的空格字符(例如,在 macOS 上可以通过 OPTION+空格键输入不间断空格,Windows 上则是 CTRL-SHIFT+空格键)。为了解决这个问题,我们将在字符计数之前将所有空白字符转换为相同的""字符串,这样所有空白字符将通过一个带有可读标签的条形图进行可视化。按照清单 14-41 中所示更新你的script.js文件。这些更新出现在文件的末尾。

`--snip--`
function standardizeSpace(char) {
❶ if (char.trim() == " ") {
    return "<space>";
  } else {
    return char;
  }
}

d3.select("textarea").on("input", e => {
 let frequencies = {};

  e.target.value.split(" ").forEach(char => {
  ❷ let standardized = standardizeSpace(char);
    let currentCount = frequencies[standardized] || 0;
    frequencies[standardized] = currentCount + 1;
 });
`--snip--` 

清单 14-41:标准化空白字符

我们首先声明一个 standardizeSpace 函数,该函数接受一个字符并调用其 trim 方法❶。trim 方法会移除字符串开头或结尾的空白字符,因此,如果返回一个空字符串,我们知道该字符是空白字符。在这种情况下,我们返回字符串""。否则,我们返回未改变的字符。然后,我们需要修改文本处理代码,在将空白字符作为频率对象中的键使用之前调用我们的函数来标准化它们❷。

现在,当你在文本区域中输入各种类型的空白字符时,你应该会看到一个标签为的单一条形图,而不是多个带有空标签的条形图。

动画化变化

我们的最终任务是为坐标轴和条形图添加动画。这将使我们更容易看到何时添加新元素,以及现有元素的计数何时变化。为了动画化坐标轴,我们只需在更新函数内对 topContainer 和 leftContainer 选择添加一个过渡调用,如 Listing 14-42 所示。

`--snip--`
let leftAxis = d3.axisLeft(yScale);

topContainer
  .transition()
 .call(topAxis);

leftContainer
  .transition()
 .call(leftAxis);
`--snip--` 

Listing 14-42: 为坐标轴添加动画

现在,当坐标轴的域更新以适应新数据时,现有的刻度将动画化到其更新后的位置,新的刻度会逐渐显现。

我们有两种方法可以为条形图添加过渡效果:我们可以保留现有的连接代码,只需添加一个过渡调用,或者我们可以使用前面提到的高级连接技术,这样我们就可以根据元素是进入、更新还是退出来定制过渡效果。正如你可能猜到的,我们将选择高级版本!你可以在 Listing 14-43 中找到更新后的更新代码。

`--snip--`
 leftContainer
 .transition()
 .call(leftAxis);

 svg
 .selectAll("rect")
  ❶ .data(data, d => d.char)
    .join(
    ❷ enter => enter
        .append("rect")
        .attr("x", xScale(0))
        .attr("y", (d, i) => yScale(d.char))
        .attr("class", d => getClass(d.char))
        .transition()
        .attr("width", d => xScale(d.count) - xScale(0))
        .attr("height", yScale.bandwidth()),
    ❸ update => update
        .transition()
        .attr("width", d => xScale(d.count) - xScale(0))
 .attr("height", yScale.bandwidth())
        .attr("y", (d, i) => yScale(d.char)),
    ❹ exit => exit
        .transition()
        .attr("width", 0)
        .attr("height", 0)
        .remove()
    );
}
`--snip--` 

Listing 14-43: 动画化条形图

我们首先需要做的是设置一个关键函数❶,告诉 D3 数据项的 char 属性应该作为标识符来使用。接下来,我们切换到类似于 Listing 14-25 的高级连接技术。enter 函数❷首先添加 rect 元素,并在调用过渡之前设置其 x、y 和 class 属性,这意味着这些属性不会被动画化。宽度和高度属性是在过渡调用之后设置的,所以这些属性被动画化。这样,新元素将在左侧坐标轴处从当前位置生长出来。

update 函数❸再次动画化宽度和高度,同时还会动画化 y 属性。这意味着当新元素添加时,现有元素会向上或向下滑动到它们的新位置。

最后,退出函数❹在元素被移除之前将宽度和高度动画化到 0,导致元素在原位置缩小消失。

重新加载页面,尝试在文本区域中添加和删除字符。享受观察元素是如何进入、退出或更新的动画效果。

总结

在本章中,你学习了 SVG 的基础知识,以及如何使用 D3 根据数据集中的实时变化来创建、更新和移除 SVG 元素。到现在为止,你应该已经对如何在 D3 中构建基于数据的应用程序有了相当清晰的了解。在下一章,我们将通过构建一个从 API 读取数据并将其渲染成交互式图表的应用程序,将这些知识付诸实践。

第七章:15 从 GitHub 搜索 API 可视化数据

在这个最终项目中,你将构建一个应用程序,从公共 API 中读取数据,并使用 D3 基于这些数据构建一个交互式条形图。我们将从 GitHub 搜索 API 中读取数据。这个 API 允许你在 GitHub 上搜索数据,GitHub 是一个托管 Git 仓库的服务(Git 是一种流行的版本控制系统,用于跟踪软件项目的源代码)。该 API 使用 HTTPS 协议,并根据你编码到 URL 中的搜索查询返回 JSON 格式的数据。

如果你以前没有使用过 GitHub,可以访问https://github.com查看它的样子。在页面的顶部,你会看到一个搜索框,你可以用它来搜索公共的,开源代码库(即,源代码对任何人开放阅读和使用的代码库)。GitHub 搜索 API 允许我们以编程方式进行搜索——例如,使用 JavaScript,而不是手动使用该搜索框。该 API 可以搜索 GitHub 上的各种项目,如代码库、用户和问题。我们将使用代码库搜索功能来查找流行的 JavaScript 代码库。然后,我们将绘制一个 D3 条形图,根据流行程度对这些代码库进行排名。用户可以通过悬停在条形图上,了解每个代码库的更多信息。我们还将通过允许用户根据软件许可证来隐藏或显示代码库,增加一些互动性。

这个项目将让你获得使用 JSON API 中实际数据的经验。大量的编程工作都归结为向第三方 API 发出请求,然后对返回的数据进行处理,正如你在这里所实践的那样。你还将把你在第十四章中学到的关于 D3 的所有内容付诸实践,构建一个更有趣、互动性更强的图表,并学习一些技术,用于创建更丰富的可视化效果。

设置

要开始,创建一个名为 github 的新目录,并添加空的 style.cssscript.js 文件。然后创建一个 index.html 文件,并添加列表 15-1 中的代码。

<!DOCTYPE html>
<html>
  <head>
    <title>GitHub</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <script src="https://unpkg.com/d3@7.4.4/dist/d3.js"></script>
    <script src="script.js"></script>
  </body>
</html> 

列表 15-1:我们的 GitHub 搜索 API 可视化的 index.html 文件

这是我们在第十四章中使用的相同基本 HTML 文件。它通过一个脚本元素链接到 https://unpkg.com,使我们能够访问 D3 库。

获取数据

现在让我们尝试从 GitHub 搜索 API 获取一些数据。为此,我们需要将我们的数据请求格式化为 URL 的一部分。访问该 URL 即可检索数据。整个 URL(包括我们将使用的搜索查询)如下所示(请注意,为了适应页面,它已经被分成了两行):

https://api.github.com/search/repositories?q=language%3Ajavascript%20stars%3A
%3E10000&per_page=20&sort=stars 

然而,我们不会手动输入 URL,而是通过 JavaScript 来构建它,这样更容易理解和修改。

URL 有两个部分:一个是基础 URL,它让我们可以访问 API,另一个是查询字符串,指定我们想要的数据。这两部分由问号(?)分隔。查询字符串包含一对对的键值对,用于向 API 发送我们查询的信息。每个键和值通过等号(=)连接,每对键值对之间由和号(&)分隔。在这个 URL 中,键有 q(搜索查询)、per_page(每页的结果数量)和 sort(如何排序结果)。查询字符串中的键和值只能包含有限的字符集:a–z、A–Z、0–9、连字符(-)、句点(.)、下划线(_)、波浪号(~)和一些其他特殊字符。所有其他字符必须通过URL 编码系统表示,这就是 URL 中所有百分号(%)字符的来源。例如,冒号(:)编码为 %3A,空格编码为 %20。

为了简化操作,我们将编写一个函数,它接受一个包含未编码查询字符串参数的对象,并将其转换为正确格式化和编码的 URL。将 Listing 15-2 中的代码添加到 script.js 文件中。

function getUrl() {
❶ let baseUrl = "https://api.github.com/search/repositories";

❷ let params = {
    q: "language:javascript stars:>10000",
    per_page: 20,
    sort: "stars"
  };

❸ let queryString = Object.entries(params).map(pair => {
    return `${pair[0]}=${encodeURIComponent(pair[1])}`;
  }).join("&");

❹ return `${baseUrl}?${queryString}`;
}

let url = getUrl();

console.log(url); 

Listing 15-2: 创建 URL

创建 URL 的代码位于 getUrl 函数中。这个函数首先设置基础 URL(查询字符串之前的部分)❶。然后,为了构建查询字符串,我们通过创建一个 params 对象 ❷ 来开始,其中包含搜索查询 q,采用 GitHub 的搜索查询格式。具体来说,我们正在搜索语言为 JavaScript 的、星标数超过 10,000 的仓库(GitHub 用户可以为仓库添加“星标”,以便稍后查看,因此星标数是受欢迎程度的粗略衡量标准)。如果你想尝试这个查询,可以在 https://github.com 的搜索框中输入。

接下来,我们遍历 params 中的键值对,为每对键值对创建一个格式为 "key=value" 的字符串 ❸。键不需要进行 URL 编码——未加引号的对象键不包含特殊字符,因此它们已经是有效的 URL 组成部分——但我们使用内建函数 encodeURIComponent 来编码值,该函数将任何不允许的字符替换为其百分号编码版本。然后,我们将这些字符串连接起来,用 & 字符分隔,并通过将基础 URL、? 字符和查询字符串组合起来,构建并返回最终的 URL ❹。最后,我们通过调用 getUrl 函数并将结果输出到控制台来结束脚本。

当你加载页面并打开控制台时,你应该能看到之前显示的 URL。如果你复制那个 URL 并将其粘贴到浏览器的地址栏中,你应该能看到一堆 JSON 数据。如果没有,确保 URL 与上一页上的 URL 匹配,并检查你的代码是否有问题。如果 URL 看起来正确而你没有获取到数据,或者你收到了错误信息,可能是 GitHub 改变了其 API 的工作方式。有关此情况的处理方法,请参见下面的“认证 API 与非认证 API”部分。

要将 JSON 数据导入你的应用程序,你可以使用 D3 的 json 辅助方法,它从给定的 URL 获取 JSON。按照 Listing 15-3 中的示例更新 script.js 的结尾部分。

`--snip--`
let url = getUrl();

d3.json(url).then(data => {
  console.log(data);
}); 

Listing 15-3: 获取 JSON 数据

从 API 获取大量数据可能需要一点时间,因此 d3.json 方法返回一个 Promise,这是一种代表将来某个时间会得到的对象类型。then 方法接受一个函数,当数据准备好时会调用该函数。D3 将 JSON 响应字符串转换为 JavaScript 对象,因此 data 将是一个对象。在这里,我们只是将其日志记录到控制台。

当你重新加载页面后,等待几秒钟,你应该能在控制台看到数据。花点时间检查它。你应该能看到三个顶级属性:incomplete_results、items 和 total_count。如果查询耗时过长并且 API 只能返回部分结果,则 incomplete _results 属性为 true;否则,它为 false。total_count 属性提供此搜索查询的结果总数(这是搜索找到的所有结果的总数,其中只有前 20 条结果被返回)。items 数组包含当前调用的结果;它应该包含 20 条数据。每个条目是一个对象,包含关于特定仓库的一些信息,包括其名称、描述以及各种其他细节。几个字段本身就是 GitHub API URL,可以调用它们获取仓库的更多信息。例如,languages_url 是一个 API URL,它告诉你该仓库中使用了哪些编程语言,并按每种语言的代码行数进行细分。

在这个项目中,我们将使用每个条目的几个字段:full_name、stargazers_count、html_url 和 license。full_name 字段包含仓库所有者的名称和仓库名称,它们用斜杠连接:例如,“facebook/react”。stargazers_count 字段提供仓库被用户标星的次数。html_url 字段包含仓库在 GitHub 上的 URL。最后,license 字段提供有关仓库使用的哪个软件许可证的数据。

注意

开源代码的所有者使用软件许可证来告诉其他用户他们可以对代码做什么和不能做什么。例如,一些许可证非常严格,规定代码不能用于非开源代码的应用程序。其他许可证则更加宽松,允许你对代码做任何你想做的事情。

基本可视化

现在我们已经有了数据,我们将创建一个基本的条形图,显示数据集中每个仓库收到的星标数量。为此,我们将创建所需的 SVG 元素,绘制坐标轴,并绘制条形图本身。稍后我们将改进这个基本图表,使其更具信息性、更有风格并且更具交互性。

创建元素

要创建我们的图表,我们首先必须创建一个将容纳图表的 svg 元素,以及两个用于坐标轴的 g 元素。在这种情况下,坐标轴将在底部和左侧。将清单 15-4 中的代码添加到script.js的开头,位于 getUrl 函数之前。

const width = 600;
const height = 400;

let svg = d3
  .select("body")
❶ .append("svg")
  .attr("width", width)
  .attr("height", height);

❷ let margin = {top: 20, right: 10, bottom: 20, left: 50};

// Bottom axis container
let bottomContainer = svg
❸ .append("g")
  .attr("id", "bottom")
  .attr("transform", `translate(0, ${height - margin.bottom})`);

// Left axis container
let leftContainer = svg
❹ .append("g")
  .attr("id", "left")
  .attr("transform", `translate(${margin.left}, 0)`);

function getUrl() {
`--snip--` 

清单 15-4:设置元素

就像我们在第十四章中为字符频率图表所做的那样,我们将一个 svg 元素添加到页面 ❶ 并设置其宽度和高度。然后我们创建一个边距对象 ❷ 并添加用于容纳底部 ❸ 和左侧 ❹ 坐标轴的 g 元素,并根据边距进行定位。

绘制坐标轴

有了创建的元素,我们可以开始编写更新函数,它将绘制可视化图形。首先,我们将基于数据创建刻度并绘制坐标轴。请在script.js中做出清单 15-5 所示的更改。

`--snip--`
// Left axis container
let leftContainer = svg
 .append("g")
 .attr("id", "left")
 .attr("transform", `translate(${margin.left}, 0)`);

function update(items) {
❶ let xScale = d3.scaleBand()
    .domain(items.map(d => d.full_name))
    .range([margin.left, width - margin.right])
    .padding(0.3);

  let yScale = d3.scaleLinear()
  ❷ .domain([0, d3.max(items, d => d.stargazers_count)])
    .range([height - margin.bottom, margin.top])
    .nice();

❸ let bottomAxis = d3.axisBottom(xScale);
  let leftAxis = d3.axisLeft(yScale);

❹ bottomContainer.call(bottomAxis);
  leftContainer.call(leftAxis);
}

function getUrl() {
--snip--

d3.json(url).then(data => {
❺ update(data.items);
}); 

清单 15-5:绘制坐标轴

更新函数从 API 响应中获取 items 数组。我们的条形图将为每个仓库绘制一个垂直条形,因此我们使用 scaleBand 助手创建水平 xScale 来均匀地间隔这些条形 ❶。域是每个仓库的 full_name。每个仓库的全名是唯一的,因此这将导致 20 个带。垂直的 yScale 用于可视化每个仓库的星标数,因此它的域从零到最大的 stargazers_count ❷。我们在这里使用 nice 将刻度的顶部四舍五入到下一个刻度值。创建刻度之后,我们创建轴生成器 ❸,然后使用这些生成器将坐标轴绘制到容器 ❹ 中,正如我们在字符频率项目中所做的那样。

最后一步是从 d3.json 回调中调用我们的更新函数,并传递 items 数组❺。我们能够直接从获取数据到调用更新函数,因为 GitHub 搜索 API 方便地以我们需要的格式返回数据用于渲染。与字符频率示例中的数据处理不同,在那个示例中,源数据只是一个字符串,我们需要一个描述每个字符及其计数的排序对象数组。

当你重新加载 index.html 时,应该可以看到坐标轴,如 Figure 15-1 所示。我们稍后会修复底部轴标签;它们现在很乱,因为 D3 正在尝试渲染每个仓库的完整名称。此外,左轴的刻度值可能会超过图中显示的 200,000,具体取决于你运行此代码时最受欢迎的 JavaScript 项目的星标数。撰写本文时,facebook/react 是 GitHub 上最受欢迎的 JavaScript 项目,拥有大约 196,000 个星标。

Figure 15-1: 坐标轴

由于我们在这里处理的是如此大的数字(而且数字每天都在增大),我们可以通过使用 SI 前缀来提高可读性,比如 k 表示 1,000。这在 D3 中很容易做到,只需使用正确的数字格式。在进行此更改时,我们还将从底部轴中移除刻度。有关这些更改,请参见 Listing 15-6。

`--snip--`
 let yScale = d3.scaleLinear()
 .domain([0, d3.max(items, d => d.stargazers_count)])
 .range([height - margin.bottom, margin.top])
 .nice();

  let bottomAxis = d3
    .axisBottom(xScale)
  ❶ .tickValues([]);

  let leftAxis = d3
    .axisLeft(yScale)
  ❷ .tickFormat(d3.format("~s"));

 bottomContainer.call(bottomAxis);
 leftContainer.call(leftAxis);
`--snip--` 

Listing 15-6: 清理坐标轴刻度

对于底部轴,我们将刻度值更新为空列表❶,这样就有效地移除了刻度。对于左轴,我们添加了一个刻度格式,使用格式说明符 "~s" ❷,例如,它会将数字 200,000 渲染为 200k,1,000,000 渲染为 1M。Figure 15-2 显示了更新后的坐标轴应该是什么样子。

Figure 15-2: 清理后的坐标轴

左轴上的数字现在一目了然,底部的轴也不再是杂乱的文本。

绘制条形图

现在轴已经画好,我们需要绘制条形图本身。将 Listing 15-7 中的代码添加到更新函数的末尾。

`--snip--`
 bottomContainer.call(bottomAxis);
 leftContainer.call(leftAxis);

  svg
    .selectAll("rect")
    .data(items, ❶ d => d.full_name)
    .join("rect")
    .attr("x", d => xScale(d.full_name))
  ❷ .attr("y", d => yScale(d.stargazers_count))
    .attr("width", xScale.bandwidth())
  ❸ .attr("height", d => yScale(0) - yScale(d.stargazers_count));
}

function getUrl() {
`--snip--` 

Listing 15-7: 绘制条形图

和字符频率项目一样,我们正在绘制一堆 rect 元素。关键函数❶提取了 full_name 属性,我们在这里将其用作每个仓库的唯一标识符。目前,我们使用简单的 join 技巧,没有对进入、更新和退出元素进行单独处理(这些将在后面处理)。

x 属性是根据在 xScale 中查找 full_name 来设置的,宽度则基于 xScale 的带宽方法。y 和高度属性这次稍微复杂一些,需要一些解释。如果您回顾一下 清单 15-5 中 yScale 的定义,您会看到域是 [0, d3.max(items, d => d.stargazers_count)],范围是 [height - margin.bottom, margin.top]。通过我们设置的值,这个范围扩展为 [380, 20]。这个范围是从高到低的,意味着域中的高值会映射到范围中的低值,反之亦然。这是因为计算机图形中的 y 值是从屏幕顶部开始递减的,而在我们的图表中,我们希望 y 值是从图表底部开始递增的。让这变得复杂的另一件事是 SVG 矩形是从左上角开始绘制的,这对于每个条形图来说可能会有所不同,因此我们需要设置一个变量高度,使得所有的条形图都能触及到底部坐标轴。

因此,我们将条形图的 y 属性设置为 yScale(d.stargazers_count) ❷,它给出了条形图顶部的垂直位置。为了计算条形图的高度,我们使用 yScale(0) - yScale(d.stargazers_count) ❸。调用 yScale(0) 会给出条形图底部的垂直位置(所有条形图的基线应该位于 0 位置),所以从条形图底部到顶部的高度就是两个位置的差值。我们需要得到一个正的高度,因此必须将较小的数值从较大的数值中减去。尽管条形图的顶部在显示范围内是一个较小的数字,但它在数据域中是一个较大的数值。图 15-3 显示了条形图的应有样式,但请记住,根据数据的变化,您的条形图高度可能会有所不同。

图 15-3:将条形图绘制为矩形元素

在查看条形图时,请记住,每个条形图是从其左上角开始绘制的,并且高度是计算得当的,使得所有条形图的底部都对齐。

改进可视化效果

我们现在已经有了一个基本的可视化效果,但它并不太具有信息性。在本节中,我们将实现一些改进,使可视化效果更加有意义。我们将创建一种方法,查看每个仓库的更多信息。我们还将为条形图添加颜色编码,以显示每个仓库的许可证类型,并确保坐标轴正确标注。

显示仓库信息

当前的图表没有提供任何方式来识别每个条形图所代表的仓库。我们有多种方法可以解决这个问题(例如,垂直或对角线方向的刻度标签,或者某种提示框,即当鼠标悬停在条形图上时弹出的文本框),但在这个项目中,我们将添加一个永久的侧边栏,当你悬停在条形图上时,它会显示更多关于该条形图的信息。首先,我们将把侧边栏的 HTML 添加到index.html中,如清单 15-8 所示。

`--snip--`
 <body>
    <div id="sidebar">
      <div id="info" class="box">
        <p class="repo">
          <span class="label">Repository</span>
        ❶ <span class="value"><a target="_blank"></a></span>
        </p>
        <p class="license">
          <span class="label">License</span>
          <span class="value"></span>
        </p>
        <p class="stars">
          <span class="label">Stars</span>
          <span class="value"></span>
        </p>
      </div>
    </div>

 <script src="https://unpkg.com/d3@7.4.4/dist/d3.js"></script>
 <script src="script.js"></script>
 </body>
`--snip--` 

清单 15-8:将侧边栏 HTML 添加到 index.html

在这里,我们设置了一个名为 info 的 div,其中包含我们需要显示仓库信息的元素。它被嵌套在另一个名为 sidebar 的 div 中。这个外部 div 现在看起来可能是多余的,但稍后我们将向侧边栏添加另一个 div 元素,因此我们需要父 div 元素来包含这两个侧边栏 div 元素。

info div 会显示仓库名称、许可证类型以及其星标数。我们使用 span 元素来包装文本的一部分。span 是一个容器元素,像 div 元素一样,但与 div 不同,span 是一个内联元素,因此它可以在不换行的情况下包裹一行文本的某一部分。稍后,当你悬停在条形图上时,我们会更新值的 span 内容,显示关于该条形图的相关信息。

其中一个 span 元素包含一个 a 元素 ❶,它创建一个指向另一个页面或网站的超链接。链接的 URL 是通过 href 属性指定的,我们稍后会动态设置它。target="_blank" 属性指示浏览器在新标签页或窗口中打开链接。

侧边栏在此阶段看起来有点丑,所以让我们添加一些 CSS。将清单 15-9 中的代码添加到style.css中。

body {
❶ display: flex;
  align-items: flex-start;
  font-family: Arial, Helvetica, sans-serif;
}

.box {
  padding: 0px 15px 0px 15px;
  border: 1px solid #888;
  margin-bottom: 15px;
}

#info .label {
  font-weight: bold;
  display: block;
}

#info a {
  text-decoration: none;
}

#info a:hover {
  text-decoration: underline;
} 

清单 15-9:为侧边栏添加样式

在这个项目中,我们使用了一种叫做flexbox ❶的 CSS 技术,这是 CSS 规范中较新的一个特性。Flexbox 使得定义布局变得更加容易,特别是那些能够在各种屏幕和视口尺寸上灵活工作的布局。Flexbox 布局有两个主要组成部分:flex 容器flex 项目。flex 容器是一个父元素,它定义了其子 flex 项目(容器的直接子元素)的尺寸和排列方式。在我们的例子中,flex 容器是 body 元素,而 flex 项目是 svg 元素和 #sidebar 元素。默认情况下,项目从左到右排列(这意味着 #sidebar 元素会出现在屏幕的左侧,接着是右侧的 svg 元素)。声明 align-items: flex-start; 表示这些项目将对齐到父容器的顶部。

注意

如果你想了解更多关于 flexbox 的内容,请查看 css-tricks.com/snippets/css/a-guide-to-flexbox/

当前,svg 元素被附加到 body 元素上,这意味着它位于侧边栏之后,但出于布局原因,我们希望它位于侧边栏之前。为此,我们需要在创建 svg 元素时,从 append 方法切换到 insert 方法,因为后者允许我们指定一个元素插入在其前面。实现这一点的 script.js 代码更改显示在 Listing 15-10 中。

`--snip--`
let svg = d3
 .select("body")
  .insert("svg", "#sidebar")
 .attr("width", width)
 .attr("height", height);
`--snip--` 

Listing 15-10: 在侧边栏之前插入 svg 元素

现在,侧边栏将出现在图表的右侧,因为 svg 元素现在出现在 flex 容器中的侧边栏之前。

在编写用于显示仓库详细信息的代码之前,我们需要一个用于获取仓库许可证名称的函数。访问其他信息将是直接的,但并非所有仓库都有许可证,因此我们的函数必须处理没有许可证数据的情况。新的 getLicense 函数见 Listing 15-11,你可以将其插入到 script.js 中,紧接在 update 函数之前。

`--snip--`
// Left axis container
let leftContainer = svg
 .append("g")
 .attr("id", "left")
 .attr("transform", `translate(${margin.left}, 0)`);

function getLicense(d) {
❶ let license = d.license?.name;

❷ if (!license) {
    return "No License";
  } else {
    return license;
  }
}

function update(items) {
`--snip--` 

Listing 15-11: getLicense 函数

如果仓库有许可证,许可证名称将作为 d.license.name 可用,但如果没有许可证,d.license 将是 undefined。我们使用 ?. 运算符来测试这种情况,称为 可选链运算符 ❶。像常规的 . 运算符一样,?. 尝试获取运算符左边指定的对象并访问运算符右边指定的方法或属性。但与常规的 . 运算符不同,?. 如果运算符左边的对象为 null 或 undefined,则返回 undefined。因此,如果 d.license 为 undefined(意味着仓库没有许可证),我们的许可证变量将设置为 undefined,但如果 d.license 是一个对象(意味着仓库有许可证),那么 license 将设置为 d.license.name。如果 license 最终为 undefined ❷,我们的 getLicense 函数将返回字符串 "No License"。否则,将返回许可证的值。

现在我们可以添加代码,在鼠标悬停在条形图上时更新侧边栏。我们将通过向 rect 元素添加 mouseover 事件处理程序来实现。请用 Listing 15-12 中的代码更新 script.js,该代码放在 update 函数的末尾。

`--snip--`
    .attr("width", xScale.bandwidth())
    .attr("height", d => yScale(0) - yScale(d.stargazers_count))
    .on("mouseover", (e, d) => {
      let info = d3.select("#info");
      info.select(".repo .value a").text(d.full_name).attr("href", d.html_url); 1
      info.select(".license .value").text(getLicense(d));
      info.select(".stars .value").text(d.stargazers_count);
    });
}

function getUrl() {
`--snip--` 

Listing 15-12: 在悬停时更新侧边栏

D3 事件处理函数会接收两个参数:事件对象(e)和绑定到触发事件的元素的 datum(d)。我们在处理函数中做的第一件事是选择 #info 元素,因为我们要修改的所有元素都是该元素的子元素。然后,我们更新 .repo 元素 ❶ 中 .value 元素内的 a 元素(参考列表 15-8 或查看 index.html 来回顾 HTML 结构)。我们同时设置该元素的文本内容和 href 属性。这样就会生成一个指向仓库的链接,链接文本是仓库的完整名称。我们同样设置 .value .license 元素的文本为 getLicense 为此 datum 返回的内容,并将 .stars .value 元素的文本设置为星标数量。

重新加载页面并尝试悬停在一些条形图上。你应该会看到类似于图 15-4 的内容。

图 15-4:侧边栏显示一个仓库的详细信息

当你悬停在每个条形图上时,该仓库的详细信息应该会显示在新的侧边栏中。如果你点击仓库名称,浏览器应当打开一个新的标签页并跳转到该仓库的 GitHub 页面。

为条形图上色

为了通过视觉方式传达一些额外信息,我们将根据许可证类型为条形图上色。D3 允许你创建输入(域)为值、输出(范围)为颜色的刻度。你将在列表 15-13 中看到如何实现这一点。

`--snip--`
function update(items) {
❶ let licenses = new Set(items.map(d => getLicense(d)));

❷ let colorScale = d3.scaleOrdinal()
    .domain(licenses)
 .range(d3.schemeCategory10);

 let xScale = d3.scaleBand()
`--snip--` 

列表 15-13:为许可证创建颜色刻度

首先,我们需要收集所有唯一的许可证名称。为此,我们对项目进行映射,对每个项目调用我们的 getLicense 函数 ❶。这会生成一个包含许可证名称的数组。在同一行中,我们将结果数组传递给 Set 构造函数。从编程角度讲,集合 是一组唯一的项目,因此 Set 构造函数可以接受一个包含项目的数组并过滤掉重复项。在 JavaScript 中,集合保持其顺序,像数组一样。

d3.scaleOrdinal 辅助函数 ❷ 创建一个具有离散输入和离散输出的刻度。在这里,输入是唯一的许可证名称,输出是颜色名称。对于刻度的范围,我们使用 d3.schemeCategory10,这是一个包含 10 个十六进制颜色字符串的数组。你可以在控制台中查看它:

**d3.schemeCategory10;**
`(10) ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b',` 
 `'#e377c2', '#7f7f7f', '#bcbd22', '#17becf']` 

集合中的每个许可证将按索引映射到这些颜色之一。如果许可证超过 10 个,颜色将会重新循环(第十一和第十二个许可证将使用与第一个和第二个相同的颜色)。

接下来,我们需要根据许可证和颜色刻度设置条形图的颜色。列表 15-14 展示了如何在更新函数的末尾进行此更改。

`--snip--`
 svg
 .selectAll("rect")
 .data(items, d => d.full_name)
 .join("rect")
 .attr("x", d => xScale(d.full_name))
 .attr("y", d => yScale(d.stargazers_count))
    .attr("fill", d => colorScale(getLicense(d)))
 .attr("width", xScale.bandwidth())
 .attr("height", d => yScale(0) - yScale(d.stargazers_count))
`--snip--` 

Listing 15-14: 设置矩形的填充颜色

我们必须在 d 上调用我们的 getLicense 函数以获取许可证名称(因为它可能是 "No License"),然后将许可证名称传递给 colorScale。这会为我们提供填充属性的颜色值,以设置矩形的填充颜色。

使用这样的颜色编码,你确实需要一个图例,让用户知道每种颜色代表什么含义。我们将把这个图例作为侧边栏中的另一个框,放在仓库信息框下方。它将包括颜色方块和相应的许可证名称。首先,我们需要更多的 HTML。使用 Listing 15-15 中的更改更新index.html

`--snip--`
 <p class="stars">
 <span class="label">Stars</span>
 <span class="value"></span>
 </p>
 </div>

      <div id="key" class="box">
        <h1>Key</h1>
      </div>
 </div>
`--snip--` 

Listing 15-15: 添加许可证图例的 div 和标题

在这里,我们在侧边栏 div 内创建另一个名为 key 的 div,并给它一个标题。我们将使用 JavaScript 创建图例的其他元素。

接下来是为这些新元素和我们将通过 JavaScript 添加的子元素进行样式设计的 CSS。将 Listing 15-16 中的代码添加到style.css的末尾。

`--snip--`
#info a:hover {
 text-decoration: underline;
}

#key h1 {
  font-size: 1.5em;
}

#key .color {
  display: inline-block;
  width: 10px;
  height: 10px;
  margin: 0px 10px 0px 10px;
} 

Listing 15-16: 样式化图例

这里的 h1 元素的字体大小设置为 1.5em,这意味着是父元素字体大小的 1.5 倍。这确保了这个标题比其他文本大 1.5 倍。#key .color 规则集用于样式化作为图例一部分的颜色方块。这些将是 div 元素,但 display: inline-block 意味着它们将像内联元素(如 span)一样表现,既不会强制换行,又像块元素(如 div)一样,可以有固定的尺寸和边距。(内联元素无法具有宽度和高度,因为它们的大小是基于内容的,而在这个例子中,方块没有内容。)

现在我们可以添加将生成图例的 JavaScript。这将涉及在更新函数的末尾进行一次新的数据连接,以将许可证与用于渲染它们的元素关联。使用 Listing 15-17 中的更改更新script.js

`--snip--`
 .on("mouseover", (e, d) => {
 let info = d3.select("#info");
 info.select(".repo .value a").text(d.full_name).attr("href", d.html_url);
 info.select(".license .value").text(getLicense(d));
 info.select(".stars .value").text(d.stargazers_count);
 });

  d3.select("#key")
    .selectAll("p")
    .data(licenses)
    .join(
      enter => {
      ❶ let p = enter.append("p");

❷ p.append("div")
          .attr("class", "color")
        ❸ .style("background-color", d => colorScale(d));

      ❹ p.append("span")
          .text(d => d)

        return p;
      }
    ); 
}

function getUrl() {
`--snip--` 

Listing 15-17: 生成图例

在这里,我们使用#key 元素作为新连接的容器,并通过连接一堆 p 元素来绑定每个许可证数据。我们使用了高级连接技术,但仅使用了 enter 函数;我们不需要自定义更新或退出项目的行为。(我们不能在这里使用常规的连接技术,因为每次调用 update 时,元素的附加操作都会发生。)首先,我们为每个新数据创建 p 元素 ❶,然后将一个 div 元素附加到 p 元素上,在其中显示颜色的方块 ❷。为 div 元素添加 color 类意味着它将具有清单 15-16 中的样式。为了给它正确的颜色,我们使用 style 方法 ❸,它在元素上设置内联 CSS 样式。我们使用 colorScale 设置颜色为数据的适当值。最后,我们为 p 元素添加一个 span 元素,用于容纳许可证的实际名称 ❹。

重新加载页面后,你应该能看到类似图 15-5 的效果。

图 15-5:带有图例的颜色编码条

我们的可视化现在有了完整的图例,显示了每种颜色对应的许可证,使颜色变得更加有意义。

标注左侧坐标轴

这里隐含的是,图表的左侧坐标轴显示的是每个仓库的星标数,但图表中并未明确指出这一点。为了解决这个问题,我们将添加一个文本元素来标注左侧坐标轴。相关代码在清单 15-18 中。将这段代码添加到 getLicense 函数之前。

`--snip--`
// Left axis container
let leftContainer = svg
 .append("g")
 .attr("id", "left")
 .attr("transform", `translate(${margin.left}, 0)`);

let chartHeight = (height - margin.bottom) - margin.top;
let midPoint = margin.top + chartHeight / 2;

svg
  .append("text")
  .text("Stars")
  .style("font-size", "14px")
❶ .attr("text-anchor", "middle")
❷ .attr("transform", `translate(12, ${midPoint}) rotate(270)`);

function getLicense(d) {
`--snip--` 

清单 15-18:添加左侧坐标轴标签

首先,我们需要计算标签绘制的位置。它的垂直位置应该位于图表的中间。我们通过总高度减去上下边距来计算图表的高度,然后根据添加上边距和图表高度的一半来计算中点位置。

接下来,我们将带有Stars字样的文本元素添加到 svg 元素中。我们在style.css文件中通过为 body 元素应用 font-family 属性来设置字体。将 text-anchor 属性设置为 middle ❶使文本围绕其计算位置居中。我们还指定了两个变换 ❷:一个平移和一个旋转。平移将标签的中心移动到正确的位置,旋转则将其逆时针旋转 90 度(或顺时针旋转 270 度)。

添加交互性

我们的可视化已经具有一定的交互性,因为悬停在条形图上可以在侧边栏显示该仓库的详细信息。现在,加入另一个交互元素来允许用户过滤数据会很有趣。例如,现在我们有一个列出不同许可证类型的键,我们可以使用它来有选择性地显示或隐藏具有这些许可证的仓库。我们将实现这个交互功能,并同时为图表的变化添加动画效果。

按许可证过滤数据

为了让用户按许可证类型过滤数据,我们将为键中的每一项添加一个复选框。然后,我们将使用这些复选框来决定哪些仓库需要(临时)从图表中排除。这将需要追踪我们想要隐藏的许可证,并在渲染之前删除使用这些许可证的任何仓库。

首先,我们将添加复选框。按清单 15-19 中的方式更改script.js来实现这一点。

`--snip--`
 d3.select("#key")
 .selectAll("p")
 .data(licenses)
 .join(
 enter => {
 let p = enter.append("p");

        p.append("input")
         .attr("type", "checkbox")
         .attr("checked", true)
         .attr("title", "Include in chart");

 p.append("div")
 .attr("class", "color")
 .style("background-color", d => colorScale(d));
`--snip--` 

清单 15-19:添加复选框

在 HTML 中,复选框是一个具有类型属性值为 checkbox 的输入元素。在代码中,我们在键的每个 p 元素开始处添加一个复选框。checked 属性决定复选框是否被选中;我们将它们默认设置为选中状态,因此在可视化首次加载时,所有的仓库都会显示。title 属性会在悬停元素时显示一个带有帮助文本的提示框。

接下来,我们需要创建一个机制来追踪哪些许可证应该被隐藏。相关代码在清单 15-20 中。

`--snip--`
function getLicense(d) {
 let license = d.license?.name;

 if (!license) {
 return "No License";
 } else {
 return license;
 }
}

❶ let hiddenLicenses = new Set();

function update(items) {
 let licenses = new Set(items.map(d => getLicense(d))); 

`--snip--`

 p.append("span")
 .text(d => d)

 return p;
 }
 );

❷ d3.selectAll("#key input").on("change", (e, d) => {
    if (e.target.checked) {
    ❸ hiddenLicenses.delete(d);
    } else {
    ❹ hiddenLicenses.add(d);
    }

    console.log(hiddenLicenses);
  ❺ update(items);
  });
}

function getUrl() {
`--snip--` 

清单 15-20:追踪隐藏的许可证

首先,我们在更新函数❶之前创建一个新的空 Set,名为 hiddenLicenses。我们使用 Set 是为了方便添加或删除许可证——如果使用数组,删除特定元素会更麻烦。然后,在渲染键的代码之后,我们为复选框创建一个变更事件处理器❷。每当复选框从选中变为未选中或反之时,这个处理器就会执行。在处理器中,e 是变更事件,d 是绑定的数据(尽管许可证绑定在 p 元素上,但像复选框这样的子元素也会继承数据)。我们使用 e.target.checked 来确定变更后复选框是否被选中。如果是,那么我们知道该数据应该从 hiddenLicenses 集合中移除,使用集合的 delete 方法❸。相反,如果复选框现在未选中,我们将该数据添加到 hiddenLicenses 中,使用集合的 add 方法❹。

最后,在修改了 hiddenLicenses 集合后,我们将该集合日志记录到控制台,并再次调用更新函数 ❺,使用最初调用时的相同项。当你重新加载页面时,你不会看到任何新的行为,因为我们实际上还没有更新图表,但如果你打开控制台,你会看到当你勾选和取消勾选不同的复选框时,hiddenLicenses 集合是如何变化的。hiddenLicenses 集合应始终与键中的未勾选许可证对应。

现在我们需要确定在隐藏许可证时要显示哪些仓库。为此,我们将在 update 方法的顶部创建一个名为 filtered 的新数组。它将是 items 数组的一个版本,其中移除了具有隐藏许可证的仓库。此更改的代码在清单 15-21 中。

`--snip--`
let hiddenLicenses = new Set();

function update(items) {
  // Items with the hidden licenses removed
  let filtered = items.filter(d => !hiddenLicenses.has(getLicense(d)));

 let licenses = new Set(items.map(d => getLicense(d)));
`--snip--` 

清单 15-21:确定具有隐藏许可证的仓库

为了过滤项列表,对于每一项,我们检查其许可证名称是否在 hiddenLicenses 集合中,使用集合的 has 方法。如果名称不在集合中,则它将出现在 filtered 列表中。否则,它会被过滤掉。

最后,我们需要切换到使用 filtered 数组,而不是 items 数组来进行渲染。新的图表将只渲染过滤后的数据,因此我们需要更改比例尺和条形绘制代码,以便它们与 filtered 一起工作。另一方面,我们不应该过滤 licenses 集合,因为它需要保持一致的颜色方案,并渲染键,无论某些许可证是否当前在条形图中隐藏。清单 15-22 显示了在 update 函数中需要更改为使用 filtered 而不是 items 的所有位置。

`--snip--`
 let xScale = d3.scaleBand()
  ❶ .domain(filtered.map(d => d.full_name))
 .range([margin.left, width - margin.right])
 .padding(0.3);

 let yScale = d3.scaleLinear()
  ❷ .domain([0, d3.max(filtered, d => d.stargazers_count)])
 .range([height - margin.bottom, margin.top])
 .nice();

`--snip--`

 svg
 .selectAll("rect")
  ❸ .data(filtered, d => d.full_name)
 .join("rect")
 .attr("x", d => xScale(d.full_name))
 .attr("y", d => yScale(d.stargazers_count))
`--snip--` 

清单 15-22:用 filtered 替换 items 用于渲染条形图

我们更新了创建底部 ❶ 和左侧轴 ❷ 的比例尺的代码,以及绘制条形图 ❸ 的代码,在每个情况下将项更改为 filtered。刷新页面并取消选择键中的一些许可证。此时,你应该看到相应的条形图从条形图中消失。变化得以呈现,因为正如你在清单 15-20 中看到的,我们正在从复选框的更改事件处理程序中调用 update。

动画化变化

为了锦上添花,我们来添加一些动画效果。这将使得在许可证显示或隐藏时,条形图的变化更容易看到,同时也让可视化效果看起来更酷。我们将对图表的两个部分进行动画:左侧轴和条形图。为此,进行清单 15-23 中所示的更改。

`--snip--`
 let leftAxis = d3
 .axisLeft(yScale)
 .tickFormat(d3.format("~s"));

 bottomContainer.call(bottomAxis);

  leftContainer
  ❶ .transition()
    .call(leftAxis);

 svg
 .selectAll("rect")
 .data(filtered, d => d.full_name)
 .join(
      enter => enter
        .append("rect")
        .attr("x", d => xScale(d.full_name))
        .attr("y", d => yScale(d.stargazers_count))
        .attr("fill", d => colorScale(getLicense(d)))
        .attr("width", xScale.bandwidth())
        .attr("height", d => yScale(0) - yScale(d.stargazers_count))
      ❷ .style("opacity", 0)
        .transition()
      ❸ .style("opacity", 1),
      update => update
        .transition()
      ❹ .attr("x", d => xScale(d.full_name))
        .attr("y", d => yScale(d.stargazers_count))
        .attr("width", xScale.bandwidth())
        .attr("height", d => yScale(0) - yScale(d.stargazers_count)),
      exit => exit
        .transition()
      ❺ .style("opacity", 0)
      ❻ .remove()
    )
 .on("mouseover", (e, d) => {
 let info = d3.select("#info");
 info.select(".repo .value a").text(d.full_name).attr("href", d.html_url);
 info.select(".license .value").text(getLicense(d));
 info.select(".stars .value").text(d.stargazers_count);
 });
`--snip--` 

清单 15-23:添加动画效果

动画化左轴非常简单:我们只需在绘制左轴之前,对轴的容器调用过渡❶,左轴将在每次缩放变化时过渡(只有在最大条形图隐藏或显示时才会发生变化,这会改变域的上界)。

为了使条形图动画化,我们遵循标准做法,采用高级连接技术,并为元素的进入、更新和退出添加过渡效果。进入的元素初始时所有属性已设置,并且透明度为 0(意味着它们是完全透明的)❷。然后我们调用过渡动画,将透明度动画至 100%不透明❸,这会产生元素渐显的效果。更新的元素保持相同的颜色和透明度,但它们的位置和尺寸可能发生变化,因此我们对所有这些元素进行动画处理❹。这会让这些更新的元素伸展并滑动到新的大小和位置。退出的元素则与进入的元素相反,逐渐消失,我们通过将它们的透明度过渡回 0❺来实现这个效果。记住,在过渡完成后,我们还需要对任何退出的元素调用 remove❻。

重新加载页面并尝试隐藏和显示不同的许可证。关键中的第一个许可证应该始终对应于拥有最多星标的仓库(因为仓库是按这个顺序排列的,我们也按这个顺序从仓库中提取许可证名称),所以如果你想看到左轴的缩放效果,你需要关闭那个许可证。你应该能看到该许可证对应的仓库淡出,而其他仓库则会扩展以填充空间,并重新计算比例。图 15-6(a)展示了所有许可证显示时的图表,图 15-6(b)展示了 MIT 许可证(在撰写时,是数据集中最流行的许可证)被隐藏时的图表。

图 15-6: 显示所有许可证的最终图表(a),以及隐藏顶部许可证的图表(b)

尝试展示和隐藏不同的许可证,感受一下动画效果的运作方式。你会对它们做出什么修改吗?你还可以通过什么方式让可视化效果更有趣?

完整代码

如果你想查看完整的script.js文件内容,可以在清单 15-24 中找到完整的代码。

const width = 600;
const height = 400;

let svg = d3
  .select("body")
  .insert("svg", "#sidebar")
  .attr("width", width)
  .attr("height", height);

let margin = {top: 20, right: 10, bottom: 20, left: 50};

// Bottom axis container
let bottomContainer = svg
  .append("g")
  .attr("id", "bottom")
  .attr("transform", `translate(0, ${height - margin.bottom})`);

// Left axis container
let leftContainer = svg
  .append("g")
  .attr("id", "left")
  .attr("transform", `translate(${margin.left}, 0)`);

let chartHeight = (height - margin.bottom) - margin.top;
let midPoint = margin.top + chartHeight / 2;

svg
  .append("text")
 .text("Stars")
  .style("font-size", "14px")
  .attr("text-anchor", "middle")
  .attr("transform", `translate(12, ${midPoint}) rotate(270)`);

function getLicense(d) {
  let license = d.license?.name;

  if (!license) {
return "No License";
  } else {
return license;
  }
}

let hiddenLicenses = new Set();

function update(items) {
  // Items with the hidden licenses removed
  let filtered = items.filter(d => !hiddenLicenses.has(getLicense(d)));

  let licenses = new Set(items.map(d => getLicense(d)));
  let colorScale = d3.scaleOrdinal()
.domain(licenses)
.range(d3.schemeCategory10);

  let xScale = d3.scaleBand()
.domain(filtered.map(d => d.full_name))
.range([margin.left, width - margin.right])
.padding(0.3);

  let yScale = d3.scaleLinear()
.domain([0, d3.max(filtered, d => d.stargazers_count)])
.range([height - margin.bottom, margin.top])
.nice();

  let bottomAxis = d3
.axisBottom(xScale)
.tickValues([]);

  let leftAxis = d3
.axisLeft(yScale)
.tickFormat(d3.format("~s"));

  bottomContainer.call(bottomAxis);

  leftContainer
.transition()
.call(leftAxis);

  svg
.selectAll("rect")
.data(filtered, d => d.full_name)
.join(
enter => enter
.append("rect")
.attr("x", d => xScale(d.full_name))
.attr("y", d => yScale(d.stargazers_count))
.attr("fill", d => colorScale(getLicense(d)))
.attr("width", xScale.bandwidth())
.attr("height", d => yScale(0) - yScale(d.stargazers_count))
        .style("opacity", 0)
        .transition()
        .style("opacity", 1),
update => update
        .transition()
        .attr("x", d => xScale(d.full_name))
        .attr("y", d => yScale(d.stargazers_count))
        .attr("width", xScale.bandwidth())
        .attr("height", d => yScale(0) - yScale(d.stargazers_count)),
exit => exit
        .transition()
        .style("opacity", 0)
        .remove()
)
.on("mouseover", (e, d) => {
let info = d3.select("#info");
info.select(".repo .value a").text(d.full_name).attr("href", d.html_url);
info.select(".license .value").text(getLicense(d));
info.select(".stars .value").text(d.stargazers_count);
});

  d3.select("#key")
.selectAll("p")
.data(licenses)
.join(
enter => {
        let p = enter.append("p");

p.append("input")
.attr("type", "checkbox")
.attr("checked", true)
.attr("title", "Include in chart");

p.append("div")
.attr("class", "color")
  .style("background-color", d => colorScale(d));

p.append("span")
.text(d => d)

return p;
}
);

  d3.selectAll("#key input").on("change", (e, d) => {
if (e.target.checked) {
hiddenLicenses.delete(d);
} else {
hiddenLicenses.add(d);
}

console.log(hiddenLicenses);
update(items);
  });
}

function getUrl() {
  let baseUrl = "https://api.github.com/search/repositories";

  let params = {
q: "language:javascript stars:>10000",
per_page: 20,
sort: "stars"
  };

  let queryString = Object.entries(params).map(pair => {
return `${pair[0]}=${encodeURIComponent(pair[1])}`;
  }).join("&");

  return `${baseUrl}?${queryString}`;
}

let url = getUrl();
let backupUrl = "https://skilldrick-jscc.s3.us-west-2.amazonaws.com/gh-js-repos.json";

// Replace url with backupUrl in following line if needed
d3.json(url).then(data => {
  update(data.items);
}); 

清单 15-24: 本项目的完整 script.js 文件

总结

在这个最终项目中,你使用从 GitHub 搜索 API 获取的实时数据创建了一个相当复杂的交互式图表。你现在已经掌握了使用 D3 创建自定义图表所需的工具。我们这里只涉及了该库提供的功能的一小部分;实际上,它支持许多不同类型的可视化,比如树状图、制图地图和其他一些更为深奥的布局。每种可视化类型都以 SVG、数据绑定、连接、比例和过渡为基础,因此,如果你决定进一步探索使用 JavaScript 进行数据可视化,你在这里学到的知识将为你打下良好的基础。https://d3js.org 是进一步研究的一个极好的起点。

第八章:后记

你已经学习了 JavaScript 语言的核心内容,完成了本书的三个项目,现在你可能在想接下来该做什么。好消息(或者坏消息,这取决于你的心态)是学习永远不会停止。你可以将编程事业带向许多不同的方向。以下是一些关于可能的下一步建议,以及可以探索的工具和资源。

项目

到目前为止,你应该已经相当了解如何设置一个新的 JavaScript 项目了,为什么不尝试构建一些新东西呢?一个选择是利用本书中学到的技术制作你自己的游戏。一些相对简单的街机游戏,如 Pong,包括 SnakeSpace InvadersTetrisBreakout。或者你可以尝试完全不同的东西,制作一个字谜游戏,比如 WordleHangman

你还可以尝试在本书其他项目的基础上进行扩展,创造属于你自己的音乐作品或数据可视化。在音乐方面,你可能想尝试制作一个鼓机,或者一个无限音乐生成器。你还可以利用你的知识为你开发的游戏加入音效。在数据可视化方面,有无数的其他 API 可以尝试,用来从其他服务获取数据。如 第十五章 中讨论的,许多 API 需要某种形式的身份验证,这对于基于浏览器的应用程序来说是不可行的;然而,使用 Node.js,你可以尝试构建自己的后端应用程序。

当然,你不必仅限于本书中所做的项目。如果有某些东西引起了你的兴趣,试着去构建它!如果你不确定从哪里开始,可以用 Google 获取一些灵感。JavaScript 是全球最受欢迎的语言之一,因此有人可能已经写好了关于如何做你想做的事的教程。

Node.js

本书只解释了如何编写在 Web 浏览器中运行的 JavaScript,但通过 Node.js,你还可以在后端 Web 服务器上运行 JavaScript。查看 https://nodejs.dev/en/learn/ 以获得一个关于 Node.js 入门的优秀指南。一旦你为应用程序搭建了后端,就可以开始做更有趣的事情,比如代表用户存储数据、使用密钥访问第三方认证的 HTTP API 等等。

工具

有许多不同种类的工具可以帮助你继续编程之旅。本节介绍了一些工具,但远非全面。

Git

Git 是一个流行的版本控制系统,允许你跟踪代码的变化并回退到早期的版本。当我刚开始编程时,我常常会修改代码并破坏某些功能,然后不知道是怎么破坏的,也无法恢复到之前的状态。为了避免这种情况,我开始备份代码,以便能回到早期版本。而 Git 是一种更好的方式来实现同样的目标。使用 Git 时,你会做出 commit,保存代码在某个特定时间点的状态。每个 commit 都是在之前的基础上构建的,同时记录了变化的内容。

有很多在线资源可以学习 Git。许多资源可以在 https://git-scm.com/doc 找到。

GitHub

一旦你在电脑上安装了 Git,使用 GitHub(我们在 第十五章 中使用的数据来源)是一个很好的下一步。GitHub 提供了一种上传和共享本地 Git 仓库的方式,让它们可以在任何地方访问。

GitHub 还为你提供了访问数百万个开源仓库的权限,你可以 fork(创建自己的副本)并根据需要修改它们。例如,要查看我所有的公开仓库,请访问 https://github.com/skilldrick

CodePen

要与他人分享你的项目,你需要一种通过网络使它们可访问的方式。你可以自己搭建网站托管,但一个更简单的选择是使用 CodePen(https://codepen.io)。这也是本书配套资源的托管工具,资源可以在 https://codepen.io/collection/ZMjYLO 查看。

使用 CodePen,你可以创建并分享使用 HTML、CSS 和 JavaScript 构建的项目。页面上的代码会在不同的面板中显示。例如,图 A-1 显示了我制作的一个示例 Pen,每次点击文本时都会添加一个额外的感叹号。你可以在线查看该 Pen,链接为 https://codepen.io/skilldrick/pen/abKaQpo

图 A-1:CodePen 上的一个示例 Pen

在 HTML 面板中,仅需包含 body 元素的内容。CodePen 会自动提供其余的 HTML 结构。你也可以通过设置对话框轻松地引入外部 JavaScript 库。

Glitch

像 CodePen 一样,Glitch 是一个托管代码的服务,并允许你与全世界分享代码。Glitch 与众不同之处在于它能够运行前端 后端代码。Glitch 不仅提供 HTML、CSS 和 JavaScript 的面板,还允许你定义一个完整的目录结构并存放所需的所有文件。你甚至可以添加一个 SQLite 数据库来存储数据。访问 https://glitch.com 了解更多信息,或查看 https://glitch.new 选择一个启动应用程序。

网页开发

尽管本书的重点是 JavaScript,但在学习过程中,你也掌握了一些通用的网页开发技能。如果这激发了你的兴趣,你可能会想花更多时间学习网页开发的其他方面。

HTML 和 CSS

HTML 是绝大多数网页使用的语言,因此深入了解其复杂性非常有价值。欲了解更多信息,请查阅 MDN 文档:https://developer.mozilla.org/HTML。CSS 用于网页样式,因此如果你希望页面看起来漂亮,理解其工作原理至关重要。想了解更多,可以访问 MDN:https://developer.mozilla.org/CSS

JavaScript 框架和库

现代网页开发非常复杂,网页应用程序通常包含成千上万行的 HTML、CSS 和 JavaScript。为了大大减少编写功能齐全的现代网页应用程序所需的代码量,许多开发人员使用 JavaScript 框架和库。本文写作时,最受欢迎的两个框架是 React 和 Vue.js。虽然掌握这些工具并非必需,但它们可以显著简化构建复杂网站和前端应用程序的过程。你可以在 CodePen 上尝试 React 和 Vue.js,或者访问它们的网站:

测试

对程序员来说,一个重要的工具就是自动化测试框架。自动化测试旨在定期运行在你的代码上,以确认它是否按预期执行功能。在编写代码时,一个常见的问题是添加新功能时没有意识到这段更改会破坏程序的其他部分。通过编写好的测试并定期运行它们,你可以及时发现问题并进行修复。你也可以在进行大型代码重构时充满信心,因为只要测试通过,就不太可能破坏其他内容。

有许多用于 JavaScript 的测试库和框架。在写这篇文章时,最流行的框架之一是 Jest:可以通过 https://jestjs.io 了解更多。

更多 JavaScript!

如果你想加深对 JavaScript 的理解,有许多资源可以供你参考。以下是一些推荐的学习起点:

  • MDN JavaScript 门户: https://developer.mozilla.org/JavaScript

  • Eloquent JavaScript,第 3 版,作者 Marijn Haverbeke(No Starch Press,2018)

  • JavaScript: The Definitive Guide,第 7 版,作者 David Flanagan(O'Reilly Media,2020)

其他语言

在这一点上,你可能决定拓宽自己的编程知识,而不是更深入地研究 JavaScript。去做吧!每学习一门语言都会为你提供宝贵的编程见解,这实际上是提高 JavaScript 技能的好方法。

TypeScript

人们对 JavaScript 的一个常见问题是其弱动态类型,这允许根据周围代码的不同隐式地将值强制转换为不同的数据类型。例如,如果一个操作数是字符串,那么+运算符会将数字操作数转换为字符串,而-运算符则会将字符串操作数转换为数字,如果另一个操作数是数字。

TypeScript 语言试图为 JavaScript 增加静态类型。静态类型意味着某一类型的变量只能包含该类型的值,并且类型之间的转换必须是显式的。TypeScript 在语法上是 JavaScript 的超集,这意味着有效的 JavaScript 程序也是有效的 TypeScript 程序。TypeScript 代码可以通过 TypeScript 编译器转换为 JavaScript。

使用静态类型使得某些错误代码无法编写。例如,在 JavaScript 中,你可能从文本框中获取一个值,假设它是一个数字,并将其加到另一个数字上。不幸的是,来自文本框的任何值都会被当作字符串处理,因此另一个数字也会隐式转换为字符串,最终你会得到两个字符串连接在一起。TypeScript 不允许这种情况发生。它知道来自文本框的值是字符串,并强制你决定是否将两个操作数转换为字符串进行连接,或者将两个操作数转换为数字进行加法运算。

TypeScript 的一个缺点是,有时编写看似应该工作的代码可能会更加困难。这有时被称为 与编译器作斗争

如果你想了解更多内容,以下是一些书籍和其他资源,可以帮助你开始学习 TypeScript:

Python

Python 是一种脚本语言,像 JavaScript 一样,但它有不同的哲学。Python 采用“电池全包”的方法,这意味着它的标准库功能非常丰富(与之相比,JavaScript 的标准库非常有限)。像 JavaScript 一样,Python 是动态类型的,因此同一个变量可以存储不同数据类型的值。但不同于 JavaScript 的弱类型,Python 是 强类型 的,这意味着没有隐式类型转换。从语法上看,Python 非常不同,它使用缩进(这要求严格,不是可选的)来定义嵌套代码块,而不是大括号。

Python 是一种流行的 Web 服务器编程语言,也广泛应用于科学和数值编程。如果你想拓宽技能,Python 是一个很好的选择。以下这本书(与 JavaScript 编程快速上手 同一系列)是学习该语言的极好入门书籍,它的项目展示了 Python 在某些领域的优势:

  • Python 编程快速上手 第 3 版,由 Eric Matthes 编写(No Starch Press, 2023)

Rust

今天我最兴奋的编程语言之一是 Rust。与 TypeScript 类似,它也是一种静态类型语言,但它拥有比大多数现今使用的语言更强大的类型系统。Rust 旨在替代旧的 C 和 C++ 语言,用于开发高性能代码。

C 和 C++ 都是没有 垃圾回收 的语言,垃圾回收是指计算机自动识别哪些值和对象不再使用,从而释放内存的过程。相反,C/C++ 程序员必须手动释放不再使用的数据——这个过程容易出错,且常常导致 bug。这些语言通常在对性能要求高的环境中使用,之所以不使用垃圾回收,是因为垃圾回收会降低软件的性能。Rust 通过编译时的 借用检查器 避免了这个问题,该检查器跟踪哪些对象在任何时刻正在使用,以及程序的哪些部分正在使用它们。

Rust 也被广泛用作编译 WebAssembly 的源语言,WebAssembly 是一种令人兴奋的新技术,可在浏览器中运行高效且性能优异的代码。以下是一些学习更多内容的资源:

第九章:2 基础知识

在本章中,我将介绍构成任何 JavaScript 程序的一些基本元素。你将了解 JavaScript 代码的基本单元,这些单元让你能够表示值并向计算机发出指令。你还将学习如何为一个值赋予名称,以便在代码中以后引用它。

本章还展示了 JavaScript 如何根据值可以表示的信息种类将值分类为不同的数据类型。我们将重点介绍表示单一值的原始数据类型,例如一个数字或一段文本。你将练习在 JavaScript 控制台中使用不同的原始数据类型,并探索可以应用于它们的一些操作。

表达式与语句

JavaScript 中最基本的构建块是 表达式,它是表示单一值的代码片段。确定表达式值的过程被称为 求值。例如,100 + 200 是一个表达式,它求值为数字 300。

另一个重要的 JavaScript 构建块是 语句,它是一个完整的思想,像英语中的句子,指示计算机做某事。每个 JavaScript 程序都由一系列语句组成。例如,在第一章中,我们使用了语句 alert("Hello, world!"); 来指示计算机显示一个包含 Hello, world! 文本的对话框。与句子以句号结尾不同,JavaScript 语句以分号结尾。

你可以通过简单地在表达式末尾加上分号,将表达式转化为语句。例如,100 + 200; 就是一个语句,指示计算机将两个数字相加。打开 JavaScript 控制台并输入此语句。当你这样做时,它将对语句中的表达式进行求值并打印结果:

**100 + 200;**
300 

该语句中的主要表达式 100 + 200 被称为 复合表达式,因为它实际上包含了两个较小的表达式,100 和 200。这些是 JavaScript 表达式最简单形式的示例,字面量,即在代码中直接表示一个固定值。具体来说,100 和 200 是 数字字面量,因为它们直接对应它们表示的数值 100 和 200。相反,复合表达式 100 + 200 不是字面量,因为它并不直接对应它的值 300。这个复合表达式中的 + 并不表示一个值。它实际上是一个 运算符,用于组合或操作表达式的符号。

区分值和表达式很重要,值是程序中的底层数据,而表达式是代码中的符号,告诉 JavaScript 值应该是什么。300 和 100 + 200 都有一个值 300,但它们是两种不同的表达式——一个是字面值,另一个是复合表达式——它们以不同的方式表示这个值。

JavaScript 表达式和语句相辅相成。表达式有值,但表达式本身并不会任何事情。相反,语句通过告诉计算机执行任务来完成工作,但语句本身没有值;它们只是指令。编程的力量来自于编写语句,利用并操作表达式的值以达到预期的结果。

数字和运算符

JavaScript 使用数字数据类型来存储数值。你已经在上一节中开始使用数字,并且遇到了 + 运算符,它将两个数字相加。JavaScript 也有其他常见数学运算的运算符,包括减法(-)、乘法(*)和除法(/)。在 JavaScript 控制台中试试这些运算符:

**100 + 200;**
300
**10000 - 999;**
9001
**999 * 111;**
110889
**997002 / 999;**
998 

在像这样的情况中,表达式涉及一个运算符(例如 + 或 ),我们通常说运算符返回一个值(而不是说表达式被求值)。例如, 运算符接收数字 999 和 111,并返回它们的乘积 110889。

运算顺序

你可以在一个表达式中组合多个数字和运算符,在这种情况下,你需要考虑操作执行的顺序。考虑这个例子:

**5 + 10 / 10 - 5;**
1 

当你有多个运算符时,JavaScript 使用标准的数学 PEMDAS(括号、指数、乘法、除法、加法、减法)规则来确定计算顺序。这个表达式中的除法操作首先执行,其次是加法和减法,最终得到的值是 1。

为了强制加法和减法先进行,可以使用括号:

**(5 + 10) / (10 – 5);**
3 

这次,表达式产生了不同的值,因为括号改变了运算顺序。

浮点数

到目前为止,我们仅限于整数,但 JavaScript 的数字数据类型也可以容纳小数,即编程术语中的浮点数。这里有一个生成浮点数的表达式:

**10 / 4;**
2.5 

浮点数的精度有限,因此小数部分不能无限延续。相反,它们会被截断,如以下示例所示:

**10 / 3;**
3.3333333333333335 

你可能会注意到浮点数学的一些怪异现象,其中结果不是你预期的那样。例如,0.1 + 0.2 并不会准确等于 0.3。相反,我们得到了一些额外的小数位:

**0.1 + 0.2;**
0.30000000000000004 

这是由于计算机表示数字的基础数学原理(可以在网上搜索“浮动点运算”了解更多)。一般来说,这些问题不应该影响你,但在某些情况下可能会。举个例子,如果你正在编写一个处理货币数值的应用程序,假如$0.10 和$0.20 相加后不等于$0.30,可能就会成为问题。在这种情况下,通常的解决方案是将货币数值转换为美分(或等值的最小单位),然后再进行数学计算。例如,表示美元金额的数字 0.10 将变成表示美分金额的 10。

绑定

在 JavaScript 中,你可以将一个名称与一个值关联,这样以后在代码中可以轻松引用该值。这个关联称为绑定。绑定是非常强大的工具,因为它们提供了一个存储表达式生成值的地方。没有它们,你的程序将无法记住它们已经做过的工作。

一种绑定类型是变量,它允许你根据需要更新与特定名称关联的值。它们之所以叫变量,是因为它们的值可以变化或改变。另一种绑定类型是常量,在赋值后你不能更新与名称关联的值。这个值保持不变,或者说是常量。

把变量或常量想象成一个有标签(名称)的盒子,里面放着一个单独的物品(值)。你把一个值放进盒子,然后在需要时通过名称去查找它。变量允许你把新值放进盒子,而常量将始终保持相同的值。

绑定的名称也叫做标识符,创建标识符的过程称为声明:绑定的名称正在被声明。在 JavaScript 中,声明是一种特殊类型的语句,它生成一个新的标识符。让我们来看一下如何声明并使用变量和常量。

变量

JavaScript 有两个关键字用于声明变量:var 和 let。关键字是内建于 JavaScript 语言中的一个词,它被保留用于特定的目的。最初,var 关键字是唯一的选择,但后来 let 被加入以修复 var 的一些不足。如今,let 是声明变量时推荐使用的关键字,所以在本书中我们将使用 let,但在查看旧代码时,如果遇到 var 也不要惊讶。

这是一个使用 let 声明变量的例子:

**let age;**
undefined 

这个 let 声明创建了一个名为 age 的新变量。let 声明本身没有值,因此 JavaScript 控制台会打印出特殊值 undefined。

现在我们已经创建了一个变量,接下来让我们编写一个表达式来赋予它一个值:

**age = 35;**
35 

给变量赋值叫做 赋值,你可以使用赋值运算符(=)来完成。在运算符的右侧,你输入一个表达式(在这个例子中是数字字面量 35),在左侧,你输入应该被赋予该表达式值的变量名(在这个例子中是 age)。

使用 = 运算符进行赋值是一种复合表达式,就像使用 + 和其他数学运算符的表达式一样。赋值表达式的值是变量的新值。在这种情况下,我们将 age 设置为 35,所以控制台打印出 35。

第一次给变量赋值叫做 初始化。程序员通常将变量的声明和初始化合并成一行代码。例如,在这里我们创建了一个名为 cats 的新变量,并给它赋值为 2,所有操作都在同一句话中完成:

**let cats = 2;**
undefined 

虽然我们在这里给变量赋值,但主要规则是 let 声明本身没有值,因此 JavaScript 控制台会打印出 undefined。

为了确认赋值是否成功,只需在控制台中输入变量名,后跟分号:

**cats;**
2 

在这里,cats 是一个表达式,它的值是当前 cats 变量的值,因此控制台打印出这个值。

因为 cats 是一个变量,我们可以使用新的赋值表达式自由地更改它的值。例如,这里我们将它的值更新为 3:

**cats = 3;**
3 

变量的一个好处是,你可以随时更改它们的值——例如,当你得到另一只猫时。

常量

在 JavaScript 中声明常量时,我们使用 const 关键字:

**const PI = 3.141592653589793;**
undefined 

这创建了一个名为 PI 的新常量,并赋值为 3.141592653589793,这就是数字 π 的近似值。

现在我们可以使用这个常量来根据圆的直径计算圆的周长:

**let diameter = 3;**
undefined
**let circumference = diameter * PI;**
undefined
**circumference;**
9.42477796076938 

在这里,我们创建了一个名为 diameter 的变量并赋值为 3。然后我们创建了另一个变量 circumference,并给它赋值为 diameter * PI。请注意,我们使用了一个复合表达式,由一个变量(diameter)、一个常量(PI)和一个数学运算符(*)组成,来设置变量的值。编程的核心就是创建常量和变量,然后通过操作它们来创建其他常量和变量。

注意

为了避免书中的代码示例过于杂乱,从现在开始,我将不再显示变量或常量声明后控制台打印的 undefined。

与变量不同,一旦常量被创建,你就无法更改它的值。例如,如果你尝试更新 PI 的值,JavaScript 会报错:

**PI = 5.378;**
Uncaught TypeError: Assignment to constant variable.
  at <anonymous>:1:4 

错误是 JavaScript 告诉你代码有问题的方式。这个错误信息中最重要的部分是“Assignment to constant variable”(赋值给常量变量)。这表明我们错误地试图给一个常量赋一个新值。

与变量不同,你不能将常量的声明与初始化分开,你必须在声明时就为常量赋值。因此,下面的代码是无法工作的:

**const TAU;**
Uncaught SyntaxError: Missing initializer in const declaration 

这个错误信息告诉我们,常量声明缺少初始化器,意味着常量应该在声明时赋予一个值。

命名约定

你应该给变量和常量起有描述性的名字,这样当你或其他人阅读你的代码时,能清楚地知道这些变量和常量代表什么。例如,如果你在写控制汽车的代码,你可能需要一个变量来存储汽车的速度,单位是英里每小时。speedInMilesPerHour 这个名字有点长,但考虑到 MPH 是一个广泛理解的“英里每小时”缩写,好的变量名应该是 speedInMPH。像 speed 这样的简短名字也可以,但前提是每个阅读代码的人都能明确知道这个 speed 是以英里每小时为单位。(想象一下,如果有一个来自德国的人在阅读你的代码,认为你说的是公里每小时。)这个变量的一个糟糕名字是 s,它完全没有给读者任何提示。

JavaScript 是区分大小写的,这意味着它会区分变量 age、Age 和 AGE。此外,标识符不能包含空格。为了避免这个问题,常见的变量命名约定是使用camelCase,其中名称中的第一个单词以小写字母开头,后续单词以大写字母开头,例如 speedInMilesPerHour。(之所以叫 camelCase,是因为中间的大写字母看起来像骆驼的驼峰。)

另一种变量命名约定是snake_case,其中所有字母都是小写,每个单词之间用下划线分隔。(我猜这看起来有点像蛇,如果你眯着眼睛看。)在 snake_case 中,我们可以写作 speed_in_miles_per_hour 或 speed_in_MPH。

使用蛇形命名法(snake_case)可以让变量名比驼峰命名法(camelCase)稍微更清晰一些,但它也让变量名变得更长,而且输入时不容易快速打字。JavaScript 程序员通常偏好使用驼峰命名法,所以本书中我将使用这种命名法。

常量遵循与变量不同的命名约定。常量有两种类型:真正的常量,它的值在程序运行时永远不会改变(例如 pi 或一天中的小时数),以及那些你将其设为常量,因为你不希望在代码中不小心修改它们的值(例如当前用户的名字)。对于真正的常量,习惯上使用全大写的 snake_case,例如 HOURS_IN_A_DAY。对于那些为了避免意外修改值而创建的常量,使用与变量相同的命名约定。在这种情况下,唯一的区别是,绑定是使用 const 而不是 let 创建的。

递增与递减

在编写软件时,你经常需要对变量的值进行递增(增加)或递减(减少)1 或其他数字。例如,你可能会使用一个变量来统计文档中某个单词的出现次数。每次看到该单词时,你就将变量的值加 1。同样,你也可以使用一个变量来跟踪游戏中玩家的金额,每当玩家赚取或花费一些钱时,就增加或减少这个金额。

递增变量的一种方法是初始化它,然后将它的值改为其原值加 1:

**let money = 100;**
**money = money +** **1;**
101 

在这里,我们声明一个名为 money 的新变量,并将其初始化为 100。然后,我们通过给 money 现有的值加 1 来给 money 赋新值。看到像 money = money + 1;这样的表达式可能会显得有些矛盾,但实际上这是一种很常见的编程模式。当 JavaScript 遇到像这样的赋值表达式时,它会首先计算赋值运算符右侧表达式的值,在这个例子中就是 money + 1。因为 money 当前是 100,所以 money + 1 的值是 101。然后,JavaScript 会更新赋值运算符左侧变量的值,这里是同一个变量 money。

关键在于,JavaScript 会等到计算完运算符右侧表达式的值之后,才会更改运算符左侧变量的值。这就是为什么同一个变量可以出现在运算符的两边。最终,money = money + 1 的意思是“将 1 加到 money 上”。

由于给变量加 1 是一个非常常见的任务,JavaScript 通过递增运算符(++)使这个操作变得更容易。当你将此运算符附加到变量时,它会将该变量的值增加 1,而无需你写出完整的赋值表达式。同样,JavaScript 的递减运算符(--)会将变量的值减少 1。在这里,我们使用++和--来改变一个温度变量的值:

**let temperature = 70;**
**++temperature;**
71
**++temperature;**
72
**--temperature;**
71 

在这个例子中,我们将递增和递减运算符放在我们想要更改的变量之前。在这种位置下,递增或递减操作的输出是变量的更新值。例如,当 temperature 的值为 70 时,++temperature 输出的值是 71。这种方式称为前缀递增和递减。

JavaScript 还允许进行后缀递增和递减,其中运算符位于变量之后。在这种情况下,变量仍然会增加或减少 1,但输出显示的是变量变化之前的值。下面是一个例子:

**let books = 2;**
**books****++;**
❶ 2
**books;**
❷ 3
**books--;**
3
**books;**
2 

我们将 books 变量初始化为 2。然后,books++会递增这个值,但它返回的是递增之前的 books 值❶。当我们随后单独请求 books 的值时,我们可以看到它的值已经变为新的值❷,这证明了递增操作已经发生。

无论你使用前缀递增还是后缀递增,递减的效果是一样的:它的值增加或减少 1。唯一的区别在于递增或递减表达式本身是如何求值的。幸运的是,大多数时候当你使用这些运算符时,实际上并不需要递增或递减表达式的值——你只需要修改保存在变量中的值。因此,你通常可以交换使用前缀和后缀版本。

加法与减法赋值

递增运算符将变量的值增加 1,但有时你希望将其增加不同的值。为此,JavaScript 提供了加法赋值运算符(+=)。它将运算符左侧的变量值增加右侧所指定的值,如下所示:

**let price = 20;**
**price** **+= 5;**
25 

在这里,我们使用+=将价格增加 5。实际上,price += 5price = price + 5的简写。+=运算符将加法和赋值合并成一个符号。

类似地,减法赋值运算符(-=)是一种方便的方式,可以从变量中减去任何值:

**let cookies = 12;**
**cookies -= 5;**
7 

在这种情况下,cookies -= 5cookies = cookies - 5的简写。

乘法与除法赋值运算符

乘法和除法赋值运算符,*=/=,通过将变量的值乘以或除以指定的数字来更新变量的值。例如:

**let tribbles =** **6;**
**tribbles *= 2;**
12
**tribbles /= 3;**
4 

与其他简写赋值运算符类似,这些赋值运算符分别转换为tribbles = tribbles * 2tribbles = tribbles / 3

字符串

JavaScript 使用字符串数据类型来表示文本。之所以叫字符串,是因为文本被当作一串字符来处理。例如,字符串Hello!由六个字符组成:H、e、l、l、o 和 !。

字符串字面量是字符串值的直接表示。要创建一个字符串字面量,只需将一些文本用双引号括起来。引号之间的每个字符都是字符串的一部分。例如,这里我们将字符串字面量"Hello!"赋值给变量greeting。然后我们检查变量的值:

**let greeting = "Hello!";**
**greeting;**
'Hello!' 

你也可以使用单引号而不是双引号来书写字符串字面量。事实上,正如这个例子所示,当 Chrome 的 JavaScript 控制台输出一个字符串时,它会将该字符串用单引号括起来(即使你是用双引号书写的)。为了保持一致性,我将在本书中书写字符串时坚持使用双引号,尽管我也会使用单引号来准确反映控制台的输出。

通常,字符串主要由字母组成,但正如我们在“Hello!”的示例中看到的,字符串也可以包含标点符号。字符串还可以包含空格以及数字,举例如下:

**let price = "5 dollars";**
**price;**
'5 dollars' 

甚至可以有一个只包含数字的字符串,如 "123",但区分这个字符串和实际的数字是很重要的。字符串字面量 "123" 是三个字符的序列,1、2 和 3,而数字字面量 123 的数值为 123。

JavaScript 提供了很多操作字符串的方法。接下来我们将查看一些这些字符串操作。

连接字符串

当应用于字符串而不是数字时,+ 操作符会将字符串连接在一起。这样,你可以通过组合不同的字符串来构建一个更长的消息。以下是一个示例:

**let first = "First string";**
**let second = "Second string";**
**let joined = first +** **second;**
**joined;**
'First stringSecond string' 

在这里,我们将两个字符串赋值给变量 first 和 second。然后,我们使用 + 将这些字符串连接起来,将结果存储在变量 joined 中。请注意,JavaScript 在连接字符串时不会自动添加空格——它只是直接将第二个字符串附加到第一个字符串的末尾。如果你希望它们之间有空格,必须通过将空格当作一个独立的字符串显式地添加进去:

**first + " " + second;**
'First string Second string' 

在这个示例中,我们连接了三个字符串字面量:

1.  "第一个字符串"

2.  " "(由一个空格组成的字符串)

3.  "第二个字符串"

结果是,我们在字符串和 Second 之间得到了一个空格。

计算字符串的长度

你经常需要检查字符串的长度。例如,如果你在制作一个评论网站,你可能希望将评论的长度限制为 1,000 个字符。要查找字符串中有多少个字符,在字符串后面加上 .length 来访问其长度属性。(属性是关于代码中某个对象的信息;我们将在第三章中详细讨论属性。)在这里,我们使用 .length 来确认字符串 "abc" 有三个字符:

**"abc".length;**
3 

在这种情况下,我们在字符串字面量上使用了 .length, 但你也可以在包含字符串的变量或常量上使用它,如下所示:

**let longString = "This is my very long string";**
**longString.length;**
27 

这段代码统计了字符串中所有字符的数量,包括空格,字符串绑定到变量名 longString。

从字符串中获取字符

要从字符串中获取单个字符,请使用该字符的 索引。这是一个表示字符在字符串中位置的数字。JavaScript 总是从零开始计数,因此索引 0 表示字符串中的第一个字符,索引 1 表示第二个字符,以此类推。这就是 零基索引

将所需的索引放入方括号中以访问该索引位置的字符,如下所示:

**let alphabet = "ABCDEFG";**
**alphabet[0];**
'A'
**alphabet[1];**
'B' 

这里,alphabet[0] 获取存储在变量 alphabet 中的字符串的第一个字符,alphabet[1] 获取第二个字符。

如果你使用超出字符串长度的索引,表达式将返回 undefined:

**alphabet[10];**
undefined 

字符串 alphabet 只有七个字符,索引范围为 0 到 6,所以alphabet[10]超出了范围。

从字符串中获取多个字符

要从字符串中获取多个字符(也称为切片),而不仅仅是单个字符,可以使用切片方法。方法是一种特定类型的函数,附加在特定的值或数据类型上。(正如我们在第一章中讨论的,函数是执行任务的命名代码块。)方法通常用于对其所附加的对象进行计算,或更新其某些内容。在这个例子中,slice 是与字符串数据类型相关联的多种方法之一,你可以用它来操作给定的字符串。

使用或调用方法的语法是,在你想应用方法的值或变量后面加一个句点,然后是方法名,再加上一对括号。在括号内,你写入方法需要的任何值,这些值用逗号分隔。和其他函数一样,这些值被称为参数。方法也可以返回一个值,称为方法的返回值

切片方法接受两个参数,即你想提取的切片的起始索引(包含)和结束索引(不包含),并返回包含指定范围字符的子字符串。以下是一个示例:

**let sentence = "My name is Nick.";**
**sentence.slice(3, 7);**
'name' 

在这里,我们将一个字符串存储在变量 sentence 中,然后通过写sentence.slice(3, 7)来调用该字符串的切片方法。括号中的第一个参数意味着我们希望切片从字符串的索引 3 开始(即第四个字符,“name”中的 n)。第二个参数意味着我们希望切片到达字符串的索引 7,但不包括该位置(即“name”后的空格)。最终结果是,切片方法返回索引 3、4、5 和 6 的字符,得到字符串"name"。

注意

我们将在第五章中更详细地讨论函数,一般而言,并在第六章中专门讨论方法。

修剪字符串中的空白字符

空白字符指的是打印时不需要任何墨水的字符,如空格或制表符。trim 方法删除字符串开头和结尾的所有空白字符,并返回一个去除空白的新字符串。这在某些情况下很有用,例如当你从用户那里获取输入时,他们不小心在开始或结束处加了几个空格,如下所示:

**let inputText = " Here is my input  ";**
**inputText.trim();**
'Here is my input'
**inputText;**
❶ ' Here is my input    ' 

存储在变量 inputText 中的字符串在单词 Here 之前有一个空格,在单词 input 后面有三个额外的空格。当我们通过写 inputText.trim() 来调用 trim 方法时,我们得到一个新字符串,其中这些空格被移除了。但是请注意,单词之间的空格没有受到影响;只有字符串开头和结尾的空格被去除。trim 方法不需要任何参数,所以我们只需在方法名后写一个空括号。

因为 trim 方法返回一个新的字符串,所以原始字符串 inputText 保持不变。我们可以通过查看代码清单末尾的 inputText 的值来看到这一点:输出结果仍然包含字符串开头和结尾的空格 ❶。

其他有用的字符串方法

除了 slice 和 trim,JavaScript 还有更多可用的字符串方法。我不会详细讲解所有方法,但这里列出了一些有用的方法:

str.toLowerCase()    返回一个新的字符串,将 str 中的所有大写字母转换为小写字母。

str.includes(otherStr)    如果 str 包含作为 otherStr 参数传入的字符串,则返回 true。

str.padStart(num, char)    返回一个新的字符串,该字符串至少有 num 个字符,如果原字符串长度小于 num,则在字符串的开头添加必要次数的 char 字符。

str.repeat(count)    返回一个新的字符串,str 重复 count 次。

转义序列

有时候你可能想在字符串中插入特殊字符,比如插入换行符以在字符串中间创建换行,或者插入制表符以创建更宽的水平间距。JavaScript 允许你通过转义序列来插入这些字符。转义序列是一串字符,始终以反斜杠(\)开头,表示将被转换为另一个字符。例如,要在字符串中插入换行符,可以使用 \n 转义序列:

**"Hello\nWorld";**
'Hello\nWorld' 

无论好坏,当 Chrome 的 JavaScript 控制台评估一个包含特殊字符的字符串并输出结果时,特殊字符会保持其转义形式。要查看该字符串如何正确解释转义序列,请将字符串作为参数传递给 console.log 方法。该方法将数据输出到控制台,包括任何必要的格式化。例如:

**console.log("Hello\nWorld");**
Hello
World 

该方法的输出显示了 \n 转义序列如何被解释为字符串中两个单词之间的换行符。

表 2-1 列出了你最常使用的一些转义序列。

表 2-1: 常见转义序列
转义序列
'
"
\
\n
\t

如表所示,如果你想在字符串中包含实际的反斜杠字符,你需要使用 \. 同样,如果你想在双引号字符串中包含双引号字符,你需要使用 "。例如:

**console.log("This string has \"double quotes\" and a \\ backslash character");**
This string has "double quotes" and a \ backslash character 

当你在双引号中编写字符串时,如果想使用单引号(例如,作为缩写中的撇号),就不需要使用 ' 转义序列。你可以直接写出单引号,如下所示:

**console.log("You don't need to escape single quotes");**
You don't need to escape single quotes 

同样,当你在单引号内编写字符串时,如果想使用双引号,也不需要进行转义。

模板字面量

模板字面量 是一种特殊类型的字符串,可以评估其中嵌入的任何表达式。这让你可以灵活地动态填充字符串,插入变量的值、计算结果或其他代码,而不必一字一句地输入字符串中的每个字符,或者使用 + 运算符将多个变量组合成一个字符串。

模板字面量用反引号(`)包围,而不是用引号。你可以使用 占位符语法 来插入代码,语法如下:${}。占位符中的内容会被视为一个表达式,并在最终字符串被求值之前进行求值,如下所示:

**let name** **= "Nick";**
**`Hello, ${name}!`;**
'Hello, Nick!' 

在这里,name 变量的值会被插入到字符串中,而不是 ${name} 占位符,最终得到字符串 "Hello Nick!"。如果我们更改 name 的值,相同的模板字面量会生成不同的字符串:

**name = "Dolly";**
**`Hello, ${name}!`;**
'Hello, Dolly!' 

你可以在占位符的大括号中放置任何表达式,而不仅仅是简单的变量。表达式会被求值,结果会插入到字符串中。例如:

**`There are ${60 * 60 * 24} seconds in a day`;**
'There are 86400 seconds in a day' 

在这种情况下,JavaScript 会计算 60 * 60 * 24 的值,将其转换为字符串,并将其包含在计算后的字符串中。

模板字面量有很多应用,比如从用户那里获取文本输入并将其插入到新字符串中。当你需要基于多个变量构造字符串时,它们特别有用。例如,假设你正在构建一个类似 Mad Libs 的 Web 应用程序,用户输入不同词性的单词并将其组合成句子。用户已经输入了以下三个单词,这些单词存储在不同的变量中:

**let noun = "moon";**
**let adverb = "strangely";**
**let adjective = "red";** 

如果没有模板字面量,你就得通过反复使用 + 运算符将变量合并成一个字符串:

**"The " + noun** **+ " was " + adverb + " " + adjective + ".";**
'The moon was strangely red.' 

这段代码写起来相当繁琐,特别是当你想在每个单词之间加一个空格,并在句末加一个句号时。使用模板字面量要简单得多:

**`The ${noun} was ${adverb} ${adjective}.`;**
'The moon was strangely red.' 

除了让代码更容易编写外,模板字面量还使其更容易阅读。这样可以更清楚地看到代码是在将一个自定义名词、副词和形容词插入到句子中。

未定义和空值

在 JavaScript 中,undefinednull有特殊的含义:它们代表。当 JavaScript 无法为某个值提供数据时,默认返回undefined。例如,正如你在本章前面看到的,如果你声明一个变量但没有给它赋值,JavaScript 会自动将其赋值为undefined

**let nothing;**
**nothing;**
undefined 

当你执行一个没有有用返回值的函数时,比如我们在第一章中使用的alert函数,它会在控制台中返回undefined,用于弹出一个对话框:

**alert("I have no value.");**
undefined 

而 JavaScript 会在某个值没有定义时自动使用undefined,程序员通常使用null来明确标记某个值为空。例如,如果你写的代码需要获取用户输入的地址,而用户没有提供地址,你可以将地址变量设置为null

**let address = null;**
**address;**
null 

从功能上讲,将地址设置为null与将变量留空(即未定义)并没有本质区别,但它让你的意图更加明确。其他阅读你代码的人会看到你故意将地址标记为空值,而不是因为它还没有定义。

布尔值

我们将要考虑的最后一种原始数据类型是布尔值类型,它表示真/假值。布尔值只有两个字面量:truefalse。在这里,我们创建了一个布尔变量并通过字面量确认其值:

**let playing = true;**
**playing;**
true 

在这个例子中,我们声明了一个名为playing的新变量,并将其初始化为布尔字面量true。你可以想象这是一个游戏中的代码,playing变量告诉我们游戏当前是处于活动状态(true)还是暂停状态(false)。

布尔值是编程中至关重要的一部分,因为它们为我们提供了一种讨论逻辑的方式。如果你希望程序根据某个特定条件作出不同的行为,你需要能够判断这个条件是否成立——即,是否为真或假。例如,如果你在开发一个视频流网站,可能需要判断当前用户是否未满 18 岁。如果是的话,可能会隐藏某些内容。在这种情况下,你会使用布尔值来决定是否隐藏这些内容。

布尔值有多种相关运算符,它们分为两类:逻辑运算符,它们接受布尔值并返回布尔值;以及比较运算符,它们可以接受其他类型的值,如数字和字符串,并返回布尔值。

逻辑运算符

有三个布尔逻辑运算符:。与运算符 (&&) 接受两个值,称为操作数,只有在两个操作数都为真时才返回 true。这对于任何需要两个条件都为真才能发生某事的情况都很有用。让我们继续用游戏示例,假设我们正在为一个平台游戏编写逻辑。在这个游戏中,你可以发射火球,但只有在你有一个道具并且你正在跳跃时。下面是如何在代码中表达这一点:

**let powerup** **= true;**
**let jumping = true;**
**powerup && jumping;**
true 

在这种情况下,powerup 为 true,jumping 为 true,因此 powerup && jumping 也为 true。然而,如果你将这两个变量中的任何一个(或两个)设置为 false,powerup && jumping 也会变为 false:

**jumping = false;**
**powerup && jumping;**
false 

或运算符 (||) 如果任意一个操作数为真时会返回 true。这在只有多个条件中的一个需要为真时非常有用。例如,在我们的游戏中,假设你死了如果火球击中了你或者你碰到了怪物:

**let hitByFireball = false;**
**let touchedMonster = true;**
**hitByFireball || touchedMonster;**
true 

因为两个操作数中的一个为真,表达式 hitByFireball || touchedMonster 为真。如果两个操作数都为真,|| 运算符也会返回 true。

非运算符 (!) 只接受一个操作数,并返回其值的反值,所以 !true 为 false,!false 为 true。如果你希望在条件为真时发生某些事情,这非常有用。例如,在我们的游戏中,我们可能有一个名为 alive 的变量,它告诉我们玩家是否还活着。游戏应在玩家死亡时结束——即当 !alive 为真时(意味着 alive 本身为假):

**let alive = false;**
**!alive;**
true 

不同的逻辑运算符通常会组合在一起,形成更复杂的逻辑表达式。例如,假设在我们的游戏中,只有在你没有搬箱子并且没有游泳时,你才能跳跃。在这种情况下,我们将使用两个布尔变量来表示搬箱子和游泳,使用 ! 来反转这两个变量,然后使用 && 来检查这两个反转后的变量,如下所示:

**let carryingBox = true;**
**let swimming = false;**
**!carryingBox && !swimming;**
false 

你没有游泳,但你正在搬箱子,所以 && 运算符返回 false,意味着你不能跳跃。

有时更复杂的逻辑表达式很难阅读,所以让我们来看一下 JavaScript 在计算 !carryingBox && !swimming 表达式时的步骤。首先,为了简化,替换变量名为它们当前设置的布尔值:

!true && !false

接下来,替换表达式 !true 和 !false 为它们的等效值,移除 ! 运算符:

false && true

现在我们只需要记住 && 仅在两个操作数都为真时返回 true。在这种情况下,其中一个操作数为假,所以我们知道这个表达式的值为 false。

处理像这样的布尔表达式有一个有用的技巧。表达式 !a && !b 可以重写为 !(a || b)。可以将其理解为将原始描述“既不搬箱子不游泳”重新表述为“不是(搬箱子游泳)”。这个技巧被称为德摩根定律(与人名无关!)。它也可以用来将 !a || !b 转换为 !(a && b)。

比较运算符

JavaScript 的比较运算符用于比较值,并根据比较结果返回布尔值。例如,=== 或 “三等号” 运算符用于检查两个值是否相等。如果相等,返回 true,否则返回 false。以下是一些 === 运算符的示例:

**5 === 5;**
true
**6 ===** **7;**
false
**2 + 2 === 4;**
true
**"hello" === "goodbye";**
false
**"hello" === "hel" + "lo";**
true
**false ===** **false;**
true
**true === false;**
false 

请注意,=== 不仅用于比较数字字面量;它还可以与数值表达式(如 2 + 2)、字符串字面量("hello")、字符串表达式("hel" + "lo")以及布尔值一起使用。它还可以比较存储在变量中的值:

**let answer = 2 + 2;**
**answer === 5;**
false 

这里,answer 被设置为 4,即 2 + 2 的值,因此与 5 的比较结果为 false。

=== 运算符的反义运算符是 !==(第一个 = 被 ! 替代)。该运算符检查两个值是否 相等。例如:

**8 !== 8;**
false
**"apples" !== "oranges";**
true 

使用 !== 运算符与使用 === 运算符相同,只是对结果应用了 ! 运算符:

**!(8 === 8);**
false
**!("apples" === "oranges");**
true 

JavaScript 的其他比较运算符用于检查一个值是否大于或小于另一个值。这些包括大于 (>)、小于 (<)、大于或等于 (>=) 和小于或等于 (<=) 等标准数学运算符。考虑以下示例:

**1 > -1;**
true
**10 > 10;**
false
**10 >= 10;**
true
**-1 < 1;**
true
**10 < 10;**
false
**10 <= 10;**
true 

特别注意,使用 > 或 < 比较相同的值时会返回 false,而使用 >= 或 <= 时则返回 true。

这些比较运算符也可以与字符串一起使用。如果一个字符串在字典中排列在另一个字符串之后,那么它被认为是“更大”的。举个例子:

**"cat" < "dog";**
true
**"abc" > "abbcdef";**
true 

第一个比较的结果为 true,因为 cat 的首字母在字母表中排在 dog 的首字母之前。在第二个比较中,两个字符串的前两个字符相同,但第三个字符上,c 在字母表中排在 b 后面,因此第一个字符串被认为更大。第二个字符串更长并不重要;JavaScript 会逐个字符地比较字符串,并在发现差异时立即停止比较。

类型强制转换

强制转换是自动将一种数据类型的值转换为另一种数据类型值的行为。在某些情况下,JavaScript 会使用强制转换,当不同数据类型的值出现在同一个表达式中时。例如,如果你在一个表达式中使用 + 运算符,且一边是字符串,另一边是数字,JavaScript 会将数字强制转换为字符串,然后将两个字符串连接在一起:

**"Current score: " + 10;**
"Current score: 10" 

注意,输出中的 10 被放在引号内,意味着它已经变成了字符串,而不是数字。这种类型的强制转换使得在字符串中轻松地嵌入数字,以便显示给用户。

在某些情况下,布尔值会被强制转换为数字,false 变为 0,true 变为 1。例如:

**100 + true;**
101 

在这里,我们在数学表达式中使用了布尔字面量 true 与数字进行运算,因此 JavaScript 将其强制转换为 1,然后加上 100 和 1 得到 101。

带强制转换的相等性

之前我们使用了三等号运算符(=)来检查相等性。还有另一个运算符,双等号(),它会在检查相等性之前对操作数进行强制类型转换。例如,如果你用==将一个数字与布尔值进行比较,布尔值会先被转换成数字:

**0 == false;**
true 

这个比较为真,因为布尔值 false 首先被强制转换为 0。然而,如果你使用三等号运算符进行相同的比较,它将为假,因为===不允许类型强制转换:

**0 ===** **false;**
false 

使用==运算符时,猜测什么会被强制转换成什么可能会很困难。以下是一些其他的例子:

**"1" == 1;**
true
**undefined == null;**
true
**undefined == false;**
false
**"" == 0;**
true
**"" == false;**
true 

当你将一个数字与一个由所有数字组成的字符串进行比较时,比如"1",该字符串会被强制转换为等效的数字,因此"1" == 1 变为 1 == 1,这个表达式的结果是 true。==运算符在比较 undefined 和 null 时也会返回 true,但如果 undefined 或 null 与其他任何值进行比较,它则返回 false。同时,空字符串——即一个不包含任何字符的字符串,用一对引号表示("")——被视为等同于数字 0 和布尔值 false。

!=运算符是的反义运算符。它会在适当的类型强制转换后判断两个操作数是否不相等。一些在使用严格的!运算符时为真的不等式,在使用强制转换的!=运算符时则变为假。例如:

**0 !****== false;**
true
**0 != false;**
false 

如果没有强制转换,0 不等于 false,因此 0 !== false 为真。然而,在强制转换的情况下,false 变为 0,因此 0 != false 为假。

了解和!=运算符非常重要,但由于类型强制转换的规则复杂,我建议尽可能坚持使用严格的=和!==运算符。这样,你的代码更不容易出现意外行为。

真值性

真值性是一种特殊的类型强制转换,定义了非布尔值如何被视为布尔值。这使得像&&和!这样的逻辑运算符可以作用于任何类型的值。运算符的工作方式取决于 JavaScript 是否将该值视为真值(等同于 true)或假值(等同于 false)。假值包括 undefined、null、数字 0 和空字符串("")。所有非零数字和非空字符串都被视为真值。

检查一个值是真值还是假值的最简单方法是使用!!对其进行两次取反操作,意味着“非非”,即双重否定。之所以有效,是因为!运算符始终返回布尔值,无论它作用于哪种数据类型。例如,假设你想验证数字 0 是否为假值。单一的!操作将数字强制转换为布尔值,然后取反该布尔值,因此!0 的结果为 true:

**!0;**
true 

添加第二个!再次反转布尔值,给出原值的布尔等效:

**!!0;**
false 

这确认了 0 等价于 false,或者说是假的。

你可以使用相同的!!技巧来检查我之前提到的其他真值规则:

**!!1;**
true
**!!"hi";**
true
**!!"";**
false
**!!undefined**
false
**!!null;**
false 

输出确认了非零数字和非空字符串为真值,而空字符串、未定义和 null 为假值。

当&&和||运算符应用于非布尔值时,它们不会返回 true 或 false 值。相反,它们返回原始操作数之一。对于&&运算符,如果第一个操作数为真值,则返回第二个操作数。如果第一个操作数为假值,则返回第一个操作数。以下是一些示例:

**15 && 17;**
17
**0 && 20;**
0
**undefined && null;**
undefined 

在第一个案例中,15 是一个真值,因此返回 17。在另外两个案例中,第一个操作数是假的,因此分别返回 0 和 undefined。

||运算符的工作方式相反。如果第一个操作数是假的,则返回第二个操作数,如果第一个操作数为真值,则返回第一个操作数,如下所示:

**"" || "hello";**
'hello'
**"hello" || "goodbye";**
'hello' 

在第一个案例中,第一个操作数是一个空字符串,这是假的,因此返回第二个操作数。在第二个案例中,第一个操作数是一个非空字符串,这是一个真值,因此返回第一个操作数。

真值的应用

你可以利用&&和||在许多情况下与真值和假值的行为。例如,||运算符可以用于在未提供值时为变量设置默认值。这在用户在表单中忘记输入名字时很有用:

**let name;**
**name =** **name || "No name provided";**
**name;**
'No name provided' 

在这个示例的开始,name 在没有赋值的情况下被创建,因此它是未定义的。然后,我们使用布尔表达式 name || "未提供名字"为其赋值。由于第一个操作数是假的,因此返回第二个操作数。结果,name 被赋予默认值"未提供名字"。另一方面,如果提供了名字,name 将被视为真值,因此它将保留原值:

**let name =** **"Nick";**
**name = name || "No name provided";**
**name;**
'Nick' 

同样,你可以使用&&或||来短路,即跳过一个表达式。使用&&时,如果第一个操作数为假值,则会评估并返回该操作数,因此 JavaScript 不会再评估第二个操作数。当操作数只是简单的值时,我们不太关心它们是否被评估;我们关心的只是哪个值被返回。例如,在表达式 1 || 2 + 2 中,JavaScript 是否计算 2 + 2 的结果并不重要,因为我们知道第一个操作数 1 会被返回。然而,当一个表达式具有某种副作用时,它是否被评估就非常重要了,这意味着评估它除了返回一个值之外,还会做其他事情。例如,alert 函数返回 undefined 值,但更重要的是它有显示对话框的副作用。但是,如果我们只希望在某些情况下显示对话框怎么办?

举个例子,假设我们想使用 alert 显示游戏中玩家的分数,但只有当分数不为零时才显示。我们可以将 score 变量和 alert 函数作为 && 表达式的操作数:

**let score = 0;**
**score && alert(`Your score is ${score}!`);**
0 

这里 score 是 0,因此 && 表达式中的第一个操作数是假的。所以,&& 运算符返回这个值并忽略第二个操作数,意味着 alert 函数没有被调用。我们已经短路了函数调用。

现在,考虑一下如果 score 增加会发生什么:

**++score;**
1
**score && alert(`Your score is ${score}!`);**
undefined 

这里我们使用 ++ 来增加 score,将其值更改为 1。这使得 score 为真,因此 && 表达式中的第二个操作数被评估,执行了 alert 函数。该函数返回 undefined,但也具有显示玩家分数对话框的(期望的)副作用(参见图 2-1)。

图 2-1:alert 函数的副作用是在对话框中显示消息。

本质上,我们使用 && 运算符来决定是否根据条件(分数是否为 0)运行某些代码(alert 函数)。在第四章中,我们将研究控制结构,如 if 语句,这些控制结构提供了更明确的方式来控制代码是否以及如何执行。

总结

本章介绍了 JavaScript 编程中的一些基础构建块。你了解了语句是 JavaScript 中的完整思想,通常以分号结尾,指示计算机执行某些操作,并且一个语句可以包含一个或多个表达式(代表值的代码单元)。你学习了如何使用绑定将值命名以便以后使用,可以是变量,这样值可以稍后更新,或者是常量,这样值保持不变。

你还学习了 JavaScript 中的三种基本数据类型:数字、字符串和布尔值。你对数字进行了数学运算,包括使用递增(++)和递减(--)等简写操作符对数字进行增减,并且练习了使用各种方法操作字符串,包括切片和修剪空格。在布尔值方面,你学习了如何使用逻辑运算符,如与(&&)、或(||)和非(!),还了解了如何通过比较运算符(如 === 和 !==)生成布尔值。最后,你学习了 JavaScript 如何将一种数据类型的值强制转换为另一种数据类型,包括非布尔值如何被处理为真值或假值,并探讨了在一些场景下这如何派上用场,比如短路表达式。

第十章:3 复合数据类型

在上一章中,我们讨论了 JavaScript 的原始数据类型,它们代表单一的数据项,比如数字或字符串。现在我们将了解 JavaScript 的复合数据类型,即数组和对象,它们将多个数据项组合成一个单元。复合数据类型是编程中不可或缺的一部分,因为它们使我们能够组织和处理任意大小的数据集合。你将学习如何创建和操作数组与对象,并如何将它们组合成更复杂的数据结构。

数组

JavaScript 的数组是一种复合数据类型,用于存储有序的值列表。数组的元素可以是任何数据类型,它们不必全是相同的类型,尽管它们通常是。例如,一个数组可以作为待办事项清单,存储一系列描述需要完成的任务的字符串,或者它也可以存储一个数字集合,表示从特定位置定期测量的温度读数。

数组非常适合这些结构,因为它们将相关的值集合在一起,并且随着值的增加或删除,它们具有增长和缩小的灵活性。如果你有固定数量的待办事项——比如四个——你可能会使用单独的变量来存储它们,但使用数组可以让你存储一个无限、可变化的项目数,并保持它们的固定顺序。此外,一旦将元素聚集在一个数组中,你就可以编写代码,高效地依次操作数组中的每个项目,正如你将在第四章中看到的那样。

创建与索引

要创建一个数组,将其元素用逗号分隔,并放在一对方括号内:

**let primes = [2, 3, 5, 7, 11, 13, 17, 19];**
**primes;**
`(8) [2, 3, 5, 7, 11, 13, 17, 19]` 

这个数组包含了前八个素数,并存储在 primes 变量中。当你输入 primes;时,Chrome 控制台应该会打印出数组的长度(8),后面跟着它的元素。

数组中的每个元素都有一个与之关联的索引号。像字符串一样,数组是零索引的,因此第一个元素位于索引 0,第二个元素位于索引 1,依此类推。要访问数组中的单个元素,可以在数组名称后加上其索引号并用方括号括起来。例如,这里我们访问了 primes 数组的第一个元素:

**primes[0];**
2 

因为数组是零索引的,所以数组最后一个元素的索引比数组的长度少 1。因此,我们八个元素的 primes 数组的最后一个元素位于索引 7:

**primes[7];**
19 

如果你不知道数组的长度,并且想要获取它的最后一个元素,可以先使用点符号访问其 length 属性,并查看数组的长度,就像我们在第二章中操作字符串一样:

**primes.length;**
8
**primes[7];**
19 

或者,为了在一个语句中做到这一点,你可以简单地从长度中减去 1 来获取最后一个索引处的元素,像这样:

**primes[primes.length - 1];**
19 

如果你使用了超出数组范围的索引,JavaScript 将返回 undefined:

**primes[10];**
undefined 

要替换数组中的一个元素,可以使用索引语法为元素赋予一个新值:

**primes[2] = 1;**
**primes;**
`(8) [2, 3, 1, 7, 11, 13, 17, 19]` 

这里我们在质数数组的第三个位置(索引 2)添加了一个 1,替换了之前在该索引位置的值。控制台输出确认 1 是数组的新第三个元素。

数组的数组

数组可以包含其他数组。这些多维数组通常用于表示二维点阵或表格。为了说明这一点,让我们制作一个简单的井字游戏。我们将创建一个数组(我们将其称为外部数组),其中包含三个元素,每个元素都是另一个数组(我们将这些称为内部数组),代表井字棋盘的每一行。每个内部数组将包含三个空字符串,表示该行中的方格:

**let ticTacToe = [**
 **["", "", ""],**
 **["", "", ""],**
 **["", "", ""]**
**];** 

为了使代码更易读,我将每个内部数组放在了新的一行。通常,当你按下 ENTER(通常是为了开始新的一行)时,JavaScript 控制台会运行你刚刚输入的代码行,但在这种情况下,它足够聪明,能够意识到第一行没有完成,因为没有关闭的方括号来匹配开括号。它会将直到最后一个闭括号和分号的所有内容解释为一个单一语句,即使你包含了额外的括号和回车符。

注意

Chrome 控制台自动为内部数组应用缩进,以表明它们嵌套在外部数组中。Chrome 和 VS Code 默认为每一层缩进使用四个空格,但这只是个人偏好的问题。在本书中,我将使用两个空格进行缩进,因为这在现代 JavaScript 代码中更为常见,也因为它能帮助一些较长的代码更好地适应页面。

我本可以将这个数组写在一行中,如此显示,但这样更难看出它的二维结构:

**let ticTacToeOneLine = [["", "", ""], ["", "", ""], ["", "", ""]];**

现在让我们看看当我们请求控制台输出 ticTacToe 变量的值时会发生什么:

**ticTacToe;**
`(3) [Array(3), Array(3), Array(3)]` 

在这种情况下,外部数组的长度显示为(3),表示它是一个包含三个元素的数组。数组的每个元素是 Array(3),这意味着每个内部数组是另一个包含三个元素的数组。

为了展开视图并查看内部数组中的内容,点击左侧的箭头:

`(3) [Array(3), Array(3), Array(3)]`
  0: (3) ['', '', '']
  1: (3) ['', '', '']
  2: (3) ['', '', '']
   length: 3
  [[Prototype]]: Array(0) 

前三行显示了索引为 0、1 和 2 的内部数组的值。在这些之后,显示了外部数组的长度属性,值为 3。最后的属性[[Prototype]],是数组内置方法的来源(更多内容请参见第六章)。

我们已经创建了井字棋棋盘,但它是空的。让我们在右上角设置一个 X。第一个内部数组代表的是上排,我们可以通过 ticTacToe[0]来访问它。右上角是这一行的第三个元素,或者说是内部数组的索引 2。由于 ticTacToe[0]返回的是一个数组,我们只需在后面加上[2]来访问我们想要的元素:ticTacToe[0][2]。知道这一点后,我们可以按如下方式将这个元素设置为"X":

**ticTacToe[0][2] = "X";**

现在,让我们再次查看 ticTacToe 的值,点击箭头展开外部数组:

**ticTacToe;**
(3) [Array(3), Array(3), Array(3)]
  0: (3) ['', '', 'X']
  1: (3) ['', '', '']
  2: (3) ['', '', '']
   length: 3
  [[Prototype]]: Array(0) 

井字棋的右上角现在包含一个 X。

接下来,让我们在左下角设置一个 O。底行是外部数组的索引 2,这一行最左边的方格是内部数组的索引 0,所以我们输入如下内容:

**ticTacToe[2][0] = "O";**
**ticTacToe;**
(3) [Array(3), Array(3), Array(3)]
  0: (3) ['', '', 'X']
  1: (3) ['', '', '']
  2: (3) ['O', '', '']
   length: 3
  [[Prototype]]: Array(0) 

现在,板子的左下角有一个 O。

总结一下,如果你想访问嵌套数组中的元素,可以使用一组方括号来选择外部数组中的元素(这会返回其中一个内部数组),然后再用第二组方括号选择内部数组中的元素。

数组方法

JavaScript 有几个用于处理数组的有用方法。在本节中,我们将看一些重要的方法。这些方法中的一些会修改目标数组,这被称为变异。变异的示例包括添加或删除数组元素,或改变元素的顺序。其他方法则创建并返回一个新的数组,同时保持原始数组不变,这在你还需要原始数组用于其他目的时非常有用。

需要注意的是,你使用的方法是否会改变数组。例如,假设你有一个包含按顺序列出月份的数组,但你程序的某一部分需要按字母顺序排列这些月份。你需要确保将月份按字母顺序排列时,不会无意中改变原始的按顺序排列的数组,否则程序的其他部分可能会误认为四月是第一个月。另一方面,如果你有一个表示待办事项的数组,当添加或删除任务时,你可能希望更新原始数组,而不是创建一个新的数组。

向数组添加元素

push 方法通过将提供的元素添加到数组的末尾来改变数组。push 方法的返回值是数组的新长度。举个例子,让我们用 push 来构建一个编程语言的数组:

**let languages = [];**
**languages.push("Python");**
1
**languages.push("Haskell");**
2
**languages.push("JavaScript");**
3
**languages.push("Rust");**
4
**languages;**
`(4) ['Python', 'Haskell', 'JavaScript', 'Rust']` 

首先,我们创建一个名为 languages 的新数组,并用[](一个空数组)初始化它。第一次调用 push 方法时,我们传入值"Python"。该方法返回 1,表示数组中现在有一个元素。我们再做三次相同的操作,最后通过输入 languages;来查看 languages 的值。这将返回我们按顺序添加到数组中的四个编程语言。

若要将元素添加到数组的开头而不是末尾,请使用 unshift 方法,如下所示:

**languages.unshift("Erlang");**
5
**languages.unshift("C");**
6
**languages.unshift("Fortran");**
7
**languages;**
`(7) ['Fortran', 'C', 'Erlang', 'Python', 'Haskell', 'JavaScript', 'Rust']` 

这里我们向 languages 数组的开头添加了三个语言。因为每个元素都被添加到数组的开头,它们最终的顺序与添加时的顺序相反。像 push 一样,调用 unshift 会返回数组的新长度。

从数组中移除元素

要通过移除数组的最后一个元素来改变数组,请使用 pop 方法。这里我们在 languages 数组上调用 pop 方法,删除它的最后一个元素:

**languages.pop();**
'Rust'
**languages;**
`(6) ['Fortran', 'C', 'Erlang', 'Python', 'Haskell', 'JavaScript']` 

该方法返回被移除元素的值,在此例中为“Rust”。当我们检查数组时,它只包含六个元素。

因为 pop 方法返回被移除的数组元素,所以如果你在移除元素时想要对其进行操作,它特别有用。例如,这里我们从 languages 数组中删除另一个元素,并在消息中使用它:

**let bestLanguage = languages.pop();**
**let message = `My favorite language is ${bestLanguage}.`;**
**message;**
'My favorite language is JavaScript.'
**languages;**
`(5) ['Fortran', 'C', 'Erlang', 'Python', 'Haskell']` 

这次我们调用 languages.pop() 时,将方法的返回值存储在 bestLanguage 变量中,并使用模板字面量将其嵌入到字符串中。当我们打印结果消息时,它包含了 JavaScript 这个单词。这个元素是从数组中移除的,现在数组只剩下五个语言。

若要移除数组中的第一个元素,而不是最后一个元素,请使用 shift 方法。像 pop 一样,shift 方法返回被移除的元素:

**let worstLanguage = languages.shift();**
**message = `My least favorite language is ${worstLanguage}.`;**
**message;**
'My least favorite language is Fortran.'
**languages;**
`(4) ['C', 'Erlang', 'Python', 'Haskell']` 

与之前的例子一样,我们将调用 shift 的结果保存到一个变量中,这次叫做 worstLanguage,并将其用于模板字面量中。该变量包含字符串“Fortran”,而 languages 数组剩下四个元素。

到目前为止我们查看过的四种方法,popunshiftpushshift,通常用于实现更专业的数据结构,如队列。队列是一种数据结构,类似于排队的人群,新项被添加到队列的末尾,而项则从队列的开头被移除和处理。这在你需要按照到达顺序处理数据时非常有用。例如,想象一个问答应用,很多用户可以提问。你可以使用一个数组来存储问题列表,push 方法将每个新问题添加到数组的末尾。当回答者准备好回答问题时,他们可以使用 shift 方法获取数组中的第一个元素并将其移除。这样可以确保数组中只有未回答的问题,并且它们会按照收到的顺序进行回答。

合并数组

concat 方法(连接的缩写)将两个数组合并在一起。例如,这里我们从两个数组 fishmammals 开始,并将它们合并成一个新数组,然后将其保存到 animals 变量中:

**let fish = ["Salmon", "Cod", "Trout"];**
**let mammals = ["Sheep", "Cat", "Tiger"];**
**let animals = fish.concat(mammals);**
**animals;**
`(6) ['Salmon', 'Cod', 'Trout', 'Sheep', 'Cat', 'Tiger']` 

当你在一个数组上调用 concat 时,会创建一个新数组,其中包含第一个数组(你调用 concat 的数组)中的所有元素,接着是第二个数组(作为参数传递给 concat 的数组)中的所有元素。原始数组保持不变,因为与我们迄今为止看到的其他方法不同,concat 并不是一个改变原数组的方法。这在这里非常有用,因为我们不希望我们的鱼类数组突然包含哺乳动物的元素!

要将三个或更多数组合并,传递多个数组作为 concat 的参数,如这个例子所示:

**let originals = ["Hope", "Empire", "Jedi"];**
**let prequels = ["Phantom", "Clones", "Sith"];**
**let sequels = ["Awakens", "Last", "Rise"];**
**let starWars = prequels.concat(originals, sequels);**
**starWars;**
`(9) ['Phantom', 'Clones', 'Sith', 'Hope', 'Empire', 'Jedi', 'Awakens', 'Last', 'Rise']` 

在这里,我们创建了三个单独的数组:originals、prequels 和 sequels,代表三套星球大战电影。然后,我们使用 concat 将它们合并成一个包含九个元素的 starWars 数组。注意,合并后的数组中的元素按传递数组作为参数的顺序出现。

查找数组中元素的索引

要找出数组中某个特定元素的位置,可以使用 indexOf 方法。该方法返回指定元素第一次出现的索引。如果元素在数组中没有找到,indexOf 返回 -1:

**let sizes = ["Small", "Medium", "Large"];**
**sizes.indexOf("Medium");**
1
**sizes.indexOf("Huge");**
-1 

在这个例子中,我们要检查 "Medium" 在 sizes 数组中的位置,并且得到了答案 1。然后,因为 "Huge" 不在数组中,我们得到了答案 -1。

如果数组包含多个指定值的实例,indexOf 只会返回第一个匹配元素的索引。例如,这里有一个包含阿根廷国旗颜色的数组:

**let flagOfArgentina = ["Blue", "White", "Blue"];**
**flagOfArgentina.indexOf("Blue");**
0 

即使 "Blue" 在数组中出现了两次,indexOf 只会返回第一次出现的索引。

将数组转换为字符串

join 方法将一个数组转换成一个单一的字符串,将所有元素连接在一起,如下所示:

**let beatles = ["John", "Paul", "George", "Ringo"];**
**beatles.join();**
'John,Paul,George,Ringo' 

注意,beatles 数组中的独立字符串如何合并成一个字符串。默认情况下,join 会在每个元素之间放置一个逗号来形成返回的字符串。要更改这一点,你可以将你自己的分隔符作为参数传递给 join。例如,如果你希望元素之间没有任何内容,可以传递一个空字符串作为参数:

**beatles.join("");**
'JohnPaulGeorgeRingo' 

你可以传递任何有效的字符串作为分隔符。在下一个例子中,我们传递一个空格、一个与号和一个换行符转义字符,将每个元素放在自己的行上。正如你在第二章中学到的那样,我们必须使用 console.log 才能在 Chrome 中正确显示换行符:

**console.log(beatles.join("&\n"));**
John&
Paul&
George&
Ringo 

请记住,分隔符只出现在数组元素之间,而不是每个元素后面。这就是为什么在 Ringo 后面没有额外的与号和换行符。

如果你对包含非字符串值的数组使用 join,这些值会被转换为字符串,正如这个例子所示:

**[100, true, false, "hi"].join(" - ");**
'100 - true - false - hi' 

与之前的连接方法一样,结果是一个由分隔符(在本例中为 " - ")连接起来的长字符串。不同之处在于,非字符串值(例如数字 100 和布尔值 true 和 false)在连接之前必须自动转换为字符串。这个例子还展示了如何可以直接在数组字面量上调用数组方法,而无需先将数组保存到变量中。

其他有用的数组方法

以下是一些你可能想尝试的其他有用的数组方法:

arr.includes(elem)    根据给定的 elem 是否在 arr 数组中,返回 true 或 false。

arr.reverse()    反转数组中元素的顺序。这是一个变异方法,因此会修改原始数组。

arr.sort()    对数组元素进行排序,修改原数组。如果元素是字符串,它们会按字母顺序排序。否则,排序会像将元素转换为字符串后进行排序一样。

arr.slice(start, end)    通过从原数组中提取从索引 start 开始到索引 end 之前的元素来创建一个新数组。此方法等同于上一章介绍的字符串的 slice 方法。如果调用 slice() 时不带任何参数,则会将整个数组复制到一个新数组中。如果你需要使用像 sort 这样的变异方法,但又不想修改原数组,这个方法会很有用。

arr.splice(index, count)    从数组中删除从索引 index 开始的 count 个元素。

对象

对象是 JavaScript 中的另一种复合数据类型。它们与数组类似,都是用来存储一组值,但不同之处在于,对象使用称为 的字符串来访问值,而不是数字索引。每个键都与一个特定的值关联,形成一个 键值对

数组通常用于存储相同数据类型的有序元素列表,而对象通常用于存储关于单一实体的多个信息。这些信息通常并非全部是相同的数据类型。例如,表示一个人的对象可能包含该人的姓名(字符串)、年龄(数字)、是否已婚(布尔值)等信息。对象比数组更适合用于这种情况,因为每个信息片段都有一个有意义的名称——它的键——而不是一个通用的索引号。如果这些值 35 和 true 存储在表示人的数组中,且其索引分别为 1 和 2,那么它们的含义就不如存储在表示人的对象中,分别作为 "age" 和 "married" 键的值那样清晰。

创建对象

创建对象的一种方式是使用对象字面量,它由一对大括号({ 和 })组成,括起来的是一系列键值对,键值对之间用逗号分隔。每个键值对必须在键和值之间有一个冒号。例如,下面是一个名为 casablanca 的对象字面量,包含一些关于那部电影的信息:

**let casablanca = {**
 **"title": "Casablanca",**
 **"released": 1942,**
 **"director": "Michael Curtiz"**
**};**
**casablanca;**
`{title: 'Casablanca', released: 1942, director: 'Michael Curtiz'}` 

这里我们创建了一个包含三个键的对象:"title"、"released" 和 "director"。每个键都关联有一个值。我将每个键值对写在单独的行上,以便更容易阅读对象字面量,但这并不是严格必要的。正如你在后面的示例中会看到的,键值对也可以写在同一行。

所有对象的键都是字符串,但如果你的键是有效的标识符,通常做法是省略引号。有效标识符是指任何可以作为 JavaScript 变量名使用的一系列字符。标识符可以由字母、数字和字符 _ 和 $ 组成,但不能以数字开头。它也不能包含其他符号,如 *、( 或 #,也不能包含空白字符,如空格和换行符。这些字符 对象键中是允许的,但前提是键必须用引号括起来。例如:

**let obj = { key1: 1, key_2: 2, "key 3": 3, "key#4": 4 };**
**obj;**
`{key1: 1, key_2: 2, key 3: 3, key#4: 4}` 

这里 key1 和 key_2 是有效的标识符,因此不需要加引号。然而,key 3 包含空格,key#4 包含井号,因此它们是无效的标识符。它们必须用引号括起来,才能作为对象的键使用。

访问对象值

要获取与某个键关联的值,可以使用方括号括起来的字符串键来调用对象名:

**obj["key 3"];**
3
**casablanca["title"];**
'Casablanca' 

这就像访问数组元素的语法一样,只不过不使用数字索引,而是使用字符串键。

对于有效的标识符,可以使用点表示法代替方括号,键名跟在点后面:

**obj.key_2;**
2 

这对于无效标识符的键不起作用。例如,你不能写 obj.key 3,因为在 JavaScript 中,这看起来像是 obj.key 后面跟着一个空格和数字字面量 3。

注意,这种点表示法看起来就像我们用于访问字符串的 length 属性(在第二章中)和数组(在本章前面)的语法。那是因为它们是一样的!属性实际上就是键值对的另一种说法。在后台,JavaScript 将字符串视为对象,数组也是一种特殊的对象。当我们写出类似 [1, 2, 3].length 这样的代码时,我们说我们正在访问数组的 length 属性,但我们也可以说我们正在获取与数组 length 键关联的值。同样,当我们写出类似 casablanca.title 的代码时,我们通常说我们正在访问对象的 title 属性,而不是与 title 键关联的值。

设置对象值

要向对象中添加一个新的键值对,使用与查找值时相同的括号或点表示法。例如,这里我们设置了一个空字典对象,然后添加了两个定义:

**let dictionary = {};**
**dictionary.mouse = "A small rodent";**
**dictionary["computer mouse"] = "A pointing device for computers";**
**dictionary;**
`{mouse: 'A small rodent', computer mouse: 'A pointing device for computers'}` 

我们首先使用一对空大括号创建一个新的空对象。然后,我们设置两个新的键,“mouse”和“computer mouse”,并为每个键设置一个定义作为值。像之前一样,我们可以使用点表示法来访问有效的标识符 mouse,但对于“computer mouse”,由于它包含空格,我们需要使用括号表示法。

更改与已存在的键相关联的值遵循相同的语法:

**dictionary.mouse =** **"A furry rodent";**
**dictionary;**
`{mouse: 'A furry rodent', computer mouse: 'A pointing device for computers'}` 

输出确认鼠标的定义已经更新。

与对象一起工作

JavaScript 有很多用于处理对象的方法;我们将在这里讨论其中一些最常见的方法。与数组不同,数组的方法是直接在你想操作的数组上调用的,而对象的方法是作为静态方法调用的,方法格式是 Object.methodName(),并在括号内传入你想操作的对象作为参数。这里,Object 是一个构造函数,是一种用于创建对象的函数类型,而静态方法是直接在构造函数上定义的方法,而不是在某个特定对象上定义的方法。我们将在第六章中更详细地讨论构造函数。

获取对象的键

要获取对象的所有键的数组,使用静态方法 Object.keys。例如,这里是如何获取我猫的名字:

**let cats = { "Kiki": "black and white", "Mei": "tabby", "Moona": "gray" };**
**Object.keys(cats);**
`(3) ['Kiki', 'Mei', 'Moona']` 

cats 对象有三个键值对,其中每个键代表一只猫的名字,每个值代表该猫的颜色。Object.keys 返回的只是键,作为一个字符串数组。

Object.keys 在像这种情况下很有用,当你只需要从对象中获取其键的名称时。例如,你可能有一个对象来跟踪你欠朋友多少钱,其中键是朋友的名字,值是欠款金额。使用 Object.keys,你可以列出你正在跟踪的朋友的名字,从而大致了解你欠钱的人。

你可能会想,为什么 keys 是一个静态方法——也就是说,为什么我们需要通过 Object.keys(cats) 来调用它,而不是用 cats.keys()。为了理解这是为什么,考虑这个钢琴对象:

**let piano = {**
 **make: "Steinway",**
 **color: "black",**
 **keys: 88**
**};** 

该对象有一个名为"keys"的属性,表示钢琴上的按键数量。如果像 keys 这样的函数可以直接在钢琴对象本身上调用,属性名和方法名会发生冲突,这是不被允许的。JavaScript 除了 keys 之外,还有许多内建的对象方法,记住所有这些方法的名称以确保它们不会与对象的属性名冲突是非常繁琐的。为了解决这个问题,语言设计者将这些对象方法设置为静态方法。它们被附加到整体的 Object 构造器上,而不是附加到像猫(cat)或钢琴(piano)这样的单个对象上,因此不会发生命名冲突。

注意

数组没有这个问题。方法名必须是有效的标识符,这意味着它们不能以数字开头。因此,数组方法不可能与数组的数字索引发生冲突。

获取对象的键和值

要获取对象的键值,可以使用 Object.entries。这个静态方法返回一个包含二元素数组的数组,每个内层数组的第一个元素是键,第二个元素是值。下面是它的工作原理:

**let chromosomes = {**
 **koala: 16,**
 **snail: 24,**
 **giraffe: 30,**
 **cat: 38**
**};**
**Object.entries(chromosomes);**
`(4) [Array(2), Array(2), Array(2), Array(2)]` 

我们创建了一个包含四个键值对的对象,展示了各种动物的染色体数量。Object.entries(chromosomes)返回一个包含四个元素的数组,每个元素都是一个包含两个元素的数组。点击箭头以展开外部数组并查看完整内容:

`(4) [Array(2), Array(2), Array(2), Array(2)]`
  0: (2) ['koala', 16]
  1: (2) ['snail', 24]
  2: (2) ['giraffe', 30]
  3: (2) ['cat', 38]
   length: 4
  [[Prototype]]: Array(0) 

这表示每个内层数组包含原始对象的一个键作为第一个元素,关联的值作为第二个元素。

使用 Object.entries 将一个对象转换成数组,可以更方便地遍历对象的所有键值对,并对每个键值对依次进行处理。我们将在第四章中看到如何使用循环来实现这一点。

合并对象

Object.assign 方法允许你将多个对象合并成一个。例如,假设你有两个对象,一个给出一本书的物理属性,另一个描述书的内容:

**let physical = { pages: 208, binding: "Hardcover" };**
**let contents = { genre: "Fiction", subgenre: "Mystery" };** 

使用 Object.assign,你可以将这些独立的对象合并成一个整体的书籍对象:

**let book = {};**
**Object.assign(book, physical, contents);**
**book;**
`{pages: 208, binding: 'Hardcover', genre: 'Fiction', subgenre: 'Mystery'}` 

Object.assign 的第一个参数是目标,即将从其他对象中复制的键赋值给的对象。在这个例子中,我们使用一个名为 book 的空对象作为目标。其余的参数是源对象,即其键值对将被复制到目标对象中的对象。你可以在初始目标参数之后传入任意多个源对象——我们这里只传入了两个。该方法会修改并返回目标对象,复制来自源对象的键值对。源对象本身不会受到影响。

你不一定需要创建一个新的空对象作为 Object.assign 的目标,但是如果不这么做,你将会修改其中一个源对象。例如,我们可以去掉之前调用中的第一个参数 book,仍然得到一个具有相同四个键值对的对象:

**Object.assign(physical, contents);**
**physical;**
`{pages: 208, binding: 'Hardcover', genre: 'Fiction', subgenre: 'Mystery'}` 

这里的问题是,physical 现在是目标对象,因此它会被修改,获得来自 contents 的所有键值对。通常情况下,这不是我们想要的,因为原本的单独对象在应用程序的其他部分通常仍然很重要。基于这个原因,常见做法是将一个空对象作为 Object.assign 的第一个参数。

嵌套对象和数组

和数组一样,我们可以将对象嵌套在其他对象中。我们还可以将对象嵌套在数组中,或者将数组嵌套在对象中,从而创建更复杂的数据结构。例如,你可能想创建一个表示“人”的对象,这个对象包含一个 children 属性,属性值是一个数组,数组中的每个元素都是一个表示该人孩子的对象。我们可以通过两种方式来构建这些嵌套结构:一种是创建一个包含嵌套对象或数组字面量的对象或数组字面量,另一种是先创建内部元素,保存到变量中,然后使用这些变量来构建复合结构。我们将在这里探讨这两种技巧。

使用字面量进行嵌套

首先,我们来使用字面量构建一个嵌套结构。我们将创建一个表示不同书籍三部曲的对象数组:

**let trilogies = [**
❶ **{**
 **title: "His Dark Materials",**
 **author: "Philip Pullman",**
 **books: ["Northern Lights", "The Subtle Knife", "The Amber Spyglass"]**
 **},**
❷ **{**
 **title: "Broken Earth",**
 **author: "N. K. Jemisin",**
 **books: ["The Fifth Season", "The Obelisk Gate", "The Stone Sky"]**
 **}**
**];** 

变量 trilogies 包含一个包含两个元素的数组,❶和❷,每个元素都是一个包含特定三部曲信息的对象。注意,每个对象都有相同的键,因为我们希望存储关于每个三部曲的相同信息。其中一个键是 books,它本身包含一个数组,表示该三部曲中的书籍标题。因此,我们得到了一个嵌套在数组中的对象,又嵌套在数组中。

从这些内部数组中访问元素需要结合数组索引和对象点表示法:

**trilogies[1].books[0];**
'The Fifth Season' 

在这里,trilogies[1]表示我们想要外部数组中的第二个对象,.books 表示我们想要该对象的 books 键的值(即一个数组),而[0]表示我们想要该数组中的第一个元素。将它们结合起来,我们就得到了外部数组中第二个三部曲的第一本书。

使用变量进行嵌套

另一种创建嵌套结构的技巧是先创建包含内部元素的对象,将这些对象赋值给变量,然后使用这些变量构建外部结构。例如,假设我们想创建一个模拟我们口袋里零钱变化的数据结构。我们创建四个对象,分别表示便士、五分镍币、十分镍币和四分之一硬币,并将每个对象赋值给各自的变量:

**let penny = { name: "Penny", value: 1, weight: 2.5 };**
**let nickel = { name: "Nickel", value: 5, weight: 5 };**
**let dime = { name: "Dime", value: 10, weight: 2.268 };**
**let quarter = { name: "Quarter", value: 25, weight: 5.67 };** 

接下来,我们使用这些变量创建一个数组,表示我们口袋中特定组合的硬币。例如:

**let change** **= [quarter, quarter, dime, penny, penny, penny];**

注意到某些硬币对象在数组中出现多次。这是先将内部对象赋值给变量再创建外部数组的一个优点:对象可以在数组中重复,而不需要每次手动写出对象字面量。

再次访问内部对象的值时,需要结合数组索引和对象点符号:

**change[0].value;**
25 

这里,change[0]给我们返回 change 数组的第一个元素(一枚硬币对象),而.value 给我们它的 value 键。

从对象变量构建数组的一个有趣结果是,重复的元素共享一个共同的身份。例如,change[3]和 change[4]引用的是同一枚 penny 对象。如果美国政府决定更新一枚 penny 的重量,我们只需要更新该 penny 对象的 weight 属性,这个更新就会反映到 change 数组中所有的 penny 元素上:

**penny.weight = 2.49;**
**change[3].weight;**
2.49
**change[4].weight;**
2.49
**change[5].weight;**
2.49 

在这里,我们将 penny 的 weight 属性从 2.5 修改为 2.49。然后,我们检查数组中每个 penny 的重量,确认更新已经反映到每一个硬币上。

在控制台中探索嵌套对象

Chrome 控制台让我们轻松地探索嵌套对象,就像我们之前在本章中使用嵌套的 ticTacToe 数组那样。为了说明这一点,我们将创建一个深度嵌套的对象并尝试查看其中内容:

**let nested = {**
 **name: "Outer",**
 **content: {**
 **name: "Middle",**
 **content: {**
 **name: "Inner",**
 **content: "Whoa…"**
 **}**
 **}**
**};** 

我们的嵌套对象包含三层对象,每一层都有 name 和 content 属性。外层和中间层的 content 值是另一个对象。要获取最内层对象的 content 属性值,需要一连串的点符号:

**nested.content.content.content;**
'Whoa…' 

这相当于请求最外层对象的内容属性的内容属性的内容属性。

现在尝试查看嵌套对象的整体值:

**nested;**
`{name: 'Outer', content: {…}}` 

控制台只会显示外部对象内容属性的简略版本,内容显示为{…},表示这里有一个对象,但没有足够的空间来展示它。点击箭头以展开外部对象的视图。现在,下一个嵌套对象(名称:“Middle”)也以简略形式显示。点击箭头再展开此对象,然后再点击一次展开名为:“Inner”的对象。现在你应该能在控制台中看到整个对象的内容:

`{name: 'Outer', content: {…}}`
  content:
content:
content: "Whoa…"
name: "Inner"
  [[Prototype]]: Object
name: "Middle"
[[Prototype]]: Object
name: "Outer"
  [[Prototype]]: Object 

[[Prototype]]属性指向 Object 构造函数,我们之前已经使用过它来调用像 Object.keys 和 Object.assign 这样的对象方法。我们将在第六章中详细讨论原型。

这样使用控制台查看复杂对象是一个非常有用的调试工具。你经常会处理来自不同 JavaScript 库的对象,或者包含你从服务器获取的数据的对象,而你不一定知道这些数据的“形状”——例如对象包含哪些属性、它们有多少层嵌套等等。通过控制台,你可以交互式地探索对象并查看它们的内容。

使用 JSON.stringify 打印嵌套对象

查看嵌套对象的另一种方式是将其转换为 JSON 字符串。JSON,即 JavaScript 对象表示法,是一种基于 JavaScript 对象和数组字面量的文本数据格式,在 web 及其他领域被广泛使用来存储和交换信息。JSON.stringify 方法将一个 JavaScript 对象转换为 JSON 字符串。我们以嵌套对象作为例子:

**JSON.stringify(nested);**
'{"name":"Outer","content":{"name":"Middle","content":{"name":"Inner","content":"Whoa…"}}}' 

结果是一个字符串(它被单引号括起来),包含了嵌套对象的 JSON 表示。实质上,它等同于我们用来创建嵌套对象的原始对象字面量。像 JavaScript 一样,JSON 使用大括号括起来对象,使用冒号分隔键和值,使用逗号分隔不同的键值对。这个表示中唯一缺失的是我们用来澄清对象字面量嵌套结构的原始换行符和缩进。为了重新创建这些换行符和缩进,我们可以传递 JSON.stringify 另一个参数,表示每个新的嵌套对象的缩进空格数:

**nestedJSON = JSON.stringify(nested, null, 2);**
**console.log(nestedJSON);**
{
  "name": "Outer",
  "content": {
"name": "Middle",
"content": {
  "name": "Inner",
"content": "Whoa…"
}
  }
} 

JSON.stringify 的第二个参数让你定义一个替换函数,可以通过替换键值对来修改输出,但在这里我们不需要这样做,所以传递 null。将 2 作为第三个参数传递会修改 JSON.stringify 的行为,在每个属性后、在大括号和方括号后添加换行符,并为每个额外的嵌套级别增加两个额外的缩进空格。如果我们直接在控制台中查看结果,我们会看到许多 \n 转义字符表示所有的换行符。相反,我们将结果存储在一个变量中并传递给 console.log,这样就能给我们一个格式良好的对象嵌套层次视图。

以这种方式调用 JSON.stringify 有助于快速获取对象的可视化表示,而无需在控制台中反复点击箭头来展开每个嵌套级别。该方法同样适用于非嵌套对象,但在这种情况下,控制台中对象的常规视图通常就足够了。

总结

本章介绍了 JavaScript 的复合数据类型,它们允许你将多个值组合成一个单一的单位。通过这种方式组织数据,你可以更高效地处理无限量的信息。你了解了数组,它是由数字索引标识的有序值集合,通常所有值的数据类型相同;你还了解了对象,它是由键值对组成的集合,其中每个键是一个字符串,而值通常是不同的数据类型。你已经了解了数组如何用于存储类似值的列表,例如素数列表或编程语言列表。同时,对象对于收集单个实体的多个信息也非常有用,比如一本书或一部电影的相关信息。

第十一章:4 条件语句与循环

条件语句循环 是编程中的基本元素。它们通过允许你的代码根据特定条件做出决策,为程序添加了逻辑和结构。条件语句和循环一起被称为 控制结构,因为它们让你控制代码的执行时机和频率。通过条件语句,你可以仅在某个条件为真时才运行特定的代码。同时,循环允许你在某个条件为真时反复执行一段代码。

在本章中,你将学习如何使用 if 语句有条件地执行代码,以及如何使用 while 和 for 语句进行代码循环。你还将学习如何在复合数据类型的元素上进行循环。这在你需要对数组或对象的每个元素执行操作时尤其有用。

当我们开始使用控制结构时,我们将编写更复杂的脚本,这些脚本直接在控制台输入时并不实用,因为每个语句一输入就会立即执行。因此,在本章中,我们将切换到将 JavaScript 代码嵌入到 HTML 文件中,然后在浏览器中打开这些文件。这让你可以一次运行整个程序,并且能够轻松地进行修改并重新运行整个程序。要复习如何操作,请参阅 第一章中的“使用文本编辑器”部分。

使用条件语句做决策

条件语句允许你在设定的条件为真时运行一段代码。例如,你可能只希望在银行账户余额低于某个阈值时显示警告消息,或者在游戏中,当玩家被敌人击中时失去一条生命。你通常使用比较运算符,如 === 和 >,来创建这些条件,这些我们在 第二章 中已经讨论过。你还可以使用逻辑运算符,如 && 和 ||,将多个条件组合在一起。关键是,整体条件必须评估为真或假。

条件语句有两种主要类型:if 语句和 if…else 语句。我们将依次讨论这两种类型。

if 语句

如果某个条件为真,if 语句会执行代码;如果条件为假,它会跳过该段代码。例如,我们可以创建一个程序,当某个值大于指定阈值时,将消息记录到控制台。打开 VS Code,创建一个名为 if.html 的新文件,并输入 列表 4-1 的内容。

<html><body><script>
let speed = 30;
console.log(`Your current speed is ${speed} mph.`);
❶ if (speed > 25) {
  console.log("Slow down!");
}
</script></body></html> 

列表 4-1:一个 if 语句

这段代码开始和结束时使用了第一章中用于嵌入 JavaScript 代码到 HTML 文件的相同标签。JavaScript 本身首先将 speed 变量初始化为 30,并使用 console.log 将该值打印到控制台。然后,我们使用 if 语句 ❶检查 speed 的值,并在其大于 25 时打印另一条消息。

if 语句以 if 关键字开始,主要由两部分组成:条件,即括号内的内容;以及当条件为真时执行的代码,称为语句体,它被包含在一对大括号内。这里,条件是 speed > 25,如果条件为真,执行的代码是 console.log("慢下来!")。由于我们设置了 speed 大于 25,条件为真,因此语句体中的代码会执行。因此,当你在浏览器中打开if.html时,你应该在 JavaScript 控制台中看到以下输出:

Your current speed is 30 mph.
Slow down! 

我们的条件通过了,因此“慢下来!”的消息被记录到控制台中。然而,如果条件为假,if 语句体中的代码将不会执行。为了亲自验证这一点,尝试将if.html中的 speed 值初始化为 20 而不是 30。然后重新保存文件并重新加载页面。这一次,你应该只看到以下输出:

Your current speed is 20 mph.

因为 speed > 25 现在为假,括号内的代码没有执行。然而,if 语句体外的代码仍然执行,因此我们仍然能看到 speed 的值,这要归功于第一次的 console.log 调用。

if…else 语句

当条件为真时,你可能希望运行一段代码,当条件为假时运行另一段代码。为此,我们使用 if…else 语句。试着创建一个名为ifElse.html的新文件,并输入列表 4-2 的内容。

<html><body><script>
let speed = 20;
console.log(`Your current speed is ${speed} mph.`);
if (speed > 25) {
❶ console.log("Slow down!");
} else {
❷ console.log("You're obeying the speed limit.");
}
</script></body></html> 

列表 4-2:一个 if…else 语句

这段代码使用 if…else 语句检查 speed 是否大于 25。与列表 4-1 中的代码一样,条件以 if 关键字开头,后面跟着括号中的条件。然而,不同于列表 4-1,if…else 语句有两个代码块,而不是只有一个,else 关键字位于它们之间。如果条件为真,第一个代码块 ❶会执行;如果条件为假,第二个代码块 ❷会执行。每个代码块都被一对大括号包围。在这个例子中,由于 speed 为 20,条件计算结果为假,因此第二个代码块会执行。当你在 Chrome 中打开文件时,你应该看到以下输出:

Your current speed is 20 mph.
You're obeying the speed limit. 

else 语句体中的消息已经被记录到控制台中。然而,如果你尝试将 speed 设置为更高的值,比如 30,那么 if 语句体中的消息将会被记录。

更复杂的条件

可以通过结合逻辑运算符,使用更复杂的布尔表达式作为条件。例如,假设你只想在上学时间检查驾驶员的速度。假设你有一个包含当前小时数的小时变量(使用 24 小时制),你可以做如下操作:

if (speed > 25 && hour > 7 && hour < 16) {

只有当速度大于 25 且小时数大于 7 但小于 16 时,这个 if 语句的主体才会执行。换句话说,如果在上学时间外,即使速度超过 25,也不会执行 if 语句的主体。

如果你的条件变得太复杂,可能会使 if 语句难以阅读。在这种情况下,通常最好将布尔表达式单独写出来,并将其赋值给一个新变量。然后,你可以将这个变量作为 if 语句的条件。例如,之前的条件可以重写为:

let tooFastForSchool = speed > 25 && hour > 7 && hour < 16;
if (tooFastForSchool) { 

在这里,我们将相同的复杂布尔表达式赋值给 tooFastForSchool 变量,然后将该变量提供给 if 语句。由于变量名具有意义,现在的条件几乎就像一句话:“如果太快而不适合上学,[做某事]。”

如果把速度和小时的测试放到一个布尔变量中显得有些奇怪,那么一个折中的方法是把小时检查单独放入一个变量中,像这样:

let schoolHours = hour > 7 && hour < 16;
if (speed > 25 && schoolHours) { 

现在,schoolHours 变量根据是否在上学时间内存储真或假,而 if 语句将该变量与速度测试结合。最终,你选择的方法归结为一个主观问题:你觉得这段代码容易阅读吗?

链式 if…else 语句

如果你需要让代码在三个或更多可能的分支之间做出选择,可以将多个 if…else 语句链接在一起。例如,你可以使用这种技巧根据速度变量的值记录三个可能的消息之一。创建一个新文件,命名为ifElseIf.html,并使用清单 4-3 中的代码。

<html><body><script>
let speed = 20;
console.log(`Your current speed is ${speed} mph.`);
if (speed > 25) {
  console.log("Slow down!");
} else if (speed > 15) {
  console.log("You're driving at a good speed.");
} else {
  console.log("You're driving too slowly.");
}
</script></body></html> 

清单 4-3:一个带有三个主体的链式 if…else 语句

这个脚本与清单 4-2 中的 if…else 语句非常相似,不同之处在于现在有三个部分,每个部分都有自己的主体:if、else if 和 else。只有一个主体——第一个条件为真的主体——会运行。下面是它的工作原理:

  1. 首先,我们使用 if 来检查速度是否大于 25。如果是,首先的主体将运行,将“减速!”记录到控制台,其余的条件将被跳过。

2.  接下来,我们使用 else if 添加第二个条件,测试速度是否大于 15,并在符合条件时记录不同的信息。如果代码执行到这一点,说明 speed > 25 的条件已经被判断为假,因此实际上 speed > 15 是在测试 speed 是否介于 15 和 25 之间。我们可以通过写 else if (speed > 15 && speed <= 25) 来明确这一点,但因为我们已经知道 speed 不会大于 25,所以不需要指定 && speed <= 25 部分。

3.  最后,我们使用 else 记录第三个可能的消息,如果前两个条件都不成立的话。

在这个例子中,我们将 speed 设置为 20,因此只有 else if 分支会运行,产生以下输出:

Your current speed is 20 mph.
You're driving at a good speed. 

尝试使用不同的 speed 值,触发 if 和 else 分支。

你可以在初始 if 和最终 else 之间链式添加任意数量的 else if 子句,如 清单 4-4 所示,从而在条件结构中创建任意数量的分支。

if (speed > 25) {
  console.log("Slow down!");
} else if (speed > 20) {
  console.log("You're driving at a good speed.");
} else if (speed > 15) {
  console.log("You're driving a little bit too slowly.");
} else if (speed > 10) {
  console.log("You're driving too slowly.");
} else {
  console.log("You're driving far too slowly!");
} 

清单 4-4:一个带有五个分支的链式 if…else 语句

这个链式 if…else 语句有五种可能的分支,取决于 speed 是否大于 25、20、15、10,或者这些都不是。与前面的例子一样,条件的顺序在这里很重要。按照从大到小的顺序进行比较,使我们能够定义速度的五个可能值范围,而无需显式定义范围的上限。例如,我们可以写成 else if (speed > 15),而不是 else if (speed > 15 && speed <= 20),因为到那时我们已经确认 speed 不大于 20。表 4-1 展示了每个分支的完整条件,清单 4-4 中提供了详细信息。

表 4-1: 清单 4-4 中的完整条件和输出
条件
speed > 25
speed > 20 && speed <= 25
speed > 15 && speed <= 20
speed > 10 && speed <= 15
speed <= 10

请注意,我们可以反转条件和分支的顺序,最终得到相同的效果。反转后,条件将是 speed <= 10、speed <= 15、speed <= 20 和 speed <= 25。speed > 25 的情况会在 else 块中处理。需要注意的是,条件是逐一检查的,因此检查第二个条件意味着第一个条件为假。同时,注意 > 的相反是 <=(想想如果 speed 正好是 10 时,应该触发哪个条件)。

使用循环重复代码

循环是 JavaScript 中另一种控制结构,允许你根据需要多次重复执行相同的代码。例如,你可以使用循环打印购物清单中的每一项。如果没有循环,这将是不可能的,因为你事先并不一定知道清单上有多少项。循环在你希望一直运行相同的代码直到某个条件成立时也非常有用;例如,反复要求用户输入他们的出生日期,直到他们提供有效的日期。

在本章中,你将学习四种循环:while 循环、for 循环、for…in 循环和 for…of 循环。我们从 while 循环开始。

while 循环

类似于 if 语句,while 循环依赖于条件测试。就像 if 语句一样,如果条件最初被发现为假,while 循环将完全跳过执行其代码。然而,与 if 语句不同的是,while 循环会在条件为真时继续运行其代码块,在每次新的一轮执行前重新检查条件。换句话说,它会在某个条件为真时重复执行一段代码。这个特性在你需要多次执行某段代码时非常有用,它使得程序可以在需要时一直运行,而不是只执行一次然后停止。

要查看 while 循环如何工作,创建一个名为 while.html 的新文件,并输入 Listing 4-5 的内容。

<html><body><script>
let speed = 30;
❶ while (speed > 25) {
  console.log(`Your current speed is ${speed} mph.`);
  speed--;
}
❷ console.log(`Now your speed is ${speed} mph.`);
</script></body></html> 

Listing 4-5: 一个 while 循环

这个脚本将速度设置为 30,然后使用 while 循环 ❶ 将速度控制在限制范围内。我们使用 while 关键字编写 while 循环,后面跟着括号中的条件和大括号中的代码块,类似于 if 语句。在这里,我们的条件检查速度是否大于 25。代码块将速度值打印到控制台,然后使用递减运算符(--)将速度减少 1。这为我们提供了一个新的速度值,以便在下一次循环中测试。while 循环会不断重复代码块,直到条件为假,输出如下:

Your current speed is 30 mph.
Your current speed is 29 mph.
Your current speed is 28 mph.
Your current speed is 27 mph.
Your current speed is 26 mph.
Now your speed is 25 mph. 

让我们来思考当这段代码运行时会发生什么。第一次进入 while 循环时,speed 是 30,因此条件(speed > 25)为真。这意味着 while 循环的主体执行一次,输出“当前速度是 30 英里每小时”,并将 speed 从 30 减少到 29。循环主体结束时,我们回到起始位置,重新检查条件。由于 speed 现在是 29,条件仍然为真,因此我们再次执行循环主体,打印“当前速度是 29 英里每小时”,并将 speed 减少到 28。然后我们再次回到起始位置,继续检查条件,依此类推。最终,在第五次循环时,speed 从 26 减少到 25。当我们第六次检查条件时,它的值为假(25 不大于 25)。这使得 JavaScript 停止循环,并跳到 while 循环之后的第一行代码 ❷,输出最后一行文本。

for 循环

for 循环是另一种更结构化的 JavaScript 循环方式。像 while 循环一样,for 循环会在某个条件为真时重复执行。但与 while 循环不同,在 for 循环中,管理重复执行的代码出现在循环的开始部分,与循环主体分开。

循环通常有一个特定的循环变量,用于跟踪循环的状态。一个常见的模式是将循环变量设置为一个初始值,某种方式更新它,并根据循环变量检查某个条件,决定是否停止重复执行。例如,我们在清单 4-5 中的 while 循环遵循了这个模式,speed 作为循环变量。我们在进入循环之前将 speed 设置为 30,每次通过循环时将 speed 减少,并一直循环,直到 speed 不再大于 25。

for 循环只是写出这个模式的一种更便捷方式。使用 for 循环时,我们将设置和更新循环变量的代码移动到循环的第一行,放在与循环条件相同的括号内。为了说明这一点,让我们将之前的例子重写,使用 for 循环代替 while 循环。将清单 4-6 的内容保存到for.html中。

<html><body><script>
for (let speed = 30; speed > 25; speed--) {
  console.log(`Your current speed is ${speed} mph.`);
}
</script></body></html> 

清单 4-6:一个 for 循环

我们使用 for 关键字声明 for 循环,后面跟着一对括号,其中包含三个组成部分,每个部分都有各自的循环管理任务:

1.  初始化循环变量(let speed = 30)。

2.  设置循环条件(speed > 25)。

3.  更新循环变量(speed--)。更新将在每次循环后进行。

这三个部分用分号隔开。

在循环体内,我们有一条语句将speed的值记录到控制台。注意,我们不再需要像在while循环中那样在循环体内递减speed;这一部分由循环管理代码的第三部分(括号内)来处理。同样,我们不再需要在声明循环之前初始化speed;这一点也在括号内处理。

运行这个脚本将产生与列表 4-5 中的while循环大致相同的输出:

Your current speed is 30 mph.
Your current speed is 29 mph.
Your current speed is 28 mph.
Your current speed is 27 mph.
Your current speed is 26 mph. 

唯一的区别是,我们不能像在while循环中那样在循环结束后记录最终速度。这是因为speed变量作为循环本身的一部分被声明,而不是在循环之前声明。因此,speed被限制在循环的作用域内,这意味着循环外的代码无法访问它。实际上,这正是for循环的一个优势:循环变量仅存在于循环内,无法在代码的其他部分意外地使用或修改。

使用for循环,你可以完成所有while循环能做的事情,但大多数程序员发现for循环比等效的while循环更容易阅读,因为所有循环逻辑都集中在一个地方。

for…of 循环

for…of 循环遍历数组中的项。与while循环或for循环在某个条件为真时一直循环不同,for…of循环逐一遍历数组中的每一项,直到没有剩余的项为止。这非常有用,因为通常需要对数组中的每个成员应用相同的操作。例如,如果你有一个数字数组,你可以通过遍历这些数字并为每个数字绘制一个矩形,使用数字来设置矩形的高度(单位为像素)来创建一个柱状图。类似地,如果你有一个关于电影的对象数组,你可以遍历这些电影并打印它们的标题。

让我们看看一个实际的for…of循环。创建一个名为forOf.html的新文件,内容参考列表 4-7。

<html><body><script>
let colors = ["Red", "Green", "Blue"];

for (let color of colors) {
  console.log(`${color} is a color.`);
}
</script></body></html> 

列表 4-7:使用 for…of 循环遍历数组

这段代码会为数组colors中的每个颜色记录一条句子,然后停止。我们首先创建了一个包含字符串“Red”、“Green”和“Blue”的数组。然后我们使用语句for (let color of colors)将循环变量color依次设置为colors中的每个元素。第一次执行时,color会被设置为“Red”。第二次时,color会被设置为“Green”。最后,第三次时,color会被设置为“Blue”。当数组中的项用完时,循环结束。该脚本应该输出如下内容:

Red is a color.
Green is a color.
Blue is a color. 

也可以使用常规的for循环来遍历数组中的项,详见列表 4-8。

for (let index = 0; index < colors.length; index++) {
  console.log(`${colors[index]} is a color.`);
} 

清单 4-8:使用 for 循环代替 for…of 循环遍历数组

在这里,循环变量 index 代表数组中每个项的索引。我们的循环设置代码将 index 初始化为 0,并逐渐增加,直到它不再小于 colors 数组的长度(记住,长度为 N 的数组中,最高的索引是 N - 1)。在循环体内,我们使用 colors[index] 访问当前的颜色。

很长一段时间,这种 for 循环风格是 JavaScript 中遍历数组的唯一方式。能够识别它是值得的,因为你可能会在许多旧代码中看到它。如今,for…of 风格更为常见。然而,旧的 for 循环技巧的一个优点是它可以让你访问数组的索引。这很有用,因为有时候知道你当前正在处理数组中的哪个元素是很重要的。例如,你可能希望对偶数和奇数元素做不同的处理,或者你可能只是想在输出元素值的同时打印出索引,以便生成一个编号列表。你也可以使用 for…of 循环,通过对数组使用 entries 方法来实现这一点。要查看它是如何工作的,创建一个新的 forOfEntries.html 文件,并输入 清单 4-9 的内容。

<html><body><script>
let colors = ["Red", "Green", "Blue"];
for (let [index, item] of colors.entries()) {
  console.log(`${index}: ${item} is a color.`);
}
</script></body></html> 

清单 4-9:使用 for…of 循环与 entries 方法访问数组中的索引

在上一章中,你看到将 Object.entries 方法应用于对象时,会得到一个包含数组的数组,其中每个内层数组包含对象的一个键及其关联的值。在这里,对 colors 数组调用 entries 方法做了类似的操作,得到数组 [[0, "Red"], [1, "Green"], [2, "Blue"]]。语法 let [index, item] 被称为 解构赋值。它将 colors.entries 中的每个两元素数组(例如 [0, "Red"])拆分成两个独立的变量,index 用于索引号,item 用于对应的值。通过这种方式,我们可以将索引包含进日志消息中,生成如下输出:

0: Red is a color.
1: Green is a color.
2: Blue is a color. 

请注意,解构赋值也可以在常规赋值语句中使用,在 for…of 循环之外,将数组拆分成独立的变量。例如,你可以像这样将表示 RGB 颜色值的三个数字数组转换为单独的 r、g 和 b 变量:

let rgbcolor = [125, 100, 0];
let [r, g, b] = rgbcolor; 

由于解构赋值,r 现在的值为 125,g 的值为 100,b 的值为 0。我们在本书中不会频繁使用这种语法,但能够识别它是有帮助的。

for…in 循环

for…in 循环遍历对象中的键。它的工作方式类似于 for…of 循环,依次取出每个键,并在没有更多键时停止。不同之处在于,for…in 循环适用于对象,而非数组,遍历的是键,而非值。保存 清单 4-10 的内容为 forIn.html 来进行尝试。

<html><body><script>
let me = {
  "first name": "Nick",
  "last name": "Morgan",
  "age": 39
};

for (let key in me) {
  console.log(`My ${key} is ${me[key]}.`);
}
</script></body></html> 

清单 4-10:使用 for…in 循环遍历对象中的键

在这里,我们创建了一个包含三个键值对的 me 对象(可以随意填写你自己的名字和年龄)。然后我们使用 for…in 循环遍历这些键。类似于 for…of 循环语法,写 for (let key in me) 会创建一个循环变量 key,并将其设置为 me 对象中的每个键,逐个进行。第一次循环时,key 被设置为 "first name"(名字),第二次时被设置为 "last name"(姓氏),依此类推。在循环体内,我们使用表示法 me[key] 来访问与当前键相关联的值,并将其与键一起嵌入到消息中。输出应类似于以下内容:

My first name is Nick.
My last name is Morgan.
My age is 39. 

我们本可以使用 Object.entries(me) 来获取一个包含键值对的数组,然后用 for…of 循环遍历这些键值对。像往常一样,这种选择主要是个人偏好。

总结

本章展示了如何使用条件语句和循环为代码添加逻辑和结构。这些控制结构让你能够决定代码何时以及多少次执行。像 if 和 if…else 这样的条件语句根据某个条件是否成立来决定是否执行代码。某些循环,如 while 和 for,会重复执行相同的代码,直到满足某个条件。而像 for…of 和 for…in 这样的循环则是用来遍历数组或对象的元素。

第十二章:5 函数

正如你在第一章中学到的,函数是一个自包含的代码块,用于执行特定任务。我们已经使用了一些 JavaScript 的内置函数,例如 alert 和 console.log,但你也可以创建自己的自定义函数来执行应用程序中特定的任务。然后,你可以调用这些函数来运行关联的代码。以这种方式将代码封装成函数,可以使你的编程更加高效,因为你不必每次使用代码时都重复它。

在这一章中,你将学习编写自己函数的不同技巧。你将看到如何向函数提供输入并从中接收输出。你还将看到函数如何作为普通值处理,就像数字或字符串一样。特别是,我们将探索函数如何作为其他高阶函数的输入或输出。

声明和调用函数

在使用自定义函数之前,你必须先定义函数的名称和它的功能。一种方法是使用函数声明,这是一个定义函数的代码块。为了说明这一点,我们将声明一个简单的函数,名为 sayHello,它接受某人的名字并在控制台中记录一条个性化的问候信息。在 Chrome 中打开 JavaScript 控制台并输入以下内容:

**function sayHello(name) {**
 **console.log(`Hello, ${name}!`);**
**}** 

函数声明有四个部分。首先,我们使用 function 关键字来告诉 JavaScript 我们正在创建一个函数。接下来,我们给函数命名——在本例中为 sayHello。然后,我们提供一个由逗号分隔的函数参数列表,参数用括号括起来。参数是函数执行任务所需要的信息。在这个例子中,我们的函数有一个参数 name,表示该函数需要提供某人的名字来创建问候语。(如果函数没有参数,我们只需写一个空的括号。)最后,我们写出函数体,用大括号括起来。这是当调用函数时应该执行的代码。在我们的示例中,函数体包含一个 console.log 调用,用于打印问候语,并通过模板字符串插入 name 参数的值。

现在我们已经声明了 sayHello 函数,我们可以在任何时候调用它来问候某人。每次调用函数时,我们需要为 name 参数提供一个值。这个值叫做参数,并在调用函数时通过括号指定。通过传递不同的参数,我们可以创建不同的个性化问候语。例如:

**sayHello("Nick");**
Hello, Nick!
undefined
**sayHello("Mei");**
Hello, Mei!
undefined 

第一次调用我们的 sayHello 函数时,我们在函数名后面的括号中传入 "Nick" 作为参数。因此,消息 Hello, Nick! 被打印到控制台。第二次调用函数时,我们传入 "Mei" 作为参数,因此消息 Hello, Mei! 被打印。每次调用时,参数的值会绑定到函数的名称参数上,函数体根据该参数值执行。实际上,你可以将 name 理解为函数内部的一个变量,当函数被调用时,它会根据相应的参数(如 "Nick" 或 "Mei")赋值。

参数和实参之间的区别微妙但重要。参数是函数输入的通用名称,而实参是在调用函数时传递给函数的实际输入值。每个函数只有一组参数,但每次调用函数时,它可以有一组新的实参。通过这种方式,参数使得函数具有高度的可定制性。例如,sayHello 函数有一个参数 name,但每次调用时都可以传入不同的实参。我们见过它被这样调用:sayHello("Nick") 和 sayHello("Mei"),但可能性是无穷的:sayHello("Kitty")、sayHello("Dolly")、sayHello("world") 等等。

注意,每次调用 sayHello 时,它会输出 undefined 以及自定义的问候语。这个额外的输出行就是函数的返回值。sayHello 返回 undefined,因为我们没有显式给它返回值;接下来我们将学习如何做到这一点。

返回值

返回值 是一个函数产生的值,可以在代码的其他地方使用。在许多情况下,你希望一个函数使用参数接收一些输入,处理这些输入并输出结果。这个输出就是返回值。例如,假设我们声明一个函数,接收两个数字并返回它们的和:

**function add(x, y) {**
 **return x + y;**
**}** 

这个 add 函数有两个参数,x 和 y。函数体由 return 关键字和表达式 x + y 组成。当调用该函数时,JavaScript 会计算这个表达式,将 x 和 y 相加,并返回结果,如下所示:

**add(1, 2);**
3 

我们用 1 和 2 作为实参调用 add,它们分别成为参数 x 和 y 的值。(实参按给定的顺序与参数一一对应。)该函数将两个实参相加并返回结果 3。

当我们在 Chrome 控制台中调用一个函数时,它的返回值会自动打印出来——但需要注意区分函数显式将文本记录到控制台(就像我们之前看到的 sayHello 做的那样)和函数返回值(就像这里的 add)。当函数使用 console.log 记录一个值时,这个值只存在于日志中;我们无法在后续代码中进一步使用它。相反,当一个函数返回一个值时,我们可以在代码中稍后使用该值。尽管返回值也会在控制台中显示,但这基本上是无关紧要的。它帮助我们看到函数的行为,但记录到控制台并不是 add 函数的主要目的,而 sayHello 函数则不同。

使用函数的返回值的一种方式是将函数调用作为赋值表达式的一部分,这样返回值就会被存储在一个变量中。然后,我们可以在代码中稍后使用这个变量。例如:

**let sum** **= add(500, 500);**
undefined
**`I walked ${sum} miles`;**
'I walked 1000 miles' 

在这里,我们声明了变量 sum,并将其初始化为 add 函数的返回值,我们用 500 和 500 作为参数调用 add。尽管 add 函数有返回值,控制台显示的是 undefined,因为正如在第二章中讨论的那样,声明变量时总是打印 undefined。然后,我们通过将 sum 嵌入到模板字面量中,利用该函数的返回值,生成字符串"I walked 1000 miles"。

请注意,像当前写法的 sayHello 函数,我们无法做类似的事情。例如,我们不能用它来生成问候语"Hello, Nick!",然后编写一些代码将该问候语嵌入到一个更长的字符串中。sayHello 函数返回的是 undefined,因为我们没有使用 return 关键字显式地给它返回值。它只是将问候语记录到控制台,而一旦记录完毕,就无法再次访问该问候语。

使用函数的返回值时,并不一定要将其存储在变量中。返回值的函数调用可以在任何可以使用值的地方使用,就像你可以交换使用变量和字面量值一样。例如,前面的示例可以这样重写:

**`I walked ${add(500, 500)} miles`;**
'I walked 1000 miles' 

在这里,我们不是单独调用 add 并将结果存储在一个变量中,而是从模板字面量内调用该函数。它的返回值直接插入到生成的字符串中,产生与之前相同的消息。通常将返回值存储在变量中会更易读,但两种方式都是有效的。

参数类型

JavaScript 中的函数参数的数据类型是动态变化的。这是因为 JavaScript 是一种动态类型的编程语言,其中变量和参数的类型可以在程序运行时发生变化,而与此相对的是静态类型语言,其中变量和参数的类型在程序运行之前就已确定。

举个例子,到目前为止我们一直在使用 add 函数来相加数字,但没有什么能阻止我们将其用来连接两个字符串:

**add("Hello, ", "world!");**
'Hello, world!' 

在这里,我们将两个字符串作为参数传递给函数,因此函数体内的+运算符会被解释为字符串连接,而不是数字加法。因此,函数会将两个字符串合并并返回结果。

从这个角度看,我们也可以传递其他类型的参数,甚至在同一个函数调用中混合数据类型:

**add(true, false);**
1
**add(1, '1');**
'11' 

在这些情况下,JavaScript 关于类型强制转换的规则(在第二章中讨论过)会起作用。当我们尝试使用 add(true, false)将两个布尔值相加时,JavaScript 会在加法前将布尔值转换为数字 1 和 0,从而得到数字 1。当我们尝试使用 add(1, "1")将数字和字符串相加时,JavaScript 会将两个操作数都转换为字符串并将它们连接起来,得到字符串"11"。

动态类型为 JavaScript 带来了很大的灵活性,但如果不小心,它也可能引发一些令人困惑的 bug。了解你使用的类型非常重要,以确保你没有向期望数字的函数传递字符串类型的参数。

副作用

副作用是指函数执行时,除了返回值外,会对函数外部产生影响的任何操作。副作用可以是有意的,也可以是无意的,包括更新函数外部声明的变量值、修改函数外部声明的数组或对象,或者向控制台输出字符串。

一些函数,比如我们的 add 函数,没有副作用,仅仅因为返回值而被调用。另一些函数,比如 sayHello,没有返回值,仅仅因为副作用而被调用。也可以编写同时返回值具有副作用的函数。例如,我们可以重新定义 add 函数,除了返回参数和外,还可以将一些信息记录到控制台并更新一个变量:

**let addCalls = 0;**

**function add(x, y) {**
 **addCalls++;**
 **console.log(`x was ${x} and y was ${y}`);**
 **return x + y;**
**}** 

在这里,我们声明了变量 addCalls,用于跟踪 add 函数被调用的次数。然后,我们编写了更新后的 add 函数声明。现在该函数会先递增 addCalls,并将参数值记录到控制台中,然后像之前一样返回参数的和。

让我们尝试调用修改后的函数:

**let sum = add(Math.PI, Math.E);**
x was 3.141592653589793 and y was 2.718281828459045
**addCalls;**
1
**sum;**
5.859874482048838 

该函数调用具有副作用,即在将两个值相加之前,将它们记录到控制台中。它还有一个副作用,就是更新了 addCalls 变量,将其值从 0 改为 1。此外,函数还有一个(非副作用)结果,即返回其参数的和,我们已将其存储在 sum 变量中。

如果我们进一步调用 add,变量 addCalls 将每次递增,从而提供一个函数被调用次数的实时计数。通常,你不需要像这样跟踪函数的调用次数,尽管你可以使用这种机制来限制程序调用某些需要大量处理能力的函数的频率(这种技术被称为速率限制)。你可以通过定期重置计数器来实现这一点——也许每分钟一次——并且当计数器超过某个阈值时跳过函数调用。

将函数作为参数传递

在 JavaScript 中,函数是一等公民,这意味着它们可以像其他值一样使用,例如数字或字符串。例如,你可以将函数存储在变量中,或者将函数作为参数传递给另一个函数。后一种情况尤其常见,因为有许多函数将工作委托给其他函数。当一个函数作为参数传递时,它通常被称为回调函数,因为传递它的函数会“回调”它并执行它。

我们将通过 JavaScript 内置的 setTimeout 函数来说明这一点,setTimeout 允许你延迟调用另一个函数。它需要两个参数:一个要调用的函数,和一个等待时间(以毫秒为单位),即在调用该函数之前需要等待的时间。以下是它的工作原理:

**function sayHi() {**
 **console.log("Hi!");**
**}**
**setTimeout(sayHi, 2000);**
1
Hi! 

首先,我们创建一个没有参数的简单函数 sayHi,它只调用 console.log。然后我们调用 setTimeout,传递 sayHi 函数和数字 2000(表示 2000 毫秒,即 2 秒)作为参数。一旦按下 ENTER,setTimeout 应立即返回一个超时 ID——在这种情况下是 1——这是一个唯一的标识符,你可以用它来取消延迟的函数调用(如果需要的话)。然后,经过两秒钟,sayHi 函数被调用,字符串 "Hi!" 被记录到控制台。

注意

要取消通过 setTimeout 延迟的函数调用,请调用 clearTimeout 函数,并将超时 ID 作为参数传递。

注意,当我们将函数作为参数传递时,我们写下的是它的名字而不带括号:在这种情况下是 sayHi 而不是 sayHi()。没有括号的函数名仅仅是引用该函数,而带括号的函数名则实际调用该函数。我们可以在 JavaScript 控制台中看到这种区别:

❶ **sayHi;**
f sayHi() {
  console.log("Hi!");
}

❷ **sayHi();**
Hi!
undefined 

仅仅执行 sayHi; 不带括号 ❶ 会打印出函数的定义,但不会调用它。然而,执行 sayHi(); 带括号 ❷ 会调用 sayHi 函数,打印字符串 "Hi!" 并返回 undefined。

其他函数语法

到目前为止,我们在本章中主要关注了使用函数声明来创建函数,但 JavaScript 也支持其他创建函数的方法。函数声明遵循简洁的格式,并使用类似于许多其他编程语言(如 C++ 和 Python)中定义函数的语法。当你编写函数并打算直接调用时,像我们讨论过的 sayHello 和 add 函数,使用函数声明完全没问题。然而,一旦你开始将函数作为值来传递(例如作为参数),其他的创建函数的方式就会变得更加有用。接下来我们将介绍这些方式,首先从函数表达式开始。

函数表达式

函数表达式,也称为函数字面量,是一种其值为函数的代码字面量,就像 123 是一个其值为数字 123 的字面量一样。而函数声明创建一个函数并将其绑定到一个名称上,函数表达式则是一个求值为(返回)函数的表达式,供你自由使用。

从语法上看,函数表达式与函数声明非常相似,主要有两个不同之处。首先,函数表达式不需要包括名称,尽管你可以根据需要添加名称。没有名称的函数表达式也被称为匿名函数。其次,函数表达式不能写在代码行的开头,否则 JavaScript 会认为它是一个函数声明;在 function 关键字之前必须有一些代码。这也是为什么函数表达式通常用于函数需要作为值来处理的上下文。

例如,你可以定义一个函数表达式并将其赋值为一个变量的值,所有操作都在一条语句中完成,如下所示:

**let addExpression = function (x, y) {**
 **return x + y;**
**};** 

在赋值语句的右侧出现function关键字,而不是在行首,因此 JavaScript 会将其视为一个函数表达式。在这种情况下,我们将函数表达式赋值给了 addExpression 变量。函数本身是匿名的,因为我们没有在function关键字后提供一个名称(在接下来的“命名函数表达式”框中,你会看到我们如何这样做的例子)。它有两个参数,x 和 y,括号中指定,就像我们最初的 add 函数一样。函数体返回这两个参数的和,并用大括号括起来,类似于函数声明的函数体,但请注意,我们需要在大括号闭合后加上分号,以表示将函数赋值给变量的语句结束。

尽管函数本身在技术上是匿名的,但它现在已绑定到 addExpression 变量。因此,我们可以通过在变量名后加上一对括号并传入必要的参数来调用该函数,就像调用任何命名函数一样:

**addExpression(1, 2);**
3 

输入 addExpression(1, 2) 会调用该函数,返回两个参数的和。

在许多方面,函数表达式和函数声明是可以互换的,所以选择这两种方法中的任何一种通常只是风格问题。例如,将我们的两个数字相加函数定义为函数表达式并将其赋值给一个变量,在大多数情况下等同于最初使用函数声明的方式。不过,当涉及将函数作为参数传递时,函数表达式提供了一些优势。例如,之前我们声明了 sayHi 函数,然后将它的名字传递给 setTimeout 作为参数。更常见的做法是直接在 setTimeout 函数的参数列表中写出一个等效的函数表达式,而不是先将其赋值给一个变量:

**setTimeout(function () {**
 **console.log("Hi!");**
**},** **2000);**
2
Hi! 

之前我们调用了 setTimeout(sayHi, 2000),将一个函数名作为第一个参数传递,但这次我们传递的是一个函数表达式。该函数表达式定义了一个匿名函数,用于向控制台打印"Hi!"(这与我们之前声明的 sayHi 函数等效)。注意,function关键字不是代码行中的第一个元素,这是函数表达式的一个要求,并且闭括号后跟着一个逗号,因为这个函数表达式是参数列表的一部分。

如前所述,调用 setTimeout 会返回一个超时 ID,这次是 2。然后,当我们的匿名函数在两秒钟后被调用时,"Hi!" 会出现在控制台中。在这种情况下,使用函数表达式更简洁,因为我们不需要在传递给 setTimeout 之前单独定义延迟函数。

箭头函数

JavaScript 还有另一种定义函数的语法,称为箭头函数表达式,或简称箭头函数。箭头函数是函数表达式的一种更简洁的版本,在大多数情况下,选择使用哪一种完全是风格上的问题。你可以在任何适用普通函数表达式的地方使用箭头函数,并在此过程中节省一些输入。例如,下面是使用箭头函数语法编写一个将两个数字相加的函数:

**let addArrow = (x, y) => {**
 **return x + y;**
**};** 

箭头函数不使用function关键字。相反,它以参数列表开始——在这个例子中是(x, y)——后面跟着一个箭头(=>)和函数体。在这里,我们将箭头函数赋值给 addArrow 变量,这样我们就可以像调用其他函数一样调用它:

**addArrow(2, 2);**
4 

我们使用块体语法定义了 addArrow,其中函数体被放置在大括号之间,每个语句都写在自己缩进的一行上。然而,如果函数体仅包含一个语句,还有一种更简单的语法,称为简洁体

**let addArrowConcise = (x, y) => x + y;**

在这里,函数体与其余的语句写在同一行,并且没有被大括号包围。同时,return关键字是隐式的,这意味着函数体中的表达式(在此例中为 x + y)自动被理解为函数的返回值。这种简洁的函数体语法非常适合编写简单的函数,但如果你的函数体涉及多个语句,你就必须使用块体语法(如果函数有显式的返回值,还需要包含return关键字)。

如果箭头函数只有一个参数,你可以通过省略参数名周围的圆括号进一步简化语法:

**let squared = x => x * x;**
**squared(3);**
9 

这个箭头函数接受一个数字 x,并返回它的平方(x * x)。由于 x 是函数的唯一参数,我们不需要将其放在圆括号中。这对于块体和简洁体语法都适用。

像函数表达式一样,箭头函数提供了一种高效的方式来定义作为参数传递的函数。为了说明这一点,我们将考虑 JavaScript 内置的 setInterval 函数。像 setTimeout 一样,它接受另一个函数和一个时间(以毫秒为单位)作为参数,但与 setTimeout 不同的是,它会重复调用提供的函数,在每次调用之间等待指定的时间。例如,在这里,我们传递给 setInterval 一个箭头函数,它会将字符串 "Beep" 打印到控制台:

**setInterval(() => {**
 **console.log("Beep");**
**}, 1000);**
3
Beep 

我们的箭头函数不接受任何参数,因此它以一对空圆括号开始作为参数列表。函数体末尾的闭合大括号后面跟着一个逗号,用来将箭头函数与传递给 setInterval 的下一个参数分隔开,这个参数指定了每次重复之间的暂停时间(1,000 毫秒,即一秒钟)。

当我们执行这段代码时,它首先返回一个用于取消重复的间隔 ID——在本例中为 3。然后,在一秒钟的延迟后,第一个 "Beep" 会被打印出来。之后,控制台输出的左侧应该会显示一个数字,每秒递增,表示 console.log("Beep") 被调用的次数。Chrome 使用这个技巧来避免控制台中重复输出的行数过多。当你准备好停止 Beep 时,只需刷新浏览器页面,或者调用 clearInterval 函数并传入间隔 ID。在我们的例子中,应该是 clearInterval(3)。

剩余参数

有时你希望你的函数能够接受可变数量的参数。例如,假设你想编写一个函数,它接受某人的名字和他们最喜欢的颜色,并将它们打印成一个句子。你事先并不知道用户会输入多少个最喜欢的颜色,因此你希望让你的函数足够灵活,以处理传入的任意数量的颜色。在 JavaScript 中,你可以使用 剩余参数 来实现这一点,剩余参数是一种特殊类型的参数,它将可变数量的参数收集到一个数组中。

剩余参数可以与任何样式的函数定义一起使用。这里我们使用一个剩余参数来创建一个箭头函数,用于列出用户的最爱颜色:

**let myColors = (name, …favoriteColors) => {**
 **let colorString** **= favoriteColors.join(", ");**
 **console.log(`My name is ${name} and my favorite colors are ${colorString}.`);**
**};**
**myColors("Nick", "blue", "green", "orange");**
My name is Nick and my favorite colors are blue, green, orange. 

一个 rest 参数看起来像是一个普通参数,前面有三个点,它必须始终是函数定义中列出的最后一个参数。当调用函数时,任何常规参数(按顺序列出)都会与第一个提供的参数匹配。然后,rest 参数将其余的参数捆绑成一个数组。在我们的示例中,name 是一个常规参数,favoriteColors 是 rest 参数。当我们调用函数时,"Nick" 参数会被分配给 name 参数,其余的参数 "blue"、"green" 和 "orange" 会被收集到一个数组中,并分配给 favoriteColors 参数。因为 favoriteColors 是一个数组,我们可以使用 join 方法将其转换为一个字符串,用逗号和空格分隔每个颜色。然后,我们使用模板字面量将颜色字符串融入到一个更大的字符串中,并使用 console.log 打印出来。

由于 favoriteColors 是一个 rest 参数,我们可以根据需要使用任意数量的颜色:

**myColors("Boring", "gray");**
My name is Boring and my favorite colors are gray.
**myColors("Indecisive", "red", "orange", "yellow", "green", "blue", "indigo", "violet");**
My name is Indecisive and my favorite colors are red, orange, yellow, green, blue, indigo, violet. 

无论我们提供多少个参数,函数仍然能够正常工作。

这是另一个使用 rest 参数的示例,这次是将所有作为参数提供的数字相加:

**function sum(…numbers) {**
 **let total = 0;**
 **for (let number of numbers) {**
 **total += number;**
 **}**
 **return total;**
**}**
**sum(1, 2, 3, 4, 5);**
15
**sum(6, 7, 8, 9, 10, 11, 12, 13);**
76 

这次我们使用了一个函数声明,而不是箭头函数,且函数的唯一参数是 rest 参数。由于没有其他参数,所有参数都被收集到一个数组中,并分配给 numbers rest 参数。然后,我们使用 for…of 循环将这些数字加起来。

高阶函数

高阶函数 是一种接受另一个函数作为参数,或者输出另一个函数作为返回值的函数。在本章中,你已经见过了两个高阶函数:setTimeout 和 setInterval,它们都接受一个回调函数作为参数,稍后执行。JavaScript 还有许多其他内置的高阶函数。我们将在这里考虑一些,并讨论如何编写你自己的高阶函数。

接受回调的数组方法

有许多内置的方法用于处理数组,这些方法接受一个回调函数。记住,方法是一种对对象(如数组)操作的函数。在大多数情况下,传递给这些高阶数组方法的回调函数会为数组中的每一项执行一次。让我们看几个示例。

查找数组元素

find 数组方法用于查找数组中第一个符合某些条件的元素。你可以通过回调函数指定条件,回调函数返回一个布尔值 true/false。例如,如果我们想查找购物清单中第一个字符数大于六的项,可以这样做:

**let shoppingList** **= ["Milk", "Sugar", "Bananas", "Ice Cream"];**
**shoppingList.find(item => item.length > 6);**
'Bananas' 

我们传递给 find 的回调函数是 item => item.length > 6。这个回调函数利用了箭头函数的两个有用的语法特性。首先,由于我们的函数只有一个参数 item,我们可以省略括号。其次,由于函数体仅包含一个语句 item.length > 6,我们可以使用简洁的函数体语法,省略 return 关键字和大括号。这些特性让我们能够尽可能简洁地定义查找元素的逻辑,使得箭头函数非常适合编写简单的回调函数。

find 方法依次对数组中的每个元素执行回调函数。回调函数接受元素并根据该元素是否包含超过六个字符来返回 true 或 false。如果回调函数对某个元素返回 true,find 方法将返回该元素并停止搜索。在这种情况下,方法返回 "Bananas" 而不是 "Ice Cream",因为 "Bananas" 在数组中出现得更早。

如果没有找到符合条件的项,find 方法将返回 undefined:

**shoppingList.find(item => item[0] ===** **"****A****"****);**
undefined 

这次我们给 find 传递一个回调函数,该函数检查元素是否以字母 A 开头。购物清单中的项目没有任何一个以字母 A 开头,因此方法返回 undefined。

过滤数组中的元素

filter 方法返回一个新数组,其中包含原数组中所有满足某些条件的元素。与 find 方法一样,条件是通过回调函数来指定的。为了说明这一点,我们将更新原来的 find 示例,将方法名改为 filter。这样,我们将得到一个包含所有超过六个字符的项目的列表,而不仅仅是通过测试的第一个项目:

**let shoppingList** **= ["Milk", "Sugar", "Bananas", "Ice Cream"];**
**shoppingList.filter(item => item.length > 6);**
`(2) ['Bananas', 'Ice Cream']` 

这将过滤掉字符长度太短的数组元素,同时将 "Bananas" 和 "Ice Cream" 保留在结果数组中。

转换数组中的每个元素

有时你可能希望转换数组中的每个元素,并将结果存储在一个新数组中。例如,你可能有一个包含数字的数组,这些数字需要以相同的方式进行操作。你可以使用我们在第四章中讨论的 for…of 循环来实现,但更简洁的方法是使用 map 数组方法。它将相同的回调应用于数组中的每个元素,并返回一个包含结果的新数组。例如,在这里,我们使用 map 来接受一个数字数组并生成一个包含这些数字立方的新数组:

**let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];**
**let cubes = numbers.map(x => x * x * x);**
**cubes;**
`(10) [1, 8, 27, 64, 125, 216, 343, 512, 729, 1000]` 

我们的回调函数 x => x * x * x 接受数组元素并将其立方。map 方法将这个回调应用到 numbers 数组的每个元素,返回一个包含前 10 个完美立方数的新数组,同时保持原数组不变。将 map 与箭头函数的简洁语法进行比较,看看使用 for…of 循环的等效代码:

**let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];**
**let cubes = [];**
**for (let x of numbers) {**
 **cubes.push(x * x * x);**
**}**
**cubes;**
`(10) [1, 8, 27, 64, 125, 216, 343, 512, 729, 1000]` 

结果是相同的,但使用 map 时,我们能够在一行代码中声明并填充 cubes 数组,而不需要先声明 cubes 为一个空数组,然后在 for…of 循环体内填充它。

如果你有一个包含相似对象的数组,并且你想从每个对象中提取相同的信息,map 方法也很有用。例如,假设你有一个表示商店商品的对象数组,每个对象都有一个名称和价格属性,而你想要获取一个仅包含价格的数组。你可以传递给 map 一个回调函数,访问每个对象的价格属性,像这样:

**let stockList = [**
 **{name: "Cheese", price: 3},**
 **{name: "Bread", price: 1},**
 **{name: "Butter", price: 2}**
**];**
**let prices = stockList.map(item => item.price);**
**prices;**
`(3) [3, 1, 2]` 

在这里,回调函数是 item => item.price,它接收一个 item 并返回该 item 的 price 属性的值。map 函数将回调依次应用于原始数组中的每个对象,并创建一个包含所有价格的新数组。

一般来说,尽可能使用 map 而不是等效的循环,因为 map 方法简洁,而且代码具有 自文档化 特性(map 方法的名称暗示你正在创建一个新数组,该数组从另一个数组中复制并修改元素,无需进一步注释)。当你的需求更加定制时,使用循环会更合适,例如,当输出数组中的元素数量与原始数组中的元素数量不匹配时。

接受回调函数的自定义函数

要创建一个接受回调作为参数的高阶函数,只需像命名其他参数一样,在函数的参数列表中包含回调的名称。然后,当你想在函数体内调用回调时,在参数名称后添加括号,就像调用任何其他函数一样。让我们通过声明一个接受回调并调用它两次的 doubler 函数来说明这一点:

**function doubler(callback) {**
 **callback();**
 **callback();**
**}** 
**doubler(() => console.log("Hi there!"));**
Hi there!
Hi there! 

当我们定义 doubler 函数时,我们给它一个回调参数。然后,在函数体内,我们写两次 callback(),以对传递给该参数的函数进行两次调用。当我们调用 doubler 时,我们传递给它一个将 "Hi there!" 打印到控制台的函数,因此该消息会被打印两次。请注意,这个回调函数不需要任何参数,因此我们在箭头符号之前写了一个空的括号。

如我们所讨论的,JavaScript 对函数参数的集合数据类型没有概念,因此没有任何东西阻止我们尝试传递一个不是函数的值作为 doubler 的参数。但是,如果我们这样做,当 JavaScript 尝试调用非函数时,会出现错误:

**doubler("hello");**
Uncaught TypeError: callback is not a function
at doubler (<anonymous>:2:5)
at <anonymous>:1:1 

在这里,我们传递给 doubler 一个字符串,而不是一个函数,因此我们会得到一个 TypeError 错误。

我们传递给 doubler 的回调函数不需要任何参数,但你也可以设置一个高阶函数,使其回调函数接受参数。例如,在这里,我们创建一个函数,该函数调用另一个函数若干次,将当前调用次数传递给回调函数:

**function callMultipleTimes(times, callback) {**
 **for (let i** **= 0; i < times; i++) {**
 **callback(i);**
 **}**
**}** 

我们声明 callMultipleTimes 函数有两个参数:一个要调用的函数(callback)和调用次数(times)。(注意,与 setTimeoutsetInterval 中回调函数是第一个参数不同,我们这里的函数遵循更常见的 JavaScript 约定,将回调函数放在最后一个参数。)函数体由一个 for 循环组成,在该循环中我们调用 callback(i),将循环变量 i 作为参数传递给回调函数。

因为回调函数只接收一个参数,我们知道传递给 callMultipleTimes 的回调函数应该有一个参数。例如:

**callMultipleTimes(3, time => console.log(`This was time: ${time}`));**
This was time: 0
This was time: 1
This was time: 2 

这里我们传递了一个箭头函数作为回调函数。它有一个 time 参数。这个函数将时间信息整合到一条消息中,并将其记录到控制台。每次执行这个回调时,time 都会取循环变量 i 的当前值,分别将 0、1 和 2 插入到记录的消息中。

返回函数的函数

到目前为止,我们关注的是将函数作为参数的高阶函数,但高阶函数也可以输出一个函数作为返回值。例如,假设你想创建多个函数,将一个后缀添加到字符串的末尾,比如添加 "!!!" 使字符串看起来更激动人心,或者 "???" 使它看起来更加迷惑。与其为每个可能的后缀手动定义一个单独的函数,或者为文本和后缀定义一个函数,每次调用时都必须提供后缀,不如定义一个高阶函数,它接收一个后缀并返回一个将该后缀附加到字符串的函数:

**function makeAppender(suffix) {**
❶ **return function (text) {**
  ❷ **return text + suffix;**
 **};**
**}** 

这里有两个 return 关键字。第一个 ❶ 被高阶函数 makeAppender 使用,用来返回一个匿名函数。后面跟着 function 关键字,表示我们正在定义一个将被返回的函数。第二个 return 关键字 ❷ 出现在匿名函数内部。当那个函数被调用时,它会返回匿名函数的 text 参数与 makeAppender 函数的 suffix 参数连接后的值。

为了能够调用内部函数,我们首先必须通过调用外部函数来访问它:

**let exciting = makeAppender("!!!");**
**exciting("Hello");**
'Hello!!!' 

调用 makeAppender("!!!") 返回一个新的函数,我们将其赋值给 exciting 变量。这个变量现在包含了从 makeAppender 返回的函数表达式,它接受一个字符串作为参数。当我们调用 exciting("Hello") 时,我们得到字符串 "Hello!!!",这是将 "Hello" 和 "!!!" 两个字符串连接起来的结果。

我们的高阶函数 makeAppender 的好处在于,我们可以利用它生成用于附加其他后缀的函数,而不仅仅是 "!!!"。例如:

**let puzzling =** **makeAppender("???");**
**puzzling("Hello");**
'Hello???'
**let winking = makeAppender(" ;-)");**
**winking("Hello");**
'Hello ;-)' 

在这里,makeAppender 返回了两个额外的函数,我们将它们分别赋值给了 puzzling 和 winking 变量。我们只需要定义一个高阶函数,但现在我们有了三个不同的后缀附加函数可以选择,并且可以随意重复使用它们:

**winking("Goodbye");**
'Goodbye ;-)'
**puzzling("Goodbye");**
'Goodbye???'
**exciting("Goodbye");**
'Goodbye!!!' 

请注意,我们从 makeAppender 返回的每个函数都记住了我们传入的 suffix 值,这就是它能够不断地附加相同后缀的原因。每个函数都是在 makeAppender 的作用域内定义的,因此即使 makeAppender 的调用已经完成,它返回的内部函数仍然能够保持对该作用域中其他值的访问,包括 suffix。

我们在第四章讨论了作用域,例如,如何定义在 while 或 for 循环中的变量无法在循环外部访问。类似地,在函数内部定义的变量只在该函数内部有效,因此通常在函数调用结束后就会消失。然而,作用域在嵌套函数中变得更加有趣,如当前示例所示。你可能会认为外部的 makeAppender 函数在调用后会“消失”,但内部函数保留了对来自该作用域的变量和参数的访问,只要我们保持对内部函数的引用(我们通过变量 exciting、puzzling 和 winking 来实现)。那些保留对其所在作用域中的变量和参数访问的函数被称为闭包,因为它们“封闭”了它们的环境。(可以想象,内部函数有一个罩子,能够保持作用域中的所有变量。)

总结

本章中,你学到了如何通过创建和使用自定义函数使你的代码更加易读和简洁。你了解了定义函数的三种主要方式——函数声明、函数表达式和箭头函数,并且实验了块体语法和简洁体语法。你学会了如何通过传递参数值来向函数提供输入,并了解了如何利用函数的工作成果,无论是通过其返回值、其副作用,还是两者兼有。你还看到了如何将函数赋值给变量,以及如何将函数传递给高阶函数或从高阶函数返回。

第十三章:6 类

是一个强大的编程工具,用于生成具有共享特征和行为的多个对象。它们是面向对象编程的核心部分,面向对象编程是一种围绕创建包含数据和操作这些数据的函数的对象的编码风格。例如,在一个面向对象的多人游戏中,你可以将每个玩家表示为一个 Player 类的对象,将每个敌人表示为一个 Enemy 类的对象。类将确定玩家或敌人应具备什么样的数据,并包含使玩家或敌人执行动作(如移动或攻击)的函数。

在本章中,你将学习如何创建 JavaScript 类,并如何使用这些类创建单个对象。你还将学习如何利用继承在不同类之间共享行为。以这种方式使用类和面向对象编程可以为你的代码提供结构,并使其更易于阅读、编写和理解,特别是当你的程序涉及大量具有共同行为的实体时。

创建类和实例

就像是一个用于创建标准化对象的模板。在第三章中,我们讨论了对象如何是由键值对组成的复合数据类型,并展示了如何通过手动编写对象字面量来创建对象。类将这个过程自动化,允许你通过类似调用函数的语法来创建对象。

一个类定义了两件主要的事情:

1.  该类的每个对象应该具有什么属性。(记住,属性是对象中键值对的另一种说法。)

2.  对象应该具有什么功能。(当这些功能作为类的一部分定义并调用时,这些功能被称为方法。)

游戏中的 Player 类可能包括玩家的名字、健康值、在环境中的位置等属性。它可能具有移动、射击、拾取物品等方法。该类可以用来创建多个不同的玩家。

从类创建的对象被称为该类的实例。例如,游戏中每个玩家的角色将是 Player 类的一个实例。每个实例用自己的详细信息填充类的通用模板。一个特定的 Player 实例将具有自己的名字、健康值和位置,这些与其他 Player 实例的不同。然而,所有实例都可以使用该类的方法。

为了了解这如何工作,我们将为一个假设的 2D 游戏创建一个简单的 Player 类。现在,我们只为玩家定义一个位置,通过一组 x 和 y 坐标来表示,并提供一个改变这些坐标的移动方法。输入以下内容在 JavaScript 控制台中声明该类:

**class Player {**
❶ **constructor(startX, startY) {**
 **this.x = startX;**
 **this.y = startY;**
 **}**

❷ **move(dx, dy) {**
 **this.x +=** **dx;**
 **this.y += dy;**
 **}**
**}** 

我们以 class 关键字开始,表示我们正在声明一个新类,接着是类的名称 Player。类名通常以大写字母开头。然后是类体,它被大括号包围,就像函数体一样。在类体内部,我们定义了两个方法,constructor ❶move ❷。声明类方法就像声明函数一样,但我们不使用 function 关键字。

如果一个类有一个名为 constructor 的方法,就像我们的 Player 类那样,每当你创建该类的实例时,这个方法会被自动调用。构造方法会为正在创建的对象执行任何必要的设置工作,包括接收定义实例的任何参数,并确定对象应具备的属性。在这种情况下,我们的 Player 类的构造方法接收两个参数,startXstartY,并将它们赋值给新实例的 xy 属性,这两个属性一起跟踪玩家在 2D 游戏中的位置。this 关键字指的是当前正在创建的实例,因此 this.x = startX 意味着“取 startX 的值并将其赋给新的 Player 对象的 x 属性。”注意,我们在这里使用的点表示法与之前访问对象属性时使用的相同;唯一的区别是,this 作为新对象名称的占位符。

move 方法通过根据提供的 dxdy 参数来更新玩家的位置,从而改变 xy 属性。dxdy 中的 d 是希腊字母 delta 的缩写,通常用来表示某物的变化量,比如“x 值的变化”和“y 值的变化”。

现在我们已经声明了 Player 类,可以创建它的实例。例如:

**let player1 = new Player(0, 0);**

我们使用 new 关键字后跟类名来创建 Player 类的新实例。在类名后,我们写一对括号,就像调用函数时一样。括号中包含需要传递给类的构造方法的参数。

当你使用 new 创建一个类的新实例时,一些神奇的事情发生了。首先,会创建一个新的空对象。然后,这个对象与类之间会创建一个隐藏的链接,这样 JavaScript 就能够知道是哪个类创建了这个对象,以及该对象应当具有哪些方法。接下来,类的构造方法会被自动调用。在构造方法内部,通过关键字 this 可以访问正在创建的新对象,从而允许你为该对象设置属性。你在类名后括号中的任何参数都会被传递给构造方法的参数。构造方法调用完成后,新对象就会被返回。

在我们的示例中,当我们输入let player1 = new Player(0, 0);时,JavaScript 会创建一个新对象并给它一个与 Player 类的隐式链接。然后,它调用类的构造方法,将参数 0 和 0 传递给构造函数的 startX 和 startY 参数。构造函数使用这些参数并通过this.xthis.y将新对象的 x 和 y 属性设置为 0。最后,返回新对象并将其赋值给player1变量。

我们现在可以与新对象进行交互。例如,在这里,我们查看它的位置,告诉它移动,然后再次查看它的位置以确认 move 方法是否起作用:

**player1.x;**
0
**player1.y;**
0
**player1.move(3, 4);**
**player1.x;**
3
**player1.y;**
4 

我们通过player1.xplayer1.y分别访问对象的 x 和 y 属性。它们的值都是 0,因为我们将 0 传递给了构造函数。接下来,我们调用了在 Player 类中定义的 move 方法。由于实例与创建它们的类之间有一个隐式链接,它们能够调用类中定义的方法。我们使用点符号来调用方法,就像调用与字符串或数组相关的内置方法一样。

当你在对象上调用一个方法时,方法定义中的this关键字会指向当前对象(接收者)。例如,当我们调用player1.move(3, 4)时,this在 move 方法的内部绑定到player1对象。这就是为什么一个方法可以被多个对象共享的原因:this会变成任何在某一时刻接收方法调用的对象。

move 方法通过将 dx 和 dy 加到当前的 x 和 y 值上来更新对象的 x 和 y 属性。例如,当我们调用player1.move(3, 4)时,我们将 x 设置为 0 + 3,y 设置为 0 + 4。当我们再次查看对象的 x 和 y 属性时,可以看到操作成功:player1.x变成了 3,player1.y变成了 4。如果我们再调用一次 move 方法,例如player1.move(2, 2),x 将变为 5,y 将变为 6。

继承

继承是面向对象编程中定义不同类之间关系的一种机制。就像孩子从父母那里继承基因一样,“子”类继承“父”类的属性和方法,获得父类的属性和方法。当你有多个类需要共享一组通用行为,并且每个类又有一些独特行为时,这非常有用。你可以将通用行为定义为父类的一部分,也叫做超类。然后,你可以定义子类,也叫做子类,来继承这些行为并使用其他专门的行为来扩展它们。这可以避免在定义每个子类时重复编写通用代码。

举个例子,在我们的 2D 游戏中,人类控制的玩家和计算机控制的敌人可能有很多相似之处。例如,它们都需要 x 和 y 属性来表示其位置,并且它们都需要一个 move 方法来改变自己的位置。然而,它们也有一些不同之处。也许敌人有能力攻击靠得太近的玩家,而玩家则不能攻击敌人——游戏的目标是玩家避免敌人,而不是击杀敌人。

我们可以利用继承以最小的代码量实现这个方案。我们将创建一个新的类 Actor,表示游戏中的 任何 参与者。它将包含玩家和敌人都应该拥有的一般代码,例如 move 方法。然后我们将定义 Player 和 Enemy 作为 Actor 的子类。它们将继承 Actor 超类中的一般代码,同时也会添加只针对玩家或敌人的特定代码。

首先,这是 Actor 类的定义。它基本上是我们之前的 Player 类的复制品,但名字不同。我们还添加了一个新的方法,叫做 distanceTo,用于计算游戏中两个参与者之间的距离:

**class Actor {**
 **constructor(startX, startY) {**
 **this.x = startX;**
 **this.y = startY;**
 **}**

 **move(dx, dy) {**
 **this.x += dx;**
 **this.y += dy;**
 **}**

 **distanceTo(otherActor) {**
 **let dx = otherActor.x - this.x;**
 **let dy = otherActor.y - this.y;**
 **return Math.hypot(dx, dy);**
 **}**
**}** 

distanceTo 方法接收另一个 Actor(或任何具有 x 和 y 坐标的对象)作为参数,并返回到该对象的距离。将对象传递给其他对象的方法是非常常见的做法。距离是通过计算水平距离(otherActor.x - this.x)和垂直距离(otherActor.y - this.y),然后使用内置的 Math.hypot 方法来找到由这两个距离形成的直角三角形的斜边长度来确定的。这是基于毕达哥拉斯定理的标准数学技巧,用来计算二维平面上两点之间的距离。

尽管从技术上讲,可以创建 Actor 类的实例,但它并不真正打算被实例化。像 Actor 这样的类,主要是供子类扩展的,有时被称为 抽象类,因为它们代表了一种抽象的概念,例如游戏中的通用实体。与此同时,像 Player 和 Enemy 这样的类,它们打算被实例化,通常被称为 具体类,因为它们代表了一些具体的事物,比如实际的玩家或敌人。

接下来,我们将重新定义 Player 类,让它继承自 Actor。我们将添加一个新属性,专门用于玩家,叫做 hp(即 生命值),表示玩家的健康水平——Enemy 类不需要这个属性,因为只有玩家可以被攻击,而敌人不能:

**class Player extends Actor {**
 **constructor(startX, startY) {**
 **super(startX, startY);**
  ❶ **this.hp = 100;**
 **}**
**}** 

这次我们使用 extends 关键字声明类,将 Player 作为 Actor 的子类。我们只需要编写类的构造方法,因为它继承了 Actor 的 movedistanceTo 方法。构造方法接收 startXstartY 参数,就像之前一样。

在构造函数中,我们首先调用 super(startX, startY)。在子类的构造函数中,super 关键字指代的是父类的构造函数——在这种情况下是 Actor 类的构造函数。因此,当我们创建一个新的 Player 实例时,Player 构造函数会自动调用,从而间接调用 Actor 构造函数(通过 super)。我们将 startX 和 startY 传递给 Actor 构造函数,Actor 使用这些值来设置 Player 对象的 x 和 y 属性。然后,在 Player 类的构造函数中,我们将新的 Player 实例的 hp 属性设置为 100 ❶。这样,每个新玩家的初始生命值为 100(满血)。

接下来,我们将创建我们的 Enemy 类。它同样会继承 Actor 类,并通过添加攻击方法来扩展它,用于攻击玩家(Player 类不需要这个方法,因为只有敌人可以攻击):

**class Enemy extends Actor {**
 **attack(player) {**
 **if (this.distanceTo(player) < 4) {**
 **player.hp -= 10;**
 **return true;**
 **} else {**
 **return false;**
 **}**
 **}**
**}** 

我们声明 Enemy 类继承自 Actor,就像 Player 类一样。然而,与 Player 不同,Enemy 类没有任何需要在构造函数中设置的额外属性(如 hp)。因此,Enemy 类没有自己的构造函数方法。当子类没有定义构造函数时,父类的构造函数会在创建子类新实例时自动调用。因此,新的 Enemy 实例仍然会通过 Actor 父类的构造函数方法获得初始位置,但我们不需要在 Enemy 类声明中明确显示这一点。

没有构造函数,Enemy 类唯一独特的方法是 attack。它接收一个 Player 对象作为参数,并检查与该对象的距离,使用从 Actor 类继承的 distanceTo 方法。(注意,我们通过 this.distanceTo 调用该方法,再次使用 this 关键字来引用当前的 Enemy 实例。)如果距离小于 4,敌人就可以攻击,将玩家的 hp 值减少 10。我们返回 true 以表示这是一次成功的攻击。如果攻击失败,因为玩家太远,我们则返回 false。

现在我们已经有了 Player 和 Enemy 类,可以看看它们是如何交互的。让我们创建每个类的实例,移动它们,并让敌人攻击玩家:

**let player = new Player(1, 2);**
**let enemy = new Enemy(3, 4);**
**player.hp;**
100
**enemy.distanceTo(player);**
2.8284271247461903
**enemy.attack(player);**
true
**player.hp;**
90
**player.move(5, 5);**
**enemy.attack(player);**
false
**player.hp;**
90 

首先,我们创建每个类的实例,位置分别为(1, 2)和(3, 4)。Player 对象初始时满血,正如 player.hp 所示。这两个对象相距大约 2.8 个单位,我们通过调用 enemy.distanceTo(player)来确认这一点。此时,敌人足够接近,可以成功攻击玩家,因此我们使用 enemy.attack(player)调用敌人的攻击方法。该方法返回 true,表示命中,检查 player.hp 后发现攻击将玩家的生命值降低到 90。接下来,我们将玩家在 x 和 y 方向上各移动 5 个单位。这个移动将玩家置于敌人攻击范围之外,因此敌人的第二次攻击失败,返回 false。最后检查 player.hp,发现玩家的生命值仍然为 90。

注意,在这段代码中,我们调用了 Enemy 对象上的 distanceTo 方法,以及 Player 对象上的 move 方法。这些方法都是在 Actor 类中定义的,但它们也可以在 Enemy 和 Player 类中使用,这证明了子类成功地从超类继承了方法。我们还可以使用instanceof关键字来验证这一点,它用于测试一个对象是否是特定类的实例。例如,在这里我们用 player 对象进行测试:

**player instanceof Player;**
true
**player instanceof Actor;**
true
**player instanceof Enemy;**
false 

正如你可能预期的那样,player 是 Player 的一个实例。令人惊讶的是,player 也是 Actor 的一个实例。当像 Player 这样的子类继承自像 Actor 这样的超类时,子类的实例也被认为是超类的实例。另一方面,player 不是 Enemy 的实例,尽管 Player 和 Enemy 类共享一个共同的超类。

在这个例子中,我们使用了单一的继承层级:一个 Actor 超类与 Player 和 Enemy 子类。一个更复杂的游戏可能会使用多个继承层级来创建不同类型的玩家和敌人。例如,可能会有 Witch、Elf 和 Centaur 类,它们都是 Player(而 Player 又是 Actor 的子类)的子类。这些子类将共享一些在 Player 超类中定义的共同能力(以及在 Actor 中定义的任何方法),同时也会有自己在个别子类中定义的专门能力。同样,Enemy 可能会有像 Troll、Demon 和 Harpy 这样的子类。

基于原型的继承

当 JavaScript 最初创建时,还没有类,但仍然可以使用基于原型的继承在对象之间共享行为。这个旧系统,今天仍然与类系统一起使用,依赖于两个机制:

1.  一个构造函数,用于创建并返回新对象。在这种情况下,构造函数只是一个普通的独立函数(不是定义在类中的函数),但它是通过new关键字调用的。

2.  一个原型,是构造函数用作它所创建的对象模型的示例对象。新创建的对象会从原型对象继承方法和属性。

JavaScript 是少数使用基于原型的继承而非类的主流语言之一。认识到这一点,开发该语言的委员会最终决定增加对类的支持,以使 JavaScript 对有其他现代编程语言背景的新手更具吸引力。然而,当他们添加类时,他们是在现有的基于原型的继承支持上构建这一新特性。换句话说,JavaScript 的基于类的继承本质上是基于原型继承的另一种语法。(这有时被称为语法糖,因为它使语法更易接受。)

如果你已经熟悉使用类,那么学习基于原型的继承并非必需。然而,由于类是 JavaScript 的相对较新特性,在旧代码中仍然常常会遇到基于原型的继承,因此了解其工作原理是值得的。探索基于原型的继承还可以帮助我们理解 JavaScript 的一些内部机制,包括你在 Chrome 控制台中看到的神秘[[Prototype]]属性的意义。即使你最终不使用基于原型的继承,了解一些这些底层的细节也能让你更轻松地使用类。

使用构造函数和原型

正如我提到的,基于原型的继承涉及一个构造函数,它创建对象实例,以及一个原型对象,实例从中继承方法和属性。这之所以可行,是因为 JavaScript 在构造函数、原型和正在创建的新实例之间建立了链接。让我们看看这一过程是如何进行的。我们将创建一个名为 Cat 的新构造函数,并向其原型添加一个名为 sayHello 的方法。这将使我们能够创建 Cat 对象,这些对象可以访问 sayHello 方法:

**function Cat(name) {**
 **this.name = name;**
**}**
**Cat.prototype.sayHello = function () {**
 **console.log(`Miaow! My name is** **${this.name}.`);**
**};** 

我们首先创建一个名为 Cat 的构造函数,并使用 name 参数。构造函数就像类名一样,通常以大写字母开头。构造函数的主体通过 this.name = name 来将新对象的 name 属性设置为提供的 name 参数的值。与类一样,构造函数中的 this 关键字指代正在被创建的对象。

当创建 Cat 构造函数时,它会自动获得一个名为 prototype 的属性。虽然函数有属性听起来有些奇怪,但 JavaScript 函数实际上是一种对象;Cat 函数可以像 person 对象可以拥有 name 和 age 属性一样,拥有一个 prototype 属性。我们可以通过 Cat.prototype 来访问这个属性,使用与访问任何其他对象属性相同的点表示法。

Cat.prototype 的值本身是一个对象,Cat 实例应该基于这个原型进行构建。通过向这个原型对象添加方法,我们可以控制任何 Cat 实例将继承哪些方法。在这个例子中,我们使用 Cat.prototype.sayHello 将一个 sayHello 方法添加到原型中。该方法会将一个包含 this.name 值的问候语输出到控制台。当 sayHello 被作为某个实例的方法调用时,方法定义中的 this 指代该实例——就像在类中定义的方法一样——因此 this.name 指代该实例的 name 属性值。

注意

注意,Cat.prototype.sayHello 将多个点符号连接在一起:Cat.prototype 指向存储在 Cat 函数的 prototype 属性中的对象,而.sayHello 指向该对象的 sayHello 属性。这个属性还不存在,所以在这里我们将它添加到对象中,并将其值设置为一个函数表达式。

我们已经创建了一个 Cat 构造函数并将一个方法添加到它的原型中。现在让我们使用该构造函数创建一个新的实例,该实例将继承自原型:

**let kiki =** **new Cat("Kiki");**
**kiki.sayHello();**
Miaow! My name is Kiki.
undefined 

在这里,我们通过使用 new 关键字调用 Cat 构造函数来创建一个新对象,并将"Kiki"作为构造函数的 name 参数传递。我们将结果对象存储在 kiki 变量中。请注意,如果我们把 Cat 声明为一个类而不是构造函数,创建对象的语法将完全相同:new Cat("Kiki")。唯一的区别是我们是将 Cat 视为函数的名称,还是类的名称。

接下来,我们在新的实例上调用 sayHello 方法。由于 kiki 是通过 Cat 构造函数创建的,它有一个指向 Cat.prototype 的隐藏链接,JavaScript 使用这个链接来查找 sayHello 的定义。由于 sayHello 作为 kiki 对象的方法被调用,因此 sayHello 中的 this 关键字被设置为 kiki。

尽管我把实例和原型之间的链接称为“隐藏的”,但是 Chrome 控制台允许你通过特殊的[[Prototype]]属性检查它。让我们看看能从 kiki 中发现什么。在控制台中输入 kiki;,然后点击旁边的箭头查看其内容:

**kiki;**
Cat {name: 'Kiki'}
  name: "Kiki"
  [[Prototype]]: Object 

输出的第一行告诉我们 kiki 是通过 Cat 构造函数创建的。接下来,我们看到 kiki 有一个名为"name"的属性,值为"Kiki"(这是在调用构造函数时分配的)。我们还看到 kiki 有一个[[Prototype]]属性,它的值是一个对象。这就是我所说的“隐藏的”链接,指向这个实例继承的原型。它是 Cat.prototype(Cat 构造函数的原型属性)引用的相同对象。点击箭头展开[[Prototype]],看看里面有什么:

`Cat {name: 'Kiki'}`
  name: "Kiki"
  [[Prototype]]: Object
sayHello: f ()
constructor: f Cat(name)
 ❶ [[Prototype]]: Object 

我们可以看到原型对象有三个属性。第一个 sayHello,它的值是一个函数,如 f()所示。这是我们添加到原型中的 sayHello 方法。第二个 constructor,指向 Cat 构造函数。这巩固了构造函数和它用来创建新实例的原型之间的联系。最后,原型本身有一个自己的[[Prototype]]属性❶,我们稍后会探索。

比较构造函数和类

在原型链继承中,从实例到其原型,再从原型到其构造函数的引用链是 JavaScript 知道在哪里找到该实例的方法和属性的方式。事实证明,类也使用了这些相同的技术。为了演示,我们创建一个 Dog 类,它的功能与我们的 Cat 构造函数相似:

**class Dog {**
 **constructor(name) {**
 **this.name = name;**
 **}**

 **sayHello() {**
 **console.log(`Woof! My name is** **${this.name}.`);**
 **}**
**}** 

这里的构造函数方法相当于 Cat 构造函数函数,而 sayHello 方法相当于 Cat.prototype.sayHello。现在,让我们创建一个 Dog 实例,并通过扩展 [[Prototype]] 属性,将其与 kiki 实例进行比较:

**let felix = new Dog("Felix");**
**felix;**
Dog {name: 'Felix'}
  name: "Felix"
  [[Prototype]]: Object
constructor: class Dog
sayHello: f sayHello()
[[Prototype]]: Object
**kiki;**
Cat {name: 'Kiki'}
  name: "Kiki"
  [[Prototype]]: Object
sayHello: f ()
constructor: f Cat(name)
[[Prototype]]: Object 

如你所见,在这两种情况下,sayHello 方法都是通过 [[Prototype]] 链接找到的。只不过有一些小差异。例如,对于 kiki,构造函数指向一个函数,而对于 felix,它指向一个类。此外,felix 上的 sayHello 方法有名字,而 kiki 上的则没有(因为我们使用匿名函数定义了 sayHello)。

请注意,如果你想在代码中直接访问对象的 [[Prototype]] 属性,可以通过 proto 来访问:

**kiki.__proto__;**
`{sayHello: f, constructor: f}` 

即使这个属性技术上叫做 proto,我们仍然称之为 [[Prototype]] 属性,因为它在 Chrome 控制台中是以这种方式显示的。

探索 Object.prototype

任何不是通过显式构造函数创建的对象,都会隐式地使用 JavaScript 内置的 Object 构造函数创建。这个构造函数所引用的原型,通过 Object.prototype 可用,包含了所有对象应该继承的基本方法。这个原型对象标志着原型链引用的终点。所有对象最终都会追溯到 Object.prototype。

例如,尽管我们的 kiki 对象是使用 Cat 构造函数创建的,但它的原型,Cat.prototype,并没有显式地通过构造函数创建。相反,JavaScript 隐式地使用 Object 构造函数创建了这个对象,因此它的原型是 Object.prototype。这就是我们在前面的代码示例中看到的 kiki 对象内部 [[Prototype]] 属性所告诉我们的内容。我们可以扩展这个内部的 [[Prototype]] 属性来查看 Object.prototype:

`Cat {name: 'Kiki'}`
  name: "Kiki"
  [[Prototype]]: Object
sayHello: f ()
constructor: f Cat(name)
[[Prototype]]: Object
❶constructor: f Object()
hasOwnProperty: f hasOwnProperty()
isPrototypeOf: f isPrototypeOf()
propertyIsEnumerable: f propertyIsEnumerable()
toLocaleString: f toLocaleString()
toString: f toString()
`--snip--` 

值得注意的是,这个内部的原型对象有一个 constructor 属性,它的值是 Object 函数 ❶,这表明它是 JavaScript 内置 Object 构造函数的原型属性。剩下的属性对应于所有对象继承的许多默认方法。例如,hasOwnProperty 是一个检查对象是否拥有自己定义的属性的方法,而不是从其原型继承的属性;toString 是一个返回对象字符串表示的方法。

当你使用对象字面量创建一个对象时,你并没有使用显式的构造函数来创建它,因此,它也是通过 Object 构造函数隐式创建的,并且其原型是 Object.prototype。当我们在第三章的控制台中检查对象时,看到它们有一个 [[Prototype]] 属性,那就是我们所看到的内容。我们现在再来看一下:

**let person = {name: "Nick", age: 39};**
**person;**
{name: 'Nick', age: 39}
  age: 39
  name: "Nick"
  [[Prototype]]: Object
constructor: f Object()
hasOwnProperty: f hasOwnProperty()
isPrototypeOf: f isPrototypeOf()
propertyIsEnumerable: f propertyIsEnumerable()
toLocaleString: f toLocaleString()
toString: f toString()
`--snip--` 

在这里,我们使用对象字面量声明了一个基本的 person 对象,这意味着它背后通过默认的 Object 构造函数创建。通过在控制台中检查这个对象,我们可以看到它的 [[Prototype]] 属性的内容与 kiki 对象的最内层 [[Prototype]] 完全相同。两个对象都追溯到 Object.prototype,kiki 通过它自己的原型(Cat.prototype)间接追溯,而 person 直接追溯。

原型链遍历

当你请求一个对象的属性或方法时,JavaScript 会首先在对象本身上查找。如果在对象中找不到这个属性,它会继续在对象的原型上查找。如果 JavaScript 仍然找不到该属性,它将检查原型的原型,以此类推,直到查找到 Object.prototype。这个过程被称为原型链遍历。让我们查找一些属性和方法,它们将遍历我们 kiki 对象的原型链:

**kiki.name;** 
'Kiki'
**kiki.sayHello();** 
Miaow! My name is Kiki.
undefined
**kiki.hasOwnProperty("name");** 
true
**kiki.madeUpMethodName();** 
Uncaught TypeError: kiki.madeUpMethodName is not a function
at <anonymous>:1:6 

首先,我们访问直接设置在 kiki 上的 name 属性。其次,我们调用 sayHello 方法,该方法位于 kiki 对象的原型上。为了调用这个方法,JavaScript 会先检查 kiki,然后在找不到后,检查它的原型。第三,我们调用 hasOwnProperty,这是来自 Object.prototype 的方法,也就是 kiki 对象的原型的原型。(该方法返回 true,因为 name 属性是直接在 kiki 上设置的。)最后,我们调用一个不存在的方法 madeUpMethodName。经过整个原型链的遍历,从 kiki 到 Cat.prototype 再到 Object.prototype,JavaScript 确定找不到该方法,并抛出一个错误。

图 6-1 展示了 kiki 对象的原型链的视觉表示,以及相关的构造函数 Cat 和 Object。

图 6-1:kiki 的原型链

图中的每个框代表一个对象,框的标题是对象的名称。每个框的左列显示对象属性的名称,右列显示这些属性的值。例如,kiki 对象的 name 属性的值是 "Kiki",而 Cat.prototype 对象的 sayHello 属性是一个函数,用 f() 表示(记住,方法只是对象的一个属性,且这个属性是一个函数)。

一些属性值指向或引用其他对象。例如,所有构造函数都有一个指向该构造函数创建的实例所使用的原型的 prototype 字段。因此,Cat 构造函数的 prototype 字段指向 Cat.prototype。同样,对象通过它们的[[Prototype]]属性与它们的原型链接。例如,kiki 的[[Prototype]]属性链接到 Cat.prototype,因为 kiki 是通过 Cat 构造函数创建的。所有原型对象都有一个 constructor 字段,指向它们所属的构造函数。如你所见,Cat.prototype 的 constructor 字段指向 Cat,而 Object.prototype 的 constructor 字段指向 Object。像 kiki 这样的实例没有直接定义 constructor 字段。相反,构造函数会通过实例的原型链进行查找。

正如我们在 Dog 示例中看到的,类在底层使用相同的原型机制,因此这种遍历原型链的技术也是查找类实例上属性和方法的方式。

覆盖方法

理解 JavaScript 如何遍历原型链以查找对象的方法非常重要,因为这使我们能够覆盖一个方法的定义,而这个方法本来会从原型继承。这个技术在我们希望一个对象大部分行为继承自原型,但又希望它具有一些独特行为时非常有用。当你调用一个方法时,JavaScript 会在遍历原型链时使用它找到的第一个定义,因此,如果我们在对象上直接定义一个方法,并且这个方法与对象原型上定义的方法同名,那么对象本身上的方法会优先。

例如,假设你想要一个新的 Cat 对象,它的问候方式与在 Cat.prototype 上定义的不同。你可以直接在这个新 cat 上设置一个单独的 sayHello 方法,像这样:

**let moona = new Cat("Moona");**
**moona.sayHello = function () {**
 **console.log(`HELLO!!! I'M** **${this.name.toUpperCase()}!`);**
**};**
**moona.sayHello();**
❶ HELLO!!! I'M MOONA!
**kiki.sayHello();**
❷ Miaow! My name is Kiki. 

在这里,我们使用 Cat 构造函数定义一个新实例,并命名为 moona。然后,我们在 moona 本身上定义一个 sayHello 方法,该方法会打印全大写的问候语。当我们调用 moona.sayHello()时,可以在输出中看到直接在 moona 上定义的 sayHello 方法优先于在 Cat.prototype 上的 sayHello 定义❶。这也被称为遮蔽,因为本地方法在某种程度上会“遮蔽”原型上的方法。然而,请注意,原始的 sayHello 方法仍然保持不变,正如我们在对 kiki 调用时看到的输出所示❷。

总结

在本章中,你学习了类的概念,类帮助你通过在多个对象之间共享功能来组织代码。你学习了如何创建类,如何使用它们来创建实例,以及如何通过创建子类和父类的层次结构来扩展类。你还了解了基于原型的继承,这是 JavaScript 允许对象继承属性和方法的原始系统。你探讨了基于原型的继承与更新后的类系统之间的比较,并了解了如何通过控制台中的[[Prototype]]属性追踪对象的继承链。

第十四章:7 HTML、DOM 和 CSS

要开发自己的交互式网页应用程序,你需要学习一些基础的 HTML 和 CSS,这些语言用于创建网页和更改网页的外观。对这两种语言的全面介绍超出了本书的范围,但本章将教授你足够的知识以便入门。我们还将讨论文档对象模型(DOM)及其应用程序接口(API),它们为我们提供了使用 JavaScript 修改网页的方式。

HTML

HTML 代表超文本标记语言超文本是指链接到其他文本或文档的文本,标记是用于在文档中注释文本的系统。因此,HTML 是一种用于在文档中注释文本并使文档之间相互链接的语言。在第一章中,我简要介绍了它作为描述网页的语言。从这个角度来看,网页是相互链接的文档,而注释是告诉网页浏览器如何显示页面的指令。

HTML 注释以标签的形式出现。最简单的 HTML 标签是一个被尖括号括起来的名称。例如,定义文档主体的标签,它标识网页上所有可见的内容,像这样:。大多数标签都是成对出现的,包括一个开始标签和一个结束标签:例如, 和 。结束标签看起来和开始标签一样,只是在开始的尖括号后面有一个正斜杠。

每一对标签定义一个元素。每个 HTML 元素表示网页的某个方面,比如标题、图片或段落。一个 HTML 文档包含一组嵌套的元素来描述文档结构。在这个上下文中,嵌套意味着元素包含在其他元素内,这些元素可能又包含在其他元素内,像俄罗斯套娃一样。

每个元素的开始标签和结束标签之间的内容称为该元素的内容。例如,图 7-1 显示了一个基本的 p 元素,p 是段落的缩写,表示网页上的标准文本段落。

图 7-1:HTML 元素的结构

p 元素的内容位于开始标签

和结束标签

之间,是实际出现在段落中的文本——在这个例子中,是 Hello, World!

创建 HTML 文档

让我们创建我们的第一个真正的 HTML 文档。它将是一个简单的网页,包含一个标题和一段简短的文本。打开你的文本编辑器并创建一个名为helloworld.html的新文件(如果你需要复习创建新文件的过程,请参阅第一章)。输入列表 7-1 的内容。

❶ <!DOCTYPE html>
❷ <html>
❸ <head>
<title>Hello, World!</title>
  </head>
❹ <body>
<h1>Hello!</h1>
<p>Welcome to my document.</p>
  </body>
</html> 

列表 7-1:一个基础的 HTML 文档

第一行,doctype ❶,指定这是一个 HTML 文档。此行对于浏览器正确显示这些文档是必需的(尽管我们在第一章中略去了它)。接下来是打开的标签❷。此文件中的其他所有内容都被包含在此标签和关闭的标签之间。每个 HTML 文档应具有一组单一的和标签来定义一个总的 html 元素。所有其他元素都嵌套在 html 元素内。

在我们的 html 元素内部有一个 head 元素❸和一个 body 元素❹。注意,我们的文档遵循常见的惯例,通过缩进来表示元素在其他元素内的嵌套。由于 head 和 body 都嵌套在 html 元素中,因此它们的标签会缩进。VS Code 和许多其他文本编辑器会自动应用这种缩进;就像在 JavaScript 中一样,它并非强制要求,但有助于提高可读性。

头部元素包含元数据,即关于页面的信息。在这个例子中,它包含一个元素,title。由于它嵌套在 head 元素内,按照惯例它会有更深一层的缩进。title 元素的文本内容“Hello, World!”是网页的名称。这个名称不会显示在页面上,但当你加载页面时,它会出现在浏览器顶部的标签标题中。

注意

头部元素还可以包含指向将在页面上运行的脚本和用于修改页面外观的样式表的链接,后者我们将在本章稍后讨论。

如前所述,body 元素包含页面的可见内容,例如标题、图片、文本等。我们的 body 元素包含两个元素。第一个,h1,是一个顶级标题(HTML 定义了六个标题级别,从 h1 到 h6)。网页浏览器知道以大号粗体显示 h1 元素的文本内容(在我们这个例子中是“Hello!”)。正如我们讨论的,第二个 body 元素,p,将显示为标准段落文本。我们的段落包含一句话:“Welcome to my document”。

打开你的网页浏览器并加载helloworld.html。你应该会看到类似图 7-2 的内容。

图 7-2:我们 helloworld.html 文档在浏览器中的显示

正如你所看到的,title 元素的文本内容显示为网页浏览器标签页的标题。h1 元素作为页面的标题显示,文本为“Hello!” p 元素则显示为标题下方的标准段落,文本为“Welcome to my document。”

理解嵌套关系

直接嵌套在另一个元素内部的元素称为子元素,而包含子元素的元素称为父元素。例如,在helloworld.html中,title 嵌套在 head 内。因此我们说 title 是 head 的子元素,而 head 是 title 的父元素。一个元素,无论是直接还是间接地包含在另一个元素内(类比为子元素、孙元素、曾孙元素等),称为后代元素。例如,h1 是 html 的后代元素,尽管它并不是直接包含在 html 中;相反,它包含在 body 中,而 body 本身包含在 html 中。相对地,html 元素可以被称为 h1 元素的祖先元素。拥有相同父元素的元素称为兄弟元素。在我们的文档中,h1 和 p 是兄弟元素,因为它们都有 body 作为父元素;类似地,head 和 body 也是兄弟元素。

文档对象模型

当你的网页浏览器加载一个 HTML 文件时,它会创建一个元素的内部模型,称为文档对象模型,或简称DOM。(记住,document 只是网页的另一种说法。)不同于 HTML 文件本身,它是一个静态的文本文件,DOM 是页面的动态模型,你可以使用 JavaScript 对其进行修改。要查看helloworld.html文档的 DOM,打开 JavaScript 控制台并切换到Elements标签。你应该会看到一个非常类似于 HTML 文件的结构,但是带有可以展开和折叠某些元素的箭头。展开它们,你应该能看到整个文档,如 Listing 7-2 所示。

<!DOCTYPE html>
<html>
 <head>
    <title>Hello, World!</title>
  </head>
 <body>
    <h1>Hello!</h1>
    <p>Welcome to my document.</p>
  </body>
</html> 

Listing 7-2: helloworld.html的 DOM

为了说明 DOM 的动态特性,尝试在 Elements 标签内双击 h1 元素中的 Hello!文本。输入一些新文本并按 ENTER 键。网页的标题应该会相应地改变。但请注意,你并没有修改 HTML 文件本身,而是修改了浏览器中页面的模型。

借助 DOM,你可以直接从浏览器更新网页元素,并且结果会立即显示出来。现在我们手动更新 DOM 只是为了看看它是如何工作的,但在本章后面,你将学习如何使用 JavaScript 程序化地更新 DOM。这使得你能够编写修改网页显示内容的代码。最终,这就是创建动态网页应用程序的关键:通过 JavaScript 代码操作 DOM,改变网页的外观,使用户在浏览和互动时能够看到不同的内容。

浏览器中的元素标签提供了可视化 DOM 的一种方式。图 7-3 展示了另一种方式:我们可以将基本网页的元素视为一组嵌套的盒子。

图 7-3:作为嵌套盒子的 DOM

DOM 实际上并不关心开闭标签,它们只是 HTML 用来描述文档结构的文本格式。从浏览器的角度来看,重要的是元素及其父子和兄弟关系。图 7-3 展示了这种更抽象的文档结构视图。你可以马上看到,h1 和 p 元素被嵌套在 body 元素内,而 body 元素又被嵌套在 html 元素内。

DOM API

Web 浏览器允许你使用 JavaScript 通过DOM API来修改 DOM。正如本章开头所提到的,API 代表应用程序编程接口,它是一种通过代码与系统或对象进行交互的方式。正如你在更新 h1 元素时看到的那样,修改 DOM 会改变网页,所做的任何更改通常都会立即可见。这意味着 DOM API 为我们提供了一种编写代码的方式,使得对页面的任何修改都能为观看者提供即时的视觉反馈。

DOM API 提供了一组方法和属性来与 DOM 交互。许多这些方法和属性可以在 document 对象上找到,这是 DOM API 提供的一个对象,表示当前文档(即网页)。例如,document.title 允许你获取和设置当前标签页的标题。现在让我们来试一试。运行以下代码,查看helloworld.html

**document.title;**
'Hello, World!'
**document.title = "Hello, JavaScript!";**
'Hello, JavaScript!' 

当你运行这个代码时,你应该能看到浏览器标签页中的标题从“Hello, World!”变为“Hello, JavaScript!”。

元素标识符

我们可以使用 DOM API 修改页面上的任何元素,甚至添加新元素。要修改元素,我们需要一种方式从代码中访问它。JavaScript 提供了多种访问 HTML 元素的方式,最简单的是通过元素的 id 属性进行引用。

HTML 的属性,例如 id,是我们可以添加到 HTML 元素中的键值对。id 属性为元素提供唯一标识符。在 HTML 文档中,属性始终附加到元素的开放标记上;也就是说,它们出现在元素名称之后,并在闭合尖括号之前。让我们返回文本编辑器,并在我们的helloworld.html文档中的 h1 元素中添加一个 id 属性。这将使得可以使用 DOM API 轻松访问该元素。按照 清单 7-3 中所示更新文档。未更改的代码已变灰。

<!DOCTYPE html>
<html>
 <head>
 <title>Hello, World!</title>
 </head>
 <body>
 <h1 id="main-heading">Hello!</h1>
 <p>Welcome to my document.</p>
 </body>
</html> 

清单 7-3: 添加 id 属性

我们将属性放置在开放标记名称 h1 之后。属性名称和属性值由等号分隔,并且值应该用引号括起来。在本例中,我们将 id 属性设置为值 "main-heading"。

如果重新加载页面,您应该看不到任何差异;id 属性默认情况下不会影响元素的显示。要确认页面已更新,请右键单击“Hello!”标题,并从菜单中选择Inspect。这将在 Elements 选项卡中突出显示 h1 元素,包括其新的 id 属性,如 图 7-4 中所示。

图 7-4: Chrome 在 Elements 选项卡中突出显示 h1 元素

现在 h1 元素有了一个 ID,我们可以在 JavaScript 中轻松地引用它。在您的网络浏览器中,切换到helloworld.htmlConsole选项卡,并输入以下内容:

**let heading = document.getElementById("main-heading");**

getElementById 方法接受一个与 HTML 元素的 id 属性对应的字符串。它返回具有指定 ID 的 HTML 元素的表示。在这里,我们将该元素存储在变量 heading 中。由于标识符应该是唯一的,getElementById 只返回一个元素。如果未找到 ID,则该方法返回 null。如果违反规则,并且具有相同 ID 的元素多于一个,则浏览器通常会返回具有该 ID 的第一个元素,但这是未定义行为,意味着行为未指定且可能会在将来更改。

现在让我们要求控制台输出 heading 的值:

**heading;**
  <h1 id="main-heading">Hello!</h1> 

控制台显示了 h1 元素的 HTML 表示。此外,如果你将鼠标悬停在输出上,浏览器会在页面上突出显示该元素,如你在 图 7-5 中所见。

现在我们将标题元素绑定到一个变量中,我们可以对其进行操作。例如,我们可以获取和设置元素的文本如下:

**heading.innerText;**
'Hello!'
**heading.innerText = "Hi there…";**
'Hi there…' 

innerText 属性表示元素的文本。如你在这个示例中看到的,它既可以用来获取文本,也可以用来修改文本。当你更新 innerText 的值时,页面上标题元素的文本也会随之更新。然而,请记住,这只是对 DOM(浏览器的网页模型)的更改,并不是对底层 HTML 文件本身的更改。如果你刷新页面,浏览器会重新加载原始的 HTML 文件,你的更改将会消失。

图 7-5:Chrome 突出显示页面上的 h1 元素

在 JavaScript 控制台中编写代码可以让你在更新 DOM 时即时看到浏览器中的结果,但如果你希望在别人查看网页时更新 DOM 呢?你不能在其他人的计算机上直接输入代码,除非你亲自到场,但你可以将 JavaScript 代码嵌入到 HTML 文档中,这样任何查看你网页的人都能看到代码的结果。这就是我们接下来要做的事情。

script 元素

如果你想在 HTML 文档中包含 JavaScript,你必须使用 script HTML 元素。有两种使用 script 元素的方式:一种是在

posted @ 2025-11-26 09:18  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报