前面一篇文章setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop詳細講解了瀏覽器和Node.js的異步API及其底層原理Event Loop。本文會講一下不用原生API怎么達到異步的效果,也就是發布訂閱模式。發布訂閱模式在面試中也是高頻考點,本文會自己實現一個發布訂閱模式,弄懂了他的原理后,我們就可以去讀Node.js的EventEmitter源碼,這也是一個典型的發布訂閱模式。
本文所有例子已經上傳到GitHub,同一個repo下面還有我所有博文和例子:
https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/DesignPatterns/PubSub
為什么要用發布訂閱模式
在沒有Promise之前,我們使用異步API的時候經常會使用回調,但是如果有幾個互相依賴的異步API調用,回調層級太多可能就會陷入“回調地獄”。下面代碼演示了假如我們有三個網絡請求,第二個必須等第一個結束才能發出,第三個必須等第二個結束才能發起,如果我們使用回調就會變成這樣:
const request = require("request");
request('https://www.baidu.com', function (error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 1');
request('https://www.baidu.com', function(error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 2');
request('https://www.baidu.com', function(error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 3');
}
})
}
})
}
});
由於瀏覽器端ajax會有跨域問題,上述例子我是用Node.js運行的。這個例子里面有三層回調,我們已經有點暈了,如果再多幾層,那真的就是“地獄”了。
發布訂閱模式
發布訂閱模式是一種設計模式,並不僅僅用於JS中,這種模式可以幫助我們解開“回調地獄”。他的流程如下圖所示:

- 消息中心:負責存儲消息與訂閱者的對應關系,有消息觸發時,負責通知訂閱者
- 訂閱者:去消息中心訂閱自己感興趣的消息
- 發布者:滿足條件時,通過消息中心發布消息
有了這種模式,前面處理幾個相互依賴的異步API就不用陷入"回調地獄"了,只需要讓后面的訂閱前面的成功消息,前面的成功后發布消息就行了。
自己實現一個發布訂閱模式
知道了原理,我們自己來實現一個發布訂閱模式,這次我們使用ES6的class來實現,如果你對JS的面向對象或者ES6的class還不熟悉,請看這篇文章:
class PubSub {
constructor() {
// 一個對象存放所有的消息訂閱
// 每個消息對應一個數組,數組結構如下
// {
// "event1": [cb1, cb2]
// }
this.events = {}
}
subscribe(event, callback) {
if(this.events[event]) {
// 如果有人訂閱過了,這個鍵已經存在,就往里面加就好了
this.events[event].push(callback);
} else {
// 沒人訂閱過,就建一個數組,回調放進去
this.events[event] = [callback]
}
}
publish(event, ...args) {
// 取出所有訂閱者的回調執行
const subscribedEvents = this.events[event];
if(subscribedEvents && subscribedEvents.length) {
subscribedEvents.forEach(callback => {
callback.call(this, ...args);
});
}
}
unsubscribe(event, callback) {
// 刪除某個訂閱,保留其他訂閱
const subscribedEvents = this.events[event];
if(subscribedEvents && subscribedEvents.length) {
this.events[event] = this.events[event].filter(cb => cb !== callback)
}
}
}
解決回調地獄
有了我們自己的PubSub,我們就可以用它來解決前面的毀掉地獄問題了:
const request = require("request");
const pubSub = new PubSub();
request('https://www.baidu.com', function (error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 1');
// 發布請求1成功消息
pubSub.publish('request1Success');
}
});
// 訂閱請求1成功的消息,然后發起請求2
pubSub.subscribe('request1Success', () => {
request('https://www.baidu.com', function (error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 2');
// 發布請求2成功消息
pubSub.publish('request2Success');
}
});
})
// 訂閱請求2成功的消息,然后發起請求3
pubSub.subscribe('request2Success', () => {
request('https://www.baidu.com', function (error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 3');
// 發布請求3成功消息
pubSub.publish('request3Success');
}
});
})
Node.js的EventEmitter
Node.js的EventEmitter思想跟我們前面的例子是一樣的,不過他有更多的錯誤處理和更多的API,源碼在GitHub上都有:https://github.com/nodejs/node/blob/master/lib/events.js。我們挑幾個API看一下:
構造函數
代碼傳送門: https://github.com/nodejs/node/blob/master/lib/events.js#L64

構造函數很簡單,就一行代碼,主要邏輯都在EventEmitter.init里面:

EventEmitter.init里面也是做了一些初始化的工作,this._events跟我們自己寫的this.events功能是一樣的,用來存儲訂閱的事件。核心代碼我在圖上用箭頭標出來了。這里需要注意一點,如果一個類型的事件只有一個訂閱,this._events就直接是那個函數了,而不是一個數組,在源碼里面我們會多次看到對這個進行判斷,這樣寫是為了提高性能。
訂閱事件
代碼傳送門: https://github.com/nodejs/node/blob/master/lib/events.js#L405
EventEmitter訂閱事件的API是on和addListener,從源碼中我們可以看出這兩個方法是完全一樣的:

這兩個方法都是調用了_addListener,這個方法對參數進行了判斷和錯誤處理,核心代碼仍然是往this._events里面添加事件:

發布事件
代碼傳送門:https://github.com/nodejs/node/blob/master/lib/events.js#L263
EventEmitter發布事件的API是emit,這個API里面會對"error"類型的事件進行特殊處理,也就是拋出錯誤:

如果不是錯誤類型的事件,就把訂閱的回調事件拿出來執行:

取消訂閱
代碼傳送門:https://github.com/nodejs/node/blob/master/lib/events.js#L450
EventEmitter里面取消訂閱的API是removeListener和off,這兩個是完全一樣的。EventEmitter的取消訂閱API不僅僅會刪除對應的訂閱,在刪除后還會emit一個removeListener事件來通知外界。這里也會對this._events里面對應的type進行判斷,如果只有一個,也就是說這個type的類型是function,會直接刪除這個鍵,如果有多個訂閱,就會找出這個訂閱,然后刪掉他。如果所有訂閱都刪完了,就直接將this._events置空:

觀察者模式
這里再提一個很相似的設計模式:觀察者模式,有些文章認為他和發布訂閱模式是一樣的,有些認為他們是有區別的。筆者認為他更像一個低配版的發布訂閱模式,我們來實現一個看看:
class Subject {
constructor() {
// 一個數組存放所有的訂閱者
// 每個消息對應一個數組,數組結構如下
// [
// {
// observer: obj,
// action: () => {}
// }
// ]
this.observers = [];
}
addObserver(observer, action) {
// 將觀察者和回調放入數組
this.observers.push({observer, action});
}
notify(...args) {
// 執行每個觀察者的回調
this.observers.forEach(item => {
const {observer, action} = item;
action.call(observer, ...args);
})
}
}
const subject = new Subject();
// 添加一個觀察者
subject.addObserver({name: 'John'}, function(msg){
console.log(this.name, 'got message: ', msg);
})
// 再添加一個觀察者
subject.addObserver({name: 'Joe'}, function(msg) {
console.log(this.name, 'got message: ', msg);
})
// 通知所有觀察者
subject.notify('tomorrow is Sunday');
上述代碼的輸出是:

通過這個輸出可以看出一旦調了通知的方法notify,所有觀察者都會收到通知,而且會收到同樣的信息。而發布訂閱模式還可以自定義需要接受的通知,所以說觀察者模式是低配版的發布訂閱模式。
總結
本文講解了發布訂閱模式的原理,並自己實現了一個簡單的發布訂閱模式。在了解了原理后,還去讀了Node.js的EventEmitter模塊的源碼,進一步學習了生產環境的發布訂閱模式的寫法。總結下來發布訂閱模式有以下特點:
- 解決了“回調地獄”
- 將多個模塊進行了解耦,自己執行時,不需要知道另一個模塊的存在,只需要關心發布出來的事件就行
- 因為多個模塊可以不知道對方的存在,自己關心的事件可能是一個很遙遠的旮旯發布出來的,也不能通過代碼跳轉直接找到發布事件的地方,debug的時候可能會有點困難。
- 觀察者模式是低配版的發布訂閱模式,一旦發布通知,所有觀察者都會收到消息,不能做到發布訂閱那樣精細的控制。
文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

