JavaScript-秘籍第三版-全-

JavaScript 秘籍第三版(全)

原文:zh.annas-archive.org/md5/88d5b5a3c7b7f6604d06c108733caa74

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当我坐下来着手编写JavaScript Cookbook的最新版本时,我仔细考虑了“烹饪书”这个比喻。什么才是一本伟大的食谱书?在我家餐厅的书架上翻阅烹饪书时,我注意到我的最爱不仅有美味的食谱,而且充满了主观的、值得的建议。一本烹饪书很少会试图教会你每一种烩牛肉的食谱;它更多地教你作者发现对他们最有效的技术和食谱,通常还会加入一些建议。我们编制这本 JavaScript 配方集就是以这个概念为基础的。本书中的建议来自三位经验丰富的专家,但最终是我们独特经验的结晶。其他任何一组开发者可能会出版一本类似但又不同的书。

JavaScript 已经发展成为一个令人惊叹且功能强大的多用途编程语言。有了这个收藏,你将能够解决遇到的各种问题,甚至可能开始开发自己的配方。

读者对象

为了包含当今 JavaScript 使用中反映的许多主题和话题,我们必须从一个前提开始:这不是一本面向编程新手的书籍。有许多适合那些想要学习使用 JavaScript 编程的好书和教程,我们觉得可以针对实践开发者,即那些希望用 JavaScript 解决特定问题和挑战的人。

如果你已经玩了几个月的 JavaScript,或许尝试过一点 Node 或 Web 开发,那么你应该对本书的内容感到舒适。此外,如果你是主要使用其他编程语言的开发者,但偶尔需要使用 JavaScript,这本书也会是一个有用的指南。最后,如果你是一个工作中的 JavaScript 开发者,有时会陷入语言的一些特异性之中,这本书应该是一个有用的资源。

书籍组织

本书有两类读者。第一类是那些从头到尾阅读它,途中获取适用知识碎片的人。第二类是那些按需浏览,寻找解决特定挑战或问题类别的人。我们尝试以这样的方式组织本书,使其对两类读者都有用,将其分为三个部分:

  • 第 I 部分,JavaScript 语言,涵盖了 JavaScript 作为编程语言的配方。

  • 第 II 部分,JavaScript 在浏览器中,涵盖了 JavaScript 在其自然栖息地中的应用:浏览器。

  • 第 III 部分,Node.js,特别从 Node.js 的视角看待 JavaScript。

每一章的书籍都被细分为多个“配方”。一个配方由几个部分组成:

问题

这定义了一种常见的开发场景,其中可能使用 JavaScript。

解决方案

一个解决问题的解决方案,包括代码示例和简要描述。

讨论

对代码示例和技术进行深入讨论。

另外,配方可能包含在“另请参阅”部分中进一步阅读的推荐或在“额外”部分中的额外技术。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

粗体

表示应选择或单击的 UI 项,如菜单项和按钮。

常量宽度

表示广义上的计算机代码,包括命令、数组、元素、语句、选项、开关、变量、属性、键、函数、类型、类、命名空间、方法、模块、属性、参数、值、对象、事件、事件处理程序、XML 标签、HTML 标签、宏、文件内容以及命令的输出。

**常量宽度粗体**

显示用户应该按照字面意义输入的命令或其他文本。

常量宽度斜体

展示应由用户提供值或由上下文确定的值替换的文本。

注意

此元素表示一般注释。

提示

此元素表示提示或建议。

警告

此元素表示警告或注意事项。

本书提及网站和页面,帮助您找到可能有用的在线信息。通常会同时提到地址(URL)和名称(或标题或适当的标题)。有些地址相对复杂。您可以使用您喜欢的搜索引擎通过名称搜索页面,从而更轻松地找到这些页面。如果不能通过地址找到页面,这也可能有帮助;URL 可能已更改,但名称仍然有效。

使用代码示例

可供下载的补充材料(代码示例、练习等)位于https://github.com/javascripteverywhere/cookbook

本书旨在帮助您完成工作。一般而言,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了本书的大部分代码,否则无需联系我们以获取许可。例如,编写使用本书多个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书的大量示例代码整合到产品文档中需要许可。

我们感激但不要求署名。署名通常包括标题、作者、出版社和 ISBN。例如:JavaScript Cookbook,第三版,作者 Adam D. Scott、Matthew MacDonald 和 Shelley Powers。版权所有 2021 年 Adam D. Scott 和 Matthew MacDonald,978-1-492-05575-4。

如果您认为您对代码示例的使用超出了公平使用范围或此处授权,请随时通过 permissions@oreilly.com 联系我们。

O’Reilly Online Learning

注意

O’Reilly Media 已经提供技术和商业培训、知识和见解,帮助公司取得成功超过 40 年。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多个出版商的大量文本和视频。欲了解更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版社:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(在美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误表、示例和任何额外信息。您可以访问 https://oreil.ly/js-cookbook-3e

发送电子邮件至 bookquestions@oreilly.com 对本书进行评论或提出技术问题。

有关我们的书籍和课程的新闻和信息,请访问 http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 观看我们:http://www.youtube.com/oreillymedia

致谢

这是 JavaScript Cookbook 的第三版。前两版由 Shelley Powers 编写。本版由 Adam Scott 和 Matthew MacDonald 撰写和更新。Adam 和 Matthew 感谢他们的编辑 Angela Rufino 和 Jennifer Pollock,在项目的所有成长阶段中引导本书;以及他们的顶尖技术审阅者 Sarah Wachs、Schalk Neethling 和 Elisabeth Robson,他们提供了许多深刻的见解和有益的建议。Adam 还感谢 John Paxton 在本版初稿期间的支持和交流。

Shelley 感谢她的编辑 Simon St. Laurent 和 Brian McDonald,以及她的技术审阅者 Dr. Axel Rauschmayer 和 Semmy Purewal。

我们所有人都感谢 O’Reilly 生产工作人员的持续帮助和支持。

第一部分:JavaScript 语言

第一章:设置开发环境

你可能听说过“工具决定开发者”的说法。虽然这有些夸张,但没有人愿意在一堆 JavaScript 代码面前没有他们喜欢的工具来编辑、分析和调试它。

在设置自己的开发环境时,你将首先考虑的工具是代码编辑器。即使是最基本的编辑器也会添加诸如自动完成和语法高亮这样的基本功能,这两个简单的功能可以避免许多潜在的错误。现代代码编辑器还添加了许多其他功能,比如与 GitHub 等源代码控制服务的集成、逐行调试和智能重构。有时这些功能会通过插件直接嵌入到你的编辑器中。有时你会从终端或作为构建过程的一部分来运行它们。但无论你如何使用你的工具,组装合适的组合来适应你的编码风格、开发环境和项目类型是其中的一部分乐趣。这就像家庭装修专家收集工具,或者有抱负的厨师投资于刚刚合适的烹饪器具。

工具选择并非静态的。作为开发者,你的偏好可能会变化。随着你的成长和新工具证明其有用性,你将扩展你的工具包。本章探讨了每个 JavaScript 开发者在着手项目之前应考虑的最低工具集。但在不同广义等效选项之间有很大的选择空间。正如许多聪明人所说,品味无法计量!

注意

在这一章中,我们将戴上宣传帽子。你将看到我们喜欢的一些工具,以及其他同样优秀的选择。但我们并不试图覆盖每一款工具,只是一些你可以从头开始的优秀默认选择。

选择代码编辑器

问题

你想要在一个理解 JavaScript 语法的编辑器中编写代码。

解决方案

如果你赶时间,我们的首选 Visual Studio Code(通常简称为VS Code)是一个不会错的选择。你可以在 Windows、Macintosh 或 Linux 上下载这个免费的开源编辑器。

如果你有时间研究,还有许多其他编辑器可以考虑。表 1-1 中的列表远非完整,但显示了一些最受欢迎的编辑器。

表 1-1。桌面代码编辑器

编辑器 支持的平台 开源 成本 备注
Visual Studio Code Windows, Macintosh, Linux 免费 任何语言的极佳选择,也是我们在 JavaScript 开发方面的首选
Atom Windows, Macintosh, Linux 免费 本书的大部分章节都是使用 Atom,并使用了支持 AsciiDoc 的插件进行编写
WebStorm Windows, Macintosh, Linux 对于开源开发者和教育用户免费,否则个人每年大约 $60 一个比较重的环境,更接近传统的 IDE 而不是代码编辑器
Sublime Text Windows, Macintosh, Linux 个人一次性支付 $80,虽然没有许可证执行或时间限制 一个以在大型文本文件中快速性能而闻名的流行编辑器
Brackets Windows, Macintosh 免费 一个由 Adobe 赞助的专注于 web 开发的项目

无论你选择哪种代码编辑器,启动新项目的过程都大同小异。首先,创建一个新文件夹来存放你的项目(比如test-site)。然后,在你的代码编辑器中,查找类似 File > Open Folder 这样的命令,选择你创建的项目文件夹。大多数代码编辑器会立即显示项目文件夹的内容,以便你可以快速在文件之间跳转。

拥有一个项目文件夹还能让你放置你使用的软件包(“使用 npm 下载软件包”)和存储应用程序特定的配置文件和代码规范文件(“使用 ESLint 强制代码规范”)。如果你的编辑器有内置终端(“额外:使用终端和 Shell”),它总是会启动在当前项目文件夹中。

讨论

推荐最佳编辑器有点像 选择 的甜点一样。个人口味绝对是一个因素,而且至少有十几种合理的选择。大部分在 表 1-1 中列出的建议都符合所有重要条件,也就是说它们:

  • 跨平台,因此无论你使用什么操作系统都没问题。

  • 基于插件的设计,因此你可以轻松地添加所需的功能。本书提到的许多工具(如 “使用 ESLint 强制代码规范” 中描述的 Prettier 代码格式化工具)都有与不同编辑器集成的插件。

  • 多语言支持,允许你不仅限于 HTML、CSS 和 JavaScript,在其他编程语言中编写代码(需要适当的插件)。

  • 社区驱动,这让你有信心它们将长期维护和改进。

  • 免费,或者仅需适度的费用。

我们的首选是 VS Code,这是一个由微软开发的代码编辑器,具有原生的 JavaScript 支持。事实上,这个编辑器本身是用 JavaScript 编写 的,并在 Electron 中托管。(更准确地说,它是用 TypeScript 编写的,这是 JavaScript 的一个更严格的超集,在分发或执行之前被转译成 JavaScript。)

在许多方面,VS Code 是 Microsoft 强大的 Visual Studio IDE 的年轻、时髦的姐妹产品。Visual Studio IDE 还有一个免费的社区版,也支持 JavaScript 编码。但是,VS Code 在为不使用 Microsoft .NET 技术栈的开发者提供更好平衡方面做得更好。因为它起初轻量级,但通过其数千个社区插件的库可以进行无限定制。在 Stack Overflow 的开发者调查中,VS Code 经常被评为最受欢迎的代码编辑器,跨越多种编程语言。

参见

想要了解 VS Code 的基本特性和整体结构,请观看一组优秀的入门视频。在本章中,您还将学习如何在 VS Code 中使用 Emmet 快捷键(“使用 Emmet 快捷键填充 HTML 模板”),以及如何添加 ESLint(“使用 Linter 强制代码规范”)和 Prettier(“使用格式化工具一致地进行代码样式设置”)插件。

在您的浏览器中使用开发者控制台

问题

您希望查看网页中发生的错误以及您在控制台中输出的消息。

解决方案

使用您浏览器的开发者控制台。表格 1-2 显示了如何在各现代桌面浏览器中加载开发者工具。

表格 1-2. 加载开发者控制台的快捷键

浏览器 操作系统 快捷键
Chrome Windows or Linux F12 或 Ctrl+Shift+J
Chrome Macintosh Cmd-Option-J
Edge Windows or Linux F12 或 Ctrl+Shift+J
Firefox Windows or Linux F12 或 Ctrl+Shift+J
Firefox Macintosh Cmd-Shift-J
Safari^(a) Macintosh Cmd-Option-C
Opera Windows Ctrl+Shift+J
Opera Macintosh Cmd-Option-J
^(a) 在 Safari 中使用开发者控制台之前,您必须先启用它。要启用控制台,请从菜单中选择Safari 菜单 > 首选项,点击高级选项卡,并勾选在菜单栏中显示“开发”菜单

开发者工具通常以选项卡形式显示在网页浏览器窗口的右侧或底部。控制台面板显示使用 console.log() 输出的消息以及任何未处理的错误。

下面是一个页面的完整代码,它先写入控制台,然后出现错误:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Log and Error Test</title>
  </head>
  <body>
    <h1>Log and Error Test</h1>

<script>
  console.log('This appears in the developer console');
</script>

<script>
  // This will cause an error that appears in the console
  const myNumber =
</script>
  </body>
</html>

Figure 1-1 显示了开发者控制台中的输出。首先是记录的消息,然后是错误(一个 SyntaxError,显示为“意外的输入结束”)。错误消息以红色字体显示,并且 Chrome 友好地在每条消息旁边添加链接,这样你可以快速查看导致消息的源代码。网页和脚本文件中的行号会自动编号。在这个例子中,这使得很容易区分消息的源(第 13 行)和错误的源(第 19 行的闭合 </script> 标签)。

jsc3 0101

Figure 1-1. 在 Chrome 开发者控制台中查看输出结果

讨论

我们在本书中经常使用 console.log(),通常用于编写快速测试消息。然而,还有其他 console 方法可以使用。Table 1-3 列出了一些最有用的方法。

表 1-3. 控制台方法

方法 描述
console.warn(object) 类似于 console.log(),但输出的文本有黄色背景。
console.error(object) 类似于 console.log(),但输出的文本有红色背景。通常用于记录错误对象。
console.assert(expression, object) 如果表达式为 false,则将消息与堆栈跟踪一起写入控制台。
console.trace() 显示堆栈跟踪。
console.count(label) 显示调用此方法的次数与标签相关联。
console.dir(object) 以可展开的树状列表形式显示对象的所有属性。
console.group() 使用你提供的标题开始一个新的分组。接下来的控制台消息会在此标题下缩进显示,使它们看起来像是一个逻辑相关的部分。你可以使用 console.groupEnd() 来结束分组。
console.time(label) 使用指定的标签开始计时。
console.timeEnd(label) 停止与标签关联的计时器并显示经过的时间。
注意

现代浏览器中的控制台有时会对对象和数组使用惰性评估。如果你使用 console.log() 输出一个对象,然后对其进行更改,再次输出相同的对象,可能会出现这个问题。如果你在网页中的脚本代码中这样做,通常会发现 console.log() 的两次调用都输出了同一个已更改的对象,即使第一次调用在实际更改之前!

为避免这种问题,你可以在记录对象之前显式将其转换为字符串。这个技巧有效,因为控制台不会对字符串使用惰性评估。虽然这种技术并不总是方便(例如,如果你想记录包含对象的完整数组时就不适用),但它确实能解决大多数情况。

当然,控制台只是开发工具中的一个面板(或选项卡)。四处看看,您会发现其他面板中包含了许多有用的功能。具体的排列和命名取决于您的浏览器,但以下是 Chrome 的一些亮点。

元素

使用此面板查看页面特定部分的 HTML 标记,并检查适用于各个元素的 CSS 规则。甚至可以 更改 标记和样式(暂时)以快速测试潜在的编辑。

源码

使用此面板浏览当前页面使用的所有文件,包括 JavaScript 库、图像和样式表。

网络

使用面板标签查看页面及其资源的大小和下载时间,以及查看通过网络发送的异步消息(例如,作为fetch请求的一部分)。

性能

使用此面板开始跟踪代码执行所需的时间(参见“分析运行时性能”)。

应用程序

使用此面板查看当前站点使用的所有数据,包括存储在 cookie 中、本地存储或使用 IndexedDB API 存储的数据。

您可以尝试大多数这些面板,以了解它们的工作原理,或者您可以查阅Google 的文档

参见

“在开发者控制台中运行代码块” 解释了如何在开发者控制台中运行即席代码片段。

在开发者控制台中运行代码块

问题

您想尝试一个代码片段,而无需打开编辑器并创建 HTML 和 JavaScript 文件。

解决方案

在您的浏览器中使用开发者控制台。首先,打开开发者工具(如“在您的浏览器中使用开发者控制台”中所述)。确保选择控制台面板。然后,粘贴或输入您的 JavaScript 代码。

按 Enter 立即运行您的代码。如果需要输入多行代码,请在每行末尾按 Shift+Enter 插入软换行。只有当完成并且想要运行完整代码块时才按 Enter。

通常,您可能希望修改同一段代码并重新运行它。在所有现代浏览器中,开发者控制台都具有历史记录功能,使这一过程变得简单。要使用它,按向上箭头键显示先前执行的代码块。如果要查看之前运行的代码 更早 的代码,则按多次向上箭头键。

图 1-2 显示了一个代码块示例,第一次由于语法错误而未能成功运行。然后将代码调用到历史记录中,进行编辑和执行,输出结果(15)显示在下方。

jsc3 0102

图 1-2. 在控制台中运行代码

历史记录功能仅在 开始输入任何新代码时才有效。如果控制台命令行不为空,则向上箭头键仅会在当前代码块中移动,而不是回溯历史记录。

讨论

在开发者控制台中,你可以像在脚本块中一样输入 JavaScript 代码。换句话说,你可以添加函数并调用它们,或定义一个类然后实例化它。你还可以访问document对象,在当前页面中与 HTML 元素交互,显示警告,并写入控制台。(消息将直接显示在下方。)

当在控制台中使用更长的代码示例时,可能会遇到一个潜在的障碍。你可能会遇到命名冲突,因为 JavaScript 不允许在同一作用域内定义相同的变量或函数名称多次。例如,考虑如下简单的代码块:

const testValue = 40+12;
console.log(testValue);

如果你只运行一次,这个方法是有效的。但如果你按向上箭头回退历史来修改它,并再次运行,你会收到一个错误提示,告诉你testValue已经声明。你可以重命名你的变量,但如果你试图完善一个包含多个值和函数的代码片段,这种重命名会很快变得笨拙。或者,你可以执行location.reload()命令来刷新页面,但对于复杂页面来说速度会慢一些,而且可能会丢失一些你试图保留的页面状态。

幸运的是,有一个更简单的解决方案。只需在你的整个代码块外再加一层大括号,就可以创建一个新的命名范围。这样每次运行代码时都会安全执行,因为每次都会创建(然后丢弃)一个新的上下文。

{
  const testValue = 40+12;
  console.log(testValue);
}

参见

“调试 JavaScript” 探讨了在开发者控制台中调试的艺术。“分析运行时性能” 展示了如何在开发者控制台中进行性能分析。

使用严格模式捕捉常见错误

问题

你想要禁止潜在风险的特性,比如自动变量创建和一些会悄悄失败的语句。

解决方案

在你的 JavaScript 代码文件顶部添加use strict指令,就像这样:

'use strict';

或者,考虑将你的 JavaScript 写成模块,这总是以严格模式加载(“使用 ES6 模块组织你的 JavaScript 类”)。

讨论

JavaScript 因容忍懒散的编码习惯而(有些情况下)名声不佳。问题在于忽视细微规则违反的语言会让开发者处于不利地位。毕竟,你不能修复你从未注意到的问题。

下面的示例演示了 JavaScript 糟糕的一个例子。你能找到错误吗?

// This function adds a list of consecutive numbers
function addRange(start, end) {
  let sum = 0;
  for (let i = start; i < end+1; i++) {
    sum += i;
  }
  return sum;
}

// Add numbers from 10 to 15
let startNumber = 10;
let endNumber = 15;
console.log(addRange(startNumber,endNumber));   // Displays 75

// Now add numbers from 1 to 5
startnumber = 1;
endNumber = 5;
console.log(addRange(startNumber,endNumber));   // Displays 0, but we expect 15

虽然代码可以无错误地运行,但结果不符合我们的期望。问题出现在这行代码上:

startnumber = 1;

这里的问题在于当你赋值时 JavaScript 会创建变量,即使你没有明确定义这个变量。因此,如果你将值赋给startnumber而实际上想要的是startNumber,JavaScript 会悄悄地创建一个新的startnumber变量。最终的结果是,你打算赋给startNumber的值会消失到另一个变量中,再也看不到或使用不了。

要捕获这个问题,在函数代码之前,在文件顶部添加严格模式指令:

'use strict';

现在当 JavaScript 到达startnumber赋值时会出现ReferenceError。这会中断你的代码,结束脚本。然而,错误会在开发者控制台以红色字体显示,解释问题及其发生的行号。现在,修复变得非常容易。

严格模式能够捕获许多微小但有害的错误。一些例子包括:

  • 给未声明的变量赋值

  • 重复的参数名(如function(a, b, a))或对象字面量属性名(如{a: 5, a: 0}

  • 尝试给特殊关键词如Infinityundefined赋值

  • 尝试设置只读属性(“自定义属性定义方式”)或更改冻结对象(“防止对象任何更改”)

很多这些操作如果没有严格模式将会失败。然而,它们会悄无声息地失败,可能导致一个令人发狂的情况,你的代码不按预期工作,而你又不知道原因所在。

提示

你可以配置你的编辑器在每个新的代码文件中插入use strict指令。例如,Visual Studio Code 至少有三个小扩展可以执行这个任务。

严格模式能够捕获一小部分错误。大多数开发者也使用一个代码检查工具(“使用 ESLint 强制代码规范”)来捕获更广泛的错误和潜在的危险操作。事实上,开发者如此依赖于代码检查工具,以至于有时根本不使用严格模式。然而,始终建议将严格模式作为基本的防护级别,以防止自己不小心犯错。

参见

关于严格模式不接受的详细信息,请参阅严格模式文档。要查看如何使用模块,请参阅“使用 ES6 模块组织你的 JavaScript 类”。

使用 Emmet 快捷方式填充 HTML 模板

问题

你希望添加一段常见的 HTML 模板代码,而不必费力地逐个输入每个起始和结束标记。

解决方案

Emmet 是一个编辑器功能,可以自动将预定义的文本缩写转换为标准的 HTML 块。一些代码编辑器,如 Visual Studio 和 WebStorm,原生支持 Emmet。其他编辑器,如 Atom 和 Sublime Text,则需要使用编辑器插件。你通常可以在插件库中搜索“Emmet”来找到合适的插件,但如果不确定,可以参考支持 Emmet 的插件大全

要使用 Emmet,创建一个新文件并将其保存为 .html.htm 扩展名,这样你的代码编辑器将其识别为 HTML 文档。然后,输入一个 Emmet 缩写,再按 Tab 键(某些编辑器可能使用不同的快捷键,如 Enter 或 Ctrl+E,但 Tab 键是最常用的)。你的文本将自动展开为相应的标记块。

例如,Emmet 缩写 input:time 展开为以下标记:

<input type="time" name="" id="" />

图 1-3 展示了 VS Code 在你输入 Emmet 缩写时如何识别它。VS Code 提供了 Emmet 的自动完成支持,因此你可以看到可能的选择,并在自动完成菜单中添加“Emmet 缩写”提示,以表明你不是在编写 HTML,而是一个将被翻译成 HTML 的 Emmet 快捷方式。

jsc3 0103

图 1-3. 在 VS Code 中使用 Emmet

讨论

Emmet 提供了简单的语法,但其灵活性出乎意料。你可以编写更复杂的表达式,创建嵌套的元素组合,设置属性,并将顺序数字整合到名称中。例如,要创建一个包含五个项目的项目符号列表,可以使用缩写 ul>li*5,它将添加以下的标记块:

<ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
</ul>

或者,你可以使用快捷键 html:5 创建 HTML5 网页的起始框架(现代标准)。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>

</body>
</html>

所有这些功能在 Emmet 文档 中有详细描述。如果你着急,可以从有用的速查表开始。

安装 npm 包管理器(使用 Node.js)

问题

你想安装 npm,这样就可以轻松地从 npm 注册表下载 JavaScript 库,并将它们添加到 Web 项目中。

解决方案

Node 包管理器(npm)是世界上最大(目前也是最流行的)软件注册表的托管者。从 npm 注册表获取软件的最简单方式是使用 npm,它与 Node.js 捆绑在一起。要安装 Node,可以从 Node 网站 下载适用于你操作系统(Windows、MacOS 或 Linux)的安装程序。

安装完 Node 后,可以通过命令行验证其可用性。打开终端窗口,输入命令 node -v 检查 Node 的版本。要检查 npm 是否安装,输入 npm -v。你将看到这两个软件包的版本号:

$ node -v
v14.15.4
$ npm -v
6.14.10

讨论

npm 包含在 Node.js 中,这是一个 JavaScript 运行时环境和 Web 服务器。你可以使用 Node 运行服务器端 JavaScript 框架如 Express,或者用 Electron 构建 JavaScript 桌面应用程序。但即使你不打算使用 Node,你几乎肯定还是会安装它,以便访问 npm 包管理器。

Node 包管理器是一个工具,可以从 npm 注册表下载包,这是一个免费目录,跟踪数以万计的 JavaScript 库。事实上,你几乎找不到一台用于 JavaScript 开发的计算机,没有安装 Node 和 npm。

包管理器的工作远不止简单下载有用的库。包管理器还负责跟踪项目使用的库(称为依赖项),下载它们依赖的包(有时称为子依赖项),存储版本信息,并区分测试和生产构建。有了 npm,你可以通过单个命令将完成的应用程序带到另一台计算机,并安装所有需要的依赖项,如“使用 npm 下载包”中所述。

尽管 npm 目前是 JavaScript 最流行的包管理器,但你可能会遇到其他选项。Yarn 受到一些开发人员青睐,因为它提供了更快的包安装速度。Pnpm 是另一个选项,它旨在与 npm 兼容,但需要更少的磁盘空间,并提供更好的安装性能。

参见

要使用 npm 安装包,请参见“使用 npm 下载包”。

如果你在开发时使用 Node(不仅仅是 npm),你应该考虑使用 nvm 安装它,即 Node 版本管理器。这样你可以轻松切换不同的 Node 版本,并在有新版本发布时快速更新安装(这经常发生)。更多信息请参见“使用 Node 版本管理器管理 Node 版本”。如果你需要帮助在 Node 环境中开始运行代码,请参阅第十七章,其中有更多示例。

附加:使用终端和 Shell

要运行 Node 或 npm,你需要使用终端。从技术上讲,终端是一个基于文本的界面,用于与shell通信以执行命令。存在许多不同的终端程序和不同的 shell。你使用的终端和 shell 程序取决于你的操作系统(以及你的个人偏好,因为有大量第三方替代品)。

这里是你可能会遇到的一些最常见的终端和 shell 组合:

  • 在 Macintosh 计算机上,转到应用程序,打开实用工具文件夹,选择终端。这将启动默认的终端程序,它使用 bash 作为其 shell。

  • 在 Linux 计算机上,终端程序取决于发行版。通常有一个名为 Terminal 的快捷方式,几乎总是使用bash shell。

  • 在 Windows 上,您可以从开始菜单启动 PowerShell。从技术上讲,PowerShell 是 Shell,并且它被包装在名为conhost的终端进程中。Microsoft 正在开发一种现代的conhost替代品,称为 Windows Terminal,早期采用者可以从 Windows 商店安装(或从GitHub 下载)。Microsoft 还将bash shell 作为其Windows 子系统的一部分包含在内,尽管这是操作系统的相对较新的添加。

  • 代码编辑器有时会包含它们自己的终端。例如,如果您在 VS Code 中打开终端窗口(使用 Ctrl + 快捷键 [注意这是一个反引号,而不是单引号] 或从菜单中选择**视图 > 终端**),您将获得 VS Code 集成的终端窗口。默认情况下,它与 Windows 上的 PowerShell 和其他系统上的bash`通信,尽管您可以配置其设置。

当我们指示您使用终端命令时,您可以使用代码编辑器中的终端窗口,特定于您计算机的终端程序,或者众多第三方终端和 Shell 应用程序。它们都具有相同的环境变量(这意味着一旦安装,它们可以访问 Node 和 npm),并且都可以运行当前路径下的程序。您还可以使用终端执行通常的文件系统维护任务,如创建文件夹和文件。

注意

在本书中,当我们展示您应在终端中输入的命令(如“安装 npm 包管理器(使用 Node.js)”)时,我们在它们之前加上$字符。这是bash的传统提示符。然而,不同的 Shell 有不同的约定。如果您使用 PowerShell,则会看到一个文件夹名称,后面跟着>字符(如C:\Projects\Sites\WebTest>)。无论哪种方式,您用来运行实用程序(如 npm)的命令不会改变。

使用 npm 下载包

问题

您希望从 npm 注册表中安装特定的软件包。

解决方案

首先,您必须在计算机上安装 npm(参见“安装 npm 包管理器(使用 Node.js)”以获取说明)。假设您已经安装好了,打开一个终端窗口(参见“额外内容:使用终端和 Shell”),并进入您网站项目的项目目录。

接下来,如果您的应用程序尚未有package.json文件,您应该创建一个。实际上,您并不需要这个文件来安装包,但是它对某些其他任务(如将您的包恢复到另一台开发计算机)变得很重要。使用 npm 的init命令创建package.json文件是最简单的方法:

$ npm init -y

-y 参数(用于 yes)意味着 npm 将只是选择默认值,而不会提示您输入有关应用程序的具体信息。如果不包括 -y 参数,您将被询问有关应用程序的各种问题(如包名称、描述、版本、许可证等)。但是,起初(或根本)您无需填写任何这些细节,因此完全可以按 Enter 键留空每个字段并创建基本的 package.json 模板。有关 package.json 中描述信息的更多信息,请参见“额外:理解 package.json”。

一旦初始化了您的应用程序,您就可以安装一个包。您必须知道要安装的包的确切名称。按照约定,npm 的名称由连字符分隔的小写单词组成,如 fs-extrareact-dom。要安装您选择的包,只需使用包名称运行 npm install 命令。例如,以下是如何安装流行的 Lodash 库的示例:

$ npm install lodash

npm 将您安装的包添加到 package.json 文件中。它还在名为 package-lock.json 的文件中记录了关于每个包更详细的版本信息。

安装包时,npm 将其文件下载并放置在名为 node_modules 的文件夹中。例如,如果您在名为 test-site 的项目文件夹中安装了 Lodash,那么 Lodash 脚本文件将放置在文件夹 test-site/node_modules/lodash 中。

您可以使用 npm uninstall 按名称删除一个包:

$ npm uninstall lodash

讨论

npm(或任何包管理器)的天才表现在于当您有一个典型的 Web 项目,其中包含半打或更多个包,每个包都依赖于其他包时,这一点变得显而易见。因为所有这些依赖项都在 package-lock.json 文件中进行了跟踪,所以很容易知道一个 Web 应用程序需要什么。您可以通过从项目文件夹执行以下命令来查看完整报告:

$ npm list

在新计算机上重新下载这些包也很容易。例如,如果您将网站复制到另一台带有 package.jsonpackage-lock.json 文件但不带 node_modules 文件夹的计算机上,您可以像这样安装所有依赖包:

$ npm install

到目前为止,你已经看到如何本地安装包(作为当前 Web 应用的一部分)。npm 还允许将包全局安装(在系统特定的文件夹中,因此所有计算机上的 Web 应用都可以使用相同版本)。对于大多数软件包来说,本地安装是最佳选择。它使你能够控制所使用包的确切版本,并允许在不同应用程序中使用不同版本的相同包,以避免兼容性问题。当一个包依赖于另一个包的特定版本时,这个潜在问题会被放大。但是,全局安装对于某些类型的包特别有用,尤其是具有命令行实用程序的开发工具。有时全局安装的包的示例包括 create-react-app(用于创建新的 React 项目)、http-server(用于运行测试 Web 服务器)、typescript(用于将 TypeScript 代码编译为 JavaScript)和 jest(用于在代码上运行自动化测试)。

要查看计算机上安装的所有全局 npm 包,请运行此命令:

`npm list -g --depth 0`

在这里,--depth 参数确保你只看到全局包的顶层,而不是这些全局包使用的其他包。npm 还有其他功能,我们在这里不会涵盖,包括以下能力:

  • 将某些依赖项指定为开发者依赖项,意味着它们对开发是必需的,但不是部署所需(比如单元测试工具)。你将在 Recipes 和 中看到这种技术。

  • 通过搜索 npm 注册表中已知漏洞的报告来审核你的依赖项,它可能可以通过安装新版本来修复这些问题。

  • 通过名为 npx 的捆绑实用程序运行命令行任务。你甚至可以通过将它们添加到package.json中来自动启动任务,比如为生产部署准备你的站点或在开发测试期间启动 Web 服务器。你将在“设置本地测试服务器”中看到这种技术。

JavaScript 开发者使用的包管理器不仅仅有 npm。Yarn 是一个类似的包管理器,最初由 Facebook 开发。在某些场景下,它由于并行下载和缓存使用的方式而具有性能优势。历史上,它还强制执行了更严格的安全检查。使用 Yarn 没有理由不可以,但 npm 在 JavaScript 社区中仍然显著更受欢迎。

要了解有关 npm 的所有内容,你可以花一些时间阅读npm 开发者文档。你还可以看看Yarn

Extra: 理解 package.json

package.json 文件是一个应用程序配置文件,最初是与 Node 引入的,但现在用于各种目的。它存储有关您的项目、其创建者和许可的描述信息,如果您决定将项目作为 npm 包发布(在“将您的库转换为 Node 模块”中讨论的一个主题),这些信息变得非常重要。package.json 文件还跟踪您的依赖项(应用程序使用的包),并且可以存储用于调试和部署的额外配置步骤。

在开始新项目时创建 package.json 文件是一个良好的实践。您可以手动创建该文件,或使用 npm init -y 命令,这是本章示例中使用的命令。您新生成的文件将类似于这样(假设您的项目文件夹名为 test_site):

{
  "name": "test_site",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

您可能已经注意到,package.json 文件使用 JSON(JavaScript 对象表示法)格式。它保存了一个用逗号分隔的属性设置列表,全部包裹在 {} 大括号中。您可以随时在代码编辑器中编辑 package.json

当您使用 npm 安装包时,该依赖项将使用名为 dependencies 的属性记录在 package.json 中。例如,如果安装了 Lodash,package.json 文件将如下所示:

{
  "name": "test_site",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "lodash": "⁴.17.20"
  }
}

不要将 package.jsonpackage-lock.json 搞混了。package.json 文件存储基本的项目设置,并列出您使用的所有包。package-lock.json 文件指定了您使用的每个包的确切版本和校验和(以及每个这些包使用的包的版本和校验和)。例如,这是您安装 Lodash 后自动生成的 package-lock.json 文件:

{
  "name": "test-site",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "lodash": {
      "version": "4.17.20",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
      "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5h
agpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
    }
  }
}

换句话说,package-lock.json “锁定”您的包到特定的版本。如果您要将项目部署到另一台计算机,并且希望安装与开发期间完全相同的每个包的确切版本,这将非常有用。

您可能会编辑应用程序的 package.json 文件有两个常见的原因。首先,您可能希望在与其他人分享项目之前添加更多描述性细节。如果您计划在 npm 注册表中共享您的包,确保这些信息是正确的是非常重要的(在“将您的库转换为 Node 模块”中讨论)。其次,您可能决定配置命令行任务以进行调试,比如启动测试服务器(“设置本地测试服务器”)。关于可以放入 package.json 的每个属性的完整逐属性描述,请参阅 npm 文档

使用 npm 更新包

问题

您希望将 npm 包更新到更新的版本。

解决方案

对于次要更新,请使用 npm update。您可以指定要更新的特定包,或要求 npm 检查您站点使用的 每个 包的新版本,并一次性更新它们:

$ npm update

npm 将检查 package.json 文件并更新每个依赖项和子依赖项。它还会下载任何缺失的包。最后,它会更新 package-lock.json 文件以匹配新版本。

讨论

定期更新您使用的包是一个好习惯。但是,并非所有更新都可以自动进行。npm 更新遵循 semver(语义化版本控制)规则。npm 将安装具有更高补丁号(例如,从 2.1.2 更新到 2.1.3)或次要版本号(从 2.1.2 更新到 2.2.0)的更新,但如果新版本更改了主要版本号(从 2.1.2 更新到 3.0.0),则不会升级依赖关系。这种行为可以防止更新或部署应用程序时发生重大变化。

您可以使用 npm outdated 命令查看所有依赖项的可用更新情况:

$ npm outdated

这将产生如下输出:

Package                Current   Wanted   Latest  Location
-------                -------   ------   ------  --------
eslint                  7.18.0   7.25.0   7.25.0  my-site
eslint-plugin-promise    4.2.1    4.3.1    5.1.0  my-site
lodash                 4.17.20  4.17.21  4.17.21  npm-test

Wanted 列显示下次运行 npm update 时将安装的可用更新。Latest 列显示包的最新版本。在上面的示例中,lodasheslint 都可以更新到最新的包版本。但 eslint-plugin-promise 包仅能更新到版本 4.3.1。最新版本 5.1.0 更改了主要版本号,这意味着根据 semver 规则,它无法自动应用。

注意

这只是一个轻微简化,因为 npm 允许您在 package.json 文件中更具体地指定版本策略。但在实际操作中,这几乎是所有 npm 更新的工作方式。有关 npm 版本控制的更多信息,请参阅 npm 文档

如果要将依赖项更新为使用新的主要版本,请故意执行此操作。选项包括手动编辑 package.json 文件(略显繁琐)或使用像 npm-check-updates 这样的工具来执行此操作。npm-check-updates 工具允许您查看依赖关系、查看可用的更新并选择更新 package.json 文件以允许新的主要版本更新。完成后,调用 npm update 下载新版本。

设置本地测试服务器

问题

您希望在开发过程中测试网页,无需本地安全限制,并且无需将其部署到实时 Web 服务器上。

解决方案

在计算机上安装一个本地测试服务器。测试服务器将处理请求并像真实 Web 服务器一样向浏览器发送网页。唯一的区别是测试服务器不会接受来自其他计算机的远程连接。

有许多选择可用于测试服务器(请参阅讨论部分)。然而,两个简单可靠的选择是您可以通过 npm 安装的http-serverlite-server软件包。我们在这里使用lite-server,因为它添加了一个实时更新功能,当您在编辑器中保存更改的代码时,它会自动刷新浏览器中的页面。

在安装lite-server之前,最好准备一个示例网页以请求。如果您还没有这样做,请创建一个项目文件夹,并使用npm init -y命令进行配置(“使用 npm 下载软件包”)。然后,添加一个名为index.html的文件,并包含基本内容。如果您赶时间,这里是一个最小但有效的 HTML 文档,您可以用来测试您的代码在哪里运行:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Test Page</title>
  </head>
  <body>
    <p>This is the index page</p>
    <script>
if (window.location.protocol === 'file:') {
  console.log('Running as local file!');
}
else if (window.location.host.startsWith('localhost')) {
  console.log('Running on a local server');
}
else {
  console.log('Running on a remote web server');
}
    </script>
  </body>
</html>

现在您已经准备好通过测试服务器使此文档对您的浏览器可访问。

要安装lite-server,请使用 npm 并使用--save-dev选项。这样它被标记为开发人员依赖项,不会在生产构建中部署。

npm install lite-server --save-dev

现在,您可以直接从终端窗口使用 npm 的软件包运行器npx运行lite-server

npx lite-server

这将启动lite-server,打开一个新的浏览器选项卡,并请求http://localhost:3000(其中3000lite-server动态获取的端口)。lite-server会尝试返回index.html,或者如果您没有具有该名称的文件,则显示“Cannot GET /”。如果您使用本节中的示例页面,您将在页面上看到“这是索引页面”消息,并在开发者控制台中看到“在本地服务器上运行”。如果您的测试站点中没有index.html页面,您可以通过编辑地址栏中的 URL 加载不同的页面(例如,http://localhost:3000/someOtherPage.html)。

现在尝试进行一些更改。lite-server实例会监视您的项目文件夹。每当您更改文件时,它会自动强制浏览器刷新页面。在终端中,每当发生这种情况时,您会看到“Reloading Browsers”消息。

要结束服务器,请在终端上按 Ctrl+C(Macintosh 上为 Command-C)并回答Y。或者,关闭终端窗口(或在 VS Code 中使用 Kill Terminal 垃圾桶图标)。

注意

在幕后,lite-server使用一种名为BrowserSync的流行浏览器自动化工具来实现其实时重新加载功能。唯一的要求是您的网页必须有一个<body>部分。(创建一个没有这个细节的超级简单测试页面,您将看不到自动刷新行为。)

讨论

你可以将网页保存在本地计算机上,在 Web 浏览器中打开并运行其代码。然而,Web 浏览器会严格限制从本地文件系统打开的页面。整个功能将不可用,并且会悄无声息地失败(如 Web Workers、ES 模块和某些 Canvas 操作)。为了避免遇到这些安全障碍,甚至更糟糕的是,对代码为何不按预期工作感到困惑,最好总是从测试 Web 服务器运行您的网页。

在测试时,通常会使用开发服务器。有许多选择,您的决定在某种程度上将取决于您计划使用的其他服务器端技术。例如,如果您想在网页中运行 PHP 代码,您将需要一个支持它的 Web 服务器。如果您计划使用 JavaScript 或 JavaScript 驱动的服务器端框架(如 Express)构建应用程序的后端的一部分,您将需要使用 Node.js。但是,如果您正在运行传统客户端 JavaScript 的网页,那么一个发送静态文件的简单服务器就足够了,比如http-serverlite-server。还有许多其他选项,代码编辑器通常有自己基于插件的测试服务器。例如,如果您使用 Visual Studio Code,您可以搜索扩展库以找到流行的Live Server 插件

在解决方案部分,您看到了如何使用npx运行lite-server。然而,更方便的设置是创建一个开发运行任务,自动启动服务器。您可以通过编辑package.json文件并将以下指令添加到scripts部分来实现这一点:

{
...
  "scripts": {
    "dev": "lite-server"
  }
}

scripts部分包含您希望定期运行的可执行任务。这些任务可能包括使用代码检查器验证代码、将代码提交到源代码控制、为部署打包文件或运行单元测试。您可以根据需要添加尽可能多的脚本——例如,通常使用一个任务来运行应用程序,另一个任务使用自动化测试工具进行测试(“为您的代码编写单元测试”),另一个任务准备分发等。在此示例中,脚本命名为dev,这是一种约定,用于标识您在开发应用程序时计划使用的任务。

一旦您在package.json中定义了一个脚本,您可以在终端上使用npm run命令运行它:

npm run dev

这将使用npx启动lite-server

一些代码编辑器对此配置细节有额外的支持。例如,如果您在 VS Code 中打开package.json文件,您会看到在dev设置的正上方添加了一个“Debug”链接。单击此链接,VS Code 将打开一个新终端并自动启动lite-server

参见

要了解更多关于使用 Node 作为测试服务器的信息,请参阅第十七章中的示例。有关使用 npm 运行任务的更多信息,您可以阅读这篇很好的概述

使用 Linter 强制执行代码标准

问题

您希望规范化您的 JavaScript 代码,遵循最佳实践,并避免可能导致错误的常见陷阱。

解决方案

使用linter检查您的代码,当您偏离所选择的规则时会收到警告。最受欢迎的 JavaScript linter 是 ESLint。

要使用 ESLint,您首先需要 npm(请参阅“安装 npm 软件包管理器(带 Node.js)”)。在项目文件夹中打开一个终端窗口。如果您还没有创建package.json文件,请让 npm 现在创建它:

$ npm init -y

接下来,使用--save-dev选项安装eslint包,因为您希望 ESLint 成为开发人员依赖项,安装在开发人员计算机上,但不部署到生产服务器上:

$ npm install eslint --save-dev

如果您还没有 ESLint 配置文件,现在需要创建一个。使用 npx 运行 ESLint 设置:

$ npx eslint --init

ESLint 会询问您一系列问题,以评估应该强制执行的规则类型。通常,它会呈现一个小菜单供选择,您必须使用箭头键选择您想要的选项。

第一个问题是“您希望如何使用 ESLint?”这里有三个选项,从最不严格到最严格排列:

仅检查语法

使用 ESLint 来捕捉错误。它并不比大多数代码编辑器中的错误突出功能更严格。

检查语法并找出问题

强制执行ESLint 推荐的实践(标有复选标记的实践)。这是一个很好的起点,您可以稍后根据个人喜好覆盖单个规则。

检查语法,找出问题,并强制执行代码风格

如果您想使用特定的 JavaScript 样式指南,比如Airbnb,来强制执行更广泛的样式约定,那么这是一个不错的选择。如果选择此选项,您将在后续过程中被要求选择样式指南。

接下来,您将被问及一系列技术问题:您是否使用模块,React 或 Vue 框架,或 TypeScript 语言?选择 JavaScript 模块以获得对“使用 ES6 模块组织您的 JavaScript 类”中描述的 ES6 模块标准的支持,并对其他问题选择 No,除非您正在使用相关技术。

接下来,您将被问及“您的代码在哪里运行?”如果您正在构建在 Node.js 服务器中运行的服务器端应用程序,则选择 Node;如果您构建传统网站使用客户端 JavaScript 代码(通常情况),则选择 Browser。

如果您选择使用样式指南,JavaScript 现在会提示您从一个小列表中选择一个。然后,它会自动安装这些规则,使用一个或多个单独的包,前提是您允许它。

最后,ESLint 会问:“您希望配置文件采用什么格式?”所有格式选择都同样有效。我们倾向于使用 JSON 以与package.json文件对称,此时 ESList 会将其配置存储在名为.eslintrc.json的文件中。如果您使用 JavaScript 配置文件,则扩展名为.js,如果选择 YAML 配置文件,则扩展名为.yaml

如果您已要求 ESLint“检查语法并找出问题”,而没有添加单独的样式指南,那么在.eslintrc.json文件中将看到以下内容:

{
  "env": {
      "browser": true,
      "es2021": true
    },
    "extends": "eslint:recommended",
    "parserOptions": {
      "ecmaVersion": 12,
      "sourceType": "module"
    },
    "rules": {
  }
}

现在您可以在终端中使用 ESLint 来检查您的文件:

npx eslint my-script.js

但更实际的选择是使用将 ESLint 与您的代码编辑器集成的插件。所有在 “选择代码编辑器” 中介绍的代码编辑器都支持 ESLint,并且您可以浏览 支持 ESLint 的插件 的完整列表。

要将 ESLint 添加到您的代码编辑器中,请转到其插件库。例如,在 Visual Studio Code 中,您可以从左侧面板中点击 Extensions,然后搜索库中的 “eslint”,然后点击 Install。安装完 ESLint 后,您需要通过插件的设置页面正式允许它(或者在编辑器中打开代码文件时点击出现的灯泡图标,然后选择 Allow)。您可能还需要在整个计算机上全局安装 ESLint,以便插件可以找到它:

$ npm install -g eslint

一旦启用 ESLint,您将看到标志着 ESLint 错误和警告的波浪线。图 1-4 显示了一个示例,在此示例中,ESLint 检测到一个 switch 语句中的 case 没有跳出到下一个 case,这在 ESLint 的标准设置中是不允许的。弹出窗口中的 “eslint” 标签标识出这条消息来自 ESLint 插件,而不是 VS Code 的标准错误检查。

注意

如果 ESLint 没有捕获您预期的问题,这可能是因为文件中的 另一个 错误,甚至可能是代码的不同部分。尝试解决任何未解决的问题,然后重新检查您的文件。

jsc3 0104

图 1-4. ESLint 在 VS Code 中标记了一个错误

点击 Quick Fix(或边栏中的灯泡图标)以了解更多有关问题或尝试修复(如果可能)。您还可以禁用当前行或文件中的此问题检查,在这种情况下,您的覆盖将记录在特殊注释中。例如,这会禁用对未使用变量进行声明的规则:

/* eslint-disable no-unused-vars */

如果必须通过注释覆盖 ESLint,则最好尽可能具体和审慎。与其禁用整个文件的检查,不如只为单个特定行覆盖它,就像这样:

// eslint-disable-next-line no-unused-vars
let futureUseVariable;

或者(将 eslint-disable-next-line 替换为 eslint-disable-line):

let futureUseVariable;  // eslint-disable-line no-unused-vars

如果要恢复对问题的检查,只需移除注释。

讨论

JavaScript 是一种宽容的语言,为开发人员提供了很大的灵活性。有时,这种灵活性可能会导致问题。例如,它可能隐藏错误或导致代码更难理解的歧义。通过实施一系列标准,即使它们不对应明显错误,代码检查器也可以通过防止这些问题来工作。它会标记潜在的问题和可疑做法,这些做法不会触发您的代码编辑器的错误检查,但最终可能会回来困扰您。

ESLint 是一种主观的 linter,这意味着它会标记您可能不认为是问题的问题,比如您声明但未使用的变量、您在函数中更改的参数值、空条件块以及包含文字空格的正则表达式(仅举几例)。如果您希望允许其中一些问题,您可以在 ESLint 配置文件中(或通过文件或逐行基础的注释)覆盖任何设置。但通常您只需改变您的方式来避免未来的麻烦,因为 ESLint 的选择最终会避免未来的问题。

ESLint 还具有自动纠正某些类型错误的能力,并强制执行样式约定(如制表符与空格、单引号与双引号、大括号和缩进样式等)。使用 VS Code 等编辑器的 ESLint 插件,可以在保存文件时自动配置它执行这些纠正。或者,您可以仅使用 ESLint 标记潜在问题,并使用格式化程序(“使用格式化程序一致地设计代码”)来强制执行代码样式约定。

如果你在团队中工作,可能会收到一个预先配置好的 ESLint 配置文件来使用。如果没有,你需要决定要遵循哪一套 ESLint 默认设置。你可以了解更多关于ESLint 推荐的设置(在这个教程中使用),它提供了每个 ESLint 可以检查的问题的不符合规范的代码示例。如果你想使用更详细的 JavaScript 样式指南,我们推荐流行的Airbnb JavaScript 样式指南,可以通过eslint -init自动安装。

使用格式化程序一致地设计代码

问题

想要保持 JavaScript 的一致格式以提高可读性并减少歧义。

解决方案

使用 Prettier 代码格式化程序根据您制定的规则自动格式化代码。Prettier 强制执行一致的样式细节,如缩进、单双引号使用、括号内的空格、函数参数列表的空格以及长代码行的换行方式。但与 linter 不同(“使用 ESLint 强制执行代码标准”),Prettier 不会标记这些问题以供您解决。相反,每次保存 JavaScript 代码、HTML 标记或 CSS 样式规则时,它都会自动应用其格式。

尽管 Prettier 作为一个可以通过 npm 安装并在命令行中使用的包存在,但在代码编辑器中使用插件要更为实用。在“选择代码编辑器”介绍的所有代码编辑器中,都有 Prettier 插件。大部分列在Prettier 网站上。

要将 Prettier 添加到您的代码编辑器中,请转到其插件库。例如,在 Visual Studio Code 中,您点击左侧面板的Extensions,搜索“prettier”库,然后点击Install安装。

一旦安装了 Prettier,您在编辑代码文件时就可以使用它。右键单击编辑器中代码旁边,并选择格式化文档。您可以配置插件设置来更改一小部分选项(例如允许代码行分割的最大宽度,以及您是否更喜欢空格而不是制表符)。

提示

在 VS Code 中,您还可以配置 Prettier 在每次保存文件时自动运行。要激活此行为,请选择文件 > 首选项 > 设置,转到文本编辑器 > 格式设置部分,并选择保存时格式化

讨论

尽管许多代码编辑器都有其自己的自动格式化功能,但代码格式化程序超越了这些功能。例如,Prettier 格式化程序会去除任何自定义格式。它解析所有代码并根据您设置的约定重新格式化代码,几乎不考虑其原始编写方式(空行和对象文字是唯二的例外)。这种方法保证了相同的代码始终以相同的方式呈现,并且来自不同开发者的代码完全一致。而像 linter 一样,代码格式化程序的规则是在配置文件中定义的,这意味着您可以轻松地将它们分发给团队的不同成员,即使他们使用不同的代码编辑器。

Prettier 格式化程序特别关注换行。默认情况下,最大行长度设置为 80,但如果可以避免混乱的换行,Prettier 将允许一些行略长一些。如果需要换行,Prettier 会以智能方式处理。例如,它更愿意将函数调用放在一行中:

myFunction(argA(), argB(), argC());

但如果这不太实际,它不会随便将代码包装起来。它会选择它理解的最合适的排列方式:

myFunction(
  reallyLongArg(),
  omgSoManyParameters(),
  IShouldRefactorThis(),
  isThereSeriouslyAnotherOne()
);

当然,无论 Prettier 这样的格式化程序有多聪明,您可能更喜欢自己特有的代码布局。有时人们说“没有人喜欢 Prettier 对他们的语法所做的事情。每个人都喜欢 Prettier 对他们同事的语法所做的事情。”换句话说,像 Prettier 这样具有攻击性和主观见解的格式化程序的价值在于它统一了不同开发者的风格,清理了旧代码,并消除了奇怪的习惯。如果您决定使用 Prettier,您将可以自由地编写代码,而不必考虑间距、换行或排版。最终,您的代码仍将转换为相同的规范形式。

提示

如果您不完全确定是否要使用代码格式化程序,或者不知道如何配置其设置,请花些时间在Prettier playground中探索其工作原理。

像 ESLint 这样的 linter 和像 Prettier 这样的格式化工具有一些重叠。然而,它们的目标不同,它们的使用是互补的。如果你同时使用 ESLint 和 Prettier,你应该保留 ESLint 规则,这些规则可以捕获可疑的编码实践,但禁用那些强制执行格式化约定的规则,如缩进、引号和间距。幸运的是,通过添加额外的 ESLint 配置规则,可以轻松做到这一点,这个规则可以关闭可能与 Prettier 产生冲突的设置。最简单的方法是向你的项目添加eslint-config-prettier包:

$ npm install --save-dev eslint-config-prettier

最后,你需要在你的.eslintrc.json文件中的extends部分中添加prettierextends部分将包含一个包含方括号的列表,prettier应位于最后。这里是一个例子:

{
  "env": {
      "browser": true,
      "es2021": true
    },
    `"extends"``:` `[``"eslint:recommended"``,` `"prettier"``]`,
    "parserOptions": {
      "ecmaVersion": 12,
      "sourceType": "module"
    },
    "rules": {
  }
}

要查看最新的安装说明,请查看eslint-config-prettier的文档。

在 JavaScript 操场上进行实验

问题

你希望快速测试或分享一个代码想法,而不必构建一个项目并启动你的桌面代码编辑器。

解决方案

使用 JavaScript 操场,这是一个可以编辑和运行 JavaScript 代码的网站。超过一打的 JavaScript 操场,但表 1-4 列出了五个最受欢迎的。

表 1-4。JavaScript 操场

网站 备注
JS 弹簧床 可以说是第一个 JavaScript 操场,JS 弹簧床在模拟异步调用和 GitHub 集成方面仍然处于领先地位。
JS 碎片 一个经典的操场,它有一个简单的标签式界面,让你一次查看一个不同的部分(JavaScript、HTML、CSS)。JS 碎片的代码也可作为开源项目可用。
CodePen 设计上更为出色的操场,以社交为重点(流行的例子在 CodePen 社区中被推广)。其光滑的界面特别适合新手用户。
代码沙箱 其中之一的新兴操场,它使用 IDE 样式布局,感觉就像是 Visual Studio Code 的 Web 主机版。
击穿 另一种浏览器内 IDE,以其 VS Code 插件而闻名,你可以在浏览器操场上编辑和使用你的桌面编辑器编辑同一项目。

所有这些 JavaScript 操场都是强大的、实用的选择。它们的工作方式大体相同,尽管它们看起来截然不同。例如,将 JS 弹簧床的密集开发驾驶舱与 CodePen 的更为宽敞的编辑器进行比较(图 1-5)。

jsc3 0105

图 1-5。JavaScript 操场 JS 弹簧床

jsc3 0106

图 1-6。CodePen 的简单示例

这是如何使用 JavaScript 游乐场的。当你访问网站时,你可以立即在一个空白页面上开始编码。尽管你的 JavaScript、HTML 和 CSS 被分开呈现,但你不需要明确地添加一个 <script> 元素来连接你的 JavaScript 或一个 <link> 元素来连接你的样式表。这些细节已经填写到你的页面标记中,或者更常见的是,它们是隐藏在幕后的样板的一个隐含部分。

所有 JavaScript 游乐场都让你在代码窗口旁边看到你正在工作的页面预览。在一些游乐场中(如 CodePen),预览会随着你的更改而自动刷新。在其他一些游乐场中(如 JSFiddle),你需要明确点击一个播放或运行按钮来重新加载你的页面。如果你使用 console.log() 写消息,一些 JavaScript 游乐场会直接将其发送到浏览器控制台(如 CodePen),而其他一些还可以在页面上可见的专用面板中显示(如 JSFiddle)。

当你完成后,你可以保存你的工作,这时你会收到一个新生成的可共享链接。然而,最好先注册一个账户,这样你就能返回到 JavaScript 游乐场,找到你创建的所有示例,并对其进行编辑。(如果你匿名保存一个示例,你将无法编辑它,尽管你可以将其用作构建另一个示例的起点。)所有在 表 1-4 中列出的游乐场都允许你免费创建账户并保存你的工作。

注意

在 JavaScript 游乐场中创建的示例的确切术语因网站而异。它可能被称为 fiddle、pen、snippet 或其他内容。

讨论

JavaScript 游乐场是一个被十多个网站采纳的有用概念。几乎所有这些网站都共享一些重要特征:

  • 它们是免费使用的。然而,许多网站有订阅选项,提供高级功能,比如能够保存你的工作并保持私密。

  • 你可以无限期保存你的工作。如果你想要分享一个快速的模型或与他人合作进行新实验,这将非常方便。

  • 它们支持各种流行的 JavaScript 库和框架。例如,你可以通过从列表中选择快速添加 Lodash、React 或 jQuery 到你的示例中。

  • 你可以在一个窗口中编辑 HTML、JavaScript 和 CSS。根据游乐场的不同,它可能被分成同时可见的面板(如 JSFiddle)或者你可以在之间切换的选项卡(如 JS Bin)。或者,它可能是可定制的(如 CodePen)。

  • 它们提供一定程度的自动完成、错误检查和语法高亮(给不同的代码部分上色),尽管它不像你在桌面代码编辑器中得到的那样完整。

  • 它们提供页面预览,让你可以轻松地在编码和测试之间跳转。

JavaScript playgrounds 也有其局限性。例如,你可能无法托管其他资源如图片,与后端服务如数据库进行交互,或者使用 fetch 进行异步请求。

JavaScript playgrounds 应当与完整的基于云的编程环境区分开来。例如,你可以在完全托管的环境中使用 GitHub Codespaces,或者来自亚马逊的 AWS Cloud9,或者 Google Cloud。这些产品都不是免费的,但如果你想要在浏览器中设置特定的开发环境,并且不需要进行任何设置或担心性能问题,它们都是很有吸引力的选择。

第二章:字符串和正则表达式

下面是一个关于你下一个 JavaScript 聚会的趣味问题:世界上最流行的语言有多少种数据类型?

答案是 eight,但它们可能不是你所期望的。JavaScript 的八种数据类型包括:

  • Number

  • String

  • Boolean

  • BigInt(用于非常大的整数)

  • Symbol(用于唯一标识符)

  • Object(其他每种 JavaScript 类型的根)

  • undefined(未分配值的变量)

  • null(丢失的对象)

本书中的配方涵盖了所有这些要点。在本章中,你将专注于字符串的文本处理能力。

检查是否存在非空字符串

问题

在使用之前,你需要验证变量是否已定义、是否为字符串以及是否不为空。

解决方案

在开始处理字符串之前,通常需要验证其是否安全使用。在这样做时,可能会有不同的问题。

如果要确保变量是字符串(而不仅仅是可转换为字符串的变量),则使用此测试:

if (typeof unknownVariable === 'string') {
  // unknownVariable is a string
}

如果你想检查是否有非空字符串(而不是零长度字符串 ''),你可以像这样收紧你的验证:

if (typeof unknownVariable === 'string' && unknownVariable.length > 0) {
  // This is a genuine string with characters or whitespace in it
}

可选地,你可能希望拒绝仅由空白字符组成的字符串,在这种情况下,可以使用 String.trim() 方法:

if (typeof unknownVariable === 'string' && unknownVariable.trim().length > 0) {
  // This is a genuine string that is not empty or all whitespace
}

条件的顺序很重要。JavaScript 使用 短路求值。这意味着只有在第一个条件(类型检查)成功时,它才会评估第二个条件(长度检查)。这很重要,因为如果 unknownVariable 是不同类型的变量(如数字),长度检查将失败。

// This test is only safe if we already know unknownVariable is a string
if (unknownVariable.length > 0)

使用 typeof 运算符时存在潜在漏洞。可以通过使用 String 对象而不是字符串字面量来绕过字符串测试:

const unknownVariable = new String('test');

现在,typeof 运算符将返回 *object* 而不是 *string*,因为字符串原始值被包装在 String 对象中。

在现代 JavaScript 中,不建议创建 String 对象实例,原因如上所述。最好从任何你遇到的代码中删除这种做法,而不是在其周围编码。然而,如果需要适应可能的 String 对象,可以使用更复杂的测试,如下所示:

if (typeof unknownVariable === 'string' ||
    String.prototype.isPrototypeOf(unknownVariable)) {
  // It's a string primitive or a string wrapped in an object.
}

此代码检查是否满足以下两个条件之一:要么是字符串原始值,要么是具有与 String 相同原型的对象。^(1)

讨论

此配方中的类型检查测试使用 typeof 运算符。它返回变量的类型名称作为小写字符串。可能的值包括:

  • undefined

  • boolean

  • number

  • bigint

  • string

  • symbol

  • function

  • object

这些值与本章开头的列表匹配,但有两个小差异。首先,没有*null*,因为 null 值返回字符串*object*而不是。这被许多人认为是一个错误,但出于历史原因而保留。其次,添加了*function*数据类型,尽管函数在技术上是对象的特例。

偶尔,您会看到以下老式的字符串验证技术。它不要求变量实际上一个字符串。它只是验证您的值是否可以被视为字符串,并且它不是空字符串。

if (unknownVariable) {
  /* We get here as long as:
 unknownVariable has been declared
 unknownVariable is not null
 unknownVariable is not the empty string ''
 */
}

这是因为在 JavaScript 中,null值、undefined值和空字符串('')都被视为假值。如果在条件表达式中评估它们中的任何一个,它们将被视为假。

这种方法在处理数字 0 时存在潜在的盲点,因为 0 始终评估为false,从而跳过if块。为了安全起见,最好显式将您的数字变量转换为字符串,如“将数值转换为格式化字符串”中所述。

将数值转换为格式化字符串

问题

您想要创建一个数字的字符串表示。

解决方案

JavaScript 是一种弱类型语言,当需要时会自动将任何值转换为字符串,例如,如果您将数字与字符串比较或使用+运算符将数字与字符串连接起来。事实上,JavaScript 开发人员用来将数字转换为字符串的最简单的技巧之一就是简单地在值的开头或结尾连接一个空字符串:

const someNumber = 42;
const someString = someNumber + '';

然而,现代做法更偏向于显式变量转换。每个 JavaScript 对象都有一个内置的toString()方法,包括Number对象。您可以像这样调用它:

const someNumber = 42;
const someString = someNumber.toString();

经常需要自定义数字的字符串表示。例如,您可能希望固定小数位数(如 30.00 而不是 30)。这可能还涉及到四舍五入(例如,从 30.009 到 30.01)。

JavaScript 有三个内置于数字数据类型中的实用方法,可以帮助您。它们都创建数字的字符串表示:

Number.toFixed()

允许您指定小数点后要保留的位数。

Number.toExponential()

使用科学计数法,并允许您指定小数点后要显示的位数。

Number.toPrecision()

允许您指定要保留的有效数字位数,而不考虑您的数字是多么大或小。

注意

如果你不熟悉有效数字,这是一个科学概念,用于确保计算具有适当的精度。它还有助于确保测量结果不会以比实际精度更高的方式表示。(例如,你的平均体重可能是 162.5 磅,但说它是 162.503018 磅可能并没有实际意义,将其四舍五入到 200 磅也没有帮助。)维基百科详细解释了这个概念

这里有一个示例,演示了所有三种字符串转换方法:

const someNumber = 1242.0055;

// Ask for exactly 2 decimal points. Numbers will be rounded if necessary.
const fixedString = someNumber.toFixed(2);
// fixedString = '1242.01'

// Ask for 5 significant digits. Scientific notation is used if necessary.
const precisionString = someNumber.toPrecision(5);
// precisionString = '1242.0'

// Ask for scientific notation with 2 decimal plates.
const scientificString = someNumber.toExponential(2);
// scientificString = '1.24e+3'

如果你想应用逗号、货币符号或其他区域特定细节的格式化,你需要Intl.NumberFormat对象的帮助。一旦你创建了一个实例并适当配置它,你可以使用Intl.NumberFormat执行你的数字转字符串转换。

例如,要将一个数字格式化为美元货币字符串,你可以使用以下代码:

const formatter =
 new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

const someNumber = 1242.0005;
const moneyString = formatter.format(someNumber);
// moneyString = '$1,242.00'

讨论

Locale代表特定的地理或文化区域。语言标识符结合了语言代码和区域字符串。en-US代表美国的英语,en_CA是加拿大的英语,fr-CA是加拿大的法语,ja-JP是日本的日语,等等。

根据你的区域设置,有一些标准的数字格式化规则适用。例如,英语语言区域中的数字通常使用逗号来分隔千位(如1,200.00),而法语语言区域中通常使用逗号而不是小数点(如1 200,00)。如果你创建一个没有构造函数参数的Intl.NumberFormat对象,你会得到当前计算机的区域设置:

const formatter = new Intl.NumberFormat();

你也可以为特定的区域创建一个Intl.NumberFormat对象,不需要额外的选项:

const formatter = new Intl.NumberFormat('en-US');

en-US区域,这个对象会添加逗号分隔符,但不会应用固定数量的小数点或添加货币符号。

Intl.NumberFormat对象支持多种选项。你可以更改负数显示的方式,设置最小和最大数字位数,显示百分比,并在某些语言中选择不同的编号系统。你可以在Mozilla 开发者网络参考文档中找到详细信息。

你可能会看到一个使用Number.toLocaleString()方法的旧版本技术。以下是一个示例:

const someNumber = 1242.0005;
const moneyString = someNumber.toLocaleString(
 'en-US', { style: 'currency', currency: 'USD' });

这种方法是完全有效的,尽管如果你打算格式化一长串数字,创建和重用一个单一的Intl.NumberFormat对象会表现更好。

参见

如果你需要比Intl.NumberFormat提供的格式支持更多的功能,你可以使用第三方库,比如Numeral.js

插入特殊字符

问题

你想要插入特殊字符,比如换行符,到一个字符串中。

解决方案

处理带有许多特殊字符的最简单方法很简单:只需将您需要的字符粘贴到您的编辑器中。例如,如果您需要版权符号(©),首先在桌面实用程序(如 Windows 计算机上的 charmap)中找到该字符,或者在 Google 中搜索“版权符号”。选择符号,复制它,然后粘贴到您的代码中。

如果您想要在代码中使用通常不允许的字符(根据 JavaScript 的语法规则),您需要使用其中的一种转义序列—特殊的字符代码组合,这些组合不会被字面解释。

例如,如果你在字符串中使用撇号作为定界符,就不能直接在字符串内放置撇号字符。相反,你需要使用 \' 转义序列,就像这样:

const favoriteMovie = 'My favorite movie is \'The Seventh Seal\'.';

现在 favoriteMovie 包含文本 My favorite movie is ‘The Seventh Seal’.

讨论

JavaScript 中的转义序列都以反斜杠字符\)开头。该字符表示后续的字符序列需要特殊处理。表 2-1 列出了 JavaScript 所识别的其他转义序列。

表 2-1. 转义序列

序列 字符
\' 单引号
\" 双引号
\\ 反斜杠
\n 换行符
\t 水平制表符
\b 非破坏性退格*
\f 换页符*
\r 回车符^(a)
\ddd 八进制序列(3 位数:ddd
\xdd 十六进制序列(2 位数:dd
\udddd Unicode 序列(4 个十六进制数字:dddd
^(a) 一些转义序列(如用于退格和换页的序列)是从原始 ASCII 字符标准和 C 语言中保留下来的。除非您处理遗留场景(如将输入发送到终端),否则这些转义序列在 JavaScript 中不太可能有用。

表 2-1 中的最后三个转义序列是需要提供数值的模式。例如,如果您不想使用复制粘贴技巧来添加版权符号,您可以使用 \u 转义序列和版权符号的 Unicode 值来插入它:

const copyrightNotice = 'This page \u00A9 Shelley Powers.';

现在 copyrightNotice 字符串设置为 This page © Shelley Powers.

另请参见

有关在字符串中插入更专业字符的信息,请参阅 “插入表情符号”。有关处理换行而不使用 \n 的替代方法,请参阅 “使用模板文字清晰地进行字符串连接”。

插入表情符号

问题

您想要插入一个具有 4 字节编码的扩展 Unicode 字符,如表情符号或某些类型的重音非英文字母。

解决方案

如果您只是想创建一个带有表情符号的字符串,通常可以从“插入特殊字符”的“复制并粘贴技巧”中工作。在现代代码编辑器中,您可以像这样编写代码:

  • const hamburger = '🍔';

  • const hamburgerStory = '我喜欢汉堡包' + hamburger;

由于代码编辑器将使用操作系统提供的表情符号支持作为后备,因此您的代码字体甚至无需支持表情符号。(当然,问题仍可能发生。例如,在旧系统上,表情符号不可用时,您可能会看到一个方框“缺失字符”图标。)

另一个选项是使用表情符号的 Unicode 值。问题是,您不能使用标准的\u转义序列来获取表情符号,因为每个表情符号都存储为 4 字节值。(相比之下,映射到键盘键的 Unicode 字符通常编码为 2 字节值。)

解决方案是使用String.fromCodePoint()方法:

const hamburgerStory = 'I like hamburgers' + String.fromCodePoint(0x1F354);

汉堡包表情的十六进制代码是 U+1F354。要使用它与fromCodePoint(),请用0x替换前缀U+

创建了一个富含表情符号的字符串后,您可以像处理由普通字符组成的普通字符串一样将其写入开发者控制台或显示在网页上。

讨论

截至 2020 年,全世界仅有三千多个表情符号。您可以在完整表情列表上看到它们及其相应的十六进制值。仅仅因为一个表情符号存在并不意味着它将在您计划使用它的设备上受支持,因此请尽早测试兼容性。

如果您需要处理可能包含表情符号的字符串进行字符串处理,可能会遇到其他问题。例如,您认为以下代码将找到什么?

  • const hamburger = '🍔';

  • const hamburgerLength = hamburger.length;

尽管hamburger字符串只有一个字符,但在你的代码中,长度看起来是 2,因为汉堡包表情在内存中占用了两倍的字节。这是 JavaScript 对 Unicode 支持的一个不愉快的泄漏抽象和限制。

有人已经发明了一些解决表情符号问题的变通方法,例如不正确的长度和在字符上进行迭代或切片的问题。但是自制解决方案风险很大,因为通常存在奇怪的边缘情况。如果需要处理富含表情符号的文本,可以考虑使用像Grapheme Splitter这样支持表情符号的 JavaScript 库。

使用模板字面量来进行更清晰的字符串连接

问题

您希望有一个更简单、更清晰的方法来编写长字符串的连接操作。

解决方案

在编程中的一个常见任务是将静态文本的各个部分与变量结合起来,创建一个更长的字符串。组装这种类型的字符串的传统方式是使用连接运算符+,如下所示:

const employeeDetail = 'Our team includes ' + firstName + ' ' + lastName +
 ' who works on the ' + team + " team. They/'ve been a team member since "
  + hireDate + '!';

它并不糟糕,但在固定文本变得更长时可能会变得尴尬。而且很容易忘记在变量周围添加空格。

另一种方法是使用模板字面量,一种允许嵌入表达式的字符串字面量。要创建模板字面量,只需用反引号(`)替换标准字符串定界符(撇号或双引号)即可:

const greeting = `Hello world from a template literal!`;

现在,您可以直接将变量插入到模板字面量中。您只需将每个变量用花括号包裹起来,并在前面加上一个美元符号,例如${firstName}。这称为表达式

模板字面量方法的优势在于当您查看完整示例时变得更加明显:

employeeDetail = `Our team includes ${firstName} ${lastName} who works on the
${team} team. They've been a team member since ${hireDate}!`;

当您在现代代码编辑器中使用着色花括号表达式时,情况会变得更清晰,这使得变量从文字字面上脱颖而出。

模板字面量还保留换行符。在这里显示的示例中,您看不到这种效果,因为我们已经把代码换行以适应页面。但是,如果您故意按 Enter 键在模板字面量中添加硬换行,这些换行将保留在字符串中,就像使用\n换行转义序列一样(参见“插入特殊字符”)。

注意

许多 JavaScript 风格指南,包括Airbnb,都有规则不鼓励字符串连接,而是青睐使用模板字面量。您可以使用类似 ESLint 的代码检查工具(“使用 ESLint 执行代码标准”)来在您的代码中强制执行这一实践。

讨论

在模板字面量中使用表达式时,不限于直接插入变量。事实上,您可以使用 JavaScript 能评估的任何代码表达式。例如,请考虑以下代码:

const calculation = `The sum of 5 + 3 is ${5+3}`;

在这里,JavaScript 执行表达式{5+3}中的加法,获取结果,并创建字符串The sum of 5 + 3 is 8

如果您想做一些更复杂的事情,比如格式化字符串或操作对象,您可以使用调用函数的表达式。例如,如果您创建了一个用于计算日期间差异的getDaysSince()函数(参见“计算两个日期间经过的时间”),您可以在模板字面量中像这样使用它:

function getDaysSince(date) {
  const today = new Date();
  const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds
  return Math.round(Math.abs((today - date) / oneDay));
}

employeeDetail = `Our team includes ${firstName} ${lastName}. They've been a
team member since ${hireDate}! That's ${getDaysSince(hireDate)} days.`;

唯一的限制是实际情况——换句话说,不要使您的表达式过于复杂,以至于生成的模板字面量比使用传统字符串连接方法的代码更难阅读。

目前,JavaScript 没有内置的方法来在模板字面量表达式内格式化数字、日期和货币值。许多人猜测未来版本的 JavaScript 将添加此功能。甚至有一个 JavaScript 库,使用了一个尴尬的可扩展性特性称为标记模板来添加它。

执行不区分大小写的字符串比较

问题

你想要查看两个字符串是否匹配,同时将大写和小写字母视为相同。

解决方法

一种即兴的方法是对两个字符串都使用 String.toLowerCase() 方法,然后比较结果,例如:

const a = "hello";
const b = "HELLO";

if (a.toLowerCase() === b.toLowerCase()) {
  // We end up here, because the lowercase versions of both strings match
}

这种方法相当可靠,但在处理不同语言、重音符号和特殊字符时可能会出现边缘情况。(例如,请查看与土耳其语相关的 潜在问题。)

另一种健壮的方法是使用 String.localeCompare() 方法,并将 sensitivity 设置为 *accent*,如下所示:

const a = "hello";
const b = "HELLO";

if (a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0) {
  // We end up here, because the case-insensitive strings match.
}

讨论

如果 localeCompare() 判定两个字符串匹配,则返回 0。否则,返回一个正数或负数,指示比较的字符串在排序顺序中是在参考字符串之前还是之后。(因为我们使用 localeCompare() 来测试相等性,排序顺序并不重要,可以忽略。)

localeCompare() 的第二个参数是一个字符串,用于指定区域设置(如 “将数值转换为格式化字符串” 中所述)。如果传入 undefined,则 localeCompare() 使用当前计算机的区域设置,这通常是你想要的。

要执行大小写不敏感的比较,需要设置 sensitivity 属性。有两个可以工作的值。如果将 sensitivity 设置为 *accent*,则具有不同重音符号的字符(如 aá)将被视为不相等。但如果将 sensitivity 设置为 *base*,则会得到一个更宽松的大小写不敏感比较,将所有重音符号的字母视为匹配。

检查字符串是否包含特定子字符串

问题

你想要检查一个字符串是否包含另一个子字符串。

解决方法

如果只需要一个是或否的测试,可以使用 String.includes() 方法:

const searchString = 'infinitely';
const fullText = 'I know not where I was born, save that the castle was' +
 ' infinitely old and infinitely horrible.';

if (fullText.includes(searchString)) {
  // The search string was found
}

可选地,你可以告诉 includes() 方法从哪个字符位置开始搜索。例如,传入值 5,则搜索跳过字符串中的第六个字符,并继续直到末尾:

const searchString = 'infinitely';
const fullText = 'I know not where I was born, save that the castle was' +
 ' infinitely old and infinitely horrible.';

if (fullText.includes(searchString, 70)) {
  // Still true, because the search skips the first 'infinitely' and
  // hits the second one.
}

讨论

includes() 执行的搜索区分大小写。如果需要大小写不敏感的搜索,可以先对两个字符串调用 toLowerCase()

const searchString = 'INFINITELY';
const fullText = 'I know not where I was born, save that the castle was' +
 ' infinitely old and infinitely horrible.';

if (fullText.toLowerCase().includes(searchString.toLowerCase())) {
  // The search string was found
}

includes() 方法不提供关于匹配发生位置的任何信息。如果需要此信息,请考虑使用 String.indexOf() 方法,详情请见 “从字符串中提取列表”。

替换字符串的所有出现

问题

你想要查找字符串中特定子字符串的所有出现,并用其他内容替换它们。

解决方法

可以使用 String.replaceAll() 方法一次性进行更改。你只需要一个要搜索的子字符串和另一个要替换的字符串:

const storyText = 'I know not where I was born, save that the castle was' +
 ' infinitely old and infinitely horrible.';

const changedStory = storyText.replaceAll('infinitely', 'somewhat');

console.log(changedStory);

如果运行此代码,你将在开发者控制台看到改变后的字符串 “I know not where I was born, save that the castle was somewhat old and somewhat horrible.”。

讨论

replaceAll()方法有能力使用正则表达式进行搜索,而不是普通字符串。你可以在“使用正则表达式替换字符串中的模式”中看到这是如何工作的。

参见

查看Recipes来了解如何在字符串中查找匹配项并逐个检查,而不仅仅是直接替换它们。

将 HTML 标签替换为命名实体

问题

你想要将标记插入到网页中,并转义该标记(使浏览器显示角括号而不解释它们为 HTML 标签)。这可能是因为你希望在教程文章中显示一些示例 HTML 标记,或者因为你需要安全地清理外部数据,如用户提交的文本或从数据库中提取的文本。

解决方案

使用String.replaceAll()方法将角括号(< >)转换为命名的 HTML 实体&lt;&gt;。你需要执行两个步骤,分别进行替换:

const originalPieceOfHtml = '<p>This is a <span>paragraph</span></p>';

// Get a new string with no < characters
let safePieceOfHtml = originalPieceOfHtml.replaceAll('<', '&lt;');

// Get a new string with no > characters
safePieceOfHtml = safePieceOfHtml.replaceAll('>', '&gt;');

// Show it in the page
document.getElementById('placeholder').innerHtml = safePieceOfHtml;

如果现在检查字符串,你会发现它包含文本“

This is a paragraph

”,在网页中将如你所预期地显示(显示角括号)。

只要代码可读性能够保持,你可以一步完成两次字符串替换:

const safePieceOfHtml =
 originalPieceOfHtml.replaceAll('<', '&lt;').replaceAll('>', '&gt;');

第一个replaceAll()返回一个新字符串,然后在这个第二个字符串上调用replaceAll()来获得第三个字符串。这种在从一个方法返回的值上调用方法的技术称为方法链

讨论

如果将原始文本插入到网页中,HTML 转义非常重要。如果你不执行此步骤,将会存在严重的安全漏洞。事实上,你应该确保在显示文本内容之前,对所有文本内容进行转义,即使你认为该文本不包含任何 HTML 实体(例如,即使它只是在你的代码中直接设置为文字)。无法预测何时可能会从其他地方更改代码并替换文本值。

尽管如此,自行进行 HTML 转义通常不是最佳方法。如果你有意创建一个将 HTML 标签与外部内容混合的字符串,那么你需要这样做。但是理想情况下,你应该使用元素的textContent属性而不是innerHTML属性将文本放入网页中。使用textContent时,浏览器会自动转义内容,这意味着你不需要使用String.replaceAll()

参见

更多关于使用 HTML DOM 将文本内容插入到网页中的信息,请参见第十二章。

使用正则表达式替换字符串中的模式

问题

你希望在字符串中搜索模式,而不是确切的字符序列。然后,你想创建一个新字符串,将模式替换掉。

解决方案

你可以使用String.replace()String.replaceAll()方法,两者都支持正则表达式。

注意

正则表达式是定义文本模式的字符序列。正则表达式是 JavaScript 和许多其他编程语言中实现的标准。请参阅表 2-2 以简要介绍正则表达式语法。

例如,考虑正则表达式模式t\w{2}e。这将被翻译为查找以t开头,以e结尾,并包含两个其他字母数字字符的任意字符序列。该解决方案匹配time,但也匹配tame

这是使用此正则表达式的代码:

const originalString = 'Now is the time, this is the tame';
const regex = /t\w{2}e/g;
const newString = originalString.replaceAll(regex, 'place');

// newString = 'Now is the place, this is the place'

注意,正则表达式不是作为字符串编写的。相反,它是以斜杠(/)开始和结束的文字。JavaScript 识别这种语法并创建一个使用您的表达式的RegEx对象。

正则表达式末尾的g是称为全局标志的附加细节。它表示您正在搜索整个字符串以找到匹配项。如果不包括g标志,则在调用replaceAll()时会收到错误消息。但是,当您使用replace()方法仅更改模式的一个出现时,可以使用没有全局标志的正则表达式。

讨论

如果您不想使用/分隔符创建正则表达式,还有另一种选择。您可以显式创建一个RegEx对象,如下所示:

const regex = new RegExp('t\\w{2}e', 'g');
const newString = originalString.replaceAll(regex, 'place');

当您使用这种方法时,您不需要在正则表达式周围包含斜杠,但您需要转义模式中的任何反斜杠(将/替换为//)。此外,全局标志成为RegExp构造函数的第二个参数,而不是添加到正则表达式的末尾。

您可能会发现,在长而复杂的正则表达式中,转义反斜杠是令人困惑的。如果是这样,您可以通过模板文字(在“使用模板文字进行更清晰的字符串连接”中介绍)来避免转义要求。窍门是将您的模板文字与String.raw()方法结合使用。请记住,在表达式字符串周围使用反引号(`)而不是撇号或引号:

// Although String.raw is a method, it has no parentheses after it,
// and it uses the specialized backtick syntax shown here.
const regex = new RegExp(String.raw`t\w{2}e`, 'g');

额外:正则表达式

正则表达式由普通字符单独使用或与特殊字符结合而成。例如,以下是一个正则表达式,用于匹配包含单词technology和单词book(以这个顺序,并由一个或多个空白字符分隔)的字符串模式:

const regex = /technology\s+book/;

反斜杠字符(\)有两个用途:它要么与普通字符一起使用,表示它是一个特殊字符,要么与特殊字符一起使用,例如加号(+),表示应将该字符视为字面量。在这种情况下,反斜杠与 s 一起使用,将字母 s 转换为指定空格字符(空格、制表符、换行符或换页符)的特殊字符。 +\s+ 特殊字符后跟加号,\s 是一个匹配前一个字符(在这个例子中是空格字符)一次或多次的信号。此正则表达式适用于以下情况:

technology book

以下也有效:

technology     book

它不适用于以下情况,因为单词之间没有空白字符:

technologybook

由于使用 \s+,在 technologybook 之间有多少空白字符并不重要。然而,使用加号确实需要至少一个空白字符。

表 2-2 展示了 JavaScript 应用中最常用的特殊字符。

表 2-2. 正则表达式特殊字符

字符 匹配 示例
^ 匹配输入的开头 /^This/ 匹配 This is…
` 字符 匹配
--- --- ---
^ 匹配输入的开头 /^This/ 匹配 This is…
| 匹配输入的结尾 | /end$/ 匹配 This is the end
* 匹配零次或多次 /se*/ 匹配 seeeese
? 匹配零次或一次 /ap?/ 匹配 appleand
+ 匹配一次或多次 /ap+/ 匹配 apple 但不匹配 and
{n} 精确匹配 n /ap{2}/ 匹配 apple 但不匹配 apie
\{n,\} 匹配至少 n /ap{2,}/ 匹配 appleappple 中的所有 p 但不匹配 apie
\{n,m\} 匹配至少 n 次,至多 m /ap{2,4}/ 匹配 appppple 中的四个 p
. 除换行符外的任意字符 /a.e/ 匹配 apeaxe
[] 括号内的任何字符 /a[px]e/ 匹配 apeaxe 但不匹配 ale
[^] 除括号内的字符外的任何字符 /a[^px]/ 匹配 ale 但不匹配 axeape
\b 匹配单词边界 /\bno/ 匹配 nono 中的第一个 no
\B 匹配非单词边界 /\Bno/ 匹配 nono 中的第二个 no
\d 数字 0 到 9 /\d{3}/ 匹配 Now in 123 中的 123
\D 任何非数字字符 /\D{2,4}/ 匹配 Now ' in ‘Now in 123;
\w 匹配单词字符(字母、数字、下划线) /\w/ 匹配 javascript 中的 j
\W 匹配任何非单词字符(不是字母、数字或下划线) \/W/ 匹配 100% 中的 %
\n 匹配换行符
\s 单个空白字符
\S 任何非空格字符
\t 制表符
(x) 捕获括号 记住匹配的字符
注意

正则表达式很强大,但可能有些棘手。本书只是简单介绍了它们。如果你想要更深入地了解正则表达式,你可以阅读 Jan Goyvaerts 和 Steven Levithan(O’Reilly)的优秀著作正则表达式手册,或者参考在线参考资料

从字符串中提取列表

问题

你有一个包含多个句子的字符串,其中一个句子包括一个项目列表。列表以冒号(:)开头,以句号(.)结尾,并用逗号(,)分隔每个项目。你想要提取出列表部分。

之前:

This is a list of items: cherries, limes, oranges, apples.

之后:

['cherries','limes','oranges','apples']

解决方案

解决方案需要两个步骤:提取包含项目列表的字符串,然后将此字符串转换为列表。

使用String.indexOf()方法两次——首先定位冒号,然后再找到冒号后面的第一个句号:

const sentence = 'This is one sentence. This is a sentence with a list of items:' +
'cherries, oranges, apples, bananas. That was the list of items.';
const start = sentence.indexOf(':');
const end = sentence.indexOf('.', start + 1);

使用这两个位置和String.slice()方法,你可以提取你想要的字符串:

const list = sentence.slice(start + 1, end);
// list = 'cherries, oranges, apples, bananas'

你可以编写一个循环,使用indexOf()方法查找逗号,并使用slice()方法将list字符串拆分为较小的部分,每个项目一个部分。但有一种更简单的方法。你可以使用String.split()方法将字符串拆分为数组:

let fruits = list.split(',');
// now fruits has these elements: ['cherries', ' oranges', ' apples', ' bananas']

当你调用split()时,你必须选择一个分隔符。它可以是空格、逗号、一系列破折号或其他内容。分隔符用于将字符串切分为较小的部分,并且它不会出现在结果中。

讨论

分割提取的字符串的结果是一个项目列表的数组。然而,这些项目可能带有一些不必要的部分(在这种情况下,除第一个字符串外,所有字符串都有额外的前导空格)。幸运的是,清理它们很容易。

一个明显的方法是遍历字符串数组并手动修剪每个字符串,使用“从字符串开头和结尾删除空格”中描述的技术。这样做是有效的,但有一种更简单的方法。

关键是使用Array.map(),它会在数组中的每个元素上运行你提供的代码。你只需要一行代码来调用trim()方法:

fruits = fruits.map(s => s.trim());
// now fruits has these elements: ['cherries', 'oranges', 'apples', 'bananas']

如果你对在这个例子中提供修剪函数所使用的箭头语法不熟悉,你可以在“使用箭头函数”中阅读更详细的解释。

参见

在字符串中查找匹配项的另一种方法是使用正则表达式。例如,根据列表的结构方式,你可以使用一个抓取逗号之间的单词的正则表达式。正则表达式在“使用正则表达式替换字符串中的模式”中介绍,使用正则表达式执行搜索在“查找所有模式的实例”中介绍。

查找所有模式的实例

问题

你想要在字符串中找到模式的所有实例,并对它们进行迭代。

解决方案

使用 String.matchAll() 方法与正则表达式。matchAll() 方法返回一个迭代器,让你可以遍历所有的匹配项。

下一个示例使用正则表达式来查找以 t 开头并以 e 结尾的任意单词,中间包含任意数量的字符。它使用来自 “使用模板字面量进行更清晰的字符串连接” 的模板字面量语法构建一个新的带有结果的字符串:

const searchString = 'Now is the time and this is the time and that is the time';
const regex = /t\w*e/g;

const matches = searchString.matchAll(regex);
for (const match of matches) {
  console.log(`at ${match.index} we found ${match[0]}`);
}

这段代码的结果如下:

at 7 we found the
at 11 we found time
at 28 we found the
at 32 we found time
at 49 we found the
at 53 we found time

讨论

当你使用 matchAll() 进行搜索时,每个匹配都是一个对象。当你遍历你的匹配项时,你可以检查匹配的文本(match[0])和匹配被找到的索引(match.index)。

在当前示例中有一些看起来有点奇怪的地方。尽管你一次只看一个结果,但你使用 match[0] 来获取数组中的第一个项目。这个数组存在是因为正则表达式可以使用括号捕获多个匹配部分。稍后你可以引用这些捕获的部分。例如,想象一下你写了一个匹配有关某人信息行的正则表达式。通过捕获,你可以轻松地从每个匹配中抓取单独的信息片段,比如那个人的姓名和出生日期。当你将这个技术与 matchAll() 结合使用时,匹配的子字符串会分别作为 match[1]match[2] 等提供。

如果你不想立即遍历结果,你可以使用扩展运算符将所有内容转储到一个数组中:

const searchString = 'Now is the time and this is the time and that is the time';
const regex = /t\w*e/g;

// Put the 6 match objects into an array
const matches = [...searchString.matchAll(regex)];

现在你可以使用 foreach 在另一个时间循环遍历你的 matches 数组。但请记住,matches 不仅仅是一个匹配文本的数组。它是一个匹配对象的数组。正如你在原始示例中看到的,每个匹配对象都有一个位置(match.index)和一个包含一个或多个匹配文本组的数组(以 match[0] 开头)。

补充:高亮匹配项

让我们看一个更详细的例子,展示如何在网页上查找并高亮文本匹配。图 2-1 展示了该应用在威廉·华兹华斯的诗歌《小猫和落叶》上的运行情况。

jsc3 0201

图 2-1. 应用程序查找和突出显示所有匹配的字符串

该页面有一个 textarea 和一个输入框,用于输入搜索字符串和正则表达式。该模式用于创建一个 RegExp 对象,然后像前面(更短的)示例一样,应用于 textarea 中的文本,使用 matchAll()

当代码检查匹配项时,它创建一个字符串,包含未匹配的文本和匹配的文本。匹配的文本被包围在一个 <span> 元素中,并使用 CSS 类来突出显示文本。然后将生成的字符串插入到页面中,使用 <div> 元素的 innerHTML 属性(参见 示例 2-1)。

示例 2-1. 在文本字符串中突出显示所有匹配项
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Finding All Instances of a Pattern</title>

    <style>
      .found {
        background-color: #ff0;
      }
      body {
        margin: 15px;
      }
      textarea {
        width: 100%;
        height: 350px;
      }
    </style>
  </head>
  <body>
    <h1>Finding All Instances of a Pattern</h1>

    <form id="textsearch">
      <textarea id="incoming">
      </textarea>
      <p>
        Search pattern: <input id="pattern" type="text">
      </p>
    </form>
    <button id="searchSubmit">Search for pattern</button>
    <div id="searchResult"></div>

    <script>
    document.getElementById("searchSubmit").onclick = function() {
        // Get the pattern
        const pattern = document.getElementById('pattern').value;
        const regex = new RegExp(pattern, 'g');

        // Get the text to search
        const searchText = document.getElementById('incoming').value;

        let highlightedResult = "<pre>";
        let startPosition = 0;
        let endPosition = 0;

        // Find each match, and build the result
        const matches = searchText.matchAll(regex);
        for (const match of matches) {
            endPosition = match.index;

            // Get all of the string up to the match, and concatenate
            highlightedResult += searchText.slice(startPosition, endPosition);

            // Add matched text, using a CSS class for formatting
            highlightedResult += "<span class='found'>" + match[0] + "</span>";
            startPosition = endPosition + match[0].length;
        }

        // Finish off the result string
        highlightedResult += searchText.slice(startPosition);
        highlightedResult += "</pre>";

        // Show the highlighted text in the page
        document.getElementById("searchResult").innerHTML = highlightedResult;
     }
    </script>
  </body>
</html>

在图 2-1 的页面中,使用以下正则表达式进行搜索:

lea(f|ves)

竖线(|)是条件测试,将匹配基于竖线两侧的值的单词。所以leaf会匹配,leaves也会匹配,但leap不会匹配。

去除字符串开头和结尾的空白字符

问题

你想要去除填充在字符串开头或结尾的额外空格。

解决方案

使用String.trim()方法。它会移除字符串两端的所有空白字符,包括空格、制表符、不间断空格和行终止符。

const paddedString = '     The road is long, with many a winding turn.  ';
const trimmedString = paddedString.trim();

// trimmedString = 'The road is long, with many a winding turn.'

讨论

trim()方法很简单,但不可定制。如果你有稍微复杂的字符串修改需求,你将需要使用正则表达式。

一个常见的问题是trim()方法无法移除字符串内部的多余空格。replaceAll()方法可以使用带有\s字符的正则表达式相对轻松地完成这个任务:

const paddedString = 'The road is long,    with many a    winding turn.';
const trimmedString = paddedString.replaceAll(/\s\s+/g, ' ');

// trimmedString = 'The road is long, with many a winding turn.'

当然,即使在处理带有额外空格的坏数据之后,仍然可能出现不需要的残留物。例如,如果在你不希望有任何空格的地方有多个空格('is long ,    with'),在替换后仍然会留下一个空格('is long , with')。处理这类问题的唯一方法是手动逐个匹配,正如“查找模式的所有实例”所示。

参见

正则表达式语法在“使用正则表达式替换字符串中的模式”中描述。

将字符串的第一个字母转换为大写

问题

你想将字符串的第一个字母变成大写,但不改变字符串的其余部分。

解决方案

分离第一个字母并使用String.toUpper()将其大写化。然后将大写字母与字符串的其余部分连接起来,你可以使用String.slice()来获取字符串的剩余部分:

const original = 'if you cut an orange, there is a risk it will orbisculate.';
const fixed = original[0].toUpperCase() + original.slice(1);

// fixed = 'If you cut an orange, there is a risk it will orbisculate.';

讨论

要从字符串中获取单个字符,你可以使用字符串的索引器,如original[0]。这会获取位置 0(即第一个字符)的字符。

const firstLetter = original[0];

或者,你可以使用String.charAt()方法,它的工作方式完全相同。

要获取字符串的片段,你可以使用slice()方法。在调用slice()时,你必须始终指定你希望从哪里开始提取字符串。例如,text.slice(5)从索引位置 5 开始,一直到字符串的末尾,并将该部分文本复制到一个新字符串中。

如果你不希望slice()继续到字符串的末尾,你可以提供一个可选的第二个参数,指定字符串复制应该停止的索引位置:

// Get a string from index position 5 to 10.
const substring = original.slice(5, 10);

本示例中的示例更改了一个字母为大写。如果要将整个句子更改为使用首字母大写(称为标题大小写),这是一个更复杂的问题。您可能决定将字符串拆分为单独的单词,修剪每个单词,然后连接结果,使用从 “从字符串中提取列表” 技术的变体。

另请参阅

您可以将 slice()indexOf() 结合使用,以找到要提取的特定文本位的位置。例如,请参见 “从字符串中提取列表”。

验证电子邮件地址

问题

您希望捕捉和拒绝电子邮件地址中的常见错误。

解决方案

正则表达式不仅对搜索有用。您还可以通过测试字符串是否与给定模式匹配来验证字符串。在 JavaScript 中,您可以使用 RegEx.test() 方法测试字符串是否与正则表达式匹配。

const emailValid = "abeLincoln@gmail.com";
const emailInvalid = "abeLincoln@gmail .com";
const regex = /\S+@\S+\.\S+/;

if (regex.test(emailValid)) {
  // This code is executed, because the email passes.
}
if (regex.test(emailInvalid)) {
  // This code is not executed, because the email fails.
}

讨论

程序员使用许多不同的正则表达式来验证电子邮件地址。最好的正则表达式捕获明显的错误和虚假值,但不要过于复杂。过于严格的正则表达式有时会无意中禁止有效的邮件地址。即使电子邮件地址通过了最严格的测试,也没有办法知道它是否正确(至少不发送电子邮件消息并请求确认)。

本示例中的正则表达式要求电子邮件地址以至少一个非空白字符开头,后跟 @ 字符,然后是一个或多个非空白字符,然后是一个句点(.),再跟着一个或多个非空白字符。它捕获了明显无效的电子邮件地址,如 tomkhangmail.comtomkhan@gmail

通常,您不会自己编写用于验证的正则表达式。相反,您将使用匹配您数据的预定义表达式。有关大量正则表达式资源,请访问 Awesome Regex 页面

另请参阅

正则表达式语法在 “使用正则表达式替换字符串中的模式” 中有描述。

^(1) 在 JavaScript 中,原型是特定类型对象的模板。在更传统的面向对象语言中,我们会说具有相同原型的对象是同一类的实例。《第八章》有很多示例,探讨了 JavaScript 中的原型。

第三章:数字

在日常编程中,很少有比数字更重要的要素。许多现代语言都有一组不同的数值数据类型,用于不同的场景,如整数、小数、浮点值等。但是当涉及到数字时,JavaScript 作为一种松散类型的脚本语言,揭示了其匆忙、稍微临时的创建。

直到最近,JavaScript 只有一个称为 Number 的全能数值数据类型。如今,它有两种:您几乎所有时候使用的标准 Number,以及只在需要处理巨大整数时考虑的非常专业化的 BigInt。在本章中,您将同时使用这两种类型,以及 Math 对象的实用方法。

生成随机数

问题

你想生成一个落在一定范围内的随机整数(例如,从 1 到 6)。

解决方案

您可以使用 Math.random() 方法生成介于 0 和 1 之间的浮点值。通常,您会缩放这个小数值并四舍五入,这样您就会得到一个特定范围内的整数。假设您的范围从某个最小数 min 到最大数 max,这是您需要的语句:

randomNumber = Math.floor(Math.random() * (max - min + 1) ) + min;

例如,如果您想在 1 到 6 之间选择一个随机数,代码如下:

const randomNumber = Math.floor(Math.random()*6) + 1;

现在 randomNumber 的可能值是 1、2、3、4、5 或 6。

讨论

Math 对象中充满了您随时可以调用的静态实用方法。本示例使用 Math.random() 获取一个随机小数,然后使用 Math.floor() 截断小数部分,得到一个整数。

要理解这是如何工作的,让我们考虑一个示例运行。首先,Math.random() 选择一个值介于 0 和 1 之间,如 0.374324823

const randomNumber = Math.floor(0.374324823*6) + 1;

该数字乘以您范围内的值的数量(在本例中为 6),变为 2.245948938

const randomNumber = Math.floor(2.245948938) + 1;

然后 Math.floor() 函数将其截断为 2

const randomNumber = 2 + 1;

最后,将范围的起始数字相加,得到最终结果为 3。重复这个计算,你会得到一个不同的数字,但它将始终是我们设置的范围从 1 到 6 的整数。

参见

Math.floor() 方法只是一个四舍五入数字的方法之一。更多信息请参见“四舍五入到特定小数位”。

重要的是要理解 Math.random() 生成的数字是伪随机的,这意味着它们可以被猜测或逆向工程。它们对于密码学、彩票或复杂建模来说不够随机。有关差异的更多信息,请参见“生成密码安全的随机数”。如果您需要一种生成可重复序列的伪随机数的方法,请参考“额外:构建可重复的伪随机数生成器”。

生成密码安全的随机数

问题

你想创建一个不容易被逆向工程(猜测)的随机数。

解决方案

使用window.crypto属性来获取Crypto对象的一个实例。使用Crypto.getRandomValues()方法来生成比Math.random()产生的具有更多的随机值。(换句话说,它们很不可能被重复或预测到—详细内容请参见讨论部分。)

Crypto.getRandomValues()方法的工作方式与Math.random()不同。它不是给您一个在 0 到 1 之间的浮点数,而是用随机整数填充一个数组。您可以选择这些整数是 8 位、16 位还是 32 位,以及它们是有符号还是无符号的。(有符号数据类型可以是负数或正数,而无符号数仅为正数。)

有一个被接受的解决方案来将getRandomValues()的输出转换为 0 到 1 之间的分数值。诀窍是将随机值除以数据类型可以包含的最大可能数字:

const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
const randomFraction = randomBuffer[0] / (0xffffffff + 1);

现在您可以像处理从Math.random()返回的数字一样处理randomFraction。例如,您可以将其转换为特定范围内的随机整数,如“生成随机数”中所述:

// Use the random fraction to make a random integer from 1-6
const randomNumber = Math.floor(randomFraction*6) + 1;
console.log(randomNumber);

如果您在 Node.js 运行时环境中运行代码,则无法访问window对象。但是,您可以使用以下代码访问非常类似的 Web Crypto API 实现:

const crypto = require('crypto').webcrypto;

讨论

此示例中有很多需要解开的内容。首先,即使您不深入研究这段代码的工作方式,您也需要了解关于Crypto.getRandomValues()实现的一些重要细节:

  • 从技术上讲,Crypto创建的是由数学公式生成的伪随机数,就像Math.random()提供的那样。不同之处在于,这些数字被认为是密码学强的,因为随机数生成器的种子是用真正随机的值生成的。这种权衡的好处是getRandomValues()的性能类似于Math.random()。(它很快。)

  • 由于Crypto对象的种子是由实现(对于网页代码来说,这意味着浏览器制造商)决定的,因此无法知道它是如何生成的,而这又依赖于操作系统的功能。通常情况下,种子是通过键盘定时、鼠标移动和硬件读数的最近记录细节的组合来创建的。

  • 无论您的随机数有多好,如果您的 JavaScript 代码在浏览器中运行,它都会受到大量攻击的威胁。毕竟,没有什么可以阻止恶意方看到您的代码并创建一个绕过所有随机数生成的修改副本。如果您的代码在服务器上运行,情况就不同了。

现在让我们更仔细地看一下getRandomValues()的工作原理。在调用getRandomValues()之前,您必须创建一个类型化数组,它是一个类似数组的对象,只能保存特定数据类型的值。(我们说它是类数组,因为它的行为像数组,但它不是Array类型的实例。)JavaScript 提供了几种强类型化的数组对象供您使用,比如Uint32Array(用于存储无符号 32 位整数的数组),Uint16ArrayUint8Array,以及有符号的对应物Int32ArrayInt16ArrayInt8Array。您可以根据需要创建这个数组的大小,getRandomValues()将填充整个缓冲区。

在此示例中,我们只为Uint32Array中的一个值留出空间:

const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);

最后一步是将这个随机值除以可能的最大无符号 32 位整数,即 4,294,967,295。这个数字在其十六进制表示中更为整洁,为0xffffffff

const randomFraction = randomBuffer[0] / (0xffffffff + 1);

正如这段代码所示,您还需要将最大值加 1。这是因为随机值理论上可能恰好落在最大整数值上。如果确实如此,randomFraction将变为 1,这与Math.random()和大多数其他随机数生成器不同。(微小的与规范不符的变化可能导致错误的假设,进而导致后续错误。)

将数字四舍五入到特定小数位

问题

您想要将数字四舍五入到特定精度(例如,将 124.793 四舍五入到 124.80 或 120)。

解决方案

您可以使用Math.round()方法将数字四舍五入到最接近的整数:

const fractionalNumber = 19.48938;
const roundedNumber = Math.round(fractionalNumber);

// Now roundedNumber is 19

奇怪的是,round()方法没有接受可以指定保留小数位数的参数。如果您想要不同精度的结果,您需要自行将数值乘以适当的 10 的幂,四舍五入后再除以相同的 10 的幂。以下是此操作的一般公式:

const numberToRound = fractionalNumber * (10**numberOfDecimalPlaces);
let roundedNumber = Math.round(numberToRound);
roundedNumber = roundedNumber / (10**numberOfDecimalPlaces);

例如,如果您想要将数字四舍五入到两位小数,代码如下:

const fractionalNumber = 19.48938;
const numberToRound = fractionalNumber * (10**2);
let roundedNumber = Math.round(numberToRound);
roundedNumber = roundedNumber / (10**2);

// Now roundedNumber is 19.49

如果您想要将小数点左边的数字四舍五入(例如,四舍五入到最接近的十位、百位等),只需对numberOfDecimalPlaces使用负数即可。例如,-1 四舍五入到最接近的十位,-2 四舍五入到最接近的百位,依此类推。

讨论

Math对象有几个静态方法,用于将分数值转换为整数。floor()方法删除所有小数位,将一个数向下舍入到最接近的整数。ceil()方法则相反,总是将分数数值向上舍入到最接近的整数。round()方法将数值四舍五入到最接近的整数。

关于round()如何工作,有两点重要的内容您需要知道:

  • 精确值 0.5 始终四舍五入为最接近的整数,即使它与下一个较低和较高整数的距离相等。在金融和科学中,通常使用不同的舍入技术来消除这种偏差(例如将某些 0.5 值向上舍入,将其他值向下舍入)。但是,如果你希望 JavaScript 中有这种行为,你需要自己实现或使用第三方库。

  • 在对负数进行舍入时,JavaScript 会将–0.5 向零舍入。这意味着–4.5 会被舍入为–4,这与许多其他编程语言的舍入实现不同。

参见

舍入数字是将数值接近适当显示格式的一种方法。如果你正在使用舍入准备向用户显示的数字,你可能也对在“将数值转换为格式化字符串”中描述的Number格式化方法感兴趣。

在十进制值中保持精度

问题

JavaScript 中的所有数字都是浮点数值,某些操作可能会产生微小的四舍五入误差。在某些应用中(例如处理金额时),这些错误可能是不可接受的。

解决方案

浮点数的四舍五入误差是一个众所周知的现象,几乎存在于每一种编程语言中。要在 JavaScript 中看到它,请运行以下代码:

const sum = 0.1 + 0.2;
console.log(sum);      // displays 0.30000000000000004

你无法避免四舍五入误差,但可以将其最小化。如果你正在处理有两位小数精度的货币类型(如美元),考虑将所有值乘以 100 以避免处理小数。而不是编写如下代码:

const currentBalance = 5382.23;
const transactionAmount = 14.02;

const updatedBalance = currentBalance - transactionAmount;

// Now updatedBalance = 5368.209999999999

像这样使用货币变量:

const currentBalanceInCents = 538223;
const transactionAmountInCents = 1402;

const updatedBalanceInCents = currentBalanceInCents - transactionAmountInCents;

// Now updatedBalanceInCents = 536821

这解决了像加减几个分数值得到精确整数时的操作问题。但是,当你需要计算税款或利息时会发生什么?在这些情况下,无论如何都会得到分数值,并且在交易后立即对数值进行四舍五入,这就是企业和银行的做法:

const costInCents = 4899;

// Calculate 11% tax, and round the result to the nearest cent
const costWithTax = Math.round(costInCents*1.11);

讨论

浮点数的四舍五入问题源于某些十进制值无法在二进制表示中存储而不四舍五入的事实。与十进制数系统相同的问题(例如尝试写出 1/3 的结果)。浮点数的区别在于效果是反直觉的。我们不期望在加 0.1 和 0.2 时会有问题,因为在十进制表示法中这两个分数都可以被精确表示。

尽管其他编程语言也会经历同样的现象,但其中许多包含了专门的十进制或货币值数据类型。JavaScript 则不包括。然而,有一个提案用于新的十进制类型,可能会被纳入未来版本的 JavaScript 语言中。

参见

如果你进行大量的财务计算,可以通过使用像 bignumber.js 这样的第三方库简化你的生活,它提供了一个定制的数值数据类型,几乎与普通的 Number 类似,但保留了固定数量小数位的精确精度。

将字符串转换为数字

问题

你想要解析字符串中的数字并将其转换为数字数据类型。

解决方案

将数字转换为字符串是安全的,因为这个操作不会失败。反向任务——将字符串转换为数字,以便在计算中使用——则需要更谨慎。

使用 Number() 函数是最常见的方法:

const stringData = '42';
const numberData = Number(stringData);

Number() 函数不会接受带有货币符号和逗号分隔符的格式。它允许字符串开头和结尾的额外空格。Number() 函数还会将空字符串或仅包含空白字符的字符串转换为数字 0。这可能是一个合理的默认值(例如,如果你从文本框中获取用户输入),但并非总是合适的。为了避免这种情况,在调用 Number() 之前,请考虑检测是否为仅包含空白字符的字符串:

if (stringData.trim() === '') {
  // This is an all-whitespace or empty string
}

如果转换失败,Number() 函数会将值 NaN(即 非数)分配给你的变量。你可以在使用 Number() 后立即调用 Number.isNaN() 方法来测试此失败情况:

const numberData = Number(stringData);

if (Number.isNaN(numberData)) {
  // It's safe to process this data as a number
}
注意

isFinite() 方法与 isNaN() 几乎相同,但避免了奇怪的边缘情况,例如 1/0,它返回一个值为 infinity。如果你在 infinity 上使用 isNaN() 方法,它会返回 false,这有些难以置信。

另一种方法是使用 parseFloat() 方法。它是一种稍微宽松的转换,可以容忍数字后的文本。然而,parseFloat() 对空字符串更为严格,它会拒绝这种情况。

console.log(Number('42'));               // 42
console.log(parseFloat('42'));           // 42

console.log(Number('12 goats'));         // NaN
console.log(parseFloat('12 goats'));     // 12

console.log(Number('goats 12'));         // NaN
console.log(parseFloat('goats 12'));     // NaN

console.log(Number('2001/01/01'));       // NaN
console.log(parseFloat('2001/01/01'));   // 2001

console.log(Number(' '));                // 0
console.log(parseFloat(' '));            // NaN

讨论

开发者使用一些与 Number() 函数功能上等效的转换技巧,例如将字符串乘以 1 (numberInString*1) 或使用一元操作符 (+numberInString)。但为了清晰起见,推荐使用 Number()parseFloat()

如果你有一个格式化的数字(例如 2,300),你需要做更多工作来进行转换。Number() 方法会返回 NaN,而 parseFloat() 会在逗号处停止,并将其视为 2。不幸的是,尽管 JavaScript 有一个 Intl.NumberFormat 对象,可以从数字创建格式化字符串(参见 “将数值转换为格式化字符串”),但它并未提供反向操作的解析功能。

您可以使用正则表达式来处理诸如从字符串中删除逗号的任务(参见“替换字符串的所有出现”)。但是自制解决方案可能存在风险,因为某些语言环境使用逗号分隔千位数,而其他语言环境使用逗号分隔小数。在这种情况下,像Numeral这样经过广泛使用和测试的 JavaScript 库更加合适。

将十进制数转换为十六进制值

问题

您有一个十进制值,需要找到其十六进制等效值。

解决方法

使用Number.toString()方法,使用指定转换为的基数参数*:

const num = 255;

// displays ff, which is hexadecimal equivalent for 255
console.log(num.toString(16));

讨论

默认情况下,JavaScript 中的数字是十进制的。但是,它们也可以转换为不同的基数,包括十六进制(16)和八进制(8)。十六进制数字以0x开头(零后跟小写 x)。八进制数字过去以零(0)开头,但现在应该以零开始,然后是拉丁字母O(大写或小写):

const octalNumber = 0o255;  // equivalent to 173 decimal
const hexaNumber = 0xad;    // equivalent to 173 decimal

十进制数可以转换为 2 到 36 之间的其他基数:

const decNum = 55;
const octNum = decNum.toString(8);   // value of 67 octal
const hexNum = decNum.toString(16);  // value of 37 hexadecimal
const binNum = decNum.toString(2);   // value of 110111 binary

要完成八进制和十六进制的表示,您需要将0o连接到八进制,并将0x连接到十六进制值。但是请记住,一旦您将数字转换为字符串,无论其格式如何,都不要期望可以在任何数值计算中使用它。

尽管小数可以转换为任何基数(范围为 2 到 36 之间),但只有八进制、十六进制和十进制数字可以直接作为数字进行操作。

在度数和弧度之间进行转换

问题

您有一个以度数表示的角度。要在Math对象的三角函数中使用该值,您需要将度数转换为弧度。

解决方法

要将度数转换为弧度,请将度数值乘以(Math.PI/180)

const radians = degrees * (Math.PI / 180);

因此,如果您有一个 90 度的角度,计算如下:

const radians = 90 * (Math.PI / 180);
console.log(radians);   // 1.5707963267948966

要将弧度转换为度数,请将弧度值乘以(180/Math.PI)

const degrees = radians * (180 / Math.PI);

讨论

Math对象的所有三角函数方法(sin()cos()tan()asin()acos()atan()atan2())接受弧度值,并返回弧度作为结果。然而,人们通常提供度数而不是弧度,因为度数是更熟悉的度量单位。

计算圆弧的长度

问题

给定圆的半径和弧度中的角度,找到弧的长度。

解决方法

使用Math.PI将度数转换为弧度,并在公式中使用结果以找到弧的长度:

// angle of arc is 120 degrees, radius of circle is 2
const radians = degrees * (Math.PI / 180);
const arclength = radians * radius; // value is 4.18879020478...

讨论

圆弧的长度通过将圆的半径乘以弧度的弧度角来确定。

如果角度以度数给出,则在乘以半径之前,您需要先将度数转换为弧度。这种计算经常用于 SVG 中绘制形状,如第十五章中所述。

使用 BigInt 处理非常大的数字

问题

如果需要处理非常大的整数(超过 2⁵³),而不丢失精度。

解决方案

使用 BigInt 数据类型,可以容纳任意大小的整数,仅受系统内存限制(或您使用的 JavaScript 引擎的 BigInt 实现)。

您可以通过两种方式创建 BigInt。可以使用 BigInt() 函数,如下所示:

// Create a BigInt and set it to 10
const bigInteger = BigInt(10);

或者您可以在数字末尾添加字母 n

const bigInteger = 10n;

此示例显示了普通 NumberBigInt 在非常大数值上的差异:

// Ordinarily, large integers suffer from imprecision
const maxInt = Number.MAX_SAFE_INTEGER // Probably about 9007199254740991
console.log(maxInt + 1);  // 9007199254740992 (reasonable)
console.log(maxInt + 2);  // 9007199254740992 (not a typo, this seems wrong)
console.log(maxInt + 3);  // 9007199254740994 (sure)
console.log(maxInt + 4);  // 9007199254740996 (wait, what now?)

// BigInts behave more reliably
const bigInt = BigInt(maxInt);
console.log(bigInt + 1n);  // 9007199254740992 (as before)
console.log(bigInt + 2n);  // 9007199254740993 (this is better)
console.log(bigInt + 3n);  // 9007199254740994 (still good)
console.log(bigInt + 4n);  // 9007199254740995 (excellent!)
注意

当将 BigInt 记录到开发者控制台时,它的值会附加 n(例如 9007199254740992n)。此约定使得 BigInt 值易于识别。但如果只想要 BigInt 的数值,只需首先将其转换为文本,使用 BigInt.toString()

讨论

JavaScript 的原生 Number 类型符合 IEEE-754 规范,为 64 位双精度浮点数。该标准具有可接受的已知限制和不准确性。一个实际的限制是,超过 2⁵³ 后无法精确表示整数。超过此点后,之前仅限于小数点右侧的表示不准确性会跳到小数点左侧。换句话说,随着 JavaScript 引擎计数增加,不准确性的可能性增加。一旦超过 2⁵³,不准确性大于 1,并出现在整数计算中,而不仅仅是小数值计算中。

JavaScript 在 ECMAScript 2020 规范中引入了 BigInt 类型,部分解决了这个问题。BigInt 是一种任意大小的整数,允许您表示极大的数字。实际上,BigInt 的位宽没有上限。

几乎所有常规数字可用的运算符都可以用在 BigInt 上,包括加法(+)、减法(-)、乘法(*)、除法(/)和指数运算(**)。但是,BigInt 是整数类型,不存储小数值。执行除法操作时,BigInt 会静默丢弃小数部分:

const result = 10n / 6n;    // result is 1.

BigIntNumber 不可互换,也无法互操作。但可以使用 Number()BigInt() 函数相互转换:

let bigInteger = 10n;
let integer = Number(bigInteger);  // Number is 10

integer = 20;
bigInteger = BigInt(integer);      // bigInteger is 20n

如果要使用期望 Number 的方法与 BigInt 一起使用,或者想要在与另一个 BigInt 进行计算时使用 Number,则需要进行转换。

如果尝试将包含小数值的 Number 转换为 BigInt,将收到 RangeError。可以通过先四舍五入来避免此问题:

const decimal = 10.8;
const bigInteger = BigInt(Math.round(decimal));    // bigInteger is 11n

记得保持操作与类型一致。有时候看似简单的操作可能会失败,因为你意外地将BigInt与普通数字结合在一起:

let x = 10n;
x = x * 2;    // throws a TypeError because x is a BigInt and 2 is a Number
x += 1;       // also throws a TypeError

x = x * 2n;   // x is now 20n, as expected
x += 1n;      // x is 21

使用标准比较运算符(<><=>=),可以将BigInt值与Number值进行比较。如果想要测试BigInt和数字是否相等,请使用宽松相等运算符(==!=)。严格相等运算符(===)总是返回false,因为BigIntNumber是不同的数据类型。或者更好的方法是,将你的Number显式转换为BigInt,然后使用===进行比较。

关于BigInt还有一件事需要考虑:在发布时,它不能被序列化为 JSON。尝试对BigInt调用JSON.stringify()会导致语法错误。你有几个解决方案可供考虑。你可以通过适当的toJSON()方法对你的BigInt实现进行修补:

BigInt.prototype.toJSON = function() { return this.toString() }

你也可以使用像granola这样的库,它提供了对多种值(包括BigInt)进行 JSON 兼容字符串化的方法。

第四章:日期

JavaScript 具有出乎意料的强大日期功能,这些功能包装在有些老式的 Date 对象中。正如你将会看到的,Date 对象有其怪癖和隐藏陷阱——比如它从 0 开始计数月份,并根据当前计算机的地区设置不同解析年份信息。但一旦你学会如何应对这些障碍,你将能够完成许多常见而有用的操作,比如计算两个日期之间的天数,为显示格式化日期,以及计时事件。

获取当前日期和时间

Problem

你需要获取当前日期或时间。

Solution

JavaScript 包含一个 Date 对象,提供了良好的支持,用于操作日期信息(以及较为适度的日期计算支持)。当你创建一个新的 Date 对象时,它会自动填充当前的日期和时间,精确到最近的毫秒:

const today = new Date();

现在只需从你的 Date 对象中提取你想要的信息。Date 对象有一长串方法可以帮助你完成这个任务。表 4-1 列出了最重要的方法。请注意,不同方法使用的计数并不总是一致的。月份和星期的编号从 0 开始,而日期从 1 开始编号。

表 4-1. 获取日期信息的日期方法

方法 获取 可能的值
getFullYear() 年份 像 2021 这样的四位数字
getMonth() 月份编号 0 到 11,其中 0 表示一月
getDate() 月份中的某一天 1 到 31
getDay() 星期几 0 到 6,其中 0 表示星期日
getHours() 一天中的小时 0 到 23
getMinutes() 分钟数 0 到 59
getSeconds() 秒数 0 到 59
getMilliseconds() 毫秒数(千分之一秒) 0 到 999

这是一个显示当前日期一些基本信息的示例:

const today = new Date();

console.log(today.getFullYear());  // example: 2021
console.log(today.getMonth());     // example: 02 (March)
console.log(today.getDay());       // example: 01 (Monday)

// Do a little extra string processing to make sure minutes are padded with
// a leading 0 if needed to make a two-digit value (like '05' in the time 4:05)
const hours = today.getHours();
const minutes = today.getMinutes().toString().padStart(2, '0');
console.log('Time ' + hours + ':' + minutes);   // example: 15:32
注意

表 4-1 中列出的 Date 方法存在两个版本。表中显示的版本使用本地时间设置。第二组方法在方法名称前加上前缀 UTC(如 getUTCMonth()getUTCSeconds())。它们使用 协调世界时,全球时间标准。如果你需要比较来自不同时区的日期(或者遵循不同夏令时约定的日期),你必须使用 UTC 方法。在内部,Date 对象始终使用 UTC。

讨论

Date() 对象有几个构造函数。空构造函数创建一个当前日期和时间的 Date 对象,正如你刚刚看到的。但你也可以通过指定年、月和日来创建一个不同日期的 Date 对象,就像这样:

// February 10, 2021:
const anotherDay = new Date(2021, 1, 10);

再次注意,要注意不一致的计数方式(月份从 0 开始,而日期从 1 开始)。这意味着上面的 anotherDay 变量代表的是 2 月 10 日,而不是 1 月 10 日。

可选地,您可以为 Date 构造函数添加最多四个参数,分别为小时、分钟、秒钟和毫秒:

// February 1, 2021, at 9:30 AM:
const anotherDay = new Date(2021, 1, 1, 9, 30);

正如本章所述,JavaScript 内置的 Date 对象有一些众所周知的局限性和一些怪异之处。如果您需要在代码中执行广泛的日期操作,例如计算日期范围、解析不同类型的日期字符串或在时区之间移动日期,则最佳实践是使用经过测试的第三方日期库,例如 day.jsdate-fns

另请参阅

一旦您有了日期,您可能希望在日期计算中使用它,如 “比较日期和测试日期是否相等” 中所述。您可能还有兴趣将日期转换为格式化的字符串(“将日期值格式化为字符串”)或将包含日期的字符串转换为正确的 Date 对象(“将字符串转换为日期”)。

将字符串转换为日期

问题

您有一个日期信息的字符串,但您希望将其转换为 Date 对象,以便在代码中操纵它或执行日期计算。

解决方案

如果您很幸运,您的日期字符串符合 ISO 8601 标准时间戳格式(例如 “2021-12-17T03:24:00Z”),您可以直接将其传递给 Date 构造函数:

const eventDate = new Date('2021-12-17T03:24:00Z');

此字符串中的 T 将日期与时间分隔开,字符串末尾的 Z 表示它是使用 UTC 时区的通用时间,这是确保在不同计算机上保持一致性的最佳方式。

Date 构造函数(以及 Date.parse() 方法)可能识别其他格式。但是,现在强烈建议避免使用这些格式,因为它们在不同浏览器中的实现不一致。它们在测试示例中可能运行正常,但是在不同浏览器应用不同的区域设置(例如夏令时)时可能会遇到问题。

如果您的日期不是 ISO 8601 格式,您需要采取手动方法。从字符串中提取不同的日期组件,然后与 Date 构造函数一起使用。您可以充分利用像 split()slice()indexOf() 这样的 String 方法,这些方法在 第二章 的示例中有更详细的讨论。

例如,如果您有一个格式为 mm/dd/yyyy 的日期字符串,您可以使用以下代码:

const stringDate = '12/30/2021';

// Split on the slashes
const dateArray = stringDate.split('/');

// Find the individual date ingredients
const year = dateArray[2];
const month = dateArray[0];
const day = dateArray[1];

// Apply the correction for 0-based month numbering
const eventDate = new Date(year, month-1, day);

讨论

Date 对象构造函数并不执行太多的验证。在创建 Date 对象之前,请检查您的输入,因为 Date 对象可能接受您不希望的值。例如,它允许日期数字滚动(换句话说,如果您将 40 设置为您的日期数字,JavaScript 将会将您的日期移动到下个月)。Date 构造函数还会接受可能在不同计算机上解析不一致的字符串。

如果你尝试用非数字字符串创建一个Date对象,你将收到一个“Invalid Date”对象。你可以使用isNaN()来测试这种情况:

const badDate = '12 bananas';

const convertedDate = new Date(badDate);

if (Number.isNaN(convertedDate)) {
  // We end up here, because the date object was not created successfully
} else {
  // For a valid Data instance, we end up here
}

这种技术有效,因为Date对象实际上是幕后的数字,这一点在“比较日期和测试日期的相等性”中有所探讨。

另请参阅

“将日期值格式化为字符串”解释了相反的操作——将Date对象转换为字符串。

给日期添加天数

问题

你想找到在另一个日期之前或之后特定天数的日期。

解决方案

使用Date.getDate()找到当前日期号码,然后用Date.setDate()更改它。Date对象足够智能,可以根据需要滚动到下一个月或年。

const today = new Date();
const currentDay = today.getDate();

// Where will be three weeks in the future?
today.setDate(currentDay + 21);
console.log(`Three weeks from today is ${today}`);

讨论

setDate()方法不仅限于正整数。你可以使用负数来向后移动日期。你可能想使用其他setXxx()方法来修改日期,比如setMonths()每次向前或向后移动一个月,setHours()每小时移动,等等。所有这些方法都会像setDate()一样滚动,因此添加 48 小时将使日期向前移动两天。

Date对象是可变的,这使得其行为看起来相当老式。在更现代的 JavaScript 库中,你期望像setDate()这样的方法返回一个新的Date对象。但它实际上改变的是当前Date对象。即使你用const声明了一个日期,这种情况仍然会发生。(const阻止你将变量指向不同的Date对象,但不能阻止你修改当前引用的Date对象。)为了安全地避免潜在的问题,你可以在操作日期之前克隆它。只需使用Date.getTime()获取表示日期的毫秒数,并用它创建一个新对象:

const originalDate = new Date();

// Clone the date
const futureDate = new Date(originalDate.getTime());

// Change the cloned date
futureDate.setDate(originalDate.getDate()+21);
console.log(`Three weeks from ${originalDate} is ${futureDate}`);

另请参阅

“计算两个日期之间经过的时间”展示了如何计算两个日期之间的时间段。

比较日期和测试日期的相等性

问题

你需要查看两个Date对象是否表示相同的日历日期,或者确定一个日期是否在另一个日期之前。

解决方案

你可以像比较数字一样比较Date对象,使用<>运算符:

const oldDay = new Date(1999, 10, 20);
const newerDay = new Date(2021, 1, 1);

if (newerDay > oldDay) {
  // This is true, because newerDay falls after oldDay.
}

在内部,日期被存储为数字。当你使用<>运算符时,它们会自动转换为数字并进行比较。当你运行此代码时,你正在比较oldDay(943,074,000,000)的毫秒值与newerDay(1,612,155,600,000)的毫秒值。

等号运算符(=)的工作方式不同。它测试对象的引用,而不是对象的内容。(换句话说,只有当你比较指向同一实例的两个变量时,两个Date对象才相等。)

如果您希望测试两个Date对象是否表示同一时刻,您需要自己将它们转换为数字。最清晰的方法是调用Date.getTime(),它返回日期的毫秒数:

const date1 = new Date(2021, 1, 1);
const date2 = new Date(2021, 1, 1);

// This is false, because they are different objects
console.log(date1 === date2);

// This is true, because they have the same date
console.log(date1.getTime() === date2.getTime());
注意

尽管其名称为getTime(),但它并不仅仅返回时间。它返回的是毫秒数,准确表示该Date对象的日期和时间。

讨论

在内部,Date对象只是一个整数。具体来说,它是自 1970 年 1 月 1 日以来经过的毫秒数。毫秒数可以是负数或正数,这意味着Date对象可以表示从遥远的过去(大约公元前 271,821 年)到遥远的未来(公元 275,760 年)的日期。您可以通过调用Date.getTime()获取毫秒数。

两个Date对象仅在精确匹配(毫秒级别)时才相同。即使两个Date对象表示同一日期但具有不同的时间组件,它们也不会匹配。这可能是个问题,因为您可能没有意识到您的Date对象包含时间信息。这在为当前日期创建Date对象时是一个常见问题(“获取当前日期和时间”)。

要避免这个问题,您可以使用Date.setHours()来移除时间信息。尽管它的名称是setHours(),但该方法最多接受四个参数,允许您设置小时、分钟、秒和毫秒。为了创建一个仅包含日期的Date对象,将所有这些组件都设置为 0:

const today = new Date();

// Create another copy of the current date
// The day hasn't changed, but the time may have already ticked on
// to the next millisecond
const todayDifferent = new Date();

// This could be true or false, depending on timing factors beyond your control
console.log(today.getTime() === todayDifferent.getTime());

// Remove all the time information
todayDifferent.setHours(0,0,0,0);
today.setHours(0,0,0,0);

// This is always true, because the time has been removed from both instances
console.log(today.getTime() === todayDifferent.getTime());

另请参见

欲了解更多关于日期的数学内容,请参见 Recipes and 。

计算两个日期之间的经过时间

问题

您需要计算两个日期之间相隔多少天、小时或分钟。

解决方案

因为日期是数字(以毫秒为单位,请参见“比较日期和测试日期是否相等”),所以与它们进行计算相对简单。如果从一个日期减去另一个日期,您将得到它们之间的毫秒数:

const oldDate = new Date(2021, 1, 1);
const newerDate = new Date(2021, 10, 1);

const differenceInMilliseconds = newerDate - oldDate;

除非您正在为性能测试计时短操作,否则毫秒数不是特别有用的单位。您需要将此数字除以适当的值,以将其转换为更有意义的分钟数、小时数或天数:

const millisecondsPerDay = 1000*60*60*24;
let differenceInDays = differenceInMilliseconds / millisecondsPerDay;

// Only count whole days
differenceInDays = Math.trunc(differenceInDays);

console.log(differenceInDays);

尽管此计算应该得出确切的天数(因为两个日期都没有任何时间信息),但仍需在结果上使用Math.round()来处理浮点数运算中的舍入误差(请参见“保留小数值的准确性”)。

讨论

在执行日期计算时,有两个需要注意的陷阱:

  • 日期可能包含时间信息。(例如,为当前日期创建的新Date对象在其创建的毫秒级别上是准确的。)在计算天数之前,请使用setHours()来移除时间组件,正如“比较日期和测试日期是否相等”中所解释的那样。

  • 仅在日期处于相同时区时,两个日期的计算才有意义。理想情况下,这意味着您比较两个本地日期或两个 UTC 标准日期。从一个时区转换日期到另一个时区可能看起来很简单,但通常会出现关于夏令时的意外边缘情况。

对于老旧的Date对象,有一个暂定的替代方案。Temporal对象旨在改进使用本地日期和不同时区的计算。与此同时,如果您的日期需求超出了Date对象,可以尝试使用第三方库来操作日期。day.jsdate-fns都是流行的选择。

如果您想要使用微小的时间计算来分析性能,请注意Date对象不是最佳选择。相反,请使用Performance对象,在浏览器环境中通过内置的window.performance属性可用。它允许您捕获高分辨率时间戳,如果系统支持,精度可以达到毫秒的分数。以下是一个示例:

// Get a DOMHighResTimeStamp object that represents the start time
const startTime = window.performance.now();

// (Do a time consuming task here.)

// Get a DOMHighResTimeStamp object that represents the end time
const endTime = window.performance.now();

// Find the elapsed time in milliseconds
const elapsedMilliseconds = endTime - startTime;

结果(elapsedMilliseconds)不是最近的整毫秒,而是当前硬件支持的最精确的分数毫秒计数。

注意

虽然 Node 不提供Performance对象,但它具有自己的机制来检索高分辨率时间信息。您可以使用其全局process对象,该对象提供process.hrtime.bigint()方法。它返回以纳秒或十亿分之一秒为单位的时间读数。只需从另一个process.hrtime.bigint()读数中减去一个读数,即可找到纳秒中的时间差异。(每毫秒为 1,000,000 纳秒。)

因为纳秒计数显然会是一个非常大的数字,您需要使用BigInt数据类型来保存它,如“使用 BigInt 操作非常大的数字”中所述。

另请参阅

“添加日期”显示如何通过增加或减少日期来向前或向后移动日期。

将日期值格式化为字符串

问题

您想基于Date对象创建格式化的字符串。

解决方案

如果使用console.log()打印日期,您将获得日期的格式化字符串表示,例如“Wed Oct 21 2020 22:17:03 GMT-0400 (Eastern Daylight Time)”。此表示是由DateTime.toString()方法创建的标准化、非特定于区域设置的日期字符串,定义在JavaScript 标准中。

注意

在内部,Date对象将其时间信息存储为 UTC 时间,没有附加的时区信息。当您将Date转换为字符串时,该 UTC 时间将转换为当前计算机或设备上设置的特定于区域设置的时间。

如果您希望日期字符串格式不同,可以调用此处演示的其他预定义Date方法之一:

const date = new Date(2021, 0, 1, 10, 30);

let dateString;
dateString = date.toString();
 // 'Fri Jan 01 2021 10:30:00 GMT-0500 (Eastern Standard Time)'

dateString = date.toTimeString();
 // '10:30:00 GMT-0500 (Eastern Standard Time)'

dateString = date.toUTCString();
 // 'Fri, 01 Jan 2021 15:30:00 GMT'

dateString = date.toDateString();
 // 'Fri Jan 01 2021'

dateString = date.toISOString();
 // '2021-01-01T15:30:00.000Z'

dateString = date.toLocaledateString();
 // '1/1/2021, 10:30:00 AM'

dateString = date.toLocaleTimeString();
// '10:30:00 AM'

请记住,如果使用toLocaleString()toLocaleTime(),您的字符串表示基于浏览器实现和当前计算机的设置。不要假设一致性!

讨论

将日期信息转换为字符串有许多可能的方法。对于显示目的,toXxxString()方法效果很好。但是,如果您需要更具体或更精细调整的内容,可能需要自己控制Date对象。

如果您希望超越标准格式化方法,有两种方法可以尝试。您可以使用“获取当前日期和时间”中描述的getXxx()方法从日期中提取单独的时间组件,然后将其连接成您需要的精确字符串。以下是一个示例:

const date = new Date(2021, 10, 1);

// Ensure date numbers less than 10 are padded with an initial 0.
const day = date.getDate().toString().padStart(2, '0');

// Ensure months are 0-padded and add 1 to convert the month from its
// 0-based JavaScript representation
const month = (date.getMonth()+1).toString().padStart(2, '0');

// The year is always 4-digit
const year = date.getFullYear();

const customDateString = `${year}.${month}.${day}`;
// now customDateString = '2021.11.01'

这种方法非常灵活,但它强制您编写自己的日期样板,这并不理想,因为它增加了复杂性并为新错误创造了空间。

如果您想为特定区域设置使用标准格式,那么情况会变得简单一些。您可以使用Intl.DateTimeFormat对象进行转换。以下是三个示例,使用了美国、英国和日本的区域字符串:

const date = new Date(2020, 11, 20, 3, 0, 0);

// Use the standard US date format
console.log(new Intl.DateTimeFormat('en-US').format(date));  // '12/20/2020'

// Use the standard UK date format
console.log(new Intl.DateTimeFormat('en-GB').format(date));  // '20/12/2020'

// Use the standard Japanese date format
console.log(new Intl.DateTimeFormat('ja-JP').format(date));  // '2020/12/20'

所有这些都是仅包含日期的字符串,但是在创建Intl.DateTimeFormat()对象时,您可以设置许多其他选项。以下是一个示例,将星期几和月份添加到德语字符串中:

const date = new Date(2020, 11, 20);

const formatter = new Intl.DateTimeFormat('de-DE',
 { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });

const dateString = formatter.format(date);
// now dateString = 'Sonntag, 20\. Dezember 2020'

这些选项还允许您通过hourminutesecond属性向字符串添加时间信息,可以设置为:

const date = new Date(2022, 11, 20, 9, 30);

const formatter = new Intl.DateTimeFormat('en-US',
 { year: 'numeric', month: 'numeric', day: 'numeric',
   hour: 'numeric', minute: 'numeric' });

const dateString = formatter.format(date);
// now dateString = '12/20/2022, 9:30 AM'

参见

“将数值转换为格式化字符串”介绍了Intl对象和区域字符串的概念,这些字符串标识不同的地理和文化区域。要详细了解Intl.DateTimeFormat对象支持的 21 个选项,请参阅MDN 参考文档。值得注意的是,其中一些细节取决于实现,并非所有浏览器都支持(例如timeStyledateStyletimeZone属性,在此处我们没有讨论)。对于复杂的日期操作,始终考虑使用第三方库。

第五章:数组

自 JavaScript 问世以来,数组一直作为一个独立的数据类型存在。但多年来,我们与数组的交互方式发生了很大变化。

在过去,操作数组涉及大量的循环和迭代逻辑,以及一小组功能有限的方法。如今,Array对象提供了更多的功能,包括强调功能性方法的方法。使用这些方法,您可以过滤、排序、复制和转换数据,而无需逐个遍历数组元素。

在本章中,您将看到如何使用这些功能性方法,并了解何时可能需要回避它们。重点是使用今天可用的最现代实践来解决问题。

注意

如果您在浏览器的开发者控制台中尝试这些示例,请注意惰性评估可能会误导您。例如,考虑以下情况:如果您使用console.log()输出一个数组,然后对其进行排序,并再次记录它。您期望看到两个不同排序的数组信息。但实际上,您将看到最终排序的数组两次。这是因为大多数浏览器直到您打开控制台并点击扩展数组时才会检查数组中的项目。避免此问题的一种方法是遍历数组并分别记录每个项目。有关更多信息,请参阅“为什么 Chrome 的开发者控制台有时会撒谎”

检查对象是否为数组

问题

在执行数组操作之前,您需要验证您的对象确实是一个数组。

解决方案

使用静态的Array.isArray()方法:

const browserNames = ['Firefox', 'Edge', 'Chrome', 'IE', 'Safari'];

if (Array.isArray(browserNames)) {
  // We end up here, because browserNames is a valid array.
}

讨论

Array.isArray()方法是一个明显的选择。问题出现在开发者们试图使用旧的instanceOf运算符时。由于历史原因,instanceOf运算符在处理数组时有一些奇怪的边缘情况(例如,当您测试在另一个执行上下文中创建的数组时,比如不同的窗口时,它会返回false)。isArray()方法被添加来填补这个空白。

重要的是要理解,isArray()专门检查Array对象的实例。如果你在其他类型的集合(如MapSet)上调用它,它会返回false。即使这些集合具有类似数组的语义,甚至它们的名称中有array,比如TypedArray(用于二进制数据缓冲区的底层包装器)。

遍历数组中的所有元素

问题

您希望使用最佳方法来循环遍历数组中的每个元素,按顺序进行。

解决方案

传统方法是使用forof循环,它会自动获取每个项目:

const animals = ['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog', 'rabbit'];

for (const animal of animals) {
  console.log(animal);
}

在现代 JavaScript 中,越来越普遍地倾向于在处理数组代码时采用函数式方法。你可以使用Array.forEach()方法以函数式的方式迭代你的数组。你提供一个函数,该函数会为数组中的每个元素调用一次,并传递三个潜在有用的参数(元素本身、元素的索引和原始数组)。以下是一个示例:

const animals = ['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog', 'rabbit'];

animals.forEach(function(animal, index, array) {
  console.log(animal);
});

使用箭头语法,可以进一步简化此过程(“使用箭头函数”):

animals.forEach(animal => console.log(animal));

讨论

在像 JavaScript 这样的长寿语言中,通常有许多方法可以完成相同的事情。forof循环为遍历数组提供了简单直接的语法。它不允许修改正在遍历的数组中的元素,这是一种安全而明智的方法。

然而,有些情况下你可能需要使用一些不同的方法。其中最灵活的选择之一是使用带有计数器的基本for循环:

const animals = ['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog', 'rabbit'];

for (let i = 0; i < animals.length; ++i) {
  console.log(animals[i]);
}

这种方法可以使易于出错的一错百错错误未被检测到,这在现代编程中仍然是一个令人惊讶的常见错误来源。然而,在某些情况下,例如同时遍历多个数组时(参见“检查两个数组是否相等”),你需要使用for循环。

你还可以通过将函数传递给Array.forEach()方法来迭代数组。然后,该函数将为每个元素调用一次。你的函数可以接收三个参数:当前数组元素、当前数组索引以及对原始数组的引用。通常,你只需要元素本身。(你可以使用索引来更改原始数组中的元素,但这被认为是不好的做法。)

如果你想使用函数式方法来更改或检查数组,请考虑使用更具体、有针对性的方法。表 5-1 列出了最有用的方法。

表 5-1. 函数式数组处理方法

任务 数组方法 所涵盖
更改每个数组元素 map() “转换数组的每个元素”
查看所有元素是否满足特定条件 every() “验证数组内容”
查看至少一个元素是否满足特定条件 some() “验证数组内容”
查找满足条件的数组元素 filter() “提取满足特定条件的数组项”
重新排序数组 sort() “按属性值对对象数组进行排序”
在单个计算中使用数组的所有值 reduce() “将数组的值组合为单个计算”

现代编程实践更倾向于使用函数式方法处理数组,而不是迭代方法。函数式方法的优势在于,你的代码可以更简洁,通常更易读,也更少出错。大多数情况下,函数式方法还会为你的数组强制不可变性。它通过创建一个包含你想要的变化的数组的新副本来实现这一点,而不是直接修改原始数组对象。这种方法还会减少某些类型的错误发生的可能性。

注意

一般来说,将函数式数组方法作为首选方法。如果它们使你的任务变得更加困难(例如需要写多个数组或执行多个数组操作),切换到迭代方法。如果你正在编写性能密集型代码(例如操作极大数组的例程),考虑使用迭代方法,因为它通常执行效果更好。但是不要忘记首先对两种方法进行分析,看看差异是否真的显著。

检查两个数组是否相等

问题

你希望有一个简单的方法来测试两个数组是否等价(具有完全相同的内容)。

解决方案

最直接的方法实际上是老式的方法:使用基本的for循环和计数器,同时遍历两个数组,并比较每个元素。当然,在开始循环之前有几个检查要做,比如验证每个对象是否为数组,不为 null 等等。以下是将所有这些标准打包到一个有用函数中的代码片段:

function areArraysEqual(arrayA, arrayB) {
  if (!Array.isArray(arrayA) || !Array.isArray(arrayB)) {
    // These objects are null, undeclared, or non-array objects
    return false;
  }
  else if (arrayA === arrayB) {
    // Shortcut: they're two references pointing to the same array
    return true;
  }
  else if (arrayA.length !== arrayB.length) {
    // They can't match if they have a different item count
    return false;
  }
  else {
    // Time to look closer at each item
    for (let i = 0; i < arrayA.length; ++i) {
      // We require items to have the same content and be the same type,
      // but you could use loosely typed equality depending on your task
      if (arrayA[i] !== arrayB[i]) return false;
    }
    return true;
  }
}

现在你可以像这样检查两个数组是否相同:

const fruitNamesA = ['apple', 'kumquat', 'grapefruit', 'kiwi'];
const fruitNamesB = ['apple', 'kumquat', 'grapefruit', 'kiwi'];
const fruitNamesC = ['avocado', 'squash', 'red pepper', 'cucumber'];

console.log(areArraysEqual(fruitNamesA, fruitNamesB));  // true
console.log(areArraysEqual(fruitNamesA, fruitNamesC));  // false

在这个版本的areArraysEqual()中,将顺序不同但包含相同项的数组视为不匹配。你可以使用Array.sort()方法轻松地对字符串或数字数组进行排序。然而,把这段代码放在areArrayEquals()方法中可能并不合适,因为它可能不适用于你想要使用的数据类型,或者在比较大的数组时可能速度太慢。相反,在测试相等之前先对数组进行排序:

const fruitNamesA = ['apple', 'kumquat', 'grapefruit', 'kiwi'];
const fruitNamesB = ['kumquat', 'kiwi', 'grapefruit', 'apple'];

console.log(areArraysEqual(fruitNamesA.sort(), fruitNamesB.sort()));  // true

讨论

在编程中,常常由你自己决定什么是相等的。在这个例子中,areArraysEqual()执行的是浅比较。如果两个数组具有相同的基本类型或相同的对象引用,并且它们的元素顺序相同,它们就匹配。但是,如果开始比较更复杂的对象,就会出现歧义。

例如,考虑这两个包含单个相同Date对象的数组的比较:

const datesA = [new Date(2021,1,1)];
const datesB = [new Date(2021,1,1)];

console.log(areArraysEqual(datesA, datesB));  // false

这些数组不匹配,因为尽管底层的日期内容相同,但Date 实例是不同的。(或者换句话说,有两个单独的Date对象,它们恰好保存相同的信息。)

当然,你可以轻松比较两个Date对象的内容(只需调用getTime()将它们转换为毫秒时间表示,如“比较日期和测试日期是否相等”中所解释的)。但是如果你想在数组比较中这样做,你需要编写一个不同的函数。在你的函数中,你可以使用instanceOf来识别Date对象,然后在它们上调用getTime()

function areArraysEqual(arrayA, arrayB) {
  if (!Array.isArray(arrayA) || !Array.isArray(arrayB)) {
    return false;
  }
  else if (arrayA === arrayB) {
    return true;
  }
  else if (arrayA.length !== arrayB.length) {
    return false;
  }
  else {
    for (let i = 0; i < arrayA.length; ++i) {
      // Check for equal dates
      if (arrayA[i] instanceOf Date && arrayB[i] instanceOf Date) {
        if (arrayA[i].getTime() !== arrayB[i].getTime()) return false;
      }
      else {
        // Use the normal strict equality check
        if (arrayA[i] !== arrayB[i]) return false;
      }
    }
    return true;
  }
}

此示例中展示的问题适用于保存任何类型 JavaScript 对象的数组。甚至适用于保存嵌套数组的情况(因为每个Array都是一个对象)。然而,由于不同的对象需要不同的等式测试,因此你的解决方案可能会有所不同。

最后值得注意的是,许多流行的 JavaScript 库都有它们自己的通用解决方案来进行深度数组比较,这可能适合也可能不适合你的数据。如果你已经在使用像 Lodash 或 Underscore.js 这样的库,请调查它们的isEqual()方法。

将数组拆分为单独变量

问题

你需要将数组元素的值分配给多个变量,但是你希望有一种方便的方法,不需要分别为每个变量分配值。

解决方案

使用数组的解构语法一次性赋值多个变量。你可以编写一个表达式,在左侧声明多个变量,并从数组中获取值(在右侧)。下面是一个示例:

const stateValues = [459, 144, 96, 34, 0, 14];
const [arizona, missouri, idaho, nebraska, texas, minnesota] = stateValues;
console.log(missouri);   // 144

当你使用数组解构时,值是按位置复制的。在这个例子中,这意味着arizona获取数组中的第一个值,missouri获取第二个值,依此类推。如果你有多个变量而数组元素不足,额外的变量将获得undefined的值。

讨论

当你使用数组解构时,你不需要复制数组中的每个值。通过在没有变量名的情况下添加额外的逗号,你可以跳过不想要的值:

const stateValues = [459, 144, 96, 34, 0, 14];

// Just get three values from the array
const [arizona, , , nebraska, texas] = stateValues;
console.log(nebraska);   // 34

你还可以使用剩余操作符将所有剩余的值(那些你没有显式分配给变量的值)装入一个名为others的新数组中。下面是一个示例,将最后三个数组元素复制到数组others中:

const stateValues = [459, 144, 96, 34, 0, 14];
const [arizona, missouri, idaho, ...others] = stateValues;
console.log(others);   // 34, 0, 14
注意

JavaScript 的剩余操作符看起来就像扩展操作符(它是变量前的三个点)。它们在你的代码中甚至“感觉”相似,尽管它们实际上扮演互补的角色。剩余操作符可以收集额外的值并将它们压缩成单个数组。扩展操作符则将数组(或其他类型的可迭代对象)展开为单独的值。

到目前为止,你已经看到了变量声明和赋值在一条语句中的情况,但是你可以像创建普通变量一样将它们分开。只需确保保留方括号,因为它们表示你正在使用数组解构:

let arizona, missouri, idaho, nebraska, texas, minnesota;
[arizona, missouri, idaho, nebraska, texas, minnesota] = stateValues;

参见

如果你想要一种将数组转换为值列表而不将这些值分配给变量的方法,请查看描述在“将数组传递给期望值列表的函数”中的扩展操作符。

将数组传递给期望值列表的函数

问题

你的数组有一个值列表,你想将其传递给一个函数。但是函数期望的是一组参数值,而不是一个数组对象。

解决方案

使用扩展运算符来展开你的数组。这里有一个使用Math.max()方法的示例:

const numbers = [2, 42, 5, 304, 1, 13];

// This syntax is not allowed. The result is NaN.
const maximumFail = Math.max(numbers);

// But this works, thanks to the spread operator. (The answer is 304.)
const maximum = Math.max(...numbers);

讨论

扩展运算符将一个数组展开为元素列表。从技术上讲,它适用于任何可迭代对象,包括其他类型的集合。你将在本章的几个示例中看到它的应用。

扩展运算符不需要为函数提供所有参数,甚至最终参数。像这样使用是完全有效的:

const numbers = [2, 42, 5, 304, 1, 13];

// Call max() on the array values, along with three more arguments.
const maximum = Math.max(24, ...numbers, 96, 7);

如果你的参数顺序有任何意义,你可能不希望使用这种方法。很容易最终得到一个比预期稍大或稍小的数组,这会导致你的其他参数被移到新位置并改变其意义。

参见

“合并两个数组”展示了如何使用扩展运算符合并不同的数组的示例。“删除或替换数组元素”展示了在移除项目时如何使用扩展运算符。“克隆数组”展示了如何使用扩展运算符复制数组。

克隆数组

问题

你想复制一个现有的数组。

解决方案

使用扩展运算符将你的数组展开为项目,并将其馈送到一个新数组中:

const numbers = [2, 42, 5, 304, 1, 13];
const numbersCopy = [...numbers];

同样好的方法是使用Array.slice()方法,不带参数,告诉它取整个数组的一个切片:

const numbers = [2, 42, 5, 304, 1, 13];
const numbersCopy = numbers.slice();

这两种方法都比手动遍历数组元素并逐个构建新数组更可取。

讨论

创建数组副本很重要,因为它允许你执行非破坏性更改。例如,你可以保持原始数组不变,同时对新副本进行更改。这样,你可以减少意外副作用的风险(例如,如果代码的其他部分仍在使用原始数组)。

如同所有引用对象一样,数组不能通过赋值来复制。例如,下面的代码结束时,两个变量指向同一个内存中的Array对象:

const numbers = [2, 42, 5, 304, 1, 13];
const numbersCopy = numbers;

要正确复制一个数组,你需要复制它的所有元素。最简单的方法是使用扩展运算符,尽管Array.slice()方法同样有效。

这里展示的两种方法都创建了浅拷贝。如果你的数组由基本类型(数字、字符串或布尔值)组成,复制后的数组完全匹配。但是如果你的数组包含对象,这些技术会复制引用,而不是整个对象。因此,你的新数组将有指向相同对象的引用。改变复制数组中的一个对象也会影响原始数组:

const objectsOriginal = [{name: 'Sadie', age: 12}, {name: 'Patrick', age: 18}];
const objectsCopy = [...objectsOriginal];

// Change one of the people objects in objectsCopy
objectsCopy[0].age = 14;

// Investigate the same object in objectsOriginal
console.log(objectsOriginal[0].age);  // 14

这可能是一个问题,也可能不是,这取决于你计划如何使用你的数组。如果你想要多个可单独操作的对象副本,有几种可能的解决方案可以使用:

  • 使用for循环遍历数组,显式创建所需的新对象,然后将它们添加到新数组中。

  • 使用Array.map()函数。这对于简单对象很有效,但并不完全深度复制所有内容。(例如,如果你有对象引用其他对象,只有对象的第一层真正被复制。)

  • 使用来自其他 JavaScript 库的辅助函数,如 Lodash 中的cloneDeep()或 Ramda 中的clone()

这里有一个示例演示了Array.map()的使用。它通过首先使用展开操作符(…element)将数组元素扩展为其属性,然后使用这些属性创建一个新对象({element}),并将其分配给新数组:

const objectsOriginal = [{name: 'Sadie', age: 12}, {name: 'Patrick', age: 18}];

// Create a new array with copied objects
const objectsCopy = objectsOriginal.map( element => ({...element}) );

// Change one of the people objects in objectsCopy
objectsCopy[0].age = 14;

// Investigate the same object in objectsOriginal
console.log(objectsOriginal[0].age);  // 12

要深入了解map()方法,请参阅“转换数组的每个元素”中的完整解释。

注意

展开操作符(...)有双重作用。在原始解决方案中,你看到展开操作符如何将数组展开为单独的元素。在Array.map()示例中,展开操作符将一个对象展开为单独的属性。有关展开操作符在对象上的工作原理的更多信息,请参阅“合并两个对象的属性”。

参见

如果你只想复制某些数组项,请参阅“按位置复制数组的部分项”。要了解有关不同方式的深层复制对象的更多信息,请参阅“深层复制对象”。

合并两个数组

问题

想要将两个完整的数组合并成一个新数组。

解决方案

有两种常用的方法可以组合两个数组。传统的方法(可能也是性能最佳的选项)是使用Array.concat()方法。在第一个数组上调用concat(),将第二个数组作为参数传入。结果是一个包含两者所有元素的第三个数组:

const evens = [2, 4, 6, 8];
const odds = [1, 3, 5, 7, 9];

const evensAndOdds = evens.concat(odds);
// now evensAddOdds contains [2, 4, 6, 8, 1, 3, 5, 7, 9]

结果数组的项首先是第一个数组的项(在此示例中是偶数),然后是第二个数组的项(奇数)。当然,你可以在concat()之后调用Array.sort()方法(“按属性值对对象数组进行排序”)。

另一种方法是使用展开操作符(引入自“将数组传递给期望值列表的函数”):

const evens = [2, 4, 6, 8];
const odds = [1, 3, 5, 7, 9];

const evensAndOdds = [...evens, ...odds];

这种方法的优点在于代码(可以说)更直观和更易读。展开操作符也是一个很棒的工具,如果你想一次性组合多个数组,或者将数组与字面值组合:

const evens = [2, 4, 6, 8];
const odds = [1, 3, 5, 7, 9];

const evensAndOdds = [...evens, 10, 12, ...odds, 11];

性能测试表明,在当前实现中,使用 concat() 更快地合并大数组。但在大多数情况下,这种性能差异不会显著(甚至不会明显)。

讨论

在您使用任一种合并数组的技术后,您会得到三个数组:原始两个数组和新合并的结果数组。如果您的数组包含基本值(数字、字符串、布尔值),这些值在新数组中会被复制。但如果您的数组包含对象,则会复制对象的引用。例如,如果您合并两个包含 Date 对象的数组,则不会创建新的 Date 对象。相反,新合并的数组会得到指向相同 Date 对象的引用。如果您在合并后的数组中更改了 Date 对象,您将在原始数组中看到相同的修改:

const dates2020 = [new Date(2020,1,10), new Date(2020,2,10)];
const dates2021 = [new Date(2021,1,10), new Date(2021,2,10)];

const datesCombined = [...dates2020, ...dates2021];

// Change a date in the new array
datesCombined[0].setYear(2022);

// The same object is in the first array
console.log(dates2020[0]);   // 2022/02/10

欲了解浅复制和深复制之间的区别,请参阅 “深复制对象”。

另请参阅

当您合并数组时,您无法控制元素的组合方式。如果您想要复制数组的一部分,或者将一个数组放在另一个数组的中间,请参阅 “按位置复制数组的一部分” 中的 slice() 方法。

按位置复制数组的一部分

问题

您想要复制数组的一部分,并保持原始数组不变。

解决方案

使用 Array.slice() 方法,它可以浅复制现有数组的一部分,并将其作为新数组返回:

const animals = ['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog',
 'rabbit', 'goose'];

// Get the chunk from index 4 to index 7.
const domestic = animals.slice(4, 7);

console.log(domestic); // ['cat', 'dog', 'rabbit']

讨论

slice() 方法接受两个参数,表示起始和结束位置。您可以省略第二个参数以从起始索引到数组末尾。对数组调用 slice(0) 将复制整个数组。

例如,以下代码使用 slice 获取第一个数组的两个子部分,并使用它们构建一个新数组:

const animals = ['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog',
 'rabbit', 'goose'];

const firstHalf = animals.slice(0, 3);
const secondHalf = animals.slice(4, 7);

// Put two new animals in the middle
const extraAnimals = [...firstHalf, 'emu', 'platypus', ...secondHalf];

这可能看起来像是一个随意的示例,因为索引数字是硬编码的。但是您可以将其与数组搜索和 findIndex() 方法结合使用(参见 “在数组中搜索精确匹配项”)来确定您应该分割数组的位置。

注意

slice() 方法很容易与 splice() 方法混淆,后者用于替换或删除数组的部分内容。与 slice() 不同,splice() 方法会对原始数组进行影响的就地修改。在现代实践中,最好将对象锁定,尽可能保持其不可变性(因此使用 const),并通过创建带有更改的新副本来实现。因此,除非您有强烈的理由要使用 splice()(例如,在您的使用情况下性能存在显著差异),否则请坚持使用 slice()

另请参阅

“删除或替换数组元素” 展示了如何使用 slice() 删除数组的部分内容。

提取符合特定条件的数组项

问题

你想要找出数组中所有满足某个条件的项,并将它们复制到一个新数组中。

解决方案

使用 Array.filter() 方法在每个项上运行一个测试:

function startsWithE(animal) {
  return animal[0].toLowerCase() === 'e';
}

const animals = ['elephant', 'tiger', 'emu', 'zebra', 'cat', 'dog',
 'eel', 'rabbit', 'goose', 'earwig'];
const animalsE = animals.filter(startsWithE);
console.log(animalsE);   // ["elephant", "emu", "eel", "earwig"]

这个例子有意冗长,以便你能看到解决方案的不同部分。过滤函数 对数组中的每个项进行调用。在这种情况下,意味着 startsWithE() 会被调用 10 次,并传递不同的字符串。如果过滤函数返回 true,那么该项将被添加到新数组中。

这是使用箭头函数压缩后的相同示例。现在过滤逻辑在代码中的同一位置定义,你可以直接使用它:

const animals = ['elephant', 'tiger', 'emu', 'zebra', 'cat', 'dog',
 'eel', 'rabbit', 'goose', 'earwig'];
const animalsE = animals.filter(animal => animal[0].toLowerCase() === 'e');

讨论

在这个例子中,过滤函数检查每个项是否以字母 e 开头。但是你也可以轻松地获取落在某个范围内的数字,或者具有特定属性值的对象。

filter() 方法是一组现代数组方法中的一员,它取代了老式的迭代代码,采用了功能化方法。你可以使用 for 循环遍历数组,测试每个项,并使用 Array.push() 将匹配项插入新数组中。但是,如果能用 filter() 方法完成同样的任务,通常可以得到更紧凑的代码和更简便的测试。

参见

本章中的几个示例介绍了类似的功能化数组处理方法。特别是,“数组每个元素的转换”(#mapping_array)展示了如何转换数组中的所有元素,“将数组值组合成单一计算结果”(#reducing_array)展示了如何将数组中所有值组合成一个结果。

清空数组

问题

你需要从数组中删除所有元素,以释放内存或使数组可以重用。

解决方案

将数组的 length 属性设置为 0:

const numbers = [2, 42, 5, 304, 1, 13];
numbers.length = 0;

讨论

给自己创建一个新数组的最简单方法之一是简单地分配一个新的空数组,就像这样:

myArray = [];

然而,这种方法有一些限制。首先,因为它创建了一个全新的数组对象,所以如果你使用 const 关键字定义了数组,它就不起作用。这是一个小细节,但是现代实践倾向于使用 const 而不是 let,以减少代码中的错误可能性。其次,这种赋值并没有真正销毁数组。如果你有另一个变量指向你的数组,它将继续存在并驻留在内存中。

另一种解决方案是反复调用Array.pop()方法。每次调用pop()时,都会从数组中移除最后一项,因此你可以通过一个循环调用pop()直到数组为空来清空数组。然而,length设置技巧具有完全相同的效果,并且只需要一个语句。开发人员有时会忽视这种技术,因为他们期望length是只读属性(在许多其他语言中确实如此)。但是在 JavaScript 数组上设置length允许你缩小其大小并丢弃剩余的项。

还有其他有趣的方法可以使用length属性。例如,你可以通过减少length来仅截取数组的一部分,但不全为 0。或者,你可以通过增加length向数组末尾添加空白项:

const numbers = [2, 42, 5, 304, 1, 13];
numbers.length = 3;

console.log(numbers);  // [2, 42, 5]

numbers.length = 5;
console.log(numbers);  // [2, 42, 5, undefined, undefined]

移除重复值

问题

你希望确保数组中的每个值都是唯一的,通过删除重复项。

解决方案

创建一个新的Set对象并用你的数组填充它。Set对象会自动丢弃重复项。然后,将Set对象转换回数组:

const numbersWithDuplicates = [2, 42, 5, 42, 304, 1, 13, 2, 13];

// Create a Set with unique values (the duplicate 42, 2, and 13 are discarded)
const uniqueNumbersSet = new Set(numbersWithDuplicates);

// Turn the Set back into an array (now with 6 items)
const uniqueNumbersArray = Array.from(uniqueNumbersSet);

一旦理解了这个想法,你可以用展开运算符将其压缩为一个单一的语句:

const numbersWithDuplicates = [2, 42, 5, 42, 304, 1, 13, 2, 13];

const uniqueNumbers = [...new Set(numbersWithDuplicates)];

讨论

Set对象是一种特殊类型的集合,它忽略重复值。它还可以作为一种快速高效地从数组中删除重复项的方法。这种技术(切换到Set,然后再转回数组)比遍历数组并使用findIndex()查找重复项要高效得多。

在搜索重复项时,Set使用类似于严格相等比较===的测试,这意味着 3 和'3'不被视为重复项。Set实现的一个特殊行为是,它将重复的NaN值视为重复项,即使NaN === NaN通常计算结果为false

参见

本示例使用了在“将数组传递给期望值列表的函数”中描述的展开运算符。关于Set对象的更多信息,请参见“创建一个无重复值的集合”。

展平二维数组

问题

你想要将二维数组展平,使其成为一维列表。

解决方案

使用Array.flat()方法:

const fruitArray = [];

// Add three elements to fruitArray
// Each element is an array of strings
fruitArray[0] = ['strawberry', 'blueberry', 'raspberry'];
fruitArray[1] = ['lime', 'lemon', 'orange', 'grapefruit'];
fruitArray[2] = ['tangerine', 'apricot', 'peach', 'plum'];

const fruitList = fruitArray.flat();
// Now fruitList has 11 elements, and each one is a string

讨论

考虑一个二维数组,例如:

const fruitArray = [];
fruitArray[0] = ['strawberry', 'blueberry', 'raspberry'];
fruitArray[1] = ['lime', 'lemon', 'orange', 'grapefruit'];
fruitArray[2] = ['tangerine', 'apricot', 'peach', 'plum'];

fruitArray中的每个元素都包含另一个数组。例如,fruitArray[0]有三个字符串,代表不同的浆果。fruitArray[1]有柑橘类水果,fruitArray[2]有核果类水果。

你可以利用concat()方法转换fruitArray。从第一个嵌套数组开始,调用concat(),然后传递其他嵌套数组,就像这样:

const fruitList =
 fruitArray[0].concat(fruitArray[1],fruitArray[2],fruitArray[3]);

如果数组有多个成员,这种方法很繁琐且容易出错。或者,你可以使用循环或递归,但这些方法同样很繁琐。flat()方法实现了相同的逻辑,并为你拼接了每一行。

flat() 方法接受一个可选的 depth 参数,默认值为 1。你可以增加这个数字以深度展平嵌套更深的数组。例如,假设你有一个包含嵌套数组的数组,而这些数组又包含另一层嵌套数组。在这种情况下,depth 为 2 将连接两个层,将所有内容放入单个列表中:

// An array with several levels of nested arrays inside
const threeDimensionalNumbers = [1, [2, [3, 4, 5], 6], 7];

// The default flattening
const flat2D = threeDimensionalNumbers.flat(1);
// now flat2D = [1, 2, [3, 4, 5], 6, 7]

// Flatten two levels
const flat1D = threeDimensionalNumbers.flat(2);
// now flat1D = [1, 2, 3, 4, 5, 6, 7]

// Flatten all levels, no matter how many there are
const flattest = threeDimensionalNumbers.flat(Infinity);

depth 参数设置所需的最大展开级别。增加 depth 超出数组的实际维度不会有风险。

搜索数组以查找完全匹配项

问题

你想要在数组中搜索特定值。你可能想知道数组是否包含匹配项,或者匹配发生的位置。

解决方案

使用以下数组搜索方法之一:indexOf()lastIndexOf()includes()

const animals = ['dog', 'cat', 'seal', 'elephant', 'walrus', 'lion'];
console.log(animals.indexOf('elephant'));    // 3
console.log(animals.lastIndexOf('walrus'));  // 4
console.log(animals.includes('dog'));        // true

此技术仅适用于基本值(通常是数字、字符串和布尔值)。如果要搜索对象,则需要改用 Array.find() 方法(“搜索数组以满足特定条件的项”)。

讨论

indexOf()lastIndexOf() 都接受一个搜索值,然后将其与数组中的每个元素进行比较。如果找到该值,则返回数组元素的索引位置。如果未找到该值,则返回-1

indexOf() 方法从最低索引开始搜索并返回找到的第一个匹配项(换句话说,从数组的开始向前搜索)。lastIndexOf() 方法则相反,从数组的末尾开始搜索。如果相同项在数组中出现多次,则会出现差异:

const animals = ['dog', 'cat', 'seal', 'walrus', 'lion', 'cat'];

console.log(animals.indexOf('cat'));      // 1
console.log(animals.lastIndexOf('cat'));  // 5

indexOf()lastIndexOf() 都接受一个可选的起始索引参数。它设置搜索将从该位置开始:

const animals = ['dog', 'cat', 'seal', 'walrus', 'lion', 'cat'];

console.log(animals.indexOf('cat', 2));      // 5
console.log(animals.lastIndexOf('cat', 4));  // 1

你可能会想到可以使用循环通过 indexOf() 逐步增加索引,直到找到所有匹配项。但在编写这种样板代码之前,请考虑使用 filter() 方法,该方法可以根据指定的条件快速轻松地创建包含所有匹配项的数组(参见“按特定条件提取数组项”)。

最后,重要的是要理解 indexOf()lastIndexOf()includes() 都使用 === 运算符来测试匹配。这意味着不会执行类型转换(因此 3 不等于 '3')。此外,如果数组包含对象,则比较的是引用而不是内容。如果需要更改相等性的含义或者想使用不同的搜索测试,请改用 findIndex() 方法(参见“搜索数组以满足特定条件的项”)。

参见

对于可定制的搜索,请参阅 “搜索数组以满足特定条件的项” 中的 find()findIndex() 方法。

在数组中搜索符合特定标准的项目

问题

你想在数组中搜索符合某些标准的项。例如,也许你在寻找具有特定属性的对象。

解决方案

使用一种函数式数组搜索方法:find()findIndex()。无论哪种方式,你都需要提供一个测试每个项目的函数,直到找到匹配项。

这里有一个例子,找出第一个大于 10 的数字:

const nums = [2, 4, 19, 15, 183, 6, 7, 1, 1];

// Find the first value over 10.
const bigNum = nums.find(element => element > 10);

console.log(bigNum);  // 19 (the first match)

如果你更希望知道匹配元素的位置,而不是找到它,你可以使用类似的findIndex()方法:

const nums = [2, 4, 19, 15, 183, 6, 7, 1, 1];

const bigNumIndex = nums.findIndex(element => element > 100);

console.log(bigNumIndex);  // 4 (the index of the first match)

如果没有找到匹配项,find()返回undefined,而findIndex()返回-1。

讨论

当使用find()findIndex()时,你需要提供一个回调函数,该函数最多接收三个参数(迭代中的当前数组元素、其索引和数组本身)。箭头语法提供了一种更简洁的方法,允许你在使用时直接定义回调函数。

当你需要编写更复杂的条件时,find()findIndex()方法真正发挥了作用。考虑以下代码,它找到特定年份中的第一个日期:

// Remember, the Date constructor takes a zero-based month number, so a
// month value of 10 corresponds to the eleventh month, November
const dates = [new Date(2021, 10, 20), new Date(2020, 3, 12),
 new Date(2020, 5, 23), new Date(2022, 3, 18)];

// Find the first date in 2020
const matchingDate = dates.find(date => date.getFullYear() === 2020);

console.log(matchingDate);  // 'Sun Apr 12 2020 ...'

使用indexOf()方法是不可能的,因为它涉及检查数组项的属性。(事实上,标准的indexOf()方法甚至不能测试Date对象是否相等,因为它只检查对象引用是否匹配。)

另见

如果你想编写一个查找函数并用它获取多个结果,你可能需要使用在“按特定标准提取数组项”中描述的filter()函数。有关箭头函数语法的更多信息,请参见“使用箭头函数”。

移除或替换数组元素

问题

你想在数组中查找给定值的出现次数,并且要么移除该元素,要么替换它。

解决方案

首先,使用indexOf()找到你想要移除的项的位置。然后,你可以使用两种方法中的任何一种。

对于小任务,最干净的解决方案是构建一个新的数组,围绕你不想要的项。你使用slice()和扩展运算符构建新数组:

const animals = ['dog', 'cat', 'seal', 'walrus', 'lion', 'cat'];

// Find where the 'walrus' item is
const walrusIndex = animals.indexOf('walrus');

// Join the portion before 'walrus' to the portion after 'walrus'
const animalsSliced =
 [...animals.slice(0, walrusIndex), ...animals.slice(walrusIndex+1)];

// now animalsSliced has ['dog', 'cat', 'seal', 'lion', 'cat']

讨论

另一种方法是进行就地数组编辑,而不是创建一个更改的副本。这对于大型数组可能会表现更好。然而,你允许的可变性越多,你的代码就会变得越复杂,这可能会使将来更难管理和调试。

为了进行就地编辑,你使用同名但非常不同的splice()方法。它允许你从任何位置开始移除你想要的任意数量的项:

const animals = ['dog', 'cat', 'seal', 'walrus', 'lion', 'cat'];

// Find where the 'walrus' item is
const walrusIndex = animals.indexOf('walrus');

// Starting at walrusIndex, remove 1 element
animals.splice(walrusIndex, 1);

// now animals = ['dog', 'cat', 'seal', 'lion', 'cat']

splice()方法的第一个参数是切片开始的位置。这是你需要提供的唯一参数。如果省略其他参数,从索引到末尾的所有数组元素都会被移除:

const animals = ['cat', 'walrus', 'lion', 'cat'];

// Start at 'lion', and remove the rest of the elements
animals.splice(2);
// now animals = ['cat', 'walrus']

可选的第二个参数是要删除的元素数。第三个参数是要在相同位置插入的可选替换元素集。

const animals = ['cat', 'walrus', 'lion', 'cat'];

// Remove one element and add two new elements
animals.splice(2, 1, 'zebra', 'elephant');
// now animals = ['cat', 'walrus', 'zebra', 'elephant', 'cat']

您可以在循环中使用 indexOf() 来查找并删除一系列匹配元素。但是,如果这是您的目标,通常使用 filter() 方法会更简洁,它允许您定义一个选择要保留的项目的函数(参见 “按特定条件提取数组项”)。

根据属性值对对象数组进行排序

问题

您希望根据其属性对包含对象的数组进行排序。

解决方案

Array.sort() 方法重新排序数组。例如,它可以将数字数组从最小到最大排列,或者将字符串数组按字母顺序排列。但是你不必局限于数组的标准排序系统。相反,你可以将一个比较函数传递给 sort() 方法,数组将使用它来排序其项。

比较函数获取两个项(对应于两个不同的数组元素),比较它们,并返回一个指示结果的数字。如果值应被视为相等,则返回 0,如果第一个值小于第二个值,则返回 –1,如果第一个值大于第二个值,则返回 1

下面是对带有人员信息的对象数组进行排序的简单实现:

const people  = [
 { firstName: 'Joe', lastName: 'Khan', age: 21 },
 { firstName: 'Dorian', lastName: 'Khan', age: 15 },
 { firstName: 'Tammy', lastName: 'Smith', age: 41 },
 { firstName: 'Noor', lastName: 'Biles', age: 33 },
 { firstName: 'Sumatva', lastName: 'Chen', age: 19 }
];

// Sort the people from youngest to oldest
people.sort( function(a, b) {
  if (a.age < b.age) {
    return -1;
  } else if (a.age > b.age) {
    return 1;
  } else {
    return 0;
  }
});
console.log(people);
// Now the order is Dorian, Sumatva, Joe, Noor, Tammy

这里可以有几个快捷方式。从技术上讲,您可以返回任何负数而不是 -1,并返回任何正数而不是 1。这允许您编写一个更短的比较函数:

people.sort(function(a, b) {
  // Subtract the ages to sort from youngest to oldest
  return a.age - b.age;
});

结合紧凑的箭头语法,它变得更短:

people.sort((a,b) => a.age - b.age);

有时,在进行排序时,您可以利用现有的比较方法。例如,如果您希望此示例按姓氏排序,无需重新发明轮子。相反,充分利用 String.localeCompare() 方法,如下所示:

people.sort((a,b) => a.lastName.localeCompare(b.lastName));
console.log(people);
// Now the order is Noor, Sumatva, Joe, Dorian, Tammy

讨论

sort() 方法会直接修改您的数组。这与您将使用的大多数其他数组方法不同,后者返回更改后的副本,但保留原始数组不变。如果这不是您想要的行为,您可以在排序之前克隆数组,如 “克隆数组” 中详细说明的那样。

转换数组的每个元素

问题

您希望使用相同的转换将数组中的每个元素转换,并使用更改后的值构建一个新数组。

解决方案

使用 Array.map() 方法,并提供执行更改的函数。map() 方法遍历整个数组,将您的函数应用于每个元素,并使用返回值构建一个新数组。

这里有一个示例,使用这种方法将十进制数字数组转换为具有其十六进制等效项的新数组(使用 “将十进制转换为十六进制值” 中描述的转换技术):

const decArray = [23, 255, 122, 5, 16, 99];

// Use the toString() method to conver to base-16 values
const hexArray = decArray.map( element => element.toString(16) );

console.log(hexArray);  // ['17', 'ff', '7a', '5', '10', '63']

讨论

通常,map()函数只关注数组元素。然而,你的回调函数可以接受两个额外参数:索引和原始数组。使用这些细节,从技术上讲,可以使用map()来更改你的原始数组。这被认为是一种反模式。换句话说,如果你不打算使用map()返回的新数组,就不应该使用map()方法。考虑使用forEach()方法代替(“遍历数组中的所有元素”),或者只是按程序顺序迭代你的数组。

将数组的值合并为单个计算

问题

你想要在某种聚合计算中使用数组中的所有值,比如计算总和或平均值。

解决方案

你可以在循环中迭代数组。但为了更简洁的解决方案,可以使用带有回调函数的Array.reduce()方法。你的函数(称为reducer 函数)会对数组中的每个元素进行调用。你使用累加器构建某种运行总和,这是reduce()方法在过程结束前维护的值。

例如,想象一下,你想要计算一个数字数组的总和。每次调用你的 reducer 函数时,它会在累加器中获取当前的运行总和。然后将当前元素的值加上并返回新的总和:

const reducerFunction = function (accumulator, element) {
  // Add the current value to the running total in the accumulator.
  const newTotal = accumulator + element;
  return newTotal;
}

当 reducer 为下一个项目调用时,这个新的总和就会成为累加器。

现在你可以使用这个函数来对数组求和:

const numbers = [23, 255, 122, 5, 16, 99];

// The second argument (0) sets the starting value of the accumulator.
// If you don't set a starting value, the accumulator is automatically set
// to the first element.
const total = numbers.reduce(reducerFunction, 0);
console.log(total);  // 520

当 reducer 函数在最后一个项目上调用时,它进行最终计算。该返回值成为从reduce()返回的结果。

一旦你熟悉了reduce()的工作方式,你可以使用内联函数和箭头语法使你的代码更短更简洁。以下是一个演示,使用reduce()来计算平方值的总和、平均值和最大值:

const numbers = [23, 255, 122, 5, 16, 99];

// The reducer function adds to the accumulator
const totalSquares = numbers.reduce( (acc, val) => acc + val**2, 0);
// totalSquares = 90520

// The reducer function adds to the accumulator
const average = numbers.reduce( (acc, val) => acc + val, 0) / numbers.length;
// average = 86.66...

// The reducer function returns the higher value (accumulator or current value)
const max = numbers.reduce( (acc, val) => acc > val ? acc: val);
// max = 255

讨论

使用reduce()方法可能比其他函数式数组处理方法更复杂,比如map()(“转换数组的每个元素”)、filter()(“提取符合特定条件的数组项”)或sort()(“按属性值对对象数组进行排序”)。不同之处在于,你需要仔细考虑每个函数调用后需要存储的数据。记住,你可以使用累加器来存储一个具有多个属性的自定义对象,从而跟踪所需的所有信息。你还可以向 reducer 函数添加两个可选参数:index(元素的当前索引号)和array(正在被 reduce 的整个数组)。但要小心。过于热衷于使用reduce()的代码可能会很快变得难以理解。

参见

还有另一种方法可以从数字数组中获取最大值。您可以使用Math.max()方法与展开运算符结合使用,将数组转换为参数列表(参见“将数组传递给期望值列表的函数”)。

验证数组内容

问题

您希望确保数组内容满足特定的条件。

解决方案

使用Array.every()方法来检查每个元素是否通过了给定的测试。例如,以下代码使用正则表达式来确保数组中每个元素都由字母组成:

// The testing function
function containsLettersOnly(element) {
  const textExp = /^[a-zA-Z]+$/;
  return textExp.test(element);
}

// Test an array
const mysteryItems = ['**', 123, 'aaa', 'abc', '-', 46, 'AAA'];
let result = mysteryItems.every(containsLettersOnly);
console.log(result);  // false

// Test another array
const mysteryItems2 = ['elephant', 'lion', 'cat', 'dog'];
result = mysteryItems2.every(containsLettersOnly);
console.log(result);  // true

或者,使用Array.some()方法来确保至少有一个元素通过了测试。例如,以下代码检查数组中至少有一个元素是字母字符串:

const mysteryItems = new Array('**', 123, 'aaa', 'abc', '-', 46, 'AAA');

// testing function
function testValue (element) {
   const textExp = /^[a-zA-Z]+$/;
   return textExp.test(element);
}

// run test
const result = mysteryItems.some(testValue);
console.log(result);  // true

讨论

不同于许多其他使用回调函数的数组方法,every()some()方法不会处理所有数组元素。相反,它们只会处理足够多的数组元素以满足其功能需求。

解决方案展示了相同的回调函数可以同时用于every()some()方法。区别在于,使用every()时,一旦函数返回false值,处理就结束,并且方法返回falsesome()方法会继续对每个数组元素进行测试,直到回调函数返回true。此时,不再验证其他元素,并且方法返回true。但是,如果回调函数对所有元素进行了测试,并且没有为任何元素返回truesome()将返回false

参见

要查看此示例中用于字符串匹配模式的正则表达式语法,请参阅“使用正则表达式替换字符串中的模式”。

创建一个非重复值的集合

问题

您希望创建一个类似数组的对象,它从不包含超过一次的相同值。

解决方案

创建一个Set对象。它会静默地忽略尝试多次添加相同项的操作,而不会生成错误。

Set不是数组,但像数组一样,它是一个可迭代的元素集合。您可以使用add()方法逐个添加元素到Set中,或者您可以将数组传递给Set构造函数一次性添加多个项:

// Start with six elements
const animals = new Set(['elephant', 'tiger', 'lion', 'zebra', 'cat', 'dog']);

// Add two more
animals.add('rabbit');
animals.add('goose');

// Nothing happens, because this item is already in the Set
animals.add('tiger');

// Iterate over the Set, just as you would with an array
for (const animal of animals) {
    console.log(animal);
}

讨论

Set对象不是数组。与Array类不同,后者提供了三十多个有用的方法,Set类提供的功能要少得多。您可以使用add()插入项目,delete()删除项目,has()检查项目是否在Set中,以及clear()一次性删除所有项目。没有用于排序、过滤、转换或复制的方法。

然而,如果需要像处理数组一样处理您的Set对象,通过将Set传递给静态的Array.from()方法进行转换非常容易:

// Convert an array to a Set
const animalSet = new Set(['elephant', 'tiger', 'zebra', 'cat', 'dog']);

// Convert a Set to an array
const animalArray = Array.from(animalSet);

实际上,你可以随意将Set转换为Array对象,反复进行操作,除了可能的性能损失外(如果列表项非常长)并不会有其他成本。

注意

要计算SetMap集合中的项数,您使用size属性。这与数组不同,数组具有length属性。

创建带有键索引项的集合

问题

您想创建一个集合,其中每个项都带有唯一的字符串键。

解决方案

使用Map对象。每个对象都以唯一键索引(通常是字符串,但不一定)。要添加项目,可以调用set()方法。当需要检索特定项目时,可以通过键直接获取所需的项目:

const products = new Map();

// Add three items
products.set('RU007', {name: 'Rain Racer 2000', price: 1499.99});
products.set('STKY1', {name: 'Edible Tape', price: 3.99});
products.set('P38', {name: 'Escape Vehicle (Air)', price: 2999.00});

// Check for two items using the item code
console.log(products.has('RU007'));  // true
console.log(products.has('RU494'));  // false

// Retrieve an item
const product = products.get('P38');
if (typeof product !== 'undefined') {
  console.log(product.price);  // 2999
}

// Remove the Edible Tape item
products.delete('STKY1');

console.log(products.size);  // 2

讨论

当向Map对象添加项时,必须始终使用set()方法。不要陷入这个陷阱:

const products = new Map();

// Don't do this!
products['RU007'] = {name: 'Rain Racer 2000', price: 1499.99};

尽管一开始似乎能行得通(并且它使用了许多其他编程语言中用于名称-值集合的相同语法),但实际上是绕过了Map集合,并在Map对象上设置了一个名为RU007的普通属性。如果你用for...of循环迭代Map时,这些属性不会出现,并且它们对于has()get()方法也是不可见的。

Map对象有一小组用于管理其内容的方法:set()get()has()delete()。如果您想利用Array对象中的功能,可以使用静态的Array.from()方法轻松将Map转换为数组:

const productArray = Array.from(products);

console.log(productArray[0]);
 // ['RU007', {name: 'Rain Racer 2000', price: 1499.99}]

您可能期望此示例中的productArray将保存一组产品对象,但这并不完全正确。相反,productsArray中的每个元素都是一个单独的数组,其中第一个元素是键(如*RUU07*),第二个元素是值(产品对象)。

在某些情况下,当您将Map转换为数组时,可能不需要保留键名。也许键名不重要,或者被元素的属性重复了。在这种情况下,您可以选择转换您的集合,将键值丢弃,同时将数据从Map中复制出来。这是它的工作原理:

const productArray = Array.from(products, ([name, value]) => value);

console.log(productArray[0]);
 // {name: 'Rain Racer 2000', price: 1499.99}

第六章:函数

函数是你用来组装程序的离散、可重用代码例程的构建块。但在 JavaScript 中,这只是故事的一部分。

JavaScript 函数也是真正的对象Function 类型的实例。它们可以被赋值给变量并在代码中传递。它们可以在表达式中声明,不需要函数名,并且可以使用简化的箭头语法。你甚至可以将一个函数包装在另一个函数中,以创建一个包含函数状态的私有包(称为闭包)。

函数也是 JavaScript 面向对象支持的核心。这是因为自定义类实际上只是一种特殊类型的构造函数(正如你将在第八章中看到的)。迟早,JavaScript 中的一切都会回到函数。

将函数作为参数传递给另一个函数

问题

你正在调用一个期望你提供自己函数的函数。最佳的传递方式是什么?

解决方案

JavaScript 中的许多函数接受,甚至要求,作为参数传递的函数。一些操作要求回调函数在任务完成时触发。其他需要使用你的函数完成更广泛的任务。例如,Array 对象的许多方法要求你提供一个用于排序、转换、组合或选择数据的函数。然后数组多次使用你的函数,直到处理完每个元素。

在提供函数作为参数时,有几种不同的方法可以使用。以下是三种常见模式:

  • 提供对已在代码中其他位置声明的函数的引用。如果你想在应用程序的其他部分使用该函数,或者该函数特别长或复杂,这种方法是有意义的。

  • 函数表达式中声明函数,然后将其作为参数传递。这种方法适用于简单的任务,并且如果你不打算在其他地方使用该函数。

  • 在需要时内联声明函数—当你将其作为参数传递给另一个函数时。这类似于第二种方法,但使你的代码更加紧凑。它最适合非常简短、直接的函数(尤其是一行代码)。

让我们从一个简单的页面开始,页面上有这个按钮:

<button id="runTest">Run Test</button>

我们如下附加事件处理程序:

// Attach button event handler.
document.getElementById('runTest').addEventListener("click", buttonClicked);

现在考虑内置的setTimeout()函数,它安排一个函数在一定延迟后运行(你提供函数)。这是将函数传递的第一种方法,使用一个名为showMessage()的单独函数:

// Runs when a button is clicked
function buttonClicked() {
  // Trigger the function after 2000 milliseconds (2 seconds)
  setTimeout(showMessage, 2000);
}

// Runs when setTimeout() triggers it
function showMessage() {
  alert('You clicked the button 2 seconds ago');
}
注意

当你通过名称传递函数引用时,请确保不要添加一组空括号。这个例子将showMessage传递给setTimeout()函数。如果你意外地写成showMessage(),JavaScript 将立即运行showMessage()函数,并将其返回值传递给setTimeout(),而不是传递函数引用。

这是第二种方法,它在需要的地方使用函数表达式声明函数:

function buttonClicked() {
  // Declare a function expression to use with setTimeout()
  const timeoutCallback = function showMessage() {
    alert('You clicked the button 2 seconds ago');
  }

  // Trigger the function after 2000 milliseconds (2 seconds)
  setTimeout(timeoutCallback, 2000);
}

在这种情况下,showMessage()的作用域仅限于buttonClicked()函数。它无法从代码中的其他函数中调用。选择性地,你可以省略函数名(showMessage),使其成为一个匿名函数。无论哪种方式,timeoutCallback的工作方式都是相同的,但是函数名在调试时非常有用,因为在堆栈跟踪中会显示它如果发生错误。

这是第三种方法,它在调用setTimeout()时内联声明函数:

function buttonClicked() {
  // Trigger the function after 2000 milliseconds (2 seconds)
  setTimeout(function showMessage() {
    alert('You clicked the button 2 seconds ago');
  }, 2000);
}

现在showMessage()函数在一个语句中声明并传递给setTimeout()。任何代码的其他部分都无法与showMessage()交互,即使在buttonClicked()函数内部也是如此。选择性地,你可以省略名称showMessage(),使其成为一个匿名函数:

  setTimeout(function() {
    alert('You clicked the button 2 seconds ago');
  }, 2000);

你可以进一步简化这种方法,使用箭头语法,如示例中的“使用箭头函数”所示。但是对于长或复杂的代码例程,使用函数名是一个很好的实践。这是因为如果函数内部发生错误,你将在堆栈跟踪中看到函数名。

注意

当你使用匿名函数时,注意你组织的风格约定。一个常见的模式是在同一行上放置function()声明和开放的{括号。然后,在下面放置匿名函数的所有代码,缩进一个额外的级别。最后,将闭合的}括号放在单独的一行上,紧接着函数调用的其余参数。

讨论

这三种方法展示了逐渐缩小范围的示例,从最可访问的函数(第一个示例中)到最不可访问的函数(最后一个示例中)。作为一个通用的规则,尽可能使用最窄的范围是最好的。这减少了代码中的歧义(使其更容易理解后续开发者),并减少了意外副作用的可能性。但是,这是一个权衡。随着函数变得更长和更复杂,内联声明变得不太可读。如果你想单独使用函数或对其运行单元测试,你需要将其拆分为一个单独的函数。

如果你对函数如何使用函数引用有任何疑问,这里有一个简单的示例,使用一个名为callYouBack()的自定义函数,它接受一个函数参数然后调用它。在callYouBack()函数内部,你像调用普通函数一样对待函数引用,通过名称调用它并提供它需要的任何参数:

function buttonClicked() {
  // Create a function that will handle the callback
  function logTime(time) {
    console.log('Logging at: ' + time.toLocaleTimeString());
  }

  console.log('About to call callYouBack()');
  callYouBack(logTime);
  console.log('All finished');
}

function callYouBack(callbackFunction) {
  console.log('Starting callYouBack()');

  // Call the provided function and supply an argument
  callbackFunction(new Date());

  console.log('Ending callYouBack()');
}

如果你运行这段代码并点击按钮,它会产生如下输出:

About to call callYouBack()
Starting callYouBack()
Logging at: 2:20:59 PM
Ending callYouBack()
All finished

参见

参见“使用箭头函数”,这种语法允许您简化匿名函数的声明,特别适用于返回值的单行函数。参见表 5-1 中接受函数参数的最重要的Array方法。

使用箭头函数

问题

想要使用 JavaScript 的箭头语法以最简洁的方式声明内联函数。

解决方案

近年来,JavaScript 已经转向强调函数式编程模式——数组处理和异步 Promise 是其中两个显著例子。为了帮助,他们添加了一种新的、简化的函数语法来编写内联函数,称为箭头语法

这是使用Array.map()方法对数组内容进行转换的示例,使用了不带箭头语法的命名函数。初始数组是一组数字,转换后的数组是每个数字的平方:

const numbers = [1,2,3,4,5,6,7,8,9,10];

function squareNumber(number) {
  return number**2;
}
const squares = numbers.map(squareNumber);

console.log(squares);
// Displays [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

这是相同的示例,但使用箭头语法内联声明了squareNumber()函数:

const numbers = [1,2,3,4,5,6,7,8,9,10];
const squares = numbers.map( number => number**2 );

console.log(squares);

讨论

此示例使用最简洁的箭头语法形式。这适用于单参数、单语句函数。其他函数可能无法使用箭头语法的所有简化。为了理解其中的原因,这里逐步解释如何将命名函数转换为使用箭头语法的函数表达式:

  1. 首先放置参数列表,然后是=>符号。如果没有参数,在=>符号之前使用空的括号。
(number) =>
  1. 如果只有一个参数(如此示例),可以删除参数列表周围的括号。
number =>
  1. 将函数的大括号和函数体放在箭头的另一侧。
number => {
  return number**2;
}
  1. 如果只有一个语句,可以删除大括号和return关键字。但如果有多个语句,则必须保留大括号和return关键字。
number => number**2;

记住,箭头函数用于声明内联函数,因此你总是会将它传递给一个参数或将其分配给一个表达式中的变量:

const myFunc = number => number**2;

const squaredNumber = myFunc(10);
// squaredNumber = 100

现在让我们看看如何转换这个稍微复杂的函数:

function raiseToPower(number, power) {
  return number**power;
}

你可以执行步骤 1、3 和 4,但步骤 2 不适用(因为此函数有两个参数):

const myFunc = (number, power) => number**power;

或者,考虑这个更详细的字符串处理函数:

function applyTitleCase(inputString) {
  // Split the string into an array of words
  const wordArray = inputString.split(' ');

  // Create a new array that will hold the processed words
  const processedWordArray = [];

  for (const word of wordArray) {
    // Capitalize the first letter of this word
    processedWordArray.push(word[0].toUpperCase() + word.slice(1));
  }

  // Join the words back into a single string
  return processedWordArray.join(' ');
}

这里,步骤 1、2 和 3 适用,但步骤 4 不适用。你必须保留大括号和return语句不变。

const myFunc = inputString => {
  // Split the string into an array of words
  const wordArray = inputString.split(' ');

  // Create a new array that will hold the processed words
  const processedWordArray = [];

  for (const word of wordArray) {
    // Capitalize the first letter of this word
    processedWordArray.push(word[0].toUpperCase() + word.slice(1));
  }

  // Join the words back into a single string
  return processedWordArray.join(' ');
}

现在传统方法和箭头语法之间的差异变得更小。只有开头的函数声明发生了变化,总体代码节省量极少。

注意

在这里,围绕箭头语法的决策变得更加模糊。通常可以将几个语句的函数压缩为单个表达式。在字符串处理示例中,您可以使用方法链(如“替换字符串的所有出现”)和Array.map()函数(“转换数组的每个元素”)而不是for循环。如果应用得当,这些更改可以将applyTitleCase()缩短为一个长语句。然后,您可以使用所有箭头语法的快捷方式。然而,在这种情况下,更简洁的代码目标不值得以清晰度为代价。作为一般的经验法则,箭头语法仅在帮助您编写更可读的代码时才有益。

箭头函数有一种不同的绑定this关键字的方式。在声明的函数中,this映射到调用函数的对象,可以是当前窗口、按钮等。在箭头函数中,this简单地指向定义箭头函数的代码。换句话说,无论在何处创建箭头函数时this是什么,函数运行时this保持不变。这种行为简化了许多问题,但代价是箭头语法不适合对象方法和构造函数,因为箭头函数不会绑定到它们被调用的对象上。即使使用Function.bind()也不会改变这种行为。

还有一些较小的限制。箭头函数不能与yield一起用于生成器函数,并且不支持arguments对象。

参见

第五章有许多使用箭头语法将短函数传递给数组处理方法的示例。例如,Recipes,,和。

提供默认参数值

问题

您希望为参数指定默认值,如果调用函数时未传入参数,则将使用该默认值。

解决方案

当您声明一个函数时,可以直接为参数分配默认值。以下是一个为第三个参数thirdNum设置默认值的示例:

function addNumbers(firstNum, secondNum, thirdNum=0) {
  return firstNum+secondNum+thirdNum;
}

现在可以调用此函数而不指定所有三个参数:

console.log(addNumbers(42, 6, 10));  // displays 58
console.log(addNumbers(42, 6));      // displays 48

讨论

默认参数是一个相对较新的发明。然而,JavaScript 从未强制函数调用者为函数提供所有参数。在遥远的过去,函数可以简单地检查参数是否为undefined(通过使用typeof运算符测试,如“检查对象是否为某一类型”中所述)。

您可以为尽可能多的参数设置默认值。作为良好风格的一部分,应首先放置您需要的参数,然后是具有默认值的参数。换句话说,一旦添加了默认参数,后面的所有参数都应该变得可选并具有默认值。这种约定不是必需的,但可以使代码更清晰。

当调用具有多个默认参数的函数时,可以选择提供哪些值。考虑以下示例:

function addNumbers(firstNum=10, secondNum=20, thirdNum=30, multiplier=1) {
  return multiplier*(firstNum+secondNum+thirdNum);
}

如果要指定firstNumsecondNummultiplier,但省略thirdNum参数,则需要使用undefined作为占位符。这使您可以按正确的顺序传递所有参数:

const sum = addNumbers(42, 10, undefined, 1);
// sum = 82

但是null不能作为占位符使用。在这个例子中,它被简单地转换为数字 0,改变了结果:

const sum = addNumbers(42, 10, null, 1);
// sum = 52

许多其他语言有更好的默认参数快捷方式(例如使用逗号指示顺序而无需提供占位符值,或通过名称设置参数值)。JavaScript 没有这样的功能,尽管您可以使用对象文字语法模拟命名参数(“使用命名函数参数”)。

创建一个接受无限参数的函数

问题

您希望创建一个函数,该函数接受调用者想要提供的任意数量的参数,而无需创建数组。

解决方案

在声明函数时使用rest 参数。rest 参数在其名称之前用三个点定义:

function sumRounds(...numbers) {
  let sum = 0;
  for(let i = 0; i < numbers.length; i+=1)  {
    sum += Math.round(numbers[i]);
  }
  return sum;
}

console.log(sumRounds(2.3, 4, 5, 16, 18.1));  // 45

讨论

rest 参数不需要是唯一的参数,但必须是最后一个参数。它收集传递给函数的所有额外参数,并将它们添加到一个新数组中。

在过去,JavaScript 开发者使用arguments对象来实现类似的功能。arguments对象在每个函数中都可用(技术上来说,它是Function.arguments属性),它提供了类似数组的访问所有参数的方式。然而,arguments不是真正的数组,开发者经常使用样板代码将其转换为数组。您可能仍然会在实际应用中看到这种方法,但现在使用 rest 参数可以避免这种麻烦。

注意

rest 参数看起来与展开运算符相似(“将数组分解为单独变量”),但两者扮演互补角色。展开运算符展开一个数组或对象的属性为单独的值,而 rest 运算符则收集单独的值并将它们插入到一个单一的数组对象中。

参见

如果您有一个值数组,想要将其传递给一个函数,但该函数期望一个 rest 参数,您可以使用展开运算符进行转换(参见“将数组分解为单独变量”)。

本例中使用循环来处理值数组,但您可以使用Array.reduce()函数更清晰地实现相同的结果,如“将数组的值组合成单一计算”中演示的那样。

使用命名函数参数

问题

您希望有一种更简单的方法来选择发送到函数的可选参数。

解决方案

将所有可选参数捆绑到单个对象字面量中(“使用对象字面量捆绑数据”)。调用者可以决定在创建对象字面量时包含哪些可选参数。这是使用此模式的函数调用示例:

someFunction(arg1, arg2, {optionalArg1: val1, optionalArg2: val2});

在您的函数中,您可以使用 解构赋值 快速地将值从对象字面量中复制到单独的变量中。这是一个接受三个参数的函数的示例。前两个参数(newerDateolderDate)是必需的,但第三个参数是一个对象字面量,可以包含三个可选值(discardTimediscardYearsprecision):

function dateDifferenceInSeconds(
 newerDate, olderDate, {discardTime, discardYears, precision} = {}) {
  if (discardTime) {
    newerDate = newerDate.setHours(0,0,0,0);
    olderDate = newerDate.setHours(0,0,0,0);
  }
  if (discardYears) {
    newerDate.setYear(0);
    olderDate.setYear(0);
  }

  const differenceInSeconds = (newerDate.getTime() - olderDate.getTime())/1000;
  return differenceInSeconds.toFixed(precision);
}

可以带有或不带有对象字面量调用 dateDifferenceInSeconds()

// Compare the current date to an older date
const newDate = new Date();
const oldDate = new Date(2010, 1, 10);

// Call the function without an object literal
let difference = dateDifferenceInSeconds(newDate, oldDate);
console.log(difference);   // Shows something like 354378086

// Call the function with an object literal, and specify two properties
difference = dateDifferenceInSeconds(
 newDate, oldDate, {discardYears:true, precision:2});
console.log(difference);   // Shows something like 7226485.90

讨论

JavaScript 中的一个常见模式是使用对象字面量传递可选值。这样可以只设置需要的属性,而不必担心顺序。

// This works
dateDifferenceInSeconds(newDate, oldDate, {precision:2});

// This also works
dateDifferenceInSeconds(newDate, oldDate, {discardYears:true, precision:2});

// This works too
dateDifferenceInSeconds(newDate, oldDate, {precision:2, discardYears:true});

在函数中,您可以像这样单独从对象字面量中检索属性:

function dateDifferenceInSeconds(newerDate, olderDate, options) {
  const precision = options.precision;

但是本节中的解决方案使用了一个更好的快捷方式。它使用解构将对象字面量解包到命名变量中,这将对象的属性映射到单独的命名变量中。您可以在语句中使用解构赋值:

function dateDifferenceInSeconds(newerDate, olderDate, options) {
  const {discardTime, discardYears, precision} = options;

或者直接在函数声明中:

function dateDifferenceInSeconds(
 newerDate, olderDate, {discardTime, discardYears, precision})

将一个空对象字面量设置为默认值是一个很好的做法(“提供默认参数值”)。如果调用者没有提供对象字面量,则使用这个空对象:

function dateDifferenceInSeconds(
 newerDate, olderDate, {discardTime, discardYears, precision} `=` `{``}`)

调用者可以决定是否设置一些、全部或不设置对象字面量中的属性。未设置的任何值将计算为特殊值 undefined,您可以在代码中测试这些值。以下是一个不太优化的示例:

  if (discardTime != undefined || discardTime === true) {

通常,您不需要显式检查 undefined 值。例如,undefined 在条件逻辑中求值为 falsedateDifferenceInSeconds() 函数在评估 discardYearsdiscardTime 属性时使用这种行为,这使我们可以缩短代码:

  if (discardTime) {

precision 属性有一个类似的快捷方式。安全地调用 Number.toPrecision(undefined) 是可以的,因为这与不带参数调用 toPrecision() 是一样的。无论哪种方式,数字都会四舍五入到最接近的整数。

对象字面量模式的唯一缺点是无法防止属性命名错误,例如:

// We want discardYears, but we accidentally set discardYear
dateDifferenceInSeconds(newDate, oldDate, {discardYear:true});

另请参阅

“使用对象字面量捆绑数据” 介绍了对象字面量。“将数组分解为单独变量” 展示了数组解构语法,它类似于本节中使用的对象解构语法,只是作用于数组而不是对象(并使用方括号而不是花括号)。

使用闭包存储其状态的函数创建

问题

您想创建一个可以记住数据但又无需使用全局变量并且不需要在每个函数调用中重复发送相同数据的函数。

解决方案

将需要保留其状态的函数包装在另一个函数中。外部函数返回内部函数,遵循以下结构:

function outerFunction() {

  function innerFunction() {
    ...
  }

  return innerFunction;
}

这两个函数都可以接受参数。但这里有一个诀窍。外部函数的参数只要您引用内部函数就会一直存在。您可以随意调用内部函数,外部函数中的数据将持久存在。(在概念上,外部函数就像是一个对象创建方法,而内部函数就像是具有状态的对象。)

这里有一个完整的示例:

function greetingMaker(greeting) {
  function addName(name) {
    return `${greeting} ${name}`;
  }
  return addName;
}

// Use the outer function to create two copies of the inner function,
// each with a different value for greeting
const daytimeGreeting = greetingMaker('Good Day to you');
const nightGreeting = greetingMaker('Good Evening');

console.log(daytimeGreeting('Peter'));   // Shows 'Good Day to you Peter'
console.log(nightGreeting('Sally'));     // Shows 'Good Evening Sally'

讨论

通常,您会发现需要一种方法来存储跨多个函数调用使用的数据。您可以使用全局变量,但这是最后的手段。全局变量会导致命名冲突,使代码复杂化,并经常导致不同函数之间隐藏的相互依赖关系,限制代码的重用,并为隐藏的编码错误提供掩盖。

您可以要求函数调用者维护此信息,并在每个函数调用时发送它,但这样做可能会很尴尬。本示例展示了另一种解决方案——创建一个保持状态的函数包,称为闭包

在此解决方案中,外部函数greetingMaker()接受一个参数,即特定的问候语。它还返回一个内部函数addName(),该函数本身接受人名。闭包包含addName()函数及其周围的上下文,其中包括传递给greetingMaker()函数的参数。为了演示这一事实,创建了两个addName()的副本,存在于两个不同的上下文中。一个存在于将白天消息传递给greetingMaker()的闭包中,另一个存在于将夜间消息传递给greetingMaker()的闭包中。无论如何,当调用addName()函数时,它都使用当前上下文构造其消息。

值得注意的是,状态不仅限于参数值。任何在外部函数中的变量只要函数引用存在就会保持活动状态。以下是一个示例,其中使用简单的计数器变量来跟踪调用了多少次函数:

function createCounter() {
  // This variable persists as long as the createCounter function reference
  let count = 0;

  function counter() {
    count += 1;
    console.log(count);
  }
  return counter;
}

const counterFunction = createCounter();
counterFunction();  // displays 1
counterFunction();  // displays 2
counterFunction();  // displays 3

参见

要查看使用闭包存储状态的另一个函数示例,请参阅“额外:构建可重复使用的伪随机数生成器”。

不是偶然闭包和包装函数似乎在模仿面向对象编程。过去,JavaScript 开发人员使用函数来模仿自定义类(参见“使用构造函数模式创建自定义类”),而 JavaScript 的 class 关键字扩展了这种方法(参见“创建可重用类”)。

创建一个生成器函数,可以产生多个值

问题

你想创建一个生成器,一个能按需提供多个值的函数。每当生成器返回一个值时,它会暂停执行,直到调用者请求下一个值。

解决方案

要声明一个生成器函数,首先用function*替换function关键字:

function* generateValues() {
}

在生成器函数内部,每当你想要返回一个结果时使用yield关键字。记住,执行在你yield后停止(就像使用return关键字时一样)。然而,当调用者请求函数的下一个值时,执行会恢复。这个过程会一直持续,直到你的函数代码结束,或者你使用return关键字返回最终值。

这里是一个生成器的简单实现。(它可以工作,但并没有解决一个有用的问题。)这个函数生成三个值,然后返回一个值:

function* generateValues() {
  yield 895498;
  yield 'This is the second value';
  yield 5;
  return 'This is the end';
}

当你调用一个生成器函数时,你会得到一个Generator对象作为返回值。这发生在生成器函数代码开始运行之前立即发生。你可以使用Generator对象来运行函数并检索生成的值。你也可以用它来确定生成器函数何时完成。

每当你调用Generator.next()时,生成器函数会运行直到达到下一个yield(或最终的return)。next()方法返回一个带有两个值的对象。value属性包装了从生成器函数中产生的值或返回的值。done属性是一个布尔值,在生成器函数结束前保持为false

const generator = generateValues();

// Start the generator (it runs from the beginning to the first yield)
console.log(generator.next().value);  // 895498

// Resume the generator (until the next yield)
console.log(generator.next().value);  // 'This is the second value'

// Get the final two values
console.log(generator.next().value);  // 5
console.log(generator.next().value);  // 'This is the end'

讨论

生成器允许你创建可以暂停和恢复的函数。最重要的是,JavaScript 会自动管理它们的状态,这意味着你不需要编写任何代码来在调用next()之间保存值。(这与构建自定义迭代器不同,例如。)

因为生成器具有延迟执行模型,所以它们非常适合耗时的数据创建或检索操作。例如,你可以使用生成器来计算复杂序列中的数字,或者从数据流中检索信息块。

通常情况下,你不会知道一个生成器会返回多少值。你可以编写一个while循环,检查Generator.done属性,并不断调用next()直到完成。但因为生成器对象是可迭代的,一个forof循环效果更好:

// Get all the values from the generator
for (const value of generateValues()) {
  console.log(value);
}

// With spread syntax, you can dump everything into an array in one step
const values = [...generateValues()];

无论哪种方式,这种方法只能获取yielded的结果。如果你的生成器有最终的返回值,它将被忽略。

一些生成器函数设计为无限。只要你继续调用next(),它们就会继续生成值。如果你调用一个无限生成器,你不能把所有的值都放入数组中(程序会挂起)。相反,你可能会使用一个带有条件的while循环,当你得到所需的所有值时条件会变为false

参见

“创建异步生成器函数”展示了如何创建异步运行的生成器。

额外信息:构建一个可重复的伪随机数生成器

虽然您已经剖析了生成器函数的基本语法,但尚未见过一个真正实用的示例。以下是一个演示无限生成器函数如何提供有用值序列的示例。

如“生成随机数”中所解释的,Math.random() 方法允许您生成伪随机数,但您无法控制种子值。(相反,Math.random() 使用不透明、非加密安全的方法来为其伪随机数生成器种子化,这可能因 JavaScript 实现而异。)这对大多数应用来说都是可以接受的。但在某些情况下,您需要一种方法来生成一个可重复的伪随机数序列。这些数字在分布上仍然需要是统计上随机的;唯一的区别在于,您需要能够要求您的伪随机数生成器给出同样的序列超过一次。需要重复的伪随机数的重要示例包括某些需要精确可再现的模拟或测试。

有几个第三方 JavaScript 库提供可种子化(因此可重复)的伪随机数生成器。您可以在GitHub上找到一个长列表。其中一个最简单的是 Mulberry32。其 JavaScript 实现适合于单个密集的代码块:

function mulberry32(seed) {
  return function random() {
    let t = seed += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  }
}

// Choose a seed
const seed = 98345;

// Get a version of mulberry32() that uses this seed:
const randomFunction = mulberry32(seed);

// Generate some random numbers
console.log(randomFunction());  // 0.9057375795673579
console.log(randomFunction());  // 0.44091642647981644
console.log(randomFunction());  // 0.7662326360587031

mulberry32() 函数使用了在“创建使用闭包记住其状态的函数”中描述的闭包技术。它接受一个种子值,然后将其锁定在内部random()函数的上下文中。这意味着无论何时调用random(),原始种子值都将在外部函数中可用。这很重要,因为不同的种子意味着不同的随机变量序列。如果您使用相同的种子值调用mulberry32(),则保证从random()获得相同的伪随机数序列。

注意

像大多数伪随机数生成器一样,Mulberry32 返回一个介于 0 和 1 之间的分数值。要将其转换为给定范围内的整数,请使用“生成随机数”中所示的技术。

自闭包从 JavaScript 语言的太古时代起就存在,但生成器却是一个较新的创新。您可以使用生成器函数重写此示例,更清晰地表达其目的:

function* mulberry32(seed) {
  let t = seed += 0x6D2B79F5;

  // Generate numbers indefinitely
  while(true) {
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    yield ((t ^ t >>> 14) >>> 0) / 4294967296;
  }
}

// Use the same seed to get the same sequence.
const seed = 98345;

const generator = mulberry32(seed);
console.log(generator.next().value);  // 0.9057375795673579
console.log(generator.next().value);  // 0.7620641703251749
console.log(generator.next().value);  // 0.0211441791616380

因为mulberry32()函数声明为function*,所以立即清楚它将返回多个值。在内部,无限循环确保生成器始终准备好创建新的数字。每次通过循环后,random()都会产生一个新的随机值,然后暂停,直到使用next()请求新值。该解决方案的整体操作与其原始版本类似,但现在遵循一个熟悉的模式,这可能使其使用更容易发现。(但——像往常一样——这种重构的价值取决于您组织的约定、阅读您代码的人的期望以及您个人的品味。)

在生成器中构建无限循环并没有危险,只要它们进行 yield 操作。通过 yield 操作暂停代码,确保不会阻塞 JavaScript 事件循环。与普通函数不同,没有期望生成器函数将运行到最终的闭括号。一旦Generator对象超出范围,该函数及其上下文将可供垃圾收集。

通过使用部分应用程序减少冗余

问题

您有一个接受多个参数的函数。您希望用一个或多个特定版本的新函数包装此函数,这些版本需要更少的参数。

解决方案

下面的makestring()函数接受三个参数(换句话说,它的arity为 3):

function makeString(prefix, str, suffix) {
   return prefix + str + suffix;
}

但是,第一个和最后一个参数通常基于特定用例重复。您希望尽可能消除参数的重复。

您可以通过创建新的函数来解决这个问题,这些函数封装了先前创建的makeString()函数,但已知参数值被锁定:

function quoteString(str) {
   return makeString('"',str,'"');
}

function barString(str) {
   return makeString('-', str, '-');
}

function namedEntity(str) {
   return makeString('&#', str, ';');
}

现在只需一个参数即可调用这些新函数中的任何一个:

console.log(quoteString('apple')); // "apple"
console.log(barString('apple'));   // -apple-
console.log(namedEntity(169));     // "&#169; (the copyright symbol in HTML)

讨论

将一个函数包装在另一个函数中以锁定一个或多个参数值的技术称为部分应用程序(因为新函数部分应用到原始函数的参数值)。当然,这样做的权衡是,您创建的额外函数也可能会使您的代码变得混乱,因此不要构建您不打算使用和重用的包装器。

高级:部分函数工厂

您甚至可以通过创建一个能够部分化任何其他函数的函数进一步减少此方法的冗余。事实上,这种方法是一种相当常见的 JavaScript 设计模式。在过去,您需要依赖 JavaScript 的arguments对象和数组操作。在现代 JavaScript 中,剩余和展开运算符使这项工作变得更加简单。

在这里显示的实现中,部分化函数命名为partial()。它能够为任何函数减少任意数量的参数。

function partial(fn, ...argsToApply) {
  return function(...restArgsToApply) {
    return fn(...argsToApply, ...restArgsToApply);
  }
}

这个函数需要一点解包操作。但首先,看一个使用它的简单例子是有帮助的。在这里,partial()函数用于创建一个新的cubeIt()函数,它包装了更通用的raiseToPower()函数。换句话说,cubeIt()使用部分应用来锁定raiseToPower()的一个参数(指数,它设置为 3)。

// The function you want to partialize
function raiseToPower(exponent, number) {
  return number**exponent;
}

// Using partial(), make a customized function
const cubeIt = partial(raiseToPower, 3);

// Calculate the cube of 9 (9**3)
console.log(cubeIt(9));  // 729

现在当你调用cubeIt(9)时,调用被映射到raiseToPower(3, 9)

那么它是如何工作的呢?partial()函数接受两个参数。第一个是你想要部分应用的函数(fn)。第二个是你想要锁定在原位的所有参数的列表(argsToApply),这些参数使用剩余操作符(...)捕获到一个数组中,如在“创建一个接受无限参数的函数”中所解释的那样。

function partial(fn, ...argsToApply) {

现在事情变得有趣了。partial函数返回一个嵌套的内部函数(一种在“创建一个存储其闭包状态的函数”中探讨的技术)。嵌套的内部函数接受所有未锁定在原位的参数。再次强调,这些参数使用剩余操作符(...restToApply)捕获到一个数组中:

  // This returns a new anonymous function
  return function(...restArgsToApply) {

现在,这个新创建的函数有三个关键部分的信息:底层函数(fn),锁定在原位的参数(argsToApply),以及每次调用函数时设置的参数(restArgsToApply)。

函数内部只有一行代码,但包含了很多信息。它使用展开操作符将两个数组展开为参数列表(这有些令人困惑,因为它看起来与剩余操作符完全相同)。换句话说,argsToApply变成了一个参数列表,后跟restToApply

    // This calls the wrapped function
    return fn(...argsToApply, ...restArgsToApply);
注意

函数式编程中的一个常见做法是编写高阶函数(操作其他函数的函数)。partial()函数是一个创建另一个函数包装器的高级函数。

这个partial()函数的实现有一个限制。因为它首先放置固定的参数,所以你无法锁定稍后的参数而不锁定所有先出现的参数。如果你想使用partial()为原始解决方案中的makeString()函数创建一个包装器,你需要首先重新排列它的参数:

function makeString(prefix, suffix, str) {
  return prefix + str + suffix;
}

const namedEntity = partial(makeString, "&#", ";");

console.log(namedEntity(169));

额外:使用 bind()部分提供参数

您还可以使用Function.bind()方法创建部分应用程序。bind()方法返回一个新函数,将this设置为提供的第一个参数。所有其他参数都被预置到新函数的参数列表中。

现在我们不必使用partial()来创建命名实体函数,而是可以使用bind()来提供相同的功能,将undefined作为第一个参数传递:

function makeString(prefix, suffix, str) {
  return prefix + str + suffix;
}

const named = makeString.bind(undefined, "&#", ";");

console.log(named(169)); // "&#169;"

现在你有两种好方法来创建使用不同参数的函数的多个版本。

修复此功能绑定问题

问题

您的函数试图使用关键字this,但未绑定到正确的对象。

解决方案

使用Function.bind()方法来更改函数的上下文和this引用的含义:

window.onload = function() {
  window.name = 'window';

  const newObject = {
    name: 'object',

    sayGreeting: function() {
      console.log(`Now this is easy, ${this.name}`);

      const nestedGreeting = function(greeting) {
        console.log(`${greeting} ${this.name}`);
        }.bind(this);

      nestedGreeting('hello');
    }
  };

  newObject.sayGreeting();
};

讨论

关键字this指的是函数的所有者或父级。在 JavaScript 中,与this相关的挑战是我们无法始终保证哪个父对象将应用于函数。

在解决方案中,对象有一个方法sayGreeting(),输出一条消息并将另一个嵌套函数映射到其属性nestedGreeting。如果您使用构造函数模式(“使用构造函数模式创建自定义类”)创建类似类的函数对象,您将看到这种方法。

没有Function.bind()方法,第一条消息将会说“现在这很容易,对象”,但第二条消息将会说“hello window”。第二条消息有不同名称的原因是因为函数的嵌套使内部函数与周围对象分离,所有未作用域限定的函数自动成为window对象的属性。

bind()方法通过将函数绑定到您选择的对象来解决这个问题。在示例中,bind()方法被调用在嵌套函数上,并给出对父对象的引用。现在,当nestedGreeting()内部代码使用this时,它指向您设置的父对象。

bind()方法特别适用于setTimeout()setInterval()计时器函数。通常情况下,当这些函数触发您的回调时,this引用会丢失(变为undefined)。但是通过bind(),您可以确保回调函数保持您想要的引用。

示例 6-1 是一个网页,使用setTimeout()执行从 10 到 0 的倒计时操作。随着数字的倒数,它们被插入到网页中。此示例还使用构造函数模式进行对象创建(如“使用构造函数模式创建自定义类”中描述的)来创建类似类的Counter函数。

示例 6-1. 展示bind()的实用性
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Using Bind with Timers</title>
  </head>
  <body>
    <div id="counterDiv"></div>

    <script>
    // This is the constructor function for the Counter object.
    function Counter(from, to, divElement) {
      this.currentCount = from;
      this.finishCount = to;
      this.element = divElement;

      // The incrementCounter() method updates the page
      this.incrementCounter = function() {
        this.currentCount -= 1;
        this.element.textContent = this.currentCount;

        if (this.currentCount > this.finishCount) {
          // Schedule this function to run again after 1 second.
          setTimeout(this.incrementCounter.bind(this), 1000);
        }
      };

      this.startCounter = function() {
        this.incrementCounter();
      }
    }

    // Create the counter for this page.
    const counter = new Counter(10, 0, document.getElementById('counterDiv'));

    // When the page loads, start the counter.
    window.onload = function() {
      counter.startCounter();
    }
    </script>
  </body>
</html>

如果代码示例中的setTimeout()函数是以下内容:

setTimeout(this.incrementCounter, 1000);

它会丢失this,回调函数将无法访问像currentCount这样的变量,即使incrementCounter()方法是同一对象的一部分。

额外:self = this

使用bind()的一个较旧的替代方法,仍在使用中,是将this分配给外部函数中的一个变量,然后内部函数可以访问该变量。通常this被分配给一个名为thatself的变量:

window.onload = function() {
  window.name = 'window';

  const newObject = {
    name: 'object',

    sayGreeting: function() {
      const self = this;
      alert('Now this is easy, ' + this.name);
      nestedGreeting = function(greeting) {
        alert(greeting + ' ' + self.name);
      };

      nestedGreeting('hello');
    }
  };

  newObject.sayGreeting('hello');
};

没有这个赋值,第二条消息将再次引用“window”,而不是“object”。

实现递归算法

问题

要实现一个调用自身以完成任务的函数,这种技术称为递归。递归在处理分层数据结构(例如节点树或嵌套数组)、某些类型的算法(排序)和一些数学计算(斐波那契数列)时非常有用。

解决方案

递归在数学和计算机科学领域都是一个众所周知的概念。在数学中,递归的一个例子是斐波那契数列。斐波那契数是前两个斐波那契数的和:

f(n)= f(n-1) + f(n-2),
  for n= 2,3,4,...,n and
  f(0) = 0 and f(1) = 1

数学递归的另一个例子是阶乘,通常用感叹号表示(4!)。阶乘是从 1 到给定数字n的所有整数的乘积。如果n为 4,则阶乘(4!)为:

4! = 4 x 3 x 2 x 1 = 24

这些递归可以使用一系列循环和条件在 JavaScript 中编码,但也可以使用功能性递归来编码。这里是一个找到斐波那契数列第 n 个数的递归函数:

function fibonacci(n) {
  return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}

这里有一个解决阶乘的例子:

function factorial(n) {
  return n <= 1 ? 1 : n * factorial(n - 1);
}

讨论

区分递归函数的特征是终止条件(也称为基本情况)。递归函数不能随意地持续调用自身,因为这会导致无限循环(直到堆栈空间耗尽并导致程序失败)。相反,递归函数检查一个条件,然后决定调用自身(进入递归的下一层)或返回一个值(回到调用函数的一层)。当顶层函数返回一个值时,这个值就成为最终结果,递归操作完成。

在斐波那契的例子中,测试n是否小于 2。如果是,则返回它;否则再次调用斐波那契函数,分别传入(n-1)和(n-2),并返回两者的和。

在阶乘的例子中,当首次调用函数时,传递的参数值与数字 1 进行比较。如果n小于或等于 1(在这个简单的实现中不支持负数),函数将终止并返回 1。然而,如果n大于 1,返回的是n乘以再次调用factorial()函数,这次传入的值是n-1。随着函数的每次迭代,n的值递减,直到达到终止条件。

当计算阶乘时,每个函数调用的中间值被推送到内存中的堆栈,并保留直到达到终止条件。然后,这些值从内存中弹出并返回,类似于以下状态:

return 1; // 0!
return 1; // 1!
return 1 * 2; // 2!
return 1 * 2 * 3; // 3!
return 1 * 2 * 3 * 4; // 4!

大多数递归函数可以用通过某种循环线性执行相同功能的代码来替换。循环可能性能更好,尽管差异通常微不足道。递归的优势在于递归函数可以非常简洁和简洪。它们是否更清晰是一个有争议的问题。(它们显然更短,这使得它们更容易消化,但它们的自我引用性质可能使得它们的逻辑一开始就更难理解,特别是对于之前没有使用过递归函数的程序员来说。)

如果一个递归函数一遍又一遍地调用自身,最终会耗尽调用栈。这种情况会导致出现类似“栈空间不足”、“递归过深”或“超出最大调用栈大小”等错误。确切的错误消息和一次允许的开放函数调用数量取决于 JavaScript 引擎的实现。然而,这些错误消息通常表明一个结构不正确的递归函数未能评估其终止条件并在无限循环中调用自身。

第七章:对象

JavaScript 中有两类广泛的类型。一方面是一小组原始类型,比如字符串和数字。另一方面是真正的对象,所有这些对象都源自 JavaScript 的Object

JavaScript 的内置对象很容易识别。它们有构造函数,通常会使用new关键字实例化它们。像数组、Date、错误对象、MapSet集合,以及RegExp正则表达式等基本组件都是对象。

JavaScript 对象在重要方面也与传统面向对象编程语言中的对象不同。例如,JavaScript 允许你创建基本Object类型的实例,并在运行时附加新属性和函数。事实上,你可以取一个活动对象——任何对象——并修改其成员,而无需遵守类定义。

在本章中,你将更仔细地查看 JavaScript 的Object类型的功能和怪癖。你将看到如何使用核心Object功能来检查、扩展和复制所有类型的对象。在下一章中,你将进一步学习如何规范自定义对象的最佳实践。

检查对象是否为某种类型

问题

你有一个神秘对象,想要确定它的类型。

解决方案

使用instanceof运算符:

const mysteryObject = new Date(2021, 2, 1);

if (mysteryObject instanceof Date) {
  // We end up here because mysteryObject is a Date
}

你可以使用非运算符(!)来测试一个对象是否不是某种类型的实例。但确保使用括号将!应用于整个instanceof条件:

if (!(mysteryObject instanceof Date)) {
  // You get here if mysteryObject isn't a Date
}

// Don't make this mistake!
if (!mysteryObject instanceof Date) {
  // This code never runs
}

instanceof运算符存在一个缺陷。它无法处理原始值,比如数字、字符串、布尔值、BigInt值、nullundefined。以下是问题的演示:

const testNumber = 42;
if (testNumber instanceof Number) {
  // This code never runs
}

const testString = 'Hello';
if (testString instanceof String) {
  // This code never runs
}

// The following two tests work because the primitives are wrapped in objects,
// but that's uncommon in modern JavaScript.
const numberObject = new Number(42);
if (numberObject instanceof Number) {
  // This code runs
}

const stringObject = new String('Hello');
if (stringObject instanceof String) {
  // This code runs
}

如果你要测试一个可能包含原始数据类型之一的变量,解决方案是使用typeof运算符。与instanceof不同,typeof会为你提供九个预定义的字符串值之一(如“检查是否存在、非空字符串”中所述)。如果得到一个object值,你可以使用instanceof运算符深入挖掘:

const mysteryPrimitive = 42;
const mysteryObject = new Date();

if (typeof mysteryPrimitive === 'number') {
  // This code runs
}

if (typeof mysteryObject === 'object') {
  // This code runs, because a Date is an object, not a primitive

  if (mysteryObject instanceof Date) {
    // This code also runs
  }
}

讨论

instanceof运算符通过检查对象的原型链来工作,这是在“额外:原型链”中解释的一个概念。根据对象的构造方式,原型链中可能有几种类型(类似于传统面向对象编程语言中对象从一系列类继承的方式)。例如,每个对象在其链的基础上都有Object原型,因此这总是成立:

if (mysteryObject instanceof Object) {
  // This is true, unless mysteryObject is a primitive type
}

记住,原始值不仅包括数字、字符串和布尔值。它们还包括专门的BigIntSymbol,以及特殊值nullundefined。如果你使用instanceof Object测试,所有这些值都会返回false

使用对象字面量来捆绑数据

问题

你想要将几个变量组合在一起,创建一个基本的数据包。

解决方案

使用对象字面量语法创建Object类型的新实例。您不使用new关键字,甚至不命名Object类型。相反,您只需编写一组{}括号,其中包含一个逗号分隔的属性列表。每个属性由属性名称后跟冒号,然后是属性值组成:

const employee = {
  employeeId: 402,
  firstName: 'Lisa',
  lastName: 'Stanecki',
  birthDate: new Date(1995, 8, 15)
};

console.log(employee.firstName);  // 'Lisa'

当然,您可以在创建对象后添加附加属性,就像处理任何 JavaScript 对象一样:

employee.role = 'Manager';

即使您已使用const声明对象,此技术也适用,因为对象字面量是引用类型,而不是值(与其他语言中的结构体不同)。添加属性会更改对象,但不会更改引用。 (另一方面,在此示例中,将employee变量分配给新对象是不允许的,因为该操作将更改引用。)

讨论

对象字面量语法为您提供了快速创建简单对象的最清晰、最紧凑的方式。但是,它只是显式创建新Object实例并分配属性的快捷方式,就像这样:

const employee = new Object();
employee.employeeId = 402;
employee.firstName = 'Lisa';
employee.lastName = 'Stanecki';
employee.birthDate = new Date(1995, 8, 15);

或者您可以使用键值语法:

const employee = new Object();
employee['employeeId'] = 402;
employee['firstName'] = 'Lisa';
employee['lastName'] = 'Stanecki';
employee['birthDate'] = new Date(1995, 8, 15);

对象字面量语法最好的特性之一是它处理嵌套对象的方式,例如本例中的birthPlace

const employee = {
  employeeId: 402,
  firstName: 'Lisa',
  lastName: 'Stanecki',
  birthPlace: {country: 'Canada', city: 'Toronto'}
};

console.log(employee.birthPlace.city);  // 'Toronto'

在 JavaScript 的眼中,对象字面量是基本Object类型的一个实例。这种简单性使得从任意的临时数据组合创建对象变得容易,但也有代价——您的对象没有有意义的身份

是的,您可以测试对象是否具有某个特定属性(“检查对象是否具有属性”),或者枚举其所有属性(“遍历对象的所有属性”)。但您不能使用instanceof来针对自定义对象类型进行测试。换句话说,没有可编程的合同,也没有简单的方法来验证您的对象是否符合预期。如果您需要使用在代码中传递的更持久的对象、建模复杂实体并包含自己的方法,则应考虑使用正式类(“创建可重用类”)。

注意

您可能会想到通过创建接受参数并构建相应对象的工厂函数来简化对象创建过程。虽然这种方法本质上没有问题,但有一个更强大和常规的替代方案。一旦您想要使用相同结构构建多个对象,请考虑使用类(“创建可重用类”)。

另请参阅

要查找对象字面量上的所有属性,请参见“遍历对象的所有属性”。要升级到正式类定义,请参见“创建可重用类”。

额外:计算属性名

正如您所知,您可以通过两种方式向任何 JavaScript 对象添加新属性。您可以使用点语法和属性名称:

employee.employeeId = 402;

或者键值语法:

employee['employeeId'] = 402;

这两种方法并不等价。当你使用键值语法时,属性名被存储为字符串,这意味着你有机会在运行时生成属性名。这被称为计算属性名,在某些可扩展性场景中非常重要。(例如,想象一下,如果你正在获取一些外部数据并使用它来创建匹配的对象。)

const dynamicProperty = 'nickname';
const dynamicPropertyValue = 'The Izz';

employee[dynamicProperty] = dynamicPropertyValue;
// Now employee.nickname = 'The Izz'

const i = 10;
employee['sequence' + i] = 1;
// Now employee.sequence10 = 1

计算属性名始终转换为字符串。它们支持普通变量名中不允许的字符,如空格。例如,这是可能的(尽管这是一个非常糟糕的主意):

const employee = {};
const today = new Date();

employee[today] = 42;

// This reveals that 42 is stored in a property that has a long string name like
// "Tue May 04 2021 08:18:16 GMT-0400 (Eastern Daylight Time)"
console.log(employee);

对象字面量语法还允许你创建计算属性。但由于它不使用具有字符串键名的格式,你需要用方括号括起每个计算属性名。这是它的样子:

const dynamicProperty = 'nickname';
const dynamicPropertyValue = 'The Izz';
const i = 10;

const employee = {
  employeeId: 402,
  firstName: 'Lisa',
  lastName: 'Stanecki',
  [dynamicProperty]: dynamicPropertyValue,
  ['sequence' + i]: 1
};
提示

如果你动态创建属性名,可能会遇到需要确保属性名唯一的情况。有各种自制的解决方法:检查属性并添加一个序列号直到得到唯一的内容,或者只使用 GUID(全局唯一标识符)。但 JavaScript 提供了一个内置解决方案,即Symbol类型,这是你最好的选择(参见“创建绝对唯一对象属性键”)。

检查对象是否有属性

问题

你想在运行时检查对象是否具有给定的属性。

解决方案

使用in运算符按名称查找属性:

const address = {
  country: 'Australia',
  city: 'Sydney',
  streetNum: '412',
  streetName: 'Worcestire Blvd'
};

if ('country' in address) {
  // This code runs, because there is an address.country property
}

if ('zipCode' in address) {
  // This code does not run, because there is no address.zipCode property
}

讨论

如果尝试读取一个不存在的属性,则会得到值undefined。你可以测试undefined,但这并不能完全保证该属性不存在。(技术上讲,可能存在一个属性并将其设置为undefined,这种情况下属性仍然存在,但你的测试会漏掉它。)查找属性的更好方法是使用in运算符。

in运算符搜索对象及其原型链。这意味着,如果你创建一个从另一个对象Animal派生的对象Dogin测试会在DogAnimal中定义了属性时返回true。或者,你可以使用hasOwnProperty()方法,它仅搜索当前对象,忽略继承的属性。

const address = {
  country: 'Australia',
  city: 'Sydney',
  streetNum: '412',
  streetName: 'Worcestire Blvd'
};

console.log(address.hasOwnProperty('country'));  // true
console.log(address.hasOwnProperty('zipCode'));  // false

关于使用继承的更多信息,请参见“从另一个类继承功能”。

另请参阅

“迭代对象的所有属性”展示了如何将对象的所有属性检索到一个数组中。“测试空对象”展示了如何测试对象是否为空。

迭代对象的所有属性

问题

你想检查对象中的所有属性。

解决方案

使用静态的Object.keys()方法获取包含对象属性名的数组。例如,这段代码:

const address = {
  country: 'Australia', city: 'Sydney', streetNum: '412',
  streetName: 'Worcestire Blvd'
};

const properties = Object.keys(address);

// Show every property and its value
for (const property of properties) {
  console.log(`Property: ${property}, Value: ${address[property]}`);
}

创建此控制台输出:

Property: country, Value: Australia
Property: city, Value: Sydney
Property: streetNum, Value: 412
Property: streetName, Value: Worcestire Blvd

这种技术——检查对象,找到其所有属性并显示它们——与当你向 console.log() 方法传递一个对象时的操作类似。

Discussion

使用 Object.keys() 时,你会检索所有属性名称(也称为)。但你仍需要查找对象中对应的值。你不能使用点语法来做到这一点(object.propertyName),因为属性是一个字符串。而是使用类似数组的索引器语法(object['propertyName'])。属性通常以定义的顺序出现,但 JavaScript 不保证顺序。

Object.keys() 方法通常用于计算对象的属性数(或长度):

const address = {
  country: 'Australia', city: 'Sydney', streetNum: '412',
  streetName: 'Worcestire Blvd'
};

properties = Object.keys(address);
console.log(`The address object has a length of ${properties.length}`);
// (In this example, the length is 4.)

Object.keys() 方法只是反映 JavaScript 对象的许多可能解决方案之一。但它是一个很好的默认起点,因为它忽略继承的属性和不可枚举的属性,这是大多数情况下所希望的行为。

另一个选择是使用 for...in 循环,如下所示:

for (const property in address) {
  console.log(`Property: ${property}, Value: ${address[property]}`);
}

for...in 循环沿原型链遍历以查找对象继承的属性。在此示例中,使用名为 address 的对象字面量没有区别。然而,如果经常需要反映对象,无意中使用 for...in 循环而应该使用 Object.keys() 可能会对 performance 产生不利影响。

Note

与你可能期望的相反,for...in 循环与 in 运算符的覆盖范围略有不同。in 运算符检查所有属性,包括不可枚举的属性、符号属性和继承属性。for...in 循环找到继承的属性,但忽略不可枚举的属性和符号属性。

JavaScript 还有其他更专门的函数,用于查找对象的不同子集属性。例如,getOwnPropertyNames() 函数忽略继承的属性,而 getOwnPropertyDescriptors() 函数则忽略继承的属性,但还会找到不可枚举的属性和符号属性,这些通常用于可扩展性(参见 “Creating Absolutely Unique Object Property Keys”)。Table 7-1 概述了这些不同的方法。要获取更详细的信息,请参阅 Mozilla 开发者网络有关 不同属性搜索函数 的完整信息。

表 7-1. 查找对象属性的不同方法

方法 返回 获取可枚举属性 获取不可枚举属性 获取符号属性 包括继承属性
Object.keys() 一个属性名称的数组
Object.values() 一个属性值的数组
Object.entries() 一个属性数组的数组,每个数组包含一个属性名称和相应的值
Object.getOwnPropertyNames() 属性名数组
Object.getOwnProperty​Sym⁠bols() 属性名数组
Object.getOwnProperty​De⁠scriptors() 属性描述符对象数组,类似于使用 defineProperty() 时的情况(“自定义属性定义方式”)
Reflect.ownKeys() 属性名数组
for...in 循环 每个属性名

参见

“检查对象是否具有属性” 解释了如何使用 in 运算符来检查单个属性。

测试空对象

问题

你想确定一个对象是否为空(没有属性)。

解决方案

使用 Object.keys() 获取属性数组,并检查其 length 是否为 0:

const blankObject = {};

if (Object.keys(blankObject).length === 0) {
  // This code runs because there's nothing in this object
}

const objectWithProperty = {price: 47.99};
if (Object.keys(objectWithProperty).length === 0) {
  // This code won't run, because objectWithProperty isn't empty
}

讨论

可以使用对象字面量语法创建空对象:

const blankObject = {};

或通过使用 new 创建 Object 的实例:

const blankObject = new Object();

空对象也可以通过其他不太常见的方法产生,例如使用 delete 运算符删除现有对象的属性:

const objectWithProperty = {price: 47.99};
delete objectWithProperty.price;

if (Object.keys(objectWithProperty).length === 0) {
  // This code runs, because objectWithProperty had its only property removed
}

因为对象是引用类型,你不能简单地比较一个空对象和另一个空对象。例如,这个测试无法识别你的未知对象是否为空:

const blankObject = {};
const unknownObject = {};

if (unknownObject === blankObject) {
  // We never get here
  // Even though unknownObject is empty, like blankObject, it holds a
  // different reference to a different memory location
}

许多 JavaScript 库,如 Underscore 和 Lodash,提供了用于检查对象是否为空的 isEmpty() 方法。但是,使用 Object.keys() 的测试同样简单。

合并两个对象的属性

问题

你已经创建了两个带有属性的简单对象,并且想要将它们的数据合并到单个对象中。

解决方案

使用扩展操作符 (...) 扩展两个对象,并将它们赋值给一个新对象:

const address = {
  country: 'Australia', city: 'Sydney', streetNum: '412',
  streetName: 'Worcestire Blvd'
};

const customer = {
  firstName: 'Lisa', lastName: 'Stanecki'
};

const customerWithAddress = {...customer, ...address};
console.log(customerWithAddress);
// The customerWithAddress now has all six properties

讨论

合并两个对象是一个简单的操作,但不是没有潜在问题。如果两个对象都有相同名称的属性,第二个对象(在前面的示例中是 address)的属性将悄悄地覆盖第一个对象的属性。以下是演示问题的修改版本的示例:

const address = {
  `country``:` `'Australia'`, city: 'Sydney', streetNum: '412',
  streetName: 'Worcestire Blvd'
};

const customer = {
  firstName: 'Lisa', lastName: 'Stanecki', `country``:` `'South Korea'`
};

const customerWithAddress = {...customer, ...address};
console.log(customerWithAddress.country);  // Shows 'Australia' 

在这个例子中,country 属性出现了两次。当两个对象合并时,首先扩展 customer 对象,然后是 address 对象。因此,address.country 属性将覆盖 customer.country 属性。

自定义属性定义方式

问题

你可以很容易地给对象添加一个新属性。但有时候你需要显式地定制你的属性,以便更好地控制它的使用方式。

解决方案

不要通过简单赋值来创建属性,而是使用 Object.defineProperty() 方法来定义它。例如,考虑以下对象:

const data = {};

让我们假设你想要添加以下两个属性,并具有以下特征:

type

初始值设置后不能更改,不能删除或修改,但可以枚举

id

设置初始值,但可以更改,不能删除或修改,并且不能枚举

使用以下 JavaScript:

const data = {};

Object.defineProperty(data, 'type', {
  value: 'primary',
  enumerable: true
});

// Attempt to change the read-only property
console.log(data.type); // primary
data.type = 'secondary';
console.log(data.type); // nope, still primary

Object.defineProperty(data, 'id', {
  value: 1,
  writable: true
});

// Change this modifiable property
console.log(data.id); // 1
data.id = 300;
console.log(data.id); // 300

// See what properties appear during enumeration
for (prop in data) {
  console.log(prop); // only type displays
}

在本例中,尝试更改只读属性会静默失败。更常见的情况是,您将处于严格模式,要么因为您的代码在一个模块中(参见 “使用 ES6 模块组织您的 JavaScript 类”),要么因为您已经在 JavaScript 文件的顶部添加了 'use strict'; 指令。在严格模式下,试图设置只读属性会中断您的代码,并显示 TypeError

讨论

defineProperty() 是一种在对象上添加属性的方式,而不是直接赋值,它使您可以控制属性的行为和状态。即使您使用 defineProperty() 只是设置属性名称和值,它也不同于简单地设置属性。这是因为使用 defineProperty() 创建的属性默认情况下是只读且不可枚举的。

defineProperty() 方法接受三个参数:您要设置属性的对象、属性的名称以及配置属性的描述符对象。这里事情变得更有趣。实际上,您可以使用两种类型的描述符。解决方案中的示例使用了一个 数据描述符,它有四个可以设置的细节:

configurable

控制属性描述符是否可以更改。默认为 false

enumerable

控制属性是否可枚举。默认为 false

value

设置属性的初始值。

writable

控制属性值是否可以更改。默认为 false

而不是使用数据描述符,您可以使用一个 访问器描述符,它支持一组略有不同的选项:

configurable

与数据描述符相同

enumerable

与数据描述符相同

get

设置一个作为属性 getter 使用的函数,它返回属性值

set

设置一个作为属性 setter 的函数,它应用属性值

这是一个使用带有访问器描述符的 defineProperty() 的示例:

const person = {
  firstName: 'Joe',
  lastName: 'Khan',
  dateOfBirth: new Date(1996, 6, 12)
};

Object.defineProperty(person, 'age', {
  configurable: true,
  enumerable: true,
  get: function() {
    // Calculate the difference in years
    const today = new Date();
    let age = today.getFullYear() - this.dateOfBirth.getFullYear();

    // Adjust if the bithday hasn't happened yet this year
    const monthDiff = today.getMonth() - this.dateOfBirth.getMonth();
    if (monthDiff < 0 ||
       (monthDiff === 0 && today.getDate() < this.dateOfBirth.getDate())) {
      age -= 1;
    }

    return age;
  }
});

console.log(person.age);

这里的 defineProperty() 创建了一个计算属性 (age),它使用另一个属性 (birthdate) 进行计算。(你会注意到在 setter 或 getter 中可以使用 this 引用其他实例属性。)此时,对象的设计变得有点过于雄心勃勃,不适合使用对象字面量语法进行即兴创建。最好使用形式化类,它有更自然的方式来暴露相同的属性 getter 和 setter 特性(“向类添加属性”)。

可以使用 defineProperty()更改现有属性,而不是添加新属性。实际上,语法完全相同——唯一的区别在于您指定的属性名称已存在于对象中。但是,有一个限制条件。如果将属性设置为不可配置,则在调用 defineProperty() 时将会得到 TypeError

另请参阅

“向类添加属性” 解释了如何在类上设置属性,部分重叠了 defineProperty() 方法的方法。 “防止对象的任何更改” 涵盖了冻结对象以防止属性更改。

防止对象的任何更改

问题

您已经定义了对象,并且现在希望确保其属性不会被其他代码重新定义或编辑。

解决方案

使用 Object.freeze() 冻结对象,防止任何和所有更改:

const customer = {
  firstName: 'Josephine',
  lastName: 'Stanecki'
};

// freeze the object
Object.freeze(customer);

// This statement throws an error in strict mode
customer.firstName = 'Joe';

// So does an attempt to add a property
customer.middleInitial = 'P';

// Or remove one
delete customer.lastName;

当您尝试更改冻结对象时,会发生两种情况之一。如果打开了严格模式,则会抛出 TypeError 异常。如果未打开严格模式,则操作会静默失败——对象不会更改,但您的代码会继续执行。模块中始终打开严格模式(请参阅 “使用 ES6 模块组织您的 JavaScript 类”),或者在 JavaScript 文件顶部添加 'use strict'; 指令。

讨论

正如您所知,对象是引用类型,JavaScript 允许您以任何方式更改它们。您可以更改属性值,添加或删除属性,即使已使用 const 声明了对象变量。

然而,JavaScript 还包含了一些静态方法在 Object 类中,您可以使用它们来锁定对象。您有三个选择,从最不限制到最严格依次列出如下:

Object.preventExtensions()

防止您添加新属性。但是,您仍然可以设置属性值。您还可以使用 Object.getOwnPropertyDescriptor() 删除属性和配置属性。

Object.seal()

阻止添加、删除或配置属性。但是,您仍然可以设置属性值。有时用于捕捉对不存在属性的赋值,这是一种静默的错误。

Object.freeze()

禁止任何方式的属性修改。您不能配置属性,添加新属性或设置属性值。对象变得不可变。

如果您使用严格模式(除了在控制台编写测试代码时),尝试更改冻结对象会抛出 TypeError 异常。如果没有使用严格模式,尝试更改属性会静默失败,保留原始属性值但允许代码继续执行。

您可以使用 Object.isFrozen() 检查对象是否已冻结,这是一个伴随方法:

if (Object.isFrozen(obj)) ...

拦截并更改对象上的操作

问题

您希望在对象发生某些操作时运行代码,但不希望将代码放在对象内部。

解决方案

Proxy 类允许你拦截任何对象上的各种不同操作。下面的示例使用代理在名为 product 的对象上执行验证。代理确保代码可以使用不存在的属性,或者使用非数值数据类型来设置数字:

// This is the object that we'll watch with the proxy
const product = {name: 'banana'};

// This is the handler that the proxy uses to intercept traps
const propertyChecker = {
  set: function(target, property, value) {
    if (property === 'price') {
      if (typeof value !== 'number') {
        throw new TypeError('price is not a number');
      }
      else if (value <= 0) {
        throw new RangeError('price must be greater than zero');
      }
    }
    else if (property !== 'name') {
      throw new ReferenceError(`property '${property}' not valid`);
    }
    target[property] = value;
  }
};

// Create the proxy
const proxy = new Proxy(product, propertyChecker);

// Now, modify the product object through the proxy object
proxy.name = 'apple';

// This throws a ReferenceError
proxy.type = 'red delicious';

// This throws a TypeError
proxy.price = 'three dollars';

// This throws a RangeError
proxy.price = -1.00;

// This bypasses the proxy and succeeds
product.price = -1.00;
提示

一旦你创建了一个对单个属性有效的代理,你可以重用它来拦截其他属性或其他对象上的操作。

讨论

Proxy 对象包装一个对象,并可用于拦截特定操作,然后根据操作和对象在操作时的数据提供额外或替代行为。

当你创建一个Proxy时,需要提供两个参数:你想监视的对象和能拦截你选择的操作的处理程序。在这里展示的解决方案中,处理程序仅拦截属性设置操作。每次拦截属性设置动作时,它都会接收目标对象、正在设置的属性以及新的属性值。然后函数会检查正在设置的属性是否是price。如果是,则检查它是否是一个数字。如果不是,则抛出TypeError。如果是数字,则检查其值是否大于零。如果不是,则抛出RangeError。最后,处理程序检查属性是否是name。如果不是,则抛出最终的异常ReferenceError。如果没有触发任何错误条件,则像往常一样分配属性值。

Proxy 对象支持大量的陷阱,它们在表 7-2 中列出。该表列出了每个陷阱,随后是处理程序函数预期的参数、预期的返回值以及它是如何触发的。

表 7-2. 代理陷阱

代理陷阱 函数参数 预期返回值 触发陷阱的方式
getOwnProperty​Descrip⁠tor 目标, 名称 描述符或未定义 Object.getOwnPropertyDescriptor(proxy,name)
getOwnPropertyNames 目标 字符串 Object.getOwnPropertyNames(proxy)
getPrototypeOf 目标 任意类型 Object.getPrototypeOf(proxy)
defineProperty 目标, 名称, 描述符 布尔值 Object.defineProperty(proxy,name,desc)
deleteProperty 目标, 名称 布尔值 Object.deleteProperty(proxy,name)
freeze 目标 布尔值 Object.freeze(target)
seal 目标 布尔值 Object.seal(target)
preventExtensions 目标 布尔值 Object.preventExtensions(proxy)
isFrozen 目标 布尔值 Object.isFrozen(proxy)
isSealed 目标 布尔值 Object.isSealed(proxy)
isExtensible 目标 布尔值 Object.isExtensible(proxy)
has 目标, 名称 布尔值 名称 in proxy
hasOwn 目标, 名称 布尔值 ({}).hasOwnProperty.call(proxy,name)
get 目标, 名称, 接收者 任意类型 receiver[name]
set 目标, 名称, 值, 接收者 布尔值 receiver[name] = val
enumerator 目标 迭代器 for (name in proxy)(迭代器应产生所有可枚举的自有和继承属性)
keys 目标 字符串 Object.keys(proxy)(仅返回可枚举的自有属性的数组)
apply 目标,thisArg,args 任何 proxy(...args)
construct 目标,args 任何 new proxy(...args)

代理还可以包装内置对象,例如 ArrayDate 对象。在以下代码中,代理用于重新定义访问数组时发生的操作语义。当进行 get 操作时,处理程序检查给定索引处数组的值。如果它是零(0),则返回 false 的值;否则返回 true 的值:

const handler = {
    get: function(array, index) {
      if (array[index] === 0) {
        return false;
      }
      else {
        return true;
      }
    }
};

const numbers = [1,0,6,1,1,0];
const proxy = new Proxy(numbers, handler);

console.log(proxy[2]);  // true
console.log(proxy[0]);  // true
console.log(proxy[1]);  // false

在索引为 2 的数组值不为零时返回 true。对于索引为零的值也是如此。然而,索引为 1 的值为零,因此返回 false。无论何时访问此数组代理,这种行为都是成立的。

克隆对象

问题

您想创建一个自定义对象的精确副本。

解决方案

使用展开操作符 (...) 将对象展开为一组属性,并将该属性列表放入大括号 {} 中以构建新对象:

const animal = {
  name: 'Red Fox', class: 'Mammalia', order: 'Carnivora',
  family: 'Canidae', genus: 'Vulpes', species: 'Vulpes vulpes'
};

const animalCopy = {...animal};
console.log(animalCopy.species);  // 'Vulpes vulpes'

讨论

您可能期望此语句将复制一个对象:

const animalCopy = animal;

这适用于原始类型,如字符串、数字和 BigInt。但对象是引用类型,赋值对象会复制引用。最终您会得到两个变量(animalanimalCopy)指向同一个内存对象。

要正确复制自定义对象,您需要创建一个新对象,然后迭代旧对象,复制其每个属性。您可以使用 in 操作符(“迭代对象的所有属性”)来进行冗长的方式,但展开操作符提供了更好的方法,因为您可以将工作压缩为一行干净的代码。

当您使用展开操作符时,您会获得对象的所有 可枚举 属性。这包括使用对象字面量语法创建的所有属性,或者您事后分配的任何新属性。然而,您可以使用 Object.defineProperty() 方法具体选择创建不可枚举属性(如 “自定义属性定义方式” 所介绍)。通常,不可枚举属性是额外的一些数据,例如另一项服务作为某种可扩展性系统的一部分添加的数据片段。

注意

通常,您不希望复制不可枚举属性,因此展开操作符忽略它们是有道理的。然而,也有其他方法可行。JavaScript 对象具有特殊的内置功能,如 Object.getOwnPropertyDescriptors() 方法,可让您找到不可枚举属性。“迭代对象的所有属性” 更详细地解释了属性枚举。

你可能还会看到一种稍旧的克隆方法,使用Object.assign()方法。这相当于使用展开运算符:

const animalCopy = Object.assign({}, animal);

无论哪种方式,这些操作执行的都是浅拷贝。如果你的对象包含数组或其他对象作为属性,则这些细节不会被复制。相反,它们将在原始对象和新对象之间共享。以下是该问题的演示:

const student = {
  firstName: 'Tazie', lastName: 'Yang',
  testScores: [78, 88, 94, 91, 88, 96]
};

const studentCopy = {...student};

// Now there are two objects sharing the same testScores array
// We can see this if we change some details.
// This affects just the copy:
studentCopy.firstName = 'Dori';
// This affects both objects:
studentCopy.testScores[0] = 56;

console.log(student);
// {firstName: "Tazie", lastName: "Yang", testScores: [56, 88, 94, 91, 88, 96]
console.log(studentCopy);
// {firstName: "Dori", lastName: "Yang", testScores: [56, 88, 94, 91, 88, 96]

这不一定是问题,这取决于你想要实现的目标。但是,如果你想要复制多层深度,你需要考虑一种可以创建深拷贝的不同克隆方法(“深拷贝对象”)。

参见

“深拷贝对象”展示了如何采取相同的基本数据结构(一个包含数组的学生对象)并创建其深拷贝。

深拷贝对象

问题

你想创建一个自定义对象的精确副本。你不仅想复制顶层对象,还想复制它引用的每个对象。

解决方案

没有单一的解决方案来深拷贝一个对象。相反,开发者使用各种技术,每种技术都有其自己的权衡。

最安全的方法是编写针对要克隆对象类型的特定克隆逻辑。以下是一个示例,演示了如何对“克隆对象”中介绍的student对象进行深拷贝。

const student = {
  firstName: 'Tazie', lastName: 'Yang',
  testScores: [78, 88, 94, 91, 88, 96]
};

function cloneStudent(student) {
  // Start with a shallow copy
  const studentCopy = {...student};

  // Now duplicate the array (by expanding it with spread)
  studentCopy.testScores = [...studentCopy.testScores];

  return studentCopy;
}

// Create a truly independent student copy
const studentCopy = cloneStudent(student);

// Verify the arrays are separate
studentCopy.testScores[0] = 56;

console.log(student.testScores[0]);      // 78
console.log(studentCopy.testScores[0]);  // 56

这种方法的美妙之处在于你了解对象,因此你知道应该深入到多深程度。在这个例子中,我们知道testScores数组保存的是数字。因此,你知道简单地使用展开运算符进行克隆就足够了。但是,如果数组包含的是对象,你需要决定是否要复制所有这些对象,这是在“克隆数组”中演示的技术。或者,如果testScores是其他类型的集合对象(如SetMap),你可以适当地创建并填充一个相应类型的新集合。

如果你想要一个通用解决方案,可以深拷贝任意对象,远远最好的选择是使用一个来自著名 JavaScript 库的预构建、经过测试的例程,比如 Lodash 的cloneDeep(),可以通过lodash.clonedeep模块单独导入。

讨论

有关 JavaScript 未来版本中内置序列化和深拷贝支持的讨论。但目前,深克隆是一个你需要自行解决的空白。

如果你正在创建一个成熟的类(“创建可重用类”),考虑将你的自定义克隆函数作为类本身的一个方法:

class Student {
  constructor(firstName, lastName, testScores) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.testScores = testScores;
  }

  clone() {
    return new Student(this.firstName, this.lastName,
     [...this.testScores]);
  }
}

const student = new Student('Tazie', 'Yang', [78, 88, 94, 91, 88, 96]);
const studentCopy = student.clone();

// Verif the arrays are separate
studentCopy.testScores[0] = 56;

console.log(student.testScores[0]);      // 78
console.log(studentCopy.testScores[0]);  // 56

此示例未使用扩展运算符。相反,它使用构造函数创建一个新的Student对象。如果使用扩展运算符,你的副本将是基本Object类的实例,而不是Student类的实例。你的副本仍将具有与原始对象相同的属性,但如果使用instanceof测试,它将不会显示为Student。它还将无法使用你添加到Student类的任何方法。为了避免这些问题,你应始终为你的副本创建正确的对象类型。

如果你想知道是否可能创建自己的通用对象复制例程,你可能会感到疑惑。这些问题比它们看起来更加困难,而且有许多在网络上推荐的反模式很可能会引起严重的头痛。

对于自引用对象链,使用递归逻辑的天真方法会导致灾难性失败(堆栈溢出)。一个简单的例子是,当一个对象引用另一个引用原始对象的对象时。然而,更微妙的版本却出奇地常见。

该问题的另一个变体是,如果一个对象有两个对同一对象的引用。例如,考虑一个ProductCatalog,其中包含一些对同一个Supplier对象的引用的Product对象数组。天真的方法将为每个Product创建多个Supplier副本。更复杂的实现方式,如 Lodash 的cloneDeep(),在进行时跟踪引用,以确保不会多次重建同一对象。(其克隆实现的源码对于考虑重复发明轮子的人来说是一个有用的解药。)

另一个常见推荐的克隆方法是使用 JSON 序列化将对象转换为字符串表示形式,然后再转换回来。这会在处理Date对象(变为字符串)、Infinity等特殊值以及包含函数的自定义对象(被丢弃)时遇到问题。最糟糕的是,你不会收到有关缺失信息的警告。

注意

如果你想测试两个对象是否相等,相同的考虑因素也会发挥作用。===运算符只会告诉你两个变量是否指向同一个对象。如果你有包含相同数据的单独对象,它会返回false。你可以编写一个通用例程来查找和比较任何两个对象的所有属性。然而,相等的含义取决于你比较的数据类型,因此编写自己的isEqual()函数始终是最安全的方法。

创建绝对唯一的对象属性键

问题

想要给对象添加一个唯一命名的属性,并且保证它不会与任何其他属性名称冲突。

解决方案

使用Symbol类型创建一个新的属性名称。然后,使用键值语法设置该属性:

const newObj = {};

// Set a unique property that will never clash with anything else
const uniqueId = Symbol();
newObj[uniqueId] = 'No two alike';

// Set another one
const anotherUniqueId = Symbol();
newObj[anotherUniqueId] = 'This will not clash, either';

console.log(newObj);

有趣的是,您实际上从未看到Symbol类型使用的唯一标识符。在这个例子中,这是您在控制台中将得到的输出:

{Symbol(): 'No two alike', Symbol(): 'This will not clash, either'}

要访问使用Symbol创建的属性,您需要跟踪具有属性名称的变量。您可以随时使用它来检索您的值:

console.log(newObj[uniqueID]);  // 'No two alike'

讨论

属性名称冲突并不常见,但在 JavaScript 中比许多其他语言更常见。问题的一部分是属性始终是公共的。这意味着,如果您从另一个类继承(参见“从另一个类继承功能”),您需要注意每个继承的属性,并确保自己不使用相同的名称。但命名冲突的最常见原因是,如果您正在创建某种可扩展性系统或服务,该系统需要您向其他人的对象添加属性。在这种情况下,您不会知道您的属性是否会与已存在于该对象中的属性冲突,因为您不拥有该对象的设计。

您可以使用各种解决方法来检查属性并生成随机名称。但是,Symbol类型为您提供了一种快速有效的解决方案。每个Symbol都保证是唯一的。您通过调用Symbol()方法来创建它(因为Symbol是一个原始类型,而不是对象,所以不使用new调用构造函数)。

可选地,您可以为您的符号添加描述,这对调试很有用:

newObj = {};
const propertyName = Symbol('Log Status');
newObj[propertyName] = 'logged';

但是,描述并不用于创建Symbol。如果您使用相同的描述创建两个Symbol实例,将会有两个完全独立的唯一标识符,这些标识符由 JavaScript 在全局Symbol值注册表中内部存储。

使用 Symbol 创建枚举

问题

您希望存储一小组相关的常量,以便在代码中按名称引用它们。

解决方案

使用Symbol()为每个常量设置值:

// Create three constants to use as an enum
const TrafficLight = {
  Green: Symbol('green'),
  Red: Symbol('red'),
  Yellow: Symbol('yellow')
}

// This function uses the light enum
function switchLight(newLight) {
  if (newLight === TrafficLight.Green) {
    console.log('Turning light green');
  }
  else if (newLight === TrafficLight.Yellow) {
    console.log('Get ready to stop');
  }
  else {
    console.log('Turning light red');
  }
  return newLight;
}

let light = TrafficLight.Green;
light = switchLight(TrafficLight.Yellow);
light = switchLight(TrafficLight.Red);

console.log(light);   // shows "Symbol('red')"

讨论

枚举(或枚举标识符)是一组命名常量。枚举在任何时候都很有用,当您有一个只能取一小组允许值的变量时。通过使用枚举值,您使您的代码更清晰。您还减少了出错的机会(与使用魔术数字相比),因为您不会忘记每个数字的含义,并且您不能意外使用没有为其定义常量的数字。

注意

有关常量大写的适当惯例存在一些争论。例如,Math类将只读属性如Math.PIMath.E放在大写字母中。此示例中的解决方案使用了首字母大写的枚举常量和包装它们的对象,例如TrafficLight.Red

常量通常使用数字值或字符串值创建。如果常量映射到其他有用的信息,例如这里显示的单位转换值,那么这是一个特别好的方法:

const Units = {
  Meters: 100,
  Centimeters: 1,
  Kilometers: 100000,
  Yards: 91.44,
  Feet: 30.48,
  Miles: 160934,
  Furlongs: 20116.8,
  Elephants: 625,
  Boeing747s: 7100
};

如果您没有自然的唯一值可用于枚举常量,请考虑使用Symbol。这可以避免您需要选择任意数字,并且每个Symbol的保证唯一性确保您无法替换任何其他值。(这还消除了当您进行更改时可能导致错误的一致性问题,比如有些地方使用硬编码的数字,其他地方使用const变量。)本示例中的TrafficLight示例使用了三个值的Symbol

使用Symbol的缺点在于其底层值完全不透明。这就是为什么本示例中的解决方案为每个 Symbol 提供一个描述性名称,例如Symbol('red')。当您将Symbol记录到控制台或将其转换为字符串时,您将看到这个文本。如果在创建Symbol时没有提供描述性名称,您只会看到通用文本"Symbol()"

参见

要查看Symbol数据类型的详细信息,请参见“创建绝对唯一的对象属性键”。

第八章:类

JavaScript 是一种面向对象编程语言吗?答案取决于你问的是谁(以及你如何提问)。但一般的共识是,尽管有些限制条件。

在学术界之外,面向对象编程语言通常围绕类、接口和继承等概念展开。但直到最近,JavaScript 一直是一个特例——一个建立在函数和原型上的面向对象编程语言。然后,ES6 出现了,突然之间类作为一种本地语言结构就出现了,使情况变得复杂起来。它只是语法糖还是一次重大的语言演进呢?

答案介于两者之间。总体而言,ES6 类是建立在 JavaScript 原型的熟悉基础上的一种更高级的语言特性。但映射并非完全一致,类模型引入了一些新的细微差别,这些差别在原型模型中并没有完全体现出来。此外,很可能未来的类将支持新的面向对象特性,进一步拉开这两种重叠模型的差距。

底线是:如今,新的开发倾向于使用类,但基于原型的代码仍然普遍存在(远非过时)。本章重点介绍使用类的常见模式,同时也探讨了原型。

创建可重复使用的类

问题

你希望为自定义对象创建一个可重复使用的模板。

解决方案

使用class关键字,并为你的类取一个名字。在内部,添加一个构造函数来初始化你的对象。以下是一个完整的Person类示例:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

// Test the Person class by creating an object
// The constructor is invoked when you use the new keyword with the class
const newPerson = new Person('Luke', 'Takei');
console.log(newPerson.firstName);  // 'Luke'

在本例中,Person类是一个简单的包装,将两个公共字段(firstNamelastName)捆绑在一起。但是,很容易向你的类添加方法,这些方法像函数一样工作,但不包括function关键字。以下是如何编写Person.swapNames()方法的代码示例:

class Person {
  constructor(firstName, lastName, dateOfBirth) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.dateOfBirth = dateOfBirth;
  }

  // This is a method
  swapNames() {
    // Use a handy shortcut (destructuring assignment) to assign both
    // properties at once
    [this.firstName, this.lastName] = [this.lastName, this.firstName];
  }
}

// Test the Person class
const newPerson = new Person('Luke', 'Takei', new Date(1990, 5, 22));
newPerson.swapNames();
console.log(newPerson.firstName);   // 'Takei'

讨论

JavaScript 类的本质是构造函数。事实上,在幕后,JavaScript 类一个构造函数,并且所有方法都附加到该函数的原型上。这意味着像Person.swapNames()这样的方法在Person类的所有实例之间是共享的,因为它们共享同一个原型。(要深入了解这个幕后实现,请查看“使用构造函数模式制作自定义类”中的构造函数模式。)

类有其自己的语法要求,你必须遵循这些要求:

  • 构造函数总是命名为constructor

  • 无论构造函数还是方法,都不使用关键字function,尽管在其他方面声明方式与函数相同。

当你编写一个构造函数时,你使用this来在当前对象上创建新的公共字段。然后,在你的类方法中,无论何时需要,都可以引用这些字段,只要记得始终在变量名前加上this前缀。你还可以在类代码外部访问这些字段,使用熟悉的点语法。

您可能想知道如何更改此可访问性——例如,使字段私有并用公共属性包装它们。答案是目前您无法做到这一点——至少不会引入自己的自制解决方案而带来其它复杂性。有关完整讨论,请参阅“向类添加属性”。

与函数类似,JavaScript 允许您在表达式中创建类。以下是一个示例:

const personExpression = class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

// This won't work, because there is no Person class to be found in scope
const newPerson = new Person('Luke', 'Takei');

// This works because you can create a new instance of the variable that holds
// the class expression
const newPerson = new personExpression('Luke', 'Takei');

这是一种专业的——但并非罕见的——技术。它允许您避免将类添加到当前作用域中。例如,在这个例子中,如果您担心可能已经有另一个Person类的定义,这可能会很有用。(解决名称冲突问题的另一种方法是使用模块,如“使用 ES6 模块组织您的 JavaScript 类”所述。)

另请参阅

对于老式的构造函数模式用于对象创建,请参阅“使用构造函数模式创建自定义类”。要了解如何创建类属性,请参考“向类添加属性”。要学习如何在继承关系中连接类,请参阅“从另一个类继承功能”。

额外内容:多个构造函数

在大多数面向对象的语言中,可以创建多个构造函数,因此创建类的代码可以选择指定哪些参数。但 JavaScript 不支持构造函数重载或方法重载。

这并不像看起来的那样有限,因为 JavaScript 在处理函数参数时非常宽松,并且从不强制您提供它们。因此,即使Person有一个单独的三参数构造函数,以下都是创建实例的有效方法,而不必提供每个参数:

const noDatePerson = new Person('Luke', 'Takei');
const firstNamePerson = new Person('Luke');
const noDataPerson = new Person();

每个类都有且只有一个构造函数,并且它总是运行的。即使在创建Person对象时没有指定任何参数,三参数构造函数仍然会运行并设置this.firstNamethis.lastNamethis.birthDate(这些都将被设置为undefined)。如果这样不可接受,您可以设置默认参数值,就像在普通函数中一样(参见“提供默认参数值”)。

注意

如果您创建一个没有构造函数的类,JavaScript 会自动给它一个空的无参数构造函数。如果您决定使用类继承,这个细节将变得重要(参见“从另一个类继承功能”)。

处理可选参数的另一种方法是使用传递给构造函数的对象字面量。这样调用者可以选择设置他们想要使用的命名属性:

const partialInfoPerson1 = new Person({
  lastName: "Takei",
  birthDate: new Date(1990, 04, 23)
});
const partialInfoPerson2 = new Person({firstName: 'Luke', lastName: 'Takei'});

这是一种常见的 JavaScript 设计模式,详细描述在 “使用命名函数参数” 中。它提供的一个优点是你不需要担心对象字面量中属性的顺序。一个缺点是,没有任何东西可以防止你意外地创建错误命名的参数,这些参数将会被静默地忽略:

// The Person class will look for a firstName property in this object literal
// It will quietly ignore the firstname property
const partialInfoPerson2 = new Person({firstname: 'Luke'});

另一种可能的方法是为您的类创建一个单一的构造函数,但添加静态方法来创建不同配置的对象实例。根据实现的不同,这有时被称为 建造者模式工厂模式。它在 “使用静态方法创建对象” 中有描述。

添加类属性

问题

您希望添加属性的获取器和设置器以包装类数据。

解决方案

首先,考虑属性是否是您用例的最佳解决方案。(正如讨论中所解释的那样,它们有众所周知的局限性,并且稍微有争议。)如果决定使用属性,可以为每个属性创建 getset 方法。这里有一个计算属性 age 的示例,它是从存储在 this.dateOfBirth 中的日期计算出来的:

class Person {
  constructor(firstName, lastName, dateOfBirth) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.dateOfBirth = dateOfBirth;
  }

  // This is a getter for the age property
  get age() {
    if (this.dateOfBirth instanceof Date) {
      // Calculate the difference in years
      const today = new Date();
      let age = today.getFullYear() - this.dateOfBirth.getFullYear();

      // Adjust if the bithday hasn't happened yet this year
      const monthDiff = today.getMonth() - this.dateOfBirth.getMonth();
      if (monthDiff < 0 ||
         (monthDiff === 0 && today.getDate() < this.dateOfBirth.getDate())) {
        age -= 1;
      }

      return age;
    }
  }
}

// Test the Person class
const newPerson = new Person('Luke', 'Takei', new Date(1990, 5, 22));
console.log(newPerson.age);

由您决定是否仅包含获取器、仅包含设置器,或者两者都包含。这里有一个使用属性模式应用基本验证到出生日期的示例:

class Person {
  constructor(firstName, lastName, date) {
    this.firstName = firstName;
    this.lastName = lastName;

    // Set the date using the property setter so a Person
    // can't be created in an invalid state
    this.dateOfBirth = date;
  }

  // Just return the date with no extra processing
  get dateOfBirth() {
    return this._dateOfBirth;
  }

  // Don't allow dates in the future
  set dateOfBirth(value) {
    if (value instanceof Date && value < Date.now()) {
      // This is a valid date
      this._dateOfBirth = value;
    }
    else {
      throw new TypeError('Birthdate needs to be a valid date in the past');
    }
  }
}

// Test the date restrictions
const newPerson = new Person('Luke', 'Takei', new Date(1990, 5, 22));
console.log(newPerson.dateOfBirth);

// This change is allowed
newPerson.dateOfBirth = new Date(2010, 10, 10);
console.log(newPerson.dateOfBirth);

// This change causes an error
newPerson.dateOfBirth = new Date(2035, 10, 10);
注意

这个示例在试图设置无效值时抛出异常(“抛出标准错误”),以通知调用者。这是一个合理的设计决策,但在 JavaScript 中,当尝试设置属性(甚至更糟的是,尝试使用无效日期创建 Person 时)时发生错误不是预期的行为,并且调用代码可能未预料到可能的错误。(另一种选择——在设置属性时静默地忽略有问题的错误——也是有风险的。)最终,使用方法来提供潜在问题数据而不是属性可能是更好的方法。

讨论

有许多原因可能导致您考虑创建属性过程。一些例子包括:

  • 计算一个值(如 Person.age

  • 将一个字段转换为另一种表示形式

  • 在更新字段之前执行验证

  • 为某些其他服务(如日志记录或测试)添加钩子,这些服务应在每次读取或设置字段时发生

  • 使用某种惰性初始化,只有在需要时才创建或计算属性值

  • 公开存储在字段中的对象的单个属性

本篇介绍两个示例。Person.age 属性是只读的计算属性。Person.dateOfBirth 属性是带有验证的可设置属性。

使用属性时,必须小心避免名称冲突。存储值的字段不能与属性或构造函数参数具有相同的名称。为了理解原因,让我们更仔细地看看dateOfBirth的例子。构造函数接受一个date参数,并像这样设置它:

this.dateOfBirth = date;

乍一看,您可能会认为此语句将日期存储在名为this.dateOfBirth的公共字段中(这是通常的模式)。但在这种情况下,this.dateOfBirth指的是dateOfBirth属性。其 setter 接管了:

set dateOfBirth(value) {
if (value instanceof Date && value < Date.now()) {
  // This is a valid date
  this._dateOfBirth = value;
}
else {
  throw new TypeError('Birthdate needs to be a valid date in the past');
}

如果新值通过测试,它将存储在名为this._dateOfBirth的公共字段中。这种笨拙的命名是必要的,因为this.dateOfBirth(属性)和this._dateOfBirth(字段)具有相同的作用域。如果两者使用相同的名称,您将会调用错误的那个(并触发无限调用序列,最终导致堆栈溢出)。

类似_dateOfBirth这样的变量名中的前导下划线还有另一个目的。目前,JavaScript 没有任何创建私有字段的方法。但下划线表示该字段应该是类的私有字段。然后,您可以信任调用代码会避免使用这个字段。如果不遵循这种约定,几乎肯定会遇到问题,即调用代码意外使用字段而不是属性。即使遵循了这种模式,也不能保证调用代码会遵循它。

许多 JavaScript 开发人员认为,在 JavaScript 中更自然的模式是使用setXxx()getXxx()方法:

class Person {
  constructor(firstName, lastName, date) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.setDateOfBirth(date);
  }

  getDateOfBirth() {
    return this._dateOfBirth;
  }

  setDateOfBirth(value) {
    if (value instanceof Date && value < Date.now()) {
      // This is a valid date
      this._dateOfBirth = value;
    }
    else {
      throw new TypeError('Birthdate cannot be in the future');
    }
  }
}

const newPerson = new Person('Luke', 'Takei', new Date(1990, 5, 22));
console.log(newPerson.getDateOfBirth());

// This change is allowed
newPerson.setDateOfBirth (new Date(2010, 10, 10));
console.log(newPerson.getDateOfBirth());

// This change causes an error
newPerson.setDateOfBirth (new Date(2035, 10, 10));

这种方法有点繁琐,但有一些优点。它明确表明您正在调用方法并运行代码,而不仅仅是设置变量。因此,调用代码可以期望来自类型检查或其他副作用的异常。方法还可以防止这样的问题:

// This isn't the property you want (that's dateOfBirth) but JavaScript
//  creates it anyway, and you won't notice the mistake
person.DateOfBirth = new Date(2035, 10, 10);

// You can't call a function that doesn't exist, so this typo
// ("Data" instead of "Date") always fails and won't be ignored
person.setDataOfBirth(new Date(2035, 10, 10));
注意

Google JavaScript Style Guide和经常查阅的Airbnb JavaScript Style Guide都不鼓励使用属性的 getter 和 setter,但允许使用setXxx()getXxx()方法。

使用属性时,还有一个要考虑的细微之处。在 JavaScript 内部,使用Object.defineProperty()方法来实现您的属性 getter 和 setter。大多数情况下,这样做完全正常。但在特定情况下,您可能决定使用defineProperty(),因为它允许您配置无法以其他方式设置的元数据细节。例如,如果您想使属性不可配置(因此其实现不能被更改)或不可枚举(因此它不会在for...in循环中显示),您需要显式调用defineProperty()。在这种情况下,通常的做法是在构造函数中调用defineProperty

参见

如果你希望使用属性程序来响应属性变化并触发其他操作(比如日志记录),考虑使用代理而不是(“使用代理拦截和修改对象上的操作”)。有关使用 Object.defineProperty() 创建属性的更多信息,请参阅“自定义属性定义方式”。

额外:私有字段

目前,JavaScript 没有办法使成员变量(使用 this 创建的变量)私有化。许多解决方案都在使用,并且其中许多都是非常有创意且危险的。最流行的实现使用 WeakMap 来存储内部数据。它确实有效,但也增加了一层危险的自制复杂性。

更好的方法是使用下划线约定(如 _firstName)来命名那些不应在类外部访问的字段。未来,JavaScript 将弥补这一空缺并采纳某种版本的私有类字段提案。目前,私有字段语法使用 # 来标识私有字段,可以在类块的开头声明,使得你的类自说明性更强。具体如下所示:

// A likely implementation of private field syntax in the near future
class Person {
  #firstName;
  #lastName;

  constructor(firstName, lastName) {
    this.#firstName = firstName;
    this.#lastName = lastName;
  }

  // Wrap the fields in properties
	get firstName() {
    return this.#firstName;
  }
  set firstName(name) {
    this.#firstName = name;
  }

  get lastName() {
    return this.#lastName;
  }
  set lastName(name) {
    this.#lastName = name;
  }
}

如果你想要立即尝试这些功能,可以使用Babel来转译你的代码,尽管请注意语法可能会发生变化。

有趣的是,在这种情况下,JavaScript 类的功能比老式的构造函数模式更少(“使用构造函数模式创建自定义类”)。因为构造函数模式可以使用闭包来存储私有变量,如“使用闭包存储其状态的函数”中所解释的。

给类一个更好的字符串表示

问题

当将对象转换为字符串时,你想选择一个合适的文本表示方式。

解决方案

在你的类中添加一个名为 toString() 的方法,并返回你想要使用的字符串:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  toString() {
    return `${this.lastName}, ${this.firstName}`;
  }
}

const newPerson = new Person('Luke', 'Takei');
console.log(newPerson.toString());   // 'Takei, Luke'

讨论

所有对象的默认 toString() 实现显示不太有帮助的文本 [object Object]。你可以通过添加 toString() 方法来设置自己的文本。

toString() 方法可以显式调用(就像这个例子中一样),或者当你的对象转换为字符串时可以隐式调用。例如,如果你将你的对象与一个字符串连接,toString() 就会自动调用:

const newPerson = new Person('Luke', 'Takei');
const message = 'The name is ' + newPerson;

// Now message = 'The name is Takei, Luke'
// which is much better than 'The name is [object Object]'

但是,仅对对象调用 console.log() 并不会触发你的 toString()。这是因为 console.log() 有额外的逻辑,遍历对象的属性并用其构建自定义字符串。你可以通过手动调用 toString() 或使用模板字面量(“使用模板字面量进行更清晰的字符串拼接”)来解决这个问题。下面是一个比较:

const newPerson = new Person('Luke', 'Takei');

console.log(newPerson);       // 'Person {firstName: "Luke", lastName: "Takei"}'
console.log(`${newPerson}`);  // 'Takei, Luke'
console.log(newPerson+'');    // 'Takei, Luke'

使用构造函数模式创建自定义类

问题

您希望在您的代码中创建一个可重用的类似类的实体。您希望使用传统的构造函数模式,因为它与您现有的代码匹配。

解决方案

构造函数模式是一种稍微过时但仍然可接受的对象创建模式。即使您打算使用正式类(“创建可重用类”),了解构造函数模式也很重要,因为您可能会在野外遇到它。它还可以帮助您理解 JavaScript 类的工作原理。

下面是一个来自“使用 ES6 类”的Person类的示例,但按照构造函数模式编写:

function Person(firstName, lastName) {
  // Store public data using 'this'
  this.firstName = firstName;
  this.lastName = lastName;

  // Add a nested function to represent a method
  this.swapNames = function() {
    [this.firstName, this.lastName] = [this.lastName, this.firstName];
  }
}

// Create a Person object
const newPerson = new Person('Luke', 'Takei');
console.log(newPerson.firstName);  // 'Luke'

newPerson.swapNames();
console.log(newPerson.firstName);  // 'Takei'

注意,使用基于函数的对象的代码与使用具有相同构造函数的基于类的代码相同。因此,通常可以将代码从构造函数模式迁移到正式类而不会破坏应用程序的其他部分。

讨论

类是 JavaScript 语言的一个相对晚期的引入者。在类存在之前,开发人员使用函数来代替。这是因为 JavaScript 允许您使用new关键字创建函数的新实例(函数对象)。每个函数都有自己的作用域,具有自己的局部数据。

构造函数模式存在多种变体。最常见的方法是创建一个函数,函数名为您的“类”的名称,并接受创建实例所需的所有构造函数参数。在函数内部,使用this来创建公共字段。您还可以创建普通变量,这些变量对外部代码不可见,仅由构造函数和任何嵌套函数使用。

有两种常见的方法来创建类似方法的函数。这里展示的方法使用函数表达式创建每个方法,并使用this使它们公开可访问。因为方法函数被包裹在构造函数内部,它们具有与构造函数相同的作用域,并且可以访问所有相同的变量和局部变量。 (从技术上讲,构造函数创建了一个闭包,如在“创建一个使用闭包存储其状态的函数”中所解释的。)

创建方法的另一种方式是将它们显式添加到构造函数的原型中。如果您尚未遇到原型,它们是一种基本(但大多数情况下隐藏的)组成部分,允许对象共享功能。当您尝试调用方法(如Person.swapNames())时,JavaScript 会查找Person构造函数中的swapNames()函数。如果找不到它,JavaScript 会在原型中查找swapNames()函数。当涉及继承时,这个过程会变得更复杂,因为 JavaScript 会在整个原型链中搜索函数,如“从另一个类继承功能”中所解释的。

那么如何向原型添加函数呢?您可以直接使用prototype属性:

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

// Add function to the Person prototype to represent a method
Person.prototype.swapNames = function() {
  [this.firstName, this.lastName] = [this.lastName, this.firstName];
}

const newPerson = new Person('Luke', 'Takei');
newPerson.swapNames();
console.log(newPerson.firstName);  // 'Takei'

这个示例的行为与嵌套构造函数版本基本相同。但是有一个区别。以前,swapNames()在每个Person对象中独立存在。现在,在原型中设置了一个单独的swapNames()函数,并在所有Person实例之间共享。如果您计划创建将原型链接在一起的继承关系(参见“额外:原型链”),这一点很重要。如果您尝试使用闭包与私有变量(“使用闭包存储其状态的函数”)函数,因为附加到原型的函数不存在于构造函数的相同上下文中,并且无法访问在构造函数中定义的私有变量。

注意

使用原型,您可以改变内置 JavaScript 对象的行为。例如,您可以为基本的ArrayString类型添加功能。这听起来像一个很棒的功能,但它充满了复杂性,并且强烈不建议使用(除非用于构建框架)。模糊标准代码和自定义代码之间的区别会引起混淆,并且可能导致非标准模式、性能不佳的代码和隐藏的错误。如果有多个人尝试使用相同名称扩展内置对象,它也可能完全失败。

将构造函数模式与“创建可重用类”中显示的class关键字进行比较是很有趣的。在这两个示例中,大部分代码都是完全相同的:

  • 您编写一个接受参数并初始化对象的构造函数。

  • 您使用this关键字创建公开可访问的字段。

  • 创建对象时使用new关键字(现在技术上是一个函数的实例,而不是一个类)。

但是也有一些细微的差异,最明显的是语法。在构造函数模式中没有专用属性,并且方法是单独声明的,而不是嵌套在构造函数中或显式附加到构造函数的原型(尽管在运行时确实是这样)。

参见

“使用 es6 类”演示了在现代 JavaScript 中创建自定义对象模板的首选方法,即使用class关键字。

在您的类中支持方法链

问题

您希望以一种使得多个方法可以快速连续调用的方式定义类方法。

解决方案

确保在支持方法链的每个方法的末尾返回当前对象。在自定义类中,通常只需添加return this语句即可。

这是一个自定义的Book对象的示例,其中包含两个方法,raisePrice()releaseNewEdition(),两者都使用方法链:

class Book {
  constructor(title, author, price, publishedDate) {
    this.title = title;
    this.author = author;
    this.price = price;
    this.publishedDate = publishedDate;
  }

  raisePrice(percent) {
    const increase = this.price*percent;
    this.price += Math.round(increase)/100;
    return this;
  }

  releaseNewEdition() {
    // Set the pulishedDate to today
    this.publishedDate = new Date();
    return this;
  }
}

const book = new Book('I Love Mathematics', 'Adam Up', 15.99,
 new Date(2010, 2, 2));

// Raise the price 15% and then change the edition, using method chaining
console.log(book.raisePrice(15).releaseNewEdition());

讨论

直接在另一个方法的结果上调用一个方法,形成单一的代码语句,被称为 方法链。以下是一个使用字符串和 replaceAll() 方法的示例。因为 replaceAll() 返回一个新字符串,您可以在该字符串上再次调用 replaceAll(),并得到一个 第三个 字符串:

const safePieceOfHtml =
 originalPieceOfHtml.replaceAll('<', '&lt;').replaceAll('>', '&gt;');

方法链不一定要使用相同的方法。它可以与返回对象的任何方法一起工作。考虑以下代码如何通过将 concat()sort() 的调用链接来连接两个数组,然后对结果数组进行排序:

const evens = [2, 4, 6, 8];
const odds = [1, 3, 5, 7, 9];

const evensAndOdds = evens.concat(odds).sort();
console.log(evensAndOdds);  // [1, 2, 3, 4, 5, 6, 7, 8, 9]

在内置的 JavaScript 对象和许多 JavaScript 库和框架中广泛使用链式调用。要在自己的类中使用此模式,您只需在方法末尾返回对 this 的引用即可。调用代码可以忽略此引用,或者使用它执行方法链。

在当前示例中,调用 Book 上的方法会更改对象并返回对更改后对象的引用。调用者可以忽略返回值,因为他们已经有了对 Book 对象的引用。但是,许多函数式编程纯粹主义者会做一些不同的事情。他们编写返回更改后对象 副本 的方法,同时保持原始对象不变。以下是如何实现此模式的示例:

class Book {
  constructor(title, author, price, publishedDate) {
    this.title = title;
    this.author = author;
    this.price = price;
    this.publishedDate = publishedDate;
  }

  getRaisedPriceBook(percent) {
    const increase = this.price*percent;
    return new Book(this.title, this.author, Math.round(increase)/100,
     this.publishedDate);
  }

  getNewEdition() {
    return new Book(this.title, this.author, this.price, new Date());
  }
}

这种模式不影响方法链工作的方式,但确实意味着调用者需要接受返回值,否则他们将看不到变化。

向类添加静态方法

问题

您希望创建一个与类相关但可以在不创建对象的情况下调用的实用方法。

解决方案

在方法前加上 static 关键字。确保您的方法不尝试使用任何实例字段、属性或方法。这里有一个名为 Book.isEqual() 的静态方法示例:

class Book {
  constructor(isbn, title, author, publishedDate) {
    this.isbn = isbn;
    this.title = title;
    this.author = author;
    this.publishedDate = publishedDate;
  }

  static isEqual(book, otherBook) {
    if (book instanceof Book && otherBook instanceof Book) {
      // Books are deemed equal if their ISBNs match,
      // irrespective of dashes
      return (book.isbn.replaceAll('-','') === otherBook.isbn.replaceAll('-',''));
    }
    else {
      return false;
    }
  }
}

您通过类名访问静态方法(如 Book.isEqual())。不能通过对象变量访问它。

const firstPrinting = new Book('978-3-16-148410-0', 'A.I. Is Not a Threat',
 'Anne Droid', new Date(2019, 2, 2));
const secondPrinting = new Book('978-3-16-148410-0', 'A.I. Is Not a Threat',
 'A. Droid', new Date(2021, 2, 10));

// Compare the books with the static method
const sameBook = Book.isEqual(firstPrinting, secondPrinting);
// sameBook = true

// This doesn't work, because isEqual isn't available in Book instances
sameBook = firstPrinting.isEqual(firstPrinting, secondPrinting);

讨论

静态方法具有与类逻辑相关但不绑定于特定实例的功能。Array.isArray() 方法是一个很好的例子 —— 它允许您测试任何对象是否为数组,而无需首先创建数组对象。偶尔,类完全由静态方法组成。JavaScript 的 Math 类就是一个很好的例子。

在当前示例中,您可能希望为 Book 类提供与处理或验证 ISBN 相关的静态方法。您还可以使用静态方法来决定如何复制或比较某个类的对象。解决方案通过静态 isEqual() 方法演示了这一原则。您还可以添加一个 compare() 方法,使您能够对数组中的对象进行排序(如 “按属性值排序对象数组” 中所示)。

在静态方法中,this指的是当前类,而不是对象实例。这可能会导致问题,因为您的代码仍然允许在this中存储数据(或检索数据)。它只是可能没有您期望的效果。本质上,静态this中的所有内容都像一个类范围的全局变量,最好避免使用。

提示

如果你想要一个静态方法调用另一个静态方法,可以使用this关键字。例如,如果你想要在Book类的另一个静态方法中调用静态方法isEqual(),你可以使用Book.isEqual()this.isEqual(),这可能更清晰。

属性的setget方法也可以是静态的,尽管它们的使用有时是有争议的。例如,您可以使用静态 getter 来存储常量,就像这样:

class Book {
  constructor(isbn, title, author, publishedDate) {
    this.isbn = isbn;
    this.title = title;
    this.author = author;
    this.publishedDate = publishedDate;
  }

  // Create a static, read-only Books.isnbnPrefix property
  static get isbnPrefix() {
    return '978-1';
  }
}

您可以编写一个静态 setter,它在您的应用程序中类似于全局变量。然而,由于没有静态构造函数,您将被迫在某个地方运行代码以分配初始值。这并不特别清晰,因此正在开发一种new static property syntax,目前更现代的浏览器版本已经支持。它允许您使用类似变量的语法设置公共静态属性:

class Book {
  // Create a static Book.isbnPrefix property
  static isbnPrefix = '978-1';

  constructor(isbn, title, author, publishedDate) {
    this.isbn = isbn;
    this.title = title;
    this.author = author;
    this.publishedDate = publishedDate;
  }
}

然而,最好完全避免这种语言特性——或者至少在 JavaScript 中的使用更为规范的未来数据时再考虑使用。

使用静态方法创建对象

问题

您想要创建一个生成预配置对象的方法,可能是为了绕过 JavaScript 的单构造函数限制。

解决方案

在您的类中添加一个静态方法,创建并返回您想要的对象。以下是一个Book类的示例,您可以通过构造函数或通过静态方法Book.createSequel()创建它:

class Book {
  constructor(title, firstName, lastName) {
    this.title = title;
    this.firstName = firstName;
    this.lastName = lastName;
  }

  static createSequel(prevBook, title) {
    return new Book(title, prevBook.firstName, prevBook.lastName);
  }
}

以下是如何使用静态方法:

// Create a Book with the usual constructor
const book = new Book('Good Design', 'Polly', 'Morfissim');

// Create a sequel with the static method
const sequel = Book.createSequel(book, 'Even Gooder Design');
console.log(sequel);

讨论

使用静态方法,您可以实现不同类型的创建型模式——基本上是帮助您创建类的预配置实例的模式。例如,JavaScript 的Date类有一个now()属性,返回一个新的Date对象,自动设置为当前日期和时间。

此方法特别适用于创建更复杂的对象组合。例如,您可以通过Book.createTrilogy()方法扩展前面的示例,以获取一个包含三个Book对象的数组。在这个示例中,Book对象共享一个Author对象,这意味着如果您更新Author对象,所有链接到它的Book实例都会看到这个变化:

class Author {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

class Book {
  constructor(title, author) {
    this.title = title;
    this.author = author;
  }

  static createSequel(prevBook, title) {
    return new Book(title, prevBook.author);
  }

  static createTrilogy(author, title1, title2, title3) {
    return [new Book(title1, author),
      new Book(title2, author),
      new Book(title3, author)];
  }
}

// Create a trilogy of three books with a factory method
const author = new Author('Koh','Der');
const books = Book.createTrilogy(author, 'A Sea of Fire', 'A Sea of Ice',
 'A Sea of Water');
console.log(books);

与构造函数不同,您可以添加多少个静态方法来支持不同的对象创建场景。

注意

有时这些静态方法被称为工厂方法,尽管这个描述在技术上并不精确。在面向对象设计理论中,当你不知道正在创建的对象的确切类型时,会使用工厂模式。例如,你可以编写一个createBook()方法,检查你提供的参数并返回TechBook类或FictionBook类的实例,它们都继承自基类Book。在 JavaScript 中也可以实现这种设计,但人们对语言如何处理这种更重的经典面向对象抽象的看法不一。

从另一个类继承功能

问题

你想创建一个自定义类,继承另一个类的功能。

解决方案

使用继承,一个或多个类从类派生。在代码中建模这一点时,声明子类时使用extends关键字:

public class SomeChild extends SomeParent {

}

这里有一个例子,展示了一个从名为Shape的基础父类继承的Triangle类:

// This is the parent class
class Shape {
  getArea() {
    return null;
  }
}

// This is a child class
class Triangle extends Shape {
  constructor(base, height) {
    // Call the base class constructor
    super();

    this.base = base;
    this.height = height;
  }

  getArea() {
    return this.base * this.height/2;
  }
}

在这个例子中,父类(Shape)没有任何有用的功能。getArea()方法只是一个占位符。但在其他情况下,基类本身可能会有用。例如,你可以使用Book类的继承来创建EBook子类或Person类的Customer

在 JavaScript 这样的弱类型语言中,如果你只打算使用Triangle,似乎没有必要构建一个从Shape继承的Triangle。而且在这种情况下,通常确实如此!但是当你使用单个父类来标准化更多子类时,潜在的价值就显现出来:

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  getArea() {
    return Math.PI * this.radius**2;
  }
}

class Square extends Shape {
  constructor(length) {
    super();
    this.length = length;
  }

  getArea() {
    return this.length**2;
  }
}

现在可以这样编写代码:

// Create an array of different shapes
const shapes = [new Triangle(15, 8), new Circle(8), new Square(7)];

// Sort them by area from smallest to largest
shapes.sort( (a,b) => a.getArea()-b.getArea() );

console.log(shapes);
// New order: Square, Triangle, Circle

当然,JavaScript 是一种弱类型语言,即使它们没有共享定义该方法的父类,你也可以在TriangleCircleSquare对象上调用getArea()。但是使用继承来规范这种接口可以帮助明确这些要求。如果你需要使用instan⁠ce​of来测试对象是否是某种类型,这也很重要(“检查对象类型”):

const triangle = new Triangle(15, 8);

if (triangle instanceof Shape) {
  // We end up here, because triangle is a Triangle which is a Shape
}

讨论

如果你不为子类编写构造函数,JavaScript 会自动创建一个。该构造函数调用基类构造函数(但不提供参数)。

如果为子类编写了构造函数,必须调用父类构造函数。否则,在尝试创建实例时会收到ReferenceError。要调用父类构造函数,使用super()关键字:

constructor(length) {
  super();
}

如果父类构造函数接受参数,你应该像创建对象时那样将它们传递给super()。以下是一个扩展BookEBook类的示例:

class Book {
  constructor(title, author, publishedDate) {
    this.title = title;
    this.author = author;
    this.publishedDate = publishedDate;
  }
}

class EBook extends Book {
  constructor(title, author, publishedDate, format) {
    super(title, author, publishedDate);
    this.format = format;
  }
}

你还可以使用super()调用父类中的其他方法或属性。例如,如果一个子类想调用父类formatString()的实现,它会调用super.formatString()

类是 JavaScript 相对较晚引入的功能。虽然它们支持继承,但你在传统面向对象语言中可能习惯的许多其他工具,如抽象基类、虚方法和接口,在 JavaScript 中都没有对应的概念。一些开发者喜欢 JavaScript 轻量级的特性和原型的重视,而另一些则觉得缺少构建大型复杂应用程序的重要工具。如果你属于后者,最好考虑使用 TypeScript,这是 JavaScript 的一个更严格的超集。

但继承并非没有其缺点。它可能会促使你编写紧密耦合的类,这些类相互依赖并且难以适应未来的变更。更糟糕的是,往往很难识别这些依赖关系,开发者变得不愿意对父类进行更改(这种情况被称为脆弱基类问题)。正因为这些问题,现代开发往往更喜欢聚合对象组而不是使用继承关系。例如,不是建立一个扩展PersonEmployee类,而是创建一个包含Person属性及其它所需细节的Employee对象。这种模式称为组合

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

class Employee {
  constructor(person, department, hireDate) {
    // person is a full-fledged Person object
    this.person = person;

    // These properties hold the extra, nonperson information
    this.department = department;
    this.hireDate = hireDate;
  }
}

// Create an Employee object that's composed of a Person object
// and some extra details
const employee = new Employee(new Person('Mike', 'Scott'), 'Sales', new Date());

补充:原型链

你可能还记得 JavaScript 类功能创建对象的原型。这个原型包含了所有方法和属性的实现,并在该类的所有实例之间共享。原型也是实现继承的秘诀。当一个类扩展另一个类时,它们被链接在一个原型链中。

例如,考虑ShapeTriangle之间的关系。Triangle类有一个原型,其中保存了你为子类定义的任何内容。然而,该原型还有它自己的原型,即Shape类的原型,其中包含所有其成员。Shape原型也有它自己的原型:基础的Object.prototype,这样便形成了原型链。

继承可以无限层级地延伸,因此原型链可以变得非常长。当你调用像Triangle.getArea()这样的方法时,JavaScript 会搜索原型链。它首先在Triangle原型中查找方法,然后是Shape原型,最后是Object原型(如果找不到匹配的方法,则会报错)。

当然,JavaScript 类是相对较新的,而原型自从语言的第一个版本以来就存在。因此,即使不使用 JavaScript 类,你也可以创建类似继承的关系。有时这与老式的构造函数模式(“使用构造函数模式创建自定义类”)结合使用,结果是一些相当不优雅的代码。

// This will be the parent class
function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

// Add the methods you want to the Person class
Person.prototype.greet = function() {
  console.log('I am ' + this.firstName + ' ' + this.lastName);
}

// This will be the child class
function Employee(firstName, lastName, department) {
  // The Object.call() method allows you to chain constructor functions
  // It binds the Person constructor to this object's context
  Person.call(this, firstName, lastName);

  // Add extra details
  this.department = department;
}

// Link the Person prototype to the Employee function
// This establishes the inheritance relationship
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;

// Now add the methods you want to the Employee class
Employee.prototype.introduceJob = function() {
  console.log('I work in ' + this.department);
}

// When you create an instance of the Employee function, its prototype
// is chained back to the Person prototype
const newEmployee = new Employee('Luke', 'Takei', 'Tech Support');

// You can call Person methods and Employee methods
newEmployee.greet();          // 'I am Luke Takei'
newEmployee.introduceJob();   // 'I work in Tech Support'

这种模式现在应该大部分已经过时了,因为类给你提供了一个更清晰的方法来创建继承关系。但它仍然存在于许多长期存在的代码库中。

使用模块组织你的 JavaScript 类

问题

你想要将你的类封装在一个单独的命名空间中,以便于重用并防止与其他库的命名冲突。

解决方案

使用 ES6 引入的模块系统。有三个步骤:

  1. 决定哪些功能代表一个完整的模块。将那些类、函数和全局变量的代码放在一个单独的脚本文件中。

  2. 选择你想要导出(在其他文件的其他脚本中可用)的代码细节。

  3. 在另一个脚本中,导入你想要使用的功能。

这是一个模块的示例;我们将其存储在名为 lengthConverterModule.js 的文件中:

const Units = {
  Meters: 100,
  Centimeters: 1,
  Kilometers: 100000,
  Yards: 91.44,
  Feet: 30.48,
  Miles: 160934,
  Furlongs: 20116.8,
  Elephants: 625,
  Boeing747s: 7100
};

class InvisibleLogger {
  static log() {
    console.log('Greetings from the invisible logger');
  }
}

class LengthConverter {
  static Convert(value, fromUnit, toUnit) {
    InvisibleLogger.log();
    return value*fromUnit/toUnit;
  }
}

export {Units, LengthConverter}

最重要的是末尾的 export 语句。它列出了所有将被其他代码文件访问的函数、变量和类。在这个例子中,Units 常量(实际上只是一个枚举)和 LengthConverter 类是可用的,而 InvisibleLogger 类则不是。

注意

当你创建模块文件时,有时建议使用扩展名为 .mjs.mjs 扩展名清楚地表明你在使用 ES6 模块,它有助于 Node 和 Babel 等工具自动识别这些文件。但是,如果你的 web 服务器没有正确配置为使用正确的 MIME 类型(text/javascript)提供 .mjs 文件,那么 .mjs 扩展名也可能会引起问题,就像普通的 .js 文件一样。因此,在这个例子中我们不使用它。

现在你可以将需要的功能导入到另一个模块中。你可以将这个模块写成一个单独的文件,或者像我们在这里做的那样在网页中使用一个 <script> 块。但无论哪种方式,你的 <script> 标签必须包含 type="module" 属性。

这是完整的页面,包括触发 doSampleConversion() 测试的按钮:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Module Test</title>
  </head>
  <body>
    <h1>Module Test</h1>
    <button id="convertButton">Do Sample Conversion</button>

<script type="module">
  import {Units, LengthConverter} from './lengthConverterModule.js';

  function doSampleConversion() {
    const lengthInMiles = 495;

    // This works because you have access to LengthConverter and Units
    const lengthInElephants =
     LengthConverter.Convert(lengthInMiles, Units.Feet, Units.Yards);
    alert(lengthInElephants);

    // This wouldn't work, because you don't have access to InvisibleLogger
    //InvisibleLogger.log();
  }

  // Connect the button
  document.getElementById('convertButton').addEventListener('click',
   doSampleConversion);
</script>

  </body>
</html>

讨论

多年来,JavaScript 使用了许多模块系统,最显著的是 Node 和 npm。但自 ES6 以来,JavaScript 已经有了自己的模块标准,所有现代浏览器都原生支持。

在创建带有模块的解决方案之前,有几个你应该了解的考虑因素:

  • 浏览器安全限制意味着你不能从本地文件系统运行模块示例。相反,你需要将你的示例托管在开发 Web 服务器上(如 “设置本地测试服务器” 中描述的)。

  • 模块被锁定在它们自己独特的“模块”作用域中。你无法从普通的非模块脚本访问模块。同样,你也无法从开发者控制台访问模块。

  • 你无法从页面的 HTML 中访问模块。这意味着你不能使用像onclick这样的 HTML 属性来连接事件处理程序,因为页面无法访问模块内部的事件处理程序。相反,你的模块代码需要通过windowdocument来访问周围的浏览器上下文。

  • 模块会自动以严格模式执行(“使用严格模式捕捉常见错误”)。

模块功能只能被导入到另一个模块中。如果你想在网页中为一个模块创建一个<script>块,确保将type属性设置为module,否则模块导入功能将无法工作:

<script type="module">

当你从一个模块导入功能时,你必须在import语句的from部分指定模块的文件路径。模块支持一种方便的快捷方式,允许你以./开头的相对路径,因此./lengthConverterModule.js指向当前文件夹中的lengthConverterModule.js文件:

import {Units, LengthConverter} from './lengthConverterModule.js';

在导入模块功能时,你使用的命名具有相当大的灵活性。你可以将你的导入包装在一个模块对象中,这是一种特殊的容器,用于对所有内容进行命名空间管理。下面是一个将每种导出类型导入到名为LConvert的模块对象中的示例:

import * as LConvert from './lengthConverterModule.js';

// Now you can access LengthConverter as LConvert.LengthConverter

注意,在使用模块对象时不需要大括号。

你还可以在你的模块中设置一个默认导出:

export default LengthConverter

然后你可以使用任何名称进行导入:

import LConvert from './lengthConverterModule.js';

默认导出功能与其他模块系统中的类似功能匹配。这使得那些模块更容易迁移到 ES6 模块标准中。

ES6 模块很可能最终会成为 JavaScript 中主导的模块标准。但是今天,在 npm 中实现 ES 模块仍然存在一些问题。在可预见的未来,这意味着开发人员将至少需要处理两种模块标准:ES6 标准,这是现代浏览器本地识别的,以及较老的 CommonJS 标准,在 Node 和 npm 生态系统中成熟和广泛使用。

参见

有关如何在 Node 和 npm 中使用 CommonJS 模块的信息,请参见第十八章。

第九章:异步编程

JavaScript 最初是作为单线程编程语言构建的,具有一个调用堆栈,一个内存堆,一次只能执行一个代码例程的特点。但多年来,JavaScript 已经发展壮大。它已经获得了发送网络消息、读取文件和等待用户确认的能力——所有这些操作可能需要时间并可能锁定用户界面。为了安全地处理这些操作,JavaScript 引入了自己的异步编程模式。

在早期阶段,JavaScript 的异步支持围绕回调展开。使用回调时,您请求一个操作(比如从 Web 获取图像),浏览器在另一个线程中执行工作,而不是在您的应用程序代码中。当图像下载完成并且您的应用程序处于空闲状态时,JavaScript 会触发您的回调并将数据传递回您的代码。最终结果是,您的应用程序代码仍然是单线程的,但通过一组标准化的 Web API,您可以启动异步工作。

回调在 JavaScript 中仍然随处可见,但近年来已经用更加精致的语言特性(如 Promise 和asyncawait关键字)进行了包装。底层实现机制相同,但现在可以创建复杂的应用程序,管理并发的异步任务序列,并优雅地处理意外错误。

在本章中,您将使用回调和 Promise 来管理异步任务。您还将看到如何跳出 JavaScript 的单线程模型,并使用 Web Worker API 执行持续的后台工作。

在循环期间更新页面

问题

您希望在长时间的 CPU 密集型操作期间更新页面,但浏览器在忙碌时不会重新绘制窗口。

解决方案

使用setTimeout()函数定期排队您的工作。与其名称相反,您不需要使用setTimeout()设置延迟。相反,使用超时值为0,以便在 UI 线程空闲时立即安排操作的下一步执行。

例如,考虑以下循环,它在 10 秒钟(10,000 毫秒)内递增一个计数器。每次循环通过后,它尝试更改名为status<p>元素中的文本:

function doWork() {
  // Get the <p> element to change
  const statusElement = document.getElementById('status');

  // Track the time and the number of passes through the loop
  const startTime = Date.now();
  let counter = 0;

  statusElement.innerText = 'Processing started';

  while ((Date.now() - startTime < 10000)) {
    counter += 1;
    statusElement.innerText = `Just generated number ${counter}`;
  }

  statusElement.innerText = 'Processing completed';
}

如果您运行此代码,将不会看到任何“刚生成的数字”消息。相反,页面将在 10 秒钟内无响应,然后显示“处理完成”。

要解决这个问题,您将工作(在本例中是递增计数器和显示消息)移动到一个单独的函数中。然后,不再在循环中反复调用此函数,而是使用setTimeout()调用它。每次,该函数递增计数器,更新页面,然后调用setTimeout()以进行另一个循环,直到 10 秒时间限制结束:

function doWorkInChunks() {
   // Get the <p> element to change
   const statusElement = document.getElementById("status");

   // Track the time and the number of passes through the loop
   const startTime = Date.now();
   let counter = 0;

   statusElement.innerText = 'Processing started';

   // Create an anonymous function that does one chunk of work
   const doChunkedTask = () => {
      if (Date.now() - startTime < 10000) {
        counter += 1;
        statusElement.innerText = `Just generated number ${counter}`;

        // Call the function again, for the next chunk
        setTimeout(doChunkedTask, 0);
      }
      else {
        statusElement.innerText = 'Processing completed';
     }
   };

  // Start the process by calling the function for the first time
  doChunkedTask();
}

这里,doChunkedTask 变量包含了一个用箭头函数语法定义的匿名函数(见 “使用箭头函数”)。你并不一定需要使用匿名函数或箭头语法,但它简化了代码。doChunkedTask 函数可以访问创建时所在范围内的所有内容,包括 startTimestatusElement 变量。因此,你无需担心将这些信息传递给函数,这在单独声明函数时是必要的。

运行这段代码时,你会看到网页段落中的数字迅速闪过,然后在 10 秒后被完成消息替换。

讨论

JavaScript 提供了成熟的解决方案来处理异步工作,例如 web workers 功能(见 “使用 Web Worker 执行后台任务”)。然而,并非总是需要这种复杂的功能。如果你有一个长时间运行的任务,或者一个异步操作需要在工作过程中接受数据块,或者一个异步操作需要支持取消,那么 Web workers 是很好的选择。但如果你处理的是相对短暂的任务,并且需求不是很复杂,比如仅在 CPU 密集型工作的短暂突发期间更新页面,那么 setTimeout() 方法完全可以胜任。

在这里提供的示例中,setTimeout() 方法被重复调用。每次调用时,页面会放弃控制权并等待浏览器调度请求的函数,这几乎会立即发生,一旦主应用程序线程空闲(在这种情况下)。要理解其工作原理,重要的是要意识到 setTimeout() 并不确切设置函数何时运行。它实际上是设置了一个最小时间间隔。当 setTimeout() 计时器结束时,它会要求浏览器执行函数,但是浏览器安排这个请求是由浏览器决定的。如果浏览器很忙,请求会延迟执行。(事实上,即使浏览器不忙,现代浏览器也会限制一系列请求,因此不会频繁触发,最小间隔为每 4 毫秒一次。)但在实践中,这些延迟非常小,将 setTimeout() 设置为 0 毫秒几乎会立即触发代码执行。

setTimeout() 方法并非 JavaScript 中用于定时调度工作的唯一方法。还有 window.setInterval() 方法,它会定时重复调用一个函数,每次调用之间有固定的等待时间。如果你想用一个定时器来创建动画(例如通过重新绘制 <canvas> 中的对象),最好使用 requestAnimationFrame(),它会与浏览器的重绘操作同步,确保不会在浏览器无法显示动画的频率下浪费资源计算动画。

注意

setTimeout()setInterval() 方法都是 JavaScript 的古老部分。然而,它们并非过时或已弃用。对于更复杂的情景,您应该使用 Web Worker,而不是依赖于setTimeout()setInterval()自行开发的解决方案。然而,这两种方法仍然是可接受的。

参见

“使用 Web Worker 执行后台任务”描述了如何使用 Web Worker 在后台执行更有雄心的操作。

使用返回 Promise 的函数

问题

您希望在异步任务完成时(成功或失败)运行代码。您希望通过Promise对象通知任务完成。

解决方案

Promise 是一种帮助您管理异步任务的对象。它跟踪任务的状态,并且——最重要的是——处理通知代码的回调,告知任务成功或失败的情况。从技术上讲,promises 并没有为 JavaScript 添加新功能,但它们确实使得清晰协调一系列异步操作变得更加容易。

要使用 promises,被调用的 API 必须支持它们。关于这一点很少会有歧义,因为支持 promises 的 API 会有返回Promise对象的方法。不支持 promises 的旧 API 将要求您提供一个或多个回调函数或处理特定事件。(如果要在使用基于回调的 API 中使用 promise,请参见“将使用回调的异步函数转换为 Promise”。)

要指定 Promise 完成后的操作,可以调用Promise.then()并提供一个函数。要指定出现错误时的操作,可以调用Promise.catch()并提供另一个函数。要添加一些在 Promise 成功或失败后都应该运行的清理代码,可以调用Promise.finally()并提供第三个函数。

这是使用 Fetch API 的一个天真的 Promise 实现:

// Create the promise
const promise = fetch(
 'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg');

// Supply a function that logs successful requests
promise.then( function onSuccess(response) {
  console.log(`HTTP status: ${response.status}`);
});

// Supply a function that logs errors
promise.catch( function onError(error) {
  console.error(`Error: ${error}`);
});

// Supply a function that runs either way
promise.finally( function onFinally() {
  console.log('All done');
});

如果调用成功,您将在控制台窗口中看到 HTTP 状态,然后是“全部完成”消息。

这个示例展示了基本 promise 调用的结构,但它不是我们通常编写基于 promise 的代码的方式,原因有两点。首先,为了更紧凑和可读的代码,我们更喜欢使用箭头函数语法声明函数(参见“使用箭头函数”)。其次,then()catch()finally() 方法通常被链式调用成一条语句。这是可能的,因为这些方法都返回同一个Promise对象。

这是更紧凑和更典型的编写此代码的方式:

fetch(
 'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg')
.then(response => {
  console.log(`HTTP status: ${response.status}`);
})
.catch(error => {
  console.error(`Error: ${error}`);
})
.finally(() => {
  console.log('All done');
});
注意

这个基于 Promise 的示例只使用了一个语句,你可以随意在任何地方断行。一个常见的约定是,在点操作符之前断行,所以下一行以.then.catch开始。这样,代码易于跟踪,并且具有类似于同步代码的错误处理布局。这也是 Prettier 代码格式化器所应用的结构(“使用格式化器一致地排版代码”)。

讨论

一个Promise对象不是结果,而是未来将可用结果的占位符

一旦创建了Promise对象,它的代码就开始执行。甚至可能在调用then()catch()之前就完成了Promise的工作。这不会改变你的代码工作方式。如果在已经解析(成功)的Promise上调用then(),或者在已经拒绝(带有错误)的Promise上调用catch(),你的代码立即运行。

这里展示的简单解决方案使用链式调用附加成功函数(使用then())和失败函数(使用catch())。然而,将多个异步任务链在一起依次运行也很常见。fetch()函数提供了一个很好的例子。它返回一个 Promise,一旦服务器响应就解析。但是,如果你想读取这条消息的主体,你需要启动第二个异步操作。(听起来很痛苦,但这是有道理的,因为发送的数据量可能非常大,所以你不希望在检索数据时阻塞代码。在 JavaScript 中,I/O 操作总是异步的。)

这里有一个示例,执行一个异步的fetch请求,然后使用response.blob()以二进制流形式读取结果,返回第二个Promise对象。现在在该对象上调用then()添加第三步——将二进制数据转换为可以显示在<img>元素中的 Base64 编码字符串:

fetch(
 'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg')
.then(response => response.blob())
.then(blob => {
  const img = document.getElementById('imgDownload');
  img.src = URL.createObjectURL(blob);
});

良好的代码格式很重要,因为Promise链可能会变得非常长。但如果一致组织,你的异步调用看起来可以类似于线性的代码块,这比过去显著改进了,当开发者创造术语回调地狱来描述连续回调函数的嵌套金字塔时。

在链接多个 Promise 时,如果决定使用,你可以在链的末尾调用catch()finally()。这样一来,你可以在 Promise 链的任何阶段收集未处理的错误。甚至可以在then()函数中抛出自己的异常来表示失败并结束链:

fetch(
 'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg')
.then(response => {
  if (!response.ok) {
    // Ordinarily, it's not an error if the server responds to our request
    // Now, let's treat any response other than HTTP 200 OK as an error
    throw new Error(`HTTP code: ${response.status}`);
  }
  else {
    return response.blob();
  }
})
.then(blob => {
  const img = document.getElementById('imgDownload');
  img.src = URL.createObjectURL(blob);
})
.catch(error => {
  console.log('An error occurred in the first or second promise');
});

一旦发生未处理的错误,整个 Promise 链都会中断。您可以响应此错误执行日志记录或其他诊断任务,但无法恢复进一步的已放弃的 Promise。如果未在 Promise 中捕获错误,则最终会作为window.unhandledrejection事件引发,并且如果未在那里取消,则会记录到控制台。

参见

第十三章更详细地解释了 Fetch API。“并发执行多个 Promise”展示了如何使用 Promise 链接并发任务。“使用 Await 和 Async 等待 Promise 完成”展示了如何使用fetch()await关键字。

将使用回调的异步函数转换为 Promise

问题

您希望将基于回调的异步函数更改为使用 Promise。

解决方案

创建另一个函数来包装您的异步函数。此函数创建并返回一个新的Promise对象。当异步任务完成时,函数调用Promise.resolve()(如果成功)或Promise.reject()(如果失败)。

下面是一个类似于传统回调式异步函数的示例函数。它使用定时器执行其异步工作:

function factorializeNumber(number, successCallback, failureCallback) {
  if (number < 0) {
    failureCallback(
      new Error('Factorials are only defined for positive numbers'));
  }
  else if (number !== Math.trunc(number)) {
    failureCallback(new Error('Factorials are only defined for integers'));
  }
  else {
    setTimeout( () => {
      if (number === 0 || number === 1) {
        successCallback(1);
      }
      else {
        let result = number;
        while (number > 1) {
          number -= 1;
          result *= number;
        }
        successCallback(result);
      }
    }, 5000);  // This hard-coded 5-second delay simulates a long async process
  }
}

没有将阶乘异步计算或使用定时器的好处。此示例仅用作使用回调的任何旧 API 的替代品。

现在,您可以像这样使用factorializeNumber()函数:

function logResult(result) {
  console.log(`5! = ${result}`);
}

function logError(error) {
  console.log(`Error: ${error.message}`);
}

factorializeNumber(5, logResult, logError);

factorializeNumber()函数转换为 Promise 对象的最简单方法是创建一个包装函数:

function factorializeNumberPromise(number) {
  return new Promise((resolve, reject) => {
    factorializeNumber(number,
      result => {
        resolve(result);
      },
      error => {
        reject(error);
      });
  });
}

现在,您可以调用factorializeNumberPromise(),接收一个Promise对象,并使用Promise.then()处理结果:

factorializeNumberPromise(5)
.then( result => {
  console.log(`5! = ${result}`);
});

您还可以捕获潜在错误,甚至创建整个异步操作链。

factorializeNumberPromise('Bad value')
.then( result => {
  console.log(`6! = ${result}`);
})
.catch( error => {
  console.log(error);
});

讨论

在深入研究此解决方案之前,很重要立即澄清一个可能的误解。轻松创建返回Promise对象的函数并不会使您的代码异步运行。您的代码将像往常一样在 UI 线程上同步运行。(这类似于使用延迟为 0 的setTimeout()调用。)

要解决此限制,factorializeNumber()示例使用定时器模拟异步 API。如果确实希望在后台线程上运行自己的代码,您需要使用 Web Workers API(“使用 Web Worker 执行后台任务”)。

注意

在 JavaScript 中,您经常会使用 Promise,但很少创建它们。创建Promise对象的最常见原因是您正在包装较旧的基于回调的代码,如本示例中所示。

要创建一个函数的 promisified 版本,你需要一个创建 Promise 对象并返回它的函数。这就是 factorializeNumberPromise() 函数的主要工作。尽管创建 Promise 很容易,但一开始可能看起来复杂,因为有两层嵌套函数在起作用。在其核心,Promise 对象包装了一个具有以下结构的函数:

function(resolve, reject) {
  ...
}

promise 函数接收两个参数,它们本质上是回调函数。你可以使用这些函数来表示 promise 的完成。调用 resolve()(带有返回值)来成功结束 promise,或者调用 reject()(带有错误对象)来表示失败。或者,如果在 promise 函数中的任何地方发生未处理的错误,Promise 对象将捕获它并自动调用 reject(),将错误传递给它。

在 promise 函数内部,你启动你的异步任务。或者,在 factorializeNumberPromise() 示例中,你调用现有的 factorializeNumber() 函数来启动计时器。你仍然需要使用回调函数与旧的 factorializeNumber() 函数进行交互。不同之处在于,现在你将它们通过调用 resolve()reject() 转发到 promise 中。例如,这是 successCallback 的函数,调用 resolve()

function(resolve, reject) {
  factorializeNumber(number,
    function successCallback(result) {
      resolve(result);
    },
    ...
  );
}

这是调用 reject() 的失败回调:

function(resolve, reject) {
  factorializeNumber(number,
    function successCallback(result) {
      resolve(result);
    },
    function failureCallback(error) {
      reject(error);
    });
  );
}
注意

Promise.reject() 方法接受一个参数,表示失败的原因。这个原因可以是任何类型的对象,但强烈建议使用 Error 对象的实例或从 Error 派生的自定义对象(“抛出自定义错误”)。在当前示例中,失败回调已经发送了一个 Error 对象,所以我们可以简单地将其传递给 reject()

通过使用箭头函数语法(“使用箭头函数”),可以通过声明 successCallbackfailureCallback 和保存它们的 promise 函数来使代码更加简洁。

可以编写一个通用的 promisifying 函数,该函数可以将任何基于回调的函数 promisify 化。事实上,一些库,比如 BlueBird.js,提供了这种功能。然而,在大多数情况下,使用 promisification 要简单得多,更不容易混淆——例如,当你想要将一个异步任务与另一个已经使用 promises 的任务统一起来时,而不是试图包装每一个旧的异步 API。

参见

如果你正在为 Node 运行时环境开发,可以使用 promisify 实用程序将函数包装为 promise,如 “管理回调地狱” 中所述。

并行执行多个 promises

问题

你想要同时执行多个 promises,并且在所有 promises 完成它们的工作后做出反应。

解决方案

使用静态的 Promise.all() 方法将多个 Promise 合并为单个 Promise 并等待它们全部成功解决(或任何一个失败)。

要演示其工作原理,请想象一个返回 Promise 的函数,该 Promise 在大约 0 到 10 秒的等待后解决。以下是一个 randomWaitPromise() 函数,它正是使用 setTimeout() 来实现这一点。将其视为任何异步操作的替代品:

function randomWaitPromise() {
  return new Promise((resolve, reject) => {
    // Decide how long to wait
    const waitMilliseconds = Math.round(Math.random() * 10000);

    // Simulate an asynchronous task with setTimeout()
    setTimeout(() => {
      console.log(`Resolved after ${waitMilliseconds}`);

      // Return the number of seconds waited
      resolve(waitMilliseconds);
    }, waitMilliseconds);
  });
}

现在,您可以使用 randomWaitPromise() 快速创建任意数量的新 Promise。要等待多个 Promise 完成,您需要将所有的 Promise 对象放入一个数组中,并将该数组传递给 Promise.all() 方法。Promise.all() 返回一个新的 Promise,表示所有 Promise 的完成。使用它,您可以调用 then()catch() 来构建一个 Promise 链,如同往常一样:

// Create three promises
const promise1 = randomWaitPromise();
const promise2 = randomWaitPromise();
const promise3 = randomWaitPromise();
const promises = [promise1, promise2, promise3];

// Wait for all of them, then log the result
Promise.all(promises).then(values => {
  console.log(`All done with: ${values}`);
});

在此链中没有 Promise.catch(),因为这段代码不可能失败。

当您运行此示例时,每个 Promise 完成时都会在控制台中输出。当最后一个、最慢的 Promise 解决时,您将获得最终的 "All done" 消息:

Resolved after 790
Resolved after 4329
Resolved after 6238
All done with: 790,6238,4329
小贴士

当您同时使用多个 Promise 时,通常会传递一个带有某种标识符的对象给您的 Promise(比如 URL 或 ID)。然后,当 Promise 解决时,它可以返回一个包含此标识详细信息的对象。这样,您可以确定哪个结果对应哪个 Promise。这种跟踪很方便,但并非必需,因为您可以通过它们的顺序来判断哪个结果是哪个。您在结果数组中接收的结果顺序与您最初在 Promises 数组中提交的顺序相匹配。

讨论

异步编程的一个优点是能够减少等待时间。换句话说,而不是等待一个任务完成,然后另一个任务,再然后是另一个任务,您可以同时启动所有三个任务。在实际生活中,这是一种比较专业化的场景。更常见的是有一个依赖于另一个异步任务结果的异步任务,此时您需要按顺序链式调用任务。但如果不是这种情况,通过同时运行多个 Promise 并使用 Promise.all() 等待它们,您可以节省大量时间。

Promise.all() 使用 快速失败 行为。一旦其中一个 promises 被拒绝(无论是通过调用 Promise.reject() 还是出现未处理的错误),您使用 Promise.all() 创建的组合 promise 也会被拒绝,触发您附加到 promise 链的任何函数与 Promise.catch()。其他 promises 仍将运行,并且您可以从相应的 Promise 对象获取它们的结果。例如,如果 promise1 被拒绝,您仍可以调用 promise2.then() 来获取其结果。但实际上,当您使用 Promise.all() 时,您可能会将一个 promise 的失败视为组合操作的结束。否则,将更容易保持 promises 分开,或使用下面列出的其他 Promise 方法之一。

除了 all() 之外,还有其他静态 Promise 方法接受多个 promises 并返回一个组合的 promise。它们的行为略有不同:

Promise.allSettled()

当每个 promise 被解决 被拒绝时解决。(这与 Promise.all() 不同,后者仅在所有 promise 成功时解决。)使用 Promise.then() 附加的函数接收一个结果对象数组,每个 promise 一个。每个结果对象有两个属性:status 表示 promise 是否被实现或被拒绝,value 有返回的值或错误对象。

Promise.any()

一旦一个 promise 成功解决,就会解决。它仅提供该 promise 的值。

Promise.race()

一旦一个 promise 成功解决或被拒绝,就会解决。这是所有 Promise 方法中最专业的,但它可以用来构建某种自定义调度系统,当现有任务完成时排队新的异步任务。

使用 Await 和 Async 等待 Promise 完成

问题

而不是创建一个 promise 链,您希望编写更易于阅读且更像同步代码的线性逻辑。

解决方案

不要调用 Promise.then()。而是在 promise 上使用 await 关键字:

console.log('taskPromise is working asynchronously');
await taskPromise;
console.log('taskPromise has finished');

await 后的代码直到等待的 promise 被解决或被拒绝才运行。您的代码执行暂停,但不会阻塞线程,锁定 UI,或阻止其他计时器和事件触发。

但有一个陷阱。await 关键字只能在 async 函数内部使用。这意味着您可能需要一些重新排列来使用 await。考虑来自 “使用返回 Promise 的函数” 的 fetch() 示例。使用 promises,它看起来像这样:

const url =
 'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg';

fetch(url)
.then(response => {
  // The fetch operation has completed
  console.log(`HTTP status: ${response.status}`);
  console.log('All asynchronous steps completed');
})

使用 asyncawait 关键字,您可以这样结构化:

`async` function getImage() {
  const url =
'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg';

  const response = `await` fetch(url);

  // The fetch operation has completed and the promise is resolved or rejected
  console.log(`HTTP status: ${response.status}`);
}

getImage().then(() => {
  console.log('All asynchronous steps completed');
});

您还可以在等待的操作周围使用传统的异常捕获块,而不是 Promise.catch() 方法:

async function getImage() {
  const url =
'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg';

  try {
    const response = await fetch(url);
    console.log(`HTTP status: ${response.status}`);
  }
  catch(err) {
    console.error(`Error: ${error}`);
  }
  finally {
    console.log('All done');
  }
}

仅仅使用 await 调用一个调用的优势相对较小。然而,如果你有一整个需要依次发生的异步操作序列,await 可以使你的代码变得更清晰。通常,你会通过多次调用 Promise.then() 来处理这种情况。但是通过 await,代码看起来像普通的同步代码。以下是一个示例,复制了从“使用返回 Promise 的函数”中读取异步 web 请求的图像数据的示例:

async function getImage() {
  const url =
   'https://upload.wikimedia.org/wikipedia/commons/b/b2/Eagle_nebula_pillars.jpg';

  // Wait (asynchronously) for the response
  const response = `await` fetch(url);

  if (response.ok) {
    // Wait (asynchronously) for the blob to be read
    const blob = `await` response.blob();

    // Now show the image
    const img = document.getElementById('imgDownload');
    img.src = URL.createObjectURL(blob);
  }
}

讨论

await 关键字以一种类似同步代码的方式处理 Promise,但不会锁定应用程序。考虑如下语句:

const response = await fetch(url);

从你的代码角度来看,就像执行停止并且 fetch() 函数变成同步一样。但实际上,JavaScript 将你函数的剩余部分附加到由 fetch() 返回的 Promise 上,就像你将其传递给 Promise.then() 一样。因此,你的其余代码被调度,UI 线程不会被阻塞。你的应用程序可以自由处理其他事件和计时器,同时等待 fetch 操作完成。

await 关键字只能在 async 函数中工作。你不能在 web 页面代码的顶层使用 await。相反,你需要创建一个新的 async 函数来容纳它,就像本例中的 getImage() 函数一样:

async function getImage() {
  ...
}

现在 getImage() 是一个 async 函数,它将自动返回一个 Promise 对象。你可以使用 Promise.then() 来附加在 getImage() 完成时运行的代码,就像处理任何 Promise 链一样。

如果你忘记 getImage() 是一个异步函数,你可能会调用它,但忘记使用 Promise。这是新手在使用 asyncawait 时常见的错误。

// This probably isn't right, because you're discarding the Promise object
getImage();

相反,你需要接受由 getImage() 返回的 Promise 对象,并分别调用 then()catch() 来附加下一步运行的代码和你的错误处理代码:

getImage()
.then(response => {
  console.log('Image download finished');
})
.catch(error => {
  console.error(`Error: ${error}`);
});

你可能会想知道为什么要处理 Promise,而 asyncawait 关键字应该帮你节省这些工作。答案是,你总是需要管理根级 Promise 对象,它启动你的异步操作。

注意

最近有一个相对较新的例外情况。你可以在模块的顶层代码中使用 await(参见“使用 ES6 模块组织你的 JavaScript 类”)。如果你使用了这种能力,请确保将使用 await 的语句放在一个 try...catch 异常处理块中,以捕获任何未处理的错误。

当你需要执行多个异步操作并在途中做出决策时,await关键字变得更有用。例如,想象一下,你需要编写等待异步任务完成、评估其结果,然后决定下一个任务启动的代码。你可以用承诺来实现这种模式,但逻辑更难理解。使用await,它的组织方式就像传统的同步代码一样:

const step1 = await someAsyncTask();

if (step1 === someResult) {
  const step2 = await differentAsyncTask();
  ...
}
else {
  const step2 = await anotherAsyncTask();
  ...
}

鉴于这段代码看起来如此清晰简洁,你可能会想为什么使用await。像所有抽象一样,await隐藏了底层Promise对象的一些细节,并使某些情况更加困难。例如,使用await等待一系列动作一个接一个地完成,而实际上你想要的是同时启动所有动作,这是一个常见的错误。以下是问题的演示:

const response1 = await slowFunction(dataObject1);
const response2 = await slowFunction(dataObject2);
const response3 = await slowFunction(dataObject3);

你可以用Promise.all()来解决这种情况(如“同时执行多个承诺”中所述)。但这并不是必要的。只要确保所有承诺都已启动,你仍然可以使用await。以下是一个更正:

const promise1 = slowFunction(dataObject1);
const promise2 = slowFunction(dataObject2);
const promise3 = slowFunction(dataObject3);

const response1 = await promise1;
const response2 = await promise2;
const response3 = await promise3;

这能够运行是因为承诺在创建时立即运行代码。在代码分配promise1promise2promise3时,所有三个异步过程都已启动。虽然await通常与返回承诺的函数一起使用,但它也适用于任何Promise对象。

等待哪个承诺先完成并不重要,因为你可以安全地在已经完成的承诺上使用await。无论你做什么,你都无法跳过代码的这一部分,直到每个承诺被解决或拒绝。 (从技术上讲,这意味着这段代码遵循与Promise.allSettled()相同的行为,而不是Promise.all(),因为代码会继续等待所有承诺被处理,即使其中一个失败了。)

创建一个异步生成器函数

问题

你想为返回值异步地生成一个操作的生成器。

解决方案

使用async关键字与“创建产生多个值的生成器函数”中所示的专门生成器函数语法。

考虑这个极其简单的生成器,它产生一个永无止境的随机数序列:

function* getRandomIntegers(max) {
  while (true) {
    yield Math.floor(Math.random() * Math.floor(max) + 1);
  }
}

你可以这样称呼:

const randomGenerator = getRandomIntegers(6);

// Get 10 random values between 1 and 6
for (let i=0; i<10; i++) {
  console.log(randomGenerator.next());
}

要使生成器异步化,你只需像普通函数一样添加async关键字:

`async` function* getRandomIntegers(max) {
  while (true) {
    yield Math.floor(Math.random() * Math.floor(max) + 1);
  }
}

与任何其他async函数一样,异步生成器函数不会直接产生结果。相反,它会产生包装结果的Promise对象。当结果准备好时,你可以调用Promise.then()来获取结果。以下是一个展示正在发生的事情的示例:

const randomGenerator = getRandomIntegers(6);

// Get 10 random values between 1 and 6
for (let i=0; i<10; i++) {
  const promise = randomGenerator.next();
  console.log('Received promise.');
  promise.then(result => console.log(`Received result: ${result.value}`));
}

运行此代码时,你会看到一系列“接收到承诺”消息,紧接着是结果列表。

经常将异步生成器与await关键字结合使用。一个常见的快捷方式是for await循环,它会等待从生成器请求新值,直到前一个 Promise 已解析。以下是一个使用这种技术一次搜索一个随机数的示例:

// This function uses a for await loop to perform consecutive awaits async function searchRandomNumbers(searchNumber, generator) {
  `for` `await` (const value of generator) {
    console.log(value);
    if (value === searchNumber) return;
  }
}

// Use the searchRandomNumbers() function to generate random numbers // from 1 to 100, asynchronously, until we find 42 const randomGenerator = getRandomIntegers(100);
searchRandomNumbers(42, randomGenerator).then(result => {
  console.log('Number found');
});

你会注意到使用异步迭代器的代码现在被包装在一个async函数中。这是因为你不能在顶层代码中使用await(如“使用 await 和 async 等待 Promise 完成”中所解释的那样)。

讨论

生成器函数提供了一种简化的方式来按需返回值。在每个yield语句之后,JavaScript 会暂停生成器函数。但是它周围的上下文(所有局部变量和传入的参数)会被保留,直到调用代码请求下一个值。

解决方案示例中的例子并没有执行任何真正的异步工作,随机数可以立即获得。你可以通过添加超时来在此示例中模拟异步过程。但更有趣的是考虑一个使用真正的异步 API 展示异步生成器的例子。

异步生成器最适合访问外部资源并具有一定延迟的任务。例如,你可能会在网络请求或文件流 API 中看到它们。以下是一个使用 Fetch API 从 Web 服务获取随机数列表的生成器:

async function* getRandomWebIntegers(max) {
  // Construct a URL to get a random number in the requested range
  const url = https://www.random.org/integers/?num=1&min=1&max=' + max +
  '&col=1&base=10&format=plain&rnd=new';

  while (true) {
    // Start the request (and wait asynchronously for the response)
    const response = await fetch(url);

    // Start reading the text asynchronously
    const text = await response.text();

    // Yield the result and wait for the next request
    yield Number(text);
  }
}

现在,每次调用代码请求一个值时,生成器都会启动一个异步的fetch()操作并返回一个 Promise。当fetch()完成时,Promise 会解析。调用代码可以通过多次在生成器上调用next()同时启动多个异步调用。但更常见的是使用for await循环逐个处理。无论哪种方式,都无需修改原始解决方案中使用的代码。如果你运行这个版本的示例,你会看到每个随机数在出现在开发者控制台之前都会有一个短暂但可测量的延迟。

参见

“创建返回多个值的生成器函数” 解释了如何创建非异步生成器。“使用 await 和 async 等待 Promise 完成” 解释了如何创建普通的异步函数。

使用 Web Worker 执行后台任务

问题

你希望长时间运行的代码在单独的线程上执行,这样就不会阻塞用户界面。

解决方案

使用 Web Worker API。你可以创建一个Worker对象,它在后台线程上运行所有代码。虽然Worker对象与你的其余代码隔离开来(例如,它无法访问 DOM、页面或任何全局变量),但你可以通过来回发送消息与它进行通信。

图 9-1 展示了一个在给定范围内计算所有素数的示例页面。因为页面使用了 Web Workers,所以在任务进行中,界面仍然保持响应。例如,仍然可以在文本框中输入文本或点击取消按钮。

jsc3 0901

图 9-1. 一个网络工作者计算素数

启动按钮触发一个名为 startSearch() 的函数。它创建一个新的工作者,附加处理 Worker.errorWorker.message 事件的函数,并最终通过调用 Worker.postMessage() 开始操作。以下是网页脚本中相关代码:

// Keep a reference to the worker so we can cancel it, if needed
let worker;

function startSearch() {
  // Create the worker
  worker = new Worker('prime-worker.js');

  const statusDisplay = document.getElementById('status');
  statusDisplay.textContent = 'Search started.';

  // Report error message on the page
  worker.onerror = error => {
    statusDisplay.textContent = error.message;
  };

  // Respond to messages from the worker, and display the final result
  // (the list of primes) on the page when it's received
  worker.onmessage = event => {
    const primes = event.data;

    document.getElementById('primeContainer').textContent = primes.join(', ');
  };

  // Get the search range and tell the worker to start
  const fromNumber = document.getElementById('from').value;
  const toNumber = document.getElementById('to').value;
  worker.postMessage({from: fromNumber, to: toNumber});
}

prime-worker.js 文件包含 Web Worker 运行的代码。其中包括一个 findPrimes() 函数(此处未显示),该函数使用埃拉托斯特尼筛法查找素数。prime-worker.js 文件还处理 Worker.message 事件,当页面调用 Worker.postMessage() 时触发该事件。在本例中,页面调用 postMessage() 向工作者发送数字范围并开始搜索:

// This is the code the worker uses to handle messages from the page
onmessage = (event) => {
  // Get the sent object from event.data and call the time-consuming
  // findPrimes() method to do the search
  const primes = findPrimes(Number(event.data.from), Number(event.data.to));

  // Send back the result
  postMessage(primes);
};

唯一剩下的是取消按钮的事件处理程序,它关闭 Web Worker,即使它在搜索过程中也是如此:

function cancelSearch() {
  // Cancel the worker, provided the page has created it
  if (worker) worker.terminate();
}

讨论

普通情况下,您编写的 JavaScript 代码在单个应用程序线程上运行。JavaScript 使用基于事件循环的调度系统。它不断监视事件,监听计时器间隔,并等待来自异步 API 的回调。当它接收到要运行的函数时,按照它们到达的顺序将它们排队。如果您决定编写 CPU 密集型代码(例如执行耗时的计算),您将占用主线程,并阻止其他函数在您完成工作之前运行。

注意

您可能会对普通的 JavaScript 代码如何单线程执行感到困惑,但 JavaScript 提供了某些能够异步工作的 API(如 fetch)。这是因为这些 API 由浏览器中的服务提供,并最终由操作系统提供。它们超出了 JavaScript 环境。例如,使用 fetch() 进行的网络请求是在单独的线程上进行的,而不是用于应用程序的主线程。

Web Worker API 提供了一种逃脱 JavaScript 单线程执行模型的方式。使用 Web Workers,您可以在与主应用程序用户界面分离的单独线程上并发运行代码。为了确保您不必处理线程安全、竞争条件和锁等棘手问题,Web Workers 保持在单独的执行上下文中。它们无法与网页、浏览器窗口或您的其他代码交互。为了强调这一事实,Worker 对象要求您将 Web Worker 代码放在单独的文件中,并在创建工作者时提供该文件:

worker = new Worker('prime-worker.js');

一旦你理解了这个限制,剩下的 Web 工作线程模型就非常直观了。所有应用程序与工作线程之间的通信都是通过消息传递完成的。要发送消息,你需要调用postMessage()。在质数示例中,页面发送一个带有两个属性tofrom的对象字面量,表示搜索范围:

worker.postMessage({from: fromNumber, to: toNumber});

当工作线程响应时,它调用postMessage()来发送质数数组:

postMessage(primes);

你可以随意发送消息,没有限制。例如,你可以创建一个工作线程,调用postMessage()来发送一些任务,让它空闲一段时间,然后再调用postMessage()来发送更多任务。Web 工作线程还可以使用setTimeout()setInterval()函数来安排定期任务。

有两种方式可以停止工作线程。首先,工作线程可以调用close()自行停止。更常见的是,创建工作线程的页面会调用worker.terminate()来关闭它。一旦以这种方式停止了工作线程,就不能再重新启动它了。

参见

要查看完整的代码,包括质数搜索例程,请参考本书示例代码。如果要查看使用更复杂消息传递的此示例的修订版本,请参见“为 Web 工作线程添加进度支持”。

为 Web 工作线程添加进度支持

问题

你希望你的 Web 工作线程在运行任务时报告进度。

解决方案

你可以使用工作线程的标准消息传递行为。使用消息对象的属性来区分不同类型的消息。

例如,考虑一个质数示例的版本(来自“为 Web 工作线程添加进度支持”),它发送两种类型的消息:进度通知(在工作进行时)和质数列表(当工作完成时)。

为了让应用程序能够区分这两种消息类型,它添加了一个messageType字符串属性,可以设置为"Progress""PrimeList"。以下是返回结果的重写代码:

onmessage = function(event) {
  // Perform the prime number search.
  const primes = findPrimes(Number(event.data.from), Number(event.data.to));

  // Send back the results.
  postMessage(
    {messageType: "PrimeList", data: primes}
  );
};

现在,质数计算代码还需要使用postMessage()来报告其进度。它使用速率限制检查将进度四舍五入到最接近的百分比,并确保不会多次通知相同的进度:

function findPrimes(fromNumber, toNumber) {
  // Prepare the prime number search range
  ...

  // This is the loop that searches for primes
  for (let i = 0; i < list.length; i+=1) {

    // Check if the current number is prime
    ...

    // Calculate and report the progress
    var progress = Math.round(i/list.length*100);

    // Only send a progress update if the progress has changed at least 1%
    if (progress !== previousProgress) {
      postMessage(
       {messageType: 'Progress', data: progress}
      );
      previousProgress = progress;
    }

  }

  // Clean up and return the list of prime numbers
  ...
}

当页面接收到消息时,它检查messageType属性以确定消息类型,然后采取相应措施。如果是质数列表,则在页面上显示结果。如果是进度通知,则更新进度文本,如图 9-2 所示。

worker.onmessage = event => {
  const message = event.data;

  if (message.messageType === 'PrimeList') {
    const primes = message.data;
    document.getElementById('primeContainer').textContent = primes.join(', ');
  }
  else if (message.messageType === 'Progress') {
    statusDisplay.textContent = `${message.data} % done ...`;
  }
};

jsc3 0902

图 9-2. Web 工作线程在工作时报告进度

讨论

为了强制线程安全性,应用程序和 Web Worker 之间除了通过传递消息外没有其他互动方式。你可以发送任何可以被序列化为 JSON 的对象作为消息。这与向远程网站发送消息时几乎相同。

要创建自己的自定义消息类来规范化你正在使用的结构,这可能是你的选择。但是,请记住,一旦对象在线程之间传递,它看起来就像一个普通的对象字面量。它没有自定义原型或任何方法,你也无法用instanceof来测试其类型。同样地,你可能会考虑使用来自“使用 Symbol 创建枚举”的枚举值技巧,但这行不通,因为应用程序和工作线程无法共享它们的符号。

参见

JavaScript 还有两个构建在 Web Worker API 基础上的专用 API。如果你希望从不同窗口与相同的工作线程交互,可以使用shared workers。你还可以使用更高级的service workers来创建工作线程,即使你的页面未打开,它们也能保持运行。这个 API 的理念是帮助你构建缓存、同步和通知服务,使网站的行为更像本地应用程序。

第十章:错误和测试

编写代码就是编写错误。经常情况下,可以预见到错误。风险活动包括与外部资源交互的操作(如文件、数据库或 Web 服务器 API)。来自代码外部的信息——无论是从网页表单中读取还是从另一个库接收——可能会出现错误,或者以不同于预期的形式出现。但要修改一个老生常谈,问题不在于错误本身,而在于如何处理它。

那么我们应该如何处理我们的错误?JavaScript 的默认行为是在错误点死机,静默地将堆栈跟踪记录到控制台。然而,还有更好的选择。您可以捕获错误,对其做出反应,修改它,重新抛出它,甚至隐藏它,如果您选择的话。与许多其他语言相比,JavaScript 的错误处理功能相对不太完善。但基本的错误处理仍然同样重要,本章中的许多示例将专注于此任务。

防范错误是必不可少的实践,但同样重要的是预防它们。为此,有许多适用于 JavaScript 的测试框架,包括 Jest、Mocha、Jasmine 和 Karma。借助它们,您可以编写确保代码按预期执行的单元测试。本章将简要介绍 Jest。

捕获和中和错误

问题

您正在执行可能不会成功的任务,并且不希望错误中断您的代码或出现在开发者控制台中。

解决方案

将你的代码段放入try...catch块中,像这样:

try {
  // This is guaranteed to fail with a URIError
  const uri = decodeURI('http%test');

  // We never get here
  console.log('Success!');
}
catch (error) {
  console.log(error);
}

decodeURI()函数失败并发生错误时,执行跳转到catch块。catch块接收一个错误对象(也称为异常),该对象提供以下属性:

name

通常反映错误子类型的字符串(如“URIError”),但它可能只是“Error”。

message

一个提供问题人类语言描述的字符串,如“URI malformed”。

stack

一个列出堆栈中当前打开函数的字符串,按照最近调用到更早调用的顺序排列。根据浏览器的不同,stack 属性可能包含有关函数位置(如行号和文件名)以及调用这些函数时使用的参数的信息。

警告

要小心。错误对象上还定义了几个其他属性(如descriptionlineNumber),但这些属性仅在特定浏览器中有效。在编写错误处理代码时不要依赖这些非标准属性,因为它们在所有浏览器上都不起作用。

如果直接将错误对象传递给console.log()方法(如此示例),将从这三个属性中提取信息。具体显示内容将根据浏览器而异:

URIError: URI malformed
    at decodeURI (<anonymous>)
    at runTest (<anonymous>):14:15
    at <anonymous>:20:1

这里,开发控制台中编写的一段顶层代码(在调用堆栈列表中由底部 <anonymous> 表示)调用了一个名为 runTest() 的函数,然后使用上述代码调用 decodeURI() 处理了一个错误的 URI,触发了随后记录的错误。

解决方案

在测试错误处理代码之前,您需要一个能够引发错误的例程。例如,我们不考虑语法错误或在编写代码时应该实际捕获的任何逻辑错误(例如使用 “使用 ESLint 强制代码标准” 中描述的 linter)。相反,我们需要执行一个依赖外部资源并且可能由于您的代码没有错误而失败的操作。

JavaScript 对许多其他编程语言中会被视为错误的用法异常宽容。尝试访问不存在的属性会得到一个无错误的值 undefined。如果超出数组边界也是如此。JavaScript 的错误容忍在数学方面特别明显,例如,将数字乘以字符串会返回一个无错误的值 NaN(非数),而除以零则返回特殊值 Infinity。尝试使用 decodeURI() 函数就是一个可能失败的操作示例,在这种情况下会出现 UriError

注意

decodeURI()encodeURI() 方法旨在使用可接受的转义序列替换 Web URL 中不允许的字符,如果在 查询字符串(跟在 ? 后面的 URL 部分)中存储任意数据,则这是一种重要的技术。如果尝试对未经正确编码的字符串进行反向编码可能会失败,例如,如果它包含应该开始一个转义序列的 % 字符。

捕获错误的行为可以防止其成为未处理的错误。这意味着您的代码可以继续执行(在 Node 应用程序的情况下,防止应用程序完全结束)。但是,您应该只捕获您理解并准备处理的错误。永远不要仅仅使用错误处理来抑制和忽略潜在的问题。“检测未处理的错误” 详细介绍了未处理错误的影响。

虽然 try...catch 块是错误处理的最常见结构,但您可以选择在末尾添加一个 finally 部分。finally 块中的代码始终会运行。如果没有错误发生,则在 try 块之后运行;如果捕获了错误,则在 catch 块之后运行。它通常用作放置无论您的代码成功还是失败都应运行的清理代码的地方。

try {
  const uri = decodeURI('http%test');

  // We never get here
  console.log('Success!');
}
catch (error) {
  console.log(error);
}
finally {
  console.log('The operation (and any error handling) is complete');
}

参见

“捕获不同类型的错误” 显示了如何有选择地捕获不同类型的错误。“捕获异步错误” 显示了如何捕获在异步操作期间发生的错误。

捕获不同类型的错误

问题

你希望区分不同类型的错误并采取不同的处理方式,或仅处理特定类型的错误。

解决方案

不同于许多语言,JavaScript 不允许按类型捕获错误。相反,你必须像往常一样捕获所有错误,然后使用 instanceof 运算符调查错误:

try {
  // Some code that will raise an error
}
catch (error) {
  if (error instanceof RangeError) {
    // Do something about the value being out of range
  }
  else if (error instanceof TypeError) {
    // Do something about the value being the wrong type
  }
  else {
    // Rethrow the error
    throw error;
  }
}

最后,如果错误不是你可以处理的类型,应该重新抛出该错误。

讨论

JavaScript 有八种错误类型,由不同的错误对象表示(参见表 10-1)。你可以检查错误的类型以确定发生的问题类型。这可能指示你应该采取哪些措施,或者是否可以执行备用代码、重试操作或恢复。它还可能提供有关出现问题的详细信息。

表 10-1. 错误对象

错误类型 描述
RangeError 当数值超出其允许范围时发生。
ReferenceError 尝试将不存在的对象赋给变量时发生。
SyntaxError 当代码存在明显的语法错误,如多余的 ( 或缺少 } 时发生。
TypeError 当值不适合特定操作的数据类型时发生。
URIError 在使用 decodeURI() 和其他相关函数转义 URL 时发生。
AggregateError 是多个错误的包装器,对于异步发生的错误非常有用。errors 属性中提供了错误对象数组。
EvalError 用于表示与内置的 eval() 发生的问题,但现在已不再使用。现在,对语法无效的代码使用 eval() 将引发 SyntaxError
InternalError 发生于各种非标准情况,具体取决于浏览器。例如,在 Firefox 中,如果超过递归限制(函数反复调用自身),则会发生 InternalError,而在 Chrome 中,同样的情况则表示为 RangeError

除了这些错误类型外,你还可以抛出和捕获自定义的错误对象,详见“抛出自定义错误”。

JavaScript 仅允许每个 try 块有一个 catch 块,这会阻止你按类型捕获错误。但是,你可以捕获标准的 Error 对象,用 instanceof 检查其类型,并编写条件代码进行相应处理。使用这种方法时,一定要小心,不要意外地忽略无法处理的错误。

在当前示例中,代码明确处理 RangeErrorTypeError 类型。如果错误是其他类型,则假定我们无法实际解决问题。然后使用 throw 语句重新抛出错误。当你使用 throw 时,就好像再次发生了同样的错误。如果你的代码在一个函数中,这允许错误继续向上冒泡,直到它到达一些可以适当处理它的错误处理代码。如果没有其他捕获此错误的错误处理,则它变成未处理的错误,就像你一开始没有捕获它一样。 (有关详细信息,请参阅 “检测未处理错误”。)

换句话说,重新抛出未知错误会给你带来仅捕获特定异常类型时的相同行为,这是你在 JavaScript 语言支持时可能采取的方法。

参见

“抛出自定义错误” 展示了如何创建自定义错误类来指示自定义错误条件,并传递关于错误的额外信息。

捕获异步错误

问题

你想要添加错误处理,但是风险操作是在后台线程执行的。

解决方案

JavaScript 的 API 有多种异步模型,处理错误的方式取决于你使用的函数。

如果你使用较旧的 API,可能需要提供一个回调函数,在发生错误时调用它,或者附加一个事件处理程序。例如,XMLHttpRequest 对象提供了一个 error 事件来通知你有关失败请求的信息:

const request = new XMLHttpRequest();

request.onerror = function errorHander(error) {
  console.log(error);
}

request.open('GET', 'http://noserver');
request.send();

这里对 send() 的调用触发了导致错误的异步操作,但实际错误发生在单独的线程上。在此语句周围添加 try...catch 块不会捕获问题。你最好能通过 error 事件接收到通知。

如果你使用基于 promise 的 API,你可以通过调用 Promise.catch() 来附加你的错误处理函数。以下是使用 Fetch API 的示例:

fetch('http://noserver')
.then((response) => {
  console.log('We did it, fam.');
})
.catch((error) => {
  console.log(error);
});

在此处编写的代码将在未处理的错误或被拒绝的 promise 的事件中触发。如果你没有捕获 promise 中发生的错误,它将冒泡到你的主应用程序线程,并触发 window.unhandledrejection 事件,这是基于 promise 的等效于 window.error 事件(见 “检测未处理错误”)。

最后,如果你使用带有更高级别的 asyncawait 模型的 promises,你可以使用传统的错误处理块。catch 部分将自动附加到 promise 上,通过 Promise.catch()。以下是一个示例:

async function doWork() {
  try {
    const response = await fetch('http://noserver');
  }
  catch (error) {
    console.log(error);
  }
}

doWork().then(() => {
  console.log('All done');
});

讨论

将错误处理代码放在错误的位置是一个常见的错误。不幸的是,您的错误处理代码是否无效或永远不会运行并不总是显而易见,尽管 linting 工具(“使用 eslint 强制代码标准”)可能会提醒您存在问题。最好的解决方案是在应用程序中测试实际的错误条件,并验证您的错误处理代码是否运行并减少了它们的影响。

参见

“使用返回 Promise 的函数” 展示了一个完整的示例,其中包含 Fetch API 和基于 Promise 的错误处理。“使用 Await 和 Async 等待 Promise 结束” 展示了一个完整的示例,其中包含 Fetch API 和 asyncawait 错误处理。

检测未处理的错误

问题

您希望捕获代码中未处理的错误,可能是为了创建诊断日志。

解决方案

处理 window.error 事件。您的事件处理函数会接收到五个带有错误信息的参数。除了代表实际错误的错误对象外,还会得到单独的 message 参数和位置信息(source 表示脚本文件的 URL,lineno 表示发生错误的行号,colno 表示列号)。

下面是一个测试此事件的示例:

// Attach the event handler
window.onerror = (message, url, lineNo, columnNo, error) => {
  console.log(`An unhandled error occurred in ${url}`);
}

// Cause an unhandled error
console.log(null.length);

请注意,要测试此示例,您需要使用一个示例测试页面。您不能使用开发者控制台将函数附加到 window.error 事件处理程序上。

注意

在某些情况下,浏览器的跨源安全策略会阻止您的 JavaScript 代码访问错误详情。例如,如果您从本地文件系统而不是使用测试服务器运行测试页面,则会出现这种情况,此时 message 参数将显示通用文本“Script error”,而 urllineNocolumnNoerror 属性将为空白。有关更多信息,请参见《onerror 注释》

讨论

在您应用程序的主线程上发生的未处理错误会一直向上传递,直到达到代码的顶层,如果在那里未处理,则会触发浏览器中的 window.error 事件。

window.error 事件不同寻常之处在于它允许您取消错误,有效地将其抑制。要做到这一点,您需要在事件处理函数中返回 true。如果不抑制错误,浏览器的默认错误处理程序会立即起作用。它将以鲜红色字体在开发者控制台中显示错误信息,就像使用 console.error() 方法记录一样。但是,如果从 window.error 返回 true,错误将消失,开发者控制台中也不会留下任何痕迹。

除此之外,在您的 window.error 事件处理程序中抑制或允许错误没有实际区别。当错误触发 window.error 事件时,您的代码已经停止执行,并且调用栈已经展开。然而,这并不会阻止您的网页工作。一旦发生另一个事件(例如,单击按钮),JavaScript 就会再次执行您的代码。

注意

现代实践不鼓励我们隐藏错误,即使是从开发者控制台。除非有非常好的理由。一种可能性是您正在用某种调整后的错误显示替换默认错误显示,这种替换适应您的应用程序,并提供更有用的信息或者移除您不想向用户显示的信息。

您可以使用您的 window.error 事件处理程序执行任何类型的 JavaScript 代码。例如,您可以将错误记录到本地数据存储,甚至使用 Fetch API 将其发送到 Web 服务器。如果在 window.error 事件处理程序期间发生错误,则不会再次触发事件处理程序。它将直接传递给浏览器的默认错误处理程序,并显示在开发者控制台中。

对于异步代码,错误处理方式有所不同。对于较旧的基于回调的 API,通常不会出现错误。相反,这些 API 使用回调通知您的代码有关错误条件(参见 “捕获异步错误”)。但对于基于 promise 的 API,未处理的错误会冒泡并触发 window.unhandledrejection 事件:

// Attach the event handler
window.onunhandledrejection = (e) => {
  console.log(e.reason);
}

// Create a promise that will cause an unhandled asynchronous error
const faultyPromise = new Promise(() => {
  throw new Error('Disaster strikes!');
});

// Create a promise that rejects (also triggers window.onunhandledrejection)
const rejectedPromise = new Promise((resolve, reject) => {
  reject(new Error('Another disaster strikes!'));
});

unhandledrejection 事件会将一个带有事件属性的对象传递给您的事件处理程序。上面的示例中使用的 reason 属性包含未处理的错误对象,或者如果手动拒绝了 promise,则包含传递给 Promise.reject() 的对象。您还可以从 promise 属性获取底层的 Promise 对象。

window.error 类似,window.unhandledrejection 是一个可取消的事件。然而,它使用了一种不同的、更现代的取消约定。您可以使用带有事件参数的对象的 preventDefault() 方法,而不是返回 true。以下是一个示例,显示当发生未处理的 promise 错误时显示消息,但隐藏自动错误日志记录:

window.onunhandledrejection = (e) => {
  console.log('An error occurred, but we won\'t tell you what it was');

  // Cancel the default error handling
  e.preventDefault();
}
提示

您可能认为未处理的异常事件是放置日志记录代码的好地方。有时确实如此。但通常情况下,您会希望在错误发生的地方更接近地捕获它们,并在那里记录它们,必要时重新抛出它们。然而,未处理的异常事件始终是查找需要异常处理逻辑但却没有的风险代码块的好方法。

额外信息:日志工具

广义上讲,有两种情况下你会捕获错误:在测试代码并且可以修复它们的时候,以及在生产环境中你想知道出了什么问题的时候。在第一种情况下,日志记录很简单 —— 你的目标是检测问题并修复它。通常你的日志记录只涉及调用 console.log()。在后一种情况下,你需要调查可能在特定环境和最终用户面前偶发发生的问题。现在你需要一种方法来检测问题并将详细信息报告给你。

你可以处理 window.errorwindow.unhandledrejection 事件,然后将详细信息写入某种存储介质。例如,你可以将错误信息保存在 localStorage 对象中,以便它在当前浏览器会话之外持久存在。你可以使用 fetch() 将详细信息发送到服务器上的 Web API。如果你正在构建一个 Node 应用程序,你可以将详细信息写入服务器上的文件或数据库。你可以添加额外的上下文信息,比如系统详情、优先级和时间戳。但随着日志记录需求的增长,你可能想考虑使用开源日志记录工具,而不是自己开发解决方案。

一个好的日志工具为你的日志提供了一个 抽象层。这意味着你可以记录消息(就像调用通常的 console.log() 方法一样),而不用考虑日志存储在哪里或者它是如何实现的。在测试期间,日志层可能只是将你的消息输出到控制台。但当你的应用部署时,日志层可能会忽略低级消息,将重要的消息发送到其他地方,比如远程 Web 服务器。日志工具还可以实现高级功能,如批处理,在快速连续记录多条消息到远程站点时提高性能。

JavaScript 应用程序中有多种日志记录库,包括 Winston、Bunyan、Log4js、Loglevel、Debug、Pino 等等。有些专门设计用于 Node 应用程序,但也有许多可以在浏览器中的网页代码中使用。

抛出标准错误

问题

通过抛出错误对象来指示错误条件。

解决方案

创建一个 Error 对象的实例,将问题的简短描述传递给构造函数,用于 message 属性。使用 throw 语句抛出 Error 对象。然后你的代码可以像捕获其他类型的 JavaScript 错误一样捕获这个 Error 对象:

function strictDivision(number, divisor) {
  if (divisor == 0) {
    throw new Error('Dividing by zero is not allowed');
  }
  else {
    return number/divisor;
  }
}

// Catch the error
try {
  const result = strictDivision(42, 0);
}
catch (error) {
  // Shows the custom error message
  console.log(`Error: ${error.message}`);
}

讨论

创建 Error 对象有两种方式。你可以使用 new 关键字创建它,就像解决方案中一样。或者(不常见),你可以像调用函数一样调用 Error(),它的结果相同:

// Standard error-throwing
throw new Error(`Dividing by zero is not allowed`);

// An equivalent approach
throw Error(`Dividing by zero is not allowed`);

Error 对象具有标准的错误属性,包括你设置的 message,一个 name(默认为Error),和一个 stack(指出错误发生位置的堆栈跟踪)。

警告

JavaScript 还允许代码使用throw与非错误对象(如字符串)。这种用法非标准,可能会导致期望具有namemessage等属性的异常处理代码出现问题。作为经验法则,请不要抛出非异常对象。

有时,您可以重新利用更具体的错误子类型。大多数 JavaScript 内置的错误类型(列在表 10-1 中)是为特殊情况设计的,并不适合自定义代码。但有几种可能会有用。例如,如果函数接收到超出可接受数值范围的值,可以使用RangeError。请确保包含一个包含给定值和预期范围的信息丰富的错误消息:

function setAge(age) {
  const upper = 125;
  const lower = 18;
  if (age > 125 || age < 18) {
    throw new RangeError(
     `Age [${age}] is out of the acceptable range of ${lower} to ${upper}.`);
  }
}

RangeError 特别用于数值。但是,您可以使用TypeError来指示提供的值类型错误的情况。您可以自行决定何为“错误”类型;例如,当您期望一个数字而得到一个字符串时(可以用typeof进行测试),或者当您得到错误类型的对象时(可以用instanceof进行测试)。

function calculateValue(num) {
  if (typeof num !== 'number') {
    throw new TypeError(`Value [${num}] is not a number.`);
  }
}

您可能会考虑一些较少使用的错误子类型,如ReferenceError(当您期望对象而收到null引用或undefined值时)或SyntaxError(例如,如果您解析某种类型的字符串内容,而该内容不符合您设定的规则)。要更具体地处理其他错误条件,请考虑创建您自己的错误类(参见“抛出自定义错误”)。

与许多更严格的语言相比,JavaScript 较少使用错误。在设计自己的库时,通常最好遵循这一惯例。不要为 JavaScript 通常会容忍的情况(如隐式类型转换)使用异常。不要使用错误来通知调用代码有关非异常情况的信息,换句话说,像无效用户输入这样在正常操作期间可能发生的事情。使用异常来防止代码由于未正确初始化某些内容而继续执行操作失败。

参见

“抛出自定义错误”解释了如何创建自己的错误对象。

抛出自定义错误

问题

当您希望通过抛出自定义错误对象来指示特定的错误条件时。

解决方案

创建一个从标准Error类继承的类。构造函数应接受message属性的描述文本,并使用super()调用基础Error类构造函数以传递消息。以下是一个最简自定义错误的示例,包含抛出它的代码:

class CustomError extends Error {
  constructor(message) {
    super(message);
    this.name = 'CustomError';

    // Optional improvement: clean up the stack trace, if supported
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, CustomError);
    }
  }
}

// Try raising this error
throw new CustomError('An application-specific problem occurred');

还有一个推荐但可选的改进。您可以使用静态方法Error.captureStackTrace()来稍微清理堆栈跟踪。(技术上,captureStackTrace()确保错误构造函数的调用不会出现在存储在Error.stack属性中的堆栈跟踪中。)

你也可以添加自定义属性来传递有关错误条件的额外信息。这里有一个示例,在查找失败后存储一个productID

class ProductNotFound extends Error {
  constructor(missingProductID) {
    super(`Product ${missingProductID} does not exist in the catalog`);

    this.name = 'ProductNotFound';
    this.productID = missingProductID;

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, ProductNotFound);
    }
  }
}

try {
  throw new ProductNotFound(420);
}
catch (error) {
  console.log(`An error occured with the message: ${error.message}`);

  if (error instanceof ProductNotFound) {
    console.log(`Missing: ${error.productID}`);
  }
}

讨论

在创建自定义Error类时,我们应该牢记两个可能相互竞争的考虑因素:保持在典型 JavaScript 错误范围内,并表达足够的信息以满足我们定制的错误条件。在前一种情况下,不要试图重新创建你第二喜欢的语言的错误或异常。不要通过不必要的方法和额外功能过度扩展 JavaScript 的Error类型。

当你创建自定义错误时,有一些惯例需要牢记:

  • 使用类名来指示错误类型,并设置name属性以匹配。如果任何代码检查name来确定错误类型(而不是使用instanceof),这一点很重要。即使错误对象被序列化为 JSON,它也会持久存在,并出现在错误的默认字符串表示和开发者控制台中。

  • 在构造函数中,将你的自定义属性放在参数列表的最前面。如果包括一个message参数,它应该是最后一个参数。

  • 在构造函数中,调用super()并将message传递给基类构造函数。

  • 作为一种礼貌,正确设置堆栈跟踪。检查captureStackTrace()方法,如果存在,调用它,传递对当前实例(作为this)和你的自定义错误类的引用。

参见

要了解更多关于继承和extends关键字的知识,请参见“从另一个类继承功能”。

为你的代码编写单元测试

问题

你想要使用自动化测试来确保你的代码现在和将来符合设计标准。

解决方案

使用类似 Jest 的工具尽早为你的代码编写单元测试。

安装 Jest 最简单的方法是使用 npm(“使用 npm 下载包”)。在你的项目文件夹中打开一个终端窗口,并使用npm init创建package.json配置文件(如果你还没有):

$ npm init -y

接下来,使用--save-dev参数安装 Jest,这样它只会包含在开发构建中:

$ npm install --save-dev jest

现在你需要找一些代码来测试。假设你有一个名为factorialize.js的文件,其中包含如下所示的factorialize()函数:

function factorialize(number) {
  if (number < 0) {
    throw new RangeError('Factorials are only defined for positive numbers');
  }
  else if (number != Math.trunc(number)) {
    throw new RangeError('Factorials are only defined for integers');
  }
  else {
    if (number == 0 || number == 1) {
      return 1;
    }
    else {
      let result = number;
      while (number > 1) {
        number--;
        result *= number;
      }
      return result;
    }
  }
}

为了使这个函数对 Jest 可访问,你需要通过在文件末尾添加这行来导出factorialze()函数:

export {factorialize}
注意

Jest 假设你正在使用 Node 模块标准(CommonJS)。如果你已经在使用更新的 ES6 模块标准,你需要使用 Babel,一个 JavaScript 转译工具,将你的模块引用转换为 Jest 处理你的代码之前。这听起来很复杂,但 plugin-transform-modules-commonjs 模块会处理大部分工作。要查看完全配置的解决方案(使用 CommonJS 模块或 ES6 模块),请参考示例代码。关于 CommonJS 模块的更多信息,请参阅 “将你的库转换为 Node 模块”。关于 ES6 模块的更多信息,请参阅 “使用模块组织你的 JavaScript 类”。

现在你需要创建你的测试文件。在 Jest 中,测试文件的扩展名为 .test.js。在这种情况下,这意味着你需要创建一个名为 factorialize.test.js 的新文件。然后这个文件导入你想要测试的函数:

import {factorialize} from './factorialize.js';

你的测试文件的其余部分定义了你想要运行的测试。测试的最简单方法是首先验证你的函数是否按照你的预期工作。例如,你可以编写一个 Jest 测试,验证 factorialize() 对几个代表性案例返回正确的信息。这里有一个检查 10! 是否为 3,628,800 的示例:

test('10! is 3628800', () => {
  expect(factorialize(10)).toBe(3628800);
});

Jest 的 test() 函数创建了一个命名测试。这个名称允许你在测试报告中识别测试,这样你就可以准确知道哪些测试成功了,哪些失败了。这个示例中的测试使用了 Jest 的 expect() 函数,它调用你的代码(在这种情况下是 factorialize() 函数),然后用 toBe() 评估结果。技术上,toBe() 是 Jest 的几个 匹配器函数 之一。它确定代码是否通过了测试。

要运行这个测试,你需要使用 Jest。你可以从命令行运行它,使用你的测试文件和 npm 的包运行器 npx 的帮助。在这个例子中,你会在终端中使用这个命令:

$ npx jest factorialize.test.js

运行你编写的单个测试,并生成如下报告:

PASS  ./factorialize.test.js
 √ 10! is 3628800 (4 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.725 s, estimated 3 s

Ran all test suites matching /factorialize.test.js

更常见的是,你会将 Jest 添加到你的 package.json 文件的 scripts 部分,这样它可以自动运行所有你的测试:

{
  "scripts": {
    "test": "jest"
  }
}

现在你可以要求 Jest 运行项目文件夹中的所有测试(.test.js 文件)。

讨论

有多种类型的测试,比如安全性、可用性和性能测试,但最基本的测试形式是 单元测试。单元测试包括对离散源代码单元进行测试,并验证这些单元的行为是否符合预期。在 JavaScript 中,最常见的单元测试单元是函数。

尽管有许多可能的测试框架(如 Jest、Mocha、Jasmine、Karma 等),它们大多数使用相似的语法。在 Jest 中,一切都围绕着一个test()函数展开,它接受两个参数。第一个参数是测试的标签,在测试报告中显示。第二个参数是一个包含一个或多个测试断言的函数,这些断言要么成功证明为真(通过),要么为假(失败):

test('Some test name', () => {
  // Test assertions go here
});

要创建测试断言,你使用expect()函数,它是 Jest 的关键。它与像toBe()这样的匹配函数配合使用,用于评估测试调用的结果:

test('10! is 3628800', () => {
  `expect`(factorialize(10)).`toBe`(3628800);
});

此示例演示了factorialize()函数的单个测试。但测试编写者的目标更广泛。你需要选择一组代表性的测试,这些测试检查多个值,并捕捉可能的边界条件。例如,对于factorialize()函数的测试,测试如何处理非数值输入、负值、0、非常大的值等是有意义的。

以下代码展示了一个更完整的测试套件。它检查了对factorialize()的五个不同调用的结果。这些调用都使用describe()进行了分组,describe()函数简单地让你标记一组相关的测试调用。在这个例子中,describe()将调用同一函数分组,但你也可以用它来分组使用相同样本数据的调用:

describe('factorialize() function tests', () => {
  test('0! is 1', () => {
    expect(factorialize(0)).toBe(1);
  });
  test('1! is 1', () => {
    expect(factorialize(1)).toBe(1);
  });
  test('10! is 3628800', () => {
    expect(factorialize(10)).toBe(3628800);
  });
  test('"5"! is 120', () => {
    expect(factorialize('5')).toBe(120);
  });
  test('NaN is 0', () => {
    expect(factorialize(NaN)).toBe(0);
  });
});

当你运行这个测试时,你会发现最终的测试失败了。它期望调用factorialize(NaN)返回0,但实际上抛出了一个错误,测试日志清楚地表明:

 FAIL  ./factorialize.test.js
  factorialize() function tests
    √ 0! is 1 (3 ms)
    √ 1! is 1
    √ 10! is 3628800
    √ "5"! is 120
    × NaN is 0 (3 ms)

  ● factorialize() function tests › NaN is 0

    RangeError: Factorials are only defined for integers

      4 |   }
      5 |   if (number != Math.trunc(number)) {
    > 6 |     throw new RangeError('Factorials are only defined for integers');
        |           ^
      7 |   }
      8 |   else {
      9 |     if (number == 0 || number == 1) {

      at factorialize (factorialize.js:6:11)
      at Object.<anonymous> (factorialize.test.js:17:12)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 4 passed, 5 total
Snapshots:   0 total
Time:        2.833 s
Ran all test suites.

目前,你看到的每个测试都使用toBe()匹配函数来检查精确值。但是,像所有测试框架一样,Jest 允许你使用不同类型的规则。例如,你可以检查一个数字是否落在特定范围内,文本是否匹配某种模式,或者值是否不为空。表 10-2 概述了一些可用于expect()的最有用的匹配函数。要查看全面的列表,请参阅 Jest 文档中的expect()方法

表 10-2. Jest 匹配器

函数 描述
arrayContaining() 在数组中搜索给定值。
not() 允许你反转任何条件。例如,使用expect(...).not.toBe(5),如果值不是5,则测试通过。
stringContaining() 在字符串中搜索子字符串。
stringMatching() 尝试将字符串与正则表达式匹配。
toBe() 测试标准的 JavaScript 相等性,就像使用==运算符一样。
toBeCloseTo() 测试两个数字是否相等或非常接近。旨在避免浮点数的微小舍入误差(详见“保留十进制数值精度”)。
toBeGreaterThan() 检查数值是否大于指定的值。还有一小组类似的比较器,包括toBeGreaterThanOrEqual()toBeLessThan()toBeLessThanOrEqual()
toBeInstanceOf() 检查返回的对象是否是指定类的实例,就像使用instanceof操作符一样。
toBeNull() 检查值是否为null。您还可以使用toBeNaN()测试NaN值,并使用toBeUndefined()测试未定义的值。
toBeTruthy() 检查一个数字是否真值,这意味着它在if语句中将被强制转换为true。在 JavaScript 中,除了nullundefined、空字符串、NaN0false之外的所有内容都是真值。
toEqual() 执行深度比较,检查一个对象是否与另一个对象具有相同的内容。这与toBe()形成对比,后者用于测试对象的引用相等性。作为一个基本的规则,对于原始类型,toBe()有效,但是对于比较对象实例,您需要使用toEqual()。(“Making a Deep Copy of an Object”更详细地解释了 JavaScript 中对象相等性的问题。)
toHaveProperty() 检查返回的对象是否具有特定属性,并(可选)检查该属性是否匹配特定值。
toStrictEqual() 类似于toEqual(),但要求对象完全匹配。例如,具有相同属性和属性值的对象,如果它们是不同类的实例,或者其中一个是类实例,另一个是对象字面量,则不匹配。
toThrow 测试函数是否抛出异常。您可以选择要求异常是特定的错误对象。

要修复当前示例,您可以指示您期望值为NaN时抛出异常,并使用toThrow()匹配器。但是,toThrow()需要额外的步骤。您需要将expect()内部的代码包装在另一个匿名函数中。否则,异常不会被捕获,测试仍将失败。以下是正确的代码:

test('NaN causes error', () => {
  expect(() => {
    factorialize(NaN);
  }).toThrow();
});

另请参阅

此示例很好地概述了 Jest 的核心功能,但还有许多其他功能可能会被考虑。例如,Jest 还支持使用模拟数据、处理来自 Promise 的异步结果、模拟计时器以及快照测试(用于验证页面 UI 未更改)。有关所有这些功能的更多信息,请参阅Jest 文档

Extra: 首先编写测试

现代开发实践已经接受了在应用程序(和库)的大部分功能写入之前编写测试的想法。这种测试驱动的开发(TDD)是敏捷开发范 paradigm 的一部分。

TDD 需要一些适应。与更加正式的结构化编程瀑布式项目设计不同,后者推迟测试直到您拥有相当完整的代码,TDD 要求您在编写任何其他内容之前编写测试。以下是其步骤:

  1. 定义测试。 例如,如果您计划编写上一个示例中显示的factorialize()函数,则首先需要定义一组能够捕捉其预期输入的代表性测试,例如,它可以计算的最大数值、边界值如 0,以及可能的边缘情况(如隐式强制转换的字符串或BigInt值)。您还需要编写测试来验证失败情况是否得到适当处理——在这种情况下,即抛出预期的错误。

  2. 使其失败。 完成测试编写后,接下来编写代码。一些 TDD 实践者建议,第一步是使您的代码编译失败,测试失败。通过完成此步骤,您确保测试正在运行,测试需求是有意义的,并且在代码完全编写之前不会意外通过代码。

  3. 使其通过。 下一步有时被描述为“以任何可能的方式使测试通过”。换句话说,您不必担心创建最佳解决方案,而只需使所有测试通过。不要编写超出测试需求的代码。

  4. 重构。 成功通过测试后,您开始改进代码。这是重构的时机,删除重复代码并引入改进,同时反复运行测试以确保它们继续通过。您可能还会发现一些未覆盖的情况,并需要编写更多的测试。

TDD 的一个明显优势是它让您专注于手头的问题。您不需要解释设计要求来决定如何编写解决方案。相反,您根据测试中明确的规格编写代码。但 TDD 开发也有助于应用程序的演变,因为它减少了对变更的恐惧。只要您的代码继续通过您设定的测试,并且您的测试是真正代表性的(一个更大的“如果”),提交新的代码修订是安全的。

为此保护付出的代价是,编写适当的测试需要更多的时间和显著的经验才能完成。一个可以帮助您评估测试方案的度量标准是测试代码覆盖率(“追踪测试代码覆盖率”)。

追踪测试代码覆盖率

问题

您希望评估您的测试案例在代码中涵盖所有可能性的程度。

解决方案

从您的测试工具获取代码覆盖率报告。在 Jest 中,您可以使用--collect-coverage选项:

$ npx jest --collect-coverage

现在 Jest 将运行所有test.js文件中的所有测试(像往常一样),然后生成一个更详细的报告,分析您测试的代码覆盖率。以下是测试factorialize()函数的报告,来自“为您的代码编写单元测试”:

-----------------|---------|----------|---------|---------|-------------------
File             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files        |   82.61 |    66.67 |     100 |   82.61 |
 factorialize.js |   82.61 |    66.67 |     100 |   82.61 | 3-4,6-7
-----------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.741 s

讨论

确定测试代码覆盖率需要一个多方面的方法。要成功,它应包括诸如与同行进行代码审查和演练之类的技术。然而,所有测试工具也包括可以帮助您评估测试在评估代码时的成功程度的自动化代码分析功能。

在 Jest 中,--collect-coverage参数触发此分析。您可以在命令行中使用此参数,或将其添加到应用程序的package.json配置文件中的jest命令中。

代码覆盖率报告通过几个百分比评估您的代码测试覆盖多少,这些百分比显示在单独的列中:

函数

显示已测试的函数数量。这是评估测试覆盖率的良好起点,但也是最粗粒度的统计数据。在factorialize()测试中,所有函数都已测试。但这并不意味着这些函数中的所有代码都被执行!

语句

显示在您的测试中执行的代码语句的百分比。在factorialize()测试中,大约 83%的代码都至少被一个测试覆盖。

分支

显示已达到多少不同分支(通过条件逻辑,如if语句)。在factorialize()测试中,测试涉及到 67%的独立条件分支。

此外,代码报告还可以指出没有代码覆盖的行。例如,factorialize()示例突出显示了源代码文件中的第 3 至 4 行,用于拒绝负数,以及第 6 至 7 行,用于拒绝非整数数字。要提高测试代码的覆盖率,您可以编写一个使用toThrow()的测试断言,以确保这两种情况都被正确拒绝。

命令行报告让您快速查看覆盖率,但 Jest 还会生成一个更全面的 HTML 格式报告,并将其存储在coverage文件夹中。打开index.html,您可以看到所有测试文件的列表,并稍微详细说明了顶级统计数据(参见图 10-1)。例如,报告不仅仅提供百分比,还告诉您确切的语句数、分支数和函数数。点击列表中的任何文件,将跳转到另一页显示代码,未覆盖的语句会被高亮显示,以便快速参考(参见图 10-2)。

jsc3 1001

图 10-1. 代码覆盖率报告

jsc3 1002

图 10-2. 未测试覆盖的代码高亮显示
注意

适当的测试覆盖率目标备受争议。一些开发者主张尽可能接近 100%,而另一些则认为 70%至 80%更为实际,并且能够在你的测试编写投资中获得最佳回报。然而,诚实的答案是测试覆盖率并非一个确定的指标。不仅百分比因测量方式不同而异(函数、语句或分支),而且测试工具无法识别代码库中最危险或最脆弱的路径。

第十一章:Part II. JavaScript in the Browser

第十一章:浏览器工具

作为 Web 开发者,浏览器是世界访问您创作的窗口。 它还提供了有用的工具,用于开发和测试您的站点。 学习如何使用浏览器的开发工具是值得的投资,这样您可以更轻松地调试代码。 在本章中,我们将介绍几个用于调试、性能分析和分析 JavaScript 的有用功能。

出于简化,本书中的所有示例都将使用 Google Chrome 的开发者工具(DevTools)。 在撰写本文时,Chrome 的使用率超过了全球浏览器市场的 65%。 大多数其他浏览器提供类似的功能。 Mozilla 的 Firefox 开发者版 是一个具有有用开发者功能的优秀替代品。

调试 JavaScript

问题

您需要知道 JavaScript 代码执行的特定点上变量的值。

解决方案

使用断点检查代码的值和类型。 设置断点时,浏览器的调试器将在断点代码执行点停止,并显示每个当前范围内的值。 然后可以逐步执行代码或允许 JavaScript 完成执行。 图 11-1 显示了在断点上暂停的代码屏幕截图。

使用 Chrome 调试器设置断点的屏幕截图

图 11-1. 使用 Chrome 调试器设置断点的屏幕截图

要在 Chrome 的开发者工具中的特定 JavaScript 代码行上设置断点:

  1. 使用 Command-Option-C(Macintosh)或 Control+Shift+C(Windows 或 Linux) 打开 Chrome 的开发者工具。

  2. 单击 DevTools Sources 选项卡。

  3. 从文件列表中选择 JavaScript 文件。

  4. 单击要设置断点的行号。

  5. 通过与页面交互或刷新浏览器窗口来执行代码。

讨论

使用 console.log 语句在代码中特定点标识值是一种常见做法,但断点提供更多信息和更大的灵活性。 当您熟悉以这种方式调试时,您将能够更轻松地排除基于浏览器的 JavaScript 代码中的问题。

除了在浏览器用户界面中设置断点之外,还可以通过添加 debugger 语句来使用代码设置断点。 这样做将在 debugger 语句的执行点暂停代码执行。

function normalize(string) {
  const normalized = string.replace(/[^\w]/g, "").toLowerCase();
  debugger;
  return normalized;
}

一旦到达断点,您将有几个选项可以执行 JavaScript 代码:

恢复脚本执行

继续完整地执行代码。

跨过

在不“步入”调试的情况下执行函数。

进入

进入函数以进一步调试。

退出

执行当前函数代码的其余部分。

步骤

跳到下一行代码。

这些基于行的断点只是可以设置的一种断点类型。此外,还可以基于 DOM 更改、条件值、事件监听器、异常和 fetch/XHR 请求设置断点。使用断点可以更好地控制 JavaScript 调试体验。

分析运行时性能

问题

您的 JavaScript 代码执行速度似乎较慢或存在错误,但您不确定问题的根源。

解决方案

使用浏览器开发者工具的性能分析来查找代码中的瓶颈和 CPU 密集型任务(见图 11-2)。

要分析 Chrome 开发者工具中的 JavaScript 代码性能:

  1. 打开 Chrome 的开发者工具,使用 Command-Option-C(Macintosh)或 Control+Shift+C(Windows 或 Linux)。

  2. 单击 DevTools 的性能选项卡。

  3. 要么单击记录按钮与页面交互,或单击重新加载按钮查看与新页面加载相关的性能指标。

一旦 Chrome 完成页面的分析,您将获得允许查看潜在性能瓶颈的信息。

Chrome 性能选项卡的屏幕截图

图 11-2. Chrome 的性能选项卡

讨论

Chrome 性能工具将分解页面的浏览器渲染过程,并使用视觉时间轴、截图和摘要图表进行展示(见图 11-3)。使用这些信息可以查找性能受到负面影响的地方。

作为开发者,您可能拥有高端设备和快速互联网连接。浏览器性能工具最有用的功能之一是能够模拟受限的 CPU 或互联网连接。这样做可以帮助您发现用户可能遇到的性能问题,但这些问题可能对您不明显。

带有限制 CPU 和网络连接启用的开发者工具屏幕截图

图 11-3. Chrome 开发者工具性能工具允许您限制 CPU 和网络连接

查看性能数据是确保用户体验良好的重要步骤。良好的网站性能已被证明可以提高用户保留率和销售转化率。在“使用 Lighthouse 测量最佳实践”中,我们将介绍如何进一步审查潜在的性能问题。

识别未使用的 JavaScript

问题

您的应用性能受到大型 JavaScript 文件的影响。

解决方案

使用 Chrome 开发者工具的 Coverage 功能来识别未使用的 JavaScript(见图 11-4)。

Chrome 覆盖工具结果的屏幕截图

图 11-4. Chrome 的 Coverage 工具

要查看未使用的 JavaScript,请访问 Coverage 标签页:

  1. 打开 Chrome 的开发者工具,使用 Command-Option-C(Macintosh)或 Control+Shift+C(Windows 或 Linux)。

  2. 打开命令菜单,使用 Command-Shift-P(Macintosh)或 Control+Shift+P(Windows 或 Linux),然后输入coverage

  3. 选择显示覆盖率并按 Enter。

  4. 要么点击记录按钮与页面进行交互,或者点击重新加载按钮记录与新页面加载相关的覆盖率结果。

  5. 当您想停止记录结果时,请点击停止仪表覆盖并显示结果

结果将显示包含以下信息的报告:

  • 文件 URL

  • 文件类型

  • 总字节数

  • 未使用的字节数

  • 使用可视化

您随后可以使用此信息来辅助重构代码,以减少页面上未使用的字节总量。

讨论

查看代码使用情况有助于了解您提供给用户的未使用 JavaScript 的百分比。然后,减少此未使用代码的任务通常留给手动重构。但是,JavaScript 打包工具,如 Webpack,也可以用于将代码拆分成多个包并执行“摇树”操作,自动消除死代码。这些方法在“JavaScript 与移动 Web”中有详细介绍。

使用 Lighthouse 衡量最佳实践

问题

您希望衡量您的 Web 应用程序遵循的最佳实践。

解决方案

使用内置于 Chrome 开发者工具中的 Google Lighthouse 工具(参见图 11-5)。

  1. 使用 Command-Option-C(Macintosh)或 Control+Shift+C(Windows 或 Linux)打开 Chrome 的开发者工具。

  2. 点击 DevTools Lighthouse选项卡。

  3. 选择要分析的类别和设备类型(移动或桌面)。

  4. 点击生成报告

Lighthouse 随后将生成一份报告,每个类别都有得分,并针对改进提出具体建议。

Google Lighthouse 的截图

图 11-5. 在 Chrome 的开发者工具中查看 Google Lighthouse 报告的结果

讨论

Lighthouse 是由 Google 创建的开源工具,用于衡量网站的性能和最佳实践。该工具内置于 Chrome 的开发者工具中,但也可以作为独立的浏览器扩展、Node 模块或从命令行运行。Lighthouse 报告可在桌面或移动视图中生成,帮助您快速了解移动性能。Lighthouse 生成以下各个领域的报告和建议:

  • 性能

  • 渐进式 Web 应用程序

  • 最佳实践

  • 可访问性

  • SEO

报告输出提供具体问题的可操作反馈,并提供链接到文档和推荐的改进措施。在图 11-6 中,您可以看到对一个分析网站的性能建议,包括删除未使用的 JavaScript 和减少第三方代码的影响。展开每个诊断将提供额外的细节和文件具体信息。

Lighthouse 性能建议的截图

图 11-6. Lighthouse 性能建议

Google 的 Lighthouse 是一个有用的工具,用于评估您开发的网站和应用的整体健康和性能。通过浏览器开发者工具访问 Lighthouse 提供了一种快速高效的方式,在开发过程中对站点进行分析。除了开发者工具的用户界面外,开源 的命令行工具和 Node 模块使得可以将 Lighthouse 报告集成到持续集成和交付流水线中。

第十二章:与 HTML 一起工作

1995 年,网景公司委托软件开发者布兰登·艾奇创建一种旨在为网景导航器浏览器页面添加交互性的编程语言。作为回应,艾奇在 10 天内臭名昭著地开发了 JavaScript 的第一个版本。几年后,通过 ECMAScript 标准化的采纳,JavaScript 成为了跨浏览器的标准。

尽管早期尝试进行了标准化,但多年来,Web 开发人员与具有不同 JavaScript 引擎解释或功能的浏览器作斗争。流行的库,如 jQuery,有效地允许我们编写简单的跨浏览器 JavaScript。值得庆幸的是,今天的浏览器几乎共享了对该语言的统一实现,使 Web 开发人员能够编写与 HTML 页面交互的“纯净”(无库)JavaScript。

在处理 HTML 时,我们正在处理文档对象模型(DOM),这是 HTML 页面的数据表示。本章中的示例将回顾如何通过选择、更新和删除页面中的元素与 HTML 页面的 DOM 进行交互。

访问给定元素并找到其父元素和子元素

问题

您想要访问特定的网页元素,然后找到其父元素和子元素。

解决方案

给元素一个唯一的标识符:

<div id="demodiv">
  <p>
    This is text.
  </p>
</div>

使用document.getElementById()获取对特定元素的引用:

const demodiv = document.getElementById("demodiv");

通过parentNode属性找到其父元素:

const parent = demodiv.parentNode;

通过childNodes属性找到其子元素:

const children = demodiv.childNodes;

讨论

网页文档的组织结构类似于倒置的树,顶部元素位于根部,所有其他元素都在其下分支。除了根元素(HTML)外,每个元素都有一个父节点,所有元素都可以通过document访问。

有几种不同的技术可用于访问这些文档元素,或者在 DOM 中称为节点。如今,我们通过 DOM 的标准化版本(如 DOM Level 2 和 3)访问这些节点。不过,最初,一种事实上的技术是通过浏览器对象模型访问元素,有时称为 DOM Level 0。DOM Level 0 是由当时的主要浏览器公司网景发明的,并且自那时以来在大多数浏览器中得到了支持(或多或少)。在 DOM Level 0 中访问网页元素的关键对象是document对象。

最常用的 DOM 方法是document.getElementById()。它接受一个参数:元素的标识符,区分大小写的字符串。如果元素存在,则返回一个引用到该元素的element对象;否则返回 null。

注意

有许多方法可以获取一个特定的网页元素,包括后面章节中介绍的选择器的使用。但您始终希望使用可能性最小的方法,并且您无法比document.getElementById()更严格了。

返回的 element 对象具有一组方法和属性,包括从 node 对象继承的一些属性。node 方法主要与遍历文档树相关。例如,要查找元素的父节点,请使用以下代码:

const parent = document.getElementById("demodiv").parentNode;

您可以通过 nodeName 属性了解每个节点的元素类型:

const type = parent.nodeName;

如果您想要了解一个元素具有哪些子节点,可以通过 childNodes 属性遍历它们的集合:

let outputString = '';

if (demodiv.hasChildNodes()) {
  const children = demodiv.childNodes;
  children.forEach(child => {
    outputString += `has child ${child.nodeName} `;
  });
}
console.log(outputString);

针对解决方案中的元素,输出将是:

"has child #text has child P has child #text "

你可能会对出现的子节点感到惊讶。在这个例子中,段落元素前后的空白本身是一个具有 nodeName#text 的子节点。对于以下的 div 元素:

<div id="demodiv" class="demo">
  <p>Some text</p>
  <p>Some more text</p>
</div>

demodiv 元素(节点)有五个子节点,而不是两个:

has child #text
has child P
has child #text
has child P
has child #text

要看到 DOM 可以有多混乱,最好的方法是使用诸如 Firefox 或 Chrome 开发者工具之类的调试器,访问网页,然后利用调试器提供的任何 DOM 检查工具来显示元素树。我在 Chrome 中打开了一个简单的页面,并使用开发者工具显示了元素树,如 图 12-1 所示。

jsc3 1201

图 12-1. 使用 Chrome 开发者工具检查网页的元素树

使用 forEach() 遍历 querySelectorAll() 的结果

问题

您希望遍历从调用 querySelectorAll() 返回的 nodeList

解决方案

在现代浏览器中,使用 forEach() 处理 NodeList(由 querySelectorAll() 返回的集合)是可行的:

// use querySelectorAll to find all list items on a page
const items = document.querySelectorAll('li');

items.forEach(item => {
  console.log(item.firstChild.data);
});

讨论

forEach() 是一个数组方法,但 querySelectorAll() 返回的是一个 NodeList,它是一种与数组不同的对象类型。幸运的是,现代浏览器内置支持 forEach,允许我们像处理数组一样迭代 NodeList

不幸的是,Internet Explorer(IE)不支持以这种方式使用 forEach。如果您想要支持 IE,推荐的方法是包含一个使用标准 for 循环的 polyfill:

if (window.NodeList && !NodeList.prototype.forEach) {
  NodeList.prototype.forEach = function(callback, thisArg) {
    thisArg = thisArg || window;
    for (var i = 0; i < this.length; i++) {
      callback.call(thisArg, this[i], i, this);
    }
  };
}

在 polyfill 中,我们检查是否存在 NodeList.prototype.forEach。如果不存在,则向 NodeList 原型添加一个使用 for 循环来遍历 DOM 查询结果的 forEach 方法。通过这样做,您可以在整个代码库中自由地使用 forEach 语法。

为元素添加点击功能

问题

您需要在用户点击页面上的按钮、链接或元素时添加 JavaScript 功能。

解决方案

为元素添加一个 click 事件监听器:

// define an event handler function
const clickHandler = (event) => {
  window.alert('The element has been clicked!');
};

// select element
const btn = document.getElementById('click-button');
// add the event listener to the element and call 'clickHandler' function
btn.addEventListener('click', clickHandler);

讨论

addEventListener() 方法允许我们的 JavaScript 监听特定类型的事件,并定义一个在触发事件时调用的函数。在前面的示例中,我已经为按钮元素添加了一个 click 监听器。当按钮被点击时,将调用 clickHandler 函数,该函数触发一个警告框。

默认情 默认情况下,您应该使用button元素来处理可点击的事件处理程序,因为这是处理点击事件的最易访问的解决方案。如果需要,button元素可以样式化为链接,以适应应用程序的设计。然而,当希望在 JavaScript 加载失败时,链接到页面的后备行为是所需的行为时,使用一个元素是合适的。当这样做时,preventDefault事件方法允许您覆盖默认的链接行为:

const clickHandler = (event) => {
  event.preventDefault();
  window.alert(`The ${event.currentTarget.nodeName} element has been clicked!`);
};

const href = document.getElementById('click-link');
href.addEventListener('click', clickHandler);
小贴士

在传统的 JavaScript 函数中,this关键字会绑定到被点击的项目。然而,当使用 JavaScript 的新箭头函数语法,如本例中所示时,this的值是从父函数继承的,默认情况下是window。如果你习惯于非箭头函数语法,这可能会让人感到困惑。如果您有兴趣了解更多,我推荐阅读Joe Cardillo 的相关文章

在罕见的情况下,可能希望使一个块级元素,如div,变为可点击。我建议谨慎使用,尽可能选择button元素。然而,在这些情况下,您需要确保功能对使用屏幕阅读器和键盘导航的用户是可访问的。首先,在您的标记中应用rolebuttontabindex值。role属性将通知屏幕阅读器用户这是一个可点击的元素,而tabindex将使元素可以通过键盘导航:

<div tabindex="0" role="button" id="click-div">Click me</div>

在此实例中,我们使用keydown事件处理程序。这将允许键盘用户与元素进行交互:

const clickHandler = (event) => {
  window.alert(`The ${event.currentTarget.nodeName} element has been clicked!`);
};

const clickableDiv = document.getElementById('click-link');
clickableDiv.addEventListener('click', clickHandler);

// when using a div add a keydown event listener for keyboard users
clickableDiv.addEventListener('keydown', (event) => {
  if (event.code === 'Space' || event.code === 'Enter') {
    clickableDiv.click();
  }
});

查找所有具有相同属性的元素

问题

您想在网页文档中找到所有具有相同属性的元素。

解决方案

使用通用选择器*)结合属性选择器,查找所有具有属性的元素,无论其值是什么:

const elems = document.querySelectorAll('*[class]');

通用选择器也可以用来查找所有具有相同值的属性的元素:

const reds = document.querySelectorAll('*[class="red"]');

讨论

解决方案演示了一个相当优雅的查询选择器,通用选择器*)。通用选择器会评估所有元素,因此在需要验证每个元素的某些信息时,您应该使用它。在解决方案中,我们要找到所有具有特定属性的元素。

要测试一个属性是否存在,您只需在方括号内列出属性名称([attrname])。在解决方案中,我们首先测试元素是否包含class属性。如果存在,则与元素集合一起返回:

var elems = document.querySelectorAll('*[class]');

接下来,我们获取所有class属性值为red的元素。如果您不确定类名,可以使用子字符串匹配查询选择器:

const reds = document.querySelectorAll('*[class="red"]');

现在,任何包含子字符串red的类名都匹配。

你也可以修改语法以找到所有不具有特定值的元素。例如,要查找所有不具有目标类名的div元素,使用:not否定运算符:

const notRed = document.querySelectorAll('div:not(.red)');

访问特定类型的所有元素

问题

你想访问给定文档中的所有img元素。

解决方案

使用document.getElementsByTagName()方法,传入img作为参数:

const imgElements = document.getElementsByTagName('img');

讨论

document.getElementsByTagName()方法返回给定元素类型(如解决方案中的img标签)的节点集合(NodeList)。该集合可以像数组一样遍历,并且节点的顺序基于文档中元素的顺序(页面中的第一个img元素可通过索引 0 访问等):

const imgElements = document.getElementsByTagName('img');
for (let i = 0; i < imgElements.length; i += 1) {
  const img = imgElements[i];
  ...
}

如在“使用forEach()遍历querySelectorAll()的结果”中讨论的那样,NodeList集合可以像数组一样遍历,但它不是Array对象。你不能使用Array对象的方法,比如push()reverse(),在NodeList上。它的唯一属性是length,唯一的方法是item(),传入一个索引作为参数返回该位置的元素:

const img = imgElements.item(1); // second image

NodeList是一个有趣的对象,因为它是一个实时集合,这意味着在检索NodeList之后对文档进行的更改会反映在集合中。示例 12-1 展示了NodeList实时集合的功能,以及getElementsByTagName

在示例中,通过getElementsByTagName方法作为NodeList集合访问网页中的三个图像。length属性值为3,输出到控制台。紧接着,创建了一个新段落和img元素,并将img附加到段落中。为了在页面中追加这些段落,再次使用getElementsByTagName,这次使用段落标签(p)。我们实际上不关心段落,而是段落的父元素,通过每个段落上的parentNode属性找到它们。

新段落元素被附加到段落的父元素,并且先前访问的NodeList集合的长度属性再次被打印出来。现在,值为4,反映了新添加的img元素。

示例 12-1. 演示getElementsByTagNameNodeList实时集合属性
<!DOCTYPE html>
<html>
<head>
<title>NodeList</title>
</head>
<body>
  <p><img src="firstimage.jpg" alt="image description" /></p>
  <p><img src="secondimage.jpg" alt="image description" /></p>
  <p><img src="thirdimage.jpg" alt="image description" /></p>

<script>
  const imgs = document.getElementsByTagName('img');
  console.log(imgs.length);
  const p = document.createElement('p');
  const img = document.createElement('img');
  img.src = './img/someimg.jpg';
  p.appendChild(img);

  const paras = document.getElementsByTagName('p');
  paras[0].parentNode.appendChild(p);

  console.log(imgs.length);
</script>

</body>
</html>

示例 12-1 将在浏览器控制台中记录以下输出:

<img src="img/firstimage.jpg" alt="image description">
<img src="img/secondimage.jpg" alt="image description">
<img src="img/thirdimage.jpg" alt="image description">
3
4

除了使用getElementsByTagName()来获取特定元素类型之外,还可以将通用选择器(*)作为方法的参数传递,以获取所有元素:

const allElems = document.getElementsByTagName('*');

参见

在讨论中演示的代码中,使用传统的for循环遍历子节点。在现代浏览器中,可以直接使用NodeListforEach()方法,如在“使用 forEach() 遍历 querySelectorAll() 的结果”中所示。

使用选择器 API 发现子元素

问题

想要获取所有子元素实例的列表,比如img元素,这些子元素是父元素(如article元素)的后代,而不必遍历整个元素集合。

解决方案

使用选择器 API 并使用 CSS 样式选择器字符串访问包含在article元素中的img元素:

const imgs = document.querySelectorAll('article img');

讨论

有两种选择器查询 API 方法。第一种是querySelectorAll(),在解决方案中进行了演示;第二种是querySelector()。两者之间的区别在于querySelectorAll()返回匹配选择器条件的所有元素,而querySelector()只返回找到的第一个结果。

选择器语法源自 CSS 选择器语法,不同之处在于不会为所选元素设置样式,而是将它们返回给应用程序。例如,返回所有属于article元素后代的img元素。要访问所有img元素而不管父元素,请使用:

const imgs = document.querySelectorAll('img');

在解决方案中,您将获得所有直接或间接属于article元素的img元素。这意味着如果img元素包含在article内的div中,img元素将是返回结果之一:

<article>
   <div>
      <img src="..." />
   </div>
</article>

如果只想获取article元素的直接子级img元素,请使用以下方法:

const imgs = document.querySelectorAll('article > img');

如果你想访问紧随段落的所有img元素,请使用:

const imgs = document.querySelectorAll('img + p');

如果你对具有空alt属性的img元素感兴趣,请使用以下方法:

const imgs = document.querySelectorAll('img[alt=""]');

如果只对没有空alt属性的img元素感兴趣,请使用:

const imgs = document.querySelectorAll('img:not([alt=""])');

否定伪选择器(:not)用于查找所有具有非空alt属性的img元素。

与之前介绍的getElementsByTagName()返回的集合不同,从querySelectorAll()返回的元素集合是“活动”集合。如果更新发生在检索集合之后,则页面的更新不会反映在集合中。

注意

虽然选择器 API 是一个很棒的创建,但不应该用于每个文档查询。为了保持应用程序的性能,我建议在访问元素时始终使用最严格的查询可能性。例如,使用getElementById()来获取具有特定标识符的特定元素比使用querySelectorAll()更有效率(意味着对浏览器更快)。

参见

有三种不同的 CSS 选择器规范,分别标记为选择器 Level 1、Level 2 和 Level 3。CSS 选择器 Level 3 包含指定其他级别文档的链接。这些文档提供了不同类型选择器的定义和示例。

更改元素的类值

问题

要通过更改其类值来更新应用于元素的 CSS 规则。

解决方案

使用元素的 classList 属性来添加、删除和切换类值:

const element = document.getElementById('example-element');
// add a new class
element.classList.add('new-class');
// remove an existing class
element.classList.remove('existing-class');
// if toggle-me is present it is removed, if not it is added
element.classList.toggle('toggle-me');

讨论

使用 classList 可以轻松操作所选元素的类属性。这在更新或交换样式而不使用内联 CSS 时非常方便。有时,检查元素是否已应用类值也可能很有帮助,这可以通过 contains 方法实现:

if (element.classList.contains('new-class')) {
  element.classList.remove('new-class');
}

也可以通过将它们作为单独的属性传递或使用扩展运算符来添加、删除或切换多个类:

// add multiple classes
.classList.add("my-class", "another-class");

// remove multiple classes with a spread operator
const classes = ["my-class", "another-class"];
div.classList.remove(...classes);

设置元素的样式属性

问题

要直接添加或替换特定元素的内联样式。

解决方案

要作为内联样式更改一个 CSS 属性,请通过元素的 style 属性修改属性值:

elem.style.backgroundColor = 'red';

要修改单个元素的一个或多个 CSS 属性,可以使用 setAttribute() 并创建整个 CSS 样式规则:

elem.setAttribute('style',
  'background-color: red; color: white; border: 1px solid black');

这些技术为 HTML 元素设置了内联样式值,这些值将出现在 HTML 中。为了进一步演示,以下 JavaScript 在具有 ID card 的元素上设置了样式属性:

const card = document.getElementById('card');
card.setAttribute(
  'style',
  'background-color: #ecf0f1; color: #2c3e50;'
);

结果的 HTML 输出包含内联样式值:

<div id="card" style="background-color: #ecf0f1; color: #2c3e50;">
...
</div>

讨论

可以使用三种方法之一在 JavaScript 中修改元素的 CSS 属性。如解决方案所示,最简单的方法是直接使用元素的 style 属性设置属性值:

elem.style.width = '500px';

如果 CSS 属性包含连字符,例如 font-familybackground-color,则使用 CamelCase 符号法 设置属性:

elem.style.fontFamily = 'Courier';
elem.style.backgroundColor = 'rgb(255,0,0)';

CamelCase 符号法去除连字符并将其后的第一个字母大写。

你也可以使用 setAttribute()cssText 来设置 style 属性。在添加多个样式时很有用:

// using setAttribute
elem.setAttribute('style','font-family: Courier; background-color: yellow');

// alternately apply a value to style.cssText
elem.style.cssText = 'font-family: Courier; background-color: yellow';

setAttribute() 方法是向 Web 页面元素添加属性或替换现有属性值的一种方法。该方法的第一个参数是属性名(如果元素是 HTML 元素,则自动转换为小写),第二个是新的属性值。

在设置 style 属性时,所有更改的 CSS 属性必须同时指定,因为设置属性会擦除任何先前设置的值。然而,使用 setAttribute() 设置 style 属性不会擦除样式表中的任何设置,也不会由浏览器默认设置。

额外:访问现有样式设置

大多数情况下,访问现有属性值与设置它们一样简单。而不是使用setAttribute(),使用getAttribute()。例如,要获取类的值:

const className = elem.getAttribute('class');

然而,获取样式设置要复杂得多,因为特定元素的样式设置随时是所有设置的合成整体。这个元素的计算样式在你想要查看特定时刻元素的具体样式设置时可能是你最感兴趣的。幸运的是,有一个方法可以做到,即window.getComputedStyle(),它将返回应用于元素的当前计算样式:

const style = window.getComputedStyle(elem);

高级

与其使用setAttribute()添加或修改属性,你可以创建一个属性并使用createAttribute()将其附加到元素,以创建一个Attr节点,设置其值使用nodeValue属性,然后使用setAttribute()将属性添加到元素中:

const styleAttr = document.createAttribute('style');
styleAttr.nodeValue = 'background-color: red';
someElement.setAttribute(styleAttr);

你可以使用createAttribute()setAttribute()或直接使用setAttribute()来为元素添加任意数量的属性。这两种方法同样有效,所以除非确实有必要,你很可能想要使用更简单的方法直接设置属性名和值,使用setAttribute()

何时会使用createAttribute()?如果属性值将是另一个实体引用(如 XML 中允许的),你需要使用createAttribute()来创建一个Attr节点,因为setAttribute()仅支持简单字符串。

向新段落添加文本

问题

你想要创建一个包含文本的新段落并将其插入到文档中。

解决方案

使用createTextNode方法向元素添加文本:

const newPara = document.createElement('p');
const text = document.createTextNode('New paragraph content');
newPara.appendChild(text);

讨论

元素内的文本本身是 DOM 中的一个对象。它的类型是Text节点,并使用专门的方法createTextNode()创建。该方法接受一个参数:包含文本的字符串。

示例 12-2 显示一个包含四个段落的div元素的网页。JavaScript 从用户通过提示提供的文本创建一个新段落。文本同样可以来自服务器通信或其他过程。

提供的文本用于创建一个文本节点,然后将其作为子节点附加到新段落中。paragraph元素在第一个段落之前插入到网页中。

示例 12-2. 展示向网页添加内容的各种方法
<!DOCTYPE html>
<html>
<head>
<title>Adding Paragraphs</title>
</head>
<body>
<div id="target">
  <p>
    There is a language 'little known,'<br />
    Lovers claim it as their own.
  </p>
  <p>
    Its symbols smile upon the land, <br />
    Wrought by nature's wondrous hand;
  </p>
  <p>
    And in their silent beauty speak,<br />
    Of life and joy, to those who seek.
  </p>
  <p>
    For Love Divine and sunny hours <br />
    In the language of the flowers.
  </p>
</div>
<script>
  // use getElementById to access the div element
  const div = document.getElementById('target');

  // get paragraph text
  const txt = prompt('Enter new paragraph text', '');

  // use getElementsByTagName and the collection index
  // to access the first paragraph
  const oldPara = div.getElementsByTagName('p')[0];

  // create a text node
  const txtNode = document.createTextNode(txt);

  // create a new paragraph
  const para = document.createElement('p');

  // append the text to the paragraph, and insert the new para
  para.appendChild(txtNode);
  div.insertBefore(para, oldPara);
</script>
</body>
</html>
警告

直接将用户提供的文本插入网页而不先清理文本并不是一个好主意。一旦留下后门,各种恶意内容可能会悄悄爬进来。示例 12-2 仅用于演示目的。

在特定 DOM 位置插入新元素

问题

你想要在一个div元素内第三个段落之前插入一个新段落。

解决方案

使用某种方法访问第三段落,例如getElementsByTagName(),以获取div元素的所有段落。然后使用createElement()insertBefore() DOM 方法,在现有的第三段落之前添加新段落:

// get the target div
const div = document.getElementById('target');

// retrieve a collection of paragraphs
const paras = div.getElementsByTagName('p');

// create the element and append text to it
const newPara = document.createElement('p');
const text = document.createTextNode('New paragraph content');
newPara.appendChild(text);

// if a third para exists, insert the new element before
// otherwise, append the paragraph to the end of the div
if (paras[2]) {
  div.insertBefore(newPara, paras[2]);
} else {
  div.appendChild(newPara);
}

讨论

document.createElement()方法创建任何 HTML 元素,然后可以将其插入或附加到页面中。在解决方案中,使用insertBefore()在现有段落之前插入新段落元素。

因为我们希望在现有的第三段落之前插入新段落,所以需要检索div元素段落的集合,确保第三段落存在,然后使用insertBefore()在现有段落之前插入新段落。如果第三段落不存在,则可以使用appendChild()将元素附加到div元素的末尾。

检查复选框是否已选中

问题

您需要验证应用程序中的用户是否已选中复选框。

解决方案

选择复选框元素,并使用checked属性验证其状态。在此示例中,我选择了一个具有idcheck的 HTML input复选框元素,并监听点击事件。当事件触发时,运行validate函数,该函数查看元素的checked属性并将其状态记录到控制台:

const checkBox = document.getElementById('check');

const validate = () => {
  if (checkBox.checked) {
    console.log('Checkbox is checked')
  } else {
    console.log('Checkbox is not checked')
  }
}

checkBox.addEventListener('click', validate);

讨论

用户通常会看到一个复选框,以进行某种确认,例如接受服务条款。在这些情况下,通常会禁用按钮,除非用户已选中复选框。我们可以修改先前的示例以添加此功能:

const checkBox = document.getElementById('check');
const acceptButton = document.getElementById('accept');

const validate = () => {
  if (checkBox.checked) {
    acceptButton.disabled = false;
  } else {
    acceptButton.disabled = true;
  }
}

checkBox.addEventListener('click', validate);

在 HTML 表中累加数值

问题

您希望对表列中的所有数字求和。

解决方案

遍历包含数字字符串值的表列,将值转换为数字并对数字求和:

let sum = 0;

// use querySelectorAll to find all second table cells
const cells = document.querySelectorAll('td:nth-of-type(2)');

// iterate over each
cells.forEach(cell => {
  sum += Number.parseFloat(cell.firstChild.data);
});

讨论

:nth-of-type(n)选择器匹配元素的特定子元素(n)。通过使用td:nth-of-type(2),我们选择第二个td子元素。在示例 HTML 标记中,表格中第二个td元素是一个数值:

<td>Washington</td><td>145</td>

parseInt()parseFloat()方法将字符串转换为数字,但是在处理 HTML 表中的数字时,parseFloat()更具适应性。除非您确定所有数字都是整数,否则parseFloat()可以处理整数和浮点数。

示例 12-3 演示了如何转换和累加 HTML 表中的数字值,然后如何在末尾插入一个包含此总和的表行。代码使用了document.querySelectorAll(),这次使用了 CSS 选择器td + td的不同变体来访问数据。此选择器查找所有紧随另一个表格单元格之前的表格单元格。

示例 12-3. 将表格值转换为数字并汇总结果
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Adding Up Values in an HTML Table</title>
</head>
<body>
  <h1>Adding Up Values in an HTML Table</h1>
    <table>
      <tbody id="table1">
        <tr>
            <td>Washington</td><td>145</td>
        </tr>
        <tr>
            <td>Oregon</td><td>233</td>
        </tr>
        <tr>
            <td>Missouri</td><td>833</td>
        </tr>
      <tbody>
    </table>

    <script>
      let sum = 0;

      // use querySelector to find all second table cells
      const cells = document.querySelectorAll('td:nth-of-type(2)');

      // iterate over each
      cells.forEach(cell => {
        sum += Number.parseFloat(cell.firstChild.data);
      });

      // now add sum to end of table
      const newRow = document.createElement('tr');

      // first cell
      const firstCell = document.createElement('td');
      const firstCellText = document.createTextNode('Sum:');
      firstCell.appendChild(firstCellText);
      newRow.appendChild(firstCell);

      // second cell with sum
      const secondCell = document.createElement('td');
      const secondCellText = document.createTextNode(sum);
      secondCell.appendChild(secondCellText);
      newRow.appendChild(secondCell);

      // add row to table
      document.getElementById('table1').appendChild(newRow);
    </script>
</body>
</html>

能够对表格数据进行求和或其他操作在处理动态更新时非常有帮助,比如从数据库中访问数据行。获取的数据可能无法提供汇总值,或者直到网页读者选择执行操作时才想要提供汇总数据。用户可能希望操作表格结果,然后点击按钮执行求和操作。

向表格添加行非常简单,只要记住以下步骤:

  1. 使用document.createElement("tr")创建新的表格行。

  2. 使用document.createElement("td")创建每个表格行单元格。

  3. 使用document.createTextNode()创建每个表格行单元格的数据,传入节点的文本(包括数字,它们会自动转换为字符串)。

  4. 将文本节点附加到表格单元格。

  5. 将表格单元格附加到表格行。

  6. 将表格行附加到表格。反复进行。

Extra: forEach 和 querySelectorAll

在上述示例中,我使用forEach()方法迭代querySelectorAll()的结果,后者返回一个NodeList,而不是数组。尽管forEach()是数组方法,现代浏览器已经实现了NodeList.prototype.forEach(),使其能够使用forEach()语法迭代NodeList,正如在“使用 forEach()遍历 querySelectorAll()的结果”中讨论的那样。另一种方法是使用循环:

let sum = 0;

// use querySelector to find all second table cells
let cells = document.querySelectorAll("td:nth-of-type(2)");

for (var i = 0; i < cells.length; i++) {
  sum+=parseFloat(cells[i].firstChild.data);
}

Extra: 全局变量的模块化

作为模块化JavaScript 日益增长努力的一部分,parseFloat()parseInt() 方法现在作为新的静态方法附加到 Number 对象上,自 ECMAScript 2015 起:

// modular method
const modular = Number.parseInt('123');
// global method
const global = parseInt('123');

这些模块已经得到广泛的浏览器支持,但可以通过像 Babel 这样的工具或单独支持的方式进行旧版浏览器的填充:

if (Number.parseInt === undefined) {
  Number.parseInt = window.parseInt
}

从 HTML 表格中删除行

问题

你想要从 HTML 表格中移除一个或多个行。

解决方案

在 HTML 表格行上使用removeChild()方法,所有子元素,包括行单元格,也会被移除:

const parent = row.parentNode;
const oldrow = parent.removeChild(parent);

讨论

当你从网页文档中移除一个元素时,不仅仅是移除了该元素,还移除了它所有的子元素。在这个DOM 修剪过程中,如果你希望在完全丢弃之前处理其内容,可以获取对已移除元素的引用。后者在你希望在不小心选择错误的表格行时提供某种撤销方法时非常有帮助。

为了演示 DOM 剪枝的性质,在 示例 12-4 中,使用 DOM 方法 createElement()createTextNode() 创建表行和单元格,以及插入到单元格中的文本。每个表行创建时,都会附加到行的 click 事件处理程序。如果点击任何新的表行,将调用一个函数,从表中移除该行。然后遍历已移除的表行元素,并提取并连接其单元格中的数据到一个字符串中,然后将其打印出来。

示例 12-4. 添加和移除表行及其关联的表格单元格和数据
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Deleting Rows from an HTML Table</title>
    <style>
      table {
        border-collapse: collapse;
      }
      td,
      th {
        padding: 5px;
        border: 1px solid #ccc;
      }
      tr:nth-child(2n + 1) {
        background-color: #eeffee;
      }
    </style>
  </head>
  <body>
    <h1>Deleting Rows from an HTML Table</h1>
    <table id="mixed">
      <tr>
        <th>Value One</th>
        <th>Value two</th>
        <th>Value three</th>
      </tr>
    </table>

    <div id="result"></div>
    <script>
    // table values
    const values = new Array(3);
    values[0] = [123.45, 'apple', true];
    values[1] = [65, 'banana', false];
    values[2] = [1034.99, 'cherry', false];

    const mixed = document.getElementById('mixed');
    const tbody = document.createElement('tbody');

    function pruneRow() {
    // remove row
    const parent = this.parentNode;
    const oldRow = parent.removeChild(this);

    // dataString from removed row data
    let dataString = '';
    oldRow.childNodes.forEach(row => {
      dataString += `${row.firstChild.data} `;
    });

    // output message
    const msg = document.createTextNode(`removed ${dataString}`);
    const p = document.createElement('p');
    p.appendChild(msg);
    document.getElementById('result').appendChild(p);
    }

    // for each outer array row
    values.forEach(value => {
      const tr = document.createElement('tr');

      // for each inner array cell
      // create td then text, append
      value.forEach(cell => {
        const td = document.createElement('td');
        const txt = document.createTextNode(cell);
        td.appendChild(txt);
        tr.appendChild(td);
      });

      // attache event handler
      tr.onclick = pruneRow;

      // append row to table
      tbody.appendChild(tr);
      mixed.appendChild(tbody);
    });
    </script>
  </body>
</html>

隐藏页面部分

问题

您想要隐藏现有页面元素及其子元素,直到需要时。

解决方案

您可以设置 CSS visibility 属性来隐藏和显示元素:

msg.style.hidden = 'visible'; // to display
msg.style.hidden = 'hidden'; // to hide

或者您可以使用 CSS display 属性:

msg.style.display = 'block'; // to display
msg.style.display = 'none'; // to remove from display

讨论

CSS visibilitydisplay 属性都可以用于隐藏和显示元素。它们之间有一个主要区别会影响您选择使用哪个。

visibility 属性控制元素的视觉渲染,但其存在也会影响其他元素。当元素隐藏时,仍然占据页面空间。另一方面,display 属性完全从页面布局中移除元素。

display 属性可以设置为几种不同的值,但其中四种对我们特别感兴趣:

none

display 设置为 none 时,元素完全从显示中移除。

block

display 设置为 block 时,元素会像 block 元素一样对待,前后都有换行。

inline-block

display 设置为 inline-block 时,内容会像 block 元素一样格式化,然后像内联内容一样流动。

inherit

这是默认显示,并指定 display 属性从元素的父级继承。

还有其他值,但这些是我们在 JavaScript 应用中最有可能使用的。

除非使用绝对定位和隐藏元素,否则应使用 CSS display 属性。否则,该元素会影响页面布局,将后续元素推到右下角,具体取决于隐藏元素的类型。

还有一种方法可以将元素从页面视图中移除,即使用负左值将其完全移出屏幕。这在创建从左侧滑入的滑块元素时特别有效。这也是辅助技术(AT)设备建议使用的方法,当您希望内容由辅助技术设备呈现,但不在视觉上呈现时。

创建基于悬停的弹出信息窗口

问题

您想要创建一个互动,用户将鼠标悬停在缩略图图像上时显示附加信息。

解决方案

此交互基于四种不同的功能。

首先,您需要捕获每个图像缩略图的mouseovermouseout事件,以显示或移除弹出窗口。在以下代码中,跨浏览器事件处理程序附加到页面中的所有图像:

window.onload = () => {
  const imgs = document.querySelectorAll('img');
  imgs.forEach(img => {
    img.addEventListener(
      'mouseover',
      () => {
        getInfo(img.id);
      },
      false
    );

    img.addEventListener(
      'mouseout',
      () => {
        removeWindow();
      },
      false
    );
  });
};

其次,您需要访问悬停在其上的项目的某些内容,以了解如何填充弹出气泡。这些信息可以在页面中,或者您可以使用 Web 服务器通信获取信息:

function getInfo(id) {
  // get the data
}

第三,如果弹出窗口已经存在但没有显示,您需要显示弹出窗口,或者创建新窗口。在以下代码中,当 Web 服务器返回项目信息时,弹出窗口会在对象正下方创建,并且向右移动。使用getBoundingClientRect()方法确定弹出窗口应放置的位置,使用createElement()createTextNode()创建弹出窗口:

// compute position for pop-up
function compPos(obj) {
  const rect = obj.getBoundingClientRect();
  let height;
  if (rect.height) {
    height = rect.height;
  } else {
    height = rect.bottom - rect.top;
  }
  const top = rect.top + height + 10;
  return [rect.left, top];
}

function showWindow(id, response) {
  const img = document.getElementById(id);

  console.log(img);
  // derive location for pop-up
  const loc = compPos(img);
  const left = `${loc[0]}px`;
  const top = `${loc[1]}px`;

  // create pop-up
  const div = document.createElement('popup');
  div.id = 'popup';
  const txt = document.createTextNode(response);
  div.appendChild(txt);

  // style pop-up
  div.setAttribute('class', 'popup');
  div.setAttribute('style', `position: fixed; left: ${left}; top: ${top}`);
  document.body.appendChild(div);
}

最后,当mouseover事件触发时,您需要隐藏弹出窗口或将其移除——根据您的设置决定。因为应用程序在mouseover事件中创建了新的弹出窗口,所以它会在mouseout事件处理程序中移除弹出窗口:

function removeWindow() {
  const popup = document.getElementById('popup');
  if (popup) popup.parentNode.removeChild(popup);
}

讨论

如果您按照解决方案中概述的四个步骤简单操作,并且弹出窗口提供form元素的帮助,那么创建弹出信息或帮助窗口就不必复杂化。但是,如果您的页面上有数百个项目,通过 Web 服务调用按需获取弹出窗口信息会获得更好的性能。

当我定位示例中的弹出窗口时,我没有直接将其放在对象上方。原因是我没有捕获鼠标位置来使弹出窗口跟随光标移动,确保我不会直接移动鼠标指向弹出窗口。但是,如果我静态地将弹出窗口部分放在对象上方,Web 页面读者可能会将鼠标移到弹出窗口上,从而触发隐藏弹出窗口的事件……然后触发显示弹出窗口的事件,如此反复。这会产生闪烁效果,更不用说大量的网络活动了。

如果我允许鼠标事件继续(通过从任一事件处理程序函数返回true),那么当 Web 页面读者将鼠标移到弹出窗口上时,弹出窗口不会消失。但是,如果他们将鼠标从图像移到弹出窗口,然后移到页面的其余部分,将不会触发移除弹出窗口事件,弹出窗口就会留在页面上。

最佳方法是将弹出窗口直接放置在对象的下方(或侧边,或页面的特定位置),而不是直接覆盖对象。

验证表单数据

问题

您的 Web 应用程序使用 HTML 表单从用户那里收集数据。但在将数据发送到服务器之前,您希望确保数据格式正确、完整且有效,并向用户提供反馈。

解决方案

使用 HTML5 内置的表单验证属性,可以通过外部库进行字符串验证的扩展:

<form id="example" name="example" action="" method="post">
  <fieldset>
    <legend>Example Form</legend>
    <div>
      <label for="email">Email (required):</label>
      <input type="email" id="email" name="email" value="" required />
    </div>
    <div>
      <label for="postal">Postal Code:</label>
      <input type="text" pattern="[0-9]*" id="postal" name="url" value="" />
    </div>
    <div id="error"></div>
    <div>
      <input type="submit" value="Submit" />
    </div>
  </fieldset>
</form>

你可以使用独立的库,比如validator.js,在用户输入时进行有效性检查:

<script type="text/javascript">
  function inputValidator(id, value) {
    // check email validity
    if (id === 'email') {
     return validator.isEmail(value);
    }

    // check US postal code validity
    if (id === 'postal') {
     return validator.isPostalCode(value, 'US');
    }

    return false;
  }

  const inputs = document.querySelectorAll('#example input');

  inputs.forEach(input => {
    // fire an event each time an input value changes
    input.addEventListener('input', () => {
     // pass the input value to the validation function
     const valid = inputValidator(input.id, input.value);
     // if not valid set the aria-invalid attribute to true
     if (!valid && input.value.length > 0) {
       this.setAttribute('aria-invalid', 'true');
     }
    });
  });
</script>

讨论

到目前为止,我们不应该编写自己的表单验证程序。除非我们处理的是一些非常奇怪的表单行为和/或数据。所谓的奇怪,是指远远超出寻常的范围,试图将 JavaScript 库整合进去实际上比自己做更难——比如“表单字段值必须是字符串,除了星期四必须是数字,但在偶数月份则相反”的验证类型。

您有很多库选项,我只演示了其中一个。validator.js库是一个不错的、简单易用的库,可以为多种不同类型的字符串提供验证。它也不要求您修改表单字段,这意味着只需轻松地将其插入即可,而不必重新设计表单。所有的样式和错误消息的放置也取决于开发者。

在解决方案中,代码为每个input元素添加了事件监听器。当用户对字段进行任何更改时,将触发input事件监听器,并调用inputValidator函数,该函数使用validator.js库检查值。如果值无效,将使用最小的 CSS 样式向输入字段添加红色边框。当值有效时,则不添加样式。

有时候,您可能需要一个专门用于某种类型数据验证的小型库。信用卡是棘手的东西,虽然您可以确保正确的格式,但其中包含的值必须满足特定规则,才能被视为有效的信用卡提交。

除了其他验证库,你还可以集成信用卡验证库,比如Payment,它提供了一个直接的验证 API。例如,在表单加载后指定字段为信用卡号码:

const cardInput = document.querySelector('input.cc-num');

Payment.formatCardNumber(cardInput);

然后,在提交表单时验证信用卡号码:

var valid = Payment.fns.validateCardNumber(cardInput.value);

if (!valid) {
  message.innerHTML = 'You entered an invalid credit card number';
  return false;
}

该库不仅检查格式,还确保值符合所有主要信用卡公司的有效卡号。根据您如何处理信用卡,支付处理器在客户端代码中可能提供类似的功能。例如,支付处理器 Stripe 的Stripe.js包括信用卡验证 API。

最后,您可以使用客户端和服务器验证,使用相同或不同的库。在此示例中,我们在浏览器中使用validator.js,但它也可以用于 Node 应用程序中的后端验证。

附加:HTML5 表单验证技术

HTML5 提供了相当广泛的内置表单验证功能,无需 JavaScript,包括:

minmax

数字输入的最小值和最大值

minlengthmaxlength

字符串输入的最小和最大长度

pattern

输入必须遵循的正则表达式模式

required

必填输入必须在提交表单之前完成

type

允许开发人员为输入指定内容类型,例如日期、电子邮件地址、数字、密码、URL 或其他特定预设类型。

此外,可以使用 CSS 伪选择器匹配 :valid:invalid 输入。

因此,对于简单的表单,您可能根本不需要 JavaScript。如果您需要对表单验证的外观和行为有限控制,建议使用 JavaScript 库,而不是依赖 HTML5 和 CSS 的表单验证规范。不过,确保将无障碍功能整合到您的表单中。我建议阅读WebAIM 的“创建无障碍表单”

突出显示表单错误和无障碍功能

问题

您希望突出显示输入有错误的表单字段,并确保对所有网页用户都有效果。

解决方案

使用 CSS 突出显示输入错误的表单字段,并使用 WAI-ARIA(Web Accessibility Initiative-Accessible Rich Internet Applications)标记,以确保所有用户都能看到突出显示:

[aria-invalid] {
  background-color: #f5b2b2;
}

对需要验证的字段,将一个函数分配给表单字段的 oninput 事件处理程序,以检查字段值是否有效。如果值无效,同时显示有关错误的信息,并突出显示字段:

function validateField() {
  // check for number
  if (typeof this.value !== 'number') {
    this.setAttribute('aria-invalid', 'true');
    generateAlert(
      'You entered an invalid value. Only numeric values are allowed'
    );
  }
}

document.getElementById('number').oninput = validateField;

对需要必填值的字段,将一个函数分配给字段的 onblur 事件处理程序,以检查是否已输入值:

function checkMandatory() {
  // check for data
  if (this.value.length === 0) {
    this.setAttribute('aria-invalid', 'true');
    generateAlert('A value is required in this field');
  }
}

document.getElementById('required-field').onblur = checkMandatory;

如果任何验证检查作为表单提交的一部分执行,请确保在验证失败时取消提交事件。

讨论

WAI-ARIA 提供了一种标记特定字段和行为的方式,使辅助设备为需要这些设备的人提供相应的行为。如果使用屏幕阅读器,将 aria-invalid 属性设置为 true(或添加到元素中)应触发屏幕阅读器中的听觉警告,这与为不使用辅助技术的人员提供的颜色指示器相当。

注意

Web Accessibility Initiative at the W3C上了解更多关于 WAI-ARIA 的信息。在 Windows 上,我推荐使用NVDA,这是一个开源免费的屏幕阅读器,用于测试您的应用程序是否与屏幕阅读器响应如您所预期。在 macOS 上,建议使用内置的 VoiceOver 工具结合 Safari 浏览器。

此外,role 属性可以设置为几个值,其中一个“alert”,在屏幕阅读器中触发类似的行为(通常将字段内容读出)。

在验证表单元素时,提供这些线索是至关重要的。您可以在提交之前验证表单,并提供关于所有错误的文本描述。然而,更好的方法是在用户完成时为每个字段验证数据,这样他们最终不会被留下大量令人恼火的错误消息。

在验证字段时,您可以确保用户准确知道哪个字段失败了,通过使用视觉指示器。这不应是标记错误的唯一方法,但这是一种额外的礼貌。

如果您用颜色突出显示错误的表单字段条目,请避免与背景颜色难以区分的颜色。如果表单背景是白色,并且您使用深黄色、灰色、红色、蓝色、绿色或其他颜色,有足够的对比度,无论查看页面的人是否色盲。在示例中,我在表单字段中使用了较深的粉色。

直接设置颜色可能更为直接,但通过一个 CSS 设置来处理更新更为合理 —— 设置 aria-invalid 和改变颜色。幸运的是,CSS 属性选择器 在这方面简化了我们的任务。

除了使用颜色外,您还需要提供一个关于错误的文本描述,这样用户就不会对问题感到困惑。

如何显示信息也是一个重要考虑因素。我们都不喜欢使用警报框,如果可能的话。警报框可能会遮挡表单,访问表单元素的唯一方式是解除带有错误消息的警报。更好的方法是将信息嵌入页面,靠近表单附近。我们还希望确保错误消息对使用辅助技术(如屏幕阅读器)的人员可用。通过为包含屏幕阅读器或其他辅助技术设备的警报元素分配 ARIA alert role 来轻松实现这一点。

使用 aria-invalid 的另一个额外好处是,在提交表单时,它可以用来发现所有不正确的字段。只需搜索所有具有该属性的元素,如果发现任何错误字段值,就知道还需要更正。

示例 12-5 演示了如何突出显示一个表单元素中的无效输入,并突出显示另一个中缺少的数据。该示例还捕获表单提交,并检查是否仍然设置了任何无效表单字段标志。只有在一切清楚的情况下,才允许表单提交继续。

示例 12-5. 在验证表单字段时提供视觉和其他线索
<!DOCTYPE html>
<head>
<title>Validating Forms</title>
<style>
[aria-invalid] {
   background-color: #ffeeee;
}

[role="alert"] {
  background-color: #ffcccc;
  font-weight: bold;
  padding: 5px;
  border: 1px dashed #000;
}

div {
  margin: 10px 0;
  padding: 5px;
  width: 400px;
  background-color: #ffffff;
}
</style>
</head>
<body>

<form id="testform">
   <div><label for="firstfield">*First Field:</label><br />
      <input id="firstfield" name="firstfield" type="text" aria-required="true"
      required />
   </div>
   <div><label for="secondfield">Second Field:</label><br />
      <input id="secondfield" name="secondfield" type="text" />
   </div>
   <div><label for="thirdfield">Third Field (numeric):</label><br />
      <input id="thirdfield" name="thirdfield" type="text" />
   </div>
   <div><label for="fourthfield">Fourth Field:</label><br />
      <input id="fourthfield" name="fourthfield" type="text" />
   </div>

   <input type="submit" value="Send Data" />
</form>

<script>

  document.getElementById("thirdfield").onchange=validateField;
  document.getElementById("firstfield").onblur=mandatoryField;
  document.getElementById("testform").onsubmit=finalCheck;

  function removeAlert() {

    var msg = document.getElementById("msg");
    if (msg) {
      document.body.removeChild(msg);
    }
  }

  function resetField(elem) {
    elem.parentNode.setAttribute("style","background-color: #ffffff");
    var valid = elem.getAttribute("aria-invalid");
    if (valid) elem.removeAttribute("aria-invalid");
  }

  function badField(elem) {
    elem.parentNode.setAttribute("style", "background-color: #ffeeee");
    elem.setAttribute("aria-invalid","true");
  }

  function generateAlert(txt) {

    // create new text and div elements and set
    // Aria and class values and id
    var txtNd = document.createTextNode(txt);
    msg = document.createElement("div");
    msg.setAttribute("role","alert");
    msg.setAttribute("id","msg");
    msg.setAttribute("class","alert");

    // append text to div, div to document
    msg.appendChild(txtNd);
    document.body.appendChild(msg);
  }

  function validateField() {

    // remove any existing alert regardless of value
    removeAlert();

    // check for number
    if (!isNaN(this.value)) {
      resetField(this);
    } else {
      badField(this);
      generateAlert("You entered an invalid value in Third Field. " +
                    "Only numeric values such as 105 or 3.54 are allowed");
    }
  }

  function mandatoryField() {

    // remove any existing alert
    removeAlert();

    // check for value
    if (this.value.length > 0) {
      resetField(this);
    } else {
      badField(this);
      generateAlert("You must enter a value into First Field");
    }
  }

  function finalCheck() {

    removeAlert();
    var fields = document.querySelectorAll("[aria-invalid='true']");
    if (fields.length > 0) {
      generateAlert("You have incorrect field entries that must be fixed " +
                     "before you can submit this form");
      return false;
    }
  }

</script>

</body>

如果应用程序中任一经过验证的字段不正确,则在该字段中将aria-invalid属性设置为true,并在错误消息上设置 ARIA rolealert,如图 12-2 所示。当错误被纠正时,aria-invalid属性被移除,警报消息也被移除。这两者都会改变表单字段的背景颜色。

jsc3 1202

图 12-2. 突出显示不正确的表单字段

请注意代码中,当输入的数据正确时,包裹目标表单字段的元素被设置为其正确状态,这样当字段被更正时,它不会在下一轮中显示为不准确或缺失。我移除现有的消息警报,无论之前的事件如何,因为它对新事件不再有效。

您还可以禁用或甚至隐藏正确输入的表单元素,以突出显示那些具有不正确或缺失数据的元素。然而,我不推荐这种方法。用户可能会发现,当他们填写缺失信息时,其他字段中的答案是不正确的。如果让他们难以更正字段,他们将不会对体验或提供表单的公司、个人或组织感到满意。

您可以采取的另一种方法是仅在提交表单时进行验证。许多内置库都是这样操作的。与其在用户通过时检查每个字段是否具有必填或正确的值,不如在提交表单时应用验证规则。这样,希望以不同顺序填写表单的用户可以在不受到烦人的验证消息干扰的情况下这样做。

使用 JavaScript 突出显示具有不正确和缺失数据的表单字段只是表单提交过程的一部分。您还必须考虑到 JavaScript 被关闭的情况,这意味着在服务器上处理表单信息并在单独页面上提供结果时,您必须提供相同级别的反馈。

标记表单字段是否为必填也很重要。在表单字段标签中使用星号,并注明所有带星号的表单字段都是必填的。使用aria-required属性确保这些信息传达给使用辅助设备的人。我还建议在使用aria-required时使用 HTML5 的required属性,这提供了内置的浏览器验证。

参见

在“验证表单数据”中,我介绍了简化表单验证的表单验证库和模块。我还涉及使用 HTML5 声明性表单验证技术。

创建一个可访问的自动更新区域

问题

您有一个网页的部分定期更新,例如列出文件的最新更新或反映某个主题的最近 Twitter 活动的部分。您希望在页面更新时,使用屏幕阅读器的用户能够收到新信息的通知。

解决方案

在正在更新的元素上使用 WAI-ARIA region 属性:

<div id="update" role="log" aria-live="polite" aria-atomic="true"
aria-relevant="additions">
</div>

讨论

页面加载后可以更新的网页部分,且无需直接用户干预,需要使用 WAI-ARIA Live Regions。这可能是最简单的 ARIA 功能之一,而且它们提供即时且积极的结果。除了创建页面更新所需的 JavaScript 之外,不需要其他代码。

<div id="update" role="log" aria-live="polite" aria-atomic="true"
aria-relevant="additions"></div>

从左到右,role 设置为 log,用于从文件轮询日志更新时使用。其他选项包括 status,用于状态更新,以及更通用的 region 值,用于未确定的目的。

aria-live 区域属性设置为 polite,因为更新不是关键性更新。polite 设置告诉屏幕阅读器朗读更新,但不会中断当前任务。如果我使用 assertive 值,屏幕阅读器会中断正在执行的任务并朗读内容。除非信息很关键,否则始终使用 polite

aria-atomic 设置为 false,因此屏幕阅读器仅朗读基于 aria-relevant 设置的新添加内容。如果将此值设置为 true,屏幕阅读器每次添加新内容时都会朗读整个集合,可能会非常恼人。

最后,aria-relevant 设置为 additions,因为我们不关心从顶部删除的条目。这实际上是此属性的默认设置,所以在技术上是不需要的。此外,辅助技术设备不必支持此属性。不过,我宁愿列出它而不是不列出。其他值包括 removalstextall(表示所有事件)。您可以用空格分隔多个值。

这个启用了 WAI-ARIA 功能的功能可能是给我留下印象最深刻的一个。很多年前,我首次使用远程数据获取,用于更新网页信息。当时,测试带屏幕阅读器(当时是 JAWS)的页面时,每次页面更新都听到一片寂静,真是令人沮丧。我甚至无法想象那些需要这功能的人会有多么沮丧。

现在我们有它了,使用起来非常简单。这是一举两得。

第十三章:获取远程数据

在浏览器中接收和处理数据的能力,无需刷新页面,是 JavaScript 的超级能力之一。实时数据跟踪器、聊天应用程序、社交媒体动态更新等,都是通过 JavaScript 发出请求到服务器并更新页面内容来实现的。在本章中,我们将介绍如何发出和处理这些请求。

注意

您可能也会听到 “AJAX” 这个术语,它是 Asynchronous JavaScript and XML 的缩写。虽然最初是指检索 XML,但 AJAX 已经成为从 Web 浏览器向远程服务器检索和发送数据的泛化术语。

使用 Fetch 请求远程数据

问题

您需要从服务器请求远程数据。

解决方案

使用 Fetch API,允许您发出请求并处理响应。要发出简单请求,请将 URL 作为 fetch 参数传递,该方法将返回一个 promise。以下示例请求 URL,解析 JSON 响应,并将响应记录到控制台:

const url = 'https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY';
fetch(url)
  .then(response => response.json())
  .then(data => console.log(data));

或者,使用 fetchasync/await 语法:

const url = 'https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY';

async function fetchRequest() {
  const response = await fetch(url);
  const data = await response.json();
  console.log(data);
}

fetchRequest();

讨论

Fetch API 提供了从远程源发送和检索数据的方法。在 Web 浏览器环境中工作时,这意味着可以在不刷新页面的情况下检索数据。作为 Web 用户,您可能经常遇到这些类型的请求。Fetch API 可用于:

  • 在社交媒体动态中加载额外的条目

  • 表单自动完成建议

  • “喜欢” 一个社交媒体帖子

  • 基于先前响应更新表单字段值

  • 在不离开页面的情况下提交表单

  • 将商品添加到购物车

正如您所想象的那样,列表可以无限延续。

fetch() 方法接受两个参数:

url(必填)

正在进行请求的 URL

options

发出请求时的选项对象

可能的 options 包括:

body

请求的请求体内容

cache

请求的缓存模式 (defaultno-storereloadno-cacheforce-cacheonly-if-cached)

credentials

请求的凭证 (omitsame-origininclude)

headers

请求中包含的头部信息

integrity

用于验证资源的子资源完整性值

keepalive

将其设置为 true,使请求在页面生存期内继续存在

method

请求方法 (GETPOSTPUTDELETE)

mode

请求的模式 (corsno-corssame-origin)

redirect

设置重定向的行为 (followerrormanual)

referrer

设置引用者头部的值 (about:client、当前 URL 或空字符串)

referrerPolicy

指定引用者策略 (no-referrerno-referrer-when-downgradesame-originoriginstrict-originorigin-when-cross-originstrict-origin-when-cross-originunsafe-url)

signal

AbortController 对象用于中止请求

如前例所示,仅需 url 参数即可。当仅传递 URL 时,fetch 方法将执行 GET 请求。以下示例演示如何使用选项对象:

const response = await fetch(url, {
  method: 'GET',
  mode: 'cors',
  credentials: 'omit',
  redirect: 'follow',
  referrerPolicy: 'no-referrer'
});

fetch 使用 JavaScript promises。最初的 promise 返回一个 Response 对象,其中包含完整的 HTTP 响应,包括主体、头部、状态码、重定向信息、cors 类型和 URL。使用返回的响应,然后可以使用额外的解析方法来解析请求的主体。在示例中,我使用 json() 方法将响应体解析为 JSON。以下是可能的解析方法:

arrayBuffer()

将响应体解析为 ArrayBuffer

blob()

将响应体解析为 Blob

json()

将响应体解析为 JSON

text()

将响应体解析为 UTF-8 字符串

formData()

将响应体解析为 FormData() 对象

在使用 fetch 时,可以根据服务器的状态响应处理错误。在 async/await 中:

async function fetchRequestWithError() {
  const response = await fetch(url);
  if (response.status >= 200 && response.status < 400) {
    const data = await response.json();
    console.log(data);
  } else {
    // Handle server error
    // example: INTERNAL SERVER ERROR: 500 error
    console.log(`${response.statusText}: ${response.status} error`);
  }
}

若要进行更健壮的错误处理,可以将整个 fetch 请求包装在 try/catch 块中,以便处理任何其他错误:

async function fetchRequestWithError() {
  try {
    const response = await fetch(url);
    if (response.status >= 200 && response.status < 400) {
      const data = await response.json();
      console.log(data);
    } else {
      // Handle server error
      // example: INTERNAL SERVER ERROR: 500 error
      console.log(`${response.statusText}: ${response.status} error`);
    }
  } catch (error) {
    // Generic error handler
    console.log(error);
  }
}

在使用 JavaScript 的 then promise 语法时,错误处理方式类似:

fetch(url)
  .then((response) => {
    if (response.status >= 200 && response.status < 400) {
      return response.json();
    } else {
      // Handle server error
      // example: INTERNAL SERVER ERROR: 500 error
      console.log(`${response.statusText}: ${response.status} error`);
    }
  })
  .then((data) => {
    console.log(data)
  }).catch(error) => {
    // Generic error handler
    console.log(error);
  };

如果您以前使用过 AJAX 请求,可能已经使用过 XMLHttpRequest(XHR)方法(在 “使用 XMLHttpRequest” 中介绍)。由于其基于 promise 的语法、简单的语法和广泛的浏览器支持,现在推荐使用 Fetch API 进行这些请求。fetch 在所有现代浏览器(Chrome、Edge、Firefox、Safari)中都受支持,但不支持 Internet Explorer。如果您的应用程序需要支持较旧版本的 Internet Explorer,可以选择使用 XHR(XMLHttpRequest)或使用 fetch polyfillpromise polyfill

使用 XMLHttpRequest

问题

您的应用程序需要请求远程数据,同时支持较旧的浏览器。

解决方案

使用 XMLHttpRequest(XHR)来替代 fetch。以下是一个 XHR GET请求示例,与 “使用 Fetch 请求远程数据” 的示例相似:

const url = 'https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY';
const request = new XMLHttpRequest();
request.open('GET', url);
request.send();

request.onload = () => {
  if (request.status >= 200 && request.status < 400) {
    // successful request logs the returned JSON data
    const data = JSON.parse(request.response);
    console.log(data);
  } else {
    // server error
    // example: INTERNAL SERVER ERROR: 500 error
    console.log(`${request.statusText}: ${request.status} error`);
  }
};

// request error
request.onerror = () => console.log(request.statusText);

讨论

XMLHttpRequest 是发起远程数据请求的原始语法。尽管名称中含有 XML,但它可以用于请求各种数据。在前面的示例中,我正在请求 JSON 数据。那么 XMLHttpRequestfetch 有何不同?

  • fetch 大量使用 JavaScript promises,而 XMLHttpRequest 则基于 XMLHttpRequest() 构造函数。

  • XMLHttpRequest 在所有浏览器中都受支持,包括较旧版本的 Internet Explorer。在 Internet Explorer 11 或更早的某些版本以及 2017 年或更早的某些版本的现代自动更新浏览器中,fetch 将无法正常工作,除非使用基于 XMLHttpRequest 的 polyfill。

  • XMLHttpRequest 默认将每个请求发送到服务器的 cookie,而 fetch 要求明确设置 credentials 选项。

  • XMLHttpRequest 支持跟踪上传进度,而 fetch 目前仅支持下载进度。

  • fetch 不支持超时,请求的长度由用户的浏览器决定。

尽管本章的其余部分将使用现代的 fetch 语法,但由于其浏览器支持和不同的特性,XMLHttpRequest 仍然是一个合理的选择,特别是在处理传统应用程序时。

提交表单

问题

您希望从客户端提交一个表单。

解决方案

使用 fetch 使一个 FormData 对象的 POST 请求:

const myForm = document.getElementById('my-form');
const url = 'http://localhost:8080/';

myForm.addEventListener('submit', async event => {
  event.preventDefault();

  const formData = new FormData(myForm);
  const response = await fetch(url, {
    method: 'post',
    body: formData
  });

  const result = await response.text();
  alert(result);
});

讨论

在示例代码中,我使用 getElementById 选择一个 HTML 表单元素,并将 POST 表单的 URL 存储为一个变量。在这种情况下,我将表单 POST 到本地开发服务器,如示例 13-1 所示。然后我为表单添加了一个事件监听器,并阻止了默认的表单提交行为,以便我可以使用 fetch 执行 JavaScript 的 POST 请求。

完整的 HTML 标记和 JavaScript 如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Form POST</title>
  </head>
  <body>
    <h1>Form POST HTML</h1>

    <form id="my-form">
      <label for="name">Name:</label>
      <input type="text" id="name" name="name" />

      <label for="mail">E-mail:</label>
      <input type="email" id="mail" name="email" />

      <label for="msg">Message:</label>
      <textarea id="message" name="message"></textarea>

      <button>Submit</button>
    </form>

    <script>
      const myForm = document.getElementById('my-form');
      const url = 'http://localhost:8080/';

      myForm.addEventListener('submit', async event => {
      event.preventDefault();

      const formData = new FormData(myForm);
      const response = await fetch(url, {
      method: 'post',
      body: formData
      });

      const result = await response.text();
      alert(result);
      });

    </script>
  </body>
</html>

JavaScript 的 FormData 提供了一种简便的方式来创建所有表单数据的键/值对。这适用于基于文本的表单元素,如示例中所示,以及文件上传。首先,使用 FormData 构造函数:

const myForm = document.getElementById('my-form');
const formData = new FormData(myForm);

您还可以使用一些有用的方法来操作 FormData 中包含的数据:

FormData.append(key, value)FormData.append(key, blob, filename)

向表单添加新数据

FormData.delete(key)

删除一个字段

FormData.set(key, value)

添加新数据,如果存在重复键,则删除

这是您如何向上一个示例中添加一个额外字段的方法:

const myForm = document.getElementById('my-form');
const url = 'http://localhost:8080/';

myForm.addEventListener('submit', async event => {
  event.preventDefault();

  const formData = new FormData(myForm);
  // add a new field using FormData.append
  formData.append('user', true);

  const response = await fetch(url, {
    method: 'post',
    body: formData
  });

  const result = await response.text();
  console.log(result);
});

现在 POST 请求的主体将是:

{
  name: 'Adam',
  email: 'adam@example.com',
  message: 'Hello',
  user: 'true'
}

也可以使用 gethas 方法处理表单值:

FormData.get(key)

获取特定键的值

FormData.has(key)

检查给定键的值并返回布尔值

虽然 FormData 非常有用,但它并不是 POST 请求体的唯一值类型。以下类型可以通过 POST 请求发送:

  • 一个字符串

  • 一个编码的字符串,例如 JSON 或 XML

  • 一个 URLSearchParams 对象

  • 二进制数据的 BlobBufferSource

在 “从服务器填充选择列表” 中,我将演示如何使用 fetch 发送 JSON 的 POST 请求。

最后,示例 13-1 是一个 Node.js Express 服务器的示例,用于处理请求:

示例 13-1. Express 表单服务器示例
const express = require('express');
const formidable = require('formidable');
const cors = require('cors');

const app = express();
const port = 8080;

app.use(cors());

app.get('/', (req, res) =>
  res.send('Example server for receiving JS POST requests')
);

app.post('/', (req, res) => {
  const form = formidable();

  form.parse(req, (err, fields) => {
    if (err) {
      return;
    }
    console.log('POST body:', fields);
    res.sendStatus(200);
  });
});

app.listen(port, () =>
  console.log(`Example app listening at http://localhost:${port}`)
);
注意

我们在 第二十一章 中详细介绍了 Express。

从服务器填充选择列表

问题

基于用户对另一个表单元素的操作,您希望用值填充一个选择列表。

解决方案

捕获表单元素的 change 事件:

const niceThings = document.getElementById('nice-thing');
niceThings.addEventListener('change', async () => {
  // GET request and events go here
});

在事件处理函数中,以 JSON 格式作为表单数据进行 POST 请求:

const niceThings = document.getElementById('nice-thing');
const url = 'http://localhost:8080/select';

// perform GET request when select value changes
niceThings.addEventListener('change', async () => {
  // object containing select value
  const selection = {
    niceThing: niceThings.value
  };

  // GET request to server
  const response = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/json;charset=utf-8'
    },
    body: JSON.stringify(selection)
  });

});

用结果填充选择列表:

const select = document.getElementById('nicestuff');

if (response.ok) {
  const result = await response.json();
  // empty the select element
  select.length = 0;
  // add a default display option with text and no value
  select.options[0] = new Option('--Please choose an option--', '');
  // populate the select with the returned values
  for (let i = 0; i < result.length; i += 1) {
    select.options[select.length] = new Option(result[i], result[i]);
  }
  // display the select element
  select.style.display = 'block';
} else {
  // if there's a problem fetching the data, display an error
  alert('Error');
  }

讨论

基于用户的选择填充 select 或其他表单元素是常见的用户界面交互。您可以捕获用户在另一个表单元素中的选择,根据该值查询服务器应用程序,并基于该值构建其他表单元素,而无需离开页面,而不是填充带有许多选项的 select 元素,或者构建一组 10 或 20 个单选按钮。

示例 13-2 展示了一个简单的页面,捕获了选择元素的变化事件,使用所选值进行 fetch 请求,并通过解析返回的数据来填充新的选择列表。在示例中,数据作为数组返回,并且新选项的创建使用返回的文本,文本同时作为选项标签和选项值。在填充 select 元素之前,将其长度设置为 0. 这是截断 select 元素的快速简便方法——删除所有现有选项并重新开始。

示例 13-2. 创建按需 select 列表
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Select List</title>
    <style>
      #nicestuff {
        display: none;
        margin: 10px 0;
      }

      label,
      legend {
        display: block;
        font-size: 1.6rem;
        font-weight: 700;
        margin-bottom: 0.5rem;
      }
    </style>
  </head>
  <body>
    <h1>Select List</h1>

    <form id="my-form">
      <label for="pet-select">Select a nice thing:</label>

      <select name="nicething" id="nice-thing">
        <option value="">--Please choose an option--</option>
        <option value="birds">Birds</option>
        <option value="flowers">Flowers</option>
        <option value="sweets">Sweets</option>
        <option value="critters">Cute Critters</option>
      </select>
      <select id="nicestuff">
        <option value="">--Please choose an option--</option>
      </select>
    </form>
    <script>
    const niceThings = document.getElementById('nice-thing');
    const select = document.getElementById('nicestuff');
    const url = 'http://localhost:8080/select';

    // perform GET request when select value changes
    niceThings.addEventListener('change', async () => {
    // object containing select value
    const selection = {
      niceThing: niceThings.value
    };

    // GET request to server
    const response = await fetch(url, {
      method: 'post',
      headers: {
        'Content-Type': 'application/json;charset=utf-8'
      },
      body: JSON.stringify(selection)
    });

    // if fetch is successful
    if (response.ok) {
      const result = await response.json();
      // empty the select element
      select.length = 0;
      // add a default display option with text and no value
      select.options[0] = new Option('--Please choose an option--', '');
      // populate the select with the returned values
      for (let i = 0; i < result.length; i += 1) {
        select.options[select.length] = new Option(result[i], result[i]);
      }
      // display the select element
      select.style.display = 'block';
    } else {
      // if there's a problem fetching the data, display an error
      alert('Error');
    }
    });

    </script>
  </body>
</html>

该示例使用 Node 应用程序来填充选择列表,但可以使用任何服务器端编程语言编写。有关详细信息,请参阅第 III 部分中的 Node 章节。

const express = require('express');
const formidable = require('formidable');
const cors = require('cors');

const app = express();
const port = 8080;

app.use(cors());

app.get('/', (req, res) =>
  res.send('Example server for receiving JS POST requests')
);

app.post('/select', (req, res) => {
  const form = formidable();

  form.parse(req, (err, fields) => {
    if (err) {
      return;
    }
    if (fields.niceThing === 'critters') {
      res.send(['puppies', 'kittens', 'guinea pigs']);
    } else if (fields.niceThing === 'sweets') {
      res.send(['licorice', 'cake', 'cookies', 'custard']);
    } else if (fields.niceThing === 'birds') {
      res.send(['robin', 'mockingbird', 'finch', 'dove']);
    } else if (fields.niceThing === 'flowers') {
      res.send(['roses', 'lilys', 'daffodils', 'pansies']);
    } else {
      res.send(['No Nice Things Found']);
    }
  });
});

app.listen(port, () =>
  console.log(`Example app listening at http://localhost:${port}`)
);

逐步构建表单元素并非所有应用都必要,但对于数据可能变化或表单复杂的情况下,这是确保更有效的表单的一个很好的方法。

解析返回的 JSON

问题

您希望安全地从 JSON 创建 JavaScript 对象。您还希望将 true 和 false 的数值表示(分别为 1 和 0)替换为它们的布尔对应值(truefalse)。

解决方案

使用 JSON.parse 功能解析对象。要将数值转换为布尔对应值,创建一个复苏函数:

const jsonobj = '{"test" : "value1", "test2" : 3.44, "test3" : 0}';
const obj = JSON.parse(jsonobj, (key, value) => {
  if (typeof value === 'number') {
    if (value === 0) {
      value = false;
    } else if (value === 1) {
      value = true;
    }
  }
  return value;
});

console.log(obj.test3); // false

讨论

要想知道如何创建 JSON,请考虑如何创建对象字面量,并将其转换为字符串(有一些注意事项)。

如果对象是一个数组:

const arr = new Array("one","two","three");

JSON 表示法等同于数组的字面表示法:

["one","two","three"];

注意使用双引号 ("") 而不是单引号,因为 JSON 不允许使用单引号。

如果您正在处理一个对象:

const obj3 = {
   prop1 : "test",
   result : true,
   num : 5.44,
   name : "Joe",
   cts : [45,62,13]
 };

JSON 表示法为:

{"prop1":"test","result":true,"num":5.44,"name":"Joe","cts":[45,62,13]}

注意在 JSON 中,属性名用引号括起来,但只有当值是字符串时才用引号括起来。此外,如果对象包含其他对象(如数组),它也会被转换为其 JSON 等效形式。然而,对象不能包含方法。如果有方法,则会抛出错误。JSON 只能处理数据。

JSON 静态对象并不复杂,因为它只提供两个方法:stringify()parse()parse() 方法接受两个参数:一个 JSON 格式的字符串和一个可选的 reviver 函数。该函数以键/值对作为参数,并返回原始值或修改后的结果。

在解决方案中,JSON 格式的字符串是一个包含三个属性的对象:一个字符串,一个数值,以及第三个属性,其数值为布尔型表示—0 为假,1 为真。

为了将所有的 0 和 1 值转换为falsetrue,第二个参数作为JSON.parse()的函数提供。它检查对象的每个属性,看它是否为数值。如果是,函数再检查值是否为 0 或 1。如果值为 0,则返回值设为false;如果为 1,则返回值设为true;否则,返回原始值。

能够转换传入的 JSON 格式数据非常重要,特别是在处理 AJAX 请求或 JSONP 响应结果时。您并不总能控制从服务获取的数据结构。

注意

JSON 有一些限制:字符串必须用双引号括起来,不能包含十六进制值,也不能在字符串中包含制表符。

获取和解析 XML

问题

您需要获取远程 XML 文件并解析其内容。

解决方案

使用fetch以及提供从字符串解析 XML 的DomParserAPI 的能力。

首先,您需要使用fetch请求 XML 文件。在此示例中,我请求的是纽约时报主页的 XML 源:

const url = 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml';

async function fetchAndParse() {
  const response = await fetch(url);
  const data = await response.text();
  console.log(data);
}

fetchAndParse();

接下来,使用DOMParser解析返回的 XML 字符串,然后使用 DOM 方法查询文档中的数据:

const url = 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml';

async function fetchAndParse() {
  const response = await fetch(url);
  const data = await response.text();
  const parser = new DOMParser();
  const XMLDocument = parser.parseFromString(data, 'text/xml');
  console.log(XMLDocument);
}

fetchAndParse();

讨论

使用fetch获取 XML 时,文档作为纯文本返回。然后您可以使用DOMParserAPI 启用 DOM 方法来查询文档并处理结果。

DOMParser使您能够使用像getElementsByTagName这样的 DOM 查询方法与 XML 内容交互。DOMParser需要两个参数。第一个参数是要解析的字符串。第二个参数是mimeType,指定文档类型。mimeType的选项包括:

  • text/html

  • text/xml

  • application/xml

  • applicatiom/xhtml+html

  • image/svg+xml

下面的例子扩展了 XML 解析器以使用 DOM 查询选择器将最新文章的名称输出到网页:

(async () => {
  const url = 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml';

  // fetch and parse the XML document
  async function fetchAndParse() {
    const response = await fetch(url);
    const data = await response.text();
    const parser = new DOMParser();
    const XMLDocument = parser.parseFromString(data, 'text/xml');
    return XMLDocument;
  }

  function displayTitles(xml) {
    // HTML element where the results will be displayed
    // the markup contains a ul with an id of "results"
    const listElem = document.getElementById('results');
    // get the article titles
    // each is wrapped in a <title> tag within an <item> tag
    const titles = xml.querySelectorAll('item title');
    // loop over each title in the XML; append its text content to the HTML list
    titles.forEach(title => {
      const listItem = document.createElement('li');
      listItem.innerText = title.textContent;
      listElem.appendChild(listItem);
    });
  }

  const xml = await fetchAndParse();
  displayTitles(xml);
})();

发送二进制数据并加载为图像

问题

您想要作为二进制数据请求服务器端的图像。

解决方案

通过fetch请求获取二进制数据只需将响应类型设置为blob,然后在返回时操作数据。在解决方案中,数据随后被转换并加载到img元素中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Binary Data</title>
  </head>
  <body>
    <h1>Binary Data</h1>

    <img id="result" />
    <script>
      async function fetchImage() {
      const url = 'logo.png';
      const response = await fetch(url);
      const blob = await response.blob();

      // add returned url to image element
      const img = document.getElementById('result');
      img.src = URL.createObjectURL(blob);
      }

      fetchImage();
    </script>
  </body>
</html>

讨论

CORS 规范的一个好处是支持 fetch 请求中的二进制数据(也称为类型化数组)。二进制请求的关键要求是将响应类型设置为以下之一:

arraybuffer

固定长度的原始二进制数据缓冲区

blob

类似文件的不可变原始数据

在解决方案中,我使用了URL.createObjectURL()方法将blob转换为 DOMString(通常映射为 JavaScript 字符串),并将该 URL 赋给img元素的src属性。

当然,在首次为src属性分配 PNG 文件的 URL 时也很简单。但是,使用诸如 Web Workers 和 WebGL 等各种技术必须能够操作二进制数据。

在不同域之间共享 HTTP Cookies

问题

您希望作为带凭证请求从另一个域访问资源,包括 HTTP cookies 和任何身份验证信息。

解决方案

必须在客户端和服务器应用程序中进行更改以支持带凭证的请求。在以下示例中,客户端应用程序位于somedomain.com,而服务器位于api.example.com。由于这些是不同的域,默认情况下客户端到服务器的带凭证请求将不会共享。

在客户端,必须测试fetch请求上的credentials属性:

fetch('https://api.example.com', {
  credentials: "include"
})

在服务器中,Access-Control-Allow-Controls头的值必须设置为true

const http = require('http');
const Cookies = require('cookies');

const server = http.createServer((req,res) => {
  // Set CORS headers
  res.setHeader('Content-type', 'text/plain');
  res.setHeader('Access-Control-Allow-Origin', 'https://somedomain.com');
  res.setHeader('Access-Control-Allow-Credentials', true);

  const cookies = new Cookies (req, res);
  cookies.set("apple","red");

  res.writeHead(200);
  res.end("Hello cross-domain");

});

server.listen(8080);
注意

在使用 Express 时,我建议使用CORS 中间件。我们在第二十一章中详细讨论了 Express。

讨论

跨域资源共享(Cross-Origin Resource Sharing,CORS)是指在不同域之间共享信息,如 cookies 和凭据头信息,由于安全原因,浏览器对跨域共享信息进行限制。通过配置 CORS 扩展,可以在不同域之间发送 HTTP cookies 或身份验证头信息,只要客户端和服务器都信号同意。

如果在客户端使用XMLHttpRequest替代fetch,请设置withCredentials属性:

const request = new XMLHttpRequest();

request.onreadystatechange = function() {
    if (this.readyState == 4) {
        console.log(this.status);
        if (this.status == 200) {
            document.getElementById('result').innerHTML = this.responseText;
        }
    }
};
request.open('GET','http://localhost:8080/');
request.withCredentials = true;
request.send(null);

使用 Websockets 建立客户端和服务器之间的双向通信

问题

您希望在服务器和网页客户端之间建立双向实时通信。

解决方案

WebSockets 允许您支持客户端和服务器之间的双向通信。客户端创建一个新的 WebSockets 对象,传入 WebSockets 服务器的 URI。请注意,使用ws:协议代替httphttps。当客户端收到消息时,它将消息文本转换为对象,检索计数器,增加它,然后在对象的字符串成员中使用它。

在以下示例中,客户端打印出每隔一个数字,从 2 开始。通过在消息中传递要打印的字符串,客户端和服务器之间保持状态:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Using Websockets</title>
  </head>
  <body>
    <h1>Using Websockets</h1>

    <div id="output"></div>
    <script type="text/javascript">
      const socket = new WebSocket('ws://localhost:8080');
      socket.onmessage = event => {
        const msg = JSON.parse(event.data);
        msg.counter = Number(msg.counter) + 1;
        msg.strng += `${msg.counter}-`;
        const html = `<p> ${msg.strng} </p>`;
        document.getElementById('output').innerHTML = html;
        socket.send(JSON.stringify(msg));
      };
    </script>
  </body>
</html>

对于服务器,我正在使用ws Node 模块。一旦创建服务器,它通过发送具有两个成员的 JavaScript 对象与客户端开始通信:数字计数器和字符串。必须首先将对象转换为字符串。代码监听传入消息和close事件。当接收到传入消息时,它增加计数器并发送对象:

var wsServer = require('ws').Server;
var wss = new wsServer({port:8001});
wss.on('connection', (function (conn) {

    // object being passed back and forth between
    // client and server
    var counter = {counter: 1, strng: ''};

    // send first communication to client
    conn.send(JSON.stringify(counter));

    // on response back
    conn.on('message', function(message) {
        var ct = JSON.parse(message);
        ct.counter = parseInt(ct.counter) + 1;
        if (ct.counter < 100) {
           conn.send(JSON.stringify(ct));
        }
    });
}));

讨论

双向通信,也称为全双工通信,是可以同时进行的双向通信。将其视为一条双向道路,交通双向流动。所有现代浏览器都支持 WebSockets 规范,正如您所看到的,它非常容易使用。

WebSockets 的优势不仅仅在于在浏览器中使用非常简单,而且它能够穿越代理和防火墙,这是其他双向通信技术(如长轮询)无法轻松实现甚至不可能的。为了确保应用程序的安全性,诸如 Chrome 和 Firefox 之类的用户代理禁止混合内容(即同时使用 HTTP 和 HTTPS)。

WebSockets 支持二进制数据以及文本。正如示例所示,可以通过在发送之前调用JSON.stringify()将 JSON 对象传输,并在接收端对字符串调用JSON.parse()

参见

参见有关WebSockets的更多信息。

长轮询远程数据源

问题

您希望与服务器保持连接,以便客户端能够立即更新新信息,但服务器不使用 WebSockets。

解决方案

使用长轮询,这是一种技术,客户端通过使用异步的fetch函数来维持与服务器的连接,并在响应后再次调用自身。在其最基本的形式下,客户端端的长轮询看起来像这样:

const url = 'http://localhost:8080/';

async function longPoll() {
  const response = await fetch(url);
  // if message received, log response to console and call polling function
  const message = await response.text();
  console.log(message);
  await longPoll();
}

longPoll();

通过添加一些错误处理,当接收到错误时将等待一定的时间,然后尝试轮询服务器可以改善这一情况:

const url = 'http://localhost:8080/';

async function longPoll() {
  try {
    // if message received, log response to console and call polling function
    const response = await fetch(url);
    const message = await response.text();
    console.log(message);
    await longPoll();
  } catch (error) {
    // if fetch returns an error, wait 1 second and try again
    console.log(`Request failed ${error}`);
    await new Promise(resolve => setTimeout(resolve, 1000));
    await longPoll();
  }
}

longPoll();

讨论

长轮询服务器涉及向服务器发出请求并保持连接,直到响应被发送。一旦客户端收到响应,它立即重新连接到服务器并等待新的响应。这个过程可以分解如下:

  1. 客户端向服务器发送请求。

  2. 客户端保持与服务器的连接,同时等待响应。

  3. 服务器将响应发送给客户端。

  4. 客户端重新连接到服务器,进程重复。

我发现一个聊天程序是思考长轮询的一个有帮助的方式。想象一个聊天程序,其中有两个用户在彼此聊天,Riley 和 Harlow。他们每个人都连接到服务器。当 Riley 发送消息时,服务器将向 Harlow 的浏览器发送响应,后者立即重新连接并等待下一条消息。

长轮询的局限性在于服务器能维持的开放连接数量。Node 被设计用来处理许多并发连接,而某些语言有其限制。所有语言都受限于服务器硬件本身。尽管长轮询是一种简单且有效地维持连接的方法,WebSockets(如 “使用 Websockets 建立客户端和服务器之间的双向通信” 中所述)是客户端和服务器之间更高效的双向通信手段。

第十四章:数据持久性

我们可以进行动画和交互,流式传输,播放和渲染,但我们总是回到数据。数据是我们构建大多数 JavaScript 应用程序的基础。在本书的第一部分中,我们使用 JavaScript 语言标准来处理数据类型,在第十三章中,我们从远程源获取数据,在第二十章中,我们将在服务器上处理数据,使用 API 和数据源操纵数据。数据和 JavaScript,永远的朋友。

在本章中,我们将探讨如何使用 cookie、sessionStoragelocalStorage 和 IndexedDB 在浏览器中使用 JavaScript 持久化数据的方法。

使用 Cookie 持久化信息

问题

您需要读取或设置浏览器 cookie 的值。

解决方案

使用 document.cookie 来设置和检索 cookie 值:

document.cookie = 'author=Adam';
console.log(document.cookie);

要对字符串进行编码,使用 encodeURIComponent,它将删除任何逗号、分号或空格:

const book = encodeURIComponent('JavaScript Cookbook');
document.cookie = `title=${book}`;
console.log(document.cookie);

// logs title=JavaScript%20Cookbook

选项可以添加到 cookie 值的末尾,并应使用分号分隔:

document.cookie = 'user=Abigail;  max-age=86400; path=/';

要删除一个 cookie,设置一个已经过期的 cookie 过期日期:

function eraseCookie(key) {
  const cookie = `${key}=;expires=Thu, 01 Jan 1970 00:00:00 UTC`;
  document.cookie = cookie;
}

讨论

Cookie 是存储在浏览器中的小数据片段。它们通常是从服务器应用程序设置并几乎在每个请求中发送到服务器。在浏览器中,它们通过 document.cookie 对象访问。

Cookie 接受以下选项,每个选项用分号分隔:

domain

cookie 可访问的域。如果未设置,这将默认为当前主机位置。指定域允许在子域中访问 cookie。

expires

设置 cookie 过期的时间。接受 GMTString 格式的日期。

max-age

设置 cookie 有效的时间长度。接受以秒为单位的值。

path

cookie 可访问的路径(例如 //app)。如果未指定,则 cookie 默认为当前路径。

secure

如果设置为 true,则 cookie 只会通过 https 传输。

samesite

默认为 strict。如果设置为 strict,则 cookie 不会在跨站点浏览中发送。或者,lax 将在顶级 GET 请求中发送 cookie。

在下面的示例中,用户可以输入一个值,该值将被存储为一个 cookie。然后他们可以检索指定键的值并删除该值。

在一个 HTML 文件中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <style>
      div {
        margin: 10px;
      }

      .data {
        width: 200px;
        background-color: yellow;
        padding: 5px;
      }
    </style>
    <title>Store, retrieve, and delete a cookie</title>
  </head>
  <body>
    <h1>Store, retrieve, and delete a cookie</h1>

    <form>
      <div>
        <label for="key"> Enter key:</label>
        <input type="text" id="key" />
      </div>
      <div>
        <label for="value">Enter value:</label>
        <input type="text" id="value" />
      </div>
    </form>
    <button id="set">Set data</button>
    <button id="get">Get data</button>
    <button id="erase">Erase data</button>

    <p>Cookie value:</p>
    <div id="cookiestr" class="data"></div>

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

以及相关的 cookie.js 文件:

// set the cookie
function setData() {
  const formKey = document.getElementById('key').value;
  const formValue = document.getElementById('value').value;

  const cookieVal = `${formKey}=${encodeURIComponent(formValue)}`;
  document.cookie = cookieVal;
}

// retrieve the cookie value for a specified key
function getData() {
  const key = document.getElementById('key').value;
  const cookie = document.getElementById('cookiestr');
  cookie.innerHTML = '';

  const keyValue = key.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1');
  const regex = new RegExp(`(?:^|;)\\s?${keyValue}=(.*?)(?:;|$)`, 'i');
  const match = document.cookie.match(regex);
  const value = (match && decodeURIComponent(match[1])) || '';
  cookie.innerHTML = `<p>${value}</p>`;
}

// remove the cookie for a specified key
function removeData() {
  const key = document.getElementById('key').value;
  document.getElementById('cookiestr').innerHTML = '';

  const cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC`;
  document.cookie = cookie;
}

document.getElementById('set').onclick = setData;
document.getElementById('get').onclick = getData;
document.getElementById('erase').onclick = removeData;

请注意,我正在使用正则表达式来匹配已使用 encodeURIComponent 编码的 cookie 值。这是因为 document.cookie 返回一个包含所有 cookie 值的字符串。以这种方式使用正则表达式允许我提取我需要的信息。正则表达式在第二章中有更详细的介绍。

使用 sessionStorage 进行客户端存储

问题

您希望轻松地为单个会话存储信息,而不会遇到与 cookie 相关的大小和跨页面污染问题。

解决方案

使用 DOM 存储 sessionStorage 功能:

sessionStorage.setItem('name', 'Franco');
sessionStorage.city = 'Pittsburgh';

// returns 2
console.log(sessionStorage.length);

// retrieve individual values
const name = sessionStorage.getItem('name');
const city = sessionStorage.getItem('city');

console.log(`The stored name is ${name}`);
console.log(`The stored city is ${city}`);

// remove an individual item from storage
sessionStorage.removeItem('name');

// remove all items from storage
sessionStorage.clear();

// returns 0
console.log(sessionStorage.length);

讨论

sessionStorage 允许我们轻松地在用户的浏览器中为单个会话存储信息。会话的持续时间与单个浏览器标签页保持打开状态的时间相同。一旦用户关闭浏览器或标签页,会话就会结束。打开同一页面的新标签页将启动一个新的浏览器会话。

相比之下,cookies 和 localStorage 的默认行为(在“创建客户端本地数据存储项 localStorage”中讨论)是跨会话持久。作为这些存储方法差异的示例,示例 14-1 将表单中的信息存储在 cookie、localStoragesessionStorage 中。

示例 14-1. 比较 sessionStorage 和 cookies
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <style>
      div {
        margin: 10px;
      }

      .data {
        width: 100px;
        background-color: yellow;
        padding: 5px;
      }
    </style>
    <title>Comparing Cookies, localStorage, and sessionStorage</title>
  </head>
  <body>
    <h1>Comparing Cookies, localStorage, and sessionStorage</h1>

    <form>
      <div>
        <label for="key"> Enter key:</label>
        <input type="text" id="key" />
      </div>
      <div>
        <label for="value">Enter value:</label>
        <input type="text" id="value" />
      </div>
    </form>
    <button id="set">Set data</button>
    <button id="get">Get data</button>
    <button id="erase">Erase data</button>

    <p>Session:</p>
    <div id="sessionstr" class="data"></div>
    <p>Local:</p>
    <div id="localstr" class="data"></div>
    <p>Cookie:</p>
    <div id="cookiestr" class="data"></div>

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

cookies.js 文件包含设置、检索和删除给定 cookie 所需的代码:

// set session cookie
function setCookie(cookie, value) {
  const cookieVal = `${cookie}=${encodeURIComponent(value)};path=/`;
  document.cookie = cookieVal;
  console.log(cookieVal);
}

// each cookie separated by semicolon;
function getCookie(key) {
  const keyValue = key.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1');
  const { cookie } = document;
  const regex = new RegExp(`(?:^|;)\\s?${keyValue}=(.*?)(?:;|$)`, 'i');
  const match = cookie.match(regex);

  return match && decodeURIComponent(match[1]);
}

// set cookie date to the past to erase
function eraseCookie(key) {
  const cookie = `${key}=;path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC`;
  document.cookie = cookie;
  console.log(cookie);
}

app.js 文件包含程序其余功能:

// set data for both session and cookie
function setData() {
  const key = document.getElementById('key').value;
  const { value } = document.getElementById('value');

  // set sessionStorage
  sessionStorage.setItem(key, value);

  // set localStorage
  localStorage.setItem(key, value);

  // set cookie
  setCookie(key, value);
}

function getData() {
  try {
    const key = document.getElementById('key').value;
    const session = document.getElementById('sessionstr');
    const local = document.getElementById('localstr');
    const cookie = document.getElementById('cookiestr');

    // reset display
    session.innerHTML = '';
    local.innerHTML = '';
    cookie.innerHTML = '';

    // sessionStorage
    let value = sessionStorage.getItem(key) || '';
    if (value) session.innerHTML = `<p>${value}</p>`;

    // localStorage
    value = localStorage.getItem(key) || '';
    if (value) local.innerHTML = `<p>${value}</p>`;

    // cookie
    value = getCookie(key) || '';
    if (value) cookie.innerHTML = `<p>${value}</p>`;
  } catch (e) {
    console.log(e);
  }
}

function removeData() {
  const key = document.getElementById('key').value;

  // sessionStorage
  sessionStorage.removeItem(key);

  // localStorage
  localStorage.removeItem(key);

  // cookie
  eraseCookie(key);

  // reset display
  getData();
}

document.getElementById('set').onclick = setData;
document.getElementById('get').onclick = getData;
document.getElementById('erase').onclick = removeData;

您可以从 sessionStorage 获取和设置数据,直接访问它,就像解决方案中演示的那样,但更好的方法是使用 getItem()setItem() 函数。

加载示例页面,为相同键添加一个或多个值,然后点击“获取数据”按钮。结果显示在图 14-1 中。这里没有意外。数据已存储在 cookies、localStoragesessionStorage 中。现在,在新的标签页中打开同一页面,将值输入到 key 表单字段中,然后点击“获取数据”按钮。该操作会导致类似于图 14-2 所示的页面。

jsc3 1401

jsc3 1402

在新的标签页中,cookielocalStorage 的值会持久存在,因为 cookie 是会话特定的,但是 sessionStorage 只在标签窗口内有效。

屏幕截图展示了跨标签页持久性的差异,这是 sessionStorage 和 cookies 之间的主要区别之一,除了它们在 JavaScript 中的设置和访问方式不同。希望这些图像和示例还能展示在使用 sessionStorage 时可能涉及的潜在风险。

如果您的网站或应用程序用户熟悉标签页窗口间的 cookie 持久性,则 sessionStorage 可能会让他们感到不快。除了不同的行为之外,还有一个事实是,浏览器菜单选项删除 cookies 可能不会影响 sessionStorage,这对用户来说也可能是个令人不快的惊喜。另一方面,sessionStorage 使用起来非常干净,并且在我们想要将存储与特定标签页窗口关联时,提供了一种受欢迎的存储选项。

关于sessionStorage的最后一点补充与其实现相关:sessionStorage和下一个案例中涵盖的localStorage都是 W3C DOM 存储规范的一部分。它们都是window对象的属性,这意味着可以在全局范围内访问。它们都是Storage对象的实现,对Storage的原型进行的更改会影响到sessionStoragelocalStorage对象:

Storage.prototype.someMethod = function (param) { ...};
...
localStorage.someMethod(param);
...
sessionStorage.someMethod(param);

除了本文和下文涵盖的差异之外,另一个主要区别在于Storage对象不会向服务器发出往返请求 —— 它们完全是客户端存储技术。

参见

要了解有关Storage对象、sessionStoragelocalStorage或 Storage DOM 的更多信息,请参阅规范。有关如何设置和检索sessionStoragelocalStorage的不同方法,请参阅“创建一个客户端本地存储项”。

创建一个客户端本地存储项

问题

如果你希望以一种用户在浏览器崩溃、用户意外关闭浏览器或者网络连接中断后能够继续使用的方式来持久保存表单元素条目(或任何数据),请参考以下方法。

解决方案

如果数据量较小,可以使用 Cookie,但在离线情况下此策略无效。另一种更好的方法,特别是在持久保存大量数据或需要支持无网络连接时的功能时,是使用localStorage

const formValue = document.getElementById('formelem').value;
if (formValue) {
  localStorage.formelem = formValue;
}

// recover
const storedValue = localStorage.formelem;
if (storedValue) {
  document.getElementById('formelem').value = storedValue;
}

讨论

“使用sessionStorage进行客户端存储”介绍了sessionStorage,这是 DOM 存储技术之一。localStorage对象接口相同,对于设置数据的方法也一样:

// use item methods
sessionStorage.setItem('key', 'value');
localStorage.setItem('key', 'value');

// use property names directly
sessionStorage.keyName = 'value';
localStorage.keyName = 'value';

// use the key method
sessionStorage.key(0) = 'value';
localStorage.key(0) = 'value';

以及获取数据的方法:

// use item methods
value = sessionStorage.getItem('key');
value = localStorage.getItem('key');

// use property names directly
value = sessionStorage.keyName;
value = localStorage.keyName;

// use the key method
value = sessionStorage.key(0);
value = localStorage.key(0);

正如对sessionStorage,尽管你可以直接访问和设置localStorage中的数据,你应该使用getItem()setItem()来进行操作。

这两个存储对象都支持length属性,用于计算存储项对的数量,以及clear方法(无参数),用于清除所有存储。此外,两者都受限于 HTML5 的起源,这意味着数据存储在域中的所有页面之间共享,但不跨协议(例如,httphttps不同)或端口。

两者的区别在于数据存储的时间长短。sessionStorage对象仅在会话期间存储数据,而localStorage对象会永久地或直到明确移除在客户端存储数据。

sessionStoragelocalStorage对象还支持一个事件:storage事件。这是一个有趣的事件,当localStorage项发生更改时,所有页面都会触发该事件。这也是浏览器兼容性较低的领域:在 Firefox 中,可以在bodydocument元素上捕获事件,在 IE 中在body上,在 Safari 中在document上。

示例 14-2 展示了比本方案解决方案中涵盖的用例更全面的实现。在示例中,小表单的所有元素都将它们的 onchange 事件处理程序方法分配给一个函数,该函数捕获更改元素名称和值,并通过 localStorage 将值存储在本地存储中。提交表单时,将清除所有存储的表单数据。

当页面加载时,表单元素的 onchange 事件处理程序分配给函数以存储值,如果值已存储,则将其恢复到表单元素。要测试应用程序,请在几个表单字段中输入数据,但在单击提交按钮之前刷新页面。如果没有 localStorage,您将丢失数据。现在,当您重新加载页面时,表单将恢复到重新加载页面之前的状态。

示例 14-2. 使用 localStorage 在页面重新加载或浏览器崩溃时备份表单条目
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Creating a localStorage Client-Side Data Storage Item</title>
  </head>
  <body>
    <h1>Creating a localStorage Client-Side Data Storage Item</h1>

    <form id="inputform">
      <div>
        <label for="field1">Enter field1:</label>
        <input type="text" id="field1" />
      </div>
      <div>
        <label for="field2">Enter field2:</label>
        <input type="text" id="field2" />
      </div>
      <div>
        <label for="field3">Enter field1:</label>
        <input type="text" id="field3" />
      </div>
      <div>
        <label for="field4">Enter field1:</label>
        <input type="text" id="field4" />
      </div>
      <input type="submit" value="Clear Storage" />
    </form>

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

在 JavaScript 文件中:

// store the form input elements as a variable
const elems = document.querySelectorAll('input');

// store field values
function processField() {
  localStorage.setItem(window.location.href, 'true');
  localStorage.setItem(this.id, this.value);
}

// clear individual fields
function clearStored() {
  elems.forEach(elem => {
    if (elem.type === 'text') {
      localStorage.removeItem(elem.id);
    }
  });
}

// capture submit button to clear storage when clicked
document.getElementById('inputform').onsubmit = clearStored;

// on form element change, store the value in localStorage
elems.forEach(elem => {
  if (elem.type === 'text') {
    const value = localStorage.getItem(elem.id);
    if (value) elem.value = value;

    // change event
    elem.onchange = processField;
  }
});

浏览器为 localStorage 分配的大小因浏览器而异,但大多数在 5 mb 到 10 mb 范围内。您可以使用 try/catch 块来测试,确保未超出用户浏览器的限制:

try {
  localStorage.setItem('key', 'value');
} catch (domException) {
  if (
    ['QuotaExceededError', 'NS_ERROR_DOM_QUOTA_REACHED'].includes(
      domException.name
    )
  ) {
    // handle file size exceeded error
  } else {
    // handle any other error
  }
}

localStorage 对象可用于离线工作。例如,对于表单,您可以将数据存储在 localStorage 中,并提供一个按钮,以便在连接到互联网时从 localStorage 同步数据到服务器存储。

另请参阅

更多关于 Storage 对象、sessionStoragelocalStorage 的信息,请参见“使用 sessionStorage 进行客户端存储”。

在客户端使用 IndexedDB 持久化更大的数据块

问题

在客户端,您需要比其他持久存储方法提供的更复杂的数据存储,例如 localStorage

解决方案

在现代浏览器中,请使用 IndexedDB。

示例 14-3 中的 JavaScript 文件使用 IndexedDB 创建数据库和数据对象。创建后,它添加数据,然后检索第一个对象。在讨论中详细描述了正在发生的事情。

示例 14-3. 使用 IndexedDB 创建数据存储、添加数据,然后检索数据对象的示例
const data = [
  { name: 'Joe Brown', age: 53, experience: 5 },
  { name: 'Cindy Johnson', age: 44, experience: 5 },
  { name: 'Some Reader', age: 30, experience: 3 }
];

// delete the 'Cookbook' database, so the example can be run more than once
const delReq = indexedDB.deleteDatabase('Cookbook');
delReq.onerror = event => {
  console.log('delete error', event);
};

// open the 'Cookbook' database with a version of '1'
// or create it if it does not exist
const request = indexedDB.open('Cookbook', 1);

// upgradeneeded event is fired when a db is opened
// with a version number higher than the currently stored version (in this case none)
request.onupgradeneeded = event => {
  const db = event.target.result;
  const { transaction } = event.target;

  // create a new object store named 'reader' in the database
  const objectStore = db.createObjectStore('reader', {
    keyPath: 'id',
    autoIncrement: true
  });

  // create new keys in the object store
  objectStore.createIndex('experience', 'experience', { unique: false });
  objectStore.createIndex('name', 'name', { unique: true });

  // when all data loaded, log to the console
  transaction.oncomplete = () => {
    console.log('data finished');
  };

  const readerObjectStore = transaction.objectStore('reader');

  // add each value from the data object to the indexedDB database
  data.forEach(value => {
    const req = readerObjectStore.add(value);
    // console log a message when successfully added
    req.onsuccess = () => {
      console.log('data added');
    };
  });

  // if the request throws an error, log it to the console
  request.onerror = () => {
    console.log(event.target.errorCode);
  };

  // when the data store is successfully created, log to the console
  request.onsuccess = () => {
    console.log('datastore created');
  };

  // on page click, get a random value from the database and log it to the console
  document.onclick = () => {
    const randomNum = Math.floor(Math.random() * 3) + 1;
    const dataRequest = db
      .transaction(['reader'])
      .objectStore('reader')
      .get(randomNum);
    dataRequest.onsuccess = () => {
      console.log(`Name : ${dataRequest.result.name}`);
    };
  };
};

讨论

IndexedDB 是 W3C 和其他人在探索客户端大数据管理解决方案时达成的规范。虽然它是基于事务的,并支持游标的概念,但它并非关系数据库系统。它使用 JavaScript 对象,每个对象都由给定的索引,无论您决定将键设为什么。

IndexedDB 可以是异步和同步的。它可以用于传统服务器或云应用程序中的更大数据块,但也对离线 Web 应用程序有帮助。

大多数 IndexedDB 的实现不限制数据存储大小,但如果在 Firefox 中存储超过 50 MB,则用户需要提供权限。Chrome 创建一个临时存储池,每个应用程序最多可以使用其 20%。其他代理程序也有类似的限制。所有主流浏览器都支持 IndexedDB,除了 Opera Mini,尽管整体支持可能不完全相同。

正如解决方案所示,IndexedDB API 方法触发成功和错误回调函数,您可以使用传统事件处理、回调函数或分配给函数来捕获这些回调。

  1. 打开数据库。

  2. 在升级数据库中创建对象存储。

  3. 开启一个事务,并发出一个数据库操作请求,如添加或检索数据。

  4. 通过监听适当类型的 DOM 事件等待操作完成。

  5. 处理返回的结果(可以在请求对象中找到)。

从解决方案的顶部开始,创建一个具有三个值的数据对象以添加到数据存储中。如果数据库存在,则删除数据库,以便可以多次运行示例。随后,调用open()打开数据库(如果存在)或创建数据库(如果不存在)。因为在运行示例之前删除了数据库,所以它会被重新创建。名称和版本都是必需的,因为只有在打开新版本的数据库时才能修改数据库。

open()方法返回一个请求对象(IDBOpenDBRequest),并且操作是否成功会作为该对象的事件触发。在代码中,捕获该对象的onsuccess事件处理程序以在控制台上提供成功的消息。您还可以在此事件处理程序中将数据库句柄分配给全局变量,但代码在下一个事件处理程序upgradeneeded中分配它。

当给定数据库名称和版本的数据库不存在时,只有upgradeneeded事件处理程序才会被调用。事件对象还提供了一种访问 IDBDatabase 引用的方式,该引用分配给全局变量db。现有的事务也可以通过作为事件处理程序参数传递的事件对象访问,并且它被访问并分配给一个局部变量。

此事件的事件处理程序是唯一可以创建对象存储及其相关索引的时间。在解决方案中,创建了一个名为reader的数据存储,其键设置为自增的id。另外两个索引用于数据存储的nameexperience字段。数据也在事件中添加到数据存储中,尽管可以在其他时间添加,例如当用户提交 HTML 表单时。

upgradeneeded事件处理程序之后,编写了successerror处理程序,仅用于提供反馈。最后,使用document.onclick事件处理程序来触发数据库访问。在解决方案中,通过数据库处理程序访问随机数据实例,其事务,对象存储,并最终获得给定键的数据。当查询成功时,访问name字段并将其值打印到控制台。我们可以使用游标而不是访问单个值,但这留给您自己的实验。

结果将按顺序打印到控制台:

data added
data finished
datastore created
Name : Cindy Johnson

使用库简化 IndexedDB

问题

您希望使用 JavaScript promises 以异步方式处理 IndexedDB。

解决方案

使用IDB 库,它提供了对 IndexedDB API 的可用性改进以及使用 promises 的包装器。

以下文件导入 IDB 库,创建一个 IndexedDB 数据存储并向其添加数据:

import { openDB, deleteDB } from 'https://unpkg.com/idb?module';

const data = [
  { name: 'Riley Harrison', age: 57, experience: 1 },
  { name: 'Harlow Everly', age: 29, experience: 5 },
  { name: 'Abigail McCullough', age: 38, experience: 10 }
];

(async () => {
  // for demo purposes, delete existing db on page load
  try {
    await deleteDB('CookbookIDB');
  } catch (err) {
    console.log('delete error', err);
  }

  // open the database and create the data store
  const database = await openDB('CookbookIDB', 1, {
    upgrade(db) {
      // Create a store of objects
      const store = db.createObjectStore('reader', {
        keyPath: 'id',
        autoIncrement: true
      });

      // create new keys in the object store
      store.createIndex('experience', 'experience', { unique: false });
      store.createIndex('name', 'name', { unique: true });
    }
  });

  // add all of the reader data to the store
  data.forEach(async value => {
    await database.add('reader', value);
  });
})();
注意

在示例中,我正在从UNPKG加载idb模块,这使我可以直接从 URL 访问模块,而不是在本地安装它。这在演示目的中效果很好,但在应用程序中,您将希望通过npm安装模块并将其与您的代码捆绑在一起。

讨论

IDB 自称为“一个几乎与 IndexedDB API 完全相同的小型库,但通过一些小的改进大大提升了可用性。”使用idb简化了 IndexedDB 的某些语法,并支持使用 promises 执行异步代码。

openDB方法打开数据库并返回一个 promise:

const db = await openDB(name, version, {
  // ...
});

在以下示例中,用户可以向数据库添加数据并检索所有数据以在页面上显示。在 HTML 文件中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>IDB Discussion Example</title>
    <style>
      div {
        margin: 10px;
      }

      .data {
        width: 200px;
        background-color: yellow;
        padding: 5px;
      }
    </style>
  </head>
  <body>
    <h1>IDB Discussion Example</h1>

    <form>
      <div>
        <label for="name"> Enter name:</label>
        <input type="text" id="name" />
      </div>
      <div>
        <label for="age">Enter age:</label>
        <input type="text" id="age" />
      </div>
    </form>
    <button id="set">Set data</button>
    <button id="get">Get data</button>

    <p>Data:</p>
    <div class="data">
      <ul id="data-list"></ul>
    </div>

    <script type="module" src="idb-discussion.js"></script>
  </body>
</html>

以及idb-discussion.js文件:

import { openDB } from 'https://unpkg.com/idb?module';

(async () => {
  // open the database and create the data store
  const database = await openDB('ReaderNames', 1, {
    upgrade(db) {
      // Create a store of objects
      const store = db.createObjectStore('reader', {
        keyPath: 'id',
        autoIncrement: true
      });

      // create new keys in the object store
      store.createIndex('age', 'age', { unique: false });
      store.createIndex('name', 'name', { unique: true });
    }
  });

  async function setData() {
    const name = document.getElementById('name').value;
    const age = document.getElementById('age').value;

    await database.add('reader', {
      name,
      age
    });
  }

  async function getData() {
    // get the reader data from the database
    const readers = await database.getAll('reader');

    const dataDisplay = document.getElementById('data-list');

    // add the name and age of each reader in the db to the page
    readers.forEach(reader => {
      const value = `${reader.name}: ${reader.age}`;
      const li = document.createElement('li');
      li.appendChild(document.createTextNode(value));
      dataDisplay.appendChild(li);
    });
  }

  document.getElementById('set').onclick = setData;
  document.getElementById('get').onclick = getData;
})();

我不会详细介绍整个 API,但强烈建议查阅库的文档,并在使用 IndexedDB 时使用 IDB。

第十五章:处理媒体

漂亮的图片。动画。酷炫的视频。声音!

通过多种媒体类型,Web 变得更加丰富。我们的老朋友 SVG 和 Canvas 可用于复杂的动画、图表和图形。除此之外,HTML5 还包括视频和音频元素,以及近期将要出现的 3D 图形潜力。

最重要的是,这些都不需要任何专有插件,它们都集成在您的浏览器客户端中,包括智能手机、平板电脑和计算机。

向 SVG 添加 JavaScript

问题

您想要向 SVG 文件或元素添加 JavaScript。

解决方案

SVG 中的 JavaScript 包含在 script 元素中,与 HTML 类似,只是需要在脚本周围添加 CDATA 标记(示例 15-1)。DOM 方法也适用于处理 SVG 元素。

示例 15-1。演示 SVG 文件中的 JavaScript
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="600" height="600">
  <script type="text/ecmascript">
    <![CDATA[

      // set element onclick event handler
      window.onload = function() {
        const square = document.getElementById('square');

        // onclick event handler, change circle radius
        square.onclick = function click() {
          const color = this.getAttribute('fill');
          if (color === '#ff0000') {
            this.setAttribute('fill', '#0000ff');
          } else {
            this.setAttribute('fill', '#ff0000');
          }
        };
      };
    ]]>
  </script>
  <rect id="square" width="400" height="400" fill="#ff0000"
   x="10" y="10" />
</svg>

讨论

如本解决方案所示,SVG 是 XML 格式,必须遵循向 XML 嵌入脚本的规则。这意味着在 script 标签中提供脚本 type,并将脚本内容包裹在 CDATA 块中。如果没有 CDATA 部分,并且您的脚本使用了 <& 等字符,您的页面将会出现错误,因为 XML 解析器会将它们视为 XML 字符,而不是脚本。

注意

有一些倾向于将 SVG 视为 HTML,特别是当 SVG 内联在 HTML 文档中时。这是 Chrome 的做法。不过,还是遵循 XML 的要求更为保险。

DOM 方法,如 document.getElementById(),并非仅限于 HTML;它们也适用于任何 XML 文档,包括 SVG。新功能是 SVG 特有的 fill 属性,这是 SVG 元素(如 rect)中独特的属性。

警告

如果文件中的任何元素使用了命名空间,则必须使用命名空间版本的 DOM 方法。

解决方案中的代码是一个独立的 SVG 文件,扩展名为 .svg。如果我们要将 SVG 嵌入到 HTML 文件中,就像在 示例 15-2 中展示的那样,颜色变化动画将会同样起作用。CDATA 部分被移除,因为所有现代浏览器都理解 SVG 现在处于 HTML 上下文中。但如果文件是 XHTML,则需要将它们添加回去。

示例 15-2。来自 示例 15-1 的 SVG 元素,嵌入到 HTML 页面中
<!DOCTYPE html>
<html>
<head>
<title>Accessing Inline SVG</title>
<meta charset="utf-8">
</head>
<body>
<svg width="600" height="600">
  <script>
    // set element onclick event handler
    window.onload = function() {
      const square = document.getElementById('square');

      // onclick event handler, change circle radius
      square.onclick = function click() {
        const color = this.getAttribute('fill');
        if (color === '#ff0000') {
          this.setAttribute('fill', '#0000ff');
        } else {
          this.setAttribute('fill', '#ff0000');
        }
      };
    };
  </script>
  <rect id="square" width="400" height="400" fill="#ff0000"
 x="10" y="10" />
</svg>
</body>
</html>

上述示例直接将 SVG 嵌入到 HTML 页面中。您还可以使用 <object> 标签和回退的 <img> 标签在页面上嵌入包含 JavaScript 的 SVG 文件:

<object type="image/svg+xml" data="demo.svg">
    <img src="demo.svg" />
</object>

所有现代浏览器都支持 SVG,包括将 SVG 嵌入到 HTML 中。IE 在 9 版本之后支持 SVG。

注意

要了解更多关于 SVG 的内容,我推荐阅读 SVG Animations,作者是 Sarah Drasner(O’Reilly)。

额外内容:使用 SVG 库

与使用 Canvas 工作的库相比,用于处理 SVG 的库没有那么多,但现有的库非常方便。其中最流行的之一是 D3 库,详见“使用 D3 创建 SVG 条形图”。另外几个流行的库包括RaphaëlGreenSockSnap.svgSVG.js。所有这些库都可以简化 SVG 的创建和动画。以下代码片段展示了使用 Raphaël 的示例:

// Creates canvas 320 × 400 at 10, 50
const paper = Raphael(10, 50, 320, 400);
// Creates circle at x = 150, y = 140, with radius 100
const circle = paper.circle(150, 140, 100);
// Sets the fill attribute of the circle to red (#f00)
circle.attr("fill", "#f0f");
// Sets the stroke attribute of the circle to white
circle.attr("stroke", "#ff0");

从网页脚本访问 SVG

问题

您希望通过网页内的脚本修改 SVG 元素的内容。

解决方案

如果 SVG 直接嵌入到网页中,可以使用与访问任何其他网页元素相同的功能来访问元素及其属性:

const square = document.getElementById("square");
square.setAttribute("width", "500");

但是,如果 SVG 是通过object元素嵌入到页面中的外部 SVG 文件,您必须获取外部 SVG 文件的文档才能访问元素。该技术需要进行对象检测,因为不同浏览器的处理过程有所不同:

window.onload = function onLoad() {
  const object = document.getElementById('object');
  let svgdoc;

  try {
    svgdoc = object.contentDocument;
  } catch (e) {
    try {
      svgdoc = object.getSVGDocument();
    } catch (err) {
      console.log(err, 'SVG in object not supported in this environment');
    }
  }

  if (!svgdoc) return;

  const square = svgdoc.getElementById('square');
  square.setAttribute('width', '900');
};

讨论

解决方案中列出的第一个选项访问嵌入在 HTML 文件中的 SVG。您可以使用与访问 HTML 元素相同的方法访问 SVG 元素。

第二个选项有点复杂,它依赖于检索 SVG 文档的文档对象。第一种方法尝试在对象上访问contentDocument属性。如果失败,应用程序然后尝试使用getSVGDocument()来访问 SVG 文档。一旦您访问了 SVG 文档对象,您可以使用与网页本地元素相同的 DOM 方法。

示例 15-3 展示了将 SVG 添加到网页的第二种方式,以及如何在 HTML 中从脚本访问 SVG 元素。

示例 15-3. 从脚本中访问对象元素中的 SVG
<!DOCTYPE html>
<head>
  <title>SVG in Object</title>
  <meta charset="utf-8" />
</head>
<body>
  <object id="object" type="image/svg+xml" data="../demo1.svg">
    <p>No SVG support</p>
  </object>
  <script type="text/javascript">
    const object = document.getElementById('object');
    object.onload = function() {
      let svgdoc;

      // get access to the SVG document object
      try {
        svgdoc = object.contentDocument;
      } catch (e) {
        try {
          svgdoc = object.getSVGDocument();
        } catch (err) {
          console.log(err, 'SVG in object not supported in this environment');
        }
      }

      if (!svgdoc) return;

      // get SVG element and modify
      const square = svgdoc.getElementById('square');
      square.onclick = function() {
        let width = parseFloat(square.getAttribute('width'));
        width -= 50;
        square.setAttribute('width', width);
        const color = square.getAttribute('fill');
        if (color == 'blue') {
          square.setAttribute('fill', 'yellow');
          square.setAttribute('stroke', 'green');
        } else {
          square.setAttribute('fill', 'blue');
          square.setAttribute('stroke', 'red');
        }
      };
    };
  </script>
</body>

在示例代码中,对象在加载后被访问;然后访问object.onload事件处理程序以获取 SVG 文档,并将函数分配给onclick事件处理程序。

使用 D3 创建 SVG 条形图

问题

您希望创建一个可伸缩的条形图,但希望避免必须创建所有图形的最后一点。

解决方案

使用 D3 和 SVG 创建一个图表,该图表绑定到您的应用程序提供的一组数据。示例 15-4 展示了使用 D3 创建的垂直条形图,显示了每个条形的高度。

示例 15-4. 使用 D3 创建的 SVG 条形图
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>SVG Bar Chart using D3</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.15.0/d3.min.js"></script>
  </head>
  <body>
    <script type="text/javascript">
      const data = [56, 99, 14, 12, 46, 33, 22, 100, 87, 6, 55, 44, 27, 28, 34];

      const height = 400;
      const barWidth = 25;

      const x = d3
        .scaleLinear()
        .domain([0, d3.max(data)])
        .range([0, height]);

      const svg = d3
        .select('body')
        .append('svg')
        .attr('width', data.length * (barWidth + 1))
        .attr('height', height);

      svg
        .selectAll('rect')
        .data(data)
        .enter()
        .append('rect')
        .attr('fill', '#008b8b')
        .attr('x', function(d, i) {
          return i * (barWidth + 1);
        })
        .attr('y', function(d) {
          return height - x(d);
        })
        .attr('width', barWidth)
        .attr('height', x);
    </script>
  </body>
</html>

讨论

D3 不是一个标准的图形工具,它根据您提供的尺寸创建形状。使用 D3,您提供一组数据,用于可视化数据的对象,然后静静地让它运行。听起来很简单,但要获得这种数据可视化的好处,您必须正确设置它,当您开始使用库时可能会有挑战。

首先要注意,D3 方法链 的应用达到了最大化。是的,你可以分别调用方法,但使用库的链式支持更清晰、更干净、更高效。

在解决方案中,第一行是将数据集创建为数组。D3 期望数据点在数组中,尽管每个元素可以是一个对象,也可以是一个简单的值,如解决方案所示。接下来,定义了柱状图的最大高度以及每个柱子的宽度。接下来,我们开始使用 D3。

注意

D3,由 Mike Bostock 创建,是一个强大的数据可视化工具,并不是你可以在一个懒散的下午就能掌握的东西。然而,它是一个非常值得学习的工具,所以请将这个示例看作是激发你兴趣的引子,而不是一个权威的介绍。

要深入了解,我推荐 D3 快速入门,作者是 Philipp Janert(O’Reilly)。

我本可以在网页上添加一个静态 SVG 元素,但我想演示 D3 如何创建一个元素。通过创建 SVG 元素,我们还可以获取到它的引用以便今后的工作,尽管我们也可以使用 D3 来获取现有元素的引用。在代码中,使用 D3 的 select() 方法获取了对 body 元素的引用。一旦这样做,就通过 append() 方法向 body 元素附加了一个新的 SVG 元素,并通过 attr() 函数给它赋予属性。元素的高度已经预定义,但宽度等于数据元素的数量乘以柱子宽度(+1,以提供必要的间距)。

一旦创建了 SVG 元素,代码使用 D3 的 scale 功能来确定元素高度和每个柱子高度之间必要的比例,以使柱状图填充 SVG 元素,但每个柱子的高度是成比例的。它通过使用 scale.linear() 来创建线性比例。根据 D3 文档,“映射是线性的,即输出范围值 y 可以表达为输入域值 x 的线性函数:y = mx + b。”

domain() 函数设置比例尺的输入域,而 range() 设置输出范围。在解决方案中,给定域的值是从数据集中的零到最大值,通过调用 max() 确定。给定的范围值是从零到 SVG 元素的高度。然后将一个函数返回给一个变量,当调用时会将传递给它的任何数据归一化。如果函数给定一个等于最大数据值高度的值,返回值将等于元素的高度(在这种情况下,最大数据值 100 返回的缩放值为 400)。

代码的最后部分是创建条形图的部分。我们需要一些东西来处理,所以代码调用selectAll()选择rect。SVG 块中还没有rect元素,但我们将添加它们。数据通过data()方法传递给 D3,然后调用enter()函数。enter()函数的作用是处理数据并为所有缺失的元素返回占位符。在解决方案中,为所有 15 个rect元素(每个柱形条的一个)创建了占位符。

然后在 SVG 元素中附加了一个rect元素,并使用attr()设置了每个元素的属性。在解决方案中,提供了fillstroke,尽管这些可以在页面的样式表中定义。接下来,为x属性的位置或条形图的左下角属性提供了一个函数,其中d是当前数据(数据值),i是当前索引。对于x属性,索引乘以barWidth,再加上一(1),以考虑间距。

对于y属性,我们需要有些技巧。SVG 的原点是左上角,这意味着增加y的值会使图表向下移动,而不是向上。为了反转这一点,我们需要从高度减去y的值。然而,我们不能直接这样做。如果代码直接使用传递给它的数据,那么我们将得到一个比例相当小、压缩的柱状图。相反,我们需要使用新创建的比例尺函数x,将数据传递给它。

每个条形图的宽度是常量barWidth给定的值,高度只是比例尺函数的变量,相当于调用比例尺函数并传递数据。所有这些都创建了图表,显示在图 15-1 中。

jsc3 1501

图 15-1. 柱状图示例,每个条形的高度标准化以填充给定空间

将 SVG 和 Canvas 元素集成到 HTML 中

问题

您希望在网页中同时使用canvas元素和 SVG。

解决方案

一种选择是直接将 SVG 和canvas元素嵌入到 HTML 页面中,然后从 SVG 中的脚本访问canvas元素:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Integrating SVG and the Canvas Element in HTML</title>
  </head>
  <body>
    <canvas id="myCanvas" width="400px" height="100px">
      <p>canvas item alternative content</p>
    </canvas>

    <svg id="svgelem" height="400">
      <title>SVG Circle</title>
      <script type="text/javascript">
      window.onload = function () {
        var context = document.getElementById("myCanvas").getContext('2d');
        context.fillStyle = 'rgba(0,200,0,0.7)';
        context.fillRect(0,0,100,100);
      }
      </script>
      <circle id="redcircle" cx="100" cy="100" r="100" fill="red" stroke="#000" />
    </svg>
  </body>
</html>

或者您可以直接将canvas元素嵌入 SVG 中作为外来对象:

<!DOCTYPE html>
<html>
<head>
<title>Accessing Inline SVG</title>
<meta charset="utf-8">
</head>
<body>
<svg id="svgelem" height="400" width="600">
   <script type="text/javascript">
      window.onload = function () {
         var context2 = document.getElementById("thisCanvas").getContext('2d');

         context2.fillStyle = "#ff0000";
         context2.fillRect(0,0,200,200);
       };
   </script>

   <foreignObject width="300" height="150">
      <canvas width="300" height="150" id="thisCanvas">
         alternate content for browsers that do not support Canvas
      </canvas>
   </foreignObject>
   <circle id="redcircle" cx="300" cy="100" r="100" fill="red" stroke="#000" />
  </svg>
</body>
</html>

讨论

当 SVG 元素嵌入到当前网页中时,您可以从 SVG 内部访问 HTML 元素。但是,您也可以直接在 SVG 中嵌入元素,使用 SVG 的foreignObject元素。此元素允许我们嵌入 XHTML、MathML、RDF 或任何其他基于 XML 的语法。

在两个解决方案中,我都能使用getElementById()。但是,如果我想使用其他方法来操作元素,例如getElementsByTagName(),我必须注意使用哪个版本的方法。例如,我可以使用getElementsByTagName()获取外部的canvas元素,但如果包含的对象是 XML(例如 RDF/XML),则需要使用命名空间版本的方法getElementsByTagNameNS。由于解决方案中嵌入的对象是 HTML5,因此不需要命名空间。

一旦获得 Canvas 上下文,就像在 HTML 中从脚本中使用元素一样:添加矩形,绘制路径,创建弧线等。

额外:Canvas?还是 SVG?

为什么要使用 Canvas 而不是 SVG,或者 SVG 而不是 Canvas? canvas元素在帧动画中更快。每次动画时,浏览器只需重新绘制更改的像素,而不是重新创建整个场景。但是,当您需要支持从智能手机到大型显示器的各种屏幕尺寸时,canvas元素动画的优势减弱。SVG 具有出色的缩放性。

SVG 的另一个优势是,在强大的库的帮助下,它在丰富的数据可视化中占据一席之地。但是,Canvas 与 WebGL 等 3D 系统一起使用。

将 SVG 和 Canvas 结合使用的一个用途是为 Canvas 元素提供后备:SVG 写入 DOM 并在 JavaScript 关闭时持续存在,而canvas元素则不会。

当音频文件开始播放时运行例行程序

问题

您想在音频文件开始或结束播放时提供音频文件并分享额外信息。

解决方案

使用 HTML5 audio元素:

<audio id="meadow" controls>
  <source src="meadow.wav" type="audio/wav" />
  <p><a href="meadow.wav">Meadow sounds</a></p>
</audio>

并捕获其play事件(播放已开始)或ended事件(播放已完成):

const meadow = document.getElementById('meadow');
meadow.addEventListener('play', aboutAudio);

然后显示信息:

function aboutAudio() {
  const info = 'A summer field near a lake in July.';
  const txt = document.createTextNode(info);
  const div = document.createElement('div');
  div.appendChild(txt);
  document.body.appendChild(div);
}

讨论

HTML5 添加了两个媒体元素:audiovideo。这些简单易用的控件提供了播放音频和视频文件的方法。

在解决方案中,audio元素的controls布尔属性已设置,因此显示了控件。该元素的src是用于浏览器内播放的 WAV 音频文件。此外,提供了 WAV 文件的链接作为后备,这意味着使用不支持audio的浏览器的用户仍然可以访问声音文件。我还可以提供object元素或其他后备内容。

注意

WAV 是广泛支持的音频格式,但不同浏览器支持各种格式和文件类型。Mozilla 开发者网络有一个详细的表格,列出了各种浏览器支持的音频和视频编解码器,而维基百科维护了一个简单的浏览器支持表格,用于音频编码格式。

媒体元素提供一组方法来控制播放,以及在事件发生时可以触发的事件。在解决方案中,捕获了 ended 事件,并将事件处理程序分配给 aboutAudio(),在播放结束后显示有关文件的消息。请注意,尽管代码在使用窗口加载事件时使用了 DOM Level 0 事件处理程序,但在 audio 元素中使用了 DOM Level 2 事件处理。此事件处理程序在不同浏览器中的支持情况不稳定,因此强烈建议您使用 addEventListener()。但是,onended 在直接在元素中使用时似乎没有问题:

<audio id="meadow" src="meadow.wav" controls onended="alert('All done')">
  <p><a href="meadow.wav">Meadow sounds</a></p>
</audio>

看到当前支持这些元素的所有浏览器中元素的外观是很有趣的。没有标准的外观,所以每个浏览器都提供了自己的解释。您可以通过提供自己的播放控件并使用自己的元素/CSS/SVG/Canvas 来提供装饰来控制外观。

使用视频元素从 JavaScript 控制视频

问题

您希望在网页中嵌入视频,并希望无论浏览器和操作系统如何,视频控件的外观保持一致。

解决方案

使用 HTML5 video 元素:

<video id="meadow" poster="purples.jpg" >
   <source src="meadow.m4v" type="video/mp4"/>
   <source src="meadow.ogv" type="video/ogg" />
</video>

您可以通过 JavaScript 为其提供控件,如示例 15-5 所示。按钮用于提供视频控件,并在 div 元素中使用文本来提供播放期间时间的反馈。

示例 15-5. 为 HTML5 视频元素提供自定义控件
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Controlling Video from JavaScript with the video Element</title>
    <style>
      video {
        border: 1px solid black;
        max-width: 600px;
      }
    </style>
  </head>
  <body>
    <h1>Controlling Video from JavaScript with the video Element</h1>

    <video id="meadow" controls>
      <source src="meadow.mp4" type="video/mp4" />
      <source src="meadow.webm" type="video/webm" />
    </video>
    <div id="feedback"></div>
    <div id="controls">
      <button id="start">Play</button>
      <button id="stop">Stop</button>
      <button id="pause">Pause</button>
    </div>

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

而且在 video.js 中:

// dom elements
const meadow = document.getElementById('meadow');
const start = document.getElementById('start');
const pause = document.getElementById('pause');
const stop = document.getElementById('stop');

// start video, enable stop and pause
// disable play
function startPlayback() {
  meadow.play();
  pause.disabled = false;
  stop.disabled = false;
  this.disabled = true;
}

// pause video, enable start, disable stop
// disable pause
function pausePlayback() {
  meadow.pause();
  pause.disabled = true;
  start.disabled = false;
  stop.disabled = true;
}

// stop video, return to zero time
// enable play, disable pause and stop
function stopPlayback() {
  meadow.pause();
  meadow.currentTime = 0;
  start.disabled = false;
  pause.disabled = true;
  this.disabled = true;
}

// for every time divisible by 5, output feedback
function reportProgress() {
  const time = Math.round(this.currentTime);
  const div = document.getElementById('feedback');
  div.innerHTML = `${time} seconds`;
}

// event listeners
document.getElementById('start').addEventListener('click', startPlayback);
document.getElementById('stop').addEventListener('click', stopPlayback);
document.getElementById('pause').addEventListener('click', pausePlayback);
meadow.addEventListener('timeupdate', reportProgress);

讨论

HTML5 video 元素和 HTML5 audio 元素一样,可以使用其内置控件进行控制,或者您可以提供自定义控件。媒体元素支持以下方法:

play

开始播放视频

pause

暂停视频

load

预加载视频但不开始播放

canPlayType

测试用户代理是否支持视频类型

媒体元素不支持停止方法,因此该代码通过暂停视频播放然后设置视频的 currentTime 属性为 0 来模拟停止。我还使用 currentTime 打印视频时间,使用 Math.round 将时间四舍五入到最接近的秒。

视频控件提供两种不同的视频编解码器:H.264(.mp4)和 VP8(.webm)。几乎所有现代浏览器都支持 WebM 文件格式,但包含 MP4 文件可以为支持video元素的旧浏览器提供备用。

视频和音频控件本身支持键盘访问。如果替换控件,您需要为您的替代品提供可访问性信息。

注意

在解决方案中演示的视频播放功能可以直接使用未加密的视频(或音频)文件。如果视频(或音频)文件已加密,则需要更多的工作来使视频播放,可以使用 HTML 5.1 W3C Encrypted Media Extensions (EME)。

W3C EME 工作草案 已经在 Internet Explorer 11、Chrome、Firefox、Microsoft Edge 和 Safari 中实现。

第十六章:写作 Web 应用程序

尽管 JavaScript 曾经用于向网页添加简单的交互功能,但今天它可以用于构建复杂和功能完善的软件应用程序,在 Web 浏览器中运行。可能性包括地图、电子邮件客户端、流媒体视频站点、实时聊天应用程序等等。网站和应用程序之间的界限可能模糊,但一种思考方式是,应用程序是任何接受用户输入并返回结果的站点。

作为开发者,你可以开发这些应用并立即在全球范围内部署,但这种能力带来了独特的挑战。随着应用程序代码库的增长,你需要将代码库拆分为更小的模块,并确保用户接收到优化的代码包。你需要创建与原生移动应用程序相竞争的功能和体验,例如离线功能、通知和应用程序图标。幸运的是,现代 JavaScript 和浏览器 API 使这些功能丰富的体验成为可能。

打包 JavaScript

问题

你希望在浏览器环境中使用 JavaScript 模块。

解决方案

利用原生 JavaScript 模块或打包工具,如Webpack

现代浏览器中支持原生 JavaScript 模块。如果我们有一个简单的模块导出一个值,命名为mod.js

export const name = 'Riley';

我们可以在 HTML 文件中原生地使用模块:

<script type='module'>
  import {name} from './mod.js';
  console.log(name);
</script>

对于更高级的应用程序和站点,你可能会从使用一个能够优化你的模块的打包工具中受益。要将 Webpack 用作打包工具,首先使用 npm 安装其依赖项:

$ npm install webpack webpack-cli --save-dev
注意

在能够从 npm 安装包之前,你的项目将需要一个package.json文件。要生成此文件,请确保你在项目目录的根目录中,并输入npm init。然后,命令行界面将指导你完成一系列提示。有关安装和使用 npm 的其他信息,请参阅第一章。

然后,我们可以在项目目录的根目录中创建一个名为webpack.config.js的文件,在其中指定入口文件和输出目录:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
};

最后,在package.json中添加一个脚本来运行 Webpack 构建:

"scripts": {
  ...
  "build": "webpack"
}

讨论

JavaScript 模块现在广泛可用并受浏览器支持。这使我们能够将我们的代码分解为更小、更易维护的部分。

Webpack 是一个流行的编译 JavaScript 模块的工具。Webpack 的强大之处在于配置文件。

在之前的配置文件中,我们指示 Webpack 查看src目录中名为index.js的文件。这个文件将是我们项目 JavaScript 的入口文件:

import foo from './foo.js';
import bar from './bar.js';

foo();
bar();

index.js文件正在导入另外两个文件,foo.jsbar.js

当运行build脚本时,Webpack 将在dist目录中输出一个名为bundle.js的新的压缩文件。

编译简单的导入语句只是冰山一角。Webpack 可用于热模块重载、代码拆分、浏览器支持的填充以及作为开发服务器使用。在“JavaScript 与移动 Web”中,我们将探讨 Webpack 如何用于减少 JavaScript 捆绑包的大小。

额外:使用 npm 模块

除了使用自己的模块之外,Webpack 还使您能够直接从npm下载和使用模块。为此,请先安装模块并将其保存为项目的依赖项:

$ npm install some-module --save

然后,您可以直接在代码中要求模块,而无需指定模块的路径:

import some-code from 'some-module'

JavaScript 与移动 Web

问题

您的网站或应用程序使用 JavaScript,这可能会显著增加在移动设备和慢速连接上加载的时间。

解决方案

对于仅使用少量 JavaScript 的单个文件站点,请使用像UglifyJS这样的工具来缩小您的 JavaScript。缩小将通过删除不必要的字符(例如空格)来减少 JavaScript 文件的大小。

要使用 UglifyJS,请先通过 npm 安装它:

$ npm install uglify-js

接下来,在您的package.json文件中添加一个脚本,指定输入 JavaScript 文件和缩小后的文件名:

"scripts": {
  "minify": "uglifyjs index.js --output index.min.js"
}

对于具有多个 JavaScript 文件的较大站点和应用程序,请使用捆绑工具,例如Webpack,执行缩小、代码拆分、树摇和延迟加载的组合。

Webpack 在生产模式下会自动对其输出进行缩小,因此不需要特定的配置或缩小工具。

代码拆分是生成多个捆绑包的过程,因此 HTML 页面或模板仅加载它们所需的代码。以下webpack.config.js文件将在dist目录中输出两个 JavaScript 文件(index.bundle.jssecondary.bundle.js):

const path = require('path');

module.exports = {
  entry: {
    index: './src/index.js',
    secondary: './src/secondary.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

捆绑包的大小可能会激增,特别是在导入可能不需要的第三方库功能时。 树摇 是消除死代码或未使用代码的概念。Webpack 可以通过optimization设置进行配置以消除死代码:

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js',
    secondary: './src/secondary.js'
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    usedExports: true
  }
};

进行代码拆分的最后一步是在项目的package.json文件中添加一个sideEffects字段。根据 Webpack 文档,“side effect”被定义为在导入时执行特殊行为的代码,除了暴露一个或多个导出。一个side effect的示例可能是全局填充,它不会暴露任何export语句。

如果不存在这样的文件,我们可以在package.json中设置以下内容:

"sideEffects": false

如果您的项目确实包含 JavaScript 文件,这些文件将属于“side effect”类别,我们可以将它们作为一个数组提供:

"sideEffects": [
  "./src/file-with-side-effect.js"
]

最后,我们可以利用 Webpack 实现 JavaScript 模块的延迟加载,仅在用户与浏览器交互时加载它们。Webpack 通过动态的 import 语句使此过程变得简单。在 src 目录中有一个名为 button.js 的文件,当用户点击按钮时,文件的内容可以被加载。在 index.js 中:

const buttonElement = document.getElementById('button');
buttonElement.onclick = e =>
  import(/* webpackChunkName: "button" */ './button').then(module => {
    const button = module.default;
    button();
  });

讨论

最快的 JavaScript 是没有 JavaScript;然而,现代 Web 应用程序的交互需求通常依赖于客户端 JavaScript。考虑到这一点,我们的目标是限制用户浏览器下载的 JavaScript 的数量和文件大小。利用诸如代码缩小、代码分割、摇树和延迟加载等策略,可以更精细地控制用户浏览器中加载的 JavaScript 的大小和数量。

参见

Webpack 的入门指南是关于代码打包和 Webpack 配置文件的有用介绍。

编写渐进式 Web 应用程序

问题

你想让你的 Web 应用程序利用本机应用程序功能,例如快速加载时间、离线功能和应用程序启动图标。

解决方案

将你的 Web 应用程序转变为渐进式 Web 应用程序(PWA)。术语“渐进式 Web 应用程序”用来描述一组技术,这些技术结合在一起,使得 Web 应用程序可以使用类似本机应用程序的功能,例如离线功能和用户安装的应用程序图标,同时使用标准的 Web 技术构建和部署到 Web。

所有 PWA 都需要包含超出典型网页的两个功能:

应用程序清单

为浏览器定义特定应用程序功能。

服务工作者

启用应用程序的离线功能。

创建渐进式 Web 应用程序的第一步是添加 Web 应用程序清单文件。该文件使开发人员可以控制应用程序图标、启动画面、浏览器显示样式和视图方向等内容。在名为 manifest.json 的文件中:

{
  "name": "JavaScript Everywhere",
  "short_name": "JavaScript",
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#ffc40d",
  "theme_color": "#ffc40d",
  "icons": [
    {
      "src": "/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

现在,在你的 HTML 文件或模板中,在文档的 <head> 中添加对清单文件和适当应用程序图标的引用。

示例 16-1. PWA 元标签
<!-- link to manifest.json file -->
<link rel="manifest" href="manifest.json" />
<!-- link to iOS icons -->
<link rel="apple-touch-icon" sizes="180x180" href="images/icons/apple-touch-icon.png" />
<!-- Microsoft application tile icons and color settings -->
<meta name="msapplication-TileColor" content="#ffc40d" />
<meta name="msapplication-TileImage" content="/img/icons/mstile-310x310.png" />
<!-- set theme color -->
<meta name="theme-color" content="#ffc40d" />

当网站符合 PWA 标准时(参见图 16-1),Chrome 会自动触发 PWA 安装提示。一旦安装完成,PWA 的图标会出现在用户设备上,就像本机应用程序一样(参见图 16-2)。

在 Chrome 中的安装提示的屏幕截图

图 16-1. PWA 安装提示

在移动设备上显示应用图标的屏幕截图

图 16-2. 应用程序可以保存到移动设备上

第二步是创建服务工作线程。服务工作线程是一个独立于页面运行的脚本,为我们提供一种方式使我们的站点脱机工作、运行更快,并添加后台功能的能力。在移动连接的限制下,服务工作线程为我们提供一种构建首次脱机能力应用程序的方式,这些应用程序将在用户首次访问站点后加载内容,而不管网络条件如何。最重要的是,服务工作线程真正是一种渐进增强,为支持的浏览器增加了额外的功能,而不会改变非支持浏览器用户的站点功能。

当引入服务工作线程时,初始步骤是向用户的浏览器注册包含我们服务工作线程代码的脚本。为了实现这一点,请在页面底部,在闭合</body>标签之前添加脚本注册:

<!-- initiate the service worker -->
<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function() {
      navigator.serviceWorker
        .register('service-worker.js')
        .then(reg => {
          console.log('Service worker registered!', reg);
        })
        .catch(err => {
          console.log('Service worker registration failed: ', err);
        });
    });
  }
</script>

此脚本检查服务工作线程的支持情况,如果支持可用,则指向服务工作线程脚本(在本例中为 service-worker.js)。为了调试目的,脚本还会捕获错误并记录到控制台。

service-worker.js 文件中,开始通过指定缓存版本并列出浏览器应该缓存的文件,设置 installfetchactivate 事件监听器。

var cacheVersion = 'v1';

filesToCache = [
  'index.html',
  '/styles/main.css',
  '/js/main.js',
  '/images/logo.svg'
]
注意

对于网站的更改,需要更新cacheVersion,否则用户可能会从缓存中获取内容。

现在,在 service-worker.js 文件中,设置 installfetchactivate 事件监听器。install 事件为浏览器提供了安装缓存文件的指令。fetch 事件通过指示浏览器加载缓存文件或通过网络接收的文件来处理 fetch 事件。最后,当服务工作线程被激活时触发 activate 事件,可以用于检查缓存中的现有项目,并在存在更新的 cacheVersion 和文件不再在 filestoCache 列表中时移除它们(见 图 16-3)。

const cacheVersion = 'v1';

const filesToCache = ['index.html', '/styles/main.css', '/js/main.js'];

self.addEventListener('install', event => {
  console.log('Service worker install event fired');
  event.waitUntil(
    caches.open(cacheVersion).then(cache => {
      return cache.addAll(filesToCache);
    })
  );
});

self.addEventListener('fetch', event => {
  console.log('Fetch intercepted for:', event.request.url);
  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keyList => {
      return Promise.all(
        keyList.map(key => {
          if (key !== cacheVersion) {
            return caches.delete(key);
          }
        })
      );
    })
  );
});

应用程序离线模式运行的屏幕截图

图 16-3. 配备服务工作线程后,应用可以在离线状态下加载文件。

讨论

渐进式 Web 应用是一种用户可安装的 Web 应用程序,具有某种离线功能。这些特性使 Web 应用程序能够紧密模仿原生应用程序的最佳特性,同时提供开放 Web 的好处。

Web 应用清单是一个提供有关应用程序信息的 JSON 文件。它可以包含的所有关键值的完整列表如下:

background_color

用于占位启动屏幕背景的颜色代码。

categories

应用程序所属类别的字符串数组。

description

应用程序的字符串描述。

dir

显示字符的方向。可以是autoltr(从左到右)或rtl(从右到左)。

display

首选的显示模式。可以是browser,表示默认的浏览器行为,或者是fullscreen,在某些设备上会减少浏览器的外壳。

iarc_rating_id

国际年龄评级值。

icons

一个链接到图标图像和描述的对象数组。

lang

标识应用程序的主要语言。

name

应用程序名称。

orientation

允许开发人员设置应用程序的默认方向。

prefer_related_applications

如果设置为true,允许开发人员指定应安装的相关应用程序,而不是 Web 应用程序。

related_applications

包含一系列相关本地应用程序列表的对象数组。

scope

包含应用程序的导航范围的字符串。指定范围会限制应用模式下的导航到该目录。

screenshots

一个应用程序截图数组。

short_name

应用程序名称的缩短版本,用于在显示完整名称太长时的上下文中使用。

start_url

用户启动应用程序时应该打开的 URL。

theme_color

定义应用程序的默认主题颜色的字符串。

W3C 提供了一个示例,展示了一个面向 Web 游戏的强大清单文件:

{
  "lang": "en",
  "dir": "ltr",
  "name": "Super Racer 3000",
  "description": "The ultimate futuristic racing game from the future!",
  "short_name": "Racer3K",
  "icons": [{
    "src": "icon/lowres.webp",
    "sizes": "64x64",
    "type": "image/webp"
  },{
    "src": "icon/lowres.png",
    "sizes": "64x64"
  }, {
    "src": "icon/hd_hi",
    "sizes": "128x128"
  }],
  "scope": "/racer/",
  "start_url": "/racer/start.html",
  "display": "fullscreen",
  "orientation": "landscape",
  "theme_color": "aliceblue",
  "background_color": "red",
  "screenshots": [{
    "src": "screenshots/in-game-1x.jpg",
    "sizes": "640x480",
    "type": "image/jpeg"
  },{
    "src": "screenshots/in-game-2x.jpg",
    "sizes": "1280x920",
    "type": "image/jpeg"
  }]
}

除了 Web 应用清单文件外,某些平台(如 iOS 和 Windows)还需要提供额外的信息,这些信息可以通过 HTML 元标签提供。在示例 16-1 中,元标签用于定义主题颜色、iOS 图标和 Windows 平铺设置。

提示

为所有不同的设备类型和分辨率生成图标可能是一件繁琐的事情,因此我建议使用RealFaviconGenerator

服务工作线程是浏览器在后台运行的脚本,与页面的渲染和执行并行进行。由于它是一个“工作线程”,因此服务工作线程无法直接访问 DOM,但这种并行脚本能够实现各种新的用例。其中最令人兴奋的用例之一是能够缓存应用程序的各个部分以供离线使用。在上面的示例中,我正在缓存一个 HTML、JavaScript 和 CSS 文件,以便在离线时提供一个完整的(尽管是最小化的)站点体验。其他用例可能包括创建一个独立的离线体验或缓存共享模板标记和样式,通常称为“应用程序外壳”。

在使用服务工作线程时,需要注意一些限制:

  • 使用服务工作线程的站点必须通过 HTTPS 提供。

  • 当用户处于私密浏览模式时,服务工作线程不起作用。

  • 由于服务工作线程在浏览器中作为一个独立的线程运行,因此它们无法访问 DOM。

  • 服务工作线程是有作用域的,这意味着它们应该放置在应用程序的根目录中。

  • 缓存存储大小可能因浏览器和用户硬盘空间的可用情况而异。

尽管在上述示例中手动创建了服务工作者,但对于更大的应用程序来说,这很快就变得难以管理。由 Google 创建的 Workbox 库是用于管理 Web 应用程序中服务工作者和离线功能的包。Workbox 大大简化了版本控制和缓存管理的痛点,以及背景同步和预缓存等高级功能。

渐进式 Web 应用程序对 Web 是一大步进,它与框架无关,这意味着可以使用简单的 HTML、CSS 和 JavaScript 构建,也可以使用最新的 JavaScript 框架。在本节中,我们只是浅尝这些技术的强大之处。Tal Alter 的书籍 Building Progressive Web Apps(O’Reilly 出版)详细介绍了渐进式 Web 应用程序的特性和功能。

测试和分析渐进式 Web 应用程序

问题

您希望测试是否成功满足了渐进式 Web 应用程序的要求。

解决方案

使用 Lighthouse 来审计性能、可访问性、最佳实践、SEO 和渐进式 Web 应用程序标准。访问网站(无论是在生产环境还是本地 Web 服务器上),然后单击“生成报告”(见图 Figure 16-4)。

Lighthouse 随后将生成报告,提出任何分数降低的建议性改进(参见图 16-5 和 16-6)。

Google Lighthouse 的截图

图 16-4. Chrome 开发者工具中的 Lighthouse

一个高 Lighthouse 分数站点的截图

图 16-5. 高分表明应用程序性能优越,是成功的渐进式 Web 应用。

一个低 Lighthouse 分数站点的截图

图 16-6. 分数低的站点还将获得改进建议
注意

关于在 Chrome 开发者工具中使用 Lighthouse 对非渐进式 Web 应用程序站点进行性能分析的一般用法更详细地介绍在 “Using Lighthouse to Measure Best Practices” 中。

讨论

Lighthouse 是一款测量 Web 最佳实践的工具,包括性能和渐进式 Web 应用程序兼容性。它内置于 Chrome 开发者工具中,但也可以作为 Firefox 扩展安装。

除了作为浏览器工具外,Lighthouse 还可以通过 npm 安装,并且可以在命令行中使用或作为 Node 模块使用。您可以像安装其他 Node 模块一样安装 Lighthouse:

$ npm install -g lighthouse

然后可以通过传递 URL 作为参数来运行:

$ lighthouse https://www.oreilly.com/

通过传递 --view 参数将在浏览器中打开结果:

$ lighthouse https://www.oreilly.com/ --view

您还可以指定输出文件类型和位置来存储报告结果:

$ lighthouse https://www.oreilly.com/ --view --output html --output-path ./report.html

budget.json 文件可用于设置和测试性能预算限制。在 budget.json 文件中,定义要测试的限制:

[
  {
    "path": "/*",
    "timings": [
      {
        "metric": "interactive",
        "budget": 3000
      },
      {
        "metric": "first-meaningful-paint",
        "budget": 1000
      }
    ],
    "resourceSizes": [
      {
        "resourceType": "script",
        "budget": 125
      },
      {
        "resourceType": "total",
        "budget": 300
      }
    ],
    "resourceCounts": [
      {
        "resourceType": "third-party",
        "budget": 10
      }
    ]
  }
]
提示

谷歌 Chrome 团队维护着一个包含 budget.json 选项文档的存储库

从命令行本地测试对本地开发有帮助,但作为代码模块使用 Lighthouse 的真正威力在于与 GitHub Actions、Circle CI、Jenkins 和 Travis CI 等持续集成工具一起使用。Lighthouse CI 模块使您能够在持续集成流水线中执行 Lighthouse 测试,例如在每个 GitHub 拉取请求上。

这是 CircleCI 的一个示例配置:

version: 2.1
jobs:
  build:
    docker:
      - image: circleci/node:10.16-browsers
    working_directory: ~/your-project
    steps:
      - checkout
      - run: npm install
      - run: npm run build
      - run: sudo npm install -g @lhci/cli@0.3.x
      - run: lhci autorun

在 Google 的入门指南中可以找到如何在多个 CI 环境中使用 Lighthouse 的详细信息。

获取当前 URL 的值

问题

您的应用程序需要读取当前 URL 的值。

解决方案

使用 window.locationhref 属性来读取当前完整 URL 的值:

const URL = window.location.href;

讨论

window.location 提供了关于当前文档位置或 location 的只读信息。href 属性提供了完整的 URL,包括协议(如 HTTPS)、主机名、当前文档的路径和任何查询字符串。所有这些将与用户的 URL 栏中显示的内容匹配:

const URL = window.location.href;
// logs https://www.jseverywhere.io/example
console.log(`The current URL is ${URL}`);
注意

全局变量 locationwindow.location 相同;但我更喜欢使用 window API 来表达清晰。

href 属性并非唯一有用的属性。如果您已经知道用户在您的站点上,访问 pathnamesearch 属性可能更有用:

// user is at https://www.jseverywhere.io/example?page=2

const PATH = window.location.pathname;
// logs /example/
console.log(`The current path is ${PATH}`);

const QUERY = window.location.search;
// logs ?page=2
console.log(`The current query parameter is ${QUERY}`)

window.location 的全部只读属性列表如下:

hash

URL 中的哈希值,例如 #id

host

域名加端口

hostname

域名

href

完整 URL

origin

协议、主机名和端口

pathname

当前文档的路径

port

服务器端口号值

protocol

协议(HTTP 或 HTTPS)

search

查询字符串值

重定向 URL

问题

你需要使用 JavaScript 将用户路由到另一页。

解决方案

根据重定向目的,可以使用 window.locationassignreplace 方法:

// route user to new page & preserve browser history
window.location.assign('https://www.example.com');
// route user to new page but do not preserve current page in history
window.location.replace('https://www.example.com');

window.location.assign 方法会将用户路由到一个新 URL,但会保留浏览器历史记录中的当前页面。这意味着用户可以使用浏览器的返回按钮返回该页面。相反,window.location.replace 会替换历史记录中的当前 URL,禁用返回到当前页面的功能。

讨论

通过使用window.location方法,您可以使用 JavaScript 将用户路由到新的 URL。这使您能够根据页面交互重新路由用户或重定向用户。assignreplace 不是您唯一可以使用的 window.location 方法。完整的方法列表如下:

.assign()

将用户的浏览器导航到给定的 URL

.reload()

重新加载页面

.replace()

将用户的浏览器导航到给定的 URL,并从浏览器历史记录中删除当前文档

toString()

将当前 URL 作为字符串返回

通过利用这些方法,您将能够使用 JavaScript 来操纵页面的路由,这可以为应用程序 UI 和交互式路由提供有用的功能。尽管在开发应用程序时这些功能非常有用,但应始终使用 HTTP 重定向执行完整页面重定向,具体的状态码为 301 表示永久重定向或 302 表示临时重定向。

注意

流行的 JavaScript 框架带有路由库或可以通过第三方路由库进行扩展,用于强大的客户端路由。

将文本复制到用户的剪贴板

问题

您的应用程序需要复制文本,例如共享链接,到用户的剪贴板。

解决方案

要将文本复制到用户的剪贴板,将文本放在文本inputtextarea元素内,并使用navigator.clipboard.writeText方法复制文本。

在你的 HTML 中,包括表单元素以及一个按钮。在示例中,我为输入元素设置了一个明确的value。这个值也可以由用户或者在代码中动态设置:

<input type="text" id="copy-text" value="https://example.com/share/12345">
<button id="copy-button">Copy To Clipboard</button>

并在相应的 JavaScript 中,向button元素添加事件处理程序。当点击按钮时,使用select方法选择input元素内的文本,然后使用navigator.clipboard.writeText()将文本复制到用户的剪贴板,如示例 16-2 所示。

示例 16-2. 复制文本到剪贴板
const copyText = document.getElementById('copy-text');
const copyButton = document.getElementById('copy-button');

const copyToClipboard = () => {
  copyText.select();
  navigator.clipboard.writeText(copyText.value);
};

copyButton.addEventListener('click', copyToClipboard);

讨论

在 Web 应用程序中经常看到从文本输入框向用户剪贴板添加文本的常见 UI 模式,例如 GitHub 和 Google Docs。这可以是一个有用的功能,用于简化用户共享信息或 URL 的方式。主要食谱中演示的输入和按钮模式是最常见的使用方式,但有时您可能希望从页面内容的用户选择中复制。在这种情况下,隐藏表单控件可能会很有用。为此,请包括页面内容的标记以及一个textareainput元素。在本例中,我使用了一个textarea元素,并将tabindex设置为将其从用户的标签流中移除,然后将aria-hidden设置为true,以便屏幕阅读器知道忽略该元素。

<p>Some example text<p>

<textarea id="copy-text" tabindex="-1" aria-hidden="true"></textarea>
<button id="copy-button">Copy the Highlighted Text</button>

在我的 CSS 中,我通过将元素放在屏幕外并设置高度和宽度值为0来隐藏该元素:

#copy-text {
  position: absolute;
  left: -9999px;
  height: 0;
  width: 0;
}

最后,在我的 JavaScript 中,我遵循类似于示例 16-2 的模式,此外还使用document.getSelection()来获取用户在页面上选择的任何文本的值:

const copyText = document.getElementById('copy-text');
const copyButton = document.getElementById('copy-button');

const copyToClipboard = () => {
  const selection = document.getSelection();
  copyText.value = `${selection} — Check out my highlight at https://example.com `;
  copyText.select();
  navigator.clipboard.writeText(copyText.value);
}

copyButton.addEventListener('click', copyToClipboard);

在社交网络时代,使 Web 应用程序内容易于分享是一种常见模式。使用这些技术提供了简化交互的模式。

在桌面浏览器中启用类似移动设备的通知

问题

您需要一种方式来通知用户事件已发生或长时间运行的过程已完成,即使您的网站在活动选项卡中未打开。

解决方案

使用 Web 通知 API。

此 API 提供了一种相对简单的技术,可以在浏览器之外弹出通知窗口,因此如果某人当前正在另一个选项卡中查看网页,则仍会看到通知。

要使用 Web 通知,确实需要获取权限。在以下代码中,当用户点击按钮时请求通知权限。如果授予权限,则显示通知:

const notificationButton = document.getElementById('notification-button');

const showNotification = permission => {
  // if the user didn't grant permission, exit the function
  if (permission !== 'granted') return;

  // content of the notification
  const notification = new Notification('Title', {
    body: 'Check out this super cool thing'
  });

  // optional: action to take when a user clicks the notification
  notification.onclick = () => {
    window.open('https://example.com');
  };
};

const notificationCheck = () => {
  // if notifications aren't supported return
  // alternately you could perform a different action
  // like redirect the user to email signup
  if (!window.Notification) return;

  // request permission from the user
  Notification.requestPermission().then(showNotification);
};

// on click, call the `notificationCheck` function
notificationButton.addEventListener('click', notificationCheck);

讨论

移动环境中有通知功能,可以让您知道在 Facebook 帖子上收到新的“赞”或在电子邮件客户端中收到新的电子邮件。传统上,在桌面环境中我们没有这种功能,尽管有些人可能会认为这是件好事。

尽管如此,随着我们创建更复杂的 Web 应用程序,拥有此功能可能会有所帮助,特别是当我们的应用程序可能需要较长时间。而不是迫使人们在我们的页面上挂着“工作”图标,网页访问者可以在其他选项卡中查看其他网页,并知道当长时间运行的进程完成时会收到通知。

在解决方案中,第一次代码创建新通知时,它会从网页访问者那里获取权限。如果您的应用程序是作为独立的 Web 应用程序创建的,您可以在清单文件中指定权限,但对于网页,则必须请求权限。

在通知权限请求之前,您还可以测试通知是否存在,这样如果不支持它,就不会抛出错误:

if (window.Notification) {
  Notification.requestPermission(() => {
    setTimeout(() => {
      const notification = new Notification('hey wake up', {
        body: 'your process is done',
        tag: 'loader',
        icon: 'favicon.ico'
      });
      notification();
    }, 5000);
  });
}

通知接受两个参数——标题字符串和带有选项的对象:

body

通知正文中的文本消息

tag

用于标识全局更改通知的标签

icon

自定义图标

lang

通知的语言

dir

语言的方向

您还可以编写四个事件处理程序:

  • onerror

  • onclose

  • onshow

  • onclose

您还可以使用Notification.close()来以编程方式关闭通知,尽管 Safari 和 Firefox 会在几秒钟内自动关闭通知。所有浏览器在通知中提供窗口关闭(x)选项。

额外信息:Web 通知和页面可见性 API

您可以将 Web 通知与页面可见性 API 结合使用,以便仅在网页访问者没有主动查看网页时显示通知。

页面可见性 API 在现代浏览器中得到广泛支持。它增加了一个事件支持,visibilitychange,当标签页的可见性发生变化时触发。它还支持几个新属性 — document.hidden 返回 true 如果标签页不可见,document.visibilityState 可能有以下四个值之一:

  • visible: 当标签页可见时

  • hidden: 当标签页被隐藏时

  • prerender: 页面正在呈现但尚未可见(浏览器支持是可选的)

  • unloaded: 页面正在从内存中卸载(浏览器支持是可选的)

要修改解决方案,以便通知仅在选项卡页面隐藏时触发,请修改代码以检查 visbilityState

if (window.Notification) {
  Notification.requestPermission(() => {
    setTimeout(() => {
      if (document.visibilityState === 'hidden') {
        const notification = new Notification('hey wake up', {
          body: 'your process is done',
          icon: 'favicon.ico'
        });
        notification();
      } else {
        document.getElementById('result').innerHTML = 'your process is done';
      }
    }, 5000);
  });
}

在创建通知之前,代码会测试页面是否隐藏。如果是,则创建通知。如果不是,则在页面上写入一条消息。

在浏览器中本地加载文件

问题

您想要在浏览器中打开图像文件并输出元数据。

解决方案

使用文件 API:

const inputElement = document.getElementById('file');

function handleFile() {
  // read the contents of the file
  const file = this.files[0];
  const reader = new FileReader();
  // add 'load' event listener
  reader.addEventListener('load', event => {
    // once loaded do something with the contents of the file
  });
  reader.readAsDataURL(file);
}

inputElement.addEventListener('change', handleFile, false);

讨论

文件 API 添加到现有的 file 类型输入元素中,用于文件上传。除了通过表单上传将文件上传到服务器的功能外,现在还可以直接在 JavaScript 中访问文件,可以在本地处理文件,或者将文件上传到服务器。

注意

欲了解更多关于 FileReader 的内容,请查看 MDN 的 API 页面,以及一个 相关教程

文件 API 中有三个对象:

FileList

一个通过 input type="file" 上传文件的文件列表

File

关于特定文件的信息

FileReader

对象用于异步上传客户端访问的文件

每个对象都有关联的属性和事件,包括能够跟踪文件上传的进度(并提供自定义进度条),以及在上传完成时发出信号。File 对象可以提供有关文件的信息,包括文件名、大小和 MIME 类型。FileList 对象提供一个 File 对象列表,如果输入元素具有设置 multiple 属性,则可以指定多个文件。FileReader 是执行实际文件上传的对象。

示例 16-3 展示了一个上传图像、将其嵌入网页并显示有关图像的信息的应用程序。结果显示在 图 16-7 中。

示例 16-3. 加载图像和元数据
<!DOCTYPE html>
<head>
  <title>Image Reader</title>
  <meta charset="utf-8" />
  <style>
    #result {
      width: 500px;
      margin: 30px;
    }
  </style>
</head>
<body>
  <h1>Image Reader</h1>
  <form>
    <label for="file">File:</label> <br />
    <input type="file" id="file" accept=".jpg, .jpeg, .png" />
  </form>
  <div id="result">
    <ul>
      <li>Image name: <span id="name"></span></li>
      <li>Image type: <span id="type"></span></li>
    </ul>
  </div>

  <script>
    const inputElement = document.getElementById('file');
    const result = document.getElementById('result');
    const nameEl = document.getElementById('name');
    const typeEl = document.getElementById('type');

    function handleFile() {
      // read the contents of the file
      const file = this.files[0];
      const reader = new FileReader();
      // add 'load' event listener
      reader.addEventListener('load', event => {
        // create the image element and display it within the result div
        const img = document.createElement('img');
        img.setAttribute('src', event.target.result);
        img.setAttribute('width', '250');
        result.appendChild(img);
        // display the image name and file type
        const name = document.createTextNode(file.name);
        const type = document.createTextNode(file.type);
        nameEl.appendChild(name);
        typeEl.appendChild(type);
      });
      reader.readAsDataURL(file);
    }

    inputElement.addEventListener('change', handleFile, false);
  </script>
</body>

jsc3 1607

图 16-7. 使用文件 API 读取图像
注意

文件 API 是 W3C 的一项工作。欲了解更多信息,您可以阅读 最新草案Mozilla 的相关报道

通过 Web 组件扩展可能性

问题

您需要一个封装了特定外观、感觉和行为的组件,您可以像包含 HTML 元素一样轻松地包含它,但不希望使用 Web 框架。

解决方案

考虑 Web 组件,它允许您创建自定义且可重用的 HTML 元素。Web 组件包括模板、自定义元素和影子 DOM。每个都将在讨论中进行介绍。

讨论

想象一个完全自包含的网页小部件,并且它与 Web 组件有些相似,但仅限于表面上的相似性。作为术语,Web 组件涵盖了几种不同的构造。在接下来的章节中,我将介绍每一个,提供示例,讨论 polyfill,并展望未来。

HTML 模板

template 元素现在是 HTML5 规范的一部分。目前在大多数现代浏览器中有支持。在 template 元素内部,我们包含希望作为整体分组的 HTML 内容,直到被克隆之前都不会被实例化。它在加载时被解析以确保有效性,但实际上并不存在。但是。

使用模板非常直观。考虑今天的单页面 JavaScript 应用程序的常见做法:从 web 服务返回数据并将其格式化为无序列表 (ul)(或新段落、表格等)。通常,我们会使用 DOM 方法查询现有的 ul 元素,为列表中的每个列表项 (li) 创建,向项目添加文本,然后将项目附加到列表中。

如果我们能够省略一些步骤会怎么样?我们可以使用 template。考虑以下 HTML:

<template id="hello-world">
  <p>Hello world!</p>
</template>

这是将我们的“Hello World”模板添加到页面的 JavaScript:

const template = document.getElementById('hello-world');
const templateContent = template.content;
document.body.appendChild(templateContent);

在示例中,我们访问 template 元素,访问 HTML 元素的内容,然后使用 appendChild() 将其附加到 HTML 文档中。正如我所指出的,模板非常直观,但您可能会想知道,它的意义何在?我们所做的只是为一个已经很简单的过程增加了更多代码,但模板在自定义元素中的使用是非常重要的,详见“自定义元素”和“影子 DOM”。

自定义元素

Web 组件构造中引起最大兴趣的是自定义元素。与处理现有 HTML 元素及其默认行为和外观不同,我们创建一个自定义元素,打包其样式和行为,然后将其附加到网页上。自定义元素可以扩展现有元素,也可以是“自主”的,意味着它是一个全新的元素。在下面的示例中,我将扩展 HTML <p> 元素以创建一个名为 <hello-world> 的新元素。为此,我首先需要定义一个包含任何特殊方法的类:

class CustomGreeting extends HTMLParagraphElement {
  constructor() {
    // always call super first in constructor
    super();

    // any additional element functionality can be written here
  }
}

一旦类被定义,我就可以注册我的元素。请注意,元素名称必须包含连字符以避免与现有 HTML 元素可能的冲突:

customElements.define("custom-greeting", CustomGreeting);

现在我可以在我的 HTML 页面中使用我的元素:

<custom-greeting>Hello world!</custom-greeting>

影子 DOM

当我提到 shadow DOM 时,脑海中不禁浮现了虚构人物 “The Shadow”。多么伟大的角色,而且也很合适。只有阴影知道人们心中的邪恶,只有 shadow DOM 知道其元素 DOM 中隐藏了什么。

摆脱虚构的干扰,影子 DOM 是 Web 组件中最复杂的部分。但也同样引人入胜。

首先,非神秘的部分。影子 DOM 是一个 DOM,一个节点树,就像我们从 document 元素访问元素时一样。主要区别在于它不存在,不像我们知道 DOM 存在的方式。当我们为一个元素创建 影子根 时,它就存在了。但是,元素以前拥有的任何东西都不见了。这就是要记住的关键:创建它会替换元素的现有 DOM。

通过使用 attachShadow 方法,你可以将影子根附加到任何元素上:

const shadow = element.attachShadow({mode: 'open'});

attachShadow 方法接受一个参数(mode),可以接受值openclosed。将值设置为open允许你在页面上下文中访问影子 DOM,就像访问任何其他元素一样。最常见的影子 DOM 使用场景是作为构造函数的一部分将影子 DOM 附加到自定义元素上:

class CustomGreeting extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const greeting = this.getAttribute('greeting') || 'world'
    shadow.innerHTML = `<p>
 Hello, <span class="greeting">${greeting}</span>
 </p>`;
  }
}

尽管上述示例包含两个 HTML 元素,全局 CSS 样式不会应用于影子 DOM 元素。要为具有影子 DOM 的自定义元素设置样式,我们将在自定义元素类中创建一个样式元素并应用样式:

class CustomGreeting extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const greeting = this.getAttribute('greeting') || 'world'
    shadow.innerHTML = `<p class="wrapper">
 Hello, <span class="greeting">${greeting}</span>
 </p>`;

    // add css styles
    const style = document.createElement('style');

    style.textContent = `
 .wrapper {
 color: pink;
 }

 .greeting {
 color: green;
 font-weight: bold;
 }
 `;
  }
}
小贴士

Polymer 项目 是一组用于处理 Web 组件的库和工具。

Web 组件是 Web 标准生态系统中非常有趣的一部分,具有巨大潜力。HTML 模板、自定义 HTML 元素和影子 DOM 提供了一种创建小型、可重用 UI 组件的方法。这种轻量级组件的理念已经反映在诸如 React 和 Vue 等 JavaScript 库中。

选择前端框架

问题

你正在构建一个复杂的 Web 应用程序,需要一个 JavaScript 框架。如何选择合适的框架?

解决方案

曾经有一段时间,JavaScript 框架似乎比时装周的时尚风更快速地进入并退出流行。幸运的是,在过去几年中,框架之战已经减缓,留下了一些优秀的选择。尽管新开发的速度放缓,但为你和你的项目选择最佳框架仍然是具有挑战性的。在评估项目的框架时,我建议你问自己以下几个问题:

我是否需要一个 JavaScript 框架?

不要默认情况下总是使用框架。通常情况下,简单的网站和应用程序可能更容易编写和维护,同时对用户来说更具性能。

我将开发什么类型的项目?

这是一个个人项目吗?是客户项目吗?是需要长期支持的企业项目吗?还是开源项目?考虑项目的维护者以及什么样的框架最能满足他们的需求。

社区采用水平和项目的长期性如何?

考虑框架的长期支持情况。它仍然是一个活跃的项目吗?是否有一个大社区支持来回答问题和修复错误?

框架的文档编写得有多好?

确保文档易于理解和完整。

框架的开发者生态系统如何?

评估工具、插件和元框架。

我是否熟悉这个框架?

框架是您已经了解或熟悉的内容,还是一个学习项目?

这对我的用户有什么影响?

或许最重要的问题。确定一个框架是否会影响您项目的性能、可访问性或可用性。

虽然这远非详尽列表,但本书的作者建议查看以下框架:React、Vue、Svelte 和 Angular。

React

React是由 Facebook 开发和发布的 UI 驱动的 JavaScript 框架。React 专注于小型视觉组件,并通常使用jsx,这是 JavaScript 中的 XML 语法,用于渲染 HTML 组件。React 通过使用称为virtual DOM的 DOM 表示来使页面更新更有效率。

Vue

Vue是一个面向社区的、UI 驱动的框架。与 React 类似,Vue 利用虚拟 DOM 使页面更新即时化。许多人将 Vue 视为 React 的替代品。功能集类似,但 Vue 使用更友好的 HTML 模板语法,并且由社区支持,而不是由 Facebook 支持。我建议试试 React 和 Vue,看看哪个更符合您和您团队的开发风格。

Svelte

Svelte与这里的其他 JS 框架采取了不同的方法。类似于 React 和 Vue,它是一个 UI-focused 库,但与其在用户浏览器中执行大部分工作不同,Svelte 侧重于在开发构建时进行编译步骤。其目标是减少用户浏览器的负担,以便开发人员可以构建高性能的应用程序。

Angular

Angular是由 Google 开发和发布的功能齐全的 JavaScript 框架。Angular 在第一波“框架”战争中生存下来,并适应了类似于现代库的基于组件的架构。与 React、Vue 和 Svelte 不同,Angular 是一个开箱即用的完整框架,具有应用内导航、数据和状态管理以及内置测试等功能。对于许多企业专注的团队来说,这是一个有用的功能,因为在构建新应用程序或添加功能时可以限制决策。

第十八章:Part III. Node.js

第十七章:Node 基础

“旧”和“新” JavaScript 之间的分界线是在 Node.js(主要简称为 Node)发布到世界上时发生的。是的,动态修改页面元素的能力是一个重要的里程碑,以及强调为 ECMAScript 的新版本确立前进路径,但真正让我们以全新的方式看待 JavaScript 的是 Node。而我很喜欢这种方式——我是 Node 和服务器端 JavaScript 开发的铁杆粉丝。

在本章中,我们将探讨 Node 的基础知识。至少,您需要已安装 Node,如“安装 npm 包管理器(与 Node.js 一起)”或“使用 Node Version Manager 管理 Node 版本”中所述。

使用 Node Version Manager 管理 Node 版本

问题

您需要在开发机器上安装和管理多个 Node 版本。

解决方案

使用Node Version Manager (NVM),它允许您在每个 shell 基础上安装和使用任何分发版本的 Node。NVM 兼容 Linux、macOS 和 Windows Subsystem for Linux。

要安装 NVM,请在系统的终端应用程序中使用curlwget运行安装脚本:

## using curl:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash

## using wget:
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash
注意

如果您在 Windows 上进行开发,我们建议使用nvm-windows,它与 NVM 项目无关,但提供了类似的功能来支持 Windows 操作系统。有关如何使用nvm-windows的说明,请参阅该项目的文档。

安装完 NVM 后,您需要安装一个 Node 版本。要安装最新版本的 Node,请运行:

$ nvm install node

您还可以安装特定版本的 Node:

# install the latest path release of a major version
$ nvm install 15

# install a specific major/minor/patch version
$ nvm install 15.6.0

安装完 Node 后,您需要为新的 shell 会话设置一个默认版本。这可以是已安装的最新版本 Node 或一个特定的版本号:

# default new shell sessions to the latest version of node
nvm alias default node
# default new shell sessions to a specific version
nvm alias default 14

要在 shell 会话中切换使用的版本,请使用nvm use命令,然后跟上一个特定已安装的版本:

$ nvm use 15

讨论

使用 NVM 允许您在操作系统上轻松下载和切换多个 Node 版本。这在处理支持多个版本和遗留代码库的库时非常有用。它还简化了在开发环境中管理 Node。您可以查看每个发布的发布和支持时间表

使用 NVM 可以通过nvm ls命令列出您机器上安装的所有版本。这将显示所有已安装的版本、新 shell 会话的默认版本以及您未安装的任何 LTS 版本:

$ nvm ls
         v8.1.2
        v8.11.3
       v10.13.0
->     v10.23.1
        v12.8.0
       v12.20.0
       v12.20.1
        v13.5.0
       v14.14.0
       v14.15.1
       v14.15.4
        v15.6.0
         system
default -> 14 (-> v14.15.4)
node -> stable (-> v15.6.0) (default)
stable -> 15.6 (-> v15.6.0) (default)
iojs -> N/A (default)
unstable -> N/A (default)
lts/* -> lts/fermium (-> v14.15.4)
lts/argon -> v4.9.1 (-> N/A)
lts/boron -> v6.17.1 (-> N/A)
lts/carbon -> v8.17.0 (-> N/A)
lts/dubnium -> v10.23.1
lts/erbium -> v12.20.1
lts/fermium -> v14.15.4

正如您所见,我在我的机器上安装了几个重复的主要版本的冗余补丁版本。要卸载和移除特定版本,您可以使用nvm uninstall命令:

nvm uninstall 14.14

要追踪项目设计用于使用哪个 Node 版本可能是个挑战。为了简化这一点,您可以在项目的根目录下添加一个 .nvmrc 文件。文件的内容是项目设计使用的 Node 版本。例如:

# default to the latest LTS version
$ lts/*

# to use a specific version
$ 14.15.4

要使用项目 .nvmrc 文件中指定的版本,请从目录的根目录运行 nvm use 命令。

提示

对于大型项目,使用 Docker 等容器技术是确保版本匹配的极其有用的方式,包括部署。 Node 文档中有一篇关于 将 Node.js Web 应用程序 Docker 化 的有用指南。

响应简单的浏览器请求

问题

您想创建一个能够响应非常基本浏览器请求的 Node 应用程序。

解决方案

使用内置的 Node HTTP 服务器来响应请求:

// load http module
const http = require('http');

// create http server
http
  .createServer((req, res) => {
    // content header
    res.writeHead(200, { 'content-type': 'text/plain' });

    // write message and signal communication is complete
    res.end('Hello, World!');
  })
  .listen(8124);

console.log('Server running on port 8124');

讨论

Web 服务器响应浏览器请求是 Node 的“Hello World”应用程序。 它不仅演示了 Node 应用程序的功能,还演示了如何使用一种相当传统的通信方法与其通信:请求 Web 资源。

从顶部开始,解决方案的第一行使用 Node 的 require() 函数加载 http 模块。 这指示 Node 的模块化系统加载用于应用程序的特定库资源。 http 模块是默认情况下随 Node 安装的众多模块之一。

接下来,使用 http.createServer() 创建一个 HTTP 服务器,传入一个匿名函数,即 RequestListener,带有两个参数。 Node 将此函数附加为每个服务器请求的事件处理程序。 这两个参数是 requestresponse。 请求是 http.IncomingMessage 对象的一个实例,响应是 http.ServerResponse 对象的一个实例。

http.ServerResponse 用于响应 Web 请求。 http.IncomingMessage 对象包含有关请求的信息,例如请求 URL。如果您需要从 URL 获取特定信息(例如查询字符串参数),则可以使用 Node 的 url 实用程序模块解析字符串。 示例 17-1 演示了如何使用查询字符串来向浏览器返回更自定义的消息。

示例 17-1. 解析查询字符串数据
// load http module
const http = require('http');
const url = require('url');

// create http server
http
  .createServer((req, res) => {
    // get query string and parameters
    const { query } = url.parse(req.url, true);

    // content header
    res.writeHead(200, { 'content-type': 'text/plain' });

    // write message and signal communication is complete
    const name = query.first ? query.first : 'World';

    // write message and signal communication is complete
    res.end(`Hello, ${name}!`);
  })
  .listen(8124);

console.log('Server running on port 8124');

如下所示的 URL:

http://localhost:8124/?first=Reader

导致一个显示“Hello, Reader!”的网页。

在代码中,url 模块对象具有一个 parse() 方法,该方法解析 URL 并返回其各个组件(hrefprotocolhost 等)。 如果将 true 作为第二个参数传递,字符串还会由另一个模块 querystring 解析,后者将查询字符串作为对象返回,每个参数作为对象属性,而不仅仅返回字符串。

在解决方案和 示例 17-1 中,都返回一个文本消息作为页面输出,使用 http.ServerResponseend() 方法。我也可以使用 write() 输出消息,然后调用 end()

res.write(`Hello, ${name}!`);
res.end();

无论采用哪种方法,重要的是在设置所有标题和响应主体后,必须调用响应的 end() 方法。

createServer() 函数调用的末尾链接了另一个函数调用,这次是 listen(),传入服务器监听的端口号。这个端口号也是应用程序的一个特别重要的组成部分。

传统上,端口 80 是大多数 Web 服务器的默认端口(不使用 HTTPS 的服务器,默认端口是 443)。通过使用端口 80,在请求服务的 URL 时无需指定端口。但端口 80 也是我们更传统的 Web 服务器 Apache 的默认端口。如果尝试在 Apache 使用的端口上运行 Node 服务,应用程序将失败。Node 应用程序要么必须独立运行在服务器上,要么在不同的端口上运行。

你也可以指定 IP 地址(主机),除了端口。这样做可以确保人们向特定的主机和端口发出请求。如果不提供主机,则应用程序将监听与服务器关联的任何 IP 地址的请求。你也可以指定域名,Node 将解析主机。

还有其他方法演示的参数,以及大量其他方法,但这将让你入门。有关更多信息,请参阅 Node 文档

使用 REPL 交互式尝试 Node 代码片段

问题

你希望轻松运行基于服务器的 Node 代码片段。

解决方案

使用 Node 的 REPL(读取-评估-打印-循环),这是 Node 的交互式命令行版本,可以运行任何代码片段。

要使用 REPL,在命令行中键入 node 而不指定要运行的应用程序:

$ node

然后,你可以以简化的 Emacs 风格进行 JavaScript。你可以导入库,创建函数——可以在静态应用程序中做的任何事情。主要区别是每行代码都会立即解释:

> const add = (x, y) => { return x + y };
undefined
> add(2, 2);
4

完成后,使用 .exit 退出程序:

> .exit

讨论

REPL 可以独立启动,或者在另一个应用程序中启动,如果你想设置某些功能。你输入 JavaScript 就像在文本文件中输入脚本一样。主要的行为差异是在每输入一行后可能会看到一个结果,比如运行时 REPL 中显示的 undefined

但你可以导入模块:

> const fs = require('fs');

你也可以访问全局对象,在我们使用 require() 时刚刚这样做。

在键入某些代码后显示的 undefined 是前一行代码执行的返回值。设置新变量和创建函数是一些返回 undefined 的 JavaScript 操作,这可能会很快让人厌烦。为了消除这种行为以及进行其他一些修改,可以在一个小的 Node 应用程序中使用 REPL.start() 函数触发 REPL(但使用您指定的选项)。

可用的选项包括:

prompt

更改显示的提示(默认为 >

input

更改输入可读流(默认为 process.stdin,标准输入)

output

更改输出可写流(默认为 process.stdout,标准输出)

terminal

如果流应该像 TTY 一样对待并写入 ANSI/VT100 转义代码,则设置为 true

eval

用于替换异步 eval() 函数的函数,用于评估 JavaScript

useColors

设置为 truewriter 函数设置输出颜色(默认基于终端的默认值)

useGlobal

设置为 true 可以使用 global 对象,而不是在单独的上下文中运行脚本

ignoreUndefined

设置为 true 以消除 undefined 返回值

writer

从评估的代码中返回格式化结果以显示(默认为 util.inspect 函数)

以下是一个示例应用程序,它使用新的提示启动 REPL,忽略未定义的值,并使用颜色:

const repl = require('repl');

const options = {
  prompt: '-> ',
  useColors: true,
  ignoreUndefined: true
};

repl.start(options);

我们想要的选项在 options 对象中定义,然后作为参数传递给 repl.start()。当我们运行应用程序时,REPL 被启动,但我们不再需要处理未定义的值:

-> const add = (x, y) => { return x + y };
-> add(2, 2);
4

正如您所看到的,这是一个更清晰的输出,没有所有那些混乱的 undefined 打印输出。

额外信息:等等,什么是全局对象?

你发现了吗?

JavaScript 在 Node 和浏览器中的一个区别是全局作用域。传统上,在浏览器中,当你在函数外部使用 var 创建变量时,它属于顶层全局对象,我们称之为 window

var test = 'this is a test';
console.log(window.test); // 'this is a test'

类似地,在浏览器中使用 letconst 时,变量是全局作用域的,但不附加到 window 对象。

在 Node 中,每个模块都在自己的独立上下文中运行,因此模块可以声明相同的变量,如果它们都在同一个应用程序中使用,则不会发生冲突。

然而,有些对象可以从 Node 的 global 对象访问。在前面的例子中,我们使用了一些,包括 console、Buffer 对象和 require()。其他包括一些非常熟悉的老朋友:setTimeout()clearTimeout()setInterval()clearInterval()

读取和写入文件数据

问题

您想要从本地存储的文件中读取或写入。

解决方案

Node 的文件系统管理功能作为 Node 核心的一部分包含在 fs 模块中:

const fs = require('fs');

要读取文件的内容,请使用 readFile() 函数:

const fs = require('fs');

fs.readFile('main.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

要写入文件,请使用writeFile()

const fs = require('fs');

const buf = "I'm going to write this text to a file";
fs.writeFile('main2.txt', buf, err => {
  if (err) throw err;
  console.log('wrote text to file');
});

writeFile()函数会覆盖现有文件。要向文件追加文本,请使用appendText()

const fs = require('fs');

const buf = "\nI'm going to add this text to a file";
fs.appendFile('main.txt', buf, err => {
  if (err) throw err;
  console.log('appended text to file');
});

讨论

Node 的文件系统支持既全面又简单易用。要从文件中读取,请使用readFile()函数,支持以下参数:

  • 文件名,包括操作系统路径(如果不是应用程序本地文件)

  • 一个选项对象,包括encoding选项(如解决方案中所示)和flag选项,默认设置为r(读取)

  • 回调函数,带有错误和读取的数据两个参数

在解决方案中,如果在应用程序中未指定编码,Node 将以原始缓冲区形式返回文件内容。由于我指定了编码,文件内容以字符串形式返回。

writeFile()appendFile()函数用于分别写入和追加数据,其参数类似于readFile()

  • 文件名和路径

  • 要写入文件的字符串或缓冲区数据

  • 选项对象,包括encoding选项(writeFile()默认为wappendFile()默认为a)和mode选项,默认为438(八进制中的0666

  • 回调函数,只有一个参数:错误信息

如果文件是通过写入或追加创建的,则可以使用mode的选项值来设置文件的权限。默认情况下,文件由所有者可读写,并由组和全局用户可读。

我提到,要写入的数据可以是缓冲区或字符串。字符串不能处理二进制数据,因此 Node 提供了缓冲区,可以处理字符串或二进制数据。这两者都可以在本节讨论的所有文件系统函数中使用,但如果要同时使用它们,就需要明确地在两种类型之间进行转换。

例如,在使用writeFile()时,不提供utf8encoding选项,而是将字符串转换为缓冲区,并在这样做时提供所需的编码:

const fs = require('fs');

const str = "I'm going to write this text to a file";
const buf = Buffer.from(str, 'utf8');
fs.writeFile('mainbuf.txt', buf, err => {
  if (err) throw err;
  console.log('wrote text to file');
});

反之,将缓冲区转换为字符串同样简单:

const fs = require('fs');

fs.readFile('main.txt', (err, data) => {
  if (err) throw err;
  const str = data.toString();
  console.log(str);
});

缓冲区的toString()函数有三个可选参数:编码、开始转换的位置和结束转换的位置。默认情况下,整个缓冲区使用utf8编码进行转换。

readFile()writeFile()appendFile()函数是异步的,这意味着它们在继续执行代码之前不会等待操作完成。在处理诸如文件访问等速度慢的操作时,这是至关重要的。每个函数还有同步版本:readFileSync()writeFileSync()appendFileSync()。我强调不应使用这些同步版本。我只是全面起见才提到它们。

高级

从文件中读取或写入的另一种方法是结合使用 open() 函数和 read() 用于读取文件内容,或 write() 用于写入文件。这种方法的优点是在过程中有更精细的控制。缺点是与所有函数相关的额外复杂性,包括只能使用缓冲区进行文件的读取和写入。

open() 的参数包括:

  • 文件名和路径

  • 标志

  • 可选模式

  • 回调函数

所有操作都使用相同的 open(),标志控制发生的情况。有很多标志选项,但这时我们最感兴趣的是:

r

打开文件以供读取;文件必须存在

r+

打开文件以供读取和写入;如果文件不存在则引发异常

w

打开文件以供写入,如果文件存在则截断文件,否则创建文件

wx

打开文件以供写入,但如果文件 存在 则失败

w+

打开文件以供读取和写入;如果文件不存在则创建;如果文件存在则截断文件

wx+

类似于 w+,但如果文件存在则失败

a

打开文件以供追加,如果文件不存在则创建文件

ax

打开文件以供追加,如果文件存在则失败

a+

打开文件以供读取和追加;如果文件不存在则创建文件

ax+

类似于 a+,但如果文件存在则失败

模式与前述相同,设置文件的 粘性权限 位,如果创建,则默认为 0666。回调函数有两个参数:如果发生错误,则为错误对象,否则为 文件描述符,用于后续文件操作。

read()write() 函数共享相同类型的基本参数:

  • open() 方法回调文件描述符

  • 用于保存待写入或追加的数据或进行读取的缓冲区

  • 输入/输出(I/O)操作开始的偏移量

  • 缓冲区长度(由读取操作设置,控制写入操作)

  • 操作将进行的文件位置;如果位置是当前位置则为 null

这两种方法的回调函数都有三个参数:一个错误、读取(或写入)的字节数和缓冲区。

这是很多参数和选项。展示如何运作的最佳方式是创建一个完整的 Node 应用程序,用于打开一个全新的文件进行写入,写入一些文本,再写入一些文本,然后读取所有文本并打印到 console 中。由于 open() 是异步的,读取和写入操作必须在回调函数内进行。在 示例 17-2 中准备好,因为你将首次体验到被称为 回调地狱 的概念。

示例 17-2. 展示打开、读取和写入操作
const fs = require('fs');

fs.open('newfile.txt', 'a+', (err, fd) => {
  if (err) {
    throw err;
  } else {
    const buf = Buffer.from('The first string\n');
    fs.write(fd, buf, 0, buf.length, 0, (err, written) => {
      if (err) {
        throw err;
      } else {
        const buf2 = Buffer.from('The second string\n');
        fs.write(fd, buf2, 0, buf2.length, buf.length, (err, written2) => {
          if (err) {
            throw err;
          } else {
            const length = written + written2;
            const buf3 = Buffer.alloc(length);
            fs.read(fd, buf3, 0, length, 0, err => {
              if (err) {
                throw err;
              } else {
                console.log(buf3.toString());
              }
            });
          }
        });
      }
    });
  }
});
注意

驯服回调函数在 “管理回调地狱” 中有介绍。

要找出缓冲区的长度,我使用了length,它返回缓冲区的字节数。这个值不一定与缓冲区中字符串的长度匹配,但在这种用法中起作用。

那么多级缩进可能会让您毛骨悚然,但该示例演示了open()read()write()的工作方式。这些函数的组合是在readFile()writeFile()appendFile()函数中用于管理文件访问的。高级函数只是简化了最常见的文件操作。

注意

查看“管理回调地狱”以解决所有这些恶心的缩进问题。

从终端获取输入

问题

您希望通过终端从应用程序用户获取输入。

解决方案

使用 Node 的 Readline 模块。

要从标准输入获取数据,请使用以下代码:

const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question(">>What's your name?  ", answer => {
  console.log(`Hello ${answer}`);
  rl.close();
});

讨论

Readline 模块提供了从可读流获取文本行的能力。您首先通过createInterface()创建 Readline 接口的实例,至少传入可读和可写流。您两者都需要,因为您既要写入提示,又要读取文本。在解决方案中,输入流是process.stdin,标准输入流,而输出流是process.stdout。换句话说,输入和输出来自命令行。

解决方案使用question()函数发布问题,并提供回调函数来处理响应。在函数内部,调用了close(),它关闭接口,释放输入和输出流的控制权。

您还可以创建一个应用程序,继续监听输入,对传入的数据采取一些操作,直到某些信号结束应用程序。通常,这个信号是一系列信号,表示个人已经完成,比如exit这个词。这种类型的应用程序还使用其他 Readline 函数,如setPrompt()用于根据每行文本为个人设置提示;prompt()准备输入区域,包括更改为setPrompt()设置的提示;以及write(),用于写出提示。此外,您还需要使用事件处理程序来处理事件,例如line,它监听每一行新的文本。

示例 17-3 包含一个完整的 Node 应用程序,该应用程序继续从用户那里处理输入,直到他们输入exit。请注意,该应用程序利用了process.exit()。这个函数干净地终止了 Node 应用程序。

示例 17-3. 从 stdin 访问数字,直到用户键入exit
const readline = require('readline');

let sum = 0;

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

console.log("Enter numbers, one to a line. Enter 'exit' to quit.");

rl.setPrompt('>> ');
rl.prompt();

rl.on('line', input => {
  const userInput = input.trim();
  if (userInput === 'exit') {
    rl.close();
    return;
  }
  sum += Number(userInput);
  rl.prompt();
});

// user typed in 'exit'
rl.on('close', () => {
  console.log(`Total is ${sum}`);
  process.exit(0);
});

运行应用程序时,使用多个数字会产生以下输出:

Enter numbers, one to a line. Enter 'exit' to quit.
>> 55
>> 209
>> 23.44
>> 0
>> 1
>> 6
>> exit
Total is 294.44

我使用了console.log()而不是 Readline 接口的write()来写入提示,后面跟一个新行,以区分输出和输入。

参见

第十九章涵盖了在 Node 应用程序中传递和读取命令行参数。

获取当前脚本的路径

问题

你的应用程序需要读取正在执行的脚本的路径。

解决方案

使用__dirname__filename变量,它们在执行模块的作用域中:

// logs the directory of the currently executed file
// ex: /Users/Adam/Projects/js-cookbook/node
console.log(__dirname);

// logs the directory and filename of the currently executed file
// ex: /Users/Adam/Projects/js-cookbook/node/example.js
console.log(__filename);

讨论

__dirname__filename变量看起来在全局范围内,但它们实际上存在于模块本身的作用域中。假设你有以下目录结构的项目:

example-app
|   index.js
├───dir1
|   |   example.js
|   └───dir3
|       |   nested.js

如果你在 index.js 文件中读取__dirname,它将是项目根目录的路径。然而,如果在 nested.js 文件中从脚本中读取__dirname,它将读取到 dir3 目录的路径。这使得你可以在模块执行时读取模块的路径,而不仅仅是限于父目录本身。

__dirname在创建当前目录内的新文件或目录时的一个有用的示例。在下面的示例中,脚本在当前文件的目录中创建一个名为cache的新子目录:

const fs = require('fs');
const path = require('path');
const newDirectoryPath = path.join(__dirname, '/cache');

fs.mkdirSync(newDirectoryPath);

使用 Node 定时器和理解 Node 事件循环

问题

你需要在 Node 应用程序中使用定时器,但是你不确定应该使用 Node 的哪个定时器,或者它们有多精确。

解决方案

如果你的定时器不需要很精确,你可以使用setTimeout()来创建单个定时器事件,或者如果你需要一个重复的定时器,可以使用setInterval()

setTimeout(() => {}, 3000);

setInterval(() => {}, 3000);

这两个定时器函数都可以被取消:

const timer1 = setTimeout(() => {}, 3000);
clearTimeout(timer1);

const timer2 = setInterval(() => {}, 3000);
clearInterval(timer2);

然而,如果你需要更精细地控制你的定时器,并且需要立即得到结果,你可能想要使用setImmediate()。你不需要为它指定延迟,因为你希望回调在所有 I/O 回调处理完毕之后但是在任何setTimeout()setInterval()回调之前立即被调用:

setImmediate(() => {});

它也可以通过clearImmediate()清除。

讨论

Node,作为基于 JavaScript 的运行在单线程上。它是同步的。然而,输入/输出(I/O)和其他本机 API 访问是异步的或在单独的线程上运行。Node 处理这种时间上的不连续性的方法是事件循环

在你的代码中,当你执行 I/O 操作时,比如向文件写入文本块,你会指定一个回调函数来处理写入后的活动。一旦你这样做了,剩下的应用程序代码就会被处理。它不会等待文件写入完成。当文件写入完成时,会返回一个事件来通知 Node,并被推送到一个队列中等待处理。Node 处理这个事件队列,当它处理到完成文件写入的事件时,它将该事件与回调匹配,并处理该回调。

作为比较,想象一下走进一家快餐店并点午餐。你排队等候下单,并被分配一个订单号。你坐下来看报纸,或者查看 Twitter 账户等待。与此同时,午餐订单进入另一个队列,供快餐店员工处理订单。但并不是每个午餐请求都会按接收顺序完成。有些午餐订单可能需要更长时间。它们可能需要更长时间烘烤或烤制。因此,快餐店员工通过准备您的午餐项目然后将其放入烤箱,并设置一个定时器以便完成后通知您,然后继续其他任务。

当定时器触发时,快餐店员工迅速完成当前任务,并从烤箱中取出您的午餐订单。然后通过呼叫您的订单号来通知您可以取餐。如果同时处理多个耗时的午餐项目,则快餐店员工会按顺序处理每个项目的定时器触发。

所有的 Node 进程都符合快餐店订单队列的模式:先进先出发送到快餐店(线程)工人。但是,某些操作(如 I/O 操作)就像那些需要额外时间在烤箱或烤架中烘烤的午餐订单,但不需要快餐店员工停止任何其他工作等待烘烤和烤制。烤箱或烤架定时器相当于在 Node 事件循环中出现的消息,触发基于请求操作的最终动作。

现在,你拥有了同步和异步进程的工作混合体。但是定时器会发生什么呢?

setTimeout()setInterval() 都在给定的延迟后触发,但实际上是将消息添加到事件循环中,按顺序处理。因此,如果事件循环特别拥挤,定时器函数的回调会有延迟:

需要注意的是,你的回调函数可能不会在准确的(延迟)毫秒内调用。Node.js 不保证回调函数触发的确切时间,也不保证触发顺序。回调函数会尽可能接近指定的时间调用。

Node 定时器文档

在大多数情况下,发生的延迟超出了我们的感知能力,但可能导致看起来不流畅的动画。它也可能给其他应用程序添加一个奇怪的效果。

在 示例 17-4 中,我创建了一个 SVG 滚动时间轴,通过 WebSockets 将数据提供给客户端。为了模拟真实世界的数据,我使用了一个三秒的定时器,并随机生成一个数作为数据值。在服务器端的代码中,我使用了 setInterval(),因为定时器是循环的:

示例 17-4. 滚动时间轴示例
const app = require('http');
const fs = require('fs');
const ws = require('nodejs-websocket');

let server;

// serve static page
const handler = (req, res) => {
  fs.readFile(`${__dirname}/drawline.html`, (err, data) => {
    if (err) {
      res.writeHead(500);
      return res.end('Error loading drawline.html');
    }
    res.writeHead(200);
    res.end(data);
    return data;
  });
};

/// start the webserver
// connections on Port 8124 will be handled by the handler
app.listen(8124);
app.createServer(handler);

// data timer
const startTimer = () => {
  setInterval(() => {
    const newval = Math.floor(Math.random() * 100) + 1;
    if (server.connections.length > 0) {
      console.log(`sending ${newval}`);
      const counter = { counter: newval };
      server.connections.forEach(conn => {
        conn.sendText(JSON.stringify(counter), () => {
          console.log('conn sent');
        });
      });
    }
  }, 3000);
};

// Create a websocket connection handler on a different port
server = ws
  .createServer(conn => {
    console.log('connected');
    conn.on('close', () => {
      console.log('Connection closed');
    });
  })
  .listen(8001, () => {
    startTimer();
  });

我在代码中包含了 console.log() 调用,这样你就可以看到计时器事件与通信响应的比较。当调用 setInterval() 函数时,它被推送到进程中。当处理其回调时,WebSocket 通信也被推送到队列中。

解决方案使用了 setInterval(),这是 Node 的三种不同类型计时器之一。setInterval() 函数的格式与我们在浏览器中使用的格式相同。你为第一个函数指定一个回调,提供延迟时间(以毫秒为单位),以及任何潜在的参数。计时器将在三秒后触发,但我们已经知道计时器的回调可能不会立即被处理。

与 WebSocket sendText() 调用中传递的回调函数一样。这些基于 Node 的 Net(或者如果安全的话,是 TLS)套接字,正如 socket.write()(用于 sendText() 的内容)文档所述:

可选的回调参数将在数据最终写出时执行 — 这可能不会立即发生。

Node 文档

如果将计时器设置为立即调用(将延迟值设为零),你会看到发送的数据消息与通信发送消息交错(在浏览器客户端由于套接字通信而冻结之前,你不希望在应用程序中再次使用零值)。

然而,所有客户端的时间表保持不变,因为通信是在计时器的回调函数中同步发送的,所以所有通信的数据都是相同的 — 只是回调处理似乎是无序的。

我之前提到过使用延迟为零的 setInterval()。实际上,它并不完全是零 — Node 遵循浏览器遵循的 HTML5 规范,并将计时器间隔“夹紧”到最小值四毫秒。虽然这看似过小以至于不会引起问题,但对于动画和时间关键的进程而言,时间延迟可能会影响整体的外观和/或功能。

为了绕过这些约束,Node 开发者使用 Node 的 process.nextTick()。与 process.nextTick() 关联的回调会在下一个事件循环回合中处理,通常在任何 I/O 回调之前(虽然有一些限制,我马上会讲到)。不再有讨厌的四毫秒节流了。但是,如果有大量递归调用 process.nextTick(),会发生什么呢?

回到我们的熟食店比喻,在忙碌的午餐时间,工作人员可能被订单压倒,全神贯注于处理新订单,以至于无法及时响应烤箱和烧烤的提示。这时候事物就会烧焦。如果你去过一个运营良好的熟食店,你会注意到接单的服务员在接受订单之前会评估厨房情况,稍微拖延一下,甚至承担一些厨房职责,让顾客在订单队列中稍微等待久一点。

Node 也是如此。如果 process.nextTick() 被允许一直处于受宠的地位,I/O 操作将会被饿死。Node 使用另一个值 process.maxTickDepth,默认值为 1000,来限制在允许 I/O 回调之前处理的 process.next() 回调数量。这就像是熟食店中的服务员。

在 Node 的更新版本中,添加了 setImmediate() 函数。此函数试图解决与定时操作相关的所有问题,并创建一个适合大多数人的良好平衡点。调用 setImmediate() 时,其回调将在 I/O 回调之后但在 setTimeout()setInterval() 回调之前添加。我们不需要传统定时器的四毫秒税,但也不需要 process.nextTick() 这个顽皮的家伙。

再次回到熟食店的比喻中,setImmediate() 就像是订单队列中的一个顾客,看到熟食店工作人员忙于处理烤箱和烧烤的提示,礼貌地表示他们愿意等待以便让出订单。

注意

然而,在滚动时间轴示例中,千万不要使用 setImmediate(),因为它会比你眨眼的速度更快地让你的浏览器冻结起来。

第十八章:Node 模块

编写 Node.js 应用程序的一个重要方面是环境提供的内置模块化功能。下载和安装任意数量的 Node 模块很简单,并且使用它们同样简单:只需包含一个require()语句命名模块,即可开始运行。

模块可以轻松整合的便利性是 JavaScript模块化的好处之一。模块化确保外部功能以不依赖于其他外部功能的方式创建,这被称为松耦合的概念。这意味着我可以使用Foo模块,而无需包含Bar模块,因为Foo与必须包含Bar之间有着紧密依赖的关系。

JavaScript 的模块化既是一种纪律,也是一种契约。纪律在于必须遵循一定的标准,以便外部代码参与模块系统。契约是我们之间,以及其他 JavaScript 开发者之间的:我们在模块系统中生产(或消费)外部功能时遵循一个共识的路径,我们都对模块系统有基于期望的期望。

注意

应用程序和库管理以及发布的几乎所有方面都依赖于 Git,一个源代码控制系统,以及 GitHub,一个极其流行的 Git终端点。本书不涵盖 Git 的工作原理及其与 GitHub 的使用。我建议阅读 Richard Silverman(O'Reilly)的《Git Pocket Guide》,以更熟悉 Git,以及 GitHub 的官方文档以了解更多关于该服务的信息。

通过 npm 搜索特定的 Node 模块

问题

您正在创建一个 Node 应用程序,并希望使用现有的模块,但不知道如何发现它们。

解决方案

“使用 npm 下载包”解释了如何使用 npm 安装包,Node.js 流行的包管理器(也是维系 Node 生态系统的关键)。但您还没有考虑如何查找您在 npm 庞大的注册表中所需的有用包。

大多数情况下,您会通过朋友和共同开发者的推荐来发现模块,但有时您需要新的东西。您可以直接在npm 网站上搜索新的模块。您也可以直接使用 npm 命令行界面来搜索模块。例如,如果您对能够处理 PDF 的模块感兴趣,请在命令行中运行以下搜索:

$ npm search pdf

讨论

npm 网站不仅提供了 npm 使用的文档,还提供了一个用于搜索模块的界面。如果您访问 npm 的每个模块页面,您可以查看模块的流行程度,其他模块对它的依赖情况,许可证以及其他相关信息。

然而,你也可以直接使用 npm 搜索模块。这个过程可能需要相当多的时间,当搜索结束时,你可能会得到大量的模块返回,尤其是对于像与 PDF 相关的模块这样的广泛主题。

你可以通过列出多个术语来细化结果:

$ npm search PDF generation

这个查询返回一个更小的模块列表,专门用于 PDF 生成。

一旦你找到一个听起来有趣的模块,你可以通过以下方式获取关于它的详细信息:

$ npm view electron

你可以从模块的package.json中获得有用的信息,该文件可以告诉你它依赖于什么,由谁编写,以及创建时间。我们仍然建议直接查看模块的 npm 网页和 GitHub 存储库页面。在那里,你将能够确定模块是否正在积极维护,了解模块的流行程度,查看未解决的问题,并查看源代码。

将你的库转换为 Node 模块

问题

你想在 Node 中使用你的一个库。

解决方案

将库转换为 Node 模块。在 Node 中,每个文件都被视为一个模块。例如,如果库是一个包含在/lib/hello.js文件中的函数:

const hello = val => {
  return console.log(`Hello ${val}`);
};

你可以使用exports关键字将其转换为 Node 模块:

const hello = val => {
  return console.log(`Hello ${val}`);
};

module.exports = hello;

或者,也可以直接export该函数:

module.exports = val => {
  return console.log(`Hello ${val}`);
};

然后你可以在你的应用程序中使用该模块:

var hello = require('./lib/hello.js');

// logs 'Hello world'
hello('world');

讨论

Node 的默认模块系统基于 CommonJS,使用三个构造:exports用于定义从库中导出的内容,require()用于将模块包含到应用程序中,以及module,它包含关于模块的信息,同时也可以用于直接导出一个函数。

如果你的库返回一个包含多个函数和数据对象的对象,你可以将每个函数和数据对象分配给module.exports上的同名属性,或者你可以返回一个对象:

const greeting = {
  hello: val => {
    return console.log(`Hello ${val}`);
  },
  ciao: val => {
    return console.log(`Ciao ${val}`);
  }
};

module.exports = greeting;

或者:

const hello = val => {
  return console.log(`Hello ${val}`);
};

const ciao = val => {
  return console.log(`Ciao ${val}`);
};

module.exports = { hello, ciao };

然后直接访问对象属性:

const greeting = require('./lib/greeting.js')

// logs 'Hello world'
greeting.hello('world');
// logs 'Ciao mondo'
greeting.ciao('mondo');

因为模块并未使用 npm 安装,而是只存在于应用程序所在的目录中,因此访问时使用文件位置和名称,而不仅仅是名称。

参见

在“在模块环境中使用你的代码”中,我们介绍了如何确保你的库代码在 CommonJS 和 ECMAScript 模块环境中都能正常工作。

在“创建可安装的 Node 模块”中,我们介绍了如何创建一个独立的模块。

在模块环境中使用你的代码

问题

你已经写了一个库,想与他人共享,但是人们使用各种 Node 版本,包括 CommonJS 和 ECMAScript 模块。你如何确保你的库在所有不同的环境中都能正常工作?

解决方案

使用带有 ECMAScript 模块包装的 CommonJS 模块。

首先,将库写成一个 CommonJS 模块,保存为.cjs文件扩展名:

const bbarray = {
  concatArray: (str, array) => {
    return array.map(element => {
      return `${str} ${element}`;
    });
  },
  splitArray: (str, array) => {
    return array.map(element => {
      return element.substring(str.length + 1);
    });
  }
};

module.exports = bbarray;
exports.concatArray = bbarray.concatArray;
exports.splitArray = bbarray.splitArray;

接着是一个使用.mjs文件扩展名的 ECMAScript 包装模块:

import bbarray from './index.cjs';

export const { concatArray, splitArray } = bbarray;
export default bbarray;

还有一个包含typemainexports字段的package.json文件:

"type": "module",
"main": "./index.cjs",
"exports": {
  ".": "./index.cjs",
  "./module": "./wrapper.mjs"
},

使用 CommonJS 语法的我们模块的用户可以使用require语法导入模块:

const bbarray = require('bbarray');

bbarray.concatArray('is', ['test', 'three']);
bbarray.splitArray('is', ['is test', 'is three']);

或者:

const { concatArray, splitArray } = require('bbarray');

concatArray('is', ['test', 'three']);
splitArray('is', ['is test', 'is three']);

虽然使用 ECMAScript 模块的人可以指定module版本的库以使用 ES 的import语法:

import bbarray from 'bbarray/module';

bbarray.concatArray('is', ['test', 'three']);
bbarray.splitArray('is', ['is test', 'is three']);

或者:

import { concatArray, splitArray } from 'bbarray/module';

concatArray('is', ['test', 'three']);
splitArray('is', ['is test', 'is three']);
注意

在撰写本文时,可以通过--experimental-conditional-exports标志避免 ECMAScript 模块使用*/module*命名约定。然而,由于当前的实验性质和语法未来可能发生变化的潜力,我们目前建议不要这样做。在未来的 Node 版本中,这可能会成为标准。您可以在Node 文档中了解更多信息。

讨论

自从一开始,CommonJS 模块一直是 Node 的标准,诸如 Browserify 之类的工具将这种语法从 Node 生态系统中带出,允许开发人员在浏览器中使用 Node 风格的模块。ECMAScript 2015(也称为 ES6)标准引入了原生 JavaScript 模块语法,这在 Node 8.5.0 中首次引入,并可以在--experimental-module标志后使用。从 Node 13.2.0 开始,Node 直接支持 ECMAScript 模块。

一种常见的模式是使用 CommonJS 或 ECMAScript 模块语法编写模块,并使用编译工具将它们作为单独的模块入口点或导出路径进行发布。然而,如果应用程序直接通过一种语法加载模块,并且使用另一种语法直接加载或依赖加载该模块,可能会导致模块加载两次的风险。

package.json中有三个关键字段:

"type": "module",
"main": "./index.cjs",
"exports": {
  ".": "./index.cjs",
  "./module": "./wrapper.mjs"
},

"type"

指定这是一个module,意味着此库使用 ECMAScript 模块语法。对于专门使用 CommonJS 的库,"type"将是"commonjs"

"main"

指定应用程序的主入口点,我们将指向 CommonJS 文件。

"exports"

定义我们模块的导出路径。通过这种方式,使用默认package的消费者将直接接收 CommonJS 模块,而使用package/module的人将从 ECMAScript 模块包装器导入文件。

如果我们希望避免使用.cjs.mjs文件扩展名,我们可以这样做:

"type": "module",
"main": "./index.js",
"exports": {
  ".": "./index.js",
  "./module": "./wrapper.js"
},

参见

在“编写多平台库”中,我们介绍了如何通过使用 Webpack 作为代码捆绑器,确保您的库代码在 Node 和浏览器中的多个模块环境中正常工作。

创建可安装的 Node 模块

问题

您要么从头开始创建一个 Node 模块,要么将现有库转换为可以在浏览器或 Node 中工作的模块。现在,您想知道如何修改它成为可以使用 npm 安装的模块。

解决方案

一旦您创建了您的 Node 模块及任何支持功能(包括模块测试),您可以将整个目录打包。打包和发布 Node 模块的关键在于创建一个package.json文件,描述模块、任何依赖项、目录结构、忽略内容等。您可以通过在项目根目录运行npm init命令并按照提示操作来生成package.json文件。

以下是一个相对基础的package.json文件:

{
  "name": "bbArray",
  "version": "0.1.0",
  "description": "A description of what my module is about",
  "main": "./lib/bbArray",
  "author": {
    "name": "Shelley Powers"
  },
  "keywords": [
    "array",
    "utility"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/accountname/bbarray.git"
  },
  "engines" : {
    "node" : ">=0.10.0"
  },
  "bugs": {
    "url": "https://github.com/accountname/bbarray/issues"
  },
  "licenses": [
    {
      "type": "MIT",
      "url": "https://github.com/accountname/bbarray/raw/master/LICENSE"
    }
  ],
  "dependencies": {
     "some-module": "~0.1.0"
  },
  "directories":{
     "doc":"./doc",
     "man":"./man",
     "lib":"./lib",
     "bin":"./bin"
  },
  "scripts": {
    "test": "nodeunit test/test-bbarray.js"
  }
 }

创建package.json后,将所有源目录和package.json文件作为 gzipped tarball 打包。然后在本地安装包,或在 npm 上安装以便公共访问。

讨论

package.json文件是将 Node 模块打包到本地安装或上传到 npm 进行管理的关键。至少需要一个name和一个version。解决方案中给出的其他字段包括:

description

模块的描述及其功能

main

模块的入口文件

author

模块的作者

keywords

可以帮助其他人找到模块的关键字列表

repository

代码存放的位置,通常是 GitHub

engines

您知道您的模块适用的 Node 版本

bugs

提交错误的位置

licenses

您的模块的许可证

dependencies

模块所需的依赖项列表

directories

描述您的模块目录结构的哈希

scripts

在模块生命周期内运行的一组对象命令的哈希

还有一大堆其他选项在npm 网站上有描述。您还可以使用工具来帮助您填写许多这些字段。在命令行输入以下内容运行该工具,它会询问问题,然后生成一个基本的package.json文件:

$ npm init

一旦设置好源代码并有了package.json文件,您可以通过在模块的顶级目录中运行以下命令来测试一切是否正常工作:

$ npm install . -g

如果没有错误,那么您可以将文件打包为 gzipped tarball。此时,如果要发布模块,您首先需要在 npm 注册表中添加自己作为用户:

$ npm add-user

要将 Node 模块发布到 npm 注册表,请在模块的根目录中使用以下内容,指定 tarball 的 URL、tarball 的文件名或路径:

$ npm publish ./

如果您的模块有开发依赖项,比如使用 Jest 这样的测试框架,确保将这些依赖项添加到您的package.json文件的一个优秀快捷方式是在安装依赖模块的同一目录中使用以下命令:

$ npm install jest --save-dev

这不仅会安装 Jest(稍后在“单元测试您的模块”中讨论),这条命令还会使用以下命令更新您的package.json文件:

 "devDependencies": {
    "jest": "²⁴.9.0"
  }

您还可以使用相同类型的选项将模块添加到package.json中的dependencies。以下:

$ npm install express --save

将以下内容添加到package.json文件中:

"dependencies": {
    "express": "³.4.11"
  }

如果该模块不再需要,并且不应列在 package.json 中,则使用以下方式从devDependencies中删除它:

$ npm remove jest

并使用以下方式从dependencies中移除模块:

$ npm remove express

如果模块是dependenciesdevDependencies中的最后一个,则不会删除该属性。它只是被设置为空值:

"dependencies": {}

npm 提供了一个不错的开发者指南,用于创建和安装 Node 模块。您应考虑使用 .npmignore.gitignore 文件来排除您的模块之外的内容。尽管这超出了本书的范围,但您还应熟悉 Git 和 GitHub,并为您的应用程序/模块使用它。

补充:README 文件和 Markdown 语法

当您为重复使用打包您的模块或库并将其上传到诸如 GitHub 等源代码仓库时,您需要提供关于安装该模块/库以及如何使用的详细信息。因此,您需要一个 README 文件。

您可能已经看到过带有应用程序和 Node 模块的 README.md 文件。它们是基于文本的,并带有一些奇怪而不显眼的标记,您不确定它是否有用,直到在 GitHub 等网站上看到它,README 文件为项目页面提供了所有的安装和使用信息。这些标记被转换成 HTML,使得网络帮助易于阅读。

README 的内容使用称为 Markdown 的标注进行标记。流行的网站 Daring Fireball 称 Markdown 为易读易写,但“强调的是可读性”。与 HTML 不同,Markdown 标记不会妨碍文本阅读。

Daring Fireball 还提供了一份通用 Markdown 概述,但如果您在处理 GitHub 文件,还可能想了解GitHub 的增强 Markdown

这里是一个 REAMDE.md 文件的示例:

# Project Title

Provide a brief description of the project and what it does.
If the project has a UI, include a screenshot as well.

If more comprehensive documentation exists, link to it here.

## Features

Describe the core features of the project (what does it do?)
in the form of a bulleted list:

- Feature #1
- Feature #2
- Feature #3

## Getting Started

Provide installation instructions, general usage guidance, API examples,
and build and deployment information. Assume as little prior knowledge
as possible, describing everything in clear and coherent steps.

### Installation/Dependencies

How does a user get up and running with your project? What dependencies
does the project have? Aim to describe these in clear and simple steps.
Provide external links.

### Usage

Provide examples of how the project may be used. For large projects with
external documentation, provide a few examples and link to the full docs here.

### Build/Deployment

If the user will be building or deploying the project, add any useful guidance.

## Getting Help

What should users do and expect when they encounter bugs or get stuck using
your project? Set expectations for support, link to the issue tracker and
roadmap, if applicable.

Where should users go if they have a question? (Stack Overflow, Gitter, IRC,
mailing list, etc.)

If desired, you may also provide links to core contributor email addresses.

## Contributing Guidelines

Include instructions for setting up the development environment, code standards,
running tests, and submitting pull requests. It may be useful to link to a
separate CONTRIBUTING.md file. See this example from the Hoodie project:
https://github.com/hoodiehq/hoodie/blob/master/CONTRIBUTING.md

## Code of Conduct

Provide a link to the Code of Conduct for your project. I recommend using the
Contributor Covenant: http://contributor-covenant.org/

## License

Include a license for your project. If you need help choosing a license,
use this guide: https://choosealicense.com

大多数流行的文本编辑器都包含 Markdown 语法高亮和预览功能。还有适用于所有平台的桌面 Markdown 编辑器可用。我也可以使用命令行工具,如Pandoc,将 README.md 文件转换为可读的 HTML:

$ pandoc README.md -o readme.html

图 18-1 显示了生成的内容。它不花哨,但极易阅读。

jsc3 1801

图 18-1. 从 README.md 文本和 Markdown 标注生成的 HTML

当您将源代码托管在诸如 GitHub 等网站时,GitHub 使用 README.md 文件来生成仓库的封面页面。

编写多平台库

问题

您创建了一个既在浏览器中又在 Node.js 中有用的库,并希望在两个环境中都可用。

解决方案

使用打包工具(例如 Webpack)对您的库进行打包,以便它作为 ES2015 模块、CommonJS 模块和 AMD 模块工作,并可以作为脚本标签在浏览器中加载。

在 Webpack 的webpack.config.js文件中,包括librarylibraryTarget字段,表示模块应该被打包为库并目标多个环境:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-library.js',
    library: 'myLibrary',
    libraryTarget: 'umd',
    globalObject: 'this'
  },
};

library字段指定了在 ECMAScript、CommonJS 和 AMD 模块环境中将要使用的库的名称。libraryTarget字段允许您指定模块将如何公开。默认为var,将公开一个变量。指定umd将使用 JavaScript 通用模块定义(UMD),使多个模块样式能够消耗该库。要使 UMD 构建在浏览器和 Node.js 环境中都可用,您需要将output.globalObject选项设置为this

注意:

有关使用 Webpack 打包代码的详细信息,请参阅第十七章。

讨论:

在示例中,我创建了一个简单的数学库。目前,唯一的函数是一个叫做squareIt的函数,它接受一个数字作为参数,并返回该数字乘以自身的值。这在src/index.js中:

export function squareIt(num) {
    return num * num;
};

package.json文件包含了 Webpack 和 Webpack 命令行接口(CLI)作为开发依赖项。它还将main分发指向了库的打包版本,Webpack 将其输出到dist文件夹中。我还添加了一个名为build的构建脚本,通过输入npm run build(或者如果使用 Yarn 则是yarn run build)来运行 Webpack 打包程序。

{
  "name": "my-library",
  "version": "1.0.0",
  "description": "An example library bundled by Webpack",
  "main": "dist/my-library.js",
  "scripts": {
    "build": "webpack"
  },
  "keywords": ["example"],
  "author": "Adam Scott <adam@jseverywhere.io>",
  "license": "MIT",
  "devDependencies": {
    "webpack": "4.44.1",
    "webpack-cli": "3.3.12"
  }
}

最后,我的项目包含一个webpack.config.js,如配方中所述。

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-library.js',
    library: 'myLibrary',
    libraryTarget: 'umd',
    globalObject: 'this'
  },
};

使用此设置,命令npm run build将会打包库并将其放置在项目的dist目录中。这个打包文件是库的消费者将使用的文件。

提示:

在将包发布到 npm 之前,可以在项目目录的根目录下运行npm link来本地测试包。然后在另一个项目中,希望使用该模块的地方,输入npm link <library name>。这样做将创建一个符号链接到该包,就像全局安装一样。

发布库:

一旦您的库完成,您很可能希望将其发布到 npm 以便分发。确保您的项目使用 Git 进行版本控制,并已推送到公共远程存储库(例如 GitHub 或 GitLab)。从项目目录的根目录开始:

$ git init
$ git remote add origin git://git-remote-url
$ npm publish

一旦发布到远程 Git 仓库和 npm 注册表中,可以通过运行npm install、下载或克隆 Git 仓库,或直接在网页中使用https://unpkg.com/引用该库,来消耗该库。该库可以跨多个 JavaScript 库格式使用。

作为 ES 2015 模块:

import * as myLibrary from 'my-library';

myLibrary.squareIt(4);

作为 CommonJS 模块:

const myLibrary = require('my-library');

myLibrary.squareIt(4);

作为 AMD 模块:

require(['myLibrary'], function (myLibrary) {
  myLibrary.squareIt(4);
});

在网页上使用脚本标签:

<!doctype html>
<html>
  <script src="https://unpkg.com/my-library"></script>
  <script>
    myLibrary.squareIt(4);
  </script>
</html>

处理库的依赖关系:

通常库可能包含子依赖项。通过我们当前的设置,所有依赖项将与库本身一起打包和捆绑。为了限制输出的捆绑包,并确保库使用者未安装多个子依赖项的实例,最好将它们视为“对等依赖项”,这也必须安装或单独引用。为此,在您的webpack.config.js中添加一个externals属性。在下面的示例中,moment被视为对等依赖项:

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'my-library.js',
    library: 'myLibrary',
    libraryTarget: 'umd',
    globalObject: 'this'
  },
  externals: {
    moment: {
      commonjs: 'moment',
      commonjs2: 'moment',
      amd: 'moment',
      root: 'moment',
    }
  }
};

通过这种配置,moment将被我们的库视为全局变量。

单元测试您的模块

问题

您希望确保您的模块正常运行,并准备好供其他人使用。

解决方案

单元测试作为您的生产过程的一部分添加。

鉴于以下名为bbarray的模块,并在名为index.js的文件中创建:

const util = require('util');

const bbarray = {
  concatArray: (str, array) => {
    if (!util.isArray(array) || array.length === 0) {
      return -1;
    }

    if (typeof str !== 'string') {
      return -1;
    }

    return array.map(element => {
      return `${str} ${element}`;
    });
  },
  splitArray: (str, array) => {
    if (!util.isArray(array) || array.length === 0) {
      return -1;
    }

    if (typeof str !== 'string') {
      return -1;
    }

    return array.map(element => {
      return element.substring(str.length + 1);
    });
  }
};

module.exports = bbarray;

使用Jest,一个 JavaScript 测试框架,下面的单元测试(创建为index.js,位于项目的test子目录中)应该能够成功通过六个测试:

const bbarray = require('../index.js');

describe('concatArray()', () => {
  test('should return -1 when not using array', () => {
    expect(bbarray.concatArray(9, 'str')).toBe(-1);
  });

  test('should return -1 when not using string', () => {
    expect(bbarray.concatArray(9, ['test', 'two'])).toBe(-1);
  });

  test('should return an array with proper args', () => {
    expect(bbarray.concatArray('is', ['test', 'three'])).toStrictEqual([
      'is test',
      'is three'
    ]);
  });
});

describe('splitArray()', () => {
  test('should return -1 when not using array', () => {
    expect(bbarray.splitArray(9, 'str')).toBe(-1);
  });

  test('should return -1 when not using string', () => {
    expect(bbarray.splitArray(9, ['test', 'two'])).toBe(-1);
  });

  test('should return an array with proper args', () => {
    expect(bbarray.splitArray('is', ['is test', 'is three'])).toStrictEqual([
      'test',
      'three'
    ]);
  });
});

测试的结果显示在图 18-2 中,使用npm test运行。

jsc3 1802

图 18-2. 基于 Jest 运行单元测试

讨论

单元测试是开发人员测试其代码以确保其符合规范的一种方式。它涉及测试功能行为,并查看当您发送错误参数或根本不发送参数时会发生什么。它被称为单元测试,是因为它与代码的个别单元一起使用,例如在 Node 应用程序中测试一个模块,而不是测试整个 Node 应用程序。它成为集成测试的一部分,其中所有部件在集成在一起之前,进行用户验收测试:测试以确保应用程序是否符合用户的期望(并且他们在使用时通常不会讨厌它)。

单元测试是一种开发任务,起初可能看起来很麻烦,但很快就会变得很自然。一个良好的目标是并行开发测试和代码。许多开发人员甚至实践测试驱动开发,其中单元测试是在编写代码本身之前编写的。

在解决方案中,我们使用了 Jest,一个复杂的测试框架。这个模块很简单,所以我们没有使用一些更复杂的 Jest 测试机制。然而,这提供了编写单元测试的基本示例。

要安装 Jest,请使用以下命令:

$ npm install jest --save-dev

我正在使用--save-dev标志,因为我正在将 Jest 安装到模块的开发依赖项中。此外,我修改模块的package.json文件以添加以下部分:

 "scripts": {
    "test": "jest"
  },

测试脚本保存为index.js,位于项目的tests子目录下。Jest 会自动查找tests目录中的文件或遵循filename.test.js命名模式的文件。以下命令运行测试:

$ npm test

Jest 单元测试利用 expect matchers 来测试返回值。

第十八章:Node 模块

编写 Node.js 应用程序的一个重要方面是其内建的模块化环境。下载和安装任意数量的 Node 模块都非常简单,同样简单的是使用它们:只需包含一个 require() 语句来命名模块,然后就可以运行了。

JavaScript 模块化 的一个好处是模块的轻松集成。模块化确保外部功能被创建的方式不依赖于其他外部功能,这是一种称为 松耦合 的概念。这意味着我可以使用 Foo 模块,而不必包含 Bar 模块,因为 Foo 严重依赖于已经包含的 Bar

JavaScript 的模块化既是一种纪律,也是一种契约。纪律在于必须遵循特定的标准,以便外部代码能够参与模块系统。契约是在你、我和其他 JavaScript 开发人员之间的一种约定:我们在模块系统中生产(或消费)外部功能时,都遵循了一个商定的路径,并且我们都对模块系统有期望。

注意

几乎所有应用程序和库管理及发布的一个主要依赖是 Git,一个源代码控制系统,以及 GitHub,一个非常流行的 Git 终点。Git 的工作原理以及如何在 GitHub 上使用 Git 超出了本书的范围。我建议阅读 Richard Silverman 的 Git Pocket Guide(O’Reilly)来更深入了解 Git,以及访问 GitHub 的 官方文档 以获取更多关于该服务的信息。

通过 npm 搜索特定的 Node 模块

问题

您正在创建一个 Node 应用程序,并希望使用现有模块,但不知道如何找到它们。

解决方案

“使用 npm 下载包” 解释了如何使用 npm 安装包,这是 Node 的流行包管理器(也是维系 Node 世界的粘合剂)。但是您尚未考虑如何 查找 您在 npm 广阔的注册表中需要的有用包。

大多数情况下,您会通过朋友和共同开发者的推荐来发现模块,但有时您需要一些新的东西。您可以直接在 npm 网站 上搜索新模块。您还可以直接使用 npm 命令行界面来搜索模块。例如,如果您对处理 PDF 的模块感兴趣,可以在命令行中运行以下搜索:

process.env.NODE_ENV

讨论

npm 网站不仅提供了使用 npm 的文档,还提供了一个界面用于搜索模块。如果您访问每个模块在 npm 上的页面,您可以看到模块的流行程度、其它依赖它的模块、许可证以及其他相关信息。

但是,您也可以直接使用 npm 搜索模块。这个过程可能需要相当长的时间,完成后,您可能会得到大量的模块返回,特别是对于诸如处理 PDF 的模块这样广泛的主题。

您可以通过列出多个术语来细化结果:

$ NODE_ENV=development node index.js

此查询返回一个更小的模块列表,专门用于 PDF 生成。

一旦您找到一个听起来有趣的模块,您可以通过以下方式获取详细信息:

$ npm install dotenv --save

您将从模块的 package.json 中获得有用的信息,它可以告诉您它依赖于什么,由谁编写的,以及创建时间。我们仍建议直接查看模块的 npm 网站页面和 GitHub 存储库页面。在那里,您可以确定模块是否正在积极维护,了解模块的受欢迎程度,查看开放的问题,并查看源代码。

将您的库转换为 Node 模块

问题

您想在 Node 中使用您的一个库。

解决方案

将库转换为 Node 模块。在 Node 中,每个文件都被视为一个模块。例如,如果库是包含在 /lib/hello.js 的函数文件:

require('dotenv').config();

您可以使用 exports 关键字将其转换为 Node 模块:

PORT=8080
DB_URI=mongodb://mongodb0.example.com:27017
KEY=12345

或者,也可以直接 export 函数:

const port = process.env.PORT || 8080;

然后,您可以在应用程序中使用该模块:

require('dotenv').config();

讨论

Node 的默认模块系统基于 CommonJS,使用三种结构:exports 用于定义从库中导出的内容,require() 用于在应用程序中包含模块,以及 module,它包含关于模块的信息,但也可用于直接导出函数。

如果您的库返回一个带有多个函数和数据对象的对象,则可以将每个分配给module.exports上同名属性,或者您可以返回一个对象:

require('dotenv').config({ path: '/alternate/file/path/.env' })

或者:

import dotenv from 'dotenv'

dotenv.config()

然后直接访问对象属性:

if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config();
}

因为该模块未使用 npm 安装,而是仅驻留在应用程序所在的目录中,所以它通过文件位置和名称访问,而不仅仅是名称。

参见

在 “跨模块环境进行代码转换” 中,我们介绍了如何确保您的库代码在 CommonJS 和 ECMAScript 模块环境中都能正常工作。

在 “创建可安装的 Node 模块” 中,我们介绍了如何创建一个独立的模块。

跨模块环境进行代码转换

问题

您编写了一个库,希望与他人分享,但使用各种 Node 版本和 CommonJS 及 ECMAScript 模块。如何确保您的库在所有不同的环境中都能正常工作?

解决方案

使用带有 ECMAScript 模块包装器的 CommonJS 模块。

首先,将库写成一个 CommonJS 模块,并保存为 .cjs 文件扩展名:

const fs = require('fs');
const { promisify } = require('util');

const readFile = promisify(fs.readFile);
const appendFile = promisify(fs.appendFile);

const readAppend = async (originalFile, secondaryFile) => {
  const fileData = await readFile(originalFile);
  await appendFile(secondaryFile, fileData);
  console.log(
    `The data from ${originalFile} was appended to ${secondaryFile}!`
  );
};

readAppend('./files/main.txt', './files/secondary.txt');

紧随其后的是 ECMAScript 包装模块,使用 .mjs 文件扩展名:

const fsp = require('fs').promises;

const readAppend = async (originalFile, secondaryFile) => {
  const fileData = await fsp.readFile(originalFile);
  await fsp.appendFile(secondaryFile, fileData);
  console.log(
    `The data from ${originalFile} was appended to ${secondaryFile}!`
  );
};

readAppend('./files/main.txt', './files/tertiary.txt');

以及包含 typemainexports 字段的 package.json 文件:

fs.readFile(file, (error, data) => {
  if (error) {
    // handle error
  } else {
    // execute an operation after the file is read
  }
});

我们模块的用户可以使用require语法导入模块,使用 CommonJS 语法:

const waitOne = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('It has been one second');
      resolve();
    }, 1000);
  });
};

const callWait = async () => {
  await waitOne();
};

callWait();

or:

const fs = require('fs');
const { promisify } = require('util');

const writeFile = promisify(fs.writeFile);

而那些使用 ECMAScript 模块的用户可以指定使用 ES import语法的module版本库:

try {
  await writeFile(file, buf);
} catch (error) {
  console.log(error);
  throw error;
}

or:

const fs = require('fs');

const callbackHell = file => {
  const buf = Buffer.from('Callback hell first string\n');
  const buf2 = Buffer.from('Callback hell second string\n');

  // write or append the contents of the first buffer
  fs.writeFile(file, buf, err => {
    if (err) {
      console.log(err);
      throw err;
    }
    // append the contents of the second buffer
    fs.appendFile(file, buf2, err2 => {
      if (err2) {
        console.log(err2);
        throw err2;
      }
      // log the contents of the file
      fs.readFile(file, 'utf-8', (err3, data) => {
        if (err3) {
          console.log(err3);
          throw err3;
        }
        console.log(data);
      });
    });
  });
};

callbackHell('./files/callback.txt');
注意

当前编写时,可以通过使用--experimental-conditional-exports标志避免 ECMAScript 模块中的/module命名约定。然而,由于当前是实验性质并且语法可能会发生变化,我们目前建议不要这样做。在 Node 的未来版本中,这可能会成为标准。您可以在Node 文档中了解更多关于这种方法的信息。

讨论

自 Node 起,CommonJS 模块一直是标准,诸如 Browserify 之类的工具将这种语法带出 Node 生态系统,允许开发者在浏览器中使用 Node 风格模块。ECMAScript 2015(也称为 ES6)标准引入了一种原生 JavaScript 模块语法,这在 Node 8.5.0 中引入,并可以在--experimental-module标志后使用。从 Node 13.2.0 开始,Node 支持原生 ECMAScript 模块。

一个常见的模式是使用 CommonJS 或 ECMAScript 模块语法编写模块,并使用编译工具将它们作为单独的模块入口点或导出路径发布。然而,如果应用程序通过一种语法直接加载模块,并且该模块通过另一种语法或依赖项直接或间接加载,这可能导致模块加载两次。

package.json中有三个关键字段:

const fs = require('fs');
const { promisify } = require('util');

const writeFile = promisify(fs.writeFile);
const appendFile = promisify(fs.appendFile);
const readFile = promisify(fs.readFile);

const fileWriteRead2 = async file => {
  const buf = Buffer.from('The first string\n');
  const buf2 = Buffer.from('The second string\n');

  // write or append the contents of the first buffer
  try {
    await writeFile(file, buf);
  } catch (error) {
    console.log(error);
    throw error;
  }

  // append the contents of the second buffer
  try {
    await appendFile(file, buf2);
  } catch (error) {
    console.log(error);
    throw error;
  }

  // log the contents of the file
  console.log(await readFile(file, 'utf8'));
};

fileWriteRead2('./files/async.txt');

"type"

指定这是一个module,意味着这个库正在使用 ECMAScript 模块语法。对于完全使用 CommonJS 的库,"type"将是"commonjs"

"main"

指定应用程序的主入口点,我们将指向 CommonJS 文件。

"exports"

定义我们模块的导出路径。通过这种方式,使用默认package的消费者将直接接收 CommonJS 模块,而使用package/module的消费者将从 ECMAScript 模块包装器中导入文件。

如果我们希望避免使用.cjs.mjs文件扩展名,我们可以这样做:

const { spawn } = require('child_process');

const identify = spawn('identify', ['-verbose', 'osprey.jpg']);

identify.stdout.on('data', data => {
  console.log(`stdout: ${data}`);
});

identify.stderr.on('data', data => {
  console.log(`stderr: ${data}`);
});

identify.on('exit', code => {
  console.log(`child process exited with code ${code}`);
});

另请参阅

在“编写多平台库”中,我们介绍了如何通过使用 Webpack 作为代码捆绑器,确保您的库代码在 Node 和浏览器中的多个模块环境中正常工作。

创建一个可安装的 Node 模块

问题

您可以从头开始创建一个 Node 模块,或者将现有库转换为可以在浏览器或 Node 中工作的模块。现在,您想知道如何将其修改为可以使用 npm 安装的模块。

解决方案

一旦创建了你的 Node 模块和任何支持的功能(包括模块测试),你可以打包整个目录。打包和发布 Node 模块的关键是创建一个描述模块、任何依赖项、目录结构、忽略内容等的 package.json 文件。你可以在项目根目录运行 npm init 命令并按提示操作来生成 package.json 文件。

下面是一个相对基本的 package.json 文件:

identify.stdout.on('data', (data) => {
    console.log(data.toString().split("\n"));
});

一旦创建了 package.json,将所有源目录和 package.json 文件打包为一个 gzip 压缩的 tarball。然后在本地安装该包,或者在 npm 上进行公共访问。

讨论

package.json 文件是将 Node 模块打包为本地安装或上传到 npm 进行管理的关键。至少需要 nameversion。解决方案中给出的其他字段包括:

description

模块的描述和功能

main

模块的入口文件

author

模块的作者

keywords

关键字列表,可以帮助其他人找到这个模块

repository

代码所在的地方,通常是 GitHub

engines

你知道你的模块可以运行的 Node 版本

bugs

提交 bug 的地方

licenses

你的模块许可证

dependencies

模块所需的依赖项列表

directories

描述你的模块目录结构的哈希

scripts

描述在模块生命周期内运行的对象命令的哈希

npm 网站 上有一系列其他选项描述。你也可以使用工具来帮助填写许多这些字段。在命令行输入以下内容运行该工具,它会询问问题,然后生成一个基本的 package.json 文件:

const { spawn } = require('child_process');

const cmd = spawn('cmd', ['/c', 'dir\n']);

cmd.stdout.on('data', data => {
  console.log(`stdout: ${data}`);
});

cmd.stderr.on('data', data => {
  console.log(`stderr: ${data}`);
});

cmd.on('exit', code => {
  console.log(`child process exited with code ${code}`);
});

一旦设置了源代码并创建了 package.json 文件,你可以在模块的顶级目录运行以下命令测试一切是否正常:

process.argv.forEach((value, index) => {
  console.log(`${index}: ${value}`);
});

如果没有错误,那么可以将文件打包为 gzip 压缩的 tarball。此时,如果要发布模块,你首先需要在 npm 注册表中添加自己为用户:

$ node index.js --name=Adam --food=pizza

要将 Node 模块发布到 npm 注册表,请在模块的根目录中使用以下命令,指定一个 tarball 的 URL、文件名或路径:

0: /usr/local/bin/node
1: /Users/ascott/Projects/command-line-args/index.js
2: --name=Adam
3: --food=pizza

如果你的模块有开发依赖项,比如使用 Jest 这样的测试框架,确保这些依赖项添加到 package.json 文件的一个优秀捷径是在与 package.json 文件相同的目录中使用以下内容时,安装依赖模块:

const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const {argv} = yargs(hideBin(process.argv));

console.log(argv);

不仅会安装 Jest(稍后讨论,在 “单元测试你的模块” 中),这条命令还会更新你的 package.json 文件,内容如下:

$ node index.js --name=Adam --food=pizza
# logs the following:
{ _: [], name: 'Adam', food: 'pizza', '$0': 'yargs/index.js' }

你也可以使用相同类型的选项将模块添加到 package.json 中的 dependencies。以下内容:

const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');

const {argv} = yargs(hideBin(process.argv));

if (argv.food === 'pizza') {
  console.log('mmm');
}

将以下内容添加到 package.json 文件中:

#!/usr/bin/env node

如果不再需要模块并且不应出现在 package.json 中,请使用以下方法从 devDependencies 中删除它:

#!/usr/bin/env node
const program = require('commander');

program
  .version('0.0.1')
  .option('-n, --number <value>', 'A number to square')
  .parse(process.argv);

const square = Math.pow(program.number, 2);

console.log(`The square of ${program.number} is ${square}`);

并且从 dependencies 中移除模块的方法是:

#!/usr/bin/env node

如果模块是 dependenciesdevDependencies 中的最后一个,则不会移除该属性。它只是被设置为空值:

$ chmod a+x square.js
注意

npm 还为创建和安装 Node 模块提供了一个不错的开发人员指南。您应该考虑使用 .npmignore.gitignore 文件来排除您模块中的内容。虽然这超出了本书的范围,但您还应该熟悉 Git 和 GitHub,并在应用程序/模块中使用它。

额外信息:README 文件和 Markdown 语法

当您将模块或库打包以便重用,并将其上传到 GitHub 等源代码存储库时,您需要提供有关安装模块/库的详细信息以及如何使用它的基本信息。为此,您需要一个 README 文件。

您可能已经看到过名为 README.md 的文件,它们与应用程序和 Node 模块一起。它们基于文本,并带有一些奇怪而不显眼的标记,您可能不确定其有用性,直到在像 GitHub 这样的网站上看到它,其中 README 文件提供了项目页面安装和使用信息的全部内容。这些标记被转换为 HTML,使得网页帮助内容更易读。

README 的内容使用称为 Markdown 的注释标记进行标记。流行的网站 Daring Fireball 称 Markdown 易于阅读和写作,但“可读性始终是最重要的”。与 HTML 不同,Markdown 标记不会妨碍文本阅读。

注意

Daring Fireball 还提供了一个通用 Markdown 概述,但如果您在处理 GitHub 文件,可能还想查看GitHub 的 Flavored Markdown

这里是一个示例 REAMDE.md 文件:

$ ./square.js -n 4

大多数流行的文本编辑器包括 Markdown 语法高亮和预览功能。所有平台还提供桌面 Markdown 编辑器。我还可以使用 CLI 工具,例如 Pandoc,将 README.md 文件转换为可读的 HTML:

#!/usr/bin/env node
const program = require('commander');
const puppeteer = require('puppeteer');

program
  .version('0.0.1')
  .option('-s, --source [website]', 'Source website')
  .option('-f, --file [filename]', 'Filename')
  .parse(process.argv);

(async () => {
  console.log('capturing screenshot...');
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(program.source);
  await page.screenshot({ path: program.file });
  await browser.close();
  console.log(`captured screenshot at ${program.file}`);
})();

图 18-1 显示了生成的内容。它并不花哨,但却非常易读。

jsc3 1801

图 18-1. README.md 文本和 Markdown 注释生成的 HTML

当您将源代码托管在 GitHub 等网站时,GitHub 使用 README.md 文件生成存储库的封面页面。

编写多平台库

问题

您已经创建了一个在浏览器和 Node.js 中都有用的库,并希望在两个环境中都可以使用它。

解决方案

使用打包工具(如 Webpack)来打包您的库,以使其作为 ES2015 模块、CommonJS 模块和 AMD 模块运行,并且可以作为脚本标签在浏览器中加载。

在 Webpack 的 webpack.config.js 文件中,包括 librarylibraryTarget 字段,表示该模块应作为库捆绑,并针对多个环境进行目标化:

"main": "snapshot.js",
"preferGlobal": true,
"bin": {
  "snapshot": "snapshot.js"
},

library 字段指定一个库的名称,将在 ECMAScript、CommonJS 和 AMD 模块环境中使用。libraryTarget 字段允许您指定模块将如何公开。默认值为 var,将公开一个变量。指定 umd 将利用 JavaScript Universal Module Definition (UMD),使多个模块样式能够使用该库。要使 UMD 构建在浏览器和 Node.js 环境中都可用,需要设置 output.globalObject 选项为 this

注意

有关使用 Webpack 进行代码捆绑的更多详情,请参见第十七章。

讨论

在示例中,我创建了一个简单的数学库。当前,唯一的函数是一个名为 squareIt 的函数,接受一个数字作为参数,并返回该数字乘以自身的值。这在 src/index.js 中:

$ snapshot -s http://oreilly.com -f test.png

package.json 文件包含 Webpack 和 Webpack 命令行接口(CLI)作为开发依赖项。它还将 main 分发指向库的捆绑版本,Webpack 将其输出到 dist 文件夹。我还添加了一个名为 build 的构建脚本,将运行 Webpack 打包工具。这样可以通过输入 npm run build(如果使用 Yarn,则为 yarn run build)来生成捆绑包。

$ snapshot --source http://oreilly.com --file test.png

最后,我的项目包含一个 webpack.config.js,如食谱所述:

  Usage: snapshot [options]

  Options:

    -h, --help              output usage information
    -V, --version           output the version number
    -s, --source [website]  Source website
    -f, --file [filename]   Filename

使用此设置,命令 npm run build 将捆绑库并放置在项目的 dist 目录中。消费者将使用此捆绑文件。

提示

在将包发布到 npm 之前,可以在本地测试包,从项目目录的根目录运行 npm link。然后在希望使用该模块的另一个项目中,键入 npm link <library name>。这样做将创建一个符号链接到包,就像全局安装一样。

发布库

完成库后,您可能会希望将其发布到 npm 进行分发。确保项目已使用 Git 进行版本控制,并已推送到公共远程仓库(如 GitHub 或 GitLab)。从项目根目录:

$ snapshot -V

发布到远程 Git 仓库和 npm 注册表后,可以通过运行 npm install,下载或克隆 Git 仓库,或直接在网页中引用库,使用 https://unpkg.com/。该库可以跨多个 JavaScript 库格式进行消费。

作为 ES 2015 模块:

$ npm publish

作为 CommonJS 模块:

$ pm2 start index.js

作为 AMD 模块:

$ sudo npm install pm2 -g

并在网页上使用脚本标签:

$ pm2 start index.js

处理库的依赖关系

往往一个库可能包含子依赖项。使用我们当前的设置,所有依赖项将被打包并与库一起捆绑。为了限制输出的捆绑包并确保库使用者不会安装多个子依赖项的实例,最好将它们视为“对等依赖项”,这也必须被单独安装或引用。要执行此操作,请向您的webpack.config.js添加一个externals属性。在下面的示例中,moment正被用作对等依赖项:

$ pm2 start  -l forever.log -o out.log -e err.log -n app_name index.js --watch

使用此配置,moment将作为我们库的全局变量处理。

单元测试你的模块

问题

您希望确保您的模块正常运行并准备好供他人使用。

解决方案

单元测试 作为生产过程的一部分添加。

鉴于以下名为bbarray的模块,并在名为index.js的文件中创建:

"scripts": {
    "start": "pm2 start index.js",
}

使用Jest,一个 JavaScript 测试框架,以下单元测试(作为index.js创建,并位于项目的test子目录中)应该能够成功通过六个测试:

$ npm install -g nodemon

测试结果显示在图 18-2,使用npm test运行。

jsc3 1802

图 18-2. 基于 Jest 运行的单元测试

讨论

单元测试 是开发人员测试其代码以确保其符合规范的一种方式。它涉及测试功能行为,并查看在发送错误参数或根本没有参数时会发生什么。之所以称为单元测试,是因为它用于单独的代码单元,例如在 Node 应用程序中测试一个模块,而不是测试整个 Node 应用程序。它成为集成测试 的一部分,其中所有部分在连接在一起之前进行测试,然后进行用户验收测试:测试确保应用程序按照用户期望的方式运行(并且他们通常在使用时不讨厌它)。

单元测试是开发中可能一开始看起来很痛苦的任务之一,但很快就会变得轻车熟路。一个良好的目标是并行开发测试和代码。许多开发人员甚至练习测试驱动开发,在编写代码本身之前先编写单元测试。

在解决方案中,我们使用了 Jest,一个复杂的测试框架。该模块很简单,所以我们没有使用一些更复杂的 Jest 测试机制。但是,这提供了编写单元测试的基本示例。

要安装 Jest,请使用以下命令:

$ nodemon index.js

我使用--save-dev标志,因为我将 Jest 安装到模块的开发依赖项中。此外,我修改了模块的package.json文件以添加以下部分:

$ nodemon index.js -- -param1 -param2

测试脚本保存在项目的tests子目录下的index.js中。Jest 会自动查找tests目录中的文件或遵循filename.test.js命名模式的文件。以下命令运行测试:

[nodemon] 2.0.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
Listening on port 8124

Jest 单元测试利用 expect matchers 来测试返回的值。

第二十章:远程数据

数据围绕着我们。我们在日常生活中创建和交互的数据往往以有趣且意想不到的方式存在。在构建 Node 应用程序时,我们经常与数据交互。有时,这些数据可能是我们为应用程序创建的,或者是用户输入到我们系统中的数据。然而,通常需要与来自应用程序外部的数据进行交互。本章介绍了在 Node 应用程序中处理远程数据的最佳实践和技术。

获取远程数据

问题

您希望在 Node 应用程序中向远程服务器发出请求。

解决方案

使用 node-fetch,这是最受欢迎和广泛使用的模块之一,将浏览器的 window.fetch 带到 Node 中。它通过 npm 安装:

$ npm install node-fetch

并且可以简单地使用:

const fetch = require('node-fetch');

fetch('https://oreilly.com')
  .then(res => res.text())
  .then(body => console.log(body));

讨论

node-fetch 提供了一个 API,紧密模仿了浏览器的 window.fetch,允许我们的 Node 程序访问远程资源。与 window.fetch 类似,它支持 GET、POST、DELETE 和 PUT 的 HTTP 方法。在 GET 的情况下,如果响应表示成功(状态码为 200),则可以按照您希望的方式处理返回的数据(在此示例中格式为 HTML)。

您可以请求 JSON 资源:

fetch('https://swapi.dev/api/people/1')
  .then(res => res.json())
  .then(json => console.log(json));

还可以使用 async/await 语法,包括 try/catch 块进行错误处理:

(async () => {
  try {
    const response = await fetch('https://swapi.dev/api/people/3');
    const json = await response.json();
    console.log(json);
  } catch (error) {
    console.log(error);
  }
})();

你还可以使用文件系统模块将结果流到文件中:

const fs = require('fs');
const fetch = require('node-fetch');

fetch('https://example.com/image.png')
  .then(res => {
    const dest = fs.createWriteStream('image.png');
    res.body.pipe(dest);
  });

node-fetch 还可以处理 POST、DELETE 和 PUT 方法,允许您向服务器发送数据。在以下示例中,我们进行了 POST 请求:

// example body for the request
const body = {
  id: 1,
  title: "Example"
};

fetch('https://example.com/post', {
    method: 'post',
    body:    JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  })
  .then(res => res.json())
  .then(json => console.log(json));
注意

node-fetch 是用于获取远程数据的常见和有用的库,但不是唯一的选择。流行的替代方案包括 Request(虽然仍然流行,但不再活跃维护),Got,Axios 和 Superagent。

屏幕抓取

问题

您希望从 Node 应用程序内部访问 web 资源的特定内容。

解决方案

使用 node-fetch 和 Cheerio 模块进行屏幕抓取网站。

首先安装所需的模块:

$ npm install node-fetch cheerio

要抓取页面,利用 node-fetch 检索内容,然后使用 Cheerio 查询检索到的内容:

const fetch = require('node-fetch');
const cheerio = require('cheerio');

fetch('https://example.com')
  .then(res => res.text())
  .then(body => {
    const $ = cheerio.load(body);
    $('h1').each((i, element) => {
      console.log(element.children[0].data);
    });
  });

讨论

一个有趣的 Node 使用方法是抓取网站或资源,然后使用其他功能查询返回材料中的特定信息。用于查询的流行模块是 Cheerio,这是一个专为服务器使用的 jQuery 核心的微型实现。在以下示例中,创建了一个简单的应用程序来提取 O’Reilly Radar 博客页面上的所有文章标题。为了选择这些标题,我们使用 Cheerio 来查找位于 main 内容中的 h2 元素内部的链接 (a)。然后,将链接的文本列出到单独的输出中:

const fetch = require('node-fetch');
const cheerio = require('cheerio');

fetch('https://www.oreilly.com/radar/posts/')
  .then(res => res.text())
  .then(body => {
    const $ = cheerio.load(body);
    $('main h2 a').each((i, element) => {
      console.log(element.children[0].data);
    });
  });

成功请求后,通过load()方法将返回的 HTML 传递给 Cheerio,并将结果赋值给一个美元符号变量($),以便我们可以类似于 jQuery 库的方式选择结果中的元素。

然后使用main h2 a元素模式查询所有匹配项,并使用each方法处理结果,访问每个标题的文本。控制台的输出应该是博客主页上所有文章的标题。

一个常见的用例是在没有提供 API 的情况下下载数据。在以下示例中,我们正在定位页面上的特定链接,并将链接的资源传输到本地文件。我还使用了async/await语法来演示如何使用它:

const path =
  'data-research/mortgage-performance-trends/mortgages-30-89-days-delinquent/';
const url = `https://www.consumerfinance.gov/${path}`;

(async () => {
  try {
    const response = await fetch(url);
    const body = await response.text();
    const $ = cheerio.load(body);
    $("a:contains('state')").each(async (i, element) => {
      const fetchFile = await fetch(element.attribs.href);
      const dest = fs.createWriteStream(`data-${i}.csv`);
      await fetchFile.body.pipe(dest);
    });
  } catch (error) {
    console.log(error);
  }
})();

我们首先获取特定 URL 上的页面,这个例子中是一个包含几个链接的美国政府网站的页面。然后我们使用 Cheerio 定位页面上所有包含“state”单词的链接。最后,我们获取链接的文件并将其传输到本地文件。

警告

屏幕抓取可以是您工具箱中有用的工具,但请谨慎操作。在为生产应用程序抓取网站之前,请务必查阅其服务条款(ToS)或征得网站所有者的许可。同时,要小心不要通过过载主机的服务器而意外执行拒绝服务攻击(DDoS)。

通过 RESTful API 访问 JSON 格式化数据

问题

您想通过其 API 从服务中访问以 JSON 格式化的数据。

解决方案

在 Node 应用程序中,从 API 获取 JSON 格式化数据的最简单技术是使用 HTTP 请求库。

在以下示例中,我将再次使用node-fetch,就像在“获取远程数据”中一样:

const fetch = require('node-fetch');

(async () => {
  try {
    const response = await fetch('https://swapi.dev/api/people/1/');
    const json = await response.json();
    console.log(json);
  } catch (error) {
    console.log(error);
  }
})();

npm 模块gotnode-fetch的一种流行替代品:

const got = require('got');

(async () => {
  try {
    const response = await got('https://swapi.dev/api/people/2/');
    console.log(JSON.parse(response.body));
  } catch (error) {
    console.log(error.response.body);
  }
})();

讨论

RESTful API 是一种无状态的 API,意味着每个客户端请求都包含服务器响应所需的一切(不涉及请求之间的存储状态);它显式地使用 HTTP 方法。它支持类似目录结构的 URI,并以特定的方式传输数据(通常是 XML 或 JSON)。HTTP 方法包括:

  • GET: 获取资源数据

  • PUT: 更新资源

  • DELETE: 删除资源

  • POST: 创建资源

因为我们专注于获取数据,所以目前唯一感兴趣的方法是 GET。因为我们专注于 JSON,所以我们使用可以访问 JSON 格式数据并将其转换为我们可以在 JavaScript 应用程序中操作的对象的客户端方法。

让我们看另一个例子。

Open Exchange Rate 提供了一个 API,我们可以用它来获取当前的汇率,将名称转换为不同类型的货币缩写,以及特定日期的汇率。它有一个永久免费计划,可以免费访问有限的 API。

可以对系统进行两次查询(当前货币汇率和名称到缩写),当两个查询都完成时,将缩写作为键获取,并使用这些键在结果中查找长名称和汇率,然后将这些配对打印到控制台:

const fetch = require('node-fetch');
require('dotenv').config();

const id = process.env.APP_ID;

(async () => {
  try {
    const moneyAPI1 = await fetch(
      `https://openexchangerates.org/api/latest.json?app_id=${id}`
    );
    const moneyAPI2 = await fetch(
      `http://openexchangerates.org/api/currencies.json?app_id=${id}`
    );

    const latest = await moneyAPI1.json();
    const names = await moneyAPI2.json();
    const keys = Object.keys(latest.rates);

    keys.forEach((value, index) => {
      const rate = latest.rates[keys[index]];
      const name = names[keys[index]];
      console.log(`${name} ${rate}`);
    });
  } catch (error) {
    console.log(error);
  }
})();
注意

请注意,id 值需要用您在创建账户时 API 提供商分配的唯一 ID 替换。在这个例子中,我使用 dotenv 模块从 .env 文件中加载存储的值。

基础货币是“USD”或美元,以下是结果的样本:

"Malawian Kwacha 394.899498"
"Mexican Peso 13.15711"
"Malaysian Ringgit 3.194393"
"Mozambican Metical 30.3662"
"Namibian Dollar 10.64314"
"Nigerian Naira 162.163699"
"Nicaraguan Córdoba 26.03978"
"Norwegian Krone 6.186976"
"Nepalese Rupee 98.07189"
"New Zealand Dollar 1.185493"

在代码片段中,我使用 async/await 进行查询,并在两个查询都完成后处理结果。在生产系统中,我们很可能会根据我们的计划允许的时间(免费 API 访问每小时一次)缓存结果。

参见

示例中不需要 转义 用作 API 请求参数的值,但如果需要转义值,可以使用 Node 的内置 querystring.escape() 方法。

第二十一章:使用 Express 构建 Web 应用程序

Express是一个轻量级的 Web 框架,在 Node 中长期领先于 Web 应用程序开发。与 Ruby 的 Sinatra 和 Python 的 Flask 类似,Express 框架本身非常简洁,但可以扩展以构建任何类型的 Web 应用程序。Express 也是 Web 应用程序框架中的一部分,例如Keystone.jsSailsVulcan.js。如果你在 Node 中进行 Web 应用程序开发,你很可能会遇到 Express。本章重点介绍了使用 Express 处理基本应用程序的几种方法,可以扩展到各种 Web 应用程序。

使用 Express 响应请求

问题

您的 Node 应用程序需要响应 HTTP 请求。

解决方案

安装 Express 包:

$ npm install express

要设置 Express,我们需要引入模块,调用模块,并在名为index.js的文件中指定一个连接端口:

const express = require('express');

const app = express();
const port = process.env.PORT || '3000';

app.listen(port, () => console.log(`Listening on port ${port}`));

要响应请求,指定一个路由并使用 Express 的.get方法进行响应:

const express = require('express');

const app = express();
const port = process.env.PORT || '3000';

app.get('/', (req, res) => res.send('Hello World'));

app.listen(port, () => console.log(`Listening on port ${port}`));

要提供静态文件,我们可以使用express.static中间件指定一个目录。

const express = require('express');

const app = express();
const port = process.env.PORT || '3000';

// middleware for static files
// will serve static files from the 'files' directory
app.use(express.static('files'));

app.listen(port, () => console.log(`Listening on port ${port}`));

要使用从模板生成的 HTML 来响应,请先安装模板引擎:

$ npm install pug --save

接下来,在index.js文件中设置view engine并指定将以模板内容响应的路由:

app.set('view engine', 'pug')

app.get('/template', (req, res) => {
  res.render('template');
});

然后在项目的views子目录中创建一个新文件,作为模板文件。模板文件名应与res.render中指定的名称匹配。在views/template.pug中:

html
  head
    title="Using Express"
  body
    h1="Hello World"

现在,对http://localhost:3000/template的请求将返回 HTML 格式的模板内容。

讨论

Express 是一个极简但高度可配置的框架,用于响应 HTTP 请求和构建 Web 应用程序。在示例中,我们将端口设置为process.env.PORT或端口3000。在开发中,我们可以通过环境变量指定一个新的端口:

$ PORT=7777 node index.js

或者通过使用配对dotenvNode 模块的.env文件。在部署应用程序时,应用程序托管平台可能需要特定的端口号或允许我们自己配置端口号。

使用 Express 的get方法,应用程序接收到特定 URI 的请求,然后做出响应。在我们的例子中,当应用程序接收到根 URI(/)的请求时,我们会回复“Hello World”文本:

app.get('/', (req, res) => res.send('Hello World'));

这些响应也可以是 HTML、渲染为 HTML 的模板、静态文件和格式化数据(如 JSON 或 XML)。

由于其极简的特性,Express 本身包含了极少的功能,但可以通过中间件进行扩展。在 Express 中,中间件函数可以访问requestresponse对象。应用级中间件通过app.use(MIDDLEWARE)绑定到app对象的一个实例上。在这个例子中,我们正在使用内置的静态文件中间件:

app.use(express.static('files'));

中间件包可以在许多方面扩展 Express 的功能。helmet 中间件包可以用于改善 Express 的安全默认设置:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(helmet());

模板引擎简化了编写 HTML 的过程,并允许你将数据传递给页面。

这里我正在从 userData 对象中传递数据到 views/user.pug 模板,该模板可以在 /user 路由中访问:

// a user object of data to send to the template
const userData = {
  name: 'Adam',
  email: 'adam@jseverywhere.io',
  avatar: 'https://s.gravatar.com/avatar/33aab819d1ffa11fc4b31a4eebaf0c5a?s=80'
};

// render the template with user data
app.get('/user', (req, res) => {
  res.render('user', { userData });
});

然后在我们的模板中,我们可以使用数据:

html
  head
    title User Page
  body
    h1 #{userData.name} Profile
    ul
      li
        image(src=userData.avatar)
      li #{userData.name}
      li #{userData.email}

Pug 模板引擎由 Express 核心团队维护,是 Express 应用程序的流行选择,但其基于空白符的语法不适合每个人。EJS 是一个更接近 HTML 的语法的优秀选择。以下是使用 EJS 的上述示例的效果。

首先,指定安装 ejs 包:

$ npm install ejs

然后在你的 Express 应用程序中设置 EJS 作为视图引擎:

app.set('view engine', 'ejs');

而在 views/user.ejs 中:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>User Page</title>
  </head>
  <body>
    <h1><%= userData.name %> Profile</h1>
    <ul>
      <li><img src=<%= userData.avatar %> /></li>
      <li><%= userData.name %></li>
      <li><%= userData.email %></li>
    </ul>
  </body>
</html>

使用 Express-Generator

问题

你有兴趣使用 Express 管理你的服务器端数据应用程序,但不想自己处理所有设置。

解决方案

要启动你的 Express 应用程序,请使用 Express-Generator。这是一个命令行工具,用于生成典型 Express 应用程序的基础架构骨架。

首先,创建一个工作目录,在这里工具可以安全地安装一个新的应用程序子目录。接下来,使用 npx 运行 express-generator 命令:

$ npx express-generator --pug --git

我在命令中传递了两个选项:--pug 将使用 Pug 模板引擎,而 --git 将在项目目录中生成默认的 .gitignore 文件。要查看所有选项,请使用 -h 选项运行生成器:

$ npx express-generator -h

生成器创建一个新目录,包含多个子目录、一些基本文件和一个包含所有依赖项的 package.json 文件。要安装依赖项,请切换到新创建的目录并输入:

$ npm install

一旦安装了所有依赖项,使用以下命令运行应用程序:

$ npm start

现在,你可以通过你的 IP 地址或域名和端口 3000 访问生成的 Express 应用程序,这是 Express 的默认端口。

讨论

Express 提供了一个基于 Node 的 Web 应用程序框架,并支持多个模板引擎和 CSS 预处理器。在这个解决方案中,我选择的示例应用程序选项是 Pug 作为模板引擎(默认设置)和纯 CSS(无 CSS 预处理器)。虽然从头开始构建应用程序可以选择更多选项,但 Express 仅支持以下模板引擎:

--ejs

添加对 EJS 模板引擎的支持

--pug

添加对 Pug 模板引擎的支持

--hbs

添加对 Handlebar 模板引擎的支持

--hogan

添加对 Hogan.js 模板引擎的支持

Express 还支持以下 CSS 预处理器:

express --css sass

支持 Sass

express --css less

支持 Less

express --css stylus

支持 Stylus

express --css compass

支持 Compass

如果不指定任何 CSS 预处理器,默认为纯 CSS。

Express 还假定项目目录为空。如果不是,可以通过使用 -f--force 选项强制 Express 生成内容。

新生成的子目录具有以下结构(忽略 node_modules):

app.js
package-lock.json
package.json
/bin
   www
/node_modules
/public
   /images
   /javascripts
   /stylesheets
      style.css
      style.styl
/routes
   index.js
   users.js
/views
   error.pug
   index.pug
   layout.pug

app.js 文件是 Express 应用程序的核心。它包括对必要库的引用:

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
注意

尽管本书遵循的惯例是在定义变量时使用 constlet,但在编写时,Express 生成器使用 var

还使用以下行创建 Express 应用程序:

var app = express():

接下来,通过定义 viewsview engine 变量,将 Pug 设置为视图引擎:

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

接下来通过 app.use() 加载 middleware 调用。中间件是位于原始请求和路由之间的功能,用于处理特定类型的请求。中间件的规则是如果不给定路径作为第一个参数,则默认路径为 /,这意味着中间件函数加载了默认路径。在以下生成的代码中:

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

每个应用程序请求都加载了前几个中间件。其中中间件包括开发日志记录的支持,以及 JSON 和 urlencoded 体的解析器。只有当我们到达 static 条目时,我们看到分配到特定路径的静态文件请求中间件:当请求发送到 public 目录时加载静态文件请求中间件。

接下来处理路由:

app.use('/', indexRouter);
app.use('/users', usersRouter);

顶级 Web 请求 (/) 被定向到 routes 模块,而所有用户请求 (/users) 被路由到 users 模块。

注意

了解更多关于 Express 中的路由的信息,请参阅 “Routing”。

接下来是错误处理。首先处理的是当请求到一个不存在的 Web 资源时的 404 错误处理:

app.use(function(req, res, next) {
  next(createError(404));
});

接下来是服务器错误处理,适用于生产和开发环境:

app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

生成文件的最后一行是 module.exportsapp

module.exports = app;

routes 子目录中,默认路由包含在 routes/index.js 文件中:

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index', { title: 'Express' });
});

module.exports = router;

文件中发生的情况是 Express 路由器用于将任何 HTTP GET 请求路由到 / 的回调函数中,其中请求响应接收为特定资源页面渲染的视图。这与 routes/users.js 文件中发生的情况形成对比,后者响应接收的是文本消息而不是视图:

var express = require('express');
var router = express.Router();

/* GET users listing. */
router.get('/', function(req, res, next) {
  res.send('respond with a resource');
});

module.exports = router;

在第一个请求中视图渲染会发生什么?在 views 子目录中有三个 Pug 文件:一个用于错误处理,一个定义页面布局,还有一个 index.pug,用于渲染页面。index.pug 文件包含:

extends layout

block content
  h1= title
  p Welcome to #{title}

它扩展了 layout.pug 文件,其中包含:

doctype html
html
  head
    title= title
    link(rel='stylesheet', href='/stylesheets/style.css')
  body
    block content

layout.pug 文件定义了页面的整体结构,包括对自动生成的 CSS 文件的引用。block content 设置定义了内容的位置。内容的格式在 index.js 中定义,与同名的 block content 设置相对应。

注意

Pug 模板引擎(以前称为 Jade)由 Express 推广,并提供了一种极简的模板化方式,使用空白符代替传统的 HTML 样式标签。这种方法可能不适合所有人,而 Pug 的替代方案(Handlebars、Hogan.js 和 EJS)都提供了更接近 HTML 的语法。

这两个 Pug 文件定义了一个基本的网页,其中包含一个分配了标题变量的 h1 元素,以及包含欢迎消息的段落。图 21-1 显示了默认页面。

jsc3 2101

图 21-1. Express 生成的网页

图 21-1 显示了页面并不特别吸引人,但它确实展示了这些组件如何协同工作:应用程序路由将请求路由到适当的路由模块,该模块将响应定向到适当的渲染视图,并使用传递给它的数据生成网页。如果您进行以下 Web 请求:

http://yourdomain.com:3000/users

您将看到纯文本消息,而不是渲染后的视图。

默认情况下,Express 设置为运行在 开发模式 下。要将应用程序切换到 生产模式,您需要设置一个 环境变量 NODE-ENV 为 “production”。在 Linux 或 Unix 环境中,可以使用以下方式:

$ export NODE_ENV=production

路由

问题

您希望根据请求将用户路由到应用程序中的不同资源。

解决方案

在 Express 中使用路由,根据请求路径和参数发送特定资源:

// respond with different route paths
app.get('/', (req, res) => res.send('Hello World'));
app.get('/users', (req, res) => res.send('Hello users'));

// parameters
app.get('/users/:userId', (req, res) => {
  res.send(`Hello user ${req.params.userId}`);
});

讨论

在 Express 中,当用户发出 HTTP 请求时,我们可以向其返回响应。在上面的示例中,我使用了 get 请求,但 Express 支持多种额外的方法。其中最常见的方法包括:

  • app.get: 请求数据

  • app.post: 发送数据

  • app.put: 发送或更新数据

  • app.delete: 删除数据

app.post('/new', (req, res) => {
  res.send('POST request to the `new` route');
});

通常情况下,我们可能希望对特定路由启用多个 HTTP 方法。我们可以通过将它们链接在一起来实现这一目标:

app
  .route('/record')
  .get((req, res) => {
    res.send('Get a record');
  })
  .post((req, res) => {
    res.send('Add a record');
  })
  .put((req, res) => {
    res.send('Update a record');
  });

请求通常具有具体值的参数,我们将在应用程序中使用这些值。我们可以在 URL 中使用冒号 (:) 指定这些值:

app.get('/users/:userId', (req, res) => {
  res.send(`Hello user ${req.params.userId}`);
});

在上面的示例中,当用户访问 /users/adam123 这个 URL 时,浏览器将发送 Hello user adam123 的响应。虽然这是一个简单的示例,但我们也可以利用 URL 参数从数据库中检索数据,并将信息传递给模板。

我们还能够为请求参数指定格式。在以下示例中,我使用正则表达式将 noteId 参数限制为六位整数:

app.get('^/users/:userId/notes/:noteId([0-9]{6})', (req, res) => {
  res.send(`This is note ${req.params.noteId}`);
});

我们还可以使用正则表达式来定义整个路由:

app.get(/.*day$/, (req, res) => {
  res.send(`Every day feels like ${req.path}`);
});

上述示例将路由任何以day结尾的请求。例如,在本地开发中,请求http://localhost:3000/Sunday将在页面上打印出“Every day feels like Sunday”。

使用 OAuth 进行工作

问题

你需要在 Node 应用程序中访问第三方 API(如 GitHub、Facebook 或 Twitter),但它需要授权。具体而言,它需要 OAuth 授权。

解决方案

需要在你的应用程序中集成一个 OAuth 客户端。你还需要满足资源提供者所要求的 OAuth 需求。

查看详细讨论。

讨论

OAuth 是一个授权框架,与大多数流行的社交媒体和云内容应用程序一起使用。如果你曾经访问过一个网站,并被要求授权访问来自第三方服务的数据,比如 GitHub,那么你已经参与了 OAuth 授权流程

OAuth 有两个版本,1.0 和 2.0,它们彼此不兼容。OAuth 1.0 基于 Flickr 和 Google 开发的专有 API,主要集中在网页上,并未很好地跨越 Web、移动和服务应用程序之间的障碍。在想要在手机应用程序中访问资源时,应用程序需要用户在移动浏览器中登录应用程序,然后将访问令牌复制到应用程序中。对 OAuth 1.0 的其他批评是,授权服务器必须与资源服务器相同,这在涉及 Twitter、Facebook 和 Amazon 等服务提供商时无法扩展。

OAuth 2.0 提供了一个更简单的授权流程,并为不同情况提供了不同类型的授权(不同流程)。尽管如此,有人会说,这是以安全为代价的,因为它对加密哈希令牌和请求字符串没有同样的要求。

大多数开发者不必创建 OAuth 2.0 服务器,这超出了本书甚至本节的范围。但是,应用程序通常会集成一个 OAuth 客户端(1.0 或 2.0)来使用某项服务,因此我将介绍不同类型的 OAuth 使用。首先,让我们讨论授权与认证之间的区别。

授权并非认证

授权意味着:“我授权此应用程序访问我在你服务器上的资源。”认证是验证你是否确实是拥有此账户并控制这些资源的人。例如,如果我想在报纸的在线站点上发表评论,它可能会要求我通过某些服务登录。如果我选择使用我的 Facebook 账户作为登录方式,新闻网站很可能会要求一些来自 Facebook 的数据。

首先,新闻网站验证我是合法的 Facebook 用户,拥有已建立的 Facebook 账户。换句话说,我不是只是随便进来匿名评论的任何人。其次,新闻网站希望在为评论提供特权的同时从我这里获取一些东西:它将要求获取有关我的数据。也许它会请求允许代表我发布(如果我将我的评论同时发布到 Facebook 和新闻网站)。这既是身份验证又是授权请求。

如果我还没有登录 Facebook,我将不得不登录。Facebook 使用我正确的用户名和密码来验证,确认我拥有所讨论的 Facebook 账户。登录后,Facebook 会询问我是否同意允许新闻网站访问它想要的资源。如果我同意(因为我非常想评论某个特定的故事),Facebook 会授予新闻网站授权,此时从报纸到我的 Facebook 账户之间就建立了持久连接(你可以在你的 Facebook 设置中看到)。我可以发表我的评论,以及在其他故事中发表评论,直到我注销或撤销 Facebook 授权。

当然,这并不意味着 Facebook 或新闻网站实际上在验证我的身份。在这种情况下,身份验证是指确认我是 Facebook 账户的所有者。只有在社交媒体环境(如 Twitter 为名人创建的认证账户)中,才会涉及真正的身份验证。

我们的开发任务因为处理授权的软件通常也是验证个人身份的软件而变得更简单,所以我们不需要处理两种不同的 JavaScript 库/模块/系统。在 Node 应用中,有几个优秀的 OAuth(1.0 和 2.0)模块可供使用。其中最流行的之一是Passport,并且还有为 Passport 系统专门创建的各种授权服务扩展。然而,也有一些非常简单的 OAuth 客户端,专门为各种服务提供基础授权访问,以及一些为单一服务创建的模块。

注:

Passport.js 的内容可在“使用 Passport.js 进行 OAuth 2 用户身份验证”中找到。您也可以在其网站上了解更多关于 Passport 及其支持不同服务器的各种策略

现在,进入技术方面。

客户端凭据授权

如今几乎没有网页资源提供可以在没有某种授权凭证的情况下访问的 API。这意味着必须要向最终用户发出一个往返指令——要求他们在应用程序可以访问数据之前授权访问其服务账户。问题在于,有时你只需要简单的只读访问权限,而不需要更新权限,也不需要前端登录界面,并且也不需要特定用户进行授权授予。

OAuth 2.0 考虑了使用客户端凭据授予进行特定类型的授权流程。这种简化授权的图示显示在图 21-2 中。

jsc3 2102

图 21-2. 客户端凭据授权流程

Twitter 提供了所谓的应用程序授权,基于 OAuth 2.0 的客户端凭据授予。我们可以使用这种授权类型来访问 Twitter 的搜索 API。

在下面的示例中,我使用了 Node 模块oauth来实现授权。这是授权模块中最基本的一种,支持 OAuth 1.0 和 OAuth 2.0 的授权流程:

const OAuth = require('oauth');
const fetch = require('node-fetch');
const { promisify } = require('util');

// read Twitter keys from a .env file
require('dotenv').config();

// Twitter's search API endpoint and the query we'll be searching
const endpointUrl = 'https://api.twitter.com/2/tweets/search/recent';
const query = 'javascript';

async function getTweets() {
  // consumer key and secret passed in from environment variables
  const oauth2 = new OAuth.OAuth2(
    process.env.TWITTER_CONSUMER_KEY,
    process.env.TWITTER_CONSUMER_SECRET,
    'https://api.twitter.com/',
    null,
    'oauth2/token',
    null
  );

  // retrieve the credentials from Twitter
  const getOAuthAccessToken = promisify(
    oauth2.getOAuthAccessToken.bind(oauth2)
  );
  const token = await getOAuthAccessToken('', {
    grant_type: 'client_credentials'
  });

  // make the request for data with the retrieved token
  const res = await fetch(`${endpointUrl}?query=${query}`, {
    headers: {
      authorization: `Bearer ${token}`
    }
  });

  const json = await res.json();
  return json;
}

(async () => {
  try {
    // Make request
    const response = await getTweets();
    console.log(response);
  } catch (e) {
    console.log(e);
    process.exit(-1);
  }
  process.exit();
})();

要使用 Twitter 授权 API,客户端应用程序必须在 Twitter 注册其应用程序。Twitter 提供了消费者密钥消费者密钥

使用oauth模块,创建了一个新的 OAuth2 对象,传入:

  • 消费者密钥

  • 消费者密钥

  • API 基本 URI(API URI 减去查询字符串)

  • 空值表示 OAuth 使用默认的/oauth/authorize

  • 访问令牌路径

  • 空,因为我们不使用任何自定义标头

oauth模块使用这些数据创建一个 POST 请求到 Twitter,同时传递消费者密钥和密钥,还为请求提供了范围。Twitter 的文档提供了一个获取访问令牌的示例 POST 请求(为了可读性插入了换行符):

POST /oauth2/token HTTP/1.1
Host: api.twitter.com
User-Agent: My Twitter App v1.0.23
Authorization: Basic eHZ6MWV2RlM0d0VFUFRHRUZQSEJvZzpMOHFxOVBaeVJn
                NmllS0dFS2hab2xHQzB2SldMdzhpRUo4OERSZHlPZw==
                Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Content-Length: 29
Accept-Encoding: gzip

grant_type=client_credentials

响应包括访问令牌(再次为了可读性插入换行符):

HTTP/1.1 200 OK
Status: 200 OK
Content-Type: application/json; charset=utf-8
...
Content-Encoding: gzip
Content-Length: 140

{"token_type":"bearer","access_token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
%2FAAAAAAAAAAAAAAAAAAAA%3DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}

任何 API 请求都必须使用访问令牌。没有进一步的授权步骤,因此流程非常简单。此外,由于授权是在应用程序级别进行的,不需要个人的授权,使得对用户的干扰较少。

注意

Twitter 提供了出色的文档。建议阅读“仅应用程序身份验证概述”

使用 OAuth 1.0 进行读/写授权

仅应用程序身份验证非常适合访问只读数据,但如果您想要访问用户的特定数据,甚至对其数据进行更改怎么办?那么您将需要完整的 OAuth 授权。在本节中,我们再次使用 Twitter 进行演示,因为它使用 OAuth 1.0 授权。在下一个示例中,我们将介绍 OAuth 2.0。

注意

我称之为 OAuth 1.0,但 Twitter 的服务基于OAuth Core 1.0 修订版 A。不过,说 OAuth 1.0 更简单。

OAuth 1.0 需要数字签名。生成此数字签名的步骤在 Twitter 的图 21-3 中有图形表示。

  1. 收集 HTTP 方法和基本 URI,去除任何查询字符串。

  2. 收集参数,包括消费者密钥、请求数据、随机数、签名方法等等。

  3. 创建签名基础字符串,其中包含我们收集的数据,以一种精确的方式形成字符串,并适当编码。

  4. 创建签名密钥,这是消费者密钥和 OAuth 令牌密钥的组合,再次以精确的方式组合起来。

  5. 将签名基本字符串和签名密钥传递给 HMAC-SHA1 散列算法,返回一个需要进一步编码的二进制字符串。

jsc3 2103

Figure 21-3. OAuth 1.0 授权流程

每次请求都必须遵循这个流程。幸运的是,我们有模块和库来完成所有这些令人昏昏欲睡的工作。我不知道你怎么看,但如果我必须这样做,我对将 Twitter 数据和服务整合到我的应用中的兴趣很快就会减退。

我们的朋友 oauth 提供了底层的 OAuth 1.0 支持,但这次我们不必直接编写代码。另一个模块 node-twitter-api 已经包装了所有 OAuth 的部分。我们只需要创建一个新的 node-twitter-api 对象,传入我们的消费者密钥和密钥,以及资源服务所需的回调/重定向 URL,作为授权过程的一部分。处理该 URL 中的 request 对象会为我们提供访问令牌和密钥,以便访问 API。每次我们发起请求时,都会传入访问令牌和密钥。

twitter-node-api 模块是 REST API 的一个薄包装:为了发起请求,我们从 API 推断功能是什么。如果我们有兴趣发布状态更新,REST API 的端点是:

https://api.twitter.com/1.1/statuses/update.json

twitter-node-api 对象实例函数是 statuses(),第一个参数是动词 update

 twitter.statuses('update', {
        "status": "Hi from Shelley's Toy Box. (Ignore--developing Node app)"
        }, atoken, atokensec, function(err, data, response) {...});

twitter.statuses(
  'update',
  {
    status: 'Ignore learning OAuth with Node'
  },
  tokenValues.atoken,
  tokenValues.atokensec,
  (err, data) => { ... });

回调函数的参数包括任何可能的错误、请求的数据(如果有的话)以及原始响应。

完整的示例显示在 Example 21-1 中。它使用 Express 作为服务器,并为用户提供了一个基本的网页,然后使用另一个模块。

示例 21-1. 使用 OAuth 1.0 完全授权的 Twitter 应用
const express = require('express');
const TwitterAPI = require('node-twitter-api');

require('dotenv').config();

const port = process.env.PORT || '8080';

// keys and callback URL are configured in the Twitter Dev Center
const twitter = new TwitterAPI({
  consumerKey: process.env.TWITTER_CONSUMER_KEY,
  consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
  callback: 'http://127.0.0.1:8080/oauth/callback'
});

// object for storing retrieved token values
const tokenValues = {};

// twitter OAuth API URL
const twitterAPI = 'https://api.twitter.com/oauth/authenticate';

// simple HTML template
const menu =
  '<a href="/post/status/">Say hello</a><br />' +
  '<a href="/get/account/">Account Settings<br />';

// Create a new Express application.
const app = express();

// request Twitter permissions when the / route is visited
app.get('/', (req, res) => {
  twitter.getRequestToken((error, requestToken, requestTokenSecret) => {
    if (error) {
      console.log(`Error getting OAuth request token : ${error}`);
      res.writeHead(200);
      res.end(`Error getting authorization${error}`);
    } else {
      tokenValues.token = requestToken;
      tokenValues.tokensec = requestTokenSecret;
      res.writeHead(302, {
        Location: `${twitterAPI}?oauth_token=${requestToken}`
      });
      res.end();
    }
  });
});

// callback url as specified in the Twitter Developer Center
app.get('/oauth/callback', (req, res) => {
  twitter.getAccessToken(
    tokenValues.token,
    tokenValues.tokensec,
    req.query.oauth_verifier,
    (err, accessToken, accessTokenSecret) => {
      res.writeHead(200);
      if (err) {
        res.end(`problems getting authorization with Twitter${err}`);
      } else {
        tokenValues.atoken = accessToken;
        tokenValues.atokensec = accessTokenSecret;
        res.end(menu);
      }
    }
  );
});

// post a status update from an authenticated and authorized users
app.get('/post/status/', (req, res) => {
  twitter.statuses(
    'update',
    {
      status: 'Ignore teaching OAuth with Node'
    },
    tokenValues.atoken,
    tokenValues.atokensec,
    (err, data) => {
      res.writeHead(200);
      if (err) {
        res.end(`problems posting ${JSON.stringify(err)}`);
      } else {
        res.end(`posting status: ${JSON.stringify(data)}<br />${menu}`);
      }
    }
  );
});

// get account details for an authenticated and authorized user
app.get('/get/account/', (req, res) => {
  twitter.account(
    'settings',
    {},
    tokenValues.atoken,
    tokenValues.atokensec,
    (err, data) => {
      res.writeHead(200);
      if (err) {
        res.end(`problems getting account ${JSON.stringify(err)}`);
      } else {
        res.end(`<p>${JSON.stringify(data)}</p>${menu}`);
      }
    }
  );
});

app.listen(port, () => console.log(`Listening on port ${port}!`));

应用中感兴趣的路由包括:

  • /: 触发重定向到 Twitter 进行授权的页面

  • /auth: 注册在应用程序中的回调或重定向 URL,并在请求中传递

  • /post/status/: 向 Twitter 账户发布状态

  • /get/account/: 获取个人账户信息

在每种情况下,都使用适当的 node-twitter-api 函数:

  • /: 使用 getRequestToken() 获取请求令牌和请求令牌密钥

  • /auth/: 获取 API 访问令牌和令牌密钥,将它们缓存到本地,显示菜单

  • /post/status/: status() 的第一个参数为 update,状态、访问令牌和密钥,以及回调函数

  • /get/account/: account() 的第一个参数为 settings,一个空对象,因为请求不需要数据,还有访问令牌、密钥和回调

弹出的 Twitter 授权页面显示在 Figure 21-4 中,显示本人账户信息的网页显示在 Figure 21-5 中。

注意

虽然它已经不再积极维护,但您可以在其GitHub 仓库页面上阅读有关 node-twitter-api 模块的更多信息。其他库更积极地维护并提供相同类型的功能,但我发现 node-twitter-api 提供了最简单的功能示例,用于演示目的。

jsc3 2104

图 21-4. 从菜谱应用程序重定向到 Twitter 授权页面

jsc3 2105

图 21-5. 在应用程序中显示 Twitter 用户账户数据

使用 Passport.js 进行 OAuth 2 用户认证

问题

您希望通过第三方服务在应用程序中对用户进行认证。

解决方案

使用 Passport.js 库与所选认证提供程序的适当策略配对。在本例中,我将使用 GitHub 策略,但对于包括 Facebook、Google 和 Twitter 在内的任何 OAuth 2 提供程序,工作流程都将是相同的。

您可以利用 GitHub 策略,首先访问 GitHub 的网站并注册新的 OAuth 应用程序。一旦注册了应用程序,就可以将 Passport.js OAuth 代码集成到应用程序中。

要开始,请配置 Passport 策略,其中包括 GitHub 提供的客户端 ID 和客户端密钥,以及您指定的回调 URL:

const express = require('express');
const passport = require('passport');
const { Strategy } = require('passport-github');

passport.use(
  new Strategy(
    {
      clientID: GITHUB_CLIENT_ID,
      clientSecret: GITHUB_CLIENT_SECRET,
      callbackURL: 'login/github/callback'
    },
    (accessToken, refreshToken, profile, cb) => {
      return cb(null, profile);
    }
  )
);

为了在 HTTP 请求之间恢复认证状态,Passport 需要对用户进行序列化和反序列化:

passport.serializeUser((user, cb) => {
  cb(null, user);
});

passport.deserializeUser((obj, cb) => {
  cb(null, obj);
});

要跨浏览器会话保留用户登录状态,请使用 express-session 中间件:

app.use(
  require('express-session')({
    secret: SESSION_SECRET,
    resave: true,
    saveUninitialized: true
  })
);

app.use(passport.session());

您可以然后使用 passport.authenticate 对请求进行认证:

app.use(passport.initialize());

app.get('/login/github', passport.authenticate('github'));

app.get(
  '/login/github/callback',
  passport.authenticate('github', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/');
  }
);

并引用来自请求的 user 对象:

app.get('/', (req, res) => {
  res.render('home', { user: req.user });
});

讨论

OAuth 是用于用户认证的开放标准。它允许我们通过第三方应用程序对用户进行认证。当允许用户轻松创建帐户并登录到您的应用程序时,以及用于从第三方来源认证使用数据时,这将非常有用。

OAuth 请求遵循特定的流程:

  1. 您的应用程序向第三方服务发出授权请求。

  2. 用户批准该请求。

  3. 服务将用户重定向回您的应用程序,并携带授权码。

  4. 应用程序使用授权码向第三方服务发出请求。

  5. 服务响应访问令牌(及可选的刷新令牌)。

  6. 应用程序使用访问令牌向服务发送请求。

  7. 服务响应受保护的资源(在我们的案例中是用户账户信息)。

在 Express.js 应用程序中,结合 Passport.js 策略使用 Passport.js 简化了此流程。在本例中,我们将构建一个小型 Express 应用程序,该应用程序通过 GitHub 进行认证,并跨会话保持用户登录状态。

一旦我们已经向服务提供商注册了我们的应用程序,就可以通过安装适当的依赖项开始开发:

# install general application dependencies
npm install express pug dotenv
# install passport dependencies
npm install passport passport-github
# install persistent user session dependencies
npm install connect-ensure-login express-session

为了存储我们的 OAuth 客户端 ID、客户端密钥和会话密钥值,我们将使用一个 .env 文件。或者,你可以使用一个 JavaScript 文件(比如 config.js 文件)。重要的是不要将此文件提交到公共源代码控制中,并建议将其添加到你的 .gitignore 文件中。在 .env 中:

GITHUB_CLIENT_ID=<Your client ID>
GITHUB_CLIENT_SECRET=<Your client secret>
SESSION_SECRET=<A session secret - this can be any value you decide>

接下来,我们将在 index.js 中设置我们的 Express 应用程序与 Passport.js 集成。

const express = require('express');
const passport = require('passport');
const { Strategy } = require('passport-github');

require('dotenv').config();

const port = process.env.PORT || '3000';

// Configure the Passport strategy
passport.use(
  new Strategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
      callbackURL: `http://localhost:${port}/login/github/callback`
    },
    (accessToken, refreshToken, profile, cb) => {
      return cb(null, profile);
    }
  )
);

// Serialize and deserialize the user
passport.serializeUser((user, cb) => {
  cb(null, user);
});

passport.deserializeUser((obj, cb) => {
  cb(null, obj);
});

// create the Express application
const app = express();
app.set('views', `${__dirname}/views`);
app.set('view engine', 'pug');

// use the Express session middleware for preserving user session
app.use(
  require('express-session')({
    secret: process.env.SESSION_SECRET,
    resave: true,
    saveUninitialized: true
  })
);

// Initialize passport and restore the authentication state from the session
app.use(passport.initialize());
app.use(passport.session());

// listen on port 3000 or the PORT set as an environment variable
app.listen(port, () => console.log(`Listening on port ${port}!`));

然后,你可以构建你的视图模板,这些模板可以访问用户数据。

views/home.pug 中:

if !user
  p Welcome! Please
    a(href='/login/github') Login with GitHub
else
  h1 Hello #{user.username}!
  p View your
    a(href='/profile') profile

views/login.pug 中:

h1 Login
a(href='/login/github') Login with GitHub

views/profile.pug 中:

h1 Profile
ul
  li ID: #{user.id}
  li Name: #{user.username}
  if user.emails
    li Email: #{user.emails[0].value}

最后,在 index.js 文件中设置我们的路由:

app.get('/', (req, res) => {
  res.render('home', { user: req.user });
});

app.get('/login', (req, res) => {
  res.render('login');
});

app.get('/login/github', passport.authenticate('github'));

app.get(
  '/login/github/callback',
  passport.authenticate('github', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/');
  }
);

app.get(
  '/profile',
  require('connect-ensure-login').ensureLoggedIn(),
  (req, res) => {
    res.render('profile', { user: req.user });
  }
);

这个示例被设计得与 Express 4.x Facebook 示例 高度匹配,提供了与 Express 和 Facebook 认证相关的代码详细文档。你可以查看数百种额外的 Passport.js 策略

提供格式化数据

问题

你希望返回格式化的数据(如 XML)给浏览器,而不是提供一个网页或发送纯文本。

解决方案

使用 Node 模块来帮助格式化数据。例如,如果你想返回 XML,可以使用一个模块来创建格式化的数据:

const builder = require('xmlbuilder');

const xml = builder
  .create('resources')
  .ele('resource')
  .ele('title', 'Ecma-262 Edition 10')
  .up()
  .ele('url', 'https://www.ecma-international.org/ecma-262/10.0/index.html')
  .up()
  .end({ pretty: true });

然后创建适当的头部信息,与数据一起返回到浏览器:

app.get('/', (req, res) => {
  res.setHeader('Content-Type', 'application/xml');
  res.end(xml.toString(), 'utf8');
});

讨论

Web 服务器经常提供静态或服务器端生成的资源,但同样频繁的是,返回给浏览器的是格式化的数据,在网页显示之前进行处理。

生成和返回格式化数据有两个关键要素。第一个是利用任何 Node 库来简化数据生成的过程,第二个是确保发送数据的头部信息与数据一致。

在解决方案中,使用了 xmlbuilder 模块来辅助我们创建正确的 XML。这不是 Node 默认安装的模块之一,所以我们需要使用 Node 包管理器 npm 来安装它:

npm install xmlbuilder

然后,创建一个新的 XML 文档,一个根元素,然后每个资源元素,正如解决方案中所示。的确,我们可以自己构建 XML 字符串,但这很麻烦。而且容易出现难以发现的错误。Node 最好的一点之一是有大量的模块可以做我们能想到的几乎任何事情。我们不仅不必亲自编写代码,大多数模块都经过了彻底测试并得到了积极维护。

一旦格式化数据准备好返回,创建对应的头部信息。在解决方案中,因为文档是 XML,所以在将数据返回为字符串之前,设置头部内容类型为 application/xml

构建一个 RESTful API

问题

你希望使用 Node.js 构建一个 REST API。

解决方案

使用 Express 和 app.getapp.postapp.putapp.delete 方法:

const express = require('express');

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  return res.send('Received a GET HTTP method');
});
app.post('/', (req, res) => {
  return res.send('Received a POST HTTP method');
});
app.put('/', (req, res) => {
  return res.send('Received a PUT HTTP method');
});
app.delete('/', (req, res) => {
  return res.send('Received a DELETE HTTP method');
});
app.listen(port, () => console.log(`Listening on port ${port}!`));

讨论

REST 代表“表述性状态转移”,是构建 API 的最常见的架构方法。REST 允许我们使用标准的 HTTP 方法(GETPOSTPUTDELETE)通过 HTTP 与远程数据源交互。我们可以利用 Express 路由方法来接受这些请求。

在下面的示例中,我将创建几个作为 API 端点的路由。每个端点将响应 HTTP 请求:

/todos

将接受一个 get 请求以获取 todo 列表,以及一个 post 请求以创建新的 todo。

/todos/:todoId

将接受一个 get 请求,返回特定的 todo,以及一个 put 请求,允许用户更新 todo 的内容或完成状态,以及一个 delete 请求,删除特定的 todo。

有了这些定义的路由,我们可以开发一个能够适当响应这些请求的 REST API。

const express = require('express');

const port = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// an array of data
let todos = [
  {
    id: '1',
    text: 'Order pizza',
    completed: true
  },
  {
    id: '2',
    text: 'Pick up pizza',
    completed: false
  }
];

// get the list of todos
app.get('/todos', (req, res) => {
  return res.send({ data: { todos } });
});

// get an individual todo
app.get('/todos/:todoId', (req, res) => {
  const foundTodo = todos.find(todo => todo.id === req.params.todoId);
  return res.send({ data: foundTodo });
});

// create a new todo
app.post('/todos', (req, res) => {
  const todo = {
    id: String(todos.length + 1),
    text: req.body.text,
    completed: false
  };

  todos.push(todo);
  return res.send({ data: todo });
});

// update a todo
app.put('/todos/:todoId', (req, res) => {
  const todoIndex = todos.findIndex(todo => todo.id === req.params.todoId);
  const todo = {
    id: req.params.todoId,
    text: req.body.text || todos[todoIndex].text,
    completed: req.body.completed || todos[todoIndex].completed
  };

  todos[todoIndex] = todo;
  return res.send({ data: todo });
});

// delete a todo
app.delete('/todos/:todoId', (req, res) => {
  const deletedTodo = todos.find(todo => todo.id === req.params.todoId);
  todos = todos.filter(todo => todo.id !== req.params.todoId);
  return res.send({ data: deletedTodo });
});

// listen on port 3000 or the PORT set as an environment variable
app.listen(port, () => console.log(`Listening on port ${port}!`));

从终端可以使用 curl 测试我们的响应:

# get the list of todos
curl http://localhost:3000/todos

# get an individual todo
curl http://localhost:3000/todos/1

# create a new todo
curl -X POST -H "Content-Type:application/json" /
  http://localhost:3000/todos -d '{"text":"Eat pizza"}'

# update a todo
curl -X PUT -H "Content-Type:application/json" /
  http://localhost:3000/todos/2 -d '{"completed": true }

# delete a todo
curl -X DELETE http://localhost:3000/todos/3

使用 curl 进行手动测试很快会变得乏味。对于 API 开发,您可能还想使用 REST 客户端 UI,例如 InsomniaPostman(参见 Figure 21-6)。

Insomnia REST 客户端的截图

图 21-6. Insomnia REST 客户端中的 GET 请求

在上面的示例中,我正在使用内存数据存储。构建 API 时,您很可能希望连接到数据库。为此,您可以使用诸如 Sequelize(用于 SQL 数据库)、Mongoose(用于 MongoDB)或在线数据存储(如 Firebase)等库。

构建 GraphQL API

问题

您想要构建一个 GraphQL API 服务器应用程序或向现有的 Express 应用程序添加 GraphQL 端点。

解决方案

使用 Apollo Server 包含 GraphQL 类型定义、GraphQL 解析器和 GraphQL Playground:

const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');

const port = process.env.PORT || 3000;
const app = express();

const typeDefs = gql`
 type Query {
 hello: String
 }
`;

const resolvers = {
  Query: {
    hello: () => 'Hello world!'
  }
};
const server = new ApolloServer({ typeDefs, resolvers });
server.applyMiddleware({ app, path: '/' });
app.listen({ port }, () => console.log(`Listening on port ${port}!`));

Apollo Server 提供了对 GraphQL Playground 的访问(参见 Figure 21-7),这使我们可以在开发过程中轻松与 API 进行交互(如果需要,在生产环境中也可以)。

GraphQL Playground 的截图

图 21-7. GraphQL Playground 中的 GraphQL 查询

GraphQL Playground 还根据您提供的类型定义自动生成 API 的文档(参见 Figure 21-8)。

生成的 GraphQL 文档截图

图 21-8. GraphQL Playground 中生成的文档

讨论

GraphQL 是用于 API 的开放源码查询语言。它的开发目标是提供数据的单一端点,允许应用程序请求所需的特定数据。Apollo Server 可以作为独立包使用,也可以集成为流行的 Node.js 服务器应用程序库的中间件,如 Express、Hapi、Fastify 和 Koa。

在 GraphQL 中,类型定义模式是我们数据和交互的书面表示。通过要求模式,GraphQL 强制执行 API 的严格计划。这是因为您的 API 只能返回在模式中定义的数据并执行其中定义的交互。GraphQL 模式的基本组件是对象类型。GraphQL 包含五种内置标量类型:

  • 字符串:采用 UTF-8 字符编码的字符串

  • 布尔值:一个真或假的值

  • 整数:一个 32 位整数

  • 浮点数:一个浮点数值

  • ID:一个唯一标识符

一旦模式编写完成,我们就为 API 提供一系列解析器。这些解析器是指定查询中应如何返回数据或在数据变异中进行更改的函数。

在先前的示例中,我们使用了 apollo-server-express 包,它应与 expressgql 包一起安装:

$ npm install express apollo-server-express gql

要创建 CRUD 应用程序,我们可以定义我们的 GraphQL 类型定义和适当的解析器。下面的示例模仿了 “构建 RESTful API” 中找到的示例:

const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');

const port = process.env.PORT || 3000;
const app = express();

// an array of data
let todos = [
  {
    id: '1',
    text: 'Order pizza',
    completed: true
  },
  {
    id: '2',
    text: 'Pick up pizza',
    completed: false
  }
];

// GraphQL Type Definitions
const typeDefs = gql`
 type Query {
 todos: [Todo!]!
 todo(id: ID!): Todo!
 }

 type Mutation {
 newTodo(text: String!): Todo!
 updateTodo(id: ID!, text: String, completed: Boolean): Todo!
 deleteTodo(id: ID!): Todo!
 }

 type Todo {
 id: ID!
 text: String!
 completed: Boolean
 }
`;

// GraphQL Resolvers
const resolvers = {
  Query: {
    todos: () => todos,
    todo: (parent, args) => {
      return todos.find(todo => todo.id === args.id);
    }
  },
  Mutation: {
    newTodo: (parent, args) => {
      const todo = {
        id: String(todos.length + 1),
        text: args.text,
        completed: false
      };

      todos.push(todo);
      return todo;
    },

    updateTodo: (parent, args) => {
      const todoIndex = todos.findIndex(todo => todo.id === args.id);
      const todo = {
        id: args.id,
        text: args.text || todos[todoIndex].text,
        completed: args.completed || todos[todoIndex].completed
      };

      todos[todoIndex] = todo;
      return todo;
    },
    deleteTodo: (parent, args) => {
      const deletedTodo = todos.find(todo => todo.id === args.id);
      todos = todos.filter(todo => todo.id !== args.id);
      return deletedTodo;
    }
  }
};

// Apollo + Express server setup
const server = new ApolloServer({ typeDefs, resolvers });
server.applyMiddleware({ app, path: '/' });
app.listen({ port }, () => console.log(`Listening on port ${port}!`));

在上面的示例中,我正在使用内存数据存储。构建 API 时,您很可能希望连接到数据库。为此,您可以使用像 Sequelize(用于 SQL 数据库)、Mongoose(用于 MongoDB)或在线数据存储(如 Firebase)这样的库。

定义的查询直接从 API 返回数据,而变异允许我们对数据执行更改,例如创建新项目、更新项目或删除项目。

posted @ 2025-11-15 13:04  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报