前面的話
發布—訂閱模式又叫觀察者模式,它定義對象間的一對多的依賴關系,當一個對象的狀態發生改變時,所有依賴于它的對象都將得到通知。在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不是件輕松的事情
文章列表