1 釋出-訂閱模式的定義
釋出-訂閱模式又稱為觀察者模式。它定義了物件之間的一種一對多的依賴關係。當一個物件發生改變時,所有依賴於它的物件都將得到通知。在javascript開發中,我們一般用事件模型來替代傳統的釋出-訂閱模式。
1.1 觀察者模式與釋出訂閱模式關係
- 釋出訂閱模式屬於廣義上的觀察者模式。
釋出訂閱模式是最常用的一種觀察者模式的實現,並且從解耦和重用角度來看,更優於典型的觀察者模式 - 釋出訂閱模式多了個事件通道
在觀察者模式中,觀察者需要直接訂閱目標事件;在目標發出內容改變的事件後,直接接收事件並作出響應。
在釋出訂閱模式中,釋出者和訂閱者之間多了一個釋出通道;一方面從釋出者接收事件,另一方面向訂閱者釋出事件;訂閱者需要從事件通道訂閱事件,以此避免釋出者和訂閱者之間產生依賴關係。
2 現實中的釋出-訂閱模式
現實中的售樓部就是一個釋出-訂閱模式的例子。想要買房子的顧客會去售樓部檢視房源情況,但是不一定都遇到此時有房源,因此留下了自己的電話號碼,向售樓部訂閱了最新房源的訊息。而售樓部就是釋出者,當有房源就會立馬通知所有訂閱了訊息的使用者,房源已經出來了。這樣的好處在於:
- 顧客不用每天都去售房部看是否有房源,顧客就是訂閱者,售樓部是釋出者。
- 顧客與售房部不會強耦合在一起,當有新的購房者出現,只需要向售樓部訂閱即可。售樓部不會管使用者是人還是鬼,而顧客也不會管售樓部內部職員變動情況,只要售樓部即使傳送訊息。
第一點在非同步程式設計中有體現,例如我們訂閱了ajax請求的error,succ事件。當ajax請求完畢後,一定會通知error或succ執行。我們無需關心ajax非同步執行期間的內部狀態,只需要訂閱感興趣的事件發生點。
第二點可以取代物件之間的硬編碼通知機制,一個物件不再需要顯示呼叫另一個物件的某個介面。釋出訂閱模式將釋出者和訂閱者鬆耦合地聯絡在一起,不需要知道彼此之間的實現細節,也能夠相互通訊。當新的訂閱者出現,釋出者不需要有任何修改。同樣釋出者有修改時,訂閱者也不會有任何影響。只要之前約定的事件名沒有發生任何變化,就可以自由改變它們。
3 DOM事件
編寫程式碼的過程中,我們隊DOM結構上的div註冊事件也是一個釋出訂閱模式。釋出者DOM的div,向該釋出者訂閱了click事件,當釋出者發生了click事件後,之前訂閱的click函式都會觸發。
var div = document.getElementById('myDiv');
div.addEventListener('click', function() {
console.log('訊息通知過來了1');
});
div.addEventListener('click', function() {
console.log('訊息通知過來了2');
});
div.click();
複製程式碼
4 自定義事件
下面我們就以現實中的售樓部與客戶的關係,來實現一個釋出-訂閱模式的例子。
- 有一個售樓部物件,擁有存放客戶資訊的倉庫,擁有釋出訊息的方法
- 不同的使用者向售樓部訂閱不同的訊息
- 售樓部在適當的時候釋出訊息
var saleOffices = {
// 使用者資料中心
customerDatas: {},
// type:訂閱型別, messageFunc:使用者感興趣的內容
subscribe: function(type, messageFunc) {
// 如果沒有訂閱過,則在資料中心是不存在的,需要為其建立一個存放訂閱內容
var customerData = this.customerDatas[type];
if (!customerData) {
this.customerDatas[type] = [];
}
// 訂閱的訊息新增到訊息快取列表
this.customerDatas[type].push(messageFunc);
},
notify: function() {
// 獲取訊息型別
var type = [].shift.call(arguments);
// 取出訊息型別的訊息集合
var funcs = this.customerDatas[type];
// 不存在的訊息返回
if (!funcs || funcs.length === 0) {
return false;
}
// 存在則呼叫
funcs.map(function(fn) {
fn.apply(this, arguments);
})
}
};
// 使用者1:訂閱
saleOffices.subscribe('squareMeter88', function(price) {
console.log('88平米的價格:' + price);
});
// 使用者2:訂閱
saleOffices.subscribe('squareMeter120', function(price) {
console.log('120平米的價格:' + price);
});
// 釋出者釋出
// 使用者1才能收到
saleOffices.notify('squareMeter88', 8000);
// 使用者2才能收到
saleOffices.notify('squareMeter120', 9000);
複製程式碼
5 釋出訂閱的通用實現
上例中,加入客戶1不僅去售樓部A出訂閱了,也去售樓部B處訂閱了訊息。那麼相當於我們需要給售樓部B處也寫一個釋出訂閱功能。那麼如何使售樓部都能夠使用呢?
- 提取釋出-訂閱方法
- 擁有售樓部類,建立售樓部A,售樓部B
- 在售樓部A處訂閱訊息,在售樓部B處訂閱訊息
// 提取觀察訂閱方法
var observer = {
// 使用者資料中心
customerDatas: {},
// type:訂閱型別, messageFunc:使用者感興趣的內容
subscribe: function(type, messageFunc) {
// 如果沒有訂閱過,則在資料中心是不存在的,需要為其建立一個存放訂閱內容
var customerData = this.customerDatas[type];
if (!customerData) {
this.customerDatas[type] = [];
}
// 訂閱的訊息新增到訊息快取列表
this.customerDatas[type].push(messageFunc);
},
notify: function() {
// 獲取訊息型別
var type = [].shift.call(arguments);
var params = arguments;
// 取出訊息型別的訊息集合
var funcs = this.customerDatas[type];
// 不存在的訊息返回
if (!funcs || funcs.length === 0) {
return false;
}
// 存在則呼叫
funcs.map(function(fn) {
fn.apply(this, params);
})
},
// 訂閱了的內容,可以取消
remove: function(type, fn) {
var fns = this.customerDatas[type];
// 如果不存在訂閱內容返回
if (!fns || fns.length === 0) {
return false;
}
// 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
if (!fn && fns) {
fns.length = 0;
return;
}
fns.map(function(_fn, index) {
if (fn === _fn) {
fns.splice(index, 1)
}
});
}
};
// 建立售樓部類
var SaleOffice = function() {
return Object.create(observer);
}
// 售樓部1 訂閱訊息
var saleOfficeA = SaleOffice();
saleOfficeA.subscribe('squareMeter88', f1 = function(price) {
console.log('88平米的價格:' + price);
});
// 售樓部2 訂閱訊息
var saleOfficeB = SaleOffice();
saleOfficeB.subscribe('squareMeter120', f2 = function(price) {
console.log('120平米的價格:' + price);
});
// 售樓部A通知,售樓部B通知 都能夠收到
saleOfficeA.notify('squareMeter88', 8000);
saleOfficeB.notify('squareMeter120', 9000);
// 移除訂閱
saleOfficeA.remove('squareMeter88', f1);
// 售樓部A的通知不能收到
saleOfficeA.notify('squareMeter88', 8000);
saleOfficeB.notify('squareMeter120', 9000);
複製程式碼
6 全域性的釋出-訂閱模式
剛剛的釋出訂閱模式中,我們給售樓處物件和登入物件都新增了訂閱和釋出的功能。但是還有2個小問題:
- 我們給每一個售樓部都新增了subscrible和notify方法,以及一個快取customerDatas物件。這其實是一種資源浪費
- 購買者與售樓部有一定的耦合,購買者必須要知道售樓部,售樓部也必須要知道購買者,才能順利訂閱事件
現實中,我們一般不回去售樓部,我們會把訂閱事件的請求交給中介公司,而不同的售樓部會將資訊釋出在中介公司。這樣就消除了購買者與售樓部的耦合關係。只需要購買者與售樓部知道中介公司就可以了。
因此釋出-訂閱模式可以使用一個Observer全域性
的物件來實現。訂閱者不需要知道是哪個釋出者,釋出者也不知道訊息會推送給哪些訂閱者。Observer物件
作為中介
的角色,把釋出者和訂閱者關聯起來。
var Observer = (function() {
// 使用者資料中心
var customerDatas = {};
// 訂閱
function subscribe(type, fn) {
if (!(type in customerDatas)) {
customerDatas[type] = [];
}
customerDatas[type].push(fn);
}
// 釋出
function notify() {
var type = [].shift.call(arguments);
var param = arguments;
var fns = customerDatas[type];
if(fns.length === 0) {
return false;
}
fns.map(function(fn) {
fn.apply(this, param);
});
}
function remove(type, fn) {
var fns = this.customerDatas[type];
// 如果不存在訂閱內容返回
if (!fns || fns.length === 0) {
return false;
}
// 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
if (!fn && fns) {
fns.length = 0;
return;
}
fns.map(function(_fn, index) {
if (fn === _fn) {
fns.splice(index, 1)
}
});
}
return {
subscribe: subscribe,
notify: notify,
remove: remove
};
})();
// 使用者1 註冊
Observer.subscribe('squareMeter88', f1 = function(price, tel) {
console.log(tel + '使用者您好:' + '88平米的價格:' + price);
});
Observer.subscribe('squareMeter120', f2 = function(price, tel) {
console.log(tel + '使用者您好:' + '120平米的價格:' + price);
});
// 中介通知
Observer.notify('squareMeter88', 8000, 18784578444);
Observer.notify('squareMeter120', 9000, 18784578444);
複製程式碼
7 網站登入-釋出訂閱模式
假如我們正在開發一個商城網站,網站有header頭部,nav導航,訊息列表,購物車等等。而這幾個模組的渲染有一個共同的條件:登入使用者的資訊(使用者名稱,暱稱等)。而使用者資訊需要通過ajax請求獲取結果。然後將資訊填寫到各個模組中。
var getUserInfo = function() {
$.ajax('url', function(data) {
header.setAvata(data.avata); // 設定header的頭像
nav.setAvata(data.avata); // 設定導航模組的頭像
message.refresh(); // 重新整理訊息
cart.refresh(); // 重新整理購物車
});
};
複製程式碼
各個模組的設定都依賴於使用者資訊的獲取,導致模組與使用者資訊產生了強烈的耦合性。
假如專案中還要新增一個收貨地址模組。也需要在使用者資訊獲取後重新整理。僱員A負責獲取使用者資訊,僱員B負責收貨地址模組,此時僱員B就會找僱員A又去修改使用者獲取模組。而不願B也需要新增收貨地址模組程式碼
var getUserInfo = function() {
$.ajax('url', function(data) {
header.setAvata(data.avata);
nav.setAvata(data.avata);
message.refresh();
cart.refresh();
// 僱員A:為了收貨地址模組需要修改程式碼
address.refresh();
});
};
// 僱員B需要新增收貨地址模組程式碼
var address = (function() {
function refresh() {
console.log('地址模組完成');
}
return {
refresh: refresh
};
})();
複製程式碼
假如後面還需要新增其他模組,也需要依賴於使用者資訊,那麼僱員A就會發脾氣了:“為什麼你們的模組老是讓我來修改,增加我的工作量”。那麼此時僱員A就思考,是不是我的程式碼有問題?因此就想到了重構程式碼。這時他引用了釋出訂閱模式。我只管告知你們,我登入成功了,你們愛做什麼做什麼。我不再管你們的業務了,自己玩去。而其他模組收到訊息後就處理自己的業務。
var Login = (function() {
// 存放所有的訂閱事件
var registerFunc = {};
// 釋出
function notify(userInfo) {
// 遍歷存放在Login中的訂閱事件,依次觸發
for ( var type in registerFunc) {
var fns = registerFunc[type];
if(!fns || fns.length === 0) {
return false;
}
fns.map(function(fn) {
fn.call(this, userInfo);
});
}
}
// 訂閱
function subscribe(type, fn) {
if (!(type in registerFunc)) {
registerFunc[type] = [];
}
registerFunc[type].push(fn);
}
// 獲取使用者資訊後釋出訊息
function getUserInfo() {
$.ajax('url', function(data) {
notify({avata: 'yezi'});
});
}
return {
getUserInfo: getUserInfo,
subscribe: subscribe
};
})();
// 地址模組
var address = (function() {
function refresh() {
console.log('地址模組完成');
}
// 訂閱
Login.subscribe('address', refresh);
})();
var header = (function() {
function setAvata() {
console.log('頭部頭像完成');
}
// 訂閱
Login.subscribe('header', setAvata);
})();
// 登入
Login.getUserInfo();
複製程式碼
8 模組之間的通訊
根據第7小節中編寫的中介Observer物件,其實可以提取出來作為一個完整的釋出訂閱物件。例如頁面上有兩個模組,點選A模組的按鈕後B模組的內容進行修改。
- B模組向觀察者(Observer)訂閱事件:如果A模組的count修改則通知自己count的內容,B會顯示count內容
- A模組通過觀察者(Observer)釋出訊息:我的count修改了,你幫我通知一下訂閱的模組
<html>
<body>
<body>
<button id="count">點我</button>
<div id="show"></div>
<script>
var Observer = (function() {
// ...和上面的一樣
return {
subscribe: subscribe,
notify: notify,
remove: remove
};
})();
var Button = (function() {
var count = 0;
var el = document.getElementById('count');
el.onclick = function() {
Observer.notify('addCount', count++);
}
})();
var Div = (function() {
var el = document.getElementById('show');
Observer.subscribe('addCount', function(count) {
el.innerHTML = count;
});
})();
</script>
</body>
</html>
複製程式碼
這裡我們需要注意一點:如果模組與模組之間使用太多的釋出訂閱模式,那麼模組與模組之間的聯絡被隱藏,最終我們自己都會搞不清楚訊息來自哪個模組,會在維護的時候帶來麻煩。
9 先發布再訂閱
一直以來我們都是通過先訂閱後釋出,然後訊息都會傳送出來,但是如果我們先發布後訂閱那麼由於沒有人訂閱它,這條訊息就會石沉大海。
某些情況下,我們需要先將訊息保留下來,等有物件訂閱它,再重新將訊息傳送給訂閱者。
- 就像QQ訊息在離線時,訊息被保留在伺服器,接收人下次登入上線則可以重新收到這條訊息。
- 就像商城登入後需要渲染導航模組的使用者圖片。假如出現導航還沒渲染完畢,而使用者ajax資料已經返回,那麼導航模組就無法渲染圖片。因此需要建立一個離線事件的堆疊,將事件釋出時,將沒有訂閱者的事件的動作包裹在一個函式中,然後將包裹的函式放入堆疊,等到有物件來訂閱事件,依次遍歷堆疊並且依次執行這些包裝的函式,也就是重新發布里面的事件。當然離線事件的生命週期只有一次,就像QQ未讀訊息紙杯重新閱讀一次。
var Observer = (function() {
// 使用者資料中心
var customerDatas = {};
// 存放已釋出但是未訂閱的事件
var enquene = {};
// 訂閱
function subscribe(type, fn) {
if (!(type in customerDatas)) {
customerDatas[type] = [];
}
customerDatas[type].push(fn);
// 獲取該型別未真正釋出的事件佇列
var noNotifyParanms = enquene[type];
// 如果已經存在訂閱未釋出,需要重新發布,並從未釋出佇列中移除
if (noNotifyParanms && noNotifyParanms.length > 0) {
noNotifyParanms.map(function(noNotifyParam) {
fn.apply(this, noNotifyParam);
});
// 移除在為訂閱事件佇列中的事件
delete enquene[type];
}
}
// 釋出
function notify() {
var type = [].shift.call(arguments);
var param = arguments;
var fns = customerDatas[type];
// 如果釋出時候訂閱數為0,則按照訂閱型別放入未訂閱事件佇列,並直接返回
if(!fns || fns.length === 0) {
enquene[type] = enquene[type] || [];
enquene[type].push(param);
return false;
}
fns.map(function(fn) {
fn.apply(this, param);
});
}
function remove(type, fn) {
var fns = this.customerDatas[type];
// 如果不存在訂閱內容返回
if (!fns || fns.length === 0) {
return false;
}
// 如果沒有傳入具體的需要退訂的內容,則取消所有該型別的訂閱
if (!fn && fns) {
fns.length = 0;
return;
}
fns.map(function(_fn, index) {
if (fn === _fn) {
fns.splice(index, 1)
}
});
}
return {
subscribe: subscribe,
notify: notify,
remove: remove
};
})();
// 先發布
Observer.notify('add', 10);
Observer.notify('add', 20);
// 訂閱,會收到前兩次的通知
Observer.subscribe('add', function(num) {
console.log('訂閱的value:' + num);
});
複製程式碼
10 全域性事件的命名衝突
全域性的釋出-訂閱物件裡只有一個customerDatas來存放訊息名和回撥函式,大家都通過它來訂閱和釋出各種訊息,久而久之,難免會出現事件名衝突的情況。因此可以給Observer物件提供建立名稱空間的功能。
var Observer = (function() {
var _default = 'default';
var Event = (function() {
// 快取名稱空間物件
var namespaceCache = [];
var _listener = function(key, fn, cache) {
if (!(key in cache)) {
cache[key] = [];
}
cache[key].push(fn);
}
var _remove = function(key, cache, fn) {
if (!(key in cache)) {
// 如果移除fn沒有傳入,則移除所有該key型別的訂閱
if (fn) {
cache[key] = cache[key].filter(function(_fn) {
return _fn != fn;
})
} else {
cache[key] = [];
}
}
}
var _notify = function() {
var cache = [].shift.call(arguments);
var key = [].shift.call(arguments);
var args = arguments;
var stack = cache[key];
if (!stack || stack.length === 0) {
return;
}
return stack.map(function(fn) {
fn.apply(this, args);
});
}
var _create = function(nameSpace) {
var nameSpace = nameSpace || _default;
// 快取註冊的事件
var cache = [];
// 離線事件
var offlineStack = [], ret;
if (!(nameSpace in namespaceCache)) {
ret = {
// 訂閱事件
subscribe: function(key, fn, last) {
// 基本的訂閱事件
_listener(key, fn, cache);
// 離線事件不存在返回
if (offlineStack === null) {
return;
}
// 執行最後一個離線事件
if (last === 'last') {
offlineStack.length && offlineStack.pop()();
} else {
// 執行所有的離線事件
offlineStack.map(function(_fn) {
_fn();
});
}
// 清空離線事件
offlineStack = null;
},
// 移除訂閱
remove: function(key, fn) {
_remove(key, cache, fn);
},
// 釋出
notify: function() {
var _self = this;
// 將註冊的所有事件cache插入到引數前面
[].unshift.call(arguments, cache);
var args = arguments;
var fn = function() {
return _notify.apply(_self, args);
}
// 如果是被訂閱了的,則離線offlineStack物件等於null。如果沒有訂閱則offlineStack為[]
if (offlineStack) {
return offlineStack.push(fn);
}
return fn();
}
};
namespaceCache[nameSpace] = ret;
}
return namespaceCache[nameSpace];
}
return {
create: _create,
remove: function() {
var event = this.create();
event.remove(key, fn);
},
subscribe: function(key, fn, last) {
var event = this.create();
event.subscribe(key, fn, last);
},
notify: function() {
var event = this.create();
event.remove.apply(this, arguments);
}
};
})();
return Event;
})();
// 定義namespace1, 先訂閱再發布
var a = Observer.create('nameSpace1');
a.subscribe('onclick', function(value) {
console.log('nameSpace1: ' + value);
});
a.notify('onclick', 20);
// 定義namespace2, 先發布再訂閱
var b = Observer.create('nameSpace2');
b.notify('onclick', 10);
b.notify('onclick', 30);
b.subscribe('onclick', function(value) {
console.log('nameSpace2: ' + value);
});
複製程式碼
11 小結
在java語言中實現釋出訂閱模式,需要將訂閱者物件自身當成引用傳入到釋出物件中,同時訂閱者需要提供一個諸如update的方法,供釋出者物件在適當的時候呼叫。javascript中,使用註冊回撥函式的形式代替傳統的釋出定語模式,更簡潔優雅。
在javascript中不需要選擇推模型
還是拉模型
。一般都是推模型
。
- 推模型指當事件發生,釋出者一次性將所有的改變的狀態和資料都推送給訂閱者
- 拉模型:釋出者僅僅只通知訂閱事件已經發生了,此外發布者需要提供一些公開的介面供訂閱者主動拉去資料。好處在於
按需獲取
,但是會讓釋出者變成一個門戶大開
的物件,同時增加程式碼量和複雜度
釋出訂閱模式的缺點: - 建立訂閱者需要消耗一定的時間和記憶體(當訂閱一個訊息後,也許訊息從未發生,但是訂閱者始終都存在於記憶體中)
- 弱化了定於這與建立者之間的關係,但是如果過度使用的話,物件與物件之間的關係也將被掩蓋,難以維護跟蹤。