發(fā)布—訂閱模式又叫觀察者模式笋敞,它定義對(duì)象間的一種一對(duì)多的依賴關(guān)系观挎,當(dāng)一個(gè)對(duì)象的狀態(tài)發(fā)生改變時(shí),所有依賴于它的對(duì)象都將得到通知役衡。在JavaScript開發(fā)中,我們一般用事件模型來(lái)替代傳統(tǒng)的發(fā)布—訂閱模式薪棒。
現(xiàn)實(shí)中的發(fā)布-訂閱模式
不論是在程序世界里還是現(xiàn)實(shí)生活中手蝎,發(fā)布—訂閱模式的應(yīng)用都非常之廣泛。我們先看一個(gè)現(xiàn)實(shí)中的例子。
小明最近看上了一套房子,到了售樓處之后才被告知忠寻,該樓盤的房子早已售罄排嫌。好在售樓MM告訴小明,不久后還有一些尾盤推出千扶,開發(fā)商正在辦理相關(guān)手續(xù),手續(xù)辦好后便可以購(gòu)買。但到底是什么時(shí)候吨述,目前還沒有人能夠知道。
于是小明記下了售樓處的電話钞脂,以后每天都會(huì)打電話過(guò)去詢問(wèn)是不是已經(jīng)到了購(gòu)買時(shí)間揣云。除了小明,還有小紅冰啃、小強(qiáng)邓夕、小龍也會(huì)每天向售樓處咨詢這個(gè)問(wèn)題。一個(gè)星期過(guò)后阎毅,售樓MM決定辭職焚刚,因?yàn)閰捑肓嗣刻旎卮?000個(gè)相同內(nèi)容的電話。
當(dāng)然現(xiàn)實(shí)中沒有這么笨的銷售公司扇调,實(shí)際上故事是這樣的:小明離開之前矿咕,把電話號(hào)碼留在了售樓處。售樓MM答應(yīng)他狼钮,新樓盤一推出就馬上發(fā)信息通知小明痴腌。小紅、小強(qiáng)和小龍也是一樣燃领,他們的電話號(hào)碼都被記在售樓處的花名冊(cè)上士聪,新樓盤推出的時(shí)候,售樓MM會(huì)翻開花名冊(cè)猛蔽,遍歷上面的電話號(hào)碼剥悟,依次發(fā)送一條短信來(lái)通知他們灵寺。
發(fā)布-訂閱模式的作用
在剛剛的例子中,發(fā)送短信通知就是一個(gè)典型的發(fā)布—訂閱模式区岗,小明略板、小紅等購(gòu)買者都是訂閱者,他們訂閱了房子開售的消息慈缔。售樓處作為發(fā)布者叮称,會(huì)在合適的時(shí)候遍歷花名冊(cè)上的電話號(hào)碼,依次給購(gòu)房者發(fā)布消息藐鹤。
可以發(fā)現(xiàn)瓤檐,在這個(gè)例子中使用發(fā)布—訂閱模式有著顯而易見的優(yōu)點(diǎn)。
購(gòu)房者不用再天天給售樓處打電話咨詢開售時(shí)間娱节,在合適的時(shí)間點(diǎn)挠蛉,售樓處作為發(fā)布者會(huì)通知這些消息訂閱者。
購(gòu)房者和售樓處之間不再?gòu)?qiáng)耦合在一起肄满,當(dāng)有新的購(gòu)房者出現(xiàn)時(shí)谴古,他只需把手機(jī)號(hào)碼留在售樓處,售樓處不關(guān)心購(gòu)房者的任何情況稠歉,不管購(gòu)房者是男是女還是一只猴子掰担。 而售樓處的任何變動(dòng)也不會(huì)影響購(gòu)買者,比如售樓MM離職怒炸,售樓處從一樓搬到二樓恩敌,這些改變都跟購(gòu)房者無(wú)關(guān),只要售樓處記得發(fā)短信這件事情横媚。
第一點(diǎn)說(shuō)明發(fā)布—訂閱模式可以廣泛應(yīng)用于異步編程中纠炮,這是一種替代傳遞回調(diào)函數(shù)的方案。比如灯蝴,我們可以訂閱ajax請(qǐng)求的error恢口、succ等事件。 或者如果想在動(dòng)畫的每一幀完成之后做一些事情穷躁,那我們可以訂閱一個(gè)事件耕肩,然后在動(dòng)畫的每一幀完成之后發(fā)布這個(gè)事件。在異步編程中使用發(fā)布—訂閱模式问潭,我們就無(wú)需過(guò)多關(guān)注對(duì)象在異步運(yùn)行期間的內(nèi)部狀態(tài)猿诸,而只需要訂閱感興趣的事件發(fā)生點(diǎn)。
第二點(diǎn)說(shuō)明發(fā)布—訂閱模式可以取代對(duì)象之間硬編碼的通知機(jī)制狡忙,一個(gè)對(duì)象不用再顯式地調(diào)用另外一個(gè)對(duì)象的某個(gè)接口梳虽。發(fā)布—訂閱模式讓兩個(gè)對(duì)象松耦合地聯(lián)系在一起,雖然不太清楚彼此的細(xì)節(jié)灾茁,但這不影響它們之間相互通信窜觉。當(dāng)有新的訂閱者出現(xiàn)時(shí)谷炸,發(fā)布者的代碼不需要任何修改;同樣發(fā)布者需要改變時(shí)禀挫,也不會(huì)影響到之前的訂閱者旬陡。只要之前約定的事件名沒有變化,就可以自由地改變它們语婴。
DOM事件
實(shí)際上描孟,只要我們?cè)?jīng)在DOM節(jié)點(diǎn)上面綁定過(guò)事件函數(shù),那我們就曾經(jīng)使用過(guò)發(fā)布—訂閱模式砰左,來(lái)看看下面這兩句簡(jiǎn)單的代碼發(fā)生了什么事情:
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.click(); // 模擬用戶點(diǎn)擊
在這里需要監(jiān)控用戶點(diǎn)擊document.body的動(dòng)作匿醒,但是我們沒辦法預(yù)知用戶將在什么時(shí)候點(diǎn)擊。所以我們訂閱document.body上的click事件菜职,當(dāng)body節(jié)點(diǎn)被點(diǎn)擊時(shí),body節(jié)點(diǎn)便會(huì)向訂閱者發(fā)布這個(gè)消息旗闽。這很像購(gòu)房的例子酬核,購(gòu)房者不知道房子什么時(shí)候開售,于是他在訂閱消息后等待售樓處發(fā)布消息适室。
當(dāng)然我們還可以隨意增加或者刪除訂閱者嫡意,增加任何訂閱者都不會(huì)影響發(fā)布者代碼的編寫:
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.addEventListener( 'click', function(){
alert(3);
}, false );
document.body.addEventListener( 'click', function(){
alert(4);
}, false );
document.body.click(); // 模擬用戶點(diǎn)擊
注意,手動(dòng)觸發(fā)事件更好的做法是IE下用fireEvent捣辆,標(biāo)準(zhǔn)瀏覽器下用dispatchEvent實(shí)現(xiàn)蔬螟。
自定義事件
除了DOM事件,我們還會(huì)經(jīng)常實(shí)現(xiàn)一些自定義的事件汽畴,這種依靠自定義事件完成的發(fā)布—訂閱模式可以用于任何JavaScript代碼中旧巾。
現(xiàn)在看看如何一步步實(shí)現(xiàn)發(fā)布—訂閱模式。
首先要指定好誰(shuí)充當(dāng)發(fā)布者(比如售樓處)忍些;
然后給發(fā)布者添加一個(gè)緩存列表鲁猩,用于存放回調(diào)函數(shù)以便通知訂閱者(售樓處的花名冊(cè));
最后發(fā)布消息的時(shí)候罢坝,發(fā)布者會(huì)遍歷這個(gè)緩存列表廓握,依次觸發(fā)里面存放的訂閱者回調(diào)函數(shù)(遍歷花名冊(cè),挨個(gè)發(fā)短信)嘁酿。
另外隙券,我們還可以往回調(diào)函數(shù)里填入一些參數(shù),訂閱者可以接收這些參數(shù)闹司。這是很有必要的娱仔,比如售樓處可以在發(fā)給訂閱者的短信里加上房子的單價(jià)、面積游桩、容積率等信息拟枚,訂閱者接收到這些信息之后可以進(jìn)行各自的處理:
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++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是發(fā)布消息時(shí)帶上的參數(shù)
}
};
下面我們來(lái)進(jìn)行一些簡(jiǎn)單的測(cè)試:
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萬(wàn),88平方米
salesOffices.trigger( 3000000, 110 ); // 輸出:300萬(wàn)恩溅,110平方米
至此隔箍,我們已經(jīng)實(shí)現(xiàn)了一個(gè)最簡(jiǎn)單的發(fā)布—訂閱模式,但這里還存在一些問(wèn)題脚乡。我們看到訂閱者接收到了發(fā)布者發(fā)布的每個(gè)消息蜒滩,雖然小明只想買88平方米的房子,但是發(fā)布者把110平方米的信息也推送給了小明奶稠,這對(duì)小明來(lái)說(shuō)是不必要的困擾俯艰。所以我們有必要增加一個(gè)標(biāo)示key,讓訂閱者只訂閱自己感興趣的消息锌订。改寫后的代碼如下:
var salesOffices = {}; // 定義售樓處
salesOffices.clientList = {}; // 緩存列表竹握,存放訂閱者的回調(diào)函數(shù)
salesOffices.listen = function( key, fn ){
if ( !this.clientList[ key ] ){ // 如果還沒有訂閱過(guò)此類消息,給該類消息創(chuàng)建一個(gè)緩存列表
this.clientList[ key ] = [];
}
this.clientList[ key ].push( fn ); // 訂閱的消息添加進(jìn)消息緩存列表
};
salesOffices.trigger = function(){ // 發(fā)布消息
var key = Array.prototype.shift.call( arguments ), // 取出消息類型
fns = this.clientList[ key ]; // 取出該消息對(duì)應(yīng)的回調(diào)函數(shù)集合
if ( !fns || fns.length === 0 ){ // 如果沒有訂閱該消息辆飘,則返回
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments是發(fā)布消息時(shí)附送的參數(shù)
}
};
salesOffices.listen( 'squareMeter88', function( price ){ // 小明訂閱88平方米房子的消息
console.log( '價(jià)格= ' + price ); // 輸出: 2000000
});
salesOffices.listen( 'squareMeter110', function( price ){ // 小紅訂閱110平方米房子的消息
console.log( '價(jià)格= ' + price ); // 輸出: 3000000
});
salesOffices.trigger( 'squareMeter88', 2000000 ); // 發(fā)布88平方米房子的價(jià)格
salesOffices.trigger( 'squareMeter110', 3000000 ); // 發(fā)布110平方米房子的價(jià)格
很明顯啦辐,現(xiàn)在訂閱者可以只訂閱自己感興趣的事件了。
發(fā)布-訂閱模式的通用實(shí)現(xiàn)
現(xiàn)在我們已經(jīng)看到了如何讓售樓處擁有接受訂閱和發(fā)布事件的功能蜈项。假設(shè)現(xiàn)在小明又去另一個(gè)售樓處買房子芹关,那么這段代碼是否必須在另一個(gè)售樓處對(duì)象上重寫一次呢,有沒有辦法可以讓所有對(duì)象都擁有發(fā)布—訂閱功能呢紧卒?
答案顯然是有的侥衬,JavaScript作為一門解釋執(zhí)行的語(yǔ)言,給對(duì)象動(dòng)態(tài)添加職責(zé)是理所當(dāng)然的事情跑芳。
所以我們把發(fā)布—訂閱的功能提取出來(lái)轴总,放在一個(gè)單獨(dú)的對(duì)象內(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 ){ // 如果沒有綁定對(duì)應(yīng)的消息
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是trigger時(shí)帶上的參數(shù)
}
}
};
再定義一個(gè)installEvent函數(shù),這個(gè)函數(shù)可以給所有的對(duì)象都動(dòng)態(tài)安裝發(fā)布—訂閱功能:
var installEvent = function( obj ){
for ( var i in event ){
obj[ i ] = event[ i ];
}
};
再來(lái)測(cè)試一番博个,我們給售樓處對(duì)象salesOffices動(dòng)態(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
取消訂閱的事件
有時(shí)候肘习,我們也許需要取消訂閱事件的功能。比如小明突然不想買房子了坡倔,為了避免繼續(xù)接收到售樓處推送過(guò)來(lái)的短信漂佩,小明需要取消之前訂閱的事件。現(xiàn)在我們給event對(duì)象增加remove方法:
event.remove = function( key, fn ){
var fns = this.clientList[ key ];
if ( !fns ){ // 如果key對(duì)應(yīng)的消息沒有被人訂閱罪塔,則直接返回
return false;
}
if ( !fn ){ // 如果沒有傳入具體的回調(diào)函數(shù)投蝉,表示需要取消key對(duì)應(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
真實(shí)的例子——網(wǎng)站登錄
通過(guò)售樓處的虛擬例子,我們對(duì)發(fā)布—訂閱模式的概念和實(shí)現(xiàn)都已經(jīng)熟悉了征堪,那么現(xiàn)在就趁熱打鐵瘩缆,看一個(gè)真實(shí)的項(xiàng)目。
假如我們正在開發(fā)一個(gè)商城網(wǎng)站佃蚜,網(wǎng)站里有header頭部庸娱、nav導(dǎo)航着绊、消息列表、購(gòu)物車等模塊熟尉。這幾個(gè)模塊的渲染有一個(gè)共同的前提條件归露,就是必須先用ajax異步請(qǐng)求獲取用戶的登錄信息。這是很正常的斤儿,比如用戶的名字和頭像要顯示在header模塊里剧包,而這兩個(gè)字段都來(lái)自用戶登錄后返回的信息。
至于ajax請(qǐng)求什么時(shí)候能成功返回用戶信息往果,這點(diǎn)我們沒有辦法確定〗海現(xiàn)在的情節(jié)看起來(lái)像極了售樓處的例子,小明不知道什么時(shí)候開發(fā)商的售樓手續(xù)能夠成功辦下來(lái)陕贮。
但現(xiàn)在還不足以說(shuō)服我們?cè)诖耸褂冒l(fā)布—訂閱模式堕油,因?yàn)楫惒降膯?wèn)題通常也可以用回調(diào)函數(shù)來(lái)解決。更重要的一點(diǎn)是肮之,我們不知道除了header頭部掉缺、nav導(dǎo)航、消息列表局骤、購(gòu)物車之外攀圈,將來(lái)還有哪些模塊需要使用這些用戶信息暴凑。如果它們和用戶信息模塊產(chǎn)生了強(qiáng)耦合峦甩,比如下面這樣的形式:
login.succ(function(data){
header.setAvatar( data.avatar); // 設(shè)置header模塊的頭像
nav.setAvatar( data.avatar ); // 設(shè)置導(dǎo)航模塊的頭像
message.refresh(); // 刷新消息列表
cart.refresh(); // 刷新購(gòu)物車列表
});
現(xiàn)在登錄模塊是我們負(fù)責(zé)編寫的,但我們還必須了解header模塊里設(shè)置頭像的方法叫setAvatar现喳、購(gòu)物車模塊里刷新的方法叫refresh凯傲,這種耦合性會(huì)使程序變得僵硬,header模塊不能隨意再改變setAvatar的方法名嗦篱,它自身的名字也不能被改為header1冰单、header2。 這是針對(duì)具體實(shí)現(xiàn)編程的典型例子灸促,針對(duì)具體實(shí)現(xiàn)編程是不被贊同的诫欠。
等到有一天,項(xiàng)目中又新增了一個(gè)收貨地址管理的模塊浴栽,這個(gè)模塊本來(lái)是另一個(gè)同事所寫的荒叼,而此時(shí)你正在馬來(lái)西亞度假,但是他卻不得不給你打電話:“Hi典鸡,登錄之后麻煩刷新一下收貨地址列表被廓。”于是你又翻開你3個(gè)月前寫的登錄模塊萝玷,在最后部分加上這行代碼:
login.succ(function( data ){
header.setAvatar( data.avatar);
nav.setAvatar( data.avatar );
message.refresh();
cart.refresh();
address.refresh(); // 增加這行代碼
});
我們就會(huì)越來(lái)越疲于應(yīng)付這些突如其來(lái)的業(yè)務(wù)要求嫁乘,要么跳槽了事昆婿,要么必須來(lái)重構(gòu)這些代碼。
用發(fā)布—訂閱模式重寫之后蜓斧,對(duì)用戶信息感興趣的業(yè)務(wù)模塊將自行訂閱登錄成功的消息事件仓蛆。當(dāng)?shù)卿洺晒r(shí),登錄模塊只需要發(fā)布登錄成功的消息法精,而業(yè)務(wù)方接受到消息之后多律,就會(huì)開始進(jìn)行各自的業(yè)務(wù)處理,登錄模塊并不關(guān)心業(yè)務(wù)方究竟要做什么搂蜓,也不想去了解它們的內(nèi)部細(xì)節(jié)狼荞。改善后的代碼如下:
$.ajax( 'http:// xxx.com?login', function(data){ // 登錄成功
login.trigger( 'loginSucc', data); // 發(fā)布登錄成功的消息
});
各模塊監(jiān)聽登錄成功的消息:
var header = (function(){ // header模塊
login.listen( 'loginSucc', function( data){
header.setAvatar( data.avatar );
});
return {
setAvatar: function( data ){
console.log( '設(shè)置header模塊的頭像' );
}
}
})();
var nav = (function(){ // nav模塊
login.listen( 'loginSucc', function( data ){
nav.setAvatar( data.avatar );
});
return {
setAvatar: function( avatar ){
console.log( '設(shè)置nav模塊的頭像' );
}
}
})();
如上所述,我們隨時(shí)可以把setAvatar的方法名改成setTouxiang帮碰。如果有一天在登錄完成之后相味,又增加一個(gè)刷新收貨地址列表的行為,那么只要在收貨地址模塊里加上監(jiān)聽消息的方法即可殉挽,而這可以讓開發(fā)該模塊的同事自己完成丰涉,你作為登錄模塊的開發(fā)者,永遠(yuǎn)不用再關(guān)心這些行為了斯碌。代碼如下:
var address = (function(){ // nav模塊
login.listen( 'loginSucc', function( obj ){
address.refresh( obj );
});
return {
refresh: function( avatar ){
console.log( '刷新收貨地址列表' );
}
}
})();
全局的發(fā)布-訂閱對(duì)象
回想下剛剛實(shí)現(xiàn)的發(fā)布—訂閱模式一死,我們給售樓處對(duì)象和登錄對(duì)象都添加了訂閱和發(fā)布的功能,這里還存在兩個(gè)小問(wèn)題傻唾。
我們給每個(gè)發(fā)布者對(duì)象都添加了listen和trigger方法投慈,以及一個(gè)緩存列表clientList,這其實(shí)是一種資源浪費(fèi)冠骄。
小明跟售樓處對(duì)象還是存在一定的耦合性伪煤,小明至少要知道售樓處對(duì)象的名字是salesOffices,才能順利的訂閱到事件凛辣。見如下代碼:
salesOffices.listen( 'squareMeter100', function( price ){ // 小明訂閱消息
console.log( '價(jià)格= ' + price );
});
如果小明還關(guān)心300平方米的房子抱既,而這套房子的賣家是salesOffices2,這意味著小明要開始訂閱salesOffices2對(duì)象扁誓。見如下代碼:
salesOffices2.listen( 'squareMeter300', function( price ){ // 小明訂閱消息
console.log( '價(jià)格= ' + price );
});
其實(shí)在現(xiàn)實(shí)中防泵,買房子未必要親自去售樓處,我們只要把訂閱的請(qǐng)求交給中介公司蝗敢,而各大房產(chǎn)公司也只需要通過(guò)中介公司來(lái)發(fā)布房子信息捷泞。這樣一來(lái),我們不用關(guān)心消息是來(lái)自哪個(gè)房產(chǎn)公司前普,我們?cè)谝獾氖悄芊耥樌盏较⒍切稀.?dāng)然,為了保證訂閱者和發(fā)布者能順利通信,訂閱者和發(fā)布者都必須知道這個(gè)中介公司骡湖。
同樣在程序中贱纠,發(fā)布—訂閱模式可以用一個(gè)全局的Event對(duì)象來(lái)實(shí)現(xiàn),訂閱者不需要了解消息來(lái)自哪個(gè)發(fā)布者响蕴,發(fā)布者也不知道消息會(huì)推送給哪些訂閱者谆焊,Event作為一個(gè)類似“中介者”的角色,把訂閱者和發(fā)布者聯(lián)系起來(lái)浦夷。見如下代碼:
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ā)布消息
模塊間通信
上面的發(fā)布—訂閱模式的實(shí)現(xiàn)辖试,是基于一個(gè)全局的Event對(duì)象,我們利用它可以在兩個(gè)封裝良好的模塊中進(jìn)行通信劈狐,這兩個(gè)模塊可以完全不知道對(duì)方的存在罐孝。就如同有了中介公司之后,我們不再需要知道房子開售的消息來(lái)自哪個(gè)售樓處肥缔。
比如現(xiàn)在有兩個(gè)模塊莲兢,a模塊里面有一個(gè)按鈕,每次點(diǎn)擊按鈕之后续膳,b模塊里的div中會(huì)顯示按鈕的總點(diǎn)擊次數(shù)改艇,我們用全局發(fā)布—訂閱模式完成下面的代碼,使得a模塊和b模塊可以在保持封裝性的前提下進(jìn)行通信坟岔。
<!DOCTYPE html>
<html>
<body>
<button id="count">點(diǎn)我</button>6
<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>
但在這里我們要留意另一個(gè)問(wèn)題谒兄,模塊之間如果用了太多的全局發(fā)布—訂閱模式來(lái)通信,那么模塊與模塊之間的聯(lián)系就被隱藏到了背后社付。我們最終會(huì)搞不清楚消息來(lái)自哪個(gè)模塊承疲,或者消息會(huì)流向哪些模塊,這又會(huì)給我們的維護(hù)帶來(lái)一些麻煩瘦穆,也許某個(gè)模塊的作用就是暴露一些接口給其他模塊調(diào)用纪隙。
必須先訂閱再發(fā)布嗎
我們所了解到的發(fā)布—訂閱模式赊豌,都是訂閱者必須先訂閱一個(gè)消息扛或,隨后才能接收到發(fā)布者發(fā)布的消息。如果把順序反過(guò)來(lái)碘饼,發(fā)布者先發(fā)布一條消息熙兔,而在此之前并沒有對(duì)象來(lái)訂閱它,這條消息無(wú)疑將消失在宇宙中艾恼。
在某些情況下住涉,我們需要先將這條消息保存下來(lái),等到有對(duì)象來(lái)訂閱它的時(shí)候钠绍,再重新把消息發(fā)布給訂閱者舆声。就如同QQ中的離線消息一樣,離線消息被保存在服務(wù)器中,接收人下次登錄上線之后媳握,可以重新收到這條消息碱屁。
這種需求在實(shí)際項(xiàng)目中是存在的,比如在之前的商城網(wǎng)站中蛾找,獲取到用戶信息之后才能渲染用戶導(dǎo)航模塊娩脾,而獲取用戶信息的操作是一個(gè)ajax異步請(qǐng)求。當(dāng)ajax請(qǐng)求成功返回之后會(huì)發(fā)布一個(gè)事件打毛,在此之前訂閱了此事件的用戶導(dǎo)航模塊可以接收到這些用戶信息柿赊。
但是這只是理想的狀況,因?yàn)楫惒降脑蚧猛鳎覀儾荒鼙WCajax請(qǐng)求返回的時(shí)間碰声,有時(shí)候它返回得比較快,而此時(shí)用戶導(dǎo)航模塊的代碼還沒有加載好(還沒有訂閱相應(yīng)事件)熬甫,特別是在用了一些模塊化惰性加載的技術(shù)后奥邮,這是很可能發(fā)生的事情。也許我們還需要一個(gè)方案罗珍,使得我們的發(fā)布—訂閱對(duì)象擁有先發(fā)布后訂閱的能力洽腺。
為了滿足這個(gè)需求,我們要建立一個(gè)存放離線事件的堆棧覆旱,當(dāng)事件發(fā)布的時(shí)候蘸朋,如果此時(shí)還沒有訂閱者來(lái)訂閱這個(gè)事件,我們暫時(shí)把發(fā)布事件的動(dòng)作包裹在一個(gè)函數(shù)里扣唱,這些包裝函數(shù)將被存入堆棧中藕坯,等到終于有對(duì)象來(lái)訂閱此事件的時(shí)候,我們將遍歷堆棧并且依次執(zhí)行這些包裝函數(shù)噪沙,也就是重新發(fā)布里面的事件炼彪。當(dāng)然離線事件的生命周期只有一次,就像QQ的未讀消息只會(huì)被重新閱讀一次正歼,所以剛才的操作我們只能進(jìn)行一次辐马。
全局事件的命名沖突
全局的發(fā)布—訂閱對(duì)象里只有一個(gè)clinetList來(lái)存放消息名和回調(diào)函數(shù),大家都通過(guò)它來(lái)訂閱和發(fā)布各種消息局义,久而久之喜爷,難免會(huì)出現(xiàn)事件名沖突的情況,所以我們還可以給Event對(duì)象提供創(chuàng)建命名空間的功能萄唇。
在提供最終的代碼之前檩帐,我們來(lái)感受一下怎么使用這兩個(gè)新增的功能。
/************** 先發(fā)布后訂閱 ********************/
Event.trigger( 'click', 1 );
Event.listen( 'click', function( a ){
console.log( a ); // 輸出:1
});
/************** 使用命名空間 ********************/
Event.create( 'namespace1' ).listen( 'click', function( a ){
console.log( a ); // 輸出:1
});
Event.create( 'namespace1' ).trigger( 'click', 1 );
Event.create( 'namespace2' ).listen( 'click', function( a ){
console.log( a ); // 輸出:2
});
Event.create( 'namespace2' ).trigger( 'click', 2 );
具體實(shí)現(xiàn)代碼如下:
var Event = (function(){
var global = this,
Event,
_default = 'default';
Event = function(){
var _listen,
_trigger,
_remove,
_slice = Array.prototype.slice,
_shift = Array.prototype.shift,
_unshift = Array.prototype.unshift,
namespaceCache = {},
_create,
find,
each = function( ary, fn ){
var ret;
for ( var i = 0, l = ary.length; i < l; i++ ){
var n = ary[i];
ret = fn.call( n, i, n);
}
return ret;
};
_listen = function( key, fn, cache ){
if ( !cache[ key ] ){
cache[ key ] = [];
}
cache[key].push( fn );
};
_remove = function( key, cache ,fn){
if ( cache[ key ] ){
if( fn ){
for( var i = cache[ key ].length; i >= 0; i-- ){
if( cache[ key ][i] === fn ){
cache[ key ].splice( i, 1 );
}
}
}else{
cache[ key ] = [];
}
}
};
_trigger = function(){
var cache = _shift.call(arguments),
key = _shift.call(arguments),
args = arguments,
_self = this,
ret,
stack = cache[ key ];
if ( !stack || !stack.length ){
return;
}
return each( stack, function(){
return this.apply( _self, args );
});
};
_create = function( namespace ){
var namespace = namespace || _default;
var cache = {},
offlineStack = [], // 離線事件
ret = {
listen: function( key, fn, last ){
_listen( key, fn, cache );
if ( offlineStack === null ){
return;
}
if ( last === 'last' ){
offlineStack.length && offlineStack.pop()();
}else{
each( offlineStack, function(){
this();
});
}
offlineStack = null;
},
one: function( key, fn, last ){
_remove( key, cache );
this.listen( key, fn ,last );
},
remove: function( key, fn ){
_remove( key, cache ,fn);
},
trigger: function(){
var fn,
args,
_self = this;
_unshift.call( arguments, cache );
args = arguments;
fn = function(){
return _trigger.apply( _self, args );
};
if ( offlineStack ){
return offlineStack.push( fn );
}
return fn();
}
};
return namespace ?
( namespaceCache[ namespace ] ? namespaceCache[ namespace ] :
namespaceCache[ namespace ] = ret )
: ret;
};
return {
create: _create,
one: function( key,fn, last ){
var event = this.create( );
event.one( key,fn,last );
},
remove: function( key,fn ){
var event = this.create( );
event.remove( key,fn );
},
listen: function( key, fn, last ){
var event = this.create( );
event.listen( key, fn, last );
},
trigger: function(){
var event = this.create( );
event.trigger.apply( this, arguments );
}
};
}();
return Event;
})();
JavaScript實(shí)現(xiàn)發(fā)布-訂閱模式的便利性
這里要提出的是另萤,我們一直討論的發(fā)布—訂閱模式湃密,跟一些別的語(yǔ)言(比如Java)中的實(shí)現(xiàn)還是有區(qū)別的。在Java中實(shí)現(xiàn)一個(gè)自己的發(fā)布—訂閱模式,通常會(huì)把訂閱者對(duì)象自身當(dāng)成引用傳入發(fā)布者對(duì)象中泛源,同時(shí)訂閱者對(duì)象還需提供一個(gè)名為諸如update的方法揍障,供發(fā)布者對(duì)象在適合的時(shí)候調(diào)用。而在JavaScript中俩由,我們用注冊(cè)回調(diào)函數(shù)的形式來(lái)代替?zhèn)鹘y(tǒng)的發(fā)布—訂閱模式毒嫡,顯得更加優(yōu)雅和簡(jiǎn)單。
另外幻梯,在JavaScript中兜畸,我們無(wú)需去選擇使用推模型還是拉模型。推模型是指在事件發(fā)生時(shí)碘梢,發(fā)布者一次性把所有更改的狀態(tài)和數(shù)據(jù)都推送給訂閱者咬摇。拉模型不同的地方是,發(fā)布者僅僅通知訂閱者事件已經(jīng)發(fā)生了煞躬,此外發(fā)布者要提供一些公開的接口供訂閱者來(lái)主動(dòng)拉取數(shù)據(jù)肛鹏。拉模型的好處是可以讓訂閱者“按需獲取”,但同時(shí)有可能讓發(fā)布者變成一個(gè)“門戶大開”的對(duì)象恩沛,同時(shí)增加了代碼量和復(fù)雜度在扰。
剛好在JavaScript中,arguments可以很方便地表示參數(shù)列表雷客,所以我們一般都會(huì)選擇推模型芒珠,使用Function.prototype.apply方法把所有參數(shù)都推送給訂閱者。
小結(jié)
這里我們學(xué)習(xí)了發(fā)布—訂閱模式搅裙,也就是常說(shuō)的觀察者模式皱卓。發(fā)布—訂閱模式在實(shí)際開發(fā)中非常有用。
發(fā)布—訂閱模式的優(yōu)點(diǎn)非常明顯部逮,一為時(shí)間上的解耦娜汁,二為對(duì)象之間的解耦。它的應(yīng)用非常廣泛兄朋,既可以用在異步編程中掐禁,也可以幫助我們完成更松耦合的代碼編寫。發(fā)布—訂閱模式還可以用來(lái)幫助實(shí)現(xiàn)一些別的設(shè)計(jì)模式蜈漓,比如中介者模式穆桂。 從架構(gòu)上來(lái)看宫盔,無(wú)論是MVC還是MVVM融虽,都少不了發(fā)布—訂閱模式的參與,而且JavaScript本身也是一門基于事件驅(qū)動(dòng)的語(yǔ)言灼芭。
當(dāng)然有额,發(fā)布—訂閱模式也不是完全沒有缺點(diǎn)。創(chuàng)建訂閱者本身要消耗一定的時(shí)間和內(nèi)存,而且當(dāng)你訂閱一個(gè)消息后巍佑,也許此消息最后都未發(fā)生茴迁,但這個(gè)訂閱者會(huì)始終存在于內(nèi)存中。另外萤衰,發(fā)布—訂閱模式雖然可以弱化對(duì)象之間的聯(lián)系堕义,但如果過(guò)度使用的話,對(duì)象和對(duì)象之間的必要聯(lián)系也將被深埋在背后脆栋,會(huì)導(dǎo)致程序難以跟蹤維護(hù)和理解倦卖。特別是有多個(gè)發(fā)布者和訂閱者嵌套到一起的時(shí)候,要跟蹤一個(gè)bug不是件輕松的事情椿争。