手写“事件总线”

1,076 阅读5分钟

大家最熟悉的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); // 再次触发,只会执行一个回调函数了

运行效果应该是:

image.png

手写解析

那么如何实现上面的三个功能呢?

首先我们可以定义一个类(语法糖),类的构造函数中定义事件总线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);
            }
        }
    }
}

原理分析

上面实现了这么多,但其实我们对 事件,事件总线等的概念以及为什么要实现事件总线 还是不够清晰的,那么下面我们就详细地解释一下

“事件总线”的提出其实是基于“发布-订阅”设计模式的,引用 事件总线(发布订阅模式)中的描述:

发布-订阅者模式

定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖于它的对象都会得到通知并被自动更新

另外,上面的描述很像我们所了解的“观察者模式”,其实“发-订”是属于“观察者模式”的,它们的区别就是:观察者模式是可以没有事件总线作为中台的,可以没有中间媒介直接观察,而发-订则是通过中间的媒介进行观察

观察者模式

image.png

发步者-订阅者模式;

image.png

在上图中,我们可以看到:

发布者:发出事件

订阅者:订阅事件,并会进行响应

事件总线:上面两者通过它作为中台

然后回答上面提出的几个问题:

什么是事件?

事件很抽象,我们只能用不同的几个面来反映,比如上面我们用eventName来描述事件,然后用eventCallback来处理事件

那么什么是事件总线呢?

从上图可以看出,事件总线就是用来联系 发布者 和 订阅者 的中台,进行事件调度,实现消息的通知与更新

为什么需要事件总线呢?

实现兄弟组件间的通信(不一定要是父子关系)