@H_403_0@学习 Node.js 一定要理解的内容之一,文中主要涉及到了 EventEmitter 的使用和一些异步情况的处理,比较偏基础,值得一读。
@H_
403_0@大多数 Node.js 对象都依赖了 EventEmitter 模块来监听和响应事件,比如我们常用的 HTTP requests,responses,以及 streams。
@H_
403_0@事件驱动机制的最简单形式,是在 Node.js 中十分流行的回调
函数,例如 fs.readFile。 在回调
函数这种形式中,事件每被触发一次,回调就会被触发一次。
@H_
403_0@我们先来探索下这个最基本的方式。
@H_
403_0@
你准备好了就叫我哈,Node!
@H_
403_0@很久很久以前,在 js 里还没有原生
支持 Promise,async/await 还只是一个遥远的梦想,回调
函数是处理异步问题的最原始的方式。
@H_
403_0@回调从本质上讲是传递给其他
函数的
函数,在 JavaScript 中
函数是第一类对象,这也让回调的存在成为可能。
@H_
403_0@一定要搞清楚的是,回调在
代码中的并不表示异步
调用。 回调既可以是同步
调用的,也可以是异步
调用的。
@H_
403_0@举个例子,这里有一个宿主
函数 fileSize,它接受一个回调
函数 cb,并且可以通过
条件判断来同步或者异步地
调用该回调
函数:
{
if (err) {
// Async
return cb(err);
}
// Async
cb(null,stats.size);
});
}
@H_
403_0@这其实也是个反例,这样写经常会引起一些意外的
错误,在设计宿主
函数的时候,应当尽可能的使用同一种风格,要么始终都是同步的使用回调,要么始终都是异步的。
@H_
403_0@我们来研究下一个典型的异步 Node
函数的简单示例,它用回调样式编写:
@H_
403_0@readFileAsArray
函数接受两个参数:一个
文件路径和一个回调
函数。它读取
文件内容,将其拆分成行数组,并将该数组作为回调
函数的参数传入,
调用回调
函数。
@H_
403_0@现在设计一个用例,假设我们在同一目录中的
文件 numbers.txt 包含如下
内容:
@H_
403_0@如果我们有一个需求,要求
统计该
文件中的奇数
数量,我们可以使用 readFileAsArray 来简化
代码:
{
if (err) throw err;
const numbers = lines.map(Number);
const oddNumbers = numbers.filter(n => n%2 === 1);
console.log('Odd numbers count:',oddNumbers.length);
});
@H_
403_0@这段
代码将
文件内容读入字符串数组中,回调
函数将其解析为数字,并计算奇数的个数。
@H_
403_0@这才是最纯粹的 Node 回调风格。回调的第一个参数要遵循
错误优先的原则,err 可以为空,我们要将回调作为宿主
函数的最后一个参数传递。你应该一直用这种方式这样设计你的
函数,因为
用户可能会假设。让宿主
函数把回调当做其最后一个参数,并让回调
函数以一个可能为空的
错误对象作为其第一个参数。
@H_
403_0@
回调在现代 JavaScript 中的替代品
@H_
403_0@在现代 JavaScript 中,我们有 Promise,Promise 可以用来替代异步 API 的回调。回调
函数需要作为宿主
函数的一个参数进行传递(多个宿主回调进行嵌套就形成了回调地狱),而且
错误和成功都只能在其中进行处理。而 Promise 对象可以让我们分开处理成功和
错误,还允许我们链式
调用多个异步事件。
@H_
403_0@如果 readFileAsArray
函数支持 Promise,我们可以这样使用它,如下所示:
{
const numbers = lines.map(Number);
const oddNumbers = numbers.filter(n => n%2 === 1);
console.log('Odd numbers count:',oddNumbers.length);
})
.catch(console.error);
@H_
403_0@我们在宿主
函数的返回值上
调用了一个
函数来处理我们的需求,这个 .then
函数会把刚刚在回调版本中的那个行数组传递给这里的匿名
函数。为了处理
错误,我们在结果上
添加一个 .catch
调用,当发生
错误时,它会捕捉到
错误并让我们访问到这个
错误。
@H_
403_0@在现代 JavaScript 中已经
支持了 Promise 对象,因此我们可以很容易的将其使用在宿主
函数之中。下面是
支持 Promise 版本的 readFileAsArray
函数(同时
支持旧有的回调
函数方式):
{}) {
return new Promise((resolve,reject) => {
fs.readFile(file,data) {
if (err) {
reject(err);
return cb(err);
}
const lines = data.toString().trim().split('\n');
resolve(lines);
cb(null,lines);
});
});
};
@H_
403_0@我们使该
函数返回一个 Promise 对象,该对象包裹了 fs.readFile 的异步
调用。Promise 对象暴露了两个参数,一个 resolve
函数和一个 reject
函数。
@H_
403_0@当有异常抛出时,我们可以通过向回调
函数传递 error 来处理
错误,也同样可以使用 Promise 的 reject
函数。每当我们将数据交给回调
函数处理时,我们同样也可以用 Promise 的 resolve
函数。
@H_
403_0@在这种同时可以使用回调和 Promise 的情况下,我们需要做的唯一一件事情就是为这个回调参数设置默认值,防止在没有传递回调
函数参数时,其被执行然后报错的情况。 在这个例子中使用了一个简单的默认空
函数:()=> {}。
@H_
403_0@
通过 async/await 使用 Promise
@H_
403_0@当需要连续
调用异步
函数时,使用 Promise 会让你的
代码更容易编写。不断的使用回调会让事情变得越来越复杂,最终陷入回调地狱。
@H_
403_0@Promise 的出现改善了一点,Generator 的出现又改善了一点。 处理异步问题的最新
解决方式是使用 async
函数,它允许我们将异步
代码视为同步
代码,使其整体上更加可读。
@H_
403_0@以下是使用 async/await 版本的
调用 readFileAsArray 的例子:
n%2 === 1).length;
console.log('Odd numbers count:',oddCount);
} catch(err) {
console.error(err);
}
}
countOdd();
@H_
403_0@首先,我们创建了一个 async
函数 —— 就是一个普通的
函数声明之前,加了个 async 关键字。在 async
函数内部,我们
调用了 readFileAsArray
函数,就像把它的返回值赋值给变量 lines 一样,为了真的拿到 readFileAsArray 处理
生成的行数组,我们使用关键字 await。之后,我们继续执行
代码,就好像 readFileAsArray 的
调用是同步的一样。
@H_
403_0@要让
代码运行,我们可以直接
调用 async
函数。这让我们的
代码变得更加简单和易读。为了处理异常,我们需要将异步
调用包装在一个 try/catch 语句中。
@H_
403_0@有了 async/await 这个特性,我们不必使用任何特殊的API(如 .then 和 .catch )。我们只是把这种
函数标记出来,然后使用纯粹的 JavaScript 写
代码。
@H_
403_0@我们可以把 async/await 这个特性用在
支持使用 Promise 处理后续逻辑的
函数上。但是,它无法用在只
支持回调的异步
函数上(例如setTimeout)。
@H_
403_0@
EventEmitter 模块
@H_
403_0@EventEmitter 是一个处理 Node 中各个对象之间通信的模块。 EventEmitter 是 Node 异步事件驱动架构的核心。 Node 的许多内置模块都继承自 EventEmitter。
@H_
403_0@它的概念其实很简单:emitter 对象会发出被定义过的事件,导致之前
注册的所有监听该事件的
函数被
调用。所以,emitter 对象基本上有两个主要特征:
@H_
403_0@为了使用 EventEmitter,我们需要创建一个继承自 EventEmitter 的类。
@H_
403_0@我们从 EventEmitter 的子类实例化的对象,就是 emitter 对象:
@H_
403_0@在这些 emitter 对象的生命周期里,我们可以
调用 emit
函数来触发我们想要的触发的任何被命名过的事件。
@H_
403_0@myEmitter.emit('something-happened');
emit
函数的使用表示发生某种情况发生了,让大家去做该做的事情。 这种情况通常是某些状态变化引起的。
@H_
403_0@我们可以使用 on
方法添加监听器
函数,并且每次 emitter 对象触发其关联的事件时,将执行这些监听器
函数。
@H_
403_0@
事件 !== 异步
@H_
403_0@先看看这个例子:
class WithLog extends EventEmitter {
execute(taskFunc) {
console.log('Before executing');
this.emit('begin');
taskFunc();
this.emit('end');
console.log('After executing');
}
}
const withLog = new WithLog();
withLog.on('begin',() => console.log('About to execute'));
withLog.on('end',() => console.log('Done with execute'));
withLog.execute(() => console.log(' Executing task '));
@H_
403_0@WithLog 是一个事件触发器,它有一个
方法 —— execute,该
方法接受一个参数,即具体要处理的任务
函数,并在其前后包裹 log 以
输出其执行日志。
@H_
403_0@为了看到这里会以什么顺序执行,我们在两个命名的事件上都
注册了监听器,最
后执行一个简单的任务来触发事件。
@H_
403_0@下面是上面程序的
输出结果:
@H_
403_0@这里我想证实的是以上的
输出都是同步发生的,这段
代码里没有什么异步的成分。
- 第一行输出了 "Before executing"
- begin 事件被触发,输出 "About to execute"
- 真正应该被执行的任务函数被调用,输出 " Executing task "
- end 事件被触发,输出 "Done with execute"
- 最后输出 "After executing"
@H_
403_0@就像普通的回调一样,不要以为事件意味着同步或异步
代码。
@H_
403_0@跟之前的回调一样,不要一提到事件就认为它是异步的或者同步的,还要具体分析。
@H_
403_0@如果我们传递 taskFunc 是一个异步
函数,会发生什么呢?
withLog.execute(() => {
setImmediate(() => {
console.log(' Executing task ')
});
});
@H_
403_0@
输出结果变成了这样:
@H_
403_0@这样就有问题了,异步
函数的
调用导致 "Done with execute" 和 "After executing" 的
输出并不准确。
@H_
403_0@要在异步
函数完成后发出事件,我们需要将回调(或 Promise)与基于事件的通信相结合。 下面的例子说明了这一点。
@H_
403_0@使用事件而不是常规回调的一个好处是,我们可以通过定义多个监听器对相同的信号做出多个不同的反应。如果使用回调来完成这件事,我们要在单个回调中写更多的处理逻辑。事件是应用程序允许多个外部
插件在应用程序核心之上构建
功能的好办法。你可以把它们当成钩子来挂一些由于状态变化而引发执行的程序。
@H_
403_0@
异步事件
@H_
403_0@我们把刚刚那些同步
代码的示例改成异步的:
class WithTime extends EventEmitter {
execute(asyncFunc,...args) {
this.emit('begin');
console.time('execute');
asyncFunc(...args,data) => {
if (err) {
return this.emit('error',err);
}
this.emit('data',data);
console.timeEnd('execute');
this.emit('end');
});
}
}
const withTime = new WithTime();
withTime.on('begin',() => console.log('About to execute'));
withTime.on('end',() => console.log('Done with execute'));
withTime.execute(fs.readFile,__filename);
@H_
403_0@用 WithTime 类执行 asyncFunc
函数,并通过
调用 console.time 和 console.timeEnd 报告该asyncFunc 所花费的时间。它在执行之前和之后都将以正确的顺序触发相应的事件,并且还会发出 error/data 事件作为处理异步
调用的信号。
@H_
403_0@我们传递一个异步的 fs.readFile
函数来测试一下 withTime emitter。 我们现在可以直接通过监听 data 事件来处理读取到的
文件数据,而不用把这套处理逻辑写到 fs.readFile 的回调
函数中。
@H_
403_0@执行这段
代码,我们以预期的顺序执行了一系列事件,并且得到异步
函数的执行时间,这些是十分重要的。
@H_
403_0@请注意,我们是将回调与事件触发器 emitter 相结合实现的这部分
功能。 如果 asynFunc
支持Promise,我们可以使用 async/await
函数来做同样的事情:
@H_
403_0@我认为这段
代码比之前的回调风格的
代码以及使用 .then/.catch 风格的
代码更具可读性。async/await 让我们更加接近 JavaScript 语言本身(不必再使用 .then/.catch 这些 api)。
@H_
403_0@
事件参数和错误
@H_
403_0@在之前的例子中,有两个事件被发出时还携带了别的参数。
@H_
403_0@error 事件被触发时会携带一个 error 对象。
@H_
403_0@data 事件被触发时会携带一个 data 对象。
@H_
403_0@我们可以在 emit
函数中不断的
添加参数,当然第一个参数一定是事件的
名称,除去第一个参数之外的所有参数都可以在该事件
注册的监听器中使用。
@H_
403_0@例如,要处理 data 事件,我们
注册的监听器
函数将访问传递给 emit
函数的 data 参数,而这个 data 也正是由 asyncFunc 返回的数据。
{
// do something with data
});
@H_
403_0@error 事件比较特殊。在我们基于回调的那个示例中,如果不使用监听器处理 error 事件,node 进程将会
退出。
@H_
403_0@举个由于
错误使用参数而造成程序崩溃的例子:
console.timeEnd('execute');
});
}
}
const withTime = new WithTime();
withTime.execute(fs.readFile,''); // BAD CALL
withTime.execute(fs.readFile,__filename);
@H_
403_0@第一次
调用 execute 将会触发 error 事件,由于没有处理 error ,Node 程序随之崩溃:
@H_
403_0@第二次执行
调用将受到此崩溃的影响,并且可能根本不会被执行。
@H_
403_0@如果我们为这个 error 事件
注册一个监听器
函数来处理 error,结果将大不相同:
{
// do something with err,for example log it somewhere
console.log(err)
});
@H_
403_0@如果我们执行上述操作,将会报告第一次执行 execute 时发送的
错误,但是这次 node 进程不会崩溃
退出,其他程序的
调用也都能正常完成:
@H_
403_0@{ Error: ENOENT: no such file or directory,open '' errno: -2,code: 'ENOENT',syscall: 'open',path: '' }
execute: 4.276ms
@H_
403_0@需要注意的是,基于 Promise 的
函数有些不同,它们暂时只是
输出一个警告:
@H_
403_0@UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory,open ''
@H_
403_0@DeprecationWarning: Unhandled promise rejections are deprecated. In the future,promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
@H_
403_0@另一种处理异常的方式是在监听全局的 uncaughtException 进程事件。 然而,使用该事件全局捕获
错误并不是一个好办法。
@H_
403_0@关于 uncaughtException,一般都会建议你避免使用它,但是如果必须用它,你应该让进程
退出:
{
// something went unhandled.
// Do any cleanup and exit anyway!
console.error(err); // don't do just that.
// FORCE exit the process too.
process.exit(1);
});
@H_
403_0@但是,假设在同一时间发生多个
错误事件,这意味着上面的 uncaughtException 监听器将被多次触发,这可能会引起一些问题。
@H_
403_0@EventEmitter 模块暴露了 once
方法,这个
方法发出的信号只会
调用一次监听器。所以,这个
方法常与 uncaughtException 一起使用。
@H_
403_0@
监听器的顺序
@H_
403_0@如果针对一个事件
注册多个监听器
函数,当事件被触发时,这些监听器
函数将按其
注册的顺序被触发。
{
console.log(`Length: ${data.length}`);
});
// second
withTime.on('data',(data) => {
console.log(Characters: ${data.toString().length}
);
});
withTime.execute(fs.readFile,__filename);
@H_
403_0@上述
代码会先
输出 Length 信息,再
输出 Characters 信息,执行的顺序与
注册的顺序保持一致。
@H_
403_0@如果你想定义一个新的监听
函数,但是希望它能够第一个被执行,你还可以使用 prependListener
方法:
{
console.log(`Length: ${data.length}`);
});
withTime.prependListener('data',__filename);
@H_
403_0@上述
代码中,Charaters 信息将首先被
输出。
@H_
403_0@最后,你可以用 removeListener
函数来
删除某个监听器
函数。
@H_
403_0@以上就是本文的全部
内容,希望对大家的学习有所帮助,也希望大家多多
支持编程之家。