001-手写源码之EventBus及其优缺点分析

863 阅读8分钟

手写源码之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

我们分析一下:

  1. 如果我们想要取消同名事件的某个回调,我们首先需要知道这是哪一个回调,可以用一个自增的id来区分,这个id是需要返回给用户,这样用户就可以在off取消时,传入id供我们取消。
  2. 同名事件的不同回调需要用不同的id来区分开,所以上面我们的busMap要从 {eventName: callbackList}改为{eventName: {id: callback}}的结构来收集事件及其回调函数
  3. 我们在emit触发事件的时候,一般不会传入id(和兄弟组件不在同一个模块里面,无法得知id。如果需要单独触发,建议使用单独的事件名称),所以需要把同名事件的所有id的回调函数都调用一遍。对对象遍历,我们需要使用for ... in 拿到key
  4. 在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的时候,执行完回调就自动取消了。需求明确了,我们来分析一下应该怎样实现:

  1. 我们需要将once注册的事件和on注册的区分开来,可以新增个map,也可以对id进行特殊处理来区分
  2. 如果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则是非常随意的,不好定位。这两张图应该能很好地对比其中的区别:

image.png

image.png

九、EventBus的替代方式

1.转换思路

其实很多时候我们需要的是兄弟组件 通信,而不是兄弟页面 通信。很多时候,兄弟组件都有一个共同的父组件,我们可以转换一下思路,把参数从子组件A传递到父组件,再由父组件传递到子组件B,同样能实现兄弟组件,况且这样当组件销毁时,会自动销毁相关注册。

2.使用Vuex等状态管理工具

现在主流的三大框架都有自己专门的状态管理工具,我们可以使用这些工具来进行状态管理。当然,如Vuex官方文档所说:

如果您不打算开发大型单页应用,使用 Vuex 可能是繁琐冗余的。确实是如此——如果您的应用够简单,您最好不要使用 Vuex。一个简单的 store 模式就足够您所需了。但是,如果您需要构建一个中大型单页应用,您很可能会考虑如何更好地在组件外部管理状态,Vuex 将会成为自然而然的选择。引用 Redux 的作者 Dan Abramov 的话说就是:

Flux 架构就像眼镜:您自会知道什么时候需要它。

十、参考资料

\