5 发布订阅模式(观察者模式)
发布订阅模式定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知;
5.1 发布- 订阅模式的作用
发布—订阅模式的应用都非常之广泛,首先看一个现实中的例子,小明最近在看房子,到了某个售楼处之后才被告知,该楼盘的房子早已售罄,小明离开之前,把电话号码留在了售楼处。售楼处答应他,新楼盘一推出就马上发信息通知小明。小红、小强和小龙也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼 MM 会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们;
在这个例子中使用发布—订阅模式有着显而易见的优点:
- 购房者不用再天天给售楼处打电话咨询开售时间,在合适的时间点,售楼处作为发布者会通知这些消息订阅者。这点说明发布—订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案;
- 购房者和售楼处之间不再强耦合在一起,当有新的购房者出现时,他只需把手机号码留在售楼处,售楼处不关心购房者的任何情况,同时售楼处的任何变动也不会影响购买者,只要售楼处记得发短信这件事情。这点说明发布—订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布—订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就可以自由地改变它们。
5.2 发布订阅模式的实现
- 实现上述发布订阅模式实例步骤:
- 首先要指定好谁充当发布者(比如售楼处);
- 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者(售楼处的花名册);
- 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数(遍历花名册,挨个发短信)。
var salesOffices = {}; // 定义售楼处
salesOffices.clientList = []; // 缓存列表,存放订阅者的回调函数
salesOffices.listen = function( fn ){ // 增加订阅者
this.clientList.push( fn ); // 订阅的消息添加进缓存列表
};
salesOffices.trigger = function(){ // 发布消息
for( var i = 0, fn; fn = this.clientList[ i++ ]; ){
f n.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 平方米
- 发布订阅模式的通用实现
// 第一步:把发布订阅的功能提取出来,放在一个单独的对象内
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 时带上的参数
}
}
};
// 第二步:定义 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( 'squareMeter100', function( price ){ // 小红订阅消息
console.log( '价格= ' + price );
});
salesOffices.trigger( 'squareMeter88', 2000000 ); // 输出: 2000000
salesOffices.trigger( 'squareMeter100', 3000000 ); // 输出: 3000000
5.3 取消订阅的事件
有时也许需要取消订阅事件的功能,因此给 event
对象增加 remove
方法,如下:
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
5.4 网站登录实例(P116)
5.5 全局的发布订阅对象
刚刚实现的发布订阅模式,给售楼处对象和登录对象都添加了订阅和发布的功能,这里还存在两个小问题:
- 给每个发布者对象都添加了
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 ); // 售楼处发布消息
5.6 模块间通信
我们利用上一节中实现的发布订阅模式的全局 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' );
Event.listen( 'add', function( count ){
div.innerHTML = count;
});
})();
</script>
</html>
5.7 JavaScript 实现发布订阅模式的便利性
在 JavaScript 中,无需去选择使用推模型还是拉模型。推模型是指在事件发生时,发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型不同的地方是,发布者仅仅通知订阅者事件已经发生了,此外发布者要提供一些公开的接口供订阅者来主动拉取数据。拉模型的好处是可以让订阅者“按需获取”,但同时有可能让发布者变成一个“门户大开”的对象,同时增加了代码量和复杂度。刚好在 JavaScript
中, arguments
可以很方便地表示参数列表,所以我们一般都会选择推模型,使用 Function.prototype.apply
方法把所有参数都推送给订阅者。
5.8 发布订阅模式小结
发布订阅模式是一种非常重要的模式,在实际开发中非常有用。既可以用在异步编程中,也可以帮助完成更松耦合的代码编写。发布订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。 从架构上来看,无论是 MVC 还是 MVVM,都少不了发布—订阅模式的参与,而且 JavaScript 本身也是一门基于事件驱动的语言。
发布订阅模式的优点一为时间上的解耦,二为对象之间的解耦。发布订阅模式缺点就是创建订阅者本身要消耗一定的时间和内存,若订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中;若过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
系列链接
- JavaScript 设计模式(上)——基础知识
- JavaScript 设计模式(中)——1.单例模式
- JavaScript 设计模式(中)——2.策略模式
- JavaScript 设计模式(中)——3.代理模式
- JavaScript 设计模式(中)——4.迭代器模式
- JavaScript 设计模式(中)——5.发布订阅模式
- JavaScript 设计模式(中)——6.命令模式
- JavaScript 设计模式(中)——7.组合模式
- JavaScript 设计模式(中)——8.模板方法模式
- JavaScript 设计模式(中)——9.享元模式
- JavaScript 设计模式(中)——10.职责链模式
- JavaScript 设计模式(中)——11. 中介者模式
- JavaScript 设计模式(中)——12. 装饰者模式
- JavaScript 设计模式(中)——13.状态模式
- JavaScript 设计模式(中)——14.适配器模式
- JavaScript 设计模式(下)——设计原则
- JavaScript 设计模式练习代码
本文主要参考了《JavaScript设计模式和开发实践》一书