ホームページ > 記事 > ウェブフロントエンド > Node.jsのイベント駆動メカニズムの原理
この記事は主に Node.js のイベント駆動の仕組みを理解するための原則を紹介しています。非常に優れた内容なので、参考として共有します。
Node.js を学習する際に理解する必要があることの 1 つ。この記事では主に EventEmitter の使用といくつかの非同期状況の処理について説明します。これは比較的基本的なものであり、読む価値があります。
ほとんどの Node.js オブジェクトは、一般的に使用される HTTP リクエスト、応答、ストリームなどのイベントをリッスンして応答するために EventEmitter モジュールに依存しています。
const EventEmitter = require('events');
イベント駆動型メカニズムの最も単純な形式は、fs.readFile など、Node.js で非常に人気のあるコールバック関数です。 コールバック関数の形式では、イベントがトリガーされるたびにコールバックがトリガーされます。
最初にこの最も基本的な方法を見てみましょう。
準備ができたら電話してください、ノード!
その昔、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 関数は、ファイル パスとコールバック関数の 2 つのパラメーターを受け入れます。ファイルの内容を読み取り、それを行の配列に分割し、コールバック関数を呼び出して、その配列を引数としてコールバック関数に渡します。
次に、同じディレクトリにあるファイル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 は空でもかまいません。コールバックをホスト関数の最後のパラメータとして渡す必要があります。ユーザーは推測する可能性があるため、関数は常にこのように設計する必要があります。ホスト関数にコールバックを最後の引数として取り、コールバック関数に null の可能性があるエラー オブジェクトを最初の引数として取ります。
最新の JavaScript でのコールバックの置換
最新の JavaScript には、非同期 API のコールバックを置換するために使用できる Promise があります。コールバック関数はホスト関数のパラメータとして渡す必要があり (複数のホスト コールバックがネストされてコールバック ヘルを形成します)、エラーと成功はそこでのみ処理できます。 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 呼び出しを追加します。これにより、エラーがキャッチされ、エラー発生時にアクセスできるようになります。
Promise オブジェクトは最新の JavaScript ですでにサポートされているため、ホスト関数で簡単に使用できます。以下は、readFileAsArray 関数の Promise サポート バージョンです (古いコールバック関数メソッドもサポートしています):
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); }); }); };
fs.readFile への非同期呼び出しをラップする Promise オブジェクトを関数が返すようにします。 Promise オブジェクトは、解決関数と拒否関数という 2 つのパラメーターを公開します。
例外がスローされた場合、コールバック関数にerrorを渡すことでエラーを処理することもできますし、Promiseのreject関数を使用することもできます。処理のためにデータをコールバック関数に渡すときはいつでも、Promise のsolve 関数を使用することもできます。
コールバックと 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のイベント駆動メカニズムの原理の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。