在前端開發中,我們常常會定義事件系統,例如:
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 能幫我們「提前擋掉」。
今天我們就要做一個 型別安全的事件系統,讓:
首先,我們用一個物件型別定義所有事件和對應資料:
ts
CopyEdit
type AppEvents = {
userCreated: { id: number; name: string };
userDeleted: { id: number };
orderPlaced: { orderId: string; amount: number };
};
這個 AppEvents
就是 單一真相來源,
之後所有的事件名稱、事件資料型別都從它推導。
我們用泛型 + 映射型別來建立一個通用事件系統:
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]
保證資料型別與事件名對應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));
},
};
}
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" }); // ❌ 事件名錯字
假設我們要把事件命名統一成 "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) => { ... });
如果我們的事件型別定義在一個檔案:
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>();
這樣就算事件定義變更,整個專案的事件系統都能同步更新型別。
我們可以用泛型參數限制讓 eventName
參數有 IntelliSense:
ts
CopyEdit
function on<K extends keyof AppEvents>(eventName: K, handler: (data: AppEvents[K]) => void) {}
這樣在輸入 eventName
時會有下拉選單提示所有事件名。
錯誤 1:事件型別與資料型別不同步
→ 解法:單一真相來源(AppEvents
)
錯誤 2:事件名稱用字串常量,容易打錯
→ 解法:用 keyof AppEvents
限制
錯誤 3:跨模組事件型別不共用
→ 解法:把事件型別集中在一個檔案