在衆多設計模式中,可能最常見、最有名的就是發佈 - 訂閱模式了,本篇我們一起來學習這個模式。
發佈 - 訂閱模式(Publish-Subscribe Pattern, pub-sub)又叫觀察者模式(Observer Pattern),它定義了一種一對多的關係,讓多個訂閱者對象同時監聽某一個發佈者,或者叫主題對象,這個主題對象的狀態發生變化時就會通知所有訂閱自己的訂閱者對象,使得它們能夠自動更新自己。
當然有人提出發佈 - 訂閱模式和觀察者模式之間是 有一些區別的,但是大部分情況下你可以將他們當成是一個模式,本文將不對它們之間進行區分,文末會簡單討論一下他們之間的微妙區別,瞭解即可。
1. 你曾遇見過的發佈 - 訂閱模式
在現實生活中其實我們會經常碰到發佈 - 訂閱模式的例子。
比如當我們進入一個聊天室 / 羣,如果有人在聊天室發言,那麼這個聊天室裏的所有人都會收到這個人的發言。這是一個典型的發佈 - 訂閱模式,當我們加入了這個羣,相當於訂閱了在這個聊天室發送的消息,當有新的消息產生,聊天室會負責將消息發佈給所有聊天室的訂閱者。
再舉個栗子,當我們去 adadis 買鞋,發現看中的款式已經售罄了,售貨員告訴你不久後這個款式會進貨,到時候打電話通知你。於是你留了個電話,離開了商場,當下周某個時候 adadis 進貨了,售貨員拿出小本本,給所有關注這個款式的人打電話。
這也是一個日常生活中的一個發佈 - 訂閱模式的實例,雖然不知道什麼時候進貨,但是我們可以登記號碼之後等待售貨員的電話,不用每天都打電話問鞋子的信息。
上面兩個小栗子,都屬於發佈 - 訂閱模式的實例,羣成員 / 買家屬於消息的訂閱者,訂閱消息的變化,聊天室 / 售貨員屬於消息的發佈者,在合適的時機向羣成員 / 小本本上的訂閱者發佈消息。
adadis 售貨員這個例子的各方關係大概如下圖:
在這樣的邏輯中,有以下幾個特點:
1. 買家(訂閱者)只要聲明對消息的一次訂閱,就可以在未來的某個時候接受來自售貨員(發佈者)的消息,不用一直輪詢消息的變化;
2. 售貨員(發佈者)持有一個小本本(訂閱者列表),對這個本本上記錄的訂閱者的情況並不關心,只需要在消息發生時挨個去通知小本本上的訂閱者,當訂閱者增加或減少時,只需要在小本本上增刪記錄即可;
3. 將上面的邏輯升級一下,一個人可以加多個羣,售貨員也可以有多個小本本,當不同的羣產生消息或者不款式的鞋進貨了,發佈者可以按照不同的名單 / 小本本分別去通知訂閱了不同類型消息的訂閱者,這裏有個消息類型的概念;
2. 實例的代碼實現
如果你在 DOM 上綁定過事件處理函數 addEventListener 那麼你已經使用過發佈 - 訂閱模式了。
我們經常將一些操作掛載在 onload 事件上執行,當頁面元素加載完畢,就會觸發你註冊在 onload 事件上的回調。我們無法預知頁面元素何時加載完畢,但是通過訂閱 window 的 onload 事件,window 會在加載完畢時向訂閱者發佈消息,也就是執行回調函數。
window.addEventListener('load', function () {
console.log('loaded!')
})
這與買鞋的例子類似,我們不知道什麼時候進貨,但只需訂閱鞋子的消息,進貨的時候售貨員會打電話通知我們。
在現實中和編程中我們還會遇到很多這樣類似的問題,我們可以將 adadis 的例子提煉一下,用 JavaScript 來實現:
const adadisPub = {
// adadis售貨員的小本本
adadisBook: [],
// 買家在小本本上登記號碼
subShoe(phoneNumber) {
this.adadisBook.push(phoneNumber)
},
// 售貨員打電話通知小本本上的買家
notify() {
for (const customer of this.adadisBook) {
customer.update()
}
}
};
const customer1 = {
phoneNumber: '15224334XXX',
update() {
console.log(this.phoneNumber + ': 去商場看看');
}
};
const customer2 = {
phoneNumber: '13823344XXX',
update() {
console.log(this.phoneNumber + ': 給表弟買雙');
}
};
// 在小本本上留下號碼
adadisPub.subShoe(customer1);
adadisPub.subShoe(customer2);
// 打電話通知買家到貨了
adadisPub.notify();
// 15224334XXX:去商場看看
// 13823344XXX:給表弟買雙
這樣我們就實現了在有新消息時對買家的通知。
當然還可以對功能進行完善,比如:
1. 在登記號碼的時候進行一下判重操作,重複號碼就不登記了;
2. 買家登記之後想了一下又不感興趣了,那麼以後也就不需要通知了,增加取消訂閱的操作;
const adadisPub = {
// adadis售貨員的小本本
adadisBook: [],
// 買家在小本本上登記號碼
subShoe(customer) {
// 判斷去重
if (!this.adadisBook.includes(customer)) {
this.adadisBook.push(customer)
};
},
// 取消訂閱
unSubShoe(customer) {
if (!this.adadisBook.includes(customer)){
return
};
const idx = this.adadisBook.indexOf(customer)
this.adadisBook.splice(idx, 1)
},
// 售貨員打電話通知小本本上的買家
notify() {
for (const customer of this.adadisBook) {
customer.update()
}
}
}
const customer1 = {
phoneNumber: '15224334XXX',
update() {
console.log(this.phoneNumber + ': 去商場看看')
}
}
const customer2 = {
phoneNumber: '13823344XXX',
update() {
console.log(this.phoneNumber + ': 給表弟買雙')
}
}
// 在小本本上留下號碼
adadisPub.subShoe(customer1);
adadisPub.subShoe(customer1);
adadisPub.subShoe(customer2);
adadisPub.unSubShoe(customer1);
// 打電話通知買家到貨了
adadisPub.notify();
// 13823344XXX:給表弟買雙
到現在我們已經簡單完成了一個發佈 - 訂閱模式。
但是還可以繼續改進,比如買家可以關注不同的鞋型,那麼當某個鞋型進貨了,只通知關注了這個鞋型的買家,總不能通知所有買家吧。改寫後的代碼:
const adadisPub = {
// adadis售貨員的小本本
adadisBook: {},
// 買家在小本本是登記號碼
subShoe(type, customer) {
// 如果小本本上已經有這個type
if (this.adadisBook[type]) {
// 判斷去重
if (!this.adadisBook[type].includes(customer)){
this.adadisBook[type].push(customer)
}
} else {
this.adadisBook[type] = [customer]
}
},
// 取消訂閱
unSubShoe(type, customer) {
if (!this.adadisBook[type] ||!this.adadisBook[type].includes(customer)) {
return
}
const idx = this.adadisBook[type].indexOf(customer)
this.adadisBook[type].splice(idx, 1)
},
// 售貨員打電話通知小本本上的買家
notify(type) {
if (!this.adadisBook[type]) return
this.adadisBook[type].forEach(customer =>
customer.update(type)
)
}
}
const customer1 = {
phoneNumber: '15224334XXX',
update(type) {
console.log(this.phoneNumber + ': 去商場看看' + type)
}
}
const customer2 = {
phoneNumber: '13823344XXX',
update(type) {
console.log(this.phoneNumber + ': 給表弟買雙' + type)
}
}
// 訂閱運動鞋
adadisPub.subShoe('運動鞋', customer1);
adadisPub.subShoe('運動鞋', customer1);
adadisPub.subShoe('運動鞋', customer2);
// 訂閱帆布鞋
adadisPub.subShoe('帆布鞋', customer1);
// 打電話通知買家運動鞋到貨了
adadisPub.notify('運動鞋');
// 15224334XXX:去商場看看運動鞋
// 13823344XXX:給表弟買雙運動鞋
這樣買家就可以訂閱不同類型的鞋子,售貨員也可以只通知關注某特定鞋型的買家了。
3. 發佈 - 訂閱模式的通用實現
我們可以把上面例子的幾個核心概念提取一下,買家可以被認爲是訂閱者(Subscriber),售貨員可以被認爲是發佈者(Publisher),售貨員持有小本本(SubscriberMap),小本本上記錄有買家訂閱(subscribe)的不同鞋型(Type)的信息,當然也可以退訂(unSubscribe),當鞋型有消息時售貨員會給訂閱了當前類型消息的訂閱者發佈(notify)消息。
主要有下面幾個概念:
1. Publisher:發佈者,當消息發生時負責通知對應訂閱者;
2. Subscriber:訂閱者,當消息發生時被通知的對象;
3. SubscriberMap:持有不同 type 的數組,存儲有所有訂閱者的數組;
4. type:消息類型,訂閱者可以訂閱的不同消息類型;
5. subscribe:該方法爲將訂閱者添加到 SubscriberMap 中對應的數組中;
6. unSubscribe:該方法爲在 SubscriberMap 中刪除訂閱者;
7. notify :該方法遍歷通知 SubscriberMap 中對應 type 的每個訂閱者;
現在的結構如下圖:
下面使用通用化的方法實現一下。
首先我們使用立即調用函數 IIFE(Immediately Invoked Function Expression) 方式來將不希望公開的 SubscriberMap隱藏,然後可以將註冊的訂閱行爲換爲回調函數的形式,這樣我們可以在消息通知時附帶參數信息,在處理通知的時候也更靈活:
const Publisher = (function() {
// 存儲訂閱者
const _subsMap = {};
return {
// 消息訂閱
subscribe(type, cb) {
if (_subsMap[type]) {
if (!_subsMap[type].includes(cb)){
// 註冊一個函數
_subsMap[type].push(cb)
}
} else{
// 初始化一個數組
_subsMap[type] = [cb]
}
},
// 消息退訂
unsubscribe(type, cb) {
if (!_subsMap[type] ||!_subsMap[type].includes(cb)) {
return
}
const idx = _subsMap[type].indexOf(cb)
_subsMap[type].splice(idx, 1)
},
// 消息發佈
notify(type, ...payload) {
if (!_subsMap[type]){
return
};
_subsMap[type].forEach(cb => cb(...payload))
}
}
})()
// 訂閱運動鞋
Publisher.subscribe('運動鞋', message => console.log('15224334XXX' + message));
Publisher.subscribe('運動鞋', message => console.log('13823344XXX' + message));
// 訂閱帆布鞋
Publisher.subscribe('帆布鞋', message => console.log('13924323XXX' + message));
// 打電話通知買家運動鞋消息
Publisher.notify('運動鞋', ' 運動鞋到貨了');
// 15224334XXX 運動鞋到貨了
// 13823344XXX 運動鞋到貨了
// 打電話通知買家帆布鞋消息
Publisher.notify('帆布鞋', ' 帆布鞋售罄了');
// 13924323XXX 帆布鞋售罄了
4. 實戰中的發佈 - 訂閱模式
4.1. 使用 jQuery 的方式
我們使用 jQuery 的時候可以通過其自帶的 API 比如 on、trigger、off 來輕鬆實現事件的訂閱、發佈、取消訂閱等操作:
function eventHandler() {
console.log('自定義方法');
};
// 事件訂閱
$('#app').on('myEvent', eventHandler);
// 發佈
$('#app').trigger('myEvent');
// 自定義方法
// 取消訂閱
$('#app').off('myEvent');
$('#app').trigger('myEvent');
甚至我們可以使用原生的 addEventListener、dispatchEvent、removeEventListener 來實現發佈訂閱:
// 自定義方法
function eventHandler(dom) {
console.log('自定義方法');
}
var app = document.getElementById('app');
// 事件訂閱
app.addEventListener('myEvent', eventHandler);
// 事件發佈
app.dispatchEvent(new Event('myEvent'));
// 自定義方法
// 取消訂閱
app.removeEventListener('myEvent', eventHandler);
// 取消訂閱後,事件再次派發,沒有輸出
app.dispatchEvent(new Event('myEvent'));
4.2. 使用 Vue 的 EventBus
和 jQuery 一樣,Vue 也是實現有一套事件機制,其中一個我們熟知的用法是 EventBus。在多層組件的事件處理中,如果你覺得一層層 $on、$emit 比較麻煩,而你又不願意引入 Vuex,那麼這時候推介使用 EventBus 來解決組件間的數據通信:
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue();
在組件A中使用時:
// 組件A
import { EventBus } from "./event-bus.js";
EventBus.$on("myevent", args => {
console.log(args)
})
在線件B中使用時:
// 組件B
import { EventBus } from "./event-bus.js";
EventBus.$emit("myevent", 'some args')
實現組件間的消息傳遞,不過在中大型項目中,還是推介使用 Vuex,因爲如果 Bus 上的事件掛載過多,事件滿天飛,就分不清消息的來源和先後順序,對可維護性是一種破壞。
5. 源碼中的發佈 - 訂閱模式
發佈 - 訂閱模式在源碼中應用很多,特別是現在很多前端框架都會有的雙向綁定機制的場景,這裏以現在很火的 Vue 爲例,來分析一下 Vue 是如何利用發佈 - 訂閱模式來實現視圖層和數據層的雙向綁定。先借用官網的雙向綁定原理圖:
下面稍微解釋一下這個圖(框架源碼整個過程比較複雜,如果現在看不懂下面幾段也沒關係,大致瞭解一下即可)。
組件渲染函數(Component Render Function)被執行前,會對數據層的數據進行響應式化。響應式化大致就是使用 Object.defineProperty 把數據轉爲 getter/setter 併爲每個數據添加一個訂閱者列表的過程。這個列表是 getter 閉包中的屬性,將會記錄所有依賴這個數據的組件。
也就是說,響應式化後的數據相當於發佈者。
每個組件都對應一個 Watcher 訂閱者。當每個組件的渲染函數被執行時,都會將本組件的 Watcher 放到自己所依賴的響應式數據的訂閱者列表裏,這就相當於完成了訂閱,一般這個過程被稱爲依賴收集(Dependency Collect)。
組件渲染函數執行的結果是生成虛擬 DOM 樹(Virtual DOM Tree),這個樹生成後將被映射爲瀏覽器上的真實的 DOM 樹,也就是用戶所看到的頁面視圖。
當響應式數據發生變化的時候,也就是觸發了 setter 時,setter 會負責通知(Notify)該數據的訂閱者列表裏的 Watcher,Watcher 會觸發組件重渲染(Trigger re-render)來更新(update)視圖。
我們可以看看 Vue 的 源碼 :
// src/core/observer/index.js 響應式化過程
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// 其它操作
// 如果原本對象擁有getter方法則執行
const value = getter ? getter.call(obj) : val;
// 進行依賴收集,dep.addSub
dep.depend();
return value
},
set: function reactiveSetter(newVal) {
// 其它操作
// 如果原本對象擁有setter方法則執行
if (setter) { setter.call(obj, newVal) };
// 如果發生變更,則通知更新
dep.notify();
}
})
而這個 dep上的 depend 和 notify 就是訂閱和發佈通知的具體方法。
6. 發佈 - 訂閱模式的優缺點
發佈 - 訂閱模式最大的優點就是解耦:
1. 時間上的解耦:註冊的訂閱行爲由消息的發佈方來決定何時調用,訂閱者不用持續關注,當消息發生時發佈者會負責通知;
2. 對象上的解耦 :發佈者不用提前知道消息的接受者是誰,發佈者只需要遍歷處理所有訂閱該消息類型的訂閱者發送消息即可(迭代器模式),由此解耦了發佈者和訂閱者之間的聯繫,互不持有,都依賴於抽象,不再依賴於具體;
由於它的解耦特性,發佈 - 訂閱模式的使用場景一般是:當一個對象的改變需要同時改變其它對象,並且它不知道具體有多少對象需要改變。發佈 - 訂閱模式還可以幫助實現一些其他的模式,比如中介者模式。
發佈 - 訂閱模式也有缺點:
1. 增加消耗:創建結構和緩存訂閱者這兩個過程需要消耗計算和內存資源,即使訂閱後始終沒有觸發,訂閱者也會始終存在於內存;
2. 增加複雜度 :訂閱者被緩存在一起,如果多個訂閱者和發佈者層層嵌套,那麼程序將變得難以追蹤和調試,參考一下 Vue 調試的時候你點開原型鏈時看到的那堆 deps/subs/watchers 們…
缺點主要在於理解成本、運行效率、資源消耗,特別是在多級發佈 - 訂閱時,情況會變得更復雜。
7. 其他相關模式
7.1. 發佈 - 訂閱模式和觀察者模式
觀察者模式與發佈 - 訂閱者模式,在平時你可以認爲他們是一個東西,但是某些場合(比如面試)下可能需要稍加註意,借用網上一張流行的圖:
區別主要在發佈 - 訂閱模式中間的這個 Event Channel:
1. 觀察者模式 中的觀察者和被觀察者之間還存在耦合,被觀察者還是知道觀察者的;
2. 發佈 - 訂閱模式中的發佈者和訂閱者不需要知道對方的存在,他們通過消息代理來進行通信,解耦更加徹底;
7.2. 發佈 - 訂閱模式和責任鏈模式
發佈 - 訂閱模式和責任鏈模式也有點類似,主要區別在於:
1. 發佈 - 訂閱模式 傳播的消息是根據需要隨時發生變化,是發佈者和訂閱者之間約定的結構,在多級發佈 - 訂閱的場景下,消息可能完全不一樣;
2. 責任鏈模式 傳播的消息是不變化的,即使變化也是在原來的消息上稍加修正,不會大幅改變結構;
推薦閱讀: