JavaScript设计模式笔记-发布-订阅模式

新年快乐~

定义

发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都讲得到通知.

8.1 现实中的发布-订阅模式

比如买房,售楼处与买房者的关系.不可能买房者整天打电话过去 而是售楼处记录下这些买房者的电话 一但有合适的楼层再打电话过去.

8.2 发布-订阅模式的作用

在上述的例子中使用发布-订阅模式有显而易见的优点

  • 购房者不用再每天给售楼处打电话咨询问题.售楼处会作为发布者通知这些消息订阅者
  • 购房者和售楼处之间不再强耦合在一起,当有新的购房者出现的时候,他只需要把手机号留给售楼处.不管售楼处换了几个售楼MM离职都不会影响购房者

第一点说明发布-订阅模式可以广泛应用各种异步编程中,这是一种替代传递回调函数的方案.比如ajax请求中的error,succ事件.
第二点说明发布-订阅模式可以取代对象之间的硬编码的通知机制.一个对象不再显式调用另一个对象的某个接口.

8.3 DOM事件

实际上DOM事件就是一种发布-订阅模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.click(); // 模拟用户点击
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.addEventListener( 'click', function(){
1128 章 发布订阅模式
alert(3);
}, false );
document.body.addEventListener( 'click', function(){
alert(4);
}, false );

这里不过多记笔记..直接来实现吧.

8.4 自定义事件

除了DOM事件,我们还会经常实现一些自定义事件,这种依靠自定义事件完成的发布-订阅模式可以用于任何JavaScript代码中.

实现发布-订阅模式的步骤

  • 首先要指定好谁充当发布者(比如售楼处)
  • 然后给发布者添加一个缓冲列表,用于存放回调函数以便通知订阅者(售楼处的花名册)
  • 最后发布消息的时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数.
1
2
3
4
5
6
7
8
9
10
11
12
13
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);//arguments是发布消息时带上的参数
}
}

下面来进行一些简单的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//下面我们来进行一些简单的测试:
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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=[].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);//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 平方米房子的价格

这样订阅者就可以定义自己感兴趣的事情了.

8.5 发布-订阅模式的通用实现

这段代码在salesOffices这个对象上实现的,但是我想在salesPeople(笑)这个对象上实现呢..是否必须重写对象?答案是不必的 只需要执行一个安装函数
这里用继承也可以实现吧?

我们先把通用代码提取出来,单独放在一个对象中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var event={
clientList:[],
listen:function(key,fn){
if(!this.clientList[key]){
this.clientList[key]=[];
}
this.clientList[key].push(fn)//添加进缓存列表
},
trigger:function(){
var key=[].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);//arguments是发布消息时带上的参数
}
}
};
//定义一个安装函数
var installEvent=function(obj){
for(var i in event){
obj[i]=event[i]
}
}

实际上这个安装函数就是copy功能..但是这个copy又分浅拷贝和深拷贝.这里不做解释.有兴趣的可以去看看jquery的extend
再来测试一下

1
2
3
4
5
6
7
8
9
10
11
//再来测试一番,我们给售楼处对象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

8.6 取消订阅的事件

比如DOM操作中的removeListen这个函数一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
event.remove=function(key,fn){
var fns=this.clientList[key];
if(!fns||fns.length==0){//如果key对应的消息没有被人订阅 则直接返回false
return false
}
if(!fn){//如果没有传入具体的回调函数,则表示需要取消key对应消息的所有订阅
fns&&(fns.length==0)//这里的&&运算符始终会返回最后一个数值 也就是说会执行fns和fns.length==0 其中有一个为false则不执行下一个
}else{
for(var l=fns.length-1;l>=0;i--){//反向遍历订阅的回调函数列表
var _fn=fns[l];
if(_fn===fn){
fns.splice(l,1);//删除订阅者的回调函数
}
}
}
}
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

这里判断删除也就是说移除的办法是 判断移除时候传递的回调函数是否等于绑定时候的回调函数

8.7 真实的例子——网站登陆

比如一个网站有header,nav,message等模块 且这些模块的内容是ajax加载后才生成的.
单纯的异步回调函数会造成一种耦合性 比如

1
2
3
4
5
6
login.succ(function(data){
header.setAvatar(data.avatar);//设置header模块的头像
nav.setAvater(data.avatar);//设置导航模块的头像
message.refresh();//刷新消息列表
cart.refresh();//刷新购物车列表
})

这里就会有一种耦合性.因为setAvatar这个方法我们不能变更 一变更各个地方都需要变更 而且当需要加载的模块越来越多 这个回调函数也会越来越大
比如某天需要增加一个收获地址列表于是又得在这个回调函数中加上一个更新函数

1
2
3
4
5
6
7
login.succ(function(data){
header.setAvatar(data.avatar);//设置header模块的头像
nav.setAvater(data.avatar);//设置导航模块的头像
message.refresh();//刷新消息列表
cart.refresh();//刷新购物车列表
address.refresh();//刷新地址
})

用了发布-订阅模式重写后,对用户信息感兴趣的业务模块将自行订阅登陆成功的消息事件,当登陆成功后,登陆模块只需要发布登陆成功的消息,而业务方接受到消息之后,就会开始进行各自的业务处理。登陆模块并不关心业务方究竟要做什么,也不想去了解它们内部的细节.

1
2
3
$.ajax("http://xxx.com?login",function(data){//登陆成功
login.trigger("loginSucc",data);//发布登陆成功的消息
});

各个模块监听登陆成功的消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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( '刷新收货地址列表' );
}
}
})();

就算事后需要增加其他模块都不需要往login中塞填函数了.

8.8 全局的发布-订阅对象

刚刚编写的发布订阅函数还存在两个问题

  • 我们给每个发布者对象都添加了listen和trigger方法,以及一个缓存列表clientList,这其实是一种资源浪费
  • 购房者跟售楼处还存在一定的耦合性 购房者必须要知道售楼处对象名字是salesOffices才能顺利订阅到事件
1
2
3
salesOffices.listen("squareMeter100",function(price){//小明订阅消息
console.log("价格:",price)
})

如果小明(购房者)需要订阅300平方米的消息 而这套房子的卖家是salesOffice2 这意味着小明要开始订阅salesOffices2对象

1
2
3
salesOffices2.listen("squareMeter300",function(price){//小明订阅消息
console.log("价格:",price)
})

在现实中买房都未必要去售楼处 都是通过中介公司.
同样,在程序中我们也可以用一个中介公司来实现发布和订阅,订阅者不需要了解消息来自哪个发布者,Event类似于一个中介的角色,把发布者和订阅者联系起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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
}
})()

..观察这个函数…实际上就封装了一层. 核心代码并没有变.就不做进一步解释了.

8.9 模块间通信

比如现在我们有两个点击元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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' );
1208 章 发布订阅模式
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>

我们用订阅——发布模式来做数据通信.但这样会引发一个问题,模块之间如果用了太多的全局发布-订阅模式来通信就会搞不清消息来自哪个模块 而导致后期维护变得比较困难

8.10 必须订阅再发布吗

类似于QQ离线消息一样,当检测到订阅者则先建立一个离线事件的堆,当事件发布的时候,如果此时还没有订阅者来定义这个事件,我们暂时把发布事件的工作包裹在一个函数里,这些包装函数将被存入堆,等到终于有对象来订阅此事件的时候,我们将遍历离线事件堆栈并且一次执行这些包装函数..当然离线事件的生命周期只有一次,就像QQ的未读消息只会被重新阅读一次,所以我们的操作只能进行一次.

8.11 全局事件的命名冲突

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//先发布后订阅
Event.trigger("click",1);
Event.listen("click",function(a){
console.log(a);//1
})
//使用命名空间
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)

第一段函数没有创建命名空间来监听和触发函数 实际上内部已经创建了一个命名空间为default,当先发布的时候实际上会添加进一个offlineStack这个堆里面,当监听的时候检测到有offlineStack则会先触发所有的离线事件…(话说每次监听都会触发离线事件)而且监听一次后则会把所有的离线事件清空 那遇到这种情况怎么办?)

1
2
3
4
5
Event.trigger("click",1);
Event.trigger("click2",2)
Event.listen("click",function(a){
console.log(a);//1
})

这种情况因为当监听的时候会清除所有的离线事件 那么这个click2没有进行监听就算下次再监听也会被清除. 也就是说只支持缓存一次离线事件

下面哪种情况也是一样的 不多说.

具体代码实现如下:(作者仅仅放出了函数代码,并未做解释..本渣渣来读一读代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
var Event=(function(){
var global=this,
Event,
_default="default";//没有使用命名空间或者创建命名空间未传递名字时候使用的默认的命名空间
//Event这个是封装函数 所有的方法都在Event函数里
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){//遍历工具函数
//ary:数组,fn:遍历回调函数
var ret;
for(var i=0;l=ary.length;i<l;i++){
var n=arr[i];//数组元素
ret=fn.call(n,i,n);//回调函数传递参数 数组下标i 数组元素n 作用域为n 后面的_create中this()看这段解释会比较有用
}
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){//如果移除的时候传递了fn那么就删除cache中对应的订阅者
for(var i=cache[key].length;i>=0;i--){//反向遍历
if(cache[key][i]===fn){//如果存在则删除
cache[key].splice(i,1)
}
}
}else{//没有传递fn那么则定因为删除该事件中所有的订阅者
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 false;
}
return each(stack,function(){//遍历事件堆 分别发布事件 这里的this指向的是stack[key]中的函数
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();//执行每条离线事件 每个离线消息都是一个function
})
}
offlineStack=null;//清除离线消息缓存
}
},
one:function(key,fn,last){//命名空间 监听一次函数 类似于jquery.one
_remove(key,cache);//没有传递fn那么则定因为删除该事件堆中所有的订阅者
this.listen(key,fn,last);//重新创建监听函数
},
remove:function(key,fn){
_remove(key,cache,fn);//传递fn那么则定因为删除该事件堆中的订阅者
},
trigger:function(){
var fn,
args,
_self=this;
_unshift.call(arguments,cache);//把cache添加进arguments中
args=arguments;
fn=function(){
return _trigger.apply(_self,args);//触发函数 这里在去看_trigger 那么就比较好解释了为什么会弹出两次arguments
};
if(offlineStack){//如果离线事件存在话
return offlineStack.push(fn)//添加进离线事件
}
return fn();//返回当前fn函数
};
//给全局的namespace添加上当前的ret函数 下次调用则直接从全局的namespace查找然后返回相应的ret
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
})()

8.13 小结

发布-订阅模式优点非常明显,一为时间上的解耦,二为对象之间的解耦.它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更加松耦合的代码编写.MVC MVVM这些框架也离不开发布-订阅模式的参与 而且JavaScript本身也是一门基于事件驱动的语言.
当然 如果过度使用这种模式 特别是有多个发布者和订阅者嵌套在一起的时候 要跟踪一个bug不是一件轻松的事情