一、什么是事件总线
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) 就行。