Vue.js 3 Event Bus

Vue.js 3 Event Bus

技术背景

在 Vue 2 中,创建事件总线(Event Bus)很简单,只需创建一个 Vue 实例,然后使用 $on$emit 方法来监听和触发事件。但在 Vue 3 中,Vue 不再是一个构造函数,Vue.createApp({}) 返回的对象也没有 $on$emit 方法,因此需要新的方式来实现事件总线。

实现步骤

使用 mitt 库

  1. 安装 mitt
1
npm install --save mitt
  1. 在 main.js 中创建并定义全局属性
1
2
3
4
5
6
7
import { createApp } from 'vue'
import App from './App.vue'
import mitt from 'mitt';
const emitter = mitt();
const app = createApp(App);
app.config.globalProperties.emitter = emitter;
app.mount('#app');
  1. 在组件中使用
    • 在 header 组件中触发事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<header>
<button @click="toggleSidebar">toggle</button>
</header>
</template>
<script>
export default {
data() {
return {
sidebarOpen: true
};
},
methods: {
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
this.emitter.emit("toggle-sidebar", this.sidebarOpen);
}
}
};
</script>
- **在 sidebar 组件中接收事件**:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<aside class="sidebar" :class="{'sidebar--toggled': !isOpen}">
....
</aside>
</template>
<script>
export default {
name: "sidebar",
data() {
return {
isOpen: true
};
},
mounted() {
this.emitter.on("toggle-sidebar", isOpen => {
this.isOpen = isOpen;
});
}
};
</script>
  1. 使用组合式 API
    • 创建 src/composables/useEmitter.js
1
2
3
4
5
6
7
8
import { getCurrentInstance } from 'vue'

export default function useEmitter() {
const internalInstance = getCurrentInstance();
const emitter = internalInstance.appContext.config.globalProperties.emitter;

return emitter;
}
- **在组件中使用**:
1
2
3
4
5
6
7
8
9
import useEmitter from '@/composables/useEmitter'

export default {
setup() {
const emitter = useEmitter()
...
}
...
}

自定义事件类

  1. 创建 event.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Event{
constructor(){
this.events = {};
}

on(eventName, fn) {
this.events[eventName] = this.events[eventName] || [];
this.events[eventName].push(fn);
}

off(eventName, fn) {
if (this.events[eventName]) {
for (var i = 0; i < this.events[eventName].length; i++) {
if (this.events[eventName][i] === fn) {
this.events[eventName].splice(i, 1);
break;
}
};
}
}

trigger(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(function(fn) {
fn(data);
});
}
}
}

export default new Event();
  1. 在 index.js 中使用
1
2
3
4
5
import Vue from 'vue';
import $bus from '.../event.js';

const app = Vue.createApp({})
app.config.globalProperties.$bus = $bus;

使用 EventTarget

  1. 创建 EventBus 类文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class EventBusEvent extends Event {
public data: any

constructor({type, data} : {type: string, data: any}) {
super(type)
this.data = data
}
}

class EventBus extends EventTarget {
private static _instance: EventBus

public static getInstance() : EventBus {
if (!this._instance) this._instance = new EventBus()
return this._instance
}

public emit(type : string, data?: any) : void {
this.dispatchEvent(new EventBusEvent({type, data}))
}
}

export default EventBus.getInstance()
  1. 在项目中使用
    • 触发事件
1
2
3
import EventBus from '...path to eventbus file with class'
//...bla bla bla... code...
EventBus.emit('event type', {..some data..})
- **监听事件**:
1
2
3
import EventBus from '...path to eventbus file with class' 
//...bla bla bla... code...
EventBus.addEventListener('event type', (event) => { console.log(event.data) })

使用 VueUse 的 useEventBus

  1. 定义注入键
1
2
3
//myInjectionKey.ts
import type { EventBusKey } from '@vueuse/core'
export const myInjectionKey: EventBusKey<string> = Symbol('my-injection-key')
  1. 触发事件
1
2
3
4
5
import { useEventBus } from '@vueuse/core'
import { myInjectionKey } from "src/config/myInjectionKey";

const bus = useEventBus(mapInjectionKey)
bus.emit("Hello")
  1. 接收事件
1
2
3
4
5
6
import { useEventBus } from '@vueuse/core'
import { myInjectionKey } from "src/config/myInjectionKey";
const bus = useEventBus(myInjectionKey)
bus.on((e) => {
console.log(e) // "Hello"
})

自定义事件总线类

  1. 创建 event-bus.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/**
* Replacement for the Vue 2-based EventBus.
*
* @template EventName
*/
class Bus {
constructor() {
/**
* @type {Map<EventName, Array<{ callback: Function, once: boolean }>>}
*/
this.eventListeners = new Map()
}

/**
* @param {EventName} eventName
* @param {Function} callback
* @param {boolean} [once]
* @private
*/
registerEventListener(eventName, callback, once = false) {
if (!this.eventListeners.has(eventName)) {
this.eventListeners.set(eventName, [])
}

const eventListeners = this.eventListeners.get(eventName)
eventListeners.push({ callback, once })
}

/**
* See: https://v2.vuejs.org/v2/api/#vm-on
*
* @param {EventName} eventName
* @param {Function} callback
*/
$on(eventName, callback) {
this.registerEventListener(eventName, callback)
}

/**
* See: https://v2.vuejs.org/v2/api/#vm-once
*
* @param {EventName} eventName
* @param {Function} callback
*/
$once(eventName, callback) {
const once = true
this.registerEventListener(eventName, callback, once)
}

/**
* Removes all event listeners for the given event name or names.
*
* When provided with a callback function, removes only event listeners matching the provided function.
*
* See: https://v2.vuejs.org/v2/api/#vm-off
*
* @param {EventName | EventName[]} eventNameOrNames
* @param {Function} [callback]
*/
$off(eventNameOrNames, callback = undefined) {
const eventNames = Array.isArray(eventNameOrNames) ? eventNameOrNames : [eventNameOrNames]

for (const eventName of eventNames) {
const eventListeners = this.eventListeners.get(eventName)

if (eventListeners === undefined) {
continue
}

if (typeof callback === 'function') {
for (let i = eventListeners.length - 1; i >= 0; i--) {
if (eventListeners[i].callback === callback) {
eventListeners.splice(i, 1)
}
}
} else {
this.eventListeners.delete(eventName)
}
}
}

/**
* See: https://v2.vuejs.org/v2/api/#vm-emit
*
* @param {EventName} eventName
* @param {any} args
*/
$emit(eventName, ...args) {
if (!this.eventListeners.has(eventName)) {
return
}

const eventListeners = this.eventListeners.get(eventName)
const eventListenerIndexesToDelete = []
for (const [eventListenerIndex, eventListener] of eventListeners.entries()) {
eventListener.callback(...args)

if (eventListener.once) {
eventListenerIndexesToDelete.push(eventListenerIndex)
}
}

for (let i = eventListenerIndexesToDelete.length - 1; i >= 0; i--) {
eventListeners.splice(eventListenerIndexesToDelete[i], 1)
}
}
}

const EventBus = new Bus()

export default EventBus
  1. 在项目中使用
1
2
// import EventBus from './old-event-bus.js'
import EventBus from './event-bus.js'

基于组合式 API 的改进方案

  1. 创建 useEventBus.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ref, watch } from 'vue';

const bus = ref(new Map());

export function useEventsBus() {
const emit = (event, props) => {
const currentValue = bus.value.get(event);
const counter = currentValue ? ++currentValue[1] : 1;
bus.value.set(event, [props, counter]);
};

const on = (event, callback) => {
watch(() => bus.value.get(event), (val) => {
callback(val[0]);
});
};

return {
emit,
on,
bus,
};
}
  1. 在组件中使用
    • 发布者组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
import { useEventsBus } from '~/composables/useEventsBus';
</script>

<template>
<Button
@click="useEventsBus().emit('btn-clicked', 'Hello there')"
>
Button with payload
</Button>
<Button
@click="useEventsBus().emit('btn-another-clicked')"
>
Button without payload
</Button>
</template>
- **订阅者组件**:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { useEventsBus } from '~/composables/useEventsBus';

useEventsBus().on('btn-clicked', (payload) => {
console.log(payload); // 'Hello there'
});

useEventsBus().on('btn-another-clicked', (payload) => {
console.log(payload); // undefined
})
// you can subscribe on the event several times
useEventsBus().on('btn-another-clicked', (payload) => {
console.log(payload); // undefined
})
</script>

使用 JavaScript 自定义事件

  1. 在源组件中触发事件
1
2
3
4
5
6
7
8
9
10
<script setup>
function showMyCustomModal() {
const showModal = new CustomEvent('modal::show', {
// for hiding, send `modal::hide`, just make sure
// the component is currently mounted and it can listen to this event
detail: 'my-custom-modal',
})
window.dispatchEvent(showModal);
}
</script>
  1. 在模态框组件中监听事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup>
// define function for handling show modal event
function handleModalShowEvent(event) {
if (event.detail === props.id) {
show();
}
}

// define another for handling hide modal
function handleModalHideEvent(event) {
if (event.detail === props.id) {
hide();
}
}

onMounted(() => {
// on mounted, listen to the events
window.addEventListener('modal::show', handleModalShowEvent);
window.addEventListener('modal::hide', handleModalHideEvent);
})

onUnmounted(() => {
// on unmounted, remove them:
window.removeEventListener('modal::show', handleModalShowEvent);
window.removeEventListener('modal::hide', handleModalHideEvent);
})
</script>

使用 vue-eventer 库

  1. 安装 vue-eventer
1
npm install vue-eventer
  1. 在项目中使用
1
2
3
// Vue 3.x
import VueEventer from 'vue-eventer';
YourVueApp.config.globalProperties.$eventBus = new VueEventer();

使用简单的 mitt 封装

  1. 创建 EventBus.js
1
2
3
4
5
6
7
8
9
import mitt from 'mitt'

const emitter = mitt()
export default {
$on: (...args) => emitter.on(...args),
$once: (...args) => emitter.once(...args),
$off: (...args) => emitter.off(...args),
$emit: (...args) => emitter.emit(...args)
}
  1. 在项目中使用
    • 触发事件
1
2
3
4
import EventBus from "../../EventBus"; // path to EventBus.js

// ... code ...
EventBus.$emit('event name',{'some': 'data'});
- **监听事件**:
1
2
3
4
5
6
import EventBus from "../../EventBus"; // path to EventBus.js

// ... code ...
EventBus.$on('event name', (data) => {
console.log('Event emitted', data);
});

最佳实践

  • 选择合适的方案:根据项目的规模和需求选择合适的事件总线实现方式。对于小型项目,简单的自定义事件类或 mitt 库可能就足够了;对于大型项目,使用成熟的库如 VueUse 的 useEventBus 可能更合适。
  • 避免滥用:事件总线虽然方便,但过度使用会导致代码难以维护。尽量使用组件间的 props 和 emits 进行通信,只有在确实需要跨组件通信时才使用事件总线。
  • 注意事件命名:使用有意义的事件名,避免命名冲突。

常见问题

  • 类型问题:在 TypeScript 项目中,可能需要添加类型声明文件来确保类型检查正常工作。
  • SSR 问题:在使用组合式 API 实现事件总线时,要注意 SSR 环境下的状态共享问题,可以将事件总线实现为插件并通过组合式函数暴露。
  • 内存泄漏:确保在组件销毁时移除事件监听器,避免内存泄漏。