NodeJS-开发者高级教程-一-

109 阅读57分钟

NodeJS 开发者高级教程(一)

原文:Pro Node.js for Developers

协议:CC BY-NC-SA 4.0

零、简介

自 2009 年创建以来,Node.js 已经发展成为一个强大且日益流行的异步开发框架,用于创建高度可伸缩的 JavaScript 应用。像道琼斯、LinkedIn 和沃尔玛这样受人尊敬的公司是许多已经看到 Node 的潜力并在他们的业务中采用它的组织中的一员。

Pro Node.js for Developers 为这项激动人心的年轻技术提供了全面的指导。在深入探究支撑其操作的关键概念和 API 之前,将从较高的层次向您介绍 Node。基于您现有的 JavaScript 技能,将向您展示如何使用 Node.js 构建基于 web 和基于网络的应用,处理各种数据源,捕获和生成事件,生成和控制子进程,等等。

一旦你掌握了这些技能,你将学习更高级的软件工程技能,这将使你的代码具有专业优势。您将学习如何创建易于重用的代码模块,快速有效地调试和测试您的应用,以及随着应用需求的增加,将您的代码从单线程扩展到云。

一、入门指南

JavaScript 最初被命名为 Mocha,是由 Brendan Eich 于 1995 年在网景公司开发的。1995 年 9 月,Netscape Navigator 2.0 的测试版与 Mocha 一起发布,Mocha 已被重命名为 LiveScript。到了 1995 年 12 月,LiveScript 经过了另一次更名,变成了 JavaScript,也就是现在的名字。在那段时间,网景公司与负责开发 Java 编程语言的 Sun 公司密切合作。JavaScript 这个名字的选择引起了很多猜测。许多人认为网景公司试图借用当时的热门词汇 Java。不幸的是,命名选择引起了很多混乱,因为许多人自动认为这两种语言在某种程度上是相关的。事实上,他们几乎没有共同点。

尽管混乱,JavaScript 成为了一种非常成功的客户端脚本语言。作为对 JavaScript 成功的回应,微软创建了自己的实现,命名为 JScript,并于 1996 年 8 月与 Internet Explorer 3.0 一起发布。1996 年 11 月,Netscape 向国际标准组织 Ecma International 提交了 JavaScript 标准。1997 年 6 月,JavaScript 成为了标准的 ECMA-262。

多年来,JavaScript 一直是客户端开发事实上的标准。然而,服务器领域是一个完全不同的故事。在很大程度上,服务器领域属于 PHP 和 Java 等语言。许多项目已经将 JavaScript 实现为服务器语言,但是没有一个项目特别成功。两个主要障碍阻碍了 JavaScript 在服务器上的广泛应用。首先是它的声誉。JavaScript 一直被视为玩具语言,只适合业余爱好者。第二个障碍是 JavaScript 与其他语言相比表现不佳。

然而,JavaScript 有一大优势。网络正在经历前所未有的增长,浏览器大战正在激烈进行。作为所有主流浏览器都支持的唯一语言,JavaScript 引擎开始受到谷歌、苹果和其他公司的关注。所有这些关注导致了 JavaScript 性能的巨大提高。突然间 JavaScript 不再落后了。

开发社区注意到了 JavaScript 的新力量,并开始创建有趣的应用。2009 年,Ryan Dahl 创建了 Node.js,这是一个主要用于为 web 应用创建高度可伸缩的服务器的框架。Node.js,简称 Node,是用 C++和 JavaScript 编写的。为了驱动 Node,达尔利用了谷歌的 V8 JavaScript 引擎 (V8 是谷歌 Chrome 内部的引擎,是现存最流行的浏览器)。使用 V8,开发人员可以用 JavaScript 编写成熟的应用——通常用 C 或 Java 等语言编写的应用。因此,随着 Node 的发明,JavaScript 最终成为了真正的服务器端语言。

Node 执行模式

除了速度,Node 还带来了一个非常规的执行模型。为了理解 Node 有何不同,我们应该将它与 Apache 进行比较,Apache 是 Linux、Apache、MySQL 和 PHP (LAMP) 软件栈中流行的 web 服务器。首先,Apache 只处理 HTTP 请求,将应用逻辑留给 PHP 或 Java 之类的语言来实现。Node 通过将服务器和应用逻辑结合在一个地方,消除了一层复杂性。一些开发人员批评这种模型消除了 LAMP 堆栈中使用的传统的关注点分离。然而,这种方法也为 Node 作为服务器提供了前所未有的灵活性。

Node 在并发性的使用上也不同于许多其他服务器。像 Apache 这样的服务器维护一个线程池来处理客户端连接。这种方法缺乏可伸缩性,因为线程相当耗费资源。此外,繁忙的服务器会很快耗尽所有可用的线程;结果,产生了更多的线程,创建和拆除这些线程的成本很高。另一方面,Node 在单个线程中执行。虽然这看起来是个坏主意,但实际上它工作得很好,因为大多数服务器应用都是这样工作的。通常,服务器接收客户端请求,然后执行一些高延迟 I/O 操作,如文件读取或数据库查询。在此期间,服务器阻塞,等待 I/O 操作完成。服务器可以处理更多的请求或做其他有用的工作,而不是无所事事。

在传统服务器中,线程在阻塞 I/O 操作时什么都不做是可以接受的。然而,Node 只有一个线程,阻塞它会导致整个服务器挂起。为了缓解这个问题,Node 几乎只使用非阻塞 I/O。例如,如果 Node 需要执行数据库查询,它只需发出查询,然后处理其他事情。当查询最终返回时,它会触发一个负责处理查询结果的异步回调函数。该过程的伪代码示例如清单 1-1 中的所示。

清单 1-1 。非阻塞数据库查询的伪代码示例

var sql = "SELECT * FROM table";

database.query(sql, function(results) {
  // process the results
});
// do something else instead of waiting

Node 的非阻塞异步执行模型以最小的开销提供了高度可伸缩的服务器解决方案。许多高调的公司,包括微软,LinkedIn,雅虎!和零售巨头沃尔玛已经注意到了 Node,并开始与它一起实施项目。例如,LinkedIn 将其整个移动堆栈迁移到 Node,并“从在每台物理机上运行 15 台服务器和 15 个实例(虚拟服务器),减少到只有 4 个实例,可以处理两倍的流量。”Node 还获得了媒体的广泛认可,例如赢得了 2012 年 InfoWorld 年度技术奖。

安装 Node

开始使用 Node 的第一步是安装。本节将帮助您在 Ubuntu、OS X 或 Windows 机器上安装并运行 Node。安装 Node 最简单的方法是通过 Node 主页上的安装按钮http://nodejs.org,如图 1-1 中的所示。这将下载适用于您的操作系统的二进制文件或安装程序。

9781430258605_Fig01-01.jpg

图 1-1 。从项目主页安装 Node

您还可以在http://nodejs.org/download浏览所有平台的二进制文件、安装程序和源代码。Windows 用户最有可能想要下载 Windows 安装程序(.msi文件),而 Mac 用户应该选择 Mac OS X 安装程序(.pkg文件)。Linux 和 SunOS 用户可以下载二进制文件,但是使用包管理器安装可能更简单。

通过软件包管理器安装

有关通过操作系统的软件包管理器安装 Node 的说明,请转到https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager。本页包含 Windows、OS X 和 Linux 的说明。同样,Windows 和 Mac 用户应该使用前面讨论过的安装程序。就 Linux 而言,有针对 Gentoo、Debian、Linux Mint、Ubuntu、openSUSE、SLE、Red Hat、Fedora、Arch Linux、FreeBSD 和 OpenBSD 的指令。

Ubuntu 用户可以使用清单 1-2 所示的高级打包工具(APT)命令安装 Node 和所有必备软件。这些步骤还会安装npm,Node 的软件包管理软件(在第二章中介绍)。

清单 1-2 。使用 Ubuntu 的软件包管理器安装 Node

$ sudo apt-get install python-software-properties python g++ make
$ sudo add-apt-repository ppa:chris-lea/node.js
$ sudo apt-get update
$ sudo apt-get install nodejs npm

如果add-apt-repository命令失败,使用清单 1-3 中所示的命令安装software-properties-common包。

清单 1-3 。安装Software-Properties-Common

$ sudo apt-get install software-properties-common

从源头开始构建

如果您想为 Node 的 C++核心做出贡献,或者只是尝试它的功能,您将需要编译项目的源代码。您可以从下载页面获得源代码,或者从项目的 GitHub 资源库https://github.com/joyent/node获得。一旦下载了代码,如果适用的话,从存档中提取它。在构建 Node 之前,Ubuntu 用户需要安装 Python 等构建工具;使用清单 1-4 中的命令。安装 Python 时,一定要安装 2.7 版本,而不是更新的 Python 3。

清单 1-4 。在 Ubuntu 上安装必备软件包

$ sudo apt-get install python-software-properties python g++ make

Ubuntu 和 OS X 用户可以从源代码目录中发出清单 1-5 所示的命令来构建 Node。请注意,源代码目录的完整路径不应包含任何空格。

清单 1-5 。在 Ubuntu 和 OS X 上从源代码安装 Node

./configure
make
sudo make install

在 Windows 上,您需要安装 Visual C++和 Python 2.7 来构建 Node。Visual C++可以通过 Visual Studio Express 从微软免费下载。Python 也可以在www.python.org/免费获得。要编译 Node,发出清单 1-6 中的命令。

清单 1-6 。在 Windows 上从源安装 Node

> vcbuild.bat release

最终安装步骤

无论您决定采用哪种安装方式,此时 Node 都应该可以使用了。为了验证一切都设置正确,打开一个新的终端窗口,并运行node可执行文件(参见清单 1-7 )。-v标志使Node打印已安装的版本,然后退出。在此示例中,安装了 0.10.18 版的 Node。

清单 1-7 。从命令行检查Node的版本

$ node -v
v0.10.18

你还应该确认npm已经安装(见清单 1-8 )。

清单 1-8 。从命令行检查npm的版本

$ npm -v
1.3.8

最后一个安装注意事项:即使您没有从源代码安装 Node,也可能需要在您的机器上安装 Python 和 C++编译器。这样做可以确保用 C++编写的本机模块可以在您的 Node 安装中编译和运行。在 Windows 上,这涉及到安装微软的 Visual C++编译器(参见上一节“从源代码构建”)。对于任何其他操作系统,构建基础应该包括必要的编译器。

读取-评估-打印循环

Node 提供了一个交互式 shell,称为读取-评估-打印循环,或 REPL。REPL 从用户那里读取输入,将输入作为 JavaScript 代码进行评估,打印结果,然后等待更多的输入。REPL 对于调试和试验小的 JavaScript 代码片段非常有用。要启动 REPL,请不带命令行参数启动Node。然后你会看到 REPL 命令提示符,即>字符。在提示符下,开始输入任意 JavaScript 代码。

清单 1-9 显示了如何启动 REPL 并输入代码。在这个例子中,用字符串值"Hello World!"创建了一个名为foo的变量。在第三行,REPL 打印出"undefined",因为变量声明语句没有返回值。接下来,语句foo;使得foo的值被检查。不出所料,REPL 返回字符串"Hello World!"。最后,使用console.log()功能将foo的值打印到终端。打印完foo后,REPL 再次显示"undefined",因为console.log()不返回值。

清单 1-9 。启动 REPL 并输入 JavaScript 代码

$ node
> var foo = "Hello World!";
undefined
> foo;
'Hello World!'
> console.log(foo);
Hello World!
undefined

您也可以在 REPL 中输入多行表达式。例如,在清单 1-10 的中,一个for循环进入了 REPL。REPL 使用...来表示正在进行的多行表达式。注意,...是由 REPL 显示的,而不是用户输入的。

清单 1-10 。在 REPL 中执行多行表达式的例子

> for (var i = 0; i < 3; i++) {
... console.log(i);
... }
0
1
2
undefined

REPL 特色

REPL 有许多增加可用性的特性,其中最有用的是使用上下箭头键浏览先前发布的命令的能力。要终止任何命令并返回空白提示符,请键入Control+C。从空白行按下Control+C两次会导致 REPL 终止。你可以随时按下Control+D退出 REPL。您可以使用Tab键查看当前命令的可能完成列表。如果只有一个可能的选项,Node 会自动插入它。该列表包括关键字、函数和变量。例如,清单 1-11 显示了在提示符下输入t时的完成选项。

清单 1-11 。通过键入t后跟 Tab 显示自动完成选项

> t
this            throw           true            try
typeof          tls             tty             toLocaleString
toString

REPL 还提供了一个特殊的变量,_(下划线),它总是包含上一个表达式的结果。清单 1-12 显示了_的几个使用示例。首先,创建一个字符串数组,使_引用该数组。然后使用pop()方法移除数组的最后一个元素baz。最后,访问baz的长度,使_变成3

清单 1-12 。_ 变量的使用示例

> ["foo", "bar", "baz"]
[ 'foo', 'bar', 'baz' ]
> _.pop();
'baz'
> _.length
3
> _
3

REPL 命令

.help

.help命令显示所有可用的 REPL 命令。清单 1-13 显示了运行.help命令的输出。

清单 1-13.help REPL 命令的输出

> .help
.break Sometimes you get stuck, this gets you out
.clear Alias for .break
.exit Exit the repl
.help Show repl options
.load Load JS from a file into the REPL session
.save Save all evaluated commands in this REPL session to a file

.exit

命令终止了 REPL。该命令相当于按下Control+D

.break

用于退出多行表达式的.break命令在您犯了一个错误或者只是选择不完成表达式时非常有用。清单 1-14 显示了一个使用.break命令在完成前终止for循环的例子。注意正常的>提示符显示在.break命令之后。

清单 1-14 。使用.break命令终止多行表达式

> for (var i = 0; i < 10; i++) {
... .break
>

.save文件名

.save命令将当前 REPL 会话保存到filename中指定的文件中。如果文件不存在,则创建该文件。如果文件确实存在,现有文件将被覆盖。REPL 命令和输出不会被保存。清单 1-15 显示了一个使用.save命令的例子。在本例中,当前会话被保存到文件repl-test.jsrepl-test.js的结果内容如清单 1-16 所示。请注意,该文件不包含 REPL 提示符或输出或.save命令。

清单 1-15 。使用.save命令保存当前 REPL 会话

> var foo = [1, 2, 3];
undefined
> foo.forEach(function(value) {
... console.log(value);
... });
1
2
3
undefined
> .save repl-test.js
Session saved to:repl-test.js

清单 1-16 。由.save命令生成的repl-test.js的内容

var foo = [1, 2, 3];
foo.forEach(function(value) {
console.log(value);
});

.load文件名

.load命令执行filename中指定的 JavaScript 文件。文件被执行,就好像每一行都被直接输入到 REPL 中。清单 1-17 显示了从清单 1-16 加载文件repl-test.js的输出。

清单 1-17 。使用.load命令执行repl-test.js的结果

> .load repl-test.js
> var foo = [1, 2, 3];
undefined
> foo.forEach(function(value) {
... console.log(value);
... });
1
2
3
undefined

.clear

类似于.break.clear可以用来终止多行表达式。.clear也用于重置 REPL 的上下文对象。在这一点上,你不需要理解细节,但是清单 1-18 显示了一个嵌入了 REPL 的 Node 程序。换句话说,运行这个程序实际上调用了 REPL 的一个实例。此外,您可以为 REPL 定义自定义的执行环境。在这种情况下,嵌入式 REPL 有一个已定义的变量foo,它保存字符串"Hello REPL"。从嵌入式 REPL 内部调用.clear会重置上下文并删除foo

清单 1-18 。在另一个 Node 程序中嵌入 REPL

var repl = require("repl");

repl.start({}).context.foo = "Hello REPL";

执行 Node 程序

尽管 REPL 环境很有用,但它很少在生产系统中使用。相反,程序被写成一个或多个 JavaScript 文件,然后由 Node 解释。最简单的 Node 程序如清单 1-19 所示。该示例只是将字符串"Hello World!"打印到控制台。

清单 1-19 。Hello World Node 的源代码!程序

console.log("Hello World!");

将清单 1-19 中的代码复制到一个新文件中,并另存为hello.js。接下来,打开一个终端窗口,并执行hello.js(参见清单 1-20 )。注意,Node 不要求你指定.js文件扩展名。如果没有找到输入文件并且没有提供文件扩展名,Node 将尝试添加扩展名.js.json.node。Node 将.js文件解释为 JavaScript 源代码,将扩展名为.json的文件解释为 JavaScript 对象符号 (JSON)文件。扩展名为.node的文件被视为已编译的附加模块。

清单 1-20 。从命令行执行 Node 程序

$ node hello.js

image JSON 是数据交换的明文标准。本书假设读者已经熟悉 JSON。但是,如果您需要介绍或复习,JSON 包含在附录 a 中。

摘要

恭喜你!您已经正式向开发 Node 应用迈出了第一步。本章为您提供了 Node 的高级介绍,并指导您完成安装过程。您甚至已经使用 REPL 编写了一些 Node 代码。本书的其余部分建立在本章的基础上,涵盖了 Node 开发的最重要的方面。Node 最出名的是创建可伸缩的 web 服务器,所以当然会讨论这个特性。但是,您还将了解更多内容,包括文件系统编程、流数据、应用伸缩和 Node 的模块系统。

二、Node 模块系统

作为开发人员,您可以使用核心 Node 功能解决许多复杂的问题。然而,Node 真正的优势之一是它的开发者社区和丰富的第三方模块。Node 的包管理器npm负责跟踪所有这些模块。npm FAQ 页面开玩笑地说npm不是“Node 包管理器”的首字母缩写,而是一个递归的反义词。npm不是首字母缩写不管它的意思是什么,npm是一个命令行工具,从 Node 版本 0.6.3 开始,它与 Node 环境捆绑在一起。

npm所做的——而且做得非常好——是管理 Node 模块及其依赖关系。在编写本报告时,官方登记册中有 47 000 多个包裹。您可以在注册中心的网站https://npmjs.org/上浏览所有可用的软件包。除了每个单独的模块,该网站还显示了各种排名,包括哪些模块最受欢迎,哪些模块最受依赖。如果您更愿意亲自使用命令行,您可以使用npm search命令搜索注册表,该命令允许您基于一个或多个关键字搜索软件包。例如,npm search可以用来定位名称或描述中包含database一词的所有模块(参见清单 2-1 )。第一次运行这个命令时,预计会有短暂的延迟,因为npm会构建一个本地索引。

清单 2-1 。使用npm searchnpm注册表中定位模块

$ npm search database

安装软件包

为了使用一个模块,你必须在你的机器上安装它。这通常就像下载几个 JavaScript 源文件一样简单(有些模块还需要下载或编译二进制文件)。要安装软件包,请键入npm install,后跟软件包名称。例如,commander模块提供了实现命令行接口的方法。要安装最新版本的commander,发出清单 2-2 中的命令。

清单 2-2 。使用npm安装最新版本的commander

$ npm install commander

如果您对安装软件包的最新版本不感兴趣,您可以指定一个版本号。Node 模块跟随一个专业小调补丁版本控制方案。例如,要安装commander版本 1.0.0 ,使用清单 2-3 中所示的命令。@字符用于将包名和版本分开。

清单 2-3 。安装commander的 1.0.0 版本

$ npm install commander@1.0.0

对主要版本号的更改可以表明模块已经以非向后兼容的方式进行了更改(称为重大更改)。即使对次要版本的更改也可能会意外引入重大更改。因此,您通常会希望安装某个版本的最新补丁——npm支持使用x通配符。清单 2-4 中的命令安装了commander1.0 版本的最新补丁。(注意,x通配符也可以用来代替主要版本和次要版本。)

清单 2-4 。安装最新的commander 1.0 补丁

$ npm install commander@1.0.x

您还可以使用关系版本范围描述符来选择版本。关系版本范围描述符选择与一组给定标准相匹配的最新版本。npm支持的各种关系版本范围描述符在表 2-1 中列出。

表 2-1 。关系版本范围描述符

|

关系版本范围描述符

|

版本标准

| | --- | --- | | =版本 | 与版本完全匹配。 | | >版本 | 大于版本。 | | > =版本 | 大于或等于版本。 | | | 小于版本。 | | < =版本 | 低于或等于版本。 | | *版本 | 大于或等于版本,但小于下一个主要版本。 | | * | 最新版本。 | | "" | 最新版本。 | | 版本1–版本 2 | 大于等于版本 1 ,小于等于版本 2 。 | | 范围 1 ||范围 2 | 匹配范围 1 和范围 2 指定的版本。 |

根据表 2-1 ,列表 2-5 中的所有命令都是有效的npm命令。

清单 2-5 。使用关系版本范围描述符的各种npm install命令

$ npm install commander@"=1.1.0"
$ npm install commander@">1.0.0"
$ npm install commander@"∼1.1.0"
$ npm install commander@"*"
$ npm install commander@""
$ npm install commander@">=1.0.0 <1.1.0"
$ npm install commander@"1.0.0 - 1.1.0"
$ npm install commander@"<=1.0.0 || >=1.1.0"

从 URL 安装

此外,npm允许直接从gitURL 安装软件包。这些 URL 必须采用清单 2-6 中所示的形式之一。在清单中,commit-ish表示一个标签、SHA 或分支,可以作为参数提供给git checkout。注意,例子中的链接并没有指向任何特定的git项目。

image 注意使用 Node 不需要了解git和 GitHub。然而,大多数 Node 模块使用 GitHub 生态系统进行源代码控制和错误跟踪。虽然 GitHub 及其使用已经超出了本书的范围,但是熟悉它是非常可取的。

清单 2-6git支持的 URL 格式npm

`git://github.com/user/project.git#commit-ish`
git+ssh://user@hostname:project.git#commit-ish
git+ssh://user@hostname/project.git#commit-ish
git+http://user@hostname/project/blah.git#commit-ish
git+https://user@hostname/project/blah.git#commit-ish

软件包也可以从 tarball URLs 安装。例如,要安装 GitHub 库的主分支,使用清单 2-7 中所示的语法。虽然这个 URL 没有指向实际的存储库,但是您可以通过下载commander模块:https://github.com/visionmedia/commander.js/tarball/master进行试验。

清单 2-7 。从 GitHub 库安装 Tarball

$ npm install https://github.com/user/project/tarball/master

包装位置

当软件包被安装时,它们被保存在本地机器的某个地方。通常,这个位置是当前目录中名为node_modules的子目录。要确定位置,使用命令npm root。您也可以使用npm ls命令查看所有已安装的模块。安装commander模块后,您可以使用npm ls验证它是否存在。出于此示例的目的,请安装版本 1.3.2。清单 2-8 显示commander版本 1.3.2 已安装。另外,请注意安装了一个名为keypress的模块。树形结构表明commander依赖于keypress模块。由于npm能够识别这种依赖性,它会自动安装任何需要的模块。

清单 2-8 。使用npm ls列出所有当前安装的软件包

$ npm ls
/home/colin/npm-test
└─┬ commander@1.3.2
     └── keypress@0.1.0

也可以通过浏览node_modules子目录来查看已安装的模块。在这个例子中,commander安装在node_modules/commander,而keypress安装在node_modules/commander/node_modules/keypress。如果keypress有任何依赖项,它们将被安装在keypress目录下的另一个node_modules子目录中。

全球软件包

如前所述,包是包含在程序中的库。这些被称为本地包,必须安装在使用它们的每个项目中。另一种类型的软件包,称为全局软件包,只需要安装在一个位置。尽管全局包通常不包含代码库,但它们可以。根据经验,全局包通常包含命令行工具,它们应该包含在PATH环境变量中。

要全局安装包,只需发出带有-g--global选项的npm install。事实上,您可以通过在大多数npm命令中添加-g选项来处理全局包。例如,您可以通过发出命令npm ls -g来查看已安装的全局包。您也可以使用npm root -g命令定位全局node_modules文件夹。

链接包

使用npm,您可以创建到本地包的链接。当您链接到一个包时,它可以像一个全局包一样被引用。如果您正在开发一个模块,并且希望另一个项目引用该模块的本地副本,这将非常有用。如果您想部署您的模块而不将它发布到公共的npm注册中心,链接也是有用的。

包链接是一个两步过程。第一步,创建链接,是通过切换到您想要使其可链接的项目的目录来完成的。清单 2-9 展示了如何创建一个到你的模块的链接,假设你的模块位于foo-module中。执行npm link命令后,验证该链接是使用npm ls -g创建的。

清单 2-9 。使用npm link创建链接

$ cd foo-module
$ npm link

模块链接的第二步,实际上是引用链接,非常类似于包安装。首先,切换到将导入链接模块的项目的目录。接下来,发出另一个npm link命令。但是,这一次您还必须指定链接模块的名称。该程序的一个例子如清单 2-10 所示。在这个例子中,清单 2-9 中的foo-module链接是从第二个模块bar-module引用的。

清单 2-10 。使用npm link引用现有链接

$ cd bar-module
$ npm link foo-module

解除包的链接

移除链接模块的过程与创建链接模块的过程非常相似。要从应用中删除链接的模块,使用npm unlink命令,后跟名称。清单 2-11 显示了从bar-module中移除链接的foo-module的命令。

清单 2-11 。使用npm unlink删除对链接的引用

$ cd bar-module
$ npm unlink foo-module

类似地,要从您的系统中删除一个链接,切换到链接模块的目录,并发出npm unlink命令。清单 2-12 展示了如何移除foo-module链接。

清单 2-12 。使用npm unlink移除链接的模块

$ cd foo-module
$ npm unlink

更新软件包

因为任何被积极开发的包最终都会发布一个新版本,所以你的拷贝会变得过时。要确定你的副本是否过期,在你的项目目录中运行npm outdated(见清单 2-13 )。在示例中,假设安装了commander的过时版本 1.0.0,npm表示最新版本是 2.0.0,但您的副本只有 1.0.0。清单 2-13 检查所有的本地包。您可以通过指定它们的名称来检查单个包,并且可以通过指定-g选项来处理全局包。

清单 2-13 。使用npm outdated显示过期的包

$ npm outdated
npm http GET https://registry.npmjs.org/commander
npm http 304 https://registry.npmjs.org/commander
commander@2.0.0 node_modules/commander current=1.0.0

要更新任何过期的本地包,使用npm update命令。与outdated非常相似,update在默认情况下适用于所有本地包。同样,您可以通过指定它们的名称来定位单个模块。您也可以使用-g选项更新全局包。在清单 2-14 的中,npm使用-g选项更新自己。

清单 2-14 。使用npm update更新npm

$ npm update npm -g

卸载软件包

要删除一个包,使用npm uninstallnpm rm命令(这两个命令可以互换使用),并指定一个或多个要删除的包。您也可以通过提供-g选项来删除全局包。清单 2-15 显示了如何使用npm rm移除commander模块。

清单 2-15 。使用npm rm卸载commander

$ npm rm commander

require()功能

如前一节所示,使用npm管理 Node 包。然而,要将模块导入到程序中,需要使用require()函数。require()接受单个参数,即指定要加载的模块的字符串。如果指定的模块路径存在,require()返回一个可用于与模块接口的对象。如果找不到该模块,就会引发异常。清单 2-16 显示了如何使用require()函数将commander模块导入到程序中。

清单 2-16 。使用require()功能

var commander = require("commander")

核心模块

核心模块是编译成 Node 二进制的模块。require()赋予它们最高的优先级,这意味着在模块命名冲突的情况下,加载核心模块。例如,Node 包含一个名为http的核心模块,顾名思义,它提供了使用超文本传输协议(HTTP) 的功能。无论如何,对require("http")的调用总是会加载核心http模块。顺便提一下,核心模块位于 Node 源代码的lib目录中。

文件模块

文件模块是从文件系统加载的非核心模块。可以使用绝对路径、相对路径或从node_modules目录指定它们。以斜杠(/)开头的模块名被视为绝对路径。例如,在清单 2-17 中,一个文件模块foo使用绝对路径加载。

清单 2-17 。使用绝对路径导入文件模块

require("/some/path/foo");

image 注意Windows 等一些操作系统使用不区分大小写的文件系统。这允许你写require("commander")require("COMMANDER")require("CoMmAnDeR")。然而,在像 Linux 这样区分大小写的文件系统上,最后两个调用会失败。因此,无论使用什么操作系统,都应该区分大小写。

Node 还支持 Windows 样式的文件路径。在 Windows 上,Node 允许交换使用斜杠和反斜杠字符(/\)。为了一致性,也为了避免转义反斜杠字符,本书主要使用 Unix 风格的路径。然而,请注意在清单 2-18 中显示的所有路径在 Windows 上都是有效的。

清单 2-18 。在 Windows 上有效的模块路径示例

require("/some/path/foo");
require("C:/some/path/foo");
require("C:\\some\\path\\foo");
require("\\some/path\\foo");

以一两个点(...)开头的模块路径被解释为相对路径——也就是说,它们被认为是相对于调用require()的文件的。清单 2-19 显示了相对模块路径的三个例子。在第一个示例中,foo从与调用脚本相同的目录中加载。在第二个中,foo位于调用脚本的父目录中。在第三个示例中,foo位于调用脚本目录的子目录sub中。

清单 2-19 。使用相对路径的模块导入示例

require("./foo");
require("../foo");
require("./sub/foo");

如果模块路径不对应于核心模块、绝对路径或相对路径,那么 Node 开始在node_modules文件夹中搜索。Node 从调用脚本的父目录开始,并追加/node_modules。如果没有找到该模块,Node 在目录树中向上移动一级,追加/node_modules,然后再次搜索。重复这种模式,直到找到模块或到达目录结构的根。清单 2-20 中的例子假设一个项目位于/some/path中,并按顺序显示了将被搜索的各种node_modules目录。

清单 2-20node_modules目录的搜索顺序示例

/some/path/node_modules
/some/node_modules
/node_modules

文件扩展名处理

如果require()没有找到完全匹配,它会尝试添加.js.json.node文件扩展名。如第一章所述,.js文件被解释为 JavaScript 源代码,.json文件被解析为 JSON 源代码,.node文件被视为编译后的附加模块。如果 Node 仍然找不到匹配,就会抛出一个错误。

还可以使用内置的require.extensions对象以编程方式添加对附加文件扩展名的支持。最初,这个对象包含三个键,.js.json.node。每个键映射到一个函数,该函数定义了require()如何导入该类型的文件。通过扩展require.extensions,可以自定义require()的行为。例如,清单 2-21 扩展了require.extensions,使得.javascript文件被视为.js文件。

清单 2-21 。扩展require.extensions对象以支持额外的文件类型

require.extensions[".javascript"] = require.extensions[".js"];

您甚至可以添加自定义处理程序。在清单 2-22 ,.javascript文件使require()将导入文件的数据打印到控制台。

清单 2-22 。向require.extensions对象添加自定义处理程序

require.extensions[".javascript"] = function() {
 console.log(arguments);
};

image 注意虽然这个特性最近被弃用,但是模块系统 API 被锁定,所以require.extensions不太可能完全消失。官方文档推荐将非 JavaScript 模块包装在另一个 Node 程序中,或者先验地编译成 JavaScript。

解析模块位置

如果您只对了解包的位置感兴趣,可以使用require.resolve()函数,它使用与require()相同的机制来定位模块。然而,resolve()并没有真正加载模块,而是只返回模块的路径。如果传递给resolve()的模块名是核心模块,则返回该模块的名称。如果模块是文件模块,resolve()返回模块的文件名。如果 Node 找不到指定的模块,则会引发错误。清单 2-23 中的例子显示了resolve()在 REPL 环境中的用法。

清单 2-23 。使用require.resolve()定位http模块

> require.resolve("http");
'http'

模块缓存

成功加载的文件模块缓存在require.cache对象中。同一模块的后续导入将返回缓存的对象。一个警告是,解析的模块路径必须完全相同。这是因为模块通过其解析的路径进行缓存。因此,缓存成为导入模块和调用脚本的功能。假设你的程序依赖于两个模块,foobar。第一个模块foo没有依赖关系,但是bar依赖foo。产生的依赖层次结构如清单 2-24 所示。假设foo驻留在node_modules目录中,它被加载两次。第一次加载发生在foo解析到your-project/node_modules/foo目录时。当从bar引用foo并解析为your-project/node_modules/foo/node_modules时,发生第二次加载。

清单 2-24 。一个依赖层次结构,其中foo被多次引用

your-project
├── foo@1.0.0
└─┬ bar@2.0.0
     └── foo@1.0.0

package.json文件

在前面的部分中,您看到了npm识别包之间的依赖关系并相应地安装模块。但是npm如何理解模块依赖 ies 的概念呢?事实证明,所有相关信息都存储在名为package.json的配置文件中,该文件必须位于项目的根目录下。正如文件扩展名所暗示的,文件必须包含有效的 JSON 数据。从技术上来说,你不需要提供一个package.json,但是如果没有的话,npm将无法访问你的代码。

package.json中的 JSON 数据应该符合特定的模式。最低限度,你必须为你的包指定一个名字和版本。没有这些字段,npm将无法处理您的包裹。最简单的package.json文件如清单 2-25 所示。包的名称由name字段指定。该名称应该在npm注册表中唯一地标识您的包。通过使用npm,该名称成为 URL、命令行参数和目录名的一部分。因此,名称不能以点或下划线开头,也不能包含空格或任何其他非 URL 安全字符。最佳实践还规定,名称应该简短且具有描述性,并且不包含“js”或“node”,因为它们是隐含的。此外,如果您计划向公众发布您的包,请验证该名称在npm注册表中是否可用。

清单 2-25 。最小的package.json文件

{
  "name": "package-name",
  "version": "0.0.0"
}

包的版本在version字段中指定。当与名称结合时,版本为包提供了真正唯一的标识符。版本号指定了主版本号、次版本号和补丁号,用点分隔(npm允许版本以v字符开头)。您还可以通过在修补程序编号后附加一个标记来指定内部版本号。有两种类型的标签,预发布和发布后。后发布标签增加版本号,而预发布标签减少版本号。发布后标签是一个连字符后跟一个数字。所有其他标签都是预发布标签。清单 2-26 中的例子展示了版本标记的作用。几个带标签的版本和一个不带标签的版本(0.1.2)按降序排列。

清单 2-26 。几个带标签的版本和一个不带标签的版本按降序排列

0.1.2-7
0.1.2-7-beta
0.1.2-6
0.1.2
0.1.2beta

描述和关键字

description字段用于提供您的包的文本描述。类似地,使用keywords字段提供一组关键字来进一步描述您的包。关键字和描述帮助人们发现你的包,因为它们是由npm search命令搜索的。清单 2-27 显示了包含descriptionkeywords字段的package.json摘录。

清单 2-27 。在package.json文件中指定描述和关键字

"description": "This is a description of the module",
"keywords": [
  "foo",
  "bar",
  "baz"
]

作者和撰稿人

项目的主要作者在author字段中指定。该字段只能包含一个条目。然而,第二个字段contributors可以包含对项目做出贡献的人员的数组。有两种方法可以指定一个人。第一个是包含nameemailurl字段的对象。清单 2-28 中显示了这种语法的一个例子。该示例指定了一个主要作者和两个额外的投稿人。

清单 2-28 。在package.json文件中指定作者和贡献者

"author": {
  "name": "Colin Ihrig",
  "email": "colin@domain.com",
  "url": "http://www.cjihrig.com"
},
"contributors": [
  {
    "name": "Jim Contributor",
    "email": "jim@domain.com",
    "url": "http://www.domain.com"
  },
  {
    "name": "Sue Contributor",
    "email": "sue@domain.com",
    "url": "http://www.domain.com"
  }
]

或者,表示人的对象可以写成字符串。在一个字符串中,一个人由名字指定,然后由尖括号内的电子邮件地址指定,后面是圆括号内的 URL。在清单 2-28 中显示的对象语法已经在清单 2-29 中使用字符串重写。

清单 2-29 。将作者和贡献者指定为字符串而不是对象

"author": "Colin Ihrig <colin@domain.com> (http://www.cjihrig.com)",
"contributors": [
  "Jim Contributor <jim@domain.com> (http://www.domain.com)",
  "Sue Contributor <sue@domain.com> (http://www.domain.com)"
]

主入口点

由于包可以由许多文件组成,Node 需要某种方法来标识它的主入口点。像大多数其他配置选项一样,这是在package.json文件中处理的。在main字段中,您可以告诉 Node 在使用require()导入您的模块时加载哪个文件。假设您的模块名为foo,但是它的主入口点位于一个名为bar.js的文件中,该文件位于src子目录中。您的package.json文件应该包含清单 2-30 中的main字段。

清单 2-30 。指定包的主入口点

"main": "./src/bar.js"

preferGlobal设置

有些包是打算全局安装的,但是没有办法实际执行这个意图。然而,如果用户通过包含preferGlobal字段并将其设置为true来本地安装您的模块,您至少可以生成一个警告。同样,这将而不是阻止用户执行本地安装。

依赖性

包依赖关系在package.json文件的dependencies字段中指定。这个字段是一个将包名映射到版本字符串的对象。版本字符串可以是npm理解的任何版本表达式,包括 git 和 tarball URLs。清单 2-31 显示了一个仅依赖于commander的包的dependencies字段的例子。

清单 2-31 。一个简单的dependencies字段

"dependencies": {
  "commander": "1.1.x"
}

注意commander的版本字符串使用了清单 2-31 中的x通配符。在指定模块依赖关系时,使用这种语法通常被认为是最佳实践,因为主版本和次版本更新可能表示不兼容的更改,而补丁更改通常仅表示错误修复。保持软件包更新是好的,但是只有在彻底测试之后才这样做。例如,如果在清单 2-31 中使用的版本字符串是>= 1.1.0,那么在更新到版本 1.2.0 后,程序中可能会神秘地出现 bug。为了在安装新的软件包时自动更新dependencies字段,在npm install命令后添加--save标志。因此,要在安装期间将commander添加到package.json文件中,发出命令npm install commander --save

发展依赖性

许多包都有仅用于测试和开发的依赖项。这些包不应包含在dependencies字段中。相反,将它们放在单独的devDependencies字段中。例如,mocha包是 Node 社区中常用的一个流行的测试框架。使用mocha进行测试的包应该在devDependencies字段中列出,如清单 2-32 所示。

清单 2-32 。将mocha列为发展依赖

"devDependencies": {
  "mocha": "∼1.8.1"
}

开发依赖性也可以自动添加到package.json文件中。为此,将--save-dev标志附加到npm install命令上。命令npm install mocha --save-dev就是一个例子。

可选依赖项

可选依赖项是您希望使用但不需要的包,例如,提高加密性能的模块。如果可以的话,一定要使用它。如果由于某种原因它不可用,您的应用可以依靠一个较慢的替代方案。通常,如果依赖项不可用,npm将会失败。对于可选的依赖项,npm将继续执行,尽管它们不存在。与devDependencies一样,可选的依赖项列在一个单独的optionalDependencies字段中。通过将--save-optional标志指定给npm install,可选的依赖项也可以在安装过程中自动添加到package.json文件中。

如果您选择使用可选的依赖项,您的程序仍然必须考虑到包不存在的情况。这是通过在try...catchif语句中包装对模块的引用来实现的。在清单 2-33 的例子中,commander被假定为一个可选的依赖项。由于require()函数在commander不存在时抛出异常,所以它被包装在try...catch语句中。在程序的后面,在使用之前检查commander是否有定义的值。

清单 2-33 。引用可选依赖项时使用防御性编程

var commander;

try {
  commander = require("commander");
} catch (exception) {
  commander = null;
}

if (commander) {
  // do something with commander
}

发动机

engines 字段用于指定模块使用的nodenpm的版本。引擎版本控制类似于用于依赖关系的方案。然而,最佳实践会有所不同,这取决于您是在开发独立的应用还是可重用的模块。应用应该使用保守的版本控制来确保新发布的依赖项不会引入错误。另一方面,可重用模块应该使用积极的版本控制,以确保尽可能地使用最新版本的 Node。清单 2-34 中的例子包括一个engines字段。在这个例子中,node字段使用积极的版本控制,总是选择最新的版本。同时,npm版本字符串比较保守,只允许补丁更新。

清单 2-34 。在package.json文件中定义支持的引擎版本

"engines": {
  "node": ">=0.10.12",
  "npm": "1.2.x"
}

剧本

当存在时,scripts字段包含npm命令到脚本命令的映射。脚本命令可以是任何可执行命令,在外部 shell 进程中运行。两个最常见的命令是startteststart命令启动您的应用,而test运行您的应用的一个或多个测试脚本。在清单 2-35 的例子中,start命令导致node执行文件server.jstest命令显示没有指定测试。在真实的应用中,test可能会调用mocha或其他一些测试框架。

清单 2-35 。在package.json文件中指定一个scripts字段

"scripts": {
  "start": "node server.js",
  "test": "echo \"Error: no test specified\" && exit 1"
}

image 注意尽可能避免使用特定于平台的命令。例如,使用 Makefile 是 Unix 系统上的常见做法,但是 Windows 没有make命令。

要执行starttest命令,只需将命令名传递给npm。清单 2-36 ,基于清单 2-35 中的scripts字段,显示了test命令的输出。您可以从输出中看到npm将非零退出代码视为错误并中止命令。

清单 2-36 。启动npm test命令

$ npm test

> example@0.0.0 test /home/node/example
> echo "Error: no test specified" && exit 1

\"Error: no test specified\"
npm ERR! Test failed.  See above for more details.
npm ERR! not ok code 0

请注意,您不能简单地添加任意命令并从npm调用它们。例如,发出命令npm foo将不起作用,即使您已经在scripts字段中定义了foo。还有一些命令充当钩子,在某些事件发生时执行。例如,installpostinstall命令是在使用npm install安装包之后执行的。scripts字段(见清单 2-37 )使用这些命令显示软件包安装后的消息。要获得可用脚本命令的完整列表,请发出命令npm help scripts

清单 2-37 。一些 npm 挂钩

"scripts": {
  "install": "echo \"Thank you for installing!\"",
  "postinstall": "echo \"You're welcome!\""
}

附加字段

package.json文件中通常可以找到许多其他字段。例如,您可以在homepage字段中列出项目的主页,在license字段中列出软件许可类型,在repository字段中列出项目源代码所在的存储库。如果您计划将您的模块发布到npm注册中心,那么repository字段尤其有用,因为您的模块的npm页面将包含到您的存储库的链接。此外,通过包含一个repository字段,用户可以使用命令npm repo module-name快速导航到存储库(其中module-name是您的模块的npm名称)。

只要没有命名冲突,您甚至可以添加自己的特定于应用的字段。有关package.json文件的更多信息,请发出命令npm help json

生成 package.json 文件

虽然一个package.json文件的语法并不复杂,但是它可能会很乏味并且容易出错。最困难的部分可能是记住你的包的依赖项和它们的版本。为了帮助缓解这个问题,Node 提供了npm init,这是一个命令行向导,提示您输入关键字段的值,并自动生成一个package.json文件。如果您已经有了一个package.json文件,npm init会维护它的所有信息,只添加新信息。

例如,假设您有一个名为foo-module的项目目录。在那个目录里面是foo.js,你的模块的主要入口点。您的模块只有一个依赖项,commander,它是在开发过程中安装的。此外,您还有一个测试脚本test.js,它测试您的模块。现在是创建package.json文件的时候了。发出命令npm init,逐步完成清单 2-38 中所示的向导。

清单 2-38 。使用npm init 生成一个package.json文件

$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sane defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (foo-module)
version: (0.0.0) 1.0.0
description: An awesome new Node module.
entry point: (foo.js)
test command: test.js
git repository:
keywords: node, awesome, foo
author: Colin Ihrig <cjihrig@domain.com>
license: (BSD)
About to write to /home/colin/foo-module/package.json:

{
  "name": "foo-module",
  "version": "1.0.0",
  "description": "An awesome new Node module.",
  "main": "foo.js",
  "dependencies": {
    "commander": "∼1.1.1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "test.js"
  },
  "repository": "",
  "keywords": [
    "node",
    "awesome",
    "foo"
  ],
  "author": "Colin Ihrig <cjihrig@domain.com>",
  "license": "BSD"
}

Is this ok? (yes)
npm WARN package.json foo-module@1.0.0 No README.md file found!

请注意,一些值,包括名称foo-module,都用括号括起来了。这些值都是npm的猜测。你可以按下Enter键来接受它们。如果您想使用自己的值,只需在按下Enter前输入即可。对于某些字段,如descriptionnpm就不提供猜测了。在这些情况下,您可以提供一个值或将该字段留空,如git repository字段所示。在向导的最后,npm显示生成的 JSON 数据。此时,要么接受建议的数据并生成package.json文件,要么中止整个过程。

最后,npm提供了一条警告消息,表明没有找到README.md文件。README.md是一个可选的推荐文件,它提供了关于你的模块的文档。.md文件扩展名表示该文件包含 降价数据。Markdown 是一种标记语言,很容易转换为 HTML,但比 HTML 更容易阅读,它是 Node 文档的天然选择,因为 GitHub 能够显示 Markdown,并且大多数 Node 项目都托管在 GitHub 上。在你的项目根目录中总是包含一个README.md文件是一个好的惯例。如果存在,文件名使用readmeFilename域在package.json文件中指定。清单 2-39 中的例子显示了一个降价文件。GitHub 上呈现的相同降价显示在图 2-1 中。关于 Markdown 语法的其他信息在网上随处可见。

清单 2-39 。使用降价语法

#Level One Heading
This test is *italicized*, while this text is **bold**.

##Level Two Heading
By combining the two, this text is ***bold and italicized***.

9781430258605_Fig02-01.jpg

图 2-1 。GitHub 上呈现的清单 2-39 的降价

一个完整的例子

这可能是查看包含依赖项的 Node 程序的完整示例的好时机。在这个例子中,我们将创建一个 Hello World 风格的程序,它将彩色文本打印到控制台。为了创建彩色文本,程序将导入一个名为colors的第三方模块。示例程序的源代码如清单 2-40 所示。将源代码添加到名为colors-test.js的文件中并保存。第一行代码使用require()函数导入colors模块。第二行将消息"Hello Node!"打印到控制台。附加到控制台消息的.rainbow使字符串中的字符以各种颜色打印出来。

清单 2-40 。使用colors模块打印彩虹文本

var colors = require("colors");

console.log("Hello Node!".rainbow);

由于colors不是核心模块,运行程序前需要安装。为此,发出命令npm install colors。安装完成后,发出命令node colors-test执行程序。您应该会在控制台上看到一条彩色的消息。如果你是团队的一员,其他人将需要运行你的代码。对于这么小的程序,只有一个依赖项,您的团队成员可以简单地将您的代码从源代码控制中签出并安装colors。然而,这种方法对于具有数十甚至数百个依赖项的大型程序来说并不真正可行。如果你想让其他人运行你的重要程序,你必须提供一个package.json文件。要生成package.json,运行npm init。逐步执行向导,根据需要输入值。(该项目的示例package.json文件如清单 2-41 所示。)您的程序现在可以只安装您的源代码、package.json文件和npm

清单 2-41 。彩虹文本程序的package.json文件

{
  "name": "colors-test",
  "version": "1.0.0",
  "description": "An example program using the colors module.",
  "main": "colors-test.js",
  "dependencies": {
    "colors": "∼0.6.0-1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": "",
  "keywords": [
    "colors",
    "example"
  ],
  "author": "Colin Ihrig <cjihrig@domain.com>",
  "license": "BSD"
}

image 注意很多开发者并不将node_modules文件夹检入源代码控制。因为这个文件夹可以使用npm重新生成,所以在源代码管理中排除它可以节省空间。然而,应用开发人员应该考虑提交他们的node_modules文件夹,以避免在依赖关系引入不兼容的变更时可能出现的神秘错误。不幸的是,当应用加载到不同的机器或操作系统上时,这会带来问题。另一种方法是使用npm shrinkwrap实用程序锁定已知有效的确切模块版本。shrinkwrap不仅锁定顶层依赖关系的版本,还锁定所有依赖关系的版本(这不能通过package.json文件完成)。不用将node_modules签入源代码控制,只需运行npm shrinkwrap,并签入结果npm-shrinkwrap.json文件(与package.json在同一个目录下)。另一方面,模块开发者不应该提交他们的依赖或者使用shrinkwrap。相反,他们应该努力确保他们的代码尽可能地跨版本兼容。

模块创作

到目前为止,这一章的重点是使用现有的模块。这一节解释了模块实际上是如何产生的。在 Node 中,模块和文件是一一对应的。这意味着一个文件是一个可以使用require()导入到其他文件中的模块。为了演示这个概念,在同一个目录中创建两个文件,foo.jsbar.jsfoo.js的内容如清单 2-42 所示。该文件导入第二个文件bar.js,其内容如清单 2-43 所示。在foo.js 内部,require()的返回值保存在变量bar中,打印到控制台。

清单 2-42foo.js的内容,导入文件bar.js

var bar = require("./bar");

console.log(bar);

bar.js内部,定义了一个名为bar()的函数。该模块包含两个打印语句,一个在模块级,另一个在bar()函数 中。

清单 2-43 。在清单 2-42 中导入的bar.js的内容

function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

要运行这个示例,发出命令node foo.js。结果输出如清单 2-44 所示。对foo.js中的require()的调用导入了bar.js,这导致第一条消息被打印出来。接下来,打印bar变量,显示一个空对象。基于这个例子,有两个问题需要回答。第一,空的对象到底是什么?第二,如何从bar.js外部调用bar()函数。

清单 2-44 。运行清单 2-42 中的代码的输出

$ node foo.js
Inside of bar module
{}

module物体

Node 在每个代表当前模块的文件中提供了一个自由变量modulemodule是包含名为exports的属性的对象,默认为空对象。exports的值由require()函数返回,定义了一个模块的公共接口。由于exports在清单 2-43 中从未被修改,这解释了在清单 2-44 中看到的空对象。

为了使bar()函数在bar.js之外可用,我们有两种选择。首先,bar可以被分配给bar.js内部的module.exports(如清单 2-45 所示)。请注意,exports对象已经被一个函数覆盖。

清单 2-45 。重写bar.js以导出bar()

module.exports = function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

foo.js然后可以访问bar()功能,如清单 2-46 所示。因为bar变量现在指向一个函数,所以可以直接调用它。

清单 2-46 。重写foo.js以从清单 2-45 中访问bar()

var bar = require("./bar");

console.log(bar);
bar();

这种方法的缺点是bar模块只能导出bar()函数。第二种选择是简单地将bar()函数附加到现有的exports对象上,如清单 2-47 所示。这种技术允许模块导出任意数量的方法和属性。为了适应这种变化,foo.js将访问bar()函数作为bar.bar()

清单 2-47 。通过扩充现有的exports对象导出bar()

module.exports.bar = function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

module对象提供了其他几个不常用的属性。这些属性总结在表 2-2 中。

表 2-2 。模块对象的附加属性

|

财产

|

描述

| | --- | --- | | id | 模块的标识符。通常这是模块的完全解析文件名。 | | filename | 模块的完全解析文件名。 | | loaded | 表示模块状态的布尔值。如果模块已经完成加载,这将是true。否则就是false。 | | parent | 一个对象,表示加载当前模块的模块。 | | children | 表示由当前模块导入的模块的对象数组。 |

发布到npm

为了将您的模块发布到npm,您必须首先创建一个npm用户帐户。清单 2-48 展示了建立一个npm账户所需的命令。前三个命令用于关联您的个人信息。最后一个命令npm adduser,将提示您输入用户名并创建一个npm账户(假设用户名可用)。帐户创建后,用户发布的模块可以在https://npmjs.org/∼username查看。

清单 2-48 。创建 npm 用户帐户

npm set init.author.name "John Doe"
npm set init.author.email "john@domain.com"
npm set init.author.url "http://www.johnspage.com"
npm adduser

在设置了一个npm帐户之后,您必须为您的模块创建一个package.json文件。本章已经介绍了这样做的过程。最后,发出命令npm publish来基于package.json文件创建一个npm条目。

摘要

这一章已经涵盖了大量的材料——这是必须的。开发 Node 应用的很大一部分是使用npm和第三方包。从本章开始,你应该已经很好地掌握了npmrequire()函数、package.json文件和模块创作。虽然整个软件包系统不能在一章中全面介绍,但是你现在应该知道足够的知识来完成本书的其余部分。通过阅读在线文档来填补知识上的空白。

三、Node 编程模型

在尝试编写任何有意义的 Node 应用之前,了解幕后发生的事情很重要。可能需要理解的最重要的一点是 JavaScript——以及扩展 Node——是单线程的。这意味着 Node 应用一次只能做一件事。然而,JavaScript 可以通过使用事件循环给人一种多线程的错觉。事件循环用于在 Node 的事件驱动编程模型中调度任务。每次事件发生时,它都被放入 Node 的事件队列中。在事件循环的每次迭代中,单个事件会出队并被处理。如果在处理过程中,此事件创建了任何其他事件,它们将被简单地添加到队列的末尾。当事件被完全处理后,控制返回到事件循环,并处理另一个事件。

清单 3-1 中的例子说明了事件循环如何允许多个任务并行执行。在本例中,setInterval()用于创建两个周期性任务,每个任务每秒运行一次。第一个任务是显示字符串foo的函数,而第二个任务显示bar。当应用运行时,setInterval()使每个功能大约每 1000 毫秒运行一次。结果是foobar每秒打印一次。记住,要执行一个 Node 程序,只需键入"node",后跟程序的文件名。

清单 3-1 。一个给出多线程执行错觉的示例应用

setInterval(function() {
  console.log("foo");
}, 1000);

setInterval(function() {
  console.log("bar");
}, 1000);

基于清单 3-1 中的代码,JavaScript 似乎在同时做多件事。不幸的是,验证它真正的单线程本质太容易了。在清单 3-2 中,一个无限循环被引入到一个重复函数中。无限循环阻止第一个函数返回。因此,控制永远不会传递回事件循环,从而阻止其他任何事情的执行。如果代码是真正多线程的,那么bar将继续被打印到控制台,即使其他函数陷入了无限循环。

清单 3-2 。通过引入无限循环利用 Node 的单线程特性

setInterval(function() {
  console.log("foo");

  while (true) {
  }
}, 1000);

setInterval(function() {
  console.log("bar");
}, 1000);

异步编程

Node 编程模型的另一个重要方面是几乎所有事情都是异步完成的。异步是如此普遍,以至于许多同步函数在其名称中包含字符串sync以避免混淆。在 Node 的范式下,有时被称为延续传递风格 (CPS)编程,异步函数需要一个额外的参数,这个函数在异步代码完成执行后被调用。这个额外的参数被称为延续,或者更常见的是回调函数

清单 3-3 中显示了一个异步函数调用的例子。这段代码从文件系统中读取一个文件,并将内容打印到屏幕上。访问文件系统将在本书的后面重新讨论,但是现在,这个例子应该足够简单,容易理解。第一行中导入的核心模块fs用于处理文件系统。readFile()方法异步工作,使用 UTF-8 编码读入文件foo.txt。一旦文件被读取,匿名回调函数被调用。回调函数有两个参数,errordata,它们分别代表错误条件和文件内容。

清单 3-3 。异步文件读取的一个例子

var fs = require("fs");

fs.readFile("foo.txt", "utf8", function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
});

console.log("Reading file...");

这个简短的例子说明了 Node 开发人员的两个重要约定。首先,如果一个方法将回调函数作为参数,那么它应该是最后一个参数。第二,如果一个方法将错误作为参数,它应该是第一个参数。这些不是语言的规则,而是 Node 开发人员社区中普遍认同的调用约定。

当这个程序被执行时,它展示了异步编程的另一个重要方面。为了测试示例程序,将源代码保存在名为file-reader.js的文件中。接下来,在与 Node 脚本相同的目录中创建第二个文件foo.txt。为简单起见,只需将单词"foo"添加到文件中,并保存它。清单 3-4 显示了运行示例程序的输出。注意,消息Reading file...显示在文件内容之前,尽管消息直到最后一行代码才打印出来。

清单 3-4 。文件读取器示例程序的控制台输出

$ node file-reader.js
Reading file...
foo

readFile()被调用时,它对文件系统进行一个非阻塞 I/O 调用。I/O 是非阻塞的这一事实意味着 Node 不等待文件系统返回数据。相反,Node 继续下一条语句,这恰好是一个console.log()调用。最终,文件系统返回foo.txt的内容。发生这种情况时,调用readFile()回调函数,显示文件内容。这种行为似乎与 Node 程序是单线程的事实相矛盾,但是您必须记住,文件系统不是 Node 的一部分。

回调地狱

Node 中使用的 CPS 语法很容易导致被称为回调地狱的情况。当回调嵌套在几个级别的其他回调中时,就会出现回调地狱。这可能导致代码混乱,难以阅读和维护。回调地狱有时被称为末日金字塔,它的名字来自于代码所呈现的金字塔结构。

举个例子,让我们重温一下清单 3-3 中的文件阅读器程序。如果我们要访问一个不存在的文件,就会抛出一个异常,程序就会崩溃。为了使程序更健壮,首先要检查文件是否存在,并且它确实是一个文件(不是目录或其他结构)。修改后的程序如清单 3-5 所示。注意,程序现在包含对fs.exists()fs.stat()的调用,以及对readFile()的原始调用。由于所有这些都利用了回调函数,代码缩进的级别增加了。将这一点与类似于if语句的结构中的缩进结合起来,您会看到回调地狱如何成为复杂 Node 应用中的一个问题。

清单 3-5 。一个带有回调地狱的文件阅读器程序开始悄悄进入

var fs = require("fs");
var fileName = "foo.txt";

fs.exists(fileName, function(exists) {
  if (exists) {
    fs.stat(fileName, function(error, stats) {
      if (error) {
        throw error;
      }

      if (stats.isFile()) {
        fs.readFile(fileName, "utf8", function(error, data) {
          if (error) {
            throw error;
          }

          console.log(data);
        });
      }
    });
  }
});

在本章的后面,你将了解到async,一个可以帮助防止回调地狱的模块。但是,您也可以通过使用小型命名函数作为回调,而不是嵌套的匿名函数来避免这个问题。例如,清单 3-6 重构了清单 3-5 来使用命名函数。注意,对命名函数cbExists()cbStat()cbReadFile()的引用已经取代了匿名回调函数。缺点是代码稍长,可能更难理解。对于这么小的应用来说,这可能有点过了,但是对于大型应用来说,这对于整个软件架构来说是必不可少的。

清单 3-6 。重构了文件读取器示例以防止回调崩溃

var fs = require("fs");
var fileName = "foo.txt";

function cbReadFile(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
}

function cbStat(error, stats) {
  if (error) {
    throw error;
  }

  if (stats.isFile()) {
    fs.readFile(fileName, "utf8", cbReadFile);
  }
}

function cbExists(exists) {
  if (exists) {
    fs.stat(fileName, cbStat);
  }
}

fs.exists(fileName, cbExists);

异常处理

异步代码对异常处理也有很大的影响。在同步 JavaScript 代码中,try ... catch ... finally语句用于处理错误。然而,Node 的回调驱动特性允许函数在定义它们的错误处理代码之外执行。例如,清单 3-7 将传统的错误处理添加到来自清单 3-3 的文件阅读器示例中。此外,要读取的文件名已被硬编码为空字符串。因此,当调用readFile()时,它无法读取文件并填充回调函数的error参数。然后回调函数抛出错误。直觉上,人们假设catch子句将处理抛出的错误。然而,当回调函数被执行时,try ... catch语句不再是调用堆栈的一部分,异常被置之不理。

清单 3-7 。异步错误处理的错误尝试

var fs = require("fs");

try {
  fs.readFile("", "utf8", function(error, data) {
    if (error) {
      throw error;
    }

    console.log(data);
  });
} catch (exception) {
  console.log("The exception was caught!")
}

同步异常仍然可以用try...catch...finally语句处理,但是你会发现它们在 Node 中相对无用。大多数 Node 异常都是异步的,可以用多种方式处理。首先,所有接受错误参数的函数都应该检查它——至少清单 3-7 中的例子做到了这一点。在本例中,异常已经被检测到,但随后立即再次被抛出。当然,在实际的应用中,您会希望处理错误,而不是抛出它。

处理异步异常的第二种方法是为流程的uncaughtException事件设置一个全局事件处理程序。Node 提供了一个名为process的全局对象,它与 Node 流程进行交互。当一个未处理的异常一路冒泡回到事件循环时,一个uncaughtException错误被创建。这个异常可以使用process对象的on()方法来处理。清单 3-8 显示了一个全局异常处理程序的例子。

清单 3-8 。全局异常处理程序的示例

var fs = require("fs");

fs.readFile("", "utf8", function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
});

process.on("uncaughtException", function(error) {
  console.log("The exception was caught!")
});

虽然全局异常处理程序对于防止崩溃很有用,但是它们不应该用于从错误中恢复。如果处理不当,异常会使您的应用处于不确定的状态。试图摆脱这种状态会带来额外的错误。如果你的程序包含一个全局异常处理程序,那么只使用它来优雅地终止程序。

域是处理 Node 中异步错误的首选机制。域,一个相对较新的特性(在 0.8 版本中引入),允许将多个 I/O 操作分组到一个单元中。当一个定时器、事件发射器(在第四章的中介绍)或者在一个域中注册的回调函数产生一个错误时,该域会得到通知,这样错误就可以得到适当的处理。

清单 3-9 中的例子展示了域是如何被用来处理异常的。在示例的第二行,导入了domain模块,并创建了一个新的域。然后使用域的run()方法来执行提供的函数。在run()的上下文中,所有的定时器、事件发射器和回调方法都隐式地注册到域中。当抛出一个错误时,它触发域的错误处理程序。当然,如果没有定义处理函数,异常就会导致程序崩溃。最后,当不再需要该域时,调用它的dispose()方法。

清单 3-9 。使用域的异常处理

var fs = require("fs");
var domain = require("domain").create();

domain.run(function() {
  fs.readFile("", "utf8", function(error, data) {
    if (error) {
      throw error;
    }

    console.log(data);
    domain.dispose();
  });
});

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

显式绑定

如前所述,在run()的上下文中创建的定时器、事件发射器和回调函数被隐式地注册到相应的域中。但是,如果您创建了多个域,那么您可以显式地绑定到另一个域,甚至是在run()的上下文中。例如,清单 3-10 创建了两个域,d1d2。在d1run()方法中,创建了一个抛出错误的异步定时器。因为异常发生在d1run()回调中,所以异常通常由d1处理。然而,定时器是使用add()方法向d2显式注册的。因此,当抛出异常时,d2的错误处理程序被触发。

清单 3-10 。使用域的绑定回调函数示例

var domain = require("domain");
var d1 = domain.create();
var d2 = domain.create();

d1.run(function() {
  d2.add(setTimeout(function() {
    throw new Error("test error");
  }, 1));
});

d2.on("error", function(error) {
  console.log("Caught by d2");
});

d1.on("error", function(error) {
  console.log("Caught by d1")
});

正如我们刚刚看到的,add()用于显式地将定时器绑定到一个域。这也适用于事件发射器。类似的方法remove()从域中删除一个计时器或事件发射器。清单 3-11 展示了如何使用remove()解除一个定时器的绑定。需要注意的非常重要的一点是,从d2中移除timer变量并不会自动将其绑定到d1。相反,由timer的回调函数抛出的异常没有被捕获,程序崩溃。

清单 3-11 。使用remove()解除定时器与域的绑定

var domain = require("domain");
var d1 = domain.create();
var d2 = domain.create();

d1.run(function() {
  var timer = setTimeout(function() {
    throw new Error("test error");
  }, 1);

  d2.add(timer);
  d2.remove(timer);
});

d2.on("error", function(error) {
  console.log("Caught by d2");
});

d1.on("error", function(error) {
  console.log("Caught by d1")
});

image 注意每个域都有一个数组属性members,它包含所有明确添加到域中的定时器和事件发射器。

域还提供了一个bind()方法,可以用来向域显式注册回调函数。这很有用,因为它允许将一个函数绑定到一个域,而不像run()那样立即执行该函数。bind()方法将回调函数作为唯一的参数。返回的函数是原始回调的注册包装。与run()方法一样,异常通过域的错误处理程序来处理。清单 3-12 回顾了使用域bind()方法处理与readFile()回调函数相关的错误的文件阅读器示例。

清单 3-12 。使用域的绑定回调函数示例

var fs = require("fs");
var domain = require("domain").create();

fs.readFile("", "utf8", domain.bind(function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
  domain.dispose();
}));

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

还有一种方法intercept(),与bind()几乎相同。除了捕捉任何抛出的异常,intercept()还检测任何作为回调函数的第一个参数传递的Error对象。这消除了检查传递给回调函数的任何错误的需要。例如,清单 3-13 使用intercept()方法重写了清单 3-12 。这两个例子行为相同,但是注意在 3-13 中回调不再有error参数。我们还删除了用于检测error参数的if语句。

清单 3-13 。使用域intercept()方法的错误处理

var fs = require("fs");
var domain = require("domain").create();

fs.readFile("", "utf8", domain.intercept(function(data) {
  console.log(data);
  domain.dispose();
}));

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

async模块

async是第三方开源模块,对于管理异步控制流非常有用。在撰写本文时,asyncnpm注册表中第二个最依赖的模块。虽然最初是为 Node 应用开发的,async也可以在客户端使用,因为该模块受到许多流行浏览器的支持,包括 Chrome、Firefox 和 Internet Explorer。开发人员可以提供一个或多个函数,并使用async模块定义它们将如何执行——是串行执行还是以指定的并行度执行。鉴于该模块的受欢迎程度、灵活性和强大功能,async是本书中第一个全面探讨的第三方模块。

串行执行

异步开发最具挑战性的方面之一是在保持代码可读的同时,强制执行函数的顺序。然而,使用async,强制串行执行只是使用series()方法的问题。作为它的第一个参数,series()接受一个数组或对象,其中包含要按顺序执行的函数。每个函数都将回调作为参数。按照 Node 约定,每个回调函数的第一个参数是一个错误对象,如果没有错误,则为null,。回调函数还接受一个可选的第二个参数来表示返回值。调用回调函数导致series()移动到下一个函数。但是,如果有任何函数向它们的回调函数传递错误,那么其余的函数都不会被执行。

series()方法也接受可选的第二个参数,这是在所有函数完成后调用的回调。这个最后的回调接受两个参数,一个错误和一个包含函数结果的数组或对象。如果任何函数向它们的回调函数传递错误,控制会立即传递给最后一个回调函数。

清单 3-14 包含三个定时器任务,每个任务填充results数组的一个元素。在本例中,任务 1 用了 300 毫秒完成,任务 2 用了 200 毫秒,任务 3 用了 100 毫秒。假设我们希望任务按顺序运行,那么需要重新构造代码,以便从任务 2 调用任务 3,而任务 2 又从任务 1 调用任务 3。此外,我们无法知道所有任务何时完成,结果何时准备好。

清单 3-14 。在没有建立控制流的情况下执行定时器任务的示例

var results = [];

setTimeout(function() {
  console.log("Task 1");
  results[0] = 1;
}, 300);

setTimeout(function() {
  console.log("Task 2");
  results[1] = 2;
}, 200);

setTimeout(function() {
  console.log("Task 3");
  results[2] = 3;
}, 100);

清单 3-15 显示了运行前一个例子的结果。请注意,任务没有按照正确的顺序执行,也没有办法验证任务返回的结果。

清单 3-15 。验证任务执行顺序错误的控制台输出

$ node timer-tasks
Task 3
Task 2
Task 1

清单 3-16 展示了我们如何使用asyncseries()方法来解决所有与控制流相关的问题,而不会使代码变得复杂。第一行导入了async模块,正如您在第二章中了解到的,可以使用命令npm install async 安装该模块。接下来,调用series(),用一组包含原始定时器任务的函数封装在匿名函数中。在每个任务中,期望的返回值作为回调函数的第二个参数传递。对series()的调用还包括一个最终回调函数,它解决了不知道所有结果何时准备好的问题。

清单 3-16 。使用Async串行执行功能的示例

var async = require("async");

async.series([
  function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
], function(error, results) {
  console.log(results);
});

清单 3-17 显示了清单 3-16 的控制台输出,它验证了三个任务是按照指定的顺序执行的。此外,最后的回调提供了检查结果的机制。在这种情况下,结果被格式化为数组,因为任务函数是在数组中传递的。如果使用对象传递任务,结果也会被格式化为对象。

清单 3-17 。清单 3-16 中代码的控制台输出

$ node async-series
Task 1
Task 2
Task 3
[ 1, 2, 3 ]

处理错误

如前所述,如果任何函数向它们的回调函数传递一个错误,执行会立即短路到最后一个回调函数。在清单 3-18 中,第一个任务中故意引入了一个错误。此外,为了简洁起见,第三个任务已经被删除,最后一个回调现在检查错误。

清单 3-18 。系列示例已经过修改,包含了一个错误

var async = require("async");

async.series([
  function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(new Error("Problem in Task 1"), 1);
    }, 200);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 100);
  }
], function(error, results) {
  if (error) {
    console.log(error.toString());
  } else {
    console.log(results);
  }
});

引入错误后的结果输出如列表 3-19 所示。请注意,第一个任务中的错误阻止了第二个任务的执行。

清单 3-19 。出现错误时的控制台输出

$ node async-series-error
Task 1
Error: Problem in Task 1

并行执行

async模块也可以使用parallel()方法并行执行多个功能。当然,JavaScript 仍然是单线程的,所以您的代码实际上不会并行执行。除了async在调用下一个函数之前不等待一个函数返回之外,parallel()方法的行为与series()完全一样,给人一种并行的错觉。清单 3-20 显示了一个使用parallel()执行同样三个任务的例子。此示例还传递了使用对象中的任务,因为您已经在前面的示例中看到了数组语法。

清单 3-20 。使用Async并行执行三个任务

var async = require("async");

async.parallel({
  one: function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  two: function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  three: function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
}, function(error, results) {
  console.log(results);
});

清单 3-21 显示了来自清单 3-20 的输出。在这种情况下,任务不按程序顺序执行。另外,请注意,显示任务结果的最后一行输出是一个对象,而不是一个数组。

清单 3-21 。并行执行任务的控制台输出

$ node async-parallel
Task 3
Task 2
Task 1
{ three: 3, two: 2, one: 1 }

极限平行度

parallel()方法试图尽快执行传递给它的所有函数。一个类似的方法,parallelLimit(),的行为与parallel()完全一样,除了您可以为并行执行的任务数量设置一个上限。清单 3-22 显示了一个parallelLimit()方法的使用示例。在这种情况下,并行度限制设置为 2,在最终回调之前使用一个额外的参数。需要注意的是,parallelLimit()不会在 n 的离散批次中执行功能。相反,该函数只是确保永远不会有超过 n 个函数同时执行。

清单 3-22 。并行执行三个任务,最大并行度为 2

var async = require("async");

async.parallelLimit({
  one: function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  two: function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  three: function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
}, 2, function(error, results) {
  console.log(results);
});

清单 3-23 显示了来自清单 3-22 的结果输出。请注意,任务 1 和 2 在第三个任务之前完成,尽管它的计时器延迟最小。这表明任务 3 直到前两个任务中的一个完成后才开始执行。

清单 3-23 。运行清单 3-22 中的代码的输出

$ node parallel-limit.js
Task 2
Task 1
Task 3
{ two: 2, one: 1, three: 3 }

瀑布模型

瀑布模型 是一种串行模型,当任务依赖于先前完成的任务的结果时,这种模型很有用。瀑布也可以被认为是装配线,每个任务执行一个更大的任务的一部分。瀑布是使用async方法waterfall()创建的。设置瀑布与使用series()parallel()非常相似。然而,有几个关键的区别。首先,组成瀑布的函数列表只能存储在一个数组中(不支持对象符号)。第二个关键区别是,只有最后一个任务的结果被传递给最终的回调函数。第三个区别是任务函数可以接受前一个任务提供的附加参数。

清单 3-24 显示了一个瀑布的例子。它使用勾股定理来计算三角形斜边的长度。勾股定理指出,对于直角三角形,斜边的平方长度等于其他两条边的平方之和。定理一般写成a2+b2=c2,其中 c 为斜边的长度。在清单 3-24 中,使用waterfall()方法将问题分解为三个任务。第一个任务创建两个随机数作为值 ab 。这些值被传递给任务的回调函数,从而使它们成为第二个任务的前两个参数。第二个任务计算 ab 的平方和,并将该值传递给第三个任务。第三个任务计算传递给它的值的平方根。这个值,斜边的长度,被传递给最终的回调函数,在那里被打印到控制台。

清单 3-24 。计算直角三角形斜边长度的瀑布

var async = require("async");

async.waterfall([
  function(callback) {
    callback(null, Math.random(), Math.random());
  },
  function(a, b, callback) {
    callback(null, a * a + b * b);
  },
  function(cc, callback) {
    callback(null, Math.sqrt(cc));
  }
], function(error, c) {
  console.log(c);
});

排队模型

async也支持使用queue()方法的任务队列。与以前的执行模型不同,以前的执行模型执行许多作为参数传入的函数,队列模型允许您在执行过程中的任何时候动态添加任务。队列对于解决生产者-消费者类型的问题很有用。因为 JavaScript 是单线程的,所以您可以放心地忽略生产者-消费者问题中通常会出现的潜在并发问题。

清单 3-25 显示了一个async队列的基本初始化。队列对象是使用queue()方法创建的,该方法将任务处理函数作为输入参数。任务处理程序接受两个参数,一个用户定义的任务和一个回调函数,一旦任务被处理,就应该用一个错误参数调用该回调函数。在这个例子中,没有发生错误,所以调用回调函数,用null作为它的参数。与parallelLimit()方法类似,queue()方法也采用一个参数来指定队列的并行级别。清单 3-25 中所示的队列可以同时处理多达四个任务。

清单 3-25 。初始化一个async队列

var async = require("async");
var queue = async.queue(function(task, callback) {
  // process the task argument
  console.log(task);
  callback(null);
}, 4);

一旦建立了队列,就开始使用它的push()unshift()方法向它添加任务。与同名的数组方法一样,unshift()push()分别将任务添加到队列的开头和结尾。这两种方法都可以将单个任务添加到队列中,或者通过传入数组将多个任务添加到队列中。两种方法都接受可选的回调函数;如果存在,则在每个任务完成后,将使用错误参数调用它。

在清单 3-26 的中,每隔 200 毫秒,一个间隔被用来把一个新任务添加到前一个例子的队列的末尾。在这个例子中,每个任务只是一个带有数字id字段的对象。然而,任务实际上可以是任何数据,这取决于您的应用。这个例子中包含了可选的回调参数。在这种情况下,回调函数只是打印一条消息,说明任务已经完成。

清单 3-26 。向async队列添加任务的例子

var i = 0;

setInterval(function() {
  queue.push({
    id: i
  }, function(error) {
    console.log("Finished a task");
  });
  i++;
}, 200);

其他队列方法和属性

在任何时候,您都可以通过使用length()方法来确定队列中元素的数量。您还可以使用concurrency属性来控制队列的并行级别。例如,如果队列长度超过了一个阈值,您可以使用清单 3-27 中所示的代码来增加并发任务的数量。

清单 3-27 。根据负载更新队列的Concurrency

if (queue.length() > threshold) {
  queue.concurrency = 8;
}

队列还支持许多回调函数,这些函数在某些事件发生时被触发。这些回调函数是saturated()empty()drain()。每当队列的长度等于它的并发性时,就会触发saturated()函数,每当从队列中移除最后一个任务时,就会调用empty(),当最后一个任务处理完毕时,就会调用drain()。清单 3-28 中显示了每个函数的示例。

清单 3-28saturated()empty()drain()的使用示例

queue.saturated = function() {
  console.log("Queue is saturated");
};

queue.empty = function() {
  console.log("Queue is empty");
};

queue.drain = function() {
  console.log("Queue is drained");
};

重复方法

async模块还提供了其他方法,这些方法重复调用一个函数,直到满足某个条件。其中最基本的是whilst(),它的行为类似于一个while循环。清单 3-29 展示了如何使用whilst()来实现一个异步while循环。whilst()方法将三个函数作为参数。第一个是同步真值测试,它没有参数,在每次迭代之前被检查。传递给whilst()的第二个函数在每次真值测试返回true时执行。这个函数将回调作为它唯一的参数,并且可以被认为是循环体。循环体的回调函数将一个可选的错误作为其唯一的参数,在本例中该参数被设置为null。一旦真值测试返回false,就执行whilst()的第三个参数,并作为最终的回调函数。这个函数也将一个可选的错误作为它唯一的参数。

清单 3-29 。使用whilst() 实现简单循环

var async = require("async");
var i = 0;

async.whilst(function() {
  return i < 5;
}, function(callback) {
  setTimeout(function() {
    console.log("i = " + i);
    i++;
    callback(null);
  }, 1000);
}, function(error) {
  console.log("Done!");
});

重复变化

async模块提供了三种额外的方法来实现异步的类循环结构。这些方法是doWhilst()until()doUntil(),它们的行为几乎和whilst()一模一样。第一个doWhilst(),是一个do-while循环的异步等价物,until()whilst()的逆,一直执行到真值测试返回true。类似地,doUntil()doWhilst()的逆,只要真值测试返回false就执行。这些方法的签名如清单 3-30 所示。请注意,body参数出现在doWhilst()doUntil()test之前。

清单 3-30doWhilst()until()doUntil() 的方法签名

async.doWhilst(body, test, callback)
async.until(test, body, callback)
async.doUntil(body, test, callback)

附加async功能

async除了已经介绍的功能之外,还提供了许多其他实用功能。例如,async提供了实现记忆化的memoize()unmemoize()方法。该模块还提供了用于处理集合的许多常用方法的串行和并行版本。这些方法包括each()map()filter()reduce()some()every()。在模块的 GitHub 页面上可以找到async提供的方法以及参考代码的完整列表:https://github.com/caolan/async

image 记忆化是一种编程技术,它试图通过缓存函数先前计算的结果来提高性能。当调用记忆化函数时,它的输入参数被映射到软件缓存中的输出。下次使用相同的输入调用该函数时,将返回缓存的值,而不是再次执行该函数。

摘要

本章已经开始探索 Node 编程模型。阅读本章后,您应该对异步编程和非阻塞 I/O 的概念有了更好的理解。如果您仍然不确定,请返回并再次阅读该章。如果您计划进行任何严肃的 Node 开发,理解这些概念是绝对必要的。异常处理(也在这里讨论)可能会被推迟到以后,但是由于异步错误处理可能是一个棘手的问题,所以最好尽快将其提上日程。

本章还介绍了现有最流行的 Node 模块之一async。在任何 Node 开发者的工具箱中,async都是一个非常强大的工具,它也可以在浏览器中工作,这也使它成为前端开发者的资产。使用async提供的模型,几乎可以抽象出任何执行模式。此外,模型可以嵌套在其他模型中。例如,您可以创建一组并行执行的函数,每个函数包含一个嵌套的瀑布。

四、事件和计时器

前一章介绍了 Node 的事件驱动编程模型。本章对事件和事件处理进行了更深入的研究。对事件处理的深刻理解将允许您创建复杂的、事件驱动的应用,例如 web 服务器。本章介绍事件发射器,即用于创建新事件的对象。在学习了如何创建事件之后,本章将转向事件处理。最后,本章讨论了 Node 中的定时器和功能调度。

事件发射器

在 Node 中,生成事件的对象称为事件发射器。创建一个事件发射器就像导入events核心模块并实例化一个EventEmitter对象一样简单。然后,EventEmitter实例可以使用它的emit()方法创建新事件。清单 4-1 中显示了一个创建事件发射器的例子。在这个例子中,事件发射器创建了一个foo事件。

清单 4-1 。一个简单事件发射器的例子

var events = require("events");
var emitter = new events.EventEmitter();

emitter.emit("foo");

事件名称可以是任何有效的字符串,但是按照惯例使用 camelCase 命名。例如,创建一个事件来表明一个新用户被添加到系统中,这个事件可能被命名为userAdded或类似的名称。

通常,事件需要提供事件名称之外的附加信息。例如,当按下一个键时,该事件还指定键入哪个键。为了支持这一功能,emit()方法可以在事件名称后接受任意数量的可选参数。回到创建新用户的例子,清单 4-2 展示了额外的参数是如何传递给emit()的。这个例子假设执行了一些 I/O(可能是一个数据库事务)操作,这会创建一个新用户。一旦 I/O 操作完成,事件发射器emitter创建一个新的userAdded事件,并传入用户的用户名和密码。

清单 4-2 。向发出的事件传递参数的示例

var events = require("events");
var emitter = new events.EventEmitter();var username = "colin";var password = "password";

// add the user
// then emit an event
emitter.emit("userAdded", username, password);

监听事件

在清单 4-2 的例子中,一个事件发射器被用来创建一个事件。不幸的是,如果没有人在听,一个事件是毫无意义的。在 Node 中,事件监听器使用on()addListener()方法连接到事件发射器。这两种方法可以互换使用。这两种方法都将事件名称和处理函数作为参数。当发出指定类型的事件时,会调用相应的处理函数。例如,在清单 4-3 中,使用on()方法将一个userAdded事件处理程序附加到emitter上。接下来,emitter发出一个userAdded事件,导致处理程序被调用。这个例子的输出如清单 4-4 所示。

清单 4-3 。使用on()设置事件监听器

var events = require("events");
var emitter = new events.EventEmitter();

var username = "colin";
var password = "password";

// an event listener
emitter.on("userAdded", function(username, password) {
  console.log("Added user " + username);
});

// add the user
// then emit an event
emitter.emit("userAdded", username, password);

image 注意事件监听器只能检测那些在监听器被连接后发生的事件。也就是说,收听者不能检测过去的事件。因此,如清单 4-3 所示,确保在发出事件之前附加一个监听器。

清单 4-4 。运行清单 4-3 中代码的输出

$ node user-event-emitter.js
Added user colin

一次性事件侦听器

有时你可能只对事件第一次发生时的反应感兴趣。在这些情况下,您可以使用once()方法。once()的用法与on()addListener().完全一样,但是,使用 once()附加的监听器最多执行一次,然后被删除。清单 4-5 显示了一个once()方法的使用示例。在本例中,once()用于监听foo事件。然后使用emit()方法创建两个foo事件。但是,因为事件监听器是使用once()注册的,所以只处理第一个foo事件。如果事件监听器是使用on()addListener()注册的,那么两个 foo 事件都会得到处理。运行该示例的输出如清单 4-6 所示。

清单 4-5 。使用once()的一次性事件监听器的示例

var events = require("events");
var emitter = new events.EventEmitter();

emitter.once("foo", function() {
  console.log("In foo handler");
});

emitter.emit("foo");
emitter.emit("foo");

清单 4-6 。运行清单 4-5 中代码的输出

$ node once-test.js
In foo handler

检查事件侦听器

在事件发射器的生命周期中,它可以有零个或多个侦听器。每种事件类型的侦听器可以通过几种方式进行检查。如果您只对确定附加侦听器的数量感兴趣,那么只需看看EventEmitter.listenerCount()方法就可以了。该方法将一个EventEmitter实例和一个事件名作为参数,并返回附加侦听器的数量。例如,在清单 4-7 中,创建了一个事件发射器,并附加了两个无趣的foo事件处理程序。该示例的最后一行显示了通过调用EventEmitter.listenerCount()连接到emitterfoo处理程序的数量。在这种情况下,该示例输出数字 2。请注意,listenerCount()调用被附加到了EventEmitter类,而不是特定的实例。许多语言称之为静态方法。然而,Node 文档将listenerCount()标识为一个类方法,因此本书也是如此。

清单 4-7 。使用EventEmitter.listenerCount()确定听众人数

var events = require("events");
var EventEmitter = events.EventEmitter; // get the EventEmitter constructor from the events module
var emitter = new EventEmitter();

emitter.on("foo", function() {});
emitter.on("foo", function() {});
console.log(EventEmitter.listenerCount(emitter, "foo"));

如果获取附加到事件发射器的处理程序的数量还不够,那么可以使用listeners()方法来获取事件处理程序函数的数组。该数组通过length属性提供处理程序的数量,以及事件发生时调用的实际函数。也就是说,修改由listeners()返回的数组不会影响由事件发射器对象维护的处理程序。

清单 4-8 提供了一个使用listeners()方法的例子。在这个例子中,一个foo事件处理程序被添加到一个事件发射器中。然后使用listeners()来检索事件处理程序的数组。然后使用数组forEach()方法遍历事件处理程序,一路调用每个事件处理程序。因为本例中的事件处理程序不接受任何参数,也不改变程序状态,所以对forEach()的调用实质上复制了emitter.emit("foo")的功能。

清单 4-8 。一个通过listeners()方法迭代事件处理程序的例子

var events = require("events");
var EventEmitter = events.EventEmitter;
var emitter = new EventEmitter();

emitter.on("foo", function() { console.log("In foo handler"); });
emitter.listeners("foo").forEach(function(handler) {
  handler();
});

newListener事件

每次注册新的事件处理程序时,事件发射器都会发出一个newListener事件。此事件用于检测新的事件处理程序。当您需要为每个新的事件处理程序分配资源或执行某些操作时,通常会使用newListener。一个newListener事件的处理方式和其他事件一样。处理程序需要两个参数:字符串形式的事件名称和处理程序函数。例如,在清单 4-9 中,一个foo事件处理程序被附加到一个事件发射器上。在幕后,发射器发出一个newListener事件,导致newListener事件处理程序被调用。

清单 4-9 。添加一个newListener事件处理器

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("newListener", function(eventName, listener) {
  console.log("Added listener for " + eventName + " events");
});

emitter.on("foo", function() {});

重要的是要记住newListener事件是在创建自己的事件时存在的。清单 4-10 显示了如果你忘记了会发生什么。在这个例子中,开发人员创建了一个定制的newListener事件处理程序,该程序期望被传递一个Date对象。当发出一个newListener事件时,一切都按预期工作。然而,当创建一个看似不相关的foo事件处理程序时,会抛出一个异常,因为内置的newListener事件是以字符串foo作为第一个参数发出的。因为Date对象有一个getTime()方法,而字符串没有,所以抛出一个TypeError

清单 4-10newListener事件的无效处理程序

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("newListener", function(date) {
  console.log(date.getTime());
});

emitter.emit("newListener", new Date());
emitter.on("foo", function() {});

删除事件侦听器

事件侦听器可以在附加到事件发射器后被删除。例如,要将事件发射器重置到某个没有监听器的初始状态,最简单的方法是使用removeAllListeners()方法。可以不带任何参数调用此方法,在这种情况下,所有事件侦听器都会被移除。或者,传入事件名称会导致命名事件的处理程序被移除。removeAllListeners()的语法如清单 4-11 所示。

清单 4-11removeAllListeners()方法的语法

emitter.removeAllListeners([eventName])

如果removeAllListeners()对于您的需求来说过于粗糙,那么就求助于removeListener()方法。此方法用于移除单个事件侦听器,并接受两个参数—要移除的事件的名称和处理函数。清单 4-12 展示了一个removeListener()的使用示例。在这种情况下,一个foo事件监听器被添加到一个事件发射器,然后立即被移除。发出事件时,不会发生任何事情,因为没有附加的侦听器。注意,removeListener()的用法与on()addListener()方法的用法相同,尽管它们执行相反的操作。

清单 4-12 。使用removeListener()删除事件处理程序

var events = require("events");
var emitter = new events.EventEmitter();

function handler() {
  console.log("In foo handler");
}

emitter.on("foo", handler);
emitter.removeListener("foo", handler);
emitter.emit("foo");

如果你打算使用removeListener() ,避免匿名处理函数。就其本质而言,匿名函数不会绑定到命名引用。如果创建了匿名事件处理程序,第二个相同的匿名函数将无法成功移除该处理程序。这是因为两个不同的Function对象不被认为是等价的,除非它们指向内存中的同一个位置。因此,清单 4-13 中的例子将而不是删除一个事件监听器。

清单 4-13 。匿名函数对removeListener()的不正确使用

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("foo", function() {
  console.log("foo handler");
});
emitter.removeListener("foo", function() {
  console.log("foo handler");
});
emitter.emit("foo");

检测潜在的内存泄漏

通常,单个事件发射器只需要少量的事件侦听器。因此,如果应用以编程方式向事件发射器添加事件侦听器,而该发射器突然拥有了几百个事件侦听器,这可能表明出现了某种类型的逻辑错误,从而导致内存泄漏。这方面的一个例子是添加事件侦听器的循环。如果循环包含逻辑错误,可能会创建大量事件处理程序,消耗不必要的内存。默认情况下,如果为任何单个事件添加了十个以上的侦听器,Node 会打印一条警告消息。该阈值可以使用setMaxListeners()方法进行控制。这个方法将一个整数作为它唯一的参数。通过将该值设置为0,事件发射器将接受无限制的侦听器,而不会输出警告消息。请注意,程序语义不受setMaxListeners()的影响(它只会打印一条警告消息)。相反,它只是提供了一个有用的调试机制。setMaxListeners()的用法如清单 4-14 所示。

清单 4-14setMaxListeners()方法的语法

emitter.setMaxListeners(n)

从事件发射器继承

到目前为止,所有的例子都明确涉及到了对EventEmitter实例的管理。或者,您可以创建从EventEmitter继承的定制对象,并包含额外的特定于应用的逻辑。清单 4-15 显示了这是如何完成的。第一行导入熟悉的EventEmitter构造函数。第二行导入util核心模块。顾名思义,util提供了许多有用的实用函数。本例中特别有趣的inherits()方法有两个参数,都是构造函数。使第一个构造函数继承第二个构造函数的原型方法。在这个例子中,自定义的User构造函数继承自EventEmitter。在User构造函数内部,调用了EventEmitter构造函数。此外,定义了一个方法addUser(),它发出userAdded事件。

清单 4-15 。创建一个扩展EventEmitter的对象

var EventEmitter = require("events").EventEmitter;
var util = require("util");

function UserEventEmitter() {
  EventEmitter.call(this);

  this.addUser = function(username, password) {
    // add the user
    // then emit an event
    this.emit("userAdded", username, password);
  };
};

util.inherits(UserEventEmitter, EventEmitter);

image 注意 JavaScript 采用了一种被称为原型继承的继承类型,它不同于传统继承 Java 等语言中使用的那种继承。在原型继承中,没有类。相反,对象充当其他对象的原型。

清单 4-16 展示了如何使用定制的User事件发射器。出于这个例子的目的,假设在同一个文件中定义了User构造函数——尽管理论上它可以在其他地方定义并使用require()函数导入。在这个例子中,一个新的User被实例化。接下来,添加一个userAdded事件监听器。然后调用addUser()方法来模拟新用户的创建。由于addUser()发出一个userAdded事件,事件处理程序被调用。另外,请注意示例最后一行的 print 语句。该语句检查user变量是否是EventEmitter的实例。由于UserEventEmitter继承而来,这将计算为true

清单 4-16 。使用自定义事件发射器

var user = new UserEventEmitter();
var username = "colin";
var password = "password";

user.on("userAdded", function(username, password) {
  console.log("Added user " + username);
});

user.addUser(username, password)
console.log(user instanceof EventEmitter);

使用事件来避免回调地狱

第三章探讨了许多避免回调地狱的方法,其中之一就是使用async模块。事件发射器提供了另一种避免死亡金字塔的好方法。举个例子,让我们用清单 4-17 来重温一下清单 3-5 中的文件阅读器应用。

清单 4-17 。一个带有回调地狱的文件阅读器程序开始悄悄进入

var fs = require("fs");
var fileName = "foo.txt";

fs.exists(fileName, function(exists) {
 if (exists) {
   fs.stat(fileName, function(error, stats) {
     if (error) {
       throw error;
     }

     if (stats.isFile()) {
       fs.readFile(fileName, "utf8", function(error, data) {
         if (error) {
           throw error;
         }

         console.log(data);
       });
     }
   });
 }
});

清单 4-18 展示了如何使用事件发射器重写文件阅读器应用。在本例中,创建了一个封装了所有文件读取功能的FileReader对象。需要EventEmitter构造函数和util模块来设置事件发射器继承。此外,需要使用fs模块来访问文件系统。

FileReader构造函数中,你会注意到的第一件事是this是私有_self变量的别名。这样做是为了在异步文件系统回调函数中维护对FileReader对象的引用。在这些回调函数中,this变量并不指向FileReader。这意味着在这些回调中不能通过关键字this访问emit()方法。

除了_self变量,代码相当简单。exists()方法用于检查文件是否存在。如果是,就会发出一个stats事件。然后触发stats监听器,调用stat()方法。如果该文件是一个正常文件,并且没有错误发生,则发出一个read事件。read事件触发了read监听器,它试图读取并打印文件的内容。

清单 4-18 。使用事件发射器重构文件阅读器应用

var EventEmitter = require("events").EventEmitter;
var util = require("util");
var fs = require("fs");

function FileReader(fileName) {
  var _self = this;

  EventEmitter.call(_self);

  _self.on("stats", function() {
    fs.stat(fileName, function(error, stats) {
      if (!error && stats.isFile()) {
        _self.emit("read");
      }
    });
  });

  _self.on("read", function() {
    fs.readFile(fileName, "utf8", function(error, data) {
      if (!error && data) {
        console.log(data);
      }
    });
  });

  fs.exists(fileName, function(exists) {
    if (exists) {
      _self.emit("stats");
    }
  });
};

util.inherits(FileReader, EventEmitter);

var reader = new FileReader("foo.txt");

定时器和时间安排

由于所有熟悉的用于处理定时器和时间间隔的 JavaScript 函数都可以在 Node 中作为全局变量使用,所以您不需要使用require()来导入它们。setTimeout()函数用于调度一个一次性回调函数在未来某个时间执行。setTimeout()的参数是要执行的回调函数、执行前等待的时间(以毫秒为单位),以及传递给回调函数的零个或多个参数。清单 4-19 显示了如何使用setTimeout()来安排一个回调函数在一秒钟的延迟后执行。在这个例子中,回调函数接受两个参数,foobar,它们由setTimeout()的最后两个参数填充。

image 注意记住 JavaScript 时间(实际上是一般的计算机时间)并不是 100%准确的,所以回调函数很可能不在指定的时间执行。因为 JavaScript 是单线程的,所以长时间运行的任务会完全影响时间。

清单 4-19 。创建一个延迟一秒后执行的计时器

setTimeout(function(foo, bar) {
  console.log(foo + " " + bar);
}, 1000, "foo", "bar");

The setTimeout()函数还返回一个超时标识符,可以用来在回调函数执行前取消定时器。通过将超时标识符传递给clearTimeout()函数来取消定时器。清单 4-20 显示了一个定时器在执行前被取消。在本例中,定时器在创建后立即被取消。然而,在实际应用中,定时器通常基于一些事件的发生而被取消。

清单 4-20 。使用clearTimeout()功能取消定时器

var timeoutId = setTimeout(function() {
  console.log("In timeout function");
}, 1000);

clearTimeout(timeoutId);

间隔时间

本质上,时间间隔是一个周期性重复的计时器。创建和取消间隔的功能分别是setInterval()clearInterval()。像setTimeout()一样,setInterval()接受一个回调函数、delay和可选的回调参数。它还返回一个可以传递给clearInterval()的间隔标识符,以便取消间隔。清单 4-21 展示了如何使用setInterval()clearInterval()创建和取消间隔。

清单 4-21 。创建和取消间隔的示例

var intervalId = setInterval(function() {
  console.log("In interval function");
}, 1000);

clearInterval(intervalId);

ref()unref()方法

事件循环中剩下的唯一一项计时器或时间间隔将阻止程序终止。然而,这种行为可以通过使用定时器或间隔标识符的ref()unref()方法以编程方式改变。调用unref()方法允许程序在定时器/间隔是事件循环中唯一剩下的项目时退出。例如,在清单 4-22 的中,interval 是在调用setInterval()之后的事件循环中唯一安排的项目。然而,因为unref()在这个区间被调用,程序终止了。

清单 4-22 。不使程序保持活动状态的时间间隔示例

var intervalId = setInterval(function() {
  console.log("In interval function");
}, 1000);

intervalId.unref();

如果已经在计时器或时间间隔上调用了unref(),但是您希望恢复到默认行为,那么可以调用ref()方法。ref()的用法如清单 4-23 所示。

清单 4-23ref()方法的使用

timer.ref()

即时

Immediates 用于安排回调函数立即执行。这允许在当前执行的函数之后调度函数。使用setImmediate()函数创建即时消息,该函数将回调和可选的回调参数作为其参数。与setTimeout()setInterval()不同,setImmediate()不接受delay参数,因为延迟被假定为零。也可以使用clearImmediate()功能取消即时消息。清单 4-24 显示了一个创建和取消即时消息的例子。

清单 4-24 。创建和取消即时消息的示例

var immediateId = setImmediate(function() {
  console.log("In immediate function");
});

clearImmediate(immediateId);

拆分长时间运行的任务

任何熟悉浏览器中 JavaScript 开发的人无疑都遇到过这样的情况:一段长时间运行的代码使用户界面没有响应。这种行为是 JavaScript 单线程特性的产物。例如,清单 4-25 中的函数包含一个长时间运行的循环,模拟计算密集型代码,即使循环体为空,也会导致应用的响应时间明显滞后。

清单 4-25 。合成计算密集型函数

function compute() {
 for (var i = 0; i < 1000000000; i++) {
   // perform some computation
 }
}

compute();
console.log("Finished compute()");

在浏览器世界中,这个问题的一个常见解决方案是使用setTimeout().将计算量大的代码分割成更小的块。同样的技术也适用于 Node,但是,首选的解决方案是setImmediate()。清单 4-26 展示了如何使用setImmediate()将计算密集型代码分解成更小的部分。在本例中,每次调用compute()时都会处理一次迭代。这个过程允许其他代码运行,同时仍然向事件循环添加compute()的迭代。但是,请注意,执行速度将明显慢于原始代码,因为每个函数调用只处理一次循环迭代。通过对每个函数调用执行更多的工作,可以更好地平衡性能和响应。例如,setImmediate()可以在每 10,000 次迭代后被调用。最佳方法将取决于您的应用的需求。

清单 4-26 。使用setImmediate()分解计算密集型代码

var i = 0;

function compute() {
 if (i < 1000000000) {
   // perform some computation
   i++;
   setImmediate(compute);
 }
}

compute();
console.log("compute() still working…");

使用process.nextTick()进行调度

Node 的process对象包含一个名为nextTick()的方法,该方法提供了一种类似于 immediate 的高效调度机制。nextTick() 将回调函数作为其唯一的参数,并在事件循环的下一次迭代中调用回调函数,称为 tick 。因为回调函数被安排在下一个时钟周期,nextTick()不需要delay参数。根据官方的 Node 文档,nextTick()也比类似的调用setTimeout(fn, 0)更有效,因此更受青睐。清单 4-27 显示了一个使用nextTick()的函数调度的例子。

清单 4-27 。使用process.nextTick()安排功能

process.nextTick(function() {
  console.log("Executing tick n+1");
});

console.log("Executing nth tick");

image 注意在 Node 的旧版本中,process.nextTick()是分解计算密集型代码的首选工具。然而,现在不鼓励递归调用nextTick();应当用setImmediate()来代替。

不幸的是,没有办法将参数传递给回调函数。幸运的是,这个限制可以通过创建一个绑定任何所需参数的函数来轻松克服。例如,清单 4-28 中的代码不会像预期的那样工作,因为没有办法将参数传递给回调函数。然而,清单 4-29 中的代码将会工作,因为函数的参数在传递给nextTick()之前是绑定的。

清单 4-28 。向process.nextTick()传递参数的错误尝试

process.nextTick(function(f, b) {
  console.log(f + " " + b);
});
// prints "undefined undefined"

清单 4-29 。将带有绑定参数的函数传递给process.nextTick()

function getFunction(f, b) {
  return function myNextTick() {
    console.log(f + " " + b);
  };
}

process.nextTick(getFunction("foo", "bar"));
// prints "foo bar"

实现异步回调函数

process.nextTick()通常用于创建接受异步回调函数作为最终参数的函数。如果不使用nextTick(),回调函数就不是真正的异步,它的行为就像普通(同步)函数调用一样。同步回调函数会阻止事件循环中的其他任务执行,从而导致资源匮乏。如果使用您的代码的人期望异步行为,那么它们也会给他们带来困惑。

清单 4-30 展示了一个简单的函数,将两个数相加,然后将它们的和传递给一个回调函数。Node 的调用约定规定回调函数应该异步执行。因此,人们会期望代码打印出The sum is:,后跟实际的总和 5。但是,回调函数不是使用nextTick()异步调用的。因此,总和实际上是先打印后打印,如清单 4-31 所示。为了避免混淆,这个函数命名为addSync()可能更合适。

清单 4-30 。一个同步回调函数的例子

function add(x, y, cb) {
  cb(x + y);
}

add(2, 3, console.log);
console.log("The sum is:");

清单 4-31 。运行清单 4-30 中代码的输出

$ node sync-callback.js
5
The sum is:

幸运的是,将同步回调函数转换成异步回调函数相当简单,如清单 4-32 所示。在这个例子中,回调函数被传递给nextTick()。另外,请注意,将回调函数包装在匿名函数中允许xy的值通过nextTick()传递。这些简单的更改会导致程序按照最初的预期运行。清单 4-33 显示了正确的输出结果。

清单 4-32 。使用process.nextTick()的适当异步回调函数

function add(x, y, cb) {
  process.nextTick(function() {
    cb(x + y);
  });
}

add(2, 3, console.log);
console.log("The sum is:");

清单 4-33 。运行清单 4-32 中的异步代码的输出

$ node async-callback.js
The sum is:
5

保持一致的行为

任何非平凡函数都可能有多个控制流路径。重要的是,所有这些路径都是一致异步或一致同步的。换句话说,函数不应该对一组输入异步运行,而应该对另一组输入同步运行。此外,您必须确保回调函数只被调用一次。这是一个常见的问题来源,因为许多开发人员认为调用回调函数会导致当前函数返回。实际上,一旦回调函数返回,函数就会继续执行。解决这个问题的一个非常简单的方法是每次调用nextTick()时返回。

考虑清单 4-34 中的函数,它决定一个数是否为负。如果n参数小于 0,则将true传递给回调函数。否则,false就通过了。不幸的是,这个例子有两个主要问题。第一个是true回调是异步的,而false回调是同步的。第二种是当n为负时,回调函数执行两次,一次在isNegative()结束时,第二次在执行nextTick()回调时。

清单 4-34 。回调函数的不一致实现

function isNegative(n, cb) {
  if (n < 0) {
    process.nextTick(function() {
      cb(true);
    });
  }

  cb(false);
}

清单 4-35 显示了同一个函数的正确实现(注意回调函数的两次调用现在是异步的)。此外,对nextTick()的两次调用都会导致isNegative()返回,确保回调函数只能被调用一次。

清单 4-35 。清单 4-34 中回调函数的一致实现

function isNegative(n, cb) {
  if (n < 0) {
    return process.nextTick(function() {
      cb(true);
    });
  }

  return process.nextTick(function() {
    cb(false);
  });
}

当然,这是一个人为的例子。代码可以大大简化,如清单 4-36 所示。

清单 4-36 。清单 4-35 中代码的简化版本

function isNegative(n, cb) {
  process.nextTick(function() {
    cb(n < 0);
  });
}

摘要

本章探讨了 Node.js 世界中的事件、计时器和调度控制。这一章和前一章一起,应该给你一个坚实的 Node 基础的掌握。以这种理解为基础,本书的其余部分将重点探讨各种 Node API,并使用它们创建令人兴奋的应用。下一章将向您展示如何创建命令行界面——这是构建真实 Node 应用的第一步。