NodeJS-开发学习手册-全-

NodeJS 开发学习手册(全)

原文:zh.annas-archive.org/md5/551AEEE166502AE00C0784F70639ECDF

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《学习 Node.js 开发》。本书充满了大量的内容、项目、挑战和真实世界的例子,所有这些都旨在通过实践教授 Node。这意味着在接下来的章节中,您将很早就开始动手写一些代码,并且您将为每个项目编写代码。您将编写支持我们应用程序的每一行代码。现在,我们需要一个文本编辑器。我们有各种文本编辑器选项可供选择。我始终建议使用 Atom,您可以在atom.io找到它。它是免费的、开源的,并且适用于所有操作系统,即 Linux、macOS 和 Windows。它是由 GitHub 背后的人员创建的。

本书中的所有项目都很有趣,并且它们旨在教会您启动自己的 Node 应用程序所需的一切,从规划到开发、测试到部署。现在,当您启动这些不同的 Node 应用程序并阅读本书时,您将遇到错误,这是不可避免的。也许某些东西没有按预期安装,或者您尝试运行一个应用程序,而不是得到预期的输出,您得到了一个非常长的晦涩的错误消息。不要担心,我会在章节中向您展示通过这些错误的技巧和窍门。让我们继续并开始吧。

本书适合对象

本书面向希望启动自己的 Node 应用程序、转行或作为 Node 开发人员自由职业的任何人。您应该对 JavaScript 有基本的了解才能跟上本书的内容。

本书涵盖的内容

第一章《设置》,讨论了 Node 是什么以及为什么要使用它。在本章中,您将学习 Node 的安装,到本章结束时,您将能够运行您的第一个 Node 应用程序。

第二章《Node 基础知识-第一部分》讨论了构建 Node 应用程序。《Node 基础知识》主题已分为 3 部分。本主题的第一部分包括模块基础知识、需要自己的文件以及第三方 NPM 模块。

第三章《Node 基础知识-第二部分》继续讨论一些更多的 Node 基础知识。本章探讨了 yargs、JSON、addNote 函数和重构,将功能移入单独的函数并测试功能。

第四章《Node 基础知识-第三部分》包括从文件系统中读取和写入内容等内容。我们将深入研究高级 yargs 配置、调试故障应用程序以及一些新的 ES6 函数。

第五章《Node.js 异步编程基础》涵盖了与异步编程相关的基本概念、术语和技术,使其在我们的天气应用程序中变得非常实用。

第六章《异步编程中的回调》是 Node 中异步编程的第二部分。我们将研究回调、HTTPS 请求以及在回调函数中的错误处理。我们还将研究天气预报 API,并获取我们地址的实时天气数据。

第七章《异步编程中的 Promise》是 Node 中异步编程的第三部分,也是最后一部分。本章重点介绍 Promise,它的工作原理,为什么它们有用等等。在本章结束时,我们将在我们的天气应用程序中使用 Promise。

第八章《Node 中的 Web 服务器》讨论了 Node Web 服务器以及将版本控制集成到 Node 应用程序中。我们还将介绍一个名为 Express 的框架,这是最重要的 NPM 库之一。

第九章,将应用部署到 Web,讨论了将应用部署到 Web。我们将使用 Git、GitHub,并使用这两项服务将我们的实时应用程序部署到 Web。

第十章,测试 Node 应用程序-第一部分,讨论了我们如何测试代码以确保其按预期工作。我们将开始设置测试,然后编写我们的测试用例。我们将研究基本的测试框架和异步测试。

第十一章,测试 Node 应用程序-第二部分,继续我们测试 Node 应用程序的旅程。在本章中,我们将测试 Express 应用程序,并研究一些高级的测试方法。

为了充分利用本书

Web 浏览器,我们将在整本书中使用 Chrome,但任何浏览器都可以,以及终端,有时在 Linux 上称为命令行,Windows 上称为命令提示符。Atom 作为文本编辑器。以下模块列表将在本书的整个过程中使用:

  • lodash

  • nodemon

  • yargs

  • 请求

  • axios

  • express

  • hbs

  • heroku

  • rewire

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

本书的代码捆绑包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Node.js-Development。我们还有来自丰富书籍和视频目录的其他代码捆绑包可用于github.com/PacktPublishing/。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Process', process.argv); console.log('Yargs', argv);

任何命令行输入或输出都以以下方式编写:

cd hello-world node app.js

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中出现。例如:"从管理面板中选择系统信息。"

警告或重要说明看起来像这样。

提示和技巧看起来像这样。

第一章:设置

在本章中,您将为本书的其余部分设置本地环境。无论您使用的是 macOS、Linux 还是 Windows,我们都将安装 Node,并查看我们如何运行 Node 应用程序。

我们将讨论 Node 是什么,为什么您会想要使用它,以及为什么您会想要使用 Node 而不是像 Rails、C++、Java 或任何其他可以完成类似任务的语言。在本章结束时,您将运行您的第一个 Node 应用程序。这将是简单的,但它将使我们走上创建真实生产 Node 应用程序的道路,这是本书的目标。

更具体地,我们将涵盖以下主题:

  • Node.js 安装

  • Node 是什么

  • 为什么使用 Node

  • Atom

  • Hello World

Node.js 安装

在我们开始讨论 Node 是什么以及它为什么有用之前,您需要先在您的计算机上安装 Node,因为在接下来的几节中,我们将想要运行一些 Node 代码。

现在,要开始,我们只需要两个程序-一个浏览器,我将在整本书中都使用 Chrome,但任何浏览器都可以,还有终端。我将使用Spotlight打开终端,在我的操作系统中它就是这个名字。

如果您使用 Windows,寻找命令提示符,您可以使用 Windows 键搜索,然后输入command prompt,在 Linux 上,您要寻找命令行,尽管根据您的发行版,它可能被称为终端或命令提示符。

现在,一旦您打开了该程序,您将看到一个屏幕,如下面的截图所示:

基本上,它在等待您运行一个命令。在整本书中,我们将从终端运行相当多的命令。我将在几节后讨论它,所以如果您以前从未使用过这个,您可以开始舒适地进行导航。

Node.js 版本确认

在浏览器中,我们可以转到nodejs.org下载最新版本的 Node 安装程序(如下所示)。在本书中,我们将使用最新版本 9.3.0:

重要的是安装 Node.js 的 V8 版本。它不一定要是 4.0,可以是 1.0,但重要的是它在 V8 分支上,因为 V8 带来了大量新功能,包括您可能在浏览器中使用 ES6 喜欢的所有功能。

ES6 是 JavaScript 的下一个版本,它带来了很多我们将在整本书中使用的优秀增强功能。如果您查看下面的图片,Node.js 长期支持发布计划(github.com/nodejs/LTS),您会看到当前的 Node 版本是 V8,发布于 2017 年 4 月:

在继续之前,我想谈谈 Node 的发布周期。我在上面的图片中所看到的是官方发布周期,这是由 Node 发布的。您会注意到,只有在偶数 Node 版本旁边才会找到活跃的 LTS,蓝色条和维护条。现在,LTS 代表长期支持,这是推荐大多数用户使用的版本。我建议您坚持当前提供的 LTS 选项(Node v 8.9.4 LTS),尽管左侧的任何内容都可以,这显示在nodejs.org上的两个绿色按钮上。

现在,您可以看到,主要版本号每六个月增加一次。无论有任何大的全面性变化,这都会像钟表一样发生,即使没有发生任何重大变化。这不像 Angular,从 1.0 跳到 2.0 几乎就像使用完全不同的库一样。这在 Node 中并不是这种情况,您从本书中得到的是 Node 所提供的最新和最好的东西。

安装 Node

一旦确认并选择了版本,我们所要做的就是在 Node 网站(nodejs.org)上点击所需版本按钮并下载安装程序。安装程序是那种基本的点击几次下一步就完成类型的安装程序,不需要运行任何花哨的命令。我将启动安装程序。如下截图所示,它只会问几个问题,然后让我们通过所有问题点击下一步或继续:

您可能想要指定自定义目标,但如果您不知道这意味着什么,并且通常在安装程序时不这样做,请跳过该步骤。在下一个截图中,您可以看到我只使用了 58.6 MB,没有问题。

我将通过输入我的密码来运行安装程序。一旦我输入密码,安装 Node 应该只需要几秒钟:

如下截图所示,我们有一条消息,说安装已成功完成,这意味着我们可以开始了:

验证安装

现在 Node 已经成功安装,我们可以通过在终端中运行 Node 来验证。在终端中,我将通过退出终端并重新打开来关闭它:

我之所以打开它是因为我们安装了一个新的命令,有些终端需要在运行新命令之前重新启动。

在我们的情况下,我们重新启动了一些东西,我们可以运行我们全新的命令,所以我们会输入它:

node -v

在这个命令中,我们正在运行 Node 命令,并传入所谓的标志,即连字符后跟一个字母。它可以是a,可以是j,或者在我们的情况下是v。这个命令将打印当前安装的 Node 版本。

我们可能会遇到这样的错误:

如果您尝试运行一个不存在的命令,比如nodeasdf,您将看到命令未找到。如果您看到这个,通常意味着 Node 安装程序没有正确工作,或者您根本没有运行它。

然而,在我们的情况下,使用v标志运行 Node 应该会得到一个数字。在我们的情况下,它是版本 9.3.0。如果您已经安装了 Node,并且看到类似下一个截图的东西,那么您已经完成了。在下一节中,我们将开始探索 Node 到底是什么。

什么是 Node?

Node 诞生于原始开发人员将 JavaScript 带到了您的机器上作为一个独立的进程,而不仅仅是在浏览器中运行。这意味着我们可以在浏览器之外使用 JavaScript 创建应用程序。

现在,JavaScript 以前的功能集是有限的。当我在浏览器中使用它时,我可以做一些事情,比如更新 URL 和删除 Node 标志,添加点击事件或其他任何东西,但我实际上不能做更多。

有了 Node,我们现在有了一个看起来更类似于其他语言(如 Java、Python 或 PHP)的功能集。其中一些如下:

  • 我们可以使用 JavaScript 语法编写 Node 应用程序

  • 您可以操纵您的文件系统,创建和删除文件夹

  • 您可以直接创建查询数据库

  • 您甚至可以使用 Node 创建 Web 服务器

这些是过去不可能的事情,现在却因为 Node 而成为可能。

现在,Node 和在浏览器中执行的 JavaScript 都在完全相同的引擎上运行。它被称为 V8 JavaScript 运行时引擎。这是一个将 JavaScript 代码编译成更快的机器代码的开源引擎。这是 Node.js 如此快速的一个重要部分。

机器码是低级代码,你的计算机可以直接运行它,而无需解释。你的计算机只知道如何运行某些类型的代码,例如,你的计算机不能直接运行 JavaScript 代码或 PHP 代码,而是需要先将其转换为低级代码。

使用这个 V8 引擎,我们可以将我们的 JavaScript 代码编译成更快的机器码,并执行它。这就是所有这些新功能的来源。V8 引擎是用一种叫做 C++的语言编写的。因此,如果你想扩展 Node 语言,你不会编写 Node 代码,而是编写建立在 V8 已有基础上的 C++代码。

现在,我们不会在这本书中编写任何 C++代码。这本书不是关于扩展 Node,而是关于使用 Node。因此,我们只会编写 JavaScript 代码。

说到 JavaScript 代码,让我们开始在终端内编写一些。在整本书中,我们将创建文件并执行这些文件,但我们实际上可以通过运行node命令来创建一个全新的 Node 进程。

参考下面的截图,我有一个小的右尖括号,它正在等待 JavaScript Node 代码,而不是一个新的命令提示符命令:

这意味着我可以运行像console.log这样的东西,你可能已经知道,它会将消息记录到屏幕上。log是一个函数,所以我会像这样调用它,打开和关闭括号,并在两个单引号内传递一个字符串,一个消息Hello world!,就像下面的命令行中所示:

console.log('Hello world!');

这将在屏幕上打印出 Hello world。如果我按下enter,Hello world!就会像你期望的那样打印出来,就像下面的代码输出中所示:

现在,在幕后实际发生了什么?这就是 Node 的工作原理。它接受你的 JavaScript 代码,将其编译成机器码,然后执行它。在上面的代码中,你可以看到它执行了我们的代码,打印出了 Hello world!现在,当我们执行这个命令时,V8 引擎在幕后运行,并且也在 Chrome 浏览器内运行。

如果我在 Chrome 中打开开发者工具,可以通过设置 | 更多工具 | 开发者工具来实现:

我可以忽略大部分的东西。我只是在寻找控制台选项卡,就像下面的截图中所示的那样:

上面的截图显示了控制台,这是一个可以运行一些 JavaScript 代码的地方。我可以输入完全相同的命令console.log('Hello world!');并运行它:

正如你在上面的截图中所看到的,Hello world!打印到了屏幕上,这与我们之前在终端中运行时得到的完全相同的结果。在这两种情况下,我们都是通过 V8 引擎运行它,输出也是相同的。

现在,我们已经知道这两者是不同的。Node 具有文件系统操作等功能,而浏览器具有操作窗口内显示内容的功能。让我们花点时间来探索它们的区别。

使用 Node 和浏览器进行 JavaScript 编码的区别

在浏览器中,如果你进行过任何 JavaScript 开发,你可能已经使用过window

Window 是全局对象,它基本上存储了你可以访问的一切。在下面的截图中,你可以看到诸如数组、各种 CSS 操作和 Google Analytics 关键字等内容;基本上你创建的每个变量都存在于 Window 内:

在 Node 内部,我们有一个类似的东西叫做global,如下所示:

它不叫window,因为在 Node 中没有浏览器窗口,因此它被称为globalglobal对象存储了许多与window相同的东西。在下面的截图中,你可以看到一些可能很熟悉的方法,比如setTimeoutsetInterval

如果我们看一下这段代码的截图,我们会发现大部分东西都是在 window 中定义的,只有一些例外,如下面的截图所示:

现在,在 Chrome 浏览器中,我也可以访问document

document对象在 Node 网站中存储了对文档对象模型DOM)的引用。document对象显示了我在浏览器视口中的内容,如下面的截图所示:

我可以更改文档以更新在浏览器视口中显示的内容。当然,在 Node 中我们没有这个 HTML document,但我们有类似的东西,叫做process。你可以通过从 Node 运行 process 来查看它,在下面的截图中,我们有关于正在执行的特定 Node 进程的大量信息:

这里还有一些方法可以关闭当前的 Node 进程。我想让你运行process.exit命令,并将数字零作为参数传入,表示退出时没有错误:

process.exit(0);

当我运行这个命令时,你可以看到我现在回到了命令提示符,如下面的截图所示:

我已经离开了 Node,现在可以运行任何常规的命令提示符命令,比如检查我的 Node 版本。我可以通过运行node随时重新进入 Node,并且可以通过两次按下control + C来离开,而不使用process.exit命令。

现在,我又回到了我的常规命令提示符。所以,这些是显而易见的差异,在浏览器中你有可视区域,window 变成了 global,而 document 基本上变成了 process。当然,这是一个概括,但这些是一些大的变化。我们将在整本书中探索所有细微之处。

现在,当有人问你什么是 Node 时,你可以说Node 是一个使用 V8 引擎的 JavaScript 运行时。当他们问你 V8 引擎是什么时,你可以说V8 引擎是一个用 C++编写的开源 JavaScript 引擎,它接受 JavaScript 代码并将其编译成机器代码。它被用在 Node.js 内部,也被用在 Chrome 浏览器中

为什么使用 Node

在本节中,我们将探讨 Node.js 背后的原因。为什么它在创建后端应用方面如此出色?为什么像 Netflix、Uber 和 Walmart 这样的公司正在越来越多地使用 Node.js 在生产中?

正如你可能已经注意到的,由于你正在学习这门课程,当人们想要学习一门新的后端语言时,他们越来越多地转向 Node 作为他们想要学习的语言。Node 技能组合需求很高,无论是需要每天使用 Node 来编译他们的应用程序的前端开发人员,还是使用 Node.js 创建应用程序和实用程序的工程师。所有这些都使 Node 成为了首选的后端语言。

现在,如果我们看一下 Node 的主页,我们会发现三个句子,如下面的截图所示:

在上一节中,我们解释了第一个句子。我们看了 Node.js 是什么。图片中只有三个句子,所以在本节中,我们将看一下后面的两个句子。我们现在来读一下,然后我们将分解它,学习 Node 为什么如此出色。

第一句话,Node.js 使用事件驱动的、非阻塞 I/O 模型,使其轻量高效;我们现在将探索所有这些。第二句话,我们将在本节结束时探讨——Node.js 的打包生态系统 npm 是世界上最大的开源库生态系统。现在,这两句话中包含了大量的信息。

我们将介绍一些代码示例,深入研究一些图表和图形,探讨 Node 的不同之处以及它的优点。

Node 是一个事件驱动的、非阻塞的语言。那么,什么是 I/O?I/O 是您的应用程序一直在做的事情。当您读取或写入数据库时,这就是 I/O,它是输入/输出的缩写。

这是您的 Node 应用程序与物联网中其他事物的通信。这可能是数据库读写请求,您可能正在更改文件系统中的一些文件,或者您可能正在向单独的 Web 服务器发出 HTTP 请求,例如 Google API,以获取用户当前位置的地图。所有这些都使用 I/O,而 I/O 需要时间。

非阻塞 I/O 非常好。这意味着当一个用户从 Google 请求 URL 时,其他用户可以请求数据库文件读写访问,他们可以请求各种各样的事情,而不会阻止其他人完成一些工作。

阻塞和非阻塞软件开发

让我们继续看看阻塞和非阻塞软件开发之间的区别:

在前面的截图中,我有两个将要执行的文件。但在进行之前,首先让我们探索每个文件的操作方式,以及完成程序所需的步骤。

这将帮助我们了解阻塞和非阻塞之间的重大差异,我在图像的左侧显示了阻塞,这不是 Node 使用的方式,而非阻塞在右侧,这正是我们书中所有 Node 应用程序的运行方式。

您不必了解诸如 require 之类的具体细节,才能理解这个代码示例中发生了什么。我们将以非常一般的方式来分解事物。每个代码的第一行负责获取一个被调用的函数。这个函数将是我们模拟的 I/O 函数,它将去数据库,获取一些用户数据并将其打印到屏幕上。

请参考前面的代码图像。在我们加载函数之后,两个文件都尝试使用 ID 为123的用户。当它获取到该用户时,首先打印user1字符串到屏幕上,然后继续获取 ID 为321的用户,并将其打印到屏幕上。最后,两个文件都将1 + 2相加,将结果 3 存储在sum变量中,并将其打印到屏幕上。

尽管它们都做同样的事情,但它们的方式却大不相同。让我们逐步分解各个步骤。在下面的代码图像中,我们将介绍 Node 执行的内容以及所需的时间:

您可以考虑前面截图中显示的秒数;这并不重要,只是为了显示两个文件之间的相对操作速度。

阻塞 I/O 的工作方式

阻塞示例可以如下所示:

在我们的阻塞示例中,首先发生的事情是我们在代码的第 3 行获取用户:

var user1 = getUserSync('123');

现在,这个请求需要我们去数据库,这是一个 I/O 操作,需要一点时间。在我们的例子中,我们将说它需要三秒。

接下来,在代码的第 4 行,我们将用户打印到屏幕上,这不是一个 I/O 操作,它会立即运行,将user1打印到屏幕上,如下图所示:

console.log('user1', user1); 

正如你在下面的屏幕截图中所看到的,这几乎不需要时间:

接下来,我们等待获取user2

var user2 = getUserSync('321');

user2返回时,正如你所期望的那样,我们将其打印到屏幕上,这正是第 7 行发生的事情:

console.log('user2', user2);

最后,我们将数字相加并将其打印到屏幕上:

var sum = 1 + 2; 
console.log('The sum is ' + sum); 

这些都不是 I/O 操作,所以在这里,我们的总和几乎立即打印到屏幕上。

这就是阻塞的工作原理。它被称为阻塞,因为当我们从数据库获取数据时,也就是进行 I/O 操作时,我们的应用程序无法做其他任何事情。这意味着我们的机器会空闲地等待数据库的响应,甚至不能做一些简单的事情,比如将两个数字相加并将它们打印到屏幕上。在阻塞系统中这是不可能的。

工作中的非阻塞 I/O

在我们的非阻塞示例中,这就是我们将构建我们的 Node 应用程序的方式。

让我们逐行分解这个代码示例。首先,事情的开始方式与我们在阻塞示例中讨论的方式非常相似。我们将为user1启动getUser函数,这正是我们之前所做的:

但我们并没有等待,我们只是启动了那个事件。这都是 Node.js 内部事件循环的一部分,我们将会详细探讨这个问题。

请注意,这需要一点时间;我们只是开始请求,我们并没有等待数据。我们接下来要做的可能会让你感到惊讶。我们没有将user1打印到屏幕上,因为我们仍在等待该请求返回,而是开始获取 ID 为321user2的过程:

在代码的这一部分中,我们启动了另一个事件,这需要一点时间来完成-这不是一个 I/O 操作。现在,在幕后,数据库的获取是 I/O 操作,但启动事件,调用这个函数并不是,所以它会非常快速地发生。

接下来,我们打印总和。总和与这两个用户对象无关。它们基本上没有关联,所以在打印sum变量之前,我们不需要等待用户返回,如下面的屏幕截图所示:

打印总和之后会发生什么?嗯,我们有点线框,如下面的屏幕截图所示:

这个框表示我们的事件得到响应所需的模拟时间。现在,这个框的宽度与阻塞示例的第一部分(等待 user1)中的框完全相同,如下所示:

使用非阻塞并不会使我们的 I/O 操作变得更快,但它可以让我们同时运行多个操作。

在非阻塞的例子中,我们在半秒钟之前启动了两个 I/O 操作,在三秒半之间,两者都返回,如下面的屏幕截图所示:

现在,结果是整个应用程序完成得更快。如果比较执行这两个文件所花费的时间,非阻塞版本在三秒多一点结束,而阻塞版本则需要六秒多一点。相差 50%。这 50%来自于阻塞中我们有两个请求,每个请求需要三秒,而在非阻塞中,我们有两个请求,每个请求需要三秒,但它们同时运行。

使用非阻塞模式,我们仍然可以做一些事情,比如打印总和,而不必等待数据库回应。现在,这就是两者之间的重大区别;阻塞,一切按顺序发生,在非阻塞中,我们启动事件,附加回调,这些回调稍后被触发。我们仍然打印出user1user2,只是当数据返回时才这样做,因为数据不会立即返回。

在 Node.js 中,事件循环会为事件附加一个监听器,比如数据库回应完成。当它完成时,在非阻塞情况下调用你传递的回调函数,然后我们将其打印到屏幕上。

现在,想象一下这是一个网页服务器,而不是前面的例子。这意味着如果一个网页服务器来查询数据库,我们不能处理其他用户的请求而不启动一个单独的线程。现在,Node.js 是单线程的,这意味着你的应用程序在一个单一的线程上运行,但由于我们有非阻塞 I/O,这不是一个问题。

在阻塞的情况下,我们可以在两个单独的线程上处理两个请求,但这并不是很好扩展,因为对于每个请求,我们都必须增加应用程序使用的 CPU 和 RAM 资源的数量,而且这很糟糕,因为这些线程仍然处于空闲状态。仅仅因为我们可以启动其他线程并不意味着我们应该这样做,我们正在浪费没有做任何事情的资源。

在非阻塞的情况下,我们不是通过创建多个线程来浪费资源,而是在一个线程上做所有事情。当一个请求进来时,I/O 是非阻塞的,所以我们不会占用比根本没有发生更多的资源。

使用终端的阻塞和非阻塞示例

让我们实时运行这些示例,看看我们得到什么。我们有两个文件(blockingnon-blocking文件),我们在上一节中看到了。

我们将运行这两个文件,我正在使用 Atom 编辑器来编辑我的文本文件。这些是我们将在本节后面设置的东西,这只是为了让你看看,你不需要运行这些文件。

现在,blockingnon-blocking文件,都将被运行,并且它们将以不同的方式做与我们在上一节中所做的类似的事情。两者都使用 I/O 操作,getUserSyncgetUser,每个操作需要 5 秒。时间没有区别,只是它们执行的顺序使非阻塞版本快得多。

现在,为了模拟和展示工作原理,我将添加一些console.log语句,如下面的代码示例所示,console.log('starting user1')console.log('starting user2')

这将让我们看到终端内部的工作原理。通过运行node blocking.js,这就是我们运行文件的方式。我们输入node,然后指定文件名,如下面的代码所示:

 node blocking.js 

当我运行文件时,我们会得到一些输出。开始用户 1 打印到屏幕上,然后停在那里:

现在,我们有用户 1 对象打印到屏幕上,名字是 Andrew,并且开始用户 2 打印到屏幕上,如下面的代码输出所示:

之后,大约 5 秒后,用户 2 对象带着名字 Jen 回来。

如前面的屏幕截图所示,我们的两个用户已经打印到屏幕上,最后我们的总和,即 3,打印到屏幕上;一切都很顺利。

请注意,开始用户 1 立即后面就是用户 1 的完成,开始用户 2 立即后面就是用户 2 的完成,因为这是一个阻塞应用程序。

现在,我们将运行非阻塞文件,我称之为non-blocking.js。当我运行这个文件时,开始用户 1 打印,开始用户 2 打印,然后总和连续打印:

大约 5 秒后,基本上在同一时间,用户 1 和用户 2 都在屏幕上打印出来。

这就是非阻塞的工作原理。仅仅因为我们启动了一个 I/O 操作,并不意味着我们不能做其他事情,比如启动另一个操作并将一些数据打印到屏幕上,在这种情况下只是一个数字。这就是重大的区别,也是非阻塞应用程序如此出色的地方。它们可以在完全相同的时间做很多事情,而不必担心多线程应用程序的混乱。

让我们回到浏览器,再次查看 Node 网站上的那些句子:

Node.js 使用事件驱动的、非阻塞的 I/O 模型,使其轻量级和高效,我们在实际操作中看到了这一点。

因为 Node 是非阻塞的,我们能够将应用程序所需的时间减少了一半。这种非阻塞 I/O 使我们的应用程序非常快速,这就是轻量级和高效的作用所在。

Node 社区-解决问题的开源库

现在,让我们去看 Node 网站上的最后一句话,如下截图所示:

Node.js 的软件包生态系统 npm 是世界上最大的开源库生态系统。这正是使 Node 如此出色的地方。这是锦上添花-社区,每天都有人开发新的库,解决 Node.js 应用程序中的常见问题。

诸如验证对象、创建服务器以及使用套接字实时提供内容等事情。所有这些都已经有库构建好了,所以你不必担心这些。这意味着你可以专注于与你的应用程序相关的特定事物,而不必在你甚至写真正的代码之前创建所有这些基础设施,这些代码是针对你应用程序的特定用例的。

现在,npm 可以在npmjs.org上找到,这是我们将寻求许多第三方模块的网站:

如果你试图在 Node 中解决一个通用的问题,很有可能已经有人解决了。例如,如果我想验证一些对象,比如我想验证一个名字属性是否存在,以及是否有一个长度为三的 ID。我可以去谷歌或者去 npm;我通常选择谷歌,然后搜索npm validate object

当我谷歌搜索时,我只会寻找npmjs.com的结果,你可以发现前三个结果都来自那里:

我可以点击第一个,这将让我探索文档,看看它是否适合我:

这个看起来很不错,所以我可以毫不费力地将它添加到我的应用程序中。

现在,我们将通过这个过程。别担心,我不会让你不知所措地如何添加第三方模块。我们将在书中使用大量的第三方模块,因为这才是真正的 Node 开发者所做的。他们利用了出色的开发者社区,这也是使 Node 如此出色的最后一点。

这就是为什么 Node 能够达到当前的强大地位,因为它是非阻塞的,这意味着它非常适合 I/O 应用程序,并且有一个出色的开发者社区。因此,如果你想要完成任何事情,有可能已经有人编写了代码来完成它。

这并不是说你永远不应该再次使用 Rails 或 Python 或任何其他阻塞语言,这不是我的意思。我真正想向你展示的是 Node.js 的强大之处,以及你如何使你的应用程序变得更好。像 Python 这样的语言有一些库,比如旨在为 Python 添加非阻塞特性的 Twisted。尽管存在一个大问题,那就是所有的第三方库仍然是以阻塞方式编写的,所以你在使用哪些库方面受到了很大的限制。

由于 Node 是从头开始构建的非阻塞式,npmjs.com上的每个库都是非阻塞式的。所以你不必担心找到一个是非阻塞式的还是阻塞式的;你可以安装一个模块,知道它是从头开始使用非阻塞式思想构建的。

在接下来的几节中,你将编写你的第一个应用程序,并从终端运行它。

不同的文本编辑器用于节点应用程序

在这一节中,我想给你介绍一下你可以用来阅读本书的各种文本编辑器。如果你已经有一个你喜欢使用的,你可以继续使用你已经有的。在本书中,没有必要更换编辑器来完成任何工作。

现在,如果你没有一个,并且正在寻找一些选择,我总是建议使用Atom,你可以在atom.io找到它。它是免费的,开源的,并且可以在所有操作系统上使用,包括 Linux、macOS 和 Windows。它是由 GitHub 背后的人创建的,这是我在本书中将要使用的编辑器。有一个很棒的主题和插件开发社区,所以你真的可以根据自己的喜好进行定制。

除了 Atom 之外,还有一些其他选择。我听到很多人在谈论Visual Studio Code。它也是开源的,免费的,并且可以在所有操作系统上使用。如果你不喜欢 Atom,我强烈建议你试试这个,因为我听到很多好评。

接下来,我们总是有Sublime Text,你可以在sublimetext.com找到。现在,Sublime Text 并不是免费的,也不是开源的,但是很多人确实喜欢使用它。我更喜欢 Atom,因为它与 Sublime Text 非常相似,但我觉得它更快速、更容易使用,而且它是免费和开源的。

现在,如果你正在寻找一个更高级的编辑器,拥有所有 IDE 的功能,而不是一个文本编辑器,我总是推荐JetBrains。他们的产品都不是免费的,尽管它们都有 30 天的免费试用期,但它们确实是最好的工具。如果你发现自己处于公司环境中,或者你在一家公司愿意为编辑器付费的工作中,我总是建议你选择 JetBrains。他们的所有编辑器都配备了你所期望的所有工具,比如版本控制集成、调试工具和内置的部署工具。

所以,请花点时间,下载你想要使用的,玩弄一下,确保它符合你的需求,如果不符合,再尝试另一个。

Hello World - 创建和运行第一个 Node 应用程序

在这一节中,你将创建并运行你的第一个 Node 应用程序。嗯,它将是一个简单的应用程序。它将演示整个过程,从创建文件到从终端运行它们。

创建 Node 应用程序

第一步是创建一个文件夹。我们创建的每个项目都将放在自己的文件夹中。我将在 macOS 上打开Finder并导航到我的桌面。我希望你也能在你的操作系统上打开桌面,无论你是在 Linux、Windows 还是 macOS 上,并创建一个名为hello-world的全新文件夹。

我不建议在项目文件或文件夹名称中使用空格,因为这只会使在终端内导航变得更加混乱。现在,我们有了这个hello-world文件夹,我们可以在编辑器中打开它。

现在我将使用 command + O(Windows 用户为Ctrl + O)来打开,然后我将导航到桌面并双击我的 hello-world 文件夹,如下所示:

在左边,我有我的文件,没有。所以,让我们创建一个新的。我将在项目的根目录中创建一个新文件,我们将把它命名为app.js,如下所示:

这将是我们在 Node 应用程序中唯一的文件,而且在这个文件中,我们可以编写一些代码,当我们启动应用程序时,它将被执行。

在未来,我们将做一些疯狂的事情,比如初始化数据库和启动 Web 服务器,但现在我们将简单地使用console.log,这意味着我们正在访问控制台对象上的日志属性。这是一个函数,所以我们可以用括号调用它,然后我们将一个字符串作为一个参数传递进去,Hello world!。我会在末尾加上一个分号并保存文件,如下所示的代码:

console.log('Hello world!');

这将是我们运行的第一个应用程序。

现在,请记住,这门课程有一个基本的 JavaScript 要求,所以这里的任何东西对你来说都不应该太陌生。我将在这门课程中涵盖所有新鲜的内容,但基础知识,比如创建变量,调用函数,这些应该是你已经熟悉的。

运行 Node 应用程序

现在我们有了app.js文件,唯一剩下的事情就是运行它,我们将在终端中进行。现在,要运行这个程序,我们必须导航到我们的项目文件夹中。如果你对终端不熟悉,我会给你一个快速的复习。

你可以随时使用pwd在 Linux 或 macOS 上,或者在 Windows 上使用dir命令来查看你所在的位置。当你运行它时,你会看到类似于以下截图的内容:

我在Users文件夹中,然后我在我的用户文件夹中,我的用户名恰好是Gary

当你打开终端或命令提示符时,你将会在你的用户目录中开始。

我们可以使用cd进入桌面,就像这样:

现在我们坐在桌面上。你可以从计算机的任何地方运行另一个命令cd /users/Gary/desktop。这将导航到你的桌面,无论你位于哪个文件夹。命令cd desktop要求你在用户目录中才能正确工作。

现在我们可以通过 cd 进入我们的项目目录,我们称之为hello-world,如下命令所示:

cd hello-world

通过以下截图:

一旦我们在这个目录中,我们可以在 Linux 或 Mac 上运行ls命令(在 Windows 上是dir命令)来查看我们所有的文件,而在这种情况下,我们只有一个,我们有app.js

这是我们将要运行的文件。

现在,在你做任何其他事情之前,请确保你在hello-world文件夹中,并且你应该有app.js文件。如果有的话,我们要做的就是运行node命令,后面跟一个空格,这样我们就可以传入一个参数,那个参数就是文件名app.js,如下所示:

node app.js

一旦你准备好了,按下enter,然后我们就可以看到,Hello world!打印到屏幕上,如下所示:

这就是创建和运行一个非常基本的 Node 应用程序所需的全部步骤。虽然我们的应用程序没有做任何酷炫的事情,但我们将在整本书中使用这个创建文件夹/文件并在终端中运行它们的过程,所以这是我们开始制作真实世界 Node 应用程序的一个很好的开始。

总结

在本章中,我们接触了 Node.js 的概念。我们看了一下 Node 是什么,我们了解到它是建立在 V8 JavaScript 引擎之上的。然后我们探讨了为什么 Node 变得如此流行,它的优势和劣势。我们看了一下我们可以选择的不同文本编辑器,最后,你创建了你的第一个 Node 应用程序。

在下一章中,我们将深入并创建我们的第一个应用程序。我真的很兴奋开始编写真实世界的应用程序。

第二章:Node 基础-第一部分

在本章中,你将学到很多关于构建 Node 应用的知识,你将实际上构建你的第一个 Node 应用程序。这将是真正有趣的开始。

我们将开始学习所有内置到 Node 中的模块。这些是让你能够以前从未能够做到的 JavaScript 的对象和函数。我们将学习如何做一些事情,比如读写文件系统,这将在 Node 应用程序中用来持久化我们的数据。

我们还将研究第三方 npm 模块;这是 Node 变得如此受欢迎的一个重要原因。npm 模块为你提供了一个很好的第三方库集合,你可以使用它们,它们也有非常常见的问题。因此,你不必一遍又一遍地重写那些样板代码。在本章中,我们将使用第三方模块来帮助获取用户输入。

本章将专门涵盖以下主题:

  • 模块基础

  • 引入自己的文件

  • 第三方模块

  • 全局模块

  • 获取输入

模块基础

在本节中,你将最终学习一些 Node.js 代码,我们将以讨论 Node 中的模块开始。模块是功能单元,所以想象一下,我创建了一些做类似事情的函数,比如一些帮助解决数学问题的函数,例如加法、减法和除法。我可以将它们捆绑成一个模块,称之为 Andrew-math,其他人可以利用它。

现在,我们不会讨论如何制作我们自己的模块;事实上,我们将讨论如何使用模块,这将使用 Node 中的一个函数require()来实现。require()函数将让我们做三件事:

  • 首先,它让我们加载 Node.js 捆绑的模块。这些包括 HTTP 模块,它让我们创建一个 Web 服务器,以及fs模块,它让我们访问机器的文件系统。

我们还将在后面的部分中使用require()来加载第三方库,比如 Express 和 Sequelize,这将让我们编写更少的代码。

  • 我们将能够使用预先编写的库来处理复杂的问题,我们所需要做的就是通过调用一些方法来实现require()

  • 我们将使用require()来引入我们自己的文件。它将让我们将应用程序分解为多个较小的文件,这对于构建真实世界的应用程序至关重要。

如果你的所有代码都在一个文件中,测试、维护和更新将会非常困难。现在,require()并不那么糟糕。在本节中,我们将探讨require()的第一个用例。

使用require()的情况

我们将看一下两个内置模块;我们将弄清楚如何引入它们和如何使用它们,然后我们将继续开始构建那个 Node 应用程序的过程。

应用程序的初始化

我们在终端中的第一步是创建一个目录来存储所有这些文件。我们将使用cd Desktop命令从我们的主目录导航到桌面:

cd Desktop

然后,我们将创建一个文件夹来存储这个项目的所有课程文件。

现在,这些课程文件将在每个部分的资源部分中提供,因此如果你遇到困难,或者你的代码出了问题,你可以下载课程文件,比较你的文件,找出问题所在。

现在,我们将使用mkdir命令来创建那个文件夹,这是make directory的缩写。让我们将文件夹命名为notes-node,如下所示:

mkdir notes-node

我们将在 Node 中制作一个笔记应用,所以notes-node似乎很合适。然后我们将cd进入notes-node,然后我们可以开始玩一些内置模块:

cd notes-node

这些模块是内置的,所以不需要在终端中安装任何东西。我们可以直接在我们的 Node 文件中引入它们。

在这个过程中的下一步是打开 Atom 文本编辑器中的那个目录。所以打开我们刚刚在桌面上创建的目录,你会在那里找到它,如下面的截图所示:

现在,我们需要创建一个文件,并将该文件放在项目的根目录中:

我们将把这个文件命名为app.js,这是我们应用程序的起点:

我们将编写其他在整个应用程序中使用的文件,但这是我们唯一会从终端运行的文件。这是我们应用程序的初始化文件。

使用 require()的内置模块

现在,为了开始,我将首先使用console.log打印Starting app,如下面的代码所示:

console.log('Starting app');

我们这样做的唯一原因是为了跟踪我们的文件如何执行,我们只会在第一个项目中这样做。在以后,一旦你熟悉了文件的加载和运行方式,我们就可以删除这些console.log语句,因为它们将不再必要。

在调用console.log开始应用程序之后,我们将使用require()加载一个内置模块。

我们可以在 Node.js API 文档中获得所有内置模块的完整列表。

要查看 Node.js API 文档,请转到nodejs.org/api。当你访问这个 URL 时,你会看到一个很长的内置模块列表。使用文件系统模块,我们将创建一个新文件和OS模块。OS 模块将让我们获取当前登录用户的用户名等信息。

在文件系统模块中创建和追加文件

不过,首先我们将从文件系统模块开始。我们将逐步介绍如何创建文件并追加内容:

当你查看内置模块的文档页面时,无论是文件系统还是其他模块,你都会看到一个很长的列表,列出了你可以使用的所有不同函数和属性。在本节中,我们将使用的是fs.appendFile

如果你点击它,它会带你到具体的文档页面,这是我们可以找出如何使用appendFile的地方,如下面的截图所示:

现在,appendFile非常简单。我们将向它传递两个字符串参数(如前面的截图所示):

  • 一个是文件名

  • 另一个是我们想要追加到文件中的数据

这是我们调用fs.appendFile所需要提供的全部内容。在我们调用fs.appendFile之前,我们需要先引入它。引入的整个目的是让我们加载其他模块。在这种情况下,我们将从app.js中加载fs模块。

让我们创建一个变量,使用const来定义它。

由于我们不会操纵模块返回的代码,所以不需要使用var关键字;我们将使用const关键字。

然后我们会给它一个名字,fs,并将其设置为require(),如下面的代码所示:

const fs = require()

在这里,require()是一个可以在任何 Node.js 文件中使用的函数。你不需要做任何特殊的事情来调用它,只需要像前面的代码中所示的那样调用它。在参数列表中,我们只需要传入一个字符串。

现在,每次调用require()时,无论是加载内置模块、第三方模块还是你自己的文件,你只需要传入一个字符串。

在我们的例子中,我们将传入模块名fs,并在末尾加上一个分号,如下面的代码所示:

const fs = require('fs');

这将告诉 Node,你想要获取fs模块的所有内容,并将它们存储在fs变量中。此时,我们可以访问fs模块上的所有可用函数,包括fs.appendFile,我们在文档中探索过。

回到 Atom,我们可以通过调用 fs.appendFile 来调用 appendFile,传入我们将使用的两个参数;第一个将是文件名,所以我们添加 greetings.txt,第二个将是你想要追加到文件中的文本。在我们的例子中,我们将追加 Hello world!,如下面的代码所示:

fs.appendFile('greetings.txt', 'Hello world!');

让我们保存文件,如上面的命令所示,并从终端运行它,看看会发生什么。

在 Node v7 上运行程序时的警告 如果你在 Node v7 或更高版本上运行,当你在终端内运行程序时会收到一个小警告。现在,在 v7 上,它仍然可以工作,只是一个警告,但你可以使用以下代码来摆脱它:

// Orignal line 
fs.appendFile('greetings.txt', 'Hello world!');

// Option one
fs.appendFile('greetings.txt', 'Hello world!', function (err){
  if (err) { 
    console.log('Unable to write to file');
  }
});

// Option two
fs.appendFileSync('greetings.txt', 'Hello world!');

在上面的代码中,我们有我们程序中的原始行。

在这里的 Option one 是将回调添加为追加文件的第三个参数。当发生错误或文件成功写入时,此回调将被执行。在选项一中,我们有一个 if 语句;如果有错误,我们只是在屏幕上打印一条消息 Unable to write to file

现在,在上面的代码中,我们的第二个选项 Option two 是调用 appendFileSync,这是一个同步方法(我们稍后会详细讨论);这个函数不需要第三个参数。你可以像上面的代码中所示那样输入它,你就不会收到警告。

因此,如果你看到警告,选择其中一种选项,两者都可以工作得差不多。

如果你使用的是 v6,你可以坚持使用上面代码中的原始行,尽管你可能会使用下面这两个选项之一来使你的代码更具未来性。

不要担心,我们将在整本书中广泛讨论异步和同步函数,以及回调函数。我在代码中给你的只是一个模板,你可以在你的文件中写下来以消除错误。在几章中,你将准确理解这两种方法是什么,以及它们是如何工作的。

如果我们在终端中进行追加,node app.js,我们会看到一些很酷的东西:

如前面的代码所示,我们得到了我们的一个 console.log 语句,Starting app.。所以我们知道应用程序已经正确启动了。此外,如果我们转到 Atom,我们实际上会看到一个全新的 greetings.txt 文件,如下面的代码所示。这是由 fs.appendFile 创建的文本文件:

console.log('Starting app.');

const fs = require('fs');

fs.appendFile('greetings.txt', 'Hello world!');

在这里,fs.appendFile 尝试将 greetings.txt 追加到一个文件中;如果文件不存在,它就会简单地创建它:

你可以看到我们的消息 Hello world!greetings.txt 文件中打印到屏幕上。在短短几分钟内,我们就能够加载一个内置的 Node 模块并调用一个函数,让我们创建一个全新的文件。

如果我们再次调用它,通过使用上箭头键和回车键重新运行命令,并回到 greetings.txt 的内容,你会看到这一次我们有两次 Hello world!,如下所示:

它每次运行程序都会追加 Hello world! 一次。我们有一个应用程序,在我们的文件系统上创建一个全新的文件,如果文件已经存在,它就会简单地添加到它。

在 require()中的 OS 模块

一旦我们创建并追加了 greetings.txt 文件,我们将自定义这个 greeting.txt 文件。为了做到这一点,我们将探索另一个内置模块。我们将在未来使用不仅仅是 appendFile。我们将探索其他方法。对于本节,真正的目标是理解 require()require() 函数让我们加载模块的功能,以便我们可以调用它。

我们将使用的第二个模块是 OS,我们可以在文档中查看它。在 OS 模块中,我们将使用在最底部定义的方法,os.userInfo([options]):

os.userInfo([options])方法被调用并返回有关当前登录用户的各种信息,例如用户名,这就是我们要提取的信息:

使用来自操作系统的用户名,我们可以自定义greeting.txt文件,以便它可以说Hello Gary!而不是Hello world!

要开始,我们必须要求 OS。这意味着我们将回到 Atom 内部。现在,在我创建fs常量的下面,我将创建一个名为os的新常量,将其设置为require(); 这作为一个函数调用,并传递一个参数,模块名称os,如下所示:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

fs.appendFile('greetings.txt', 'Hello world!');

从这里开始,我们可以开始调用 OS 模块上可用的方法,例如 os.userInfo([optional])。

让我们创建一个名为user的新变量来存储结果。变量 user 将被设置为os.userInfo,我们可以调用userInfo而不带任何参数:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();

fs.appendFile('greetings.txt', 'Hello world!');

现在,在我们对fs.appendFile行执行任何操作之前,我将对其进行注释,并使用console.log打印用户变量的内容:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();
console.log(user);
// fs.appendFile('greetings.txt', 'Hello world!');

这将让我们准确地探究我们得到了什么。在终端中,我们可以使用上箭头键和回车键重新运行我们的程序,并且在下面的代码中,你可以看到我们有一个带有一些属性的对象:

我们有uidgidusernamehomedirshell。根据您的操作系统,您可能不会拥有所有这些,但您应该始终拥有username属性。这是我们关心的。

这意味着回到 Atom 内部,我们可以在appendFile中使用user.username。我将删除console.log语句并取消注释我们对fs.appendFile的调用:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();

fs.appendFile('greetings.txt', 'Hello world!');

现在,在fs.appendFile中的world处,我们将其与user.username交换。我们可以以两种方式做到这一点。

连接用户.username

第一种方法是删除world!并连接user.username。然后我们可以使用+(加号)运算符连接另一个字符串,如下面的代码所示:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();

fs.appendFile('greetings.txt', 'Hello' + user.username + '!');

现在,如果我们运行这个,一切都会按预期工作。在终端中,我们可以重新运行我们的应用程序。它会打印Starting app

greetings.txt文件中,你应该看到类似Hello Gary!的东西打印到屏幕上,如下所示:

使用fs模块和os模块,我们能够获取用户的用户名,创建一个新文件并存储它。

使用模板字符串

第二种方法是使用 ES6 功能模板字符串来交换fs.appendFile中的worlduser.username。模板字符串以`(对勾)运算符开头和结尾,位于键盘上1键的左侧。然后你像平常一样打字。

这意味着我们首先输入hello,然后我们会用!(感叹号)标记添加一个空格,在!之前,我们会放置名字:

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello !`);

要在模板字符串中插入 JavaScript 变量,你需要使用$(美元)符号,后面跟上大括号。然后我们将引用一个变量,比如user.username

console.log('Starting app.');

const fs = require('fs');
const os = require('os');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello ${user.username}!`);

请注意,Atom 编辑器实际上可以识别出大括号的语法。

这就是使用模板字符串所需要的。它是一个 ES6 功能,因为你使用的是 Node v6。这种语法比我们先前看到的字符串/串联版本容易理解和更新。

如果你运行这段代码,它将产生完全相同的输出。我们可以运行它,查看文本文件,这一次我们有两次Hello Gary!,这正是我们想要的:

有了这个配置,我们现在已经完成了我们非常基础的示例,并准备在下一节中开始创建我们的笔记应用程序的文件并在app.js中要求它们。

首先,你已经学到了我们可以使用require来加载模块。这让我们可以使用 Node 开发者、第三方库或者自己编写的现有功能,并将其加载到文件中,以便可以重复使用。创建可重复使用的代码对于构建大型应用程序至关重要。如果每次都必须在应用程序中构建所有内容,那么没有人会有所作为,因为他们会被困在构建基础设施上,比如 HTTP 服务器和 Web 服务器等。这些东西已经有模块了,我们将利用 npm 社区的伟大作用。在这种情况下,我们使用了两个内置模块,fsos。我们使用require将它们加载进来,并将模块结果存储在两个变量中。这些变量存储了模块中提供给我们的所有内容;在fs的情况下,我们使用appendFile方法,而在 OS 的情况下,我们使用userInfo方法。一起,我们能够获取用户名并将其保存到文件中,这太棒了。

要求自己的文件

在本节中,你将学习如何使用require()来加载项目中创建的其他文件。这将让你将函数从app.js移到更具体的文件中;这将使你的应用程序更容易扩展、测试和更新。要开始,我们要做的第一件事就是创建一个新文件。

创建一个新文件来加载其他文件

在我们的笔记应用程序的上下文中,新文件将存储各种用于编写和阅读笔记的函数。目前,你不需要担心该功能,因为我们稍后将详细介绍,但我们将创建文件,它最终将存放在那里。这个文件将是notes.js,我们将把它保存在应用程序的根目录下,就在app.jsgreetings.txt旁边,如下所示:

目前,我们在notes中所做的就是使用console.log打印一小段日志,显示文件已经被执行,使用以下代码:

console.log('Starting notes.js');

现在,我们在notes的顶部和app.js的顶部都有了console.log。我将把app.js中的console.logStarting app.更改为Starting app.js。有了这个配置,我们现在可以 require notes 文件。它没有导出任何功能,但没关系。

顺便说一下,当我说导出时,我指的是 notes 文件没有任何其他文件可以利用的函数或属性。

我们将在后面的部分讨论如何导出东西。不过,目前我们将以与加载内置 Node 模块相同的方式加载我们的模块。

让我们创建const;我会将其命名为 notes,将其设置为从require()返回的结果:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello ${user.username}!`);

在括号内,我们将传入一个参数,这个参数将是一个字符串,但它会有一点不同。在之前的部分中,我们键入了模块名称,但在这种情况下,我们拥有的不是一个模块,而是一个文件,notes.js。我们需要做的是告诉 Node 文件的位置,使用相对路径。

现在,相对路径以./(点斜杠)开头,指向文件所在的当前目录。在这种情况下,这将指向我们的项目根目录notes-nodeapp.js目录。从这里开始,我们不必进入任何其他文件夹来访问notes.js,它就在我们项目的根目录中,所以我们可以输入它的名称,如下面的代码所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello ${user.username}!`);

有了这个配置,当我们保存app.js并运行我们的应用程序时,我们就可以看到发生了什么。我将使用node app.js命令运行应用程序:

如前面的代码输出所示,我们得到了两个日志。首先,我们得到了Starting app.js,然后我们得到了Starting notes.js。现在,Starting notes.js来自于note.js文件,并且它只能执行,因为我们在app.js内部需要了这个文件。

app.js文件中注释掉这条命令行,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
// const notes = require('./notes.js');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello ${user.username}!`);

保存文件,并从终端重新运行它;您可以看到notes.js文件从未被执行,因为我们从未明确地触摸它。

我们从来没有像前面的示例那样在终端中调用它,并且我们也从未 require 过。

目前,我们将需要 require 它,所以我将取消注释。

顺便说一下,我使用命令/(斜线)来快速注释和取消注释行。这是大多数文本编辑器中可用的键盘快捷键;如果您使用的是 Windows 或 Linux,它可能不是command,可能是Ctrl或其他内容。

从 notes.js 中导出文件以在 app.js 中使用

现在,焦点将是从notes.js中导出东西,我们可以在app.js中使用。在notes.js内部(实际上,在我们所有的 Node 文件中),我们可以访问一个名为module的变量。我会用console.log来将module打印到屏幕上,这样我们就可以在终端中探索它,如下所示:

console.log('Starting notes.js');

console.log(module);

让我们重新运行文件来探索它。如下截图所示,我们得到了一个相当大的对象,即与notes.js文件相关的不同属性:

现在,说实话,我们将不会使用大部分这些属性。我们有诸如idexportsparentfilename之类的东西。在本书中,我们唯一会使用的属性是exports

exports对象位于module属性上,该对象上的一切都会被导出。此对象会被设置为const变量notes。这意味着我们可以在其上设置属性,它们将被设置在 notes 上,并且我们可以在app.js内部使用它们。

exports 对象工作的一个简单示例

让我们快速看看它是如何工作的。我们将定义一个age属性使用module.exports,刚刚在终端中探索过的对象。我们知道这是一个对象,因为我们在之前的截图中可以看到(exports: {});这意味着我可以添加一个属性age,并将其设置为我的年龄25,如下所示:

console.log('Starting notes.js');

module.exports.age = 25;

然后我可以保存这个文件并移动到app.js利用这个新的age属性。在当前情况下,const变量 notes 将存储我所有的输出,现在只有 age。

fs.appendFile中,greeting.txt文件后面,我将添加You are,然后是年龄。在模板字符串内,我们将使用$和花括号,notes.age,以及末尾的句号,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

var user = os.userInfo();

fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

现在我们的问候应该是Hello Gary! You are 25。它得到了我们单独文件(即note.js)中的25值,这太棒了。

让我们花点时间使用上箭头键和回车键在终端重新运行程序:

回到应用程序内部,我们可以打开greetings.txt,如下截图所示,我们有Hello Gary! You are 25

使用require(),我们能够引入一个我们创建的文件,这个文件存储了对项目其他部分有利的一些属性。

导出函数

很明显,上面的例子是相当刻意的。我们不会导出静态数字;导出的真正目的是能够导出在app.js内部使用的函数。让我们花点时间导出两个函数。在notes.js文件中,我将设置module.exports.addnote等于一个函数;function关键字后跟随圆括号,然后是花括号:

console.log('Starting notes.js');

module.exports.addNote = function () {

} 

现在,在整个课程中,我将尽可能使用箭头函数,如前面的代码所示。要将常规的 ES5 函数转换为箭头函数,你只需删除function关键字,然后在括号和开放花括号之间用=>符号替换,如下所示:

console.log('Starting notes.js');

module.exports.addNote = () => {

} 

现在,箭头函数还有一些更微妙的地方需要在整本书中讨论,但如果你有一个匿名函数,你可以毫不费力地用箭头函数代替。主要区别在于箭头函数不会绑定() => {}关键字或参数数组,这是我们将在整本书中探讨的。所以如果你遇到一些错误,知道箭头函数可能是引起错误的原因是很好的。

不过目前,我们将保持事情非常简单,使用console.log来打印addNote。这将让我们知道addNote函数已被调用。我们将返回一个字符串,'New note',如下所示:

console.log('Starting notes.js');

module.exports.addNote = () => {
  console.log('addNote');
  return 'New note';
};

现在,addNote函数在notes.js中被定义了,但我们可以在app.js中利用它。

让我们快速地注释掉app.js中的appendFile和用户行:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

我将添加一个变量,称为结果,(简称res),并将其设置为notes.addNote的返回结果:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

var res = notes.addNote();

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

现在,addNote函数目前只是一个虚拟函数。它不需要任何参数,也实际上什么也不做,所以我们可以无需任何参数地调用它。

然后我们将打印结果变量,如下面的代码所示,我们期望结果变量等于字符串New note

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

var res = notes.addNote();
console.log(res);

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

如果我保存我的两个文件(app.jsnotes.js),然后在终端重新运行,你会看到New note打印到屏幕最后并在addNote之前打印:

这意味着我们成功地引入了我们称为addNote的笔记文件,并且它的返回结果成功地返回给了app.js

使用这个确切的模式,我们将能够在我们的notes.js文件中定义添加和删除笔记的函数,但我们将能够在我们的应用程序内的任何地方调用它们,包括在app.js中。

练习 - 在导出对象中添加一个新函数

现在是时候进行一个快速的挑战了。我想让你在notes.js中创建一个名为add的新函数。这个add函数将被设置在exports对象上。

记住,exports是一个对象,所以你可以设置多个属性。

这个add函数将接受两个参数ab;它会将它们相加并返回结果。然后在app.js中,我想让你调用add函数,传入两个你喜欢的数字,比如9-2,然后将结果打印到屏幕上并确保它正常工作。

你可以开始移除对addNote的调用,因为在这个挑战中将不再需要它。

所以,请花一点时间,在notes.js内创建add函数,在app.js内调用它,并确保正确的结果打印到屏幕上。进行得如何?希望你能够创建该函数并从app.js中调用它。

练习的解决方案

过程中的第一步是定义新函数。在notes.js中,我将module.exports.add设置为该函数,如下所示:

console.log('Starting notes.js');

module.exports.addNote = () => {
  console.log('addNote');
  return 'New note';
}; 

module.exports.add =

让我们将其等于箭头函数。如果你使用普通函数,那完全没问题,我只是更喜欢在我可以的时候使用箭头函数。此外,在括号内,我们将会有两个参数,我们将得到ab,就像这里展示的一样:

console.log('Starting notes.js');

module.exports.addNote = () => {
  console.log('addNote');
  return 'New note';
}; 

module.exports.add = (a, b) => {

};

我们需要做的只是返回结果,这非常简单。所以我们将输入return a + b

console.log('Starting notes.js');

module.exports.addNote = () => {
  console.log('addNote');
  return 'New note';
}; 

module.exports.add = (a, b) => {
  return a + b;
};

现在,这是你的挑战的第一部分,在notes.js中定义一个实用函数;第二部分是实际在app.js中使用它。

app.js中,我们可以通过打印带有冒号:console.log结果来使用我们的函数(这只是为了格式化)。作为第二个参数,我们将打印实际结果,notes.add。然后,我们将两个数字相加;我们将加上9-2,就像这段代码展示的那样:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const notes = require('./notes.js');

console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

在这种情况下,结果应该是7。如果我们运行程序,你可以看到,我们得到了7,它打印到屏幕上:

如果你能理解这个,恭喜你,你成功完成了你的第一个挑战。这些挑战将分布在整本书中,并且会变得越来越复杂。但不要担心,我们会将挑战描述得很明确;我会告诉你我想要什么,以及我想要它如何完成。现在,你可以尝试不同的方法去做,真正的目标是让你能够独立编写代码,而不是跟随他人的步伐。这才是真正的学习过程。

在下一节中,我们将探讨如何使用第三方模块。从那里开始,我们将开始构建笔记应用程序。

第三方模块

你现在已经知道了使用require()的三种方式中的两种,在本节中,我们将探索最后一种方式,即要求你从 npm 安装的软件包中获取。正如我在第一章中提到的,npm 是 Node 变得如此奇妙的重要部分。有一个庞大的开发者社区已经创建了成千上万的软件包,已经解决了 Node 应用程序中一些最常见的问题。我们将在整本书中利用相当多的软件包。

使用 npm 模块创建项目

现在,在 npm 软件包中,没有什么神奇的,这是普通的 Node 代码,旨在解决特定的问题。你想要使用它的原因是,这样你就不必花费所有时间编写这些已经存在的实用函数;它们不仅存在,而且已经经过测试,已经被证明有效,而且其他人已经使用它们并记录了它们。

现在,这么多话说了,我们应该如何开始呢?好吧,要开始,我们实际上必须从终端运行一个命令,告诉我们的应用程序我们想要使用 npm 模块。这个命令将在终端上运行。确保你已经进入了你的项目文件夹,并且在notes-node目录中。当你安装了 Node 时,你也安装了一个叫做 npm 的东西。

有一段时间,npm 代表Node 包管理器,但那现在是一个笑话,因为有很多东西在 npm 上并不特定于 Node。许多前端框架,如 jQuery 和 react,现在也存在于 npm 上,所以他们几乎抛弃了 Node 包管理器的解释,在他们的网站上,现在他们循环播放一堆与 npm 相匹配的滑稽事情。

我们将运行一些 npm 命令,你可以通过运行npm,一个空格,和-v(我们正在用v标志运行 npm)。这将打印版本,如下面的代码所示:

如果你的版本略有不同,也没有关系;重要的是你已经安装了 npm。

现在,我们将在终端中运行一个名为npm init的命令。这个命令将提示我们回答关于我们的 npm 项目的一些问题。我们可以运行这个命令,并且可以按照下面的截图循环回答问题:

在上述截图中,顶部是正在发生的事情的快速描述,下面将开始提出一些问题,如下面的截图所示:

这些问题包括以下内容:

  • 名称:你的名称不能包含大写字符或空格;你可以使用notes-node,例如。你可以按“回车”使用默认值,括号中就是默认值。

  • 版本:1.0.0 也可以正常工作;我们将大多数设置保留在默认值。

  • 描述:我们暂时可以将其保留为空。

  • 入口点:这将是app.js,确保它正确显示。

  • 测试命令:我们将在本书的后面探索测试,所以现在可以将其保留为空。

  • git 仓库:我们现在也将其保留为空。

  • 关键词:这些用于搜索模块。我们不会发布这个模块,所以可以将其保留为空。

  • 作者:你可能会输入你的名字。

  • 许可证:对于许可证,我们暂时将使用 ISC;因为我们不打算发布它,所以这并不重要。

回答了这些问题后,如果我们按“回车”,我们将在屏幕上看到以下内容和一个最终问题:

现在,我想驱散这个命令有任何神奇的谣言。这个命令所做的就是在你的项目内创建一个单个文件。它将位于项目的根目录,并且被称为package.json,该文件将与上述截图完全一样。

对于最后一个问题,如上面截图下面所示,你可以按“回车”或者输入yes来确认这是你想要做的事情:

现在我们已经创建了文件,我们可以实际在项目内查看它。如下面的代码所示,我们有package.json文件:

{
  "name": "notes-node",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

并且这就是它,这是对你的应用程序的简单描述。就像我提到的,我们不打算将我们的应用程序发布到 npm 上,所以很多这些信息对我们来说并不重要。然而,重要的是,package.json是我们定义要在应用程序中安装的第三方模块的地方。

在我们的应用程序中安装 lodash 模块

要在应用程序中安装模块,我们将在终端中运行一个命令。在本章中,我们将安装一个名为lodash的模块。lodash模块附带了大量的实用方法和函数,使得在 Node 或 JavaScript 中开发变得更加容易。让我们看看我们到底要接触到什么,让我们进入浏览器。

我们将前往www.npmjs.com。然后我们将搜索lodash包,你会看到它出现在下面的截图中:

当你点击它时,你应该会进入到包页面,包页面将向你展示有关该模块的很多统计信息和文档,如下所示:

现在,我在寻找新模块时使用lodash包页面;我喜欢看看它有多少下载量以及上次更新是什么时候。在包页面上,你可以看到它最近更新过,这很棒,这意味着该包很可能与 Node 的最新版本兼容,如果你向页面下方看,你会看到这实际上是一个最受欢迎的 npm 包之一,每天有超过一百万次的下载。我们将使用这个模块来探索如何安装 npm 模块以及如何在项目中实际使用它们。

安装 lodash

要安装lodash,你需要的第一件事就是获取一个模块名,就是lodash。一旦你有了这个信息,你就可以开始安装了。

进入终端,我们将运行npm install命令。在安装后,我们将指定模块lodash。单独运行这个命令也可以;但我们还会提供save标志。

npm install lodash命令将安装该模块,save标志,即--(两个)破折号后跟单词save,将更新package.json文件的内容。让我们运行这个命令:

npm install loadsh --save

上述命令将前往 npm 服务器并获取代码,然后将其安装到你的项目中,每当你安装一个 npm 模块时,它都将存放在node_modules文件夹中。

现在,如果你打开node_modules文件夹,你会看到下面的代码所示的lodash文件夹。这就是我们刚刚安装的模块:

{
  "name": "notes-node",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "lodash": "^4.17.4"
  }
}

就像您在上图的package.json中所看到的那样,我们还进行了一些自动更新。有一个新的dependencies属性,其中有一个键值对对象,其中键是我们想在项目中使用的模块,值是版本号,本例中是最新版本,版本4.17.4。有了这个,我们现在可以在项目中引入我们的模块了。

app.js内部,我们可以通过相同的要求过程利用lodash中的所有内容。我们将创建一个const,我们将把这个const命名为_(这是lodash实用程序库的常见名称),并将其设置为require()。在 require 括号内,我们将传入与package.json文件中完全相同的模块名称。这是您在运行npm install时所使用的相同模块名称。然后,我们会输入lodash,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

现在,操作的顺序非常重要。Node 首先会查找lodash的核心模块。它找不到核心模块,所以下一个地方它会查找是node_modules文件夹。如下代码所示,它会找到lodash并加载该模块,返回任何它提供的输出:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

使用 Lodash 的实用工具

现在,有了输出,我们可以利用 Lodash 带来的一些实用工具。我们将在本节快速探讨其中的两个,并且在整本书中将更多地探索,因为 Lodash 基本上就是一组非常实用的工具。在我们开始之前,我们应该先看一下文档,这样我们才知道我们要做什么。

当您使用 npm 模块时,这是一个非常常见的步骤:首先安装它;第二,你必须查看那些文档,并确保你能做你想做的事情。

在 npm 页面上,点击那里给出的 lodash 链接,或者前往lodash.com,点击 API 文档页面,如下所示:

您可以查看您可用的各种方法,如下截图所示:

在我们的情况下,我们将使用command + F(Windows 用户为Ctrl + F)来搜索_.isString。然后在文档中,我们可以点击它,将其在主页面打开,如下截图所示:

_.isString是与 lodash 一起的一个实用工具,如果您传入的变量是字符串,它将返回true,如果您传入的值不是字符串,它将返回false。我们可以在 Atom 中使用它来验证。让我们来试一试。

使用 _.isString 实用工具

要使用_.isString实用程序,我们将在app.js中添加console.log以显示结果并且我们将使用_.isString,传入一些值。首先让我们传入true,然后我们可以复制这行,并传入一个字符串,比如Gary,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

console.log(_.isString(true));
console.log(_.isString('Gary'));

// console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

我们可以在终端中使用先前使用的相同命令node app.js来运行我们的文件:

当我们运行文件时,我们会得到两个提示,一个是我们已经开始了两个文件,一个是false,然后是truefalse 是因为布尔值不是字符串,true 是因为 Gary 确实是一个字符串,所以它通过了_.isString的测试。这是lodash捆绑的许多实用函数中的一个。

现在,lodash可以做的远不止简单的类型检查。它附带了一堆其他我们可以利用的实用方法。让我们探索另一个实用程序。

使用 _.uniq

回到浏览器中,我们可以再次使用command + F来搜索一个新的实用程序,即_.uniq

这个唯一的方法,简单地获取一个数组,并返回删除所有重复项的数组。这意味着如果我有好几次相同的数字或相同的字符串,它将删除任何重复内容。让我们运行一下。

回到 Atom 中,我们可以将这个实用工具添加到我们的项目中,我们将注释掉_.isString的调用,并且我们将创建一个名为filteredArray的变量。这将是没有重复项的数组,我们将调用_.uniq

现在,正如我们所知,这需要一个数组。由于我们正在尝试使用唯一功能,我们将传入一个具有一些重复项的数组。将你的名字作为字符串用两次;我将使用一次我的名字,然后跟着数字1,然后再用一次我的名字。然后我可以使用1234,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

// console.log(_.isString(true));
// console.log(_.isString('Gary'));
var filteredArray = _.uniq(['Gary', 1, 'Gary', 1, 2, 3, 4]);
console.log();

// console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

现在,如果一切按计划进行,我们应该得到一个删除了所有重复项的数组,这意味着我们将有一个Gary的实例,一个1的实例,然后没有重复的234

最后要做的事情是用console.log打印出来,这样我们就可以在终端中查看了。我将把这个filteredArray变量传递给我们的console.log语句,如下面的代码所示:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

// console.log(_.isString(true));
// console.log(_.isString('Gary'));
var filteredArray = _.uniq(['Gary', 1, 'Gary', 1, 2, 3, 4]);
console.log(filteredArray);

// console.log('Result:', notes.add(9, -2));

// var user = os.userInfo();
//
// fs.appendFile('greetings.txt', `Hello ${user.username}! You are ${notes.age}.`);

从这里,我们可以在 Node 中运行我们的项目。我将使用上次的命令,然后我可以按下回车键,你会看到我们得到了一个删除了所有重复项的数组,如下代码输出所示:

我们有一个字符串Gary的实例,一个数字1的实例,然后有234,正是我们所期望的。

lodash工具确实是无穷无尽的。有很多函数,一开始探索起来可能有点压倒,但当你开始创建更多的 JavaScript 和 Node 项目时,你会发现自己在排序、过滤或类型检查方面一遍又一遍地解决许多相同的问题,在这种情况下,最好使用lodash这样的工具来完成这一工作。lodash工具之所以如此优秀,有以下几个原因:

  • 你不必不断地重写你的方法

  • 经过充分测试,已在生产环境中使用过

如果有任何问题,现在已经解决了。

node_modules文件夹

现在你知道如何使用第三方模块了,我还想讨论一件事。那就是node_modules文件夹的一般情况。当你将你的 Node 项目放在 GitHub 上,或者你在拷贝它或发送给朋友时,node_modules文件夹实际上不应该跟着一起走。

node_modules文件夹包含生成的代码。这不是你编写的代码,你不应该对 Node 模块内部的文件进行任何更新,因为很有可能下次安装一些模块时它们会被覆盖。

在我们的情况下,我们已经在package.json文件中定义了模块和版本,如下面的代码所示,因为我们使用了方便的save标志:

{
  "name": "notes-node",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "lodash": "^4.17.4"
  }
}

这实际上意味着我们可以彻底删除node_modules文件夹。现在,我们可以拷贝这个文件夹并给朋友,可以放在 GitHub 上,或者任何我们想做的事情。当我们想要恢复node_modules文件夹时,我们只需在终端内运行npm install命令,而不带任何模块名或任何标志。

当不带任何名称或标志运行此命令时,它将加载你的package.json文件,抓取所有的依赖项并安装它们。运行完这个命令后,node_modules文件夹将看起来和我们删除它之前一模一样。现在,当你使用 Git 和 GitHub 时,你只需忽略node_modules文件夹,而不是删除它。

现在,到目前为止我们所探索的内容是我们将在整本书中经常进行的过程。因此,如果 npm 看起来还很陌生,或者你不太确定它到底有什么用,当我们与第三方模块做更多事情时,它将变得清晰明了,而不仅仅是进行类型检查或在数组中查找唯一的项目。npm 社区背后有着巨大的力量,我们将充分利用这一点,以便我们创建真实世界的应用。

全局模块

我得到的一个主要的抱怨是学生们每次想要在文本编辑器内看到他们刚刚做出的更改时,都必须从终端重新启动应用。因此,在这一部分,我们将学习如何在文件更改时自动重新启动应用程序。这意味着,如果我从Gary更改为Mike并保存,它将在终端上自动重新启动。

安装 nodemon 模块

现在,为了在我们对文件进行更改时自动重新启动我们的应用程序,我们必须安装一个命令行实用程序,并且我们将使用 npm 来完成这个步骤。要开始,请打开 Google Chrome(或您使用的浏览器)并转到www.npmjs.com,就像我们在Installing the* lodash module in our app部分以及我们正在寻找的模块之前所做的一样,这个模块叫做nodemon

nodemon 将负责监视我们应用程序的更改,并在这些更改发生时重新启动应用程序。正如我们在下面截图中所见,我们还可以查看nodemon的文档,以及其他各种内容,比如当前版本号等:

您还将注意到它是一个非常受欢迎的模块,每天有超过 30,000 次下载。现在,这个模块与我们上一节使用的lodash有些不同。lodash会被安装并添加到我们项目的package.json文件中,如下所示:

{
 "name": "notes-node",
 "version": "1.0.0",
 "description": "",
 "main": "app.js",
 "scripts": {
 "test": "echo \"Error: no test specified\" && exit 1"
 },
 "author": "",
 "license": "ISC",
 "dependencies": {
 "lodash": "^4.17.4"
 }
}

这意味着它进入我们的node_modules文件夹,我们可以在我们的app.js文件中引用它(更多细节请参考前面的部分)。但是,Nodemon 的工作方式有些不同。这是一个从终端执行的命令行实用程序。这将是启动我们应用程序的一个全新方式,并且要安装模块以在命令行中运行,我们必须调整上一节中使用的install命令。

现在,我们可以以类似的方式开始,但是稍微不同。我们将使用npm install并输入名字,就像我们在Installing the lodash module in our app部分所做的那样,但是我们将使用g标志而不是使用save标志,g标志代表全局,如下所示:

npm install nodemon -g

该命令会在您的机器上将nodemon安装为全局实用程序,这意味着它不会添加到您具体的项目中,你也不会需要nodemon。相反,你将在终端中运行nodemon命令,如下所示:

当我们使用前面的命令安装nodemon时,它将去 npm 中获取与nodemon一起的所有代码。

它会将其添加到 Node 和 npm 位于您机器上的安装位置,而不是添加到您正在工作的项目之外。

npm install nodemon -g命令可以在您机器上的任何地方执行;它不需要在项目文件夹中执行,因为它实际上并不更新项目。不过,这样一来,我们现在在我们的机器上有了一个全新的命令,nodemon

执行 nodemon

Nodemon 将像 Node 一样执行,我们键入命令,然后键入我们要启动的文件。在我们的案例中,app.js是我们项目的根。运行时,您将会看到一些东西,如下所示:

我们将看到我们应用程序的输出,以及显示发生了什么的nodemon日志。如前面的代码所示,您可以看到nodemon正在使用的版本,它监视的文件以及它实际运行的命令。此时,已经等待进行更多更改;它已经运行完整个应用程序,并将继续运行,直到发生另一个更改或直到您关闭它。

在 Atom 中,我们将对我们的应用程序进行一些更改。让我们开始通过在app.js中将Gary更改为Mike,然后将filteredArray变量更改为var filteredArray = _.uniq(['Mike']),如下所示的代码:

console.log('Starting app.js');

const fs = require('fs');
const os = require('os');
const _ = require('lodash');
const notes = require('./notes.js');

// console.log(_.isString(true));
// console.log(_.isString('Gary'));
var filteredArray = _.uniq(['Mike']);
console.log(filteredArray);

现在,我将保存文件。在终端窗口中,您可以看到应用程序已自动重新启动,并且在瞬间屏幕上显示了新的输出:

如前面的截图所示,我们现在有一个包含一个字符串Mike的数组。这就是nodemon的真正威力。

您可以创建您的应用程序,并它们将在终端中自动重启,这非常有用。这将节省您大量时间和许多头痛。每次进行小修改时,您都不必来回切换。这还可以防止很多错误,比如当您正在运行 Web 服务器时,您进行了更改,但忘记重新启动 Web 服务器。您可能认为您的更改与预期不同,因为应用程序不按预期工作,但实际上,您只是从未重新启动应用程序。

在大部分情况下,我们将在整本书中使用nodemon,因为它非常有用。它仅用于开发目的,这正是我们在本地机器上正在进行的操作。现在,我们将继续并开始探索如何从用户那里获取输入来创建我们的笔记应用程序。这将是接下来几节的主题。

在开始之前,我们应该清理本节中已经编写的大部分代码。我将删除app.js中所有被注释掉的代码。然后,我将简单地删除os,因为在整个项目中我们将不再使用它,而我们已经有了fsoslodash。我还将在第三方和 Node 模块与我编写的文件之间添加一个空格,这些文件如下:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

我发现这是一个很好的语法,让人很容易快速浏览第三方模块或 Node 模块,或者我创建和需要的模块。

接下来,在notes.js中,我们将移除add函数;这仅用于示范目的,如下图所示。然后我们可以保存notes.jsapp.js文件,nodemon将自动重新启动:

console.log('Starting notes.js');

module.exports.addNote = () => {
  console.log('addNote');
  return 'New note';
};

module.exports.add = (a, b) => {
  return a + b;
};

现在我们可以删除greetings.txt文件。这是用来演示fs模块如何工作的,既然我们已经知道它是如何工作的,我们可以删除那个文件。最后但并非最不重要的,我们总是可以使用 Ctrl + C关闭nodemon。现在我们回到了常规的终端。

有了这个,现在我们应该继续,弄清楚如何从用户那里获取输入,因为这是用户可以创建笔记、删除笔记和获取他们的笔记的方式。

获取输入

如果用户想要添加一条笔记,我们需要知道笔记的标题以及笔记的内容。如果他们想要获取一条笔记,我们需要知道他们想要获取的笔记的标题,所有这些信息都需要输入我们的应用程序。而且笔记应用程序在获取动态用户输入之前不会有什么特别之处。这就是使您的脚本变得有用和令人敬畏的原因。

现在,在本书中,我们将创建多种不同方式从用户那里获取输入的笔记应用程序。我们将使用套接字 I/O 从网络应用程序中实时获取信息,我们将创建我们自己的 API,以便其他网站和服务器可以向我们的应用程序发出 Ajax 请求,但在本节中,我们将以一个非常基本的示例开始解释如何获取用户输入。

我们将在命令行内从用户那里获取输入。这意味着当您在命令行中运行应用程序时,您将能够传入一些参数。这些参数将在 Node 内部可用,然后我们可以对它们进行其他操作,例如创建一个笔记、删除一个笔记或返回一个笔记。

在命令行内获取用户输入

要开始,让我们从终端运行我们的应用程序。我们将类似于我们在较早的章节中运行它的方式运行它:我们将以node开头(我不使用nodemon,因为我们将更改输入),然后我们将使用app.js,这是我们想要运行的文件,但是我们仍然可以输入其他变量。

我们可以传递各种命令行参数。我们可以有一个命令,这将告诉应用程序要做什么,无论您想要添加一个笔记,删除一个笔记,还是列出一个笔记。

如果我们想要添加一条笔记,可能看起来像下面的命令:

node app.js add

这条命令会添加一条笔记;我们可以使用remove命令来移除一条笔记,如下所示:

node app.js remove

我们可以使用list命令列出所有的笔记:

node app.js list

现在,当我们运行这条命令时,应用程序仍然会按预期工作。只是因为我们传入了一个新的参数,并不意味着我们的应用程序会崩溃:

实际上,我们已经可以访问list参数了,只是我们没有在应用程序中使用它。

要访问应用程序初始化时使用的命令行参数,您需要使用我们在第一章中探讨过的process对象。

我们可以使用console.log将所有的参数打印到屏幕上以输出它们;它在进程对象上,我们要寻找的属性是argv

argv对象简称为参数向量,或者在 JavaScript 的情况下更像是参数数组。这将是传入的所有命令行参数的数组,我们可以使用它们开始创建我们的应用程序。

现在保存app.js文件,它将如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

console.log(process.argv);

然后我们将重新运行这个文件:

现在,正如前面的命令输出所示,我们有以下三个条目:

  • 第一个指向所使用的 Node 的可执行文件。

  • 第二个指向启动的应用程序文件;在这种情况下,它是app.js

  • 第三个就是我们的命令行参数开始发挥作用的地方。在其中,我们有我们的list显示为字符串。

这意味着我们可以访问数组中的第三个项目,那将是我们笔记应用程序的命令。

访问笔记应用程序的命令行参数

现在,让我们来访问数组中的命令行参数。我们将创建一个名为command的变量,并将其设置为process.argv,然后我们将获取第三个位置上的项目(就像前面的命令输出中所示的list一样),这在这里显示为了 2:

var command = process.argv[2];

然后我们可以通过记录command字符串来将其输出到屏幕上。然后,作为第二个参数,我将传入实际使用的命令:

console.log('Command: ' , command);

这只是一个简单的日志,用于跟踪应用程序的执行情况。酷的东西将在我们添加根据该命令执行不同操作的 if 语句时出现。

添加 if/else 语句

让我们在console.log('Command: ', command);下面创建一个 if/else 块。我们将添加if (command === 'add'),如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') 

在这种情况下,我们将通过添加new note的过程来添加一个新的笔记。现在,我们在这里没有指定其他参数,比如标题或正文(我们将在后面的部分中讨论这个问题)。目前,如果命令确实等于add,我们将使用console.log打印Adding new note,如下面的代码所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
}

我们可以使用list这样的命令做同样的事情。我们将添加else if (command === 'list'),如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list')

如果命令确实等于字符串list,我们将使用console.log运行以下代码块打印Listing all notes。我们还可以添加一个 else 子句,如果没有命令的话,打印Command not recognized,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else {
  console.log('Command not recognized');
}

有了这个设置,我们现在可以第三次运行我们的应用程序,在这一次中,你将会看到我们的命令等于列表,并且所有的笔记都会显示出来,如下面的代码所示:

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else {
  console.log('Command not recognized');
}

这意味着我们能够使用我们的参数来运行不同的代码。请注意,我们并没有运行Adding new noteCommand not recognized。但是,我们可以将node app.js命令从list切换到add,在这种情况下,我们将会得到Adding new note的打印,如下面的截图所示:

如果我们运行一个不存在的命令,比如read,你会看到Command not recognized被打印出来,如下面的截图所示:

练习 - 在 if 块中添加两个 else if 子句

现在,我想让你在我们的 if 块中添加另外两个else if子句,如下所示:

  • 其中一个将用于read命令,负责获取个别的笔记

  • 另一个叫做remove的命令将负责删除笔记

你需要做的就是为它们都添加else if语句,然后快速地用console.log打印出Fetching noteRemoving note之类的东西。

花点时间来解决这个挑战。当您添加了这两个else if子句后,从终端运行它们并确保您的日志显示出来。如果显示出来,您就完成了,可以继续进行下一步。

练习的解决方案

对于解决方案,我首先要做的是为read添加一个else if。我将打开和关闭我的大括号,然后在中间按下enter,以便所有内容都被格式化正确。

else if语句中,我将检查command变量是否等于字符串read,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if () {

} else {
  console.log('Command not recognized');
}

在未来,我们将调用更新本地数据库的方法以更新笔记。

目前,我们将使用console.log来打印Reading note

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {

} else {
  console.log('Command not recognized');
}

您需要做的下一件事是添加一个else if子句,检查command是否等于remove。在else if中,我将打开和关闭我的条件,并按下enter,就像我在前一个else if子句中所做的那样;这一次,我将添加if command等于remove,我们想要删除笔记。在那种情况下,我们只需使用console.log来打印Reading note,如下面的代码所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else {
  console.log('Command not recognized');
}

有了这个,我们就完成了。如果我们参考代码块,我们已经添加了可以在终端上运行的两个新命令,并且我们可以测试这些命令:

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else {
  console.log('Command not recognized');
}

首先,我将用read命令运行node app.js,然后Reading note显示出来:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command: ', command);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else if (command == 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

然后我会重新运行命令;这一次,我将使用remove。当我这样做时,屏幕上会打印出Removing note,就像这个屏幕截图中显示的那样:

我将用一个不存在的命令结束我的测试,当我运行它时,您可以看到Command not recognized出现了。

获取特定的笔记信息

现在,我们在前面的小节中所做的是第 1 步。我们现在支持各种命令。我们需要弄清楚的下一件事是如何获取更具体的信息。例如,您想删除哪个笔记?您想读哪个笔记?在添加笔记时,您希望笔记文本是什么?我们都需要从终端获取这些信息。

现在,获取它将与我们早些时候所做的非常相似,为了向您展示它是什么样子,我们将再次使用以下命令打印整个argv对象:

console.log(process.argv);

接下来,我们可以在终端运行一个更复杂的命令。假设我们要使用node app.js remove命令来删除一个笔记,我们可以通过它的标题来做到这一点。我们可能会使用title参数,它看起来像下面的代码:

node app.js remove --title

在这个title参数中,我们有--(两个)破折号,后面是参数名title,然后是=(等号)。然后我们可以输入我们的笔记标题。也许笔记标题是secrets。这样就可以将标题参数传递到我们的应用程序中。

现在,你可以以以下方式格式化title参数:

  • 你可以像前面的命令一样拥有标题secrets

  • 你可以将标题等于引号内的secrets,这样可以让我们在标题中使用空格:

 node app.js remove --title=secrets
  • 你可以完全去掉=(等号),只需留下一个空格:
 node app.js remove --title="secrets 2"

无论你选择如何格式化你的参数,这都是传递标题的有效方式。

正如你在前面的截图中看到的那样,当我包装我的字符串时,我使用双引号。现在,如果你切换到单引号,它不会在 Linux 或 OS X 上断开,但在 Windows 上会断开。这意味着当你传递命令行参数,如标题或笔记正文时,如果存在空格,你会想要用双引号而不是单引号。所以,如果你正在使用 Windows,并且在参数方面遇到了一些意外的行为,请确保你使用的是双引号而不是单引号;这应该解决问题。

目前,我将保留=(等号)和引号,并重新运行命令:

node app.js remove --title="secrets 2"

当我运行命令时,您可以在以下代码输出中看到我们有两个参数:

这些是我们不需要的参数,然后我们有我们的删除命令,这是第三个,现在我们有一个新的第四个字符串,标题等于secrets 2。我们的参数已成功传递到应用程序中。问题是它不太容易使用。在第四个字符串中,我们必须解析出键title和值secrets 2

当我们使用命令时,它是前一节中的第三个参数,它在我们的应用程序内使用起来更容易。我们只需从参数数组中取出它,并通过使用命令变量引用它,并检查它是否等于添加列表读取删除

随着我们使用不同的样式传递参数,事情变得更加复杂。如果我们使用空格而不是=(等号)重新运行上一个命令,如下面的代码所示,这是完全有效的,我们的参数数组现在看起来完全不同:

在上面的代码输出中,您可以看到标题作为第四项,值作为第五项,这意味着我们必须添加其他条件来解析。这很快就会变得痛苦,这就是为什么我们不会这样做。

在下一章中,我们将使用一个名为 yargs 的第三方模块来使解析命令行参数变得轻松。与我们之前展示的字符串不同,我们将得到一个对象,其中 title 属性等于secrets 2字符串。这将使实现其余笔记应用程序变得非常容易。

现在,解析某些类型的命令行参数,例如键值对,变得更加复杂,这就是为什么在下一章中,我们将使用 yargs 来做到这一点。

摘要

在本章中,我们学会了如何使用 require 加载 Node.js 提供的模块。我们为笔记应用程序创建了我们的文件,并在app.js中引入它们。我们探索了如何使用内置模块,以及如何使用我们定义的模块。我们发现了如何要求我们创建的其他文件,并如何从这些文件中导出属性和函数等东西。

我们稍微探索了 npm,我们如何使用npm init生成package.json文件,以及我们如何安装和使用第三方模块。接下来,我们探索了nodemon模块,使用它在我们对文件进行更改时自动重新启动我们的应用程序。最后,我们学会了如何从用户那里获取输入,这对创建笔记应用程序是必要的。我们了解到我们可以使用命令行参数将数据传递给我们的应用程序。

在下一章中,我们将探索一些更有趣的 Node 基本概念,包括 yargs,JSON 和重构。

第三章:Node 基础知识-第二部分

在这一章中,我们将继续讨论一些更多的 node 基础知识。我们将探讨 yargs,并看看如何使用process.argv和 yargs 来解析命令行参数。之后,我们将探讨 JSON。JSON 实际上就是一个看起来有点像 JavaScript 对象的字符串,与之不同的是它使用双引号而不是单引号,并且所有的属性名称,比如nameage,在这种情况下都需要用引号括起来。我们将探讨如何将对象转换为字符串,然后定义该字符串,使用它,并将其转换回对象。

在我们完成了这些之后,我们将填写addNote函数。最后,我们将进行重构,将功能移入单独的函数并测试功能。

更具体地,我们将讨论以下主题:

  • yargs

  • JSON

  • 添加注释

  • 重构

yargs

在本节中,我们将使用 yargs,一个第三方的 npm 模块,来使解析过程更加容易。它将让我们访问诸如标题和正文信息之类的东西,而无需编写手动解析器。这是一个很好的例子,说明何时应该寻找一个 npm 模块。如果我们不使用模块,对于我们的 Node 应用程序来说,使用经过测试和彻底审查的第三方模块会更加高效。

首先,我们将安装该模块,然后将其添加到项目中,解析诸如标题和正文之类的内容,并调用在notes.js中定义的所有函数。如果命令是add,我们将调用add note

安装 yargs

现在,让我们查看 yargs 的文档页面。了解自己将要涉足的领域总是一个好主意。如果你在 Google 上搜索yargs,你应该会发现 GitHub 页面是你的第一个搜索结果。如下截图所示,我们有 yargs 库的 GitHub 页面:

现在,yargs 是一个非常复杂的库。它有很多功能来验证各种输入,并且有不同的方式来格式化输入。我们将从一个非常基本的例子开始,尽管在本章中我们将介绍更复杂的例子。

如果你想查看我们在本章中没有讨论的任何其他功能,或者你只是想看看我们讨论过的某些功能是如何工作的,你可以在yarg 文档中找到它。

现在,我们将进入终端,在我们的应用程序中安装这个模块。为了做到这一点,我们将使用npm install,然后是模块名称yargs,在这种情况下,我将使用@符号来指定我想要使用的模块的特定版本,即 11.0.0,这是我写作时最新的版本。接下来,我将添加save标志,正如我们所知,这会更新package.json文件:

npm install yargs@11.0.0 --save

如果我不加save标志,yargs 将被安装到node_modules文件夹中,但如果我们稍后清空该node_modules文件夹并运行npm install,yargs 将不会被重新安装,因为它没有列在package.json文件中。这就是为什么我们要使用save标志的原因。

运行 yargs

现在我们已经安装了 yargs,我们可以进入 Atom,在app.js中开始使用它。yargs 的基础,它的功能集的核心,非常简单易用。我们要做的第一件事就是像我们在上一章中使用fslodash一样,将其require进来。让我们创建一个常量并将其命名为yargs,将其设置为require('yargs'),如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

var command = process.argv[2];
console.log('Command:', command);
console.log(process.argv);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

从这里,我们可以获取 yargs 解析的参数。它将获取我们在上一章中讨论过的process.argv数组,但它在后台解析它,给我们比 Node 给我们的更有用的东西。就在command变量的上面,我们可以创建一个名为argvconst变量,将其设置为yargs.argv,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log(process.argv);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

yargs.argv模块是 yargs 库存储应用程序运行的参数版本的地方。现在我们可以使用console.log打印它,这将让我们查看process.argvyargs.argv变量;我们还可以比较它们,看看 yargs 的不同之处。对于使用console.log打印process.argv的命令,我将把第一个参数命名为Process,这样我们就可以在终端中区分它。我们将再次调用console.log。第一个参数将是Yargs字符串,第二个参数将是来自 yargs 的实际argv变量:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Process', process.argv);
console.log('Yargs', argv);

if (command === 'add') {
  console.log('Adding new note');
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

现在我们可以以几种不同的方式运行我们的应用程序(参考前面的代码块),看看这两个console.log语句的区别。

首先,我们将使用add命令运行node app.js,我们可以运行这个非常基本的例子:

node app.js add

我们已经从上一章知道了process.argv数组的样子。有用的信息是数组中的第三个字符串,即'add'。在第四个字符串中,Yargs 给了我们一个看起来非常不同的对象:

如前面的代码输出所示,首先是下划线属性,然后存储了 add 等命令。

如果我要添加另一个命令,比如add,然后我要添加一个修饰符,比如encrypted,你会看到add是第一个参数,encrypted是第二个参数,如下所示:

node app.js add encrypted

到目前为止,yargs 并没有表现出色。这并不比我们在上一个例子中拥有的更有用。它真正发挥作用的地方是当我们开始传递键值对时,比如我们在Node 基础知识-第一部分获取输入部分中使用的标题示例中。我可以将我的title标志设置为secrets,按enter,这一次,我们得到了更有用的东西:

node app.js add --title=secrets

在下面的代码输出中,我们有第三个字符串,我们需要解析以获取值和键,而在第四个字符串中,我们实际上有一个带有值为 secrets 的标题属性:

此外,yargs 已经内置了对您可能指定的所有不同方式的解析。

我们可以在title后面插入一个空格,它仍然会像以前一样工作;我们可以在secrets周围添加引号,或者添加其他单词,比如Andrew 的秘密,它仍然会正确解析,将title属性设置为Andrew 的秘密字符串,如下所示:

node app.js add --title "secrets from Andrew"

这就是 yargs 真正发挥作用的地方!它使解析参数的过程变得更加容易。这意味着在我们的应用程序中,我们可以利用这种解析并调用适当的函数。

使用 add 命令

让我们以add命令为例,解析您的参数并调用函数。一旦调用add命令,我们希望调用notes中定义的一个函数,这个函数将负责实际添加笔记。notes.addNote函数将完成这项工作。现在,我们想要传递给addNote函数什么?我们想要传递两件事:标题,可以在argv.title上访问,就像我们在前面的例子中看到的那样;和正文,argv.body

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Process', process.argv);
console.log('Yargs', argv);

if (command === 'add') {
  console.log('Adding new note');
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  console.log('Listing all notes');
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

目前,这些命令行参数titlebody并不是必需的。因此从技术上讲,用户可以在没有其中一个的情况下运行应用程序,这将导致应用程序崩溃,但在未来,我们将要求这两者都是必需的。

现在我们已经放置了notes.addNote,我们可以删除我们之前的console.log语句,这只是一个占位符,然后我们可以进入笔记应用程序notes.js

notes.js中,我们将通过创建一个与我们在app.js中使用的方法同名的变量来开始,然后将其设置为一个匿名箭头函数,如下所示:

var addNote = () => {

};

但是,仅仅这样还不太有用,因为我们没有导出addNote函数。在变量下面,我们可以以稍微不同的方式定义module.exports。在之前的部分中,我们添加属性到exports来导出它们。我们实际上可以定义一个整个对象,将其设置为exports,在这种情况下,我们可以将addNote设置为前面代码块中定义的addNote函数:

module.exports = {
  addNote: addNote
};

在 ES6 中,实际上有一个快捷方式。当你设置一个对象属性和一个变量的值,它们都完全相同时,你可以省略冒号和值。无论哪种方式,结果都是相同的。

在前面的代码中,我们将一个对象设置为module.exports,这个对象有一个属性addNote,指向我们在前面的代码块中定义的addNote函数的变量。

再次强调,在 ES6 中,addNote:addNote在 ES6 内部是相同的。我们将在本书中始终使用 ES6 语法。

现在我可以拿到我的两个参数,titlebody,并且实际上对它们做一些事情。在这种情况下,我们将调用console.logAdding note,将两个参数作为console.log的第二个和第三个参数传递进去,titlebody,如下所示:

var addNote = (title, body) => {
  console.log('Adding note', title, body);
};

现在我们处于一个非常好的位置,可以使用titlebody运行add命令,并查看我们是否得到了我们期望的结果,也就是前面代码中显示的console.log语句。

在终端中,我们可以通过node app.js运行应用程序,然后指定文件名。我们将使用add命令;这将运行适当的函数。然后,我们将传入title,将其设置为secret,然后我们可以传入body,这将是我们的第二个命令行参数,将其设置为字符串This is my secret

node app.js add --title=secret --body="This is my secret"

在这个命令中,我们指定了三件事:add命令,title参数,设置为secret;和body参数,设置为"This is my secret"。如果一切顺利,我们将得到适当的日志。让我们运行这个命令。

在下面的命令输出中,你可以看到Adding note secret,这是标题;和This is my secret,这是正文:

有了这个,我们现在有了一个设置好并准备好的方法。我们接下来要做的是转换我们拥有的其他命令——listreadremove命令。让我们再看一个命令,然后你可以自己练习另外两个。

使用list命令

现在,使用list命令,我将删除console.log语句并调用notes.getAll,如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Process', process.argv);
console.log('Yargs', argv);

if (command === 'add') {
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  notes.getAll();
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

在某个时候,notes.getAll将返回所有的笔记。现在,getAll不需要任何参数,因为它将返回所有的笔记,而不管标题是什么。read命令将需要一个标题,remove也将需要你想要删除的笔记的标题。

现在,我们可以创建getAll函数。在notes.js中,我们将再次进行这个过程。我们将首先创建一个变量,称之为getAll,并将其设置为一个箭头函数,这是我们之前使用过的。我们从我们的参数list开始,然后设置箭头(=>),这是等号和大于号。接下来,我们指定我们想要运行的语句。在我们的代码块中,我们将运行console.log(Getting all notes),如下所示:

var getAll = () => {
  console.log('Getting all notes');
};

在添加分号之后的最后一步是将getAll添加到exports中,如下面的代码块所示:

module.exports = {
  addNote,
  getAll
};

请记住,在 ES6 中,如果你有一个属性的名称与值相同,这个值是一个变量,你可以简单地删除值变量和冒号。

现在我们在notes.js中有了getAll,并且在app.js中已经连接好了,我们可以在终端中运行。在这种情况下,我们将运行list命令:

node app.js list

在前面的代码输出中,你可以看到屏幕上打印出了Getting all notes。现在我们已经有了这个,我们可以从app.jscommand变量中删除console.log('Process', process.argv)。结果代码将如下所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Yargs', argv);

if (command === 'add') {
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  notes.getAll();
} else if (command === 'read') {
  console.log('Reading note');
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

我们将保留 yargs 日志,因为我们将在本章中探索其他使用 yargs 的方法和方式。

现在我们已经有了list命令,接下来,我想让你为readremove命令创建一个方法。

读取命令

当使用read命令时,我们希望调用notes.getNote,传入title。现在,title将被传递并使用 yargs 进行解析,这意味着我们可以使用argv.title来获取它。这就是在调用函数时所需要做的一切:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Yargs', argv);

if (command === 'add') {
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  notes.getAll();
} else if (command === 'read') {
  notes.getNote(argv.title);
} else if (command === 'remove') {
  console.log('Removing note');
} else {
  console.log('Command not recognized');
}

下一步是定义getNote,因为目前它并不存在。在notes.js中,在getAll变量的下面,我们可以创建一个名为getNote的变量,它将是一个函数。我们将使用箭头函数,并且它将接受一个参数;它将接受note的标题。getNote函数接受标题,然后返回该笔记的内容:

var getNote = (title) => {

};

getNote中,我们可以使用console.log打印一些类似于Getting note的内容,后面跟着你将要获取的笔记的标题,这将是console.log的第二个参数:

var getNote = (title) => {
  console.log('Getting note', title);
};

这是第一个命令,我们现在可以在继续第二个命令remove之前进行测试。

在终端中,我们可以使用node app.js来运行文件。我们将使用新的read命令,传入一个title标志。我将使用不同的语法,其中title被设置为引号外的值。我将使用类似accounts的东西:

node app.js read --title accounts

这个accounts值将在将来读取accounts笔记,并将其打印到屏幕上,如下所示:

如你在前面的代码输出中所看到的,我们得到了一个错误,现在我们将对其进行调试。

处理解析命令中的错误

遇到错误并不是世界末日。通常出现错误意味着你可能有一个小的拼写错误或者在过程中忘记了一步。所以,我们首先要弄清楚如何解析这些错误消息,因为代码输出中得到的错误消息可能会让人望而生畏。让我们来看一下代码输出中的错误:

正如你所看到的,第一行显示了错误发生的位置。它在我们的app.js文件中,冒号后面的数字 19 是行号。它准确地告诉你事情出了问题的地方。TypeError: notes.getNote is not a function行清楚地告诉你你尝试运行的getNote函数不存在。现在我们可以利用这些信息来调试我们的应用程序。

app.js中,我们看到我们调用了notes.getNote。一切看起来很好,但当我们进入notes.js时,我们意识到我们实际上从未导出getNote。这就是为什么当我们尝试调用该函数时,我们会得到getNote is not a function。我们只需要做的就是导出getNote,如下所示:

module.exports = {
  addNote,
  getAll,
  getNote
};

现在当我们保存文件并从终端重新运行应用程序时,我们将得到我们期望的结果——Getting note后面跟着标题,这里是accounts

这就是我们如何调试我们的错误消息。错误消息包含非常有用的信息。在大多数情况下,前几行是你编写的代码,其他行是内部 Node 代码或第三方模块。在我们的情况下,堆栈跟踪的第一行很重要,因为它准确地显示了错误发生的位置。

删除命令

现在,由于read命令正在工作,我们可以继续进行最后一个命令remove。在这里,我将调用notes.removeNote,传入标题,正如我们所知道的在argv.title中可用:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = process.argv[2];
console.log('Command:', command);
console.log('Yargs', argv);

if (command === 'add') {
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  notes.getAll();
} else if (command === 'read') {
  notes.getNote(argv.title);
} else if (command === 'remove') {
  notes.removeNote(argv.title);
} else {
  console.log('Command not recognized');
}

接下来,我们将在我们的笔记 API 文件中定义 removeNote 函数,就在 getNote 变量的下面:

var removeNote = (title) => { 
 console.log('Removing note', title);
};

现在,removeNote 将与 getNote 工作方式基本相同。它只需要标题;它可以使用这些信息来查找笔记并从数据库中删除它。这将是一个接受 title 参数的箭头函数。

在这种情况下,我们将打印 console.log 语句 Removing note;然后,作为第二个参数,我们将简单地打印 title 回到屏幕上,以确保它成功地通过了这个过程。这一次,我们将导出我们的 removeNote 函数;我们将使用 ES6 语法来定义它:

module.exports = {
  addNote,
  getAll,
  getNote,
  removeNote
};

最后要做的事情是测试它并确保它有效。我们可以使用上箭头键重新加载上一个命令。我们将 read 改为 remove,这就是我们需要做的全部。我们仍然传入 title 参数,这很好,因为这是 remove 需要的:

node app.js remove --title accounts

当我运行这个命令时,我们得到了我们预期的结果。移除笔记打印到屏幕上,如下面的代码输出所示,然后我们得到了我们应该移除的笔记的标题,即 accounts:

看起来很棒!这就是使用 yargs 解析你的参数所需的全部内容。

有了这个,我们现在有了一个地方来定义所有这些功能,用于保存、读取、列出和删除笔记。

获取命令

在我们结束本节之前,我想讨论的最后一件事是——我们如何获取 command

正如我们所知,command_ 属性中作为第一个且唯一的项可用。这意味着在 app.js 中,var command 语句中,我们可以将 command 设置为 argv,然后 ._,然后我们将使用 [] 来抓取数组中的第一个项目,如下面的代码所示:

console.log('Starting app.js');

const fs = require('fs');
const _ = require('lodash');
const yargs = require('yargs');

const notes = require('./notes.js');

const argv = yargs.argv;
var command = argv._[0];
console.log('Command:', command);
console.log('Yargs', argv);

if (command === 'add') {
  notes.addNote(argv.title, argv.body);
} else if (command === 'list') {
  notes.getAll();
} else if (command === 'read') {
  notes.getNote(argv.title);
} else if (command === 'remove') {
  notes.removeNote(argv.title);
} else {
  console.log('Command not recognized');
}

有了这个功能,我们现在有了相同的功能,但我们将在所有地方使用 yargs。如果我重新运行上一个命令,我们可以测试功能是否仍然有效。它确实有效!如下面的命令输出所示,我们可以看到命令:remove 显示出来:

接下来,我们将研究填写各个函数。我们首先来看一下如何使用 JSON 将我们的笔记存储在文件系统中。

JSON

现在你知道如何使用 process.argv 和 yargs 解析命令行参数,你已经解决了 notes 应用程序的第一部分难题。现在,我们如何从用户那里获取独特的输入呢?解决这个难题的第二部分是解决我们如何存储这些信息。

当有人添加新的笔记时,我们希望将其保存在某个地方,最好是在文件系统上。所以下次他们尝试获取、移除或读取该笔记时,他们实际上会得到该笔记。为了做到这一点,我们需要引入一个叫做 JSON 的东西。如果你已经熟悉 JSON,你可能知道它非常受欢迎。它代表 JavaScript 对象表示法,是一种用字符串表示 JavaScript 数组和对象的方法。那么,为什么你会想要这样做呢?

嗯,你可能想这样做是因为字符串只是文本,而且几乎在任何地方都得到支持。我可以将 JSON 保存到文本文件中,然后稍后读取它,将其解析回 JavaScript 数组或对象,并对其进行操作。这正是我们将在本节中看到的。

为了探索 JSON 以及它的工作原理,让我们继续在我们的项目中创建一个名为 playground 的新文件夹。

在整本书中,我将创建 playground 文件夹和各种项目,这些项目存储简单的一次性文件,不是更大应用程序的一部分;它们只是探索新功能或学习新概念的一种方式。

playground 文件夹中,我们将创建一个名为 json.js 的文件,这是我们可以探索 JSON 工作原理的地方。让我们开始,让我们创建一个非常简单的对象。

将对象转换为字符串

首先,让我们创建一个名为obj的变量,将其设置为一个对象。在这个对象上,我们只定义一个属性name,并将其设置为你的名字;我将这个属性设置为Andrew,如下所示:

var obj = {
  name: 'Andrew'
};

现在,假设我们想要获取这个对象并对其进行操作。例如,我们想要将其作为字符串在服务器之间发送并保存到文本文件中。为此,我们需要调用一个 JSON 方法。

让我们定义一个变量来存储结果stringObj,并将其设置为JSON.stringify,如下所示:

var stringObj = JSON.stringify(obj);

JSON.stringify方法接受你的对象,这里是obj变量,并返回 JSON 字符串化的版本。这意味着存储在stringObj中的结果实际上是一个字符串。它不再是一个对象,我们可以使用console.log来查看。我将使用console.log两次。首先,我们将使用typeof运算符打印字符串对象的类型,以确保它实际上是一个字符串。由于typeof是一个运算符,它以小写形式输入,没有驼峰命名法。然后,传入要检查其类型的变量。接下来,我们可以使用console.log来打印字符串本身的内容,打印stringObj变量,如下所示:

console.log(typeof stringObj);
console.log(stringObj);

我们在这里所做的是将一个对象转换为 JSON 字符串,并将其打印到屏幕上。在终端中,我将使用以下命令导航到playground文件夹中:

cd playground

现在,无论你在哪里运行命令都无所谓,但在将来当我们在playground文件夹中时,这将很重要,所以花点时间进入其中。

现在,我们可以使用node来运行我们的json.js文件。运行文件时,我们会看到两件事:

如前面的代码输出所示,首先我们会得到我们的类型,它是一个字符串,这很好,因为记住,JSON 是一个字符串。接下来,我们将得到我们的对象,它看起来与 JavaScript 对象非常相似,但有一些区别。这些区别如下:

  • 首先,你的 JSON 将自动用双引号包裹其属性名称。这是 JSON 语法的要求。

  • 接下来,你会注意到你的字符串也被双引号包裹,而不是单引号。

现在,JSON 不仅支持字符串值,还可以使用数组、布尔值、数字或其他任何类型。所有这些类型在你的 JSON 中都是完全有效的。在这种情况下,我们有一个非常简单的示例,其中有一个name属性,它设置为"Andrew"

这是将对象转换为字符串的过程。接下来,我们将定义一个字符串,并将其转换为我们可以在应用程序中实际使用的对象。

定义一个字符串并在应用程序中使用

让我们开始创建一个名为personString的变量,并将其设置为一个字符串,使用单引号,因为 JSON 在其内部使用双引号,如下所示:

var personString = '';

然后我们将在引号中定义我们的 JSON。我们将首先打开和关闭一些花括号。我们将使用双引号创建我们的第一个属性,我们将其称为name,并将该属性设置为Andrew。这意味着在闭合引号之后,我们将添加:;然后我们将再次打开和关闭双引号,并输入值Andrew,如下所示:

var personString = '{"name": "Andrew"}';

接下来,我们可以添加另一个属性。在值Andrew之后,我将在逗号后创建另一个属性,称为age,并将其设置为一个数字。我可以使用冒号,然后定义数字而不使用引号,例如25

var personString = '{"name": "Andrew","age": 25}';

你可以继续使用你的名字和年龄,但确保其余部分看起来与这里看到的完全相同。

现在,假设我们从服务器获取了先前定义的 JSON,或者我们从文本文件中获取了它。目前它是无用的;如果我们想获取name值,没有好的方法可以做到,因为我们使用的是一个字符串,所以personString.name不存在。我们需要将字符串转换回对象。

将字符串转换回对象

要将字符串转换回对象,我们将使用JSON.stringify的相反操作,即JSON.parse。让我们创建一个变量来存储结果。我将创建一个person变量,并将其设置为JSON.parse,传入作为唯一参数要解析的字符串,即person字符串,我们之前定义过的:

var person = JSON.parse(personString);

现在,这个变量将把你的 JSON 从字符串转换回其原始形式,可以是数组或对象。在我们的情况下,它将其转换回对象,并且我们有person变量作为对象,如前面的代码所示。此外,我们可以使用typeof运算符证明它是一个对象。我将使用console.log两次,就像我们之前做过的那样。

首先,我们将打印persontypeof,然后我们将打印实际的person变量,console.log(person)

console.log(typeof person);
console.log(person);

有了这个,我们现在可以在终端中重新运行命令;我将实际启动nodemon并传入json.js

nodemon json.js 

如下所示的代码输出,您现在可以看到我们正在使用一个对象,这很棒,我们有我们的常规对象:

我们知道Andrew是一个对象,因为它没有用双引号包裹;值没有引号,我们使用单引号Andrew,这在 JavaScript 中是有效的,但在 JSON 中是无效的。

这是将对象转换为字符串,然后将字符串转换回对象的整个过程,这正是我们将在notes应用程序中做的。唯一的区别是,我们将取以下字符串并将其存储在文件中,然后稍后,我们将使用JSON.parse从文件中读取该字符串,将其转换回对象,如下面的代码块所示:

// var obj = {
//  name: 'Andrew'
// };
// var stringObj = JSON.stringify(obj);
// console.log(typeof stringObj);
// console.log(stringObj);

var personString = '{"name": "Andrew","age": 25}';
var person = JSON.parse{personString};
console.log(typeof person);
console.log(person);

将字符串存储在文件中

基本知识已经就位,让我们再进一步,也就是将字符串存储在文件中。然后,我们希望使用fs模块读取该文件的内容,并打印一些属性。这意味着我们需要将从fs.readfilesync获取的字符串转换为对象,使用JSON.parse

在 playground 文件夹中写入文件

让我们继续注释掉到目前为止的所有代码,从干净的板上开始。首先,让我们加载fs模块。const变量fs将被设置为require,我们将传递过去使用过的fs模块,如下所示:

// var obj = {
//  name: 'Andrew'
// };
// var stringObj = JSON.stringify(obj);
// console.log(typeof stringObj);
// console.log(stringObj);

// var personString = '{"name": "Andrew","age": 25}';
// var person = JSON.parse(personString);
// console.log(typeof person);
// console.log(person);

const fs = require('fs');

接下来要做的是定义对象。这个对象将被存储在我们的文件中,然后将被读取并解析。这个对象将是一个名为originalNote的变量,我们将称它为originalNote,因为后来,我们将重新加载它并将该变量称为Note

现在,originalNote将是一个常规的 JavaScript 对象,有两个属性。我们将有title属性,将其设置为Some title,和body属性,将其设置为Some body,如下所示:

var originalNote = {
  title: 'Some title',
  body: 'Some body'
};

您需要做的下一步是获取原始注释并创建一个名为originalNoteString的变量,并将该变量设置为我们之前定义的对象的 JSON 值。这意味着您需要使用我们在本节先前使用过的两种 JSON 方法之一。

现在,一旦你有了originalNoteString变量,我们就可以将文件写入文件系统。我会为你写下这一行,fs.writeFileSync。我们之前使用的writeFileSync方法需要两个参数。一个是文件名,由于我们使用的是 JSON,使用 JSON 文件扩展名很重要。我会把这个文件叫做notes.json。另一个参数将是文本内容,originalNoteString,它还没有被定义,如下面的代码块所示:

// originalNoteString
fs.writeFileSync('notes.json', originalNoteString);

这是整个过程的第一步;这是我们将文件写入playground文件夹的方法。下一步是读取内容,使用之前的 JSON 方法进行解析,并打印其中一个属性到屏幕上,以确保它是一个对象。在这种情况下,我们将打印标题。

读取文件中的内容

打印标题的第一步是使用我们尚未使用过的方法。我们将使用文件系统模块上可用的read方法来读取内容。让我们创建一个名为noteString的变量。noteString变量将被设置为fs.readFileSync

现在,readFileSyncwriteFileSync类似,只是它不需要文本内容,因为它会为你获取文本内容。在这种情况下,我们只需指定第一个参数,即文件名notes.JSON

var noteString = fs.readFileSync('notes.json');

现在我们有了字符串,你的工作就是拿到那个字符串,使用前面的方法之一,将它转换回对象。你可以将那个变量叫做note。接下来,唯一剩下的事情就是测试一切是否按预期工作,通过使用console.log(typeof note)来打印。然后,在这之下,我们将使用console.log来打印标题,note.title

// note
console.log(typeof note);
console.log(note.title);

现在,在终端中,你可以看到(参考下面的截图),我保存了一个损坏的文件,并且它崩溃了,这是使用nodemon时预期的结果:

为了解决这个问题,我要做的第一件事是填写originalNoteString变量,这是我们之前注释掉的。现在它将成为一个名为originalNoteString的变量,并且我们将把它设置为JSON.stringify的返回值。

现在,我们知道JSON.stringify将我们的普通对象转换为字符串。在这种情况下,我们将把originalNote对象转换为字符串。下一行,我们已经填写好了,将保存该 JSON 值到notes.JSON文件中。然后我们将读取该值出来:

var originalNoteString = JSON.stringify(originalNote);

下一步将是创建note变量。note变量将被设置为JSON.parse

JSON.parse方法将字符串 JSON 转换回普通的 JavaScript 对象或数组,取决于你保存的内容。在这里,我们将传入noteString,这是我们从文件中获取的:

var note = JSON.parse(noteString);

有了这个,我们现在完成了。当我保存这个文件时,nodemon将自动重新启动,我们不会看到错误。相反,我们期望看到对象类型以及笔记标题。在终端中,我们有对象和一些标题打印到屏幕上:

有了这个,我们已经成功完成了挑战。这正是我们将保存我们的笔记的方法。

当有人添加新的笔记时,我们将使用以下代码来保存它:

var originalNote = {
  title: 'Some title',
  body: 'Some body'
};
var originalNoteString = JSON.stringify(originalNote);
fs.writeFileSync('notes.json', originalNoteString);

当有人想要阅读他们的笔记时,我们将使用以下代码来读取它:

var noteString = fs.readFileSync('notes.json');
var note = JSON.parse(noteString);
console.log(typeof note);
console.log(note.title);

现在,如果有人想要添加一条笔记呢?这将要求我们首先读取所有的笔记,然后修改笔记数组,然后使用代码(参考前面的代码块)将新数组保存回文件系统中。

如果你打开notes.JSON文件,你可以看到我们的 JSON 代码就在文件中:

.json实际上是大多数文本编辑器支持的文件格式,因此我已经内置了一些不错的语法高亮。现在,在下一节中,我们将填写addNote函数,使用刚刚在本节中使用的完全相同的逻辑。

添加和保存笔记

在上一节中,您学习了如何在 Node.js 中处理 JSON,这是我们将在notes.js应用程序中使用的确切格式。当您首次运行命令时,我们将加载可能已经存在的所有笔记。然后我们将运行命令,无论是添加、删除还是阅读笔记。最后,如果我们已经更新了数组,就像我们在添加和删除笔记时所做的那样,我们将这些新的笔记保存回 JSON 文件中。

现在,所有这些将发生在我们在notes.js应用程序中定义的addNote函数内部,我们已经连接了这个函数。在之前的部分中,我们运行了add命令,这个函数执行了titlebody参数。

添加笔记

要开始添加笔记,我们要做的第一件事是创建一个名为notes的变量,暂时将其设置为空数组,就像下面这样使用我们的方括号:

var addNote = (title, body) => {
  var notes = [];
};

现在我们有了空数组,我们可以继续创建一个名为note的变量,这是单个笔记。这将代表新的笔记:

var addNote = (title, body) => {
  var notes = [];
  var note = {

  }
};

在这一点上,我们将有两个属性:一个title和一个body。现在,title可以设置为title变量,但是,正如我们所知,在 ES6 中,当两个值相同时,我们可以简单地将其删除;因此,我们将添加titlebody如下所示:

var addNote = (title, body) => {
  var notes = [];
  var note = {
    title,
    body
  };
};

现在我们有了notenotes数组。

将笔记添加到笔记数组中

添加笔记过程中的下一步将是将note添加到notes数组中。notes.push方法将让我们做到这一点。数组上的push方法允许您传入一个项目,该项目将被添加到数组的末尾,在这种情况下,我们将传入note对象。因此,我们有一个空数组,并且我们添加了一个项目,如下面的代码所示;接下来,我们将其推入,这意味着我们有一个包含一个项目的数组:

var addNote = (title, body) => {
  var notes = [];
  var note = {
    title,
    body
  };

  notes.push(note);
};

下一步将是更新文件。现在,我们没有文件,但我们可以加载一个fs函数并开始创建文件。

addNote函数的上面,让我们加载fs模块。我将创建一个名为fsconst变量,并将其设置为require的返回结果,并且我们将要求fs模块,这是一个核心的 node 模块,因此不需要使用 NPM 安装它:

const fs = require('fs');

有了这个,我们可以在addNote函数内部利用fs

在我们将项目推入notes数组之后,我们将调用fs.writeFileSync,这是我们以前使用过的。我们知道我们需要传入两件事:文件名和我们想要保存的内容。对于文件,我将调用notes-data.JSON,然后我们将传入要保存的内容,这种情况下将是stringify notes 数组,这意味着我们可以调用JSON.stringify传入notes

notes.push(note);
fs.writeFileSync('notes-data.json', JSON.stringify(notes));

我们本可以将JSON.stringfy(notes)拆分为自己的变量,并在上面的语句中引用该变量,但由于我们只会在一个地方使用它,我认为这是更好的解决方案。

在这一点上,当我们添加一个新的笔记时,它将更新notes-data.JSON文件,该文件将在机器上创建,因为它不存在,并且笔记将位于其中。现在,重要的是要注意,当前每次添加新的笔记时,它将擦除所有现有的笔记,因为我们从未加载现有的笔记,但我们可以开始测试这个笔记是否按预期工作。

我将保存文件,在终端内部,我们可以使用node app.js运行这个文件。因为我们想要添加一个note,我们将使用我们设置的add命令,然后我们将指定我们的标题和正文。title标志可以设置为secret,对于body标志,我将把它设置为Some body here字符串,如下所示:

node app.js add --title=secret --body="Some body here"

现在,当我们从终端运行这个命令时,我们将看到我们所期望的结果:

如前面的屏幕截图所示,我们看到了我们添加的一些文件命令:我们看到add命令被执行了,并且我们有我们的 Yargs 参数。标题和正文参数也显示出来了。在 Atom 中,我们还看到了一个新的notes-data.json文件,在下面的屏幕截图中,我们有我们的笔记,有secret标题和Some body here正文:

这是连接addNote函数的第一步。我们有一个现有的notes文件,我们确实希望利用这些笔记。如果笔记已经存在,我们不希望每次有人添加新笔记时都将它们简单地清除。这意味着在notes.js中,在addNote函数的开头,我们将获取这些笔记。

获取新笔记

我将添加获取新笔记的代码,在那里我定义了notesnote变量。如下面的代码所示,我们将使用fs.readFileSync,这是我们已经探索过的。这将获取文件名,在我们的情况下是notes-data.JSON。现在,我们将希望将readFileSync的返回值存储在一个变量上;我将称这个变量为notesString

var notesString = fs.readFileSync('notes-data.json');

由于这是字符串版本,我们还没有通过JSON.parse方法传递它。因此,我可以将notes(我们在addNote函数中之前定义的变量)设置为JSON.parse方法的返回值。然后JSON.parse将获取我们从文件中读取的字符串,并将其解析为一个数组;我们可以像这样传递notesString

notes = JSON.parse(notesString);

有了这个,添加新的笔记将不再删除已经存在的所有笔记。

在终端中,我将使用上箭头键加载上一个命令,并导航到title标志,将其更改为secret2,然后重新运行命令:

node app.js add --title=secret2 --body="Some body here"

在 Atom 中,这次你可以看到我们的文件中现在有两个笔记:

我们有一个包含两个对象的数组;第一个对象的标题是secret,第二个对象的标题是secret2,这太棒了!

尝试和捕获代码块

现在,如果notes-data.json文件不存在,当用户第一次运行命令时,程序将崩溃,如下面的代码输出所示。我们可以通过简单地删除note-data.JSON文件后重新运行上一个命令来证明这一点:

在这里,你可以看到我们实际上遇到了一个 JavaScript 错误,没有这样的文件或目录;它试图打开notes-data.JSON文件,但并不成功。为了解决这个问题,我们将使用 JavaScript 中的try-catch语句,希望你之前已经见过。为了快速复习一下,让我们来看一下。

要创建一个try-catch语句,你所要做的就是输入try,这是一个保留关键字,然后打开和关闭一对花括号。花括号内部是将要运行的代码。这是可能会或可能不会抛出错误的代码。接下来,你将指定catch块。现在,catch块将带有一个参数,一个错误参数,并且还有一个将运行的代码块:

try{

} catch (e) {

}

只有在try中的一个错误实际发生时,此代码才会运行。因此,如果我们使用readFileSync加载文件并且文件存在,那就没问题,catch块将永远不会运行。如果失败,catch块将运行,我们可以做一些事情来从错误中恢复。有了这个,我们将把noteString变量和JSON.parse语句移到try中,如下所示:

try{
  var notesString = fs.readFileSync('notes-data.json');
  notes = JSON.parse(notesString);
} catch (e) {

}

就是这样;不需要发生其他任何事情。我们不需要在catch中放任何代码,尽管您需要定义catch块。现在,让我们看看运行整个代码时会发生什么。

首先发生的事情是我们创建静态变量——没有什么特别的——然后我们尝试加载文件。如果notesString函数失败,那没关系,因为我们已经定义notes为空数组。如果文件不存在并且加载失败,那么我们可能希望notes为空数组,因为显然没有notes,也没有文件。

接下来,我们将把数据解析成 notes。如果notes-data.JSON文件中有无效数据,这两行可能会失败。通过将它们放在try-catch中,我们基本上保证程序不会出现意外情况,无论文件是否存在,但包含损坏的数据。

有了这个,我们现在可以保存notes并重新运行之前的命令。请注意,我没有放置notes-data文件。当我运行命令时,我们没有看到任何错误,一切似乎都按预期运行:

当您现在访问 Atom 时,您会发现notes-data文件确实存在,并且其中的数据看起来很棒:

这就是我们需要做的一切,获取 notes,使用新 note 更新 notes,最后将 notes 保存到屏幕上。

现在,addNote还存在一个小问题。目前,addNote允许重复的标题;我可以在 JSON 文件中已经有一个标题为secret的 note。我可以尝试添加一个标题为secret的新 note,它不会抛出错误。我想要做的是使标题唯一,这样如果已经有一个具有该标题的 note,它将抛出错误,让您知道需要使用不同的标题创建 note。

使标题唯一

使标题唯一的第一步是在加载 note 后循环遍历所有 note,并检查是否有任何重复项。如果有重复项,我们将不调用以下两行:

notes.push(note);
fs.writeFileSync('notes-data.json', JSON.stringify(notes));

如果没有重复项,那就没问题,我们将调用前面代码块中显示的两行,更新notes-data文件。

现在,我们将在以后重构这个函数。事情变得有点混乱,有点失控,但目前,我们可以将这个功能直接添加到函数中。让我们继续并创建一个名为duplicateNotes的变量。

duplicateNotes变量最终将存储一个数组,其中包含notes数组中已经存在的具有您尝试创建的 note 标题的 note。现在,这意味着如果duplicateNotes数组有任何项目,那就不好。这意味着该 note 已经存在,我们不应该添加该 note。duplicateNotes变量将被设置为对notes的调用,这是我们的notes.filter数组:

var duplicateNotes = notes.filter();

filter方法是一个接受回调的数组方法。我们将使用箭头函数,回调将使用参数调用。在这种情况下,它将是单数形式;如果我有一个 notes 数组,它将被调用为一个单独的 note:

var duplicateNotes = notes.filter((note) => {

});

这个函数会为数组中的每个项目调用一次,并且你有机会返回 true 或 false。如果你返回 true,它会保留数组中的那个项目,最终会保存到duplicateNotes中。如果你返回 false,它生成的新数组就不会包含duplicateNotes变量中的那个项目。我们只需要在标题匹配时返回 true,这意味着我们可以返回note.title === title,如下所示:

var duplicateNotes = notes.filter((note) => {
  return note.title === title;
});

如果标题相等,那么前面的return语句将返回 true,并且该项目将保留在数组中,这意味着有重复的笔记。如果标题不相等,这很可能是情况,那么该语句将返回 false,这意味着没有重复的笔记。现在,我们可以使用箭头函数来简化一下。

箭头函数实际上允许你在只有一个语句的情况下删除花括号。

我将使用箭头函数,如下所示:

var duplicateNotes = notes.filter((note) => note.title === title);

在这里,我已经删除了除了note.title === title之外的所有内容,并在箭头函数语法的前面添加了这个。

这在 ES6 箭头函数中是完全有效的。你的参数在左边,箭头在中间,右边是一个表达式。这个表达式不需要分号,它会自动返回作为函数结果。这意味着我们这里的代码与之前的代码是相同的,只是更简单,而且只占用一行。

现在我们有了这个设置,我们可以继续检查duplicateNotes变量的长度。如果duplicateNotes的长度大于0,这意味着我们不想保存这个笔记,因为已经存在一个具有相同标题的笔记。如果它是0,我们将保存这个笔记。

if(duplicateNotes.length === 0) {

}

在这里,在if条件内部,我们正在将笔记的长度与数字 0 进行比较。如果它们相等,那么我们确实想要将笔记推送到notes数组中并保存文件。我将删除以下两行:

notes.push(note);
fs.writeFileSync('notes-data.json', JSON.stringify(notes));

让我们把它们粘贴到if语句的内部,如下所示:

if(duplicateNotes.length === 0) {
  notes.push(note);
  fs.writeFileSync('notes-data.json', JSON.stringify(notes));
}

如果它们不相等,也没关系;在这种情况下,我们将什么也不做。

有了这个设置,我们现在可以保存我们的文件并测试这个功能。我们有我们的notes-data.json文件,这个文件已经有一个标题为secret2的笔记。让我们重新运行之前的命令,尝试添加一个具有相同标题的新笔记:

node app.js add --title=secret2 --body="Some body here"

你现在在终端中,所以我们将回到我们的 JSON 文件。你可以看到,我们仍然只有一个笔记:

现在我们应用程序中的所有标题都将是唯一的,所以我们可以使用这些标题来获取和删除笔记。

让我们继续测试其他笔记是否仍然可以添加。我将把title标志从secret2改为secret,然后运行该命令:

node app.js add --title=secret --body="Some body here"

在我们的notes-data文件中,你可以看到两个笔记都显示出来:

正如我之前提到的,接下来我们将进行一些重构,因为加载文件的代码和保存文件的代码将在我们已经定义和/或将要定义的大多数函数中使用(即getAllgetNoteremoveNote函数)。

重构

在前面的部分中,你创建了addNote函数,它工作得很好。它首先创建一些静态变量,然后我们获取任何现有的笔记,检查重复项,如果没有,我们将其推送到列表上,然后将数据保存回文件系统。

唯一的问题是,我们将不断重复执行这些步骤。例如,对于getAll,我们的想法是获取所有的笔记,并将它们发送回app.js,以便它可以将它们打印到用户的屏幕上。在getAll语句的内部,我们首先要做的是有相同的代码;我们将有我们的try-catch块来获取现有的笔记。

现在,这是一个问题,因为我们将在整个应用程序中重复使用代码。最好将获取笔记和保存笔记拆分为单独的函数,我们可以在多个位置调用这些函数。

将功能移入各个函数

为了解决问题,我想首先创建两个新函数:

  • fetchNotes

  • saveNotes

第一个函数fetchNotes将是一个箭头函数,它不需要接受任何参数,因为它将从文件系统中获取笔记,如下所示:

var fetchNotes = () => {

};

第二个函数saveNotes将需要接受一个参数。它将需要接受要保存到文件系统的notes数组。我们将设置它等于一个箭头函数,然后提供我们的参数,我将其命名为notes,如下所示:

var saveNotes = (notes) => {

};

现在我们有了这两个函数,我们可以开始将一些功能从addNote中移动到各个函数中。

使用 fetchNotes

首先,让我们做fetchNotes,它将需要以下try-catch块。

我将它从addNote中剪切出来,粘贴到fetchNotes函数中,如下所示:

var fetchNotes = () => {
  try{
    var notesString = fs.readFileSync('notes-data.json');
    notes = JSON.parse(notesString);
  } catch (e) {

}
};

仅此还不够,因为目前我们没有从函数中返回任何内容。我们想要做的是返回这些笔记。这意味着我们不会将JSON.parse的结果保存到我们尚未定义的notes变量上,而是简单地将其返回给调用函数,如下所示:

var fetchNotes = () => {
  try{
    var notesString = fs.readFileSync('notes-data.json');
    return JSON.parse(notesString);
  } catch (e) {

}
};

因此,如果我在addNote函数中调用fetchNotes,如下所示,我将得到notes数组,因为在前面的代码中有return语句。

现在,如果没有笔记,可能根本没有文件;或者有一个文件,但数据不是 JSON,我们可以返回一个空数组。我们将在catch中添加一个return语句,如下面的代码块所示,因为请记住,如果try中的任何内容失败,catch就会运行:

var fetchNotes = () => {
  try{
    var notesString = fs.readFileSync('notes-data.json');
    return JSON.parse(notesString);
  } catch (e) {
    return [];
}
};

现在,这让我们进一步简化了addNote。我们可以删除空格,并且可以取出我们在notes变量上设置的数组,并将其删除,而是调用fetchNotes,如下所示:

var addNote = (title, body) => {
  var notes = fetchNotes();
  var note = {
      title,
      body
};

有了这个,我们现在有了与之前完全相同的功能,但是我们有了一个可重用的函数fetchNotes,我们可以在addNote函数中使用它来处理应用程序将支持的其他命令。

我们已经将代码拆分到一个地方,而不是复制代码并将其放在文件的多个位置。如果我们想要更改此功能的工作方式,无论是更改文件名还是一些逻辑,比如try-catch块,我们只需更改一次,而不必更改每个函数中的代码。

使用 saveNotes

现在,saveNotes的情况与fetchNotes函数一样。saveNotes函数将获取notes变量,并使用fs.writeFileSync来保存。我将剪切addNote中执行此操作的行(即fs.writeFileSync('notes-data.json', JSON.stringfy(notes));),并将其粘贴到saveNotes函数中,如下所示:

var saveNotes = (notes) => {
  fs.writeFileSync('notes-data.json', JSON.stringify(notes));
};

现在,saveNotes不需要返回任何内容。在这种情况下,我们将在addNote函数的if语句中复制saveNotes中的行,并调用saveNotes,如下所示:

if (duplicateNotes.length === 0) {
  notes.push(note);
  saveNotes();
}

这可能看起来有点多余,我们实际上是用不同的行替换了一行,但开始养成创建可重用函数的习惯是一个好主意。

现在,如果没有数据调用saveNotes是行不通的,我们想要传入notes变量,这是我们在saveNotes函数中之前定义的notes数组:

if (duplicateNotes.length === 0) {
  notes.push(note);
  saveNotes(notes);
}

有了这个,addNote函数现在应该像我们进行重构之前一样工作。

测试功能

在这个过程中的下一步将是通过创建一个新的笔记来测试这个功能。我们已经有两个笔记,在notes-data.json中有一个标题是secret,一个标题是secret2,让我们使用终端中的node app.js命令来创建第三个笔记。我们将使用add命令并传入一个标题to buy和一个正文food,就像这里显示的那样:

node app.js add --title="to buy" --body="food"

这应该会创建一个新的笔记,如果我运行这个命令,你会看到我们没有任何明显的错误:

在我们的notes-data.json文件中,如果我向右滚动,我们有一个全新的笔记,标题是to buy,正文是food

所以,即使我们重构了代码,一切都按预期工作。现在,我想在addNote中的下一步是花点时间返回正在添加的笔记,这将发生在saveNotes返回之后。所以我们将返回note

if (duplicateNotes.length === 0) {
  notes.push(note);
  saveNotes(notes);
  return note;
}

这个note对象将被返回给调用该函数的人,而在这种情况下,它将被返回给app.js,我们在app.js文件的add命令的if else块中调用它。我们可以创建一个变量来存储这个结果,我们可以称之为note

if (command === 'add')
  var note = notes.addNote(argv.title, argv.body);

如果note存在,那么我们知道笔记已经创建。这意味着我们可以继续打印一条消息,比如Note created,然后我们可以打印note的标题和note的正文。现在,如果note不存在,如果它是未定义的,这意味着有一个重复的标题已经存在。如果是这种情况,我希望你打印一个错误消息,比如Note title already in use

你可以用很多不同的方法来做这个。不过,目标是根据是否返回了笔记来打印两条不同的消息。

现在,在addNote中,如果duplicateNotesif语句从未运行,我们就没有显式调用return。但是你知道,在 JavaScript 中,如果你不调用return,那么undefined会自动返回。这意味着如果duplicateNotes.length不等于零,将返回 undefined,我们可以将其用作我们语句的条件。

首先,我要做的是在我们在app.js中定义的note变量旁边创建一个if语句:

if (command === 'add') {
  var note = notes.addNote(argv.title, argv.body);
  if (note) {

  }

如果事情进展顺利,这将是一个对象,如果事情进展不顺利,它将是未定义的。这里的代码只有在它是一个对象的时候才会运行。在 JavaScript 中,Undefined的结果会使 JavaScript 中的条件失败。

现在,如果笔记成功创建,我们将通过以下console.log语句向屏幕打印一条消息:

if (note) {
  console.log('Note created');
}

如果事情进展不顺利,在else子句中,我们可以调用console.log,并打印一些像Note title taken这样的东西,就像这里显示的那样:

if (note) {
  console.log('Note created');
} else {
  console.log('Note title taken');
}

现在,如果事情进展顺利,我们想要做的另一件事是打印notes的内容。我会首先使用console.log打印几个连字符。这将在我的笔记上方创建一些空间。然后我可以使用console.log两次:第一次我们将打印标题,我会添加Title:作为一个字符串来显示你究竟看到了什么,然后我可以连接标题,我们可以在note.title中访问到,就像这段代码中显示的那样:

if (note) {
  console.log('Note created');
  console.log('--');
  console.log('Title: ' + note.title);

现在,上面的语法使用了 ES5 的语法;我们可以用我们已经讨论过的内容,即模板字符串,来换成 ES6 的语法。我们会添加Title,一个冒号,然后我们可以使用我们的美元符号和花括号来注入note.title变量,就像这里显示的那样:

console.log(`Title: ${note.title}`);

同样地,我会在此之后添加note.body来打印笔记的正文。有了这个,代码应该看起来像这样:

if (command === 'add') {
  var note = note.addNote(argv.title, argv.body);
  if (note) {
    console.log('Note created');
    console.log('--');
    console.log(`Title: ${note.title}`);
    console.log(`Body: ${note.body}`);
  } else {
    console.log('Note title taken');
}

现在,我们应该能够运行我们的应用程序并看到标题和正文笔记都被打印出来。在终端中,我会重新运行之前的命令。这将尝试创建一个已经存在的购买笔记,所以我们应该会得到一个错误消息,你可以在这里看到Note title taken

现在,我们可以重新运行命令,将标题更改为其他内容,比如从商店购买。这是一个独特的note标题,因此笔记应该可以顺利创建:

node app.js add --title="to buy from store" --body="food"

如前面的输出所示,您可以看到我们确实得到了这样的结果:我们有我们的笔记创建消息,我们的小间隔,以及我们的标题和正文。

addNote命令现在已经完成。当命令实际完成时,我们会得到一个输出,并且我们有所有在后台运行的代码,将笔记添加到存储在我们文件中的数据中。

总结

在本章中,您了解到在process.argv中解析可能会非常痛苦。我们将不得不编写大量手动代码来解析那些连字符、等号和可选引号。然而,yargs 可以为我们完成所有这些工作,并将其放在一个非常简单的对象上,我们可以访问它。您还学会了如何在 Node.js 中使用 JSON。

接下来,我们填写了addNote函数。我们能够使用命令行添加笔记,并且能够将这些笔记保存到一个 JSON 文件中。最后,我们将addNote中的大部分代码提取到单独的函数fetchNotessaveNotes中,这些函数现在是独立的,并且可以在整个代码中重复使用。当我们开始填写其他方法时,我们可以简单地调用fetchNotessaveNotes,而不必一遍又一遍地将内容复制到每个新方法中。

在下一章中,我们将继续探讨有关 node 的基本知识。我们将探索与 node 相关的一些更多概念,比如调试;我们将处理readremove笔记命令。除此之外,我们还将学习有关 yargs 和箭头函数的高级特性。

第四章:Node 基础知识-第三部分

我们开始为笔记应用程序中的所有其他命令添加支持。我们将看看如何创建我们的read命令。read命令将负责获取单个笔记的正文。它将获取所有笔记并将它们打印到屏幕上。除此之外,我们还将研究如何调试损坏的应用程序,并了解一些新的 ES6 功能。您将学习如何使用内置的 Nodedebugger

然后,您将学习更多关于如何配置 yargs 以用于命令行界面应用程序。我们将学习如何设置命令、它们的描述和参数。我们将能够在参数上设置各种属性,例如它们是否是必需的等等。

删除笔记

在这一部分,当有人使用remove命令并传入他们想要移除的笔记的标题时,您将编写删除笔记的代码。在上一章中,我们已经创建了一些实用函数来帮助我们获取和保存笔记,所以代码实际上应该非常简单。

使用removeNote函数

这个过程的第一步是填写我们在之前章节中定义的removeNote函数,这将是您的挑战。让我们从notes.js文件的removeNote函数中删除console.log。您只需要编写三行代码就可以完成这项任务。

现在,第一行将获取笔记,然后工作将是过滤掉笔记,删除参数标题的笔记。这意味着我们想要遍历笔记数组中的所有笔记,如果它们中的任何一个标题与我们想要删除的标题匹配,我们就想要摆脱它们。这可以使用我们之前使用的notes.filter函数来完成。我们只需要在duplicateNotes函数中的等式语句中切换为不等式,这段代码就可以做到这一点。

它将遍历笔记数组。每当它找到一个与标题不匹配的笔记时,它将保留它,这是我们想要的,如果它找到标题,它将返回false并将其从数组中删除。然后我们将添加第三行,即保存新的笔记数组:

var removeNote = (title) => {
  // fetch notes
  // filter notes, removing the one with title of argument
  // save new notes array
};

上述代码行是您需要填写的唯一三行。不要担心从removeNote返回任何内容或填写app.js中的任何内容。

对于fetchNotes行的第一件事是创建一个名为notes的变量,就像我们在上一章的addNote中所做的一样,并将其设置为从fetchNotes返回的结果:

var removeNote = (title) => {
  var notes = fetchNotes();
  // filter notes, removing the one with title of argument
  // save new notes array
};

此时,我们的笔记变量存储了所有笔记的数组。我们需要做的下一件事是过滤我们的笔记。

如果有一个具有这个标题的笔记,我们想要删除它。这将通过创建一个新变量来完成,我将称其为filteredNotes。在这里,我们将filteredNotes设置为将从notes.filter返回的结果,这是我们之前已经使用过的:

var removeNote = (title) => {
  var notes = fetchNotes();
  // filter notes, removing the one with title of argument
  var filteredNotes = notes.filter();
  // save new notes array
};

我们知道notes.filter接受一个函数作为它唯一的参数,并且该函数被调用时会用数组中的单个项目。在这种情况下,它将是一个note。我们可以使用 ES6 箭头语法在一行上完成所有这些。

如果我们只有一条语句,我们不需要打开和关闭大括号。

这意味着在这里,如果note.title不等于传入函数的标题,我们可以返回true

var removeNote = (title) => {
  var notes = fetchNotes();
  var filteredNotes = notes.filter((note) => note.title !== title);
  // save new notes array
};

这将用所有标题与传入标题不匹配的所有笔记填充filteredNotes。如果标题与传入的标题匹配,它将不会被添加到filteredNotes中,因为我们的过滤函数。

最后要做的是调用saveNotes。在这里,我们将调用saveNotes,传入我们在filteredNotes变量下拥有的新笔记数组:

var removeNote = (title) => {
  var notes = fetchNotes();
  var filteredNotes = notes.filter((note) => note.title !== title);
  saveNotes(filteredNotes);
  // save new notes array
};

如果我们传入笔记,它不会按预期工作;我们正在过滤掉笔记,但实际上并没有保存这些笔记,因此它不会从 JSON 中删除。我们需要像前面的代码中所示那样传递filteredNotes。我们可以通过保存文件并尝试删除我们的笔记来测试这些。

我将尝试从notes-data.json文件中删除secret2。这意味着我们需要做的就是运行我们在app.js中指定的命令remove(参考下面的代码图像,然后它将调用我们的函数)。

我将使用app.js运行 Node,并传入remove命令。我们需要为 remove 提供的唯一参数是标题;无需提供正文。我将把这个设置为secret2

node app.js remove --title=secret2

如屏幕截图所示,如果我按enter,你会看到我们没有得到任何输出。虽然我们有删除打印命令,但没有消息表明是否删除了笔记,但我们稍后会在本节中添加。

现在,我们可以检查数据。在这里你可以看到secret2不见了:

这意味着我们的 remove 方法确实按预期工作。它删除了标题匹配的笔记,并保留了所有标题不等于secret2的笔记,这正是我们想要的。

打印删除笔记的消息

现在,我们要做的下一件事是根据是否实际删除了笔记来打印消息。这意味着调用removeNote函数的app.js需要知道是否删除了笔记。我们如何弄清楚呢?在notes.js removeNotes函数中,我们如何可能返回那个信息?

我们可以这样做,因为我们有两个非常重要的信息。我们有原始笔记数组的长度和新笔记数组的长度。如果它们相等,那么我们可以假设没有笔记被删除。如果它们不相等,我们将假设已经删除了一个笔记。这正是我们要做的。

如果removeNote函数返回true,那意味着已删除一个笔记;如果返回false,那意味着没有删除笔记。在removeNotes函数中,我们可以添加返回,如下面的代码所示。我们将检查notes.length是否不等于filteredNotes.length

var removeNote = (title) => {
  var notes = fetchNotes();
  var filteredNotes = notes.filter((note) => note.title !== title);
  saveNotes(filteredNotes);

 return notes.length !== filteredNotes.length;
};

如果它们不相等,它将返回true,这是我们想要的,因为已经删除了一个笔记。如果它们相等,它将返回false,这很好。

现在,在app.js中,我们可以在removeNoteelse if块中添加几行代码,以使此命令的输出更加友好。要做的第一件事是存储布尔值。我将创建一个名为noteRemoved的变量,并将其设置为返回的结果,如下面的代码所示,它将是truefalse

} else if (command == 'remove') {
  var noteRemoved = notes.removeNote(argv.title);
}

在下一行,我们可以创建我们的消息,我将使用三元运算符在一行上完成所有这些。现在,三元运算符允许您指定条件。在我们的情况下,我们将使用一个变量消息,并将其设置为条件noteRemoved,如果删除了一个笔记,则为true,如果没有,则为false

现在,三元运算符可能有点令人困惑,但在 JavaScript 和 Node.js 中非常有用。三元运算符的格式是首先添加条件,问号,要运行的真值表达式,冒号,然后是要运行的假值表达式。

在条件之后,我们将放一个空格,然后是一个问号和一个空格;这是如果条件为真时将运行的语句。如果noteRemoved条件通过,我们要做的是将消息设置为Note was removed

 var message = noteRemoved ? 'Note was removed' :

现在,如果noteRemovedfalse,我们可以在前一个语句的冒号后面指定该条件。在这里,如果没有笔记被删除,我们将使用文本Note not found

var message = noteRemoved ? 'Note was removed' : 'Note not found';

现在,有了这个,我们可以测试一下我们的消息。最后要做的就是使用console.log将消息打印到屏幕上:

var noteRemoved = notes.removeNote(argv.title);
var message = noteRemoved ? 'Note was removed' : 'Note not found';
console.log(message);

这使我们避免了使我们的else-if子句变得不必要复杂的if语句。

回到 Atom 中,我们可以重新运行上一个命令,在这种情况下,没有笔记会被删除,因为我们已经删除了它。当我运行它时,你可以看到Note not found打印到屏幕上:

现在,我将删除一个确实存在的笔记;在notes-data.json中,我有一个标题为 secret 的笔记,如下所示:

让我们重新运行在终端中删除标题中的2的命令。当我运行这个命令时,你可以看到Note was removed打印到屏幕上:

这就是本节的全部内容;我们现在已经有了我们的remove命令。

阅读笔记

在本节中,您将负责填写read命令的其余部分。现在,read命令确实有一个 else-if 块,在app.js中找到我们调用getNote的地方:

} else if (command === 'read') {
  notes.getNote(argv.title);

getNotenotes.js中定义,尽管目前它只是打印一些虚拟文本:

var getNote = (title) => {
  console.log('Getting note', title);
};

在本节中,您需要连接这两个函数。

首先,您需要对getNote的返回值做一些处理。如果我们的getNote函数找到了笔记对象,它将返回该笔记对象。如果没有找到,它将返回 undefined,就像我们在上一章节添加和保存笔记中讨论的addNote一样。

存储了该值之后,您将使用console.log进行一些打印,类似于我们这里所做的:

if (command === 'add') {
  var note = notes.addNote(argv.title, argv.body);
  if (note) {
    console.log('Note created');
    console.log('--');
    console.log(`Title: ${note.title}`);
    console.log(`Body: ${note.body}`);
  } else {
    console.log('Note title taken');
  }

显然,Note created将类似于Note readNote title taken将类似于Note not found,但是一般的流程将完全相同。现在,一旦您在app.js中连接了这一切,您可以继续填写函数的notes.js

现在,在notes.js中的函数不会太复杂。您需要做的只是获取笔记,就像我们在以前的方法中所做的那样,然后您将使用notes.filter,我们探索了只返回标题与作为参数传入的标题匹配的笔记。现在,在我们的情况下,这要么是零个笔记,这意味着找不到笔记,要么是一个笔记,这意味着我们找到了人们想要返回的笔记。

接下来,我们确实需要返回那个笔记。重要的是要记住,notes.filter的返回值始终是一个数组,即使该数组只有一个项目。您需要做的是返回数组中的第一个项目。如果该项目不存在,那没关系,它将返回 undefined,这正是我们想要的。如果存在,那很好,这意味着我们找到了笔记。这个方法只需要三行代码,一个用于获取,一个用于过滤,一个用于返回语句。现在,一旦您完成了所有这些,我们将对其进行测试。

使用 getNote 函数

让我们来处理这个方法。现在,我要做的第一件事是在app.js中填写一个名为 note 的变量,它将存储从getNote返回的值:

} else if (command === 'read') {
  var note = notes.getNote(argv.title);

现在,这可能是一个单独的笔记对象,也可能是 undefined。在下一行,我可以使用if语句来打印消息,如果它存在,或者如果它不存在。我将使用if note,并且我将附加一个else子句:

} else if (command === 'read') {
  var note = notes.getNote(argv.title);
  if (note) {

  } else {

  }

这个else子句将负责在找不到笔记时打印错误。让我们先从这个开始,因为它非常简单,console.logNote not found,如下所示:

  if (note) {

  } else {
    console.log('Note not found');  
  }

现在我们已经填写了else子句,我们可以填写if语句。对于这一点,我将打印一条小消息,console.log ('Note found')就可以了。然后我们可以继续打印实际的笔记详情,我们已经有了这段代码。我们将添加连字符间隔,然后有我们的笔记标题和笔记正文,如下所示:

if (note) {
    console.log('Note found');
    console.log('--');
    console.log(`Title: ${note.title}`);
    console.log(`Body: ${note.body}`);    
  } else {
    console.log('Note not found');  
  }

现在我们已经完成了app.js的内部,我们可以进入notes.js文件,并填写getNote方法,因为目前它没有对传入的标题做任何处理。

在 notes 中,你需要做的是填写这三行。第一行将负责获取笔记。我们在上一节中已经用fetchNotes函数做过了:

var getNote = (title) => {
  var notes = fetchNotes();
};

现在我们已经准备好了笔记,我们可以调用notes.filter,返回所有的笔记。我将创建一个名为filteredNotes的变量,将其设置为notes.filter。现在,我们知道 filter 方法需要一个函数,我将定义一个箭头函数(=>)就像这样:

var filteredNotes = notes.filter(() => {

});

在箭头函数(=>)中,我们将传入的单个笔记,并在笔记标题,也就是我们在 JSON 文件中找到的笔记标题,等于标题时返回true

var filteredNotes = notes.filter(() => {
    return note.title === title;
  });
};

当笔记标题匹配时,这将返回true,如果不匹配则返回 false。或者,我们可以使用箭头函数,我们只有一行代码,如下所示,我们返回了一些东西;我们可以剪切掉我们的条件,删除大括号,然后简单地将该条件粘贴到这里:

var filteredNotes = notes.filter((note) => note.title === title);

这具有完全相同的功能,只是更短,更容易查看。

现在我们已经有了所有的数据,我们只需要返回一些东西,我们将返回filteredNotes数组中的第一个项目。接下来,我们将获取第一个项目,也就是索引为零的项目,然后我们只需要使用return关键字返回它:

var getNote = (title) => {
  var notes = fetchNotes();
  var filteredNotes = notes.filter((note) => note.title === title);
  return filteredNotes[0];
};

现在,有可能filteredNotes,第一个项目不存在,没关系,它将返回 undefined,在这种情况下,我们的 else 子句将运行,打印找不到笔记。如果有笔记,太好了,那就是我们想要打印的笔记,在app.js中我们就是这样做的。

运行getNote函数

既然我们已经准备就绪,我们可以通过在终端中运行我们的应用程序node app.js来测试这个全新的功能。我将使用read命令,并传入一个标题等于我知道在notes-data.json文件中不存在的字符串:

node app.js read --title="something here"

当我运行命令时,我们得到了找不到笔记,如图所示,这正是我们想要的:

现在,如果我尝试获取一个标题存在的笔记,我期望那个笔记会返回。

在数据文件中,我有一个标题为购买的笔记;让我们尝试获取它。我将使用上箭头键填充上一个命令,并将标题替换为to space,购买,并按enter

如前面的代码所示,您可以看到找到笔记打印到屏幕上,这太棒了。在找到笔记之后,我们有我们的间隔符,然后是标题购买和正文食物,正如它出现在数据文件中一样。有了这个,我们就完成了read命令。

DRY 原则

现在,在我们结束本节之前,我还想解决一件事。在app.js中,我们现在在两个地方有相同的代码。我们在add命令以及read命令中都有空格或标题正文:

if (command === 'add') {
  var note = notes.addNote(argv.title, argv.body);
  if (note) {
    console.log('Note created');
    console.log('--');
    console.log(`Title: ${note.title}`);
    console.log(`Body: ${note.body}`);
  } else {
    console.log('Note title taken');
  }
 } else if (command === 'list') {
   notes.getAll();
 } else if (command === 'read') {
   var note = notes.getNote(argv.title);
   if (note) {
     console.log('Note found');
     console.log('--');
     console.log(`Title: ${note.title}`);
     console.log(`Body: ${note.body}`);
   } else {
     console.log('Note not found');
 }

当你发现自己在复制和粘贴代码时,最好将其拆分成一个函数,两个位置都调用该函数。这就是DRY 原则,它代表不要重复自己

使用 logNote 函数

在我们的情况下,我们在重复自己。最好将其拆分成一个函数,我们可以从两个地方调用它。为了做到这一点,我们要做的就是在notes.js中创建一个名为logNote的函数。

现在,在notes.js中,在removeNote函数下面,我们可以将这个全新的函数命名为logNote。这将是一个带有一个参数的函数。这个参数将是笔记对象,因为我们想要打印标题和正文。如下所示,我们期望传入笔记:

var logNote = (note) => {

};

现在,填写logNote函数将会非常简单,特别是当你解决 DRY 问题时,因为你可以简单地将重复的代码剪切出来,然后粘贴到logNote函数中。在这种情况下,变量名已经对齐,所以不需要更改任何内容:

var logNote = (note) => {
  console.log('--');
  console.log(`Title: ${note.title}`);
  console.log(`Body: ${note.body}`);
};

现在我们已经有了logNote函数,我们可以在app.js中进行更改。在app.js中,我们已经删除了console.log语句,我们可以调用notes.logNote,传入笔记对象就像这样:

else if (command === 'read') {
  var note = notes.getNote(argv.title);
  if (note) {
    console.log('Note found');
    notes.logNote(note);
  } else {
    console.log('Note not found');
 }

add命令的if块中也可以做同样的事情。我可以删除这三个console.log语句,并调用notes.logNote,传入笔记:

if (command === 'add') {
  var note = notes.addNote(argv.title, argv.body);
  if (note) {
    console.log('Note created');
    notes.logNote(note);
 } else {
   console.log('Note title taken');
 }

现在我们已经有了这个,我们可以重新运行我们的程序,希望我们看到的是完全相同的功能。

在重新运行程序之前要做的最后一件事是在notes.js文件的exports模块中导出logNote函数。LogNote将被导出,我们使用 ES6 语法来做到这一点:

module.exports = {
  addNote,
  getAll,
  getNote,
  removeNote,
  logNote
};

有了这个,我现在可以使用上箭头键重新运行 Terminal 中的上一个命令,然后按enter

node app.js read --title="to buy"

如所示,我们得到Note found打印到屏幕上,标题和正文就像以前一样。我还将测试add命令,以确保它正常工作,node app.js add;我们将使用一个标题things to do和一个正文go to post office

node app.js add --title="things to do" --body="go to post office"

现在,当我按下enter,我们期望打印的日志与之前的add命令一样,这正是我们得到的:

笔记创建后打印,我们得到我们的间隔,然后得到我们的标题和正文。

在下一节中,我们将涵盖本书中最重要的一个主题;调试。知道如何正确地调试程序将在你的 Node.js 职业生涯中节省你数百个小时。如果你没有正确的工具,调试可能会非常痛苦,但一旦你知道如何做,它其实并不那么糟糕,而且可以节省你大量的时间。

调试

在本节中,我们将使用内置的debugger,这可能看起来有点复杂,因为它在命令行中运行。这意味着你必须使用命令行界面,这并不总是最令人愉快的事情。不过,在下一节中,我们将安装一个使用 Chrome DevTools 来调试你的 Node 应用程序的第三方工具。这个看起来很棒,因为 Chrome DevTools 非常出色。

在调试模式下执行程序

在继续之前,我们将了解到我们确实需要创建一个调试的地方,这将在一个游乐场文件中进行,因为我们要编写的代码对notes应用程序本身并不重要。在 notes 应用程序中,我将创建一个名为debugging.js的新文件:

debugging.js中,我们将从一个基本示例开始。我们将创建一个名为person的对象,在该对象上,我们暂时设置一个属性名。将其设置为你的名字,我将我的设置为字符串Andrew,如下所示:

var person = {
  name: 'Andrew'
};

接下来我们将设置另一个属性,但在下一行,person.age。我将我的设置为我的年龄,25

var person = {
  name: 'Andrew'
};

person.age = 25;

然后我们将添加另一个语句来更改名称,person.name等于Mike之类的东西:

var person = {
  name: 'Andrew'
};

person.age = 25;

person.name = 'Mike';

最后,我们将console.log打印person对象,代码将如下所示:

var person = {
  name: 'Andrew'
};

person.age = 25;

person.name = 'Mike';

console.log(person);

现在,实际上在这个例子中我们已经有了一种调试的形式,我们有一个console.log语句。

当你进行 Node 应用程序开发过程时,你可能已经使用了console.log来调试你的应用程序。也许有些东西不像预期的那样工作,你想准确地弄清楚那个变量里面存储了什么。例如,如果你有一个解决数学问题的函数,也许在函数的某个部分方程式是错误的,你得到了一个不同的结果。

使用console.log可能是一个非常好的方法,但它的功能非常有限。我们可以通过在终端中运行它来查看,我将运行以下命令:

node playground/debugging.js

当我运行文件时,我确实可以在屏幕上打印出我的对象,这很好,但是,你知道,如果你想调试除了person对象之外的东西,你必须添加另一个console.log语句来做到这一点。

想象一下,你有一个类似我们的app.js文件,你想看看命令等于什么,然后你想看看argv等于什么,这可能需要花费很多时间来添加和删除那些console.log语句。有一种更好的调试方法。这就是使用 Node 的debugger。现在,在我们对项目进行任何更改之前,我们将看看debugger在终端内部是如何工作的,正如我在本节开头警告过你的,内置的 Nodedebugger虽然有效,但有点丑陋和难以使用。

不过,现在,我们将以基本相同的方式运行应用程序,只是这一次我们将输入node inspect。Node debug 将以与常规 Node 命令完全不同的方式运行我们的应用程序。我们在 playground 文件夹中运行相同的文件,它叫做debugging.js

node inspect playground/debugging.js

当你按下enter时,你应该会看到类似这样的东西:

在输出中,我们可以忽略前两行。这基本上意味着debugger已经正确设置,并且能够监听后台运行的应用程序。

接下来,我们在 playground 调试中有我们的第一个换行符在第一行,紧接着它你可以看到带有一个小尖号(>)的第一行。当你首次以调试模式运行你的应用程序时,它会在执行第一条语句之前暂停。当我们暂停在像第一行这样的行上时,这意味着这行还没有执行,所以在这个时间点上我们甚至还没有person变量。

现在,正如你在前面的代码中所看到的,我们还没有返回到命令行,Node 仍在等待输入,我们可以运行一些不同的命令。例如,我们可以运行n,它是下一个的缩写。你可以输入n,按下enter,这会移到下一个语句。

我们有下一条语句,第一行的语句被执行了,所以person变量确实存在。然后我可以再次使用n去到下一个语句,我们在那里声明person.name属性,将它从Andrew更新为Mike

注意,在这一点上,年龄确实存在,因为那行已经执行过了。

现在,n命令会逐条执行你的整个程序。如果你意识到你不想在整个程序中这样做,这可能需要花费很多时间,你可以使用cc命令是Continue的缩写,它会一直执行到程序的最后。在下面的代码中,你可以看到我们的console.log语句运行了名字Mike和年龄25

这就是如何使用debug关键字的一个快速示例。

现在,我们实际上并没有进行任何调试,我们只是运行了整个程序,因为在写这些命令时有点陌生,比如下一个和继续,我决定在没有调试的情况下进行一次干运行。你可以使用control + C来退出debugger并返回到终端。

我将使用clear来清除所有输出。现在我们对如何在debug模式下执行程序有了一个基本的了解,让我们看看我们实际上如何进行一些调试。

使用调试

我将使用上箭头键两次重新运行程序,返回到 Node debug命令。然后,我将运行程序,并连续按两次nn

此时,我们在第七行,这就是当前的断点所在。从这里,我们可以使用一个叫做repl的命令进行一些调试,它代表读取-求值-打印-循环。在我们的情况下,repl命令会将你带到debugger的一个完全独立的区域。当你使用它时,你实际上是在一个 Node 控制台中:

你可以运行任何 Node 命令,例如,我可以使用console.log打印出test,然后test就会打印出来。

我可以创建一个变量a,它等于13,然后我可以引用a,我可以看到它等于4,如下所示:

更重要的是,我们可以访问当前程序的状态,也就是在第七行执行之前的状态。我们可以使用这个来打印出person,如下面的代码所示,你可以看到person的名字是Andrew,因为第七行还没有执行,年龄是25,正如程序中显示的那样:

这就是调试变得非常有用的地方。能够在某个特定时间点暂停程序将使查找错误变得非常容易。我可以做任何我想做的事情,我可以打印出person的名字属性,并且它会在屏幕上打印出Andrew,如下所示:

现在,我们还是有这个问题。我必须通过程序按下next。当你有一个非常长的程序时,可能需要运行数百或数千个语句,然后才能到达你关心的点。显然这不是理想的,所以我们要找到更好的方法。

让我们使用control + C退出repl;现在我们回到了debugger

从这里开始,我们将在debugging.js中对我们的应用程序进行快速更改。

假设我们想要在第七行暂停,介于person年龄属性更新和person名字属性更新之间。为了暂停,我们要做的是运行语句debugger

var person = {
  name: 'Andrew'
};

person.age = 25;

debugger;

person.name = 'Mike';

console.log(person);

当你有一个像之前一样的debugger语句时,它告诉 Node debugger在这里停下,这意味着你可以使用c(continue)来继续,而不是使用n(next)逐条语句执行,它会一直执行,直到程序退出或者看到一个debugger关键字。

现在,在终端中,我们将重新运行程序,就像之前一样。这一次,我们不会按两次n,而是使用c来继续:

现在,当我们第一次使用c时,它到达了程序的末尾,打印出了我们的对象。这一次它会继续,直到找到debugger关键字。

现在,我们可以使用repl,访问任何我们喜欢的东西,例如,person.age,如下所示:

一旦我们完成调试,我们可以退出并继续执行程序。同样,我们可以使用control + C来退出repldebugger

真正的调试基本上都是使用debugger关键字。你可以把它放在程序的任何地方,以调试模式运行程序,最终它会到达debugger关键字,然后你可以做一些事情。例如,你可以探索一些变量值,运行一些函数,或者玩弄一些代码来找到错误。没有人真的使用n来逐行打印程序,找到导致问题的那一行。那太费时间了,而且不现实。

在笔记应用程序中使用调试器

现在你对debugger有了一点了解,我希望你在我们的笔记应用内使用它。我们将在notes.js内添加debugger语句到logNote函数的第一行。然后我将以调试模式运行程序,传入一些参数,这些参数将导致logNote运行;例如,读取一个笔记,在笔记被获取后,它将调用logNote

现在,一旦我们在logNote函数中有了debugger关键字,并以这些参数在调试模式下运行它,程序应该在这一点停止。一旦程序以调试模式启动,我们将使用c来继续,它会暂停。接下来,我们将打印出笔记对象并确保它看起来没问题。然后,我们可以退出repl并退出debugger

现在,首先我们在这里添加debugger语句:

var logNote = (note) => {
  debugger;
  console.log('--');
  console.log(`Title: ${note.title}`);
  console.log(`Body: ${note.body}`);
};

我们可以保存文件,现在我们可以进入终端;我们的应用内不需要做其他任何事情。

在终端内,我们将运行我们的app.js文件,node debug app.js,因为我们想以调试模式运行程序。然后我们可以传入我们的参数,比如read命令,我将传入一个标题,"to buy"如下所示:

node debug app.js read --title="to buy"

在这种情况下,我有一个标题为"to buy"的笔记,如下所示:

现在,当我运行上述命令时,它会在第一条语句运行之前暂停,这是预期的:

我现在可以使用c来继续程序。它会运行尽可能多的语句,直到程序结束或找到debugger关键字,如下面的代码所示,你可以看到debugger被找到,我们的程序已经停在notes.js的第49行:

这正是我们想要做的。现在,从这里,我将进入repl并打印出笔记参数,如下面的代码所示,你可以看到我们有一个标题为to buy和正文为food的笔记:

现在,如果这个语句出现了错误,也许屏幕上打印了错误的东西,这将给我们一个很好的想法。无论传递给note的是什么,显然都被用在console.log语句内,所以如果打印出了问题,最有可能是logNote函数内传递的问题。

现在我们已经打印了note变量,我们可以关闭repl,我们可以使用control + Cquit来退出debugger

现在我们回到了常规终端,我们已经成功完成了 Node 应用内的调试。在下一节中,我们将看一种不同的方法来做同样的事情,这种方法有一个更好的图形用户界面,我发现它更容易导航和使用。

列出笔记

现在我们在调试方面取得了一些进展,让我们回到我们应用的命令,因为只剩下一个要填写的命令了(我们已经分别在第三章、Node 基础知识-第二部分和本章中涵盖了addreadremove命令)。这是list命令,它将非常容易,list命令中没有复杂的情况。

使用getAll函数

为了开始,我们所需要做的就是填写list notes函数,这种情况下我们称之为getAllgetAll函数负责返回每一个笔记。这意味着它将返回一个对象数组,即我们所有笔记的数组。

我们所要做的就是返回fetchNotes,如下所示:

var getAll = () => {
  return fetchNotes();
}

无需过滤,也无需操作数据,我们只需要将数据从fetchNotes传递回getAll。现在我们已经做好了这一点,我们可以在app.js内填写功能。

我们必须创建一个变量来存储便签,我本来打算称它为 notes,但我可能不应该这样做,因为我们已经声明了一个 notes 变量。我将创建另一个变量,称为allNotes,将其设置为从getAll返回的值,我们知道这是因为我们刚刚填写了返回所有便签的内容:

else if (command === 'list') {
  var allNotes = notes.getAll();
}

现在我可以使用console.log打印一条小消息,我将使用模板字符串,这样我就可以注入要打印的实际便签数量。

在模板字符串中,我将添加打印,然后使用$(美元)符号和大括号,allNotes.length:这是数组的长度,后跟带有s的便签,括号中的s用于处理单数和复数情况,如下面的代码块所示:

else if (command === 'list') {
  var allNotes = notes.getAll();
  console.log(`Printing ${allNotes.length} note(s).`);
}

因此,如果有六条便签,它会说打印六条便签。

既然我们已经有了这个,我们必须继续实际打印每个便签的过程,这意味着我们需要为allNotes数组中的每个项目调用logNote一次。为此,我们将使用forEach,这是一个类似于 filter 的数组方法。

Filter 允许您通过返回truefalse来操作数组以保留项目或删除项目;forEach只是为数组中的每个项目调用一次回调函数。在这种情况下,我们可以使用allNotes.forEach,传入一个回调函数。现在,该回调函数将是一个箭头函数(=>),在我们的情况下,它将被调用note变量,就像 filter 一样。我们将调用notes.logNote,传入note参数,就像这里一样:

else if (command === 'list') {
  var allNotes = notes.getAll();
  console.log(`Printing ${allNotes.length} note(s).`);
  allNotes.forEach((note) => {
    notes.logNote(note);
  });
}

现在我们已经有了这个,我们可以通过在这里添加logNote调用来简化它:

else if (command === 'list') {
  var allNotes = notes.getAll();
  console.log(`Printing ${allNotes.length} note(s).`);
  allNotes.forEach((note) => notes.logNote(note));
}

这完全是相同的功能,只是使用了表达式语法。现在我们已经有了箭头函数(=>),我们正在为所有便签数组中的每个项目调用notes.logNote一次。让我们保存app.js文件并在终端中测试一下。

为了测试list命令,我将使用node app.jslist命令。不需要传递任何参数:

node app.js list

当我运行这个程序时,我确实得到了打印 3 条便签,然后我得到了我的3 条购买便签从商店购买要做的事情,如下面的代码输出所示,这太棒了:

有了这个,我们所有的命令现在都可以工作了。我们可以添加便签,删除便签,阅读单个便签,并列出存储在我们的 JSON 文件中的所有便签。

接下来,我想清理一些命令。在app.jsnotes.js中,我们有一些不再需要的console.log语句打印出一些东西。

app.js的顶部,我将删除console.log('Starting app.js')语句,使常量fs成为第一行。

我还将删除两个语句:console.log('Command: ', command)console.log('Yargs', argv),打印命令和yargs变量值。

notes.js中,我还将删除文件顶部的console.log('Stating notes.js')语句,因为它不再需要,将常量fs放在顶部。

当我们首次开始探索不同的文件时,它绝对是有用的,但现在我们已经把一切都放在了一起,就没有必要了。如果我重新运行list命令,这次你可以看到它看起来更整洁了:

打印三条便签是出现的第一行。有了这个,我们已经完成了我们的命令。

在下一节中,我们将稍微深入地看一下如何配置 yargs。这将让我们为我们的命令要求某些参数。因此,如果有人尝试添加一个没有标题的便签,我们可以警告用户并阻止程序执行。

高级 yargs

在我们深入讨论 yargs 的高级内容之前,首先,我想查看一下 yargs 文档,这样你至少知道 yargs 的信息是从哪里来的。你可以通过谷歌搜索npm yargs来获取。我们将前往 npm 上的 yargs 包页面。这里有 yargs 的文档,如下所示:

现在 yargs 文档没有目录,这使得导航变得有点困难。它以一些没有特定顺序的示例开始,然后最终列出了你可以使用的所有方法,这就是我们要找的。

所以我将使用command + FC**trl + F)在页面上搜索方法,如下面的截图所示,我们得到了方法标题,这就是我们要找的:

如果你在页面上滚动,你会开始看到一个字母顺序的列表,列出了你在 yargs 中可以访问的所有方法。我们特别寻找.command;这是我们可以用来配置我们的四个命令:addreadremovelist notes 的方法:

我们将指定它们需要哪些选项,如果有的话,我们还可以设置描述和帮助功能。

在 yargs 上使用链式语法

现在,为了开始,我们需要在app.js中进行一些更改。我们将从add命令开始(有关更多信息,请参阅上一章节中的添加和保存笔记部分)。

我们想在app.js中的argv函数中添加一些有用的信息,将:

  • 让 yargs 验证add命令是否被正确执行,并

  • 让用户知道add命令应该如何执行

现在我们将进行属性调用的链式调用,这意味着在访问.argv之前,我想调用.command,然后我将在命令的返回值上调用.argv,如下所示:

const argv = yargs
  .command()
  .argv;

现在,如果你使用过 jQuery,这种链式语法可能看起来很熟悉,支持许多不同的库。一旦我们在yargs上调用.command,我们将传入三个参数。

第一个是命令名称,用户在终端中输入的方式,我们的情况下是add

const argv = yargs
  .command('add')
  .argv;

然后我们将传入另一个字符串,这将是对命令做什么的描述。这将是一种用户可以阅读的英文可读描述,以便用户可以阅读以确定是否是他们想要运行的命令:

const argv = yargs
  .command('add', 'Add a new note')
  .argv;

接下来的是一个对象。这将是一个选项对象,让我们指定这个命令需要什么参数。

调用.help命令

现在,在我们进入选项对象之前,让我们在命令之后再添加一个调用。我们将调用.help,这是一个方法,所以我们将它作为一个函数调用,我们不需要传入任何参数:

const argv = yargs
  .command('add', 'Add a new note', {

  })
  .help()
  .argv;

当我们添加这个帮助调用时,它设置了yargs在有人运行程序时返回一些非常有用的信息。例如,我可以运行node app.js命令并带有help标志。help标志是因为我们调用了那个帮助方法,当我运行程序时,你可以看到我们有哪些可用的选项:

node app.js --help

如前面的输出所示,我们有一个命令add Add a new note,以及当前命令helphelp选项。如果我们运行node app.js add命令并带有help,情况也是如此,如下所示:

node app.js add --help

在这个输出中,我们可以查看add命令的所有选项和参数,这种情况下是没有的,因为我们还没有设置:

添加选项对象

让我们在 Atom 中重新添加选项和参数。为了添加属性,我们将更新选项对象,其中键是属性名称,无论是标题还是正文,值是另一个对象,让我们指定该属性应该如何工作,如下所示:

const argv = yargs
  .command('add', 'Add a new note', {
    title: {

    }
  })
  .help()
  .argv;

添加标题

在标题的情况下,我们将在左侧添加标题,并在右侧放置我们的选项对象。在标题中,我们将配置三个属性describedemandalias

describe属性将被设置为一个字符串,这将描述应该传递给标题的内容。在这种情况下,我们可以使用Title of note

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note'
    }
  })
  .help()
  .argv;

接下来我们配置demand。它将告诉 yarg 这个参数是否是必需的。demand默认为false,我们将把它设置为true

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true
    }
  })
  .help()
  .argv;

现在,如果有人尝试运行 add 命令而没有标题,它将失败,我们可以证明这一点。我们可以保存app.js,在终端中,我们可以重新运行我们之前的命令,删除help标志,当我这样做时,你会看到一个警告,Missing required argument: title如下所示:

请注意,在输出中,标题参数是Title of note,这是我们使用的描述字符串,并且在右侧是required,让您知道在调用add命令时必须提供标题。

除了describedemand,我们还将提供第三个选项,这称为aliasalias让您提供一个快捷方式,这样您就不必输入--title;您可以将别名设置为单个字符,如t

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true,
      alias: 't'
    }
  })
  .help()
  .argv;

完成后,您现在可以使用新的语法在终端中运行命令。

让我们运行我们的 add 命令,node app.js add,而不是--title。我们将使用-t,这是标志版本,我们可以将其设置为任何我们喜欢的值,例如,flag title将是标题,--body将被设置为body,如下面的代码所示。请注意,我们还没有设置 body 参数,所以没有alias

node app.js add -t="flag title" --body="body"

如果我运行这个命令,一切都按预期工作。标志标题出现在应该出现的地方,即使我们使用了字母t的别名版本,如下所示:

添加正文

现在我们已经配置了标题,我们可以为正文做完全相同的事情。我们将指定我们的选项对象并提供这三个参数:describedemandalias用于body

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true,
      alias: 't'
    },
    body: {

 }
  })
  .help()
  .argv;

第一个是describe,这个很容易。describe将被设置为一个字符串,在这种情况下,Body of note将完成任务:

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true,
      alias: 't'
    },
    body: {
      describe: 'Body of note'
    }
  })
  .help()
  .argv;

接下来是demand,为了添加一个注释,我们需要一个body。所以我们将demand设置为true,就像我们之前为title做的那样:

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true,
      alias: 't'
    },
    body: {
      describe: 'Body of note'
      demand: true
    }
  })
  .help()
  .argv;

最后但并非最不重要的是aliasalias将被设置为一个单个字母,我将使用字母b代表body

const argv = yargs
  .command('add', 'Add a new note', {
    title: {
      describe: 'Title of note',
      demand: true,
      alias: 't'
    },
    body: {
      describe: 'Body of note'
      demand: true,
      alias: 'b'
    }
  })
  .help()
  .argv;

有了这个设置,我们现在可以保存app.js,在终端中,我们可以花点时间重新运行node app.js add,并使用help标志:

node app.js add --help

当我们运行这个命令时,现在应该会看到正文参数出现,甚至可以看到它显示了标志版本,如下面的输出所示,别名-bBody of note),并且是必需的:

现在我将运行node app.js add,传入两个参数t。我将把它设置为tb设置为b

当我运行命令时,一切都按预期工作:

node app.js add -t=t -b=b

如前面的输出截图所示,创建了一个标题为t,正文为b的新便笺。有了这个设置,我们现在已经成功完成了add命令的设置。我们有我们的add命令标题,一个描述,以及为该命令指定参数的块。现在我们还有三个命令需要添加支持,所以让我们开始做吧。

添加对读取和删除命令的支持

在下一行,我将再次调用.command,传入命令名称。让我们先执行list命令,因为这个命令非常简单,不需要任何参数。然后我们将为list命令传入描述,“列出所有便笺”,如下所示:

.command('list', 'List all notes')
.help()
.argv;

接下来,我们将再次调用命令。这次我们将为read命令执行命令。read命令读取一个单独的便笺,所以对于read命令的描述,我们将使用类似“读取便笺”的内容:

.command('list', 'List all notes')
.command('read', 'Read a note')
.help()
.argv;

现在read命令确实需要标题参数。这意味着我们需要提供该选项对象。我将从add命令中取出title,复制它,并粘贴到read命令的选项对象中:

.command('list', 'List all notes')
.command('read', 'Read a note', {
  title: {
    describe: 'Title of note',
    demand: true,
    alias: 't'
  }
})
.help()
.argv;

您可能刚刚注意到,我们有重复的代码。标题配置刚刚被复制并粘贴到多个位置。如果这是 DRY 的话,如果它在一个变量中,我们可以在addread命令中引用它,那将是非常好的。

在我们调用read命令的地方后面,将调用remove命令。现在,remove命令将有一个描述。我们将坚持使用一些简单的描述,比如“删除便笺”,并且我们将提供一个选项对象:

.command('remove', 'Remove a note', {

})

现在我可以添加与read命令相同的选项对象。但是,在该选项对象中,我将标题设置为titleOptions,如下所示,以避免重复的代码。

.command('remove', 'Remove a note', {
  title: titleOptions
})

添加titleOptionbodyOption变量

现在我还没有创建titleOptions对象,所以代码目前可能会失败,但这是一个一般的想法。我们希望创建titleOptions对象一次,并在我们使用它的所有位置,为addreadremove命令引用它。我可以像这样为readadd命令添加titleOptions

.command('add', 'Add a new note', {
  title: titleOptions,
  body: {
    describe: 'Body of note',
    demand: true,
    alias: 'b'
  }
})
.command('list', 'List all notes')
.command('read', 'Read a note', {
  title: titleOptions
})
.command('remove', 'Remove a note', {
title: titleOptions
})

现在,在常量argv之前,我可以创建一个名为titleOptions的常量,并将其设置为我们之前为addread命令定义的对象,即describedemandalias,如下所示:

const titleOptions = {
  describe: 'Title of note',
  demand: true,
  alias: 't'
};

现在我们已经准备好了titleOptions,这将按预期工作。我们拥有了之前完全相同的功能,但是现在我们将titleOptions放在了一个单独的对象中,这符合我们在读取便笺部分讨论的 DRY 原则。

现在,我们也可以为正文做同样的事情。这可能看起来有点多余,因为我们只在一个地方使用它,但是如果我们坚持将它们分解成变量,我也会在正文的情况下这样做。在titleOptions常量之后,我可以创建一个名为bodyOptions的常量,将其设置为我们在前面的小节中为add命令定义的正文的选项对象:

const bodyOptions = {
  describe: 'Body of note',
  demand: true,
  alias: 'b'
};

有了这个设置,我们现在已经完成了。我们有addreadremove,所有这些都已经设置好了,引用了titleObjectbodyObject变量。

测试删除命令

让我们在终端中测试remove命令。我将列出我的便笺,使用node app.js list,这样我就可以看到我需要删除哪些便笺:

node app.js list

我将使用node app.js remove命令和我们的标志t删除标题为t的便笺:

node app.js remove -t="t"

我们将删除标题为t的便笺,如前所示,“便笺已删除”将打印到屏幕上。如果我两次使用上箭头键,我可以再次列出便笺,你会看到标题为t的便笺确实已经消失了:

让我们使用node app.js remove命令再删除一条笔记。这次我们将使用--title,这是参数名,我们要remove的笔记具有标题标志标题,如下所示:

当我删除它时,它会显示Note was removed,如果我重新运行list命令,我可以看到我们还剩下三条笔记,笔记确实被删除了,如下所示:

这就是笔记应用程序的全部内容。

箭头函数

在本节中,您将学习箭头函数的各个方面。这是一个 ES6 功能,我们已经稍微了解了一下。在notes.js中,我们在一些基本示例中使用它来创建方法,比如fetchNotessaveNotes,我们还将它传递给一些数组方法,比如 filter,对于每个数组,我们将它用作在数组中的每个项目上调用的回调函数。

现在,如果你尝试用箭头函数替换程序中的所有函数,很可能不会按预期工作,因为两者之间存在一些差异,了解这些差异非常重要,这样你就可以决定使用常规的 ES5 函数还是 ES6 箭头函数。

使用箭头函数

本节的目标是为您提供知识,以便做出选择,我们将通过在 playground 文件夹中创建一个名为arrow-function.js的新文件来开始:

在这个文件中,我们将玩几个例子,讨论箭头函数的一些微妙之处。在文件内输入任何内容之前,我将用nodemon启动这个文件,这样每次我们做出更改时,它都会自动在终端中刷新。

如果你还记得,nodemon是我们在第二章中安装的实用程序,Node 基础知识-第一部分。它是一个全局的 npm 模块。nodemon是要运行的命令,然后我们只需像对待其他 Node 命令一样传入文件路径。因为我们要进入playground文件夹,文件本身叫做arrow-function.js,我们将运行以下命令:

nodemon playground/arrow-function.js

我们将运行文件,除了nodemon日志之外,屏幕上什么都不会打印,如下所示:

要开始,在arrowfunction.js文件中,我们将创建一个名为 square 的函数,通过创建一个名为 square 的变量并将其设置为箭头函数。

为了创建我们的箭头函数(=>),我们首先要在括号内提供参数。因为我们将对一个数字进行平方,所以我们只需要一个数字,我将把这个数字称为x。如果我传入 3,我应该期望得到 9,如果我传入 9,我会期望得到 81。

在参数列表之后,我们必须通过将等号和大于号放在一起来放置箭头函数(=>),创建我们漂亮的小箭头。从这里开始,我们可以在花括号内提供我们想要执行的所有语句:

var square = (x) => {

};

接下来,我们可能会创建一个名为 result 的变量,将其设置为x乘以x,然后我们可能会使用return关键字返回结果变量,如下所示:

var square = (x) => {
  var result = x * x;
  return result;
};

现在,显然这可以在一行上完成,但这里的目标是说明当你使用语句箭头函数(=>)时,你可以在这些花括号之间放置任意多的行。让我们调用一个平方,我们将使用console.log来做到这一点,这样我们就可以将结果打印到屏幕上。我会调用 square;我们将用9调用 square,9的平方将是81,所以我们期望81打印到屏幕上:

var square = (x) => {
  var result = x * x;
  return result;
};
console.log(square(9));

我会保存箭头函数(=>)文件,在终端中,81就像我们预期的那样显示出来:

现在我们在之前的例子中使用的语法是箭头函数(=>)的语句语法。我们之前也探讨了表达式语法,它让你在返回一些表达式时简化你的箭头函数。在这种情况下,我们只需要指定我们想要返回的表达式。在我们的例子中,就是x乘以x

var square = (x) => x * x;
console.log(square(9));

你不需要显式添加return关键字。当你使用不带大括号的箭头函数(=>)时,它会隐式地为你提供。这意味着我们可以保存之前显示的函数,完全相同的结果会打印到屏幕上,81出现。

这是箭头函数的一个巨大优势,当你在notes.js文件中使用它们时。它让你简化你的代码,保持一行,使你的代码更容易维护和扫描。

现在,有一件事我想指出:当你有一个只有一个参数的箭头函数(=>)时,你实际上可以省略括号。如果你有两个或更多的参数,或者你有零个参数,你需要提供括号,但如果你只有一个参数,你可以不用括号引用它。

如果我以这种状态保存文件,81仍然打印到屏幕上;这很好,我们有一个更简单的箭头函数(=>)版本:

现在我们已经有了一个基本的例子,我想继续进行一个更复杂的例子,探讨常规函数和箭头函数之间的细微差别。

探索常规函数和箭头函数之间的差异

为了说明区别,我会创建一个名为user的变量,它将是一个对象。在这个对象上,我们会指定一个属性,名字。将名字设置为字符串,你的名字,在这种情况下我会将其设置为字符串Andrew

var user = {
  name: 'Andrew'
};

然后我们可以在user对象上定义一个方法。在名字后面,我在行末加上逗号,我会提供方法sayHi,将其设置为一个不带任何参数的箭头函数(=>)。暂时,我们会让箭头函数非常简单:

var user = {
  name: 'Andrew',
  sayHi: () => {

  }
};

sayHi里我们要做的就是使用console.log打印到屏幕上,在模板字符串里是Hi

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi`);
  }
};

我们还没有使用模板字符串,但我们以后会使用,所以我会在这里使用它们。在用户对象之后,我们可以通过调用它来测试sayHiuser.sayHi

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi`);
  }
};
user.sayHi();

我会称之为然后保存文件,我们期望Hi打印到屏幕上,因为我们的箭头函数(=>)只是使用console.log打印一个静态字符串。在这种情况下,没有任何问题;你可以毫无问题地用箭头函数(=>)替换常规函数。

当你使用箭头函数时,首先会出现的问题是箭头函数不会绑定this关键字。所以如果你在函数内部使用this,当你用箭头函数(=>)替换它时,它不会起作用。现在,this绑定;指的是父绑定,在我们的例子中没有父函数,所以这将指向全局的this关键字。现在我们的console.log不使用this,我会用一个使用this的情况来替换它。

我们会在Hi后面加一个句号,然后我会说我是,接着是名字,通常我们可以通过this.name来访问:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi. I'm ${this.name}`);
  }
};
user.sayHi();

如果我尝试运行这段代码,它不会按预期工作;我们会得到Hi I'm undefined 打印到屏幕上,如下所示:

为了解决这个问题,我们将看一种替代箭头函数的语法,当你定义对象字面量时非常好用,就像我们在这个例子中所做的那样。

sayHi之后,我将使用不同的 ES6 特性创建一个名为sayHiAlt的新方法。ES6 为我们提供了一种在对象上创建方法的新方式;你提供方法名sayHiAlt,然后直接跳过冒号到括号。虽然它是一个常规函数,但不是箭头函数(=>),所以也不需要函数关键字。然后我们继续到大括号,如下所示:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi. I'm ${this.name}`);
  },
  sayHiAlt() {

  }
};
user.sayHi();

在这里,我可以有与sayHi函数中完全相同的代码,但它将按预期工作。它将打印Hi. I'm Andrew。我将在下面调用sayHiAlt而不是常规的sayHi方法:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi. I'm ${this.name}`);
  },
  sayHiAlt() {
    console.log(`Hi. I'm ${this.name}`);
  }
};
user.sayHiAlt();

在终端中,你可以看到Hi. I'm Andrew打印到屏幕上:

sayHiAlt语法是一种可以解决在对象字面量上创建函数时的this问题的语法。现在我们知道this关键字不会被绑定,让我们探索箭头函数的另一个怪癖,它也不会绑定参数数组。

探索参数数组

常规函数,比如sayHiAlt,将在函数内部有一个可访问的参数数组:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi. I'm ${this.name}`);
  },
  sayHiAlt() {
    console.log(arguments);
    console.log(`Hi. I'm ${this.name}`);
  }
};
user.sayHiAlt();

现在,它不是一个实际的数组,更像是一个带有数组属性的对象;但是 arguments 对象确实在常规函数中指定。如果我传入 1、2 和 3 并保存文件,当我们记录 arguments 时,我们将得到它:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(`Hi. I'm ${this.name}`);
  },
  sayHiAlt() {
    console.log(arguments);
    console.log(`Hi. I'm ${this.name}`);
  }
};
user.sayHiAlt(1, 2, 3);

nodemon中,它需要快速重启,然后我们有我们的对象:

我们有 1、2 和 3,我们为每个属性名有索引,这是因为我们使用了常规函数。但是,如果我们切换到箭头函数(=>),它将不会按预期工作。

我将在我的箭头函数(=>)中添加console.log(arguments),并切换回调用sayHiAlt到原始方法sayHi,如下所示:

var user = {
  name: 'Andrew',
  sayHi: () => {
    console.log(arguments);
    console.log(`Hi. I'm ${this.name}`);
  },
  sayHiAlt() {
    console.log(arguments);
    console.log(`Hi. I'm ${this.name}`);
  }
};
user.sayHi(1, 2, 3);

当我保存在arrow-function.js文件中时,我们将得到与之前完全不同的东西。实际上我们将得到全局的 arguments 变量,这是我们探索的包装函数的 arguments 变量:

在之前的截图中,我们有像 require 函数、定义、我们的模块对象和一些字符串路径到文件和当前目录的东西。显然这不是我们期望的,这是你在使用箭头函数时必须注意的另一件事;你不会得到arguments关键字,你也不会得到你期望的this绑定(在sayHi语法中定义)。

这些问题主要出现在你尝试在对象上创建方法并使用箭头函数时。在这种情况下,我强烈建议你切换到我们讨论的sayHiAlt语法。你会得到一个简化的语法,但你也会得到this绑定和你期望的 arguments 变量。

总结

在本章中,我们能够重用我们在之前章节中已经创建的实用函数,使填写删除笔记的过程变得更加容易。在app.js中,我们处理了removeNote函数的执行,如果成功执行,我们打印一条消息;如果没有,我们打印另一条消息。

接下来,我们成功填写了read命令,并创建了一个非常酷的实用函数,我们可以在多个地方利用它。这使我们的代码保持干净,并防止我们在应用程序内的多个地方重复相同的代码。

然后我们讨论了一下快速调试的简介。基本上,调试是一个让你可以在任何时间点停止程序并玩弄程序的过程。这意味着你可以玩弄存在的变量、函数或者 Node 内部的任何东西。我们更多地了解了 yargs,它的配置,设置命令,它们的描述和参数。

最后,你更多地探索了箭头函数,它们的工作原理,何时使用它们,何时不使用它们。一般来说,如果你不需要 this 关键字或者 arguments 关键字,你可以毫无问题地使用箭头函数,我总是更喜欢在可以的时候使用箭头函数而不是普通函数。

在下一章中,我们将探讨异步编程以及如何从第三方 API 获取数据。我们将更多地使用普通函数和箭头函数,你将能够第一手看到如何在两者之间进行选择。

第五章:Node.js 中异步编程的基础知识

如果你读过任何关于 Node 的文章,你可能会遇到四个术语:异步、非阻塞、事件驱动和单线程。所有这些都是描述 Node 的准确术语;问题是通常就到此为止,而且这些术语非常抽象。Node.js 中的异步编程主题已经分为三章。在接下来的三章中,我们的目标是通过在我们的天气应用程序中使用所有这些术语,使异步编程变得非常实用。这是我们将在这些章节中构建的项目。

本章节主要讲解异步编程的基础知识。我们将深入了解与异步编程相关的基本概念、术语和技术。我们将学习如何向地理位置 API 发出请求。我们需要进行异步 HTTP 请求。让我们深入了解 Node 中异步编程的基础知识。

具体来说,我们将研究以下主题:

  • 异步程序的基本概念

  • 调用栈和事件循环

  • 回调函数和 API

  • HTTPS 请求

异步程序的基本概念

在本节中,我们将创建我们的第一个异步非阻塞程序。这意味着我们的应用程序将在等待其他事件发生时继续运行。在本节中,我们将看一个基本的例子;然而,在本章中,我们将构建一个与第三方 API(如 Google API 和天气 API)通信的天气应用程序。我们需要使用异步代码从这些来源获取数据。

为此,我们只需要在桌面上创建一个新的文件夹。我会进入我的桌面并使用mkdir创建一个新目录,我会将其命名为weather-app。然后我只需要进入 weather app:

现在,我将使用clear命令清除终端输出。

现在,我们可以在 Atom 中打开weather app目录:

这是我们在整个章节中将使用的目录。在本节中,我们不会立即构建天气应用程序,而是先尝试一下异步特性。所以在weather-app中,我们将创建playground文件夹。

这段代码不会成为天气应用程序的一部分,但在后面创建天气应用程序时,它将非常有用。现在在playground中,我们可以创建这一节的文件。我们将其命名为async-basics.js,如下所示:

说明异步编程模型

为了说明异步编程模型的工作原理,我们将从一个简单的例子开始,使用console.log。让我们通过以同步的方式添加一些console.log语句来开始。我们将在应用程序的开头创建一个console.log语句,显示Starting app,然后在结尾添加第二个console.log语句,显示Finishing up,如下所示:

console.log('Starting app');

console.log('Finishing up');

现在这些语句将始终同步运行。无论你运行程序多少次,Starting app都会在Finishing up之前显示。

为了添加一些异步代码,我们将看一下 Node 提供的一个名为setTimeout的函数。setTimeout函数是一个很好的方法,可以说明非阻塞编程的基础知识。它接受两个参数:

  • 第一个是一个函数。这将被称为回调函数,并且会在一定时间后触发。

  • 第二个参数是一个数字,它告诉你想要等待的毫秒数。所以如果你想等待一秒,你需要传入一千毫秒。

让我们调用setTimeout,将箭头函数(=>)作为第一个参数。这将是回调函数。它将立即触发;也就是说,在超时结束后,两秒后触发。然后我们可以设置我们的第二个参数,即延迟2000毫秒,等于这两秒:

console.log('Starting app');

setTimeout(() => {

}, 2000);

在箭头函数(=>)中,我们将使用console.log语句,以便我们可以确定我们的函数何时触发,因为该语句将打印到屏幕上。我们将添加console.log,然后在回调函数内完成工作,如下所示:

setTimeout(() => {
  console.log('Inside of callback');
}, 2000);

有了这个,我们实际上已经准备好运行我们的第一个异步程序了,我将不使用nodemon来执行它。我将使用基本的 Node 命令从终端运行这个文件;node playgroundplayground文件夹中的文件async-basic.js

node playground/async-basics.js

现在要特别注意当我们按下回车键时发生了什么。我们会立即看到两条消息显示出来,然后两秒后我们的最终消息Inside of callback打印到屏幕上:

这些消息显示的顺序是:首先我们得到Starting app;几乎立即,Finishing up打印到屏幕上,最后(两秒后),Inside of callback如前面的代码所示。在文件内部,这不是我们编写代码的顺序,但这是代码执行的顺序。

Starting app语句按我们的预期打印到屏幕上。接下来,我们调用setTimeout,但实际上我们并没有告诉它等待两秒。我们正在注册一个回调,将在两秒后触发。这将是一个异步回调,这意味着在这两秒内 Node 可以做其他事情。在这种情况下,它会继续执行Finishing up消息。现在,由于我们使用setTimeout注册了这个回调,它将在某个时间点触发,两秒后我们确实看到Inside of callback打印到屏幕上。

通过使用非阻塞 I/O,我们能够等待,这种情况下是两秒,而不会阻止程序的其余部分执行。如果这是阻塞 I/O,我们将不得不等待两秒钟才能触发此代码,然后Finishing up消息将打印到屏幕上,显然这是不理想的。

现在这是一个相当牵强的例子,我们不会在我们的真实应用程序中使用setTimeout来创建不必要的任意延迟,但原则是相同的。例如,当我们从 Google API 获取数据时,我们需要等待大约 100 到 200 毫秒才能收到数据,我们不希望程序的其余部分只是空闲,它将继续执行。我们将注册一个回调,一旦数据从 Google 服务器返回,该回调将被触发。即使实际发生的事情完全不同,但相同的原则也适用。

现在,我们想在这里写另一个setTimeout。我们想要注册一个setTimeout函数,打印一条消息; 类似Second setTimeout works。这将在回调函数内部,我们想要注册一个延迟0毫秒,没有延迟。让我们填写 async 基础setTimeout。我将使用箭头函数(=>)调用setTimeout,传入0毫秒的延迟,如下面的代码所示。在箭头函数(=>)中,我将使用console.log,以便我可以看到这个函数何时执行,我将使用Second setTimeout作为文本:

setTimeout(() => {
  console.log('Second setTimeout');
}, 0);

现在我们已经准备好从终端运行程序了,非常重要的是要注意语句打印的顺序。让我们运行程序:

node playground/async-basics.js

立即我们得到三个语句,然后在最后,两秒后,我们得到我们的最终语句:

我们从Starting app开始,这是有道理的,它在顶部。然后我们得到Finishing up。在Finishing up之后,我们得到Second setTimeout,这似乎很奇怪,因为我们明确告诉 Node 我们希望在0毫秒后运行这个函数,这应该立即运行它。但在我们的示例中,Second setTimeoutFinishing up之后打印出来。

最后,Inside of callback打印到屏幕上。这种行为完全是预期的。这正是 Node.js 应该运行的方式,而在下一节中,我们将通过这个例子来详细展示幕后发生了什么。我们将从一个更基本的示例开始,向你展示调用堆栈的工作方式,我们将在下一节中详细讨论这一点,然后我们将继续进行一个更复杂的示例,其中附加了一些异步事件。我们将讨论为什么Second setTimeoutFinishing up消息之后出现。

调用堆栈和事件循环

在上一节中,我们最终创建了我们的第一个异步应用程序,但不幸的是,我们提出了更多问题,而不是得到了答案。尽管我们使用了异步编程,但我们并不确切知道它是如何工作的。我们本节的目标是理解程序运行的方式。

例如,为什么以下代码中的两秒延迟不会阻止应用程序的其余部分运行,为什么0秒延迟会导致函数在Finishing up打印到屏幕后执行?

console.log('Starting app');

setTimeout(() => {
  console.log('Inside of callback');
}, 2000);

setTimeout(() => {
  console.log('Second setTimeout');
}, 0);

console.log('Finishing up');

这些都是我们将在本节中回答的问题。本节将带你深入了解异步程序运行时 V8 和 Node 中发生的事情。现在让我们直接深入了解异步程序的运行方式。我们将从一些基本的同步示例开始,然后继续找出异步程序中究竟发生了什么。

同步程序示例

以下是示例一。在左侧是代码,一个基本的同步示例,右侧是发生在幕后的一切,调用堆栈、我们的 Node API、回调队列和事件循环:

现在,如果你曾经阅读过有关 Node 工作原理的文章或观看过任何视频课程,你很可能听说过这些术语中的一个或多个。在本节中,我们将探讨它们如何共同组成一个真实的、可工作的 Node 应用程序。现在,对于我们的第一个同步示例,我们只需要担心调用堆栈。调用堆栈是 V8 的一部分,对于我们的同步示例,它是唯一要运行的东西。我们不使用任何 Node API,也不进行任何异步编程。

调用堆栈

调用堆栈是一个非常简单的数据结构,用于跟踪 V8 内部的程序执行。它跟踪当前正在执行的函数和已触发的语句。调用堆栈是一个非常简单的数据结构,可以做两件事:

  • 你可以在顶部添加一些东西

  • 你可以移除顶部的项目

这意味着如果数据结构的底部有一个项目,上面有一个项目,你不能移除底部的项目,你必须移除顶部的项目。如果已经有两个项目,你想要添加一个项目,它必须放在上面,因为这就是调用堆栈的工作方式。

想象一下它就像一罐薯片或一罐网球:如果里面已经有一个项目,你再放一个进去,你刚刚放进去的项目不会成为底部项目,它将成为顶部项目。此外,你不能从一罐网球中移除底部的网球,你必须先移除顶部的网球。这正是调用堆栈的工作方式。

运行同步程序

现在当我们开始执行下面截图中显示的程序时,首先会发生的事情是 Node 将运行 main 函数。main 函数是我们在 nodemon 中看到的包装函数(参见第二章中的安装 nodemon 模块部分,Node 基础知识第一部分),当我们通过 Node 运行所有文件时,它会包装在所有文件周围。在这种情况下,通过告诉 V8 运行 main 函数,我们开始了程序。

如下面的截图所示,我们在程序中的第一件事是创建一个变量x,将其设置为1,这是将要运行的第一个语句:

注意它出现在 main 的上面。现在这个语句将运行,创建变量。一旦完成,我们可以将其从调用堆栈中移除,并继续下一个语句,其中我们将变量y设置为xx19。这意味着y将等于10

如前面的截图所示,我们这样做并继续下一行。下一行是我们的console.log语句。console.log语句将在屏幕上打印y10。我们使用模板字符串来注入y变量:

console.log(`y is ${y}`);

当我们运行这行时,它被弹出到调用堆栈上,如下所示:

一旦语句完成,它就会被移除。此时,我们已经执行了程序中的所有语句,程序几乎完成了。主函数仍在运行,但由于函数结束,它会隐式返回,当它返回时,我们将 main 从调用堆栈中移除,程序就完成了。此时,我们的 Node 进程已关闭。现在这是使用调用堆栈的一个非常基本的例子。我们进入了 main 函数,然后逐行通过程序。

一个复杂的同步程序示例

让我们来看一个稍微复杂一点的例子,我们的第二个例子。如下面的代码所示,我们首先定义一个add函数。add函数接受参数ab,将它们相加并将结果存储在一个名为total的变量中,然后返回total。接下来,我们将38相加,得到11,将其存储在res变量中。然后,我们使用console.log语句打印出响应,如下所示:

var add = (a, b) => {
 var total = a + b;

 return total;
};

var res = add(3, 8);

console.log(res);

就是这样,没有发生任何同步的事情。再次强调,我们只需要调用堆栈。发生的第一件事是我们执行 main 函数;这启动了我们这里的程序:

然后我们运行第一个语句,其中我们定义add变量。我们实际上并没有执行函数,我们只是在这里定义它:

在前面的图像中,add()变量被添加到调用堆栈上,并且我们定义了add。下一行,第 7 行,是我们调用add变量,将返回值存储在 response 变量中:

当你调用一个函数时,它会被添加到调用堆栈的顶部。当你从一个函数返回时,它会从调用堆栈中移除。

在这个例子中,我们将调用一个函数。所以我们将add()添加到调用堆栈上,并开始执行该函数:

正如我们所知,当我们添加 main 时,我们开始执行 main,当我们添加add()时,我们开始执行 add。add内的第一行将total变量设置为a + b,即11。然后我们使用return total语句从函数中返回。这是下一个语句,当这个语句运行时,add被移除:

因此,当return total完成时,add()被移除,然后我们继续执行程序中的最后一行,我们的console.log语句,其中我们将11打印到屏幕上:

console.log语句将运行,将11打印到屏幕上并完成执行,现在我们已经到达主函数的末尾,在隐式返回时从调用栈中移除。这是程序通过 V8 调用栈运行的第二个示例。

异步程序示例

到目前为止,我们还没有使用 Node APIs、回调队列或事件循环。下一个示例将使用所有四个(调用栈、Node APIs、回调队列和事件循环)。如下面截图左侧所示,我们有一个异步示例,与上一节中写的完全相同:

在这个示例中,我们将使用调用栈、Node APIs、回调队列和事件循环。所有这四个都将在我们的异步程序中发挥作用。现在事情将开始如你所期望的那样。首先发生的是我们将主函数添加到调用栈中运行。这告诉 V8 启动我们在上一截图左侧的代码,再次显示如下:

console.log('Starting app');

setTimeout(() => {
  console.log('Inside of callback');
}, 2000);

setTimeout(() => {
  console.log('Second setTimeout');
}, 0);

console.log('Finishing up');

这段代码中的第一个语句非常简单,是一个console.log语句,将Starting app打印到屏幕上:

这个语句会立即运行,然后我们继续执行第二个语句。第二个语句是开始变得有趣的地方,这是对setTimeout的调用,它确实是一个 Node API。它在 V8 内部不可用,这是 Node 给我们提供的:

异步编程中的 Node API

当我们调用setTimeout (2 sec)函数时,实际上是在 Node APIs 中注册事件回调对。事件就是等待两秒,回调函数是我们提供的函数,也就是第一个参数。当我们调用setTimeout时,它会像下面这样在 Node APIs 中注册:

现在这个语句将结束,调用栈将继续执行,setTimeout将开始倒计时。setTimeout正在倒计时,并不意味着调用栈不能继续执行它的工作。调用栈一次只能运行一件事情,但即使调用栈在执行,我们也可以有事件等待处理。现在运行的下一个语句是另一个对setTimeout的调用:

在这里,我们注册了一个延迟为0毫秒的setTimeout回调函数,发生了完全相同的事情。这是一个 Node API,将如下截图所示注册:

setTimeout (0 sec)语句被注册,调用栈移除该语句。

异步编程中的回调队列

在这一点上,让我们假设setTimeout,那个零秒延迟的函数,已经完成。当它完成时,它不会立即被执行;它会把回调函数移动到回调队列中,如下所示:

回调队列是所有准备好被触发的回调函数。在前面的截图中,我们将函数从 Node API 移动到回调队列中。现在回调队列是我们的回调函数将等待的地方;它们需要等待调用栈为空。

当调用栈为空时,我们可以运行第一个函数。在它之后还有另一个函数。在第一个函数运行之前,我们必须等待第二个函数运行,这就是事件循环发挥作用的地方。

事件循环

事件循环查看调用堆栈。如果调用堆栈不为空,则不执行任何操作,因为它无法执行任何操作,一次只能运行一件事。如果调用堆栈为空,事件循环会说好的,让我们看看是否有任何要运行的东西。在我们的情况下,有一个回调函数,但是因为我们没有空的调用堆栈,事件循环无法运行它。所以让我们继续示例。

运行异步代码

我们程序中的下一件事是运行我们的console.log语句,将Finishing up打印到屏幕上。这是在终端中显示的第二条消息:

这个语句运行,我们的主函数完成,并从调用堆栈中移除。

此时,事件循环说,嘿,我看到我们的调用堆栈中没有任何内容,而回调队列中有内容,所以让我们运行那个回调函数。它将获取回调并将其移动到调用堆栈中;这意味着函数正在执行:

它将运行第一行,该行位于第8行,console.log,将Second setTimeout打印到屏幕上。这就是为什么在我们之前的部分示例中,Second setTimeoutFinishing up之后出现的原因,因为在调用堆栈完成之前,我们无法运行回调。由于Finishing up是主函数的一部分,它将始终在Second setTimeout之前运行。

在我们的Second setTimeout语句完成后,函数将隐式返回,并且回调将从调用堆栈中移除:

此时,调用堆栈中没有任何内容,回调队列中也没有任何内容,但是我们的 Node API 中仍然有内容,我们仍然注册了一个事件监听器。因此,Node 进程尚未完成。两秒后,setTimeout(2 sec)事件将触发,并将回调函数移动到回调队列中。它将从 Node API 中移除,并添加到回调队列中:

此时,事件循环将查看调用堆栈,并看到它是空的。然后它会快速查看回调队列,并确实有要运行的内容。它会怎么做?它将获取该回调,将其添加到调用堆栈中,并开始执行该过程。这意味着我们将运行回调中的一个语句。完成后,回调函数将隐式返回,程序完成:

这正是我们的程序运行的方式。这说明了我们如何能够使用 Node API 注册我们的事件,以及为什么当我们使用setTimeout为零时,代码不会立即运行。它需要通过 Node API 和回调队列才能在调用堆栈上执行。

现在,正如我在本节开头提到的,调用堆栈、Node API、回调队列和事件循环是非常令人困惑的话题。它们令人困惑的一个重要原因是因为我们实际上从未直接与它们交互;它们是在幕后发生的。我们没有调用回调队列,我们没有触发事件循环方法来使这些事情工作。这意味着在有人解释之前,我们不知道它们的存在。这些是非常难以理解的话题。通过编写真正的异步代码,它将变得更加清晰。

现在我们对代码在幕后执行的一些想法,我们将继续本章的其余部分,并开始创建一个与第三方 API 交互的天气应用程序。

回调函数和 API

在本节中,我们将深入研究回调函数,并使用它们从 Google 地理位置 API 中获取一些数据。这将是一个接受地址并返回纬度和经度坐标的 API,这对于天气应用来说非常重要。这是因为我们使用的天气 API 需要这些坐标,并返回实时天气数据,如温度、五天预报、风速、湿度和其他天气信息。

回调函数

在我们开始进行 HTTPS 请求之前,让我们谈谈回调函数,我们已经使用过它们。参考以下代码(我们在上一节中使用过):

console.log('Starting app');

setTimeout(() => {
  console.log('Inside of callback');
}, 2000);

setTimeout(() => {
  console.log('Second setTimeout');
}, 0);

console.log('Finishing up');

setTimeout函数内部,我们使用了一个callback函数。一般来说,callback函数被定义为作为参数传递给另一个函数并在某些事件发生后执行的函数。现在这是一个一般性的定义,在 JavaScript 中没有严格的定义,但在这种情况下它确实满足函数的要求:

setTimeout(() => {
  console.log('Inside of callback');
}, 2000);

在这里,我们有一个函数,并将其作为参数传递给另一个函数,setTimeout,并在某个事件——两秒后执行。现在事件可能是其他事情,它可能是数据库查询完成,它可能是 HTTP 请求返回。在这些情况下,你将需要一个回调函数,就像我们的情况一样,对数据进行一些操作。在setTimeout的情况下,我们不会得到任何数据,因为我们没有请求任何数据;我们只是创建了一个任意的延迟。

创建回调函数

现在在我们实际向 Google 发出 HTTP 请求之前,让我们在playground文件夹中创建一个回调函数示例。让我们创建一个名为callbacks.js的新文件:

在文件内,我们将创建一个虚构的示例,展示回调函数在幕后的样子。我们将在整本书中创建真实的示例,并使用许多需要回调的函数。但在本章中,我们将从一个简单的示例开始。

让我们开始,让我们创建一个名为getUser的变量。这将是我们定义的函数,当我们将回调传递给另一个函数时,它将向我们展示幕后发生的事情。getUser回调将是一些模拟从数据库或某种 Web API 中获取用户的样子。它将是一个函数,所以我们将使用箭头函数(=>)来设置它:

var getUser = () => {

};

箭头函数(=>)将接受一些参数。它将接受的第一个参数是id,它将是一个代表每个用户的唯一数字。我可能有一个id54,你可能有一个id2000;无论如何,我们都需要id来找到一个用户。接下来我们将得到一个回调函数,这是我们稍后将用数据调用的函数,用那个用户对象:

var getUser = (id, callback) => {

};

这正是当你将一个函数传递给setTimeout时发生的情况。

setTimeout函数的定义如下:

var getUser = (callback, delay) => {

};

它有一个回调和一个延迟。你拿到回调,然后在一定的时间后调用它。在我们的情况下,我们将交换顺序,先是id,然后是回调。

现在我们可以在实际填写之前调用这个函数。我们将调用getUser,就像我们在上一个代码示例中使用setTimeout一样。我将调用getUser,传入这两个参数。第一个将是一些id;因为我们现在是假的,所以并不重要,我会选择31。第二个参数将是当用户数据返回时我们想要运行的函数,这一点非常重要。如下所示,我们将定义该函数:

getUser(31, () => {

});

现在单独的回调并不是真正有用的;只有在我们实际获取用户数据后才能运行这个函数,这就是我们在这里期望的:

getUser(31, (user) => {

});

我们期望user对象,诸如idnameemailpassword等,作为回调函数的参数返回。然后在箭头函数(=>)中,我们实际上可以对这些数据做一些操作,例如,在 Web 应用程序中显示它,响应 API 请求,或者在我们的情况下,我们可以简单地将其打印到控制台上,console.log(user)

getUser(31, (user) => {
  console.log(user);
});

现在我们已经调用了,让我们填写getUser函数,使其像我们定义的那样工作。

我要做的第一件事是创建一个虚拟对象,它将成为user对象。将来,这将来自数据库查询,但现在我们将创建一个名为user的变量,并将其设置为某个对象:

var getUser = (id, callback) => {
  var user = {

  }
};

让我们将id属性设置为用户传入的id,并将name属性设置为某个名称。我会使用Vikram

var getUser = (id, callback) => {
  var user = {
    id: id,
    name: 'Vikram'
  };
};

现在我们有了user对象,我们想要做的是调用回调,将其作为参数传递。然后我们将能够实际运行getUser(31, (user)函数,将user打印到屏幕上。为了做到这一点,我们将像调用任何其他函数一样调用回调函数,只需通过名称引用它并添加括号,就像这样:

var getUser = (id, callback) => {
  var user = {
    id: id,
    name: 'Vikram'
  };
  callback();
};

现在,如果我们这样调用函数,我们不会从getUser传递任何数据回到回调函数。在这种情况下,我们期望传回一个user,这就是为什么我们要像这样指定user

callback(user);

现在命名并不重要,我碰巧称之为user,但我可以很容易地称之为userObjectuserObject如下所示:

callback(user);
};

getUser(31, (userObject) => {
  console.log(userObject);
});

重要的是参数的位置。在这种情况下,我们将第一个参数称为userObject,并且第一个参数传回的确实是userObject。有了这个,我们现在可以运行我们的例子。

运行回调函数

在终端中,我们将使用node运行回调函数,它在playground文件夹中,我们调用文件callbacks.js

node playground/callback.js

当我们运行文件时,我们的数据立即打印到屏幕上:

我们使用同步编程创建了一个回调函数。现在正如我提到的,这仍然是一个人为的例子,因为在这种情况下不需要回调。我们可以简单地返回用户对象,但在这种情况下,我们不会使用回调,这里的重点是探索幕后发生的事情以及我们如何实际调用作为参数传入的函数。

使用setTimeout模拟延迟

现在,我们还可以使用setTimeout来模拟延迟,所以让我们这样做。在我们的代码中,在callback (user)语句之前,我们将像在上一节中一样使用setTimeout。我们将传入一个箭头函数(=>)作为第一个参数,并使用3000毫秒设置 3 秒的延迟:

  setTimeout(() => {

  }, 3000);
  callback(user);
};

现在我可以取出我的回调调用,从第 10 行删除它,并将其添加到回调函数中,如下所示:

setTimeout(() => {
    callback(user);
  }, 3000);
};

现在在三秒钟后,我们将不会响应getUser请求。现在这将更多或多少类似于我们创建回调的真实例子时发生的情况,我们传入一个回调,无论是从数据库请求还是从 HTTP 端点请求,都会发生某种延迟,然后回调被触发。

如果我保存callbacks.js并从终端重新运行代码,你会看到我们等待了三秒钟,这是模拟的延迟,然后user对象打印到屏幕上:

这正是我们需要理解的原则,以便开始使用回调,并且这正是我们将在本节中开始做的事情。

向地理位置 API 发出请求

我们将要向 Geolocation API 发出的请求实际上可以在浏览器中模拟,然后再在 Node 中发出请求,这正是我们想要做的。所以跟着 URL 一起进行,maps.googleapis.com/maps/api/geocode/json

现在这是实际的终端 URL,但是我们必须指定我们想要地理编码的地址。我们将使用查询字符串来做到这一点,这将在问号后面提供。然后,我们可以设置一组键值对,并且可以使用 URL 中的和号添加多个,例如:maps.googleapis.com/maps/api/geocode/json?key=value&keytwo=valuetwo

在我们的案例中,我们只需要一个查询字符串地址,maps.googleapis.com/maps/api/geocode/json?address,对于地址查询字符串,我们将把它设置为一个地址。为了填写查询地址,我将开始输入1301 lombard street philadelphia

请注意,我们在 URL 中使用了空格。这只是为了说明一个观点:我们可以在浏览器中使用空格,因为它会自动将这些空格转换为其他内容。然而,在 Node 中,我们将不得不自己处理这个问题,我们稍后会在本节中讨论这个问题。现在,如果我们保留这些空格,按下回车键,我们可以看到它们自动为我们转换:

空格字符被转换为%20,这是空格的编码版本。在这个页面上,我们有所有返回的数据:

现在我们将使用一个名为 JSONView 的扩展,它适用于 Chrome 和 Firefox。

我强烈建议安装 JSONView,因为我们应该看到我们的 JSON 数据的更好版本。它让我们可以最小化和展开各种属性,并且使得导航变得非常容易。

如前面的屏幕截图所示,这个页面上的数据正是我们需要的。我们有一个 address_components 属性,我们不需要它。接下来,我们有一个格式化的地址,这真的很好,它包括了州、邮政编码和国家,这些我们甚至没有在地址查询中提供。

然后,我们得到了我们真正想要的东西:在几何学中,我们有位置,这包括纬度和经度数据。

在我们的代码中使用 Google Maps API 数据

现在,我们从 Google Maps API 请求中得到的只是一些 JSON 数据,这意味着我们可以将这些 JSON 数据转换为 JavaScript 对象,并在我们的代码中开始访问这些属性。为了做到这一点,我们将使用一个第三方模块,让我们在我们的应用程序中进行这些 HTTP 请求;这个模块叫做请求。

我们可以通过访问www.npmjs.com/package/request来访问它。当我们访问这个页面时,我们将看到所有的文档和我们可以使用请求软件包进行 HTTP 请求的所有不同方式。不过,现在我们将坚持一些基本的例子。在请求文档页面的右侧,我们可以看到这是一个非常受欢迎的软件包,在过去的一天里有七十万次下载:

为了开始,我们将在我们的项目中安装该软件包,并向该 URL 发出请求。

安装请求软件包

要安装该软件包,我们将转到终端并使用npm init来安装模块,以创建package.json文件:

我们将运行这个命令,并使用回车键使用每个选项的默认值:

最后,我们将输入yes并再次按下回车键。

现在我们有了package.json文件,我们可以使用npm install,然后是模块名称,request,我会指定一个版本。您可以在 npm 页面上找到模块的最新版本。撰写时的最新版本是2.73.0,所以我们将添加@2.73.0。然后我们可以指定保存标志,因为我们确实希望将此模块保存在我们的package.json文件中:

npm install request@2.73.0 --save

这对于运行天气应用程序至关重要。

将请求用作函数

现在我们已经安装了请求模块,我们可以开始使用它。在 Atom 中,我们将通过在项目的根目录中创建一个名为app.js的新文件,向该 URL 发出请求,来结束本节。这将是天气应用程序的起点。天气应用程序将是我们创建的最后一个命令行应用程序。将来,我们将为 Web 应用程序以及使用 Socket.IO 创建实时应用程序的后端。但是为了说明异步编程,命令行应用程序是最好的方式。

现在,我们有了我们的应用文件,我们可以开始加载request,就像我们加载其他 npm 模块一样。我们将创建一个常量变量,称之为request,并将其设置为require(request),如下所示:

const request = require('request');

现在我们需要做的是发出一个request。为了做到这一点,我们将不得不调用request函数。让我们调用它,这个函数需要两个参数:

  • 第一个参数将是一个选项对象,我们可以在其中配置各种信息

  • 第二个将是一个回调函数,一旦数据从 HTTP 端点返回,就会被调用

request({}, () => {

});

在我们的情况下,一旦 JSON 数据,即来自 Google Maps API 的数据,返回到 Node 应用程序中,它就会被调用。我们可以添加从request返回的参数。现在,这些是在request文档中概述的参数,我并没有为这些参数编写名称:

在文档中,您可以看到他们称之为errorresponsebody。这正是我们将要调用的。因此,在 Atom 中,我们可以添加errorresponsebody,就像文档中一样。

现在我们可以填写选项对象,这是我们将要指定的与我们的request相关的唯一事物。在这种情况下,其中一个唯一的事物是 URL。URL 准确地指定了您要请求的内容,在我们的情况下,我们在浏览器中有这个。让我们粘贴 URL,就像它出现的那样,将其粘贴到 URL 属性的字符串中:

request({
  url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
}, (error, response, body) => {

});

现在我们已经有了 URL 属性,我们可以在最后加上一个逗号,然后按下回车键。因为我们将指定另一个属性,我们将把json设置为true

request({
  url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
  json: true
}, (error, response, body) => {

});

这告诉request返回的数据将是 JSON 数据,它应该继续,获取 JSON 字符串,并将其转换为对象。这让我们跳过了一步,这是一个非常有用的选项。

有了这个,我们现在可以在回调中做一些事情。将来,我们将使用这个经度和纬度来获取天气。现在,我们将简单地通过使用console.logbody打印到屏幕上。我们将把body参数传递给console.log,如下所示:

request({
  url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
  json: true
}, (error, response, body) => {
  console.log(body);
});

现在我们已经设置了我们的第一个 HTTP 请求,并且有一个回调函数,当数据返回时将会触发,我们可以从终端运行它。

运行请求

要运行请求,我们将使用node并运行app.js文件:

node app.js

当我们这样做时,文件将开始执行,并且在数据打印到屏幕之前会有一个非常短的延迟:

我们得到的正是我们在浏览器中看到的。一些属性,比如address_components,在这种情况下显示为对象,因为我们将其打印到屏幕上。但这些属性确实存在;我们将在本章后面讨论如何获取它们。不过,我们现在有我们的formatted_address,如前面的屏幕截图所示,geometry对象,place_idtypes。这是我们将用来获取经度和纬度,并稍后获取天气数据的内容。

现在我们已经完成了这一步。我们已经向 Google 地理位置 API 发出了请求,并且正在收到数据。我们将在下一节继续创建天气应用程序。

漂亮地打印对象

在我们继续学习 HTTP 和errorresponsebody中确切的内容之前,让我们花一点时间来谈谈如何漂亮地打印一个对象到屏幕上。正如我们在上一小节中看到的,当我们用node app.js运行我们的应用程序时,body 会打印到屏幕上。

但由于有很多嵌套在一起的对象,JavaScript 开始裁剪它们:

如前面的屏幕截图所示,它告诉我们一个对象在results中,但我们无法看到确切的属性是什么。这发生在address_componentsgeometrytypes中。显然这是没有用的;我们想要做的是确切地看到对象中的内容。

使用 body 参数

为了探索所有属性,我们将看一种漂亮地打印我们的对象的方法。这将需要一个非常简单的函数调用,一个我们实际上已经使用过的函数,JSON.stringify。这是一个将你的 JavaScript 对象(body)转换为字符串的函数,记住我们使用json: true语句告诉request将 JSON 转换为对象。在console.log语句中,我们将取得该对象,传入body,并提供如下参数:

const request = require('request');

request({
  url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
  json: true
}, (error, response, body) => {
  console.log(JSON.stringify(body));
});

现在,这就是我们通常使用JSON.stringify的方式,在过去我们只提供了一个参数,即我们想要stringify的对象,在这种情况下,我们将提供另外一些参数。下一个参数用于过滤属性。我们不想使用它,通常是无用的,所以我们暂时将其留空:

console.log(JSON.stringify(body, undefined));

我们需要提供它的原因是,第三个参数是我们想要的东西。第三个参数将格式化 JSON,并且我们将指定每个缩进使用多少空格。我们可以选择24,取决于你的偏好。在这种情况下,我们将选择2

console.log(JSON.stringify(body, undefined, 2));

我们将保存文件并从终端重新运行它。当我们stringify我们的 JSON 并将其打印到屏幕上时,当我们重新运行应用程序时,我们将看到整个对象显示出来。没有任何属性被裁剪,无论它有多复杂,我们都可以看到整个address_components数组,所有内容都显示出来:

接下来,我们有我们的几何对象,这是我们的纬度和经度存储的地方,你可以在这里看到它们的显示:

然后在下面,我们有我们的types,之前被截断了,尽管它是一个包含一个字符串的数组:

现在我们知道如何漂亮地打印我们的对象,扫描控制台内部的数据将会更容易——我们的属性不会被裁剪,它的格式使得数据更易读。在下一节中,我们将开始深入研究 HTTP 和回调中的所有参数。

组成 HTTPS 请求

在前一节中,我们的目标不是理解 HTTP 的工作原理,或者确切的参数errorresponsebody的含义,目标是提供一个回调的真实示例,而不是我们迄今为止使用setTimeout的人为示例:

const request = require('request');

request({
 url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
 json: true
}, (error, response, body) => {
 console.log(JSON.stringify(body, undefined, 2));
});

在前面的案例中,我们有一个真正的回调,一旦 HTTP 请求从谷歌服务器返回,就会被触发。我们能够打印body,并且我们看到了网站上的确切内容。在本节中,我们将深入研究这些参数,让我们首先来看一下body参数。这是request传递给回调函数的第三个参数。

现在,body不是request模块的独有内容(body是 HTTP 的一部分,代表超文本传输协议)。当您向网站发出请求时,返回的数据就是请求的body。我们实际上在生活中已经无数次使用了body。每当我们在浏览器中请求 URL 时,屏幕上呈现的内容就是body

www.npmjs.com的情况下,返回的body是浏览器知道如何渲染的 HTML 网页。body也可以是一些 JSON 信息,这是我们谷歌 API 请求的情况。无论哪种情况,body都是从服务器返回的核心数据。在我们的情况下,body存储了我们需要的所有位置信息,我们将使用这些信息来提取本节中的格式化地址、纬度和经度。

响应对象

在我们深入讨论body之前,让我们先讨论一下response对象。我们可以通过将其打印到屏幕上来查看response对象。让我们在代码中将console.log语句中的body替换为response

const request = require('request');
request({
  url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
  json: true
}, (error, response, body) => {
  console.log(JSON.stringify(response, undefined, 2));
});

然后保存文件,并通过运行node app.js命令在终端内重新运行。我们将等待请求返回时出现一点延迟,然后我们会得到一个非常复杂的对象:

在前面的截图中,我们可以看到response对象中的第一件事是状态码。状态码是从 HTTP 请求返回的东西;它是响应的一部分,告诉您请求的情况如何。

在这种情况下,200表示一切都很顺利,你可能熟悉一些状态码,比如 404 表示页面未找到,或者 500 表示服务器崩溃。在本书中,我们将使用其他的 body 代码。

我们将创建自己的 HTTP API,因此您将熟悉如何设置和使用状态码。

在本节中,我们只关心状态码是200,这表示事情进行得很顺利。在response对象中,接下来我们实际上有body重复,因为它是response的一部分。由于它是从服务器返回的最有用的信息,请求模块的开发人员选择将其作为第三个参数,尽管你可以像在这种情况下清楚地看到的那样,使用response.body来访问它。在这里,我们已经查看了所有的信息,地址组件、格式化地址几何等等。

body参数旁边,我们有一个叫做headers的东西,如下所示:

现在,headers是 HTTP 协议的一部分,它们是键值对,就像你在前面的截图中看到的那样,键和值都是字符串。它们可以在请求中从 Node 服务器发送到谷歌 API 服务器,也可以在谷歌 API 服务器的响应中发送回 Node 服务器。

标头很棒,有很多内置的标头,比如content-typecontent-type是网站的 HTML,在我们的案例中是application/json。我们将在后面的章节中更多地讨论标头。这些标头中大多数对我们的应用程序不重要,大多数我们永远不会使用。当我们在本书后面创建自己的 API 时,我们将设置自己的标头,所以我们将非常熟悉这些标头的工作方式。现在,我们可以完全忽略它们,我只想让你知道的是,你看到的这些标头是由 Google 设置的,它们是从他们的服务器返回的标头。

在标头旁边,我们有请求对象,它存储了关于所做请求的一些信息:

如前面的屏幕截图所示,您可以看到协议 HTTPS,主机,maps.googleapis.com网站,以及其他一些内容,比如地址参数,整个 URL,以及存储在这部分的请求的其他内容。

接下来,我们还有我们自己的标头。这些是从 Node 发送到 Google API 的标头。

当我们在代码中的选项对象中添加json: true时,这个标头就被设置了。我们告诉请求我们想要 JSON 返回,请求继续告诉 Google,“嘿,我们想要接受一些 JSON 数据,所以如果你能用这种格式发送回来就行了!”这正是 Google 所做的。

这是response对象,它存储了关于response和请求的信息。虽然我们不会使用response参数中的大多数内容,但知道它们存在是很重要的。所以如果你需要访问它们,你知道它们在哪里。我们将在整本书中使用一些这些信息,但正如我之前提到的,大部分都是不必要的。

大多数情况下,我们将访问 body 参数。我们将使用的一个东西是状态。在我们的案例中是200。当我们确保请求成功完成时,这将是重要的。如果我们无法获取位置或者如果我们在状态码中得到错误,我们就不想继续尝试获取天气,因为显然我们没有纬度和经度信息。

错误参数

现在,我们可以继续进行最后的错误处理。正如我刚才提到的,状态码可以显示出现了错误,但这将是在 Google 服务器上的错误。也许 Google 服务器有语法错误,他们的程序崩溃了,也许你发送的数据是无效的,例如,你发送了一个不存在的地址。这些错误将通过状态码变得明显。

错误参数包含的是与进行 HTTP 请求的过程相关的错误。例如,也许域名是错误的:如果我在 URL 中删除s和点,用go替换,那么在我们的代码中,我得到的 URL 很可能是不存在的:

const request = require('request');

request({
  url: 'https://mapogleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',

在这种情况下,我会在错误对象中得到一个错误,因为 Node 无法进行 HTTP 请求,甚至无法将其连接到服务器。如果我发出请求的机器没有互联网访问权限,也会出现错误。它将尝试连接到 Google 服务器,但会失败,我们将会得到一个错误。

现在,我们可以通过从 URL 中删除这些文本并发出请求来检查错误对象。在这种情况下,我将把 response 换成error,如下所示:

const request = require('request');

request({
  url: 'https://mapogleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
  json: true
}, (error, response, body) => {
  console.log(JSON.stringify(error, undefined, 2));
});

现在,在终端中,让我们通过运行node app.js命令重新运行应用程序,我们可以看到我们得到了什么:

当我们发出错误请求时,我们的错误对象会打印到屏幕上,我们真正关心的是错误代码。在这种情况下,我们有ENOTFOUND错误。这意味着我们的本地机器无法连接到提供的主机。在这种情况下,mapogleapis.com不存在,所以我们会在这里得到一个错误。

这些将是系统错误,例如您的程序无法连接到互联网或找不到域名。当我们为应用程序创建一些错误处理时,这也将非常重要,因为有可能用户的机器无法连接到互联网。我们将要确保根据错误对象中的内容采取适当的行动。

如果我们可以修复 URL,将其设置回maps.googleapis.com,然后使用上箭头键和enter键进行完全相同的请求,请求错误对象将为空,并且您可以看到 null 打印到屏幕上:

在这种情况下,一切都很顺利,没有错误,并且它能够成功获取数据,这应该是可以的,因为我们有一个有效的 URL。这是对 body、response和错误参数的快速概述。随着我们添加错误处理,我们将更详细地使用它们。

从 body 对象中打印数据

现在,我们将从 body 中将一些数据打印到屏幕上。让我们从打印格式化地址开始,然后我们将负责打印纬度和经度。

打印格式化地址

我们将首先找出格式化地址在哪里。为此,我们将转到浏览器并使用 JSONView。在浏览器页面底部,当我们在项目上高亮显示时,可以看到一个小蓝条出现,并且随着我们切换项目而改变。例如,对于格式化地址,我们访问results属性,results 是一个数组。在大多数地址的情况下,您只会得到一个结果:

我们每次都会使用第一个结果,所以我们有索引0,然后是.formatted_address属性。这最后一行正是我们需要在 Node 代码中输入的内容。

在 Atom 中,在我们的代码中,我们将删除console.log语句,并将其替换为一个新的console.log语句。我们将使用模板字符串为此添加一些漂亮的格式。我们将添加Address后跟一个冒号和一个空格,然后我将使用美元符号和大括号注入地址。我们将访问 body、results 和 results 数组中的第一项,然后是 formatted address,如下所示:

const request = require('request');

request({
 url: 'https://maps.googleapis.com/maps/api/geocode/json?address=1301%20lombard%20street%20philadelphia',
 json: true
}, (error, response, body) => {
 console.log(`Address: ${body.results[0].formatted_address}`);
});

有了这个,我现在可以在末尾加上一个分号并保存文件。接下来,我们将在终端中重新运行应用程序,这一次我们将在屏幕上打印出我们的地址,如下所示:

现在我们已经在屏幕上打印出地址,接下来我们想打印出纬度和经度。

打印纬度和经度

为了开始,在 Atom 中,我们将在我们为格式化地址添加的console.log旁边添加另一行console.log。我们将再次使用模板字符串添加一些漂亮的格式。让我们首先打印纬度。

为此,我们将添加纬度后跟一个冒号。然后,我们可以使用带有大括号的美元符号注入我们的变量。然后,我们想要的变量在 body 中。就像格式化地址一样,它也在第一个 results 项中;在索引为零的 results 中。接下来,我们将进入 geometry。从 geometry 中,我们将获取位置属性,即纬度.lat,如下所示:

  console.log(`Address: ${body.results[0].formatted_address}`);
  console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
});

现在我们已经做好了,我们将对经度做同样的操作。我们将在代码的下一行添加另一个console.log语句。我们将再次使用模板字符串,首先输入经度。之后,我们会加上一个冒号,然后注入数值。在这种情况下,数值在 body 中;它在相同的 results 项中,第一个。我们将再次进入 geometry 位置。我们将访问.lng而不是.lat。然后我们可以在末尾加上一个分号并保存文件。这将看起来像下面这样:

  console.log(`Address: ${body.results[0].formatted_address}`);
  console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
  console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
});

现在我们将从终端进行测试。我们将重新运行先前的命令,并且如下截图所示,您可以看到我们的纬度为39.94,经度为-75.16打印到屏幕上:

而这些值在 Chrome 浏览器中也是完全相同的,39.94-75.16。有了这个,我们现在成功地获取了我们需要向天气 API 发出请求的数据。

总结

在本章中,我们已经介绍了异步编程的基本示例。接下来,我们讨论了在运行异步代码时发生了什么。我们对程序的运行方式以及在幕后发生的工具和技巧有了一个很好的了解。我们通过几个示例说明了调用堆栈、Node API、回调队列和事件循环的工作原理。

然后,我们学习了如何使用请求模块来发出 HTTP 请求以获取一些信息,我们请求的 URL 是 Google Maps 地理编码 URL,并且我们传入了我们想要获取纬度和经度的地址。然后我们使用了一个回调函数,一旦数据返回,就会触发该函数。

回调函数和 API部分的结尾,我们研究了一个关于如何在想要将对象打印到控制台时如何格式化对象的快速提示。最后,我们研究了 HTTPS 请求的组成部分。

在下一章中,我们将为这个回调函数添加一些错误处理,因为这对于我们的 HTTP 请求非常重要。有可能出现问题,当出现问题时,我们希望通过将错误消息打印到屏幕上来处理该错误。

第六章:异步编程中的回调

本章是我们在 Node.js 中的异步编程的第二部分。在本章中,我们将看到回调、HTTP 请求等。我们将处理回调中发生的许多错误。我们在app.js中的请求有很多错误的处理方式,我们将想要弄清楚如何在异步编程中从回调函数中恢复错误。

接下来,我们将把我们的请求代码块移到一个单独的文件中,并抽象出许多细节。我们将讨论这意味着什么,以及为什么对我们很重要。我们将使用 Google 的地理位置 API,并使用 Dark Sky API 来获取像邮政编码这样的位置信息,并将其转换为实时的天气信息。

然后,我们将开始连接天气预报 API,获取地理编码的地址的实时天气数据。我们将在geocodeAddress的回调函数中添加我们的请求。这将让我们使用参数列表中地址的动态经纬度坐标lat/lng,并获取该位置的天气。

具体来说,我们将研究以下主题:

  • 编码用户输入

  • 回调错误

  • 抽象回调

  • 连接天气搜索

  • 链接回调

编码用户输入

在本节中,您将学习如何为天气应用程序设置 yargs。您还将学习如何包含用户输入,这对我们的应用程序非常重要。

如前一章节所示,HTTPS 请求部分,用户不会在终端中输入编码后的地址;相反,他们将输入一个普通文本地址,如1301 Lombard Street

现在这对我们的 URL 不起作用,我们需要对这些特殊字符进行编码,比如空格,用%20替换它们。现在%20是空格的特殊字符,其他特殊字符有不同的编码值。我们将学习如何对字符串进行编码和解码,以便设置我们的 URL 是动态的。它将基于终端提供的地址。这就是我们将在本节讨论的全部内容。在本节结束时,您将能够输入任何您喜欢的地址,并看到格式化的地址、纬度和经度。

安装 yargs

在我们开始进行任何编码之前,我们必须从用户那里获取地址,而在设置 yargs 之前,我们必须安装它。在终端中,我们将运行npm install命令,模块名称是yargs,我们将寻找 10.1.1 版本,这是写作时的最新版本。我们将使用save标志来运行此安装,如下面的截图所示:

现在save标志很棒,因为你记得。它会更新package.json文件,这正是我们想要的。这意味着我们可以摆脱占用大量空间的 node 模块文件夹,但我们总是可以使用npm install重新生成它。

如果你只运行npm install,不加其他模块名称或标志。它将通过package.json文件查找要安装的所有模块,并安装它们,将你的 node 模块文件夹恢复成你离开时的样子。

在安装过程中,我们在app.js文件中进行了一些配置。因此,我们可以首先加载 yargs。为此,在app.js文件中,在请求常量旁边,我将创建一个名为yargs的常量,将其设置为require(yargs),就像这样:

const request = require('request');
const yargs = require('yargs');

现在我们可以继续进行配置。接下来我们将创建另一个常量叫做argv。这将是存储最终解析输出的对象。它将从 process 变量中获取输入,通过yargs处理,结果将直接存储在argv常量中。这将被设置为yargs,我们可以开始添加一些调用:

const request = require('request');
const yargs = require('yargs');

const argv = yargs

现在,当我们创建笔记应用程序时,有各种命令,您可以添加一个需要一些参数的笔记,列出一个只需要标题的笔记,列出所有不需要任何参数的笔记,并且我们在 yargs 中指定了所有这些。

天气应用程序的配置将会简单得多。没有命令,唯一的命令将是获取天气,但如果我们只有一个命令,为什么还要让用户输入呢。在我们的情况下,当用户想要获取天气时,他们只需输入 node app.js,然后跟上 address 标志,就像这样:

node app.js --address

然后他们可以在引号内输入他们的地址。在我的情况下,可能是 1301 lombard street

node app.js --address '1301 lombard street'

这正是命令将被执行的方式。不需要像获取天气这样的实际命令,我们直接从文件名进入我们的参数。

配置 yargs

要配置 yargs,事情看起来会有点不同,但仍然非常相似。在 Atom 中,我将开始调用 .options,这将让我们配置一些顶级选项。在我们的情况下,我们将传入一个对象,其中我们配置所有我们需要的选项。现在我将像我对所有链接调用所做的那样格式化它,将调用移到下一行,并将其缩进,就像这样:

const argv = yargs
  .options({

})

现在我们可以设置我们的选项,在这种情况下我们只有一个,那就是 a 选项;a 将是地址的缩写。我可以在选项中输入地址,然后在别名中输入 a,或者我可以在选项中输入 a,然后在别名中输入地址。在这种情况下,我将像这样输入 a:

const argv = yargs   
  .options({
    a: {

    }
  })

接下来,我可以继续提供那个空对象,然后我们将通过与笔记应用程序中使用的完全相同的选项进行。我们将要求它。如果您要获取天气,我们需要一个地址来获取天气,所以我将设置 demand 等于 true

const argv = yargs
  .options({
    a: {
       demand: true,
    }
  })

接下来,我们可以设置一个 alias,我将把 alias 设置为 address。最后我们将设置 describe,我们可以将 describe 设置为任何我们认为有用的东西,在这种情况下,我将选择 Address to fetch weather for,如下所示:

const argv = yargs
  .options({
    a: {
      demand: true,
      alias: 'address',
 describe: 'Address to fetch weather for'
    }
  })

这些是我们为笔记应用程序提供的三个选项,但我将添加第四个选项,以使我们的天气应用程序的 yargs 配置更加完善。这将是一个名为 string 的选项。现在 string 接受一个布尔值,要么是 true 要么是 false。在我们的情况下,我们希望值为 true。这告诉 yargs 始终将 aaddress 参数解析为字符串,而不是其他类型,比如数字或布尔值:

const argv = yargs
  .options({
    a: {
      demand: true,
      alias: 'address',
      describe: 'Address to fetch weather for',
      string: true
    }
  })

在终端中,如果我删除实际的字符串 addressyargs 仍然会接受它,它只会认为我正在尝试添加一个布尔标志,在某些情况下可能很有用。例如,我想要以摄氏度还是华氏度获取?但在我们的情况下,我们不需要任何 truefalse 标志,我们需要一些数据,所以我们将字符串设置为 true,以确保我们获得这些数据。

现在我们已经配置好了选项配置,我们可以继续添加我们已经探索过的一些其他调用。我将添加 .help,如下面的代码所示,调用它将添加 help 标志。这在某人第一次使用命令时非常有用。然后我们可以访问 .argv,它接受所有这些配置,通过我们的参数运行它,并将结果存储在 argv 变量中:

const argv = yargs
  .options({
    a: {
      demand: true,
      alias: 'address',
      describe: 'Address to fetch weather for',
      string: true
    }
  })
  .help()
 .argv;

现在 help 方法添加了 help 参数,我们还可以通过调用 .alias 来添加它的别名。现在 .alias 接受两个参数,您要为其设置别名的实际参数和别名。在我们的情况下,当我们调用 help 时,已经注册了 help,我们将设置一个别名,它将只是字母 h,很棒:

.help()
.alias('help', 'h')
.argv;

现在我们已经设置了各种非常好的配置,例如,在终端中,我现在可以运行 help,并且可以看到这个应用程序的所有帮助信息:

我也可以使用快捷键-h,然后我会得到完全相同的数据:

将地址打印到屏幕上

现在地址也被传递了,但我们没有将其打印到屏幕上,所以让我们这样做。在配置之后,让我们使用console.log将整个argv变量打印到屏幕上。这将包括yargs解析的所有内容:

  .help()
  .alias('help', 'h')
  .argv;
console.log(argv);

让我们继续在终端中重新运行它,这次传入一个地址。我会使用a标志,并指定类似1301 lombard street的内容,然后关闭引号,按enter

node app.js -a '1301 lombard street'

当我们这样做时,我们得到了我们的对象,并且如代码输出所示,我们有 1301 Lombard St, Philadelphia, PA 19147, USA,纯文本地址:

在上面的截图中,请注意我们恰好获取了该地址的纬度和经度,但这只是因为我们在app.js中的 URL 中硬编码了它。为了让在 URL 中显示的地址是在参数中输入的地址,我们仍然需要做一些更改。

编码和解码字符串

为了探索如何对字符串进行编码和解码,我们将进入终端。在终端中,首先我们将使用clear命令清除屏幕,然后通过输入node命令启动一个节点进程,如下所示:

node

在这里,我们可以运行任何我们喜欢的语句。当我们探索一个非常基本的节点或 JavaScript 功能时,我们首先会查看一些示例,然后再将其添加到我们的实际应用程序中。我们将查看两个函数,encodeURIComponentdecodeURIComponent。我们将首先开始编码。

编码 URI 组件

编码,该方法称为encodeURIComponent,将 URI 组件编码为大写,并且只需要一个参数,即要编码的字符串。在我们的情况下,该字符串将是地址,类似于1301 lombard street philadelphia。当我们通过encodeURIComponent运行这个地址,我们得到编码版本:

encodeURIComponent('1301 lombard street philadelphia')

如下面的代码输出所示,我们可以看到所有的空格,比如 1301 和 lombard 之间的空格,都被替换为它们的编码字符,对于空格的情况,它是%20。通过encodeURIComponent传递我们的字符串,我们将创建一个准备好被注入到 URL 中的内容,以便我们可以发出动态请求。

解码 URI 组件

现在encodeURIComponent的替代方法是。这将接受一个编码的字符串,就像前面的例子中一样,并将所有特殊字符,比如%20,转换回它们的原始值,即空格。为此,在decodeURIComponent中,我们再次传递一个字符串。

让我们继续输入我们的名字和姓氏。在我的情况下,是Andrew,而不是它们之间的空格,我会添加%20,我们知道这是空格的编码字符。由于我们正在尝试解码一些内容,这里有一些编码字符是很重要的。一旦你的代码看起来像下面的代码,带有你的名字和姓氏,你可以继续按enter,然后我们得到的是解码版本:

decodeURIComponent('Andrew%20Mead')

如下面的代码输出所示,我有 Andrew Mead,%20被空格替换,正是我们预期的。这就是我们如何在我们的应用程序中对 URI 组件进行编码和解码:

从 argv 中提取地址

现在我们想要从argv中提取地址,我们已经看到它在那里,我们想要对其进行编码,并且我们想要将其注入到app.js文件中的 URL 中,替换地址:

这将基本上创建我们一直在谈论的动态请求。我们将能够输入任何地址,无论是地址、邮政编码还是城市州组合,我们都将能够获取格式化的地址、纬度和经度。

为了开始,我将首先获取编码后的地址。让我们在app.js中创建一个名为encodedAddress的变量,放在argv变量旁边,我们可以在其中存储结果。我们将把这个变量设置为我们刚刚在终端中探索的方法的返回值,encodeURIComponent。这将获取纯文本地址并返回编码后的结果。

现在我们需要传入字符串,并且我们可以在argv.address中找到它,这是别名:

  .help()
  .alias('help', 'h')
  .argv;
var encodedAddress = encodeURIComponent(argv.address);

在这里,我们可以使用argv.a以及argv.address,两者都可以正常工作。

现在我们已经得到了编码的结果,剩下的就是将其注入到 URL 字符串中。在app.js中,我们目前使用的是普通字符串。我们将其替换为模板字符串,这样我就可以在其中注入变量。

现在我们有了模板字符串,我们可以突出显示静态地址,它在philadelphia结束并延伸到=号,然后将其删除,而不是输入静态地址,我们可以注入动态变量。在我的花括号内,encodedAddress,如下所示:

var encodedAddress = encodeURIComponent(argv.address);

request({
  url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,

有了这个,我们现在完成了。我们从终端获取地址,对其进行编码,并在geocode调用中使用。因此,格式化的地址、纬度和经度应该匹配。在终端中,我们将使用control + C两次关闭 node,并使用 clear 清除终端输出。

然后我们可以使用node app.js运行我们的应用,传入aaddress标志。在这种情况下,我们将只使用a。然后我们可以输入一个地址,例如1614 south broad street philadelphia,如下所示:

node app.js -a '1614 south broad street philadelphia'

当你运行它时,你应该会有一个小延迟,当我们从地理编码 URL 获取数据时。

在这种情况下,我们会发现它实际上比我们预期的要慢一点,大约三到四秒,但我们确实得到了地址:

这里我们有一个格式化的地址,包括正确的邮政编码、州和国家,还有显示经纬度。我们将尝试一些其他例子。例如,对于宾夕法尼亚州的一个名叫查尔方特的小镇,我们可以输入chalfont pa,这并不是一个完整的地址,但是谷歌地理编码 API 会将其转换为最接近的地址,如下所示:

我们可以看到这实际上是查尔方特镇的地址,邮编为 18914,所在州为美国。接下来,我们有该镇的一般纬度和经度数据,这对获取天气数据将很有用。天气在几个街区内并不会有太大变化。

现在我们的数据是动态获取的,我们可以继续下一节,处理回调函数中发生的错误。这个请求有很多可能出错的地方,我们需要弄清楚如何在异步编程中从回调函数中恢复错误。

回调错误

在这一部分,我们将学习如何处理回调函数中的错误,因为正如你可能猜到的那样,事情并不总是按计划进行。例如,我们的应用的当前版本存在一些很大的缺陷,如果我尝试使用node app.js来获取天气,使用a标志输入一个不存在的邮政编码,比如000000,程序会崩溃,这是一个很大的问题。它正在进行。它正在获取数据,最终数据会返回并且我们会得到一个错误,如下所示:

它试图获取不存在的属性,比如body.results[0].formatted_address不是一个真实的属性,这是一个大问题。

我们当前的回调期望一切都按计划进行。它不关心错误对象,也不关注响应代码;它只开始打印它想要的数据。这是快乐的路径,但在现实世界的 node 应用程序中,我们也必须处理错误,否则应用程序将变得非常无用,当事情似乎不如预期工作时,用户可能会变得非常沮丧。

为了做到这一点,我们将在回调函数中添加一组if/else语句。这将让我们检查某些属性,以确定这次调用,即我们在app.js中的 URL,是否应该被视为成功或失败。例如,如果响应代码是 404,我们可能希望将其视为失败,并且我们将要做一些事情,而不是尝试打印地址、纬度和经度。不过,如果一切顺利,这是一个完全合理的做法。

检查 Google API 请求中的错误

  • 机器错误,例如无法连接到网络,通常会显示在错误对象中,

  • 来自其他服务器,Google 服务器的错误,这可能是无效的地址

为了开始,让我们看看当我们向 Google API 传递错误数据时会发生什么。

要查看像之前的示例调用返回的实际内容,其中我们有一个无效地址,我们将转到浏览器并打开我们在app.js文件中使用的 URL:

在本节中,我们将担心两种类型的错误。那将是:

我们将从浏览器历史记录中删除之前使用的地址,并输入000000,然后按enter

我们得到了结果,但没有结果,状态显示ZERO_RESULTS,这是非常重要的信息。我们可以使用状态文本值来确定请求是否成功。如果我们传入一个真实的邮政编码,比如19147,它是费城,我们将得到我们的结果,如下图所示,status将被设置为OK

我们可以使用这个状态来确定事情进行得很顺利。在这些状态属性和我们应用程序中的错误对象之间,我们可以确定在回调函数中究竟要做什么。

添加回调错误的 if 语句

我们要做的第一件事是添加一个如下所示的if语句,检查错误对象是否存在:

request({
  url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
  json: true
}, (error, response, body) => {
  if (error) {

 }

如果错误对象存在,这将运行我们代码块中的代码,如果不存在,我们将继续进入下一个else if语句,如果有的话。

如果有错误,我们只会添加一个console.log和一个屏幕消息,类似于无法连接到 Google 服务器

if (error) {
  console.log('Unable to connect Google servers.');
}

这将让用户知道我们无法连接到用户服务器,而不是他们的数据出了问题,比如地址无效。这就是错误对象中的内容。

现在我们要做的下一件事是添加一个else if语句,并在条件中检查状态属性。如果状态属性是ZERO_RESULTS,就像邮政编码000000一样,我们希望做一些事情,而不是尝试打印地址。在 Atom 中的我们的条件中,我们可以使用以下语句进行检查:

if (error) {
  console.log('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {

}

如果是这种情况,我们将打印一个不同的消息,而不是无法连接到 Google 服务器,对于这个消息,我们可以使用console.log打印无法找到该地址。

if (error) {
  console.log('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {
  console.log('Unable to find that address.');
}

这让用户知道这不是连接的问题,我们只是无法找到他们提供的地址,他们应该尝试其他的东西。

现在我们已经处理了系统错误的错误处理,比如无法连接到 Google 服务器,以及输入错误的错误处理,在这种情况下,我们无法找到该地址的位置,这太棒了,我们已经处理了我们的两个错误。

现在body.status属性出现在else if语句中,这不会出现在每个 API 中,这是特定于 Google Geocode API 的。当您探索新的 API 时,重要的是尝试各种数据,好的数据,比如真实地址和坏的数据,比如无效的邮政编码,以确定您可以使用哪些属性来确定请求是否成功,或者是否失败。

在我们的情况下,如果状态是ZERO_RESULTS,我们知道请求失败了,我们可以相应地采取行动。在我们的app中,现在我们将添加我们的最后一个else if子句,如果事情进展顺利。

在检查 body 状态属性时添加 if else 语句

现在我们想要添加else if子句,检查body.status属性是否等于OK。如果是,我们可以继续运行代码块内的这三行代码:

  console.log(`Address: ${body.results[0].formatted_address}`);
  console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
  console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
});

如果不是,这些行不应该运行,因为代码块不会执行。然后我们将在终端内测试一下,尝试获取00000的地址,并确保程序不会崩溃,而是打印我们的错误消息到屏幕上。然后我们继续搞砸应用程序中的 URL,删除一些重要的字符,并确保这次我们收到无法连接到 Google 服务器。的消息。最后,我们将看看当我们输入一个有效地址时会发生什么,并确保我们的三个console.log语句仍然执行。

首先,我们将添加else if语句,并在条件内检查body.status是否为OK

if (error) {
  console.log('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {
  console.log('Unable to find that address.');
} else if (body.status === 'OK') {

}

如果是OK,那么我们将简单地将三个console.log行(在上一个代码块中显示)移到else if条件中。如果是OK,我们将运行这三个console.log语句:

if (error) {
  console.log('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {
  console.log('Unable to find that address.');
} else if (body.status === 'OK') {
  console.log(`Address: ${body.results[0].formatted_address}`);
 console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
 console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
}

现在我们有一个非常好处理错误的请求。如果出了问题,我们有一个特殊的消息,如果事情顺利,我们打印用户期望的内容,地址,纬度和经度。接下来我们将测试这个。

测试 body 状态属性

为了在终端中测试这一点,我们将首先重新运行具有无效地址的命令:

node app.js -a 000000

当我们运行这个命令时,我们看到无法找到地址。打印到屏幕上。程序不会崩溃,打印一堆错误,而是简单地在屏幕上打印一条消息。这是因为我们在第二个else if语句中的代码尝试访问那些不存在的属性,不再运行,因为我们的第一个else if条件被捕获,我们只是将消息打印到屏幕上。

现在我们还想测试第一个消息(无法连接到 Google 服务器。)在应该打印时是否打印。为此,我们将在我们的代码中删除一部分 URl,比如s.,然后保存文件:

request({
  url: `https://mapgoogleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
  json: true
}, (error, response, body) => {
  if (error) {
    console.log('Unable to connect Google servers.');
  } else if (body.status === 'ZERO_RESULTS') {
   console.log('Unable to find that address.');
  } else if (body.status === 'OK') {
    console.log(`Address: ${body.results[0].formatted_address}`);
    console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
    console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
  }
});

然后我们将重新运行终端中的上一个命令。这一次,我们可以看到无法连接到 Google 服务器。像应该一样打印到屏幕上:

现在我们可以测试最后一件事,首先调整 URL 使其正确,然后从终端获取有效地址。例如,我们可以使用node app.js,将address设置为08822,这是新泽西州的一个邮政编码:

node app.js --address 08822

当我们运行这个命令时,我们确实得到了 Flemington, NJ 的格式化地址,包括邮政编码和州,我们的纬度和经度如下所示:

现在我们有了完整的错误处理模型。当我们向谷歌提供有问题的地址时,比如ZERO_RESULTS,错误对象将被填充,因为从请求的角度来看,这不是一个错误,实际上它在响应对象中,这就是为什么我们必须使用body.status来检查错误。

这就是本节的内容,我们现在已经有了错误处理,我们处理系统错误,谷歌服务器错误,还有我们的成功案例。

抽象回调

在这一节中,我们将重构app.js,将与地理编码相关的复杂逻辑移动到一个单独的文件中。目前,所有与发出请求和确定请求是否成功相关的逻辑,我们的if else语句,都存在于app.js中:

request({
  url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
  json: true
}, (error, response, body) => {
  if (error) {
    console.log('Unable to connect Google servers.');
  } else if (body.status === 'ZERO_RESULTS') {
   console.log('Unable to find that address.');
  } else if (body.status === 'OK') {
    console.log(`Address: ${body.results[0].formatted_address}`);
    console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
    console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
  }
});

这并不是可以重复使用的,而且它真的不属于这里。在我们添加更多与获取天气预报相关的逻辑之前,我想要做的是把这部分代码拆分成自己的函数。这个函数将会存在于一个单独的文件中,就像我们为笔记应用程序所做的那样。

notes app中,我们有一个单独的文件,其中有用于添加、列出和从我们的本地相邻文件中删除笔记的函数。我们将创建一个单独的函数,负责对给定地址进行地理编码。尽管逻辑将保持不变,实际上是没有办法绕过它的,它将被从app.js文件中抽象出来,并放到自己的位置中。

重构 app.js 和代码到 geocode.js 文件

首先,我们需要创建一个新的目录和一个新的文件,然后我们将为该函数添加一些更高级的功能。但在此之前,我们将看看 require 语句会是什么样子。

处理请求语句

我们将通过一个名为geocode的常量变量加载模块,并将其设置为require,因为我们需要一个本地文件,所以我们会添加相对路径,./geocode/geocode.js

const geocode = require('./geocode/geocode.js');

这意味着你需要在weather-app文件夹中创建一个名为geocode的目录,以及一个名为geocode.js的文件。因为我们有一个.js扩展名,所以我们实际上可以在我们的 require 调用中省略它。

现在,在app.js文件中,紧挨着.argv对象,我们需要调用geocode.geocodeAddressgeocodeAddress函数,它将负责app.js中当前所有逻辑。geocodeAddress函数将获取地址,argv.address

geocode.geocodeAddress(argv.address);

它将负责做所有事情,编码 URL,发出请求,并处理所有错误情况。这意味着在新文件中,我们需要导出geocodeAddress函数,就像我们从notes application文件中导出函数一样。接下来,我们在这里有所有的逻辑:

var encodedAddress = encodedURIComponent(argv.address);

request({
  url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
  json: true
}, (error, response, body) => {
  if (error) {
    console.log('Unable to connect Google servers.');
  } else if (body.status === 'ZERO_RESULTS') {
   console.log('Unable to find that address.');
  } else if (body.status === 'OK') {
    console.log(`Address: ${body.results[0].formatted_address}`);
    console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
    console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
  }
});

这个逻辑需要被移动到geocodeAddress函数中。现在我们可以直接复制并粘贴上面显示的代码,有些更复杂的逻辑确实无法避免,但我们需要做一些改变。我们需要将请求加载到新文件中,因为我们使用了它,而且它不会在默认情况下被该文件所需。然后我们可以继续清理代码中的请求调用,因为我们不会在这个文件中使用它。

接下来,argv对象将不再存在,我们将通过第一个参数传递进来,就像geocode.Address语句中的argv.address一样。这意味着我们需要用我们称呼第一个参数的任何东西来替换它,比如 address。一旦这样做了,程序应该会和在app.js中没有任何更改的情况下一样工作,功能上不应该有任何改变。

创建地理编码文件

首先,让我们在weather-app文件夹中创建一个全新的目录,这是我们需要做的第一件事。这个目录叫做geocode,与我们在geocode变量中的 require 语句相匹配。在geocode文件夹中,我们将创建我们的文件geocode.js

现在在geocode.js中,我们可以开始加载请求,让我们创建一个名为request的常量,并将其设置为require('request')

const request = require('request');

现在我们可以继续定义负责地理编码的函数,这个函数将被称为geocodeAddress。我们将创建一个名为geocodeAddress的变量,将其设置为一个箭头函数,并且这个箭头函数将接收一个address参数:

var geocodeAddress = (address) => {

};

这是未编码的纯文本地址。现在在将代码从app.js复制到此函数体之前,我们要使用module.exports导出我们的geocodeAddress函数,我们知道它是一个对象。我们放在module.exports对象上的任何东西都将对任何需要此文件的文件可用。在我们的情况下,我们希望使geocodeAddress属性可用,将其设置为我们在前面的语句中定义的geocodeAddress函数:

var geocodeAddress = (address) => {

};

module.exports.geocodeAddress = geocodeAddress;

现在是时候将app.js中的所有代码复制到geocode.js中了。我们将剪切请求函数代码,移动到geocode.js中,并将其粘贴到我们函数的主体中:

var geocodeAddress = (address) => {
  var encodedAddress = encodedURIComponent(argv.address);

  request({
    url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
    json: true
  }, (error, response, body) => {
    if (error) {
      console.log('Unable to connect Google servers.');
    } else if (body.status === 'ZERO_RESULTS') {
      console.log('Unable to find that address.');
    } else if (body.status === 'OK') {
      console.log(`Address: ${body.results[0].formatted_address}`);
      console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
      console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
    }
  });
};

module.exports.geocodeAddress = geocodeAddress;

在这段代码中,我们唯一需要更改的是如何获取纯文本地址。我们不再有argv对象,而是将address作为参数传入。最终的代码将如下代码块所示:

const request = require('request');

var geocodeAddress = (address) => {
  var encodedAddress = encodedURIComponent(argv.address);

  request({
    url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
    json: true
  }, (error, response, body) => {
    if (error) {
      console.log('Unable to connect Google servers.');
    } else if (body.status === 'ZERO_RESULTS') {
      console.log('Unable to find that address.');
    } else if (body.status === 'OK') {
      console.log(`Address: ${body.results[0].formatted_address}`);
      console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
      console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
    }
  });
};

module.exports.geocodeAddress = geocodeAddress;

有了这个,我们现在完成了geocode文件。它包含了所有复杂的逻辑,用于发出和完成请求。在app.js中,我们可以通过删除一些额外的空格和移除不再在此文件中使用的请求模块来清理代码。最终的app.js文件将如下代码块所示:

const yargs = require('yargs');

const geocode = require('./geocode/geocode');

const argv = yargs
  .options({
    a: {
      demand: true,
      alias: 'address',
      describe: 'Address to fetch weather for',
      string: true
    }
  })
  .help()
  .alias('help', 'h')
  .argv;

geocode.geocodeAddress(argv.address);

现在在这一点上,功能应该完全相同。在终端中,我将继续运行一些来确认更改是否有效。我们将使用a标志搜索一个存在的邮政编码,比如19147,如图所示,我们可以看到地址、纬度和经度:

现在我们将这个邮政编码更改为一个不存在的邮政编码,比如000000,当我们通过地理编码器运行这个时,你会看到“无法找到地址”打印到屏幕上:

这意味着geocode.js中的所有逻辑仍然有效。现在,下一步是向geocodeAddress添加回调函数的过程。

向 geocodeAddress 添加回调函数

重构代码和app.js的目标不是为了摆脱回调,目标是将与编码数据、发出请求和检查错误相关的所有复杂逻辑抽象出来。app.js不应该关心任何这些,它甚至不需要知道是否曾经发出过 HTTP 请求。app.js唯一需要关心的是将地址传递给函数,并对结果进行处理。结果可以是错误消息或数据,格式化的地址、纬度和经度。

在 app.js 中的 geocodeAddress 函数中设置函数

在我们继续在geocode.js中进行任何更改之前,我们要看一下我们将如何在app.js中构造事物。我们将向geocodeAddress传递一个箭头函数,这将在请求返回后被调用:

geocode.geocodeAddress(argv.address, () => {

});

在括号中,我们将期望两个参数,errorMessage,它将是一个字符串,和results,它将包含地址、纬度和经度:

geocode.geocodeAddress(argv.address, (errorMessage, results) => {

});

在这两者中,只有一个会一次可用。如果我们有错误消息,我们将没有结果,如果我们有结果,我们将没有错误消息。这将使箭头函数中的逻辑,确定调用是否成功,变得更简单。我们将能够使用if语句,if (errorMessage),如果有错误消息,我们可以简单地使用console.log语句将其打印到屏幕上:

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  }
});

我们不需要深入任何对象并准确了解发生了什么,所有这些逻辑都在geocode.js中抽象出来。现在如果else子句中没有错误消息,我们可以继续打印结果。我们将使用我们在上一章中讨论过的漂亮打印方法,我们将添加console.log(JSON.stringify)语句,并且漂亮打印结果对象,这个对象将包含一个地址属性、一个纬度属性和一个经度属性。

然后,我们将undefined参数作为我们的第二个参数。这跳过了我们不需要的过滤函数,然后我们可以指定间距,这将以一种非常好的方式格式化,我们将使用两个空格,如下所示:

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(JSON.stringify(results, undefined, 2));
  }
});

现在我们已经在app.jsgeocodeAddress函数中设置好了我们的函数,并且对它的外观有了一个很好的想法,我们可以继续在geocode.js中实现它。

geocode.js文件中实现回调函数

在我们的参数定义中,我们不仅期望一个地址参数,还期望一个回调参数,我们可以在任何时候调用这个回调参数。我们将在三个地方调用它。我们将在if (error)块内部调用它一次,而不是调用console.log,我们将简单地用Unable to connect to Google servers.字符串调用回调。这个字符串将是我们在app.js中的geocodeAddress函数中定义的错误消息。

为了做到这一点,我们所需要做的就是将我们的console.log调用更改为callback调用。我们将作为第一个参数传递我们的错误消息。我们可以将字符串完全按照它在console.log中出现的方式,移动到callback的参数中。然后我可以删除console.log调用并保存文件。结果代码将如下所示:

request({
  url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
  json: true
}, (error, response, body) => {
  if (error) {
    callback('Unable to connect to Google servers.');
  }

现在我们可以在下一个else if块中做完全相同的事情,用我们的另一个console.log语句替换zero results时的console.log

if (error) {
  callback('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {
  callback('Unable to find that address.');
}

现在最后的else if块会有点棘手。这有点棘手,因为我们并没有确切的对象。我们还需要为第一个参数创建一个undefined变量,因为当事情顺利进行时不会提供错误消息。我们只需要调用callback,将一个undefined变量作为第一个参数传递,就可以创建未定义的错误消息。然后我们可以继续指定我们的对象作为第二个参数,这个对象将会完全符合geocodeAddress函数中的结果。

} else if (body.status === 'OK') {
  callback(undefined, {

  })
  console.log(`Address: ${body.results[0].formatted_address}`);
  console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
  console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
}

正如我提到的,结果有三个属性:第一个将是格式化的地址,所以让我们先解决这个问题。我们将把address设置为body.results,就像我们在console.log语句的Address变量中一样。

} else if (body.status === 'OK') {
  callback(undefined, {
    address: body.results[0].formatted_address
  })
  console.log(`Address: ${body.results[0].formatted_address}`);
  console.log(`Latitude: ${body.results[0].geometry.location.lat}`);
  console.log(`Longitude: ${body.results[0].geometry.location.lng}`);
}

在这里,我们正在使事情变得更容易,而不是在app.js中深层嵌套的复杂属性,我们将能够访问一个简单的address属性,对于console.log语句的LatitudeLongitude也是同样的做法。

接下来,我们将获取让我们获取纬度的代码,并添加我的第二个属性latitude,将其设置为我们从console.log语句中获取的代码。然后我们可以继续添加最后一个属性,即longitude,将其设置为latitude代码,用lng替换lat。现在我们已经完成了这一步,我们可以在末尾添加一个分号,并删除console.log语句,因为它们已经不再需要了,这样我们就完成了:

if (error) {
  callback('Unable to connect Google servers.');
} else if (body.status === 'ZERO_RESULTS') {
  callback('Unable to find that address.');
} else if (body.status === 'OK') {
  callback(undefined, {
    address: body.results[0].formatted_address,
    latitude: body.results[0].geometry.location.lat,
    longitude: body.results[0].geometry.location.lng
  });
}

现在我们可以重新运行文件,当我们这样做时,我们将向geocodeAddress传递一个地址,这将发出请求,当请求返回时,我们将能够以一种非常简单的方式处理响应。

geocode.js文件中测试回调函数

在终端中,我们将返回运行两个node app.js命令;使用邮政编码19147的命令,一切都按预期工作,以及一个错误的邮政编码000000,以显示错误消息。

如下所示的代码输出中,我们可以看到我们的结果对象具有一个地址属性,一个纬度属性和一个经度属性:

如果邮政编码错误,我们只需确保错误消息仍然显示出来,确实如此,无法找到该地址。打印到屏幕上,如下所示:

这是因为app.js中的geocodeAddress函数中的if语句。

在将所有这些逻辑抽象到geocode文件之后,app.js文件现在变得简单得多,更容易维护。我们还可以在多个位置调用geocodeAddress。如果我们想要重用代码,我们不必复制和粘贴代码,这不符合DRY原则,即不要重复自己,相反,我们可以做 DRY 的事情,就像我们在app.js文件中所做的那样,简单地调用geocodeAddress。有了这个设置,我们现在已经完成了获取geocode数据。

连接天气搜索

在这一部分,您将向天气 API 发出您的第一个请求,并且一开始我们将以静态方式进行,这意味着它不会使用我们传入的地址的实际纬度和经度,我们将简单地有一个静态的 URL。我们将发出请求,并探索我们在主体中得到的数据。

在浏览器中探索 API 的工作原理

现在,在我们可以向 Atom 添加任何内容之前,我们想要先探索一下这个 API,这样我们就可以看到它在浏览器中的工作原理。这将让我们更好地了解当我们向 API 传递纬度和经度时,我们会得到什么样的天气数据。为了做到这一点,我们将前往浏览器,并访问一些 URL。

首先让我们去forecast.io。这是一个普通的天气网站,您输入您的位置,就会得到您所期望的所有天气信息:

如前面的图像所示,网站上有警告、雷达、当前天气,还有周报预测,如下图所示:

这类似于weather.com,但forecast.io的一个很酷的地方是,驱动这个网站的 API 实际上是可以供开发者使用的。您可以向我们的 URL 发出请求,获取完全相同的天气信息。

这正是我们将要做的,当我们可以通过访问网站developer.forecast.io来探索 API。在这里,我们可以注册一个免费的开发者账户,以便开始发出天气请求:

Dark Sky Forecast API 每天为您提供 1,000 次免费请求,我认为我们不会超过这个限制。在 1,000 次请求之后,每次请求的成本是一千分之一的一分钱,因此您每花一分钱就可以获得一千次请求。我们永远不会超过这个限制,所以不用担心。开始时不需要信用卡,您只需在发出一千次请求后就会被切断。

要开始,您需要注册一个免费账户,这非常简单,我们只需要一个电子邮件和一个密码。一旦我们创建了一个账户,我们就可以看到如下所示的仪表板:

我们从这个页面需要的唯一信息是我们的 API 密钥。API 密钥就像一个密码,它将成为我们请求的 URL 的一部分,并且将帮助forecast.io跟踪我们每天发出的请求数量。现在我将拿到这个 API 密钥并粘贴到app.js中,这样我们以后需要时就可以访问它。

接下来,我们将探索文档,我们需要提供的实际 URL 结构,以便获取给定纬度和经度的天气。我们可以通过单击 API 文档链接按钮来获取,该按钮位于 The Dark Sky Forecast API 页面右上方。这将引导我们到以下页面:

在 API 文档链接中,我们有一个天气预报请求 URL。如前图所示,这个 URL 正是我们需要发出请求以获取数据的 URL。

探索实际的代码 URL

在将此 URL 添加到我们的应用程序并使用请求库之前,我们需要找到实际的 URL,我们可以用它来发出请求。为此,我们将复制它并粘贴到一个新的标签页中:

现在,我们确实需要替换一些 URL 信息。例如,我们有需要替换的 API 密钥,我们还有纬度和经度。这两者都需要用真实的数据替换。让我们从 API 密钥开始,因为我们已经将它复制并粘贴到app.js中。我们将复制 API 密钥,并用实际值替换[key]

接下来,我们可以获取一组经度和纬度坐标。为此,进入终端并运行我们的应用程序,node app.js,对于地址,我们可以使用任何邮政编码,比如19146来获取纬度和经度坐标。

接下来,我们将复制这些内容并放入 URL 中。纬度放在斜杠和逗号之间,经度将放在逗号之后,如下所示:

一旦我们有了一个真实的 URL,其中所有这三个信息都被实际信息替换掉,我们就可以发出请求,我们将得到的是天气预报信息:

请记住,这种方式显示在前面的图像中的信息是由 JSONView 生成的,我强烈建议安装它。

现在我们得到的数据是令人不知所措的。我们有按分钟的预报,按小时的预报,按周的预报,按天的预报,各种各样的信息,这些信息非常有用,但也非常令人不知所措。在本章中,我们将使用currently中的第一个对象。这存储了所有当前的天气信息,比如当前的摘要是晴朗,温度,降水概率,湿度,很多真正有用的信息都在其中。

在我们的情况下,我们真正关心的是温度。费城的当前温度显示为84.95度。这是我们想在应用程序中使用的信息,当有人搜索特定位置的天气时。

使用静态 URL 请求天气应用程序

现在,为了玩转天气 API,我们将采用在上一节中定义的完全相同的 URL,并在app.js中发出请求。首先,我们需要做一些设置工作。

app.js中,我们将注释掉到目前为止的所有内容,并在我们的 API 密钥旁边,我们将调用请求,请求这个确切的 URL,就像我们在上一节/章节中为地理编码 API 所做的那样,然后我们将打印出body.currently.temperature属性到屏幕上,这样当我们运行应用程序时,我们将看到我们使用的纬度和经度的当前温度。在我们的情况下,它是代表费城的静态纬度和经度。

为了开始,我们将加载请求。现在我们之前在app.js文件中有它,然后我们在上一节中将它移除了,但是我们将再次添加它。我们将它添加到注释掉的代码旁边,通过创建一个名为request的常量,并加载它,const request等于require('request')

const request = require('request');

现在我们可以继续进行实际请求,就像我们为地理编码 API 所做的那样,通过调用request,这是一个与此函数相同的函数:

const request = require('request');

request();

我们必须传入我们的两个参数,选项对象是第一个,第二个是箭头函数:

request({}, () => {

});

这是我们的回调函数,一旦 HTTP 请求完成就会触发。在填写实际函数之前,我们要设置我们的选项。有两个选项,URL 和 JSON。我们将url设置为静态字符串,即我们在浏览器中的确切 URL:

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',

}, () => {

然后在逗号后的下一行,我们可以将json设置为true,告诉请求库继续解析该 JSON,这就是它的作用:

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
 json: true
}, () => {

从这里,我们可以继续添加我们的回调参数;errorresponsebody。这些是我们在geocode.js文件的geocoding请求的if块中具有的完全相同的三个参数:

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
 json: true
}, (error, response, body) => {

});

既然我们已经做到了这一点,我们需要做的最后一件事就是打印当前温度,这是在body中使用console.log语句可用的。我们将使用console.log来打印body.currently.temperature,如下所示:

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
 json: true
}, (error, response, body) => {
  console.log(body.currently.temperature);
});

既然我们已经打印了温度,我们需要通过从终端运行来测试它。在终端中,我们将重新运行之前的命令。这里实际上没有使用地址,因为我们已经注释掉了那段代码,我们得到的是 28.65,如代码输出所示:

我们的天气 API 调用已经在应用程序中工作。

回调函数中的错误处理

现在我们确实想在回调函数中添加一些错误处理。我们将在错误对象上处理错误,还将处理从forecast.io服务器返回的错误。首先,就像我们为地理编码 API 所做的那样,我们将检查错误是否存在。如果存在,这意味着我们无法连接到服务器,因此我们可以打印一条向用户传达该消息的消息,console.log类似于无法连接到 forecast.io 服务器。

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
 json: true
}, (error, response, body) => {
  if (error){
    console.log('Unable to connect to Forecast.io server.');
  }
  console.log(body.currently.temperature);
});

现在我们已经处理了一般错误,我们可以继续处理forecast.io API 抛出的特定错误。当 URL 的格式,即纬度和经度不正确时,就会发生这种情况。

例如,如果我们删除 URL 中包括逗号的一些数字,然后按enter,我们将得到 400 Bad Request:

这是实际的 HTTP 状态码。如果你还记得geolocation API 中的body.status属性,它要么是OK,要么是ZERO_RESULTS。这与该属性类似,只是这里使用了 HTTP 机制,而不是谷歌使用的某种自定义解决方案。在我们的情况下,我们将检查状态码是否为 400。现在,如果我们有一个错误的 API 密钥,我将在 URL 中添加一些 e,我们也会得到 400 Bad Request:

因此,这两个错误都可以使用相同的代码来处理。

在 Atom 中,我们可以通过检查状态码属性来处理这个问题。在我们的if语句的闭合大括号之后,我们将添加else if块,else if (response.statusCode),这是我们在详细查看响应参数时查看的属性。如果出现问题,response.statusCode将等于400,这正是我们要在这里检查的:

if (error){
  console.log('Unable to connect to Forecast.io server.');
} else if (response.statusCode === 400) {

}

如果状态码是400,我们将打印一条消息,console.log('无法获取天气')

if (error){
  console.log('Unable to connect to Forecast.io server.');
} else if (response.statusCode === 400) {
  console.log('Unable to fetch weather.');
}

现在我们已经处理了这两个错误,我们可以继续处理成功的情况。为此,我们将添加另一个else if块,其中response.statusCode等于200。如果一切顺利,状态码将等于200,在这种情况下,我们将把当前温度打印到屏幕上。

我将删除console.log(body.currently.temperature)行,并将其粘贴到 else if 代码块中:

  if (error){
    console.log('Unable to connect to Forecast.io server.');
  } else if (response.statusCode === 400) {
    console.log('Unable to fetch weather.');
  } else if (response.statusCode === 200) {
    console.log(body.currently.temparature);
  }
});

另一种错误处理方式

还有另一种方法来表示我们整个 if 块代码。以下是一个更新的代码片段,我们实际上可以用这段代码替换当前回调函数中的所有内容:

if (!error && response.statusCode === 200) {
  console.log(body.currently.temperature);
} else {
  console.log('Unable to fetch weather.');
}

这个条件检查是否没有错误并且响应状态码是200,如果是这样,我们该怎么办?我们只需像上次一样打印温度,那是在最底部的else if子句中。现在我们在更新的代码片段中有一个else情况,所以如果有错误或状态码不是200,我们将继续打印这条消息到屏幕上。这将处理服务器没有网络连接,或者来自无效或损坏 URL 的 404。好了,使用这段代码,一切应该按照最新版本的天气 API 预期的那样工作。

测试回调中的错误处理

现在我们已经放置了一些错误处理,我们可以继续测试我们的应用程序是否仍然有效。从终端中,我们将重新运行之前的命令,我们仍然得到一个温度 28.71:

在 Atom 中,我们将通过去掉逗号来清除一些数据,保存文件:

request({
 url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284-75.18663959999999',
 json: true
}, (error, response, body) => {
  if (error){
    console.log('Unable to connect to Forecast.io server.');
  } else if (response.statusCode === 400) {
    console.log('Unable to fetch weather.');
  } else if (response.statusCode === 200) {
    console.log(body.currently.temparature);
  }
});

当我们从终端重新运行它时,这次,我们期望“无法获取天气。”打印到屏幕上,当我重新运行应用程序时,这正是我们得到的,如下所示:

现在,让我们把逗号加回去,测试我们代码的最后一部分。为了测试错误,我们可以通过从forecast.io中删除点这样的东西来测试:

request({
 url: 'https://api.forecastio/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
 json: true
}, (error, response, body) => {

我们可以重新运行应用程序,我们会看到“无法连接到 Forecast.io 服务器。”:

我们所有的错误处理都很好用,如果没有错误,适当的温度将打印到屏幕上,这太棒了。

链接回调

在这一节中,我们将把我们在上一节中创建的代码分解成自己的文件。类似于我们在调用geocodeAddress时所做的地理编码 API 请求,而不是实际在app.js中进行请求调用。这意味着我们将创建一个新文件夹,一个新文件,并在其中创建一个导出的函数。

之后,我们将继续学习如何将回调链接在一起。因此,当我们从终端获取该地址时,我们可以将其转换为坐标。然后我们可以将这些坐标转换为温度信息,或者我们想要从 Forecast API 的返回结果中获取的任何天气数据。

重构我们在 weather.js 文件中的请求调用

现在在我们进行重构之前,我们将创建一个全新的文件,并且我们将担心将我们在上一节中创建的代码放入该函数中。然后我们将创建回调。

在 weather 文件中定义新函数 getWeather

首先,让我们创建目录。目录将被称为weather。在weather目录中,我们将创建一个名为weather.js的新文件。

现在在这个文件中,我们可以将我们从app.js中的所有代码复制到weather.js中:

const request = require('request');

request({
  url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
  json: true
}, (error, response, body) => {
  if (error) {
    console.log('Unable to connect to Forecast.io server.');
  } else if (response.statusCode === 400) {
    console.log('Unable to fetch weather.');
  } else if (response.statusCode === 200) {
    console.log(body.currently.temperature);
  }
});

为了将这段代码转换为创建该函数所需的唯一事情,我们将调用请求移到其中。我们将创建一个名为getWeather的全新函数,放在request变量旁边:

const request = require('request');
var getWeather = () => {

};

getWeather将需要一些参数,但这将稍后添加。现在我们将保持参数列表为空。接下来,我们将把我们对请求的调用移到getWeather函数内部:

const request = require('request');
var getWeather = () => {
  request({
   url: 'https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/39.9396284,-75.18663959999999',
   json: true
}, (error, response, body) => {
  if (error) {
    console.log('Unable to connect to Forecast.io server.');
  } else if (response.statusCode === 400) {
    console.log('Unable to fetch weather.');
  } else if (response.statusCode === 200) {
    console.log(body.currently.temperature);
  }
});
};

然后,我们可以继续导出这个getWeather函数。我们将添加module.exports.getWeather并将其设置为我们定义的getWeather函数:

module.exports.getWeather = getWeather;

在 app.js 中提供 weather 目录

现在我们已经准备就绪,可以继续进入app.js添加一些代码。我们需要做的第一件事是删除 API 密钥。我们不再需要它。然后我们将突出显示所有注释掉的代码,并使用命令/取消注释。

现在我们将导入weather.js文件。我们将创建一个名为weatherconst变量,并将其设置为require返回的结果:

const yargs = require('yargs');

const geocode = require('./geocode/geocode');
const weather = require('');

在这种情况下,我们正在引入我们刚刚创建的全新文件。我们将提供一个相对路径./,因为我们正在加载我们编写的文件。然后我们将提供名为weather的目录,后跟名为weather.js的文件。我们可以省略js扩展名,因为我们已经知道:

const weather = require('./weather/weather');

现在我们已经加载了天气 API,我们可以继续调用它。我们将注释掉对geocodeAddress的调用,并运行weather.getWeather()

// geocode.geocodeAddress(argv.address, (errorMessage, results) => {
//  if (errorMessage) {
//    console.log(errorMessage);
//  } else {
//    console.log(JSON.stringify(results, undefined, 2));
//  }
//});

weather.getWeather();

正如我之前提到的,后面将有参数。现在我们将把它们留空。我们可以从终端运行我们的文件。这意味着我们应该看到我们在上一节中硬编码的坐标的天气打印出来。因此,我们将运行node app.js。因为我们没有注释掉 yargs 代码,所以我们需要提供一个地址。因此,我们将添加一个虚拟地址。我将使用新泽西州的邮政编码:

node app.js -a 08822

现在,geolocation代码从未运行,因为它被注释掉了。但是我们正在运行已移至新文件的天气代码。我们确实看到了温度为 31.82 度,这意味着代码在新文件中得到了正确执行。

getWeather函数中传递参数

现在我们需要传入一些参数,包括回调函数和天气文件中的getWeather变量。我们需要使用这些参数来代替静态的lat/lng对。我们还需要调用回调函数,而不是使用console.log。在我们实际更改weather.js代码之前,我们需要做的第一件事是更改app.js代码。需要添加三个参数。这些是latlngcallback

首先,我们需要传入纬度。我们将从weather.js中的 URL 中获取静态数据,复制它,并将其粘贴到app.js的参数列表中作为第一个参数。接下来是经度。我们将从 URL 中获取它,复制它,并将其粘贴到app.js中作为第二个参数:

// lat, lng, callback
weather.getWeather(39.9396284, -75.18663959999999);

然后我们可以继续提供第三个参数,这将是回调函数。一旦天气数据从 API 返回,这个函数将被触发。我将使用一个箭头函数,它将得到我们在上一节中讨论过的那两个参数:errorMessageweatherResults

weather.getWeather(39.9396284, -75.18663959999999, (errorMessage, weatherResults) => {

});

weatherResults对象包含我们想要的任何温度信息。在这种情况下,它可以是温度和实际温度。现在,我们已经在geocodeAddress中使用weatherResults代替results,这是因为我们想要区分weatherResultsgeocodeAddress中的results变量。

getWeather函数中打印errorMessage

app.js中的getWeather函数内部,我们现在需要使用if-else语句来根据错误消息是否存在来打印适当的内容到屏幕上。如果有errorMessage,我们确实希望使用console.log来打印它。在这种情况下,我们将传入errorMessage变量:

weather.getWeather(39.9396284, -75.18663959999999, (errorMessage, weatherResults) => {
  if (errorMessage) {
    console.log(errorMessage);
  }
});

现在如果没有错误消息,我们将使用weatherResults对象。稍后我们将打印一个漂亮格式的消息。现在我们可以简单地使用我们在上一章中讨论过的漂亮打印技术,即在console.log中调用JSON.stringify来打印weatherResults对象:

weather.getWeather(39.9396284, -75.18663959999999, (errorMessage, weatherResults) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(JSON.stringify());
  }
});

JSON.stringify的括号内,我们将提供这三个参数,实际对象;weatherResults,我们的过滤函数的undefined,以及缩进的数字。在这种情况下,我们将再次选择2

weather.getWeather(39.9396284, -75.18663959999999, (errorMessage, weatherResults) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(JSON.stringify(weatherResults, undefined, 2));
  }
});

现在我们已经用所有三个参数调用了getWeather,我们可以继续实际在weather.js中实现这个调用。

在 weather.js 文件中实现 getWeather 回调

首先,我们将使weather.js文件中的 URL 动态化,这意味着我们需要用模板字符串替换 URL 字符串。一旦我们有了模板字符串,我们就可以将纬度和经度的参数直接注入 URL 中。

添加动态纬度和经度

让我们继续定义传入的所有参数。我们添加latlng和我们的callback

var getWeather = (lat, lng, callback) => {

首先让我们注入那个纬度。我们将取出静态纬度,然后在斜杠和逗号之间使用花括号和美元符号注入它。这让我们能够将一个值注入到我们的模板字符串中;在这种情况下是lat。然后我们可以在逗号后面做完全相同的事情,注入经度。我们将删除静态经度,使用美元符号和花括号将变量注入到字符串中:

var getWeather = (lat, lng, callback) => {
  request({
    url: `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`,

现在 URL 是动态的,我们在getWeather中需要做的最后一件事是将我们的console.log调用更改为callback调用。

将 console.log 调用更改为 callback 调用

要将我们的console.log更改为callback调用,对于前两个console.log调用,我们可以将console.log替换为callback。这将与我们在app.js中指定的参数对齐,第一个是errorMessage,第二个是weatherResults。在这种情况下,我们将传递errorMessage,第二个参数是undefined,这应该是的。我们可以对Unable to fetch weather做同样的事情:

if (error) {
  callback('Unable to connect to Forecast.io server.');
} else if (response.statusCode === 400) {
  callback('Unable to fetch weather.');
}

现在第三个console.log调用将会更复杂一些。我们将不得不创建一个对象,而不仅仅是传递温度。我们将用第一个参数调用callback,因为在这种情况下没有errorMessage。相反,我们将提供weatherResults对象:

if (error) {
  callback('Unable to connect to Forecast.io server.');
} else if (response.statusCode === 400) {
  callback('Unable to fetch weather.');
} else if (response.statusCode === 200) {
  callback(undefined, {

  })
  console.log(body.currently.temperature);
}

在括号内,我们可以定义我们喜欢的所有温度属性。在这种情况下,我们将定义temperature,将其设置为body.currently,它存储所有currently天气数据,.temperature

else if (response.statusCode === 200) {
  callback(undefined, {
    temperature: body.currently.temperature
  })
  console.log(body.currently.temperature);
}

现在我们有了temperature变量,我们可以继续为对象提供第二个属性,即实际温度。实际温度将考虑湿度、风速和其他天气条件。实际温度数据在当前称为apparentTemperature的属性下可用。我们将提供它。作为值,我们将使用相同的东西。这将使我们得到currently对象,就像我们为温度所做的那样。这将是body.currently.apparentTemperature

else if (response.statusCode === 200) {
  callback(undefined, {
    temperature: body.currently.temperature,
    apparentTemperature: body.currently.apparentTemperature
  })
  console.log(body.currently.temperature);
}

现在我们有了两个属性,所以我们可以继续删除那个console.log语句。添加一个分号。最终的代码将如下所示:

const request = require('request');

var getWeather = (lat, lng, callback) => {
  request({
    url: `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`,
    json: true
  }, (error, response, body) => {
    if (error) {
      callback('Unable to connect to Forecast.io server.');
    } else if (response.statusCode === 400) {
      callback('Unable to fetch weather.');
    } else if (response.statusCode === 200) {
      callback(undefined, {
        temperature: body.currently.temperature,
        apparentTemperature: body.currently.apparentTemperature
      });
    }
  });
};

module.exports.getWeather = getWeather;

现在我们可以继续运行应用程序。我们已经在weather.js文件和app.js文件中都连接了getWeather函数。现在我们再次使用静态坐标,但这将是我们最后一次使用静态数据运行文件。从终端中,我们将重新运行应用程序:

如图所示,我们将我们的温度对象打印到屏幕上。我们有我们的温度属性 48.82,还有明显温度,已经达到了 47.42 度。

有了这个,我们现在准备好学习如何将我们的回调链接在一起。这意味着在app.js中,我们将获取从geocodeAddress返回的结果,将它们传递给getWeather,并用它来打印您在终端中提供的地址的动态天气。在这种情况下,我们将获取新泽西镇的地址。与我们在app.js文件中使用的静态地址相反,那个纬度/经度对是为费城的。

将 geocodeAddress 和 getWeather 回调链接在一起

首先,我们必须将我们的getWeather调用移动到geocodeAddresscallback函数中。因为在这个callback函数中是我们唯一可以访问纬度和经度对的地方。

现在,如果我们打开geocode.js文件,我们可以看到我们得到formatted_address作为地址属性,我们得到latitude作为纬度,我们得到longitude作为经度。我们将开始连接这些。

将 getWeather 调用移到 geocodeAddress 函数中

首先,我们需要在app.js中删除geocodeAddress的注释。

接下来,我们将继续,将成功情况下的console.log语句替换为一个将打印格式化地址的console.log调用:

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(results.address);
  }
});

这将在屏幕上打印地址,这样我们就知道我们获取天气数据的确切地址。

现在我们已经让console.log打印出地址,我们可以把getWeather调用移到console.log行的下面:

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(results.address);
    weather.getWeather(39.9396284, -75.18663959999999, 
    (errorMessage, weatherResults) => {
      if (errorMessage) {
        console.log(errorMessage);
      } else {
        console.log(JSON.stringify(weatherResults, undefined, 2));
      }
    });
  }
});

有了这个,我们现在非常接近实际将这两个回调链接在一起。唯一剩下的就是用动态坐标替换这些静态坐标,这些动态坐标将在results对象中可用。

用动态坐标替换静态坐标

第一个参数将是results.latitude,我们在app.js中定义的对象。第二个参数将是results.longitude

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(results.address);
    weather.getWeather(results.latitude, results.longitude, 
    (errorMessage, weatherResults) => {
      if (errorMessage) {
        console.log(errorMessage);
      } else {
        console.log(JSON.stringify(weatherResults, undefined, 2));
      }
    });
  }
});

这就是我们需要做的一切,将数据从geocodeAddress传递给getWeather。这将创建一个在终端中打印我们动态天气的应用程序。

现在在我们继续运行之前,我们将用更格式化的对象调用替换它。我们将从weather.js文件中获取temperature变量和apparentTemperature变量的信息,并在app.js中的字符串中使用它们。这意味着我们可以删除getWeather调用的else块中的console.log,并用不同的console.log语句替换它:

if (errorMessage) {
  console.log(errorMessage);
} else {
  console.log();
}

我们将使用模板字符串,因为我们计划注入一些变量,这些变量是当前的温度。我们将使用weatherResults.temperature进行注入。然后我们可以继续添加一个句号,然后添加类似于:It feels like,后面跟着apparentTemperature属性,我将使用weatherResults.apparentTemperature进行注入。之后我会加一个句号:

if (errorMessage) {
  console.log(errorMessage);
} else {
  console.log(`It's currently ${weatherResults.temperature}. It feels like 
    ${weatherResults.apparentTemperature}`);
}

我们现在有一个console.log语句,可以将天气打印到屏幕上。我们还有一个可以将地址打印到屏幕上的语句,我们还为geocodeAddressgetWeather都设置了错误处理程序。

测试回调链的链接

让我们继续测试,通过在终端中重新运行node app.js命令。我们将使用相同的邮政编码08822

node app.js -a 08822

当我们运行它时,我们得到了 Flemington, NJ 作为格式化的地址,当前温度是 31.01 度。体感温度是 24.9 度。现在为了测试这个是否有效,我们将在引号内输入其他内容,比如Key West fl

node app.js -a 'Key West fl'

当我们运行这个命令时,我们确实得到了 Key West, FL 作为格式化的地址,当前温度是 64.51 度。体感温度是 64.52 度。

有了这个,天气应用程序现在已经连接起来了。我们获取地址,使用 Google Geocoding API 获取纬度/经度对。然后我们使用我们的预报 API 将这个纬度/经度对转换成温度信息。

总结

在本章中,我们学习了如何为weather-app文件设置 yargs,以及如何在其中包含用户输入。接下来,我们研究了如何处理回调函数中的错误以及如何从这些错误中恢复。我们只是在callback函数中添加了else/if语句。回调函数只是一个函数,所以为了弄清楚事情是顺利进行还是出了问题,我们必须使用else/if语句,这让我们可以根据我们是否认为请求进行顺利来执行不同的操作,比如打印不同的消息。然后,我们发出了第一个天气 API 请求,并研究了根据经纬度组合获取天气的方法。

上一次,我们讨论了链接geocodeAddressgetWeather调用函数。我们将最初在app.js中的请求调用移动到了weather.js中,并在那里定义了它。我们使用回调将weather.js中的数据传递到了我们导入weather.js文件的app.js中。然后,在geocodeAddress的回调中调用getWeather,在getWeather的回调中将天气特定信息打印到屏幕上。所有这些都是使用回调函数完成的。

在下一章中,我们将讨论使用 ES6 promises 来同步异步代码的另一种方法。

第七章:异步编程中的 promises

在前两章中,我们学习了 Node 中许多重要的异步编程概念。本章是关于 promises 的。自 ES6 以来,JavaScript 中就有了 promises。尽管它们在第三方库中已经存在了相当长的时间,但它们最终进入了核心 JavaScript 语言,这很棒,因为它们是一个真正棒的特性。

在本章中,我们将学习 promise 的工作原理,开始了解它们为什么有用,以及它们为什么甚至存在于 JavaScript 中。我们将看一下一个叫做 axios 的库,它支持 promises。这将让我们简化我们的代码,轻松地创建我们的 promise 调用。我们实际上将在最后一节重新构建整个天气应用程序。

具体来说,我们将研究以下主题:

  • ES6 promises 简介

  • 高级 promises

  • 使用 promises 的天气应用程序

ES6 promises 简介

Promises 旨在解决我们的应用程序中存在大量异步代码时出现的许多问题。它们使我们更容易管理我们的异步计算,比如从数据库请求数据。或者在天气应用程序的情况下,比如从 URL 获取数据。

app.js文件中,我们使用回调做了类似的事情:

const yargs = require('yargs');

const geocode = require('./geocode/geocode');
const weather = require('./weather/weather');

const argv = yargs
  .options({
    a: {
      demand: true,
      alias: 'address',
      describe: 'Address to fetch weather for',
      string: true
    }
  })
  .help()
  .alias('help', 'h')
  .argv;

geocode.geocodeAddress(argv.address, (errorMessage, results) => {
  if (errorMessage) {
    console.log(errorMessage);
  } else {
    console.log(results.address);
    weather.getWeather(results.latitude, results.longitude, (errorMessage, weatherResults) => {
      if (errorMessage) {
        console.log(errorMessage);
      } else {
        console.log(`It's currently ${weatherResults.temperature}. It feels like ${weatherResults.apparentTemperature}.`);
      }
    });
  }
});

在这段代码中,我们有两个回调:

  • 传递给geocodeAddress的一个

  • 传递给getWeather的一个

我们使用这个来管理我们的异步操作。在我们的情况下,这些操作包括从 API 获取数据,使用 HTTP 请求。在这个例子中,我们可以使用 promises 来使代码更加简洁。这正是本章的目标。

在本节中,我们将探讨 promise 的基本概念。我们暂时不会比较和对比回调和 promise,因为有很多微妙之处,不能在不知道 promise 如何工作的情况下描述。因此,在我们讨论它们为什么更好之前,我们将简单地创建一些。

创建一个例子 promise

在 Atom 中,我们将在playground文件夹中创建一个新文件,并将其命名为promise.js。在我们定义 promise 并讨论它们的工作原理之前,我们将通过一个简单的例子来运行,因为这是学习任何东西的最佳方式——通过一个例子并看到它是如何工作的。

首先,我们将通过一个非常基本的例子来开始。我们将坚持核心 promise 功能。

要开始这个非常简单的例子,我们将创建一个名为somePromise的变量。这将最终存储 promise 对象。我们将在这个变量上调用各种方法来处理 promise。我们将把somePromise变量设置为 promise 构造函数的返回结果。我们将使用new关键字创建 promise 的新实例。然后,我们将提供我们想要创建新实例的东西,Promise,如下所示:

var somePromise = new Promise

现在这个Promise函数,实际上是一个函数——我们必须像调用函数一样调用它;也就是说,它需要一个参数。这个参数将是一个函数。我们将使用一个匿名箭头函数(=>),在其中,我们将做所有我们想做的异步工作:

var somePromise = new Promise(() => {

});

它将被抽象化,有点像我们在geocode.js文件的geocodeAddress函数中抽象化 HTTP 请求一样:

const request = require('request');

var geocodeAddress = (address, callback) => {
  var encodedAddress = encodeURIComponent(address);

  request({
    url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
    json: true
  }, (error, response, body) => {
    if (error) {
      callback('Unable to connect to Google servers.');
    } else if (body.status === 'ZERO_RESULTS') {
      callback('Unable to find that address.');
    } else if (body.status === 'OK') {
      callback(undefined, {
        address: body.results[0].formatted_address,
        latitude: body.results[0].geometry.location.lat,
        longitude: body.results[0].geometry.location.lng
      });
    }
  });
};

module.exports.geocodeAddress = geocodeAddress;

geocodeAddress函数中的所有复杂逻辑确实需要发生,但app.js文件不需要担心它。app.js文件中的geocode.geocodeAddress函数有一个非常简单的if语句,检查是否有错误。如果有错误,我们将打印一条消息,如果没有,我们将继续。同样的情况也适用于 promises。

new Promise回调函数将使用两个参数resolvereject调用:

var somePromise = new Promise((resolve, reject) => {

});

这就是我们管理承诺状态的方式。当我们做出承诺时,我们正在做出承诺;我们在说,“嘿,我会去并且为你获取那个网站的数据。”现在这可能会顺利进行,这种情况下,你会resolve承诺,将其状态设置为实现。当一个承诺实现时,它已经出去并且做了你期望它做的事情。这可能是一个数据库请求,一个 HTTP 请求,或者完全不同的东西。

现在当你调用reject时,你是在说,“嘿,我们试图完成那件事,但我们就是无法。”所以承诺被认为是被拒绝的。这是你可以设置一个承诺的两种状态——实现或拒绝。就像在geocode.js中一样,如果事情顺利进行,我们要么为错误提供一个参数,要么为第二个参数提供一个参数。不过,承诺给了我们两个可以调用的函数。

现在,为了准确说明我们如何使用这些,我们将调用resolve。再次强调,这不是异步的。我们还没有做任何事情。所以所有这些都将在终端中实时发生。我们将使用一些数据调用resolve。在这种情况下,我将传入一个字符串嘿。它起作用了!如下所示:

var somePromise = new Promise((resolve, reject) => {
     resolve('Hey. It worked!');
});

现在这个字符串就是承诺实现的价值。这正是某人会得到的。在应用文件中的geocode.geocodeAddress函数的情况下,它可能是数据,无论是结果还是错误消息。但在我们的情况下,我们使用resolve,所以这将是用户想要的实际数据。当事情顺利进行时,“嘿。它起作用了!”就是他们期望的。

现在你只能给resolvereject传递一个参数,这意味着如果你想提供多个信息,我建议你解决或拒绝一个对象,你可以在上面设置多个属性。但在我们的情况下,一个简单的消息“嘿。它起作用了!”就可以了。

然后调用承诺方法

现在,为了在承诺被实现或拒绝时实际执行某些操作,我们需要调用一个名为then的承诺方法;somePromise.thenthen方法让我们为成功和错误情况提供回调函数。这是回调和承诺之间的一个区别。在回调中,我们有一个无论如何都会触发的函数,参数让我们知道事情是否顺利进行。而在承诺中,我们将有两个函数,这将决定事情是否按计划进行。

现在在我们深入添加两个函数之前,让我们先从一个函数开始。在这里,我将调用then,传入一个函数。只有在承诺实现时,这个函数才会被调用。这意味着它按预期工作。当它这样做时,它将被调用并传递给resolve。在我们的情况下,它是一个简单的message,但在数据库请求的情况下,它可以是像用户对象这样的东西。不过,现在我们将坚持使用message

somePromise.then((message) => {

})

这将在屏幕上打印message。在回调中,当承诺得到实现时,我们将调用console.log,打印“成功”,然后作为第二个参数,我们将打印实际的message变量:

somePromise.then((message) => {
  console.log('Success: ', message);
})

在终端中运行承诺示例

现在我们已经有了一个非常基本的承诺示例,让我们使用我们在上一章中安装的nodemon从终端运行它。我们将添加nodemon,然后进入playground文件夹,/promise.js

当我们立即这样做时,我们的应用程序运行并且我们获得成功。“嘿。它起作用了!”这是瞬间发生的。没有延迟,因为我们还没有异步地做任何事情。现在当我们首次探索回调时(参见第五章,Node.js 中异步编程的基础),我们使用setTimeout来模拟延迟,这正是我们在这种情况下要做的。

在我们的somePromise函数中,我们将调用setTimeout,传入两个参数:延迟后要调用的函数和以毫秒为单位的延迟。我将选择2500,即 2.5 秒:

var somePromise = new Promise((resolve, reject) => {
 setTimeout(() => {

}, 2500);

现在在这 2.5 秒之后,然后,只有在这时,我们才希望resolve承诺。这意味着我们的函数,我们传递给then的函数将在 2.5 秒后才会被调用。因为,正如我们所知,这将在承诺解决之前不会被调用。我将保存文件,这将重新启动nodemon

在终端中,你可以看到我们有延迟,然后success: Hey it worked!打印到屏幕上。这 2.5 秒的延迟是由setTimeout引起的。延迟结束后(在这种情况下是人为延迟,但以后将是真正的延迟),我们能够用数据resolve

承诺中的错误处理

现在有可能事情并不顺利。我们必须在 Node 应用程序中处理错误。在这种情况下,我们不会调用resolve,而是会调用reject。让我们注释掉resolve行,并创建第二行,在这一行中我们调用reject。我们将以与调用resolve相同的方式调用reject。我们必须传入一个参数,在这种情况下,一个简单的错误消息如无法实现承诺就可以了。

var somePromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    // resolve('Hey. It worked!');
    reject('Unable to fulfill promise');
  }, 2500);
});

现在当我们调用reject时,我们告诉承诺它已被拒绝。这意味着我们尝试的事情并不顺利。目前,我们没有处理这一点的参数。正如我们提到的,这个函数只有在事情按预期进行时才会被调用,而不是在出现错误时。如果我保存文件并在终端中重新运行它,我们将得到一个拒绝的承诺:

然而,我们没有一个处理程序,所以什么都不会打印到屏幕上。这将是一个相当大的问题。我们需要对错误消息做些什么。也许我们会警告用户,或者我们会尝试一些其他代码。

如前面的代码输出所示,我们可以看到在重新启动和退出之间没有打印任何内容。为了处理错误,我们将在then方法中添加第二个参数。这个第二个参数让我们能够处理承诺中的错误。这个参数将被执行,并用该值调用。在这种情况下,它是我们的消息。我们将创建一个名为errorMessage的参数,如下所示:

somePromise.then((message) => {
  console.log('Success: ', message);
}, (errorMessage) => {

});

在参数中,我们可以对其进行一些操作。在这种情况下,我们将使用console.log将其打印到屏幕上,打印带有冒号和空格的Error,然后是被拒绝的实际值:

}, (errorMessage) => {
  console.log('Error: ', errorMessage);
});

现在我们已经有了这个,我们可以通过保存文件来刷新事情。现在我们将在终端中看到我们的错误消息,因为我们现在有一个可以做一些事情的地方:

在这里,我们有一个地方可以将消息打印到屏幕上;无法实现承诺打印到屏幕上,这正如预期的那样。

承诺的优点

我们现在有一个可以被解决或拒绝的承诺。如果它被解决,意味着承诺已经实现,我们有一个处理它的函数。如果它被拒绝,我们也有一个处理它的函数。这是承诺很棒的原因之一。你可以根据承诺是否被解决或拒绝来提供不同的函数。这让你避免了在我们的代码中使用大量复杂的if语句,我们需要在app.js中管理实际回调是否成功或失败。

现在在承诺中,重要的是要理解你只能resolvereject一次承诺。如果你resolve了一个承诺,你就不能在以后reject它,如果你用一个值resolve它,你就不能在以后改变主意。考虑这个例子,我有一个像下面这样的代码;在这里我首先resolve然后我reject

var somePromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Hey. It worked!');
    reject('Unable to fulfill promise');
  }, 2500);
});

somePromise.then((message) => {
  console.log('Success: ', message);
}, (errorMessage) => {
  console.log('Error: ', errorMessage);
});

在这种情况下,我们将看到我们的成功message打印到屏幕上。我们永远不会看到errorMessage,因为,正如我刚才说的,你只能执行其中一个操作一次。你只能一次resolve或一次reject。你不能两次都做;你不能两次做任何一种。

这是回调的另一个巨大优势。没有什么能阻止我们意外地两次调用callback函数。例如,让我们考虑geocode.js文件。让我们在 geocode 请求调用的if块中添加另一行,如下所示:

const request = require('request');

var geocodeAddress = (address, callback) => {
  var encodedAddress = encodeURIComponent(address);

  request({
    url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
    json: true
  }, (error, response, body) => {
    if (error) {
      callback('Unable to connect to Google servers.');
      callback();

这是一个更明显的例子,但它很容易隐藏在复杂的if-else语句中。在这种情况下,我们app.js中的callback函数确实会被调用两次,这可能会给我们的程序带来很大的问题。在 promise 示例中,无论你尝试多少次调用resolvereject,这个函数只会被触发一次,这个回调永远不会被调用两次。

我们可以通过再次调用resolve来证明这一点。在 promise 示例中,让我们保存文件并进行以下更改:

var somePromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Hey. It worked!');
    resolve();
    reject('Unable to fulfill promise');
  }, 2500);
});

现在,让我们刷新一下;我们将用我们的消息Hey. It worked!resolve,我们永远不会再次触发函数,因为,正如我们所说的,promise 已经解决。一旦将 promise 的状态设置为已满足或已拒绝,就不能再次设置它。

在 promise 的resolvereject函数被调用之前,promise 处于一种称为待定的状态。这意味着你正在等待信息返回,或者你正在等待异步计算完成。在我们的例子中,当我们等待天气数据返回时,promise 将被视为待定。当 promise 被满足或拒绝时,它被认为是已解决的。

无论你选择哪一个,你都可以说 promise 已经完成,这意味着它不再是待定的。在我们的例子中,这将是一个已完成的 promise,因为resolve就是在这里调用的。这些只是 promise 的一些好处。你不必担心回调被调用两次,你可以提供多个函数——一个用于成功处理,一个用于错误处理。这真的是一个很棒的工具!

现在我们已经快速介绍了 promise 的工作原理,只是基本原理,我们将转向稍微复杂一些的内容。

高级 promise

在本节中,我们将探讨使用 promise 的另外两种方法。我们将创建接受输入并返回 promise 的函数。此外,我们将探索 promise 链式调用,这将让我们组合多个 promise。

提供 promise 的输入

现在我们在上一节讨论的示例中的问题是,我们有一个 promise 函数,但它不接受任何输入。当我们使用真实世界的 promise 时,这很可能永远不会发生。我们将想要提供一些输入,比如从数据库中获取用户的 ID,请求的 URL,或者部分 URL,例如只有地址组件。

为了做到这一点,我们必须创建一个函数。在这个例子中,我们将创建一个名为asyncAdd的函数。

var asyncAdd = () => {

}

这将是一个使用setTimeout模拟异步功能的函数。实际上,它只是将两个数字相加。但是,它将准确地说明我们需要在本章后面做的事情,以便使用 promise 来获取我们的天气应用程序。

现在在函数中,我们将使用ab两个参数,并返回一个 promise:

var asyncAdd = (a, b) => {

};

因此,无论谁调用这个asyncAdd方法,他们都可以传入输入,但他们也可以得到 promise,以便他们可以使用 then 来同步并等待它完成。在asyncAdd函数内部,我们将使用return来做到这一点。我们将使用完全相同的new Promise语法来return一个new Promise对象,就像我们创建somePromise变量时所做的那样。现在这是相同的函数,所以我们确实需要提供构造函数,该构造函数使用resolvereject两个参数进行调用,就像这样:

var asyncAdd = (a, b) => {
 return new Promise((resolve, reject) => {

 });

现在我们有一个asyncAdd函数,它接受两个数字并返回一个 promise。唯一剩下的事情就是实际模拟延迟,并调用resolve。为此,我们将使用setTimeout来模拟延迟。然后我们将传入我的callback函数,将延迟设置为 1.5 秒,或1500毫秒:

return new Promise((resolve, reject) => {
 setTimeout(() => {

 }, 1500)
 });

callback函数中,我们将编写一个简单的if-else语句,检查ab的类型是否都是数字。如果是,太好了!我们将resolve这两个数字相加的值。如果它们不是数字(一个或多个),那么我们将reject。为此,我们将使用if语句和typeof运算符:

setTimeout(() => {
  if (typeof a === 'number')
 }, 1500);

在这里,我们使用typeof对象来获取变量之前的字符串类型。此外,我们检查它是否等于一个数字,这是当我们有一个数字时typeof将返回的内容。现在类似于a,我们将添加typeof b,它也是一个数字:

    if (typeof a === 'number' && typeof b === 'number') {}

我们可以将这两个数字相加,解析出值。在if语句的代码块内,我们将调用resolve,传入a + b

 return new Promise((resolve, reject) => {
   setTimeout(() => {
     if (typeof a === 'number' && typeof b === 'number') { 
       resolve(a + b);
     }
   }, 1500);

这将把这两个数字相加,传入一个参数给resolve。现在这是一个快乐的路径,当ab都是数字时。如果事情不顺利,我们会想要添加reject。我们将使用else块来做这个。如果前面的条件失败,我们将通过调用reject('Arguments must be numbers')reject

   if (typeof a === 'number' && typeof b === 'number') { 
     resolve(a + b);
   } else {
     reject('Argumets must be numbers');
   }

现在我们有一个asyncAdd函数,它接受两个变量ab,返回一个 promise,任何调用asyncAdd的人都可以添加一个 then 调用到返回结果上,以获得该值。

返回 promise

现在这到底是什么样子?为了展示这一点,首先我们将注释掉promise.jsnewPromise变量中的所有代码。接着,我们将调用asyncAdd变量,我们将调用asyncAdd。我们将像调用任何其他函数一样调用它,传入两个值。请记住,这可以是数据库 ID 或任何其他异步函数的内容。在我们的例子中,它只是两个数字。假设是57。现在这个函数的返回值是一个 promise。我们可以创建一个变量并在该变量上调用 then,但我们也可以直接使用then方法,如下所示:

asyncAdd(5, 7).then

这正是我们在使用 promise 时要做的事情;我们将添加 then,传入我们的回调。第一个回调是成功的情况,第二个是错误的情况:

ouldasyncAdd(5, 7).then(() => {
}, () => {

});

在第二个回调中,我们将得到我们的errorMessage,我们可以使用console.log(errorMessage);语句将其记录到屏幕上,如下所示:

asyncAdd(5, 7).then(() => {

}, (errorMessage) => {
 console.log(errorMessage);
});

如果一个或多个数字实际上不是数字,error函数将会触发,因为我们调用了reject。如果两个都是数字,我们将得到结果并将其打印到屏幕上,使用console.log。我们将添加res并在箭头函数(=>)内部添加console.log语句,并打印带有冒号的字符串Result。然后,作为console.log的第二个参数,我们将传入实际的数字,这将打印到屏幕上:

asyncAdd(5, 7).then(() => {
 console.log('Result:', res);
}, (errorMessage) => {
 console.log(errorMessage);
});

现在我们已经在适当的位置有了我们的 promise asyncAdd函数,让我们在终端中测试一下。为此,我们将运行nodemon来启动nodemon playground/promise.js

我们将立即得到延迟和结果,12打印到屏幕上。这太棒了!我们能够创建一个接受动态输入的函数,但仍然返回一个 promise。

现在请注意,我们已经将通常需要回调的异步函数包装成使用 promise 的形式。这是一个很方便的功能。当你开始在 Node 中使用 promise 时,你会意识到有些东西不支持 promise,而你希望它们支持。例如,我们用来进行 HTTP 请求的 request 库不支持原生的 promise。然而,我们可以将我们的请求调用包装在一个 promise 中,这是我们稍后将要做的。不过,现在我们有一个基本的例子说明了这是如何工作的。接下来,我们想谈谈 promise chaining。

Promise chaining

Promise chaining 是指多个 promise 按顺序运行的概念。例如,我想要将地址转换为坐标,然后将这些坐标转换为天气信息;这是需要同步两件事情的一个例子。而且,我们可以很容易地使用 promise chaining 来做到这一点。

为了链接我们的 promise,在我们的成功调用中,我们将返回一个新的 promise。在我们的例子中,我们可以通过再次调用asyncAddreturn一个新的 promise。我将在resconsole.log语句旁边调用asyncAdd,传入两个参数:结果,前一个 promise 返回的任何东西,以及某种新的数字;让我们使用33

asyncAdd(5, 7).then((res) => {
 console.log('Result:', res);
 return asyncAdd(res, 33);

现在我们返回了一个 promise,所以我们可以通过再次调用then方法来添加我的链式操作。then方法将在我们关闭前一个then方法的括号后被调用。这也将接受一个或多个参数。我们可以传入一个成功处理程序,它将是一个函数,以及一个错误处理程序,它也将是一个函数:

 asyncAdd(5, 7).then((res) => {
   console.log('Result:', res);
   return asyncAdd(res, 33);
 }, (errorMessage) => {
   console.log(errorMessage);
 }).then(() => {

 }, () => {

 })

现在我们已经设置好了我们的then回调,我们可以填写它们。再一次,我们将得到一个结果;这将是57的结果,即12,再加33,将是45。然后,我们可以打印console.log ('Should be 45')。接下来,我们将打印结果变量的实际值:

}).then((res) => {
 console.log('Should be 45', res);
}, () => {
});

现在我们的错误处理程序也将是一样的。我们将有errorMessage,并使用console.log将其打印到屏幕上,打印errorMessage

}).then((res) => {
 console.log('Should be 45', res);
}, (errorMessage) => {
 console.log(errorMessage);
});

现在我们有了一些链式操作。我们的第一个then回调函数将根据我们第一个asyncAdd调用的结果触发。如果顺利进行,第一个将触发。如果进行不顺利,第二个函数将触发。我们的第二个 then 调用将基于asyncAdd调用,我们在其中添加33。这将让我们将两个结果链接在一起,我们应该得到45打印在屏幕上。我们将保存这个文件,这将重新启动nodemon中的事情。最终,我们会得到我们的两个结果:12和我们的Should be 45。如下图所示,我们得到了Result: 12Should be 45,打印在屏幕上:

promise 链中的错误处理

现在谈到错误处理时,有一些怪癖;所以,我们将模拟一些错误。首先,让我们模拟第二个asyncAdd调用中的错误。我们知道可以通过传入一个非数字的值来实现这一点。在这种情况下,让我们用引号括起33

 asyncAdd(5, 7).then((res) => {
   console.log('Result:', res);
   return asyncAdd(res, '33');
 }, (errorMessage) => {
   console.log(errorMessage);
 }).then((res) => {
   console.log('Should be 45', res);
 }, (errorMessage) => {
   concole.log(errorMessage);
 })

这将是一个字符串,我们的调用应该reject。现在我们可以保存文件并看看会发生什么:

我们得到Result: 12,然后我们得到我们的错误,Arguments must be numbers。正如我们所期望的那样,这会打印在屏幕上。我们没有得到Should be 45,而是得到了我们的错误消息。

但是,当 promise 链中的早期某个东西被拒绝时,事情就会变得有点棘手。让我们用数字33替换'33'。然后让我们用字符串'7'替换7,如下所示:

 asyncAdd(5, '7').then((res) => {
   console.log('Result:', res);
   return asyncAdd(res, 33);
 }, (errorMessage) => {
   console.log(errorMessage);
 }).then((res) => {
   console.log('Should be 45', res);
 }, (errorMessage) => {
   concole.log(errorMessage);
 })

这将导致我们的第一个 promise 失败,这意味着我们将永远看不到结果。我们应该看到错误消息打印在屏幕上,但这不会发生:

当我们重新启动时,确实会将错误消息打印到屏幕上,但然后我们还会得到Should be 45 undefined。第二个console.log正在运行,因为我们在第二个asyncAdd函数中提供了一个错误处理程序。它正在运行错误处理程序。然后它说,好的,现在事情一定很好,我们运行了错误处理程序。让我们继续进行下一个 then 调用,调用成功案例

catch 方法

要修复错误,我们可以从两个then调用中删除我们的错误处理程序,并用一个调用替换它们,即在最底部调用一个不同的方法,我们将称之为.catch

asyncAdd(5, '7').then((res) => {
 console.log('Result:', res);
 return asyncAdd(res, 33);
}).then((res) => {
 console.log('Should be 45', res);
}).catch;

catch promise 方法类似于 then,但它只接受一个函数。如下面的代码所示,如果我们的任何 promise 调用失败,我们可以指定一个错误处理程序。我们将获取errorMessage并使用console.log(errorMessage)将其打印到屏幕上:

asyncAdd(5, '7').then((res) => {
 console.log('Result:', res);
 return asyncAdd(res, 33);
}).then((res) => {
 console.log('Should be 45', res);
}).catch((errorMessage) => {
 console.log(errorMessage)
});

不过,如果现在有些模糊,没关系,只要你开始看到我们到底在做什么。我们正在将一个 promise 的结果传递给另一个 promise。在这种情况下,结果的工作方式与预期完全相同。第一个 promise 失败,我们得到了打印到屏幕上的Arguments must be numbers。此外,我们没有得到那个破碎的语句,我们尝试打印45,但我们得到了 undefined。使用 catch,我们可以指定一个错误处理程序,它将对我们之前的所有失败进行处理。这正是我们想要的。

承诺中的请求库

现在正如我之前提到的,有些库支持 promise,而另一些则不支持。请求库不支持 promise。我们将创建一个包装请求并返回 promise 的函数。我们将使用前一章中的geocode.js文件中的一些功能。

首先,让我们讨论一下快速设置,然后我们将实际填写它。在playground文件夹中,我们可以创建一个新文件来存储这个,名为promise-2.js

我们将创建一个名为geocodeAddress的函数。geocodeAddress函数将接受纯文本地址,并返回一个 promise:

var geocodeAddress = (address) => {

};

geocodeAddress函数将返回一个 promise。因此,如果我传入一个邮政编码,比如19146,我会期望返回一个 promise,我可以附加一个then调用。这将让我等待该请求完成。在这里,我将添加一个调用then,传入我的两个函数:当 promise 被实现时的成功处理程序和当 promise 被拒绝时的错误处理程序:

geocodeAddress('19146').then(() => {

}, () => {

})

现在当事情顺利进行时,我期望得到带有地址、纬度经度location对象,当事情进行不顺利时,我期望得到错误消息:

geocodeAddress('19146').then((location) => {

}, (errorMessage) => {

})

当错误消息发生时,我们将只是使用console.log(errorMessage)将其打印到屏幕上。目前,当事情顺利进行并且成功案例运行时,我们将使用我们的漂亮打印技术,console.log打印整个对象。然后,我们将调用JSON.stringify,就像我们以前做过很多次一样,传入三个参数——对象,未定义的过滤方法——我们在书中永远不会使用,以及数字2作为我们想要用作缩进的空格数:

geocodeAddress('19146').then((location) => {
 console.log(JSON.stringify(location, undefined, 2));
}, (errorMessage) => {
 console.log(errorMessage); 
});

这就是我们想要创建的功能正常工作的函数。这个then调用应该像前面的代码中显示的那样工作。

要开始,我将通过调用return new Promise返回 promise,传入我的构造函数:

var geocodeAddress = (address) => {
 return new Promise(() => {

 });
};

在函数内部,我们将添加对请求的调用。让我们提供resolvereject参数:

 return new Promise((resolve, reject) => {
 });
};

现在我们已经设置好了我们的Promise,我们可以在代码顶部加载请求模块,创建一个名为request的常量,并将其设置为require('request')的返回结果:

const request = require('request');

var geocodeAddress = (address) => {

接下来,我们将进入geocode.js文件,获取geocodeAddress函数内的代码,并将其移动到promise-2文件中的构造函数内:

const request = require('request');
var geocodeAddress = (address) => {
 return new Promise((resolve, reject) => {
 var encodedAddress = encodeURIComponent(address);

request({
 url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
 json: true
 }, (error, response, body) => {
   if (error) {
   callback('Unable to connect to Google servers.');
 } else if (body.status === 'ZERO_RESULTS') {
   callback('Unable to find that address.');
 } else if (body.status === 'OK') {
   callback(undefined, {
     address: body.results[0].formatted_address,
     latitude: body.results[0].geometry.location.lat,
     longitude: body.results[0].geometry.location.lng
     });
    }
   });
 });
};

现在我们基本上可以开始了;我们只需要改变一些东西。我们需要做的第一件事是替换我们的错误处理程序。在代码的if块中,我们用一个参数调用了我们的callback处理程序;相反,我们将调用reject,因为如果这段代码运行,我们希望reject这个 promise。在下一个else块中也是一样的。如果我们得到了ZERO_RESULTS,我们将调用reject。这确实是一个失败,我们不想假装我们成功了:

if (error) {
   reject('Unable to connect to Google servers.');
 } else if (body.status === 'ZERO_RESULTS') {
   reject('Unable to find that address.');

现在在下一个else块中,事情进展顺利;在这里我们可以调用resolve。此外,我们可以删除第一个参数,因为我们知道resolvereject只接受一个参数:

if (error) { 
  reject('Unable to connect to Google servers.');
 } else if (body.status === 'ZERO_RESULTS') {
   reject('Unable to find that address.');
 } else if (body.status === 'OK') {
   resolve(

我们可以指定多个值,因为我们在对象上resolve了属性。既然我们已经做到了这一点,我们就完成了。我们可以保存我们的文件,重新在终端中运行它,然后测试一下。

测试请求库

为了测试,我们将保存文件,进入终端,关闭promise.js文件的nodemon。我们将运行promise.js文件的node。它在playground文件夹中,名为promise-2.js

node playground/promise-2.js

现在,当我们运行这个程序时,我们实际上正在发出 HTTP 请求。如下面的代码输出所示,我们可以看到数据返回的确如我们所期望的那样:

我们得到了我们的addresslatitudelongitude变量。这太棒了!现在让我们测试一下,当我们传入一个无效的地址时会发生什么,比如 5 个零,这是我们以前用来模拟错误的:

const request = require('request');

var geocodeAddress = (address) => {
  return new Promise((resolve, reject) => {
    var encodedAddress = encodeURIComponent(address);

  request({
   url: `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`,
   json: true
 }, (error, response, body) => {
   if (error) {
     reject('Unable to connect to Google servers.');
   } else if (body.status === 'ZERO_RESULTS') {
     reject('Unable to find that address.');
   } else if (body.status === 'OK') {
     resolve({
       address: body.results[0].formatted_address,
       latitude: body.results[0].geometry.location.lat,
       longitude: body.results[0].geometry.location.lng
      });
     }
   });
  });
};

我们将保存文件,重新运行程序,屏幕上打印出无法找到该地址。

这仅仅是因为我们调用了reject。我们将在Promise构造函数中调用reject。我们有我们的错误处理程序,它将消息打印到屏幕上。这是一个将不支持 promise 的库包装成 promise 的示例,创建一个准备好的 promise 函数。在我们的情况下,该函数是geocodeAddress

带有 promise 的天气应用程序

在本节中,我们将学习如何使用内置 promise 的库。我们将探索 axios 库,它与 request 非常相似。不过,它使用 promise 而不是像 request 那样使用回调。因此,我们不必将我们的调用包装在 promise 中以获得 promise 功能。我们实际上将在本节中重新创建整个天气应用程序。我们只需要编写大约 25 行代码。我们将完成整个过程:获取地址、获取坐标,然后获取天气。

从 app.js 文件中获取天气应用程序代码

要从 app.js 文件中获取天气应用程序代码,我们将复制app.js,因为我们在原始的app.js文件中配置了yargs,我们希望将代码转移到新项目中。没有必要重写它。在weather目录中,我们将复制app.js,给它一个新的名字,app-promise.js

app-promise.js中,在我们添加任何内容之前,让我们先删除一些东西。我们将删除geocodeweather变量声明。我们将不需要引入任何文件:

然后我将删除我们yargs配置之后的所有内容,这种情况下只有我们对geocodeAddress的调用。结果代码将如下所示:

const yargs = require('yargs');

const argv = yargs
 .options({
   a: {
     demand: true,
     alias: 'address',
     describe: 'Address to fetch weather for',
     string: true
   }
 })
 .help()
 .alias('help', 'h')
 .argv;

Axios 文档

现在我们有了一个干净的板子,我们可以开始安装新的库。在运行npm install命令之前,我们将看看在哪里可以找到文档。我们可以通过访问以下网址获取:www.npmjs.com/package/axios。如下面的截图所示,我们有 axios npm 库页面,我们可以查看有关它的各种信息,包括文档:

在这里我们可以看到一些看起来很熟悉的东西。我们调用了thencatch,就像我们在 axios 之外使用 promise 时一样。

在这个页面的统计栏中,你可以看到这是一个非常受欢迎的库。最新版本是 0.13.1。这正是我们将要使用的确切版本。当你在项目中使用 axios 时,可以随时访问这个页面。这里有很多非常好的例子和文档。不过,现在我们可以安装它。

安装 axios

要安装 axios,在终端中,我们将运行npm install;库的名称是axios,我们将使用save标志指定版本0.17.1来更新package.json文件。现在我可以运行install命令,来安装 axios:

app-promise文件中进行调用

在我们的app-promise文件中,我们可以通过在顶部加载axios来开始。我们将创建一个常量叫做axios,将其设置为require('axios'),如下所示:

const yargs = require('yargs');
const axios = require('axios');

既然我们已经准备就绪,我们实际上可以开始在代码中进行调用了。这将涉及我们从地理编码和天气文件中提取一些功能。因此,我们将打开geocode.jsweather.js文件。因为我们将从这些文件中提取一些代码,比如 URL 和一些错误处理技术。尽管我们会在遇到时讨论它们的不同之处。

我们需要做的第一件事是对地址进行编码并获取地理编码 URL。现在这些操作发生在geocode.js中。因此,我们实际上会复制encodedAddress变量行,即我们创建编码地址的行,并将其粘贴到app-promise文件中,跟在argv变量后面。

  .argv;

var encodedAddress = encodeURIComponent(argv.address);

现在我们需要稍微调整一下这个。address变量不存在;但是我们有argv.address。因此,我们将address替换为argv.address

var encodeAddress = encodeURIComponent(argv.address);

现在我们有了编码后的地址;在我们开始使用 axios 之前,我们需要获取的下一件事是我们想要发出请求的 URL。我们将从geocode.js文件中获取。在app-promise.js中,我们将创建一个名为geocodeURI的新变量。然后,我们将从geocode.js中获取 URL,从开头的反引号到结束的反引号,复制并粘贴到app-promise.js中,赋值给geocodeURI

var encodedAddress = encodeURIComponent(argv.address);
var geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`;

现在我们在 URL 中使用了编码的address变量;这没问题,因为它确实存在于我们的代码中。因此,在这一点上,我们有了我们的geocodeUrl变量,我们可以开始制作我们的第一个 axios 请求了。

发出 axios 请求

在我们的情况下,我们将获取地址并获取纬度经度。为了发出请求,我们将调用 axios 上可用的一个方法,axios.get

var geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedAddress}`;

axios.get

get是让我们发出 HTTP get 请求的方法,这正是我们在这种情况下想要做的。而且,设置起来非常简单。当你期望 JSON 数据时,你所要做的就是传入geocodeUrl变量中的 URL。无需提供任何其他选项,比如让它知道它是JSON的选项。axios 知道如何自动解析我们的 JSON 数据。get返回的实际上是一个 promise,这意味着我们可以使用.then来在 promise 被实现或被拒绝时运行一些代码,无论事情进行得好还是糟:

axios.get(geocodeUrl).then()

then中,我们将提供一个函数。这将是成功的情况。成功的情况将被调用一个参数,axios库建议你将其称为response

axios.get(geocodeUrl).then((response) => {

});

从技术上讲,我们可以随意调用任何你喜欢的东西。现在在函数内部,我们将获得与我们在请求库内部获得的所有相同的信息;诸如我们的头部、响应和请求头部,以及正文信息;各种有用的信息。不过,我们真正需要的是response.data属性。我们将使用console.log打印出来。

axios.get(geocodeUrl).then((response) => {
  console.log(response.data);
});

现在我们已经做好了准备,我们可以运行我们的app-promise文件,传入一个有效的地址。此外,我们可以看看当我们发出请求时会发生什么。

在命令行(终端)中,我们将首先使用clear命令清除终端输出。然后我们可以运行node``app-promise.js,传入一个地址。让我们使用一个有效的地址,例如1301 lombard street, philadelphia

node app-promise.js -a '1301 lombard street philadelphia

请求发出。我们得到了什么?我们得到了与我们在前几章中使用其他模块时看到的结果对象完全相同的结果:

这种情况下唯一的区别是我们使用了内置的 promises,而不是必须将其包装在 promises 中或使用回调。

axios 请求中的错误处理

现在除了我们在上一个示例中使用的成功处理程序之外,我们还可以添加一个调用 catch 的调用,让我们捕获可能发生的所有错误。我们将获得错误对象作为唯一的参数;然后我们可以对该错误对象进行处理:

axios.get(geocodeUrl).then((response) => {
 console.log(response.data);
});catch((e) => {

});

在函数内部,我们将使用console.log来启动事情,打印错误参数:

}).catch((e) => {
 console.log(e)
});

现在让我们通过删除 URL 中的点来模拟错误:

var encodedAddress = encodeURIComponent(argv.address);
var geocodeUrl = `https://mapsgoogleapis.com/maps/api/geocode/json?address=${encodedAddress}`;

axios.get(geocodeUrl).then((response) => {
   console.log(response.data);
}).catch((e) => {
   console.log(e)
});

我们可以看看当我们重新运行程序时会发生什么。现在我这样做是为了探索axios库。我知道会发生什么。这不是我这样做的原因。我这样做是为了向你展示你应该如何处理新的库。当你得到一个新的库时,你想玩弄它的所有不同工作方式。当我们有一个请求失败时,错误参数中究竟会返回什么?这是重要的信息;所以当你编写一个真实的应用程序时,你可以添加适当的错误处理代码。

在这种情况下,如果我们重新运行完全相同的命令,我们将收到一个错误:

正如你所看到的,屏幕上真的没有什么可打印的。我们有很多非常神秘的错误代码,甚至errorMessage属性,通常包含一些好的内容或者没有。然后我们有一个错误代码,后面跟着 URL。相反,我们希望打印一个纯文本的英文消息。

为此,我们将使用一个if-else语句,检查代码属性是什么。这是错误代码,在这种情况下是ENOTFOUND;我们知道这意味着它无法连接到服务器。在app-promise.js中,在错误处理程序内部,我们可以通过使用if来添加这个条件:

}).catch((e) => {
 if (e.code === 'ENOTFOUND') {

}

如果是这种情况,我们将使用console.log在屏幕上打印某种自定义消息:

}).catch((e) => {
  if (e.code === 'ENOTFOUND') {
   console.log('Unable to connect to API servers.');
  } 
  console.log(e);
 });

现在我们有了一个处理这种特定情况的错误处理程序。所以我们可以删除我们对console.log的调用:

axios.get(geocodeUrl).then((response) => {
  console.log(response.data);
}).catch((e) => {
  if (e.code === 'ENOTFOUND') {
    console.log('Unable to connect to API servers.');
 }
});

现在,如果我们保存文件,并从终端重新运行事情,我们应该会得到一个更好的错误消息打印到屏幕上:

这正是我们得到的:“无法连接到 API 服务器”。现在我会把那个点加回去,这样事情就开始运作了。我们可以担心返回的响应。

ZERO_RESULT 状态的错误处理

你记得,在 geocode 文件中,有一些事情我们需要做。我们已经处理了与服务器连接相关的错误,但还有另一个待处理的错误,即,如果body.status属性等于ZERO_RESULTS。在这种情况下,我们想打印一个错误消息。

为此,我们将在app-promise中创建我们自己的错误。我们将在axios.get函数中抛出一个错误。这个错误将导致它之后的所有代码都不会运行。它将直接进入错误处理程序。

现在我们只想在 status 属性设置为ZERO_RESULTS时抛出错误。我们将在get函数的顶部添加一个if语句来检查“if(response.data.status)”是否等于ZERO_RESULTS

axios.get(geocodeUrl).then((response) => {
  if (response.data.status === 'ZERO_RESULTS') {

  }

如果是这种情况,那么事情就变糟了,我们不想继续进行天气请求。我们想运行我们的 catch 代码。为了抛出一个新的错误,让我们的 promise 可以捕获,我们将使用一个称为throw new Error的语法。这将创建并抛出一个错误,让 Node 知道出了问题。我们可以提供我们自己的错误消息,对用户来说是可读的:无法找到该地址

axios.get(geocodeUrl).then((response) => {
  if (response.data.status === 'ZERO_RESULTS') {
    throw new Error('Unable to find that address.');
  }

这是一个消息,将让用户准确地知道出了什么问题。现在当这个错误被抛出时,相同的 catch 代码将运行。目前,我们只有一个if条件,检查代码属性是否为ENOTFOUND。所以我们将添加一个else子句:

axios.get(geocodeUrl).then((response) => {
 if (response.data.status === 'ZERO_RESULTS') {
   throw new Error('Unable to find that address.');
 }

 console.log(response.data);
}).catch((e) => {
 if (e.code === 'ENOTFOUND') {
   console.log('Unable to connect to API servers.');
 } else {

 }
});

else块中,我们可以打印错误消息,这是我们使用e.消息属性在 throw new Error语法中键入的字符串,如下所示:

axios.get(geocodeUrl).then((response) => {
 if (response.data.status === 'ZERO_RESULTS') {
   throw new Error('Unable to find that address.');
 }

 console.log(response.data);
}).catch((e) => {
  if (e.code === 'ENOTFOUND') {
   console.log('Unable to connect to API servers.');
 } else {
   console.log(e.message);
 }
});

如果错误代码不是ENOTFOUND,我们将简单地将消息打印到屏幕上。如果我们得到零结果,就会发生这种情况。所以让我们模拟一下,以确保代码能正常工作。在终端中,我们将重新运行之前的命令,传入一个邮政编码。起初,我们将使用一个有效的邮政编码08822,我们应该得到我们的数据。然后我们将使用一个无效的邮政编码:00000

当我们用有效地址运行请求时,我们得到这个:

当我们用无效的地址运行请求时,我们得到了错误:

通过调用throw new Error,我们立即停止了这个函数的执行。所以console.loge.message永远不会打印,这正是我们想要的。现在我们的错误处理程序已经就位,我们可以开始生成天气 URL 了。

生成天气 URL

为了生成天气 URL,我们将从weather文件中复制 URL,将其带有引号的部分放入app-promise文件中。我们将创建一个名为weatherUrl的新变量,将其设置为复制的 URL:

url: `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`,

现在weatherUrl确实需要一些信息。我们需要纬度经度。我们有两个变量latlng,所以让我们创建它们,从响应对象中获取适当的值,var latvar lng

var lat;
var lng;
url: `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`,

现在,为了取出它们,我们必须经历挖掘对象的过程。我们以前做过。我们将在响应对象的数据属性中查找,这类似于请求库中的 body。然后我们将进入results,获取第一个项目并访问geometry属性,然后我们将访问location.lat

var lat = response.data.results[0].geometry.location.lat;

现在同样,我们可以为longitude变量添加内容:

var lat = response.data.results[0].geometry.location.lat;
var lng = response.data.results[0].geometry.location.lng;

现在,在我们发出天气请求之前,我们要打印格式化的地址,因为之前的应用程序也这样做了。在我们的console.log(response.data)语句中,我们将进入数据对象获取格式化的地址。这也是在结果数组的第一项上。我们将访问formatted_address属性:

var lat = response.data.results[0].geometry.location.lat;
var lng = response.data.results[0].geometry.location.lng;
var weatherUrl = `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`;
console.log(response.data.results[0].formatted_address);

现在我们的格式化地址已经打印到屏幕上,我们可以通过返回一个新的 promise 来进行第二次调用。这将让我们链接这些调用在一起。

链接承诺调用

要开始,我们将返回一个调用axios.get,传入 URL。我们刚刚定义了它,它是weatherUrl

 var lat = response.data.results[0].geometry.location.lat;
 var lng = response.data.results[0].geometry.location.lng;
 var weatherUrl = `https://api.forecast.io/forecast/4a04d1c42fd9d32c97a2c291a32d5e2d/${lat},${lng}`;
 console.log(response.data.results[0].formatted_address);
 return axios.get(weatherUrl);

现在我们有了这个调用返回,我们可以在之前的then调用和 catch 调用之间再添加一个then调用,通过调用 then,传递一个函数,就像这样:

 return axios.get(weatherUrl);
}).then(() => {

}).catch((e) => {
 if (e.code === 'ENOTFOUND') {

当天气数据返回时,将调用此函数。我们将得到相同的响应参数,因为我们使用相同的方法axios.get

}).then((response) => {

then调用中,我们不必担心抛出任何错误,因为我们从未需要访问 body 属性来检查是否出了问题。对于天气请求,如果这个回调运行,那么事情就对了。我们可以打印天气信息。为了完成这个任务,我们将创建两个变量:

  • temperature

  • apparentTemperature

temperature变量将被设置为response.data。然后我们将访问currently属性。然后我们将访问温度。我们将提取出第二个变量,实际温度或apparentTemperature,这是属性名称,var apparentTemperature。我们将把这个设置为response.data.currently.apparentTemperature

}).then((response) => {
 var temperature = response.data.currently.temperature;
 var apparentTemperature = response.data.currently.apparentTemperature;

现在我们已经将两个东西提取到变量中,我们可以将这些东西添加到console.log中。我们选择定义两个变量,这样我们就不必将两个非常长的属性语句添加到console.log中。我们可以简单地引用这些变量。我们将添加console.log,并在console.log语句中使用模板字符串,这样我们可以在引号中插入前面提到的两个值:当前温度,然后是温度。然后我们可以添加一个句号,感觉像,然后是apparentTemperature

}).then((response) => {
 var temperature = response.data.currently.temperature;
 var apparentTemperature = response.data.currently.apparentTemperature;
 console.log(`It's currently ${temperature}. It feels like ${apparentTemperature}.`);

现在我们的字符串已经打印到屏幕上,我们可以测试我们的应用程序是否按预期工作。我们将保存文件,在终端中,我们将重新运行两个命令之前的命令,其中我们有一个有效的邮政编码:

当我们运行这个代码时,我们得到了新泽西州Flemington的天气信息。当前温度是84华氏度,但感觉像90华氏度。如果我们运行的是一个错误的地址,我们会得到错误消息:

所以一切看起来都很棒!使用axios库,我们能够像app-promise一样链式调用 promise,而不需要做任何太疯狂的事情。axios get方法返回一个 promise,所以我们可以直接使用then访问它。

在代码中,我们使用then一次来处理地理位置数据。我们将地址打印到屏幕上。然后我们返回另一个 promise,在其中我们请求天气。在我们的第二个then调用中,我们将天气打印到屏幕上。我们还添加了一个catch调用,用于处理任何错误。如果我们的任何一个 promise 出现问题,或者我们抛出错误,catch将被触发,将消息打印到屏幕上。

这就是使用 axios 设置 HTTP 请求的 promise 所需的全部内容。人们喜欢 promise 而不是传统回调的一个原因是,我们可以简单地链式调用而不是嵌套。所以我们的代码不会缩进到疯狂的水平。正如我们在上一章的app.js中看到的,我们深入了几个缩进级别,只是为了将两个调用组合在一起。如果我们需要添加第三个,情况会变得更糟。有了 promise,我们可以保持一切在同一级别,使我们的代码更容易维护。

摘要

在本章中,我们通过一个快速的例子介绍了 promise 的工作原理,只是介绍了非常基础的内容。异步是 Node.js 的一个关键部分。我们介绍了回调和 promise 的基础知识。我们看了一些例子,创建了一个相当酷的天气应用程序。

这就是我们的异步 Node.js 编程的结束,但这并不意味着你必须停止构建天气应用程序。有一些想法可以让你继续这个项目。首先,你可以加载更多的信息。我们从天气 API 得到的响应除了当前温度之外还包含了大量的其他信息。如果你能在其中加入一些东西,比如高/低温度或降水几率,那就太棒了。

接下来,拥有默认位置的能力将是非常酷的。会有一个命令让我设置一个默认位置,然后我可以在没有位置参数的情况下运行天气应用程序来使用默认位置。我们也可以指定一个位置参数来搜索其他地方的天气。这将是一个很棒的功能,它的工作方式有点类似于 Notes 应用程序,我们可以将数据保存到文件系统中。

在下一章中,我们将开始创建异步的网络服务器。我们将制作异步的 API。此外,我们将创建实时的 Socket.IO 应用程序,这也将是异步的。我们将继续创建 Node 应用程序,将其部署到服务器上,使这些服务器对任何具有网络连接的人都可以访问。

第八章:Node 中的 Web 服务器

在本章中,我们将涵盖大量令人兴奋的内容。我们将学习如何创建 Web 服务器,以及如何将版本控制集成到 Node 应用程序中。现在,为了完成所有这些工作,我们将看一下一个叫做 Express 的框架。它是最受欢迎的 npm 库之一,原因很充分。它使得诸如创建 Web 服务器或 HTTP API 之类的工作变得非常容易。这有点类似于我们在上一章中使用的 Dark Sky API。

现在大多数课程都是从 Express 开始的,这可能会让人困惑,因为它模糊了 Node 和 Express 之间的界限。我们将通过将 Express 添加到全新的 Node 应用程序来开始。

具体来说,我们将涵盖以下主题:

  • 介绍 Express

  • 静态服务器

  • 渲染模板

  • 高级模板

  • 中间件

介绍 Express

在本节中,您将创建自己的第一个 Node.js Web 服务器,这意味着您将有一种全新的方式让用户访问您的应用程序。而不是让他们从终端运行它并传递参数,您可以给他们一个 URL,他们可以访问以查看您的 Web 应用程序,或者一个 URL,他们可以发出 HTTP 请求以获取一些数据。

这将类似于我们在之前的章节中使用地理编码 API 时所做的。不过,我们将能够创建自己的 API,而不是使用 API。我们还将能够为诸如作品集网站之类的静态网站设置一个静态网站。这两者都是非常有效的用例。现在,所有这些都将使用一个叫做Express的库来完成,这是最受欢迎的 npm 库之一。实际上,这是 Node 变得如此受欢迎的原因之一,因为它非常容易制作 REST API 和静态 Web 服务器。

配置 Express

Express 是一个直截了当的库。现在有很多不同的配置方式。所以它可能会变得非常复杂。这就是为什么在接下来的几章中我们将使用它的原因。首先,让我们创建一个目录,我们可以在其中存储这个应用程序的所有代码。这个应用程序将是我们的 Web 服务器。

在桌面上,让我们通过在终端中运行mkdir node-web-server命令来创建一个名为node-web-server的目录:

创建了这个目录后,我们将使用cd进入其中:

我们还将在 Atom 中打开它。在 Atom 中,我们将从桌面打开它:

在继续之前,我们将运行npm init命令,以便生成package.json文件。如下所示,我们将运行npm init

然后,我们将通过在以下截图中显示的所有选项中按enter来使用默认值。目前没有必要自定义任何选项:

然后我们将在最后一个语句Is this ok? (yes)中输入yespackage.json文件就位了:

Express 文档网站

如前所述,Express 是一个非常庞大的库。有一个专门的网站专门用于 Express 文档。您可以访问www.expressjs.com查看网站提供的所有内容,而不是简单的README.md文件:

我们将找到入门、帮助文章等。该网站有一个“指南”选项,可以帮助您执行诸如路由、调试、错误处理和 API 参考之类的操作,因此我们可以准确了解我们可以访问的方法以及它们的作用。这是一个非常方便的网站。

安装 Express

现在我们有了我们的node-web-server目录,我们将安装 Express,这样我们就可以开始制作我们的 Web 服务器。在终端中,我们将首先运行clear命令以清除输出。然后我们将运行npm install命令。模块名称是express,我们将使用最新版本@4.16.0。我们还将提供save标志来更新我们的package.json文件中的依赖项,如下所示:

npm install express@4.16.0 --save

再次,我们将使用clear命令来清除终端输出。

现在我们已经安装了Express,我们可以在 Atom 中创建我们的 Web 服务器。为了运行服务器,我们需要一个文件。我会把这个文件叫做server.js。它将直接放在我们应用程序的根目录中:

这是我们将配置各种路由的地方,像网站的根目录,像/about这样的页面等。这也是我们将启动服务器的地方,将其绑定到我们机器上的端口。现在我们将部署到一个真正的服务器。稍后我们将讨论这是如何工作的。现在,我们大部分的服务器示例将发生在我们的本地主机上。

server.js中,我们要做的第一件事是通过创建一个常量express并将其设置为require('express')来加载 Express:

const express = require('express');

接下来,我们要做的是创建一个新的 Express 应用程序。为此,我们将创建一个名为 app 的变量,并将其设置为从调用express作为函数返回的结果:

const express = require('express');

var app = express();

现在我们不需要传递任何参数到express中。我们将进行大量的配置,但这将以不同的方式进行。

创建一个应用程序

为了创建一个应用程序,我们只需要调用这个方法。在变量app旁边,我们可以开始设置所有我们的 HTTP 路由处理程序。例如,如果有人访问网站的根目录,我们将想要发送一些东西回去。也许是一些 JSON 数据,也许是一个 HTML 页面。

我们可以使用app.get函数注册一个处理程序。这将让我们为 HTTP get 请求设置一个处理程序。我们必须传入app.get的两个参数:

  • 第一个参数将是一个 URL

  • 第二个参数将是要运行的函数;告诉 Express 要发送什么回去给发出请求的人的函数

在我们的情况下,我们正在寻找应用程序的根。所以我们可以只使用斜杠(/)作为第一个参数。在第二个参数中,我们将使用一个简单的箭头函数(=>)如下所示:

const express = require('express');

var app = express();

app.get('/', (req, res) => {

};

现在箭头函数(=>)将被调用两个参数。这对于 Express 的工作方式非常重要:

  • 第一个参数是请求(req),存储了关于进来的请求的大量信息。像使用的标头、任何主体信息,或者用请求到路径的方法。所有这些都存储在请求中。

  • 第二个参数,respond(res),有很多可用的方法,所以我们可以以任何我们喜欢的方式响应 HTTP 请求。我们可以自定义发送回去的数据,还可以设置我们的 HTTP 状态码。

我们将详细探讨这两者。不过现在,我们将使用一个方法,res.send。这将让我们响应请求,发送一些数据回去。在app.get函数中,让我们调用res.send,传入一个字符串。在括号中,我们将添加Hello Express!

app.get('/', (req, res) => {
  res.send('Hello Express!');
});

这是 HTTP 请求的响应。所以当有人查看网站时,他们将看到这个字符串。如果他们从应用程序发出请求,他们将得到Hello Express!作为主体数据。

现在在这一点上,我们还没有完全完成。我们已经设置了其中一个路由,但是应用程序实际上永远不会开始监听。我们需要做的是调用app.listenapp.listen函数将在我们的机器上将应用程序绑定到一个端口。在这种情况下,对于我们的本地主机应用程序,我们将使用端口3000,这是一个非常常见的用于本地开发的端口。在本章的后面,我们将讨论如何根据您用于将应用程序部署到生产环境的服务器来自定义此设置。不过,像3000这样的数字是有效的:

app.get('/', (req, res) => {
  res.send('Hello Express!');
});

app.listen(3000);

有了这个设置,我们现在完成了。我们有了我们的第一个 Express 服务器。我们实际上可以从终端运行它,并在浏览器中查看它。在终端中,我们将使用nodemon server.js来启动我们的应用程序:

nodemon server.js

这将启动应用程序,并且您将看到应用程序从未真正完成,如下所示:

现在它正在挂起。它正在等待请求开始进来。使用app.listen的应用程序永远不会停止。您必须手动使用control + C关闭它们,就像我们以前做过的那样。如果您的代码中有错误,它可能会崩溃。但是它通常不会停止,因为我们在这里设置了绑定。它将监听请求,直到您告诉它停止。

现在服务器已经启动,我们可以进入浏览器,打开一个新标签,访问网站,localhost:后跟端口3000

这将加载网站的根目录,并且我们指定了该路由的处理程序。Hello Express!显示出来,这正是我们所期望的。现在没有花哨。没有格式。我们只是从服务器向发出请求的客户端发送一个字符串。

在浏览器中探索应用程序请求的开发者工具

接下来,我们想打开开发者工具,以便我们可以探索在发出请求时发生了什么。在 Chrome 中,您可以使用设置|更多工具|开发者工具来打开开发者工具:

或者您可以使用与操作系统的开发者工具一起显示的键盘快捷键。

我强烈建议您记住这个键盘快捷键,因为在使用 Node 时,您将大量使用开发者工具

我们现在将打开开发者工具,它应该看起来与我们运行 Node Inspector 调试器时使用的工具类似。它们有点不同,但是思想是一样的:

我们在顶部有一堆标签,然后我们在页面下方有我们特定标签的信息。在我们的情况下,我们想转到网络标签,目前我们什么都没有。因此,我们将在打开标签的情况下刷新页面,我们在这里看到的是我们的本地主机请求:

这是负责在屏幕上显示 Hello Express!的请求。实际上,我们可以单击请求以查看其详细信息:

这个页面一开始可能有点压倒性。有很多信息。在顶部,我们有一些一般信息,例如被请求的 URL,客户端想要的方法;在这种情况下,我们发出了一个 GET 请求,并且返回的状态代码。默认状态代码为 200,表示一切顺利。我们想要指出的是一个响应头。

在响应标头下,我们有一个叫做 Content-Type 的标头。这个标头告诉客户端返回了什么类型的数据。现在这可能是像 HTML 网站、一些文本或一些 JSON 数据,而客户端可能是一个网络浏览器、iPhone、安卓设备或任何其他具有网络功能的计算机。在我们的情况下,我们告诉浏览器返回的是一些 HTML,所以为什么不按照这样的方式进行渲染呢。我们使用了 text/html Content-Type。这是由 Express 自动设置的,这也是它如此受欢迎的原因之一。它为我们处理了很多这样的琐事。

将 HTML 传递给 res.send

现在我们有了一个非常基本的例子,我们想要把事情提升到一个新的水平。在 Atom 中,我们实际上可以通过将我们的Hello Express!消息放在一个h1标签中,直接在 send 中提供一些 HTML。在本节的后面,我们将设置一个包含 HTML 文件的静态网站。我们还将研究模板化以创建动态网页。但现在,我们实际上可以只是将一些 HTML 传递给res.send

app.get('/', (req, res) => {
  res.send('<h1>Hello Express!</h1>');
});

app.listen(3000);

我们保存服务器文件,这应该会重新启动浏览器。当我们刷新浏览器时,我们会看到 Hello Express!打印到屏幕上:

不过这一次,我们把它放在了一个h1标签中,这意味着它是由默认的浏览器样式格式化的。在这种情况下,它看起来很漂亮而且很大。有了这个,我们现在可以在网络选项卡中打开请求,我们得到的是和之前完全一样的东西。我们仍然告诉浏览器它是 HTML。这一次唯一的区别是:我们实际上有一个 HTML 标签,所以它会使用浏览器的默认样式进行渲染。

发送 JSON 数据回去

我们接下来要看的是如何发送一些 JSON 数据回去。使用 Express 发送 JSON 非常容易。为了说明我们如何做到这一点,我们将注释掉当前对res.send的调用,并添加一个新的调用。我们将调用res.send,传入一个对象:

app.get('/', (req, res) => {
  // res.send('<h1>Hello Express!</h1>');
  res.send({

  })
});

在这个对象上,我们可以提供任何我们喜欢的东西。我们可以创建一个name属性,将它设置为任何名字的字符串版本,比如Andrew。我们可以创建一个名为likes的属性,将它设置为一个数组,并且可以指定一些我们可能喜欢的东西。让我们把Biking添加为其中之一,然后再添加Cities作为另一个:

  res.send({
    name: 'Andrew',
    likes: [
      'Biking',
      'Cities'
    ]
  });

当我们调用res.send并传入一个对象时,Express 会注意到。Express 会将其转换为 JSON,并发送回浏览器。当我们保存server.js并且 nodemon 刷新时,我们可以刷新浏览器,我们得到的是我的数据使用 JSON 视图格式化的结果:

这意味着我们可以折叠属性并快速导航 JSON 数据。

现在 JSON 视图之所以能够捕捉到这一点,是因为我们在上一个请求中探索的 Content-Type 标头实际上发生了变化。如果我打开localhost,很多东西看起来都一样。但现在 Content-Type 变成了 application/json Content-Type:

这个 Content-Type 告诉请求者,它是一个安卓手机、一个 iOS 设备,还是浏览器,JSON 数据正在返回,它应该解析为这样。这正是浏览器在这种情况下所做的。

Express 还可以很容易地设置除根路由之外的其他路由。我们可以在 Atom 中调用app.get来探索这一点。我们将调用app.get。我们将创建第二个路由。我们将这个叫做about

app.get('/about')

app.listen(3000);

注意我们只是使用了/about作为路由。保持斜杠的位置很重要,但在那之后你可以输入任何你喜欢的东西。在这种情况下,我们将有一个/about页面供某人访问。然后我会提供处理程序。处理程序将接收reqres对象:

app.get('/about', (req, res) => {

});

app.listen(3000);

这将让我们弄清楚是什么样的请求进来了,以及让我们对该请求做出响应。现在,为了说明我们可以创建更多页面,我们将保持响应简单,res.send。在字符串内部,我们将打印About Page

app.get('/about', (req, res) => {
  res.send('About Page');
});

现在当我们保存server.js文件时,服务器将重新启动。在浏览器中,我们可以访问localhost:3000/about。在/about处,我们现在应该看到我们的新数据,这正是我们得到的,About Page 显示如下:

使用app.get,我们可以指定尽可能多的路由。目前我们只有一个about路由和一个/路由,也被称为根路由。根路由返回一些数据,恰好是 JSON,而 about 路由返回一点 HTML。现在我们已经有了这个设置,并且对于如何在 Express 中设置路由有了一个非常基本的理解,我们希望你创建一个新的路由/bad。这将模拟当请求失败时会发生什么。

JSON 请求中的错误处理

为了显示 JSON 的错误处理请求,我们将调用app.get。这个app.get将让我们为 get HTTP 请求注册另一个处理程序。在我们的情况下,我们正在寻找的路由将在引号内,即/bad。当有人请求这个页面时,我们想要做的将在回调中指定。回调将使用我们的两个参数,reqres。我们将使用一个箭头函数(=>),这是我到目前为止所有处理程序都使用的:

app.get('/bad', (req, res) => {

  });

app.listen(3000);

在箭头函数(=>)内部,我们将通过调用res.send发送一些 JSON。但我们不是传递一个字符串,或一些字符串 HTML,而是传递一个对象:

app.get('/bad', (req, res) => {
  res.send({

  });
});

现在我们已经有了我们的对象,我们可以指定要发送回去的属性。在这种情况下,我们将设置一个errorMessage。我们将把我的错误消息属性设置为一个字符串,无法处理请求

app.get('/bad', (req, res) => {
  res.send({
    errorMessage: 'Unable to handle request'
  });
});

接下来我们将保存文件,在 nodemon 中重新启动它,并在浏览器中访问它。确保我们的错误消息正确显示。在浏览器中,我们将访问/bad,按下enter,我们会得到以下内容:

我们的 JSON 数据显示出来了。我们有错误消息,还有消息显示出来:无法处理请求。现在,如果你正在使用 JSON 视图,并且想查看原始的 JSON 数据,你实际上可以点击“查看源代码”,它会在新标签页中显示出来。在这里,我们正在查看原始的 JSON 数据,所有内容都用双引号括起来。

我将坚持使用 JSON 视图数据,因为它更容易导航和查看。我们现在有一个非常基本的 Express 应用程序正在运行。它在端口3000上监听,并且目前有 3 个 URL 的处理程序:当我们获取页面的根目录时,当我们获取/about时,以及当我们对/bad发出 get 请求时。

静态服务器

在这一部分,我们将学习如何设置一个静态目录。因此,如果我们有一个包含 HTML、CSS、JavaScript 和图像的网站,我们可以提供这些内容,而不需要为每个文件提供自定义路由,这将是一个真正的负担。现在设置这个非常简单。但在我们对server.js进行任何更新之前,我们需要在项目中创建一些静态资产,这样我们才能提供服务。

制作一个 HTML 页面

在这种情况下,我们将制作一个 HTML 页面,我们将能够在浏览器中查看。在我们开始之前,我们需要创建一个新的目录,这个目录中的所有内容都可以通过网络服务器访问,所以重要的是不要在这里放任何你不希望别人看到的东西。

目录中的所有内容都应该是任何人都可以查看的。我们将创建一个公共文件夹来存储所有静态资产,在这里我们将创建一个 HTML 页面。我们将通过创建一个名为help.html的文件为我们的示例项目创建一个帮助页面:

现在在help.html中,我们将创建一个快速的基本 HTML 文件,尽管我们不会涉及 HTML 的所有微妙之处,因为这不是一本真正的 HTML 书。相反,我们将只设置一个基本页面。

我们需要做的第一件事是创建一个DOCTYPE,让浏览器知道我们正在使用的 HTML 版本。看起来会像这样:

<!DOCTYPE html>

在开标签和感叹号之后,我们会输入大写的DOCTYPE。然后,我们提供 HTML5 的实际DOCTYPE,最新版本。然后我们可以使用大于号来关闭事物。在下一行,我们将打开我们的html标签,以便定义整个 HTML 文件:

<!DOCTYPE html>
<html>
</html>

html内部,有两个标签我们将使用:head标签让我们配置我们的文档,和body标签包含我们想要呈现在屏幕上的所有内容。

head 标签

我们将首先创建head标签:

<!DOCTYPE html>
<html>
  <head>

  </head>
</html>

head中,我们将提供两个信息,charsettitle标签:

  • 首先,我们必须设置charset,让浏览器知道如何呈现我们的字符。

  • 接下来我们将提供title标签。title标签让浏览器知道在标题栏中呈现什么内容,通常是新标签。

如下面的代码片段所示,我们将设置meta。在meta上,我们将使用等号设置charset属性,并提供值utf-8

  <head>
    <meta charset="utf-8">
  </head>

对于title标签,我们可以将其设置为任何我们喜欢的内容;Help Page似乎很合适:

  <head>
    <meta charset="utf-8">
    <title>Help Page</title>
  </head>

body 标签

现在我们的head已经配置好,我们可以在网站的正文中添加一些内容。这些内容实际上将在视口内可见。在head旁边,我们将打开和关闭body标签:

  <body>

  </body>

body中,我们将再次提供两个内容:一个h1标题和一个p段落标签。

标题将与我们在head中使用的title标签匹配,Help Page,段落将只有一些填充文本——这里有一些文本

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Help Page</title>
  </head>
  <body>
    <h1>Help Page</h1>
    <p>Some text here</p>
  </body>
</html>

现在我们有一个 HTML 页面,目标是能够在 Express 应用程序中提供此页面,而无需手动配置。

在 Express 应用程序中提供 HTML 页面

我们将使用 Express 中间件来在 Express 应用程序中提供我们的 HTML 页面。中间件让我们配置我们的 Express 应用程序的工作方式,并且在整本书中我们将广泛使用它。现在,我们可以将其视为第三方附加组件。

为了添加一些中间件,我们将调用app.useapp.use接受我们想要使用的中间件函数。在我们的情况下,我们将使用内置的中间件。因此,在server.js中,在app变量语句旁边,我们将提供express对象的函数:

const express = require('express');

var app = express();

app.use();

在下一章中,我们将制作自己的中间件,所以很快就会清楚究竟传递了什么。现在,我们将传递express.static并将其作为一个函数调用:

var app = express();

app.use(express.static());

现在express.static需要获取要提供的文件夹的绝对路径。如果我们想要提供/help,我们需要提供public文件夹的路径。这意味着我们需要指定从硬盘根目录开始的路径,这可能会很棘手,因为您的项目会移动。幸运的是,我们有__dirname变量:

app.use(express.static(__dirname));

这是由我们探索的包装函数传递给我们文件的变量。__dirname变量存储着您项目的目录路径。在这种情况下,它存储着node-web-server的路径。我们只需连接/public,告诉它使用这个目录作为我们的服务器。我们将使用加号和字符串/public进行连接:

app.use(express.static(__dirname + '/public'));

有了这个设置,我们现在已经完成了。我们的服务器已经设置好,没有其他事情要做。现在我们应该能够重新启动我们的服务器并访问/help.html。我们现在应该能够看到我们的 HTML 页面。在终端中,我们现在可以使用nodemon server.js来启动应用程序:

一旦应用程序运行起来,我们就可以在浏览器中访问它。我们将首先转到localhost:3000

在这里,我们得到了我们的 JSON 数据,这正是我们所期望的。如果我们将该 URL 更改为/help.html,我们应该会看到我们的帮助页面渲染:

这正是我们得到的,我们的帮助页面显示在屏幕上。我们将帮助页面标题设置为标题,然后是一些文本段落作为正文。能够轻松设置静态目录已经使 Node 成为简单项目的首选,这些项目实际上并不需要后端。如果您想创建一个仅用于提供目录的 Node 应用程序,您可以在server.js文件中用大约四行代码完成:前三行和最后一行。

app.listen的调用

现在我们要讨论的另一件事是对app.listen(3000)的调用。app.listen确实需要第二个参数。这是可选的。这是一个函数。这将让我们在服务器启动后执行某些操作,因为启动可能需要一点时间。在我们的情况下,我们将为console.log分配一条消息:服务器已在 3000 端口上启动

app.listen(3000, () => {
  console.log('Server is up on port 3000');
});

现在对于启动应用程序的人来说,服务器实际上已经准备就绪,因为消息将打印到屏幕上。如果我们保存server.js,并返回到终端,我们可以看到服务器已在 3000 端口上启动打印出来:

回到浏览器,我们可以刷新,得到完全相同的结果:

这就是本节的全部内容。我们现在有一个静态目录,可以在其中包含 JavaScript、CSS、图像或任何其他文件类型。

渲染模板

在最后几节中,我们看了多种使用 Express 渲染 HTML 的方法。我们将一些 HTML 传递给response.send,但显然这并不理想。在字符串中编写标记是真正痛苦的。我们还创建了一个公共目录,可以在其中放置我们的静态 HTML 文件,例如我们的help文件,并将其提供给浏览器。这两种方法都很好,但还有第三种解决方案,这将是本节的主题。解决方案是模板引擎。

模板引擎将允许您以动态方式呈现 HTML,我们可以在模板中注入值,例如用户名或当前日期,就像我们在 Ruby 或 PHP 中所做的那样。使用这个模板引擎,我们还将能够为诸如页眉或页脚之类的可重用标记创建可重用的标记,这将在您的许多页面上都是相同的。这个模板引擎,handlebars,将是本节和下一节的主题,所以让我们开始吧。

安装 hbs 模块

我们要做的第一件事是安装hbs模块。这是 Express 的 handlebars 视图引擎。现在有很多其他 Express 视图引擎,例如 EJS 或 Pug。我们将选择 handlebars,因为它的语法很棒。这是一个很好的开始方式。

现在我们将在浏览器中看到一些内容。首先,我们将访问handlebarsjs.com。这是 handlebars 的文档。它向您展示了如何使用其所有功能,因此如果我们想使用任何内容,我们总是可以在这里学习如何使用它。

现在我们将安装一个包装在 handlebars 周围的模块。它将让我们将其用作 Express 视图引擎。要查看此内容,我们将转到npmjs.com/package/hbs

这是所有软件包的 URL 结构。因此,如果您想找到软件包页面,只需键入npmjs.com/package/软件包名称

这个模块非常受欢迎。这是一个非常好的视图引擎。他们有很多文档。我只是想让你知道这也存在。现在我们可以安装并将其集成到我们的应用程序中。在终端中,我们将使用npm install安装hbs,模块名称是hbs,最新版本是@4.0.1。我将使用save标志来更新package.json

现在实际上配置 Express 使用这个 handlebars 视图引擎非常简单。我们所要做的就是导入它并在我们的 Express 配置中添加一个语句。我们将在 Atom 中做到这一点。

配置 handlebars

在 Atom 中,让我们开始加载 handlebars const hbs = require hbs,如所示,从这里我们可以添加一行:

const express = require('express');
const hbs = require('hbs');

接下来,让我们调用app.set,在那里我们为 Express 静态调用app.use

app.set
app.use(express.static(__dirname + '/public'));

这让我们设置一些与 Express 相关的配置。有很多内置的配置。我们稍后会谈论更多。现在,我们要做的是传入一个键值对,其中键是你想要设置的东西,值是你想要使用的值。在这种情况下,我们设置的键是view engine。这将告诉 Express 我们想要使用的视图引擎,并且我们将在引号内传入hbs

app.set('view engine', 'hbs');
app.use(express.static(__dirname + '/public'));

这就是我们需要做的一切。

我们的第一个模板

现在,为了创建我们的第一个模板,我们想要做的是在项目中创建一个名为views的目录。views是 Express 用于模板的默认目录。所以我们将添加views目录,然后在其中添加一个模板。我们将为我们的关于页面创建一个模板。

在 views 中,我们将添加一个新文件,文件名将是about.hbshbs handlebars 扩展名很重要。确保包含它。

现在 Atom 已经知道如何解析hbs文件。在about.hbs文件的底部,显示当前语言的地方,使用括号内的 HTML mustache。

Mustache 被用作这种类型的 handlebars 语法的名称,因为当你输入大括号({)时,它们看起来有点像胡须。

我们要做的是开始使用help.html的内容并直接复制它。让我们复制这个文件,这样我们就不必重写那个样板,然后我们将它粘贴到about.hbs中:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Help Page</title>
  </head>
  <body>
    <h1>Help Page</h1>
    <p>Some text here</p>
  </body>
</html>

现在我们可以尝试渲染这个页面。我们将把h1标签从帮助页面改为关于页面:

  <body>
    <h1>About Page</h1>
    <p>Some text here</p>
  </body>

我们将在稍后讨论如何在此页面内动态渲染内容。在那之前,我们只想让它渲染。

获取静态页面进行渲染

server.js中,我们已经有了一个/about的根目录,这意味着我们可以渲染我们的 hbs 模板,而不是发送回这个关于页面字符串。我们将删除我们对res.send的调用,并将其替换为res.render

app.get('/about', (req, res) => {
  res.render
});

Render 将让我们使用我们当前视图引擎设置的任何模板进行渲染about.hbs文件。我们确实有关于模板,我们可以将该名称about.hbs作为第一个且唯一的参数传递。我们将渲染about.hbs

app.get('/about', (req, res) => {
  res.render('about.hbs');
});

这就足以让静态页面渲染。我们将保存server.js,在终端中清除输出,然后使用nodemon server.js运行我们的服务器:

一旦服务器运行起来,它就会显示在端口3000上。我们可以打开/about URL 并查看我们得到了什么。我们将进入 Chrome 并打开localhost:3000 /about,当我们这样做时,我们得到以下结果:

我们得到了我的关于页面的渲染,就像我们期望的那样。我们有一个h1标签,显示得很大,我们有一个段落标签,显示如下。到目前为止,我们已经使用了 hbs,但实际上我们还没有使用它的任何功能。现在,我们正在渲染一个动态页面,所以我们可能根本不需要它。我想要做的是讨论一下我们如何在模板中注入数据。

在模板中注入数据

让我们想一些我们想要在 handlebars 文件中使动态的东西。首先,我们将使这个h1标签动态,以便页面名称传递到about.hbs页面中,我们还将添加一个页脚。现在,我们只需将其设置为一个简单的footer标签:

    <footer>

    </footer>
  </body>
</html>

footer内,我们将添加一个段落,这个段落将包含我们网站的版权。我们只是说版权,然后是年份,2018 年:

    <footer>
      <p>Copyright 2018</p>
    </footer>

现在年份也应该是动态的,这样当年份变化时,我们不必手动更新我们的标记。我们将看看如何使 2018 年和关于页面都是动态的,这意味着它们被传递而不是在 handlebars 文件中输入。

为了做到这一点,我们需要做两件事:

  • 我们将不得不将一些数据传递到模板中。这将是一个对象,一组键值对,

  • 我们将不得不学习如何在 handlebars 文件中提取一些键值对

传递数据非常简单。我们所要做的就是在server.js中的res.render指定第二个参数。这将接受一个对象,在这个对象上,我们可以指定任何我们喜欢的东西。我们可能有一个pageTitle,它被设置为About Page

app.get('/about', (req, res) => {
  res.render('about.hbs', {
    pageTitle: 'About Page'
  });
});

我们有一个数据片段被注入到模板中。虽然还没有被使用,但确实被注入了。我们也可以添加另一个,比如currentYear。我们将把currentYear放在pageTitle旁边,并将currentYear设置为 JavaScript 构造函数的实际年份。这将看起来像这样:

app.get('/about', (req, res) => {
  res.render('about.hbs', {
    pageTitle: 'About Page',
    currentYear: new Date().getFullYear()
  });
});

我们将创建一个新的日期,它将创建一个日期对象的新实例。然后,我们将使用一个叫做getFullYear的方法,它返回年份。在这种情况下,它将返回2018,就像这样.getFullYear。现在我们有了pageTitlecurrentYear。这两者都被传递进来了,我们可以使用它们。

为了使用这些数据,我们在模板内部要使用 handlebars 语法,看起来有点像下面的代码。我们首先在h1标签中打开两个大括号,然后关闭两个大括号。在大括号内,我们可以引用我们传入的任何 props。在这种情况下,让我们使用pageTitle,在我们的版权段落内,我们将使用双大括号内的currentYear

  <body>
    <h1>{{pageTitle}}</h1>
    <p>Some text here</p>

    <footer>
      <p>Copyright 2018</p>
    </footer>
  </body>
</html>

有了这个,我们现在有两个动态数据片段被注入到我们的应用程序中。现在 nodemon 应该在后台重新启动了,所以没有必要手动做任何事情。当我们刷新页面时,我们仍然会得到 About Page,这很好:

这来自我们在server.js中定义的数据,我们得到了版权 2018 年。嗯,这个网页非常简单,看起来并不那么有趣。至少你知道如何创建那些服务器并将数据注入到你的网页中。从这里开始,你只需要添加一些自定义样式,让事情看起来不错。

在继续之前,让我们进入 about 文件并替换标题。目前,它说Help Page。这是从公共文件夹中留下的。让我们把它改成Some Website

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Some Website</title>
  </head>
  <body>
    <h1>{{pageTitle}}</h1>
    <p>Some text here</p>

    <footer>
      <p>Copyright 2018</p>
    </footer>
  </body>
</html>

现在我们已经有了这个位置。接下来,我们将创建一个全新的模板,当有人访问我们网站的根目录/时,这个模板将被渲染。现在,我们当前渲染一些 JSON 数据:

app.get('/', (req, res) => {
  // res.send('<h1>Hello Express!</h1>');
  res.send({
    name: 'Andrew',
    likes: [
      'Biking',
      'Cities'
    ]
  });

我们想要的是用response.render来替换这个,渲染一个全新的视图。

渲染网站根目录的模板

要开始,我们将复制about.hbs文件,这样我们就可以开始根据我们的需求定制它。我们将复制它,并将其命名为home.hbs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Some Website</title>
  </head>
  <body>
    <h1>{{pageTitle}}</h1>
    <p>Some text here</p>

    <footer>
      <p>Copyright 2018</p>
    </footer>
  </body>
</html>

从这里开始,大部分事情都将保持不变。我们将保持pageTitle不变。我们还将保持Copyrightfooter不变。但我们想要改变的是这个段落。About Page作为静态的是可以的,但对于home页面,我们将把它设置为,双大括号内的welcomeMessage属性:

  <body>
    <h1>{{pageTitle}}</h1>
    <p>{{welcomeMessage}}</p>

    <footer>
      <p>Copyright {{currentYear}}</p>
    </footer>
  </body>

现在welcomeMessage只能在home.hbs上使用,这就是为什么我们在home.hbs中指定它而不在about.hbs中指定它。

接下来,我们需要在回调函数中调用 response render。这将让我们实际渲染页面。我们将添加response.render,传入我们要渲染的模板名称。这个叫做home.hbs。然后我们将传入我们的数据:

app.get('/', (req, res) => {
  res.render('home.hbs', {

  })
});

现在开始,我们可以传入页面标题。我们将把这个设置为主页,然后我们将传入一些通用的欢迎消息 - 欢迎来到我的网站。然后我们将传入currentYear,我们已经知道如何获取currentYear: new Date(),并且在日期对象上,我们将调用getFullYear方法:

 res.render('home.hbs', {
    pageTitle: 'Home Page',
    welcomeMessage: 'Welcome to my website',
    currentYear: new Date().getFullYear()
  })

有了这个设置,我们所需要做的就是保存文件,这将自动使用 nodemon 重新启动服务器并刷新浏览器。当我们这样做时,我们会得到以下结果:

我们得到我们的主页标题,我们的欢迎来到我的网站的消息,以及我的 2018 年版权。如果我们去到/about,一切看起来仍然很棒。我们有我们的动态页面标题和版权,以及我们的静态some text here文本:

有了这个设置,我们现在已经完成了 handlebars 的基础知识。我们看到了这在现实世界的 web 应用中是如何有用的。除了像版权这样的现实例子,您可能使用它的其他原因是为了注入某种动态用户数据 - 诸如用户名和电子邮件或其他任何东西。

现在我们已经基本了解了如何使用 handlebars 创建静态页面,我们将在下一部分中看一些 hbs 的更高级功能。

高级模板

在这一部分,我们将学习一些更高级的功能,这些功能可以更容易地渲染我们的标记,特别是在多个地方使用的标记,它将更容易地将动态数据注入到您的网页中。

为了说明我们将要谈论的第一件事,我想打开about.hbshome.hbs,你会注意到底部它们都有完全相同的页脚代码如下:

<footer>
  <p>Copyright {{currentYear}}</p>
</footer>

我们为两者都有一个小版权消息,它们都有相同的头部区域,即h1标签。

现在这并不是问题,因为我们有两个页面,但随着您添加更多页面,更新页眉和页脚将变得非常麻烦。您将不得不进入每个文件并在那里管理代码,但我们将讨论的是另一种叫做 partial 的东西。

添加 partials

Partial 是您网站的部分片段。这是您可以在模板中重复使用的东西。例如,我们可能有一个页脚 partial 来渲染页脚代码。您可以在任何需要页脚的页面上包含该 partial。您也可以对页眉做同样的事情。为了开始,我们需要做的第一件事是稍微调整我们的server.js文件,让 handlebars 知道我们想要添加对 partials 的支持。

为了做到这一点,我们将在server.js文件中添加一行代码,这是我们之前声明视图引擎的地方,它看起来会像这样(hbs.registerPartials):

hbs.registerPartials
app.set('view engine', 'hbs');
app.use(express.static(__dirname + '/public'));

现在registerPartials将使用您想要用于所有 handlebar 部分文件的目录,并且我们将指定该目录作为第一个和唯一的参数。再次强调,这确实需要是绝对目录,所以我将使用__dirname变量:

hbs.registerPartials(__dirname)

然后我们可以连接路径的其余部分,即/views。在这种情况下,我希望您使用/partials

hbs.registerPartials(__dirname + '/views/partials')

我们将把我们的partial文件直接放在views文件夹中的一个目录中。现在我们可以在 views 中创建一个名为partials的文件夹。

partials中,我们可以放置任何我们喜欢的 handlebars 部分。为了说明它们是如何工作的,我们将创建一个名为footer.hbs的文件:

footer.hbs中,我们将可以访问相同的 handlebars 功能,这意味着我们可以编写一些标记,我们可以注入变量,我们可以做任何我们喜欢的事情。现在,我们将做的是粘贴footer标签,粘贴到footer.hbs中:

<footer>
  <p>Copyright {{getCurrentYear}}</p>
</footer>

现在我们有了我们的footer.hbs文件,这就是部分,我们可以在about.hbshome.hbs中包含它。为了做到这一点,我们将删除部分中已有的代码,并用两个大括号打开和关闭它。现在,我们不再想要注入数据,而是想要注入一个模板,其语法是添加一个大于符号和一个空格,然后是部分名称。在我们的情况下,该部分称为footer,所以我们可以在这里添加它:

    {{> footer}}
  </body>
</html>

然后我可以保存about并在home.hbs中做同样的事情。我们现在有了我们的页脚部分。它在两个页面上都渲染出来了。

部分的工作

为了说明这是如何工作的,我将启动我的服务器,默认情况下是nodemon;它不会监视你的 handlebars 文件。所以如果你做出了更改,网站不会像你期望的那样渲染。我们可以通过运行nodemon,传入server.js并提供-e标志来解决这个问题。这让我们可以指定我们想要监视的所有扩展名。在我们的情况下,我们将监视服务器文件的 JS 扩展名,逗号后是hds扩展名:

现在我们的应用程序已经启动,我们可以在浏览器中刷新一下,它们应该看起来一样。我们有关于页面和页脚:

我们的主页上有完全相同的页脚:

现在的优势是,如果我们想要更改页脚,我们只需在footer.hbs文件中进行更改。

我们可以在我们的footer段落标签中添加一些内容。让我们添加一个由Andrew Mead创建的小消息,带有一个-

<footer>
 <p>Created By Andrew Mead - Copyright {{CurrentYear}}</p>
</footer>

现在,保存文件,当我们刷新浏览器时,我们有了全新的主页页脚:

我们有了关于页面的全新页脚:

它将显示在主页和关于页面上。在这两个页面中都不需要手动做任何事情,这就是部分的真正力量。你有一些代码,想要在网站内重用它,所以你只需创建一个部分,然后在你喜欢的地方注入它。

头部部分

现在我们已经有了页脚部分,让我们创建头部部分。这意味着我们需要创建一个全新的文件header.hbs。我们将想要在该文件中添加h1标签,然后在about.hbshome.hbs中渲染部分。两个页面应该看起来一样。

我们将从头部文件夹中创建一个名为header.hbs的新文件。

header.hbs中,我们将从我们的网站中取出h1标签,粘贴到里面并保存:

<h1>{{pageTitle}}</h1>

现在我们可以在abouthome文件中使用这个头部部分。在about中,我们需要使用双大括号和大于符号的语法,然后是部分名称header。我们将在home页面上做完全相同的事情。在home页面上,我们将删除我们的h1标签,注入header并保存文件:

现在我们将创建一些略有不同的东西,以便我们可以测试它是否真的在使用部分。我们将在header.hbs中的h1标签后面输入123

<h1>{{pageTitle}}</h1>123

现在所有文件都已保存,我们应该可以刷新浏览器,看到打印的about页面上有 123,这太棒了:

这意味着header部分确实起作用,如果我回到home页面,一切看起来仍然很棒:

现在我们已经将标题拆分为自己的文件,我们可以做很多事情。我们可以将我们的h1标签放入header 标签中,这是在 HTML 中声明标题的适当方式。如图所示,我们添加了一个打开和关闭的header标签。我们可以取出h1,然后将其放在里面:

<header>
 <h1>{{pageTitle}}</h1>
</header>

我们还可以向我们网站的其他页面添加一些链接。我们可以通过添加a标签为主页添加一个锚标签:

<header>
 <h1>{{pageTitle}}</h1>
 <p><a></a></p>
</header>

a标签内,我们将指定我们想要显示的链接文本。我会选择“主页”,然后在href属性内部,我们可以指定链接应该带您去的路径,即/

<header>
 <h1>{{pageTitle}}</h1>
 <p><a href="/">Home</a></p>
</header>

然后我们可以使用相同的段落标签,复制它并粘贴到下一行,并为about页面创建一个链接。我会将页面文本更改为“关于”,链接文本和 URL,而不是转到/,将转到/about

<header>
 <h1>{{pageTitle}}</h1>
 <p><a href="/">Home</a></p>
 <p><a href="/about">About</a></p>
</header>

现在我们已经对我们的header文件进行了更改,并且它将在我们网站的所有页面上都可用。我在home页面。如果我刷新它,我会得到主页和关于页面的链接:

我可以点击“关于”去关于页面:

同样,我可以点击主页直接返回。现在我们网站内部的所有这些都更容易管理。

Handlebars 助手

现在在我们继续之前,我想谈谈另一件事,那就是 handlebars 助手。 Handlebars 助手将是我们注册函数以动态创建一些输出的方式。例如,在server.js中,我们当前在我们的app.get模板中注入当前年份,这实际上并不是必要的。

有一种更好的方法来传递这些数据,并且不需要提供这些数据,因为我们将始终使用完全相同的函数。我们将始终获取新日期getfullYear返回值并将其传递。相反,我们将使用部分,并且我们将立即设置我们的部分。现在,部分只不过是您可以从 handlebars 模板内部运行的函数。

我们需要做的就是注册它,我将在server.js中执行此操作,从我们设置 Express 中间件的位置继续。如下所示,我们将调用hbs.register,并且我们将注册一个助手,因此我们将调用registerHelper

hbs.registerPartials(__dirname + '/views/partials')
app.set('view engine', 'hbs');
app.use(express.static(__dirname + '/public'));

hbs.registerHelper();

现在registerHelper接受两个参数:

  • 助手的名称作为第一个参数

  • 作为第二个参数运行的函数。

这里的第一个参数将是我们的getCurrentYear。我们将创建一个助手,返回当前年份:

hbs.registerHelper('getCurrentYear',);

第二个参数将是我们的函数。我将使用箭头函数(=>):

hbs.registerHelper('getCurrentYear', () => {

});

我们从此函数返回的任何内容都将在getCurrentYear调用的位置呈现。这意味着,如果我们在footer内部调用getCurrentYear,它将从函数返回年份,并且该数据将被呈现。

server.js中,我们可以通过使用return并且具有与我们app.get对象完全相同的代码来返回年份:

hbs.registerHelper('getCurrentYear'), () => {
 return new Date().getFullYear()
});

我们将创建一个新日期,并调用其getFullYear方法。现在我们有了一个助手,我们可以从我们的每一个渲染调用中删除这些数据:

hbs.registerHelper('getCurrentYear, () => {
 return new Date().getFullYear()
});

app.get('/', (req, res) => {
 res.render('home.hbs', {
   pageTitle: 'Home Page',
   welcomeMessage: 'Welcome to my website'
 });
});

app.get('/about', (req, res) => {
 res.render('about.hbs', {
   pageTitle: 'About Page'
 });
});

这将非常棒,因为实际上没有必要为每个页面计算它,因为它总是相同的。现在我们已经从渲染的各个调用中删除了这些数据,我们将不得不在footer.hbs文件中使用getCurrentYear

<footer>
 <p>Created By Andrew Mead - Copyright {{getCurrentYear}}</p>
</footer>

而不是引用当前年份,我们将使用助手getCurrentYear,并且不需要任何特殊语法。当您在花括号内使用某些东西时,显然不是部分,handlebars 首先会查找具有该名称的助手。如果没有助手,它将查找具有getCurrentYear名称的数据片段。

在这种情况下,它将找到辅助程序,因此一切都将按预期工作。现在我们可以保存footer.hbs,返回浏览器,然后刷新。当我刷新页面时,我们仍然在主页上得到版权 2018:

如果我去关于页面,一切看起来都很好:

我们可以通过简单地返回其他内容来证明数据是从我们的辅助程序返回的。让我们在server.js中注释掉我们的辅助程序代码,并在注释之前,我们可以使用return test,就像这样:

hbs.registerHelper('getCurrentYear', () => {
 return 'test';//return new Date().getFullYear()
});

现在我们可以保存server.js,刷新浏览器,然后我们会看到测试显示如下:

因此,在版权词之后呈现的数据确实来自该辅助程序。现在我们可以删除代码,以便返回正确的年份。

辅助程序中的参数

辅助程序还可以接受参数,这真的很有用。让我们创建一个将成为大写辅助程序的第二个辅助程序。我们将称之为screamIt辅助程序,它的工作是获取一些文本,并以大写形式返回该文本。

为了做到这一点,我们将再次调用hbs.registerHelper。这个辅助程序将被称为screamIt,它将接受一个函数,因为我们确实需要运行一些代码才能做任何有用的事情:

hbs.registerHelper('getCurrentYear', () => {
  return new Date().getFullYear()
});

hbs.registerHelper('screamIt', () => {

});

现在screamIt将接受要大声喊出的text,它将只是调用该字符串的toUpperCase方法。我们将返回text.toUpperCase,就像这样:

hbs.registerHelper('screamIt', (text) => {
  return text.toUpperCase();
});

现在我们可以在我们的文件中实际使用screamIt。让我们进入home.hbs。在这里,我们在p标签中有我们的欢迎消息。我们将删除它,然后大声喊出欢迎消息。为了将数据传递给我们的辅助程序之一,我们首先必须按名称引用辅助程序screamIt,然后在空格后,我们可以指定要作为参数传递的任何数据。

在这种情况下,我们将传递欢迎消息,但我们也可以通过键入一个空格并传递一些其他我们无法访问的变量来传递两个参数:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Some Website</title>
  </head>
  <body>
    {{> header}}

    <p>{{screamIt welcomeMessage}}</p>

    {{> footer}}
  </body>
</html>

目前,我们将像这样使用它,这意味着我们将调用screamIt辅助程序,传入一个参数welcomeMessage。现在我们可以保存home.hbs,返回浏览器,转到主页,如下所示,我们得到了 WELCOME TO MY WEBSITE 的全大写:

使用 handlebars 辅助程序,我们可以创建既不带参数的函数,也带参数的函数。因此,当您需要在网页内部对数据执行某些操作时,可以使用 JavaScript。现在我们已经做到了。

Express 中间件

在本节中,您将学习如何使用 Express 中间件。Express 中间件是一个很棒的工具。它允许您添加到 Express 现有功能中。因此,如果 Express 没有做您想要做的事情,您可以添加一些中间件并教它如何做这件事。现在我们已经使用了一点中间件。在server.js文件中,我们使用了一些中间件,并教 Express 如何从static目录中读取,如下所示:

app.use(express.static(__dirname + '/public'));

我们调用了app.use,这是您注册中间件的方式,然后我们提供了要使用的中间件函数。

现在中间件可以做任何事情。您可以执行一些代码,例如将某些内容记录到屏幕上。您可以对请求或响应对象进行更改。在下一章中,当我们添加 API 身份验证时,我们将这样做。我们将确保发送正确的标头。该标头将期望具有 API 令牌。我们可以使用中间件来确定某人是否已登录。基本上,它将确定他们是否应该能够访问特定路由,我们还可以使用中间件来响应请求。我们可以像在任何其他地方一样,使用response.renderresponse.send从中间件发送一些内容回来。

探索中间件

为了探索中间件,我们将创建一些基本的中间件。在我们调用app.use注册我们的 Express 静态中间件之后,我们将再次调用app.use

app.use(express.static(__dirname + '/public'));

app.use();

现在app.use是用来注册中间件的方法,它接受一个函数。因此,我们将传递一个箭头函数(=>):

app.use(() =>  {

});

use函数只接受一个函数。不需要添加任何其他参数。将使用此函数调用请求(req)对象,响应(res)对象和第三个参数next

app.use((req, res, next) =>  {

});

现在请求和响应对象,现在应该看起来很熟悉。这些正是我们注册处理程序时得到的完全相同的参数。next参数是让事情变得有点棘手的地方。next参数存在是为了告诉 Express 何时完成您的中间件函数,这很有用,因为您可以将尽可能多的中间件注册到单个 Express 应用程序中。例如,我有一些中间件用于提供目录。我们将编写一些日志,将一些请求数据记录到屏幕上,我们还可以编写第三个部分,用于帮助应用程序性能,跟踪响应时间,所有这些都是可能的。

现在在app.use函数内部,我们可以做任何我们喜欢的事情。我们可以将一些东西记录到屏幕上。我们可能会进行数据库请求,以确保用户已经通过身份验证。所有这些都是完全有效的,我们使用next参数告诉 Express 我们何时完成。因此,如果我们执行一些异步操作,中间件将不会继续。只有当我们调用next时,应用程序才会继续运行,就像这样:

app.use((req, res, next) =>  {
  next();
});

这意味着如果您的中间件不调用next,则每个请求的处理程序都不会触发。我们可以证明这一点。让我们调用app.use,传入一个空函数:

app.use((req, res, next) =>  {

});

让我们保存文件,在终端中,我们将使用server.js运行我们的应用程序,使用nodemon

nodemon server.js

我将进入浏览器,然后请求主页。我将刷新页面,您可以看到顶部正在尝试加载,但永远不会完成:

现在问题不是它无法连接到服务器。它可以很好地连接到服务器。真正的问题是在我们的应用程序内部,我们有一些不调用next的中间件。为了解决这个问题,我们只需这样调用next

app.use((req, res, next) => {
  next();
});

现在当浏览器内部刷新时,我们得到了我们期望的主页:

唯一的区别是现在我们有一个地方可以添加一些功能。

创建记录器

app.use内部,我们将开始创建一个记录器,记录所有发送到服务器的请求。我们将存储一个时间戳,以便我们可以看到某人何时请求特定 URL。

在中间件内部开始,让我们获取当前时间。我将创建一个名为now的变量,将其设置为newDate,创建我们的日期对象的一个新实例,并调用toString方法:

app.use((req, res, next) => {
 var now = new Date().toString();
 next();
});

toString方法创建一个格式良好的日期,一个可读的时间戳。现在我们有了我们的now变量,我们可以通过调用console.log来开始创建实际的记录器。

让我们调用console.log,传入我喜欢的任何内容。让我们在反引号内传入now变量,并在后面加上一个冒号:

app.use((req, res, next) => {
  var now = new Date().toString();

  console.log(`${now};`)
  next();
});

现在如果我保存我的文件,因为nodemon正在运行,终端中的东西将重新启动。当我们再次请求网站并进入终端时,我们应该看到日志:

目前只是一个时间戳,但我们正在正确的轨道上。现在一切都正常,因为我们调用了next,所以在这个console.log调用打印到屏幕后,我们的应用程序会继续并提供页面。

在中间件中,我们可以通过探索请求对象添加更多功能。在请求对象上,我们可以访问有关请求的一切内容-HTTP 方法、路径、查询参数以及来自客户端的任何内容。无论客户端是应用程序、浏览器还是 iPhone,所有这些都将在请求对象中可用。现在我们要提取的是 HTTP 方法和路径。

如果您想查看您可以访问的所有内容的完整列表,可以转到expressjs.com,并转到 API 参考:

我们碰巧使用的是 Express 的 4.x 版本,因此我们将点击该链接:

在此链接的右侧,我们有请求和响应。我们将查找请求对象,因此我们将点击它。这将引导我们到以下内容:

我们将使用两个请求属性:req.urlreq.method。在 Atom 中,我们可以开始实现这些,将它们添加到console.log中。在时间戳之后,我们将打印 HTTP 方法。稍后我们将使用其他方法。目前我们只使用了get方法。在console.log中,我将注入request.method,将其打印到控制台:

app.use((req, res, next) => {
  var now = new Date().toString();

  console.log(`${now}: ${req.method}`)
  next();
});

接下来,我们可以打印路径,以便我们确切知道用户请求的是哪个页面。我将通过注入另一个变量req.url来实现:

   console.log(`${now}: ${req.method} ${req.url}`);

有了这个,我们现在有一个相当有用的中间件。它获取请求对象,输出一些信息,然后继续让服务器处理该请求。如果我们保存文件并从浏览器重新运行应用程序,我们应该能够进入终端并看到这个新的记录器打印到屏幕上,如下所示:

我们有我们的时间戳、HTTP 方法是GET,以及路径。如果我们将路径更改为更复杂的内容,例如/about,然后我们返回到终端,我们将看到我们访问req.url/about

这是一种相当基本的中间件示例。我们可以再进一步。除了只是将消息记录到屏幕上,我们还将把消息打印到文件中。

将消息打印到文件

要将消息打印到文件中,让我们在server.js文件中加载fs。我们将创建一个常量。将其命名为const fs,并将其设置为从模块中获取的返回结果:

const express = require('express');
const hbs = require('hbs');
const fs = require('fs');

现在我们可以在app.use中实现这一点。我们将使用当前在console.log中定义的模板字符串。我们将把它剪切出来,而是存储在一个变量中。我们将创建一个名为log的变量,将其设置为如下所示的模板字符串:

app.use((req, res, next) => {
  var now = new Date().toString();
  var log = `${now}: ${req.method} ${req.url}`;

  console.log();
  next();
});

现在我们可以将log变量传递给console.logfs方法,以便写入我们的文件系统。对于console.log,我们将像这样调用 log:

  console.log(log);

对于fs,我将调用fs.appendFile。现在您记得,appendFile允许您添加到文件中。它需要两个参数:文件名和我们要添加的内容。我们将使用的文件名是server.log。我们将创建一个漂亮的日志文件,实际内容将只是log消息。我们需要添加一件事:我们还希望在每个请求被记录后继续下一行,因此我将连接新行字符,即\n

  fs.appendFile('server.log', log + '\n');

如果您使用的是 Node V7 或更高版本,则需要对此行进行微小调整。如下面的代码所示,我们向fs.appendFile添加了第三个参数。这是一个回调函数。现在是必需的。

fs.appendFile('server.log', log + '\n', (err) => {

  if (err) {

    console.log('Unable to append to server.log.')

  }

});如果你没有回调函数,你会在控制台中得到一个弃用警告。现在你可以看到,我们的回调函数在这里接受一个错误参数。如果有错误,我们只是在屏幕上打印一条消息。如果你将你的行改成这样,无论你的 Node 版本如何,你都将是未来的保障。如果你使用的是 Node V7 或更高版本,控制台中的警告将消失。现在警告将会说一些诸如弃用警告。调用异步函数而没有回调是被弃用的。如果你看到这个警告,做出这个改变。

现在我们已经准备就绪,我们可以测试一下。我保存文件,这应该重新启动nodemon中的东西。在 Chrome 中,我们可以刷新页面。如果我们回到终端,我们仍然可以得到我的日志,这很棒:

请注意,我们还有一个对favicon.ico的请求。这通常是在浏览器标签中显示的图标。我从以前的项目中缓存了一个。实际上并没有定义图标文件,这完全没问题。浏览器仍然会发出请求,这就是为什么它显示在前面的代码片段中。

在 Atom 中,我们现在有了我们的server.log文件,如果我们打开它,我们可以看到所有已经发出的请求的日志:

我们有时间戳,HTTP 方法和路径。使用app.use,我们能够创建一些中间件,帮助我们跟踪服务器的工作情况。

现在有时候你可能不想调用 next。我们学到了在做一些异步操作后可以调用 next,比如从数据库中读取,但想象一下出了问题。你可以避免调用 next 来阻止移动到下一个中间件。我们想在views文件夹中创建一个新的视图。我们将称之为maintenance.hbs。这将是一个 handlebars 模板,当网站处于维护模式时将进行渲染。

没有 next 对象的维护中间件

我们将从复制home.hbs开始制作maintenance.hbs文件。在maintenance.hbs中,我们将擦除 body 并添加一些标签:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Some Website</title>
 </head>
 <body>

  </body>
</html>

如下代码所示,我们将添加一个h1标签来向用户打印一条小消息:

 <body>
   <h1></h1>
 </body>

我们将使用类似我们马上回来的东西:

 <body>
   <h1>We'll be right back</h1>
 </body>

接下来,我可以添加一个段落标签:

 <body>
   <h1>We'll be right back</h1>
   <p>

   </p>
 </body>

现在我们将能够使用p后跟制表符。这是 Atom 中用于创建 HTML 标签的快捷方式。它适用于所有标签。我们可以输入 body 并按enter,或者我可以输入p并按enter,标签就会被创建。

在段落中,我会留下一条小消息:网站目前正在更新

 <p>
   The site is currently being updated.
 </p>

现在我们已经准备好了模板文件,我们可以定义我们的维护中间件。这将绕过我们的所有其他处理程序,其中我们渲染其他文件并打印 JSON,而是直接将此模板呈现到屏幕上。我们保存文件,进入server.js,并定义该中间件。

就在之前定义的中间件旁边,我们可以调用app.use,传入我们的函数。该函数将使用这三个参数:请求(req),响应(res)和next

app.use((req, res, next) => {

})

在中间件中,我们需要做的就是调用res.render。我们将添加res.render,传入我们想要渲染的文件的名称;在这种情况下,它是maintenance.hbs

app.use((req, res, next) => {
  res.render('maintenance.hbs');
});

这就是你需要做的一切来设置我们的主要中间件。这个中间件将阻止它之后的一切执行。我们不调用 next,所以实际的处理程序在app.get函数中,它们将永远不会被执行,我们可以测试这一点。

测试维护中间件

在浏览器中,我们将刷新页面,然后我们将得到以下输出:

我们得到了维护页面。我们可以转到主页,然后得到完全相同的东西:

现在还有一个非常重要的中间件部分我们还没有讨论。请记住,在public文件夹中,我们有一个help.html文件,如下所示:

如果我们通过在浏览器中访问localhost:3000/help.html来查看这个问题,我们仍然会得到帮助页面。我们不会得到维护页面:

这是因为中间件是按照调用app.use的顺序执行的。这意味着我们首先设置 Express 静态目录,然后设置日志记录器,最后设置maintenance.hbs日志记录器:

app.use(express.static(__dirname + '/public'));

app.use((req, res, next) => {
  var now = new Date().toString();
  var log = `${now}: ${req.method} ${req.url}`;

  console.log(log);
  fs.appendFile('server.log', log + '\n');
  next();
});

app.use((req, res, next) => {
  res.render('maintenance.hbs');
});

这是一个相当大的问题。如果我们还想使public目录文件(如help.html)私有,我们将不得不重新调整我们对app.use的调用,因为当前 Express 服务器正在 Express 静态中间件内响应,因此我们的维护中间件没有机会执行。

为了解决这个问题,我们将采取app.use Express 静态调用,从文件中删除,并在呈现维护文件到屏幕后添加。结果代码将如下所示:

app.use((req, res, next) => {
  var now = new Date().toString();
  var log = `${now}: ${req.method} ${req.url}`;

  console.log(log);
  fs.appendFile('server.log', log + '\n');
  next();
});

app.use((req, res, next) => {
  res.render('maintenance.hbs');
});

app.use(express.static(__dirname + '/public'));

现在,无论我们要记录请求的内容,一切都将按预期工作。然后我们将检查是否处于维护模式,如果维护中间件函数已经就位。如果是,我们将呈现维护文件。如果不是,我们将忽略它,因为它将被注释掉或类似的情况,最后我们将使用 Express 静态。这将解决所有这些问题。如果我现在重新渲染应用程序,我会在help.html上看到维护页面:

如果我回到网站的根目录,我仍然会看到维护页面:

现在,一旦我们完成了维护中间件,我们总是可以将其注释掉。这将使其不再被执行,网站将按预期工作。

这是对 Express 中间件的一个快速潜入。我们将在整本书中更多地使用它。我们将使用中间件来检查我们的 API 请求是否真的经过了身份验证。在中间件内部,我们将进行数据库请求,检查用户是否确实是他们所说的那个人。

总结

在本章中,您学习了 Express 以及如何使用它轻松创建网站。我们看了如何设置静态 Web 服务器,因此当我们有整个目录的 JavaScript、图像、CSS 和 HTML 时,我们可以轻松地提供这些内容而无需为每个内容提供路由。这将让我们创建各种应用程序,这将贯穿整本书的内容。

接下来,我们继续学习如何使用 Express。我们看了一下如何呈现动态模板,有点像我们在 PHP 或 Ruby on Rails 文件中所做的那样。我们有一些变量,我们呈现了一个模板并注入了这些变量。然后我们学习了一些关于 handlebars 部分的知识,它让我们可以创建可重用的代码块,比如头部和页脚。我们还学习了关于 Handlebars 助手的知识,这是一种从 handlebars 模板内部运行一些 JavaScript 代码的方法。最后,我们回到了关于 Express 以及如何定制我们的请求、响应和服务器的讨论。

在下一章中,我们将探讨如何将应用程序部署到网络上。

第九章:将应用程序部署到 Web

在本章中,我们将担心添加版本控制和部署我们的应用程序,因为当涉及到创建真实的 Node 应用程序时,将应用程序部署到 Web 上显然是其中非常重要的一部分。现实世界中,每家公司都使用某种形式的版本控制。这对软件开发过程至关重要,而且大多数公司都没有使用 Git。Git 已经变得非常流行,占据了版本控制的市场份额。Git 也是免费和开源的,并且有大量优质的教育材料。他们有一本关于如何学习 Git 的书。它是免费的,Stack Overflow 上充满了 Git 特定的问题和答案。

我们将使用 Git 保存我们的项目。我们还将使用它将我们的工作备份到一个名为 GitHub 的服务中,最后我们将使用 Git 将我们的项目实时部署到 Web 上。因此,我们将能够将我们的 Web 服务器部署给任何人访问。它不仅仅可以在本地主机上使用。

具体来说,我们将研究以下主题:

  • 设置和使用 Git

  • 设置 GitHub 和 SSH 密钥

  • 将 Node 应用程序部署到 Web

  • 整个开发生命周期的工作流程

添加版本控制

在本节中,我们将学习如何设置和使用 Git,这是一个版本控制系统。Git 将允许我们随着时间的推移跟踪我们项目的变化。当出现问题并且我们需要恢复到项目中以前工作正常的状态时,这非常有用。它还非常有用于备份我们的工作。

安装 Git

要开始,我们需要在计算机上安装 Git,但幸运的是,这是一个非常简单的安装过程。这是一个我们只需通过几个步骤单击“下一步”按钮的安装程序。所以让我们继续做到这一点。

  1. 我们可以通过浏览器转到git-scm.com来获取安装程序。

在我们继续安装之前,我想向您展示一本名为 Pro Git 的书的链接(git-scm.com/book/en/v2)。这是一本免费的书,也可以在线阅读。它涵盖了 Git 所提供的一切。在本章中,我们将研究一些更基本的功能,但我们可以很容易地创建一个关于 Git 的整个课程。实际上,Udemy 上有专门关于 Git 和 GitHub 的课程,所以如果您想学习更多内容,我建议阅读这本书或参加课程,无论您的首选学习方法是什么。

  1. 单击主页右侧的下载按钮,适用于所有操作系统,无论是 Windows、Linux 还是 macOS。这应该会带我们到安装程序页面,我们应该能够自动下载安装程序。如果您在SourceForge.net上遇到任何问题,那么我们可能需要实际点击它以手动下载以开始下载。

  2. 安装程序下载完成后,我们可以简单地运行它。

  3. 接下来,通过安装程序:

  1. 点击“继续”并安装软件包:

  1. 完成后,我们可以继续测试安装是否成功:

macOS 上的 Git

如果您使用的是 macOS,您需要启动软件包安装程序,可能会收到以下消息框,表示它来自未知开发者:

这是因为它是通过第三方分发的,而不是在 macOS 应用商店中。我们可以右键单击软件包,然后单击“打开”按钮,并确认我们确实要打开它。

一旦您到达安装程序,整个过程将非常简单。您可以在每个步骤中单击“继续”和“下一步”。

Windows 上的 Git

但是,如果您使用的是 Windows,有一个重要的区别。在安装程序中,您将看到一个与此类似的屏幕:

同样重要的是,您还要安装 Git Bash,如截图所示。Git Bash 是一个模拟 Linux 类型终端的程序,在我们创建下一节中的 SSH 密钥时,它将非常重要,以便唯一标识我们的机器。

测试安装

现在,让我们进入终端测试安装。从终端中,我们可以继续运行git --version。这将打印出我们安装的新版本的 Git:

git --version

如下截图所示,我们可以看到我们有 git 版本 2.14.3:

现在,如果您的终端仍然打开,并且出现类似 git 命令未找到的错误,我建议尝试重新启动终端。有时在安装新命令(如刚刚安装的git命令)时,这是必需的。

将 node-web-server 目录转换为 Git 仓库

安装 Git 成功后,我们现在可以将我们的node-web-server目录转换为 Git 存储库。为了做到这一点,我们将运行以下命令:

git init

git init命令需要在我们项目的根目录中执行,即包含我们要跟踪的所有内容的文件夹。在我们的情况下,node-web-server就是那个文件夹。它包含我们的server.js文件,我们的package.json文件和所有的目录。因此,从服务器文件夹中,我们将运行git init

这将在该文件夹内创建一个.git目录。我们可以通过运行ls -a命令来证明:

ls -a

如下截图所示,我们获得了所有目录,包括隐藏的目录,而我确实有.git:

对于 Windows,可以从 Git Bash 中运行这些命令。

现在这个目录不是我们应该手动更新的东西。我们将使用终端中的命令来对 Git 文件夹进行更改。

您不希望手动进入那里搞乱事情,因为您很可能会破坏 Git 存储库,而您的辛苦工作将变得毫无意义。现在显然,如果有备份,这不是什么大问题,但实际上没有理由进入那个 Git 文件夹。

让我们使用clear命令清除终端输出,现在我们可以开始看 Git 的工作原理。

使用 Git

如前所述,Git 负责跟踪项目的更改,但默认情况下它实际上不会跟踪任何文件。我们必须告诉 Git 确切地要跟踪哪些文件,这是有很好的理由的。每个项目中都有一些文件,我们很可能不想将其添加到 Git 仓库中,我们将在稍后讨论哪些文件以及为什么。现在让我们继续运行以下命令:

git status

现在,所有这些命令都需要在项目的根目录中执行。如果您尝试在存储库之外运行此命令,您将收到类似 git repository not found 的错误。这意味着 Git 找不到.git目录,无法实际获取存储库的状态。

当我们运行此命令时,我们将得到以下输出:

现在重要的部分是未跟踪文件标题和其下的所有文件。这些都是 Git 捕获的所有文件和文件夹,但它目前没有跟踪。Git 不知道您是否要跟踪这些文件的更改,或者您是否要将它们从存储库中忽略。

现在,例如,views文件夹是我们确实想要跟踪的。这对项目至关重要,我们希望确保每当有人下载存储库时,他们都会得到views文件夹。另一方面,日志文件实际上不需要包含在 Git 中。通常我们的日志文件不会被提交,因为它们通常包含特定时间点服务器运行时的信息。

如上面的代码输出所示,我们有server.js,我们的 public 文件夹和package.json。这些都是执行应用程序过程中必不可少的。这些肯定会被添加到我们的 Git 仓库中,而我们上面有的第一个是node_modules文件夹。node_modules文件夹是所谓的生成文件夹。

生成的文件夹可以通过运行命令轻松生成。在我们的情况下,我们可以使用npm install重新生成整个目录。我们不想将 Node 模块添加到我们的 Git 仓库,因为它的内容取决于您安装的 npm 版本和您使用的操作系统。最好不要添加 Node 模块,让每个使用您的存储库的人手动在他们实际运行应用程序的计算机上安装模块。

将未跟踪的文件添加到提交

现在我们列出了这六个文件夹和文件,所以让我们继续添加我们想要保留的四个文件夹和文件。首先,我们将使用任何git add命令。git add命令让我们告诉 Git 我们要跟踪某个文件。让我们输入以下命令:

git add package.json

在这之后,我们可以再次运行git status,这次我们得到了一个非常不同的结果:

现在我们有一个初始提交标题。这是新的,我们有我们旧的未跟踪文件标题。请注意,在未跟踪的文件下,我们不再有package.json。它移到了初始提交标题下。这些都是在我们进行第一次提交时要保存的文件,也就是提交的文件。现在我们可以继续添加其他 3 个。我们将再次使用git add命令告诉 Git 我们要跟踪 public 目录。我们可以运行git status命令来确认它是否按预期添加了:

在上面的截图中,我们可以看到 public/help.html 文件现在将在我们运行提交后提交到 Git。

接下来,我们可以使用git add server.js添加server.js,并使用git add views添加views目录,就像这样:

git add server.js

git add views/

我们将运行git status命令进行确认:

一切看起来都很好。现在未跟踪的文件将一直保留在这里,直到我们执行以下两种操作之一——要么将它们添加到 Git 存储库中,要么使用我们将在 Atom 中创建的自定义文件来忽略它们。

在 Atom 中,我们想要在我们项目的根目录中创建一个名为.gitignore的新文件。gitignore文件将成为我们的 Git 存储库的一部分,并告诉 Git 要忽略哪些文件和文件夹。在这种情况下,我们可以继续忽略node_modules,就像这样:

当我们保存gitignore文件并从终端重新运行git status时,我们现在会得到一个完全不同的结果:

如图所示,我们有一个新的未跟踪文件—.gitignore—但node_modules目录不见了,这正是我们想要的。我们想要完全删除它,确保它永远不会被添加到 Git 仓库中。接下来,我们可以继续忽略server.log文件,通过输入它的名称,server.log

node modules/
server.log

我们将保存gitignore,再次从终端运行git status,确保一切看起来都很好:

如图所示,我们有一个gitignore文件作为我们唯一的未跟踪文件。server.log文件和node_modules都不见了。

现在我们有了gitignore,我们将使用git add .gitignore将其添加到 Git 中,当我们运行git status时,我们应该能够看到所有显示的文件都在初始提交之下:

git add .gitignore

git status

现在是时候进行提交了。提交实际上只需要两件事。它需要存储库中的一些更改。在这种情况下,我们正在教 Git 如何跟踪大量新文件,所以我们确实在改变一些东西,还需要一个消息。我们已经处理了文件部分。我们告诉 Git 我们想要保存什么,只是还没有真正保存它。

进行提交

为了进行我们的第一个提交并将我们的第一件事保存到 Git 存储库中,我们将运行git commit并提供一个标志,即m标志,这是短消息。在引号内,我们可以指定我们想要用于此提交的消息。使用这些消息非常重要,因此当有人查看提交历史时,可以看到对项目的所有更改的列表,这实际上是有用的。在这种情况下,Initial commit总是一个很好的消息,用于你的第一个提交:

git commit -m 'Initial commit'

我将继续点击enter,如下面的截图所示,我们可以看到对存储库所做的所有更改:

我们在 Git 存储库中创建了一堆新文件。这些都是我们告诉 Git 我们想要跟踪的文件,这太棒了。

我们现在有了我们的第一个提交,这基本上意味着我们已经保存了项目的当前状态。如果我们对server.js进行了重大更改,搞砸了,不知道如何恢复到原来的状态,我们总是可以恢复,因为我们做了一个 Git 提交。现在我们将在后面的部分探讨一些更高级的 Git 功能。我们将讨论如何使用 Git 做大部分你想做的事情,包括部署到 Heroku 和推送到 GitHub。

设置 GitHub 和 SSH 密钥

现在你有了一个本地的 Git 存储库,我们将看看如何将代码推送到一个名为 GitHub 的第三方服务。GitHub 将让我们远程托管我们的 Git 存储库,所以如果我们的机器崩溃了,我们可以找回我们的代码,它还有很棒的协作工具,所以我们可以开源一个项目,让其他人使用我们的代码,或者我们可以保持私有,只有我们选择合作的人可以看到源代码。

现在,为了在我们的机器和 GitHub 之间进行实际通信,我们将不得不创建一个称为 SSH 密钥的东西。SSH 密钥旨在在两台计算机之间进行安全通信。在这种情况下,它将是我们的机器和 GitHub 服务器。这将让我们确认 GitHub 是他们所说的那样,它将让 GitHub 确认我们确实可以访问我们试图修改的代码。这将全部通过 SSH 密钥完成,我们将首先创建它们,然后配置它们,最后将我们的代码推送到 GitHub。

设置 SSH 密钥

设置 SSH 密钥的过程可能是一个真正的负担。这是一个那种话题,错误的余地真的很小。如果你输入任何错误的命令,事情就不会按预期工作。

现在,如果你使用的是 Windows,你需要在 Git Bash 中执行本节中的所有操作,而不是常规命令提示符,因为我们将使用一些在 Windows 上不可用的命令。但是,在 Linux 和 macOS 上是可用的。因此,如果你使用这两种操作系统中的任何一种,你可以继续使用本书中一直在使用的终端。

SSH 密钥文档

在我们深入命令之前,我想向您展示一个快速指南,以防您遇到困难或有任何问题。您可以搜索 GitHub SSH 密钥,这将链接您到一篇名为生成 SSH 密钥的文章:help.github.com/articles/connecting-to-github-with-ssh/。一旦您到达这里,您就可以单击 SSH 面包屑,这将带您回到他们关于 SSH 密钥的所有文章:

在这些文章中,我们将专注于检查是否有密钥,生成新密钥,将密钥添加到 GitHub,最后测试一切是否按预期工作。如果您在这些步骤中遇到任何问题,您可以随时单击该步骤的指南,并且您可以选择您正在使用的操作系统,以便查看该操作系统的适当命令。既然您知道这一点,让我们一起来做吧。

工作中的命令

我们将从终端运行的第一个命令是检查是否有现有的 SSH 密钥。如果没有,那没关系。我们将继续创建一个。如果您不确定是否有密钥,您可以运行以下命令来确认您是否有密钥:lsal标志。这将打印出给定目录中的所有文件,默认情况下,SSH 密钥存储在您的计算机上的用户目录中,您可以使用(~)作为/.ssh的快捷方式:

ls -al ~/.ssh

当您运行该命令时,您将看到 SSH 目录中的所有内容:

在这种情况下,我已经删除了所有我的 SSH 密钥,所以我的目录中没有任何内容。我只有当前目录和上一个目录的路径。既然我们已经做好了准备,并且确认我们没有密钥,我们可以继续生成一个。如果您已经有一个密钥,例如id_rsa文件,您可以跳过生成密钥的过程。

生成密钥

要生成一个密钥,我们将使用ssh-keygen命令。现在ssh-keygen需要三个参数。我们将传入t,将其设置为rsa。我们将传入b,用于字节,将其设置为4096。确保精确匹配这些参数,我们将设置一个大写的C标志,该标志将设置为您的电子邮件:

ssh-keygen -t rsa -b 4096 -C 'garyngreig@gmail.com'

现在,实际发生在幕后的范围不在本书的讨论范围之内。SSH 密钥和设置安全性,这可能是一个完整的课程。我们将使用此命令来简化整个过程。

现在我们可以继续按enter键,这将在我们的.ssh文件夹中生成两个新文件。当您运行此命令时,您将受到几个步骤的欢迎。我希望您对所有步骤都使用默认设置:

他们想要问您是否要自定义文件名。我不建议这样做。您可以直接按enter键:

接下来,他们会要求您输入密码,我们将不使用密码。我将按下enter键,不设置密码,然后需要确认密码,所以我将再次按下enter键:

如图所示,我们收到了一条消息,说明我们的 SSH 密钥已经正确创建,并且确实保存在我们的文件夹中。

有了这个,我现在可以通过之前的命令循环运行ls命令,我会得到什么?

我们得到了id_rsaid_rsa.pub文件。id_rsa文件包含私钥。这是您绝对不应该给任何人的密钥。它只存在于您的计算机上。.pub文件是公共文件。这是您将提供给 GitHub 或 Heroku 等第三方服务的文件,我们将在接下来的几节中进行操作。

启动 SSH 代理

现在我们的密钥已生成,我们需要做的最后一件事是启动 SSH 代理并添加此密钥,以便它知道它的存在。我们将通过运行两个命令来实现这一点。这些是:

  • eval

  • ssh-add

首先,我们将运行eval,然后我们将打开一些引号,在引号内,我们将使用美元符号并打开和关闭一些括号,就像这样:

eval "$()"

在括号内,我们将键入带有s标志的ssh-agent

eval "$(ssh-agent -s)"

这将启动 SSH 代理程序,并且还会打印进程 ID 以确认它确实正在运行,如所示,我们得到 Agent pid 1116:

进程 ID 显然对每个人都是不同的。只要你得到这样的回复,你就可以继续了。

接下来,我们必须告诉 SSH 代理此文件的位置。我们将使用ssh-add来实现这一点。这需要我们的私钥文件的路径,我们在用户目录/.ssh/id_rsa中有:

ssh-add ~/.ssh/id_rsa

当我运行这个时,我应该收到一个像身份添加的消息:

这意味着本地计算机现在知道了这对公钥/私钥,并且在与 GitHub 等第三方服务通信时会尝试使用这些凭据。既然我们已经准备就绪,我们就可以配置 GitHub 了。我们将创建一个帐户,设置它,然后我们将回来测试一切是否按预期工作。

配置 GitHub

要配置 GitHub,请按照以下步骤操作:

  1. 首先进入浏览器,转到github.com

  2. 在这里,登录到您现有的帐户或创建一个新帐户。如果您需要一个新帐户,请注册 GitHub。如果您已经有一个现有的帐户,请继续登录。

  3. 一旦登录,您应该看到以下屏幕。这是您的 GitHub 仪表板:

  1. 从这里,导航到设置,位于左上角,通过个人资料图片。转到设置| SSH 和 GPG 密钥| SSH 密钥:

  1. 从这里,我们可以添加公钥,让 GitHub 知道我们要使用 SSH 进行通信。

  2. 添加新的 SSH 密钥:

在这里,您需要做两件事:给它一个名称,并添加密钥。

首先添加名称。名称可以是任何你喜欢的东西。例如,我通常使用一个唯一标识我的计算机的名称,因为我有几台电脑。我会像这样使用MacBook Pro

接下来,添加密钥。

要添加密钥,我们需要获取在上一小节中生成的id_rsa.pub文件的内容。该文件包含 GitHub 需要的信息,以便在我们的计算机和他们的计算机之间进行安全通信。有不同的方法来获取密钥。在浏览器中,我们有添加新的 SSH 密钥到您的 GitHub 帐户文章供我们参考。

  1. 这包含一个命令,您可以使用它从终端中直接复制该文件的内容到剪贴板。现在显然对于操作系统,macOS,Windows 和 Linux 是不同的,所以运行适用于您的操作系统的命令。

  2. 使用 macOS 可用的pbcopy命令。

然后,进入终端并运行它。

 pbcopy < ~/.ssh/id_rsa.pub

这将文件的内容复制到剪贴板。您还可以使用常规文本编辑器打开命令并复制文件的内容。我们可以使用任何方法来复制文件。重要的是你要做。

  1. 现在回到 GitHub,点击文本区域并粘贴进去。

id_rsa.pub的内容应该以ssh-rsa开头,并以您使用的电子邮件结尾。

  1. 完成后,继续点击“添加 SSH 密钥”。

现在我们可以继续测试一下事情是否正常运行,通过在终端中运行一个命令。再次强调,这个命令可以在您的机器的任何地方执行。你不需要在你的项目文件夹中执行这个命令。

测试配置

为了测试我们的 GitHub 配置的工作情况,我们将使用ssh,它尝试建立连接。我们将使用T标志,后面跟着我们要连接到的 URL,获取git@github.com

ssh -T git@github.com

这将测试我们的连接。它将确保 SSH 密钥已正确设置,并且我们可以安全地与 GitHub 通信。当我运行命令时,我收到一条消息,说主机'github.com (192.30.253.113)'的真实性无法得到证实。

我们知道我们想要与github.com进行通信。我们期望通信会发生,所以我们可以继续输入yes

从这里,我们会收到 GitHub 服务器的消息,如前面的屏幕截图所示。如果你看到这条消息和你的用户名,那么你已经完成了。你已经准备好创建你的第一个存储库并推送你的代码。

现在,如果你没有看到这条消息,那么在这个过程中出了问题。也许 SSH 密钥没有正确生成,或者 GitHub 没有识别它。

接下来,我们将进入 GitHub,返回到主页,并创建一个新的存储库。

创建一个新的存储库

要创建一个新的存储库,请按照以下步骤进行:

  1. 在 GitHub 主页的右上角,导航到新存储库按钮,它应该是这样的(如果是新的存储库,点击开始新项目):

这将带我们到新的存储库页面:

  1. 在这里,我们只需要给它一个名字。我要把这个叫做node-course-2-web-server

一旦你有了一个名字,你可以给它一个可选的描述,你可以选择是公共存储库还是私有存储库。

现在私有存储库会让你选择$7 的计划。如果你正在与其他公司创建项目,我建议你选择私有存储库。

  1. 不过,在这种情况下,我们正在创建非常简单的项目,如果其他人发现了代码也不会有太大关系,所以继续使用公共存储库的选项。

  1. 一旦你填写好这两个内容,点击创建存储库按钮:

这将带你到你的存储库页面:

它会给你一些设置,因为目前没有代码可以查看,所以它会根据你所处的情况给你一些指示。

设置存储库

现在,在前面的三个设置说明中,我们不需要创建新存储库的说明。我们也不会使用从其他 URL 导入我们的代码的说明。我们已经有一个现有的存储库,我们想要从命令行推送它。

我们将从项目内运行这两个命令:

  • 第一个命令将向我们的 Git 存储库添加一个新的远程

  • 第二个命令将把它推送到 GitHub

远程让 Git 知道你想要同步的第三方 URL。也许我想把我的代码推送到 GitHub 与我的同事进行交流。也许我还想能够推送到 Heroku 来部署我的应用程序。这意味着你会想要两个远程。在我们的情况下,我们只会添加一个,所以我会复制这个 URL,进入终端,粘贴它,然后点击enter

git remote add origin https://github.com/garygreig/node-course-2-web-server.git

现在我们已经添加了git remote,我们可以继续运行第二个命令。我们将在整本书中广泛使用第二个命令。在终端中,我们可以复制并粘贴第二个命令的代码,然后运行它:

git push -u origin master

如前面的屏幕截图所示,我们可以看到一切都进行得很顺利。我们成功地将所有数据写入 GitHub,如果我们回到浏览器并刷新页面,我们将不再看到那些设置说明。相反,我们将看到我们的存储库,有点像树形视图:

在这里我们可以看到我们有server.js文件,这很好。我们看不到日志文件或node_module文件,这很好,因为我们忽略了它。我有我的公共目录。一切都运行得非常非常好。我们还有问题跟踪,拉取请求。您可以创建一个 Wiki 页面,用于为存储库设置说明。GitHub 有很多非常棒的功能。我们将只使用最基本的功能。

在我们的存储库中,我们可以看到我们有一个提交,如果我们点击那个提交按钮,实际上可以进入提交页面,在这里我们可以看到我们输入的初始提交消息。我们在上一节中进行了提交:

这将让我们跟踪所有我们的代码,如果我们进行了不需要的更改,可以回滚,并管理我们的存储库。现在我们的代码已经推送上去,我们完成了。

将 node 应用程序部署到 Web

在本节中,您将使用 Heroku 将您的 Node 应用程序实时部署到 Web。在本节结束时,您将获得一个 URL,您可以将其提供给任何人,他们将能够在其浏览器中访问该 URL 以查看应用程序。我们将通过 Heroku 完成这一点。

Heroku 是一个网站。它是一个用于管理托管在云中的 Web 应用程序的 Web 应用程序。这是一个非常棒的服务。他们几乎可以毫不费力地创建新应用程序,部署您的应用程序,更新应用程序,并添加一些很酷的附加功能,如日志记录和错误跟踪,所有这些都是内置的。现在 Heroku,就像 GitHub 一样,不需要信用卡即可注册,并且有免费的套餐,我们将使用。他们为几乎所有功能提供付费计划,但我们可以使用免费套餐来完成本节中的所有操作。

安装 Heroku 命令行工具

首先,我们将打开浏览器并转到heroku.com。在这里,我们可以继续注册一个新帐户。花点时间要么登录您现有的帐户,要么注册一个新帐户。一旦登录,它会显示您的仪表板。现在您的仪表板将看起来像这样:

尽管可能会有一个问候语告诉您创建一个新应用程序,但您可以忽略。我有很多应用程序。您可能没有这些,这完全没问题。

接下来我们要做的是安装 Heroku 命令行工具。这将让我们能够在终端中创建应用程序,部署应用程序,打开应用程序,并且可以在终端中进行各种非常酷的操作,而不必进入 Web 应用程序。这将节省我们的时间并使开发变得更加容易。我们可以通过访问toolbelt.heroku.com来获取下载。

在这里,我们可以获取适用于您正在运行的任何操作系统的安装程序。让我们开始下载。这是一个非常小的下载,所以应该很快。

完成后,我们可以继续进行以下步骤:

这是一个简单的安装程序,您只需点击“安装”。无需自定义任何内容。您不必输入关于您的 Heroku 帐户的任何特定信息。让我们继续完成安装程序。

这将为我们提供一个新的终端命令,我们可以执行。在我们执行之前,我们必须在终端中本地登录,这正是我们接下来要做的事情。

在本地登录 Heroku 帐户

现在我们将启动终端。如果您已经运行它,您可能需要重新启动它,以便您的操作系统识别新的命令。您可以通过运行以下命令来测试它是否已正确安装:

heroku --help

当您运行此命令时,您将看到它正在首次安装 CLI,然后我们将获得所有的帮助信息。这将告诉我们我们可以访问哪些命令以及它们的确切工作方式:

现在我们需要在本地登录 Heroku 账户。这个过程非常简单。在前面的代码输出中,我们有所有可用的命令,其中之一恰好是登录。我们可以像这样运行heroku login来开始这个过程:

heroku login

我将运行login命令,现在我们只需使用之前设置的电子邮件和密码:

我将输入我的电子邮件和密码。密码输入是隐藏的,因为它是安全的。当我这样做时,您会看到已登录为 garyngreig@gmail.com 显示出来,这太棒了:

现在我们已经登录,并且能够成功地在我们的机器命令行和 Heroku 服务器之间进行通信。这意味着我们可以开始创建和部署应用程序。

获取 SSH 密钥到 Heroku

在继续之前,我们将使用clear命令清除终端输出,并将我们的 SSH 密钥放在 Heroku 上,有点像我们在 GitHub 上所做的,只是这次我们可以通过命令行来完成。所以这将更容易。为了将我们的本地密钥添加到 Heroku,我们将运行heroku keys:add命令。这将扫描我们的 SSH 目录并添加密钥:

heroku keys:add

在这里,您可以看到它找到了id_rsa.pub文件的密钥:您想将其上传到 Heroku 吗?。

输入Yes并按enter

现在我们已经上传了我们的密钥。就是这么简单。比配置 GitHub 要容易得多。从这里开始,我们可以使用heroku keys命令来打印当前在我们账户上的所有密钥:

heroku keys

我们总是可以使用heroku keys:remove命令删除它们,后面跟着与该密钥相关的电子邮件。在这种情况下,我们将保留我们拥有的 Heroku 密钥。接下来,我们可以使用v标志和git@heroku.com测试我们的连接使用 SSH:

ssh -v git@heroku.com

这将与 Heroku 服务器通信:

如图所示,我们可以看到它正在询问同样的问题:主机'heroku.com'的真实性无法确定,您确定要继续连接吗?输入Yes

您将看到以下输出:

现在当您运行该命令时,您将得到大量的加密输出。您要寻找的是认证成功,然后在括号中的公钥。如果事情没有进行顺利,您将看到权限被拒绝的消息,括号中有公钥。在这种情况下,认证是成功的,这意味着我们可以继续。我将再次运行 clear,清除终端输出。

在 Heroku 的应用程序代码中设置

现在我们可以把注意力转向应用程序代码,因为在我们可以部署到 Heroku 之前,我们需要对代码进行两处更改。这些是 Heroku 希望您的应用程序具备的东西,以便正常运行,因为 Heroku 会自动执行很多操作,这意味着您必须为 Heroku 设置一些基本的东西。这并不复杂——一些非常简单的更改,一些一行代码。

server.js文件中的更改

首先,在server.js文件的最底部,我们有端口和我们的app.listen静态编码在server.js中:

app.listen(3000, () => {
  console.log('Server is up on port 3000');
});

我们需要使这个端口动态化,这意味着我们想要使用一个变量。我们将使用 Heroku 将设置的环境变量。Heroku 将告诉您的应用程序使用哪个端口,因为随着部署应用程序,该端口将发生变化,这意味着我们将使用该环境变量,这样我们就不必每次部署时都要更换我们的代码。

使用环境变量,Heroku 可以在操作系统上设置一个变量。您的 Node 应用程序可以读取该变量,并将其用作端口。现在所有的机器都有环境变量。您实际上可以通过在 Linux 或 macOS 上运行env命令或在 Windows 上运行set命令来查看您的机器上的环境变量。

当您这样做时,您将得到一个非常长的键值对列表,这就是所有环境变量的内容:

在这里,我有一个 LOGNAME 环境变量设置为 Andrew。我有一个 HOME 环境变量设置为我的主目录,还有各种各样的环境变量在我的操作系统中。

Heroku 将设置其中一个叫做PORT的变量,这意味着我们需要去获取那个port变量,并在server.js中使用它,而不是 3000。在server.js文件的顶部,我们需要创建一个叫做port的常量,这将存储我们将用于应用程序的端口:

const express = require('express');.
const hbs = require('hbs');
const fs = require('fs');

const port

现在我们要做的第一件事是从process.env中获取一个端口。process.env是一个存储所有环境变量的键值对的对象。我们正在寻找一个 Heroku 将设置的叫做PORT的变量:

const port = process.env.PORT;

这对 Heroku 来说将会很好,但是当我们在本地运行应用程序时,PORT环境变量将不存在,因此我们将使用这个语句中的 OR (||)运算符来设置默认值。如果process.env.port不存在,我们将把端口设置为3000

const port = process.env.PORT || 3000;

现在我们有一个配置为与 Heroku 一起工作并在本地运行的应用程序,就像以前一样。我们所要做的就是取PORT变量,并在app.listen中使用它,而不是3000。如所示,我将引用port,并在我们的消息中,我将用模板字符串替换它,现在我可以用注入的端口变量替换3000,这将随时间变化:

app.listen(port, () => {
  console.log(`Server is up on port ${port}`);
});

有了这个设置,我们现在已经解决了应用程序的第一个问题。我现在将从终端中运行node server.js,就像我们在上一章中做的那样:

node server.js

我们仍然会得到完全相同的消息:服务器在端口 3000 上运行,所以您的应用程序在本地仍然可以正常工作:

在 package.json 文件中的更改

接下来,我们必须在package.json中指定一个脚本。在package.json中,您可能已经注意到我们有一个scripts对象,在其中我们有一个test脚本。

这是 npm 默认设置的:

我们可以在scripts对象内创建各种脚本,做任何我们喜欢的事情。脚本只不过是我们从终端运行的命令,所以我们可以把这个命令node server.js转换成一个脚本,这正是我们要做的。

scripts对象内,我们将添加一个新的脚本。脚本需要被命名为start

这是一个非常特定的内置脚本,我们将把它设置为启动我们应用程序的命令。在这种情况下,它将是node server.js

"start": "node server.js"

这是必要的,因为当 Heroku 尝试启动我们的应用程序时,它不会使用您的文件名运行 Node,因为它不知道您的文件名叫什么。相反,它将运行启动脚本,启动脚本将负责执行正确的操作;在这种情况下,启动服务器文件。

现在我们可以使用终端中的start脚本来运行我们的应用程序,使用以下命令:

npm start

当我这样做时,我们会得到与 npm 相关的一些输出,然后我们会得到服务器在端口 3000 上运行的消息,如果我们在浏览器中访问应用程序,一切都与上一章中完全相同:

最大的区别是我们现在已经准备好使用 Heroku 了。我们也可以使用终端运行npm test来运行测试脚本:

npm test

现在,我们没有指定任何测试,这是预期的:

在 Heroku 中进行提交

该过程的下一步将是进行提交,然后我们最终可以开始将其上载到 Web 上。从终端,我们将使用本章前面探讨过的一些 Git 命令。首先是git status。当我们运行git status时,我们会看到一些新的东西:

这里显示的代码输出中,我们不是有新文件,而是有修改过的文件。我们有一个修改过的package.json文件和一个修改过的server.js文件。如果我们现在运行git commit,这些将不会被提交;我们仍然需要使用git add。我们将运行git add并使用点作为下一个参数。点将添加所有显示的每一样东西,并将状态添加到下一个提交。

现在我只建议使用Changes not staged for commit标题中列出的所有内容的语法。这些是您实际想要提交的内容,在我们的情况下,这确实是我们想要的。如果我运行git add,然后重新运行git status,我们现在可以看到下一个将要提交的内容,在Changes to be committed标题下:

这里有我们的package.json文件和server.js文件。现在我们可以继续进行提交。

我将运行git commit命令,并使用m标志来指定我们的消息,对于这个提交,一个好的消息可能是设置启动脚本和 heroku 端口

git commit -m 'Setup start script and heroku port'

现在我们可以继续运行该命令,这将进行提交。

现在我们可以使用git push命令将其推送到 GitHub,我们可以省略origin远程,因为 origin 是默认远程。我将继续运行以下命令:

git push

这将把它推送到 GitHub,现在我们准备实际创建应用程序,将我们的代码推送上去,并在浏览器中查看它:

运行 Heroku 创建命令

该过程的下一步是从终端运行一个名为heroku create的命令。heroku create需要在应用程序内部执行:

heroku create

就像我们运行 Git 命令一样,当我运行heroku create时,会发生一些事情:

  • 首先,它将在 Heroku Web 应用程序中创建一个真正的新应用程序

  • 它还将向您的 Git 存储库添加一个新的远程

现在记住我们有一个指向我们 GitHub 存储库的 origin 远程。我们将有一个指向我们 Heroku Git 存储库的 Heroku 远程。当我们部署到 Heroku Git 存储库时,Heroku 将会看到。它将接受更改并将其部署到 Web 上。当我们运行 Heroku create 时,所有这些都会发生:

现在我们仍然需要将其推送到这个 URL,以实际执行部署过程,我们可以使用git push后跟heroku来完成:

git push heroku

刚刚添加的全新远程是因为我们运行了heroku create。现在这次推送将按照正常流程进行。然后您将开始看到一些日志。

这些是来自 Heroku 的日志,让您知道您的应用程序是如何部署的。它正在进行整个过程,向您展示沿途发生了什么。这将花费大约 10 秒,在最后我们有一个成功的消息—验证部署...完成:

它还验证了应用程序成功部署,并且确实通过了。从这里,我们实际上有一个可以访问的 URL(sleepy-retreat-32096.herokuapp.com/)。我们可以复制它,粘贴到浏览器中。我将使用以下命令:

heroku open

heroku open将在默认浏览器中打开 Heroku 应用程序。当我运行这个命令时,它会切换到 Chrome,我们的应用程序会如预期般显示出来:

我们可以在页面之间切换,一切都像在本地一样工作。现在我们有一个 URL,这个 URL 是由 Heroku 给我们的。这是 Heroku 生成应用程序 URL 的默认方式。如果您有自己的域名注册公司,您可以继续配置其 DNS 以指向此应用程序。这将让您为 Heroku 应用程序使用自定义 URL。您将不得不参考您的域名注册商的具体说明来做到这一点,但这确实是可以做到的。

现在我们已经完成了这一步,成功地将我们的 Node 应用程序部署到 Heroku 上,并且这真是太棒了。为了做到这一点,我们所要做的就是提交更改我们的代码并将其推送到一个新的 Git 远程。部署我们的代码再也不会更容易了。

您还可以通过转到 Heroku 仪表板来管理您的应用程序。如果您刷新一下,您应该会在仪表板的某个地方看到全新的 URL。记住我的是 sleepy retreat。你的会是其他的。如果我点击 sleepy retreat,我就可以查看应用程序页面:

在这里,我们可以进行很多配置。我们可以管理活动和访问权限,这样我们就可以与他人合作。我们有指标,我们有资源,各种真正酷的东西。有了这个,我们现在已经完成了基本的部署部分。

在下一节中,您的挑战将是再次经历这个过程。您将对 Node 应用程序进行一些更改。您将提交它们,部署它们,并在 Web 上实时查看它们。我们将首先创建本地更改。这意味着我将在这里使用app.get注册一个新的 URL。

我们将创建一个新的页面/projects,这就是为什么我将其作为我的 HTTP get 处理程序的路由。在第二个参数中,我们可以指定我们的callback函数,它将被调用并传入请求和响应,就像我们对上面的其他路由,根路由和 about 路由一样,我们将调用response.render来渲染我们的模板。在渲染参数列表中,我们将提供两个。

第一个将是文件名。文件不存在,但我们仍然可以继续调用render。我会称它为projects.hbs,然后我们可以指定要传递给模板的选项。在这种情况下,我们将设置页面标题,将其设置为Projects,P 要大写。太棒了!现在,服务器文件已经全部完成了。那里不会再有更多的更改了。

我将继续前往views目录,创建一个名为projects.hbs的新文件。在这里,我们将能够配置我们的模板。首先,我将从 about 页面复制模板。因为它非常相似,我会复制它。关闭 about,粘贴到 projects,然后我只会更改这个文本为项目页面文本将在这里。然后我们可以保存文件并进行最后的更改。

我们想要做的最后一件事是更新页眉。我们现在有一个位于/projects的全新项目页面。所以我们要继续并将其添加到页眉链接列表中。在这里,我会创建一个新的段落标签,然后我会创建一个锚标签。链接的文本将是Projects,P 要大写,href是链接被点击时要访问的 URL。我们将把它设置为/projects,就像我们为 about 设置为/about一样。

现在我们已经完成了这一切,所有的更改都已经完成,我们准备在本地测试。我将使用server.js文件在本地启动应用程序。首先,我们在 localhost 3000 上启动。因此,在浏览器中,我可以切换到 localhost 标签页,而不是 Heroku 应用标签页,然后单击刷新。在这里,我们有主页,指向主页,我们有关于,指向关于,我们有项目,确实指向/projects,呈现项目页面。项目页面的文本将在这里。有了这个,我们现在在本地完成了。

我们已经做出了更改,已经测试过了,现在是时候进行提交了。这将在终端内进行。我将关闭服务器并运行 Git 状态。这将显示我仓库中自上次提交以来的所有更改。我有两个修改过的文件:服务器文件和标题文件,还有我的全新项目文件。所有这些看起来都很好。我想将所有这些添加到下一个提交中,所以我可以使用Git add .来做到这一点。

现在在我实际进行提交之前,我确实想通过运行 Git 状态来测试是否添加了正确的内容。在这里,我可以看到要提交的更改显示为绿色。一切看起来都很好。接下来,我们将运行 Git 提交来实际进行提交。这将把所有更改保存到 Git 仓库中。这次提交的消息可能是添加一个项目页面。

提交完成后,下一步需要做的是将其推送到 GitHub。这将备份我们的代码并让其他人进行协作。我将使用 Git push 来做到这一点。记住,我们可以省略 origin 远程,因为 origin 是默认远程,所以如果你省略远程,它仍然会使用默认的远程。

更新了我们的 GitHub 仓库,最后要做的事情就是部署到 Heroku,我们可以通过 Git push 将 Git 仓库推送到 Heroku 远程。当我们这样做时,我们会得到一长串日志,因为 Heroku 服务器正在安装我们的 npm 模块,构建应用程序,并实际部署它。一旦完成,我们将回到终端,然后可以在浏览器中打开 URL。现在我可以从这里复制它,或者运行 Heroku open。由于我已经在浏览器中打开了 URL,我只需刷新一下。现在你可能会在刷新应用程序时遇到一些延迟。有时,在部署新应用程序后立即启动应用程序可能需要大约 10 到 15 秒。这只会在第一次访问时发生。其他时候,当你点击刷新按钮时,它应该立即重新加载。

现在我们有了项目页面,如果我访问它,一切看起来都很棒。导航栏运行良好,项目页面确实在/projects处呈现。有了这个,我们现在完成了。我们已经完成了添加新功能、在本地测试、进行 Git 提交、推送到 GitHub 并部署到 Heroku 的过程。现在我们有了一个使用 Node.js 构建真实网络应用的工作流程。这也标志着本节的结束。

总结

你也学到了 Git、GitHub 和 Heroku。这些是我在创建应用程序时喜欢使用的工具。我喜欢使用 Git,因为它非常流行。这基本上是当今唯一的选择。我喜欢使用 GitHub,因为它有一个很棒的用户界面。它拥有大量令人惊叹的功能,几乎每个人都在使用它。有一个很棒的社区。我喜欢使用 Heroku,因为它非常简单,可以轻松部署应用程序的新版本。你可以用其他工具替换这些工具。你可以使用亚马逊网络服务等服务进行托管。你可以使用 Bitbucket 作为 GitHub 的替代品。这些都是完全可以接受的解决方案。真正重要的是你有一些适合你的工具,你有一个 Git 仓库在某个地方备份,无论是 GitHub 还是 Bitbucket,你有一个简单的部署方式,这样你就可以快速进行更改并将其快速推送给用户。

在不同的章节中,我们学习了如何将文件添加到 Git 以及如何进行第一次提交。接下来,我们设置了 GitHub 和 Heroku,然后学习了如何推送我们的代码并部署它。然后,我们学习了如何与 Heroku 通信以部署我们的代码。之后,我们学习了一些实际的工作流程,用于创建新的提交,推送到 GitHub,并部署到 Heroku。

在下一章中,我们将学习如何测试我们的应用程序。

第十章:测试 Node 应用程序-第一部分

在本章中,我们将看一下如何测试我们的代码,以确保它按预期工作。现在,如果您曾经为其他语言设置过测试用例,那么您就知道开始可能有多么困难。您必须设置实际的测试基础设施。然后您必须编写您的各个测试用例。每次我没有测试一个应用程序,都是因为设置过程和可用工具对我来说是如此繁重。然后您在网上搜索信息,您会得到一些非常简单的例子,但不是用于测试异步代码等真实世界事物的例子。我们将在本章中做所有这些。我将为您提供一个非常简单的测试设置和编写测试用例。

我们将会看一下最好的可用工具,这样你就会真正兴奋地编写这些测试用例,并看到所有那些绿色的勾号。从现在开始我们也会进行测试,所以让我们深入研究一下如何测试一些代码。

基本测试

在这一部分,您将创建您的第一个测试用例,以便测试您的代码是否按预期工作。通过将自动测试添加到我们的项目中,我们将能够验证函数是否按其所说的那样工作。如果我们创建一个应该将两个数字相加的函数,我们可以自动验证它是否正在执行这个操作。如果我们有一个应该从数据库中获取用户的函数,我们也可以确保它正在执行这个操作。

现在在本节中开始,我们将看一下在 Node.js 项目中设置测试套件的基础知识。我们将测试一个真实世界的函数。

安装测试模块

为了开始,我们将创建一个目录来存储本章的代码。我们将在桌面上使用mkdir创建一个目录,并将其命名为node-tests

mkdir node-tests

然后我们将使用cd更改其中的目录,这样我们就可以运行npm init。我们将安装模块,这将需要一个package.json文件:

cd node-tests

npm init

我们将使用默认值运行npm init,在每一步中只需简单地按下enter

现在一旦生成了package.json文件,我们就可以在 Atom 中打开该目录。它在桌面上,名为node-tests

从这里开始,我们准备实际定义我们想要测试的函数。本节的目标是学习如何为 Node 项目设置测试,因此我们将要测试的实际函数将会相当琐碎,但这将帮助说明如何设置我们的测试。

测试一个 Node 项目

让我们开始制作一个虚假模块。这个模块将有一些函数,我们将测试这些函数。在项目的根目录中,我们将创建一个全新的目录,我将把这个目录命名为utils

我们可以假设这将存储一些实用函数,比如将一个数字加到另一个数字上,或者从字符串中去除空格,任何不属于任何特定位置的混杂物。我们将在utils文件夹中创建一个名为utils.js的新文件,这与我们在上一章中创建weatherlocation目录时所做的类似模式:

您可能想知道为什么我们有一个同名的文件夹和文件。当我们开始测试时,这将变得清晰。

现在在我们可以编写我们的第一个测试用例来确保某些东西工作之前,我们需要有东西来测试。我将创建一个非常基本的函数,它接受两个数字并将它们相加。我们将创建一个如下所示的加法器函数:

module.exports.add = () => {

}

这个箭头函数(=>)将接受两个参数ab,在函数内部,我们将返回值a + b。这里没有太复杂的东西:

module.exports.add = () => {
  return a + b;
};

现在,由于我们在箭头函数(=>)内只有一个表达式,并且我们想要返回它,我们实际上可以使用箭头函数(=>)表达式语法,这使我们可以添加我们的表达式,如下面的代码所示,a + b,它将被隐式返回:

module.exports.add = (a, b) => a + b;

在函数上不需要显式添加return关键字。现在我们已经准备好utils.js,让我们来探索测试。

我们将使用一个名为 Mocha 的框架来设置我们的测试套件。这将让我们配置我们的单个测试用例,并运行所有的测试文件。这对于创建和运行测试非常重要。我们的目标是使测试变得简单,我们将使用 Mocha 来实现这一点。现在我们有了一个文件和一个我们真正想要测试的函数,让我们来探索如何创建和运行测试套件。

Mocha - 测试框架

我们将使用超级流行的测试框架 Mocha 进行测试,您可以在mochajs.org找到它。这是一个创建和运行测试套件的绝佳框架。它非常受欢迎,他们的页面上包含了有关设置、配置以及所有酷炫功能的所有信息:

如果您在此页面上滚动,您将能够看到目录:

在这里,您可以探索 Mocha 提供的所有功能。我们将在本章中涵盖大部分内容,但对于我们未涵盖的任何内容,我希望您知道您可以在此页面上了解到。

现在我们已经探索了 Mocha 文档页面,让我们安装它并开始使用它。在终端中,我们将安装 Mocha。首先,让我们清除终端输出。然后我们将使用npm install命令进行安装。当您使用npm install时,您也可以使用快捷方式npm i。这具有完全相同的效果。我将使用npm imocha,指定版本@3.0.0。这是拍摄时的最新版本:

npm i mocha@3.0.0

现在我们确实希望将其保存到package.json文件中。以前,我们使用了save标志,但我们将讨论一个新标志,称为save-devsave-dev标志将仅为开发目的保存此软件包 - 这正是 Mocha 的用途。我们实际上不需要 Mocha 在像 Heroku 这样的服务上运行我们的应用程序。我们只需要在本地机器上使用 Mocha 来测试我们的代码。

当您使用save-dev标志时,它会以相同的方式安装模块:

npm i mocha@5.0.0 --save-dev

但是,如果您查看package.json,您会发现情况有所不同。在我们的package.json文件中,我们有一个devDependencies属性,而不是一个 dependencies 属性:

在这里,我们有 Mocha,版本号作为值。devDependencies非常棒,因为它们不会安装在 Heroku 上,但它们将在本地安装。这将使 Heroku 的启动时间非常快。它不需要安装实际上不需要的模块。从现在开始,我们将在大多数项目中同时安装devDependenciesdependencies

为 add 函数创建一个测试文件

现在我们已经安装了 Mocha,我们可以继续创建一个测试文件。在utils文件夹中,我们将创建一个名为utils.test.js的新文件:

这个文件将存储我们的测试用例。我们不会将测试用例存储在utils.js中。这将是我们的应用程序代码。相反,我们将创建一个名为utils.test.js的文件。当我们使用这个test.js扩展名时,我们基本上告诉我们的应用程序,这将存储我们的测试用例。当 Mocha 在我们的应用程序中寻找要运行的测试时,它应该运行任何具有此扩展名的文件。

现在我们有一个测试文件,唯一剩下的事情就是创建一个测试用例。测试用例是运行一些代码的函数,如果一切顺利,测试被认为是通过的。如果事情不顺利,测试被认为是失败的。我们可以使用it创建一个新的测试用例。这是 Mocha 提供的一个函数。我们将通过 Mocha 运行我们的项目测试文件,所以没有理由导入它或做任何类似的事情。我们只需要像这样调用它:

it();

现在它让我们定义一个新的测试用例,并且它需要两个参数。这些是:

  • 第一个参数是一个字符串

  • 第二个参数是一个函数

首先,我们将有一个关于测试具体做什么的字符串描述。如果我们正在测试加法函数是否有效,我们可能会有类似以下的内容:

it('should add two numbers');

请注意这里与句子相符。它应该读起来像这样,it should add two numbers;准确描述了测试将验证的内容。这被称为行为驱动开发,或BDD,这是 Mocha 构建的原则。

现在我们已经设置了测试字符串,下一步是将一个函数添加为第二个参数:

it('should add two numbers', () => {

});

在这个函数内部,我们将添加测试 add 函数是否按预期工作的代码。这意味着它可能会调用add并检查返回的值是否是给定的两个数字的适当值。这意味着我们确实需要在顶部导入util.js文件。我们将创建一个常量,称为utils,将其设置为从utils中获取的返回结果。我们使用./,因为我们将要求一个本地文件。它在同一个目录中,所以我可以简单地输入utils而不需要js扩展名,如下所示:

const utils = require('./utils');

it('should add two numbers', () => {

});

现在我们已经加载了 utils 库,在回调函数内部我们可以调用它。让我们创建一个变量来存储返回的结果。我们将称之为 results。然后我们将它设置为utils.add,传入两个数字。让我们使用类似3311的数字:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);
});

我们期望得到44。现在在这一点上,我们的测试套件内确实有一些代码,所以我们运行它。我们将通过在package.json中配置我们在上一章中看到的测试脚本来实现这一点。

目前,测试脚本只是简单地在屏幕上打印一条消息,说没有测试存在。我们要做的是调用 Mocha。如下面的代码所示,我们将调用 Mocha,将我们想要测试的实际文件作为唯一的参数传递进去。我们可以使用通配符模式来指定多个文件。在这种情况下,我们将使用**来查找每个目录中的文件。我们正在寻找一个名为utils.test.js的文件:

"scripts": {
  "test": "mocha **/utils.test.js"
},

现在这是一个非常具体的模式。这不会特别有用。相反,我们也可以用星号替换文件名。现在我们正在寻找项目中以.test.js结尾的任何文件:

"scripts": {
  "test": "mocha **/*.test.js"
},

这正是我们想要的。从这里,我们可以通过保存package.json并转到终端来运行我们的测试套件。我们将使用clear命令来清除终端输出,然后我们可以运行我们的test脚本,使用如下所示的命令:

npm test

当我们运行这个时,我们将执行那个 Mocha 命令:

它会触发。它将获取我们所有的测试文件。它将运行所有这些文件,并在终端内打印结果,就像前面的截图中显示的那样。在这里,我们可以看到我们的测试旁边有一个绿色的勾号,should add two numbers。接下来,我们有一个小结,一个通过的测试,在 8 毫秒内完成。

现在在我们的情况下,我们实际上并没有断言关于返回的数字的任何内容。它可以是 700,我们也不在乎。测试将始终通过。要使测试失败,我们需要抛出一个错误。这意味着我们可以抛出一个新的错误,并将我们想要用作错误的消息传递给构造函数,如下面的代码块所示。在这种情况下,我可以说类似值不正确的内容:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);
  throw new Error('Value not correct')
});

现在有了这个,我可以保存测试文件,并从终端重新运行测试,通过重新运行npm test,现在我们有 0 个通过的测试和 1 个失败的测试:

接下来,我们可以看到一个测试是应该添加两个数字,我们得到了我们的错误消息,值不正确。当我们抛出一个新的错误时,测试失败了,这正是我们想要为add做的。

为测试创建 if 条件

现在,我们将为测试创建一个if语句。如果响应值不等于44,那意味着我们有麻烦了,我们将抛出一个错误:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  if (res != 44){

  }
});

if条件内部,我们可以抛出一个新的错误,我们将使用模板字符串作为我们的消息字符串,因为我确实想要在错误消息中使用返回的值。我会说Expected 44, but got,然后我会注入实际的值,无论发生什么:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  if (res != 44){
    throw new Error(`Expected 44, but got ${res}.`);
  }
});

现在在我们的情况下,一切都会很顺利。但是如果add方法没有正确工作会怎么样呢?让我们通过简单地添加另一个加法来模拟这种情况,在utils.js中添加上类似22的东西:

module.exports.add = (a, b) => a + b + 22;

我会保存文件,重新运行测试套件:

现在我们得到了一个错误消息:期望得到 44,但得到了 66。这个错误消息很棒。它让我们知道测试出了问题,甚至告诉我们确切得到了什么,以及我们期望得到了什么。这将让我们进入add函数,寻找错误,并希望修复它们。

创建测试用例不需要非常复杂。在这种情况下,我们有一个简单的测试用例,测试一个简单的函数。

测试平方函数

现在,我们将创建一个新的函数,它对一个数字进行平方并返回结果。我们将在utils.js文件中定义,使用module.exports.square。我们将把它设置为一个箭头函数(=>),它接受一个数字x,然后我们返回x乘以xx * x,就像这样:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

现在我们有了这个全新的square函数,我们将创建一个新的测试用例,确保square按预期工作。在utils.test.js中,在add函数的if条件旁边,我们将再次调用it函数:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  if (res != 44){
    throw new Error(`Expected 44, but got ${res}.`);
  }
});

it();

it函数内部,我们将添加我们的两个参数,字符串和回调函数。在字符串内部,我们将创建我们的消息,should square a number

it('should square a number', () => {

});

在回调函数内部,我们实际上可以继续调用square。现在我们确实想要创建一个变量来存储结果,以便我们可以检查结果是否符合预期。然后我们可以调用utils.square传入一个数字。在这种情况下,我会选择3,这意味着我应该期望返回9

it('should square a number', () => {
  var res = utils.square(3);
});

在下一行,我们可以有一个if语句,如果结果不等于9,那么我们会抛出一个消息,因为事情出错了:

it('should square a number', () => {
  var res = utils.square(3);

  if (res !== 9) {

  }
});

我们可以使用throw new Error抛出错误,传入任何我们喜欢的消息。我们可以使用普通字符串,但我总是更喜欢使用模板字符串,这样我们可以轻松地注入值。我会说类似于Expected 9, but got,后面跟着不正确的值;在这种情况下,这个值存储在响应变量中:

it('should square a number', () => {
  var res = utils.square(3);

  if (res !== 9) {
    throw new Error(`Expected 9, but got ${res}`);
  }
});

现在我可以保存这个测试用例,并从终端运行测试套件。使用上箭头键和enter键,我们可以重新运行上一个命令:

npm test

我们得到了两个通过的测试,应该添加两个数字和应该对一个数字进行平方都有对号。而且我们只用了 14 毫秒运行了两个测试,这太棒了。

现在,下一件事,我们想要做的是搞砸square函数,以确保当数字不正确时我们的测试失败。我会在utils.js中的结果上加1,这将导致测试失败:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x + 1;

然后我们可以从终端重新运行测试,我们应该会看到错误消息:

我们得到了预期的 9,但得到了 10。这太棒了。我们现在有一个能够测试add函数和square函数的测试套件。我将删除那个+ 1,然后我们就完成了。

我们现在有一个非常非常基本的测试套件,我们可以使用 Mocha 执行。目前,我们有两个测试,并且为了创建这些测试,我们使用了 Mocha 提供的it方法。在接下来的部分中,我们将探索 Mocha 给我们的更多方法,并且我们还将寻找更好的方法来进行断言。我们将使用一个断言库来帮助完成繁重的工作,而不是手动创建它们。

自动重新启动测试

在编写更多测试用例之前,让我们看一种自动重新运行测试套件的方法,当我们更改测试代码或应用程序代码时。我们将使用nodemon来实现这一点。现在,我们之前是这样使用nodemon的:

nodemon app.js

我们将输入nodemon,然后传入一个文件,如app.js。每当我们应用程序中的任何代码更改时,它将重新运行app.js文件作为 Node 应用程序。实际上,我们可以指定我们想要在文件更改时运行的世界上的任何命令。这意味着我们可以在文件更改时重新运行npm test

为此,我们将使用exec标志。此标志告诉nodemon我们将指定要运行的命令,它可能不一定是一个 Node 文件。如下命令所示,我们可以指定该命令。它将是'npm test'

nodemon --exec 'npm test'

如果您使用 Windows,请记住使用双引号代替单引号。

现在,我们可以运行nodemon命令。它将首次运行我们的测试套件:

在这里,我们看到有两个测试通过。让我们继续进入应用程序utils.js,并对其中一个函数进行更改,以便它失败。我们将为add的结果添加34

module.exports.add = (a, b) => a + b + 4;

module.exports.square = (x) => x * x;

它会自动重新启动:

现在我们看到我们有一个测试套件,其中一个测试通过,一个测试失败。我可以随时撤消我们添加的错误,保存文件,测试套件将自动重新运行。

这将使测试应用程序变得更加容易。每当我们对应用程序进行更改时,我们就不必切换到终端并重新运行npm test命令。现在我们有一个可以运行的命令,我们将关闭nodemon并使用上箭头键再次显示它。

我们实际上可以将其移入package.json中的一个脚本。

package.json中,我们将在测试脚本之后创建一个新的脚本。现在我们已经使用了start脚本和test脚本-这些是内置的-我们将创建一个名为test-watch的自定义脚本,并且我们可以运行test-watch脚本来启动。在test-watch中,我们将使用与终端中运行的完全相同的命令。这意味着我们将会使用nodemon。我们将使用exec标志,并在引号内运行npm test

"scripts": {
  "test": "mocha **/*.test.js",
  "test-watch": "nodemon --exec 'npm test'"
},

现在我们已经有了这个,我们可以从终端运行脚本,而不是每次启动自动测试套件时都要输入这个命令。

我们目前在package.json中拥有的脚本将在 macOS 和 Linux 上运行。它也将在使用 Linux 的 Heroku 上运行。但它在 Windows 上不起作用。以下脚本将起作用:

"test-watch": "nodemon --exec \"npm test\"".

如您所见,我们正在转义围绕npm test的引号,并且我们正在使用双引号,正如我们所知,这是 Windows 支持的唯一引号。此脚本将消除您看到的任何错误,例如找不到 npm,如果您将npm tests用单引号括起来并在 Windows 上运行脚本时会出现。因此,请使用上述脚本以实现跨操作系统的兼容性。

要在终端中运行具有自定义名称的脚本,例如test-watch,我们只需要运行npm run,然后是脚本名称test-watch,如下命令所示:

npm run test-watch

如果我这样做,它会启动。我们将得到我们的测试套件,它仍在等待变化,如下所示:

现在,每次你启动测试套件,你可以简单地使用npm run test-watch。这将启动test-watch脚本,它会启动nodemon。每当你的项目发生变化,它都会重新运行npm test,并将测试套件的结果显示在屏幕上。

现在我们有了一种自动重新启动测试套件的方法,让我们继续深入了解在 Node 中进行测试的具体内容。

在测试 Node 模块中使用断言库

在前面的部分,我们制作了两个测试用例来验证utils.add和我们的utils.square方法是否按预期工作。我们使用了一个if条件来做到这一点,也就是说,如果值不是44,那就意味着出了问题,我们就会抛出一个错误。在本节中,我们将学习如何使用一个断言库,它将为我们处理utils.test.js代码中的所有if条件:

if (res !== 44)
  throw new Error(`Expected 44, but got ${res}.`)
}

因为当我们添加越来越多的测试时,代码最终会变得非常相似,没有理由一直重写它。断言库让我们可以对值进行断言,无论是关于它们的类型,值本身,还是数组是否包含元素,诸如此类的各种事情。它们真的很棒。

我们将使用的是 expect。你可以通过谷歌搜索mjackson expect来找到它。这就是我们要找的结果:

这是 mjackson 的存储库,expect。这是一个很棒而且非常受欢迎的断言库。这个库让我们可以传递一个值并对其进行一些断言。在这个页面上,我们可以在介绍和安装之后滚动到一个例子:

如前面的截图所示,我们有我们的断言标题和我们的第一个断言,toExist。这将验证一个值是否存在。在下一行,我们有一个例子,我们将一个字符串传递给expect

这是我们想要对其进行一些断言的值。在我们的应用程序上下文中,这将是utils.test.js中的响应变量,如下所示:

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);
  if (res !== 44) {
    throw new Error(`Expected 44, but got ${res}.`)
  }
});

我们想要断言它是否等于44。在我们调用expect之后,我们可以开始链接一些断言调用。在下一个断言示例中,我们检查它是否存在:

expect('something truthy').toExist()

这不会抛出错误,因为在 JavaScript 中,字符串确实是真值。如果我们传入一些不是真值的东西,比如undefinedtoExist会失败。它会抛出一个错误,测试用例不会通过。使用这些断言,我们可以非常轻松地检查测试中的值,而不必自己编写所有的代码。

探索断言库

让我们继续开始探索断言库。首先,让我们在终端中运行npm install来安装模块。模块名本身叫做 expect,我们将获取最新版本@1.20.2。我们将再次使用save-dev标志,就像我们在 Mocha 中所做的那样。因为我们确实希望将这个依赖保存在package.json中,但它是一个dev依赖,不管是在 Heroku 还是其他服务上运行,都不是必需的:

npm install expect@1.20.2 --save-dev

expect库已经捐赠给了另一个组织。最新版本是 v21.1.0,与我们在这里使用的旧版本 1.20.2 不兼容。我希望你安装 1.20.2 版本,以确保在接下来的几节中使用。

让我们继续安装这个依赖。

然后我们可以转到应用程序,查看package.json文件,如下截图所示,看起来很棒:

我们既有 expect,又有 Mocha。现在,在我们的utils.test文件中,我们可以通过加载库并使用 expect 进行第一次断言来启动。在文件的顶部,我们将加载库,创建一个名为expect的常量,并require('expect'),就像这样:

const expect = require('expect');

现在,我们可以通过调用expect来替换utils.test.js代码中的if条件:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  // if(res !== 44) {
  //   throw new Error(`Expected 44, but got ${res}.`)
  //}
});

正如你在断言/expect 页面上的示例中看到的,我们将通过调用expect作为一个函数来开始所有的断言,传入我们想要进行断言的值。在这种情况下,那就是res变量:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  expect(res)
  // if(res !== 44) {
  //   throw new Error(`Expected 44, but got ${res}.`)
  //}
});

现在,我们可以断言各种事情。在这种情况下,我们想要断言该值等于44。我们将使用我们的断言toBe。在文档页面上,它看起来是这样的:

这断言一个值等于另一个值,这正是我们想要的。我们断言传入 expect 的值等于另一个值,使用toBe,将该值作为第一个参数传入。回到 Atom 中,我们可以使用这个断言.toBe,我们期望结果变量是数字44,就像这样:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  expect(res).toBe(44);
  // if(res !== 44) {
  //   throw new Error(`Expected 44, but got ${res}.`)
  //}
});

现在我们有了我们的测试用例,它应该与if条件一样正常工作。

为了证明它确实有效,让我们进入终端并使用clear命令来清除终端输出。现在我们可以运行test-watch脚本,如下命令所示:

npm run test-watch

如前面的代码输出所示,我们的两个测试都通过了,就像以前一样。现在,如果我们将44更改为像40这样的其他值,那么会抛出错误:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  expect(res).toBe(40);
  // if(res !== 44) {
  //   throw new Error(`Expected 44, but got ${res}.`)
  //}
});

我们保存文件,然后会得到一个错误,expect库将为我们生成有用的错误消息:

它说我们预期 44 是 40。显然这不是这样,所以会抛出一个错误。我将把它改回44,保存文件,所有的测试都会通过。

链接多个断言

现在我们也可以链接多个断言。例如,我们可以断言从add返回的值是一个数字。这可以使用另一个断言来完成。所以让我们进入文档看一看。在 Chrome 中,我们将浏览断言文档列表。有很多方法。我们将探索其中一些。在这种情况下,我们正在寻找toBeA,这个方法接受一个字符串:

这将使用字符串类型,并使用typeof运算符来断言该值是某种类型。在这里,我们期望2是一个数字。我们可以在我们的代码中做完全相同的事情。在 Atom 中,在toBe之后,我们可以链接另一个调用toBeA,然后是类型。这可能是字符串,也可能是对象,或者在我们的情况下,可能是一个数字,就像这样:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  expect(res).toBe(44).toBeA('number');
  // if(res !== 44) {
  //   throw new Error(`Expected 44, but got ${res}.`)
  //}
});

我们将打开终端,这样我们就可以看到结果。它目前是隐藏的。保存文件。我们的测试将重新运行,我们可以看到它们都通过了:

让我们使用一个不同的类型,例如会导致测试失败的字符串:

 expect(res).toBe(44).toBeA('string');

然后我们会得到一个错误消息,预期 44 是一个字符串:

这真的很有用。它将帮助我们快速清理错误。让我们把代码改回数字,然后就可以开始了。

对于 square 函数的多个断言

现在我们想为我们的平方数函数的测试做同样的事情。我们将使用expect来断言响应确实是数字9,并且类型是一个数字。我们将使用与add函数相同的这两个断言。首先,我们需要删除当前的平方if条件代码,因为我们将不再使用它。如下所示,我们将对res变量做一些期望。我们期望它是数字9,就像这样:

it('should square a number', () => {
  var res = utils.square(3);

  expect(res).toBe(9);
});

我们将保存文件并确保测试通过,它确实通过了:

现在,我们将使用toBeA来断言类型。在这里,我们正在检查square方法的返回值类型是否为数字:

it('should square a number', () => {
  var res = utils.square(3);

  expect(res).toBe(9).toBeA('number');
});

当我们保存文件时,我们仍然通过了我们的两个测试,这太棒了:

现在这只是一个关于expect能做什么的小测试。让我们创建一个虚假测试用例,探索一些我们可以使用expect的更多方式。我们将不会测试一个实际的函数。我们只是在it回调内部玩一些断言。

探索使用 expect 进行虚假测试

要创建虚假测试,我们将使用it回调函数创建一个新的测试:

it('should expect some values');

我们可以在这里放任何我们想要的东西,这并不太重要。我们将传入一个箭头函数(=>)作为我们的回调函数:

it('should expect some values', () => {

});

现在正如我们已经看到的,你将做的最基本的断言之一就是检查是否相等。我们想要检查类似响应变量是否等于其他东西,比如数字44。在expect内部,我们也可以做相反的事情。我们可以期望一个值像12不等于,使用toNotBe。然后我们可以断言它不等于其他值,比如11

it('should expect some values', () => {
  expect(12).toNotBe(11);
});

两者不相等,所以当我们在终端中保存文件时,所有三个测试都应该通过:

如果我将其设置为相同的值,它将无法按预期工作:

it('should expect some values', () => {
  expect(12).toNotBe(12);
});

我们会得到一个错误,预期 12 不等于 12:

现在toBetoNotBe对于数字、字符串和布尔值效果很好,但是如果你试图比较数组或对象,它们将无法按预期工作,我们可以证明这一点。

使用 toBe 和 toNotBe 比较数组/对象

我们将从注释掉当前代码开始。我们将保留它,以便稍后使用:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
});

我们将expect一个具有name属性设置为Andrew的对象,toBe,并且我们将断言它是另一个具有 name 属性等于Andrew的对象,就像这样:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  expect({name: 'Andrew'})
});

我们将使用toBe,就像我们用number一样,检查它是否与另一个 name 等于Andrew的对象相同:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  expect({name: 'Andrew'}).toBe({name: 'Andrew'});
});

现在当我们保存这个文件时,你可能会认为测试会通过,但它并没有:

如前面的输出所示,我们看到我们期望这两个名称相等。当使用三重等号进行对象比较时,也就是toBe使用的方式,它们不会相同,因为它试图看它们是否是完全相同的对象,而它们不是。我们创建了两个具有相同属性的单独对象。

使用 toEqual 和 toNotEqual 断言

要检查这两个名称是否相等,我们将不得不使用不同的东西。它被称为toEqual,如下所示:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  expect({name: 'Andrew'}).toEqual({name: 'Andrew'});
});

如果我们现在保存文件,这将起作用。它将深入对象属性,确保它们具有相同的属性:

toNotEqual也是一样的。这检查两个对象是否不相等。为了检查这一点,我们将继续并将第一个对象更改为andrew中的小写 a:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
});

现在,测试通过了。它们不相等:

这是我们如何对我们的对象和数组进行相等性比较的方式。现在我们还有一个非常有用的东西,那就是toInclude

使用 toInclude 和 toExclude

toInclude断言检查数组或对象是否包含一些东西。如果是数组,我们可以检查数组中是否包含某个项目。如果是对象,我们可以检查对象是否包含某些属性。让我们通过一个例子来运行一下。

我们期望在it回调中有一个包含数字234的数组包含数字5,我们可以使用toInclude来做到这一点:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  expect([2,3,4]).toInclude(5);
});

toInclude断言接受项目。在这种情况下,我们将检查数组中是否包含5。现在显然它没有,所以这个测试将失败:

我们得到消息,期望[ 2, 3, 4]包括 5。那不存在。现在我们把这个改成一个存在的数字,比如2

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  expect([2,3,4]).toInclude(2);
});

我们将重新运行测试套件,一切都将按预期工作:

现在,除了toInclude,我们还有toExclude,就像这样:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  expect([2,3,4]).toExclude(1);
});

这将检查某些东西是否不存在,例如数字1,它不在数组中。如果我们运行这个断言,测试通过:

同样的两种方法,toIncludetoExclude,也适用于对象。我们可以在下一行直接使用。我期望以下对象有一些东西:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  // expect([2,3,4]).toExclude(1);
  expect({

  })
});

让我们继续创建一个具有一些属性的对象。这些是:

  • name:我们将把它设置为任何名字,比如Andrew

  • age:我们将把它设置为年龄,比如25

  • location:我们将把它设置为任何位置,例如Philadelphia

这将看起来像以下的代码块:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  // expect([2,3,4]).toExclude(1);
  expect({
    name: 'Andrew',
 age: 25,
 location: 'Philadelphia'
  })
});

现在假设我们想对特定属性做一些断言,而不一定是整个对象。我们可以使用toInclude来断言对象是否具有某些属性,并且这些属性的值等于我们传入的值:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  // expect([2,3,4]).toExclude(1);
  expect({
    name: 'Andrew',
    age: 25,
    location: 'Philadelphia'
  }).toInclude({

 })
});

例如,age属性。假设我们只关心年龄。我们可以断言对象具有一个等于25age属性,方法是输入以下代码:

it('should expect some values', () => {
  // expect(12).toNotBe(12);
  // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
  // expect([2,3,4]).toExclude(1);
  expect({
    name: 'Andrew',
    age: 25,
    location: 'Philadelphia'
  }).toInclude({
    age: 25
  })
});

name属性无关紧要。name属性可以是任何值。这在这个断言中是无关紧要的。现在让我们使用值23

.toInclude({
    age: 23
  })

由于值不正确,这个测试将失败:

我们期望age属性是23,但实际上是25,所以测试失败。toExclude断言也是一样的。

在这里我们可以保存我们的测试文件。这检查对象是否没有一个等于23的属性 age。它确实没有,所以测试通过:

这只是对 expect 能做什么的一个快速了解。关于功能的完整列表,我建议浏览文档。还有很多其他断言可以使用,比如检查一个数字是否大于另一个数字,一个数字是否小于或等于另一个数字,还包括各种与数学相关的操作。

测试 setName 方法

现在让我们用一些更多的测试来结束这一节。在utils.js中,我们可以创建一个新的函数,一个我们将要测试的函数,module.exports.setNamesetName函数将接受两个参数。它将接受一个user对象,一个具有一些通用属性的虚构用户对象,它将接受一个字符串fullName

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

module.exports.setName (user, fullName)

setName的工作将是将fullName分成两部分——名字和姓氏——通过在空格上分割它。我们将设置两个属性,名字和姓氏,并返回user对象。我们将填写函数,然后编写测试用例。

我们将首先将名字分割成一个names数组,var names将是那个数组:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

module.exports.setName (user, fullName) => {
  var names
};

它将有两个值,假设名称中只有一个空格。我们假设有人输入他们的名字,敲击空格,然后输入他们的姓氏。我们将把这个设置为fullName.split,然后我们将在空格上分割。所以我将传入一个包含空格的空字符串作为分割的值:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

module.exports.setName (user, fullName) => {
  var names = fullName.split(' ');
};

现在我们有一个names数组,其中第一项是firstName,最后一项是lastName。所以我们可以开始更新user对象。user.firstName将等于names数组中的第一项,我们将获取索引0,这是第一项。我们将对lastName做类似的操作,user.lastName等于names数组的第二项:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

module.exports.setName (user, fullName) => {
  var names = fullName.split(' ');
  user.firstName = names[0];
  user.lastName = names[1];
};

现在我们已经完成了,我们已经设置了名称,并且我们可以返回user对象,就像这样使用return user:

module.exports.add = (a, b) => a + b;

module.exports.square = (x) => x * x;

module.exports.setName (user, fullName) => {
  var names = fullName.split(' ');
  user.firstName = names[0];
  user.lastName = names[1];
  return user;
};

utils.test文件中,我们现在可以开始。首先,我们将注释掉我们的it('should expect some values')处理程序:

const expect = require('expect');

const utils = require('./utils');

it('should add two numbers', () => {
  var res = utils.add(33, 11);

  expect(res).toBe(44).toBeA('number');
});

it('should square a number', () => {
  var res = utils.square(3);

  expect(res).toBe(9).toBeA('number');
});

// it('should expect some values', () => {
//   // expect(12).toNotBe(12);
//   // expect({name: 'andrew'}).toNotEqual({name: 'Andrew'});
//   // expect([2,3,4]).toExclude(1);
//   expect({
//      name: 'Andrew',
//      age: 25,
//      location: 'Philadelphia'
//    }).toExclude({
//      age: 23
//    })
//  });

这对于文档来说非常棒。如果您忘记了事情是如何工作的,您随时可以稍后探索它。我们将创建一个新的测试,应该验证名字和姓氏是否已设置。

我们将创建一个user对象。在该user对象上,我们想设置一些属性,如agelocation。然后我们将变量user传递给setName方法。这将是utils.js文件中定义的第一个参数。我们将传入一个字符串。这个字符串是firstName后面跟着一个空格,然后是lastName。然后我们将得到结果,并对其进行一些断言。我们想要断言返回的对象是否包含使用toInclude断言。

如下所示的代码,我们将调用它来创建新的测试用例。我们将测试:

it('should set firstName and lastName')

it中,我们现在可以提供我们的第二个参数,这将是我们的回调函数。让我们将其设置为箭头函数(=>),现在我们可以创建user对象:

it('should set firstName and lastName', () => {

});

user对象将有一些属性。让我们添加一些像location的东西,将其设置为Philadelphia,然后设置一个age属性,将其设置为25

it('should set firstName and lastName', () => {
  var user = {location: 'Philadelphia', age: 25};
});

现在我们将调用我们在utils.js中定义的方法,即setName方法。我们将在下一行执行这个操作,创建一个名为res的变量来存储响应。然后我们将把它设置为utils.setName,传入两个参数,即user对象和fullNameAndrew Mead

it('should set firstName and lastName', () => {
  var user = {location: 'Philadelphia', age: 25};
  var res = utils.setName(user, 'Andrew Mead');
});

现在在这一点上,结果应该是我们期望的。我们应该有firstNamelastName属性。我们应该有location属性和age属性。

现在,如果您对 JavaScript 了解很多,您可能知道对象是按引用传递的,因此user变量实际上也已经更新了。这是预期的。userres将具有完全相同的值。我们实际上可以继续使用断言来证明这一点。我们将expect user等于res使用toEqual

it('should set firstName and lastName', () => {
  var user = {location: 'Philadelphia', age: 25};
  var res = utils.setName(user, 'Andrew Mead');

  expect(user).toEqual(res);
});

在终端中,我们可以看到测试确实通过了:

让我们删除expect(user).toEqual(res);。现在,我们想要检查user对象或res对象是否包含某些属性。我们将使用expect来检查res变量是否具有某些属性,使用toInclude

it('should set firstName and lastName', () => {
  var user = {location: 'Philadelphia', age: 25};
  var res = utils.setName(user, 'Andrew Mead');

  expect(res).toInclude({

 })
});

我们要查找的属性是firstName等于我们期望的值,即Andrew,以及lastName等于Mead

it('should set firstName and lastName', () => {
  var user = {location: 'Philadelphia', age: 25};
  var res = utils.setName(user, 'Andrew Mead');

  expect(res).toInclude({
    firstName: 'Andrew',
 lastName: 'Mead'
  })
});

这些是应该进行的断言,以验证setName是否按预期工作。如果我保存文件,test套件将重新运行,我们确实得到了通过的测试,如下所示:

我们有三个,只用了 10 毫秒来运行。

有了这个,我们现在为我们的test套件创建了一个断言库。这太棒了,因为编写测试用例变得更加容易,整个章节的目标是使测试变得易于接近和简单。

在下一节中,我们将开始看如何测试更复杂的异步函数。

异步测试

在这一部分,您将学习如何测试异步函数。测试异步函数的过程与测试同步函数并没有太大不同,就像我们已经做过的那样,但是有一点不同,所以它有自己的部分。

使用 setTimeout 对象创建 asyncAdd 函数

首先,我们将使用setTimeout创建一个虚拟的async函数,以模拟utils.js中的延迟。就在我们创建add函数的下面,让我们创建一个叫做asyncAdd的函数。它基本上具有相同的特性,但它将使用setTimeout,并且它将有一个回调来模拟延迟。现在在现实世界中,这种延迟可能是数据库请求或 HTTP 请求。我们将在接下来的章节中处理这个问题。不过,现在让我们添加module.exports.asyncAdd

module.exports.add = (a, b) => a + b;

module.exports.asyncAdd = ()

这将需要三个参数,而不是add函数所需的两个参数,abcallback

module.exports.add = (a, b) => a + b;

module.exports.asyncAdd = (a, b, callback)

这就是使函数异步的原因。最终,一旦setTimeout结束,我们将调用回调函数并传递总和,无论是 1 加 3 得到 4,还是 5 加 9 得到 14。接下来,我们可以在箭头函数(=>)中放置箭头并打开和关闭大括号:

module.exports.asyncAdd = (a, b, callback) => {

};

如上所述,在箭头函数(=>)中,我们将使用setTimeout来创建延迟。我们将传递一个回调和我们的setTimeout。在这种情况下,我们将使用 1 秒:

module.exports.asyncAdd = (a, b, callback) => {
  setTimeout(() => {

  }, 1000);
};

默认情况下,如果我们的测试时间超过 2 秒,Mocha 将认为这不是我们想要的,它将失败。这就是为什么我们在这种情况下使用 1 秒的原因。在我们的回调中,我们可以调用实际的callback参数,使用和b的和a,就像这样:

module.exports.asyncAdd = (a, b, callback) => {
  setTimeout(() => {
    callback(a + b);
  }, 1000);
};

现在我们有了一个asyncAdd函数,我们可以开始为它编写测试了。

为 asyncAdd 函数编写测试

utils.test文件中,就在我们之前对utils.add的测试下面,我们将为asyncAdd添加一个新的测试。测试设置看起来非常相似。我们将调用it并传递一个字符串作为第一个参数,传递一个回调作为第二个参数。然后我们将添加我们的回调,就像这样:

it('should async add two numbers', () => {

});

在回调中,我们可以开始调用utils.asyncAdd。我们将使用utils.asyncAdd调用它,并传入这三个参数。我们将使用43,这应该得到7。我们将提供回调函数,它应该被调用并传递该值,该值为7

it('should async add two numbers', () => {
  utils.asyncAdd(4, 3, () => {

  });
});

在回调参数中,我们期望像sum这样的东西返回:

it('should async add two numbers', () => {
  utils.asyncAdd(4, 3, (sum) => {

  });
});

对 asyncAdd 函数进行断言

现在我们可以开始对sum变量进行一些断言,使用expect对象。我们可以将它传递给expect来进行我们的断言,这些断言并不是新的。这是我们已经做过的事情。我们将expect sum变量等于数字7,使用toBe,然后我们将检查它是否是一个数字,使用toBeA,在引号内,number

it('should async add two numbers', () => {
  utils.asyncAdd(4, 3, (sum) => {
    expect(sum).toBe(7).toBeA('number');
  });
});

显然,如果它等于7,那就意味着它是一个数字,但我们两者都使用只是为了模拟我们的期望调用内部链式调用的工作原理。

现在我们的断言已经就位,让我们保存文件并运行测试,看看会发生什么。我们将从终端运行它,npm run test-watch来启动我们的nodemon监视脚本:

npm run test-watch

现在我们的测试将运行,测试确实通过了:

唯一的问题是它通过了错误的原因。如果我们将7更改为10并保存文件:

it('should async add two numbers', () => {
  utils.asyncAdd(4, 3, (sum) => {
    expect(sum).toBe(10).toBeA('number');
  });
});

在这种情况下,测试仍然会通过。在这里,您可以看到我们有四个测试通过:

添加 done 参数

现在,这个测试通过的原因不是因为utils.test.js中的断言是有效的。它通过是因为我们有一个需要 1 秒的异步操作。这个函数将在async回调被触发之前返回。当我说函数返回时,我指的是callback函数,即it的第二个参数。

这是 Mocha 认为你的测试已经完成的时候。这意味着这些断言永远不会运行。Mocha 输出已经说我们的测试通过了,然后才会触发这个回调。我们需要做的是告诉 Mocha 这将是一个需要时间的异步测试。为了做到这一点,我们只需在传递给它的回调函数内提供一个参数。我们将称之为done

it('should async add two numbers', (done) => {

当我们指定了done参数时,Mocha 知道这意味着我们有一个异步测试,并且它不会完成处理此测试,直到调用done。这意味着我们可以在断言之后调用done

it('should async add two numbers', (done) => {
  utils.asyncAdd(4, 3, (sum) => {
    expect(sum).toBe(10).toBeA('number');
    done();
  });
});

有了这个,我们的测试现在将运行。函数在调用async.Add后将立即返回,但这没关系,因为我们已经指定了done。大约一秒钟后,我们的回调函数将触发。在asyncAdd回调函数内部,我们将进行断言。这次断言将很重要,因为我们有done,而且我们还没有调用它。在断言之后,我们调用 done,这告诉 Mocha 我们已经完成了测试。它可以继续处理结果,让我们知道它是通过还是失败。这将修复那个错误。

如果我保存文件在这个状态下,它将重新运行测试,我们将看到我们的测试应该async.Add两个数字确实失败。在终端中,让我们打开错误消息,我们预期的是 7 是 10:

这正是我们第一次没有使用done时认为会发生的情况,但正如我们所看到的,当我们在测试中进行异步操作时,我们确实需要使用done

现在我们可以将这个期望改回7,保存文件:

it('should async add two numbers', (done) => {
  utils.asyncAdd(4, 3, (sum) => {
    expect(sum).toBe(7).toBeA('number');
    done();
  });
});

这一次事情应该按预期工作,1 秒延迟后运行此测试:

它不能立即报告,因为它必须等待 done 被调用。请注意,我们的总测试时间现在大约是一秒。我们可以看到我们有四个测试通过。Mocha 还在测试花费很长时间时警告我们,因为它认为这是不正常的。即使是 Node 中的任何东西,甚至是数据库或 HTTP 请求,也不应该花费接近一秒的时间,所以它基本上是在告诉我们,你的函数中可能有错误——它花费了非常非常长的时间来处理。但在我们的情况下,一秒的延迟显然是在utils中清楚地设置的,所以不需要担心那个警告。

有了这个,我们现在有了我们的第一个异步方法的测试。我们所要做的就是添加一个done作为参数,并在完成断言后调用它。

square函数的异步测试

现在让我们创建square方法的异步版本,就像我们用同步方法一样。为了开始,我们将首先定义函数,然后我们将担心编写测试。

创建异步平方函数

utils文件中,我们可以在square方法旁边开始创建一个名为asyncSquare的新方法:

module.exports.square = (x) => x * x;

module.exports.asyncSquare

它将需要两个参数:我们称之为x的原始参数,以及在 1 秒延迟后将被调用的callback函数:

module.exports.square = (x) => x * x;

module.exports.asyncSquare = (x, callback) => {

};

然后我们可以完成箭头函数(=>),然后开始编写asyncSquare的主体。它看起来与asyncAdd很相似。我们将调用setTimeout传递一个回调和一个延迟。在这种情况下,延迟将是相同的;我们将使用 1 秒:

module.exports.square = (x) => x * x;

module.exports.asyncSquare = (x, callback) => {
  setTimeout(() => {

  }, 1000);
};

现在我们可以实际调用回调。这将触发传入的callback函数,并且我们将传入值x乘以x,这将正确地平方替代x的数字:

module.exports.square = (x) => x * x;

module.exports.asyncSquare = (x, callback) => {
  setTimeout(() => {
    callback(x * x);
  }, 1000);
};

编写asyncSquare的测试

现在在test文件中,事情确实通过了,但我们还没有为asyncSquare函数添加测试,所以让我们这样做。在utils.test文件中,您需要做的下一件事是调用it。在测试asyncAdd函数旁边,让我们调用it来为这个asyncSquare函数创建一个新的测试:

it('should square a number', () => {
  var res = utils.square(3);

  expect(res).toBe(9).toBeA('number');
});

it('should async square a number')

接下来,我们将提供回调函数,当测试实际执行时将调用该函数。由于我们正在测试一个async函数,我们将在回调函数中放置done,如下所示:

it('should async square a number', (done) => {

});

这将告诉 Mocha 等到调用done后才决定测试是否通过。接下来,我们现在可以调用utils.asyncSquare,传入我们选择的一个数字。我们将使用5。接下来,我们可以传入一个回调函数:

it('should async square a number', (done) => {
  utils.asyncSquare(5, () => {

  })
});

这将得到最终结果。在箭头函数(=>)中,我们将创建一个变量来存储该结果:

 utils.asyncSquare(5, (res) => {

  });

现在我们有了这个,我们可以开始进行断言。

asyncSquare函数进行断言

断言将使用expect库完成。我们将对res变量进行一些断言。我们将使用toBe断言它等于数字25,即5乘以5。我们还将使用toBeA来断言关于值类型的一些内容:

it('should async square a number', (done) => {
  utils.asyncSquare(5, (res) => {
    expect(res).toBe(25).toBeA('number');
  });
});

在这种情况下,我们希望确保square确实是一个数字,而不是布尔值、字符串或对象。有了这个,我们确实需要调用done,然后保存文件:

it('should async square a number', (done) => {
  utils.asyncSquare(5, (res) => {
    expect(res).toBe(25).toBeA('number');
    done();
  });
});

请记住,如果您不调用done,您的测试将永远不会完成。您可能会发现偶尔会在终端内出现这样的错误:

您收到了一个错误超时,超过了 2,000 毫秒。这是 Mocha 中断您的测试。如果您看到这个,通常意味着两件事:

  • 您有一个async函数,实际上从未调用回调函数,因此您对done的调用从未被触发。

  • 你从未调用过done

如果您看到此消息,通常意味着async函数中某处有小错误。要克服这一点,要么通过确保调用回调来修复方法(utils.js)中的问题,要么通过调用done来修复测试(utils.test.js)中的问题,然后保存文件,您现在应该看到所有测试都通过了。

在我们的案例中,有 5 个测试通过,用了 2 秒钟。这太棒了:

现在我们有了测试同步函数和异步函数的方法。这将使测试更加灵活。它将让我们测试应用程序中的几乎所有内容。

总结

在本章中,我们研究了同步和异步函数的测试。我们研究了基本测试。我们探索了测试框架 Mocha。然后,我们研究了在测试 Node 模块中使用断言库。

在下一章中,我们将看看如何测试我们的 Express 应用程序。

第十一章:测试 Node 应用程序-第二部分

在本章中,我们将继续测试 Node 应用程序的旅程。在上一章中,我们看了基本的测试框架,并且处理了同步和异步的 Node 应用程序。在本章中,我们将继续测试 Express 应用程序,然后我们将研究一种方法来更好地组织我们的测试结果输出,最后但并非最不重要的是,我们将进入一些高级的测试 Node 应用程序的方法。

具体来说,我们将研究以下主题:

  • 为 Express 应用程序设置测试

  • 测试 Express 应用程序

  • 使用describe()组织测试

  • 测试间谍

测试 Express 应用程序

在这一部分,我们将设置一个 Express 应用程序,然后,我们将看看如何测试它,以验证从我们的路由返回的数据是否是用户应该得到的。现在,在我们做任何事情之前,我们需要创建一个 Express 服务器,这就是本节的目标。

为 Express 应用程序设置测试

我们将从安装 Express 开始。我们将使用npm i,这是安装的缩写,来安装 Express。记住,你总是可以用i替换install。我们将获取最新版本@4.16.2。现在,我们将使用save标志,而不是我们在上一章中用于测试的save dev标志:

npm i express@4.16.2 --save

这个命令将安装 Express 作为常规依赖项,这正是我们想要的:

我们在部署到生产环境时需要 Express,无论是 Heroku 还是其他一些服务。

回到应用程序内部,如果我们打开package.json,我们可以看到我们之前见过的依赖项,以及对我们来说是新的devDependencies

  "devDependencies": {
    "expect": "¹.20.2",
    "mocha": "³.0.0"
  },
  "dependencies": {
    "express": "⁴.14.0"
  }
}

这就是我们如何分解不同的依赖关系。从这里开始,我们将在项目的根目录下创建一个server文件夹,我们可以在其中存储服务器示例以及测试文件。我们将创建一个名为server的目录。然后在server内,我们将创建一个名为server.js的文件。

server.js文件将包含启动服务器的实际代码。我们将定义我们的路由,我们将监听一个端口,所有这些都将在这里发生。这就是我们在以前的服务器章节中所做的。在server.js中,我们将添加const express,并将其等于require('express')的返回结果:

const express = require('express');

接下来,我们可以通过创建一个名为app的变量并将其设置为对express的调用来制作我们的应用程序:

const express = require('express');

var app = express();

然后我们可以开始配置我们的路由。让我们为本节设置一个app.get

const express = require('express');

var app = express();

app.get

这将设置一个 HTTP GET 处理程序。URL 将仅为/(正斜杠),即网站的根目录。当有人请求时,我们暂时将指定一个非常简单的字符串作为返回结果。我们像对所有的express路由一样获得请求和响应对象。要响应,我们将调用res.send,发送字符串Hello World!作为返回结果:

app.get('/', (req, res) => {
  res.send('Hello world!');
});

在这个过程的最后一步将是使用app.listen监听一个端口。我们将通过将其作为第一个且唯一的参数传递来绑定到端口3000

app.get('/', (req, res) => {
  res.send('Hello world!');
});

app.listen(3000);

有了这个,我们现在完成了。我们有了一个基本的 Express 服务器。在我们继续探索如何测试这些路由之前,让我们启动它。我们将使用以下命令来做到这一点:

node server/server.js

当我们运行这个时,我们不会得到任何日志,因为我们还没有为服务器启动添加回调函数,但它确实已经启动了。

如果我们转到 Chrome 并访问localhost:3000,我们会看到 Hello world!打印到屏幕上:

现在,我们准备好开始测试我们的 Express 应用程序了。

使用 SuperTest 测试 Express 应用程序

现在,我们将学习一种简单而直接的方法来测试我们的 Express 应用程序。这意味着我们可以验证当我们向/URL 发出 HTTP GET 请求时,我们会得到Hello world!的响应。

传统上,测试 HTTP 应用程序一直是最难测试的事情之一。我们需要启动一个服务器,就像我们在上一节中所做的那样。然后我们需要一些代码来实际向适当的 URL 发出请求。然后我们必须浏览响应,获取我们想要的内容,并对其进行断言,无论是头部、状态码、正文还是其他任何内容。这是一个真正的负担。这不是本节的目标。我们的目标是使测试变得简单和易于接近,所以我们将使用一个名为 SuperTest 的库来测试我们的 Express 应用程序。

SuperTest 是由最初创建 Express 的开发人员创建的。它内置支持 Express,使得测试 Express 应用程序变得非常简单。

SuperTest 文档

为了开始,让我们打开文档页面,这样你就知道它在哪里,如果你想查看它提供的任何其他功能。如果你谷歌supertest,它应该是第一个结果:

这是 VisionMedia 的存储库,存储库本身称为 SuperTest。让我们切换到存储库页面,我们可以快速看一下它提供了什么。在这个页面上,我们可以找到安装说明和介绍内容。我们不真的需要那个。让我们快速看一个例子:

如前面的截图所示,我们可以看到 SuperTest 的工作示例。我们创建一个 Express 应用程序,就像我们通常做的那样,并定义一个路由。然后我们调用request方法,这是由 SuperTest 提供的,传入我们的 Express 应用程序。我们说我们要对/ URL 进行get请求。然后我们开始进行断言。无需手动检查头部、状态码或正文。它对所有这些都有内置的断言。

为 Express 应用程序创建测试

为了开始,我们将通过在终端中运行 npm install 来在我们的应用程序中安装 SuperTest。我们的 Node 服务器仍在运行。让我们关闭它,然后安装该模块。

我们可以使用npm i,模块名称是supertest,我们将获取最新版本@2.0.0。这是一个专门用于测试的模块,所以我们将使用save来安装它。我们将使用save-dev将其添加到package.json中的devDependencies中:

npm i supertest@3.0.0 --save-dev

安装了 SuperTest 后,我们现在准备在server.test.js文件上工作。因为它还不存在于server文件夹中,所以我们可以创建它。它将与server.js并排放置:

现在我们有了server.test.js,我们可以开始设置我们的第一个测试。首先,我们将创建一个名为 request 的常量,并将其设置为从supertest中导入的返回结果:

const request = require('supertest');

这是我们将用来测试我们的 Express 应用程序的主要方法。从这里,我们可以加载 Express 应用程序。现在在server.js中,我们没有导出导出应用程序的导出,所以我们需要添加。我将在app.listen语句旁边添加,通过创建module.exports.app并将其设置为app变量:

app.listen(3000);
module.exports.app = app;

现在我们有一个名为 app 的导出,我们可以从其他文件中访问。当我们从终端启动时,server.js仍将按预期运行,而不是在测试模式下。我们只是添加了一个导出,所以如果有人需要它,他们可以访问该应用程序。在server.test.js中,我们将创建一个变量来导入这个。我们将调用变量app。然后我们将使用require('./server.js')或者只是server来进行导入。然后我们将访问.app属性:

const request = require('supertest');

var app = require('./server').app;

有了这个,我们现在拥有了编写我们的第一个测试所需的一切。

为 Express 应用程序编写测试

我们将编写的第一个测试是验证当我们对/ URL 进行 HTTP GET 请求时,我们会得到Hello world!。为此,我们将调用it,就像我们在上一章中为其他测试所做的那样。我们仍然使用mocha作为实际的测试框架。我们使用 SuperTest 来填补空白:

var app = require('./server').app;

it('should return hello world response')

现在我们将设置函数如下:

it('should return hello world response', (done) => {

});

这将是一个异步调用,所以我正在提供done作为参数,让mocha知道在确定测试是否通过或失败之前等待。从这里,我们现在可以对request进行第一次调用。要使用 SuperTest,我们调用request并传入实际的 Express 应用程序。在这种情况下,我们传入app变量:

it('should return hello world response', (done) => {
  request(app)
});

然后我们可以开始链接所有我们需要的方法来发出请求,做出断言,最后结束。首先,您将使用一个方法来实际发出请求,无论是getputdelete还是post

现在,我们将发出一个get请求,所以我们将使用.get.get请求需要 URL。因此,我们将提供/(斜杠),就像我们在server.js中所做的那样:

it('should return hello world response', (done) => {
  request(app)
    .get('/')
});

接下来,我们可以做一些断言。要进行断言,我们将使用.expect。现在.expect是其中一种方法,根据您传递给它的内容而执行不同的操作。在我们的情况下,我们将传递一个字符串。让我们传递一个字符串,这将是我们断言的响应主体,Hello world!

it('should return hello world response', (done) => {
  request(app)
    .get('/')
    .expect('Hello world!')
});

现在我们已经完成并做出了断言,我们可以结束了。要在 SuperTest 中结束一个请求,我们所做的就是调用.end并传入done作为回调:

it('should return hello world response', (done) => {
  request(app)
    .get('/')
    .expect('Hello world!')
    .end(done);
});

这一切都是在幕后处理的,所以您不需要在以后手动调用done。所有这些都由 SuperTest 处理。通过这四行代码(在上一段代码中),我们已经成功测试了我们的第一个 API 请求。

测试我们的第一个 API 请求

我们将在终端中通过运行我们的test-watch脚本来开始:

npm run test-watch

测试脚本将开始运行,如图所示,我们有一些测试:

我们的测试应该返回 hello world 响应显示在上一张截图中。

现在我们可以进一步进行其他关于返回数据的断言。例如,我们可以在server.test.js中的.get请求之后使用expect来对状态码进行断言。默认情况下,我们所有的 Express 调用都将返回200状态码,这意味着事情进行得很顺利:

it('should return hello world response', (done) => {
  request(app)
    .get('/')
    .expect(200)
    .expect('Hello world!')
    .end(done);
});

如果我们保存文件,测试仍然通过:

现在让我们对请求进行一些更改,使这些测试失败。首先,在server.js中,我们将在字符串中添加几个字符(ww),然后保存文件:

app.get('/', (req, res) => {
  res.send('Hello wwworld!');
});

app.listen(3000);
module.exports.app = app;

这应该导致 SuperTest 测试失败,确实如此:

如上一张截图所示,我们得到了一条消息,expected 'Hello world!' response body, but we got 'Hello wwworld!'。这让我们知道发生了什么。回到server.js中,我们可以删除那些额外的字符(ww)并尝试其他方法。

设置自定义状态

现在我们还没有讨论如何为我们的响应设置自定义状态,但我们可以使用一个方法.status来实现。让我们在server.js中添加.status,在send('Hello world!')之前链接它,就像这样:

app.get('/', (req, res) => {
  res.status().send('Hello world!');
});

然后,我们可以传入数字状态码。例如,我们可以使用404表示页面未找到:

app.get('/', (req, res) => {
  res.status(404).send('Hello world!');
});

如果这次保存文件,主体将匹配,但在终端内我们可以看到我们现在有一个不同的错误:

我们期望得到一个200,但我们得到了一个404。使用 SuperTest,我们可以对我们的应用程序进行各种断言。现在对于不同类型的响应也是如此。例如,我们可以创建一个对象作为响应。让我们创建一个简单的对象,然后创建一个名为error的属性。然后我们将error设置为一个404的通用错误消息,比如Page not found

app.get('/', (req, res) => {
  res.status(404).send({
    error: 'Page not found.'
  });
});

现在,我们发送了一个 JSON body,但目前我们没有对这个 body 做任何断言,所以测试将失败:

我们可以更新我们的测试来expect JSON 返回。为了完成这个目标,我们只需要在server.test中改变我们传递给expect的内容。我们不再传递一个字符串,而是传递一个对象:

it('should return hello world response', (done) => {
  request(app)
    .get('/')
    .expect(200)
    .expect({

    })
    .end(done);
});

现在我们可以精确匹配这个对象。在对象内部,我们将expect错误属性存在,并且它等于我们在server.js中的内容:

    .expect({
      error: 'Page not found.'
    })

然后,我们将.expect调用从200更改为404

    .expect(404)
    .expect({
    error: 'Page not found.'
    })

有了这个,我们的断言现在与我们在 Express 应用程序中定义的实际端点相匹配。让我们保存文件,看看所有的测试是否通过:

如前面的截图所示,我们可以看到它确实通过了。Should return hello world响应通过了。完成大约41ms(毫秒),这完全没问题。

为 SuperTest 添加灵活性

大多数内置断言确实可以完成大部分工作。有时候你想要更灵活一点。例如,在上一章中,我们学习了所有那些很酷的断言,expect 可以做到。我们可以使用toIncludetoExclude,所有这些都非常方便,失去它真是太可惜了。幸运的是,SuperTest 非常灵活。我们可以提供一个函数,而不是将一个对象传递给 expect,或者一个状态码的数字,这个函数将被 SuperTest 调用,并且会传递响应:

    .expect((res) => {

    })

这意味着我们可以访问头部,body,我们想要从 HTTP 响应中访问的任何内容——它都可以在函数中使用。我们可以通过常规的 expect 断言库进行断言,就像我们在上一章中所做的那样。

让我们加载它,创建一个名为expect的常量,并将其设置为 require expect

const express = require('supertest');
const express = require('express');

现在在我们看它将如何工作之前,我们将在server.js中做一个改变。在这里,我们将在.status对象上添加第二个属性。我们将添加一个error,然后添加其他内容。让我们使用name,将其设置为应用程序名称,Todo App v1.0

app.get('/', (req, res) => {
  res.status(404).send({
    error: 'Page not found.',
    name: 'Todo App v1.0'
  });
});

现在我们有了这个,我们可以看看如何在我们的测试文件中使用这些自定义断言。在.expect对象中,我们将可以访问响应,在响应中有一个 body 属性。这将是一个具有键值对的 JavaScript 对象,这意味着我们期望有一个error属性和一个name属性,这是我们在server.js中设置的。

回到我们的测试文件,我们可以使用expect来创建自定义断言。我会对 body,res.body做一些expect。现在我们可以使用任何我们喜欢的断言,不仅仅是等于断言,这是 SuperTest 支持的唯一一种。让我们使用toInclude断言:

    .expect((res) => {
      expect(res.body).toInclude({

      });
    })

记住,toInclude让你指定对象上的属性的一个子集。只要它有这些属性就可以。它并不要求它有额外的属性。在我们的情况下,在toInclude内部,我们只需指定error消息,不需要指定 name 是否存在。我们想要检查error: Page not found,格式与我们在server.js中的格式完全一样:

    .expect((res) => {
      expect(res.body).toInclude({
        error: 'Page not found.'
      });
    })

现在当我们保存文件回到终端时,一切都重新启动了,我的所有测试都通过了:

使用 SuperTest 和 expect 的组合,我们可以为我们的 HTTP 端点创建超级灵活的测试套件。有了这个,我们将创建另一个express路由,并定义一个测试,确保它按预期工作。

创建一个 express 路由

这个 express 路由将有两个方面,一个是server.js中的实际设置,另一个是测试。我们可以从server.js开始。在这里,我们将创建一个新的路由。首先,让我们添加一些注释,明确指定我们要做什么。这将是一个 HTTP GET路由。路由本身将是/users,我们可以假设这会返回一个用户数组:

app.get('/', (req, res) => {
  res.status(404).send({
    error: 'Page not found.',
    name: 'Todo App v1.0'
  });
});

  // GET /users

我们可以通过send方法返回一个数组,就像我们在先前的代码中对对象所做的那样。现在这个数组将是一个对象数组,其中每个对象都是一个用户。现在,我们想给用户一个name属性和一个age属性:

  // GET /users
  // Give users a name prop and age prop

然后我们将为这个示例创建两到三个用户。一旦我们完成了这个,我们将负责编写一个测试,断言它按预期工作。这将发生在server.test.js中。在server.test.js中,我们将创建一个新的测试:

it('should return hello world response', (done) => {
  request(app)
    .get('/')
    .expect(404)
    .expect((res) => {
      expect(res.body).toInclude({
        error: 'Page not found.'
      });
    })
    .end(done);
});

// Make a new test

这个测试将断言一些事情。首先,我们断言返回的状态码是200,我们想要断言的是数组中的内容,我们将使用toInclude来做到这一点:

// Make a new test
// assert 200
// Assert that you exist in users array

让我们首先定义端点。在server.js中,跟随注释,我们将调用app.get,这样我们就可以为我们的应用程序注册全新的 HTTP 端点。这个将会在/users处:

app.get('/users')
// GET /users
// Give users a name prop and age prop

接下来,我们将指定接受请求和响应的回调函数:

app.get('/users', (req, res) => {

});
// GET /users
// Give users a name prop and age prop

这将让我们实际响应请求,这里的目标只是响应一个数组。在这种情况下,我将调用response.send,传入一个对象数组:

app.get('/users', (req, res) => {
  res.send([{

    }])
  }); 

第一个对象将是name。我们将把name设置为Mike,并将他的age设置为27

app.get('/users', (req, res) => {
  res.send([{
    name: 'Mike',
    age: 27
  }])  
});

然后我可以添加另一个对象。让我们将第二个对象添加到数组中,名称设置为Andrew,年龄设置为25

app.get('/users', (req, res) => {
  res.send([{
    name: 'Mike',
    age: 27
  }, {
    name: 'Andrew',
    age: 25
  }])    
});

在最后一个中,我们将把名称设置为Jen,年龄设置为26

app.get('/users', (req, res) => {
  res.send([{
    name: 'Mike',
    age: 27
  }, {
    name: 'Andrew',
    age: 25
  }, {
    name: 'Jen',
    age: 26
  }])    
});

现在我们的端点已经完成,我们可以保存server.js,进入server.test.js,并开始担心实际创建我们的测试用例。

编写 express 路由的测试

server.test.js中,跟随注释,我们需要首先调用it来开始。it是创建新测试的唯一方法:

// Make a new test
// assert 200
// Assert that you exist in users array
it('should return my user object')

然后我们将指定回调函数。它将传递done参数,因为这是一个异步的:

// Make a new test
// assert 200
// Assert that you exist in users array
it('should return my user object', (done) => {

});

在测试用例中开始时,我们将调用请求,就像我们在 hello world 响应中所做的那样,传入 Express 应用程序:

it('should return my user object', (done) => {
  request(app)
});

现在我们可以设置实际的调用。在这种情况下,我们只是在以下 URL 进行调用,/users

it('should return my user object', (done) => {
  request(app)
    .get('/users')
});

接下来,我们可以开始做出断言,首先我们要断言的是状态码为200,这是 Express 使用的默认状态码。我们可以通过调用.expect并传入状态码作为数字来断言。在这种情况下,我们将传入200

it('should return my user object', (done) => {
  request(app)
    .get('/users')
    .expect(200)
});

之后,我们将使用自定义的expect断言。这意味着我们将调用expect,传入一个函数,并在it内部使用toInclude来断言你存在于那个用户数组中。我们将调用expect方法,传入函数,该函数将使用响应调用:

it('should return my user object', (done) => {
  request(app)
    .get('/users')
    .expect(200)
    .expect((res) => {

    })
});

这将让我们对响应做出一些断言。我们实际上将使用expect进行断言。我们将期望响应体的某些内容。在这种情况下,我们将检查它是否包含我们的用户对象:

it('should return my user object', (done) => {
  request(app)
    .get('/users')
    .expect(200)
    .expect((res) => {
      expect(res.body).toInclude()

    })
});

现在记住,你可以在数组和对象上都调用toInclude。我们所做的就是传入我们想要确认在数组中的项目。在我们的情况下,它是一个对象,其中name属性等于Andrewage属性等于25,这是我们在server.js中使用的内容:

    expect(res.body).toInclude({
      name: 'Andrew',
      age: 25
    })

现在我们已经有了自定义的expect调用,我们可以在最底部调用.end。这将结束请求,我们可以将done作为回调传入,这样如果有任何错误发生,它就可以正确地触发:

    expect(res.body).toInclude({
      name: 'Andrew',
      age: 25
    })
  })
  .end(done);

有了这个,我们就可以开始了。我们可以保存文件。

在终端中,我们可以看到测试确实正在重新运行:

我们有一个测试,如前面的截图所示,should return my user object。它通过了。

现在我们可以确认,我们不会因为弄乱数据而疯狂地测试错误的东西。我们现在将在server.js中的Andrew后面添加一个小写的a,如下所示:

app.get('/users', (req, res) => {
  res.send([{
    name: 'Mike',
    age: 27
  }, {
    name: 'Aandrew',
    age: 25
  }, {
    name: 'Jen',
    age: 26
  }])    
});

测试将会失败。我们可以在终端中看到:

我们已经为我们的 Express 应用程序进行了测试。现在我们将讨论另一种测试 Node 代码的方法。

使用 describe()组织测试

在本节中,我们将学习如何使用describe()describe是一个注入到我们的测试文件中的函数,就像it函数一样。它来自于mocha,真的很棒。基本上,它让我们将测试分组在一起。这样可以更容易地扫描测试输出。如果我们在终端中运行npm test命令,我们会得到我们的测试:

我们有七个测试,目前它们都被分组在一起。在utils文件中查找测试非常困难,而且要找到asyncAdd的测试几乎是不可能的,除非扫描所有文本。我们将调用describe()。这将让我们对测试进行分组。我们可以给该组起一个名字。这将使我们的测试输出更易读。

utils.test.js文件中,在utils常量右边,我们将调用describe()

const expect = require('expect');

const utils = require('./utils');

describe()

描述对象接受两个参数,就像it一样。第一个是名称,另一个是回调函数。我们将使用Utils。这将是包含utils.test文件中所有测试的describe块。然后我们提供函数。这是回调函数:

describe('Utils', () => {

});

在回调函数中,我们将定义测试。在回调函数中定义的任何测试都将成为utils块的一部分。这意味着我们可以拿出现有的测试,剪切它们出来,粘贴到那里,我们将在文件中有一个名为utilsdescribe块,其中包含了所有的测试。所以,让我们就这样做吧。

我们将获取所有的测试,排除那些只是在各种expect功能中玩耍的测试。然后我们将把它们粘贴到回调函数中。结果代码将如下所示:

describe('Utils', () => {
  it('should add two numbers', () => {
    var res = utils.add(33, 11);

    expect(res).toBe(44).toBeA('number');
  });

  it('should async add two numbers', (done) => {
    utils.asyncAdd(4, 3, (sum) => {
      expect(sum).toBe(7).toBeA('number');
      done();
    });
  });

  it('should square a number', () => {
    var res = utils.square(3);

    expect(res).toBe(9).toBeA('number');
  });

  it('should async square a number', (done) => {
    utils.asyncSquare(5, (res) => {
      expect(res).toBe(25).toBeA('number');
      done();
    });
  });
});

这些分别是addasyncAddsquareasyncSquare的四个测试。现在我们将保存文件,然后可以从终端启动test-watch脚本并检查输出:

npm run test-watch

脚本将启动并运行我们的测试,如下截图所示,我们将得到不同的输出:

我们有一个Utils部分,在Utils下,我们有该describe块中的所有测试。这样可以使阅读和扫描测试变得更加容易。我们也可以对各个方法做同样的事情。

为单独的方法添加 describe()

现在,在utils.test.js的情况下(参考前面的截图),我们每个方法有一个测试,但是如果你有很多测试针对一个复杂的方法,最好将其包装在自己的describe块中。我们可以以任何我们喜欢的方式嵌套describe块和测试。例如,在utilsdescribe语句之后,我们可以再次调用describe。我们可以传递一个新的描述。让我们使用#(井号)后跟add

describe('Utils', () => {

  describe('#add')

#(井号)后跟方法名是为特定方法添加describe块的常见语法。然后我们可以提供回调函数:

describe('Utils', () => {

  describe('#add', () => {

  })

然后,我们可以将任何要添加到该组的测试剪切出来,粘贴进去:

describe('Utils', () => {

  describe('#add', () => {
    it('should add two numbers', () => {
      var res = utils.add(33, 11);

      expect(res).toBe(44).toBeA('number');
    });
  });

然后我保存文件。这将重新运行测试套件,现在我们有更易于扫描的测试输出:

很容易找到utils添加方法的测试,因为它们都有清晰的标签。你可以根据需要进行任意的嵌套。关于在测试中使用describe的频率并没有硬性规定,真的取决于你要根据方法或文件的测试数量来决定使用describe的次数。

在这种情况下,文件中有相当多的测试,因此添加utils块是有意义的。我只是想向你展示你可以嵌套它们,所以我也为add添加了它。如果我写这段代码,我可能不会添加第二层测试,但如果一个方法有多个测试,我肯定会添加第二个describe块。

server.test.js文件添加路由describe

现在,让我们在server.test文件中创建一些describe块。我们将创建一个名为Server的路由describe块。然后我们将为路由 URL 和/users分别创建describe块。我们将有GET/,其中将包含测试用例some test case。然后在//旁边,我们将有GET /users,它将有自己的测试用例some test case,如旁边的注释所解释的那样:

const request = require('supertest');
const expect = require('expect');

var app = require('./server').app;

// Server
  // GET /
    // some test case
  // GET / user
    // some test case

现在测试用例显然已经定义好了。我们只需要调用describe三次来生成之前解释过的结构。

我们将在注释部分之后调用describe()一次,这个描述将是关于路由的,所以我们将称之为Server

// Server
  // GET /
    // some test case
  // GET / user
    // some test case

  describe('Server')

这将包含我们server文件中的所有测试。接下来我们可以添加回调函数,然后继续:

describe('Server', () => {

})

接下来,我们将再次调用describe。这次我们将为测试GET /路由创建一个describe块,并添加回调函数:

describe('Server', () => {

  describe('GET /', () => {

  })

})

现在我们可以简单地将我们的测试剪切出来,然后粘贴到describe回调函数中。最终的代码将如下所示:

describe('Server', () => {

  describe('GET /', () => {
    it('should return hello world response', (done) => {
      request(app)
        .get('/')
        .expect(404)
        .expect((res) => {
          expect(res.body).toInclude({
            error: 'Page not found.'
          });
        })
        .end(done);
    });
  });

})

接下来,我们将第三次调用describe。我们将调用describe并传入GET /users作为描述:

  describe('GET /users')

我们将像往常一样拥有我们的回调函数,然后我们可以直接复制并粘贴我们的测试代码:

  describe('GET /users'), () => {
    it('should return my user object', (done) => {
      request(app)
        .get('/users')
        .expect(200)
        .expect((res) => {
          expect(res.body).toInclude({
            name: 'Andrew',
            age: 25
          });
        })
        .end(done);
    });
  });

有了这个设置,我们现在完成了。我们的测试结构更加清晰,当我们保存文件重新运行测试套件时,我们将能够在终端中看到结果:

如前面的代码所示,我们有一个更易于扫描的测试套件。我们可以立即看到我们的服务器测试。我们可以为每个功能创建测试组。由于我们现在只有静态数据,我们实际上不需要每个功能超过一个测试。但在未来,我们将为每个 HTTP 请求有多个测试,因此及早创建describe块是个好习惯。就是这样了!

测试间谍

在这一部分,也是测试章节的最后一部分,我们将学习一些相当高级的测试技术。我们将在构建真实应用时使用这些技术,但现在让我们从一个例子开始。我们将在接下来的内容中学习相关词汇。

暂时,我们将关闭所有当前的文件,并在项目的根目录下创建一个新的目录。我们将创建一个名为spies的新文件夹。我们将马上讨论spies到底是什么,以及它们与测试的关系。在spies文件夹中,我们将创建两个文件:app.js(这是我们将要测试的文件)和另一个名为db.js的文件。在我们的示例中,我们可以假设db.js是一个包含各种方法用于保存和读取数据库中数据的文件。

db.js中,我们将使用module.exports创建一个函数。让我们创建一个名为saveUser的函数。saveUser函数将是一个非常简单的函数,它将接受一个像这样的user对象:

module.exports.saveUser = (user) => {

}

现在,我们将使用console.log语句将其打印到屏幕上。我们将打印一条消息“保存用户”,并打印出如下所示的对象:

module.exports.saveUser = (user) => {
  console.log('Saving the user', user);
}

显然,这不是一个真正的saveUser函数。我们不与任何类型的数据库交互,但它将清楚地说明我们将如何使用spies来测试我们的代码。

接下来,我们将填充我们的app.js,这是我们实际要测试的文件。在app.js中,我们将创建一个新函数:module.exports.handleSignup。在具有身份验证的应用程序的上下文中,handleSignup可能会接受一个email和一个password;也许它会继续检查email是否已经存在。如果没有,很好;它保存用户,然后发送某种欢迎邮件。我们可以通过创建一个接受emailpassword的箭头函数(=>)来模拟这一点:

module.exports.handleSignup = (email, password) => {

};

在箭头函数(=>)中,我们将留下三条注释。这些将是函数应该执行的操作。它将检查email是否已经存在;它将用户保存到数据库;最后,我们将发送欢迎邮件:

module.exports.handleSignup = (email, password) => {
  // Check if email already exists
  // Save the user to the database
  // Send the welcome email
};

现在,这三件事只是handleSignup方法可能实际执行的一个示例。当我们经历真正的过程时,你会看到它是如何进行的。现在,我们已经有了其中一个。我们刚刚创建了saveUser,所以我们要做的是调用saveUser,而不是有第二个注释:

  // Check if email already exists
  db.saveUser()
  // Send the welcome email

它还没有被导入,但这不会阻止我们调用它;我们将在接下来的一秒钟内添加导入,并传入它所期望的user对象。现在,我们没有一个user对象;我们有一个email和一个password。我们可以通过将email设置为email参数并将password设置为password参数来创建该user对象:

db.saveUser({
  email: email,
  password: password
});

现在有一件重要的事情要注意:在 ES6 中,如果你设置的对象中的属性名与变量名相同,你实际上可以这样定义它:

db.saveUser({
  email,
  password
});

在这个例子中,由于我们将password属性设置为password变量上的任何值,所以没有必要两者都有。这种 ES6 语法还允许我们创建一个看起来简单得多的调用。由于长度相当合理,所以没有必要将其放在多行上。

现在,在顶部,我们可以通过创建一个变量db并将其设置为require('db.js')来加载db。这是一个本地文件,所以我们将以./开头,以从当前目录中获取它:

var db = require('./db.js');

现在,这是我们想要在我们的代码中测试的一个示例。我们有一个handleSignup方法。它接受一个email和一个password,我们需要确保db.saveUser也能正常工作。这是一个大问题,这意味着我们不仅要测试handleSignup,还要测试以下内容:

  • 我们正在测试handleSignup

  • 我们正在测试我们的代码,检查email是否存在

  • 也许这允许另一个功能

  • 我们正在检查saveUser函数是否按预期工作

  • 我们正在检查欢迎邮件是否已发送

这真是一个痛苦。我们将做的是伪造saveUser函数。它实际上永远不会执行db中的代码,但它会让我们验证当我们运行handleSignup时,saveUser是否被调用。我们将使用一种称为spies的东西来做到这一点。

spies函数允许您将真实函数(如saveUser)替换为测试实用程序。当调用该测试函数时,我们可以对其进行各种断言,确保它是使用特定参数调用的。让我们开始探索一下。

为间谍创建一个测试文件

我们将从创建一个新文件开始。在spies目录中,我们将创建一个名为app.test.js的新文件,然后我们可以开始玩弄spies。现在,spies是内置在expect中的,所以我们只需要加载它:

const expect = require('expect');

从这里我们可以创建我们的第一个测试。我们将把它放在一个describe块中,这样在我们的测试输出中更容易找到:

const expect = require('expect');

describe('')

我们将称这个describe块为App,然后我们将添加我的回调函数:

describe('App', () => {

});

现在我们可以添加单独的测试用例。首先,我们将调用it并创建一个新的测试,我们可以在其中玩弄spies

describe('App', () => {

  it('')

});

我们还没有在app.js文件中调用这个函数。我们将在it对象中添加一个字符串,比如,Should call the spy correctly

describe('App', () => {

  it('should call the spy correctly', () => {
  });

});

为了可视化spies的工作原理,我们将通过最基本的例子来进行。首先,创建一个spy

创建一个spy

要创建一个spy,我们将在it回调函数中调用一个名为expect.createSpy的函数:

  it('should call the spy correctly', () => {
    expect.createSpy();
  });

createSpy将返回一个函数,这个函数将替换真实的函数,这意味着我们确实想要将其存储在一个变量中。我将创建一个名为spy的变量,将其设置为返回的结果:

   it('should call the spy correctly', () => {
    var spy = expect.createSpy();
   });

现在,我们将spy注入到我们的代码中,无论是app.js还是其他函数,我们都会等待它被调用。我们可以直接像这样调用它:

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    spy();
   });

设置间谍断言

接下来,我们可以通过前往浏览器并转到expect文档,mjackson expect (github.com/mjackson/expect),来设置一系列断言。

在这个页面上,我们可以向下滚动到间谍部分,他们在那里谈论我们可以访问的所有断言。我们应该开始在方法名称中看到间谍,这时我们就知道我们已经到达那里了:

>

如前面的代码所示,我们有toHaveBeenCalled函数,这是我们使用spies的第一个断言。我们可以断言我们的spy确实被调用了。在 Atom 中,我们将通过调用expect并传入spy来做到这一点,就像这样:

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    spy();
    expect(spy)
  });

然后,我们将添加断言toHaveBeenCalled

    expect(spy).toHaveBeenCalled();

如果spy被调用,这将导致测试通过,如果spy从未被调用,这将导致测试失败。我们可以使用npm run test-watch命令在终端中运行测试套件,这将使用nodemon启动测试:

如前面的截图所示,我们有所有的测试用例,在App下面,我们有should call the spy correctly。它确实通过了,这太棒了。

现在让我们注释掉我调用spy的那一行:

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    // spy();
    expect(spy).toHaveBeenCalled();
  });

这一次测试应该失败,因为spy实际上从未被调用,如下截图所示,我们看到spy was not called

间谍断言的更多细节

现在,检查spy是否被调用或未被调用是很好的,但我们可以从我们的断言中获得更多细节。例如,如果我用名字Andrew和年龄25来调用spy,会发生什么:

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    spy('Andrew', 25);
    expect(spy).toHaveBeenCalled();
  });

现在,我们想要验证spy不仅仅是被调用了,而且是被调用了这些参数?幸运的是,我们也有一个断言。我们可以调用toHaveBeenCalledWith,这样我们就可以传入一些参数并验证spy确实是用这些参数被调用的。

如下代码所示,我们将断言我的spy是否被调用,并且传入了Andrew和数字25

    expect(spy).toHaveBeenCalledWith('Andrew', 25);

当我们保存文件并重新启动测试用例时,我们应该看到所有的测试都通过了,这正是我们得到的:

现在,如果spy没有用提到的数据被调用,我将删除25

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    spy('Andrew');
    expect(spy).toHaveBeenCalledWith('Andrew', 25);
  });

现在,如果我们重新运行测试套件,测试将失败。它将给出一个错误消息,让你知道spy was never called with [ 'Andrew', 25 ]。这导致了测试失败,这很棒。

我们可以使用我们的spy进行许多其他断言。你可以在expect文档中找到它们。我们有toHaveBeenCalled,我们使用了;toNotHaveBeenCalled,验证spy是否未被调用。然后我们有toHaveBeenCalledWith,我们也使用了。你还可以看到spy还有很多其他功能:如何创建spy,我们已经做过了,还有一些其他方法。

将函数与间谍交换

为了我们的目的,我们需要一个间谍,这样我们就可以在app.jssaveUser)内模拟该函数。我们需要一种方法来用spy替换saveUser函数。然后我们可以验证当handleSignup被调用时,它确实调用了saveUser。它不需要实际经过db.js中的过程;这对我们的测试不重要。唯一重要的是函数是否以正确的参数被调用。

为了做到这一点,我们将看看一个名为rewire的 npm 模块,它允许我们在测试中交换变量。在我们的情况下,在我们的测试文件中,我们将能够完全用其他东西替换db对象。然后,当代码运行时,而不是调用app.js中定义的db.saveUser,它将调用db.saveUser,这将是一个spy

安装和设置 rewire 函数

要开始,我们确实需要在终端中安装rewire。这是一个很棒的测试工具。对于测试具有副作用的函数,就像我们在本节中看到的那样,它非常重要。让我们运行npm install。模块名称本身称为rewire,我们将根据此次拍摄的最新版本@3.0.2来获取。这是一个专门用于测试的模块。我们不需要它来正常运行我们的应用程序,所以我们将使用--save-dev标志将其添加到我们的package.json依赖列表中:

npm install rewire@3.0.2 --save-dev

模块安装后,我们可以开始使用它,设置起来非常简单。在app.test.js内,我们可以开始加载它。在顶部,我们将创建一个新的常量。这个将被称为rewire,我们将其设置为从rewire中要求的返回结果:

const expect = require('expect');
const rewire = require('rewire');

用 spy 替换 db

现在,rewire的工作方式是,当你加载要模拟的文件时,它要求你使用rewire而不是require。在这个例子中,我们想要用其他东西替换db,所以当我们加载app时,我们必须以特殊的方式加载它。我们将创建一个名为app的变量,并将其设置为rewire,然后是我们通常放在require内的内容。在这种情况下,它是一个相对文件,我们创建的文件./app就可以完成任务:

const expect = require('expect');
const rewire = require('rewire');

var app = rewire('./app');

现在,rewire 通过 require 加载你的文件,但它还在app上添加了两个方法。这些方法是:

  • app.__set__

  • app.__get__

我们可以使用这些来模拟app.js内的各种数据。这意味着我们将模拟db对象,即从db.js返回的对象,但我们将用spy替换函数。

在我们的describe块内,我们可以通过创建一个变量来开始。这个变量将被称为db,我们将其设置为一个对象:

describe('App', () => {
  var db = {

  }

在我们的情况下,我们唯一需要模拟的是saveUser。在对象内,我们将定义saveUser,然后将其设置为spy,通过使用expect.createSpy来创建一个,就像这样:

describe('App', () => {
  var db = {
    saveUser: expect.createSpy()
  };

现在我们有了这个db变量,唯一剩下的事情就是替换它。我们使用app.__set__来做到这一点,它将需要两个参数:

describe('App', () => {
  var db = {
    saveUser: expect.createSpy()
  };
  app.__set__();

第一个是你想要替换的东西。我们试图替换db,并试图用db变量替换它,这是我们的对象,其中有saveUser函数:

describe('App', () => {
  var db = {
    saveUser: expect.createSpy()
  };
  app.__set__('db', db);

有了这个设置,我们现在可以编写一个测试,验证handleSignup确实调用了saveUser

编写一个测试来验证函数的交换

为了验证handleSignup是否调用了saveUser,在app.test.js中,我们将调用it

describe('App', () => {
  var db = {
    saveUser: expect.createSpy()
  };
  app.__set__('db', db);

  it('should call the spy correctly', () => {
    var spy = expect.createSpy();
    spy('Andrew', 25);
    expect(spy).toHaveBeenCalledWith('Andrew', 25);
  });

  it('should call saveUser with user object')

然后我们可以传入我们的函数,这将在测试执行时实际运行,不需要使用任何异步的 done 参数。这将是一个同步测试:

  it('should call saveUser with user object', () => {

  });

在回调函数中,我们可以想出一个email和一个password,然后将它们传递给db.js中的handleSignup。我们将创建一个名为email的变量,将其设置为某个邮箱andrew@example.com,然后我们可以用password做同样的事情,var password;我们将把它设置为123abc

  it('should call saveUser with user object', () => {
    var email = 'andrew@example.com';
    var password = '123abc';
  });

接下来,我们将调用handleSignup。这是我们想要测试的函数。我们将调用app.handleSignup,传入我们的两个参数,emailpassword

  it('should call saveUser with user object', () => {
    var email = 'andrew@example.com';
    var password = '123abc';

    app.handleSignup(email, password);
  });

现在,在这一点上,handleSignup将被执行。这意味着这里的代码将运行,并且它将触发db.saveUser,但db.saveUser不是db.js中的方法;它是一个spy,这意味着我们现在可以使用我们刚学到的那些断言。

在测试用例中,我们将使用expect来期望关于db的某些内容;我们将变量.saveUser设置为一个spy

    app.handleSignup(email, password);
    expect(db.saveUser)

我们将使用一个对象调用.toHaveBeenCalledWith,因为这是db.js应该被调用的方式。我们将使用相同的 ES6 快捷方式:emailpassword

    app.handleSignup(email, password);
    expect(db.saveUser).toHaveBeenCalledWith({email, password});
  });

这将创建一个email属性,设置为email变量,并创建一个password属性,设置为password变量。有了这个设置,我们现在可以保存我们的测试文件,在终端中,我们可以使用上箭头键两次重新运行npm run test-watch命令来重新启动test-watch脚本。这将启动我们的测试套件,开始所有的测试:

如前面的屏幕截图所示,我们看到should call the spy correctly通过了。同时,我们刚刚创建的测试用例也通过了。我们可以看到should call saveUser with the user object,这太棒了。我们现在有一种方法可以测试几乎任何 Node 中的东西。我们甚至可以测试调用其他函数的函数,验证通信是否如预期发生。所有这些都可以使用 spy 来完成。

总结

在本章中,我们研究了如何测试 Express 应用程序,就像在上一章中对同步和异步 Node 应用程序进行测试一样。然后,我们使用describe()对象组织我们的测试,以便我们可以立即看到不同的测试方法。

在上一节中,我们探讨了另一种测试 Node 应用程序的方法,即 spy。我们为 spy 创建了测试文件,研究了spy断言和使用spy交换函数。

结论

这就是书的结尾!在本书的过程中,您学习了 Node.js 的基础知识,以便在网络上测试和部署 Node.js 应用程序。我们希望您喜欢本书带您走过的旅程。我们祝愿您一切成功,并希望您继续改进您的 Node.js 应用程序。

posted @ 2024-05-23 15:58  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报