發布-訂閱模式是什么?
發布-訂閱模式又叫做觀察者模式,它定義對象間的一種一對多的依賴關系,當一個對象的狀態發生改變的時候,所有依賴於它的對象都將得到通知。
作為一名JavaScript開發者,我100%相信你已經使用過了這個模式,不信你看如下代碼:
document.body.addEventListener('click',function(){
console.log('執行了點擊事件');
})
在這里我們為body加上了一個點擊事件,相當於我們訂閱了點擊事件,但是我們不關系,它什么時候觸發,但是一旦觸發點擊事件,那么就會執行我們所寫功能函數。
這個就是一個簡單的應用。我們在來看一個例子:
var obj = {name: 'ydb'}; Object.defineProperty(obj,'name',{ set: function(){ console.log('更新了'); } }) obj.name = 'ydb11';
在這里我們訂閱了name屬性的更新,一旦name發生改變,就會執行set函數,同樣我們並不關心name什么時候更新,但是只要更新,就會觸發我們定義的set函數,從而執行相關的操作。
仔細想一下,你在日常開發中除了使用DOM事件外,有沒有使用過自定義事件,比如vue中子組件向父組件通信,看代碼:
假設有那么一個場景:小明要去買房,但是沒有他喜歡的房源,所以他就留下了自己的聯系方式和要求給售房處,一旦有了符合自己要求的房子,就打電話給他。這個時候小紅也來買房子,和小明一樣沒有喜歡的房子,於是也留下了自己的聯系方式和要求。
1.有了符合自己要求的,售房處就會主動聯系自己,不需要自己每天打電話問有沒有符合自己的房子。
2.售房處只要記得有了房子,通知這些買家就行了,其他的因素影響不了這個操作。比如售房處搬家了,之前的員工辭職了,這些都無關緊要,只要在新的地方或者新的員工記得打電話通知就行了。
3.最后發布消息的時候,遍歷緩存列表,依次觸發里面的回調函數(遍歷花名冊,挨個打電話通知)
看代碼:
// 定義售房處 var salesOffices = {}; // 定義花名冊 salesOffices.clientList = []; // 留下聯系方式 訂閱消息 salesOffices.on = function (callback) { this.clientList.push(callback); } salesOffices.emit = function () { for (var i = 0, fn; fn = this.clientList[i++];) { fn.apply(this, arguments); // arguments 發布消息時所帶的參數 } } // 下面進行訂閱消息 // 小明 salesOffices.on(function (price, squareMetar) { console.log('價格:' + price + '萬'); console.log('面積:' + squareMetar); }); // 小紅 價格300萬,面積110平方米 salesOffices.on(function (price, squareMetar) { console.log('價格:' + price + '萬'); console.log('面積:' + squareMetar); }); // 發布消息 小明(價格200萬,面積88平方米) salesOffices.emit(200, 88); // 發布消息 小紅(價格200萬,面積88平方米) salesOffices.emit(300, 110);
這里我們基本上實現了這個場景,當有滿足要求的房子時候,發布者只要發布消息,訂閱者就能做出相關的事情,挺好的,看一下測試結果:

結果正確,但是注意現在的代碼中,不管哪個訂閱者被滿足的時候,其他訂閱者也會收到消息,這也就是為什么會出現四次打印結果的原因。設想一下假如有100個買房子的人,只要其中一個滿足條件了,其他的買房子的人也會收到電話。我擦這誰頂的住啊,別人買的房子給我打什么電話,我tm一天都被電話轟炸了,所以必須修改上面的代碼。
且看代碼:
// 定義售房處 var salesOffices = {}; // 定義花名冊 salesOffices.clientList = {}; // 留下聯系方式 訂閱消息 salesOffices.on = function (key, callback) { if (!this.clientList[key]) { // 如果沒有訂閱此類消息,就給該類消息創建一個緩存列表 this.clientList[key] = []; } this.clientList[key].push(callback); // 消息加入緩存列表 } salesOffices.emit = function () { var key = Array.prototype.shift.call(arguments); //取出消息類型 var fns = this.clientList[key]; // 取出該消息類型下的回調函數的集合 if (!fns || fns.length === 0) { // 如果沒有訂閱消息,則返回 return false; } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this, arguments); // arguments 發布消息時所帶的參數 } } // 下面進行訂閱消息 // 小明 salesOffices.on('squareMeter88', function (price) { console.log('價格:' + price + '萬'); }); // 小紅 價格300萬,面積110平方米 salesOffices.on('squareMetar110', function (price) { console.log('價格:' + price + '萬'); }); // 發布消息 小明(價格200萬,面積88平方米) salesOffices.emit('squareMeter88', 88); // 發布消息 小紅(價格200萬,面積88平方米) salesOffices.emit('squareMetar110', 110);
現在只有符合自己要求的訂閱者,才會收到電話,這樣子就合理多了。
在我們日常開發中,增加需求是很常見的事情,這里也是,小明有點不放心這個售房處,期間他又找了許多售房處,並登記了信息。通過上面測例子我們可以看出,售房處的代碼還是有點多的,多個售房處,就有多個相同的操作,那是不是每一個售房處,都要這樣子寫?可以是可以,但是太麻煩了,我們想着如果把訂閱發布那部分統一出來,那豈不是很簡單了。
看代碼:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 訂閱的消息添加緩存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果沒有綁定對應的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this.arguments); // arguemnts是emit時候帶上的參數 } } }
這里我們封裝了一個發布-訂閱的對象,里面具備完整的功能,現在只要有新的售房處出現,就可以直接復用里面的代碼:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 訂閱的消息添加緩存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果沒有綁定對應的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this,arguments); // arguemnts是emit時候帶上的參數 } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 給售房處添加發布-訂閱功能 initalEvent(salesOffices1); salesOffices1.on('squareMeter88', function (price) { console.log('價格:' + price + '萬'); }) salesOffices1.emit('squareMeter88', 200)
就這樣子操作,所有售房處都能發布消息了,initalEvent相當於售房處的電話,只要買了電話,那么就可以打電話了。
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 訂閱的消息添加緩存列表 }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果沒有綁定對應的消息 } for (var i = 0, fn; fn = fns[i++];) { fn.apply(this,arguments); // arguemnts是emit時候帶上的參數 } }, remove: function(key,fn){ var fns = this.clientList[key]; if (!fns) { // 如果沒有訂閱的消息,則返回 return false; } if (!fn) { // 沒有傳入具體的回調函數,標示需要取消key對應的所有訂閱 fns && (fns.length = 0); } else { for (var i=fns.length-1;i>=0;i--) { if (fn === fns[i]) { fns.splice(i,1) // 刪除訂閱的回調函數 } } } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 給售房處添加發布-訂閱功能 initalEvent(salesOffices1); var fn1 = function(price) { console.log('價格:' + price + '萬'); } salesOffices1.on('squareMeter88', fn1); salesOffices1.emit('squareMeter88', 200); // 刪除小明的訂閱 salesOffices1.remove('squareMeter88',fn1); salesOffices1.emit('squareMeter88', 200);
測試如下:


嗯,沒毛病老鐵。
有一天小明中了五千萬,想要出國買房,但是想如果能在國內買一套別墅,放在那兒升值也可以。由於之前的矛盾,他對售房處產生了不好的印象,說只給你們一次機會給我找好房子,一次過后我不滿意我就要出國了,你們就聯系不到我了。所以現在我們就需要實現一次訂閱的事件,看看代碼:
var event = { clientList: {}, on: function (key, fn) { if (!this.clientList[key]) { this.clientList[key] = []; } this.clientList[key].push(fn); // 訂閱的消息添加緩存列表 }, onece: function (key, fn) { this.on(key, fn); // 標志只訂閱一次 fn.onece = true; }, emit: function () { var key = Array.prototype.shift.call(arguments); var fns = this.clientList[key]; if (!fns || fns.length === 0) { return false; // 如果沒有綁定對應的消息 } for (var i = fns.length - 1; i >= 0; i--) { var fn = fns[i]; fn.apply(this, arguments); // arguemnts是emit時候帶上的參數 if (!!fn.onece) { // 刪除訂閱的消息所對應的回調函數 fns.splice(i, 1); } } }, remove: function (key, fn) { var fns = this.clientList[key]; if (!fns) { // 如果沒有訂閱的消息,則返回 return false; } if (!fn) { // 沒有傳入具體的回調函數,標示需要取消key對應的所有訂閱 fns && (fns.length = 0); } else { for (var i = fns.length - 1; i >= 0; i--) { if (fn === fns[i]) { fns.splice(i, 1) // 刪除訂閱的回調函數 } } } } } var initalEvent = function (obj) { for (key in event) { obj[key] = event[key]; } } var salesOffices1 = {}; // 給售房處添加發布-訂閱功能 initalEvent(salesOffices1); var fn1 = function (price) { console.log('價格:' + price + '萬'); } // 小明只訂閱一次 salesOffices1.onece('squareMeter88', fn1); salesOffices1.emit('squareMeter88', 200); salesOffices1.emit('squareMeter88', 200);
測試如下:


現在一看,我們這個發布-訂閱功能還是很完美的,對吧!但是還存在一些問題的:
1. 我們給米一個發布者都添加了on,emit,clientList,這其實是一種浪費資源的現象
2.小明跟售房處對象還存在一定的耦合性,小明至少要知道售房處對象名字是salesOffice,才能順利訂閱事件。
想一想我們平時找房子很少直接跟房東聯系的,我們大多數是跟各種各樣的中介公司聯系的,我們留下聯系方式給中介,房東通過中介發布房源信息。
所以我們需要定制一個中介公司,也就是全局的發布-訂閱對象,看代碼:
var event = (function () { var clientList = {}, on, emit, remove, onece; on = function (key, fn) { if (!clientList[key]) { clientList[key] = []; } clientList[key].push(fn); }; onece = function (key, fn) { this.on(key, fn); fn.onece = true; } emit = function () { var key = Array.prototype.shift.call(arguments); var fns = clientList[key]; if (!fns || fns.length === 0) { return false; } for (var i = fns.length - 1; i >= 0; i--) { var fn = fns[i]; fn.apply(this, arguments); if (!!fn.onece) { fns.splice(i, 1); } } } remove = function (key, fn) { var fns = clientList[key]; if (!fns) { return false; } if (!fn) { fns && (fns.length === 0); } for (var i = fns.length - 1; i >= 0; i--) { if (fns[i] === fn) { fns.splice(i, 1); } } } return { on, emit, onece, remove } })(); var fn1 = function (price) { console.log('價格:' + price + '萬'); } console.log('一直訂閱'); event.on('squareMeter88', fn1); event.emit('squareMeter88', 200); event.emit('squareMeter88', 200); console.log('訂閱一次'); event.onece('squareMeter120', fn1); event.emit('squareMeter120', 300); event.emit('squareMeter120', 300); console.log('取消訂閱'); event.on('squareMeter160', fn1); event.remove('squareMeter160', fn1); event.emit('squareMeter160', 500);
看看測試結果:


果然如此。
但是在這里我們又遇到了新的問題,模塊之間如果用了太多的全局發布-訂閱模式來通信,那么模塊與模塊之間的聯系就被隱藏到了背后。我們最終會搞不清楚消息來自哪個模塊,或者消息會流向那些模塊,這個又會對我們的維護帶來一定的麻煩,也許某個模塊的作用就是暴露一些接口給其他模塊使用。具體使用還是要根據業務場景來的。
到這里我們基本實現來發布-訂閱功能,但是我們想幾個問題:
我們QQ離線的時候,我們登陸QQ是不是會收到之前的離線消息,而且只能收到一次,所以說不是必須先訂閱在發布,也可以先發布,之后在訂閱與否是自己的事情。
我們在全局使用發布-訂閱對象很方便,但是隨着使用的次數增多,難免會出現事件名沖突的情況,所以我們可以給event對象提供創建命名空間的空能。
這兩個需求只是我們為了更加完善我們全局的發布-訂閱對象,對之前的event對象不是去顛覆,而是去升級,使其更健壯。
再加入這兩個需求之后,我們最終的全局的發布-訂閱對象如下:
var event = (function () { // 全局的命名空間緩存數據 var namesapceCaches = {}; var _default = 'default'; var shift = Array.prototype.shift; var hasNameSpace = function (namespace, key) { // 不存在命名空間 if (!namesapceCaches[namespace]) { namesapceCaches[namespace] = {} } // 命名空間下不存在該key的訂閱對象 if (!namesapceCaches[namespace][key]) { namesapceCaches[namespace][key] = { // 該key下的訂閱的事件緩存列表 cache: [], // 該key下的離線事件 offlineStack: [] } } } // 使用命名空間 var _use = function (namespace) { var namespace = namespace || _default; return { // 訂閱消息 on: function (key, fn) { hasNameSpace(namespace, key); namesapceCaches[namespace][key].cache.push(fn); // 沒有訂閱之前,發布者發布的信息保存在offlineStack中,現在開始顯示離線消息(只發送一次) var offlineStack = namesapceCaches[namespace][key].offlineStack; if (offlineStack.length === 0) { return; } for (var i = offlineStack.length - 1; i >= 0; i--) { // 一次性發送所有的離線數據 fn(offlineStack[i]); } offlineStack.length = 0; }, // 發布消息 emit: function () { // 獲取key var key = shift.call(arguments); hasNameSpace(namespace, key); // 獲取該key對應緩存的訂閱回調函數 var fns = namesapceCaches[namespace][key].cache; if (fns.length === 0) { var data = shift.call(arguments); // 還沒有訂閱,保存發布的信息 namesapceCaches[namespace][key].offlineStack.push(data); return; } for (var i = fns.length - 1; i >= 0; i--) { fns[i].apply(this, arguments); if (fns.onece) { fns.splice(i, 1); } } }, remove: function (key, fn) { // 獲取key var key = shift.call(arguments); // 不存在命名空間和訂閱對象 if (!namesapceCaches[namespace] || !namesapceCaches[namespace][key]) { return; } // 獲取該key對應緩存的訂閱回調函數 var fns = namesapceCaches[namespace][key].cache; if (fns.length === 0) { return; } for (var i = fns.length - 1; i >= 0; i--) { if (fn === fns[i]) { fns.splice(i, 1); } } }, onece: function (key, fn) { this.on(key, fn); fn.onece = true; } } } return { // 用戶的命名空間 use: _use, /** * 默認的命名空間 * on,emit,remove,onece都為代理方法。 */ on: function (key, fn) { var event = this.use(); event.on(key, fn); }, emit: function () { var event = this.use(); event.emit.apply(this, arguments); }, remove: function (key, fn) { var event = this.use(); event.remove(key, fn); }, onece: function (key, fn) { var event = this.use(); event.onece(key, fn); }, show: function () { return namesapceCaches; } } })();
看就是那么簡單,但是這里有一個不好的地方,那就是離線消息,只要有一個對應的訂閱者訂閱,那么離線消息就會全部發送完畢。聰明的你可以自己再去改造一下。
下面的是我的測試代碼:
console.log('先發布后訂閱測試');
event.emit('111', '離線數據1');
event.emit('111', '離線數據2');
setTimeout(function () {
event.on('111', function (data) {
console.log(data);
})
}, 2000);
setTimeout(function () {
event.emit('111', '在線數據');
}, 3000);
console.log('默認命名空間測試----');
var fn1 = function (data) { console.log(data) }
event.on('default', fn1);
event.emit('default', '默認命名空間測試');
event.remove('default', fn1);
event.emit('default', '默認命名空間測試');
console.log('自定義命名空間測試');
var fn1 = function (data) { console.log(data) }
event.use('ydb').on('111', fn1);
event.emit('ydb', '默認命名空間發布消息');
event.use('ydb').emit('111', 'ydb空間發送數據1');
event.use('ydb').remove('111', fn1);
event.use('ydb').emit('111', 'ydb空間發送數據1(現在是離線數據)');
event.use('ydb').emit('111', '離線數據');
event.use('ydb').on('111', fn1);
event.use('ydb').emit('111', '在線數據');
可以自己下去測試一下,看看結果是怎么樣子的。用這個模式我們完全可以在自己的spa應用中實現跨組件通信。那就再見了。
