在前端开发中,跨组件通信是高频需求 ------ 父子组件可通过 Props / 自定义事件快速解决,但非父子、跨层级甚至跨页面的组件通信,单纯依赖组件传参会让代码耦合度极高。发布订阅模式(Publish/Subscribe Pattern) 作为解耦跨组件通信的核心方案,是 React/Vue 开发中必备的技能,也是面试高频考点。本文将系统梳理 React 和 Vue 实现消息订阅的方式、开发中的避坑点,以及面试中必问的核心问题。
一、先搞懂:发布订阅模式的核心逻辑
发布订阅模式(简称 "Pub/Sub")的本质是通过一个全局消息中心解耦发布者和订阅者:
- 订阅者(Subscriber):向消息中心注册 "感兴趣的事件",并绑定处理函数;
- 发布者(Publisher):向消息中心触发 "指定事件",并传递数据;
- 消息中心:维护事件与处理函数的映射关系,负责在事件触发时执行所有订阅的处理函数。
核心优势:发布者和订阅者无需感知对方的存在,仅通过事件名交互,彻底解耦组件间的依赖。
二、Vue:从原生事件系统到专用库的实现方式
Vue 对发布订阅的支持更 "原生",不同版本(Vue2/Vue3)的实现方式略有差异,核心分为两类:
1. Vue2:原生事件总线(on/emit/$off)
Vue2 实例内置了 $on(订阅/监听)、$emit(发布/触发)、$off(取消订阅)方法,可直接基于 Vue 实例实现全局事件总线。
步骤 1:注册全局事件总线
在 main.js 中挂载全局 Vue 实例:
javascript
import Vue from 'vue'
import App from './App.vue'
// 挂载全局事件总线
Vue.prototype.$bus = new Vue()
new Vue({
el: '#app',
render: h => h(App)
})
步骤 2:组件中订阅 / 发布 / 取消订阅
vue
javascript
<!-- 订阅者组件:UserInfo.vue -->
<template>
<div>用户名称:{{ userName }}</div>
</template>
<script>
export default {
data() {
return {
userName: ''
}
},
mounted() {
// 订阅事件:命名规范「模块:事件名」,避免冲突
this.$bus.$on('user:updateName', (name) => {
this.userName = name
})
},
beforeDestroy() {
// 组件销毁时取消订阅,避免内存泄漏
this.$bus.$off('user:updateName')
}
}
</script>
<!-- 发布者组件:UserEdit.vue -->
<template>
<button @click="changeName">修改用户名</button>
</template>
<script>
export default {
methods: {
changeName() {
// 发布事件,传递数据
this.$bus.$emit('user:updateName', '张三')
}
}
}
</script>
2. Vue3:mitt 库(官方推荐)
Vue3 移除了实例的 $on/$off 方法,官方推荐使用轻量级库 mitt(体积 <200B)实现发布订阅。
步骤 1:安装并注册全局 mitt 实例
javascript
npm install mitt
javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import mitt from 'mitt'
const app = createApp(App)
// 注册全局事件总线
app.config.globalProperties.$bus = mitt()
app.mount('#app')
步骤 2:组合式 API 中使用(setup 语法)
javascript
<!-- 订阅者组件 -->
<script setup>
import { ref, onMounted, onUnmounted, getCurrentInstance } from 'vue'
const userName = ref('')
// 获取全局实例
const instance = getCurrentInstance()
const $bus = instance.appContext.config.globalProperties.$bus
onMounted(() => {
// 订阅事件
const handleNameUpdate = (name) => {
userName.value = name
}
$bus.on('user:updateName', handleNameUpdate)
})
// 组件卸载时取消订阅
onUnmounted(() => {
$bus.off('user:updateName', handleNameUpdate)
})
</script>
<!-- 发布者组件 -->
<script setup>
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
const $bus = instance.appContext.config.globalProperties.$bus
const changeName = () => {
// 发布事件
$bus.emit('user:updateName', '李四')
}
</script>
三、React:手写实现 + 第三方库 + Hook 封装
React 本身没有内置发布订阅能力,需通过 "手写类" 或第三方库实现,结合 Hooks 封装后更贴合 React 开发习惯。
1. 基础版:手写发布订阅类
核心是维护一个 "事件池"(对象),实现 on/emit/off 核心方法:
javascript
// utils/eventBus.js
class EventBus {
// 事件池:{ 事件名: [处理函数1, 处理函数2] }
constructor() {
this.events = {}
}
// 订阅事件
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
// 避免重复订阅
if (!this.events[eventName].includes(callback)) {
this.events[eventName].push(callback)
}
}
// 发布事件
emit(eventName, ...args) {
if (this.events[eventName]) {
// 拷贝数组,避免执行过程中数组长度变化(如取消订阅)
this.events[eventName].slice().forEach(callback => {
callback(...args)
})
}
}
// 取消订阅
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(fn => fn !== callback)
}
}
// 清空所有订阅
clear(eventName) {
if (eventName) {
delete this.events[eventName]
} else {
this.events = {}
}
}
}
// 导出单例(全局唯一)
export default new EventBus()
组件中使用手写的 EventBus
jsx
javascript
// 订阅者组件:UserInfo.jsx
import { useState, useEffect } from 'react'
import eventBus from './utils/eventBus'
const UserInfo = () => {
const [userName, setUserName] = useState('')
useEffect(() => {
// 定义处理函数(需抽离,否则off无法匹配)
const handleNameUpdate = (name) => {
setUserName(name)
}
// 订阅事件
eventBus.on('user:updateName', handleNameUpdate)
// 组件卸载时取消订阅(React 核心避坑点)
return () => {
eventBus.off('user:updateName', handleNameUpdate)
}
}, [])
return <div>用户名称:{userName}</div>
}
// 发布者组件:UserEdit.jsx
import eventBus from './utils/eventBus'
const UserEdit = () => {
const changeName = () => {
eventBus.emit('user:updateName', '王五')
}
return <button onClick={changeName}>修改用户名</button>
}
2. 进阶版:使用第三方库 + Hook 封装
手写类满足基础需求,复杂场景可使用成熟库:
mitt:轻量(同 Vue3 推荐),API 简洁;eventemitter3:功能更全(支持事件命名空间、错误捕获),体积稍大。
步骤 1:安装 mitt 并封装自定义 Hook
javascript
npm install mitt
javascript
// hooks/useEventBus.js
import { useCallback } from 'react'
import mitt from 'mitt'
// 创建全局 mitt 实例
const emitter = mitt()
// 封装自定义 Hook,简化订阅/发布
export const useEventBus = () => {
// 发布事件
const emit = useCallback((eventName, ...args) => {
emitter.emit(eventName, ...args)
}, [])
// 订阅事件
const on = useCallback((eventName, callback) => {
emitter.on(eventName, callback)
}, [])
// 取消订阅
const off = useCallback((eventName, callback) => {
emitter.off(eventName, callback)
}, [])
return { emit, on, off }
}
步骤 2:组件中使用自定义 Hook
javascript
// 订阅者组件
import { useState, useEffect } from 'react'
import { useEventBus } from './hooks/useEventBus'
const UserInfo = () => {
const [userName, setUserName] = useState('')
const { on, off } = useEventBus()
useEffect(() => {
const handleNameUpdate = (name) => {
setUserName(name)
}
on('user:updateName', handleNameUpdate)
return () => {
off('user:updateName', handleNameUpdate)
}
}, [on, off])
return <div>用户名称:{userName}</div>
}
// 发布者组件
import { useEventBus } from './hooks/useEventBus'
const UserEdit = () => {
const { emit } = useEventBus()
const changeName = () => {
emit('user:updateName', '赵六')
}
return <button onClick={changeName}>修改用户名</button>
}
四、开发中必须关注的核心避坑点
发布订阅模式看似简单,但使用不当会引发内存泄漏、逻辑混乱等问题,以下是开发中的核心注意事项:
1. 必做:组件销毁 / 卸载时取消订阅(防内存泄漏)
这是最高频的坑!如果订阅者组件销毁后未取消订阅,事件触发时仍会执行处理函数,导致:
- 内存泄漏(处理函数无法被垃圾回收);
- 无意义的逻辑执行(如更新已卸载组件的状态,React 会报警告)。
| 框架 | 取消订阅时机 | 示例代码 |
|---|---|---|
| Vue2 | beforeDestroy 钩子 |
this.$bus.$off('eventName') |
| Vue3 | onUnmounted 钩子 |
$bus.off('eventName', callback) |
| React | useEffect 返回清理函数 |
return () => eventBus.off('eventName', callback) |
2. 规范事件名:避免命名冲突
全局事件总线的事件名是 "全局变量",命名冲突会导致逻辑错乱。建议遵循:
「业务模块」:「事件行为」:「具体操作」
示例:user:update:name、order:create:success、cart:delete:item。
3. 避免重复订阅
同一组件多次执行 on 会导致同一处理函数被多次订阅,事件触发时执行多次。解决方案:
- 订阅前先取消订阅:
this.$bus.$off('eventName').$on('eventName', callback); - 手写 EventBus 时,
on方法中判断是否已存在该回调(如上文手写类的实现)。
4. 不要滥用全局事件总线
发布订阅适合简单跨组件通信,复杂场景(如全局状态管理、多组件共享数据)建议使用框架专属状态管理工具:
- Vue:Pinia/Vuex(支持状态追踪、调试工具);
- React:Redux/Zustand/Jotai(支持状态持久化、中间件)。
5. 处理异步场景:确保订阅者已注册
如果发布者先触发事件,订阅者后注册,订阅者会丢失该事件。解决方案:
- 确保订阅者先挂载(如路由守卫控制加载顺序);
- 改用 "状态管理"(状态可持久化,订阅者挂载后可读取最新状态)。
五、面试中必问的核心考点
发布订阅模式是前端面试的高频考点,面试官不仅会问实现方式,更会考察对设计模式和框架特性的理解:
1. 基础概念:发布订阅模式 vs 观察者模式
这是必问问题,核心区别在于是否有 "第三方消息中心":
- 观察者模式:观察者直接依赖被观察者,被观察者维护观察者列表,状态变化时主动通知观察者(如 Vue 的响应式依赖收集);
- 发布订阅模式:发布者和订阅者无直接依赖,通过消息中心解耦(如本文的事件总线)。
2. Vue 相关高频问题
- Q:Vue2 和 Vue3 的事件总线实现有什么区别?
- A:Vue2 基于实例的
$on/$emit/$off,Vue3 移除了这些方法,推荐使用 mitt 库;Vue2 挂载到prototype,Vue3 挂载到app.config.globalProperties。 - Q:为什么 Vue3 移除了
$on/$off? - A:Vue3 重构了实例逻辑,聚焦组件核心能力,将非核心的事件总线能力交给第三方库,符合 "渐进式" 理念。
- Q:Vue 的事件总线和 Props / 自定义事件有什么区别?
- A:Props / 自定义事件是父子组件通信,耦合度高;事件总线是全局通信,解耦但无状态追踪,适合简单场景。
3. React 相关高频问题
- Q:React 为什么没有内置事件总线?
- A:React 核心理念是 "单向数据流",事件总线是 "双向 / 全局数据流",不符合核心设计;React 推荐用 Context + useReducer 或状态管理库解决跨组件通信。
- Q:手写发布订阅类的核心要点是什么?
- A:① 维护事件池(对象映射);②
on方法去重;③emit方法拷贝回调数组(避免执行中数组变化);④off方法精准移除回调;⑤ 支持清空所有事件。 - Q:React 中使用发布订阅时,为什么处理函数要抽离到 useEffect 内部?
- A:如果直接写匿名函数,
off时无法匹配到对应的回调,导致无法取消订阅;抽离后可精准匹配。
4. 场景题:跨组件通信的选型
Q:请说说跨组件通信的几种方式,以及各自的适用场景?
A:
- 父子组件:Props / 自定义事件(Vue)、Props / 回调函数(React);
- 跨层级 / 非父子:Context(React)、Provide/Inject(Vue)(适合祖孙组件);
- 全局跨组件:发布订阅(简单场景)、状态管理库(复杂场景);
- 跨页面:LocalStorage/SessionStorage、URL 参数、全局状态管理。
六、总结
发布订阅模式是前端解耦跨组件通信的核心方案,React 和 Vue 的实现方式虽有差异,但核心逻辑一致:
- Vue:Vue2 原生支持,Vue3 推荐 mitt 库,更贴近框架原生体验;
- React:需手写或使用第三方库,结合 Hooks 封装后更易用。
开发中记住 "先避坑,再使用":取消订阅防泄漏、规范命名防冲突、避免滥用;面试中重点掌握 "发布订阅 vs 观察者模式""框架实现差异""内存泄漏解决方案",就能轻松应对相关问题。
最后,技术选型的核心原则是:简单场景用发布订阅,复杂场景用状态管理,平衡解耦和可维护性。