文章出處

前面的話

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

 


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()