理解Node事件驅動架構
前言
今日早讀文章由前端早讀課專欄作者@野草翻譯分享。
正文從這開始~
Node中的絕大多數對象,比如HTTP請求,響應,流,都是實現了EventEmitter模塊,所以它們可以觸發或監聽事件。
能體現事件驅動本質的最簡單形式就是函數的回調,比如Node中常用的fs.readFile。在這個例子中,事件僅觸發一次(當Node完成文件的讀取操作後),回調函數也就充當了事件處理者的身份。
讓我們更深入地探究一下回調形式。
Node的回調
Node處理非同步事件最開始使用的是回調。很久之後(也就是現在),原生JavaScript有了Promise對象和async/await特性來處理非同步。
回調函數其實就是作為函數參數的函數,這個概念的實現得益於JavaScript語言中的函數是第一類對象。
但我們必須要搞清楚,回調並不意味著非同步。函數的回調可以是同步的,也可以是非同步的。
比如,下例中的主函數fileSize接受一個名為cb的回調函數。該回調函數可以根據判斷條件來決定是同步執行還是非同步執行回調。
functionfileSize(fileName,cb){
if(typeoffileName!== string ){
returncb(newTypeError( argument should be string ));// 同步調用
}
fs.stat(fileName,(err,stats)=>{
if(err){returncb(err);}// 非同步調用
cb(null,stats.size);// 非同步調用
});
}
注意,這是不好的實踐,很容易出現意想不到的bug。設計主函數時,回調函數的調用應該總是同步或者非同步的。
再看一個經典的非同步回調例子:
constreadFileAsArray=function(file,cb){
fs.readFile(file,function(err,data){
if(err){
returncb(err);
}
constlines=data.toString().trim().split(
);
cb(null,lines);
});
};
readFileAsArray接收一個文件路徑參數以及一個回調函數。它讀取文件內容,將內容拆分成數組lines,然後調用回調函數處理這個數組。
舉個實例。假設有個numbers.txt文件,內容如下:
現需要計算文件中奇數的個數,上面的readFileAsArray函數就可以利用起來了:
readFileAsArray( ./numbers.txt ,(err,lines)=>{
if(err)throwerr;
constnumbers=lines.map(Number);
constoddNumbers=numbers.filter(n=>n%2===1);
console.log( Odd numbers count: ,oddNumbers.length);
});
這段代碼讀取txt文件中的數字成字元數組,解析成數字,然後計算出奇數的個數。
此處的回調函數用得恰到好處。主函數將回調函數作為最後一個參數,而回調函數的第一個參數是可為null的錯誤信息參數err。這種參數傳遞方式是開發者默認的規則,你最好也遵守:將回調作為主函數的最後一個參數,將錯誤信息作為回調函數的第一個參數。
Promise:回調的取代者
如今,JavaScript有了Promise對象,非同步可以不再需要回調了。回調方式將回調函數作為參數傳遞給主函數,同時在主函數內部處理錯誤信息。Promise對象則不同,它可以單獨處理成功/失敗情況,也可以鏈接多個非同步調用,而不是嵌套處理。
如果readFileAsArray函數支持Promise寫法,我們就可以這麼用:
readFileAsArray( ./numbers.txt )
.then(lines=>{
constnumbers=lines.map(Number);
constoddNumbers=numbers.filter(n=>n%2===1);
console.log( Odd numbers count: ,oddNumbers.length);
})
.catch(console.error);
Promise用法使得我們可以直接在主函數的返回值上調用.then函數,而不是傳入一個回調函數。.then函數能獲取到之前用回調獲取的內容,並且執行相同的業務操作。繼續添加.catch函數,捕捉可能會產生的錯誤信息。
由於原生JavaScript自帶 Promise對象,主函數很容易改造成支持Promise介面。以下是改造後的結合回調方式的readFileAsArray。
constreadFileAsArray=function(file,cb=()=>{}){
returnnewPromise((resolve,reject)=>{
fs.readFile(file,function(err,data){
if(err){
reject(err);
returncb(err);
}
constlines=data.toString().trim().split(
);
resolve(lines);
cb(null,lines);
});
});
};
現在這個函數返回一個包含fs.readFile非同步調用的Promise對象。Promise對象有兩個參數,resolve函數和reject函數。
當我們獲取了錯誤信息需要回調時,用reject處理信息;反之,當我們獲取結果數據需要回調時,用resolve來處理。
另外,回調函數要指定一個預設值,以免直接用Promise介面調用,這裡我們指定為空函數()=>{}。
Promise升級:結合async/await使用
當非同步遇到循環的時候,Promise介面會讓代碼簡單很多。用回調的話,代碼容易混亂。處理非同步的最新特性是async函數,它能讓我們像處理同步函數一樣處理非同步函數,使得代碼更具可讀性。
我們用async/await的方式調用readFileAsArray:
asyncfunctioncountOdd(){
try{
constlines=awaitreadFileAsArray( ./numbers );
constnumbers=lines.map(Number);
constoddCount=numbers.filter(n=>n%2===1).length;
console.log( Odd numbers count: ,oddCount);
}catch(err){
console.error(err);
}
}
countOdd();
首先創建一個非同步函數,其實就是一個帶async關鍵詞的普通函數。函數內部,在readFileAsArray函數前面加上await關鍵詞,保證lines結果返回才執行下一行。
執行這個非同步函數countOdd,就能得到我們想要的結果。代碼看起來簡單且更具可讀性。需要注意的是,我們需要用try/catch處理這個非同步調用,以免出錯。
有了async/await特性之後,我們不再需要像.then,.catch之類的特殊介面。我們僅僅標記一下函數,然後用純原生的代碼寫書。
我們可以給所有支持Promise介面的函數添加async/await特性,不過,不包括非同步回調的函數,比如setTimeout。
EventEmitter模塊
EventEmitter是促進Node中對象之間交流的模塊,它是Node非同步事件驅動機制的核心。Node中很多自帶的模塊都繼承自事件觸發模塊。
概念很簡單:觸發器觸發事件,該事件對應的監聽函數被調用。也就是說,觸發器有兩個特徵:
觸發某個事件
註冊/註銷監聽函數
我們創建一個繼承EventEmitter模塊的類:
classMyEmitterextendsEventEmitter{
}
實例化該類,得到一個事件觸發器:
constmyEmitter=newMyEmitter();
在事件觸發器的生命周期任何時候,我們都能利用emit函數觸發已有的事件。
myEmitter.emit( something-happened );
觸發事件意味著某些情況發生,通常是關於觸發器的狀態變化。
使用on方法添加某個事件的監聽函數,每次觸發器觸發事件時,對應的監聽函數就會被執行。
事件!==非同步
看個例子:
constEventEmitter=require( events );
classWithLogextendsEventEmitter{
execute(taskFunc){
console.log( Before executing );
this.emit( begin );
taskFunc();
this.emit( end );
console.log( After executing );
}
}
constwithLog=newWithLog();
withLog.on( begin ,()=>console.log( About to execute ));
withLog.on( end ,()=>console.log( Done with execute ));
withLog.execute(()=>console.log( *** Executing task *** ));
WithLog類是事件觸發器,它裡面定義了execute函數。該函數接收一個任務函數的參數,頭尾分別用列印語句列印提示信息,並且在任務函數執行前後觸發事件。
為了弄清楚執行順序,我們註冊好事件的監聽函數,給定一個簡單的任務函數,然後執行代碼。
運行的結果如下:
Before executing
About to execute
***Executing task***
Donewithexecute
After executing
注意,上述的結果說明代碼執行是同步的,沒有任何非同步代碼。
首先輸出Before executing
begin事件觸發對應的監聽函數,函數執行輸出About to execute
任務函數執行並且輸出*** Executing task ***
end事件觸發對應的監聽函數,函數執行輸出Done with execute
最後輸出After executing
正如回調一樣,不要想當然地認為事件一定是同步或者非同步的。
明白這點至關重要,如果給execute函數傳入非同步的taskFunc,事件觸發時機就不準確了。
我們可以藉助setImmediate函數模擬非同步的函數:
// ...
withLog.execute(()=>{
setImmediate(()=>{
console.log( *** Executing task *** )
});
});
執行結果如下:
Before executing
About to execute
Donewithexecute
After executing
***Executing task***
執行的結果是有問題的,非同步調用之後的那些代碼,即輸出Done with execute,After executing的部分,不再是正確有效的提示。
若要在非同步函數執行完畢之後觸發事件,需要結合回調或者Promise對象。下文會具體講到如何解決。
相對於一般的回調,事件觸發的優點在於可以通過定義多個監聽函數來達到一個事件觸發多個函數的執行。如果用回調方式,需要在單個回調函數中寫很多代碼邏輯。
非同步事件
我們將上面這個同步的例子再修改一下,變成實用一點的非同步例子。
constfs=require( fs );
constEventEmitter=require( events );
classWithTimeextendsEventEmitter{
execute(asyncFunc,...args){
this.emit( begin );
console.time( execute );
asyncFunc(...args,(err,data)=>{
if(err){
returnthis.emit( error ,err);
}
this.emit( data ,data);
console.timeEnd( execute );
this.emit( end );
});
}
}
constwithTime=newWithTime();
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列印出非同步函數執行所需的時間,並且在函數執行前後觸發正確的事件。在非同步函數的回調中,根據執行情況觸發error或者data事件。
我們傳入非同步函數fs.readFile來測試WithTime。 現在我們不再需要通過回調來處理讀取後的文件數據,我們只要監聽data事件就好了。
執行之後,我們得到正確的事件觸髮結果,也得到了函數執行所需的時間。
About to execute
execute:4.507ms
Donewithexecute
我們可以看到上述代碼是如何結合回調和事件觸發器完成的。如果asyncFunc支持Promise的話,我們還可以用async/await 來代替。
classWithTimeextendsEventEmitter{
asyncexecute(asyncFunc,...args){
this.emit( begin );
try{
console.time( execute );
constdata=awaitasyncFunc(...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事件對應的是數據信息:
this.emit( data ,data);
我們可以在事件參數後面帶上任意多的參數,這些參數會作為對應監聽函數的參數。
比如,我們傳入的data參數會被註冊的監聽函數接收,而這個data對象正好是非同步函數asyncFunc返回的結果數據。
withTime.on( data ,(data)=>{
// do something with data
});
error事件比較特殊,在那個回調例子中,如果我們不人為處理錯誤事件,node進程會自動退出。
下面例子可以證明:
classWithTimeextendsEventEmitter{
execute(asyncFunc,...args){
console.time( execute );
asyncFunc(...args,(err,data)=>{
if(err){
returnthis.emit( error ,err);// 未被處理
}
console.timeEnd( execute );
});
}
}
constwithTime=newWithTime();
withTime.execute(fs.readFile, );// 不好的調用
withTime.execute(fs.readFile,__filename);
第一次調用會拋出錯誤,node進程崩潰然後自動退出;
events.js:163
thrower;// Unhandled error event
^
Error:ENOENT:no such file or directory,open
第二次調用受上一行的崩潰影響,根本就沒有機會執行。
如果我們註冊error事件的監聽函數,結果就不一樣。比如:
withTime.on( error ,(err)=>{
// 處理錯誤信息, 比如說列印出來
console.log(err)
});
如有上述代碼存在,第一次調用的錯誤會被報告,node進程不會像之前一樣崩潰退出。這也就意味著第二次調用正常進行:
{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 processwitha non-zero exit code.
處理error事件觸發的異常的另一種方式是註冊一個監聽全局uncaughtException進程事件的函數,但這並不是個好主意。
一般情況下,建議避免使用uncaughtException。但如果非用不可(比如列印日誌或者清理工作之類的),必須在監聽函數中退出進程。
process.on( uncaughtException ,(err)=>{
// 還不夠
console.error(err);
// 還需要強制推出進程
process.exit(1);
});
問題是,如果同時有多個錯誤事件觸發,就會多次觸發uncaughtException事件註冊的監聽函數,多次清理工作可能會造成問題。比如,當異常事件觸發關閉資料庫的動作時。
EventEmitter模塊暴露一個once方法,限制了事件觸發的監聽函數只能被調用一次。它很適用未捕獲異常的情況,因為只要第一次異常發生,我們就會開始清理,然後退出進程。
監聽函數的順序
如果給一個事件註冊了多個監聽函數,它們的調用是有序進行的。調用的順序跟註冊的順序保持一致。
// 第一個監聽函數
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 方法。
關於本文
譯者:@野草
譯文:https://github.com/fezaoduke/TranslationInstitute/blob/master/理解Node事件驅動架構.md
作者:@Samer Buna
原文:https://medium.freecodecamp.com/understanding-node-js-event-driven-architecture-223292fcbc2d
點擊展開全文
※【第957期】JavaScript 模塊現狀
※「零廣告,全乾貨」iWeb峰會上海站,最後500位參會名額限免來襲!
※代碼審查應該關注什麼:數據結構
※我對知乎前端相關問題的十問十答
※你不懂JS:ES6與未來之元編程(下)
TAG:前端早讀課 |
※一位從事Linux設備驅動多年的嵌入式er教你理解嵌入式Wi-Fi的驅動架構
※代幣驅動治理結構的探索者District0x
※Nvidia停止Kepler架構筆記本顯卡驅動更新支持
※取消煩人的Windows Update自動下載驅動
※Windows Core OS將成為雲驅動操作系統
※電機驅動器模組方案如何搭配Arduino MICRO而運作?
※妙用Digital Twin虛實融合 驅動智能製造升級轉型
※微軟開源驅動程序模塊框架 輕鬆編寫Windows驅動程序
※ASP.NET Core Web API下事件驅動型架構的實現(三):基於RabbitMQ的事件匯流排
※智能舞台決策支撐系統MapReduce驅動模型初探
※思科與聯想計劃將XPoint與Optane驅動器應用於超融合套件
※Win10安裝顯卡驅動提示「此NVIDIA驅動程序與此Windows版本不兼容」的解決方法
※更容易驅動了:Stenheim Alumine Two SE書架音箱
※Google與JBL 合作帶來由 Android TV 驅動的 Link Bar 條形音箱
※vivo Lab推出vivo Play教育項目 以創造力驅動非凡生活體驗
※技術驅動產品與服務,Talent Spot與i人事獲雙重大獎
※技術驅動產品與服務,Talent Spot與i人事獲雙重大獎
※以尖端可信計算技術驅動,Taxa Network帶來包容可用可開發的DApp服務
※以創造力驅動非凡生活體驗,vivo 推出vivo Play 教育項目
※AMD改善Linux驅動,支持動態電源管理