学习 Node.js 一定要理解的内容之一,文中主要涉及到了 EventEmitter 的使用和一些异步情况的处理,比较偏基础,值得一读。

大多数 Node.js 对象都依赖了 EventEmitter 模块来监听和响应事件,比如我们常用的 HTTP requests, responses, 以及 streams。

  1. const EventEmitter = require('events');

事件驱动机制的最简单形式,是在 Node.js 中十分流行的回调函数,例如 fs.readFile。 在回调函数这种形式中,事件每被触发一次,回调就会被触发一次。

我们先来探索下这个最基本的方式。

你准备好了就叫我哈,Node!

很久很久以前,在 js 里还没有原生支持 Promise,async/await 还只是一个遥远的梦想,回调函数是处理异步问题的最原始的方式。

回调从本质上讲是传递给其他函数的函数,在 JavaScript 中函数是第一类对象,这也让回调的存在成为可能。

一定要搞清楚的是,回调在代码中的并不表示异步调用。 回调既可以是同步调用的,也可以是异步调用的。

举个例子,这里有一个宿主函数 fileSize,它接受一个回调函数 cb,并且可以通过条件判断来同步或者异步地调用该回调函数:

  1. function fileSize (fileName, cb) {
  2. if (typeof fileName !== 'string') {
  3. // Sync
  4. return cb(new TypeError('argument should be string'));
  5. }
  6. fs.stat(fileName, (err, stats) => {
  7. if (err) {
  8. // Async
  9. return cb(err);
  10. }
  11. // Async
  12. cb(null, stats.size);
  13. });
  14. }

这其实也是个反例,这样写经常会引起一些意外的错误,在设计宿主函数的时候,应当尽可能的使用同一种风格,要么始终都是同步的使用回调,要么始终都是异步的。

我们来研究下一个典型的异步 Node 函数的简单示例,它用回调样式编写:

  1. const readFileAsArray = function(file, cb) {
  2. fs.readFile(file, function(err, data) {
  3. if (err) {
  4. return cb(err);
  5. }
  6. const lines = data.toString().trim().split('\n');
  7. cb(null, lines);
  8. });
  9. };

readFileAsArray 函数接受两个参数:一个文件路径和一个回调函数。它读取文件内容,将其拆分成行数组,并将该数组作为回调函数的参数传入,调用回调函数。

现在设计一个用例,假设我们在同一目录中的文件 numbers.txt 包含如下内容:

  1. 10
  2. 11
  3. 12
  4. 13
  5. 14
  6. 15

如果我们有一个需求,要求统计该文件中的奇数数量,我们可以使用 readFileAsArray 来简化代码:

  1. readFileAsArray('./numbers.txt', (err, lines) => {
  2. if (err) throw err;
  3. const numbers = lines.map(Number);
  4. const oddNumbers = numbers.filter(n => n%2 === 1);
  5. console.log('Odd numbers count:', oddNumbers.length);
  6. });

这段代码将文件内容读入字符串数组中,回调函数将其解析为数字,并计算奇数的个数。

这才是最纯粹的 Node 回调风格。回调的第一个参数要遵循错误优先的原则,err 可以为空,我们要将回调作为宿主函数的最后一个参数传递。你应该一直用这种方式这样设计你的函数,因为用户可能会假设。让宿主函数把回调当做其最后一个参数,并让回调函数以一个可能为空的错误对象作为其第一个参数。

回调在现代 JavaScript 中的替代品

在现代 JavaScript 中,我们有 Promise,Promise 可以用来替代异步 API 的回调。回调函数需要作为宿主函数的一个参数进行传递(多个宿主回调进行嵌套就形成了回调地狱),而且错误和成功都只能在其中进行处理。而 Promise 对象可以让我们分开处理成功和错误,还允许我们链式调用多个异步事件。

如果 readFileAsArray 函数支持 Promise,我们可以这样使用它,如下所示:

  1. readFileAsArray('./numbers.txt')
  2. .then(lines => {
  3. const numbers = lines.map(Number);
  4. const oddNumbers = numbers.filter(n => n%2 === 1);
  5. console.log('Odd numbers count:', oddNumbers.length);
  6. })
  7. .catch(console.error);

我们在宿主函数的返回值上调用了一个函数来处理我们的需求,这个 .then 函数会把刚刚在回调版本中的那个行数组传递给这里的匿名函数。为了处理错误,我们在结果上添加一个 .catch 调用,当发生错误时,它会捕捉到错误并让我们访问到这个错误。

在现代 JavaScript 中已经支持了 Promise 对象,因此我们可以很容易的将其使用在宿主函数之中。下面是支持 Promise 版本的 readFileAsArray 函数(同时支持旧有的回调函数方式):

  1. const readFileAsArray = function(file, cb = () => {}) {
  2. return new Promise((resolve, reject) => {
  3. fs.readFile(file, function(err, data) {
  4. if (err) {
  5. reject(err);
  6. return cb(err);
  7. }
  8. const lines = data.toString().trim().split('\n');
  9. resolve(lines);
  10. cb(null, lines);
  11. });
  12. });
  13. };

我们使该函数返回一个 Promise 对象,该对象包裹了 fs.readFile 的异步调用。Promise 对象暴露了两个参数,一个 resolve 函数和一个 reject 函数。

当有异常抛出时,我们可以通过向回调函数传递 error 来处理错误,也同样可以使用 Promise 的 reject 函数。每当我们将数据交给回调函数处理时,我们同样也可以用 Promise 的 resolve 函数。

在这种同时可以使用回调和 Promise 的情况下,我们需要做的唯一一件事情就是为这个回调参数设置默认值,防止在没有传递回调函数参数时,其被执行然后报错的情况。 在这个例子中使用了一个简单的默认空函数:()=> {}。

通过 async/await 使用 Promise

当需要连续调用异步函数时,使用 Promise 会让你的代码更容易编写。不断的使用回调会让事情变得越来越复杂,最终陷入回调地狱。

Promise 的出现改善了一点,Generator 的出现又改善了一点。 处理异步问题的最新解决方式是使用 async 函数,它允许我们将异步代码视为同步代码,使其整体上更加可读。

以下是使用 async/await 版本的调用 readFileAsArray 的例子:

  1. async function countOdd () {
  2. try {
  3. const lines = await readFileAsArray('./numbers');
  4. const numbers = lines.map(Number);
  5. const oddCount = numbers.filter(n => n%2 === 1).length;
  6. console.log('Odd numbers count:', oddCount);
  7. } catch(err) {
  8. console.error(err);
  9. }
  10. }
  11. countOdd();

首先,我们创建了一个 async 函数 —— 就是一个普通的函数声明之前,加了个 async 关键字。在 async 函数内部,我们调用了 readFileAsArray 函数,就像把它的返回值赋值给变量 lines 一样,为了真的拿到 readFileAsArray 处理生成的行数组,我们使用关键字 await。之后,我们继续执行代码,就好像 readFileAsArray 的调用是同步的一样。

要让代码运行,我们可以直接调用 async 函数。这让我们的代码变得更加简单和易读。为了处理异常,我们需要将异步调用包装在一个 try/catch 语句中。

有了 async/await 这个特性,我们不必使用任何特殊的API(如 .then 和 .catch )。我们只是把这种函数标记出来,然后使用纯粹的 JavaScript 写代码。

我们可以把 async/await 这个特性用在支持使用 Promise 处理后续逻辑的函数上。但是,它无法用在只支持回调的异步函数上(例如setTimeout)。

EventEmitter 模块

EventEmitter 是一个处理 Node 中各个对象之间通信的模块。 EventEmitter 是 Node 异步事件驱动架构的核心。 Node 的许多内置模块都继承自 EventEmitter。

它的概念其实很简单:emitter 对象会发出被定义过的事件,导致之前注册的所有监听该事件的函数被调用。所以,emitter 对象基本上有两个主要特征:

  • 触发定义过的事件
  • 注册或者取消注册监听函数

为了使用 EventEmitter,我们需要创建一个继承自 EventEmitter 的类。

  1. class MyEmitter extends EventEmitter {
  2. }

我们从 EventEmitter 的子类实例化的对象,就是 emitter 对象:

  1. const myEmitter = new MyEmitter();

在这些 emitter 对象的生命周期里,我们可以调用 emit 函数来触发我们想要的触发的任何被命名过的事件。

  1. myEmitter.emit('something-happened');

emit 函数的使用表示发生某种情况发生了,让大家去做该做的事情。 这种情况通常是某些状态变化引起的。

我们可以使用 on 方法添加监听器函数,并且每次 emitter 对象触发其关联的事件时,将执行这些监听器函数。

事件 !== 异步

先看看这个例子:

  1. const EventEmitter = require('events');
  2. class WithLog extends EventEmitter {
  3. execute(taskFunc) {
  4. console.log('Before executing');
  5. this.emit('begin');
  6. taskFunc();
  7. this.emit('end');
  8. console.log('After executing');
  9. }
  10. }
  11. const withLog = new WithLog();
  12. withLog.on('begin', () => console.log('About to execute'));
  13. withLog.on('end', () => console.log('Done with execute'));
  14. withLog.execute(() => console.log('*** Executing task ***'));

WithLog 是一个事件触发器,它有一个方法 —— execute,该方法接受一个参数,即具体要处理的任务函数,并在其前后包裹 log 以输出其执行日志。

为了看到这里会以什么顺序执行,我们在两个命名的事件上都注册了监听器,最后执行一个简单的任务来触发事件。

下面是上面程序的输出结果:

  1. Before executing
  2. About to execute
  3. *** Executing task ***
  4. Done with execute
  5. After executing

这里我想证实的是以上的输出都是同步发生的,这段代码里没有什么异步的成分。

  • 第一行输出了 "Before executing"
  • begin 事件被触发,输出 "About to execute"
  • 真正应该被执行的任务函数被调用,输出 " Executing task "
  • end 事件被触发,输出 "Done with execute"
  • 最后输出 "After executing"

就像普通的回调一样,不要以为事件意味着同步或异步代码。

跟之前的回调一样,不要一提到事件就认为它是异步的或者同步的,还要具体分析。

如果我们传递 taskFunc 是一个异步函数,会发生什么呢?

  1. // ...
  2. withLog.execute(() => {
  3. setImmediate(() => {
  4. console.log('*** Executing task ***')
  5. });
  6. });

输出结果变成了这样:

  1. Before executing
  2. About to execute
  3. Done with execute
  4. After executing
  5. *** Executing task ***

这样就有问题了,异步函数的调用导致 "Done with execute" 和 "After executing" 的输出并不准确。

要在异步函数完成后发出事件,我们需要将回调(或 Promise)与基于事件的通信相结合。 下面的例子说明了这一点。

使用事件而不是常规回调的一个好处是,我们可以通过定义多个监听器对相同的信号做出多个不同的反应。如果使用回调来完成这件事,我们要在单个回调中写更多的处理逻辑。事件是应用程序允许多个外部插件在应用程序核心之上构建功能的好办法。你可以把它们当成钩子来挂一些由于状态变化而引发执行的程序。

异步事件

我们把刚刚那些同步代码的示例改成异步的:

  1. const fs = require('fs');
  2. const EventEmitter = require('events');
  3. class WithTime extends EventEmitter {
  4. execute(asyncFunc, ...args) {
  5. this.emit('begin');
  6. console.time('execute');
  7. asyncFunc(...args, (err, data) => {
  8. if (err) {
  9. return this.emit('error', err);
  10. }
  11. this.emit('data', data);
  12. console.timeEnd('execute');
  13. this.emit('end');
  14. });
  15. }
  16. }
  17. const withTime = new WithTime();
  18. withTime.on('begin', () => console.log('About to execute'));
  19. withTime.on('end', () => console.log('Done with execute'));
  20. withTime.execute(fs.readFile, __filename);

用 WithTime 类执行 asyncFunc 函数,并通过调用 console.time 和 console.timeEnd 报告该asyncFunc 所花费的时间。它在执行之前和之后都将以正确的顺序触发相应的事件,并且还会发出 error/data 事件作为处理异步调用的信号。

我们传递一个异步的 fs.readFile 函数来测试一下 withTime emitter。 我们现在可以直接通过监听 data 事件来处理读取到的文件数据,而不用把这套处理逻辑写到 fs.readFile 的回调函数中。

执行这段代码,我们以预期的顺序执行了一系列事件,并且得到异步函数的执行时间,这些是十分重要的。

  1. About to execute
  2. execute: 4.507ms
  3. Done with execute

请注意,我们是将回调与事件触发器 emitter 相结合实现的这部分功能。 如果 asynFunc 支持Promise,我们可以使用 async/await 函数来做同样的事情:

  1. class WithTime extends EventEmitter {
  2. async execute(asyncFunc, ...args) {
  3. this.emit('begin');
  4. try {
  5. console.time('execute');
  6. const data = await asyncFunc(...args);
  7. this.emit('data', data);
  8. console.timeEnd('execute');
  9. this.emit('end');
  10. } catch(err) {
  11. this.emit('error', err);
  12. }
  13. }
  14. }

我认为这段代码比之前的回调风格的代码以及使用 .then/.catch 风格的代码更具可读性。async/await 让我们更加接近 JavaScript 语言本身(不必再使用 .then/.catch 这些 api)。

事件参数和错误

在之前的例子中,有两个事件被发出时还携带了别的参数。

error 事件被触发时会携带一个 error 对象。

  1. this.emit('error', err);

data 事件被触发时会携带一个 data 对象。

  1. this.emit('data', data);

我们可以在 emit 函数中不断的添加参数,当然第一个参数一定是事件的名称,除去第一个参数之外的所有参数都可以在该事件注册的监听器中使用。

例如,要处理 data 事件,我们注册的监听器函数将访问传递给 emit 函数的 data 参数,而这个 data 也正是由 asyncFunc 返回的数据。

  1. withTime.on('data', (data) => {
  2. // do something with data
  3. });

error 事件比较特殊。在我们基于回调的那个示例中,如果不使用监听器处理 error 事件,node 进程将会退出。

举个由于错误使用参数而造成程序崩溃的例子:

  1. class WithTime extends EventEmitter {
  2. execute(asyncFunc, ...args) {
  3. console.time('execute');
  4. asyncFunc(...args, (err, data) => {
  5. if (err) {
  6. return this.emit('error', err); // Not Handled
  7. }
  8. console.timeEnd('execute');
  9. });
  10. }
  11. }
  12. const withTime = new WithTime();
  13. withTime.execute(fs.readFile, ''); // BAD CALL
  14. withTime.execute(fs.readFile, __filename);

第一次调用 execute 将会触发 error 事件,由于没有处理 error ,Node 程序随之崩溃:

  1. events.js:163
  2. throw er; // Unhandled 'error' event
  3. ^
  4. Error: ENOENT: no such file or directory, open ''

第二次执行调用将受到此崩溃的影响,并且可能根本不会被执行。

如果我们为这个 error 事件注册一个监听器函数来处理 error,结果将大不相同:

  1. withTime.on('error', (err) => {
  2. // do something with err, for example log it somewhere
  3. console.log(err)
  4. });

如果我们执行上述操作,将会报告第一次执行 execute 时发送的错误,但是这次 node 进程不会崩溃退出,其他程序的调用也都能正常完成:

  1. { Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
  2. execute: 4.276ms

需要注意的是,基于 Promise 的函数有些不同,它们暂时只是输出一个警告:

  1. UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open ''
  2. 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.

另一种处理异常的方式是在监听全局的 uncaughtException 进程事件。 然而,使用该事件全局捕获错误并不是一个好办法。

关于 uncaughtException,一般都会建议你避免使用它,但是如果必须用它,你应该让进程退出:

  1. process.on('uncaughtException', (err) => {
  2. // something went unhandled.
  3. // Do any cleanup and exit anyway!
  4. console.error(err); // don't do just that.
  5. // FORCE exit the process too.
  6. process.exit(1);
  7. });

但是,假设在同一时间发生多个错误事件,这意味着上面的 uncaughtException 监听器将被多次触发,这可能会引起一些问题。

EventEmitter 模块暴露了 once 方法,这个方法发出的信号只会调用一次监听器。所以,这个方法常与 uncaughtException 一起使用。

监听器的顺序

如果针对一个事件注册多个监听器函数,当事件被触发时,这些监听器函数将按其注册的顺序被触发。

  1. // first
  2. withTime.on('data', (data) => {
  3. console.log(`Length: ${data.length}`);
  4. });
  5. // second
  6. withTime.on('data', (data) => {
  7. console.log(`Characters: ${data.toString().length}`);
  8. });
  9. withTime.execute(fs.readFile, __filename);

上述代码会先输出 Length 信息,再输出 Characters 信息,执行的顺序与注册的顺序保持一致。

如果你想定义一个新的监听函数,但是希望它能够第一个被执行,你还可以使用 prependListener 方法:

  1. withTime.on('data', (data) => {
  2. console.log(`Length: ${data.length}`);
  3. });
  4. withTime.prependListener('data', (data) => {
  5. console.log(`Characters: ${data.toString().length}`);
  6. });
  7. withTime.execute(fs.readFile, __filename);

上述代码中,Charaters 信息将首先被输出。

最后,你可以用 removeListener 函数来删除某个监听器函数。

作者:痕迹绝陌路

来源:51CTO

[译]理解Node.js事件驱动机制相关推荐

  1. [译]理解 Node.js 事件驱动架构

    原文地址:Understanding Node.js Event-Driven Architecture 大部分 Node 模块,例如 http 和 stream,都是基于EventEmitter模块 ...

  2. Node.js 事件驱动

    Node.js 最主要的两个亮点就是异步I/O 和 事件驱动.那么啥是事件驱动呢? 目录 浏览器的事件驱动 addEventListner和removeEventListener 事件绑定三要素 浏览 ...

  3. 理解node.js(Understanding node.js)

    因为最近自己在学习node.js,刚开始学.看到这篇文章挺有意思,介绍了一下node.js有助于理解基于事件驱动的回调,就翻译了一下. 英文原文: Understanding node.js 理解no ...

  4. 深入理解 Node.js 中的 Worker 线程

    多年以来,Node.js 都不是实现高 CPU 密集型应用的最佳选择,这主要就是因为 JavaScript 的单线程.作为对此问题的解决方案,Node.js v10.5.0 通过 worker_thr ...

  5. node.js事件驱动_了解Node.js事件驱动架构

    node.js事件驱动 by Samer Buna 通过Samer Buna 了解Node.js事件驱动架构 (Understanding Node.js Event-Driven Architect ...

  6. [译]深入理解 Node.js Worker Threads

    最近工作中又有可能需要写 Node.js 应用了,距离上次写 Node.js 应用也有好些年了,所以就开始重新熟悉下 Node.js 了.刚好最近又在学 Go,其最大的特点就是简单.轻量级的并发模型. ...

  7. node.js事件驱动的非阻塞 I/O模型理解

    都说Node.js是一个基于chrome V8引擎的js运行环境,用于方便地搭建响应速度快.易于扩展的网络应用,它有非阻塞.事件驱动模型的特性,有轻量高效的特点,适用于分布式设备上运行数据密集型的实时 ...

  8. linux进程退出所有tcp数据才发送,深入理解Node.js 进程与线程(8000长文彻底搞懂)...

    前言 进程与线程是一个程序员的必知概念,面试经常被问及,但是一些文章内容只是讲讲理论知识,可能一些小伙伴并没有真的理解,在实际开发中应用也比较少.本篇文章除了介绍概念,通过Node.js 的角度讲解进 ...

  9. 由Node.js事件驱动模型引发的思考

    引言 近段时间听说了Node.js,很多文章表述这个事件驱动模型多么多么优秀,应用在服务器开发中有很大的优势,本身对此十分感性去,决定深入了解一下,由此也引发了一些对程序设计的思考,记录下来. 什么是 ...

最新文章

  1. [转]CDN(内容分发网络)技术原理
  2. 我们无法在你选择的位置安装Windows。0x80300002
  3. ava线程池ThreadPoolExecutor的keepAliveTime=0时,表示超过core线程数的线程在空闲时立即结束
  4. Elasticsearch集群配置以及REST API使用
  5. Dart基础-泛型和库
  6. 淘宝SOA框架dubbo学习(2)--搭建Zookeeper注册中心服务
  7. java kdj_基于Java语言开发的个性化股票分析技术随机指数[KDJ].doc
  8. python中定制类
  9. 洛谷.U19464.山村游行wander(LCT 伪期望)
  10. 利用vbs脚本编写Windows XP/2003序列号更改器
  11. 极易上手搭建自己日志采集服务器分析日志(winlogbeat+Elasticsearch+Kibana)
  12. 高等数学张宇18讲 第十一讲 二重积分
  13. mdf文件修复工具 专业修复sql server数据库
  14. 传智播客总裁黎活明“传智专修学院成立暨揭牌仪式”演讲实录
  15. 增强 扫描王 源码_CamScanner扫描全能王v5.15.3 安卓版
  16. MAC Mail签名添加图片,不显示为附件操作
  17. Apple个人开发者账号相关问题
  18. From Nand to Tetris Week1 超详细2021
  19. 加拿大政府贯彻量子技术重要性,221万美元资助量子算法研究所
  20. Android手绘涂鸦PaintView

热门文章

  1. fail-fast机制
  2. Hadoop源码分析:Hadoop编程思想
  3. XAMPP 使用教程
  4. JS设置Cookie,及COOKIE的限制
  5. CSS样式(四)- CSS定位
  6. 数据结构笔记(二十二)--已知先序中序求树
  7. 计算机专业的入职动机,大学生学习计算机动机的研究
  8. python安装sqlalchemy python2_Python SQLAlchemy --2
  9. pytorch实现人脸识别_PyTorch实现,GitHub4000星:微软开源的CV库
  10. php能连接动易吗,动易CMS数据转成dedecms的php程序