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

或者用yarn

terry 3小时前 阅读数 44 #Vue

Vue3里没有了$on和$emit全局方法?跨组件通信到底该怎么选Bus还是Pinia?

可能刚从Vue2转Vue3的开发者,打开项目第一件事就懵了:想写个简单的父子兄弟远亲组件的消息广播?翻遍Vue3文档,居然找不到原来挂在Vue原型上的$on、$off、$once这些全局事件总线方法,连官方文档都直接说“不再推荐使用事件总线模式”,那是不是Bus彻底不能用了?Pinia是不是所有场景都能打?今天咱们就把这些问题掰扯清楚。

为什么Vue3官方要放弃推荐全局Bus?

先别急着找替代方案,得先明白为啥官方会改,其实在Vue2后期,官方就一直在暗示事件总线别瞎用了,只是没彻底拿掉原型方法给大家留了过渡空间,Vue3直接砍了默认支持,背后有几个很实在的技术和工程原因:

第一个是调试难哭了,原来的全局Bus,谁都能$emit、谁都能$on,哪天突然某个组件发了个“update-user”的消息,把你刚填的表单冲没了,你打开Vue DevTools根本找不到触发链路——不像组件内或父子之间的$emit,有清晰的调用栈和父组件绑定记录,如果项目稍微大一点,几十上百个组件订阅同一个全局事件,排查问题简直是大海捞针。

第二个是类型安全没保障,Vue3现在主推TypeScript,类型提示能帮我们少写很多bug,但原来的事件总线,消息名称、参数类型都是完全自由的字符串或者any,比如你在组件A里写bus.emit('change-mode', 'dark'),参数是个字符串;但组件B可能记错了,写const mode = ref('light'); bus.on('change-mode', (newMode: number) => mode.value = newMode),这种类型不匹配的错误,编译器根本不会提示,得等到运行时才会发现mode变成了NaN,调试成本太高。

第三个是容易造成内存泄漏,Vue2里用Bus,很多新手开发者会忘了在组件销毁时$off对应的事件,如果组件是动态创建销毁的(比如弹窗、tab切换内容),订阅的事件就会一直留在Bus的事件队列里,每次触发都会执行多次,时间长了页面会越来越卡,甚至崩溃,虽然Vue3如果用手动实现的Bus,也可以通过组合式API的onUnmounted自动解绑,但总不如Pinia这种有状态管理、自动追踪依赖的工具省心。

Vue3真的完全不能用Bus了吗?怎么手动实现一个安全的Bus?

当然不是完全不能用,官方只是不推荐,但如果是小型项目、或者临时需要两个完全独立的、没有任何共享状态的组件之间传个一次性的小消息(比如点击侧边栏菜单触发主页面的一个动画,不需要传数据也不需要修改状态),用Bus反而比引入Pinia写一堆state、actions更轻量,那怎么手动实现一个符合Vue3特性、带类型提示、还能自动解绑的Bus呢?

咱们可以利用Vue3自带的mitt库来简化实现——mitt是一个只有200字节左右的微型事件发射器,完全符合Vue3对轻量工具的要求,而且本身就支持TypeScript类型定义,很多人不知道的是,Vue3早期的文档里其实还提过mitt作为事件总线的替代方案,只是后来觉得不如Pinia全面就删掉了,但mitt本身还是非常好用的。

我们需要在项目里安装mitt:

npm install mittyarn add mitt

我们可以在src目录下新建一个utils文件夹,里面建一个bus.ts文件(如果是JavaScript项目就建bus.js),来封装我们的Bus:

TypeScript版本的安全Bus

import mitt from 'mitt';
import { onUnmounted } from 'vue';
// 定义事件类型接口,限制消息名称和参数类型
type Events = {
  // 消息名是字符串,参数可以是任意类型,也可以指定具体类型
  'toggle-sidebar': boolean; // 侧边栏切换,参数是是否展开
  'show-notification': { title: string; message: string; type: 'success' | 'error' | 'warning' }; // 通知消息
  // 其他事件类型可以在这里添加
  [key: string]: any; // 临时加的兜底,避免偶尔需要传自定义any的场景,但尽量少用
};
// 创建mitt实例
const emitter = mitt<Events>();
// 封装一个带自动解绑的useBus钩子
export function useBus() {
  // 订阅事件,返回一个解绑函数
  const on = <K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) => {
    emitter.on(event, handler);
    // 组件销毁时自动解绑
    onUnmounted(() => {
      emitter.off(event, handler);
    });
  };
  // 发布事件
  const emit = <K extends keyof Events>(event: K, payload: Events[K]) => {
    emitter.emit(event, payload);
  };
  // 解绑所有事件(一般用不到,除非是整个应用重置)
  const offAll = () => {
    emitter.all.clear();
  };
  return { on, emit, offAll };
}

JavaScript版本的简化Bus

如果你的项目还没用到TypeScript,也可以简化一下,只保留自动解绑的功能:

import mitt from 'mitt';
import { onUnmounted } from 'vue';
const emitter = mitt();
export function useBus() {
  const on = (event, handler) => {
    emitter.on(event, handler);
    onUnmounted(() => {
      emitter.off(event, handler);
    });
  };
  const emit = (event, payload) => {
    emitter.emit(event, payload);
  };
  const offAll = () => {
    emitter.all.clear();
  };
  return { on, emit, offAll };
}

怎么使用这个封装好的Bus?

比如我们有一个Header组件,里面有个侧边栏切换按钮,点击后要通知Main组件侧边栏的状态,同时通知Notification组件显示一条“侧边栏已展开/收起”的消息:

Header组件(发布者)

<template>
  <header class="header">
    <button @click="toggleSidebar">
      {{ sidebarCollapsed ? '展开侧边栏' : '收起侧边栏' }}
    </button>
  </header>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useBus } from '@/utils/bus';
const { emit } = useBus();
const sidebarCollapsed = ref(false);
const toggleSidebar = () => {
  sidebarCollapsed.value = !sidebarCollapsed.value;
  // 发布切换侧边栏的事件
  emit('toggle-sidebar', !sidebarCollapsed.value);
  // 发布通知消息事件
  emit('show-notification', { '操作成功',
    message: sidebarCollapsed.value ? '侧边栏已收起' : '侧边栏已展开',
    type: 'success'
  });
};
</script>

Main组件和Notification组件(订阅者)

<!-- Main.vue -->
<template>
  <main class="main" :class="{ 'sidebar-collapsed': !sidebarExpanded }">
    <!-- 主内容 -->
  </main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useBus } from '@/utils/bus';
const { on } = useBus();
const sidebarExpanded = ref(true);
// 订阅切换侧边栏的事件
on('toggle-sidebar', (expanded) => {
  sidebarExpanded.value = expanded;
});
</script>
<!-- Notification.vue -->
<template>
  <div v-if="visible" class="notification" :class="notification.type">
    <h3>{{ notification.title }}</h3>
    <p>{{ notification.message }}</p>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useBus } from '@/utils/bus';
const { on } = useBus();
const visible = ref(false);
const notification = ref({ title: '', message: '', type: 'success' });
// 订阅通知消息事件
on('show-notification', (data) => {
  notification.value = data;
  visible.value = true;
  // 3秒后自动隐藏
  setTimeout(() => {
    visible.value = false;
  }, 3000);
});
</script>

你看,这样用起来是不是和Vue2的全局Bus差不多?但多了TypeScript的类型提示,还不用手动在onUnmounted里解绑事件,安全性提高了不少。

什么时候用Bus?什么时候必须用Pinia?

现在我们知道了怎么实现一个安全的Bus,但核心问题还是没解决:到底什么时候用Bus,什么时候用Pinia?其实很简单,你可以从这三个维度来判断:

第一个维度:是否需要共享状态?

如果两个组件之间只是需要“通知一下对方做某件事”,不需要传递需要持久保存的数据,也不需要让多个组件同时知道某个数据的变化,那用Bus就够了,比如刚才的例子里,点击按钮触发动画、显示一条自动消失的通知,这些都是“一次性的动作”,不需要把通知的内容或者侧边栏的状态(如果侧边栏的状态只在Header和Main之间用,而且不需要刷新页面后保留)存起来。

但如果两个或者多个组件需要共享同一个数据,而且这个数据可能会被多个组件修改,或者刷新页面后需要保留,那必须用Pinia,比如用户的登录状态、购物车的商品列表、全局的主题色设置,这些都是“共享状态”,用Bus的话很难管理:购物车加个商品,你得发消息通知所有显示购物车的组件更新,刷新页面后数据还会丢失;而Pinia有state、getters、actions三个核心概念,还支持持久化插件,管理起来非常方便。

第二个维度:组件之间的距离有多远?

如果两个组件是父子关系,那直接用props和emit就行了,不用Bus也不用Pinia;如果是爷孙关系,可以用provide和inject;如果是堂兄弟或者更远的关系,而且只是偶尔传个一次性消息,可以用Bus;但如果是经常需要传数据、或者数据被多个远亲组件同时使用,还是用Pinia更清晰。

第三个维度:项目的规模有多大?

如果是个人的小项目、或者临时做的demo,只有几个组件,那用Bus完全没问题,轻量又快捷;但如果是团队开发的大型项目,有几十个上百个组件,那必须用Pinia——大型项目讲究代码的可维护性和可扩展性,Pinia的状态管理有清晰的结构,团队成员一看就知道哪些状态是全局的、哪些是局部的,哪个状态是由哪个actions修改的,调试起来也比Bus方便很多,Vue DevTools里还能直接看到Pinia的state变化历史、时间线,甚至可以回到之前的某个状态重新运行,对调试太友好了。

Bus和Pinia不是对手,是搭档

最后再强调一遍:Vue3官方只是不推荐滥用全局Bus,并没有彻底禁止使用,Bus和Pinia不是非此即彼的对手,而是可以搭配使用的搭档:

  • 用Pinia管理所有需要共享的、持久的、被多个组件使用的状态;
  • 用封装好的、带自动解绑和类型提示的mitt Bus,处理那些偶尔需要的、一次性的、不需要共享状态的跨组件动作通知。

这样搭配使用,既能保证项目的可维护性和可扩展性,又能在一些小场景下保持代码的轻量和简洁,才是Vue3跨组件通信的最佳实践。

版权声明

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

热门