下一代-JavaScript-编程入门指南-全-

下一代 JavaScript 编程入门指南(全)

原文:Get Programming with JavaScript Next

译者:飞龙

协议:CC BY-NC-SA 4.0

第 1 课. ECMAScript 规范和提案流程

在本课中,你将了解 JavaScript 的起源以及 JavaScript 与 ECMAScript 之间的区别。由于本书是关于从 ES2015 开始引入的新特性,在本课中,你将了解这些新特性是如何被提出来作为语言规范的,以及这些提案成为语言规范的过程。

1.1. ECMAScript 的简要历史

JavaScript 最初是在 1995 年由 Netscape 创建的。后来,该语言被提交给 Ecma International 进行标准化,并于 1997 年发布了 ECMAScript 第一版。Ecma International 曾经被称为欧洲计算机制造商协会(ECMA),但为了反映其全球地位,将其名称更改为“Ecma International”。尽管 Ecma 不再是缩写,但 ECMAScript 仍然使用大写的 ECMA。在 ECMAScript 发布后,接下来的两年每年都看到了更新版的发布,其中 ECMAScript 第三版,通常被称为ES3,于 1999 年 12 月发布。

ECMAScript 第四版(ES4)原本打算进行一次彻底的变革。它引入了许多新概念,包括类和接口,并且是静态类型。它也与 ES3 不向后兼容。这意味着如果实施,它有可能破坏野外的现有 JavaScript 应用程序。不用说,ES4 是有争议的,并分裂了 Ecma 技术委员会,导致成立了一个子委员会来工作于一个规模较小的更新,被称为ECMAScript 3.1。最终,ECMAScript 第四版被放弃,ECMAScript 3.1 被更名为ES5(第五版)并于 2009 年发布。

如果你一直在计算,那已经是 10 年,在发布语言新版本之前整整十年——尽管版本号跳了两级,但更新实际上相当小。

1.2. 为什么 ES2015 添加了这么多

ECMAScript 第六版于 2015 年 6 月最终确定。这是自 15 年前以来语言的第一次重大更新。网络景观以及网站和 Web 应用程序的构建方式发生了巨大变化,因此自然会有许多新想法,这导致了新的语法、运算符、原始类型和对象,以及对现有内容的增强,以及大量新概念。所有这些加起来,使得第六版成为了一次重大修订。

最初(并且通常仍然)被称为ES6的第六版,为了与每年发布一个新版本的原始策略相一致,被更名为ES2015。因此,ECMAScript 第七版最初被称为ES7,后来更名为ES2016,于 2016 年 6 月最终确定。

每年发布一个新版本的思路是,语言可以逐渐且持续地成熟,而无需经历像 2000 年代初那样的停滞期。这也应该使新版本更容易、更快地为开发者所采用。

1.3.谁决定添加什么?

Ecma International 内部的一个称为TC39的任务组(www.ecma-international.org/memento/TC39.htm)负责开发和维护 ECMAScript 规范。该小组的成员大多来自构建网络浏览器的公司,如 Mozilla、Google、Microsoft 和 Apple,因为他们对该规范有既得利益,并且必须实施该规范。您可以在tc39wiki.calculist.org/about/people/查看 TC39 成员的完整列表。ECMAScript 规范的增加要经过从阶段 0 到阶段 4 的五阶段流程。

1.3.1.规范阶段

  • 第一阶段:草稿—这个阶段是非正式的,可以以任何形式存在;它允许任何人将他们的意见添加到语言的进一步发展中。要添加您的意见,您必须是 TC39 的成员或已在 Ecma International 注册。您可以在tc39.github.io/agreements/contributor/注册。一旦注册,您可以通过 esdiscuss 邮件列表提出您的想法。您也可以在这些讨论中查看esdiscuss.org/

  • 第一阶段:提案—在草稿完成后,TC39 的成员必须支持这一添加,以推进到下一阶段。这意味着 TC39 的成员必须解释为什么这一添加是有用的,并描述一旦实施,添加将如何表现和看起来。

  • 第二阶段:草案—在这个阶段,添加被完全规范化,被视为实验性的。如果添加达到这个阶段,这意味着委员会期望该特性最终会被纳入语言中。

  • 第三阶段:候选—在这个阶段,解决方案被认为是完整的,并得到了批准。此阶段之后的变更很少,通常是实施和重大使用后的关键发现。在适当的部署期后,添加可以安全地提升到第四阶段。

  • 第四阶段:完成—这是最终阶段;如果一个添加达到这个阶段,它就可以被纳入正式的 ECMAScript 标准规范中。

关于这些特定阶段和其他关于 TC39 流程的信息,请参阅tc39.github.io/process-document/

1.3.2.选择阶段

有像 Babel(见第 2 课)这样的项目,允许你使用今天的 JavaScript 新功能。如果你打算使用这样的工具,在项目开始时选择一个合适的阶段可能是个好主意。如果你只想使用保证将在下一个版本中包含的功能,阶段 4 就很合适。选择阶段 3 也被认为是安全的,因为很可能包含在阶段 3 的功能最终会保持不变,并且变化很少。选择低于该阶段的阶段,你面临的风险是功能在未来可能会改变甚至被撤销。你可能会发现某个功能足够有用,足以使这种风险变得值得承担。

你也可以根据你想要使用的功能来决定选择哪个阶段。当然,你可能不想使用尚未正式包含在 ECMAScript 规范中的任何功能,这是完全可以的。如果你确实想选择一个阶段,你可以在以下网址查看每个阶段包含的功能:

1.4. 本书将涵盖的内容

本书旨在帮助现有的 JavaScript 开发者跟上最新版本的 JavaScript(包括 ES2015、ES2016 以及以后的版本)的步伐,并变得高效。本书重点介绍这些版本和提案中最重要和最广泛使用的功能。本书的目的不是教授 JavaScript 或编程基础。但你也无需成为 JavaScript 专家程序员就能从本书中受益。

由于本书涵盖了 ES2015、ES2016 以及提议/分阶段的功能的混合,我将定义一些术语以使跟踪所有这些内容更容易。从现在开始,我可能将 ES2015 与 ES6 互换使用,当我这样做时,我总是指同一件事:ECMAScript 的第六版。同样,我可能将 ES7 和 ES2016 互换使用。我可能使用术语 ESNext 作为泛指,以指代 ES2015 及以后的版本——基本上是 ES5 之后对 JavaScript 的新增内容。

摘要

在本课中,你学习了 ECMAScript 是 JavaScript 的官方规范以及提案流程是如何工作的。在下一课中,你将学习如何转换尚未实现的功能,以便你现在可以使用它们。

第 2 课:使用 Babel 进行转译

当 JavaScript 添加新功能时,浏览器总是不得不玩一场追赶的游戏。在所有现代浏览器完全实现并支持 JavaScript 规范更新之后,需要一段时间。为了使用本书涵盖的所有功能,你将利用本节课介绍的技术:转译。

2.1. 什么是转译?

Transpile是由translatecompile两个词组合而成的。编译器通常将一种编程语言的书面语言编译成难以理解的机器代码。转译器是一种特殊的编译器,它将一种编程语言的源代码转换为另一种编程语言的源代码。

¹

至少对人类来说是这样的。

2.1.1. 编译到 JavaScript 语言

转译器已经存在一段时间了,但它们在 2009 年随着CoffeeScriptcoffeescript.org)的引入而突然出现在 JavaScript 舞台上。CoffeeScript 是由 Jeremy Ashkenas 创建的编译到 JavaScript 的语言,他也是 Underscore 和 Backbone 等流行 JavaScript 库的创建者。它从 Ruby、Python 和 Haskell 中汲取了许多灵感,并专注于 Douglas Crockford 在其著作JavaScript: The Good Parts(O’Reilly Media,2008)中推广的 JavaScript 的“优点”。CoffeeScript 通过隐藏用户所说的 JavaScript 的许多缺点,只暴露更安全的部分来实现这一点。

然而,CoffeeScript 既不是 JavaScript 的子集,也不是超集。它暴露了新的语法和许多新概念,其中一些成为了 ES2015 中功能灵感的来源,例如箭头函数。随着 CoffeeScript 的成功,许多其他编译到 JavaScript 的语言开始出现,例如 ClojureScript、PureScript、TypeScript 和 Elm,仅举几个例子。

JavaScript 不一定是最适合编译到的语言,但为了在网络上运行代码,别无选择。最近宣布了一种名为WebAssembly(通常简称为wasm)的新技术。WebAssembly 承诺将成为前端开发中比 JavaScript 更好的编译目标,如果成功,将为在浏览器中选择运行的语言提供更多样化的途径。

2.1.2. Babel 的位置

到目前为止,你可能正在想,“转译器听起来很酷,但谁在乎呢?我正在读一本关于 JavaScript 的书,而不是编译到它的语言。”好吧,转译器不仅用于编译到 JavaScript 的语言。它们还可以帮助你编写 ESNext 代码,并在今天将其用于浏览器。想想看:当另一种语言编译到 JavaScript 时,它不仅针对 JavaScript,还针对 JavaScript 的特定版本。例如,CoffeeScript 针对 ES3。所以如果一种完全不同的语言可以被转译成 JavaScript 的特定版本,那么另一种版本的 JavaScript 不也应该能够做到吗?

有几种转换器可以将 ESNext JavaScript 转换为今天可以在浏览器中执行的合适版本。最常用的两种是TraceurBabel。Babel 曾经被称为 ES6to5,因为它将 ES6 代码转换为 ES5 代码,但自从它开始支持所有未来的 JavaScript 特性,并且考虑到 ES6 的名称正式改为 ES2015,ES6to5 背后的团队决定将项目名称更改为 Babel。

2.2. 设置 Babel 6

Babel 作为 NPM(www.npmjs.com/)包提供,并随 Node.js(nodejs.org/en/)一起打包。您可以从他们的网站下载 Node.js 的安装程序。本书假设您已安装 Node.js 版本 4 或更高版本以及 NPM 版本 3 或更高版本。NPM 随 Node.js 一起打包,因此不需要单独安装。

为了使用 Babel,您需要设置一个 node.js 包,以便您可以安装所需的依赖项。安装了 Node.js 和 NPM 后,打开命令行程序(OSX 中的 Terminal .app 或 Windows 中的 cmd.exe)并执行以下 shell 命令以初始化一个新项目(确保将占位符project_name替换为您项目的名称):^([1])

¹

开头处的\(**表示这是一个要在命令行程序中执行的 shell 命令。**\)不是实际命令的一部分,不应输入。

$ mkdir project_name
$ cd project_name
$ npm init –y

让我们分解这个命令。mkdir project_name这一行将创建一个名为您提供的名称的新目录(文件夹)。然后cd project_name将切换到为新项目创建的新目录。最后,npm init将初始化它为一个新项目。-y标志告诉 NPM 跳过提问并使用所有默认值。

您现在应该能在项目中看到一个名为 package.json 的新文件,表明这是一个 Node.js 项目。现在您已经初始化了项目,可以设置 Babel。执行以下 shell 命令以安装 Babel 的命令行界面:^([2])

²

在撰写本文时,Babel 的当前版本是 6.5.2。本书中的说明适用于 Babel 6.x 版本,这是从 5.x 版本的重大变化。您可以使用版本范围>=6.0.0 <7.0.0来约束 Babel 6 的某个版本,例如:npm install babel@">=6.0.0 <7.0.0";,请参阅docs.npmjs.com/cli/install

$ npm install babel-cli --save-dev

从版本 6 开始,Babel 默认不进行任何转换,您必须安装插件或预设才能应用任何转换。要使用插件或预设,您必须在项目中安装它并指定其在 Babel 配置中的使用。

Babel 使用一个名为 .babelrc 的特殊文件进行配置。你必须将此文件放在项目的根目录下,并且文件内容必须是有效的 JSON 格式。为了指定你希望 Babel 转译所有 ES2015 特性,你可以使用 ES2015 预设。编辑 .babelrc 文件,使其内容如下

{
  "presets": ["es2015"]
}

现在你已经告诉 Babel 使用 ES2015 预设,你必须也安装它:

$ npm install babel-preset-es2015 --save-dev

你现在应该准备好转译一些 ES6 代码了!测试一下。首先在你的项目中添加一个名为 src 的新文件夹,并在其中添加一个名为 index.js 的新文件。你的项目结构现在应该看起来像这样:

project_name
 src
    index.js

现在添加一些 ES2015 代码进行转译。将以下代码添加到你的 index.js 文件中:

let foo = "bar";

你现在可以告诉 Babel 转译你的源代码。在你的终端中,运行以下命令。

列表 2.1. 从 src 文件夹编译到 dist 文件夹
$ babel src -d dist

运行此命令后,将创建一个名为 dist 的新目录,其中包含转译后的代码。让我们分解这个命令。当你指定 babel src 时,你是在告诉 Babel 对 src 目录的内容进行操作。默认情况下,Babel 将转译后的代码输出到终端。当你添加 –d <directory_name> 时,你可以告诉 Babel 将转译后的代码输出到一个目录中。

你的项目结构现在应该看起来像这样:

project_name
 dist
 index.js
 src
    index.js

dist/index.js 文件包含以下转译后的代码:

"use strict";

var foo = "bar";

2.3. 本书所需的 Babel 配置

TC39 的每个阶段都有一个预设。你可以包含任何五个阶段中的预设,Babel 将能够编译达到该阶段(或更高阶段)的代码。例如,如果你使用 stage-2 预设,你可以使用达到阶段 2、3 或 4 的特性,但不能使用阶段 0 或 1 的特性。

由于我无法预测你在阅读时每个提案将处于哪个阶段,请参考第一部分中的 TC39 阶段链接,以确定你需要哪些预设。

或者,你也可以使用以下 .babelrc 预设来获取所有阶段的内容。

列表 2.2. Babel stage-0 预设
{
  "presets": ["es2015", "stage-0"],
  "plugins": ["transform-decorators-legacy"],
  "sourceMaps": "inline"
}

在列表 2.2 中,你使用了 ES2015 和 stage-0 预设来包含所有 ES2015 和现有的所有提案特性。你还需要包含 transform-decorators-legacy 插件来转译装饰器。最后,你告诉 Babel 包含内联源映射以简化调试。现在,为了使 Babel 能够使用这些插件和预设,你需要安装它们:^([1])

¹

你可以一次性安装它们,而不是像我一样一个接一个地安装。我这样做是为了在书籍小边距中的可读性。

$ npm install babel-preset-es2015 --save-dev
$ npm install babel-preset-stage-0 --save-dev
$ npm install babel-plugin-transform-decorators-legacy --save-dev

2.3.1. 关于源映射的说明

在你的 Babel 配置中,你添加了一个关于 source maps 的部分。如果你不熟悉 source maps,它们是一种为了使调试压缩代码更容易而发明的技术。大多数生产应用程序都带有压缩后的代码,以节省带宽,使应用程序加载更快。然而,这种压缩代码调试起来可能是一个噩梦,因此 source maps 被发明出来,可以将代码映射回其原始形式。编译到 JavaScript 的语言开始使用 source maps 来显示原始语言的源代码,而不是转换后的 JavaScript,Babel 也是如此。要了解更多关于 source maps 的信息,请参阅www.html5rocks.com/en/tutorials/developertools/sourcemaps/

2.3.2. 将 Babel 设置为 NPM 脚本

你可能不想反复告诉 Babel 从哪个文件夹转换到哪个文件夹(就像你在列表 2.1 中所做的那样)。你可以通过设置一个 NPM 脚本来简化你的生活。如果你不熟悉 NPM 脚本的工作方式,其实很简单。在你的 NPM 配置文件 package.json 中,有一个特殊的 scripts 部分,允许你指定可以通过名称执行的 shell 命令。有关 NPM 脚本的更多信息,请参阅docs.npmjs.com/misc/scripts

默认情况下,你的 package.json 文件中应该已经添加了一个测试脚本。如果你打开 package.json 文件并定位到 scripts 部分,它应该看起来像这样:^([1])

¹

根据你的操作系统,它可能看起来不同。

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},

你可以将我们的 Babel 命令作为脚本添加,如下所示:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "babel": "babel src –d dist",
},

不要忘记在测试命令后添加逗号!现在你应该可以通过执行以下 shell 命令来执行此 NPM 脚本:

$ npm run babel

这不仅更简单、更容易记忆,而且随着你的需求变化,你可以修改你的 Babel 脚本,而你的命令始终保持不变。

摘要

在本课中,你学习了什么是转换编译以及如何使用它来开始使用 ESNext 功能。你还学习了如何设置 Babel 来转换本书中的代码。

第 3 课:使用 Browserify 打包模块

模块是 JavaScript 新增功能的重要组成部分。正如你将在本课中学到的,仅进行转译对于模块来说是不够的。这是因为模块最定义性的方面是将你的代码分割成单独的文件。这意味着你需要将它们打包成一个文件。有几种流行的工具可以用于打包 JavaScript 模块;两个流行的上升选项是 Webpack 和 Rollup。在本课中,你将使用最早出现的其中之一,Browserify。

3.1. 什么是模块?

许多编程语言都支持模块化代码。Ruby 将这些模块化代码称为gem,Python 称为egg,Java 称为packages。JavaScript 直到 ES2015 引入模块概念之前都没有官方支持这一概念。模块是单个模块化 JavaScript 代码文件。由于 JavaScript 在这个领域来得太晚,因此为 JavaScript 中的模块创建了多种社区解决方案,其中最普遍的是 node.js 模块。

3.2. Node.js 中的模块是如何工作的

Node.js 拥有一个出色的模块系统,配合 NPM 使用。NPM 是随 Node.js 一起打包的 Node 包管理器。大约有 25 万个包发布到 NPM 的注册库,每月下载量达数十亿次,NPM 是世界上代码生态系统最丰富的之一。

在 Node.js 中有一种特定的方式来导入和导出模块。当提到 Node.js 风格的模块时,许多人使用CommonJS这个术语,这是一个最初被称为 ServerJS 的规范,它被创建出来是为了让许多服务器端 JavaScript 实现能够共享一个兼容的模块定义。只有一个服务器端 JavaScript 实现流行起来——Node.js——因此没有必要标准化它们的模块定义。所以尽管 Node 的模块定义与 CommonJS 相似,并且经常被称为 CommonJS,但严格来说并不是。

Node.js 中的模块系统允许开发者将程序分割成封装逻辑并仅暴露必要 API 的模块。由于模块只暴露显式导出的内容,因此没有必要将所有内容包裹在一个立即执行的函数表达式中。更好的是,因为模块仅在导入的地方可用,它们不会污染全局命名空间,从而防止意外的命名冲突。

3.3. 什么是 Browserify?

Browserify是一个工具,它允许你以与 Node.js 在开发期间相同的方式定义模块,然后将它们打包成一个单独的文件。Browserify 在入口点操作,即你的主 JavaScript 文件,并分析哪些脚本被导入。然后它还会运行所有这些脚本,最终构建一个所需所有依赖项的树。Browserify 随后生成一个包含所有所需模块的单个 JavaScript 文件,同时保持它们适当的范围和命名空间。这个打包的 JavaScript 文件可以随后被包含在前端网页中。这允许前端开发者编写模块化的 JavaScript,甚至利用所有发布到 NPM 的丰富生态系统。Browserify 之所以得名,是因为它能够以 Node.js 风格编写代码并使用浏览器中的 Node.js 模块。

3.4. Browserify 是如何帮助处理 ES6 模块的?

你在第 2 课中学到了 Babel 将你的 ESNext 代码转换为可以在浏览器中执行的形式。但是 Babel 并不提供模块系统。它只是将你的 ESNext 源代码转换为 ES5 目标代码,并留给开发者解决打包的任务。另一方面,ES2015 确实为 JavaScript 定义了一个正式的模块规范。那么,如果 Babel 没有暴露模块加载器,你今天如何使用 ES2015 模块呢?如果你能让 Browserify 和 Babel 一起工作,使得 Babel 能够将 ES2015 模块转换为 Browserify 喜欢处理的模块类型,然后 Browserify 继续处理,会怎么样呢?幸运的是,Browserify 有转换的概念。转换允许在代码被 Browserify 操作之前对其进行转换。有一个名为babelify的 Babel 转换器。当你使用 babelify 时,每个文件在发送到 Browserify 之前都会被转换,这样你就可以使用 ES2015 模块。

3.5. 使用 Babel 设置 Browserify

现在你已经了解了 Browserify 是什么以及它所扮演的角色,让我们来安装它。

3.5.1. 安装 Browserify

首先全局安装 Browserify。执行以下 shell 命令:

$ npm install browserify --global

这将在全局范围内安装 Browserify,因此它可以用于任何项目。除非你想升级到新版本,否则你不需要再次安装它。

3.5.2. 使用 babelify 设置项目

现在你已经安装了 Browserify,你可以通过创建一个名为 babelify_example 的新项目来开始。创建一个名为 babelify_example 的新文件夹,包含一个.babelrc 文件、一个 dist 文件夹和一个 src 文件夹,其中 src 文件夹包含一个 index.js 和一个 app.js,这样你的项目结构看起来就像这样:

babelify_example
 dist
 src

 app.js
 index.js
 .babelrc

现在,在你的终端中,cd(更改目录)到你的项目根文件夹,使用 babelify 以及你在上一章中使用过的其他 Babel 预设和插件初始化为一个 NPM 项目:

$ cd babelify_example
$ npm init -y
$ npm install babelify --save-dev
$ npm install babel-preset-es2015 --save-dev
$ npm install babel-preset-stage-0 --save-dev
$ npm install babel-plugin-transform-decorators-legacy --save-dev

注意到你没有安装 babel-cli 包。这是因为你现在正在使用 Browserify 和 babelify,因此不再需要 Babel CLI(命令行界面)。继续添加你之前课程中的相同 Babel 配置到.babelrc 文件中:

{
  "presets": ["es2015", "stage-0"],
  "plugins": ["transform-decorators-legacy"],
  "sourceMaps": "inline"
}

好的,你现在应该已经准备好开始使用 Browserify 和 babelify 了!通过编写一个小模块来测试它。在 app.js 文件中添加以下代码:

const MyModule = {
  check() {
    console.log('Yahoo! modules are working!!');
  }

}

export default MyModule;

现在在 index.js 文件中,添加以下代码:

import MyModule from './app';

MyModule.check();

太棒了。现在使用以下 shell 命令打包:

$ browserify src/index.js --transform babelify --outfile dist/bundle.js
--debug

如果一切设置正确,你的 dist 文件夹现在应该包含一个包含一些转换后的 JavaScript 代码的新 bundle.js 文件。让我们分解这个命令。Browserify 的第一个参数是src/index.js。这告诉 Browserify 这是你的应用程序的入口点——入口点意味着导入其他模块的根 JavaScript 文件。然后--transform babelify告诉 Browserify 在打包之前使用 babelify 转换来转换你的代码。--outfile dist/bundle.js指定了打包和转换后的源代码的输出文件。最后,--debug标志是必要的,以包含源映射,没有它则不会包含。

你可以通过运行以下命令来查看 Browserify 可用的参数列表:

$ browserify help

现在使用 Node.js 测试你的代码。如果你以前从未使用 Node.js 执行 JavaScript,不要担心。你已经有它安装了,告诉它执行 JavaScript 就像指向一个 JavaScript 文件一样简单。所以告诉 Node 执行你的转换后的 bundle.js 文件,通过执行以下 shell 脚本:

$ node dist/bundle.js

你应该会收到热情的

Yahoo! modules are working!!

不要担心现在就理解模块的工作语义。我们将在第 20 课和第 21 课中介绍。现在,先在你的浏览器中让你的打包工作起来,而不是在 Node 中。在你的项目根目录中创建一个 index.html 文件,内容如下:

<!DOCTYPE html>
<html>
<head>
  <title>Babelify Example</title>
</head>
<body>
  <h1>Hello, ES6!</h1>

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

现在在你的网络浏览器中打开你的 index.html。检查控制台;如果使用 Google Chrome,请选择菜单 > 更多工具 > 开发者工具,然后选择控制台标签。你应该在你的控制台中看到相同的引用:Yahoo! 模块正在工作!!

让我们回顾一下你到目前为止所做的一切:

  1. 你在 app.js 中创建了一个具有check方法的模块来记录消息。

  2. 在你的 index.js 中,你导入了模块并调用了check方法。

  3. 你使用了 Browserify 和 Babel 来转换和打包你的 JavaScript。

  4. 然后你在 HTML 页面中包含了你的打包代码,并看到它工作得很好!

这包括了执行本书中所有代码所需的所有步骤。接下来的章节将假设你已经设置好并且能够执行你的示例。

每当你修改源文件时,不要忘记重新编译(通过执行 browserify 命令),以确保你的 bundle.js 反映你最新的代码。(了解 watchify 以实现自动捆绑。)你可以在 package.json 中添加 Browserify 壳命令作为 NPM 脚本,以便更容易运行。

3.6. Browserify 的替代方案

有许多其他方法可以将你的 ESNext 代码进行转译和捆绑。Webpack 和 Rollup 目前是非常受欢迎的选项。哪个最适合你的项目将很大程度上取决于你项目的细节。Babel 为不同的场景提供了良好的设置示例,你可以在这里查看:babeljs.io/docs/setup

摘要

在本节课中,你学习了如何设置 Browserify 以捆绑你的 ES2015 模块。有关模块的更多信息,请参阅第二十部分–第二十一部分。

第 1 单元. 变量和字符串

在 JavaScript 中最熟悉的语句之一是var语句。但letconst的加入意味着var将很快被使用得越来越少。var语句并不会消失,仍然可以使用,但大多数程序员将很快选择在不需要重新赋值时使用const来声明变量,而在需要时使用let。在本单元的前两课中,你将发现这是为什么。

以下两课将介绍新的字符串方法和一种称为模板的新字符串类型。模板很方便,将使在编写代码时(代码编写时)拼接大型字符串的繁琐任务成为过去式。模板还有一个不太为人所知的特性,称为标签模板,它允许自定义处理,并为创建特定领域的语言打开了大门。你将通过使用标签模板创建几个自己的特定领域语言来结束本单元。

第 4 课. 使用 let 声明变量

阅读第 4 课课后后,你将

  • 了解let的作用域以及它与var的区别

  • 了解块作用域和函数作用域之间的区别

  • 了解let变量的提升方式

在 JavaScript 的历史中,变量总是使用关键字var来声明。^([1]) ES6 引入了两种新的声明变量的方式,即使用letconst关键字。^([2]) 这两种方式与使用var声明的变量略有不同。与let相关的有两个主要区别:

¹

实际上,在非严格模式下,可以在完全不使用var声明的情况下创建一个新变量。然而,这却创建了一个全局变量,通常作者并不知道,导致了一些相当有趣的错误。这就是为什么在严格模式下需要var的原因。

²

技术上const不是变量,而是常量。

  • let变量的作用域规则不同。

  • let变量在提升时的行为不同。

考虑这一点

考虑以下两个for语句。它们之间的唯一区别是其中一个使用var声明的迭代器,而另一个使用let。但结果却大相径庭。你认为当每个语句运行时会发生什么?

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1);
};
for (let n = 0; n < 5; n++) {
  setTimeout(function () {
    console.log(n);
  }, 1);
};

4.1. let 的作用域如何工作

使用let声明的变量具有块作用域,这意味着它们只能在它们声明的块(或子块)内访问:

if (true) {
  let foo = 'bar';
}

console.log(foo);                 *1*
  • 1 由于 foo 在其声明块之外不存在,因此会抛出错误。

这使得变量更加可预测,并且不会因为变量从其使用的块中泄漏而导致引入错误。一个 是语句或函数的主体。它是大括号 {} 之间的区域。你甚至可以使用大括号来创建一个不与语句相关的独立块:

列表 4.1. 使用独立块来保持变量私有
let read, write;
{                                         *1*
  let data = {};                          *2*

  write = function (key, val) {
    data[key] = val;
  }

  read = function (key) {
    return data[key];
  }
}                                        *3*

write('message', 'Welcome to ES6!');
read('message');                         *4*
console.log(data);                       *5*
  • 1 打开一个独立块

  • 2 data 事实上是一个私有变量。

  • 3 关闭一个独立块

  • 4 “欢迎来到 ES6!”

  • 5 在块外引用数据会导致错误。

在这个例子中,readwrite 在块外声明,但它们的值在 data 声明的块内赋值。这使得它们可以访问 data 变量。但是 data 在块外不可访问,因此成为一个私有变量,writeread 可以使用它来存储它们内部的数据。

通常,对于使用 let 声明的变量要作用域到特定的块,变量必须在那个块内声明。然而,有一个例外:在 for 循环中,在 for 循环的子句内使用 let 声明的变量将作用域在 for 循环的块内:

for (let i = 0; i < 5; i++) {          *1*
  console.log(i);                      *2*
}
console.log(i);                        *3*
  • 1 ifor 语句的子句中声明。

  • 2 ifor 循环的块作用域内。

  • 3 i 不能在 for 循环外部引用,并且会抛出错误。

4.1.1. 为什么 let 的块作用域更受欢迎

使用 var 声明的变量有 函数作用域,这意味着它们可以在其包含函数的任何地方访问:

(function() {
  if (true) {
    var foo = 'bar';
  }

  console.log(foo);              *1*
}())
  • 1 foo 在其声明的 if 语句外部被引用。

这在历史上让开发者感到困惑,并由于错误的假设而导致错误。让我们在下一个列表中看看一个经典示例:

列表 4.2. 这里有一个作用域问题,但问题是什么?
<ul>
  <li>one</li>
  <li>two</li>
  <li>three</li>
  <li>four</li>
  <li>five</li>
</ul>

...
<script type="javascript">
  var items = document.querySelectorAll('li');         *1*
  for (var i = 0; i < 5; i++) {
    var li = items[i];
    li.addEventListener('click', function() {          *2*
      alert(li.textContent + ':' + i);
    });
  };
</script>
  • 1 document.querySelectorAll 是一个标准的 Web API 方法,允许选择所有匹配指定查询的 DOM 节点。

  • 2 addEventListener 是 DOM 节点上的一个方法,允许附加事件监听器。

这段代码看起来像是在每个列表项上附加了一个事件监听器,以便当点击时,它的文本和索引将被提示。换句话说,如果点击第一个列表项,期望提示的是 one:0。但现实是,无论点击哪个列表项,提示的值总是相同的,即 five:5。这是因为这段代码中有一个错误,这个错误由于函数级作用域而导致许多开发者遇到了问题。

你发现了错误吗?由于变量没有作用域到 for 语句,每个迭代都在使用相同的变量。以下是正在发生的事情的分解:

  • 变量 i 被声明为 0。

  • for 循环的第一次迭代执行。

    • i 的值为 0,li 是第一个列表项。
  • for 循环的第二次迭代执行。

    • i 的值为 1,li 是第二个列表项。
  • for 循环的第三次迭代运行。

    • i 的值为 2,li 是第三个列表项。
  • for 循环的第四次迭代运行。

    • i 的值为 3,li 是第四个列表项。
  • for 循环的第五次迭代运行。

    • i 的值为 4,li 是第五个列表项。
  • i 增加到 5,for 循环停止。

这意味着在 for 循环完成后,ili 分别被设置为 5 和第五个列表项。如果 for 循环立即发出警报,则不会观察到任何错误;然而,警报语句被设置为在事件监听器中发出,该监听器将在 for 循环完成后才会触发。因此,在触发任何事件并发出 ili.textContent 的警报时,你会得到 5 和五个。

之前你使用一个独立的代码块来创建一个作用域,以保持变量私有。通常,这样的作用域是通过使用一个立即执行的函数表达式(IIFE)来创建的,如下所示:

(function () {
  var foo = 'bar';
}());

console.log(foo); // ReferenceError

如果你使用立即执行的函数表达式重写 列表 4.1,它将看起来像这样:

var read, write;

(function () {
  var data = {};

  write = function write(key, val) {
    data[key] = val;
  }

  read = function read(key) {
    return data[key];
  }
}());

write('message', 'Back in ES5 land.');
read('message');                              *1*
console.log(data);                            *2*
  • 1 “回到 ES5 世界。”

  • 2 数据超出作用域,因此抛出错误。

这段代码看起来更复杂,一眼看去更难理解,因为它是为了创建作用域而创建和调用函数。仅仅为了创建作用域而使用函数是一种过度设计,但在 ES6 之前,这是唯一的选择。有了块作用域,这种情况就不再存在了,IIFE 可以被独立的代码块所取代。

快速检查 4.1

Q1:

给定以下代码,控制台将输出什么?

 var words = ["function", "scope"];
 for(var i = 0; i < words.length; i++) {
   var word = words[i];
   for(var i = 0; i < word.length; i++) {
     var char = word[i]
     console.log('char', i, char);
   };
};

| |

QC 4.1 答案

A1:

这会导致处理第一个单词 function,但不会处理第二个单词 scope,因为当内循环完成后,i 的值等于 7,导致外循环在处理第二个单词之前停止。

从技术上讲,这个问题可以通过用 let 而不是 var 声明两个 i 变量来解决。但这种情况被称为 变量遮蔽,^([3]),通常被认为是一种不好的做法,所以我仍然建议为内部变量使用不同的变量名。

³

查看 en.wikipedia.org/wiki/Variable_shadowing

4.2. let 的提升工作原理

使用 letvar 声明的变量都具有称为 提升 的行为。这意味着在变量声明的整个作用域内,无论是 let 的整个块还是 var 的整个函数,变量都会消耗整个作用域。无论变量在作用域中的哪个位置声明,都会发生这种情况:

if (condition) {
  // ------ scope of myData starts here -----
  doSomePrework();
  //... more code
  let myData = getData();
  //... more code
  doSomePostwork();
  // ------ scope of myData ends here -----
}

这意味着在这个代码段中,变量在作用域内,即使在其声明之前也可以被访问!如果存在另一个同名的变量,只是作用域稍微大一点,这种情况是真实且有些反直觉的。考虑以下示例:

// ------ scope of outer myData starts here -----
let myData = getDefaultData();
if (condition) {
  // ------ scope of inner myData starts here -----
  doSomePrework(myData); // <-- What variable is this???
  //... more code
  let myData = getData();
  //... more code
  doSomePostwork();
  // ------ scope of inner myData ends here -----
}
// ------ scope of outer myData ends here -----

实际上这里有两个myData变量:一个只在if语句的作用域内,另一个在包含的作用域内。这很棘手,因为直观上,你可能认为具有默认值的更外层作用域的myData被传递给了doSomePrework函数,因为内部变量尚未声明。但这并不是事实。因为内部变量消耗了整个作用域,所以它是传递给doSomePrework函数的变量。它被提升并在声明之前使用。

变量在声明之前就在作用域内,这种称为提升的概念实际上并不新鲜。let提升到块的顶部,而var提升到函数的顶部。然而,这里有一个更重要的区别,即当使用let声明的变量在声明之前被访问时会发生什么,与var相比。

当一个let变量在声明之前在作用域内被访问时,它会抛出一个引用错误。这与var不同,var允许使用,但值始终是未定义的。这个区域或区域,在这个区域中let变量可以在声明之前被访问,但如果实际访问则会抛出错误,被称为时间死区。更具体地说,时间死区是指变量在声明之前的作用域内的区域。对时间死区内的变量的任何引用都会抛出引用错误:

{
 console.log(foo);           *1*
 let foo = 2;
}
  • 1 抛出错误,因为 foo 尚未声明

花点时间看看这段代码。你认为执行时将输出什么?

let num = 10;
function getNum() {
  if (!num) {
    let num = 1;
  }
  return num;
}
console.log( getNum() );

如果你认为答案是10,你是正确的。但这里还有其他一些容易忽略的事情发生。通过稍微修改示例,你可以看到正在发生什么:

let num = 0;
function getNum() {
  if (!num) {
    let num = 1;
  }
  return num;
}
console.log( getNum() );

你现在期望输出什么?如果你回答1,你是错误的。为什么?

let num = 0;
function getNum() {
  if (!num) {                                             *1*
    // ------ scope of inner num starts here -----
    let num = 1;                                          *2*
    // ------ scope of inner num end here -----
  }
  return num;                                             *3*
}
console.log( getNum() );
  • 1 num 是 0,所以 if 语句执行。

  • 2 你正在声明一个新的let变量,其值为 1。

  • 3 哎呀,当你声明一个新的变量为 1 时,它只限于 if 语句的作用域,所以这个变量仍然保持为 0。

为了解决这个问题,在将 num 设置为 1 时移除let

let num = 0;
function getNum() {
  if (!num) {
    num = 1;
  }
  return num;
}
console.log( getNum() );

快速检查 4.2

Q1:

给定以下代码,控制台将输出什么?

 {
  console.log('My lucky number is', luckNumber);
  let luckNumber = 2;
  console.log('My lucky number is', luckNumber);
}

| |

QC 4.2 答案

A1:

不会输出任何内容,因为第一个console.log语句会抛出错误,因为它试图在声明之前访问变量。

4.3. 我应该从现在开始使用let而不是var吗?

这是一个有争议的问题。一些开发者认为是的,而另一些则认为不是。我恰好属于前者。⁴

然而,对于 var 来说,正如我们将在下一课中看到的,const 通常比 let 更受欢迎。

使用 var 的论点是,如果一个变量在函数的根处声明,应该使用 var 来表达这个变量的作用域是整个函数。我不同意这个论点。我认为这是由于开发者想要继续在函数作用域内思考,并且需要接受块作用域。对于 let 来说,函数只是另一个块,你当然不需要在 ifforwhile 或任何其他块级语句内部声明 let 时使用不同类型的声明,那么为什么函数应该是特殊的呢?

在函数开始处声明的 letvar 的作用域相同,但它提升的方式不同。如果在使用之前访问 var,它将是 undefined;而 let 会抛出异常。我认为异常是更好的行为,因为在使用声明之前使用变量会导致棘手的错误。我也从未遇到过任何令人信服的理由说明为什么那是一个好主意。这也是我赞成不再使用 var 的另一个原因。

但当我提出完全用 let 替换 var 的观点时,请警惕在现有代码中将 var 替换为 let。对现有代码库进行全面的更改可能会导致错误。

摘要

在本课中,你学习了如何使用 let 声明变量以及这与使用 var 声明变量的区别:

  • 使用 let 声明的变量使用块作用域。

  • 块作用域意味着变量仅在其包含块中的作用域内。

  • 使用 var 声明的变量使用函数作用域。

  • 函数作用域意味着变量在其整个包含函数中的作用域内。

  • var 变量不同,let 变量在声明之前不能被引用。

看看你是否理解了:

Q4.1

以下代码创建了一个函数,该函数生成一个包含一系列值的数组。它使用 var 和几个 IIFE 来防止在它们被使用的上下文之外访问变量:

  • 一个全面的 IIFE 隐藏 DEFAULT_STARTDEFAULT_STEP
  • 一个 IIFE 防止 tmp 从它被使用的 if 语句中逃逸。
  • 另一个 IIFE 防止 ifor 循环外部被访问。

将此代码重写为使用 let 并消除对任何 IIFEs 的需求:

(function (namespace) {

  var DEFAULT_START = 0;
  var DEFAULT_STEP  = 1;
  var range = function (start, stop, step) {
    var arr = [];

    if (!step) {
      step = DEFAULT_STEP;
    }

    if (!stop) {
      stop = start;
      start = DEFAULT_START;
    }

    if (stop < start) {
      (function () {
        // reverse values
        var tmp = start;
        start = stop;
        stop = tmp;
      }());
    }

    (function () {
      var i;
      for (i = start; i < stop; i += step) {
        arr.push(i);
      }
    }());

    return arr;
  }

  namespace.range = range;

}(window.mylib));

第 5 课:使用 const 声明常量

在阅读 第 5 课 之后,你将

  • 理解常量的概念以及它们是如何工作的。

  • 知道何时使用常量。

关键字 const 代表 常量,即 永不改变。许多程序都有永远不会改变的价值,无论是出于故意还是偶然。使用 const 声明的值,称为 常量,具有与上一章中你了解的 let 声明相同的特征,但额外的一个特性是禁止重新赋值。

考虑这一点

考虑以下switch语句,它使用标志来确定正在执行哪种类型的操作。ADD_ITEMDEL_ITEM的上标表示这些值永远不会改变,^([a])但如果它们真的改变了,会发生什么?这会如何影响程序的行为?您如何编写应用程序以防止这种情况?

^a

这种大写完全是出于惯例,并不是必需的语法。

switch (action.type) {
  case ADD_ITEM:
    // handle adding a new item
  break;
  case DEL_ITEM:
    // handle deleting an item
  break;
};

5.1. 常量是如何工作的

常量不能重新赋值。这意味着一旦您为常量赋值,任何尝试赋新值的尝试都将导致错误:

const myConst = 5;

myConst = 6;            *1*
  • 1 错误

由于无法重新赋值常量以获得新值,它们很快就被误解为不可变的。常量不是不可变的。那么,无法重新赋值和不可变之间有什么区别?赋值与变量绑定有关,即将一个名称绑定到数据片段上。不可变或可变是绑定所包含实际数据的属性。所有原始数据(字符串、数字等)都是不可变的,而对象是可变的。

让我们看看一个例子,您将可变对象赋给常量,并且可以自由地修改它:

const mutableConstant = {};

mutableConstant.foo = 'bar';            *1*
mutableConstant.foo = 'baz';            *2*

mutableConstant = {foo: 'bat'}          *3*
  • 1 当您将 foo 设置为 bar 时,您正在修改(修改)现有对象。

  • 2 当您将 foo 设置为 baz 时,您正在修改(修改)现有对象。

  • 3 您试图通过分配一个全新的对象来设置 foo 为 baz,这是不允许的。

图 5.1. 修改值

因为您创建了一个常量并将其赋值为可变值,所以您能够修改该值。但是您不能将新值赋给常量;您能够改变值的唯一方法是通过修改该值本身,如图 5.1 所示。

如果您将不可变值(如数字)赋给常量,那么常量就变得不可变,因为它包含一个无法修改的值,并且常量不能重新赋值,因此它们变得固定。

快速检查 5.1

Q1:

当执行以下代码时会发生什么?

const i = 0;
i++;

| |

QC 5.1 答案

A1:

因为增量运算符会为它操作的变量分配新值,所以这段代码会抛出错误,因为常量不能重新赋值。

如果您对原始数据(如字符串和数字)是不可变的这一说法不熟悉,可能会觉得难以接受。您可能会想:“我经常使用原始数据并改变它们的值!”事实是,由于能够重新分配变量以获得新值,原始数据更难被发现。考虑以下代码:

let a = "Hello";
let b = a;

b += ", World!";

console.log(a);           *1*
console.log(b);           *2*
  • 1 Hello

  • 2 Hello, World!

在这个例子中,看起来你正在更改变量 b 中包含的字符串,但实际上你正在创建一个新的字符串并将这个新字符串重新赋值给 b。你可以通过确认,在更新 b 之前,它包含与 a 相同的值,但之后 a 保持不变。这是因为 a 仍然指向同一个字符串,但 b 被重新赋值了一个新的字符串。这就是为什么你不能使用 += 运算符与常量一起使用。参见图 5.2。

快速检查 5.2

Q1:

执行以下代码会发生什么?

const a = "Hello";

   const b = a.concat(", World!");

QC 5.2 答案

A1:

你可能会认为当 a 是一个常量时,a.concat 会抛出错误,但记住字符串上的 concat 方法不会修改现有的字符串或重新赋值包含它的变量:它只是返回一个新的字符串。正因为如此,这个语句是有效的,b 变成了字符串“Hello, World!”

图 5.2. 展示原始数据不可变

5.2. 何时使用常量

使用常量的明显地方是在创建比包含的实际值更关注唯一标识符的标志时。例如,像这样的预热练习:

const ADD_ITEM = 'ADD_ITEM';
const DEL_ITEM = 'DEL_ITEM';
let items = [];

function actionHandler(action) {
  switch (action.type) {
    case ADD_ITEM:
      items.push(action.item);
    break;
    case DEL_ITEM:
      items.splice(items.indexOf(action.item), 1);
    break;
  };
}

这确保了操作标志 ADD_ITEMDEL_ITEM 永远不会被意外更改。你可以在那里停止,但如果你想一下,items 数组是否应该被重新赋值?可能不会,所以你也可以将其设置为常量:

const items = [];

但如果你后来决定你需要一个清空列表的操作,你的直觉可能是将 items 赋值给一个新的空数组,但使用常量是无法做到这一点的:

case CLEAR_ALL_ITEMS:
  items = [];              *1*
break;
  • 1 错误

尽管如此,你仍然可以清空数组;你只需要找出一种方法来修改实际值而不需要重新赋值。在这种情况下,你还可以再次使用 splice 来完成这项任务:

case CLEAR_ALL_ITEMS:
  items.splice(0, items.length);
break;

好的,现在你的完整代码看起来是这样的:

const ADD_ITEM = 'ADD_ITEM';
const DEL_ITEM = 'DEL_ITEM';
const CLEAR_ALL_ITEMS = 'CLEAR_ALL_ITEMS';
const items = [];

function actionHandler(action) {
  switch (action.type) {
    case ADD_ITEM:
      items.push(action.item);
    break;
    case DEL_ITEM:
      items.splice(items.indexOf(action.item), 1);
    break;
    case CLEAR_ALL_ITEMS:
      items.splice(0, items.length);
    break;
  };
}

注意你如何使用 const 声明每个值?这种情况不会很罕见。

保护绑定不被重新赋值不是使用常量的唯一原因。因为常量不会被重新赋值,JavaScript 引擎可以做出某些优化来提高性能。正因为如此,在变量不需要重新赋值时使用 const,而在需要时回退到 let 是有意义的。

快速检查 5.3

Q1:

执行以下代码会发生什么?

 function getValue() {
   const val = 5;
   return val;
 }

 let myVal = getValue();
myVal += 1;

QC 5.3 答案

A1:

你可能会认为,因为值最初是在函数返回之前存储在常量中,所以在函数外部重新分配时会出错。但请记住,常量与值绑定有关,而不是这些绑定内的值。函数只是返回值,而不是绑定。所以新的let绑定是安全重新分配的。

摘要

在本课中,你学习了如何使用const声明变量以及它与使用varlet声明的变量有何不同。

  • 常量是不能重新分配的变量。

  • 常量不是不可变的。

  • 常量与let具有相同的作用域规则。

让我们看看你是否掌握了这个:

Q5.1

将你在第 4 课练习中的答案修改为尽可能使用const

第 6 课。新的字符串方法

在阅读第 6 课之后,你将

  • 知道如何使用String.prototype.startsWith

  • 知道如何使用String.prototype.endsWith

  • 知道如何使用String.prototype.includes

  • 知道如何使用String.prototype.repeat

  • 知道如何使用String.prototype.padStart

  • 知道如何使用String.prototype.padEnd

这些方法中没有一个特别难实现,但它们是足够常用的任务,值得包含在标准库中。

考虑这一点

假设你正在编写一个函数,用来显示当前时间。使用Date对象的实例,你可以分别通过getHoursgetMinutes方法获取当前的小时和分钟:

function getTime() {
  const date = new Date();
  return date.getHours() + ':' + date.getMinutes();
}

但如果当前时间是早上 5:06,这个函数将返回字符串5:6。你如何修改这个函数,使得分钟部分总是两位数?

6.1. 搜索字符串

想象一下你正在从数据库中加载产品。产品数据来自几个不同的制造商,并且没有规范化。因此,一些价格以没有前导\(的 499.99 的格式出现,而其他价格以带有前导\)的 37.95 的格式出现。当你需要在网页上显示价格时,你不能简单地在所有价格前加上\(,因为这会使一些价格出现双美元符号,如下所示:\)$37.95。

你可以很容易地判断第一个字符是否是一个$,如下所示:

if( price[0] === '$' ) {
  // price starts with $
}

但如果你需要检查前三个字符呢?想象一下你正在显示一个电话号码列表。它们都有XXX-XXX-XXXX的格式,你需要确定哪些是在当前用户的区号中。你可能做如下操作:

if( phone.substr(0, 3) === user.areaCode ) {
  // phone number is in users area code
}

这两个都是做同样的事情,不是吗——检查给定的字符串是否以给定的值开头?那么为什么它们采取不同的方式呢?答案是,在前一种情况下,你是在检查单个字符,而在后一种情况下,是在检查字符范围。这两种方法都没有很好地使你容易理解你在检查什么;因此需要注释来解释每个方法的作用。使用 ES6,你现在可以以相同的方式 自文档化 解决这两个问题:

if( price.startsWith('$') ) {
  // ...
}
if( phone.startsWith(user.areaCode) ) {
  // ...
}

不仅这些现在更一致,而且不是一眼就能清楚地理解它们在做什么吗?

通过添加 includesstartsWithendsWith 方法,在字符串中搜索字符串变得更加简单。startsWith 检查字符串是否以指定的值开头,而 endsWith 检查字符串是否以该值结尾。includes 检查整个字符串以查看是否包含指定的值:

let str = 'foo-bar';

str.startsWith('foo');         *1*
str.startsWith('bar');         *2*

str.endsWith('foo');           *3*
str.endsWith('bar');           *4*

str.includes('foo');           *5*
str.includes('bar');           *5*
str.includes('o-b');           *5*
  • 1 正确

  • 2 错误

  • 3 错误

  • 4 正确

  • 5 正确

所有这些方法都是大小写敏感的。在进行搜索之前,你总是可以将字符串转换为小写:

let location = 'Atlanta, Ga';
location.endsWith('GA');                     *1*
location.endsWith('ga');                     *2*
location.toLowerCase().endsWith('ga');       *3*
  • 1 错误

  • 2 错误

  • 3 正确

所有这些方法也接受第二个参数来指定字符串中开始搜索的位置:

let locationA = 'Atlanta, Ga';
let locationB = 'Galveston, Tx';

locationA.includes('Ga');                              *1*
locationB.includes('Ga');                              *2*

locationA.includes('Ga', locationA.indexOf(' '));      *3*
locationB.includes('Ga', locationB.indexOf(' '));      *4*
  • 1 正确

  • 2 正确

  • 3 正确

  • 4 错误

如果你的需求比这些方法能实现的更复杂,你仍然可以回退到使用正则表达式进行更自定义的字符串搜索。

快速检查 6.1

Q1:

假设你有一个表示天气数据的对象的数组。每个对象都有一个 icon 属性,该属性指定了代表天气条件的图标名称。如果使用的图标是图标的夜间版本,则图标名称以 night 结尾。编写一个过滤器,使用我们介绍的三种方法之一,获取所有具有夜间图标的对象。

| |

QC 6.1 答案

A1:

let nightIcons = weatherObjs.filter(function(weather) {
  return weather.icon.endsWith('-night');
});

6.2. 字符串填充

字符串填充意味着指定你想要字符串有多长,然后用填充字符填充字符串直到达到该长度。例如,在预训练练习中,你希望分钟数总是两个字符长。有时它是(例如 36),但有时不是(例如 5)。通过使用填充字符 0 将字符串填充到长度为 2,36 将保持不变,因为它已经是两个字符,而 5 将变成 05 或 50,具体取决于你是向左还是向右填充。

假设你需要一个函数,可以将以十进制(基数 10)写成的 IP 地址转换为二进制(基数 2)。你可能最终会编写一个看起来像这样的函数:

function binaryIP(decimalIPStr) {
  return decimalIPStr.split('.').map(function(octet) {
    return Number(octet).toString(2);
  }).join('.');
}

但这个函数对小于 128 的任何数字都不会起作用,因为它的二进制表示将少于八位数字。

binaryIP('192.168.2.1');            *1*
  • 1 “11000000.10101000.10.1”

为了解决这个问题,你需要将每个八位字节用零填充,直到每个字节都是八位,如下一页顶部的示例所示。为了实现这一点,你可以使用 ES2015 中引入的新特性,String.prototype.repeat。我们也可以使用 padStart,但它更加灵活,你将在下一分钟看到,而且 repeat 也是值得了解的。

function binaryIP(decimalIPStr) {
  return decimalIPStr.split('.').map(function(octet) {
    let bin = Number(octet).toString(2);
    return '0'.repeat(8 - bin.length) + bin;
  }).join('.');
}

binaryIP('192.168.2.1');     *1*
  • 1 “11000000.10101000.00000010.00000001”

这之所以有效,是因为 repeat 函数重复调用的函数,其重复次数由参数中指定的次数决定:

'X'.repeat(4);               *1*
  • 1 XXXX (X 重复 4 次)

如果指定的数字不是一个整数,它将首先被向下取整(向下舍入,而不是向上舍入):

'X'.repeat(4.9);             *1*
  • 1 XXXX

这对任何长度的字符串都适用:

'foo'.repeat(3);             *1*
  • 1 foofoofoo

使用 String.prototype.repeat 对我们的二进制示例有效。但如果你想要用超过一个字符的字符串进行填充,仅使用 repeat 函数将会更难实现,但你可以很容易地使用 String.prototype.padStartString.prototype.padEnd 来实现。

padStartpadEnd 方法接受两个参数:返回字符串的最大长度和填充字符串,如图 6.1 所示 figure 6.1。填充字符串默认为空格 " " 字符:

'abc'.padStart(6);               *1*
'abc'.padEnd(6);                 *2*

'abc'.padStart(6, 'x');          *3*
'abc'.padEnd(6, 'x');            *4*

'abc'.padStart(6, 'xyz');        *5*
'abc'.padEnd(6, 'xyz');          *6*
  • 1 “ abc”

  • 2 “abc ”

  • 3 “xxxabc”

  • 4 “abcxxx”

  • 5 “xyzabc”

  • 6 “abcxyz”

图 6.1. 拆解 padStart 函数

最大长度属性指定了填充重复后的字符串的最大长度。如果填充是多个字符并且不能均匀添加,它将被截断:

'abc'.padStart(8, '123');         *1*
'abc'.padEnd(5, '123');           *2*
  • 1 “12312abc”

  • 2 “12abc”

如果最大长度小于原始字符串的长度,原始字符串不会被截断,而是返回没有任何填充应用的原字符串:

'abcdef'.padStart(4, '123');        *1*
  • 1 “abcdef”

使用这个,你的 binaryIP 函数可以重写如下:

function binaryIP(decimalIPStr) {
  return decimalIPStr.split('.').map(function(octet) {
    return Number(octet).toString(2).padStart(8, '0')
  }).join('.');
}

binaryIP('192.168.2.1');            *1*
  • 1 “11000000.10101000.00000010.00000001”

这是一种相当简洁的实现,易于阅读和理解。使用纯 ES5 代码来实现相同的功能将会非常冗长。当然,你也可以使用第三方字符串填充函数或自己编写一个,但 String.prototype.padStartString.prototype.padEnd 消除了这种需求。

快速检查 6.2

Q1:

编写一个使用 repeat 的函数,将任何字符串重复到正好 50 个字符,截断任何多余的字符。

| |

QC 6.2 答案

A1:

function repeat50(str) {
  const len = 50;
  return str.repeat(Math.ceil(len / str.length)).substr(0, len);
}

摘要

在本课中,目的是教给你即将添加到字符串中的最有用的新方法。

  • String.prototype.startsWith 检查一个字符串是否以一个值开头。

  • String.prototype.endsWith 检查一个字符串是否以一个值结尾。

  • String.prototype.includes 检查一个字符串是否包含一个值。

  • String.prototype.repeat 重复一个字符串指定次数。

  • String.prototype.padStart 填充字符串的开始部分。

  • String.prototype.padEnd 用于填充字符串的末尾。

让我们看看你是否理解了:

Q6.1

编写一个函数,该函数将接受一个电子邮件地址并屏蔽所有直到@的字符。例如,电子邮件地址 christina@example.com 将被屏蔽为*********@example.com。

第 7 课. 模板字符串

在阅读第 7 课之后,你将

  • 了解如何使用模板字符串实现字符串插值

  • 理解如何使用模板字符串的多行字符串

  • 了解如何使用模板字符串创建可重用的模板

  • 理解如何使用标签化模板字符串为模板字符串添加自定义处理

在 JavaScript 中,我最大的烦恼之一就是缺乏对多行字符串的支持。必须将每一行拆分为单独的字符串并将它们全部粘合在一起是一个繁琐的过程。随着模板字符串的添加,这种痛苦消失了,还有一些其他的好处。模板字符串将多行字符串引入 JavaScript,以及插值和标签化。如果你不确定这些术语的含义,不要担心:我会解释每一个。

考虑以下内容

研究以下函数,该函数接受一个产品对象并返回表示产品的适当 HTML。目前它只显示产品的照片和描述,由于许多字符串被粘合在一起以弥补对多行字符串和插值的支持不足,因此很难理解正在发生的事情。一个真实的产品可能需要显示标题、价格和其他细节,这使得理解更加困难。随着产品 HTML 要求的日益复杂,你会采取哪些步骤来保持代码的可读性?

 function getProductHTML(product) {
   let html = '';
   html += '<div class="product">';
   html += '<div class="product-image">';
   html += '<img alt="'+ product.name +'" src="'+ product.image_url +'">';
   html += '</div>';
   html += '<div class="product-desc">' + product.desc + '</div>';
   html += '</div>';
   return html;

}

7.1. 模板字符串是什么?

模板字符串是创建字符串的新字面量语法。它使用反引号(`)作为分隔符,而不是像字符串字面量那样使用引号(),并支持新的特性和功能。但模板字符串的评估结果与字符串字面量一样是字符串:

let str1 = `Hello, World`;
let str2 = "Hello, World";
let str3 = `Hello, World`;          *1*
console.log(str1 === str3);         *2*
console.log(str2 === str3);         *2*
}
  • 1 使用模板字符串创建字符串

  • 2 真实

区别在于模板字符串支持三个常规字符串字面量不支持的新特性:插值多行标签化。让我们定义这些术语。

字符串插值—这是将动态值放入字符串创建过程中的概念。许多其他语言支持这一功能,但到目前为止,JavaScript 只能通过连接将动态值放入较长的字符串中:

let interpolated = `You have ${cart.length} items in your cart`

let concatenated = 'You have ' + cart.length + ' items in your cart';

多行字符串——这并不是创建多行字符串的概念。JavaScript 一直能够做到这一点(例如,"Line One\nLine Two")。这是指字面量本身跨越多行。JavaScript 中的常规字符串必须在单行中定义,这意味着开引号和结束引号必须在同一行上。使用模板字面量,情况并非如此:

let multiline = `line one
                 lines two`

标记模板字面量——这是一个更高级的使用。记得模板字面量可以插入值吗?标记模板字面量是一个带有函数标记的模板字面量。该函数会获得所有原始字符串部分以及单独的插值值,以便它可以对字符串进行自定义预处理。它可以返回一个完全不同的字符串或一个甚至不是字符串的定制值。如果你想在合并到最终值之前对插值值进行某种预处理,这特别有用。例如,你可以有一个将模板字面量转换为 DOM 节点的标记函数,同时转义插值值以防止 HTML 注入。你也可以通过将字符串部分视为安全内容,将插值值视为潜在危险内容来防止 SQL 注入:

let html = domTag`<div class="first-name">${userInput}</div>`

因此,我们现在有一个概述;让我们更详细地看看这些内容,从插值开始。

7.1.1. 使用模板字面量的字符串插值

将值插值到模板字面量的语法是将要插值的值用大括号 {} 括起来,并在开大括号前加上美元符号 $

function fa(icon) {
  return `fa-${icon} fa`;
}

fa('check-square');           *1*
  • 1 “fa-check-square fa”

这将从 icon 变量中获取值并将其注入到模板字符串中。你可以在图 7.1 中可视化这个过程。

图 7.1. 图标变量的值被插值到字符串中

此函数生成给定图标所需的 Font Awesome (fontawesome.io/icon/check-square/) CSS 类。所需的插值越多,这种功能的好处就越明显,如下页的代码示例所示。

function greetUserA(user, cart) {
  const greet = `Welcome back, ${user.name}.`;
  const cart  = `You have ${cart.length} items in your cart.`;
  return `${greet} ${cart}`;
}

function greetUserB(user, cart) {
  const greet = 'Welcome back, ' + user.name + '.';
  const cart  = 'You have ' + cart.length + ' items in your cart.';
  return greet + ' ' + cart;
}

function madlibA(adjective, noun, verb) {
  return `The ${adjective} brown ${noun} ${verb}s`;
}

function madlibB(adjective, noun, verb) {
  return 'The ' + adjective + ' brown ' + noun + ' ' + verb + 's';
}

madlibA('quick', 'fox', 'jump');           *1*
madlibB('quick', 'fox', 'jump');           *1*
  • 1 “The quick brown fox jumps”

在每个示例中,这两个函数在功能上是等效的。不过,我认为在两种情况下,没有人会认为变体 B 更受欢迎。

注意,你可以使用正常的转义字符来转义插值:

let color = 'hazel';

let str1 = `My eyes are ${color}`;          *1*
let str2 = `My eyes are \${color}`;         *2*
  • 1 “My eyes are hazel”

  • 2 “My eyes are ${color}”

但如果你只是添加一个没有后跟开括号的美元符号,那么就没有必要转义任何内容:

let price = `Only $${5.0.toFixed(2)}`;      *1*
  • 1 “Only $5.00”

任何你插值的值如果不是字符串,都会被转换为字符串,并使用其字符串表示形式:

let obj = { foo: 'bar' };
let str = `foo: ${obj}`;

console.log(str);                *1*
  • 1 “foo: [object Object]”

快速检查 7.1

Q1:

分配给短语变量的模板字面量会评估成什么?

 let fruit = 'banana';
 let color = 'yellow';

let phrase = `the ${`big ${color}`} ${fruit}`;

| |

QC 7.1 答案

A1:

如果模板字面量被插值到另一个模板字面量中,则内部字面量将被评估为字符串,然后其字符串值将被插值到外部模板字面量中。因此,这里的最终值将是the big yellow banana

7.1.2. 使用模板字面量的多行字符串

与字符串字面量不同,未终止的模板字面量将继续到下一行,直到终止:

let multiline = `Hello,
World`;

console.log(multiline);          *1*
  • 1 “Hello,\nWorld”

一定要记住的一件事是,多行模板字面量保留所有空白字符:

function greetUser(user, cart) {
  return `Welcome back, ${user.name}.
          You have ${cart.length} items in your cart.`;
}

greetUser(currentUser, currentCart);                  *1*
  • 1 “Welcome back, JD.\n You have 0 items in your cart.”

您现在可以使用模板字面量来解决这个前导练习:

function getProductHTML(product) {
  return `
    <div class="product">
      <div class="product-image">
        <img alt="${product.name}" src="${product.image_url}">
      </div>
      <div class="product-desc">${product.desc}</div>
    </div>
  `;
}

快速检查 7.2

Q1:

以下两个函数是否等价?

 // Using String Literals
 function getProductHTML(product) {
   let html = '';
   html += '<div class="product">';
   html += '<div class="product-image">';
   html += '<img alt="'+ product.name +'" src="'+ product.image_url +'">';
   html += '</div>';
   html += '<div class="product-desc">' + product.desc + '</div>';
   html += '</div>';
   return html;
 }

 // Using Template Literals
 function getProductHTML(product) {
   return `
     <div class="product">
       <div class="product-image">
         <img alt="${product.name}" src="${product.image_url}">
       </div>
       <div class="product-desc">${product.desc}</div>
     </div>
   `;
}

| |

QC 7.2 答案

A1:

不,它们很接近,但由于模板字面量保留了空白字符,它将包括换行符和空格,这可能会在渲染到页面时稍微影响布局。

7.2. 模板字面量不是可重用模板

我认为“模板字面量”这个名字有点误导,因为它创建的是字符串,而不是模板。当开发者想到模板时,他们通常想到的是可重用的东西。模板字面量不是可重用模板,而是一次性模板,它们会一次性评估并转换为字符串,因此没有可重用性。如果您不熟悉使用其他 JavaScript 模板,如 Underscore、Lodash、Handlebars 或几个其他模板,那么您可能不会太困惑。如果您是从我列出的模板库之一开始使用的,那么让我们来探讨一下差异:

let name = 'Talan';

// Using underscore
let greetA = _.template('Hello, <%= name %>');

greetA(name);                                     *1*
greetA('Jonathon');                               *2*

// Using template literals
let greetB = `Hello, ${name}`;
greetB;                                           *3*
greetB('Jonathon');                               *4*
  • 1 Hello, Talan

  • 2 Hello, Jonathon

  • 3 Hello, Talan

  • 4 未捕获的类型错误:greetB 不是一个函数

当您使用 Underscore 等库创建模板时,您指定值占位符,并返回一个函数,您可以多次调用该函数,用不同的值进行插值。另一方面,模板字面量在创建时插值值,因此不会用新值重新评估它。

然而,您可以通过将模板字面量包裹在一个函数中来创建一个可重用模板:

let name = 'Talan';

function greet(name) {
  return `Hello, ${name}`;
}
greet(name); // Hello, Talan
greet('Jonathon'); // Hello, Jonathon

快速检查 7.3

Q1:

在以下代码中,会在控制台输出什么?

let name = 'JD';
let greetingTemplate = `Hello, ${name}`;
name = 'Jon';
console.log(greetingTemplate);

| |

QC 7.3 答案

A1:

Hello, JD

7.3. 使用标记模板字面量进行自定义处理

想象一下,你负责一个不断增长的电子商务网站。最初,所有用户都是英语使用者,但现在你需要支持多种语言。如果有一种方法可以标记需要国际化的字符串,那岂不是很好?如果你可以在字符串前加上 i18n^([1]) 并自动为用户的区域设置翻译,那岂不是更好?

¹

i18n 是 国际化 的常见缩写,意味着 i + 18 个字母 + n。

function greetUser(user, cart) {
  return i18n`Welcome back, ${user.name}.
              You have ${cart.length} items in your cart.`;
}

标记模板字面量是一个带有函数标记的模板字面量,通过在模板字面量前加上该函数的名称来实现。其基本语法如下:

myTag`my string`

通过这样做,你正在用函数 myTagmy string 打上标签。myTag 函数将被调用,并作为参数传递模板。如果模板有任何插值值,它们将作为单独的参数传递。然后该函数负责对模板进行某种类型的处理,并返回一个新值。

目前只有一个内置的标记函数,String.raw,它处理模板而不解释特殊字符:

const a = `my\tstring`;                     *1*
const b = String.raw`my\tstring`;           *2*
  • 1 “my string”

  • 2 “my\tstring”

这可能只是内置的标记函数,但你也可以使用库作者提供的,或者自己编写!

当模板字面量被标记为函数时,它将被分解为字符串部分和插值值。标记函数将把这些值作为单独的参数传递。第一个参数将是一个包含所有字符串部分的数组;然后对于每个插值值,它将作为函数的附加参数传递:

function tagFunction(stringPartsArray, interpolatedValue1, ...,
interpolatedValueN)
{
}

幸运的是,你永远不需要明确写出这些参数。

为了更好地理解这些部分是如何分解和传递的,编写一个简单的函数来记录这些部分:

function logParts() {
  let stringParts = arguments[0];
  let values = [].slice.call(arguments, 1);           *1*
  console.log( 'Strings:', stringParts );
  console.log( 'Values:', values );
}

logParts`1${2}3${4}${5}`;                             *2*
  • 1 将第一个参数之后的所有参数收集为一个数组。

  • 2 字符串:[“1”, “3”, “”, “”] 值:[2, 4, 5]

在这里,你使用 JavaScript 的 arguments 对象来解析字符串部分和插值值。你使用 arguments[0] 来获取第一个参数,它是一个字符串部分的数组,而你使用 [].slice.call(arguments, 1) 来获取第一个参数之后的所有参数的数组。这个数组将包含所有的插值值。

你应该期望得到字符串 13,但为什么会有两个额外的空字符串?如果被插值的值相邻且之间没有字符串部分,例如 45,它们之间将隐含一个空字符串。对于字符串的开始和结束也是如此。你可以通过以下图表来可视化它是如何计算的:

字符串 “1” “3” “” “”
2 4 5

由于这个原因,第一部分和最后一部分始终是字符串,值将是将这些字符串粘合在一起的内容。这也意味着你可以很容易地使用简单的reduce函数处理这些值:

function noop() {
  let stringParts = arguments[0];
  let values = [].slice.call(arguments, 1);
  return stringParts.reduce(function(memo, nextPart) {
    return memo + String(values.shift()) + nextPart;            *1*
  });
}

noop`1${2}3${4}${5}`;                                           *2*
  • 1 .shift()函数移除并返回数组的第一个值。

  • 2 “12345”

在这个函数中,你处理模板字符串的方式与它没有被标记时一样。但重点是允许自定义处理。这个特性很可能会由库的作者实现,但你仍然可以使用它来创建一些自定义处理。编写一个函数,用于去除你的标记之间的空白:

function stripWS() {
  let stringParts = arguments[0];
  let values = [].slice.call(arguments, 1);
  let str = stringParts.reduce(function(memo, nextPart) {
    return memo + String(values.shift()) + nextPart;
  });
  return str.replace(/\n\s*/g, '');
}
function getProductHTML(product) {
  return stripWS`
    <div class="product">
      <div class="product-image">
        <img alt="${product.name}" src="${product.image_url}">
      </div>
      <div class="product-desc">${product.desc}</div>
    </div>
  `;
}

我们只是触及了你可以用模板字符串做什么的表面。因为它们允许将插值值与字面量的字符串部分分开进行预处理,并且可以返回任何数据类型,所以它们非常适合创建称为领域特定语言(DSL)的抽象,你将在第一个综合项目中深入了解。

快速检查 7.4

Q1:

在下面的代码片段中有三行代码。使用函数标记模板字符串的正确语法是哪一个?

 myTag(`my templat`)
 myTag()`my templat`
myTag`my templat`

| |

QC 7.4 答案

A1:

最后一个是:myTag`my templat`

摘要

在本课中,你学习了模板字符串的基础知识。

  • 模板字符串是使用反引号`字符创建字符串的新字面量语法。

  • 模板字符串可以使用${}进行值插值。

  • 模板字符串可以跨越多行。

  • 模板字符串将为具有标记模板的领域特定语言打开大门。

让我们看看你是否理解了:

Q7.1

编写一个函数,你可以用它来标记模板字符串,该函数检查每个值是否是对象,如果是,则在插值之前将对象转换为键/值对的字符串。

例如,如果你这样调用函数

const props = {
  src: 'http://fillmurray.com/100/100',
  alt: 'Bill Murray'
};

const img = withProps`<img ${props}>`;

你会得到

<img src="http://fillmurray.com/100/100" alt="Bill Murray">

第 8 课:综合:构建领域特定语言

在这个项目中,你将

  • 学习领域特定语言 (DSL) 是什么

  • 为处理来自 HTML 格式化器的用户输入创建一个简单的领域特定语言

  • 为在数组周围重复或循环模板创建一个简单的领域特定语言

在你构建领域特定语言之前,让我们首先定义一下它是什么。领域特定语言以一种巧妙的方式使用编程语言的功能和语法,使得解决特定任务看起来就像编程语言本身提供了第一类支持。例如,JavaScript 中常见的一种领域特定语言是由单元测试库使用的:

describe('push', function() {
  it('should add new values to the end of an array', function() {
    // perform test
  });
});

这是一种领域特定语言,因为它看起来你正在使用自然语言来声明你正在描述推送,然后给出实际的描述:它应该将新值添加到数组的末尾。实际上,句子中的describeit部分是函数,其余部分是这些函数的参数。

其他语言,如 Ruby,有很多领域特定语言(DSL)。特别是 Ruby 非常灵活,有大量的语法糖,这使得创建领域特定语言变得极其容易。另一方面,JavaScript 在历史上并没有容易创建领域特定语言。但是,有了标签模板,这可能有所改变。例如,一个聪明的程序员可能能够利用标签模板将前面的单元测试领域特定语言转换为以下语法:

describe`push: it should add new values to the end of an array`{
  // perform test
}`

你不会实现这个领域特定语言(DSL),但你将创建一些自己的。在接下来的章节中,你将

  • 创建一些字符串处理的辅助函数

  • 创建一个 HTML 转义领域特定语言(DSL)

  • 创建一个将数组转换为 HTML 的领域特定语言(DSL)

8.1. 创建一些辅助函数

在我们开始之前,你需要创建一些辅助函数。在上一个课程中,当创建模板标记函数时,你开始编写一些类似于以下代码的代码:

function stripWS() {
  let stringParts = arguments[0];
  let values = [].slice.call(arguments, 1);
  // do something with stringParts and values
}

这前两行是为了获取模板字面量的字符串值和插值值。虽然它们有点晦涩,但会分散其他逻辑的注意力。你很快会发现收集这些值更好的方法,但现在你将在这个步骤中抽象出这一层,使其隐藏在其他逻辑之外。如何做到?你将创建一个单独的函数来完成这个任务,然后将收集到的值传递给你的标签函数。

列表 8.1. 抽象收集插值值的过程
function createTag(func) {
  return function() {
    const strs = arguments[0];
    const vals = [].slice.call(arguments, 1);
    return func(strs, vals);
  }
}

现在当你创建一个标记函数时,你可以这样做,这要干净得多:

const stripWS = createTag(function(strs, vals){
  // do something with strs and vals
});

另一个冗余步骤是在你必须使用reduce来交织给定的字符串和插值值时。你也可以抽象出这个步骤。

列表 8.2. 抽象字符串和插值值的交织
function interlace(strs, vals) {
  vals = vals.slice(0);                           *1*
  return strs.reduce(function(all, str) {
    return all + String(vals.shift()) + str;
  });
}
  • 1 这一行是复制数组,在调用像shift这样的原地修改数组的函数之前是一个好的实践。

你现在可以使用createTaginterlace的组合来处理模板字面量,如下一列表所示的标准实现。

列表 8.3. 处理模板字面量
const processNormally = createTag(interlace);

const text = 'Click Me';
const link = processNormally`<a>${text}</a>`;          *1*

现在你已经抽象出了这些部分,你可以编写一个专注于当前业务的标记函数——转义 HTML 字符串。

8.2. 创建一个 HTML 转义领域特定语言(DSL)

让我们考虑将模板字面量和一些值转换为 HTML 转义和插值的业务逻辑。你的第一个标签将转义你的插值值。你想要做的是将任何<>的出现分别转换为&lt;&gt;。你将使用这个方法来转义用户输入,以防止在文档中注入意外的 HTML。首先编写一个函数,如下一列表所示,来完成这个任务。

列表 8.4. 一个简单的 HTML 转义函数
function htmlEscape (str) {
  str = str.replace(/</g, '&lt;');
  str = str.replace(/>/g, '&gt;');
  return str;
}

这个函数使用正则表达式将<>字符分别替换为&lt;&gt;。现在你有了这个,编写你的标记函数将会非常简单。

列表 8.5. 你的标记函数,htmlSafe
const htmlSafe = createTag(function(strs, vals){
  return interlace(strs, vals.map(htmlEscape));                *1*
});
  • 1 仅对要插值的值调用 htmlEscape*

这之所以作为一个标记函数比直接使用htmlEscape函数更有益,是因为它允许你仅针对要转义的插值值进行转义,如列表 8.6 中所示。

列表 8.6. 基本 HTML 转义 DSL 的实际应用
const userInput = 'I <3 ES6!';

const a = htmlEscape(`<strong>${userInput}</strong>`);         *1*
const b = htmlSafe`<strong>${userInput}</strong>`;             *2*
  • 1 <strong>I ❤️ ES6!</strong>

  • 2 I ❤️ ES6!

注意字符串a中所有的 HTML 片段都被转义了,但字符串b正确地只转义了<3>

8.3. 创建一个将数组转换为 HTML 的 DSL

看看下面的代码。你期望list的值是什么?

const fruits = ['apple', 'orange', 'banana'];

const list = expand`<li>${fruits}</li>`;

如果有一个实用工具可以重复模板,围绕值数组扩展模板,生成以下 HTML,那岂不是很好?

<li>apple</li>
<li>orange</li>
<li>banana</li>

让我们看看如何构建它!实际上很简单。你只需要

  • 获取模板的第一部分和最后一部分

  • 获取插值的数组

  • 对数组中的每个项目进行映射,将每个项目包裹在模板的部分中

所有这些都可以转化为以下代码:

const expand = function(parts, items){
  const start = parts[0];
  const end   = parts[1];
  const mapped = items.map(function(item){
      return start + item + end;
  });

  return mapped.join('');
};

现在我们来看看它的实际应用。

列表 8.7. DSL 插值一个值数组,而不仅仅是单个值
const lessons = [
  'Declaring Variables with let',
  'Declaring constants with const',
  'New String Methods',
  'Template Literals',
]

const html = `<ul>${
  expand`<li>${lessons}</il>`
}</ul>`

html变量现在看起来是这样的:

<ul>
  <li>Declaring Variables with let</li>
  <li>Declaring constants with const</li>
  <li>New String Methods</li>
  <li>Template Literals</li>
</ul>

目前输出看起来像是 HTML,但仍然只是一个字符串。你可以通过修改expand或创建另一个模板标记来进一步改进,将字符串转换为实际的 DOM 节点。

摘要

在这个综合项目中,你使用了letconst来定义你的变量,并使用了模板字面量和标签模板字面量来构建领域特定语言(DSL)。这仅仅是 ES6 新字符串功能所能做到的一小部分。在下一个单元中,我们将探索对象和数组的新增功能。

第 2 单元:对象和数组

对象和数组一直是 JavaScript 的工作马,作为组织数据的首选数据结构。即使添加了映射和集合,你将在 第 5 单元 中学习到,对象和数组也不会消失,并且仍然会像以前一样被广泛使用。

注意我如何将对象和数组称为 数据结构。使用字面量,你可以轻松地将数据结构化成复杂的结构,而没有字面量将变得繁琐。相反的机制一直缺失。你可能甚至从未注意到它的缺失,但一旦你看到能够像 解构 数据一样轻松地 结构化 数据是多么的酷,你可能会在任何不得不回到旧方法的时候感到不便。解构是 JavaScript 中我最喜欢的新增功能之一,而且有很好的理由。你会发现自己在日常生活中经常使用它来使代码更容易阅读和编写。但在你跳到解构之前,我们将看看一些被添加到对象和数组中的有用新方法。我们还将看看对象字面量的一些受欢迎的添加,这使得它们比以前更强大。

最后,我们将探讨一个全新的原始数据类型,即符号。符号通常用于定义我所说的“元行为”——用于更改或定义现有功能行为的钩子。它们还可以用来避免与字符串可能遇到的命名冲突。

你将通过编程一个使用符号的唯一性作为锁的键的锁和钥匙结构来结束本单元。然后,你将使用这些锁和钥匙创建一个 选择门 游戏,玩家将尝试使用他们获得的钥匙解锁门。

第 9 课:新数组方法

在阅读 第 9 课 之后,你将

  • 了解如何使用 Array.from 构建数组

  • 了解如何使用 Array.of 构建数组

  • 了解如何使用 Array.prototype.fill 构建数组

  • 了解如何使用 Array.prototype.includes 在数组中进行搜索

  • 了解如何使用 Array.prototype.find 在数组中进行搜索

数组可能是 JavaScript 中最常用的数据结构。我们使用它们来存储各种数据,但有时将所需数据放入或从数组中取出并不像应该的那样容易。但一些新的数组方法使得这些任务变得容易得多,我们将在本课中介绍这些方法。

考虑这一点

考虑以下 jQuery 代码片段,它获取所有具有特定 CSS 类的 DOM 节点并将它们设置为红色。如果你要从头开始实现这个功能,你需要考虑哪些因素?例如,如果你要使用 document.querySelectorAll,它返回一个 NodeList(而不是 Array),你将如何迭代每个节点来更新其颜色?

$('.danger').css('color', 'red');

9.1. 使用 Array.from 构建数组

假设你将要编写一个用于计算平均值的函数。该函数应接受任意数量的参数,并返回所有这些数字的平均值。你最初可能会像在 列表 9.1 中定义此函数。但此实现不会工作,因为它忘记了 arguments 对象实际上不是一个数组的事实。在尝试使用此函数并得到类似 arguments.reduce 不是函数 的错误后,你可能会意识到你需要将 arguments 对象转换为数组。

列表 9.1. avg 版本 1:产生错误,因为参数不是一个数组
function avg() {
  const sum = arguments.reduce(function(a, b) {
    return a + b;
  });
  return sum / arguments.length;
}

在 ES6 之前,将类似数组的对象转换为数组的常见方法是在 Array.prototype 上使用 slice 方法并将其应用于类似数组的对象。这是因为对数组调用 slice 而不带任何参数只是创建了一个数组的浅拷贝。通过将相同的逻辑应用于类似数组的对象,你仍然会得到一个浅拷贝,但这个拷贝是一个实际的数组:^([1])

¹

使用 Array.prototype.apply 也可以达到同样的效果。

Array.prototype.slice.call(arrayLikeObject);

// or a shorter version:
[].slice.call(arrayLikeObject);

考虑到这一点,你可以通过使用以下技巧将 arguments 对象转换为数组来修复你的 avg 函数,如下面的列表所示。

列表 9.2. avg 版本 2:使用 slice 将参数转换为数组
function avg() {
  const args = [].slice.apply(arguments);
  const sum = args.reduce(function(a, b) {
    return a + b;
  });
  return sum / args.length;
}

使用 Array.from 后,这个快捷方式就不再需要了。Array.from 的目的是从一个类似数组的对象中获取一个实际的数组。类似数组的对象是任何具有 length 属性的对象。length 属性用于确定新数组的长度;任何小于 length 属性的整数属性都将添加到新创建的数组中适当的索引位置。例如,字符串具有 length 属性和表示每个字符索引的数字属性。因此,使用字符串调用 Array.from 将返回一个字符数组。arguments 对象也可以使用此技术转换为数组,如下面的列表所示。

列表 9.3. 更新 avg 函数以使用 Array.from
function avg() {
  const args = Array.from(arguments);
  const sum = args.reduce(function(a, b) {
    return a + b;
  });
  return sum / args.length;
}

avg(1, 2, 3);                    *1*
avg(100, 104);                   *2*
avg(10 , 99, 5, 46);             *3*
  • 1 返回 2

  • 2 返回 102

  • 3 返回 40

需要使用 Array.from 的另一个常见场景是与 document.querySelectorAll 结合使用。document.querySelectorAll 返回一个匹配的 DOM 节点列表,但其对象类型是 NodeList,而不是数组。

你不仅限于使用内置对象与 Array.from 一起使用。任何具有长度属性的对象都可以工作,即使它只有长度属性:

Array.from({ length: 50 })

这将创建一个与 new Array(50) 完全相同的数组。但如果你只想创建一个包含单个值 50 的数组,而不是长度为 50 的数组呢?下一节将给出答案。

快速检查 9.1

问题 1:

将以下代码转换为使用 Array.from

 let nodes = document.querySelectorAll('.accordian-panel');
 let nodesArr = [].slice.call(nodes);
nodesArr.forEach(activatePanel);

QC 9.1 答案

A1:

let nodes = document.querySelectorAll('.accordian-panel');
let nodesArr = Array.from(nodes);
nodesArr.forEach(activatePanel);

9.2. 使用 Array.of 构造数组

有一个函数有时以一种方式处理其参数,而在其他时候则以完全不同的方式处理,这通常被认为是一种糟糕的设计。例如,在前一节中,你创建了一个avg函数,该函数返回所有参数的平均值。现在想象一下,如果它只有一个参数:你期望只得到那个数字,对吧?毕竟,任何单个数字的平均值都是相同的数字?但是,如果只提供一个数字时,它执行了完全不同的操作并返回了该数字的平方根,你可能会告诉我这是一个糟糕的设计,但那种混合行为正是Array构造函数的工作方式。考虑以下三个数组;其中一个是表面现象:

let a   = new Array(1, 2, 3);            *1*
let b   = new Array(1, 2);               *2*
let c   = new Array(1);                  *3*
  • 1 数组 a 包含三个值:1,2,3。

  • 2 数组 b 包含两个值:1 和 2。

  • 3 最后数组 c 包含一个值:undefined。

当你声明new Array(1)时,你得到一个包含undefined值的数组,这有点奇怪.^([2]) 这种情况的原因是Array构造函数中的一种特殊行为,如果只有一个参数且该参数是一个整数,它将创建一个长度为n的稀疏数组,其中n是作为参数传入的数字。为了避免这种怪癖,你可以使用更可预测的Array.of工厂函数。

²

它甚至没有undefined值;它有一个空位。

let a = Array.of(1, 2, 3);
let b = Array.of(1, 2);
let c = Array.of(1);

使用Array.of创建的数组除了数组c之外都是相同的,这次数组c更直观地包含单个值1。此时,你可能正在想,为什么不直接使用数组字面量呢?例如:

let a = [1, 2, 3];
let b = [1, 2];
let c = [1];

在大多数情况下,数组字面量实际上是创建数组的首选方式。但是,在某些情况下,数组字面量可能无法使用。其中一种情况是使用数组的子类.^([3]) 假设你正在使用一个提供Array子类AwesomeArray的库。因为它是一个Array的子类,所以它有相同的怪癖。所以你不能简单地调用new AwesomeArray(1),因为你会得到一个包含单个undefined值的AwesomeArray。但是你不能在这里使用数组字面量,因为这会给你一个Array的实例,而不是AwesomeArray的实例。然而,你可以使用AwesomeArray.of(1)并得到一个包含单个值1AwesomeArray实例。

³

我们将在后面的单元中介绍类和子类。

因此,现在你可以使用Array.of(50)构造一个包含单个数值的数组,但如果你确实想要一个包含 50 个值的数组怎么办?你可以回到new Array(50),但那仍然有问题,我们将在下一节中看到。

快速检查 9.2

Q1:

以下哪个返回包含undefined值的数组?

 new Array()
 new Array(true)
 new Array(false)
 new Array(5)
new Array("five")

| |

QC 9.2 答案

A1:

以下哪个返回一个包含 undefined 值的数组?

new Array(5)

9.3. 使用 Array.prototype.fill 构建数组

想象你正在创建一个井字棋游戏。也许你是为某个客户构建的,或者可能是一个副项目。无论如何,井字棋很有趣,你可以构建它!为了开始,你需要决定你将如何实现棋盘。井字棋棋盘是一个 3 × 3 的 9 个槽位的网格。你决定你将只使用一个数组来表示。你可以使用长度为 9 的数组来表示 9 个网格槽位,可能的值将是 "x""o" 或空格 " "。你将称这个数组为 board,但你需要用 9 个空格 " " 初始化。也许你会这样做:

const board = new Array(9).map(function(i) {
  return ' '
})

这里的思考过程是,你用 9 个值初始化数组,所有这些值都是 undefined。然后你使用 map 将每个 undefined 值转换为空格。但这不会起作用。当你创建一个像 new Array(9) 这样的数组时,它实际上并没有向新数组添加九个 undefined 值。它只是将新创建数组的 length 属性设置为 9

如果你对此感到困惑,让我们首先谈谈数组在 JavaScript 中的工作方式。数组并不像许多人想象的那样特殊。除了有 [ ... ] 这样的字面语法外,它与任何其他对象没有区别。当你创建一个像 ['a', 'b', 'c'] 这样的数组时,内部这个数组看起来是这样的:

{
  length: 3,
  0: 'a',
  1, 'b',
  2, 'c'
}

当然,它还会继承来自 Array.prototype 的几个方法,例如 pushpopmap 等。当执行 mapforEach 这样的迭代操作时,数组会内部查看其长度,然后检查从零开始到 length 结束范围内的任何属性。

当你通过 new Array(9) 创建一个数组时,许多开发者认为数组内部看起来是这样的:

{
  length: 9,
  0: undefined,
  1: undefined,
  2: undefined,
  3: undefined,
  4: undefined,
  5: undefined,
  6: undefined,
  7: undefined,
  8: undefined
}

但实际上它内部看起来是这样的:

{
  length: 9
}

它将有一个长度为 9,但它实际上不会有九个值。这些缺失的值被称为 holes。像 map 这样的方法在 holes 上不起作用,这就是为什么你尝试创建一个包含九个空格的数组没有成功的原因。一个新的方法 fill 可以用指定的值填充数组。在填充数组时,它不在乎给定索引处是否有值或 hole,因此它将完美地适用于你的使用:

const board = new Array(9).fill(' ')

我们已经介绍了几种构建包含所需值的数组的方法。现在让我们看看在数组构建完成后,如何搜索这些值的一些新方法。

快速检查 9.3

Q1:

arrayAarrayB 之间有什么区别?

let arrayA = new Array(9).map(function() { return 1 });
let arrayB = new Array(9).fill(1);

| |

QC 9.3 答案

A1:

变量 arrayB 准确地创建了一个包含九个 1 的数组。变量 arrayA 没有这样做,因为它实际上不包含任何要映射的值。

9.4. 使用 Array.prototype.includes 在数组中进行搜索

你在第 6 课中学到了,字符串在其原型上有一个新的方法叫做 includes,用于判断一个字符串是否包含某个值。数组也被赋予了此方法,其工作方式与下述示例类似。

列表 9.4. 使用 includes 检查数组是否包含某个值
const GRID = 'grid';
const LIST = 'list';
const availableOptions = [GRID, LIST];

let optionA = 'list';
let optionB = 'table';

availableOptions.includes(optionA);         *1*
availableOptions.includes(optionB);         *2*
  • 1 true

  • 2 false

使用 String.prototype.includes 方法,你是在检查一个字符串是否包含子字符串。Array.prototype.includes 的工作方式类似,但你是在检查数组中任何索引处的值是否是你检查的值。

之前使用 indexOf 来判断一个值是否在数组中。例如:

availableOptions.indexOf('list');         *1*
  • 1 1

这可以正常工作,但往往会导致错误,如果开发者忘记他们需要将结果与 -1 进行比较,而不是比较其真值。如果给定的值位于 0 索引处,他们会得到 0,一个假值,反之亦然:如果值未找到,他们会得到 -1,一个真值。但现在可以通过使用 includes 来避免这个错误,它返回一个布尔值而不是索引。

真值和假值

作为复习,假值的概念是指任何评估为假的值:false, undefined, null, NaN, 0 和空字符串 “”。真值是指评估为真的值。任何之前未列为假值的值都是真值,包括负数。

9.5. 使用 Array.prototype.find 在数组中进行搜索

假设你有一个从数据库缓存的记录数组。当请求一个记录(通过其 ID)时,你首先想检查缓存以查看是否有该记录,并在再次访问数据库之前返回它。你可能会编写如下类似的代码。

列表 9.5. filter 方法返回所有匹配项,即使你只想要一个
function findFromCache(id) {
  let matches = cache.filter(function(record) {
    return record.id === id;
  });
  if (matches.length) {
    return matches[0];
  }
}

列表 9.5 中的 findFromCache 函数当然可以工作,但当缓存有 10,000 条记录,并且只在第 100 次尝试时找到所需的记录时会发生什么?在其当前实现中,它仍然会检查剩余的 9,900 条记录,然后返回找到的记录。这是因为 filter 函数的目的是返回所有匹配的记录。你只期望找到一条记录,一旦找到,你只需要那条记录。这正是 Array.prototype.find 所做的:它像 Array.prototype.filter 一样遍历数组,但一旦找到匹配项,它就会立即返回该匹配项并停止搜索数组。

你可以将你的 findFromCache 函数重写为利用 find 函数,如下所示:

function findFromCache(id) {
  return cache.find(function(record) {
    return record.id === id;
  });
}

find 的另一个优点是,因为它返回匹配项而不是匹配项的数组,所以你不需要在之后从数组中取出它;实际上,你可以直接返回 find 的结果。

快速检查 9.4

Q1:

在以下片段中,变量 result 将被设置为什么值?

 let result = [1, 2, 3, 4].find(function(n) {
   return n > 2;
});

| |

QC 9.4 答案

A1:

变量 result 将被设置为 3,而不是 ['3, 4'],因为 3 是第一个符合条件的价值。

摘要

在本课中,目标是教你即将添加到数组中的最有用的新方法。

  • Array.from 创建一个包含从类似数组的对象中所有值的数组。

  • Array.of 创建一个包含提供值的数组,并且比 new Array 更安全。

  • Array.prototype.includes 检查数组是否在其任何索引中包含一个值。

  • Array.prototype.find 根据一个标准函数搜索数组,并返回找到的第一个值。

  • Array.prototype.fill 使用指定的值填充数组。

让我们看看你是否理解了:

Q9.1

实现从预热练习中的函数。不用担心重新创建所有 jQuery。只需编写你自己的实现,以便以下片段能够工作:

$('a').css('background', 'yellow').css('font-weight', 'bold');

第 10 课:Object.assign

在阅读完 第 10 课 后,你将

  • 知道如何使用 Object.assign 设置默认值

  • 知道如何使用 Object.assign 扩展对象

  • 知道如何在使用 Object.assign 时防止突变

  • 理解 Object.assign 如何分配值

像 Underscore.js 和 Lodash.js 这样的库已经变成了 JavaScript 的工具带。它们为常见任务提供了解决方案,这样普通开发者就不需要为每个项目重新发明轮子。其中一些任务变得如此普遍和定义明确,以至于实际上将它们包含在语言中是有意义的。Object.assign 就是这样的一个方法。有了它,你可以轻松地设置默认值或就地或作为副本扩展对象,而无需编写大量样板代码来做到这一点。

考虑这个

JavaScript 对象可以通过原型链从另一个对象继承属性。但任何给定的对象只能有一个原型。你将如何设计一种方法,允许对象从多个源对象继承?

10.1. 使用 Object.assign 设置默认值

让我们假设你最喜欢的当地披萨店联系你,想要建立一个披萨追踪网站。这个想法是使用 GPS 让顾客能够看到他们订购的披萨在送递过程中的确切位置。你解释说,你非常喜欢他们的披萨,在你的帮助下,你可以帮他们登上地图!当你着手构建这个网站时,你决定你需要一个从披萨店到送递地址生成地图的功能。这个函数将接受一个 options 参数来指定地图的宽度、高度和坐标。问题是,如果这些值中的任何一个没有被设置,你需要使用合理的默认值。一种方法如下所示。

列表 10.1:基本的披萨追踪地图
function createMap(options) {

  const defaultOptions = {
    width: 900,
    height: 500,
    coordinates: [33.762909, -84.422675]
  }

  Object.keys(defaultOptions).forEach(function(key) {
    if ( !(key in options) ) {
      options[key] = defaultOptions[key];
    }
  });

  // ...
}

这种实现只是在解决实际问题的过程中,在分配默认值之前有很多事情要做。使用 Object.assign 重新实现默认值:

function createMap(options) {

  options = Object.assign({
    width: 900,
    height: 500,
    coordinates: [33.762909, -84.422675]
  }, options);

  // ...
}

使用 Object.assign 比你最初的方法更简洁。Object.assign 通过接受任意数量的对象作为参数,并将后续对象的值分配到第一个对象上(见图 10.1)。如果任何对象包含相同的键,列表中的右侧对象将覆盖左侧对象,如下所示:

let a = { x: 1, y: 2, z: 3 };
let b = { x: 5, y: 6 };
let c = { x: 12 };

Object.assign(a, b, c);

console.log(a);                    *1*
  • 1 { x: 12, y: 6, z: 3 }
图 10.1. 使用 Object.assign 分配值

当使用 Object.assign 设置默认值时,你基本上是在取不完整的对象并填充缺失的值。另一个常见任务是取已经完整的对象,并将它们扩展成更定制的变体。

快速检查 10.1

Q1:

在以下 Object.assign 的使用中,ab 之后被设置为什么?

 let a = { city: 'Dallas', state: 'GA' };
 let b = { state: 'TX' };

Object.assign(a, b);

| |

QC 10.1 答案

A1:

a 被设置为 { city: 'Dallas', state: 'TX' },而 b 保持不变。

10.2. 使用 Object.assign 扩展对象

Object.assign 的另一个常见用途是为现有对象添加一些额外的属性。比如说你正在开发一个飞船游戏。游戏有一个基础飞船和几个其他特殊飞船。这些特殊飞船都具有与基础飞船相同的基本功能,但具有额外的或增强的技能。你从一个创建基础飞船的函数开始:

function createBaseSpaceShip() {
  return {
    fly: function() {
      // ... fly function implementation
    },
    shoot: function() {
      // ... shoot function implementation
    },
    destroy: function() {
      // ... function for when the ship is destroyed
    }
  }
}

然后为了创建一个特殊飞船,你创建一个基础飞船的副本并扩展其特殊功能:

列表 10.2. 向飞船添加炸弹属性
function createBomberSpaceShip() {
  let spaceship = createBaseSpaceShip();

  Object.assign(spaceship, {
    bomb: function() {
      // ... make the ship drop a bomb
    }
  });

  return spaceship;
}

let bomber = createBomberSpaceShip();
bomber.shoot();
bomber.bomb();

首先,你创建一个基础飞船对象。然后你使用 Object.assign 添加一个额外的炸弹属性。你可以使用这种技术创建几个增强飞船。不过,这个函数确实有很多样板代码,所以如果你打算重用这个技术,你可以创建一个用于增强基础飞船的辅助函数。

列表 10.3. 用于增强飞船的辅助函数
function enhancedSpaceShip(enhancements) {
  let spaceship = createBaseSpaceShip();

  Object.assign(spaceship, enhancements);

  return spaceship;
}

function createBomberSpaceShip() {
  return enhancedSpaceShip({
    bomb: function() {
      // ... make the ship drop a bomb
    }
  });
}

function createStealthSpaceShip() {
  return enhancedSpaceShip({
    stealth: function() {
      // ... make the ship invisible
    }
  });
}

let bomber = createBomberSpaceShip();bomber.shoot();
bomber.bomb();

let stealthship = createStealthSpaceShip();
stealthship.shoot();
stealthship.stealth();

现在你已经创建了一个 enhancedSpaceShip 函数,它接受一个增强对象作为参数。这些增强可以是新功能,也可以是覆盖现有功能。注意到目前为止,在每一个例子中,你都是创建了一个对象然后对其进行变异?有时这是所需要的,但通常你只是想要一个副本。

快速检查 10.2

Q1:

使用 Object.assign 创建一个扩展了基础飞船的 warp 函数的飞船。

| |

QC 10.2 答案

A1:

function createWarpSpaceShip() {
  return enhancedSpaceShip({
    warp: function() {
      // ... make the ship go warp speed
    }
  });
}

10.3. 防止使用 Object.assign 时发生突变

在对对象进行修改时,程序员通常喜欢操作不修改现有对象,而是返回一个具有所需更改的副本。其中一个原因是为了防止任何当前持有对象引用的其他东西从其下面改变其引用。例如,如果在你太空船的例子中,你没有创建新副本的基础太空船对象的函数?如果你只有一个基础太空船对象呢?如果是这样的话,每次你调用 Object.assign 来增强它时,你就是在增强基础对象,而这不是你想要的。相反,你想要创建一个副本并保持基础对象不变。你可能认为 const 可以帮助你在这里,但它不能。记住,const 只能防止用新对象覆盖变量;它不能防止修改/修改现有对象。

因为 Object.assign 修改了参数列表中的第一个对象,而其余对象保持不变,所以一个常见的做法是将第一个对象设为一个空对象字面量:

let newObject = {};
Object.assign(newObject, {foo: 1}, {bar: 2});
console.log(newObject);                                  *1*
  • 1 {foo: 1, bar: 2}

在进行此操作时,首先创建一个空对象,然后将其传递给 Object.assign 是不方便的。方便的是,Object.assign 也会返回被修改的对象,因此您可以将其缩短如下:

let newObject = Object.assign({}, {foo: 1}, {bar: 2});
console.log(newObject);                                  *1*
  • 1 {foo: 1, bar: 2}

将你的太空船示例重写,使用单个基础对象和你的增强函数来创建一个增强的副本,如下所示。

列表 10.4. 复制太空船
const baseSpaceShip = {
  fly: function() {
    // ... fly function implementation
  },
  shoot: function() {
    // ... shoot function implementation
  },
  destroy: function() {
    // ... function for when the ship is destroyed
  }
}

function enhancedSpaceShip(enhancements) {
  return Object.assign({}, baseSpaceShip, enhancements);           *1*
}

function createBomberSpaceShip() {
  return enhancedSpaceShip({
    bomb: function() {
      // ... make the ship drop a bomb
    }
  });
}

function createStealthSpaceShip() {
  return enhancedSpaceShip({
    stealth: function() {
      // ... make the ship invisible
    }
  });
}
  • 1 通过传递 {} 作为第一个参数,Object.assign 创建了一个副本。

注意,因为你将增强技术抽象到了自己的函数中,所以当你修改其工作方式时,你不需要对 createBomberSpaceShipcreateStealthSpaceShip 函数进行任何修改。

到目前为止,你可能认为 Object.assign 实际上是从右到左合并对象。嗯,不是的;它只从左到右分配值。这个小的区别是我们接下来要讨论的。

快速检查 10.3

Q1:

编写一个名为 createStealthBomber 的函数,该函数使用 Object.assign 创建一个结合了轰炸机和隐形太空船的太空船。

| |

QC 10.3 答案

A1:

function createStealthBomber() {
  const stealth = createStealthSpaceShip();
  const bomber = createBomberSpaceShip();
  return Object.assign({}, stealth, bomber);
}

10.4. Object.assign 如何分配值

定义和赋值之间有一个细微的区别。将值赋给现有属性并不定义属性的行为方式,就像 Object.defineProperty 一样。赋值只有在将赋值操作应用于不存在的属性时才会回退到定义属性。这一点很重要,因为 Object.assign 不定义(或重新定义)属性;它只是对它们进行赋值。大多数时候,这种区别没有影响,但有时它确实有影响。让我们来探讨一下这种区别:

let person = {
  name: 'JD'
}

Object.assign(person, { name: 'JD Isaacks' })

前面的函数按预期工作。但如果 name 属性不是一个简单的字符串,而是一个确保名称不包含空格的 getter 和 setter 呢?

let person = {
  __name: 'JD',
  get name() {
    return this.__name
  },
  set name(newName) {
    if (newName.includes(' ')) {
      throw new Error('No spaces allowed!')
    } else {
      this.__name = newName
    }
  }
}

person.name = 'JD Isaacks'            *1*
  • 1 错误:不允许空格!

如你所见,如果你将一个字符串赋给 name,它会被 setter 函数处理,如果字符串包含空格,它会抛出一个错误。使用 Object.assign 也会发生同样的事情,因为它只是进行赋值;它不会重新定义属性:

Object.assign(person, { name: 'JD Isaacks' })         *1*
  • 1 错误:不允许空格!

此外,Object.assign 只分配对象的自身可枚举属性,这意味着它不会分配在对象原型链上的属性或设置为不可枚举的属性。这通常是好事。以下是一个说明我意思的例子:

let numbers = [1, 2, 3]
let summableNumbers = Object.assign({}, numbers, {
  sum: function() {
    return this.reduce(function(a, b) {
      return a + b
    })
  }
})

summableNumbers[0]                 *1*
summableNumbers.sum()              *2*
  • 1 1

  • 2 TypeError: this.reduce is not a function.

为什么你会得到一个错误?因为 reduce 方法在 numbers 数组的原型链上,而不是直接在 numbers 数组本身上。numbers 数组上直接有的属性只有它包含的三个值对应的 0、1 和 2。正因为如此,这三个值被分配到了新对象上,但数组原型上的所有方法,如 poppushreduce 都没有。然而,你可以通过将值赋给一个已经具有所有所需方法的空数组来使这个工作:

let summableNumbers = Object.assign([], numbers, {             *1*
  // ...
})
  • 1 将值赋给空数组而不是空对象

大多数情况下,这些问题不会成为问题,因为更常见的是,你会在许多开发者喜欢称之为 POJO(普通的旧 JavaScript 对象)的对象上使用 Object.assign,这是一个没有像 getter、setter 或原型这样的东西的对象。这是使用对象字面量 { ... } 语法创建的对象类型。

快速检查 10.4

Q1:

将会在控制台输出什么?

 let doubleTheFun = {
   __value: 1,
   get value() {
     return this.__value
   },
   set value(newVal) {
     this.__value = newVal * 2
   }
 }
console.log( Object.assign(doubleTheFun, { value: 2 }).value );

| |

QC 10.4 答案

A1:

4

摘要

在本节课中,你学习了如何使用 Object.assign 以及为什么你会这样做。

  • Object.assign 将多个对象的值属性分配到基础对象上。

  • Object.assign 可以用来填充默认值。

  • Object.assign 可以用来向对象添加新属性。

  • Object.assign 返回被修改的对象,因此使用 {} 作为第一个参数将创建一个副本。

  • Object.assign 只分配属性;它不会重新定义它们。

让我们看看你是否掌握了:

Q10.1

在本节课中,你创建了一个基础太空船并使几个其他太空船从它继承而来。你甚至制作了一个从多个其他太空船继承而来的隐形轰炸机。想出另一系列相互继承的对象,例如客用车辆、动物王国、视频游戏角色,或者任何你能想到的东西。

第 11 课。解构

阅读完第 11 课后,你将

  • 了解如何解构对象

  • 了解如何解构数组

  • 了解如何组合数组和对象解构

  • 了解可以解构的类型

JavaScript,像其他编程语言一样,有对象和数组这样的数据结构,允许你将数据结构化成逻辑组,并将它们视为单一的数据块。JavaScript 一直支持结构化的概念,这是一种简洁的语法,用于将多个数据项组合成一个数据结构。但故事只讲了一半:JavaScript 一直缺少相反的语法,即如何将现有的数据结构解构回构成它的各个部分。

考虑这一点

在下面的代码中,有两个部分。第一部分构建了一个数据结构,第二部分将其拆解。你可能已经想到了使用对象字面量清理数据结构创建的方法,但你是如何清理拆解过程的呢?

// Creating data structures without structuring
let potus = new Object();
potus.name = new Object();
potus.name.first = 'Barack';
potus.name.last = 'Obama';
potus.address = new Object();
potus.address.street = '1600 Pennsylvania Ave NW';
potus.address.region = 'Washington, DC';
potus.address.zipcode = '20500';

// Breaking down data structures without destructuring
let firstName = potus.name.first;
let lastName = potus.name.last;
let street = potus.address.street;
let region = potus.address.region;
let zipcode = potus.address.zipcode;

11.1. 解构对象

想象一下,如果不使用对象字面量编写应用程序会有多繁琐。你可能认为你会在做这样的事情之前放弃编写 JavaScript。但你在分解对象时一直在做同样繁琐的过程,一旦你看到以与结构相同的方式解构对象是多么方便,你就永远不会想回头。让我们从一个简单的例子开始:

let person = {                     *1*
  name: 'Christina'
}

let { name } = person;             *2*

console.log(name);                 *3*
  • 1 创建数据结构

  • 2 解构数据结构

  • 3 Christina

在这个例子中,你使用对象字面量以结构化的方式创建了一个对象。通过这样做,你能够以简洁的方式指定在结构上设置的属性。后来,你使用解构语句解构了该数据结构。你指定了想要提取的 name 属性到变量中。

在这个简单的例子中,好处并不那么显著,但就像往常一样,用例越复杂,解构的实用性就越明显:

let person = {
  name: 'Christina',
  age: 25
}

let { name, age } = person;

console.log(name, age);            *1*
  • 1 “Christina” 25

当你这样解构一个对象时,你指定了你要从中提取的字段名称。具有相同名称的变量将被分配相应的值。ES5 的等效代码如下:

let name = person.name;
let age = person.age;

但如果你想要使用不同的名字呢?

let firstName = person.name;
let yearsOld = person.age;

在解构时,你可以指定不同的名称,如下所示:

let { name: firstName, age: yearsOld } = person;

这将创建变量 firstNameyearsOld,并将它们分别赋值为 person.nameperson.age。这很好,因为它正好是你构建数据结构方式的相反:

let person = { name: firstName, age: yearsOld };

你可以进行嵌套解构,深入数据结构以提取不同层次的数据:

let geolocation = {
  "location": {
    "lat": 51.0,
    "lng": -0.1
  },
  "accuracy": 1200.4
}

let { location: {lat, lng}, accuracy } = geolocation;

console.log(lat); // 51.0
console.log(lng); // -0.1
console.log(accuracy); // 1200.4
console.log(location); // undefined

使用语法 key: {otherKeys},你指定你正在深入到指定的 key 中以从中提取 otherKeys。在这里,你指定了你想从 location 属性中提取 latlng 属性。注意,这里没有创建一个 location 变量:你只是深入到了它。

快速检查 11.1

Q1:

你将如何重写之前的解构语句,将 lng 属性分配给一个名为 lon 的变量?

| |

QC 11.1 答案

A1:

let {location: {lat, lng: lon}, accuracy} = geolocation;

11.2. 数组的解构

在上一节中,你学习了对象解构的语法是对象结构的语法的逆。同样,数组解构的语法是数组结构的语法的逆。参见 图 11.1。

图 11.1. 结构化和解构语法

let coords = [51.0, -0.1];

let [lat, lng] = coords;

console.log(lat);                   *1*
console.log(lng);                   *2*
  • 1 51.0

  • 2 -0.1

因为对象通过键或名称跟踪它们的值,所以它们必须通过相同的键/名称提取。由于数组通过它们的位置索引跟踪它们的值,所以在解构数组时,变量的位置就是指定你正在解构的方式是有意义的。

就像解构对象一样,你也可以对数组进行嵌套解构:

let location = ['Atlanta', [33.7490, 84.3880]];

let [ place, [lat, lng] ] = location;

console.log(place); // "Atlanta"
console.log(lat); // 33.7490
console.log(lng); // 84.3880

我非常喜欢的一个惯用法是使用数组解构从数组中获取第一个值:

let [firstValue] = myArray;

// vs

let firstValue = myArray[0];

在关于 let 的课程中,你反转了两个变量之间的值,在这个过程中,你需要创建一个第三个临时变量来防止反转前值丢失:

if (stop < start) {
  // reverse values
  let tmp = start;
  start = stop;
  stop = tmp;
}

你可以通过使用解构来达到同样的效果,而不需要临时变量:

if (stop < start) {
  // reverse values
  [start, stop] = [stop, start];
}

快速检查 11.2

Q1:

如果上一个例子中的数组以相反的顺序存储 lat/lng 值,你该如何提取它们到正确的变量中?

| |

QC 11.2 答案

A1:

由于数组解构与索引相关,而不是名称,你可以在解构语句中简单地颠倒名称的顺序:

let location = ['Atlanta', [84.3880, 33.7490]];
let [ place, [lng, lat] ] = location;

11.3. 组合数组和对象解构

你可以像创建数据结构时一样组合对象和数组的解构语句:

let geoResults = {
  coords: [51.0, -0.1],
  ...
}

let { coords: [ latitude, longitude ] } = geoResults;

console.log(latitude, longitude)            *1*
  • 1 51.0 -0.1

这也是一种嵌套解构的形式,因为你正在使用对象解构来深入到数组解构中。

还要注意,在这个例子中,你在解构赋值中使用了coords这个名字,但你并没有创建一个名为coords的变量。你只创建了latitudelongitude变量。使用coords这个名字只是为了深入并指定你正在解构的数组。你可以把coords看作是一个分支,而latitudelongitude则是解构赋值中的叶子。分支不会创建变量;只有叶子会。考虑以下代码:

let product = {
  name: 'Whiskey Glass',
  details: {
    price: 18.99,
    description: 'Enjoy your whiskey in this glass'
  },
  images: {
    primary: '/images/main.jpg',
    others: [
      '/images/1.jpg',
      '/images/2.jpg'
    ]
  }
}

let {
  name,
  details: { price, description},
  images: {
    primary,
    others: [secondary, tertiary]
  }
} = product;

console.log(name);                 *1*
console.log(price);                *2*
console.log(description);          *3*
console.log(primary);              *4*
console.log(secondary);            *5*
console.log(tertiary);             *6*

console.log(details);              *7*
console.log(images);               *7*
console.log(others);               *7*
  • 1 威士忌杯

  • 2 18.99

  • 3 在这个杯子里享受你的威士忌。

  • 4 /images/main.jpg

  • 5 /images/1.jpg

  • 6 /images/2.jpg

  • 7 未定义

在前面的解构赋值中,如果你标记了哪些值作为分支和哪些作为叶子,你就会知道哪些变量被创建了。参见图 11.2。

图 11.2. 解构产品数组

11.4. 可以解构的类型

你可以在任何对象上使用对象解构,甚至包括数组。但由于数字不是有效的变量名,你需要在解构赋值中给它们重命名:

const { 0:a, 1:b, length } = ['foo', 'bar']

console.log(a)               *1*
console.log(b)               *2*
console.log(length)          *3*
  • 1 foo

  • 2 bar

  • 3 2

使用数组解构更为严格,但它可以用于比数组多得多的对象。任何实现了可迭代协议的对象(我们将在后面的单元中介绍)都可以使用数组解构。一个可迭代对象的例子是Set,我们将在本书的后面部分介绍。另一个例子是String

const [first, second, last] = 'abc'

console.log(first)                      *1*
console.log(second)                     *2*
console.log(last)                       *3*
  • 1 a

  • 2 b

  • 3 c

在本书的后面部分,当你创建自己的可迭代对象时,你也将能够对它们使用数组解构!

摘要

在本课中,你学习了解构的机制以及为什么它是一项如此有用的技术。

  • 解构是获取数据结构值的语法糖。

  • 解构是对象/数组字面量提供结构化语法的逆补。

  • 使用对象解构时,你通过属性名指定值。

  • 使用数组解构时,你通过它们的索引指定值。

  • 解构可以像数据结构一样组合和嵌套。

  • 当嵌套解构时,只有叶子(而不是分支)会被检索。

让我们看看你是否理解了:

Q11.1

现在,你应该能够将预热练习中的代码转换成一个单一的解构语句:

let firstName = potus.name.first;
let lastName = potus.name.last;
let street = potus.address.street;
let region = potus.address.region;
let zipcode = potus.address.zipcode;

此外,尝试将此代码转换成一个单一的解构语句:

let firstProductName = products[0].name
let firstProductPrice = products[0].price
let firstProductFirstImage = products[0].images[0]

let secondProductName = products[1].name
let secondProductPrice = products[1].price
let secondProductFirstImage = products[1].images[0]

第 12 课. 新的对象字面量语法

在阅读第 12 课之后,你将

  • 了解如何使用简写属性名

  • 了解如何使用简写方法名

  • 了解如何使用计算属性名

我认为在 JavaScript 中没有什么比对象字面量更普遍的了。它们无处不在。由于这个工具被频繁使用,任何便利性都可以对生产力产生巨大的净正面影响。ES2015 中引入的对象字面量的三个语法添加使它们读起来和写起来更加愉快。您不会获得新的功能,但拥有易于阅读和编写的代码,尤其是在维护方面,同样是一个重要的特性。

考虑这一点

看看以下对象字面量。哪些部分看起来是多余的?如果您正在编写一个 JavaScript 感知的字符串压缩引擎,您认为您可以在仍然能够重建原始对象的情况下安全地删除哪些部分?

const redundant = {
  name: name,
  address: address,
  getStreet: function() { /* ... */ },
  getZip:    function() { /* ... */ },
  getCity:   function() { /* ... */ },
  getState:  function() { /* ... */ },
  getName:   function() { /* ... */ },
}

12.1. 简写属性名

在 ES6 之前,我无数次不可避免地创建了一个对象字面量,它有一个键或属性,其名称与我正在分配给它的变量相同:

const message = { text: text }           *1*
  • 1 将属性 text 分配给同名的变量 text

我一直认为这看起来很笨拙,多亏了 ES6 简写属性名,这已经成为过去式。之前的对象字面量现在可以简洁地写成

const message = { text }                 *1*
  • 1 将属性 text 分配给同名的变量 text

当与解构结合使用时,这也非常出色:

function incrementCount(amount) {
  let { count } = stateManager.getState()                *1*
  count += amount                                        *2*
  stateManager.update({ count })                         *3*
}
  • 1getState返回的对象中提取 count

  • 2 更新 count 的值

  • 3 将一个名为 count 的属性设置为新的 count 的对象字面量传递

使用简写属性名,如果属性的名称与您要分配给它的变量的名称相同,您只需列出一次名称。这意味着原本需要写成{ count: count }的对象字面量现在可以写成{ count },并且会评估为相同的结果(参见图 12.1)。

图 12.1. 使用简写属性名

注意,现在您在用{ count }获取 count,然后用{ count }传递它之间有了对称性。我个人认为这种对称性非常优雅。

简写属性可以与对象字面量中的任何其他属性或方法混合使用。在下一个列表中,简写长写对象字面量是等效的。

列表 12.1. 混合简写长写对象字面量
const lat = 33.762909;
const lng = -84.422675;
const accuracy = 1200.4;

const shorthand = {
  name: 'Atlanta',
  accuracy,
  location: {
    lat,
    lng
  }
};

const longhand = {
  name: 'Atlanta',
  accuracy: accuracy,
  location: {
    lat: lat,
    lng: lng
  }
}

简洁的属性名消除了具有匹配键/值名称的对象字面量的冗余。您知道对象字面量中还有什么冗余吗?方法。

快速检查 12.1

Q1:

将以下对象字面量重写为使用简写属性名。

let foo, bar, bizz, bax = {
  foo: foo, bar: bar,
  biz: bizz
}

QC 12.1 答案

A1:

let foo, bar, bizz, bax = {
  foo, bar, biz: bizz
}

12.2. 简写方法名

假设你的团队正在开发一个教小学生数学的游戏。游戏必须跟踪游戏会话中的多个状态(例如玩家的姓名、当前级别、正确/错误答案的数量、已提出的问题等)。你可能从一个简单的状态管理器开始,如下所示:

function createStateManager() {
  const state = {};
  return {
    update: function(changes) {
      Object.assign(state, changes);
    },
    getState: function() {
      return Object.assign({}, state);
    }
  }
}

在这里使用单词 function 是多余的。因为函数始终有一个参数列表 (),并且由于括号在变量或属性名称中无效,它们的存在应该足以表示它是一个方法。简写方法名称允许你做到这一点。

这就像简写属性名称消除了输入 : redundant name 的需要,并且 { count: count } 变成了 { count }。简写方法名称消除了输入 : redundant function 的需要,并且 { update: function(){} } 被缩短为 { update(){} }。参见 图 12.2。

图 12.2. 简写方法名称消除了冗余语法。

考虑到这一点,你可以更新你的 createStateManager 以使用简写方法名称:

function createStateManager() {
  const state = {};
  return {
    update(changes) {
      Object.assign(state, changes);
    },
    getState() {
      return Object.assign({}, state);
    }
  }
}

重要的是要理解简写方法评估为匿名函数,而不是命名函数。这意味着你无法在函数内部通过名称引用该函数:

const fibonacci = {
  at(n) {
    if (n <= 2) return 1;
    return at(n - 1) + at(n - 2);
  }
}

fibonacci.at(7)                 *1*
  • 1 ReferenceError: at is not defined

在下一个列表中,withShorthandFunction 对象字面量评估(或被简化)类似于 withAnonymousFunction 对象,而不是 withNamedFunction 对象。

列表 12.2. 简写方法是匿名函数
const withShorthandFunction = {
  fib() {
    // ...
  }
}
const withNamedFunction = {
  fib: function fib() {
    // ...
  }
}
const withAnonymousFunction = {
  fib: function() {
    // ...
  }
}

这实际上只在函数自我引用时很重要,这意味着函数正在对自己进行引用,就像递归一样。因此,使用简写语法定义 at 方法将不起作用。你可能想使用 this.at 来解决这个问题,如列表 12.3 所示,只要函数始终在 this.at 将解析回函数的上下文中调用,它就会起作用。如果函数被分离或以不同的名称附加,它将不再起作用。

列表 12.3. 使用 this.at 的递归函数
const fibonacci = {
  at(n) {
    if (n <= 2) return 1;
    return this.at(n - 1) + this.at(n - 2);
  }
}
const { at } = fibonacci;
const fib = { at };
const nacci = { find: at };

fibonacci.at(7);                      *1*
at(7);                                *2*
fib.at(7);                            *3*
nacci.find(7);                        *4*
  • 1 13

  • 2 抛出 ReferenceError

  • 3 13

  • 4 抛出 ReferenceError

最后,如果你需要自我引用,不要使用简写方法名称。

简洁的属性和方法使对象字面量更加简洁。在下一节中,你将使用计算属性名称移除更多的冗余。

快速检查 12.2

Q1:

将以下对象字面量重写为使用简写方法名称:

const dtslogger = {
  log: function(msg) {
    console.log(new Date(), msg);
  }
}

| |

QC 12.2 答案

A1:

const dtslogger = {
  log(msg) {
    console.log(new Date(), msg);
  }
}

12.3. 计算属性名称

假设你正在使用你的状态管理器,并需要使其与另一个库一起工作,该库将提供你需要存储在状态中的值。问题是这个库没有以你可以传递给状态管理器update函数的对象形式提供键/值对。相反,这个其他库以数组的形式提供键/值对,其中第一个索引是键,第二个索引是值。为了使这两个库能够很好地一起工作,你编写了一个中间函数setValue,该函数接受一个名称和一个值作为参数,并在内部将它们转换成一个对象,然后将其传递给stateManager.update

function setValue(name, value) {
  const changes = {};
  changes[name] = value;
  stateManager.update(changes);
}

const [name, value] = otherLibrary.operate();
setValue(name, value);

注意在setValue函数中,你必须首先创建一个空对象,然后再定义属性。这是因为属性的名称是动态的:如果我们做了类似{ name: value }的事情,那么属性名称实际上将是值"name",而不是name变量包含的值。这类似于changes.name = value,这又会将属性名称设置为name。相反,你使用changes[name]来使用name变量中包含的实际值。

你用来在对象创建后添加动态命名的属性的语法现在也可以用来在对象字面量中计算属性名称。这种语法简洁,似乎比你的原始代码更好地说明了它在做什么:

function setValue(name, value) {
  stateManager.update({
    [name]: value
  });
}

const [name, value] = otherLibrary.operate();
setValue(name, value);

这与在对象创建后使用方括号的效果完全相同,并允许你使用相同的语法与对象字面量一起使用。

快速检查 12.3

Q1:

将会在控制台输出什么?

 const property = 'color';
 const eyes = { [property]: 'green' };
console.log(eyes.property, eyes.color);

| |

QC 12.3 答案

A1:

未定义的“绿色”

摘要

在本课中,你学习了如何利用对象字面量语法的扩展。

  • 简洁的属性名称在键和值具有相同名称时可以去除冗余。

  • 简洁的方法为在对象字面量上定义函数提供了一个更简单的语法。

  • 简洁的方法是匿名的,不应用于递归。

  • 计算属性允许你在对象字面量中使用动态属性名称。

让我们看看你是否理解了:

Q12.1

使用简写的方法名称来扩展你的状态管理器,以支持订阅和取消订阅状态变化。

第 13 课。符号——一个新的原始类型

在阅读第 13 课之后,你将

  • 了解如何使用符号作为常量

  • 了解如何使用符号作为对象键

  • 了解如何使用全局符号创建行为钩子

  • 了解如何使用已知的符号修改对象行为

在 JavaScript 中,有对象原始数据类型。JavaScript 的原始数据类型包括字符串、数字、布尔值(true 或 false)、null 和 undefined。Symbol是 ES2015 中添加的新原始数据类型,也是自 JavaScript 创建以来首次添加的原始数据类型。Symbol 是一个独特的值,用于挂钩到内置 JavaScript 对象的行为。Symbols 可以分为三个类别:

  1. 唯一符号

  2. 全局符号

  3. 已知符号

考虑这一点

想象一下,如果你正在编写一个棋盘游戏。你编写了一个带有moves()函数的基本棋子,该函数确定可用的目的地,然后通过检查目的地是否被队友棋子占用来检查每个目的地是否合法。每种棋子的代码都需要从基本棋子代码继承。你将如何使每个棋子覆盖moves()函数确定可能目的地的行为,而不覆盖它确定移动是否合法的行为?

13.1. 使用符号作为常量

开发者经常创建常量来表示标志。标志是一个特殊变量,只有一个目的:识别。标志的值并不重要,重要的是标志被识别。

许多库使用标志;例如,Google Maps 使用标志HYBRIDROADMAPSATELLITETERRAIN来识别要绘制的地图类型。每个标志的值是其名称的小写字符串版本(例如HYBRID === "hybrid"),但这并不重要,只要标志可以被识别。

在列表 13.1 中,你使用常量作为标志来识别放置工具提示的位置。但名称是通用的,很容易与其他值冲突。由于标志的唯一目的是被识别,因此保护标志免受误识别是有意义的。想象一下,程序的另一部分有CORRECTINCORRECT标志,分别包含rightwrong值。这意味着CORRECT标志可能会被误识别为RIGHT标志,因为它们都包含相同的内部值。

一些库,如 Redux,通过多个处理程序发送动作。每个处理程序通过使用标志和标识符来识别它负责的动作。不同作者编写的不同插件可以指定动作的处理程序。这增加了命名冲突的可能性。

列表 13.1. 使用常量作为标志
const answer = {
  CORRECT   : 'right',
  INCORRECT : 'wrong'
}

const positions = {
  TOP    : 'top',
  BOTTOM : 'bottom',
  LEFT   : 'left',
  RIGHT  : 'right'
}

function addToolTip(content, position) {
  switch(position) {
    case positions.TOP:
      // add content above
    break;
    case positions.BOTTOM:
      // add content below
    break;
    case positions.RIGHT:                                      *1*
      // add content to right
    break;
    case positions.LEFT:
      // add content to left
    break;
    default:
      throw new Error(`${position} is not a valid position`)
    break;
  }
}

addToolTip('You are not logged in', answer.CORRECT);           *2*
  • 1 这将匹配 answer.CORRECT。

  • 2 这将使工具提示定位到右侧。

注意到addToolTip函数期望从positions接收一个标志,但你传递了一个来自answer的标志。理想情况下,这应该抛出一个错误来通知你的错误,但它没有。它只是将工具提示定位到右侧,因为你的标志不是唯一的。

你可以使用唯一的符号来解决这些命名冲突。通过将可选的字符串描述作为参数调用 Symbol() 函数来创建一个唯一的符号。每个唯一的符号总是唯一的,如下所示:

Symbol() === Symbol()                          *1*
Symbol('right') === Symbol('right')            *1*
  • 1 false

当你使用 Symbol 函数创建一个符号时,创建的符号总是唯一的。你可以多次使用相同的描述调用 Symbol 函数,每次都会得到一个唯一的符号。这是因为每次调用 Symbol 函数时,你都会在内存中创建一个新的值,这个值永远不会等于内存中其他地方的另一个符号。

相同的字符串,例如 "foo",在内存中只会存储一次。无论你创建多少次新的字符串 "foo",原始值总是指向内存中的同一个位置。这就是为什么 "foo" == "foo" 总是成立。对于所有原始值都是如此,这就是为什么原始值可以相互比较,无论它们来自何方。另一方面,对象总是在内存中创建自己的标识,因此看起来相同的两个对象不会相等,即 { foo: 1 } != { foo: 1 }

每次调用 Symbol() 时,你都在内存中创建一个新的值,因此不可能发生意外的冲突。现在你知道从 Symbol() 函数创建的符号永远不会冲突,你可以重写之前的工具提示示例,利用符号并捕获之前的错误:

const positions = {
  TOP    : Symbol('top'),
  BOTTOM : Symbol('bottom'),
  LEFT   : Symbol('left'),
  RIGHT  : Symbol('right')
}

这也有确保在调用函数时使用的是常量而不是值的优点,因为传递 Symbol('top') 作为位置参数实际上不会匹配任何标志!这将使重构你的常量更容易,因为你知道值不能直接用作字符串。

快速检查 13.1

Q1:

在以下代码片段中,从 ae 的值中,哪些是 true

const x = Symbol('x')
const y = Symbol('y')

const a = x === x;
const b = x === y;
const c = x === Symbol('x');
const d = y === y;
const e = y === Symbol('y');

| |

QC 13.1 答案

A1:

只有 ad

13.2. 使用符号作为对象键

有时开发者会在对象属性前添加一个前导下划线,以表示它们应该被视为伪私有,例如 Store._internals。这个问题在于(除非使用 Object.defineProperty 指定),这些值仍然会被枚举,这意味着它们仍然会被包含在 for...in 语句和 JSON.stringify() 等操作中,这可能不是所希望的。你可以使用符号作为属性名来达到相同的伪私有属性效果:

const Store = {
  [Symbol('_internals')]: { /* ... */ }          *1*
}
  • 1 要将符号用作属性名,它必须是一个计算属性名

你必须使用 计算属性名 来添加符号作为属性名。当使用符号作为属性名时,该属性是不可枚举的,这意味着它不会包含在 for...in 语句或 JSON.stringify() 中,甚至不会在 Object.getOwnPropertyNames() 中返回。此外,由于你没有任何符号的引用,并且调用 Symbol('hidden') 再次将返回不同的符号,因此该属性本质上就是私有的。

然而,这并不是真正的私有,因为 ES6 中的 Object 有一个新的函数 Object.getOwnPropertySymbols(),它返回一个包含所有自身属性(符号)的数组。使用这个函数,你可以获取符号的引用并访问该属性。

如果作为属性名的符号并非真正私有,并且由于它们可以通过闭包实现私有,那么这有什么意义呢?设置符号属性并不是为了从安全角度实现私有,而更多的是提供一个可用但隐藏的 API。这种既不完全私有也不完全公开的 API 是定义修改对象或 属性的绝佳地方。

快速检查 13.2

Q1:

这段代码有什么问题?

const DataLayer = {
  Symbol.for('fetch'): function() {
    // fetch from database.
  }
}

| |

QC 13.2 答案

A1:

符号属性需要是一个计算属性:

const DataLayer = {
  [Symbol.for('fetch')]: function() {
     // fetch from database.
  }
}

13.3. 使用全局符号创建行为钩子

全局符号是添加到注册表中的符号,可以从任何地方全局访问。你通过调用 Symbol.for 访问全局符号:

const sym = Symbol.for('my symbol');

当你调用 Symbol.for 时,它首先检查注册表以查看是否存在给定名称的符号;如果存在,则返回。如果尚不存在符号,则创建它,将其添加到注册表,然后返回。由于全局符号是全局的,所以不言而喻,从 Symbol.for() 获取的符号与从 Symbol() 获取的符号不同。全局符号非常适合将预定义的钩子设置到对象的行为中。

在 第 10 课 中,你创建了一个用于创建基础太空船的工厂函数。然后你创建了其他工厂函数来创建更专业的太空船。在那节课中,你只是向基础对象添加了新的方法,例如 bomb,如下所示。

列表 13.2. 来自 第 10 课 的 createBomberSpaceShip 函数
function createBomberSpaceShip() {
  return enhancedSpaceShip({
    bomb: function() {
      // ... make the ship drop a bomb
    }
  });
}

但在真正的游戏中,你很可能有一种让玩家开火的方式,比如按空格键。提供一个 fire() 方法,让增强型太空船可以挂钩并改变飞船开火时发生的事情,会更有意义。这样,当用户按下空格键时,代码可以调用 fire(),而不必担心它是什么类型的飞船,以及是否应该调用 shoot()bomb() 或其他方法。这种做法可能看起来像这样:

const baseSpaceShip = {
  [Symbol.for('Spaceship.weapon')]: {
    fire() {
      // the default shooting implementation
    }
  },
  fire: function() {
    if (this.hasAmmo()) {
      const weapon = this[Symbol.for('Spaceship.weapon')];
      weapon.fire();
      this.decrementAmmo();
    }
  },

  // other methods omitted
}

增强型轰炸机太空船可以如此实现:

const bomberSpaceShip = Object.assign({}, baseSpaceShip, {
  [Symbol.for('Spaceship.weapon')]: {
    fire() {
      // drop a bomb
    }
  }
});

你可以使用像 getWeapon() 这样的方法,这种方法并不糟糕,但会向飞船添加一个额外的公共方法,而这个方法并不打算被使用(只是被覆盖)。如果有许多这样的钩子,它可能会使公共飞船 API 变得臃肿且难以维护。

如果你使用符号来定义这些钩子,它们比 getWeapon() 方法更不易访问,但也不是完全私有的,因此它们仍然可以被覆盖。这是一个完美的平衡。

到目前为止,你一直在钩入你定义的对象的行为,但如果你想要钩入内置对象的行为怎么办?

快速检查 13.3

Q1:

以下代码有什么问题?

const lazer = Symbol.for('Spaceship.weapon')
const lazerSpaceShip = Object.assign({}, baseSpaceShip, {
  lazer: {
    fire() {
      // shoot a lazer
    }
  }
});

| |

QC 13.3 答案

A1:

符号属性必须是一个计算属性,如下所示:

const lazer = Symbol.for('Spaceship.weapon')
const lazerSpaceShip = Object.assign({}, baseSpaceShip, {
  [lazer]: {
    fire() {
      // shoot a lazer
    }
  }
});

13.4. 使用已知符号修改对象行为

正如你在上一节中使用符号钩入飞船的行为一样,内置的 JavaScript 对象也通过 已知符号 提供了几个钩入其功能的方法。一个已知符号是直接附加到 Symbol 的内置符号,例如 Symbol.toPrimitive

Symbol.toPrimitive 允许钩入对象并控制它如何被强制转换为原始值。一个简单的实现可以是

const age = {
  number: 32,
  string: 'thirty-two',
  [Symbol.toPrimitive]: function() {
    return this.string;
  }
};
console.log(`${myObject}`)           *1*
  • 1 三十二

使用简写方法语法来整理这个值,因为它的值是一个函数:

const age = {
  number: 32,
  string: 'thirty-two',
  [Symbol.toPrimitive]() {
    return this.string;
  }
};
console.log(`${myObject}`)           *1*
  • 1 三十二

在这个特定的例子中,你正在将对象强制转换为字符串,但当你想要在对象被强制转换为数字时做不同的事情。Symbol.toPrimitive 函数传递一个参数,暗示在这种情况下它被强制转换为哪种原始类型。可能的值是 stringnumberdefault。我们可以更新我们的示例以利用这一点,如下一列表所示。

列表 13.3. 使用已知符号
const age = {
  number: 32,
  string: 'thirty-two',
  Symbol.toPrimitive {
    switch(hint) {
      case 'string':
        return this.string;
      break;
      case 'number':
        return this.number;
      break;
      default:
        return `${this.string}(${this.number})`;
      break;
    }
  }
};

console.log(`
  I am ${age} years old, but by the time you
  read this I will be at least ${+age + 1}
`);                                          *1*
console.log( age + '' );                     *2*
console.log( age.toString() );               *3*
  • 1 我今年 32 岁,但当你读到这篇文章时,我至少会 33 岁。

  • 2 三十二(32)

  • 3 [object Object]

在 列表 13.3 中,当 age 对象被强制转换为原始值时,将默认为 thirty-two(32),但当被强制转换为字符串或数字时,将分别返回 thirty-two32。但调用 toString() 不是一个强制转换,因此不受影响。

有几个已知的符号。涵盖所有这些超出了本书的范围。我们将在迭代单元中介绍 Symbol.iterator,你可以在这里找到所有已知符号的列表:mng.bz/5838

快速检查 13.4

Q1:

创建一个具有 born 属性的对象,该属性是一个日期。使用 Symbol.toPrimitive 在强制转换为数字时根据出生日期计算年龄。

| |

QC 13.4 答案

A1:

const Person = {
  name: 'JD',
  born: new Date(1984, 1, 11),
  Symbol.toPrimitive {
    const age = new Date().getFullYear() - this.born.getFullYear();
    switch(hint) {
      case 'number':
        return age;
      break;
      default:
        return `${this.name}, ${age}`;
      break;
    }
  }
};

13.5. 符号注意事项

符号是原始类型,但它们是唯一没有字面形式的原始类型。因此,它们必须通过调用 Symbol() 函数来创建。其他原始类型,如数字、字符串和布尔值(但不包括 null 或 undefined),也可以以相同的方式创建;例如,Number(1)String('foo')Boolean(true)。这些相同的原始类型函数也可以用 new 调用,例如 new Number(1),但这不会返回原始值;它返回一个原始包装器,一个包含原始值的对象。围绕符号的原始包装器通常是不希望的,因此为了防止意外创建一个,如果 Symbol()new 的方式调用,例如 new Symbol(),将会抛出错误。

为了防止意外将符号强制转换为字符串或数字,存在相应的保护措施。这样做会抛出错误,如下一列表中所示。

列表 13.4. 这里每一行都会抛出错误
new Symbol()
`${Symbol()}`
Symbol() + ''
+Symbol()
+Symbol.iterator
Symbol.for('foo') + 'bar'

在本课中,你学习了如何创建唯一常量,这些常量可以防止命名冲突。然后你学习了如何使用符号通过全局符号创建对对象行为的自定义钩子。最后,你学习了如何使用已知符号为内置行为钩子。

摘要

在本课中,你学习了符号的基础知识。

  • 唯一符号保证是唯一的,因为它们在内存中都具有唯一的身份。

  • 唯一符号是防止命名冲突的好方法。

  • 全局符号会自动存储和检索自全局注册表。

  • 全局符号可用于提供对自定义对象的钩子。

  • 已知符号存在是为了提供对内置对象的钩子。

  • 不应使用 new 运算符来创建符号。

  • 不应将符号强制转换为其他原始类型。

让我们看看你是否掌握了这个:

Q13.1

使用 Symbol.toPrimitive 创建一个对象,当强制转换为字符串时,会序列化为 URI 查询字符串。

第 14 课:综合练习:模拟锁和键

在这个综合练习中,你将构建一个锁和键系统。每个锁都将有其独特的密钥。锁将持有一些秘密数据,并且只有在使用正确的密钥访问时才会返回数据。然后你将构建一个游戏,其中生成三个锁,并给玩家一个密钥和一个解锁奖品的机会!

14.1. 创建锁和键系统

你将从设计锁 API 开始。你希望有一个名为 lock 的函数,它接受一个参数,即你要锁定的数据。然后它应该返回一个独特的密钥(密钥即锁的钥匙)和一个 unlock 函数,如果用正确的密钥调用,则会揭示秘密。所以高级 API 基本上是 { key, unlock } = lock(secret)

secret 可以是任何单个数据项。lockunlock 都是函数,但你需要确定 key 将会是哪种数据类型。

一个好的解决方案是随机生成的字符串。但字符串并不完全独特;它们可能重叠,甚至可预测。你可以使用一个难以猜测或很少重叠的复杂字符串,如 SHA-1,但这需要大量的努力或需要安装库。你已经有一个易于生成且保证唯一的 数据类型,即 Symbol。所以你会使用它:

function lock(secret) {
  const key = Symbol('key')

  return {
    key, unlock(keyUsed) {
      if (keyUsed === key) {
        return secret
      } else {
        // do something else
      }
    }
  }
}

这里有一个 lock 函数。当被调用时,它会接收一个 secret 并生成一个新的 key,这是一个独特的符号。记住,以这种方式创建的每个符号都将始终是唯一的,并且永远不会重叠。然后,锁函数返回一个包含生成的密钥和一个 unlock 函数的对象。unlock 函数接受一个 keyUsed 参数,并将用于解锁秘密的密钥与正确的密钥进行比较。如果它们相同,它就返回秘密:

const { key, unlock } = lock('JavaScript is Awesome!')
unlock(key)                                              *1*
  • 1 JavaScript 真棒!

你还需要弄清楚如果使用了错误的密钥会怎样。在现实世界的应用程序中,如果你使用了错误的密钥,你可能会抛出一个错误。但为了这个练习的目的,只需将其值隐藏起来。你可以使用你在 课程 6 中学到的 String.prototype.repeat 方法来返回字符串的隐藏副本。类似于

'*'.repeat( secret.length || secret.toString().length )

这里是更新后的函数。

列表 14.1. 检测错误的密钥
function lock(secret) {
  const key = Symbol('key')

  return {
    key, unlock(keyUsed) {
      if (keyUsed === key) {
        return secret
      } else {
        return '*'.repeat( secret.length || secret.toString().length )
      }
    }
  }
}

const { key, unlock } = lock(42)

unlock()                    *1*
unlock(Symbol('key'))       *1*
unlock('key')               *1*
unlock(key)                 *2*
  • **1 ****

  • 2 42

太棒了!你已经完成了你的锁和钥匙系统,它工作得很好!最好的是,你用很少的代码和不需要外部库就做到了。现在你可以创建多个锁和多个钥匙,每个锁只能通过其关联的钥匙访问。如果你创建了多个锁和钥匙并将它们混合在一起会怎样?

14.2. 创建选择门游戏

如果你创建了三个锁,并以门 #1、门 #2 和门 #3 的形式向玩家展示,并给玩家一个单独的钥匙,你可以要求玩家猜测他们的钥匙对应哪扇门。如果他们猜对了,他们就能赢得门后的奖品。希望这听起来像是一个有趣的游戏,因为这正是你要构建的。由于这个游戏中的锁将是门,秘密可以是 门后的东西。这可能看起来像是一个熟悉的游戏,但转折在于玩家可以选择任何门,但只有当他们的钥匙能打开那扇门时,他们才能发现门后的秘密并赢得奖品。准备好了吗?让我们看看如何构建它。

首先,构建你的游戏主用户界面,如图 列表 14.2 所示。你需要一种方式向玩家展示一些选项,并让玩家选择一个选项。为了简单起见,使用 prompt 来完成这个任务。

列表 14.2. 游戏主界面
function choose(message, options, secondAttempt) {
  const opts = options.map(function(option, index) {                      *1*
    return `Type ${index+1} for: ${option}`
  })

  const resp = Number( prompt(`${message}\n\n${opts.join('\n')}`) ) - 1   *2*

  if ( options[resp] ) {
    return resp                                                           *3*
  } else if (!secondAttempt) {
    return choose(`Last try!\n${message}`, options, true)                 *4*
  } else {
    throw Error('No selection')                                           *5*
  }
}
  • 1 构建你在提示中显示的多项选择题选项。将每个索引加 1,以便选项从 1 开始而不是 0。

  • 2 然后获取用户在提示中输入的数字。你需要减去之前加上的那个 1。

  • 3 如果你从提示中得到了适当的响应,返回该值。

  • 4 如果你没有得到有效的值,进行第二次尝试。

  • 5 如果你在第二次尝试后仍然没有得到有效的值,抛出一个错误。

这个 choose 函数接受一个消息和一个选项数组。然后它处理所有连接,以便玩家可以看到消息和选项,并被告知输入与每个选项相对应的数字以进行选择。如果收到不正确的输入,玩家将最后一次被询问,如果玩家仍然没有选择有效的选项,将抛出一个错误。一旦选定了有效的选项,choose 函数将返回该选项的索引。通过在这个 choose 函数中处理所有这些细节,你可以专注于游戏其他地方的消息和选项。你可以像下一个列表和图 14.1 中所示那样使用 choose 函数。

图 14.1. 基本多项选择题

图片

列表 14.3. 构建一个多项选择题
const message = 'Who is the greatest superhero of all time?'
const options = ['Superman', 'Batman', 'Iron Man', 'Captain America']

const hero = choose(message, options)

现在你有了一种让用户选择门的方法,你需要生成三扇门(锁)并给玩家随机分配一个钥匙。首先生成带有奖品的门。你认为你应该怎么做?你可能尝试这样做:

const { key1, door1 } = lock('A new car')
const { key2, door2 } = lock('A trip to Hawaii')
const { key3, door3 } = lock('$100 Dollars')

然而,这样做是不行的。记住,你必须使用该对象上正确的属性名称来解构对象。从 lock 返回的对象只有 keyunlock 属性,并且必须这样解构。这可能会让你尝试这样做:

const { key, unlock } = lock('A new car')
const { key, unlock } = lock('A trip to Hawaii')
const { key, unlock } = lock('$100 Dollars')

然而,这同样是不行的。在第一次解构后,常量 keyunlock 已经被占用,不能再声明,这在第 2 和第 3 行尝试时发生。但并非一切都已失去:记住,当你学习解构属性名称时,有一个特殊的语法可以将它们分配给不同的变量名。这正是你现在需要做的:

const { key:key1, unlock:door1 } = lock('A new car')
const { key:key2, unlock:door2 } = lock('A trip to Hawaii')
const { key:key3, unlock:door3 } = lock('$100 Dollars')

现在你有三个钥匙和三扇门。所以把它们各自放入一个数组中,随机抽取一个钥匙给用户:

const keys = [key1, key2, key3]
const doors = [door1, door2, door3]

const key = keys[Math.floor(Math.random() * 3)]

足够简单。现在创建你将向玩家展示的消息和选项:

const message = 'You have been given a \u{1F511} please choose a door.'  *1*

const options = doors.map(function(door, index) {
  return `Door #${index+1}: ${door()}`
})
  • 1 \u{1F511} 是 Unicode 键字符。

注意你如何使用 \u{1F511} 来转义你的 Unicode 字符。你完全可以像这样直接将实际字符放入消息中:

const message = 'You have been given a  please choose a door.'

但这会让读者输入变得非常困难。顺便说一句,这也提出了另一个新特性。在 ES2015 之前,Unicode 转义语法的语法是 \uXXXX,其中 XXXX 是 Unicode 十六进制代码。但它只允许最多四个十六进制字符(16 位),但请注意你的字符使用了五个:1F511。为了在 ES2015 之前解决这个问题,你必须使用所谓的“代理对”,它使用两个较小的 Unicode 值来生成一个较大的值。为了获取你的键,你可以使用 \uD83D\uDD11。代理对的概念或如何生成它们超出了本书的范围,但 ES2015 引入了一种更简单的方法,即 \u{XXXXX},它允许你转义任何大小的 Unicode 字符。

现在你有了你的信息和选项,你可以将它们传递给你的 choose 函数,该函数将要求用户选择一个门。参见图 14.2。一旦他们选择了一个门,他们就可以尝试用他们的钥匙打开它。如果钥匙正确,他们将得到门后的秘密;否则,他们将得到加密文本。无论如何,你都会将响应警报回用户:

const door = doors[ choose(message, options) ]
alert( door(key) )
图 14.2. 选择门游戏动作

图片 14.2

将所有这些组合在一个函数调用 init:中,如下一个列表所示。

列表 14.4. 组合选择门功能
function init() {
  const { key:key1, unlock:door1 } = lock('A new car')
  const { key:key2, unlock:door2 } = lock('A trip to Hawaii')
  const { key:key3, unlock:door3 } = lock('$100 Dollars')

  const keys = [key1, key2, key3]
  const doors = [door1, door2, door3]

  const key = keys[Math.floor(Math.random() * 3)]

  const message = 'You have been given a \u{1F511} please choose a door.'

  const options = doors.map(function(door, index) {
    return `Door #${index+1}: ${door()}`
  })

  const door = doors[ choose(message, options) ]

  alert( door(key) )
}

现在如果用户选择了正确的门,他们将看到门后的东西,赢得奖品。你能想到这个锁和钥匙系统还有什么其他用途吗?也许是一个多人游戏,隐藏宝箱和它们的钥匙散布在整个游戏中。如果你从一个锁中取出钥匙,并将其锁入另一个锁中呢?那可能会很有趣。

摘要

在这个综合练习中,你使用符号模拟了一个锁和一个钥匙。当你创建一个锁时,你会得到一个唯一的符号作为钥匙。因为符号总是唯一的,且不会存在冲突风险,所以没有正确的钥匙(符号)是无法打开锁的。然后你使用这个锁和钥匙创建了一个“选择门”游戏。

单元 3. 函数

函数是编写应用程序的一个相当基本的构造。这在像 JavaScript 这样的语言中尤其如此,因为 JavaScript 将函数视为一等公民。随着 ES2015 及以后的版本,许多令人惊叹的功能被添加到函数中,包括几种全新的函数类型。

我们将从这个单元的开始,通过查看默认参数和剩余参数。我认为大多数程序员在某个时候都曾需要默认参数,并且可能通过在函数开始时检查值并在undefined时分配一个值来解决这个问题。剩余参数甚至更有用。任何以前使用过arguments对象的人都会很高兴使用剩余参数。不仅剩余参数使arguments对象变得过时,它还允许你将其与其他参数结合使用,而不仅仅是像arguments对象那样总是充当一个通用的容器。

然后,我们将深入研究解构函数参数,这也可以与默认参数结合使用。当你把这些都放在一起时,你可以创建一些相当强大的函数声明。你甚至可以模拟命名参数。所以,不再需要传递null值给像myFunc(null, null, 5)这样的函数,因为你只需要指定第三个参数(或者第四个、第五个,以此类推)。

在查看所有可以用现有函数完成的新功能之后,我们将探讨两种新的函数类型:箭头函数生成器函数。箭头函数在 JavaScript 中非常有用,一旦它们成为你的技能库的一部分,你将发现自己每天都在使用它们。另一方面,生成器函数可能不是你经常需要使用的,但在需要时它们是一个强大的新工具。

你将通过创建一个模拟运行器来结束本单元的学习,该运行器在囚徒困境场景中将囚犯模拟相互对抗。

第 15 课. 默认参数和剩余参数

在阅读了第 15 课之后,你将

  • 了解如何使用默认参数

  • 了解如何使用剩余操作符收集参数

  • 了解如何使用剩余参数在函数之间传递参数

有时新的语言特性提供了实现以前不可能或几乎不可能实现的事情的方法。其他时候,它们只是提供了一种更优雅的实现已经容易实现的事情的方式。但仅仅因为某件事容易实现或需要很少的代码行数来完成,并不意味着它就一定易于阅读。这正是默认函数参数剩余参数所做的:它们提供了一种更简洁、更易于阅读的实现方式。

考虑这一点

通过查看pluck函数的实现,你能快速判断它在做什么吗?那第一行——它在做什么?这一行需要太多的思考。在其当前状态下,它可能需要一条注释来解释它的作用。

function pluck(object) {
  const props = Array.from(arguments).slice(1);
  return props.map(function(property) {
    return object[property];
  });
}
const [ name, desc, price ] =
pluck(product, 'name', 'description', 'price');

15.1. 默认参数

让我们假设你正在构建一个网站,该网站显示现任总统在其任期内的工作满意度。你想要构建一个图表函数,以折线图的形式显示这些信息。你决定 800x400 是图表的好大小,但你希望允许设置自定义大小。例如,800x400 的图表可能位于顶部,显示全国满意度,然后通过只有 400x200 的图表按州细分。你可能会编写一个像这样的函数:

function approvalsChart(ratings, width, height) {
  if (!width) {
    width = 800
  }
  if (!height) {
    height = width / 2
  }
  // build the chart with ratings
}

const nationalChart = approvalsChart(nationalRatings)             *1*
const georgiaChart = approvalsChart(georgiaRatings, 400)          *2*
  • 1 图表大小为 800x400

  • 2 图表大小为 400x200

你现在可以使用如下默认函数参数实现相同的效果:

function approvalsChart(ratings, width = 800, height = width / 2) {
  // build the chart with ratings
}

const nationalChart = approvalsChart(nationalRatings)           *1*
const georgiaChart = approvalsChart(georgiaRatings, 400)        *2*
  • 1 图表大小为 800x400

  • 2 图表大小为 400x200

注意你如何在参数列表中直接使用表达式 height = width / 2。实际上,你可以在默认参数的赋值中使用任何表达式,并且该表达式将在函数评估时被评估。你可以使用作用域内的任何变量,包括前面的参数。表达式的上下文始终与函数调用的上下文相同,这意味着任何 this 的使用都将与函数体内相同。

列表 15.1. 在默认参数中访问 this
const ajax = {
  host: 'localhost',
  load(url = this.host + '/data.json') {
    // load data from url.
  }
}

ajax.load();                                   *1*
ajax.host = 'www.example.com';
ajax.load();                                   *2*
ajax.load('localhost/moredata.json');          *3*
  • 1 网址是 localhost/data.json

  • 2 网址是 www.example.com/data.json

  • 3 网址是 localhost/moredata.json

当你第一次调用 ajax.load() 而不带参数时,默认选中 this.host,其值为 localhost,而 url 参数变为 localhost/data.json。然后你更改了 host 属性,所以当你再次调用该函数时,默认再次选中 this.host,此时为 www.example.com,因此 url 参数变为 www.example.com/data.json。最后,当你调用 ajax.load('localhost/moredata.json') 时,没有使用默认值,url 参数被设置为 localhost/moredata.json

参数的默认值与其在参数列表中的索引相关联。你可能想使用默认值来使可选参数跟随必选参数,就像我们在范围示例中做的那样。如果传递了两个值,那么你希望它们分别是 minmax,但如果只传递了一个值,你希望它是 max,而 min 默认为 0。虽然这听起来很棒,但默认参数的工作方式并非如此,如下一列表所示。

列表 15.2. 必选参数必须位于可选参数之前
function range(min = 0, max) {
  // ... create a range from min to max
}
range(5, 10);              *1*
range(10);                 *2*
  • 1 分钟将是 5,最大值将是 10。

  • 2 最小值将是 10,最大值将是未定义。

因为默认参数与其索引相关联,除非它是最后一个参数,否则你不能使用它们来使参数可选。传递给函数的参数按其索引分配,所以无论如何,第一个值都将分配给 min,第二个值将分配给 max。唯一能够设置 max 而使用默认的 min 的方法是通过调用 range(undefined, 10),因为这保持了每个参数的正确索引。第一个参数是 undefined,因此成为默认值 0;第二个参数是 10

利用默认参数中的表达式功能,你可以做一些巧妙的事情来检查参数的长度,并条件性地将 min 参数设置为正确的值,如下一清单所示。

清单 15.3. 伪造默认参数(不推荐!)
function range(temp = 0, max = temp, min = arguments.length > 1 ? temp : 0)
           {
  // ... create a range from min to max
}

在这个 range 函数中,你首先定义了一个默认值为 0temp 参数,然后是一个默认值为 temp 值的 max,最后定义了一个检查 arguments 长度并条件性地默认为 0 或 tempmin 参数。这之所以可行,是因为以下条件:

  • 使用 10 调用:

    • temp 被设置为 10。

    • max 默认为 temp,所以 max 为 10。

    • arguments.length 不大于 1,所以 min 被设置为 0。

    • 影响:min 为 0,max 为 10。

  • 使用 5, 10 调用:

    • temp 被设置为 5。

    • max 被设置为 10。

    • arguments.length 大于 1,所以 min 被设置为 temp(5)。

    • 影响:min 为 5,max 为 10。

  • 使用 5, 5 调用:

    • temp 被设置为 5。

    • max 被设置为 5。

    • arguments.length 大于 1,所以 min 被设置为 temp(5)。

    • 影响:min 为 5,max 为 5。

尽管这样可行,但我建议不要使用这种代码,因为它会掩盖正在发生的事情,浪费了一个参数,并且如果函数的使用者开始用所有三个参数调用它,可能会导致错误。这只是一个练习,用来展示可能性。

注意在 清单 15.3 中,你如何在参数列表中使用 arguments 对象来计算默认值。记住,用于默认参数的表达式是在函数体上下文中调用的!这意味着你可以使用函数体中声明的其他变量吗?实际上不行:记住在 第 4 课 我们讨论了临时死区。函数体中的其他变量在技术上处于作用域内,但它们处于临时死区,还不能被访问。

快速检查 15.1

Q1:

在以下函数调用中,args 参数的值将是什么?

 function processArgs(args = Array.from(args)) {
   // ...
 }
processArgs(1, 2, 3);

QC 15.1 答案

A1:

它将是 1。记住你调用它时传了一个值。所以即使默认值从参数对象创建了一个数组,这也没有关系:由于传入了值,默认值不会被计算。如果你想创建一个参数的数组,请使用剩余操作符。

通常,默认参数允许你在未提供值的情况下使用合理的默认值。这使得函数更有用,因为回退到默认值通常比抛出错误要好(取决于场景)。但是,你还可以用默认参数做更多的事情。在下一节中,我们将探讨使用默认参数来防止重新计算数据。

15.2. 使用默认参数以避免重新计算值

记住,在本课的开始,你创建了一个 approvalsChart 函数来计算批准评分。你将更进一步,创建一个用于计算评分和生成图表的库。第一次尝试可能如下所示。

列表 15.4. 两次调用 getRatings
const chartManager = {
  getRatings() {
    // some intensive task to calculate all the ratings
  },

  nationalRatings() {
    const ratings = this.getRatings()
    return approvalsChart(ratings)
  },

  stateRatings(state) {
    const ratings = this.getRatings()
    stateRatings = ratings.filter(function(rating) {
      rating.state === state
    })
    return approvalsChart(ratings, 400)
  },

  stateAndNationalRatings(state) {
    const nationalChart = this.nationalRatings()            *1*
    const stateChart = this.stateRatings(state)             *2*
    return {
      nationalChart,
      stateChart
    }
  }
}

const charts = stateAndNationalRatings('georgia')
  • 1 计算评分

  • 2 再次计算评分

注意它最终调用了 getRatings 方法两次?如果这个方法很耗费资源,那么避免浪费第二次计算是有意义的。你可以通过以下列表中的默认参数来解决。

列表 15.5. 只调用一次 getRatings
const chartManager = {
  getRatings() {
    // some intensive task to calculate all the ratings
  },

  nationalRatings(ratings = this.getRatings()) {
    return approvalsChart(ratings)
  },

  stateRatings(state, ratings = this.getRatings()) {
    stateRatings = ratings.filter(function(rating) {
      rating.state === state
    })
    return approvalsChart(ratings, 400)
  },

  stateAndNationalRatings(state) {
    const ratings = this.getRatings()
    const nationalChart = this.nationalRatings(ratings)
    const stateChart = this.stateRatings(state, ratings)
    return {
      nationalChart,
      stateChart
    }
  }
}

const charts = stateAndNationalRatings('georgia')

现在方法 nationalRatingsstateRatingsgetRatings 作为默认参数调用。这意味着每个函数都可以独立调用并计算评分。但如果你想同时获取国家和州的评分,你可以预先计算评分并将数据传递给每个方法,从而避免额外的计算步骤。

默认参数是处理预期参数的绝佳方式,确保它们有值。它们对处理意外(或不确定)的参数帮助不大,但你可以使用新的 剩余 操作符来做到这一点。

快速检查 15.2

Q1:

在以下代码片段中,getC() 将返回什么?

function getC(a = 'b', b = c, c = a) {
  return c;
}
getC();

| |

QC 15.2 答案

A1:

无,它将是一个语法错误,因为 b 的默认值是 c,但 c 是在 b 之后声明的 之后

15.3. 使用剩余操作符收集参数

在 第 9 课 中,你学习了如何使用 Array.fromarguments 对象中获取数组。虽然这很有用,但还有更方便的方法可以将参数作为数组获取,即使用剩余参数:

function avg() {                             *1*
  const args = Array.from(arguments);
  //...
}

function avg(...args) {                      *2*
  //...
}
  • 1 使用 Array.from 将参数收集到数组中

  • 2 使用剩余参数收集参数

rest 的工作方式是通过声明一个以三个点开始的函数参数。这被称为 rest 参数,尽管参数本身的名称可以是任何有效的变量名。这将把从 rest 参数位置开始的所有剩余参数组合成一个数组。你也可以只有一个 rest 参数,并且它必须是列表中的最后一个参数:

function countKids(...allMyChildren) {
  return `You have ${allMyChildren.length} children!`;
}

countKids('Talan', 'Jonathan');                      *1*

function family(spouse, ...kids) {
  return `You are married to ${spouse} with ${kids.length} kids`;
}

family('Christina', 'Talan', 'Jonathan');            *2*
  • 1 你有两个孩子!

  • 2 你和克里斯蒂娜结婚,有两个孩子。

这个名称来源于首先指定你想放入单独变量的参数,然后指定你想将剩余参数组合在一起的参数(参见以下列表)。一些语言,如 Ruby,将这个概念称为 splat,但这很可能是由于这些语言使用星号而不是三个点作为操作符,而星号类似于 splat 的形状。

列表 15.6. 在 JavaScript 中,如果在 rest 之后放置 params 会发生错误
function restInMiddle(a, ...b, c) {           *1*
  return [a, b, c];
}
  • 1 语法错误:rest 参数必须是最后一个

rest 参数必须在参数列表的末尾,否则会抛出语法错误。你可能认为这是 rest 参数工作的唯一逻辑方式,但其他语言,例如 CoffeeScript,例如,允许在 rest 参数之后指定参数,如下面的列表所示。

列表 15.7. CoffeeScript 中的 rest 之后放置 params
restInMiddle = (a, b..., c) -> [a, b, c];
restInMiddle(1,2,3,4,5);                      *1*
  • 1 [ 1, [2, 3, 4], 5];

现在你已经知道了 rest 的用法,让我们看看使用 rest 重新实现的 priming 函数:

function pluck(object, ...props) {
  return props.map(function(property) {
    return object[property];
  });
}

乍一看,这不是更易于阅读吗?它不再需要注释来解释一行晦涩的代码。

快速检查 15.3

Q1:

实现以下 cssClass 函数,使其通过将第一个参数添加到其余参数中生成 CSS 类列表。

cssClass('button', 'danger', 'medium');          *1*
  • 1 应返回“button button-danger button-medium”

QC 13.3 答案

A1:

function cssClass (primary, ...others) {
  const names = others.reduce(function(list, other) {
    return list.concat(`${primary}-${other}`);
  }, [primary]);
  return names.join(' ');
}

15.4. 使用 rest 在函数之间传递参数

想象一下你正在使用一个进行图像处理的库,但你需要挂钩到处理函数以添加一些日志记录。许多库提供中间件^([1]) 来注入这样的代码,但并非所有库都这样做;在这种情况下,猴子补丁 将不得不使用。猴子补丁是指重新定义一个函数以在调用原始函数之前注入一些自定义逻辑的过程。在 列表 15.8 中,你对一个虚构的 imageDoctor 库的处理函数进行猴子补丁,并使用 rest 收集所有参数并将它们传递给原始函数。这确保了原始函数始终以与包装函数相同的参数调用。

¹

中间件是由库作者提供的自定义钩子,允许注入自定义行为。一些提供中间件的 JavaScript 库有 express.js (expressjs.com/) 和 redux.js (redux.js.org/)。另请参阅 en.wikipedia.org/wiki/Middleware

列表 15.8:在 monkey-patching 时使用 rest 传递参数
{
  const originalProcess = imageDoctor.process;                *1*
  imageDoctor.process = function(...args) {                   *2*
    console.log('imageDoctor processing', args);              *3*
    return originalProcess.apply(imageDoctor, args);          *4*
  }
}
  • 1 首先获取原始方法的引用。

  • 2 定义一个新的函数来收集所有参数。

  • 3 注入你的日志。

  • 4 返回原始函数调用 args 的结果。

当我们在本书的后面部分介绍类时,你将学习关于子类扩展超类的内容。当一个子类扩展超类时,它可以覆盖超类上的方法。被覆盖的方法可以在覆盖它的方法中调用,并且 rest 可以再次用来处理两个方法之间的参数传递。

快速检查 15.4

Q1:

假设你正在使用一个名为 ajax 的函数来加载数据。使用 rest 来 monkey-patch 这个函数以记录所有其参数。

QC 15.4 答案

A1:

{
  const originalAjax = ajax;
  ajax = function(...args) {
    console.log('ajax invoked with', args);
    return originalAjax.apply(null, args);
  }

概述

在本课中,你学习了如何使用默认参数和 rest。

  • 默认参数允许设置合理的默认值。

  • 默认参数可以使用表达式来计算。

  • 默认参数表达式在函数体内部执行。

  • 默认参数与其在参数列表中的索引相关联。

  • Rest 参数将所有剩余的参数收集为一个数组。

  • Rest 参数可以用来收集所有参数为一个数组。

  • Rest 参数必须是最后一个参数。

  • 每个参数列表中只能有一个 rest 参数。

让我们看看你是否掌握了:

Q15.1

创建一个名为 car 的函数,允许你创建一个 car 对象。该函数应接受一个参数,表示可用的座位数,并具有默认值。car 对象应有一个 board 方法,该方法接受一个驾驶员和任意数量的乘客(使用 rest)。然后 board 方法应记录驾驶员是谁,哪些乘客可以乘坐(根据座位数),并列出无法乘坐的乘客。

第 16 课:解构参数

在阅读完第 16 课(lesson 16)后,你将

  • 了解如何解构数组参数

  • 了解如何解构对象参数

  • 了解如何模拟命名参数

  • 了解如何创建别名参数

在第 11 课(lesson 11)中,你学习了如何解构对象和数组。这些相同的原理可以直接应用于函数的参数列表中。这使得数组和对象参数更容易处理,并且更具自文档性,并为诸如模拟命名参数等有用的技术打开了大门。

考虑这一点

以下函数接受三个参数。你将如何使所有参数都成为可选的,以便函数可以只设置必要的值?例如,如果你只想设置高度并使用默认宽度怎么办?函数的调用者如何指定只想指定高度?通过名称?如果名称错误怎么办?例如,调用者可能使用 h 而不是完整的单词 height

function image(src, width, height) {
  // build image

}

16.1. 解构数组参数

想象你正在编写一个程序来比较两个文件之间的差异。你正在使用一个提供 diff 函数的库,该函数接受两个字符串并计算它们之间的差异。它返回一个包含三个值的数组。第一个值是已添加的文本。第二个是已删除的文本,最后第三个值是已修改的文本。

图 16.1. 确定哪些文本已被添加(绿色)、更改(黄色)和删除(红色)

你需要编写一个函数,它可以接受这些差异并渲染一个可视化,就像在 图 16.1 中显示的那样。让我们称这个函数为 visualize。如果它接受三个单独的参数——插入、删除和修改——你可以这样连接这两个函数:

function visualize(inserted, deleted, modified) {
  // ... render visualization to screen
}

const [ inserted, deleted, modified ] = diff(fileA, fileB);
visualize(inserted, deleted, modified);

这可行,但不得不从 diff 函数中提取值以便将它们传递给 visualize 函数,这很繁琐。一个更干净的方法是使 visualize 函数的输入与 diff 函数的输出相匹配。然后连接它们会简单得多:

visualize( diff(fileA, fileB) );

现在两个函数之间的连接要干净得多。我甚至可以说,代码现在更具自文档性,因为它现在读作 visualize diff,这正是你正在做的事情:可视化差异!但是为了使这成为可能,visualize 函数现在只接受一个参数,一个包含三个值的数组。所以你必须重新实现你的 visualize 函数来考虑这一点:

function visualize(diff) {
  const inserted = diff[0];
  const deleted = diff[1];
  const modified = diff[2];
  // ... render visualization to screen
}

到目前为止,你已经将繁琐的提取步骤从函数外部移到了函数内部。是的,当你连接 visualizediff 函数时,它现在看起来更干净,但这是以模糊化 visualize 函数的实现为代价的。

希望你已经想到了,你可以使用数组解构来清理它,如下所示:

function visualize(diff) {
  const [ inserted, deleted, modified ] = diff;          *1*
  // ... render visualization to screen
}
  • 1 将 diff 参数解构为所需的三个值。

如果是这样,你是对的!然而,你可以通过在参数列表中进行解构来移除一个步骤,使它更加干净:

function visualize([ inserted, deleted, modified ]) {
  // ... render visualization to screen
}

现在你已经成功消除了连接这两个函数之间的认知负担。你使代码更具自文档性,而且你做到了这一点,而无需在其他地方牺牲可读性。

如果 diff 函数返回一个具有 insertedmodifieddeleted 属性的对象而不是数组怎么办?在下一节中,你将更新你的可视化函数以处理这种情况。

快速检查 16.1

Q1:

假设你正在编写一个小部件库。你希望允许安装你的小部件的人能够设置小部件使用的颜色。实现以下 setColors 函数,以便解构颜色数组。你可以为每个三种颜色使用你喜欢的名称。

setColors([ '#4989F3', '#82D2E1', '#282C34']);

| |

QC 16.1 答案

A1:

function setColors([primary, secondary, attention]) {
  // ...
}

16.2. 解构对象参数

在前面的章节中,你使用了一个返回包含两个文件之间插入、删除和修改行的数组的 diff 函数。但如果你更新了你使用的 diff 库的版本,并且函数返回了一个具有插入、删除和修改属性的对象,你该如何更新你的 visualize 函数以正确地从新格式中提取数据?解决方案几乎完全相同:

function visualize({ inserted, deleted, modified }) {
  // ... render visualization to screen
}

visualize( diff(fileA, fileB) );

如你所见,你需要做的唯一改变是,不再用中括号 [] 括起参数列表,而是用花括号 {} 替换。不再通过在数组中的位置来获取值,而是现在通过属性的真正名称从对象中获取特定的属性。

在数组解构中,你可以使用你想要的任何名称,只要字段的顺序正确。在对象解构中,你可以以任何顺序列出属性,只要名称匹配正确。这就像常规的对象和数组解构一样,只是在函数的参数列表中发生。

快速检查 16.2

Q1:

setColors 函数重写为使用对象解构解构颜色。记住:这次不是顺序,而是名称必须对齐。

setColors({ primary: '#4989F3', danger: '#DB231C', success: '#61C084' });

| |

QC 16.2 答案

A1:

function setColors({ primary, success, danger }) {
  // ...
}

16.3. 模拟命名参数

让我们想象你正在编写一个构建汽车的函数。你决定你想让函数的调用者设置汽车的制造商、型号和年份,所以你为每个参数添加了一个:

function car(make, model, year) {
  // ... make the car
}

然而,你希望每个参数都是可选的,并且有一个默认值。你可能认为由于 JavaScript 现在支持默认值,它将解决你的问题。但如果调用者只想设置年份(第三个参数)怎么办?使用默认参数,前两个必须设置或明确传递为 undefined

function car(make = 'Ford', model = 'Mustang', year = 2017) {
  // ... make the car
}

let classic = car(undefined, undefined, 1965);

这并不理想。你可以在参数列表中使用对象解构来模拟命名参数(通过名称而不是位置设置参数的能力):

function car({ make, model, year }) {
  // ... make the car
}

let classic = car({ year: 1965 });

现在它看起来和表现就像你正在使用命名参数一样,但实际上你只是在使用一个对象的单个参数,如图 16.2 所示。

图 16.2. 模拟命名参数

你仍然需要想出一个方法为每个设置默认值。由于这只有一个参数,你可以将这个参数(整个对象)默认为一个预设了所有值的对象:

function car({ make, model, year } = {make:'Ford',model:'mustang',year:2017}
           ) {
  // ... make the car
}

但这只会在没有传递任何对象的情况下工作。通过传递一个如 { year: 1965 } 的对象,即使它缺少一些所需的键,参数仍然被传递。记住,实际上你只处理一个参数,所以默认值没有被设置:

function car({ make, model, year } = {make:'Ford',model:'mustang',year:2017}
           ) {
  // ... make the car
}

let modern = car();                          *1*
let classic = car({ year: 1965 });           *2*
  • 1 没有传递参数,所以默认为设置所有值的对象

  • 2 传递了参数,所以没有使用默认值,也没有得到制造商或型号。

有一个更好的方法。实际上,你可以解构对象,并为每个单独的键独立地提供一个默认值:

function car({ make = 'Ford', model = 'Mustang', year = 2017 }) {
  // ... make the car
}

let classic = car({ year: 1965 });             *1*
  • 1 年份被设置,而制造商和型号则回退到默认值。

现在你正在为每个你正在解构的单独键设置默认值,你可以成功设置你想要更改的值,而其他值则回退到默认值。唯一剩下的问题是当你完全没有任何参数调用函数时:

function car({ make = 'Ford', model = 'Mustang', year = 2017 }) {
  // ... make the car
}

let modern = car();          *1*
  • 1 这里抛出错误是因为你尝试解构一个缺失的对象。

按照现状,你仍然需要用一个空对象如 car({}) 调用函数,即使你不想更改任何默认值。这是因为如果你没有传递一个对象,参数将是 undefined,你将无法在 undefined 值上执行对象解构。为了解决这个问题,你可以继续将每个键默认为特定值,但也将整个参数默认为一个空对象:

function car({ make = 'Ford', model = 'Mustang', year = 2017 } = {}) {
  // ... make the car
}

let modern = car();                          *1*
let classic = car({ year: 1965 });           *2*
  • 1 所有值都回退到默认值。

  • 2 年份被设置,而制造商和型号则回退到默认值。

现在如果你完全没有任何参数调用函数,它默认为一个空对象,由于空对象缺少制造商、型号和年份,它们都仍然回退到它们的默认值。当然,如果你传递了一个对象,任何缺失的键都会被设置为它们各自的默认值。

快速检查 16.3

Q1:

创建一个分页函数,该函数模拟 currentPageresultsPerPage 的命名参数,默认值分别为 1 和 24。

| |

QC 16.3 答案

A1:

function pagination({ currentPage = 1, resultsPerPage = 24 } = {}) {
  // ...
}

16.4. 创建别名参数

让我们想象你有一个名为setCoordinates的函数,它接受一个具有纬度和经度属性的对象。问题是你在使用两个不同的映射库:一个用于渲染地图,另一个用于反向地理查找。一个使用latlon属性,而另一个使用latlng属性。正因为如此,你希望你的函数足够智能,可以处理这两种情况。你可能倾向于这样定义你的函数:

function setCoordinates(coords) {
  let lat = coords.lat;
  let lng = coords.lng || coords.lon;           *1*
  // ... use lat and lng
}
  • 1 将 coords.lng 或 coords.lon( whichever exists)分配给您的变量。

你也可以通过参数解构和默认值的组合来做这件事:

function setCoordinates({ lat, lon, lng = lon }) {
  // ... use lat and lng
}

这里你使用解构直接获取latlonlng。但你将lng默认为lon;这意味着无论给你哪一个,你都能将其捕获为lng

如果lng默认为lon以创建别名,那么如果你实际上想为lng设置一个真正的默认值,会怎样呢?嗯,由于lng默认为lon,你只需简单地将默认值添加到lon中:

function setCoordinates({ lat = 33.7490, lon = -84.3880, lng = lon }) {
  // ... use lat and lng
}

现在您的函数接受lonlng,就像之前一样,但这次它还默认到一个特定的位置(亚特兰大)。这样工作的方式是,如果设置了lng,那么lon设置成什么并不重要;你忽略它。如果lng没有设置,它默认为lon。如果lon没有设置,它默认为-84.3880,这间接地也使得lng默认为-84.3880。

这种有用的技巧也可以通过使用默认值而不使用解构来使用:

function setCoordinates(lat = 33.7490, lon = -84.3880, lng = lon) {
  // ... use lat and lng
}

注意你没有使用{}:这意味着这个函数不像之前的那个函数那样接受一个对象作为参数,然后从中解构值。这次,你实际上接受了三个不同的参数,并以类似的方式为它们设置默认值。

快速检查 16.4

Q1:

使用相同的技巧来创建一个可以接受widthheight属性或wh属性(具有默认大小)的函数。

QC 16.4 答案

A1:

function setSize({ width = 50, height = width, w = width, h = height }) {
  // use w and h
}

摘要

在本课中,你学习了如何将解构技术应用于函数参数,包括如何

  • 更优雅地将一个函数的输出连接到另一个函数的输入。

  • 通过模拟命名参数,使所有参数都成为可选的。

  • 通过结合解构与默认值,使参数的名称具有别名。

让我们看看你是否理解了:

Q16.1

在下面的代码中,有三个函数。每个都与一个内部映射对象交互,更新不同的属性。将这些三个函数合并成一个名为updateMap的单个函数,该函数模拟了zoomboundscoords的命名参数。此外,创建一个别名,以便coords也可以通过命名的center来设置。

function setZoom(zoomLevel) {
  _privateMapObject.setZoom(zoomLevel);
}

function setCoordinates(coordinates) {
  _privateMapObject.setCenter(coordinates);
}

function setBounds(northEast, southWest) {
  _privateMapObject.setBounds([northEast, southWest]);
}

第 17 课. 箭头函数

在阅读了第 17 课之后,你将

  • 了解如何使用箭头函数使代码简洁

  • 了解如何使用箭头函数保持上下文

JavaScript 中的箭头函数直接受到 CoffeeScript 中的胖箭头函数的启发。它们的行为与 CoffeeScript 类似,通过提供一种更简洁的方式来编写函数表达式,并保持它们的上下文(this指的是什么)。语法并不总是与 CoffeeScript 相同,但它们同样有用,并且是一个很好的补充,使得匿名函数和内联回调变得更加优雅。

有时多余的语法会使代码对人类思维来说难以解析,因为需要考虑的额外部分很多。并不是说字符越少就越容易理解。例如,单字母变量名或过于聪明的 _ 代码高尔夫 _(1)解决方案很难阅读。然而,如果您能用更少的字符传达一个含义,那么它通常比用更多的字符更容易理解。人类大脑很难一次性处理大量信息,所以您能减少的噪音越多,效果越好。

¹

在这个游戏中,程序员编写尽可能少的字符的程序。

考虑这一点

这里有一些代码,它将一组数字映射到另一个集合,对每个数字应用指数。注意您是如何在传递给map的匿名函数中使用that=this来保持上下文的。特别是,map函数接受一个设置上下文的第二个参数,但许多函数不提供这种便利,让开发者不得不想出像这里使用的that=this这样的解决方案。您有多少次不得不编写这样的代码?如果有一种优雅的方式来保持外部上下文,那不是很好吗?

const exponential = {
  exponent: 5,
  calculate(...numbers) {
    const that = this;
    return numbers.map(function(n) {
      return Math.pow(n, that.exponent);
    });
  }
}
exponential.calculate(2, 5, 10); // ???

17.1. 使用箭头函数编写简洁的代码

箭头函数的语法取决于函数参数的数量或函数体中表达式的数量。最优雅的情况是当函数体中只有一个参数和一个表达式。我们刚才看到的double函数就是一个例子。它从功能上等同于其之前定义的函数表达式。其语法为singleParam => returnExpression。如果参数不是恰好一个(无论是零个、两个或更多),那么参数必须用括号括起来:

const add = (a, b) => a + b;               *1*
const rand = () => Math.random();          *2*
  • 1 多个参数需要用括号括起来。

  • 2 当没有参数时,必须使用空括号。

图 17.1. 箭头函数括号规则

如果需要,您也可以将单个参数用括号括起来(参见图 17.1)。还有一些其他情况下,如果参数是剩余参数或解构参数,您必须用括号将单个参数括起来:

const rest = (...args) => console.log(args);        *1*
const rest = ...args => console.log(args);          *2*
const destruct = ([ first ]) => first;              *3*
const destruct = [ first ] => first;                *4*
  • 1 正确的语法

  • 2 语法错误

  • 3 正确的语法

  • 4 语法错误

如果函数体中有多个表达式,则必须用花括号括起来:

const doTasks = (a, b) => {
  taskOne();                  *1*
  taskTwo();                  *2*
}
  • 1 第一个表达式

  • 2 第二个

这与if语句和for循环类似,其中花括号是可选的,只有当主体(花括号内的代码)是一个单独的表达式或语句时。如果有多个表达式或语句,则必须使用花括号:

if (true) {
  doFirstThing();
  doSecondThing();               *1*
}

if (true)
  dofirstThing();
  doSecondThing();               *2*

const doTasks = (a, b) => {
  taskOne();
  taskTwo();                     *3*
}

const doTasks = (a, b) =>
  taskOne();
  taskTwo();                     *4*
  • 1 这是 if 语句的一部分。

  • 2 这不是 if 语句的一部分。

  • 3 这是函数的一部分。

  • 4 这不是函数的一部分。

当省略花括号时,除了不需要输入额外的两个字符外,还有一个额外的好处:隐式地返回单个表达式。这种单个表达式箭头函数的隐式返回是优雅的,因为它读起来就像输入值指向返回值。例如,一个无操作函数^([2])看起来像x => x,或者一个将值包裹在数组中的函数看起来像x => [x]。看看它如何读作from => tostart => finishgive => get。这种简洁的语法认知负荷几乎为零,同时也很容易在心理上解析,并且具有自文档化的特性。

²

无操作,常用于允许挂钩到功能。见en.wikipedia.org/wiki/NOP

另一个箭头函数使代码极其简洁的场景是当你需要创建一个返回另一个函数的函数时。比如说,你想创建一个名为exponent的函数,它接受一个数字并返回另一个函数,该函数将对给定的基数应用指数:

const exponent = exp => base => base ** exp

const square   = exponent(2)
const cube     = exponent(3)
const powerOf4 = exponent(4)

square(5)             *1*
cube(5)               *2*
powerOf4(5)           *3*
  • 1 25

  • 2 125

  • 3 625

还要注意**运算符。这是 ES2016 中引入的一个新运算符,用于应用指数。之前,你需要使用Math.pow来实现相同的功能。

让我们看看这个exponent函数的 ES5 等价实现:

var exponent = function(exp) {
  return function(base) {
    return Math.pow(base, exp)
  }
}

我确实认为箭头函数版本更简单、更容易阅读。

在第 15 课中,你定义了一个pluck函数,该函数从对象中获取指定的值集。以下是原始的pluck函数,后面跟着使用箭头函数的版本:

function pluck(object, ...props) {
  return props.map(function(property) {
    return object[property];
  });
}

function pluck(object, ...props) {
  return props.map( prop => object[prop] );
}

箭头函数的版本不是读起来更顺口吗?像mapreducefilter等高阶函数非常适合箭头函数。当这些高阶函数需要在它们的包含上下文中执行时,这一点尤其正确。

快速检查 17.1

Q1:

将以下求和函数转换为使用箭头函数:

const sum = function(...args) {
  return args.reduce(function(a, b) {
    return a + b;
  });
}

| |

QC 17.1 答案

A1:

const sum = (...args) => args.reduce( (a, b) => a + b );

17.2. 使用箭头函数保持上下文

让我们进一步探讨上一节中的pluck函数。而不是让它作为参数获取要操作的对象,让我们假设一个Model对象,它是数据库记录的包装对象。pluck函数将是这个模型对象上的一个方法。如果你在没有任何上下文考虑的情况下实现它,那么在回调函数内部使用关键字this引用自身时,它就会出错:

const Model = {
  // ... other methods

  get(propName) {
    // return the value for `propName`
  }

  pluck(...props) {
    return props.map(function(prop) {
      return this.get(prop);                *1*
    });
  }
}
  • 1 关键字 this 不会指向模型对象。

我认为一个常见的约定相当丑陋,那就是首先声明一个名为that的变量并将其赋值给this,这样在回调函数内部,即使在失去上下文之后,你仍然可以访问变量that

// ...
  pluck(...props) {
    const that = this;                      *1*
    return props.map(function(prop) {
      return that.get(prop);                *2*
    });
  }
// ...
  • 1 获取回调可以使用的 this 引用。

  • 2 使用 that 引用

这种that equals this的猴子业务是可行的,但它并没有使代码更易于阅读。实际上,大多数像这样的高阶函数都有一个最终参数,允许设置调用上下文。一个更好的版本可能是这样的:

// ...
  pluck(...props) {
    return props.map(function(prop) {
      return this.get(prop);                *1*
    }, this);                               *2*
  }
// ...
  • 1 将上下文设置为 this(模型对象)

  • 2 this 关键字现在指向 Model 对象。

这已经比that equals this解决方案好多了,但你可以通过使用箭头函数来跳过整个上下文步骤。箭头函数的上下文直接绑定到其定义的上下文。这意味着箭头函数内部的this关键字始终与箭头函数外部相同。所以使用箭头函数的版本看起来会是这样:

// ...
  pluck(...props) {
    return props.map( prop => this.get(prop) );        *1*
  }
// ...
  • 1 this 关键字仍然指向模型对象。

为了好玩,比较一下它可能看起来像 JavaScript。接下来移除的功能:

// ...
  pluck: function() {
    var props = [].slice.call(arguments);
    return props.map(function(prop) {
      return this.get(prop);
    }, this);
  }
// ...

哇,这对即使是这种小方法的可读性都有很大的影响。想象一下,如果整个应用程序的可读性会提高多少,而你只是刚刚开始!

快速检查 17.2

Q1:

将预热练习中的指数方法重新实现为使用箭头函数。

| |

QC 17.2 答案

A1:

const exponential = {
  exponent: 5,
  calculate(...numbers) {
    return numbers.map( n => Math.pow(n, this.exponent) );
  }
}
exponential.calculate(2, 5, 10); // [32, 3125, 100000]

17.3. 箭头函数的注意事项

关于箭头函数的一个重要注意事项是,它们始终是函数表达式,而不是函数定义:

function double (number) {                 *1*
  return number * 2;
}

const double = function (number) {         *2*
  return number * 2;
}

const double = number => number * 2;       *3*
  • 1 函数定义

  • 2 函数表达式

  • 3 箭头函数(相当于函数表达式)

这意味着箭头函数不能像函数定义那样提升:

const ids = getIds()                *1*
const items = getItems()            *2*

function getIds() {
  //...
}

const getItems = () => {
  //...
}
  • 1 因为函数定义是提升的

  • 2 ReferenceError,因为常量在声明之前不能被访问

这会抛出一个引用错误,因为constlet变量在声明之前不能被访问。但是var变量可以在声明之前被访问,但总是undefined。所以即使你使用var创建了一个箭头函数,如果你在它声明之前尝试使用箭头函数,你仍然会得到一个错误,但现在它将是一个类型错误:

const ids = getIds()                 *1*
const items = getItems()             *2*

function getIds() {
  //...
}

var getItems = () => {
  //...
}
  • 1 因为函数定义是提升的

  • 2 类型错误:undefined 不是一个函数。

箭头函数还有一种常见的情况可能会让人困惑。这是当你尝试从箭头函数中隐式返回一个对象字面量时。你可能写成这样:

const getSize = () => { width: 50, height: 50 };            *1*
  • 1 语法错误

这实际上会是一个语法错误。每当箭头函数的=>后面跟着大括号时,这意味着那些大括号是函数体的开始和结束(无论有多少表达式)。为了解决这个问题,你可以将对象字面量包裹在括号中:

const getSize = () => ({ width: 50, height: 50 });          *1*
  • 1 按预期工作

最后,箭头函数不能通过bindcallapply改变其上下文。这意味着如果你使用一个尝试改变回调函数上下文的库,可能会导致错误。例如,jQuery通过使用callapply调用回调并设置上下文为给定的节点来改变传递给$.each()的函数的上下文,如下所示:

const $spans = $('span');
$spans.each(function() {
  const $span = $(this);                   *1*
  $span.text( $span.data('title') );
});
  • 1 jQuery 将其设置为指向单个 span DOM 节点。

jQuery 通过调用回调并使用callapply来设置上下文为给定的节点来实现这一点。如果你使用箭头函数,jQuery 将无法改变上下文:

const $spans = $('span');
$spans.each(function() {
  const $span = $(this);                 *1*
  $span.text( $span.data('title') );
});
  • 1 jQuery 无法设置此值,因此$(this)可能不起作用。

如果你能够避免这些陷阱,箭头函数将成为你开发者工具包中的强大新工具。

快速检查 17.3

Q1:

将会在控制台输出什么?

console.log(typeof myFunction);
var myFunction = () => {
  //...
}

QC 17.3 答案

A1:

undefined

概述

在本课中,你学习了箭头函数的语法和机制。

  • 箭头函数是编写函数的一种简洁方式。

  • 箭头函数在参数列表之后使用操作符=>,而不是在参数列表之前使用关键字function

  • 当函数体中只有一个表达式时,箭头函数的大括号是可选的。

  • 当省略大括号时,箭头函数隐式地return

  • 箭头函数的上下文(this)绑定到其定义的上下文中。

让我们看看你是否理解了:

Q17.1

这里有一个translator函数,当用国家代码调用时,它会返回一个模板字面量标记函数,该函数使用TRANSLATE函数将插值值翻译成对应语言。TRANSLATE函数只是一个模拟,用于测试translator函数是否工作。构建实际的翻译库超出了本书的范围。重新实现translator函数以使用箭头函数。总共应该使用三个箭头函数:

function TRANSLATE (str, lang) {
  // mock translator as building a real translator is beyond
  // the scope of this book.
  switch (lang) {
    case 'sv':
      return str.replace(/e/g, 'ë');
    break;
    case 'es':
      return str.replace(/n/g, 'ñ');
    break;
    case 'fr':
      return str.replace(/e/g, 'é');
    break;
  }
}

const translator = function (lang) {
  return function(strs, ...vals) {
    return strs.reduce(function(all, str) {
      return all + TRANSLATE(vals.shift(), lang) + str;
    });
  }
}

const fr = translator('fr');
const sv = translator('sv');
const es = translator('es');

const word = 'nice';

console.log( fr`${word}` ); // nicé
console.log( sv`${word}` ); // nicë
console.log( es`${word}` ); // ñice

第 18 课. 生成器函数

阅读完第 18 课后,你将

  • 了解如何定义生成器函数

  • 了解如何从生成器函数中产生值

  • 理解生成器函数的生命周期

生成器函数是 JavaScript 最近添加的较难理解的功能之一。它们引入了一种新的代码执行和处理形式,这是大多数 JavaScript 开发者以前没有见过的。尽管生成器不是新概念,但它们已经是 Python、C#和其他语言的一部分。本课仅旨在对该主题进行温和的介绍。

你将看到生成器在创建列表方面的良好表现,但这并不是生成器的全部用途。这就像说对象只用于存储键/值对一样。相反,生成器,就像对象一样,是一种功能强大的多功能工具,可以用于各种问题。例如,我们将在第 5 单元和第 7 单元中看到,它们在处理可迭代和异步任务方面是多么有用。然而,目前,你需要掌握基础知识。

考虑这一点

在函数内部,一旦你return一个值,函数的其余部分就不再处理,函数退出。这意味着return语句阻止函数继续执行。如果你可以返回一个值,但只在该点暂停函数而不是退出,然后稍后告诉函数从上次离开的地方继续,会怎么样呢?

18.1. 定义生成器函数

生成器函数是一种特殊类型的函数。它是一个返回新生成器对象的工厂。从生成器函数返回的每个生成器对象都根据其来源函数的主体行为。也就是说,生成器函数的主体定义了它返回的每个生成器的蓝图:

function* myGeneratorFunction() {                     *1*
  // ...
}

const myGenerator = myGeneratorFunction();            *2*
  • 1 生成器函数用星号表示。

  • 2 生成器函数调用时不使用 new。

为了表示一个函数是生成器函数,它用星号定义。星号可以像上面的例子那样紧挨着函数名,或者紧挨着关键字function,如function* ...。后者似乎越来越受欢迎,尽管我喜欢前者,因为它与对象字面量上的简洁生成器函数保持一致。最后,星号也可以在两边都有空格,如function * myFunction

const myObj = {
  * myGen() {                  *1*
    // ...
  }
}
  • 1 对象字面量上的简洁生成器方法

生成器函数内部的代码行为就像一个常规函数,尽管有一个巨大的前提:yielding。JavaScript 中有一个新关键字,仅用于生成器函数内部:yield。这个yield在函数内部和外部之间创建了一个双向通信通道。

每当遇到yield时,函数的执行会暂停,将控制权返回到调用函数的外部代码。yield还可以将值传递给外部进程,类似于return语句:

function* myGeneratorFunction() {
  // ...
  const message = 'Hello'
  yield message;                  *1*
  // ...
}
  • 1 执行将在这里暂停,并将值“Hello”返回给包含进程。

return语句不同,yield实际上是一个表达式,可以捕获一个值以供函数稍后使用:

function* myGeneratorFunction() {
  // ...
  const message = 'Hello'
  const response = yield message;          *1*
  // ...
}
  • 1 从包含进程捕获值

这个细节很重要,因为它在 JavaScript 中是独一无二的。当发生yield时,函数体不会像return返回值时那样短路,而是暂停,返回一个带有valuedone属性的对象,然后等待包含进程告诉它继续。此时,包含进程可能会传递一个值回来,你在这里捕获它作为响应。这是生成器函数创建的双向通信通道。见图 18.1。

如果你感到困惑,那没关系:在真正开始理解它们之前,你需要从函数内部和外部进程两个方面理解生成器的工作方式。到目前为止,我们只讨论了内部行为,这只有在与外部行为对比时才有意义。我们将在下一节中讨论这一点。

图 18.1. 双向通信通道

快速检查 18.1

Q1:

哪个生成器函数声明使用了正确的语法?

function* a() { /* ... */ }
function * b() { /* ... */ }
function *c() { /* ... */ }

QC 18.1 答案

A1:

它们都是有效的。

18.2. 使用生成器函数

当你调用一个生成器函数时,你会得到一个新的生成器对象。这个生成器对象开始处于暂停状态,直到你调用它上的next(),这会指示它开始执行生成器函数体内的代码:

function* myGeneratorFunction() {
  console.log('This code is now running');
}

myGeneratorFunction();                           *1*

const myGenerator = myGeneratorFunction();       *2*
myGenerator.next();                              *3*
  • 1 这里的日志没有被评估。

  • 2 这里的日志也没有被评估。

  • 3 现在才评估日志。

如果生成器函数体遇到yield,它将再次暂停,并且不会继续,直到你在生成器对象上再次调用next()

function* myGeneratorFunction() {
  console.log('A');
  yield;
  console.log('B');
}

const myGenerator = myGeneratorFunction();
myGenerator.next();                          *1*
myGenerator.next();                          *2*
  • 1 现在发生第一条日志,然后生成器再次在yield处暂停。

  • 2 现在发生第二条日志。

每次调用 next() 时,你都会得到一个包含两个属性的对象:一个包含任何已产生值的 value 属性和一个指示生成器是否暂停或完成的 done 属性。请参阅以下列表和 图 18.2。

列表 18.1. 生成器函数的实际应用
function* myGeneratorFunction() {
  console.log('Started')
  let recievedA = yield 'a';
  console.log('recievedA:', recievedA);
  let recievedB = yield 'b';
  console.log('recievedB:', recievedB);
}

const myGenerator = myGeneratorFunction();

let gotA = myGenerator.next(0);            *1*
console.log('gotA:', gotA);                *2*
let gotB = myGenerator.next(1);            *3*
console.log('gotB:', gotB);                *4*
let gotC = myGenerator.next(2);            *5*
console.log('gotC:', gotC);                *6*
  • 1 Started

  • 2 gotA: { value: “a”, done: false }

  • 3 receivedA: 1

  • 4 gotB: { value: “b”, done: false }

  • 5 receivedB: 2

  • 6 gotC { value: undefined, done: true }

图 18.2. 生成器函数执行顺序

注意到生成器返回的最后一个对象将 done 设置为 true,将 value 设置为 undefineddonetrue 表示此生成器已到达其函数的末尾,没有更多的代码要执行。这可以由尝试处理生成器的代码用来确定何时停止对其调用 next()。只有当生成器在末尾实际 return(而不是 yield)了一个值时,value 属性才会包含除 undefined 之外的内容。

相反,请注意传递给初始 next()0 从未被使用。这是因为第一个 next() 并不像之后的 next() 那样与 yield 相关联。第一个 next() 是从其初始暂停状态启动生成器的。如果你确实需要传递一个初始值,你可以使用常规函数参数来这样做:

function* myGeneratorFunction(initialValue) {
  // ...
}

const myGenerator = myGeneratorFunction(0);

好吧,这有很多东西需要理解。请随意再次阅读本节或玩自己的生成器函数,直到你理解为止。我不期望你理解为什么或何时会使用生成器函数。你只是必须完全理解所有这些全新的功能,然后你才能深入研究一些有用的应用。

快速检查 18.2

Q1:

创建一个生成器函数,在完成前产生两个不同的值。

| |

QC 18.2 answer

A1:

function* simpleGen() {
  yield 'Java';
  yield 'Script';
}

18.3. 使用生成器函数创建无限列表

一些语言,如 Haskell,允许创建无限列表,因为它们是惰性求值的。这意味着从技术上讲,列表包含无限个值,但每个值只有在请求时才会分配到内存中。另一方面,JavaScript 是急切求值的。所以如果你尝试创建一个包含无限值的数组,它将尝试将它们全部添加到内存中,直到耗尽内存。不过,你可以通过生成器函数轻松地模拟这一点。如下所示,模拟从 0 到无穷大的无限列表。

列表 18.2. 使用生成器函数生成无限列表
function* infinite() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const list = infinite();
console.log( list.next().value );         *1*
console.log( list.next().value );         *2*
console.log( list.next().value );         *3*
  • 1 Logs 0

  • 2 Logs 1

  • 3 Logs 2

当你继续调用 next() 时,你会从你的无限列表中获取下一个值。同时注意你使用了 while(true) 而不会停止。如果这是一个常规函数,这将创建一个无限循环并崩溃。然而,由于每次都会遇到一个 yield,它会停止并一次只产生一个值。所以你现在只是懒加载请求的项目。

定义一个小的辅助函数来帮助你从无限列表中取值:

function take(gen, count) {
  return Array(count).fill(1).map(
    () => gen.next().value
  );
}

take(infinite(), 7);         *1*
  • 1 [0, 1, 2, 3, 4, 5, 6]

好的,现在你将创建一个更有趣的无限数字列表。斐波那契序列以数字 0 和 1 开始,由序列中每个整数是前两个整数的和来定义。所以如果你从 01 开始,它将继续以 12358 等等,如下一列表所示。

列表 18.3. 生成斐波那契序列
function* fibonacci() {
  let prev = 0, next = 1;

  for (;;) {
    yield next;
    let tmp = next;              *1*
    next = next + prev;          *2*
    prev = tmp;                  *3*
  }
}

take(fibonacci(), 7);            *4*
  • 1 首先获取未更改的 next 值的引用。

  • 2 将未更改的 prev 值加到 next 上。

  • 3 最后,将 prev 更新为你的 tmp 值。

  • 4 [1, 1, 2, 3, 5, 8, 13]

太棒了!然而,你可以使用数组解构来更新你的 prevnext 值,而无需依赖于中间的 tmp 值:

function* fibonacci() {
  let prev = 0, next = 1;

  for (;;) {
    yield next;
    [next, prev] = [next + prev, next];
  }
}

希望到现在为止,你可以看到使用生成器创建列表是多么容易。这不是偶然,你很快就会在 单元 5 中发现原因,该单元涵盖了迭代。

快速检查 18.3

Q1:

实现一个生成器函数,创建无限的自然奇数列表。

| |

QC 18.3 答案

A1:

function* odd() {
  let i = 1;
  while (true) {
    yield i;
    i += 2;
  }
}

概要

本课介绍了生成器函数的语法和生命周期。

  • 生成器函数用星号定义。

  • 生成器函数返回一个生成器对象。

  • 生成器可以使用 yield 关键字产生值。

  • 生成器在每个 yield 处暂停,直到通过调用 next() 来告诉它继续。

  • 你可以将值作为 next() 的参数传递给生成器。

让我们看看你是否理解了:

Q18.1

创建一个生成器,用于无限日期列表。第一次调用 next() 时,应该产生当前日期。后续的每次调用应该产生下一天。或者,你可以让生成器函数接受一个参数,告诉它从哪一天开始,默认为当前日期。

第 19 课. 核心课程:囚徒困境

让我们假装你被赋予了组织黑客马拉松的荣誉——这是一个编程竞赛,参赛者会被给予一个他们预期要解决的编码挑战。

作为一位游戏理论的爱好者,你决定使用囚犯困境作为黑客马拉松的挑战。你怎么知道你是一位游戏理论的爱好者呢?嗯,谁不是呢?但万一你不是,我会解释囚犯困境。两个囚犯被分别关在不同的房间里审问,每人被要求告密另一个囚犯。每个囚犯告密对方时发生的情况会根据讲述故事的人而变化,但基本想法是这样的。对两个囚犯来说最好的结果是他们都不告密。如果一个人告密了另一个人,这对告密者是有利的,但对被告密者是不利的。如果他们都告密了对方,这对双方都不好,但比只有一个人被告密的情况要轻。为了黑客马拉松,你决定结果如下:

  • 如果两个囚犯都告密,他们每人将服刑一年。

  • 如果只有一个囚犯告密,那么这个囚犯将被释放,而另一个囚犯将服刑两年。

  • 如果两个囚犯都没有告密,他们都将被释放。

你将向每个参赛者解释,他们需要编写一个囚犯程序,该程序在与其他参赛者的囚犯对抗后,在监狱中的总刑期最少。你认为这是一个很好的黑客马拉松问题,因为它不依赖于主观评分,而是一个无人可以争论的指标。

你想在黑客马拉松之前做一个概念验证,以了解你可能会看到的结果,所以你需要

  1. 理解囚犯 API:单个囚犯界面的样子

  2. 理解囚犯工厂 API:生成囚犯的接口是什么样的

  3. 建立几个不同的囚犯工厂进行对抗

  4. 建立一个模拟,该模拟接受两个囚犯,比较他们的行为,并记录分数

  5. 建立一种方式来展示最后的分数

19.1. 生成囚犯

为了运行每个囚犯的模拟,每个参赛者的提交必须实现一个所有参赛者都将遵守的通用接口。你需要一个用于生成囚犯的接口;你将称之为囚犯工厂 API。囚犯工厂 API 将生成囚犯对象。你还需要确定每个囚犯的接口。你将简单地称之为囚犯 API。

囚犯 API 将会非常简单:只需要一个名为snitch的方法,该方法返回truefalse,表示囚犯是否告密。你还会为每个囚犯添加另一个名为sentencedTo的方法,这将允许你为囚犯分配监狱刑期。不过,这不会是要求参赛者构建 API 的一部分,因为你不想让参赛者处理监狱判决。

犯人工厂 API 稍微复杂一些,因为它应该始终为你生成一个新的犯人进行审问,但它需要能够允许生成的犯人在实时中适应。为此,每次你从工厂请求犯人时,你将向工厂提供他们到目前为止的表现统计数据。这些统计数据将是一个数字数组,表示每个之前犯人被判刑的年数。如果没有服刑,则为 0。这将允许工厂实时适应,根据表现的好坏调整他们产生的犯人。这意味着对于每次审问,你需要在得到另一个犯人对象之前向工厂传递数据(他们的统计数据)。对于这种双向通信,使用生成器是有意义的,因为生成器可以向你的模拟产生犯人,而你的模拟可以传递回统计数据(参见图 19.1)。

图 19.1. 犯人生命周期

定义一个生成器,它产生一个犯人:

function* prisoner() {
  for(;;) {
    yield {
      snitch() {
        return true
      }
    }
  }
}

这个犯人生成器始终产生一个新的犯人。犯人是一个具有snitch方法的对象。参赛者不知道他们的提交将要在模拟器中运行多少次,所以这个生成器通过使用永无止境的for(;;)循环来产生无限数量的犯人是有意义的。你可以这样测试:

const prisoners = prisoner()
prisoners.next().value.snitch()           *1*
  • 1 true

看起来这是一个总是告密的犯人。但你仍然需要传递之前的结果。每次调用next时,如果你传递回一个包含所有之前结果的数组,犯人生成器可以在它产生时捕获这些结果,如下所示:

function* prisoner() {
  let stats = []              *1*
  for(;;) {
    stats = yield {           *2*
      snitch() {
        return true
      }
    }
  }
}
  • 1 第一个产生的犯人还没有任何统计数据,所以你将默认统计数据设置为空数组。

  • 2 在每个犯人产生后,你会得到所有之前结果的数据。

在产生犯人之前,代码可以查看之前的结果,并使用这些信息来指导其决定是否告密。当然,这个总是告密的犯人并没有利用这些统计数据;你将要改变这一点。创建一个犯人,如果他们最近服过刑,就会告密,否则不会告密:

function* prisoner() {
  let stats = []
  for(;;) {
    stats = yield {
      snitch() {
        return stats.pop() > 0          *1*
      }
    }
  }
}
  • 1 如果犯人上次去过监狱,就告密;否则不要。

犯人工厂 API 的最后一部分是让参赛者命名他们的提交。这样你的模拟就可以告诉你哪个犯人赢得了比赛。为了实现这一点,你决定每个犯人工厂都应该是一个具有name属性和generator属性的对象。所以之前的例子将按照下面的列表进行修改:

列表 19.1. 带有名称和生成器的犯人工厂
const retaliate = {
  name: 'retaliate',
  *generator() {
    let stats = []
    for(;;) {
      stats = yield {
        snitch() {
          return stats.pop() > 0
        }
      }
    }
  }
}

19.2. 让犯人互动

这基本上涵盖了每个参赛者需要使用的囚犯和囚犯工厂 API 来构建他们的参赛作品。现在您需要一种方法来将每个囚犯与其他囚犯进行审讯,并查看哪个囚犯获得的刑期最少。为此,定义一个 interrogate 函数,该函数接受两个囚犯,要求每个囚犯告密,然后根据预定义的规则分配刑期,如下所示。

列表 19.2. interrogate 函数将两个囚犯置于对立面
function interrogate(criminal, accomplice) {

  const criminalsnitched = criminal.snitch()
  const accomplicesnitched = accomplice.snitch()

  if (criminalsnitched && accomplicesnitched) {          *1*
    criminal.sentencedTo(1)
    accomplice.sentencedTo(1)
  } else if (criminalsnitched) {                         *2*
    criminal.sentencedTo(0)
    accomplice.sentencedTo(2)
  } else if (accomplicesnitched) {                       *3*
    criminal.sentencedTo(2)
    accomplice.sentencedTo(0)
  } else {                                               *4*
    criminal.sentencedTo(0)
    accomplice.sentencedTo(0)
  }
}
  • 1 他们都告密了;每人一年。

  • 2 只有罪犯告密;他们获释,而同谋被判两年。

  • 3 只有同谋告密;他们获释,而罪犯被判两年。

  • 4 都没有告密;他们都获释。

interrogate 函数接受两个囚犯,要求每个囚犯告密,然后根据预定义的规则分配刑期。请注意,您现在正在对每个囚犯调用 sentencedTo 方法。记住,您不希望参赛者自己实现此方法,以避免他们作弊的冲动,因此您需要自己将此方法添加到每个囚犯身上。

19.3. 获取和存储结果

为了将您的 sentencedTo 方法附加到每个囚犯身上,您需要编写自己的生成器函数,该函数接受囚犯工厂作为参数,从工厂的生成器中内部获取囚犯,并在添加您的 sentencedTo 方法后产出它们:

function* getPrisoners({ name, generator }) {                     *1*
  const stats = [], prisoners = generator()                       *2*

  for(;;) {
    const prisoner = prisoners.next(stats.slice()).value          *3*

    yield Object.assign({}, prisoner, {                           *4*
      sentencedTo(years) {
        stats.push(years)                                         *5*
      }
    })
  }
}

const wrappedRetaliate = getPrisoners(retaliate)                  *6*

const prisoner = wrappedRetaliate.next().value                    *7*

if (prisoner.snitch()) {
  prisoner.sentencedTo(10)                                        *8*
}
  • 1 您可以直接通过参数解构获取名称和生成器。

  • 2 统计数组将跟踪之前的成果

  • 3 通过调用 .slice,您传递了一个副本。

  • 4 将 sentencedTo 方法添加到囚犯的副本中。

  • 5 当囚犯被判刑时,将其添加到您的统计数据中。

  • 6 将您之前的报复工厂包裹在您的 getPrisoners 生成器中。

  • 7 就像以前一样获取囚犯。

  • 8 产生的囚犯现在有了 sentencedTo 方法。

此函数封装了参赛者的代码,在添加所需的 sentencedTo 方法后,产出每个参赛者的囚犯。sentencedTo 方法将结果(或判决)添加到跟踪所有结果的数组中。这些是传递回囚犯生成器的统计数据,允许其适应。您在将它们传递回参赛者的代码时调用 stats.slice()。这会创建一个副本,以确保参赛者的代码无法更改您的统计数据。

你的模拟需要能够从这个函数中获取统计数据和名称。这很棘手,因为这个函数每次调用next时都会产生囚犯。你可以让它每次都产生namestatsprisoner,但这是不必要的,因为你一旦完成对囚犯的询问,实际上只需要名称和统计数据。所以当你完成对囚犯的询问时,你可以在最后的next调用中传递true,表示你已经完成。这可以告诉getPrisoners生成器你不再需要囚犯,而是希望得到你正在测试的囚犯的统计数据和名称,如下所示。

列表 19.3. 从生成器获取统计数据和囚犯名称
function* getPrisoners({ name, generator }) {
  const stats = [], prisoners = generator()

  for(;;) {
    const prisoner = prisoners.next(stats.slice()).value

    const finished = yield Object.assign({}, prisoner, {          *1*
      sentencedTo(years) {
        stats.push(years)
      }
    })

    if (finished) {
      break                                                       *2*
    }
  }

  return { name, stats }                                          *3*
}
  • 1 每次 yield 后捕获一个完成标志。

  • 2 完成后跳出 for 循环。

  • 3 最终值将是名称和统计数据。

19.4. 组合模拟

现在你需要编写一个test函数,实际上是对参赛者的囚犯进行模拟,将他们相互审问,如下所示。

列表 19.4. 组合所有内容
function test(...candidates) {                                *1*

  const factories = candidates.map(getPrisoners)              *2*
  const tested = []

  for(;;) {
    const criminal = factories.pop()
    tested.push(criminal)

    if (!factories.length) {
      break                                                   *3*
    }

    for (let i = 0; i < factories.length; i++) {
      const accomplice = factories[i]
      interrogate(criminal.next().value, accomplice.next().value)
    }
  }

  return tested.map(testee => testee.next(true).value)        *4*
}
  • 1 使用休息时间让任意数量的囚犯进行测试。

  • 2 使用 getPrisoner 包装器映射每个囚犯。

  • 3 在测试每个囚犯与其他每个囚犯对抗后跳出循环。

  • 4 获取最终值并返回。

这个test函数使用 rest 来允许测试任意数量的参赛者囚犯。通过pop每个囚犯出数组并测试他们与数组中剩余的所有囚犯对抗,你确保了测试每个囚犯与其他每个囚犯对抗。然后你将那个囚犯推入一个tested数组,以便你可以在最后返回结果。快速定义一些其他囚犯,以便你可以测试它们之间的对抗,如下所示。

列表 19.5. 定义更多囚犯
const never = {
  name: 'never',
  * generator() {
    for(;;) {
      yield {
        snitch() {
          return false
        }
      }
    }
  }
}

const always = {
  name: 'always',
  * generator() {
    for(;;) {
      yield {
        snitch() {
          return true
        }
      }
    }
  }
}

const rand = {
  name: 'random',
  * generator() {
    for(;;) {
      yield {
        snitch() {
          return Math.random() > 0.5
        }
      }
    }
  }
}

现在你有三个其他囚犯来对抗你的报复囚犯:一个“永不”告密囚犯,一个“总是”告密囚犯,以及一个“随机”告密囚犯。让我们看看他们之间是如何对抗的:

test(retaliate , never, always, rand);          *1*
  • 1 [对象,对象,对象,对象]

19.5. 哪个囚犯表现最好?

哇哦!你成功测试了你的囚犯输入(希望如此)。但是,以当前格式阅读结果相当困难。目前你得到的是一个对象数组;每个对象都有一个名为name的属性,包含所有结果(每个结果都是一个判决的年数)。如果结果是一个包含每个囚犯类型名称作为键和所有判决年数总和作为值的单个对象,那就容易阅读得多。编写一个辅助函数将你的结果转换为该格式:

function getScore({ value }) {
  const { name, stats } = value
  const score = stats.reduce( (total, years) => total + years)
  return {
    [name]: score              *1*
  }
}
  • 1 使用计算属性名称将属性分配为囚犯名称。

这个getScore函数从囚犯那里获取最后产生的值,计算总年数,并返回一个对象,其中囚犯名字作为属性,总和作为值。你需要更新test函数,将每个结果映射到getScore函数以进行格式化:

function test(...candidates) {

  // ... omitted for brevity

  return Object.assign.apply({}, tested.map(criminal => (
    getScore(criminal.next(true)
  ))))
}

再次运行你的测试:

test(retaliate , never, always, rand);           *1*
  • 1 {random: 2, always: 0, never: 6, retaliate: 2}

到目前为止,看起来总是告密是有利的。但你可以通过让每个囚犯被其他每个囚犯审问 50 次而不是一次来增强你的模拟器。你还将让每个囚犯与自身对抗。首先定义一个快速的帮助函数来执行回调 50 次:

function do50times(cb) {
  for (let i = 0; i < 50; i++) {
    cb()
  }
}

最后,更新test函数以进行最终更改,如下所示。

列表 19.6. 对囚犯进行 50 次审问
function test(...candidates) {

  const factories = candidates.map(getPrisoners)
  const tested = []

  for(;;) {
    const criminal = factories.pop()
    tested.push(criminal)

    do50times(() => interrogate(criminal.next().value, criminal.next().value))*1*

    if (!factories.length) {
      break
    }

    for (let i = 0; i < factories.length; i++) {
      const accomplice = factories[i]
      do50times(() =>
      interrogate(criminal.next().value, accomplice.next().value))          *1*
    }
  }

  return Object.assign.apply({}, tested.map(criminal => (
    getScore(criminal.next(true)
  ))))
}
  • 1 你的更改

现在你正在对每个囚犯进行 50 次与其他囚犯的测试,包括自我测试。最后一次运行测试:

test(retaliate , never, always, rand);             *1*
  • 1 {random: 187, always: 177, never: 156, retaliate: 90}

现在看来,报复似乎是个不错的选择。只有利用了统计数据的囚犯最终得到了最好的解决方案,这是有道理的。

摘要

在这个项目中,你创建了一个囚犯困境模拟运行器。运行器的作用是允许竞赛者编写自己的囚犯模拟程序,以查看哪种囚犯在囚犯困境场景中表现最好。你只编写了几个简单的囚犯模拟;你可以通过编写一个更深思熟虑的囚犯模拟来进一步扩展,看看它在你在这个项目中编写的囚犯模拟中的表现如何。

第四单元:模块

直到最近,大多数前端应用程序都遵循相同的库使用范式:暴露全局变量。例如,如果有人想在项目中使用 jQuery(jquery.com),他们需要访问 jQuery 的网站,下载 jquery.js 或 jquery.min.js,将其添加到项目的 js 或 vendor 文件夹中,然后通过一个<script>标签将库导入到他们的应用程序中。这并不理想,因为它要求模块使用可能与其他全局变量冲突的全局变量来导出自己。这也要求在依赖于它们的任何库之前包含依赖项,否则会出现错误。

模块通过允许模块加载器包含脚本来解决这些问题。模块本身可以指定和加载它们的依赖项,从而减轻应用程序作者管理它们的负担。

模块允许将大量代码分成小而紧密的单元。每个模块都包含在其自己的文件中。文件中任何未导出的内容都是私有的,无需用立即调用的函数表达式(IIFE)包裹以创建私有作用域。因为模块内部的代码不会将任何内容附加到全局命名空间,所以模块外部的任何代码都必须明确从模块中导入它需要的部分,以便获得对其的引用。

使用模块使你的代码易于推理,因为你确切地知道每个值来自哪里。你要么直接在你的模块中定义它,要么从其他地方导入它,因此你永远不会发现自己看着一个变量而不知道它在哪里定义。你也不必担心你的模块的哪些部分在其他地方被使用。如果值没有被导出,它是私有的,如果值被导出,它很可能被其他东西使用。

就像将你的代码拆分成许多小的函数一样,将你的应用程序拆分成许多小的模块会使你的程序易于推理,并随着复杂性的增加而保持可维护性。

我们将从这个单元开始,看看如何创建你自己的模块。然后我们将探讨导入和组合模块的各种方法。最后,你将通过创建一个猜谜游戏来结束这个单元,看看使用模块可以使你的代码多么整洁。

第 20 课:创建模块

在阅读第 20 课课后后,你将

  • 理解什么是模块

  • 了解 JavaScript 在模块中的行为

  • 创建模块并导出值

在学习如何创建和使用 ES2015 模块之前,让我们定义一下 JavaScript 中的模块是什么。在最基本的情况下,一个模块是一个具有自己的作用域和规则的 JavaScript 文件,它可以导入或导出值供其他模块使用。模块不是一个对象:它没有数据类型,你不能将其存储在变量中。它只是一个用来拆分、封装和组织代码的工具。

模块是分离你的逻辑到紧密单元并跨文件共享逻辑的好方法,而不必使用全局变量那么繁琐。它们还允许你只导入所需的,降低认知负荷,并使维护更容易。

考虑这一点

假设你正在编写一个大型应用程序。你会使用一个文件来编写全部代码,还是会将其拆分成几个较小的文件?你将如何使应用程序的各个组件在文件之间进行通信和共享资源?

20.1. 模块规则

在模块内部,JavaScript 的规则略有不同。代码始终在严格模式下执行,因此你永远不需要添加 "use strict";use strict 字符串变得流行的原因是允许脚本选择加入严格模式。这是因为如果浏览器切换到一个开关,开始以严格模式运行一切,许多遗留应用程序将会崩溃。选择加入严格模式是一种保留向后兼容性和防止破坏网络的方式。但模块是一个全新的上下文,因此没有必要使它们向后兼容。正因为如此,决定模块始终在严格模式下运行。

另一个不同之处在于根上下文中 this 的引用。在常规 JavaScript 中,this 在根级别将默认为全局对象(在浏览器中为 window)。但在 ES2015 模块中,它将是 undefined

var obj = {
  foo() {
    return this
  }
}
function bar() {
  return this
}
obj.foo()                      *1*
bar()                          *2*
console.log(this)              *2*
  • 1 obj

  • 2 undefined

obj.foo() 返回的值是 obj,就像在模块外一样。但在模块外,bar() 会返回 window 或全局对象,因为它是在没有上下文的情况下调用的。然而,在模块中,如果没有上下文调用 bar(),它将返回 undefined。此外,模块内部根级别对 this 的任何引用也将是 undefined,而不是 window,就像在模块外一样。

在 JavaScript 中,通常情况下,在根作用域中定义的任何变量都会自动设置为全局变量。许多过去的 JavaScript 开发者会在立即调用的函数表达式(IIFE)内部定义它们的变量,以防止变量成为全局变量。你也在本书的第一单元中了解到,在 ES2015 中,你现在可以使用简单的代码块 {} 来添加作用域,并防止变量成为全局变量。但在模块中,定义的变量永远不会是全局的。你可以想象你的整个模块都在一个代码块 {} 或 IIFE 内运行。这是因为模块被设计成只向外界显式导出内容。尽管如此,你仍然可以在模块内部将值附加到全局对象上:

window.someName = 'my value'

然而,在大多数情况下,从模块设置全局变量是一种不良做法。

现在你已经了解了模块中代码的工作规则,让我们在下一节中看看如何创建一些模块。

快速检查 20.1

Q1:

在模块内部,如果你像以下这样调用obj.foo,结果会是什么?

obj.foo.call(this)

QC 20.1 答案

A1:

它会是undefined,因为你将上下文设置为根级别的this,而在模块中根级别的thisundefined

20.2. 创建模块

想象你正在编写一个应用程序,并且你想要一个可重用的函数,该函数可以将统计数据记录到数据库中。你希望这个函数到处都可以使用。你可能只是将其设置为一个全局函数,如下所示:

window.logStats = function(stat) {
  // log stat to data base
}

有几个原因说明全局变量是一种不良实践,但在许多年里,在 JavaScript 中我们没有选择。但现在有了模块,你可以将这个函数放在一个模块中,并在你的应用程序中共享它,而无需将其设置为全局:

export default function logStats(stat) {
  // log stat to data base
}

通过在函数前加上export关键字,你是在说这个模块导出了logStats函数。你还指定这是默认导出;稍后会更详细地介绍这一点。

现在假设你的logStats函数需要一个辅助函数。logStats函数需要访问这个辅助函数,但你不想将其暴露给其他任何东西。如果没有模块,你现在将不得不求助于将一切包裹在私有上下文中,以防止全局值的泄露:

{
  function statsHelper() {
    // do some stat processing
  }

  window.logStats = function(stat) {
    // log stat to data base using  statsHelper
  }
}

但在模块内部,一切都已经在其自己的作用域中,因此已经保护了值不会泄露(除非明确导出或设置为全局):

function statsHelper() {
  // do some stat processing
}

export default function logStats(stat) {
  // log stat to data base using  statsHelper
}

当你创建一个模块时,你通常专注于一个特定的任务。当你想要创建一个可重用的自包含代码块时,你可以将其放入一个模块(一个单独的文件)中,只暴露所需的部分,同时保持模块的大部分细节是私有的。通常你的模块只会导出一个单一值。在这种情况下,将单一值作为默认值导出是有意义的:

export default function currency(num) {
  // ... return a formatted currency string
}

函数表达式是完全正常的;它只是被export default语句所前缀。语法是export default <expression>。从表达式评估出的值最终成为导出的值。所以如果你只想导出数字5,你可以这样做:

export default 5

如果你将模块想象成一个函数,默认导出将与函数的return值类似。一个函数只能返回一个值,一个模块也只能有一个默认导出。但模块并不局限于单个默认导出:它可以导出多个值,但只能有一个是默认的。所有其他导出都将被命名为导出。回到你的货币函数,如果你想按名称导出它,你只需要移除default关键字:

export function currency(num) {
  // ... return a formatted currency string
}

由于你移除了default关键字,函数通过名称导出。但请注意,你没有指定名称。名称会自动被选中并作为currency导出。

语法是 export <declaration>。你注意到区别了吗?使用默认导出时,你导出一个表达式,该表达式计算出一个值:导出的值。但使用命名导出时,你导出一个声明。一个函数声明、变量声明,甚至是一个类声明总是有一个名称,这就是导出值所使用的名称。你可以使用这种语法导出任意数量的声明。

对于命名导出,还有一个类似的语法:

function currency(num) {
  // ... return a formatted currency string
}

export { currency }

这与之前的功能相同,但使用了不同的语法。语法是 export { binding1, binding2, ... }。你可以指定一个名称或一个以逗号分隔的名称列表,数量不限。当你以这种方式导出一个值时,你必须使用一个指向值的名称(一个绑定)。你不能以这种方式导出一个原始值:

export { currency }             *1*
export { 'currency' }           *2*
export { 5 }                    *2*
  • 1 OK

  • 2 Syntax Error

这种语法的优点是它允许你指定一个用于导出的备用名称。比如说,在内部你将一个值称为 formattedCurrentUsername,但你想简单地将其导出为 username。你可以这样做:

export { formattedCurrentUsername as currency }

这种语法的格式是 export { originalName as exportedName }

当导出多个值时,你可以为每个值使用单独的导出语句,就像你声明它们时一样,或者你可以使用一个导出语句并列出你想要导出的所有名称:

export function currency(num) {
  // ... return a formatted currency string
}

export function number(num) {
  // ... return a number formatted with commas
}

这相当于

function currency(num) {
  // ... return a formatted currency string
}

function number(num) {
  // ... return a number formatted with commas
}

export { currency, number }

无论你决定哪种方式,都是个人喜好,尽管大多数开发者似乎更喜欢第一个例子。

这与函数声明一样适用于变量声明:

export let one = 1              *1*
export const two = 2            *2*
export 3                        *3*
  • 1 Exported as the name one

  • 2 Exported as the name two

  • 3 Invalid. No way to infer name.

导出语句必须位于顶层,这意味着你不能有条件地导出值。这里的顶层指的是文件主体代码的根部,而不是在代码块、if 语句、函数等内部:

export const number = num => /* format number */              *1*

if (currencyNeeded) {
  export const currency = num => /* format currency */        *2*
}

function exportMore() {
  export const date = dateObj => /* format date */            *2*
}
  • 1 Top level, valid

  • 2 Not top level, invalid

这是由设计决定的:模块旨在是静态可分析的,因此必须无条件地始终以相同的方式导出和导入。如果允许在函数或 if 语句中导出一个值,那么有时可以导出一个值,有时则不行。模块设计为始终执行其导出,因此它们必须位于顶层。

现在当创建一个导出多个值的模块时,你可能想知道,其中之一是否应该是默认导出,其余的应该是命名导出,还是所有都应该命名为导出?这个问题没有一刀切的答案。这实际上取决于你如何设计你的模块。我可以说,作为一个经验法则,如果你导出的所有内容都是同等重要的,那么使用所有命名导出而没有默认导出。但是,如果你有一项内容作为主要导出,而其他所有内容都似乎增强或围绕这个主要导出,那么它应该是默认导出,其余的应该是命名导出。如果你发现自己处于想要有多个命名导出但又有两个或更多默认导出的情况,那么你的模块可能做得太多,应该分解成更小的模块。我们将在下一课中介绍将大模块分解成小模块的技术。

快速检查 20.2

Q1:

在以下代码片段中,有两个函数。修改代码,使函数 ajax 成为默认导出,并通过名称导出 setAjaxDefaults

function ajax(url) {
  // perform ajax
}

function setAjaxDefaults(options) {
  // store options
}

| |

QC 20.2 答案

A1:

export default function ajax(url) {
  // perform ajax
}

export function setAjaxDefaults(options) {
  // store options
}

20.3. 当一个 JavaScript 文件成为模块时?

如果一个模块只是一个文件,什么决定了给定的 JavaScript 文件是模块还是不是?负责制定特定于 Web 的 JavaScript 环境规范的 WHATWG(Web Hypertext Application Technology Working Group)提出了一个新的脚本类型模块。基本上,当使用脚本标签时,你将指定 type="module" 而不是 type="javascript",浏览器使用的 JavaScript 加载器就会知道将此文件作为模块执行:

<script type="module" src="./example.js">

虽然在 Node.js 中这不起作用,因为 Node.js 不使用脚本标签或 HTML。因此,有一个提议,通过是否导出值来推断文件是否为模块。要指定当文件不导出值时它是模块,你会使用一个没有实际导出任何内容的命名导出:

export {}                 *1*

// rest of the module
  • 1 一个空的导出,表示此文件是一个模块

不要将此与导出对象混淆。这是命名导出语法,你通常会通过括号列出你的导出名称。如果没有命名任何内容,则不会导出任何内容,但根据提议,它将识别该文件为模块。如果这个提议失败,Node.js 团队计划使用替代文件扩展名 .mjs 来指定文件是模块。

进一步阅读

blog.whatwg.org/js-modules

github.com/bmeck/UnambiguousJavaScriptGrammar

概述

在本课中,你学习了如何创建模块。

  • 导出一个声明创建一个命名导出。

  • 命名导出也可以在括号中列出。

  • 列在括号中的命名导出可以使用不同的名称导出。

  • 可以有多个命名导出,但只能有一个默认导出。

  • 导出语句必须在顶层(根)级别声明。

  • 在根级别,关键字this是未定义的。

  • 模块默认处于严格模式。

让我们看看你是否理解了:

Q20.1

创建一个名为 luck_numbery.js 的模块。给它一个内部变量(未导出),称为luckyNumber。导出一个名为guessLuckyNumber的默认导出函数,该函数接受一个名为guess的参数,检查猜测是否与luckyNumber相同,并返回truefalse以指示是否猜对了。

第 21 课. 使用模块

在阅读第 21 课之后,你将

  • 理解如何指定你打算使用的模块的位置

  • 理解从模块中导入值的所有不同方式

  • 理解如何导入模块以产生副作用

  • 理解导入模块时代码执行的顺序

  • 能够将大型模块分解成更小的模块

模块是分离你的逻辑到紧密单元并跨文件共享逻辑的好方法,而不必使用全局变量带来的繁琐。它们还允许你只导入所需的模块,降低认知负荷,并使维护更容易。在上一课中,你学习了什么是模块以及如何创建模块和导出值的基础知识。在本课中,我们将探讨使用其他模块、导入值的不同方式以及如何使用模块分解和组织你的代码。

考虑这一点

假设你正在编写一个 Web 应用程序,并且需要使用几个第三方开源库。问题是其中两个库都使用相同的全局变量暴露自己。你将如何使这两个库协同工作?

21.1. 指定模块的位置

你使用import语句从其他模块文件导入代码,该语句由两个关键部分组成,即whatwhere。当你使用import语句时,你必须指定what(变量/值)你正在导入,以及from where(模块文件)你正在从哪里导入它们。这与使用多个<script>标签包含多个文件,并通过使用全局变量在文件之间通信或共享值的方式形成对比。基本语法是import X from Y,其中X指定了你正在导入的内容,Y指定了模块的位置。一个简单的导入语句可能看起来像这样:

import myFormatFunction from './my_format.js'

然后当你指定从哪里或从哪个模块导入时,你必须使用字符串字面量值。以下是不合法的:

const myModule = './my_format.js'
import myFormatFunction from myModule            *1*
  • 1 无效,因为 myModule 是一个变量,而不是一个字符串字面量

你不能使用变量来定义模块的位置(from),即使该变量指向一个字符串。你必须使用字符串字面量。这再次是因为 JavaScript 中所有的导入和导出都是为了静态分析而设计的。你所有的导入都将先于当前文件中的任何其他代码执行。JavaScript 将扫描你的文件,找出所有的导入,首先执行这些文件,然后使用导入的正确值运行你的当前文件。这意味着你不能基于变量导入,因为那个变量还没有定义!

除了使用字符串来确定模块的位置之外,JavaScript 没有规定其他规则。对于每个 JavaScript 环境(主要是浏览器和 Node.js),都会有所谓的加载器,这些加载器将定义字符串的实际样子。Web 和 Node.js 的加载器仍在开发中。今天,大多数使用 ES6 模块的人都在使用像 Browserify 或 Webpack 这样的工具。这两个工具都将文件路径,如./file./file.js视为相对于当前文件:

import myVal from './src/file'           *1*
import myVal from './src/file.js'        *1*
  • 1 这两种方式都是等效的,指定了文件的相对路径。

文件扩展名是可选的,大多数人省略它。没有文件扩展名,路径也可以是一个包含index.js的目录。

import myVal from './src/file'                *1*
import myVal from './src/file.js'             *2*
  • 1 匹配 ./src/file.js 和 ./src/file/index.js

  • 2 仅匹配 ./src/file.js

指定一个没有路径的名称,例如jquery,表示这是一个已安装的模块,应该在node_modules目录中查找。

现在你已经知道了如何指定模块的位置,让我们看看如何指定你想要从它导入的内容。

快速检查 21.1

Q1:

假设的加载器最有可能在以下导入中查找模块文件:

import A from './jquery'
import B from 'lodash'
import C from './my/file'
import D from 'my/file'

QC 21.1 答案

A1:

  1. 从相对于当前文件的./jquery.js**.**/jquery/index.js文件。
  2. node_modules/lodash/package.json中指定的main字段。
  3. 从相对于当前文件的./my/file.js./my/file/index.js文件。
  4. 从相对于node_modules/my/package.json中指定的main字段的./file.js./file/index.js

21.2. 从模块中导入值

在上一课中,你创建了一个用于格式化货币字符串的模块。它有一个单独的default导出,用于实现此功能,如下所示:

export default function currency(num) {
  // ... return a formatted currency string
}

假设你将这个模块放在./utils/format/currency.js的位置,并想导入currency函数来格式化你正在构建的购物车系统中的某些货币。你可以使用以下import语句来完成:

import formatCurrency from './utils/format/currency'           *1*

function price(num) {
  return formatCurrency(num)
}
  • 1 你正在导入默认值。

注意函数的名称是 currency,但你使用 formatCurrency 名称导入。当你说 import <name> 时,你是在决定使用什么名称,就像你使用 var <name>const <name>let <name> 一样,只是你用等号分配值,而不是从另一个文件、一个模块中导入值。当你从模块中导入默认值时,你可以使用任何你想要的名称,如下面的愚蠢示例所示:

import makeNumberFormattedLikeMoney from './utils/format/currency'

function price(num) {
  return makeNumberFormattedLikeMoney(num)
}

前两个示例的行为将完全相同,假设它们导入的 ./utils/format/currency 模块没有发生变化。记住上一课中的类比:如果你把模块想象成一个函数,默认导出就相当于函数的 return 值。如果我们继续使用这个类比,导入默认值就像将函数的 return 值分配给一个变量:

function getValue() {
  const value = Math.random()
  return value
}

const value = getValue()
const whatchamacallit = getValue()

注意 getValue 函数返回了一个名为 value 的变量,并且将这个值分配给一个匹配的命名变量,或者一个完全不同的变量名,比如 whatchamacallit,都没有关系。这是因为该函数只返回一个值,而你只是捕获这个值并决定一个名称来存储它。当你从模块中导入默认值时也是这样。模块只能导出一个默认值,并且它只导出值,所以当你导入它时,你可以指定任何你想要的名称来存储这个值。

现在你已经在上一课中学到了,除了单个默认导出之外,一个模块还可以有一个或多个命名导出。正如其名所示,在这些情况下,名称确实很重要。你在上一课中学到的命名导出的语法之一如下:

function currency(num) {
  // ... return a formatted currency string
}

function number(num) {
  // ... return a number formatted with commas
}

export { currency, number }

便利的是,导入这些值的语法类似:

import { currency, number } from './utils/format'

function details(product) {
  return `
    price: ${currency(product.price)}
    ${number(product.quantityAvailable)} in stock ready to ship.
  `
}

在这里,currencynumber 名称必须与它们导出的名称相匹配。但你可以指定不同的名称来分配给它们:

import { number as formatter } from './utils/format'        *1*
  • 1 指定导入 number 但将其分配给格式化器名称

这在从多个使用相同名称导出的模块中导入命名值时很有用。比如说,如果你从一个功能工具模块中导入名为 fold 的函数,同时也从折纸模块中导入名为 fold 的函数。你可以使用 as 来将一个或两个导入映射到不同的名称,以避免命名冲突:

import { fold } from './origami'
import { fold as reduce } from './functional_tools'            *1*
  • 1 将 fold 导入为 reduce 名称以避免与其他导入值冲突。

如果你想要从模块中导入所有命名导出,你可以使用星号这样做:

import * as format from './utils/format'         *1*

format.currency(1)
format.number(3000)
  • 1 创建了一个名为 format 的新对象,并分配了模块中的所有值。

这将创建一个新对象,其属性与模块中所有命名导出相关联。如果你需要导入模块导出的所有值,可能用于内省或测试,但通常你应该只导入你将要使用的值。即使你碰巧使用了模块导出的所有内容,这也不一定意味着你将继续使用该模块的所有内容,因为它会不断增长。

想象一下,如果格式模块只导出了 currencydate 格式函数,而你需要这两个函数来构建你的产品模块,所以你使用星号导入它们。但后来在构建你的应用程序时,你继续向格式模块添加新的格式函数。你不需要这些新函数在你的产品模块中,但由于你使用星号导入,你将继续获得所有这些函数,而不仅仅是那些你正在使用的函数。某些情况下可能需要导入所有内容,但作为一般规则,你应该通过指定每个值的名字来明确指定你导入的内容。

当使用星号导入所有值时,这不包括默认导出,只包括命名导出。你可以通过逗号分隔来组合从模块导入默认导出和命名导出:

import App, * as parts from './app'                                     *1*
import autoFormat, { number as numberFormat } from './utils/format'     *2*
  • 1 默认值被命名为 App,所有命名导出都被设置为名为 parts 的新创建对象上的属性。

  • 2 默认值被命名为 autoFormat,而按名称导出的值被命名为 numberFormat。

一旦从模块中导入一个值,它不会创建像声明变量时的绑定。在下一节中,我们将探讨它是如何工作的。

快速检查 21.2

Q1:

在下面的 import 语句中,哪个是默认导入,哪个是命名导入?

import lodash, { toPairs } from './vendor/lodash'

| |

QC 21.2 答案

A1:

lodash 是默认导入,toPairs 是命名导入。

21.3. 导入值的绑定方式

默认导入和命名导入都创建只读值,这意味着一旦导入,就不能重新分配值:

import ajax from './ajax'

ajax = 1                      *1*
  • 1 错误:ajax 只读

但与默认导入不同,命名导入直接绑定到导出的变量。这意味着如果导出它的文件(模块)中的变量发生变化,导入它的文件中的变量也会发生变化。

让我们想象一个导出一个名为 title 的变量的模块,该变量有一个初始值,同时也导出一个名为 setTitle 的函数,允许你像这样更改标题:

export let title = 'Java'
export function setTitle(newTitle) {
  title = newTitle
}

如果你导入这两个值,你不能直接通过赋值更改 title 的值,但你可以通过调用 setTitle 来间接更改 title 的值:

import { title, setTitle } from './title_master'

console.log(title)         *1*
setTitle('Script')
console.log(title)         *2*
  • 1 “Java”

  • 2 “脚本”

这与你在 JavaScript 中通常检索值的方式非常不同。通常当你从函数调用、解构或其他表达式中检索值并将其赋给变量时,你正在检索该值,并创建一个指向该值的新绑定。但是当你从模块中导入值时,你导入的不仅是值,还有绑定。这就是为什么模块可以内部更改值,而你导入的变量将反映这种变化。

一旦值发生变化,它不仅会在当前文件和导出值的文件中变化,还会在导入该值的所有文件中变化。除此之外,没有通知值已更改。没有事件广播更改。它默默地改变,所以在更改导出的值时要小心。

在下一节中,你将学习为什么以及如何导入一个模块而不导入任何值。

快速检查 21.3

Q1:

在以下片段中有五个绑定。在所示上下文中,哪些可以重新赋值?

import a, { b } from './some/module'
const c = 1
var d = 1
let e = 1

| |

QC 21.3 答案

A1:

只有 d 和 e。

21.4. 引入副作用

有时候你只想导入一个模块以产生副作用,这意味着你希望模块中的代码执行,但你不需要引用来使用该模块中的任何值。一个例子是包含设置 Google Analytics 代码的模块。你不需要从这样的模块中获取任何值;你只需要执行,以便它可以设置自己。

你可以这样导入一个模块以产生副作用:

import './google_analytics'

这就像任何其他导入一样;你省略任何默认或命名的值,也省略了关键字 from。当你为了副作用导入一个文件时,你导入的模块中的所有代码都会在你导入的文件中的任何代码之前执行。这无论导入发生在哪里:

setup()

import './my_script'

在上一个例子中,模块 my_script 中的所有代码在 setup() 函数执行之前就已经执行了,即使它是后来导入的。

在下一节中,我们将探讨如何将较小的模块组织并分组到较大的模块中。

快速检查 21.4

Q1:

假设模块 log_b 包含语句 console.log('B')。在运行以下代码后,输出的顺序将是什么?

console.log('A')
import './log_b'
console.log('C')

| |

QC 21.4 答案

A1:

B, A, C

21.5. 分解和组织模块

有时候,一个模块会变得太大,可能需要将其分解成更小的模块。但你可能有一个已经大量使用该模块的大型代码库。你想要将这个模块重构为更小、更专注的模块,但你不希望因为这一点而不得不重构整个应用程序。让我们探讨如何将一个已经使用的模块分解成更小的块,而不会对应用程序的其他部分产生影响。

假设你有一个格式模块。它最初很小,只有几个格式化函数,但随着应用程序的增长,你继续需要为不同的需求创建新的格式化器。一些格式化器共享逻辑,而其他格式化器需要它们自己的辅助函数。将所有这些放在一个模块中已经变得过于复杂。为了简洁起见,让我们假设有四个格式化器,如下面的列表所示;在实际应用程序中,可能还有更多。

列表 21.1. src/format.js
function formatTime(date) {
  // format time string from date object
}

function formateDate(date) {
  // format date string from date object
}

function formatNumber(num) {
  // format number string from number
}

function formatCurrency(num) {
  // format currency string from number
}

现在假设许多模块正在使用这些模块,例如以下产品模块。

列表 21.2. src/product.js
import { formatCurrency, formatDate } from './format'

这个产品模块只是使用格式器的许多模块之一。你想要以不会破坏这个模块或任何其他模块的方式重构你的格式模块。

将模块分解成两个独立的模块,一个用于数字,一个用于日期,并将它们分组在格式文件夹中。

下面是日期格式模块。

列表 21.3. src/format/date.js
function formatTime(date) {
  // format time string from date object
}

function formateDate(date) {
  // format date string from date object
}

下面是数字格式模块。

列表 21.4. src/format/number.js
function formatNumber(num) {
  // format number string from number
}

function formatCurrency(num) {
  // format currency string from number
}

你已经很好地将大型模块分解成更小、更专注的模块。但为了使用这些模块,你必须重构所有其他导入值的模块,比如产品模块。你想要避免这种情况。在格式模块内部创建另一个索引模块,从更专注的模块中导入值并导出它们,如下面的列表所示。

列表 21.5. src/format/index.js
import { formatDate, formatTime } from './date'
import { formateNumber, formatCurrency } from './number'

export { formatDate, formatTime, formatNumber, formatCurrency }

这很好,因为现在当其他模块尝试从 ./src/format 导入时,如果找不到 ./src/format.js,它实际上会从 ./src/format/index.js 导入。这意味着你不再需要重构任何其他模块。这是省略在导入模块路径时指定文件扩展名的绝佳论据,因为如果你指定了文件扩展名,这次重构将会痛苦得多。

这种组织方式非常常见,实际上有一个直接用于它的语法。src/format/index.js 模块可以写成如下所示。

列表 21.6. src/format/index.js
export { formatDate, formatTime } from './date'
export { formateNumber, formatCurrency } from './number'

如果你导入一个值只是为了将其导出,你可以跳过一步,直接从该模块导出!现在,格式模块总是应该导出所有更专注格式化器的值,对吧?嗯,与其列出所有名称然后回来添加任何未来添加的新格式化器的名称,不如可以这样导出所有值。

列表 21.7. src/format/index.js
export * from './date'
export * from './number'

太棒了!通过这个简单的外观类型模块,你成功且优雅地将大型模块分解成更小、更专注的模块,而且你以对应用程序其余部分透明的方式无缝地做到了这一点!

快速检查 21.5

Q1:

假设你将在 ./src/format/word 添加另一个模块,并更新索引文件以导出所有单词格式化器。

| |

QC 21.5 答案

A1:

export * from './date'
export * from './number'
export * from './word'

概述

在本课中,你学习了如何使用和组织模块。

  • 默认导入可以使用任何名称进行设置。

  • 命名导入以大括号列出,类似于它们的导出方式。

  • 命名导入必须指定正确的名称。

  • 命名导入可以在指定正确名称后通过 as 使用别名。

  • 你可以使用 * 导入所有命名值。

  • 命名导入是直接绑定(不仅仅是引用)到导出的变量。

  • 默认导入不是直接绑定,但仍然是只读的。

  • 值可以直接从其他模块导出。

让我们看看你是否掌握了这些:

Q21.1

创建一个模块,从上一课导入 luck_numbery.js,并尝试猜测幸运数字,并记录在猜对数字之前它尝试了多少次。

第 22 课:综合项目:猜字游戏

在这个综合项目中,你将构建一个猜字游戏。游戏将包含状态信息、单词的字母槽和猜测字母的按钮(图 22.1)。

你将使用本书附带代码中的 start 文件夹开始你的项目。如果你在任何时候遇到困难,你还可以查看包含完成游戏的最终文件夹。start 文件夹是一个已经设置好以使用 Babel 和 Browserify 的项目(见第 1 课–第 3 课);你只需要运行 npm install 来设置。如果你还没有阅读第 1 课–第 3 课,你应该在完成这个综合项目之前先阅读。还有一个包含的 index.html 文件:这是游戏将运行的地方。它已经包含了所需的全部 HTML 和 CSS;你只需要在捆绑你的 JavaScript 文件后,在浏览器中打开它。src 文件夹是放置你所有 JavaScript 文件的地方,dest 文件夹是在你运行 npm run build 后捆绑的 JavaScript 文件将放置的地方。

图 22.1. 猜字游戏

22.1. 规划

你将把你的游戏分成几个模块,所以首先确定你要把游戏分成哪些模块是有意义的。在你开始游戏之前,你需要一个随机单词。创建一个用于生成随机单词的模块是有意义的。其次,你需要跟踪你的游戏状态——游戏是否获胜、失败等等。所以你还需要一个状态模块。用户界面需要显示三个部分。首先是游戏状态的表示;称之为 status-display。你还需要显示玩家猜测的单词的字母槽;称之为 letter-slots。第三个 UI 元素是你需要的字母按钮模块,以便玩家可以进行猜测。称之为 keyboard。最后,你需要将这些模块粘合在一起以创建实际的游戏。粘合剂不会很多,所以你只需在 index 中完成。

22.2. 单词模块

从一个简单的函数开始,该函数仅返回一个单词数组。

列表 22.1. src/words.js
function getWords(cb) {                            *1*
  cb(['bacon', 'teacher', 'automobile'])           *2*
}
  • 1 函数接受一个回调作为参数。*

  • 2 使用你的单词数组调用回调函数。*

你不是返回一个单词数组,而是使用回调函数来返回一个单词数组。在其当前状态下,这可能没有意义,但这将允许稍后重写 getWords 函数,以使用 AJAX 请求从 API 或其他外部资源获取单词。

现在你有了你的单词,你需要一个函数来返回一个随机单词,如下所示。

列表 22.2. src/words.js
export default function getRandomWord(cb) {
  getWords(words => {                                                    *1*

    const randomWord = words[Math.floor(Math.random() * words.length)]   *2*
    cb(randomWord.toUpperCase())                                         *3*
  })
}
  • 1 将 cb 传递给 getWords。*

  • 2 从单词数组中获取一个随机单词。*

  • 3 使用你的随机单词调用 getRandomWord 传递给它的回调函数。*

这就是你的单词模块的全部内容。你只从模块中导出了 getRandomWord 函数,因为游戏的其他部分不需要单词数组,只需要一次一个随机单词。此外,因为你只导出了一个函数,所以将其设置为默认导出。接下来,你将构建状态模块。

22.3. 状态模块

在游戏中,你将使用四种状态:剩余多少次猜测、玩家是否获胜、玩家是否失败,以及游戏是否仍在进行(当他们还有剩余猜测且未获胜或失败时)。为了确定这些状态,需要随机单词以及玩家的猜测,因此你需要为每个状态导出一个函数,该函数接受当前单词和猜测作为参数。

列表 22.3. src/status.js
const MAX_INCORRECT_GUESSES = 5                                           *1*

export function guessesRemaining(word, guesses) {
  const incorrectGuesses = guesses.filter(char => !word.includes(char))   *2*
  return MAX_INCORRECT_GUESSES - incorrectGuesses.length
}

export function isGameWon(word, guesses) {
  return !word.split('').find(letter => !guesses.includes(letter))        *3*
}

export function isGameOver(word, guesses) {
  return !guessesRemaining(word, guesses) && !isGameWon(word, guesses)    *4*
}

export function isStillPlaying(word, guesses) {
  return guessesRemaining(word, guesses) &&
         !isGameOver(word, guesses) &&
         !isGameWon(word, guesses)                                        *5*
}
  • 1 你不需要导出这个函数,因为其他东西不应该需要它。*

  • 2 找出所有被猜测但不在单词中的字母。*

  • 3 确定单词中的所有字母是否都被猜测。*

  • 4 如果游戏未获胜且没有猜测,则游戏结束。*

  • 5 只要还有猜测,游戏还没有被赢得或输掉,玩家仍在玩游戏。

注意你没有导出MAX_INCORRECT_GUESSES常量。这是因为没有其他东西会使用它,你应该只导出其他模块所需的内容,而不是更多,以使 API 表面尽可能小,这将使未来的更改和调试更容易。其他四个函数都将被其他模块使用以使游戏工作,所以你导出所有这些函数。但这并不意味着你应该总是导出每个函数。如果这些函数中的任何一个使用了辅助函数来确定其值,你就不会导出这样的辅助函数,因为它不需要在其他地方使用。

你可能会问自己,为什么要把wordguesses作为函数参数传递?为什么不直接导入它们?你可以这样做,并且它将工作。但是这样做会将所有单个模块紧密耦合到主游戏逻辑(单词和猜测存储的地方),这将使它们更难隔离和测试。在这个小型游戏中,你不会添加任何测试,但这仍然是一个好习惯。

接下来,我们将关注三个 UI 元素:状态显示、字母槽位和键盘。

22.4. 游戏界面模块

正如我们之前所说的,你有三个 UI 部分:将显示剩余猜测次数或游戏是否结束或赢得游戏的状况显示,字母槽位和键盘。你可以创建一个单独的 UI 模块来导出每一个;这并不错,但我更愿意将每个部分放入它自己的模块中。它们没有共享任何逻辑或任何其他表明它们应该在一起的东西,除了都是 UI 的一部分,所以我认为这样做最有意义。我更愿意有几个简单的模块而不是一个更复杂的模块,如果你后来决定添加更多的 UI 部分,这也会进一步表明每个部分都应该是一个单独的模块。

每个 UI 模块将导出一个函数,该函数返回表示游戏界面部分的 HTML(作为字符串)。所有 UI 模块都将需要玩家的猜测,而状态显示和字母槽位还需要当前随机单词。因此,它们也将接受与状态模块类似的参数。

好的,所以你有一个简单的 API,每个 UI 模块都将遵循。它将导出一个函数(你将使其成为默认导出),该函数将接受所需的数据并返回一个 HTML 字符串。从状态显示模块开始。

列表 22.4. /src/status_display.js
import * as status from './status'                                        *1*

function getMessage(word, guesses) {
  if (status.isGameWon(word, guesses)) {                                  *2*
    return 'YOU WIN!'
  } else if (status.isGameOver(word, guesses)) {                          *2*
    return 'GAME OVER'
  } else {
    return `Guesses Remaining: ${status.guessesRemaining(word, guesses)}` *2*
  }
}

export default function statusDisplay(word, guesses) {
  return `<div>${getMessage(word, guesses)}</div>`
}
  • 1 将所有值导入到一个单独的状态对象中。

  • 2 从生成的状态对象中调用导入的函数。

状态显示将需要大多数状态函数,所以不是单独导入它们,而是从状态模块导入所有内容,并将它们全部组合成一个生成的status对象。然后你可以直接从创建的status对象中调用任何函数。

除了这个模块之外,这个模块相当简单。它只是根据游戏状态生成一条消息。接下来,你将构建字母槽模块。

列表 22.5. /src/letter_slots.js
function letterSlot(letter, guesses) {
  if (guesses.includes(letter)) {
    return `<span>${letter}</span>`
  } else {
    return '<span>&nbsp;</span>'
  }
}

export default function letterSlots(word, guesses) {
  const slots = word.split('').map(letter => letterSlot(letter, guesses))

  return `<div>${ slots.join('') }</div>`
}

这个模块也很简单:你生成与单词中的每个字母对应的多个 span。span 要么为空,要么揭示字母,这取决于玩家是否猜对了那个字母。再次按照计划导出我们的一个默认函数。现在完成最后的 UI 部分,即键盘模块,如下列所示。

列表 22.6. /src/keyboard.js
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')                   *1*

const firstRow = alphabet.slice(0, 13)                                    *2*
const secondRow = alphabet.slice(13)                                      *2*

function key(letter, guesses) {
  if (guesses.includes(letter)) {
    return `<span>${letter}</span>`                                       *3*
  } else {
    return `<button data-char=${letter}>${letter}</button>`               *4*
  }
}

export default function keyboard(guesses) {
  return `
    <div>
      <div>${ firstRow.map(char => key(char, guesses)).join('') }</div>   *5*
      <div>${ secondRow.map(char => key(char, guesses)).join('') }</div>  *5*
    </div>
  `
}
  • 1 获取所有字母表字母数组的快捷方式

  • 2 你想要第一行的前 13 个字母和最后一行的最后 13 个字母。

  • 3 如果字母已被猜中,你不想让他们再次猜测,所以使用 span。

  • 4 如果字母未被猜中,你使用按钮允许他们选择它。

  • 5 根据字母是否被猜中,将每个字母映射到按钮或 span。

这个模块也很简单:它生成所有字母表中的字母列表,将尚未被猜中的字母作为按钮,将已被猜中的字母作为 span。

那就是你需要的所有 UI。在下一节中,你将把它们全部组合起来,创建一个可工作的游戏。

22.5. 索引

索引是应用程序的入口点。在这里,你将协调所有单个模块,创建一个功能游戏。首先,像下一个列表中所示,导入你需要的所有内容。

列表 22.7. /src/index.js
import getRandomWord from './words'
import { isStillPlaying } from './status'
import letterSlots from './letter_slots'
import keyboard from './keyboard'
import statusDisplay from './status_display'

你还从单词模块导入默认函数以及所有 UI 模块。但你只需要从状态模块中导入 isStillPlaying 函数,以确定你是否应该继续与玩家交互。

在下一个列表中,你只需要创建一个渲染实际游戏的函数。

列表 22.8. /src/index.js
function drawGame(word, guesses) {
  document.querySelector('#status-display').innerHTML =
  statusDisplay(word, guesses)
  document.querySelector('#letter-slots').innerHTML =
  letterSlots(word, guesses)
  document.querySelector('#keyboard').innerHTML = keyboard(guesses)
}

在这里,你调用导入的每个 UI 函数,并使用 innerHTML^([1]) 将它们插入到网页的所需位置。你不需要任何其他界面逻辑,因为每个 UI 模块都会自己处理单词和猜测。所以你唯一需要做的事情就是获取一个随机单词,监听按钮点击,并将每个猜测添加到猜测列表中,如下列所示。

¹

查看 developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML

列表 22.9. /src/index.js
getRandomWord(word => {                                                      *1*
  const guesses = []                                                         *2*

  document.addEventListener('click', event => {                              *3*
    if (isStillPlaying(word, guesses) && event.target.tagName === 'BUTTON') {*4*
      guesses.push(event.target.dataset.char)                                *5*
      drawGame(word, guesses)                                                *6*
    }
  })

  drawGame(word, guesses)                                                    *7*
})
  • 1 首先,你需要获取一个随机单词。

  • 2 你还需要一个地方来存储玩家的猜测。

  • 3 使用事件委托来监听所有点击事件。

  • 4 如果游戏仍在进行中且玩家点击了按钮...

  • 5 ...将玩家猜中的字母添加到你的数组中。

  • 6 重新绘制游戏。

  • 7 绘制初始游戏用户界面。

在你得到一个随机单词后,你开始使用事件委托来监听按钮点击。² 每当玩家做出猜测时,整个 UI 都会被销毁并重新创建。这可以优化,但对于这个小型游戏来说,这是可以接受的,并且使游戏变得更加简单。事件委托允许你在 UI 重建时无需重新注册即可一次性添加点击事件监听器。

²

参考:davidwalsh.name/event-delegate

现在你有一个可以工作的游戏了。你可以在终端中使用npm run build来构建游戏,然后在浏览器中打开index.html

摘要

在这个综合项目中,你创建了一个猜字游戏。你首先制作了一个单词模块,用于生成游戏中使用的随机单词。然后,你着手处理游戏的状态和界面组件。最后,你在索引文件中将所有部件组合在一起。目前你只使用了三个随机单词,这并不使游戏变得很难猜。你可以自由地更新游戏,使用更长的单词列表或使用 API。你还可以通过添加“再玩一次”按钮来进一步改进游戏,以便游戏结束后可以重新开始。

单元 5. 可迭代对象

在 JavaScript 中,StringArray始终有一些共同之处。它们都包含一定数量的内容——字符串中的字符和数组中的任何数据类型。它们也都有一个length属性,表示它们包含的项目数量。但从未有一个共同的协议来描述这些内容是如何工作的。从 ES2015 开始,有两个新协议描述了这些 JavaScript 行为,被称为迭代迭代器协议。

StringArray现在被称为可迭代对象。这意味着它们遵循新的迭代协议,并且可以用常见的方式进行预测性交互,包括使用新的for..of语句和新的扩展运算符。还有两个新的可迭代对象:MapSet。此外,你可以定义自己的可迭代对象,甚至可以自定义内置对象的行为。由于它们都遵循迭代协议,它们的行为总是可预测的。

我们将从这个单元开始,看看迭代协议本身:它是如何工作的,如何创建自己的可迭代对象,以及如何使用for..of语句和扩展运算符来使用可迭代对象。然后,你将了解新的内置可迭代类型SetMap。最后,你将通过构建一个使用MapSet以及使用for..of和扩展运算符的 21 点游戏来结束这个单元。

第 23 课. 可迭代对象

在阅读第 23 课之后,你将

  • 了解可迭代对象是什么以及如何使用它

  • 了解如何在可迭代对象上使用扩展运算符来取消对象的分组

  • 了解如何在for..in语句中使用可迭代对象来遍历对象的值

  • 了解如何创建自己的可迭代对象

  • 了解如何自定义内置可迭代对象的行为

JavaScript 在 ES2015 中引入了几个新协议:迭代协议和迭代器协议。这两个协议共同描述了可迭代对象的行为和机制——也就是说,可以产生一系列值并且可以遍历其值的对象。当你想到可迭代对象时,Array可能首先浮现在你的脑海中,但你也可以迭代Stringarguments对象、NodeList,以及我们将在接下来的课程中看到的SetMap。除了所有这些内置的可迭代对象之外,你还可以,正如你将在本课中发现的,创建自己的可迭代对象!

考虑这一点

以下代码记录了数组的所有索引。但如果你想要记录所有值呢?

for (const i in array) {
  console.log(i);
}

23.1. 可迭代对象——它们是什么?

可迭代对象是任何遵循 JavaScript 中引入的迭代协议的对象,该协议在 ES2015 中引入。

常见的内置可迭代对象有StringArray,但SetMap也是。因为所有对象都遵循一个共同的协议,这意味着它们的行为方式相似。你还可以使用此协议来创建自己的可迭代对象或自定义默认对象的行为!

正如我提到的,并非所有可迭代对象都是新的。JavaScript 一直有字符串和数组。但为字符串、数组和其他迭代器行为设定契约的协议是新的。字符串和数组已经更新以使用这个新协议,而像 SetMap 这样的其他对象是新的对象,它们也利用了这个协议。

除了新的可迭代协议之外,我们还得到了一些新的与使用它的对象交互的方法,特别是 for..of 语句和展开操作符。

23.2. for..of 语句

你写过多少次这样的代码?

for (var i = 0; i < myArray; i++) {
  var item = myArray[i]
  // do something with item
}

使用 for..of,你现在可以用这个来实现相同的功能:

for (const item of myArray) {
  // do something with item
}

JavaScript 很早就有了允许你枚举对象键(属性名)的 for..in 语句,但现在有了 for..of 语句,你可以迭代可迭代对象的值,如下一列表所示。

列表 23.1. 比较 for..infor..of
const obj = { series: "Get Programming", publisher: "Manning" };
const arr = [ "Get Programming", "Manning" ];

for (const name in obj) {
  console.log(name);                    *1*
}

for (const name of arr) {
  console.log(name);                    *2*
}
  • 1 系列,出版社

  • 2 编程入门,Manning

这与 Array.prototype.forEach 类似,但它有几个优点。它可以与任何可迭代对象一起使用,而不仅仅是数组。它是一个命令式操作,因此可以优化以比高阶 forEach 方法表现得更好。此外,它可以使用 break 来提前退出。

使用 for..of 的一个最终好处是,你无法从一个非生成器函数中 yield,即使该函数位于生成器内部:

function* yieldAll(...values) {
  values.forEach(val => {
    yield val                       *1*
  })
}
  • 1 语法错误:意外的标识符

你得到这个错误是因为 yield 实际上位于 forEach 的回调箭头函数内部。yield 不会冒泡到堆栈中最接近的生成器函数。如果它在一个非生成器函数内部使用,则是一个语法错误。然而,你可以通过如下使用 for..of 来解决这个问题:

function* yieldAll(...values) {
  for (const val of values) {
    yield val
  }
}

for..of 语句并不是一个颠覆性的改变,但它又是你 JavaScript 新工具箱中的另一个工具。到目前为止,我们一直在探讨处理现有可迭代对象的方法;在下一节中,你将创建自己的可迭代对象。

快速检查 23.1

Q1:

以下 for..of 循环将运行多少次?

for (const x of "ABC") {
  console.log('running')
}

QC 23.1 答案

A1:

3

23.3. 展开操作

JavaScript 中所有可迭代的对象都有一个共同点,那就是它们可以使用展开操作符。展开操作符允许你将单个可迭代的传递视为传递了所有其项目。让我们看看这意味着什么。想象你正在运行一个在线市场。对于任何商品,都有一系列的卖家提供该商品,价格各不相同。你想要向用户展示最低的价格。你可以轻松地获取所有价格组成的数组,但一旦你有了价格数组,你如何找到最低的价格?你想要将这个价格数组传递给Math.min,但这需要你单独传递所有价格,而不是作为一个数组。在展开操作符之前,你可能会使用类似以下的方法:

const prices = // get array of prices
const lowestPrice = Math.min.apply(null, prices);

然而,使用展开操作符可以极大地简化这个过程:

const prices = // get array of prices
const lowestPrice = Math.min(...prices);

而不是将价格数组作为一个单独的参数传递给Math.min,它将数组的所有值作为单独的参数传递。这不仅适用于数组,也适用于任何可迭代的对象,甚至是字符串。然而,在字符串的情况下,它将每个单独的字符作为单独的参数传递。

你可能已经注意到,展开使用了与剩余参数完全相同的语法。这是有意为之,因为展开是剩余参数的完全对立面。当你编写一个期望接收一系列值作为参数的函数时,你可以使用剩余参数将所有提供的值组合成一个数组,如下一个列表所示。

列表 23.2. 使用剩余参数将参数组合成一个数组
function findDuplicates(...values) {
  // gather all values as an array
  // return an array of the duplicates
}

findDuplicates("a", "b", "a", "c", "c");          *1*
  • 1 [“a”, “c”]

findDuplicates函数接受任意数量的参数,并返回重复的参数。内部使用展开将这些值分组到一个数组中。

反过来,如果你已经有一个数组,你不能直接将其传递给findDuplicates函数,因为它只有一个数组,并且正在寻找数组的重复项。然而,你可以取消分组数组,并将它的内容作为单独的参数发送,如下一个列表所示:

列表 23.3. 使用展开取消分组参数
const letters = ["a", "b", "a", "c", "c"]

findDuplicates(...letters);                    *1*
  • 1 [“a”, “c”]

再次强调,这不需要是一个数组来使用展开;它可以是一个任何可迭代的对象,例如一个字符串(或MapSet,你将在本单元的后面学习到),如下面的列表和图 23.1 所示。

列表 23.4. 使用展开取消分组参数
findDuplicates(..."abacc");                 *1*
  • 1 [“a”, “c”]
图 23.1. rest-spread 关系

展开不仅限于函数参数,它还可以用来将可迭代的对象展开到数组字面量中:

const surname = "Isaacks"
const letters = [ ...surname ]
console.log(letters)                     *1*
  • 1 [“I”, “s”, “a”, “a”, “c”, “k”, “s”]

它也不必是唯一的项,而且与剩余参数不同,它甚至不需要是最后一个项:

const easyAs = [ ...'123', 'ABC' ]
console.log(easyAs)                      *1*
  • 1 [ “1”, “2”, “3”, “ABC” ]

你甚至可以将多个展开操作符组合在一起:

const vowels = ['A', 'E', 'I', 'O', 'U']
const consonants = 'BCDFGHJKLMNPQRSTVWXYZ'

const alphabet = [ ...vowels, ...consonants ].sort()

console.log(alphabet.length)             *1*
  • 1 26

注意我们是如何展开一个数组和展开一个字符串的。它们可以是任何类型,只要它们都是可迭代的。结果是包含所有 26 个字母的新数组。

23.3.1. 使用展开操作作为不可变推送

这种技术可以用作Array.prototype.push的不可变形式。有时你可能想向数组中添加一些内容,但不是修改原始数组,而是获取一个包含新值的副本。这在像 Redux.js 这样的库中是一个常见的技巧,在那里你必须获取现有状态和一些数据,并推导出下一个状态,而不修改现有状态。如果现有状态是一个数组,并且你想向数组中添加一个项目,使用push会修改现有状态(当前数组),这可能会导致错误。但你可以使用展开操作将现有数组复制到一个包含新项目的新数组中:

function addItemToCart(item) {
  return [ ...cart, item ]
}

这将创建一个新数组,而不会触及原始数组。为了使用push实现这一点,你必须首先创建数组的副本,然后向副本中推送,然后返回副本:

function addItemToCart(item) {
  const newCart = cart.slice(0)
  newCart.push(item)
  return newCart
}

注意使用展开操作的这个版本是多么简洁。你可以使用箭头函数等来使这种操作更加表达性:

const addItemToCart = item => [ ...cart, item ]

看起来你只是在描述函数的功能。实际上,这个描述本身就是实现。

你也可以使用这种技术来创建数组的浅拷贝。如果你需要对数组执行一些破坏性操作,但又不想修改原始数组,这非常有用:

function processItems(items) {
  copy = [ ...items ]

  // do destructive things to copy without altering items
}

在单元 1 的总结中,你编写了一些辅助函数,createTaginterlace,以使创建标记模板函数变得更加容易。你使用它们两个创建了一个名为htmlSafe的标记模板函数。原始代码在下一列表中重复。

列表 23.5. 单元 1 总结中的原始函数
function createTag(func) {                                *1*
  return function() {
    const strs = arguments[0];
    const vals = [].slice.call(arguments, 1);
    return func(strs, vals);
  }
}

function interlace(strs, vals) {
  vals = vals.slice(0);                                   *2*
  return strs.reduce(function(all, str) {                 *3*
    return all + String(vals.shift()) + str;
  });
}

const htmlSafe = createTag(function(strs, vals){
  return interlace(strs, vals.map(htmlEscape));           *4*
});

const greeting = htmlSafe`<h1>Hello, ${userInput}</h1>`   *5*
  • 1 整个函数只是为了抽象出如何分组除了第一个参数之外的所有参数。

  • 2 获取vals数组的副本。

  • 3 组合字符串模板。

  • 4 对所有插值值进行 HTML 转义。

  • 5 示例用法

我们现在有一些工具可以使这变得更加容易。你使用展开操作和剩余参数重新实现了相同的功能,如下列表所示。

列表 23.6. 单元 1 总结中更新的函数
function interlace(strs, vals) {
  vals = [ ...vals ];                                                     *1*
  return strs.reduce((all, str) => {
    return all + String(vals.shift()) + str;
  });
}

const htmlSafe = (strs, ...vals) => interlace(strs, vals.map(htmlEscape));*2*
  • 1 使用展开操作而非切片复制数组

  • 2 使用剩余参数收集值

现在,你能够将这段代码大大简化。在interlace函数中,你使用展开操作来复制vals而不是使用slice。通过使用剩余参数来收集参数,你甚至不再需要createTag函数。

在此过程中,使用默认函数参数来消除在将值映射到你的 reducer 之前的需求:

function interlace(strs, vals, processer=String) {
  vals = [ ...vals ];
  return strs.reduce((all, str) => {
    return all + processer(vals.shift()) + str;
  });
}

const htmlSafe = (strs, ...vals) => interlace(strs, vals, htmlEscape);

在这里,你让 interlace 函数接受一个第三个参数,称为 processer,默认值为 String,并使用它来处理每个 reducer 中的值。通过这样做,只用两个参数调用 interlace 函数的效果与之前相同,但使用第三个参数(在这种情况下为 htmlEscape)将代替在 reducer 中的每个值上运行该函数。这样就消除了在减少之前迭代整个值列表的需要。

快速检查 23.2

Q1:

日志中以下长度将会是多少?

const a = '123'
const b = ['123']
const c = [1, 2, 3]
console.log([ ...a ].length)
console.log([ ...b ].length)
console.log([ ...c ].length)

| |

QC 23.2 答案

A1:

  1. 3
  2. 1
  3. 3

23.4. 迭代器——查看可迭代对象的内部

任何具有 @@iterator 属性且遵循迭代器协议的对象都是可迭代的。也就是说,一个对象成为可迭代的,当它有一个 @@iterator 属性指向另一个对象,而这个对象是一个迭代器。

¹

术语 @@name 是描述 name 属性符号的简写方式。如果一个对象被说成有 @@foo 属性,那么这是一个简写方式,表示它有 Symbol.foo 属性。

迭代器的目的是生成一系列值。迭代器需要能够按顺序逐个提供每个值。它还需要知道何时完成,以便停止尝试生成值。

迭代器是一个实现了 next 函数的对象。next 函数必须返回一个具有两个属性的对象,valuedone

done 属性表示迭代器是否已经完成了其属性的迭代。展开操作符和 for..of 都会继续在迭代器上调用 next,直到它通过将 done 设置为 true 来指定没有更多值。你永远不需要将 done 设置为 true;但创建一个无限值迭代器是完全合法的。但是,不建议在无限迭代器上使用展开操作符或 for..of,因为它永远不会完成。

next 返回的 value 属性表示迭代器正在生成的下一个值。

考虑你使用展开操作符从一个字符串中获取字符数组示例:

[ ..."Isaacks" ]              *1*
  • 1 [ “I”, “s”, “a”, “a”, “c”, “k”, “s”]

是字符串的 @@iterator 产生了这些值,而不是字符串本身。正是因为字符串有这个 @@iterator 属性,字符串才成为可迭代的。

也就是说,为了使一个对象可迭代,它必须有一个返回新迭代器对象的 @@iterator 方法。

接下来,你将创建一个可以作为对象 @@iterator 的函数。这意味着该函数需要返回一个新的对象,该对象有一个 next 方法,并且 next 方法需要返回另一个具有 donevalue 属性的对象。从一个简单的迭代器开始,它只生成前三个素数:

function primesIterator () {
  const primes = [2, 3, 5]
  return {
    next() {
      const value = primes.shift()
      const done = !value
      return {
        value,
        done
      }
    }
  }
}

现在创建一个使用此作为其迭代器的可迭代对象:

const primesIterable = {
  [Symbol.iterator]: primesIterator
}

const myPrimes = [ ...primesIterable ]              *1*
  • 1 [2, 3, 5]

这可以工作,但这是一个相当繁琐的创建迭代器的方式。有一个更简单的方法。你可能甚至已经注意到了。在函数单元中,我们已经介绍了一种返回具有nextdone属性的对象的函数类型:生成器函数。事实上,生成器既是迭代器也是可迭代的。这意味着你可以直接迭代生成器,或者将其用作@@iterator属性,使另一个对象成为可迭代的。

使用生成器函数重新创建相同的迭代器:

function* primesIterator () {
  yield 2
  yield 3
  yield 5
}

const primesIterable = {
  [Symbol.iterator]: primesIterator
}

const myPrimes = [ ...primesIterable ]       *1*
  • 1 [2, 3, 5]

哇,这容易多了!但是因为生成器本身也是一个可迭代的,所以你可以直接使用它,而无需首先将其设置为Symbol.iterator

[ ...primesIterator() ]             *1*
  • 1 [2, 3, 5]

这里你只是使用生成器创建了一个简单的迭代器来理解它是如何工作的。然而,你可以使用生成器创建自定义迭代器的可能性是无限的。

让我们回到字符串。当你迭代它时,它会按顺序产生每个字符。但为什么是每个字符?为什么不是每个单词?这实际上不是字符串做出的决定;这是由字符串使用的默认迭代器决定的。用你自己的产生单词而不是字符的迭代器覆盖字符串的迭代器:

const myString = Object("Iterables are quite something");
myString[Symbol.iterator] = function* () {
  for (const word of this.split(' ')) yield word;
}
const words = [ ...myString ]                              *1*
  • 1 [“可迭代对象”,是,“相当”的东西]

这里你创建了一个字符串。你用Object调用将其包装起来以创建一个对象字符串;否则,当你更新一个属性时,它不会保留。然后你将字符串的@@iterator设置为你的自己的,它天真地产生单词而不是字符。现在当你展开字符串时,你会得到一个单词数组!

在重写对象的@@iterator时要小心。如果你尝试在其自己的迭代器中迭代对象,你会创建一个无限循环!让我们看看一个例子。比如说,你想创建一个数组,当你迭代它时,它会以相反的顺序产生其值。你可能尝试这样做:

myArray[Symbol.iterator] = function* () {
  const copy = [ ...this ];                          *1*
  copy.reverse();
  for (const item of copy) yield item;
}
const backwards = [ ...myArray ]                     *2*
  • 1 哎呀,你正在尝试在你的迭代器中迭代你自己!

  • 2 未捕获的 RangeError:超出最大调用栈大小

你看,这里发生的事情是在迭代器内部,你使用了[ ...this ],这反过来又尝试迭代this以获取值。这反过来又必须使用迭代器,但你已经在迭代器内部了,所以这是一个递归调用!

经常你会发现你想迭代一个对象的键和值。使用for..infor..of迭代一个或另一个很简单。但要同时迭代两者,大多数人会求助于看起来像这样的代码:

for (const key in Object.keys(obj)) {
  const val = obj[key]
  // Do something with key and val
}

这里你正在迭代对象的键,然后使用每个键来获取相应的值。这并不很优雅。你可以使用生成器创建一个迭代器,它同时迭代这两个:

function* yieldKeyVals (obj) {
  for (const key in obj) {
    yield [ key, obj[key] ];
  }
}

这个生成器接受一个对象,并为每个属性yields 一个包含属性名和属性值的数组。你可以这样使用它:

var address = {
  street: '420 Paper St.',
  city: 'Wilmington',
  state: 'Delaware'
};

for (const [ key, val ] of yieldKeyVals(address)) {
  // Do something with key and val
}

这里你使用for..of来迭代键/值对。然后你使用数组解构来直接获取这些值。相当不错!

假设你正在构建一个社交应用,允许朋友点赞状态更新。你想要列出哪些朋友点赞了某些内容。你已经有一个名为sentenceJoin的函数,它接受一个名字列表并将它们连接起来用于句子:

sentenceJoin(['JD', 'Christina')                               *1*
sentenceJoin(['JD', 'Christina', 'Talan', 'Jonathan'])         *2*
  • 1 JD 和 Christina

  • 2 JD,Christina,Talan 和 Jonathan

问题在于,如果名字列表非常长,你只想列出前两个名字,然后是剩余的朋友数量。你可以创建一个迭代器来做到这一点,如下所示:

function* listFriends(friends) {
  const [first, second, ...others] = friends
  if (first) yield first
  if (second) yield second
  if (others.length === 1) yield others[0]
  if (others.length > 1) yield `${others.length} others`
}

现在,你可以这样格式化你的朋友列表:

const friends = ['JD', 'Christina', 'Talan', 'Jonathan']

const friendsList = [ ...listFriends(friends) ]

const liked = `${sentenceJoin(friendsList)} liked this.`             *1*
  • 1 JD,Christina 和两位其他人喜欢这个。

可迭代和迭代器协议为支持所有类型的可迭代对象奠定了基础。在本课中,我们主要探讨了字符串、数组和自定义可迭代对象。在本单元的其余部分,我们将探讨全新的可迭代对象。

快速检查 23.3

Q1:

  1. 如何使一个对象成为可迭代的?
  2. 生成器对象是可迭代的还是迭代器?

| |

QC 23.3 答案

A1:

  1. 通过设置其@@iteratorSymbol.iterator)属性。
  2. 它既是。

概述

在本课中,你学习了如何使用和创建自己的可迭代对象和迭代器的基础知识。

  • 可迭代是一个具有@@iterator属性的对象。

  • @@iterator属性必须是一个返回新迭代器对象的函数。

  • 迭代器对象必须有一个next方法。

  • 迭代器对象的next方法必须返回一个包含value和/或done属性的对象。

  • value属性是可迭代对象的下一个值。

  • done属性指示是否已迭代所有值。

让我们看看你是否明白了:

Q23.1

正如我们讨论的,在无限可迭代对象上使用展开操作会导致中断,因为它会继续请求值而永远不会停止。编写一个名为take的函数,它接受两个参数:n表示要获取的项目数量,iterable表示从中获取项目的可迭代对象。创建一个无限可迭代对象,并从中获取前 10 个值。如果taken达到之前可迭代对象就耗尽了值,则加分。

第 24 课. 集合

阅读完第 24 课后,你将

  • 了解如何使用和创建集合

  • 了解如何在集合上执行数组操作

  • 了解何时使用数组,何时使用集合

  • 理解 WeakSets 是什么以及何时使用它们

Set是 JavaScript 中的一种新类型对象。集合是一组唯一的数据。它可以存储任何数据类型,但不会存储对同一值的重复引用。集合是可迭代的,因此你可以使用展开操作符和for..of与它们一起使用。集合与数组最为接近;然而,当你使用数组时,你的焦点通常是数组中的单个项目。当处理集合时,你通常是将集合作为一个整体来处理。

考虑这一点

想象你正在制作一个视频游戏,玩家开始时有一组技能,随着玩家遇到新的技能,这些技能会被添加到玩家的技能集中。你将如何确保玩家不会最终拥有重复的技能?

24.1. 创建集合

没有集合的文本表示形式,例如数组或对象的 [ ... ]{ ... },因此必须使用 new 关键字如下创建集合:

const mySet = new Set();

此外,如果你想创建一个具有一些初始值的集合,你可以使用可迭代作为第一个(也是唯一一个)参数:

const mySet = new Set(["some", "initial", "values"]);

现在,这个集合将具有三个字符串作为初始值。每次你使用可迭代参数时,可迭代参数的各个值都会作为集合中的项添加,而不是可迭代本身:

const vowels = new Set("AEIOU");          *1*
  • 1 集合 {“A”, “E”, “I”, “O”, “U”}

字符串 “AEIOU” 是 A-E-I-O-U 字母的可迭代,因此集合最终包含这五个单独的字符作为不同的值,而不是整个字符串作为一个单一值。如果你想用一个单个字符串值初始化一个 Set,你可以通过将其放入 Array 中来实现:

const vowels = new Set(["AEIOU"]);        *1*
  • 1 集合 {“AEIOU”}

你甚至可以使用另一个集合作为可迭代参数来创建一个新的集合。这实际上是一种方便的克隆或复制现有集合的方法:

const mySet = new Set(["some", "initial", "values"]);
const anotherSet = new Set(mySet);

如果传递给集合构造函数的可迭代参数有任何重复值,它们将被忽略,并且只使用每个值的第一个出现:

const colors = new Set(["red", "black", "green", "black", "red"]);      *1*
  • 1 集合 {“red”, “black”, “green”}

如果你用一个非可迭代参数创建一个集合,将会抛出一个错误:

const numbers = new Set(36);          *1*
  • 1 未捕获的类型错误:undefined 不是一个函数

你得到这个错误是因为数字 36 没有具有 Symbol.iterator 函数。错误很令人困惑,但当一个 Set 用一个值初始化时,它首先尝试使用其 Symbol.iterator 迭代该值。如果给定的值没有 @@iterator(例如,数字没有),你会得到一个模糊的 undefined 不是一个函数 错误。

如果你想用一个数字初始化一个包含单个数字的 Set,只需像这样用 Array 包裹数字:

const numbers = new Set([36]);           *1*
  • 1 集合 {36}

现在你已经知道了如何创建集合,在下一节中,我们将转向如何使用它们。

快速检查 24.1

Q1:

以下两个集合之间的区别是什么?

const a = new Set("Hello");
const b = new Set(["Hello"]);

| |

QC 24.1 答案

A1:

  1. 集合
  2. 集合

24.2. 使用集合

大多数时候,数组就足够了。但如果你发现自己需要一组独特的事物,你可能想使用一个集合。你也应该根据你想要对项目列表执行的操作来做出决定。如果你想要在列表上执行的操作更偏向于数组,例如与特定索引的元素交互或使用像 splice 这样的方法,那么你可能想使用数组。但如果你发现 Set 的 API 与你想要执行的操作更一致,例如添加、检查是否存在以及基于值(而不是索引)删除,那么 Set 可能是你的选择。如果你发现自己需要两者的混合,那么在需要对该集合执行数组操作时将其转换为数组可能更容易。

让我们想象你正在制作一个允许角色在区域内移动的视频游戏。你使用一系列瓦片来渲染区域。每次角色移动时,你会得到一组新的瓦片来渲染,以绘制游戏的当前状态。但为了加快游戏的渲染时间,在每一帧中,你只想渲染那些尚未绘制到屏幕上的瓦片。如果你有一个当前渲染的瓦片集合存储,你可以使用 Set.prototype.has 来检查它们是否已经渲染,如下所示:

if ( !frame.has(tile) ) {
  // paint the tile to the screen
}

在这里,你有一个名为 frame 的集合,你正在使用 .has() 来确定瓦片是否已经被绘制到屏幕上。当然,一旦你将瓦片绘制到屏幕上,你将希望将其添加到集合中,以便记住这个瓦片在下一帧已经被绘制。你可以使用 Set.prototype.has 如下操作:

if ( !frame.has(tile) ) {
  // paint the tile to the screen
  frame.add(tile);
}

现在,你也会想要删除那些在当前帧上不再绘制的帧。因此,你需要从你的集合中删除所有不再绘制的瓦片,以及添加所有需要绘制的新的瓦片。编写一个函数来为你完成这项工作,如下一个列表所示。

列表 24.1. 绘制下一帧
function draw(nextFrame) {
  for (const tile of frame) {                 *1*
    if ( !nextFrame.has(tile) ) {             *2*
      frame.delete(tile);                     *3*
    }
  }
  for (const tile of nextFrame) {             *4*
    if ( !frame.has(tile) ) {                 *5*
      // paint the tile to the screen
      frame.add(tile);                        *6*
    }
  }
}
  • 1 使用 for..of 来迭代组成当前帧的所有瓦片。

  • 2 检查下一帧是否有指定的瓦片。

  • 3 如果下一帧包含该瓦片,则移除它。

  • 4 使用 for..of 来迭代下一帧的所有瓦片。

  • 5 检查当前帧是否尚未包含该瓦片。

  • 6 如果当前帧尚未包含该瓦片,则添加它。

好的,让我们想象一下,当你的玩家在游戏中四处旅行时,他们可以收集新的任务。如果玩家已经在他们的任务书中有一个任务,你不希望添加重复的任务。如果你使用数组,你必须想出一个策略来确保不会添加重复的任务,但如果你使用集合,你会免费获得这个功能。

如果你想要提供一个指示器,让玩家知道他们有多少个任务,你可以使用 Set.prototype.size

const questDisplay = `You have ${quests.size} things to do.`

属性 Set.prototype.size 等同于字符串或数组中的 length 属性。此外,你一直在使用的 add 方法与数组的 push 方法非常相似,它将项目添加到数组的末尾项目列表中。但是,delete 函数没有数组对应的函数。你可以很容易地使用 popshift 从数组中删除一个项目。(pop 会删除并返回数组中的最后一个项目;shift 会删除并返回第一个项目,这也会导致其他所有项目的索引下移一个位置。)但是,这些函数是基于位置删除值的,分别是最后一个和第一个。集合的 delete 方法指定从集合中删除一个特定的值,无论其位置如何。这个概念不容易应用到数组上,因为数组可能包含多个位置上的特定值。你可以将数组转换为集合,然后使用 delete 删除项目,但这会产生副作用,即也会使数组去重,这可能不是你想要的。另一方面,集合没有 popshift 的等效方法。但是,集合确实维护插入顺序,因此你可以很容易地将集合转换为数组以获取第一个或最后一个项目:

function pop(set) {
  return [ ...set ].pop();
}

当你使用扩展操作符与 Set 一起使用,如 [ ...set ],我们正在创建一个新的 Array,其中包含集合中所有单独的值作为数组的项目。这个函数会返回集合中的最后一个项目,但不会删除它,因为使用扩展操作符对可迭代对象不会改变可迭代对象。新创建的数组会删除其最后一个项目,但集合不会。要同时从集合中删除最后一个项目,你必须确保你也从集合中 delete 它:

function pop(set) {
  const last = [ ...set ].pop();
  set.delete(last);
  return last;
}

创建一个 shift 函数就像在内部使用 shift 而不是 pop 一样简单:

function shift(set) {
  const first = [ ...set ].shift();
  set.delete(first);
  return first;
}

集合维护其插入顺序,没有方法可以重新排列它们,除非完全清空并按新顺序添加项目。想象一下你正在创建一个游戏并存储一组玩家。在每一轮结束后,你想要将第一个玩家放到最后一个位置,以保持每一轮轮流确定哪个玩家先手。使用数组,你可以像这样组合 shiftpush

function sendFirstToBack(arr) {
  arr.push( arr.shift() );
}

如果你想要创建一个新的集合并具有新的顺序,你可以将其转换为数组,设置顺序,然后返回一个新的集合,如下所示:

function sendFirstToBack(set) {
  const arr = [ ...set ];
  arr.push( arr.shift() );
  return new Set(arr);
}

如果你需要实际改变现有集合的顺序,你可以通过使用 Set.prototype.clear 清空整个集合来实现。但是,没有方法可以一次向集合中添加多个项目。在改变顺序后,为了将项目重新添加到集合中,你需要使用 Set.prototype.add 逐个添加,如下所示:

function sendFirstToBack(set) {
  const arr = [ ...set ];                   *1*
  set.clear();                              *2*
  arr.push( arr.shift() );                  *3*
  for(const item of arr) {                  *4*
    set.add(item);
  }
}
  • 1 首先将集合中的所有项目放入一个数组中。

  • 2 从集合中删除所有项目。

  • 3 重新排序数组。

  • 4 以新的顺序将项目添加回集合。

现在你已经知道了如何使用集合,在下一节中,我们将探讨你何时可能想要使用集合而不是数组,反之亦然。

快速检查 24.2

Q1:

数组方法 Array.prototype.shiftArray.prototype.pop 和集合方法 Set.prototype.delete 之间的基本区别是什么?

QC 24.2 答案

A1:

数组方法 Array.prototype.shiftArray.prototype.pop 分别基于数组中元素的(位置)索引(第一个和最后一个)来移除元素。集合方法 Set.prototype.delete 基于元素的值本身来移除元素。

24.3. 关于 WeakSet 呢?

WeakSetSet 的一种特殊类型。它的唯一目的是以 弱引用 的方式包含对象,这意味着它不会阻止它们被垃圾回收。通常情况下,如果你将一个对象添加到一个数组中,并移除对该对象的全部其他引用,它仍然不符合垃圾回收的条件,因为数组仍然持有对该对象的引用。对于集合来说也是如此。有时你可能想要存储一个对象,同时又不阻止它被垃圾回收。

假设你正在构建一个大型多人在线(MMO)游戏,并想使用一个集合来确定哪些玩家目前正在生成,以防止他们在生成过程中被杀死。你可以像这样将它们添加到 spawning 集合中:

function spawn(player) {
  spawning.add(player);
  // ... do stuff, (possibly set a timeout for N seconds)
  spawning.delete(player);
}

然后,任何试图攻击玩家的东西都可以首先检查玩家是否目前正在生成,然后再向它添加伤害:

function addDamage(player, damage) {
  if (!spawning.has(player)) {
    // add damage to player
  }
}

如果生成是一个普通的集合,那么如果玩家离开游戏或者在生成过程中可能丢失互联网连接,你需要确保从生成集合中移除玩家。这看起来可能并不困难,但如果你有多个集合,它们出于各种原因跟踪玩家,确保在玩家退出游戏时移除所有对玩家的引用可能会变得麻烦。但是,如果我们使用了 WeakSet,那就没有必要了,因为 WeakSet 不会阻止一个元素被垃圾回收。

为了实现这一点,WeakSet 对其包含的任何元素都没有引用。你必须已经有一个对该元素的引用,才能检查 WeakSet 是否包含该元素。这对于我们的用例来说是可行的,因为在你的游戏中,你已经有一个玩家,并想要检查该玩家是否在 spawning WeakSet 中,所以它工作得很好。

由于 WeakSet 对其项目没有引用,因此 WeakSet 不能被迭代。因此,WeakSet 并不是一个可迭代的集合,像 Set 一样。你不能在它上面使用 for..of 或展开操作。WeakSet 也只能包含对象值:不允许原始值,尝试添加一个原始值将会抛出错误。

摘要

在本课中,你学习了如何创建和使用集合,以及为什么你会选择使用集合而不是数组。

  • 一个集合有一个新的字面量,并且必须使用 new 来创建。

  • 你可以使用可迭代对象作为参数来创建一个集合。

  • 你可以通过创建一个新的集合并将现有的集合作为参数来克隆一个集合。

  • 集合是可迭代的,因此可以使用展开和 for..of

  • 你可以使用 Set.prototype.add 向集合中添加一个值。

  • 你可以使用 Set.prototype.has 来判断一个集合是否包含某个值。

  • 你可以使用 Set.prototype.delete 从集合中移除一个值。

  • 你可以使用 Set.prototype.clear 来清空一个集合。

  • 你可以使用 Set.prototype.size 来确定一个集合包含多少项。

  • WeakSet 不是一个可迭代的。

  • WeakSet 只能包含对象。

  • WeakSet 不能阻止其内容被垃圾回收。

  • WeakSet 没有方法来检查其内容。

让我们看看你是否理解了:

Q24.1

创建以下辅助函数,使处理集合更加有用:

  • union—一个函数,它接受两个集合,并返回一个新集合,其中包含两个集合的所有值
  • intersection—一个函数,它接受两个集合,并返回一个新集合,其中只包含两个集合都有的值
  • subtract—一个函数,它接受两个集合,并返回一个新集合,其中包含第一个集合的所有值,但不包括第二个集合中的任何值
  • difference—一个函数,它接受两个集合,并返回一个新集合,其中只包含它们都不在的值(交集的相反)

更进一步。为了更有趣,你可以更新这些函数,使其能够处理任意数量的集合,而不仅仅是两个。

第 25 课. Maps

在阅读完第 25 课(lesson 25)后,你将

  • 了解如何创建 Map

  • 如何将普通对象转换为 Map

  • 如何在 Map 上添加和访问值

  • 如何迭代 Map 的键和值

  • Map 的解构方式

  • 理解何时使用 Map 而不是 Object 更有意义

  • 理解为什么使用 WeakMap 而不是 Map 更有意义

Set 类似,Map 是 JavaScript 中的一种新类型对象。不要与 Array.prototype.map 混淆,后者是一个高阶数组方法,Map 是 JavaScript 在 ES2015 中引入的另一种可迭代类型。Map 在 JavaScript 中类似于具有 的通用对象。但 Map 可以被迭代,而对象不能。普通对象也仅限于只能将字符串值作为键;^([1]) 相反,Map 可以使用任何数据类型作为键,甚至可以是另一个 Map

¹

技术上,字符串和符号。

考虑这一点

当你需要一个不需要为每个数据项指定合格标识符的数据容器时,你可能需要使用数组。当你需要存储数据并能够根据字符串标识符检索特定数据时,你可能需要使用对象。但如果你需要根据更复杂的标识符(如 DOM 节点或其他不能用作对象键的东西)来检索数据,你该怎么办?

25.1. 创建映射

Set一样,没有Map的文本表示形式,它们必须使用new关键字创建,如下所示:

const myMap = new Map();

记得上一课中你可以用一个数组参数实例化一个Set,以定义集合的初始值吗?你可能认为,因为Map是一系列键和值,你可以通过传递一个单个对象参数来使用初始值实例化一个Map。但这并不是事实。但是如果你这么想,这很有道理。数组可以包含任何集合可以包含的数据类型,但对象的键限于字符串,而Map的键可以是任何数据类型。所以,为了使用初始值实例化一个Map,你使用一个包含键/值对小数组的大的数组,如下所示:

const myMap = new Map([
  ["My First Key", "My First Value"],
  [3, "My key is a number!"],
  [/\S/g, "My key is a Regular Expression object!"]
]);

注意你如何使用不同类型的对象作为映射的键:首先是一个字符串,然后是一个数字,接着是一个RegExp对象。所有这些都是映射的有效键,并且它们不会像对象那样被转换为字符串。

如果你发现自己想要将一个Object转换为Map,你可以这样做:

const myMap = new Map(Object.keys(myObj).map(key => [ key, myObj[key] ]))

在这里,你使用了Object.keys来获取myObj的所有属性的数组。然后你使用了Array.prototype.map将键的数组转换为键/值对的数组。然后,键/值对被用作Map构造函数的参数。

不要因为你在使用Array.prototype.mapMap结合而感到困惑或陷入其中。数组上的map方法是将一个数组的值映射到新数组中转换后的值的一个操作。另一方面,Map对象将键映射到值。它不是一个操作或转换,而是一个数据存储,允许你根据键访问值。你给Map一个键,然后得到一个值;从某种意义上说,它将键映射到值。

快速检查 25.1

Q1:

以下哪些是创建新Map的有效方式?

const a = new Map({ foo: "bar" })
const b = new Map([ "foo", "bar" ])
const c = new Map([[ "foo", "bar" ]])
const d = new Map([{ foo: "bar" }, []])
const e = new Map()

| |

QC 25.1 答案

A1:

a 和 b 都是无效的。c、d 和 e 都是有效的。

25.2. 使用映射

在这个时候,你可能想知道:“但是Map不也是Object吗?JavaScript 是否与其他对象不同对待它们?”为了回答这个问题,我们首先需要定义一些术语。在本课中,当我将MapObject进行比较时,我一直在暗示通常所说的POJO(纯旧 JavaScript 对象),这意味着一个用作数据结构的普通Object,其属性用作键和值。Map的键不是其属性。Map就像所有其他对象一样扩展自Object,并且其属性必须是字符串。但是映射内部存储其键和值,而不是作为属性,这就是它能够使用任何数据类型的原因。

如果你使用 Object.keys 来获取映射的属性,你不会得到你设置的键:相反,你会得到一个空数组:^([2])

²

即使 Map 确实有 size 等属性,但它们没有被列出,因为它们在 Map 原型上,而不是在特定实例上。

Object.keys(myMap);          *1*
  • 1 返回 []

如果你确实想获取 Map 的键(不是属性),你可以像这样使用 Map.prototype.keys

myMap.keys();                 *1*
  • 1 { “我的第一个键”,3,/\S/g }

这不会返回键的数组;相反,它返回一个迭代器,允许你迭代键。但请记住,如果需要,你可以轻松地将迭代器转换为数组:

[ ...myMap.keys() ]       *1*
  • 1 [ “我的第一个键”,3,/\S/g ]

映射也有 Map.prototype.values 用于获取,正如你所猜的,它返回一个迭代器,而不是数组:

myMap.values();           *1*
  • 1 { “我的第一个键”,3,/\S/g }

实际上,集合(Set)也有这两种方法。但 Set.prototype.keysSet.prototype.values 返回相同的内容,因为集合不使用键,只使用值。你实际上会注意到 MapSet 的 API 非常一致,但并不完全相同。例如,它们都有 size 属性以及 表 25.1 中显示的相同方法。

表 25.1. Map 和 Set 共享的常见方法
方法 描述
clear 从映射(或集合)中移除所有值。
delete 从映射(或集合)中移除指定的值。
entries 返回一个迭代器,用于访问所有键和值。
forEach 与数组上的相同方法类似。遍历映射的所有键和值。
has 检查映射是否具有给定的键(或集合是否具有给定的值)。
keys 返回一个迭代器,用于访问所有键。
values 返回一个迭代器,用于访问所有值。

然而,SetSet.prototype.add 方法用于添加值,而 Map 使用令人困惑的命名 Map.prototype.set 方法来添加条目:

myMap.set("My next key", "My next value");

Map 还有一个 Map.prototype.get 方法用于检索特定键的值:

myMap.get("My next key");             *1*
myMap.get("Some other key");          *2*
  • 1 “我的下一个值”

  • 2 undefined

我不确定为什么 TC39 委员会走这么远,确保 API 的一致性,甚至包括在 Set 上包含冗余的 keys 方法,但最终却未能实现完全相同的 API。

值得注意的是,虽然 Set 上的方法处理参数作为值,例如 delete(value),但 Map 处理键,例如 delete(key)

快速检查 25.2

Q1:

最后两行代码有什么区别?

const myMap = new Map([[ "a", 1], ["b", 2]]);
Object.keys(myMap);
myMap.keys();

| |

QC 25.2 答案

A1:

  • Object.keys(myMap) 返回映射的自身可枚举属性(它没有)。
  • myMap.keys() 返回与存储数据关联的键(“a”和“b”)。

25.3. 何时使用映射

你可能会问,“我现在是否应该每次需要键和值时都使用Map?”答案可能是否定的。大多数时候,你可能仍然希望使用常规对象。当你需要传递值的结构时,常规对象更轻量级且更具表现力。

例如,如果你有一个可以指定选项如widthheight的函数。这样做使用对象解构就像这样:

function renderWithOptions({ width, height, ... }) {
  // do something with width, height and other options
}

你不能通过键的名称解构Map;再次强调,这会假设Map的所有键都是字符串。如果Map的键是某种对象,比如日期呢?这样解构是不可能的。Map以及所有可迭代对象都可以解构,但使用数组风格的解构:

const options = new Map();
options.set('width', 400);
options.set('height', 90);
options.set(new Date(), 'now');

const [a, b, c] = options;
console.log(a);                *1*
console.log(b);                *2*
console.log(c);                *3*
  • 1 [“width”, 400]

  • 2 [“height”, 90]

  • 3 <日期对象>, “now”]

你可以使用嵌套数组解构来访问键和值,如下所示:

const [ [ widthKey, width ], [ heightKey, height ] ] = options;
console.log(widthKey);               *1*
console.log(width);                  *2*
console.log(heightKey);              *3*
console.log(height);                 *4*
  • 1 “width”

  • 2 400

  • 3 “height”

  • 4 90

这不仅更加冗长,还假设宽度是Map中的第一个项目,高度是第二个。这是因为这种解构类型基于索引或位置,而不是基于键或属性的名称:

const [ [ heightKey, height ], [ widthKey, width ] ] = options;
console.log(heightKey);            *1*
console.log(height);               *2*
console.log(widthKey);             *3*
console.log(width);                *4*
  • 1 “width”

  • 2 400

  • 3 “height”

  • 4 90

普通对象确实有一些限制,这就是Map介入的地方。普通对象有键,因此你可以通过特定的值来组织你的数据,但对象不保证任何特定的顺序。另一方面,数组确实保证它们的顺序,但你只能通过索引访问数组中的值,而不是通过特定的标识符。使用映射你可以做到这两点。

因此,如果你需要迭代键和值且顺序很重要,请使用Map。即使顺序不重要,使用Map迭代键和值也更直接。

让我们看看区别。比如说,你在网站上使用分面搜索。你知道,就像那种允许你根据价格、品牌或颜色等分面来缩小搜索范围的搜索。你想要列出用于搜索的所有方面,并按如下所示显示它们:图 25.1。

图 25.1. 显示搜索方面的键和值

为了显示它们,你需要迭代每个方面(键和值)并将它们添加到屏幕上。如果你使用Map来存储方面,你可以这样迭代它们:

for (const [name, value] of facets) {
  // render facet name and value
}

如果你将方面存储在普通对象中,要迭代它们,你需要额外的步骤:

for (const name of Object.keys(facets)) {
  const value = facets[name];
  // render facet name and value
}

对象的另一个限制是它们的属性必须是字符串。有时你可能需要将对象用作键。比如说,你想创建一个名为 Singleton 的函数,它可以将任何构造函数转换为单例实例.^([3]) 在设计 Singleton 对象时通常需要特别注意,以确保始终只创建一个实例——通常使用静态 getInstance 方法。但你可以使用 Map 来轻松地为任何类型的对象添加此功能,如下面的列表所示。

³

Singleton 是一个只能有一个实例的构造函数/类。见 en.wikipedia.org/wiki/Singleton_pattern

列表 25.1. 一个保证任何对象类型单例实例的模块
const instances = new Map();                                  *1*
export default function Singleton(constructor) {              *2*
  if (!instances.has(constructor)) {                          *3*
    instances.set(constructor, new constructor());            *4*
  }
  return instances.get(constructor);                          *5*
}
  • 1 创建一个 map 来存储每个构造函数的单例实例。这个 map 不会被导出,并且对应用程序的其他部分是隐藏的。

  • 2 Singleton 函数接受一个构造函数作为参数。

  • 3 首先检查 map 是否已经为该构造函数创建了实例

  • 4 如果没有,它将为构造函数创建一个实例,并将该实例添加到 map 中,构造函数作为键。

  • 5 获取并返回给定构造函数的单例实例。

这就是所有需要的。在这里,你使用一个名为 Singleton 的函数,它接受任何对象的构造函数。它使用一个 map 来存储构造函数到实例的映射。它首先检查是否已经为给定的构造函数创建了一个实例,如果没有,就创建一个。然后它返回这个实例。这确保了始终只创建一个实例:

import Singleton from './path/to/single/module';
Singleton(Array)                                    *1*
Singleton(Array).length                             *2*
Singleton(Array).push("new value")                  *3*
Singleton(Array).push("another value")              *3*
Singleton(Array).length                             *4*
const now = Singleton(Date)
setTimeout(() => {
  const later = Singleton(Date)
  now === later                                     *5*
}, 10000)
  • 1 创建一个空数组

  • 2 数组长度为零

  • 3 向数组添加两个元素

  • 4 数组长度为两个

  • 5 返回 true,因为现在和以后都是同一个对象

这只是许多你可能想将对象用作键的场景之一。你可能需要 DOM 节点来注册它们自己或绑定数据到 DOM 节点:

const domData = new Map()
function addDomData(domNode, data) {
  domData.set(domNode, data);
}
function getDomData(domNode) {
  domData.get(domNode);
}

或者你可能想将数据绑定到对象上,而不改变对象本身。这可能是视频中的字符、DOM 节点,或者你不应该更改的框架中使用的对象。任何你想存储关于另一个对象的数据,而这些数据又不在对象本身之外时,Map 都是非常合适的。

想象你正在构建一个房地产网站。你正在处理多个列表服务;每个服务都为你提供了一组待售的列表。每个列表都有一个列表 ID (listingId),为了避免与其他提供者的列表 ID 发生冲突,每个列表还有一个多重列表 ID (mlsId)。你将展示特定区域的全部列表,这意味着你将需要同时将来自多个提供者的列表存储在内存中。为了识别一个列表,你需要使用 listingIdmlsId。正因为如此,你可能认为使用类似 [mlsId, listingId] 的数组作为每个列表的键来存储在 Map 中会更简单。但这并不理想,因为要从 Map 中获取一个列表,你需要引用你用来添加它的确切数组,而不仅仅是任何具有正确 mlsIdlistingId 的数组:

const listings = new Map()
listings.set(['mls37', 'listing29'], /* ... some listing */ )

// ...

listings.has(['mls37', 'listing29'])          *1*
  • 1 false

为什么列表 Map 告诉你它没有你已知的存在的那条记录?即使你使用了正确的 mlsId 和正确的记录 ID,你创建了一个包含它们的新的数组,这是一个不同的对象。Map 是查看对象本身,而不是查看对象的内容。你提供给 Map 作为键的对象必须是完全相同的对象。你可以将此视为对象必须满足严格等于 === 的条件。但这也不是完全正确的,因为即使在 Map 中,你也可以使用 NaN 作为有效的键,尽管 NaN !== NaN

看看这段代码。它允许在对象上设置任何键和值。但如果键来自不受信任的来源呢?你怎么知道键不会变成在对象如 toStringproto 等上设置会危险的东西?

const data = {};
function set(key, val) {
  data[key] = val;
}

通过使用 Map 而不是 Object,它可以避免这样的问题,因为 Map 的键不是它的属性,因此不会发生冲突并覆盖内置属性。

快速检查 25.3

Q1:

myObjmyMap 中解构的 widthw 常量之间有什么区别?

 const myObj = {}, myMap = new Map();
 myObj['width'] = 400;
 myObj['height'] = 50;
 myMap.set('width', 400);
 myMap.set('height', 90);

 const { height, width } = myObj;
const [ h, w ] = myMap;

| |

QC 25.3 答案

A1:

width 的值如预期为 400,但 w 的值为 ["height", 90]

25.4. 关于 WeakMap 呢?

与你在第 21 课中学到的 WeakSet 类似,WeakMapMap 的一个不可迭代的子集。但 whereas the WeakSet 对其值有弱引用,WeakMap 对其键有弱引用。此外,WeakMap 只能以对象作为其键。WeakMap 不会阻止其键成为垃圾回收的候选对象。由于 WeakMap 不可迭代,因此无法获取 WeakMap 所有键的列表。同样,除非你已经有了对该键的引用,否则无法检查 WeakMap 是否具有特定的键。一旦没有更多对该键的引用,它将不再可通过 WeakMap 访问,并将被垃圾回收。

当你试图避免内存泄漏且不需要迭代 Map 中的所有键/值,但只需要在已有键的引用的情况下访问值时,你可能想使用 WeakMap。例如,你可能想存储有关某些对象的元数据。也许这些对象是游戏的登录玩家,他们可能会注销。或者,也许这些对象是随着 UI 变化可能从 DOM 中移除的 DOM 节点。如果你在常规 Map 中根据这些对象存储元数据,你将创建内存泄漏,因为 Map 对这些对象的引用将阻止它们被垃圾回收。WeakMap 不会阻止它们被垃圾回收,因此不会创建内存泄漏。但是,如果你需要使用 Map,因为你需要一些额外的功能,如迭代,你仍然可以使用它;你只需要在必要时管理从 Map 中删除对象。

摘要

在本课中,你学习了如何创建和使用 Map,以及为什么你会选择使用它而不是常规对象。

  • 要使用键和值创建一个新的 Map,你使用数组对数组作为参数,而不是对象。

  • MapSet 有许多相同的属性和方法。

  • 你使用 Map.prototype.set(key, value)Map 添加新的键/值对。

  • Map 的键在内部存储,与其属性无关。

  • Map 使用数组风格的解构,而不是对象风格的解构。

  • Map 的迭代比常规对象更优雅。

  • Map 保证其顺序,与对象不同。

  • Map 可以有任意数据类型作为键。

  • WeakMap 必须有一个对象作为键。

  • WeakMap 不会阻止其键被垃圾回收。

  • WeakMap 不可迭代。

让我们看看你是否掌握了这些:

Q25.1

编写以下三个辅助函数以修改 Map

  1. sortMapByKeys——一个返回按其键排序的 Map 副本的函数
  2. sortMapByValues——一个返回按其值排序的 Map 副本的函数
  3. invertMap——一个返回键和值反转的 Map 副本的函数

在实际应用中,这些函数需要考虑到,根据Map的工作定义,它们的键必须是唯一的,而它们的值可以包含重复项。为了简化这个练习,假设这些函数将只操作具有唯一值的Map

第 26 课. 项目:黑杰克

在这个项目中,您将构建一个类似于图 26.1 所示的 21 点(黑杰克)游戏。

图 26.1. 黑杰克游戏

一副牌有许多集合。牌组是一组牌,每位玩家的手牌也是一组牌。您还将使用映射,并最终使用生成器创建一个函数,以减慢迭代器的循环速度,使其可以在屏幕上动画化。

注意

您将使用本书附带代码中的起始文件夹来开始您的项目。如果在任何时候您遇到了困难,您也可以查看包含完成游戏的最终文件夹。起始文件夹是一个已经设置好以使用 Babel 和 Browserify 的项目(见第 1 课–第 3 课);您只需运行 npm install 来设置环境。如果您还没有阅读单元 0,您应该在完成这个项目之前先阅读。还有一个包含的 index.html 文件:这是游戏将运行的地方。它已经包含了所有需要的 HTML 和 CSS;您只需在将 JavaScript 文件打包后,在浏览器中打开它。src 文件夹是您将放置所有 JavaScript 文件的地点;其中已经包含了一些。dest 文件夹是运行 npm run build 后捆绑的 JavaScript 文件将存放的地方。您需要记住,每次您对代码进行更改时,都要运行 npm run build 来编译您的代码。

您的项目将从已经创建的一些模块开始,具体是一个元素模块和一个模板模块。为了使游戏代码易于理解,您将继续创建处理游戏特定部分的模块。您将首先构建一个卡片模块,该模块将处理所有与创建牌组、洗牌或计算玩家手牌数量相关的困难任务。然后您将创建一个 utils 模块,该模块将存储您需要的辅助函数。最后,您将使用所有这些模块在您的主 index 文件中编排游戏。由于您正确地使用了模块,index 文件应该相当简单且易于理解。

26.1. 牌和牌组

无论如何,这是一个牌类游戏,所以先从创建和存储您的牌开始。每张牌将是一个简单的对象,包含一个花色和一个面值。为了帮助创建牌,您可以存储可用的花色和面值的集合。创建一个名为 src/cards.js 的文件,并使用一个Set来存储所有可能的牌花色,如下所示。

列表 26.1. src/cards.js
const suits = new Set(['Spades', 'Clubs', 'Diamonds', 'Hearts']);

您还可以使用一个集合来存储所有可能的牌面值,如下所示。

列表 26.2. src/cards.js
const faces = new Set([
  '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
]);

你还需要一种方法来确定每个面值。为此,你可以使用一个Map,如下所示。

列表 26.3. src/cards.js
const faceValues = new Map([
  ['2', 2], ['3', 3], ['4', 4], ['5', 5], ['6', 6], ['7', 7], ['8', 8],
  ['9', 9], ['10', 10], ['J', 10], ['Q', 10], ['K', 10]
]);

在这里,你使用一个映射来存储每个牌面的面值,除了 A 牌。由于 A 牌的值是上下文相关的,可以是 1 或 11,你不能用这种方式存储它的值,而必须以不同的方式处理。

游戏将要渲染牌。每张牌可以显示为面朝下或面朝上。牌将从牌堆传递给每位玩家,因此跟踪哪些牌被翻转可能会很棘手。不过,使用Map会使这变得简单。你可以将实际的牌作为Map的键,是否翻转作为值。这意味着只要你有卡片的引用,你就可以确定它是面朝上还是面朝下,如下面的列表所示。

列表 26.4. src/cards.js
export const isCardFlipped = new Map();

export function flipCardUp(card) {
  isCardFlipped.set(card, true);
}

export function flipCardDown(card) {
  isCardFlipped.set(card, false);
}

现在编写一个创建牌堆的函数,如以下列表所示。

列表 26.5. src/cards.js
export function createDeck() {
  const deck = new Set();
  for (const suit of suits) {
    for (const face of faces) {
      deck.add({ face, suit });
    }
  }
  shuffle(deck);
  return deck;
}

这个函数很简单。你首先为牌堆创建一个新的Set。然后你只是使用for..of循环遍历所有花色和面值,将牌添加到牌堆中,以包含所有可能的组合(确切地说,是 52 种)。你做的最后一件事是调用shuffle(deck)。任何牌局在开始使用之前都需要洗牌,黑杰克也不例外,所以你需要编写洗牌函数。

Set维护它们的条目顺序,但它们不存储键,这意味着没有与值关联的索引。正因为如此,为了洗牌,你需要使用一个数组。

列表 26.6. src/cards.js
export function shuffle(deck) {
  const cards = [ ...deck ];                                     *1*
  let idx = cards.length;
  while (idx > 0) {
    idx--                                                        *2*
    const swap = Math.floor(Math.random() * cards.length);       *3*
    const card = cards[swap];                                    *4*
    cards[swap] = cards[idx];                                    *5*
    cards[idx] = card;                                           *6*
  }
  deck.clear();                                                  *7*
  cards.forEach(card => deck.add(card));                         *8*
}
  • 1 你使用展开操作将集合的所有值放入一个数组中。

  • 2 当前牌的索引

  • 3 获取一个随机索引,以与你的当前牌交换。

  • 4 获取你的交换索引处的牌。

  • 5 将你的交换索引处的牌设置为当前牌。

  • 6 将你的当前索引处的牌设置为交换的牌。

  • 7 在添加洗好的牌之前清空集合。

  • 8 将洗好的牌按新顺序放回牌堆中。

在这个函数中,你首先使用展开操作将所有卡片从集合中提取到一个数组中。然后你遍历数组的每个索引,将当前索引处的卡片与随机索引处的另一张卡片交换。同一个索引可能会被选中多次进行交换,这是可以的。这会导致卡片数组随机洗牌。最后,你需要将它们全部放回牌堆中。你必须首先清空牌堆,因为每张牌只能出现在集合中一次。

现在你有了创建和洗牌的方法,接下来你需要做的是将牌分发给每位玩家。当你从牌堆中发牌时,通常是从顶部开始发。正如你所知,数组有一个内置的用于此类操作的pop方法。集合没有对应的方法,所以你需要自己构建,如以下列表所示。

列表 26.7. src/cards.js
export function pop(deck) {
  const card = [ ...deck ].pop();
  isCardFlipped.set(card, true);
  deck.delete(card);
  return card;
}

在这个pop函数中,你使用Arraypop来获取牌堆中的最后一张牌。然后你默认牌是正面朝上。大多数游戏都是背面朝上发牌,但在这个游戏中你几乎总是希望牌是正面朝上,所以默认为这种方式。你使用delete来从牌堆中移除牌,因为它显然不能同时存在于玩家手中和牌堆中。最后你返回这张牌。

在二十一点游戏中,每位玩家开始时有两张牌。在你的游戏中,每位玩家的牌手也将用Set来表示。编写一个小的函数,从牌堆中为给定的牌手发两张牌,如下所示。

列表 26.8. src/cards.js
export function dealInitialHand(hand, deck) {
  hand.add(pop(deck));
  hand.add(pop(deck));
}

二十一点的全部要点是使你的牌的总数尽可能接近 21,但不能超过,因此你需要一个函数来计算牌手的总面值。你可以通过迭代所有牌并检查每张牌的面值与之前创建的faceValues映射来计算面值总和。但对于 A 来说,它可以代表 1 或 11。因此,在所有牌的初始循环中,你可以为每个 A 加 1,并跟踪每个 A。在初始循环之后,当牌的总数仍然小于或等于 21 时,可以为每个 A 额外加 10,如下所示。

列表 26.9. src/cards.js
export function countHand(hand) {
  let count = 0;                                          *1*
  const aces = new Set();                                 *2*
  for (const card of hand) {
    const { face } = card;
    if (face === 'A') {                                   *3*
      count += 1;                                         *3*
      aces.add(card);
    } else {
      count += faceValues.get(face);                      *4*
    }
  }
  for (const card of aces) {
    if (count <= 11) {                                    *5*
      count += 10;                                        *5*
    }
  }
  return count;
}
  • 1 从零开始计数。

  • 2 创建一个 Set 来跟踪所有的 A。

  • 3 对于每个 A,加 1,并跟踪 A。

  • 4 如果不是 A,则从faceValues映射中添加值。

  • 5 对于所有的 A,只要总数不超过 21,就额外加 10。

这就是在这个牌模块中你需要的一切。你现在有了创建牌堆、洗牌、将初始两张牌发到牌手手中以及计算牌手中牌的总价值的函数。你还有一个方法来跟踪给定的牌是正面朝上还是反面朝下。在下一节中,你将编写一个函数来延迟 CPU 的决定,以便玩家可以实时看到正在发生的事情。

26.2. 使 CPU 的回合足够慢以便可以看到

游戏将通过允许用户从牌堆中抽牌开始,直到用户将控制权交给庄家,此时庄家将能够从牌堆中抽牌。由于庄家是 CPU,它将能够非常快地做出决定,因此你需要减慢它们的速度,以便让人类玩家看到正在发生的事情。

你可以通过使用生成器函数并在需要暂停时使用yield来实现这一点。你需要另一个函数来迭代你的生成器,并在每次出现yield时暂停。创建文件src/utils.js并添加如下所示的函数。

列表 26.10. src/utils.js
export function wait(iterator, milliseconds, callback) {
  const int = setInterval(() => {                            *1*
    const { done } = iterator.next();                        *2*
    if (done) {                                              *3*
      clearInterval(int);                                    *4*
      callback();                                            *4*
    }
  }, milliseconds);                                          *1* *5*
}
  • 1 使用毫秒参数创建一个新的间隔。

  • 2 从迭代器中获取下一个迭代。

  • 3 检查迭代器是否完成。

  • 4 如果迭代器已完成,则清除间隔并调用回调函数。

  • 5 使用毫秒参数创建一个新的间隔。

wait 函数接受三个参数:一个迭代器对象、迭代之间等待的毫秒数和一个回调函数。你使用带有给定 milliseconds 参数的 setInterval。在你的间隔函数内部,你传递给 setInterval 的箭头函数中调用 iterator.next()。这将导致迭代器尝试每 milliseconds 毫秒检索下一个值。你检查迭代器的 done 值,如果迭代器已经完成,则清除间隔。现在这可以与任何迭代器一起工作,在每次迭代之间产生暂停,但如果你使用生成器,它将允许你在使用 yield 时指定何时暂停函数的执行。

例如,当使用 26.11 列表 中的 wait 函数时,你会在一秒后记录 A。然后由于你调用了 yield,它会等待 wait 函数的下一个间隔。然后它会记录 B 和 C,但在记录 D 之前会再等待一秒,因为你使用了另一个 yield

列表 26.11. 使用 wait 函数
function* example() {
  console.log('A');                       *1*
  yield;                                  *2*
  console.log('B');                       *3*
  console.log('C');                       *3*
  yield;                                  *4*
  console.log('D');                       *5*
}
wait(example(), 1000, () => {
  console.log('Done.')                    *6*
})
  • 1 ‘A’ 将会在 1 秒后记录。

  • 2 你又引入了另一个延迟。

  • 3 ‘B’ 和 ‘C’ 将会在 2 秒后记录。

  • 4 你又引入了另一个延迟。

  • 5 ‘D’ 将会在 3 秒后记录。

  • 6 示例生成器完全完成后将记录 ‘Done。’

之后,当庄家决定从牌堆中抽取牌时,你会使用这个 wait 函数。你会使用 yield 来延迟下一个决策,以便有足够的时间为玩家手中添加的每一张牌进行动画处理。

26.3. 组装组件

现在你已经处理好了卡片模块和一些必要的工具,你需要将它们全部组合起来以完成游戏。首先导入你主要游戏逻辑所需的所有内容。创建文件 src/index.js 并添加以下导入。

列表 26.12. src/index.js
import { cardTemplate } from './templates';
import { wait } from './utils';

import {
  dealerEl, playerEl, buttonsEl, updateLabel, status, render, addCard
} from './elements';

import {
  createDeck, pop, countHand, dealInitialHand, flipCardDown, flipCardUp
} from './cards';

你导入的东西确实很多!通过将你的逻辑分成连贯的模块,将所有东西联系起来的粘合代码(主要游戏逻辑)将会简单得多。你将 utils.js 和 cards.js 文件中的所有内容都写在一起。templates.js 和 elements.js 主要关注与 DOM(文档对象模型)的交互。这些文件中的逻辑与迭代器关系不大,所以我省略了它们的详细内容,但你可以自由地浏览代码。

打开一副牌是玩牌游戏的第一步。所以使用你导入的函数创建一副牌,如下所示。

列表 26.13. src/index.js
const deck = createDeck();                    *1*
  • 1 创建一个新的包含 52 张洗好的牌对象的 Set

现在创建几个 Set 用于庄家和玩家的手牌,如下所示。

列表 26.14. src/index.js
const dealerHand = new Set();
dealInitialHand(dealerHand, deck);
flipCardDown([ ...dealerHand ][0]);           *1*

const playerHand = new Set();
dealInitialHand(playerHand, deck);
  • 1 将发牌员的第一张牌面朝下翻转。

在这里,你为每个玩家的手牌创建了一个新集合。你使用了之前创建的 dealInitialHand 函数来为每个玩家发两张初始牌。你将所有玩家的牌面朝上,这样他们就能看到自己手中的牌。你将发牌员的第一张牌面朝下,并将他们的第二张牌面朝上,就像实际的赌场发牌员那样,如图 26.2 所示。

图 26.2. 发牌员的初始手牌

现在为了生成那个截图,你需要渲染每一只手。你可以使用从元素模块导入的 render 函数来完成这个操作,如下列表所示。

列表 26.15. src/index.js
render(dealerEl, dealerHand);
render(playerEl, playerHand);

好了,这应该会在屏幕上渲染出初始游戏,但还没有任何交互性。你需要编写一些函数,允许用户通过决定要 来玩游戏,并且你需要编程让发牌员也这样做。从发牌员开始,希望你之前编写的 wait 函数仍然记忆犹新。

创建以下生成器函数。

列表 26.16. src/index.js
function* dealerPlay() {
  dealerEl.querySelector('.card').classList.add("flipped");       *1*
  while( countHand(dealerHand) < countHand(playerHand) ) {        *2*
    addCard(dealerEl, dealerHand, pop(deck));                     *3*
    yield;                                                        *4*
  }
  if ( countHand(dealerHand) === countHand(playerHand) ) {        *5*
    if (countHand(dealerHand) < 17) {                             *5*
      addCard(dealerEl, dealerHand, pop(deck));
      yield;                                                      *6*
    }
  }
}
  • 1 将发牌员的所有牌面朝上翻转。

  • 2 在发牌员的手牌小于玩家的手牌时继续循环。

  • 3 从牌堆中再抽一张牌。

  • 4 延迟足够长,以便动画化添加到发牌员手牌的牌。

  • 5 如果发牌员的手牌与玩家的手牌相同且总分小于 17,再抽一张牌。

  • 6 等待最后一张牌动画化完成。

在这个生成器函数中,你首先翻转发牌员的所有牌,这样玩家就能看到发牌员有什么牌。然后发牌员应该继续加牌,直到他们的总分达到玩家的总分。你不需要担心玩家的手牌超过 21,因为如果玩家爆牌(超过 21),游戏将在发牌员需要操作之前结束。

一旦发牌员与玩家打平,如果总分小于 17,发牌员应该再抽一张牌以尝试赢得比赛。如果分数是 17 或更高,发牌员应该接受平局(推牌),因为再抽一张牌的风险太高。

在这个生成器函数中,每当发牌员抽一张牌时,你使用 yield。当与上一节中你编写的 wait 函数结合使用时,这将导致一个足够长的延迟,以便在发牌员需要决定是否再抽一张牌之前,让牌在屏幕上动画化。这将创建一个很好的效果,即随着发牌员的操作,牌被添加到屏幕上。

现在你只需要一个小的函数,将这个生成器与你的 wait 函数结合起来,如下一列表所示。

列表 26.17. src/index.js
function dealerTurn(callback) {
  wait(dealerPlay(), 1000, callback);
}

现在我们把注意力转向用户。正在玩游戏的用户将有两个选择,要“击”还是要“停”。所以你需要为每种情况编写一个函数。让我们从“击”开始。

列表 26.18. src/index.js
function hit() {
  addCard(playerEl, playerHand, pop(deck));
  updateLabel(playerEl, playerHand);
  const score = countHand(playerHand);
  if (score > 21) {
    buttonsEl.classList.add('hidden');
    status('Bust!');
  } else if (score === 21) {
    stay();
  }
}

在这个 hit 函数中,你首先从牌堆中给玩家手中加一张牌。然后调用 updateLabel。这会将文本从得分改为黑杰克或爆牌,如果玩家得分达到 21 或爆牌。然后检查得分,如果玩家爆牌,隐藏按钮并更新游戏状态。否则,如果玩家正好得到 21 分,则自动调用 stay 并将游戏转为庄家。不过,你还没有编写那个 stay 函数,所以你需要在下一个列表中完成它。

列表 26.19. src/index.js
function stay() {
  buttonsEl.classList.add('hidden');                                *1*
  dealerEl.querySelector('.score').classList.remove('hidden');      *2*
  dealerTurn(() => {                                                *3*
    updateLabel(dealerEl, dealerHand);                              *4*
    const dealerScore = countHand(dealerHand);                      *5*
    const playerScore = countHand(playerHand);                      *5*
    if (dealerScore > 21 || dealerScore < playerScore) {
      status('You win!');                                           *6*
    } else if (dealerScore === playerScore) {
      status('Push.');                                              *7*
    } else {
      status('Dealer wins!');                                       *8*
    }
  });
}
  • 1 隐藏 hitstay 按钮。

  • 2 显示庄家的得分。

  • 3 让庄家进行他们的回合

  • 4 庄家完成后,更新其标签。

  • 5 计算庄家和玩家的手牌总数。

  • 6 如果庄家的得分超过 21 或低于玩家,则玩家获胜。

  • 7 如果平局,则为平局

  • 8 否则,庄家获胜

完成这个游戏只剩最后一步。你需要给 hitstay 按钮添加一些事件监听器,如下一个列表所示。

列表 26.20. src/index.js
document.querySelector('.hit-me').addEventListener('click', hit);
document.querySelector('.stay').addEventListener('click', stay);

到这里,你就完成了。现在你应该能够和电脑玩一局 21 点游戏了。构建这个游戏真的帮助我意识到,当你和庄家对弈时,你面临的概率是多么不利。

摘要

在这个项目中,你通过将逻辑分解成模块来构建了一个 21 点游戏。你从元素和模板模块开始,然后创建了一个卡片模块,接着是一个工具模块。你的卡片模块使用 Set 来表示牌堆和手牌。你还使用了一个 Map 来跟踪特定卡片是正面朝上还是背面朝下。一旦你的游戏逻辑被分离到逻辑模块中,将它们组合在一起在根索引文件中构建游戏就变得既简单又容易。

你可以通过添加“再玩一次”按钮并跟踪玩家击败庄家的概率来进一步改进这个游戏。

单元 6. 类

JavaScript 是一种面向对象的语言。除了少数原始类型外,JavaScript 中的大多数值,甚至是函数,都是对象。大多数编写的 JavaScript 代码都是为了创建自定义对象。很多时候你可能从头开始构建自己的对象,但有时你会从一个框架或库提供的基类开始,并为其添加自定义功能。不幸的是,还没有一个明确的方法让库作者提供可以被应用开发者扩展的基础对象原型。这导致许多库作者不得不重新发明轮子。

但没有人以相同的方式重新发明轮子。以下是 Backbone.js 提供扩展基对象的方法:

Backbone.Model.extend({
  // ... new methods
});

以下是使用 React.js 实现的方法:

React.createClass({
  // ... new methods
});

Backbone 提供了一个名为 initialize 的方法,它充当一个伪构造函数。React 没有这个方法。但是,当使用 createClass 创建方法时,React 会自动绑定这些方法。这只是差异的开始:我们在这里快速比较了两个,但实际上有数十个 JavaScript 框架提供了基础对象,这意味着你需要记住根据你使用的库或框架,不同类的工作方式的所有细微差别。

然而,使用类的情况下,框架作者现在可以在语言级别提供一个可扩展的基础类。这意味着一旦你学会了如何使用一个框架扩展类,你就能将这种知识迁移,使得切换和学习新框架变得更加容易。

课 27. 类

阅读完课 27 后,你将

  • 理解定义类的语法

  • 了解如何实例化类以及如何使用构造函数

  • 了解如何从模块中导出类

  • 理解类方法不是绑定的

  • 了解如何分配类和静态属性

类不过是声明构造函数并设置其原型的语法糖。即使引入了类,JavaScript 也不是静态或强类型。它仍然是一种动态和弱类型语言。然而,类确实为定义具有原型的构造函数提供了一个简单的自包含语法。不过,除了语法之外,主要优势在于扩展类时,我们将在下一课中讨论。如果没有一个易于扩展的内建结构,如类,许多库如 Backbone.js、React.js 和其他几个库不得不继续重新发明轮子,以允许扩展其基对象。随着越来越多的库开始提供可以轻松扩展的基础 JavaScript 类,这种特定于库的对象扩展形式将成为过去式。这意味着一旦你学会了如何使用和扩展类,你将在今天和明天的许多框架中领先一步。

考虑这一点

以下两个函数是当前 JavaScript 的实例化对象模式:工厂函数和构造函数。如果你要为实例化对象设计一种新的语法,你会如何改进它?

 const base = {
   accel() { /* go forwards */ },
   brake() { /* stop */ },
   reverse() { /* go backwards */ },
   honk() { /* be obnoxious */ }
 }

 function carFactory (make) {
   const car = Object.create(base);
   car.make = make;
   return car;
 }

 function CarConstructor(make) {
   this.make = make;
 }

CarConstructor.prototype = base;

27.1. 类声明

假设你正在构建一个需要连接到多个 API 以获取各种资源(如用户、团队和产品)的 Web 应用程序。你可能想要创建几个存储对象来存储各种资源的记录。你可以创建一个类似以下的存储类:

class DataStore {
  // class body
}

你使用关键字class后跟类名DataStore声明了一个类。名称不需要大写,但按照惯例应该大写。在类名之后,你有一对大括号{}:类体,所有构成类的方法和属性都位于这两个大括号之间。

例如,假设你需要一个名为fetch的方法在 DataStore 类中处理从数据库中获取记录。你会添加一个类似以下的方法:

class DataStore {
  fetch() {
    // fetch records from data base
  }
}

在这里添加方法与你在第 12 课中学习的对象上的简写方法名语法完全相同。不过,不要被它迷惑:类定义不是一个对象,其他语法将不起作用。尝试在下一个列表中创建方法将导致语法错误。

列表 27.1. 向类添加方法的错误语法
class DataStore {
  fetch: function() {                    *1*
    // fetch records from data base
  }
}
  • 1 语法错误*

类方法在 JavaScript 中像任何其他函数一样工作:它们接受参数,可以使用解构和默认值。实际上,导致错误的是函数的语法,而不是冒号(:)的使用。冒号用于在对象字面量中分隔属性名和属性值,但类声明不使用它们。此外,任何在方法内部使用this的用法都将引用实例,而不是类。

类语法和对象语法之间的另一个区别是,在类中属性之间没有逗号分隔,就像在对象中那样:

class DataStore {
  fetch() {
    // fetch records from data base
  }                                    *1*
  update() {
    // update a record
  }
}
  • 1 两个方法之间没有逗号分隔*

注意到类方法之间没有逗号分隔。如果你在创建一个对象,你需要一个逗号,否则你会得到一个语法错误。但在类声明中,情况正好相反,添加逗号会导致语法错误。

快速检查 27.1

Q1:

你能发现以下汽车类中的两个问题吗?

 class Car {
   steer(degree) {
     // turn the car by degree
   },
   accel(amount=1) {
     // accelerate the car by amount
   }
   break: function() {
     // decelerate the car
   }
}

QC 27.1 答案

A1:

不允许使用逗号和冒号。

27.2. 实例化类

一旦你有你的类定义,你可以使用new关键字创建它的一个实例:

const dataStore = new DataStore();          *1*
  • 1 在一个名为 dataStore 的新常量中创建 DataStore 的实例。*

如果你尝试在没有new关键字的情况下实例化类,你会收到一个 TypeError:

const myStore = DataStore();          *1*
  • 1 类构造函数 DataStore 不能在没有‘new’的情况下调用*

你也可以在创建实例时传递参数,如下所示:

const userStore = new DataStore('users');

当然,这引发了一个问题:类是如何接收这些参数的?有一个特殊的方法名叫做constructor,当创建实例时会自动调用。创建新实例时给出的任何参数都将传递给这个构造函数。

class DataStore {
  constructor(resource) {
    // setup an api connection for the specified resource
  }
}

构造函数在创建实例的上下文中被调用。这意味着构造函数内部的this将指向正在创建的实例,而不是类本身。构造函数是可选的,它提供了一个钩子,可以在类的实例上进行任何初始设置。构造函数的位置并不重要,但我喜欢将其放在顶部。

在下一节中,我们将快速查看如何从模块中导出类。

快速检查 27.2

Q1:

创建以下类时将记录什么?

 class Pet {
   constructor(species) {
     console.log(`created a pet ${species}`);
   }
 }

const myPet = new Pet('dog');

QC 27.2 答案

A1:

“创建了一只宠物狗”

27.3. 导出类

当创建 JavaScript 类时,你很可能会在模块中这样做。这意味着你需要能够导出它们。例如,如果你想从一个模块中导出你创建的DataStore类,你可以这样做:

export default class DataStore {           *1*
  // class body
}
  • 1 将 DataStore 类指定为默认导出。

这会将类作为默认导出。导入它与导入任何其他默认导出相同;你可以使用你想要的任何名称:

import Store from './data_store'

此外,你也可以省略默认导出,并按名称导出类:

export class DataStore {            *1*
  // class body
}
  • 1 将 DataStore 类以 DataStore 名称导出。

如你所猜,这会将类导出为DataStore名称,并且必须使用其名称导入:

import { DataStore } from './data_store'

在大多数情况下,你可能只想在每个模块中创建一个类并将其作为默认导出。

快速检查 27.3

Q1:

以下两种类导出哪一种是有效的?

export class A {
  // class body
}
export default class B {
 // class body
}

QC 27.3 答案

A1:

它们都是有效的。

27.4. 类方法未绑定

一些对类新接触的开发者可能会惊讶地发现,类方法并不是自动绑定的。比如说,假设DataStore类内部使用ajax库来处理 API 调用。你可能会有如下设置。

列表 27.2. 使用未绑定方法作为回调
class DataStore {
  fetch() {
    ajax(this.url, this.handleNewRecords)         *1*
  }
  handleNewRecords(records) {
    this.records = records                        *2*
  }
}
  • 1 发起 ajax 调用并指定 this.handleNewRecords 作为回调

  • 2 关键字 this 不会指向实例,因为它没有被绑定。

在列表 27.2 中,你将使用一个ajax库来为你获取记录。ajax库接受两个参数:加载数据的 URL 和一个回调函数,一旦数据加载完成就调用该函数。这看起来似乎一切正常,但由于handleNewRecords方法未绑定,当它从ajax库中调用时,this将不再指向DataStore实例,因此记录将无法正确存储。

有几种不同的方法可以解决这个问题。最简单的是使用箭头函数作为回调:

class DataStore {
  fetch() {
    ajax(this.url, records => this.records = records)
  }
}

这将正常工作。但如果你的回调更复杂,并且被用在多个地方,这种方法可能就不合适了。你仍然可以使用箭头函数,但作为一个中间步骤:

class DataStore {
  fetch() {
    ajax(this.url, records => this.handleNewrecords(records) )
  }
  handleNewRecords(records) {
    // do something more complex like
    // merging new records in with existing records
  }
}

在下一课中,当你学习类属性时,你将了解绑定方法的一种新方式。

快速检查 27.4

Q1:

调用car.delayedHonk()将导致错误。为什么?

 class Car {
   honk() {
     this.honkAudio.play();
   }
   delayedHonk() {
     window.setTimeout(this.honk, 1000);
   }
 }

 const car = new Car();
car.delayedHonk();

QC 27.4 答案

A1:

因为this.honk被用作回调但未绑定。

27.5. 在类定义中设置实例属性

在撰写本文时,类的一个方面只是一个提议,但正在得到广泛的应用,那就是在类定义中设置实例属性。在类中设置属性(与方法不同)乍一看可能很简单,但实际上比表面看起来要复杂得多。这是因为方法被添加到原型中,但属性被分配给每个实例。

假设你想要在DataStore上设置一个属性来确定资源的 URL。你还想有一个数组属性来存储实际的记录。你可以像以下列表所示那样做。

列表 27.3. 在类上设置属性
class DataStore {
  url = '/data/resources';                   *1*
  records = [];                              *2*

  fetch() {
    ajax(this.url, records => this.records = records)
  }
}
console.log(DataStore.url);                  *3*

const store = new DataStore();
console.log(store.url);                      *4*
console.log(store.records.length);           *5*
  • 1 一个名为 url 的属性

  • 2 一个名为 records 的属性

  • 3 undefined

  • 4 “/data/resources”

  • 5 0

正如你所见,属性将在创建的实例上可用,而不是类本身。注意类属性看起来就像直接在类定义中进行的赋值。这是因为它们在技术上就是赋值。与类方法不同,类属性是一种语法糖,它直接在构造函数中被重写。这意味着你刚刚设置的urlrecords赋值实际上就像你在构造函数内部做了它们一样:

class DataStore {
  constructor() {
    this.url = '/data/resources';
    this.records = [];
  }
}

这与类方法不同,因为那些方法会被添加到类的原型中。实例随后通过原型继承继承那些方法。但类属性不会设置在原型上;它们最终直接设置在实例本身上。直接在原型上设置属性会导致错误。让我们通过一个例子来探讨原因:

class DataStore {};

DataStore.prototype.records = [];

const storeA = new DataStore();

console.log(storeA.records.length);          *1*

const storeB = new DataStore();

console.log(storeB.records.length);          *1*

storeB.records.push('Example Record')

console.log(storeA.records.length);          *2*
console.log(storeA.records[0]);              *3*
  • 1 0

  • 2 1

  • 3 ‘Example Record’

在这里,你直接在DataStore的原型上设置records属性,而不是在实例上。这意味着所有实例都将使用相同的记录数组,因为它是设置在它们的原型上,而不是在实例对象本身上。当对象storeB添加记录时,storeA也会收到一个新的记录。它们正在共享同一个记录数组!

类实例属性的一个明显好处是声明绑定方法。在上一个部分中,你遇到了一些问题,因为你的handleNewRecords方法没有绑定到实例上。你可以通过将其声明为一个指向箭头函数的属性来使其绑定:

class DataStore {
  fetch() {
    ajax(this.url, this.handleNewrecords)
  }
  handleNewRecords = (records) => {
    // do something more complex like
    // merging new records in with existing records
  }
}

在下一节中,我们将探讨另一种类型的类属性,即静态属性。

快速检查 27.5

Q1:

以下类存在一个常见的类属性语法错误。你能找到它吗?

 class Nachos {
   toppings: ['cheese', 'jalapenos']
}

| |

QC 27.5 答案

A1:

它应该是

toppings = ['cheese', 'jalapenos'];

27.6. 静态属性

类上的静态属性是一种特殊的属性,它不会在实例或原型上设置属性,而是在类对象(构造函数)本身上设置。静态属性对于不会在实例之间变化的属性是有意义的。例如,你的DataStore可能有一个静态属性,用于在连接到 API 时使用哪个域:

class DataStore {
  static domain = 'https://example.com';       *1*
  static url(path) {                           *2*
    return `${this.domain}${path}`
  }
  constructor(resource) {
    this.url = DataStore.url(resource);        *3*
  }
}

const userStore = new DataStore('/users');
console.log(userStore.url);                    *4*

你使用一个静态属性domain和一个静态方法url^([1])来从构造函数函数中指定的资源生成实例的 URL。

¹

虽然静态属性在撰写本文时仍然只是一个提案,但静态方法是在 ES2015 中引入的。

静态属性只是将它们直接分配到类本身上的语法糖,如下一列表所示。

列表 27.4. 静态属性的简化
class DataStore {
  constructor(resource) {
    this.url = DataStore.url(resource);
  }
}

DataStore.domain = 'https://example.com';
DataStore.url = function(path) {
  return `${this.domain}${path}`
}

这总结了创建和使用类的方法,但我们还没有结束类。在下一节课中,我们将探讨如何扩展类。

快速检查 27.6

Q1:

以下哪个console.log实际上记录了轮子的数量?

class Bicycle {
  static numberOfWheels = 2;
}
const bike = new Bicycle();

console.log(bike.numberOfWheels);
console.log(Bicycle.prototype.numberOfWheels);
console.log(Bicycle.numberOfWheels);

| |

QC 27.6 答案

A1:

console.log(Bicycle.numberOfWheels);

摘要

在本节课中,你学习了如何定义和使用自己的类。

  • 类是通过class关键字、类名和类体来创建的。

  • 类方法使用简写方法语法来声明方法。

  • 类不支持用于声明属性或方法的冒号。

  • 类不应该用逗号分隔方法或属性。

  • 构造函数在类实例化时执行。

  • 类方法不是自动绑定到实例上的。

让我们看看你是否掌握了这个:

Q27.1

将以下构造函数和原型转换为类:

function Fish(name) {
  this.name = name;
  this.hunger = 1;
  this.dead = false;
  this.born = new Date();
}
Fish.prototype = {
  eat(amount=1) {
    if (this.dead) {
      console.log(`${this.name} is dead and can no longer eat.`);
      return;
    }
    this.hunger -= amount;
    if (this.hunger < 0) {
      this.dead = true;
      console.log(`${this.name} has died from over eating.`)
      return
    }
  },
  sleep() {
    this.hunger++;
    if (this.hunger >= 5) {
      this.dead = true;
      console.log(`${this.name} has starved.`)
    }
  },
  isHungry: function() {
    return this.hunger > 0;
  }
}

const oscar = new Fish('oscar');
console.assert(oscar instanceof Fish);
console.assert(oscar.isHungry());
while(oscar.isHungry()) {
  oscar.eat();
}
console.assert(!oscar.isHungry());
console.assert(!oscar.dead);
oscar.eat();
console.assert(oscar.dead);

第 28 课. 扩展类

在阅读第 28 课后,你将

  • 了解如何扩展类以创建更定制化的类

  • 学习如何使用提供基类的库

  • 理解类如何进行继承

  • 了解如何使用super在超类上调用函数

与传统的构造函数声明相比,类提供的最佳特性是它们易于扩展。许多人认为扩展构造函数的语法很笨拙。这导致许多库的作者在自己的库中创建了扩展基对象自己的方式。有了内置的可扩展类,开发者可以学习一种简单语法,可以在任何地方使用。然而,在 JavaScript 中扩展类时,需要记住的一个关键方面是它们仍然使用原型继承。

考虑这一点

在以下代码中,你有一个plane对象和一个jet对象。在某种程度上,jet对象扩展了plane对象,因为它复制了所有属性并覆盖了其fly方法。如果jetfly方法与planefly方法有重叠的逻辑,你将如何使它们能够在两个方法之间共享代码?

 const plane = {
   type: 'aircraft',
   fly() {
     // make the plane fly
   }
 }
 const jet = Object.assign({}, plane, {
   fly() {
     // make the jet fly faster
   }

});

28.1. 扩展

让我们继续使用第 27 课中的DataStore示例。假设你想要创建一个TeamStore,它是DataStore的定制版本。你的TeamStore需要DataStore的基本功能,但还需要添加或删除用户的方法。你可以创建一个扩展DataStoreTeamStore,如下所示:

列表 28.1. 扩展类
class TeamStore extends DataStore {
  addUserToTeam(teamId, userId) {
    // add user to team
  }
  removeUserFromTeam(teamId, userId) {
    // remove user from team
  }
}

在列表 28.1 中,你创建了一个名为TeamStore的新类,它从DataStore扩展而来。通过使TeamStoreDataStore扩展,DataStore原型被附加到TeamStore原型链上。这意味着当创建TeamStore的实例时,原型链将是这样的:TeamStore实例委托给TeamStore原型,该原型反过来委托给从它继承而来的DataStore原型。DataStore原型将委托回Object原型,该原型委托给null(见图 28.1)。

图 28.1. 原型继承

当你创建TeamStore的实例并在实例上调用方法时,如果实例没有该方法,则检查DataStore定义的实例原型。如果仍然找不到该方法,它将继续沿着原型链查找该方法。这并不新鲜:这是 JavaScript 中继承一直以来的工作方式,并且随着类的出现并没有改变。

快速检查 28.1

Q1:

migaloo对象的完整原型链是什么?

class Whale extends Animal {
   // whale stuff
}
class Humpback extends Whale {
   // humpback stuff
}

const migaloo = new Humpback();

| |

QC 28.1 答案

A1:

instance → Humpback → Whale → Object → null

28.2. Super

当你在前面的例子中定义DataStore时,你让构造函数接受一个参数,表示它负责的资源 URL:

class DataStore {
  constructor(resource) {
    // setup an api connection for the specified resource
  }
}

这意味着,即使TeamStore总是使用相同的 URL,你仍然需要在创建TeamStore的实例时指定资源。你可以在TeamStore的构造函数中使用super来实现这一点,如下列表所示。

列表 28.2. 使用 super 调用超类构造函数
class TeamStore extends DataStore {
  constructor() {
    super('/teams');                     *1*
  }
}
  • 1 调用 super() 调用超类的构造函数。

TeamStore的构造函数中,你使用特殊关键字super调用了其超类(它扩展的类)的构造函数。这允许你自动将TeamStore的 URL 设置为"/teams"

在任何扩展另一个类的类的构造函数中,在可以引用this之前,必须调用super

class TeamStore extends DataStore {
  constructor() {
    this.url = '/teams';                 *1*
    super(this.url);
  }
}
  • 1 ReferenceError: this is not defined

关键字super实际上不引用任何东西。它是一个特殊的关键字,允许你调用超类的构造函数或访问和调用超类上的其他方法。比如说,如果你有一个产品商店,你想要更新一个关于哪些产品被访问的分析对象。你可以覆盖DataStorefetch函数来添加你的额外逻辑。然而,你仍然想要调用原始的fetch函数(你覆盖的那个),你可以使用super.fetch来这样做,如下一列表所示。

列表 28.3. 使用 super 调用超类方法
class ProductStore extends DataStore {
  fetch(id) {
    analytics.productWasViewed(id);
    return super.fetch(id);
  }
}

当你引用super[name]时,你可以通过name在超类的原型上访问任何属性。在这里,你使用了super.fetch来在超类上调用fetch方法。

快速检查 28.2

Q1:

以下代码有什么问题?

class Whale extends Animal { }
class Humpback extends Whale {
  constructor() {
    this.hasHump = true;
  }
}

| |

QC 28.2 答案

A1:

在扩展类的构造函数中,this不能被引用,直到super()被调用。

28.3. 扩展类时常见的陷阱

在上一课中,我们介绍了一种通过设置指向箭头函数的类属性来绑定方法的方法:

class DataStore {
  fetch() {
    ajax(this.url, this.handleNewrecords)
  }
  handleNewRecords = (records) => {
    // merging new records in with existing records
  }
}

这是一种我在野外随处可见的常见模式。这种方法的缺点是handleNewRecords没有被添加到DataStore的原型上。它是直接添加到创建的实例上的。这意味着它不能通过super来访问。

如果你没有计划在子类中通过 super 调用该方法,那么这种方法是可行的。否则,你需要一种新的方法。那么,如何绑定一个方法,同时仍然使其可扩展并通过 super 调用?一种方法是将它定义为一个实际的方法,然后在构造函数中将方法绑定到实例上,如下所示。

列表 28.4. 以一种与 super 一起工作的方式绑定方法
class DataStore {
  constructor(resource) {
    // setup an api connection for the specified resource
    this.handleNewRecords = this.handleNewRecords.bind(this);         *1*
  }
  handleNewRecords(records) {
    // merging new records in with existing records
  }
}

class ProductStore {
  handleNewRecords(records) {
    super.handleNewRecords(records);
    // do something else
  }
}
  • 1 将原型上的 handleNewRecords 方法绑定到实例上。

这之所以有效,是因为它仍然将方法分配给实例,但定义来源于原型上的方法。子类可以覆盖超类上的实例方法,甚至使用 super,然后子类的覆盖实例方法将被绑定到实例上。参见图 28.2。

图 28.2. handleNewRecords 方法如何在原型链上被调用

图片

快速检查 28.3

Q1:

为什么以下代码无法工作?

class Whale {
  dive = () => {
    // go deep!
  }
}
class Humpback extends Whale {
  dive = () => {
    super.dive();
  }
}

QC 28.3 答案

A1:

两个 dive 属性都是直接在实例上设置的,所以第二个完全覆盖了第一个。原型链上没有 dive 方法,因此无法通过 super 访问。

摘要

在本节课中,你学习了如何扩展类。

  • 你可以使用 extends 关键字扩展类。

  • 类使用原型继承。

  • 你可以使用 super 关键字访问超类的构造函数和方法。

  • super 必须在子类的构造函数中调用,然后才能引用 this

  • 类属性是在实例上设置的,因此不能使用 super 访问。

让我们看看你是否掌握了这些:

Q28.1

编写一个扩展以下 Car 类的类。添加一个名为 fuel 的方法,将油量重置为 50。然后覆盖 drive 方法,接受一个名为 miles 的数字参数,然后调用足够多的超类 drive 方法来行驶这么多英里:

class Car {
  constructor() {
    this.gas = 50;
    this.milage = 0;
  }

  hasGas() {
    return this.gas > 0;
  }

  drive() {
    if (this.hasGas()) {
      this.milage++;
      this.gas--;
    }
  }
}

第 29 课. 顶点:彗星

在这个顶点项目中,你将构建一个类似《小行星》的游戏,名为彗星,如图 29.1 所示。

图 29.1.彗星游戏

图片

有许多有效的理由可以创建一个基类或者不扩展任何东西的类。但我认为,大多数开发者的大部分时间都将花费在扩展由 React.js 等框架提供的基类上。对于这个顶点项目,你将使用我整理的游戏框架:这个框架(就像大多数框架一样)将处理大部分的动态部分,同时给你一些可以自定义以创建独特游戏的基础类。

注意

你将使用这本书附带的代码中包含的 start 文件夹开始你的项目。如果你在任何时候遇到困难,你还可以查看包含完成游戏的 final 文件夹。start 文件夹是一个已经设置好以使用 Babel 和 Browserify 的项目(见单元 0);你只需要运行npm install来设置。如果你还没有阅读单元 0,你应该在完成这个项目之前先阅读。还有一个包含的index.html文件:游戏将在其中运行。它已经包含了所有需要的 HTML 和 CSS;你只需要在捆绑你的 JavaScript 文件后将其在浏览器中打开。src 文件夹是放置所有 JavaScript 文件的地方;其中已经包含了一些。dest 文件夹是在你运行npm run build后捆绑的 JavaScript 文件将去的地方。你需要记住,每次你做出更改时都要运行npm run build来编译你的代码。

29.1. 创建一个可控制的精灵

你将尽快在屏幕上显示一些内容。确保你从这本书附带的代码中包含的 start 文件夹开始。如果你查看 src 文件夹,你会看到那里已经有一个框架文件夹和一个 shapes.js 文件。框架文件夹包含游戏框架,而 shapes.js 文件包含一些数组和函数,用于描述游戏角色的形状。你可以自由探索这些文件中的代码。

在 src 目录下创建一个名为 comet.js 的文件,包含以下代码。

import ControllableSprite from './framework/controllable_sprite';     *1*
import { ship as shape } from './shapes';                             *2*

export default class Ship extends ControllableSprite {                *3*
  static shape = shape;                                               *4*
  static stroke = '#fff';                                             *4*
}
  • 1 基础类来自你使用的游戏框架。

  • 2 你包含的形状中的宇宙飞船

  • 3 你的飞船类扩展了可控制精灵类。

  • 4 设置一些静态属性

这是一个简单的类,但它做了很多,因为你是从ControllableSprite类扩展的,而ControllableSprite类本身又扩展自Sprite类。Sprite类是任何将在屏幕上绘制的对象的基线。ControllableSprite类增加了用户使用键盘箭头键控制精灵的功能。你设置了静态属性shapestroke,这些属性决定了飞船精灵的外观。一旦你在屏幕上绘制了这个,就可以尝试其他形状和颜色。

你几乎准备好在屏幕上看到并控制这个了。在 src 文件夹中创建另一个名为 index.js 的文件,并添加以下代码。

import { start } from './framework/game';        *1*

import Ship from './ship';                       *2*

new Ship();                                      *3*

start();                                         *4*
  • 1 框架中的 start 函数启动游戏。

  • 2 导入你之前创建的类。

  • 3 创建你的飞船类的实例。

  • 4 启动游戏。

这段代码也很简单。你只是创建了一个Ship类的实例,并告诉游戏开始。index.js文件将是你的源代码的根文件,它启动游戏。如果你现在编译你的代码并打开包含的index.html文件,你会看到你刚刚创建的飞船。甚至更多,通过使用箭头键,你可以移动它!

29.2. 添加彗星

现在你可以移动了,你的飞船看起来相当孤单。你将向你的游戏中添加一些额外的角色。在 src 文件夹中创建一个名为 comet.js 的新文件,并添加以下代码:

import { CANVAS_WIDTH, CANVAS_HEIGHT } from './framework/canvas';
import DriftingSprite from './framework/drifting_sprite';
import { cometShape } from './shapes'

function defaultOptions() {                                   *1*
  return {
    size: 20,
    sides: 20,
    speed: 1,
    x: Math.random() * CANVAS_WIDTH,
    y: Math.random() * CANVAS_HEIGHT,
    rotation: Math.random() * (Math.PI * 2)
  }
}

export default class Comet extends DriftingSprite {           *2*
  static stroke = '#73C990';

  constructor(options) {                                      *3*
    super(Object.assign({}, defaultOptions(), options));      *4*
    this.shape = cometShape(this.size, this.sides);           *5*
  }
}
  • 1每个彗星的默认选项

  • 2彗星扩展了 DriftingSprite 类。

  • 3构造函数接受选项。

  • 4将选项和默认选项合并后调用 super。

  • 5设置彗星的形状。

Comet类比前两个例子稍微复杂一些,所以让我们深入了解一下这里的情况。游戏框架中的所有精灵都可以通过构造函数的参数传递初始选项(属性)。你的飞船不需要这些选项,但每个彗星都需要。精灵在设置属性方面很灵活。它们可以是静态的,比如stroke属性,或者像shape属性一样设置在实例上。

为什么要把一些属性设置为静态,而把一些设置为实例属性?你的所有彗星都将有相同的绿色描边,所以你可以将其设置为类本身的静态属性。但你想让每个彗星都有独特的形状,这样它看起来就像一个单独的彗星,而不是复制品。因为每个彗星的形状都是独特的,所以它需要设置在实例上。

函数cometShape返回一个给定大小和边的随机形状。通过在构造函数中调用此函数,你确保每个彗星都有其独特的形状。

现在你有了Comet类,你需要在开始游戏之前导入它并创建几个实例。像这样更新 index.js 文件:

import { start } from './framework/game';

import Ship from './ship';
import Comet from './comet';         *1*

new Ship();

new Comet();                         *2*
new Comet();                         *2*
new Comet();                         *2*

start();
  • 1导入你的 Comet 类。

  • 2创建几个实例。

现在如果你再次打包你的代码,你应该会看到一些彗星在你的飞船周围漂浮。

29.3. 射击火箭

显然,现在你有了所有这些漂浮的目标,你想要射击!接下来你将在屏幕上得到一些火箭。首先创建火箭类。在 src 文件夹中创建一个名为 rocket.js 的新文件,并添加以下代码:

import DriftingSprite from './framework/drifting_sprite';
import { rocket } from './shapes';

export default class Rocket extends DriftingSprite {
  static shape = rocket;
  static fill = '#1AC2FB';
  static removeOnExit = true;
  static speed = 5;
}

这里还没有什么特别新的内容。这与你之前的彗星非常相似,当然,形状不同。你还给你的火箭添加了填充而不是描边。不过,有一个新属性是removeOnExit。通常情况下,当精灵离开屏幕时,它们会在另一边重新出现。这个属性告诉你的游戏,一旦精灵离开可视区域,就将其移除。

现在你需要在屏幕上得到一些火箭。你不想在开始游戏之前像飞船和彗星那样创建实例。你想要从飞船上发射火箭!游戏框架提供了一些辅助函数,你需要使用这些函数来实现这一点。打开 ship.js 文件,并在顶部添加以下导入:

import { isPressingSpace } from './framework/interactions';
import { getSpriteProperty } from './framework/game';
import { throttle } from './framework/utils';
import Rocket from './rocket';

isPressingSpace 函数告诉你玩家是否按下了空格键。你会用它来表示发射火箭的意图。当你发射火箭时,你想要将火箭设置为与你的船相同的起始位置和方向,这样它看起来就像是从你的船发射出来的。因为精灵可以将它们的属性存储为静态属性、实例属性甚至方法,你需要特殊的辅助方法 getSpriteProperty 来读取值。

在游戏过程中,屏幕会不断地重新渲染。在渲染之前,每个精灵都会通过精灵上的一个名为 next() 的方法形式的钩子来通知自己更新。下一个函数将在绘制下一帧之前始终在每个精灵上调用,并且必须始终返回精灵本身。你可以使用这个钩子来检查用户是否按下了空格键,如果是,就发射火箭。问题是每一帧渲染得非常快。你不想那么快地发射火箭;你想要减慢速度。这就是 throttle 函数的作用。你给它一个函数,它返回一个节流函数。让我们看看如何实现它。向船添加以下方法:

next() {
  super.next();                     *1*

  if (isPressingSpace()) {          *2*
    this.fire();                    *3*
  }

  return this;                      *4*
}
  • 1 你正在覆盖 next,因此你需要调用 super.next。

  • 2 如果用户正在按空格键,你想要发射火箭。

  • 3 你仍然需要添加这个方法。

  • 4 下一个函数始终需要返回 this。

你覆盖了 next 函数,所以你需要调用 super.next。请注意,当我说你需要时,我并不是指它在语言级别上是必需的。如果你不希望调用超类的 next 方法,你不需要调用 super.next()。但在这个情况下,你需要。

你仍然需要添加 fire 方法。因为你需要这个方法被节流,你不能将其设置为常规方法。相反,你将把它设置为指向节流函数的类属性:

export default class Ship extends ControllableSprite {
  static shape = shape;
  static stroke = '#fff';

  fire = throttle(() => {                                     *1*
    const x = getSpriteProperty(this, 'x');
    const y = getSpriteProperty(this, 'y');
    const rotation = getSpriteProperty(this, 'rotation');

    new Rocket({ x, y, rotation });
  });

  ...
}
  • 1 将节流函数设置为类属性

因为 fire 被设置为属性,而不是方法,所以它没有被添加到原型中。它在创建船实例时直接设置。这意味着任何扩展了 Ship 类的类都不能覆盖 fire 方法并使用 super.fire

装饰器

或者,还有一个解决方案可以限制fire方法,使其作为一个与super一起工作的真正方法。这将是使用装饰器。目前广泛使用的装饰器将与真正进入语言的装饰器不同。在撰写本文时,没有可用的转译器来描述未来装饰器的工作方式。因此,我决定将装饰器从这本书中排除。如果你想了解更多关于它们的信息,规范可以在以下位置阅读:tc39.github.io/proposal-decorators。此外,还有一个名为core-decorators的 JavaScript 库,它甚至包括一个用于限制方法的装饰器。在这里查看:github.com/jayphelps/core-decorators.js/tree/c4d9a654093a6c02d436e4d236f4d21e3271867d#throttle

现在如果你再次打包你的代码并启动游戏,你将能够通过按空格键从你的宇宙飞船中发射火箭。但有一个问题:当火箭击中彗星时,什么都没有发生!

29.4. 当物体发生碰撞时

当火箭击中彗星时,你不仅希望它爆炸,还希望它分裂成更小的彗星。向Comet类添加以下方法:

multiply() {
  const x = getSpriteProperty(this, 'x');
  const y = getSpriteProperty(this, 'y');
  let size = getSpriteProperty(this, 'size');
  let speed = getSpriteProperty(this, 'speed');

  if (size < 5) {
    this.remove();                              *1*
    return;
  }
  size /= 2;                                    *2*
  speed *= 1.5;                                 *2*
  const removeOnExit = size < 5;                *3*
  const r = Math.random() * Math.PI;
  const ninetyDeg = Math.PI/2;
  range(4).forEach(i => new Comet({             *4*
    x, y, size, speed, removeOnExit,
    rotation: r + ninetyDeg * i,
  }))
  this.remove();                                *5*
}
  • 1 如果彗星已经小于 5,则不再进行乘法操作。

  • 2 减小大小并增加速度。

  • 3 如果碎片小于 5,让它飘向太空。

  • 4 你将要乘以 4 块碎片。

  • 5 添加较小的碎片后,移除原始碎片。

这个方法会将彗星分割成更小、移动速度更快的碎片。你需要在这里使用rangegetSpriteProperty方法,所以请在上面的 comet 文件顶部导入它们:

import { getSpriteProperty } from './framework/game';
import { range } from './framework/utils';

现在彗星可以被分割成多个,但你仍然需要一个方法在火箭击中彗星时启动这个操作。你的游戏框架有内置的碰撞检测:你只需要指定你期望与之碰撞的精灵类型。打开 rocket.js 文件,并在Rocket类中添加以下属性:

export default class Rocket extends DriftingSprite {
  static collidesWith = [Comet];

  ...
}

通过设置这个属性,你是在告诉你的游戏在任意与彗星碰撞的火箭上调用collision方法。这意味着你需要在你的Rocket类中添加collision方法:

collision(target) {              *1*
  target.multiply();             *2*
  this.remove();                 *3*
}
  • 1 与精灵发生碰撞时调用collision,在这个例子中是一个彗星。

  • 2 告诉被火箭击中的彗星进行乘法操作。

  • 3 移除火箭。

你还需要将Comet类导入到火箭模块中。在 rocket.js 文件顶部添加以下导入:

import Comet from './comet';

你现在可以再次构建代码并尝试玩游戏。你应该能够摧毁一些彗星。哇,这开始看起来像是一个真正的游戏了!

29.5. 添加爆炸效果

好的,你可以用火箭摧毁彗星,但什么样的火箭不会爆炸?你将在下一部分修复这个问题。创建一个名为explosion.js的新文件,并添加以下代码:

import Sprite from './framework/sprite';
import { explosionShape } from './shapes';

const START_SIZE = 10;
const END_SIZE = 50;
const SIDES = 8;

const defaultOptions = {
  size: START_SIZE
}

export default class Explosion extends Sprite {                       *1*
  constructor(options) {
    super(Object.assign({}, defaultOptions, options));
    this.shape = explosionShape(this.size, SIDES);
  }

  next() {
    this.size = this.size * 1.1;                                      *2*
    this.shape = explosionShape(this.size, SIDES);                    *3*
    this.fill = `rgba(255, 100, 0, ${(END_SIZE-this.size)*.005})`     *4*
    this.stroke = `rgba(255, 0, 0, ${(END_SIZE-this.size)*.1})`       *4*
    if (this.size > END_SIZE) {                                       *5*
      this.remove();
    }
    return this;
  }
}
  • 1 爆炸不会漂移,所以你需要扩展基础精灵类。

  • 2 你希望爆炸在每一帧都增长大小。

  • 3 在增加大小后,你需要重新计算形状。

  • 4 你希望爆炸在达到最终大小时逐渐淡入。

  • 5 一旦爆炸达到最终大小,就移除它。

这里没有太多新内容。从某种程度上说,这与Comet类相似,因为你正在设置默认选项并使用动态形状。但是,你会在每一帧改变爆炸的形状。你还在改变大小和透明度。一旦爆炸的大小增加到最终大小,你就移除它。

现在你只需要在火箭碰撞时创建爆炸。将爆炸类以及getSpriteProperty辅助函数导入到火箭模块中:

import { getSpriteProperty } from './framework/game';
import Explosion from './explosion';

现在在Rocket类的collision方法中,创建一个爆炸:

new Explosion({
  x: getSpriteProperty(this, 'x'),
  y: getSpriteProperty(this, 'y'),
});

现在你的火箭在撞击彗星时会爆炸。(别忘了在测试之前再次打包你的代码!)你还可以添加一些东西来使这个游戏变得更好,但你需要收尾。但还有一个明显的缺失部分。当彗星撞击你的飞船时,需要发生某些事情。

ship.js文件中,导入CometExplosion类以及来自游戏框架的stop函数:

import { getSpriteProperty, stop } from './framework/game';         *1*
import Comet from './comet';
import Explosion from './explosion';
  • 1 将停止函数添加到现有的导入中。

现在将Ship设置为与Comet发生碰撞,并添加以下碰撞方法:

export default class Ship extends ControllableSprite {
  ...

  static collidesWith = [Comet];

  collision() {

    new Explosion({
      x: getSpriteProperty(this, 'x'),
      y: getSpriteProperty(this, 'y'),
    });

    this.remove();

    setTimeout(stop, 500);
  }

  ...

}

现在当彗星撞击飞船时,将会有爆炸,游戏也将结束。

摘要

在这个项目中,你使用类创建了一个游戏。你扩展了从框架中提供的基类。在扩展这些基类时,你在一个构造函数以及覆盖方法时使用了super。你使用了静态和实例属性,我们讨论了为什么将函数设置为实例属性时,在覆盖时不能使用super

我希望你在构建这个游戏的过程中和我一样享受乐趣。你还可以做很多事情来进一步发展这个游戏。你可以添加多个生命值,或者添加一个重新开始游戏的按钮,一旦被击败。不要害怕对框架进行修改以增强这个游戏。你甚至可以让 UFO 出现并向你的飞船射击;如果你决定尝试,我在shapes.js模块中包含了一个ufo形状!

单元 7. 异步工作

JavaScript 一直是一种异步语言。这使得它非常适合创建丰富的应用程序,因为它可以处理许多并发任务而不会锁定界面。但直到最近,除了能够传递可以在以后调用的函数之外,对它的异步性质的支持并没有多少是第一类的。但现在一切都变了。JavaScript 现在有了对承诺、异步函数和即将对可观察对象的第一类支持。

承诺是表示未来值的对象。与回调函数相比,它们更方便传递,因为承诺不依赖于时间。如果值已经被检索到,承诺会立即提供一个值,或者可以等待直到值被检索。使用回调时,如果你来得太晚,值已经被检索,你的回调可能不会被调用。

正如我们很快就会看到的,承诺还提供了工具,使复杂的异步调用更容易,并消除了回调地狱的需要。

人们很难理解复杂的异步交互,这可能导致错误。阻塞操作更容易思考,但效率较低。承诺使复杂的异步代码更容易管理,但你仍然需要异步思考。但不是使用async函数。异步函数允许你像编写阻塞操作一样编写代码。

承诺产生一个值,然后结束。一个可观察对象就像一个承诺,它继续产生值。可观察对象允许你将任何东西转换成你可以订阅的事件,并允许你将事件当作对象来处理,你可以对其应用高阶函数,如mapreduce

第 30 课. 承诺

在阅读第 30 课之后,你将能够

  • 使用基于承诺的库来获取异步数据

  • 对承诺进行基本错误处理

  • 使用Promise.resolve来缓存异步调用

  • 将多个承诺合并为一个

一个承诺是一个表示最终值的对象。你可以通过在承诺上调用.then()并提供一个回调函数来访问这个最终或未来的值。承诺最终会使用这个值调用这个回调。如果承诺仍在等待值(承诺处于挂起状态),那么承诺将等待直到值准备好或已加载(此时承诺进入解决状态),然后使用值调用回调。如果承诺已经解决,则回调将立即被调用.^([1])

¹

不是立即的。回调将被添加到事件循环中,类似于带有 0 延迟的setTimeout

考虑这一点

在 JavaScript 中,异步值传统上是通过传递回调函数来访问的,这些回调函数会在数据准备好时被调用。这很有限,因为只有拥有回调引用的对象会在值准备好时收到通知。如果除了传递回调之外,你还可以传递一个表示异步值最终将变成什么的价值,会怎么样?这将如何工作?

30.1. 使用承诺

假设你正在使用一个名为 axios 的库,该库执行 AJAX 请求并返回一个承诺。你想使用它从 GitHub API 加载数据并列出特定用户的组织。你可以这样发出请求:

axios.get('https://api.github.com/users/jisaacks/orgs')       *1*
  .then(resp => {                                             *2*
    const orgs = resp.data;
    // do something with array of orgs
  });
  • 1 axios.get 函数执行 AJAX 请求并返回一个承诺。

  • 2 你在承诺上调用 then 函数时使用了箭头函数。箭头函数将在请求完成后使用 resp 被调用。

在这里,你调用 axios.get,它返回一个承诺。当该承诺解决时,你可以从响应对象中获取数据。然后你可以对结果进行一些操作。

让我们看看如果 axios 使用回调而不是承诺会是什么样子:

axios.get('https://api.github.com/users/jisaacks/orgs', resp => {
  const orgs = resp.data;
  // do something with array of orgs
});

在这个简单的例子中,使用承诺似乎比使用回调增加了更多步骤。然而,随着我们的示例变得更加复杂,你会看到承诺如何使代码更加灵活和可读。

函数中硬编码了我的用户名。将其重写为加载指定用户的组织:

function getOrgs(username) {
  return axios.get(`https://api.github.com/users/${username}/orgs`);
}

getOrgs('jisaacks').then(data => {
  const orgs = resp.data;
  // do something with array of orgs
});

在这里,你创建了一个接受用户名并返回承诺的函数。然后你使用该函数获取我的用户的组织。再次看看使用传统回调会是什么样子:

 function getOrgs(username, callback) {
   return axios.get(`https://api.github.com/users/${username}/orgs`, callback
);
 }

 getOrgs('jisaacks', data => {
   const orgs = resp.data;
   // do something with array of orgs
 });

在这个虚构的函数中,getOrgs 函数变得更加复杂,因为它需要接受一个回调函数,以便将其传递给 AJAX 库。

现在让我们想象你有一个表示用户视图的类。在这个用户视图中,你不会立即显示用户的组织,但为了使应用程序更快,你希望立即开始加载它们,以便稍后显示:

class UserView {
  constructor(user) {
    this.user = user;
    this.orgs = getOrgs(user.username);      *1*
  }

  defaultView() {
    // show the default view
  }

  orgView() {
    // show loading screen
    this.orgs.then(resp => {                 *2*
      const orgs = resp.orgs;
      // show the orgs
    })
  }
}
  • 1 立即开始加载用户组织。

  • 2 加载完成后显示组织。

在这里,你创建了一个名为 UserView 的类,该类立即开始加载用户的组织。稍后当用户视图需要显示用户的组织时,无论它们是否已经加载或仍在加载中,都不会影响。无论如何,它将等待它们加载完成,然后显示它们——如果它们已经加载,它将立即显示它们。使用传统的回调来实现这一点会困难得多。

快速检查 30.1

Q1:

以下示例使用了一个接受回调的 AJAX 函数。假设 AJAX 函数返回一个承诺,将其重写为使用承诺:

function handleData(data) {
  // do something with data
}

ajax('example.com', handleData);

| |

QC 30.1 答案

A1:

function handleData(data) {
  // do something with data
}

ajax('example.com').then(handleData);

30.2. 错误处理

我们并不生活在一个完美的世界里。有时承诺检索的数据会失败,无论是由于网络错误还是其他问题。承诺上的 then 方法接受一个可选的第二个回调,如果发生错误,则调用该回调:

function handleResp(resp) {
  // handle successful response
}

function handleError(err) {
  // handle an error
}

const url = 'https://api.github.com/users/jisaacks/orgs';
axios.get(url).then(handleResp, handleError);

如果这里一切如预期进行,handleResp 函数将接收到来自请求的响应对象。如果出现问题,handleError 函数将被调用以处理错误。

这些是使用承诺进行错误处理的基础,但我们将触及一些关于使用承诺进行错误处理的不同技术和注意事项,在关于高级承诺的第 31 课中讨论。

快速检查 30.2

Q1:

以下示例使用一个名为 getJSON 的函数,该函数接受一个接受两个参数的函数。第一个是一个可能的错误对象;第二个是加载的 JSON 数据。通常只有一个值会存在。将此重写为基于承诺而不是基于回调:

getJSON('example.com/data.json', (err, json) => {
  if (err) {
    // handle error
  } else {
    // handle json
  }
});

| |

QC 30.2 答案

A1:

getJSON('example.com/data.json').then(
  (json) => {
    // handle json
  },
  (err) => {
    // handle error
  }
);

30.3. 承诺辅助函数

Promise 对象有四个辅助方法,使处理承诺变得更加容易:

  1. Promise.resolve()

  2. Promise.reject()

  3. Promise.all()

  4. Promise.race()

让我们从 Promise.resolve() 开始。这个函数返回一个已经解析的承诺。传递给 Promise.resolve 的任何参数将通过传递给 .then 方法中提供的函数的参数从承诺中返回。这可以出于各种原因而很有帮助,通常在期望承诺但已经有可用值的情况下使用。考虑一个场景,当你正在加载会计程序的交易时。每次加载交易时,你都想将其缓存起来,这样下次请求时就不需要再次加载。你可能会得到一些像这样的代码:

const transactions = {};

getTransaction(id, cb) {
  if (transactions[id]) {
    cb(transaction[id]);                                     *1*
  } else {
    load(`/transactions/${id}`).then(transaction => {        *2*
      transactions[id] = transaction;
      cb(transaction);                                       *3*
    })
  }
}

getTransaction(405, transaction => {
  // do something with transaction
})
  • 1 如果你已经有了数据,立即调用回调。

  • 2 假设一个虚构的加载函数,它获取数据并直接返回

  • 3 如果你已加载数据,一旦你有它就调用回调。

这里你使用一个假想的 load 函数,它从 URL 获取数据并通过承诺直接返回它。getTransaction 函数有效地缓存了网络的请求,这样每个交易只需要加载一次。但你不得不回到回调,并失去了承诺的许多好处。例如,你将如何添加错误处理?使用 Promise.resolve 重写 getTransaction 以始终返回一个承诺:

const transactions = {};

getTransaction(id) {
  if (transactions[id]) {
    return Promise.resolve(transactions[id]);          *1*
  } else {
    const loading = load(`/transactions/${id}`);       *2*
    loading.then(transaction => {
      transactions[id] = transaction;                  *3*
    });
    return loading;                                    *4*
  }
}

getTransaction(405).then(transaction => {
  // do something with transaction
}, err => {                                            *5*
  // handle error
})
  • 1 如果你已经有了数据,使用 promise.resolve 返回它。

  • 2 创建一个承诺来加载交易。

  • 3 当承诺解析时,将交易内部添加到你的缓存中。

  • 4 返回承诺。

  • 5 你现在可以轻松地添加错误处理。

你的 getTransaction 函数现在总是返回一个承诺。因为承诺已经被返回,你可以在 getTransaction 函数外部轻松添加错误处理,而无需 getTransaction 函数担心任何错误处理。

当调用 getTransaction 函数时,如果值已经在你的缓存中,你将使用 Promise.resolve 返回它,这会创建一个已经解决的承诺,并给出交易。如果你需要加载交易,首先创建一个加载交易的承诺。一旦加载,你将内部将新交易添加到你的缓存中。你也会返回这个承诺。这显示了你可以用承诺做的另一件事——你注意到了吗?你可以对承诺多次调用 .then。在这里,你内部在 loading 承诺上调用 .then 以将交易添加到你的缓存中。你也在外部对同一个承诺调用 .then 以实际对交易进行操作。

Promise.reject()Promise.resolve 的工作方式相同,只是结果承诺自动处于 拒绝 状态而不是 解决 状态。你还可以向 Promise.reject 传递一个值,该值将被传递给处理程序:

Promise.reject('my value').then(null, reason => {
  console.log('Rejected with:', reason);               *1*
});
  • 1 拒绝原因:my value

有时你需要从几个不同的位置获取数据,并在做任何事情之前等待所有数据都加载完成。让我们考虑一个新网站,该网站有作者的简介页面。简介页面显示了作者的信息并列出他们所写的文章。你希望在两者都加载完成之前显示一个加载屏幕。你可以使用 Promise.all 如此操作:

function getAuthorAndArticles(authorId) {
  const authorReq = load(`/authors/${authorId}/details`)
  const articlesReq = load(`/authors/${authorId}/articles`)

  return Promise.all([authorReq, articlesReq])                   *1*
}

getAuthorAndArticles(37).then( ([author, articles]) => {         *2*
  // author and articles have loaded
});
  • 1 Promise.all 接受一个承诺数组并返回一个单独的承诺。

  • 2 新的承诺(promise)返回前一个承诺中所有值的数组。

在这里,你创建了加载作者及其文章的承诺。你将这些承诺的数组传递给 Promise.all,它返回一个单独的承诺。新的承诺将等待所有承诺都解决,并将从之前的承诺中给出的所有值作为一个数组给出。如果任何一个承诺失败,新的承诺也会因为第一个失败的承诺的错误而失败。

Promise.racePromise.all 的工作方式类似:它接受一个承诺数组并返回一个新的承诺。但是,这个新的承诺会在它所给的第一个承诺解决时立即解决,并给出其值。

快速检查 30.3

Q1:

在以下示例中,将记录哪个数字?

 Promise.all([
   Promise.resolve(1),
   Promise.reject(2),
   Promise.resolve(3),
   Promise.resolve(4)
 ]).then(
   num => console.log(num),
   num => console.log(num)
)

QC 30.3 答案

A1:

2

概述

在本节课中,你学习了如何使用承诺(promises)的基础知识。

  • 承诺(promise)是一个表示最终或未来值的对象。

  • 通过在承诺的 .then 上传递一个回调来访问这个值。

  • 可以向 .then 传递一个可选的第二个回调来处理承诺被拒绝的情况。

  • 可以在承诺上多次调用 .then

  • 如果 Promise 处于挂起状态,.then 将等待直到 Promise 解决。

  • 如果 Promise 已解决,.then 仍然会提供一个值。

  • Promise.resolve 创建一个已解决的 Promise。

  • Promise.reject 创建一个已拒绝的 Promise。

  • Promise.all 接受一个 Promise 数组,并返回一个新的 Promise,该 Promise 等待所有这些 Promise 都解决。

  • Promise.race 接受一个 Promise 数组,并返回一个新的 Promise,该 Promise 只等待第一个解决。

让我们看看你是否掌握了这些:

Q30.1

假设你有三个端点:

  1. /user/4XJ/credit_availability
  2. /transunion/credit_score?user=4XJ
  3. /equifax/credit_score?user=4XJ

第一个端点检查用户的信用可用性。其他两个端点检查用户从 TransUnion 和 EquiFax 的最新信用评分。为了渲染页面,你需要等待信用可用性和至少一个信用评分。使用任何基于 Promise 的 AJAX 库,并创建一个新的 Promise,该 Promise 结合了Promise.allPromise.race来实现这一点。

第 31 课:高级 Promise

在阅读完第 31 课后,你将能够

  • 创建你自己的 Promise

  • 使用 Promise 包装基于回调的方法

  • 使用并理解如何正确编写嵌套的 Promise

  • 理解错误处理如何在 Promise 链中传播

在上一课中,你学习了 Promise 的基础知识。现在我们将更深入地探讨创建新的 Promise 以及将异步代码转换为使用 Promise 的方法。我们将探讨高级错误处理、使用 Promise 进行多个异步调用以及其他高级用法。

考虑这一点

有时候你需要进行多次异步调用以获取所需的数据。一块数据依赖于另一块数据,而另一块数据又依赖于另一块数据。然而,所有这些数据都必须从不同的位置获取。传统上,你不得不将这些视为三个不同的操作,每个操作都有三个不同的错误处理程序。如果你能将其转换为一个单一的操作,并使用一个错误处理程序会怎样呢?

31.1. 创建 Promise

通过使用一个函数参数实例化一个新的Promise对象来创建 Promise,new Promise(fn)。传递给 Promise 的函数本身应该接受两个参数。第一个参数是一个函数,用于解决Promise,第二个参数是一个函数,用于拒绝Promise:

const later = new Promise((resolve, reject) => {
  // Do something asynchronously
  resolve('alligator');
});

later.then(response => {
  console.log(response);        *1*
});
  • 1 “鳄鱼”

假设你正在使用 JavaScript 加载一张图片。经典的方法是创建一个Image对象,并设置一个src属性,将onloadonerror回调分配给它,如下所示:

const img = new Image();
img.onload = () => // Do something when image loads
img.onerror = () => // Do something when image fails
img.src = 'https://www.fillmurray.com/200/300';

如果你想要将图片加载转换为 Promise,你可以在它周围包装一个Promise,并将onloadonerror分别分配给你的解决和拒绝函数:

function fetchImage(src) {
  return new Promise((res, rej) => {
    const img = new Image();
    img.onload = res;
    img.onerror = rej;
    img.src = src;
  });
}

fetchImage('https://www.fillmurray.com/200/300').then(
  () => console.log('image loaded'),
  () => console.log('image failed')
);

在上一课中,我们看到了 Promise 对象有一些辅助方法,如 Promise.resolvePromise.reject,但如果你想要承诺在解析前等待五秒,这将是一个方便的基于承诺的 setTimeout 版本。实际上,你可以用承诺包裹 setTimeout 来创建这样的工具:

function wait(milliseconds) {
  return new Promise((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

wait(5000).then(() => {
  // 5 seconds later...
});

你可以使用这种技术将任何基于回调的函数转换为承诺。例如,假设你正在使用 navigator.geolocation.getCurrentPosition 函数,这是一个基于回调的函数。它接受一个 success 回调和一个 error 回调,如下所示:

navigator.geolocation.getCurrentPosition(
  location => {
    // do something with user's geo location
  },
  error => {
    // Handle error
  }
)

你可以这样包裹它:

function getGeoPosition(options) {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, options);
  });
}

getGeoPosition().then(
  () => // do something with user's geo location
)

如果它需要一个回调,你可以将其包裹在一个承诺中!

快速检查 31.1

Q1:

你如何将这个 registerUser 函数包裹在一个承诺中?

 registerUser(userData, (error, user) => {
   if (error) {
     // handle error
   } else {
     // do something with new user
   }
});

| |

QC 31.1 答案

A1:

 function registerUserAsync(userData) {
   return new Promise(resolve, reject) {
     registerUser(userData, (error, user) => {
       if (error) {
         reject(error);
       } else {
         resolve(user);
       }
     });
   }
}

31.2. 嵌套的承诺

在关于承诺的上一课中,我们介绍了如何使用返回承诺的 AJAX 库。但你可以使用新的 Web Hypertext Application Technology Working Group (WHATWG) 标准方法 fetch 来进行 AJAX 请求。为此,你需要使用多个承诺:

fetch("https://api.github.com/users/jisaacks/orgs")
  .then(resp => {
    resp.json().then(results => {
      // do something with results
    });
  });
//

这开始看起来像是回调地狱,但实际上承诺并不是这样使用的。每次你在承诺上调用 then(),它都会返回一个新的承诺。这使得承诺可以链式调用:

fetch("https://api.github.com/users/jisaacks/orgs")
  .then(resp => resp.json())
  .then(results => {
    // do something with results
  });

注意你的 thens 现在不再是嵌套的,而是被链式连接在一起。

当你在承诺上调用 then() 时,你传递一个函数作为参数。该函数返回的任何内容都将作为链中下一个 then() 的值:

Promise.resolve(1)
  .then(number => number + 1)         *1*
  .then(number => number + 1)         *2*
  .then(number => number + 1)         *3*
  .then(number => {
    console.log(number)               *4*
  });
  • 1 1 + 1 = 2

  • 2 2 + 1 = 3

  • 3 3 + 1 = 4

  • 4 4

有一个例外:如果传递给 then() 的函数返回一个承诺,那么 then() 返回的承诺将等待该承诺解析或拒绝一个值,并执行相同的操作:

const time = new Date().getTime();
const logTime = () => {
  const seconds = (new Date().getTime() - time);
  console.log(seconds/1000, 'have elapsed.');
}
wait(5000)
  .then(() => {
    logTime();                  *1*
    return wait(5000);
  })
  .then(() => {
    logTime();                  *2*
    return wait(2000);
  })
  .then(() => {
    logTime();                  *3*
  });
  • 1 “已过去。”

  • 2 “已过去。”

  • 3 “已过去。”

在这里,第一个承诺指定等待 5 秒。当它解析时,你记录过去的时间。你得到 5.004,但这只是因为你在底层使用的 setTimeout 并不保证精确的时间。该函数返回另一个承诺,设置为再等待 5 秒。因此,链中的下一个 then() 在总共约 10 秒后解析。然后你返回另一个等待 2 秒的承诺,导致最终的 then() 在总共约 12 秒后解析。

重要的是要记住,这只有在 then 内部的函数返回承诺时才有效。then 内部的未返回的承诺将不起作用:

wait(5000)
  .then(() => {
    logTime();             *1*
    wait(5000);
  })
  .then(() => {
    logTime();             *2*
  });
  • 1 “已过去。”

  • 2 “已过去。”

这次,第二个 wait 没有返回,所以链中的下一个 then() 没有等待它。

快速检查 31.2

Q1:

在以下虚构的一组函数中,每个承诺都是嵌套的。你如何重写它以使用链式连接?

 getUserPosition().then(position => {
   getDestination(position).then(destination => {
     calculateRoute(destination => {
       // render map
     });
   });
});

| |

QC 31.2 答案

A1:

getUserPosition()
  .then(position => getDestination(position))
  .then(destination => {
    // render map
 });

31.3. 捕获错误

在上一节中,我们看到了可以通过多个 then() 将承诺链式连接起来,以创建更复杂的承诺。你最近也了解到 then() 接受一个额外的回调来处理任何错误。这引发了一个问题:“如果我链式连接多个 then(),我是否需要多个错误处理器?” 答案是,你可以,但不需要。每当承诺拒绝时,它将通过其上的任何承诺向上冒泡,直到找到第一个错误处理器:

Promise.resolve()
  .then(() => console.log('A'))
  .then(() => Promise.reject('X'))
  .then(() => console.log('B'))
  .then(null, err => console.log('caught:', err))
  .then(() => console.log('C'));

之前的例子将记录以下内容:

  • A

  • 捕获:X

  • C

注意,它没有记录 B,这是在拒绝之后但在错误处理器之前发生的。但它确实记录了 C,这是在错误处理器之后发生的。这是因为当承诺拒绝时,其后的任何承诺都不会解决,直到拒绝被捕获。一旦捕获,链中的任何后续承诺都将能够解决。

注意你如何使用 null 作为第一个参数,因为你只关心在那个步骤捕获错误。有一个方便的方法 catch 正是为了这个目的。之前的例子可以写成这样:

Promise.resolve()
  .then(() => console.log('A'))
  .then(() => Promise.reject('X'))
  .then(() => console.log('B'))
  .catch(err => console.log('caught:', err))
  .then(() => console.log('C'));

你不必使用 Promise.reject 或在承诺内部显式调用 reject() 来使承诺拒绝。任何 JavaScript 错误都会导致拒绝:

Promise.resolve()
  .then(() => { throw "My Error"; })
  .catch(err => console.log('caught:', err));     *1*
  • 1 捕获:我的错误

让我们来看另一个例子:

ajax('/my-data.json')
  .then(
    res => {
      throw "Some Error";
    },
    err => {
      console.log('First catcher: ', err);       *1*
    }
  )
  .catch(
    err => {
      console.log('Second catcher:', err);       *2*
    }
  );
  • 1 这不会捕获错误。

  • 2 这将捕获错误。

当你向 .then() 提供两个函数时,第一个函数在承诺解决时运行,第二个函数在承诺拒绝时运行。但如果第一个函数中存在错误,第二个函数将不会捕获它。它将冒泡到从 .then() 返回的下一个承诺,并导致该承诺拒绝。因此,为了捕获错误,你必须从该承诺中捕获它。参见图 31.1。

图 31.1. 承诺错误处理

因为每次调用 .then.catch 都会返回一个新的承诺,这意味着总有一个承诺在末尾,它可能会未被捕获。最初,这引起了一些担忧,因为如果你没有从承诺中捕获错误,承诺就会“吞下”错误,并且没有报告。但大多数环境今天都会引发未处理的承诺拒绝错误。

快速检查 31.3

Q1:

在以下示例中,如果 handlerOne 抛出错误,哪个捕获器将捕获错误?如果 handlerTwo 抛出错误呢?

myPromise()
  .then(handlerOne, catcherOne)
 .then(handlerTwo, catcherTwo)

| |

QC 31.3 答案

A1:

catcherTwo 将从 handlerOne 捕获。handlerTwo 将导致未处理的承诺拒绝。

摘要

在本课中,你学习了 promise 的最先进用法。

  • 你可以使用一个接受 resolvereject 函数的回调来创建一个新的 promise。

  • .catch(errCatcher) 函数是 .then(null, errCatcher) 的简写。

  • 每次调用 .then().catch() 都会返回一个新的 promise,从而创建一个 promise 链。

  • 前一个 promise 的响应或拒绝会冒泡到 promise 链中。

让我们看看你是否掌握了这个:

Q31.1

以下函数 getArticle 是基于回调的。它还使用了一个名为 load 的函数,该函数也是基于回调的。编写一个函数,将 load 函数用 promise 包装。然后重写 getArticle 以使其基于 promise 而不是基于回调:

function getArticle(id, callback) {
  load(`/articles/${id}`, (error, article) => {
    if (error) {
      callback(error);
    } else {
      load(`/authors/${article.author_id}`, (error, author) => {
        if (error) {
          callback(error);
        } else {
          load(`/articles/${id}/comments`, (error, author) => {
            if (error) {
              callback(error);
            } else {
              callback(null, [article, comments, author])
            }
          });
        }
      });
    }
  });
}

getArticle(57, (error, results) => {
  if (error) {
    // show error
  } else {
    // render article
  }
});

第 32 课. 异步函数

在阅读第 32 课 lesson 32 之后,你将能够

  • 使用同步语法编写异步代码

  • 使用生成器编写异步代码

  • 使用异步函数编写异步代码

JavaScript 难以学习的一个原因是它的异步特性。异步代码难以思考,但正是这种异步特性使得 JavaScript 成为一种非常适合网络的语言。Web 应用程序充满了异步操作——从加载资源到处理用户输入。异步函数的目的是使编写和思考异步代码变得容易。

考虑这一点

到目前为止,在本单元中,我们一直在处理 promise,这已经证明比使用回调的传统异步代码更有用。但是,在使用 promise 时,你仍然必须 异步地思考,这比同步思考要困难得多。像 PHP 或 Ruby 这样的语言是同步的,所以需要时间完成的操作更容易思考,但它们是 阻塞的。这意味着它们会停止任何代码的执行,并阻塞其他所有事情的发生,直到它们完成。如果你能够得到两者的最佳之处,编写看起来像阻塞的代码——使其更容易思考——但仍然异步操作,那会怎么样呢?

32.1. 使用生成器的异步代码

当我们在第 18 课讨论生成器函数时,你学习了如何通过 yield 一个值来创建暂停点。每当你在生成器内部 yield 一个值时,它会在代码的该点暂停,直到再次在生成器上调用 .next()。作为提醒,当你对生成器调用 next() 时,你能够向生成器传递一个值,并返回一个 value 和一个 done 属性,表示生成器已经完成了什么。

如果生成器要 yield 一个承诺怎么办?如果运行生成器的代码等待该承诺解决,然后调用 next() 函数,并将从承诺中解决的值传递回生成器怎么办?如果你在循环中这样做,允许生成器连续产生几个承诺怎么办?

编写一个函数,它接受一个生成器函数并执行此操作。不要过分关注理解这个 runner 函数。你如何使用它比它的内部工作更重要:

const runner = gen => (...args) => {                       *1*
  const generator = gen(...args);                          *2*
  return new Promise((resolve, reject) => {                *3*
    const run = prev => {                                  *4*
      const { value, done } = generator.next(prev);        *5*
      if (done) {
        resolve(value);                                    *6*
      } else if (value instanceof Promise) {
        value.then(run, reject);                           *7*
      } else {
        run(value);                                        *8*
      }
    }
    run();                                                 *9*
  });
}
  • 1 取一个带有参数的生成器函数并返回一个新的函数。

  • 2 使用相同的参数调用生成器函数。

  • 3 返回一个新的承诺。

  • 4 创建一个 run 函数。

  • 5 将任何前一个值传递给生成器,同时从中获取下一个值。

  • 6 如果生成器已完成,则使用最终值解决承诺。

  • 7 如果值是一个承诺,告诉它在解决时再次调用 run。

  • 8 否则立即再次调用 run。

  • 9 通过调用 run 启动循环。

这里有一个函数,它正好做了我们刚才讨论的事情。你可以给它一个生成器函数,然后得到一个新的函数,它可以 yield 承诺,并在解决后获取它们的值。这个 runner 函数相当复杂,所以如果你发现它难以理解,不要担心。重点是你可以如何使用这个 runner 函数。

这里有一个使用 fetch 加载图像的例子,该图像需要三个承诺:

fetch('my-image.png')
  .then(resp => resp.blob())
  .then(blob => createImageBitmap(blob))
  .then(image => {
    // do something with image
  });

如果你使用 runner 函数与生成器一起使用,你可以这样编写它:

const fetchImageAsync = runner(function* fetchImage(url) {
  const resp = yield fetch(url);
  const blob = yield resp.blob();
  const image = yield createImageBitmap(blob);
  // do something with image
});

fetchImageAsync('my-image.png');

或者,如果你想使这个函数更通用,你可以这样做:

const fetchImageAsync = runner(function* fetchImage(url) {
  const resp = yield fetch(url);
  const blob = yield resp.blob();
  const image = yield createImageBitmap(blob);

  return image;
});

fetchImageAsync('my-image.png').then(image => {
  // do something with image
});

通过使用 runner 函数包装你的生成器,每次你 yield 一个承诺时,你都会从该承诺中获取值。这允许你编写看起来是同步或阻塞的代码,但实际上仍然是异步的。

在撰写本文时,你在这些例子中使用的 createImageBitmap 函数仅由最新版本的 Mozilla Firefox 和 Google Chrome 支持。

快速检查 32.1

Q1:

你会如何转换以下代码以使用生成器和 runner 函数?

getUserPosition()
  .then(position => getDestination(position))
  .then(destination => {
    // render map
 });

| |

QC 32.1 答案

A1:

const renderMap = runner(function* renderMap() {
  const position = yield getUserPosition();
  const destination = yield getDestination(position);
  // render map
});

renderMap();

32.2. 异步函数

学习如何使用生成器、承诺和 runner 函数编写异步代码的酷之处在于,你不必学习任何新东西就可以跳转到异步函数。这是因为异步函数只是生成器 + 承诺方法的语法糖。你真正需要学习的就是新关键字。为了表示一个函数是异步函数,你需要在它前面加上 async 关键字。然后,你不再使用 yield 关键字,而是使用 await 关键字。就是这样!

之前的 fetch 图像函数可以用异步函数重写:

async function fetchImage(url) {
  const resp = await fetch(url);
  const blob = await resp.blob();
  const image = await createImageBitmap(blob);

  return image;
};

fetchImage('my-image.png').then(image => {
  // do something with image
});

注意你做了多小的改变。你不再使用生成器,也不再需要 runner 函数。你只需要表明你的函数是 async 的,然后你可以 await promise 而不是 yield 它们。

异步函数总是会返回一个 promise。如果异步函数本身返回一个值,返回的 promise 将解析为该值。如果异步函数返回一个 promise,异步函数返回的 promise 将解析那个 promise 的值。这意味着你可以这样简化你的 fetchImage 函数:

async function fetchImage(url) {
  const resp = await fetch(url);
  const blob = await resp.blob();
  return createImageBitmap(blob);
};

fetchImage('my-image.png').then(image => {
  // do something with image
});

在异步函数中,当你 await 一个 promise 时,它将等待 promise 解析然后返回 promise 的值。

快速检查 32.2

Q1:

你会如何转换以下代码以使用异步函数?

getUserPosition()
  .then(position => getDestination(position))
  .then(destination => {
    // render map
 });

| |

QC 32.2 答案

A1:

async function renderMap() {
  const position = await getUserPosition();
  const destination = await getDestination(position);
  // render map
};

renderMap();

32.3. 异步函数中的错误处理

如果你 await 的任何一个 promise 被拒绝,你可以像这样从异步函数外部处理拒绝:

async function fail() {
  const msg = await Promise.reject('I failed.');
  return 'I Succeeded.';
};

fail().then(msg => console.log(msg), msg => console.log(msg))        *1*
  • 1 “我失败了。”

我看到很多人在异步函数中使用 try-catch 语句。如果实际抛出了错误,这将有效。然而,记住,错误会导致 promise 拒绝,但你也可以手动拒绝一个 promise。在后一种情况下,try-catch 语句不会捕获到拒绝:

async function tryTest() {
  try {
    return Promise.reject('My Rejection Msg');
  } catch(err) {
    return Promise.resolve(`caught: ${err}`);
  }
}

tryTest().then(response => {
  console.log('response:', response);
});                                        *1*
  • 1 未捕获(在 promise 中)我的拒绝信息

在这个例子中,try-catch 语句没有捕获到 Promise.reject,导致你的 tryTest 函数抛出了 uncaught in promise 错误。

你可以编写自己的 promise 包装器,使异步函数中的错误处理更容易。你只需要一个函数,它接受一个 promise 并返回一个新的 promise,该 promise 总是解析为一个数组。如果成功,第一个值将是结果;如果 promise 被拒绝,第二个值将是拒绝:

function rescue(promise) {
  return promise.then(
    res => [res, null],
    err => [null, err]
  );
}

使用这个小小的辅助函数,你现在可以像这样编写你的 tryTest 函数:

async function tryTest() {
  const [res, err] = await rescue(Promise.reject('err err'));
  if (err) {
    return `caught: ${err}`
  } else {
    return res;
  }
}

tryTest().then(response => {
  console.log('response:', response);
});                                      *1*
  • 1 response: caught: err err

让我们假设你正在构建一个相册,并想使用你的 fetchImage 函数获取所有图片。你需要等待所有图片加载完成后再渲染相册。你可以使用 Promise.all 来实现这一点,但这意味着如果任何图片加载失败(可能性很高),整个操作将会拒绝。相反,你希望跳过失败的图片,只获取所有成功加载的图片的结果。使用异步函数来实现这一点:

async function allResolved(promises) {
  const resolved = [];                              *1*
  const handlers = promises.map(promise => (
    promise
      .then(resp => resolved.push(resp))            *2*
      .catch(() => { /* skip rejects */ })          *3*
  ));
  await Promise.all(handlers);                      *4*
  return resolved;                                  *5*
}
  • 1 一个用于存储所有解析值的数组

  • 2 当 promise 解析时,将响应添加到数组中。

  • 3 如果 promise 拒绝,则不执行任何操作。

  • 4 等待所有 promise 解析(或拒绝)。

  • 5 返回所有解析值。

在这里,你使用了一个异步函数来处理一个 promise 数组,获取所有响应并跳过所有拒绝。你可以像这样在你的相册中使用它:

const sources = ['image1.png', 'image2.png', ...];
allResolved(sources.map(fetchImage)).then(images => {
  // build photo gallery with loaded images
});

任何时候您在处理多个承诺时,考虑是否使用异步函数可以简化事情。

快速检查 32.3

Q1:

如果在异步函数中await的承诺拒绝,会发生什么?

| |

QC 32.3 答案

A1:

如果未捕获,异步函数返回的承诺将拒绝。

概述

在本课中,您学习了异步函数的工作原理以及如何使用它们。

  • 异步函数是生成器和运行函数的语法糖。

  • 异步函数是通过在function关键字之前使用async关键字声明的。

  • 在异步函数内部,您可以使用await等待一个承诺以获取其解析的值。

  • 异步函数总是返回一个承诺。

  • 异步函数返回的值将解析为异步函数返回的承诺。

  • 如果异步函数内部的承诺拒绝,异步函数返回的承诺将拒绝。

让我们看看你是否明白了:

Q32.1

这是之前课程练习的解决方案。通过将其转换为异步函数可以大大简化。让它发生:

function getArticle(id) {
  return Promise.all([
    loadAsync(`/articles/${id}`),
    loadAsync(`/articles/${id}/comments`)
  ]).then(([article, comments]) => Promise.all([
    article,
    comments,
    loadAsync(`/authors/${article.author_id}`)
  ]));
}

第 33 课. 可观察对象

在阅读第 33 课之后,您将能够

  • 创建您自己的可观察对象

  • 订阅可观察对象

  • 使用高阶组合函数来构建新的可观察对象

  • 创建您自己的组合函数来构建可观察对象

可观察对象是您可以订阅数据流的对象。可观察对象类似于承诺,但承诺只解析或拒绝一次,而可观察对象可以无限期地持续发出新值。如果您将承诺视为可以围绕setTimeout包装的异步数据,那么可观察对象将是可以围绕setInterval包装的异步数据。

注意

为了在今天使用可观察对象,在撰写本文时,您需要使用开源实现之一。目前,zen-observable 是最接近规范实现的:github.com/zenparsing/zen-observable

| |

考虑这一点

WebSockets 允许前端订阅后端的事件。使用 WebSockets,服务器可以在新值可用时将其推送到客户端。没有它们,您需要客户端通过不断轮询服务器,询问是否有新数据可用来从后端拉取新项目。使用 WebSockets,您可以说客户端正在从服务器进行观察。不是很好吗,能够使用这种相同的推送机制将新数据发送给任何观察者?

33.1. 创建可观察对象

创建一个可观察对象类似于创建一个承诺,从某种意义上说,您通过一个回调函数调用构造函数。您给可观察对象提供的回调函数接受一个观察者作为参数,您将数据发送给它:

const myObservable = new Observable(observer => {
  // send things to observer
});

一个观察者只是任何具有 startnexterrorcomplete 方法的对象,所有这些方法都是可选的:

  • start——当观察者订阅可观察对象时的一次性通知

  • next——发送可观察对象向观察者发送的数据的方法

  • error——与 next() 类似,但用于向观察者发送错误

  • complete——当可观察对象完成时的一次性通知

假设,例如,你将要制作一个始终显示当前时间的时钟小部件。你可以创建一个可观察对象,每秒持续发出当前时间:

const currentTime$ = new Observable(observer => {
  setInterval(() => {
    const currentTime = new Date();
    observer.next(currentTime);            *1*
  }, 1000);
});
  • 1 以一秒间隔通知观察者当前时间

然后,你可以这样 订阅 此可观察对象:

const currentTimeSubscription = currentTime$.subscribe({
  next(currentTime) {
    // show current time
  }
});

这很好,但如果你在应用程序中到达一个不再需要显示当前时间的点怎么办?也许用户关闭了时钟小部件或导航到了应用程序中的新部分,那里不需要它。目前,没有停止 setInterval 运行的方法。

你传递给 Observable 构造函数的回调函数可以返回一个清理函数,该函数在可观察对象取消订阅时执行。

const currentTime$ = new Observable(observer => {
  const interval = setInterval(() => {
    const currentTime = new Date();
    observer.next(currentTime);
  }, 1000);

  return () => clearInterval(interval);           *1*
});
  • 1 当可观察对象从订阅中取消时,此清理函数将运行。

你现在可以成功取消对 currentTime$ 可观察对象的订阅,并告诉它不再运行间隔:

currentTimeSubscription.unsubscribe();

注意你创建的可观察对象名称后面有一个尾随的 $。这不是必需的,但这是一个常见的约定,用来表示以 $ 结尾的变量是一个可观察对象。此类变量的发音应该是复数:你会将 currentTime$ 称为“当前时间”或“当前时间可观察对象”。

Observable 对象还有一些方便的方法用于创建可观察对象:Observable.of()Observable.from()。前者接受任意数量的参数,并创建一个包含这些值的可观察对象。后者接受一个可迭代对象,并从中创建一个可观察对象:

Observable.from(new Set([1, 2, 3])).subscribe({
  start() {
    console.log('--- started getting values ---');
  },
  next(value) {
    console.log('==> next value', value);
  },
  complete(a) {
    console.log('--- done getting values ---');
  }
});

此订阅将记录以下五个字符串:

  1. --- 开始获取值 ---

  2. 下一个值 1

  3. 下一个值 2

  4. 下一个值 3

  5. --- 完成获取值 ---

正如你所见,当你通过 Observable.from(iterable) 创建可观察对象时,它与 Observble.of(...iterable) 等效。

快速检查 33.1

Q1:

在以下可观察对象完成之前,将发出多少个值?

const a = Observable.from('ABC');
const b = Observable.of('ABC', 'XYZ');

QC 33.1 答案

A1:

  1. 3 (‘A’,‘B’,‘C’)
  2. 2 (‘ABC’,‘XYZ’)

33.2. 组合可观察对象

可观察对象最酷的方面之一是你可以使用高阶组合子来组合它们,从而创建新的独特可观察对象。这意味着你可以将可观察对象发出的值流视为一个列表,你可以对其运行诸如 mapfilter 等方法。想想 lodash^([1])应用于事件时的强大功能。

¹

lodash.com

你将要在你的 currentTime$ 可观察对象上使用一些组合函数。目前,它每秒发出一个新的日期对象:

-[date object]-[date object]-[date object]-[date object]-

你可以将这个日期流映射到格式化成你想要的日期字符串流:

currentTime$.map(date => `${date.getHours()}:${date.getMinutes()}`);

现在,你有一个时间字符串流:

-"15:19"-"15:19"-"15:19"-"15:20"-

注意你得到了重复的值;实际上,我们每分钟会得到 60 个重复值。你的时钟小部件不需要在下一个时间字符串改变时通知,因此你可以应用另一个组合函数:

currentTime$.map(data => `${date.getHours()}:${date.getMinutes()}`).distinct();

现在,你有一个唯一的时字符串流:

-"15:19"---"15:20"---"15:21"--

每次你对一个可观察对象应用一个组合函数,你都会得到一个新的可观察对象。原始的可观察对象永远不会改变。

Observable 规范实际上并没有包含任何组合函数,但它旨在通过使用它们来组合。然而,像 zen-observable 和 RxJS 这样的库包含了多个组合函数。

快速检查 33.2

Q1:

当你对 number$ 可观察对象应用 map 组合函数时,number$ 可观察对象是如何被修改的?

const number$ = Observable.from(1, 2, 3, 4, 5, 6, 7, 8, 9);
const square$ = number$.map(num => num * num);

| |

QC 33.2 答案

A1:

number$ 可观察对象根本没有被修改。创建了一个新的可观察对象。

33.3. 创建可观察对象组合器

你可能可以找到任何你想要的组合函数的开源解决方案,但理解如何创建自己的组合函数应该会消除当你开始组合它们时实际发生的事情的神秘感。创建一个从可观察对象中过滤值的组合函数:

function filter (obs$, fn) {                               *1*
  return new Observable(observer => obs$.subscribe({       *2*
    next(val) {
      if (fn(val)) observer.next(val);                     *3*
    }
  }));
}
  • 1 接受一个可观察对象和一个用于测试值是否应该被过滤的函数。

  • 2 返回一个新的可观察对象。

  • 3 如果通过过滤测试,则从上一个可观察对象代理值到下一个观察者。

这里有一个函数,它接受一个现有的可观察对象和一个函数,用于测试值是否应该被过滤掉。然后你返回一个订阅现有可观察对象的新可观察对象。新的可观察对象代理现有可观察对象的值,但只有当它们通过过滤测试时。你可以这样使用这个函数:

filter(Observable.of(1, 2, 3, 4, 5), n => n % 2).subscribe({
  next(val) {
    console.log('filtered: ', val);
  }
});

这里你使用你创建的 filter 函数来过滤数字可观察对象,得到一个只包含奇数的新的可观察对象:

-1-2-3-4-5-

  filtered to

-1--3--5-

当然,一个更完整的组合函数也会代理 errorcomplete。为了简洁起见,我省略了这一点。

快速检查 33.3

Q1:

以下组合函数函数将组合哪些类型的操作?

 function myCombinator(obs$, fn) {
   return new Observable(observer => obs$.subscribe({
     next(val) {
       observer.next(fn(val));
     }
   }));
}

| |

QC 33.3 答案

A1:

它将执行一个 map 操作。

摘要

在本课中,你学习了关于可观察对象的基础知识。

  • 你通过传递给构造函数一个接受观察者的回调来创建一个新的可观察对象。

  • 观察者是一个具有 startnexterrorcomplete 方法的对象。

  • 可以通过应用高阶组合子来组合可观察对象,以创建更具体的可观察对象。

  • 组合子只是一个订阅现有可观察对象并返回新可观察对象的函数

让我们看看你是否掌握了这个:

Q33.1

创建一个名为 collect 的组合子函数,它跟踪另一个可观察对象发出的所有值,并发出到目前为止所有值的数组。然后创建另一个名为 sum 的组合子,它接受发出值数组的可观察对象并求和。然后组合这些组合子,在 Observable.of(1, 2, 3, 4) 上创建一个新的可观察对象。最终的可观察对象应该发出值 1, 3, 6 和 10。

第 34 课:项目:Canvas 图像画廊

在这个项目中,你将构建一个基于 canvas 的图像画廊。

你的图像画廊将从 Unsplash 加载随机图像([1]),并使用淡入淡出过渡渲染到 HTML canvas 元素。你将使用多个承诺来实现这一点。你将使用承诺和异步函数来获取图像。你还将使用承诺来编排过渡和图像之间的延迟,以及需要完成的其他连接。

¹

unsplash.com/license

注意

你将使用本书代码中包含的起始文件夹开始你的项目。如果你在某个时候遇到困难,你还可以查看包含完成代码的最终文件夹。起始文件夹是一个已经设置好使用 Babel 和 Browserify 的项目(见单元 0);你只需运行 npm install 来设置。如果你还没有阅读单元 0,你应该在继续这个项目之前先阅读它。

还包含一个名为 index.html 的文件,这是应用程序运行的地方。它已经包含了所需的全部 HTML 和 CSS,所以你只需在将 JavaScript 文件打包后,在浏览器中打开它即可。src 文件夹是放置所有 JavaScript 文件的地方。dest 文件夹是运行 npm run build 后打包的 JavaScript 文件将存放的地方。你需要记住,每次你修改代码时,都要运行 npm run build 来编译你的代码。

34.1. 获取图像

由于你将制作一个图像画廊,首先编写一个获取图像的函数。你可以使用古老的在 Image 对象上设置 src 属性的方法:

function fetchImage(src, handleLoad, handError) {
  const img = new Image();
  img.onload = handleLoad;
  img.onerror = handError;
  img.src = src;
}

但使用异步函数和新的 fetch API 来加载和创建可以渲染到 canvas 上的 ImageBitmap

async function fetchImage(url) {
  const resp = await fetch(url);           *1*
  const blob = await resp.blob();          *2*
  return createImageBitmap(blob);          *3*
}
  • 1 等待一个承诺,向给定的 URL 发起请求。

  • 2 等待另一个承诺,从请求的文件创建数据块。

  • 3 返回一个最终承诺,从数据块创建 ImageBitmap。

现在,您应该能够从 URL 加载图像,并最终获取一个 ImageBitmap 对象。您不需要担心任何成功或错误状态的回调,因为您正在使用 promises。编写一个函数来加载一张随机图像:

function getRandomImage() {
  return fetchImage('https://source.unsplash.com/random');
}

此函数将 URL source.unsplash.com/random 作为参数硬编码到您之前创建的 fetchImage 函数中。该 URL 将重定向到 Unsplash 社区中的随机免费图像。这意味着每次您调用此函数时,您都会得到一个最终会生成随机 ImageBitmap 的 promise。

如果您想测试这个函数,现在就可以:

function getRandomImage().then(imgBmp => {
  console.log('loaded:', imgBmp);
});

现在您已经可以获取随机图像,让我们将注意力转向将它们渲染到画布上。

34.2. 在画布上绘制图像

您首先想要做的是获取您 HTML 文件中提供的 canvas 对象的引用:

const gal = document.getElementById('gallery');          *1*
const ctx = gal.getContext('2d');                        *2*
  • 1 获取您的 canvas HTML 元素的引用。

  • 2 获取一个 2D 上下文,您可以使用它开始绘制。

如果您偶然浏览了之前 capstone 中提供的游戏框架代码,这段代码应该看起来很熟悉。每当您在 HTML 画布上绘制时,您必须首先获取一个 2D 或 3D 上下文来绘制。由于图像是二维对象,2D 上下文将完全适用。

2D 上下文恰好有一个名为 drawImage 的方法,它接受一个 ImageBitmap 并将其绘制到画布上:

function draw(img) {                                  *1*
  const gal = document.getElementById('gallery');
  const ctx = gal.getContext('2d');
  ctx.drawImage(img,                                  *2*
    0, 0, img.width, img.height,
    0, 0, gal.width, gal.height
  );
}
  • 1 接受一个 ImageBitmap 作为参数。

  • 2 将 ImageBitmap 绘制到画布上。

注意,drawImageImageBitmap 之后还接受另外八个参数。这些参数如下:

  1. 源 x

  2. 源 y

  3. 源宽度

  4. 源高度

  5. 目标 x

  6. 目标 y

  7. 目标宽度

  8. 目标高度

您可以使用源属性来定义要绘制的图像的一部分。您还可以使用目标属性来指定要绘制到画布上的部分。您指定了要绘制整个图像到画布的整个区域。

在完成此函数之前,您还需要添加一个功能。您将过渡或 渐变 新图像。为了实现这种效果,您需要首先使用透明度或 alpha 绘制图像,然后随着时间的推移,使用稍微更多的 alpha 重新绘制图像以创建渐入效果。确定应用多少 alpha 的逻辑的大部分将发生在另一个函数中。您的绘制函数只需要能够接受一个 alpha 属性并将其应用于您正在绘制的图像:

function draw(img, alpha=1) {
  const gal = document.getElementById('gallery');
  const ctx = gal.getContext('2d');
  ctx.globalAlpha = alpha;
  ctx.drawImage(img,
    0, 0, img.width, img.height,
    0, 0, gal.width, gal.height
  );
}

画布上下文中的 globalAlpha 属性将应用于所有被绘制的元素,因此,在绘制图像之前设置此属性,您可以有效地将 alpha 通道应用于图像。很酷。

现在编写一个函数,它接受一个图像并动画化图像的淡入。你将使用你的draw函数来进行实际的绘制。我们只需要随着时间的推移继续绘制图像的更不透明版本:

function fadeIn(image, alpha) {
  draw(image, alpha);
  if (alpha >= .99) {                                            *1*
    // we are done
  } else {
    alpha += (1-alpha)/24;                                       *2*
    window.requestAnimationFrame(() => fadeIn(image, alpha));    *3*
  }
}
  • 1 如果 alpha 达到 99%或更多不透明度,你已完成过渡。

  • 2 否则增加 alpha。

  • 3 然后在下一个动画帧上使用新的 alpha 值淡入图像。

这个fadeIn函数接受一个图像和一个 alpha 值,并使用requestAnimationFrame递归地增加 alpha 并重新绘制图像。一旦 alpha 达到 99%或更多,你就完成了。但在这个时候你应该做什么?传统上,这将是调用回调函数以表示动画已完成的地方。但请记住,任何可以使用回调的东西都可以被改造成使用承诺。实际上,你可以将整个操作包裹在一个承诺中,并在动画完成后解决它:

function fade(image) {
  return new Promise(resolve => {                             *1*
    function fadeIn(alpha) {                                  *2*
      draw(image, alpha);                                     *3*
      if (alpha >= .99) {
        resolve();                                            *4*
      } else {
        alpha += (1-alpha)/24;                                *5*
        window.requestAnimationFrame(() => fadeIn(alpha));    *5*
      }
    }
    fadeIn(0.1);                                              *6*
  });
}
  • 1 淡入函数将在淡入转换完成后解决承诺。

  • 2 定义一个 fadeIn 函数,该函数在闭包中捕获图像和解决变量。

  • 3 在给定的 alpha 值下绘制图像。

  • 4 如果 alpha 超过.99,解决承诺。

  • 5 如果 alpha 没有达到.99,稍微增加 alpha 并安排另一个 fadeIn。

  • 6 以低 alpha 值开始 fadeIn 过程。

在这里,你将fadeIn函数包裹在一个返回承诺的函数中。现在,fadeIn函数在动画完成后解决承诺。因为图像从未改变,所以你不再需要将图像传递给fadeIn函数,因为它将在闭包中保持引用。你也不再需要外部的fade函数接受一个alpha参数,因为你始终想从.1 开始,并始终想以.99 结束。

你现在应该能够像这样获取一个随机图像并将其绘制到画布上:

getRandomImage().then(fade);

这将获取一个随机图像并将其绘制到画布上。图像甚至可以淡入,而不是突然出现。试试看!

34.3. 重复过程

现在你可以获取一个随机图像并将其淡入画布,所以你只需要一种方法来连续不断地重复执行这个过程。但你不想在最后一个图像之后立即淡入图像;你想要等待一秒钟,以便用户在过渡到下一个图像之前查看图像。首先编写一个函数,在给定的时间段后解决一个承诺:

function wait(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

这个wait函数接受一个参数,指定要等待的毫秒数,并返回一个在指定时间内解决的承诺。内部你告诉setTimeout在指定的毫秒数后解决承诺。现在创建一个名为pause的函数,硬编码为等待一秒半:

const pause = () => wait(1500);

现在,你可以这样使用这个暂停函数:

pause().then(() => {
  // Do something 1.5 seconds later
});

更重要的是,你现在可以创建一个连续循环:

function nextImage() {
  return getRandomImage().then(fade).then(pause).then(nextImage);
}

我喜欢这里的优雅——函数的读取方式正好反映了它的行为。首先你获取一个随机图像,然后淡入该图像,然后暂停,然后转到下一个图像并重新开始整个过程。

在这一点上,你可以调用nextImage函数,并观察你的奇妙画廊每隔几秒钟淡入随机图像。但如果其中一张图像加载失败怎么办?在其当前状态下,这会导致整个系统停止。但你可以轻松解决这个问题:

function start() {
  return nextImage().catch(start);
}

你注意到你在这里做了什么吗?你的start函数通过调用nextImage启动整个动画。只要没有出错,nextImage将继续运行,加载随机图像并淡入。但如果出了问题——例如图像加载失败——你会catch到它,并通过再次调用start并重新启动一切来重新开始整个过程。当然,这对用户来说将是无缝的,因为最新加载的图像仍然会显示,直到下一个加载。这不是很酷吗?

再次,在这个点上,你可以简单地调用start()并完成。从所有目的来看,这会工作得很好。试试看。但你确实创建了一个小的内存泄漏。让我们再次看看那个优雅的函数,它循环遍历随机图像动画:

function nextImage() {
  return getRandomImage().then(fade).then(pause).then(nextImage);
}

每次运行时,它都会添加到现有的承诺链中。它必须这样做,以便将start函数中定义的catch冒泡回上。这意味着您的承诺链会越来越长,直到最终耗尽内存并崩溃。这需要很长时间,因为您仅在约 2 秒的间隔内增加承诺链的长度,但您仍然需要解决这个问题。

而不是一次又一次地添加到同一个承诺链中,你可以在每次创建一个新的承诺链。但如何将这个冒泡回你的catch?简单:你将整个东西包裹在另一个承诺中,如果你的内部承诺链中的任何一个拒绝,拒绝外部承诺:

function repeat(fn) {                           *1*
  return new Promise((resolve, reject) => {     *2*
    const go = () => fn().then(() => {          *3*
      setTimeout(go, 0);                        *4*
    }, reject);                                 *5*
    go();                                       *6*
  });
}
  • 1 选择一个将要重复的初始函数。*

  • 2 返回一个新的承诺。*

  • 3 创建一个新的函数,该函数将调用传入的函数。*

  • 4 当传入的函数解决时,再次调用你的 go 函数。*

  • 5 如果传入的函数拒绝,拒绝你返回的外部承诺。*

  • 6 开始过程。*

这个repeat函数是另一个函数的代理。它代理的函数预期返回一个承诺,因此repeat函数也需要返回一个承诺。内部,每次repeat函数解决时,它都会连续重新调用其代理函数。但它不会添加到单个承诺链中。相反,如果其中任何一个拒绝,它将手动将拒绝传播到它返回的承诺。这意味着不会有更多的内存泄漏。

图 34.1 展示了两种可视化。第一个展示了你的nextImage函数如何使用递归承诺。链将不断变长,因为每个承诺都保留在内存中,以便错误或已解析的值能够一路冒泡到返回的根承诺。最终,这个链将消耗太多内存并崩溃。第二个可视化展示了你可以有一个包装承诺,它重复(非递归地)相同的承诺链,但它们不是相互链接,而是每个都链接到外部承诺(但仅在失败的情况下)。这意味着一旦每个内部承诺解析完成,就没有必要在内存中保留它们,因为它们仅在拒绝时与外部承诺连接。因此,它们不会继续堆叠并导致内存泄漏。

图 34.1. 递归与重复承诺的比较

由于repeat函数现在负责重复操作,你不再需要让你的nextImage函数持续运行。修改它如下:

function nextImage() {
  return getRandomImage().then(fade).then(pause);
}

现在你只需要在start函数中将其包裹在repeat中:

function start() {
  return repeat(nextImage).catch(start);
}

最后,你可以无顾虑地调用start

start();

现在你应该已经拥有了一个图像库,它可以随机显示带有过渡效果的图像,具有自我修复功能,并且可以无限期运行而不会出现内存泄漏。而你实现这一切,无需任何库的帮助,代码行数不超过 100 行!

摘要

在这个最后的里程碑中,你创建了一个图像库,它可以加载随机图像并将它们过渡到画布上。你利用了几个不同的承诺和一个异步函数来实现这一点。通过使用小的承诺并将它们连接起来,你创造了一个优雅的解决方案,用于解决复杂问题,使用了易于维护、自我文档化的代码。

附录. 练习题答案

第 4 课

A1:

{
  let DEFAULT_START = 0;
  let DEFAULT_STEP  = 1;

  window.mylib.range = function (start, stop, step) {
    let arr = [];

    if (!step) {
      step = DEFAULT_STEP;
    }

    if (!stop) {
      stop = start;
      start = DEFAULT_START;
    }

    if (stop < start) {
      // reverse values
      let tmp = start;
      start = stop;
      stop = tmp;
    }

    for (let i = start; i < stop; i += step) {
      arr.push(i);
    }

    return arr;
  }
}

第 5 课

A1:

{
  const DEFAULT_START = 0;
  const DEFAULT_STEP  = 1;

  window.mylib.range = function (start, stop, step) {
    const arr = [];

    if (!step) {
      step = DEFAULT_STEP;
    }

    if (!stop) {
      stop = start;
      start = DEFAULT_START;
    }

    if (stop < start) {
      // reverse values
      const tmp = start;
      start = stop;
      stop = tmp;
    }

    for (let i = start; i < stop; i += step) {
      arr.push(i);
    }

    return arr;
  }
}

第 6 课

A1:

function
maskEmail(email, mask) {
  if (!email.includes('@')) {
    throw new Error('Invalid Email');
  }
  if (!mask) {
    mask = '*';
  }
  const atIndex = email.indexOf('@');
  const masked = mask.repeat(atIndex);
  const tld = email.substr(atIndex);

  return masked + tld;
}

第 7 课

A1:

function
withProps() {
  let stringParts = arguments[0];
  let values = [].slice.call(arguments, 1);
  return stringParts.reduce(function(memo, nextPart) {
    let nextValue = values.shift();
    if (nextValue.constructor === Object) {
      nextValue = Object.keys(nextValue).map(function(key) {
        return `${key}="${nextValue[key]}"`;
      }).join(' ');
    }
    return memo + String(nextValue) + nextPart;
  });
}

let props = {
  src: 'http://fillmurray.com/100/100',
  alt: 'Bill Murray'
};

let img = withProps`<img ${props}>`;

console.log(img);
// <img src="http://fillmurray.com/100/100" alt="Bill Murray">

第 9 课

A1:

function $(query) {
  let nodes = document.querySelectorAll(query);
  return {
    css: function(prop, value) {
      Array.from(nodes).forEach(function(node) {
        node.style[prop] = value;
      });
      return this;
    }
  }
}

第 10 课

A1:

// helper function (not required but removes a lot of boilerplate)
function
inherit() {
  return Object.assign.apply(Object, [{}].concat(Array.from(arguments)))
}

const PASSENGER_VEHICLE = {
  board: function() {
    // add passengers
  },
  disembark: function() {
    // remove passengers
  }
}

const AUTOMOBILE = inherit(PASSENGER_VEHICLE, {
  drive: function() {
    // go somewhere
  }
})

const SEA_VESSEL = inherit(PASSENGER_VEHICLE, {
  float: function() {
    // go somewhere
  }
})

const AIRCRAFT = inherit(PASSENGER_VEHICLE, {
  fly: function() {
    // go somewhere
  }
})

const AMPHIBIAN_AIRCRAFT = inherit(SEA_VESSEL, AIRCRAFT, {
  // do some other stuff
})

第 11 课

A1:

let {
  name: { first: firstName, last: lastName },
  address: { street, region, zipcode }
} = potus;

let [{
  name: firstProductName,
  price: firstProductPrice,
  images: [firstProductFirstImage]
},{
  name: secondProductName,
  price: secondProductPrice,
  images: [secondProductFirstImage]
}] = products

第 12 课

A1:

function
createStateManager() {
  const state = {};
  const handlers = [];
  return {
    onChange(handler) {
      if (handlers.includes(handler)) {
        return;
      }
      handlers.push(handler);
      return handler;
    },
    offChange(handler) {
      if (!handlers.includes(handler)) {
        return;
      }
      handlers.splice(handlers.indexOf(handler), 1);
      return handler;
    },
    update(changes) {
      Object.assign(state, changes);
      handlers.forEach(function(cb) {
        cb(Object.assign({}, changes));
      });
    },
    getState() {
      return Object.assign({}, state);
    }
  }
}

第 13 课

A1:

function
makeSerializableCopy(obj) {
  return Object.assign({}, obj, {
    [Symbol.toPrimitive]() {
      return Object.keys(this).map(function(key) {
        const encodedKey = encodeURIComponent(key);
        const encodedVal = encodeURIComponent(this[key].toString());
        return `${encodedKey}=${encodedVal}`;
      }, this).join('&');
    }
  });
}

const myObj = makeSerializableCopy({
  total: 1307,
  page: 3,
  per_page: 24,
  title: 'My Stuff'
});

const url = `http://example.com?${myObj}`;

第 15 课

A1:

function car(name, seats = 4) {
  return {
    name,
    seats,
    board(driver, ...passengers) {
      console.log(driver, 'is driving the', this.name)
      const passengersThatFit = passengers.slice(0, this.seats - 1)
      const passengersThatDidntFit = passengers.slice(this.seats - 1)
      if (passengersThatFit.length === 0) {
        console.log(driver, 'is alone')
      } else if (passengersThatFit.length === 1) {
        console.log(passengersThatFit[0], 'is with', driver)
      } else {
        console.log(passengersThatFit.join(' & '), 'are with', driver)
      }
      if (passengersThatDidntFit.length) {
        console.log(passengersThatDidntFit.join(' & '), 'got left behind')
      }
    }
  }
}

第 16 课

A1:

function
updateMap({ zoom, center, coords = center, bounds }) {
  if (zoom) {
    _privateMapObject.setZoom(zoom);
  }
  if (coords) {
    _privateMapObject.setCenter(coords);
  }
  if (bounds) {
    _privateMapObject.setBounds(bounds);
  }
}

第 17 课

A1:

const translator = lang => (strs, ...vals) => strs.reduce(
  (all, str) => all + TRANSLATE(vals.shift(), lang) + str
);

第 18 课

A1:

function* dates(date = new Date()) {
  for (;;) {
    yield date;
    // clone the date
    date = new Date( date.getTime() );
    // increment the clone
    date.setDate( date.getDate() + 1 );
  }
}

第 20 课

A1:

export let luckyNumber = Math.round(Math.random()*10)
export function guessLuckyNumber(guess) {
  return guess === luckyNumber
}

第 21 课

A1:

import guessLuckyNumber from './luck_numbery'

let foundNumber = false

for (let i = 1; i <= 50 i++) {
  if (guessLuckyNumber(i)) {
    console.log('Guessed the correct number in ', i, 'attempts!')
    foundNumber = true
    break
  }
}

if (!foundNumber) {
  console.log('Could not guess number.')
}

第 23 课

A1:

function
take(n, iterable) {
  i = 0
  const items = []
  for (const val of iterable) {
    items.push(val)
    i++
    if (i === n) break
  }
  return items
}

function* fibonacci() {
  let prev = 0
  let curr = 1
  while(true) {
    yield curr;
    [prev, curr] = [curr, prev + curr]
  }
}

take(10, fibonacci()) // [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

function* two() {
  yield 1
  yield 2
}

take(10, two()) // [1, 2]

第 24 课

A1:

function
union(a, b) {
  return new Set([ ...a, ...b ]);
}

function intersection(a, b) {
  const inBoth = new Set();
  for (const item of union(a, b)) {
    if (a.has(item) && b.has(item)) {
      inBoth.add(item);
    }
  }
  return inBoth;
}

function subtract(a, b) {
  const subtracted = new Set(a);
  for (const item of b) {
    subtracted.delete(item);
  }
  return subtracted;
}

function difference(a, b) {
  return subtract(
    union(a, b),
    intersection(a, b)
  );
}

第 25 课

A1:

function
sortMapByKeys(map) {
  const sorted = new Map();
  const keys = [ ...map.keys() ].sort();
  for (const key of keys) {
    sorted.set(key, map.get(key));
  }
  return sorted;
}

function invertMap(map) {
  const inverted = new Map();
  for (const [ key, val] of map) {
    inverted.set(val, key);
  }
  return inverted;
}

function sortMapByValues(map) {
  return invertMap(sortMapByKeys(invertMap(map)))
}

第 27 课

A1:

class Fish {
  hunger = 1;
  dead = false;
  born = new Date();

  constructor(name) {
    this.name = name;
  }

  eat(amount=1) {
    if (this.dead) {
      console.log(`${this.name} is dead and can no longer eat.`);
      return;
    }
    this.hunger -= amount;
    if (this.hunger < 0) {
      this.dead = true;
      console.log(`${this.name} has died from over eating.`)
      return
    }
  }

  sleep() {
    this.hunger++;
    if (this.hunger >= 5) {
      this.dead = true;
      console.log(`${this.name} has starved.`)
    }
  }

  isHungry() {
    return this.hunger > 0;
  }
}

第 28 课

A1:

class Cruiser extends Car {
  drive(miles=1) {
    const destination = this.milage + miles;
    while(this.milage < destination) {
      if (!this.hasGas()) this.fuel();
      super.drive();
    }
  }

  fuel() {
    this.gas = 50;
  }
}

第 30 课

A1:

function loadCreditAndScore(userId) {
  return Promise.all(
    ajax(`/user/${userId}/credit_availability`),
    Promise.race(
      ajax(`/transunion/credit_score?user=${userId}`),
      ajax(`/equifax/credit_score?user=${userId}`)
    )
  )
}

loadCreditAndScore('4XJ').then(([creditAvailability, creditScore]) => {
  // Do something with credit availability and credit score
})

第 31 课

A1:

function
loadAsync(url) {
  return new Promise((resolve, reject) => {
    load(url, (error, data) => {
      if (error) {
        reject(error);
      } else {
        resolve(data);
      }
    })
  });
}

function getArticle(id) {
  return Promise.all([
    loadAsync(`/articles/${id}`),
    loadAsync(`/articles/${id}/comments`)
  ]).then(([article, comments]) => Promise.all([
    article,
    comments,
    loadAsync(`/authors/${article.author_id}`)
  ]));
}

getArticle(57).then(
  results => {
    // render article
  },
  error => {
    // show error
  }
);

第 32 课

A1:

async function
getArticle(id) {
  const article = await loadAsync(`/articles/${id}`);
  const comments = await loadAsync(`/articles/${id}/comments`);
  const author = await loadAsync(`/authors/${article.author_id}`);
  return [ article, comments, author ];
}

第 33 课

A1:

function
collect(obs$) {
  const values = [];
  return new Observable(observer => obs$.subscribe({
    next(val) {
      values.push(val);
      observer.next(values);
    }
  }));
}

function sum(obs$) {
  return new Observable(observer => obs$.subscribe({
    next(arr) {
      observer.next(arr.reduce((a, b) => a + b))
    }
  }));
}

sum(collect(Observable.of(1, 2, 3, 4))).subscribe({
  next(val) {
    console.log('sum:', val);
  }
});

这里是你在第二单元中将学习到的一些新语法的预览

新的解构语法为现有数据结构语法提供了一个对称的对应物。

图片

使用简写属性和方法,你可以从对象字面量中移除很多冗余。

图片

这里是使用承诺和异步函数从第 7 单元的预览

[source,js]
----
async function fetchImage(url) {
  const resp = await fetch(url);
  const blob = await resp.blob();
  return createImageBitmap(blob);
};

fetchImage('my-image.png').then(image => {
  // do something with image
});
----

要在不使用承诺或异步函数的情况下实现这一点,你需要编写更多晦涩难懂的代码。

[source,js]
----
function fetchImage(url, cb) {
  fetch(url, function(resp) {
    resp.blob(function(blob) {
      createImageBitmap(blob, cb);
    })
  })
};

fetchImage('my-image.png', function(image) {
  // do something with image
});
----

  1. 1 ↩︎

posted @ 2025-11-24 09:13  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报