如果面試官讓你講講發布訂閱設計模式?
本文轉載自微信公眾號「DYBOY」,作者DYBOY。轉載本文請聯系DYBOY公眾號。
發布訂閱設計模式在程序中經常涉及,例如 Vue 中的 $on 和 $off、document.addEventListener()、document.removeEventListener()等,發布訂閱模式可以降低程序的耦合度,統一管理維護消息、處理事件也使得程序更容易維護和擴展。
有小伙伴問,該如何學習設計模式,設計模式本身是一些問題場景的抽象解決方案,死記硬背肯定不行,無異于搭建空中樓閣,所以得結合實際,從解決問題角度去思考、舉一反三,如此便能更輕松掌握知識點。
最近在程序中使用到了 eventEmitter3 這個事件發布訂閱庫,該庫可用于組件之間的通信管理,通過簡單的 Readme 文檔可學會如何使用,但同時了解這個庫的設計也有助于大家了解認識發布訂閱設計模式,不妨一起來看看。
一、定義
在軟件架構中,發布訂閱是一種消息范式,消息的發送者(稱為發布者)不會將消息直接發送給特定的接收者(稱為訂閱者),而是將發布的消息分為不同的類別,無需了解哪些訂閱者(如果有的話)可能存在。同樣的,訂閱者可以表達對一個或多個類別的興趣,只接收感興趣的消息,無需了解哪些發布者(如果有的話)存在。
類比一個很好理解的例子,例如微信公眾號,你關注(理解為訂閱)了“DYBOY”公眾號,當該公眾號發布了新文章,微信就會通知你,而不會通知其他為訂閱公眾號的人,另外你還可以訂閱多個公眾號。
放到程序的組件中,多個組件的通信除了父子組件傳值外,還有例如 redux、vuex 狀態管理,另外就是本文所說的發布訂閱模式,可以通過一個事件中心來實現。
發布訂閱模式
二、手搓一個發布訂閱事件中心
“紙上得來終覺淺,絕知此事要躬行”,所以根據定義,我們嘗試實現一個JavaScript版本的發布訂閱事件中心,看看會遇到哪些問題?
2.1 基本結構版
首先實現的 DiyEventEmitter 如下:
- /**
- * 事件發布訂閱中心
- */
- class DiyEventEmitter {
- static instance: DiyEventEmitter;
- private _eventsMap: Map<string, Array<() => void>>;
- static getInstance() {
- if (!DiyEventEmitter.instance) {
- DiyEventEmitter.instance = new DiyEventEmitter();
- }
- return DiyEventEmitter.instance;
- }
- constructor() {
- this._eventsMap = new Map(); // 事件名與回調函數的映射Map
- }
- /**
- * 事件訂閱
- *
- * @param eventName 事件名
- * @param eventFnCallback 事件發生時的回調函數
- */
- public on(eventName: string, eventFnCallback: () => void) {
- const newArr = this._eventsMap.get(eventName) || [];
- newArr.push(eventFnCallback);
- this._eventsMap.set(eventName, newArr);
- }
- /**
- * 取消訂閱
- *
- * @param eventName 事件名
- * @param eventFnCallback 事件發生時的回調函數
- */
- public off(eventName: string, eventFnCallback?: () => void) {
- if (!eventFnCallback) {
- this._eventsMap.delete(eventName);
- return;
- }
- const newArr = this._eventsMap.get(eventName) || [];
- for (let i = newArr.length - 1; i >= 0; i--) {
- if (newArr[i] === eventFnCallback) {
- newArr.splice(i, 1);
- }
- }
- this._eventsMap.set(eventName, newArr);
- }
- /**
- * 主動通知并執行注冊的回調函數
- *
- * @param eventName 事件名
- */
- public emit(eventName: string) {
- const fns = this._eventsMap.get(eventName) || [];
- fns.forEach(fn => fn());
- }
- }
- export default DiyEventEmitter.getInstance();
導出的 DiyEventEmitter 是一個“單例”,保證在全局中只有唯一“事件中心”實例,使用時候直接可使用公共方法
- import e from "./DiyEventEmitter";
- const subscribeFn = () => {
- console.log("DYBOY訂閱收到了消息");
- };
- const subscribeFn2 = () => {
- console.log("DYBOY第二個訂閱收到了消息");
- };
- // 訂閱
- e.on("dyboy", subscribeFn);
- e.on("dyboy", subscribeFn2);
- // 發布消息
- e.emit("dyboy");
- // 取消第一個訂閱消息的綁定
- e.off("dyboy", subscribeFn);
- // 第二次發布消息
- e.emit("dyboy");
輸出 console 結果:
- DYBOY訂閱收到了消息
- 第二個訂閱的消息
- 第二個訂閱的消息
那么第一版的支持訂閱、發布、取消的“發布訂閱事件中心”就OK了。
2.2 支持只訂閱一次once方法
在一些場景下,某些事件訂閱可能只需要執行一次,后續的通知將不再響應。
實現的思路:新增 once 訂閱方法,當響應了對應“發布者消息”,則主動取消訂閱當前執行的回調函數。
為此新增類型,如此便于回調函數的描述信息擴展:
- type SingleEvent = {
- fn: () => void;
- once: boolean;
- };
_eventsMap的類型更改為:
- private _eventsMap: Map<string, Array<SingleEvent>>;
同時抽出公共方法 addListener,供 on 和 once 方法共用:
- private addListener( eventName: string, eventFnCallback: () => void, once = false) {
- const newArr = this._eventsMap.get(eventName) || [];
- newArr.push({
- fn: eventFnCallback,
- once,
- });
- this._eventsMap.set(eventName, newArr);
- }
- /**
- * 事件訂閱
- *
- * @param eventName 事件名
- * @param eventFnCallback 事件發生時的回調函數
- */
- public on(eventName: string, eventFnCallback: () => void) {
- this.addListener(eventName, eventFnCallback);
- }
- /**
- * 事件訂閱一次
- *
- * @param eventName 事件名
- * @param eventFnCallback 事件發生時的回調函數
- */
- public once(eventName: string, eventFnCallback: () => void) {
- this.addListener(eventName, eventFnCallback, true);
- }
與此同時,我們需要考慮在觸發事件時候,執行一次就需要取消訂閱
- /**
- * 觸發:主動通知并執行注冊的回調函數
- *
- * @param eventName 事件名
- */
- public emit(eventName: string) {
- const fns = this._eventsMap.get(eventName) || [];
- fns.forEach((evt, index) => {
- evt.fn();
- if (evt.once) fns.splice(index, 1);
- });
- this._eventsMap.set(eventName, fns);
- }
另外取消訂閱中函數中比較需要替換對象屬性比較:newArr[i].fn === eventFnCallback
這樣我們的事件中心支持 once 方法改造就完成了。
2.3 緩存發布消息
在框架開發下,通常會使用異步按需加載組件,如果發布者組件先發布了消息,但是異步組件還未加載完成(完成訂閱注冊),那么發布者的這條發布消息就不會被響應。因此,我們需要把消息做一個緩存隊列,直到有訂閱者訂閱了,并只響應一次緩存的發布消息,該消息就會從緩存出隊。
首先梳理下緩存消息的邏輯流程:
UML時序圖
發布者發布消息,事件中心檢測是否存在訂閱者,如果沒有訂閱者訂閱此條消息,則把該消息緩存到離線消息隊列中,當有訂閱者訂閱時,檢測是否訂閱了緩存中的事件消息,如果是,則該事件的緩存消息依次出隊(FCFS調度執行),觸發訂閱者回調函數執行一次。
新增離線消息緩存隊列:
- private _offlineMessageQueue: Map<string, number>;
在emit發布消息中判斷對應事件是否有訂閱者,沒有訂閱者則向離線事件消息中更新
- /**
- * 觸發:主動通知并執行注冊的回調函數
- *
- * @param eventName 事件名
- */
- public emit(eventName: string) {
- const fns = this._eventsMap.get(eventName) || [];
- + if (fns.length === 0) {
- + const counter = this._offlineMessageQueue.get(eventName) || 0;
- + this._offlineMessageQueue.set(eventName, counter + 1);
- + return;
- + }
- fns.forEach((evt, index) => {
- evt.fn();
- if (evt.once) fns.splice(index, 1);
- });
- this._eventsMap.set(eventName, fns);
- }
然后在 addListener 方法中根據離線事件消息統計的次數,重新emit發布事件消息,觸發消息回調函數執行,之后刪掉離線消息中的對應事件。
- private addListener(
- eventName: string,
- eventFnCallback: () => void,
- once = false
- ) {
- const newArr = this._eventsMap.get(eventName) || [];
- newArr.push({
- fn: eventFnCallback,
- once,
- });
- this._eventsMap.set(eventName, newArr);
- + const cacheMessageCounter = this._offlineMessageQueue.get(eventName);
- + if (cacheMessageCounter) {
- + for (let i = 0; i < cacheMessageCounter; i++) {
- + this.emit(eventName);
- + }
- + this._offlineMessageQueue.delete(eventName);
- + }
- }
這樣,一個支持離線消息的事件中心就寫好了!
2.4 回調函數傳參&執行環境
在上面的回調函數中,我們可以發現是一個沒有返回值,沒有入參的函數,這其實有些雞肋,在函數運行的時候會指向執行的上下文,可能某些回調函數中含有this指向就無法綁定到事件中心上,因此針對回調函數需要綁定執行上下文環境。
2.4.1 支持回調函數傳參
首先將TypeScript中的函數類型fn: () => void 改為 fn: Function,這樣能夠通過函數任意參數長度的TS校驗。
其實在事件中心里回調函數是沒有參數的,如有參數也是提前通過參數綁定(bind)方式傳入。
另外如果真要支持回調函數傳參,那么就需要在 emit() 的時候傳入參數,然后再將參數傳遞給回調函數,這里我們暫時先不實現了。
2.4.2 執行環境綁定
在需要實現執行環境綁定這個功能前,先思考一個問題:“是應該開發者自行綁定還是應該事件中心來做?”
換句話說,開發者在 on('eventName', 回調函數) 的時候,是否應該主動綁定 this 指向?在當前設計下,初步認為無參數的回調函數自行綁定 this 比較合適。
因此,在事件中心這暫時不需要去做綁定參數的行為,如果回調函數內有需要傳參、綁定執行上下文的,需要在綁定回調函數的時候自行 bind。這樣,我們的事件中心也算是保證了功能的純凈性。
到這里我們自己手搓簡單的發布訂閱事件中心就完成了!
三、學習EventEmitter3的設計實現
雖然我們按照自己的理解實現了一版,但是沒有對比我們也不知道好壞,因此一起看看 EventEmitter3 這個優秀“極致性能優化”的庫是怎么去處理事件訂閱與發布,同時可以學習下其中的性能優化思路。
首先,EventEmitter3(后續簡稱:EE3)的實現思路,用Events對象作為“回調事件對象”的存儲器,類比我們上述實現的“發布訂閱模式”作為事件的執行邏輯,另外addListener() 函數增加了傳入執行上下文環境參數,emit() 函數支持最多傳入5個參數,同時EventEmitter3中還加入了監聽器計數、事件名前綴。
3.1 Events存儲器
避免轉譯,以及為了提升兼容性和性能,EventEmitter3用ES5來編寫。
在JavaScript中萬物是對象,函數也是對象,因此存儲器的實現:
- function Events() {}
3.2 事件偵聽器實例
同理,我們上述使用singleEvent對象來存儲每一個事件偵聽器實例,EE3 中用一個EE對象存儲每個事件偵聽器的實例以及必要屬性
- /**
- * 每個事件偵聽器實例的表示形式
- *
- * @param {Function} fn 偵聽器函數
- * @param {*} context 調用偵聽器的執行上下文
- * @param {Boolean} [once=false] 指定偵聽器是否僅支持調用一次
- * @constructor
- * @private
- */
- function EE(fn, context, once) {
- this.fn = fn;
- this.context = context;
- this.once = once || false;
- }
3.3 添加偵聽器方法
- /**
- * 為給定事件添加偵聽器
- *
- * @param {EventEmitter} emitter EventEmitter實例的引用.
- * @param {(String|Symbol)} event 事件名.
- * @param {Function} fn 偵聽器函數.
- * @param {*} context 調用偵聽器的上下文.
- * @param {Boolean} once 指定偵聽器是否僅支持調用一次.
- * @returns {EventEmitter}
- * @private
- */
- function addListener(emitter, event, fn, context, once) {
- if (typeof fn !== 'function') {
- throw new TypeError('The listener must be a function');
- }
- var listener = new EE(fn, context || emitter, once)
- , evt = prefix ? prefix + event : event;
- // TODO: 這里為什么先是使用對象,多個的時候使用對象數組存儲,有什么好處?
- if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++;
- else if (!emitter._events[evt].fn) emitter._events[evt].push(listener);
- else emitter._events[evt] = [emitter._events[evt], listener];
- return emitter;
- }
該“添加偵聽器”的方法有幾個關鍵功能點:
如果有前綴,給事件名增加前綴,避免事件沖突
每次新增事件名則 _eventsCount+1,用于快速讀寫所有事件的數量
如果事件只有單個偵聽器,則 _events[evt] 指向這個 EE 對象,訪問效率更高
3.4 清除事件
- /**
- * 通過事件名清除事件
- *
- * @param {EventEmitter} emitter EventEmitter實例的引用
- * @param {(String|Symbol)} evt 事件名
- * @private
- */
- function clearEvent(emitter, evt) {
- if (--emitter._eventsCount === 0) emitter._events = new Events();
- else delete emitter._events[evt];
- }
清除事件,只需要使用 delete 關鍵字,刪除對象上的屬性
另外這里一個很巧妙的地方在于,依賴事件計數器,如果計數器為0,則重新創建一個 Events 存儲器指向 emitter 的 _events 屬性。
這樣做的優點是,假如需要清空所有事件,只需要將 emitter._eventsCount 的值賦值為1,然后調用 clearEvent() 方法就可以了,而不必遍歷清除事件
3.5 EventEmitter
- function EventEmitter() {
- this._events = new Events();
- this._eventsCount = 0;
- }
EventEmitter 對象參考 NodeJS 中的事件觸發器,定義了最小的接口模型,包含 _events 和 _eventsCount屬性,另外的方法都通過原型來增加。
EventEmitter 對象等同于上述我們的事件中心的定義,其功能梳理如下:
EventEmitter
其中有必要講的就是 emit() 方法,而訂閱者注冊事件的on() 和 once() 方法,都是使用的 addListener() 工具函數。
emit() 方法實現如下:
- /**
- * 調用執行指定事件名的每一個偵聽器
- *
- * @param {(String|Symbol)} event 事件名.
- * @returns {Boolean} `true` 如果當前事件名沒綁定偵聽器,則返回false.
- * @public
- */
- EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) {
- var evt = prefix ? prefix + event : event;
- if (!this._events[evt]) return false;
- var listeners = this._events[evt]
- , len = arguments.length
- , args
- , i;
- // 如果只有一個偵聽器綁定了該事件名
- if (listeners.fn) {
- // 如果是執行一次的,則移除偵聽器
- if (listeners.once) this.removeListener(event, listeners.fn, undefined, true);
- // Refrence:https://juejin.cn/post/6844903496450310157
- // 這里的處理是從性能上考慮,傳入5個入參數的調用call方法處理
- // 超過5個參數的使用apply處理
- // 大部分場景超過5個參數的都是少數
- switch (len) {
- case 1: return listeners.fn.call(listeners.context), true;
- case 2: return listeners.fn.call(listeners.context, a1), true;
- case 3: return listeners.fn.call(listeners.context, a1, a2), true;
- case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true;
- case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true;
- case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true;
- }
- for (i = 1, args = new Array(len -1); i < len; i++) {
- args[i - 1] = arguments[i];
- }
- listeners.fn.apply(listeners.context, args);
- } else {
- // 當有多個偵聽器綁定了同一個事件名
- var length = listeners.length
- , j;
- // 循環執行每一個綁定的事件偵聽器
- for (i = 0; i < length; i++) {
- if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true);
- switch (len) {
- case 1: listeners[i].fn.call(listeners[i].context); break;
- case 2: listeners[i].fn.call(listeners[i].context, a1); break;
- case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break;
- case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break;
- default:
- if (!args) for (j = 1, args = new Array(len -1); j < len; j++) {
- args[j - 1] = arguments[j];
- }
- listeners[i].fn.apply(listeners[i].context, args);
- }
- }
- }
- return true;
- };
在 emit() 方法中顯示的傳入了五個入參:a1 ~ a5,同時優先使用 call() 方法綁定 this 指向并執行偵聽器的回調函數。
這樣處理的原因是,call 方法比 apply 方法效率更高,相關比較驗證討論可參考《call和apply的性能對比》
到這基本上 EventEmitter3 的實現就啃完了!
四、總結
EventEmitter3 是一個號稱優化到極致的事件發布訂閱的工具庫,通過梳理可知曉:
- call 與 apply 在效率上的差異
- 對象和對象數組的存取性能考慮
- 理解發布訂閱模式,以及在事件系統中的應用實例