theme: channing-cyan
highlight: vs2015
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
概念
在《JavaScript设计模式与开发实践》 中对发布-订阅模式的定义为 定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。。在
JavaScript
开发中,我们一般用事件模型来替代传统的发布-订阅模式。
JS中的发布-订阅模式
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
看到上面代码,相信大家也能明白类似场景应该如何去实现:需要新增一个offlineStack
变量去保存我们的离线信息,我们添加一条消息订阅时,会到离线数据中找是否有历史信息,如果有的话,将其依次执行,并将历史数据清空即可。
上面这种方式是我自行实现的,在书中也有更完善的代码,不仅支持先发布再订阅的逻辑,也添加了命名空间的概念,可以有效的避免因长期维护导致命名冲突的问题,下面就是书中的原始代码:
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)
}
_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)
})
}
_remove = function () {}
_create = function (namespace) {
var namespace = namespace || _default
var cache = {},
offlineStack = [],
ret = {
listen: function (key, fn) {
_listen(key, fn, cache)
if (offlineStack === null) {
return
} else {
each(offlineStack, function () {
this()
})
}
offlineStack = null
},
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()
},
remove: function (key, fn) {
_remove(key, cache, fn)
},
}
return namespace
? namespaceCache[namespace]
? namespaceCache[namespace]
: (namespaceCache[namespace] = ret)
: ret
}
return {
create: _create,
remove: function (key, fn) {
var event = this.create()
event.remove(key, fn)
},
listen: function (key, fn) {
var event = this.create()
event.listen(key, fn)
},
tirgger: function () {
var event = this.create()
event.trigger.apply(this, arguments)
},
}
})()
return Event
})()
// 可以通过这种方式创建一个命名空间
Event.create('name1').trigger('hello')
Event.create('name1').trigger('world')
Event.create('name1').listen('hello', function () {
console.log('hello')
})
Event.create('name1').listen('world', function () {
console.log('world')
})
书中的代码,理解起来可能比较吃力,但其核心的逻辑是一致的,我也是根据书中源码进行了一定的简化,只是为了方便大家理解这种思路,如果在项目中实际使用,还是推荐大家参考书中的代码实现。但书中这种方式有一个问题:由于
offlineStack
是一个数组,在每次添加订阅时(listen
)都会判断是否有离线消息,执行完成后会设置为null
,这样就会导致: 当我们事先发布了两条不同的消息时,再次添加订阅只会执行第一条订阅的历史消息。如上的代码,最终只会执行hello
,也正因为如此,我将offlineStack
改成了对象的形式,这样我们可以仅清空对应事件的历史消息即可。
源码中的发布-订阅模式
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中的源码也非常容易理解了呢。
总结
这节没有介绍发布-订阅模式的应用,一个原因是因为相信大家已经很熟悉使用场景啦,还有就是其实不管是什么场景,我们的实现逻辑都是相似的,所以也就没有再单独介绍了。
发布-订阅模式优点: 时间上的解耦,对象间的解耦,既可以应用在异步编程中,也可以帮助我们完成更松耦合的代码编写。
发布-订阅模式缺点:创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中;第二点,发布-订阅模式如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。
感谢阅读 🙏
暂无评论内容