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

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue/React 中的 Event Bus 如何实现?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/83-既然React Vue可以用Event Bus进行组件通信,你可以实现下吗_1
本文目录9 个章节
  1. 一、先回答组件通信问题
  2. 二、Event Bus 的本质
  3. 三、从零实现一个简易 EventEmitter
  4. 四、在 Vue 组件中使用
  5. 五、在 React 中使用
  6. 六、Event Bus 的问题和边界
  7. 七、和 Node.js EventEmitter 的差异
  8. 总结
  9. 参考资料

Vue/React 中的 Event Bus 如何实现?

原文从组件通信面试题延伸到发布订阅模式,并尝试仿照 Node.js EventEmitter 实现事件库。本文保留这条主线,修正 Vue/React 通信方式、事件总线的适用范围、监听器移除和一次性监听等问题。

一、先回答组件通信问题

Vue

  • 父传子:props;
  • 子传父:emits / $emit
  • 跨层级:provide/inject;
  • 兄弟或跨模块:共同父状态、composable、Pinia 或明确的事件通道;
  • 复杂业务状态:Pinia,历史 Vue 2 项目也可能使用 Vuex。

Vue 2 曾经常用一个 Vue 实例作为 Event Bus,并调用 $on/$emit/$off。Vue 3 已移除组件实例的 $on$off$once,不能继续 new Vue() 作为总线。mitt 等第三方库可以实现类似能力,但它们不是 Vue 内置 API。

React

React 没有内置的 Event Bus。常见方式是:

  • 父组件通过 props 传递数据和回调;
  • 子组件调用回调通知父组件;
  • Context 传递跨层级依赖;
  • 外部 store、订阅库或自定义事件通道处理跨模块状态。

Event Bus 可以用在确实需要“事件通知、发送方不关心接收方”的场景,但它不是所有组件通信的默认方案。状态需要被读取、派生、持久化或调试时,明确的 store 往往更合适。

二、Event Bus 的本质

Event Bus 是发布/订阅模式的一种实现:

publisher -- publish(type, payload) --> bus
subscriber <-- subscribe(type, handler) -- bus

一个可靠的事件库至少需要处理:

  • 一个事件对应多个监听器;
  • 监听器按注册顺序调用;
  • off 能准确删除指定函数;
  • once 只执行一次;
  • 组件卸载时取消订阅;
  • 监听器异常如何处理;
  • 重复订阅、监听器上限和内存泄漏;
  • SSR 中是否会把不同请求的状态混在一起。

事件监听器通常同步调用。需要异步执行时,应由 listener 自己使用 queueMicrotasksetTimeout 或业务调度器,而不是让 bus 隐式改变所有事件的时序。

三、从零实现一个简易 EventEmitter

下面的实现使用 Map<PropertyKey, Function[]>。使用数组而不是单个函数,可以支持同一事件的多个监听器;使用 PropertyKey 可以支持字符串和 Symbol 事件名。

export class EventEmitter {
  constructor(options = {}) {
    this._events = new Map()
    this._maxListeners = options.maxListeners ?? 10
  }

  _assertListener(listener) {
    if (typeof listener !== 'function') {
      throw new TypeError('listener 必须是函数')
    }
  }

  on(type, listener) {
    this._assertListener(listener)

    const listeners = this._events.get(type) || []
    listeners.push(listener)
    this._events.set(type, listeners)

    if (
      this._maxListeners > 0
      && listeners.length > this._maxListeners
    ) {
      console.warn(
        `事件 ${String(type)} 的监听器超过 ${this._maxListeners} 个,` +
        '请检查是否忘记取消订阅。',
      )
    }

    return this
  }

  addListener(type, listener) {
    return this.on(type, listener)
  }

  off(type, listener) {
    this._assertListener(listener)
    const listeners = this._events.get(type)
    if (!listeners) return this

    // 允许用原始函数移除 once 包装器
    const index = [...listeners].reverse().findIndex(item => {
      return item === listener || item.listener === listener
    })

    if (index >= 0) {
      listeners.splice(listeners.length - 1 - index, 1)
    }

    if (listeners.length === 0) {
      this._events.delete(type)
    }

    return this
  }

  removeListener(type, listener) {
    return this.off(type, listener)
  }

  once(type, listener) {
    this._assertListener(listener)

    const wrapper = (...args) => {
      this.off(type, wrapper)
      return listener.apply(this, args)
    }

    wrapper.listener = listener
    return this.on(type, wrapper)
  }

  emit(type, ...args) {
    const listeners = this._events.get(type)

    if (!listeners || listeners.length === 0) {
      // 这是 Node EventEmitter 对 error 事件的特殊约定;浏览器业务 bus
      // 也可以选择改成 console.error 或返回 false。
      if (type === 'error') {
        throw args[0] instanceof Error
          ? args[0]
          : new Error(String(args[0] || 'Unhandled error event'))
      }
      return false
    }

    // 使用副本,避免某个 listener 在 emit 期间 off 影响本轮遍历。
    for (const listener of listeners.slice()) {
      listener.apply(this, args)
    }

    return true
  }

  removeAllListeners(type) {
    if (arguments.length === 0) {
      this._events.clear()
    } else {
      this._events.delete(type)
    }
    return this
  }

  listeners(type) {
    const listeners = this._events.get(type) || []
    return listeners.map(listener => listener.listener || listener)
  }

  listenerCount(type) {
    return (this._events.get(type) || []).length
  }

  eventNames() {
    return [...this._events.keys()]
  }

  setMaxListeners(value) {
    if (!Number.isInteger(value) || value < 0) {
      throw new RangeError('maxListeners 必须是非负整数')
    }
    this._maxListeners = value
    return this
  }
}

1. 基本使用

const emitter = new EventEmitter()

function handleMessage(message) {
  console.log('收到消息:', message)
}

emitter.on('message', handleMessage)
emitter.on('message', message => {
  console.log('第二个监听器:', message)
})

emitter.emit('message', 'hello')
// 收到消息: hello
// 第二个监听器: hello

emitter.off('message', handleMessage)
emitter.emit('message', 'world')
// 只触发第二个监听器

原文的第一个版本只保存一个函数,因此第二次 addListener 会覆盖或无法触发;上面的数组实现解决了多个监听器问题。

2. once

let count = 0

emitter.once('ready', () => {
  count++
})

emitter.emit('ready')
emitter.emit('ready')
console.log(count) // 1

once 必须保存包装函数与原函数的关系,否则调用 off('ready', originalHandler) 时无法移除包装器。示例中的 wrapper.listener 就是为此服务的。

3. 为什么不能用匿名函数直接移除

emitter.on('message', () => {})

// ❌ 这是另一个函数引用,无法匹配第一次注册的函数
emitter.off('message', () => {})

正确做法是保存函数引用:

const handler = message => console.log(message)
emitter.on('message', handler)
emitter.off('message', handler)

原文 Map.delete(type, fn) 也是错误的:Map.delete 只接收 key,不会根据 value 删除监听器;同时原文遍历数组时不断覆盖 position,可能删错元素。应保存精确函数引用并按引用删除。

四、在 Vue 组件中使用

// bus.ts
import { EventEmitter } from './EventEmitter'

export const bus = new EventEmitter()

发布方:

<script setup lang="ts">
import { bus } from './bus'

function sendMessage() {
  bus.emit('message', '来自组件 A')
}
</script>

<template>
  <button @click="sendMessage">发送</button>
</template>

订阅方必须在卸载时取消:

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { bus } from './bus'

function handleMessage(message: string) {
  console.log(message)
}

onMounted(() => {
  bus.on('message', handleMessage)
})

onUnmounted(() => {
  bus.off('message', handleMessage)
})
</script>

如果在 setup() 顶层直接注册,也可以利用 Vue 的 effect scope 生命周期,但显式写 onUnmounted 更容易让资源边界清晰。不要在每次 render 中重复 on,否则监听器会不断增长。

更推荐的 Vue 组件通信:

<!-- 父子组件:props + emits -->
<Child :value="value" @update:value="value = $event" />

跨层级的主题、表单上下文等使用 provide/inject;需要持久共享状态、派生状态和调试时使用 Pinia。Event Bus 更适合一次性的解耦事件,例如“通知某个区域刷新”或“插件发出生命周期事件”。

五、在 React 中使用

React 组件本身没有 $emit 或内置 Event Bus。可以把上面的 emitter 作为外部对象使用,但要注意订阅生命周期:

import { useEffect } from 'react'
import { bus } from './bus'

function MessagePanel() {
  useEffect(() => {
    const handleMessage = message => {
      console.log(message)
    }

    bus.on('message', handleMessage)
    return () => bus.off('message', handleMessage)
  }, [])

  return <div>消息面板</div>
}

如果外部事件源需要驱动 React 渲染,React 18+ 更适合使用 useSyncExternalStore 把订阅和快照接入 React 的更新模型:

import { useSyncExternalStore } from 'react'

function useMessageStore(store) {
  return useSyncExternalStore(
    listener => store.subscribe(listener),
    () => store.getSnapshot(),
  )
}

普通一次性通知可以使用 Event Bus;需要让组件根据可观察状态稳定渲染,则应该实现 subscribe/getSnapshot 或使用成熟状态库,而不是在事件回调里到处手动改 DOM。

六、Event Bus 的问题和边界

1. 隐式依赖

事件名是字符串时,发布者和订阅者之间没有类型约束,也无法从组件 props 直接看出数据流:

bus.emit('update-user', user)
bus.on('update-user', handler)

大型项目应集中定义事件类型,或使用 TypeScript 泛型约束事件表:

type Events = {
  'user:updated': { id: string }
  'toast': { message: string; type: 'success' | 'error' }
}

2. 内存泄漏

总线通常比组件生命周期长。如果组件卸载后没有 off,总线仍然持有组件闭包,可能导致内存无法回收或重复响应。Vue 的 onUnmounted、React 的 effect cleanup 都是必需的。

3. 时序和异常

本实现同步调用 listeners,某个 listener 抛错时,后续 listener 是否继续执行取决于调用者是否捕获异常。Node 的 EventEmitter 对没有监听器的 'error' 事件有特殊行为;浏览器业务事件通常应使用统一错误处理策略,不能无条件照搬 Node 语义。

4. SSR 和请求隔离

Nuxt 4 或其他 SSR 应用中,不要把带有用户数据的可变 Event Bus 作为服务端进程级单例,否则不同请求可能互相收到事件。请求相关状态应按请求创建,跨页面客户端事件则在客户端插件中注册,并确保服务端不访问 window

5. maxListeners 不是安全限制

Node 的默认监听器数量警告主要用于发现可能的内存泄漏,不是硬性上限,也不是权限控制。示例中的 _maxListeners 只提供提醒,不应通过把它调成很大来掩盖重复订阅。

七、和 Node.js EventEmitter 的差异

Node.js 的 EventEmitter 还提供:

  • on/addListeneroff/removeListener
  • onceprependListener
  • removeAllListenerseventNameslistenerCount
  • newListenerremoveListener 生命周期事件;
  • 'error' 特殊事件;
  • captureRejections 和监听器上限警告。

本文实现的是浏览器/面试教学版,不应该宣称完全兼容 Node。生产项目可以直接使用 node:events 或成熟的浏览器库,而不是重复维护一个不完整实现。

总结

  1. Vue 3 已移除组件实例 Event Bus API,React 也没有内置 Event Bus;
  2. Event Bus 的核心是事件名到监听器集合的映射,以及 emit/on/off/once 生命周期;
  3. 多监听器、精确移除、once 包装、同步时序和异常处理都不能遗漏;
  4. 组件卸载时必须取消订阅,SSR 中必须考虑请求隔离;
  5. 父子数据优先 props/emits,跨层级依赖使用 provide/inject,复杂共享状态使用 Pinia 或其他 store;
  6. Event Bus 适合解耦通知,不适合替代所有状态管理和组件数据流。

参考资料

原文作者:寻找海蓝 96。原文关于 Vue/React 组件通信、发布订阅模式、Map 存储、多个监听器和移除监听的主线予以保留;错误的 EventEmeitter 实现、单监听器覆盖、Map.delete 用法、错误的移除索引、重复推广链接和 Vue 3 API 混淆已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS