JavaScript-专家级编程-全-

JavaScript 专家级编程(全)

原文:zh.annas-archive.org/md5/918F303F1357704D1EED66C3323DB7DD

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:前言

关于

本节简要介绍了作者、本书的内容、开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

深入了解 JavaScript,更容易学习其他框架,包括 React、Angular 和相关工具和库。本书旨在帮助您掌握构建现代应用程序所需的核心 JavaScript 概念。

您将首先学习如何在文档对象模型(DOM)中表示 HTML 文档。然后,您将结合对 DOM 和 Node.js 的知识,为实际情况创建一个网络爬虫。随着您阅读更多章节,您将使用 Express 库为 Node.js 创建基于 Node.js 的 RESTful API。您还将了解如何使用模块化设计来实现更好的可重用性,并与多个开发人员在单个项目上进行协作。后面的章节将指导您构建单元测试,以确保程序的核心功能不会随时间而受到影响。本书还将演示构造函数、async/await 和事件如何快速高效地加载您的应用程序。最后,您将获得有关不可变性、纯函数和高阶函数等函数式编程概念的有用见解。

通过本书,您将掌握使用现代 JavaScript 方法解决客户端和服务器端的任何真实世界 JavaScript 开发问题所需的技能。

关于作者

雨果·迪弗朗西斯科(Hugo Di Francesco)是一名软件工程师,他在 JavaScript 方面有丰富的经验。他拥有伦敦大学学院(UCL)的数学计算工程学士学位。他曾在佳能和 Elsevier 等公司使用 JavaScript 创建可扩展和高性能的平台。他目前正在使用 Node.js、React 和 Kubernetes 解决零售运营领域的问题,同时运营着同名的 Code with Hugo 网站。工作之外,他是一名国际击剑运动员,他在全球范围内进行训练和比赛。

高思远(Siyuan Gao)是艺电公司的软件工程师。他拥有普渡大学的计算机科学学士学位。他已经使用 JavaScript 和 Node.js 超过 4 年,主要为高可用性系统构建高效的后端解决方案。他还是 Node.js 核心项目的贡献者,并且已经发布了许多 npm 模块。在业余时间,他喜欢学习视频游戏设计和机器学习。

Vinicius Isola于 1999 年开始使用 Macromedia Flash 和 ActionScript 进行编程。2005 年,他获得了 Java 认证,并专门从事构建 Web 和企业应用程序。JavaScript 和 Web 技术一直在他的许多工作角色和所在公司中发挥作用。在业余时间,他喜欢参与开源项目并指导新开发人员。

菲利普·柯克布赖德(Philip Kirkbride)在蒙特利尔拥有超过 5 年的 JavaScript 经验。他于 2011 年从技术学院毕业,自那时起一直在不同的角色中使用 Web 技术。他曾与 2Klic 合作,这是一家由主要电暖公司 Convectair 承包的物联网公司,用 Z-Wave 技术创建智能加热器。他的角色包括在 Node.js 和 Bash 中编写微服务。他还有机会为开源项目 SteemIt(基于区块链的博客平台)和 DuckDuckGo(基于隐私的搜索引擎)做出一些贡献。

学习目标

通过本书,您将能够:

  • 应用函数式编程的核心概念

  • 构建一个使用 Express.js 库托管 API 的 Node.js 项目

  • 为 Node.js 项目创建单元测试以验证其有效性

  • 使用 Cheerio 库与 Node.js 创建基本网络爬虫

  • 开发一个 React 界面来构建处理流程

  • 使用回调作为将控制权带回的基本方法

受众

如果您想从前端开发人员转变为全栈开发人员,并学习 Node.js 如何用于托管全栈应用程序,那么这本书非常适合您。阅读本书后,您将能够编写更好的 JavaScript 代码,并了解语言中的最新趋势。为了轻松掌握这里解释的概念,您应该了解 JavaScript 的基本语法,并且应该使用过流行的前端库,如 jQuery。您还应该使用过 JavaScript 与 HTML 和 CSS,但不一定是 Node.js。

方法

本书的每一部分都经过明确设计,旨在吸引和激发您,以便您可以在实际环境中保留和应用所学知识,产生最大的影响。您将学习如何应对具有智力挑战的编程问题,这将通过函数式编程和测试驱动开发实践为您准备真实世界的主题。每一章都经过明确设计,以 JavaScript 作为核心语言进行构建。

硬件要求

为了获得最佳体验,我们建议以下硬件配置:

  • 处理器:Intel Core i5 或同等级处理器

  • 内存:4GB RAM

  • 存储:5GB 可用空间

软件要求

我们还建议您提前安装以下软件:

约定

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:

"ES6 的import函数还允许您导入模块的子部分,而不是整个模块。这是 ES6 的import比 Node.js 的require函数更强大的功能。SUSE"

代码块设置如下:

let myString = "hello";
console.log(myString.toUpperCase()); // returns HELLO
console.log(myString.length); // returns 5

安装和设置

在我们可以使用数据做出了不起的事情之前,我们需要准备好最高效的环境。在这个简短的部分中,我们将看到如何做到这一点。

安装 Node.js 和 npm

Node.js 的安装包中包含 npm(Node.js 的默认包管理器)。

在 Windows 上安装 Node.js

  1. nodejs.org/en/download/current/官方安装页面上找到您想要的 Node.js 版本。

  2. 确保选择 Node.js 12(当前版本)。

  3. 确保您为计算机系统安装了正确的架构;即 32 位或 64 位。您可以在操作系统的系统属性窗口中找到这些信息。

  4. 下载安装程序后,只需双击文件,然后按照屏幕上的用户友好提示操作即可。

在 Linux 上安装 Node.js 和 npm

在 Linux 上安装 Node.js,您有几个不错的选择:

curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
# As root
curl -sL https://deb.nodesource.com/setup_12.x | bash -
apt-get install -y nodejs

在 macOS 上安装 Node.js 和 npm

与 Linux 类似,Mac 上安装 Node.js 和 npm 有几种方法。要在 macOS X 上安装 Node.js 和 npm,请执行以下操作:

  1. 按下cmd + Spacebar打开 Mac 的终端,输入terminal并按下Enter

  2. 通过运行xcode-select --install命令行来安装 Xcode。

  3. 安装 Node.js 和 npm 的最简单方法是使用 Homebrew,通过运行ruby -e "$(curl -fsSL (raw.githubusercontent.com/Homebrew/install/master/install)来安装 Homebrew。

  4. 最后一步是安装 Node.js 和 npm。在命令行上运行brew install node

  5. 同样,您也可以通过nodejs.org/en/download/current/提供的安装程序安装 Node.js 和 npm。

安装 Git

安装 git,请前往git-scm.com/downloads,并按照针对您平台的说明进行操作。

其他资源

本书的代码包也托管在 GitHub 上,网址为github.com/TrainingByPackt/Professional-JavaScript。我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/找到。快去看看吧!

第二章:JavaScript,HTML 和 DOM

学习目标

在本章结束时,您将能够:

  • 描述 HTML 文档对象模型(DOM)

  • 使用 Chrome DevTools 源选项卡来探索网页的 DOM

  • 实现 JavaScript 来查询和操作 DOM

  • 使用 Shadow DOM 构建自定义组件

在本章中,我们将学习 DOM 以及如何使用 JavaScript 与其交互和操作。我们还将学习如何使用可重用的自定义组件构建动态应用程序。

介绍

HTML 最初是用于静态文档的标记语言,易于使用,并且可以使用任何文本编辑器编写。在 JavaScript 成为互联网世界的主要角色之后,有必要将 HTML 文档暴露给 JavaScript 运行时。这就是创建 DOM 的时候。DOM 是将 HTML 映射到可以使用 JavaScript 查询和操作的对象树。

在本章中,您将学习 DOM 是什么以及如何使用 JavaScript 与其交互。您将学习如何在文档中查找元素和数据,如何操作元素状态以及如何修改其内容。您还将学习如何创建 DOM 元素并将其附加到页面上。

了解 DOM 及其如何操作后,您将使用一些示例数据构建动态应用程序。最后,您将学习如何创建自定义 HTML 元素以构建可重用组件,使用 Shadow DOM。

HTML 和 DOM

当浏览器加载 HTML 页面时,它会创建代表该页面的树。这棵树基于 DOM 规范。它使用标记来确定每个节点的起始和结束位置。

考虑以下 HTML 代码片段:

<html>
  <head>
    <title>Sample Page</title>
  </head>
  <body>
    <p>This is a paragraph.</p>
    <div>
      <p>This is a paragraph inside a div.</p>
    </div>
    <button>Click me!</button>
  </body>
</html>

浏览器将创建以下节点层次结构:

图 1.1:段落节点包含文本节点

图 1.1:段落节点包含文本节点

一切都变成了节点。文本,元素和注释,一直到树的根部。这棵树用于匹配 CSS 样式并渲染页面。它还被转换为对象,并提供给 JavaScript 运行时使用。

但为什么它被称为 DOM 呢?因为 HTML 最初是设计用来共享文档,而不是设计我们今天拥有的丰富动态应用程序。这意味着每个 HTML DOM 都以一个文档元素开始,所有元素都附加到该元素上。考虑到这一点,前面的 DOM 树示意图实际上变成了以下内容:

图 1.2:所有 DOM 树都有一个文档元素作为根

图 1.2:所有 DOM 树都有一个文档元素作为根

当我说浏览器使 DOM 可用于 JavaScript 运行时时,这意味着如果您在 HTML 页面中编写一些 JavaScript 代码,您可以访问该树并对其进行一些非常有趣的操作。例如,您可以轻松访问文档根元素并访问页面上的所有节点,这就是您将在下一个练习中要做的事情。

练习 1:在文档中迭代节点

在这个练习中,我们将编写 JavaScript 代码来查询 DOM 以查找按钮,并向其添加事件侦听器,以便在用户单击按钮时执行一些代码。事件发生时,我们将查询所有段落元素,计数并存储它们的内容,然后在最后显示一个警报。

此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise01找到。

执行以下步骤完成练习:

  1. 打开您喜欢的文本编辑器,并创建一个名为alert_paragraphs.html的新文件,其中包含上一节中的示例 HTML(可以在 GitHub 上找到:bit.ly/2maW0Sx):
<html>
  <head>
    <title>Sample Page</title>
  </head>
  <body>
    <p>This is a paragraph.</p>
    <div>
      <p>This is a paragraph inside a div.</p>
    </div>
    <button>Click me!</button>
  </body>
</html>
  1. body元素的末尾,添加一个script标签,使最后几行看起来像下面这样:
    </div>
    <button>Click me!</button>
    <script>
    </script>
  </body>
</html>
  1. script标签内,为按钮的点击事件添加一个事件监听器。为此,你需要查询文档对象以找到所有带有button标签的元素,获取第一个(页面上只有一个按钮),然后调用addEventListener
document.getElementsByTagName('button')[0].addEventListener('click', () => {});
  1. 在事件监听器内部,再次查询文档以查找所有段落元素:
const allParagraphs = document.getElementsByTagName('p');
  1. 之后,在事件监听器内创建两个变量,用于存储你找到的段落元素的数量和存储它们的内容:
let allContent = "";
let count = 0;
  1. 迭代所有段落元素,计数它们,并存储它们的内容:
for (let i = 0; i < allParagraphs.length; i++) {  const node = allParagraphs[i];
  count++;
  allContent += `${count} - ${node.textContent}\n`;
}
  1. 循环结束后,显示一个警报,其中包含找到的段落数和它们所有内容的列表:
alert(`Found ${count} paragraphs. Their content:\n${allContent}`);

你可以在这里看到最终的代码应该是什么样子的:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise01/alert_paragraphs.html

在浏览器中打开 HTML 文档并点击按钮,你应该会看到以下警报:

图 1.3:显示页面上段落信息的警报框

图 1.3:显示页面上段落信息的警报框

在这个练习中,我们编写了一些 JavaScript 代码,查询了特定元素的 DOM。我们收集了元素的内容,以在警报框中显示它们。

我们将在本章的后续部分探索其他查询 DOM 和迭代节点的方法。但是从这个练习中,你已经可以看到这是多么强大,并开始想象这开启了哪些可能性。例如,我经常使用它来计数或从互联网上的网页中提取我需要的数据。

开发者工具

现在我们了解了 HTML 源代码和 DOM 之间的关系,我们可以使用一个非常强大的工具来更详细地探索它:浏览器开发者工具。在本书中,我们将探索谷歌 Chrome 的DevTools,但你也可以在所有其他浏览器中轻松找到等效的工具。

我们要做的第一件事是探索我们在上一节中创建的页面。当你在谷歌 Chrome 中打开它时,你可以通过打开Chrome菜单来找到开发者工具。然后选择更多工具开发者工具来打开开发者工具:

图 1.4:在谷歌 Chrome 中访问开发者工具

图 1.4:在谷歌 Chrome 中访问开发者工具

开发者工具将在页面底部打开一个面板:

图 1.5:谷歌 Chrome DevTools 打开时的面板

图 1.5:谷歌 Chrome DevTools 打开时的面板

你可以在顶部看到提供加载页面上发生的不同视角的各种选项卡。在本章中,我们将主要关注三个选项卡:

  • 元素 – 显示浏览器看到的 DOM 树。你可以检查浏览器如何查看你的 HTML,CSS 如何被应用,以及哪些选择器激活了每个样式。你还可以改变节点的状态,模拟特定状态,比如hovervisited

图 1.6:元素选项卡的视图

图 1.6:元素选项卡的视图
  • 控制台 – 在页面的上下文中提供对 JavaScript 运行时的访问。在加载页面后,可以使用控制台来测试简短的代码片段。它还可以用于打印重要的调试信息:

图 1.7:控制台选项卡的视图

图 1.7:控制台选项卡的视图
  • – 显示当前页面加载的所有源代码。这个视图可以用来设置断点和开始调试会话:

图 1.8:源选项卡的视图

选择元素选项卡,你会看到当前文档的 DOM 树:

图 1.9:在 Chrome DevTools 中查看的元素选项卡中的 DOM 树

图 1.9:在 Chrome DevTools 中查看的元素选项卡中的 DOM 树

练习 2:从元素选项卡操作 DOM

为了感受到这个工具有多强大,我们将对练习 1:遍历文档中的节点中的页面进行一些更改。我们将在其中添加一个新段落并删除一个现有的段落。然后,我们将使用样式侧边栏来更改元素的一些样式。

执行以下步骤完成练习:

  1. 首先,右键单击body元素,然后选择编辑为 HTML图 1.10:编辑 HTML 主体元素
图 1.10:编辑 HTML 主体元素
  1. 这将把节点更改为一个可以输入的文本框。在第一个段落下面,添加另一个文本为另一个段落的段落。它应该看起来像下面这样:图 1.11:在 HTML 主体中添加一个新段落
图 1.11:在 HTML 主体中添加一个新段落
  1. 按下Ctrl + Enter(或 Mac 上的Cmd + Enter)保存您的更改。

  2. 再次单击点击我!按钮,您会看到新段落及其内容现在显示在列表中:图 1.12:显示所有段落内容的警报,包括添加到页面中的段落

图 1.12:显示所有段落内容的警报,包括添加到页面中的段落
  1. 您还可以玩弄元素的样式,并在页面上实时看到变化。让我们将第一个段落的背景更改为黑色,颜色更改为白色。首先,通过单击 DOM 树上的它来选择它;它会变成蓝色以表示已选择:图 1.13:在元素选项卡上选择 DOM 元素
图 1.13:在元素选项卡上选择 DOM 元素
  1. 现在,在右侧,您会看到样式选项卡。它包含已应用于元素的样式和一个用于元素样式的空占位符。单击它,您将获得一个输入框。输入background: black,按下Enter,然后输入color: white,再次按下Enter。您会看到随着您的输入,元素会发生变化。最终,它将看起来像下面这样:图 1.14:左侧的样式化段落和右侧的应用样式
图 1.14:左侧的样式化段落和右侧的应用样式
  1. 您还可以通过单击样式选项卡右上角的新规则按钮来创建一个应用于页面的新 CSS 规则:图 1.15:当您单击添加新规则时,它将基于所选元素(在本例中为段落)添加一个新规则
图 1.15:当您单击添加新规则时,它将基于所选元素(在本例中为段落)添加一个新规则
  1. 让我们添加类似的规则来影响所有段落,输入background: green,按下Enter,输入color: yellow,然后按下Enter。现在除了第一个段落外,所有段落都将具有绿色背景和黄色文本。页面现在应该是这样的:

图 1.16:向段落添加规则

图 1.16:向段落添加规则

在这个练习中,您改变了页面的 DOM,并实时看到了变化。您向页面添加了元素,更改了一个元素的样式,然后添加了一个新的 CSS 规则来影响更广泛的元素组。

像这样实时操作 DOM 对于您试图弄清布局并测试一些迭代或操作 DOM 元素的代码的情况非常有用。在我们的情况下,我们可以轻松测试如果我们向页面添加一个新段落元素会发生什么。

练习 3:从源选项卡调试代码

我们之前提到过,您可以从选项卡调试代码。要做到这一点,您只需要设置一个断点,并确保代码通过该点。在这个练习中,我们将在调试我们的代码时探索选项卡。

执行以下步骤完成练习:

  1. 您需要做的第一件事是在“开发者工具”面板中选择“源”选项卡。然后,打开我们目前拥有的一个源文件。您可以通过在左侧面板中点击它来实现这一点:图 1.17:源选项卡显示了如何找到您的源文件
图 1.17:源选项卡显示了如何找到您的源文件
  1. 要在源代码中设置断点,您需要点击行号所在的边栏,在您想要设置断点的行处点击。在这个练习中,我们将在事件处理程序内的第一行设置一个断点。一个蓝色的箭头符号将出现在那一行上:图 1.18:断点显示为源文件边栏上的箭头标记
图 1.18:断点显示为源文件边栏上的箭头标记
  1. 点击页面上的“点击我!”按钮来触发代码执行。您会注意到发生了两件事情 - 浏览器窗口冻结了,并且有一条消息表明代码已经暂停了:图 1.19:当浏览器遇到断点时,执行会暂停
图 1.19:当浏览器遇到断点时,执行会暂停
  1. 此外,正在执行的代码行在“源”选项卡中得到了突出显示:图 1.20:源代码中的执行暂停,突出显示将要执行的下一行
图 1.20:源代码中的执行暂停,突出显示将要执行的下一行
  1. 在侧边栏中,注意当前执行的堆栈和当前作用域中的所有内容,无论是全局还是局部。这是右侧面板的视图,显示了有关运行代码的所有重要信息:图 1.21:源选项卡右侧显示了当前暂停执行的执行上下文和堆栈跟踪
图 1.21:源选项卡右侧显示了当前暂停执行的执行上下文和堆栈跟踪
  1. 顶部的工具栏可以用来控制代码执行。每个按钮的功能如下:

“播放”按钮结束暂停并正常继续执行。

“步过”按钮会执行当前行直到完成,并在下一行再次暂停。

点击“步入”按钮将执行当前行并步入任何函数调用,这意味着它将在被调用的函数内的第一行暂停。

“步出”按钮将执行所有必要的步骤以退出当前函数。

“步”按钮将执行下一个操作。如果是函数调用,它将步入。如果不是,它将继续执行下一行。

  1. 按下“步过”按钮,直到执行到第 20 行:图 1.22:突出显示的行显示了执行暂停以进行调试
图 1.22:突出显示的行显示了执行暂停以进行调试
  1. 在右侧的“作用域”面板上,您会看到四个作用域:两个“块”作用域,然后一个“局部”作用域和一个“全局”作用域。作用域将根据您在代码中的位置而变化。在这种情况下,第一个“块”作用域仅包括for循环内的内容。第二个“块”作用域是整个循环的作用域,包括在for语句中定义的变量。“局部”是函数作用域,“全局”是浏览器作用域。这是您应该看到的:图 1.23:作用域面板显示了当前执行上下文中不同作用域中的所有变量
图 1.23:作用域面板显示了当前执行上下文中不同作用域中的所有变量
  1. 此时要注意的另一件有趣的事情是,如果你将鼠标悬停在当前页面中的 HTML 元素上,Chrome 会为你突出显示该元素:

图 1.24:Chrome 在不同位置悬停时突出显示 DOM 元素

图 1.24:Chrome 在不同位置悬停时突出显示 DOM 元素

使用选项卡调试代码是作为 Web 开发人员最重要的事情之一。了解浏览器如何看待你的代码,以及每行中变量的值是解决复杂应用程序中问题的最简单方法。

注意

内联值:当你在选项卡中调试时逐步执行代码时,你会注意到 Chrome 在每行的侧边添加了一些浅橙色的突出显示,显示了在该行中受影响的变量的当前值。

控制台选项卡

现在你知道如何在元素选项卡中遍历和操作 DOM 树,以及如何在选项卡中探索和调试代码,让我们来探索一下控制台选项卡。

控制台选项卡可以帮助你调试问题,也可以探索和测试代码。为了了解它能做什么,我们将使用本书代码库中Lesson01/sample_002文件夹中的示例商店。

打开商店页面,你会看到这是一个食品产品的商店。它看起来是这样的:

图 1.25:商店示例页面的屏幕截图

图 1.25:商店示例页面的屏幕截图

在底层,你可以看到 DOM 非常简单。它有一个section元素,其中包含所有的页面内容。里面有一个带有类项的div标签,代表产品列表,以及每个产品的一个带有类项的div。在元素选项卡中,你会看到这样的内容:

图 1.26:商店页面的 DOM 树非常简单

图 1.26:商店页面的 DOM 树非常简单

回到控制台选项卡:你可以在这个 DOM 中运行一些查询来了解更多关于元素和内容的信息。让我们写一些代码来列出所有产品的价格。首先,我们需要找到 DOM 树中的价格在哪里。我们可以查看元素选项卡,但现在,我们将只使用控制台选项卡来学习更多。在控制台选项卡中运行以下代码将打印一个包含 21 个项目的HTMLCollection对象:

document.getElementsByClassName('item')

让我们打开第一个,看看里面有什么:

document.getElementsByClassName('item')[0]

现在你看到 Chrome 打印了一个 DOM 元素,如果你在上面悬停,你会看到它在屏幕上被突出显示。你也可以打开在控制台选项卡中显示的迷你 DOM 树,看看元素是什么样子的,就像在元素选项卡中一样:

图 1.27:控制台选项印刷 DOM 中的元素

图 1.27:控制台选项印刷 DOM 中的元素

你可以看到价格在一个span标签内。要获取价格,你可以像查询根文档一样查询元素。

注意:自动完成和之前的命令

控制台选项卡中,你可以通过按下Tab来使用基于当前上下文的自动完成,并通过按上/下箭头键快速访问之前的命令。

运行以下代码来获取列表中第一个产品的价格:

document.getElementsByClassName('item')[0]
  .getElementsByTagName('span')[0].textContent

产品的价格将显示在控制台中作为一个字符串:

图 1.28:查询包含价格的 DOM 元素并获取其内容

图 1.28:查询包含价格的 DOM 元素并获取其内容

活动 1:从页面中提取数据

假设您正在编写一个需要来自 Fresh Products Store 的产品和价格的应用程序。商店没有提供 API,其产品和价格大约每周变化一次-不够频繁以证明自动化整个过程是合理的,但也不够慢以至于您可以手动执行一次。如果他们改变了网站的外观方式,您也不想麻烦太多。

您希望以一种简单生成和解析的方式为应用程序提供数据。最终,您得出结论,最简单的方法是生成一个 CSV,然后将其提供给您的应用程序。

在这个活动中,您将编写一些 JavaScript 代码,可以将其粘贴到商店页面的控制台选项卡中,并使用它从 DOM 中提取数据,将其打印为 CSV,以便您的应用程序消费。

注意:在控制台选项卡中的长代码

在 Chrome 控制台中编写长代码时,我建议在文本编辑器中进行,然后在想要测试时粘贴它。控制台在编辑代码时并不糟糕,但在尝试修改长代码时很容易搞砸事情。

执行以下步骤:

  1. 初始化一个变量来存储 CSV 的整个内容。

  2. 查询 DOM 以找到表示每个产品的所有元素。

  3. 遍历找到的每个元素。

  4. product元素中,查询带有单位的价格。使用斜杠拆分字符串。

  5. 再次,从product元素中查询名称。

  6. 将所有信息附加到步骤 1 中初始化的变量中,用逗号分隔值。不要忘记为附加的每一行添加换行字符。

  7. 使用console.log函数打印包含累积数据的变量。

  8. 在打开商店页面的控制台选项卡中运行代码。

您应该在控制台选项卡中看到以下内容:

name,price,unit
Apples,$3.99,lb
Avocados,$4.99,lb
Blueberry Muffin,$2.50,each
Butter,$1.39,lb
...

注意

此活动的解决方案可在第 582 页找到。

在这个活动中,您可以使用控制台选项卡查询现有页面并从中提取数据。有时,从页面中提取数据非常复杂,而且爬取可能会变得非常脆弱。根据您需要从页面获取数据的频率,可能更容易在控制台选项卡中运行脚本,而不是编写一个完整的应用程序。

节点和元素

在之前的章节中,我们学习了 DOM 以及如何与其交互。我们看到浏览器中有一个全局文档对象,表示树的根。然后,我们观察了如何查询它以获取节点并访问其内容。

但在前几节探索 DOM 时,有一些对象名称、属性和函数是在没有介绍的情况下访问和调用的。在本节中,我们将深入研究这些内容,并学习如何找到每个对象中可用的属性和方法。

关于本节将讨论的内容,最好的文档位置是 Mozilla 开发者网络网页文档。您可以在developer.mozilla.org找到。他们对所有 JavaScript 和 DOM API 都有详细的文档。

节点是一切的起点。节点是表示 DOM 树中的接口。如前所述,树中的一切都是节点。所有节点都有一个nodeType属性,用于描述节点的类型。它是一个只读属性,其值是一个数字。节点接口对于每个可能的值都有一个常量。最常见的节点类型如下:

  • Node.ELEMENT_NODE - HTML 和 SVG 元素属于这种类型。在商店代码中,如果您从产品中获取description元素,您将看到它的nodeType属性是1,这意味着它是一个元素:

图 1.29:描述元素节点类型为 Node.ELEMENT_NODE

图 1.29:描述元素节点类型为 Node.ELEMENT_NODE

这是我们从元素选项卡中获取的元素:

图 1.30:在元素选项卡中查看的描述节点

图 1.30:在元素选项卡中查看的描述节点
  • Node.TEXT_NODE - 标签内的文本变成文本节点。如果您从description节点获取第一个子节点,您会发现它的类型是TEXT_NODE

图 1.31:标签内的文本变成文本节点

图 1.31:标签内的文本变成文本节点

这是在元素选项卡中查看的节点:

图 1.32:在元素选项卡中选择的文本节点

图 1.32:在元素选项卡中选择的文本节点
  • Node.DOCUMENT_NODE - 每个 DOM 树的根是一个document节点:

图 1.33:树的根始终是文档节点

图 1.33:树的根始终是文档节点

一个重要的事情要注意的是html节点不是根节点。当创建 DOM 时,document节点是根节点,它包含html节点。您可以通过获取document节点的第一个子节点来确认:

图 1.34:html 节点是文档节点的第一个子节点

图 1.34:html 节点是文档节点的第一个子节点

nodeName是节点具有的另一个重要属性。在element节点中,nodeName将为您提供它们的 HTML 标签。其他节点类型将返回不同的内容。document节点将始终返回#document(如前图所示),而Text节点将始终返回#text

对于TEXT_NODECDATA_SECTION_NODECOMMENT_NODE等类似文本的节点,您可以使用nodeValue来获取它们所包含的文本。

但节点最有趣的地方在于你可以像遍历树一样遍历它们。它们有子节点和兄弟节点。让我们在下面的练习中稍微练习一下这些属性。

练习 4:遍历 DOM 树

在这个练习中,我们将遍历图 1.1中示例页面中的所有节点。我们将使用递归策略来迭代所有节点并打印整个树。

执行以下步骤以完成练习:

  1. 第一步是打开文本编辑器并设置它以编写一些 JavaScript 代码。

  2. 要使用递归策略,我们需要一个函数,该函数将被调用以处理树中的每个节点。该函数将接收两个参数:要打印的节点和节点在 DOM 树中的深度。以下是函数声明的样子:

function printNodes(node, level) {
}
  1. 函数内部的第一件事是开始标识将要打开此节点的消息。为此,我们将使用nodeName,对于HTMLElements,它将给出标签,对于其他类型的节点,它将给出一个合理的标识符:
let message = `${"-".repeat(4 * level)}Node: ${node.nodeName}`;
  1. 如果节点也有与之关联的nodeValue,比如Text和其他文本行节点,我们还将将其附加到消息中,然后将其打印到控制台:
if (node.nodeValue) {
  message += `, content: '${node.nodeValue.trim()}'`;
}
console.log(message);
  1. 之后,我们将获取当前节点的所有子节点。对于某些节点类型,childNodes属性将返回 null,因此我们将添加一个空数组的默认值,以使代码更简单:
var children = node.childNodes || [];
  1. 现在我们可以使用for循环来遍历数组。对于我们找到的每个子节点,我们将再次调用该函数,启动算法的递归性质:
for (var i = 0; i < children.length; i++) {
  printNodes(children[i], level + 1);
}
  1. 函数内部的最后一件事是打印具有子节点的节点的关闭消息:
if (children.length > 0) {
  console.log(`${"-".repeat(4 * level)}End of:${node.nodeName}`);}
  1. 现在我们可以通过调用该函数并将文档作为根节点传递,并在函数声明结束后立即将级别设置为零来启动递归:
printNodes(document, 0);

最终的代码应该如下所示:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise04/open_close_tree_print.js

  1. 在 Chrome 中打开示例 HTML。文件位于:bit.ly/2maW0Sx

  2. 打开开发者工具面板,在控制台选项卡中粘贴 JavaScript 代码,然后运行。以下是您应该看到的输出:

图 1.35:遍历 DOM 并递归打印所有节点及其子节点

图 1.35:遍历 DOM 并递归打印所有节点及其子节点

在这个练习中,您学会了如何使用递归来逐个节点地遍历整个 DOM 树。您还学会了如何检查节点的属性,因为在遍历整个树时,您会看到不是 HTML 的节点,比如文本和注释。

非常有趣的一点是浏览器还保留了您添加到 HTML 中的空格。以下截图将源代码与练习中打印的树进行了比较:

图 1.36:演示空格也成为 DOM 树中的节点

图 1.36:演示空格也成为 DOM 树中的节点

您可以使用颜色代码查看映射:

  • 红色标记了包含标题文本的文本节点。

  • 绿色标记了整个title元素。

  • 蓝色框和箭头标记了title元素之前和之后的空格。

注意:注意间隔

在处理 DOM 节点时,非常重要的一点是要记住并非所有节点都是 HTML 元素。有些甚至可能是您没有故意放入文档中的东西,比如换行符。

我们谈论了很多关于节点的内容。您可以查看 Mozilla 开发者网络文档以了解其他节点属性和方法。但您会注意到节点接口主要关注 DOM 树中节点之间的关系,比如兄弟节点和子节点。它们非常抽象。因此,让我们更具体一些,探索Element类型的节点。

所有 HTML 元素都被转换为HTMLElement节点,它们继承自Element,后者又继承自一个节点。它们继承了父类型的所有属性和方法。这意味着元素是一个节点,而HTMLElement实例是一个元素。

因为element代表一个元素(带有其所有属性和内部标签的标签),所以您可以访问其属性。例如,在image元素中,您可以读取src属性。以下是获取商店页面第一个img元素的src属性的示例:

图 1.37:获取页面第一个图像的 src 属性

图 1.37:获取页面第一个图像的 src 属性

HTML 元素还具有的另一个有用属性是innerHTML属性。使用它,您可以获取(和设置)元素的 HTML。以下是获取具有image类的第一个div并打印其innerHTML的示例:

图 1.38:innerHTML 可用于访问元素内部的 HTML

图 1.38:innerHTML 可用于访问元素内部的 HTML

还有outerHTML属性,它将给出元素本身的 HTML,包括其中的所有内容:

图 1.39:outerHTML 给出了元素及其内部的 HTML

图 1.39:outerHTML 给出了元素及其内部的 HTML

最后但同样重要的是className属性,它可以让您访问应用于元素的类:

图 1.40:className 可以访问元素的类

图 1.40:className 可以访问元素的类

关于这些属性更重要的是它们是可读/可写的,这意味着您可以使用它们来修改 DOM,添加类并更改元素的内容。在接下来的部分中,我们将使用这里所学到的内容来创建根据用户交互而变化的动态页面。

特殊对象

到目前为止,我们在许多示例和练习中都访问了document对象。但它到底是什么,还能做什么?文档是一个代表浏览器中加载的页面的全局对象。正如我们所见,它作为 DOM 树中元素的入口点。

它还有一个我们到目前为止还没有讨论的重要作用,那就是在页面中创建新节点和元素的能力。这些元素可以附加到树的不同位置,以在页面加载后修改它。我们将在接下来的章节中探讨这种能力。

除了document,还有另一个对象是 DOM 规范的一部分,那就是window对象。window对象是一个全局对象,也是所有在浏览器中运行的 JavaScript 代码的绑定目标。这意味着该变量是指向window对象的指针:

图 1.41:浏览器中的全局范围和默认绑定目标是窗口对象

图 1.41:浏览器中的全局范围和默认绑定目标是窗口对象

window对象包含您需要从浏览器访问的所有内容:位置、导航历史、其他窗口(弹出窗口)、本地存储等等。documentconsole对象也归属于window对象。当您访问document对象时,实际上是在使用window.document对象,但绑定是隐式的,因此您不需要一直写window。而且因为window是一个全局对象,这意味着它必须包含对自身的引用:

图 1.42:窗口对象包含对自身的引用

图 1.42:窗口对象包含对自身的引用

使用 JavaScript 查询 DOM

我们一直在讨论通过document对象查询 DOM。但是我们用来查询 DOM 的所有方法也可以从 DOM 中的元素中调用。本节介绍的方法也可以从 DOM 中的元素中调用。我们还将看到一些只能从元素中而不是document对象中使用的方法。

从元素中查询非常方便,因为查询的范围仅限于执行查询的位置。正如我们在Activity 1, Extracting Data from the DOM中看到的,我们可以从一个查询开始,找到所有基本元素 - 在这种特定情况下是产品元素,然后我们可以从执行查询的元素中执行一个新的查询,该查询将仅搜索在执行查询的元素内部的元素。

我们在上一节中用来查询 DOM 的方法包括直接从 DOM 中使用childNodes列表访问元素,或者使用getElementsByTagNamegetElementsByClassName方法。除了这些方法,DOM 还提供了一些其他非常强大的查询元素的方法。

首先,有getElement*方法系列:

  • getElementsByTagName - 我们之前见过并使用过这个方法。它获取指定标签的所有元素。

  • getElementsByClassName - 这是getElement的一个变体,它返回具有指定类的所有元素。请记住,一个元素可以通过用空格分隔它们来包含一个类的列表。以下是在商店页面中运行的代码的屏幕截图,您可以看到选择ui类名将获取还具有itemsteal(颜色)和label类的元素:

图 1.43:按类名获取元素通常返回包含其他类的元素
  • getElementById - 注意该方法名称中的单数形式。该方法将获取具有指定 ID 的唯一元素。这是因为在页面上预期 ID 是唯一的。

getElement*方法族非常有用。但有时,指定类或标记名称是不够的。这意味着您必须使用一系列操作来使您的代码非常复杂:获取所有具有此类的元素,然后获取具有此其他标记的元素,然后获取具有此类的元素,然后选择第三个,依此类推。

多年来,jQuery 是唯一的解决方案,直到引入了querySelectorquerySelectorAll方法。这两种方法可以用来在 DOM 树上执行复杂的查询。它们的工作方式完全相同。两者之间唯一的区别是querySelector只会返回与查询匹配的第一个元素,而querySelectorAll会返回一个可以迭代的列表。

querySelector*方法使用 CSS 选择器。您可以使用任何 CSS 选择器来查询元素。让我们在下一个练习中更深入地探索一下。

练习 5:使用 querySelector 查询 DOM

在这个练习中,我们将探索在之前章节学到的各种查询和节点导航技术。为此,我们将使用商店代码作为基本 HTML 来探索,并编写 JavaScript 代码来查找商店页面上所有有机水果的名称。为了增加难度,有一个标记为有机的蓝莓松饼。

在开始之前,让我们看一下product元素及其子元素。以下是从Elements选项卡查看的product元素的 DOM 树:

图 1.44:产品元素及其子元素

图 1.44:产品元素及其子元素

您可以看到每个产品的根元素是一个带有class项的div标记。名称和标记位于一个带有类 content 的子 div 中。产品的名称位于一个带有类 header 的锚点中。标记是一组带有三个类uilabeltealdiv标记。

在处理这样的问题时,您想要查询和过滤一组在一个共同父级下相关的元素时,有两种常见的方法:

  • 首先查询根元素,然后进行过滤和查找所需的元素。以下是这种方法的图形表示:

图 1.45:第一种方法涉及从根元素开始

图 1.45:第一种方法涉及从根元素开始
  • 从匹配过滤条件的子元素开始,如果需要,应用额外的过滤,然后导航到您要查找的元素。以下是这种方法的图形表示:

图 1.46:第二种方法涉及从过滤条件开始

图 1.46:第二种方法涉及从过滤条件开始

执行以下步骤完成练习:

  1. 为了使用第一种方法解决练习,我们需要一个函数来检查产品是否包含指定的标签列表。这个函数的名称将是the,它接收两个参数-产品根元素和要检查的标签列表:
function containLabels(element, ...labelsToCheck) {
}
  1. 在这个函数中,我们将使用一些数组映射和过滤来找到参数中指定的标签和被检查产品的标签之间的交集:
const intersection = Array.from(element.querySelectorAll('.label'))
  .map(e => e.innerHTML)
  .filter(l => labelsToCheck.includes(l));
  1. 函数中的最后一件事是返回一个检查,告诉我们产品是否包含所有标签。检查告诉我们交集的大小是否与要检查的所有标签的大小相同,如果是,我们就有一个匹配:
return intersection.length == labelsToCheck.length;
  1. 现在我们可以使用查询方法来查找元素,将它们添加到数组中,进行过滤和映射到我们想要的内容,然后打印到控制台:
//Start from the product root element
Array.from(document.querySelectorAll('.item'))
//Filter the list to only include the ones with both labels
.filter(e => containLabels(e, 'organic', 'fruit'))
//Find the product name
.map(p => p.querySelector('.content a.header'))
.map(a => a.innerHTML)
//Print to the console
.forEach(console.log);
  1. 要使用第二种方法解决问题,我们需要一个函数来查找指定元素的所有兄弟元素。打开您的文本编辑器,让我们从声明带有数组的函数开始存储我们找到的所有兄弟元素。然后,我们将返回数组:
function getAllSiblings(element) {
  const siblings = [];
  // rest of the code goes here
  return siblings;
}
  1. 然后,我们将使用while循环和previousElementSibling属性迭代所有先前的兄弟元素。在迭代兄弟元素时,我们将它们推入数组中:
let previous = element.previousElementSibling;
while (previous) {
  siblings.push(previous);
  previous = previous.previousElementSibling;
}

注意:再次注意间隙

我们使用previousElementSibling而不是previousNode,因为这将排除所有文本节点和其他节点,以避免不得不为每个节点检查nodeType

  1. 对于指定元素之后的所有兄弟元素,我们做同样的操作:
let next = element.nextElementSibling;
while (next) {
  siblings.push(next);
  next = next.nextElementSibling;
}
  1. 现在我们有了getAllSiblings函数,我们可以开始查找产品。我们可以使用querySelectorAll函数,以及一些数组映射和过滤来找到并打印我们想要的数据:
//Start by finding all the labels with content 'organic'
Array.from(document.querySelectorAll('.label'))
.filter(e => e.innerHTML === 'organic')
//Filter the ones that don't have a sibling label 'fruit'
.filter(e => getAllSiblings(e).filter(s => s.innerHTML === 'fruit').length > 0)
//Find root product element
.map(e => e.closest('.item'))
//Find product name
.map(p => p.querySelector('.content a.header').innerHTML)
//Print to the console
.forEach(console.log);
  1. 开发者工具控制台选项卡中执行代码,您将看到以下输出:

图 1.47:练习中代码的输出。打印所有有机水果的名称。

图 1.47:练习中代码的输出。打印所有有机水果的名称。

注意

此练习的代码可以在 GitHub 上找到。包含第一种方法代码的文件路径是:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise05/first_approach.js

包含第二种方法代码的文件路径是:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise05/second_approach.js

在这个练习中,我们使用了两种不同的技术从页面中获取数据。我们使用了许多查询和节点导航方法和属性来查找元素并在 DOM 树中移动。

当构建现代 Web 应用程序时,了解这些技术是至关重要的。在这种类型的应用程序中,导航 DOM 和获取数据是最常见的任务。

操作 DOM

现在我们知道了 DOM 是什么,以及如何查询元素和在其周围导航,是时候学习如何使用 JavaScript 来更改它了。在本节中,我们将重写商店前端,通过使用 JavaScript 加载产品列表并创建页面元素,使其更具交互性。

本节的示例代码可以在 GitHub 上找到:bit.ly/2mMje1K

在使用 JavaScript 创建动态应用程序时,我们需要知道的第一件事是如何创建新的 DOM 元素并将它们附加到树中。由于 DOM 规范完全基于接口,没有具体的类可实例化。当您想要创建 DOM 元素时,需要使用document对象。document对象有一个名为createElement的方法,它接收一个标签名称作为字符串。以下是创建div元素的示例代码:

const root = document.createElement('div');

product项元素具有item类。要将该类添加到它,我们只需设置className属性,如下所示:

root.className = 'item';

现在我们可以将元素附加到需要去的地方。但首先,我们需要找到它需要去的地方。此示例代码的 HTML 可以在 GitHub 上找到bit.ly/2nKucVo。您可以看到它有一个空的div元素,产品项将被添加到其中:

<div class="ui items"></div>

我们可以使用querySelector来找到该元素,然后在其上调用appendChild方法,这是每个节点都有的方法,并将刚刚创建的元素节点传递给它,以便将其添加到 DOM 树中:

const itemsEl = document.querySelector('.items');
products.forEach((product) => {
  itemsEl.appendChild(createProductItem(product));
});

在这里,createProductItem是一个函数,它接收一个产品并使用先前提到的createElement函数为其创建 DOM 元素。

创建一个 DOM 元素并没有太大的用处。对于动态商店示例,我们有一个包含我们构建页面所需的所有数据的对象数组。对于每一个对象,我们需要创建所有的 DOM 元素,并将它们粘合在正确的位置和顺序上。但首先,让我们来看看数据是什么样子的。以下显示了每个product对象的外观:

{
  "price": 3.99,
  "unit": "lb",
  "name": "Apples",
  "description": "Lorem ipsum dolor sit amet, ...",
  "image": "../images/products/apples.jpg",
  "tags": [ "fruit", "organic" ]
}

以下是我们在之前章节中使用的静态商店代码中相同产品的 DOM 看起来的方式:

图 1.48:产品的 DOM 树部分

图 1.48:产品的 DOM 树部分

您可以看到有许多嵌套的元素需要创建才能得到所需的最终 DOM 树。因此,让我们看看在使用 JavaScript 构建复杂应用程序时非常有用的一些技术。

让我们开始看一下示例代码中的createProductItem

function createProductItem(product) {
  const root = document.createElement('div');
  root.className = 'item';
  root.appendChild(createProductImage(product.image));
  root.appendChild(createContent(product));
  return root;
}

我们通过创建产品树的根元素开始这个方法,这是一个div元素。从前面的截图中,您可以看到这个div需要一个item类,这就是在元素创建后的下一行发生的事情,就像本节开头描述的那样。

元素准备好后,就可以开始向其添加子元素了。我们不是在同一个方法中完成所有操作,而是创建其他负责创建每个子元素的函数,并直接调用它们,将每个函数的结果附加到根元素:

root.appendChild(createProductImage(product.image));
root.appendChild(createContent(product));

这种技术很有用,因为它将每个子元素的逻辑隔离在自己的位置上。

现在让我们来看一下createProductImage函数。从之前的示例代码中,您可以看到该函数接收product图像的路径。这是该函数的代码:

function createProductImage(imageSrc) {
  const imageContainer = document.createElement('div');
  imageContainer.className = 'image';
  const image = document.createElement('img');
  image.setAttribute('src', imageSrc);
  imageContainer.appendChild(image);
  return imageContainer;
}

该函数分为两个主要部分:

  1. 它创建图像的容器元素。从 DOM 截图中,您可以看到img元素位于一个带有image类的div内。

  2. 它创建img元素,设置src属性,然后将其附加到container元素。

这种代码风格简单、可读且易于理解。但这是因为需要生成的 HTML 相当简短。它只是一个div标签中的一个img标签。

不过,有时树变得非常复杂,使用这种策略使得代码几乎无法阅读。因此,让我们看看另一种策略。附加到产品根元素的另一个子元素是content元素。这是一个具有许多子元素的div标签,包括一些嵌套的子元素。

我们可以像createProductImage函数一样处理它。但是该方法需要执行以下操作:

  1. 创建container元素并为其添加一个类。

  2. 创建包含产品名称的锚元素并将其附加到容器。

  3. 创建价格的容器并将其附加到根容器。

  4. 创建带有价格的span元素并将其附加到上一步中创建的元素。

  5. 创建包含描述的元素并将其附加到容器。

  6. tag元素创建container元素并将其附加到根容器。

  7. 对于每个标签,创建tag元素并将其附加到上一步中的容器。

听起来像是一长串步骤,不是吗?我们可以使用模板字符串来生成 HTML,然后为容器元素设置innerHTML,而不是试图编写所有那些代码。因此,步骤看起来会像下面这样:

  1. 创建container元素并为其添加一个类。

  2. 使用字符串模板创建内部内容的 HTML。

  3. container元素上设置innerHTML

这听起来比以前的方法简单得多。而且,正如我们将看到的那样,它也会更加可读。让我们来看看代码。

如前所述,第一步是创建根容器并为其添加类:

function createContent(product) {
  const content = document.createElement('div');
  content.className = 'content';

然后,我们开始生成tag元素的 HTML。为此,我们有一个函数,它接收标签作为字符串并返回其 HTML 元素。我们使用它将所有标签映射到使用tags数组上的map函数的元素。然后,我们通过使用其outerHTML属性将元素映射到 HTML:

 const tagsHTML = product.tags.map(createTagElement)
    .map(el => el.outerHTML)
    .join('');

有了container元素创建和标签的 HTML 准备好后,我们可以使用模板字符串设置content元素的innerHTML属性并返回它:

  content.innerHTML = `
    <a class="header">${product.name}</a>
    <div class="meta"><span>$${product.price} / ${product.unit}</span></div>
    <div class="description">${product.description}</div>
    <div class="extra">${tagsHTML}</div>
  `;
  return content;
}

与生成 HTML 元素并附加它们所需的许多步骤相比,这段代码要简短得多,更容易理解。在编写动态应用程序时,您可以决定在每种情况下哪种方式最好。在这种情况下,权衡基本上是可读性和简洁性。但对于其他情况,权衡也可以是根据某些过滤器要求缓存元素以添加事件侦听器或隐藏/显示它们。

练习 6:过滤和搜索产品

在这个练习中,我们将为我们的商店应用程序添加两个功能,以帮助我们的客户更快地找到产品。首先,我们将使标签可点击,这将通过所选标签过滤产品列表。然后,我们将在顶部添加一个搜索框,供用户按名称或描述查询。页面将如下所示:

图 1.49:顶部带有搜索栏的新商店前端

图 1.49:顶部带有搜索栏的新商店前端

在这个新的商店前端,用户可以点击标签来过滤具有相同标签的产品。当他们这样做时,用于过滤列表的标签将显示在顶部,呈橙色。用户可以点击搜索栏中的标签以删除过滤器。页面如下所示:

图 1.50:顶部标签过滤的工作原理

图 1.50:顶部标签过滤的工作原理

用户还可以使用右侧的搜索框按名称或描述搜索产品。随着他们的输入,列表将被过滤。

此练习的代码可以在 GitHub 上找到:github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise06

执行以下步骤以完成练习:

  1. 我们将首先编写基本的 HTML 代码,稍后将使用 JavaScript 添加所有其他元素。此 HTML 现在包含一个基本的div容器,其中将包含所有内容。其中的内容分为两部分:一个包含标题的部分,其中包含标题和搜索栏,以及一个div,其中将包含所有产品项目。创建一个名为dynamic_storefront.html的文件,并在其中添加以下代码:
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="../css/semantic.min.css" />
    <link rel="stylesheet" type="text/css" href="../css/store_with_header.css" />
  </head>
  <body>
    <div id="content">
      <section class="header">
        <h1 class="title">Welcome to Fresh Products Store!</h1>
        <div class="ui menu">
          <div class="right item">
            <div class="ui icon input">
              <input type="text" placeholder="Search..." />
              <i class="search icon"></i>
            </div>
          </div>
        </div>
      </section>
      <div class="ui items"></div>
    </div>
    <script src="../data/products.js"></script>
    <script src="../sample_003/create_elements.js"></script>
    <script src="filter_and_search.js"></script>
  </body>
</html>

此 HTML 使用了products.jscreate_elements.js脚本,这与本节中使用的示例代码相同。它还使用了Lesson01文件夹中的 CSS 文件。如果您在同一个文件夹中,可以直接参考它们,或者将它们复制粘贴到您的项目中。

  1. 创建一个名为filter_and_search.js的文件,这是在 HTML 代码中加载的最后一个 JavaScript 代码。这是我们将为此练习添加所有代码的地方。我们需要做的第一件事是存储过滤器状态。用户可以应用到页面的两种可能过滤器:选择标签和/或输入一些文本。为了存储它们,我们将使用一个数组和一个字符串变量:
const tagsToFilterBy = [];
let textToSearch = '';
  1. 现在我们将创建一个函数,该函数将为页面中的所有标签添加事件侦听器。此函数将查找所有tag元素,将它们包装在一个数组中,并使用Element中的addEventListener方法添加事件侦听器以响应click事件:
function addTagFilter() {
  Array.from(document.querySelectorAll('.extra .label')).forEach(tagEl => {
    tagEl.addEventListener('click', () => {
      // code for next step goes here
    });
  });
}
  1. 在事件侦听器中,我们将检查标签是否已经在要按其进行过滤的标签数组中。如果没有,我们将添加它并调用另一个名为applyTagFilters的函数:
if (!tagsToFilterBy.includes(tagEl.innerHTML)) {
  tagsToFilterBy.push(tagEl.innerHTML);
  applyFilters();
}
  1. applyFilters只是一个包含与更新页面相关的所有逻辑的捕捉函数。您将只调用我们将在接下来的步骤中编写的函数:
function applyFilters() {
  createListForProducts(filterByText(filterByTags(products)));
  addTagFilter();
  updateTagFilterList();
}
  1. 在继续applyFilters函数之前,我们将添加另一个函数来处理文本搜索输入框上的事件。这个处理程序将监听keyup事件,当用户完成输入每个字母时触发。处理程序将获取输入框中的当前文本,将值设置为textToSearch变量,并调用applyFilters函数:
function addTextSearchFilter() {
  document.querySelector('.menu .right input'
.addEventListener('keyup', (e) => {
      textToSearch = e.target.value;
      applyFilters();
    });
}
  1. 现在,回到applyFilters函数。在其中调用的第一个函数几乎是隐藏的。这就是filterByTags函数,它使用tagsToFilterBy数组对产品列表进行过滤。它使用递归的方式对传入的产品列表使用选择的标签进行过滤:
function filterByTags() {
  let filtered = products;
  tagsToFilterBy
    .forEach((t) => filtered = filtered.filter(p => p.tags.includes(t)));
  return filtered;
}
  1. 无论过滤函数的输出是什么,都会传递给另一个过滤函数,即基于文本搜索过滤产品的函数。filterByText函数在比较之前将所有文本转换为小写。这样,搜索将始终不区分大小写:
function filterByText(products) {
  const txt = (textToSearch || '').toLowerCase();
  return products.filter((p) => {
    return p.name.toLowerCase().includes(txt)
      || p.description.toLowerCase().includes(txt);
  });
}

在通过选择的标签进行过滤和通过输入的文本进行过滤之后,我们将过滤后的数值传递给createListForProducts,这是create_elements.js中的一个函数,在本节练习之前已经描述过。

  1. 现在我们已经在页面上显示了新产品列表,我们需要重新注册标签过滤器事件监听器,因为 DOM 树元素已经被重新创建。所以我们再次调用addTagFilter。如前所示,这就是applyFilters函数的样子:
function applyFilters() {
  createListForProducts(filterByText(filterByTags(products)));
  addTagFilter();
  updateTagFilterList();
}
  1. applyTagFilter函数中调用的最后一个函数是updateTagFilterList。此函数将找到将保存过滤器指示器的元素,检查是否有选定的标签进行过滤,并相应地进行更新,要么将文本设置为无过滤器,要么为每个应用的标签添加指示器:
function updateTagFilterList() {
  const tagHolder = document.querySelector('.item span.tags');
  if (tagsToFilterBy.length == 0) {
    tagHolder.innerHTML = 'No filters';
  } else {
    tagHolder.innerHTML = '';
    tagsToFilterBy.sort();
    tagsToFilterBy.map(createTagFilterLabel)
      .forEach((tEl) => tagHolder.appendChild(tEl));
  }
}
  1. 我们需要将所有这些联系在一起的最后一个函数是createTagFilterLabel函数,它用于在搜索栏中创建标签被选中的指示器。此函数将创建 DOM 元素并添加一个事件侦听器,当单击时,将从数组中删除标签并再次调用applyTagFilter函数:
function createTagFilterLabel(tag) {
  const el = document.createElement('span');
  el.className = 'ui label orange';
  el.innerText = tag;
  el.addEventListener('click', () => {
    const index = tagsToFilterBy.indexOf(tag);
    tagsToFilterBy.splice(index, 1);
    applyTagFilter();
  });

  return el;
}
  1. 使页面工作的最后一步是调用applyTagFilter函数,以便将页面更新到初始状态,即未选择任何标签。此外,它将调用addTextSearchFilter以添加文本框的事件处理程序:
addTextSearchFilter();
applyFilters();

在 Chrome 中打开页面,您会看到顶部的过滤器为空,并且所有产品都显示在列表中。它看起来像本练习开头的截图。单击一个标签或在文本框中输入内容,您会看到页面更改以反映新状态。例如,选择两个饼干面包店标签,并在文本框中输入巧克力,页面将只显示具有这两个标签和名称或描述中包含巧克力的产品:

图 1.51:商店前端通过两个面包店和饼干标签以及单词巧克力进行过滤

图 1.51:商店前端通过两个面包店和饼干标签以及单词巧克力进行过滤

在本练习中,您已经学会了如何响应用户事件并相应地更改页面,以反映用户希望页面处于的状态。您还学会了当元素被移除并重新添加到页面时,事件处理程序会丢失并需要重新注册。

影子 DOM 和 Web 组件

在之前的部分中,我们已经看到一个简单的 Web 应用可能需要复杂的编码。当应用程序变得越来越大时,它们变得越来越难以维护。代码开始变得混乱,一个地方的变化会影响其他意想不到的地方。这是因为 HTML、CSS 和 JavaScript 的全局性质。

已经创建了许多解决方案来尝试规避这个问题,万维网联盟W3C)开始着手提出标准的方式来创建自定义的、隔离的组件,这些组件可以拥有自己的样式和 DOM 根。Shadow DOM 和自定义组件是从这一倡议中诞生的两个标准。

Shadow DOM 是一种创建隔离的 DOM 子树的方式,可以拥有自己的样式,并且不受添加到父树的样式的影响。它还隔离了 HTML,这意味着在文档树上使用的 ID 可以在每个影子树中多次重用。

以下图示了处理 Shadow DOM 时涉及的概念:

图 1.52:Shadow DOM 概念

图 1.52:Shadow DOM 概念

让我们描述一下这些概念的含义:

  • 文档树是页面的主要 DOM 树。

  • 影子宿主是附加影子树的节点。

  • 影子树是附加到文档树的隔离 DOM 树。

  • 影子根是影子树中的根元素。

影子宿主是文档树中附加影子树的元素。影子根元素是一个不显示在页面上的节点,就像主文档树中的文档对象一样。

要理解这是如何工作的,让我们从一些具有奇怪样式的 HTML 开始:

<style>
  p {
    background: #ccc;
    color: #003366;
  }
</style>

这将使页面上的每个段落都具有灰色背景,并带有一些蓝色文字。这是页面上段落的样子:

图 1.53:应用了样式的段落

图 1.53:应用了样式的段落

让我们添加一个影子树,并在其中添加一个段落,看看它的行为。我们将使用div元素将段落元素包装起来,并添加一些文本:

<div><p>I'm in a Shadow DOM tree.</p></div>

然后我们可以在元素中使用attachShadow方法创建一个影子根元素:

const shadowHost = document.querySelector('div');
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });

上面的代码选择了页面上的div元素,然后调用attachShadow方法,将配置对象传递给它。配置表示这个影子树是打开的,这意味着可以通过元素的shadowRoot属性访问它的影子根元素 - 在这种情况下是div

图 1.54:可以通过附加树的元素访问打开的影子树

图 1.54:可以通过附加树的元素访问打开的影子树

影子树可以关闭,但不建议采用这种方法,因为这会产生一种虚假的安全感,并且会让用户的生活变得更加困难。

在我们将影子树附加到文档树后,我们可以开始操纵它。让我们将影子宿主中的 HTML 复制到影子根中,看看会发生什么:

shadowRoot.innerHTML = shadowHost.innerHTML;

现在,如果您在 Chrome 中加载页面,您会看到以下内容:

图 1.55:加载了影子 DOM 的页面

图 1.55:加载了影子 DOM 的页面

您可以看到,即使向页面添加了样式来选择所有段落,但向影子树添加的段落不受其影响。Shadow DOM 中的元素与文档树完全隔离。

现在,如果您查看 DOM,您会发现有些地方看起来很奇怪。影子树替换并包装了原来在div元素内部的段落,这就是影子宿主:

图 1.56:影子树与影子宿主中的其他节点处于同一级别

图 1.56:影子树与影子宿主中的其他节点处于同一级别

但是影子宿主内部的原始段落不会在页面上呈现。这是因为当浏览器渲染页面时,如果元素包含具有新内容的影子树,它将替换宿主下的当前树。这个过程称为平铺,下面的图表描述了它的工作原理:

图 1.57:平铺时,浏览器会忽略影子宿主下的节点

现在我们了解了 Shadow DOM 是什么,我们可以开始使用它来构建或者自己的 HTML 元素。没错!通过自定义组件 API,你可以创建自己的 HTML 元素,然后像任何其他元素一样使用它。

在本节的其余部分,我们将构建一个名为counter的自定义组件,它有两个按钮和中间的文本。你可以点击按钮来增加或减少存储的值。你还可以配置它具有初始值和不同的增量值。下面的屏幕截图显示了组件完成后的外观。这个代码存放在 GitHub 上,网址是bit.ly/2mVy1XP

图 1.58:计数器组件及其在 HTML 中的使用

图 1.58:计数器组件及其在 HTML 中的使用

要定义你的自定义组件,你需要在自定义组件注册表中调用define方法。有一个名为customElements的全局注册表实例。要注册你的组件,你调用define,传递你的组件将被引用的字符串。它至少需要有一个破折号。你还需要传递实例化你的组件的构造函数。下面是代码:

customElements.define('counter-component', Counter);

你的构造函数可以是一个普通函数,或者,就像这个例子中一样,你可以使用新的 JavaScript class定义。它需要扩展HTMLElement

class Counter extends HTMLElement {
}

为了使自定义组件与页面的其余部分隔离,你可以使用一个阴影树,其中阴影主机是你的组件元素。你不需要使用 Shadow DOM 来构建自定义组件,但建议对于更复杂的组件也包装一些样式。

在你的元素的构造函数中,通过调用attachShadow来创建自己的实例的阴影根:

constructor() {
  super(); // always call super first
  // Creates the shadow DOM to attach the parts of this component
  this.attachShadow({mode: 'open'});
  // ... more code here
}

记住,当你使用open模式将阴影 DOM 附加到元素时,元素将把该阴影根存储在shadowRoot属性中。所以,从现在开始我们可以使用this.shadowRoot来引用它。

在前面的图中,你看到counter组件有两个属性,它用来配置自身:valueincrement。这些属性在构造函数的开始使用ElementgetAttribute方法设置,并在没有可用时设置合理的默认值:

this.value = parseInt(this.getAttribute('value') || 0);
this.increment = parseInt(this.getAttribute('increment') || 1);

之后,我们为这个组件创建了所有的 DOM 元素,并将它们附加到阴影根。我们不会深入细节,因为你现在已经看到了足够的 DOM 操作。在构造函数中,我们只是调用创建这些元素的函数,并使用this.shadowRoot.appendChild将它们附加:

// Create and attach the parts of this component
this.addStyles();
this.createButton('-', () => this.decrementValue());
this.createValueSpan();
this.createButton('+', () => this.incrementValue());

第一个方法创建一个link元素,导入counter组件的 CSS 文件。第二和第四个方法创建decrementincrement按钮,并附加事件处理程序。第三个方法创建一个span元素,并在property下保留对它的引用。

incrementValuedecrementValue方法通过指定的数量增加当前值,然后调用updateState方法,将值的状态与 DOM(在这种情况下是 Shadow DOM)同步。incrementValueupdateState方法的代码如下:

incrementValue() {
  this.value += this.increment;
  this.triggerValueChangedEvent();
  this.updateState();
}
updateState() {
  this.span.innerText = `Value is: ${this.value}`;
}

incrementValue函数中,我们还调用函数来触发事件,通知用户值已经改变。这个函数将在后面讨论。

现在你已经定义并注册了你的新的HTMLElement,你可以像任何其他现有的 HTML 元素一样使用它。你可以通过 HTML 代码中的标签添加它,如下所示:

<counter-component></counter-component>
<counter-component value="7" increment="3"></counter-component>

或者,通过 JavaScript,通过创建一个元素并将其附加到 DOM 中:

const newCounter = document.createElement('counter-component');
newCounter.setAttribute('increment', '2');
newCounter.setAttribute('value', '3');
document.querySelector('div').appendChild(newCounter);

要完全理解 Web 组件的强大之处,还有两件事情你需要知道:回调和事件。

自定义组件有生命周期回调,你可以在你的类中设置它们,以便在它们周围的事情发生变化时得到通知。最重要的两个是connectedCallbackattributeChangedCallback

第一个对于当你想要在组件附加到 DOM 后操纵 DOM 时很有用。对于counter组件,我们只是在控制台上打印一些东西,以显示组件现在连接到了 DOM:

connectedCallback() {
  console.log("I'm connected to the DOM!");
}

当页面加载时,你可以看到为每个counter组件添加到 DOM 中打印的语句:

图 1.59:当计数器组件附加到 DOM 时在控制台中打印的语句

图 1.59:当计数器组件附加到 DOM 时在控制台中打印的语句

attributeChangedCallback在组件中的某个属性被更改时被调用。但是为了让它工作,你需要一个静态的 getter,它会告诉你想要被通知属性的更改。以下是静态 getter 的代码:

static get observedAttributes() {
  return ['value', 'increment'];
}

它只是返回一个包含我们想要被通知的所有属性的数组。attributeChangedCallback接收几个参数:更改的属性名称,旧值(如果没有设置,则为 null),和新值。以下是counter组件的回调代码:

attributeChangedCallback(attribute, _, newValue) {
  switch(attribute) {
    case 'increment':
      this.increment = parseInt(newValue);
      break;
    case 'value':
      this.value = parseInt(newValue);
      break;
  }
  this.updateState();
}

我们的回调检查属性名称,忽略旧值,因为我们不需要它,将其转换为整数,解析为整数,并根据属性的名称相应地设置新值。最后,它调用updateState函数,该函数将根据其属性更新组件的状态。

关于网络组件的最后一件事是你需要知道如何分发事件。事件是标准组件的重要部分;它们构成了与用户的所有交互的基础。因此,将逻辑封装到组件中的一个重要部分是理解你的组件的用户将对哪些事件感兴趣。

对于我们的counter组件,每当值更改时分发事件是非常有意义的。在事件中传递值也是有用的。这样,用户就不需要查询你的组件来获取当前值。

要分发自定义事件,我们可以使用ElementdispatchEvent方法,并使用CustomEvent构造函数来使用自定义数据构建我们的事件。我们的事件名称将是value-changed。用户可以添加事件处理程序来监听此事件,并在值更改时收到通知。

以下代码是triggerValueChangedEvent函数,之前提到过;这个函数从incrementValuedecrementValue函数内部调用:

triggerValueChangedEvent() {
  const event = new CustomEvent('value-changed', { 
    bubbles: true,
    detail: { value: this.value },
  });
  this.dispatchEvent(event);
}

这个函数创建了一个CustomEvent的实例,它在 DOM 中冒泡,并在detail属性中包含当前值。我们本可以创建一个普通的事件实例,并直接在对象上设置属性,但是对于自定义事件,建议使用CustomEvent构造函数,它可以正确处理自定义数据。创建事件后,调用dispatchEvent方法,传递事件。

现在我们已经发布了事件,我们可以注册并在页面上显示信息。以下是查询所有counter-components并为value-changed事件添加事件侦听器的代码。处理程序在每次单击组件时向现有的div添加一个段落:

const output = document.getElementById('output');
Array.from(document.querySelectorAll('counter-component'))
  .forEach((el, index) => {
    el.addEventListener('value-changed', (e) => {
    output.innerHTML += '<p>Counter ${index} value is now ${e.detail.value}</p>';
  });
});

这是在不同计数器上点击几次后页面的外观:

图 1.60:页面上添加的段落,显示计数器被点击

图 1.60:页面上添加的段落,显示计数器被点击

练习 7:用网络组件替换搜索框

要完全理解网络组件的概念,你需要看看一个应用程序如何被分解为封装的、可重用的组件。我们在上一个练习中构建的商店页面是我们开始的好地方。

在这个练习中,我们将编写一个网络组件,以替换页面右上角的搜索框。这就是我们谈论的组件:

图 1.61:将转换为 Web 组件的搜索框

图 1.61:将转换为 Web 组件的搜索框

这个组件将处理它的外观、渲染和状态,并在状态改变时发出事件。在这种情况下,搜索框只有一个状态:搜索文本。

执行以下步骤以完成练习:

  1. 将代码从Exercise 6复制到一个新文件夹中,这样我们就可以在不影响现有 storefront 的情况下进行更改。

  2. 让我们开始创建一个 Web 组件。创建一个名为search_box.js的文件,添加一个名为SearchBox的新类,并使用这个类定义一个新组件:

class SearchBox extends HTMLElement {
}
customElements.define('search-box', SearchBox);
  1. 在类中,添加一个构造函数,调用super,并将组件附加到一个影子根。构造函数还将通过设置一个名为_searchText的变量来初始化状态:
constructor() {
  super();
  this.attachShadow({ mode: 'open' });
  this._searchText = '';
}
  1. 为了公开当前状态,我们将为_searchText字段添加一个 getter:
get searchText() {
  return this._searchText;
  1. 仍然在类中,创建一个名为render的方法,它将把shadowRoot.innerHTML设置为我们想要的模板组件。在这种情况下,它将是搜索框的现有 HTML 加上一个指向 semantic UI 样式的链接,以便我们可以重用它们:
render() {
  this.shadowRoot.innerHTML = '
    <link rel="stylesheet" type="text/css" href="../css/semantic.min.css" />
    <div class="ui icon input">
      <input type="text" placeholder="Search..." />
      <i class="search icon"></i>
    </div>
  ';
}
  1. 创建另一个名为triggerTextChanged的方法,它将触发一个事件来通知监听器搜索文本已更改。它接收新的文本值并将其传递给监听器:
triggerTextChanged(text) {
  const event = new CustomEvent('changed', {
    bubbles: true,
    detail: { text },
  });
  this.dispatchEvent(event);
}
  1. 在构造函数中,在附加影子根后,调用render方法并注册一个监听器到输入框,以便我们可以为我们的组件触发 changed 事件。构造函数现在应该是这样的:
constructor() {
  super();
  this.attachShadow({ mode: 'open' });
  this._searchText = '';
  this.render();
  this.shadowRoot.querySelector('input').addEventListener('keyup', (e) => {
    this._searchText = e.target.value;
    this.triggerTextChanged(this._searchText);
  });
}
  1. 准备好我们的 Web 组件后,我们可以用它替换旧的搜索框。在dynamic_storefront.html HTML 中,用我们创建的新组件search-box替换div标签和它们的所有内容。还要将新的 JavaScript 文件添加到 HTML 中,放在所有其他脚本之前。您可以在 GitHub 上查看最终的 HTML,网址为github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise07/dynamic_storefront.html

  2. 通过使用文档的querySelector方法保存对search-box组件的引用:

const searchBoxElement = document.querySelector('search-box');
  1. 注册一个 changed 事件的事件监听器,这样我们就知道何时有新值可用,并调用applyFilters
searchBoxElement.addEventListener('changed', (e) => applyFilters());
  1. 现在我们可以清理filter_and_search.js JavaScript,因为部分逻辑已经移动到新组件中。我们将进行以下清理:

删除textToSearch变量(第 2 行),并将其替换为searchBoxElement.searchText(第 40 行)。

删除addTextSearchFilter函数(第 16-22 行)和脚本末尾对它的调用(第 70 行)。

如果一切顺利,在 Chrome 中打开文件将得到完全相同的 storefront,这正是我们想要的。

现在,处理搜索框和搜索文本的逻辑已经封装起来,这意味着如果我们需要更改它,我们不需要四处寻找分散在各处的代码片段。当我们需要知道搜索文本的值时,我们可以查询保存它的组件。

活动 2:用 Web 组件替换标签过滤器

现在我们已经用 web 组件替换了搜索框,让我们使用相同的技术替换标签过滤器。这个想法是我们将有一个组件来存储选定的标签列表。

这个组件将封装一个可以通过使用mutator方法(addTagremoveTag)来修改的选定标签列表。当内部状态发生变化时,会触发一个 changed 事件。此外,当列表中的标签被点击时,将触发一个tag-clicked事件。

步骤:

  1. 首先将代码从练习 7 复制到一个新文件夹中。

  2. 创建一个名为tags_holder.js的新文件,在其中添加一个名为TagsHolder的类,它扩展了HTMLElement,然后定义一个名为tags-holder的新自定义组件。

  3. 创建两个render方法:一个用于渲染基本状态,另一个用于渲染标签或指示未选择任何标签进行过滤的文本。

  4. 在构造函数中,调用super,将组件附加到影子根,初始化所选标签列表,并调用两个render方法。

  5. 创建一个 getter 来公开所选标签的列表。

  6. 创建两个触发器方法:一个用于触发changed事件,另一个用于触发tag-clicked事件。

  7. 创建两个mutator方法:addTagremoveTag。这些方法接收标签名称,如果不存在则添加标签,如果存在则删除标签。如果列表被修改,触发changed事件并调用重新渲染标签列表的方法。

  8. 在 HTML 中,用新组件替换现有代码,并将新的脚本文件添加到其中。

  9. filter_and_search.js中,删除tagsToFilterBy变量,并用新创建的组件中的新mutator方法和事件替换它。

注意。

此活动的解决方案可在第 584 页找到。

总结

在本章中,我们通过学习基本接口、属性和方法来探索 DOM 规范。我们了解了你编写的 HTML 与浏览器从中生成的树之间的关系。我们查询了 DOM 并导航 DOM 树。我们学会了如何创建新元素,将它们添加到树中,并操作现有元素。最后,我们学会了如何使用 Shadow DOM 来创建隔离的 DOM 树和可以在 HTML 页面中轻松重用的自定义组件。

在下一章中,我们将转向后端世界。我们将开始学习有关 Node.js 及其基本概念。我们将学习如何使用nvm安装和管理多个 Node.js 版本,最后但同样重要的是,我们还将学习有关npm以及如何查找和使用外部模块。

第三章:Node.js 和 npm

学习目标

在本章结束时,您将能够:

  • 安装和使用 Node.js 构建应用程序

  • 使用 Node.js 执行环境运行 JavaScript 代码

  • 使用 nvm 安装和管理多个 Node.js 版本

  • 识别并使用其他开发人员开发的模块,使用 npm

  • 创建和配置自己的 npm 包

在本章中,我们将转向后端世界,学习有关 Node.js 及其基本概念。我们将学习如何使用 nvm 安装和管理多个 Node.js 版本,然后我们将学习 npm 以及如何查找和使用外部模块。

介绍

在上一章中,我们了解了 HTML 如何成为 DOM 以及如何使用 JavaScript 来查询和操作页面内容。

在 JavaScript 出现之前,所有页面都是静态的。在 Netscape 将脚本环境引入其浏览器后,开发人员开始使用它来创建动态和响应式应用程序。应用程序变得越来越复杂,但 JavaScript 运行的唯一地方是在浏览器内部。然后,在 2009 年,Node.js 的原始开发人员 Ryan Dahl 决定创建一种在服务器端运行 JavaScript 的方式,通过允许他们构建应用程序而无需依赖其他语言,简化了 Web 开发人员的生活。

在本章中,您将学习 Node.js 的工作原理以及如何使用它来使用 JavaScript 创建脚本。您将了解 Node.js 核心 API 的基础知识,以及如何找到它们的文档,并如何使用它们的read-eval-print loop (REPL)命令行。

掌握构建 JavaScript 代码的技能后,您将学习如何管理多个 Node.js 版本,并了解 Node.js 的重要性。您还将学习 npm 是什么,以及如何导入和使用其他开发人员的软件包并构建 Node.js 应用程序。

什么是 Node.js?

Node.js 是在 V8 JavaScript 引擎之上运行的执行环境。它的基本前提是它是异步和事件驱动的。这意味着所有阻塞操作,例如从文件中读取数据,可以在后台处理,而应用程序的其他部分可以继续工作。当数据加载完成时,将发出事件,等待数据的人现在可以执行并进行工作。

从诞生之初,Node.js 就被设计为 Web 应用程序的高效后端。因此,它被各种规模和行业类型的公司广泛采用。Trello、LinkedIn、PayPal 和 NASA 是一些在其技术堆栈的多个部分中使用 Node.js 的公司。

但是什么是执行环境?执行环境为程序员编写应用程序提供基本功能,例如 API。例如,想象一下浏览器-它具有 DOM,诸如文档和窗口的对象,诸如setTimeoutfetch的函数,以及前端世界中可以做的许多其他事情。所有这些都是浏览器执行环境的一部分。由于该执行环境专注于浏览器,它提供了与 DOM 交互和与服务器通信的方式,这是它存在的全部。

Node.js 专注于为开发人员提供一种有效构建 Web 应用程序后端的环境。它提供 API 来创建 HTTP(S)服务器,读写文件,操作进程等。

正如我们之前提到的,Node.js 在底层使用 V8 JavaScript 引擎。这意味着为了将 JavaScript 文本转换为计算机处理的可执行代码,它使用了 V8,这是由 Google 构建的开源 JavaScript 引擎,用于驱动 Chromium 和 Chrome 浏览器。以下是这个过程的示例:

图 2.1:Node.js 使用 V8 引擎将 JavaScript 源代码转换为可在处理器中运行的可执行代码

图 2.1:Node.js 使用 V8 引擎将 JavaScript 源代码转换为在处理器中运行的可执行代码

Node.js 提供的执行环境是单线程的。这意味着每次只有一段 JavaScript 代码可以执行。但是 Node.js 有一个叫做事件循环的东西,它可以将等待某些东西的代码(比如从文件中读取数据)放入队列,而另一段代码可以执行。

从文件中读取或写入数据以及通过网络发送或接收数据都是由系统内核处理的任务,在大多数现代系统中都是多线程的。因此,一些工作最终会分布在多个线程中。但对于在 Node.js 执行环境中工作的开发人员来说,这一切都隐藏在一个叫做异步编程的编程范式中。

异步编程意味着你将要求执行一些任务,当结果可用时,你的代码将被执行。让我们回到从文件中读取数据的例子。在大多数编程语言和范式中,你只需编写一些伪代码,如下所示:

var file = // open file here
var data = file.read(); // do something with data here

采用异步编程模型,工作方式有所不同。你打开文件并告诉 Node.js 你想要读取它。你还给它一个回调函数,当数据对你可用时将被调用。伪代码如下:

var file = // open file here
file.read((data) => {
  // do something with data here
});

在这个例子中,脚本将被加载,并开始执行。脚本将逐行执行并打开文件。当它到达读取操作时,它开始读取文件并安排稍后执行回调。之后,它到达脚本的末尾。

当 Node.js 到达脚本的末尾时,它开始处理事件循环。事件循环分为阶段。每个阶段都有一个队列,存储着计划在其中运行的代码。例如,I/O 操作被安排在轮询阶段。有六个阶段,它们按以下顺序执行:

  1. 计时器:使用setTimeoutsetInterval计划的代码

  2. 挂起 回调:上一个周期的 I/O 的延迟回调

  3. 空闲准备:仅内部

  4. 轮询:计划进行 I/O 处理的代码

  5. 检查setImmediate回调在这里执行

  6. 关闭回调:计划在关闭套接字等上执行的代码

每个阶段都会执行代码,直到发生两种情况之一:阶段队列耗尽,或者执行了最大数量的回调:

图 2.2:事件循环阶段

图 2.2:事件循环阶段

要理解这是如何工作的,让我们看一些代码,将阶段映射到事件循环,并了解底层到底发生了什么:

console.log('First');
setTimeout(() => {
  console.log('Last');
}, 100);
console.log('Second');

在这段简短的代码中,我们向控制台打印一些内容(在 Node.js 中,默认情况下会输出到标准输出),然后我们设置一个函数在100毫秒后调用,并向控制台打印一些其他文本。

当 Node.js 启动你的应用程序时,它会解析 JavaScript 并执行脚本直到结束。当结束时,它开始事件循环。这意味着,直接打印到控制台时,它会立即执行。计划的函数被推送到计时器队列,并等待脚本完成(以及100毫秒过去)才会执行。当事件循环没有任务可执行时,应用程序结束。以下图表说明了这个过程:

图 2.3:Node.js 应用程序的执行流程

图 2.3:Node.js 应用程序的执行流程

由于执行顺序,应用程序的输出如下:

First
Second
Last

这里发生了两件重要的事情。首先,传递给setTimeout函数的代码在脚本执行完成后执行。其次,应用程序在脚本执行到最后不会立即退出;相反,它会等待事件循环耗尽要执行的任务。

Node.js 有两种执行方法。最常用的是当您传递文件的路径时,JavaScript 代码将从那里加载和执行。第二种方法是在 REPL 中。如果您执行 Node.js 命令而不给出任何参数,它将以 REPL 模式启动,这类似于我们在上一章中看到的 Dev Tools 中的控制台。让我们在下一个练习中详细探讨这一点。

练习 8:运行您的第一个 Node.js 命令

在这个练习中,您将在计算机上下载和安装 Node.js,创建您的第一个脚本并运行它。然后,我们将使用 Node.js 附带的 REPL 工具,并在其中运行一些命令。

注意

要能够运行 Node.js 应用程序,您需要在计算机上安装它。为此,您可以转到nodejs.org并下载 Node.js 软件包。建议下载最新的长期支持LTS)版本,这将为您提供最稳定和最长的安全和错误修补支持时间。在撰写本文时,该版本为10.16.0

执行以下步骤以完成此练习:

  1. 下载并安装 Node.js 后,转到命令行并检查您已安装的版本:
$ node –version
v10.16.0
  1. 现在,创建一个名为event_loop.js的新文本文件,并添加代码的扩展版本(事件循环示例),如前所示。它看起来像这样:
console.log('First');
const start = Date.now();
setTimeout(() => {
  console.log(`Last, after: ${Date.now() - start}ms`);
}, 100);
console.log('Second');
  1. 要使用 Node.js 运行 JavaScript,调用node并传递要执行的文件的路径。要运行刚刚创建的文件,请在命令行中执行以下代码,从您创建文件的目录中执行:
$ node event_loop.js

您将看到以下输出:

$ node event_loop.js
First
Second
Last, after: 106ms

最后看到的时间将在每次运行时都有所不同。这是因为setTimeout只能确保代码将在指定的时间之后运行,但不能保证它会准确地在您要求的时间执行。

  1. 运行node命令而不带任何参数;您将进入 REPL 模式:
$ node
>

>表示您现在在 Node.js 执行环境中。

  1. 在 REPL 命令行中,键入命令并按Enter执行。让我们尝试第一个:
> console.log('First');
First
Undefined

你可以看到它打印出你传递给console.log调用的字符串。它还打印出Undefined。这是最后执行语句的返回值。由于console.log没有返回任何东西,它打印了 undefined。

  1. 创建存储当前时间的常量:
> const start = Date.now()
undefined
  1. 声明变量也不会返回任何东西,所以它再次打印undefined
> start
1564326469948

如果要知道变量的值是多少,只需键入变量名称并按Enter。变量名称的返回语句是变量值,因此它会打印出该值。

  1. 现在,键入setTimeout调用,就像在您的文件中一样。如果您按Enter并且您的语句不完整,因为您正在启动一个函数或打开括号,Node.js 将打印省略号,表示它正在等待命令的其余部分:
> setTimeout(() => {
... 
  1. 您可以继续键入,直到所有命令都被键入。setTimeout函数返回一个Timeout对象,您可以在控制台中看到它。您还可以看到在执行回调时打印的文本:
> setTimeout(() => {
...   console.log('Last, after: ${Date.now() - start}ms');
... }, 100);

以下是前述代码的输出:

Timeout {
  _called: false,
  _idleTimeout: 100,
  _idlePrev: [TimersList],
  _idleNext: [TimersList],
  _idleStart: 490704,
  _onTimeout: [Function],
  _timerArgs: undefined,
  _repeat: null,
  _destroyed: false,
  domain: [Domain],
  [Symbol(unrefed)]: false,
  [Symbol(asyncId)]: 492,
  [Symbol(triggerId)]: 5 }
> Last, after: 13252ms

您可以看到打印出的时间远远超过了100毫秒。这是因为start变量是一段时间前声明的,它正在从初始值中减去当前时间。因此,该时间表示100毫秒,再加上您键入和执行命令所花费的时间。

  1. 尝试更改start的值。您会观察到 Node.js 不会让您这样做,因为我们将其声明为常量:
> start = Date.now();
Thrown:
TypeError: Assignment to constant variable.

我们可以尝试将其重新声明为一个变量,但是 Node.js 不会让我们这样做,因为它已经在当前环境中声明过了:

> let start = Date.now()
Thrown:
SyntaxError: Identifier 'start' has already been declared
  1. 在另一个函数中声明超时的整个调度,以便每次执行函数时都获得一个新的作用域:
> const scheduleTimeout = () => {
... const start = Date.now();
... setTimeout(() => {
..... console.log('Later, after: ${Date.now() - start}');
..... }, 100);
... };

每次调用该函数,它都会安排并在100毫秒后执行,就像在您的脚本中一样。这将输出以下内容:

Undefined
> scheduleTimeout
[Function: scheduleTimeout]
> scheduleTimeout()
Undefined
> Later, after: 104
  1. 要退出 REPL 工具,您可以按两次Ctrl + C,或者输入.exit然后按Enter
>
(To exit, press ^C again or type .exit)
>

安装 Node.js 并开始使用它非常容易。其 REPL 工具允许您快速原型设计和测试。了解如何使用这两者可以提高您的生产力,并在日常 JavaScript 应用程序开发中帮助您很多。

在这个练习中,您安装了 Node.js,编写了一个简单的程序,并学会了如何使用 Node.js 运行它。您还使用了 REPL 工具来探索 Node.js 执行环境并运行一些代码。

Node 版本管理器(nvm)

Node.js 和 JavaScript 拥有一个庞大的社区和非常快速的开发周期。由于这种快速的发展和发布周期,很容易过时(查看 Node.js 的先前版本页面以获取更多信息:nodejs.org/en/download/releases/)。

你能想象在一个使用 Node.js 且已经存在几年的项目上工作吗?当您回来修复一个错误时,您会注意到您安装的版本无法再运行代码,因为存在一些兼容性问题。或者,您会发现您无法使用当前版本更改代码,因为生产环境中运行的版本已经有几年了,没有 async/await 或其他您在最新版本中经常使用的功能。

这个问题发生在所有编程语言和开发环境中,但在 Node.js 中,由于其极快的发布周期,这一点尤为突出。

为了解决这个问题,通常会使用版本管理工具,这样您就可以快速在 Node.js 的不同版本之间切换。Node 版本管理器nvm)是一个广泛使用的工具,用于管理安装的 Node.js 版本。您可以在github.com/nvm-sh/nvm上找到有关如何下载和安装它的说明。

注意

如果您使用 Windows,可以尝试 nvm-windows(github.com/coreybutler/nvm-windows),它为 Linux 和 Mac 中的 nvm 提供了类似的功能。此外,在本章中,许多命令都是针对 Mac 和 Linux 的。对于 Windows,请参阅nvm-windows的帮助部分。

安装程序在您的系统中执行两件事:

  1. 在您的主目录中创建一个.nvm目录,其中放置了所有与管理 Node.js 的所有托管版本相关的脚本

  2. 添加一些配置以使 nvm 在所有终端会话中可用

nvm 非常简单易用,并且有很好的文档。其背后的想法是您的机器上将运行多个版本的 Node.js,您可以快速安装新版本并在它们之间切换。

在我的电脑上,我最初只安装了一段时间前下载的 Node.js 版本(10.16.0)。安装 nvm 后,我运行了列出所有版本的命令。以下是输出:

$ nvm ls

->system
iojs -> N/A (default)
node -> stable (-> N/A) (default)
unstable -> N/A (default)

您可以看到我没有其他版本可用。我还有一个系统版本,这是您在系统中安装的任何版本。我可以通过运行 node --version 来检查当前的 Node.js 版本:

$ node --version
v10.16.0

作为使用 nvm 的示例,假设您想要在最新版本上测试一些实验性功能。您需要做的第一件事是找出那个版本。因此,您运行nvm ls-remote命令(或者对于 Windows 系统,运行nvm list命令),这是列出远程版本的命令:

$ nvm ls-remote
        v0.1.14
        v0.1.15
        v0.1.16
       ...
       v10.15.3   (LTS: Dubnium)
       v10.16.0   (Latest LTS: Dubnium)
       ...
        v12.6.0
        v12.7.0

这将打印出所有可用版本的长列表。在写作时,最新的版本是 12.7.0,所以让我们安装这个版本。要安装任何版本,请运行nvm install <version>命令。这将下载指定版本的 Node.js 二进制文件,验证包是否损坏,并将其设置为终端中的当前版本:

$ nvm install 12.7.0
Downloading and installing node v12.7.0...
Downloading https://nodejs.org/dist/v12.7.0/node-v12.7.0-darwin-x64.tar.xz...
######################################################################## 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v12.7.0 (npm v6.10.0)

现在,您可以验证您已经安装了最新版本,并准备在终端中使用:

$ node --version
v12.7.0

或者,您可以直接使用别名node,这是最新版本的别名。但是对于 Windows,您需要提到需要安装的特定版本:

$ nvm install node
v12.7.0 is already installed.
Now using node v12.7.0 (npm v6.10.0)

广泛使用的框架和语言(如 Node.js)通常会为特定版本提供 LTS。这些 LTS 版本被认为更稳定,并保证对错误和安全修复提供更长时间的支持,这对于无法像正常发布周期那样快速迁移到新版本的公司或团队来说非常重要。如果您想使用最新的 LTS 版本,可以使用--lts选项:

$ nvm install --lts
Installing the latest LTS version.
Downloading and installing node v10.16.0...
Downloading https://nodejs.org/dist/v10.16.0/node-v10.16.0-darwin-x64.tar.xz...
######################################################################## 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v10.16.0 (npm v6.9.0)

使用 nvm 安装多个版本的 Node.js 后,您可以使用use命令在它们之间切换:

$ nvm use system --version
Now using system version of node: v10.16.0 (npm v6.9.0)
$ nvm use node
Now using node v12.7.0 (npm v6.10.0)
$ nvm use 7
Now using node v7.10.1 (npm v4.2.0)

当您有多个项目并经常在它们之间切换时,很难记住您为每个项目使用的 Node.js 版本。为了让我们的生活更轻松,nvm 支持项目目录中的配置文件。您只需在项目的根目录中添加一个.nvmrc文件,它将使用文件中的版本。您还可以在项目的任何父目录中添加一个.nvmrc文件。因此,如果您想在父目录中按 Node.js 版本对项目进行分组,可以在该父目录中添加配置文件。

例如,如果您在一个文件夹中有一个.nvmrc文件,版本为12.7.0,当您切换到该文件夹并运行nvm use时,它将自动选择该版本:

$ cat .nvmrc 
12.7.0
$ nvm use
Found '.../Lesson02/Exercise09/.nvmrc' with version <12.7.0>
Now using node v12.7.0 (npm v6.10.0)

练习 9:使用 nvm 管理版本

正如我们之前提到的,Node.js 的发布周期非常短。例如,如果您寻找 URL 类(nodejs.org/dist/latest-v12.x/docs/api/url.html#url_class_url),您会发现它最近才在全局范围内可用。这发生在 10.0.0 版本中,这个版本在写作时只有大约一年的历史。

在这个练习中,我们将编写一个.nvmrc文件,使用 nvm 安装多个版本的 Node.js,并尝试不同的版本,看看当您使用错误的 Node.js 版本时会得到什么类型的错误。

执行以下步骤完成这个练习:

  1. 在您的项目中添加一个.nvmrc文件。在一个空文件夹中,创建一个名为.nvmrc的文件,并在其中添加数字 12.7.0。您可以使用echo命令一次完成这个操作,并将输出重定向到文件中:
$ echo '12.7.0' > .nvmrc
  1. 您可以使用cat命令检查文件是否包含您想要的内容:
$ cat .nvmrc
12.7.0
  1. 让我们使用nvm use命令,它将尝试使用.nvmrc文件中的版本:
$ nvm use
Found '.../Lesson02/Exercise09/.nvmrc' with version <12.7.0>
N/A: version "12.7.0 -> N/A" is not yet installed.

在使用之前,您需要运行nvm install 12.7.0来安装它。如果您没有安装指定的版本,nvm 将给出清晰的消息。

  1. 调用nvm install来安装项目需要的版本:
$ nvm install
Found '.../Lesson02/Exercise09/.nvmrc' with version <12.7.0>
Downloading and installing node v12.7.0...
Downloading https://nodejs.org/dist/v12.7.0/node-v12.7.0-darwin-x64.tar.xz...
#################################################################### 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v12.7.0 (npm v6.10.0)

请注意,您不必传递您想要的版本,因为 nvm 将从.nvmrc文件中获取这个版本。

  1. 现在,创建一个名为url_explorer.js的文件。在其中,通过传递完整的 URL 来创建一个 URL 的实例。让我们还添加一些调用来探索 URL 的各个部分:
const url = new URL('https://www.someserver.com/not/a/path?param1=value1&param2=value2`);
console.log(`URL is: ${url.href}`);
console.log(`Hostname: ${url.hostname}`);
console.log(`Path: ${url.pathname}`);
console.log(`Query string is: ${url.search}`);
console.log(`Query parameters:`)
Array.from(url.searchParams.entries())
  .forEach((entry) => console.log(`\t- ${entry[0]} = ${entry[1]}`));
  1. 运行脚本。您会看到 URL 被正确解析,并且所有关于它的细节都正确地打印到控制台上:
$ node url_explorer.js
URL is: https://www.someserver.com/not/a/path?param1=value1&param2=value2
Hostname: www.someserver.com
Path: /not/a/path
Query string is: ?param1=value1&param2=value2
Query parameters:
    - param1 = value1
    - param2 = value2
  1. 现在,让我们尝试错误的 Node.js 版本。使用nvm安装版本9.11.2
$ nvm install 9.11.2
Downloading and installing node v9.11.2...
Downloading https://nodejs.org/dist/v9.11.2/node-v9.11.2-darwin-x64.tar.xz...
################################################################## 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v9.11.2 (npm v5.6.0)
  1. 现在,您可以再次运行url_explorer.js,看看会发生什么:
$ node url_explorer.js
.../Exercise09/url_explorer.js:1 ... { const url = new URL('...);^
ReferenceError: URL is not defined
    at Object.<anonymous> (.../Exercise09/url_explorer.js:1:75)
    at Module._compile (internal/modules/cjs/loader.js:654:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:665:10)
    at Module.load (internal/modules/cjs/loader.js:566:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:506:12)
    at Function.Module._load (internal/modules/cjs/loader.js:498:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:695:10)
    at startup (internal/bootstrap/node.js:201:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:516:3)

您应该看到与前面代码中相似的错误。它告诉您 URL 未定义。这是因为,正如我们之前提到的,URL 类只在 10.0.0 版本中变为全局可用。

  1. 修复 Node.js 的版本并再次运行脚本以查看正确的输出:
$ nvm use
Found '.../Lesson02/Exercise09/.nvmrc' with version <12.7.0>
Now using node v12.7.0 (npm v6.10.0)
$ node url_explorer.js 
URL is: https://www.someserver.com/not/a/path?param1=value1&param2=value2
Hostname: www.someserver.com
Path: /not/a/path
Query string is: ?param1=value1&param2=value2
Query parameters:
    - param1 = value1
    - param2 = value2

第 7 步中的错误消息没有提及 Node.js 版本。它只是一些关于缺少类的神秘错误。这类错误很难识别,并需要大量的历史追踪。这就是为什么在项目的根目录中有.nvmrc是重要的原因。它使其他开发人员能够快速识别和使用正确的版本。

在这个练习中,您学会了如何安装和使用多个版本的 Node.js,还学会了为项目创建.nvmrc文件。最后,您还了解了在使用错误版本时会看到的错误类型,以及.nvmrc文件的重要性。

Node 包管理器(npm)

当有人谈论Node 包管理器或简称 npm 时,他们可能指的是以下三种情况之一:

  • 一个管理 Node.js 应用程序包的命令行应用程序

  • 开发人员和公司发布他们的包供他人使用的存储库

  • 管理个人资料和搜索包的网站

大多数编程语言至少提供一种开发人员之间共享包的方式:Java 有 Maven,C#有 NuGet,Python 有 PIP 等。Node.js 在初始发布几个月后开始使用自己的包管理器。

包可以包括开发人员认为对他人有用的任何类型的代码。有时,它们还包括帮助开发人员进行本地开发的工具。

由于打包的代码需要共享,因此需要一个存储所有包的存储库。为了发布他们的包,作者需要注册并注册自己和他们的包。这解释了存储库和网站部分。

第三部分,即命令行工具,是您应用程序的实际包管理器。它随 Node.js 一起提供,并可用于设置新项目、管理依赖项以及管理应用程序的脚本,如构建和测试脚本。

注意

Node.js 项目或应用程序也被视为一个包,因为它包含一个package.json文件,代表了包中的内容。因此,通常可以互换使用以下术语:应用程序、包和项目。

每个 Node.js 包都有一个描述项目及其依赖关系的package.json文件。要为您的项目创建一个package.json文件,您可以使用npm init命令。只需在您想要项目存在的文件夹中运行它:

$ cd sample_npm
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items and tries to guess sensible defaults.
See 'npm help json' for definitive documentation on these fields and exactly what they do.
Use 'npm install <pkg>' afterwards to install a package and save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name: (sample_npm) 
version: (1.0.0) 
description: Sample project for the Professional JavaScript.
entry point: (index.js) 
test command: 
git repository: https://github.com/TrainingByPackt/Professional-JavaScript/
keywords: 
author: 
license: (ISC) MIT
About to write to .../Lesson02/sample_npm/package.json:
{
  "name": "sample_npm",
  "version": "1.0.0",
  "description": "Sample project for the Professional JavaScript.",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/TrainingByPackt/Professional-JavaScript.git"
  },
  "author": "",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/TrainingByPackt/Professional-JavaScript/issues"
  },
  "homepage": "https://github.com/TrainingByPackt/Professional-JavaScript#readme"
}
Is this OK? (yes) yes

该命令将询问您一些问题,指导您创建package.json文件。最后,它将打印生成的文件并要求您确认。它包含关于项目的所有信息,包括代码的位置、使用的许可证以及作者是谁。

现在我们有了一个 npm 包,我们可以开始寻找可以使用的外部模块。让我们去npmjs.com寻找一个帮助我们解析命令行参数的包。在搜索框中输入command line并按Enter键,我们会得到一个包选择列表:

图 2.4:搜索一个包来帮助我们构建一个命令行应用程序

图 2.4:搜索一个包来帮助我们构建一个命令行应用程序

由于我们正在寻找一个工具来帮助我们解析命令行参数,commander听起来像是一个不错的解决方案。它的简短描述是node.js 命令行程序的完整解决方案。让我们在一个应用程序中安装它,并使用它来理解这个流程是如何工作的。

要将包添加为您的包的依赖项,您可以从命令行请求 npm 按名称安装它:

$ npm install commander
npm notice created a lockfile as package-lock.json. You should commit this file.
+ commander@2.20.0
added 1 package from 1 contributor and audited 1 package in 1.964s
found 0 vulnerabilities

您可以看到 npm 找到了该包并下载了最新版本,截至本文撰写时为2.20.0。它还提到了关于package-lock.json文件的一些内容。我们将稍后更多地讨论这个问题,所以现在不用担心它。

最近添加到 npm 的另一个很酷的功能是漏洞检查。在install命令输出的末尾,您可以看到有关发现的漏洞的注释,或者更好的是,没有发现漏洞。npm 团队正在努力增加对其存储库中所有包的漏洞检查和安全扫描。

注意

从 npm 使用包是如此简单,以至于很多人都在向那里推送恶意代码,以捕捉最不注意的开发人员。强烈建议您在从 npm 安装包时要非常注意。检查拼写、下载次数和漏洞报告,并确保您要安装的包确实是您想要的。您还需要确保它来自可信任的方。

运行npm install后,您会注意到package.json文件中添加了一个新的部分。它是dependencies部分,包含您刚刚请求的包:

"dependencies": {
  "commander": "².20.0"
}

这就是install命令输出中commander前面的+号的含义:该包已作为项目的依赖项添加。

dependencies部分用于自动检测和安装项目所需的所有包。当您在一个具有package.json文件的 Node.js 应用程序上工作时,您不必手动安装每个依赖项。您只需运行npm install,它将根据package.json文件的dependencies部分自动解决所有问题。这里是一个例子:

$ npm install
added 1 package from 1 contributor and audited 1 package in 0.707s
found 0 vulnerabilities

尽管没有指定任何包,npm 假定您想要安装当前包的所有依赖项,这些依赖项来自package.json

除了向package.json文件添加dependencies部分之外,它还创建了一个node_modules文件夹。那是它下载并保留项目所有包的地方。您可以使用列表命令(ls)检查node_modules中的内容:

$ ls node_modules/
commander
$ ls node_modules/commander/
CHANGELOG.md  LICENSE   Readme.md   index.js    package.json  typings

如果您再次运行npm install来安装 commander,您会注意到 npm 不会再次安装该包。它只显示该包已更新和已审核:

$ npm install commander
+ commander@2.20.0
updated 1 package and audited 1 package in 0.485s
found 0 vulnerabilities

在下一个练习中,我们将构建一个使用 commander 作为依赖项的 npm 包,然后创建一个命令行 HTML 生成器。

练习 10:创建一个命令行 HTML 生成器

现在您已经学会了使用 npm 创建包以及如何安装一些依赖项的基础知识,让我们把这些知识整合起来,构建一个可以为您的下一个网站项目生成 HTML 模板的命令行工具。

在这个练习中,您将创建一个 npm 包,该包使用 commander 作为处理命令行参数的依赖项。然后,您将探索您创建的工具,并生成一些 HTML 文件。

此练习的代码可以在 GitHub 上找到,网址为github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson02/Exercise10

执行以下步骤以完成此练习:

  1. 创建一个新的文件夹,您将在其中放置此练习的所有文件。

  2. 在命令行中,切换到新文件夹并运行npm init来初始化一个package.json文件。选择所有默认选项应该就足够了:

$ npm init
This utility will walk you through creating a package.json file.
...
Press ^C at any time to quit.
package name: (Exercise10) 
version: (1.0.0) 
...
About to write to .../Lesson02/Exercise10/package.json:
{
  "name": "Exercise10",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
Is this OK? (yes)
  1. 安装commander包作为依赖项:
$ npm install commander
npm notice created a lockfile as package-lock.json. You should commit this file.
+ commander@2.20.0
added 1 package from 1 contributor and audited 1 package in 0.842s
found 0 vulnerabilities

在您的package.json中,添加以下内容:

"main": "index.js"

这意味着我们应用程序的入口点是index.js文件。

  1. 运行一个具有入口点的 npm 包,并使用node命令,传递包含package.json文件的目录。以下是一个在Lesson02/sample_npm中运行该包的示例,该示例可在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson02/sample_npm上找到:
$ node sample_npm/
I'm an npm package running from sample_npm
  1. 创建一个名为index.js的文件,在其中使用require函数加载commander包:
const program = require('commander');

这就是您开始使用外部包所需要的全部内容。

Commander 解析传入 Node.js 应用程序的参数。您可以配置它告诉它您期望的参数类型。对于这个应用程序,我们将有三个选项:-b--add-bootstrap,它将在生成的输出中添加 bootstrap 4;-c--add-container,它将在 body 中添加一个带有 ID container 的<div>标签;以及-t--title,它将在页面上添加一个接受标题文本的<title>

  1. 配置 commander,我们调用 version 方法,然后多次调用 option 方法来添加应用程序将支持的每个选项。最后,我们调用parse,它将验证传入的参数(process.argv将在下一章详细讨论)是否与预期的选项匹配:
program.version('0.1.0')
  .option('-b, --add-bootstrap', 'Add Bootstrap 4 to the page.')
  .option('-c, --add-container', 'Adds a div with container id in the body.')
  .option('-t, --title [title]', 'Add a title to the page.')
  .parse(process.argv);
  1. 现在,您可以运行您的应用程序并查看到目前为止的结果:
$ node . –help

我们将收到以下输出:

Usage: Exercise10 [options]
Options:
  -V, --version        output the version number
  -b, --add-bootstrap  Add Bootstrap 4 to the page.
  -c, --add-container  Adds a div with container id in the body.
  -t, --title [title]  Add a title to the page.
  -h, --help           output usage information

您可以看到 commander 为您提供了一个很好的帮助消息,解释了您的工具应该如何使用。

  1. 现在,让我们使用这些选项来生成 HTML。我们需要做的第一件事是声明一个变量,用于保存所有的 HTML:
let html = '<html><head>';

我们可以使用<html><head>开放标签来初始化它。

  1. 然后,检查程序是否接收到title选项。如果是,就添加一个带有传入标签内容的<title>标签:
if (program.title) {
  html += `<title>${program.title}</title>`;
}
  1. 对于Bootstrap选项也是同样的操作。在这种情况下,选项只是一个布尔值,因此您只需检查并添加一个指向Bootstrap.css文件的<link>标签:
if (program.addBootstrap) {
  html += '<link';
  html += ' rel="stylesheet"';
  html += ' href="https://stackpath.bootstrapcdn.com';
  html += '/bootstrap/4.3.1/css/bootstrap.min.css"';
  html += '/>';
}
  1. 关闭<head>标签并打开<body>标签:
html += '</head><body>';
  1. 检查容器<div>选项,并在启用时添加它:
if (program.addContainer) {
  html += '<div id="container"></div>';
}
  1. 最后,关闭<body><html>标签,并将 HTML 打印到控制台:
html += '</body></html>';
console.log(html);
  1. 不带任何选项运行应用程序将给我们一个非常简单的 HTML:
$ node .
<html><head></head><body></body></html>
  1. 运行应用程序,启用所有选项:
$ node . -b -t Title -c
This will return a more elaborate HTML:
<html><head><title>Title</title><link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"/></head><body><div id="container"></div></body></html>

npm 使得在您的应用程序中使用包变得非常容易。像 commander 和 npm 存储库中的其他数以千计的包使得 Node.js 成为构建功能强大且复杂的应用程序的绝佳选择,而代码量却很少。探索和学习如何使用包可以为您节省大量时间和精力,这将决定一个项目是否能够成功应用于数百万用户。

在这个练习中,您创建了一个 npm 包,使用外部包来解析命令行参数,这通常是一项费力的任务。您已经配置了 commander 来将参数解析为一个很好的可用格式,并学会了如何使用解析后的参数来构建一个根据用户输入做出决策的应用程序。

依赖项

在上一节中,我们看到 npm 如何使用package.json文件的dependencies部分来跟踪您的包的依赖关系。依赖关系是一个复杂的话题,但您必须记住的是,npm 支持语义版本或 semver 格式的版本号,并且它可以使用区间和其他复杂的运算符来确定您的包可以接受其他包的哪些版本。

默认情况下,正如我们在上一个练习中看到的,npm 使用插入符号标记所有包版本,例如 2.20.0。该插入符号表示您的包可以使用与 2.20.0 兼容的任何版本。在语义版本的意义上,兼容性意味着新的次要或补丁版本被认为是有效的,因为它们是向后兼容的:

图 2.5:将次要和补丁版本视为有效的语义格式

图 2.5:将次要和补丁版本视为有效的语义格式

与 2.20.0 兼容的版本是 2.21.0 或 2.21.5,甚至是 2.150.47!

偶尔,您可能希望更新您的软件包版本,以提高安全性或转移到具有解决某些依赖项中出现的问题的版本。这就是为什么 npm 为您安装的软件包版本添加了插入符号的原因。使用一个命令,您可以将所有依赖项更新为更新的兼容版本。

例如,很久以前启动的命令行应用程序使用的是 commander 的 2.0.0 版本。当开发人员运行install命令时,他们在package.json文件中得到了 2.0.0 版本。几年后,他们回过头来注意到 commander 中存在一些安全漏洞。他们只需运行npm update命令来解决这个问题:

$ npm update
+ commander@2.20.0
added 1 package from 1 contributor and audited 1 package in 0.32s
found 0 vulnerabilities

大多数情况下,开发人员遵循语义版本控制规范,并不会在次要或补丁版本更改时进行破坏性更改。但是,随着项目的增长,依赖项的数量很快就会达到成千上万,破坏性更改或兼容性问题的概率呈指数级增长。

为了帮助您在出现复杂的依赖树时,npm 还会生成一个package-lock.json文件。该文件包含了您的node_modules目录中的软件包的表示,就像您上次更改依赖包时一样。当您使用install命令安装新依赖项或使用update命令更新版本时,就会发生这种情况。

package-lock.json文件应该与您的其他代码一起检查,因为它跟踪您的依赖树,并且对于调试复杂的兼容性问题非常有用。另一方面,node_modules应该始终添加到您的.gitignore文件中,因为 npm 可以使用来自您的package.jsonpackage-lock.json文件的信息随时重新创建该文件夹,并从 npm 存储库下载包。

除了dependencies部分,您的package.json文件还可以包含一个devDependencies部分。这个部分是开发人员在构建或测试包时使用的依赖项,但其他人不需要。这可以包括诸如babel之类的工具来转译代码,或者诸如jest之类的测试框架。

devDependencies中的依赖项在其他包使用时不会被拉取。一些框架,如 Webpack 或Parcel.js,也有一个生产模型,将在创建最终捆绑包时忽略这些依赖项。

npm 脚本

当您运行npm init命令时,创建的package.json文件中将包含一个scripts部分。默认情况下,会添加一个测试脚本。它看起来像这样:

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

脚本可用于运行开发人员在处理软件包时可能需要的任何类型的命令。脚本的常见示例包括测试、linting 和其他代码分析工具。还可以有脚本来启动应用程序或从命令行执行其他任何操作。

要定义一个脚本,您需要在scripts部分添加一个属性,其中值是将要执行的脚本,如下所示:

"scripts": {
  "myscript": "echo 'Hello world!'"
},

上述代码创建了一个名为myscript的脚本。当调用时,它将打印文本“Hello World!”。

要调用一个脚本,您可以使用npm run或 run-script 命令,传入脚本的名称:

$ npm run myscript
> sample_scripts@1.0.0 myscript .../Lesson02/sample_scripts
> echo 'Hello World!'
Hello World!

npm 将输出正在执行的所有细节,以让您知道它在做什么。您可以使用--silent(或-s)选项要求它保持安静:

$ npm run myscript --silent
Hello World!
$ npm run myscript -s
Hello World!
$ npm run-script myscript -s
Hello World!

关于脚本的一个有趣的事情是,您可以使用前缀“pre”和“post”在设置和/或清理任务之前和之后调用其他脚本。以下是这种用法的一个例子:

"scripts": {
  "preexec": "echo 'John Doe' > name.txt",
  "exec": "node index.js",
  "postexec": "rm -v name.txt"
}

index.js是一个 Node.js 脚本,它从name.txt文件中读取名称并打印一个 hello 消息。exec脚本将执行index.js文件。在执行之前和之后,将自动调用预和后exec脚本,创建和删除name.txt文件(在 Windows 中,您可以使用del命令而不是rm)。运行 exec 脚本将产生以下输出:

$ ls
index.js package.json
$ npm run exec
> sample_scripts@1.0.0 preexec ../Lesson02/sample_scripts
> echo 'John Doe' > name.txt
> sample_scripts@1.0.0 exec ../Lesson02/sample_scripts
> node index.js
Hello John Doe!
> sample_scripts@1.0.0 postexec ../Lesson02/sample_scripts
> rm -v name.txt
name.txt
$ ls
index.js        package.json

您可以看到,在调用 exec 脚本之前,name.txt文件不存在。调用preexec脚本,它将创建带有名称的文件。然后调用 JavaScript 并打印 hello 消息。最后,调用postexec脚本,它将删除文件。您可以看到,在 npm 执行完成后,name.txt文件不存在。

npm 还带有一些预定义的脚本名称。其中一些是 published,install,pack,test,stop 和 start。这些预定义名称的优势在于您不需要使用runrun-script命令;您可以直接按名称调用脚本。例如,要调用由npm init创建的默认测试脚本,只需调用npm test

$ npm test
> sample_scripts@1.0.0 test .../Lesson02/sample_scripts
> echo "Error: no test specified" && exit 1
Error: no test specified
npm ERR! Test failed.  See above for more details.

在这里,您可以看到它失败了,因为它有一个exit 1命令,这使得 npm 脚本的执行失败,因为任何以非零状态退出的命令都会立即使调用停止。

start是一个广泛使用的脚本,用于启动本地前端开发的 Web 服务器。前面代码中的 exec 示例可以重写为以下形式:

"scripts": {
  "prestart": "echo 'John Doe' > name.txt",
  "start": "node index.js",
  "poststart": "rm -v name.txt"
}

然后,只需调用npm start即可运行:

$ npm start
> sample_scripts@1.0.0 prestart .../Lesson02/sample_scripts
> echo 'John Doe' > name.txt
> sample_scripts@1.0.0 start .../Lesson02/sample_scripts
> node index.js
Hello John Doe!
> sample_scripts@1.0.0 poststart .../Lesson02/sample_scripts
> rm -v name.txt
name.txt

注意

编写 npm 脚本时要牢记的一件重要事情是是否有必要使它们独立于平台。例如,如果您正在与一大群开发人员一起工作,其中一些人使用 Windows 机器,另一些人使用 Mac 和/或 Linux,那么在 Windows 中编写的脚本可能会在 Unix 世界中失败,反之亦然。JavaScript 是这种情况的完美用例,因为 Node.js 为您抽象了平台依赖性。

正如我们在上一章中看到的,有时我们想从网页中提取数据。在那一章中,我们使用了一些 JavaScript,它是从开发者工具控制台选项卡中注入到页面中的,这样就不需要为此编写应用程序。现在,您将编写一个 Node.js 应用程序来做类似的事情。

活动 3:创建一个用于解析 HTML 的 npm 包

在这个活动中,您将使用 npm 创建一个新的包。然后,您将编写一些 Node.js 代码来使用名为cheerio的库加载和解析 HTML 代码。有了加载的 HTML,您将查询和操作它。最后,您将打印操作后的 HTML 以查看结果。

执行的步骤如下:

  1. 使用 npm 在新文件夹中创建一个新包。

  2. 使用npm installwww.npmjs.com/package/cheerio)安装一个名为cheerio的库。

  3. 创建一个名为index.js的新条目文件,并在其中加载cheerio库。

  4. 创建一个变量,用于存储第一章,JavaScript,HTML 和 DOM中第一个示例的 HTML(文件可以在 GitHub 上找到:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Example/sample_001/sample-page.html)。

  5. 使用 cheerio 加载和解析 HTML。

  6. 在加载的 HTML 中的div中添加一个带有一些文本的段落元素。

  7. 使用 cheerio,迭代当前页面中的所有段落,并将它们的内容打印到控制台。

  8. 打印控制台的操作版本。

  9. 运行您的应用程序。

输出应该看起来像下面这样:

图 2.6:从 node.js 调用应用程序后的预期输出

图 2.6:从 Node.js 调用应用程序后的预期输出

注意

此活动的解决方案可在第 588 页找到。

在本活动中,你使用 npm init 命令创建了一个 Node.js 应用程序。然后,你导入了一个 HTML 解析库,用它来操作和查询解析后的 HTML。在下一章中,我们将继续探索技术,帮助我们更快地抓取网页,并且我们将实际应用于一个网站。

总结

在本章中,我们了解了 Node.js 是什么,以及它的单线程、异步、事件驱动的编程模型如何用于构建简单高效的应用程序。我们还学习了 nvm 以及如何管理多个 Node.js 版本。然后,我们学习了 npm,并在我们的 Node.js 应用程序中使用了外部库。最后,我们学习了 npm 脚本以及与其相关的一些基本概念。

为了帮助你理解本章学到的内容,你可以去 npm 仓库,找一些项目,探索它们的代码库。了解 npm、Node.js 以及存在的包和库的最佳方法是探索其他人的代码,看看他们是如何构建的,以及他们使用了哪些库。

在下一章中,我们将探索 Node.js 的 API,并学习如何使用它们来构建一个真正的网页抓取应用程序。在未来的章节中,你将学习如何使用 npm 脚本和包来通过 linting 和自动化测试来提高代码质量。

第四章:Node.js API 和 Web 抓取

学习目标

在本章结束时,您将能够:

  • 使用全局对象实现 Node.js 应用程序

  • 创建可读和可写流

  • 使用异步和同步 API 读写文件

  • 使用 http 模块创建静态和动态 Web 服务器

  • 使用 http/https 模块从网站下载内容

  • 查询和提取解析后的 HTML 内容中的数据

在本章中,我们将学习全局对象和函数。然后,我们将学习如何使用 http 模块编写高效的 Web 服务器,包括静态和动态的。最后,我们将使用 http 和 https 模块来抓取网页并从中提取数据。

介绍

从一开始,Node.js 就被创建为第一代 HTTP 服务器的每个请求模型的替代方案。Node.js 的事件循环和异步特性使其非常适合需要为大量并发客户端提供高吞吐量的 I/O 密集型服务器。因此,它配备了强大且易于使用的 API,可以直接构建 HTTP 服务器。

在上一章中,我们讨论了 Node.js 和 NPM 是什么以及它们是如何工作的。在本章中,您将了解 Node.js 中每个脚本都可以使用的基本全局对象。您将学习可读和可写流,以及如何使用它们来异步读写文件。您还将学习如何使用同步文件系统 API 来读写文件。

在最后几节中,您将学习如何使用 HTTP 模块来编写 Web 服务器和发起 HTTP 请求。您将构建一个静态和一个动态 Web 服务器。然后,您将学习 Web 抓取的基础知识,以及如何使用它来从网站中提取数据。

全局对象

Node.js 执行上下文包含一些全局变量和函数,可以在任何脚本中的任何地方使用。其中最常用的是require函数,因为它是帮助您加载其他模块并访问来自 Node.js API 的非全局函数、类和变量的函数。

您一定注意到了在上一章中使用了这个函数,当我们从您的应用程序中安装的包中加载commander模块时:

const program = require('commander');

它接收一个参数,这个参数是一个表示您想要加载的模块的 ID 的字符串,并返回模块的内容。内部模块,比如我们将在本章讨论的模块,以及从 npm 安装的包中加载的模块,都可以直接通过它们的名称来识别,比如 commander、fs 和 http。在第五章,模块化 JavaScript中,您将学习如何创建自己的模块,以及如何使用这个函数来加载它们。

另一个重要且广泛使用的全局对象是控制台。就像在 Chrome 开发者工具中一样,控制台可以用来使用标准输出和标准错误将文本打印到终端。它也可以用来将文本打印到文件进行日志记录。

到目前为止,您已经多次使用了控制台,比如在上一章的最后一个练习中,您打印了以下操作过的 HTML:

console.log(html);

控制台不仅仅只有log函数。让我们更深入地了解一些它的应用。

当您想要将一些文本打印到控制台时,您可以使用以下任何一个函数:debugerrorinfowarn。它们之间的区别在于文本的输出位置。当您使用debuginfo方法时,文本将被打印到标准输出。对于warnerror,消息将被打印到标准错误。

确保您在index.js中有以下代码:

console.debug('This will go to Standard Output');
console.info('This will also go to Standard Output');
console.warn('This will go to standard error');
console.error('Same here');

现在,运行脚本并重定向到不同的文件,然后打印它们的内容:

$ node index.js > std.out 2> err.out
$ cat std.out 
This will go to Standard Output
This will also go to Standard Output
$ cat err.out 
This will go to standard error
Same here

所有前面的函数以及 log 函数都可以根据需要格式化文本,方法是提供额外的参数和格式字符串。您可以在util.format函数文档中阅读更多关于格式字符串的信息:nodejs.org/dist/latest-v12.x/docs/api/util.html#util_util_format_format_args。如果愿意,您也可以使用反引号:

const whatILike = 'cheese';
console.log('I like %s', whatILike);
console.log(`I like ${whatILike}`);

输出将如下所示:

I like cheese
I like cheese

如果需要有条件地打印一些文本,可以使用assert。Assert 可用于检查条件是否为真。如果为假,则它将使用console.warn打印文本,并解释断言失败的原因。如果为真,则不会打印任何内容。以下是一个示例:

console.assert(1 == 1, 'One is equal to one');
console.assert(2 == 1, 'Oh no! One is not equal to two');

这将只输出以下内容:

Assertion failed: Oh no! One is not equal to two

trace函数用于标识输出的源文件和行。它接收与 log 和其他函数相同的参数,但它还将打印日志语句的堆栈跟踪;也就是调用发生的文件名和行:

console.trace('You can easily find me.');

这将打印以下内容:

Trace: You can easily find me.
    at Object.<anonymous> (.../Lesson03/sample_globals/console.js:14:9)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions.js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)

如果您有一组数据并希望将其显示为表格,可以使用 table 方法。它接收两个参数:表格数据和您希望在表格中看到的属性。例如,考虑以下表格数据(对象数组):

const myTable = [
  { name: 'John Doe', age: 10 },
  { name: 'Jane Doe', age: 17 },
];

您可以通过将数据传递给console.table来打印所有列:

console.table(myTable);

这将给我们以下输出:

图 3.1:console.table 函数的输出

图 3.1:console.table 函数的输出

或者,您可以传递要显示的属性名称列表:

console.table(myTable, ['name']);

以下是前面代码的输出:

图 3.2:当传递要打印的属性列表时,console.table 的输出

您还可以使用console来计算代码中特定部分运行所需的时间。为此,您可以使用timetimeEnd方法,如下例所示:

console.time();
blockFor2Seconds();
console.timeEnd();

这将输出以下内容:

default: 2000.911ms

您还可以为计时器命名,并同时使用多个计时器:

console.time('Outer');
console.time('Inner');
blockFor2Seconds();
console.timeEnd('Inner');
console.timeEnd('Outer');

这将输出以下内容:

Inner: 2000.304ms
Outer: 2000.540ms

有时,您想知道脚本是从哪里加载的,或者文件的完整路径是什么。为此,每个脚本都有两个全局变量:__filename__dirname(两个下划线,然后是文件名/目录名)。示例如下:

console.log(`This script is in: ${__dirname}`);
console.log(`The full path for this file is: ${__filename}`);

这将输出以下内容:

This script is in: /.../Lesson03/sample_globals
The full path for this file is: /.../Lesson03/sample_globals/dir_and_filename.js

在浏览器中,当您想要在将来的某个时间执行特定函数或定期执行时,可以分别使用setTimeoutsetInterval。这些函数也在 Node.js 执行上下文中可用,并且与在浏览器中的工作方式相同。

您可以通过将回调函数传递给它以及您希望它在未来的毫秒数中执行的时间量来安排代码在未来的某个时间执行:

const start = Date.now();
setTimeout(() => {
  console.log('I'm ${Date.now() - start}ms late.');
}, 1000);

在浏览器中,setTimeout返回一个定时器 ID,这是一个整数,除了通过clearTimeout函数取消定时器外,不能做更多事情。在 Node.js 中,setTimeout返回一个Timeout对象,它本身具有一些方法。一个有趣的方法是refresh方法,它将定时器的开始时间重置为当前时间,并重新开始计时,就好像它是在那一刻被安排的一样。看看下面的示例代码:

const secondTimer = setTimeout(() => {
  console.log(`I am ${Date.now() - start}ms late.');
}, 3000);
setTimeout(() => {
  console.log(`Refreshing second timer at ${Date.now() - start}ms`);
  secondTimer.refresh();
}, 2000);

这打印以下内容:

Refreshing second timer at 2002ms
I am 5004ms late.

从输出中可以看出,即使secondTimer被安排在未来 3 秒运行,它实际上是在未来 5 秒运行。这是因为第二个setTimeout设置为 2 秒,刷新了它,重新从那个时间开始计时,将 2 秒添加到 3 秒计时器上。

如前所述,您可以使用Timeout实例使用clearTimeout函数取消定时器。以下代码是此示例:

const thirdTimer = setTimeout(() => {
  console.log('I am never going to be executed.');
}, 5000);
setTimeout(() => {
  console.log('Cancelling third timer at ${Date.now() - start}ms');
  clearTimeout(thirdTimer);
}, 2000);

此代码的输出将如下所示:

Cancelling third timer at 2007ms

setTimeout只执行一次。您可以使用setInterval每隔一段时间执行特定任务。setInterval还返回一个Timeout实例,可以使用clearInterval取消定时器。以下示例设置了一个定时器,每秒运行一次,并跟踪它运行的次数。在一定数量的执行之后,它会取消定时器:

let counter = 0;
const MAX = 5;
const start = Date.now();
const timeout = setInterval(() => {
  console.log(`Executing ${Date.now() - start}ms in the future.`);
  counter++
  if (counter >= MAX) {
    console.log(`Ran for too long, cancelling it at ${Date.now() - start}ms`);
    clearInterval(timeout);
  }
}, 1000);

此代码的输出看起来像以下内容:

Executing 1004ms in the future.
Executing 2009ms in the future.
Executing 3013ms in the future.
Executing 4018ms in the future.
Executing 5023ms in the future.
Ran for too long, cancelling it at 5023ms

在浏览器中,我们有一个称为 window 的全局对象,代表浏览器。在 Node.js 中,我们有 process,它代表当前运行的应用程序。通过它,我们可以访问传递给应用程序的参数,包括标准输入和输出以及有关进程的其他信息,例如版本或进程 ID。

要访问传递给进程的参数,可以使用全局变量 process 的argv属性。argv是一个包含每个参数的数组。它包括 Node.js 二进制文件的路径和脚本的完整路径作为前两个元素。之后,所有其他额外的参数都被传递进来。

以下代码将打印传入的所有参数,每个参数一行:

console.log(`Arguments are:\n${process.argv.join('\n')}`);

让我们来看看这个单行应用程序的一些示例输出。

无额外参数:

$ node argv.js 
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js

许多参数一个接一个地分开:

$ node argv.js this is a test
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js
this
is
a
test

一个参数都在一个字符串中:

$ node argv.js 'this is a test'
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js
this is a test

在上一章中,我们使用了commander库来解析命令行参数。在配置commander时,对它的最后一次调用是parse(process.argv),这使commander可以访问传入的所有选项:

program.version('0.1.0')
  .option('-b, --add-bootstrap', 'Add Bootstrap 4 to the page.')
  .option('-c, --add-container', 'Adds a div with container id in the body.')
  .option('-t, --title [title]', 'Add a title to the page.')
  .parse(process.argv);

process 变量扮演的另一个重要角色是访问标准输入和输出。如果要向控制台打印内容,可以使用stdoutstderr。这两个属性是控制台中的console.log和所有其他方法在内部使用的。不同之处在于stdoutstderr在每次调用时不会在末尾添加新行,因此如果希望每个输出都进入自己的行,您必须自己添加新行:

process.stdout.write(`You typed: '${text}'\n`);
process.stderr.write('Exiting your application now.\n');

这是两个示例,打印出以换行结束的内容。在大多数情况下,建议使用控制台,因为它可以提供一些额外的东西,例如日志级别和格式化。

如果要从命令行读取输入,可以使用process.stdinstdin是一个流,我们将在下一节中更多地讨论。现在,您只需要知道流是基于事件的。这意味着当输入进来时,它将以数据事件的形式到达。要从用户那里接收输入,您需要监听该事件:

process.stdin.addListener('data', (data) => {
  ...
});

当没有更多的代码需要执行时,事件循环将阻塞,等待标准输入的输入。当读取输入时,它将作为字节缓冲传递到回调函数中。您可以通过调用其toString方法将其转换为字符串,如下面的代码所示:

const text = data.toString().trim();

然后,您可以像普通字符串一样使用它。以下示例应用程序演示了如何使用stdoutstderrstdin从命令行请求用户输入:

process.stdout.write('Type something then press [ENTER]\n');
process.stdout.write('> ');
process.stdin.addListener('data', (data) => {
  const text = data.toString().trim();
  process.stdout.write('You typed: '${text}'\n');
  if (text == 'exit') {
    process.stderr.write('Exiting your application now.\n');
    process.exit(0);
  } else {
    process.stdout.write('> ');
  }
});

以下代码显示了在运行应用程序并输入一些单词,按Enter,然后输入“exit”以退出应用程序后的样子:

$ node read_input.js 
Type something then press [ENTER]
> test
You typed: 'test'
> something
You typed: 'something'
> exit
You typed: 'exit'
Exiting your application now.

在前面的代码中,您可以看到当用户输入“exit”时,它执行应用程序代码的特殊分支,调用process.exit,这是一个退出整个进程并返回指定退出代码的函数。

练习 11:创建任务提醒应用程序

在这个练习中,我们将创建一个任务提醒应用程序。现在我们已经学会了如何使用全局变量 process 与用户进行交互,还学会了如何创建定时器,让我们编写一个应用程序,利用这些新技能来管理命令行中的提醒。

应用程序将接收用户输入并收集信息以构建提醒。它将使用消息、时间单位和一定的时间。应用程序的输入将分阶段提供。每个阶段都会要求用户输入一些内容,收集它,验证它,然后设置一个变量的值以进入下一个阶段。

执行以下步骤完成此练习:

  1. 在一个空文件夹中,使用npm init创建一个新的包,并创建一个名为index.js的文件。在index.js文件中,我们将首先添加一些常量和变量,用于存储创建计时器的状态:
// Constants to calculate the interval based on time unit
const timeUnits = ['Seconds', 'Minutes', 'Hours'];
const multipliers = [1000, 60 * 1000, 3600 * 1000];
// Variables that will store the application state
let amount = null;
let message = null;
let timeUnit = null;
// Alias to print to console
const write = process.stdout.write.bind(process.stdout);
  1. 接下来,我们将添加应用程序的核心函数。该函数如下所示:
function processInput(input) {
  // Phase 1 - Collect message
  if (message == null) {
    askForMessage(input);
    input = null;
  }
  // Phase 2 - Collect time unit
  if (message != null && timeUnit == null) {
    askForTimeUnit(input);
    input = null;
  }
  // Phase 3 - Collect amount of time
  if (timeUnit != null && amount == null) {
    askForAmount(input);
  }
}

该函数处理用户的所有输入,根据当前状态的一组条件进行处理,根据已经可用的变量。处理输入后,将其设置为 null,以便可以执行下一个阶段。

前面的函数调用了一些尚不存在的函数:askForMessageaskForTimeUnitaskForAmount。这些函数负责验证输入并根据每个阶段设置变量,以便代码可以进入下一个阶段。

  1. 添加一些细节到askForMessage函数。该函数首先检查输入是否为 null,这意味着它正在首次更改阶段。这意味着它需要为用户打印输入提示。

代码如下所示:

function askForMessage(input) {
  if (input == null) {
    write('What do you want to be reminded of? > ');
    return;
  }
  if (input.length == 0) {
    write('Message cannot be empty. Please try again. > ');
    return;
  }
  message = input;
}

如果输入不是null,这意味着用户已经为当前状态输入了信息,需要进行验证。如果验证失败,打印更多信息并等待下一个输入。

如果输入有效,则设置当前状态的变量,这种情况下是message,这将使代码进入下一个阶段。

  1. 接下来,我们创建askForTimeUnit函数,这是处理代码的下一个阶段的函数。该函数使用第一步列出的常量来打印支持的时间单位,并让用户选择一个。它的工作方式类似于askForMessage函数:promptvalidateset value
function askForTimeUnit(input) {
  if (input == null) {
    console.log('What unit?');
    timeUnits.forEach((unit, index) => console.log('${index + 1} - ${unit}') );
    write('> ');
    return;
  }
  const index = parseInt(input, 10);
  if (isNaN(index) || index <= 0 || index > timeUnits.length) {
    write(`Sorry, '${input}' is not valid. Please try again. > `);
    return;
  }
 timeUnit = index - 1;
  console.log(`Picked: ${timeUnits[timeUnit]}`);
}
  1. 最后,我们创建askForAmount函数,处理最后一个阶段。该函数提示用户输入一定的时间来创建计时器。与之前一样,它有三个部分:promptvalidateset value
function askForAmount(input) {
  if (input == null) {
    write(`In how many ${timeUnits[timeUnit]}? > `);
    return;
  }
  const number = parseInt(input, 10);
  if (isNaN(number)) {
    write(`Sorry, '${input}' is not valid. Try again. > `);
    return;
  }
  amount = number;
  setTimerAndRestart();
}
  1. askForAmount函数的末尾,它调用setTimerAndRestart函数。让我们创建该函数,它创建计时器并重置所有状态,以便循环可以重新开始,并且用户可以创建新的计时器。该setTimerAndRestart函数如下所示:
function setTimerAndRestart() {
  const currentMessage = message;
  write(`Setting reminder: '${message}' in ${amount} ${unit} from now.\n`);
  let timerMessage = `\n\x07Time to '${currentMessage}'\n> `;
  setTimeout(() => write(timerMessage), amount * multipliers[timeUnit]);
  amount = message = timeUnit = null;
  askForMessage();
}

这里的一个重要部分是特殊字符\x07。这将导致您的终端发出哔哔声,然后打印消息中设置的文本。此外,文本经过特殊格式化,在开头和结尾都有换行,以便不会太大干扰工具的使用,因为计时器将在用户继续使用应用程序的同时打印。

  1. 应用程序的最后一部分需要在标准输入中注册数据事件的监听器,并通过询问用户消息来启动循环:
process.stdin.on('data', (data) => processInput(data.toString().trim()));
askForMessage();
  1. 现在,您可以从终端运行应用程序,设置一些提醒,并在计时器到期时听到它发出哔哔声:

图 3.3:运行应用程序后的输出

您会注意到退出应用程序的唯一方法是同时按下Ctrl+C键发送中断信号。作为额外的挑战,尝试添加一些代码,以创建一个退出点,使用户可以以更友好的方式退出。

处理用户输入对于每个命令行应用程序都是至关重要的。在这个练习中,您学会了如何掌握 Node.js 的异步特性,以便处理一组复杂的输入,引导用户在创建提醒的决策过程中。

文件系统 API

在上一节中,我们了解了在 Node.js 执行上下文中可用的全局变量。在本节中,我们将了解文件系统 API,这些 API 用于访问文件和目录,读取和写入文件等等。

但在我们深入研究文件系统 API 之前,我们需要了解流。在 Node.js 中,流是表示流数据的抽象接口。在上一节中,我们使用了标准 I/O,并简要提到它们是流,所以让我们详细了解它们。

流可以是可读的、可写的,或者两者兼有。它们是事件发射器,这意味着要接收数据,你需要注册事件监听器,就像我们在上一节中对标准输入所做的那样:

process.stdin.addListener('data', (data) => {
  ...
});

在下一节中,我们将继续建立对前几节的理解,并看到流被用作抽象来表示数据可以流经的所有东西,包括标准输入和输出、文件和网络套接字。

为了开始理解这是如何工作的,我们将编写一个应用程序,通过使用文件系统包中的createReadStream来读取自己的代码。要使用文件系统 API,我们需要导入它们,因为它们不是全局可用的:

const fs = require('fs');

然后,我们可以创建一个指向脚本文件本身的可读流:

const readStream = fs.createReadStream(__filename);

最后,我们注册流的事件,以便了解发生了什么。读取流有四个你应该关心的事件:ready、data、close 和 error。

Ready 会告诉你文件何时准备好开始读取,尽管当你创建一个指向文件的可读流时,它会在文件可用时立即开始读取文件。

数据,正如我们在标准输入中看到的,将通过传递从流中读取的数据作为字节缓冲区来调用。缓冲区需要通过调用它的toString方法或与另一个字符串连接来转换为字符串。

当所有字节都被读取完毕,流不再可读时,将调用 close。

如果在从流中读取时发生错误,将调用Error

以下代码演示了我们如何通过在控制台打印内容来注册事件:

readStream.on('data', (data) => console.log(`--data--\n${data}`));
readStream.on('ready', () => console.log(`--ready--`));
readStream.on('close', () => console.log(`--close--`));

该应用程序的输出如下:

$ node read_stream.js 
--ready--
--data--
const fs = require('fs');
const readStream = fs.createReadStream(__filename);
readStream.on('data', (data) => console.log(`--data--\n${data}`));
readStream.on('ready', () => console.log(`--ready--`));
readStream.on('close', () => console.log(`--close--`));
--close--

现在你知道如何读取文件和使用读取流,让我们更详细地了解可写流。你在上一节中看到了一些它们的用法,因为标准输出是一个可写流:

process.stdout.write('You typed: '${text}'\n');
process.stderr.write('Exiting your application now.\n');

write方法是可写流中最常用的方法。如果你想创建一个写入文件的可写流,你只需要传递文件名即可:

const fs = require('fs');
const writable = fs.createWriteStream('todo.txt');

然后,你可以开始写入它:

writable.write('- Buy milk\n');
writable.write('- Buy eggs\n');
writable.write('- Buy cheese\n');

不要忘记在最后添加换行符,否则所有内容将打印在同一行。

在写入文件完成后,调用end方法来关闭它:

writable.end();

可写流也有你可以监听的事件。最重要的两个事件是errorclose。当写入流时发生错误时,将触发error事件。当流关闭时,将调用close事件。还有finish事件,当调用end方法时将触发。以下代码是可以在 GitHub 上找到的示例代码的最后部分:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_filesystem/write_stream.js

writable.on('finish', () => console.log("-- finish --"));
writable.on('close', () => console.log("-- close --"));

运行应用程序后,你会看到它会创建todo.txt文件,并在其中包含预期的内容:

$ node write_stream.js 
-- finish --
-- close --
$ cat todo.txt 
- Buy milk
- Buy eggs
- Buy cheese

注意

创建一个指向文件的流将默认创建一个覆盖文件内容的流。要创建一个追加到文件的流,你需要传递一个带有"a"标志的选项对象,如追加一样:

const writable = fs.createWriteStream('todo.txt', { flags: 'a'});

关于流的另一个有趣的事情是你可以将它们连接起来。这意味着你可以将读取流中的所有字节发送到写入流中。你可以使用以下代码轻松地将一个文件的内容复制到另一个文件中:

const fs = require('fs');
fs.createReadStream('somefile.txt')
  .pipe(fs.createWriteStream('copy.txt'));

除了读写文件外,文件系统 API 还提供了方法,可以列出目录中的文件,检查文件状态,监视目录或文件的更改,复制,删除,更改文件权限等。

在处理文件系统操作时,你必须记住这些操作是异步的。这意味着所有操作都会接收一个回调函数,在操作完成时调用。例如,当创建目录时,你可以编写以下代码:

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  if (error != null) {
    console.error(`Error: ${error.message}`, error);
    return;
  }
  console.log(`Directory created: ${firstDirectory}`);
});

如果尝试创建目录时出现问题,例如目录已经存在,回调函数会接收一个错误参数。第一次运行代码会成功:

$ node directories_and_files.js
...
Directory created: first

但是当第二次运行时,它会失败,因为目录已经被创建了:

$ node directories_and_files.js 
Error: EEXIST: file already exists, mkdir 'first' { [Error: EEXIST: file already exists, mkdir 'first'] errno: -17, code: 'EEXIST', syscall: 'mkdir', path: 'first' }
...

如果你想在刚刚创建的目录中创建一个文件,你需要在传递给mkdir的回调函数中创建文件。以下方式可能会失败:

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  ...
});
fs.writeFile(`${firstDirectory}/test.txt`, 'Some content', (error) => {
  console.assert(error == null, 'Error while creating file.', error);
});

当你尝试运行它时会发生这种情况:

$ node directories_and_files.js 
Assertion failed: Error while creating file. { [Error: ENOENT: no such file or directory, open 'first/test.txt']
...

这是因为当调用writeFile时,目录可能还不存在。正确的做法是在传递给mkdir的回调函数中调用writeFile

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  ...
  fs.writeFile(`${firstDirectory}/test.txt`, 'Some content', (error) => {
    console.assert(error == null, 'Error while creating file.', error);
  });
});

由于处理前面的异步调用很复杂,并且并非所有情况都需要高性能的异步操作,在文件系统模块中,几乎所有操作都包括相同 API 的同步版本。因此,如果你想在目录中创建一个文件并在其中创建一些内容,而在目录不存在时应用程序没有其他事情可做,你可以按照以下方式编写代码:

const thirdDirectory = 'third';
fs.mkdirSync(thirdDirectory);
console.log(`Directory created: ${thirdDirectory}`);
const thirdFile = `${thirdDirectory}/test.txt`;
fs.writeFileSync(thirdFile, 'Some content');
console.log(`File created: ${thirdFile}`);

注意每个方法名称末尾的Sync单词。上述代码的输出如下:

$ node directories_and_files.js 
Directory created: third
File created: third/test.txt

在 Node.js 10 中,文件系统模块还添加了基于 Promise 的 API。关于 Promise 和其他处理异步操作的技术将在后续章节中讨论,所以我们暂时跳过这部分。

现在你知道如何创建目录和读写文件数据,让我们继续下一个最常用的文件系统操作:列出目录。

要列出目录中的文件,可以使用readdir方法。传递给函数的回调函数如果在尝试读取目录时出现问题,将会接收到一个错误对象和一个文件名列表。以下代码将打印当前目录中所有文件的名称:

fs.readdir('./', (error, files) => {
  if (error != null) {
    console.error('Error while reading directory.', error);
    return;
  }
  console.log('-- File names --');
  console.log(files.join('\n'));
});

这是一个示例输出:

$ node list_dir.js 
-- File names --
.gitignore
copy_file.js
directories_and_files.js
first
list_dir.js
read_stream.js
second
third
write_stream.js
...

但有时,你不仅仅想要文件名。在这里,readdir函数接受一个选项对象,可以提供withFileTypes标志。如果传递了该标志,那么回调函数得到的不是文件名,而是一个包含有关文件的额外信息的Dirents数组,例如它是目录还是文件。以下示例将打印当前目录中的文件名,并根据它是目录还是文件分别添加(D)或(F):

fs.readdir('./', { withFileTypes: true }, (error, files) => {
  if (error != null) {
    console.error('Error while reading directory.', error);
    return;
  }
  console.log('-- File infos --');
  console.log(files.map(d => `(${d.isDirectory() ? 'D': 'F'}) ${d.name}`)
    .sort()
    .join('\n'));
});

示例输出如下:

$ node list_dir.js 
...
-- File infos --
(D) first
(D) second
(D) third
(F) .gitignore
(F) copy_file.js
(F) directories_and_files.js
(F) list_dir.js
(F) read_stream.js
(F) write_stream.js

文件系统 API 的最后一个重要操作是如何检查文件状态。如果你只需要知道文件是否存在且可读,可以使用access函数,它接收文件路径和一组状态标志来检查。如果文件状态与指定的标志匹配,那么错误将不会传递给回调函数。让我们看一个例子:

const fs = require('fs');
const filename = process.argv[2];
fs.access(filename, fs.constants.F_OK | fs.constants.R_OK, (error) => {
  if (error == null) {
    console.log('File exists and is readable');
  } else {
    console.log(error.message);
  }
});

在这个例子中,我们结合了两个标志,F_OKR_OK。第一个检查文件是否存在,而第二个检查文件是否可读。你可以使用|(或)运算符组合多个标志。

执行上述代码后,如果文件存在,你会看到以下输出:

$ node file_status.js test.txt 
File exists and is readable

如果文件不存在,那么你会看到以下输出:

$ node file_status.js not.txt 
ENOENT: no such file or directory, access 'not.txt'

最后,如果文件存在但不可读,你将收到以下消息:

$ node file_status.js not.txt 
EACCES: permission denied, access 'not.txt'

所有这些看起来很有趣,但如果你需要知道一个路径是文件还是目录,它是何时最后修改的等等,那么你需要使用lstat函数,它将返回一个 Stats 实例。Stats 包含了你需要了解的关于路径的一切。

以下示例检查路径是文件还是目录,它是何时创建和最后修改的,并将该信息打印到控制台:

fs.lstat(filename, (statError, stat) => {
  if (statError != null) {
    console.error('Error while file status.', statError);
    return;
  }
  console.log(`Is file: ${stat.isFile()}`);
  console.log(`Is directory: ${stat.isDirectory()}`);
  console.log(`Created at: ${stat.birthtime}`);
  console.log(`Last modified at: ${stat.mtime}`);
});

这是一个示例输出:

$ node file_status.js first/test.txt 
...
Is file: true
Is directory: false
Created at: Tue Aug 13 2019 20:39:37 GMT-0400 (Eastern Daylight Time)
Last modified at: Tue Aug 13 2019 21:26:53 GMT-0400 (Eastern Daylight Time)

Globs 是包含路径部分的字符串,通配符*代表。当你有两个*时,例如**,这意味着任何目录或子目录。一个简单的例子是在当前目录的任何子目录中搜索所有的.txt文件:

$ search '**/*.txt'

练习 12:使用 Glob 模式通过目录搜索文件

在这个练习中,我们将创建一个应用程序,它将扫描目录树并根据 glob 搜索文件。为了实现这一点,我们将递归调用readdir函数的同步版本,并使用commanderglob-to-regexp模块来帮助我们处理用户的输入。

执行以下步骤完成这个练习:

  1. 在一个空目录中,使用npm init开始一个新的应用程序,并添加一个index.js文件,这将是我们的入口点。

  2. 安装我们将使用的两个外部模块:commanderglob-to-regexp。为此,执行npm install命令:

$ npm install commander glob-to-regexp
npm notice created a lockfile as package-lock.json. You should commit this file.
+ glob-to-regexp@0.4.1
+ commander@3.0.0
added 2 packages from 2 contributors and audited 2 packages in 0.534s
found 0 vulnerabilities
  1. index.js文件中,使用你喜欢的编辑器,在文件开头导入所有这个项目所需的模块:
const fs = require('fs');
const globToRegExp = require('glob-to-regexp');
const join = require('path').join;
const program = require('commander');

我们已经知道了fs和 commander 模块。globToRegExp模块和join函数将在接下来的步骤中进行解释。

  1. 初始化counterfound变量。这些将用于显示与正在执行的搜索相关的一些统计信息:
let counter = 0;
let found = 0;
const start = Date.now();
  1. 配置commander以接收 glob 作为参数,并为用户设置初始目录开始搜索的额外选项:
     program.version('1.0.0')
  .arguments('<glob>')
  .option('-b, --base-dir <dir>', 'Base directory to start the search.', './')
  .parse(process.argv);
  1. 在这个练习中,我们将使用递归函数来遍历目录树。walkDirectory函数调用readdirSync,并将withFileTypes标志设置为truewalkDirectory函数接收两个参数:要开始读取的路径和要为每个文件调用的回调函数。当找到一个目录时,它被传递给walkDirectory函数,以便递归继续:
function walkDirectory(path, callback) {
  const dirents = fs.readdirSync(path, { withFileTypes: true });
  dirents.forEach(dirent => {
    if (dirent.isDirectory()) {
      walkDirectory(join(path, dirent.name), callback);
    } else {
      counter++;
      callback(join(path, dirent.name));
   }
  });
}

当找到一个文件时,路径被传递给回调函数,并且计数器被递增。在这里,我们使用path.join函数将文件名连接到父路径,以重建文件的整个路径。

  1. 现在我们有了walkDirectory树函数,我们将验证传递给应用程序的参数:
const glob = program.args[0];
if (typeof glob === 'undefined') {
  program.help();
  process.exit(-1);
}
  1. 然后,我们使用globToRegExp模块将 glob 转换为RegExp,以便用于测试文件:
const matcher = globToRegExp(program.args[0], { globstar: true });
  1. 有了匹配器和遍历目录树函数,我们现在可以遍历目录树并测试我们找到的每个文件:
walkDirectory(program.baseDir, (f) => {
  if (matcher.test(f)) {
    found++;
    console.log(`${found} - ${f}`);
  }
});
  1. 最后,由于所有的代码都是同步执行的,在调用walkDirectory完成后,所有的目录和子目录都将被处理。现在,我们可以打印出我们找到的统计信息:

图 3.4:找到的文件的统计信息

图 3.4:找到的文件的统计信息

你可以通过在父目录中开始执行搜索:

图 3.5:在父目录中执行搜索

图 3.5:在父目录中执行搜索

在这个练习中,你学会了如何使用文件系统 API 来遍历目录树。你还使用了正则表达式来按名称过滤文件。

文件系统 API 为几乎每个应用程序提供了基础。学习如何同步和异步地使用它们对于后端世界中的任何事情都是至关重要的。在下一节中,我们将使用这些 API 来构建一个基本的 Web 服务器,以便向浏览器提供文件。

HTTP API

起初,Node.js 的目标是取代使用传统的每个连接一个线程模型的旧 Web 服务器。在线程每请求模型中,服务器保持一个端口开放,当新连接进来时,它使用线程池中的一个线程或创建一个新线程来执行用户请求的工作。服务器端的所有操作都是同步进行的,这意味着当从磁盘读取文件或从数据库中读取记录时,线程会休眠。以下插图描述了这个模型:

图 3.6:在线程每请求模型中,线程在 I/O 和其他阻塞操作发生时处于休眠状态

图 3.6:在线程每请求模型中,线程在 I/O 和其他阻塞操作发生时处于休眠状态

线程每请求模型的问题在于创建线程的成本很高,而当它们在有更多工作要做时处于休眠状态,这意味着资源的浪费。另一个问题是,当线程的数量高于 CPU 的数量时,它们开始失去并发的最宝贵的价值。

由于这些问题,使用线程每请求模型的 Web 服务器将拥有一个不够大的线程池,以便服务器仍然可以并行响应许多请求。并且因为线程数量是有限的,当并发用户发出请求的数量增加时,服务器会耗尽线程,用户现在必须等待:

图 3.7:当并发请求数量增加时,用户必须等待线程可用

Node.js 以其异步模型和事件循环,提出了这样一个观念:如果只有一个线程来执行工作并将阻塞和 I/O 操作移到后台,只有在数据可用于处理时才返回到它,那么您可以更加高效。当您需要进行数据密集型工作时,比如 Web 服务器,它主要从文件、磁盘和数据库中读取和写入记录时,异步模型变得更加高效。以下插图描述了这个模型:

图 3.8:带有事件循环的异步模型

图 3.8:带有事件循环的异步模型

当然,这个模型并不是万能的,在高负载和大量并发用户的情况下,队列中的工作量会变得如此之大,以至于用户最终会开始相互阻塞。

现在您已经了解了异步模型的历史以及 Node.js 为什么实现它,让我们来构建一个简单的 hello world Web 服务器。在接下来的章节中,您将学习更多关于 REST API 以及如何使用一些库来帮助您做一些更高级的事情。现在,我们将使用 http 模块来构建一个返回"hello world"字符串的服务器。

要创建一个 HTTP 服务器,您可以使用 http 模块中的createServer函数。只需按照以下步骤即可:

const http = require('http');
const server = http.createServer();

服务器由事件驱动,我们最感兴趣的事件是请求。当 HTTP 客户端连接到服务器并发起请求时,将触发此事件。我们可以使用一个接收两个参数的回调来监听此事件:

  • 请求:客户端发送给服务器的请求。

  • 响应:用于与客户端通信的响应对象。

响应是一个可写流,这意味着我们已经知道如何向其发送数据:通过调用write方法。但它还包含一个特殊的方法叫做writeHead,它将返回 HTTP 状态码和任何额外的标头。以下是将 hello world 字符串发送回客户端的示例:

server.on('request', (request, response) => {
  console.log('Request received.', request.url);
  response.writeHead(200, { 'Content-type': 'text/plain' });
  response.write('Hello world!');
  response.end();
});

我们有了服务器和请求处理程序。现在,我们可以开始在特定端口上监听请求。为此,我们在服务器实例上调用listen方法:

const port = 3000;
console.log('Starting server on port %d.', port);
console.log('Go to: http://localhost:%d', port);
server.listen(port);

此示例的代码可在 GitHub 上找到:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_server.js

如果您通过运行此应用程序启动 hello world 服务器,您将在控制台中看到类似以下内容:

$ node http_server.js 
Starting server on port 3000.
Go to: http://localhost:3000

如果您打开浏览器并转到指定路径,您将看到以下内容:

图 3.9:Hello world web 服务器示例响应

图 3.9:Hello world web 服务器示例响应

您可以尝试访问其他路径,例如http://localhost:3000/index.html。结果将是相同的:

图 3.10:Hello world 服务器始终以 Hello world 响应

图 3.10:Hello world 服务器始终以 Hello world 响应

如果您返回到运行服务器的控制台,您将看到类似以下内容:

$ node http_server.js 
Starting server on port 3000.
Go to: http://localhost:3000
Request received. /
Request received. /favicon.ico
Request received. /index.html
Request received. /favicon.ico

您可以看到服务器正确地从浏览器接收到路径。但是,由于代码没有处理任何特殊情况,它只是返回 Hello world。客户端无论请求什么路径,始终会得到相同的结果。

练习 13:提供静态文件

我们已经学会了如何构建一个始终以相同字符串响应的 hello world web 服务器,无论客户端请求什么。在这个练习中,我们将创建一个 HTTP 服务器,从目录中提供文件。这种类型的服务器称为静态 HTTP 服务器,因为它只在目录中查找文件并将它们无修改地返回给客户端。

执行以下步骤完成此练习:

  1. 在空目录中,使用init命令初始化一个新的 npm 应用程序,并向其添加一个index.js文件。还要使用npm install安装mime包。我们将使用此包确定我们将提供的文件的内容类型是什么:
npm install mime
  1. 让我们首先导入我们在这个项目中需要的所有模块:
const fs = require('fs');
const http = require('http');
const mime = require('mime');
const path = require('path');
const url = require('url');

我们将使用fs模块从磁盘加载文件。http 模块将用于创建 HTTP 服务器和处理 HTTP 请求。mime模块是我们在上一步中安装的,将用于确定每个文件的内容类型。path 模块用于以平台无关的方式处理路径。最后,url模块用于解析 URL。

  1. 为了知道我们将要提供哪些文件,我们将使用上一个练习中的walkDirectory函数扫描目录:
function walkDirectory(dirPath, callback) {
  const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
  dirents.forEach(dirent => {
    if (dirent.isDirectory()) {
      walkDirectory(path.join(dirPath, dirent.name), callback);
    } else {
      callback(path.join(dirPath, dirent.name));
    }
  });
}
  1. 然后,我们将选择根目录,可以将其作为参数传递。否则,我们将假定它是我们运行脚本的目录:
const rootDirectory = path.resolve(process.argv[2] || './');
  1. 现在,我们可以扫描目录树并将所有文件的路径存储在Set中,这将使文件可用性检查的过程更快:
const files = new Set();
walkDirectory(rootDirectory, (file) => {
 file = file.substr(rootDirectory.length);
  files.add(file);
});
console.log(`Found ${files.size} in '${rootDirectory}'...`);
  1. 准备好提供文件列表后,我们将创建 HTTP 服务器实例:
const server = http.createServer();
  1. 启动请求处理程序函数:
server.on('request', (request, response) => {
  1. 在处理程序函数内部,将用户请求的内容解析为 URL。为此,我们将使用 url 模块,并从解析后的 URL 中获取指向客户端想要的文件的路径名:
const requestUrl = url.parse(request.url);
const requestedPath = path.join(requestUrl.pathname);
  1. 有了文件路径,我们将检查文件是否在之前收集的列表中,如果不在,则响应 404(未找到)错误消息,记录请求的结果并返回它:
if (!files.has(requestedPath)) {
  console.log('404 %s', requestUrl.href);
  response.writeHead(404);
  response.end();
  return;
}
  1. 如果文件在Set中,我们将使用 path 模块提取其扩展名,并使用mime模块解析内容类型。然后,我们将以 200(ok)错误消息响应,创建一个读取文件的流,并将其传输到响应中:
  const contentType = mime.getType(path.extname(requestedPath));
  console.log('200 %s', requestUrl.href);
  response.writeHead(200, { 'Content-type': contentType });
  fs.createReadStream(path.join(rootDirectory, requestedPath))
    .pipe(response);
});
  1. 处理程序函数到此为止。之后,我们可以通过选择一个端口来启动服务器,让用户知道那是什么,并调用 http 服务器中的监听方法:
const port = 3000;
console.log('Starting server on port %d.', port);
console.log('Go to: http://localhost:%d', port);
server.listen(port);
  1. 您可以通过运行以下命令来启动服务器:
$ node .
Found 23 in '/Path/to/Folder'...
Starting server on port 3000.
o to: http://localhost:3000
  1. 从另一个终端窗口,我们可以使用命令行 HTTP 客户端 curl 来调用我们的服务器并查看响应:
$ curl -i localhost:3000/index.js
HTTP/1.1 200 OK
Content-type: application/javascript
Date: Fri, 16 Aug 2019 02:06:05 GMT
Connection: keep-alive
Transfer-Encoding: chunked
const fs = require('fs');
const http = require('http');
const mime = require('mime');
... rest of content here....

我们也可以从浏览器中进行相同操作:

图 3.11:从浏览器中查看的静态 index.js 从我们的 HTTP 服务器提供的

图 3.11:从浏览器中查看的静态 index.js 从我们的 HTTP 服务器提供的

您也可以尝试使用一个不存在的文件来查看结果:

$ curl -i localhost:3000/not_real.js
HTTP/1.1 404 Not Found
Date: Fri, 16 Aug 2019 02:07:14 GMT
Connection: keep-alive
Transfer-Encoding: chunked

从浏览器中,404 响应看起来像一个错误页面:

图 3.12:当请求一个不存在的文件时,服务器会以 404 错误响应

图 3.12:当请求一个不存在的文件时,服务器会以 404 错误响应

在运行服务器的终端上,您可以看到它打印了有关正在提供的信息:

$ node .
Found 23 in '/Path/to/Folder'...
Starting server on port 3000
Go to: http://localhost:3000
200 /index.js
404 /not_real.js

只需几行代码,您就能够构建一个提供静态内容的 HTTP 服务器。

HTTP 服务器是互联网的基本组件之一。Node.js 使构建强大的服务器变得简单。在这个练习中,只需几行代码,我们就建立了一个静态 HTTP 服务器。在本节的其余部分,我们将学习如何构建一个动态服务器,它可以使用模板和从请求中传递的数据生成 HTML,并且还可以从其他数据源加载,比如 JSON 文件。

在继续构建动态 HTTP 服务器之前,让我们看看 Node.js 中可用的 HTTP 客户端 API。为了测试 HTTP 客户端 API,我们将使用 HTTP Bin,这是一个免费的服务,可以用来测试 HTTP 请求。您可以在这里阅读更多信息:httpbin.org

在接下来的章节中,您将了解每个 HTTP 方法的含义,但现在,我们将只探索其中的两个:GET 和 POST。HTTP GET 是我们到目前为止一直在使用的。它告诉服务器:“为我获取这个 URL。” HTTP POST 的意思是:“将这个内容存储在这个 URL 上。”在我们之前构建的静态服务器中,它是磁盘上一个真实文件的真实路径。但它可以以服务器认为合适的任何方式使用。

让我们使用 Node.js 执行对httpbin API 的 GET 请求。HTTP 客户端模块与服务器位于同一模块中,因为它使用了许多相同的构造。因此,我们必须要求 http 模块:

const http = require('http');

由于 GET 是一个广泛使用的 HTTP 方法,http 模块为其设置了别名。我们可以通过调用get函数来执行 GET:

const request = http.get('http://httpbin.org/get', (response) => {

get函数接收 URL 和回调函数,一旦服务器开始发送它,回调就会被调用并传递给响应。传递给回调的响应是一个可读流,我们可以通过监听数据事件来从中获取数据:

response.on('data', (data) => {
  console.log(data.toString());
});

这里的数据是响应的主体。如果我们只是将其打印到控制台,我们将在终端中看到响应。

get方法返回的请求实例是一个可写流。要告诉服务器我们已经完成了请求,我们需要调用end方法:

request.end();

以下是前面代码的一些示例输出,可以在 GitHub 上找到github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_get.js

$ node http_client_get.js 
{
  "args": {}, 
  "headers": {
    "Host": "httpbin.org"
  }, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/get"
}

您可以看到它将响应主体打印到终端。

有时,您需要发送一些额外的标头或使用 HTTP 基本身份验证。为此,get方法接受一个options对象,您可以在其中设置标头、基本身份验证等。以下是一个示例选项对象,其中设置了自定义标头和基本身份验证:

const options = {
  auth: 'myuser:mypass',
  headers: {
    Test: 'Some Value'
  }
};

然后,在回调函数之前传递选项对象:

const request = http.get(url, options, (response) => {

以下片段是前述代码的输出,也可在 GitHub 上找到github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_get_with_headers.js

$ node http_client_get_with_headers.js 
{
  "args": {}, 
  "headers": {
    "Authorization": "Basic bXl1c2VyOm15cGFzcw==", 
    "Host": "httpbin.org", 
    "Test": "Some Value"
  }, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/get"
}

httpbin响应我们在请求中传递的所有信息。您可以看到现在有两个额外的标头,Test 和 Authorization,其值与我们指定的相同。授权标头是 base64 编码的,如基本身份验证规范中指定的。

如前所述,get 方法只是一个别名。request 方法是其更灵活的版本,可用于执行 HTTP POST 请求。尽管它更灵活,但 request 方法接收相同的参数:urloptionscallback

要指定要执行的 HTTP 方法,我们在选项对象中设置它:

const options = {
  method: 'POST',
};

然后,我们调用 request 函数,而不是 get 函数:

const request = http.request(url, options, (response) => {

如果要向服务器发送数据,可以使用我们创建的请求对象。请记住,它是一个可写流,因此我们可以直接将内容写入其中:

request.write('Hello world.');

在向请求写入数据后,调用end方法,请求就完成了:

request.end();

使用我们之前解释过的 write 和end方法的一些示例代码可在 GitHub 上找到github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_post.js

以下是运行上述代码的输出:

$ node http_client_post.js 
{
  "args": {}, 
  "data": "Hello world.", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Content-Length": "12", 
    "Host": "httpbin.org"
  }, 
  "json": null, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/post"
}

您可以看到 http 模块会根据您发送的数据量自动设置 Content-Length 标头。您还可以看到响应中设置了数据属性,指示服务器接收到的数据。

练习 14:提供动态内容

在本练习中,我们将重写上一章的商店前端。但现在,内容将以动态方式提供,并且 HTML 将在服务器端生成。为此,我们将有一个存储在 JSON 文件中的产品数组,该数组将被加载并用于生成要返回给客户端的 HTML 文件。

有许多生成要发送给客户端的 HTML 的方法:连接字符串,搜索和替换,模板字符串,甚至可以使用诸如 cheerio 之类的库。模板化通常是最简单的,因为您可以将模板存储在一个单独的文件中,就像普通的 HTML 文件一样,但其中有一些占位符。在本练习中,我们将使用 handlebars 模板库来完成这项艰苦的工作。

执行以下步骤以完成此练习:

  1. 创建一个新的 npm 包,其中包含一个index.js文件。安装我们在本练习中将使用的两个外部包:
$ npm init
...
$ npm install handlebars mime
+ handlebars@4.1.2
+ mime@2.4.4
updated 2 packages and audited 10 packages in 1.075s
found 0 vulnerabilities

handlebars 包是一个模板引擎。它可用于渲染带有占位符和一些基本逻辑(如 for 循环和 if/else 语句)的模板文本。我们还将使用之前使用过的mime包来确定静态提供的文件的内容类型。

  1. 在应用程序中需要所有将使用的模块:
const fs = require('fs');
const handlebars = require('handlebars');
const http = require('http');
const mime = require('mime');
const path = require('path');
const url = require('url');
  1. 使用基本目录检查静态文件的路径。该目录将是脚本加载的静态目录。我们将该路径存储在变量中,以便以后使用:
const staticDir = path.resolve(`${__dirname}/static`);
console.log(`Static resources from ${staticDir}`);
  1. 接下来,我们使用readFileSync从 JSON 文件中加载产品数组。我们使用内置的JSON.parse函数解析 JSON,然后将找到的产品数量打印到控制台:
const data = fs.readFileSync(`products.json`);
const products = JSON.parse(data.toString());
console.log(`Loaded ${products.length} products...`);

Handlebars 有一个辅助函数的概念。这些是可以在模板内注册和使用的函数。要注册一个辅助函数,您调用registerHelp函数,将您的辅助函数的名称作为第一个参数传递,并将处理程序函数作为第二个参数传递。

  1. 让我们添加一个辅助函数,用于格式化货币:
handlebars.registerHelper('currency', (number) => `$${number.toFixed(2)}`);
  1. 为了初始化 HTTP 处理程序并开始监听连接,我们将使用以下函数:
function initializeServer() {
  const server = http.createServer();
  server.on('request', handleRequest);
  const port = 3000;
  console.log('Go to: http://localhost:%d', port);
  server.listen(port);
}

我们在 HTTP 服务器中注册了一个名为handleRequest的函数。这是根处理程序,所有请求都将通过它。对于这个应用程序,我们期望有两种类型的请求:第一种是指向 css、图像和其他静态文件的静态请求,而第二种是获取商店 HTML 的请求。这意味着我们的根处理程序只关心这两种情况。

  1. 要请求商店,我们将假设当用户请求//index.html(http://localhost:3000/http://localhost:3000/index.html)时,用户正在尝试访问商店,也就是应用程序的基本页面或根页面。其他一切都将被视为静态资源。为了处理这些请求,我们将解析 URL,检查路径名,并使用if语句:
function handleRequest(request, response) {
  const requestUrl = url.parse(request.url);
  const pathname = requestUrl.pathname;
  if (pathname == '/' || pathname == '/index.html') {
    handleProductsPage(requestUrl, response);
    return;
  }
  handleStaticFile(pathname, response);
}
  1. 为了处理静态文件,我们将在静态文件应该来自的目录前面添加路径,并将其用作完整路径。然后,我们将使用文件系统 API 中的access函数来检查文件是否存在并且可读。如果有错误,那么返回404错误;否则,只需创建一个可读流并将文件的内容传输到响应。我们还希望使用 mime 库来检查每个文件的内容类型,并向响应添加一个头部:
function handleStaticFile(pathname, response) {
  // For security reasons, only serve files from static directory
  const fullPath = path.join(staticDir, pathname);
  // Check if file exists and is readable
  fs.access(fullPath, fs.constants.R_OK, (error) => {
    if (error) {
      console.error(`File is not readable: ${fullPath}`, error);
      response.writeHead(404);
      response.end();
      return;
    }
    const contentType = mime.getType(path.extname(fullPath));
   response.writeHead(200, { 'Content-type': contentType });
    fs.createReadStream(fullPath)
      .pipe(response);
  });
}
  1. 现在我们有了用于提供静态文件的函数,让我们使用 handlebars 来提供动态内容。为此,我们需要使用readFileSync加载 HTML 模板,然后编译它。编译后的脚本被转换为一个函数,当调用时返回处理过的模板的字符串。

模板函数接收将用于呈现模板的上下文。上下文可以在模板中访问,这将在下一步中演示。对于这个应用程序,上下文将是一个带有一个名为products的属性的对象:

const htmlString = fs.readFileSync(`html/index.html`).toString();
const template = handlebars.compile(htmlString);
function handleProductsPage(requestUrl, response) {
  response.writeHead(200);
 response.write(template({ products: products }));
  response.end();
}
  1. 在模板处理就位后,我们需要一个模板。Handlebars 使用双花括号作为占位符(例如,{{variable}}),你可以使用双花括号和井号来执行 for 循环:{{#arrayVariable}}。在一个相对于index.js文件的html/index.html文件中,添加以下 HTML 模板:
<html>
  <head>
    <link rel="stylesheet" type="text/css" href="css/semantic.min.css" />
    <link rel="stylesheet" type="text/css" href="css/store.css" />
  </head>
  <body>
    <section>
      <h1 class="title">Welcome to Fresh Products Store!</h1>
      <div class="ui items">
        {{#products}}
        <div class="item">
          <div class="image"><img src="{{image}}" /></div>
          <div class="content">
            <a class="header">{{name}}</a>
            <div class="meta">
              <span>{{currency price}} / {{unit}}</span>
            </div>
            <div class="description">{{description}}</div>
            <div class="extra">
              {{#tags}}
              <div class="ui label teal">{{this}}</div>
              {{/tags}}
            </div>
         </div>
        </div>
        {{/products}}
      </div>
    </section>
  </body>
</html>

注意辅助函数currency,它被调用来呈现价格:{{currency price}}.

  1. 不要忘记在最后调用initialize函数以开始监听 HTTP 连接:
initializeServer();

为了使商店正确加载和呈现,你还需要 css 文件和图像。只需将它们放在一个名为static的文件夹中。你可以在 GitHub 上找到这些文件:github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Exercise14

  1. 所有文件就位后,运行服务器:
$ node .
Static resources from
.../Lesson03/Exercise14/static
Loaded 21 products...
Go to: http://localhost:3000
  1. 打开浏览器窗口,转到http://localhost:3000。你应该看到商店:

图 3.13:从动态网络服务器提供的商店

图 3.13:从动态网络服务器提供的商店

在这个练习中,我们将商店应用程序转换为一个动态的网络应用程序,它从一个 JSON 文件中读取数据,并在用户请求时呈现一个 HTML 请求。

动态网络服务器是所有在线应用程序的基础,从 Uber 到 Facebook。你可以总结这项工作为加载数据/处理数据以生成 HTML。在第二章,Node.js 和 npm中,我们在前端使用了一些简单的 HTML 并进行了处理。在这个练习中,你学会了如何在后端使用模板引擎来完成相同的工作。每种方法都有其优缺点,大多数应用程序最终会结合两者。

你可以将过滤选项添加到商店前端网页作为改进。比如说用户想要按标签或它们的组合来筛选产品。在你的handleProductsPage函数中,你可以使用查询参数来过滤你传递给模板渲染的产品列表。看看你是否可以自己做出这个改进。

什么是爬取?

在本章的其余部分,我们将讨论网络爬取。但网络爬取到底是什么?这是下载页面并处理其内容以执行一些重复的自动化任务的过程,否则这些任务将需要手动执行太长时间。

例如,如果你想要购买汽车保险,你需要去每家保险公司的网站获取报价。这个过程通常需要几个小时,因为你需要填写表单,提交表单,等待他们在每个网站给你发送电子邮件,比较价格,然后选择你想要的:

图 3.14:用户下载内容,输入数据,提交数据,然后等待结果

图 3.14:用户下载内容,输入数据,提交数据,然后等待结果

那么为什么不制作一个可以为你做到这一点的程序呢?这就是网络爬取的全部内容。一个程序像人一样下载页面,从中提取信息,并根据某种算法做出决策,然后将必要的数据提交回网站。

当你为你的汽车购买保险时,似乎自动化不会带来太多价值。为不同的网站编写正确执行此操作的应用程序将花费很多时间——比手动操作自己做要多得多。但如果你是一家保险经纪公司呢?那么你每天可能要做这个动作数百次,甚至更多。

如果你是一个保险经纪公司,如果你花时间建立一个机器人(这些应用程序就是这样称呼的),你将开始变得更加高效。这是因为对于那个网站,你不需要花时间填写表单。通过建立第一个机器人获得的效率,你可以节省时间并能够建立第二个,然后是第三个,依此类推:

图 3.15:机器人通过下载内容并根据算法做出决策自动执行任务

图 3.15:机器人通过下载内容并根据算法做出决策自动执行任务

网络爬虫始于互联网早期,当时雅虎!试图手动索引所有存在的网站。然后,一家初创公司,由两名大学生在车库里开始使用机器人来提取数据并索引一切。在很短的时间内,谷歌成为了第一大搜索网站,这个位置对竞争对手来说越来越难以挑战。

网络爬取是一种广泛使用的技术,用于从不提供 API 的网站提取数据,比如大多数保险公司和银行。搜索和索引也是另一个非常常见的情况。一些公司使用爬取来分析网站的性能并对其进行评分,比如 HubSpot(website.grader.com)。

网络爬虫有许多技术,取决于你想要实现的目标。最基本的技术是从网站下载基本的 HTML 并从中读取内容。如果你只需要下载数据或填写表单,这可能已经足够了:

图 3.16:基本的爬取技术涉及下载和处理基本的 HTML 文件

图 3.16:基本的爬取技术涉及下载和处理基本的 HTML 文件

但有时,网站使用 Ajax 在 HTML 渲染后动态加载内容。对于这些情况,仅下载 HTML 是不够的,因为它只是一个空模板。为了解决这个问题,您可以使用一个无头浏览器,它像浏览器一样工作,解析所有 HTML,下载和解析相关文件(CSS、JavaScript 等),将所有内容一起渲染,并执行动态代码。这样,您就可以等待数据可用:

图 3.17:根据用例,抓取需要一个模拟或完全无头浏览器来更准确地下载和渲染页面

图 3.17:根据用例,抓取需要一个模拟或完全无头浏览器来更准确地下载和渲染页面

第二种技术要慢得多,因为它需要下载、解析和渲染整个页面。它也更加脆弱,因为执行额外的调用可能会失败,等待 JavaScript 完成处理数据可能很难预测。

下载和解析网页

让我们来看看更简单的网页抓取方法。假设我们想要关注 Medium 上关于 JavaScript 的最新帖子。我们可以编写一个应用程序来下载 JavaScript 主题页面,然后搜索锚点(链接),并使用它来导航。

首先,拥有一个通用的下载函数,它将对 HTTP 客户端进行一些基本的封装,是一个好主意。我们可以使用外部库,比如 request,但让我们看看如何封装这种逻辑。

我们将需要 http 模块,但在这种情况下,我们将使用它的 https 版本,因为大多数网站这些天会在你尝试访问普通 HTTP 版本时将你重定向到它们的安全版本。https 模块提供了相同的 API,只是它理解 HTTPS 协议,这是 HTTP 的安全版本。

const http = require('https');

downloadPage函数接收要下载的 URL 和在页面内容下载完成后将被调用的回调函数:

function downloadPage(urlToDownload, callback) {
}

在该函数内部,我们将首先发出一个请求,并确保我们调用 end 函数来完成请求:

const request = http.get(urlToDownload, (response) => {
});
request.end();

在我们传递给 get 函数的回调中,我们首先要做的是检查响应状态,并在它不匹配 200 时打印错误消息,这是表示我们有一个成功请求的 HTTP 代码。如果发生这种情况,我们还会通过从回调中返回来停止一切,因为如果发生这种情况,body 可能不是我们所期望的。

if (response.statusCode != 200) {
  console.error('Error while downloading page %s.', urlToDownload);
  console.error('Response was: %s %s', response.statusCode, response.statusMessage);
  return;
}

在那个if语句之后,我们可以使用数据事件在一个变量中累积页面的内容。当连接关闭时,在close事件中,我们调用回调函数,并将累积在 content 变量中的全部内容传递给它。

let content = '';
response.on('data', (chunk) => content += chunk.toString());
response.on('close', () => callback(content));

这个示例的完整代码可以在 GitHub 上找到:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_scraping/print_all_texts.js

这个函数的一个简单用法如下:

downloadPage('https://medium.com/topic/javascript', (content) => {
  console.log(content);
});

这将下载页面并将其打印到控制台。但我们想做更多的事情,所以我们将使用jsdom库来解析 HTML 并从中获取一些信息。jsdom是一个解析 HTML 并生成 DOM 表示的库,可以像浏览器中的 DOM 一样进行查询和操作。

使用npm install命令安装后,您可以在代码中引用它。该模块公开了一个接收字符串的构造函数。在被实例化后,JSDOM实例包含一个窗口对象,其工作方式与浏览器中的窗口对象完全相同。以下是使用它来获取所有锚点、过滤掉空的锚点并打印它们的文本的示例:

const JSDOM = require('jsdom').JSDOM;
downloadPage('https://medium.com/topic/javascript', (content) => {
 const document = new JSDOM(content).window.document;
  Array.from(document.querySelectorAll('a'))
    .map((el) => el.text)
    .filter(s => s.trim() != '')
    .forEach((s) => console.log(s));
});

以下是前述代码的示例输出:

$ node print_all_texts.js 
Javascript
Become a member
Sign in
14 Beneficial Tips to Write Cleaner Code in React Apps
Be a hygienic coder by writing cleaner
14 Beneficial Tips to Write Cleaner Code in React Apps
Be a hygienic coder by writing cleaner
...

练习 15:抓取 Medium 文章

在这个练习中,我们将使用爬虫在控制台上打印文章。让我们利用这些知识构建一个应用程序,该应用程序将从 Medium 下载主题页面,解析信息,并以可消化的方式打印出来。该应用程序将有一个硬编码的主题列表,并将下载每个页面的 HTML。然后,它将使用jsdom解析已下载的内容,获取有关每篇文章的信息,并以漂亮的格式在控制台上打印出来,使每篇文章都只是一个点击之遥。

执行以下步骤完成此练习:

  1. 创建一个新文件夹,其中包含一个index.js文件。然后,运行npm init并使用npm install安装jsdom
$ npm init
...
$ npm install jsdom
+ jsdom@15.1.1
added 97 packages from 126 contributors and audited 140 packages in 12.278s
found 0 vulnerabilities
  1. index.js文件中,使用 require 函数引入我们将使用的所有模块:
const http = require('https');
const JSDOM = require('jsdom').JSDOM;
const url = require('url');
  1. 创建一个包含我们将下载页面的所有主题的常量数组:
const topics = [
  'artificial-intelligence',
  'data-science',
  'javascript',
  'programming',
  'software-engineering',
];
  1. 复制我们在上一节中创建的downloadPage函数:
function downloadPage(urlToDownload, callback) {
  const request = http.get(urlToDownload, (response) => {
    if (response.statusCode != 200) {
      console.error('Error while downloading page %s.', urlToDownload);
      console.error('Response was: %s %s', response.statusCode, response.statusMessage);
      return;
    }
    let content = '';
    response.on('data', (chunk) => content += chunk.toString());
    response.on('close', () => callback(content));
  });
  request.end();
}
  1. 迭代每个主题,为每个主题调用downloadPage函数:
topics.forEach(topic => {
  downloadPage(`https://medium.com/topic/${topic}`, (content) => {
    const articles = findArticles(new JSDOM(content).window.document);
    Object.values(articles)
     .forEach(printArticle);
  });
});

在上述代码中,我们调用了两个函数:findArticlesprintArticle。第一个函数将遍历从页面解析的 DOM,并返回一个对象,其中键是文章标题,值是包含每篇文章信息的对象。

  1. 接下来,我们编写findArticles函数。我们首先初始化对象,该对象将是函数的结果,然后查询传递的文档中所有 H1 和 H3 元素内的所有锚点元素,这些元素代表文章的标题:
function findArticles(document) {
  const articles = {};
  Array.from(document.querySelectorAll('h1 a, h3 a'))
  1. 根据 Medium 文章路径有两部分:/author/articleId,过滤锚点。这意味着我们可以将锚点的href解析为 URL,获取路径名,使用“/”作为分隔符拆分,并忽略那些不完全有两部分的锚点:
.filter(el => {
  const parsedUrl = url.parse(el.href);
  const split = parsedUrl.pathname.split('/').filter((s) => s.trim() != '');
  return split.length == 2;
})

使用 Chrome 开发者工具在页面上,您可以看到文章的标题位于一个标题元素内,其下一个兄弟元素是一个包含以下简短描述的 DIV:

图 3.18:父级的下一个兄弟元素包含文章的简短描述

图 3.18:父级的下一个兄弟元素包含文章的简短描述

这意味着对于每个锚元素,我们可以获取该 DIV,查询一个锚点,并获取其文本作为文章的描述。

  1. 使用文章标题作为键,将文章信息设置在结果对象中。我们使用文章的标题作为键,因为这将自动去重结果中的文章:
.forEach(el => {
  const description = el.parentNode.nextSibling.querySelector('p a').text;
  articles[el.text] = {
    description: description,
    link: url.parse(el.href).pathname,
    title: el.text,
 };
});
  1. 最后,从findArticles函数中返回包含所有文章的数组:
  return articles;
}

我们在传递给downloadPage的回调中调用的另一个函数是printArticle。这也是使该应用程序完整的最后一部分代码。

  1. 让我们编写printArticle函数,它接收一个文章对象,并以漂亮的方式将其打印到控制台上:
function printArticle(article) {
  console.log('-----');
  console.log(` ${article.title}`);
  console.log(` ${article.description}`);
  console.log(` https://medium.com${article.link}`);
}

运行应用程序,以漂亮的格式将文章打印到控制台上,附加额外信息:

图 3.19:运行应用程序后在控制台上打印的文章

图 3.19:运行应用程序后在控制台上打印的文章

在这个练习中,我们编写了一个从 Medium 获取数据并将找到的文章摘要打印到控制台的应用程序。

网络爬虫是在没有 API 可用时获取数据的强大方式。许多公司使用爬虫在系统之间同步数据,分析网站的性能,并优化否则无法扩展的流程,从而阻碍了一些重要的业务需求。了解爬虫背后的概念使您能够构建否则不可能构建的系统。

活动 4:从商店前端爬取产品和价格

第二章,Node.js 和 npm中,我们编写了一些代码,用于获取商店示例页面中产品的信息。当时,我们说网站不会经常更新,因此可以从 Chrome 开发者控制台手动执行。对于某些情况,这是可以接受的,但是当内容是动态生成的,就像我们在本章中编写的商店的新版本一样,我们可能需要消除所有手动干预。

在此活动中,您将编写一个应用程序,通过使用 http 模块下载商店网页并使用jsdom解析它来抓取商店网页。然后,您将从 DOM 中提取数据并生成一个带有数据的CSV文件。

您需要执行以下步骤才能完成此活动:

  1. 使用您之前构建的代码或其副本来为localhost:3000提供商店前端网站。 代码可以在 GitHub 上找到github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Activity04

  2. 创建一个新的npm包,安装jsdom库,并创建一个名为index.js的入口文件。

  3. 在入口文件中,调用require()方法加载项目中所需的所有模块。

  4. localhost:3000发出 HTTP 请求。

  5. 确保成功响应并从主体中收集数据。

  6. 使用jsdom解析 HTML。

  7. 从 DOM 中提取产品数据; 您将需要名称,价格和单位。

  8. 打开CSV文件,数据将被写入其中。

  9. 将产品数据写入CSV文件,这是一个产品行。

  10. 运行应用程序并检查结果。

输出应该看起来像这样:

$ node .
Downloading http://localhost:3000...
Download finished.
Parsing product data...
.....................
Found 21 products.
Writing data to products.csv...
Done.
$ cat products.csv 
name,price,unit
Apples,3.99,lb
Avocados,4.99,lb
Blueberry Muffin,2.5,each
Butter,1.39,lb
Cherries,4.29,lb
Chocolate Chips Cookies,3.85,lb
Christmas Cookies,3.89,lb
Croissant,0.79,each
Dark Chocolate,3.49,lb
Eggs,2.99,lb
Grapes,2.99,lb
Milk Chocolate,3.29,lb
Nacho Chips,2.39,lb
Parmesan Cheese,8.99,lb
Pears,4.89,lb
Petit French Baguette,0.39,each
Smiling Cookies,2.79,lb
Strawberries,7.29,lb
Swiss Cheese,2.59,lb
White Chocolate,3.49,lb
Whole Wheat Bread,0.89,each

注意

此活动的解决方案可以在第 591 页找到。

摘要

在本章中,我们学习了每个 Node.js 脚本都可以使用的全局变量。我们学习了如何设置定时器并从控制台读取和写入数据。之后,我们学习了有关流的知识以及如何使用它们从文件中读取和写入数据。我们还学习了如何使用同步文件系统 API。然后,我们学习了如何使用 HTTP 模块构建 Web 服务器并从 Web 页面中抓取内容。

现在您已经对 Web 抓取概念有了很好的了解,可以开始探索机会,构建自己的 Web 应用程序,并构建自动机器人来从其他 Web 应用程序中抓取内容。一个好主意是尝试构建一个简单的内容管理应用程序来为您的博客提供服务,您将在其中写有关您刚学到的所有新事物的内容。

在下一章中,您将学习有关 REST API,并使用一些框架来帮助您构建它们。在后续章节中,您将学习有关可以使用的技术,以管理异步操作,使您的 Node.js 应用程序功能强大,但代码易于编写和维护。

第五章:使用 Node.js 创建 RESTful API

学习目标

在本章结束时,您将能够:

  • 为 Express.js API 设置项目结构

  • 使用不同的 HTTP 方法设计具有端点的 API

  • 在本地主机上运行 API,并通过 cURL 或基于 GUI 的工具与其交互

  • 解析端点的用户输入,并考虑处理错误的不同方式

  • 设置需要用户身份验证的端点

在本章中,我们将使用 Express.js 和 Node.js 来设置一个可以供前端应用程序使用的 API。

介绍

应用程序编程接口API)变得比以往任何时候都更加重要。使用 API 可以使单个服务器端程序被多个脚本和应用程序使用。由于其有用性,使用 Node.js 的后端开发人员的 API 管理已成为最常见的任务之一。

让我们以一个既有网站又有移动应用程序的公司为例。这两个前端界面都需要服务器端的基本相同功能。通过将这些功能封装在 API 中,我们可以实现服务器端代码的清晰分离和重用。过去那些将后端功能直接嵌入网站界面代码的笨拙 PHP 应用程序的时代已经一去不复返。

我们将使用 Node.js 来设置一个表述状态转移REST)API。我们的 API 将在 Express.js 上运行,这是一个具有路由功能的流行 Web 应用程序框架。借助这些工具,我们可以快速在本地主机上运行一个端点。我们将研究设置 API 的最佳实践,以及 Express.js 库中使用的特定语法。除此之外,我们还将考虑 API 设计的基础知识,简化开发人员和使用它的服务的使用。

什么是 API?

API 是与软件应用程序进行交互的标准化方式。API 允许不同的软件应用程序相互交互,而无需了解底层功能的内部工作原理。

API 在现代软件工程中变得流行,因为它们允许组织通过重用代码更加有效。以地图的使用为例:在 API 普及之前,需要地图功能的组织必须在内部维护地图小部件。通常,这些地图小部件的性能会很差,因为它们只是业务和工程团队的次要关注点。

现在,使用地图的网站或应用程序很少在内部维护地图。许多网络和手机应用程序都使用来自 Google 或 OpenStreetMap 等替代方案的地图 API。这使得每家公司都可以专注于其核心竞争力,而不必创建和维护自己的地图小部件。

有几家成功的初创公司的业务模式围绕着通过 API 提供服务。一些例子包括著名公司如 Twilio、Mailgun 和 Sentry。除此之外,还有一些较小的公司通过 API 提供独特的服务,比如 Lob,它可以通过其 API 根据请求发送实体信件和明信片。在这里,开发人员只需将信件内容和目的地地址发送到 Lob 的 API,它就会自动打印并代表开发人员寄出。以下是一些知名公司提供的 API 服务的示例。

图 4.1:基于 API 的公司示例

这些公司通过提供可用于提供特定服务的构建块,使开发人员能够更好地、更快地开发应用程序。其有效性的证明可以从这些服务的广泛采用中看出。使用 Twilio 提供文本或电话集成的公司包括可口可乐、Airbnb、优步、Twitch 等许多其他公司。这些公司中的许多公司又为其他公司和开发人员提供自己的 API 来构建。这种趋势被称为 API 经济。

这些服务的另一个共同点是它们都通过 HTTP 使用 REST。新开发人员经常认为所有 API 都是通过 HTTP 使用的;然而,当我们谈论 API 时,对使用的协议或介质没有限制。API 的接口理论上可以是任何东西,从按钮到无线电波。虽然有许多接口选项可供选择,但 HTTP 仍然是最广泛使用的介质。在下一节中,我们将更详细地讨论 REST。

REST 是什么?

REST 是一种用于创建基于 web 的服务的软件架构模式。这意味着资源由特定的 URL 端点表示,例如website.com/post/12459,可以使用其特定 ID 访问网站的帖子。REST 是将资源映射到 URL 端点的方法。

在数据库管理领域的一个相关概念是CRUD创建、读取、更新和删除)。这是你可以与数据库资源交互的四种方式。同样,我们通常与由我们的 API 端点定义的资源对象交互的方式也有四种。HTTP 协议具有内置方法,可以简化诸如POSTGETPUTDELETE等任务。

先前提到的任务的功能如下:

  • POST:创建对象资源

  • GET:检索有关对象资源的信息

  • PUT:更新特定对象的信息

  • DELETE:删除特定对象

其他方法:除了四种主要方法外,还有一些其他不太常用的方法。我们不会在这里使用它们,你也不必担心它们,因为客户端和服务器很少使用它们:

  • HEAD:与GET相同,但只检索标头而不是主体。

  • OPTIONS:返回服务器或 API 的允许选项列表。

  • CONNECT:用于创建 HTTP 隧道。

  • TRACE:这是用于调试的消息回路。

  • PATCH:这类似于PUT,但用于更新单个值。请注意,PUT可以代替PATCH使用。

Express.js 用于 Node.js 上的 RESTful API

好消息是,如果你了解基本的 JavaScript,你已经完成了创建你的第一个 API 的一半。使用 Express.js,我们可以轻松构建 HTTP 端点。Express 是一个流行的、最小的 web 框架,用于在节点上创建和托管 web 应用程序。它包括几种内置的路由方法,允许我们映射传入的请求。有许多中间件包可以使常见任务更容易。在本章后面,我们将使用一个验证包。

在本章中,我们将创建一个假设的智能房屋 API 的各个方面。这将需要为具有改变设备状态逻辑的各种设备添加端点。一些端点将对网络中的任何人开放,例如智能灯,而其他一些,如加热器,将需要身份验证。

注意

什么是智能房屋?智能房屋是一个包含互联网连接设备的房屋,您可以通过基于云的控制系统与之交互。与用户和其他设备通信的互联网连接设备的趋势通常被称为物联网IoT)。

在本章中,我们将为一个包含智能设备的房屋编写 API,包括智能灯泡和加热器。此练习的代码文件可在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise16上找到。

练习 16:创建一个带有索引路由的 Express 项目

在这个练习中,我们的目标是创建一个新的节点项目,安装 Express,然后创建一个返回带有消息单个属性的 JSON 对象的索引路由。一旦它运行起来,我们可以通过在本地主机上进行 cURL 请求来测试它。要做到这一点,执行以下步骤:

  1. 创建一个名为smartHouse的文件夹并初始化一个npm项目:
mkdir smartHouse
cd smartHouse
npm init
  1. 安装express库,使用-s标志将其保存到我们的package.json文件中:
npm install -s express
  1. 创建一个名为server.js的文件,导入express并创建一个app对象:
const express = require('express');
const app = express();
  1. server.js中添加一个指定'/'的app.get方法,用于我们的索引路由:
app.get('/', (req, res) => {
  let info = {};
  info.message = "Welcome home! Our first endpoint.";
  res.json(info);
});

前面的代码创建了一个HTTP GET函数,返回一个名为info的对象,其中包含一个名为message的属性。

  1. 添加一个app.listen函数,告诉我们的应用程序监听端口 3000
// Start our application on port 3000
app.listen(3000, () => console.log('API running on port 3000'));

前面的步骤就是一个简单的 Node.js Express API 示例所需的全部内容。通过运行前面的代码,我们将在本地主机上创建一个应用程序,返回一个简单的 JSON 对象。

  1. 在另一个终端窗口中,返回到您的smartHouse文件夹的根目录并运行以下命令:
npm start
  1. 通过在 Web 浏览器中转到localhost:3000,确认应用程序是否正确运行:

图 4.2:在 Web 浏览器中显示 localhost:3000

图 4.2:在 Web 浏览器中显示 localhost:3000

如果您已正确复制了代码,您应该在localhost:3000看到一个 JSON 对象被提供,就像在前面的屏幕截图中显示的那样。

注意

如果在任何步骤中遇到问题或不确定项目文件应该是什么样子,您可以使用项目文件夹将代码恢复到与项目一致的状态。文件夹将根据它们关联的步骤命名,例如Exercise01,Exercise02等。当您第一次进入文件夹时,请确保运行npm install来安装项目使用的任何模块。

通过 HTTP 与您的 API 进行交互

在这一部分,我们将与练习 16中创建的服务器进行交互,创建一个带有索引路由的 Express 项目。因此,请确保您保持一个终端窗口打开并运行服务器。如果您已经关闭了该窗口或关闭了它,只需返回到smartHouse文件夹并运行npm start

我们通过使用 Web 浏览器验证了我们的 API 正在运行。Web 浏览器是查看路由的最简单方式,但它有限,只适用于GET请求。在本节中,我们将介绍另外两种与 API 进行更高级交互的方法,这两种方法都允许进行更高级的请求,包括以下内容:

  • 超出GET的请求,包括PUTPOSTDELETE

  • 向您的请求添加标头信息

  • 为受保护的端点包括授权信息

我首选的方法是使用命令行工具 cURL。cURL 代表 URL 的客户端。它已安装在大多数版本的 macOS、Linux 和 Windows 10 上(2018 年及以后的版本)。它是一个用于进行 HTTP 请求的命令行工具。对于一个非常简单的命令,运行以下命令:

curl localhost:3000

以下是前面代码的输出:

图 4.3:显示 cURL localhost:3000

注意

命令行程序jq将在本章中用于格式化 cURL 请求。jq是一个轻量级和灵活的命令行 JSON 处理器。该程序适用于 macOS、Linux 和 Windows。如果您无法在系统上安装它,仍然可以使用不带jqcurl。要这样做,只需从本章中任何 curl 命令的末尾删除| jq命令。

安装jq的说明可以在github.com/stedolan/jq找到。

通过使用带有jqcurl,我们可以使阅读输出变得更容易,这将在我们的 JSON 变得更复杂时特别有用。在下面的示例中,我们将重复与前面示例中相同的 curl 命令,但这次使用 Unix 管道(|)将输出传送到jq

curl -s localhost:3000 | jq

当像前面的命令一样将curl传送到jq时,我们将使用-s标志,该标志代表“静默”。如果curl在没有此标志的情况下进行传送,您还将看到关于请求速度的不需要的信息。

假设你已经做了一切正确的事情,你应该观察到一些干净的 JSON 作为输出显示:

图 4.4:cURL 管道传输到 jq

图 4.4:cURL 管道传输到 jq

如果你喜欢使用基于 GUI 的应用程序,你可以使用 Postman,它是一个 Chrome 扩展程序,可以以直接的方式轻松发送 HTTP 请求。一般来说,我更喜欢在命令行上快速使用 cURL 和 jq。然而,对于更复杂的用例,我可能会打开 Postman,因为 GUI 使得处理头部和授权变得更容易一些。有关安装 Postman 的说明,请访问网站www.getpostman.com

图 4.5:Postman 中 cURL 请求的屏幕截图

图 4.5:Postman 中 cURL 请求的屏幕截图

练习 17:创建和导入路由文件

目前,我们的应用程序在根 URL 上运行一个端点。通常,一个 API 会有许多路由,将它们全部放在主server.js文件中会很快导致项目变得杂乱。为了防止这种情况发生,我们将把每个路由分离成模块,并将每个模块导入到我们的server.js文件中。

注意

此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise17找到。

执行以下步骤完成练习:

  1. 要开始,创建smartHouse文件夹中的一个新文件夹:
mkdir routes
  1. 创建routes/index.js文件,并将server.js中的import语句和main函数移动到该文件中。然后,在下面,我们将添加一行将router对象导出为一个模块:
const express = require('express');
const router = express.Router();
router.get('/', function(req, res, next) {
  let info = {};
  info.message = "Welcome home! Our first endpoint.";
  res.json(info);
});
// Export route so it is available to import
module.exports = router;

上述代码本质上是我们在第一个练习中编写的代码移动到不同的文件中。关键的区别在于底部的一行,那里写着 module.exports = router;。这一行将我们创建的 router 对象导出,并使其可以被导入到另一个文件中。每当我们创建一个新的路由文件时,它都会包含相同的底部导出行。

  1. 打开server.js并删除第 3 到第 8 行,因为app.get方法已经移动到/routes/index.js文件中。然后,我们将导入pathfs(文件系统)库。我们还将导入一个名为http-errors的库,稍后将用于管理 HTTP 错误。server.js的前九行将如下所示:
const express = require('express');
const app = express();
// Import path and file system libraries for importing our route files
const path = require('path');
const fs = require('fs');
// Import library for handling HTTP errors
const createError = require('http-errors');
  1. 此外,在server.js中,我们将打开 URL 编码,并告诉express使用 JSON:
// Tell express to enable url encoding
app.use(express.urlencoded({extended: true}));
app.use(express.json());
  1. 接下来,我们将导入我们的索引路由并将其与一个路径关联起来。在我们完成了这些步骤之后,server.js应该包含以下内容:
// Import our index route
let index = require('./routes/index');
// Tell Express to use our index module for root URL
app.use('/', index);
  1. 我们可以为任何访问的 URL 创建一个捕获所有的404错误,这些 URL 没有对应的函数。在app.use方法内部,我们将 HTTP 状态码设置为404,然后使用我们在步骤 2中导入的http-errors库创建一个捕获所有的404错误(重要的是以下代码位于所有其他路由声明的下方):
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  res.status(404);
  res.json(createError(404));
});
  1. 文件的最后一行应该存在于我们之前的练习中:
// Start our application on port 3000
app.listen(3000, () => console.log('API running on port 3000'));

完成这些步骤后,运行我们的代码应该产生以下输出,与练习 16,创建带有索引路由的 Express 项目中的结果相同:

图 4.6:输出消息

图 4.6:输出消息

routes文件夹的优势在于,随着 API 的增长,它使得组织我们的 API 变得更容易。每当我们想要创建一个新的路由时,我们只需要在routes文件夹中创建一个新文件,使用requireserver.js中导入它,然后使用 Express 的app.use函数将文件与一个端点关联起来。

模板引擎:在前两行中我们使用app.use时,我们修改了express的设置以使用扩展的 URL 编码和 JSON。它也可以用于设置模板引擎;例如,嵌入式 JavaScriptEJS)模板引擎:

app.set('view engine', 'ejs');

模板引擎允许 Express 为网站生成和提供动态 HTML 代码。流行的模板引擎包括 EJS、Pug(Jade)和 Handlebars。例如,通过使用 EJS,我们可以使用从路由传递到视图的用户对象动态生成 HTML:

<p><%= user.name %></p>

在我们的情况下,我们不需要使用view或模板引擎。我们的 API 将专门返回和接受标准的 JSON。如果您有兴趣在 HTML 网站中使用 Express,我们鼓励您研究与 Express 兼容的模板引擎。

HTTP 状态代码

练习 17步骤 6中,创建和导入路由文件,我们将响应的 HTTP 状态代码设置为404。大多数人都听说过 404 错误,因为在网站上找不到页面时通常会看到它。然而,大多数人不知道状态代码是什么,也不知道除了404之外还有哪些代码。因此,我们将从解释状态代码的概念开始,并介绍一些最常用的代码。

状态代码是服务器在 HTTP 响应中返回给客户端请求的三位数字。每个三位代码对应于一个标准化的状态,例如未找到成功服务器错误。这些标准化代码使处理服务器变得更加容易和标准化。通常,状态代码将附带一些额外的消息文本。这些消息对人类很有用,但在编写处理 HTTP 响应的脚本时,仅仅考虑状态代码会更容易。例如,基于返回的状态代码创建一个 case 语句。

响应代码分为由三位数字中的第一位数字确定的类别:

图 4.7:HTTP 响应代码类别表

图 4.7:HTTP 响应代码类别表

HTTP 代码的每个类别都包含可在特定情况下使用的几个具体代码。这些标准化的代码将帮助客户端处理响应,即使涉及不熟悉的 API。例如,任何 400 系列的客户端错误代码都表示问题出在请求上,而 500 系列的错误代码表示问题可能出在服务器本身。

让我们来看看以下图中每个类别中存在的一些具体 HTTP 状态代码:

图 4.8:HTTP 响应代码表

图 4.8:HTTP 响应代码表

在下图中,我们可以看到一些更具体的 HTTP 状态代码:

图 4.9:HTTP 响应代码继续表

图 4.9:HTTP 响应代码继续表

这里列出的代码只是可用的数十种 HTTP 状态代码中的一小部分。在编写 API 时,使用适当的状态代码是有用的。状态代码使响应对用户和机器更容易理解。在测试我们的应用程序时,我们可能希望编写一个脚本,将一系列请求与预期的响应状态代码进行匹配。

在使用 Express 时,默认状态代码始终为200,因此如果您在结果中未指定代码,它将为200,表示成功的响应。完整的 HTTP 状态代码列表可以在developer.mozilla.org/en-US/docs/Web/HTTP/Status找到。

要设置状态代码错误,请使用上面的代码部分,并将404替换为http-errors库支持的任何错误代码,该库是 Express 的子依赖项。您可以在项目的 GitHub 上找到所有支持的错误代码列表github.com/jshttp/http-errors

您还可以向createError()传递一个额外的字符串来设置自定义消息:

res.status(404);
res.json(createError(401, 'Please login to view this page.'));

如果您使用成功代码,只需使用res.status并像使用默认的200状态一样返回您的 JSON 对象:

res.status(201); // Set 201 instead of 200 to indicate resource created
res.json(messageObject); // An object containing your response

注意

有许多很少使用的状态代码;其中包括一些在互联网历史上创建的笑话代码:

418-我是一个茶壶:1998 年愚人节的一个笑话。它表示服务器拒绝冲泡咖啡,因为它是一个茶壶。

420-增强您的冷静:在 Twitter 的原始版本中使用,当应用程序被限制速率时。这是对电影《拆弹专家》的引用。

设计您的 API

在软件设计过程的早期考虑 API 的设计非常重要。在发布后更改 API 的端点将需要更新依赖于这些端点的任何服务。如果 API 发布供公众使用,则通常需要保持向后兼容。在规划端点、接受的 HTTP 方法、所需的输入类型和返回的 JSON 结构上花费的时间将在长远节省下来。

通常,可以找到与您特定用例或行业相关的指南,因此请务必提前进行研究。在我们的智能家居 API 示例中,我们将从万维网联盟WC3)关于 IoT 设备的推荐中汲取灵感。WC3 是致力于制定 Web 标准的最有影响力的组织之一,他们的 IoT 倡议被称为物联网WoT)。您可以在www.w3.org/WoT/了解更多信息。

根据 WoT 指南,每个设备都应包含有关模型的信息以及可与设备一起使用的操作列表。以下是 WoT 标准推荐的一些端点:

图 4.10:标准 WoT 路由表

图 4.10:标准 WoT 路由表

这种设计有两个原因很有用-首先,因为它符合标准,这给用户一组期望。其次,使用诸如/properties//actions/之类的辅助端点使用户能够通过在这些端点请求附加信息来发现 API 的使用方式。

添加到房屋的每个设备都应该有/model//properties//actions/端点。我们将在我们的 API 中将上表中显示的端点映射到每个设备上。以下树状图显示了从根端点开始的我们 API 的映射。

以下图中的第三级显示了/devices/light/端点,并且从该端点开始,我们有上表中列出的端点:

图 4.11:智能家居 API 设计的树状图

图 4.11:智能家居 API 设计的树状图

作为端点返回的 JSON 的示例,我们将更仔细地查看前图中定义的/devices/light/actions路由。以下示例显示了包含名为Fade的单个操作的操作对象:

"actions": {
  "fade": {
    "title": "Fade Light",
    "description": "Dim light brightness to a specified level",
    "input": {
      "type": "object",
      "properties": {
        "level": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100
        },
        "duration": {
          "type": "integer",
          "minimum": 0,
          "unit": "milliseconds"
        }
      }
    },
    "links": [{"href": "/light/actions/fade"}]
  }
}

我们的fade操作是基于 Mozilla 在其 WoT 文档中提出的建议。他们创建了这份文档,目标是补充 W3C 提出的标准,并包含了许多代表 IoT 设备及其相关操作的 JSON 示例。

注意对象包含操作的名称、操作的描述以及使用操作的接受值。在适用的情况下,包含单位的度量单位也总是一个好主意。通过持续时间,我们知道它是以毫秒为单位的;如果没有这些信息,我们就不知道"1"实际上是什么意思。

通过阅读前面的 JSON,我们可以看到我们需要发送一个请求,其中包含所需的照明级别(0 到 100)的数字,以及另一个数字来指定调暗的时间长度。使用curl,我们可以这样淡化灯光:

curl -sd "level=80&duration=500" -X PUT localhost:3000/lightBulb/actions/fade

根据 API 操作描述,前面的请求应该导致灯泡在 500 毫秒的时间内淡出到 80%的亮度。

注意

Swagger 文档:虽然本书不涉及,但你应该了解的另一个项目是 Swagger。这个项目有助于自动化创建、更新和显示 API 文档,并与 Node.js 和 Express 很好地配合。

Swagger 生成的交互式文档示例可在petstore.swagger.io/中看到。

练习 18:创建操作路由

在这个练习中,我们的目标是创建一个新的路由文件,返回关于fade操作的信息,这是我们在上一节中看到的。这个练习的起点将是我们在练习 17,创建和导入路由文件结束时留下的地方。

注意

这个示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise18找到。

执行以下步骤完成练习:

  1. routes文件夹中创建一个名为devices的子文件夹:
mkdir routes/devices
  1. routes/index.js复制到routes/devices/light.js
cp routes/index.js routes/devices/light.js
  1. 接下来,我们将打开上一个练习中的/routes/devices/light.js并修改它。找到第 6 行,应该包含以下内容:
info.message = "Welcome home! Our first endpoint.";

我们将用一个大块的 JSON 代替前面的行,表示所有设备操作的列表:

  let info =    {
    "actions": {
      "fade": {
        "title": "Fade Light",
        "description": "Dim light brightness to a specified level",
        "input": {
          "type": "object",
          "properties": {
            "level": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },

在我们的情况下,唯一的操作是fade。这个操作将在一定的时间内(以毫秒为单位)改变灯泡的亮度级别。这个端点不包含实现功能的逻辑,但它将返回与之交互所需的细节。

  1. server.js文件中,导入我们新创建的设备路由:
let light = require('./routes/devices/light');
  1. 现在我们将告诉 Express 使用我们的light对象来使用前面的路由:
app.use('/devices/light', light);
  1. 使用npm start运行程序:
npm start
  1. 使用curljq测试路由:
curl -s localhost:3000/devices/light | jq

如果你正确复制了前面的代码,你应该得到一个格式化的 JSON 对象,表示fade操作如下:

图 4.12:localhost:3000/devices/light 的 cURL 响应

图 4.12:localhost:3000/devices/light 的 cURL 响应

进一步模块化

在项目文件中,我们将通过创建一个lightStructure.js文件进一步分离灯路由,其中只包含表示灯的 JSON 对象。我们不会包括包含modelpropertiesaction描述的长字符串的 JSON。

注意

在本节中对所做更改不会有练习,但你可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Example/Example18b找到代码。

练习 19将使用在Example18b文件夹中找到的代码开始。

将静态数据(如端点对象和单独文件的函数)分离是有用的。lightStructure.js将包含表示模型、属性和操作的数据。这使我们能够专注于light.js中端点的逻辑。有了这个,我们将有四个端点,每个端点都返回 JSON 灯对象的相关部分:

// Light structure is imported at the top of the file
const lightStructure = require('./lightStructure.js');
// Create four routes each displaying a different aspect of the JSON object
router.get('/', function(req, res, next) {
  let info = lightStructure;
  res.json(info);
});
router.get('/properties', function(req, res, next) {
  let info = lightStructure.properties;
  res.json(info);
});
router.get('/model', function(req, res, next) {
  let info = lightStructure.model;
  res.json(info);
});
router.get('/actions', function(req, res, next) {
  let info = lightStructure.actions;
  res.json(info);
});

在处理像lightStructure.js中找到的大块 JSON 时,可以使用 GUI 可视化工具非常有用。一个例子是jsoneditoronline.org/,它提供了一个工具,允许您在页面的左侧部分粘贴一个 JSON 块,并在右侧将其可视化为类似树状对象的形式:

图 4.13:在线 JSON 资源管理器/编辑器

可在可视化的任一侧进行更改并复制到另一侧。这很有用,因为 JSON 对象变得越复杂,就越难以看到属性中存在多少级别。

对发送到端点的输入进行类型检查和验证

虽然类型检查和验证对于创建 API 并不是严格要求的,但使用它们可以减少调试时间并帮助避免错误。对端点的输入进行验证意味着可以专注于返回期望的结果的代码编写,而不必考虑输入超出预期范围所产生的许多边缘情况。

由于这个任务在创建 API 时非常常见,因此已经创建了一个库来简化验证 Express 端点的输入。使用express-validator中间件,我们可以简单地将输入要求作为参数传递给我们的端点。例如,我们在练习 18中返回的 JSON 对象描述的要求,可以用以下数组表示:

  check('level').isNumeric().isLength({ min: 0, max: 100 }),
  check('duration').isNumeric().isLength({ min: 0 })
]

如您所见,它包含了每个预期输入的条目。对于这些输入的每一个,我们执行两个检查。第一个是.isNumeric(),用于检查输入是否为数字。第二个是.isLength(),用于检查长度是否在指定的最小到最大范围内。

练习 19:创建带有类型检查和验证的路由

注意

此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise19找到。

在这个练习中,我们将通过在routes/devices/light.js文件中添加一个接受PUT请求的路由/actions/fade来扩展。

路由将检查请求是否符合我们在练习 18,返回表示动作路由的 JSON中添加到devices/light端点的fade动作对象指定的标准。这包括以下方面:

  • 请求包含级别和持续时间值。

  • 级别和持续时间的值是整数。

  • 级别值介于 0 和 100 之间。

  • 持续时间值大于 0。

执行以下步骤完成练习:

  1. 安装express-validator,这是一个中间件,用于在express中轻松使用validationsanitization函数包装validator.js
npm install -s express-validator
  1. 通过将routes/devices/light放在第 2 行导入express-validator库中的checkvalidationResult函数,就在expressrequire语句下方:
const { check, validationResult } = require('express-validator/check');
  1. 在上一练习中编写的route.get函数下面,创建以下函数来处理PUT请求:
// Function to run if the user sends a PUT request
router.put('/actions/fade', [
    check('level').isNumeric().isLength({ min: 0, max: 100 }),
    check('duration').isNumeric().isLength({ min: 0 })
  ],
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(422).json({ errors: errors.array() });
    }
    res.json({"message": "success"});
});
  1. 使用npm start运行 API:
npm start
  1. /devices/light/actions/fade进行PUT请求,使用不正确的值(na)来测试验证:
curl -sd "level=na&duration=na" -X PUT \
http://localhost:3000/devices/light/actions/fade | jq

-d标志表示要传递给端点的“数据”值。-X标志表示 HTTP 请求类型。

如果前面的步骤执行正确,当我们对/devices/light/actions/fade进行PUT请求时,如果级别和持续时间的值为非数字,我们应该会收到错误:

图 4.14:/device/light/actions/fade 路由的 cURL 错误响应,数据不正确

图 4.14:/device/light/actions/fade 路由的 cURL 错误响应
  1. 接下来,我们将像以前一样进行PUT请求,但使用正确的值5060
curl -sd "level=50&duration=60" -X PUT \
http://localhost:3000/devices/light/actions/fade | jq

发送具有正确范围内值的PUT请求应返回以下内容:

图 4.15:/device/light/actions/fade 路由的 cURL 响应与正确数据

图 4.15:/device/light/actions/fade 路由的 cURL 响应与正确数据

上述截图表明PUT请求成功。

有用的默认值和简单的输入

因此,我们已经看到了对端点输入施加限制如何有所帮助。然而,过多的限制和要求可能会妨碍 API 的用户体验。让我们更仔细地看一下灯泡淡入淡出动作。为了允许在一段时间内淡入淡出的功能,我们要求用户传递一个持续时间的值。许多人已经有使用物理灯泡上的淡入淡出动作的经验。

对于物理灯泡,我们知道我们通过调节物理开关或其他输入来输入我们期望的亮度级别。持续时间不一定是这个过程的一部分,或者用户有意识地考虑过。这会导致期望您应该能够仅通过所需级别来淡化光线。

因此,我们应该考虑使duration值变为可选。如果没有收到duration值,脚本将退回到默认值。这使我们能够满足用户的期望,同时仍允许那些想要指定持续时间的用户进行精细控制。

练习 20:使持续时间输入变为可选

注意

此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise20找到。

在这个练习中,我们将修改淡入淡出动作,使持续时间成为可选输入。如果没有提供持续时间值,我们将修改我们的淡入淡出动作端点,使用默认值 500 毫秒:

  1. routes/devices/light.js中,通过在函数链中添加.optional()来修改验证duration的行。它应该是这样的:
check('duration').isNumeric().optional().isLength({ min: 0 })
  1. routes/devices/light.js中,删除return语句,并在相同位置添加以下内容:
let level = req.body.level;
let duration;
if(req.body.duration) {
  duration = req.body.duration;
} else {
  duration = 500;
}

上述代码使用level输入创建了一个level变量,并初始化了一个空变量用于持续时间。接下来,我们检查用户是否提供了duration输入。如果是,我们将持续时间设置为该值。如果没有,我们将duration设置为500

  1. 现在,我们将使用我们的levelduration变量创建一个名为messagemessage对象。然后,我们将将该message对象返回给客户端:
let message = `success: level to ${level} over ${duration} milliseconds`;
res.json({"message": message});
  1. 最后,我们将将第二个路由与我们的函数关联起来,以便向/devices/light发送PUT请求执行与/devices/light/actions/fade相同的功能。这是通过将router.put的第一个参数更改为包含旧值和新值/的数组来实现的。router.put部分的开头应该是这样的:
// Function to run if user sends a PUT request
router.put(['/', '/actions/fade'], [
    check('level').isNumeric().isLength({ min: 0, max: 100 }),
    check('duration').isNumeric().optional().isLength({ min: 0 })
  ],
  (req, res) => {
  1. 现在我们已经完成了编码部分,我们将打开服务器进行测试:
npm start
  1. 在一个终端中运行服务器,打开另一个终端使用curl进行一些测试。在第一条命令中,我们将检查我们的新默认端点是否正常工作,并且在没有提供持续时间时使用我们的默认值:
curl -sd "level=50" -X PUT http://localhost:3000/devices/light | jq

如果您已经正确复制了所有内容,您应该会看到这样的输出:

图 4.16:/device/light 路由的 cURL 响应,没有指定持续时间

图 4.16:/device/light 路由的 cURL 响应,没有指定持续时间
  1. 我们还希望确保提供duration值会覆盖默认值。我们可以通过进行 cURL 请求来测试这一点,该请求指定了duration值:
curl -sd "level=50&duration=250" -X PUT http://localhost:3000/devices/light | jq

当将250指定为duration值时,我们应该在响应中看到level将会变为 250 毫秒以上的确认:

图 4.17:/device/light 路由的 cURL 响应,指定了持续时间

图 4.17:指定持续时间的/device/light 路由的 cURL 响应

通过这些更改,我们现在已经将fade设置为/devices/light的默认操作,并且如果未提供持续时间输入,则给出了默认值。值得注意的是,我们现在有两个与/devices/light端点相关联的函数:

  • HTTP GET /devices/light:这将返回与灯交互的信息。

  • HTTP PUT /devices/light:这执行灯的默认操作。

多种方法重复使用相同的端点是一个很好的做法。另一个常见的例子是博客条目,其中 API 可能具有基于使用的方法的四个函数的单个端点:

  • HTTP POST /blog/post/42:这将创建 ID 为 42 的博客文章。

  • HTTP GET /blog/post/42:这将以 JSON 对象返回博客文章#42。

  • HTTP PUT /blog/post/42:这通过发送新内容编辑博客文章#42。

  • HTTP DELETE /blog/post/42:这将删除博客文章#42。

这在逻辑上使用 REST 模型是有意义的,其中每个端点代表可以以各种方式进行交互的资源。

在我们的案例中,我们已经向/devices/light路由发出了PUT请求,触发了fade函数。可以说,一个打开和关闭灯的switch函数更符合大多数人对灯的默认操作的期望。此外,开关将是更好的默认选项,因为它不需要客户端的任何输入。Fade 之所以被选择是因为认为开关过于简单。

我们不会深入讨论switch函数,但它可能包含类似以下代码段的内容,允许客户端指定所需的状态。如果未指定状态,则它将成为当前值的相反值:

if(req.body.state) {
  state = req.body.state;
} else {
  state = !state;
}

中间件

Express 中的中间件函数是在与端点关联的函数之前运行的函数。一些常见的例子包括在运行端点的主函数之前记录请求或检查身份验证。在这些情况下,记录和身份验证函数将在使用它们的所有端点中是常见的。通过使用中间件,我们可以重用在端点之间常见的代码。

使用 Express,我们可以通过使用app.use()来运行所有端点的中间件函数。例如,如果我们想要创建一个在运行主路由之前将请求记录到控制台的函数,我们可以编写一个logger中间件:

var logger = function (req, res, next) {
  // Request is logged
  console.log(req);
  // Call the special next function which passes the request to next function
  next();
}

要使记录器在所有端点上运行,我们告诉我们的应用程序使用以下内容:

app.use(logger);

如果我们希望我们的中间件函数仅在某些路由上运行,我们可以直接附加它:

app.use('/devices/light', logger, light);

对于一些或所有路由,可以使用多个中间件函数,没有限制。当使用多个中间件函数时,它们按照在代码中声明的顺序调用。当一个中间件函数完成时,它将reqres对象传递给链中的下一个函数:

图 4.18:中间件链接图

图 4.18:中间件链接图

前面的图表可视化了一个请求过程,其中一旦服务器接收到请求,它将运行第一个中间件函数,将结果传递给第二个中间件函数,当完成时,最终运行我们的/devices/light目标路由。

在下一节中,我们将创建自己的中间件来检查客人是否已经签到以获取身份验证令牌。

练习 21:设置需要身份验证的端点

注意

此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise21找到。

在下一个练习中,我们将通过添加一个需要身份验证的端点来完善我们的项目,该身份验证需要使用JSON Web TokenJWT)。我们将创建两个新的端点:第一个restricted light,与light相同,但需要身份验证。第二个端点check-in允许客户端通过向服务器发送他们的名称来获取令牌。

注意

JWT 和安全性:此练习旨在突出 JWT 身份验证的工作原理。在生产中,这不是安全的,因为没有办法验证客户端提供的名称是否真实。

在生产中,JWT 还应包含一个到期日期,客户端必须在该日期之前更新令牌以继续使用。例如,给移动应用客户端的令牌可能具有 7 天的到期日期。客户端可能在启动时检查令牌是否即将到期。如果是这样,它将请求更新的令牌,应用程序的用户将不会注意到这个过程。

然而,如果移动应用的用户多天没有打开它,该应用将要求用户重新登录。这增加了安全性,因为任何可能找到 JWT 的第三方只有很短的时间来使用它。例如,如果手机丢失并在几天后被找到,许多使用带有到期日期的 JWT 的应用程序将需要再次登录以与所有者的帐户交互。

执行以下步骤以完成练习:

  1. 创建一个带有随机密钥值的config.js文件:
let config = {};
config.secret = "LfL0qpg91/ugndUKLWvS6ENutE5Q82ixpRe9MSkX58E=";
module.exports = config;

前面的代码创建了一个config对象。它将configsecret属性设置为一个随机字符串。然后,导出config对象。

重要的是要记住,密钥是随机的,因此您的密钥应该与此处显示的密钥不同。没有固定的方法来生成随机字符串,但在命令行上的一个简单方法是使用openssl,它应该默认安装在大多数 Linux 和 Mac 操作系统上:

openssl rand -base64 32
  1. 使用npm安装jwt-simple
npm install -s jwt-simple
  1. check-in端点创建routes/check-in.js文件。导入以下模块,我们将需要使用它们:
const express = require('express');
const jwt = require('jwt-simple');
const { check, validationResult } = require('express-validator/check');
const router = express.Router();
// import our config file and get the secret value
const config = require('../config');
const secret = config.secret;
  1. routes/check-in.js中的导入下面,我们将创建一个需要name的字符串值的post路由。然后,我们将对发送的所有信息进行编码成 JWT。然后将此 JWT 返回给客户端用于身份验证:
router.post('/', [
    check('name').isString()
  ],
  (req, res) => {
    // If errors return 422, client didn't provide required values
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(422).json({ errors: errors.array() });
    }
    // Otherwise use the server secret to encode the user's request as a JWT
    let info = {};
    info.token = jwt.encode(req.body, secret);
    res.json(info);
});
// Export route so it is available to import
module.exports = router;
  1. server.js中,还要导入config.jsjwt-simple,并设置密钥值:
// Import library for working with JWT tokens
const jwt = require('jwt-simple');
// import our config file and get the secret value
const config = require('../config');
const secret = config.secret;
  1. server.js中,添加一个中间件函数,以查看用户是否具有有效令牌:
// Check if the requesting client has checked in
function isCheckedIn(req, res, next) {
  // Check that authorization header was sent
  if (req.headers.authorization) {
    // Get token from "Bearer: Token" string
    let token = req.headers.authorization.split(" ")[1];
    // Try decoding the client's JWT using the server secret
    try {
      req._guest = jwt.decode(token, secret);
    } catch {
      res.status(403).json({ error: 'Token is not valid.' });
    }
    // If the decoded object has a name protected route can be used
    if (req._guest.name) return next();
  }
  // If no authorization header or guest has no name return a 403 error
  res.status(403).json({ error: 'Please check-in to recieve a token.' });
}
  1. server.js中,添加check-in端点和第二个restricted-light端点的 light:
// Import our index route
let index = require('./routes/index');
let checkIn = require('./routes/check-in');
let light = require('./routes/devices/light');
// Tell Express to use our index module for root URL
app.use('/', index);
app.use('/check-in', checkIn);
app.use('/devices/light', light);
app.use('/devices/restricted-light', isCheckedIn, light);

server.js的部分,其中导入和设置路由的代码应该看起来像前面的代码,添加了三行新代码。您可以看到有一行用于导入check-in路由,另外两行用于创建我们的新路由。请注意,我们不需要导入restricted-light,因为它重用了light对象。restricted-lightlight的关键区别在于使用了isCheckedIn中间件函数。这告诉express在提供 light 路由之前运行该函数。

  1. 使用npm start打开服务器:
npm start
  1. 打开另一个终端窗口,并运行以下命令以获取签名的 JWT 令牌:
TOKEN=$(curl -sd "name=john" -X POST http://localhost:3000/check-in \
  | jq -r ".token")

前面的命令使用curl将名称发布到check-in端点。它获取服务器的结果并将其保存到名为TOKEN的 Bash 变量中。TOKEN变量是在运行该命令的终端窗口中本地的;因此,如果关闭终端,则需要再次运行。要检查它是否正确保存,告诉 Bash shell 打印该值:

echo $TOKEN

以下是前面代码的输出:

图 4.19:在 Bash shell 中检查$TOKEN 的值

图 4.19:在 Bash shell 中检查$TOKEN 的值

您应该看到一个 JWT 令牌,如前面的图所示。

  1. 通过在终端中运行以下命令,向restricted-light发送带有身份验证令牌的 cURL 请求:
curl -sd "level=50&duration=250" -X PUT \
  -H "Authorization: Bearer ${TOKEN}" \
  http://localhost:3000/devices/restricted-light \
  | jq

它应该返回一个成功的淡入效果,如下图所示:

图 4.20:使用 JWT 成功向 restricted-light 发送 cURL 请求

图 4.20:使用 JWT 成功向 restricted-light 发送 cURL 请求
  1. 在终端中向restricted-light发送不带身份验证令牌的curl请求:
curl -sd "level=50&duration=250" -X PUT \
  http://localhost:3000/devices/restricted-light \
  | jq

相比之下,发送相同的请求但不带端点会返回错误:

图 4.21:尝试在没有 JWT 的情况下 cURL restricted-light

图 4.21:尝试在没有 JWT 的情况下 cURL restricted-light

我们现在已经设置了一个端点来分发身份验证令牌,并且有一个需要这些令牌的受保护的端点。我们现在可以通过重用我们的isCheckedIn函数与任何新的端点来添加需要身份验证令牌的额外路由。我们只需要将该函数作为第二个参数传递给 Express,就像在server.js中所做的那样。

JWT 的内容

在上一个练习中,在步骤 7期间,我们从服务器请求了一个令牌,并将该值保存到我们的本地终端会话中。为了使练习有效,JWT 应该有三个部分,由句点分隔。如果我们将从echo $TOKEN命令返回的 JWT 放入网站 jwt.io 中,我们可以更仔细地查看 JWT 的内容。

此外,将您的秘密值粘贴到 GUI 的右下角,应在左下角显示“签名已验证”。这告诉我们,查看的 JWT 是使用私有签名创建的:

图 4.22:显示 JWT.io 与 JWT 数据

图 4.22:显示 JWT.io 与 JWT 数据

JWT 网站允许我们轻松地可视化 JWT 的三个部分代表什么。红色的第一部分是标头,即描述所使用的编码标准的信息。紫色部分是有效载荷-它包含在创建令牌时服务器验证的数据,在我们的情况下只是一个名称。最后,蓝色部分是签名,它是使用服务器的秘密对其他两个部分的内容进行哈希的结果。

在前面的示例中,有效载荷部分是三个部分中最小的。这并不总是这样,因为红色和蓝色部分的大小是固定的,而紫色部分取决于有效载荷的大小。如果我们使用check-in端点从服务器请求另一个令牌,那么我们不仅提供一个名称,还提供电子邮件和电话号码。这意味着我们将看到一个具有较大紫色部分的结果令牌:

图 4.23:JWT.io 显示具有较大负载的令牌

图 4.23:JWT.io 显示具有较大负载的令牌

MongoDB

许多 API 使用数据库来跟踪 API 读取和写入的基础数据。在其他情况下,例如物联网,端点的功能可能会更新真实对象。即使在跟踪或触发真实对象或事件时,跟踪数据库中的预期状态也是一个好主意。可以快速访问和操作数据库表示。

我们不会深入讨论数据库的使用和设计;但是,我们将简要讨论如何使用数据库来扩展 API 的功能。很少会有一个 API 在不使用某种数据库的情况下超越hello world

与 Node.js 一起使用最广泛的数据库是 MongoDB。MongoDB 是一个面向对象的库,具有方便的语法,可用于处理 JSON 对象。除了将数据存储为类似 JSON 的对象之外,它不需要使用模式。这意味着对象的属性可以随时间改变,而无需对数据库进行任何配置。

例如,我们可以开始在数据库中跟踪事件,这些事件只包含请求正文和时间戳:

{
  "timestamp": 1556116316288,
  "body" : { "level" : "50", "duration" : "250" }
}

我们可能会从一个非常简单的事件日志开始,然后决定随着每个事件保存额外的细节。例如,如果我们包括授权数据和请求的确切路径,我们的日志对象将如下所示:

{
  "timestamp": 1556116712777,
  "body" : { "level" : "20", "duration" : "500" },
  "path" : "/devices/light",
  "token" : null
}

如果使用 SQL 数据库,我们首先需要向数据库模式添加pathtoken列。MongoDB 的灵活性是其伟大特性之一,以及将其添加到已经使用 JSON 进行数据操作的项目的简单性。

通常,API 将完全基于数据库,就像大多数社交媒体应用一样。例如,对于 Twitter、Facebook 和 Instagram,每个用户、帖子和评论最终都是数据库中的一个条目,通过 API 向客户端软件提供访问。

我们不会深入讨论如何在 API 中使用数据库,但是额外的文件夹包含了如何设置 MongoDB 并将其与此 API 一起使用以记录事件的说明(请参见下面的注释)。

使用 JWT 进行事件记录将允许我们将受限端点的任何恶意使用与特定的 JWT 关联起来。通过使用日志系统并强制在所有端点上使用 JWT,我们可以将任何请求的操作与smartHouse关联到特定用户。在恶意使用的情况下,JWT 可以被列入黑名单。当然,这将需要更严格的要求来发放 JWT;例如,要求客人出示政府发行的照片身份证明。

注意

带有 MongoDB 日志记录示例的中间件:您可以参考项目文件中名为extra/mongo_logger_middleware的文件夹,了解创建一个捕获所有信息的中间件的示例,包括请求的方法、数据和用户信息。类似的东西可以用来跟踪由谁发出的请求。

运行此代码时,您需要首先运行npm install。除此之外,确保您已经在本地安装并运行了 MongoDB。有关更多详细信息,请参阅文件夹中的 README 文件github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Example/extra/mongo_logger_middleware

活动 5:为键盘门锁创建 API 端点

在这个活动中,您需要为键盘门锁创建一个 API 端点。该设备需要一个新的端点来支持经过身份验证的用户能够创建一次性密码来打开门的用例。

执行以下步骤完成活动:

  1. 创建一个新的项目文件夹并切换到该文件夹。

  2. 初始化一个npm项目并安装expressexpress-validatorjwt-simple。然后,创建一个routes目录。

  3. 创建一个config.js文件,其中应包含一个随机生成的秘密值。

  4. 创建routes/check-in.js文件,以创建一个签到路由。

  5. 创建一个名为routes/lock.js的第二个路由文件。首先导入所需的库和模块,然后创建一个空数组来保存我们的有效密码。

  6. routes/lock.js中的代码下面,创建一个GET路由,用于/code,需要一个name值。

  7. routes/lock.js中创建另一个路由。这个路由将是/open,需要一个四位数的代码,将被检查是否在passCodes数组中有效。在该路由下面,确保导出router,以便在server.js中使用。

  8. 创建主文件,在其中我们的路由将在server.js中使用。首先导入所需的库,还有设置 URL 编码的 JSON。

  9. 接下来,在server.js中,导入这两个路由,实现一个404捕获,并告诉 API 监听端口3000

  10. 测试 API 以确保它被正确完成。首先运行您的程序。

  11. 程序运行时,打开第二个终端窗口,使用/check-in端点获取 JWT 并将值保存为TOKEN。然后,回显该值以确保成功。

  12. 使用我们的 JWT 来使用/lock/code端点获取新名称的一次性验证码。

  13. 两次向/lock/open端点发送代码,以获取第二个实例的错误。

注意

此活动的解决方案可在第 594 页找到。

摘要

在本章中,我们探讨了使用 Node.js 创建 RESTful API 的用途。我们考虑了 API 的各种用途以及一些设计技巧。通过查看诸如 HTTP 代码和输入验证之类的方面,我们考虑了在创建和维护 API 时处理的常见问题。尽管如此,仍有许多 API 设计和开发领域尚未考虑。

继续提高您关于 API 设计和创建的知识的最佳方法是开始制作自己的 API,无论是在工作中还是通过个人项目。我们在本章的练习中创建的代码可以用作起点。尝试扩展我们在这里所做的工作,创建您自己的端点,最终创建您自己的 API。

在下一章中,我们将讨论代码质量。这将包括编写可读代码的技术,以及用于测试我们代码的技术。这些技术可以与您在这里学到的内容结合使用,以确保您创建的端点在项目增长时继续返回正确的值。

第六章:模块化 JavaScript

学习目标

在本章结束时,您将能够:

  • 在 JavaScript 中导入和导出函数和对象以实现代码的可重用性

  • 使用 JavaScript ES6 类来减少代码复杂性

  • 在 JavaScript 中实现面向对象编程概念

  • 使用封装为对象创建私有变量

  • 使用 Babel 将 ES6 转换为通用 JavaScript

  • 在 JavaScript 中创建和发布 npm 包

  • 使用组合性和策略结合模块创建更高级的模块。

在本章中,我们将学习现代 JavaScript 中可重用代码的重要性,以及 ES6 如何引入了用于轻松创建和使用模块的语法。我们将创建一个 JavaScript 模块,可以被 API 的不同端点导入和使用。

介绍

在上一章中,我们使用 Node.js 和 Express 构建了一个 API。我们讨论了设计 API 结构、HTTP 方法和JSON Web TokenJWT)身份验证。在本章中,我们将研究 JavaScript 模块和基于模块的设计的各个方面。

模块对于编程生产力很重要,将软件分解为可重用的模块。模块化设计鼓励开发人员将软件构建成小的、单一焦点的组件。您可能熟悉流行的 UI 库,如 Bootstrap、Material-UI 和 jQuery UI。这些都是一组组件 - 专门构建的最小图形元素,可以在许多情况下使用。

由于广泛使用外部库来处理图形元素和编程方面,大多数开发人员已经熟悉了模块的使用。也就是说,使用模块比创建模块或以模块化方式编写应用程序要容易得多。

注意组件、模块和 ES6 模块

关于这些术语的确切用法和关系有各种不同的观点。在本章中,我们将组件称为可以在网站上使用的视觉小部件。

我们将把一个模块称为在一个文件中编写的源代码,以便在另一个文件中导入和使用。由于大多数组件都存在为可重用代码,通常通过脚本标签导入,我们将把它们视为模块。当然,当您导入 Bootstrap 库时,您导入了所有组件。也就是说,大多数库都提供了编译和导入所需的特定组件的能力 - 例如,getbootstrap.com/docs/3.4/customize/

当我们提到 ES6 模块时,我们指的是 ES6 中添加的特定语法,允许在一个文件中导出一个模块,并在另一个文件中导入它。虽然 ES6 模块是 ES6 标准的一部分,但重要的是要记住它们目前不受浏览器支持。使用它们需要一个预编译步骤,我们将在本章中介绍。

JavaScript 的受欢迎程度和生产力的最近爆炸部分原因是node 包管理器npm)生态系统。无论是使用 JavaScript 进行前端还是后端开发,您都可能在某个时候使用 npm。通过简单的npm install命令,开发人员可以获得数百个有用的包。

npm 现在已成为互联网上模块化代码的最大来源,超过任何编程语言。npm 现在包含了将近 50 亿个包。

npm 上的所有包本质上都是模块。通过将相关函数分组为一个模块,我们使得该功能可以在多个项目或单个项目的多个方面中重复使用。

所有在 npm 上的优秀包都是以一种使其在许多项目中易于重用的方式构建的。例如,一个很好的日期时间选择器小部件可以在成千上万个项目中使用,节省了许多开发时间,并且可能产生更好的最终产品。

在本节中,我们将讨论模块化的 JavaScript 以及如何通过以模块化的方式编写 JavaScript 来改进我们的代码。这包括导出和导入的基本语法,但除此之外,还有几种模式和技术可用于编写更好的模块,例如在模块开发中有用的面向对象编程的概念。然而,JavaScript 在技术上是原型导向的,这是一种与经典面向对象风格不同的特定风格的面向对象编程,它使用原型而不是类。我们将在本章后面讨论原型和类。

依赖关系和安全性

模块是一种强大的技术,但如果不小心使用,它们也可能失控。例如,添加到node.js项目中的每个包都包含自己的依赖关系。因此,重要的是要密切关注您正在使用的包,以确保您不会导入任何恶意内容。在网站npm.broofa.com上有一个有用的工具,您可以在那里上传package.json文件并获得依赖关系的可视化。

如果我们以第一章练习 1,使用 Express 创建项目并添加索引路由中的package.json文件为例,它只包含四个dependencies

  "dependencies": {
   "express": "⁴.16.4",
   "express-validator": "⁵.3.1",
   "jwt-simple": "⁰.5.6",
   "mongodb": "³.2.3"
  }

然而,当我们上传这个package.json文件时,我们可以看到我们的 4 个依赖项在考虑子依赖时激增到了 60 多个:

图 5.1:package.json 中的 61 个依赖项

这突显了基于模块的设计所带来的风险,以及在制作和使用模块时需要深思熟虑的设计。糟糕编写的包或模块可能会产生意想不到的后果。近年来,有关广泛使用的包变得恶意的报道。例如,event-stream包在 2018 年的 2.5 个月内被下载了 800 多万次。发现这个曾经合法的模块已经更新,试图从用户的机器中窃取加密货币。除了安全风险和错误之外,还存在污染全局命名空间或降低父项目性能的风险。

注意 npm audit

作为对恶意依赖或子依赖的情况的回应,npm 添加了一个audit命令,可以用来检查包的依赖关系,以查看已知为恶意的模块。在 Node.js 项目的目录中运行npm audit来检查项目的依赖关系。当您安装从 GitHub 等地方下载的项目时,该命令也会自动作为npm install的一部分运行。

模块化的其他成本

与模块化设计相关的其他成本包括:

  • 加载多个部分的成本

  • 坏模块的成本(安全性和性能)

  • 使用的模块总量迅速增加

总的来说,这些成本通常是可以接受的,但应该谨慎使用。当涉及到加载许多模块所带来的开销时,预编译器(如webpackbabel)可以通过将整个程序转换为单个文件来帮助。

在创建模块或导入模块时需要牢记以下几点:

  • 使用模块是否隐藏了重要的复杂性或节省了大量的工作?

  • 模块是否来自可信任的来源?

  • 它是否有很多子依赖?

以 npm 包isarray为例。该包包含一个简单的函数,只是运行:

return toString.call(arr) == '[object Array]';

这是一个例子,第一个问题的答案是“使用模块是否隐藏了重要的复杂性?”不是。第二个问题 - “它是来自可信任的来源吗?”并不特别。最后,对于关于子依赖的最后一个问题的回答是不是 - 这是一件好事。鉴于这个模块的简单性,建议根据前面的单行编写自己的函数。

应避免随意安装增加项目复杂性而几乎没有好处的包。如果您考虑到了提到的三点,您可能不会觉得值得导入诸如isarray之类的包。

审查进口和出口

在上一节中,我们使用了导入和导出,但没有深入讨论这个主题。每当我们创建一个新的路由时,我们都会确保将其放在routes文件夹中的自己的文件中。如果您还记得,我们所有的路由文件都以导出router对象的行结束:

module.exports = router;

我们还使用了 Node.js 内置的require函数来使用我们的路由:

let light = require('./routes/devices/light');

关注点分离

在设计模块时,关键概念之一是关注点分离。关注点分离意味着我们应该将软件分成处理程序的单个关注点的部分。一个好的模块将专注于很好地执行单个功能方面。流行的例子包括:

  • MySQL - 一个具有多种方法连接和使用 MySQL 数据库的包

  • Lodash - 一个用于高效解析和处理数组、对象和字符串的包

  • Moment - 一个用于处理日期和时间的流行包

在这些包或我们自己的项目中,通常还会进一步分成子模块。

注意 ES6

在之前的章节中,我们已经使用了一些 ES6 的特性,但是作为提醒,ES6,或者更长的 ECMAScript,是欧洲计算机制造商协会脚本的缩写。ECMA 是负责标准化标准的组织,包括 2015 年标准化的新版本 JavaScript。

ES6 模块

在使用 Node.js 编写 JavaScript 时,长期以来一直使用内置的require()函数来导入模块的能力。由于这个功能很有用,许多前端开发人员开始利用它,通过使用诸如 Babel 之类的编译器对他们的 JavaScript 进行预处理。JavaScript 预编译器处理通常无法在大多数浏览器上运行的代码,并生成一个兼容的新 JavaScript 文件。

由于 JavaScript 中对导入样式函数的需求很大,它最终被添加到了 ES6 版本的语言中。在撰写本文时,大多数浏览器的最新版本几乎完全兼容 ES6。然而,不能认为使用import是理所当然的,因为许多设备将继续运行多年前的旧版本。

ES6 的快速标准化告诉我们,未来,ES6 的导入将是最流行的方法。

在上一章中,我们使用了 Node.js 的require方法来导入模块。例如,看看这一行:

const express = require('express');

另一方面,ES6 的import函数具有以下语法:

import React from 'react';

ES6 的import函数还允许您导入模块的子部分,而不是整个模块。这是 ES6 的import相对于 Node.js 的require函数的一个能力。导入单个组件有助于节省应用程序中的内存。例如,如果我们只想使用 React 版本的 Bootstrap 中的button组件,我们可以只导入那个:

import { Button } from 'reactstrap';

如果我们想要导入额外的组件,我们只需将它们添加到列表中:

import { Button, Dropdown, Card } from 'reactstrap';

注意 React

如果您曾经使用过流行的前端框架 React,您可能已经看到过这种导入方式。该框架以模块化为重点而闻名。它将交互式前端元素打包为组件。

在传统的纯 JavaScript/HTML 中,项目通常被分成 HTML/CSS/JavaScript,各种组件分散在这些文件中。相反,React 将元素的相关 HTML/CSS/JavaScript 打包到单个文件中。然后将该组件导入到另一个 React 文件中,并在应用程序中用作元素。

练习 22:编写一个简单的 ES6 模块

注意

本章有一个起始点目录,可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/start找到。

此练习的完成代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise22找到。

在这个练习中,我们将使用 ES6 语法导出和导入一个模块:

  1. 切换到/Lesson_05/start/目录;我们将使用这个作为起点。

  2. 使用npm install安装项目依赖项。

  3. 创建js/light.js文件,其中包含以下代码:

let light = {};
light.state = true;
light.level = 0.5;
var log = function () {
  console.log(light);
};
export default log;
  1. 打开名为js/viewer.js的文件。这是将在我们页面上运行的 JavaScript。在文件顶部添加:
import light from './light.js';
  1. js/viewer.js的底部,添加:
light();
  1. js/viewer.js已经包含在index.html中,所以现在我们可以使用npm start启动程序。

  2. 在服务器运行时,打开一个 Web 浏览器,转到localhost:8000。一旦到达那里,按下F12打开开发者工具。

如果您做得没错,您应该在 Google Chrome 控制台中看到我们的对象被记录:

图 5.2:在 Google Chrome 控制台中记录的对象

图 5.2:在 Google Chrome 控制台中记录的对象

JavaScript 中的对象

如果您已经写了一段时间的 JavaScript,您很快就会遇到object类型。JavaScript 是使用原型设计的,这是一种基于对象的编程类型。JavaScript 中的对象是一个可以包含多个属性的变量。这些属性可以指向值、子对象,甚至函数。

JavaScript 程序中的每个变量都是对象或原始值。原始值是一种更基本的类型,只包含单个信息片段,没有属性或方法。使 JavaScript 变得更加复杂并使对象变得更加重要的是,即使是最基本的类型,如字符串和数字,一旦分配给变量,也会被包装在对象中。

例如:

let myString = "hello";
console.log(myString.toUpperCase()); // returns HELLO
console.log(myString.length); // returns 5

上述代码显示,即使在 JavaScript 中,基本的字符串变量也具有属性和方法。

真正的原始值没有属性或方法。例如,直接声明的数字是原始值:

5.toString(); // this doesn't work because 5 is a primitive integer
let num = 5;
num.toString(); // this works because num is a Number object

原型

如前所述,JavaScript 是一种基于原型的语言。这是面向对象编程的一种变体,其中使用原型而不是类。原型是另一个对象作为另一个对象的起点。例如,在上一节中,我们看了一个简单的字符串变量:

let myString = "hello";

正如我们在上一节中看到的,myString带有一些内置函数,比如toUpperCase(),以及属性,比如length。在幕后,myString是从字符串原型创建的对象。这意味着字符串原型中存在的所有属性和函数也存在于myString中。

JavaScript 对象包含一个名为__proto__属性的特殊属性,该属性包含对象的父原型。为了查看这一点,让我们在 Google Chrome 开发者控制台中运行console.dir(myString)

图 5.3:JavaScript 中的原型(字符串)

图 5.3:JavaScript 中的原型(字符串)

运行该命令返回String,一个包含多个方法的对象。内置的String对象本身具有原型。接下来,运行console.dir(myString.__proto__.__proto__)

图 5.4:JavaScript 中的原型(对象)

图 5.4:JavaScript 中的原型(对象)

再次运行带有附加__proto__属性的命令将返回null。JavaScript 中的所有原型最终都指向null,这是唯一一个本身没有原型的原型:

图 5.5:附加 _proto_ 返回 null

图 5.5:附加 proto 返回 null

这种一个原型导致另一个原型,依此类推的关系被称为原型链:

图 5.6:原型链

图 5.6:原型链

在 JavaScript 中,当你使用变量的属性时,它从当前对象开始查找,如果找不到,就会在父原型中查找。因此,当我们运行myString.toUpperCase()时,它首先在myString中查找。在那里找不到该名称的方法后,它会检查String,在那里找到该方法。如果String中没有包含该方法,它将检查Object原型,然后达到null,此时会返回not found error

JavaScript 提供了重新定义任何原型函数行为的语法,无论是内置的还是用户定义的。可以使用以下命令来实现:

Number.prototype.functionName = function () {
  console.log("do something here");
}

在下一个练习中,我们将修改内置的Number原型,以赋予它一些额外的功能。请记住,这种技术可以应用于内置和自定义的原型。

练习 23:扩展 Number 原型

在这个练习中,我们将看一个例子,扩展 JavaScript 的内置原型Number,以包含一些额外的函数。在步骤 1之后,看看你是否能自己想出第二个解决方案:

  • double(返回值乘以二)

  • square(返回数字乘以自身)

  • Fibonacci(返回斐波那契序列中的n,其中每个数字是前两个数字的和)

  • 阶乘(返回 1 和n之间所有数字的乘积的结果)

以下是要遵循的步骤:

  1. 在一个新的文件夹中,创建一个名为number.js的文件。我们将首先向Number原型添加一个double函数。注意使用this.valueOf()来检索数字的值:
Number.prototype.double = function () {
  return this.valueOf()*2;
}
  1. 接下来,按照相同的模式,我们将为任意数字的平方添加一个解决方案:
Number.prototype.square = function () {
  return this.valueOf()*this.valueOf();
}
  1. 同样,我们将遵循相同的模式,尽管这个问题的解决方案有点棘手,因为它使用了记忆递归,并且使用了BigInt原型:
Number.prototype.fibonacci = function () {
  function iterator(a, b, n) {
   return n == 0n ? b : iterator((a+b), a, (n-1n))
  }
  function fibonacci(n) {
   n = BigInt(n);
   return iterator(1n, 0n, n);
  }
  return fibonacci(this.valueOf());
}

注意 BigInt(大整数)

在前面的步骤中,你会注意到我们使用了BigInt关键字。BigIntNumber一样,是 JavaScript 内置的另一个原型。它是 ES6 中的第一个新的原始类型。主要区别在于BigInt可以安全处理非常大的数字。Number原型在大于9007199254740991的值时开始失败。

一个数字可以通过用BigInt()包装它或附加n来转换为BigInt;注意使用0n1n

  1. 接下来,我们将使用相同的模式和BigInt添加阶乘的解决方案:
Number.prototype.factorial = function () {
  factorial = (n) => {
   n = BigInt(n);
   return (n>1) ? n * factorial(n-1n) : n;
  }
  return factorial(this.valueOf());
}
  1. 为了演示,定义一个数字并调用函数:
let n = 100;
console.log(
  "for number " + n +"\n",
  "double is " + n.double() + "\n",
  "square is " + n.square() + "\n",
  "fibonacci is " + n.fibonacci() + "\n",
  "factorial is " + n.factorial() + "\n"
);
  1. 使用 Node.js 运行脚本:
node number.js

你应该得到类似以下的结果:

图 5.7:扩展 JavaScript 内置原型后的输出

图 5.7:扩展 JavaScript 内置原型后的输出

ES6 类

如前所述,基于原型的语言和经典面向对象语言之间的关键区别之一是使用原型而不是类。然而,ES6 引入了内置类。我们将通过创建Vehicle原型/类和Car原型/类,比较并使用原型语法和 ES6 类语法创建对象。

首先是原型的方式:

function Vehicle(name, color, sound) {
   this.name = name;
   this.color = color;
   this.sound = sound;
   this.makeSound = function() {console.log(this.sound);};
}
var car = new Vehicle("car", "red", "beep");
car.makeSound();

然后,使用 ES6 类做同样的事情:

class Vehicle {
   constructor(name, color, sound) {
      this.name = name;
      this.color = color;
      this.sound = sound;
      this.makeSound = () => console.log(this.sound);
   }
}
const car = new Vehicle("car", "red", "beep");
car.makeSound();

ES6 类语法允许我们以面向对象的方式编写代码。在语言的较低级别上,类只是用于创建原型的语法样式。

在接下来的部分,我们将讨论使用 ES6 类以面向对象的方式进行编程。

面向对象编程(OOP)

重要的是要清楚地区分 JavaScript 对象和面向对象编程(OOP)。这是两个非常不同的东西。JavaScript 对象只是一个包含属性和方法的键值对。另一方面,面向对象编程是一组原则,可以用来编写更有组织和高效的代码。

模块化 JavaScript 并不需要面向对象编程,但它包含许多与模块化 JavaScript 相关的概念。类的使用是面向对象编程的一个基本方面,它允许我们通过创建类和子类来重用代码。

它教导我们以使维护和调试更容易的方式对程序的相关方面进行分组。它侧重于类和子类,使得代码重用更加实际。

从历史上看,面向对象编程成为处理过程代码中常见的混乱、难以阅读的代码(意思不明确的代码)的一种流行方式。通常,无组织的过程代码由于函数之间的相互依赖而变得脆弱和僵化。程序的某一方面的变化可能会导致完全不相关的错误出现。

想象一下我们正在修理一辆汽车,更换前灯导致发动机出现问题。我们会认为这是汽车设计者的糟糕架构。模块化编程拥抱程序的共同方面的分组。

面向对象编程有四个核心概念:

  • 抽象

  • 封装

  • 继承

  • 多态

在本章中,我们将看看这四个原则以及如何使用 ES6 语法在 JavaScript 编程语言中使用它们。在本章中,我们将尝试专注于实际应用,但与上述核心概念相关。

抽象

抽象是编程中使用的高级概念,也是面向对象编程的基础。它允许我们通过不必处理具体实现来创建复杂系统。当我们使用 JavaScript 时,许多东西默认被抽象化。例如,考虑以下数组和内置的includes()函数的使用:

let list = ["car", "boat", "plane"];
let answer = list.includes("car") ? "yes" : "no";
console.log(answer);

我们不需要知道在运行includes()时使用的算法或代码。我们只需要知道如果数组中包含car,它将返回true,如果不包含则返回false。这是一个抽象的例子。随着 JavaScript 版本的更改,include()的内部工作方式可能会发生变化。它可能变得更快或更智能,但因为它已经被抽象化,我们不需要担心程序会出错。我们只需要知道它将返回truefalse的条件。

我们不需要考虑计算机如何将二进制转换为屏幕上的图像,或者按下键盘如何在浏览器中创建事件。甚至构成 JavaScript 语言的关键字本身也是代码。

我们可以查看在使用内置 JavaScript 函数时执行的低级代码,这些代码在浏览器引擎之间会有所不同。使用JSON.stringify()

让我们花一点时间思考抽象对象是什么。想象一下你桌子上的一个苹果,这是一个具体的苹果。它是苹果的一个实例或分类的概念。我们也可以谈论苹果的概念以及什么使苹果成为苹果;哪些属性在苹果中是共同的,哪些是必需的。

当我说“苹果”这个词时,你脑海中会浮现出水果的图片。你想象中的苹果的确切细节取决于你对苹果概念的理解。当我们在计算机程序中定义一个苹果类时,我们正在定义程序如何定义苹果类。就像我们的想象力一样,一个事物的概念可以是具体的或不具体的。它可能只包含一些因素,比如形状和颜色,也可能包含几十个因素,包括重量、产地和口味。

类和构造函数

在第一个练习中,我们创建了一个灯模块。虽然它是一个模块,但它不是面向对象的。在本节中,我们将以面向对象的方式重新设计该模块。

类最重要的一个方面是它的构造函数。构造函数是在创建类的实例时调用的内置函数。通常,构造函数用于定义对象的属性。例如,您经常会看到类似于这样的东西:

class Apple {
  constructor(color, weight) {
   this.color = color;
   this.weight = weight;
  }
}

传递的参数将保存到实例中以供以后使用。您还可以根据传递的参数添加一些额外的属性。例如,假设我们想通过附加日期时间戳来给我们的苹果一个出生日期。我们可以在我们的构造函数内添加第三行:

  this.birthdate = Date.now();

或者我们可能想在灯模块中调用一些其他函数。想象一下,每个进入世界的苹果都有 1/10 的机会是腐烂的:

  this.checkIfRotten();

我们的类需要包含一个checkIfRotten函数,该函数将isRotten属性设置为 10 次中的 1 次为true

checkIfRotten() {
  If (Math.floor(Math.random() * Math.floor(10)) == 0) {
   this.isRotten = true;
  } else {
   this.isRotten = false;
  }
}

练习 24:将灯模块转换为类

注意

本练习使用本章练习 22,编写一个简单的 ES6 模块的最终产品作为起点。完成此练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise24找到。

让我们回到本章练习 22,编写一个简单的 ES6 模块中的灯示例。我们将使用在上一章中为灯模块定义的属性,并在创建时进行分配。此外,我们将编写函数来检查灯属性的格式。如果使用无效的属性值创建了灯,我们将将其设置为默认值。

执行练习的步骤如下:

  1. 打开js/light.js并删除上一个练习中的代码。

  2. 为我们的Light类创建一个类声明:

class Light  {
}
  1. 向类添加constructor函数,并从参数中设置属性以及datetime属性。我们将首先将参数传递给两个函数以检查正确的格式,而不是直接设置statebrightness。这些函数的逻辑将在以下步骤中编写:
class Light  {
  constructor(state, brightness) {
   // Check that inputs are the right types
   this.state = this.checkStateFormat(state);
   this.brightness = this.checkBrightnessFormat(brightness);
   this.createdAt = Date.now();
  }
}
  1. checkStateFormatcheckBrightnessFormat函数添加到类声明中:
  checkStateFormat(state) {
   // state must be true or false
   if(state) {
    return true;
   } else {
    return false;
   }
  }
  checkBrightnessFormat(brightness) {
   // brightness must be a number between 0.01 and 1
   if(isNaN(brightness)) {
    brightness = 1;
   } else if(brightness > 1) {
    brightness = 1;
   } else if(brightness < 0.01) {
    brightness = 0.01;
   }
   return brightness;
  }
  1. 添加一个toggle函数和一个test函数,我们将用于调试。这两个函数也应该在类声明内。toggle函数将简单地将灯的状态转换为当前状态的相反状态;例如,从开到关,反之亦然:
  toggle() {
   this.state = !this.state;
  }
  test() {
   alert("state is " + this.state);
  }
  1. js/lightBulb.js中,在类声明下面,添加一个模块导出,就像我们在上一个练习中所做的那样:
export default Light;
  1. 打开js/viewer.js,并用包含Light类实例的变量替换我们在练习 22,编写一个简单的 ES6 模块中编写的light()行:
let light = new Light(true, 0.5);
  1. js/viewer.js中的前一行下面,添加以下代码。此代码将图像的源连接到state,并将图像的不透明度连接到brightness
// Set image based on light state
bulb.src = light.state ? onImage : offImage;
// Set opacity based on brightness
bulb.style.opacity = light.brightness;
// Set slider value to brightness
slider.value = light.brightness;
bulb.onclick = function () {
  light.toggle();
  bulb.src = light.state ? onImage : offImage;
}
slider.onchange = function () {
  light.brightness = this.value;
  bulb.style.opacity = light.brightness;
}
  1. 返回项目目录并运行npm start。项目运行后,在浏览器中打开localhost:8000。您应该看到灯的新图片,指示它是开启的:图 5.8:状态为 true 的灯
图 5.8:状态为 true 的灯

打开页面后,单击图像并确保这样做会导致图像更改。还要注意页面底部的输入滑块。尝试更改值以确认这样做是否会更新图像的不透明度。

类的命名约定

在上面的代码中,我们创建了一个Light类。请注意,我们使用的是大写的“L”,而不是 JavaScript 中通常使用的驼峰命名法。将类的名称大写是一种常见的做法;有关命名约定的更多详细信息,请参阅 Google 的 JavaScript 样式指南:google.github.io/styleguide/javascriptguide.xml#Naming

Camelcase 是 JavaScript 中最流行的命名风格。其他风格包括 snake_case、kebab-case 和 PascalCase。

默认属性

使用类时,您最常用的功能之一是默认属性值。通常,您希望创建类的实例,但不关心属性的具体值-例如,不指定参数:

myLight = new Light();

statebrightness都将默认为undefined

根据我们编写的代码,调用没有属性的light不会引发错误,因为我们编写了checkStateFormatcheckBrightnessFormat来处理所有无效值。然而,在许多情况下,您可以通过在构造函数中提供默认值来简化代码,如下所示:

  constructor(state=false, brightness=100) {

上述语法不是特定于类constructor,可以用于设置任何函数的默认参数,假设您使用的是 ES6、ES2015 或更新版本的 JavaScript。默认参数在 ES2015 之前的版本中不可用。

封装

封装是模块只在必要时才公开对象属性的想法。此外,应该使用函数而不是直接访问和修改属性。例如,让我们回到我们的灯模块。在constructor函数内部,我们确保首先通过状态检查器运行值:

  constructor(state, brightness) {
   // Check that input has the right format
   this.brightness = this.checkBrightnessFormat(brightness);
  }

假设您开发了前面的模块并发布供同事使用。您不必担心他们使用错误的值初始化类,因为如果他们这样做,checkBrightnessFormat()将自动更正该值。但是,一旦我们的类的实例存在,其他人就可以直接修改该值,没有任何阻止:

let light = new Light();
light.brightness = "hello";

在一个命令中,我们绕过了Light类的checkBrightnessFormat函数,并且我们有了一个brightness值为hello的灯。

封装是以使这种情况不可能的方式编写我们的代码的想法。诸如 C#和 Java 之类的语言使封装变得容易。不幸的是,即使在 ES6 更新后,JavaScript 中使用封装也不明显。有几种方法可以做到这一点;其中最受欢迎的方法之一是利用内置的WeakMap对象类型,这也是 ES6 的新功能之一。

WeakMap

WeakMap对象是一个键值对集合,其中键是对象。WeakMap 具有一个特殊的特性,即如果 WeakMap 中的键对象被从程序中移除并且没有对它的引用存在,WeakMap 将从其集合中删除关联的键值对。这个删除键值对的过程称为垃圾回收。因此,在使用映射可能导致内存泄漏的情况下,该元素特别有用。

WeakMap 比 Map 更适合的一个例子是,一个脚本跟踪动态变化的 HTML 页面中的每个元素。假设 DOM 中的每个元素都被迭代,我们在 Map 中创建了一些关于每个元素的额外数据。然后,随着时间的推移,元素被添加到 DOM 中并从中删除。使用 Map,所有旧的 DOM 元素将继续被引用,导致存储与已删除的 DOM 元素相关的无用信息,从而导致随着时间的推移内存使用量增加。使用 WeakMap,DOM 元素的删除(它是集合中的键对象)会导致在垃圾回收期间删除集合中的关联条目。

在这里,我们将使用WeakMap()。首先,我们创建一个空的map变量,然后创建一个带有一些属性的light对象。然后,我们将对象本身与一个字符串kitchen light关联起来。这不是向light添加属性的情况;相反,我们使用对象就像它是地图中的属性名称一样:

var map = new WeakMap();
var light = {state: true, brightness: 100};
map.set(light, "kitchen light");
console.log(map.get(light));

另外,需要注意的是,键对象是基于对对象的特定引用。如果我们创建具有相同属性值的第二个灯,那将算作一个新的键:

let light2 = {state: true, brightness: 100};
map.set(light2, "bedroom light");
// above has not changed kitchen light reference
console.log(map.get(light));

如果我们更新对象的属性,那不会改变映射:

light.state = false;
// reference does not change
console.log(map.get(light));

映射将存在,直到键对象超出范围,或者直到它被设置为 null 并进行垃圾回收;例如:

light = null;
// value will not be returned here
console.log(map.get(light));

练习 25:封装的 WeakMap

注意

本练习以本章的练习 24,将灯模块转换为类的最终产品为起点。完成此练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise25找到。

在这个练习中,我们将使用WeakMap来创建无法直接从模块外部访问的私有变量。执行以下步骤完成练习:

  1. 打开js/light.js,并在文件顶部添加一个名为privateVarsWeakMap对象:
let privateVars = new WeakMap();
  1. js/light.js中,修改constructor函数,使得对象属性通过set方法保存到privateVars中,而不是直接在对象上:
constructor(state, brightness) { 
  // Parse values
  state = this.checkStateFormat(state);
  brightness = this.checkBrightnessFormat(brightness);
  // Create info object 
  let info = {
   "state": state,
   "brightness": brightness,
   "createdAt": Date.now()
  };
// Save info into privateVars 
  privateVars.set(this, info); 
}
  1. 现在,在js/light.js中,修改toggle函数,以便我们从名为privateVarsWeakMap对象获取状态信息。请注意,当我们设置变量时,我们发送回一个包含所有信息而不仅仅是state的对象。在我们的示例中,每个light实例都与WeakMap关联的单个info对象:
toggle() { 
  let info = privateVars.get(this); 
  info.state = !info.state;
  privateVars.set(this, info); 
}
  1. 我们还需要以类似的方式修改js/light.js中的test函数。我们将改变发送给用户的state的来源,以便在警报中使用WeakMap
test() { 
  let info = privateVars.get(this); 
  alert("state is " + privateVars.get(this).state);
}
  1. 由于封装夺走了直接更改状态和亮度的能力,我们需要添加允许这样做的方法。我们将从在js/light.js中添加setState函数开始。请注意,它几乎与我们的toggle函数相同:
setState(state) {
  let info = privateVars.get(this);
  info.state = checkStateFormat(state); 
  privateVars.set(this, info); 
}
  1. 接下来,在js/light.js中添加 getter 方法:
getState() {
  let info = privateVars.get(this); 
  return info.state;
}
  1. 按照最后两个步骤的模式,在js/light.js中为brightness属性添加 getter 和 setter 函数:
setBrightness(brightness) { 
  let info = privateVars.get(this);
  info.brightness = checkBrightnessFormat(brightness);
  privateVars.set(this, info);
}
getBrightness() { 
  let info = privateVars.get(this);
  return info.brightness;
}
  1. 我们需要做的最后一个更改是在js/viewer.js中。在变量声明下面,将每个对光亮度和状态的引用更改为使用我们创建的 getter 方法:
// Set image based on light state
bulb.src = light.getState() ? onImage : offImage;
// Set opacity based on brightness
bulb.style.opacity = light.getBrightness();
// Set slider value to brightness
slider.value = light.getBrightness();
bulb.onclick = function () {
  light.toggle();
  bulb.src = light.getState() ? onImage : offImage;
}
slider.onchange = function () {
  light.setBrightness(this.value);
  bulb.style.opacity = light.getBrightness();
}
  1. 使用npm start运行代码,并在浏览器中查看localhost:8000上的页面项目。检查确保单击图像有效,以及使用输入滑块更改亮度有效:

图 5.9:使用单击和滑块功能正确呈现网站

图 5.9:使用单击和滑块功能正确呈现网站

获取器和设置器

在使用封装时,由于我们不再允许用户直接访问属性,大多数对象最终将具有一些或全部属性的 getter 和 setter 函数:

console.log(light.brightness);
// will return undefined

相反,我们专门创建允许获取和设置属性的函数。这些被称为 getter 和 setter,它们是一种流行的设计模式,特别是在诸如 Java 和 C++等语言中。如果您在上一个练习中完成了第 7 步,应该已经为brightness添加了 setter 和 getter:

setBrightness(brightness) {
  let info = privateVars.get(this);
  info.brightness = checkBrightnessFormat(state);
  privateVars.set(this, info);
}
getBrightness() {
  let info = privateVars.get(this);
  return info.brightness;
}

继承

继承是一个类继承另一个类的属性和方法的概念。从另一个类继承的类称为子类,被继承的类称为超类。

正是从术语超类中,我们得到了内置的super()函数,它可以用于调用子类的超类的构造函数。我们将在本章后面使用super()来创建自己的子类。

应该注意的是,一个类既可以是子类,也可以是超类。例如,假设我们有一个模拟不同类型动物的程序。在我们的程序中,我们有一个哺乳动物类,它是动物类的子类,也是狗类的超类。

通过这种方式组织我们的程序,我们可以将所有动物相关的属性和方法放在动物类中。哺乳动物子类包含哺乳动物相关的方法,但不包括爬行动物;例如:

图 5.10:JavaScript 中的继承

图 5.10:JavaScript 中的继承

这一开始可能听起来很复杂,但通常可以节省大量的编码工作。如果不使用类,我们将不得不将方法从一个动物复制并粘贴到另一个动物中。这就带来了在多个地方更新函数的困难。

回到我们的智能家居场景,假设我们收到了一个新的彩色灯泡设备。我们希望我们的彩色灯泡具有灯泡中包含的所有属性和函数。此外,彩色灯应该有一个额外的color属性,包含一个十六进制颜色代码,一个颜色格式检查器和与改变颜色相关的函数。

我们的代码也应该以一种方式编写,如果我们对底层的Light类进行更改,彩色灯泡将自动获得任何添加的功能。

练习 26:扩展一个类

注意

本练习使用练习 25,封装的 WeakMap的最终产品作为起点。完成此练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise26找到。

为了扩展上一个练习中编写的Light类,我们将创建一个新的ColorLight类:

  1. /js/colorLight.js中创建一个新文件。在第一行,我们将导入./light.js,这将作为起点:
import Light from './light.js';
  1. 接下来,我们将为私有变量创建WeakMap。然后,我们将为我们的ColorLight类创建一个类语句,并使用extends关键字告诉 JavaScript 它将使用Light作为起点:
let privateVars = new WeakMap();
class ColorLight extends Light {
}
  1. ColorLight类语句内部,我们将创建一个新的constructor,它使用内置的super()函数,运行我们基类Lightconstructor()函数:
class ColorLight extends Light {
  constructor(state=false, brightness=100, color="ffffff") {
   super(state, brightness);
   // Create info object
   let info = {"color": this.checkColorFormat(color)};
   // Save info into privateVars
   privateVars.set(this, info);
  }
}
  1. 请注意在上述构造函数中,我们调用了checkColorFormat(),这是一个检查提供的颜色值是否是有效十六进制值的函数。如果不是,我们将把值设置为白色的十六进制值(#FFFFFF)。该函数应该在ColorLight类语句内部:
  checkColorFormat(color) {
   // color must be a valid hex color
   var isHexColor  = /^#[0-9A-F]{6}$/i.test('#'+color);
   if(!isHexColor) {
    // if invalid make white
    color = "ffffff";
   }
   return color;
  }
  1. 添加 getter 和 setter 函数,就像我们在后面的练习中所做的那样:
  getColor() {
   let info = privateVars.get(this);
   return info.color;
  }
  setColor(color) {
   let info = privateVars.get(this);
   info.color = this.checkColorFormat(color);
   privateVars.set(this, info);
  }
  1. js/colorLight.js的底部,添加一个export语句以使模块可供导入:
export default ColorLight;
  1. 在文件顶部打开js/viewer.js,并将Light导入切换为ColorLight。在下面,我们将导入一个预先编写的名为changeColor.js的脚本:
import ColorLight from './colorLight.js';
import changeColor from './__extra__/changeColor.js';
  1. js/viewer.js中更下面,找到初始化light变量的行,并将其替换为以下内容:
let light = new ColorLight(true, 1, "61AD85");
  1. js/viewer.js的底部,添加以下内容:
// Update image color
changeColor(light.getColor());
  1. 再次使用npm start启动程序,并在浏览器中转到localhost:8000

如果您按照说明正确操作,现在应该看到灯泡呈浅绿色,如下图所示。尝试打开js/viewer.js并更改十六进制值;这样做应该会导致灯泡图像显示不同的颜色:

图 5.11:change-color 函数应用 CSS 滤镜使灯泡变绿

图 5.11:change-color 函数应用 CSS 滤镜使灯泡变绿

多态

多态性就是简单地覆盖父类的默认行为。在 Java 和 C#等强类型语言中,多态性可能需要花费一些精力。而在 JavaScript 中,多态性是直接的。你只需要重写一个函数。

例如,在上一个练习中,我们将LightColorLight类扩展了。假设我们想要获取在Light中编写的test()函数,并覆盖它,以便不是弹出灯的状态,而是弹出灯的当前颜色值。

因此,我们的js/light.js文件将包含以下内容:

  test() {
   let info = privateVars.get(this); 
   alert("state is " + privateVars.get(this).state);
  }
Then all we have to do is create a new function in js/colorLight.js which has the same name, and replace state with color:
  test() { 
   let info = privateVars.get(this); 
   alert("color is " + privateVars.get(this).color);
  }

练习 27:LightBulb Builder

注意

这个练习使用Exercise 26, Extending a Class的最终产品作为起点。完成这个练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise27找到。

在这个练习中,我们将运用到目前为止学到的概念来增强我们的示例项目。我们将修改项目,使我们能够创建无限个lightbulb类的实例,选择颜色、亮度和状态:

  1. 打开js/light.js,并在WeakMap引用的下面添加两个图像源的值:
let onImage = "images/bulb_on.png";
let offImage = "images/bulb_off.png";
  1. 接下来,在js/light.js中,在info变量定义的下面,添加以下内容:
   // Create html element
   let div = document.createElement("div");
   let img = document.createElement("img");
   let slider = document.createElement("input");
   // Save reference to element as private variable
   info.div = div;
   info.img = img;
   info.slider = slider;
   this.createDiv(div, img, slider, state, brightness);
  1. js/light.js的最后一步中,我们引用了this.createDiv。在这一步中,我们将在js/light.js的构造函数下面创建该函数。该函数为Light类的每个实例创建 HTML:
  createDiv(div, img, slider, state, brightness) {
   // make it so we can access this in a lower scope
   let that = this;
   // modify html
   div.style.width = "200px";
   div.style.float = "left";
   img.onclick = function () { that.toggle() };
   img.width = "200";
   img.src = state ? onImage : offImage;
   img.style.opacity = brightness;
   slider.onchange = function () { that.setBrightness(this.value) };
   slider.type = "range";
   slider.min = 0.01;
   slider.max = 1;
   slider.step = 0.01;
   slider.value = brightness;
   div.appendChild(img);
   div.appendChild(slider);
   // append to document
   document.body.appendChild(div);
  }
  1. 接下来,在js/light.js中,找到setState函数,并在函数内添加以下行:
info.img.src = info.state ? onImage : offImage;
  1. js/light.jstoggle函数中添加相同的行:
info.img.src = info.state ? onImage : offImage;
  1. 同样地,我们将更新js/light.js中的setBrightness函数,以根据亮度设置图像的不透明度:
info.img.style.opacity = brightness;
  1. js/light.js中的最后一个更改是为img HTML 对象添加一个 getter 函数。我们将它放在getBrightnesstoggle函数之间:
  getImg() {
   let info = privateVars.get(this);
   return info.img;
  }
  1. js/colorLight.js中,我们将导入预先构建的colorChange函数。这应该放在你的导入下面的位置,就在Light导入的下面:
import changeLight from './__extra__/changeColor.js';
  1. 接下来,在js/colorLight.js中,我们将通过添加以下行来更新构造函数:
   let img = this.getImg();
   img.style.webkitFilter = changeLight(color);
  1. js/viewer.js中,删除所有代码并替换为以下内容:
import ColorLight from './colorLight.js';
let slider = document.getElementById("brightnessSlider");
let color = document.getElementById("color");
let button = document.getElementById("build");
button.onclick = function () {
  new ColorLight(true, slider.value, color.value);
}
  1. 最后的更改是index.html;删除imginput标签,并替换为以下内容:
  <div style="position: 'fixed', top: 0, left: 0">
   <input type="color" id="color" name="head" value="#e66465">
   <input id="brightnessSlider" min="0.01" max="1" step="0.01" type="range"/>
   <button id="build">build</button>
  </div>
  1. 完成所有更改后,运行npm start并在浏览器中打开localhost:8000。如果一切都做对了,点击build按钮应该根据所选的颜色向页面添加一个新元素:

图 5.12:创建多个 lightclub 类的实例

图 5.12:创建多个 lightclub 类的实例

如你所见,一旦你创建了许多相同的实例,类就真的开始变得非常有用了。在下一节中,我们将看看 npm 包以及如何将我们的Light类导出为一个。

npm 包

npm 包是一个已经打包并上传到 npm 服务器的 JavaScript 模块。一旦模块被上传到 npm,任何人都可以快速安装和使用它。

这对你可能不是新鲜事,因为任何使用 Node.js 的人很快就会安装一个包。不太常见的是如何创建和上传一个包。作为开发人员,很容易花费数年的时间而不需要发布一个公共模块,但了解这一点是值得的。这不仅有助于当你想要导出自己的模块时,还有助于阅读和理解你的项目使用的包。

创建 npm 模块的第一步是确保您有一个完整的package.json文件。在本地运行项目时,通常不必过多担心诸如authordescription之类的字段。但是,当您准备将模块用于公共使用时情况就不同了。您应该花时间填写与您的软件包相关的所有字段。

以下是包括 npm 推荐的常见属性的表格。其中许多是可选的。有关更多信息和完整列表,请参阅docs.npmjs.com/files/package.json

至少,元数据应包括名称、版本和描述。此外,大多数软件包将需要一个dependencies属性;但是,这应该通过在使用npm install安装依赖项时自动生成使用--save-s选项:

图 5.13:npm 属性表

图 5.13:npm 属性表

以下表格显示了 npm 的一些更多属性:

图 5.14:npm 属性表续

图 5.14:npm 属性表续

npm 链接命令

完成package.json并且您想要测试的软件包的第一个版本后,您可以使用npm link命令。链接命令将将您的本地 npm 项目与命名空间关联起来。例如,首先导航到要使用本地npm软件包的项目文件夹:

cd ~/projects/helloWorld
npm link

然后,进入另一个项目文件夹,您想要使用该软件包,并运行npm link helloWorld,其中helloWorld是您正在测试的软件包的名称:

cd ~/projects/otherProject
npm link helloWorld

这两个步骤将使您能够像使用npm install helloWorld安装helloWorld一样工作。通过这样做,您可以确保在另一个项目中使用时,您的软件包在本地工作。

Npm 发布命令

一旦您对在本地测试软件包的结果感到满意,您可以使用npm publish命令轻松将其上传到 npm。要使用publish命令,您首先需要在www.npmjs.com/上创建一个帐户。一旦您拥有帐户,您可以通过在命令行上运行npm login来本地登录。

登录后,发布软件包非常简单。只需导航到您的project文件夹并运行npm publish。以下是成功上传到 npm 供他人使用的软件包的示例:

图 5.15:已发布的 npm 软件包示例

图 5.15:已发布的 npm 软件包示例

ESM 与 CommonJS

ESM 是 ECMAScript 模块的缩写,这是 ES6 中模块的标准。因此,您可能会听到将“ES6 模块”称为 ESM。这是因为 ESM 标准在 ES6 成为标准之前就已经在开发中。

您可能已经看到了在上一章中使用的 CommonJS 格式:

const express = require('express');

ES6 模块样式中的相同代码将是这样的:

import express from 'express';

ES6 模块非常棒,因为它们使 JavaScript 开发人员对其导入有更多控制。但是,重要的是要注意,目前 JavaScript 正处于过渡期。ES6 已经明确规定了 ES6 模块应该如何工作的标准。尽管大多数浏览器已经实现了它,但 npm 仍在使用自己的标准 CommonJS。

也就是说,ES6 的引入正在迅速得到接受。npm 现在附带一个实验性标志,--experimental-modules,允许使用 ES6 样式模块。但是,不建议使用此标志,因为它增加了不必要的复杂性,例如必须将文件扩展名从.js更改为.mjs

Babel

使用 ES6 模块与 Node.js 的更常见和推荐的方法是运行 JavaScript 编译器。最流行的编译器是Babel.js,它将 ES6 代码编译为可以在任何地方运行的较旧版本的 JavaScript。

Babel 是 Node.js 生态系统中广泛使用的工具。通常,项目使用具有 Babel 和其他捆绑工具(如 webpack)的起始模板。这些起始项目允许开发人员开始使用 ES6 导入,而无需考虑是否需要编译步骤。例如,有 Facebook 的 create-react-app,它会在文件更改时编译和显示您的应用程序。

React 是推动 ES6 的最大社区之一。在 React 生态系统中,标准导入使用的是 ES6。以下内容摘自 React 关于创建组件的文档:

import React, { Component } from 'react';
class Button extends Component {
  render() {
   // ...
  }
}
export default Button; // Don't forget to use export default!

注意前面的代码与我们一直在进行的工作之间的相似之处。这是继承的一个例子,其中Button继承了Component的属性,就像ColorLight继承了Light的属性一样。React 是一个基于组件的框架,大量使用 ES6 功能,如导入和类。

webpack

另一个常见的 JavaScript 编译器是 webpack。webpack 接受多个 JavaScript 文件并将它们编译成单个捆绑文件。此外,webpack 可以采取步骤来提高性能,例如缩小代码以减少总大小。在使用模块时,webpack 特别有用,因为每个加载到 HTML 站点中的单独文件都会增加加载时间,因为会产生额外的 HTTP 调用。

使用 webpack,我们可以非常简单地指定要编译的 JavaScript 的入口点,并且它将自动合并任何引用的文件。例如,如果我们想要编译上一个练习中的代码,我们将创建一个webpack.config.js文件来指定入口点:

const path = require("path");
module.exports = {
  mode: 'development',
  entry: "./src/js/viewer.js",
  output: {
   path: path.resolve(__dirname, "build"),
   filename: "bundle.js"
  }
};

注意上面定义的entry;这将是我们程序的起点,webpack 将自动找到所有引用的文件。另一个重要的值要注意的是output。这定义了编译器创建的结果捆绑 JavaScript 文件的位置和文件名。

在下一个练习中,我们将使用 Babel 将我们的代码从 ES6 转换为通用 JavaScript。一旦我们转换了我们的 JavaScript,我们将使用 webpack 将生成的文件编译成一个捆绑的 JavaScript 文件。

练习 28:使用 webpack 和 Babel 转换 ES6 和包

注意

此练习使用练习 27,LightBulb Builder的最终产品作为起点。完成此练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise28找到。

在这个练习中,我们将使用 Babel 将我们的 ES6 转换为与旧浏览器(如 Internet Explorer)兼容的通用 JavaScript。我们要做的第二件事是运行 webpack 将所有 JavaScript 文件编译成单个文件:

  1. 在项目的基础上创建两个新文件夹,一个名为build,另一个名为src
mkdir src build
  1. imagesindex.htmljs文件夹移动到新的src文件夹中。源文件夹将用于稍后生成build文件夹的内容:
mv images index.html js src
  1. 安装babel-clibabel preset作为开发人员依赖项:
npm install --save-dev webpack webpack-cli @babel/core @babel/cli @babel/preset-env
  1. 在根目录下添加一个名为.babelrc的文件。在其中,我们将告诉 Babel 使用预设设置:
{
  "presets": ["@babel/preset-env"]
}
  1. 在根目录中添加一个名为webpack.config.js的 webpack 配置文件:
const path = require("path");
module.exports = {
  mode: 'development',
  entry: "./build/js/viewer.js",
  output: {
   path: path.resolve(__dirname, "build"),
   filename: "bundle.js"
  }
};
  1. 要从src生成build文件夹的内容,我们需要向项目添加一个新的脚本命令。打开package.json,查找列出脚本的部分。在该部分,我们将添加一个build命令,该命令运行 Babel 和 webpack,并将我们的image文件复制到build文件夹中。我们还将修改start命令以引用我们的build文件夹,以便在构建后进行测试:
  "scripts": {
   "start": "ws --directory build",
   "build": "babel src -d build && cp -r src/index.html src/images build && webpack --config webpack.config.js"
  },

注意

Windows 用户应使用以下命令:

"build": "babel src -d build && copy src build && webpack --config webpack.config.js"

  1. 为了确保命令已经正确添加,运行命令行上的npm run build。你应该会看到这样的输出:图 5.16:npm run build 输出
图 5.16:npm run build 输出
  1. 接下来,打开build/index.html并将script标签更改为导入我们新创建的文件bundle.js
<script src="bundle.js"></script>
  1. 要测试,运行npm start并在浏览器中打开localhost:8000。你应该会看到与上次练习相同的网站。按几次build按钮以确保它按预期工作:图 5.17:使用构建按钮进行测试运行
图 5.17:使用构建按钮进行测试运行
  1. 为了双重检查一切是否编译正确,去浏览器中输入localhost:8000/bundle.js。你应该会看到一个包含所有我们的 JavaScript 源文件编译版本的大文件:

图 5.18:所有我们的 JavaScript 源文件的编译版本

图 5.18:所有我们的 JavaScript 源文件的编译版本

如果你做的一切都正确,你应该有一个包含所有我们的 JavaScript 代码编译成单个文件的bundle.js文件。

可组合性和组合模块的策略

我们已经看到模块如何成为另一个模块的扩展,就像ColorLightLight的扩展一样。当项目增长时,另一个常见的策略是有模块本身由多个子模块组成。

使用子模块就像在模块文件本身导入模块一样简单。例如,假设我们想要改进我们灯模块中的亮度滑块。也许如果我们创建一个新的Slider模块,我们可以在除了Light类之外的多种情况下使用它。这是一种情况,我们建议将我们的“高级滑块输入”作为子模块。

另一方面,如果你认为你的新滑块只会在Light类中使用,那么将它添加为一个新类只会增加更多的开销。不要陷入过度模块化的陷阱。关键因素在于可重用性和实用性。

活动 6:创建带有闪光模式的灯泡

你工作的灯泡公司要求你为他们的产品工作。他们想要一个带有特殊“闪光模式”的灯泡,可以在活动和音乐会上使用。闪光模式的灯允许人们将灯置于闪光模式,并在给定的时间间隔内自动打开和关闭。

创建一个FlashingLight类,它扩展了Light。该类应该与Light相同,只是有一个名为flashMode的属性。如果flashMode打开,则状态的值应该每五秒切换一次。

创建了这个新组件后,将其添加到js/index.js中的包导出,并使用 Babel 编译项目。

执行以下步骤完成活动:

  1. 安装babel-clibabel预设为开发人员依赖项。

  2. 添加.babelrc告诉 Babel 使用preset-env

  3. 添加一个 webpack 配置文件,指定模式、入口和输出位置。

  4. 创建一个名为js/flashingLight.js的新文件;它应该作为一个空的 ES6 组件开始,扩展Light

  5. 在文件顶部,添加一个weakMap类型的privateVars变量。

  6. 在构造函数中,设置flashMode属性并将其保存到构造函数中的privateVars中。

  7. FlashingLight对象添加一个 setter 方法。

  8. FlashingLight对象添加一个 getter 方法。

  9. 在第 2 行,添加一个空变量,用于在类的全局级别跟踪闪烁计时器。

  10. 创建一个引用父类的lightSwitch()函数的startFlashing函数。这一步很棘手,因为我们必须将它绑定到setInterval

  11. 创建一个stopFlashing函数,用于关闭计时器。

  12. 在构造函数中,检查flashMode是否为 true,如果是,则运行startFlashing

  13. 在设置mode时,还要检查flashMode - 如果为 true,则startFlashing;否则,stopFlashing

  14. index.js中导入和导出新组件。

  15. 通过使用 npm 运行我们的build函数来编译代码。

预期输出

图 5.19:带闪光模式的灯泡

图 5.19:带闪光模式的灯泡

注意

这个活动的解决方案可以在第 599 页找到。

总结

在本章中,我们探讨了模块化设计的概念,ES6 模块以及它们在 node 中的使用。面向对象设计原则在设计由多个模块层组成的复杂系统的程序时非常有用。

ES6 类允许我们比以前的 JavaScript 版本更轻松地创建类。这些类可以使用extends关键字构建。这允许在更复杂的对象之上构建更复杂的对象等等。

我们还看到了新的 ES6 WeakMap类型如何允许我们创建私有变量。这种模式限制了将被其他人使用的模块中的错误数量。例如,通过要求更改属性,我们可以在允许更改之前检查格式和值。这就是灯泡示例的情况,我们希望检查state在允许设置之前是否为布尔值。我们通过为我们想要向我们代码的其他部分公开的每个私有变量创建 getter 和 setter 方法来实现这一点。

之后,我们谈到了 ES6 模块目前在 Node.js 中没有得到原生支持,尽管像 Facebook 支持的 React 这样的知名项目广泛使用它们。作为解决这一限制的方法,我们安装了 Babel,一个 ES6 到 JavaScript 的编译器,并用它将我们的src文件夹转换为最终的构建代码。

我们还谈到了一旦在本地使项目工作,就可以将其转换为可以通过 npm 共享和更新的 npm 包。这个过程涉及使用npm link在本地进行测试。然后,一旦满意包的工作方式,使用npm publish进行发布。

在下一章中,我们将讨论代码质量以及如何实施自动化测试来防御回归,因为我们更新我们的代码。

第七章:代码质量

学习目标

在本章结束时,你将能够:

  • 确定编写清晰 JavaScript 代码的最佳实践

  • 执行代码检查并在你的 node 项目中添加一个检查命令

  • 在你的代码上使用单元测试、集成测试和端到端测试方法

  • 使用 Git 钩子自动化代码检查和测试

在本章中,我们将专注于提高代码质量,设置测试,并在 Git 提交之前自动运行测试。这些技术可以确保错误或问题能够及早被发现,从而不会进入生产环境。

介绍

在上一章中,我们探讨了模块化设计、ES6 模块以及它们在 Node.js 中的使用。我们将我们编译的 ES6 JavaScript 转换为兼容的脚本使用 Babel。

在本章中,我们将讨论代码质量,这是专业 JavaScript 开发的关键品质之一。当我们开始编写代码时,我们往往会专注于解决简单的问题和评估结果。对于大多数开发人员开始的小型项目,很少需要与他人沟通或作为大团队的一部分工作。

随着你参与的项目范围变得更大,代码质量的重要性也增加。除了确保代码能够正常工作,我们还必须考虑其他开发人员将使用我们创建的组件或更新我们编写的代码。

代码质量有几个方面。最明显的是它能够实现预期的功能。这通常说起来容易做起来难。很难满足大型项目的要求。更复杂的是,通常添加新功能可能会导致应用程序的某些现有部分出现错误。通过良好的设计可以减少这些错误,但即便如此,这些类型的故障还是会发生。

随着敏捷开发变得越来越流行,代码变更的速度也在增加。因此,测试比以往任何时候都更加重要。我们将演示如何使用单元测试来确认函数和类的正确功能。除了单元测试,我们还将研究集成测试,以确保程序的所有方面都能正确地一起运行。

代码质量的第二个组成部分是性能。我们代码中的算法可能会产生期望的结果,但它们是否能够高效地实现?我们将看看如何测试函数的性能,以确保算法在处理大量输入时能够在合理的时间内返回结果。例如,你可能有一个排序算法在处理 10 行数据时效果很好,但一旦尝试处理 100 行数据,就需要几分钟的时间。

本章我们将讨论代码质量的第三个方面,即可读性。可读性是衡量人类阅读和理解代码的难易程度。你是否曾经看过使用模糊函数和变量名称或者误导性变量名称编写的代码?在编写代码时,要考虑其他人可能需要阅读或修改它。遵循一些基本准则可以帮助提高可读性。

清晰命名

使代码更易读的最简单方法之一是清晰命名。尽可能使变量和函数的使用明显。即使是一个人的项目,也很容易在 6 个月后回到自己的代码时,难以记住每个函数的作用。当你阅读别人的代码时,这一点更加明显。

确保你的名称清晰且可读。考虑以下示例,开发人员创建了一个以yymm格式返回日期的函数:

function yymm() {
  let date = new Date();
  Return date.getFullYear() + "/" + date.getMonth();
}

当我们了解了这个函数的上下文和解释时,它是明显的。但对于第一次浏览代码的外部开发人员来说,yymm很容易引起一些困惑。

将模糊函数重命名为使用明显的方式:

function getYearAndMonth() {
  let date = new Date();
  return date.getFullYear() + "/" + date.getMonth();
}

当使用正确的函数和变量命名时,编写易读的代码变得容易。再举一个例子,我们想在夜间打开灯:

if(time>1600 || time<600) {
  light.state = true;
}

在前面的代码中并不清楚发生了什么。1600600到底是什么意思,如果灯的状态是true又代表什么?现在考虑将相同的函数重写如下:

if(time.isNight) {
  light.turnOn;
}

前面的代码使相同的过程变得清晰。我们不再询问时间是否在 600 和 1600 之间,而是简单地询问是否是夜晚,如果是,我们就打开灯。

除了更易读外,我们还将夜间的定义放在了一个中心位置,isNight。如果我们想在 5:00 而不是 6:00 结束夜晚,我们只需要在isNight中更改一行,而不是在代码中找到所有time<600的实例。

规范

在格式化或编写代码的规范方面,有两类:行业或语言范例和公司/组织范例。行业或语言特定的规范通常被大多数使用该语言的程序员所接受。例如,在 JavaScript 中,行业范例是使用驼峰命名法来命名变量。

行业范例的良好来源包括 W3 JavaScript 样式指南和 Mozilla MDN Web 文档。

除了行业范例外,软件开发团队或项目通常会有一套更进一步的规范。有时,这些规范被编制成样式指南文件;在其他情况下,这些规范是未记录的。

如果你是一个有着相对庞大代码库的团队的一部分,记录特定的样式选择可能是有用的。这将帮助你考虑你想要保留和强制执行新更新的哪些方面,以及你可能想要更改的哪些方面。它还有助于培训可能熟悉 JavaScript 但不熟悉公司具体规范的新员工。

一个公司特定的样式指南的很好的例子是 Google JavaScript 样式指南(google.github.io/styleguide/jsguide.html)。它包含一些一般有用的信息。例如,第 2.3.3 节讨论了在代码中使用非 ASCII 的问题。它建议如下:

const units = 'μs';

最好使用类似于:

const units = '\u03bcs'; // 'μs'

没有注释使用\u03bcs会更糟。你的代码的意思越明显,越好。

公司通常有一套他们偏爱的库,用于记录日志、处理时间值(例如 Moment.js 库)和测试等。这对于兼容性和代码重用非常有用。例如,如果一个项目已经使用 Bunyan 记录日志,而其他人决定安装 Morgan 等替代库,那么使用不同开发人员使用的执行类似功能的多个依赖项会增加编译项目的大小。

注意:样式指南

值得花时间阅读一些更受欢迎的 JavaScript 样式指南。不要觉得自己必须遵循每一个规则或建议,但要习惯于规则背后的思维方式。一些值得查看的热门指南包括以下内容:

MSDN 样式指南:developer.mozilla.org/en-US/docs/Web/JavaScript/Guide

主观与非主观

在规范方面,"主观"这个术语是你可能会遇到的。在探索现有的库和框架时,你经常会看到诸如"一个主观的框架"之类的短语。在这种情况下,"主观"是规范执行的严格程度的衡量标准:

主观的:严格执行其选择的规范和方法

非主观:不强制执行规范,也就是说,只要代码有效,就可以使用

Linting

Linting是一个自动化的过程,其中代码被检查并根据一套样式指南的标准进行验证。例如,一个设置了 linting 以确保使用两个空格而不是制表符的项目将检测到制表符的实例,并提示开发人员进行更改。

了解 linting 很重要,但它并不是项目的严格要求。当我在一个项目上工作时,我考虑的主要因素是项目的规模和项目团队的规模。

在中长期项目和中大型团队中,Linting 确实非常有用。通常,新人加入项目时会有使用其他样式约定的经验。这意味着你会在文件之间甚至在同一个文件中得到混合的样式。这导致项目变得不太有组织且难以阅读。

另一方面,如果你正在为一个黑客马拉松编写原型,我建议你跳过 linting。它会给项目增加额外的开销,除非你使用一个带有你喜欢的 linting 的样板项目作为起点。

还有一种风险是 linting 系统过于严格,最终导致开发速度变慢。

良好的 Linting 应该考虑项目,并在强制执行通用样式和不太严格之间找到平衡。

练习 29:设置 ESLint 和 Prettier 来监视代码中的错误

在这个练习中,我们将安装并设置 ESLint 和 Prettier 来监视我们的代码的样式和语法错误。我们将使用由 Airbnb 开发的流行的 ESLint 约定,这已经成为了一种标准。

注意

这个练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson06/Exercise29/result找到。

执行以下步骤完成练习:

  1. 创建一个新的文件夹并初始化一个npm项目:
mkdir Exercise29
cd Exercise29
npm init -y
npm install --save-dev eslint prettier eslint-config-airbnb-base eslint-config-prettier eslint-plugin-jest eslint-plugin-import

我们在这里安装了几个开发者依赖项。除了eslintprettier之外,我们还安装了由 Airbnb 制作的起始配置,一个与 Prettier 一起工作的配置,以及一个为基于 Jest 的测试文件添加样式异常的扩展。

  1. 创建一个.eslintrc文件:
{
 "extends": ["airbnb-base", "prettier"],
  "parserOptions": {
   "ecmaVersion": 2018,
   "sourceType": "module"
  },
  "env": {
   "browser": true,
   "node": true,
   "es6": true,
   "mocha": true,
   "jest": true,
  },
  "plugins": [],
  "rules": {
   "no-unused-vars": [
    "error",
    {
      "vars": "local",
      "args": "none"
    }
   ],
   "no-plusplus": "off",
  }
}
  1. 创建一个.prettierignore文件(类似于.gitignore文件,这只是列出应该被 Prettier 忽略的文件)。你的.prettierignore文件应包含以下内容:
node_modules
build
dist
  1. 创建一个src文件夹,并在其中创建一个名为square.js的文件,其中包含以下代码。确保你包含了不合适的制表符:
var square = x => x * x;
	console.log(square(5));
  1. 在你的 npm package.json文件中创建一个lint脚本:
  "scripts": {
   "lint": "prettier --write src/**/*.js"
  },
  1. 接下来,我们将通过从命令行运行新脚本来测试和演示prettier --write
npm run lint
  1. 在文本编辑器中打开src/square.js,你会看到不合适的制表符已被移除:图 6.1:不合适的制表符已被移除
图 6.1:不合适的制表符已被移除
  1. 接下来,回到package.json,扩展我们的 lint 脚本,在prettier完成后运行eslint
  "scripts": {
   "lint": "prettier --write src/**/*.js && eslint src/*.js"
  },
  1. 在命令行中再次运行npm run lint。你将因square.js中的代码格式而遇到一个 linting 错误:
> prettier --write src/**/*.js && eslint src/*.js
src/square.js 49ms
/home/philip/packt/lesson_6/lint/src/square.js
  1:1  error   Unexpected var, use let or const instead  no-var
  2:1  warning  Unexpected console statement          no-console
  2 problems (1 error, 1 warning)
  1 error and 0 warnings potentially fixable with the --fix option.

上述脚本产生了一个错误和一个警告。错误是由于在可以使用letconst的情况下使用var。尽管在这种特殊情况下应该使用const,因为square的值没有被重新赋值。警告是关于我们使用console.log,通常不应该在生产代码中使用,因为这会使在发生错误时难以调试控制台输出。

  1. 打开src/example.js,并按照下图所示,在第 1 行将var更改为const图 6.2:将 var 语句替换为 const
图 6.2:将 var 语句替换为 const
  1. 现在再次运行npm run lint。现在你应该只会收到警告:
> prettier --write src/**/*.js && eslint src/*.js
src/js.js 48ms
/home/philip/packt/lesson_6/lint/src/js.js
  2:1  warning  Unexpected console statement  no-console
  1 problem (0 errors, 1 warning)

在这个练习中,我们安装并设置了 Prettier 以进行自动代码格式化,并使用 ESLint 检查我们的代码是否存在常见的不良实践。

单元测试

单元测试是一种自动化软件测试,用于检查某个软件中的单个方面或功能是否按预期工作。例如,计算器应用程序可能被分成处理应用程序的图形用户界面(GUI)的函数和负责每种类型的数学计算的另一组函数。

在这样的计算器中,可以设置单元测试来确保每个数学函数按预期工作。这种设置使我们能够快速发现任何由于任何更改而导致的不一致结果或损坏函数。例如,这样一个计算器的测试文件可能包括以下内容:

test('Check that 5 plus 7 is 12', () => {
  expect(math.add(5, 7)).toBe(12);
});
test('Check that 10 minus 3 is 7', () => {
  expect(math.subtract(10, 3)).toBe(7);
});
test('Check that 5 multiplied by 3 is 15', () => {
  expect(math.multiply(5, 3).toBe(15);
});
test('Check that 100 divided by 5 is 20', () => {
  expect(math.multiply(100, 5).toBe(20);
});
test('Check that square of 5 is 25', () => {
  expect(math.square(5)).toBe(25);
});

前面的测试将在每次更改代码库时运行,并被检入版本控制。通常,当更新用于多个地方的函数并引发连锁反应导致某些其他函数损坏时,错误会意外地出现。如果发生这样的更改,并且前面的某个语句变为假(例如,5 乘以 3 返回 16 而不是 15),我们将立即能够将新的代码更改与损坏联系起来。

这是一种非常强大的技术,在已经设置好测试的环境中可能被认为是理所当然的。在没有这样一个系统的工作环境中,开发人员的更改或软件依赖项的更新可能会意外地破坏现有的函数并提交到源代码控制中。后来,发现了错误,并且很难将损坏的函数与导致它的代码更改联系起来。

还要记住,单元测试确保某个子单元的功能,但不确保整个项目的功能(其中多个函数一起工作以产生结果)。这就是集成测试发挥作用的地方。我们将在本章后面探讨集成测试。

练习 30:设置 Jest 测试以测试计算器应用程序

在这个练习中,我们将演示如何使用 Jest 设置单元测试,Jest 是 JavaScript 生态系统中最流行的测试框架。我们将继续使用计算器应用程序的示例,并为一个接受一个数字并输出其平方的函数设置自动化测试。

注意

此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson06/Exercise30找到。

执行以下步骤以完成练习:

  1. 在命令行中,导航到Exercise30/start练习文件夹。该文件夹包括一个包含我们将运行测试的代码的src文件夹。

  2. 通过输入以下命令来初始化一个npm项目:

npm init -y
  1. 使用以下命令安装 Jest,使用--save-dev标志(表示该依赖项对开发而非生产是必需的):
npm install --save-dev jest
  1. 创建一个名为__tests__的文件夹。这是 Jest 查找测试的默认位置:
mkdir __tests__
  1. 现在我们将在__tests__/math.test.js中创建我们的第一个测试。它应该导入src/math.js并确保运行math.square(5)返回25
const math = require('./../src/math.js');
test('Check that square of 5 is 25', () => {
  expect(math.square(5)).toBe(25);
});
  1. 打开package.json并修改测试脚本,使其运行jest。注意以下截图中的scripts部分:图 6.3:修改后的测试脚本,使其运行 Jest
图 6.3:修改后的测试脚本,使其运行 Jest
  1. 在命令行中,输入npm run test。这应该返回一条消息,告诉我们找到了错误的值,如下面的代码所示:
FAIL  __test__/math.test.js
  ✕ Check that square of 5 is 25 (17ms)
  ● Check that square of 5 is 25
   expect(received).toBe(expected) // Object.is equality
   Expected: 25
   Received: 10
    2 | 
    3 | test('Check that square of 5 is 25', () => {
   > 4 |  expect(math.square(5)).toBe(25);
      |                  ^
    5 | });
    6 | 
    at Object.toBe (__test__/math.test.js:4:26)
Test Suites: 1 failed, 1 total
Tests:     1 failed, 1 total
Snapshots:  0 total
Time:      1.263s

这个错误是因为起始代码故意在square函数中包含了一个错误。我们没有将数字乘以自身,而是将值加倍。请注意,接收到的答案数量是10

  1. 通过打开文件并修复square函数来修复错误。它应该像下面的代码一样将x相乘,而不是将其加倍:
const square = (x) => x * x;
  1. 修复了我们的代码后,让我们再次用npm run test进行测试。你应该会得到一个成功的消息,如下所示:

图 6.4:使用 npm run test 进行测试后显示的成功消息

图 6.4:使用 npm run test 进行测试后显示的成功消息

在这个练习中,我们设置了一个 Jest 测试,以确保用输入 5 运行我们的square函数返回 25。我们还看了一下当代码中返回错误值时会发生什么,比如返回 10 而不是 25。

集成测试

因此,我们已经讨论了单元测试,当项目的代码发生变化时,它们非常有用,可以帮助找到错误的原因。然而,也有可能项目通过了所有的单元测试,但并不像预期的那样工作。这是因为整个项目包含了将我们的函数粘合在一起的额外逻辑,以及静态组件,如 HTML、数据和其他工件。

集成测试可以用来确保项目在更高层次上工作。例如,虽然我们的单元测试直接调用math.square等函数,但集成测试将测试多个功能一起工作以获得特定结果。

通常,这意味着将多个模块组合在一起,或者与数据库或其他外部组件或 API 进行交互。当然,集成更多部分意味着集成测试需要更长的时间,因此它们应该比单元测试更少地使用。集成测试的另一个缺点是,当一个测试失败时,可能有多种可能性作为原因。相比之下,失败的单元测试通常很容易修复,因为被测试的代码位于指定的位置。

练习 31:使用 Jest 进行集成测试

在这个练习中,我们将继续上次 Jest 练习的内容,上次我们测试了square函数对 5 的响应是否返回 25。在这个练习中,我们将继续添加一些新的测试,使用我们的函数相互结合:

  1. 在命令行中,导航到Exercise31/start练习文件夹,并使用npm安装依赖项:
npm install
  1. 创建一个名为__tests__的文件夹:
mkdir __tests__
  1. 创建一个名为__tests__/math.test.js的文件。然后,在顶部导入math库:
const math = require('./../src/math.js');
  1. 与上一个练习类似,我们将添加一个测试。然而,这里的主要区别是我们将多个函数组合在一起:
test('check that square of result from 1 + 1 is 4', () => {
  expect(math.square(math.add(1,1))).toBe(4);
});
  1. 在前面的测试中添加一个计时器来测量性能:
test('check that square of result from 1 + 1 is 4', () => {
  const start = new Date();
  expect(math.square(math.add(1,1))).toBe(4);
  expect(new Date() - start).toBeLessThan(5000);
});
  1. 现在,通过运行npm test来测试一切是否正常运行:

图 6.5:运行 npm test 以确保一切正常

图 6.5:运行 npm test 以确保一切正常

你应该看到与前面图中类似的输出,每个测试都通过了预期的结果。

应该注意,这些集成测试有点简单。在实际情况下,集成测试结合了我们之前演示的不同来源的函数。例如,当你有多个由不同团队创建的组件时,集成测试可以确保一切都能一起工作。通常,错误可能是由简单的事情引起的,比如更新外部库。

这个想法是你的应用程序的多个部分被集成在一起,这样你就有更大的机会找到哪里出了问题。

代码性能斐波那契示例

通常,一个问题有不止一种解决方案。虽然所有的解决方案可能会返回相同的结果,但它们的性能可能不同。例如,考虑获取斐波那契数列的第 n 个数字的问题。斐波那契是一个数学模式,其中序列中的下一个数字是前两个数字的和(1, 1, 2, 3, 5, 8, 13, …)。

考虑以下解决方案,其中斐波那契递归调用自身:

function fib(n) {
  return (n<=1) ? n : fib(n - 1) + fib(n - 2);
}

前面的例子说明,如果我们想要递归地得到斐波那契数列的第 n 个数字,那么就得到n减一的斐波那契加上n减二的斐波那契,除非n为 1,此时返回 1。它可以返回任何给定数字的正确答案。然而,随着n的增加,执行时间呈指数增长。

要查看这个算法的执行速度有多慢,将fib函数添加到一个新文件中,并使用以下方式通过控制台记录结果:

console.log(fib(37));

接下来,在命令行中运行以下命令(time应该在大多数 Unix 和基于 Mac 的环境中可用):

time node test.js

在特定的笔记本电脑上,我得到了以下结果,表明斐波那契的第 37 位数字是24157817,执行时间为 0.441 秒:

24157817
real 0m0.441s
user 0m0.438s
sys 0m0.004s

现在打开同一个文件,并将37改为44。然后再次运行相同的time node test命令。在我的情况下,仅增加了 7,执行时间就增加了 20 倍:

701408733
real 0m10.664s
user 0m10.653s
sys 0m0.012s

我们可以以更高效的方式重写相同的算法,以增加大数字的速度:

function fibonacciIterator(a, b, n) {
  return n === 0 ? b : fibonacciIterator((a+b), a, (n-1));
}
function fibonacci(n) {
  return fibonacciIterator(1, 0, n);
}

尽管看起来更复杂,但由于执行速度快,这种生成斐波那契数的方法更优越。

Jest 测试的一个缺点是,鉴于前面的情景,斐波那契的慢速和快速版本都会通过。然而,在现实世界的应用程序中,慢速版本显然是不可接受的,因为需要快速处理。

为了防范这种情况,您可能希望添加一些基于性能的测试,以确保函数在一定时间内完成。以下是一个示例,创建一个自定义计时器,以确保函数在 5 秒内完成:

test('Timer - Slow way of getting Fibonacci of 44', () => {
  const start = new Date();
  expect(fastFib(44)).toBe(701408733);
  expect(new Date() - start).toBeLessThan(5000);
});

注意:Jest 的未来版本

手动为所有函数添加计时器可能有些麻烦。因此,在 Jest 项目中有讨论,以创建更简单的语法来实现之前所做的事情。

要查看与此语法相关的讨论以及是否已解决,请在 GitHub 上的 Jest 的问题#6947 中查看github.com/facebook/jest/issues/6947

练习 32:使用 Jest 确保性能

在这个练习中,我们将使用之前描述的技术来测试获取斐波那契的两种算法的性能:

  1. 在命令行中,导航到Exercise32/start练习文件夹,并使用npm安装依赖项:
npm install
  1. 创建一个名为__tests__的文件夹:
mkdir __tests__
  1. 创建一个名为__tests__/fib.test.js的文件。在顶部,导入快速和慢速的斐波那契函数(这些已经在start文件夹中创建):
const fastFib = require('./../fastFib');
const slowFib = require('./../slowFib');
  1. 为快速斐波那契添加一个测试,创建一个计时器,并确保计时器运行时间不超过 5 秒:
test('Fast way of getting Fibonacci of 44', () => {
  const start = new Date();
  expect(fastFib(44)).toBe(701408733);
  expect(new Date() - start).toBeLessThan(5000);
});
  1. 接下来,为慢速斐波那契添加一个测试,同时检查运行时间是否少于 5 秒:
test('Timer - Slow way of getting Fibonacci of 44', () => {
  const start = new Date();
  expect(slowFib(44)).toBe(701408733);
  expect(new Date() - start).toBeLessThan(5000);
});
  1. 从命令行中,使用npm test命令运行测试:

图 6.6:斐波那契测试的结果

图 6.6:斐波那契测试的结果

注意前面提到的关于计时器的错误响应。函数运行时间的预期结果应该在 5,000 毫秒以下,但在我的情况下,我实际收到了 10,961。根据您的计算机速度,您可能会得到不同的结果。如果您没有收到错误,可能是因为您的计算机速度太快,完成时间少于 5,000 毫秒。如果是这种情况,请尝试降低预期的最大时间以触发错误。

端到端测试

虽然集成测试结合了软件项目的多个单元或功能,端到端测试更进一步,模拟了软件的实际使用。

例如,虽然我们的单元测试直接调用了math.square等函数,端到端测试将加载计算器的图形界面,并模拟按下一个数字,比如 5,然后是平方按钮。几秒钟后,端到端测试将查看图形界面中的结果,并确保它等于预期的 25。

由于开销较大,端到端测试应该更加节制地使用,但它是测试过程中的一个很好的最后一步,以确保一切都按预期工作。相比之下,单元测试运行起来相对快速,因此可以更频繁地运行而不会拖慢开发速度。下图显示了测试的推荐分布:

图 6.7:测试的推荐分布

图 6.7:测试的推荐分布

注意:集成测试与端到端测试

值得注意的是,什么被认为是集成测试和端到端测试之间可能存在一些重叠。对于测试类型的解释可能会在不同公司之间有所不同。

传统上,测试被分类为单元测试或集成测试。随着时间的推移,其他分类变得流行,如系统测试、验收测试和端到端测试。因此,特定测试的类型可能会有重叠。

Puppeteer

2018 年,谷歌发布了Puppeteer JavaScript 库,大大提高了在基于 JavaScript 的项目上设置端到端测试的便利性。Puppeteer 是 Chrome 浏览器的无头版本,意味着它没有 GUI 组件。这是至关重要的,因为这意味着我们使用完整的 Chrome 浏览器来测试我们的应用,而不是模拟。

Puppeteer 可以通过类似于 jQuery 的语法进行控制,其中可以通过 ID 或类选择 HTML 页面上的元素并与之交互。例如,以下代码打开 Google News,找到一个.rdp59b类,点击它,等待 3 秒,最后截取屏幕:

(async() => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('http://news.google.com');
  const more = await page.$(".rdp59b");
  more.click();
  await page.waitFor(3000);
  await page.screenshot({path: 'news.png'});
  await browser.close();
})();

请记住,在上面的示例中,我们选择了一个看起来是自动生成的.rdp59b类;因此,很可能这个类将来会发生变化。如果类名发生变化,脚本将不再起作用。

如果在阅读本文时,您发现前面的脚本不起作用,我挑战您更新它。在使用 Puppeteer 时,其中一个最好的工具是 Chrome DevTools。我的常规工作流程是转到我为其编写脚本的网站,并右键单击我将要定位的元素,如下图所示:

图 6.8:在 Chrome 中右键单击进行检查

图 6.8:在 Chrome 中右键单击进行检查

一旦单击检查,DOM 资源管理器将弹出,您将能够看到与元素相关的任何类或 ID:

图 6.9:Chrome DevTools 中的 DOM 资源管理器

图 6.9:Chrome DevTools 中的 DOM 资源管理器

注意:Puppeteer 用于 Web 抓取和自动化

除了用于编写端到端测试,Puppeteer 还可以用于 Web 抓取和自动化。几乎可以在普通浏览器中完成的任何事情都可以自动化(只要有正确的代码)。

除了能够通过选择器在页面上选择元素之外,正如我们之前所看到的,Puppeteer 还可以完全访问键盘和鼠标模拟。因此,诸如自动化基于 Web 的游戏和日常任务等更复杂的事情是可能的。一些人甚至成功地使用它绕过了验证码等东西。

练习 33:使用 Puppeteer 进行端到端测试

在这个练习中,我们将使用 Puppeteer 手动打开一个基于 HTML/JavaScript 的计算器,并像最终用户一样使用它。我不想针对一个实时网站,因为它的内容经常会发生变化或下线。因此,我在项目文件的Exercise33/start中包含了一个 HTML 计算器。

您可以通过使用 npm 安装依赖项,运行npm start,然后在浏览器中转到localhost:8080来查看它:

图 6.10:显示使用 Puppeteer 创建的计算器演示的网站

图 6.10:显示使用 Puppeteer 创建的计算器演示的网站

在这个练习中,我们将创建一个脚本,打开网站,按下按钮,然后检查网站的正确结果。我们不仅仅是检查函数的输出,而是列出在网站上执行的操作,并指定要用作我们测试对象的值的 HTML 选择器。

执行以下步骤完成练习:

  1. 打开Exercise33/start文件夹并安装现有的依赖项:
npm install
  1. 安装所需的jestpuppeteerjest-puppeteer包:
npm install --save-dev jest puppeteer jest-puppeteer
  1. 打开package.json并配置 Jest 使用jest-puppeteer预设,这将自动设置 Jest 以与 Puppeteer 一起工作:
  "jest": {
   "preset": "jest-puppeteer"
  },
  1. 创建一个名为jest-puppeteer.config.js的文件,并添加以下内容:
module.exports = {
  server: {
   command: 'npm start',
   port: 8080,
  },
}

前面的配置将确保在测试阶段之前运行npm start命令。它还告诉 Puppeteer 在port: 8080上查找我们的 Web 应用程序。

  1. 创建一个名为__tests__的新文件夹,就像我们在之前的示例中所做的那样:
mkdir __test__
  1. __tests__文件夹中创建一个名为test.test.js的文件,其中包含以下内容:
describe('Calculator', () => {
  beforeAll(async () => {
   await page.goto('http://localhost:8080')
  })
  it('Check that 5 times 5 is 25', async () => {
   const five = await page.$("#five");
   const multiply = await page.$("#multiply");
   const equals = await page.$("#equals");
   await five.click();
   await multiply.click();
   await five.click();
   await equals.click();
   const result = await page.$eval('#screen', e => e.innerText);
   expect(result).toMatch('25');
  })
})

前面的代码是一个完整的端到端测试,用于将 5 乘以 5 并确认界面返回的答案为 25。在这里,我们打开本地网站,按下五,按下乘,按下五,按下等于,然后检查具有 ID 为screendiv的值。

  1. 使用npm运行测试:

图 6.11:运行计算器脚本后的输出

图 6.11:运行计算器脚本后的输出

您应该看到一个结果,如前图所示,输出为 25。

Git 钩子

这里讨论的测试和 linting 命令对于维护和改进代码质量和功能非常有用。然而,在实际开发的热情中,我们的重点是特定问题和截止日期,很容易忘记运行 linting 和测试命令。

解决这个问题的一个流行方法是使用 Git 钩子。Git 钩子是 Git 版本控制系统的一个特性。Git 钩子指定要在 Git 过程的某个特定点运行的终端命令。Git 钩子可以在提交之前运行;在用户通过拉取更新时运行;以及在许多其他特定点运行。可以在git-scm.com/docs/githooks找到可能的 Git 钩子的完整列表。

对于我们的目的,我们将只关注使用预提交钩子。这将允许我们在提交代码到源代码之前找到任何格式问题。

注意:探索 Git

探索可能的 Git 钩子以及它们通常如何使用的另一个有趣的方法是打开任何 Git 版本控制项目并查看hooks文件夹。

默认情况下,任何新的.git项目都将在.git/hooks文件夹中包含大量的示例。探索它们的内容,并通过使用以下模式重命名它们来触发它们:

<hook-name>.sample to <hook-name>

练习 34:设置本地 Git 钩子

在这个练习中,我们将设置一个本地 Git 钩子,在我们允许使用 Git 提交之前运行lint命令:

  1. 在命令行中,导航到Exercise34/start练习文件夹并安装依赖项:
npm install
  1. 将文件夹初始化为 Git 项目:
git init
  1. 创建.git/hooks/pre-commit文件,其中包含以下内容:
#!/bin/sh
npm run lint
  1. 如果在基于 OS X 或 Linux 的系统上,通过运行以下命令使文件可执行(在 Windows 上不需要):
chmod +x .git/hooks/pre-commit
  1. 我们现在将通过进行提交来测试钩子:
git add package.json
git commit -m "testing git hook"

以下是前面代码的输出:

图 6.12:提交到 Git 之前运行的 Git 钩子

图 6.12:提交到 Git 之前运行 Git 钩子

在您的代码提交到源代码之前,您应该看到lint命令正在运行,如前面的屏幕截图所示。

  1. 接下来,让我们通过添加一些代码来测试失败,这些代码将生成 linting 错误。通过在您的src/js.js文件中添加以下行来修改:
      let number = square(5);

确保在上一行中保留不必要的制表符,因为这将触发 lint 错误。

  1. 重复添加文件并提交的过程:
git add src/js.js
git commit -m "testing bad lint"

以下是上述代码的输出:

图 6.13:提交代码到 git 之前的失败 linting

图 6.13:提交代码到 git 之前的失败 linting

您应该看到lint命令像以前一样运行;但是,在运行后,由于 Git 钩子返回错误,代码不会像上次那样被提交。

使用 Husky 共享 Git 钩子

要注意的一个重要因素是,由于这些钩子位于.git文件夹本身内部,它们不被视为项目的一部分。因此,它们不会被共享到您的中央 Git 存储库供协作者使用。

然而,Git 钩子在协作项目中最有用,新开发人员可能不完全了解项目的约定。当新开发人员克隆项目,进行一些更改,尝试提交,并立即根据 linting 和测试获得反馈时,这是一个非常方便的过程。

husky节点库是基于这个想法创建的。它允许您使用一个名为.huskyrc的单个配置文件在源代码中跟踪您的 Git 钩子。当新开发人员安装项目时,钩子将处于活动状态,开发人员无需做任何操作。

练习 35:使用 Husky 设置提交钩子

在这个练习中,我们将设置一个 Git 钩子,它与练习 34,设置本地 Git 钩子中的钩子做相同的事情,但具有可以在团队中共享的优势。通过使用husky库而不是直接使用git,我们将确保任何克隆项目的人也有在提交任何更改之前运行lint的钩子:

  1. 在命令行中,导航到Exercise35/start练习文件夹并安装依赖项:
npm install
  1. 创建一个名为.huskyrc的文件,其中包含以下内容:
{
  "hooks": {
   "pre-commit": "npm run lint"
  }
}

前面的文件是这个练习的最重要部分,因为它确切地定义了在 Git 过程的哪个时刻运行什么命令。在我们的情况下,在将任何代码提交到源代码之前,我们运行lint命令。

  1. 通过运行git init将文件夹初始化为 Git 项目:
git init
  1. 使用npm安装 Husky:
npm install --save-dev husky
  1. src/js.js进行更改,以便用于我们的测试提交。例如,我将添加以下注释:图 6.14:测试提交注释
图 6.14:测试提交注释
  1. 现在,我们将运行一个测试,确保它像之前的示例一样工作:
git add src/js.js
git commit -m "test husky hook"

以下是上述代码的输出:

图 6.15:提交测试 husky 钩子后的输出

图 6.15:提交测试 husky 钩子后的输出

我们收到关于我们使用console.log的警告,但是出于我们的目的,您可以忽略这一点。主要问题是我们已经使用 Husky 设置了我们的 Git 钩子,因此安装项目的任何其他人也将设置好钩子,而不是我们直接在 Git 中设置它们。

注意:初始化 Husky

请注意,npm install --save-dev husky是在创建 Git 存储库后运行的。当您安装 Husky 时,它会运行必需的命令来设置您的 Git 钩子。但是,如果项目不是 Git 存储库,则无法运行。

如果您遇到与此相关的任何问题,请在初始化 Git 存储库后尝试重新运行npm install --save-dev husky

练习 36:使用 Puppeteer 按文本获取元素

在这个练习中,我们将编写一个 Puppeteer 测试,验证一个小测验应用程序是否正常工作。如果你进入练习文件夹并找到练习 36的起点,你可以运行npm start来查看我们将要测试的测验:

图 6.16:Puppeteer 显示一个小测验应用程序

图 6.16:Puppeteer 显示一个小测验应用程序

在这个应用程序中,点击问题的正确答案会使问题消失,分数增加一:

  1. 在命令行中,导航到Exercise36/start练习文件夹并安装依赖项:
npm install --save-dev jest puppeteer jest-puppeteer
  1. 通过修改scripts部分,向package.json文件添加一个test脚本,使其看起来像下面这样:
  "scripts": {
   "start": "http-server",
   "test": "jest"
  },
  1. package.json中添加一个 Jest 部分,告诉 Jest 使用 Puppeteer 预设:
  "jest": {
   "preset": "jest-puppeteer"
  },
  1. 创建一个名为jest-puppeteer.config.js的文件,在其中告诉 Jest 在运行任何测试之前打开我们的测验应用程序:
module.exports = {
  server: {
   command: 'npm start',
   port: 8080,
  },
}
  1. 创建一个名为__test__的文件夹,我们将把我们的 Jest 测试放在其中:
mkdir __test__
  1. 在名为quiz.test.js的文件夹中创建一个测试。它应该包含以下内容来初始化我们的测试:
describe('Quiz', () => {
  beforeAll(async () => {
   await page.goto('http://localhost:8080')
  })
// tests will go here
})
  1. 接下来,用我们测验中的第一个问题的测试替换前面代码中的注释:
  it('Check question #1', async () => {
   const q1 = await page.$("#q1");
   let rightAnswer = await q1.$x("//button[contains(text(), '10')]");
   await rightAnswer[0].click();
   const result = await page.$eval('#score', e => e.innerText);
   expect(result).toMatch('1');
  })

注意我们使用的q1.$x("//button[contains(text(), '10')]")。我们不是使用 ID,而是在答案中搜索包含文本10的按钮。当解析一个网站时,这可能非常有用,该网站没有在您需要交互的元素上使用 ID。

  1. 在最后一步添加了以下测试。我们将添加三个新测试,每个问题一个:
  it('Check question #2', async () => {
   const q2 = await page.$("#q2");
   let rightAnswer = await q2.$x("//button[contains(text(), '36')]");
   await rightAnswer[0].click();
   const result = await page.$eval('#score', e => e.innerText);
   expect(result).toMatch('2');
  })
  it('Check question #3', async () => {
   const q3 = await page.$("#q3");
   let rightAnswer = await q3.$x("//button[contains(text(), '9')]");
   await rightAnswer[0].click();
   const result = await page.$eval('#score', e => e.innerText);
   expect(result).toMatch('3');
  })
  it('Check question #4', async () => {
   const q4 = await page.$("#q4");
   let rightAnswer = await q4.$x("//button[contains(text(), '99')]");
   await rightAnswer[0].click();
   const result = await page.$eval('#score', e => e.innerText);
   expect(result).toMatch('4');
  })

注意每个测试的底部都有一个预期结果,比上一个高一个;这是我们在页面上跟踪分数。如果一切正常,第四个测试将找到一个分数为 4。

  1. 最后,返回到命令行,以便我们可以确认正确的结果。运行以下test命令:
npm test

以下是前面代码的输出:

图 6.17:命令行确认正确的结果

图 6.17:命令行确认正确的结果

如果一切正确,运行npm test应该看到四个通过的测试作为响应。

活动 7:将所有内容组合在一起

在这个活动中,我们将结合本章的几个方面。从使用 HTML/JavaScript 构建的预先构建的计算器开始,你的任务是:

  • 创建一个lint命令,使用eslint-config-airbnb-base包检查项目是否符合prettiereslint,就像在之前的练习中所做的那样。

  • 使用jest安装puppeteer并在package.json中创建一个运行jesttest命令。

  • 创建一个 Puppeteer 测试,使用计算器计算 777 乘以 777,并确保返回的答案是 603,729。

  • 创建另一个 Puppeteer 测试来计算 3.14 除以 2,并确保返回的答案是 1.57。

  • 安装并设置 Husky,在使用 Git 提交之前运行 linting 和测试命令。

执行以下步骤完成活动(高级步骤):

  1. 安装在 linting 练习中列出的开发人员依赖项(eslintprettiereslint-config-airbnb-baseeslint-config-prettiereslint-plugin-jesteslint-plugin-import)。

  2. 添加一个eslint配置文件.eslintrc

  3. 添加一个.prettierignore文件。

  4. package.json文件中添加一个lint命令。

  5. 打开assignment文件夹,并安装使用 Puppeteer 和 Jest 的开发人员依赖项。

  6. 通过修改package.json文件,添加一个选项告诉 Jest 使用jest-puppeteer预设。

  7. package.json中添加一个test脚本来运行jest

  8. 创建一个jest-puppeteer.config.js来配置 Puppeteer。

  9. __tests__/calculator.js创建一个测试文件。

  10. 创建一个.huskyrc文件。

  11. 通过运行npm install --save-dev husky安装husky作为开发人员依赖项。

预期输出

图 6.18:最终输出显示 calc.test 通过

图 6.18:最终输出显示 calc.test 通过

完成任务后,您应该能够运行npm run lint命令和npm test命令,并像前面的截图中一样通过测试。

注意

这个活动的解决方案可以在 602 页找到。

总结

在本章中,我们着重介绍了自动化测试的代码质量方面。我们从清晰命名和熟悉语言的行业惯例的基础知识开始。通过遵循这些惯例并清晰地书写,我们能够使我们的代码更易读和可重用。

从那里开始,我们看了一下如何使用一些流行的工具(包括 Prettier、ESLint、Jest、Puppeteer 和 Husky)在 Node.js 中创建 linting 和测试命令。

除了设置测试之外,我们还讨论了测试的类别和它们的用例。我们进行了单元测试,确保单个函数按预期工作,并进行了集成测试,将多个函数或程序的方面结合在一起,以确保它们一起工作。然后,我们进行了端到端测试,打开应用程序的界面并与其进行交互,就像最终用户一样。

最后,我们看了如何通过 Git 钩子自动运行我们的 linting 和测试脚本。

在下一章中,我们将研究构造函数、promises 和 async/await。我们将使用其中一些技术以一种现代化的方式重构 JavaScript,利用 ES6 中提供的新功能。

第八章:高级 JavaScript

学习目标

在本章结束时,您将能够:

  • 使用 Node.js REPL 测试简单脚本

  • 构造对象和数组并修改它们的内容

  • 使用对象方法和运算符获取有关对象的信息

  • 创建简单的 JavaScript 类和继承自其他类的类

  • 使用 Math、RegEx、Date 和 String 的高级内置方法

  • 使用数组、Map 和 Set 方法在 JavaScript 中操作数据

  • 实现符号、迭代器、生成器和代理

在本章中,我们将使用 JavaScript 中的数组、类和对象,然后我们将使用继承和常见 JavaScript 类中的内置方法来简化我们的代码并使其高度可重用。

介绍

在为中大型项目(10+个文件)编写 JavaScript 代码时,了解语言提供的所有可能特性是有帮助的。使用已有的东西总比重新发明轮子更容易更快。这些内置方法不仅可以帮助您执行基本功能,还可以帮助提高代码的可读性和可维护性。这些内置方法涵盖了从基本计算到开发人员每天面临的复杂数组和字符串操作。通过使用这些内置方法,我们可以减少代码大小,并帮助提高应用程序的性能。

JavaScript 通常用作函数式语言,但您也可以将其用于面向对象编程OOP)。近年来,为了满足 JavaScript 完成更复杂和数据驱动的任务的不断增长的需求,语言中添加了许多新功能,例如类。虽然仍然可以使用函数原型创建 JavaScript,但许多开发人员已经放弃了这样做,因为它提供了更接近流行的 OOP 语言(如 C++、Java 和 C#)的语法。

在本章中,我们将探索 JavaScript 提供的大量内置方法。我们将使用 Node.js REPL读取-求值-打印循环)来测试我们的代码,因为这不需要我们在磁盘上创建任何文件或调用任何特殊命令。

ES5、ES6、ES7、ES8 和 ES9 支持的语言特性

在我们深入了解这些令人惊奇的语言特性之前,让我们先看一下不同版本的 JavaScript。目前,大多数您经常遇到的支持旧版浏览器的网站使用 ES5。截至 2019 年,许多主流浏览器已经添加了对 ES6 的支持。后续版本将只有最小的浏览器支持。由于我们将在 Node.js 运行时中运行和测试我们的代码,只要我们使用最新的 LTS(长期支持)版本的 Node.js,就不必担心版本兼容性。关于本章将使用的材料,以下是您的运行时需要支持的最低 ES 版本的详细说明:

图 7.1:最低要求的 ES 版本

图 7.1:最低要求的 ES 版本

在本章中,我们不会切换运行时,但在将来,在开始之前最好先检查您要开发的运行时的语言支持。

在 Node.js REPL 中工作

在本章中,我们不会做任何太复杂的事情,所以我们将在Node.js REPL 中编写我们的代码。这样可以让我们在开始编码之前测试一些想法,而无需创建任何文件。在开始之前,请确保您的计算机上已安装了 Node.js,并且已打开终端应用程序。

执行 Node.js REPL

每个 Node.js 安装都包括一个 node 可执行文件,允许您运行本地 JavaScript 文件或启动 REPL。要将 Node.js 可执行文件作为 REPL 运行,您只需在您喜欢的终端中输入node命令,不带任何参数。要测试我们的 Node.js 安装,您可以运行node -v命令:

图 7.2:测试 Node.js 安装

图 7.2:测试 Node.js 安装

如果你看到这样的输出,这意味着你已经正确安装了Node.js

注意

这个命令输出当前运行的Node.js运行时版本,因此这也是一个非常好的检查当前版本的方法。对于本书,我们将使用当前的 LTS,即 v10.16.0。

在验证了我们的 Node.js 安装之后,要以 REPL 模式运行 node 命令,你只需要在命令提示符中输入node

图 7.3:在 REPL 模式下运行 node 命令

图 7.3:在 REPL 模式下运行 node 命令

如果你看到一个等待你输入的光标,恭喜你——你已经成功进入了 Node.js 的 REPL 模式!从现在开始,你可以开始在提示符中输入代码,然后按 Enter 键进行评估。

JavaScript 中的数组操作

在 JavaScript 中创建数组并修改其内容非常容易。与其他语言不同,在 JavaScript 中创建数组不需要指定数据类型或大小,因为这些可以在以后根据需要更改。

要创建一个 JavaScript 数组,请使用以下命令:

const jsArray = [];

请注意,在 JavaScript 中,不需要定义数组中的大小或类型。

要创建一个具有预定义元素的数组,请使用以下命令:

const foodList = ['sushi', 'fried chicken', 21];

要访问和修改数组中的项目,请使用以下命令:

const sushi = foodList[0];
foodList[2] = 'steak';

这与访问数组时其他编程语言非常相似。

练习 37:创建和修改数组中的项目

在这个练习中,我们将创建一个简单的数组,并使用 REPL 来探索它的值。创建数组的语法与许多其他脚本语言非常相似。我们将以两种方式创建singers数组:一种是使用Array构造函数,另一种是使用数组文字方式。一旦我们创建了数组,我们将操纵数组的内容。让我们开始吧:

  1. 使用数组文字方法创建一个空数组并测试它是否成功创建后:
> let exampleArray1 = [];
=> undefined
> Array.isArray(exampleArray1);
=> true
  1. 现在,我们将使用Array构造函数来做同样的事情。虽然它们产生相同的结果,但构造函数允许更多的灵活性:
> let exampleArray2 = new Array();
=> undefined
> Array.isArray(exampleArray2);
=> true

请注意,我们没有使用typeof来检查数组的类型,因为在 JavaScript 中,数组是对象的一种类型。如果我们在刚刚创建的数组上使用typeof,我们会得到一个意外的结果:

> let exampleArray3 = [];
=> undefined
> typeof exampleArray3
=> 'object'
  1. 创建具有预定义大小和项目的数组。请注意,随着向数组添加项目,JavaScript 数组将自动调整大小:
> let exampleArray4 = new Array(6)
=> undefined
> exampleArray4
=> [ <6 empty items> ]
or
> let singers = new Array(6).fill('miku')
=> undefined
> singers
=> [ 'miku', 'miku', 'miku', 'miku', 'miku', 'miku' ]

正如你所看到的,我们初始化了一个具有初始大小为6的数组。我们还使用了fill方法来预定义数组中的所有项目。当我们想要使用数组来跟踪应用程序中的标志时,这是非常有用的。

  1. 为索引0分配一个值:
> singers[0] = 'miku'
=> 'miku'
> singers
=> [ 'miku' ]
  1. 为 JavaScript 数组分配任意索引。未分配的索引将简单地是undefined
> singers[3] = 'luka'
=> 'luka'
> singers[1]
=> undefined
  1. 使用数组的长度修改数组末尾的项目:
> singers[singers.length - 1] = 'rin'
=> 'rin'
> singers
=> [ 'miku', 'miku', 'miku', 'miku', 'miku', 'rin' ]

因此,我们已经学会了如何在 JavaScript 中定义数组。这些数组的行为类似于其他语言,它们也会自动扩展,因此你不必担心手动调整数组的大小。在下一个练习中,我们将讨论如何向数组中添加项目。

练习 38:添加和删除项目

在 JavaScript 中,很容易添加和删除数组中的项目,在许多应用程序中我们必须累积许多项目。在这个练习中,我们将修改之前创建的singers数组。让我们开始吧:

  1. 从一个空数组开始:
> let singers = [];
=> undefined
  1. 使用push在数组末尾添加一个新项目:
> singers.push('miku')
=> 1
> singers
=> [ 'miku' ]

push方法将始终将项目添加到数组的末尾,即使数组中有undefined的项目:

> let food = new Array(3)
=> undefined
> food.push('burger')
=> 4
> food
=> [ <3 empty items>, 'burger' ]

如你在上面的代码中所看到的,如果你有一个预定义大小的数组,使用push将会扩展数组并将其添加到数组的末尾,而不是只将其添加到开头

  1. 从数组末尾删除一个项目:
> singers.push('me')
=> 2
> singers
=> [ 'miku', 'me' ]
> singers.pop()
=> 'me'
> singers
=> [ 'miku' ]
  1. 在数组开头添加一个项目:
> singers.unshift('rin')
=> 2
> singers
=> [ 'rin', 'miku' ]
  1. 从数组的开头移除项目:
> singers.shift()
=> 'rin'
> singers
=> [ 'miku' ]

在更大规模的应用程序中,这些非常有用,比如如果您正在构建一个处理图像的简单 Web 应用程序。当请求到来时,您可以将图像数据、作业 ID 甚至客户端连接推送到数组中,这意味着 JavaScript 数组可以是任何类型。您可以有另一个工作人员在数组上调用pop来检索作业,然后处理它们。

练习 39:获取数组中项目的信息

在这个练习中,我们将介绍获取有关数组中项目的各种基本方法。当我们在处理需要操作数据的应用程序时,这些函数非常有帮助。让我们开始吧:

  1. 创建一个空数组并向其中推送项目:
> let foods = []
=> undefined
> foods.push('burger')
=> 1
> foods.push('fries')
=> 2
> foods.push('wings')
=> 3
  1. 查找项目的索引:
> foods.indexOf('burger')
=> 0
  1. 查找数组中项目的数量:
> foods.length
=> 3
  1. 从数组中的特定索引中移除一个项目。我们将通过将要移除的项目的位置存储到一个变量中来实现这一点。知道我们要移除项目的位置后,我们可以调用array.splice来移除它:
> let position = foods.indexOf('burger')
=> undefined
> foods.splice(position, 1) // splice(startIndex, deleteCount)
=> [ 'burger' ]
> foods
=> [ 'fries', 'wings' ]

注意

array.splice也可以用于在特定索引处插入/替换项目到数组中。我们将在后面详细介绍该函数的具体情况。当我们使用它时,我们提供它两个参数。第一个告诉 splice 从哪里开始,下一个告诉它从起始位置删除多少个项目。因为我们只想删除该索引处的项目,所以我们提供 1。

在这个练习中,我们探讨了获取有关数组更多信息的方法。尝试定位特定项目的索引在构建应用程序中非常有用。使用这些内置方法非常有用,因为您不需要通过数组来查找项目。在下一个活动中,我们将使用用户的 ID 构建一个简单的用户跟踪器。

活动 8:创建用户跟踪器

假设您正在构建一个网站,并且想要跟踪当前有多少人正在查看它。为了做到这一点,您决定在后端保留一个用户列表。当用户打开您的网站时,您将更新列表以包括该用户,当该用户关闭您的网站时,您将从列表中删除该用户。

对于此活动,我们将有一个名为users的列表,其中存储了一系列字符串,以及一些辅助函数来帮助存储和删除列表中的用户。

为了做到这一点,我们需要定义一个函数,该函数接受我们的用户列表并对其进行修改以符合我们的要求。

完成此活动的步骤如下:

  1. 创建Activity08.js文件。

  2. 定义一个logUser函数,它将添加用户到提供的userList参数中,并确保不添加重复项。

  3. 定义一个userLeft函数。它将从提供的userList参数中移除用户。

  4. 定义一个numUsers函数,它返回当前列表中的用户数量。

  5. 定义一个名为runSite的函数。这将用于测试我们的实现。

注意

此活动的解决方案可在第 607 页找到。

在这个活动中,我们探讨了在 JavaScript 中使用数组完成某些任务的一种方式。我们可以使用它来跟踪项目列表,并使用内置方法来添加和删除项目。我们看到user3user5user6是因为这些用户从未被移除。

JavaScript 中的对象操作

在 JavaScript 中创建基本对象非常容易,并且对象在每个 JavaScript 应用程序中都被使用。JavaScript 对象还包括一系列内置方法供您使用。当我们编写代码时,这些方法非常有帮助,因为它使得在 JavaScript 中开发非常容易和有趣。在本节中,我们将研究如何在我们的代码中创建对象以及如何最大限度地利用它们的潜力。

要在 JavaScript 中创建一个对象,请使用以下命令:

const myObj = {};

通过使用{}符号,我们正在定义一个空对象并将其分配给我们的变量名。

我们可以使用对象在我们的应用程序中存储许多键值对的数字:

myObj.item1 = 'item1';
myObj.item2 = 12;

如果我们想要访问值,这也很容易:

const item = myObj.item1;

在 JavaScript 中,创建对象并不意味着必须遵循特定的模式。您可以在对象中放入任意数量的属性。只需确保对象键没有重复:

> dancers = []
=> undefined
> dancers.push({ name: 'joey', age: 30 })
=> undefined

请注意,新对象的语法与 JSON 表示法非常相似。有时我们需要确切知道对象中有什么样的信息。

您可以创建一个具有一些属性的用户对象:

> let myConsole = { name: 'PS4', color: 'black', price: 499, library: []}
=> undefined

要获取所有属性名称,您需要使用keys方法,如下所示:

> Object.keys(myConsole)
=> [ 'name', 'color', 'price', 'library' ]

我们还可以测试属性是否存在。让我们检查尚未定义的属性:

> if (myConsole.ramSize) {
... console.log('ram size is defined.');
... }
> undefined

现在,让我们检查我们之前定义的属性:

> if (myConsole.price) {
... console.log('price is defined.');
... }
> price is defined.

这是测试属性是否存在于对象中的一种非常简单的方法。在许多应用程序中,这经常用于检查字段的存在性,如果不存在,则将设置默认值。只需记住,在 JavaScript 中,空字符串、空数组、数字零和其他虚假值将被if语句评估为false。在下一个练习中,我们将尝试创建一个包含大量信息并从中输出非常有用信息的对象。

练习 40:在 JavaScript 中创建和修改对象

在这个练习中,我们将在数组中存储对象,并通过对对象进行更改来修改数组。然后,我们将检查如何使用其属性访问对象。我们将继续使用之前定义的singers数组,但这次不仅存储字符串列表,而是使用对象。让我们开始吧:

  1. singers数组设置为空数组:
> singers = []
=> undefined
  1. 将对象推送到数组中:
> singers.push({ name: 'miku', age: 16 })
=> undefined
  1. 修改数组中第一个对象的name属性:
> singers[0].name = 'Hatsune Miku'
=> 'Hatsune Miku'
> singers
=> [ { name: 'Hatsune Miku', age: 16 } ]

修改对象中的值非常简单;例如,您可以将任何值分配给属性,但不仅如此。您还可以添加原本不是对象一部分的属性,以扩展其信息。

  1. 向对象添加一个名为birthday的属性:
> singers[0].birthday = 'August 31'
=> 'August 31'
> singers
=> [ { name: 'Hatsune Miku', age: 16, birthday: 'August 31' } ]

要向现有对象添加属性,只需将值分配给属性名称。如果该属性不存在,将创建该属性。您可以将任何值分配给属性,函数、数组或其他对象。

  1. 通过执行以下代码读取对象中的属性:
> singers[0].name
=> 'Hatsune Miku'
or
> const propertyName = 'name'
=> undefined
> singers[0][propertyName]
=> 'Hatsune Miku'

正如您所看到的,访问 JavaScript 对象的属性值非常简单。如果您已经知道值的名称,只需使用点表示法。在某些情况下,属性名称是动态的或来自变量,您可以使用括号表示法来访问该属性名称的属性值。

在这个练习中,我们讨论了在 JavaScript 中创建对象的方法以及如何修改和添加属性。JavaScript 对象和数组一样,非常容易修改,而且不需要您指定模式。在下一个活动中,我们将构建一个非常有趣的实用程序,可以帮助您了解对象在网络中的工作方式以及如何有效地使用它们。

JSON.stringify

JSON.stringify是一个非常有用的实用程序,它将 JavaScript 对象转换为格式化的字符串。稍后,可以通过网络传输字符串。

例如,假设我们有一个user对象,我们想将其转换为字符串:

const user = {
   name: 'r1cebank',
   favoriteFood: [
      'ramen',
      'sushi',
      'fried chicken'
   ]
};

如果我们想要将对象转换为字符串,我们需要使用JSON.stringify调用此对象,如下面的代码所示:

JSON.stringify(user);

我们将得到这样的结果:

图 7.4:使用 JSON.stringify 的结果

图 7.4:使用 JSON.stringify 的结果

正如您所看到的,调用JSON.stringify已将我们的对象转换为对象的字符串表示。

但由于它的实现方式,JSON.stringify非常低效。尽管在大多数应用程序中性能差异并不明显,在高性能应用程序中,性能确实很重要。使JSON.stringify更快的一种方法是知道你需要最终输出中的哪些属性。

练习 41:创建一个高效的 JSON.Stringify

我们的目标是编写一个简单的函数,该函数接受一个对象和要包含在最终输出中的属性列表。然后,该函数将调用JSON.stringify来创建对象的字符串版本。让我们在Exercise41.js文件中定义一个名为betterStringify的函数:

  1. 创建betterStringify函数:
function betterStringify(item, propertyMap) {
}
  1. 现在,我们将创建一个临时输出。我们将存储我们想要包含在propertyMap中的属性:
let output = {};
  1. 遍历我们的propertyMap参数以挑选我们想要包含的属性:
propertyMap.forEach((key) => {
});

因为我们的propertyMap参数是一个数组,我们希望使用forEach来对其进行迭代。

  1. 将值从我们的项目分配给临时输出:
propertyMap.forEach((key) => {
if (item[key]) {
   output[key] = item[key];
}
});

在这里,我们正在检查我们的propertyMap参数中的键是否已设置。如果已设置,我们将把值存储在我们的output属性中。

  1. 在测试对象上使用一个函数:
const singer = {
 name: 'Hatsune Miku',
 age: 16,
 birthday: 'August 31',
 birthplace: 'Sapporo, Japan',
 songList: [
  'World is mine',
  'Tell your world',
  'Melt'
 ]
}
console.log(betterStringify(singer, ['name', 'birthday']))

完成函数后,运行文件将产生以下输出:

图 7.5:运行 better_stringify.js 的输出

图 7.5:运行 Exercise41.js 的输出

现在,是时候回答一个棘手的问题了:如果你像这样做了一些事情,你的代码会有多快?

如果你对此进行基准测试,你会看到比JSON.stringify快 30%的性能提升:

图 7.6 JSON.stringify 和我们的方法之间的性能差异

图 7.6:JSON.stringify 和我们的方法之间的性能差异

这是你可以用来挑选属性而不是使用JSON.stringify来转储所有内容的一个非常基本的例子。

数组和对象的解构赋值

在之前的练习和活动中,我们讨论了修改对象和数组中的值的基本方法,以及从中获取更多信息的方法。还有一种方法可以使用解构赋值从数组或对象中检索值。

假设你已经得到了一个需要分配给变量的参数列表:

const param = ['My Name', 12, 'Developer'];

一种分配它们的方法是访问数组中的每个项目:

const name = param[0];
const age = param[1];
const job = param[2];

我们还可以使用解构赋值将其简化为一行:

[name, age, job] = param;

练习 42:使用数组的解构赋值

在这个练习中,我们将声明一个名为userInfo的数组。它将包括基本的用户信息。我们还将声明一些变量,以便我们可以使用解构赋值将数组中的项目存储起来。让我们开始吧:

  1. 创建userInfo数组:
> const userInfo = ['John', 'chef', 34]
=> undefined
  1. 创建用于存储nameagejob的变量:
> let name, age, job
=> undefined
  1. 使用解构赋值语法将值分配给我们的变量:
> [name, job, age] = userInfo
=> [ 'John', 'chef', 34 ]

检查我们的值:

> name
=> 'John'
> job
=> 'chef'
> age
=> 34
  1. 你还可以使用以下代码忽略数组中的值:
> [name, ,age] = userInfo
=> [ 'John', 'chef', 34 ] // we ignored the second element 'chef'

解构赋值在处理数据时非常有用,因为数据的格式通常不是你所期望的。它还可以用来挑选数组中你想要的项目。

练习 43:使用对象的解构赋值

在之前的练习中,我们声明了一个包含用户信息的数组,并使用解构赋值从中检索了一些值。同样的事情也可以用于对象。在这个练习中,我们将尝试对对象使用解构赋值。让我们开始吧:

  1. 创建一个名为userInfo的对象:
> const userInfo = { name: 'John', job: 'chef', age: 34 }
=> undefined
  1. 创建我们将用来存储信息的变量:
> let name, job
=> undefined
  1. 使用解构赋值语法来分配值:
> ({ name, job } = userInfo)
=> { name: 'John', job: 'chef', age: 34 }
  1. 检查这些值:
> name
=> 'John'
> job
=> 'chef'

请注意,在对象上使用解构赋值时,它的作用类似于一个过滤器,其中变量名必须匹配,并且您可以有选择地选择要选择的数组中的属性。还有一种不需要预先声明变量的对象使用方式。

  1. 使用数组进行解构赋值:
> userInfo = ['John', 'chef', 34]
=> undefined
> [ name, , age] = userInfo
=> undefined
> name
=> 'John'
> age
=> 34
  1. 使用解构运算符从对象值创建变量:
> const userInfoObj = { name: 'John', job: 'chef', age: 34 }
=> undefined
> let { job } = userInfoObj
=> undefined
> job
=> 'chef'

以下是前面代码的输出:

图 7.7:作业变量的输出

图 7.7:作业变量的输出

在这个练习中,我们讨论了如何使用解构运算符从对象和数组中提取特定信息。当我们处理大量信息并且只想传输该信息的子集时,这非常有用。

展开运算符

在上一个练习中,我们讨论了从对象或数组中获取特定信息的一些方法。还有另一个运算符可以帮助我们展开数组或对象。展开运算符被添加到 ES6 规范中,但在 ES9 中,它还添加了对对象展开的支持。展开运算符的功能是将每个项目展开为单独的项目。对于数组,当我们使用展开运算符时,我们可以将其视为单独值的列表。对于对象,它们将展开为键值对。在下一个练习中,我们将探索在应用程序中使用展开运算符的不同方式。

要使用展开运算符,我们在任何可迭代对象之前使用三个点(),就像这样:

printUser(...userInfo)

练习 44:使用展开运算符

在这个练习中,我们将看到展开运算符如何帮助我们。我们将使用上一个练习中的原始userInfo数组。

执行以下步骤完成练习:

  1. 创建userInfo数组:
> const userInfo = ['John', 'chef', 34]
=> undefined
  1. 创建一个打印用户信息的函数:
> function printUser(name, job, age) {
... console.log(name + ' is working as ' + job + ' and is ' + age + ' years old');
... }
=> undefined
  1. 将数组展开为参数列表:
> printUser(...userInfo)
John is working as chef and is 34 years old

正如你所看到的,调用这个函数的原始方式,没有使用展开运算符,是使用数组访问运算符,并为每个参数重复这样做。由于数组的排序与相应的参数匹配,我们可以只使用展开运算符。

  1. 当你想要合并数组时使用展开运算符:
> const detailedInfo = ['male', ...userInfo, 'July 5']
=> [ 'male', 'John', 'chef', 34, 'July 5' ]
  1. 使用展开运算符作为复制数组的一种方式:
> let detailedInfoCopy = [ ...detailedInfo ];
=> undefined
> detailedInfoCopy
=> [ 'male', 'John', 'chef', 34, 'July 5' ]

在对象上使用展开运算符要强大得多且实用。

  1. 创建一个名为userRequest的新对象:
> const userRequest = { name: 'username', type: 'update', data: 'newname'}
=> undefined
  1. 使用object展开克隆对象:
> const newObj = { ...userRequest }
=> undefined
> newObj
=> { name: 'username', type: 'update', data: 'newname' }
  1. 创建一个包含此对象的每个属性的对象:
> const detailedRequestObj = { data: new Date(), new: true, ...userRequest}
=> undefined
> detailedRequestObj
=> { data: 'newname', new: true, name: 'username', type: 'update' }

您可以看到,当您想要复制所有属性到一个新对象时,展开运算符非常有用。您可以在许多应用程序中看到它的使用,其中您希望用一些通用属性包装用户请求以进行进一步处理。

剩余运算符

在上一节中,我们看了展开运算符。同样的运算符也可以以不同的方式使用。在函数声明中,它们被称为剩余运算符

剩余运算符主要用于表示无限数量的参数。然后,参数将被放入一个数组中:

function sum(...numbers) {
   console.log(numbers);
}
sum(1, 2, 3, 4, 5, 6, 7, 8, 9);

正如你所看到的,我们在名称前使用了相同的三个点。这告诉我们的代码,我们期望这个函数有无限数量的参数。当我们使用参数列表调用函数时,它们将被放入一个 JavaScript 数组中:

图 7.8:当使用数字列表调用 sum 时的输出

图 7.8:当使用数字列表调用 sum 时的输出

这并不意味着你对参数的数量没有任何控制。您可以像这样编写函数声明,让 JavaScript 将多个参数映射到您喜欢的方式,并将其余参数放入数组中:

function sum(initial, ...numbers) {
   console.log(initial, numbers);
}

这将第一个参数映射到名为 initial 的变量,其余参数映射到名为numbers的数组:

sum(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);

以下是前面代码的输出:

图 7.9:当使用 0 和 1-9 调用 sum 时的输出。

图 7.9:调用 0 和 1-9 时 sum 的输出。

JavaScript 中的面向对象编程

由于 JavaScript 在 Web 开发中的流行,它主要以一种功能性的方式使用。这导致许多开发人员认为在 JavaScript 中没有办法进行面向对象编程。甚至在 ES6 标准发布之前,已经有一种定义类的方式:使用函数。您可能在旧版前端代码中看到过这种定义类的方式。例如,如果您想创建一个名为Food的类,您将不得不写类似于这样的代码:

function Food(name) {
   this.name = name;
}
var leek = new Food("leek");
console.log(leek.name); // Outputs "leek"

在 ES6 发布后,越来越多的开发人员采用了使用class关键字编写现代 JavaScript 类的方式。在本章中,我们将介绍使用 ES6 标准声明类的方法。

在 JavaScript 中定义类

在我们深入讨论 JavaScript 中定义类的最新语法之前,让我们先了解 ES6 之前的做法。

在 ES6 之前用于定义类的语法如下:

function ClassName(param1, param2) {
   // Constructor Logic
}

本质上,我们正在定义constructor类。函数的名称将是类的名称。

使用 ES6 定义类的语法如下:

class ClassName {
   constructor(param1, param2) {
      // Constructor logic
   }
   method1(param) {
      // Method logic
   }
}

这通常是我们在其他语言中对类定义所做的事情。在这里,我们可以定义一个构造函数和一个方法。

练习 45:使用函数声明对象构造函数

在这个练习中,我们将创建一个非常简单的名为Food的类。稍后,我们还将为类添加一些方法。我们将在这里使用函数构造方法。让我们开始吧:

  1. 定义Food构造函数:
function Food(name, calories, cost) {
   this.name = name;
   this.calories = calories;
   this.cost = cost;
}
  1. 将方法添加到构造函数中:
Food.prototype.description = function () {
   return this.name + ' calories: ' + this.calories;
}
  1. 使用Food构造函数创建一个新对象:
let burger = new Food('burger', 1000, 9);
  1. 调用我们声明的方法:
console.log(burger.description());

以下是前面代码的输出:

图 7.10:burger.description()方法的输出

图 7.10:burger.description()方法的输出

你们中的许多人可能熟悉这种类声明的方式。但这也会带来问题。首先,使用函数作为构造函数会让开发人员不清楚何时将函数视为函数,何时将其视为构造函数。后来,当 JavaScript 发布了 ES6 时,它引入了一种新的声明类的方式。在下一个练习中,我们将使用新的方法来声明Food类。

练习 46:在 JavaScript 中创建一个类

在这个练习中,我们将在 JavaScript 中创建一个类定义来存储食物数据。它将包括一个名称、成本和卡路里计数。稍后,我们还将创建一些返回食物描述的方法,以及另一个静态方法来输出特定食物的卡路里。让我们开始吧:

  1. 声明一个Food类:
class Food {
}
  1. 对类名运行typeof以查看它的类型:
console.log(typeof Food) // should print out 'function'

以下是前面代码的输出:

图 7.11:在类上运行 typeof 命令

图 7.11:在类上运行 typeof 命令

正如您所看到的,我们刚刚声明的新类的类型是function - 这不是很有趣吗?这是因为在 JavaScript 内部,我们声明的类只是另一种编写constructor函数的方式。

  1. 让我们添加我们的constructor
class Food {
   constructor(name, calories, cost) {
      this.name = name;
      this.calories = calories;
      this.cost = cost;
   }
}

就像任何其他语言一样,类定义将包括一个构造函数,使用new关键字调用它来创建这个类的实例。

  1. 在类定义中编写description方法:
class Food {
   constructor(name, calories, cost) {
      this.name = name;
      this.calories = calories;
      this.cost = cost;
   }
   description() {
      return this.name + ' calories: ' + this.calories;
   }
}
  1. 如果您尝试像调用函数一样调用Food类构造函数,它将抛出以下错误:
Food('burger', 1000, 9);
// TypeError: Class constructor Food2 cannot be invoked without 'new'

以下是前面代码的输出:

图 7.12:以函数方式调用构造函数的 TypeError

图 7.12:以函数方式调用构造函数的 TypeError

请注意,当您尝试将构造函数作为函数调用时,运行时会抛出错误。这非常有帮助,因为它可以防止开发人员错误地将构造函数作为函数调用。

  1. 使用类构造函数创建一个新的食物对象:
let friedChicken = new Food('fried chicken', 520, 5);
  1. 调用我们声明的方法:
console.log(friedChicken.description());
  1. 声明static方法,它返回卡路里数:
class Food {
   constructor(name, calories, cost) {
      this.name = name;
      this.calories = calories;
      this.cost = cost;
   }
   static getCalories(food) {
      return food.calories
   }
   description() {
      return this.name + ' calories: ' + this.calories;
   }
}
  1. 使用我们刚刚创建的对象调用static方法:
console.log(Food.getCalories(friedChicken)); /// 520

以下是前面代码的输出:

图 7.13:调用 Food 类的静态方法后生成的输出

图 7.13:调用 Food 类的静态方法后生成的输出

与任何其他编程语言一样,您可以在不实例化对象的情况下调用static方法。

现在我们已经看过了在 JavaScript 中声明类的新方法,让我们谈谈一些类声明的不同之处:

  • 构造函数方法是必需的。 如果您没有声明一个,JavaScript 将添加一个空构造函数。

  • 类声明不会被提升,这意味着您不能在声明之前使用它。 因此,最好将类定义或导入放在代码的顶部。

使用对象创建简单的用户信息缓存

在本节中,我们将设计一个简单的用户信息缓存。 缓存是一个临时位置,您可以在从原始位置获取它们时将最常访问的项目存储在其中。 假设您正在为处理用户配置文件的后端应用程序进行设计。 每当请求到来时,服务器都需要调用数据库来检索用户配置文件并将其发送回处理程序。 正如您可能知道的那样,调用数据库是一个非常昂贵的操作。 作为后端开发人员,您可能会被要求提高服务的读取性能。

在下一个练习中,您将创建一个简单的缓存,用于存储用户配置文件,以便大部分时间可以跳过对数据库的请求。

练习 47:创建一个缓存类以添加/更新/删除数据存储中的记录

在这个练习中,我们将创建一个包含本地内存数据存储的缓存类。 它还包括一个从数据存储中添加/更新/删除记录的方法。

执行以下步骤以完成此练习:

  1. 创建MySimpleCache类:
class MySimpleCache {
constructor() {
   // Declare your cache internal properties here
   this.cacheItems = {};
}
}

在构造函数中,我们还将初始化缓存的内部状态。 这将是一个简单的对象。

  1. 定义addItem,它将为键设置缓存项:
addItem(key, value) {
// Add an item with the key
this.cacheItems[key] = value;
  }
  1. 定义updateItem,它将使用我们已经定义的addItem
updateItem(key, value) {
// Update a value use the key
this.addItem(key, value);
}
  1. 定义removeItem。 这将删除我们存储在缓存中的对象,并调用我们之前创建的updateItem方法:
removeItem(key) {
this.updateItem(key, undefined);
}
  1. 使用assert()测试我们的缓存,通过更新和删除一些用户来测试testMycache
function testMyCache() {
   const cache = new MySimpleCache ();
   cache.addItem('user1', { name: 'user1', dob: 'Jan 1' });
   cache.addItem('user2', { name: 'user2', dob: 'Jul 21' });
   cache.updateItem('user1', { name: 'user1', dob: 'Jan 2' });
   cache.addItem('user3', { name: 'user3', dob: 'Feb 1' });
   cache.removeItem('user3');
   assert(cache.getItem('user1').dob === 'Jan 2');
   assert(cache.getItem('user2').dob === 'Jul 21');
   assert(cache.getItem('user3') === undefined);
   console.log ('=====TEST PASSED=====')
}
testMyCache();

注意

assert()是一个内置的 Node.js 函数,它接受一个表达式。 如果表达式求值为true,它将通过,如果求值为false,它将抛出异常。

运行文件后,您应该看不到错误,并且会看到以下输出:

图 7.14:simple_cache.js 的输出

图 7.14:simple_cache.js 的输出

类继承

到目前为止,我们只在 JavaScript 中创建了简单的类定义。 在 OOP 中,我们还可以让一个类继承自另一个类。 类继承只是使一个类的实现派生自另一个类。 创建的子类将具有父类的所有属性和方法。 这在以下图表中显示:

图 7.15:类继承

图 7.15:类继承

类继承提供了一些好处:

  • 它创建了干净,可测试和可重用的代码。

  • 它减少了相似代码的数量。

  • 在编写适用于所有子类的新功能时,减少了维护时间。

在 JavaScript 中,很容易创建一个从另一个类继承的子类。 为此,使用extends关键字:

class MySubClass extends ParentClass {
}

练习 48:实现子类

在这个练习中,我们将定义一个名为Vehicle的超类,并从中创建我们的子类。 超类将具有名为startbuynamespeedcost的方法作为其属性。

超类的构造函数将获取名称,颜色和速度属性,然后将它们存储在对象内部。

start方法将简单地打印一个字符串,告诉您正在使用哪种车辆以及您是如何旅行的。buy函数将打印出您即将购买的车辆。

执行以下步骤以完成此练习:

  1. 定义Vehicle类:
class Vehicle {
   constructor(name, speed, cost) {
      this.name = name;
      this.speed = speed;
      this.cost = cost;
   }
   start() {
      console.log('Starting vehicle, ' + this.name + ' at ' + this.speed + 'km/h');
   }
   buy() {
      console.log('Buying for ' + this.cost);
   }
}
  1. 创建一个vehicle实例并测试其方法:
const vehicle = new Vehicle('bicycle', 15, 100);
vehicle.start();
vehicle.buy();

您应该看到以下输出:

图 7.16:车辆类的输出

图 7.16:车辆类的输出
  1. 创建CarPlaneRocket子类:
class Car extends Vehicle {}
class Plane extends Vehicle {}
class Rocket extends Vehicle {}
  1. CarPlaneRocket中,重写start方法:
class Car extends Vehicle {
   start() {
      console.log('Driving car, at ' + this.speed + 'km/h');
   }
}
class Plane extends Vehicle {
   start() {
      console.log('Flying plane, at ' + this.speed + 'km/h');
   }
}
class Rocket extends Vehicle {
   start() {
      console.log('Flying rocket to the moon, at ' + this.speed + 'km/h');
   }
}
  1. PlaneRocketCar创建一个实例:
const car = new Car('Toyota Corolla', 120, 5000);
const plane = new Plane('Boeing 737', 1000, 26000000);
const rocket = new Rocket('Saturn V', 9920, 6000000000);
  1. 在所有三个对象上调用start方法:
car.start();
plane.start();
rocket.start();

以下是前述代码的输出:

图 7.17:对象的输出

图 7.17:对象的输出

现在当您调用这些 start 方法时,您可以清楚地看到输出是不同的。在声明子类时,大多数时候,我们需要重写父类的一些方法。当我们减少重复的代码同时保留定制时,这非常有用。

定制不止于此 - 您还可以创建具有不同构造函数的新子类。您还可以从子类调用父方法。

  1. 对我们之前创建的子类,我们将修改Car子类,以便在构造函数中包含额外的参数:
class Car extends Vehicle {
   constructor(name, speed, cost, tankSize) {
      super(name, speed, cost);
      this.tankSize = tankSize;
   }
   start() {
      console.log('Driving car, at ' + this.speed + 'km/h');
   }
}
  1. 检查额外的属性是否已设置:
const car2 = new Car('Toyota Corolla 2', 120, 5000, 2000);
console.log(car2.tankSize); // 2000

以下是前述代码的输出:

图 7.18:检查 Car 类的额外属性

图 7.18:检查 Car 类的额外属性

如您所见,声明子类非常容易 - 在以这种方式编码时,您可以共享大量代码。此外,您不会失去进行定制的能力。在 ES6 标准之后,您可以轻松地定义类,就像其他面向对象的编程语言一样。它可以使您的代码更清晰,更易于测试和更易于维护。

私有和公共方法

在面向对象编程中,有时将可公开访问的属性和函数与私有可访问的属性和函数分开是有用的。这是一种保护层,可以防止使用类的开发人员调用或访问类的一些内部状态。在 JavaScript 中,这种行为是不可能的,因为 ES6 不允许声明私有属性;您在类中声明的所有属性都将是公开可访问的。为了实现这种类型的行为,一些开发人员选择使用下划线前缀,例如privateMethod(),以通知其他开发人员不要使用它。但是,有关声明私有方法的黑客。在下一个练习中,我们将探讨私有方法。

练习 49:车辆类中的私有方法

在这个练习中,我们将尝试为我们之前创建的Car类声明一个私有函数,以便在以后将类导出为模块时确保我们的私有方法不会暴露出来。让我们开始吧:

  1. 创建一个名为printStat的函数:
function printStat() {
   console.log('The car has a tanksize of ', this.tankSize);
}
  1. 修改public方法以使用我们刚刚声明的函数:
class Car extends Vehicle {
   constructor(name, speed, cost, tankSize) {
      super(name, speed, cost);
      this.tankSize = tankSize;
   }
   start() {
      console.log('Driving car, at ' + this.speed + 'km/h');
      printStat();
   }
}

我们直接从start方法调用了printStat,但是没有真正的方法可以直接访问,而是使用我们类中的一个方法。通过在外部声明方法,我们使方法成为private

  1. 创建另一个car实例并调用start方法:
const car = new Car('Toyota Corolla', 120, 5000, 2000);
car.start();

当您运行此代码时,您将意识到这会导致异常:

图 7.19:printStat 的输出

图 7.19:printStat 的输出
  1. 修改start方法,以便函数了解我们从中调用它的对象实例:
start() {
      console.log('Driving car, at ' + this.speed + 'km/h');
      printStat.bind(this)();
   }

请注意我们使用了.bind()。通过使用绑定,我们将当前实例绑定到此函数内部的this变量。这使我们的代码能够按预期工作:

图 7.20:使用.bind()后的 printStat 的输出

图 7.20:使用.bind()后 printStat 的输出

正如您所看到的,目前在 JavaScript 中没有一种简单地声明private方法或属性的方法。这个例子只是对这个问题的一个变通方法;它仍然不能像其他面向对象的语言(如 Java 或 Python)那样提供相等的分离。也有在线选项,可以使用符号声明私有方法,但如果知道在哪里查找,它们也可以被访问。

数组和对象内置方法

之前,我们讨论了基本数组和对象。它们处理我们如何存储数据。现在,我们将深入探讨如何对刚刚存储在其中的数据进行高级计算和操作。

array.map(function)

数组映射将遍历数组中的每个项目,并返回一个新数组作为结果。传递给方法的函数将以当前项目作为参数,并且函数的返回值将包含在最终数组的结果中;例如:

const singers = [{ name: 'Miku', age: 16}, { name: 'Kaito', age: 20 }];

如果我们想要创建一个新数组,并且只包括列表中对象的名称属性,我们可以使用array.map来实现:

const names = singers.map((singer) => singer.name);

以下是上述代码的输出:

图 7.21:使用数组映射方法的输出

图 7.21:使用数组映射方法的输出

array.forEach(function)

.forEach是一种迭代数组项的方法。与.map不同,它不会返回新值。我们传递的函数只是重复调用数组中的值;例如:

const singers = [{ name: 'Miku', age: 16}, { name: 'Kaito', age: 20 }];
singers.forEach((singer) => {
   console.log(singer.name);
})

这将打印出数组中每个歌手的名字。

array.find(function)

.find方法的工作原理与.map.forEach方法相同;它接受一个函数作为参数。此函数将用于确定当前对象是否符合搜索的要求。如果找到匹配项,它将用作方法的返回结果。如果数组中找到多个匹配项,则此方法将不返回任何结果。例如,如果我们想要找到名称等于某个字符串的对象,我们可以这样做:

const singers = [{ name: 'Miku', age: 16}, { name: 'Kaito', age: 20 }];
const miku = singers.find((singer) => singer.name === 'Miku');

array.filter(function)

.filter的工作原理与.find相同,但它允许返回多个项目。如果我们想要在列表中匹配多个项目,我们需要使用.filter。如果要查找年龄小于 30 岁的歌手列表,请使用以下代码:

const singers = [{ name: 'Miku', age: 16}, { name: 'Kaito', age: 20 }];
const youngSingers = singers.filter((singer) => singer.age < 30);

数组的map方法在迭代数组中的每个项目时创建一个新数组。map方法接受一个函数,就像forEach方法一样。当执行时,它将使用当前项目调用函数的第一个参数和当前索引的第二个参数。map方法还期望返回提供给它的函数。返回的值将放入新数组中,并由该方法返回,如下所示:

const programmingLanguages = ['C', 'Java', 'Python'];
const myMappedArray = programmingLanguages.map((language) => {
   return 'I know ' + language;
});

.map方法将遍历数组,我们的map函数将返回"I know,"加上当前语言。因此,myMappedArray的结果将如下所示:

图 7.22:使用数组映射方法的示例输出

图 7.22:使用数组映射方法的示例输出

我们将在第十章 JavaScript 中的函数式编程中更详细地介绍array.map

我们将在接下来的练习中使用的另一种方法是forEach方法。forEach方法更加简洁,因为不需要管理当前索引并编写实际调用函数的代码。forEach方法是一个内置的数组方法,它接受一个函数作为参数。以下是forEach方法的示例:

foods.forEach(eat_food);

在接下来的练习中,我们将在数组上使用迭代方法。

练习 50:在数组上使用迭代方法

有许多遍历数组的方法。一种是使用带有索引的for循环,另一种是使用其中一种内置方法。在这个练习中,我们将初始化一个字符串数组,然后探索 JavaScript 中可用的一些迭代方法。让我们开始吧:

  1. 创建一个食物列表作为数组:
const foods = ['sushi', 'tofu', 'fried chicken'];
  1. 使用join连接数组中的每个项目:
foods.join(', ');

以下是上述代码的输出:

图 7.23:数组中的连接项目

图 7.23:数组中的连接项目

数组连接是另一种遍历数组中每个项目的方法,使用提供的分隔符将它们组合成一个单一的字符串。

  1. 创建一个名为eat_food的函数:
function eat_food(food) {
   console.log('I am eating ' + food);
}
  1. 使用for循环来遍历数组并调用函数:
const foods = ['sushi', 'tofu', 'fried chicken'];
function eat_food(food) {
   console.log('I am eating ' + food);
}
for(let i = 0; i < foods.length; i++) {
   eat_food(foods[i]);
}

以下是上述代码的输出:

图 7.24:在循环中调用 eat_food 的输出

图 7.24:在循环中调用 eat_food 的输出
  1. 使用forEach方法来实现相同的效果:
foods.forEach(eat_food);

以下是上述代码的输出:

图 7.25:使用 forEach 方法生成相同的输出

图 7.25:使用 forEach 方法生成相同的输出

因为eat_food是一个函数,它的第一个参数引用了当前项目,所以我们可以直接传递函数名。

  1. 创建一个新的卡路里数字数组:
const nutrition = [100, 50, 400]

这个数组包括我们food数组中每个项目的卡路里。接下来,我们将使用不同的迭代函数来创建一个包含这些信息的新对象列表。

  1. 创建新的对象数组:
const foodInfo = foods.map((food, index) => {
   return {
      name: food,
      calories: nutrition[index]
   };
});
  1. foodInfo打印到控制台上:
console.log(foodInfo);

以下是上述代码的输出:

图 7.26:包含食物和卡路里信息的数组

图 7.26:包含食物和卡路里信息的数组

运行array.map后,将创建一个新数组,其中包含有关我们食物名称和其卡路里计数的信息。

在这个练习中,我们讨论了两种迭代方法,即forEachmap。每种方法都有其自己的功能和用法。在大多数应用程序中,通常使用映射来通过在每个数组项上运行相同的代码来计算数组结果。如果你想要在不直接修改数组的情况下操作数组中的每个项目,这是非常有用的。

练习 51:查找和过滤数组

以前,我们讨论了遍历数组的方法。这些方法也可以用于查找。众所周知,当你从头到尾迭代数组时,查找是非常昂贵的。幸运的是,JavaScript 数组有一些内置方法,因此我们不必自己编写搜索函数。在这个练习中,我们将使用includesfilter来搜索数组中的项目。让我们开始吧:

  1. 创建一个名为profiles的名称列表:
let profiles = [
   'Michael Scott',
   'Jim Halpert',
   'Dwight Shrute',
   'Random User',
   'Hatsune Miku',
   'Rin Kagamine'
];
  1. 尝试找出profiles列表中是否包含名为Jim Halpert的人:
let hasJim = profiles.includes('Jim Halpert');
console.log(hasJim);

以下是上述代码的输出:

图 7.27:hasJim 方法的输出

图 7.27:hasJim 方法的输出
  1. 修改profiles数组以包含额外的信息:
const profiles = [
   { name: 'Michael Scott', age: 42 },
   { name: 'Jim Halpert', age: 27},
   { name: 'Dwight Shrute', age: 37 },
   { name: 'Random User', age: 10 },
   { name: 'Hatsune Miku', age: 16 },
   { name: 'Rin Kagamine', age: 14 }
]

现在,数组不再是简单的字符串列表-它是一个对象列表,当我们处理对象时,事情会有点不同。

  1. 尝试再次使用includes查找Jim Halpert个人资料:
hasJim = profiles.includes({ name: 'Jim Halpert', age: 27});
console.log(hasJim);

以下是上述代码的输出:

图 7.28:hasJim 方法的输出

图 7.28:hasJim 方法的输出
  1. 找到名为Jim Halpert的个人资料:
hasJim = !!profiles.find((profile) => {
   return profile.name === 'Jim Halpert';
}).length;
console.log(hasJim);
  1. 找到所有年龄大于18的用户:
const adults = profiles.filter((profile) => {
   return profile.age > 18;
});
console.log(adults);

当你运行上述代码时,它应该输出所有年龄超过 18 岁的用户。filterfind之间的区别在于filter返回一个数组:

图 7.29:使用 filter 方法后的输出

图 7.29:使用 filter 方法后的输出

在这个练习中,我们看了两种定位数组中特定项的方法。通过使用这些方法,我们可以避免重写搜索算法。findfilter之间的区别在于filter返回符合要求的所有对象的数组。在实际的生产环境中,当我们想要测试数组是否有与我们要求匹配的对象时,通常使用find方法,因为它在找到一个匹配时就停止扫描,而filter会与数组中的所有对象进行比较,并返回所有匹配的结果。如果您只是测试某物的存在,这将更加昂贵。我们还使用了双重否定运算符将结果转换为布尔值。如果您稍后在条件语句中使用这个值,这种表示法非常有用。

排序

排序是开发人员面临的最大挑战之一。当我们想要对数组中的一些项目进行排序时,通常需要定义特定的排序算法。这些算法通常需要我们编写大量的排序逻辑,并且不容易重用。在 JavaScript 中,我们可以使用内置的数组方法对我们的自定义项目列表进行排序,并编写最少的自定义代码。

在 JavaScript 数组中进行排序需要在数组上调用.sort()函数。sort()函数接受一个参数,称为排序比较器。根据比较器,sort()函数将决定如何排列每个元素。

以下是我们将在即将进行的练习中使用的一些其他函数的简要描述。

compareNumber函数只计算ab之间的差异。在sort方法中,我们可以声明自己的自定义比较函数进行比较:

function compareNumber(a, b) {
   return a - b;
}

compareAge函数与compareNumber函数非常相似。唯一的区别在于我们比较的是 JavaScript 对象而不是数字:

function compareAge(a, b) {
   return a.age - b.age;
}

练习 52:JavaScript 中的数组排序

在这个练习中,我们将讨论对数组进行排序的方法。在计算机科学中,排序总是复杂的。在 JavaScript 中,数组对象内置了一个排序方法,可以对数组进行基本排序。

我们将使用上一个练习中的profiles对象数组。让我们开始吧:

  1. 创建一个numbers数组:
const numbers = [ 20, 1, 3, 55, 100, 2];
  1. 调用array.sort()对这个数组进行排序:
numbers.sort();
console.log(numbers);

当您运行上述代码时,您将获得以下输出:

图 7.30:数组.sort()的输出

图 7.30:数组.sort()的输出

这并不是我们想要的;似乎sort函数只是随机排列值。其背后的原因是,在 JavaScript 中,array.sort()实际上并不支持按值排序。默认情况下,它将所有内容视为字符串。当我们使用数字数组调用它时,它将所有内容转换为字符串,然后开始排序。这就是为什么您会看到数字 1 出现在 2 和 3 之前的原因。为了实现对数字的排序,我们需要做一些额外的工作。

  1. 定义compareNumber函数:
function compareNumber(a, b) {
   return a - b;
}

该函数期望接受两个要进行比较的值,并返回一个必须匹配以下内容的值:如果a小于b,则返回小于 0 的数字;如果a等于b,则返回 0;如果a大于b,则返回大于 0 的数字。

  1. 运行sort函数,并将compareNumber函数作为参数传递:
numbers.sort(compareNumber);
console.log(numbers);

当您运行上述代码时,您将看到该函数已将我们的数组按照我们想要的顺序排序:

图 7.31:数组.sort(compareNumber)的输出

图 7.31:数组.sort(compareNumber)的输出

现在,数组已经正确地从最小到最大排序。然而,大多数情况下,当我们需要进行排序时,我们需要将复杂的对象排序。在下一步中,我们将使用在上一个练习中创建的profiles数组。

  1. 如果您的工作空间中未定义profiles数组,请创建它:
const profiles = [
   { name: 'Michael Scott', age: 42 },
   { name: 'Jim Halpert', age: 27},
   { name: 'Dwight Shrute', age: 37 },
   { name: 'Random User', age: 10 },
   { name: 'Hatsune Miku', age: 16 },
   { name: 'Rin Kagamine', age: 14 }
]
  1. 调用profiles.sort()
profiles.sort();
console.log(profiles);

以下是前面代码的输出:

图 7.32:profiles.sort()函数的输出

图 7.32:profiles.sort()函数的输出

因为我们的sort函数不知道如何比较这些对象,所以数组保持原样。为了正确排序对象,我们需要一个与上次一样的比较函数。

  1. 定义compareAge
function compareAge(a, b) {
   return a.age - b.age;
}

提供给compareAge的两个参数ab是数组中的对象。因此,为了正确排序它们,我们需要访问这些对象的age属性并进行比较。

  1. 使用我们刚刚定义的compare函数调用sort函数:
profiles.sort(compareAge);
console.log(profiles);

以下是前面代码的输出:

图 7.33:profile.sort(compareAge)的结果

图 7.33:profile.sort(compareAge)的结果

在这个练习中,我们讨论了对数组进行排序的方法。需要记住的一件事是,在 JavaScript 中,如果不对字符串值进行排序,则需要使用比较函数来告诉它如何排序。该方法的空间和时间复杂度因平台而异,但如果使用 Node.js,JavaScript 的 V8 引擎对这些类型的操作进行了高度优化,因此您不必担心性能问题。在下一个练习中,我们将讨论 JavaScript 中非常有趣但又有用的数组操作,即数组减少器。通过使用数组减少器,我们可以轻松地将数组中的项目组合在一起,并将它们减少为一个单一的值。

数组减少

在构建后端应用程序时,经常会出现给定格式化结果列表并且必须从中计算单个值的情况。虽然可以使用传统的循环方法来完成,但使用 JavaScript 减少函数时更加简洁和易于维护。减少意味着对数组中的每个元素进行处理,并返回一个单一的值。

如果我们想要减少一个数组,我们可以调用内置的array.reduce()方法:

Array.reduce((previousValue, currentValue) => {
   // reducer
}, initialValue);

当我们调用array.reduce()时,我们需要传入一个函数和初始值。该函数将以前一个值和当前一个值作为参数,并将返回值用作最终值。

练习 53:使用 JavaScript 减少方法为购物车进行计算

在这个练习中,我们将尝试使用 JavaScript 的reduce方法为购物车进行计算。让我们开始吧:

  1. 创建购物车变量:
const cart = [];
  1. 将项目推入数组:
cart.push({ name: 'CD', price: 12.00, amount: 2 });
cart.push({ name: 'Book', price: 45.90, amount: 1 });
cart.push({ name: 'Headphones', price: 5.99, amount: 3 });
cart.push({ name: 'Coffee', price: 12.00, amount: 2 });
cart.push({ name: 'Mug', price: 15.45, amount: 1 });
cart.push({ name: 'Sugar', price: 5.00, amount: 1 });
  1. 使用循环方法计算购物车的总成本:
let total = 0;
cart.forEach((item) => {
   total += item.price * item.amount;
});
console.log('Total amount: ' + total);

以下是前面代码的输出:

图 7.34:计算总数的循环方法的结果

图 7.34:计算总数的循环方法的结果
  1. 我们编写了名为priceReducer的 reducer:
function priceReducer (accumulator, currentValue) {
   return accumulator += currentValue.price * currentValue.amount;
}
  1. 使用我们的 reducer 调用cart.reduce
total = cart.reduce(priceReducer, 0);
console.log('Total amount: ' + total);

以下是前面代码的输出:

图 7.35:cart.reduce 的结果

图 7.35:cart.reduce 的结果

在这个练习中,我们讨论了在 JavaScript 中将数组减少为单个值的方法。虽然使用循环迭代数组并返回累加器是完全正确的,但是使用减少函数时,代码会更加简洁。我们不仅减少了作用域中可变变量的数量,还使代码更加简洁和可维护。下一个维护代码的人将知道该函数的返回值将是一个单一的值,而forEach方法可能会使得返回结果不清晰。

活动 9:使用 JavaScript 数组和类创建学生管理器

假设你正在为当地的学区工作,到目前为止,他们一直在使用纸质登记簿来记录学生信息。现在,他们获得了一些资金,并希望您开发一款计算机软件来跟踪学生信息。他们对软件有以下要求:

  • 它需要能够记录关于学生的信息,包括他们的姓名、年龄、年级和书籍信息。

  • 每个学生将被分配一个唯一的 ID,用于检索和修改学生记录。

  • 书籍信息将包括该学生的书籍名称和当前成绩(数字成绩)。

  • 需要一种方法来计算学生的平均成绩。

  • 需要一种方法来搜索具有相同年龄或年级的所有学生。

  • 需要一种方法来使用他们的名字搜索学生。当找到多个时,返回所有学生。

注意

此活动的完整代码也可以在我们的 GitHub 存储库中找到,链接在这里:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson07/Activity09/Activity09.js

执行以下步骤以完成此活动:

  1. 创建一个School类并在构造函数中初始化学生列表。

  2. 创建一个Student类,并在其中存储课程列表、学生的agenamegrade level

  3. 创建一个Course类,其中包括有关coursenamegrades的信息。

  4. School类中创建addStudent函数,将学生推入school对象中的列表中。

  5. School类中创建findByGrade函数,该函数返回具有给定grade level的所有学生。

  6. School类中创建findByAge函数,该函数返回具有相同age的学生列表。

  7. School类中创建findByName函数,通过姓名搜索学校中的所有学生。

  8. Student类中,为计算学生的平均成绩创建一个calculateAverageGrade方法。

  9. Student类中,创建一个assignGrade方法,该方法将为学生所学课程分配一个数字成绩。

注意

此活动的解决方案可以在第 608 页找到。

在上一节中,我们讨论了允许我们迭代、查找和减少数组的方法。在处理数组时,这些方法非常有用。虽然大多数方法只能完成基本任务,并且可以很容易地使用循环实现,但使用它们有助于使我们的代码更易用和可测试。一些内置方法也经过了运行时引擎的优化。

在下一节中,我们将讨论 Map 和 Set 的一些内置函数。如果我们需要在应用程序中跟踪值,它们非常有用。

Map 和 Set

Map 和 Set 在 JavaScript 中是非常被低估的类型,但在某些应用中它们可以非常强大。Map 在 JavaScript 中的工作原理就像一个基本的哈希映射,当您需要跟踪一组键值对时非常有用。Set 用于在需要保留一组唯一值时使用。大多数开发人员经常在所有情况下都使用对象,而忽略了在某些情况下使用 Map 和 Set 更有效的事实。在接下来的部分中,我们将讨论 Map 和 Set 以及如何使用它们。

有许多情况下,我们必须跟踪应用程序中的一组唯一键值对。在使用其他语言编程时,我们经常需要实现一个名为哈希映射的类。在 JavaScript 中,有两种类型可以实现这一点:一种是 Map,另一种是 Object。因为它们似乎做同样的事情,许多 JavaScript 开发人员倾向于在所有情况下都使用 Object,而忽略了在某些情况下使用 Map 对他们的用例更有效的事实。

练习 54:使用 Map 与对象

在这个练习中,我们将讨论我们可以如何使用 Map 以及它们与对象相比有何不同:

  1. 创建一个名为map的新 Map:
const map = new Map()
  1. 创建我们想要用作键的对象列表:
const key1 = 'key1';
const key2 = { name: 'John', age: 18 };
const key3 = Map;
  1. 使用map.set为我们之前定义的所有键设置一个值:
map.set(key1, 'value for key1');
map.set(key2, 'value for key2');
map.set(key3, 'value for key3');

以下是前面代码的输出:

图 7.36:对 map.set 分配值后的输出

图 7.36:对 map.set 分配值后的输出
  1. 获取键的值:
console.log(map.get(key1));
console.log(map.get(key2));
console.log(map.get(key3));

以下是前面代码的输出:

图 7.37:值检索的 console.log 输出

图 7.37:值检索的 console.log 输出
  1. 在不使用引用的情况下检索key2的值:
console.log(map.get({ name: 'John', age: 18 }));

以下是前面代码的输出:

图 7.38:在没有引用的情况下使用 get 时的 console.log 输出

图 7.38:在没有引用的情况下使用 get 时的 console.log 输出

虽然我们输入了所有正确的内容,但是我们的地图似乎无法找到该键的值。这是因为在进行这些检索时,它使用的是对象的引用而不是值。

  1. 使用forEach迭代地图:
map.forEach((value, key) => {
   console.log('the value for key: ' + key + ' is ' + value);
});

地图可以像数组一样进行迭代。使用forEach方法时,传入的函数将被调用两个参数:第一个参数是值,第二个参数是键。

  1. 获取键和值的数组列表:
console.log(map.keys());
console.log(map.values());

以下是前面代码的输出:

图 7.39:键和值的数组列表

图 7.39:键和值的数组列表

当您只需要存储信息的一部分时,这些方法非常有用。如果您有一个地图来跟踪用户,使用他们的 ID 作为键,调用values方法将简单地返回一个用户列表。

  1. 检查地图是否包含一个键:
console.log(map.has('non exist')); // false

以下是前面代码的输出:

图 7.40:指示地图不包括键的输出

图 7.40:指示地图不包括键的输出

注意

在这里,我们可以看到地图和对象之间的第一个主要区别,尽管两者都能够跟踪唯一键值对的列表。在地图中,您可以拥有对象或函数的引用作为键。这在 JavaScript 中的对象中是不可能的。我们还可以看到的另一件事是,它还保留了根据它们被添加到地图中的顺序的键的顺序。虽然您可能会在对象中获得有序的键,但 JavaScript 不能保证键的顺序与它们被添加到对象中的顺序一致。

通过这个练习,我们了解了地图的用法及其与对象的区别。当你处理键值数据并且需要进行排序时,地图应该始终优先于对象,因为它不仅保留了键的顺序,还允许将对象引用用作键。这是两种类型之间的主要区别。在下一个练习中,我们将介绍另一种经常被开发人员忽视的类型:集合。

在数学中,集合被定义为不同对象的集合。在 JavaScript 中,它很少被使用,但是我们将无论如何介绍一种使用集合的方法。

练习 55:使用集合跟踪唯一值

在这个练习中,我们将介绍 JavaScript 集合。我们将构建一个算法来删除数组中的所有重复值。

执行以下步骤完成此练习:

  1. 声明一个名为planets的字符串数组:
const planets = [
   'Mercury',
   'Uranus',
   'Mars',
   'Venus',
   'Neptune',
   'Saturn',
   'Mars',
   'Jupiter',
   'Earth',
   'Saturn'
]
  1. 使用数组创建一个新的集合:
const planetSet = new Set(planets);
  1. 检索planets数组中的唯一值:
console.log(planetSet.values());

以下是前面代码的输出:

图 7.41:唯一的数组值

图 7.41:唯一的数组值
  1. 使用add方法向集合添加更多值:
planetSet.add('Venus');
planetSet.add('Kepler-440b');

我们可以使用add方法向我们的集合添加一个新值,但是因为集合始终保持其成员的唯一性,如果您添加任何已经存在的内容,它将被忽略:

图 7.42:无法添加重复值

图 7.42:无法添加重复值
  1. 使用.size属性获取 Set 的大小:
console.log(planetSet.size);
  1. 清除集合中的所有值:
planetSet.clear();
console.log(planetSet);

以下是前面代码的输出:

![图 7.43:从集合中清除所有值

](Images/C14587_07_43.jpg)

图 7.43:从集合中清除所有值

在这个练习中,我们介绍了一些使用 Set 作为工具来帮助我们在数组中删除重复值的方法。当您想要保留一系列唯一值并且不需要通过索引访问它们时,集合非常有用。否则,如果您处理可能包含重复项的大量项目,则数组仍然是最佳选择。在下一节中,我们将讨论 Math,Date 和 String 方法。

数学,日期和字符串

在使用 JavaScript 构建复杂应用程序时,有时您需要处理字符串操作,数学计算和日期。幸运的是,JavaScript 有几种内置方法可以处理这种类型的数据。在接下来的练习中,我们将介绍如何在应用程序中利用这些方法。

要创建new Date对象,请使用以下命令:

const currentDate = new Date();

这将指向当前日期。

要创建一个新字符串,请使用以下命令:

const myString = 'this is a string';

要使用Math模块,我们可以使用Math类:

const random = Math.random();

练习 56:使用字符串方法

在这个练习中,我们将介绍一些更容易在应用程序中处理字符串的方法。在其他语言中,字符串操作和构建一直是复杂的任务。在 JavaScript 中,通过使用 String 方法,我们可以轻松地创建,匹配和操作字符串。在这个练习中,我们将创建各种字符串并使用 String 方法来操作它们。

执行以下步骤以完成此练习:

  1. 创建一个名为planet的变量:
let planet = 'Earth';
  1. 使用模板字符串创建句子
let sentence = `We are on the planet ${planet}`;

模板字符串是 ES6 中引入的非常有用的功能。我们可以通过组合模板和变量来创建字符串,而无需创建字符串构建或使用字符串连接。字符串模板使用`包装,而要插入到字符串中的变量用${}包装。

  1. 将我们的句子分割成单词:
console.log(sentence.split(' '));

我们可以使用 split 方法和分隔符将字符串拆分为数组。在上面的示例中,JavaScript 将我们的句子分割成一个单词数组,就像这样:

图 7.44:将字符串分割为单词数组

图 7.44:将字符串分割为单词数组
  1. 我们还可以使用 replace 方法将任何匹配的子字符串替换为另一子字符串,如下所示:
sentence = sentence.replace('Earth', 'Venus');
console.log(sentence);

以下是先前代码的输出结果:

图 7.45:替换字符串中的单词

图 7.45:替换字符串中的单词

replace 方法中,我们将第一个参数作为要在字符串中匹配的子字符串提供。第二个参数是您要用来替换的字符串。

  1. 检查我们的句子是否包含单词 火星
console.log(sentence.includes('Mars'));

以下是先前代码的输出结果:

图 7.46:检查字符串中是否存在某个字符

图 7.46:检查字符串中是否存在某个字符
  1. 您还可以将整个字符串转换为大写或小写:
sentence.toUpperCase();
sentence.toLowerCase();
  1. 使用 charAt 方法在字符串中获取索引处的字符:
sentence.charAt(0); // returns W

由于句子并不一定是数组,所以无法像数组那样访问特定位置的字符。要实现这一点,您需要调用 charAt 方法。

  1. 使用字符串的 length 属性获取字符串的长度:
sentence.length;

以下是先前代码的输出结果:

图 7.47:修改后句子的长度

图 7.47:我们修改后句子的长度

在这个练习中,我们将介绍如何使用模板字符串和字符串方法构建字符串,这些方法有助于我们操作字符串。这在处理大量用户输入的应用程序中非常有用。在下一个练习中,我们将学习 Math 和 Date 方法。

Math 和 Date

在本节中,我们将学习 Math 和 Date 类型。我们很少在应用程序中涉及 Math,但是当我们涉及它时,充分利用 Math 库非常有用。稍后,我们将讨论 Date 对象及其方法。Math 和 Date 类包括各种有用的方法,帮助我们进行数学计算和日期操作。

练习 57:使用 Math 和 Date

在本练习中,我们将学习如何在 JavaScript 中实现 Math 和 Date 类型。我们将使用它们来生成随机数,并使用其内置常量进行数学计算。我们还将使用 Date 对象来测试 JavaScript 中不同处理日期的方式。让我们开始吧:

  1. 创建一个名为 generateRandomString 的函数:
function generateRandomString(length) {

}
  1. 创建一个在一定范围内生成随机数的函数:
function generateRandomNumber(min, max) {
   return Math.floor(Math.random() * (max - min + 1)) + min;
}

在上述函数中,Math.random 生成 0(inclusive)到 1(exclusive)之间的随机数。当我们想要两个范围内的数字时,我们也可以使用 Math.floor 将数字四舍五入以确保它不包括 max 在我们的输出中。

  1. generateRandomString中使用随机数生成函数:
function generateRandomString(length) {
   const characters = [];
   const characterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
   for (let i = 0; i < length; i++) {
      characters.push(characterSet.charAt(generateRandomNumber(0, characterSet.length)));
   }
   return characters.join(');
}

我们用于随机数生成的方法非常简单 - 我们有一个包含在随机字符串中的字符集。之后,我们将运行一个循环,使用我们创建的函数来获取一个随机字符,使用charAt传递一个随机索引。

  1. 测试我们的函数:
console.log(generateRandomString(16));

以下是先前代码的输出:

图 7.48:我们随机字符串函数的输出

图 7.48:我们随机字符串函数的输出

每次运行这个函数,它都会给我们一个完全随机的字符串,该字符串的长度与我们传递的参数相同。这是生成随机用户名的非常简单的方法,但不太适合生成 ID,因为它无法保证唯一性。

  1. 使用Math常数创建一个计算圆形面积的函数,如下所示:
function circleArea(radius) {
   return Math.pow(radius, 2) * Math.PI;
}

在这个函数中,我们使用了Math对象中的Math.PI。它赋予了 radius 参数的平方值。接下来,我们将探讨 JavaScript 中的Date类型。

  1. 创建一个新的Date对象:
const now = new Date();
console.log(now);

以下是先前代码的输出:

图 7.49:新日期对象的输出

图 7.49:新日期对象的输出

当我们创建一个不带参数的新Date对象时,它将生成一个存储当前时间的对象。

  1. 在特定的日期和时间创建一个新的Date对象:
const past = new Date('August 31, 2007 00:00:00');

Date构造函数将接受一个可解析为日期的字符串参数。当我们使用这个字符串调用构造函数时,它将创建一个Date对象在那个日期和时间。

  1. 从我们的past日期对象中获取年、月和日:
console.log(past.getFullYear());
console.log(past.getMonth());
console.log(past.getDate());

以下是先前代码的输出:

图 7.50:过去日期对象的年、月和日

图 7.50:过去日期对象的年、月和日

返回的月份不是从 1 开始的,一月是 1。相反,它从 0 开始,因此八月是 7。

  1. 你也可以通过调用toString生成对象的字符串表示版本:
console.log(past.toString());

以下是先前代码的输出:

图 7.51:以字符串形式呈现的日期

图 7.51:以字符串形式呈现的日期

通过使用toString方法,我们可以简单地在应用程序中记录时间戳。

  1. 如果你想得到 Unix 时间,你可以使用Date.now
console.log(Math.floor(Date.now() / 1000));

我们再次使用Math.floor的原因是,我们需要将Date.now的输出除以 1,000,因为它以毫秒返回。

在这个练习中,我们介绍了 Math 和 Date 类型在应用程序中的几种用法。当我们需要生成伪随机 ID 或随机字符串时,它们非常有用。Date对象还在我们需要在应用程序中跟踪时间戳时使用。在下一节中,我们将简要介绍 Symbols、Iterators、Generators 和 Proxies。

符号、迭代器、生成器和代理

在 JavaScript 开发中,这些类型很少被使用,但对于某些用例,它们可以非常有用。在本节中,我们将介绍这些是什么,以及如何在我们的应用程序中使用它们。

符号

符号是唯一的值;它们可以作为标识符使用,因为每次调用Symbol()时,它都会返回一个唯一的符号。即使函数返回一个 Symbol 类型,它也不能使用new关键字调用,因为它不是一个构造函数。当存储在对象中时,它们在遍历属性列表时不会被包括,因此如果你想将任何东西存储为对象内的属性,又不希望它们在运行JSON.stringify时被公开,你可以使用符号来实现这一点。

迭代器与生成器

迭代器和生成器经常一起使用。生成器函数是调用时不立即执行其代码的函数。当需要从生成器返回一个值时,需要使用yield进行调用。之后它将暂停执行,直到再次调用下一个函数。这使得生成器非常适合用作迭代器。在迭代器中,我们需要定义一个具有next方法的函数,每次调用时都会返回一个值。通过这两者的结合,我们可以构建非常强大的迭代器,其中包含大量可重用的代码。

符号是 JavaScript 中一个难以理解的概念,并且并不经常使用。在这个练习中,我们将介绍一些使用符号并探索它们属性的方法。

练习 58:使用符号并探索它们的属性

在这个练习中,我们将使用符号及其属性来识别对象的属性。让我们开始吧:

  1. 创建两个符号:
let symbol1 = Symbol();
let symbol2 = Symbol('symbol');
  1. 测试它们的等价性:
console.log(symbol1 === symbol2);
console.log(symbol1 === Symbol('symbol'));

两个语句都将被评估为 false。这是因为在 JavaScript 中,符号是唯一的,即使它们具有相同的名称,它们仍然不相等。

  1. 创建一个带有一些属性的测试对象:
const testObj = {};
testObj.name = 'test object';
testObj.included = 'this will be included';
  1. 使用符号作为键在对象中创建一个属性:
const symbolKey = Symbol();
testObj[symbolKey] = 'this will be hidden';
  1. 打印出对象中的键:
console.log(Object.keys(testObj));

以下是前面代码的输出结果:

图 7.52:使用 Object.keys 打印出的键列表

图 7.52:使用 Object.keys 打印出的键列表

看起来调用Object.keys并没有返回我们的Symbol属性。这背后的原因是因为符号不可枚举,因此它们既不会被Object.keys返回,也不会被Object.getOwnPropertyNames返回。

  1. 让我们尝试获取我们的Symbol属性的值:
console.log(testObj[Symbol()]); // Will return undefined
console.log(testObj[symbolKey]); // Will return our hidden property
  1. 使用Symbol注册表:
const anotherSymbolKey = Symbol.for('key');
const copyOfAnotherSymbol = Symbol.for('key');

在这个例子中,我们可以对Symbol键进行搜索,并将该引用存储在我们的新常量中。Symbol注册表是我们应用程序中所有符号的注册表。在这里,你可以将你创建的符号存储在一个全局注册表中,这样它们以后就可以被检索到。

  1. 使用其引用检索Symbol属性的内容:
testObj[anotherSymbolKey] = 'another key';
console.log(testObj[copyOfAnotherSymbol]);

以下是前面代码的输出结果:

图 7.53:通过符号引用检索值的结果

图 7.53:通过符号引用检索值的结果

当我们运行这段代码时,它将打印出我们想要的结果。当我们使用Symbol.for创建一个符号时,我们将在键和引用之间创建一个一对一的关系,这样当我们使用Symbol.for获取另一个引用时,这两个符号将是相等的。

在这个练习中,我们讨论了符号的一些属性。如果您需要将它们用作object属性的标识符,它们非常有用。使用Symbol注册表也可以帮助我们重新定位我们之前创建的Symbol。在下一个练习中,我们将讨论迭代器和生成器的一般用法。

在前一个练习中,我们讨论了符号。在 JavaScript 中还有另一种叫做Symbol的类型,叫做Symbol.iterator,它是一个特定的符号,用于创建迭代器。在这个练习中,我们将使用生成器来创建一个可迭代对象。

练习 59:迭代器和生成器

Python 中有一个非常有用的函数叫做range(),可以生成给定范围内的数字;现在,让我们尝试用迭代器重新创建它:

  1. 创建一个名为range的函数,它返回具有iterator属性的对象:
function range(max) {
   return {
      *[Symbol.iterator]() {
        yield 1;
      }
   };
}
  1. 在我们的range函数上使用for..in循环:
for (let value of range(10)) {
   console.log(value);
}

以下是上述代码的输出:

图 7.54:使用 for..in 循环输出

图 7.54:使用 for..in 循环输出

当我们运行这段代码时,它只会产生一个值。为了修改它以产生多个结果,我们将用循环包装它。

  1. 让我们用循环包装yield语句:
function range(max) {
   return {
      *[Symbol.iterator]() {
        for (let i = 0; i < max; i++) {
           yield i;
        }
      }
   };
}

通常情况下,这不会与returns一起使用,因为它只能被返回一次。这是因为期望生成器函数使用.next()多次被消耗。我们可以延迟其执行,直到再次被调用:

图 7.55:在循环中包装 yield 语句后的输出

图 7.55:在循环中包装 yield 语句后的输出

为了更好地理解生成器函数,我们还可以定义一个简单的生成器函数,而不必将其实现为迭代器。

  1. 创建一个名为gen的生成器函数:
function* gen() {
   yield 1;
}

这是对生成器函数的非常简单的定义。当它被调用时,它将返回一个只能遍历一次的生成器。然而,你可以使用前述函数生成任意多的生成器。

  1. 生成一个名为generator的函数:
const generator = gen();
  1. 调用生成器的next方法来获取它的值:
console.log(generator.next());
console.log(generator.next());
console.log(generator.next());
当我们在生成器上调用`.next()`时,它将执行我们的代码,直到达到`yield`关键字。然后,它将返回该语句产生的值。它还包括一个`done`属性,用于指示这个生成器是否已经遍历了所有可能的值。一旦生成器达到了`done`状态,除非你修改内部状态,否则没有重新开始迭代的方法:

图 7.56:生成语句后的值

图 7.56:生成语句后的值

如您所见,第一次调用next方法时,我们将得到值 1。之后,done属性将设置为true。无论我们调用多少次,它都将始终返回undefined,这意味着生成器已经完成了迭代。

在这个练习中,我们介绍了迭代器和生成器。它们在 JavaScript 中非常强大,早期的 async/await 功能很大程度上是使用生成器函数创建的,即使在官方支持之前。下次您创建可以通过迭代的自定义类或对象时,可以创建生成器。这使得代码更清晰,因为不需要管理大量内部状态。

代理

当您需要对对象进行更精细的控制,需要管理每个基本操作时,可以使用代理。您可以将 JavaScript 代理视为操作和对象之间的中介。通过它可以有代理,这意味着您可以实现非常复杂的对象。在下一个练习中,我们将介绍可以使用代理来启用对象的创造性方式。

代理就像是对象和程序其余部分之间的中间人。对该对象进行的任何更改都将由代理中继,并且代理将决定如何处理该更改。

创建代理非常容易 - 您只需使用包括我们的处理程序和我们正在代理的对象的对象调用Proxy构造函数。创建代理后,您可以将代理视为原始值,并且可以开始修改代理上的属性。

以下是代理的一个示例用法:

const handlers = {
   set: (object, prop, value) => {
      console.log('setting ' + prop);
   }
}
const proxiesValue = new Proxy({}, handlers);
proxiesValue.prop1 = 'hi';

我们创建了一个proxiesValue并为其设置了一个处理程序。当我们尝试设置prop1属性时,我们将得到以下输出:

图 7.57:创建的代理值

图 7.57:创建的代理值

练习 60:使用代理构建复杂对象

在这个练习中,我们将使用代理来演示如何构建一个能够隐藏其值并对属性执行数据类型强制的对象。我们还将扩展和定制一些基本操作。让我们开始吧:

  1. 创建一个基本的 JavaScript 对象:
const simpleObject = {};
  1. 创建一个handlers对象:
const handlers = {
}
  1. 为我们的基本对象创建代理封装:
const proxiesValue = new Proxy(simpleObject, handlers);
  1. 现在,将handlers添加到我们的代理中:
const handlers = {
   get: (object, prop) => {
      return 'values are private';
   }
}

在这里,我们为我们的对象添加了一个get处理程序,我们忽略了它请求的键,只返回了一个固定的字符串。当我们这样做时,无论我们做什么,对象都只会返回我们定义的值。

  1. 让我们在代理中测试我们的处理程序:
proxiedValue.key1 = 'value1';
console.log(proxiedValue.key1);
console.log(proxiedValue.keyDoesntExist);

以下是上述代码的输出:

图 7.58:在代理中测试处理程序

图 7.58:在代理中测试处理程序

当我们运行这段代码时,我们在对象中给key1赋了一个值,但由于我们定义处理程序的方式,在尝试读取值时,它总是返回我们之前定义的字符串。当我们尝试对一个不存在的值进行这样的操作时,它也返回相同的结果。

  1. 让我们为验证添加一个 set 处理程序:
set: (object, prop, value) => {
      if (prop === 'id') {
        if (!Number.isInteger(value)) {
           throw new TypeError('The id needs to be an integer');
        }
      }
   }

我们添加了一个 set 处理程序;每当我们尝试对我们的代理整数执行设置操作时,这个处理程序将被调用。

  1. 尝试将 id 设置为字符串:
proxiedValue.id = 'not an id'

图 7.59:尝试将 id 设置为字符串时显示的 TypeError 截图

图 7.59:尝试将 id 设置为字符串时显示的 TypeError 截图

正如你可能已经猜到的那样,当我们尝试进行此操作时,它将给我们一个 TypeError 异常。如果您正在构建一个库,且不希望内部属性被覆盖,这是非常有用的。您可以使用符号来做到这一点,但使用代理也是一个选择。另一个用途是实现验证。

在这个练习中,我们讨论了一些可以用来创建对象的创造性方法。通过使用代理,我们可以创建具有内置验证的非常复杂的对象。

JavaScript 中的重构

在大型应用程序中使用 JavaScript 时,我们需要不时进行重构。重构意味着在保持兼容性的同时重写部分代码。因为 JavaScript 经历了许多阶段和升级,重构也利用了提供的新功能,并使我们的应用程序运行更快,更可靠。重构的一个例子如下:

function appendPrefix(prefix, input) {
   const result = [];
   for (var i = 0; i < input.length; i++) {
      result.push(prefix + input[i]);
   }
   return result;
}

这段代码简单地在输入数组的所有元素前附加一个前缀。让我们这样调用它:

appendPrefix('Hi! ', ['Miku', 'Rin', 'Len']);

我们将得到以下输出:

图 7.60:运行数组代码后的输出

图 7.60:运行数组代码后的输出

在重构过程中,我们可以用更少的代码编写前面的函数,并仍然保留所有的功能:

function appendPrefix(prefix, input) {
   return input.map((inputItem) => {
      return prefix + inputItem;
   });
}

当我们再次调用它时会发生什么?让我们来看一下:

appendPrefix('Hi! ', ['Miku', 'Rin', 'Len']);

我们仍然会得到相同的输出:

图 7.61:重构代码后获得相同的输出

图 7.61:重构代码后获得相同的输出

活动 10:重构函数以使用现代 JavaScript 特性

你最近加入了一家公司。分配给你的第一个任务是重构一些遗留模块。你打开了文件,发现现有的代码已经使用了遗留的 JavaScript 方法编写。你需要重构该文件中的所有函数,并确保它仍然可以通过所需的测试。

执行以下步骤以完成此活动:

  1. 使用 node.js 运行Activity10.js来检查测试是否通过。

  2. 使用includes数组重构itemExist函数。

  3. pushunique函数中使用array push来向底部添加一个新项。

  4. createFilledArray中使用array.fill来用初始值填充我们的数组。

  5. removeFirst函数中使用array.shift来移除第一项。

  6. removeLast函数中使用array.pop来移除最后一项。

  7. cloneArray中使用展开运算符来克隆我们的数组。

  8. 使用ES6类重构Food类。

  9. 重构后,运行代码以观察与旧代码生成相同的输出。

注意

这个活动的解决方案可以在第 611 页找到。

在这个活动中,我们学会了如何通过重构函数来使用现代 JavaScript 函数。我们已经成功学会了如何重写代码同时保持其兼容性。

总结

在本章中,我们首先看了一下在 JavaScript 中构建和操作数组和对象的方法。然后,我们看了一下使用展开运算符来连接数组和对象的方法。使用展开运算符可以避免我们编写不带循环的函数。后来,我们看了一下在 JavaScript 中进行面向对象编程的方法。通过使用这些类和类继承,我们可以构建复杂的应用程序,而不必编写大量重复的代码。我们还看了 Array、Map、Set、Regex、Date 和 Math 的内置方法。当我们需要处理大量不同类型的数据时,这些方法非常有用。最后,符号、迭代器、生成器和代理在使我们的程序动态和清晰方面开辟了广阔的可能性。这结束了我们关于高级 JavaScript 的章节。在下一章中,我们将讨论 JavaScript 中的异步编程。

第九章:异步编程

学习目标

在本章结束时,你将能够:

  • 描述异步操作的工作原理

  • 使用回调处理异步操作

  • 演示回调和事件循环

  • 实现承诺来处理异步操作

  • 使用承诺重写带有回调的异步代码

  • 重构您的传统代码,使用 async 和 await 函数

在本章中,我们将探讨 JavaScript 的异步(后面简称为 async)特性。重点将放在传统语言如何处理需要时间完成的操作以及 JavaScript 如何处理这些操作上。之后,我们将讨论在 JavaScript 中处理这些情况的各种方法。

介绍

在上一章中,我们学习了如何使用数组和对象以及它们的辅助函数。在本章中,我们将更多地了解 JavaScript 的运行方式以及如何处理耗时操作。

在处理 JavaScript 的大型项目时,通常我们必须处理网络请求、磁盘 IO 和数据处理。许多这些操作需要时间完成,对于刚开始使用 JavaScript 的初学者来说,很难理解如何检索这些耗时操作的结果。这是因为,与其他语言不同,JavaScript 有一种特殊的处理这些操作的方式。在编写程序时,我们习惯于线性思维;也就是说,程序逐行执行,只有在有循环或分支时才会打破这种流程。例如,如果你想在 Java 中进行简单的网络请求,你将不得不做类似于下面代码中所示的事情:

import java.net.*;
import java.io.*;
public class SynchronousFetch{
  public static void main(String[] args){
   StringBuilder content = new StringBuilder();
   try {
    URL url = new URL("https://www.packtpub.com");
    URLConnection urlConnection = url.openConnection();
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
    String line;
    while ((line = bufferedReader.readLine()) != null){
      content.append(line + "\n");
    }
    bufferedReader.close();
   } catch(Exception e) {
    e.printStackTrace();
   }
   System.out.println(content.toString());
   System.exit(0);
  }//end main
}//end class SynchronousFetch 

理解起来很简单:你创建一个 HTTP 客户端,并在客户端内调用一个方法来请求该 URL 的内容。一旦请求被发出并收到响应,它将继续运行返回响应主体的下一行代码。在此期间,整个函数将暂停并等待fetch,只有在请求完成后才会继续。这是其他语言中处理这些操作的正常方式。处理耗时操作的这种方式称为同步处理,因为它强制程序暂停,只有在操作完成后才会恢复。

由于这种线性思维,许多开发人员(包括我)在开始使用 JavaScript 编码时会感到非常困惑。大多数人会开始编写这样的代码:

const request = require('request');
let response;
request('SOMEURL', (err, res) => {
   response = res.body;
});
console.log(response);

从代码的外观来看,它应该像我们之前的代码一样运行。它将发出请求,一旦完成,将响应变量设置为响应主体,然后输出响应。大多数尝试过这种方法的开发人员都会知道,这不是 JavaScript 的工作方式;代码将运行,产生'undefined'输出,然后退出。

JavaScript 如何处理耗时操作

在 JavaScript 中,这些操作通常使用异步编程来处理。在 JavaScript 中有多种方法可以做到这一点;最常用的方法,也是你在传统程序中最常见的方法,就是回调。回调只是一个传递包含应用程序其余逻辑的函数给另一个函数的花哨术语;它们实际上非常容易理解。考虑传统函数在逻辑完成后返回它们的值。在异步编程中,它们通常不返回值;相反,它们将它们的结果传递给调用者提供的回调函数。考虑以下代码:

const request = require('request');
let response;
request('SOMEURL', (err, res) => {
   response = res.body;
});
console.log(response);

让我们看看为什么这不会产生我们想要的结果。我们使用的request库可以被视为执行一些耗时操作逻辑的函数。request函数希望你传递一个回调函数作为参数,该回调函数包括你接下来要做的一切。在回调函数中,我们接受两个参数,errres;在函数内部,我们将之前声明的响应变量赋值给res体(响应体)。在request函数外部,我们有console.log来记录响应。因为回调函数将在将来的某个时刻被调用,所以我们会在给它设置任何值之前记录响应的值。大多数开发人员在处理 JavaScript 时会感到非常沮丧,因为上面的代码不是线性的。执行的顺序如下:

1const request = require('request');
2 let response;
3 request('SOMEURL', (err, res) => {
   5 response = res.body;
});
4 console.log(response);

从上面的代码执行顺序可以看出,前三行的工作正如我们所期望的那样。我们导入了request库并声明了一个响应变量,然后调用了带有回调的request库。因为回调只有在网络请求完成时才会被调用,程序将继续执行其余的代码,输出响应。

最后,当网络请求完成时,它将调用我们的回调函数并运行将体分配给我们的响应的行。为了使这段代码表现如我们所期望的那样,我们需要修改代码如下:

const request = require('request');
let response;
request('SOMEURL', (err, res) => {
   response = res.body;
   console.log(response);
});

在上面的代码中,我们将console.log放在回调函数内部,这样它只有在赋值完成后才会被执行。现在,当我们运行这段代码时,它将输出实际的响应体。

使用回调处理异步操作

在介绍中,我们谈到了 JavaScript 如何与其他语言不同地处理异步操作。在本章中,我们将探讨如何使用回调方法编写包含许多异步操作的复杂 JavaScript 应用程序。

练习 61:编写您的第一个回调

在这个练习中,我们将首先编写一个模拟需要一段时间才能完成的函数。之后,我们将编写另一个消耗我们异步函数的函数。

注意

此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise61找到。

执行以下步骤完成练习:

  1. 创建一个slowAPI对象来创建一个模拟 API 库;它的目的是在合理的时间内返回结果。我们首先编写这个来介绍如何模拟异步函数而无需执行异步操作。
const slowAPI = {}
  1. 在我们刚刚定义的slowAPI对象中创建一个getUsers函数,它不返回任何内容,需要一个回调函数。在getUsers内部调用setTimeout函数,用于在需要时给我们的代码添加 1 秒的延迟:
slowAPI.getUsers = (callback) => {
      setTimeout(() => {
        callback(null, {
           status: 'OK',
           data: {
              users: [
                {
                   name: 'Miku'
                }, 
                {
                   name: 'Len'
                }
              ]
           }
        });
      }, 1000);
}
  1. slowAPI对象中创建一个getCart函数,并在函数内部创建一个if-else循环,匹配用户名并在不匹配时返回错误:
slowAPI.getCart = (username, callback) => {
      setTimeout(() => {
        if (username === 'Miku') {
           callback(null, {
              status: 'OK',
              data: {
                cart: ['Leek', 'Cake']
              }
           })
        } else {
           callback(new Error('User not found'));
        }
      }, 500);
}
  1. 创建一个runRequests函数,调用getUsers来获取用户列表。在回调函数内部,我们将打印出响应或错误:
function runRequests() {
   slowAPI.getUsers((error, response) => {
      if (error) {
        console.error('Error occurred when running getUsers');
        throw new Error('Error occurred');
      }
      console.log(response);
   });
}
  1. 调用run Request函数:
runRequests();

输出应该如下:

图 8.1:runRequest 的输出

图 8.1:runRequest 的输出

我们可以看到runRequest函数已经运行完毕,我们的响应被正确打印出来。

  1. 修改runRequest函数以调用getCart
function runRequests() {
   slowAPI.getUsers((error, response) => {
      if (error) {
        console.error('Error occurred when running getUsers');
        throw new Error('Error occurred');
      }
      console.log(response);
   });
   slowAPI.getCart('Miku', (error, result) => {
        if (error) {
           console.error(error);
           throw new Error('Error occurred');
        }
        console.log(result);
   });
}

在这里,我们在runRequest函数内部放置了一个类似的对slowAPI的调用;其他都没有改变。当我们运行这个时,我们得到了一个非常有趣的输出,如下所示:

图 8.2:修改 runRequest 函数后的输出

图 8.2:修改 runRequest 函数后的输出

这非常有趣,因为它首先输出了getCart的结果,然后是getUsers的结果。程序之所以表现如此,是因为 JavaScript 的异步和非阻塞特性。在我们的操作中,因为getCart函数只需要 500 毫秒就能完成,所以它将是第一个输出。

  1. 修改前面的函数以输出第一个用户的购物车:
function runRequests() {
   slowAPI.getUsers((error, response) => {
      if (error) {
        console.error('Error occurred when running getUsers');
        throw new Error('Error occurred');
      }
      slowAPI.getCart(response.data.users[0].name,(error,result) => {
        if (error) {
           console.error(error);
           throw new Error('Error occurred');
        }
        console.log(result);
     });
   });
}

输出应该如下所示:

图 8.3:第一个用户的购物车输出

图 8.3:第一个用户的购物车输出

因为我们将使用第一个请求的数据,所以我们必须在第一个请求的回调函数中编写我们下一个请求的逻辑。

  1. 在访问未知用户的购物车时触发错误:
function runRequests() {
   slowAPI.getUsers((error, response) => {
    if (error) {
        console.error('Error occurred when running getUsers');
        throw new Error('Error occurred');
      }
      slowAPI.getCart(response.data.users[1].name,(error,result) => {
        if (error) {
           console.error(error);
           throw new Error('Error occurred');
        }
        console.log(result);
      });
   });
}

我们知道从getCart返回的数据是,最后一个用户不匹配任何if语句。因此,在调用时会抛出错误。当我们运行代码时,将会看到以下错误:

图 8.4:打印错误

图 8.4:打印错误

我们在白色中看到的第一个错误输出是通过console.error输出的错误。这可以根据您的喜好定制为特定格式的错误消息或输出,使用日志框架。第二个错误是由于我们在console.log后立即抛出新错误导致进程崩溃。

在这个练习中,我们检查了如何使用setTimeout模拟异步函数。setTimeout是一个非常有用的函数。虽然在实际代码中并不推荐使用,但在测试中需要模拟需要时间的网络请求或在调试软件时产生竞争条件时,它非常有用。之后,我们讨论了使用回调函数使用异步函数的方法以及异步函数中的错误处理方式。

接下来,我们将简要讨论为什么回调函数正在逐渐过时,以及如果不正确使用回调函数会发生什么。

事件循环

您可能以前听说过这个术语,指的是 JavaScript 如何处理耗时操作。了解事件循环在底层是如何工作也非常重要。

当考虑 JavaScript 最常用于什么时,它用于制作动态网站,主要在浏览器中使用。让很多人惊讶的是,JavaScript 代码在单个线程中运行,这简化了开发人员的很多工作,但在处理同时发生的多个操作时会带来挑战。在 JavaScript 运行时,后台运行一个无限循环,用于管理代码的消息和处理事件。事件循环负责消耗回调队列中的回调、运行堆栈中的函数和调用 Web API。JavaScript 中大多数操作可分为两种类型:阻塞和非阻塞。阻塞意味着阻塞事件循环(您可以将其视为其他语言的正常 UI 线程)。当事件循环被阻塞时,它无法处理来自应用程序其他部分的更多事件,应用程序将冻结直到解除阻塞。以下是示例操作及其分类的列表:

图 8.5:带有示例操作及其分类的表

图 8.5:带有示例操作及其分类的表

从前面的列表中可以看到,几乎所有 JavaScript 中的 I/O 都是非阻塞的,这意味着即使完成时间比预期时间长,也不会阻塞事件循环。像任何语言一样,阻塞事件循环是一件糟糕的事情,因为它会使应用程序不稳定和无响应。这带来了一个问题:我们如何知道非阻塞操作是否已完成。

JavaScript 如何执行代码

当 JavaScript 执行阻塞代码时,它会阻塞循环并在程序继续执行之前完成操作。如果你运行一个迭代 100 万次的循环,你的其余代码必须等待该循环完成才能继续。因此,在你的代码中不建议有大量阻塞操作,因为它们会影响性能、稳定性和用户体验。当 JavaScript 执行非阻塞代码时,它通过将进程交给 Web API 来进行获取、超时和休息。一旦操作完成,回调将被推送到回调队列中,以便稍后被事件循环消耗。

在现代浏览器中,这是如何实现的,我们有一个堆来存储大部分对象分配,和一个用于函数调用的堆栈。在每个事件循环周期中,事件循环首先优先处理堆栈,并通过调用适当的 Web API 来执行这些事件。一旦操作完成,该操作的回调将被推送到回调队列中,稍后会被事件循环消耗:

图 8.6:事件循环周期

图 8.6:事件循环周期

为了了解一切是如何在幕后运作的,让我们考虑以下代码:

setTimeout(() => {console.log('hi')}, 2000)
while(true) {
   ;
}

从外观上看,这段代码做了两件事:创建一个在 2 秒后打印hi的超时,以及一个什么都不做的无限循环。当你运行上述代码时,它会表现得有点奇怪 - 什么都不会被打印出来,程序就会挂起。它表现得像这样的原因是事件循环更偏向于堆栈中的项目,而不是回调队列中的项目。因为我们有一个无限的while循环不断推入调用堆栈,事件循环忙于运行循环并忽略了回调队列中已完成的setTimeout回调。关于setTimeout工作方式的另一个有趣事实是,我们可以使用它来延迟我们的函数到事件循环的下一个周期。考虑以下代码:

setTimeout(() => {console.log('hi again')}, 0)
console.log('hi');

在这里,我们有setTimeout后面跟着console.log,但这里我们使用0作为超时,意味着我们希望立即完成。一旦超时完成并且回调被推送到回调队列,由于我们的事件循环优先处理调用堆栈,你可以期待这样的输出:

图 8.7:超时完成后的输出

图 8.7:超时完成后的输出

我们看到hihi again之前被打印出来,因为即使我们将超时设置为零,它仍然会最后执行,因为事件循环会在调用堆栈中的项目之前执行回调队列中的项目。

活动 11:使用回调接收结果

在这个活动中,我们将使用回调来接收结果。假设你正在为一家当地燃气公司担任软件工程师,并且他们希望你为他们编写一个新功能:

  • 你有一个客户端 API 库,可以用来请求本地用户列表。

  • 你需要实现一个功能,计算这些用户的账单,并以以下格式返回结果:

{
   id: 'XXXXX',
   address: '2323 sxsssssss',
   due: 236.6
}
  • 你需要实现一个calculateBill函数,它接受id并计算该用户的燃气费用。

为了实现这一点,你需要请求用户列表并获取这些用户的费率和使用情况。最后,计算最终应付金额并返回合并结果。

注意

这个活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Activity11找到。

执行以下步骤完成这个活动:

  1. 创建一个calculate函数,它接受id和回调函数作为参数。

  2. 调用getUsers来获取所有用户,这将给我们需要的地址。

  3. 调用getUsage来获取我们用户的使用情况。

  4. 最后,调用getRate来获取我们正在为其计算的用户的费率。

  5. 使用现有 ID 调用calculate函数。

  6. 使用不存在的 ID 调用calculate函数以检查返回的错误。

您应该看到返回的错误如下:

图 8.8:使用不存在的 ID 调用函数

图 8.8:使用不存在的 ID 调用函数

注意

此活动的解决方案可在第 613 页找到。

在这个活动中,我们实现的功能与实际世界中可能看到的非常相似。我们在一个函数中处理了多个异步操作。接下来,我们将讨论回调地狱以及在处理多个异步操作时可能出现的问题。

回调地狱

回调地狱指的是 JavaScript 开发人员在处理大型项目时遇到的障碍。回调地狱的原因并不完全是开发人员的错,部分原因是 JavaScript 处理异步操作的方式。通过使用回调来处理多个异步操作,很容易让事情失控。以下代码举例说明了回调地狱的例子:

request('url', (error, response) => {
   // Do something here
   request('another url', (error, response) => {
      disk.write('filename', (result) => {
        if (result.this) {
           process(something, (result) => {
              request('another url', (error, response) => {
                if (response.this) {
                   request('this', (error, response) => {
                      // Do something for this
                   })
                } else {
                   request('that', (error, response) => {
                      if (error) {
                        request('error fallback', (error, response) => {
                           // Error fallback
                        })
                      }
                      if (response.this) {
                      }
                   })
                }
              });
           })
        } else {
           process(otherthing, (result) => {
              // Do something else
           })
        }
      })
   })
})

前面的代码示例是回调地狱的典型例子。虽然这段代码比实际世界中找到的回调地狱代码要短,但同样糟糕。回调地狱是指一段代码中嵌套了太多回调,使得开发人员难以理解、维护甚至调试代码。如果前面的代码被用来实现实际的业务逻辑,它将会扩展到超过 200 行。有这么多行和这么多层嵌套,会产生以下问题:

  • 很难弄清楚你当前在哪个回调中。

  • 它可能会导致变量名冲突和覆盖。

  • 几乎不可能调试和断点代码。

  • 代码将非常难以重用。

  • 代码将无法进行测试。

这些问题只是由回调地狱引起的问题清单中的一部分。这些问题是为什么许多公司甚至在面试问题中包括关于回调地狱的问题的原因。有许多提出的方法可以使代码比前面的代码更可读。一种方法是将几乎每个回调都作为单独的函数提取出来。使用这种技术,前面的代码可以修改如下:

function doAnotherUrl(error, response) {
   if (response.this) {
      request('this', (error, response) => {
        // Do something for this
      })
   } else {
      request('that', (error, response) => {
        if (error) {
           request('error fallback', (error, response) => {
              // Error fallback
           })
        }
        if (response.this) {
        }
      })
   }
}
function process(result) {
   request('another url', doAnotherUrl);
}
function afterWrite(result) {
   if (result.this) {
      process(something, afterProcess)
   } else {
      process(otherthing, afterProcess)
   }
}
function doAnotherThing(error, response) {
   disk.write('filename', afterWrite)
}
function doFirstThing(error, response) {
   // Do something here
   request('another url', doAnotherThing)
}
request('url', doFirstThing)

当代码像这样重写时,我们可以看到所有的处理函数都被分开了。稍后,我们可以将它们放在一个单独的文件中,并使用require()来引用它们。这解决了将所有代码放在一个地方和可测试性问题。但它也使代码库变得不必要地庞大和分散。在 ES6 中,引入了承诺。它开辟了一种全新的处理异步操作的方式。在下一节中,我们将讨论承诺的工作原理以及如何使用它们来摆脱回调地狱。

承诺

在 JavaScript 中,承诺是代表将来某个值的对象。通常,它是异步操作的包装器。承诺也可以在函数中传递并用作承诺的返回值。因为承诺代表一个异步操作,它可以有以下状态之一:

  • 待定,意味着承诺正在等待,这意味着可能仍有异步操作正在运行,没有办法确定其结果。

  • 实现,意味着异步操作已经完成,没有错误,值已准备好接收。

  • 拒绝,意味着异步操作以错误完成。

承诺只能有前面三种状态之一。当承诺被实现时,它将调用提供给.then承诺函数的处理程序,当它被拒绝时,它将调用提供给.catch承诺函数的处理程序。

要创建一个 promise,我们在Promise构造函数中使用new关键字。构造函数接受一个包含异步操作代码的函数。它还将两个函数作为参数传递,resolvereject。当异步操作完成并且值准备好被传递时,将调用resolve。当异步操作失败并且你想要返回失败原因时,通常是一个错误对象,将调用reject

 const myPromise = new Promise((resolve, reject) => {

});

以下代码使用 Promise.resolve 返回一个 promise:

const myPromiseValue = Promise.resolve(12);

Promise.resolve返回一个解析为你传递的值的 promise。当你想要保持代码库一致,或者不确定一个值是否是 promise 时,它非常有用。一旦你使用Promise.resolve包装值,你可以使用then处理程序开始处理 promise 的值。

在下一个练习中,我们将看看如何使用 promise 处理异步操作,以及如何在不导致回调地狱的情况下将多个异步操作与 promise 结合起来。

练习 62:使用 Promise 作为回调的替代方案

在上一个活动中,我们讨论了如何将多个异步操作组合成一个单一的结果。这很容易理解,但也会使代码变得很长并且难以管理。我们讨论了回调地狱以及如何避免它。我们可以做的一件事是利用 ES6 中引入的Promise对象。在这个练习中,我们将讨论如何在我们的应用程序中使用 promise。

注意

此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise62找到。

执行以下步骤完成练习:

  1. 创建一个 promise:
const myPromise = new Promise(() => {

});

创建 promise 时,我们需要在Promise构造函数中使用new关键字。Promise构造函数要求你提供一个解析器函数来执行异步操作。当创建 promise 时,它将自动调用解析器函数。

  1. 向解析器函数添加一个操作:
const myPromise = new Promise(() => {
   console.log('hi');
});

输出应该如下所示:

图 8.9:向解析器函数添加一个操作

图 8.9:向解析器函数添加一个操作

即使console.log不是一个异步操作,当我们创建一个 promise 时,它将自动执行我们的解析器函数并打印出hi

  1. 使用resolve解决 promise:
const myPromise = new Promise((resolve) => {
   resolve(12);
});
myPromise

当调用函数时,会将一个resolve函数传递给我们的解析器函数。当它被调用时,promise 将被解决:

图 8.10:调用函数后解决的 promise

图 8.10:调用函数后解决的 promise
  1. 使用then()函数检索值。通过附加一个then处理程序,你期望从回调中读取解析的 promise 值:
const myPromise = new Promise((resolve) => {
   resolve(12);
}).then((value) => {
   console.log(value);
});

输出应该如下所示:

图 8.11:使用 then 函数检索值

图 8.11:使用 then 函数检索值

每当你创建一个 promise 时,你期望异步函数完成并返回一个值。

  1. 创建一个立即解决的 promise:
const myPromiseValue = Promise.resolve(12);
  1. 创建一个立即被拒绝的 promise:
const myRejectedPromise = Promise.reject(new Error('rejected'));

输出应该如下所示:

图 8.12:立即被拒绝的 promise 创建

图 8.12:立即被拒绝的 promise 创建

就像Promise.resolve一样,使用Promise.reject创建 promise 将返回一个被提供的原因拒绝的 promise。

  1. 使用catch在 promise 中处理error
myRejectedPromise.catch((error) => {
   console.log(error);
});

你可以使用catch提供一个错误处理程序。这会向 promise 添加一个拒绝回调。当你提供一个 catch 处理程序时,从 promise 返回的错误将作为处理程序的参数传递:

图 8.13:使用 catch 处理 promise 中的错误

](Images/C14587_08_13.jpg)

图 8.13:使用 catch 处理 promise 中的错误
  1. 创建一个返回 promise 的wait函数:
function wait(seconds) {
   return new Promise((resolve) => {
      setTimeout(() => {
        resolve(seconds);
      }, seconds * 1000);
   })
}
  1. 使用async函数延迟我们的控制台日志:
wait(2).then((seconds) => {
   console.log('i waited ' + seconds + ' seconds');
});

输出应该如下所示:

图 8.14:使用异步函数延迟控制台日志

图 8.14:使用异步函数延迟控制台日志

如你所见,使用它非常简单。我们的wait函数每次调用时都返回一个新的 promise。在操作完成后运行我们的代码,将其传递给then处理程序。

  1. 使用then函数链式调用 promise:
wait(2)
   .then(() => wait(2))
   .then(() => {
      console.log('i waited 4 seconds');
   });

输出应该如下所示:

图 8.15:使用 then 函数链接的 Promise

图 8.15:使用 then 函数链式调用的 Promise

例如,当我们想要将两个 promise 链在一起时,我们只需要将它们传递到then处理程序中,并确保结果也是一个 promise。在这里,我们看到在调用wait等待 2 秒后,我们调用另一个wait等待 2 秒,并确保计时器在第一个完成后开始。

在这个练习中,我们讨论了几种创建 promise 的方法,以及如何创建一个使用 promise 而不是回调处理操作的异步函数。最后,我们使用then函数链式调用了 promise。这些都是使用 promise 的非常简单的方法。在下一章中,我们将讨论如何有效地链式调用它们以及如何处理 promise 的错误。

链式调用 Promise

在上一个练习中,我们看了一种非常简单的方法来链式调用 promise。Promise 链式调用也可能很复杂,正确地使用它可以避免代码中的许多潜在问题。当你设计一个需要同时执行多个异步操作的复杂应用程序时,使用回调时很容易陷入回调地狱。使用 promise 解决了与回调地狱相关的一些问题,但它并不是万能的。通常,你会看到像这样编写的代码:

getUser('name').then((user) => {
   increaseLike(user.id).then((result) => {
      readUser(user.id).then((user) => {
        if (user.like !== result.like) {
           generateErrorLog(user, 'LIKE').then((result) => {
              response.send(403);
           })
        } else {
           updateAvatar(user).then((result) => {
              optimizeImage(result.image).then(() => {
                response.send(200);
              })
           })
        }
      });
   });
}).catch((error) => {
   response.send(403);
});

当你看到像这样编写的代码时,很难判断是否转换为 promise 解决了任何问题。前面的代码与我们的回调地狱代码有相同的问题;所有逻辑都是分散和嵌套的。我们还有其他问题,比如上层作用域的值可能会被意外覆盖。

当我们编写带有 promise 的代码时,我们应该考虑尽可能使代码模块化,并将操作集合视为管道。对于我们前面的示例,管道将如下所示:

图 8.16:示例管道(一系列操作)

图 8.16:示例管道(一系列操作)

你会发现我们希望将值从一个过程传递到下一个过程。这有助于我们链式调用 promise,并且可以使我们的代码非常清晰和易于维护。我们可以将前面的代码重写为以下内容:

function increaseLike(user) {
   return new Promise((resolve) => {
      resolve({
        // Some result
      })
   });
};
function readUser(result) {
   return new Promise((resolve) => {
      resolve({
        // Return user
      })
   });
}
function updateAvatar(user) {
   return new Promise((resolve) => {
      resolve({
        // Return updated avatar
      })
   });
}
function optimizeImage(user) {
   return new Promise((resolve) => {
      resolve({
        // Return optimized images
      })
   });
}
function generateErrorLog(error) {
   // Handle some error
}
readUser('name')
   .then(increaseLike)
   .then(readUser)
   .then(updateAvatar)
   .then(optimizeImage)
   .catch(generateErrorLog)

正如你所看到的,重写的代码更易读,任何查看这段代码的人都会准确知道将会发生什么。当我们以这种方式链式调用 promise 时,我们基本上是将值从一个过程传递到另一个过程。通过使用这种方法,我们不仅解决了回调地狱的问题,而且使代码更具可测试性,因为这些辅助函数中的每一个都是完全独立的,它们不需要任何比传递给它们的参数更多的东西。更不用说,如果你的应用程序中有任何部分想要执行类似的操作(例如,optimizeImage),你可以轻松地重用代码的这部分。在下一个练习中,我们将讨论如何使用 promise 链式调用编写具有多个异步操作的复杂功能。

练习 63:高级 JavaScript Promise

在这个练习中,我们将编写一个简单的程序,运行多个异步操作,并使用 promise 链式调用它们的结果。之后,我们还将使用Promise类的有用静态方法来帮助我们同时管理多个 promise。

注意

此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise63找到。

执行以下步骤完成练习:

  1. 创建getProfilegetCart函数,它们返回一个 promise。getProfile应该以id字符串作为输入,并根据输入解析不同的结果:
function getProfile(id) {
   return new Promise((resolve, reject) => {
      switch(id) {
        case 'P6HB0O':
           resolve({ id: 'P6HB0O', name: 'Miku', age: 16, dob: '0831' });
        break;
        case '2ADN23':
           resolve({ id: '2ADN23', name: 'Rin', age: 14, dob: '1227' });
        break;
        case '6FFQTU':
           resolve({ id:'6FFQTU', name: 'Luka', age: 20, dob: '0130' });
        break;
        default:
           reject(new Error('user not found'));
      }
   });
}
function getCart(user) {
   return new Promise((resolve, reject) => {
      switch(user.id) {
        case 'P6HB0O':
           resolve(['leek', 'cake', 'notebook']);
        break;
        case '2ADN23':
           resolve(['ice cream', 'banana']);
        break;
        case '6FFQTU':
           resolve(['tuna', 'tako']);
        break;
        default:
           reject(new Error('user not found'));
      }
   });
}
  1. 创建另一个异步函数getSubscription,它接受一个 ID 并为该 ID 解析truefalse值:
function getSubscription(id) {
   return new Promise((resolve, reject) => {
      switch(id) {
        case 'P6HB0O':
           resolve(true);
        break;
        case '2ADN23':
           resolve(false);
        break;
        case '6FFQTU':
           resolve(false);
        break;
        default:
           reject(new Error('user not found'));
      }
   });
}

在这里,函数只接受一个字符串 ID 作为输入。如果我们想在我们的 promise 链中链接它,我们需要确保提供给该函数的 promise 解析为单个字符串值。

  1. 创建getFullRecord,它返回id的组合记录:
function getFullRecord(id) {
   return {
      id: '',
      age: 0,
      dob: '',
      name: '',
      cart: [],
      subscription: true
   };
}

getFullRecord函数中,我们希望调用所有前面的函数并将记录组合成前面代码中显示的返回值。

  1. 调用我们之前在getFullRecord中声明的函数,并返回getProfilegetCartgetSubscription的组合结果:
function getFullRecord(id) {
   return getProfile(id).then((user) => {
      return getCart(user).then((cart) => {
        return getSubscription(user.id).then((subscription) => {
           return {
              ...user,
              cart: cart,
              subscription
           };
        });
      });
   });
}

这个函数也返回一个 promise。我们可以调用该函数并打印出它的值:

getFullRecord('P6HB0O').then(console.log);

这将返回以下输出:

图 8.17:在 getFullRecord 中调用已声明的函数

图 8.17:在getFullRecord中调用已声明的函数

但是我们的代码非常混乱,并且并没有真正利用我们之前提到的 promise 链式调用。为了解决这个问题,我们需要对getCartgetSubscription进行修改。

  1. 更新getCart函数,该函数返回一个新对象,包括user对象的每个属性和cart项,而不仅仅返回cart项:
function getCart(user) {
   return new Promise((resolve, reject) => {
      switch(user.id) {
        case 'P6HB0O':
           resolve({ ...user, cart: ['leek', 'cake', 'notebook'] });
        break;
        case '2ADN23':
           resolve({ ...user, cart: ['ice cream', 'banana'] });
        break;
        case '6FFQTU':
           resolve({ ...user, cart: ['tuna', 'tako'] });
        break;
        default:
           reject(new Error('user not found'));
      }
   });
}
  1. 更新getSubscription函数,该函数以user对象作为输入并返回一个对象,而不是单个值:
function getSubscription(user) {
   return new Promise((resolve, reject) => {
      switch (user.id) {
        case 'P6HB0O':
           resolve({ ...user, subscription: true });
           break;
        case '2ADN23':
           resolve({ ...user, subscription: false });
           break;
        case '6FFQTU':
           resolve({ ...user, subscription: false });
           break;
        default:
           reject(new Error('user not found'));
      }
   });
}
  1. 更新getFullRecord函数:
function getFullRecord(id) {
   return getProfile(id)
      .then(getCart)
      .then(getSubscription);
}

现在,这比以前的所有嵌套要可读得多。我们只是通过对之前的两个函数进行最小的更改,大大减少了getFullRecord。当我们再次调用此函数时,它应该产生完全相同的结果:

图 8.18:更新的 getFullRecord 函数

图 8.18:更新的 getFullRecord 函数
  1. 创建getFullRecords函数,我们将使用它来调用多个记录并将它们组合成一个数组:
function getFullRecords() {
   // Return an array of all the combined user record in our system
   return [
      {
        // Record 1
      },
      {
        // Record 2
      }
   ]
}
  1. 使用array.map生成 promise 列表:
function getFullRecords() {
   const ids = ['P6HB0O', '2ADN23', '6FFQTU'];
   const promises = ids.map(getFullRecord);
}

在这里,我们利用了array.map函数来迭代数组并返回一个新数组。因为数组只包含 ID,所以我们可以简单地传递getFullRecord函数。

  1. 使用Promise.all来合并一系列 promise 的结果:
function getFullRecords() {
   const ids = ['P6HB0O', '2ADN23', '6FFQTU'];
   const promises = ids.map(getFullRecord);
   return Promise.all(promises);
}

Promise.all只是接受一个 promise 数组并返回一个等待所有 promise 解析的 promise。一旦数组中的所有 promise 都解析了,它将解析为这些 promise 的结果数组。因为我们的目标是返回完整记录列表,这正是我们想要的。

  1. 测试getFullRecords
getFullRecords().then(console.log);

输出应该如下所示:

图 8.19:测试 getFullRecords 函数

图 8.19:测试 getFullRecords 函数

在这个练习中,我们使用了多个异步函数和它们的 promise 返回来实现复杂的逻辑。我们还尝试链式调用它们,并修改了一些函数以便于链式调用。最后,我们使用了array.mapPromise.all来使用数组创建多个 promise 并等待它们全部解析。这有助于我们管理多个 promise 并跟踪它们的结果。接下来,我们将讨论 promise 中的错误处理。

Promise 中的错误处理

当我们向 web 服务器发出请求或访问磁盘上的文件时,不能保证我们要执行的操作会 100%成功。当它不按我们想要的方式工作时,我们需要确保我们的应用程序能够处理这些错误,以便它不会意外退出或损坏我们的数据。在以前编写异步函数的处理程序时,我们可以简单地从错误参数中获取返回的错误。当我们使用 promises 时,我们也可以从catch处理程序中获取错误。

但当我们处理错误时,我们不仅仅是在尝试防止发生对我们或用户有害的事情;我们还需要确保我们的错误足够有意义,以便我们使用这些信息并防止该错误再次发生。通常,如果我们想要处理 promises 中的错误,我们可以简单地这样做:

aFunctionReturnsPromise()
   .then(dosomething)
   .catch((error) => {
   // Handle some error here
});

当我们想要处理某种类型的错误时,我们可以调用catch函数并传递一个错误处理程序。但如果我们同时处理多个 promises 呢?如果我们使用 promise 链呢?当处理多个 promises 时,你可能会认为我们需要做类似这样的事情:

aFunctionReturnsPromise().then((result) => {
   anotherFunctionReturnsPromise().then((anotherResult) => {
   }).catch((error) => {
      // Handle error here
   });
}).catch((error) => {
   // handle error
})

在这里,我们处理了aFunctionReturnsPromise函数返回的 promise 的任何类型的错误。在该 promise 的then处理程序中,我们调用anotherFunctionReturnsPromise,在其then处理程序中,我们处理了该 promise 的错误。这看起来并不太糟糕,因为我们只使用了两个嵌套的 promises,所以严格来说不需要链式调用,而且我们分别处理了每个错误。但通常,当你看到人们写这样的代码时,你也会看到类似这样的东西:

aFunctionReturnsPromise().then((result) => {
   return anotherFunctionReturnsPromise().then((anotherResult) => {
      // Do operation here
   }).catch((error) => {
      // Handle error here
      logError(error);
      throw new Error ('something else');
   });
}).catch((error) => {
   // handle error
   logError(error);
   throw new Error ('something else');
});

我甚至看到过像这样写的生产级代码。虽然这看起来对很多开发者来说是个好主意,但这并不是处理 promises 中错误的理想方式。有一些使用情况适合这种错误处理方式。其中一种情况是,如果你确定了你将要得到的错误类型,并且想要为每种不同类型做自定义处理。当你的代码像这样时,很容易在日志文件中出现重复,因为你可以从前面的代码中看到,错误被记录了两次:一次在嵌套 promise 的 catch 处理程序中,一次在父 promise 中。为了减少错误处理的重复,你可以简单地移除嵌套 promise 中的任何处理程序,这样前面的代码看起来会像这样:

aFunctionReturnsPromise().then((result) => {
   return anotherFunctionReturnsPromise().then((anotherResult) => {
      // Do operation here
   });
}).catch((error) => {
   // handle error
   logError(error);
   throw new Error ('something else');
});

你不必担心嵌套 promise 中的错误没有被处理 - 因为我们在then处理程序中返回了 promise,并且传递了状态而不是值。所以,当嵌套 promise 遇到错误时,最终会被父错误处理程序中的catch处理程序捕获。

我们必须记住的一件事是,当我们使用 promises 时,当出现错误时,then处理程序不会被调用。考虑以下例子:

processSomeFile().then(() => {
   // Cleanup temp files
   console.log('cleaning up');
}).catch((error) => {
   console.log('oh no');
});

假设你正在创建一个文件处理函数,并且在处理完成后,在then处理程序中运行清理逻辑。当出现错误时,这会创建一个问题,因为当该 promise 被拒绝时,清理过程将永远不会被调用。这可能会引起很多问题。我们可能会因为临时文件没有被删除而耗尽磁盘空间。如果我们没有正确关闭连接,我们也可能会面临内存泄漏的风险。为了解决这个问题,一些开发者采取了简单的方法并复制了清理逻辑:

processSomeFile().then(() => {
   // Cleanup temp files
   console.log('cleaning up');
}).catch((error) => {
   // Cleanup temp files
   console.log('cleaning up');
   console.log('oh no');
})

虽然这解决了我们的问题,但也创建了一个重复的代码块,所以最终,当我们想要更改清理过程中的某些逻辑时,我们需要记住在两个地方都进行更改。幸运的是,Promise类给了我们一个非常有用的处理程序,我们可以设置它以确保无论状态如何,处理程序都会被调用:

   processSomeFile().then(() => {
}).catch((error) => {

   console.log('oh no');
}).finally(() => {
   // Cleanup temp files
   console.log('cleaning up');
})

在这里,我们正在附加一种新类型的处理程序到我们的 promise。.finally处理程序将在 promise 被settled时始终被调用,无论它是解决还是被拒绝。这是一个非常有用的处理程序,我们可以在我们的 promises 上设置它,以确保我们正确清理连接或删除文件。

在上一个练习中,我们设法使用Promise.all从一系列 promises 中获取结果列表。在我们的示例中,所有 promises 最终都解决了,并且我们得到了一个非常干净的数组返回给我们。我们如何处理我们不确定 promises 结果的情况?考虑上一个练习中的getFullRecords函数;当我们运行该函数时,它执行以下操作:

图 8.20:执行 getFullRecords 函数

图 8.20:执行 getFullRecords 函数

该函数同时执行所有三个操作,并在它们解决时解决。让我们修改getFullRecords函数以使其输出错误:

function getFullRecords() {
   const ids = ['P6HB0O', '2ADN23', 'Not here'];
   const promises = ids.map(getFullRecord);
   return Promise.all(promises);
}

我们知道我们提供的第三个 ID 在我们的getProfile函数中不存在,因此它将被拒绝。当我们运行此函数时,我们将得到如下输出:

图 8.21:运行 getProfile 函数时出错

图 8.21:运行 getProfile 函数时出错

Promise.all等待数组中的所有 promises 解决,并且如果其中一个请求被拒绝,它将返回一个拒绝的 promise。在处理多个 promises 时,请记住这一点;如果一个 promise 请求被拒绝,请确保您在错误消息中包含尽可能多的信息,以便您可以知道哪个操作被拒绝。

练习 64:使用 Promises 重构账单计算器

在上一个练习中,我们使用回调函数编写了账单计算逻辑。假设您工作的公司现在升级了他们的 Node.js 运行时,并且要求您使用 promises 重写该部分逻辑。打开promises.js文件,您将看到使用 promises 重写的更新后的clientApi

注意

Promises.js 可在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise64找到。

  • 您已经得到了支持 promises 的clientApi

  • 您需要实现一个功能,该功能计算用户的账单并以此格式返回结果:

{
   id: 'XXXXX',
   address: '2323 sxsssssss',
   due: 236.6
}
  • 您需要实现一个calculateBill函数,该函数接受一个 ID 并计算该用户的燃气账单。

  • 您需要实现一个新的calculateAll函数来计算从getUsers获取的所有用户的账单。

我们将打开包含clientApi的文件并在那里进行工作。

执行以下步骤来实现练习:

  1. 我们将首先创建calculate函数。这次,我们只会传递id
function calculate(id) {}
  1. calculate中,我们将首先调用getUsers
function calculate(id) {
return clientApi.getUsers().then((result) => {
   const currentUser = result.users.find((user) => user.id === id);
   if (!currentUser) { throw Error('user not found'); }
}
}

因为我们想要计算并返回一个 promise,并且getUsers返回一个 promise,所以当我们调用getUsers时,我们将简单地返回 promise。在这里,我们将运行相同的find方法来找到我们当前正在计算的用户。然后,如果用户不存在,我们可以在then处理程序中直接抛出错误。

  1. getUsersthen处理程序中调用getUsage
function calculate(id) {
return clientApi.getUsers().then((result) => {
   const currentUser = result.users.find((user) => user.id === id);
   if (!currentUser) { throw Error('user not found'); }
return clientApi.getUsage(currentUser.id).then((usage) => {
});
}
}

在这里,我们返回clientApi,因为我们想要链接我们的 promise,并且希望最内层的 promise 出现并被解决。

  1. getUsagethen处理程序中调用getRate
function calculate(id) {
   return clientApi.getUsers().then((result) => {
      const currentUser = result.users.find((user) => user.id === id);
      if (!currentUser) { throw Error('user not found'); }
      return clientApi.getUsage(currentUser.id).then((usage) => {
         return clientApi.getRate(currentUser.id).then((rate) => {
   return {
      id,
      address: currentUser.address,
      due: (rate * usage.reduce((prev, curr) => curr + prev)).toFixed(2)
   };
});
});
}
}

这是我们需要调用的最后一个函数。我们也将在这里使用return。在我们的then处理程序中,我们将拥有所有我们需要的信息。在这里,我们可以直接运行我们的计算并直接返回值。该值将是我们返回的 promise 的解决值。

  1. 创建一个calculateAll函数:
function calculateAll() {}
  1. 调用getUsers以获取我们用户的列表:
function calculateAll() {
   return clientApi.getUsers().then((result) => {});
}
  1. 在这里,结果将是我们系统中用户的列表。然后,我们将在每个用户上运行calculate。使用Promise.all和一个 map 数组来调用calculate函数对每个用户进行计算:
function calculateAll() {
   return clientApi.getUsers().then((result) => {
      return Promise.all(result.users.map((user) => calculate(user.id)));
});
}

我们使用一个 map 数组来返回一个新的 promise 数组。当我们调用现有的calculate函数时,返回的 promise 数组将是 promise。当我们将该数组传递给Promise.all时,它将返回一个 promise,该 promise 将解析为来自 promise 列表的结果列表。

  1. 在我们的一个用户上调用calculate
calculate('DDW2AU').then(console.log)

输出应该如下:

图 8.22:调用我们的一个用户上的 calculate

图 8.22:在我们的一个用户上调用 calculate
  1. 调用calculateAll函数:
calculateAll().then(console.log)

输出应该如下:

图 8.23:调用 calculateAll 函数

图 8.23:调用 calculateAll 函数

在以前的练习和活动中,我们创建了函数,使用回调从多个异步函数计算结果,然后使用 promise 重写了这些函数。现在,您知道如何使用 promise 重构旧的回调风格代码。当您在重构需要您开始使用 promise 的大型项目时,这是非常有用的。在下一章中,我们将介绍一种新的方法,可以用来处理异步函数。

异步和等待

JavaScript 开发人员一直梦想着处理异步函数而无需在其周围编写包装器。然后,引入了一个新功能,这改变了我们对 JavaScript 异步操作的认识。考虑我们在上一个练习中使用的代码:

function getFullRecord(id) {
   return getProfile(id)
      .then(getCart)
      .then(getSubscription);
}

这很简单,因为我们使用了 promise 链式调用,但它并没有告诉我们更多的信息,看起来我们只是调用了一堆函数。如果我们可以有这样的东西会怎样:

function getFullRecord(id) {
   const profile = getProfile(id);
   const cart = getCart(id);
   const subscription = getSubscription(id);
   return {
      ...profile,
      cart,
      subscription
   };
}

现在,当你看前面的代码时,它就更有意义了,看起来就像我们只是调用一些非异步函数来获取数据,然后返回组合数据。这就是 async 和 await 可以实现的。通过使用 async 和 await,我们可以像这样编写我们的代码,同时保持对异步操作的完全控制。考虑一个简单的async函数,它返回一个 promise:

function sayHello() {
   return Promise.resolve('hello world');
}

这只是一个简单的async函数,就像我们在以前的练习和活动中使用的那样。通常,如果我们想调用这个函数并获取返回的 promise 的值,我们需要执行以下命令:

sayHello().then(console.log)

输出应该如下:

图 8.24:获取返回的 promise 的值

图 8.24:获取返回的 promise 的值

这种方法并不新鲜;我们仍然调用函数返回一个 promise,然后通过then处理程序获取解析后的值。如果我们想要使用新的 async 和 await 功能,我们首先创建一个将运行操作的函数:

async function printHello() {
   // Operation here
}

我们所做的就是在function关键字之前添加async。我们这样做是为了将这个函数标记为async函数,这样我们就可以在printHello()函数中使用await来调用sayHello函数,而不需要使用then处理程序:

async function printHello() {
   // Operation here
   const message = await sayHello();
   console.log(message);
}

在这个async函数中,我们调用了我们的sayHello函数,它返回一个 promise。因为我们在之前使用了await关键字,它将尝试解析该 promise 并将解析后的值传递给我们声明为消息的常量。通过使用这个,我们让我们的async函数看起来像一个同步函数。稍后,我们可以像调用普通函数一样调用该函数:

printHello();

输出应该如下:

图 8.25:调用 printHello 函数

图 8.25:调用 printHello 函数

练习 65:异步和等待函数

在这个练习中,我们将学习创建 async 函数并在其他 async 函数中调用它们。在单个函数中处理大量的 async 操作时,使用 async 和 await 可以帮助我们。我们将一起编写我们的第一个async函数,并探索在应用程序中处理 async 和 await 时需要牢记的一些事情。

注意

此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise65找到。

执行以下步骤完成练习:

  1. 创建一个getConcertList函数:
function getConcertList() {
   return Promise.resolve([
      'Magical Mirai 2018',
      'Magical Mirai 2019'
   ]);
}
  1. 调用函数并使用await
const concerts = await getConcertList();

当我们运行上述代码时,我们将会得到如下错误:

图 8.26:使用 await 调用函数

图 8.26:使用 await 调用函数

我们会得到这个错误的原因是我们只能在async函数内部使用await关键字。如果我们想使用它,我们必须将语句包装在async函数中。

  1. 修改语句并将其包装在async函数中:
async function printList() {
   const concerts = await getConcertList();
   console.log(concerts);
}
printList();

输出应该如下:

图 8.27:修改语句并将其包装在 async 函数中

图 8.27:修改语句并将其包装在 async 函数中

当我们运行这个函数时,我们将看到列表被打印出来,一切都运行正常。我们也可以将async函数视为返回 promise 的函数,因此如果我们想在操作结束后运行代码,我们可以使用then处理程序。

  1. 使用async函数的then()函数调用处理程序:
printList().then(() => {
   console.log('I am going to both of them.')
});

输出应该如下:

图 8.28:使用 async 函数的 then 函数调用处理程序

图 8.28:使用 async 函数的 then 函数调用处理程序

现在,我们知道async函数的行为就像返回 promise 的普通函数一样。

  1. 创建一个getPrice函数来获取音乐会的价格:
function getPrice(i) {
   const prices = [9900, 9000];
   return Promise.resolve(prices[i]);
}
  1. 修改printList以包括从getPrice获取的价格:
async function printList() {
   const concerts = await getConcertList();
   const prices = await Promise.all(concerts.map((c, i) => getPrice(i)));
   return {
      concerts,
      prices
   };
}
printList().then(console.log);

在这个函数中,我们只是尝试使用getPrice函数获取所有的价格。在上一节中,我们提到了如何使用Promise.all将一个 promise 数组包装在一个 promise 中,该 promise 只有在数组中的每个 promise 都解析后才会解析。因为await关键字可以用于返回 promise 并解析其值的任何函数,我们可以使用它来获取一个价格数组。当我们运行上述代码时,我们将看到这个函数解析为以下内容:

图 8.29:修改 printList 以包括从 getPrice 获取的价格

图 8.29:修改 printList 以包括从 getPrice 获取的价格

这意味着如果我们有一个返回 promise 的函数,我们不再需要使用then处理程序。在async函数中,我们可以简单地使用await关键字来获取解析后的值。但是,在async函数中处理错误的方式有点不同。

  1. 创建一个返回 rejected promise 的buggyCode函数:
function buggyCode() {
   return Promise.reject(new Error('computer: dont feel like working today'));
}
  1. printList中调用buggyCode
async function printList() {
   const concerts = await getConcertList();
   const prices = await Promise.all(concerts.map((c, i) => getPrice(i)));
   await buggyCode();
   return {
      concerts,
      prices
   };
}
printList().then(console.log);

输出应该如下:

图 8.30:在 printList 中调用 buggyCode

图 8.30:在 printList 中调用 buggyCode

因为buggyCode抛出了一个错误,这会停止我们的函数执行,并且将来甚至可能终止我们的进程。为了处理这种类型的错误,我们需要捕获它。

  1. 在 buggyCode 上使用catch处理程序:
async function printList() {
   const concerts = await getConcertList();
   const prices = await Promise.all(concerts.map((c, i) => getPrice(i)));
   await buggyCode().catch((error) => {
      console.log('computer produced error');
      console.log(error);
   });
   return {
      concerts,
      prices
   };
}
printList().then(console.log);

我们可以像处理常规 promise 一样处理buggyCode的错误,并传递一个catch处理程序。这样,promise rejection 将被标记为已处理,并且不会返回UnhandledPromiseRejectionWarning

图 8.31:在 buggyCode 上使用 catch 处理程序

图 8.31:在 buggyCode 上使用 catch 处理程序

这是处理async函数中的 promise rejection 的一种方法。还有一种更常见的方法。

  1. 使用trycatch修改错误处理:
async function printList() {
   const concerts = await getConcertList();
   const prices = await Promise.all(concerts.map((c, i) => getPrice(i)));
   try {
      await buggyCode();
   } catch (error) {
      console.log('computer produced error');
      console.log(error);
   }
   return {
      concerts,
      prices
   };
}
printList().then(console.log);

输出应该如下所示:

图 8.32:使用 try…catch 修改错误处理

图 8.32:使用 try…catch 修改错误处理

使用trycatch是许多开发人员在处理可能抛出错误的函数时熟悉的。使用trycatch块来处理我们的buggyCode的错误将使代码更易读,并实现异步的目标,即消除传递 promise 处理程序。接下来,我们将讨论如何正确处理多个 promise 和并发性。

异步等待并发性

在处理 JavaScript 中的多个异步操作时,了解你想要运行的操作的顺序至关重要。你编写代码的方式可以很大程度上改变应用程序的行为。让我们看一个例子:

function wait(seconds) {
   return new Promise((resolve) => {
      setTimeout(() => {
        resolve();
      }, seconds * 1000);
   });
}

这是一个非常简单的函数,它返回一个 promise,只有在经过n秒后才会解析。为了可视化并发性,我们声明了runAsync函数:

async function runAsync() {
   console.log('starting', new Date());
   await wait(1);
   console.log('i waited 1 second', new Date());
   await wait(2);
   console.log('i waited another 2 seconds', new Date());
}

当我们运行这个函数时,我们会看到我们的程序会等待 1 秒并打印出第一条语句,然后在 2 秒后打印出另一条语句。总等待时间将是 3 秒:

图 8.33:返回在 n 秒后解析的 promise 的函数

图 8.33:返回在 n 秒后解析的 promise 的函数

如果我们想要同时运行两个wait函数呢?在这里,我们可以使用Promise.all

async function runAsync() {
   console.log('starting', new Date());
   await Promise.all([wait(1), wait(2)]);
   console.log('i waited total 2 seconds', new Date());
}

输出应该如下所示:

图 8.34:使用 Promise.all 运行两个等待函数

图 8.34:使用 Promise.all 运行两个等待函数

我们在这里做的是移除了await,并将wait函数返回的两个 promise 放入数组中,然后将其传递给Promise.all。当我们移除await关键字并使用Promise.all时,我们可以确保代码不会失控并将继续执行。如果你在循环中处理 promise,就像下面的代码一样:

async function runAsync() {
   console.log('starting', new Date());
   for (let i = 0; i < 2; i++) {
      await wait(1);
   }
   console.log('i waited another 2 seconds', new Date());
}

这不提供并发性。想象一下,我们不是在等待,而是从数据库中获取用户信息:

async function runAsync() {
   const userProfiles = [];
   for (let i = 0; i < 2; i++) {
      const profile = await getProfile(i);
      userProfiles.push(profile);
   }
   return userProfiles;
}

在这里,我们的用例是从数据库中获取多个用户配置文件。虽然前面的代码可以工作,但它不是最高效的实现。正如我们之前提到的,这段代码会等到最后一个请求完成后才会获取下一个请求。为了优化这段代码,我们可以简单地使用array.mapPromise.all结合使用:

async function runAsync() {
   return await Promise.all([0, 1].map(getProfile));
}

这样,我们不是等待每个操作完成;我们只是等待包装 promise 被解析。在 map 数组中,我们只是生成了 promises,一旦它们被创建,它将执行我们的操作。与for循环方法相比,我们不需要等待前一个 promise 在执行下一个 promise 之前解决。我们将在下一章讨论它们的区别。

何时使用 await

在之前的例子中,我们讨论了在我们的async函数中使用await关键字。但是什么时候应该使用await,什么时候应该避免呢?在上一节中,我们讨论了当我们想要启用并发并确保操作不会互相等待时,应避免使用await。考虑以下代码示例:

async function example() {
   const result1 = await operation1();
   const result2 = await operation2(result1.something);
   return result2;
}

在这个例子中,operation2函数只有在operation1完成后才会执行。当你有依赖关系并且result2依赖于result1中的某些内容时,这是很有用的,就像例子中所示的那样。如果它们之间没有相互依赖,你可以利用Promise.all来确保并发性:

async function example() {
   const result1 = operation1();
   const result2 = operation2();
   return await Promise.all([result1, result2]);
}

没有await关键字,代码只是将从两个操作返回的 promise 分配给我们声明的常量。这确保了operation2operation1之后立即触发,并且没有等待。我们还需要注意的另一点是错误处理。考虑我们在上一个练习中使用的buggyCode

function buggyCode() {
   return Promise.reject(new Error('computer: dont feel like working today'));
}

这个函数只是返回一个被拒绝的 promise。在使用它时,我们应该使用catch来处理 promise 的错误:

async function printList() {
   try {
      await buggyCode();
   } catch (error) {
      console.log('computer produced error');
      console.log(error);
   }
}

当我们运行这段代码时,我们会看到我们的错误被很好地处理,并且错误消息被记录下来。在这里,我们在运行buggyCode函数时使用了await,但是当我们删除await关键字时,我们将看到以下内容:

![图 8.35:删除 await 关键字后运行 buggyCode 函数

将以下文本按行翻译成中文:

图 8.35:删除 await 关键字后运行 buggyCode 函数

您会看到我们有一个未处理的 promise 拒绝;它似乎没有出现,因为我们的trycatch什么也没做。这是因为没有await关键字,JavaScript 不会尝试等待 promise 解析;因此,它不知道将来会抛出错误。这个trycatch块将捕获的是在执行函数时抛出的错误。这是我们在使用asyncawait编写代码时需要牢记的事情。在下一个练习中,我们将编写一个调用多个async函数并能够从错误中恢复的复杂函数。

练习 66:复杂的异步实现

在这个练习中,我们将构建一个非常复杂的async函数,并使用我们之前学到的一切来确保函数具有高性能并对错误具有弹性。

注意

此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise66找到。

完成练习的步骤如下:

  1. 创建一个getPlaylists函数,根据播放列表名称返回一个 ID 数组:
function getPlaylist(id) {
   const playLists = {
      'On the road': [0, 6, 5, 2],
      'Favorites' : [1, 4, 2],
      'Corrupted': [2, 4, 7, 1]
   };
   const playList = playLists[id];
   if (!playList) {
      throw new Error('Playlist does not exist');
   }
   return Promise.resolve(playLists[id]);
}

该函数将返回一个歌曲 ID 数组作为播放列表。如果未找到,它将简单地返回null

  1. 创建一个getSongUrl函数,根据编号id返回一个歌曲 URL:
function getSongUrl(id) {
   const songUrls = [
      'http://example.com/1.mp3',
      'http://example.com/2.mp3',
      'http://example.com/3.mp3',
      'http://example.com/4.mp3',
      'http://example.com/5.mp3',
      'http://example.com/6.mp3',
      'http://example.com/7.mp3',
   ];
   const url = songUrls[id];
   if (!url) {
      throw new Error('Song does not exist');
   }
   return Promise.resolve(url); // Promise.resolve returns a promise that is resolved with the value given
}
  1. 创建一个playSong异步函数,该函数接受歌曲的 ID 并生成两个输出-一个显示正在播放的歌曲,另一个通知用户歌曲已经完成:
async function playSong(id) {
   const url = await getSongUrl(id);
   console.log(`playing song #${id} from ${url}`);
   return new Promise((resolve) => {
      setTimeout(() => {
        console.log(`song #${id} finished playing`);
        resolve();
      }, Math.random() * 3 * 1000);
   });
}
  1. 创建一个playPlaylist函数,该函数接受一个播放列表 ID,并在播放列表中的每首歌曲上调用playSong
async function playPlaylist(id) {
   const playList = await getPlayLlist(id);
   await Promise.all(playList.map(playSong));
}

这是一个简单的实现,没有进行错误处理。

  1. 运行playPlaylist函数:
playPlaylist('On the road').then(() => {
   console.log('finished playing playlist');
});

输出应该如下:

图 8.36:运行 playPlaylist 函数

图 8.36:运行 playPlaylist 函数

我们得到了一个非常有趣的输出;它同时播放所有歌曲。而且,它没有优雅地处理错误。

  1. 不带参数调用playPlaylist
playPlaylist().then(() => {
   console.log('finished playing playlist');
});

输出应该如下:

图 8.37:不带参数调用 playPlaylist

图 8.37:不带参数调用 playPlaylist

我们之所以出现这个错误是因为当getPlaylist抛出错误时,我们没有处理错误。

  1. 修改playPlaylist以处理错误:
async function playPlaylist(id) {
   try {
      const playList = await getPlaylist(id);
      return await Promise.all(playList.map(playSong));
   } catch (error) {
      console.log(error);
   }
}

我们在这里没有做任何特别的事情;我们只是在getPlaylist周围添加了一个try…catch块,这样当 promise 被拒绝时,它将被正确处理。更新后,当我们再次运行我们的代码时,我们将收到以下输出:

图 8.38:修改 playPlaylist 以处理错误

图 8.38:修改playPlaylist以处理错误

我们看到错误已经被正确处理,但是我们仍然在最后得到了finished消息。这是我们不想要的,因为当发生错误时,我们不希望 promise 链继续。

  1. 修改playPlaylist函数和调用者:
async function playPlaylist(id) {
   const playList = await getPlaylist(id);
   return await Promise.all(playList.map(playSong));
}
playPlaylist().then(() => {
   console.log('finished playing playlist');
}).catch((error) => {
   console.log(error);
});

在编写async代码时,最好将 promise 处理放在父级,并让错误冒泡。这样,我们可以为此操作只有一个错误处理程序,并能够一次处理多个错误。

  1. 尝试调用一个损坏的播放列表:
playPlaylist('Corrupted').then(() => {
   console.log('finished playing playlist');
}).catch((error) => {
   console.log(error);
});

](Images/C14587_08_35.jpg)

图 8.39:调用一个损坏的播放列表

图 8.39:调用损坏的播放列表

这段代码运行良好,并且错误已经处理,但仍然一起播放。我们想要显示finished消息,因为歌曲不存在错误是一个小错误,我们想要抑制它。

  1. 修改playPlaylist以按顺序播放歌曲:
async function playPlaylist(id) {
   const playList = await getPlaylist(id);
   for (const songId of playList) {
      await playSong(songId);
   }
}

输出应如下所示:

图 8.40:修改后的 playPlaylist 以按顺序播放歌曲

图 8.40:修改playPlaylist以按顺序播放歌曲

在修改中,我们删除了Promise.all,并用for循环替换了它,对每首歌曲使用await。这确保我们在继续下一首歌曲之前等待每首歌曲完成。

  1. 修改playSong以抑制未找到错误:
async function playSong(id) {
   try {
      const url = await getSongUrl(id);
      console.log('playing song #${id} from ${url}');
      return new Promise((resolve) => {
        setTimeout(() => {
           console.log('song #${id} finished playing');
           resolve();
        }, Math.random() * 3 * 1000);
      });
   } catch (error) {
      console.log('song not found');
   }
}

输出应如下所示:

图 8.41:修改后的 playSong 以抑制未找到的错误

图 8.41:修改playSong以抑制未找到的错误

我们在这里做的是用try...catch块包装我们的逻辑。这使我们能够抑制代码生成的任何错误。当getSongUrl抛出错误时,它不会上升到父级;它将被catch块捕获。

在这个练习中,我们使用asyncawait实现了一个播放列表播放器,并使用了我们对Promise.allasync并发的了解来优化我们的播放列表播放器,使其一次只播放一首歌曲。这使我们能够更深入地了解 async 和 await,并在将来实现我们自己的async函数。在下一节中,我们将讨论如何将现有的基于 promise 或回调的代码迁移到 async 和 await。

活动 12:使用 Async 和 Await 重构账单计算器

您的公司再次更新了其 Node.js 运行时。在此活动中,我们将使用 async 和 await 重构之前创建的账单计算器:

  • 您获得了使用承诺实现的clientApi

  • 您需要将calculate()更新为async函数。

  • 您需要将calculateAll()更新为async函数。

  • calculateAll()需要使用Promise.all一次获取所有结果。

打开async.js文件,使用asyncawait实现calculatecalculateAll函数。

注意

此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson08/Activity12/Activity12.js找到。

执行以下步骤完成活动:

  1. 创建一个calculate函数,以 ID 作为输入。

  2. calculate中,使用await调用clientApi.getUsers()来检索所有用户。

  3. 使用array.find()使用id参数找到currentUser

  4. 使用await调用getUsage()来获取该用户的使用情况。

  5. 使用await调用getRate以获取用户的费率。

  6. 返回一个新对象,其中包括idaddress和总应付金额。

  7. calculateAll函数编写为async函数。

  8. 使用await调用getUsers以检索所有用户。

  9. 使用数组映射创建一个承诺列表,并使用Promise.all将它们包装起来。然后,在由Promise.all返回的承诺上使用等待,并返回其值。

  10. 在一个用户上调用calculate

  11. 调用calculateAll

输出应如下所示:

图 8.42:调用 calculateAll 函数

图 8.42:调用 calculateAll 函数

注意

此活动的解决方案可在第 615 页找到。

将回调和 Promise-Based 代码迁移到 Async 和 Await

在处理大型项目时,经常需要使用 async 和 await 重构现有代码。在进行这些重构时,我们需要牢记应保持相同的功能和错误处理类型。在本节中,我们将学习如何将现有的回调和基于 promise 的代码迁移到 async 和 await。

将基于回调的代码迁移到 Async 和 Await

当我们迁移基于回调的代码时,我们需要重写函数,并确保它返回一个 promise 而不是使用回调。考虑以下代码:

function makeRequest(param, callback) {
   request(param, (err, data) => {
      if (err) {
        return callback(err);
      }
      const users = data.users;
      callback(null, users.map((u) => u.id));
   });
}

上述代码接受一个参数并调用request模块,我们无法修改它,并返回用户 ID 的列表。一旦完成,如果出现错误,它将通过回调简单地返回。当我们想要使用 async 和 await 重构这段代码时,我们可以首先确保它返回一个 promise。这样做的同时,我们也想删除callback参数:

function makeRequest(param) {
   return new Promise((resolve, reject) => {
      // Logic here
   });
}

然后,我们需要把我们的逻辑复制到:

function makeRequest(param) {
   return new Promise((resolve, reject) => {
      request(param, (err, data) => {
        if (err) {
           return callback(err);
        }
        const users = data.users;
        callback(null, users.map((u) => u.id));
      });
   });
}

在这里,我们需要进行修改。我们需要删除所有对callback的引用,并改用rejectresolve

function makeRequest(param) {
   return new Promise((resolve, reject) => {
      request(param, (err, data) => {
        if (err) {
           return reject(err);
        }
        const users = data.users;
        resolve(users.map((u) => u.id));
      });
   });
}

您可以在这里看到,我们在调用request时仍在使用回调样式。那是因为我们无法控制外部库。我们能做的是确保每次调用它时,我们都返回一个 promise。现在,我们已经完全将我们的传统代码转换为现代标准。您现在可以像这样在async函数中使用它:

async function use() {
   const userIds = await makeRequest({});
}

通常,代码重构要困难得多。建议从最基本的级别开始,随着重构的进行逐步提升。当处理嵌套回调时,确保使用await来确保保留依赖关系。

总结

在本章中,我们讨论了如何使用 promise 和 async 和 await 更好地管理代码中的异步操作。我们还谈到了将现有的回调代码重构为 async 和 await 的各种方法。在我们的应用程序中使用 async 和 await 不仅有助于使我们的代码更易读,还将帮助我们对实现进行未来测试。在下一章中,我们将讨论如何在我们的应用程序中使用基于事件的编程。

第十章:事件驱动编程和内置模块

学习目标

在本章结束时,您将能够:

  • 在 Node.js 中使用事件模块

  • 创建事件发射器以增强现有代码的功能

  • 构建自定义事件发射器

  • 使用内置模块和实用工具

  • 实现一个计时器模块,以获得调度计时器函数的 API

在本章中,我们将使用事件发射器和内置模块,以避免创建具有紧密耦合依赖关系的项目。

介绍

在上一章中,我们讨论了 Node.js 中如何使用事件驱动编程,以及如何修改正常的基于回调的异步操作以使用 async-await 和 promises。我们知道 Node.js 核心 API 是建立在异步驱动架构上的。Node.js 有一个事件循环,用于处理大多数异步和基于事件的操作。

在 JavaScript 中,事件循环不断地运行并从回调队列中消化消息,以确保执行正确的函数。没有事件,我们可以看到代码非常紧密耦合。对于一个简单的聊天室应用程序,我们需要编写类似这样的东西:

class Room {
    constructor() {
        this.users = [];
    }
    addUser(user) {
        this.users.push(user);
    }
    sendMessage(message) {
        this.users.forEach(user => user.sendMessage(message));
    }
}

正如您所看到的,因为我们没有使用事件,我们需要保留房间中所有用户的列表。当我们将用户添加到房间时,我们还需要将用户添加到我们创建的列表中。在发送消息时,我们还需要遍历我们列表中的所有用户并调用sendMessage方法。我们的用户类将被定义如下:

class User {
    constructor() {
        this.rooms = {}
    }
    joinRoom(roomName, room) {
        this.rooms[roomName] = room;
        room.addUser(this);
    }
    sendMessage(roomName, message) {
        this.rooms[roomName].sendMessage(message);
    }
}

您可以看到这变得过于复杂;为了加入聊天室,我们需要同时将房间和当前用户添加到房间中。当我们的应用程序最终变得非常复杂时,我们会发现这会引发传统方法的问题。如果此应用程序需要网络请求(异步操作),它将变得非常复杂,因为我们需要用异步操作包装我们希望执行的所有代码。我们可能能够将该逻辑提取出来,但是当我们处理由未知数量的随机事件驱动的应用程序时,使用事件驱动编程的好处在于使我们的代码更易于维护。

传统方法与事件驱动编程

正如我们在介绍中提到的,在传统的编程模式中,当我们希望它们进行通信时,我们喜欢在组件之间建立直接的联系。这在下图中有所体现:

图 9.1:传统编程方法

图 9.1:传统编程方法

对于一个简单的应用程序,允许用户更新其个人资料并接收消息,我们可以看到我们有四个组件:

  • 代理

  • 个人资料

  • 投票

  • 消息

这些组件之间的交互方式是通过调用希望通信的组件中的适当方法来实现的。通过这样做,使得代码非常易于理解,但我们可能需要传递组件引用。以我们的Agent类为例:

class Agent {
    constructor(id, agentInfo, voteObj, messageObj) {
        this.voteObj = voteObj;
        this.messageObj = messageObj;
    }
    checkMessage() {
        if (this.messageObj.hasMessage()) {
            const message = this.messageObj.nextMessate();
            return message;
        }
        return undefined;
    }
    checkVote() {
        if (this.voteObj.hasNewVote()) {
            return true;
        }
        return false;
    }
}

Agent类必须在未来存储与其希望通信的组件的引用。如果没有,我们的组件就无法与其他组件通信。在前面的示例中,我们创建的Agent对象与其他所有内容都非常紧密耦合。它在创建时需要所有这些对象的引用,这使得我们的代码在未来要更改某些内容时非常难以解耦。考虑前面的Agent代码。如果我们要为其添加更多功能,我们希望代理类与新功能进行通信,例如社交页面、直播页面等。只要我们在我们的constructor中添加对这些对象的引用,这在技术上是可行的。通过这样做,我们将冒着我们的代码在未来看起来像这样的风险:

class Agent {
    constructor(id, agentInfo, voteObj, messageObj, socialPage, gamePage, liveStreamPage, managerPage, paymentPage...) {
        this.voteObj = voteObj;
        this.messageObj = messageObj;
        this.socialPage = socialPage;
        this.gamePage = gamePage;
        this.liveStreamPage = liveStreamPage;
        this.managerPage = managerPage;
        this.paymentPage = paymentPage;
        ...
    }
    ...
}

当我们的应用程序变得越来越复杂时,我们的Agent类也会变得越来越复杂。因为它在constructor中有所有的引用,所以我们容易因为错误地传递参数类型而引起问题。当我们试图一次性在多个组件之间进行通信时,这是一个常见的问题。

事件

我们之前的方法——即处理组件通信的方法——是直接的,而且非常静态。我们需要存储我们想要进行通信的组件引用,并且在想要向其发送消息时编写非常特定于组件的代码。在 JavaScript 中,有一种新的通信方式,它被称为事件

让我们考虑这个例子;你朋友传递给你的光是你从朋友那里接收事件的一种方式。在 JavaScript 中,我们可以拥有能够发出事件的对象。通过发出事件,我们可以创建对象之间的新通信方式。这也被称为观察者模式。以下图表描述了观察者模式:

图 9.2:观察者模式

图 9.2:观察者模式

在这种模式中,我们不是在组件中调用特定的方法,而是希望发起通信的组件只是发出一个事件。我们可以有多个观察者观察来自组件的事件。这样,我们把消费消息的责任完全放在了消费者身上。当观察者决定观察事件时,它将在组件发出事件时每次接收到事件。如果使用事件来实现前面复杂的例子,它会是这样的:

图 9.3:使用事件的观察者模式

图 9.3:使用事件的观察者模式

在这里,我们可以看到每个组件都遵循我们的观察者模式,当我们将其转换为代码时,它会看起来像这样:

class Agent {
    constructor(id, agentInfo, emitter) {
        this.messages = [];
        this.vote = 0;
        emitter.on('message', (message) => {
            this.messages.push(message);
        });
        emitter.on('vote', () => {
            this.vote += 1;
        })
    }
}

现在,我们不再需要获取所有我们想要进行通信的组件的引用,而是只传递一个事件发射器,它处理所有的消息。这使得我们的代码与其他组件的耦合度大大降低。这基本上就是我们在代码中实现事件观察者模式的方式。在现实生活中,这可能会变得更加复杂。在下一个练习中,我们将介绍一个简单的例子,演示如何使用 Node.js 中内置的事件系统来发出事件。

练习 67:一个简单的事件发射器

在介绍中,我们谈到了如何使用事件观察者模式来消除我们代码中想要进行通信的所有组件的引用。在这个练习中,我们将介绍 Node.js 中内置的事件模块,我们如何创建EventEmitter以及如何使用它。

执行以下步骤完成这个练习:

  1. 导入events模块:
const EventEmitter = require('events');

我们将导入 Node.js 中内置的events模块。它提供了一个构造函数,我们可以用它来创建自定义的事件发射器或创建一个继承自它的类。因为这是一个内置模块,所以不需要安装它。

  1. 创建一个新的EventEmitter
const emitter = new EventEmitter();
  1. 尝试发出一个事件:
emitter.emit('my-event', { value: 'event value' });
  1. 附加一个事件监听器:
emitter.on('my-event', (value) => {
    console.log(value);
});

要向我们的发射器添加事件监听器,我们需要在发射器上调用on方法,传入事件名称和在发出事件时要调用的函数。当我们在发出事件后添加事件监听器时,我们会发现事件监听器没有被调用。原因是在我们之前发出事件时,并没有为该事件附加事件监听器,因此它没有被调用。

  1. 再发出一个事件:
emitter.emit('my-event', { value: 'another value' });

当我们这次发出事件时,我们会看到我们的事件监听器被正确调用,并且我们的事件值被正确打印出来,就像这样:

图 9.4:使用正确的事件值发出的事件

图 9.4:使用正确的事件值发出的事件
  1. my-event附加另一个事件监听器:
emitter.on('my-event', (value) => {
    console.log('i am handling it again');
});

我们不仅限于每个事件只有一个监听器 - 我们可以附加尽可能多的事件监听器。当发射事件时,它将调用所有监听器。

  1. 发射另一个事件:
emitter.emit('my-event', { value: 'new value' });

以下是上述代码的输出:

图 9.5:多次发射事件后的输出

图 9.5:多次发射事件后的输出

当我们再次发射事件时,我们将看到我们发射的第一个事件。我们还将看到它成功地打印出我们的消息。请注意,它保持了与我们附加监听器时相同的顺序。当我们发射错误时,发射器会遍历数组并依次调用每个监听器。

  1. 创建handleEvent函数:
function handleEvent(event) {
    console.log('i am handling event type: ', event.type);
}

当我们设置我们的事件监听器时,我们使用了匿名函数。虽然这很容易和简单,但它并没有为我们提供EventEmitters提供的所有功能:

  1. 将新的handleEvent附加到新类型的事件上:
emitter.on('event-with-type', handleEvent);
  1. 发射新的事件类型:
emitter.emit('event-with-type', { type: 'sync' });

以下是上述代码的输出:

图 9.6:发射新的事件类型

图 9.6:发射新的事件类型
  1. 移除事件监听器:
emitter.removeListener('event-with-type', handleEvent);

因为我们使用了命名函数,所以我们可以使用这个函数引用来移除监听器,一旦我们不再需要将事件传递给该监听器。

  1. 在移除监听器后发射事件:
emitter.emit('event-with-type', { type: 'sync2' });

以下是上述代码的输出:

图 9.7:移除监听器后发射事件的输出

图 9.7:移除监听器后发射事件的输出

因为我们刚刚移除了对event-with-type的监听器,当我们再次发射事件时,它将不会被调用。

在这个练习中,我们构建了一个非常简单的事件发射器,并测试了添加和移除监听器。现在,我们知道如何使用事件将消息从一个组件传递到另一个组件。接下来,我们将深入研究事件监听器方法,并看看通过调用它们我们可以实现什么。

事件发射器方法

在上一个练习中,我们讨论了一些可以调用的方法来发射事件和附加监听器。我们还使用了removeListener来移除我们附加的监听器。现在,我们将讨论我们可以在事件监听器上调用的各种方法。这将帮助我们更轻松地管理事件发射器。

移除监听器

有些情况下,我们希望从我们的发射器中移除监听器。就像我们在上一个练习中所做的那样,我们可以通过调用removeListener来简单地移除一个监听器:

emitter.removeListener('event-with-type', handleEvent);

当我们调用removeListener方法时,我们必须为其提供事件名称和函数引用。当我们调用该方法时,无论事件监听器是否已设置都无关紧要;如果监听器一开始就没有设置,什么也不会发生。如果设置了,它将遍历我们的事件发射器中监听器的数组,并移除该监听器的第一次出现,就像这样:

const emitter = new EventEmitter();
function handleEvent(event) {
    console.log('i am handling event type: ', event.type);
}
emitter.on('event-with-type', handleEvent);
emitter.on('event-with-type', handleEvent);
emitter.on('event-with-type', handleEvent);
emitter.emit('event-with-type', { type: 'sync' });
emitter.removeListener('event-with-type', handleEvent);

在这段代码中,我们三次附加了相同的监听器。在事件发射器中,当我们附加事件监听器时,允许这样做,它只是简单地追加到该事件的事件监听器数组中。当我们在removeListener之前发射我们的事件时,我们将看到我们的监听器被调用三次:

图 9.8:在移除监听器之前使用 emit 事件调用三次监听器

图 9.8:在移除监听器之前使用 emit 事件调用三次监听器

在这种情况下,因为我们有三个相同的监听器附加到我们的事件上,当我们调用removeListener时,它只会移除我们的listener数组中的第一个监听器。当我们再次发射相同的事件时,我们会看到它只运行两次:

图 9.9:使用 removeListener 后,第一个监听器被移除

图 9.9:使用 removeListener 后,第一个监听器被移除

移除所有监听器

我们可以从我们的事件发射器中删除特定的侦听器。但通常,当我们在发射器上处理多个侦听器时,有时我们希望删除所有侦听器。EventEmitter类为我们提供了一个方法,我们可以使用它来删除特定事件的所有侦听器。考虑我们之前使用的相同示例:

const emitter = new EventEmitter();
function handleEvent(event) {
    console.log('i am handling event type: ', event.type);
}
emitter.on('event-with-type', handleEvent);
emitter.on('event-with-type', handleEvent);
emitter.on('event-with-type', handleEvent);

如果我们想要删除event-with-type事件的所有侦听器,我们将不得不多次调用removeListener。有时,当我们确定所有事件侦听器都是由我们添加的,没有其他组件或模块时,我们可以使用单个方法调用来删除该事件的所有侦听器:

emitter.removeAllListeners('event-with-type');

当我们调用removeAllListeners时,我们只需要提供事件名称。这将删除附加到事件的所有侦听器。调用后,事件将没有处理程序。确保您不要删除由另一个组件附加的侦听器,如果您使用此功能:

emitter.emit('event-with-type', { type: 'sync' });

当我们在调用removeAllListeners后再次发出相同的事件时,我们将看到我们的程序不会输出任何内容:

图 9.10:使用将不会输出任何内容

图 9.10:使用removeAllListeners将不会输出任何内容

附加一次性侦听器

有时,我们希望我们的组件只接收特定事件一次。我们可以通过使用removeListener来确保在调用后删除侦听器:

const EventEmitter = require('events');
const emitter = new EventEmitter();
function handleEvent(event) {
    console.log('i am handling event type once : ', event.type);
    emitter.removeListener('event-with-type', handleEvent);
}
emitter.on('event-with-type', handleEvent);
emitter.emit('event-with-type', { type: 'sync' });
emitter.emit('event-with-type', { type: 'sync' });
emitter.emit('event-with-type', { type: 'sync' });

在这里,我们可以看到,在我们的handleEvent侦听器中,执行后我们还删除了侦听器。这样,我们可以确保我们的事件侦听器只会被调用一次。当我们运行上述代码时,我们将看到以下输出:

图 9.11:使用侦听器后的输出

图 9.11:使用handleEvent侦听器后的输出

这做到了我们想要的,但还不够好。它要求我们在事件侦听器中保留发射器的引用。此外,它还不够健壮,因为我们无法将侦听器逻辑分离到不同的文件中。EventEmitter类为我们提供了一个非常简单的方法,可以用来附加一次性侦听器:

...
emitter.once('event-with-type', handleEvent);
emitter.emit('event-with-type', { type: 'sync' });
emitter.emit('event-with-type', { type: 'sync' });
emitter.emit('event-with-type', { type: 'sync' });

在这里,当我们附加事件侦听器时,我们使用了.once方法。这告诉我们的发射器,我们传递的函数应该只被调用一次,并且在被调用后将从事件侦听器列表中删除。当我们运行它时,它将为我们提供与以前相同的输出:

图 9.12:使用.once方法获取一次性侦听器

](Images/C14587_09_12.jpg)

图 9.12:使用.once方法获取一次性侦听器

这样,我们就不需要在侦听器中保留对事件发射器的引用。这使我们的代码更灵活,更容易模块化。

从事件发射器中读取

到目前为止,我们一直在设置和删除事件发射器的侦听器。EventEmitter类还为我们提供了几种读取方法,我们可以从中获取有关事件发射器的更多信息。考虑以下示例:

const EventEmitter = require('events');
const emitter = new EventEmitter();
emitter.on('event 1', () => {});
emitter.on('event 2', () => {});
emitter.on('event 2', () => {});
emitter.on('event 3', () => {});

在这里,我们向我们的发射器添加了三种类型的事件侦听器。对于event 2,我们为其设置了两个侦听器。要获取我们的发射器中特定事件的事件侦听器数量,我们可以调用listenerCount。对于上面的示例,如果我们想要知道附加到event 1的事件侦听器的数量,我们可以执行以下命令:

emitter.listenerCount('event 1');

以下是上述代码的输出:

图 9.13:输出显示附加到事件 1 的事件数量

](Images/C14587_09_13.jpg)

图 9.13:输出显示附加到事件 1 的事件数量

同样,我们可以通过执行以下命令来检查附加到event 2的事件侦听器的数量:

emitter.listenerCount('event 2');

以下是上述代码的输出:

图 9.14:输出显示附加到事件 2 的事件数量

图 9.14:输出显示附加到事件 2 的事件数量

有时我们想要知道已经附加到事件的事件监听器列表,以便我们可以确定某个处理程序是否已经附加,就像这样:

function anotherHandler() {}
emitter.on('event 4', () => {});
emitter.on('event 4', anotherHandler);

在这里,我们附加了一个匿名函数到event 4,并使用一个命名函数附加了另一个监听器。如果我们想知道anotherHandler是否已经附加到event 4,我们可以附加一个监听器列表到该事件。EventEmitter类为我们提供了一个非常简单的方法来调用这个:

const event4Listeners = emitter.listeners('event 4');

以下是前面代码的输出:

图 9.15:使用 EventEmitter 类获取附加到事件的监听器列表

图 9.15:使用 EventEmitter 类获取附加到事件的监听器列表

在这里,我们可以看到我们已经附加到我们的发射器的两个监听器:一个是我们的匿名函数,另一个是我们的命名函数anotherHandler。要检查我们的处理程序是否已经附加到发射器,我们可以检查event4Listeners数组中是否有anotherHandler

event4Listeners.includes(anotherHandler);

以下是前面代码的输出:

图 9.16:检查处理程序是否附加到发射器

图 9.16:检查处理程序是否附加到发射器

通过使用这个方法和数组包含一个方法,我们可以确定一个函数是否已经附加到我们的事件。

获取已注册监听器的事件列表

有时我们需要获取已注册监听器的事件列表。这可以用于确定我们是否已经为事件附加了监听器,或者查看事件名称是否已经被使用。继续前面的例子,我们可以通过调用EventEmitter类中的另一个内部方法来获取这些信息:

emitter.eventNames();

以下是前面代码的输出:

图 9.17:使用 EventEmitter 类获取事件名称的信息

图 9.17:使用 EventEmitter 类获取事件名称的信息

在这里,我们可以看到我们的事件发射器已经附加到四种不同的事件类型的监听器;即事件 1-4。

最大监听器

默认情况下,每个事件发射器只能为任何单个事件注册最多 10 个监听器。当我们附加超过最大数量时,我们将收到类似这样的警告:

图 9.18:为单个事件附加超过 10 个监听器时的警告

图 9.18:为单个事件附加超过 10 个监听器时的警告

这是为了确保我们不会泄漏内存而设置的预防措施,但也有时我们需要为一个事件设置超过 10 个监听器。如果我们确定了,我们可以通过调用setMaxListeners来更新默认的最大值:

emitter.setMaxListeners(20)

在这里,我们将最大监听器默认设置为20。我们也可以将其设置为0或无穷大,以允许无限数量的监听器。

在事件之前添加监听器

当我们添加监听器时,它们被附加到监听器数组的末尾。当事件被发出时,发射器将按照它们被分配的顺序调用每个分配的监听器。在某些情况下,我们需要我们的监听器首先被调用,我们可以使用事件发射器提供的内置方法来实现这一点:

const EventEmitter = require('events');
const emitter = new EventEmitter();
function handleEventSecond() {
    console.log('I should be called second');
}
function handleEventFirst() {
    console.log('I should be called first');
}
emitter.on('event', handleEventSecond);
emitter.on('event', handleEventFirst);
emitter.emit('event');

在这里,我们在handleEventFirst之前附加了handleEventSecond。当我们发出事件时,我们将看到以下输出:

图 9.19:在第一个事件之前附加第二个事件后发出事件

图 9.19:在第一个事件之前附加第二个事件后发出事件

因为事件监听器是按照它们附加的顺序调用的,我们可以看到当我们发出事件时,handleEventSecond首先被调用,然后是handleEventFirst。如果我们希望handleEventFirst在使用emitter.on()附加它们的顺序不变的情况下首先被调用,我们可以调用prependListener

...
emitter.on('event', handleEventSecond);
emitter.prependListener('event', handleEventFirst);
emitter.emit('event');

前面的代码将产生以下输出:

图 9.20:使用 prependListener 对事件进行排序

图 9.20:使用 prependListener 对事件进行排序

这可以帮助我们保持监听器的顺序,并确保优先级较高的监听器始终首先被调用。接下来我们将讨论监听器中的并发性。

监听器中的并发性

在之前的章节中,我们提到了如何将多个监听器附加到我们的发射器上,以及在事件被触发时这些监听器是如何工作的。之后,我们还谈到了如何在事件被触发时添加监听器,使得它们首先被调用。我们可能想要添加监听器的原因是,当监听器被调用时,它们是同步一个接一个被调用的。考虑以下例子:

const EventEmitter = require('events');
const emitter = new EventEmitter();
function slowHandle() {
    console.log('doing calculation');
    for(let i = 0; i < 10000000; i++) {
        Math.random();
    }
}
function quickHandle() {
    console.log('i am called finally.');
}
emitter.on('event', slowHandle);
emitter.on('event', quickHandle);
emitter.emit('event');

在这里,我们有两个附加到event类型的监听器。当事件被触发时,它将首先调用slowHandle,然后调用quickHandle。在slowHandle中,我们有一个非常大的循环,模拟一个在事件监听器中可以执行的非常耗时的操作。当我们运行前面的代码时,我们首先会看到doing calculation被打印出来,然后会有一个很长的等待,直到I am called finally被调用。我们可以看到,当发射器调用事件监听器时,它是同步进行的。这可能会给我们带来问题,因为在大多数情况下,我们不希望等待一个监听器完成后再触发另一个监听器。不过,有一种简单的解决方法:我们可以用setImmediate函数包装我们的耗时逻辑。setImmediate函数将我们的逻辑包装成一个立即执行的异步块,这意味着耗时的循环是非阻塞的。我们将在本书的后面介绍setImmediate函数:

...
function slowHandle() {
    console.log('doing calculation');
    setImmediate(() => {
        for(let i = 0; i < 10000000; i++) {
            Math.random();
        }
    });
}

当我们用setImmediate()包装我们的耗时逻辑时,代码几乎同时输出doing calculationI am called finally。通过用setImmediate包装所有逻辑,我们可以确保它是异步调用的。

构建自定义事件发射器

有些情况下,我们希望将事件发射功能构建到我们自己的自定义类中。我们可以通过使用JavaScript ES6继承来实现这一点。这允许我们创建一个自定义类,同时扩展事件发射器的所有功能。例如,假设我们正在构建一个火警类:

class FireAlarm {
    constructor(modelNumber, type, cost) {
        this.modelNumber = modelNumber;
        this.type = type;
        this.cost = cost;
        this.batteryLevel = 10;
    }
    getDetail() {
        return '${this.modelNumber}:[${this.type}] - $${this.cost}';
    }
    test() {
        if (this.batteryLevel > 0) {
            this.batteryLevel -= 0.1;
            return true;
        }
        return false;
    }
}

在这里,我们有一个FireAlarm类,它有一个存储有关这个火警的信息的构造函数。它还有一些自定义方法来测试警报,比如检查电池电量,以及一个getDetail方法来返回表示警报信息的字符串。在定义了这个类之后,我们可以像这样使用FireAlarm类:

const livingRoomAlarm = new FireAlarm('AX-00101', 'BATT', '20');
console.log(livingRoomAlarm.getDetail());

以下是前面代码的输出:

图 9.21:定义火警类

图 9.21:定义火警类

现在,我们想在刚刚创建的火警上设置事件。我们可以通过创建一个通用事件发射器并将其存储在我们的FireAlarm对象中来实现这一点:

class FireAlarm {
    constructor(modelNumber, type, cost) {
        this.modelNumber = modelNumber;
        this.type = type;
        this.cost = cost;
        this.batteryLevel = 10;
        this.emitter = new EventEmitter();
    }
    ...
}

当我们想要监视警报上的事件时,我们必须这样做:

livingRoomAlarm.emitter.on('low-battery', () => {
    console.log('battery low');
});

虽然这是完全可以的,并且对我们的用例有效,但这显然不是最健壮的解决方案。因为我们的火警是发出事件的一方,我们希望像这样:

livingRoomAlarm.on('low-battery', () => {
    console.log('battery low');
});

通过直接在火警上使用.on,我们告诉未来的开发人员,将要在这上面工作,我们的火警也是一个事件发射器。但是现在,我们的类定义不允许使用。我们可以通过使用类继承来解决这个问题,在那里我们可以使我们的FireAlarm类扩展EventEmitter类。通过这样做,它将拥有EventEmitter的所有功能。我们可以修改我们的类如下:

class FireAlarm extends EventEmitter {
    constructor(modelNumber, type, cost) {
        this.modelNumber = modelNumber;
        this.type = type;
        this.cost = cost;
        this.batteryLevel = 10;
    }
    ...
}

使用extends关键字后跟EventEmitter,我们告诉 JavaScriptFireAlarm类是EventEmitter的子类。因此,它将继承父类的所有属性和方法。但这本身并不能解决所有问题。当我们运行更新后的FireAlarm代码时,我们会看到抛出一个错误:

图 9.22:当我们运行更新后的 FireAlarm 代码时会抛出错误

图 9.22:当我们运行更新后的 FireAlarm 代码时会抛出错误

这是因为我们使用了一个非常定制的类,具有自定义的构造函数,并访问this(这用作对当前对象的引用)。在此之前,我们需要确保在此之前调用父构造函数。为了使此错误消失,我们只需在自己的构造函数中添加对父构造函数的调用:

class FireAlarm extends EventEmitter {
    constructor(modelNumber, type, cost) {
        super();
        this.modelNumber = modelNumber;
        this.type = type;
        this.cost = cost;
        this.batteryLevel = 10;
    }
    ...
}

现在,让我们测试我们自己的自定义EventEmitter

livingRoomAlarm.on('low-battery', () => {
    console.log('battery low');
});
livingRoomAlarm.emit('low-battery');

以下是上述代码的输出:

图 9.23:'low-battery'事件的事件监听器被正确触发

图 9.23:'low-battery'事件的事件监听器被正确触发

在这里,我们可以看到我们将livingRoomAlarm视为常规的EventEmitter,当我们发出low-battery事件时,我们看到该事件的事件监听器被正确触发。在下一个练习中,我们将使用我们对EventEmitters的所有了解制作一个非常简单的聊天室应用程序。

练习 68:构建聊天室应用程序

之前,我们讨论了如何在我们的事件发射器上附加事件监听器并发出事件。在这个练习中,我们将构建一个简单的聊天室管理软件,该软件使用事件进行通信。我们将创建多个组件,并查看如何使它们相互通信。

注意:

此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson09/Exercise68找到。

执行以下步骤完成此练习:

  1. 创建一个User类:
class User {
    constructor(name) {
        this.name = name;
        this.messages = [];
        this.rooms = {};
    }
    joinRoom(room) {
        room.on('newMessage', (message) => {
            this.messages.push(message);
        });
        this.rooms[room.name] = room;
    }
    getMesssages(roomName) {
        return this.messages.filter((message) => {
            return message.roomName === roomName;
        })
    }
    printMessages(roomName) {
        this.getMesssages(roomName).forEach((message) => {
            console.log(`>> [${message.roomName}]:(${message.from}): ${message.message}`);
        });
    }
    sendMessage(roomName, message) {
        this.rooms[roomName].emit('newMessage', {
            message,
            roomName,
            from: this.name
        });
    }
}

在这里,我们为用户创建了一个User类。它有一个joinRoom方法,我们可以调用该方法将用户加入房间。它还有一个sendMessage方法,该方法将消息发送给房间中的所有人。当我们加入一个房间时,我们还会监听来自该房间的所有新消息事件,并在接收到消息时追加消息。

  1. 创建一个扩展EventEmitter类的Room类:
class Room extends EventEmitter {
    constructor(name) {
        super();
        this.name = name;
    }
}

在这里,我们通过扩展现有的EventEmitter类创建了一个新的Room类。我们这样做的原因是我们希望在我们的room对象上拥有自定义属性,并且这样可以增加代码的灵活性。

  1. 创建两个用户,bobkevin
const bob = new User('Bob');
const kevin = new User('Kevin');
  1. 使用我们的Room类创建一个房间:
const lobby = new Room('Lobby');
  1. bobkevin加入lobby
bob.joinRoom(lobby);
kevin.joinRoom(lobby);
  1. bob发送几条消息:
bob.sendMessage('Lobby', 'Hi all');
bob.sendMessage('Lobby', 'I am new to this room.');
  1. 打印bob的消息日志:
bob.printMessages('Lobby');

以下是上述代码的输出:

图 9.24:打印 bob 的消息日志

图 9.24:打印 bob 的消息日志

在这里,您可以看到我们所有的消息都正确添加到了bob的日志中。接下来,我们将检查kevin的日志。

  1. 打印kevin的消息日志:
kevin.printMessage('Lobby');

以下是上述代码的输出:

图 9.25:打印 kevin 的消息日志

图 9.25:打印 kevin 的消息日志

即使我们从未明确对kevin做过任何事情,他也会收到所有消息,因为他正在监听房间中的新消息事件。

  1. kevinbob发送消息:
kevin.sendMessage('Lobby', 'Hi bob');
bob.sendMessage('Lobby', 'Hey kevin');
kevin.sendMessage('Lobby', 'Welcome!');
  1. 检查kevin的消息日志:
kevin.printMessages('Lobby');

以下是上述代码的输出:

图 9.26:检查 kevin 的消息日志

图 9.26:检查 kevin 的消息日志

在这里,我们可以看到所有我们的消息都正确地添加到我们的user对象中。因为我们使用事件发射器,所以我们避免了在我们的接收者周围传递引用。此外,因为我们在我们的房间上发出了消息事件,而我们的用户只是监听该事件,所以我们不需要手动遍历房间中的所有用户并传递消息。

  1. 让我们修改joinRoomconstructor,以便稍后可以移除监听器:
class User {
    constructor(name) {
        this.name = name;
        this.messages = [];
        this.rooms = {};
        this.messageListener = (message) => {
            this.messages.push(message);
        }
    }
    joinRoom(room) {
        this.messageListener = (message) => {
            this.messages.push(message);
        }
        room.on('newMessage', this.messageListener);
        this.rooms[room.name] = room;
    }
    ...
}

当我们移除监听器时,我们需要传递该监听器函数的引用,因此,我们需要将该引用存储在对象中,以便稍后可以使用它来移除我们的监听器。

  1. 添加leaveRoom
class User {
    ...
    leaveRoom(roomName) {
        this.rooms[roomName].removeListener('newMessage', this.messageListener);
delete this.rooms[roomName];
    }
}

在这里,我们正在使用我们在构造函数中设置的函数引用,并将其传递给我们房间的removeListener。我们还从对象中移除了引用,以便稍后可以释放内存。

  1. room中移除bob
bob.leaveRoom('Lobby');
  1. kevin发送一条消息:
kevin.sendMessage('Lobby', 'I got a good news for you guys');
  1. 检查bob的消息列表:
bob.printMessages('Lobby');

以下是上述代码的输出:

图 9.27:再次检查鲍勃的消息列表

图 9.27:检查鲍勃的消息列表

因为bob离开了房间,并且我们移除了消息监听器,所以当发出新消息事件时,newMessage事件处理程序不会再被调用。

  1. 检查kevin的消息列表:
kevin.printMessages('Lobby');

以下是上述代码的输出:

图 9.28:再次检查 kevin 的消息列表

图 9.28:再次检查 kevin 的消息列表

当我们检查kevin的消息列表时,我们应该仍然能够看到他仍然从房间中收到新消息。如果使用传统方法来完成这项工作,我们将需要编写更多的代码来完成相同的事情,这将非常容易出错。

在这个练习中,我们使用 Node.js 构建了一个带有事件的模拟聊天应用程序。我们可以看到在 Node.js 中传递事件是多么容易,以及我们如何正确使用它。事件驱动编程并不适用于每个应用程序,但是当我们需要将多个组件连接在一起时,使用事件来实现这种逻辑要容易得多。上述代码仍然可以改进-我们可以在用户离开房间时向房间添加通知,并且我们可以在添加和移除房间时添加检查,以确保我们不会添加重复的房间,并确保我们只移除我们所在的房间。请随意自行扩展此功能。

在本章中,我们讨论了如何使用事件来管理应用程序中组件之间的通信。在下一个活动中,我们将构建一个基于事件驱动的模块。

活动 13:构建一个基于事件驱动的模块

假设您正在为一家构建烟雾探测器模拟器的软件公司工作。您需要构建一个烟雾探测器模拟器,当探测器的电池电量低于一定水平时会引发警报。以下是要求:

  • 探测器需要发出警报事件

  • 当电池低于 0.5 单位时,烟雾探测器需要发出低电量事件。

  • 每个烟雾探测器在初始创建时都有 10 个单位的电池电量。

  • 烟雾探测器上的测试函数如果电池电量高于 0 则返回 true,如果低于 0 则返回 false。每次运行测试函数时,电池电量将减少 0.1 个单位。

  • 您需要修改提供的House类以添加addDetectordemoveDetector方法。

  • addDetector将接受一个探测器对象,并在打印出低电量警报事件之前,为警报事件附加一个监听器。

  • removeDetector方法将接受一个探测器对象并移除监听器。

完成此活动,执行以下步骤:

  1. 打开event.js文件并找到现有的代码。然后,修改并添加你自己的更改。

  2. 导入events模块。

  3. 创建SmokeDetector类,该类扩展EventEmitter并将batteryLevel设置为10

  4. SmokeDetector类内创建一个test方法来发出低电量消息。

  5. 创建House类,它将存储我们警报的实例。

  6. House类中创建一个addDetector方法,它将附加事件监听器。

  7. 创建一个removeDetector方法,它将帮助我们移除之前附加的警报事件监听器。

  8. 创建一个名为myHouseHouse实例。

  9. 创建一个名为detectorSmokeDetector实例。

  10. 将探测器添加到myHouse中。

  11. 创建一个循环来调用测试函数 96 次。

  12. detector对象上发出警报。

  13. myHouse对象中移除探测器。

  14. 在探测器上测试发出警报。

注意

此活动的解决方案可以在第 617 页找到。

在这个活动中,我们学习了如何使用事件驱动编程来建模烟雾探测器。通过使用这种方法,我们消除了在我们的House对象中存储多个实例的需要,并避免使用大量代码来进行它们的交互。

在本节中,我们介绍了如何充分利用事件系统来帮助我们管理应用程序中的复杂通信。在下一节中,我们将介绍一些处理事件发射器的最佳实践。

事件驱动编程最佳实践

在前一章中,我们提到了使用事件发射器和事件发射器继承创建事件驱动组件的方法。但通常,您的代码需要的不仅仅是能够正确工作。拥有更好管理的代码结构不仅可以使我们的代码看起来不那么凌乱,还可以帮助我们避免将来一些可避免的错误。在本节中,我们将介绍在处理代码中的事件时的一些最佳实践。

回顾一下我们在本章开头所讨论的内容,我们可以使用EventEmitter对象传递事件:

const EventEmitter = require('events');
const emitter = new EventEmitter();
emitter.emit('event');

当我们想要使用我们创建的事件发射器时,我们需要有它的引用,这样我们才能在以后想要发出事件时附加监听器并调用发射器的emit函数。这可能会导致我们的源代码非常庞大,这将使未来的维护非常困难:

const EventEmitter = require('events');
const userEmitter = new EventEmitter();
const registrationEmitter = new EventEmitter();
const votingEmitter = new EventEmitter();
const postEmitter = new EventEmitter();
const commentEmitter = new EventEmitter();
userEmitter.on('update', (diff) => {
    userProfile.update(diff);
});
registrationEmitter.on('user registered:activated', (user) => {
    database.add(user, true);
});
registrationEmitter.on('user registered: not activated', (user) => {
    database.add(user, false);
});
votingEmitter.on('upvote', () => {
    userProfile.addVote();
});
votingEmitter.on('downvote', () => {
    userProfile.removeVote();
});
postEmitter.on('new post', (post) => {
    database.addPost(post);
});
postEmitter.on('edit post', (post) => {
    database.upsertPost(post);
});
commentEmitter.on('new comment', (comment) => {
    database.addComment(comment.post, comment);
});

为了能够使用我们的发射器,我们需要确保我们的发射器在当前范围内是可访问的。做到这一点的一种方法是创建一个文件来保存所有我们的发射器和附加事件监听器的逻辑。虽然这样大大简化了我们的代码,但我们将创建非常庞大的源代码,这将使未来的开发人员困惑,甚至可能连我们自己也会困惑。为了使我们的代码更模块化,我们可以开始将所有的监听器函数放入它们各自的文件中。考虑以下庞大的源代码:

// index.js
const EventEmitter = require('events');
const userEmitter = new EventEmitter();
const registrationEmitter = new EventEmitter();
const votingEmitter = new EventEmitter();
const postEmitter = new EventEmitter();
const commentEmitter = new EventEmitter();
// Listeners
const updateListener = () => {};
const activationListener = () => {};
const noActivationListener = () => {};
const upvoteListener = () => {};
const downVoteListener = () => {};
const newPostListener = () => {};
const editPostListener = () => {};
const newCommentListener = () => {};
userEmitter.on('update', updateListener);
registrationEmitter.on('user registered:activated', activationListener);
registrationEmitter.on('user registered: not activated', noActivationListener);
votingEmitter.on('upvote', upvoteListener);
votingEmitter.on('downvote', downVoteListener);
postEmitter.on('new post', newPostListener);
postEmitter.on('edit post', editPostListener);
commentEmitter.on('new comment', newCommentListener);

仅仅通过这样做,我们已经大大减少了我们代码的文件大小。但我们可以做得更多。保持我们代码有组织的一种方法是将所有的发射器放在一个文件中,然后在需要时导入它。我们可以通过创建一个名为emitters.js的文件,并将所有的发射器存储在该文件中来实现这一点:

// emitters.js
const EventEmitter = require('events');
const userEmitter = new EventEmitter();
const registrationEmitter = new EventEmitter();
const votingEmitter = new EventEmitter();
const postEmitter = new EventEmitter();
const commentEmitter = new EventEmitter();
module.exports = {
    userEmitter,
    registrationEmitter,
    votingEmitter,
    postEmitter,
    commentEmitter
};

我们在这里所做的是在一个文件中创建所有的发射器,并将该emitter文件设置为导出模块。通过这样做,我们可以将所有的发射器放在一个地方,然后当我们使用发射器时,我们只需导入该文件。这将改变我们的代码如下:

// index.js
// Emitters
const {
    userEmitter,
    registrationEmitter,
    votingEmitter,
    postEmitter,
    commentEmitter
} = require('./emitters.js');
... rest of index.js

现在,当我们导入emitter.js时,我们可以使用对象解构来只选择我们想要的发射器。我们可以在一个文件中拥有多个发射器,然后在需要时选择我们想要的发射器。当我们想要在userEmitter上发出事件时,我们只需将发射器导入我们的代码并发送该事件即可:

const { userEmitter } = require('./emitters.js');
function userAPIHandler(request, response) {
    const payload = request.payload;
    const event = {
        diff: payload.diff
    };
    userEmitter.emit('update', event);
}

我们可以看到,每当我们想要使用userEmitter时,我们只需导入我们的emitter文件。当我们想要附加监听器时,也适用这一点:

const { userEmitter } = require('./emitters.js');
userEmitter.on('update', (diff) => {
    database.update(diff);
})

当我们将我们的发射器分成不同的文件时,我们不仅使我们的代码更小,而且使其更模块化。通过将我们的发射器拉入一个单独的文件,如果我们将来想要访问我们的发射器,那么我们可以很容易地重用该文件。通过这样做,我们不需要在函数中传递我们的发射器,从而确保我们的函数声明不会混乱。

Node.js 内置模块

在前一节中,我们广泛讨论了events模块,并学习了如何使用事件来实现应用程序内的简单通信。events模块是 Node.js 提供的内置模块,这意味着我们不需要使用npm来安装它。在这个模块中,我们将讨论如何使用fspathutil模块。

path

path模块是一个内置模块,提供了一些工具,可以帮助我们处理文件路径和文件名。

path.join(…paths)

Path.join()是一个非常有用的函数,当我们在应用程序中处理目录和文件时。它允许我们将路径连接在一起,并输出一个路径字符串,我们可以在fs模块中使用。要使用join路径,我们可以调用join方法,并为其提供一组路径。让我们看下面的例子:

const currentDir = '/usr/home/me/Documents/project';
const dataDir = './data';
const assetDir = './assets';

如果我们想要访问我们当前目录中的数据目录,我们可以使用path.join函数将不同的路径组合成一个字符串:

const absoluteDataDir = path.join(currentDir, dataDir);

以下是前面代码的输出:

图 9.29:使用 path.join 函数组合不同的路径

图 9.29:使用 path.join 函数组合不同的路径

它还可以处理...,如果你熟悉 POSIX 系统如何表示当前目录和父目录。..表示父目录,而.表示当前目录。例如,以下代码可以给出我们当前目录的父目录的路径:

const parentOfProject = path.join(currentDir, '..');

以下是前面代码的输出:

图 9.30:显示我们当前目录的父目录

图 9.30:显示我们当前目录的父目录

path.parse(path)

当我们想要获取有关文件路径的信息时,我们可以使用path.parse()函数来获取其根目录、基本目录、文件名和扩展名。让我们看下面的例子:

const myData = '/usr/home/me/Documents/project/data/data.json';

如果我们想要解析这个文件路径,我们可以使用path.parse调用myData字符串来获取不同的路径元素:

path.parse(myData);

这将生成以下输出:

图 9.31:使用 path.parse 函数解析的文件路径

图 9.31:使用 path.parse 函数解析的文件路径

在这里,我们可以看到我们的文件路径包括一个文件名,基本名称为data.json。扩展名是.json,文件名是data。它还解析出文件所在的目录。

path.format(path)

在前面的parse函数中,我们成功地将文件路径解析为其各自的组件。我们可以使用path.format将这些信息组合成一个单一的字符串路径。让我们来看一下:

path.format({
    dir: '/usr/home/me/Pictures',
    name: 'me',
    ext: '.jpeg'
});

以下是前面代码的输出:

图 9.32:使用 path.format 将信息组合成单个字符串路径

图 9.32:使用 path.format 将信息组合成单个字符串路径

这给我们提供了从我们提供给它的组件中生成的文件路径。

fs

fs模块是一个内置模块,为您提供 API,以便您可以与主机文件系统进行交互。当我们需要在应用程序中处理文件时,它非常有用。在本节中,我们将讨论如何在我们的应用程序中使用fs模块与asyncawait。稍后,我们将介绍最近添加的fs.promisesAPI,它提供相同的功能,但返回一个 promise 而不是使用回调。

注意

在这一部分,我们将使用 POSIX 系统。如果你使用的是 Windows 系统,请确保将文件路径更新为 Windows 的等价物。要将 fs 模块导入到你的代码中,执行以下命令:

const fs = require('fs');

fs.createReadStream(path, options)

当我们在 Node.js 中处理大文件时,建议始终使用stream。要创建一个读取流,我们可以调用fs.createReadStream方法。它将返回一个流对象,我们可以将其附加到事件处理程序,以便它们获取文件的内容:

const stream = fs.createReadStream('file.txt', 'utf-8');

fs.createWriteStream(path, options)

这与createReadStream类似,但是创建了一个可写流,我们可以用它来流式传输内容:

const stream = fs.createWriteStream('output', 'utf-8');

fs.stat(path, callback)

当我们需要关于我们正在访问的文件的详细信息时,fs.stat方法非常有用。我们还看到许多开发人员在调用、打开、读取或写入数据之前使用fs.stat来检查文件的存在。虽然使用stat检查文件的存在不会创建任何新问题,但不建议这样做。我们应该只使用从我们使用的函数返回的错误;这将消除任何额外的逻辑层,并可以减少 API 调用的数量。

考虑以下例子:

const fs = require('fs');
fs.stat('index.js', (error, stat) => {
    console.log(stat);
});

这将给我们一个类似以下的输出:

图 9.33:使用 fs.stat 方法后的输出

图 9.33:使用 fs.stat 方法后的输出

fs.readFile(path, options, callback)

这是大多数人熟悉的函数。当提供文件路径时,该方法将尝试以异步方式读取文件的整个内容。它将以异步方式执行,并且回调将被调用以获取文件的整个内容。当文件不存在时,回调将被调用以获取错误。

考虑以下例子:

const fs = require('fs');
fs.readFile('index.js', (error, data) => {
    console.log(data);
});

这将给我们以下输出:

图 9.34:使用 fs.readFile 函数读取文件的整个内容

图 9.34:使用 fs.readFile 函数读取文件的整个内容

这没有输出我们想要的结果。这是因为我们没有在选项中提供编码;要将内容读入字符串,我们需要提供编码选项。这将改变我们的代码为以下内容:

fs.readFile('index.js', 'utf-8', (error, data) => {
    console.log(data);
});

现在,当我们运行上述代码时,它会给我们以下输出:

图 9.35:使用 fs.readFile 函数读取文件的整个内容后编码

图 9.35:使用 fs.readFile 函数读取文件的整个内容后编码

我们刚刚做了一个输出自身的程序。

fs.readFileSync(path, options)

这个函数和readFile方法做的事情一样,但是以同步的方式执行read函数,这意味着它会阻塞执行。在程序启动期间,建议 - 并且期望 - 只调用一次。当需要多次调用时,不建议使用同步函数。

fs.writeFile(file, data, options, callback)

writeFile函数将数据写入我们指定的文件。它还将替换现有的文件,除非你在选项中传递一个附加的flag

fs.writeFileSync()

就像readFileSync一样,它和它的非同步对应物做的事情一样。它们之间的区别在于这个是同步执行操作。

练习 69:Fs 模块的基本用法

在这个练习中,我们将使用fs模块来读取和写入应用程序中的文件。我们将使用我们在前一节中介绍的方法,并将它们与回调一起使用。然后,我们将对它们进行promisify,这样我们就可以用asyncawait来使用它们。

执行以下步骤完成这个练习:

  1. 创建一个名为test.txt的新文件:
fs.writeFile('test.txt', 'Hello world', (error) => {
    if (error) {
        console.error(error);
        return;
    }
    console.log('Write complete');
});

如果你做对了,你会看到以下输出:

图 9.36:创建新的 test.txt 文件

图 9.36:创建新的 test.txt 文件

你应该能够在与源代码相同的目录中看到新文件:

图 9.37:在与源代码相同的目录中创建新文件

图 9.37:在与源代码相同的目录中创建新文件
  1. 读取其内容并在控制台中输出:
fs.readFile('test.txt', 'utf-8', (error, data) => {
    if (error) {
        console.error(error);
    }
    console.log(data);
});

这只是简单地读取我们的文件;我们提供了一个编码,因为我们希望输出是一个字符串而不是一个缓冲区。这将给我们以下输出:

图 9.38:使用 fs.readFile 读取文件内容

图 9.38:使用 fs.readFile 读取文件内容
  1. 尝试读取一个不存在的文件:
fs.readFile('nofile.txt', 'utf-8', (error, data) => {
    if (error) {
        console.error(error);
    }
    console.log(data);
});

当我们尝试打开一个不存在的文件时,我们的回调将会被调用并出现错误。建议我们在处理任何与文件相关的错误时,应该在处理程序内部处理,而不是创建一个单独的函数来检查它。当我们运行上述代码时,将会得到以下错误:

图 9.39:尝试读取不存在的文件时抛出的错误

图 9.39:尝试读取不存在的文件时抛出的错误
  1. 让我们创建自己的带有 promise 的readFile版本:
function readFile(file, options) {
    return new Promise((resolve, reject) => {
        fs.readFile(file, options, (error, data) => {
            if (error) {
                return reject(error);
            }
            resolve(data);
        })
    })
}

这与我们可以使用任何基于回调的方法做的事情是一样的,如下所示:

readFile('test.txt', 'utf-8').then(console.log);

这将生成以下输出:

图 9.40:使用基于回调的方法创建 readFile

图 9.40:使用基于回调的方法创建 readFile
  1. 让我们使用文件stat来获取有关我们文件的信息。在 Node.js 10.0.0 之后,引入了fsPromises,因此我们可以简单地导入fsPromise并调用 promise 的对应项,而不是手动将它们转换为 promise 并手动返回函数:
const fsPromises = require('fs').promises;
fsPromises.stat('test.txt').then(console.log);

这将生成以下输出:

图 9.41:通过导入 fspromise 调用 promise 的对应项

图 9.41:通过导入 fspromise 调用 promise 的对应项

在这里,你可以获取有关我们文件的大小、创建时间、修改时间和权限信息。

在这个练习中,我们介绍了fs模块的一些基本用法。这是 Node.js 中一个非常有用的模块。接下来,我们将讨论如何在 Node.js 中处理大文件。

在 Node.js 中处理大文件

在上一个练习中,我们讨论了如何使用fs模块在 Node.js 中读取文件内容。当处理小于 100MB 的小文件时,这很有效。当处理大文件(> 2GB)时,有时使用fs.readFile无法读取整个文件。考虑以下情况。

你得到了一个 20GB 的文本文件,你需要逐行处理文件中的数据,并将输出写入输出文件。你的计算机只有 8GB 的内存。

当你使用fs.readFile时,它会尝试将文件的整个内容读入计算机的内存中。在我们的情况下,这是不可能的,因为我们的计算机没有足够的内存来容纳我们正在处理的文件的整个内容。在这里,我们需要一个单独的方法来解决这个问题。为了处理大文件,我们需要使用流。

流是编程中一个有趣的概念。它将数据视为不是单一的内存块,而是来自源的数据流,每次一个数据块。这样,我们就不需要将所有数据都放入内存中。要创建一个文件流,我们只需使用fs模块中提供的方法:

const fs = require('fs');
const stream = fs.createReadStream('file.txt', 'utf-8');

通过使用fs.createReadStream,我们创建了一个文件流,以便稍后可以获取文件的内容。我们像使用fs.readFile一样调用这个函数,传入文件路径和编码。与fs.readFile的区别在于,这不需要提供回调,因为它只是返回一个stream对象。要从流中获取文件内容,我们需要将事件处理程序附加到stream对象上:

stream.on('data', data => {
    // Data will be the content of our file
    Console.log(data);
    // Or
    Data = data + data;
});

data事件的事件处理程序中,我们将获得文件的内容,并且当流读取文件时,此处理程序将被多次调用。当我们完成读取文件时,流对象还会发出一个事件来处理此事件:

stream.on('close', () => {
    // Process clean up process
});

Util

Util是一个包含许多有助于 Node.js 内部 API 的函数的模块。这些也可以在我们自己的开发中很有用。

util.callbackify(function)

这在我们处理现有的基于回调的遗留代码时非常有用。要将我们的async函数用作基于回调的函数,我们可以调用util.callbackify函数。让我们考虑以下示例:

async function outputSomething() {
    return 'Something';
}
outputSomething().then(console.log);

以下是前面代码的输出:

图 9.42:将 async 函数用作基于回调的函数

图 9.42:将 async 函数用作基于回调的函数

要将此async函数与回调一起使用,只需调用callbackify

const callbackOutputSomething = util.callbackify(outputSomething);

然后,我们可以这样使用它:

callbackOutputSomething((err, result) => {
    if (err) throw err;
    console.log('got result', result);
})

这将生成以下输出:

图 9.43:通过调用 callbackify 函数使用 async 函数

图 9.43:通过调用 callbackify 函数使用 async 函数

我们已成功将async函数转换为使用回调的遗留函数。当我们需要保持向后兼容性时,这非常有用。

util.promisify(function)

util模块中还有一个非常有用的方法,可以帮助我们将基于回调的函数转换为promisify函数。该方法以一个函数作为参数,并将返回一个返回 promise 的新函数,如下所示:

function callbackFunction(param, callback) {
    callback(null, 'I am calling back with: ${param}');
}

callbackFunction接受一个参数,并将使用我们提供的新字符串调用回调函数。要将此函数转换为使用 promises,我们可以使用promisify函数:

const promisifiedFunction = util.promisify(callbackFunction);

这将返回一个新函数。稍后,我们可以将其用作返回 promise 的函数:

promisifiedFunction('hello world').then(console.log);

以下是前面代码的输出:

图 9.44:使用 promisify 函数进行回调

图 9.44:使用 promisify 函数进行回调

util模块中还有许多类型检查方法,在我们尝试确定应用程序中变量类型时非常有用。

Timer

计时器模块为我们提供了一个用于调度计时器函数的 API。我们可以使用它在代码的某些部分设置延迟,或者在所需的间隔执行我们的代码。与之前的模块不同,不需要在使用之前导入timer模块。让我们看看 Node.js 提供的所有计时器函数以及如何在我们的应用程序中使用它们。

setInterval(callback, delay)

当我们想要设置一个在 Node.js 中重复执行的函数时,我们可以使用setInterval函数,并提供一个回调和延迟。要使用它,我们调用setInterval函数,并提供我们想要运行的函数以及以毫秒为单位的延迟。例如,如果我们想每秒打印相同的消息,我们可以这样实现:

setInterval(() => {
    console.log('I am running every second');
}, 1000);

当我们运行前面的代码时,将看到以下输出:

图 9.45:使用 setInterval 函数设置重复执行的函数

图 9.45:使用 setInterval 函数设置重复执行的函数

在这里,我们可以看到消息每秒打印一次。

setTimeout(callback, delay)

使用此函数,我们可以设置一次性延迟调用函数。当我们想要在运行函数之前等待一定的时间时,我们可以使用setTimeout来实现这一点。在前面的部分中,我们还使用setTimeout来模拟测试中的网络和磁盘请求。要使用它,我们需要传递我们想要运行的函数和以毫秒为单位的延迟整数。如果我们想在 3 秒后打印一条消息,我们可以使用以下代码:

setTimeout(() => {
    console.log('I waited 3 seconds to run');
}, 3000);

这将生成以下输出:

图 9.46:使用 setTimeout 函数设置一次延迟调用函数

图 9.46:使用 setTimeout 函数设置一次延迟调用函数

您将看到消息在 3 秒后打印出。当我们需要延迟调用函数或只想在测试中使用它模拟 API 调用时,这非常有用。

setImmediate(callback)

通过使用这种方法,我们可以推送一个函数,在事件循环结束时执行。如果您想在当前事件循环中的所有内容完成运行后调用某段代码,可以使用setImmediate来实现这一点。看一下以下示例:

setImmediate(() => {
    console.log('I will be printed out second');
});
console.log('I am printed out first');

在这里,我们创建了一个函数,打印出I will be printed out second,它将在事件循环结束时执行。当我们执行这个函数时,我们将看到以下输出:

图 9.47:使用 setimmediate 推送到事件循环结束时执行的函数

图 9.47:使用 setimmediate 推送到事件循环结束时执行的函数

我们也可以通过使用setTimeout并将0作为延迟参数来实现相同的效果:

setTimeout(() => {
    console.log('I will be printed out second');
}, 0);
console.log('I am printed out first');

clearInterval(timeout)

当我们使用setInterval创建一个重复的函数时,该函数还会返回表示计时器的对象。当我们想要停止间隔运行时,我们可以使用clearInterval来清除计时器:

const myInterval = setInterval(() => {
    console.log('I am being printed out');
}, 1000);
clearInterval(myInterval);

当我们运行上述代码时,我们将看到没有输出产生,因为我们清除了刚刚创建的间隔,并且它从未有机会运行:

图 9.48:使用 clearInterval 函数停止间隔运行

图 9.48:使用 clearInterval 函数停止间隔运行

如果我们想要运行这个间隔 3 秒,我们可以将clearInterval包装在setTimeout内,这样它将在3.1秒后清除我们的间隔。我们额外给了 100 毫秒,因为我们希望第三次调用发生在我们清除间隔之前:

setTimeout(() => {
    clearInterval(myInterval);
}, 3100);

当我们运行上述代码时,我们将看到我们的输出打印出 3 次:

图 9.49:使用 setTimeout 在指定的秒数内包装 clearInterval

图 9.49:使用 setTimeout 在指定的秒数内包装 clearInterval

当我们处理多个预定计时器时,这非常有用。通过清除它们,我们可以避免内存泄漏和应用程序中的意外问题。

活动 14:构建文件监视器

在这个活动中,我们将使用定时器函数创建一个文件监视器,该监视器将指示文件中的任何修改。这些定时器函数将在文件上设置监视,并在文件发生更改时生成输出。让我们开始吧:

  • 我们需要创建一个fileWatcher类。

  • 将创建一个带有要监视的文件的文件监视器。如果没有文件存在,它将抛出异常。

  • 文件监视器将需要另一个参数来存储检查之间的时间。

  • 文件监视器需要允许我们移除对文件的监视。

  • 文件监视器需要在文件更改时发出文件更改事件。

  • 当文件更改时,文件监视器将发出带有文件新内容的事件。

打开filewatcher.js文件,并在该文件中进行您的工作。执行以下步骤以完成此活动:

  1. 导入我们的库;即fsevents

  2. 创建一个文件监视器类,该类扩展了EventEmitter类。使用modify时间戳来跟踪文件更改。

  3. 创建startWatch方法以开始监视文件的更改。

  4. 创建stopWatch方法以停止监视文件的更改。

  5. 在与filewatch.js相同的目录中创建一个test.txt文件。

  6. 创建一个FileWatcher实例并开始监视文件。

  7. 修改test.txt中的一些内容并保存。

  8. 修改startWatch以便还检索新内容。

  9. 修改startWatch,使其在文件被修改时发出事件,并在遇到错误时发出错误。

  10. fileWatcher中附加事件处理程序以处理错误和更改。

  11. 运行代码并修改test.txt以查看结果。

注意

这个活动的解决方案可以在第 620 页找到。

如果您看到前面的输出,这意味着您的事件系统和文件读取完全正常。请随意扩展这个功能。您也可以尝试启用监视整个文件夹或多个文件。在这个活动中,我们只是使用文件系统模块和事件驱动编程创建了一个简单的fileWatcher类。使用这个帮助我们创建了一个更小的代码库,并在直接阅读代码时给了我们更多的清晰度。

总结

在本章中,我们讨论了 JavaScript 中的事件系统,以及如何使用内置的events模块来创建我们自己的事件发射器。后来,我们介绍了一些有用的内置模块及其示例用法。使用事件驱动编程可以帮助我们避免在编写需要多个组件相互通信的程序时出现交织的逻辑。此外,通过使用内置模块,我们可以避免添加提供相同功能的模块,并避免创建具有巨大依赖关系的项目。我们还提到了如何使用定时器来控制程序执行,使用fs来操作文件,以及使用path来组合和获取有关我们文件路径的有用信息。这些都是非常有用的模块,可以在构建应用程序时帮助我们。在下一章中,我们将讨论如何在 JavaScript 中使用函数式编程。

第十一章:使用 JavaScript 进行函数式编程

学习目标

在本章结束时,您将能够:

  • 在 Redux 减速器和选择器中使用纯函数

  • 解决高级函数测试情况

  • 在现代 JavaScript 应用程序中应用柯里化、部分应用和闭包

  • 为在使用微服务构建的前端后端(BFF)中使用组合函数

  • 应用 JavaScript 内置函数以在 Redux 应用程序中以不可变的方式编写

  • 在 BFF 的上下文中使用 GraphQL 实现查询和变异

  • 从三种方法中选择处理 React/Redux 应用程序中的副作用

在本章中,您将学习函数式编程的概念,如何在 JavaScript 中应用它们,并在流行的库(如 React、Redux)和系统(如 GraphQL 查询语言)中识别它们。

介绍

函数式编程在数学函数的定义上有很大依赖。数学函数是通过声明表达式定义的。函数式编程风格也是声明式的(与命令式编程相对),并且提倡表达式而不是语句。

JavaScript 中内置了函数式编程构造。解锁 JavaScript 中的函数式编程风格对于更深入地理解语言及其生态系统至关重要。

作为每个部分的一部分,将使用 React、Redux 和 JavaScript 中的 DOM 访问和测试模式来说明 JavaScript 中实用的函数式编程。还将包括最近的发展,如 GraphQL 和前端后端BFFs),以展示函数式编程如何渗透到 JavaScript 编程语言的现在和未来。

函数式编程概念可以解释为什么 Redux 减速器和 React 渲染函数不能包含 API 调用。很多 JavaScript 模式和最佳实践都是由语言中的函数式构造实现的;利用函数式编程可以实现更具表现力、简洁的 JavaScript 程序,更易于理解、修改和扩展。

函数-一流公民

函数作为一流意味着它们被语言视为与任何其他“值”类型一样。这意味着在 JavaScript 中,函数可以像数字、字符串、布尔值、数组、对象等一样使用。

注意

现在可能是时候看看每个人对 JavaScript 数据类型有多熟练了。原始数据类型包括布尔值、空值、未定义值、数字、(大整数)、字符串、符号、对象 à 数组/集合/映射。它们可以在对象数据类型下找到。

一流函数-成熟的 JavaScript 构建模块

定义一流支持的另一种方式是“如果函数是常规值,则函数是一流的”。这意味着函数可以被赋值(作为值)给一个变量,作为参数传递给其他函数,并且可以是另一个函数的返回值。让我们尝试用代码示例来理解前面的概念。

在 JavaScript 中,函数可以被赋值给变量,并应用于函数表达式(如下所示)和箭头函数。变量可以保存对已定义函数的引用,或者内联声明的函数。函数可以是命名的,也可以是匿名的:

const fn = function run() {
  return 'Running...';
};
function fnExpression() {}
const otherFn = fnExpression;
const anonymousArrow = () => {};

函数可以作为数组中的值:

const fn = () => {};
const operations = [
  fn,
  function() {
    console.log('Regular functions work');
  },
  () => console.log('Arrows work too')
];

函数可以作为对象中的值。此示例使用 ECMAScript 6/2015 的简写属性和方法。我们还断言Module.fn的输出与fn的输出相同:

const fn = () => 'Running...';
const Module = {
  fn,
  method1() {},
  arrow: () => console.log('works too')
};
console.assert(Module.fn() === 'Running...');

一个函数可以作为参数传递给另一个函数:

const fn = () => 'Running...';
function runner(fn) {
  return fn();
}
console.assert(runner(fn) === 'Running...');

使用一流函数的控制反转

在 JavaScript 中拥有一流函数意味着注入依赖可以像传递函数一样小。

在函数不是一等公民的语言中,我们可能需要将一个对象(类的实例)传递给构造函数,以便将依赖项注入到该依赖项的消费者中。在 JavaScript 中,我们可以利用函数是一等公民这一事实,简单地注入函数实现。这种情况的最简单示例来自前面的runner函数。它调用作为参数传递给它的任何函数。

这种依赖关系在 JavaScript 中非常有用。类型是动态的,往往不受检查。类和类类型的好处,比如检查错误和方法重载,在 JavaScript 中并不存在。

JavaScript 函数具有简单的接口。它们被调用时带有零个或多个参数,并引起副作用(网络请求、文件 I/O)和/或输出一些数据。

在没有类型或类型检查的依赖注入场景中,传递单个函数而不是整个实例对于依赖项的消费者(依赖项注入的代码)非常有益。

以下示例说明了一种情景,即 JavaScript 应用程序可以在客户端和服务器上运行。这称为通用 JavaScript 应用程序,即在 Node.js 和浏览器中都运行的 JavaScript 程序。通用 JavaScript 通常通过构建工具和依赖注入等模式的组合来实现。

在这种情况下,当服务器端进行 HTTP 调用时,使用基于头的授权机制。当从客户端进行 HTTP 调用时,使用基于 cookie 的授权机制。

看下面的函数定义:

function getData(transport) {
  return transport('https://hello-world-micro.glitch.me').then(res => res.text())
}

消耗getData的服务器端代码如下所示,其中创建了一个axios函数实例来默认授权头。然后将此函数实例作为transport传递给getData

const axios = require('axios');
const axiosWithServerHeaders = axios.create({
  headers: { Authorization: 'Server-side allowed' }
});
getData(axiosWithServerHeaders);

消耗getData的客户端代码如下所示。再次创建了一个axios函数实例,这次使用了withCredentials选项(启用了发送/接收 cookie):

import axios from 'axios';
const axiosWithCookies = axios.create({
  withCredentials: true
})
getData(axiosWithCookies);

前面的示例展示了我们如何利用一等函数支持来在不同的 JavaScript 环境中运行的应用程序之间共享代码,方法是委托 HTTP 请求的传输机制的实现。将函数作为参数传递是 JavaScript 中依赖注入的惯用方式。

在 JavaScript 中启用异步 I/O 和事件驱动编程的功能

I/O,即非阻塞,以及 JavaScript 事件循环是 JavaScript 在基于浏览器的应用程序和最近的 Node.js 服务器端应用程序中受欢迎的核心。JavaScript 是单线程的,这意味着很容易理解。在 JavaScript 程序中几乎不可能找到竞争条件和死锁。

JavaScript 的异步编程模型是非阻塞交互的输入和输出机制,这意味着如果程序受到 I/O 限制,JavaScript 是处理它的一种非常有效的方式。JavaScript 不会等待 I/O 完成;相反,它会使用事件循环安排代码在 I/O 完成时恢复执行。

对于事件驱动编程,函数是需要在以后执行的逻辑的轻量级容器。JavaScript 中的函数和事件驱动编程导致了诸如addEventListener Web API、Node.js 错误优先回调以及随后在 ECMAScript 6/ECMAScript 2015 中移动到 A+ Promise 兼容规范等模式。

这里的所有模式都公开了一个接受函数作为其参数之一的函数。

addEventListener Web API 允许在浏览器中运行的 JavaScript 程序在 DOM 元素上发生事件时执行函数;例如,我们可以监听scrollclick或键盘事件。以下示例将在滚动时打印Scrolling…。它应该在浏览器 JavaScript 环境中运行:

document.addEventListener('scroll', () => {
  console.log('Scrolling...');
});

Node.js 使用错误优先回调来处理其暴露的任何 I/O API。下面的例子展示了如何处理来自 Node.js 文件系统模块 fs 的错误。传递的回调始终具有一个错误属性作为其第一个参数。当没有错误时,此错误为 nullundefined,如果发生错误,则具有一个 Error 值:

const fs = require('fs');
fs.readdir('.', (err, data) => {
  // Shouldn't error
  console.assert(Boolean(data));
  console.assert(!err);
});
fs.readdir('/tmp/nonexistent', (err, data) => {
  // Should error
  console.assert(!data);
  console.assert(Boolean(err));
});

Web Fetch API 提供了 A+ Promise 实现。A+ Promise 是一个封装了异步逻辑并具有 .then.catch 函数的对象,这些函数接受一个函数作为参数。与 Node.js 的错误优先回调方法相比,Promise 是 JavaScript 中抽象 I/O 的一种更近期和更高级的方式。Fetch API 在 Node.js 中不是原生可用的;然而,它作为一个 npm 模块可用于在 Node.js 中使用。这意味着以下代码在 Node.js 中可用:

const fetch = require('node-fetch');
fetch('https://google.com')
  .then(response => {
    console.assert(response.ok);
  })
  .catch(error => {
    // Shouldn't error
    console.assert(false);
    console.error(error.stack);
  });

更近期的 Node.js 版本(10+)为其一些 API 暴露了 Promise 接口。以下代码等同于之前的文件系统访问和错误处理,但使用了 Promise 接口而不是错误优先回调:

const fs = require('fs').promises;
fs.readdir('.')
  .then(data => {
    console.assert(Boolean(data));
  })
  .catch(() => {
    // Shouldn't error
    console.assert(false);
  });
fs.readdir('/tmp/nonexistent')
  .then(() => {
    // Should error
    console.assert(false);
  })
  .catch(error => {
    // Should error
    console.assert(Boolean(error));
  });

JavaScript 内置的数组方法展示了一流函数的支持

JavaScript 自带了一些数组对象的内置方法。其中许多方法展示了一流函数的支持。

Array#map 函数返回传递的函数应用于每个元素后的输出数组。下面的例子展示了一个常见的用例,即通过提取每个元素的特定对象键将对象数组转换为原始值数组。在这种情况下,对象的 id 属性在新数组中返回:

const assert = require('assert').strict
assert.deepStrictEqual(
  [{id: '1'}, {id: '2'}].map(el => el.id),
  ['1', '2']
);

Array#filter 函数返回数组中使得作为参数传递的函数返回真值的元素。在下面的例子中,我们过滤掉任何小于或等于 2 的元素:

const assert = require('assert').strict
assert.deepStrictEqual(
 [1, 2, 3, 4, 5].filter(el => el > 2),
 [3, 4, 5]
);

Array#reduce 函数接受一个函数参数,对每个元素使用累加器和当前元素值调用该函数。reduce 返回传递的函数参数的最后输出。它用于改变数组的形状,例如对数组中的每个元素求和:

console.assert([2, 4].reduce((acc, curr) => acc + curr) === 6);

Array#flatMap 函数返回作为参数传递的函数应用于数组中每个元素后的扁平化输出。下面的例子中,新数组的长度是初始数组的两倍,因为我们为 flatMap 返回了一对值,以便扁平化成一个数组:

const assert = require('assert').strict
assert.deepStrictEqual(
  [1, 2, 3, 4, 5, 6].flatMap(el => [el, el + 1]),
  [ 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7 ]
);

注意

flatMap 是一个第四阶段的特性,在 Node.js 11+ 中可用,并且在 Chrome 69+、Firefox 62+ 和 Safari 12+ 中原生支持。

Array#forEach 函数对数组中的每个元素调用传递的函数。它相当于一个 for 循环,但无法中断。传递的函数将始终对每个元素调用:

let sum = 0;
[1, 2, 3].forEach(n => {
  sum += n;
});
console.assert(sum === 6);

Array#find 函数对数组中的每个元素调用传递的函数,直到函数返回真值为止,此时返回该值,或者没有更多元素可以调用函数时返回 undefined

console.assert(['a', 'b'].find(el => el === 'c') === undefined);

Array#findIndex 函数对数组中的每个元素调用作为参数传递的函数,直到函数返回真值为止,此时返回该元素的索引,或者没有更多元素可以调用函数时返回 -1

console.assert(['a', 'b'].findIndex(el => el === 'b') === 1);

Array#every 函数对数组中的每个元素调用传递的函数。在每次迭代中,如果传递的函数返回 false 值,.every 将中断并返回 false。如果 .every 在不让传递的函数返回 false 值的情况下到达数组末尾,它将返回 true

console.assert([5, 6, 7, 8].every(el => el > 4));

Array#some 函数在数组的每个元素上调用传递的函数。在每次迭代中,如果传递的函数返回一个真值,.some 就会中断并返回 true。如果 .some 在不传递函数作为参数返回真值的情况下到达数组的末尾,它就会返回 false

console.assert([0, 1, 2, 5, 6, 7, 8].some(el => el < 4));

Array#sort 函数调用传递的函数来对数组进行排序。传递的函数会用数组的两个元素(我们将称之为 ab)来调用。如果它返回大于 0 的值,a 将出现在排序后的数组中的 b 之前。如果比较函数返回小于 0 的值,b 将出现在排序后的数组中的 a 之前。如果比较函数返回等于 0 的值,ab 将按照原始数组中的顺序出现,即相对于彼此:

const assert = require('assert').strict
assert.deepStrictEqual(
  [3, 5, 1, 4].sort((a, b) => (a > b ? 1 : -1)),
  [1, 3, 4, 5]
);

还有其他数组方法,特别是那些对非函数参数进行操作的方法。这是展示支持传递函数的方法有多么强大的好方法。

练习 70:使用 some、findIndex 和 reduce 重新实现 includes、indexOf 和 join

在这个练习中,你将使用数组方法 Array#someArray#findIndexArray#reduce 来重新实现 Array#includesArray#indexOfArray#join,利用一级函数支持。它们是原始方法的更强大版本。

npm run Exercise70 的最终输出应该让所有断言都通过。这意味着我们现在有了符合以下断言的 includesindexOfjoin 函数:

  • 如果值在数组中,includes 应该返回 true。

  • 如果值在数组中,includes 应该返回 false。

  • 如果值在数组中,indexOf 应该返回索引。

  • 如果值不在数组中,indexOf 应该返回 -1

  • join 应该不带分隔符工作。

  • join 应该使用逗号作为分隔符。

注意

在这个练习中,我们将在起始文件 exercise-re-implement-array-methods-start.js 中为这些方法编写测试和骨架。可以使用 node exercise-re-implement-array-methods-start.js 运行这个文件。这个命令已经在 npm 脚本中别名为 npm run Exercise70

执行以下步骤完成这个练习:

  1. 将当前目录更改为 Lesson10。这样我们就可以使用预先映射的命令来运行我们的代码。现在,运行 npm run Exercise70 命令(或 node exercise-re-implement-array-methods-start.js):

注意

npm 脚本是在 package.jsonscripts 部分定义的。可以使用 npm run Exercise70.js 运行这个练习的工作解决方案。文件在 GitHub 上。

图 10.1:运行 npm run exercise1 的初始输出

图 10.1:运行 npm run Exercise70 的初始输出

这些错误表明提供的测试当前失败,因为实现不符合预期(因为它们目前什么也不做)。

  1. exercise-re-implement-array-methods-start.js 中实现 includes
function includes(array, needle) {
  return array.some(el => el === needle);
}

有一个我们将替换的 includes 骨架。我们可以用来实现 includes 的函数是 .some。我们将检查数组的任何/一些元素是否等于 needle 参数。

  1. 运行 npm run Exercise70。这应该给出以下输出,这意味着 includes 按照我们的两个断言正常工作(includes 的断言错误已经消失):图 10.2:实现 includes 后的输出
图 10.2:实现 includes 后的输出

needle 是一个原始类型,所以如果我们需要比较某些东西,el === needle 就足够了。

  1. 使用 .findIndex 来实现 indexOf
function indexOf(array, needle) {
  return array.findIndex(el => el === needle);
}

在这一步之后,运行 npm run Exercise70 应该给出以下输出,这意味着 indexOf 按照我们的两个断言正常工作(indexOf 的断言错误已经消失):

图 10.3:实现 includes 和 indexOf 后的输出

图 10.3:实现包含和 indexOf 后的输出

最后,我们将使用.reduce来实现join。这个函数更难实现,因为reduce是一个非常通用的遍历/累加运算符。

  1. 首先,将累加器与当前元素连接起来:
function join(array, delimiter = '') {
  return array.reduce((acc, curr) => acc + curr);
}
  1. 运行 npm run Exercise70。您将看到“不应传递分隔符”现在通过了:图 10.4:实现包含、indexOf 和天真的连接
图 10.4:实现包含、indexOf 和天真的连接
  1. 除了将累加器与当前元素连接起来,还要在它们之间添加分隔符:
function join(array, delimiter = '') {
  return array.reduce((acc, curr) => acc + delimiter + curr);
}

以下是前面代码的输出:

图 10.5:运行练习后 npm 的最终输出

图 10.5:运行练习后 npm 的最终输出

这个练习展示了支持将另一个函数传递给它们的函数比仅接收原始参数的函数更强大。我们通过使用函数参数的对应项重新实现了原始参数函数来证明这一点。

在下一个练习中,我们将向您展示另一个 JavaScript 用例,用于支持函数参数的数组函数。

练习 71:使用 Map 和 Reduce 计算购物篮的价格

在这个练习中,您将使用数组的mapfilterreduce函数来完成从线项目列表到购物篮总成本的简单转换。

注意

在这个练习中,您将在起始文件exercise-price-of-basket-start.js中有测试和方法的框架。可以使用node exercise-price-of-basket-start.js运行该文件。这个命令已经被别名为 npm 脚本npm run Exercise71。可以在 GitHub 上使用npm run Exercise71文件运行这个练习的工作解决方案。

  1. 将当前目录更改为Lesson10。这样我们就可以使用预先映射的命令来运行我们的代码。运行 npm run Exercise71(或 node exercise-price-of-basket-start.js)。您将看到以下内容:图 10.6:npm 运行的初始输出
图 10.6:npm 运行的初始输出

失败的断言表明,我们的框架实现没有输出它应该输出的内容,因为basket1的内容应该合计为5197basket2的内容应该合计为897。我们可以手动运行这个计算:1 * 199 + 2 * 249951972 * 199 + 1 * 499897

  1. 首先,获取行项目价格,这是通过在totalBasket中的每个项目上进行映射并将item.price乘以item.quantity来完成的:
function totalBasket(basket) {
  return basket.map(item => item.price * item.quantity);
}
console.log(totalBasket(basket1))
console.log(totalBasket(basket2))

运行npm run Exercise71应该给出以下输出:

图 10.7:npm 运行和 totalBasket 的输出,包括在.map 中计算行项目

图 10.7:npm 运行和 totalBasket 的输出,包括在.map 中计算行项目

请注意,断言仍然失败,因为我们没有将行项目价格相加;我们只是返回了一个行项目价格的数组。

  1. 接下来,使用reduce来对累加器和当前行项目价格进行求和,并删除console.log:
function totalBasket(basket) {
  return basket
    .map(item => item.price * item.quantity)
    .reduce((acc, curr) => acc + curr);
}

npm run Exercise71的最终输出不应该有断言错误:

图 10.8:实现 totalBasket 的最终输出

图 10.8:实现 totalBasket 的最终输出

添加reduce步骤对我们用初始map计算的行项目价格进行求和。现在totalBasket返回了basket1basket2的正确总价格,分别为5197897。因此,以下断言现在为真:

  • basket1应该合计为5197

  • basket2应该合计为897

这个练习展示了如何使用 map 和 reduce 首先将对象数组转换为原始值数组,然后从中间数组中聚合数据。

在 React 中进行子父组件通信

流行的 JavaScript 用户界面库 React 利用 JavaScript 中函数的一流特性来实现其组件 API 接口。

组件只能明确地从消费它的组件接收属性。在 React 中,一个组件被另一个组件消费的过程通常被称为渲染,因为它自己的渲染是唯一可以使用另一个组件的地方。

在这种情况下,渲染的父组件(渲染的组件)可以将属性传递给正在渲染的子组件,如下所示:

import React from 'react';
class Child extends React.Component {
  render() {
    return <div>Hello {this.props.who}</div>;
  }
}
class Parent extends React.Component {
  render() {
    return (
      <div>
        <Child who="JavaScript" />
      </div>
    );
  }
}

与其他流行的用户界面库(如 Vue.js 和 Angular)不同,在 Vue.js 中,属性从父级传递到子级,事件从子级发出到父级。在 Angular 中,使用输入绑定将数据从父级传递到子级。父级监听子级发出的事件并对其做出反应。

React 没有公开的构造允许数据传递回父级;只有属性。为了实现子父通信,React 提倡一种模式,即将一个函数作为属性传递给子级。传递的函数在父级上下文中定义,因此可以在父组件中执行其所需的操作,例如更新状态,触发 Redux 动作等:

import React from 'react';
class Child extends React.Component {
  render() {
    return (
      <div>
        <button onClick={this.props.onDecrement}>-</button>
        <button onClick={this.props.onIncrement}>+</button>
      </div>
    );
  }
}
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      count: 0
    };
  }
  increment() {
    this.setState({
      count: this.state.count + 1
    });
  }
  decrement() {
    this.setState({
      count: this.state.count - 1
    });
  }
  render() {
    return (
      <div>
        <p>{this.state.count}</p>
        <Child
          onIncrement={this.increment.bind(this)}
          onDecrement={this.decrement.bind(this)}
        />
      </div>
    );
  }
}

这种模式还暴露了 JavaScript 中一流函数的一个重大问题。当混合类/实例和一流函数时,默认情况下,类实例对象上的函数不会自动绑定到它。换句话说,我们有以下情况:

import React from 'react';
class Child extends React.Component {
  render() {
    return <div>
      <p><button onClick={() => this.props.withInlineBind('inline-bind')}>inline bind</button></p>
      <p><button onClick={() => this.props.withConstructorBind('constructor-bind')}>constructor bind</button></p>
      <p><button onClick={() => this.props.withArrowProperty('arrow-property')}>arrow property</button></p>
    </div>;
  }
}
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      display: 'default'
    };
    this.withConstructorBind = this.withConstructorBind.bind(this);
  }
  // check the render() function
  // for the .bind()
  withInlineBind(value) {
    this.setState({
      display: value
    })
  }
  // check the constructor() function
  // for the .bind()
  withConstructorBind(value) {
    this.setState({
      display: value
    })
  }
  // works as is but needs an
  // experimental JavaScript feature
  withArrowProperty = (value) => {
    this.setState({
      display: value
    })
  }
  render() {
    return (
      <div>
        <p>{this.state.display}</p>
        <Child
          withInlineBind={this.withInlineBind.bind(this)}
          withConstructorBind={this.withConstructorBind}
          withArrowProperty={this.withArrowProperty}
          />
      </div>
    );
  }
}

回调属性对于 React 中任何类型的子父通信都是核心的,因为它们的属性是从父级到子级和从子级到父级的唯一通信方式。下一个活动旨在实现一个onCheckout属性,使Basket组件的消费者在单击 Basket 的结账按钮时可以做出反应。

活动 15:onCheckout 回调属性

在这个活动中,我们将实现一个onCheckout属性,以在结账时显示购物车中商品的数量。

注意

活动 15 配备了一个预配置的开发服务器和起始文件中方法的框架,即activity-on-checkout-prop-start.jsactivity-on-checkout-prop-start.html。可以使用npm run Activity15运行开发服务器。此活动的工作解决方案可以在 GitHub 上使用 npm run Activity15文件运行。

  1. 如果您之前没有在此目录中执行过,将当前目录更改为Lesson10并运行npm installnpm install会下载运行此活动所需的依赖项(React 和 Parcel)。此命令是npx parcel serve activity-on-checkout-prop-start.html的别名。

  2. 访问 http://localhost:1234(或者启动脚本输出的任何 URL)以查看 HTML 页面。

  3. 单击继续结账按钮。您会注意到什么都没有发生。

注意

此活动的解决方案可以在第 625 页找到。

下一个练习将向您展示如何利用状态和属性将产品添加到我们的购物篮中。这个练习的起始代码并不严格与我们在活动结束后完成的代码相同。例如,状态是从 Basket 组件提升到了App组件。

练习 72:向购物篮添加产品

在这个练习中,我们将修改addProduct方法,以在单击添加到购物篮选项时更新购物篮中商品的数量。

注意

练习 72 配备了一个预配置的开发服务器和起始文件中方法的框架,即exercise-add-product-start.jsexercise-add-product-start.html。可以使用npm run Exercise72运行开发服务器。此命令是npx parcel serve exercise-add-product-start.html的别名。可以在 GitHub 上使用npm run Exercise72文件运行此练习的工作解决方案。

  1. 将当前目录更改为Lesson10。如果您以前没有在此目录中这样做,请运行npm install。现在运行npm run Exercise 72。您将看到应用程序启动,如下所示:图 10.9:运行 npm run Exe
图 10.9:运行 npm run Exercise 72 的输出

为了使开发服务器实时重新加载我们的更改并避免配置问题,请直接编辑exercise-add-product-start.js文件。

  1. 转到http://localhost:1234(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.10:浏览器中的初始应用程序
图 10.10:浏览器中的初始应用程序

单击“添加到篮子”时,应用程序崩溃并显示空白 HTML 页面。

  1. 更新“App#addProduct”以修复崩溃。
addProduct(product) {
    this.setState({
      basket: {
        items: this.state.basket.items.concat({
          name: product.name,
          price: product.price,
          quantity: 1
        })
      }
    });
  }

我们不是将篮子的值设置为{},而是使用 JavaScript 数组的concatenate方法来获取篮子中的当前项目(this.state.basket.items)并添加传入的product参数,其数量为 1。

  1. 要找出单击“添加到篮子”时会发生什么,我们需要找到“添加到篮子”按钮的onClick处理程序,然后诊断this.addProduct()调用的问题(篮子被设置为{}):
<button onClick={() => this.addProduct(this.state.product)}>
  Add to Basket
</button>

当我们单击“添加到篮子”按钮时,我们将看到以下内容:

图 10.11:单击后实现添加到篮子

图 10.11:单击一次后实现添加到篮子

当我们再次单击“添加到篮子”时,我们将看到以下内容:

图 10.12:单击 2 次后实现添加到篮子

图 10.12:单击两次后实现添加到篮子

React 中的一级函数渲染属性

渲染属性是一种 React 组件模式,其中组件将整个区域的呈现委托给其父组件。

渲染属性是一个返回 JSX 的函数(因为它需要可呈现)。它往往会使用特定于子级的数据进行调用。然后,实现属性的数据由于呈现 JSX 而使用。这种模式在库作者中非常受欢迎,因为这意味着他们可以专注于实现组件的逻辑,而不必担心如何允许用户覆盖呈现的输出(因为这一切都被委托给用户)。

渲染属性的一个非常简单的例子是将呈现委托给父组件,但操作或数据来自公开渲染属性的组件。ExitComponent包装了“window.close()”功能,但将呈现委托给其renderExit属性:

class ExitComponent extends React.Component {
  exitPage() {
    window.close();
  }
  render() {
    return <div>{this.props.renderExit(this.exitPage.bind(this))}</div>;
  }
}

这意味着,例如,我们的ExitComponent可以用于退出页面上的链接和按钮。

这是ExitButton代码可能看起来像的样子:

class ExitButton extends React.Component {
  render() {
    return (
      <ExitComponent
        renderExit={exit => (
          <button
            onClick={() => {
              exit();
            }}
          >
            Exit Page
          </button>
        )}
      />
    );
  }
}

请注意,实际页面退出逻辑在组件中没有处理;一切都由ExitComponent来实现。按钮的渲染完全在这里处理;ExitComponent不需要知道它。

以下是ExitLink组件可能实现的方式。再次注意,ExitComponent对链接一无所知,ExitLink对关闭窗口一无所知。

class ExitLink extends React.Component {
  render() {
    return (
      <ExitComponent
        renderExit={exit => (
          <a
            onClick={e => {
              e.preventDefault();
              exit();
            }}
          >
            Exit
          </a>
        )}
      />
    );
  }
}

练习 73:使用渲染属性呈现篮子内容

在这个练习中,我们将使用渲染属性将商品呈现到购物篮中,从而使得篮子组件更加灵活。

注意

练习 73 配有预配置的开发服务器和起始文件中方法的骨架,即exercise-render-prop-start.jsexercise-render-prop-start.html。可以使用npm run Exercise73运行开发服务器。此命令是npx parcel serve exercise-render-prop-start.html的别名。可以在 GitHub 上使用npm run Exercise73文件运行此练习的工作解决方案。

执行以下步骤以完成此练习:

  1. 如果您以前没有在此目录中这样做,请将当前目录更改为Lesson10并运行npm installnpm install下载所需的依赖项,以便运行此活动(React 和 Parcel)。现在,运行npm run Exercise73。您将看到应用程序启动,如下所示:图 10.13:运行启动文件后的输出
图 10.13:运行启动文件后的输出

为了使开发服务器实时重新加载我们的更改并避免配置问题,直接编辑exercise-render-prop-start.js文件。

  1. 转到http://localhost:1234(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.14:浏览器中的初始应用程序
图 10.14:浏览器中的初始应用程序
  1. 找到Basket被呈现的地方,并添加一个renderItem属性,这是从项目到 JSX 的函数。这是Basket将用于呈现每个篮子项目的渲染属性的实现:
{this.state.status === 'SHOPPING' && (
  <Basket
    items={this.state.basket.items}
    renderItem={item => (
      <div>
        x{item.quantity} - {item.name} - $
        {(item.price / 100).toFixed(2)} each{' '}
      </div>
    )}
    onCheckout={this.handleCheckout}
  />
)} 
  1. 转到Basket#render方法,并映射每个this.props.items,使用this.props.renderItem来呈现项目:
render() {
  return (
    <div>
      <p>You have {this.props.items.length} items in your basket</p>
      <div>{this.props.items.map(item => this.props.renderItem(item))}</div>
      <button onClick={() => this.props.onCheckout(this.props.items)}>
        Proceed to checkout
      </button>
    </div>
  );
}

要查看我们的更改,我们可以转到浏览器,看看篮子项目是如何呈现的:

图 10.15:渲染篮子项目

图 10.15:渲染篮子项目

我们的Basket组件现在根据组件定义的函数呈现项目。这使得Basket更加强大(它可以呈现项目),但仍然非常可重用。在不同的实例中,我们可以使用具有renderItem属性的Basket,该属性不呈现任何内容,项目的分解,或者篮子项目的单价,例如。

第一类函数和我们所涵盖的模式对于编写符合惯用法的 JavaScript 至关重要。我们在 JavaScript 中利用函数式编程的另一种方式是使用纯函数。

纯函数

纯函数是指没有副作用的函数,对于相同的输入,参数将返回相同的输出值。副作用可以是任何东西,从通过引用传递的参数的值的变异(在 JavaScript 中变异原始值)到变异本地变量的值,或执行任何类型的 I/O。

纯函数可以被认为是数学函数。它只使用输入并且只影响自己的输出。

这是一个简单的纯函数,identity函数,它返回传递给它的任何内容作为参数:

const identity = i => i;

注意没有副作用,也没有参数的变异或创建新变量。这个函数甚至没有主体。

纯函数的优势在于简单易于理解。它们也很容易测试;通常不需要模拟任何依赖关系,因为任何依赖关系都应该作为参数传递。纯函数倾向于操作数据,因为如果数据是它们唯一的依赖关系,它们是不允许有副作用的。这减少了测试表面积。

纯函数的缺点是纯函数从技术上讲不能做任何有趣的事情,比如 I/O,这意味着不能发送 HTTP 请求和数据库调用。

注意

纯函数定义中的一个有趣的空白是 JavaScript 异步函数。从技术上讲,如果它们不包含副作用,它们仍然可以是纯的。实际上,异步函数可能被用于使用await运行异步操作,例如访问文件系统、HTTP 或数据库请求。一个很好的经验法则是,如果一个函数是异步的,它可能使用await来执行某种 I/O,因此它不是纯的。

Redux 减速器和操作

Redux 是一个状态管理库。它对用户施加了一些限制,以提高状态更新的可预测性和代码库的长期可扩展性。

让我们看一个简单的 Redux 计数器实现来突出一些特性:

const {createStore} = require('redux');
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};
const store = createStore(counterReducer);

商店将其状态初始化为 0:

console.assert(store.getState() === 0, 'initalises to 0');

该商店的内部状态只通过getState的只读接口暴露出来。要更新状态,需要分派一个动作。调用dispatchINCREMENTDECREMENT类型表明counterReducer按预期工作,并减少存储中的动作:

store.dispatch({type: 'INCREMENT'});
console.assert(store.getState() === 1, 'incrementing works');
store.dispatch({type: 'DECREMENT'});
console.assert(store.getState() === 0, 'decrementing works');

注意

根据 Redux 文档,Redux 有三个支柱:redux.js.org/introduction/three-principles

Redux 的三个支柱在上面的例子中有所体现。我们有一个具有单一存储的系统,状态是只读的(通过getState访问),并且更改是由我们的减速器进行的,它是一个纯函数。counterReducer接受状态和动作,并返回一个新值,而不会改变stateaction

遵循这些规则,我们可以获得一个可预测且高性能的 JavaScript 应用程序状态容器。单一存储意味着不需要考虑状态存储在哪里;只读状态强制通过分派动作和减少它们来进行更新。由于减速器是纯函数,它们易于测试和推理,因为对于相同的输入它们将产生相同的输出,并且不会引起副作用或不需要的突变。

Redux 用于管理状态。到目前为止,我们一直将数据存储在 React 状态中。

练习 74:Redux 分派动作并将其减少为状态

在这个练习中,我们将把我们的数据状态移到 Redux 中,以便将数据操作和状态更新与呈现数据到页面的代码分离开来。

注意

练习 74 配备了一个预配置的开发服务器和起始文件中方法的骨架,即exercise-redux-dispatch-start.jsexercise-redux-dispatch-start.html。可以使用npm run Exercise74运行开发服务器。可以在 GitHub 上使用npm run Exercise74文件运行此练习的工作解决方案。

执行以下步骤完成此练习:

  1. 如果您以前没有在此目录中执行过此操作,请将当前目录更改为Lesson10并运行npm install。此命令是npx parcel serve exercise-redux-dispatch-start.html的别名。现在,运行npm run Exercise74。您将看到应用程序启动,如下所示:图 10.16:npm run Exercise74 的输出
图 10.16:npm run Exercise74 的输出
  1. 转到http://localhost:1234(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.17:浏览器中的初始 Exercise74 应用程序
图 10.17:浏览器中的初始 Exercise74 应用程序

注意点击按钮没有起作用。

  1. 通过分派CONTINUE_SHOPPING类型的动作来实现App#continueShopping
continueShopping() {
  this.props.dispatch({
    type: 'CONTINUE_SHOPPING'
  });
}
  1. appReducer中,实现相应的状态减少。对于CONTINUE_SHOPPING,我们只需要更改状态中的status,因为这是我们用来显示结账视图或主产品和购物篮视图的内容:
switch(action.type) {
  // other cases
  case 'CONTINUE_SHOPPING':
    return {
      ...state,
      status: 'SHOPPING'
    };
  // other cases
}
  1. 通过分派DONE类型的动作来实现App#finish
finish() {
  this.props.dispatch({
    type: 'DONE'
  });
}
  1. appReducer中,实现相应的状态减少。我们只需要更改状态中的status,因为这是我们用来显示Done视图的内容:
switch(action.type) {
  // other cases
  case 'DONE':
    return {
      ...state,
      status: 'DONE'
    };
  // other cases
}
  1. 通过分派START_CHECKOUT类型的动作来实现handleCheckout
handleCheckout(items) {
  this.props.dispatch({
    type: 'START_CHECKOUT',
    basket: {
      items
    }
  });
}
  1. appReducer中,实现相应的状态减少。对于START_CHECKOUT,我们只需要更改状态中的status,因为这是我们用来显示结账视图或主产品和购物篮视图的内容:
switch(action.type) {
  // other cases
  case 'START_CHECKOUT':
    return {
      ...state,
      status: 'CHECKING_OUT'
    };
  // other cases
}

注意

basket对象没有被减少,因此可以在分派时省略。

  1. 通过以下方式分派一个动作来实现addProduct。对于ADD_PRODUCT,我们需要newProduct,以及动作类型:
addProduct(product) {
  this.props.dispatch({
    type: 'ADD_PRODUCT',
    newProduct: {
      name: product.name,
      price: product.price,
      quantity: 1
    }
  });
}
  1. appReducer中,实现相应的状态减少,将新产品添加到当前商品篮中:
switch(action.type) {
  // other cases
  case 'ADD_PRODUCT':
    return {
      ...state,
      basket: {
        items: state.basket.items.concat(action.newProduct)
      }
    };
  // other cases
}

appReducer完整的应该如下所示:

const appReducer = (state = defaultState, action) => {
  switch (action.type) {
    case 'START_CHECKOUT':
      return {
        ...state,
        status: 'CHECKING_OUT'
      };
    case 'CONTINUE_SHOPPING':
      return {
        ...state,
        status: 'SHOPPING'
      };
    case 'DONE':
      return {
        ...state,
        status: 'DONE'
      };
    case 'ADD_PRODUCT':
      return {
        ...state,
        basket: {
          items: state.basket.items.concat(action.newProduct)
        }
      };
    default:
      return state;
  }
};
  1. 转到http://localhost:1234(或者启动脚本输出的任何 URL)。应用现在应该如预期般响应点击:

图 10.18:应用程序 wo

图 10.18:具有响应点击的应用程序

添加物品到购物篮并浏览应用程序(继续结账,完成,继续购物)应该与 Redux 存储实现之前的行为一样。

测试纯函数

纯函数很容易测试,因为它们是完全封装的。唯一可以改变的是输出,也就是返回值。唯一可以影响输出的是参数/参数值。而且,对于相同的输入集,纯函数的输出需要是相同的。

测试纯函数就像使用不同的输入调用它们并断言输出一样简单:

const double = x => x * 2;
function test() {
  console.assert(double(1) === 2, '1 doubled should be 2');
  console.assert(double(-1) === -2, '-1 doubled should be -1');
  console.assert(double(0) === 0, '0 doubled should be 0');
  console.assert(double(500) === 1000, '500 doubled should be 1000');
}
test();

Redux 减速器是纯函数,这意味着为了测试它们,我们可以使用我们在上一个示例中刚刚看到的方法。

练习 75:测试减速器

在这个练习中,我们将为前一个练习中使用的减速器的一部分编写测试,即appReducerADD_PRODUCT情况。

注意

练习 75 带有测试和起始文件中方法的框架,exercise-reducer-test-start.js。可以使用node exercise-reducer-test-start.js运行文件。这个命令已经被别名为 npm 脚本的npm run Exercise75。这个练习的工作解决方案可以在 GitHub 上使用 npm run exercise6 文件运行。

执行以下步骤完成这个练习:

  1. 将当前目录更改为Lesson10。这样我们可以使用预映射的命令来运行我们的代码。

  2. 现在,运行npm run Exercise75(或node exercise-reducer-test-start.js)。您将看到以下输出:图 10.19:运行启动文件后空测试通过

图 10.19:运行启动文件后空测试通过

这个起始文件中只包含ADD_PRODUCT动作减少的简化的appReducer,还有一个test函数,新的测试将会被添加到这里。输出中没有包含错误,因为我们还没有创建任何测试。

注意

为了获得appReducer的输出,它应该被调用与一个state对象和相关的action。在这种情况下,类型应该是'ADD_PRODUCT'

  1. 与之前的示例一样,我们将使用assert.deepStrictEqual,它检查两个对象的深度相等性。我们可以这样编写一个失败的测试。我们使用state和相关的action调用appReducer
function test() {
  assert.deepStrictEqual(
    appReducer(
      {
        basket: {items: []}
      },
      {
        type: 'ADD_PRODUCT',
        newProduct: {
          price: 499,
          name: 'Biscuits',
          quantity: 1
        }
      }
    ),
    {}
  );
}

如果我们运行npm run Exercise75,我们将看到以下错误。这是预期的,因为appReducer不会返回一个空对象作为状态:

图 10.20:执行启动文件后显示错误

图 10.20:执行启动文件后显示错误
  1. 我们应该使用assert.deepStrictEqual来确保appReducer按预期添加新产品。我们将预期值分配给expected变量,实际值分配给actual变量。这将有助于使测试更可读:
function test() {
  const expected = {
    basket: {
      items: [
        {
          price: 499,
          name: 'Biscuits',
          quantity: 1
        }
      ]
    }
  };
  const actual = appReducer(
    {
      basket: {items: []}
    },
    {
      type: 'ADD_PRODUCT',
      newProduct: {
        price: 499,
        name: 'Biscuits',
        quantity: 1
      }
    }
  );
  assert.deepStrictEqual(actual, expected);
}

输出现在不应该抛出任何错误:

图 10.21:测试通过,因为没有发现错误

图 10.21:测试通过,因为没有发现错误

在运行node exercise-reducer-test.js命令后,以下是输出:

图 10.22:显示断言失败的输出

图 10.22:显示断言失败的输出

Redux 选择器

选择器是 Redux 的另一个概念,这意味着我们可以使用选择器封装内部存储状态形状。选择器的使用者要求它想要的东西;选择器则留给使用存储状态形状特定知识来实现。选择器是纯函数;它们接受存储状态并返回一个或多个部分。

由于选择器是纯函数,它们很容易实现。下面的练习向我们展示了如何使用选择器,以便不是将消息数据放在渲染函数中或在传递 props 时,而是在一个纯函数中进行。

练习 76:实现一个选择器

在这个练习中,我们将使用选择器并利用它们的简单性来将项目呈现到购物篮中。

注意

练习 76 带有预配置的开发服务器和起始文件中方法的框架,即exercise-items-selector-start.jsexercise-items-selector-start.html。可以使用npm run Exercise76运行开发服务器。可以在 GitHub 上使用npm run Exercise76文件运行此练习的工作解决方案。

  1. 将当前目录更改为Lesson10,如果之前在此目录中尚未运行npm install,则运行它。

  2. 运行npx parcel serve exercise-items-selector-start.html并执行npm run Exercise76。您将看到应用程序启动,如下所示:图 10.23:运行起始 html 文件后的输出

图 10.23:运行起始 html 文件后的输出

为了使开发服务器能够实时重新加载我们的更改并避免配置问题,直接编辑exercise-items-selector-start.js文件。

  1. 转到http://localhost:1234(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.24:浏览器中的初始应用程序
图 10.24:浏览器中的初始应用程序

注意没有购物篮项目被呈现。这是因为selectBasketItems的初始实现。它返回一个空数组:

const selectBasketItems = state => [];
  1. 通过使用点符号和短路来实现selectBasketItems。如果状态有任何问题,则默认为[]
const selectBasketItems = state =>
  (state && state.basket && state.basket.items) || [];

应用程序现在应该再次按预期工作;项目将被显示:

图 10.25:实现 selectBasketItems 后的应用程序

图 10.25:实现 selectBasketItems 后的应用程序

selectBasketItems选择器获取完整状态并返回其切片(项目)。选择器允许我们进一步将 Redux 存储库内部状态的形状与在 React 组件中使用它的方式分离。

选择器是 React/Redux 应用程序的重要组成部分。正如我们所见,它们允许 React 组件与 Redux 的内部状态形状解耦。以下活动旨在使我们能够为选择器编写测试。这与在先前的练习中测试 reducer 的情况类似。

Activity 16:测试一个选择器

在这个活动中,我们将测试项目数组的各种状态的选择器,并确保选择器返回与购物篮中的项目对应的数组。让我们开始吧:

  1. 将当前目录更改为Lesson10。这样可以使用预映射命令来运行我们的代码。

注意

Activity 16 带有测试和起始文件中方法的框架,即activity-items-selector-test-start.js。可以使用node activity-items-selector-test-start.js运行此文件。此命令已经被别名为 npm 脚本npm run Activity16。可以在 GitHub 上使用npm run Activity16文件运行此练习的工作解决方案。

在测试函数中,使用assert.deepStrictEqual,执行以下操作:

  1. 测试一下,对于空状态,选择器返回[]

  2. 测试一下,对于一个空的购物篮对象,选择器返回[]

  3. 测试一下,如果items数组已设置但为空,则选择器返回[]

  4. 测试一下,如果项目数组不为空并已设置,则选择器返回它。

注意

此活动的解决方案可以在第 626 页找到。

纯函数是可预测的,易于测试和易于理解的。一等函数和纯函数都与下一个 JavaScript 函数式编程概念相关联:高阶函数。

高阶函数

高阶函数是一个要么接受函数作为参数,要么返回函数作为值的函数。

这是建立在 JavaScript 的一级函数支持之上的。在不支持一级函数的语言中,实现高阶函数是困难的。

高阶函数实现了函数组合模式。在大多数情况下,我们使用高阶函数来增强现有的函数。

绑定、应用和调用

Function对象上有一些内置的 JavaScript 方法:bindapplycall

Function#bind允许你为一个函数设置执行上下文。当调用时,bind 返回一个新的函数,其中第一个参数被绑定为函数的this上下文。bind 后面的参数在返回的函数被调用时使用。当绑定的函数被调用时,可以提供参数。这些参数将出现在参数列表中,在调用 bind 时设置参数之后。

在 React 代码中,当传递函数作为 props 时,经常使用 bind 来访问当前组件的this进行操作,比如setState或调用其他组件方法:

import React from 'react';
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      display: 'default'
    };
    this.withConstructorBind = this.withConstructorBind.bind(this);
  }
  // Check the render() function
  // for the .bind()
  withInlineBind(value) {
    this.setState({
      display: value
    });
  }
  // Check the constructor() function
  // for the .bind()
  withConstructorBind(value) {
    this.setState({
      display: value
    });
  }
  render() {
    return (
      <div>
        <p>{this.state.display}</p>
        <Child
          withInlineBind={this.withInlineBind.bind(this)}
          withConstructorBind={this.withConstructorBind}
        />
      </div>
    );
  }
}

Function#bind方法也可以在测试中用于测试函数是否被抛出。例如,运行函数意味着必须编写一个 try/catch,如果 catch 没有触发,则测试失败。使用 bind 和assert模块,可以以更简洁的形式编写:

// Node.js built-in
const assert = require('assert').strict;
function mightThrow(shouldBeSet) {
  if (!shouldBeSet) {
    throw new Error("Doesn't work without shouldBeSet parameter");
  }
  return shouldBeSet;
}
function test() {
  assert.throws(mightThrow.bind(null), 'should throw on empty parameter');
  assert.doesNotThrow(
    mightThrow.bind(null, 'some-value'),
    'should not throw if not empty'
  );
  assert.deepStrictEqual(
    mightThrow('some-value'),
    'some-value',
    'should return input if set'
  );
}
test();

Function#applyFunction#call允许你调用一个函数,而不使用fn(param1, param2, [paramX])的语法,同时以类似Function#bind的方式设置this上下文。Function#apply的第一个参数是this上下文,第二个参数是一个数组或类数组,包含函数期望的参数。类似地,Function#call的第一个参数是this上下文;与Function#apply的区别在于参数的定义。在Function#call中,它们是一个参数列表,就像使用Function#bind时一样,而不是Function#apply期望的数组。

注意

类数组对象,也称为索引集合,其中最常用的是函数中的 arguments 对象和 NodeList Web API,它们是实现了部分 Array API(例如实现.length)但没有完全实现的对象。仍然可以使用 JavaScript 的 apply/call 在它们上面使用数组函数。

Function#applyFunction#call严格来说不符合高阶函数的标准。在某种程度上,因为它们是函数对象的方法,我们可以说它们是隐式的高阶函数。它们被调用的函数对象是 apply/call 方法调用的隐式参数。通过从函数原型中读取,我们甚至可以这样使用它们:

function identity(x) {
  return x;
}
const identityApplyBound = Function.prototype.bind.apply(identity, [
  null,
  'applyBound'
]);
const identityCallBound = Function.prototype.bind.call(
  identity,
  null,
  'callBound'
);
console.assert(
  identityApplyBound() === 'applyBound',
  'bind.apply should set parameter correctly'
);
console.assert(
  identityCallBound() === 'callBound',
  'bind.call should set parameter correctly'
);

在这个例子中,我们展示了 apply 和 call 是高阶函数,但只能用于其他函数上的函数。

Function#applyFunction#call在历史上将类似数组的对象转换为数组。在符合 ECMAScript 2015+的环境中,可以使用spread操作符以类似的方式使用。

以下三个函数允许你使用Function#applyFunction#call和数组展开将类数组对象转换为数组:

function toArrayApply(arrayLike) {
  return Array.prototype.slice.apply(arrayLike);
}
function toArrayCall(arrayLike) {
  return Array.prototype.slice.call(arrayLike);
}
function toArraySpread(arrayLike) {
  return [...arrayLike];
}

柯里化和部分应用

柯里化函数是一个函数,它不是一次性接受它需要的参数数量,而是一次接受一个参数。

例如,如果一个函数接受两个参数,它的柯里化等价物将被调用两次,每次一个参数。

因此,柯里化可以被表达为将一个 n 参数函数转化为一个可以被调用 n 次的函数,每次只有一个参数。n 参数函数的经典称呼是 n 元。因此,柯里化是将一个 n 元函数转化为 n 个一元函数调用的转换:

const sum = (x, y) => x + y;
const sumCurried = x => y => x + y;
console.assert(
  sum(1, 2) === sumCurried(1)(2),
  'curried version works the same for positive numbers'
);
console.assert(
  sum(10, -5) === sumCurried(10)(-5),
  'curried version works the same with a negative operand'
);

部分应用和柯里化经常一起介绍,概念上它们是相辅相成的。

使用柯里化的两参数函数,需要两次调用,每次使用一个参数,每次都执行与两参数非柯里化函数相同的工作。当它被调用一次时,它有一半的必要参数完全应用。从第一次调用中得到的函数是整体函数的部分应用:

const sum = (x, y) => x + y;
const sumCurried = x => y => x + y;
const add1Bind = sum.bind(null, 1);
const add1Curried = sumCurried(1);
console.assert(
  add1Bind(2) === add1Curried(2),
  'curried and bound versions behave the same'
);
console.assert(add1Bind(2) === 3, 'bound version behaves correctly');
console.assert(add1Curried(2) === 3, 'curried version behaves correctly');

换句话说,部分应用是一种表达从接受 n 个参数的函数到接受n - m个参数的函数的转换的方式,其中 m 是已部分应用的参数数量。

如果我们想要能够重用通用功能,则柯里化和部分应用非常有用。部分应用不需要柯里化;柯里化是将函数转换为可以部分应用的函数。也可以使用 bind 进行部分应用。

柯里化和部分应用允许您从一个非常通用的函数开始,并在每次应用时将其转换为更专业的函数。

柯里化在每次调用时标准化参数的数量。部分应用没有这样的限制。您可以一次部分应用多个参数。

一元函数比二元函数更简单,二元函数比 N 元(其中 N > 2)函数更简单。

此外,如果我们只允许一次应用一个参数,那么柯里化会更简单。我们可以看到任意 n 参数部分应用具有更多的运行时复杂性,因为每个函数都需要在是否为最终调用上运行一些逻辑。

在 ES2015 中可以定义通用的 n 元柯里化如下:

const curry = fn => {
  return function curried(...args) {
    if (fn.length === args.length) {
      return fn.apply(this, args);
    }
    return (...args2) => curried.apply(this, args.concat(args2));
  };
};

利用闭包 React 函数组件

在定义函数时,函数定义时作用域中的任何内容在调用/执行时仍然保持在作用域中。历史上,闭包被用于创建私有变量作用域。闭包是这个函数及其记住的定义时作用域:

const counter = (function(startCount = 0) {
  let count = startCount;
  return {
    add(x) {
      count += x;
    },
    substract(x) {
      count -= x;
    },
    current() {
      return count;
    }
  };
})(0);

我们在 React 渲染函数中利用这一点,以在本地渲染范围中缓存 props 和 state。

React 函数组件还利用闭包,特别是使用钩子:

import React from 'react';
function Hello({who}) {
  return <p>Hello {who}</p>;
}
const App = () => (
  <>
    <Hello who="Function Components!" />
  </>
);

函数组件非常强大,因为它们比类组件更简单。

在使用 Redux 等状态管理解决方案时,大部分重要状态都在 Redux 存储中。这意味着我们可以编写主要是无状态的功能组件,因为存储管理应用程序的任何有状态部分。

高阶函数使我们能够有效地处理函数并增强它们。高阶函数建立在一级函数支持和纯函数之上。同样,函数组合建立在高阶函数之上。

函数组合

函数组合是从数学中泄漏出来的另一个概念。

给定两个函数 a 和 b,compose 返回一个新函数,该函数将 a 应用于 b 的输出,然后应用于给定的一组参数。

函数组合是一种从一组较小函数创建复杂函数的方法。

这意味着您可能最终会得到一堆做一件事情的简单函数。具有单一目的的函数更擅长封装其功能,因此有助于关注点分离。

组合函数与柯里化和函数的部分应用相结合,因为柯里化/部分应用是一种允许您拥有通用函数的专业版本的技术,就像这样:

const sum = x => y => x + y;
const multiply = x => y => x * y;
const compose = (f, g) => x => f(g(x));
const add1 = sum(1);
const add2 = sum(2);
const double = multiply(2);

要解释以下代码,我们可以得出以下结论:

  • 将 2 加倍然后加 1 是 5(4 + 1)。

  • 将 1 加到 2 然后加倍是 6(3 * 2)。

  • 将 2 加到 2 然后加倍是 8(4 * 2)。

  • 将 2 加倍然后加 2 是 6(4 + 2)。

以下使用我们已经定义的函数add1add2double,并展示了如何使用compose来实现前面的情况。请注意,compose 首先应用最右边的参数:

console.assert(
  compose(add1, double)(2) === 5
);
console.assert(
  compose(double, add1)(2) === 6
);
console.assert(
  compose(double, add2)(2) === 8
);
console.assert(
  compose(add2, double)(2) === 6
);

定义compose的另一种方法是使用从左到右的遍历(使用reduce)。这样做的好处是在调用组合输出时允许我们传递任意数量的参数。为此,我们从第一个参数减少到最后一个参数,但是reducing的输出是一个支持任意数量参数的函数,并在调用时在当前函数之后调用先前的输出。

以下代码使用参数 rest 来允许任意数量的函数进行组合:

const composeManyUnary = (...fns) => x =>
  fns.reduceRight((acc, curr) => curr(acc), x);

然后,它返回一个接受单个参数x(因此是一元的)的函数。当调用这个第二个函数时,它将从右到左调用传递给composeManyUnary的所有函数(最后一个参数的函数将首先被调用)。reduceRight的第一次迭代将使用x作为其参数调用最右边的函数。接下来的函数将在前一个函数调用的输出上调用。参数列表中倒数第二个函数将使用参数列表中最后一个函数的输出作为x的参数进行调用。参数列表中倒数第三个函数将使用倒数第二个函数的输出,依此类推,直到没有更多的函数可以调用。

练习 77:一个二进制到 n-ary 组合函数

在这个练习中,我们将实现一个 n-ary compose函数,可以用来组合任意数量的函数。

注意

练习 77 带有测试和起始文件中方法的框架,exercise-2-to-n-compose-start.js。可以使用node exercise-2-to-n-compose-start.js运行该文件。该命令已经被别名为 npm 脚本npm run Exercise77。可以在 GitHub 上使用 npm run Exercise77 文件来运行这个练习的工作解决方案。

  1. 将当前目录更改为Lesson10。这样我们可以使用预先映射的命令来运行我们的代码。

  2. 现在,运行npm run Exercise77(或node exercise-to-n-compose-start.js)。您将看到以下输出:图 10.26:运行练习的起始文件

图 10.26:运行练习的起始文件

compose3composeManyUnarycomposeManyReduce的断言都失败了,主要是因为它们当前被别名为compose2

  1. 已经实现了两个函数的compose
const compose2 = (f, g) => x => f(g(x));

compose3是一个天真的三参数compose函数,它先取第三个参数,然后在第一次调用的输出上调用第二个参数。

  1. 最后,它调用第一个参数在第二个参数的输出上,就像这样:
const compose3 = (f, g, h) => x => f(g(h(x)))

注意

参数定义中最右边的函数首先被调用。

考虑参数作为一个数组,并且 JavaScript 有一个reduceRight函数(它从右到左遍历数组,同时保持一个累加器,就像reduce一样),有一个形成的前进路径。

  1. 在实现compose3之后,我们可以再次运行npm run Exercise77,看到compose3的断言不再失败了:图 10.27:实现 compose3 后的输出
图 10.27:实现 compose3 后的输出
  1. 使用参数 rest 来允许任意数量的函数进行组合:
const composeManyUnary = (...fns) => x =>
  fns.reduceRight((acc, curr) => curr(acc), x);
  1. 在实现composeManyUnary之后,相应的失败断言现在通过了:图 10.28:实现 compose3 和 composeManyUnary 后的输出
图 10.28:实现 compose3 和 composeManyUnary 后的输出
  1. 定义compose使用从左到右的遍历(使用reduce):
const composeManyReduce = (...fns) =>
  fns.reduce((acc, curr) => (...args) => acc(curr(...args)));

我们可以使用三个函数fghcomposeManyReduce。我们的实现将通过这些函数开始减少。在第一次迭代时,它将返回一个函数,该函数将接受任意数量的参数(args)。当调用时,它将调用f(g(args))。在第二次迭代中,它将返回一个接受任意数量参数并返回f(g(h(args))的函数。在这一点上,没有更多的函数可以迭代,因此接受一组参数并返回f(g(h(arguments)))的函数的最终输出是composeManyReduce函数的输出。

在实现了composeManyReduce之后,相应的失败断言现在通过了:

图 10.29:实现 compose3、composeManyUnary 和 composeManyReduce

图 10.29:实现 compose3、composeManyUnary 和 composeManyReduce

在现实世界中使用简单的 BFF 进行函数组合

BFF 是一个服务器端组件,以特定于其服务的用户界面的方式包装(API)功能。这与设计用于导出通用业务逻辑的 API 相对。前端后端可能会消耗上游 API 或直接使用后端服务,这取决于架构。公司可能有一组核心服务来实现业务逻辑,然后为其移动应用程序创建一个 BFF,为其 Web 前端创建另一个 BFF,并为其内部仪表板创建最终的 BFF。每个 BFF 都将具有不同的约束和数据形状,这对于它们各自的消费者来说是最合理的。

通用 API 往往具有更大的表面积,由不同的团队维护,并且有多个消费者,这反过来导致 API 的形状演变缓慢。API 端点不特定于用户界面,因此前端应用程序可能必须进行大量的 API 请求才能加载单个屏幕。前端后端可以缓解这些问题,因为每个页面或屏幕可以有自己的端点或数据集。前端后端将协调获取任何相关数据。

为了实现前端后端,将使用micro。micro 是一个用于“异步 HTTP 微服务”的库,由 Zeit 构建。与 Express 或 Hapi 相比,它非常小。为了做到这一点,它利用了现代 JavaScript 特性,如 async/await 调用,其组合模型基于函数组合。也就是说,在 Express 或 Hapi 中的中间件是一个以函数作为参数并返回一个新函数的高阶函数。这是一个很好的使用compose的机会,因为被组合的函数的接口是以函数作为参数和以函数作为返回值。

注意

可以在github.com/zeit/micro找到 micro 的非常简要的文档。该库本身只有几百行 JavaScript 代码。

一个 micro 的“Hello world”可能如下所示。micro 接受一个可以是异步的 HTTP 处理程序函数。无论哪种方式,都会被等待。它没有内置路由器,这是 Express 或 Hapi 公开的核心 API 之一。处理程序的输出作为 HTTP 响应主体发送回去,状态码为 200:

const micro = require('micro');
const server = micro(async () => {
  return '<p>Hello micro!</p>Run this with <code>node example-2-micro-hello.js</code>';
});
server.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

可以使用内置的 JavaScript console.timeconsole.timeEnd函数来添加请求计时器日志:

// handler and server.listen are unchanged
const timer = fn => async (req, res) => {
  console.time('request');
  const value = await fn(req, res);
  console.timeEnd('request');
  return value;
};
const server = micro(timer(hello));

函数组合是前端,而 micro 的中心是 API。添加诸如 API 密钥身份验证之类的更复杂操作并不会使集成变得更加困难。

authenticate函数可以具有任意复杂性。如果它接受一个函数参数并返回一个接受req(请求)和res(响应)对象的函数,它将与其他 micro 包和处理程序兼容:

// handler, timer and server.listen are unchanged
const ALLOWED_API_KEYS = new Set(['api-key-1', 'key-2-for-api']);
const authenticate = fn => async (req, res) => {
  const {authorization} = req.headers;
  if (authorization && authorization.startsWith('ApiKey')) {
    const apiKey = authorization.replace('ApiKey', '').trim();
    if (ALLOWED_API_KEYS.has(apiKey)) {
      return fn(req, res);
    }
  }
  return sendError(
    req,
    res,
    createError(401, `Unauthorizsed: ${responseText}`)
  );
};
const server = micro(timer(authenticate(handler)));

micro 库利用函数组合,以便使每个请求处理级别之间的依赖关系变得明显。

练习 78:利用 Compose 简化微服务器创建步骤

在这个练习中,您将重构前一节中的计时器和身份验证示例,以使用compose

注意

练习 78 带有预配置的服务器和在起始文件中的 run 方法别名,即exercise-micro-compose-start.js。可以使用npm run Exercise78运行服务器。可以在 GitHub 上使用 npm run Exercise78 文件运行此练习的工作解决方案。

执行以下步骤完成此练习:

  1. 将当前目录更改为Lesson10,如果之前没有在此目录中这样做,请运行npm install

  2. 首先运行node exercise-micro-compose-start.js命令。然后运行npm run Exercise78。您将看到应用程序启动,如下所示:图 10.30:运行此练习的 start 文件

图 10.30:运行此练习的 start 文件
  1. 使用以下curl访问应用程序应该会产生未经授权的响应:
curl http://localhost:3000

以下是前面代码的输出:

图 10.31:微应用的 cURL

图 10.31:微应用的 cURL

请注意,compose 函数在此模块中预先填充。

  1. 我们将使用 compose 而不是在上一个函数的输出上调用每个函数,并调用其输出来创建服务器。这将替换服务器创建步骤:
const server = compose(
  micro,
  timer,
  authenticate,
  handler
)();

最初的服务器创建步骤如下,这相当冗长,可能难以阅读。compose版本清楚地显示了请求将经过的管道:

const server = micro(timer(authenticate(handler)));
  1. 重新启动应用程序以使更改生效。一旦npm run Exercise78运行起来,您应该能够curl
curl http://localhost:3000

以下是前面代码的输出:

图 10.32:使用“compose”的微应用的 cURL

图 10.32:使用 compose 的微应用的 cURL

在这个练习中,我们看到compose的重构并没有影响应用程序的功能。可以根据响应尝试不同的请求。

可以使用以下代码解决上述问题:

curl http://localhost:3000 -H 'Authorization: ApiKey api-key-1' -I

以下请求将因为我们没有设置有效的授权头而失败 401 错误:

curl http://localhost:3000 -H 'Authorization: ApiKey bad-key' -I
curl http://localhost:3000 -H 'Authorization: Bearer bearer-token' -I

为了比较,这里是使用 Express 及其基于中间件的组合模型的等效 BFF 应用程序。它实现了与我们完成此练习的微 BFF 类似的功能:

const express = require('express');
const app = express();
const responseText = `Hello authenticated Express!`;
const timerStart = (req, res, next) => {
  const timerName = `request_${(Math.random() * 100).toFixed(2)}`;
  console.time(timerName);
  req.on('end', () => {
    console.timeEnd(timerName);
  });
  next();
};
const ALLOWED_API_KEYS = new Set(['api-key-1', 'key-2-for-api']);
const authenticate = (req, res, next) => {
  const {authorization} = req.headers;
  if (authorization && authorization.startsWith('ApiKey')) {
    const apiKey = authorization.replace('ApiKey', '').trim();
    if (ALLOWED_API_KEYS.has(apiKey)) {
      return next();
    }
  }
  return res.status(401).send(`Unauthorized: <pre>${responseText}</pre>`);
};
const requestHandler = (req, res) => {  return res.send(responseText);
};
app.use(timerStart, authenticate, requestHandler);
app.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

了解函数组合带来的可能性将意味着更多的反思进入函数接口(输入和输出)的设计,以便例如可以利用compose。下一节涵盖了不可变性和副作用,这是必要的,以便我们可以组合一组部分应用或纯函数。

不可变性和副作用

在纯函数的上下文中,变量的突变被认为是副作用,因此发生变异的函数,特别是超出函数执行范围的变量,不是纯的。

在 JavaScript 中,不可变性很难强制执行,但语言为我们提供了良好的原语,以不可变的方式编写。这种风格严重依赖于操作符和函数,它们创建数据的副本,而不是就地突变。

可以在不使用副作用的情况下编写应用程序的整个部分。任何数据操作都可以在没有副作用的情况下进行。然而,大多数应用程序需要加载数据,以便从某个地方显示数据,并可能在某个地方保存一些数据。这些都是需要管理的副作用。

Redux 动作创建者的一瞥

动作创建者创建 Redux 动作。它们对于抽象常量并集中 Redux 存储支持的动作非常有用。

动作创建器总是返回一个新的动作对象。创建并返回一个新对象是保证返回值的不可变性的一种好方法,至少就动作创建器而言是这样。如果动作创建器返回其参数的某个版本,可能会产生令人惊讶的输出:

const ADD_PRODUCT = 'ADD_PRODUCT';
function addProduct(newProduct) {
  return {
    type: ADD_PRODUCT,
    newProduct
  };
}

可以调用dispatch并手动编组对象,也可以调用动作创建器的输出:

this.props.dispatch(addProduct(newProduct))

练习 79:重构 React/Redux 应用以使用动作创建器

动作创建器是将动作形状与 React 组件分离的好方法。

注意

练习 79 带有一个预配置的开发服务器和起始文件中方法的骨架,即exercise--refactor-action-creators-start.jsexercise-refactor-action-creators-start.html。可以使用npm run Exercise79来运行开发服务器。可以在 GitHub 上使用npm run exercise10文件来运行这个练习的工作解决方案。

在这个练习中,您将从使用内联动作定义转为使用动作创建器。

执行以下步骤完成此练习:

  1. 将当前目录更改为Lesson10并运行npm install,如果您之前没有在此目录中执行过。npm install会下载运行此活动所需的依赖项(React、Redux、react-redux 和 Parcel)。

  2. 首先运行npx parcel serve exercise-refactor-action-creators-start.html。要在开发过程中查看应用程序,请运行npm run Exercise79。您将看到应用程序正在启动,如下所示:图 10.33:运行此练习的起始文件

图 10.33:运行此练习的起始文件

为了使开发服务器实时重新加载我们的更改并避免配置问题,请直接编辑exercise-refactor-action-creators-start.js文件。

  1. 转到http://localhost:1234(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.34:浏览器中的初始应用
图 10.34:浏览器中的初始应用
  1. 实现startCheckoutcontinueShoppingdoneaddProduct动作创建器:
function startCheckout(items) {
  return {
    type: START_CHECKOUT,
    basket: {
      items
    }
  };
}
function continueShopping() {
  return {
    type: CONTINUE_SHOPPING
  };
}
function done() {
  return {
    type: DONE
  };
}
function addProduct(newProduct) {
  return {
    type: ADD_PRODUCT,
    newProduct: {
      ...newProduct,
      quantity: 1
    }
  };
}

这些分别返回以下动作类型:START_CHECKOUTCONTINUE_SHOPPINGDONEADD_PRODUCT

  1. 更新handleCheckout以使用相应的startCheckout动作创建器:
handleCheckout(items) {
  this.props.dispatch(startCheckout(items));
}
  1. 更新continueShopping以使用相应的continueShopping动作创建器:
continueShopping() {
  this.props.dispatch(continueShopping());
}
  1. 更新finish以使用相应的done动作创建器:
finish() {
  this.props.dispatch(done());
}
  1. 更新addProduct以使用相应的addProduct动作创建器:
addProduct(product) {
  this.props.dispatch(addProduct(product));
}
  1. 检查应用程序是否仍然按预期运行:

图 10.35:重构动作创建器后的应用

图 10.35:重构动作创建器后的应用

React-Redux mapStateToProps 和 mapDispatchToProps

react-redux 的核心命题是 connect 函数,正如其名称所示,它将组件连接到存储。它的签名是connect(mapStateToProps, mapDispatchToProps) (component),并返回一个connect组件。

在大多数示例中,mapStateToProps函数都是stated => state,这在一个小应用程序中是有意义的,因为所有状态都与连接的组件相关。原则上,应该在mapStateToProps中使用选择器,以避免传递太多的 props,因此当它不使用的数据发生变化时,组件重新渲染。以下是mapStateToProps函数的一个小例子:

const mapStateToProps = state => {
  return {
    items: selectBasketItems(state),
    status: selectStatus(state),
    product: selectProduct(state)
  };
};

让我们使用mapStateToProps完成一个练习。

练习 80:使用 mapDispatchToProps 函数抽象状态管理

在这个练习中,您将使用mapDispatchToProps函数来管理状态,该函数利用选择器来抽象 redux 存储的内部形状。

执行以下步骤完成此练习:

  1. 如果您之前没有在此目录中执行过npm install,请将当前目录更改为Lesson10并运行npm install

  2. 首先,运行npx parcel serve exercise-map-to-props-start.html。 然后,在开发过程中,运行npm run Exercise80。 您将看到应用程序启动,如下所示:

注意

Exercise 80 带有预配置的开发服务器和起始文件中方法的框架,即exercise-map-to-props-start.jsexercise-map-to-props-start.html。 可以使用npm run Exercise80运行开发服务器。 可以在 GitHub 上使用 npm run Exercise80 文件运行此练习的工作解决方案。

图 10.36:npm run Exercise80 的输出

图 10.36:npm run Exercise80 的输出
  1. 转到http://localhost:1234(或起始脚本输出的任何 URL)。 您应该看到一个空白的 HTML 页面。 这是因为mapStateToProps返回了一个空状态对象。

注意

审核解释了 App 组件使用的状态片段(来自存储)以及产品,项目和状态是正在使用的状态片段。

  1. status创建一个新的选择器:
const selectStatus = state => state && state.status;
  1. product创建一个新的选择器:
const selectProduct = state => state && state.product;
  1. mapStateToProps中,将itemsproductstatus映射到它们对应的选择器,这些选择器应用于状态:
const mapStateToProps = state => {
  return {
    items: selectBasketItems(state),
    status: selectStatus(state),
    product: selectProduct(state)
  };
};
  1. 将在 App 组件中调用dispatch的函数提取到mapDispatchToProps中,注意从this.props.dispatch中删除this.props。 Dispatch 是mapDispatchToProps的第一个参数。 我们的代码现在应该如下所示:
const mapDispatchToProps = dispatch => {
  return {
    handleCheckout(items) {
      dispatch(startCheckout(items))
    },
    continueShopping() {
      dispatch(continueShopping());
    },
    finish() {
      dispatch(done());
    },
    addProduct(product) {
      dispatch(addProduct(product));
    }
  };
};
  1. 替换App#render中对this.handleCheckout的引用。 相反,调用this.props.handleCheckout
{status === 'SHOPPING' && (
  <Basket
    items={items}
    renderItem={item => (
      <div>
        x{item.quantity} - {item.name} - $
        {(item.price / 100).toFixed(2)} each{' '}
      </div>
    )}
    onCheckout={this.props.handleCheckout}
    />
)}
  1. 替换App#render中对this.continueShoppingthis.finish的引用。 相反,分别调用this.props.continueShoppingthis.props.finish
{status === 'CHECKING_OUT' && (
  <div>
    <p>You have started checking out with {items.length} items.</p>
    <button onClick={this.props.continueShopping}>
      Continue shopping
    </button>
    <button onClick={this.props.finish}>Finish</button>
  </div>
)}
  1. 替换App#render中对this.addProduct的引用。 相反,调用this.props.addProduct
{status === 'SHOPPING' && (
  <div style={{marginTop: 50}}>
    <h2>{product.name}</h2>
    <p>Price: ${product.price / 100}</p>
    <button onClick={() => this.props.addProduct(product)}>
      Add to Basket
    </button>
  </div>
)}
  1. 打开http://localhost:1234,查看应用程序现在的预期行为。 您可以添加产品,转到结账,完成或继续购物:

图 10.37:mapStateToProps/mapDispatchToProps 重构后的应用程序

图 10.37:mapStateToProps/mapDispatchToProps 重构后的应用程序

该应用程序现在使用正确实现的mapStateToPropsmapDispatchToProps函数工作。 React 和 Redux 进一步从彼此抽象出来。 React 组件中不再有状态,也不再直接调用存储的dispatch方法。 这意味着原则上,可以使用另一个状态管理库来替换 Redux,而 React 代码不会改变; 只有状态管理器和 ReactApp组件之间的粘合代码会改变。

Redux Reducers In Depth

Redux 减速器不应该改变 Redux 存储状态。 与第一原则相比,纯函数更容易测试,其结果更容易预测。 作为状态管理解决方案,Redux 有两个作用:保持状态如预期,并确保更新能够高效和及时地传播。

纯函数可以帮助我们通过考虑不可变性来实现这一目标。 返回副本有助于进行更改检测。 例如,检测对象内的大部分键已更新的成本更高,而检测对象已被其浅复制替换的成本更低。 在第一种情况下,必须进行昂贵的深度比较,以遍历整个对象以检测原始值和/或结构的差异。 在浅复制情况下,仅需要检测对象引用不同即可检测到更改。 这是微不足道的,与=== JavaScript 运算符有关,该运算符通过引用比较对象。

将 JavaScript-Native 方法更改为不可变的函数式样式

Map/filter/reduce 不会改变它们操作的初始数组。在以下片段中,initial的值保持不变。Array#map返回数组的副本,因此不会改变它正在操作的数组。Array#reduceArray#filter也是如此;它们都用于数组,但不会在原地更改任何值。相反,它们会创建新的对象:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  initial.map(item => item.name),
  ['Shampoo', 'Soap'],
  'demo map'
);
assert(
  initial.map(item => item.count).reduce((acc, curr) => acc + curr) === 3,
  'demo map reduce'
);
assert.deepStrictEqual(
  initial.filter(item => item.count > 1),
  [{count: 2, name: 'Soap'}],
  'demo filter'
);

对象的restspread语法是 ECMAScript 2018 中引入的,是创建对象的浅拷贝和排除/覆盖键的好方法。以下代码结合了Array#map和对象 rest/spread 来创建数组的浅拷贝(使用Array#map),但也使用 rest/spread 来创建数组中对象的浅拷贝:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  initial.map(item => {
    return {
      ...item,
      category: 'care'
    };
  }),
  [
    {
      category: 'care',
      count: 1,
      name: 'Shampoo'
    },
    {
      category: 'care',
      count: 2,
      name: 'Soap'
    }
  ],
  'demo of spread (creates copies)'
);
assert.deepStrictEqual(
  initial.map(({name, ...rest}) => {
    return {
      ...rest,
      name: `${name.toLowerCase()}-care`
    };
  }),
  [
    {
      count: 1,
      name: 'shampoo-care'
    },
    {
      count: 2,
      name: 'soap-care'
    }
  ],
  'demo of rest in parameter + spread'
);

数组的restspread语法早于对象的 spread/rest,因为它是 ECMAScript 2015(也称为 ES6)的一部分。与其对象对应物一样,它非常有用于创建浅拷贝。我们已经看到的另一个用例是将类似数组的对象转换为完整的数组。相同的技巧也可以用于可迭代对象,如Set

在以下示例中,使用数组 spread 来创建数组的副本,然后对其进行排序,并使用它将 Set 转换为数组。数组 spread 还用于创建除第一个元素之外的所有元素的副本:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  // Without the spread, reverse() mutates the array in-place
  [...initial].reverse(),
  [
    {
      count: 2,
      name: 'Soap'
    },
    {
      count: 1,
      name: 'Shampoo'
    }
  ],
  'demo of immutable reverse'
);
assert.deepStrictEqual(
  [...new Set([1, 2, 1, 2])],
  [1, 2],
  'demo of spread on Sets'
);
const [first, ...rest] = initial;
assert.deepStrictEqual(first, {count: 1, name: 'Shampoo'});
assert.deepStrictEqual(rest, [
  {
    count: 2,
    name: 'Soap'
  }
]);

Object.freeze使对象在严格模式下变为只读。

例如,以下代码片段将使用 throw,因为我们试图在严格模式下向冻结的对象添加属性:

// Node.js built-in
const assert = require('assert').strict;
const myProduct = Object.freeze({
  name: 'Special Sauce',
  price: 1999
});
assert.throws(() => {
  'use strict';
  myProduct.category = 'condiments';
}, 'writing to an existing property is an error in strict mode');
assert.throws(() => {
  'use strict';
  myProduct.name = 'Super Special Sauce';
}, 'writing a new property is an error in strict mode');

Object.freeze在实践中很少使用。作为一种设计用于在浏览器中运行的语言,JavaScript 被设计为非常宽松。运行时错误存在,但应该避免,特别是对于绑定为应用程序问题的事情:写入只读属性。

而且,Object.freeze只在非严格模式下抛出。看看以下示例,其中允许在冻结对象上访问和修改属性,因为默认情况下 JavaScript 在非严格模式下运行:

// Node.js built-in
const assert = require('assert').strict;
const myProduct = Object.freeze({
  name: 'Special Sauce',
  price: 1999
});
assert.doesNotThrow(() => {
  myProduct.category = 'condiments';
}, 'writing to an existing property is fine in non-strict mode');
assert.doesNotThrow(() => {
  myProduct.name = 'Super Special Sauce';
}, 'writing a new property is fine in non-strict mode');

工程团队通常选择遵循支持不可变风格的编码标准,而不是强制不可变性。

注意

还可以利用诸如 Immutable.js 之类的库,该库提供了以高效方式实现的持久不可变数据结构。

在 React/Redux 应用程序中处理副作用 React 生命周期钩子

React 组件的render()方法应该是纯的,因此不支持副作用。能够根据其输入(props 和 state)来预测组件是否需要重新渲染意味着可以避免很多不必要的更新。由于每次状态或属性更新都可能导致调用render,这可能不是放置 API 调用的最佳位置。

React 文档建议使用componentDidMount生命周期方法。componentDidMount在组件挂载后运行。换句话说,如果在 React 应用程序的先前状态中未渲染组件,则它在页面上第一次渲染时运行。

我们可以使用componentDidMount发送带有fetch的 HTTP 请求。fetch Promise 的.then可以用于从服务器响应中更新状态:

import React from 'react';
class App extends React.Component {
  constructor() {
    super();
    this.state = {};
  }
  componentDidMount() {
    fetch('https://hello-world-micro.glitch.me')
      .then(response => {
        if (response.ok) {
          return response.text();
        }
      })
      .then(data => {
        this.setState({
          message: data
        });
      });
  }
  render() {
    return (
      <div>
        <p>Message: {this.state.message}</p>
      </div>
    );
  }
}

在 React/Redux 应用程序中处理副作用 React Hooks

作为 React 的最新添加,钩子允许函数组件利用以前专门用于类组件的所有功能。

前面的示例可以重构为使用useStateuseEffect钩子的函数组件。useState是我们可以使用钩子在 React 函数组件中使用状态的一种方式。当来自useState的状态发生变化时,React 将重新渲染函数组件。useEffectcomponentDidMount的对应物,在组件渲染之前调用,如果组件不是在应用程序的先前状态中渲染的:

import React, {useEffect, useState} from 'react';
const App = () => {
  const [message, setMessage] = useState(null);
  useEffect(() => {
    if (!message) {
      fetch('https://hello-world-micro.glitch.me')
        .then(response => {
          if (response.ok) {
            return response.text();
          }
        })
        .then(data => {
          setMessage(data);
        });
    }
  });
  return (
    <div>
      <p>Message: {message}</p>
    </div>
  );
};

在 React/Redux 应用程序中处理副作用 Redux-Thunk

Thunk 是延迟评估函数的一种方式。这是一种在不支持它的语言中进行惰性评估的方法:

let globalState;
function thunk() {
  return () => {
    globalState = 'updated';
  };
}
const lazy = thunk();
console.assert(!globalState, 'executing the thunk does nothing');
lazy();
console.assert(
  globalState === 'updated',
  'executing the output of the thunk runs the update'
);

这也是一种封装副作用的方式。由于我们有一流函数,我们传递 thunk,这在纯函数中是允许的(thunk 只是一个函数),尽管调用 thunk 本身可能会有副作用。

redux-thunk 非常简单;而不是传递返回对象的操作创建者(带有类型字段和可能的有效负载),操作创建者返回一个接受存储的分派和getState作为参数的函数。

在 thunk 中,可以访问当前存储状态并分派操作,这些操作将被减少到存储中。请参见以下使用 Redux 和 redux-thunk 的示例:

// store is set up, App is connected, redux-thunk middleware is applied
import React from 'react';
class App extends React.Component {
  componentDidMount() {
    // this looks like any action creator
    this.props.dispatch(requestHelloWorld());
  }
  render() {
    return (
      <div>
        <p>Message: {this.props.message}</p>
      </div>
    );
  }
}
function requestHelloWorld() {
  // this action creator returns a function
  return (dispatch, getState) => {
    fetch('https://hello-world-micro.glitch.me')
      .then(response => {
        if (response.ok) {
          return response.text();
        }
      })
      .then(data => {
        dispatch({
          type: 'REQUEST_HELLO_WORLD_SUCCESS',
          message: data
        });
      })
      .catch(error => {
        dispatch({
          type: 'REQUEST_HELLO_WORLD_ERROR',
          error
        });
      });
  };
}

GraphQL 语言模式和查询简介

GraphQL 是一种查询语言。它公开了一个类型化的模式来运行查询。GraphQL 的巨大好处是客户端请求所需的信息。这是有类型模式的直接效果。

我们将使用express-graphql将 GraphQL 添加到我们的 BFF,它与 micro 兼容。我们需要为我们的 GraphQL 端点提供模式和解析器,以便它可以响应客户端请求。在 Exercise 12 开始文件中提供了这样的服务器(将工作目录更改为Lesson10,运行npm install,然后运行npm run Exercise81,并转到http://localhost:3000以查看其运行情况)。

一个返回篮子的示例 GraphQL 查询可以在以下 GraphQL 模式定义中工作。请注意我们有三种类型,即QuerybasketbasketItembasketitems属性下包含basketItems列表。query包含顶级 GraphQL 查询字段,这种情况下只是basket。要查询basketItems,我们必须加载相应的basket并展开items字段:

type basket {
  items: [basketItem]
}
"""BasketItem"""
type basketItem {
  name: String
  price: Int
  quantity: Int
  id: String
}
"""Root query"""
type Query {
  basket: basket
}

Node.js GraphQL 服务器组件中内置的工具是 GraphiQL。它是一个用于 GraphQL 的接口,允许用户浏览模式并提供模式的文档。

我们输入的查询如下:加载basket顶级查询字段,展开其items字段,并填充篮子items字段中basketItem元素的namequantityprice

图 10.38:GraphiQL 用户界面和获取完全展开的篮子项目

图 10.38:GraphiQL 用户界面和获取完全展开的篮子项目

使用 GraphQL 变异和解析器进行运行更新

在查询和模式世界中,一个非常缺少的东西是运行写操作的方法。这就是 GraphQL 变异的作用。变异是结构化的更新操作。

解析器是服务器端 GraphQL 实现的细节。解析器是解析 GraphQL 查询的东西。解析器从模式链的顶部到底部运行。在解析查询时,对象上的字段并行执行;在解析变异时,它们按顺序解析。以下是使用变异的示例:

const mutation = new GraphQLObjectType({
  name: 'Mutation',
  fields() {
    return {};
  }
});

注意

有关 GraphQL 的更多指南,请访问graphql.org

练习 81:使用 micro 和 GraphQL 实现 BFF 变异

在这个练习中,我们将使用 micro 和 GraphQL 来实现 BFF 变异。

执行以下步骤以完成此练习:

注意

练习 81 包括一个预配置的服务器和起始文件exercise-graphql-micro-start.js中方法的框架。可以使用npm run Exercise81运行开发服务器。可以在 GitHub 上使用npm run Exercise81文件运行此练习的工作解决方案。

  1. 如果您以前没有在此目录中执行过此操作,请将当前目录更改为Lesson10并运行npm installnpm install下载所需的依赖项,以便我们可以运行此活动(micro 和express-graphql)。

  2. 运行node exercise-graphql-micro-start.js。然后,在开发过程中,运行npm run Exercise81。您将看到应用程序启动,如下所示:图 10.39:运行此练习的起始文件

图 10.39:运行此练习的起始文件
  1. 转到http://localhost:3000(或者起始脚本输出的任何 URL)。您应该看到以下 GraphiQL 页面:图 10.40:空的 GraphiQL 用户界面
图 10.40:空的 GraphiQL 用户界面
  1. 添加一个LineItemCost常量,它是一个字段定义(普通的 JavaScript 对象):
const LineItemCost:
= {
  type: GraphQLInt,
  args: {id: {type: GraphQLString}},
  resolve(root, args, context) {
    return 4;
  }
};

我们的LineItemCost应该有一个type属性设置为GraphQLInt,因为LineItemCost计算的输出是一个整数。LineItemCost还应该有一个args字段,应该设置为{id: {type: GraphQLString}}。换句话说,我们的 mutation 接受一个是字符串的id参数(这与我们的示例数据一致)。为了使 mutation 返回一些东西,它需要一个resolve()方法。目前,它可以返回任何整数。mutations 的resolve方法将根作为第一个参数,args作为第二个参数。

  1. 现在让我们实现LineItemCost的实际resolve方法。首先,我们需要使用.find(el => el.id === args.id)basketItems中查找 ID 查找项目。然后,我们可以计算项目的成本(item.price * item.quantity),如下所示:
const LineItemCost = {
  type: GraphQLInt,
  args: {id: {type: GraphQLString}},
  resolve(root, args, context) {
    const item = basketItems.find(i => i.id === args.id);
    return item ? item.quantity * item.price : null;
  }
};
  1. 创建一个GraphQLObjectType的 mutation 常量。查看查询如何初始化;其名称应为Mutation:
const mutation = new GraphQLObjectType({
  name: 'Mutation',
  fields() {
    return {};
  }
});
  1. LineItemCost添加到fields()返回值的 mutation 中。这意味着LineItemCost现在是顶级 mutation。如果在 GraphQL 模式上存在mutation,则可以调用它:
const mutation = new GraphQLObjectType({
  name: 'Mutation',
  fields() {
    return {LineItemCost};
  }
});
  1. mutation添加到GraphQLSchema模式中:
const handler = graphqlServer({
  schema: new GraphQLSchema({query, mutation}),
  graphiql: true
});
  1. 将以下查询发送到服务器(通过 GraphiQL)。在左侧编辑器中输入并单击播放按钮:
mutation {
  cost1: LineItemCost(id: "1")
  cost2: LineItemCost(id: "2")
}

注意

这个 mutation 使用了所谓的 GraphQL 别名,因为我们不能两次使用相同的名称运行 mutation。

输出应该如下所示:

图 10.41:带有 LineItemCost 别名的 ID“1”和“2”的 mutation 查询的 GraphiQL

图 10.41:带有 LineItemCost 别名的 ID“1”和“2”的 mutation 查询的 GraphiQL

为了使购物篮示例更加真实,我们将使用 GraphQL 查询,redux-thunk 来处理副作用,并使用新的 reducer 来更新 Redux 存储状态,从 GraphQL BFF 加载初始购物篮数据。下一个活动的目的是向您展示我们如何将 GraphQL BFF 与使用 redux-thunk 的 React/Redux 应用集成。

活动 17:从 BFF 获取当前购物篮

在这个活动中,我们将从 GraphQL BFF 获取初始购物篮数据,以便重新渲染物品到购物篮,从而更新购物篮的初始状态。让我们开始吧:

注意

活动 17 配备了一个预配置的开发服务器和起始文件中方法的骨架,即activity-app-start.jsactivity-app-start.html。可以使用npm run Activity17运行开发服务器。可以在 GitHub 上使用 npm run Activity17 文件运行此活动的工作解决方案。

  1. 如果以前没有在此目录中这样做,请将当前目录更改为Lesson10并运行npm install

  2. 运行活动 17 的 BFF 和npx parcel serve activity-app-start.html。在开发过程中,运行npm run Activity17

  3. 转到http://localhost:1234(或者起始脚本输出的任何 URL)以检查 HTML 页面。

  4. 编写一个查询,从 BFF 获取购物篮中的物品。您可以使用http://localhost:3000上的 GraphQL UI 进行实验。

  5. 创建一个requestBasket(thunk)动作创建器,它将使用上一步的查询调用fetchFromBff

  6. fetchFromBff()调用上链接一个.then,以使用正确的basket有效负载分派REQUEST_BASKET_SUCCESS动作。

  7. appReducer添加一个案例,将带有basket有效负载的REQUEST_BASKET_SUCCESS操作减少到状态中。

  8. requestBasket添加到mapDispatchToProps

  9. App#componentDidMount中调用requestBasket,它被映射到dispatch

注意

此活动的解决方案可在第 628 页找到。

摘要

头等函数是使用流行库(如 React 及其模式)的一部分。它们还为任何实现的委托提供动力,特别是在内置功能(如 Array)上。函数式编程的另一个核心原则是纯函数。使用纯函数进行复杂数据操作逻辑或数据结构周围的抽象层是由流行的 Redux 状态管理解决方案提出的一个很好的模式。任何必须被模拟的副作用和/或依赖关系都会使复杂数据操作变得更加难以理解。高阶函数和特定技术,如柯里化和部分应用,在日常 JavaScript 开发中广泛使用。柯里化和部分应用是一种设计函数的方式,使得每个专门化步骤都是“可保存的”,因为它已经是一个已经应用了一定数量参数的函数。

如果发现函数应用程序管道,则组合可以具有真正的价值。例如,将 HTTP 服务建模为管道非常有意义。另一方面,Node.js HTTP 服务器生态系统领导者使用基于中间件的组合模型,micro 暴露了一个函数组合模型。以不可变的方式编写 JavaScript 允许库以廉价的方式检查某些东西是否已更改。在 React 和 Redux 中,副作用是在纯函数的常规流程之外处理的,即渲染函数和 reducer。Redux-thunk 是解决这个问题的一个相当功能性的解决方案,尽管以使函数有效的行动为代价。纯 Redux 操作是具有类型属性的 JavaScript 对象。

在本书中,我们学习了包括 React、Angular 和相关工具和库在内的各种框架。它教会了我们构建现代应用程序所需的高级概念。然后,我们学习了如何在文档对象模型(DOM)中表示 HTML 文档。之后,我们结合了对 DOM 和 Node.js 的知识,为实际情况创建了一个网络爬虫。

在接下来的部分中,我们使用 Express 库为 Node.js 创建了基于 Node.js 的 RESTful API。我们看了看如何使用模块化设计来实现更好的可重用性,并与单个项目上的多个开发人员进行协作。我们还学习了如何构建单元测试,以确保我们程序的核心功能随着时间的推移不会出现问题。我们看到构造函数、async/await 和事件如何可以使我们的应用程序具有高速和性能。本书的最后部分向您介绍了函数式编程概念,如不可变性、纯函数和高阶函数。

附录

关于

本节旨在帮助学生完成书中的活动。它包括学生执行的详细步骤,以实现活动的目标。

第一章:JavaScript、HTML 和 DOM

活动 1:从页面中提取数据

解决方案

  1. 初始化一个变量来存储 CSV 的整个内容:
var csv = 'name,price,unit\n';
  1. 查询 DOM 以查找表示每个产品的所有元素。注意我们如何将返回的HTMLCollection实例包装在Array.from中,以便我们可以像处理普通数组一样处理它:
var elements = Array.from(document.getElementsByClassName('item'));
  1. 遍历找到的每个元素:
elements.forEach((el) => {});
  1. 在闭包内,使用product元素,查询以找到带单位的价格。使用斜杠拆分字符串:
var priceAndUnitElement = el.getElementsByTagName('span')[0];
var priceAndUnit = priceAndUnitElement.textContent.split("/");
var price = priceAndUnit[0].trim();
var unit = priceAndUnit[1].trim();
  1. 然后查询名称:
var name = el.getElementsByTagName('a')[0].textContent;
  1. 将所有信息附加到步骤 1 中初始化的变量中,使用逗号分隔值。不要忘记为附加到每行的换行符添加:
csv += `${name},${price},${unit}\n`;
  1. 使用console.log函数打印包含累积数据的变量:
console.log(csv);
  1. 将代码粘贴到 Chrome 控制台选项卡中;它应该看起来像这样:

图 1.62:准备在控制台选项卡中运行的代码

图 1.62:准备在控制台选项卡中运行的代码

按下Enter执行代码后,您应该在控制台中看到打印的 CSV,如下所示:

图 1.63:带有代码和控制台选项卡输出的商店

图 1.63:带有代码和控制台选项卡输出的商店

活动 2:用 Web 组件替换标签过滤器

解决方案

  1. 首先将Exercise07中的代码复制到一个新文件夹中。

  2. 创建一个名为tags_holder.js的新文件,并在其中添加一个名为TagsHolder的扩展HTMLElement的类,然后定义一个名为tags-holder的新自定义组件:

class TagsHolder extends HTMLElement {
}
customElements.define('tags-holder', TagsHolder);
  1. 创建两个render方法:一个用于呈现基本状态,一个用于呈现标签或指示未选择任何标签进行过滤的文本:
render() {
  this.shadowRoot.innerHTML = `
  <link rel="stylesheet" type="text/css" href="../css/semantic.min.css" />
  <div>
    Filtered by tags:
    <span class="tags"></span>
  </div>`;
}
renderTagList() {
  const tagsHolderElement = this.shadowRoot.querySelector('.tags');
  tagsHolderElement.innerHTML = '';
  const tags = this._selectedTags;
  if (tags.length == 0) {
    tagsHolderElement.innerHTML = 'No filters';
    return;
  }
  tags.forEach(tag => {
    const tagEl = document.createElement('span');
    tagEl.className = "ui label orange";
    tagEl.addEventListener('click', () => this.triggerTagClicked(tag));
    tagEl.innerHTML = tag;
    tagsHolderElement.appendChild(tagEl);
  });
}
  1. 在构造函数中,调用w,将组件附加到阴影根,初始化所选标签列表,并调用两个render方法:
constructor() {
  super();
  this.attachShadow({ mode: 'open' });
  this._selectedTags = [];
  this.render();
  this.renderTagList();
}
  1. 创建一个 getter 来公开所选标签的列表:
get selectedTags() {
  return this._selectedTags.slice(0);
}
  1. 创建两个触发方法:一个用于触发更改事件,另一个用于触发tag-clicked事件:
triggerChanged(tag) {
  const event = new CustomEvent('changed', { bubbles: true });
  this.dispatchEvent(event);
}
triggerTagClicked(tag) {
  const event = new CustomEvent('tag-clicked', {
    bubbles: true,
    detail: { tag },
  });
  this.dispatchEvent(event);
}
  1. 创建两个mutator方法:addTagremoveTag。这些方法接收标签名称,如果不存在,则添加标签,如果存在,则删除标签,从所选标签列表中。如果列表已修改,则触发changed事件并调用重新呈现标签列表的方法:
addTag(tag) {
  if (!this._selectedTags.includes(tag)) {
    this._selectedTags.push(tag);
    this._selectedTags.sort();
    this.renderTagList();
    this.triggerChanged();
  }
}
removeTag(tag) {
  const index = this._selectedTags.indexOf(tag);
  if (index >= 0) {
    this._selectedTags.splice(index, 1);
    this.renderTagList();
    this.triggerChanged();
  }
}
  1. 在 HTML 中,用新组件替换现有代码。删除以下行:
<div class="item">
  Filtered by tags: <span class="tags"></span>
</div>
And add:
<tags-holder class="item"></tags-holder>
Also add:
<script src="tags_holder.js"></script>

注意

您可以在 GitHub 上查看最终的 HTML,网址为github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Activity02/dynamic_storefront.html

  1. filter_and_search.js中,执行以下操作:

在顶部,创建对tags-holder组件的引用:

const filterByTagElement = document.querySelector('tags-holder');

添加事件侦听器以处理changedtag-clicked事件:

filterByTagElement.addEventListener('tag-clicked', (e) => filterByTagElement.removeTag(e.detail.tag));
filterByTagElement.addEventListener('changed', () => applyFilters());

删除以下函数及其所有引用:createTagFilterLabelupdateTagFilterList

filterByTags函数中,用filterByTagElement.selectedTags替换tagsToFilterBy

addTagFilter方法中,用filterByTagElement.addTag替换对tagsToFilterBy的引用。

第二章:Node.js 和 npm

活动 3:创建一个 npm 包来解析 HTML

解决方案

  1. 在空文件夹中,使用 npm 创建一个新包。您可以使用所有选项的默认值:
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See 'npm help json' for definitive documentation on these fields and exactly what they do.
Use 'npm install <pkg>' afterwards to install a package and save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name: (Activity03) 
version: (1.0.0) 
description: 
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) 
About to write to .../Lesson02/Activity03/package.json:
{
  "name": "Activity03",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISCs"
}
Is this OK? (yes)
  1. 要安装cheerio,运行npm install。确保您错误地输入库的名称:
$ npm install cheerio
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN Activity03@1.0.0 No description
npm WARN Activity03@1.0.0 No repository field.
+ cheerio@1.0.0-rc.3added 19 packages from 45 contributors and audited 34 packages in 6.334s
found 0 vulnerabilities
  1. 在此文件夹中,创建一个名为index.js的文件,并将以下内容添加到其中:
const cheerio = require('cheerio');
  1. 创建一个变量,存储来自 GitHub 示例代码(github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Example/sample_001/sample-page.html)的 HTML。创建多行字符串时,可以使用反引号:
const html = `
<html>
  <head>
    <title>Sample Page</title>
  </head>
  <body>
    <p>This is a paragraph.</p>
    <div>
      <p>This is a paragraph inside a div.</p>
    </div>
    <button>Click me!</button>
  </body>
</html>
`;
  1. 解析 HTML 并将其传递给 cheerio。在 cheerio 的示例中,您将看到它们将解析的变量命名为“$”(美元符号)。这是 jQuery 世界中使用的一个旧约定。它看起来像这样:
const $ = cheerio.load(html);
  1. 现在,我们可以使用该变量来操作 HTML。首先,我们将向页面添加一个带有文本的段落:
$('div').append('<p>This is another paragraph.</p>');

我们还可以查询 HTML,类似于我们在第一章 JavaScript、HTML 和 DOM中所做的,使用 CSS 选择器。让我们查询所有段落并将其内容打印到控制台。请注意,cheerio 元素的行为与 DOM 元素并不完全相同,但它们非常相似。

  1. 使用firstChild属性找到每个段落的第一个节点并打印其内容,假设它将是文本元素:
$('p').each((index, p) => {
  console.log(`${index} - ${p.firstChild.data}`);
});
  1. 最后,在index.js中,通过调用html函数将操作后的 HTML 打印到控制台:
console.log($.html());

现在,您可以通过从 Node.js 调用它来运行您的应用程序:

图 2.7:从 node.js 调用应用程序

图 2.7:从 Node.js 调用应用程序

第三章:Node.js API 和 Web 爬虫

活动 4:从商店前端爬取产品和价格

解决方案

  1. 使用本章中练习 14,提供动态内容中的代码启动动态服务器以提供商店前端应用程序:
$ node Lesson03/Activity04/
Static resources from /path/to/repo/Lesson03/Activity04/static
Loaded 21 products...
Go to: http://localhost:3000
  1. 在新的终端中,创建一个新的npm包,安装jsdom,并创建index.js入口文件:
$ npm init
...
$ npm install jsdom
+ jsdom@15.1.1
added 97 packages from 126 contributors and audited 140 packages in 12.278s
found 0 vulnerabilities
  1. 调用require()方法加载项目中需要的所有模块:
const fs = require('fs');
const http = require('http');
const JSDOM = require('jsdom').JSDOM;
  1. http://localhost:3000发出 HTTP 请求:
const page = 'http://localhost:3000';
console.log(`Downloading ${page}...`);
const request = http.get(page, (response) => {
  1. 确保成功响应并使用数据事件从主体收集数据:
if (response.statusCode != 200) {
  console.error(`Error while fetching page ${page}: ${response.statusCode}`);
  console.error(`Status message: ${response.statusMessage}`);
  return;
}
let content = '';
response.on('data', (chunk) => content += chunk.toString());
  1. close事件中,使用JSDOM解析 HTML:
response.on('close', () => {
  console.log('Download finished.');
  const document = new JSDOM(content).window.document;
  writeCSV(extractProducts(document));
});

前面的回调调用了两个函数:extractProductswriteCSV。这些函数将在接下来的步骤中描述。

  1. 使用extractProducts函数查询 DOM 并从中获取产品信息。它将所有产品存储在一个数组中,并在最后返回:
function extractProducts(document) {
  const products = [];
  console.log('Parsing product data...');
  Array.from(document.getElementsByClassName('item'))
    .forEach((el) => {
      process.stdout.write('.');
      const priceAndUnitElement = el.getElementsByTagName('span')[0];
      const priceAndUnit = priceAndUnitElement.textContent.split("/");
     const price = priceAndUnit[0].trim().substr(1);
      const unit = priceAndUnit[1].trim();
      const name = el.getElementsByTagName('a')[0].textContent;
      products.push({ name, price: parseFloat(price), unit });
    });
  console.log();
  console.log(`Found ${products.length} products.`);
  return products;
}
  1. 使用writeCSV函数打开 CSV 文件进行写入,确保没有发生错误:
function writeCSV(products) {
 const fileName = 'products.csv';
  console.log(`Writing data to ${fileName}...`);
  fs.open(fileName, 'w', (error, fileDescriptor) => {
    if (error != null) {
      console.error(`Can not write to file: ${fileName}`, error);
      return;
    }
  1. 现在文件已打开,我们可以将产品数据写入文件:
    // Write header
    fs.writeSync(fileDescriptor, 'name,price,unit\n');
    // Write content
    products.forEach((product) => {
      const line = `${product.name},${product.price},${product.unit}\n`;
      fs.writeSync(fileDescriptor, line);
    });
    console.log('Done.');
  });
}
  1. 在新的终端中,运行应用程序:
$ node .
Downloading http://localhost:3000...
Download finished.
Parsing product data...
.....................
Found 21 products.
Writing data to products.csv...

第四章:使用 Node.js 构建 RESTful API

活动 5:为键盘门锁创建 API 端点

解决方案

  1. 创建一个新的项目文件夹,并将目录更改为以下内容:
mkdir passcode
cd passcode
  1. 初始化一个npm项目并安装expressexpress-validatorjwt-simple。然后,创建一个routes目录:
npm init -y
npm install --save express express-validator jwt-simple
mkdir routes
  1. 创建一个config.js文件,就像在练习 21,设置需要身份验证的端点中所做的那样。这应该包含一个随机生成的秘密值:
let config = {};
// random value below generated with command: openssl rand -base64 32
config.secret = "cSmdV7Nh4e3gIFTO0ljJlH1f/F0ROKZR/hZfRYTSO0A=";
module.exports = config;
  1. 创建routes/check-in.js文件以创建一个签到路由。这可以从练习 21,设置需要身份验证的端点中完整复制:
const express = require('express');
const jwt = require('jwt-simple');
const { check, validationResult } = require('express-validator/check');
const router = express.Router();
// import our config file and get the secret value
const config = require('../config');
  1. 创建一个名为routes/lock.js的第二个路由文件。首先导入所需的库和模块,创建一个空数组来保存我们的有效密码:
const express = require('express');
const app = express();
const { check, validationResult } = require('express-validator/check');
const router = express.Router();
// Import path and file system libraries for importing our route files
const path = require('path');
const fs = require('fs');
// Import library for handling HTTP errors
const createError = require('http-errors');
// Import library for working with JWT tokens
const jwt = require('jwt-simple');
// import our config file and get the secret value
const config = require('./../config');
const secret = config.secret;
// Create an array to keep track of valid passcodes
let passCodes = [];
  1. /code创建一个GET路由,需要一个name值,在前面步骤中的routes/lock.js文件中继续:
router.get(['/code'], [
    check('name').isString().isAlphanumeric().exists()
  ],
  (req, res) => {
    let codeObj = {};
    codeObj.guest = req.body.name;
    // Check that authorization header was sent
    if (req.headers.authorization) {
      let token = req.headers.authorization.split(" ")[1];
      try {
        req._guest = jwt.decode(token, secret);
      } catch {
        res.status(403).json({ error: 'Token is not valid.' });
      }
      // If the decoded object guest name property
      if (req._guest.name) {
        codeObj.creator = req._guest.name;
  1. routes/lock.js中创建另一个路由。这个路由将是/open,需要一个四位代码,将根据passCodes数组进行检查以查看它是否有效。在该路由下面,确保导出router,以便在server.js中使用:
router.post(['/open'], [
    check('code').isLength({ min: 4, max: 4 })
  ],
  (req, res) => {
    let code = passCodes.findIndex(obj => {
      return obj.code === req.body.code;
    });
    if(code !== -1) {
      passCodes.splice(code, 1);
      res.json({ message: 'Pass code is valid, door opened.' });
    } else {
      res.status(403).json({ error: 'Pass code is not valid.' });
    }
});
// Export route so it is available to import
module.exports = router;
  1. 创建主文件,在其中使用我们的路由server.js。首先导入所需的库,并设置 URL 编码 JSON:
const express = require('express');
const app = express();
// Import path and file system libraries for importing our route files
const path = require('path');
const fs = require('fs');
// Import library for handling HTTP errors
const createError = require('http-errors');
// Tell express to enable url encoding
app.use(express.urlencoded({extended: true}));
app.use(express.json());
  1. 接下来,在server.js中,在前面的代码下面,导入两个路由,实现一个404捕获,并告诉 API 监听端口3000
// Import our index route
let lock = require('./routes/lock');
let checkIn = require('./routes/check-in');
app.use('/check-in', checkIn);
app.use('/lock', lock);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  1. 最后,我们将测试 API 以确保它被正确完成。首先运行您的程序:
npm start
  1. 在程序运行时,打开第二个终端窗口,并使用/check-in端点获取 JWT 并将值保存为TOKEN。然后,回显该值以确保成功:
TOKEN=$(curl -sd "name=john" -X POST http://localhost:3000/check-in \
  | jq -r ".token")
echo $TOKEN

您应该收到一个包含字母和数字的长字符串,就像下面这样:

图 4.24:从签到端点获取 TOKEN

图 4.24:从签到端点获取 TOKEN
  1. 接下来,我们将使用我们的 JWT 使用/lock/code端点为 Sarah 获取一次性通行码:
curl -sd "name=sarah" -X GET \
  -H "Authorization: Bearer ${TOKEN}" \
  http://localhost:3000/lock/code \
  | jq

您应该收到一个包含消息和四位代码的对象,就像下面这样:

图 4.25:一个四位一次性代码

图 4.25:一个四位一次性代码
  1. 为了确保代码正常工作,将其发送到/lock/open端点。我们将发送以下命令一次,期望它成功。然后我们将发送相同的命令第二次,期望它失败,因为每个代码只能使用一次。运行以下命令两次:
# IMPORTANT: Make sure to replace 4594, with your specific passcode!
curl -sd "code=4594" -X POST \
  http://localhost:3000/lock/open \
  | jq

连续运行上述命令两次应该返回类似以下内容:

图 4.26:运行命令两次会导致错误

图 4.26:运行命令两次会导致错误

如果你的结果与前面的图像相同,那么你已成功完成了这个活动。

第五章:模块化 JavaScript

活动 6:创建带有闪光模式的灯泡

解决方案

  1. 安装babel-clibabel预设为开发人员依赖项:
npm install --save-dev webpack webpack-cli @babel/core @babel/cli @babel/preset-env
  1. 在根目录下添加一个名为.babelrc的文件。在其中,我们将告诉 Babel 使用预设设置:
{
  "presets": ["@babel/preset-env"]
}
  1. 在根目录下的webpack.config.js添加一个 webpack 配置文件:
const path = require("path");
module.exports = {
  mode: 'development',
  entry: "./build/js/viewer.js",
  output: {
    path: path.resolve(__dirname, "build"),
    filename: "bundle.js"
  }
};
  1. 创建一个名为js/flashingLight.js的新文件。这应该是一个空的 ES6 组件,扩展Light。在构造函数中,我们将包括statebrightnessflashMode
import Light from './light.js';
let privateVars = new WeakMap();
class FlashingLight extends Light {
  constructor(state=false, brightness=100, flashMode=true) {
    super(state, brightness);
    let info = {"flashMode": flashMode};
    privateVars.set(this, info);
    if(flashMode===true) {
      this.startFlashing();
    }
  }
  1. FlashingLight对象添加一个 setter 方法,这也将触发停止和开始闪光方法。
  setFlashMode(flashMode) {
    let info = privateVars.get(this);
    info.flashMode = checkStateFormat(flashMode);
    privateVars.set(this, info);
    if(flashMode===true) {
      this.startFlashing();
    } else {
      this.stopFlashing();
    }
  }
  1. FlashingLight对象添加一个 getter 方法:
  getFlashMode() {
    let info = privateVars.get(this);
    return info.flashMode;
  }
  1. 创建一个startFlashing函数,引用父类的lightSwitch()函数。这一步很棘手,因为我们必须将它绑定到setInterval
  startFlashing() {
    let info = privateVars.get(this);
    info.flashing = setInterval(this.toggle.bind(this),5000);
  }
  1. 创建一个stopFlashing函数,用于关闭定时器:
  stopFlashing() {
    let info = privateVars.get(this);
    clearInterval(info.flashing);
  }
  1. 作为flashingLight.js的最后部分,关闭类并导出它:
}
export default FlashingLight;
  1. 打开src/js/viewer.js并修改按钮以创建一个闪光灯而不是一个彩色灯:
button.onclick = function () {
  new FlashingLight(true, slider.value, true);
}
  1. 通过运行我们的build函数使用 npm 编译代码:
npm run build
  1. 打开build/index.html并将脚本位置设置为bundle.js
<script src="bundle.js" type="module"></script>
  1. 为了测试一切是否按预期工作,请运行npm start并在浏览器中打开localhost:8000。点击build按钮创建一个完整页面的灯。如果一切都做对了,你应该看到每盏灯在 5 秒的间隔内闪烁:

图 5.20:带有闪光模式的灯泡

图 5.20:带有闪光模式的灯泡

第六章:代码质量

活动 7:将所有内容整合在一起

解决方案

  1. 安装 linting 练习中列出的开发人员依赖项(eslintprettiereslint-config-airbnb-baseeslint-config-prettiereslint-plugin-jesteslint-plugin-import):
npm install --save-dev eslint prettier eslint-config-airbnb-base eslint-config-prettier eslint-plugin-jest eslint-plugin-import
  1. 添加一个eslint配置文件.eslintrc,其中包含以下内容:
{
 "extends": ["airbnb-base", "prettier"],
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "env": {
    "browser": true,
    "node": true,
    "es6": true,
    "mocha": true,
    "jest": true
  },
  "plugins": [],
  "rules": {
    "no-unused-vars": [
      "error",
      {
        "vars": "local",
        "args": "none"
      }
    ],
    "no-plusplus": "off",
  }
}
  1. 添加一个.prettierignore文件:
node_modules
build
dist
  1. 在你的package.json文件中添加一个lint命令:
  "scripts": {
    "start": "http-server",
    "lint": "prettier --write js/*.js && eslint js/*.js"
  },
  1. 打开assignment文件夹并安装使用 Puppeteer 与 Jest 的开发人员依赖项:
npm install --save-dev puppeteer jest jest-puppeteer
  1. 通过添加一个选项告诉 Jest 使用jest-puppeteer预设来修改你的package.json文件:
  "jest": {
    "preset": "jest-puppeteer"
  },
  1. package.json中添加一个test脚本,运行jest
  "scripts": {
    "start": "http-server",
    "lint": "prettier --write js/*.js && eslint js/*.js",
    "test": "jest"
  },
  1. 创建一个包含以下内容的jest-puppeteer.config.js文件:
module.exports = {
  server: {
    command: 'npm start',
    port: 8080,
  },
}
  1. __tests__/calculator.js创建一个测试文件,其中包含以下内容:
describe('Calculator', () => {
  beforeAll(async () => {
    await page.goto('http://localhost:8080');
  })
  it('Check that 777 times 777 is 603729', async () => {
    const seven = await page.$("#seven");
    const multiply = await page.$("#multiply");
    const equals = await page.$("#equals");
    const clear = await page.$("#clear");
    await seven.click();
    await seven.click();
    await seven.click();
    await multiply.click();
    await seven.click();
    await seven.click();
    await seven.click();
    await equals.click();
    const result = await page.$eval('#screen', e => e.innerText);
    expect(result).toMatch('603729');
    await clear.click();
  })
  it('Check that 3.14 divided by 2 is 1.57', async () => {
    const one = await page.$("#one");
    const two = await page.$("#two");
    const three = await page.$("#three");
    const four = await page.$("#four");
    const divide = await page.$("#divide");
    const decimal = await page.$("#decimal");
    const equals = await page.$("#equals");
    await three.click();
    await decimal.click();
    await one.click();
    await four.click();
    await divide.click();
    await two.click();
    await equals.click();
    const result = await page.$eval('#screen', e => e.innerText);
    expect(result).toMatch('1.57');
  })
})
  1. 创建一个包含以下内容的.huskyrc文件:
{
  "hooks": {
    "pre-commit": "npm run lint && npm test"
  }
}
  1. 通过运行npm install --save-dev husky安装husky作为开发人员依赖项:图 6.19:安装 Husky
图 6.19:安装 Husky
  1. 确保使用npm test命令正确运行测试:
npm test

这应该返回两个测试的正面结果,如下图所示:

图 6.20:显示两个测试的正面结果

图 6.20:显示两个测试的正面结果

通过进行测试提交来确保 Git 钩子和 linting 正常工作。

第七章:高级 JavaScript

活动 8:创建一个用户跟踪器

解决方案

  1. 打开Activity08.js文件并定义logUser。它将把用户添加到userList参数中。确保不会添加重复项:
function logUser(userList, user) {
if(!userList.includes(user)) {
userList.push(user);
}
}

在这里,我们使用includes方法来检查用户是否已经存在。如果他们不存在,他们将被添加到我们的列表中。

  1. 定义userLeft。它将从userList参数中移除用户。如果用户不存在,它将不执行任何操作:
function userLeft(userList, user) {
const userIndex = userList.indexOf(user);
if (userIndex >= 0) {
    userList.splice(userIndex, 1);
}
}

在这里,我们使用indexOf来获取要移除的用户的当前索引。如果该项不存在,indexOf返回-1,因此我们只在存在时使用splice来移除该项。

  1. 定义numUsers,返回当前列表中的用户数:
function numUsers(userList) {
return userLeft.length;
}
  1. 定义一个名为runSite的函数。我们将创建一个users数组,并调用我们之前声明的函数来测试我们的实现。之后我们也会调用该函数:
function runSite() {
    // Your user list for your website
    const users = [];
    // Simulate user viewing your site
    logUser(users, 'user1');
    logUser(users, 'user2');
    logUser(users, 'user3');
    // User left your website
    userLeft(users, 'user2');
    // More user goes to your website
    logUser(users, 'user4');
    logUser(users, 'user4');
    logUser(users, 'user5');
    logUser(users, 'user6');
    // More user left your website
    userLeft(users, 'user1');
    userLeft(users, 'user4');
    userLeft(users, 'user2');
    console.log('Current user: ', users.join(', '));
}
runSite();

在定义函数之后,运行上述代码将返回以下输出:

图 7.62:运行 log_users.js 的输出

图 7.62:运行 log_users.js 的输出

活动 9:使用 JavaScript 数组和类创建学生管理器

解决方案

  1. 创建一个包含所有学生信息的School类:
class School {
constructor() {
    this.students = [];
}
}

School构造函数中,我们只是初始化了一个学生列表。稍后,我们将向此列表添加新学生。

  1. 创建一个Student类,包括有关学生的所有相关信息:
class Student {
constructor(name, age, gradeLevel) {
    this.name = name;
    this.age = age;
    this.gradeLevel = gradeLevel;
    this.courses = [];
}
}

在学生constructor中,我们存储了课程列表,以及学生的agenamegradeLevel

  1. 创建一个Course类,其中包括有关课程的namegrade的信息:
class Course {
constructor(name, grade) {
    this.name = name;
    this.grade = grade;
}
}

课程构造函数只是将课程的名称和成绩存储在object中。

  1. School类中创建addStudent
addStudent(student) {
this.students.push(student);
}
  1. School类中创建findByGrade
findByGrade(gradeLevel) {
    return this.students.filter((s) => s.gradeLevel === gradeLevel);
}
  1. School类中创建findByAge
findByAge(age) {
return this.students.filter((s) => s.age === age);
}
  1. School类中创建findByName
findByName(name) {
return this.students.filter((s) => s.name === name);
}
  1. Student类中,创建一个calculateAverageGrade方法来计算学生的平均成绩:
calculateAverageGrade() {
const totalGrades = this.courses.reduce((prev, curr) => prev + curr.grade, 0);
return (totalGrades / this.courses.length).toFixed(2);
}

calculateAverageGrade方法中,我们使用数组 reduce 来获取学生所有课程的总成绩。然后,我们将其除以课程列表中的课程数。

  1. Student类中,创建一个名为assignGrade的方法,用于为学生正在上的课程分配数字成绩:
assignGrade(name, grade) {
this.courses.push(new Course(name, grade))
}

您应该在student_manager.js文件中进行工作,并修改提供的方法模板。如果您正确实现了所有内容,您应该看到TEST PASSED消息:

图 7.63:显示 TEST PASSED 消息的屏幕截图

图 7.63:显示 TEST PASSED 消息的屏幕截图

活动 10:重构函数以使用现代 JavaScript 功能

解决方案

  1. 打开Activity03.js;它应该包含用传统 JavaScript 编写的各种函数。当您使用 Node.js 运行Activity03.js时,您应该看到以下输出:图 7.64:运行 Lesson7-activity.js 后的输出
图 7.64:运行 Lesson7-activity.js 后的输出
  1. 您需要重构itemExist,使用includes数组:
function itemExist(array, item) {
    return array.includes(item);
}

In pushUnique we will use array push to add new item to the bottom
function pushUnique(array, item) {
    if (!itemExist(array, item)) {
        array.push(item);
    }
}

  1. createFilledArray中,我们将使用array.fill来用初始值填充我们的数组:
function createFilledArray(size, init) {
    const newArray = new Array(size).fill(init);
    return newArray;
}

In removeFirst we will use array.shift to remove the first item
function removeFirst(array) {
    return array.shift();
}
  1. removeLast中,我们将使用array.pop来移除最后一项:
function removeLast(array) {
    return array.pop();
}

In cloneArray we will use spread operation to make clone for our array
function cloneArray(array) {
    return […array];
}

  1. 我们将使用ES6类重构我们的Food类:
class Food {
    constructor(type, calories) {
        this.type = type;
        this.calories = calories;
    }
    getCalories() {
        return this.calories;
    }
}

在您完成重构并运行现有代码后,您应该看到相同的输出:

图 7.65:显示 TEST PASSED 消息的输出

图 7.65:显示 TEST PASSED 消息的输出

第八章:异步编程

活动 11:使用回调接收结果

解决方案

  1. 创建一个calculate函数,它接受idcallback作为参数:
function calculate(id, callback) {
}
  1. 我们将首先调用getUsers来获取所有用户。这将给我们所需的地址:
function calculate(id, callback) {
clientApi.getUsers((error, result) => {
if (error) { return callback(error); }
const currentUser = result.users.find((user) => user.id === id);
if (!currentUser) { return callback(new Error('user not found')); }
});
  }

在这里,我们获取所有用户,然后对user应用find方法来从列表中找到我们想要的用户。如果该用户不存在,我们将使用User not found错误调用callback函数。

  1. 调用getUsage来获取用户的使用情况:
clientApi.getUsage(id, (error, usage) => {
if (error) { return callback(error); }
  });

然后,我们需要将对getUsers的调用放在getUsage的回调函数中,这样它将在我们完成调用getUsers后运行。在这里,回调函数将被调用并传入一个数字列表,这将是使用情况。如果我们从getUsage收到错误,我们还将使用错误对象调用回调函数。

  1. 最后,调用getRate以获取我们正在计算的用户的费率:
clientApi.getRate(id, (error, rate) => {
if (error) { return callback(error); }
let totalUsage = 0;
for (let i = 0; i < usage.length; i++) {
    totalUsage += usage[i];
}
callback(null, {
id,
address: currentUser.address,
due: rate * totalUsage
});
});

我们将把这个调用放在getUsage的回调函数中。这为我们需要的所有信息创建了一个嵌套的链请求。最后,我们将使用数组 reduce 来计算该用户的总使用量,然后将其乘以费率以获得最终应付金额。

  1. 当函数完成时,使用现有 ID 调用它,如下面的代码:
calculate('DDW2AU', (error, result) => {
    console.log(error, result);
});

您应该看到以下输出:

图 8.43:使用现有 ID 调用函数

图 8.43:使用现有 ID 调用函数
  1. 使用一个不存在的 ID 调用函数:
calculate('XXX', (error, result) => {
    console.log(error, result);
});

您应该看到返回的错误如下:

图 8.44:使用不存在的 ID 调用函数

图 8.44:使用不存在的 ID 调用函数

活动 12:使用异步和等待重构账单计算器

解决方案

  1. calculate函数创建为async函数:
async function calculate(id) {
}
  1. 使用await调用getUsers以获取users中的解析结果:
const users = await clientApi.getUsers();
const currentUser = users.users.find((user) => user.id === id);

当我们使用await关键字时,我们必须使用async函数。await关键字将打破程序的控制,并且只有在等待的 promise 被解析后才会返回并继续执行。

  1. 使用await调用getUsage以获取用户的使用情况:
const usage = await clientApi.getUsage(currentUser.id);
  1. 使用await调用getRate以获取用户的费率:
const rate = await clientApi.getRate(currentUser.id);
  1. 最后,我们将调用return以检索idaddressdue
return {
id,
address: currentUser.address,
due: (rate * usage.reduce((prev, curr) => curr + prev)).toFixed(2)
};
  1. calculateAll函数创建为async函数:
async function calculateAll() {
}
  1. 在调用getUsers时使用await并将结果存储在result中:
const result = await clientApi.getUsers();
  1. 使用映射数组创建一个 promise 列表,并使用Promise.all将它们包装起来。然后,应该在Promise.all返回的 promise 上使用await
return await Promise.all(result.users.map((user) => calculate(user.id)));

因为await将在任何 promise 上工作,并且会等待直到值被解析,它也会等待我们的Promise.all。在它被解析后,最终数组将被返回。

  1. 在一个用户上调用calculate
calculate('DDW2AU').then(console.log)

输出应如下所示:

图 8.45:在一个用户上调用 calculate

图 8.45:在一个用户上调用 calculate
  1. 调用calculateAll函数:
calculateAll().then(console.log)

输出应如下所示:

图 8.46:调用 calculateAll 函数

图 8.46:调用 calculateAll 函数

正如您所看到的,当我们调用async函数时,我们可以将它们视为返回 promise 的函数。

第九章:事件驱动编程和内置模块

活动 13:构建事件驱动模块

解决方案

执行以下步骤完成此活动:

  1. 导入events模块:
const EventEmitter = require('events');
  1. 创建SmokeDetector类,它扩展了EventEmitter并将batteryLevel设置为10
class SmokeDetector extends EventEmitter {
    constructor() {
        super();
        this.batteryLevel = 10;
    }
}

在我们的构造函数中,因为我们正在扩展EventEmitter类并且正在分配一个自定义属性batteryLevel,我们需要在构造函数中调用super并将batteryLevel设置为10

  1. SmokeDetector类中创建一个test方法,该方法将测试电池电量,并在电池电量低时发出低电量消息:
test() {
        if (this.batteryLevel > 0) {
            this.batteryLevel -= 0.1;
            if (this.batteryLevel < 0.5) {
                this.emit('low battery');
            }
            return true;
        }
        return false;
    }

我们的test()方法将检查电池电量,并在电池电量低于 0.5 单位时发出低电量事件。每次运行test方法时,我们还会减少电池电量。

  1. 创建House类,它将存储我们事件监听器的实例:
class House {
    constructor(numBedroom, numBathroom, numKitchen) {
        this.numBathroom = numBathroom;
        this.numBedroom = numBedroom;
        this.numKitchen = numKitchen;
        this.alarmListener = () => {
            console.log('alarm is raised');
        }
        this.lowBatteryListener = () => {
            console.log('alarm battery is low');
        }
    }
}

House类中,我们存储了一些关于房子的信息。我们还将两个事件侦听器函数存储为此对象的属性。这样,我们可以使用函数引用在想要分离侦听器时调用removeListener

  1. House类中创建一个addDetector方法。在这里,我们将附加事件侦听器:
addDetector(detector) {
        detector.on('alarm', this.alarmListener);
        detector.on('low battery', this.lowBatteryListener);
    }

在这里,我们期望传入的探测器是一个EventEmitter。我们将两个事件侦听器附加到我们的detector参数。当这些事件被触发时,它将调用我们对象内的事件发射器。

  1. 创建一个removeDetector方法,它将帮助我们删除先前附加的警报事件侦听器:
removeDetector(detector) {
        detector.removeListener('alarm', this.alarmListener);
        detector.removeListener('low battery', this.lowBatteryListener);
    }

在这里,我们使用函数引用和警报参数来删除附加到我们侦听器的侦听器。一旦调用了这个,事件就不应该再次调用我们的侦听器。

  1. 创建一个名为myHouseHouse实例。这将包含关于我们房子的一些示例信息。它还将用于监听我们的烟雾探测器发出的事件:
const myHouse = new House(2, 2, 1);
  1. 创建一个名为detectorSmokeDetector实例:
const detector = new SmokeDetector();
  1. 将我们的detector添加到myHouse
myHouse.addDetector(detector);
  1. 创建一个循环来调用测试函数96次:
for (let i = 0; i < 96; i++) {
    detector.test();
}

因为测试函数将减少电池电量,如果我们调用它96次,我们将期望发出低电量警报。这将产生以下输出:

图 9.50:发出低电量警报

图 9.50:发出低电量警报
  1. detector对象上发出警报:
detector.emit('alarm');

以下是前面代码的输出:

图 9.51:对检测器对象发出的警报

图 9.51:对检测器对象发出的警报
  1. myHouse对象中删除detector
myHouse.removeDetector(detector);
  1. 测试这个以在detector上发出警报:
detector.test();
detector.emit('alarm');

因为我们刚刚从我们的房子中移除了detector,所以我们不应该看到这个输出:

图 9.52:测试检测器上的发出警报

图 9.52:测试检测器上的发出警报

活动 14:构建文件监视器

解决方案

  1. 导入fsevents
const fs = require('fs').promises;
const EventEmitter = require('events');
  1. 创建一个扩展EventEmitter类的fileWatcher类。使用modify时间戳来跟踪文件更改。

我们需要创建一个扩展EventEmitterFileWatcher类。它将在构造函数中以文件名和延迟作为参数。在构造函数中,我们还需要设置上次修改时间和计时器变量。现在我们将它们保持为未定义:

class FileWatcher extends EventEmitter {
    constructor(file, delay) {
        super();
        this.timeModified = undefined;
        this.file = file;
        this.delay = delay;
        this.watchTimer = undefined;
    }
}

这是查看文件是否已更改的最基本方法。

  1. 创建startWatch方法以开始监视文件的更改:
startWatch() {
        if (!this.watchTimer) {
            this.watchTimer = setInterval(() => {
                fs.stat(this.file).then((stat) => {
                    if (this.timeModified !== stat.mtime.toString()) {
                        console.log('modified');
                        this.timeModified = stat.mtime.toString();
                    }
                }).catch((error) => {
                    console.error(error);
                });
            }, this.delay);
        }
    }

在这里,我们使用fs.stat来获取文件的信息,并将修改时间与上次修改时间进行比较。如果它们不相等,我们将在控制台中输出修改

  1. 创建stopWatch方法以停止监视文件的更改:
stopWatch() {
        if (this.watchTimer) {
            clearInterval(this.watchTimer);
            this.watchTimer = undefined;
        }
       }

stopWatch方法非常简单:我们将检查这个对象中是否有一个计时器。如果有,那么我们将在该计时器上运行clearInterval以清除该计时器。

  1. 在与filewatch.js相同的目录中创建一个名为test.txt的文件。

  2. 创建一个FileWatcher实例并每1000毫秒开始监视文件:

const watcher = new FileWatcher('test.txt', 1000);
watcher.startWatch();
  1. 修改test.txt中的一些内容并保存。您应该看到以下输出:图 9.53:修改文件内容后的输出
图 9.53:修改test.txt文件内容后的输出

我们修改了文件两次,这意味着我们看到了三条修改消息。这是因为当我们开始观察时,我们将其视为文件已被修改。

  1. 修改startWatch以检索新内容:
startWatch() {
        if (!this.watchTimer) {
            this.watchTimer = setInterval(() => {
                fs.stat(this.file).then((stat) => {
                    if (this.timeModified !== stat.mtime.toString()) {
                        fs.readFile(this.file, 'utf-8').then((content) => {
                            console.log('new content is: ', content);
                        }).catch((error) => {
                            console.error(error);
                        });
                        this.timeModified = stat.mtime.toString();
                    }
                }).catch((error) => {
                    console.error(error);
                });
            }, this.delay);
        }
    }

当我们修改test.txt并保存时,我们的代码应该检测到并输出新内容:

图 9.54:可以使用 startWatch 函数看到对文件所做的修改

图 9.54:可以使用 startWatch 函数看到对文件所做的修改
  1. 修改startWatch,使其在文件被修改时发出事件,并在遇到错误时发出错误:
startWatch() {
        if (!this.watchTimer) {
            this.watchTimer = setInterval(() => {
                fs.stat(this.file).then((stat) => {
                    if (this.timeModified !== stat.mtime.toString()) {
                        fs.readFile(this.file, 'utf-8').then((content) => {
                            this.emit('change', content);
                        }).catch((error) => {
                            this.emit('error', error);
                        });
                        this.timeModified = stat.mtime.toString();
                    }
                }).catch((error) => {
                    this.emit('error', error);
                });
            }, this.delay);
        }
    }

我们将不再输出内容,而是发出一个带有新内容的事件。这使我们的代码更加灵活。

  1. 将事件处理程序附加到error并在我们的文件watcher上更改它们:
watcher.on('error', console.error);
watcher.on('change', (change) => {
    console.log('new change:', change);
});
  1. 运行代码并修改test.txt

图 9.55:更改文件监视器后的输出

图 9.55:更改文件监视器后的输出

第十章:使用 JavaScript 进行函数式编程

活动 15:onCheckout 回调属性

解决方案

  1. 将当前目录更改为Lesson10,如果之前在此目录中尚未执行过npm install,则运行npm installnpm install会下载运行此活动所需的依赖项(React 和 Parcel)。

  2. 运行parcel serve activity-on-checkout-prop-start.html,然后执行npm run Activity15。您将看到应用程序启动,如下所示:图 10.42:运行 start html 脚本后的输出

图 10.42:运行 start html 脚本后的输出
  1. 转到http://localhost:1234(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.43:浏览器中的初始应用程序
图 10.43:浏览器中的初始应用程序
  1. 继续结账onClick可以实现如下:
  render() {
    return (
      <div>
        <p>You have {this.state.items.length} items in your basket</p>
        <button onClick={() => this.props.onCheckout(this.state.items)}>
          Proceed to checkout
        </button>
      </div>
    );
  }

这是以下调查的延续:

Basket组件的render方法中查找文本为“继续结账”的按钮。

注意到它的onClick处理程序当前是一个在调用时什么都不做的函数,() => {}

用正确的调用this.props.onCheckout替换onClick处理程序。

  1. 单击“继续结账”按钮后,我们应该看到以下内容:

图 10.44:单击“继续结账”按钮后的输出

图 10.44:单击“继续结账”按钮后的输出

活动 16:测试选择器

解决方案

  1. 运行npm run Activity16(或node activity-items-selector-test-start.js)。您将看到以下输出:图 10.45:运行活动的初始启动文件后的预期输出
图 10.45:运行活动的初始启动文件后的预期输出
  1. 测试一下,对于空状态,选择器返回[]
function test() {
  assert.deepStrictEqual(
    selectBasketItems(),
    [],
    'should be [] when selecting with no state'
  );
  assert.deepStrictEqual(
    selectBasketItems({}),
    [],
    'should be [] when selecting with {} state'
  );
}
  1. 测试一下,对于一个空的购物篮对象,选择器返回[]:
function test() {
  // other assertions
  assert.deepStrictEqual(
    selectBasketItems({basket: {}}),
    [],
    'should be [] when selecting with {} state.basket'
  );
}
  1. 测试一下,如果项目数组已设置但为空,则选择器返回[]
function test() {
  // other assertions
  assert.deepStrictEqual(
    selectBasketItems({basket: {items: []}}),
    [],
    'should be [] when items is []'
  );
}
  1. 测试一下,如果items数组不为空且已设置,则选择器返回它:
function test() {
  // other assertions
  assert.deepStrictEqual(
    selectBasketItems({
      basket: {items: [{name: 'product-name'}]}
    }),
    [{name: 'product-name'}],
    'should be items when items is set'
  );
}
The full test function content after following the previous solution steps:
function test() {
  assert.deepStrictEqual(
    selectBasketItems(),
    [],
    'should be [] when selecting with no state'
  );
  assert.deepStrictEqual(
    selectBasketItems({}),
    [],
    'should be [] when selecting with {} state'
  );
  assert.deepStrictEqual(
    selectBasketItems({basket: {}}),
    [],
    'should be [] when selecting with {} state.basket'
  );
  assert.deepStrictEqual(
    selectBasketItems({basket: {items: []}}),
    [],
    'should be [] when items is []'
  );
  assert.deepStrictEqual(
    selectBasketItems({
      basket: {items: [{name: 'product-name'}]}
    }),
    [{name: 'product-name'}],
    'should be items when items is set'
  );
}
  1. 实施测试的输出中不应该有错误:

图 10.46:最终输出显示没有错误

图 10.46:最终输出显示没有错误

活动 17:从 BFF 获取当前购物篮

解决方案

  1. 将当前目录更改为Lesson10,如果之前在此目录中尚未执行过npm install,则运行npm install

  2. 运行 Activity 17 的 BFF 和npx parcel serve activity-app-start.html。在开发过程中,运行npm run Activity17。您将看到应用程序启动,如下所示:图 10.47:运行活动的初始启动文件

图 10.47:运行活动的初始启动文件
  1. 转到http://localhost:1234(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:图 10.48:浏览器中的初始应用程序
图 10.48:浏览器中的初始应用程序
  1. 在 GraphiQL UI 中运行以下查询:
{
  basket {
    items {
      id
      name
      price
      quantity
    }
  }
}

以下是前面代码的输出:

图 10.49:带有购物篮查询的 GraphiQL UI

图 10.49:带有购物篮查询的 GraphiQL UI
  1. 创建一个新的requestBasket动作创建器(利用 redux-thunk)。它使用上一步的查询调用fetchFromBff,并分派一个从 GraphQL 响应中提取的购物篮有效负载的REQUEST_BASKET_SUCCESS动作:
function requestBasket() {
  return dispatch => {
    fetchFromBff(`{
      basket {
        items {
          id
          name
          price
          quantity
        }
      }
    }`).then(data => {
      dispatch({
        type: REQUEST_BASKET_SUCCESS,
        basket: data.basket
      });
    });
  };
}
  1. 将篮子数据减少到存储中,并将以下情况添加到appReducer中,以将我们新的REQUEST_BASKET_SUCCESS动作的basket负载减少到状态中:
const appReducer = (state = defaultState, action) => {
  switch (action.type) {
    // other cases
    case REQUEST_BASKET_SUCCESS:
      return {
        ...state,
        basket: action.basket
      };
    // other cases
  }
};
  1. mapDispatchToProps中添加requestBasket,如下所示:
const mapDispatchToProps = dispatch => {
  return {
    // other mapped functions
    requestBasket() {
      dispatch(requestBasket());
    }
  };
};
  1. componentDidMount上调用requestBasket
class App extends React.Component {
  componentDidMount() {
    this.props.requestBasket();
  }
  // render method
}

当使用所有前述步骤加载应用程序时,它会闪烁显示“您的篮子中有 0 件物品”的消息,然后变成以下屏幕截图。当从 BFF 获取完成时,它会减少到存储中并导致重新渲染。这将再次显示篮子,如下所示:

图 10.50:一旦与 BFF 集成,最终应用程序

图 10.50:一旦与 BFF 集成,最终应用程序
posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报