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