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

或者

terry 1小时前 阅读数 27 #Vue

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 };
};

这个封装的核心亮点有三个:

  1. TypeScript类型支持:AppEvents里定义了所有可能的事件名和对应的参数类型,在VSCode里写的时候会自动提示,根本不会写错事件名,也不会传错参数,这在Vue2里是很难做到的。
  2. 自动销毁监听:在on函数里直接调用onUnmounted注册销毁逻辑,不管你在哪里用这个on,只要组件销毁了,监听就会自动被移除,新手也不用怕内存泄漏了。
  3. 全局唯一实例+统一前缀:前缀用了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+响应式数据的好处是:

  1. 数据流向明确:只有FilterContainer的子组件能拿到数据,不会影响其他无关的组件。
  2. 天然响应式:不需要手动发送事件和监听,直接用watch或者computed就能同步数据变化。
  3. 可维护性强:所有筛选逻辑都集中在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全局事件的玩法?

当然有,

  1. Pinia/Vuex的actions触发:如果你的项目已经用了Pinia/Vuex,而且通知事件和状态管理有关,退出登录后清空购物车+跳转首页”,可以直接在Pinia的actions里触发,同时更新状态——这种情况下没必要再用mitt,避免重复造轮子。
  2. window/postMessage:如果是跨iframe的通信,或者跨浏览器标签页的通信,mitt和provide-inject都没用,必须用window/postMessage或者BroadcastChannel API——Broadcast Channel API更简单,适合同源标签页的通信。
  3. VueUse的useEventBus:如果你项目里已经用了VueUse(强烈推荐用,里面有很多实用的composable),可以直接用useEventBus,它其实就是基于mitt封装的,和我们刚才自己封装的useMittEvent差不多,不过VueUse的更完善,支持插件、持久化等功能。

最后给你一个Vue3全局组件通信的“决策树”,帮你快速选方案

说了这么多,可能你还是有点晕,没关系,我给你整理了一个简单的决策树,下次遇到跨组件通信的问题,直接照着选就行:

  1. 首先看组件之间的关系
    • 如果是父子组件:直接用props/emits,简单清晰。
    • 如果是兄弟组件:先看有没有共同的父组件,如果有,用“共同父组件provide响应式数据+子组件inject修改/监听”或者“共同父组件做中转,子组件分别emit/props接收”;如果没有共同的父组件(或者共同父组件层级太高),再看下面的条件。
    • 如果是跨多层级组件/跨路由组件:看下面的条件。
  2. 然后看:
    • 如果是简单的通知类/触发类(比如显示通知、关闭弹窗、刷新列表):用mitt(或者VueUse的useEventBus)
    • 如果是需要同步的响应式数据(比如筛选条件、用户信息的部分字段):用provide-inject响应式数据
    • 如果是复杂的状态管理(比如购物车、登录状态、主题设置):用Pinia(Vuex5已经合并到Pinia了,别再用Vuex了)。
  3. 最后看特殊场景
    • 如果是跨iframe通信:用window/postMessage
    • 如果是同源跨标签页通信:用Broadcast Channel API

好啦,今天关于Vue3全局事件的内容就讲到这里啦——从为什么删了$bus,到mitt的封装和使用,再到provide+emits的组合拳,还有决策树帮你快速选方案,应该能解决你所有的问题了,如果你还有其他Vue3的问题,欢迎在评论区留言哦!

版权声明

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

热门