iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

1) 引言:事件系統的痛點

在前端開發中,我們常常會定義事件系統,例如:

ts
CopyEdit
emitter.on("userCreated", (data) => { ... });
emitter.emit("userCreated", { id: 1, name: "Alice" });

但如果寫成:

ts
CopyEdit
emitter.emit("userCreated", { id: "oops", name: "Alice" }); // id 型別錯誤

或者:

ts
CopyEdit
emitter.emit("usrCreated", { id: 1, name: "Alice" }); // 事件名拼錯

JavaScript 在編譯期是不會報錯的,只有 TypeScript 能幫我們「提前擋掉」。

今天我們就要做一個 型別安全的事件系統,讓:

  • 事件名稱只能用既定的字串
  • 事件資料的型別與事件名稱完全對應
  • 註冊和觸發事件時都有型別提示

2) 定義事件對應型別

首先,我們用一個物件型別定義所有事件和對應資料:

ts
CopyEdit
type AppEvents = {
  userCreated: { id: number; name: string };
  userDeleted: { id: number };
  orderPlaced: { orderId: string; amount: number };
};

這個 AppEvents 就是 單一真相來源

之後所有的事件名稱、事件資料型別都從它推導。


3) 建立型別安全的 Emitter 介面

我們用泛型 + 映射型別來建立一個通用事件系統:

ts
CopyEdit
type EventEmitter<T extends Record<string, any>> = {
  on<K extends keyof T>(eventName: K, handler: (data: T[K]) => void): void;
  emit<K extends keyof T>(eventName: K, data: T[K]): void;
};

這裡:

  • K extends keyof T 保證事件名必須存在於事件型別中
  • data: T[K] 保證資料型別與事件名對應

4) 建立一個簡單的事件系統實作

ts
CopyEdit
function createEventEmitter<T extends Record<string, any>>(): EventEmitter<T> {
  const handlers: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};

  return {
    on(eventName, handler) {
      (handlers[eventName] ||= []).push(handler);
    },
    emit(eventName, data) {
      handlers[eventName]?.forEach((h) => h(data));
    },
  };
}


5) 使用型別安全的事件系統

ts
CopyEdit
const emitter = createEventEmitter<AppEvents>();

// 正確用法
emitter.on("userCreated", (data) => {
  console.log(data.name); // data: { id: number; name: string }
});
emitter.emit("userCreated", { id: 1, name: "Alice" });

// 錯誤用法(編譯期報錯)
emitter.emit("userCreated", { id: "wrong", name: "Alice" }); // ❌ id 型別錯
emitter.emit("userCrated", { id: 1, name: "Alice" });         // ❌ 事件名錯字


6) 搭配 Template Literal Types 生成事件名

假設我們要把事件命名統一成 "onXxx" 格式,可以:

ts
CopyEdit
type PrefixEvents<T extends Record<string, any>> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: T[K];
};

type PrefixedAppEvents = PrefixEvents<AppEvents>;
/*
{
  onUserCreated: { id: number; name: string };
  onUserDeleted: { id: number };
  onOrderPlaced: { orderId: string; amount: number };
}
*/

用這個型別建立 Emitter:

ts
CopyEdit
const emitter2 = createEventEmitter<PrefixedAppEvents>();

emitter2.on("onUserCreated", (data) => { ... });


7) 實戰應用:跨模組事件型別共享

如果我們的事件型別定義在一個檔案:

ts
CopyEdit
// events.ts
export type AppEvents = {
  userCreated: { id: number; name: string };
  userDeleted: { id: number };
};

那其他檔案就能直接用:

ts
CopyEdit
import { AppEvents } from "./events";
const emitter = createEventEmitter<AppEvents>();

這樣就算事件定義變更,整個專案的事件系統都能同步更新型別。


8) 加上事件名稱自動提示(進階)

我們可以用泛型參數限制讓 eventName 參數有 IntelliSense:

ts
CopyEdit
function on<K extends keyof AppEvents>(eventName: K, handler: (data: AppEvents[K]) => void) {}

這樣在輸入 eventName 時會有下拉選單提示所有事件名。


9) 常見錯誤與陷阱

錯誤 1:事件型別與資料型別不同步

→ 解法:單一真相來源(AppEvents

錯誤 2:事件名稱用字串常量,容易打錯

→ 解法:用 keyof AppEvents 限制

錯誤 3:跨模組事件型別不共用

→ 解法:把事件型別集中在一個檔案


10) 心法

  1. 事件名稱與資料型別的綁定是關鍵
    • 保證不會傳錯資料
  2. 用 Template Literal Types 標準化事件名
    • 讓事件系統更一致
  3. 單一真相來源
    • 事件型別集中管理

上一篇
Day 27|型別驅動的表單生成器實戰
系列文
我與型別的 30 天約定:TypeScript 入坑實錄28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言