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

显示模式

登录
ARCHIVE DOCUMENTVUE

既然 React/Vue 可以用 Event Bus 进行组件通信,你可以实现吗?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/84-既然React Vue可以用Event Bus进行组件通信,你可以实现下吗_2
本文目录10 个章节
  1. 一、组件之间如何通信
  2. 二、发布订阅模式
  3. 三、原文代码中的问题
  4. 四、实现一个简易 EventEmitter
  5. 五、Vue 组件中的使用方式
  6. 六、React 中的使用方式
  7. 七、类型安全的事件表
  8. 八、Event Bus 的边界
  9. 总结
  10. 参考资料

既然 React/Vue 可以用 Event Bus 进行组件通信,你可以实现吗?

原文是一篇以面试追问为线索的 Event Bus 实现文章。本文尽量保留“组件通信 → 发布订阅 → 仿照 Node.js EventEmitter 实现”的结构,并修正 Vue/React 现状、监听器覆盖、Map.delete 误用、移除索引错误和资源清理问题。

一、组件之间如何通信

Vue

场景常用方式说明
父传子props子组件通过声明接收只读输入
子传父emits / $emit子组件发出事件,父组件监听
跨层级依赖provide / inject适合主题、表单上下文、服务对象等
多个模块共享状态Pinia / composable状态、派生状态和更新动作更明确
一次性解耦通知Event Bus需要自己管理订阅和生命周期

Vue 2 中曾经有人创建一个 Vue 实例作为总线,然后调用 $on$emit$off。这不是推荐的长期架构;Vue 3 已移除组件实例的 $on$off$once,也不能继续把 new Vue() 当作 Vue 3 的 Event Bus。第三方库如 mitt 或下面的自定义 emitter 可以提供类似能力。

$dispatch$broadcast 更是 Vue 1 时代的 API,在 Vue 2 就已经移除。大型项目不应通过隐式全局事件替代清晰的数据流。

React

React 没有内置的 Event Bus,也不存在一个官方的“Event 模块”用于组件通信。常见方式是:

  • 父组件通过 props 传递值和回调;
  • 子组件调用回调通知父组件;
  • Context 传递跨层级依赖;
  • 外部 store 或订阅源管理共享状态。

Event Bus 可以作为独立的发布/订阅工具使用,但不能把它说成 React 的内置通信机制。

二、发布订阅模式

Event Bus 的基本数据结构是:

事件名 → 监听器列表

发布者只负责:

bus.emit('user:updated', user)

订阅者负责:

bus.on('user:updated', user => {})

发布者和订阅者互相不知道对方的对象引用,这就是解耦的地方,但也会带来事件名难追踪、类型不明确和生命周期容易遗漏等成本。

Node.js 的 EventEmitter 支持一个事件绑定多个监听器,监听器按注册顺序同步调用;onaddListener 的别名,offremoveListener 的别名,once 用于只执行一次。它还有 'error' 特殊事件、监听器数量警告和更多生命周期 API。下面实现的是浏览器/面试教学版,不宣称与 Node 完全兼容。

三、原文代码中的问题

原文的核心思路是对的,但抓取后的代码和简化实现有几个实际错误:

  1. EventEmeitter 是拼写错误,标准名称通常为 EventEmitter
  2. _events 的 value 先保存一个函数,后续又改成数组,导致 API 和边界分支复杂;
  3. addListener 只处理第一次注册,原实现的第二个监听器不会被保存;
  4. 没有监听器时直接 handler.apply 会抛出 undefined is not a function
  5. Map.delete(type, fn) 是错误用法,Map.delete 只接收 key,不能按 value 删除;
  6. 遍历寻找位置时每一轮都覆盖 position,最后一个不匹配项可能把之前找到的位置改回 -1
  7. 没有 onceremoveAllListeners、参数校验和卸载清理;
  8. 组件卸载后若仍被总线持有,会产生重复回调或闭包无法回收。

因此,下面统一让每个事件对应一个监听器数组,代码更容易理解。

四、实现一个简易 EventEmitter

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

  _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

    // 从后往前删除一个,行为接近 Node EventEmitter。
    // once 包装器会把原始函数挂在 listener 属性上。
    for (let i = listeners.length - 1; i >= 0; i--) {
      if (
        listeners[i] === listener
        || listeners[i].listener === listener
      ) {
        listeners.splice(i, 1)
        break
      }
    }

    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 事件会抛错。
      // 浏览器业务总线也可以改成统一上报或仅返回 false。
      if (type === 'error') {
        throw args[0] instanceof Error
          ? args[0]
          : new Error(String(args[0] || 'Unhandled error event'))
      }
      return false
    }

    // 快照保证本轮 emit 中的监听器集合稳定。
    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 handleArson(man) {
  console.log(`expel ${man}`)
}

emitter.addListener('arson', handleArson)
emitter.on('arson', man => {
  console.log(`save ${man}`)
})
emitter.on('arson', man => {
  console.log(`kill ${man}`)
})

emitter.emit('arson', 'low-end')
// expel low-end
// save low-end
// kill low-end

原文第一次实现把一个事件只映射到一个函数,所以第二次监听会被忽略;数组实现后才可以按注册顺序触发全部监听器。

2. 移除监听

emitter.off('arson', handleArson)
emitter.removeListener('arson', handleArson)

off 需要拿到原来的函数引用:

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

emitter.on('message', () => console.log('anonymous'))
emitter.off('message', () => console.log('anonymous')) // ❌ 不是同一个函数

如果注册了同一个函数两次,调用一次 off 只删除一个实例;要全部移除可以调用 removeAllListeners(type)

3. 一次性监听

let count = 0

function onReady() {
  count++
}

emitter.once('ready', onReady)
emitter.emit('ready')
emitter.emit('ready')

console.log(count) // 1

once 通过包装器在第一次调用前执行 off。保存 wrapper.listener = listener 后,也可以使用原始函数引用取消尚未触发的 once 监听:

emitter.once('ready', onReady)
emitter.off('ready', onReady)

4. 事件不存在和错误

emitter.emit('not-found') // false,不抛错

try {
  emitter.emit('error', new Error('request failed'))
} catch (error) {
  console.error(error.message)
}

是否对 error 采用 Node 语义,应由项目约定决定。不要让一个没有监听器的业务事件因为拼写错误直接导致页面崩溃,也不要静默吞掉真正需要处理的错误。

五、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', { text: '来自组件 A' })
}
</script>

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

订阅方必须和组件生命周期绑定:

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

function handleMessage(payload: { text: string }) {
  console.log(payload.text)
}

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

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

不要在 rendercomputed 或每次响应式更新中重复调用 on。如果事件表达的是可持续状态,而不是一次性通知,应优先使用 props、provide/inject、composable 或 Pinia。

六、React 中的使用方式

React 没有 useEventBus 内置 Hook。可以把 emitter 作为外部对象,但必须在 useEffect 的清理函数中取消订阅:

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

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

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

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

如果事件源代表需要驱动 React 渲染的外部状态,React 18+ 可以通过 useSyncExternalStore 接入订阅和快照;仅仅在 Event Bus 回调里修改普通变量并不会自动让 React 组件更新。

七、类型安全的事件表

TypeScript 项目可以给事件名和 payload 建立约束,减少字符串事件拼写错误:

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

class TypedEmitter<E extends object> {
  private events = new Map<keyof E, Set<(...args: any[]) => void>>()

  on<K extends keyof E>(type: K, listener: (payload: E[K]) => void) {
    const listeners = this.events.get(type)
      || new Set<(...args: any[]) => void>()
    listeners.add(listener)
    this.events.set(type, listeners)
    return () => this.off(type, listener)
  }

  off<K extends keyof E>(type: K, listener: (payload: E[K]) => void) {
    this.events.get(type)?.delete(listener)
  }

  emit<K extends keyof E>(type: K, payload: E[K]) {
    for (const listener of this.events.get(type) || []) {
      listener(payload)
    }
  }
}

实际项目也可以采用成熟库提供的类型定义,不必为了少量事件重复造完整基础设施。

八、Event Bus 的边界

1. 它会隐藏数据流

bus.emit('refresh') 的接收方不在调用点可见,事件名和 payload 也可能缺少类型约束。组件关系清楚时,props/emits 更容易阅读、测试和重构。

2. 它可能造成内存泄漏

总线通常比组件活得久。Vue 的 onUnmounted、React 的 effect cleanup 和手动 off 都不是可选项。把 on 返回取消订阅函数也是一种更安全的 API 设计:

const stop = emitter.on('message', handler)
// 如果 on 返回 emitter,也可以封装 subscribe 返回 stop
stop()

本文为了贴近 Node API 让 on 返回 emitter;如果用于组件库,建议另提供 subscribe 返回 unsubscribe。

3. SSR 要隔离请求

在 Nuxt 4 或其他 SSR 应用中,不要用带用户数据的进程级可变单例总线跨请求共享状态。请求级数据应在请求上下文中创建;客户端总线则在客户端插件或应用实例内注册,并避免在服务端访问浏览器 API。

4. Event Bus 不是状态管理

Event Bus 适合“某件事发生了,请感兴趣的模块处理”的通知;它不天然提供当前值、派生值、持久化、时间旅行和可追踪状态。需要这些能力时,应使用 Pinia、Redux、Context 配合状态,或其他明确的 store。

总结

  1. Vue 3 已移除组件实例的 $on/$off/$once,React 也没有内置 Event Bus;
  2. EventEmitter 的核心是事件名到监听器列表的映射;
  3. onemitoffonce 和清理是最重要的生命周期;
  4. Map.delete 只能按 key 删除,不能拿它直接删除某个回调;
  5. 事件触发通常是同步的,监听器会按注册顺序执行;
  6. 父子通信优先 props/emits,复杂共享状态优先 store,Event Bus 只用于确有必要的解耦通知。

参考资料

原文作者:寻找海蓝 96。原文关于组件通信、发布订阅、Map 存储、多个监听器和移除监听的主线予以保留;拼写错误、损坏代码、单监听器覆盖、Map.delete 误用、错误索引逻辑、缺失清理和过时 Vue API 已修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS