先搞懂,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前端网发表,如需转载,请注明页面地址。
code前端网




发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。