先搞懂,Vue3 事件总线是个啥?
不少刚接触Vue3的同学,一遇到跨组件通信就犯难:父子用props/emits还行,可跨层级、任意组件咋传数据?这时候“事件总线”就成了绕不开的话题,但Vue3和Vue2不一样,没了$on/$emit这些现成的方法,事件总线到底咋用?今天从原理到实战,把Vue3事件总线的门道全拆开讲清楚。
你可以把事件总线想象成“公司里的大喇叭”——某个部门(组件)喊一嗓子(触发事件),其他想听的部门(组件)能收到消息(订阅事件),在Vue里,它是**跨组件通信的中间层**,不管组件层级多深、关系多远,都能通过“发布 - 订阅”的逻辑传数据、触发行为。Vue2时代,很多人用this.$bus
(基于Vue实例的$on/$emit
)当事件总线,但Vue3变了:createApp
创建的应用实例,不再自带$on/$emit
这些事件方法,所以Vue3的事件总线,得自己实现“发布 - 订阅”机制,或者用现成的库(比如mitt)。
为什么Vue3 还需要事件总线?
不是有props、emits、provide/inject吗?但这些方案有局限:
- 父子/祖孙组件:props/emits(父子)、provide/inject(祖孙)能解决,但层级一复杂(比如曾孙给曾祖父传数据),写起来又麻烦又绕。
- 跨层级/任意组件:比如导航栏切换主题,要通知页面、侧边栏、页脚同时变样式;购物车加商品,要更新头部购物车的数量显示……这些“无直接关系”的组件通信,用事件总线更灵活。
简单说:事件总线是“解耦”组件的利器——发布者和订阅者不用知道彼此存在,只关心“事件名 + 数据”,代码维护性更高。
Vue3 事件总线怎么实现?(3种主流方式)
方式1:用第三方库「mitt」(最推荐,轻量好用)
mitt是专门做“事件发布 - 订阅”的库,体积小(不到1KB)、API简单,Vue3生态里用得最多。
步骤1:安装mitt
打开终端,项目里执行:
npm install mitt
步骤2:创建事件总线实例
新建一个bus.js
(位置随意,比如src/utils/bus.js):
import mitt from 'mitt' // 创建mitt实例,导出给其他组件用 export default mitt()
步骤3:在组件里用「发布 - 订阅」
比如有个<Header>
组件,点击按钮触发“主题切换”事件;<Sidebar>
和<Content>
组件要监听这个事件,改变样式。
- 发布事件(Header组件):
<template> <button @click="changeTheme">切换主题</button> </template>
- 订阅事件(Sidebar组件):
<template> <div :class="theme">侧边栏</div> </template>
mitt还有个once
方法,适合只触发一次的场景(比如支付成功后跳转,只需要监听一次):
bus.once('pay-success', () => { // 只执行一次,之后自动取消监听 router.push('/success') })
方式2:自己手写「发布 - 订阅」逻辑(理解原理)
不想装第三方库?自己写个极简版事件总线也不难,核心是实现on
(订阅)、emit
(发布)、off
(取消订阅)这三个方法。
新建bus.js
:
const bus = { // 存储事件和对应的回调:key是事件名,value是回调数组 events: {}, // 订阅事件:把回调存到events里 on(name, callback) { if (!this.events[name]) { this.events[name] = [] } this.events[name].push(callback) }, // 发布事件:触发对应事件名的所有回调 emit(name, ...args) { if (this.events[name]) { this.events[name].forEach(cb => cb(...args)) } }, // 取消订阅:从events里删掉某个回调 off(name, callback) { if (this.events[name]) { this.events[name] = this.events[name].filter(cb => cb !== callback) } } } export default bus
用法和mitt几乎一样,组件里导入bus
,调用on/emit/off
就行,这种方式能帮你理解“发布 - 订阅”的本质:用对象存事件和回调,触发时遍历执行。
方式3:结合Vue的「响应式」做总线(进阶玩法)
如果想让事件总线和Vue的响应式系统结合更紧密(比如传递的数据是响应式的),可以用reactive
包装:
新建bus.js
:
import { reactive } from 'vue' // 创建响应式的总线对象 const bus = reactive({ events: {}, on(name, cb) { if (!this.events[name]) this.events[name] = [] this.events[name].push(cb) }, emit(name, ...args) { if (this.events[name]) this.events[name].forEach(cb => cb(...args)) }, off(name, cb) { if (this.events[name]) { this.events[name] = this.events[name].filter(fn => fn!==cb) } } }) export default bus
这种方式下,bus.events
是响应式的,如果你在回调里修改了Vue的响应式数据,能触发界面更新,适合一些和Vue状态强绑定的场景,但要注意:别过度依赖,否则总线逻辑会和Vue耦合太深。
Vue3 事件总线和Vue2 有啥区别?
很多从Vue2转过来的同学,最困惑的是“Vue2能直接用this.$bus
,Vue3咋不行了?”
-
Vue2的实现:依赖Vue实例的
$on/$emit
,我们通常在main.js
里给Vue原型挂$bus
:Vue.prototype.$bus = new Vue()
然后组件里用
this.$bus.$emit('xxx')
、this.$bus.$on('xxx')
。 -
Vue3的变化:
createApp
创建的应用实例,不再内置$on/$emit
这些事件方法(因为Vue3核心更轻量化,把这些非核心功能剥离了),所以不能再直接用Vue实例当总线,得自己实现或用第三方库。
简单说:Vue3的事件总线,是更纯粹的“发布 - 订阅”工具,和Vue实例解耦了,灵活性更高,但需要自己搭架子。
实战:用事件总线解决真实场景问题
光说不练假把式,举两个常见场景,看事件总线咋落地。
场景1:多组件主题切换(Header、Sidebar、Content、Footer同步变)
需求:点击Header的“切换主题”按钮,所有组件的主题(亮色/暗色)同步变化。
步骤1:创建事件总线(用mitt)
新建src/utils/bus.js
如前所述(导入mitt,导出实例)。
步骤2:Header组件(发布事件)
<template> <div class="header"> <button @click="toggleTheme">切换主题</button> </div> </template> <script setup> import bus from '@/utils/bus.js' import { ref } from 'vue' const currentTheme = ref('light') const toggleTheme = () => { currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light' // 发布事件,把当前主题传出去 bus.emit('theme-change', currentTheme.value) } </script>
步骤3:Sidebar组件(订阅事件)
<template> <div :class="['sidebar', theme]">侧边栏内容</div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import bus from '@/utils/bus.js' const theme = ref('light') onMounted(() => { // 监听主题变化事件 bus.on('theme-change', (val) => { theme.value = val }) }) onUnmounted(() => { // 组件销毁时取消监听,防止内存泄漏 bus.off('theme-change') }) </script> <style scoped> .sidebar.light { background: #fff; color: #333; } .sidebar.dark { background: #333; color: #fff; } </style>
步骤4:Content、Footer组件
和Sidebar逻辑一样,订阅theme-change
事件,根据传过来的主题切换class,这样点击Header按钮,所有组件的主题就同步变了。
场景2:购物车商品数量同步(列表添加商品,头部图标数字更新)
需求:购物车列表组件添加商品后,头部导航的“购物车”图标显示最新数量。
步骤1:事件总线还是用mitt
复用之前的bus.js
。
步骤2:购物车列表组件(发布事件)
<template> <div class="cart-list"> <div v-for="(item, index) in cartList" :key="index"> {{ item.name }} - ¥{{ item.price }} <button @click="addItem(item)">加入购物车</button> </div> </div> </template> <script setup> import bus from '@/utils/bus.js' import { ref } from 'vue' const cartList = ref([ { name: '手机', price: 3999 }, { name: '耳机', price: 999 } ]) const addItem = (item) => { // 这里可以先做添加逻辑,再发布事件 // 假设已经把商品加到购物车数组里了,现在通知头部更新数量 bus.emit('cart-add', 1) // 传递新增数量(这里简化为1) } </script>
步骤3:头部购物车组件(订阅事件)
<template> <div class="header-cart"> 购物车 <span class="count">{{ cartCount }}</span> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import bus from '@/utils/bus.js' const cartCount = ref(0) onMounted(() => { bus.on('cart-add', (num) => { cartCount.value += num }) }) onUnmounted(() => { bus.off('cart-add') }) </script> <style scoped> .count { color: red; font-weight: bold; } </style>
这样,每次购物车列表点“加入购物车”,头部的数量就会 + 1,实现跨组件同步。
用事件总线容易踩的坑,怎么避?
事件总线好用,但用不对容易出问题,这几个坑要注意:
坑1:内存泄漏(组件销毁后,回调还在执行)
表现:比如组件A订阅了事件,销毁后再触发事件,A的回调还会执行,导致逻辑混乱、内存越用越多。
解决:组件销毁时,一定要取消订阅,在onUnmounted
(Vue3的setup语法糖)或beforeDestroy
(选项式API)里调用off
方法。
示例(避免内存泄漏):
<script setup> import { onMounted, onUnmounted } from 'vue' import bus from '@/utils/bus.js' onMounted(() => { const callback = (val) => { /* 逻辑 */ } bus.on('xxx', callback) onUnmounted(() => { bus.off('xxx', callback) // 销毁时取消订阅 }) }) </script>
坑2:事件名冲突(不同组件用了相同事件名,逻辑串了)
表现:组件A和组件B都订阅了'change'
事件,A触发时B的回调也执行了,导致意想不到的逻辑。
解决:约定事件名命名规范,比如加前缀区分模块,例如购物车用'cart:add'
、主题用'theme:change'
,这样不同模块的事件名不会冲突。
坑3:事件多次触发(重复订阅同一事件)
表现:比如组件在onMounted
里重复调用on
,导致一个事件触发时,回调执行多次。
解决:
- 确保
on
只调用一次(比如把订阅逻辑写在onMounted
里,且组件只挂载一次); - 用mitt的
once
方法(只触发一次就自动取消订阅); - 订阅前先
off
(不过不太推荐,容易绕)。
坑4:响应式数据“不响应”
表现:事件总线传递了原始数据(如数字、字符串),接收方修改后,其他组件没更新。
原因:事件总线传递的是“值”,不是Vue的响应式对象。
解决:
- 传递响应式对象(用
reactive
或ref
包装后的数据); - 接收方自己用
ref
或reactive
包裹数据,再触发更新。
还有没有替代事件总线的方案?
事件总线不是唯一解,这些场景可以换其他方案:
状态管理库(Pinia/Vuex)
如果是全局状态共享(比如用户信息、购物车列表、主题配置),用Pinia(Vue3官方推荐)更合适,它是集中式存储,支持响应式、模块化,还能做数据持久化。
比如主题切换,用Pinia的Store管理主题状态:
// stores/theme.js import { defineStore } from 'pinia' export const useThemeStore = defineStore('theme', { state: () => ({ theme: 'light' }), actions: { toggleTheme() { this.theme = this.theme === 'light' ? 'dark' : 'light' } } })
组件里直接调用:
<template> <button @click="toggle">切换主题</button> </template> <script setup> import { useThemeStore } from '@/stores/theme.js' const themeStore = useThemeStore() const toggle = () => { themeStore.toggleTheme() } </script>
适合场景:全局状态多、组件通信频繁的大型项目。
provide/inject(祖孙组件通信)
如果是祖孙组件(比如祖父传数据给孙子,或孙子传数据给祖父),用provide/inject
更直接,不用绕事件总线。
祖父组件provide数据:
<template> <Child /> </template> <script setup> import { provide } from 'vue' import Child from './Child.vue' provide('theme', 'light') </script>
孙子组件inject接收:
<template> <div>当前主题:{{ theme }}</div> </template> <script setup> import { inject } from 'vue' const theme = inject('theme') </script>
适合场景:明确的祖孙层级,数据只在这一层级流通。
浏览器事件(window.dispatchEvent)
如果想跨Vue应用通信(比如同一页面多个Vue实例),可以用浏览器的自定义事件:
发布方:
const event = new CustomEvent('global-change', { detail: 'dark' }) window.dispatchEvent(event)
订阅方:
window.addEventListener('global-change', (e) => { console.log(e.detail) // 拿到数据 })
适合场景:多个Vue应用共存,或和非Vue代码通信。
Vue3 事件总线该咋选?
- 简单跨组件通信、项目不大 → 用mitt库,轻量又省心;
- 想理解原理、定制逻辑 → 自己手写发布 - 订阅;
- 全局状态多、组件通信复杂 → 上Pinia;
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。