首頁 >web前端 >js教程 >Node.js 事件驅動機制的原理

Node.js 事件驅動機制的原理

不言
不言原創
2018-06-30 11:03:581833瀏覽

本篇文章主要介紹了理解 Node.js 事件驅動機制的原理,內容挺不錯的,現在分享給大家,也給大家做個參考。

學習 Node.js 一定要理解的內容之一,文中主要涉及到了 EventEmitter 的使用和一些非同步情況的處理,比較偏基礎,值得一讀。

大多數 Node.js 物件都依賴了 EventEmitter 模組來監聽和回應事件,例如我們常用的 HTTP requests, responses, 以及 streams。

const EventEmitter = require('events');

事件驅動機制最簡單的形式,是在 Node.js 中十分流行的回呼函數,例如 fs.readFile。在回呼函數這種形式中,事件每被觸發一次,回呼就會被觸發一次。

我們先來探索下這個最基本的方式。

你準備好了就叫我哈,Node!

很久很久以前,在 js 裡還沒有原生支援 Promise,async/await 還只是一個遙遠的夢想,回呼函數是處理非同步問題的最原始的方式。

回呼本質上是傳遞給其他函數的函數,在 JavaScript 中函數是第一類對象,這也讓回呼的存在成為可能。

一定要搞清楚的是,回呼在程式碼中的並不表示非同步呼叫。回調既可以是同步呼叫的,也可以是非同步呼叫的。

舉個例子,這裡有一個宿主函數fileSize,它接受一個回呼函數cb,並且可以透過條件判斷來同步或非同步地呼叫該回呼函數:

function fileSize (fileName, cb) {
 if (typeof fileName !== 'string') {
  // Sync
  return cb(new TypeError('argument should be string')); 
 } 
 fs.stat(fileName, (err, stats) => {
  if (err) {  
   // Async
   return cb(err); 
   } 
   // Async
  cb(null, stats.size);
 });
}

這其實也是個反例,這樣寫經常會引起一些意外的錯誤,在設計宿主函數的時候,應當盡可能的使用同一種風格,要么始終都是同步的使用回調,要么始終都是異步的。

我們來研究下一個典型的非同步Node 函數的簡單範例,它用回呼樣式編寫:

const readFileAsArray = function(file, cb) {
 fs.readFile(file, function(err, data) {
  if (err) {
   return cb(err);
  }
  const lines = data.toString().trim().split('\n');
  cb(null, lines);
 });
};

readFileAsArray 函數接受兩個參數:一個檔案路徑和一個回調函數。它讀取檔案內容,將其拆分成行數組,並將該數組作為回調函數的參數傳入,呼叫回調函數。

現在設計一個用例,假設我們在同一目錄中的檔案numbers.txt 包含以下內容:

10
11
12
13
14
15

如果我們有一個需求,要求統計該檔案中的奇數數量,我們可以使用readFileAsArray 來簡化程式碼:

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

這段程式碼將檔案內容讀入字串陣列中,回呼函數將其解析為數字,並計算奇數的個數。

這才是最純粹的 Node 回呼風格。回呼的第一個參數要遵循錯誤優先的原則,err 可以為空,我們要將回呼作為宿主函數的最後一個參數傳遞。你應該一直用這種方式這樣設計你的函數,因為使用者可能會假設。讓宿主函數把回調當作其最後一個參數,並讓回呼函數以一個可能為空的錯誤物件作為其第一個參數。

回呼在現代 JavaScript 中的替代品

在現代 JavaScript 中,我們有 Promise,Promise 可以用來取代非同步 API 的回呼。回調函數需要作為宿主函數的一個參數進行傳遞(多個宿主回調進行巢狀就形成了回調地獄),而且錯誤和成功都只能在其中處理。而 Promise 物件可以讓我們分開處理成功和錯誤,還允許我們鍊式呼叫多個非同步事件。

如果readFileAsArray 函數支援Promise,我們可以這樣使用它,如下所示:

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

我們在宿主函數的回傳值上調用了一個函數來處理我們的需求,這個.then 函數會把剛剛在回呼版本中的那個行數組傳遞給這裡的匿名函數。為了處理錯誤,我們在結果上添加一個 .catch 調用,當發生錯誤時,它會捕捉到錯誤並讓我們訪問到這個錯誤。

在現代 JavaScript 中已經支援了 Promise 對象,因此我們可以輕鬆的將其使用在宿主函數之中。以下是支援Promise 版本的readFileAsArray 函數(同時支援舊有的回呼函數方式):

const readFileAsArray = function(file, cb = () => {}) {
 return new Promise((resolve, reject) => {
  fs.readFile(file, function(err, data) {
   if (err) {
    reject(err);
    return cb(err);
   }   
   const lines = data.toString().trim().split('\n');
   resolve(lines);
   cb(null, lines);
  });
 });
};

我們讓該函數傳回一個Promise 對象,該物件包裹了fs.readFile 的非同步呼叫。 Promise 物件暴露了兩個參數,一個 resolve 函數和一個 reject 函數。

當有異常拋出時,我們可以透過向回呼函數傳遞 error 來處理錯誤,也同樣可以使用 Promise 的 reject 函數。每當我們將資料交給回呼函數處理時,我們同樣也可以用 Promise 的 resolve 函數。

在這種同時可以使用回調和Promise 的情況下,我們需要做的唯一一件事情就是為這個回調參數設定預設值,防止在沒有傳遞回調函數參數時,其被執行然後報錯誤的情況。在這個例子中使用了一個簡單的預設空函數:()=> {}。

透過 async/await 使用 Promise

#

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

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

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

async function countOdd () {
 try {
  const lines = await readFileAsArray('./numbers');
  const numbers = lines.map(Number);
  const oddCount = numbers.filter(n => n%2 === 1).length;
  console.log('Odd numbers count:', oddCount);
 } catch(err) {
  console.error(err);
 }
}
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 的类。

class MyEmitter extends EventEmitter {
}

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

const myEmitter = new MyEmitter();

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

myEmitter.emit('something-happened');
emit 函数的使用表示发生某种情况发生了,让大家去做该做的事情。 这种情况通常是某些状态变化引起的。

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

事件 !== 异步

先看看这个例子:

const EventEmitter = require('events');

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 ***'));

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

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

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

Before executing
About to execute
*** Executing task ***
Done with execute
After executing

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

  • 第一行输出了 "Before executing"

  • begin 事件被触发,输出 "About to execute"

  • 真正应该被执行的任务函数被调用,输出 " Executing task "

  • end 事件被触发,输出 "Done with execute"

  • 最后输出 "After executing"

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

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

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

// ...

withLog.execute(() => {
 setImmediate(() => {
  console.log('*** Executing task ***')
 });
});

输出结果变成了这样:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***

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

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

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

异步事件

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

const fs = require('fs');
const EventEmitter = require('events');

class WithTime extends EventEmitter {
 execute(asyncFunc, ...args) {
  this.emit('begin');
  console.time('execute');
  asyncFunc(...args, (err, 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);

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

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

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

About to execute
execute: 4.507ms
Done with execute

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

class WithTime extends EventEmitter {
 async execute(asyncFunc, ...args) {
  this.emit('begin');
  try {
   console.time('execute');
   const data = await asyncFunc(...args);
   this.emit('data', data);
   console.timeEnd('execute');
   this.emit('end');
  } catch(err) {
   this.emit('error', err);
  }
 }
}

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

事件参数和错误

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

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

this.emit('error', err);

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

this.emit('data', data);

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

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

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

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

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

class WithTime extends EventEmitter {
 execute(asyncFunc, ...args) {
  console.time('execute');
  asyncFunc(...args, (err, data) => {
   if (err) {
    return this.emit('error', err); // Not Handled
   }

   console.timeEnd('execute');
  });
 }
}

const withTime = new WithTime();

withTime.execute(fs.readFile, ''); // BAD CALL
withTime.execute(fs.readFile, __filename);

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

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

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

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

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

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

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

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

UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open ''

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,一般都会建议你避免使用它,但是如果必须用它,你应该让进程退出:

process.on('uncaughtException', (err) => {
 // 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);
});

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

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

监听器的顺序

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

// first
withTime.on('data', (data) => {
 console.log(`Length: ${data.length}`);
});

// second
withTime.on('data', (data) => {
 console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);

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

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

withTime.on('data', (data) => {
 console.log(`Length: ${data.length}`);
});

withTime.prependListener('data', (data) => {
 console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);

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

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

以上就是本文的全部内容,希望对大家的学习有所帮助,更多相关内容请关注PHP中文网!

相关推荐:

node.js 利用流实现读写同步,边读边写的功能

关于nodejs socket服务端和客户端的简单通信功能

如何在NodeJS项目中使用ES6

以上是Node.js 事件驅動機制的原理的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn