大家最熟悉的EventBus就是其作为在vue中协助我们在不相连的组件间进行通信的工具,然而其实在js的原生代码中也有使用EventBus,最典型的就是EventTarget(window)的addEventListener,它可以帮助我们侦听dom事件,那么我们怎么手写一个自己的EventBus呢?
实现效果
首先要了解一下EventBus的基本使用场景:基本上就是要实现三个方法,
①. 监听事件on
②. 触发事件emit
③. 移除事件off
下面展示EventBus的使用过程以及EventBus手写后应有的效果:
const eventBus = new HZEventBus(); // 创建一个实例
// 实施监听,是一个键-值对的形式,键是一个字符串,值是一个回调函数,第三个参数上,可以将回调函数的this绑定在其上
// 01. 监听事件
const fun1 = function (para) {
console.log("监听0", this, para);
}
eventBus.on("helloBus", fun1, {name : "eventBus0"});
// 其可以在同样的键上加上多个回调函数
const fun2 = function (para) {
console.log("监听1", this, para);
}
eventBus.on("helloBus", fun2, {name : "eventBus1"});
// 要用回调函数的时候,就用键去emit触发这个事件
// 触发之后,这个键对应的所有回调函数都会触发
// 02. 触发事件
eventBus.emit("helloBus", 123); // 当然还可以给回调函数传入一些参数
// 我们还可以移除事件,移除事件同样是两个参数,一个是键,一个是键中某个回调函数
// 03. 移除事件
eventBus.off("helloBus", fun1);
console.log("-----------移除'监听0'之后的效果------------")
eventBus.emit("helloBus", 456); // 再次触发,只会执行一个回调函数了
运行效果应该是:
手写解析
那么如何实现上面的三个功能呢?
首先我们可以定义一个类(语法糖),类的构造函数中定义事件总线EventBus,刚开始就是一个空对象 {},完成这一步后就可以围绕事件总线来实现三个功能了(on,emit,off),其实以上三个函数都是在维护 事件总线 的(增/减/用):
监听事件
监听事件实际上就是将事件所对应的回调函数和附带的this绑定 封装成一个对象
加入到这个 事件数组
当中,所以刚开始EventBus中都没有该事件时,就要先创建这个事件数组,若已经有了,那么就直接将回调函数push到事件数组中去了
on(eventName, eventCallback, thisArgument) { // 监听
// 第三个为this相关的参数
let handlers = this.eventBus[eventName];
if(!handlers) {
//若总对象中之前没有eventName这个键对应的任何东西,是第一次遇见该键(事件)
// 那么直接将新的回调函数和this参数以数组的形式装入到该键下
this.eventBus[eventName] = [{eventCallback, thisArgument}];
return ; // 下面的代码就不执行了
}
handlers.push({ // 否则就push插入了
// 再把回调函数和this参数push到对应键的handlers中
eventCallback,
thisArgument
});
}
【注】
①. 其中要注意的就是首先要判断一下EventBus中有没有这个事件,有才push,否则应该先创建!
②. 另外,还应注意这样的语句:let handlers = this.eventBus[eventName]; 注意其是地址赋值(由于是handlers数组),所以下面才可以直接进行push,也会push到this.eventBus中
触发事件
触发事件无非是将回调函数执行,但还是要注意一下特判,即当事件总线中都未获取到该事件对应的回调函数时,那么直接返回; 同时,要注意调用时注意使用apply,因为有this绑定和参数调用:
emit(eventName, ... payload) { // 发射,触发事件,可以给回调函数传入一些参数
// 当然,我们也可以不特意定义第二个参数,可以通过arguments将传入的参数取出来
// 并通过:Arrays.from(arguments).slice(1)的形式使用
// 根据键先取出相应的回调函数等内容
const handlers = this.eventBus[eventName];
// 没有实施监听,eventBus中没有这个键对应的任何回调函数,直接return
if(!handlers) return ;
// 若存在,那么通过forEach遍历其内容,执行所有的回调函数,通过apply绑定this和传参
handlers.forEach(handler => handler.eventCallback.apply(handler.thisArgument, payload))
}
另外,要注意是把所有该键下所对应的回调函数都执行一遍,所以应该对handlers进行循环遍历!
移除事件
移除是三个功能中最难实现的一个,因为我们考虑的是利用splice进行元素的移除,所以当一个事件数组中有多个相同的回调函数时,那么利用循环变量逐一遍历时可能会漏掉情况,比如两个回调函数相邻,那么在原数组删除时就会出现这样的情况:
刚开始[fun1, fun1],我们将index = 0的splice删除, 但删除完之后,原来的第二个fun1的索引变为了0,而index却遍历到了1,直接退出循环,这就漏掉情况了, 所以解决方案是:我们可以拷贝一个副本,i遍历的是副本数组,而真正删除的是原数组
off(eventName, eventCallback) { // 取消监听
const handlers = this.eventBus[eventName];
// 这里也是同样的逻辑,若没有取到,那么就直接返回
if(!handlers) return ;
// 将取出的handlers拷贝一份,为什要拷贝呢?
// 因为下面在执行移除时,若对同一个函数监听多次且它在数组中的位置是相邻的
// 那么我们下面利用splice移除后,可能就会因为i++,遗漏掉移除的情况
// 比如[1,1,2,3],现在我们要删除1,那么splice(0, 1)后,i变成了1,但现在0位置还剩一个1没有被删除
// 这样就会遗漏情况
const newHandlers = [...handlers];
for(let i = 0; i < newHandlers.length; i ++) {
const handler = newHandlers[i]; // 获取索引位置对应handler的是没有执行删除的副本
if(handler.eventCallback === eventCallback) {
// 另外要注意我们只能删除被命名了的回调函数
const index = handlers.indexOf(handler); // 真正执行删除的是原handler
handlers.splice(index, 1);
}
}
}
当然,首先也要判空再去执行删除 ! 另外,在删除的时候我们配合了indexOf来获取相应要被删除回调函数在原数组中首个出现的位置!
EventBus维护整体代码
class HZEventBus { // HZ加个前缀,防止与第三方库的名字重复
constructor() {
this.eventBus = {};
// 记录所有事件和回调函数的总对象,也就是事件总线(某种意义上说是个map,但由于其允许一对多,所以并不是map)
}
on(eventName, eventCallback, thisArgument) { // 监听
// 第三个为this相关的参数
let handlers = this.eventBus[eventName];
if(!handlers) {
//若总对象中之前没有eventName这个键对应的任何东西,是第一次遇见该键(事件)
// 那么直接将新的回调函数和this参数以数组的形式装入到该键下
this.eventBus[eventName] = [{eventCallback, thisArgument}];
return ; // 下面的代码就不执行了
}
handlers.push({ // 否则就push插入了
// 再把回调函数和this参数push到对应键的handlers中
eventCallback,
thisArgument
})
}
emit(eventName, ... payload) { // 发射,触发事件,可以给回调函数传入一些参数
// 当然,我们也可以不特意定义第二个参数,可以通过arguments将传入的参数取出来
// 并通过:Arrays.from(arguments).slice(1)的形式使用
// 根据键先取出相应的回调函数等内容
const handlers = this.eventBus[eventName];
// 没有实施监听,eventBus中没有这个键对应的任何回调函数,直接return
if(!handlers) return ;
// 若存在,那么通过forEach遍历其内容,执行所有的回调函数,通过apply绑定this和传参
handlers.forEach(handler => handler.eventCallback.apply(handler.thisArgument, payload))
}
off(eventName, eventCallback) { // 取消监听
const handlers = this.eventBus[eventName];
// 这里也是同样的逻辑,若没有取到,那么就直接返回
if(!handlers) return ;
// 将取出的handlers拷贝一份,为什要拷贝呢?
// 因为下面在执行移除时,若对同一个函数监听多次且它在数组中的位置是相邻的
// 那么我们下面利用splice移除后,可能就会因为i++,遗漏掉移除的情况
// 比如[1,1,2,3],现在我们要删除1,那么splice(0, 1)后,i变成了1,但现在0位置还剩一个1没有被删除
// 这样就会遗漏情况
const newHandlers = [...handlers];
for(let i = 0; i < newHandlers.length; i ++) {
const handler = newHandlers[i]; // 获取索引位置对应handler的是没有执行删除的副本
if(handler.eventCallback === eventCallback) {
// 另外要注意我们只能删除被命名了的回调函数
const index = handlers.indexOf(handler); // 真正执行删除的是原handler
handlers.splice(index, 1);
}
}
}
}
原理分析
上面实现了这么多,但其实我们对 事件,事件总线等的概念以及为什么要实现事件总线 还是不够清晰的,那么下面我们就详细地解释一下
“事件总线”的提出其实是基于“发布-订阅”设计模式的,引用 事件总线(发布订阅模式)中的描述:
发布-订阅者模式
定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新
另外,上面的描述很像我们所了解的“观察者模式”,其实“发-订”是属于“观察者模式”的,它们的区别就是:观察者模式是可以没有事件总线作为中台的,可以没有中间媒介直接观察,而发-订则是通过中间的媒介进行观察
观察者模式
发步者-订阅者模式;
在上图中,我们可以看到:
发布者:发出事件
订阅者:订阅事件,并会进行响应
事件总线:上面两者通过它作为中台
然后回答上面提出的几个问题:
什么是事件?
事件很抽象,我们只能用不同的几个面来反映,比如上面我们用eventName来描述事件,然后用eventCallback来处理事件
那么什么是事件总线呢?
从上图可以看出,事件总线就是用来联系 发布者 和 订阅者 的中台,进行事件调度,实现消息的通知与更新
为什么需要事件总线呢?
实现兄弟组件间的通信(不一定要是父子关系)