發(fā)布訂閱設(shè)計(jì)模式
發(fā)布訂閱設(shè)計(jì)模式在程序中經(jīng)常涉及,例如 Vue 中的
$on和$off、document.addEventListener()、document.removeEventListener()等,發(fā)布訂閱模式可以降低程序的耦合度,統(tǒng)一管理維護(hù)消息、處理事件也使得程序更容易維護(hù)和擴(kuò)展。

有小伙伴問,該如何學(xué)習(xí)設(shè)計(jì)模式,設(shè)計(jì)模式本身是一些問題場(chǎng)景的抽象解決方案,死記硬背肯定不行,無異于搭建空中樓閣,所以得結(jié)合實(shí)際,從解決問題角度去思考、舉一反三,如此便能更輕松掌握知識(shí)點(diǎn)。
最近在程序中使用到了 eventEmitter3 這個(gè)事件發(fā)布訂閱庫,該庫可用于組件之間的通信管理,通過簡(jiǎn)單的 Readme 文檔可學(xué)會(huì)如何使用,但同時(shí)了解這個(gè)庫的設(shè)計(jì)也有助于大家了解認(rèn)識(shí)發(fā)布訂閱設(shè)計(jì)模式,不妨一起來看看。
一、定義
在軟件架構(gòu)中,發(fā)布訂閱是一種消息范式,消息的發(fā)送者(稱為發(fā)布者)不會(huì)將消息直接發(fā)送給特定的接收者(稱為訂閱者),而是將發(fā)布的消息分為不同的類別,無需了解哪些訂閱者(如果有的話)可能存在。同樣的,訂閱者可以表達(dá)對(duì)一個(gè)或多個(gè)類別的興趣,只接收感興趣的消息,無需了解哪些發(fā)布者(如果有的話)存在。
類比一個(gè)很好理解的例子,例如微信公眾號(hào),你關(guān)注(理解為訂閱)了“DYBOY”公眾號(hào),當(dāng)該公眾號(hào)發(fā)布了新文章,微信就會(huì)通知你,而不會(huì)通知其他為訂閱公眾號(hào)的人,另外你還可以訂閱多個(gè)公眾號(hào)。
放到程序的組件中,多個(gè)組件的通信除了父子組件傳值外,還有例如 redux、vuex 狀態(tài)管理,另外就是本文所說的發(fā)布訂閱模式,可以通過一個(gè)事件中心來實(shí)現(xiàn)。

二、手搓一個(gè)發(fā)布訂閱事件中心
“紙上得來終覺淺,絕知此事要躬行”,所以根據(jù)定義,我們嘗試實(shí)現(xiàn)一個(gè)JavaScript版本的發(fā)布訂閱事件中心,看看會(huì)遇到哪些問題?
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ā)生時(shí)的回調(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ā)生時(shí)的回調(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);
}
/**
* 主動(dòng)通知并執(zhí)行注冊(cè)的回調(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 是一個(gè)“單例”,保證在全局中只有唯一“事件中心”實(shí)例,使用時(shí)候直接可使用公共方法
import e from "./DiyEventEmitter";
const subscribeFn = () => {
console.log("DYBOY訂閱收到了消息");
};
const subscribeFn2 = () => {
console.log("DYBOY第二個(gè)訂閱收到了消息");
};
// 訂閱
e.on("dyboy", subscribeFn);
e.on("dyboy", subscribeFn2);
// 發(fā)布消息
e.emit("dyboy");
// 取消第一個(gè)訂閱消息的綁定
e.off("dyboy", subscribeFn);
// 第二次發(fā)布消息
e.emit("dyboy");
輸出 console 結(jié)果:
DYBOY訂閱收到了消息
第二個(gè)訂閱的消息
第二個(gè)訂閱的消息
那么第一版的支持訂閱、發(fā)布、取消的“發(fā)布訂閱事件中心”就OK了。
2.2 支持只訂閱一次once方法
在一些場(chǎng)景下,某些事件訂閱可能只需要執(zhí)行一次,后續(xù)的通知將不再響應(yīng)。
實(shí)現(xiàn)的思路:新增 once 訂閱方法,當(dāng)響應(yīng)了對(duì)應(yīng)“發(fā)布者消息”,則主動(dòng)取消訂閱當(dāng)前執(zhí)行的回調(diào)函數(shù)。
為此新增類型,如此便于回調(diào)函數(shù)的描述信息擴(kuò)展:
type SingleEvent = {
fn: () => void;
once: boolean;
};
_eventsMap的類型更改為:
private _eventsMap: Map<string, Array<SingleEvent>>;
同時(shí)抽出公共方法 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ā)生時(shí)的回調(diào)函數(shù)
*/
public on(eventName: string, eventFnCallback: () => void) {
this.addListener(eventName, eventFnCallback);
}
/**
* 事件訂閱一次
*
* @param eventName 事件名
* @param eventFnCallback 事件發(fā)生時(shí)的回調(diào)函數(shù)
*/
public once(eventName: string, eventFnCallback: () => void) {
this.addListener(eventName, eventFnCallback, true);
}
與此同時(shí),我們需要考慮在觸發(fā)事件時(shí)候,執(zhí)行一次就需要取消訂閱
/**
* 觸發(fā):主動(dòng)通知并執(zhí)行注冊(cè)的回調(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ù)中比較需要替換對(duì)象屬性比較:newArr[i].fn === eventFnCallback
這樣我們的事件中心支持 once 方法改造就完成了。
2.3 緩存發(fā)布消息
在框架開發(fā)下,通常會(huì)使用異步按需加載組件,如果發(fā)布者組件先發(fā)布了消息,但是異步組件還未加載完成(完成訂閱注冊(cè)),那么發(fā)布者的這條發(fā)布消息就不會(huì)被響應(yīng)。因此,我們需要把消息做一個(gè)緩存隊(duì)列,直到有訂閱者訂閱了,并只響應(yīng)一次緩存的發(fā)布消息,該消息就會(huì)從緩存出隊(duì)。
首先梳理下緩存消息的邏輯流程:

發(fā)布者發(fā)布消息,事件中心檢測(cè)是否存在訂閱者,如果沒有訂閱者訂閱此條消息,則把該消息緩存到離線消息隊(duì)列中,當(dāng)有訂閱者訂閱時(shí),檢測(cè)是否訂閱了緩存中的事件消息,如果是,則該事件的緩存消息依次出隊(duì)(FCFS調(diào)度執(zhí)行),觸發(fā)訂閱者回調(diào)函數(shù)執(zhí)行一次。
新增離線消息緩存隊(duì)列:
private _offlineMessageQueue: Map<string, number>;
在emit發(fā)布消息中判斷對(duì)應(yīng)事件是否有訂閱者,沒有訂閱者則向離線事件消息中更新
/**
* 觸發(fā):主動(dòng)通知并執(zhí)行注冊(cè)的回調(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í)行,之后刪掉離線消息中的對(duì)應(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);
+ }
}
這樣,一個(gè)支持離線消息的事件中心就寫好了!
2.4 回調(diào)函數(shù)傳參&執(zhí)行環(huán)境
在上面的回調(diào)函數(shù)中,我們可以發(fā)現(xiàn)是一個(gè)沒有返回值,沒有入?yún)⒌暮瘮?shù),這其實(shí)有些雞肋,在函數(shù)運(yùn)行的時(shí)候會(huì)指向執(zhí)行的上下文,可能某些回調(diào)函數(shù)中含有this指向就無法綁定到事件中心上,因此針對(duì)回調(diào)函數(shù)需要綁定執(zhí)行上下文環(huán)境。
2.4.1 支持回調(diào)函數(shù)傳參
首先將TypeScript中的函數(shù)類型fn: () => void 改為 fn: Function,這樣能夠通過函數(shù)任意參數(shù)長(zhǎng)度的TS校驗(yàn)。
其實(shí)在事件中心里回調(diào)函數(shù)是沒有參數(shù)的,如有參數(shù)也是提前通過參數(shù)綁定(bind)方式傳入。
另外如果真要支持回調(diào)函數(shù)傳參,那么就需要在 emit() 的時(shí)候傳入?yún)?shù),然后再將參數(shù)傳遞給回調(diào)函數(shù),這里我們暫時(shí)先不實(shí)現(xiàn)了。
2.4.2 執(zhí)行環(huán)境綁定
在需要實(shí)現(xiàn)執(zhí)行環(huán)境綁定這個(gè)功能前,先思考一個(gè)問題:“是應(yīng)該開發(fā)者自行綁定還是應(yīng)該事件中心來做?”
換句話說,開發(fā)者在 on('eventName', 回調(diào)函數(shù)) 的時(shí)候,是否應(yīng)該主動(dòng)綁定 this 指向?在當(dāng)前設(shè)計(jì)下,初步認(rèn)為無參數(shù)的回調(diào)函數(shù)自行綁定 this 比較合適。
因此,在事件中心這暫時(shí)不需要去做綁定參數(shù)的行為,如果回調(diào)函數(shù)內(nèi)有需要傳參、綁定執(zhí)行上下文的,需要在綁定回調(diào)函數(shù)的時(shí)候自行 bind。這樣,我們的事件中心也算是保證了功能的純凈性。

到這里我們自己手搓簡(jiǎn)單的發(fā)布訂閱事件中心就完成了!
三、學(xué)習(xí)EventEmitter3的設(shè)計(jì)實(shí)現(xiàn)
雖然我們按照自己的理解實(shí)現(xiàn)了一版,但是沒有對(duì)比我們也不知道好壞,因此一起看看 EventEmitter3 這個(gè)優(yōu)秀“極致性能優(yōu)化”的庫是怎么去處理事件訂閱與發(fā)布,同時(shí)可以學(xué)習(xí)下其中的性能優(yōu)化思路。
首先,EventEmitter3(后續(xù)簡(jiǎn)稱:EE3)的實(shí)現(xiàn)思路,用Events對(duì)象作為“回調(diào)事件對(duì)象”的存儲(chǔ)器,類比我們上述實(shí)現(xiàn)的“發(fā)布訂閱模式”作為事件的執(zhí)行邏輯,另外addListener() 函數(shù)增加了傳入執(zhí)行上下文環(huán)境參數(shù),emit() 函數(shù)支持最多傳入5個(gè)參數(shù),同時(shí)EventEmitter3中還加入了監(jiān)聽器計(jì)數(shù)、事件名前綴。
3.1 Events存儲(chǔ)器
避免轉(zhuǎn)譯,以及為了提升兼容性和性能,EventEmitter3用ES5來編寫。
在JavaScript中萬物是對(duì)象,函數(shù)也是對(duì)象,因此存儲(chǔ)器的實(shí)現(xiàn):
function Events() {}
3.2 事件偵聽器實(shí)例
同理,我們上述使用singleEvent對(duì)象來存儲(chǔ)每一個(gè)事件偵聽器實(shí)例,EE3 中用一個(gè)EE對(duì)象存儲(chǔ)每個(gè)事件偵聽器的實(shí)例以及必要屬性
/**
* 每個(gè)事件偵聽器實(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: 這里為什么先是使用對(duì)象,多個(gè)的時(shí)候使用對(duì)象數(shù)組存儲(chǔ),有什么好處?
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;
}
該“添加偵聽器”的方法有幾個(gè)關(guān)鍵功能點(diǎn):
如果有前綴,給事件名增加前綴,避免事件沖突 每次新增事件名則 _eventsCount+1,用于快速讀寫所有事件的數(shù)量如果事件只有單個(gè)偵聽器,則 _events[evt]指向這個(gè)EE對(duì)象,訪問效率更高
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)鍵字,刪除對(duì)象上的屬性
另外這里一個(gè)很巧妙的地方在于,依賴事件計(jì)數(shù)器,如果計(jì)數(shù)器為0,則重新創(chuàng)建一個(gè) Events 存儲(chǔ)器指向 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 對(duì)象參考 NodeJS 中的事件觸發(fā)器,定義了最小的接口模型,包含 _events 和 _eventsCount屬性,另外的方法都通過原型來增加。
EventEmitter 對(duì)象等同于上述我們的事件中心的定義,其功能梳理如下:
其中有必要講的就是 emit() 方法,而訂閱者注冊(cè)事件的on() 和 once() 方法,都是使用的 addListener() 工具函數(shù)。
emit() 方法實(shí)現(xiàn)如下:
/**
* 調(diào)用執(zhí)行指定事件名的每一個(gè)偵聽器
*
* @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;
// 如果只有一個(gè)偵聽器綁定了該事件名
if (listeners.fn) {
// 如果是執(zhí)行一次的,則移除偵聽器
if (listeners.once) this.removeListener(event, listeners.fn, undefined, true);
// Refrence:https://juejin.cn/post/6844903496450310157
// 這里的處理是從性能上考慮,傳入5個(gè)入?yún)?shù)的調(diào)用call方法處理
// 超過5個(gè)參數(shù)的使用apply處理
// 大部分場(chǎng)景超過5個(gè)參數(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)有多個(gè)偵聽器綁定了同一個(gè)事件名
var length = listeners.length
, j;
// 循環(huán)執(zhí)行每一個(gè)綁定的事件偵聽器
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() 方法中顯示的傳入了五個(gè)入?yún)ⅲ?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(40, 202, 113);">a1 ~ a5,同時(shí)優(yōu)先使用 call() 方法綁定 this 指向并執(zhí)行偵聽器的回調(diào)函數(shù)。
這樣處理的原因是,call 方法比 apply 方法效率更高,相關(guān)比較驗(yàn)證討論可參考《call和apply的性能對(duì)比》
到這基本上 EventEmitter3 的實(shí)現(xiàn)就啃完了!

四、總結(jié)
EventEmitter3 是一個(gè)號(hào)稱優(yōu)化到極致的事件發(fā)布訂閱的工具庫,通過梳理可知曉:
call 與 apply 在效率上的差異 對(duì)象和對(duì)象數(shù)組的存取性能考慮 理解發(fā)布訂閱模式,以及在事件系統(tǒng)中的應(yīng)用實(shí)例
