JavaScript設(shè)計(jì)模式之發(fā)布—訂閱模式

發(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不是件輕松的事情椿争。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末怕膛,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子秦踪,更是在濱河造成了極大的恐慌褐捻,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,113評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件椅邓,死亡現(xiàn)場(chǎng)離奇詭異柠逞,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)景馁,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門边苹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人裁僧,你說(shuō)我怎么就攤上這事个束。” “怎么了聊疲?”我有些...
    開封第一講書人閱讀 153,340評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵茬底,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我获洲,道長(zhǎng)阱表,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,449評(píng)論 1 279
  • 正文 為了忘掉前任贡珊,我火速辦了婚禮最爬,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘门岔。我一直安慰自己爱致,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評(píng)論 5 374
  • 文/花漫 我一把揭開白布寒随。 她就那樣靜靜地躺著糠悯,像睡著了一般帮坚。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上互艾,一...
    開封第一講書人閱讀 49,166評(píng)論 1 284
  • 那天试和,我揣著相機(jī)與錄音,去河邊找鬼纫普。 笑死阅悍,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的昨稼。 我是一名探鬼主播溉箕,決...
    沈念sama閱讀 38,442評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼悦昵!你這毒婦竟也來(lái)了肴茄?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,105評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤但指,失蹤者是張志新(化名)和其女友劉穎寡痰,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體棋凳,經(jīng)...
    沈念sama閱讀 43,601評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡拦坠,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了剩岳。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贞滨。...
    茶點(diǎn)故事閱讀 38,161評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖拍棕,靈堂內(nèi)的尸體忽然破棺而出晓铆,到底是詐尸還是另有隱情,我是刑警寧澤绰播,帶...
    沈念sama閱讀 33,792評(píng)論 4 323
  • 正文 年R本政府宣布骄噪,位于F島的核電站,受9級(jí)特大地震影響蠢箩,放射性物質(zhì)發(fā)生泄漏链蕊。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評(píng)論 3 307
  • 文/蒙蒙 一谬泌、第九天 我趴在偏房一處隱蔽的房頂上張望滔韵。 院中可真熱鬧,春花似錦掌实、人聲如沸陪蜻。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)囱皿。三九已至勇婴,卻和暖如春忱嘹,著一層夾襖步出監(jiān)牢的瞬間嘱腥,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工拘悦, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留齿兔,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,618評(píng)論 2 355
  • 正文 我出身青樓础米,卻偏偏與公主長(zhǎng)得像分苇,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子屁桑,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評(píng)論 2 344

推薦閱讀更多精彩內(nèi)容