5 發(fā)布訂閱模式(觀察者模式)
發(fā)布訂閱模式定義對象間的一種一對多的依賴關(guān)系,當(dāng)一個(gè)對象的狀態(tài)發(fā)生改變時(shí),所有依賴于它的對象都將得到通知;
5.1 發(fā)布- 訂閱模式的作用
發(fā)布—訂閱模式的應(yīng)用都非常之廣泛,首先看一個(gè)現(xiàn)實(shí)中的例子,小明最近在看房子,到了某個(gè)售樓處之后才被告知,該樓盤的房子早已售罄,小明離開之前,把電話號碼留在了售樓處。售樓處答應(yīng)他,新樓盤一推出就馬上發(fā)信息通知小明。小紅、小強(qiáng)和小龍也是一樣,他們的電話號碼都被記在售樓處的花名冊上,新樓盤推出的時(shí)候,售樓 MM 會翻開花名冊,遍歷上面的電話號碼,依次發(fā)送一條短信來通知他們;
在這個(gè)例子中使用發(fā)布—訂閱模式有著顯而易見的優(yōu)點(diǎn):
- 購房者不用再天天給售樓處打電話咨詢開售時(shí)間,在合適的時(shí)間點(diǎn),售樓處作為發(fā)布者會通知這些消息訂閱者。這點(diǎn)說明發(fā)布—訂閱模式可以廣泛應(yīng)用于異步編程中,這是一種替代傳遞回調(diào)函數(shù)的方案;
- 購房者和售樓處之間不再強(qiáng)耦合在一起,當(dāng)有新的購房者出現(xiàn)時(shí),他只需把手機(jī)號碼留在售樓處,售樓處不關(guān)心購房者的任何情況,同時(shí)售樓處的任何變動也不會影響購買者,只要售樓處記得發(fā)短信這件事情。這點(diǎn)說明發(fā)布—訂閱模式可以取代對象之間硬編碼的通知機(jī)制,一個(gè)對象不用再顯式地調(diào)用另外一個(gè)對象的某個(gè)接口。發(fā)布—訂閱模式讓兩個(gè)對象松耦合地聯(lián)系在一起,雖然不太清楚彼此的細(xì)節(jié),但這不影響它們之間相互通信。當(dāng)有新的訂閱者出現(xiàn)時(shí),發(fā)布者的代碼不需要任何修改;同樣發(fā)布者需要改變時(shí),也不會影響到之前的訂閱者。只要之前約定的事件名沒有變化,就可以自由地改變它們。
5.2 發(fā)布訂閱模式的實(shí)現(xiàn)
- 實(shí)現(xiàn)上述發(fā)布訂閱模式實(shí)例步驟:
- 首先要指定好誰充當(dāng)發(fā)布者(比如售樓處);
- 然后給發(fā)布者添加一個(gè)緩存列表,用于存放回調(diào)函數(shù)以便通知訂閱者(售樓處的花名冊);
- 最后發(fā)布消息的時(shí)候,發(fā)布者會遍歷這個(gè)緩存列表,依次觸發(fā)里面存放的訂閱者回調(diào)函數(shù)(遍歷花名冊,挨個(gè)發(fā)短信)。
var salesOffices = {}; // 定義售樓處
salesOffices.clientList = []; // 緩存列表,存放訂閱者的回調(diào)函數(shù)
salesOffices.listen = function( fn ){ // 增加訂閱者
this.clientList.push( fn ); // 訂閱的消息添加進(jìn)緩存列表
};
salesOffices.trigger = function(){ // 發(fā)布消息
for( var i = 0, fn; fn = this.clientList[ i++ ]; ){
f n.apply( this, arguments ); // (2) // arguments 是發(fā)布消息時(shí)帶上的參數(shù)
}
};
// 測試數(shù)據(jù)
salesOffices.listen( function( price, squareMeter ){ // 小明訂閱消息
console.log( '價(jià)格= ' + price );
console.log( 'squareMeter= ' + squareMeter );
});
salesOffices.listen( function( price, squareMeter ){ // 小紅訂閱消息
console.log( '價(jià)格= ' + price );
console.log( 'squareMeter= ' + squareMeter );
});
salesOffices.trigger( 2000000, 88 ); // 輸出: 200 萬, 88 平方米
salesOffices.trigger( 3000000, 110 ); // 輸出: 300 萬, 110 平方米
- 發(fā)布訂閱模式的通用實(shí)現(xiàn)
// 第一步:把發(fā)布訂閱的功能提取出來,放在一個(gè)單獨(dú)的對象內(nèi)
var event = {
clientList: [],
listen: function( key, fn ){
if ( !this.clientList[ key ] ){
this.clientList[ key ] = [];
}
this.clientList[ key ].push( fn ); // 訂閱的消息添加進(jìn)緩存列表
},
trigger: function(){
var key = Array.prototype.shift.call( arguments ), // (1);
fns = this.clientList[ key ];
if ( !fns || fns.length === 0 ){ // 如果沒有綁定對應(yīng)的消息
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是 trigger 時(shí)帶上的參數(shù)
}
}
};
// 第二步:定義 installEvent 函數(shù)給所有的對象都動態(tài)安裝發(fā)布—訂閱功能
var installEvent = function( obj ){
for ( var i in event ){
obj[ i ] = event[ i ];
}
};
// 測試:給售樓處對象 salesOffices 動態(tài)增加發(fā)布—訂閱功能
var salesOffices = {};
installEvent( salesOffices );
salesOffices.listen( 'squareMeter88', function( price ){ // 小明訂閱消息
console.log( '價(jià)格= ' + price );
});
salesOffices.listen( 'squareMeter100', function( price ){ // 小紅訂閱消息
console.log( '價(jià)格= ' + price );
});
salesOffices.trigger( 'squareMeter88', 2000000 ); // 輸出: 2000000
salesOffices.trigger( 'squareMeter100', 3000000 ); // 輸出: 3000000
5.3 取消訂閱的事件
有時(shí)也許需要取消訂閱事件的功能,因此給 event 對象增加 remove 方法,如下:
event.remove = function( key, fn ){
var fns = this.clientList[ key ];
if ( !fns ){ // 如果 key 對應(yīng)的消息沒有被人訂閱,則直接返回
return false;
}
if ( !fn ){ // 如果沒有傳入具體的回調(diào)函數(shù),表示需要取消 key 對應(yīng)消息的所有訂閱
fns && ( fns.length = 0 );
}else{
for ( var l = fns.length - 1; l >=0; l-- ){ // 反向遍歷訂閱的回調(diào)函數(shù)列表
var _fn = fns[ l ];
if ( _fn === fn ){
fns.splice( l, 1 ); // 刪除訂閱者的回調(diào)函數(shù)
}
}
}
};
var salesOffices = {};
var installEvent = function( obj ){
for ( var i in event ){
obj[ i ] = event[ i ];
}
}
installEvent( salesOffices );
salesOffices.listen( 'squareMeter88', fn1 = function( price ){ // 小明訂閱消息
console.log( '價(jià)格= ' + price );
});
salesOffices.listen( 'squareMeter88', fn2 = function( price ){ // 小紅訂閱消息
console.log( '價(jià)格= ' + price );
});
salesOffices.remove( 'squareMeter88', fn1 ); // 刪除小明的訂閱
salesOffices.trigger( 'squareMeter88', 2000000 ); // 輸出: 2000000
5.4 網(wǎng)站登錄實(shí)例(P116)
5.5 全局的發(fā)布訂閱對象
剛剛實(shí)現(xiàn)的發(fā)布訂閱模式,給售樓處對象和登錄對象都添加了訂閱和發(fā)布的功能,這里還存在兩個(gè)小問題:
- 給每個(gè)發(fā)布者對象都添加了
listen和trigger方法,以及一個(gè)緩存列表clientList,這其實(shí)是一種資源浪費(fèi); - 訂閱者跟售樓處對象還是存在一定的耦合性,訂閱者至少要知道售樓處對象的名字是
salesOffices,才能順利的訂閱到事件;
實(shí)際上,訂閱者沒必要親自去售樓處,只需要把訂閱的請求交給中介公司,而各大房產(chǎn)公司也只需要通過中介公司來發(fā)布房子信息。為了保證訂閱者和發(fā)布者能順利通信,訂閱者和發(fā)布者都必須知道這個(gè)中介公司。因此,發(fā)布—訂閱模式可以用一個(gè)全局的 Event 對象來實(shí)現(xiàn),訂閱者不需要了解消息來自哪個(gè)發(fā)布者,發(fā)布者也不知道消息會推送給哪些訂閱者, Event 作為一個(gè)類似“中介者”的角色,把訂閱者和發(fā)布者聯(lián)系起來。見如下代碼:
var Event = (function(){
var clientList = {}, listen, trigger, remove;
listen = function( key, fn ){
if ( !clientList[ key ] ){
clientList[ key ] = [];
}
clientList[ key ].push( fn );
};
trigger = function(){
var key = Array.prototype.shift.call( arguments ),
fns = clientList[ key ];
if ( !fns || fns.length === 0 ){
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments );
}
};
remove = function( key, fn ){
var fns = clientList[ key ];
if ( !fns ){
return false;
}
if ( !fn ){
fns && ( fns.length = 0 );
}else{
for ( var l = fns.length - 1; l >=0; l-- ){
var _fn = fns[ l ];
if ( _fn === fn ){
fns.splice( l, 1 );
}
}
}
};
return {
listen: listen,
trigger: trigger,
remove: remove
}
})();
Event.listen( 'squareMeter88', function( price ){ // 小紅訂閱消息
console.log( '價(jià)格= ' + price ); // 輸出: '價(jià)格=2000000'
});
Event.trigger( 'squareMeter88', 2000000 ); // 售樓處發(fā)布消息
5.6 模塊間通信
我們利用上一節(jié)中實(shí)現(xiàn)的發(fā)布訂閱模式的全局 Event 對象可以在兩個(gè)封裝良好的模塊中進(jìn)行通信,而這兩個(gè)模塊可以完全不知道對方的存在。比如現(xiàn)在有兩個(gè)模塊, a 模塊里面有一個(gè)按鈕,每次點(diǎn)擊按鈕之后, b 模塊里的 div 中會顯示按鈕的總點(diǎn)擊次數(shù),我們用全局發(fā)布—訂閱模式完成下面的代碼,使得 a 模塊和 b 模塊可以在保持封裝性的前提下進(jìn)行通信。
<!DOCTYPE html>
<html>
<body>
<button id="count">點(diǎn)我</button>
<div id="show"></div>
</body>
<script type="text/JavaScript">
var a = (function(){
var count = 0;
var button = document.getElementById( 'count' );
button.onclick = function(){
Event.trigger( 'add', count++ );
}
})();
var b = (function(){
var div = document.getElementById( 'show' );
Event.listen( 'add', function( count ){
div.innerHTML = count;
});
})();
</script>
</html>
5.7 JavaScript 實(shí)現(xiàn)發(fā)布訂閱模式的便利性
在 JavaScript 中,無需去選擇使用推模型還是拉模型。推模型是指在事件發(fā)生時(shí),發(fā)布者一次性把所有更改的狀態(tài)和數(shù)據(jù)都推送給訂閱者。拉模型不同的地方是,發(fā)布者僅僅通知訂閱者事件已經(jīng)發(fā)生了,此外發(fā)布者要提供一些公開的接口供訂閱者來主動拉取數(shù)據(jù)。拉模型的好處是可以讓訂閱者“按需獲取”,但同時(shí)有可能讓發(fā)布者變成一個(gè)“門戶大開”的對象,同時(shí)增加了代碼量和復(fù)雜度。剛好在 JavaScript 中, arguments 可以很方便地表示參數(shù)列表,所以我們一般都會選擇推模型,使用 Function.prototype.apply 方法把所有參數(shù)都推送給訂閱者。
5.8 發(fā)布訂閱模式小結(jié)
發(fā)布訂閱模式是一種非常重要的模式,在實(shí)際開發(fā)中非常有用。既可以用在異步編程中,也可以幫助完成更松耦合的代碼編寫。發(fā)布訂閱模式還可以用來幫助實(shí)現(xiàn)一些別的設(shè)計(jì)模式,比如中介者模式。 從架構(gòu)上來看,無論是 MVC 還是 MVVM,都少不了發(fā)布—訂閱模式的參與,而且 JavaScript 本身也是一門基于事件驅(qū)動的語言。
發(fā)布訂閱模式的優(yōu)點(diǎn)一為時(shí)間上的解耦,二為對象之間的解耦。發(fā)布訂閱模式缺點(diǎn)就是創(chuàng)建訂閱者本身要消耗一定的時(shí)間和內(nèi)存,若訂閱一個(gè)消息后,也許此消息最后都未發(fā)生,但這個(gè)訂閱者會始終存在于內(nèi)存中;若過度使用的話,對象和對象之間的必要聯(lián)系也將被深埋在背后,會導(dǎo)致程序難以跟蹤維護(hù)和理解。
系列鏈接
- JavaScript 設(shè)計(jì)模式(上)——基礎(chǔ)知識
- JavaScript 設(shè)計(jì)模式(中)——1.單例模式
- JavaScript 設(shè)計(jì)模式(中)——2.策略模式
- JavaScript 設(shè)計(jì)模式(中)——3.代理模式
- JavaScript 設(shè)計(jì)模式(中)——4.迭代器模式
- JavaScript 設(shè)計(jì)模式(中)——5.發(fā)布訂閱模式
- JavaScript 設(shè)計(jì)模式(中)——6.命令模式
- JavaScript 設(shè)計(jì)模式(中)——7.組合模式
- JavaScript 設(shè)計(jì)模式(中)——8.模板方法模式
- JavaScript 設(shè)計(jì)模式(中)——9.享元模式
- JavaScript 設(shè)計(jì)模式(中)——10.職責(zé)鏈模式
- JavaScript 設(shè)計(jì)模式(中)——11. 中介者模式
- JavaScript 設(shè)計(jì)模式(中)——12. 裝飾者模式
- JavaScript 設(shè)計(jì)模式(中)——13.狀態(tài)模式
- JavaScript 設(shè)計(jì)模式(中)——14.適配器模式
- JavaScript 設(shè)計(jì)模式(下)——設(shè)計(jì)原則
- JavaScript 設(shè)計(jì)模式練習(xí)代碼