[1]現實事例
[2]DOM事件
[3]自定義事件
[4]通用實作
[5]網站登入
[6]全局釋出訂閱對象
前面的話
釋出—訂閱模式又叫觀察者模式,它定義對象間的一對多的依賴關系,當一個對象的狀态發生改變時,所有依賴于它的對象都将得到通知。在javascript開發中,一般用事件模型來替代傳統的釋出—訂閱模式。本文将詳細介紹釋出訂閱模式
現實事例
不論是在程式世界裡還是現實生活中,釋出—訂閱模式的應用都非常廣泛
比如,小明最近看上了一套房子,到了售樓處之後才被告知,該樓盤的房子早已售罄。好在售樓處從業人員告訴小明,不久後還有一些尾盤推出,開發商正在辦理相關手續,手續辦好後便可以購買。但到底是什麼時候,目前還沒有人能夠知道。于是小明記下了售樓處的電話,以後每天都會打電話過去詢問是不是已經到了購買時間。除了小明,還有小紅、小強、小龍也會每天向售樓處咨詢這個問題。一個星期過後,該從業人員決定辭職,因為厭倦了每天回答1000個相同内容的電話
當然現實中沒有這麼笨的銷售公司,實際上故事是這樣的:小明離開之前,把電話号碼留在了售樓處。售樓處從業人員答應他,新樓盤一推出就馬上發資訊通知小明。小紅、小強和小龍也是一樣,他們的電話号碼都被記在售樓處的花名冊上,新樓盤推出的時候,售樓處從業人員會翻開花名冊,周遊上面的電話号碼,依次發送一條短信來通知他們
在上面的例子中,發送短信通知就是一個典型的釋出—訂閱模式,小明、小紅等購買者都是訂閱者,他們訂閱了房子開售的消息。售樓處作為釋出者,會在合适的時候周遊花名冊上的電話号碼,依次給購房者釋出消息
使用釋出—訂閱模式有着顯而易見的優點:購房者不用再天天給售樓處打電話咨詢開售時間,在合适的時間點,售樓處作為釋出者會通知這些消息訂閱者;購房者和售樓處之間不再強耦合在一起,當有新的購房者出現時,他隻需把手機号碼留在售樓處,售樓處不關心購房者的任何情況,不管購房者是男是女還是一隻猴子。而售樓處的任何變動也不會影響購買者,比如售樓處從業人員離職,售樓處從一樓搬到二樓,這些改變都跟購房者無關,隻要售樓處記得發短信這件事情
DOM事件
釋出—訂閱模式可以廣泛應用于異步程式設計中,這是一種替代傳遞回調函數的方案。比如,可以訂閱ajax請求的error、succ等事件。或者如果想在動畫的每一幀完成之後做一些事情,可以訂閱一個事件,然後在動畫的每一幀完成之後釋出這個事件。在異步程式設計中使用釋出—訂閱模式,就無需過多關注對象在異步運作期間的内部狀态,而隻需要訂閱感興趣的事件發生點
釋出—訂閱模式可以取代對象之間寫死的通知機制,一個對象不用再顯式地調用另外一個對象的某個接口。釋出—訂閱模式讓兩個對象松耦合地聯系在一起,雖然不太清楚彼此的細節,但這不影響它們之間互相通信。當有新的訂閱者出現時,釋出者的代碼不需要任何修改;同樣釋出者需要改變時,也不會影響到之前的訂閱者。隻要之前約定的事件名沒有變化,就可以自由地改變它們
實際上,隻要在DOM節點上面綁定過事件函數,那就使用過釋出—訂閱模式
document.body.addEventListener('click',function(){
alert(2);
},false);
document.body.click(); //模拟使用者點選
在這裡需要監控使用者點選document.body的動作,但是沒辦法預知使用者将在什麼時候點選。是以訂閱document.body上的click事件,當body節點被點選時,body節點便會向訂閱者釋出這個消息
當然還可以随意增加或者删除訂閱者,增加任何訂閱者都不會影響釋出者代碼的編寫
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(); //模拟使用者點選
[注意]手動觸發事件更好的做法是IE下用fireEvent,标準浏覽器下用dispatchEvent實作
自定義事件
除了DOM事件,還會經常實作一些自定義的事件,這種依靠自定義事件完成的釋出—訂閱模式可以用于任何javascript代碼中
下面是實作釋出—訂閱模式的步驟:
1、先要指定好誰充當釋出者(比如售樓處)
2、然後給釋出者添加一個緩存清單,用于存放回調函數以便通知訂閱者(售樓處的花名冊)
3、最後釋出消息的時候,釋出者會周遊這個緩存清單,依次觸發裡面存放的訂閱者回調函數(周遊花名冊,挨個發短信)
另外,還可以往回調函數裡填入一些參數,訂閱者可以接收這些參數。這是很有必要的,比如售樓處可以在發給訂閱者的短信裡加上房子的單價、面積、容積率等資訊,訂閱者接收到這些資訊之後可以進行各自的處理
var salesOffices = {}; // 定義售樓處
salesOffices.clientList = []; // 緩存清單,存放訂閱者的回調函數
salesOffices.listen = function( fn ){ // 增加訂閱者
this.clientList.push( fn ); // 訂閱的消息添加進緩存清單
};
salesOffices.trigger = function(){ // 釋出消息
for( var i = 0, fn; fn = this.clientList[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是釋出消息時帶上的參數
}
};
salesOffices.listen( function( price, squareMeter ){ // 小明訂閱消息
console.log( '價格= ' + price );
console.log( 'squareMeter= ' + squareMeter );
});
salesOffices.listen( function( price, squareMeter ){ // 小紅訂閱消息
console.log( '價格= ' + price );
console.log( 'squareMeter= ' + squareMeter );
});
salesOffices.trigger( 2000000, 88 ); // 輸出:200 萬,88 平方米
salesOffices.trigger( 3000000, 110 ); // 輸出:300 萬,110 平方米
至此,已經實作了一個最簡單的釋出—訂閱模式,但這裡還存在一些問題。看到訂閱者接收到了釋出者釋出的每個消息,雖然小明隻想買88平方米的房子,但是釋出者把110平方米的資訊也推送給了小明,這對小明來說是不必要的困擾。是以有必要增加一個标示key,讓訂閱者隻訂閱自己感興趣的消息。改寫後的代碼如下:
var salesOffices = {}; // 定義售樓處
salesOffices.clientList = []; // 緩存清單,存放訂閱者的回調函數
salesOffices.listen = function( key, fn ){
if ( !this.clientList[ key ] ){ // 如果還沒有訂閱過此類消息,給該類消息建立一個緩存清單
this.clientList[ key ] = [];
}
this.clientList[ key ].push( fn ); // 訂閱的消息添加進消息緩存清單
};
salesOffices.trigger = function(){ // 釋出消息
var key = Array.prototype.shift.call( arguments ), // 取出消息類型
fns = this.clientList[ key ]; // 取出該消息對應的回調函數集合
if ( !fns || fns.length === 0 ){ // 如果沒有訂閱該消息,則傳回
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是釋出消息時附送的參數
}
};
salesOffices.listen( 'squareMeter88', function( price ){ // 小明訂閱88 平方米房子的消息
console.log( '價格= ' + price ); // 輸出: 2000000
});
salesOffices.listen( 'squareMeter110', function( price ){ // 小紅訂閱110 平方米房子的消息
console.log( '價格= ' + price ); // 輸出: 3000000
});
salesOffices.trigger( 'squareMeter88', 2000000 ); // 釋出88 平方米房子的價格
salesOffices.trigger( 'squareMeter110', 3000000 ); // 釋出110 平方米房子的價格
很明顯,現在訂閱者可以隻訂閱自己感興趣的事件了
通用實作
有沒有辦法可以讓所有對象都擁有釋出—訂閱功能呢?有的,javascript作為一門解釋執行的語言,給對象動态添加職責是理所當然的事情。是以把釋出—訂閱的功能提取出來,放在一個單獨的對象内:
var event = {
clientList: [],
listen: function( key, fn ){
if ( !this.clientList[ key ] ){
this.clientList[ key ] = [];
}
this.clientList[ key ].push( fn ); // 訂閱的消息添加進緩存清單
},
trigger: function(){
var key = Array.prototype.shift.call( arguments ), // (1);
fns = this.clientList[ key ];
if ( !fns || fns.length === 0 ){ // 如果沒有綁定對應的消息
return false;
}
for( var i = 0, fn; fn = fns[ i++ ]; ){
fn.apply( this, arguments ); // (2) // arguments 是trigger 時帶上的參數
}
}
};
再定義一個installEvent函數,這個函數可以給所有的對象都動态安裝釋出—訂閱功能:
var installEvent = function( obj ){
for ( var i in event ){
obj[ i ] = event[ i ];
}
};
下面給售樓處對象salesOffices動态增加釋出—訂閱功能
var salesOffices = {};
installEvent( salesOffices );
salesOffices.listen( 'squareMeter88', function( price ){ // 小明訂閱消息
console.log( '價格= ' + price );
});
salesOffices.listen( 'squareMeter100', function( price ){ // 小紅訂閱消息
console.log( '價格= ' + price );
});
salesOffices.trigger( 'squareMeter88', 2000000 ); // 輸出:2000000
salesOffices.trigger( 'squareMeter100', 3000000 ); // 輸出:3000000
【取消訂閱】
有時候,也許需要取消訂閱事件的功能。比如小明突然不想買房子了,為了避免繼續接收到售樓處推送過來的短信,小明需要取消之前訂閱的事件。現在給event對象增加remove方法
event.remove = function( key, fn ){
var fns = this.clientList[ key ];
if ( !fns ){ // 如果key 對應的消息沒有被人訂閱,則直接傳回
return false;
}
if ( !fn ){ // 如果沒有傳入具體的回調函數,表示需要取消key 對應消息的所有訂閱
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 ); // 删除訂閱者的回調函數
}
}
}
};
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( '價格= ' + price );
});
salesOffices.listen( 'squareMeter88', fn2 = function( price ){ // 小紅訂閱消息
console.log( '價格= ' + price );
});
salesOffices.remove( 'squareMeter88', fn1 ); // 删除小明的訂閱
salesOffices.trigger( 'squareMeter88', 2000000 ); // 輸出:2000000
網站登入
假如正在開發一個商城網站,網站裡有header頭部、nav導航、消息清單、購物車等子產品。這幾個子產品的渲染有一個共同的前提條件,就是必須先用ajax異步請求擷取使用者的登入資訊。這是很正常的,比如使用者的名字和頭像要顯示在header子產品裡,而這兩個字段都來自使用者登入後傳回的資訊。至于ajax請求什麼時候能成功傳回使用者資訊,這點沒有辦法确定
但現在還不足以說服在此使用釋出—訂閱模式,因為異步的問題通常也可以用回調函數來解決。更重要的一點是,不知道除了header頭部、nav導航、消息清單、購物車之外,将來還有哪些子產品需要使用這些使用者資訊。如果它們和使用者資訊子產品産生了強耦合,比如下面這樣的形式:
login.succ(function(data){
header.setAvatar( data.avatar); // 設定header 子產品的頭像
nav.setAvatar( data.avatar ); // 設定導航子產品的頭像
message.refresh(); // 重新整理消息清單
cart.refresh(); // 重新整理購物車清單
});
現在必須了解header子產品裡設定頭像的方法叫setAvatar、購物車子產品裡重新整理的方法叫refresh,這種耦合性會使程式變得僵硬,header子產品不能随意再改變setAvatar的方法名,它自身的名字也不能被改為header1、header2。這是針對具體實作程式設計的典型例子,針對具體實作程式設計是不被贊同的
等到有一天,項目中又新增了一個收貨位址管理的子產品,在最後部分加上這行代碼:
login.succ(function(data){
header.setAvatar( data.avatar); // 設定header 子產品的頭像
nav.setAvatar( data.avatar ); // 設定導航子產品的頭像
message.refresh(); // 重新整理消息清單
cart.refresh(); // 重新整理購物車清單
address.refresh();
});
用釋出—訂閱模式重寫之後,對使用者資訊感興趣的業務子產品将自行訂閱登入成功的消息事件。當登入成功時,登入子產品隻需要釋出登入成功的消息,而業務方接受到消息之後,就會開始進行各自的業務處理,登入子產品并不關心業務方究竟要做什麼,也不想去了解它們的内部細節。改進後的代碼如下:
$.ajax('http://xx.com?login',function(data){ //登入成功
login.trigger('loginSucc',data); //釋出登入成功的消息
});
各子產品監聽登入成功的消息:
var header = (function(){ // header 子產品
login.listen( 'loginSucc', function( data){
header.setAvatar( data.avatar );
});
return {
setAvatar: function( data ){
console.log( '設定header 子產品的頭像' );
}
}
})();
var nav = (function(){ // nav 子產品
login.listen( 'loginSucc', function( data ){
nav.setAvatar( data.avatar );
});
return {
setAvatar: function( avatar ){
console.log( '設定nav 子產品的頭像' );
}
}
})();
如上所述,随時可以把setAvatar的方法名改成setTouxiang。如果有一天在登入完成之後,又增加一個重新整理收貨位址清單的行為,那麼隻要在收貨位址子產品裡加上監聽消息的方法即可,代碼如下:
var address = (function(){ // nav 子產品
login.listen( 'loginSucc', function( obj ){
address.refresh( obj );
});
return {
refresh: function( avatar ){
console.log( '重新整理收貨位址清單' );
}
}
})();
全局釋出訂閱對象
剛剛實作的釋出—訂閱模式,給售樓處對象和登入對象都添加了訂閱和釋出的功能,這裡還存在兩個小問題:1、給每個釋出者對象都添加了listen和trigger方法,以及一個緩存清單clientList,這其實是一種資源浪費;2、小明跟售樓處對象還是存在一定的耦合性,小明至少要知道售樓處對象的名字是salesOffices,才能順利的訂閱到事件
salesOffices.listen('squareMeter100',function(price){ //小明訂閱消息
console.log('價格='+price);
});
如果小明還關心300平方米的房子,而這套房子的賣家是salesOffices2,這意味着小明要開始訂閱salesOffices2對象。見如下代碼:
salesOffices2.listen('squareMeter300',function(price){ //小明訂閱消息
console.log('價格='+price);
});
其實在現實中,買房子未必要親自去售樓處,隻要把訂閱的請求交給中介公司,而各大房産公司也隻需要通過中介公司來釋出房子資訊。這樣一來,不用關心消息是來自哪個房産公司,在意的是能否順利收到消息。當然,為了保證訂閱者和釋出者能順利通信,訂閱者和釋出者都必須知道這個中介公司
同樣在程式中,釋出—訂閱模式可以用一個全局的Event對象來實作,訂閱者不需要了解消息來自哪個釋出者,釋出者也不知道消息會推送給哪些訂閱者,Event作為一個類似“中介者”的角色,把訂閱者和釋出者聯系起來。見如下代碼:
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( '價格= ' + price ); // 輸出:'價格=2000000'
});
Event.trigger( 'squareMeter88', 2000000 ); // 售樓處釋出消息
【子產品間通信】
上面實作的釋出—訂閱模式的實作,是基于一個全局的Event對象,利用它可以在兩個封裝良好的子產品中進行通信,這兩個子產品可以完全不知道對方的存在
比如現在有兩個子產品,a子產品裡面有一個按鈕,每次點選按鈕之後,b子產品裡的div中會顯示按鈕的總點選次數,用全局釋出—訂閱模式完成下面的代碼,使得a子產品和b子產品可以在保持封裝性的前提下進行通信
<button id="count">點我</button>
<div id="show"></div>
<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>
但要留意一個問題,子產品之間如果用了太多的全局釋出—訂閱模式來通信,那麼子產品與子產品之間的聯系就被隐藏到了背後。最終會搞不清楚消息來自哪個子產品,或者消息會流向哪些子產品,這又會給維護帶來一些麻煩,也許某個子產品的作用就是暴露一些接口給其他子產品調用
【先釋出後訂閱】
常見的釋出—訂閱模式,都是訂閱者必須先訂閱一個消息,随後才能接收到釋出者釋出的消息。在某些情況下,需要先将這條消息儲存下來,等到有對象來訂閱它的時候,再重新把消息釋出給訂閱者。就如同QQ中的離線消息一樣,離線消息被儲存在伺服器中,接收人下次登入上線之後,可以重新收到這條消息
/**************先釋出後訂閱********************/
Event.trigger('click',1);
Event.listen('click',function(a){
console.log(a); //輸出:1
});
這種需求在實際項目中是存在的,比如在商城網站中,擷取到使用者資訊之後才能渲染使用者導航子產品,而擷取使用者資訊的操作是一個ajax異步請求。當ajax請求成功傳回之後會釋出一個事件,在此之前訂閱了此事件的使用者導航子產品可以接收到這些使用者資訊
但是這隻是理想的狀況,因為異步的原因,不能保證ajax請求傳回的時間,有時候它傳回得比較快,而此時使用者導航子產品的代碼還沒有加載好(還沒有訂閱相應事件),特别是在用了一些子產品化惰性加載的技術後,這是很可能發生的事情。也許還需要一個方案,使得的釋出—訂閱對象擁有先釋出後訂閱的能力
為了滿足這個需求,要建立一個存放離線事件的堆棧,當事件釋出的時候,如果此時還沒有訂閱者來訂閱這個事件,暫時把釋出事件的動作包裹在一個函數裡,這些包裝函數将被存入堆棧中,等到終于有對象來訂閱此事件的時候,将周遊堆棧并且依次執行這些包裝函數,也就是重新釋出裡面的事件。當然離線事件的生命周期隻有一次,就像QQ的未讀消息隻會被重新閱讀一次,是以剛才的操作隻能進行一次
【全局事件的命名沖突】
全局的釋出—訂閱對象裡隻有一個clinetList來存放消息名和回調函數,大家都通過它來訂閱和釋出各種消息,久而久之,難免會出現事件名沖突的情況,是以還可以給Event對象提供建立命名空間的功能
/**************使用命名空間********************/
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);
下面是完整代碼
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 ] === 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' ){
}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;
})();
釋出—訂閱模式,也就是常說的觀察者模式,它的優點非常明顯,一為時間上的解耦,二為對象之間的解耦。應用也非常廣泛,既可以用在異步程式設計中,也可以幫助完成更松耦合的代碼編寫。釋出—訂閱模式還可以用來幫助實作一些别的設計模式,比如中介者模式。從架構上來看,無論是MVC還是MVVM,都少不了釋出—訂閱模式的參與,而且javascript本身也是一門基于事件驅動的語言
當然,釋出—訂閱模式也不是完全沒有缺點。建立訂閱者本身要消耗一定的時間和記憶體,而且訂閱一個消息後,也許此消息最後都未發生,但這個訂閱者會始終存在于記憶體中。另外,釋出—訂閱模式雖然可以弱化對象之間的聯系,但如果過度使用的話,對象和對象之間的必要聯系也将被深埋在背後,會導緻程式難以跟蹤維護和了解。特别是有多個釋出者和訂閱者嵌套到一起的時候,要跟蹤一個bug不是件輕松的事情
好的代碼像粥一樣,都是用時間熬出來的
