面试官:实现一个带类型约束的 EventEmitter

写在前面

最近有个朋友在面试候选人的时候,问了一个关于 TypeScript 事件总线的题目,很多人都没有写出来。作为一个热门的面试题,相信大家都能写出基本的事件总线,但是朋友的面试题要求更进一步,需要能够推导入参类型。本文会分享如何实现这样一个事件总线

基础事件总线

事件总线的核心是维护一张"事件名 -> 监听函数列表"的映射表。这里使用 Map 保存所有事件:

typescript 复制代码
private events = new Map<keyof T, Array<(...args: any[]) => void>>();

整体流程如下:

  1. on 注册事件监听

    • 接收事件名 event 和监听函数 listener
    • 如果当前事件还没有对应的监听数组,就先创建一个空数组。
    • 然后把监听函数追加到数组中。
  2. emit 触发事件

    • 根据事件名从 Map 中取出对应的监听函数数组。
    • 如果有监听函数,就遍历数组并依次执行。
    • 触发时传入的参数会透传给每个监听函数。
  3. off 移除事件监听

    • 根据事件名找到对应的监听函数数组。
    • 通过 filter 过滤掉需要移除的监听函数。
    • 注意:这里要求传入的 listener 必须和 on 时传入的是同一个函数引用,否则无法移除。

代码大概是这样:

typescript 复制代码
class EventEmitter {
  private events = new Map<string, Array<(...args: any[]) => any>>();

  on(event: string, listener: (...args: any[]) => any) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }

    this.events.get(event)!.push(listener);
  }

  emit(event: string, ...args: any[]) {
    const listeners = this.events.get(event);

    if (listeners) {
      listeners.forEach((listener) => listener(...args));
    }
  }

  off(event: string, listener?: (...args: any[]) => any) {
    if (!listener) {
      this.events.delete(event);
      return;
    }

    const listeners = this.events.get(event);
    const filtered = listeners?.filter((item) => item !== listener);

    if (!filtered || filtered.length === 0) {
      this.events.delete(event);
    } else {
      this.events.set(event, filtered);
    }
  }
}

限制入参类型

为了让 TypeScript 根据事件名自动限制参数类型,可以先定义一个事件类型表:

typescript 复制代码
type Events = {
  click: { x: number; y: number };
  change: string;
  close: void;
};

这个类型表表达的是:

  • click 事件的参数必须是 { x: number; y: number }
  • change 事件的参数必须是 string
  • close 事件不需要参数

然后通过泛型把这张事件类型表传给 EventEmitter

typescript 复制代码
const emitter = new EventEmitter<Events>();

关键点有两个:

  1. 使用 keyof T 限制事件名
typescript 复制代码
on<K extends keyof T>(event: K, listener: ...)
emit<K extends keyof T>(event: K, ...)
off<K extends keyof T>(event: K, listener: ...)

这里的 K extends keyof T 表示:事件名只能是 Events 中存在的 key,比如 clickchangeclose。如果写成 emitter.emit('submit'),TypeScript 就会报错。

  1. 使用 T[K] 根据事件名推导参数类型
typescript 复制代码
listener: T[K] extends void ? () => void : (args: T[K]) => void

这段类型的意思是:

  • 如果当前事件对应的参数类型是 void,监听函数就不接收参数。
  • 否则,监听函数必须接收一个类型为 T[K] 的参数。

所以:

typescript 复制代码
emitter.on("click", (event) => {
  event.x;
  event.y;
});

这里的 event 会被自动推导成 { x: number; y: number }

emit 的参数限制也是同理:

typescript 复制代码
emit<K extends keyof T>(
  event: K,
  ...args: T[K] extends void ? [] : [T[K]]
): void

这里使用了剩余参数和条件类型:

  • 如果事件参数是 voidargs 类型就是空数组 [],也就是不能传第二个参数。
  • 如果事件参数不是 voidargs 类型就是 [T[K]],也就是必须传一个对应类型的参数。

因此下面这些调用会得到正确的类型约束:

typescript 复制代码
emitter.emit("change", "hello"); // 正确
emitter.emit("change", 123); // 错误,change 需要 string

emitter.emit("close"); // 正确
emitter.emit("close", false); // 错误,close 不需要参数

emitter.emit("click", { x: 1, y: 2 }); // 正确
emitter.emit("click", { x: 1 }); // 错误,缺少 y

完整代码

typescript 复制代码
class EventEmitter<T extends Record<string, any>> {
  private events = new Map<keyof T, Array<(...args: any[]) => void>>();

  constructor() {}

  on<K extends keyof T>(
    event: K,
    listener: T[K] extends void ? () => void : (args: T[K]) => void,
  ) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event)!.push(listener as any);
  }

  emit<K extends keyof T>(event: K, ...args: T[K] extends void ? [] : [T[K]]) {
    const listeners = this.events.get(event);
    if (listeners) {
      listeners.forEach((listener) => listener(...args));
    }
  }

  off<K extends keyof T>(
    event: K,
    listener?: T[K] extends void ? () => void : (args: T[K]) => void,
  ) {
    if (!listener) {
      this.events.delete(event);
      return;
    }

    const listeners = this.events.get(event);
    const filtered = listeners?.filter((item) => item !== listener);

    if (!filtered || filtered.length === 0) {
      this.events.delete(event);
    } else {
      this.events.set(event, filtered);
    }
  }
}

type Events = {
  click: { x: number; y: number };
  change: string;
  close: void;
};

const emitter = new EventEmitter<Events>();

emitter.on("click", (event) => {
  event.x;
});

emitter.emit("change", "hello");
emitter.on("close", () => {
  console.log("closed");
});

写在最后

感觉您耐心看完这篇文章,希望您能喜欢。这里是《前端毕业班》,前端开发者的自救互助小组。在 AI 与不确定性并存的时代,我们一起看清焦虑,聊技术、聊趋势,也聊前端还能走多远,走去哪。

相关推荐
卷帘依旧1 小时前
SPA 中的 Hash 和 History 模式
前端
用户4445543654261 小时前
AndroidAutoSize使用时遇到的特麻烦bug
前端
茉莉玫瑰花茶2 小时前
LangGraph 入门教程:构建 AI 工作流 [ 案例三 ]
前端·人工智能·python
scan7242 小时前
pydantic格式输出
服务器·前端·javascript
ZC跨境爬虫2 小时前
跟着MDN学HTML_day44:(ProcessingInstruction接口)
前端·javascript·ui·html·媒体
CODE202203182 小时前
promptfoo自定义prompt生成器
java·前端·prompt
222you2 小时前
Claude Code接入DeepSeek-v4模型
java·服务器·前端
轻口味2 小时前
AI 时代全栈开发破局:TypeScript 生态实战,从入门到部署一站式通关
前端·mongodb·docker·ai·typescript·react·next.js
ZC跨境爬虫2 小时前
跟着MDN学HTML_day_45:(EventTarget接口)
前端·javascript·ui·html·媒体