手写源码之EventBus及其优缺点分析
一、前言
在如今的WebComponent开发中,组件间通信是我们避不开的话题,兄弟组件之间通信更是其中难题。今天我们就来聊一聊兄弟组件通信方法中的EventBus通信,并尝试手写一下其源码。
二、怎么用
手写源码的一个思路就是,从使用方式入手,倒推回去写。那我们就先来看看EventBus是怎样使用的吧。
EventBus,顾名思义,就是事件中心。在这里统一管理事件的发布与订阅。
一般为了方便使用,我们会将EventBus的实例挂载到全局对象上面。例如,在Vue中:
// main.js 中
import Vue from 'vue'
import EventBus from './EventBus'
Vue.prototype.$bus = new EventBus()
需要接受通信内容的组件就是订阅者,我们在这里监听(订阅)这个事件:
// receiver.vue中
this.$bus.on('eventName', (params) => {
console.log(params)
})
而需要将信息传递出去的组件,就是我们的发布者,我们要在这里发起触发这个事件
// publisher.vue中
this.$bus.emit('eventName', params)
这就是EventBus常见的使用方式了。
三、实现一个丐版的EventBus
通过上面的用法,我们可以看到,我们需要一个on函数来注册事件,需要一个emit函数来触发事件。当事件触发时,如果我们有注册这个事件,就会去调用这个事件的回调函数来处理这个事件,所以我们还需要一个对象来记录哪些事件是注册过了的。
class MyBus {
constructor() {
this.busMap = {}
}
on() {}
emit() {}
}
接着分析,我们在使用on注册的时候,是需要接受事件的名字和对应的回调函数的。同一个事件可能会在多个地方注册,并且有不同的处理方式,所以我们在busMap中用数组来收集这些回调函数。在emit的时间,我们查看这个事件是否已经注册,注册了就执行他们的回调函数:
class MyBus {
constructor() {
this.busMap = {}
}
on(name, callback) {
if (!this.busMap[name]) {
// 如果没注册过,就注册一下,用数组来收集回调函数
this.busMap[name] = []
}
this.busMap[name].push(callback)
}
emit(name) {
if (this.busMap[name]) {
this.busMap[name].forEach((callback) => callback())
}
}
}
我们来试试能否正常使用:
const bus = new MyBus()
bus.on('test', () => {
console.log('test 触发了')
})
bus.on('test', () => {
console.log('test 触发第二次了')
})
bus.emit('test')
// test 触发了
// test 触发第二次了
四、实现一个传参版EventBus
我们在实际使用中,有时候除了告知兄弟组件发生了某件事之外,还需要附带参数,那我们可以对emit函数稍加改造:
class MyBus {
constructor() {
this.busMap = {}
}
on(name, callback) {
if (!this.busMap[name]) {
// 如果没注册过,就注册一下,用数组来收集回调函数
this.busMap[name] = []
}
this.busMap[name].push(callback)
}
emit(name, ...args) {
if (this.busMap[name]) {
this.busMap[name].forEach((callback) => callback(...args))
}
}
}
只需要在emit的时候,将参数传递给之前收集的回调函数即可
五、实现可取消订阅版的EventBus
因为我们的bus挂载在全局对象上面,而我们使用了数组来收集回调函数,所以如果我们不取消订阅的话,全局对象占用内容会越来越大,所以我们需要在合适的时候取消订阅,一般是使用off函数(与on函数对应)来取消订阅。于是我们可以很轻松地写出一个off函数:
class MyBus {
constructor() {
this.busMap = {}
}
on(name, callback) {
if (!this.busMap[name]) {
// 如果没注册过,就注册一下,用数组来收集回调函数
this.busMap[name] = []
}
this.busMap[name].push(callback)
}
emit(name, ...args) {
if (this.busMap[name]) {
this.busMap[name].forEach((callback) => callback(...args))
}
}
off(name) {
delete this.busMap[name]
}
}
上手试一下:
const bus = new MyBus()
bus.on('test', (...args) => {
console.log('test 触发了, 参数是:', args)
})
bus.on('test', (...args) => {
console.log('test 触发第二次了, 参数是:', args)
})
bus.emit('test', 1, 2, 3)
bus.off('test')
bus.emit('test', 'off后的')
// test 触发了, 参数是: [ 1, 2, 3 ]
// test 触发第二次了, 参数是: [ 1, 2, 3 ]
好像没问...等等,这好像把我两次on的注册都取消了,如果我只想取消其中的一个回调呢?
六、可取消指定回调的EventBus
我们分析一下:
- 如果我们想要取消同名事件的某个回调,我们首先需要知道这是哪一个回调,可以用一个自增的id来区分,这个id是需要返回给用户,这样用户就可以在off取消时,传入id供我们取消。
- 同名事件的不同回调需要用不同的id来区分开,所以上面我们的busMap要从 {eventName: callbackList}改为{eventName: {id: callback}}的结构来收集事件及其回调函数
- 我们在emit触发事件的时候,一般不会传入id(和兄弟组件不在同一个模块里面,无法得知id。如果需要单独触发,建议使用单独的事件名称),所以需要把同名事件的所有id的回调函数都调用一遍。对对象遍历,我们需要使用for ... in 拿到key
- 在off的时候,用户可能全部取消,也可能只需要指定id的,所以需要分开处理
那我们开始动手吧:
class MyBus {
constructor() {
this.busMap = {}
this.id = 0
}
on(name, callback) {
if (!this.busMap[name]) {
// 如果没注册过,就注册一下,可单独取消版的,需要用对象来一一对应收集
this.busMap[name] = {}
}
this.id += 1
this.busMap[name][this.id] = callback
return this.id // 返回给用户
}
emit(name, ...args) {
const event = this.busMap[name]
if (event) {
for (let id in event) {
event[id](...args)
}
}
}
off(name, id) {
const event = this.busMap[name]
if (id) {
// 传入了id则删除对应的回调
delete event[id] // delete删除不存在的key时也不会报错
if (!Object.keys(event).length) {
// 如果已经是最后一个回调了,则连整个对象也删除
delete this.busMap[name]
}
} else {
// 否则删除整个事件的所有回调
delete this.busMap[name]
}
}
}
验证一下:
const bus = new MyBus()
bus.on('test', (...args) => {
console.log('test 触发了, 参数是:', args)
})
const id = bus.on('test', (...args) => {
console.log('test 触发第二次了, 参数是:', args)
})
bus.emit('test', 1, 2, 3)
bus.off('test', id)
bus.emit('test', 'off后的')
// test 触发了, 参数是: [ 1, 2, 3 ]
// test 触发第二次了, 参数是: [ 1, 2, 3 ]
// test 触发了, 参数是: [ 'off后的' ]
七、只执行一次的EventBus(终极版)
有时候我们明确地知道这个事件只需要触发一次,如果我们还去手动on然后off,就显得有些麻烦了,所以我们还可以提供一个once函数来注册回调。通过once注册的回调,在emit的时候,执行完回调就自动取消了。需求明确了,我们来分析一下应该怎样实现:
- 我们需要将once注册的事件和on注册的区分开来,可以新增个map,也可以对id进行特殊处理来区分
- 如果once和on注册的事件同名,我们需要全都执行,只不过在执行完之后,需要将once的删掉
class MyBus {
constructor() {
this.busMap = {}
this.id = 0
this.onceMap = {} // 用来收集只执行一次的事件
}
on(name, callback) {
if (!this.busMap[name]) {
// 如果没注册过,就注册一下,可单独取消版的,需要用对象来一一对应收集
this.busMap[name] = {}
}
this.id += 1
this.busMap[name][this.id] = callback
return this.id // 返回给用户
}
once(name, callback) {
if (!this.onceMap[name]) {
// 同样可以在多个地方注册同一个once
this.onceMap[name] = []
}
this.onceMap[name].push(callback)
}
emit(name, ...args) {
const event = this.busMap[name]
if (event) {
for (let id in event) {
event[id](...args)
}
}
const onceEvent = this.onceMap[name]
if (onceEvent) {
onceEvent.forEach((callback) => callback(...args))
delete this.onceMap[name]
}
}
off(name, id) {
const event = this.busMap[name]
if (id) {
// 传入了id则删除对应的回调
delete event[id] // delete删除不存在的key时也不会报错
if (!Object.keys(event).length) {
// 如果已经是最后一个回调了,则连整个对象也删除
delete this.busMap[name]
}
} else {
// 否则删除整个事件的所有回调
delete this.busMap[name]
}
}
}
验证一下:
const bus = new MyBus()
bus.once('once', (data) => {
console.log('执行了once的回调,参数是', data)
})
bus.emit('once', 1)
bus.emit('once', 2)
// 执行了once的回调,参数是 1
八、 为什么不推荐使用EventBus
虽然EventBus用起来很爽,可以把事件和参数传递到任何其他组件中去。但是,在各种地方,我们都能看到不推荐使用EventBus的说法。究其原因,主要还是因为:
- 挂载到全局,注册的事件需要及时清理掉,否则全局对象占用的内存会越来越大
- 数据流混乱。通常我们推荐使用单向数据流,即,数据总是从一个方向传递到另一个方向,这样我们在debug的时候,能很方便地知道数据在流传到哪一步的时候出了问题,而EventBus则是非常随意的,不好定位。这两张图应该能很好地对比其中的区别:
九、EventBus的替代方式
1.转换思路
其实很多时候我们需要的是兄弟组件 通信,而不是兄弟页面 通信。很多时候,兄弟组件都有一个共同的父组件,我们可以转换一下思路,把参数从子组件A传递到父组件,再由父组件传递到子组件B,同样能实现兄弟组件,况且这样当组件销毁时,会自动销毁相关注册。
2.使用Vuex等状态管理工具
现在主流的三大框架都有自己专门的状态管理工具,我们可以使用这些工具来进行状态管理。当然,如Vuex官方文档所说:
如果您不打算开发大型单页应用,使用 Vuex 可能是繁琐冗余的。确实是如此——如果您的应用够简单,您最好不要使用 Vuex。一个简单的 store 模式就足够您所需了。但是,如果您需要构建一个中大型单页应用,您很可能会考虑如何更好地在组件外部管理状态,Vuex 将会成为自然而然的选择。引用 Redux 的作者 Dan Abramov 的话说就是:
Flux 架构就像眼镜:您自会知道什么时候需要它。
十、参考资料
\