EventEmitter 的核心功能實現
大家好,我是前端西瓜哥。
EventEmitter 是頻率較高的前端面試題。
EventEmitter 是 Nodejs 環境下才能使用的庫,所以不能直接用于瀏覽器環境的開發。所以我考慮自己實現一套邏輯,自己定制的話也容易根據實際情況的變動做修改。
因此我決定了解一下 EventEmitter 的 API,并嘗試自己實現一套邏輯。
Nodejs 的 EventEmitter API
首先當然是要了解需求,即 EventEmitter 的 API 使用。詳細使用方式請查閱 官方文檔,我這里只簡單敘述一些常用的 API。
const { EventEmitter, errorMonitor } = require('events');
// 創建事件觸發器實例
const emitter = new EventEmitter()
// on:注冊監聽者函數。可以注冊多個監聽函數,
// 觸發事件后,會依次同步執行,順序為綁定時的順序。
// 別名為:addListener
emitter.on('event', function(a, b) => {
console.log('event emit!', a, b)
})
// once:注冊一個只會被執行一次的函數
emitter.once('event', function() => {
console.log('event emit only once!')
})
// emit:觸發事件,可提供參數。
// 如果有對應監聽器函數,會返回 true,否則返回 false
emitter.emit('event', 3, 4)
// 比較特別的是,如果沒有注冊 error 事件的監聽者,
// 觸發 error 時,錯誤不會被捕獲而直接報錯;
// 若注冊,則錯誤會被捕獲
emitter.emit('error', new Error('whoops!'))
// event.errorMonitor 是一個 Symbol
// 能夠在觸發 error 事件時,先執行被綁定的監視器函數
emitter.on(errorMonitor, err => {
console.log('error monitor')
});
// 移除指定監聽器,別名為:removeListener
emitter.off(eventName, handler)
// 獲取注冊的事件名的數組形式
emitter.eventNames()
- 監聽者函數的 this 會指向 EventEmitter 實例。當然你可以使用各種方法修改 this 的指向,如箭頭函數或 bind 方法。
- 每次添加監聽器時,都會觸發 newListener 事件,傳入的參數為事件名(eventName)和監聽器函數(listener)。
- 同樣,移除監聽器時,會觸發 removeListener 事件。
- emitter.prependListener():同 on,但會添加到監聽器數組的開頭。
- ...
API 很多,但我不打算實現了這么多,就只實現最常用的 on、emit、off。
實現
首先,我們知道不同的事件是有特定的 eventName(事件名)的,通過指定 eventName,我們才能綁定對應的多個監聽器(函數),才能觸發事件執行綁定的這些監聽器。
這時候,我們就涉及到數據結構與算法的存儲問題了。因為結構和算法是相輔相成的,選擇不同的數據結構,使用的算法就會不同。
不同的數據結構與算法的優點的缺陷各不相同,比如空間復雜度上或時間復雜度上的效率不同。
listener 函數的存儲
那么如何存儲呢?常見的方法是使用哈希表,因為時間復雜度是 O(1),空間復雜度一般也不會太大。JavaScript 的對象本質上就是哈希表。所以我們的存儲方式是:
this.hashMap = {
'event1': [listener1, listenr2],
'event2': [],
}
一些可擴展的點:
- 哈希表的一個問題是:無序。可以通過額外使用一個數組來記錄添加 eventName 的記錄順序。這樣的話,實現 emitter.eventNames() 可以拿到有序的事件數據。當然這樣的需求比較少見,這里只是簡單提一下。
- 如果要實現 once(設置執行一次就不再執行的監聽器函數),則需要對函數標記,這時候可以考慮讓數組元素的格式改為 { listener: Listener, once: boolean },在觸發事件的時候,執行監聽器函數時,將 once 值為 true 的監聽器從數組中移除。
- 可以改為鏈表實現存儲,這樣移除中間監聽器時,時間復雜度可以變成 O(1)。另外數組刪除元素的時間復雜度是 O(n)。但會引入實現上的復雜度,因為沒有內置的鏈表實現,需要自己手動實現一個沒有 BUG 的鏈表類。
on() 的實現
on() 的實現,其實就是將監聽器函數綁定到指定事件對應的數組中。實現起來并不難,只要注意如果是第一次添加指定事件時,要先初始化一個空數組即可。on 最后返回了 this,是為了實現鏈式調用。
class EventEmiter {
on(eventName, listener) {
if (!this.hashMap[eventName]) {
this.hashMap[eventName] = []
}
this.hashMap[eventName].push(listener)
return this
}
}
off() 的實現
off() 會根據傳入的事件名,找到對應的監聽器數組,從中移除指定監聽器。同樣為了實現鏈式調用返回了 this。
class EventEmiter {
off(eventName, listener) {
const listeners = this.hashMap[eventName]
if (listeners && listeners.length > 0) {
const index = listeners.indexOf(listener)
if (index > -1) {
listeners.splice(index, 1)
}
}
return this
}
}
emit() 的實現
emit() 的實現很簡單,找到事件對應的監聽器,傳入參數依次執行。如果事件沒有綁定監聽器,返回 false。否則,返回 true。
class EventEmiter {
emit(eventName, ...args) {
const listeners = this.hashMap[eventName]
if (!listeners || listeners.length === 0) return false
listeners.forEach(listener => {
listener(...args)
})
return true
}
}
完整實現
雖然很突然,我這里給出的是 TypeScript 實現,只要將類型聲明去掉就是 JavaScript 實現了。當然下面代碼是做了簡單的單元測試的,大概是沒問題的。
源碼地址:
type EventName = string | symbol
type Listener = (...args: any[]) => void
class EventEmiter {
private hashMap: { [eventName: string]: Array<Listener> } = {}
on(eventName: EventName, listener: Listener): this {
const name = eventName as string
if (!this.hashMap[name]) {
this.hashMap[name] = []
}
this.hashMap[name].push(listener)
return this
}
emit(eventName: EventName, ...args: any[]): boolean {
const listeners = this.hashMap[eventName as string]
if (!listeners || listeners.length === 0) return false
listeners.forEach(listener => {
listener(...args)
})
return true
}
off(eventName: EventName, listener: Listener): this {
const listeners = this.hashMap[eventName as string]
if (listeners && listeners.length > 0) {
const index = listeners.indexOf(listener)
if (index > -1) {
listeners.splice(index, 1)
}
}
return this
}
}
因為對象不支持 Symbol 作為索引,所以這里的實現做了類型的強轉。未來,TypeScript 可能會允許對象索引為 Symbol,Enum 等,但目前不行。