原文地址:Understanding Node.js Event-Driven Architecturejavascript
大部分 Node 模塊,例如 http 和 stream,都是基於EventEmitter
模塊實現的,因此它們擁有觸發和監聽事件的能力。java
const EventEmitter = require('events');
複製代碼
事件驅動的世界中,對於大部分 Node.js 函數,經過回調的形式就是最簡單的,例如fs.readFile
。在這個例子中,事件會被觸發一次(當 Node 已經準備好去調用回調函數時),而且回調函數將做爲事件處理函數。node
首先讓咱們看一下基本形式。數據庫
Node 控制異步事件最初的形式是經過回調函數。那是在好久之前,那時候 Javascript 尚未支持原生的 Promise 和 async/await 特性。數組
回調函數最初只是你傳遞到其餘函數的函數。由於 Javascript 中,函數是第一類對象,因此才讓這種行爲成爲可能。promise
回調函數不表明代碼就是異步調用的,理解這一點是很是重要的。一個函數調用回調函數時,既能夠經過同步,也能夠經過異步。bash
例如,下面的fileSize
函數接受cb
做爲回調函數,而且能夠根據條件,經過異步或同步觸發回調。架構
function fileSize(fileName, cb) {
if (typeof fileName !== 'string') {
return cb(new TypeError('argument should be string')); // 同步
}
fs.stat(fileName, (err, stats) => {
if (err) {
return cb(err); // 異步
}
cb(null, stats.size); // 異步
});
}
複製代碼
注意:這是一個可能會致使意料以外錯誤的壞實踐。設計函數時,回調函數調用最好只經過異步,或者只經過同步。app
讓咱們看一個用回調形式編寫,典型異步 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
參數包括一個路徑和一個回調函數。該宿主函數讀取文件內容,並將它們分離到 lines 數組中,並將 lines 傳入回調函數中。
下面是一個使用案例。假如在同一目錄下,咱們有一個文件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('奇數的數量爲:', oddNumbers.length);
});
複製代碼
上面的代碼會讀取數字內容並轉化爲字符串數組,將它們解析爲數字,並找出奇數。
這裏只用了 Node 的回調函數形式。回調函數第一個參數是err
錯誤對象,沒有錯誤時,返回null
。宿主函數中,回調函數做爲最後一個參數傳入其中。在你的函數中,你應該老是這麼作。也就是將宿主函數的最後一個參數設置爲回調函數,而且將回調函數第一個參數設置爲錯誤對象。
現代的 Javascript 中,咱們擁有 Promise 對象。Promise 成爲異步 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
,而不是傳入回調函數。函數.then
會給咱們獲取相同行數的數組的途徑,就像回調函數版本的同樣,而且咱們能夠像以前同樣進行處理。若是想要進行錯誤處理,咱們須要在返回值上調用.catch
函數,這讓咱們在錯誤發生的時候能夠進行處理。
由於在現代 Javascript 中有 Promise 對象,因此讓宿主函數支持 Promise 接口變得很是容易。下面是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 對象暴露了兩個參數,分別是resolve
函數和reject
函數。
咱們可使用Promise的reject
方法處理錯誤時的調用。也能夠經過resolve
函數,處理正常獲取數據的調用。
在 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
字段。在這個異步函數中,咱們經過await
關鍵字,調用readFileAsArray
函數,就像這個函數直接返回了行數同樣。而後,調用readFileAsArray
的代碼就像同步同樣。
咱們執行異步函數,讓它能夠運做。這很是簡單而且更具可讀性。若是想要進行錯誤處理,咱們須要把異步調用包裹在try
/catch
語句中。
經過 async/await 特性,咱們不須要使用一些特殊的 API(例如.then 和.catch)。咱們只須要標記函數,並使用原生的 Javascript 代碼就能夠了。
只要函數支持 Promise 接口,咱們就可使用 async/await 特性。可是,在 async 函數中,咱們不能使用回調函數形式的代碼(例如 setTimeout)。
在 Node 中,EventEmitter 是一個能夠加快對象之間通訊的模塊,也是 Node 異步事件驅動架構的核心。許多 Node 內建模塊也是繼承於 EventEmitter 的。
核心概念很是簡單:Emitter 對象觸發具名事件,這會致使事先註冊了監聽器的具名事件被調用。因此,一個 Emitter 對象擁有兩個基本特性:
咱們只須要建立一個繼承於 EventEmitter 的類,就可讓 EventEmitter 起做用了。
class MyEmitter extends EventEmitter {
//
}
複製代碼
Emitter 對象是基於 EventEmitter 類的實例化對象:
const myEmitter = new MyEmitter();
複製代碼
在 Emitter 對象生命週期的任什麼時候刻,咱們均可以經過使用 emit 函數去觸發咱們想要的具名事件。
myEmitter.emit('something-happened');
複製代碼
觸發事件是某些條件發生了的標誌。這個條件一般是 Emitter 對象中狀態的變化產生的。
咱們經過使用方法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
類是一個事件 Emitter。它定義了實例屬性execute
。這個excute
函數接收一個參數,也就是一個任務函數,並把這個函數包裹在 log 語句中。它在執行先後觸發了事件。
爲了可以看到執行的前後順序,咱們註冊了兩個事件,並經過一個任務去觸發它們。
下面代碼的輸出結果:
Before executing
About to execute
*** Executing task ***
Done with execute
After executing
複製代碼
關於上面這個輸出信息,我想讓你注意的就是全部代碼是同步進行的,而不是經過異步。
begin
事件觸發執行 "About to execute" 這一行。*** Executing task ***
。end
事件觸發執行 "Done with execute" 這一行。就像老式的回調函數同樣,因此千萬不要認爲事件就意味着代碼是同步的或者是異步的。
這個概念很重要,由於若是咱們傳入一個異步taskFunc
來進行execute
,事件觸發順序就再也不精確。
咱們能夠經過setImmediate
模擬這種狀況:
// ...
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
。咱們如今能夠經過監聽 data 事件,而沒必要使用回調來處理文件數據。
當執行這些代碼時,咱們如期地獲取到了正確順序,而且獲取了代碼執行所用的事件,這很是有用:
About to execute
execute: 4.507ms
Done with execute
複製代碼
那咱們該如何作才能將回調函數和事件觸發器結合起來呢?若是asyncFunc
也支持 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 語言自己,這無疑是一大成功。
在上面的例子中,兩個事件被觸發的時候,都附帶了額外的參數。
error 事件觸發時,附帶了錯誤對象。
this.emit('error', err);
複製代碼
data 事件觸發時,附帶了 data 數據。
this.emit('data', data);
複製代碼
咱們能夠在具名事件中附帶不少參數,全部的這些參數能夠在以前註冊的監聽器函數中訪問到。
例如,data 事件可用時,咱們註冊的監聽函數就能夠獲取到事件觸發時傳遞的參數。這個 data 對象就是asyncFunc
暴露的。
withTime.on('data', data => {
// do something with data
});
複製代碼
一般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 調用將會引起錯誤。node 進程將會崩潰並退出:
events.js:163
throw er; // Unhandled 'error' event
^
Error: ENOENT: no such file or directory, open ''
複製代碼
而第二個 execute 調用會由於程序崩潰受到影響,而且永遠不會執行。
若是咱們註冊了一個特殊的error
事件,node 進程的行爲將會改變。例如:
withTime.on('error', err => {
// do something with err, for example log it somewhere
console.log(err);
});
複製代碼
若是咱們像上面這樣作,來自第一個 execute 調用的錯誤將會被報告給事件,從而 node 進程就不會崩潰和退出了。另一個 execute 調用將會正常執行:
{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms
複製代碼
注意如今基於 promise 的 Node 的行爲將有所不一樣,只是會輸出一個警告,可是最終將會改變。
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
的實際用例,由於發生了第一個未捕獲的異常時,咱們將開始清除,並且不管如何進程都將會退出。
若是咱們在同一個事件上,註冊了多個監聽器,這些監聽器的調用將按照順序進行。也就是說,第一個註冊的監聽函數,將會被第一個調用。
// 第一個
withTime.on('data', data => {
console.log(`Length: ${data.length}`);
});
// 另外一個
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);
複製代碼
這面的代碼將會讓 Characters 先被打印。
最後,若是你須要刪除某一個監聽器,你可使用removeListener
方法。
這就是本次話題的全部內容。感謝你的閱讀!期待下一次!