前一章介绍了 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);
注意事件监听器只能检测那些在监听器被连接后发生的事件。也就是说,收听者不能检测过去的事件。因此,如清单 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");$ node once-test.js
In foo handler检查事件侦听器
在事件发射器的生命周期中,它可以有零个或多个侦听器。每种事件类型的侦听器可以通过几种方式进行检查。如果您只对确定附加侦听器的数量感兴趣,那么只需看看EventEmitter.listenerCount()方法就可以了。该方法将一个EventEmitter实例和一个事件名作为参数,并返回附加侦听器的数量。例如,在清单 4-7 中,创建了一个事件发射器,并附加了两个无趣的foo事件处理程序。该示例的最后一行显示了通过调用EventEmitter.listenerCount()连接到emitter的foo处理程序的数量。在这种情况下,该示例输出数字 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-10 。newListener事件的无效处理程序
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-11 。removeAllListeners()方法的语法
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-14 。setMaxListeners()方法的语法
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);
注意 JavaScript 采用了一种被称为原型继承的继承类型,它不同于传统继承 Java 等语言中使用的那种继承。在原型继承中,没有类。相反,对象充当其他对象的原型。
清单 4-16 展示了如何使用定制的User事件发射器。出于这个例子的目的,假设在同一个文件中定义了User构造函数——尽管理论上它可以在其他地方定义并使用require()函数导入。在这个例子中,一个新的User被实例化。接下来,添加一个userAdded事件监听器。然后调用addUser()方法来模拟新用户的创建。由于addUser()发出一个userAdded事件,事件处理程序被调用。另外,请注意示例最后一行的 print 语句。该语句检查user变量是否是EventEmitter的实例。由于User从EventEmitter继承而来,这将计算为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);使用事件来避免回调地狱
第 3 章探讨了许多避免回调地狱的方法,其中之一就是使用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()来安排一个回调函数在一秒钟的延迟后执行。在这个例子中,回调函数接受两个参数,foo和bar,它们由setTimeout()的最后两个参数填充。
注意记住 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-23 。ref()方法的使用
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");
注意在 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:");$ node sync-callback.js
5
The sum is:幸运的是,将同步回调函数转换成异步回调函数相当简单,如清单 4-32 所示。在这个例子中,回调函数被传递给nextTick()。另外,请注意,将回调函数包装在匿名函数中允许x和y的值通过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:");$ 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 应用的第一步。