前言
為什麼寫這篇文章?
- 清楚的記得剛找node工作和麵試官聊到了事件迴圈,然後面試官問事件是如何產生的?什麼情況下產生事件。。。
- Events 在哪些場景應用到了?
- 之前封裝了一個 RxJava 的開源網路請求框架,也是基於釋出-訂閱模式,語言都是相通的,挺有趣。表情符號
- Events 模組是我公眾號 Node.js 進階路線的一部分
作者簡介:koala,專注完整的 Node.js 技術棧分享,從 JavaScript 到 Node.js,再到後端資料庫,祝您成為優秀的高階 Node.js 工程師。【程式設計師成長指北】作者,Github 部落格開源專案 github.com/koala-codin…
面試會問
說一下 Node.js 哪裡應用到了釋出/訂閱模式
Events 模組在實際專案開發中有使用過嗎?具體應用場景是?
Events 監聽函式的執行順序是非同步還是同步的?
說幾個 Events 模組的常用函式吧?
模擬實現 Node.js 的核心模組 Events
文章首發Github 部落格開源專案 github.com/koala-codin…
釋出/訂閱者模式
釋出/訂閱者模式
應該是我在開發過程中遇到的最多的設計模式。釋出/訂閱者模式
,也可以稱之為訊息機制,定義了一種依賴關係,這種依賴關係可以理解為 1對N
(注意:不一定是1對多,有時候也會1對1哦),觀察者們同時監聽某一個物件相應的狀態變換,一旦變化則通知到所有觀察者,從而觸發觀察者相應的事件,該設計模式解決了主體物件與觀察者之間功能的耦合
。
生活中的釋出/訂閱者模式
警察抓小偷
在現實生活中,警察抓小偷是一個典型的觀察者模式「這以一個慣犯在街道逛街然後被抓為例子」,這裡小偷就是被觀察者,各個幹警就是觀察者,幹警時時觀察著小偷,當小偷正在偷東西「就給幹警傳送出一條訊號,實際上小偷不可能告訴幹警我有偷東西」,幹警收到訊號,出擊抓小偷。這就是一個觀察者模式
訂閱了某個報社的報紙
生活中就像是去報社訂報紙,你喜歡讀什麼報就去報社去交錢訂閱,當釋出了新報紙的時候,報社會向所有訂閱了報紙的每一個人傳送一份,訂閱者就可以接收到。
你訂閱了我的公眾號
我這個微信公號作者是釋出者,您這些微信使用者是訂閱者「我傳送一篇文章的時候,關注了【程式設計師成長指北】的訂閱者們都可以收到文章。
例項的程式碼實現與分析
以大家訂閱公眾號
為例子,看看釋出/訂閱模式
如何實現的。(以訂閱報紙作為例子的原因,可以增加一個type
引數,用於區分訂閱不同型別的公眾號,如有的人訂閱的是前端公眾號,有的人訂閱的是 Node.js 公眾號,使用此屬性來標記。這樣和接下來要講的 EventEmitter 原始碼更相符,另一個原因是這樣你只要開啟一個訂閱號文章是不是就想到了釋出-訂閱者模式呢。)
程式碼如下:
let officeAccounts ={
// 初始化定義一個儲存型別物件
subscribes:{
'any':[]
},
// 新增訂閱號
subscribe:function(type='any',fn){
if(!this.subscribes[type]){
this.subscribes[type] = [];
}
this.subscribes[type].push(fn);//將訂閱方法存在陣列中
},
// 退訂
unSubscribe:function(type='any',fn){
this.subscribes[type] =
this.subscribes[type].filter((item)=>{
return item!=fn;// 將退訂的方法從陣列中移除
});
},
// 釋出訂閱
publish:function(type='any',...args){
this.subscribes[type].forEach(item => {
item(...args);// 根據不同的型別呼叫相應的方法
});
}
}
複製程式碼
以上就是一個最簡單的觀察者模式的實現,可以看到程式碼非常的簡單,核心原理就是將訂閱的方法按分類存在一個陣列中,當釋出時取出執行即可
接下里看小明訂閱【程式設計師成長指北】文章的程式碼:
let xiaoming = {
readArticle:function (info) {
console.log('小明收到的',info);
}
};
let xiaogang = {
readArticle:function (info) {
console.log('小剛收到的',info);
}
};
officeAccounts.subscribe('程式設計師成長指北',xiaoming.readArticle);
officeAccounts.subscribe('程式設計師成長指北',xiaogang.readArticle);
officeAccounts.subscribe('某公眾號',xiaoming.readArticle);
officeAccounts.unSubscribe('某公眾號',xiaoming.readArticle);
officeAccounts.publish('程式設計師成長指北','程式設計師成長指北的Node文章');
officeAccounts.publish('某公眾號','某公眾號的文章');
複製程式碼
執行結果:
小明收到的 程式設計師成長指北的Node文章
小剛收到的 程式設計師成長指北的Node文章
複製程式碼
- 結論
通過觀察現實生活中的三個例子以及程式碼例項發現釋出/訂閱模式的確是1對N的關係。當釋出者的狀態發生改變時,所有訂閱者都會得到通知。
- 釋出/訂閱模式的特點和結構 三要素:
- 釋出者
- 訂閱者
- 事件(訂閱)
釋出/訂閱者模式的優缺點
- 優點
主體和觀察者之間完全透明,所有的訊息傳遞過程都通過訊息排程中心完成,也就是說具體的業務邏輯程式碼將會是在訊息排程中心內,而主體和觀察者之間實現了完全的鬆耦合。物件直接的解耦,非同步程式設計中,可以更鬆耦合的程式碼編寫。
- 缺點
程式易讀性顯著降低;多個釋出者和訂閱者巢狀在一起的時候,程式難以跟蹤,其實還是程式碼不易讀,嘿嘿。
EventEmitter 與 釋出/訂閱模式的關係
Node.js 中的 EventEmitter 模組就是用了釋出/訂閱這種設計模式,釋出/訂閱 模式在主體與觀察者之間引入訊息排程中心,主體和觀察者之間完全透明,所 有的訊息傳遞過程都通過訊息排程中心完成,也就是說具體的業務邏輯程式碼將會是在訊息排程中心內完成。
事件的基本組成要素
通過Api的對比,來看看Events模組EventEmitter 定義
Events是 Node.js 中一個使用率很高的模組,其它原生node.js模組都是基於它來完成的,比如流、HTTP等。它的核心思想就是 Events 模組的功能就是一個事件繫結與觸發
,所有繼承自它的例項都具備事件處理的能力。
EventEs 的一些常用官方API原始碼與釋出/訂閱模式對比學習
本模組的官方 Api 講解不是直接帶大家學習文件,而是
通過對比
釋出/訂閱設計模式自己手寫一個版本 Events 的核心程式碼來學習並記住Api
Events 模組
Events 模組只有一個 EventEmitter 類,首先定義類的基本結構
function EventEmitter() {
//私有屬性,儲存訂閱方法
this._events = {};
}
//預設設定最大監聽數
EventEmitter.defaultMaxListeners = 10;
module.exports = EventEmitter;
複製程式碼
on 方法
on 方法,該方法用於訂閱事件(這裡 on 和 addListener 說明下),Node.js 原始碼中這樣把它們倆賦值了下,我也不太懂為什麼?知道的小夥伴可以告訴我為什麼要這樣做哦。
EventEmitter.prototype.addListener = function addListener(type, listener) {
return _addListener(this, type, listener, false);
};
EventEmitter.prototype.on = EventEmitter.prototype.addListener;
複製程式碼
接下來是我們對on方法的具體實踐:
EventEmitter.prototype.on =
EventEmitter.prototype.addListener = function (type, listener, flag) {
//保證存在例項屬性
if (!this._events) this._events = Object.create(null);
if (this._events[type]) {
if (flag) {//從頭部插入
this._events[type].unshift(listener);
} else {
this._events[type].push(listener);
}
} else {
this._events[type] = [listener];
}
//繫結事件,觸發newListener
if (type !== 'newListener') {
this.emit('newListener', type);
}
};
複製程式碼
因為有其它子類需要繼承自EventEmitter,因此要判斷子類是否存在_event屬性,這樣做是為了保證子類必須存在此例項屬性。而flag標記是一個訂閱方法的插入標識,如果為'true'就視為插入在陣列的頭部。可以看到,這就是觀察者模式的訂閱方法實現。
emit方法
EventEmitter.prototype.emit = function (type, ...args) {
if (this._events[type]) {
this._events[type].forEach(fn => fn.call(this, ...args));
}
};
複製程式碼
emit方法就是將訂閱方法取出執行,使用call方法來修正this的指向,使其指向子類的例項。
once方法
EventEmitter.prototype.once = function (type, listener) {
let _this = this;
//中間函式,在呼叫完之後立即刪除訂閱
function only() {
listener();
_this.removeListener(type, only);
}
//origin儲存原回撥的引用,用於remove時的判斷
only.origin = listener;
this.on(type, only);
};
複製程式碼
once方法非常有趣,它的功能是將事件訂閱“一次”,當這個事件觸發過就不會再次觸發了。其原理是將訂閱的方法再包裹一層函式,在執行後將此函式移除即可。
off方法
EventEmitter.prototype.off =
EventEmitter.prototype.removeListener = function (type, listener) {
if (this._events[type]) {
//過濾掉退訂的方法,從陣列中移除
this._events[type] =
this._events[type].filter(fn => {
return fn !== listener && fn.origin !== listener
});
}
};
複製程式碼
off方法即為退訂,原理同觀察者模式一樣,將訂閱方法從陣列中移除即可。
prependListener方法
EventEmitter.prototype.prependListener = function (type, listener) {
this.on(type, listener, true);
};
複製程式碼
碼此方法不必多說了,呼叫on方法將標記傳為true(插入訂閱方法在頭部)即可。 以上,就將EventEmitter類的核心方法實現了。
其他一些不太常用api
emitter.listenerCount(eventName)
可以獲取事件註冊的listener
個數emitter.listeners(eventName)
可以獲取事件註冊的listener
陣列副本。
Api學習後的小練習
//event.js 檔案
var events = require('events');
var emitter = new events.EventEmitter();
emitter.on('someEvent', function(arg1, arg2) {
console.log('listener1', arg1, arg2);
});
emitter.on('someEvent', function(arg1, arg2) {
console.log('listener2', arg1, arg2);
});
emitter.emit('someEvent', 'arg1 引數', 'arg2 引數');
複製程式碼
執行以上程式碼,執行的結果如下:
$ node event.js
listener1 arg1 引數 arg2 引數
listener2 arg1 引數 arg2 引數
複製程式碼
手寫程式碼後的說明
手寫Events模組程式碼的時候注意以下幾點:
- 使用訂閱/釋出模式
- 事件的核心組成有哪些
- 寫原始碼時候考慮一些範圍和極限判斷
注意:我上面的手寫程式碼並不是效能最好和最完善的,目的只是帶大家先弄懂記住他。舉個例子:
最初的定義EventEmitter類,原始碼中並不是直接定義 this._events = {}
,請看:
function EventEmitter() {
EventEmitter.init.call(this);
}
EventEmitter.init = function() {
if (this._events === undefined ||
this._events === Object.getPrototypeOf(this)._events) {
this._events = Object.create(null);
this._eventsCount = 0;
}
this._maxListeners = this._maxListeners || undefined;
};
複製程式碼
同樣是實現一個類,但是原始碼中更注意效能,我們可能認為簡單的一個 this._events = {}
;就可以了,但是通過jsperf
(一個小彩蛋,有需要的搜以下,檢視效能工具) 比較兩者的效能,原始碼中高了很多,我就不具體一一講解了,附上原始碼地址,有興趣的可以去學習
lib/events原始碼地址 github.com/nodejs/node…
原始碼篇幅過長,給了地址可以對比繼續研究,畢竟是公眾號文章,不想被說。但是一些疑問還是要講的,嘿嘿。
閱讀原始碼後一些疑問的解釋
監聽函式的執行順序是同步 or 非同步?
看一段程式碼:
const EventEmitter = require('events');
class MyEmitter extends EventEmitter{};
const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('listener1');
});
myEmitter.on('event', async function() {
console.log('listener2');
setTimeout(() => {
console.log('我是非同步中的輸出');
resolve(1);
}, 1000);
});
myEmitter.on('event', function() {
console.log('listener3');
});
myEmitter.emit('event');
console.log('end');
複製程式碼
輸出結果如下:
// 輸出結果
listener1
listener2
listener3
end
我是非同步中的輸出
複製程式碼
EventEmitter觸發事件的時候,各監聽函式的呼叫
是同步的(注意:監聽函式的呼叫是同步的,'end'的輸出在最後),但是並不是說監聽函式裡不能包含非同步的程式碼,程式碼中listener2那個事件就加了一個非同步的函式,它是最後輸出的。
事件迴圈中的事件是什麼情況下產生的?什麼情況下觸發的?
我為什麼要把這個單獨寫成一個小標題來講,因為發現網上好多文章都是錯的,或者不明確,給大家造成了誤導。
看這裡,某API網站的一段話,具體網站名稱在這裡就不說了,不想招黑,這段內容沒問題,但是對於剛接觸事件機制的小夥伴容易混淆
以fs.open
為例子,看一下到底什麼時候產生了事件,什麼時候觸發,和EventEmitter有什麼關係呢?
流程的一個說明:本圖中詳細繪製了從 非同步呼叫開始--->非同步呼叫請求封裝--->請求物件傳入I/O執行緒池完成I/O操作--->將完成的I/O結果交給I/O觀察者--->從I/O觀察者中取出回撥函式和結果呼叫執行。
事件產生
關於事件你看圖中第三部分,事件迴圈那裡。Node.js 所有的非同步 I/O 操作(net.Server, fs.readStream 等)在完成後
都會新增一個事件到事件迴圈的事件佇列中。
事件觸發
事件的觸發,我們只需要關注圖中第三部分,事件迴圈會在事件佇列中取出事件處理。fs.open
產生事件的物件都是 events.EventEmitter 的例項,繼承了EventEmitter,從事件迴圈取出事件的時候,觸發這個事件和回撥函式。
越寫越多,越寫越想,總是這樣,需要控制一下。
事件型別為error的問題
當我們直接為EventEmitter定義一個error事件,它包含了錯誤的語義,我們在遇到 異常的時候通常會觸發 error 事件。
當 error 被觸發時,EventEmitter 規定如果沒有響 應的監聽器,Node.js 會把它當作異常,退出程式並輸出錯誤資訊。
var events = require('events');
var emitter = new events.EventEmitter();
emitter.emit('error');
複製程式碼
執行時會報錯
node.js:201
throw e; // process.nextTick error, or 'error' event on first tick
^
Error: Uncaught, unspecified 'error' event.
at EventEmitter.emit (events.js:50:15)
at Object.<anonymous> (/home/byvoid/error.js:5:9)
at Module._compile (module.js:441:26)
at Object..js (module.js:459:10)
at Module.load (module.js:348:31)
at Function._load (module.js:308:12)
at Array.0 (module.js:479:10)
at EventEmitter._tickCallback (node.js:192:40)
複製程式碼
我們一般要為會觸發 error 事件的物件設定監聽器,避免遇到錯誤後整個程式崩潰。
如何修改EventEmitter的最大監聽數量?
預設情況下針對單一事件的最大listener數量是10,如果超過10個的話listener還是會執行,只是控制檯會有警告資訊,告警資訊裡面已經提示了操作建議,可以通過呼叫emitter.setMaxListeners()來調整最大listener的限制
(node:9379) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 event listeners added. Use emitter.setMaxListeners() to increase limit
複製程式碼
一個列印warn詳細內容的小技巧
上面的警告資訊的粒度不夠,並不能告訴我們是哪裡的程式碼出了問題,可以通過process.on('warning')來獲得更具體的資訊(emitter、event、eventCount)
process.on('warning', (e) => {
console.log(e);
})
{ MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 event listeners added. Use emitter.setMaxListeners() to increase limit
at _addListener (events.js:289:19)
at MyEmitter.prependListener (events.js:313:14)
at Object.<anonymous> (/Users/xiji/workspace/learn/event-emitter/b.js:34:11)
at Module._compile (module.js:641:30)
at Object.Module._extensions..js (module.js:652:10)
at Module.load (module.js:560:32)
at tryModuleLoad (module.js:503:12)
at Function.Module._load (module.js:495:3)
at Function.Module.runMain (module.js:682:10)
at startup (bootstrap_node.js:191:16)
name: 'MaxListenersExceededWarning',
emitter:
MyEmitter {
domain: null,
_events: { event: [Array] },
_eventsCount: 1,
_maxListeners: undefined },
type: 'event',
count: 11 }
複製程式碼
EventEmitter的應用場景
- 不能try/catch的錯誤異常丟擲可以使用它
- 好多常用模組繼承自EventEmitter
比如
fs
模組net
模組 - 面試題會考
- 前端開發中也經常用到釋出/訂閱模式(思想與Events模組相同)
釋出/訂閱模式與觀察者模式的一點說明
觀察者模式與釋出-訂閱者模式,在平時你可以認為他們是一個東西,但是在某些場合(比如面試)可能需要稍加註意,看一下二者的區別對比
借用網上的一張圖
從圖中可以看出,釋出-訂閱模式中間包含一個Event Channel- 觀察者模式 中的觀察者和被觀察者之間還是存在耦合的,兩者必須確切的知道對方的存在才能進行訊息的傳遞。
- 釋出-訂閱模式 中的釋出者和訂閱者不需要知道對方的存在,他們通過訊息代理來進行通訊,解耦更加徹底。
參考文章:
- Node.js 官網
- 樸靈老師的Node.js深入淺出
- events在github中的原始碼地址 github.com/nodejs/node…
- JavaScript設計模式精講-SHERlocked93
加入我們一起學習吧!