1. 程式人生 > >《JavaScript設計模式與開發實踐》學習筆記part4-釋出訂閱模式

《JavaScript設計模式與開發實踐》學習筆記part4-釋出訂閱模式

本篇內容主要講述JavaScript中的釋出-訂閱模式

第七章 釋出-訂閱模式

8.1 現實中的釋出-訂閱模式

不論是在程式世界裡還是現實生活中,釋出—訂閱模式的應用都非常之廣泛。我們先看一個現實中的例子。
小明最近看上了一套房子,到了售樓處之後才被告知,該樓盤的房子早已售罄。好在售樓 MM 告訴小明,不久後還有一些尾盤推出,開發商正在辦理相關手續,手續辦好後便可以購買。 但到底是什麼時候,目前還沒有人能夠知道。
於是小明記下了售樓處的電話,以後每天都會打電話過去詢問是不是已經到了購買時間。除 了小明,還有小紅、小強、小龍也會每天向售樓處諮詢這個問題。一個星期過後,售樓 MM 決 定辭職,因為厭倦了每天回答 1000 個相同內容的電話。
當然現實中沒有這麼笨的銷售公司,實際上故事是這樣的:小明離開之前,把電話號碼留在 了售樓處。售樓 MM 答應他,新樓盤一推出就馬上發信息通知小明。小紅、小強和小龍也是一 樣,他們的電話號碼都被記在售樓處的花名冊上,新樓盤推出的時候,售樓 MM 會翻開花名冊, 遍歷上面的電話號碼,依次傳送一條簡訊來通知他們。

8.2 DOM事件

實際上,只要我們曾經在 DOM 節點上面繫結過事件函式,那我們就曾經使用過釋出—訂閱模式,來看看下面這兩句簡單的程式碼發生了什麼事情:

document.body.addEventListener('click', function () {
    alert(2);
}, false);
document.body.click(); // 模擬使用者點選

在這裡需要監控使用者點選 document.body 的動作,但是我們沒辦法預知使用者將在什麼時候點 擊。所以我們訂閱 document.body 上的 click 事件,當 body 節點被點選時,body 節點便會向訂閱 者釋出這個訊息。這很像購房的例子,購房者不知道房子什麼時候開售,於是他在訂閱訊息後等 11 待售樓處釋出訊息。

8.3 自定義事件

我們現在需要通過自定義事件一步步實現釋出-訂閱模式,繼而去實現一開始說的售樓處的例子:
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]; i++) { fn.apply(this, arguments); } } salesOffices.listen(function(price, squareM) { console.log("價格=", price); console.log("面積=", squareM); }); salesOffices.listen(function(price, squareM) { console.log("價格=", price); console.log("面積=", squareM); }); salesOffices.trigger(200000, 88); // 輸出2次 200000萬,88平方米

至此,我們已經實現了一個最簡單的釋出—訂閱模式,但這裡還存在一些問題。我們看到訂 閱者接收到了釋出者釋出的每個訊息,雖然小明只想買 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, squareM) {
    console.log("價格=", price);
    console.log("面積=", squareM);
});

salesOffices.listen('squareMeter110', function(price, squareM) {
    console.log("價格=", price);
    console.log("面積=", squareM);
});

salesOffices.trigger( 'squareMeter88', 2000000 ); // 釋出 88 平方米房子的價格
salesOffices.trigger( 'squareMeter110', 3000000 ); // 釋出 110 平方米房子的價格

很明顯,現在訂閱者可以只訂閱自己感興趣的事件了。

8.4 取消訂閱的事件

有時候,我們也許需要取消訂閱事件的功能。比如小明突然不想買房子了,為了避免繼續接 收到售樓處推送過來的簡訊,小明需要取消之前訂閱的事件。現在我們給 event 物件增加 remove 方法:

salesOffices.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);  // 刪除訂閱者的回撥函式    
            }
        }
    }
};
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

8.5 真實的例子-網站登入

假如我們正在開發一個商城網站,網站裡有 header 頭部、nav 導航、訊息列表、購物車等模組。這幾個模組的渲染有一個共同的前提條件,就是必須先用 ajax 非同步請求獲取使用者的登入資訊。 這是很正常的,比如使用者的名字和頭像要顯示在 header 模組裡,而這兩個欄位都來自使用者登入後返回的資訊。通常程式碼是這樣寫的:

login.succ(function(data){ 
    header.setAvatar( data.avatar);  // 設定 header 模組的頭像
    nav.setAvatar( data.avatar );  // 設定導航模組的頭像
    message.refresh();   // 重新整理訊息列表
    cart.refresh();  // 重新整理購物車列表
});

現在登入模組是我們負責編寫的,但我們還必須瞭解 header 模組裡設定頭像的方法叫 setAvatar、購物車模組裡重新整理的方法叫 refresh,這種耦合性會使程式變得僵硬,header 模組不 能隨意再改變 setAvatar 的方法名,它自身的名字也不能被改為 header1、header2。 這是針對具 體實現程式設計的典型例子,針對具體實現程式設計是不被贊同的。
等到有一天,專案中又新增了一個收貨地址管理的模組,這個模組本來是另一個同事所寫的, 而此時你正在馬來西亞度假,但是他卻不得不給你打電話:“Hi,登入之後麻煩重新整理一下收貨地 址列表。”於是你又翻開你 3 個月前寫的登入模組,在最後部分加上收貨地址的重新整理方法的呼叫。
用釋出—訂閱模式重寫之後,對使用者資訊感興趣的業務模組將自行訂閱登入成功的訊息事件。 當登入成功時,登入模組只需要釋出登入成功的訊息,而業務方接受到訊息之後,就會開始進行 各自的業務處理,登入模組並不關心業務方究竟要做什麼,也不想去了解它們的內部細節。改善 後的程式碼如下:

$.ajax('http:// xxx.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 模組的頭像');
        }
    }
})();
// nav 模組 
var nav = (function () {
    login.listen('loginSucc', function (data) {
        nav.setAvatar( data.avatar );
    });
    return {
        setAvatar: function (avatar) {
            console.log('設定 nav 模組的頭像');
        }
    }
})();

如上所述,我們隨時可以把 setAvatar 的方法名改成 setTouxiang。如果有一天在登入完成之 後,又增加一個重新整理收貨地址列表的行為,那麼只要在收貨地址模組里加上監聽訊息的方法即可, 而這可以讓開發該模組的同事自己完成,你作為登入模組的開發者,永遠不用再關心這些行為了。

8.6 模組間通訊

比如現在有兩個模組,a 模組裡面有一個按鈕,每次點選按鈕之後,b 模組裡的 div 中會顯示 按鈕的總點選次數,我們用全域性釋出—訂閱模式完成下面的程式碼,使得 a 模組和 b 模組可以在保 持封裝性的前提下進行通訊。

<!DOCTYPE html>
<html>

<body>
    <button id="count">點我</button>
    <div id="show"></div>
</body>
<script type="text/JavaScript"> 
var a = (function(){ 
    var count = 0; 
    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>

但在這裡我們要留意另一個問題,模組之間如果用了太多的全域性釋出—訂閱模式來通訊,那 麼模組與模組之間的聯絡就被隱藏到了背後。我們最終會搞不清楚訊息來自哪個模組,或者訊息 會流向哪些模組,這又會給我們的維護帶來一些麻煩,也許某個模組的作用就是暴露一些介面給 其他模組呼叫。