从零实现一个 TypeScript 类型安全的 EventEmitter

在 Node.js 和前端开发中,EventEmitter 是一种非常常见的设计模式。它允许对象通过事件订阅和发布来进行解耦通信。Node.js 内置了 events 模块,浏览器中也有 EventTarget,但它们都有一个共同的痛点:类型不安全。你可以在 emit 时传错参数,也可以在 on 时写错事件名,而 TypeScript 无法帮你发现这些错误。

本文将带你从零实现一个类型安全的 EventEmitter,让编译器成为你的第一道防线。

为什么需要类型安全?

先看一个典型的问题场景:

const emitter = new EventEmitter();

// 监听事件
emitter.on('user:login', (user) => {
  console.log(user.name); // user 是 any,编辑器无法提示
});

// 触发事件
emitter.emit('user:login', { name: 'Alice' });
emitter.emit('user:login'); // 运行时才报错,编译期无感知
emitter.emit('user:logout', { name: 'Bob' }); // 事件名拼写错误,无法发现

我们希望达到的效果是:

  • 事件名必须是预定义的合法值
  • 每个事件对应的回调参数类型必须匹配
  • emit 时参数数量与类型必须与监听时一致
  • 编辑器能提供自动补全和类型提示

设计思路

核心思路是用一个「事件映射类型」来描述所有事件及其参数:

type EventMap = {
  'user:login': [user: { name: string; id: number }];
  'user:logout': [userId: number];
  'message': [text: string, from: string];
};

每个键是事件名,值是一个元组类型,表示该事件回调接收的参数列表。有了这个映射,我们就可以用泛型和条件类型来约束 onemitoff 等方法。

基础实现

type EventMap = Record<string, any[]>;

type EventKey<T extends EventMap> = string & keyof T;
type EventCallback<T extends any[]> = (...args: T) => void;

class TypedEventEmitter<T extends EventMap> {
  private listeners: {
    [K in keyof T]?: Array<EventCallback<T[K]>>;
  } = {};

  on<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): this {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
    return this;
  }

  off<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): this {
    const list = this.listeners[event];
    if (list) {
      this.listeners[event] = list.filter((cb) => cb !== callback) as any;
    }
    return this;
  }

  emit<K extends EventKey<T>>(event: K, ...args: T[K]): boolean {
    const list = this.listeners[event];
    if (!list || list.length === 0) {
      return false;
    }
    list.forEach((cb) => cb(...args));
    return true;
  }

  once<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): this {
    const wrapper = (...args: T[K]) => {
      this.off(event, wrapper as EventCallback<T[K]>);
      callback(...args);
    };
    return this.on(event, wrapper as EventCallback<T[K]>);
  }
}

这里有几个关键点:

  1. EventMap 约束了所有事件的形状,每个事件的值必须是数组类型。
  2. EventKey<T> 确保事件名只能是 T 的键。
  3. emit 的参数 使用 ...args: T[K],让 TypeScript 自动推导出对应事件的参数类型。
  4. listeners 使用映射类型,保证每个事件的回调数组类型正确。

使用示例

interface MyEvents {
  'user:login': [user: { name: string; id: number }];
  'user:logout': [userId: number];
  'message': [text: string, from: string];
}

const emitter = new TypedEventEmitter<MyEvents>();

// ✅ 正确
emitter.on('user:login', (user) => {
  console.log(user.name); // user 类型为 { name: string; id: number }
});

emitter.emit('user:login', { name: 'Alice', id: 1 });

// ❌ 编译错误:参数类型不匹配
emitter.emit('user:login', { name: 'Alice' });

// ❌ 编译错误:事件名不存在
emitter.on('user:register', () => {});

// ❌ 编译错误:参数数量不对
emitter.emit('message', 'hello');

此时,编辑器会自动补全事件名,参数类型也会被准确推导。所有错误都在编译期暴露,而不是等到运行时。

进阶:支持通配符与链式调用

如果你希望支持 * 通配符监听所有事件,可以扩展类型定义:

type WildcardHandler<T extends EventMap> = <K extends keyof T>(
  event: K,
  ...args: T[K]
) => void;

class TypedEventEmitter<T extends EventMap> {
  // ... 之前的代码

  onAny(handler: WildcardHandler<T>): this {
    // 实现略
    return this;
  }
}

链式调用方面,所有方法返回 this 即可,TypeScript 会自动推导出子类类型。

与 Node.js EventEmitter 的对比

特性 Node.js EventEmitter TypedEventEmitter
事件名检查 ❌ 任意字符串 ✅ 仅限预定义键
参数类型检查 ❌ any ✅ 精确推导
自动补全 ❌ 无 ✅ 完整支持
运行时开销 几乎相同

由于类型信息在编译后被擦除,TypedEventEmitter 的运行时性能与普通实现几乎无差别,但开发体验却有质的提升。

总结

类型安全的 EventEmitter 并不复杂,核心在于利用 TypeScript 的泛型、映射类型和条件类型,将「事件名 → 参数元组」的映射关系编码到类型系统中。通过本文的实现,你可以获得:

  • 编译期的事件名校验
  • 精确的参数类型推导
  • 编辑器自动补全
  • 零运行时开销

你可以把这段代码直接放进项目里,或者发布成 npm 包。如果你的项目中有多个模块通过事件通信,强烈建议用上类型安全的事件系统——它能帮你省下大量调试时间,也能让代码意图更加清晰。

类型系统不是束缚,而是帮你把错误挡在运行之前。

未经允许不得转载:任鹏个人博客 » 从零实现一个 TypeScript 类型安全的 EventEmitter

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏