或者
Vue3全局组件通信只能用provide-inject?mitt/emits还有setup语法糖组合拳怎么玩?
你好呀,最近刷到好多刚从Vue2转过来的小伙伴吐槽:“原来的$on、$off、$once全没了?传个数据还要绕来绕去用provide-inject?太麻烦了吧!”其实不止你烦,很多项目里跨多个层级、甚至跨路由的简单通知类需求,真没必要硬上状态管理或者层层provide,今天咱们就聊聊Vue3全局事件的所有主流玩法——从为什么删了$bus开始,到mitt轻量级事件总线的细节,再到provide+emits结合setup的“伪全局精准通信”,还有什么时候该用哪种方案,一次给你讲透。
为什么Vue2的$on/$off/$once被彻底删掉了?
在说新方案之前,得先搞懂尤雨溪团队为啥要把大家用顺手的EventBus废掉,其实这不是“为了改而改”,而是Vue3核心架构从Options API转向Composition API,再加上响应式系统重构后,原来的全局事件模式暴露出了越来越多的问题:
第一个问题是事件监听/销毁完全依赖开发者自觉,Vue2里如果用this.$root.$on或者自己造的EventBus实例监听,组件销毁时忘了$off,会直接导致内存泄漏——举个例子,你在详情页监听了用户修改头像的通知,跳转到列表页后没移除,下次再进详情页就会注册两次、三次,直到整个应用卡死,Options API虽然有beforeDestroy钩子,但很多新手根本记不住,老手也偶尔会漏写。
第二个问题是事件命名污染严重,全局嘛,谁都能发谁都能收,比如A组件发了个“update”,B组件也发了个“update”,结果C组件本来只想接B的,结果A的也冲进来了,排查起来简直是灾难,稍微规范点的团队会加前缀,user_update_avatar”,但前缀加得太长又麻烦,短了还是容易撞。
第三个问题是和Composition API的响应式逻辑不兼容,Vue3的setup语法糖里没有this,你要想用原来的$root或者EventBus,要么把实例挂到window上(太野了,强烈不推荐),要么通过getCurrentInstance获取ctx(但getCurrentInstance在生产环境不稳定,官方早就不建议在业务逻辑里用它的proxy属性)。
所以综合来看,EventBus的移除是趋势,不过别慌,官方给我们留了“后门”?不,是留了更规范的替代方案思路,社区也马上补了像mitt这样的轻量级工具。
Vue3最常用的“轻量级全局事件总线”mitt怎么玩?
既然官方没内置,那社区最火的mitt肯定是首选——它有多轻?压缩后只有200多字节!没有任何依赖,不管是Vue项目还是React、原生JS项目都能用,完美适配Vue3的各种语法。
第一步:安装+全局/按需引入
安装很简单,npm/pnpm/yarn随便选:
npm install mittpnpm add mittyarn add mitt
引入方式有两种,看你项目的需求: 如果是全场景都要频繁用全局事件,比如通知栏弹出、登录状态更新(注意登录状态如果涉及多字段复杂响应,还是建议Pinia),可以全局挂载到app.config.globalProperties,不过setup语法糖里要配合useVueUse的useAttrs或者defineExpose?不对,更简单的是自己封装一个composable! 如果是偶尔用跨几个页面的简单通知,直接按需引入mitt实例就行,这样更轻,不会污染全局命名。
推荐大家优先用封装composable的方式,这样既符合Vue3的Composition API理念,又方便后续维护和测试——封装的时候还能统一加前缀,避免命名污染,一举两得。
第二步:封装一个安全、规范的mitt composable
我自己平时项目里封装的叫useMittEvent,分享给你参考下:
// src/composables/useMittEvent.ts
import mitt, { Emitter, EventHandlerMap, EventType } from 'mitt';
import { onUnmounted } from 'vue';
// 定义事件类型,用TypeScript的话一定要加,防止写错事件名和参数
type AppEvents = {
'user:update-avatar': string; // 参数是新头像的url
'cart:add-item': { id: number; name: string; count: number };
'notification:show': { title: string; content: string; type: 'success' | 'error' | 'warning' };
};
// 创建全局唯一的mitt实例
const emitter: Emitter<AppEvents> = mitt<AppEvents>();
export const useMittEvent = () => {
// 监听事件,自动在组件销毁时移除,解决Vue2的内存泄漏问题!
const on = <T extends EventType<AppEvents>>(
event: T,
handler: (payload: AppEvents[T]) => void
) => {
emitter.on(event, handler);
// Vue3的onUnmounted钩子,直接在setup里用就行,不用this
onUnmounted(() => {
emitter.off(event, handler);
});
};
// 发送事件
const emit = <T extends EventType<AppEvents>>(
event: T,
payload?: AppEvents[T]
) => {
emitter.emit(event, payload as AppEvents[T]);
};
// 移除所有监听(一般在路由重置或者用户退出登录时用)
const offAll = () => {
emitter.all.clear();
};
return { on, emit, offAll };
};
这个封装的核心亮点有三个:
- TypeScript类型支持:AppEvents里定义了所有可能的事件名和对应的参数类型,在VSCode里写的时候会自动提示,根本不会写错事件名,也不会传错参数,这在Vue2里是很难做到的。
- 自动销毁监听:在on函数里直接调用onUnmounted注册销毁逻辑,不管你在哪里用这个on,只要组件销毁了,监听就会自动被移除,新手也不用怕内存泄漏了。
- 全局唯一实例+统一前缀:前缀用了
user:、cart:这种业务模块名,完全避免了命名污染,清理事件的时候也可以按模块批量清理?不过mitt本身没有批量移除模块事件的功能,要是有需求可以稍微改下封装逻辑,比如加一个事件前缀的存储数组。
第三步:在项目里实际用起来
假设我们有三个组件:AvatarUploader.vue(上传头像的组件,可能在个人中心或者弹窗里)、Header.vue(顶部导航栏,显示用户头像)、Notification.vue(全局通知栏)。
AvatarUploader.vue,上传成功后发送两个事件:更新头像的url,还有显示成功通知:
<!-- src/components/AvatarUploader.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useMittEvent } from '@/composables/useMittEvent';
const { emit } = useMittEvent();
const newAvatarUrl = ref('');
const uploadSuccess = (url: string) => {
newAvatarUrl.value = url;
// 发送更新头像事件
emit('user:update-avatar', url);
// 发送成功通知事件
emit('notification:show', { '上传成功',
content: '您的头像已更新',
type: 'success'
});
};
</script>
<template>
<div>
<!-- 省略上传按钮和逻辑 -->
<button @click="uploadSuccess('https://example.com/new-avatar.jpg')">模拟上传成功</button>
</div>
</template>
然后是Header.vue,监听更新头像的事件:
<!-- src/components/Header.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useMittEvent } from '@/composables/useMittEvent';
const { on } = useMittEvent();
const avatarUrl = ref('https://example.com/default-avatar.jpg');
// 监听头像更新事件
on('user:update-avatar', (url) => {
avatarUrl.value = url;
});
</script>
<template>
<header>
<img :src="avatarUrl" alt="用户头像" class="header-avatar" />
</header>
</template>
Notification.vue,放在App.vue的最外层,监听所有通知事件:
<!-- src/components/Notification.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useMittEvent } from '@/composables/useMittEvent';
const { on } = useMittEvent();
const notifications = ref<Array<{ id: number; title: string; content: string; type: 'success' | 'error' | 'warning' }>>([]);
let idCounter = 0;
on('notification:show', (payload) => {
const id = ++idCounter;
notifications.value.push({ id, ...payload });
// 3秒后自动移除通知
setTimeout(() => {
const index = notifications.value.findIndex(n => n.id === id);
if (index !== -1) {
notifications.value.splice(index, 1);
}
}, 3000);
});
</script>
<template>
<div class="notification-container">
<div
v-for="notification in notifications"
:key="notification.id"
:class="['notification', `notification-${notification.type}`]"
>
<h4>{{ notification.title }}</h4>
<p>{{ notification.content }}</p>
</div>
</div>
</template>
<style scoped>
/* 省略样式,放在右上角就行 */
</style>
记得把Notification.vue放在App.vue的根节点里:
<!-- src/App.vue -->
<script setup lang="ts">
import Notification from '@/components/Notification.vue';
import Header from '@/components/Header.vue';
import AvatarUploader from '@/components/AvatarUploader.vue';
</script>
<template>
<div class="app">
<Header />
<main>
<AvatarUploader />
</main>
<Notification />
</div>
</template>
你看,这样就实现了跨多个组件的全局通知,是不是比层层provide-inject方便多了?而且完全没有Vue2的那些问题。
mitt不是万能的!provide+emits组合拳的“伪全局精准通信”更适合什么场景?
刚才说了mitt适合简单的通知类、触发类跨组件通信,显示通知”“刷新列表”“关闭弹窗”,但如果是需要响应式数据同步、或者跨多层但有明确的上下层级关系的场景,provide+emits的组合拳(或者单独provide响应式数据)会更合适——为什么叫它“伪全局精准通信”?因为它可以通过provide把数据/方法传到整个子树,但只有需要的组件才会inject,不会像mitt那样所有组件都能看到(虽然mitt看不到内部实现,但事件名还是全局的)。
什么时候用provide+响应式数据?
比如你有一个电商的商品筛选组件树:
- 最上层是
FilterContainer.vue(包含所有筛选条件) - 中间层是
FilterTabs.vue(分类标签)、FilterPrice.vue(价格区间)、FilterSort.vue(排序方式) - 最下层是
ProductList.vue(显示商品列表)
这种场景下,ProductList需要同步所有筛选条件的变化,而中间的筛选组件需要修改筛选条件——这时候用provide响应式的reactive/ref对象,配合中间组件的inject+computed(或者直接修改,不过推荐用computed+方法的形式,避免直接修改根数据),会比mitt更清晰,因为数据的流向是明确的:FilterContainer提供数据,子组件修改数据,ProductList监听数据变化。
举个简单的例子:
<!-- src/components/FilterContainer.vue -->
<script setup lang="ts">
import { provide, reactive } from 'vue';
import FilterTabs from './FilterTabs.vue';
import FilterPrice from './FilterPrice.vue';
import FilterSort from './FilterSort.vue';
import ProductList from './ProductList.vue';
// 定义筛选条件的类型
type FilterState = {
category: string;
minPrice: number;
maxPrice: number;
sortBy: 'price-asc' | 'price-desc' | 'sales-desc';
};
// 提供响应式的筛选状态
const filterState = reactive<FilterState>({
category: 'all',
minPrice: 0,
maxPrice: 9999,
sortBy: 'sales-desc'
});
// 提供修改筛选状态的方法,避免子组件直接修改根数据(这是规范!)
const updateFilter = (key: keyof FilterState, value: FilterState[keyof FilterState]) => {
filterState[key] = value;
};
// provide到整个子树
provide('filterState', filterState);
provide('updateFilter', updateFilter);
</script>
<template>
<div class="filter-container">
<FilterTabs />
<div class="filter-controls">
<FilterPrice />
<FilterSort />
</div>
<ProductList />
</div>
</template>
然后是FilterTabs.vue,修改分类:
<!-- src/components/FilterTabs.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import type { FilterState } from './FilterContainer.vue';
// inject筛选状态和修改方法
const filterState = inject<FilterState>('filterState');
const updateFilter = inject<(key: keyof FilterState, value: FilterState[keyof FilterState]) => void>('updateFilter');
// 这里可以加个类型守卫,防止inject失败
if (!filterState || !updateFilter) {
throw new Error('FilterTabs必须放在FilterContainer内部使用');
}
const categories = ['all', 'electronics', 'clothes', 'food'];
</script>
<template>
<div class="filter-tabs">
<button
v-for="cat in categories"
:key="cat"
:class="{ active: filterState.category === cat }"
@click="updateFilter('category', cat)"
>
{{ cat }}
</button>
</div>
</template>
ProductList.vue,监听筛选状态变化并刷新列表:
<!-- src/components/ProductList.vue -->
<script setup lang="ts">
import { inject, watch, ref, onMounted } from 'vue';
import type { FilterState } from './FilterContainer.vue';
const filterState = inject<FilterState>('filterState');
if (!filterState) {
throw new Error('ProductList必须放在FilterContainer内部使用');
}
const products = ref([]);
// 模拟获取商品列表的API
const fetchProducts = async () => {
// 这里可以用filterState的参数发请求
console.log('当前筛选条件:', filterState);
// 假装API返回了数据
products.value = [];
};
// 组件挂载时获取一次
onMounted(fetchProducts);
// 监听筛选状态的任何变化,刷新列表
watch(filterState, fetchProducts, { deep: true });
</script>
<template>
<div class="product-list">
<!-- 省略商品列表渲染 -->
<p>商品列表会根据筛选条件变化刷新</p>
</div>
</template>
这种场景下,用provide+响应式数据的好处是:
- 数据流向明确:只有FilterContainer的子组件能拿到数据,不会影响其他无关的组件。
- 天然响应式:不需要手动发送事件和监听,直接用watch或者computed就能同步数据变化。
- 可维护性强:所有筛选逻辑都集中在FilterContainer里,修改的时候不用到处找事件监听和发送的地方。
什么时候用provide+emits组合拳?
如果是跨多层的子组件需要触发父组件的某个方法,但不需要同步数据的场景,可以用provide+emits的组合拳——比如子组件需要调用父组件的弹窗关闭方法,但父组件和子组件之间隔了三层,这时候可以直接provide一个关闭方法,子组件inject后调用就行,比mitt更精准,而且不需要考虑事件命名的问题。
举个例子,假设我们有一个弹窗组件树:
- 最上层是
Modal.vue(父组件,包含关闭按钮和内容插槽) - 中间层是
ModalContent.vue容器) - 最下层是
ModalForm.vue(表单组件,提交成功后需要关闭弹窗)
这时候就可以在Modal.vue里provide一个closeModal方法,ModalForm.vue inject后调用就行:
<!-- src/components/Modal.vue -->
<script setup lang="ts">
import { provide } from 'vue';
interface Props {
visible: boolean;
}
const props = defineProps<Props>();
interface Emits {
(e: 'update:visible', value: boolean): void;
}
const emit = defineEmits<Emits>();
// provide关闭方法
const closeModal = () => {
emit('update:visible', false);
};
provide('closeModal', closeModal);
</script>
<template>
<div v-if="visible" class="modal-mask">
<div class="modal-content">
<button class="modal-close" @click="closeModal">×</button>
<slot />
</div>
</div>
</template>
<!-- src/components/ModalForm.vue -->
<script setup lang="ts">
import { inject } from 'vue';
// inject关闭方法
const closeModal = inject<() => void>('closeModal');
if (!closeModal) {
throw new Error('ModalForm必须放在Modal内部使用');
}
const submitForm = () => {
// 假装提交表单成功
console.log('表单提交成功');
// 调用父组件的关闭方法
closeModal();
};
</script>
<template>
<div class="modal-form">
<!-- 省略表单内容 -->
<button @click="submitForm">提交</button>
</div>
</template>
你看,这样就实现了跨三层的方法调用,是不是比层层传递emits方便多了?而且比mitt更精准,因为只有Modal的子组件能拿到closeModal方法。
除了mitt和provide-inject,还有没有其他Vue3全局事件的玩法?
当然有,
- Pinia/Vuex的actions触发:如果你的项目已经用了Pinia/Vuex,而且通知事件和状态管理有关,退出登录后清空购物车+跳转首页”,可以直接在Pinia的actions里触发,同时更新状态——这种情况下没必要再用mitt,避免重复造轮子。
- window/postMessage:如果是跨iframe的通信,或者跨浏览器标签页的通信,mitt和provide-inject都没用,必须用window/postMessage或者BroadcastChannel API——Broadcast Channel API更简单,适合同源标签页的通信。
- VueUse的useEventBus:如果你项目里已经用了VueUse(强烈推荐用,里面有很多实用的composable),可以直接用useEventBus,它其实就是基于mitt封装的,和我们刚才自己封装的useMittEvent差不多,不过VueUse的更完善,支持插件、持久化等功能。
最后给你一个Vue3全局组件通信的“决策树”,帮你快速选方案
说了这么多,可能你还是有点晕,没关系,我给你整理了一个简单的决策树,下次遇到跨组件通信的问题,直接照着选就行:
- 首先看组件之间的关系:
- 如果是父子组件:直接用props/emits,简单清晰。
- 如果是兄弟组件:先看有没有共同的父组件,如果有,用“共同父组件provide响应式数据+子组件inject修改/监听”或者“共同父组件做中转,子组件分别emit/props接收”;如果没有共同的父组件(或者共同父组件层级太高),再看下面的条件。
- 如果是跨多层级组件/跨路由组件:看下面的条件。
- 然后看:
- 如果是简单的通知类/触发类(比如显示通知、关闭弹窗、刷新列表):用mitt(或者VueUse的useEventBus)。
- 如果是需要同步的响应式数据(比如筛选条件、用户信息的部分字段):用provide-inject响应式数据。
- 如果是复杂的状态管理(比如购物车、登录状态、主题设置):用Pinia(Vuex5已经合并到Pinia了,别再用Vuex了)。
- 最后看特殊场景:
- 如果是跨iframe通信:用window/postMessage。
- 如果是同源跨标签页通信:用Broadcast Channel API。
好啦,今天关于Vue3全局事件的内容就讲到这里啦——从为什么删了$bus,到mitt的封装和使用,再到provide+emits的组合拳,还有决策树帮你快速选方案,应该能解决你所有的问题了,如果你还有其他Vue3的问题,欢迎在评论区留言哦!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


