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