1. 程式人生 > >【學習筆記javascript設計模式與開發實踐(釋出--訂閱模式)----8】

【學習筆記javascript設計模式與開發實踐(釋出--訂閱模式)----8】

第8章 釋出—訂閱模式

釋出—訂閱模式又叫觀察者模式,它定義物件間的一種一對多的依賴關係,當一個物件的狀態發生了改變時,所有依賴於它的物件都將得到通知。在javascript開發中,我們一般都用事件模型來替代傳統的釋出—訂閱模式。

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

不論是在程式世界裡還是現實生活中,釋出—訂閱模式的應用都非常廣泛。我們先看一下現實中的例子。

小明最近看上了一套房子,到了售樓處之後才被告知,該樓盤的房子早已售罄。好在售樓MM告訴小明,不久後還有一些尾盤推出。開發商正在辦理相關手續,手續辦好後便可以購買。但到底是什麼時候,目前還沒有人能夠知道。

於是小明記下了售樓處的電話,以後每天都會打電話過去詢問是不是已經到了購買時間。除了小明,還有小紅,小強,小龍也會每天向售樓處諮詢這個問題。一個星期過後,售樓MM決定辭職,因為厭倦了每天回答1000個相同的電話。

當然現實中沒有這麼笨的銷售公司,實際上故事是這樣的:小明離開之前,把電話號碼留在售樓處。售樓MM答應他,新樓盤一推出就馬上發信息通知小明。小紅、小強、小龍也是一樣,他們的電話號碼都被記在售樓處的花名冊上,新樓盤推出的時候,售樓MM會翻開花名冊,遍歷上面的電話號碼,依次傳送一條簡訊通知他們。

8.2 釋出—訂閱模式的作用

l   購房者不用再天天給售樓處打電話諮詢開售時間,在合適的時間點,售樓處作為釋出者會通知這些訊息訂閱者

l   購房者和售樓處之間不再強耦合在一起,當有新的購房者出現時,他只需要把手機號碼留在售樓處,售樓處不關心購房者的任何情況,不管購房者是男是女還是一隻猴子。而售樓處的任何變動也不會影響購買者,比如售樓MM離職,售樓處人一樓搬到二樓,這些改變都跟購房者無關,只要售樓處記得發簡訊這件事情。

第一點說明發布—訂閱模式可以廣泛應用於非同步程式設計中,這是一種替代傳遞迴調函式的方案。比如,我們可以訂閱ajax請求的error,success等事件。或者如果想在動畫的每一幀完成之後做一些事情,那我們可以訂閱一個事件,然後在動畫的每一幀完成之後釋出這個事件。在非同步程式設計中使用釋出訂閱模式,我們就無需要過多關注物件在非同步執行期間的內部狀態,而只需要訂閱感興趣的事件發生點。

第二點說明發布—訂閱模式可以取代物件之間硬編碼通知機制,一個物件不用再再顯式地呼叫另外一個物件的某個介面。釋出—訂閱模式讓兩個物件鬆耦合地聯絡在一起,雖然不太清楚彼此的細節,但這不影響它們之間相互通訊。當有新的訂閱者出現時,釋出者的程式碼不需要任何修改;同樣釋出者需要改變時,也不會影響到之前的訂閱者。只要之前約定的事件名沒有變化,就可以自由地改變他們。

8.3 DOM事件

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

document.body.addEventListener(‘click’,function(){
  alert(2);
},false);
document.body.click();

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

8.4 自定義事件

除了DOM事件,我們還會經常實現一些自定義的事件,這種依靠自定義事件完成的釋出—訂閱模式可以用於任何javascript程式碼中。

現在看看如何一步步實現釋出—訂閱模式

l   首先要指定好誰充當釋出者(比如售樓處)

l   然後給釋出者新增一個快取列表,用於存放回調函式以便通知訂閱者(售樓處的花名冊)

l   最後釋出訊息的時候,釋出者會遍歷這個快取列表,今次觸發裡面存放的訂閱者回調函式(遍歷花名冊,挨個發簡訊)

另外,我們還可以往回調函式裡填入一些引數,訂閱者可以接收這些引數。這是很必要比如售樓處可以在發給訂閱者的簡訊里加上房子的單價、面積、容積等資訊,訂閱者接收到這些資訊之後可以進行各自的處理:

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);
   }
}

我們可以做個簡單的測試

salesOffices.listen(function(price,squareMeter){//小時訂閱訊息
   console.log(“價格=”+price);
   console.log(“面積=”+squareMeter);
});
 
salesOffices.listen(function(price,squareMeter){//小紅訂閱訊息
   console.log(“價格=”+price);
   console.log(“面積=”+squareMeter);
});
salesOffices.trigger(200000,88); //輸出
salesOffices.trigger(300000,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(vari=0,fn;fn=fns[i++];){
   fn.apply(this,arguments);
}
}
 
salesOffices.listen(‘squareMeter88’,function(price){
  console.log(“價格=”+price);
});
salesOffices.listen(‘squareMeter110’,function(price){
  console.log(“價格=”+price);
});
 
salesOffices.trigger(‘squareMeter88’,200000);
salesOffices.trigger(‘squareMeter88’,300000);

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

8.5 釋出—訂閱模式的通用實現

現在我們已經看到了如何讓售樓處擁有接受訂閱和釋出事件的功能。假設現在小明去另一個售樓處買房子,那麼這段程式碼是否必須在另一個售樓處物件上重寫一次呢,有沒有辦法可以讓所有的物件都擁有釋出訂閱功能呢?

當然有,js作為一門解釋執行語言,給物件動態新增職責是理所當然的事情。

所以我們把釋出—訂閱的功能提取出來,放在一個單獨的物件內:

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),
         fns = this.clientList[key]; //取出該訊息對應的回撥函式集合
         if(!fns||fns.length===0){
            return false;
         }
        for(var i=0,fn;fn=fns[i++];){
                fn.apply(this,arguments);
        }
   }
}

再定義一個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(‘squareMeter110’,function(price){
  console.log(‘價格=’+price);
});
salesOffices.listen(‘squareMeter88’,200000);
salesOffices.listen(‘squareMeter110’,400000);

8.6 取消訂閱事件

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

event.remove = function(key,fn){
   var fns =this.clientList[key];
   if(!fns){
       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(1,1);
          }
     }
   }
}
 
var salesOffices = {},fn1,fn2;
installEvent(salesOffices);
salesOffices.listen(‘squareMeter88’,fn1=function(price){
  console.log(‘價格=’+price);
});
salesOffices.listen(‘squareMeter110’,fn2=function(price){
  console.log(‘價格=’+price);
});
 
salesOffices.remove(‘squareMeter88’,fn1);
salesOffices.listen(‘squareMeter110’,400000);

8.7 真實的例子----網站登入

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

至於ajax請求什麼時候能成功返回使用者資訊,這點我們沒有辦法確定。現在的情節看起來像合肥市了售樓處的例子子,小明不知道什麼時候開發商的售樓手續能夠成功辦下來。

但現在還不足以說服我們在此使用釋出—訂閱模式,因為非同步的問題通常也可以用回撥函式來解決。更重要的一點是,我們不知道除了header頭部、nav導航、訊息列表、購物車等模組。將來還有如些模組需要使用這些使用者資訊。如果它們和使用者資訊模組產生了強耦合,比如下面這樣的呼叫形式:

login.succ(function(data){
  header.setAvatar(data.avatar);
   nav.setAvatar(data.avatar);
   message.refresh();
   cart.refresh(); //重新整理購物車列表
});

現在登入模組是我們負責編寫的,但我們還必須瞭解header模組裡設定頭像的方法叫setAvatar,購物車模組裡重新整理的方法叫refresh,這種耦合性會使程式變得僵硬,header模組不能隨意再改變setAvatar的方法名,它自身的名字也不改為header1,header2。這是針對具體實現程式設計的典型例子,針對具體程式設計是不被贊同的。

等到有一天,專案中又新增了一個收貨地址管理模組,這個模組本來是另一個同事所寫的而此時你正在馬來西亞度假,但是他卻不得不給你打電話:Hi,登入之後麻煩重新整理一下收貨地址列表。於是你又翻開你3個月前寫的登入模組,在最後部分加上這行程式碼:

login.succ(function(data){
  header.setAvatar(data.avatar);
   nav.setAvatar(data.avatar);
   message.refresh();
   cart.refresh(); //重新整理購物車列表
   <span style="color:#ff0000;">address.refresh()</span>; //重新整理地址
});

我們就會越來越疲於應付這些突如其來的業務要求,要麼跳槽了事,要麼必須重構這些程式碼,當登入成功時,登入模組只要釋出登入成功的訊息,而業務方接受到訊息之後,就會開始進行各自的業務處理,登入模組並不關心業務方究竟要做什麼,也不想去了解它的內部細節改善後的程式碼如下:

$.ajax(‘http//..com?login’,function(data){
    login.trigger(‘loginSucc’,data);//釋出成功的訊息
});

各模組監聽登入成功的訊息

var  header = (function(){
     login.listen(‘loginSucc’,function(data){
        header.setAvatar(data.avatar);
     });
     return {
      setAvatar:function(data){
          console.log(‘設定header模組的頭像’);
       }
     };
})();

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

var  address = (function(){
     login.listen(‘loginSucc’,function(data){
        address.refresh(data.avatar);
     });
     return {
       refresh:function(data){
          console.log(‘重新整理收貨地址列表’);
       }
     };
})();

8.8 全域性的釋出—訂閱物件

回想下剛剛實現的釋出—訂閱模式,我們給售樓處物件和登入物件都新增訂閱和釋出的功能,而這裡還存在兩個小問題:

我們給每個釋出者物件都添加了listen和trigger方法,以及一個快取列表clientList,這其實是一種資源的浪費。

小明跟售樓處物件還是存在一定的耦合性,小明至少要知道售樓處物件的名字是salesOffices,才能順利的訂閱到事件。如下:

salesOffices.listen(‘squareMeter100’,function(price){
 console.log(“價格=”+price);
});

如果小明還關心300平方米的房子,崦這套房子的賣家是salesOffices2,這意味著小明要開始訂閱salesOffices2物件,如下:

salesOffices2.listen(‘squareMeter300’,function(price){
 console.log(“價格=”+price);
});

其實在現實中,買房未必要去售樓處,我們只要把訂閱的請求交給中介公司,而各大房產公司也只要通過中介公司來發布房子資訊。這樣一來,我們不用關心訊息是來自哪個房產公司,我們在意的是能否順利收到訊息。當然,為了保證訂閱者和釋出者能順利通訊,訂閱者和釋出者都必須知道這個中介公司。

同樣在程式中,釋出—訂閱模式可以用一個全域性的Event物件來實現,訂閱者不需要了解訊息來自哪個釋出者,釋出者也不知道訊息會推給哪些些訂閱者,Event作為一個類似“中介者”的角色,把訂閱和釋出聯絡起來。

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(vari=0,fn;fn=fn[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(varl=fns.length-1;l>=0;l--){
            var _fn = fns[l];
            if(_fn===fn){
              fns.splice(1,1);
            }
         }
          }
   }
 
 
   return {
     listen:listen,
     trigger:trigger,
     remove:remove
   }
}
 
 
Event.listen(‘squareMeter88’,function(price){
   console.log(‘價格=’+price);
});
 
Event.trigger(‘squareMeter88’,200000);

8.9 模組間通訊

上一節中實現的釋出—訂閱模式的實現,是基於一個全域性的Event物件,我們利用它可以在兩個封裝良好的模組中進行通訊,這兩個模組可以完全不知道對方的存在。就如同有了中介公司之後,我們不於需要知道房子開售的訊息來自哪個售樓處。

比如現在有兩個模組,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;
        var button =document.getElementById(‘count’);
        button.onclick =(function(){
           Event.trigger(‘add’,count++);
        })();
    var b = (function(){
          var div = document.getElementById(‘show’);
          div.listen(‘add’,function(count){
       
              div.innerHTML =count;
          })
       })();
    })();
 </script>
</html>


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

8.10 必須先訂閱再發布嗎

這種需求在實際專案中是存在的,比如在之前的商場網站中,獲取到使用者資訊之後才能渲染使用者導航模組,而獲取使用者資訊的操作是一個ajax非同步請求。當ajax請求成功返回之後會發佈一個事件,在此之前訂閱了此事件的使用者導航模組可以接收到使用者資訊。

但這只是理想的狀況,因為非同步的原因,我們不能保證ajax請求返回的時間,有時候它回得比較快,而此時使用者導航模組的程式碼還沒有載入好,特別是在用了一些模組化惰性載入的技術後,這是很可能發生的事情。也許我們還需要一個方案,使得我們的釋出—訂閱物件擁有先發布後訂閱的能力

為了滿足這個需求,我們建立一個存放離線事件的棧,當事件釋出的時候,如果此時還沒有訂閱者來訂閱這個事件,我們暫時把釋出事件的動作包裹在一個函式裡,這些包裝函式將被存入堆疊中,等到終於有物件來訂閱事件的時候,我們將遍歷棧並依次執行這些包裝函式,也就是重新發布這些事件裡面的事件。當然離線事件的生命週期只有一次,就像QQ的未讀訊息只會被重新閱讀一次,所以剛才的操作我們只能進行一次。

8.11 全域性事件的全名衝突

全域性的釋出—訂閱物件裡只有一個clientList來存放訊息名和回撥函式,大家都通過它來訂閱和釋出各種訊息,久而久之,難免會出現事件名衝突的情況,所以我們還可以給Event物件提供建立名稱空間的功能。

在提供最終的程式碼之前,我們來感受一下怎麼使用這兩個新增的功能

//********************
Event.trigger(‘click’,1);
Event.listen(‘click’,function(a){
  console.log(a); //輸出:1
});
//********************使用全名空間
Event.create(‘namespace1’).listen(‘click’,function(a){
   console.log(a); //
});
 
Event.create(‘namespace1’).trigger(‘click’,1);
 
//********************具體實現*****************
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 (vari = 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 (vari = 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') {
                           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 = arugments;
                        fn = function () {
                            return _trigger.appley(_self, args);
                        };
                        if (offlineStack) {
                            return offlineStack.push(fn);
                        }

                        return fn();
                    }
                };

            return namespace ? (namespaceCache[namespace] ?namespaceCache[namespace] : namespaceCache[namespace] = ret) : ret;
        };

        return {
             careate:_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;
})();

 

8.12 javascript實現釋出—訂閱模式的便利性

這裡提出的是,我們一直討論的釋出—訂閱模式,跟一些別的(java)語言中的實現還是有區別的。在java中實現一個自己的釋出—訂閱模式,通常會把訂閱者物件自身當成引用傳入釋出者物件中,同時訂閱者物件還需要提供一個名為諸如update方法,供釋出者物件在適合的時候呼叫。而在javascript中,我們用註冊回撥函式的形式來代替傳統的釋出—訂閱模式,顯得更加優雅和簡單。

另外,在javascript中,我們無需去選擇使用推模型還是拉模型。推模型是指在事件發生時,釋出者一次性把所有更改的狀態和資料都推送給訂閱者。拉模型不同的地方是,釋出者僅僅通知訂閱者事件已經發生了,此外發布者要提供一些公開的介面供訂閱者來主動拉資料。拉模型的好處是可以讓訂閱者“按需獲取”,但同時有可能讓釋出者變成一個“門戶大開”的物件,同時增加了程式碼量和複雜度。