[原始碼解讀]一文徹底搞懂Events模組

ikoala發表於2019-09-23

前言

為什麼寫這篇文章?

  • 清楚的記得剛找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的關係。當釋出者的狀態發生改變時,所有訂閱者都會得到通知。

[原始碼解讀]一文徹底搞懂Events模組

  • 釋出/訂閱模式的特點和結構 三要素:
  1. 釋出者
  2. 訂閱者
  3. 事件(訂閱)

釋出/訂閱者模式的優缺點

  • 優點

主體和觀察者之間完全透明,所有的訊息傳遞過程都通過訊息排程中心完成,也就是說具體的業務邏輯程式碼將會是在訊息排程中心內,而主體和觀察者之間實現了完全的鬆耦合。物件直接的解耦,非同步程式設計中,可以更鬆耦合的程式碼編寫。

  • 缺點

程式易讀性顯著降低;多個釋出者和訂閱者巢狀在一起的時候,程式難以跟蹤,其實還是程式碼不易讀,嘿嘿。

EventEmitter 與 釋出/訂閱模式的關係

Node.js 中的 EventEmitter 模組就是用了釋出/訂閱這種設計模式,釋出/訂閱 模式在主體與觀察者之間引入訊息排程中心,主體和觀察者之間完全透明,所 有的訊息傳遞過程都通過訊息排程中心完成,也就是說具體的業務邏輯程式碼將會是在訊息排程中心內完成。

事件的基本組成要素

[原始碼解讀]一文徹底搞懂Events模組
通過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…

原始碼篇幅過長,給了地址可以對比繼續研究,畢竟是公眾號文章,不想被說。但是一些疑問還是要講的,嘿嘿。

[原始碼解讀]一文徹底搞懂Events模組

閱讀原始碼後一些疑問的解釋

監聽函式的執行順序是同步 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網站的一段話,具體網站名稱在這裡就不說了,不想招黑,這段內容沒問題,但是對於剛接觸事件機制的小夥伴容易混淆

[原始碼解讀]一文徹底搞懂Events模組
fs.open為例子,看一下到底什麼時候產生了事件,什麼時候觸發,和EventEmitter有什麼關係呢?

[原始碼解讀]一文徹底搞懂Events模組

流程的一個說明:本圖中詳細繪製了從 非同步呼叫開始--->非同步呼叫請求封裝--->請求物件傳入I/O執行緒池完成I/O操作--->將完成的I/O結果交給I/O觀察者--->從I/O觀察者中取出回撥函式和結果呼叫執行。

事件產生

關於事件你看圖中第三部分,事件迴圈那裡。Node.js 所有的非同步 I/O 操作(net.Server, fs.readStream 等)在完成後都會新增一個事件到事件迴圈的事件佇列中。

事件觸發

事件的觸發,我們只需要關注圖中第三部分,事件迴圈會在事件佇列中取出事件處理。fs.open產生事件的物件都是 events.EventEmitter 的例項,繼承了EventEmitter,從事件迴圈取出事件的時候,觸發這個事件和回撥函式。

越寫越多,越寫越想,總是這樣,需要控制一下。

[原始碼解讀]一文徹底搞懂Events模組

事件型別為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模組相同)

釋出/訂閱模式與觀察者模式的一點說明

觀察者模式與釋出-訂閱者模式,在平時你可以認為他們是一個東西,但是在某些場合(比如面試)可能需要稍加註意,看一下二者的區別對比

借用網上的一張圖

[原始碼解讀]一文徹底搞懂Events模組
從圖中可以看出,釋出-訂閱模式中間包含一個Event Channel

  1. 觀察者模式 中的觀察者和被觀察者之間還是存在耦合的,兩者必須確切的知道對方的存在才能進行訊息的傳遞。
  2. 釋出-訂閱模式 中的釋出者和訂閱者不需要知道對方的存在,他們通過訊息代理來進行通訊,解耦更加徹底。

參考文章:

  1. Node.js 官網
  2. 樸靈老師的Node.js深入淺出
  3. events在github中的原始碼地址 github.com/nodejs/node…
  4. JavaScript設計模式精講-SHERlocked93

加入我們一起學習吧!

[原始碼解讀]一文徹底搞懂Events模組

相關文章