Vue 全局事件总线详解

一、什么是事件总线

1.1 定义

事件总线 (Event Bus)本质上就是一个居中转发消息的"邮局":发送方不直接找接收方,而是把消息丢给总线,总线再帮转给所有订阅了这个消息的人。

在 Vue 里,它用来解决任意两个组件之间通信的问题,不限于父子,不限于兄弟,只要挂在同一条总线上就能互相收发。

1.2 生活类比

把事件总线想象成广播电台:

  • 你想听某个节目 → 先去电台登记订阅(订阅事件)
  • 主持人想播报 → 把内容交给电台 ($emit)
  • 电台把内容推给所有登记过的人

主持人和听众互相不认识 ,也不需要知道对方在哪,只跟电台打交道。这就是事件总线的核心思想:解耦发送方和接收方。

1.3 为什么要用

Vue 组件通信的"原生"方式有局限:

通信方式 限制
props / emits 只能父子,跨层级要一层层透传(Prop Drilling)
$refs 父直接操作子,反向不行,兄弟也不行
provide/inject 父给后代单向,后代回父不直接
Vuex / Pinia 适合管全局状态,小事件通知有点重

事件总线就填了这些空缺:任意两个组件都能对话,写法还特别轻 。适合做"开关侧边栏"、"切换主题"、"用户登出广播"这种一次性通知类需求。


二、Vue 2 的实现:用 Vue 实例当总线

2.1 核心思路

Vue 2 里,每个 Vue 实例都自带事件接口 $on / $emit / $off / $once。所以新建一个空的 Vue 实例当总线就行,谁需要谁引入。

javascript 复制代码
// src/eventBus.js
import Vue from "vue";

const bus = new Vue();   // ← 一个空实例,啥都没有,但有事件接口
export default bus;

2.2 基本用法:订阅 + 发布

javascript 复制代码
// 组件A:订阅"login"事件
import bus from "@/eventBus";

export default {
    created() {
        bus.$on("login", (user) => {
            console.log("收到登录:", user);  // ← 任何组件 emit 都能收到
        });
    },
    beforeDestroy() {
        bus.$off("login");   // ← 必须解绑,否则内存泄漏
    }
}
javascript 复制代码
// 组件B:发布"login"事件
import bus from "@/eventBus";

export default {
    methods: {
        doLogin() {
            bus.$emit("login", { name: "张三" });   // ← 不用管谁在听
        }
    }
}

A 和 B 可能隔着十层组件,可能根本不是父子,但只要都引入了 bus,消息就能传过去。

2.3 四个 API 速查

方法 作用 用法
$on(event, fn) 订阅事件 bus.$on("hi", fn)
$emit(event, ...args) 发布事件,可带参数 bus.$emit("hi", 1, 2)
$off(event, fn) 解绑;不传 fn 解绑该事件全部 bus.$off("hi") 或 bus.$off("hi", fn)
$once(event, fn) 订阅一次,触发后自动解绑 bus.$once("init", fn)

三、Vue 3 的实现:mitt 或手写

3.1 为什么 Vue 2 的写法不行了

Vue 3 移除了实例上的 $on / $off / $once (因为想跟组件实例解耦,推荐用 props/emits 显式通信)。所以 new Vue() 当总线这条路彻底断了,需要换工具。

3.2 方案一:用 mitt(推荐)

mitt 是一个超轻量的事件总线库,~200 字节,API 和 Vue 2 的几乎一样。

bash 复制代码
npm install mitt
javascript 复制代码
// src/eventBus.js
import mitt from "mitt";

const bus = mitt();   // ← 创建总线
export default bus;
javascript 复制代码
// 组件A:订阅
import bus from "@/eventBus";
import { onMounted, onBeforeUnmount } from "vue";

export default {
    setup() {
        const onLogin = (user) => console.log("登录:", user);

        onMounted(() => bus.on("login", onLogin));
        onBeforeUnmount(() => bus.off("login", onLogin));

        return {};
    }
}
javascript 复制代码
// 组件B:发布
import bus from "@/eventBus";
export default {
    setup() {
        const doLogin = () => bus.emit("login", { name: "张三" });
        return { doLogin };
    }
}

mitt 的 API 对照:

mitt 方法 Vue 2 对应 备注
on(type, handler) $on 完全对应
emit(type, data) $emit 只能传一个 data,不能像 $emit 多参
off(type, handler) $off 同样支持只传 type 解绑全部
clear() 无 清空所有订阅

3.3 方案二:手写一个 EventEmitter

不想引入依赖,自己写也很短:

javascript 复制代码
// src/eventBus.js
class EventBus {
    constructor() {
        this.map = new Map();   // ← { type: [fn1, fn2, ...] }
    }
    on(type, fn) {
        if (!this.map.has(type)) this.map.set(type, []);
        this.map.get(type).push(fn);
    }
    emit(type, ...args) {
        const fns = this.map.get(type);
        if (!fns) return;
        fns.forEach(fn => fn(...args));
    }
    off(type, fn) {
        if (!fn) { this.map.delete(type); return; }   // ← 不传 fn,清空整个事件
        const fns = this.map.get(type);
        if (fns) this.map.set(type, fns.filter(f => f !== fn));
    }
    clear() {
        this.map.clear();
    }
}

export default new EventBus();

用法和 mitt 一模一样。手写版的好处是能看清原理 ,坏处是要自己保证 this 指向、处理 once 等边缘情况。

3.4 注解

1.mitt 的 emit 只能带一个参数

Vue 2 里可以 bus.$emit("hi", 1, 2, 3),mitt 只能 bus.emit("hi", { a:1, b:2 }),多个参数要打包成对象。这是设计差异,不是 bug。

2.为什么 Vue 3 推荐显式通信却还保留 mitt

显式通信(props/emits/provide)在父子链路上更清晰,但跨层级、跨业务模块时,层层透传成本太高。mitt 是为了这类"广播型"场景留的口子,不是替代 props。


四、实战案例:全局侧边栏开关

需求:一个按钮(在任何位置)点击后,打开/关闭侧边栏(也在任何位置)。

4.1 总线文件

javascript 复制代码
// src/eventBus.js
import mitt from "mitt";
export default mitt();

4.2 侧边栏组件:监听开关

vue 复制代码
<!-- SideBar.vue -->
<template>
    <aside v-show="visible">侧边栏内容</aside>
</template>

<script setup>
import bus from "@/eventBus";
import { ref, onMounted, onBeforeUnmount } from "vue";

const visible = ref(false);
const toggle = (v) => (visible.value = v);   // ← 收到指令就改

onMounted(() => bus.on("sidebar:toggle", toggle));
onBeforeUnmount(() => bus.off("sidebar:toggle", toggle));
</script>

4.3 触发按钮:发广播

vue 复制代码
<!-- MenuButton.vue -->
<template>
    <button @click="open">打开侧边栏</button>
</template>

<script setup>
import bus from "@/eventBus";

const open = () => bus.emit("sidebar:toggle", true);   // ← 不关心谁在听
</script>

按钮和侧边栏没有任何 props 约定 ,谁也不依赖谁。换一个按钮、加一个开关,只要 emit("sidebar:toggle", x) 就行。


相关推荐
卸任9 小时前
Vibe coding工具使用心得
前端·ai编程
brien99 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance10 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M10 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写10 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李10 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易10 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding10 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马10 小时前
前沿视界
前端
kyriewen10 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员