第八章 發佈-訂閱模式
發佈—訂閱模式描述
- 發佈—訂閱模式又叫觀察者模式,它定義對象間的一種一對多的依賴關係,當一個對象的狀態發生改變時,所有依賴於它的對象都將得到通知。
- 發佈—訂閱模式可以廣泛應用於異步編程中,這是一種替代傳遞迴調函數的方案。
- 發佈—訂閱模式可以取代對象之間硬編碼的通知機制,一個對象不用再顯式地調用另外一個對象的某個接口。
發佈—訂閱模式讓兩個對象鬆耦合地聯繫在一起,雖然不太清楚彼此的細節,但這不影響它們之間相互通信。當有新的訂閱者出現時,發佈者的代碼不需要任何修改;同樣發佈者需要改變時,也不會影響到之前的訂閱者。只要之前約定的事件名沒有變化,就可以自由地改變它們。
DOM 事件:在DOM 節點上面綁定過事件函數就是發佈—訂閱模式。
自定義事件:我們還會經常實現一些自定義的事件,這種依靠自定義事件完成的發佈—訂閱模式可以用於任何JavaScript 代碼中。
實現發佈—訂閱模式
- 首先要指定好誰充當發佈者(比如售樓處);
- 然後給發佈者添加一個緩存列表,用於存放回調函數以便通知訂閱者(售樓處的花名冊);
- 最後發佈消息的時候,發佈者會遍歷這個緩存列表,依次觸發裏面存放的訂閱者回調函數(遍歷花名冊,挨個發短信)。
- 另外,我們還可以往回調函數裏填入一些參數,訂閱者可以接收這些參數。
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 平方米房子的價格
發佈-訂閱模式的通用實現:所以我們把發佈—訂閱的功能提取出來,放在一個單獨的對象內
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 時帶上的參數
}
}
};
var installEvent = function (obj) {
for (var i in event) {
obj[i] = event[i];
}
};
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 = 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 頭部、nav 導航、消息列表、購物車之外,將來還有哪些模塊需要使用這些用戶信息,需要發佈—訂閱模式方便後期擴展。
var login = {
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 時帶上的參數
}
}
};
$.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 模塊的頭像');
}
}
})();
var nav = (function () { // nav 模塊
login.listen('loginSucc', function (data) {
nav.setAvatar(data.avatar);
});
return {
setAvatar: function (avatar) {
console.log('設置nav 模塊的頭像');
}
}
})();
var address = (function () { // nav 模塊
login.listen('loginSucc', function (obj) {
address.refresh(obj);
});
return {
refresh: function (avatar) {
console.log('刷新收貨地址列表');
}
}
})();
全局的發佈-訂閱對象
實現發佈—訂閱模式例子中還有兩個問題:
- 我們給每個發佈者對象都添加了listen 和trigger 方法,以及一個緩存列表clientList,這其實是一種資源浪費。
- 訂閱對象跟發佈對象還是存在一定的耦合性,訂閱對象至少要知發佈對象的名字是salesOffices,才能順利的訂閱到事件。
發佈—訂閱模式可以用一個全局的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 模塊可以在保持封裝性的前提下進行通信。
<!DOCTYPE html>
<html>
<body>
<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>
</body>
</html>
先訂閱再發布
- 我們所瞭解到的發佈—訂閱模式,都是訂閱者必須先訂閱一個消息,隨後才能接收到發佈者發佈的消息。如果把順序反過來,發佈者先發布一條消息,而在此之前並沒有對象來訂閱它,這條消息無疑將消失在宇宙中。在某些情況下,我們需要先將這條消息保存下來,等到有對象來訂閱它的時候,再重新把消息發佈給訂閱者。就如同QQ 中的離線消息一樣,離線消息被保存在服務器中,接收人下次登錄上線之後,可以重新收到這條消息。
爲了滿足這個需求,我們要建立一個存放離線事件的堆棧,當事件發佈的時候,如果此時還沒有訂閱者來訂閱這個事件,我們暫時把發佈事件的動作包裹在一個函數裏,這些包裝函數將被存入堆棧中,等到終於有對象來訂閱此事件的時候,我們將遍歷堆棧並且依次執行這些包裝函數,也就是重新發布里面的事件。當然離線事件的生命週期只有一次,就像QQ 的未讀消息只會被重新閱讀一次,所以剛纔的操作我們只能進行一次。
全局事件的命名衝突
全局的發佈—訂閱對象裏只有一個clinetList 來存放消息名和回調函數,大家都通過它來訂閱和發佈各種消息,久而久之,難免會出現事件名衝突的情況,所以我們還可以給Event 對象提供創建命名空間的功能。
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;
})();
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);