Vue3 元件通信:使用 mitt.js 建立事件總線
簡介
在大型 Vue3 專案中,元件之間的資料傳遞往往不只局限於父子關係,跨層級、平行或完全不相關的元件 也需要互相溝通。若僅靠 props、emit、provide/inject 或 Vuex/Pinia,會讓程式碼變得繁瑣且難以維護。此時,事件總線(Event Bus) 成為一個輕量且直觀的解決方案。
mitt 是一個只有 200 行左右的微型事件庫(≈ 200 B),它不依賴 Vue 本身,卻能在任何 JavaScript 環境中提供 publish / subscribe(發布/訂閱)機制。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹在 Vue3 中如何使用 mitt.js 來達成元件通信。
核心概念
1. 什麼是事件總線?
事件總線是一個 全域的事件發佈/訂閱中心,元件可以:
emit(event, payload):發送(發佈)一個事件,攜帶任意資料。on(event, handler):訂閱(監聽)特定事件,當事件被觸發時執行回呼函式。off(event, handler?):取消訂閱,若未提供handler,則移除該事件的所有監聽器。
這樣的模式讓 任意兩個元件 能夠在不認識彼此的情況下直接交流。
2. 為什麼選擇 mitt 而不是其他方案?
| 特性 | mitt | Vuex / Pinia | provide/inject |
|---|---|---|---|
| 大小 | 200 B | 10+ KB | 內建於 Vue,無額外套件 |
| 學習曲線 | 低 | 中 | 中 |
| 跨框架 | ✅(純 JS) | ❌(Vue 專屬) | ❌(只能在 Vue 內) |
| 靈活度 | 高(任意事件) | 受限於 Store 結構 | 僅限於祖先子代關係 |
| 適用情境 | 輕量事件、非狀態管理 | 全域狀態 | 父子傳值 |
若你只需要 簡單的訊息傳遞,mitt 的足夠且不會為專案帶來額外負擔。
3. 在 Vue3 中整合 mitt
在 Vue3 中,我們通常會在 src/plugins 或 src/utils 目錄下建立一個 mitt 實例,並將它掛載到全域屬性 app.config.globalProperties,讓所有元件都能透過 this.$bus 直接使用。
// src/plugins/eventBus.js
import mitt from 'mitt';
const emitter = mitt();
export default {
install: (app) => {
// 在 Vue 實例上掛載 $bus,供所有組件使用
app.config.globalProperties.$bus = emitter;
},
};
在 main.js 中註冊:
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import eventBus from '@/plugins/eventBus';
const app = createApp(App);
app.use(eventBus);
app.mount('#app');
從此以後,任何組件都可以透過 this.$bus.emit(...) 或 this.$bus.on(...) 進行通信。
程式碼範例
以下示範 5 個常見且實用的 mitt 用法,每段程式碼均附有說明註解。
範例 1:跨層級的「通知」訊息
情境:一個深層子元件需要在資料更新後,通知位於根元件的側邊欄重新抓取資料。
// ChildComponent.vue
export default {
name: 'ChildComponent',
methods: {
updateData() {
// 假設已完成資料更新的 API 呼叫
this.$bus.emit('data-updated', { id: 42, status: 'done' });
},
},
};
// Sidebar.vue(根層元件)
export default {
name: 'Sidebar',
created() {
// 訂閱 data-updated 事件
this.$bus.on('data-updated', this.handleDataUpdated);
},
beforeUnmount() {
// 清除監聽,防止記憶體洩漏
this.$bus.off('data-updated', this.handleDataUpdated);
},
methods: {
async handleDataUpdated(payload) {
console.log('收到更新訊息', payload);
// 重新拉取最新的清單資料
await this.fetchList();
},
async fetchList() {
// API 取得資料的程式...
},
},
};
重點:在 created 訂閱、beforeUnmount 取消訂閱,確保元件銷毀時不留下多餘的監聽器。
範例 2:使用 一次性 事件(once)
mitt 本身不提供 once 方法,但可自行封裝:
// utils/once.js
export function once(emitter, event, handler) {
const wrapper = (payload) => {
handler(payload);
emitter.off(event, wrapper); // 觸發後自動解除
};
emitter.on(event, wrapper);
}
// Modal.vue(彈窗只需要一次回傳結果)
export default {
name: 'Modal',
mounted() {
// 只在第一次收到 confirm 時執行
once(this.$bus, 'modal-confirm', this.handleConfirm);
},
methods: {
handleConfirm(payload) {
console.log('使用者確認', payload);
this.close();
},
close() {
// 關閉彈窗的程式...
},
},
};
說明:once 讓事件只觸發一次,適用於一次性回饋、確認框等情境。
範例 3:傳遞 多個參數 與 錯誤處理
mitt 的 emit 只接受兩個參數:事件名稱與單一 payload。若需要傳遞多個值,可將它們包成物件或陣列。
// FormSubmit.vue
export default {
name: 'FormSubmit',
methods: {
submitForm() {
const data = { name: this.name, age: this.age };
const meta = { timestamp: Date.now() };
// 包成一個物件傳遞
this.$bus.emit('form-submitted', { data, meta });
},
},
};
// Logger.vue
export default {
name: 'Logger',
created() {
this.$bus.on('form-submitted', this.logForm);
},
beforeUnmount() {
this.$bus.off('form-submitted', this.logForm);
},
methods: {
logForm({ data, meta }) {
try {
console.log('表單資料:', data);
console.log('提交時間:', new Date(meta.timestamp).toLocaleString());
} catch (err) {
console.error('Log 錯誤:', err);
}
},
},
};
要點:將多個參數封裝成單一物件,可保持 mitt 的 API 簡潔,同時方便在接收端做 解構賦值。
範例 4:全局 Loading 狀態(使用 mitt 取代 Vuex)
在一些小型專案中,僅需要一個「全局 loading」指示器,使用 mitt 可以省去建立 Store 的成本。
// LoadingService.js
import mitt from 'mitt';
const emitter = mitt();
export const LoadingBus = {
show() {
emitter.emit('loading', true);
},
hide() {
emitter.emit('loading', false);
},
onChange(callback) {
emitter.on('loading', callback);
},
offChange(callback) {
emitter.off('loading', callback);
},
};
<!-- LoadingOverlay.vue -->
<template>
<div v-if="visible" class="overlay">讀取中…</div>
</template>
<script>
import { LoadingBus } from '@/services/LoadingService';
export default {
name: 'LoadingOverlay',
data() {
return { visible: false };
},
created() {
LoadingBus.onChange(this.toggle);
},
beforeUnmount() {
LoadingBus.offChange(this.toggle);
},
methods: {
toggle(state) {
this.visible = state;
},
},
};
</script>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
</style>
// 任意需要顯示 loading 的元件
import { LoadingBus } from '@/services/LoadingService';
export default {
async fetchData() {
LoadingBus.show();
try {
const res = await fetch('/api/data');
// 處理資料…
} finally {
LoadingBus.hide();
}
},
};
說明:透過 LoadingBus 把「顯示」與「隱藏」的行為封裝成方法,其他元件只需要呼叫 show() / hide(),而不必關心 UI 細節。
範例 5:在 Composition API 中使用 mitt
若你偏好 setup(),也可以直接在 setup 內取得全域 $bus。
// useEventBus.js
import { getCurrentInstance } from 'vue';
export function useEventBus() {
const { appContext } = getCurrentInstance();
return appContext.config.globalProperties.$bus;
}
// Counter.vue
<template>
<button @click="increment">+1</button>
</template>
<script setup>
import { useEventBus } from '@/composables/useEventBus';
const bus = useEventBus();
function increment() {
bus.emit('counter-increment');
}
</script>
// Display.vue
<template>
<div>計數:{{ count }}</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useEventBus } from '@/composables/useEventBus';
const count = ref(0);
const bus = useEventBus();
function onIncrement() {
count.value += 1;
}
onMounted(() => bus.on('counter-increment', onIncrement));
onBeforeUnmount(() => bus.off('counter-increment', onIncrement));
</script>
重點:透過自訂 useEventBus composable,讓 Composition API 與 mitt 完美結合,程式碼更具可讀性與重用性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的最佳實踐 |
|---|---|---|
| 忘記解除監聽 | 元件銷毀後仍保留監聽,導致記憶體洩漏或重複觸發 | 在 beforeUnmount / onBeforeUnmount 中 必定 呼叫 off |
| 事件名稱衝突 | 多個功能使用相同字串,造成不預期的觸發 | 使用 命名空間(例如 user:login, order:created)或集中管理常量 |
| 濫用全域事件 | 把所有溝通都塞進事件總線,變成隱式依賴,難以追蹤 | 僅在 跨層級、跨路由 或 一次性 場景使用;狀態較複雜時仍建議 Vuex/Pinia |
| 傳遞過大的資料 | 直接把大型物件(如整個列表)作為 payload,會增加記憶體佔用 | 只傳遞 必要的辨識鍵(id)或 淺層資料,由接收端自行取得完整資訊 |
| 缺乏型別或文件 | mitt 本身不提供 TypeScript 型別,容易寫錯事件名稱 | 為事件名稱建立 enum 或 字串常量檔,並在大型專案中加上 JSDoc 註解 |
進階技巧
- 封裝事件總線:將
mitt實例與常用的emit/on/off方法封裝成服務(如上LoadingService),可以統一錯誤處理與日誌。 - 型別安全(若使用 TypeScript):自行定義
interface EventMap,然後在mitt上加上泛型,確保編譯時即能捕捉錯誤。 - 使用
devtools:在開發階段,可在window上掛載emitter(window.__bus = emitter),利用瀏覽器 console 觀察事件流。上線前務必移除。
實際應用場景
| 場景 | 為何適合使用 mitt | 實作概略 |
|---|---|---|
| 即時通知(如聊天室訊息) | 多個不相干的組件(列表、彈窗、聲音提示)皆需接收同一訊息 | 發送 chat:new-message,各元件自行決定是否渲染或播放音效 |
| 表單跨頁資料同步 | 使用者在分頁表單中填寫資料,切換頁籤時需要即時更新預覽 | 在每個欄位元件 emit('form:update', { field, value }),預覽組件 on('form:update') 更新 |
| 全局 Loading / Error UI | 多處 AJAX 請求共用同一個 loading 輪播或錯誤提示 | 如上「LoadingService」範例,所有 API 呼叫只要 show()/hide() 即可 |
| 插件或第三方元件的橋接 | 某些外部 UI 庫只能透過事件機制溝通,無法直接使用 Vue 的 props | 透過 mitt 把外部事件轉成 Vue 內部事件,保持一致性 |
| 測試/偽造事件 | 單元測試時需要模擬跨元件的訊息傳遞 | 在測試檔中直接 bus.emit('event'),驗證監聽端的行為 |
總結
- mitt 為 Vue3 專案提供了一條 輕量、彈性 的事件傳遞管道,適合跨層級、跨路由或一次性訊息的需求。
- 透過 全域掛載
$bus、命名空間、以及 適時解除監聽,可以避免常見的記憶體洩漏與事件衝突。 - 在 Composition API 中使用自訂 composable
useEventBus,即可保持程式碼的可讀性與可測試性。 - 雖然 mitt 能解決許多溝通問題,但 不建議將所有狀態都交給事件總線;當需求變得複雜或需要集中管理時,仍應考慮 Vuex / Pinia。
掌握了 mitt 的基本使用與最佳實踐,你就可以在 Vue3 專案中快速構建 清晰、可維護 的元件通信機制,提升開發效率與程式品質。祝你開發順利,寫出更優雅的 Vue 應用!