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简单),也能自己写个简易版,步骤如下:
- 装依赖:
npm i mitt
(如果自己写,就定义个发布订阅对象)。 - 建工具文件:比如
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
- 组件里用:
- 发事件的组件(比如点击按钮触发全局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推荐用):
- 定义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>
- 组件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提供全局提示能力,所有后代组件都能调。
- 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)
})
- 后代组件(比如任意深度的子组件)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_loginSuccess
、cart_addItem
,或者用TS给事件定类型(mitt支持),强制约束事件名和参数。
坑3:全局事件用太多,代码维护难
表现:项目里到处是emit/on,出问题不知道哪发的、哪听的。
解决:只在必要时用,复杂场景优先状态管理(Vuex/Pinia),把事件名和逻辑收拢到工具文件(比如eventBus.js里定义所有事件名常量),方便统一管理。
坑4:TS项目里类型不安全
表现:事件名
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。