技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

请手写一个 EventBus,让我看看你的代码能力!

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/102-请手写一个EventBus,让我看看你的代码能力!
本文目录11 个章节
  1. 一、什么是 EventBus
  2. 二、乞丐版 EventBus:只订阅和发布
  3. 三、传参版 EventBus
  4. 四、取消订阅版 EventBus
  5. 五、执行一次版 EventBus
  6. 六、一个更完整、可直接复用的实现
  7. 七、EventBus 在 Vue 中的使用
  8. 八、EventTarget 和跨标签页通信
  9. 九、什么时候不应该使用 EventBus
  10. 总结
  11. 参考资料

请手写一个 EventBus,让我看看你的代码能力!

EventBus 是事件总线,也可以看成一种事件发布/订阅实现。本文保留原文从“乞丐版”、传参、取消订阅到只执行一次的递进过程,修复回调拼写、空事件报错、事件删除、$once 重入和内存清理问题,并补充 Vue 3 的事件 API、mittEventTarget 和跨标签页通信说明。

一、什么是 EventBus

事件总线允许发布者和订阅者通过一个中间对象通信:

订阅者 --$on--> EventBus <--$emit-- 发布者

发布者不需要持有订阅者实例,只需要约定事件名和 payload;EventBus 保存监听器,并在事件触发时依次调用它们。

EventBus 发布订阅关系示意图(原文图片已本地化)

它和 DOM 事件、消息队列有相似之处,但不要混为一谈:

  • DOM EventTarget 由浏览器提供,支持事件对象和部分传播机制;
  • EventBus 通常是应用内存中的对象,只在同一个 JavaScript 上下文中生效;
  • 消息队列或 BroadcastChannel 可以根据协议实现更广范围的通信;
  • Vue 组件事件主要用于子组件通知父组件,并不是全局 EventBus。

二、乞丐版 EventBus:只订阅和发布

实现目标:

  • $on(name, handler) 订阅事件;
  • $emit(name, ...args) 发布事件。
class SimpleEventBus {
  constructor() {
    // 使用无原型对象,避免事件名覆盖 toString 等继承属性。
    this.eventObj = Object.create(null)
  }

  $on(name, handler) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    if (!this.eventObj[name]) {
      this.eventObj[name] = []
    }

    this.eventObj[name].push(handler)
  }

  $emit(name, ...args) {
    // 未注册事件时安静返回,不应对 undefined 做 for...of。
    const eventList = this.eventObj[name] || []

    // 快照可以避免某个监听器在派发过程中修改数组导致遍历跳过。
    for (const handler of eventList.slice()) {
      handler(...args)
    }
  }
}

const bus = new SimpleEventBus()

bus.$on('key1', () => {
  console.info('我是订阅事件 A')
})

bus.$on('key1', () => {
  console.info('我是订阅事件 B')
})

bus.$on('key2', () => {
  console.info('我是订阅事件 C')
})

bus.$emit('key1')
// 我是订阅事件 A
// 我是订阅事件 B
bus.$emit('key2')
// 我是订阅事件 C

原文的基本思路是正确的:用一个对象保存事件名,用数组保存该事件的回调。但原始代码有两个需要修正的地方:回调变量拼写错误,以及发布一个从未注册的事件时 eventList 可能是 undefined

乞丐版 EventBus 的输出结果(原文图片已本地化)

三、传参版 EventBus

公众号发布内容时不能总是空 payload,事件总线也应允许 $emit 传递任意参数:

class ArgumentEventBus {
  constructor() {
    this.events = Object.create(null)
  }

  $on(name, handler) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    const list = this.events[name] || (this.events[name] = [])
    list.push(handler)
  }

  $emit(name, ...args) {
    for (const handler of (this.events[name] || []).slice()) {
      handler(...args)
    }
  }
}

const bus = new ArgumentEventBus()

bus.$on('key1', (name, age) => {
  console.info('我是订阅事件 A:', name, age)
})

bus.$on('key1', (name, age) => {
  console.info('我是订阅事件 B:', name, age)
})

bus.$on('key2', name => {
  console.info('我是订阅事件 C:', name)
})

bus.$emit('key1', '小猪课堂', 26)
bus.$emit('key2', '小猪课堂')

使用 ...args 让事件可以传递多个参数;大型项目更推荐传递一个结构稳定的 payload 对象,避免监听器依赖参数位置:

bus.$emit('user:login', {
  id: 1,
  source: 'dialog',
})

EventBus 传参后的输出结果(原文图片已本地化)

四、取消订阅版 EventBus

只订阅不取消会导致监听器一直保留,组件反复挂载时可能重复执行甚至造成内存泄漏。取消订阅至少需要知道事件名和回调函数;原文使用自增 id,本节保留这个思路。

class RemovableEventBus {
  constructor() {
    this.events = Object.create(null)
    this.nextId = 0
  }

  $on(name, handler) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    const list = this.events[name] || (this.events[name] = [])
    const listener = {
      id: this.nextId++,
      handler,
    }
    list.push(listener)
    return listener.id
  }

  $emit(name, ...args) {
    const list = this.events[name]
    if (!list) return

    for (const listener of list.slice()) {
      listener.handler(...args)
    }
  }

  $off(name, id) {
    const list = this.events[name]
    if (!list) return

    const index = list.findIndex(listener => listener.id === id)
    if (index !== -1) {
      list.splice(index, 1)
    }

    if (list.length === 0) {
      delete this.events[name]
    }
  }
}

const bus = new RemovableEventBus()

const id = bus.$on('key1', (name, age) => {
  console.info('我是订阅事件 B:', name, age)
})

bus.$emit('key1', '小猪课堂', 26)
bus.$off('key1', id)
bus.$emit('key1', '小猪课堂', 26) // B 不再执行

原文把每个事件的监听器存成 { id: callback } 对象,这可以实现功能,但数组或 Map 更容易保持顺序,也能避免对象 key 自动转换带来的困惑。实际项目还可以让 $off 支持回调函数、返回取消函数,或者一次性移除整个事件:

const stop = bus.$on('message', handler)
// 如果 API 直接返回取消函数,使用体验更好:
// stop()

EventBus 取消订阅的输出结果(原文图片已本地化)

五、执行一次版 EventBus

$once 表示监听器最多执行一次。不要依赖在数字 id 前拼接 D 再判断字符串,这种方式容易和普通 id、类型转换和删除时机混在一起;可以直接在监听器记录上保存 once 标记。

class EventBus {
  constructor() {
    this.events = new Map()
    this.nextId = 0
  }

  $on(name, handler) {
    return this.add(name, handler, false)
  }

  $once(name, handler) {
    return this.add(name, handler, true)
  }

  add(name, handler, once) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    const listener = {
      id: this.nextId++,
      handler,
      once,
    }

    const list = this.events.get(name) || []
    list.push(listener)
    this.events.set(name, list)
    return listener.id
  }

  $emit(name, ...args) {
    const list = this.events.get(name)
    if (!list) return

    // 使用快照;一次性监听器在回调执行前移除,重入 emit 也不会再次执行。
    for (const listener of list.slice()) {
      if (listener.once) {
        this.$off(name, listener.id)
      }
      listener.handler(...args)
    }
  }

  $off(name, id) {
    const list = this.events.get(name)
    if (!list) return

    const next = list.filter(listener => listener.id !== id)
    if (next.length > 0) {
      this.events.set(name, next)
    } else {
      this.events.delete(name)
    }
  }
}

const bus = new EventBus()

bus.$on('key1', (name, age) => {
  console.info('我是订阅事件 A:', name, age)
})

bus.$once('key1', (name, age) => {
  console.info('我是订阅事件 B:', name, age)
})

bus.$emit('key1', '小猪课堂', 26)
console.info('再次触发 key1')
bus.$emit('key1', '小猪课堂', 26)
// 第一次 A、B 都执行;第二次只有 A 执行。

一次性监听器在执行前删除,是为了处理这种情况:回调内部再次触发同一个事件。如果执行后才删除,重入调用可能让 $once 回调执行多次。

EventBus $once 的输出结果(原文图片已本地化)

六、一个更完整、可直接复用的实现

如果希望同时支持按 id、按回调和按事件名取消,可以把 API 设计得更完整:

export class EventBus {
  constructor() {
    this.events = new Map()
    this.nextId = 0
  }

  on(name, handler) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    const listener = {
      id: this.nextId++,
      handler,
      once: false,
    }

    const list = this.events.get(name) || []
    list.push(listener)
    this.events.set(name, list)

    // 返回取消函数,避免调用方额外保存数字 id。
    return () => this.off(name, listener.id)
  }

  once(name, handler) {
    if (typeof handler !== 'function') {
      throw new TypeError('handler 必须是函数')
    }

    const listener = {
      id: this.nextId++,
      handler,
      once: true,
    }

    const list = this.events.get(name) || []
    list.push(listener)
    this.events.set(name, list)
    return () => this.off(name, listener.id)
  }

  emit(name, ...args) {
    const list = this.events.get(name)
    if (!list) return

    for (const listener of list.slice()) {
      if (listener.once) this.off(name, listener.id)
      listener.handler(...args)
    }
  }

  off(name, idOrHandler) {
    if (name == null) {
      this.events.clear()
      return
    }

    const list = this.events.get(name)
    if (!list) return

    if (idOrHandler == null) {
      this.events.delete(name)
      return
    }

    const next = list.filter(listener => {
      return listener.id !== idOrHandler && listener.handler !== idOrHandler
    })

    if (next.length > 0) this.events.set(name, next)
    else this.events.delete(name)
  }
}

这里用 on/emit/off/once 命名,避免和 Vue 组件实例 API 产生不必要混淆;如果为了保留原文面试题形式,也可以把方法名改成 $on/$emit/$off/$once

实现时还要考虑:

  • 事件名是否使用常量或命名空间,避免字符串拼写冲突;
  • 监听器执行顺序是否需要稳定;
  • 一个监听器抛错时是否继续执行其他监听器;
  • $emit 过程中新增/删除监听器的语义;
  • 是否需要异步派发、错误上报和类型约束;
  • 组件卸载时是否一定调用取消函数。

七、EventBus 在 Vue 中的使用

7.1 Vue 2 的历史写法

Vue 2 曾经常见一个空 Vue 实例作为总线:

// bus.js,Vue 2
import Vue from 'vue'

export const bus = new Vue()
// 订阅方
mounted() {
  bus.$on('user:login', this.handleLogin)
},
beforeDestroy() {
  bus.$off('user:login', this.handleLogin)
}

这种方式依赖 Vue 2 实例的 $on/$off/$once 事件接口。

7.2 Vue 3 的变化

Vue 3 移除了组件实例上的 $on$off$once,组件实例不再实现事件发射器接口;组件自身的 $emit 仍用于声明的组件事件,但不能拿 app 或任意组件实例当全局 EventBus。

Vue 3 组件之间优先使用:

  • props + emit:直接父子通信;
  • provide/inject:跨多层依赖注入;
  • composable:复用有状态逻辑;
  • Pinia:跨页面、跨模块的应用状态;
  • 一个明确封装的 emitter:确实需要松耦合事件通知时使用。

如果使用 mitt 之类的轻量 emitter,也应在组件卸载时 off

import mitt from 'mitt'
import { onMounted, onUnmounted } from 'vue'

const emitter = mitt()

function handleLogin(payload) {
  console.log(payload)
}

onMounted(() => {
  emitter.on('user:login', handleLogin)
})

onUnmounted(() => {
  emitter.off('user:login', handleLogin)
})

八、EventTarget 和跨标签页通信

浏览器已有标准事件目标,可以在不写自定义数组的情况下完成事件监听:

const target = new EventTarget()

function handleMessage(event) {
  console.log(event.detail)
}

target.addEventListener('message', handleMessage)
target.dispatchEvent(new CustomEvent('message', {
  detail: { text: 'hello' },
}))
target.removeEventListener('message', handleMessage)

原文说两个标签页可以直接使用同一个内存 EventBus 通信,这不准确:不同标签页通常属于不同 JavaScript 全局上下文,不能共享同一个普通对象。跨标签页可以考虑:

  • BroadcastChannel
  • window.postMessage
  • storage 事件;
  • Service Worker 或服务器推送。

例如使用 BroadcastChannel

const channel = new BroadcastChannel('app-events')

channel.onmessage = event => {
  console.log('收到其他标签页消息', event.data)
}

channel.postMessage({ type: 'user:login', id: 1 })

// 页面卸载时关闭
channel.close()

九、什么时候不应该使用 EventBus

EventBus 适合少量、低耦合、无需明确响应者的通知,例如全局 toast、简单埋点或跨模块刷新提示。但它也容易带来“隐式数据流”:

  • 发布位置和订阅位置相距很远,难以追踪;
  • 事件名和 payload 缺少类型约束;
  • 忘记取消订阅导致重复回调和内存泄漏;
  • 事件顺序和竞态问题难以测试;
  • 把状态同步问题伪装成一次性事件,刷新后状态可能丢失。

如果多个组件需要读取和修改同一份持久状态,应优先使用 Pinia 等状态管理方案;如果只是父子组件通知,使用 props/emits 通常更清晰。

总结

  1. EventBus 是一个保存监听器并转发事件的发布/订阅对象;
  2. 最小实现需要 $on$emit,工程实现还应支持参数和取消订阅;
  3. $once 应在回调执行前移除,避免重入导致重复执行;
  4. 未注册事件应安全返回,派发过程最好遍历监听器快照;
  5. 组件卸载时必须清理监听器,避免重复订阅和内存泄漏;
  6. Vue 2 曾可用空 Vue 实例作为总线,Vue 3 已移除 $on/$off/$once 实例 API;
  7. Vue 3 优先使用 props/emits、provide/inject、composable 和 Pinia;
  8. 普通内存 EventBus 不能直接跨浏览器标签页通信,需要 BroadcastChannel 或 postMessage;
  9. EventBus 适合通知,不应替代有明确状态模型的状态管理。

参考资料

原文作者:小猪课堂。原文关于 EventBus、$on$emit、参数、$off$once 的递进实现予以保留;回调变量拼写、空事件异常、id 删除逻辑、一次性监听器重入、Vue 3 API 变化和跨标签页通信表述已修正或补充。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS