设计模式之发布-订阅模式
参考资料
- 曾探《JavaScript设计模式与开发实践》;
- JavaScript设计模式之发布-订阅模式
定义
定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。。在JavaScript
开发中,我们一般用事件模型来替代传统的发布-订阅模式。
使用场景:
- DOM事件;
- Vue EventBus;
DOM事件
只要我们在DOM节点上绑定过事件函数,那我们就算是使用过发布-订阅模式,代码如下:
document.body.addEventListener('click', function() {
console.log(1);
}, false)
这里,我们就订阅document.body
上的click
事件,当body
被点击时,body
节点便会向订阅者发布这个消息,当然我们还可以随意的添加或者删除订阅者,代码如下:
funtion func() {
console.log(2)
}
// 添加订阅者
document.body.addEventListener('click', func, false)
// 删除订阅者
document.body.removeEventListener('click', func)
发布-订阅模式的通用实现
var event = {
clientList: [],
// 添加订阅
listen: function(key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = [];
}
// 订阅的消息添加进缓存列表
this.clientList[key].push(fn);
},
// 取消订阅
remove: function(key, fn) {
var fns = this.clientList[key];
if (!fns) {
return false;
}
// 没有传入fn,则表示需要取消key对应的所有订阅消息
if (!fn) {
fns && (fns.length = 0);
} else {
// 取消fn的订阅
for (var l = fns.length - 1; l >= 0; l--) {
var _fn = fns[l];
if (_fn === fn) {
fns.splice(l, 1);
}
}
}
},
// 触发订阅
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)
}
}
}
我们可以通过调用event.listen('eventName', func)
添加一个eventName
的消息订阅,其中订阅回调函数为func
,在需要的地方,我们调用event.trigger('eventName')
方法来发布eventName
消息,此时会执行所有订阅的函数。remove
的逻辑也非常简单,找到需要删除的方法,从订阅列表中删除即可。
先发布再订阅
按照之前的例子,我们必须先订阅一个事件,然后才能收到发布者发布的消息。那么如果我们在订阅前已经有了发布信息,是不是订阅后之前的消息就再也找不到了呢?在某些场景下,我们也需要之前的消息,比如QQ的离线消息
,在我们再次订阅时,需要重新收到之前的消息,那么这个应该如何实现呢?
先说思路,其实这里我们就需要一个缓存数据,将之前发布的消息进行缓存,当我们订阅一个消息后,我们会遍历之前的缓存消息,找到之前发布过的历史消息,这样我们在订阅消息时,也可以同时收到之前的历史消息了。那么我们的代码应该如何实现呢?这里我简单的修改listen
和trigger
方法:
// 离线事件
offlineStack: {},
// 添加订阅
listen: function (key, fn) {
if (!this.clientList[key]) {
this.clientList[key] = []
}
// 订阅的消息添加进缓存列表
this.clientList[key].push(fn);
// 有离线事件时,需要将缓存的离线事件也执行
if (this.offlineStack[key] && this.offlineStack[key].length) {
this.offlineStack[key].forEach(cacheFn => {
cacheFn.call(this, key)
});
this.offlineStack[key] = null;
}
},
// 触发订阅
trigger: function () {
var key = Array.prototype.shift.call(arguments),
fns = this.clientList[key];
if (!fns || fns.length === 0) {
const cacheFn = function () {
return event.trigger.apply(this, arguments)
}
// 如果没有订阅事件时,需要将触发事件进行缓存
if (!this.offlineStack[key]) {
this.offlineStack[key] = [cacheFn]
} else {
this.offlineStack[key].push(cacheFn)
}
return false
}
for (var i = 0, fn; (fn = fns[i++]); ) {
fn.apply(this, arguments)
}
},
// 测试执行效果先订阅,后监听
event.trigger('hello')
event.trigger('world')
event.listen('hello', function () {
console.log('listen hello')
})
event.listen('world', function () {
console.log('listen world')
})
// listen hello
// listen world
全局事件的命名冲突
添加了命名空间的概念,可以有效的避免因长期维护导致命名冲突的问题:
<script type="text/javascript">
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 ] === 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' ){
}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;
})();
</script>
源码中的发布-订阅模式(Vue EventBus)
在vue中我们通常使用EventBus
来实现兄弟组件中的通信,EventBus
又称为事件总线,相当于一个事件中心,我们可以向该中心注册、发送或接收事件。就相当于我前面介绍的event
一样,属于发布-订阅模式。那么,我们一起来看下Vue源码是如何实现这个发布-订阅的呢?
Vue
中的实现是在src/core/instace/events.js
文件下的eventsMixin
方法。
-
先看
$on
方法,当调用$on
方法时,会将回调函数fn
存入到vm._events
中,代码如下:Vue.prototype.$on = function (event, fn) { const vm = this if (Array.isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { vm.$on(event[i], fn) } } else { (vm._events[event] || (vm._events[event] = [])).push(fn) // optimize hook:event cost by using a boolean flag marked at registration // instead of a hash lookup if (hookRE.test(event)) { vm._hasHookEvent = true } } return vm }
可以看到,
$on
方法的思路也是一样,存入到_events
中,不过vue中的还支持了传入数组,传入数组时,可以批量添加订阅。 -
再看
$emit
方法,当调用$emit
方法时,会取出之前$on
的事件,然后依次执行,代码如下:Vue.prototype.$emit = function (event) { const vm = this let cbs = vm._events[event] if (cbs) { cbs = cbs.length > 1 ? toArray(cbs) : cbs const args = toArray(arguments, 1) const info = `event handler for "${event}"` for (let i = 0, l = cbs.length; i < l; i++) { invokeWithErrorHandling(cbs[i], vm, args, vm, info) } } return vm } // invokeWithErrorHandling 方法 export function invokeWithErrorHandling (handler, context, args, vm, info) { let res try { res = args ? handler.apply(context, args) : handler.call(context) if (res && !res._isVue && isPromise(res) && !res._handled) { res.catch(e => handleError(e, vm, info + ` (Promise/async)`)) // issue #9511 // avoid catch triggering multiple times when nested calls res._handled = true } } catch (e) { handleError(e, vm, info) } return res }
到这里,我们已经了解了Vue中实现发布-订阅模式的方式,当然vue中还实现了
$off
取消监听、$once
函数只执行一次的方法,也都非常容易理解,这里就没有展开介绍了,感兴趣的同学可以自己去了解一下就好。是不是感觉Vue中的源码也非常容易理解了呢。
优缺点
- 优点: 时间上的解耦,对象间的解耦,既可以应用在异步编程中,也可以帮助我们完成更松耦合的代码编写。
- 缺点:创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中;第二点,发布-订阅模式如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。