来源:JavaScript设计模式与开发实践
【8发布-订阅模式】发布-订阅模式:又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象发生改变时,所有依赖于它的对象都将得到通知。在JS中,一般用事件模型来代替传统的发布-订阅模式。2. 发布-订阅模式的作用
- 发布订阅模式可以广泛应用于异步编程中,比如ajax中的succ,error等。
- 发布订阅模式可以取代对象之间硬编码的通知机制。一个对象不用再显式的调用另外一个对象的接口,让两个对象松耦合的联系在一起。
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.click();
4. 自定义事件
//简单的发布-订阅模式
var subscribe = {};
//定义发布者
subscribe.clientList = [];
//缓存列表,存放订阅者的回调函数
subscribe.listen = function(fn) { //增加订阅者
subscribe.clientList.push(fn);
//订阅的消息添加进缓存列表
};
subscribe.trigger = function() {
for (var i = 0, fn;
fn = this.clientList[i++];
) {
fn.apply(this, arguments);
}
};
subscribe.listen(function(price, squareMeter) {
console.log(squareMeter + ':' + price);
});
subscribe.trigger(200, 1);
subscribe.trigger(300, 2);
//缺点:订阅者接收到了发布者发布的每一个消息
增加标识Key,使订阅者只获取自己想要的消息
var subscribe = {};
//定义发布者
subscribe.clientList = [];
//缓存列表,存放订阅者的回调函数
subscribe.listen = function(key,fn) { //增加订阅者
if (!this.clientList[key]) {
this.clientList[key] = [];
}
this.clientList[key].push(fn);
//订阅的消息添加进缓存列表
};
subscribe.trigger = function() {
var key = [].shift.call(arguments);
//取出消息类型
var fns = this.clientList[key];
if (!fns || fns.length === 0) {
return false;
}
for (var i = 0, fn;
fn = fns[i++];
) {
fn.apply(this, arguments);
//发送消息时附送的参数,第一个参数消息类型已通过shift方法去除
}
};
subscribe.listen('click', function(status) {
console.log('click ' + status);
});
subscribe.listen('move', function(status) {
console.log('move ' + status);
});
subscribe.trigger('click', 'success');
subscribe.trigger('move', 'success too');
5. 发布-订阅模式的通用实现
//动态让对象都拥有发布-订阅模式
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);
var fns = this.clientList[key];
if (!fns || fns.length === 0) {
return false;
}
for (var i = 0, fn;
fn = fns[i++];
) {
fn.apply(this, arguments);
}
}
}
var installEvent = function(obj) {
for (const key in event) {
obj[key] = event[key];
}
} //installEvent 函数可以为所有对象动态安装发布-订阅模式
var subscribe = {};
installEvent(subscribe);
subscribe.listen('eventClick', function(res) {
console.log('eventClick ' + res);
});
subscribe.trigger('eventClick','success!');
6. 取消订阅
subscribe.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(l, 1);
}
}
}
}subscribe.listen('example', fn1 = function(res) {
console.log(1, res);
});
subscribe.listen('example', fn2 = function(res) {
console.log(2, res);
});
subscribe.remove('example',fn1);
subscribe.trigger('example', 'success');
7. example —— 网站登录
login.success(function(data) {
header.setAvatar();
// 设置header模块头像
nav.setAvatar();
//设置nav模块头像
message.refresh();
//刷新消息列表
cart.refresh();
//刷新购物车列表
});
上面这种情况,通过回调函数解决异步时,不同模块与登录模块产生了强耦合,这种耦合会使程序变得僵硬,这是针对具体实现编程的典型例子。
$.ajax('//xxx.login.com',function(data){
login.trigger('loginsucc',data);
});
var header = (function() {
login.listen('loginsucc', function(data) {
header.setAvatar(data.avatar);
});
return {
loginsucc: function(avatar) {
console.log('set header avatar success!');
}
}
})();
var nav = (function() {
login.listen('loginsucc', function(data) {
nav.setAvatar(data.avatar);
});
return {
nav: function(avatar) {
console.log('set nav avatar success!');
}
}
})();
用发布-订阅模式重写之后,对用户登录感兴趣的业务将自行订阅登录成功的事件,登录成功时,登录模块只需要发布登录成功的消息,业务方接收到消息之后开始进行各自的业务处理,登录模块不必再处理业务方的业务细节。
8.全局的 发布-订阅对象
var Event = (function() {
var clientList = [], listen, remove, trigger;
listen = function (key, fn) {
if (!clientList[key]) {
clientList[key] = [];
}
clientList[key].push(fn);
};
remove = function (key, fn) {
var fns = clientList[key];
if (!fns) return false;
if (!fn) {
fns.length = 0;
};
for (var i = fns.length;
i >= 0;
i--) {
var _fn = fns[i];
if (fn === _fn) {
fns.splice(i, 1);
}
}
};
trigger = function() {
var key = Array.prototype.shift.apply(arguments);
var fns = clientList[key];
if (!fns) return;
for( var i = 0;
i < fns.length;
i++) {
fn = fns[i];
fn.apply(this, arguments);
//改变this指向,使listen(key,fn)中fn的this指向Event
}
}
return {
listen: listen,
remove: remove,
trigger: trigger
};
/*研究后认为,作者之所以不写成 var Event = {clientList:[],listen:fn,remove:fn,trigger:fn},
* 而写成闭包方式,可能是不想暴露clientList,使Event只暴露出listen,remove,trigger三个方法
*/
})();
Event.listen('globalPublish', function(success) { //first subscribe
console.log(this);
console.log('first subscribe' + success);
});
Event.listen('globalPublish', function(success) { //second subscribe
console.log('second subscribe' + success);
});
Event.trigger('globalPublish', '1');
//the second publish
setTimeout(function() {
Event.trigger('globalPublish', '2s');
//the second publish
},2000);
/* 全局发布使得发布与订阅直接相互脱离。
* 从5的例子中可以看出具有以下两个问题:
* 每个发布者对象都需要listen,remove,trigger方法和一个缓存队列,十分浪费资源
* 发布者与订阅者还是具有一定的耦合性,订阅者必须知道发布者的名称,才可以进行订阅
*/
9.模块间通信
var a = (function() {
var count = 0;
botton = document.getElementById('a');
botton.onclick = function() {
Event.trigger('getCount',count++);
}
})();
var b = (function() {
var text = document.getElementById('b');
Event.listen('getCount', function(data) {
text.innerHTML = data;
});
})();
10. 先订阅后发布,与先发布与后订阅
正常情况:订阅者先订阅一个消息,发布者再发布消息,否则没有订阅,发布者发布的消息就没有对象来接收。
例外:某些情况,需要先将发布者发布的消息保存下来,等到有对象订阅时,再重新推给订阅者。
这种需求在实际项目中是存在的,比如在之前的商城网站中,获取到用户信息之后才能渲染用户导航模块,而获取用户信息的操作是一个 ajax 异步请求。当 ajax 请求成功返回之后会发布 一个事件,在此之前订阅了此事件的用户导航模块可以接收到这些用户信息。
而ajax请求时异步的,我们不能确定异步的请求时间,有可能在ajax请求成功时,nav模块还没有加载完成,此时还没有订阅事件,特别是在运用了一些模块化惰性加载的技术后,这种情况更加可能发生。
此时,我们需要建立一个存放离线事件的堆栈,如果此时还没有订阅事件,我们把发布事件的动作包裹在一个函数里,这些包装函数将被存放进堆栈中,等有订阅对象来订阅的时候,遍历堆栈并依次执行这些包装函数,即重新发布事件。并且注意离线事件的生命周期只有一次。
11. 全局事件的命名冲突
为Event对象提供创建命名空间的功能,防止事件命名冲突
var Event = (function() {
var glabal = this,
Event,
_default = 'default';
Event = (function() {
var _shift = Array.prototype.shift,//移除数组头部第一个元素并返回该元素
_unshift = Array.prototype.unshift,//在头部插入元素生成新的数组
nameSpaceCache = {}, //命名空间缓存存储
find;
var each = function(arys, fn) { //arys:一组函数
var ret;
for (var i = 0;
i < arys.length;
i++) {
varary = arys[i];
ret = fn.call(ary, i, ary);
//this指向ary所代表的函数本身
}
return ret;
};
var _listen = function(key, fn, cache) {
if (!cache[key]) { //是否存在该类消息订阅,若无,创建缓存列表
cache[key] = [];
}
cache[key].push(fn);
};
var _trigger = function() {
var cache = _shift.call(arguments), //订阅者的回调函数缓存队列
key = _shift.call(arguments), //订阅的事件类型
args = arguments, // 订阅事件发布的结果
_self = this,
ret,
stack = cache[key];
// 缓存的关于key的订阅者的回调函数if (!stack || !stack.length) { //若没有相关订阅
return;
}
return each(stack, function() {
// _self 此时指的是 _create函数所返回的对象,因为 _trigger被调用时,通过_trigger.apply(_self, args);
改变了this的指针
return this.apply(_self, args);
});
};
var _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] = [];
}
}
};
var _create = function(namespace) {
var namespace = namespace || _default, //命名空间
cache = {}, //缓存列表,存放订阅者的回调事件
offlineStack = [];
//离线发布者缓存
var ret = {
listen: function(key, fn, last) {
/*_create函数返回一个可以调动函数的对象,生成一个闭包,
*每一个命名空间内的订阅相同内容即(key)的订阅事件都被存储近cache,
*cache 在闭包中被保存起来,每生成不同的
*/
_listen(key, fn, cache);
// 向缓存列表插入订阅者的回调事件
if (offlineStack === null) { // 判断是否有还存在离线事件,如果没有,则不再执行离线事件
return;
}
if (last === 'last') { //执行离线事件队列中的最后一个
offlineStack.length && offlineStack.pop()();
//pop()方法 remove the last element from the array and return the element
//offlineStack.pop()是函数,offlineStack.pop()()代表执行该函数
} else { //依次执行离线事件
each(offlineStack, function() {
this();
// 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;
//this指_create函数返回的对象;作为对象属性调用,指向当前对象
_unshift.call(arguments, cache);
// 往arguments头部加入cache,生成一个新数组args = arguments;
fn = function() { // 把发布事件的动作包裹在函数里,在有事件订阅后再调用
return _trigger.apply(_self, args);
}
if (offlineStack) { // 如果listen方法还未被调用,即离线事件还未被执行,将离线事件存储中插入包装好的函数
return offlineStack.push(fn);
}
return fn();
// 若已有订阅,则调用fn函数,处理订阅事件发布的函数
}
};
return namespace ? (nameSpaceCache[namespace] ? nameSpaceCache[namespace] : nameSpaceCache[namespace] = ret) : ret;
};
return {
create: _create,
one: function(key, fn, last) { // one函数指订阅key的事件保证只有一个订阅者
var event = this.create();
event.one(key, fn, last);
},
listen: function(key, fn, last) {
var event = this.create();
event.listen(key, fn, last);
},
remove: function(key, fn) {
var event = this.create();
event.remove(key, fn);
},
trigger: function() {
var event = this.create();
event.trigger.apply(this.arguments);
}
}
})();
return Event;
})();
Event.create('namespace1').trigger('click','namespace1 success');
Event.create('namespace1').listen('click', function(res) {
console.log(res);
});
Event.create('namespace2').listen('click', function(res) {
console.log(res,222);
});
Event.create('namespace2').one('click', function(res) {
console.log(res,111);
});
Event.create('namespace2').one('click', function(res) {
console.log(res,123);
});
Event.create('namespace2').trigger('click', 'success2');
Event.create('namespace2').trigger('click', 'success2');
12 JS实现发布-订阅模式的便利性
js中实现发布-订阅模式与别的语言实现方式不同。在Java中,实现发布订阅模式,通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需要提供一个名为例如update的方法,供发布者对象在合适的时候调用。在js中,用注册回调函数的形式来替代传统的发布-订阅模式,显得更加简洁和优雅。
另外,在JS中无需选择使用推模式还是拉模式。推模式是指事件发生时,发布者一次性把所有更改状态和数据都退送给订阅者;而拉模式是发布者仅仅通知订阅者事件发生了,此外发布者要提供一些公开的接口供订阅者主动拉取数据,拉模型好处是可用让订阅者按需获取,但同时有可能让发布者变成一个门户大开的对象,同时增加了代码量和复杂度。
刚好在js中,arguments可用很方便的表示参数列表,因为我们一般会选择推模型,使用function.prototype.apply来把所有参数推给订阅者。
13小结
优点:一是时间上的解耦,二是对象上的解耦。
缺点:创建订阅要消耗一定时间和内存,而当订阅一件时间后,也可能此消息最后都没有发生,但这个订阅会始终存在内存中。并且发布-订阅模式虽然可以弱化对象之间的联系,但过度使用的话,对象与对象间的必要联系也将深埋在背后,会导致程序难以跟踪维护和理解。