本文 AI 產出,尚未審核

Vue3 元件通信:使用 mitt.js 建立事件總線

簡介

在大型 Vue3 專案中,元件之間的資料傳遞往往不只局限於父子關係,跨層級、平行或完全不相關的元件 也需要互相溝通。若僅靠 propsemitprovide/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/pluginssrc/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 註解

進階技巧

  1. 封裝事件總線:將 mitt 實例與常用的 emit/on/off 方法封裝成服務(如上 LoadingService),可以統一錯誤處理與日誌。
  2. 型別安全(若使用 TypeScript):自行定義 interface EventMap,然後在 mitt 上加上泛型,確保編譯時即能捕捉錯誤。
  3. 使用 devtools:在開發階段,可在 window 上掛載 emitterwindow.__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 應用!