JavaScript-专家级编程-全-
JavaScript 专家级编程(全)
原文:
zh.annas-archive.org/md5/918F303F1357704D1EED66C3323DB7DD
译者:飞龙
第一章:前言
关于
本节简要介绍了作者、本书的内容、开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。
关于本书
深入了解 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 可用空间
软件要求
我们还建议您提前安装以下软件:
-
Git 最新版本
-
Node.js 10.16.3 LTS (
nodejs.org/en/
)
约定
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 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:
-
在
nodejs.org/en/download/current/
官方安装页面上找到您想要的 Node.js 版本。 -
确保选择 Node.js 12(当前版本)。
-
确保您为计算机系统安装了正确的架构;即 32 位或 64 位。您可以在操作系统的系统属性窗口中找到这些信息。
-
下载安装程序后,只需双击文件,然后按照屏幕上的用户友好提示操作即可。
在 Linux 上安装 Node.js 和 npm:
在 Linux 上安装 Node.js,您有几个不错的选择:
-
要在未详细介绍的系统上通过 Linux 软件包管理器安装 Node.js,请参阅
nodejs.org/en/download/package-manager/
。 -
要在 Ubuntu 上安装 Node.js,请运行此命令(更多信息和手动安装说明可在
github.com/nodesource/distributions/blob/master/README.md#installation-instructions
找到):
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
- 要在基于 Debian 的发行版上安装 Node.js(更多信息和手动安装说明可在
github.com/nodesource/distributions/blob/master/README.md#installation-instructions
找到):
# As root
curl -sL https://deb.nodesource.com/setup_12.x | bash -
apt-get install -y nodejs
- 官方 Node.js 安装页面还提供了一些 Linux 系统的其他安装选项:
nodejs.org/en/download/current/
。
在 macOS 上安装 Node.js 和 npm:
与 Linux 类似,Mac 上安装 Node.js 和 npm 有几种方法。要在 macOS X 上安装 Node.js 和 npm,请执行以下操作:
-
按下cmd + Spacebar打开 Mac 的终端,输入
terminal
并按下Enter。 -
通过运行
xcode-select --install
命令行来安装 Xcode。 -
安装 Node.js 和 npm 的最简单方法是使用 Homebrew,通过运行
ruby -e "$(curl -fsSL
(raw.githubusercontent.com/Homebrew/install/master/install
)来安装 Homebrew。 -
最后一步是安装 Node.js 和 npm。在命令行上运行
brew install node
。 -
同样,您也可以通过
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:段落节点包含文本节点
一切都变成了节点。文本,元素和注释,一直到树的根部。这棵树用于匹配 CSS 样式并渲染页面。它还被转换为对象,并提供给 JavaScript 运行时使用。
但为什么它被称为 DOM 呢?因为 HTML 最初是设计用来共享文档,而不是设计我们今天拥有的丰富动态应用程序。这意味着每个 HTML DOM 都以一个文档元素开始,所有元素都附加到该元素上。考虑到这一点,前面的 DOM 树示意图实际上变成了以下内容:
图 1.2:所有 DOM 树都有一个文档元素作为根
当我说浏览器使 DOM 可用于 JavaScript 运行时时,这意味着如果您在 HTML 页面中编写一些 JavaScript 代码,您可以访问该树并对其进行一些非常有趣的操作。例如,您可以轻松访问文档根元素并访问页面上的所有节点,这就是您将在下一个练习中要做的事情。
练习 1:在文档中迭代节点
在这个练习中,我们将编写 JavaScript 代码来查询 DOM 以查找按钮,并向其添加事件侦听器,以便在用户单击按钮时执行一些代码。事件发生时,我们将查询所有段落元素,计数并存储它们的内容,然后在最后显示一个警报。
此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise01
找到。
执行以下步骤完成练习:
- 打开您喜欢的文本编辑器,并创建一个名为
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>
- 在
body
元素的末尾,添加一个script
标签,使最后几行看起来像下面这样:
</div>
<button>Click me!</button>
<script>
</script>
</body>
</html>
- 在
script
标签内,为按钮的点击事件添加一个事件监听器。为此,你需要查询文档对象以找到所有带有button
标签的元素,获取第一个(页面上只有一个按钮),然后调用addEventListener
:
document.getElementsByTagName('button')[0].addEventListener('click', () => {});
- 在事件监听器内部,再次查询文档以查找所有段落元素:
const allParagraphs = document.getElementsByTagName('p');
- 之后,在事件监听器内创建两个变量,用于存储你找到的段落元素的数量和存储它们的内容:
let allContent = "";
let count = 0;
- 迭代所有段落元素,计数它们,并存储它们的内容:
for (let i = 0; i < allParagraphs.length; i++) { const node = allParagraphs[i];
count++;
allContent += `${count} - ${node.textContent}\n`;
}
- 循环结束后,显示一个警报,其中包含找到的段落数和它们所有内容的列表:
alert(`Found ${count} paragraphs. Their content:\n${allContent}`);
你可以在这里看到最终的代码应该是什么样子的:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise01/alert_paragraphs.html
。
在浏览器中打开 HTML 文档并点击按钮,你应该会看到以下警报:
图 1.3:显示页面上段落信息的警报框
在这个练习中,我们编写了一些 JavaScript 代码,查询了特定元素的 DOM。我们收集了元素的内容,以在警报框中显示它们。
我们将在本章的后续部分探索其他查询 DOM 和迭代节点的方法。但是从这个练习中,你已经可以看到这是多么强大,并开始想象这开启了哪些可能性。例如,我经常使用它来计数或从互联网上的网页中提取我需要的数据。
开发者工具
现在我们了解了 HTML 源代码和 DOM 之间的关系,我们可以使用一个非常强大的工具来更详细地探索它:浏览器开发者工具。在本书中,我们将探索谷歌 Chrome 的DevTools,但你也可以在所有其他浏览器中轻松找到等效的工具。
我们要做的第一件事是探索我们在上一节中创建的页面。当你在谷歌 Chrome 中打开它时,你可以通过打开Chrome菜单来找到开发者工具。然后选择更多工具和开发者工具来打开开发者工具:
图 1.4:在谷歌 Chrome 中访问开发者工具
开发者工具将在页面底部打开一个面板:
图 1.5:谷歌 Chrome DevTools 打开时的面板
你可以在顶部看到提供加载页面上发生的不同视角的各种选项卡。在本章中,我们将主要关注三个选项卡:
- 元素 – 显示浏览器看到的 DOM 树。你可以检查浏览器如何查看你的 HTML,CSS 如何被应用,以及哪些选择器激活了每个样式。你还可以改变节点的状态,模拟特定状态,比如
hover
或visited
:
图 1.6:元素选项卡的视图
- 控制台 – 在页面的上下文中提供对 JavaScript 运行时的访问。在加载页面后,可以使用控制台来测试简短的代码片段。它还可以用于打印重要的调试信息:
图 1.7:控制台选项卡的视图
- 源 – 显示当前页面加载的所有源代码。这个视图可以用来设置断点和开始调试会话:
图 1.8:源选项卡的视图
选择元素选项卡,你会看到当前文档的 DOM 树:
图 1.9:在 Chrome DevTools 中查看的元素选项卡中的 DOM 树
练习 2:从元素选项卡操作 DOM
为了感受到这个工具有多强大,我们将对练习 1:遍历文档中的节点中的页面进行一些更改。我们将在其中添加一个新段落并删除一个现有的段落。然后,我们将使用样式侧边栏来更改元素的一些样式。
执行以下步骤完成练习:
- 首先,右键单击
body
元素,然后选择编辑为 HTML:
图 1.10:编辑 HTML 主体元素
- 这将把节点更改为一个可以输入的文本框。在第一个段落下面,添加另一个文本为另一个段落的段落。它应该看起来像下面这样:
图 1.11:在 HTML 主体中添加一个新段落
-
按下Ctrl + Enter(或 Mac 上的Cmd + Enter)保存您的更改。
-
再次单击点击我!按钮,您会看到新段落及其内容现在显示在列表中:
图 1.12:显示所有段落内容的警报,包括添加到页面中的段落
- 您还可以玩弄元素的样式,并在页面上实时看到变化。让我们将第一个段落的背景更改为黑色,颜色更改为白色。首先,通过单击 DOM 树上的它来选择它;它会变成蓝色以表示已选择:
图 1.13:在元素选项卡上选择 DOM 元素
- 现在,在右侧,您会看到样式选项卡。它包含已应用于元素的样式和一个用于元素样式的空占位符。单击它,您将获得一个输入框。输入background: black,按下Enter,然后输入color: white,再次按下Enter。您会看到随着您的输入,元素会发生变化。最终,它将看起来像下面这样:
图 1.14:左侧的样式化段落和右侧的应用样式
- 您还可以通过单击样式选项卡右上角的新规则按钮来创建一个应用于页面的新 CSS 规则:
图 1.15:当您单击添加新规则时,它将基于所选元素(在本例中为段落)添加一个新规则
- 让我们添加类似的规则来影响所有段落,输入background: green,按下Enter,输入color: yellow,然后按下Enter。现在除了第一个段落外,所有段落都将具有绿色背景和黄色文本。页面现在应该是这样的:
图 1.16:向段落添加规则
在这个练习中,您改变了页面的 DOM,并实时看到了变化。您向页面添加了元素,更改了一个元素的样式,然后添加了一个新的 CSS 规则来影响更广泛的元素组。
像这样实时操作 DOM 对于您试图弄清布局并测试一些迭代或操作 DOM 元素的代码的情况非常有用。在我们的情况下,我们可以轻松测试如果我们向页面添加一个新段落元素会发生什么。
练习 3:从源选项卡调试代码
我们之前提到过,您可以从源选项卡调试代码。要做到这一点,您只需要设置一个断点,并确保代码通过该点。在这个练习中,我们将在调试我们的代码时探索源选项卡。
执行以下步骤完成练习:
- 您需要做的第一件事是在“开发者工具”面板中选择“源”选项卡。然后,打开我们目前拥有的一个源文件。您可以通过在左侧面板中点击它来实现这一点:
图 1.17:源选项卡显示了如何找到您的源文件
- 要在源代码中设置断点,您需要点击行号所在的边栏,在您想要设置断点的行处点击。在这个练习中,我们将在事件处理程序内的第一行设置一个断点。一个蓝色的箭头符号将出现在那一行上:
图 1.18:断点显示为源文件边栏上的箭头标记
- 点击页面上的“点击我!”按钮来触发代码执行。您会注意到发生了两件事情 - 浏览器窗口冻结了,并且有一条消息表明代码已经暂停了:
图 1.19:当浏览器遇到断点时,执行会暂停
- 此外,正在执行的代码行在“源”选项卡中得到了突出显示:
图 1.20:源代码中的执行暂停,突出显示将要执行的下一行
- 在侧边栏中,注意当前执行的堆栈和当前作用域中的所有内容,无论是全局还是局部。这是右侧面板的视图,显示了有关运行代码的所有重要信息:
图 1.21:源选项卡右侧显示了当前暂停执行的执行上下文和堆栈跟踪
- 顶部的工具栏可以用来控制代码执行。每个按钮的功能如下:
“播放”按钮结束暂停并正常继续执行。
“步过”按钮会执行当前行直到完成,并在下一行再次暂停。
点击“步入”按钮将执行当前行并步入任何函数调用,这意味着它将在被调用的函数内的第一行暂停。
“步出”按钮将执行所有必要的步骤以退出当前函数。
“步”按钮将执行下一个操作。如果是函数调用,它将步入。如果不是,它将继续执行下一行。
- 按下“步过”按钮,直到执行到第 20 行:
图 1.22:突出显示的行显示了执行暂停以进行调试
- 在右侧的“作用域”面板上,您会看到四个作用域:两个“块”作用域,然后一个“局部”作用域和一个“全局”作用域。作用域将根据您在代码中的位置而变化。在这种情况下,第一个“块”作用域仅包括
for
循环内的内容。第二个“块”作用域是整个循环的作用域,包括在for
语句中定义的变量。“局部”是函数作用域,“全局”是浏览器作用域。这是您应该看到的:
图 1.23:作用域面板显示了当前执行上下文中不同作用域中的所有变量
- 此时要注意的另一件有趣的事情是,如果你将鼠标悬停在当前页面中的 HTML 元素上,Chrome 会为你突出显示该元素:
图 1.24:Chrome 在不同位置悬停时突出显示 DOM 元素
使用源选项卡调试代码是作为 Web 开发人员最重要的事情之一。了解浏览器如何看待你的代码,以及每行中变量的值是解决复杂应用程序中问题的最简单方法。
注意
内联值:当你在源选项卡中调试时逐步执行代码时,你会注意到 Chrome 在每行的侧边添加了一些浅橙色的突出显示,显示了在该行中受影响的变量的当前值。
控制台选项卡
现在你知道如何在元素选项卡中遍历和操作 DOM 树,以及如何在源选项卡中探索和调试代码,让我们来探索一下控制台选项卡。
控制台选项卡可以帮助你调试问题,也可以探索和测试代码。为了了解它能做什么,我们将使用本书代码库中Lesson01/sample_002
文件夹中的示例商店。
打开商店页面,你会看到这是一个食品产品的商店。它看起来是这样的:
图 1.25:商店示例页面的屏幕截图
在底层,你可以看到 DOM 非常简单。它有一个section
元素,其中包含所有的页面内容。里面有一个带有类项的div
标签,代表产品列表,以及每个产品的一个带有类项的div
。在元素选项卡中,你会看到这样的内容:
图 1.26:商店页面的 DOM 树非常简单
回到控制台选项卡:你可以在这个 DOM 中运行一些查询来了解更多关于元素和内容的信息。让我们写一些代码来列出所有产品的价格。首先,我们需要找到 DOM 树中的价格在哪里。我们可以查看元素选项卡,但现在,我们将只使用控制台选项卡来学习更多。在控制台选项卡中运行以下代码将打印一个包含 21 个项目的HTMLCollection
对象:
document.getElementsByClassName('item')
让我们打开第一个,看看里面有什么:
document.getElementsByClassName('item')[0]
现在你看到 Chrome 打印了一个 DOM 元素,如果你在上面悬停,你会看到它在屏幕上被突出显示。你也可以打开在控制台选项卡中显示的迷你 DOM 树,看看元素是什么样子的,就像在元素选项卡中一样:
图 1.27:控制台选项印刷 DOM 中的元素
你可以看到价格在一个span
标签内。要获取价格,你可以像查询根文档一样查询元素。
注意:自动完成和之前的命令
在控制台选项卡中,你可以通过按下Tab来使用基于当前上下文的自动完成,并通过按上/下箭头键快速访问之前的命令。
运行以下代码来获取列表中第一个产品的价格:
document.getElementsByClassName('item')[0]
.getElementsByTagName('span')[0].textContent
产品的价格将显示在控制台中作为一个字符串:
图 1.28:查询包含价格的 DOM 元素并获取其内容
活动 1:从页面中提取数据
假设您正在编写一个需要来自 Fresh Products Store 的产品和价格的应用程序。商店没有提供 API,其产品和价格大约每周变化一次-不够频繁以证明自动化整个过程是合理的,但也不够慢以至于您可以手动执行一次。如果他们改变了网站的外观方式,您也不想麻烦太多。
您希望以一种简单生成和解析的方式为应用程序提供数据。最终,您得出结论,最简单的方法是生成一个 CSV,然后将其提供给您的应用程序。
在这个活动中,您将编写一些 JavaScript 代码,可以将其粘贴到商店页面的控制台选项卡中,并使用它从 DOM 中提取数据,将其打印为 CSV,以便您的应用程序消费。
注意:在控制台选项卡中的长代码
在 Chrome 控制台中编写长代码时,我建议在文本编辑器中进行,然后在想要测试时粘贴它。控制台在编辑代码时并不糟糕,但在尝试修改长代码时很容易搞砸事情。
执行以下步骤:
-
初始化一个变量来存储 CSV 的整个内容。
-
查询 DOM 以找到表示每个产品的所有元素。
-
遍历找到的每个元素。
-
从
product
元素中,查询带有单位的价格。使用斜杠拆分字符串。 -
再次,从
product
元素中查询名称。 -
将所有信息附加到步骤 1 中初始化的变量中,用逗号分隔值。不要忘记为附加的每一行添加换行字符。
-
使用
console.log
函数打印包含累积数据的变量。 -
在打开商店页面的控制台选项卡中运行代码。
您应该在控制台选项卡中看到以下内容:
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.30:在元素选项卡中查看的描述节点
Node.TEXT_NODE
- 标签内的文本变成文本节点。如果您从description
节点获取第一个子节点,您会发现它的类型是TEXT_NODE
:
图 1.31:标签内的文本变成文本节点
这是在元素选项卡中查看的节点:
图 1.32:在元素选项卡中选择的文本节点
Node.DOCUMENT_NODE
- 每个 DOM 树的根是一个document
节点:
图 1.33:树的根始终是文档节点
一个重要的事情要注意的是html
节点不是根节点。当创建 DOM 时,document
节点是根节点,它包含html
节点。您可以通过获取document
节点的第一个子节点来确认:
图 1.34:html 节点是文档节点的第一个子节点
nodeName
是节点具有的另一个重要属性。在element
节点中,nodeName
将为您提供它们的 HTML 标签。其他节点类型将返回不同的内容。document
节点将始终返回#document
(如前图所示),而Text
节点将始终返回#text
。
对于TEXT_NODE
、CDATA_SECTION_NODE
和COMMENT_NODE
等类似文本的节点,您可以使用nodeValue
来获取它们所包含的文本。
但节点最有趣的地方在于你可以像遍历树一样遍历它们。它们有子节点和兄弟节点。让我们在下面的练习中稍微练习一下这些属性。
练习 4:遍历 DOM 树
在这个练习中,我们将遍历图 1.1中示例页面中的所有节点。我们将使用递归策略来迭代所有节点并打印整个树。
执行以下步骤以完成练习:
-
第一步是打开文本编辑器并设置它以编写一些 JavaScript 代码。
-
要使用递归策略,我们需要一个函数,该函数将被调用以处理树中的每个节点。该函数将接收两个参数:要打印的节点和节点在 DOM 树中的深度。以下是函数声明的样子:
function printNodes(node, level) {
}
- 函数内部的第一件事是开始标识将要打开此节点的消息。为此,我们将使用
nodeName
,对于HTMLElements
,它将给出标签,对于其他类型的节点,它将给出一个合理的标识符:
let message = `${"-".repeat(4 * level)}Node: ${node.nodeName}`;
- 如果节点也有与之关联的
nodeValue
,比如Text
和其他文本行节点,我们还将将其附加到消息中,然后将其打印到控制台:
if (node.nodeValue) {
message += `, content: '${node.nodeValue.trim()}'`;
}
console.log(message);
- 之后,我们将获取当前节点的所有子节点。对于某些节点类型,
childNodes
属性将返回 null,因此我们将添加一个空数组的默认值,以使代码更简单:
var children = node.childNodes || [];
- 现在我们可以使用
for
循环来遍历数组。对于我们找到的每个子节点,我们将再次调用该函数,启动算法的递归性质:
for (var i = 0; i < children.length; i++) {
printNodes(children[i], level + 1);
}
- 函数内部的最后一件事是打印具有子节点的节点的关闭消息:
if (children.length > 0) {
console.log(`${"-".repeat(4 * level)}End of:${node.nodeName}`);}
- 现在我们可以通过调用该函数并将文档作为根节点传递,并在函数声明结束后立即将级别设置为零来启动递归:
printNodes(document, 0);
最终的代码应该如下所示:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise04/open_close_tree_print.js
。
-
在 Chrome 中打开示例 HTML。文件位于:
bit.ly/2maW0Sx
。 -
打开开发者工具面板,在控制台选项卡中粘贴 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:浏览器中的全局范围和默认绑定目标是窗口对象
window
对象包含您需要从浏览器访问的所有内容:位置、导航历史、其他窗口(弹出窗口)、本地存储等等。document
和console
对象也归属于window
对象。当您访问document
对象时,实际上是在使用window.document
对象,但绑定是隐式的,因此您不需要一直写window
。而且因为window
是一个全局对象,这意味着它必须包含对自身的引用:
图 1.42:窗口对象包含对自身的引用
使用 JavaScript 查询 DOM
我们一直在讨论通过document
对象查询 DOM。但是我们用来查询 DOM 的所有方法也可以从 DOM 中的元素中调用。本节介绍的方法也可以从 DOM 中的元素中调用。我们还将看到一些只能从元素中而不是document
对象中使用的方法。
从元素中查询非常方便,因为查询的范围仅限于执行查询的位置。正如我们在Activity 1, Extracting Data from the DOM中看到的,我们可以从一个查询开始,找到所有基本元素 - 在这种特定情况下是产品元素,然后我们可以从执行查询的元素中执行一个新的查询,该查询将仅搜索在执行查询的元素内部的元素。
我们在上一节中用来查询 DOM 的方法包括直接从 DOM 中使用childNodes
列表访问元素,或者使用getElementsByTagName
和getElementsByClassName
方法。除了这些方法,DOM 还提供了一些其他非常强大的查询元素的方法。
首先,有getElement*
方法系列:
-
getElementsByTagName
- 我们之前见过并使用过这个方法。它获取指定标签的所有元素。 -
getElementsByClassName
- 这是getElement
的一个变体,它返回具有指定类的所有元素。请记住,一个元素可以通过用空格分隔它们来包含一个类的列表。以下是在商店页面中运行的代码的屏幕截图,您可以看到选择ui
类名将获取还具有items
、teal
(颜色)和label
类的元素:
图 1.43:按类名获取元素通常返回包含其他类的元素
getElementById
- 注意该方法名称中的单数形式。该方法将获取具有指定 ID 的唯一元素。这是因为在页面上预期 ID 是唯一的。
getElement*
方法族非常有用。但有时,指定类或标记名称是不够的。这意味着您必须使用一系列操作来使您的代码非常复杂:获取所有具有此类的元素,然后获取具有此其他标记的元素,然后获取具有此类的元素,然后选择第三个,依此类推。
多年来,jQuery 是唯一的解决方案,直到引入了querySelector
和querySelectorAll
方法。这两种方法可以用来在 DOM 树上执行复杂的查询。它们的工作方式完全相同。两者之间唯一的区别是querySelector
只会返回与查询匹配的第一个元素,而querySelectorAll
会返回一个可以迭代的列表。
querySelector*
方法使用 CSS 选择器。您可以使用任何 CSS 选择器来查询元素。让我们在下一个练习中更深入地探索一下。
练习 5:使用 querySelector 查询 DOM
在这个练习中,我们将探索在之前章节学到的各种查询和节点导航技术。为此,我们将使用商店代码作为基本 HTML 来探索,并编写 JavaScript 代码来查找商店页面上所有有机水果的名称。为了增加难度,有一个标记为有机的蓝莓松饼。
在开始之前,让我们看一下product
元素及其子元素。以下是从Elements
选项卡查看的product
元素的 DOM 树:
图 1.44:产品元素及其子元素
您可以看到每个产品的根元素是一个带有class
项的div
标记。名称和标记位于一个带有类 content 的子 div 中。产品的名称位于一个带有类 header 的锚点中。标记是一组带有三个类ui
、label
和teal
的div
标记。
在处理这样的问题时,您想要查询和过滤一组在一个共同父级下相关的元素时,有两种常见的方法:
- 首先查询根元素,然后进行过滤和查找所需的元素。以下是这种方法的图形表示:
图 1.45:第一种方法涉及从根元素开始
- 从匹配过滤条件的子元素开始,如果需要,应用额外的过滤,然后导航到您要查找的元素。以下是这种方法的图形表示:
图 1.46:第二种方法涉及从过滤条件开始
执行以下步骤完成练习:
- 为了使用第一种方法解决练习,我们需要一个函数来检查产品是否包含指定的标签列表。这个函数的名称将是
the
,它接收两个参数-产品根元素和要检查的标签列表:
function containLabels(element, ...labelsToCheck) {
}
- 在这个函数中,我们将使用一些数组映射和过滤来找到参数中指定的标签和被检查产品的标签之间的交集:
const intersection = Array.from(element.querySelectorAll('.label'))
.map(e => e.innerHTML)
.filter(l => labelsToCheck.includes(l));
- 函数中的最后一件事是返回一个检查,告诉我们产品是否包含所有标签。检查告诉我们交集的大小是否与要检查的所有标签的大小相同,如果是,我们就有一个匹配:
return intersection.length == labelsToCheck.length;
- 现在我们可以使用查询方法来查找元素,将它们添加到数组中,进行过滤和映射到我们想要的内容,然后打印到控制台:
//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);
- 要使用第二种方法解决问题,我们需要一个函数来查找指定元素的所有兄弟元素。打开您的文本编辑器,让我们从声明带有数组的函数开始存储我们找到的所有兄弟元素。然后,我们将返回数组:
function getAllSiblings(element) {
const siblings = [];
// rest of the code goes here
return siblings;
}
- 然后,我们将使用
while
循环和previousElementSibling
属性迭代所有先前的兄弟元素。在迭代兄弟元素时,我们将它们推入数组中:
let previous = element.previousElementSibling;
while (previous) {
siblings.push(previous);
previous = previous.previousElementSibling;
}
注意:再次注意间隙
我们使用previousElementSibling
而不是previousNode
,因为这将排除所有文本节点和其他节点,以避免不得不为每个节点检查nodeType
。
- 对于指定元素之后的所有兄弟元素,我们做同样的操作:
let next = element.nextElementSibling;
while (next) {
siblings.push(next);
next = next.nextElementSibling;
}
- 现在我们有了
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.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 树部分
您可以看到有许多嵌套的元素需要创建才能得到所需的最终 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;
}
该函数分为两个主要部分:
-
它创建图像的容器元素。从 DOM 截图中,您可以看到
img
元素位于一个带有image
类的div
内。 -
它创建
img
元素,设置src
属性,然后将其附加到container
元素。
这种代码风格简单、可读且易于理解。但这是因为需要生成的 HTML 相当简短。它只是一个div
标签中的一个img
标签。
不过,有时树变得非常复杂,使用这种策略使得代码几乎无法阅读。因此,让我们看看另一种策略。附加到产品根元素的另一个子元素是content
元素。这是一个具有许多子元素的div
标签,包括一些嵌套的子元素。
我们可以像createProductImage
函数一样处理它。但是该方法需要执行以下操作:
-
创建
container
元素并为其添加一个类。 -
创建包含产品名称的锚元素并将其附加到容器。
-
创建价格的容器并将其附加到根容器。
-
创建带有价格的
span
元素并将其附加到上一步中创建的元素。 -
创建包含描述的元素并将其附加到容器。
-
为
tag
元素创建container
元素并将其附加到根容器。 -
对于每个标签,创建
tag
元素并将其附加到上一步中的容器。
听起来像是一长串步骤,不是吗?我们可以使用模板字符串来生成 HTML,然后为容器元素设置innerHTML
,而不是试图编写所有那些代码。因此,步骤看起来会像下面这样:
-
创建
container
元素并为其添加一个类。 -
使用字符串模板创建内部内容的 HTML。
-
在
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.50:顶部标签过滤的工作原理
用户还可以使用右侧的搜索框按名称或描述搜索产品。随着他们的输入,列表将被过滤。
此练习的代码可以在 GitHub 上找到:github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson01/Exercise06
。
执行以下步骤以完成练习:
- 我们将首先编写基本的 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.js
和create_elements.js
脚本,这与本节中使用的示例代码相同。它还使用了Lesson01
文件夹中的 CSS 文件。如果您在同一个文件夹中,可以直接参考它们,或者将它们复制粘贴到您的项目中。
- 创建一个名为
filter_and_search.js
的文件,这是在 HTML 代码中加载的最后一个 JavaScript 代码。这是我们将为此练习添加所有代码的地方。我们需要做的第一件事是存储过滤器状态。用户可以应用到页面的两种可能过滤器:选择标签和/或输入一些文本。为了存储它们,我们将使用一个数组和一个字符串变量:
const tagsToFilterBy = [];
let textToSearch = '';
- 现在我们将创建一个函数,该函数将为页面中的所有标签添加事件侦听器。此函数将查找所有
tag
元素,将它们包装在一个数组中,并使用Element
中的addEventListener
方法添加事件侦听器以响应click
事件:
function addTagFilter() {
Array.from(document.querySelectorAll('.extra .label')).forEach(tagEl => {
tagEl.addEventListener('click', () => {
// code for next step goes here
});
});
}
- 在事件侦听器中,我们将检查标签是否已经在要按其进行过滤的标签数组中。如果没有,我们将添加它并调用另一个名为
applyTagFilters
的函数:
if (!tagsToFilterBy.includes(tagEl.innerHTML)) {
tagsToFilterBy.push(tagEl.innerHTML);
applyFilters();
}
applyFilters
只是一个包含与更新页面相关的所有逻辑的捕捉函数。您将只调用我们将在接下来的步骤中编写的函数:
function applyFilters() {
createListForProducts(filterByText(filterByTags(products)));
addTagFilter();
updateTagFilterList();
}
- 在继续
applyFilters
函数之前,我们将添加另一个函数来处理文本搜索输入框上的事件。这个处理程序将监听keyup
事件,当用户完成输入每个字母时触发。处理程序将获取输入框中的当前文本,将值设置为textToSearch
变量,并调用applyFilters
函数:
function addTextSearchFilter() {
document.querySelector('.menu .right input'
.addEventListener('keyup', (e) => {
textToSearch = e.target.value;
applyFilters();
});
}
- 现在,回到
applyFilters
函数。在其中调用的第一个函数几乎是隐藏的。这就是filterByTags
函数,它使用tagsToFilterBy
数组对产品列表进行过滤。它使用递归的方式对传入的产品列表使用选择的标签进行过滤:
function filterByTags() {
let filtered = products;
tagsToFilterBy
.forEach((t) => filtered = filtered.filter(p => p.tags.includes(t)));
return filtered;
}
- 无论过滤函数的输出是什么,都会传递给另一个过滤函数,即基于文本搜索过滤产品的函数。
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
中的一个函数,在本节练习之前已经描述过。
- 现在我们已经在页面上显示了新产品列表,我们需要重新注册标签过滤器事件监听器,因为 DOM 树元素已经被重新创建。所以我们再次调用
addTagFilter
。如前所示,这就是applyFilters
函数的样子:
function applyFilters() {
createListForProducts(filterByText(filterByTags(products)));
addTagFilter();
updateTagFilterList();
}
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));
}
}
- 我们需要将所有这些联系在一起的最后一个函数是
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;
}
- 使页面工作的最后一步是调用
applyTagFilter
函数,以便将页面更新到初始状态,即未选择任何标签。此外,它将调用addTextSearchFilter
以添加文本框的事件处理程序:
addTextSearchFilter();
applyFilters();
在 Chrome 中打开页面,您会看到顶部的过滤器为空,并且所有产品都显示在列表中。它看起来像本练习开头的截图。单击一个标签或在文本框中输入内容,您会看到页面更改以反映新状态。例如,选择两个饼干和面包店标签,并在文本框中输入巧克力,页面将只显示具有这两个标签和名称或描述中包含巧克力的产品:
图 1.51:商店前端通过两个面包店和饼干标签以及单词巧克力进行过滤
在本练习中,您已经学会了如何响应用户事件并相应地更改页面,以反映用户希望页面处于的状态。您还学会了当元素被移除并重新添加到页面时,事件处理程序会丢失并需要重新注册。
影子 DOM 和 Web 组件
在之前的部分中,我们已经看到一个简单的 Web 应用可能需要复杂的编码。当应用程序变得越来越大时,它们变得越来越难以维护。代码开始变得混乱,一个地方的变化会影响其他意想不到的地方。这是因为 HTML、CSS 和 JavaScript 的全局性质。
已经创建了许多解决方案来尝试规避这个问题,万维网联盟(W3C)开始着手提出标准的方式来创建自定义的、隔离的组件,这些组件可以拥有自己的样式和 DOM 根。Shadow DOM 和自定义组件是从这一倡议中诞生的两个标准。
Shadow DOM 是一种创建隔离的 DOM 子树的方式,可以拥有自己的样式,并且不受添加到父树的样式的影响。它还隔离了 HTML,这意味着在文档树上使用的 ID 可以在每个影子树中多次重用。
以下图示了处理 Shadow DOM 时涉及的概念:
图 1.52:Shadow DOM 概念
让我们描述一下这些概念的含义:
-
文档树是页面的主要 DOM 树。
-
影子宿主是附加影子树的节点。
-
影子树是附加到文档树的隔离 DOM 树。
-
影子根是影子树中的根元素。
影子宿主是文档树中附加影子树的元素。影子根元素是一个不显示在页面上的节点,就像主文档树中的文档对象一样。
要理解这是如何工作的,让我们从一些具有奇怪样式的 HTML 开始:
<style>
p {
background: #ccc;
color: #003366;
}
</style>
这将使页面上的每个段落都具有灰色背景,并带有一些蓝色文字。这是页面上段落的样子:
图 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:可以通过附加树的元素访问打开的影子树
影子树可以关闭,但不建议采用这种方法,因为这会产生一种虚假的安全感,并且会让用户的生活变得更加困难。
在我们将影子树附加到文档树后,我们可以开始操纵它。让我们将影子宿主中的 HTML 复制到影子根中,看看会发生什么:
shadowRoot.innerHTML = shadowHost.innerHTML;
现在,如果您在 Chrome 中加载页面,您会看到以下内容:
图 1.55:加载了影子 DOM 的页面
您可以看到,即使向页面添加了样式来选择所有段落,但向影子树添加的段落不受其影响。Shadow DOM 中的元素与文档树完全隔离。
现在,如果您查看 DOM,您会发现有些地方看起来很奇怪。影子树替换并包装了原来在div
元素内部的段落,这就是影子宿主:
图 1.56:影子树与影子宿主中的其他节点处于同一级别
但是影子宿主内部的原始段落不会在页面上呈现。这是因为当浏览器渲染页面时,如果元素包含具有新内容的影子树,它将替换宿主下的当前树。这个过程称为平铺,下面的图表描述了它的工作原理:
图 1.57:平铺时,浏览器会忽略影子宿主下的节点
现在我们了解了 Shadow DOM 是什么,我们可以开始使用它来构建或者自己的 HTML 元素。没错!通过自定义组件 API,你可以创建自己的 HTML 元素,然后像任何其他元素一样使用它。
在本节的其余部分,我们将构建一个名为counter的自定义组件,它有两个按钮和中间的文本。你可以点击按钮来增加或减少存储的值。你还可以配置它具有初始值和不同的增量值。下面的屏幕截图显示了组件完成后的外观。这个代码存放在 GitHub 上,网址是bit.ly/2mVy1XP
:
图 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
组件有两个属性,它用来配置自身:value
和increment
。这些属性在构造函数的开始使用Element
的getAttribute
方法设置,并在没有可用时设置合理的默认值:
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 文件。第二和第四个方法创建decrement
和increment
按钮,并附加事件处理程序。第三个方法创建一个span
元素,并在property
下保留对它的引用。
incrementValue
和decrementValue
方法通过指定的数量增加当前值,然后调用updateState
方法,将值的状态与 DOM(在这种情况下是 Shadow DOM)同步。incrementValue
和updateState
方法的代码如下:
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 组件的强大之处,还有两件事情你需要知道:回调和事件。
自定义组件有生命周期回调,你可以在你的类中设置它们,以便在它们周围的事情发生变化时得到通知。最重要的两个是connectedCallback
和attributeChangedCallback
。
第一个对于当你想要在组件附加到 DOM 后操纵 DOM 时很有用。对于counter组件,我们只是在控制台上打印一些东西,以显示组件现在连接到了 DOM:
connectedCallback() {
console.log("I'm connected to the DOM!");
}
当页面加载时,你可以看到为每个counter组件添加到 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组件,每当值更改时分发事件是非常有意义的。在事件中传递值也是有用的。这样,用户就不需要查询你的组件来获取当前值。
要分发自定义事件,我们可以使用Element
的dispatchEvent
方法,并使用CustomEvent
构造函数来使用自定义数据构建我们的事件。我们的事件名称将是value-changed
。用户可以添加事件处理程序来监听此事件,并在值更改时收到通知。
以下代码是triggerValueChangedEvent
函数,之前提到过;这个函数从incrementValue
和decrementValue
函数内部调用:
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:页面上添加的段落,显示计数器被点击
练习 7:用网络组件替换搜索框
要完全理解网络组件的概念,你需要看看一个应用程序如何被分解为封装的、可重用的组件。我们在上一个练习中构建的商店页面是我们开始的好地方。
在这个练习中,我们将编写一个网络组件,以替换页面右上角的搜索框。这就是我们谈论的组件:
图 1.61:将转换为 Web 组件的搜索框
这个组件将处理它的外观、渲染和状态,并在状态改变时发出事件。在这种情况下,搜索框只有一个状态:搜索文本。
执行以下步骤以完成练习:
-
将代码从
Exercise 6
复制到一个新文件夹中,这样我们就可以在不影响现有 storefront 的情况下进行更改。 -
让我们开始创建一个 Web 组件。创建一个名为
search_box.js
的文件,添加一个名为SearchBox
的新类,并使用这个类定义一个新组件:
class SearchBox extends HTMLElement {
}
customElements.define('search-box', SearchBox);
- 在类中,添加一个构造函数,调用
super
,并将组件附加到一个影子根。构造函数还将通过设置一个名为_searchText
的变量来初始化状态:
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._searchText = '';
}
- 为了公开当前状态,我们将为
_searchText
字段添加一个 getter:
get searchText() {
return this._searchText;
- 仍然在类中,创建一个名为
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>
';
}
- 创建另一个名为
triggerTextChanged
的方法,它将触发一个事件来通知监听器搜索文本已更改。它接收新的文本值并将其传递给监听器:
triggerTextChanged(text) {
const event = new CustomEvent('changed', {
bubbles: true,
detail: { text },
});
this.dispatchEvent(event);
}
- 在构造函数中,在附加影子根后,调用
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);
});
}
-
准备好我们的 Web 组件后,我们可以用它替换旧的搜索框。在
dynamic_storefront.html
HTML 中,用我们创建的新组件search-box
替换div
标签和它们的所有内容。还要将新的 JavaScript 文件添加到 HTML 中,放在所有其他脚本之前。您可以在 GitHub 上查看最终的 HTML,网址为github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Exercise07/dynamic_storefront.html
。 -
通过使用文档的
querySelector
方法保存对search-box
组件的引用:
const searchBoxElement = document.querySelector('search-box');
- 注册一个 changed 事件的事件监听器,这样我们就知道何时有新值可用,并调用
applyFilters
:
searchBoxElement.addEventListener('changed', (e) => applyFilters());
- 现在我们可以清理
filter_and_search.js
JavaScript,因为部分逻辑已经移动到新组件中。我们将进行以下清理:
删除textToSearch
变量(第 2 行),并将其替换为searchBoxElement.searchText
(第 40 行)。
删除addTextSearchFilter
函数(第 16-22 行)和脚本末尾对它的调用(第 70 行)。
如果一切顺利,在 Chrome 中打开文件将得到完全相同的 storefront,这正是我们想要的。
现在,处理搜索框和搜索文本的逻辑已经封装起来,这意味着如果我们需要更改它,我们不需要四处寻找分散在各处的代码片段。当我们需要知道搜索文本的值时,我们可以查询保存它的组件。
活动 2:用 Web 组件替换标签过滤器
现在我们已经用 web 组件替换了搜索框,让我们使用相同的技术替换标签过滤器。这个想法是我们将有一个组件来存储选定的标签列表。
这个组件将封装一个可以通过使用mutator
方法(addTag
和removeTag
)来修改的选定标签列表。当内部状态发生变化时,会触发一个 changed 事件。此外,当列表中的标签被点击时,将触发一个tag-clicked
事件。
步骤:
-
首先将代码从练习 7 复制到一个新文件夹中。
-
创建一个名为
tags_holder.js
的新文件,在其中添加一个名为TagsHolder
的类,它扩展了HTMLElement
,然后定义一个名为tags-holder
的新自定义组件。 -
创建两个
render
方法:一个用于渲染基本状态,另一个用于渲染标签或指示未选择任何标签进行过滤的文本。 -
在构造函数中,调用
super
,将组件附加到影子根,初始化所选标签列表,并调用两个render
方法。 -
创建一个 getter 来公开所选标签的列表。
-
创建两个触发器方法:一个用于触发
changed
事件,另一个用于触发tag-clicked
事件。 -
创建两个
mutator
方法:addTag
和removeTag
。这些方法接收标签名称,如果不存在则添加标签,如果存在则删除标签。如果列表被修改,触发changed
事件并调用重新渲染标签列表的方法。 -
在 HTML 中,用新组件替换现有代码,并将新的脚本文件添加到其中。
-
在
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,诸如文档和窗口的对象,诸如setTimeout
和fetch
的函数,以及前端世界中可以做的许多其他事情。所有这些都是浏览器执行环境的一部分。由于该执行环境专注于浏览器,它提供了与 DOM 交互和与服务器通信的方式,这是它存在的全部。
Node.js 专注于为开发人员提供一种有效构建 Web 应用程序后端的环境。它提供 API 来创建 HTTP(S)服务器,读写文件,操作进程等。
正如我们之前提到的,Node.js 在底层使用 V8 JavaScript 引擎。这意味着为了将 JavaScript 文本转换为计算机处理的可执行代码,它使用了 V8,这是由 Google 构建的开源 JavaScript 引擎,用于驱动 Chromium 和 Chrome 浏览器。以下是这个过程的示例:
图 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 操作被安排在轮询阶段。有六个阶段,它们按以下顺序执行:
-
计时器:使用
setTimeout
或setInterval
计划的代码 -
挂起 回调:上一个周期的 I/O 的延迟回调
-
空闲,准备:仅内部
-
轮询:计划进行 I/O 处理的代码
-
检查:
setImmediate
回调在这里执行 -
关闭回调:计划在关闭套接字等上执行的代码
每个阶段都会执行代码,直到发生两种情况之一:阶段队列耗尽,或者执行了最大数量的回调:
图 2.2:事件循环阶段
要理解这是如何工作的,让我们看一些代码,将阶段映射到事件循环,并了解底层到底发生了什么:
console.log('First');
setTimeout(() => {
console.log('Last');
}, 100);
console.log('Second');
在这段简短的代码中,我们向控制台打印一些内容(在 Node.js 中,默认情况下会输出到标准输出),然后我们设置一个函数在100
毫秒后调用,并向控制台打印一些其他文本。
当 Node.js 启动你的应用程序时,它会解析 JavaScript 并执行脚本直到结束。当结束时,它开始事件循环。这意味着,直接打印到控制台时,它会立即执行。计划的函数被推送到计时器队列,并等待脚本完成(以及100毫秒过去)才会执行。当事件循环没有任务可执行时,应用程序结束。以下图表说明了这个过程:
图 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
。
执行以下步骤以完成此练习:
- 下载并安装 Node.js 后,转到命令行并检查您已安装的版本:
$ node –version
v10.16.0
- 现在,创建一个名为
event_loop.js
的新文本文件,并添加代码的扩展版本(事件循环示例),如前所示。它看起来像这样:
console.log('First');
const start = Date.now();
setTimeout(() => {
console.log(`Last, after: ${Date.now() - start}ms`);
}, 100);
console.log('Second');
- 要使用 Node.js 运行 JavaScript,调用
node
并传递要执行的文件的路径。要运行刚刚创建的文件,请在命令行中执行以下代码,从您创建文件的目录中执行:
$ node event_loop.js
您将看到以下输出:
$ node event_loop.js
First
Second
Last, after: 106ms
最后看到的时间将在每次运行时都有所不同。这是因为setTimeout
只能确保代码将在指定的时间之后运行,但不能保证它会准确地在您要求的时间执行。
- 运行
node
命令而不带任何参数;您将进入 REPL 模式:
$ node
>
>
表示您现在在 Node.js 执行环境中。
- 在 REPL 命令行中,键入命令并按Enter执行。让我们尝试第一个:
> console.log('First');
First
Undefined
你可以看到它打印出你传递给console.log
调用的字符串。它还打印出Undefined
。这是最后执行语句的返回值。由于console.log
没有返回任何东西,它打印了 undefined。
- 创建存储当前时间的常量:
> const start = Date.now()
undefined
- 声明变量也不会返回任何东西,所以它再次打印
undefined
:
> start
1564326469948
如果要知道变量的值是多少,只需键入变量名称并按Enter。变量名称的返回语句是变量值,因此它会打印出该值。
- 现在,键入
setTimeout
调用,就像在您的文件中一样。如果您按Enter并且您的语句不完整,因为您正在启动一个函数或打开括号,Node.js 将打印省略号,表示它正在等待命令的其余部分:
> setTimeout(() => {
...
- 您可以继续键入,直到所有命令都被键入。
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
毫秒,再加上您键入和执行命令所花费的时间。
- 尝试更改
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
- 在另一个函数中声明超时的整个调度,以便每次执行函数时都获得一个新的作用域:
> 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
- 要退出 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
的帮助部分。
安装程序在您的系统中执行两件事:
-
在您的主目录中创建一个
.nvm
目录,其中放置了所有与管理 Node.js 的所有托管版本相关的脚本 -
添加一些配置以使 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 版本时会得到什么类型的错误。
执行以下步骤完成这个练习:
- 在您的项目中添加一个
.nvmrc
文件。在一个空文件夹中,创建一个名为.nvmrc
的文件,并在其中添加数字 12.7.0。您可以使用echo
命令一次完成这个操作,并将输出重定向到文件中:
$ echo '12.7.0' > .nvmrc
- 您可以使用
cat
命令检查文件是否包含您想要的内容:
$ cat .nvmrc
12.7.0
- 让我们使用
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 将给出清晰的消息。
- 调用
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
文件中获取这个版本。
- 现在,创建一个名为
url_explorer.js
的文件。在其中,通过传递完整的 URL 来创建一个 URL 的实例。让我们还添加一些调用来探索 URL 的各个部分:
const url = new URL('https://www.someserver.com/not/a/path?param1=value1¶m2=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]}`));
- 运行脚本。您会看到 URL 被正确解析,并且所有关于它的细节都正确地打印到控制台上:
$ node url_explorer.js
URL is: https://www.someserver.com/not/a/path?param1=value1¶m2=value2
Hostname: www.someserver.com
Path: /not/a/path
Query string is: ?param1=value1¶m2=value2
Query parameters:
- param1 = value1
- param2 = value2
- 现在,让我们尝试错误的 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)
- 现在,您可以再次运行
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 版本中变为全局可用。
- 修复 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¶m2=value2
Hostname: www.someserver.com
Path: /not/a/path
Query string is: ?param1=value1¶m2=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:搜索一个包来帮助我们构建一个命令行应用程序
由于我们正在寻找一个工具来帮助我们解析命令行参数,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
。
执行以下步骤以完成此练习:
-
创建一个新的文件夹,您将在其中放置此练习的所有文件。
-
在命令行中,切换到新文件夹并运行
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)
- 安装
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
文件。
- 运行一个具有入口点的 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
- 创建一个名为
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>
。
- 配置 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);
- 现在,您可以运行您的应用程序并查看到目前为止的结果:
$ 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 为您提供了一个很好的帮助消息,解释了您的工具应该如何使用。
- 现在,让我们使用这些选项来生成 HTML。我们需要做的第一件事是声明一个变量,用于保存所有的 HTML:
let html = '<html><head>';
我们可以使用<html>
和<head>
开放标签来初始化它。
- 然后,检查程序是否接收到
title
选项。如果是,就添加一个带有传入标签内容的<title>
标签:
if (program.title) {
html += `<title>${program.title}</title>`;
}
- 对于
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 += '/>';
}
- 关闭
<head>
标签并打开<body>
标签:
html += '</head><body>';
- 检查容器
<div>
选项,并在启用时添加它:
if (program.addContainer) {
html += '<div id="container"></div>';
}
- 最后,关闭
<body>
和<html>
标签,并将 HTML 打印到控制台:
html += '</body></html>';
console.log(html);
- 不带任何选项运行应用程序将给我们一个非常简单的 HTML:
$ node .
<html><head></head><body></body></html>
- 运行应用程序,启用所有选项:
$ 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.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.json
和package-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。这些预定义名称的优势在于您不需要使用run
或run-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 以查看结果。
执行的步骤如下:
-
使用 npm 在新文件夹中创建一个新包。
-
使用
npm install
(www.npmjs.com/package/cheerio
)安装一个名为cheerio
的库。 -
创建一个名为
index.js
的新条目文件,并在其中加载cheerio
库。 -
创建一个变量,用于存储第一章,JavaScript,HTML 和 DOM中第一个示例的 HTML(文件可以在 GitHub 上找到:
github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson01/Example/sample_001/sample-page.html
)。 -
使用 cheerio 加载和解析 HTML。
-
在加载的 HTML 中的
div
中添加一个带有一些文本的段落元素。 -
使用 cheerio,迭代当前页面中的所有段落,并将它们的内容打印到控制台。
-
打印控制台的操作版本。
-
运行您的应用程序。
输出应该看起来像下面这样:
图 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
函数。让我们更深入地了解一些它的应用。
当您想要将一些文本打印到控制台时,您可以使用以下任何一个函数:debug
、error
、info
和warn
。它们之间的区别在于文本的输出位置。当您使用debug
和info
方法时,文本将被打印到标准输出。对于warn
和error
,消息将被打印到标准错误。
确保您在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 函数的输出
或者,您可以传递要显示的属性名称列表:
console.table(myTable, ['name']);
以下是前面代码的输出:
图 3.2:当传递要打印的属性列表时,console.table 的输出
您还可以使用console
来计算代码中特定部分运行所需的时间。为此,您可以使用time
和timeEnd
方法,如下例所示:
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
在浏览器中,当您想要在将来的某个时间执行特定函数或定期执行时,可以分别使用setTimeout
和setInterval
。这些函数也在 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 变量扮演的另一个重要角色是访问标准输入和输出。如果要向控制台打印内容,可以使用stdout
和stderr
。这两个属性是控制台中的console.log
和所有其他方法在内部使用的。不同之处在于stdout
和stderr
在每次调用时不会在末尾添加新行,因此如果希望每个输出都进入自己的行,您必须自己添加新行:
process.stdout.write(`You typed: '${text}'\n`);
process.stderr.write('Exiting your application now.\n');
这是两个示例,打印出以换行结束的内容。在大多数情况下,建议使用控制台,因为它可以提供一些额外的东西,例如日志级别和格式化。
如果要从命令行读取输入,可以使用process.stdin
。stdin
是一个流,我们将在下一节中更多地讨论。现在,您只需要知道流是基于事件的。这意味着当输入进来时,它将以数据事件的形式到达。要从用户那里接收输入,您需要监听该事件:
process.stdin.addListener('data', (data) => {
...
});
当没有更多的代码需要执行时,事件循环将阻塞,等待标准输入的输入。当读取输入时,它将作为字节缓冲传递到回调函数中。您可以通过调用其toString
方法将其转换为字符串,如下面的代码所示:
const text = data.toString().trim();
然后,您可以像普通字符串一样使用它。以下示例应用程序演示了如何使用stdout
、stderr
和stdin
从命令行请求用户输入:
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 与用户进行交互,还学会了如何创建定时器,让我们编写一个应用程序,利用这些新技能来管理命令行中的提醒。
应用程序将接收用户输入并收集信息以构建提醒。它将使用消息、时间单位和一定的时间。应用程序的输入将分阶段提供。每个阶段都会要求用户输入一些内容,收集它,验证它,然后设置一个变量的值以进入下一个阶段。
执行以下步骤完成此练习:
- 在一个空文件夹中,使用
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);
- 接下来,我们将添加应用程序的核心函数。该函数如下所示:
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,以便可以执行下一个阶段。
前面的函数调用了一些尚不存在的函数:askForMessage
,askForTimeUnit
和askForAmount
。这些函数负责验证输入并根据每个阶段设置变量,以便代码可以进入下一个阶段。
- 添加一些细节到
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
,这将使代码进入下一个阶段。
- 接下来,我们创建
askForTimeUnit
函数,这是处理代码的下一个阶段的函数。该函数使用第一步列出的常量来打印支持的时间单位,并让用户选择一个。它的工作方式类似于askForMessage
函数:prompt
,validate
和set 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]}`);
}
- 最后,我们创建
askForAmount
函数,处理最后一个阶段。该函数提示用户输入一定的时间来创建计时器。与之前一样,它有三个部分:prompt
,validate
和set 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();
}
- 在
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
。这将导致您的终端发出哔哔声,然后打印消息中设置的文本。此外,文本经过特殊格式化,在开头和结尾都有换行,以便不会太大干扰工具的使用,因为计时器将在用户继续使用应用程序的同时打印。
- 应用程序的最后一部分需要在标准输入中注册数据事件的监听器,并通过询问用户消息来启动循环:
process.stdin.on('data', (data) => processInput(data.toString().trim()));
askForMessage();
- 现在,您可以从终端运行应用程序,设置一些提醒,并在计时器到期时听到它发出哔哔声:
图 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();
可写流也有你可以监听的事件。最重要的两个事件是error
和close
。当写入流时发生错误时,将触发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_OK
和R_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
函数的同步版本,并使用commander
和glob-to-regexp
模块来帮助我们处理用户的输入。
执行以下步骤完成这个练习:
-
在一个空目录中,使用
npm
init
开始一个新的应用程序,并添加一个index.js
文件,这将是我们的入口点。 -
安装我们将使用的两个外部模块:
commander
和glob-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
- 在
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
函数将在接下来的步骤中进行解释。
- 初始化
counter
和found
变量。这些将用于显示与正在执行的搜索相关的一些统计信息:
let counter = 0;
let found = 0;
const start = Date.now();
- 配置
commander
以接收 glob 作为参数,并为用户设置初始目录开始搜索的额外选项:
program.version('1.0.0')
.arguments('<glob>')
.option('-b, --base-dir <dir>', 'Base directory to start the search.', './')
.parse(process.argv);
- 在这个练习中,我们将使用递归函数来遍历目录树。
walkDirectory
函数调用readdirSync
,并将withFileTypes
标志设置为true
。walkDirectory
函数接收两个参数:要开始读取的路径和要为每个文件调用的回调函数。当找到一个目录时,它被传递给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
函数将文件名连接到父路径,以重建文件的整个路径。
- 现在我们有了
walkDirectory
树函数,我们将验证传递给应用程序的参数:
const glob = program.args[0];
if (typeof glob === 'undefined') {
program.help();
process.exit(-1);
}
- 然后,我们使用
globToRegExp
模块将 glob 转换为RegExp
,以便用于测试文件:
const matcher = globToRegExp(program.args[0], { globstar: true });
- 有了匹配器和遍历目录树函数,我们现在可以遍历目录树并测试我们找到的每个文件:
walkDirectory(program.baseDir, (f) => {
if (matcher.test(f)) {
found++;
console.log(`${found} - ${f}`);
}
});
- 最后,由于所有的代码都是同步执行的,在调用
walkDirectory
完成后,所有的目录和子目录都将被处理。现在,我们可以打印出我们找到的统计信息:
图 3.4:找到的文件的统计信息
你可以通过在父目录中开始执行搜索:
图 3.5:在父目录中执行搜索
在这个练习中,你学会了如何使用文件系统 API 来遍历目录树。你还使用了正则表达式来按名称过滤文件。
文件系统 API 为几乎每个应用程序提供了基础。学习如何同步和异步地使用它们对于后端世界中的任何事情都是至关重要的。在下一节中,我们将使用这些 API 来构建一个基本的 Web 服务器,以便向浏览器提供文件。
HTTP API
起初,Node.js 的目标是取代使用传统的每个连接一个线程模型的旧 Web 服务器。在线程每请求模型中,服务器保持一个端口开放,当新连接进来时,它使用线程池中的一个线程或创建一个新线程来执行用户请求的工作。服务器端的所有操作都是同步进行的,这意味着当从磁盘读取文件或从数据库中读取记录时,线程会休眠。以下插图描述了这个模型:
图 3.6:在线程每请求模型中,线程在 I/O 和其他阻塞操作发生时处于休眠状态
线程每请求模型的问题在于创建线程的成本很高,而当它们在有更多工作要做时处于休眠状态,这意味着资源的浪费。另一个问题是,当线程的数量高于 CPU 的数量时,它们开始失去并发的最宝贵的价值。
由于这些问题,使用线程每请求模型的 Web 服务器将拥有一个不够大的线程池,以便服务器仍然可以并行响应许多请求。并且因为线程数量是有限的,当并发用户发出请求的数量增加时,服务器会耗尽线程,用户现在必须等待:
图 3.7:当并发请求数量增加时,用户必须等待线程可用
Node.js 以其异步模型和事件循环,提出了这样一个观念:如果只有一个线程来执行工作并将阻塞和 I/O 操作移到后台,只有在数据可用于处理时才返回到它,那么您可以更加高效。当您需要进行数据密集型工作时,比如 Web 服务器,它主要从文件、磁盘和数据库中读取和写入记录时,异步模型变得更加高效。以下插图描述了这个模型:
图 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 服务器示例响应
您可以尝试访问其他路径,例如http://localhost:3000/index.html
。结果将是相同的:
图 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 服务器,因为它只在目录中查找文件并将它们无修改地返回给客户端。
执行以下步骤完成此练习:
- 在空目录中,使用
init
命令初始化一个新的 npm 应用程序,并向其添加一个index.js
文件。还要使用npm install
安装mime
包。我们将使用此包确定我们将提供的文件的内容类型是什么:
npm install mime
- 让我们首先导入我们在这个项目中需要的所有模块:
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。
- 为了知道我们将要提供哪些文件,我们将使用上一个练习中的
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));
}
});
}
- 然后,我们将选择根目录,可以将其作为参数传递。否则,我们将假定它是我们运行脚本的目录:
const rootDirectory = path.resolve(process.argv[2] || './');
- 现在,我们可以扫描目录树并将所有文件的路径存储在
Set
中,这将使文件可用性检查的过程更快:
const files = new Set();
walkDirectory(rootDirectory, (file) => {
file = file.substr(rootDirectory.length);
files.add(file);
});
console.log(`Found ${files.size} in '${rootDirectory}'...`);
- 准备好提供文件列表后,我们将创建 HTTP 服务器实例:
const server = http.createServer();
- 启动请求处理程序函数:
server.on('request', (request, response) => {
- 在处理程序函数内部,将用户请求的内容解析为 URL。为此,我们将使用 url 模块,并从解析后的 URL 中获取指向客户端想要的文件的路径名:
const requestUrl = url.parse(request.url);
const requestedPath = path.join(requestUrl.pathname);
- 有了文件路径,我们将检查文件是否在之前收集的列表中,如果不在,则响应 404(未找到)错误消息,记录请求的结果并返回它:
if (!files.has(requestedPath)) {
console.log('404 %s', requestUrl.href);
response.writeHead(404);
response.end();
return;
}
- 如果文件在
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);
});
- 处理程序函数到此为止。之后,我们可以通过选择一个端口来启动服务器,让用户知道那是什么,并调用 http 服务器中的监听方法:
const port = 3000;
console.log('Starting server on port %d.', port);
console.log('Go to: http://localhost:%d', port);
server.listen(port);
- 您可以通过运行以下命令来启动服务器:
$ node .
Found 23 in '/Path/to/Folder'...
Starting server on port 3000.
o to: http://localhost:3000
- 从另一个终端窗口,我们可以使用命令行 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 服务器提供的
您也可以尝试使用一个不存在的文件来查看结果:
$ 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 错误响应
在运行服务器的终端上,您可以看到它打印了有关正在提供的信息:
$ 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 方法接收相同的参数:url
、options
和callback
。
要指定要执行的 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 模板库来完成这项艰苦的工作。
执行以下步骤以完成此练习:
- 创建一个新的 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
包来确定静态提供的文件的内容类型。
- 在应用程序中需要所有将使用的模块:
const fs = require('fs');
const handlebars = require('handlebars');
const http = require('http');
const mime = require('mime');
const path = require('path');
const url = require('url');
- 使用基本目录检查静态文件的路径。该目录将是脚本加载的静态目录。我们将该路径存储在变量中,以便以后使用:
const staticDir = path.resolve(`${__dirname}/static`);
console.log(`Static resources from ${staticDir}`);
- 接下来,我们使用
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
函数,将您的辅助函数的名称作为第一个参数传递,并将处理程序函数作为第二个参数传递。
- 让我们添加一个辅助函数,用于格式化货币:
handlebars.registerHelper('currency', (number) => `$${number.toFixed(2)}`);
- 为了初始化 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 的请求。这意味着我们的根处理程序只关心这两种情况。
- 要请求商店,我们将假设当用户请求
/
或/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);
}
- 为了处理静态文件,我们将在静态文件应该来自的目录前面添加路径,并将其用作完整路径。然后,我们将使用文件系统 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);
});
}
- 现在我们有了用于提供静态文件的函数,让我们使用 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();
}
- 在模板处理就位后,我们需要一个模板。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}}.
- 不要忘记在最后调用
initialize
函数以开始监听 HTTP 连接:
initializeServer();
为了使商店正确加载和呈现,你还需要 css 文件和图像。只需将它们放在一个名为static的文件夹中。你可以在 GitHub 上找到这些文件:github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Exercise14
。
- 所有文件就位后,运行服务器:
$ node .
Static resources from
.../Lesson03/Exercise14/static
Loaded 21 products...
Go to: http://localhost:3000
- 打开浏览器窗口,转到
http://localhost:3000
。你应该看到商店:
图 3.13:从动态网络服务器提供的商店
在这个练习中,我们将商店应用程序转换为一个动态的网络应用程序,它从一个 JSON 文件中读取数据,并在用户请求时呈现一个 HTML 请求。
动态网络服务器是所有在线应用程序的基础,从 Uber 到 Facebook。你可以总结这项工作为加载数据/处理数据以生成 HTML。在第二章,Node.js 和 npm中,我们在前端使用了一些简单的 HTML 并进行了处理。在这个练习中,你学会了如何在后端使用模板引擎来完成相同的工作。每种方法都有其优缺点,大多数应用程序最终会结合两者。
你可以将过滤选项添加到商店前端网页作为改进。比如说用户想要按标签或它们的组合来筛选产品。在你的handleProductsPage
函数中,你可以使用查询参数来过滤你传递给模板渲染的产品列表。看看你是否可以自己做出这个改进。
什么是爬取?
在本章的其余部分,我们将讨论网络爬取。但网络爬取到底是什么?这是下载页面并处理其内容以执行一些重复的自动化任务的过程,否则这些任务将需要手动执行太长时间。
例如,如果你想要购买汽车保险,你需要去每家保险公司的网站获取报价。这个过程通常需要几个小时,因为你需要填写表单,提交表单,等待他们在每个网站给你发送电子邮件,比较价格,然后选择你想要的:
图 3.14:用户下载内容,输入数据,提交数据,然后等待结果
那么为什么不制作一个可以为你做到这一点的程序呢?这就是网络爬取的全部内容。一个程序像人一样下载页面,从中提取信息,并根据某种算法做出决策,然后将必要的数据提交回网站。
当你为你的汽车购买保险时,似乎自动化不会带来太多价值。为不同的网站编写正确执行此操作的应用程序将花费很多时间——比手动操作自己做要多得多。但如果你是一家保险经纪公司呢?那么你每天可能要做这个动作数百次,甚至更多。
如果你是一个保险经纪公司,如果你花时间建立一个机器人(这些应用程序就是这样称呼的),你将开始变得更加高效。这是因为对于那个网站,你不需要花时间填写表单。通过建立第一个机器人获得的效率,你可以节省时间并能够建立第二个,然后是第三个,依此类推:
图 3.15:机器人通过下载内容并根据算法做出决策自动执行任务
网络爬虫始于互联网早期,当时雅虎!试图手动索引所有存在的网站。然后,一家初创公司,由两名大学生在车库里开始使用机器人来提取数据并索引一切。在很短的时间内,谷歌成为了第一大搜索网站,这个位置对竞争对手来说越来越难以挑战。
网络爬取是一种广泛使用的技术,用于从不提供 API 的网站提取数据,比如大多数保险公司和银行。搜索和索引也是另一个非常常见的情况。一些公司使用爬取来分析网站的性能并对其进行评分,比如 HubSpot(website.grader.com
)。
网络爬虫有许多技术,取决于你想要实现的目标。最基本的技术是从网站下载基本的 HTML 并从中读取内容。如果你只需要下载数据或填写表单,这可能已经足够了:
图 3.16:基本的爬取技术涉及下载和处理基本的 HTML 文件
但有时,网站使用 Ajax 在 HTML 渲染后动态加载内容。对于这些情况,仅下载 HTML 是不够的,因为它只是一个空模板。为了解决这个问题,您可以使用一个无头浏览器,它像浏览器一样工作,解析所有 HTML,下载和解析相关文件(CSS、JavaScript 等),将所有内容一起渲染,并执行动态代码。这样,您就可以等待数据可用:
图 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
解析已下载的内容,获取有关每篇文章的信息,并以漂亮的格式在控制台上打印出来,使每篇文章都只是一个点击之遥。
执行以下步骤完成此练习:
- 创建一个新文件夹,其中包含一个
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
- 在
index.js
文件中,使用 require 函数引入我们将使用的所有模块:
const http = require('https');
const JSDOM = require('jsdom').JSDOM;
const url = require('url');
- 创建一个包含我们将下载页面的所有主题的常量数组:
const topics = [
'artificial-intelligence',
'data-science',
'javascript',
'programming',
'software-engineering',
];
- 复制我们在上一节中创建的
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();
}
- 迭代每个主题,为每个主题调用
downloadPage
函数:
topics.forEach(topic => {
downloadPage(`https://medium.com/topic/${topic}`, (content) => {
const articles = findArticles(new JSDOM(content).window.document);
Object.values(articles)
.forEach(printArticle);
});
});
在上述代码中,我们调用了两个函数:findArticles
和printArticle
。第一个函数将遍历从页面解析的 DOM,并返回一个对象,其中键是文章标题,值是包含每篇文章信息的对象。
- 接下来,我们编写
findArticles
函数。我们首先初始化对象,该对象将是函数的结果,然后查询传递的文档中所有 H1 和 H3 元素内的所有锚点元素,这些元素代表文章的标题:
function findArticles(document) {
const articles = {};
Array.from(document.querySelectorAll('h1 a, h3 a'))
- 根据 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:父级的下一个兄弟元素包含文章的简短描述
这意味着对于每个锚元素,我们可以获取该 DIV,查询一个锚点,并获取其文本作为文章的描述。
- 使用文章标题作为键,将文章信息设置在结果对象中。我们使用文章的标题作为键,因为这将自动去重结果中的文章:
.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,
};
});
- 最后,从
findArticles
函数中返回包含所有文章的数组:
return articles;
}
我们在传递给downloadPage
的回调中调用的另一个函数是printArticle
。这也是使该应用程序完整的最后一部分代码。
- 让我们编写
printArticle
函数,它接收一个文章对象,并以漂亮的方式将其打印到控制台上:
function printArticle(article) {
console.log('-----');
console.log(` ${article.title}`);
console.log(` ${article.description}`);
console.log(` https://medium.com${article.link}`);
}
运行应用程序,以漂亮的格式将文章打印到控制台上,附加额外信息:
图 3.19:运行应用程序后在控制台上打印的文章
在这个练习中,我们编写了一个从 Medium 获取数据并将找到的文章摘要打印到控制台的应用程序。
网络爬虫是在没有 API 可用时获取数据的强大方式。许多公司使用爬虫在系统之间同步数据,分析网站的性能,并优化否则无法扩展的流程,从而阻碍了一些重要的业务需求。了解爬虫背后的概念使您能够构建否则不可能构建的系统。
活动 4:从商店前端爬取产品和价格
在第二章,Node.js 和 npm中,我们编写了一些代码,用于获取商店示例页面中产品的信息。当时,我们说网站不会经常更新,因此可以从 Chrome 开发者控制台手动执行。对于某些情况,这是可以接受的,但是当内容是动态生成的,就像我们在本章中编写的商店的新版本一样,我们可能需要消除所有手动干预。
在此活动中,您将编写一个应用程序,通过使用 http 模块下载商店网页并使用jsdom
解析它来抓取商店网页。然后,您将从 DOM 中提取数据并生成一个带有数据的CSV
文件。
您需要执行以下步骤才能完成此活动:
-
使用您之前构建的代码或其副本来为
localhost:3000
提供商店前端网站。 代码可以在 GitHub 上找到github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Activity04
。 -
创建一个新的
npm
包,安装jsdom
库,并创建一个名为index.js
的入口文件。 -
在入口文件中,调用
require()
方法加载项目中所需的所有模块。 -
向
localhost:3000
发出 HTTP 请求。 -
确保成功响应并从主体中收集数据。
-
使用
jsdom
解析 HTML。 -
从 DOM 中提取产品数据; 您将需要名称,价格和单位。
-
打开
CSV
文件,数据将被写入其中。 -
将产品数据写入
CSV
文件,这是一个产品行。 -
运行应用程序并检查结果。
输出应该看起来像这样:
$ 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 协议具有内置方法,可以简化诸如POST
、GET
、PUT
和DELETE
等任务。
先前提到的任务的功能如下:
-
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 请求来测试它。要做到这一点,执行以下步骤:
- 创建一个名为
smartHouse
的文件夹并初始化一个npm
项目:
mkdir smartHouse
cd smartHouse
npm init
- 安装
express
库,使用-s
标志将其保存到我们的package.json
文件中:
npm install -s express
- 创建一个名为
server.js
的文件,导入express
并创建一个app
对象:
const express = require('express');
const app = express();
- 在
server.js
中添加一个指定'/'的app.get
方法,用于我们的索引路由:
app.get('/', (req, res) => {
let info = {};
info.message = "Welcome home! Our first endpoint.";
res.json(info);
});
前面的代码创建了一个HTTP GET
函数,返回一个名为info
的对象,其中包含一个名为message
的属性。
- 添加一个
app.listen
函数,告诉我们的应用程序监听端口 3000
:
// Start our application on port 3000
app.listen(3000, () => console.log('API running on port 3000'));
前面的步骤就是一个简单的 Node.js Express API 示例所需的全部内容。通过运行前面的代码,我们将在本地主机上创建一个应用程序,返回一个简单的 JSON 对象。
- 在另一个终端窗口中,返回到您的
smartHouse
文件夹的根目录并运行以下命令:
npm start
- 通过在 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
的请求,包括PUT
、POST
和DELETE
-
向您的请求添加标头信息
-
为受保护的端点包括授权信息
我首选的方法是使用命令行工具 cURL。cURL 代表 URL 的客户端。它已安装在大多数版本的 macOS、Linux 和 Windows 10 上(2018 年及以后的版本)。它是一个用于进行 HTTP 请求的命令行工具。对于一个非常简单的命令,运行以下命令:
curl localhost:3000
以下是前面代码的输出:
图 4.3:显示 cURL localhost:3000
注意
命令行程序jq
将在本章中用于格式化 cURL 请求。jq
是一个轻量级和灵活的命令行 JSON 处理器。该程序适用于 macOS、Linux 和 Windows。如果您无法在系统上安装它,仍然可以使用不带jq
的curl
。要这样做,只需从本章中任何 curl 命令的末尾删除| jq
命令。
安装jq
的说明可以在github.com/stedolan/jq
找到。
通过使用带有jq
的curl
,我们可以使阅读输出变得更容易,这将在我们的 JSON 变得更复杂时特别有用。在下面的示例中,我们将重复与前面示例中相同的 curl 命令,但这次使用 Unix 管道(|
)将输出传送到jq
:
curl -s localhost:3000 | jq
当像前面的命令一样将curl
传送到jq
时,我们将使用-s
标志,该标志代表“静默”。如果curl
在没有此标志的情况下进行传送,您还将看到关于请求速度的不需要的信息。
假设你已经做了一切正确的事情,你应该观察到一些干净的 JSON 作为输出显示:
图 4.4:cURL 管道传输到 jq
如果你喜欢使用基于 GUI 的应用程序,你可以使用 Postman,它是一个 Chrome 扩展程序,可以以直接的方式轻松发送 HTTP 请求。一般来说,我更喜欢在命令行上快速使用 cURL 和 jq。然而,对于更复杂的用例,我可能会打开 Postman,因为 GUI 使得处理头部和授权变得更容易一些。有关安装 Postman 的说明,请访问网站www.getpostman.com
:
图 4.5:Postman 中 cURL 请求的屏幕截图
练习 17:创建和导入路由文件
目前,我们的应用程序在根 URL 上运行一个端点。通常,一个 API 会有许多路由,将它们全部放在主server.js
文件中会很快导致项目变得杂乱。为了防止这种情况发生,我们将把每个路由分离成模块,并将每个模块导入到我们的server.js
文件中。
注意
此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise17
找到。
执行以下步骤完成练习:
- 要开始,创建
smartHouse
文件夹中的一个新文件夹:
mkdir routes
- 创建
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
对象导出,并使其可以被导入到另一个文件中。每当我们创建一个新的路由文件时,它都会包含相同的底部导出行。
- 打开
server.js
并删除第 3 到第 8 行,因为app.get
方法已经移动到/routes/index.js
文件中。然后,我们将导入path
和fs
(文件系统)库。我们还将导入一个名为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');
- 此外,在
server.js
中,我们将打开 URL 编码,并告诉express
使用 JSON:
// Tell express to enable url encoding
app.use(express.urlencoded({extended: true}));
app.use(express.json());
- 接下来,我们将导入我们的索引路由并将其与一个路径关联起来。在我们完成了这些步骤之后,
server.js
应该包含以下内容:
// Import our index route
let index = require('./routes/index');
// Tell Express to use our index module for root URL
app.use('/', index);
- 我们可以为任何访问的 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));
});
- 文件的最后一行应该存在于我们之前的练习中:
// Start our application on port 3000
app.listen(3000, () => console.log('API running on port 3000'));
完成这些步骤后,运行我们的代码应该产生以下输出,与练习 16,创建带有索引路由的 Express 项目中的结果相同:
图 4.6:输出消息
routes
文件夹的优势在于,随着 API 的增长,它使得组织我们的 API 变得更容易。每当我们想要创建一个新的路由时,我们只需要在routes
文件夹中创建一个新文件,使用require
在server.js
中导入它,然后使用 Express 的app.use
函数将文件与一个端点关联起来。
模板引擎:在前两行中我们使用app.use
时,我们修改了express
的设置以使用扩展的 URL 编码和 JSON。它也可以用于设置模板引擎;例如,嵌入式 JavaScript(EJS)模板引擎:
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 响应代码类别表
HTTP 代码的每个类别都包含可在特定情况下使用的几个具体代码。这些标准化的代码将帮助客户端处理响应,即使涉及不熟悉的 API。例如,任何 400 系列的客户端错误代码都表示问题出在请求上,而 500 系列的错误代码表示问题可能出在服务器本身。
让我们来看看以下图中每个类别中存在的一些具体 HTTP 状态代码:
图 4.8:HTTP 响应代码表
在下图中,我们可以看到一些更具体的 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 路由表
这种设计有两个原因很有用-首先,因为它符合标准,这给用户一组期望。其次,使用诸如/properties/
和/actions/
之类的辅助端点使用户能够通过在这些端点请求附加信息来发现 API 的使用方式。
添加到房屋的每个设备都应该有/model/
、/properties/
和/actions/
端点。我们将在我们的 API 中将上表中显示的端点映射到每个设备上。以下树状图显示了从根端点开始的我们 API 的映射。
以下图中的第三级显示了/devices/light/
端点,并且从该端点开始,我们有上表中列出的端点:
图 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
找到。
执行以下步骤完成练习:
- 在
routes
文件夹中创建一个名为devices
的子文件夹:
mkdir routes/devices
- 将
routes/index.js
复制到routes/devices/light.js
:
cp routes/index.js routes/devices/light.js
- 接下来,我们将打开上一个练习中的
/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
。这个操作将在一定的时间内(以毫秒为单位)改变灯泡的亮度级别。这个端点不包含实现功能的逻辑,但它将返回与之交互所需的细节。
- 在
server.js
文件中,导入我们新创建的设备路由:
let light = require('./routes/devices/light');
- 现在我们将告诉 Express 使用我们的
light
对象来使用前面的路由:
app.use('/devices/light', light);
- 使用
npm start
运行程序:
npm start
- 使用
curl
和jq
测试路由:
curl -s localhost:3000/devices/light | jq
如果你正确复制了前面的代码,你应该得到一个格式化的 JSON 对象,表示fade
操作如下:
图 4.12:localhost:3000/devices/light 的 cURL 响应
进一步模块化
在项目文件中,我们将通过创建一个lightStructure.js
文件进一步分离灯路由,其中只包含表示灯的 JSON 对象。我们不会包括包含model
、properties
和action
描述的长字符串的 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。
执行以下步骤完成练习:
- 安装
express-validator
,这是一个中间件,用于在express
中轻松使用validation
和sanitization
函数包装validator.js
:
npm install -s express-validator
- 通过将
routes/devices/light
放在第 2 行导入express-validator
库中的check
和validationResult
函数,就在express
的require
语句下方:
const { check, validationResult } = require('express-validator/check');
- 在上一练习中编写的
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"});
});
- 使用
npm start
运行 API:
npm start
- 对
/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 错误响应
- 接下来,我们将像以前一样进行
PUT
请求,但使用正确的值50
和60
:
curl -sd "level=50&duration=60" -X PUT \
http://localhost:3000/devices/light/actions/fade | jq
发送具有正确范围内值的PUT
请求应返回以下内容:
图 4.15:/device/light/actions/fade 路由的 cURL 响应与正确数据
上述截图表明PUT
请求成功。
有用的默认值和简单的输入
因此,我们已经看到了对端点输入施加限制如何有所帮助。然而,过多的限制和要求可能会妨碍 API 的用户体验。让我们更仔细地看一下灯泡淡入淡出动作。为了允许在一段时间内淡入淡出的功能,我们要求用户传递一个持续时间的值。许多人已经有使用物理灯泡上的淡入淡出动作的经验。
对于物理灯泡,我们知道我们通过调节物理开关或其他输入来输入我们期望的亮度级别。持续时间不一定是这个过程的一部分,或者用户有意识地考虑过。这会导致期望您应该能够仅通过所需级别来淡化光线。
因此,我们应该考虑使duration
值变为可选。如果没有收到duration
值,脚本将退回到默认值。这使我们能够满足用户的期望,同时仍允许那些想要指定持续时间的用户进行精细控制。
练习 20:使持续时间输入变为可选
注意
此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise20
找到。
在这个练习中,我们将修改淡入淡出动作,使持续时间成为可选输入。如果没有提供持续时间值,我们将修改我们的淡入淡出动作端点,使用默认值 500 毫秒:
- 在
routes/devices/light.js
中,通过在函数链中添加.optional()
来修改验证duration
的行。它应该是这样的:
check('duration').isNumeric().optional().isLength({ min: 0 })
- 在
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
。
- 现在,我们将使用我们的
level
和duration
变量创建一个名为message
的message
对象。然后,我们将将该message
对象返回给客户端:
let message = `success: level to ${level} over ${duration} milliseconds`;
res.json({"message": message});
- 最后,我们将将第二个路由与我们的函数关联起来,以便向
/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) => {
- 现在我们已经完成了编码部分,我们将打开服务器进行测试:
npm start
- 在一个终端中运行服务器,打开另一个终端使用
curl
进行一些测试。在第一条命令中,我们将检查我们的新默认端点是否正常工作,并且在没有提供持续时间时使用我们的默认值:
curl -sd "level=50" -X PUT http://localhost:3000/devices/light | jq
如果您已经正确复制了所有内容,您应该会看到这样的输出:
图 4.16:/device/light 路由的 cURL 响应,没有指定持续时间
- 我们还希望确保提供
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 响应
通过这些更改,我们现在已经将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);
对于一些或所有路由,可以使用多个中间件函数,没有限制。当使用多个中间件函数时,它们按照在代码中声明的顺序调用。当一个中间件函数完成时,它将req
和res
对象传递给链中的下一个函数:
图 4.18:中间件链接图
前面的图表可视化了一个请求过程,其中一旦服务器接收到请求,它将运行第一个中间件函数,将结果传递给第二个中间件函数,当完成时,最终运行我们的/devices/light
目标路由。
在下一节中,我们将创建自己的中间件来检查客人是否已经签到以获取身份验证令牌。
练习 21:设置需要身份验证的端点
注意
此示例的完整代码可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson04/Exercise21
找到。
在下一个练习中,我们将通过添加一个需要身份验证的端点来完善我们的项目,该身份验证需要使用JSON Web Token(JWT)。我们将创建两个新的端点:第一个restricted light
,与light
相同,但需要身份验证。第二个端点check-in
允许客户端通过向服务器发送他们的名称来获取令牌。
注意
JWT 和安全性:此练习旨在突出 JWT 身份验证的工作原理。在生产中,这不是安全的,因为没有办法验证客户端提供的名称是否真实。
在生产中,JWT 还应包含一个到期日期,客户端必须在该日期之前更新令牌以继续使用。例如,给移动应用客户端的令牌可能具有 7 天的到期日期。客户端可能在启动时检查令牌是否即将到期。如果是这样,它将请求更新的令牌,应用程序的用户将不会注意到这个过程。
然而,如果移动应用的用户多天没有打开它,该应用将要求用户重新登录。这增加了安全性,因为任何可能找到 JWT 的第三方只有很短的时间来使用它。例如,如果手机丢失并在几天后被找到,许多使用带有到期日期的 JWT 的应用程序将需要再次登录以与所有者的帐户交互。
执行以下步骤以完成练习:
- 创建一个带有随机密钥值的
config.js
文件:
let config = {};
config.secret = "LfL0qpg91/ugndUKLWvS6ENutE5Q82ixpRe9MSkX58E=";
module.exports = config;
前面的代码创建了一个config
对象。它将config
的secret
属性设置为一个随机字符串。然后,导出config
对象。
重要的是要记住,密钥是随机的,因此您的密钥应该与此处显示的密钥不同。没有固定的方法来生成随机字符串,但在命令行上的一个简单方法是使用openssl
,它应该默认安装在大多数 Linux 和 Mac 操作系统上:
openssl rand -base64 32
- 使用
npm
安装jwt-simple
:
npm install -s jwt-simple
- 为
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;
- 在
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;
- 在
server.js
中,还要导入config.js
和jwt-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;
- 在
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.' });
}
- 在
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-light
与light
的关键区别在于使用了isCheckedIn
中间件函数。这告诉express
在提供 light 路由之前运行该函数。
- 使用
npm start
打开服务器:
npm start
- 打开另一个终端窗口,并运行以下命令以获取签名的 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 的值
您应该看到一个 JWT 令牌,如前面的图所示。
- 通过在终端中运行以下命令,向
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 请求
- 在终端中向
restricted-light
发送不带身份验证令牌的curl
请求:
curl -sd "level=50&duration=250" -X PUT \
http://localhost:3000/devices/restricted-light \
| jq
相比之下,发送相同的请求但不带端点会返回错误:
图 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 数据
JWT 网站允许我们轻松地可视化 JWT 的三个部分代表什么。红色的第一部分是标头,即描述所使用的编码标准的信息。紫色部分是有效载荷-它包含在创建令牌时服务器验证的数据,在我们的情况下只是一个名称。最后,蓝色部分是签名,它是使用服务器的秘密对其他两个部分的内容进行哈希的结果。
在前面的示例中,有效载荷部分是三个部分中最小的。这并不总是这样,因为红色和蓝色部分的大小是固定的,而紫色部分取决于有效载荷的大小。如果我们使用check-in
端点从服务器请求另一个令牌,那么我们不仅提供一个名称,还提供电子邮件和电话号码。这意味着我们将看到一个具有较大紫色部分的结果令牌:
图 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 数据库,我们首先需要向数据库模式添加path
和token
列。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 端点。该设备需要一个新的端点来支持经过身份验证的用户能够创建一次性密码来打开门的用例。
执行以下步骤完成活动:
-
创建一个新的项目文件夹并切换到该文件夹。
-
初始化一个
npm
项目并安装express
,express-validator
和jwt-simple
。然后,创建一个routes
目录。 -
创建一个
config.js
文件,其中应包含一个随机生成的秘密值。 -
创建
routes/check-in.js
文件,以创建一个签到路由。 -
创建一个名为
routes/lock.js
的第二个路由文件。首先导入所需的库和模块,然后创建一个空数组来保存我们的有效密码。 -
在
routes/lock.js
中的代码下面,创建一个GET
路由,用于/code
,需要一个name
值。 -
在
routes/lock.js
中创建另一个路由。这个路由将是/open
,需要一个四位数的代码,将被检查是否在passCodes
数组中有效。在该路由下面,确保导出router
,以便在server.js
中使用。 -
创建主文件,在其中我们的路由将在
server.js
中使用。首先导入所需的库,还有设置 URL 编码的 JSON。 -
接下来,在
server.js
中,导入这两个路由,实现一个404
捕获,并告诉 API 监听端口3000
。 -
测试 API 以确保它被正确完成。首先运行您的程序。
-
程序运行时,打开第二个终端窗口,使用
/check-in
端点获取 JWT 并将值保存为TOKEN
。然后,回显该值以确保成功。 -
使用我们的 JWT 来使用
/lock/code
端点获取新名称的一次性验证码。 -
两次向
/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 Token(JWT)身份验证。在本章中,我们将研究 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
的一部分运行。
模块化的其他成本
与模块化设计相关的其他成本包括:
-
加载多个部分的成本
-
坏模块的成本(安全性和性能)
-
使用的模块总量迅速增加
总的来说,这些成本通常是可以接受的,但应该谨慎使用。当涉及到加载许多模块所带来的开销时,预编译器(如webpack
和babel
)可以通过将整个程序转换为单个文件来帮助。
在创建模块或导入模块时需要牢记以下几点:
-
使用模块是否隐藏了重要的复杂性或节省了大量的工作?
-
模块是否来自可信任的来源?
-
它是否有很多子依赖?
以 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 语法导出和导入一个模块:
-
切换到
/Lesson_05/start/
目录;我们将使用这个作为起点。 -
使用
npm install
安装项目依赖项。 -
创建
js/light.js
文件,其中包含以下代码:
let light = {};
light.state = true;
light.level = 0.5;
var log = function () {
console.log(light);
};
export default log;
- 打开名为
js/viewer.js
的文件。这是将在我们页面上运行的 JavaScript。在文件顶部添加:
import light from './light.js';
- 在
js/viewer.js
的底部,添加:
light();
-
js/viewer.js
已经包含在index.html
中,所以现在我们可以使用npm start
启动程序。 -
在服务器运行时,打开一个 Web 浏览器,转到
localhost:8000
。一旦到达那里,按下F12打开开发者工具。
如果您做得没错,您应该在 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 中的原型(字符串)
运行该命令返回String
,一个包含多个方法的对象。内置的String
对象本身具有原型。接下来,运行console.dir(myString.__proto__.__proto__)
:
图 5.4:JavaScript 中的原型(对象)
再次运行带有附加__proto__
属性的命令将返回null
。JavaScript 中的所有原型最终都指向null
,这是唯一一个本身没有原型的原型:
图 5.5:附加 proto 返回 null
这种一个原型导致另一个原型,依此类推的关系被称为原型链:
图 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
之间所有数字的乘积的结果)
以下是要遵循的步骤:
- 在一个新的文件夹中,创建一个名为
number.js
的文件。我们将首先向Number
原型添加一个double
函数。注意使用this.valueOf()
来检索数字的值:
Number.prototype.double = function () {
return this.valueOf()*2;
}
- 接下来,按照相同的模式,我们将为任意数字的平方添加一个解决方案:
Number.prototype.square = function () {
return this.valueOf()*this.valueOf();
}
- 同样,我们将遵循相同的模式,尽管这个问题的解决方案有点棘手,因为它使用了记忆递归,并且使用了
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
关键字。BigInt
和Number
一样,是 JavaScript 内置的另一个原型。它是 ES6 中的第一个新的原始类型。主要区别在于BigInt
可以安全处理非常大的数字。Number
原型在大于9007199254740991
的值时开始失败。
一个数字可以通过用BigInt()
包装它或附加n
来转换为BigInt
;注意使用0n
和1n
。
- 接下来,我们将使用相同的模式和
BigInt
添加阶乘的解决方案:
Number.prototype.factorial = function () {
factorial = (n) => {
n = BigInt(n);
return (n>1) ? n * factorial(n-1n) : n;
}
return factorial(this.valueOf());
}
- 为了演示,定义一个数字并调用函数:
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"
);
- 使用 Node.js 运行脚本:
node number.js
你应该得到类似以下的结果:
图 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()
的内部工作方式可能会发生变化。它可能变得更快或更智能,但因为它已经被抽象化,我们不需要担心程序会出错。我们只需要知道它将返回true
或false
的条件。
我们不需要考虑计算机如何将二进制转换为屏幕上的图像,或者按下键盘如何在浏览器中创建事件。甚至构成 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 模块中的灯示例。我们将使用在上一章中为灯模块定义的属性,并在创建时进行分配。此外,我们将编写函数来检查灯属性的格式。如果使用无效的属性值创建了灯,我们将将其设置为默认值。
执行练习的步骤如下:
-
打开
js/light.js
并删除上一个练习中的代码。 -
为我们的
Light
类创建一个类声明:
class Light {
}
- 向类添加
constructor
函数,并从参数中设置属性以及datetime
属性。我们将首先将参数传递给两个函数以检查正确的格式,而不是直接设置state
和brightness
。这些函数的逻辑将在以下步骤中编写:
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();
}
}
- 将
checkStateFormat
和checkBrightnessFormat
函数添加到类声明中:
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;
}
- 添加一个
toggle
函数和一个test
函数,我们将用于调试。这两个函数也应该在类声明内。toggle
函数将简单地将灯的状态转换为当前状态的相反状态;例如,从开到关,反之亦然:
toggle() {
this.state = !this.state;
}
test() {
alert("state is " + this.state);
}
- 在
js/lightBulb.js
中,在类声明下面,添加一个模块导出,就像我们在上一个练习中所做的那样:
export default Light;
- 打开
js/viewer.js
,并用包含Light
类实例的变量替换我们在练习 22,编写一个简单的 ES6 模块中编写的light()
行:
let light = new Light(true, 0.5);
- 在
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;
}
- 返回项目目录并运行
npm start
。项目运行后,在浏览器中打开localhost:8000
。您应该看到灯的新图片,指示它是开启的:
图 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();
state
和brightness
都将默认为undefined
。
根据我们编写的代码,调用没有属性的light
不会引发错误,因为我们编写了checkStateFormat
和checkBrightnessFormat
来处理所有无效值。然而,在许多情况下,您可以通过在构造函数中提供默认值来简化代码,如下所示:
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
来创建无法直接从模块外部访问的私有变量。执行以下步骤完成练习:
- 打开
js/light.js
,并在文件顶部添加一个名为privateVars
的WeakMap
对象:
let privateVars = new WeakMap();
- 在
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);
}
- 现在,在
js/light.js
中,修改toggle
函数,以便我们从名为privateVars
的WeakMap
对象获取状态信息。请注意,当我们设置变量时,我们发送回一个包含所有信息而不仅仅是state
的对象。在我们的示例中,每个light
实例都与WeakMap
关联的单个info
对象:
toggle() {
let info = privateVars.get(this);
info.state = !info.state;
privateVars.set(this, info);
}
- 我们还需要以类似的方式修改
js/light.js
中的test
函数。我们将改变发送给用户的state
的来源,以便在警报中使用WeakMap
:
test() {
let info = privateVars.get(this);
alert("state is " + privateVars.get(this).state);
}
- 由于封装夺走了直接更改状态和亮度的能力,我们需要添加允许这样做的方法。我们将从在
js/light.js
中添加setState
函数开始。请注意,它几乎与我们的toggle
函数相同:
setState(state) {
let info = privateVars.get(this);
info.state = checkStateFormat(state);
privateVars.set(this, info);
}
- 接下来,在
js/light.js
中添加 getter 方法:
getState() {
let info = privateVars.get(this);
return info.state;
}
- 按照最后两个步骤的模式,在
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;
}
- 我们需要做的最后一个更改是在
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();
}
- 使用
npm start
运行代码,并在浏览器中查看localhost:8000
上的页面项目。检查确保单击图像有效,以及使用输入滑块更改亮度有效:
图 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 中的继承
这一开始可能听起来很复杂,但通常可以节省大量的编码工作。如果不使用类,我们将不得不将方法从一个动物复制并粘贴到另一个动物中。这就带来了在多个地方更新函数的困难。
回到我们的智能家居场景,假设我们收到了一个新的彩色灯泡设备。我们希望我们的彩色灯泡具有灯泡中包含的所有属性和函数。此外,彩色灯应该有一个额外的color
属性,包含一个十六进制颜色代码,一个颜色格式检查器和与改变颜色相关的函数。
我们的代码也应该以一种方式编写,如果我们对底层的Light
类进行更改,彩色灯泡将自动获得任何添加的功能。
练习 26:扩展一个类
注意
本练习使用练习 25,封装的 WeakMap的最终产品作为起点。完成此练习后的代码状态可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson05/Exercise26
找到。
为了扩展上一个练习中编写的Light
类,我们将创建一个新的ColorLight
类:
- 在
/js/colorLight.js
中创建一个新文件。在第一行,我们将导入./light.js
,这将作为起点:
import Light from './light.js';
- 接下来,我们将为私有变量创建
WeakMap
。然后,我们将为我们的ColorLight
类创建一个类语句,并使用extends
关键字告诉 JavaScript 它将使用Light
作为起点:
let privateVars = new WeakMap();
class ColorLight extends Light {
}
- 在
ColorLight
类语句内部,我们将创建一个新的constructor
,它使用内置的super()
函数,运行我们基类Light
的constructor()
函数:
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);
}
}
- 请注意在上述构造函数中,我们调用了
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;
}
- 添加 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);
}
- 在
js/colorLight.js
的底部,添加一个export
语句以使模块可供导入:
export default ColorLight;
- 在文件顶部打开
js/viewer.js
,并将Light
导入切换为ColorLight
。在下面,我们将导入一个预先编写的名为changeColor.js
的脚本:
import ColorLight from './colorLight.js';
import changeColor from './__extra__/changeColor.js';
- 在
js/viewer.js
中更下面,找到初始化light
变量的行,并将其替换为以下内容:
let light = new ColorLight(true, 1, "61AD85");
- 在
js/viewer.js
的底部,添加以下内容:
// Update image color
changeColor(light.getColor());
- 再次使用
npm start
启动程序,并在浏览器中转到localhost:8000
:
如果您按照说明正确操作,现在应该看到灯泡呈浅绿色,如下图所示。尝试打开js/viewer.js
并更改十六进制值;这样做应该会导致灯泡图像显示不同的颜色:
图 5.11:change-color 函数应用 CSS 滤镜使灯泡变绿
多态
多态性就是简单地覆盖父类的默认行为。在 Java 和 C#等强类型语言中,多态性可能需要花费一些精力。而在 JavaScript 中,多态性是直接的。你只需要重写一个函数。
例如,在上一个练习中,我们将Light
和ColorLight
类扩展了。假设我们想要获取在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
类的实例,选择颜色、亮度和状态:
- 打开
js/light.js
,并在WeakMap
引用的下面添加两个图像源的值:
let onImage = "images/bulb_on.png";
let offImage = "images/bulb_off.png";
- 接下来,在
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);
- 在
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);
}
- 接下来,在
js/light.js
中,找到setState
函数,并在函数内添加以下行:
info.img.src = info.state ? onImage : offImage;
- 在
js/light.js
的toggle
函数中添加相同的行:
info.img.src = info.state ? onImage : offImage;
- 同样地,我们将更新
js/light.js
中的setBrightness
函数,以根据亮度设置图像的不透明度:
info.img.style.opacity = brightness;
js/light.js
中的最后一个更改是为img
HTML 对象添加一个 getter 函数。我们将它放在getBrightness
和toggle
函数之间:
getImg() {
let info = privateVars.get(this);
return info.img;
}
- 在
js/colorLight.js
中,我们将导入预先构建的colorChange
函数。这应该放在你的导入下面的位置,就在Light
导入的下面:
import changeLight from './__extra__/changeColor.js';
- 接下来,在
js/colorLight.js
中,我们将通过添加以下行来更新构造函数:
let img = this.getImg();
img.style.webkitFilter = changeLight(color);
- 在
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);
}
- 最后的更改是
index.html
;删除img
和input
标签,并替换为以下内容:
<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>
- 完成所有更改后,运行
npm start
并在浏览器中打开localhost:8000
。如果一切都做对了,点击build
按钮应该根据所选的颜色向页面添加一个新元素:
图 5.12:创建多个 lightclub 类的实例
如你所见,一旦你创建了许多相同的实例,类就真的开始变得非常有用了。在下一节中,我们将看看 npm 包以及如何将我们的Light
类导出为一个。
npm 包
npm 包是一个已经打包并上传到 npm 服务器的 JavaScript 模块。一旦模块被上传到 npm,任何人都可以快速安装和使用它。
这对你可能不是新鲜事,因为任何使用 Node.js 的人很快就会安装一个包。不太常见的是如何创建和上传一个包。作为开发人员,很容易花费数年的时间而不需要发布一个公共模块,但了解这一点是值得的。这不仅有助于当你想要导出自己的模块时,还有助于阅读和理解你的项目使用的包。
创建 npm 模块的第一步是确保您有一个完整的package.json
文件。在本地运行项目时,通常不必过多担心诸如author和description之类的字段。但是,当您准备将模块用于公共使用时情况就不同了。您应该花时间填写与您的软件包相关的所有字段。
以下是包括 npm 推荐的常见属性的表格。其中许多是可选的。有关更多信息和完整列表,请参阅docs.npmjs.com/files/package.json
。
至少,元数据应包括名称、版本和描述。此外,大多数软件包将需要一个dependencies
属性;但是,这应该通过在使用npm install
安装依赖项时自动生成使用--save
或-s
选项:
图 5.13:npm 属性表
以下表格显示了 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 软件包示例
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 文件编译成单个文件:
- 在项目的基础上创建两个新文件夹,一个名为
build
,另一个名为src
:
mkdir src build
- 将
images
,index.html
和js
文件夹移动到新的src
文件夹中。源文件夹将用于稍后生成build
文件夹的内容:
mv images index.html js src
- 安装
babel-cli
和babel preset
作为开发人员依赖项:
npm install --save-dev webpack webpack-cli @babel/core @babel/cli @babel/preset-env
- 在根目录下添加一个名为
.babelrc
的文件。在其中,我们将告诉 Babel 使用预设设置:
{
"presets": ["@babel/preset-env"]
}
- 在根目录中添加一个名为
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"
}
};
- 要从
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"
- 为了确保命令已经正确添加,运行命令行上的
npm run build
。你应该会看到这样的输出:
图 5.16:npm run build 输出
- 接下来,打开
build/index.html
并将script
标签更改为导入我们新创建的文件bundle.js
:
<script src="bundle.js"></script>
- 要测试,运行
npm start
并在浏览器中打开localhost:8000
。你应该会看到与上次练习相同的网站。按几次build
按钮以确保它按预期工作:
图 5.17:使用构建按钮进行测试运行
- 为了双重检查一切是否编译正确,去浏览器中输入
localhost:8000/bundle.js
。你应该会看到一个包含所有我们的 JavaScript 源文件编译版本的大文件:
图 5.18:所有我们的 JavaScript 源文件的编译版本
如果你做的一切都正确,你应该有一个包含所有我们的 JavaScript 代码编译成单个文件的bundle.js
文件。
可组合性和组合模块的策略
我们已经看到模块如何成为另一个模块的扩展,就像ColorLight
是Light
的扩展一样。当项目增长时,另一个常见的策略是有模块本身由多个子模块组成。
使用子模块就像在模块文件本身导入模块一样简单。例如,假设我们想要改进我们灯模块中的亮度滑块。也许如果我们创建一个新的Slider
模块,我们可以在除了Light
类之外的多种情况下使用它。这是一种情况,我们建议将我们的“高级滑块输入”作为子模块。
另一方面,如果你认为你的新滑块只会在Light
类中使用,那么将它添加为一个新类只会增加更多的开销。不要陷入过度模块化的陷阱。关键因素在于可重用性和实用性。
活动 6:创建带有闪光模式的灯泡
你工作的灯泡公司要求你为他们的产品工作。他们想要一个带有特殊“闪光模式”的灯泡,可以在活动和音乐会上使用。闪光模式的灯允许人们将灯置于闪光模式,并在给定的时间间隔内自动打开和关闭。
创建一个FlashingLight
类,它扩展了Light
。该类应该与Light
相同,只是有一个名为flashMode
的属性。如果flashMode
打开,则状态的值应该每五秒切换一次。
创建了这个新组件后,将其添加到js/index.js
中的包导出,并使用 Babel 编译项目。
执行以下步骤完成活动:
-
安装
babel-cli
和babel
预设为开发人员依赖项。 -
添加
.babelrc
告诉 Babel 使用preset-env
。 -
添加一个 webpack 配置文件,指定模式、入口和输出位置。
-
创建一个名为
js/flashingLight.js
的新文件;它应该作为一个空的 ES6 组件开始,扩展Light
。 -
在文件顶部,添加一个
weakMap
类型的privateVars
变量。 -
在构造函数中,设置
flashMode
属性并将其保存到构造函数中的privateVars
中。 -
为
FlashingLight
对象添加一个 setter 方法。 -
为
FlashingLight
对象添加一个 getter 方法。 -
在第 2 行,添加一个空变量,用于在类的全局级别跟踪闪烁计时器。
-
创建一个引用父类的
lightSwitch()
函数的startFlashing
函数。这一步很棘手,因为我们必须将它绑定到setInterval
。 -
创建一个
stopFlashing
函数,用于关闭计时器。 -
在构造函数中,检查
flashMode
是否为 true,如果是,则运行startFlashing
。 -
在设置
mode
时,还要检查flashMode
- 如果为 true,则startFlashing
;否则,stopFlashing
。 -
在
index.js
中导入和导出新组件。 -
通过使用 npm 运行我们的
build
函数来编译代码。
预期输出:
图 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;
}
在前面的代码中并不清楚发生了什么。1600
和600
到底是什么意思,如果灯的状态是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
找到。
执行以下步骤完成练习:
- 创建一个新的文件夹并初始化一个
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
我们在这里安装了几个开发者依赖项。除了eslint
和prettier
之外,我们还安装了由 Airbnb 制作的起始配置,一个与 Prettier 一起工作的配置,以及一个为基于 Jest 的测试文件添加样式异常的扩展。
- 创建一个
.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",
}
}
- 创建一个
.prettierignore
文件(类似于.gitignore
文件,这只是列出应该被 Prettier 忽略的文件)。你的.prettierignore
文件应包含以下内容:
node_modules
build
dist
- 创建一个
src
文件夹,并在其中创建一个名为square.js
的文件,其中包含以下代码。确保你包含了不合适的制表符:
var square = x => x * x;
console.log(square(5));
- 在你的 npm
package.json
文件中创建一个lint
脚本:
"scripts": {
"lint": "prettier --write src/**/*.js"
},
- 接下来,我们将通过从命令行运行新脚本来测试和演示
prettier --write
:
npm run lint
- 在文本编辑器中打开
src/square.js
,你会看到不合适的制表符已被移除:
图 6.1:不合适的制表符已被移除
- 接下来,回到
package.json
,扩展我们的 lint 脚本,在prettier
完成后运行eslint
:
"scripts": {
"lint": "prettier --write src/**/*.js && eslint src/*.js"
},
- 在命令行中再次运行
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.
上述脚本产生了一个错误和一个警告。错误是由于在可以使用let
或const
的情况下使用var
。尽管在这种特殊情况下应该使用const
,因为square
的值没有被重新赋值。警告是关于我们使用console.log
,通常不应该在生产代码中使用,因为这会使在发生错误时难以调试控制台输出。
- 打开
src/example.js
,并按照下图所示,在第 1 行将var
更改为const
:
图 6.2:将 var 语句替换为 const
- 现在再次运行
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
找到。
执行以下步骤以完成练习:
-
在命令行中,导航到
Exercise30/start
练习文件夹。该文件夹包括一个包含我们将运行测试的代码的src
文件夹。 -
通过输入以下命令来初始化一个
npm
项目:
npm init -y
- 使用以下命令安装 Jest,使用
--save-dev
标志(表示该依赖项对开发而非生产是必需的):
npm install --save-dev jest
- 创建一个名为
__tests__
的文件夹。这是 Jest 查找测试的默认位置:
mkdir __tests__
- 现在我们将在
__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);
});
- 打开
package.json
并修改测试脚本,使其运行jest
。注意以下截图中的scripts
部分:
图 6.3:修改后的测试脚本,使其运行 Jest
- 在命令行中,输入
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
。
- 通过打开文件并修复
square
函数来修复错误。它应该像下面的代码一样将x
相乘,而不是将其加倍:
const square = (x) => x * x;
- 修复了我们的代码后,让我们再次用
npm run test
进行测试。你应该会得到一个成功的消息,如下所示:
图 6.4:使用 npm run test 进行测试后显示的成功消息
在这个练习中,我们设置了一个 Jest 测试,以确保用输入 5 运行我们的square
函数返回 25。我们还看了一下当代码中返回错误值时会发生什么,比如返回 10 而不是 25。
集成测试
因此,我们已经讨论了单元测试,当项目的代码发生变化时,它们非常有用,可以帮助找到错误的原因。然而,也有可能项目通过了所有的单元测试,但并不像预期的那样工作。这是因为整个项目包含了将我们的函数粘合在一起的额外逻辑,以及静态组件,如 HTML、数据和其他工件。
集成测试可以用来确保项目在更高层次上工作。例如,虽然我们的单元测试直接调用math.square
等函数,但集成测试将测试多个功能一起工作以获得特定结果。
通常,这意味着将多个模块组合在一起,或者与数据库或其他外部组件或 API 进行交互。当然,集成更多部分意味着集成测试需要更长的时间,因此它们应该比单元测试更少地使用。集成测试的另一个缺点是,当一个测试失败时,可能有多种可能性作为原因。相比之下,失败的单元测试通常很容易修复,因为被测试的代码位于指定的位置。
练习 31:使用 Jest 进行集成测试
在这个练习中,我们将继续上次 Jest 练习的内容,上次我们测试了square
函数对 5 的响应是否返回 25。在这个练习中,我们将继续添加一些新的测试,使用我们的函数相互结合:
- 在命令行中,导航到
Exercise31/start
练习文件夹,并使用npm
安装依赖项:
npm install
- 创建一个名为
__tests__
的文件夹:
mkdir __tests__
- 创建一个名为
__tests__/math.test.js
的文件。然后,在顶部导入math
库:
const math = require('./../src/math.js');
- 与上一个练习类似,我们将添加一个测试。然而,这里的主要区别是我们将多个函数组合在一起:
test('check that square of result from 1 + 1 is 4', () => {
expect(math.square(math.add(1,1))).toBe(4);
});
- 在前面的测试中添加一个计时器来测量性能:
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);
});
- 现在,通过运行
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 确保性能
在这个练习中,我们将使用之前描述的技术来测试获取斐波那契的两种算法的性能:
- 在命令行中,导航到
Exercise32/start
练习文件夹,并使用npm
安装依赖项:
npm install
- 创建一个名为
__tests__
的文件夹:
mkdir __tests__
- 创建一个名为
__tests__/fib.test.js
的文件。在顶部,导入快速和慢速的斐波那契函数(这些已经在start
文件夹中创建):
const fastFib = require('./../fastFib');
const slowFib = require('./../slowFib');
- 为快速斐波那契添加一个测试,创建一个计时器,并确保计时器运行时间不超过 5 秒:
test('Fast way of getting Fibonacci of 44', () => {
const start = new Date();
expect(fastFib(44)).toBe(701408733);
expect(new Date() - start).toBeLessThan(5000);
});
- 接下来,为慢速斐波那契添加一个测试,同时检查运行时间是否少于 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);
});
- 从命令行中,使用
npm test
命令运行测试:
图 6.6:斐波那契测试的结果
注意前面提到的关于计时器的错误响应。函数运行时间的预期结果应该在 5,000 毫秒以下,但在我的情况下,我实际收到了 10,961。根据您的计算机速度,您可能会得到不同的结果。如果您没有收到错误,可能是因为您的计算机速度太快,完成时间少于 5,000 毫秒。如果是这种情况,请尝试降低预期的最大时间以触发错误。
端到端测试
虽然集成测试结合了软件项目的多个单元或功能,端到端测试更进一步,模拟了软件的实际使用。
例如,虽然我们的单元测试直接调用了math.square
等函数,端到端测试将加载计算器的图形界面,并模拟按下一个数字,比如 5,然后是平方按钮。几秒钟后,端到端测试将查看图形界面中的结果,并确保它等于预期的 25。
由于开销较大,端到端测试应该更加节制地使用,但它是测试过程中的一个很好的最后一步,以确保一切都按预期工作。相比之下,单元测试运行起来相对快速,因此可以更频繁地运行而不会拖慢开发速度。下图显示了测试的推荐分布:
图 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 中右键单击进行检查
一旦单击检查,DOM 资源管理器将弹出,您将能够看到与元素相关的任何类或 ID:
图 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 创建的计算器演示的网站
在这个练习中,我们将创建一个脚本,打开网站,按下按钮,然后检查网站的正确结果。我们不仅仅是检查函数的输出,而是列出在网站上执行的操作,并指定要用作我们测试对象的值的 HTML 选择器。
执行以下步骤完成练习:
- 打开
Exercise33/start
文件夹并安装现有的依赖项:
npm install
- 安装所需的
jest
,puppeteer
和jest-puppeteer
包:
npm install --save-dev jest puppeteer jest-puppeteer
- 打开
package.json
并配置 Jest 使用jest-puppeteer
预设,这将自动设置 Jest 以与 Puppeteer 一起工作:
"jest": {
"preset": "jest-puppeteer"
},
- 创建一个名为
jest-puppeteer.config.js
的文件,并添加以下内容:
module.exports = {
server: {
command: 'npm start',
port: 8080,
},
}
前面的配置将确保在测试阶段之前运行npm start
命令。它还告诉 Puppeteer 在port: 8080
上查找我们的 Web 应用程序。
- 创建一个名为
__tests__
的新文件夹,就像我们在之前的示例中所做的那样:
mkdir __test__
- 在
__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 为screen
的div
的值。
- 使用
npm
运行测试:
图 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
命令:
- 在命令行中,导航到
Exercise34/start
练习文件夹并安装依赖项:
npm install
- 将文件夹初始化为 Git 项目:
git init
- 创建
.git/hooks/pre-commit
文件,其中包含以下内容:
#!/bin/sh
npm run lint
- 如果在基于 OS X 或 Linux 的系统上,通过运行以下命令使文件可执行(在 Windows 上不需要):
chmod +x .git/hooks/pre-commit
- 我们现在将通过进行提交来测试钩子:
git add package.json
git commit -m "testing git hook"
以下是前面代码的输出:
图 6.12:提交到 Git 之前运行 Git 钩子
在您的代码提交到源代码之前,您应该看到lint
命令正在运行,如前面的屏幕截图所示。
- 接下来,让我们通过添加一些代码来测试失败,这些代码将生成 linting 错误。通过在您的
src/js.js
文件中添加以下行来修改:
let number = square(5);
确保在上一行中保留不必要的制表符,因为这将触发 lint 错误。
- 重复添加文件并提交的过程:
git add src/js.js
git commit -m "testing bad lint"
以下是上述代码的输出:
图 6.13:提交代码到 git 之前的失败 linting
您应该看到lint
命令像以前一样运行;但是,在运行后,由于 Git 钩子返回错误,代码不会像上次那样被提交。
使用 Husky 共享 Git 钩子
要注意的一个重要因素是,由于这些钩子位于.git
文件夹本身内部,它们不被视为项目的一部分。因此,它们不会被共享到您的中央 Git 存储库供协作者使用。
然而,Git 钩子在协作项目中最有用,新开发人员可能不完全了解项目的约定。当新开发人员克隆项目,进行一些更改,尝试提交,并立即根据 linting 和测试获得反馈时,这是一个非常方便的过程。
husky
节点库是基于这个想法创建的。它允许您使用一个名为.huskyrc
的单个配置文件在源代码中跟踪您的 Git 钩子。当新开发人员安装项目时,钩子将处于活动状态,开发人员无需做任何操作。
练习 35:使用 Husky 设置提交钩子
在这个练习中,我们将设置一个 Git 钩子,它与练习 34,设置本地 Git 钩子中的钩子做相同的事情,但具有可以在团队中共享的优势。通过使用husky
库而不是直接使用git
,我们将确保任何克隆项目的人也有在提交任何更改之前运行lint
的钩子:
- 在命令行中,导航到
Exercise35/start
练习文件夹并安装依赖项:
npm install
- 创建一个名为
.huskyrc
的文件,其中包含以下内容:
{
"hooks": {
"pre-commit": "npm run lint"
}
}
前面的文件是这个练习的最重要部分,因为它确切地定义了在 Git 过程的哪个时刻运行什么命令。在我们的情况下,在将任何代码提交到源代码之前,我们运行lint
命令。
- 通过运行
git init
将文件夹初始化为 Git 项目:
git init
- 使用
npm
安装 Husky:
npm install --save-dev husky
- 对
src/js.js
进行更改,以便用于我们的测试提交。例如,我将添加以下注释:
图 6.14:测试提交注释
- 现在,我们将运行一个测试,确保它像之前的示例一样工作:
git add src/js.js
git commit -m "test husky hook"
以下是上述代码的输出:
图 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 显示一个小测验应用程序
在这个应用程序中,点击问题的正确答案会使问题消失,分数增加一:
- 在命令行中,导航到
Exercise36/start
练习文件夹并安装依赖项:
npm install --save-dev jest puppeteer jest-puppeteer
- 通过修改
scripts
部分,向package.json
文件添加一个test
脚本,使其看起来像下面这样:
"scripts": {
"start": "http-server",
"test": "jest"
},
- 在
package.json
中添加一个 Jest 部分,告诉 Jest 使用 Puppeteer 预设:
"jest": {
"preset": "jest-puppeteer"
},
- 创建一个名为
jest-puppeteer.config.js
的文件,在其中告诉 Jest 在运行任何测试之前打开我们的测验应用程序:
module.exports = {
server: {
command: 'npm start',
port: 8080,
},
}
- 创建一个名为
__test__
的文件夹,我们将把我们的 Jest 测试放在其中:
mkdir __test__
- 在名为
quiz.test.js
的文件夹中创建一个测试。它应该包含以下内容来初始化我们的测试:
describe('Quiz', () => {
beforeAll(async () => {
await page.goto('http://localhost:8080')
})
// tests will go here
})
- 接下来,用我们测验中的第一个问题的测试替换前面代码中的注释:
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。
- 在最后一步添加了以下测试。我们将添加三个新测试,每个问题一个:
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。
- 最后,返回到命令行,以便我们可以确认正确的结果。运行以下
test
命令:
npm test
以下是前面代码的输出:
图 6.17:命令行确认正确的结果
如果一切正确,运行npm test
应该看到四个通过的测试作为响应。
活动 7:将所有内容组合在一起
在这个活动中,我们将结合本章的几个方面。从使用 HTML/JavaScript 构建的预先构建的计算器开始,你的任务是:
-
创建一个
lint
命令,使用eslint-config-airbnb-base
包检查项目是否符合prettier
和eslint
,就像在之前的练习中所做的那样。 -
使用
jest
安装puppeteer
并在package.json
中创建一个运行jest
的test
命令。 -
创建一个 Puppeteer 测试,使用计算器计算 777 乘以 777,并确保返回的答案是 603,729。
-
创建另一个 Puppeteer 测试来计算 3.14 除以 2,并确保返回的答案是 1.57。
-
安装并设置 Husky,在使用 Git 提交之前运行 linting 和测试命令。
执行以下步骤完成活动(高级步骤):
-
安装在 linting 练习中列出的开发人员依赖项(
eslint
,prettier
,eslint-config-airbnb-base
,eslint-config-prettier
,eslint-plugin-jest
和eslint-plugin-import
)。 -
添加一个
eslint
配置文件.eslintrc
。 -
添加一个
.prettierignore
文件。 -
在
package.json
文件中添加一个lint
命令。 -
打开
assignment
文件夹,并安装使用 Puppeteer 和 Jest 的开发人员依赖项。 -
通过修改
package.json
文件,添加一个选项告诉 Jest 使用jest-puppeteer
预设。 -
在
package.json
中添加一个test
脚本来运行jest
。 -
创建一个
jest-puppeteer.config.js
来配置 Puppeteer。 -
在
__tests__/calculator.js
创建一个测试文件。 -
创建一个
.huskyrc
文件。 -
通过运行
npm install --save-dev husky
安装husky
作为开发人员依赖项。
预期输出
图 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 版本
在本章中,我们不会切换运行时,但在将来,在开始之前最好先检查您要开发的运行时的语言支持。
在 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 安装
如果你看到这样的输出,这意味着你已经正确安装了Node.js
。
注意
这个命令输出当前运行的Node.js
运行时版本,因此这也是一个非常好的检查当前版本的方法。对于本书,我们将使用当前的 LTS,即 v10.16.0。
在验证了我们的 Node.js 安装之后,要以 REPL 模式运行 node 命令,你只需要在命令提示符中输入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
构造函数,另一种是使用数组文字方式。一旦我们创建了数组,我们将操纵数组的内容。让我们开始吧:
- 使用数组文字方法创建一个空数组并测试它是否成功创建后:
> let exampleArray1 = [];
=> undefined
> Array.isArray(exampleArray1);
=> true
- 现在,我们将使用
Array
构造函数来做同样的事情。虽然它们产生相同的结果,但构造函数允许更多的灵活性:
> let exampleArray2 = new Array();
=> undefined
> Array.isArray(exampleArray2);
=> true
请注意,我们没有使用typeof
来检查数组的类型,因为在 JavaScript 中,数组是对象的一种类型。如果我们在刚刚创建的数组上使用typeof
,我们会得到一个意外的结果:
> let exampleArray3 = [];
=> undefined
> typeof exampleArray3
=> 'object'
- 创建具有预定义大小和项目的数组。请注意,随着向数组添加项目,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
方法来预定义数组中的所有项目。当我们想要使用数组来跟踪应用程序中的标志时,这是非常有用的。
- 为索引
0
分配一个值:
> singers[0] = 'miku'
=> 'miku'
> singers
=> [ 'miku' ]
- 为 JavaScript 数组分配任意索引。未分配的索引将简单地是
undefined
:
> singers[3] = 'luka'
=> 'luka'
> singers[1]
=> undefined
- 使用数组的长度修改数组末尾的项目:
> singers[singers.length - 1] = 'rin'
=> 'rin'
> singers
=> [ 'miku', 'miku', 'miku', 'miku', 'miku', 'rin' ]
因此,我们已经学会了如何在 JavaScript 中定义数组。这些数组的行为类似于其他语言,它们也会自动扩展,因此你不必担心手动调整数组的大小。在下一个练习中,我们将讨论如何向数组中添加项目。
练习 38:添加和删除项目
在 JavaScript 中,很容易添加和删除数组中的项目,在许多应用程序中我们必须累积许多项目。在这个练习中,我们将修改之前创建的singers
数组。让我们开始吧:
- 从一个空数组开始:
> let singers = [];
=> undefined
- 使用
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
将会扩展数组并将其添加到数组的末尾,而不是只将其添加到开头
- 从数组末尾删除一个项目:
> singers.push('me')
=> 2
> singers
=> [ 'miku', 'me' ]
> singers.pop()
=> 'me'
> singers
=> [ 'miku' ]
- 在数组开头添加一个项目:
> singers.unshift('rin')
=> 2
> singers
=> [ 'rin', 'miku' ]
- 从数组的开头移除项目:
> singers.shift()
=> 'rin'
> singers
=> [ 'miku' ]
在更大规模的应用程序中,这些非常有用,比如如果您正在构建一个处理图像的简单 Web 应用程序。当请求到来时,您可以将图像数据、作业 ID 甚至客户端连接推送到数组中,这意味着 JavaScript 数组可以是任何类型。您可以有另一个工作人员在数组上调用pop
来检索作业,然后处理它们。
练习 39:获取数组中项目的信息
在这个练习中,我们将介绍获取有关数组中项目的各种基本方法。当我们在处理需要操作数据的应用程序时,这些函数非常有帮助。让我们开始吧:
- 创建一个空数组并向其中推送项目:
> let foods = []
=> undefined
> foods.push('burger')
=> 1
> foods.push('fries')
=> 2
> foods.push('wings')
=> 3
- 查找项目的索引:
> foods.indexOf('burger')
=> 0
- 查找数组中项目的数量:
> foods.length
=> 3
- 从数组中的特定索引中移除一个项目。我们将通过将要移除的项目的位置存储到一个变量中来实现这一点。知道我们要移除项目的位置后,我们可以调用
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
的列表,其中存储了一系列字符串,以及一些辅助函数来帮助存储和删除列表中的用户。
为了做到这一点,我们需要定义一个函数,该函数接受我们的用户列表并对其进行修改以符合我们的要求。
完成此活动的步骤如下:
-
创建
Activity08.js
文件。 -
定义一个
logUser
函数,它将添加用户到提供的userList
参数中,并确保不添加重复项。 -
定义一个
userLeft
函数。它将从提供的userList
参数中移除用户。 -
定义一个
numUsers
函数,它返回当前列表中的用户数量。 -
定义一个名为
runSite
的函数。这将用于测试我们的实现。
注意
此活动的解决方案可在第 607 页找到。
在这个活动中,我们探讨了在 JavaScript 中使用数组完成某些任务的一种方式。我们可以使用它来跟踪项目列表,并使用内置方法来添加和删除项目。我们看到user3、user5和user6是因为这些用户从未被移除。
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
数组,但这次不仅存储字符串列表,而是使用对象。让我们开始吧:
- 将
singers
数组设置为空数组:
> singers = []
=> undefined
- 将对象推送到数组中:
> singers.push({ name: 'miku', age: 16 })
=> undefined
- 修改数组中第一个对象的
name
属性:
> singers[0].name = 'Hatsune Miku'
=> 'Hatsune Miku'
> singers
=> [ { name: 'Hatsune Miku', age: 16 } ]
修改对象中的值非常简单;例如,您可以将任何值分配给属性,但不仅如此。您还可以添加原本不是对象一部分的属性,以扩展其信息。
- 向对象添加一个名为
birthday
的属性:
> singers[0].birthday = 'August 31'
=> 'August 31'
> singers
=> [ { name: 'Hatsune Miku', age: 16, birthday: 'August 31' } ]
要向现有对象添加属性,只需将值分配给属性名称。如果该属性不存在,将创建该属性。您可以将任何值分配给属性,函数、数组或其他对象。
- 通过执行以下代码读取对象中的属性:
> 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 的结果
正如您所看到的,调用JSON.stringify
已将我们的对象转换为对象的字符串表示。
但由于它的实现方式,JSON.stringify
非常低效。尽管在大多数应用程序中性能差异并不明显,在高性能应用程序中,性能确实很重要。使JSON.stringify
更快的一种方法是知道你需要最终输出中的哪些属性。
练习 41:创建一个高效的 JSON.Stringify
我们的目标是编写一个简单的函数,该函数接受一个对象和要包含在最终输出中的属性列表。然后,该函数将调用JSON.stringify
来创建对象的字符串版本。让我们在Exercise41.js
文件中定义一个名为betterStringify
的函数:
- 创建
betterStringify
函数:
function betterStringify(item, propertyMap) {
}
- 现在,我们将创建一个临时输出。我们将存储我们想要包含在
propertyMap
中的属性:
let output = {};
- 遍历我们的
propertyMap
参数以挑选我们想要包含的属性:
propertyMap.forEach((key) => {
});
因为我们的propertyMap
参数是一个数组,我们希望使用forEach
来对其进行迭代。
- 将值从我们的项目分配给临时输出:
propertyMap.forEach((key) => {
if (item[key]) {
output[key] = item[key];
}
});
在这里,我们正在检查我们的propertyMap
参数中的键是否已设置。如果已设置,我们将把值存储在我们的output
属性中。
- 在测试对象上使用一个函数:
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:运行 Exercise41.js 的输出
现在,是时候回答一个棘手的问题了:如果你像这样做了一些事情,你的代码会有多快?
如果你对此进行基准测试,你会看到比JSON.stringify
快 30%的性能提升:
图 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
的数组。它将包括基本的用户信息。我们还将声明一些变量,以便我们可以使用解构赋值将数组中的项目存储起来。让我们开始吧:
- 创建
userInfo
数组:
> const userInfo = ['John', 'chef', 34]
=> undefined
- 创建用于存储
name
、age
和job
的变量:
> let name, age, job
=> undefined
- 使用解构赋值语法将值分配给我们的变量:
> [name, job, age] = userInfo
=> [ 'John', 'chef', 34 ]
检查我们的值:
> name
=> 'John'
> job
=> 'chef'
> age
=> 34
- 你还可以使用以下代码忽略数组中的值:
> [name, ,age] = userInfo
=> [ 'John', 'chef', 34 ] // we ignored the second element 'chef'
解构赋值在处理数据时非常有用,因为数据的格式通常不是你所期望的。它还可以用来挑选数组中你想要的项目。
练习 43:使用对象的解构赋值
在之前的练习中,我们声明了一个包含用户信息的数组,并使用解构赋值从中检索了一些值。同样的事情也可以用于对象。在这个练习中,我们将尝试对对象使用解构赋值。让我们开始吧:
- 创建一个名为
userInfo
的对象:
> const userInfo = { name: 'John', job: 'chef', age: 34 }
=> undefined
- 创建我们将用来存储信息的变量:
> let name, job
=> undefined
- 使用解构赋值语法来分配值:
> ({ name, job } = userInfo)
=> { name: 'John', job: 'chef', age: 34 }
- 检查这些值:
> name
=> 'John'
> job
=> 'chef'
请注意,在对象上使用解构赋值时,它的作用类似于一个过滤器,其中变量名必须匹配,并且您可以有选择地选择要选择的数组中的属性。还有一种不需要预先声明变量的对象使用方式。
- 使用数组进行解构赋值:
> userInfo = ['John', 'chef', 34]
=> undefined
> [ name, , age] = userInfo
=> undefined
> name
=> 'John'
> age
=> 34
- 使用解构运算符从对象值创建变量:
> const userInfoObj = { name: 'John', job: 'chef', age: 34 }
=> undefined
> let { job } = userInfoObj
=> undefined
> job
=> 'chef'
以下是前面代码的输出:
图 7.7:作业变量的输出
在这个练习中,我们讨论了如何使用解构运算符从对象和数组中提取特定信息。当我们处理大量信息并且只想传输该信息的子集时,这非常有用。
展开运算符
在上一个练习中,我们讨论了从对象或数组中获取特定信息的一些方法。还有另一个运算符可以帮助我们展开数组或对象。展开运算符被添加到 ES6 规范中,但在 ES9 中,它还添加了对对象展开的支持。展开运算符的功能是将每个项目展开为单独的项目。对于数组,当我们使用展开运算符时,我们可以将其视为单独值的列表。对于对象,它们将展开为键值对。在下一个练习中,我们将探索在应用程序中使用展开运算符的不同方式。
要使用展开运算符,我们在任何可迭代对象之前使用三个点(…
),就像这样:
printUser(...userInfo)
练习 44:使用展开运算符
在这个练习中,我们将看到展开运算符如何帮助我们。我们将使用上一个练习中的原始userInfo
数组。
执行以下步骤完成练习:
- 创建
userInfo
数组:
> const userInfo = ['John', 'chef', 34]
=> undefined
- 创建一个打印用户信息的函数:
> function printUser(name, job, age) {
... console.log(name + ' is working as ' + job + ' and is ' + age + ' years old');
... }
=> undefined
- 将数组展开为参数列表:
> printUser(...userInfo)
John is working as chef and is 34 years old
正如你所看到的,调用这个函数的原始方式,没有使用展开运算符,是使用数组访问运算符,并为每个参数重复这样做。由于数组的排序与相应的参数匹配,我们可以只使用展开运算符。
- 当你想要合并数组时使用展开运算符:
> const detailedInfo = ['male', ...userInfo, 'July 5']
=> [ 'male', 'John', 'chef', 34, 'July 5' ]
- 使用展开运算符作为复制数组的一种方式:
> let detailedInfoCopy = [ ...detailedInfo ];
=> undefined
> detailedInfoCopy
=> [ 'male', 'John', 'chef', 34, 'July 5' ]
在对象上使用展开运算符要强大得多且实用。
- 创建一个名为
userRequest
的新对象:
> const userRequest = { name: 'username', type: 'update', data: 'newname'}
=> undefined
- 使用
object
展开克隆对象:
> const newObj = { ...userRequest }
=> undefined
> newObj
=> { name: 'username', type: 'update', data: 'newname' }
- 创建一个包含此对象的每个属性的对象:
> 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 时的输出
这并不意味着你对参数的数量没有任何控制。您可以像这样编写函数声明,让 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 的输出。
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
的类。稍后,我们还将为类添加一些方法。我们将在这里使用函数构造方法。让我们开始吧:
- 定义
Food
构造函数:
function Food(name, calories, cost) {
this.name = name;
this.calories = calories;
this.cost = cost;
}
- 将方法添加到构造函数中:
Food.prototype.description = function () {
return this.name + ' calories: ' + this.calories;
}
- 使用
Food
构造函数创建一个新对象:
let burger = new Food('burger', 1000, 9);
- 调用我们声明的方法:
console.log(burger.description());
以下是前面代码的输出:
图 7.10:burger.description()方法的输出
你们中的许多人可能熟悉这种类声明的方式。但这也会带来问题。首先,使用函数作为构造函数会让开发人员不清楚何时将函数视为函数,何时将其视为构造函数。后来,当 JavaScript 发布了 ES6 时,它引入了一种新的声明类的方式。在下一个练习中,我们将使用新的方法来声明Food
类。
练习 46:在 JavaScript 中创建一个类
在这个练习中,我们将在 JavaScript 中创建一个类定义来存储食物数据。它将包括一个名称、成本和卡路里计数。稍后,我们还将创建一些返回食物描述的方法,以及另一个静态方法来输出特定食物的卡路里。让我们开始吧:
- 声明一个
Food
类:
class Food {
}
- 对类名运行
typeof
以查看它的类型:
console.log(typeof Food) // should print out 'function'
以下是前面代码的输出:
图 7.11:在类上运行 typeof 命令
正如您所看到的,我们刚刚声明的新类的类型是function
- 这不是很有趣吗?这是因为在 JavaScript 内部,我们声明的类只是另一种编写constructor
函数的方式。
- 让我们添加我们的
constructor
:
class Food {
constructor(name, calories, cost) {
this.name = name;
this.calories = calories;
this.cost = cost;
}
}
就像任何其他语言一样,类定义将包括一个构造函数,使用new
关键字调用它来创建这个类的实例。
- 在类定义中编写
description
方法:
class Food {
constructor(name, calories, cost) {
this.name = name;
this.calories = calories;
this.cost = cost;
}
description() {
return this.name + ' calories: ' + this.calories;
}
}
- 如果您尝试像调用函数一样调用
Food
类构造函数,它将抛出以下错误:
Food('burger', 1000, 9);
// TypeError: Class constructor Food2 cannot be invoked without 'new'
以下是前面代码的输出:
图 7.12:以函数方式调用构造函数的 TypeError
请注意,当您尝试将构造函数作为函数调用时,运行时会抛出错误。这非常有帮助,因为它可以防止开发人员错误地将构造函数作为函数调用。
- 使用类构造函数创建一个新的食物对象:
let friedChicken = new Food('fried chicken', 520, 5);
- 调用我们声明的方法:
console.log(friedChicken.description());
- 声明
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;
}
}
- 使用我们刚刚创建的对象调用
static
方法:
console.log(Food.getCalories(friedChicken)); /// 520
以下是前面代码的输出:
图 7.13:调用 Food 类的静态方法后生成的输出
与任何其他编程语言一样,您可以在不实例化对象的情况下调用static
方法。
现在我们已经看过了在 JavaScript 中声明类的新方法,让我们谈谈一些类声明的不同之处:
-
构造函数方法是必需的。 如果您没有声明一个,JavaScript 将添加一个空构造函数。
-
类声明不会被提升,这意味着您不能在声明之前使用它。 因此,最好将类定义或导入放在代码的顶部。
使用对象创建简单的用户信息缓存
在本节中,我们将设计一个简单的用户信息缓存。 缓存是一个临时位置,您可以在从原始位置获取它们时将最常访问的项目存储在其中。 假设您正在为处理用户配置文件的后端应用程序进行设计。 每当请求到来时,服务器都需要调用数据库来检索用户配置文件并将其发送回处理程序。 正如您可能知道的那样,调用数据库是一个非常昂贵的操作。 作为后端开发人员,您可能会被要求提高服务的读取性能。
在下一个练习中,您将创建一个简单的缓存,用于存储用户配置文件,以便大部分时间可以跳过对数据库的请求。
练习 47:创建一个缓存类以添加/更新/删除数据存储中的记录
在这个练习中,我们将创建一个包含本地内存数据存储的缓存类。 它还包括一个从数据存储中添加/更新/删除记录的方法。
执行以下步骤以完成此练习:
- 创建
MySimpleCache
类:
class MySimpleCache {
constructor() {
// Declare your cache internal properties here
this.cacheItems = {};
}
}
在构造函数中,我们还将初始化缓存的内部状态。 这将是一个简单的对象。
- 定义
addItem
,它将为键设置缓存项:
addItem(key, value) {
// Add an item with the key
this.cacheItems[key] = value;
}
- 定义
updateItem
,它将使用我们已经定义的addItem
:
updateItem(key, value) {
// Update a value use the key
this.addItem(key, value);
}
- 定义
removeItem
。 这将删除我们存储在缓存中的对象,并调用我们之前创建的updateItem
方法:
removeItem(key) {
this.updateItem(key, undefined);
}
- 使用
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 的输出
类继承
到目前为止,我们只在 JavaScript 中创建了简单的类定义。 在 OOP 中,我们还可以让一个类继承自另一个类。 类继承只是使一个类的实现派生自另一个类。 创建的子类将具有父类的所有属性和方法。 这在以下图表中显示:
图 7.15:类继承
类继承提供了一些好处:
-
它创建了干净,可测试和可重用的代码。
-
它减少了相似代码的数量。
-
在编写适用于所有子类的新功能时,减少了维护时间。
在 JavaScript 中,很容易创建一个从另一个类继承的子类。 为此,使用extends
关键字:
class MySubClass extends ParentClass {
}
练习 48:实现子类
在这个练习中,我们将定义一个名为Vehicle
的超类,并从中创建我们的子类。 超类将具有名为start
,buy
和name
,speed
和cost
的方法作为其属性。
超类的构造函数将获取名称,颜色和速度属性,然后将它们存储在对象内部。
start
方法将简单地打印一个字符串,告诉您正在使用哪种车辆以及您是如何旅行的。buy
函数将打印出您即将购买的车辆。
执行以下步骤以完成此练习:
- 定义
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);
}
}
- 创建一个
vehicle
实例并测试其方法:
const vehicle = new Vehicle('bicycle', 15, 100);
vehicle.start();
vehicle.buy();
您应该看到以下输出:
图 7.16:车辆类的输出
- 创建
Car
,Plane
和Rocket
子类:
class Car extends Vehicle {}
class Plane extends Vehicle {}
class Rocket extends Vehicle {}
- 在
Car
,Plane
和Rocket
中,重写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');
}
}
- 为
Plane
,Rocket
和Car
创建一个实例:
const car = new Car('Toyota Corolla', 120, 5000);
const plane = new Plane('Boeing 737', 1000, 26000000);
const rocket = new Rocket('Saturn V', 9920, 6000000000);
- 在所有三个对象上调用
start
方法:
car.start();
plane.start();
rocket.start();
以下是前述代码的输出:
图 7.17:对象的输出
现在当您调用这些 start 方法时,您可以清楚地看到输出是不同的。在声明子类时,大多数时候,我们需要重写父类的一些方法。当我们减少重复的代码同时保留定制时,这非常有用。
定制不止于此 - 您还可以创建具有不同构造函数的新子类。您还可以从子类调用父方法。
- 对我们之前创建的子类,我们将修改
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');
}
}
- 检查额外的属性是否已设置:
const car2 = new Car('Toyota Corolla 2', 120, 5000, 2000);
console.log(car2.tankSize); // 2000
以下是前述代码的输出:
图 7.18:检查 Car 类的额外属性
如您所见,声明子类非常容易 - 在以这种方式编码时,您可以共享大量代码。此外,您不会失去进行定制的能力。在 ES6 标准之后,您可以轻松地定义类,就像其他面向对象的编程语言一样。它可以使您的代码更清晰,更易于测试和更易于维护。
私有和公共方法
在面向对象编程中,有时将可公开访问的属性和函数与私有可访问的属性和函数分开是有用的。这是一种保护层,可以防止使用类的开发人员调用或访问类的一些内部状态。在 JavaScript 中,这种行为是不可能的,因为 ES6 不允许声明私有属性;您在类中声明的所有属性都将是公开可访问的。为了实现这种类型的行为,一些开发人员选择使用下划线前缀,例如privateMethod()
,以通知其他开发人员不要使用它。但是,有关声明私有方法的黑客。在下一个练习中,我们将探讨私有方法。
练习 49:车辆类中的私有方法
在这个练习中,我们将尝试为我们之前创建的Car
类声明一个私有函数,以便在以后将类导出为模块时确保我们的私有方法不会暴露出来。让我们开始吧:
- 创建一个名为
printStat
的函数:
function printStat() {
console.log('The car has a tanksize of ', this.tankSize);
}
- 修改
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
。
- 创建另一个
car
实例并调用start
方法:
const car = new Car('Toyota Corolla', 120, 5000, 2000);
car.start();
当您运行此代码时,您将意识到这会导致异常:
图 7.19:printStat 的输出
- 修改
start
方法,以便函数了解我们从中调用它的对象实例:
start() {
console.log('Driving car, at ' + this.speed + 'km/h');
printStat.bind(this)();
}
请注意我们使用了.bind()
。通过使用绑定,我们将当前实例绑定到此函数内部的this
变量。这使我们的代码能够按预期工作:
图 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:使用数组映射方法的输出
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:使用数组映射方法的示例输出
我们将在第十章 JavaScript 中的函数式编程中更详细地介绍array.map
。
我们将在接下来的练习中使用的另一种方法是forEach
方法。forEach
方法更加简洁,因为不需要管理当前索引并编写实际调用函数的代码。forEach
方法是一个内置的数组方法,它接受一个函数作为参数。以下是forEach
方法的示例:
foods.forEach(eat_food);
在接下来的练习中,我们将在数组上使用迭代方法。
练习 50:在数组上使用迭代方法
有许多遍历数组的方法。一种是使用带有索引的for
循环,另一种是使用其中一种内置方法。在这个练习中,我们将初始化一个字符串数组,然后探索 JavaScript 中可用的一些迭代方法。让我们开始吧:
- 创建一个食物列表作为数组:
const foods = ['sushi', 'tofu', 'fried chicken'];
- 使用
join
连接数组中的每个项目:
foods.join(', ');
以下是上述代码的输出:
图 7.23:数组中的连接项目
数组连接是另一种遍历数组中每个项目的方法,使用提供的分隔符将它们组合成一个单一的字符串。
- 创建一个名为
eat_food
的函数:
function eat_food(food) {
console.log('I am eating ' + food);
}
- 使用
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 的输出
- 使用
forEach
方法来实现相同的效果:
foods.forEach(eat_food);
以下是上述代码的输出:
图 7.25:使用 forEach 方法生成相同的输出
因为eat_food
是一个函数,它的第一个参数引用了当前项目,所以我们可以直接传递函数名。
- 创建一个新的卡路里数字数组:
const nutrition = [100, 50, 400]
这个数组包括我们food
数组中每个项目的卡路里。接下来,我们将使用不同的迭代函数来创建一个包含这些信息的新对象列表。
- 创建新的对象数组:
const foodInfo = foods.map((food, index) => {
return {
name: food,
calories: nutrition[index]
};
});
- 将
foodInfo
打印到控制台上:
console.log(foodInfo);
以下是上述代码的输出:
图 7.26:包含食物和卡路里信息的数组
运行array.map
后,将创建一个新数组,其中包含有关我们食物名称和其卡路里计数的信息。
在这个练习中,我们讨论了两种迭代方法,即forEach
和map
。每种方法都有其自己的功能和用法。在大多数应用程序中,通常使用映射来通过在每个数组项上运行相同的代码来计算数组结果。如果你想要在不直接修改数组的情况下操作数组中的每个项目,这是非常有用的。
练习 51:查找和过滤数组
以前,我们讨论了遍历数组的方法。这些方法也可以用于查找。众所周知,当你从头到尾迭代数组时,查找是非常昂贵的。幸运的是,JavaScript 数组有一些内置方法,因此我们不必自己编写搜索函数。在这个练习中,我们将使用includes
和filter
来搜索数组中的项目。让我们开始吧:
- 创建一个名为
profiles
的名称列表:
let profiles = [
'Michael Scott',
'Jim Halpert',
'Dwight Shrute',
'Random User',
'Hatsune Miku',
'Rin Kagamine'
];
- 尝试找出
profiles
列表中是否包含名为Jim Halpert
的人:
let hasJim = profiles.includes('Jim Halpert');
console.log(hasJim);
以下是上述代码的输出:
图 7.27:hasJim 方法的输出
- 修改
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 }
]
现在,数组不再是简单的字符串列表-它是一个对象列表,当我们处理对象时,事情会有点不同。
- 尝试再次使用
includes
查找Jim Halpert
个人资料:
hasJim = profiles.includes({ name: 'Jim Halpert', age: 27});
console.log(hasJim);
以下是上述代码的输出:
图 7.28:hasJim 方法的输出
- 找到名为
Jim Halpert
的个人资料:
hasJim = !!profiles.find((profile) => {
return profile.name === 'Jim Halpert';
}).length;
console.log(hasJim);
- 找到所有年龄大于
18
的用户:
const adults = profiles.filter((profile) => {
return profile.age > 18;
});
console.log(adults);
当你运行上述代码时,它应该输出所有年龄超过 18 岁的用户。filter
和find
之间的区别在于filter
返回一个数组:
图 7.29:使用 filter 方法后的输出
在这个练习中,我们看了两种定位数组中特定项的方法。通过使用这些方法,我们可以避免重写搜索算法。find
和filter
之间的区别在于filter
返回符合要求的所有对象的数组。在实际的生产环境中,当我们想要测试数组是否有与我们要求匹配的对象时,通常使用find
方法,因为它在找到一个匹配时就停止扫描,而filter
会与数组中的所有对象进行比较,并返回所有匹配的结果。如果您只是测试某物的存在,这将更加昂贵。我们还使用了双重否定运算符将结果转换为布尔值。如果您稍后在条件语句中使用这个值,这种表示法非常有用。
排序
排序是开发人员面临的最大挑战之一。当我们想要对数组中的一些项目进行排序时,通常需要定义特定的排序算法。这些算法通常需要我们编写大量的排序逻辑,并且不容易重用。在 JavaScript 中,我们可以使用内置的数组方法对我们的自定义项目列表进行排序,并编写最少的自定义代码。
在 JavaScript 数组中进行排序需要在数组上调用.sort()
函数。sort()
函数接受一个参数,称为排序比较器。根据比较器,sort()
函数将决定如何排列每个元素。
以下是我们将在即将进行的练习中使用的一些其他函数的简要描述。
compareNumber
函数只计算a
和b
之间的差异。在sort
方法中,我们可以声明自己的自定义比较函数进行比较:
function compareNumber(a, b) {
return a - b;
}
compareAge
函数与compareNumber
函数非常相似。唯一的区别在于我们比较的是 JavaScript 对象而不是数字:
function compareAge(a, b) {
return a.age - b.age;
}
练习 52:JavaScript 中的数组排序
在这个练习中,我们将讨论对数组进行排序的方法。在计算机科学中,排序总是复杂的。在 JavaScript 中,数组对象内置了一个排序方法,可以对数组进行基本排序。
我们将使用上一个练习中的profiles
对象数组。让我们开始吧:
- 创建一个
numbers
数组:
const numbers = [ 20, 1, 3, 55, 100, 2];
- 调用
array.sort()
对这个数组进行排序:
numbers.sort();
console.log(numbers);
当您运行上述代码时,您将获得以下输出:
图 7.30:数组.sort()的输出
这并不是我们想要的;似乎sort
函数只是随机排列值。其背后的原因是,在 JavaScript 中,array.sort()
实际上并不支持按值排序。默认情况下,它将所有内容视为字符串。当我们使用数字数组调用它时,它将所有内容转换为字符串,然后开始排序。这就是为什么您会看到数字 1 出现在 2 和 3 之前的原因。为了实现对数字的排序,我们需要做一些额外的工作。
- 定义
compareNumber
函数:
function compareNumber(a, b) {
return a - b;
}
该函数期望接受两个要进行比较的值,并返回一个必须匹配以下内容的值:如果a
小于b
,则返回小于 0 的数字;如果a
等于b
,则返回 0;如果a
大于b
,则返回大于 0 的数字。
- 运行
sort
函数,并将compareNumber
函数作为参数传递:
numbers.sort(compareNumber);
console.log(numbers);
当您运行上述代码时,您将看到该函数已将我们的数组按照我们想要的顺序排序:
图 7.31:数组.sort(compareNumber)的输出
现在,数组已经正确地从最小到最大排序。然而,大多数情况下,当我们需要进行排序时,我们需要将复杂的对象排序。在下一步中,我们将使用在上一个练习中创建的profiles
数组。
- 如果您的工作空间中未定义
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 }
]
- 调用
profiles.sort()
:
profiles.sort();
console.log(profiles);
以下是前面代码的输出:
图 7.32:profiles.sort()函数的输出
因为我们的sort
函数不知道如何比较这些对象,所以数组保持原样。为了正确排序对象,我们需要一个与上次一样的比较函数。
- 定义
compareAge
:
function compareAge(a, b) {
return a.age - b.age;
}
提供给compareAge
的两个参数a
和b
是数组中的对象。因此,为了正确排序它们,我们需要访问这些对象的age
属性并进行比较。
- 使用我们刚刚定义的
compare
函数调用sort
函数:
profiles.sort(compareAge);
console.log(profiles);
以下是前面代码的输出:
图 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
方法为购物车进行计算。让我们开始吧:
- 创建购物车变量:
const cart = [];
- 将项目推入数组:
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 });
- 使用循环方法计算购物车的总成本:
let total = 0;
cart.forEach((item) => {
total += item.price * item.amount;
});
console.log('Total amount: ' + total);
以下是前面代码的输出:
图 7.34:计算总数的循环方法的结果
- 我们编写了名为
priceReducer
的 reducer:
function priceReducer (accumulator, currentValue) {
return accumulator += currentValue.price * currentValue.amount;
}
- 使用我们的 reducer 调用
cart.reduce
:
total = cart.reduce(priceReducer, 0);
console.log('Total amount: ' + total);
以下是前面代码的输出:
图 7.35:cart.reduce 的结果
在这个练习中,我们讨论了在 JavaScript 中将数组减少为单个值的方法。虽然使用循环迭代数组并返回累加器是完全正确的,但是使用减少函数时,代码会更加简洁。我们不仅减少了作用域中可变变量的数量,还使代码更加简洁和可维护。下一个维护代码的人将知道该函数的返回值将是一个单一的值,而forEach
方法可能会使得返回结果不清晰。
活动 9:使用 JavaScript 数组和类创建学生管理器
假设你正在为当地的学区工作,到目前为止,他们一直在使用纸质登记簿来记录学生信息。现在,他们获得了一些资金,并希望您开发一款计算机软件来跟踪学生信息。他们对软件有以下要求:
-
它需要能够记录关于学生的信息,包括他们的姓名、年龄、年级和书籍信息。
-
每个学生将被分配一个唯一的 ID,用于检索和修改学生记录。
-
书籍信息将包括该学生的书籍名称和当前成绩(数字成绩)。
-
需要一种方法来计算学生的平均成绩。
-
需要一种方法来搜索具有相同年龄或年级的所有学生。
-
需要一种方法来使用他们的名字搜索学生。当找到多个时,返回所有学生。
注意
此活动的完整代码也可以在我们的 GitHub 存储库中找到,链接在这里:github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson07/Activity09/Activity09.js
。
执行以下步骤以完成此活动:
-
创建一个
School
类并在构造函数中初始化学生列表。 -
创建一个
Student
类,并在其中存储课程列表、学生的age
、name
和grade level
。 -
创建一个
Course
类,其中包括有关course
、name
和grades
的信息。 -
在
School
类中创建addStudent
函数,将学生推入school
对象中的列表中。 -
在
School
类中创建findByGrade
函数,该函数返回具有给定grade level
的所有学生。 -
在
School
类中创建findByAge
函数,该函数返回具有相同age
的学生列表。 -
在
School
类中创建findByName
函数,通过姓名搜索学校中的所有学生。 -
在
Student
类中,为计算学生的平均成绩创建一个calculateAverageGrade
方法。 -
在
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 以及它们与对象相比有何不同:
- 创建一个名为
map
的新 Map:
const map = new Map()
- 创建我们想要用作键的对象列表:
const key1 = 'key1';
const key2 = { name: 'John', age: 18 };
const key3 = Map;
- 使用
map.set
为我们之前定义的所有键设置一个值:
map.set(key1, 'value for key1');
map.set(key2, 'value for key2');
map.set(key3, 'value for key3');
以下是前面代码的输出:
图 7.36:对 map.set 分配值后的输出
- 获取键的值:
console.log(map.get(key1));
console.log(map.get(key2));
console.log(map.get(key3));
以下是前面代码的输出:
图 7.37:值检索的 console.log 输出
- 在不使用引用的情况下检索
key2
的值:
console.log(map.get({ name: 'John', age: 18 }));
以下是前面代码的输出:
图 7.38:在没有引用的情况下使用 get 时的 console.log 输出
虽然我们输入了所有正确的内容,但是我们的地图似乎无法找到该键的值。这是因为在进行这些检索时,它使用的是对象的引用而不是值。
- 使用
forEach
迭代地图:
map.forEach((value, key) => {
console.log('the value for key: ' + key + ' is ' + value);
});
地图可以像数组一样进行迭代。使用forEach
方法时,传入的函数将被调用两个参数:第一个参数是值,第二个参数是键。
- 获取键和值的数组列表:
console.log(map.keys());
console.log(map.values());
以下是前面代码的输出:
图 7.39:键和值的数组列表
当您只需要存储信息的一部分时,这些方法非常有用。如果您有一个地图来跟踪用户,使用他们的 ID 作为键,调用values
方法将简单地返回一个用户列表。
- 检查地图是否包含一个键:
console.log(map.has('non exist')); // false
以下是前面代码的输出:
图 7.40:指示地图不包括键的输出
注意
在这里,我们可以看到地图和对象之间的第一个主要区别,尽管两者都能够跟踪唯一键值对的列表。在地图中,您可以拥有对象或函数的引用作为键。这在 JavaScript 中的对象中是不可能的。我们还可以看到的另一件事是,它还保留了根据它们被添加到地图中的顺序的键的顺序。虽然您可能会在对象中获得有序的键,但 JavaScript 不能保证键的顺序与它们被添加到对象中的顺序一致。
通过这个练习,我们了解了地图的用法及其与对象的区别。当你处理键值数据并且需要进行排序时,地图应该始终优先于对象,因为它不仅保留了键的顺序,还允许将对象引用用作键。这是两种类型之间的主要区别。在下一个练习中,我们将介绍另一种经常被开发人员忽视的类型:集合。
在数学中,集合被定义为不同对象的集合。在 JavaScript 中,它很少被使用,但是我们将无论如何介绍一种使用集合的方法。
练习 55:使用集合跟踪唯一值
在这个练习中,我们将介绍 JavaScript 集合。我们将构建一个算法来删除数组中的所有重复值。
执行以下步骤完成此练习:
- 声明一个名为
planets
的字符串数组:
const planets = [
'Mercury',
'Uranus',
'Mars',
'Venus',
'Neptune',
'Saturn',
'Mars',
'Jupiter',
'Earth',
'Saturn'
]
- 使用数组创建一个新的集合:
const planetSet = new Set(planets);
- 检索
planets
数组中的唯一值:
console.log(planetSet.values());
以下是前面代码的输出:
图 7.41:唯一的数组值
- 使用
add
方法向集合添加更多值:
planetSet.add('Venus');
planetSet.add('Kepler-440b');
我们可以使用add
方法向我们的集合添加一个新值,但是因为集合始终保持其成员的唯一性,如果您添加任何已经存在的内容,它将被忽略:
图 7.42:无法添加重复值
- 使用
.size
属性获取 Set 的大小:
console.log(planetSet.size);
- 清除集合中的所有值:
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 方法来操作它们。
执行以下步骤以完成此练习:
- 创建一个名为
planet
的变量:
let planet = 'Earth';
- 使用模板字符串创建
句子
:
let sentence = `We are on the planet ${planet}`;
模板字符串是 ES6 中引入的非常有用的功能。我们可以通过组合模板和变量来创建字符串,而无需创建字符串构建或使用字符串连接。字符串模板使用`
包装,而要插入到字符串中的变量用${}
包装。
- 将我们的句子分割成单词:
console.log(sentence.split(' '));
我们可以使用 split
方法和分隔符将字符串拆分为数组。在上面的示例中,JavaScript 将我们的句子分割成一个单词数组,就像这样:
图 7.44:将字符串分割为单词数组
- 我们还可以使用
replace
方法将任何匹配的子字符串替换为另一子字符串,如下所示:
sentence = sentence.replace('Earth', 'Venus');
console.log(sentence);
以下是先前代码的输出结果:
图 7.45:替换字符串中的单词
在 replace
方法中,我们将第一个参数作为要在字符串中匹配的子字符串提供。第二个参数是您要用来替换的字符串。
- 检查我们的句子是否包含单词
火星
:
console.log(sentence.includes('Mars'));
以下是先前代码的输出结果:
图 7.46:检查字符串中是否存在某个字符
- 您还可以将整个字符串转换为大写或小写:
sentence.toUpperCase();
sentence.toLowerCase();
- 使用
charAt
方法在字符串中获取索引处的字符:
sentence.charAt(0); // returns W
由于句子并不一定是数组,所以无法像数组那样访问特定位置的字符。要实现这一点,您需要调用 charAt
方法。
- 使用字符串的
length
属性获取字符串的长度:
sentence.length;
以下是先前代码的输出结果:
图 7.47:我们修改后句子的长度
在这个练习中,我们将介绍如何使用模板字符串和字符串方法构建字符串,这些方法有助于我们操作字符串。这在处理大量用户输入的应用程序中非常有用。在下一个练习中,我们将学习 Math 和 Date 方法。
Math 和 Date
在本节中,我们将学习 Math 和 Date 类型。我们很少在应用程序中涉及 Math,但是当我们涉及它时,充分利用 Math 库非常有用。稍后,我们将讨论 Date 对象及其方法。Math 和 Date 类包括各种有用的方法,帮助我们进行数学计算和日期操作。
练习 57:使用 Math 和 Date
在本练习中,我们将学习如何在 JavaScript 中实现 Math 和 Date 类型。我们将使用它们来生成随机数,并使用其内置常量进行数学计算。我们还将使用 Date 对象来测试 JavaScript 中不同处理日期的方式。让我们开始吧:
- 创建一个名为
generateRandomString
的函数:
function generateRandomString(length) {
}
- 创建一个在一定范围内生成随机数的函数:
function generateRandomNumber(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
在上述函数中,Math.random
生成 0(inclusive)到 1(exclusive)之间的随机数。当我们想要两个范围内的数字时,我们也可以使用 Math.floor
将数字四舍五入以确保它不包括 max
在我们的输出中。
- 在
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
传递一个随机索引。
- 测试我们的函数:
console.log(generateRandomString(16));
以下是先前代码的输出:
图 7.48:我们随机字符串函数的输出
每次运行这个函数,它都会给我们一个完全随机的字符串,该字符串的长度与我们传递的参数相同。这是生成随机用户名的非常简单的方法,但不太适合生成 ID,因为它无法保证唯一性。
- 使用
Math
常数创建一个计算圆形面积的函数,如下所示:
function circleArea(radius) {
return Math.pow(radius, 2) * Math.PI;
}
在这个函数中,我们使用了Math
对象中的Math.PI
。它赋予了 radius 参数的平方值。接下来,我们将探讨 JavaScript 中的Date
类型。
- 创建一个新的
Date
对象:
const now = new Date();
console.log(now);
以下是先前代码的输出:
图 7.49:新日期对象的输出
当我们创建一个不带参数的新Date
对象时,它将生成一个存储当前时间的对象。
- 在特定的日期和时间创建一个新的
Date
对象:
const past = new Date('August 31, 2007 00:00:00');
Date
构造函数将接受一个可解析为日期的字符串参数。当我们使用这个字符串调用构造函数时,它将创建一个Date
对象在那个日期和时间。
- 从我们的
past
日期对象中获取年、月和日:
console.log(past.getFullYear());
console.log(past.getMonth());
console.log(past.getDate());
以下是先前代码的输出:
图 7.50:过去日期对象的年、月和日
返回的月份不是从 1 开始的,一月是 1。相反,它从 0 开始,因此八月是 7。
- 你也可以通过调用
toString
生成对象的字符串表示版本:
console.log(past.toString());
以下是先前代码的输出:
图 7.51:以字符串形式呈现的日期
通过使用toString
方法,我们可以简单地在应用程序中记录时间戳。
- 如果你想得到 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:使用符号并探索它们的属性
在这个练习中,我们将使用符号及其属性来识别对象的属性。让我们开始吧:
- 创建两个符号:
let symbol1 = Symbol();
let symbol2 = Symbol('symbol');
- 测试它们的等价性:
console.log(symbol1 === symbol2);
console.log(symbol1 === Symbol('symbol'));
两个语句都将被评估为 false。这是因为在 JavaScript 中,符号是唯一的,即使它们具有相同的名称,它们仍然不相等。
- 创建一个带有一些属性的测试对象:
const testObj = {};
testObj.name = 'test object';
testObj.included = 'this will be included';
- 使用符号作为键在对象中创建一个属性:
const symbolKey = Symbol();
testObj[symbolKey] = 'this will be hidden';
- 打印出对象中的键:
console.log(Object.keys(testObj));
以下是前面代码的输出结果:
图 7.52:使用 Object.keys 打印出的键列表
看起来调用Object.keys
并没有返回我们的Symbol
属性。这背后的原因是因为符号不可枚举,因此它们既不会被Object.keys
返回,也不会被Object.getOwnPropertyNames
返回。
- 让我们尝试获取我们的
Symbol
属性的值:
console.log(testObj[Symbol()]); // Will return undefined
console.log(testObj[symbolKey]); // Will return our hidden property
- 使用
Symbol
注册表:
const anotherSymbolKey = Symbol.for('key');
const copyOfAnotherSymbol = Symbol.for('key');
在这个例子中,我们可以对Symbol
键进行搜索,并将该引用存储在我们的新常量中。Symbol
注册表是我们应用程序中所有符号的注册表。在这里,你可以将你创建的符号存储在一个全局注册表中,这样它们以后就可以被检索到。
- 使用其引用检索
Symbol
属性的内容:
testObj[anotherSymbolKey] = 'another key';
console.log(testObj[copyOfAnotherSymbol]);
以下是前面代码的输出结果:
图 7.53:通过符号引用检索值的结果
当我们运行这段代码时,它将打印出我们想要的结果。当我们使用Symbol.for
创建一个符号时,我们将在键和引用之间创建一个一对一的关系,这样当我们使用Symbol.for
获取另一个引用时,这两个符号将是相等的。
在这个练习中,我们讨论了符号的一些属性。如果您需要将它们用作object
属性的标识符,它们非常有用。使用Symbol
注册表也可以帮助我们重新定位我们之前创建的Symbol
。在下一个练习中,我们将讨论迭代器和生成器的一般用法。
在前一个练习中,我们讨论了符号。在 JavaScript 中还有另一种叫做Symbol
的类型,叫做Symbol.iterator
,它是一个特定的符号,用于创建迭代器。在这个练习中,我们将使用生成器来创建一个可迭代对象。
练习 59:迭代器和生成器
Python 中有一个非常有用的函数叫做range()
,可以生成给定范围内的数字;现在,让我们尝试用迭代器重新创建它:
- 创建一个名为
range
的函数,它返回具有iterator
属性的对象:
function range(max) {
return {
*[Symbol.iterator]() {
yield 1;
}
};
}
- 在我们的
range
函数上使用for..in
循环:
for (let value of range(10)) {
console.log(value);
}
以下是上述代码的输出:
图 7.54:使用 for..in 循环输出
当我们运行这段代码时,它只会产生一个值。为了修改它以产生多个结果,我们将用循环包装它。
- 让我们用循环包装
yield
语句:
function range(max) {
return {
*[Symbol.iterator]() {
for (let i = 0; i < max; i++) {
yield i;
}
}
};
}
通常情况下,这不会与returns
一起使用,因为它只能被返回一次。这是因为期望生成器函数使用.next()
多次被消耗。我们可以延迟其执行,直到再次被调用:
图 7.55:在循环中包装 yield 语句后的输出
为了更好地理解生成器函数,我们还可以定义一个简单的生成器函数,而不必将其实现为迭代器。
- 创建一个名为
gen
的生成器函数:
function* gen() {
yield 1;
}
这是对生成器函数的非常简单的定义。当它被调用时,它将返回一个只能遍历一次的生成器。然而,你可以使用前述函数生成任意多的生成器。
- 生成一个名为
generator
的函数:
const generator = gen();
- 调用生成器的
next
方法来获取它的值:
console.log(generator.next());
console.log(generator.next());
console.log(generator.next());
当我们在生成器上调用`.next()`时,它将执行我们的代码,直到达到`yield`关键字。然后,它将返回该语句产生的值。它还包括一个`done`属性,用于指示这个生成器是否已经遍历了所有可能的值。一旦生成器达到了`done`状态,除非你修改内部状态,否则没有重新开始迭代的方法:
图 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:创建的代理值
练习 60:使用代理构建复杂对象
在这个练习中,我们将使用代理来演示如何构建一个能够隐藏其值并对属性执行数据类型强制的对象。我们还将扩展和定制一些基本操作。让我们开始吧:
- 创建一个基本的 JavaScript 对象:
const simpleObject = {};
- 创建一个
handlers
对象:
const handlers = {
}
- 为我们的基本对象创建代理封装:
const proxiesValue = new Proxy(simpleObject, handlers);
- 现在,将
handlers
添加到我们的代理中:
const handlers = {
get: (object, prop) => {
return 'values are private';
}
}
在这里,我们为我们的对象添加了一个get
处理程序,我们忽略了它请求的键,只返回了一个固定的字符串。当我们这样做时,无论我们做什么,对象都只会返回我们定义的值。
- 让我们在代理中测试我们的处理程序:
proxiedValue.key1 = 'value1';
console.log(proxiedValue.key1);
console.log(proxiedValue.keyDoesntExist);
以下是上述代码的输出:
图 7.58:在代理中测试处理程序
当我们运行这段代码时,我们在对象中给key1
赋了一个值,但由于我们定义处理程序的方式,在尝试读取值时,它总是返回我们之前定义的字符串。当我们尝试对一个不存在的值进行这样的操作时,它也返回相同的结果。
- 让我们为验证添加一个
set
处理程序:
set: (object, prop, value) => {
if (prop === 'id') {
if (!Number.isInteger(value)) {
throw new TypeError('The id needs to be an integer');
}
}
}
我们添加了一个 set
处理程序;每当我们尝试对我们的代理整数执行设置操作时,这个处理程序将被调用。
- 尝试将
id
设置为字符串:
proxiedValue.id = 'not an id'
图 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:运行数组代码后的输出
在重构过程中,我们可以用更少的代码编写前面的函数,并仍然保留所有的功能:
function appendPrefix(prefix, input) {
return input.map((inputItem) => {
return prefix + inputItem;
});
}
当我们再次调用它时会发生什么?让我们来看一下:
appendPrefix('Hi! ', ['Miku', 'Rin', 'Len']);
我们仍然会得到相同的输出:
图 7.61:重构代码后获得相同的输出
活动 10:重构函数以使用现代 JavaScript 特性
你最近加入了一家公司。分配给你的第一个任务是重构一些遗留模块。你打开了文件,发现现有的代码已经使用了遗留的 JavaScript 方法编写。你需要重构该文件中的所有函数,并确保它仍然可以通过所需的测试。
执行以下步骤以完成此活动:
-
使用 node.js 运行
Activity10.js
来检查测试是否通过。 -
使用
includes
数组重构itemExist
函数。 -
在
pushunique
函数中使用array push
来向底部添加一个新项。 -
在
createFilledArray
中使用array.fill
来用初始值填充我们的数组。 -
在
removeFirst
函数中使用array.shift
来移除第一项。 -
在
removeLast
函数中使用array.pop
来移除最后一项。 -
在
cloneArray
中使用展开运算符来克隆我们的数组。 -
使用
ES6
类重构Food
类。 -
重构后,运行代码以观察与旧代码生成相同的输出。
注意
这个活动的解决方案可以在第 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
函数希望你传递一个回调函数作为参数,该回调函数包括你接下来要做的一切。在回调函数中,我们接受两个参数,err
和res
;在函数内部,我们将之前声明的响应变量赋值给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
找到。
执行以下步骤完成练习:
- 创建一个
slowAPI
对象来创建一个模拟 API 库;它的目的是在合理的时间内返回结果。我们首先编写这个来介绍如何模拟异步函数而无需执行异步操作。
const slowAPI = {}
- 在我们刚刚定义的
slowAPI
对象中创建一个getUsers
函数,它不返回任何内容,需要一个回调函数。在getUsers
内部调用setTimeout
函数,用于在需要时给我们的代码添加 1 秒的延迟:
slowAPI.getUsers = (callback) => {
setTimeout(() => {
callback(null, {
status: 'OK',
data: {
users: [
{
name: 'Miku'
},
{
name: 'Len'
}
]
}
});
}, 1000);
}
- 在
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);
}
- 创建一个
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);
});
}
- 调用
run Request
函数:
runRequests();
输出应该如下:
图 8.1:runRequest 的输出
我们可以看到runRequest
函数已经运行完毕,我们的响应被正确打印出来。
- 修改
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 函数后的输出
这非常有趣,因为它首先输出了getCart
的结果,然后是getUsers
的结果。程序之所以表现如此,是因为 JavaScript 的异步和非阻塞特性。在我们的操作中,因为getCart
函数只需要 500 毫秒就能完成,所以它将是第一个输出。
- 修改前面的函数以输出第一个用户的购物车:
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:第一个用户的购物车输出
因为我们将使用第一个请求的数据,所以我们必须在第一个请求的回调函数中编写我们下一个请求的逻辑。
- 在访问未知用户的购物车时触发错误:
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:打印错误
我们在白色中看到的第一个错误输出是通过console.error
输出的错误。这可以根据您的喜好定制为特定格式的错误消息或输出,使用日志框架。第二个错误是由于我们在console.log
后立即抛出新错误导致进程崩溃。
在这个练习中,我们检查了如何使用setTimeout
模拟异步函数。setTimeout
是一个非常有用的函数。虽然在实际代码中并不推荐使用,但在测试中需要模拟需要时间的网络请求或在调试软件时产生竞争条件时,它非常有用。之后,我们讨论了使用回调函数使用异步函数的方法以及异步函数中的错误处理方式。
接下来,我们将简要讨论为什么回调函数正在逐渐过时,以及如果不正确使用回调函数会发生什么。
事件循环
您可能以前听说过这个术语,指的是 JavaScript 如何处理耗时操作。了解事件循环在底层是如何工作也非常重要。
当考虑 JavaScript 最常用于什么时,它用于制作动态网站,主要在浏览器中使用。让很多人惊讶的是,JavaScript 代码在单个线程中运行,这简化了开发人员的很多工作,但在处理同时发生的多个操作时会带来挑战。在 JavaScript 运行时,后台运行一个无限循环,用于管理代码的消息和处理事件。事件循环负责消耗回调队列中的回调、运行堆栈中的函数和调用 Web API。JavaScript 中大多数操作可分为两种类型:阻塞和非阻塞。阻塞意味着阻塞事件循环(您可以将其视为其他语言的正常 UI 线程)。当事件循环被阻塞时,它无法处理来自应用程序其他部分的更多事件,应用程序将冻结直到解除阻塞。以下是示例操作及其分类的列表:
图 8.5:带有示例操作及其分类的表
从前面的列表中可以看到,几乎所有 JavaScript 中的 I/O 都是非阻塞的,这意味着即使完成时间比预期时间长,也不会阻塞事件循环。像任何语言一样,阻塞事件循环是一件糟糕的事情,因为它会使应用程序不稳定和无响应。这带来了一个问题:我们如何知道非阻塞操作是否已完成。
JavaScript 如何执行代码
当 JavaScript 执行阻塞代码时,它会阻塞循环并在程序继续执行之前完成操作。如果你运行一个迭代 100 万次的循环,你的其余代码必须等待该循环完成才能继续。因此,在你的代码中不建议有大量阻塞操作,因为它们会影响性能、稳定性和用户体验。当 JavaScript 执行非阻塞代码时,它通过将进程交给 Web API 来进行获取、超时和休息。一旦操作完成,回调将被推送到回调队列中,以便稍后被事件循环消耗。
在现代浏览器中,这是如何实现的,我们有一个堆来存储大部分对象分配,和一个用于函数调用的堆栈。在每个事件循环周期中,事件循环首先优先处理堆栈,并通过调用适当的 Web API 来执行这些事件。一旦操作完成,该操作的回调将被推送到回调队列中,稍后会被事件循环消耗:
图 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:超时完成后的输出
我们看到hi
在hi again
之前被打印出来,因为即使我们将超时设置为零,它仍然会最后执行,因为事件循环会在调用堆栈中的项目之前执行回调队列中的项目。
活动 11:使用回调接收结果
在这个活动中,我们将使用回调来接收结果。假设你正在为一家当地燃气公司担任软件工程师,并且他们希望你为他们编写一个新功能:
-
你有一个客户端 API 库,可以用来请求本地用户列表。
-
你需要实现一个功能,计算这些用户的账单,并以以下格式返回结果:
{
id: 'XXXXX',
address: '2323 sxsssssss',
due: 236.6
}
- 你需要实现一个
calculateBill
函数,它接受id
并计算该用户的燃气费用。
为了实现这一点,你需要请求用户列表并获取这些用户的费率和使用情况。最后,计算最终应付金额并返回合并结果。
注意
这个活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Activity11
找到。
执行以下步骤完成这个活动:
-
创建一个
calculate
函数,它接受id
和回调函数作为参数。 -
调用
getUsers
来获取所有用户,这将给我们需要的地址。 -
调用
getUsage
来获取我们用户的使用情况。 -
最后,调用
getRate
来获取我们正在为其计算的用户的费率。 -
使用现有 ID 调用
calculate
函数。 -
使用不存在的 ID 调用
calculate
函数以检查返回的错误。
您应该看到返回的错误如下:
图 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
关键字。构造函数接受一个包含异步操作代码的函数。它还将两个函数作为参数传递,resolve
和reject
。当异步操作完成并且值准备好被传递时,将调用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
找到。
执行以下步骤完成练习:
- 创建一个 promise:
const myPromise = new Promise(() => {
});
创建 promise 时,我们需要在Promise
构造函数中使用new
关键字。Promise
构造函数要求你提供一个解析器函数来执行异步操作。当创建 promise 时,它将自动调用解析器函数。
- 向解析器函数添加一个操作:
const myPromise = new Promise(() => {
console.log('hi');
});
输出应该如下所示:
图 8.9:向解析器函数添加一个操作
即使console.log
不是一个异步操作,当我们创建一个 promise 时,它将自动执行我们的解析器函数并打印出hi
。
- 使用
resolve
解决 promise:
const myPromise = new Promise((resolve) => {
resolve(12);
});
myPromise
当调用函数时,会将一个resolve
函数传递给我们的解析器函数。当它被调用时,promise 将被解决:
图 8.10:调用函数后解决的 promise
- 使用
then()
函数检索值。通过附加一个then
处理程序,你期望从回调中读取解析的 promise 值:
const myPromise = new Promise((resolve) => {
resolve(12);
}).then((value) => {
console.log(value);
});
输出应该如下所示:
图 8.11:使用 then 函数检索值
每当你创建一个 promise 时,你期望异步函数完成并返回一个值。
- 创建一个立即解决的 promise:
const myPromiseValue = Promise.resolve(12);
- 创建一个立即被拒绝的 promise:
const myRejectedPromise = Promise.reject(new Error('rejected'));
输出应该如下所示:
图 8.12:立即被拒绝的 promise 创建
就像Promise.resolve
一样,使用Promise.reject
创建 promise 将返回一个被提供的原因拒绝的 promise。
- 使用
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 中的错误
- 创建一个返回 promise 的
wait
函数:
function wait(seconds) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(seconds);
}, seconds * 1000);
})
}
- 使用
async
函数延迟我们的控制台日志:
wait(2).then((seconds) => {
console.log('i waited ' + seconds + ' seconds');
});
输出应该如下所示:
图 8.14:使用异步函数延迟控制台日志
如你所见,使用它非常简单。我们的wait
函数每次调用时都返回一个新的 promise。在操作完成后运行我们的代码,将其传递给then
处理程序。
- 使用
then
函数链式调用 promise:
wait(2)
.then(() => wait(2))
.then(() => {
console.log('i waited 4 seconds');
});
输出应该如下所示:
图 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:示例管道(一系列操作)
你会发现我们希望将值从一个过程传递到下一个过程。这有助于我们链式调用 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
找到。
执行以下步骤完成练习:
- 创建
getProfile
和getCart
函数,它们返回一个 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'));
}
});
}
- 创建另一个异步函数
getSubscription
,它接受一个 ID 并为该 ID 解析true
和false
值:
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 解析为单个字符串值。
- 创建
getFullRecord
,它返回id
的组合记录:
function getFullRecord(id) {
return {
id: '',
age: 0,
dob: '',
name: '',
cart: [],
subscription: true
};
}
在getFullRecord
函数中,我们希望调用所有前面的函数并将记录组合成前面代码中显示的返回值。
- 调用我们之前在
getFullRecord
中声明的函数,并返回getProfile
,getCart
和getSubscription
的组合结果:
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
中调用已声明的函数
但是我们的代码非常混乱,并且并没有真正利用我们之前提到的 promise 链式调用。为了解决这个问题,我们需要对getCart
和getSubscription
进行修改。
- 更新
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'));
}
});
}
- 更新
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'));
}
});
}
- 更新
getFullRecord
函数:
function getFullRecord(id) {
return getProfile(id)
.then(getCart)
.then(getSubscription);
}
现在,这比以前的所有嵌套要可读得多。我们只是通过对之前的两个函数进行最小的更改,大大减少了getFullRecord
。当我们再次调用此函数时,它应该产生完全相同的结果:
图 8.18:更新的 getFullRecord 函数
- 创建
getFullRecords
函数,我们将使用它来调用多个记录并将它们组合成一个数组:
function getFullRecords() {
// Return an array of all the combined user record in our system
return [
{
// Record 1
},
{
// Record 2
}
]
}
- 使用
array.map
生成 promise 列表:
function getFullRecords() {
const ids = ['P6HB0O', '2ADN23', '6FFQTU'];
const promises = ids.map(getFullRecord);
}
在这里,我们利用了array.map
函数来迭代数组并返回一个新数组。因为数组只包含 ID,所以我们可以简单地传递getFullRecord
函数。
- 使用
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 的结果数组。因为我们的目标是返回完整记录列表,这正是我们想要的。
- 测试
getFullRecords
:
getFullRecords().then(console.log);
输出应该如下所示:
图 8.19:测试 getFullRecords 函数
在这个练习中,我们使用了多个异步函数和它们的 promise 返回来实现复杂的逻辑。我们还尝试链式调用它们,并修改了一些函数以便于链式调用。最后,我们使用了array.map
和Promise.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 函数
该函数同时执行所有三个操作,并在它们解决时解决。让我们修改getFullRecords
函数以使其输出错误:
function getFullRecords() {
const ids = ['P6HB0O', '2ADN23', 'Not here'];
const promises = ids.map(getFullRecord);
return Promise.all(promises);
}
我们知道我们提供的第三个 ID 在我们的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
的文件并在那里进行工作。
执行以下步骤来实现练习:
- 我们将首先创建
calculate
函数。这次,我们只会传递id
:
function calculate(id) {}
- 在
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
处理程序中直接抛出错误。
- 在
getUsers
的then
处理程序中调用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 出现并被解决。
- 在
getUsage
的then
处理程序中调用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 的解决值。
- 创建一个
calculateAll
函数:
function calculateAll() {}
- 调用
getUsers
以获取我们用户的列表:
function calculateAll() {
return clientApi.getUsers().then((result) => {});
}
- 在这里,结果将是我们系统中用户的列表。然后,我们将在每个用户上运行
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 列表的结果列表。
- 在我们的一个用户上调用
calculate
:
calculate('DDW2AU').then(console.log)
输出应该如下:
图 8.22:在我们的一个用户上调用 calculate
- 调用
calculateAll
函数:
calculateAll().then(console.log)
输出应该如下:
图 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 的值
这种方法并不新鲜;我们仍然调用函数返回一个 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 函数
练习 65:异步和等待函数
在这个练习中,我们将学习创建 async 函数并在其他 async 函数中调用它们。在单个函数中处理大量的 async 操作时,使用 async 和 await 可以帮助我们。我们将一起编写我们的第一个async
函数,并探索在应用程序中处理 async 和 await 时需要牢记的一些事情。
注意
此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise65
找到。
执行以下步骤完成练习:
- 创建一个
getConcertList
函数:
function getConcertList() {
return Promise.resolve([
'Magical Mirai 2018',
'Magical Mirai 2019'
]);
}
- 调用函数并使用
await
:
const concerts = await getConcertList();
当我们运行上述代码时,我们将会得到如下错误:
图 8.26:使用 await 调用函数
我们会得到这个错误的原因是我们只能在async
函数内部使用await
关键字。如果我们想使用它,我们必须将语句包装在async
函数中。
- 修改语句并将其包装在
async
函数中:
async function printList() {
const concerts = await getConcertList();
console.log(concerts);
}
printList();
输出应该如下:
图 8.27:修改语句并将其包装在 async 函数中
当我们运行这个函数时,我们将看到列表被打印出来,一切都运行正常。我们也可以将async
函数视为返回 promise 的函数,因此如果我们想在操作结束后运行代码,我们可以使用then
处理程序。
- 使用
async
函数的then()
函数调用处理程序:
printList().then(() => {
console.log('I am going to both of them.')
});
输出应该如下:
图 8.28:使用 async 函数的 then 函数调用处理程序
现在,我们知道async
函数的行为就像返回 promise 的普通函数一样。
- 创建一个
getPrice
函数来获取音乐会的价格:
function getPrice(i) {
const prices = [9900, 9000];
return Promise.resolve(prices[i]);
}
- 修改
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 获取的价格
这意味着如果我们有一个返回 promise 的函数,我们不再需要使用then
处理程序。在async
函数中,我们可以简单地使用await
关键字来获取解析后的值。但是,在async
函数中处理错误的方式有点不同。
- 创建一个返回 rejected promise 的
buggyCode
函数:
function buggyCode() {
return Promise.reject(new Error('computer: dont feel like working today'));
}
- 在
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
因为buggyCode
抛出了一个错误,这会停止我们的函数执行,并且将来甚至可能终止我们的进程。为了处理这种类型的错误,我们需要捕获它。
- 在 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 处理程序
这是处理async
函数中的 promise rejection 的一种方法。还有一种更常见的方法。
- 使用
try
…catch
修改错误处理:
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 修改错误处理
使用try
…catch
是许多开发人员在处理可能抛出错误的函数时熟悉的。使用try
…catch
块来处理我们的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 的函数
如果我们想要同时运行两个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 运行两个等待函数
我们在这里做的是移除了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.map
和Promise.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 分配给我们声明的常量。这确保了operation2
在operation1
之后立即触发,并且没有等待。我们还需要注意的另一点是错误处理。考虑我们在上一个练习中使用的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 拒绝;它似乎没有出现,因为我们的try
…catch
什么也没做。这是因为没有await
关键字,JavaScript 不会尝试等待 promise 解析;因此,它不知道将来会抛出错误。这个try
…catch
块将捕获的是在执行函数时抛出的错误。这是我们在使用async
和await
编写代码时需要牢记的事情。在下一个练习中,我们将编写一个调用多个async
函数并能够从错误中恢复的复杂函数。
练习 66:复杂的异步实现
在这个练习中,我们将构建一个非常复杂的async
函数,并使用我们之前学到的一切来确保函数具有高性能并对错误具有弹性。
注意
此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson08/Exercise66
找到。
完成练习的步骤如下:
- 创建一个
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
。
- 创建一个
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
}
- 创建一个
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);
});
}
- 创建一个
playPlaylist
函数,该函数接受一个播放列表 ID,并在播放列表中的每首歌曲上调用playSong
:
async function playPlaylist(id) {
const playList = await getPlayLlist(id);
await Promise.all(playList.map(playSong));
}
这是一个简单的实现,没有进行错误处理。
- 运行
playPlaylist
函数:
playPlaylist('On the road').then(() => {
console.log('finished playing playlist');
});
输出应该如下:
图 8.36:运行 playPlaylist 函数
我们得到了一个非常有趣的输出;它同时播放所有歌曲。而且,它没有优雅地处理错误。
- 不带参数调用
playPlaylist
:
playPlaylist().then(() => {
console.log('finished playing playlist');
});
输出应该如下:
图 8.37:不带参数调用 playPlaylist
我们之所以出现这个错误是因为当getPlaylist
抛出错误时,我们没有处理错误。
- 修改
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
以处理错误
我们看到错误已经被正确处理,但是我们仍然在最后得到了finished
消息。这是我们不想要的,因为当发生错误时,我们不希望 promise 链继续。
- 修改
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 处理放在父级,并让错误冒泡。这样,我们可以为此操作只有一个错误处理程序,并能够一次处理多个错误。
- 尝试调用一个损坏的播放列表:
playPlaylist('Corrupted').then(() => {
console.log('finished playing playlist');
}).catch((error) => {
console.log(error);
});
](Images/C14587_08_35.jpg)
图 8.39:调用损坏的播放列表
这段代码运行良好,并且错误已经处理,但仍然一起播放。我们想要显示finished
消息,因为歌曲不存在
错误是一个小错误,我们想要抑制它。
- 修改
playPlaylist
以按顺序播放歌曲:
async function playPlaylist(id) {
const playList = await getPlaylist(id);
for (const songId of playList) {
await playSong(songId);
}
}
输出应如下所示:
图 8.40:修改playPlaylist
以按顺序播放歌曲
在修改中,我们删除了Promise.all
,并用for
循环替换了它,对每首歌曲使用await
。这确保我们在继续下一首歌曲之前等待每首歌曲完成。
- 修改
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
以抑制未找到的错误
我们在这里做的是用try
...catch
块包装我们的逻辑。这使我们能够抑制代码生成的任何错误。当getSongUrl
抛出错误时,它不会上升到父级;它将被catch
块捕获。
在这个练习中,我们使用async
和await
实现了一个播放列表播放器,并使用了我们对Promise.all
和async
并发的了解来优化我们的播放列表播放器,使其一次只播放一首歌曲。这使我们能够更深入地了解 async 和 await,并在将来实现我们自己的async
函数。在下一节中,我们将讨论如何将现有的基于 promise 或回调的代码迁移到 async 和 await。
活动 12:使用 Async 和 Await 重构账单计算器
您的公司再次更新了其 Node.js 运行时。在此活动中,我们将使用 async 和 await 重构之前创建的账单计算器:
-
您获得了使用承诺实现的
clientApi
。 -
您需要将
calculate()
更新为async
函数。 -
您需要将
calculateAll()
更新为async
函数。 -
calculateAll()
需要使用Promise.all
一次获取所有结果。
打开async.js
文件,使用async
和await
实现calculate
和calculateAll
函数。
注意
此活动的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson08/Activity12/Activity12.js
找到。
执行以下步骤完成活动:
-
创建一个
calculate
函数,以 ID 作为输入。 -
在
calculate
中,使用await
调用clientApi.getUsers()
来检索所有用户。 -
使用
array.find()
使用id
参数找到currentUser
。 -
使用
await
调用getUsage()
来获取该用户的使用情况。 -
使用
await
调用getRate
以获取用户的费率。 -
返回一个新对象,其中包括
id
、address
和总应付金额。 -
将
calculateAll
函数编写为async
函数。 -
使用
await
调用getUsers
以检索所有用户。 -
使用数组映射创建一个承诺列表,并使用
Promise.all
将它们包装起来。然后,在由Promise.all
返回的承诺上使用等待,并返回其值。 -
在一个用户上调用
calculate
。 -
调用
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
的引用,并改用reject
和resolve
:
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:传统编程方法
对于一个简单的应用程序,允许用户更新其个人资料并接收消息,我们可以看到我们有四个组件:
-
代理
-
个人资料
-
投票
-
消息
这些组件之间的交互方式是通过调用希望通信的组件中的适当方法来实现的。通过这样做,使得代码非常易于理解,但我们可能需要传递组件引用。以我们的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.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
以及如何使用它。
执行以下步骤完成这个练习:
- 导入
events
模块:
const EventEmitter = require('events');
我们将导入 Node.js 中内置的events
模块。它提供了一个构造函数,我们可以用它来创建自定义的事件发射器或创建一个继承自它的类。因为这是一个内置模块,所以不需要安装它。
- 创建一个新的
EventEmitter
:
const emitter = new EventEmitter();
- 尝试发出一个事件:
emitter.emit('my-event', { value: 'event value' });
- 附加一个事件监听器:
emitter.on('my-event', (value) => {
console.log(value);
});
要向我们的发射器添加事件监听器,我们需要在发射器上调用on
方法,传入事件名称和在发出事件时要调用的函数。当我们在发出事件后添加事件监听器时,我们会发现事件监听器没有被调用。原因是在我们之前发出事件时,并没有为该事件附加事件监听器,因此它没有被调用。
- 再发出一个事件:
emitter.emit('my-event', { value: 'another value' });
当我们这次发出事件时,我们会看到我们的事件监听器被正确调用,并且我们的事件值被正确打印出来,就像这样:
图 9.4:使用正确的事件值发出的事件
- 为
my-event
附加另一个事件监听器:
emitter.on('my-event', (value) => {
console.log('i am handling it again');
});
我们不仅限于每个事件只有一个监听器 - 我们可以附加尽可能多的事件监听器。当发射事件时,它将调用所有监听器。
- 发射另一个事件:
emitter.emit('my-event', { value: 'new value' });
以下是上述代码的输出:
图 9.5:多次发射事件后的输出
当我们再次发射事件时,我们将看到我们发射的第一个事件。我们还将看到它成功地打印出我们的消息。请注意,它保持了与我们附加监听器时相同的顺序。当我们发射错误时,发射器会遍历数组并依次调用每个监听器。
- 创建
handleEvent
函数:
function handleEvent(event) {
console.log('i am handling event type: ', event.type);
}
当我们设置我们的事件监听器时,我们使用了匿名函数。虽然这很容易和简单,但它并没有为我们提供EventEmitters
提供的所有功能:
- 将新的
handleEvent
附加到新类型的事件上:
emitter.on('event-with-type', handleEvent);
- 发射新的事件类型:
emitter.emit('event-with-type', { type: 'sync' });
以下是上述代码的输出:
图 9.6:发射新的事件类型
- 移除事件监听器:
emitter.removeListener('event-with-type', handleEvent);
因为我们使用了命名函数,所以我们可以使用这个函数引用来移除监听器,一旦我们不再需要将事件传递给该监听器。
- 在移除监听器后发射事件:
emitter.emit('event-with-type', { type: 'sync2' });
以下是上述代码的输出:
图 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 事件调用三次监听器
在这种情况下,因为我们有三个相同的监听器附加到我们的事件上,当我们调用removeListener
时,它只会移除我们的listener
数组中的第一个监听器。当我们再次发射相同的事件时,我们会看到它只运行两次:
图 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:使用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:使用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 的事件数量
有时我们想要知道已经附加到事件的事件监听器列表,以便我们可以确定某个处理程序是否已经附加,就像这样:
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 类获取附加到事件的监听器列表
在这里,我们可以看到我们已经附加到我们的发射器的两个监听器:一个是我们的匿名函数,另一个是我们的命名函数anotherHandler
。要检查我们的处理程序是否已经附加到发射器,我们可以检查event4Listeners
数组中是否有anotherHandler
:
event4Listeners.includes(anotherHandler);
以下是前面代码的输出:
图 9.16:检查处理程序是否附加到发射器
通过使用这个方法和数组包含一个方法,我们可以确定一个函数是否已经附加到我们的事件。
获取已注册监听器的事件列表
有时我们需要获取已注册监听器的事件列表。这可以用于确定我们是否已经为事件附加了监听器,或者查看事件名称是否已经被使用。继续前面的例子,我们可以通过调用EventEmitter
类中的另一个内部方法来获取这些信息:
emitter.eventNames();
以下是前面代码的输出:
图 9.17:使用 EventEmitter 类获取事件名称的信息
在这里,我们可以看到我们的事件发射器已经附加到四种不同的事件类型的监听器;即事件 1-4。
最大监听器
默认情况下,每个事件发射器只能为任何单个事件注册最多 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:在第一个事件之前附加第二个事件后发出事件
因为事件监听器是按照它们附加的顺序调用的,我们可以看到当我们发出事件时,handleEventSecond
首先被调用,然后是handleEventFirst
。如果我们希望handleEventFirst
在使用emitter.on()
附加它们的顺序不变的情况下首先被调用,我们可以调用prependListener
:
...
emitter.on('event', handleEventSecond);
emitter.prependListener('event', handleEventFirst);
emitter.emit('event');
前面的代码将产生以下输出:
图 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 calculation和I 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:定义火警类
现在,我们想在刚刚创建的火警上设置事件。我们可以通过创建一个通用事件发射器并将其存储在我们的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 代码时会抛出错误
这是因为我们使用了一个非常定制的类,具有自定义的构造函数,并访问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'事件的事件监听器被正确触发
在这里,我们可以看到我们将livingRoomAlarm
视为常规的EventEmitter
,当我们发出low-battery事件时,我们看到该事件的事件监听器被正确触发。在下一个练习中,我们将使用我们对EventEmitters
的所有了解制作一个非常简单的聊天室应用程序。
练习 68:构建聊天室应用程序
之前,我们讨论了如何在我们的事件发射器上附加事件监听器并发出事件。在这个练习中,我们将构建一个简单的聊天室管理软件,该软件使用事件进行通信。我们将创建多个组件,并查看如何使它们相互通信。
注意:
此练习的代码文件可以在github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson09/Exercise68
找到。
执行以下步骤完成此练习:
- 创建一个
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
方法,该方法将消息发送给房间中的所有人。当我们加入一个房间时,我们还会监听来自该房间的所有新消息事件,并在接收到消息时追加消息。
- 创建一个扩展
EventEmitter
类的Room
类:
class Room extends EventEmitter {
constructor(name) {
super();
this.name = name;
}
}
在这里,我们通过扩展现有的EventEmitter
类创建了一个新的Room
类。我们这样做的原因是我们希望在我们的room
对象上拥有自定义属性,并且这样可以增加代码的灵活性。
- 创建两个用户,
bob
和kevin
:
const bob = new User('Bob');
const kevin = new User('Kevin');
- 使用我们的
Room
类创建一个房间:
const lobby = new Room('Lobby');
- 将
bob
和kevin
加入lobby
:
bob.joinRoom(lobby);
kevin.joinRoom(lobby);
- 从
bob
发送几条消息:
bob.sendMessage('Lobby', 'Hi all');
bob.sendMessage('Lobby', 'I am new to this room.');
- 打印
bob
的消息日志:
bob.printMessages('Lobby');
以下是上述代码的输出:
图 9.24:打印 bob 的消息日志
在这里,您可以看到我们所有的消息都正确添加到了bob
的日志中。接下来,我们将检查kevin
的日志。
- 打印
kevin
的消息日志:
kevin.printMessage('Lobby');
以下是上述代码的输出:
图 9.25:打印 kevin 的消息日志
即使我们从未明确对kevin
做过任何事情,他也会收到所有消息,因为他正在监听房间中的新消息事件。
- 从
kevin
和bob
发送消息:
kevin.sendMessage('Lobby', 'Hi bob');
bob.sendMessage('Lobby', 'Hey kevin');
kevin.sendMessage('Lobby', 'Welcome!');
- 检查
kevin
的消息日志:
kevin.printMessages('Lobby');
以下是上述代码的输出:
图 9.26:检查 kevin 的消息日志
在这里,我们可以看到所有我们的消息都正确地添加到我们的user
对象中。因为我们使用事件发射器,所以我们避免了在我们的接收者周围传递引用。此外,因为我们在我们的房间上发出了消息事件,而我们的用户只是监听该事件,所以我们不需要手动遍历房间中的所有用户并传递消息。
- 让我们修改
joinRoom
和constructor
,以便稍后可以移除监听器:
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;
}
...
}
当我们移除监听器时,我们需要传递该监听器函数的引用,因此,我们需要将该引用存储在对象中,以便稍后可以使用它来移除我们的监听器。
- 添加
leaveRoom
:
class User {
...
leaveRoom(roomName) {
this.rooms[roomName].removeListener('newMessage', this.messageListener);
delete this.rooms[roomName];
}
}
在这里,我们正在使用我们在构造函数中设置的函数引用,并将其传递给我们房间的removeListener
。我们还从对象中移除了引用,以便稍后可以释放内存。
- 从
room
中移除bob
:
bob.leaveRoom('Lobby');
- 从
kevin
发送一条消息:
kevin.sendMessage('Lobby', 'I got a good news for you guys');
- 检查
bob
的消息列表:
bob.printMessages('Lobby');
以下是上述代码的输出:
图 9.27:检查鲍勃的消息列表
因为bob
离开了房间,并且我们移除了消息监听器,所以当发出新消息事件时,newMessage
事件处理程序不会再被调用。
- 检查
kevin
的消息列表:
kevin.printMessages('Lobby');
以下是上述代码的输出:
图 9.28:再次检查 kevin 的消息列表
当我们检查kevin
的消息列表时,我们应该仍然能够看到他仍然从房间中收到新消息。如果使用传统方法来完成这项工作,我们将需要编写更多的代码来完成相同的事情,这将非常容易出错。
在这个练习中,我们使用 Node.js 构建了一个带有事件的模拟聊天应用程序。我们可以看到在 Node.js 中传递事件是多么容易,以及我们如何正确使用它。事件驱动编程并不适用于每个应用程序,但是当我们需要将多个组件连接在一起时,使用事件来实现这种逻辑要容易得多。上述代码仍然可以改进-我们可以在用户离开房间时向房间添加通知,并且我们可以在添加和移除房间时添加检查,以确保我们不会添加重复的房间,并确保我们只移除我们所在的房间。请随意自行扩展此功能。
在本章中,我们讨论了如何使用事件来管理应用程序中组件之间的通信。在下一个活动中,我们将构建一个基于事件驱动的模块。
活动 13:构建一个基于事件驱动的模块
假设您正在为一家构建烟雾探测器模拟器的软件公司工作。您需要构建一个烟雾探测器模拟器,当探测器的电池电量低于一定水平时会引发警报。以下是要求:
-
探测器需要发出
警报事件
。 -
当电池低于 0.5 单位时,烟雾探测器需要发出低电量事件。
-
每个烟雾探测器在初始创建时都有 10 个单位的电池电量。
-
烟雾探测器上的测试函数如果电池电量高于 0 则返回 true,如果低于 0 则返回 false。每次运行测试函数时,电池电量将减少 0.1 个单位。
-
您需要修改提供的
House
类以添加addDetector
和demoveDetector
方法。 -
addDetector
将接受一个探测器对象,并在打印出低电量和警报事件之前,为警报事件附加一个监听器。 -
removeDetector
方法将接受一个探测器对象并移除监听器。
完成此活动,执行以下步骤:
-
打开
event.js
文件并找到现有的代码。然后,修改并添加你自己的更改。 -
导入
events
模块。 -
创建
SmokeDetector
类,该类扩展EventEmitter
并将batteryLevel
设置为10
。 -
在
SmokeDetector
类内创建一个test
方法来发出低电量消息。 -
创建
House
类,它将存储我们警报的实例。 -
在
House
类中创建一个addDetector
方法,它将附加事件监听器。 -
创建一个
removeDetector
方法,它将帮助我们移除之前附加的警报事件监听器。 -
创建一个名为
myHouse
的House
实例。 -
创建一个名为
detector
的SmokeDetector
实例。 -
将探测器添加到
myHouse
中。 -
创建一个循环来调用测试函数 96 次。
-
在
detector
对象上发出警报。 -
从
myHouse
对象中移除探测器。 -
在探测器上测试发出警报。
注意
此活动的解决方案可以在第 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
来安装它。在这个模块中,我们将讨论如何使用fs
、path
和util
模块。
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 函数组合不同的路径
它还可以处理..
和.
,如果你熟悉 POSIX 系统如何表示当前目录和父目录。..
表示父目录,而.
表示当前目录。例如,以下代码可以给出我们当前目录的父目录的路径:
const parentOfProject = path.join(currentDir, '..');
以下是前面代码的输出:
图 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 函数解析的文件路径
在这里,我们可以看到我们的文件路径包括一个文件名,基本名称为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 将信息组合成单个字符串路径
这给我们提供了从我们提供给它的组件中生成的文件路径。
fs
fs模块是一个内置模块,为您提供 API,以便您可以与主机文件系统进行交互。当我们需要在应用程序中处理文件时,它非常有用。在本节中,我们将讨论如何在我们的应用程序中使用fs模块与async
和await
。稍后,我们将介绍最近添加的fs.promises
API,它提供相同的功能,但返回一个 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 方法后的输出
fs.readFile(path, options, callback)
这是大多数人熟悉的函数。当提供文件路径时,该方法将尝试以异步方式读取文件的整个内容。它将以异步方式执行,并且回调将被调用以获取文件的整个内容。当文件不存在时,回调将被调用以获取错误。
考虑以下例子:
const fs = require('fs');
fs.readFile('index.js', (error, data) => {
console.log(data);
});
这将给我们以下输出:
图 9.34:使用 fs.readFile 函数读取文件的整个内容
这没有输出我们想要的结果。这是因为我们没有在选项中提供编码;要将内容读入字符串,我们需要提供编码选项。这将改变我们的代码为以下内容:
fs.readFile('index.js', 'utf-8', (error, data) => {
console.log(data);
});
现在,当我们运行上述代码时,它会给我们以下输出:
图 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
,这样我们就可以用async
和await
来使用它们。
执行以下步骤完成这个练习:
- 创建一个名为
test.txt
的新文件:
fs.writeFile('test.txt', 'Hello world', (error) => {
if (error) {
console.error(error);
return;
}
console.log('Write complete');
});
如果你做对了,你会看到以下输出:
图 9.36:创建新的 test.txt 文件
你应该能够在与源代码相同的目录中看到新文件:
图 9.37:在与源代码相同的目录中创建新文件
- 读取其内容并在控制台中输出:
fs.readFile('test.txt', 'utf-8', (error, data) => {
if (error) {
console.error(error);
}
console.log(data);
});
这只是简单地读取我们的文件;我们提供了一个编码,因为我们希望输出是一个字符串而不是一个缓冲区。这将给我们以下输出:
图 9.38:使用 fs.readFile 读取文件内容
- 尝试读取一个不存在的文件:
fs.readFile('nofile.txt', 'utf-8', (error, data) => {
if (error) {
console.error(error);
}
console.log(data);
});
当我们尝试打开一个不存在的文件时,我们的回调将会被调用并出现错误。建议我们在处理任何与文件相关的错误时,应该在处理程序内部处理,而不是创建一个单独的函数来检查它。当我们运行上述代码时,将会得到以下错误:
图 9.39:尝试读取不存在的文件时抛出的错误
- 让我们创建自己的带有 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
- 让我们使用文件
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 的对应项
在这里,你可以获取有关我们文件的大小、创建时间、修改时间和权限信息。
在这个练习中,我们介绍了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 函数用作基于回调的函数
要将此async
函数与回调一起使用,只需调用callbackify
:
const callbackOutputSomething = util.callbackify(outputSomething);
然后,我们可以这样使用它:
callbackOutputSomething((err, result) => {
if (err) throw err;
console.log('got result', result);
})
这将生成以下输出:
图 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 函数进行回调
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 函数设置重复执行的函数
在这里,我们可以看到消息每秒打印一次。
setTimeout(callback, delay)
使用此函数,我们可以设置一次性延迟调用函数。当我们想要在运行函数之前等待一定的时间时,我们可以使用setTimeout
来实现这一点。在前面的部分中,我们还使用setTimeout
来模拟测试中的网络和磁盘请求。要使用它,我们需要传递我们想要运行的函数和以毫秒为单位的延迟整数。如果我们想在 3 秒后打印一条消息,我们可以使用以下代码:
setTimeout(() => {
console.log('I waited 3 seconds to run');
}, 3000);
这将生成以下输出:
图 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 推送到事件循环结束时执行的函数
我们也可以通过使用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 函数停止间隔运行
如果我们想要运行这个间隔 3 秒,我们可以将clearInterval
包装在setTimeout
内,这样它将在3.1
秒后清除我们的间隔。我们额外给了 100 毫秒,因为我们希望第三次调用发生在我们清除间隔之前:
setTimeout(() => {
clearInterval(myInterval);
}, 3100);
当我们运行上述代码时,我们将看到我们的输出打印出 3 次:
图 9.49:使用 setTimeout 在指定的秒数内包装 clearInterval
当我们处理多个预定计时器时,这非常有用。通过清除它们,我们可以避免内存泄漏和应用程序中的意外问题。
活动 14:构建文件监视器
在这个活动中,我们将使用定时器函数创建一个文件监视器,该监视器将指示文件中的任何修改。这些定时器函数将在文件上设置监视,并在文件发生更改时生成输出。让我们开始吧:
-
我们需要创建一个
fileWatcher
类。 -
将创建一个带有要监视的文件的文件监视器。如果没有文件存在,它将抛出异常。
-
文件监视器将需要另一个参数来存储检查之间的时间。
-
文件监视器需要允许我们移除对文件的监视。
-
文件监视器需要在文件更改时发出文件更改事件。
-
当文件更改时,文件监视器将发出带有文件新内容的事件。
打开filewatcher.js
文件,并在该文件中进行您的工作。执行以下步骤以完成此活动:
-
导入我们的库;即
fs
和events
。 -
创建一个文件监视器类,该类扩展了
EventEmitter
类。使用modify
时间戳来跟踪文件更改。 -
创建
startWatch
方法以开始监视文件的更改。 -
创建
stopWatch
方法以停止监视文件的更改。 -
在与
filewatch.js
相同的目录中创建一个test.txt
文件。 -
创建一个
FileWatcher
实例并开始监视文件。 -
修改
test.txt
中的一些内容并保存。 -
修改
startWatch
以便还检索新内容。 -
修改
startWatch
,使其在文件被修改时发出事件,并在遇到错误时发出错误。 -
在
fileWatcher
中附加事件处理程序以处理错误和更改。 -
运行代码并修改
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 元素上发生事件时执行函数;例如,我们可以监听scroll
、click
或键盘事件。以下示例将在滚动时打印Scrolling…
。它应该在浏览器 JavaScript 环境中运行:
document.addEventListener('scroll', () => {
console.log('Scrolling...');
});
Node.js 使用错误优先回调来处理其暴露的任何 I/O API。下面的例子展示了如何处理来自 Node.js 文件系统模块 fs
的错误。传递的回调始终具有一个错误属性作为其第一个参数。当没有错误时,此错误为 null
或 undefined
,如果发生错误,则具有一个 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
函数调用传递的函数来对数组进行排序。传递的函数会用数组的两个元素(我们将称之为 a
和 b
)来调用。如果它返回大于 0 的值,a
将出现在排序后的数组中的 b
之前。如果比较函数返回小于 0 的值,b
将出现在排序后的数组中的 a
之前。如果比较函数返回等于 0 的值,a
和 b
将按照原始数组中的顺序出现,即相对于彼此:
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#some
、Array#findIndex
和 Array#reduce
来重新实现 Array#includes
、Array#indexOf
和 Array#join
,利用一级函数支持。它们是原始方法的更强大版本。
npm run Exercise70
的最终输出应该让所有断言都通过。这意味着我们现在有了符合以下断言的 includes
、indexOf
和 join
函数:
-
如果值在数组中,
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
。
执行以下步骤完成这个练习:
- 将当前目录更改为
Lesson10
。这样我们就可以使用预先映射的命令来运行我们的代码。现在,运行npm run Exercise70
命令(或node exercise-re-implement-array-methods-start.js
):
注意
npm 脚本是在 package.json
的 scripts
部分定义的。可以使用 npm run
Exercise70.js
运行这个练习的工作解决方案。文件在 GitHub 上。
图 10.1:运行 npm run Exercise70 的初始输出
这些错误表明提供的测试当前失败,因为实现不符合预期(因为它们目前什么也不做)。
- 在
exercise-re-implement-array-methods-start.js
中实现includes
:
function includes(array, needle) {
return array.some(el => el === needle);
}
有一个我们将替换的 includes
骨架。我们可以用来实现 includes 的函数是 .some
。我们将检查数组的任何/一些元素是否等于 needle
参数。
- 运行
npm run Exercise70
。这应该给出以下输出,这意味着includes
按照我们的两个断言正常工作(includes
的断言错误已经消失):
图 10.2:实现 includes 后的输出
needle
是一个原始类型,所以如果我们需要比较某些东西,el === needle
就足够了。
- 使用
.findIndex
来实现indexOf
:
function indexOf(array, needle) {
return array.findIndex(el => el === needle);
}
在这一步之后,运行 npm run Exercise70
应该给出以下输出,这意味着 indexOf
按照我们的两个断言正常工作(indexOf
的断言错误已经消失):
图 10.3:实现包含和 indexOf 后的输出
最后,我们将使用.reduce
来实现join
。这个函数更难实现,因为reduce
是一个非常通用的遍历/累加运算符。
- 首先,将累加器与当前元素连接起来:
function join(array, delimiter = '') {
return array.reduce((acc, curr) => acc + curr);
}
- 运行
npm run Exercise70
。您将看到“不应传递分隔符”现在通过了:
图 10.4:实现包含、indexOf 和天真的连接
- 除了将累加器与当前元素连接起来,还要在它们之间添加分隔符:
function join(array, delimiter = '') {
return array.reduce((acc, curr) => acc + delimiter + curr);
}
以下是前面代码的输出:
图 10.5:运行练习后 npm 的最终输出
这个练习展示了支持将另一个函数传递给它们的函数比仅接收原始参数的函数更强大。我们通过使用函数参数的对应项重新实现了原始参数函数来证明这一点。
在下一个练习中,我们将向您展示另一个 JavaScript 用例,用于支持函数参数的数组函数。
练习 71:使用 Map 和 Reduce 计算购物篮的价格
在这个练习中,您将使用数组的map
、filter
和reduce
函数来完成从线项目列表到购物篮总成本的简单转换。
注意
在这个练习中,您将在起始文件exercise-price-of-basket-start.js
中有测试和方法的框架。可以使用node exercise-price-of-basket-start.js
运行该文件。这个命令已经被别名为 npm 脚本npm run Exercise71
。可以在 GitHub 上使用npm run Exercise71
文件运行这个练习的工作解决方案。
- 将当前目录更改为
Lesson10
。这样我们就可以使用预先映射的命令来运行我们的代码。运行npm run Exercise71
(或node exercise-price-of-basket-start.js
)。您将看到以下内容:
图 10.6:npm 运行的初始输出
失败的断言表明,我们的框架实现没有输出它应该输出的内容,因为basket1
的内容应该合计为5197
,basket2
的内容应该合计为897
。我们可以手动运行这个计算:1 * 199 + 2 * 2499 是 5197,2 * 199 + 1 * 499 是 897。
- 首先,获取行项目价格,这是通过在
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 中计算行项目
请注意,断言仍然失败,因为我们没有将行项目价格相加;我们只是返回了一个行项目价格的数组。
- 接下来,使用
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 的最终输出
添加reduce
步骤对我们用初始map
计算的行项目价格进行求和。现在totalBasket
返回了basket1
和basket2
的正确总价格,分别为5197
和897
。因此,以下断言现在为真:
-
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.js
和activity-on-checkout-prop-start.html
。可以使用npm run Activity15
运行开发服务器。此活动的工作解决方案可以在 GitHub 上使用 npm run Activity15
文件运行。
-
如果您之前没有在此目录中执行过,将当前目录更改为
Lesson10
并运行npm install
。npm install
会下载运行此活动所需的依赖项(React 和 Parcel)。此命令是npx parcel serve activity-on-checkout-prop-start.html
的别名。 -
访问
http://localhost:1234
(或者启动脚本输出的任何 URL)以查看 HTML 页面。 -
单击继续结账按钮。您会注意到什么都没有发生。
注意
此活动的解决方案可以在第 625 页找到。
下一个练习将向您展示如何利用状态和属性将产品添加到我们的购物篮中。这个练习的起始代码并不严格与我们在活动结束后完成的代码相同。例如,状态是从 Basket 组件提升到了App
组件。
练习 72:向购物篮添加产品
在这个练习中,我们将修改addProduct
方法,以在单击添加到购物篮
选项时更新购物篮中商品的数量。
注意
练习 72 配备了一个预配置的开发服务器和起始文件中方法的框架,即exercise-add-product-start.js
和exercise-add-product-start.html
。可以使用npm run Exercise72
运行开发服务器。此命令是npx parcel serve exercise-add-product-start.html
的别名。可以在 GitHub 上使用npm run Exercise72
文件运行此练习的工作解决方案。
- 将当前目录更改为
Lesson10
。如果您以前没有在此目录中这样做,请运行npm install
。现在运行npm run Exercise 72
。您将看到应用程序启动,如下所示:
图 10.9:运行 npm run Exercise 72 的输出
为了使开发服务器实时重新加载我们的更改并避免配置问题,请直接编辑exercise-add-product-start.js
文件。
- 转到
http://localhost:1234
(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.10:浏览器中的初始应用程序
单击“添加到篮子”时,应用程序崩溃并显示空白 HTML 页面。
- 更新“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。
- 要找出单击“添加到篮子”时会发生什么,我们需要找到“添加到篮子”按钮的
onClick
处理程序,然后诊断this.addProduct()
调用的问题(篮子被设置为{}
):
<button onClick={() => this.addProduct(this.state.product)}>
Add to Basket
</button>
当我们单击“添加到篮子”按钮时,我们将看到以下内容:
图 10.11:单击一次后实现添加到篮子
当我们再次单击“添加到篮子”时,我们将看到以下内容:
图 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.js
和exercise-render-prop-start.html
。可以使用npm run Exercise73
运行开发服务器。此命令是npx parcel serve exercise-render-prop-start.html
的别名。可以在 GitHub 上使用npm run Exercise73
文件运行此练习的工作解决方案。
执行以下步骤以完成此练习:
- 如果您以前没有在此目录中这样做,请将当前目录更改为
Lesson10
并运行npm install
。npm install
下载所需的依赖项,以便运行此活动(React 和 Parcel)。现在,运行npm run Exercise73
。您将看到应用程序启动,如下所示:
图 10.13:运行启动文件后的输出
为了使开发服务器实时重新加载我们的更改并避免配置问题,直接编辑exercise-render-prop-start.js
文件。
- 转到
http://localhost:1234
(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.14:浏览器中的初始应用程序
- 找到
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}
/>
)}
- 转到
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:渲染篮子项目
我们的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
的只读接口暴露出来。要更新状态,需要分派一个动作。调用dispatch
与INCREMENT
和DECREMENT
类型表明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
接受状态和动作,并返回一个新值,而不会改变state
或action
。
遵循这些规则,我们可以获得一个可预测且高性能的 JavaScript 应用程序状态容器。单一存储意味着不需要考虑状态存储在哪里;只读状态强制通过分派动作和减少它们来进行更新。由于减速器是纯函数,它们易于测试和推理,因为对于相同的输入它们将产生相同的输出,并且不会引起副作用或不需要的突变。
Redux 用于管理状态。到目前为止,我们一直将数据存储在 React 状态中。
练习 74:Redux 分派动作并将其减少为状态
在这个练习中,我们将把我们的数据状态移到 Redux 中,以便将数据操作和状态更新与呈现数据到页面的代码分离开来。
注意
练习 74 配备了一个预配置的开发服务器和起始文件中方法的骨架,即exercise-redux-dispatch-start.js
和exercise-redux-dispatch-start.html
。可以使用npm run Exercise74
运行开发服务器。可以在 GitHub 上使用npm run Exercise74
文件运行此练习的工作解决方案。
执行以下步骤完成此练习:
- 如果您以前没有在此目录中执行过此操作,请将当前目录更改为
Lesson10
并运行npm install
。此命令是npx parcel serve exercise-redux-dispatch-start.html
的别名。现在,运行npm run Exercise74
。您将看到应用程序启动,如下所示:
图 10.16:npm run Exercise74 的输出
- 转到
http://localhost:1234
(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.17:浏览器中的初始 Exercise74 应用程序
注意点击按钮没有起作用。
- 通过分派
CONTINUE_SHOPPING
类型的动作来实现App#continueShopping
:
continueShopping() {
this.props.dispatch({
type: 'CONTINUE_SHOPPING'
});
}
- 在
appReducer
中,实现相应的状态减少。对于CONTINUE_SHOPPING
,我们只需要更改状态中的status
,因为这是我们用来显示结账视图或主产品和购物篮视图的内容:
switch(action.type) {
// other cases
case 'CONTINUE_SHOPPING':
return {
...state,
status: 'SHOPPING'
};
// other cases
}
- 通过分派
DONE
类型的动作来实现App#finish
:
finish() {
this.props.dispatch({
type: 'DONE'
});
}
- 在
appReducer
中,实现相应的状态减少。我们只需要更改状态中的status
,因为这是我们用来显示Done
视图的内容:
switch(action.type) {
// other cases
case 'DONE':
return {
...state,
status: 'DONE'
};
// other cases
}
- 通过分派
START_CHECKOUT
类型的动作来实现handleCheckout
:
handleCheckout(items) {
this.props.dispatch({
type: 'START_CHECKOUT',
basket: {
items
}
});
}
- 在
appReducer
中,实现相应的状态减少。对于START_CHECKOUT
,我们只需要更改状态中的status
,因为这是我们用来显示结账视图或主产品和购物篮视图的内容:
switch(action.type) {
// other cases
case 'START_CHECKOUT':
return {
...state,
status: 'CHECKING_OUT'
};
// other cases
}
注意
basket
对象没有被减少,因此可以在分派时省略。
- 通过以下方式分派一个动作来实现
addProduct
。对于ADD_PRODUCT
,我们需要newProduct
,以及动作类型:
addProduct(product) {
this.props.dispatch({
type: 'ADD_PRODUCT',
newProduct: {
name: product.name,
price: product.price,
quantity: 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;
}
};
- 转到
http://localhost:1234
(或者启动脚本输出的任何 URL)。应用现在应该如预期般响应点击:
图 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:测试减速器
在这个练习中,我们将为前一个练习中使用的减速器的一部分编写测试,即appReducer
的ADD_PRODUCT
情况。
注意
练习 75 带有测试和起始文件中方法的框架,exercise-reducer-test-start.js
。可以使用node exercise-reducer-test-start.js
运行文件。这个命令已经被别名为 npm 脚本的npm run Exercise75
。这个练习的工作解决方案可以在 GitHub 上使用 npm run exercise6 文件运行。
执行以下步骤完成这个练习:
-
将当前目录更改为
Lesson10
。这样我们可以使用预映射的命令来运行我们的代码。 -
现在,运行
npm run Exercise75
(或node exercise-reducer-test-start.js
)。您将看到以下输出:
图 10.19:运行启动文件后空测试通过
这个起始文件中只包含ADD_PRODUCT
动作减少的简化的appReducer
,还有一个test
函数,新的测试将会被添加到这里。输出中没有包含错误,因为我们还没有创建任何测试。
注意
为了获得appReducer
的输出,它应该被调用与一个state
对象和相关的action
。在这种情况下,类型应该是'ADD_PRODUCT'
。
- 与之前的示例一样,我们将使用
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:执行启动文件后显示错误
- 我们应该使用
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:测试通过,因为没有发现错误
在运行node exercise-reducer-test.js
命令后,以下是输出:
图 10.22:显示断言失败的输出
Redux 选择器
选择器是 Redux 的另一个概念,这意味着我们可以使用选择器封装内部存储状态形状。选择器的使用者要求它想要的东西;选择器则留给使用存储状态形状特定知识来实现。选择器是纯函数;它们接受存储状态并返回一个或多个部分。
由于选择器是纯函数,它们很容易实现。下面的练习向我们展示了如何使用选择器,以便不是将消息数据放在渲染函数中或在传递 props 时,而是在一个纯函数中进行。
练习 76:实现一个选择器
在这个练习中,我们将使用选择器并利用它们的简单性来将项目呈现到购物篮中。
注意
练习 76 带有预配置的开发服务器和起始文件中方法的框架,即exercise-items-selector-start.js
和exercise-items-selector-start.html
。可以使用npm run Exercise76
运行开发服务器。可以在 GitHub 上使用npm run Exercise76
文件运行此练习的工作解决方案。
-
将当前目录更改为
Lesson10
,如果之前在此目录中尚未运行npm install
,则运行它。 -
运行
npx parcel serve exercise-items-selector-start.html
并执行npm run Exercise76
。您将看到应用程序启动,如下所示:
图 10.23:运行起始 html 文件后的输出
为了使开发服务器能够实时重新加载我们的更改并避免配置问题,直接编辑exercise-items-selector-start.js
文件。
- 转到
http://localhost:1234
(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.24:浏览器中的初始应用程序
注意没有购物篮项目被呈现。这是因为selectBasketItems
的初始实现。它返回一个空数组:
const selectBasketItems = state => [];
- 通过使用点符号和短路来实现
selectBasketItems
。如果状态有任何问题,则默认为[]
:
const selectBasketItems = state =>
(state && state.basket && state.basket.items) || [];
应用程序现在应该再次按预期工作;项目将被显示:
图 10.25:实现 selectBasketItems 后的应用程序
selectBasketItems
选择器获取完整状态并返回其切片(项目)。选择器允许我们进一步将 Redux 存储库内部状态的形状与在 React 组件中使用它的方式分离。
选择器是 React/Redux 应用程序的重要组成部分。正如我们所见,它们允许 React 组件与 Redux 的内部状态形状解耦。以下活动旨在使我们能够为选择器编写测试。这与在先前的练习中测试 reducer 的情况类似。
Activity 16:测试一个选择器
在这个活动中,我们将测试项目数组的各种状态的选择器,并确保选择器返回与购物篮中的项目对应的数组。让我们开始吧:
- 将当前目录更改为
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
,执行以下操作:
-
测试一下,对于空状态,选择器返回
[]
。 -
测试一下,对于一个空的购物篮对象,选择器返回
[]
。 -
测试一下,如果
items
数组已设置但为空,则选择器返回[]
。 -
测试一下,如果项目数组不为空并已设置,则选择器返回它。
注意
此活动的解决方案可以在第 626 页找到。
纯函数是可预测的,易于测试和易于理解的。一等函数和纯函数都与下一个 JavaScript 函数式编程概念相关联:高阶函数。
高阶函数
高阶函数是一个要么接受函数作为参数,要么返回函数作为值的函数。
这是建立在 JavaScript 的一级函数支持之上的。在不支持一级函数的语言中,实现高阶函数是困难的。
高阶函数实现了函数组合模式。在大多数情况下,我们使用高阶函数来增强现有的函数。
绑定、应用和调用
Function
对象上有一些内置的 JavaScript 方法:bind
、apply
和call
。
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#apply
和Function#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#apply
和Function#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#apply
和Function#call
在历史上将类似数组的对象转换为数组。在符合 ECMAScript 2015+的环境中,可以使用spread
操作符以类似的方式使用。
以下三个函数允许你使用Function#apply
、Function#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)。
以下使用我们已经定义的函数add1
,add2
和double
,并展示了如何使用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 文件来运行这个练习的工作解决方案。
-
将当前目录更改为
Lesson10
。这样我们可以使用预先映射的命令来运行我们的代码。 -
现在,运行
npm run Exercise77
(或node exercise-to-n-compose-start.js
)。您将看到以下输出:
图 10.26:运行练习的起始文件
compose3
,composeManyUnary
和composeManyReduce
的断言都失败了,主要是因为它们当前被别名为compose2
。
- 已经实现了两个函数的
compose
:
const compose2 = (f, g) => x => f(g(x));
compose3
是一个天真的三参数compose
函数,它先取第三个参数,然后在第一次调用的输出上调用第二个参数。
- 最后,它调用第一个参数在第二个参数的输出上,就像这样:
const compose3 = (f, g, h) => x => f(g(h(x)))
注意
参数定义中最右边的函数首先被调用。
考虑参数作为一个数组,并且 JavaScript 有一个reduceRight
函数(它从右到左遍历数组,同时保持一个累加器,就像reduce
一样),有一个形成的前进路径。
- 在实现
compose3
之后,我们可以再次运行npm run Exercise77
,看到compose3
的断言不再失败了:
图 10.27:实现 compose3 后的输出
- 使用参数 rest 来允许任意数量的函数进行组合:
const composeManyUnary = (...fns) => x =>
fns.reduceRight((acc, curr) => curr(acc), x);
- 在实现
composeManyUnary
之后,相应的失败断言现在通过了:
图 10.28:实现 compose3 和 composeManyUnary 后的输出
- 定义
compose
使用从左到右的遍历(使用reduce
):
const composeManyReduce = (...fns) =>
fns.reduce((acc, curr) => (...args) => acc(curr(...args)));
我们可以使用三个函数f
,g
和h
来composeManyReduce
。我们的实现将通过这些函数开始减少。在第一次迭代时,它将返回一个函数,该函数将接受任意数量的参数(args
)。当调用时,它将调用f(g(args))
。在第二次迭代中,它将返回一个接受任意数量参数并返回f(g(h(args))
的函数。在这一点上,没有更多的函数可以迭代,因此接受一组参数并返回f(g(h(arguments)))
的函数的最终输出是composeManyReduce
函数的输出。
在实现了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.time
和console.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 文件运行此练习的工作解决方案。
执行以下步骤完成此练习:
-
将当前目录更改为
Lesson10
,如果之前没有在此目录中这样做,请运行npm install
。 -
首先运行
node exercise-micro-compose-start.js
命令。然后运行npm run Exercise78
。您将看到应用程序启动,如下所示:
图 10.30:运行此练习的 start 文件
- 使用以下
curl
访问应用程序应该会产生未经授权的响应:
curl http://localhost:3000
以下是前面代码的输出:
图 10.31:微应用的 cURL
请注意,compose 函数在此模块中预先填充。
- 我们将使用 compose 而不是在上一个函数的输出上调用每个函数,并调用其输出来创建服务器。这将替换服务器创建步骤:
const server = compose(
micro,
timer,
authenticate,
handler
)();
最初的服务器创建步骤如下,这相当冗长,可能难以阅读。compose
版本清楚地显示了请求将经过的管道:
const server = micro(timer(authenticate(handler)));
- 重新启动应用程序以使更改生效。一旦
npm run Exercise78
运行起来,您应该能够curl
:
curl http://localhost:3000
以下是前面代码的输出:
图 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.js
和exercise-refactor-action-creators-start.html
。可以使用npm run Exercise79
来运行开发服务器。可以在 GitHub 上使用npm run exercise10
文件来运行这个练习的工作解决方案。
在这个练习中,您将从使用内联动作定义转为使用动作创建器。
执行以下步骤完成此练习:
-
将当前目录更改为
Lesson10
并运行npm install
,如果您之前没有在此目录中执行过。npm install
会下载运行此活动所需的依赖项(React、Redux、react-redux 和 Parcel)。 -
首先运行
npx parcel serve exercise-refactor-action-creators-start.html
。要在开发过程中查看应用程序,请运行npm run Exercise79
。您将看到应用程序正在启动,如下所示:
图 10.33:运行此练习的起始文件
为了使开发服务器实时重新加载我们的更改并避免配置问题,请直接编辑exercise-refactor-action-creators-start.js
文件。
- 转到
http://localhost:1234
(或者起始脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.34:浏览器中的初始应用
- 实现
startCheckout
、continueShopping
、done
和addProduct
动作创建器:
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_CHECKOUT
、CONTINUE_SHOPPING
、DONE
和ADD_PRODUCT
。
- 更新
handleCheckout
以使用相应的startCheckout
动作创建器:
handleCheckout(items) {
this.props.dispatch(startCheckout(items));
}
- 更新
continueShopping
以使用相应的continueShopping
动作创建器:
continueShopping() {
this.props.dispatch(continueShopping());
}
- 更新
finish
以使用相应的done
动作创建器:
finish() {
this.props.dispatch(done());
}
- 更新
addProduct
以使用相应的addProduct
动作创建器:
addProduct(product) {
this.props.dispatch(addProduct(product));
}
- 检查应用程序是否仍然按预期运行:
图 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 存储的内部形状。
执行以下步骤完成此练习:
-
如果您之前没有在此目录中执行过
npm install
,请将当前目录更改为Lesson10
并运行npm install
。 -
首先,运行
npx parcel serve exercise-map-to-props-start.html
。 然后,在开发过程中,运行npm run Exercise80
。 您将看到应用程序启动,如下所示:
注意
Exercise 80 带有预配置的开发服务器和起始文件中方法的框架,即exercise-map-to-props-start.js
和exercise-map-to-props-start.html
。 可以使用npm run Exercise80
运行开发服务器。 可以在 GitHub 上使用 npm run Exercise80 文件运行此练习的工作解决方案。
图 10.36:npm run Exercise80 的输出
- 转到
http://localhost:1234
(或起始脚本输出的任何 URL)。 您应该看到一个空白的 HTML 页面。 这是因为mapStateToProps
返回了一个空状态对象。
注意
审核解释了 App 组件使用的状态片段(来自存储)以及产品,项目和状态是正在使用的状态片段。
- 为
status
创建一个新的选择器:
const selectStatus = state => state && state.status;
- 为
product
创建一个新的选择器:
const selectProduct = state => state && state.product;
- 在
mapStateToProps
中,将items
,product
和status
映射到它们对应的选择器,这些选择器应用于状态:
const mapStateToProps = state => {
return {
items: selectBasketItems(state),
status: selectStatus(state),
product: selectProduct(state)
};
};
- 将在 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));
}
};
};
- 替换
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}
/>
)}
- 替换
App#render
中对this.continueShopping
和this.finish
的引用。 相反,分别调用this.props.continueShopping
和this.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>
)}
- 替换
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>
)}
- 打开
http://localhost:1234
,查看应用程序现在的预期行为。 您可以添加产品,转到结账,完成或继续购物:
图 10.37:mapStateToProps/mapDispatchToProps 重构后的应用程序
该应用程序现在使用正确实现的mapStateToProps
和mapDispatchToProps
函数工作。 React 和 Redux 进一步从彼此抽象出来。 React 组件中不再有状态,也不再直接调用存储的dispatch
方法。 这意味着原则上,可以使用另一个状态管理库来替换 Redux,而 React 代码不会改变; 只有状态管理器和 ReactApp
组件之间的粘合代码会改变。
Redux Reducers In Depth
Redux 减速器不应该改变 Redux 存储状态。 与第一原则相比,纯函数更容易测试,其结果更容易预测。 作为状态管理解决方案,Redux 有两个作用:保持状态如预期,并确保更新能够高效和及时地传播。
纯函数可以帮助我们通过考虑不可变性来实现这一目标。 返回副本有助于进行更改检测。 例如,检测对象内的大部分键已更新的成本更高,而检测对象已被其浅复制替换的成本更低。 在第一种情况下,必须进行昂贵的深度比较,以遍历整个对象以检测原始值和/或结构的差异。 在浅复制情况下,仅需要检测对象引用不同即可检测到更改。 这是微不足道的,与===
JavaScript 运算符有关,该运算符通过引用比较对象。
将 JavaScript-Native 方法更改为不可变的函数式样式
Map/filter/reduce 不会改变它们操作的初始数组。在以下片段中,initial
的值保持不变。Array#map
返回数组的副本,因此不会改变它正在操作的数组。Array#reduce
和Array#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'
);
对象的rest
和spread
语法是 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'
);
数组的rest
和spread
语法早于对象的 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 的最新添加,钩子允许函数组件利用以前专门用于类组件的所有功能。
前面的示例可以重构为使用useState
和useEffect
钩子的函数组件。useState
是我们可以使用钩子在 React 函数组件中使用状态的一种方式。当来自useState
的状态发生变化时,React 将重新渲染函数组件。useEffect
是componentDidMount
的对应物,在组件渲染之前调用,如果组件不是在应用程序的先前状态中渲染的:
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 模式定义中工作。请注意我们有三种类型,即Query
、basket
和basketItem
。basket
在items
属性下包含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
元素的name
、quantity
和price
:
图 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
文件运行此练习的工作解决方案。
-
如果您以前没有在此目录中执行过此操作,请将当前目录更改为
Lesson10
并运行npm install
。npm install
下载所需的依赖项,以便我们可以运行此活动(micro 和express-graphql
)。 -
运行
node exercise-graphql-micro-start.js
。然后,在开发过程中,运行npm run Exercise81
。您将看到应用程序启动,如下所示:
图 10.39:运行此练习的起始文件
- 转到
http://localhost:3000
(或者起始脚本输出的任何 URL)。您应该看到以下 GraphiQL 页面:
图 10.40:空的 GraphiQL 用户界面
- 添加一个
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
作为第二个参数。
- 现在让我们实现
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;
}
};
- 创建一个
GraphQLObjectType
的 mutation 常量。查看查询如何初始化;其名称应为Mutation:
const mutation = new GraphQLObjectType({
name: 'Mutation',
fields() {
return {};
}
});
- 将
LineItemCost
添加到fields()
返回值的 mutation 中。这意味着LineItemCost
现在是顶级 mutation。如果在 GraphQL 模式上存在mutation
,则可以调用它:
const mutation = new GraphQLObjectType({
name: 'Mutation',
fields() {
return {LineItemCost};
}
});
- 将
mutation
添加到GraphQLSchema
模式中:
const handler = graphqlServer({
schema: new GraphQLSchema({query, mutation}),
graphiql: true
});
- 将以下查询发送到服务器(通过 GraphiQL)。在左侧编辑器中输入并单击播放按钮:
mutation {
cost1: LineItemCost(id: "1")
cost2: LineItemCost(id: "2")
}
注意
这个 mutation 使用了所谓的 GraphQL 别名,因为我们不能两次使用相同的名称运行 mutation。
输出应该如下所示:
图 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.js
和activity-app-start.html
。可以使用npm run Activity17
运行开发服务器。可以在 GitHub 上使用 npm run Activity17 文件运行此活动的工作解决方案。
-
如果以前没有在此目录中这样做,请将当前目录更改为
Lesson10
并运行npm install
。 -
运行活动 17 的 BFF 和
npx parcel serve activity-app-start.html
。在开发过程中,运行npm run Activity17
。 -
转到
http://localhost:1234
(或者起始脚本输出的任何 URL)以检查 HTML 页面。 -
编写一个查询,从 BFF 获取购物篮中的物品。您可以使用
http://localhost:3000
上的 GraphQL UI 进行实验。 -
创建一个
requestBasket
(thunk)动作创建器,它将使用上一步的查询调用fetchFromBff
。 -
在
fetchFromBff()
调用上链接一个.then
,以使用正确的basket
有效负载分派REQUEST_BASKET_SUCCESS
动作。 -
向
appReducer
添加一个案例,将带有basket
有效负载的REQUEST_BASKET_SUCCESS
操作减少到状态中。 -
将
requestBasket
添加到mapDispatchToProps
。 -
在
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:从页面中提取数据
解决方案:
- 初始化一个变量来存储 CSV 的整个内容:
var csv = 'name,price,unit\n';
- 查询 DOM 以查找表示每个产品的所有元素。注意我们如何将返回的
HTMLCollection
实例包装在Array.from
中,以便我们可以像处理普通数组一样处理它:
var elements = Array.from(document.getElementsByClassName('item'));
- 遍历找到的每个元素:
elements.forEach((el) => {});
- 在闭包内,使用
product
元素,查询以找到带单位的价格。使用斜杠拆分字符串:
var priceAndUnitElement = el.getElementsByTagName('span')[0];
var priceAndUnit = priceAndUnitElement.textContent.split("/");
var price = priceAndUnit[0].trim();
var unit = priceAndUnit[1].trim();
- 然后查询名称:
var name = el.getElementsByTagName('a')[0].textContent;
- 将所有信息附加到步骤 1 中初始化的变量中,使用逗号分隔值。不要忘记为附加到每行的换行符添加:
csv += `${name},${price},${unit}\n`;
- 使用
console.log
函数打印包含累积数据的变量:
console.log(csv);
- 将代码粘贴到 Chrome 控制台选项卡中;它应该看起来像这样:
图 1.62:准备在控制台选项卡中运行的代码
按下Enter执行代码后,您应该在控制台中看到打印的 CSV,如下所示:
图 1.63:带有代码和控制台选项卡输出的商店
活动 2:用 Web 组件替换标签过滤器
解决方案:
-
首先将
Exercise07
中的代码复制到一个新文件夹中。 -
创建一个名为
tags_holder.js
的新文件,并在其中添加一个名为TagsHolder
的扩展HTMLElement
的类,然后定义一个名为tags-holder
的新自定义组件:
class TagsHolder extends HTMLElement {
}
customElements.define('tags-holder', TagsHolder);
- 创建两个
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);
});
}
- 在构造函数中,调用
w
,将组件附加到阴影根,初始化所选标签列表,并调用两个render
方法:
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._selectedTags = [];
this.render();
this.renderTagList();
}
- 创建一个 getter 来公开所选标签的列表:
get selectedTags() {
return this._selectedTags.slice(0);
}
- 创建两个触发方法:一个用于触发更改事件,另一个用于触发
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);
}
- 创建两个
mutator
方法:addTag
和removeTag
。这些方法接收标签名称,如果不存在,则添加标签,如果存在,则删除标签,从所选标签列表中。如果列表已修改,则触发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();
}
}
- 在 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
。
- 在
filter_and_search.js
中,执行以下操作:
在顶部,创建对tags-holder
组件的引用:
const filterByTagElement = document.querySelector('tags-holder');
添加事件侦听器以处理changed
和tag-clicked
事件:
filterByTagElement.addEventListener('tag-clicked', (e) => filterByTagElement.removeTag(e.detail.tag));
filterByTagElement.addEventListener('changed', () => applyFilters());
删除以下函数及其所有引用:createTagFilterLabel
和updateTagFilterList
。
在filterByTags
函数中,用filterByTagElement.selectedTags
替换tagsToFilterBy
。
在addTagFilter
方法中,用filterByTagElement.addTag
替换对tagsToFilterBy
的引用。
第二章:Node.js 和 npm
活动 3:创建一个 npm 包来解析 HTML
解决方案:
- 在空文件夹中,使用 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)
- 要安装
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
- 在此文件夹中,创建一个名为
index.js
的文件,并将以下内容添加到其中:
const cheerio = require('cheerio');
- 创建一个变量,存储来自 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>
`;
- 解析 HTML 并将其传递给 cheerio。在 cheerio 的示例中,您将看到它们将解析的变量命名为“
$
”(美元符号)。这是 jQuery 世界中使用的一个旧约定。它看起来像这样:
const $ = cheerio.load(html);
- 现在,我们可以使用该变量来操作 HTML。首先,我们将向页面添加一个带有文本的段落:
$('div').append('<p>This is another paragraph.</p>');
我们还可以查询 HTML,类似于我们在第一章 JavaScript、HTML 和 DOM中所做的,使用 CSS 选择器。让我们查询所有段落并将其内容打印到控制台。请注意,cheerio 元素的行为与 DOM 元素并不完全相同,但它们非常相似。
- 使用
firstChild
属性找到每个段落的第一个节点并打印其内容,假设它将是文本元素:
$('p').each((index, p) => {
console.log(`${index} - ${p.firstChild.data}`);
});
- 最后,在
index.js
中,通过调用html
函数将操作后的 HTML 打印到控制台:
console.log($.html());
现在,您可以通过从 Node.js 调用它来运行您的应用程序:
图 2.7:从 Node.js 调用应用程序
第三章:Node.js API 和 Web 爬虫
活动 4:从商店前端爬取产品和价格
解决方案
- 使用本章中练习 14,提供动态内容中的代码启动动态服务器以提供商店前端应用程序:
$ node Lesson03/Activity04/
Static resources from /path/to/repo/Lesson03/Activity04/static
Loaded 21 products...
Go to: http://localhost:3000
- 在新的终端中,创建一个新的
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
- 调用
require()
方法加载项目中需要的所有模块:
const fs = require('fs');
const http = require('http');
const JSDOM = require('jsdom').JSDOM;
- 向
http://localhost:3000
发出 HTTP 请求:
const page = 'http://localhost:3000';
console.log(`Downloading ${page}...`);
const request = http.get(page, (response) => {
- 确保成功响应并使用数据事件从主体收集数据:
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());
- 在
close
事件中,使用JSDOM
解析 HTML:
response.on('close', () => {
console.log('Download finished.');
const document = new JSDOM(content).window.document;
writeCSV(extractProducts(document));
});
前面的回调调用了两个函数:extractProducts
和writeCSV
。这些函数将在接下来的步骤中描述。
- 使用
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;
}
- 使用
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;
}
- 现在文件已打开,我们可以将产品数据写入文件:
// 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.');
});
}
- 在新的终端中,运行应用程序:
$ node .
Downloading http://localhost:3000...
Download finished.
Parsing product data...
.....................
Found 21 products.
Writing data to products.csv...
第四章:使用 Node.js 构建 RESTful API
活动 5:为键盘门锁创建 API 端点
解决方案
- 创建一个新的项目文件夹,并将目录更改为以下内容:
mkdir passcode
cd passcode
- 初始化一个
npm
项目并安装express
,express-validator
和jwt-simple
。然后,创建一个routes
目录:
npm init -y
npm install --save express express-validator jwt-simple
mkdir routes
- 创建一个
config.js
文件,就像在练习 21,设置需要身份验证的端点中所做的那样。这应该包含一个随机生成的秘密值:
let config = {};
// random value below generated with command: openssl rand -base64 32
config.secret = "cSmdV7Nh4e3gIFTO0ljJlH1f/F0ROKZR/hZfRYTSO0A=";
module.exports = config;
- 创建
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');
- 创建一个名为
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 = [];
- 为
/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;
- 在
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;
- 创建主文件,在其中使用我们的路由
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());
- 接下来,在
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) {
- 最后,我们将测试 API 以确保它被正确完成。首先运行您的程序:
npm start
- 在程序运行时,打开第二个终端窗口,并使用
/check-in
端点获取 JWT 并将值保存为TOKEN
。然后,回显该值以确保成功:
TOKEN=$(curl -sd "name=john" -X POST http://localhost:3000/check-in \
| jq -r ".token")
echo $TOKEN
您应该收到一个包含字母和数字的长字符串,就像下面这样:
图 4.24:从签到端点获取 TOKEN
- 接下来,我们将使用我们的 JWT 使用
/lock/code
端点为 Sarah 获取一次性通行码:
curl -sd "name=sarah" -X GET \
-H "Authorization: Bearer ${TOKEN}" \
http://localhost:3000/lock/code \
| jq
您应该收到一个包含消息和四位代码的对象,就像下面这样:
图 4.25:一个四位一次性代码
- 为了确保代码正常工作,将其发送到
/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:运行命令两次会导致错误
如果你的结果与前面的图像相同,那么你已成功完成了这个活动。
第五章:模块化 JavaScript
活动 6:创建带有闪光模式的灯泡
解决方案:
- 安装
babel-cli
和babel
预设为开发人员依赖项:
npm install --save-dev webpack webpack-cli @babel/core @babel/cli @babel/preset-env
- 在根目录下添加一个名为
.babelrc
的文件。在其中,我们将告诉 Babel 使用预设设置:
{
"presets": ["@babel/preset-env"]
}
- 在根目录下的
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"
}
};
- 创建一个名为
js/flashingLight.js
的新文件。这应该是一个空的 ES6 组件,扩展Light
。在构造函数中,我们将包括state
,brightness
和flashMode
:
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();
}
}
- 为
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();
}
}
- 为
FlashingLight
对象添加一个 getter 方法:
getFlashMode() {
let info = privateVars.get(this);
return info.flashMode;
}
- 创建一个
startFlashing
函数,引用父类的lightSwitch()
函数。这一步很棘手,因为我们必须将它绑定到setInterval
:
startFlashing() {
let info = privateVars.get(this);
info.flashing = setInterval(this.toggle.bind(this),5000);
}
- 创建一个
stopFlashing
函数,用于关闭定时器:
stopFlashing() {
let info = privateVars.get(this);
clearInterval(info.flashing);
}
- 作为
flashingLight.js
的最后部分,关闭类并导出它:
}
export default FlashingLight;
- 打开
src/js/viewer.js
并修改按钮以创建一个闪光灯而不是一个彩色灯:
button.onclick = function () {
new FlashingLight(true, slider.value, true);
}
- 通过运行我们的
build
函数使用 npm 编译代码:
npm run build
- 打开
build/index.html
并将脚本位置设置为bundle.js
:
<script src="bundle.js" type="module"></script>
- 为了测试一切是否按预期工作,请运行
npm start
并在浏览器中打开localhost:8000
。点击build
按钮创建一个完整页面的灯。如果一切都做对了,你应该看到每盏灯在 5 秒的间隔内闪烁:
图 5.20:带有闪光模式的灯泡
第六章:代码质量
活动 7:将所有内容整合在一起
解决方案
- 安装 linting 练习中列出的开发人员依赖项(
eslint
,prettier
,eslint-config-airbnb-base
,eslint-config-prettier
,eslint-plugin-jest
和eslint-plugin-import
):
npm install --save-dev eslint prettier eslint-config-airbnb-base eslint-config-prettier eslint-plugin-jest eslint-plugin-import
- 添加一个
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",
}
}
- 添加一个
.prettierignore
文件:
node_modules
build
dist
- 在你的
package.json
文件中添加一个lint
命令:
"scripts": {
"start": "http-server",
"lint": "prettier --write js/*.js && eslint js/*.js"
},
- 打开
assignment
文件夹并安装使用 Puppeteer 与 Jest 的开发人员依赖项:
npm install --save-dev puppeteer jest jest-puppeteer
- 通过添加一个选项告诉 Jest 使用
jest-puppeteer
预设来修改你的package.json
文件:
"jest": {
"preset": "jest-puppeteer"
},
- 在
package.json
中添加一个test
脚本,运行jest
:
"scripts": {
"start": "http-server",
"lint": "prettier --write js/*.js && eslint js/*.js",
"test": "jest"
},
- 创建一个包含以下内容的
jest-puppeteer.config.js
文件:
module.exports = {
server: {
command: 'npm start',
port: 8080,
},
}
- 在
__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');
})
})
- 创建一个包含以下内容的
.huskyrc
文件:
{
"hooks": {
"pre-commit": "npm run lint && npm test"
}
}
- 通过运行
npm install --save-dev husky
安装husky
作为开发人员依赖项:
图 6.19:安装 Husky
- 确保使用
npm test
命令正确运行测试:
npm test
这应该返回两个测试的正面结果,如下图所示:
图 6.20:显示两个测试的正面结果
通过进行测试提交来确保 Git 钩子和 linting 正常工作。
第七章:高级 JavaScript
活动 8:创建一个用户跟踪器
解决方案
- 打开
Activity08.js
文件并定义logUser
。它将把用户添加到userList
参数中。确保不会添加重复项:
function logUser(userList, user) {
if(!userList.includes(user)) {
userList.push(user);
}
}
在这里,我们使用includes
方法来检查用户是否已经存在。如果他们不存在,他们将被添加到我们的列表中。
- 定义
userLeft
。它将从userList
参数中移除用户。如果用户不存在,它将不执行任何操作:
function userLeft(userList, user) {
const userIndex = userList.indexOf(user);
if (userIndex >= 0) {
userList.splice(userIndex, 1);
}
}
在这里,我们使用indexOf
来获取要移除的用户的当前索引。如果该项不存在,indexOf
将返回-1
,因此我们只在存在时使用splice
来移除该项。
- 定义
numUsers
,返回当前列表中的用户数:
function numUsers(userList) {
return userLeft.length;
}
- 定义一个名为
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 的输出
活动 9:使用 JavaScript 数组和类创建学生管理器
解决方案
- 创建一个包含所有学生信息的
School
类:
class School {
constructor() {
this.students = [];
}
}
在School
构造函数中,我们只是初始化了一个学生列表。稍后,我们将向此列表添加新学生。
- 创建一个
Student
类,包括有关学生的所有相关信息:
class Student {
constructor(name, age, gradeLevel) {
this.name = name;
this.age = age;
this.gradeLevel = gradeLevel;
this.courses = [];
}
}
在学生constructor
中,我们存储了课程列表,以及学生的age
,name
和gradeLevel
。
- 创建一个
Course
类,其中包括有关课程的name
和grade
的信息:
class Course {
constructor(name, grade) {
this.name = name;
this.grade = grade;
}
}
课程构造函数只是将课程的名称和成绩存储在object
中。
- 在
School
类中创建addStudent
:
addStudent(student) {
this.students.push(student);
}
- 在
School
类中创建findByGrade
:
findByGrade(gradeLevel) {
return this.students.filter((s) => s.gradeLevel === gradeLevel);
}
- 在
School
类中创建findByAge
:
findByAge(age) {
return this.students.filter((s) => s.age === age);
}
- 在
School
类中创建findByName
:
findByName(name) {
return this.students.filter((s) => s.name === name);
}
- 在
Student
类中,创建一个calculateAverageGrade
方法来计算学生的平均成绩:
calculateAverageGrade() {
const totalGrades = this.courses.reduce((prev, curr) => prev + curr.grade, 0);
return (totalGrades / this.courses.length).toFixed(2);
}
在calculateAverageGrade
方法中,我们使用数组 reduce 来获取学生所有课程的总成绩。然后,我们将其除以课程列表中的课程数。
- 在
Student
类中,创建一个名为assignGrade
的方法,用于为学生正在上的课程分配数字成绩:
assignGrade(name, grade) {
this.courses.push(new Course(name, grade))
}
您应该在student_manager.js
文件中进行工作,并修改提供的方法模板。如果您正确实现了所有内容,您应该看到TEST PASSED消息:
图 7.63:显示 TEST PASSED 消息的屏幕截图
活动 10:重构函数以使用现代 JavaScript 功能
解决方案
- 打开
Activity03.js
;它应该包含用传统 JavaScript 编写的各种函数。当您使用 Node.js 运行Activity03.js
时,您应该看到以下输出:
图 7.64:运行 Lesson7-activity.js 后的输出
- 您需要重构
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);
}
}
- 在
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();
}
- 在
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];
}
- 我们将使用
ES6
类重构我们的Food
类:
class Food {
constructor(type, calories) {
this.type = type;
this.calories = calories;
}
getCalories() {
return this.calories;
}
}
在您完成重构并运行现有代码后,您应该看到相同的输出:
图 7.65:显示 TEST PASSED 消息的输出
第八章:异步编程
活动 11:使用回调接收结果
解决方案:
- 创建一个
calculate
函数,它接受id
和callback
作为参数:
function calculate(id, callback) {
}
- 我们将首先调用
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
函数。
- 调用
getUsage
来获取用户的使用情况:
clientApi.getUsage(id, (error, usage) => {
if (error) { return callback(error); }
});
然后,我们需要将对getUsers
的调用放在getUsage
的回调函数中,这样它将在我们完成调用getUsers
后运行。在这里,回调函数将被调用并传入一个数字列表,这将是使用情况。如果我们从getUsage
收到错误,我们还将使用错误对象调用回调函数。
- 最后,调用
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 来计算该用户的总使用量,然后将其乘以费率以获得最终应付金额。
- 当函数完成时,使用现有 ID 调用它,如下面的代码:
calculate('DDW2AU', (error, result) => {
console.log(error, result);
});
您应该看到以下输出:
图 8.43:使用现有 ID 调用函数
- 使用一个不存在的 ID 调用函数:
calculate('XXX', (error, result) => {
console.log(error, result);
});
您应该看到返回的错误如下:
图 8.44:使用不存在的 ID 调用函数
活动 12:使用异步和等待重构账单计算器
解决方案
- 将
calculate
函数创建为async
函数:
async function calculate(id) {
}
- 使用
await
调用getUsers
以获取users
中的解析结果:
const users = await clientApi.getUsers();
const currentUser = users.users.find((user) => user.id === id);
当我们使用await
关键字时,我们必须使用async
函数。await
关键字将打破程序的控制,并且只有在等待的 promise 被解析后才会返回并继续执行。
- 使用
await
调用getUsage
以获取用户的使用情况:
const usage = await clientApi.getUsage(currentUser.id);
- 使用
await
调用getRate
以获取用户的费率:
const rate = await clientApi.getRate(currentUser.id);
- 最后,我们将调用
return
以检索id
,address
和due
:
return {
id,
address: currentUser.address,
due: (rate * usage.reduce((prev, curr) => curr + prev)).toFixed(2)
};
- 将
calculateAll
函数创建为async
函数:
async function calculateAll() {
}
- 在调用
getUsers
时使用await
并将结果存储在result
中:
const result = await clientApi.getUsers();
- 使用映射数组创建一个 promise 列表,并使用
Promise.all
将它们包装起来。然后,应该在Promise.all
返回的 promise 上使用await
:
return await Promise.all(result.users.map((user) => calculate(user.id)));
因为await
将在任何 promise 上工作,并且会等待直到值被解析,它也会等待我们的Promise.all
。在它被解析后,最终数组将被返回。
- 在一个用户上调用
calculate
:
calculate('DDW2AU').then(console.log)
输出应如下所示:
图 8.45:在一个用户上调用 calculate
- 调用
calculateAll
函数:
calculateAll().then(console.log)
输出应如下所示:
图 8.46:调用 calculateAll 函数
正如您所看到的,当我们调用async
函数时,我们可以将它们视为返回 promise 的函数。
第九章:事件驱动编程和内置模块
活动 13:构建事件驱动模块
解决方案:
执行以下步骤完成此活动:
- 导入
events
模块:
const EventEmitter = require('events');
- 创建
SmokeDetector
类,它扩展了EventEmitter
并将batteryLevel
设置为10
:
class SmokeDetector extends EventEmitter {
constructor() {
super();
this.batteryLevel = 10;
}
}
在我们的构造函数中,因为我们正在扩展EventEmitter
类并且正在分配一个自定义属性batteryLevel
,我们需要在构造函数中调用super
并将batteryLevel
设置为10
。
- 在
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
方法时,我们还会减少电池电量。
- 创建
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
。
- 在
House
类中创建一个addDetector
方法。在这里,我们将附加事件侦听器:
addDetector(detector) {
detector.on('alarm', this.alarmListener);
detector.on('low battery', this.lowBatteryListener);
}
在这里,我们期望传入的探测器是一个EventEmitter
。我们将两个事件侦听器附加到我们的detector
参数。当这些事件被触发时,它将调用我们对象内的事件发射器。
- 创建一个
removeDetector
方法,它将帮助我们删除先前附加的警报事件侦听器:
removeDetector(detector) {
detector.removeListener('alarm', this.alarmListener);
detector.removeListener('low battery', this.lowBatteryListener);
}
在这里,我们使用函数引用和警报参数来删除附加到我们侦听器的侦听器。一旦调用了这个,事件就不应该再次调用我们的侦听器。
- 创建一个名为
myHouse
的House
实例。这将包含关于我们房子的一些示例信息。它还将用于监听我们的烟雾探测器发出的事件:
const myHouse = new House(2, 2, 1);
- 创建一个名为
detector
的SmokeDetector
实例:
const detector = new SmokeDetector();
- 将我们的
detector
添加到myHouse
:
myHouse.addDetector(detector);
- 创建一个循环来调用测试函数
96
次:
for (let i = 0; i < 96; i++) {
detector.test();
}
因为测试函数将减少电池电量,如果我们调用它96
次,我们将期望发出低电量警报。这将产生以下输出:
图 9.50:发出低电量警报
- 在
detector
对象上发出警报:
detector.emit('alarm');
以下是前面代码的输出:
图 9.51:对检测器对象发出的警报
- 从
myHouse
对象中删除detector
:
myHouse.removeDetector(detector);
- 测试这个以在
detector
上发出警报:
detector.test();
detector.emit('alarm');
因为我们刚刚从我们的房子中移除了detector
,所以我们不应该看到这个输出:
图 9.52:测试检测器上的发出警报
活动 14:构建文件监视器
解决方案:
- 导入
fs
和events
:
const fs = require('fs').promises;
const EventEmitter = require('events');
- 创建一个扩展
EventEmitter
类的fileWatcher
类。使用modify
时间戳来跟踪文件更改。
我们需要创建一个扩展EventEmitter
的FileWatcher
类。它将在构造函数中以文件名和延迟作为参数。在构造函数中,我们还需要设置上次修改时间和计时器变量。现在我们将它们保持为未定义:
class FileWatcher extends EventEmitter {
constructor(file, delay) {
super();
this.timeModified = undefined;
this.file = file;
this.delay = delay;
this.watchTimer = undefined;
}
}
这是查看文件是否已更改的最基本方法。
- 创建
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
来获取文件的信息,并将修改时间与上次修改时间进行比较。如果它们不相等,我们将在控制台中输出修改。
- 创建
stopWatch
方法以停止监视文件的更改:
stopWatch() {
if (this.watchTimer) {
clearInterval(this.watchTimer);
this.watchTimer = undefined;
}
}
stopWatch
方法非常简单:我们将检查这个对象中是否有一个计时器。如果有,那么我们将在该计时器上运行clearInterval
以清除该计时器。
-
在与
filewatch.js
相同的目录中创建一个名为test.txt
的文件。 -
创建一个
FileWatcher
实例并每1000
毫秒开始监视文件:
const watcher = new FileWatcher('test.txt', 1000);
watcher.startWatch();
- 修改
test.txt
中的一些内容并保存。您应该看到以下输出:
图 9.53:修改test.txt
文件内容后的输出
我们修改了文件两次,这意味着我们看到了三条修改消息。这是因为当我们开始观察时,我们将其视为文件已被修改。
- 修改
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 函数看到对文件所做的修改
- 修改
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);
}
}
我们将不再输出内容,而是发出一个带有新内容的事件。这使我们的代码更加灵活。
- 将事件处理程序附加到
error
并在我们的文件watcher
上更改它们:
watcher.on('error', console.error);
watcher.on('change', (change) => {
console.log('new change:', change);
});
- 运行代码并修改
test.txt
:
图 9.55:更改文件监视器后的输出
第十章:使用 JavaScript 进行函数式编程
活动 15:onCheckout 回调属性
解决方案
-
将当前目录更改为
Lesson10
,如果之前在此目录中尚未执行过npm install
,则运行npm install
。npm install
会下载运行此活动所需的依赖项(React 和 Parcel)。 -
运行
parcel serve activity-on-checkout-prop-start.html
,然后执行npm run Activity15
。您将看到应用程序启动,如下所示:
图 10.42:运行 start html 脚本后的输出
- 转到
http://localhost:1234
(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.43:浏览器中的初始应用程序
- 继续结账的
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
处理程序。
- 单击“继续结账”按钮后,我们应该看到以下内容:
图 10.44:单击“继续结账”按钮后的输出
活动 16:测试选择器
解决方案
- 运行
npm run Activity16
(或node activity-items-selector-test-start.js
)。您将看到以下输出:
图 10.45:运行活动的初始启动文件后的预期输出
- 测试一下,对于空状态,选择器返回
[]
:
function test() {
assert.deepStrictEqual(
selectBasketItems(),
[],
'should be [] when selecting with no state'
);
assert.deepStrictEqual(
selectBasketItems({}),
[],
'should be [] when selecting with {} state'
);
}
- 测试一下,对于一个空的购物篮对象,选择器返回[]:
function test() {
// other assertions
assert.deepStrictEqual(
selectBasketItems({basket: {}}),
[],
'should be [] when selecting with {} state.basket'
);
}
- 测试一下,如果项目数组已设置但为空,则选择器返回
[]
:
function test() {
// other assertions
assert.deepStrictEqual(
selectBasketItems({basket: {items: []}}),
[],
'should be [] when items is []'
);
}
- 测试一下,如果
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'
);
}
- 实施测试的输出中不应该有错误:
图 10.46:最终输出显示没有错误
活动 17:从 BFF 获取当前购物篮
解决方案
-
将当前目录更改为
Lesson10
,如果之前在此目录中尚未执行过npm install
,则运行npm install
。 -
运行 Activity 17 的 BFF 和
npx parcel serve activity-app-start.html
。在开发过程中,运行npm run Activity17
。您将看到应用程序启动,如下所示:
图 10.47:运行活动的初始启动文件
- 转到
http://localhost:1234
(或者启动脚本输出的任何 URL)。您应该看到以下 HTML 页面:
图 10.48:浏览器中的初始应用程序
- 在 GraphiQL UI 中运行以下查询:
{
basket {
items {
id
name
price
quantity
}
}
}
以下是前面代码的输出:
图 10.49:带有购物篮查询的 GraphiQL UI
- 创建一个新的
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
});
});
};
}
- 将篮子数据减少到存储中,并将以下情况添加到
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
}
};
- 在
mapDispatchToProps
中添加requestBasket
,如下所示:
const mapDispatchToProps = dispatch => {
return {
// other mapped functions
requestBasket() {
dispatch(requestBasket());
}
};
};
- 在
componentDidMount
上调用requestBasket
:
class App extends React.Component {
componentDidMount() {
this.props.requestBasket();
}
// render method
}
当使用所有前述步骤加载应用程序时,它会闪烁显示“您的篮子中有 0 件物品”的消息,然后变成以下屏幕截图。当从 BFF 获取完成时,它会减少到存储中并导致重新渲染。这将再次显示篮子,如下所示: