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

Vue3 里咋搞全局事件?从基础到实战一次讲透

terry 2周前 (10-04) 阅读数 64 #Vue
文章标签 Vue3 全局事件

刚学Vue3开发,想让不同组件之间“隔空对话”,全局事件到底该咋上手?别担心,这篇从概念到代码、从场景到避坑,给你拆得明明白白,不管是跨组件传消息,还是全局状态联动,看完就知道咋用全局事件解决实际开发难题~

先搞懂:Vue3全局事件是干啥的?

简单说,全局事件就是让不同组件(哪怕隔了N层、是兄弟关系)能互相发消息、响应动作,比如顶部导航切换后,侧边栏自动更新选中状态;点击按钮弹出全局Toast提示;切换主题时所有组件同步换肤……这些场景里,用组件自带的props/emit(只能父子通信)、provide/inject(得有共同祖先)都不够灵活,这时候全局事件就派上用场了。

对比Vue2,以前常用this.$on/this.$emit搞“事件总线(Event Bus)”,但Vue3把实例上的$on这类API砍掉了,所以得换思路——要么自己实现事件总线,要么借助第三方库、状态管理工具,本质还是“谁要通知就发事件,谁要响应就监听事件”。

Vue3实现全局事件的4种常用方式

下面从简单到进阶,逐个讲怎么落地,代码直接能抄~

方式1:自己搞“事件总线”(最灵活,轻量级)

Vue3没了内置的事件总线,咱可以用第三方库mitt(体积小、API简单),也能自己写个简易版,步骤如下:

  1. 装依赖:npm i mitt(如果自己写,就定义个发布订阅对象)。
  2. 建工具文件:比如src/utils/eventBus.js,代码长这样:
    import mitt from 'mitt'  
    // 如果你用TS,还能给事件定类型:type Events = { 'changeTheme': boolean; 'showToast': string }  
    // const emitter = mitt<Events>()  
    const emitter = mitt()  
    export default emitter  
  3. 组件里用:
  • 发事件的组件(比如点击按钮触发全局Toast):
    <template><button @click="handleClick">点我弹Toast</button></template>  
    <script setup>  
    import emitter from '@/utils/eventBus.js'  
    const handleClick = () => {  
    emitter.emit('showToast', '操作成功~') // 发事件名+参数  
    }  
    </script>  
  • 收事件的组件(比如全局Toast组件):
    <template><div v-if="show">{{ msg }}</div></template>  
    <script setup>  
    import { onMounted, onUnmounted } from 'vue'  
    import emitter from '@/utils/eventBus.js'  

const show = ref(false)
const msg = ref('')

// 挂载时监听事件
onMounted(() => {
emitter.on('showToast', (text) => { // 监听事件,接收参数
show.value = true
msg.value = text
setTimeout(() => show.value = false, 2000)
})
})

// 卸载时销毁监听(必做!否则重复监听内存爆炸)
onUnmounted(() => {
emitter.off('showToast')
})

```

这种方式的核心是“发布 - 订阅”模式:emit发消息,on听消息,off取消听,优点是轻量、灵活,缺点是得自己管事件销毁,不然容易内存泄漏。

方式2:借状态管理工具(Vuex/Pinia)的“订阅”能力

如果项目已经用了Vuex或Pinia管理状态,那它们的“订阅”功能也能当全局事件用,比如状态变化时,触发全局动作

以Pinia为例(比Vuex更轻,Vue3推荐用):

  1. 定义Store(比如src/store/app.js):
    import { defineStore } from 'pinia'  

export const useAppStore = defineStore('app', {
state: () => ({ theme: 'light' }),
actions: {
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
}
}
})


2. 组件A:触发状态变更 + 间接发事件  
```vue
<template><button @click="toggle">切换主题</button></template>  
<script setup>  
import { useAppStore } from '@/store/app.js'  
const appStore = useAppStore()  
const toggle = () => {  
  appStore.toggleTheme() // 先改状态  
}  
</script>  
  1. 组件B:订阅状态变更,当“事件”响应
    <template><div :class="theme">页面内容</div></template>  
    <script setup>  
    import { onMounted, onUnmounted } from 'vue'  
    import { useAppStore } from '@/store/app.js'  

const appStore = useAppStore()
const theme = computed(() => appStore.theme)

// 订阅action(状态变更前/后触发)
let unsubscribe
onMounted(() => {
unsubscribe = appStore.$onAction((action) => {
// action.name 是触发的action名字,toggleTheme'
if (action.name === 'toggleTheme') {
console.log('主题要变啦,做些DOM操作或其他组件通知~')
}
})
})

onUnmounted(() => {
unsubscribe() // 销毁订阅
})

```

这种方式适合“状态变更”和“全局事件”强绑定的场景,比如主题切换时,既要改状态,又要通知所有组件换样式,优点是和状态管理结合紧密,缺点是逻辑耦合在状态变更里,不适合纯事件通信。

方式3:provide/inject + 自定义事件(祖孙组件专属)

如果组件有共同祖先(比如App.vue是所有组件的爹),可以用provide把“事件处理函数”给后代,后代用inject拿到后发事件。

举个例子:App.vue提供全局提示能力,所有后代组件都能调。

  1. App.vue里provide:
    <template>  
    <div><Toast :show="showToast" :msg="toastMsg" /></div>  
    </template>  
    <script setup>  
    import { provide, ref } from 'vue'  
    import Toast from './components/Toast.vue'  

const showToast = ref(false)
const toastMsg = ref('')

// 提供一个“显示Toast”的方法
provide('showGlobalToast', (msg) => {
toastMsg.value = msg
showToast.value = true
setTimeout(() => showToast.value = false, 2000)
})

```
  1. 后代组件(比如任意深度的子组件)inject后调用:
    <template><button @click="showTip">点我弹全局Toast</button></template>  
    <script setup>  
    import { inject } from 'vue'  

const showGlobalToast = inject('showGlobalToast')

const showTip = () => {
showGlobalToast('这是全局提示~') // 调用祖先提供的方法,触发Toast
}

```

这种方式的核心是“祖先统一管理事件逻辑,后代只负责触发”,适合有明确层级关系的全局能力(比如全局弹层、导航控制),优点是逻辑收拢在祖先,缺点是只能在祖孙间用,灵活性不如事件总线。

方式4:浏览器原生全局事件(window/document级)

比如监听窗口大小变化、键盘事件,这些属于浏览器层面的“全局事件”,Vue里也能结合生命周期用。

举个监听窗口resize的例子:

<template><div>当前窗口宽度:{{ width }}</div></template>  
<script setup>  
import { ref, onMounted, onUnmounted } from 'vue'  
const width = ref(window.innerWidth)  
const handleResize = () => {  
  width.value = window.innerWidth  
}  
onMounted(() => {  
  window.addEventListener('resize', handleResize)  
})  
onUnmounted(() => {  
  window.removeEventListener('resize', handleResize)  
})  
</script>  

这种方式属于“借力浏览器API”,适合和页面全局状态(如窗口大小、滚动位置)联动的场景,但要注意:必须在组件卸载时移除监听,否则多次渲染会导致重复执行回调,甚至内存泄漏。

全局事件在项目里的5个实战场景

光讲理论太虚?看几个真实开发中能用到的场景,直接套代码~

场景1:导航切换,侧边栏自动更新

顶部导航(Header)切换路由后,侧边栏(Aside)要高亮当前选中项,用事件总线实现:

  • Header组件(发事件):
    <template>  
    <nav>  
      <ul>  
        <li @click="changeRoute('home')">首页</li>  
        <li @click="changeRoute('about')">lt;/li>  
      </ul>  
    </nav>  
    </template>  
    <script setup>  
    import { useRouter } from 'vue-router'  
    import emitter from '@/utils/eventBus.js'  

const router = useRouter()
const changeRoute = (path) => {
router.push(/${path})
emitter.emit('routeChanged', path) // 路由变了,发事件
}

```
  • Aside组件(收事件):
    <template>  
    <aside>  
      <ul>  
        <li :class="{ active: currentRoute === 'home' }">首页</li>  
        <li :class="{ active: currentRoute === 'about' }">lt;/li>  
      </ul>  
    </aside>  
    </template>  
    <script setup>  
    import { ref, onMounted, onUnmounted } from 'vue'  
    import emitter from '@/utils/eventBus.js'  

const currentRoute = ref('home')

onMounted(() => {
emitter.on('routeChanged', (path) => {
currentRoute.value = path
})
})

onUnmounted(() => {
emitter.off('routeChanged')
})

```

场景2:全局Toast/Notification

很多页面都需要“操作成功提示”,搞个全局Toast组件,用事件总线触发:

  • Toast组件(全局组件,一般在App.vue里渲染):
    <template>  
    <div class="toast" v-if="show">  
      {{ msg }}  
    </div>  
    </template>  
    <script setup>  
    import { ref, onMounted, onUnmounted } from 'vue'  
    import emitter from '@/utils/eventBus.js'  

const show = ref(false)
const msg = ref('')

onMounted(() => {
emitter.on('showToast', (text) => {
msg.value = text
show.value = true
setTimeout(() => show.value = false, 2000)
})
})

onUnmounted(() => {
emitter.off('showToast')
})

```
  • 任意页面/组件触发:
    <template><button @click="handleClick">提交</button></template>  
    <script setup>  
    import emitter from '@/utils/eventBus.js'  

const handleClick = () => {
// 调接口等逻辑...
emitter.emit('showToast', '提交成功!')
}

```

场景3:主题切换,全页面组件同步换肤

用Pinia管理主题状态,同时触发全局事件通知所有组件:

  • Pinia的Store(src/store/theme.js):
    import { defineStore } from 'pinia'  

export const useThemeStore = defineStore('theme', {
state: () => ({ isDark: false }),
actions: {
toggle() {
this.isDark = !this.isDark
// 触发全局事件(也可以用eventBus发事件)
this.$emit('themeChanged', this.isDark)
}
}
})


- 任意需要换肤的组件(比如Footer):  
```vue
<template><footer :class="{ dark: isDark }">页脚</footer></template>  
<script setup>  
import { computed, onMounted, onUnmounted } from 'vue'  
import { useThemeStore } from '@/store/theme.js'  
const themeStore = useThemeStore()  
const isDark = computed(() => themeStore.isDark)  
let unsubscribe  
onMounted(() => {  
  unsubscribe = themeStore.$on('themeChanged', (isDark) => {  
    // 这里可以做DOM操作,比如换样式、加载暗黑主题CSS等  
    console.log('主题变了,现在是', isDark ? '暗黑' : '亮色')  
  })  
})  
onUnmounted(() => {  
  unsubscribe()  
})  
</script>  

场景4:跨页面(路由)通信,购物车徽章更新

SPA里不同路由组件(比如商品页和购物车页)通信,用事件总线:

  • 商品页(添加商品到购物车,发事件):
    <template><button @click="addCart">加入购物车</button></template>  
    <script setup>  
    import { useRouter } from 'vue-router'  
    import emitter from '@/utils/eventBus.js'  

const addCart = () => {
// 调接口添加商品...
emitter.emit('cartUpdated') // 购物车数据变了,发事件
router.push('/cart') // 跳转到购物车页
}

```
  • 购物车页(监听事件,更新徽章):
    <template><div>购物车({{ count }}件)</div></template>  
    <script setup>  
    import { ref, onMounted, onUnmounted } from 'vue'  
    import emitter from '@/utils/eventBus.js'  

const count = ref(0)

const fetchCart = () => {
// 调接口获取购物车数量...
count.value = 5 // 假设接口返回5
}

onMounted(() => {
fetchCart() // 初始化
emitter.on('cartUpdated', fetchCart) // 购物车更新时重新拉取
})

onUnmounted(() => {
emitter.off('cartUpdated', fetchCart)
})

```

场景5:监听浏览器回退,拦截非法操作

用浏览器原生事件popstate,在App.vue里全局监听:

<template><router-view /></template>  
<script setup>  
import { onMounted, onUnmounted } from 'vue'  
const handlePopState = (e) => {  
  // 比如判断当前页面是否允许回退  
  if (当前页面是表单页且没保存) {  
    e.preventDefault() // 阻止回退  
    alert('表单没保存,不能回退哦~')  
  }  
}  
onMounted(() => {  
  window.addEventListener('popstate', handlePopState)  
})  
onUnmounted(() => {  
  window.removeEventListener('popstate', handlePopState)  
})  
</script>  

全局事件和其他通信方式咋选?一张表看明白

开发时到底用全局事件,还是props/emit、Vuex/Pinia?看场景选:

通信方式 适用场景 优点 缺点
全局事件(如mitt) 跨组件临时通信,无状态依赖 轻量、灵活 需手动管理销毁
props/emit 父子组件明确数据传递 逻辑清晰、单向流 只能父子,层级深麻烦
provide/inject 祖孙组件共享逻辑/数据 跨层级方便 依赖祖先组件
Vuex/Pinia 全局状态长期共享,多组件依赖 状态集中管理 冗余(小项目没必要)
浏览器原生事件 页面级全局事件(如resize) 借力浏览器API 需手动管理生命周期

简单说:临时跨组件通信,用全局事件;长期共享数据,用状态管理;明确父子传递,用props/emit

用全局事件容易踩的4个坑,咋避?

别光看优点,这些坑踩过才知道疼,提前避坑!

坑1:内存泄漏(最常见)

表现:组件销毁后,事件还在触发,重复执行回调,页面越来越卡。
解决:组件卸载时(onUnmounted),一定要用off移除事件监听,比如用mitt时,在onUnmounted里调用emitter.off('事件名');用原生事件时,removeEventListener

坑2:事件名冲突

表现:A组件发的事件,B组件也监听了,结果逻辑串了。
解决:给事件名加前缀,比如区分业务模块:user_loginSuccesscart_addItem,或者用TS给事件定类型(mitt支持),强制约束事件名和参数。

坑3:全局事件用太多,代码维护难

表现:项目里到处是emit/on,出问题不知道哪发的、哪听的。
解决:只在必要时用,复杂场景优先状态管理(Vuex/Pinia),把事件名和逻辑收拢到工具文件(比如eventBus.js里定义所有事件名常量),方便统一管理。

坑4:TS项目里类型不安全

表现:事件名

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

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

热门