解析nodeJS模塊源碼 親手打造基於ES6的觀察者系統

毫無疑問,nodeJS改變了整個前端開發生態。本文經過分析nodeJS當中events模塊源碼,由淺入深,動手實現了屬於本身的ES6事件觀察者系統。千萬不要被nodeJS的外表嚇到,無論你是寫nodeJS已經輕車熟路的老司機,仍是初入前端的小菜鳥,都不妨礙對這篇文章的閱讀和理解。javascript

事件驅動設計理念

nodeJS官方介紹中,第二句話即是:前端

"Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient"。java

由此,「事件驅動(event-driven)」理念對nodeJS設計的重要性可見一斑。好比,咱們對於文件的讀取,任務隊列的執行等,都須要這樣一個觀察者模式來保障。node

那個最熟悉的陌生人

同時,做爲前端開發人員,咱們對於所謂的「事件驅動」理念——即「事件發佈訂閱模式(Pub/Sub模式)」必定再熟悉不過了。這種模式在js裏面有與生俱來的基因。咱們能夠認爲JS自己就是事件驅動型語言:
好比,頁面上有一個button, 點擊一下就會觸發上面的click事件。這是由於此時有特定程序正在監聽這個事件,隨之觸發了相關的處理程序。git

這個模式最大的一個好處在於可以解耦,實現「高內聚、低耦合」的理念。那麼這樣一個「熟悉的」模式應該怎麼實現呢?程序員

其實社區上已經有很多前輩的實現了,可是都不能算特別完美,或者不能徹底符合特定的場景需求。github

本文經過解析nodeJS源碼中的events模塊,提取其精華,一步步打造了一個基於ES6的eventEmitter系統。api

讀者有任何想法,歡迎與我交流。同時但願各路大神給予斧正。數組

背景簡介

爲了方便你們理解,我從一個很簡單的頁面實例提及。瀏覽器

百度某產品頁面中,存在兩處不一樣的收藏組件:

  • 一處在頁面頂部;
  • 一處在頁面詳情側欄。

第一次點擊一個收藏組件按鈕,發送異步請求,進行收藏,同時請求成功的回調函數裏,須要將頁面中全部「收藏」按鈕轉換狀態爲「已收藏」。以達到「當前文章」收藏狀態的全局同步。

頁面實例

完成這樣的設計很簡單,咱們大可在業務代碼中進行混亂的操做處理,好比初學者常見的作法是:點擊第一處收藏,異步請求以後的回調邏輯裏,修改頁面當中全部收藏按鈕狀態。

這樣作的問題在於耦合混亂,不只僅是一個收藏組件,試想當代碼中全部組件全都是這樣的「隨意」操做,後期維護成本便一發不可收。

個人Github倉庫中,也有對於這麼一個頁面實例的分析,讀者若想本身玩一下,能夠訪問這裏。

固然,更優雅的作法就是使用事件訂閱發佈系統。
咱們先來看看nodeJS是怎麼作的吧!

nodeJS方案

讀者能夠本身去nodeJS倉庫查找源碼,不過更推薦參考個人Github-事件發佈訂閱研究項目,裏面不只有本身實現的多套基於ES6的事件發佈訂閱系統,也「附贈」了nodeJS實現源碼。同時我對源碼加上了漢語註釋,方便你們理解。

在nodeJS中,引入eventEmitter的方式和實例化方法以下:

// 引入 events 模塊
var events = require('events');
// 建立 eventEmitter 對象
var eventEmitter = new events.EventEmitter();複製代碼

咱們要研究的,固然就是這個eventEmitter實例。先不急於深刻源碼,咱們須要在使用層面先有一個清晰的理解和認知。否則盲目閱讀源碼,便極易成爲一隻「無頭蒼蠅」。

一個eventEmitter實例,自身包含有四個屬性:

  • _events:
    這是一個object,其實至關於一個哈希map。他用來保存一個eventEmitter實例中全部的註冊事件和事件所對應的處理函數。以鍵值對方式存儲,key爲事件名;value分爲兩種狀況,噹噹前註冊事件只有一個註冊的監聽函數時,value爲這個監聽函數;若是此事件有多個註冊的監聽函數時,value值爲一個數組,數組每一項順序存儲了對應此事件的註冊函數。
    須要說明的是,理解value值的這兩種狀況,對於後面的源碼分析很是重要。我認爲nodeJS之因此有這樣的設計,是出於性能上的考慮。由於不少狀況(單一監聽函數狀況)並不須要在內存上新建一個額外數組。

  • _eventsCount:整型,表示此eventEmitter實例中註冊的事件個數。

  • _maxListeners:整型,表示此eventEmitter實例中,一個事件最多所能承載的監聽函數個數。

  • domain:在node v0.8+版本的時候,發佈了一個模塊:domain。這個模塊作的是捕捉異步回調中出現的異常。這裏與主題無關,不作展開。

一樣,eventEmitter實例的構造函數原型上,包含了一些更爲重要的屬性和方法,包括但不限於:

  • addListener(event, listener):
    爲指定事件添加一個註冊函數(如下稱監聽器)到監聽器數組的尾部。他存在一個別名alias:on。
  • once(event, listener):
    爲指定事件註冊一個單次監聽器,即監聽器最多隻會觸發一次,觸發後馬上解除該監聽器。
  • removeListener(event, listener):
    移除指定事件的某個監聽器,監聽器必須是該事件已經註冊過的監聽器。
  • removeAllListeners([event]):
    移除全部事件的全部監聽器。若是指定事件,則移除指定事件的全部監聽器。
  • setMaxListeners(n):
    默認狀況下,若是你添加的監聽器超過10個就會輸出警告信息。setMaxListeners 函數用於提升監聽器的默認限制的數量。
  • listeners(event):返回指定事件的監聽器數組。
  • emit(event, [arg1], [arg2], [...]):
    按參數的順序執行每一個監聽器,若是事件有註冊監聽器返回true,不然返回false。

nodeJS設計之美

上一段其實簡要介紹了nodeJS中eventEmitter的使用方法。下面,咱們要作的就是深刻nodeJS events模塊源碼,瞭解並學習他的設計之美。

如何建立空對象?

咱們已經瞭解到,_events是要來儲存監聽事件(key)、監聽器數組(value)的map。那麼,他的初始值必定是一個空對象。直觀上,咱們能夠這樣建立一個空對象:

this._events = {};複製代碼

可是nodeJS源碼中的實現方式倒是這樣:

function EventHandlers() {};
EventHandlers.prototype = Object.create(null);
this._events = new EventHandlers();複製代碼

官方稱,這麼作的緣由是出於性能上的考慮,通過jsperf比較,在v8 v4.9版本中,後者性能有超出2倍的表現。

對此,做爲一個「吹毛求疵」有態度的程序員,我寫了一個benchmark,對一個對象進行一千次取值操做,求平均時間進行驗證:

_events = {};
_events.test='test'
for (let i = 0; i < 1000; i++) {
    window.performance.mark('test empty object start');
    console.log(_events.test);
    window.performance.mark('test empty object end');
    window.performance.measure('test empty object','test empty object start','test empty object end');
} 
let sum1 = 0
for (let k = 0; k < 1000; k++) {
    sum1 +=window.performance.getEntriesByName('test empty object')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000);

function EventHandlers() {};
EventHandlers.prototype = Object.create(null);
_events = new EventHandlers();_events.test='test';
for (let i = 0; i < 1000; i++) {
    window.performance.mark('test empty object start');
    console.log(_events.test);
    window.performance.mark('test empty object end');
    window.performance.measure('test empty object','test empty object start','test empty object end');
} 
let sum1 = 0
for (let k = 0; k < 1000; k++) {
    sum1 +=window.performance.getEntriesByName('test empty object')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000);複製代碼
  • 第一段執行時間:111.86000000001695;
  • 第二段執行時間:108.37000000001353;

多執行幾回會發現,第一段也存在時間上短於第二段執行時間的狀況。整體來看,第二段時間上更短,但兩次時間比較相近。

我本身的想法是,使用nodeJS源碼中這樣建立空對象的方式,在對對象屬性的讀取上可以節省原型鏈查找的時間。可是,若是一個屬性直接在該對象上,即hasOwnProperty()爲true,是否還有節省查找時間,性能優化的空間呢?

另外,不一樣瀏覽器引擎的處理可能也存在差異,即便是流行的V8引擎,處理機制也「深不可測」。同時,benchmark中都是對同一屬性的讀取,通常來說瀏覽器引擎對一樣的操做行爲應該會有一個「cache」機制:據我瞭解JIT(just-in-time)實時彙編,會將重複執行的"hot code"編譯爲本地機器碼,極大增長效率。因此benchmark實現的purity也有被必定程度的干擾。不過好在測試實例都是在相同環境下執行。

因此源碼中,此處性能優化上的2倍數值,我持必定的保留態度。

addListener實現

通過整理,適當刪減後的源碼點擊這裏查看,保留了個人註釋。咱們來一步一步解讀下源碼。

判斷添加的監聽器是否爲函數類型,使用了typeof進行驗證:

if (typeof listener !== 'function') {
    throw new TypeError('"listener" argument must be a function');
}複製代碼

接下來,要分爲幾種狀況。
case1:
判斷_events表是否已經存在,若是不存在,則說明是第一次爲eventEmitter實例添加事件和監聽器,須要新建立_events:

if (!events) {
    events = target._events = new EventHandlers();
    target._eventsCount = 0;
} 複製代碼

還記得EventHandlers是什麼嗎?忘記了把屏幕往上滾動再看一下吧。

同時,添加指定的事件和此事件對應的監聽器:

existing = events[type] = listener;
++target._eventsCount;複製代碼

注意第一次建立時,爲了節省內存,提升性能,events[type]值是一個監聽器函數。若是再次爲相同的events[type]添加監聽器時(下面case2),events[type]對應的值須要變成一個數組來存儲。

case2:
又囉嗦一遍:若是_events已存在,在爲相關事件添加監聽器時,須要判斷events[type]是函數類型(只存在一個監聽函數)仍是已經成爲了一個數組類型(已經存在一個以上監聽函數)。
而且根據相關參數prepend,分爲監聽器數組頭部插入和尾部插入兩種狀況,以保證監聽器的順序執行:

if (typeof existing === 'function') {
    existing = events[type] = prepend ? [listener, existing] :
                                      [existing, listener];
} 
else {
    if (prepend) {
        existing.unshift(listener);
    } 
    else {
        existing.push(listener);
    }
}複製代碼

case3:
在閱讀源碼時,我還發現了一個很「詭異」的邏輯:

if (events.newListener) {
    target.emit('newListener', type,
              listener.listener ? listener.listener : listener);
    events = target._events;
}
existing = events[type];複製代碼

仔細分析,他的目的是由於nodeJS默認:當全部的eventEmitter對象在添加新的監聽函數時,都會發出newListener事件。這其實也並不奇怪,我我的認爲這麼設計仍是很是合理的。

cae4:
以前介紹了咱們能夠設置一個事件對應的最大監聽器個數,nodeJS源碼中經過這樣的代碼來實現:

EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
    if (typeof n !== 'number' || n < 0 || isNaN(n)) {
        throw new TypeError('"n" argument must be a positive number');
    }
    this._maxListeners = n;
    return this;
};複製代碼

當對這個值進行了設置以後,若是超過此閾值,將會進行報警:

if (!existing.warned) {
    m = $getMaxListeners(target);
    if (m && m > 0 && existing.length > m) {
        existing.warned = true;
        const w = new Error('Possible EventEmitter memory leak detected. ' +
                            `${existing.length} ${String(type)} listeners ` +
                            'added. Use emitter.setMaxListeners() to ' +
                            'increase limit');
        w.name = 'MaxListenersExceededWarning';
        w.emitter = target;
        w.type = type;
        w.count = existing.length;
        process.emitWarning(w);
    }
}複製代碼

emit發射器實現

有了以前的註冊監聽器過程,那麼咱們再來看看監聽器是如何被觸發的。其實觸發過程直觀上並不難理解,核心思想就是將監聽器數組中的每一項,即監聽函數逐個執行就行了。

通過整理,適當刪減後的源碼一樣能夠這裏找到。源碼中,包含了較多的錯誤信息處理內容,忽略不表。下面我挑出一些「出神入化」的細節來分析。

首先,有了上面的分析,咱們如今能夠清晰的意識到某個事件的監聽處理多是一個函數類型,表示該事件只有一個事件處理程序;也多是個數組,表示該事件有多個事件處理程序,存儲在監聽器數組中。(我又囉嗦了一遍,由於理解這個過重要了,否則你會看暈的)

同時,emit方法能夠接受多個參數。第一個參數爲事件類型:type,下面兩行代碼用於獲取某個事件的監聽處理類型。用isFn布爾值來表示。

handler = events[type];
var isFn = typeof handler === 'function';複製代碼

isFn爲true,表示該事件只有一個監聽函數。不然,存在多個,儲存在數組中。

源碼中對於emit參數個數有判斷,並進行了switch分支處理:

switch (len) {
    case 1:
        emitNone(handler, isFn, this);
        break;
    case 2:
        emitOne(handler, isFn, this, arguments[1]);
        break;
    case 3:
        emitTwo(handler, isFn, this, arguments[1], arguments[2]);
        break;
    case 4:
        emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]);
        break;
    // slower
    default:
        args = new Array(len - 1);
        for (i = 1; i < len; i++) {
            args[i - 1] = arguments[i];
        }
        emitMany(handler, isFn, this, args);
}複製代碼

咱們挑一個相對最複雜的看一下——默認模式調用的emitMany:

function emitMany(handler, isFn, self, args) {
    if (isFn) {
        handler.apply(self, args);
    }
    else {
        var len = handler.length;
        var listeners = arrayClone(handler, len);
        for (var i = 0; i < len; ++i) {
            listeners[i].apply(self, args);
        }
    }
}複製代碼

對於只有一個事件處理程序的狀況(isFn爲true),直接執行:

handler.apply(self, args);複製代碼

不然,便使用for循環,逐個調用:

listeners[i].apply(self, args);複製代碼

很是有意思的一個細節在於:

var listeners = arrayClone(handler, len);複製代碼

這裏須要讀者細心體會。

源碼讀到這裏,我不由要感嘆設計的嚴謹精妙之處。上面代碼處理的意義在於:防止在一個事件監聽器中監聽同一個事件,從而致使死循環的出現。
若是您不理解,且看我這個例子:

let emitter = new eventEmitter;
emitter.on('message1', function test () {
    // some codes here
    // ...
    emitter.on('message1', test}
});
emit('message1');複製代碼

講道理,正常來說,不通過任何處理,上述代碼在事件處理程序內部又添加了對於同一個事件的監聽,這必然會帶來死循環問題。
由於在emit執行處理程序的時候,咱們又向監聽器隊列添加了一項。這一項執行時,又會「子子孫孫無窮匱也」的向監聽器數組尾部添加。

源碼中對於這個問題的解決方案是:在執行emit方法時,使用arrayClone方法拷貝出另外一個如出一轍的數組,進而執行它。這樣一來,當咱們在監聽器內監聽同一個事件時,的確給原監聽器數組添加了新的監聽函數,但並無影響到當前這個被拷貝出來的副本數組。在循環中,咱們執行的也是這個副本函數。

單次監聽器once實現

once(event, listener)是爲指定事件註冊一個單次事件處理程序,即監聽器最多隻會觸發一次,觸發後馬上解除該監聽器。

實現方式主要是在進行監聽器綁定時,對於監聽函數進行一層包裝。該包裝方式在原有函數上添加一個flag標識位,並在觸發監聽函數前就調用removeListener()方法,除掉此監聽函數。我理解,這是一種「雙保險」的體現。

代碼裏,咱們能夠抽絲剝繭(已進行刪減)學習一下:

EventEmitter.prototype.once = function once(type, listener) {
    this.on(type, _onceWrap(this, type, listener));
    return this;
};複製代碼

once方法調用on方法(即addListener方法,on爲別名),第二個參數即監聽程序進行_onceWrap化包裝,包裝過程爲:

this.target.removeListener(this.type, this.wrapFn);
if (!this.fired) {
    this.fired = true;
    this.listener.apply(this.target, arguments);
}複製代碼

_onceWrap化的主要思想是將once第二個參數listener的執行,包上了一次判斷,並在執行前進行removeListener刪除該監聽程序。:

this.listener.apply(this.target, arguments);複製代碼

removeListener的驚鴻一瞥

removeListener(type, listener)移除指定事件的某個監聽器。其實這個實現思路也比較容易理解,咱們已經知道events[type]多是函數類型,也多是數組類型。若是是數組類型,只須要進行遍歷,找到相關的監聽器進行刪除就能夠了。

不過關鍵問題就在於對數組項的刪除。

平時開發,咱們經常使用splice進行數組中某一項的刪除,99%的case都會想到這個方法。但是nodeJS相關源碼中,對於刪除進行了優化。本身封裝了一個spliceOne方法,用於刪除數組中指定角標。而且號稱這個方法比使用splice要快1.5倍。咱們就來看一下他是如何實現的:

function spliceOne(list, index) {
    for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) {
        list[i] = list[k];
    }
    list.pop();
}複製代碼

傳統刪除方法:

list.splice(index, 1);複製代碼

到底是否計算更快,我也實現了一個benchmark,產生長度爲1000的數組,刪除其第52項。反覆執行1000次求平均耗時:

let arr = Array.from(Array(100).keys());
for (let i = 0; i < 1000; i++) {
    window.performance.mark('test splice start');
    arr.splice(52, 1);
    window.performance.mark('test splice end');
    window.performance.measure('test splice','test splice start','test splice end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
    sum1 +=window.performance.getEntriesByName('test splice')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000); // 1.7749999999869034


let arr = Array.from(Array(100).keys());
for (let i = 0; i < 1000; i++) {
    window.performance.mark('test splice start');
    spliceOne(arr, 52);
    window.performance.mark('test splice end');
    window.performance.measure('test splice','test splice start','test splice end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
    sum1 +=window.performance.getEntriesByName('test splice')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000); // 1.5350000000089494複製代碼
  • 第一段執行時間:1.7749999999869034;
  • 第二段執行時間:1.5350000000089494;

明顯使用spliceOne方法更快,時間上縮短了13.5%,不過依然沒有達到官方的1.5,須要說明的是我採用最新版本的Chrome進行測試。

本身造輪子

前文咱們感覺了nodeJS中的eventEmitter實現方式。我也對於其中的核心方法,在源碼層面進行了剖析。學習到了「精華」以後,更重要的要學以至用,本身實現一個基於ES6的事件發佈訂閱系統。

個人實現版本中充分利用了ES6語法特性,而且相對於nodeJS實現減小了一些「沒必要要的」優化和判斷。

由於nodeJS的實現中,不少api在前端瀏覽器環境開發中並用不到。因此我對對外暴露的方法進行了精簡。最終實現上,除去註釋部分,只用了不到40行代碼。若是您有興趣,能夠去代碼倉庫訪問,整個邏輯仍是很簡單的。

裏面同時附贈了我同事@顏海鏡大神基於zepto實現版本,以及nodeJS events模塊源碼,方便讀者進行對比。
整個過程編寫時間倉促,其中必然不乏疏漏之處,還請您斧正並與我討論。

總結

對於nodeJS源碼events模塊的閱讀,令我受益不淺。設計層面上,優秀的包裝和抽象思路對我必定的啓發;實現層面上,不少「意想不到」的case處理,讓我「歎爲觀止」。

雖然業務上暫時使用不到nodeJS,可是對於每個前端開發人員來講,這樣的學習我認爲是有必要的。從此,我會整理出文章,總結對nodeJS源碼更多模塊的分析,但願同讀者可以保持交流和探討。

整篇文章裏面列出的benchmark,我認爲並不完美。同時,對於瀏覽器引擎處理上,我存在知識盲點和漏洞,但願有大神給與斧正。

PS:百度知識搜索部大前端繼續招兵買馬,有意向者火速聯繫。。。

相關文章
相關標籤/搜索