Code前端首页关于Code前端联系我们

先搞懂,Vue3 事件总线是个啥?

terry 2周前 (09-29) 阅读数 52 #Vue
文章标签 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的响应式对象。
解决

  • 传递响应式对象(用reactiveref包装后的数据);
  • 接收方自己用refreactive包裹数据,再触发更新。

还有没有替代事件总线的方案?

事件总线不是唯一解,这些场景可以换其他方案:

状态管理库(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前端网发表,如需转载,请注明页面地址。

发表评论:

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

热门