Skip to main content

Command Palette

Search for a command to run...

EventBus 类型定义

Updated
3 min read
EventBus 类型定义

这几天我在想,EventBus 这种事件总线机制我经常用到,因为他的写法形式很固定,我让 copilot、AmazonQ、千问给我补全,但大模型补全出来的类型都是 any,要么就是 Function,比如这样:

export class EventBus {
  private listeners: Map<string, Function[]> = new Map();

  on(event: string, listener: Function) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)?.push(listener);
  }

  off(event: string, listener: Function) {
    if (!this.listeners.has(event)) {
      return;
    }
    const fnList = this.listeners.get(event) || []
    const index = fnList.indexOf(listener);
    if (index !== -1) {
      this.listeners.set(event, fnList.slice(index, 1));
    }
  }

  clear(event: string) {
    this.listeners.delete(event);
  }

  emit(event: string, ...args: any[]) {
    if (!this.listeners.has(event)) {
      return;
    }
    const fnList = this.listeners.get(event) || []
    fnList.forEach((fn) => {
      fn.apply(this, args);
    });
  }
}

你说他不对吧,他 EventBus 该有的都有,做法也是没错的,但你说他正确吧,他这写的和 JavaScript 有啥区别呢?丢了类型补全,还存在类型安全隐患。

于是我学习了一下别人的做法:

/* 基础事件 */
export interface IBaseEvent {
  id: string;
  type: EventType;
  timestamp: Date;
}

export interface IMenusLoad extends IBaseEvent {
  menu: Menu;
}

export interface IThemeChange extends IBaseEvent {
  theme: "light" | "dark";
}

export interface Events {
  "menus-load": IMenusLoad;
  "theme-change": IThemeChange;
}

首先定义基础事件数据接口 IBaseEvent,描述 emit 传递和 listener 接收的事件数据,里面的属性是一个事件数据必须有的。

IMenusLoadIThemeChange 作为更具体的事件数据接口,他会带有自己事件相关的属性。

Events事件类型事件数据映射,也就是触发某个类型的事件时,所携带的数据的类型。

export type T = keyof Events;

export type Listener<EventType extends T> = (e: Events[EventType]) => void;

T 是 Events 键组成的联合类型,通过 T 可以描述出 Events 所有事件类型对应的 listener(e: Events[EventType]) => void

接着他做了一个观察者,为每个事件单独做观察监听,也就是说一个事件类型,对应一个Observer 实例:

export interface IObserver<EventType extends T> {
  subscribe: (listener: Listener<EventType>) => () => void;
  publish: (event: Events[EventType]) => void;
}

export class Observer<EventType extends T> implements IObserver<EventType> {
  private listeners: Listener<EventType>[] = [];

  subscribe(listener: Listener<EventType>): () => void {
    this.listeners = [...this.listeners, listener];

    return () => {
      this.listeners = this.listeners.filter((l) => l !== listener);
    };
  }

  publish(event: Events[EventType]): void {
    this.listeners.forEach((listener) => listener(event));
  }
}

以往我的习惯是一个对象来保存 typelisteners 的关系。

接着是 EventBus 的实现:

type T = keyof Events;

type ObserversMap = {
  [Type in T]?: IObserver<Type>;
};

/* Singleton implementation of Global Event Bus */
class EventBus {
  private static instance: EventBus;
  private observers: ObserversMap = {};

  private constructor() {}

  static getInstance() {
    if (!EventBus.instance) {
      EventBus.instance = new EventBus();
    }

    return EventBus.instance;
  }

  public publish<Type extends T>(type: Type, event: Events[Type]): void {
    if (!EventBus.getInstance().observers[type]) {
      EventBus.getInstance().observers = {
        ...EventBus.getInstance().observers,
        [type]: new Observer<Type>(),
      };
    }

    // @ts-ignore
    EventBus.getInstance().observers[type].publish(event);
  }

  public subscribe<Type extends T>(
    type: Type,
    listener: Listener<Type>
  ): () => void {
    if (!EventBus.getInstance().observers[type]) {
      EventBus.getInstance().observers = {
        ...EventBus.getInstance().observers,
        [type]: new Observer<Type>(),
      };
    }

    // @ts-ignore
    return EventBus.getInstance().observers[type].subscribe(listener);
  }
}

export default EventBus.getInstance();

由于他是用事件对应独立的Observers,所以会用 Map 来管理映射关系,ObserversMap 是映射的类型定义,键来自于先前的 Events

私有的构造函数getInstance 让 EventBus 实现了单例模式,项目中只会有一个 EventBus 对象存在。

publishsubscribe 的预检查逻辑一致,都是先判断 observers 中有没有某事件类型,没有就创建新的,然后订阅/发布。(这两个 ts-ignore 可以不用管,之前已经判断过对象存在了)

到这里 EventBus 的功能已经基本实现了。

接下来的是事件类:

class BaseEvent implements IBaseEvent {
  public id: string;
  public timestamp: Date;

  constructor(public type: EventType) {
    this.id = generateKey();
    this.timestamp = new Date();
  }
}

export class MenusLoadEvent extends BaseEvent implements IMenusLoad {
  constructor(public menu: Menu) {
    super("menus-load");
  }
}

export class ThemeChangeEvent extends BaseEvent implements IThemeChange {
  constructor(public theme: "light" | "dark") {
    super("theme-change");
  }
}

idtimestamp 都是自动生成的,具体事件的参数在之前的 IBaseEvent 那已经定义了,这里是实现。

具体使用起来是这样的:

EventBus.getInstance().subscribe("theme-change", (event) => console.log(event));

事件类型不能乱填,TS 会检查 Events 映射表的键,同理 event 的类型也会根据 Events 的值确定。

参考

https://medium.com/@nijatismayilbeyli/implementing-type-safe-and-generic-event-bus-in-typescript-752ba94984ec

More from this blog

12 月装机行动记录

(Banner 图文无关) 这几天买了新的装备回来升级配置,除了显卡和散热器,其他都更新了,这两个不更新的原因是太贵了。 由于我不太懂选配置,所以还是让朋友给推荐,我说我的预算在 5000 左右,他就给我转了一个整机,配置大概是这样的: CPU:AMD 9700X 主板:微星 B650M GAMING PLUS WIFI 或者 微星 B650M GAMING WIFI 显卡:木有 内存:英睿达/宇瞻 DDR5 6000 32G 硬盘:1T NVMe PCIe4 SSD 读速 3500M...

Dec 7, 20241 min read
12 月装机行动记录

Homekit + cozylife 插座连接 HA

近期在淘宝上买了个 Homekit + cozylife 的插座,就这种: 一开始只通过 iOS 访问,就是只连接 Homekit,长按开关重置插座,iOS 一扫码就连上了,后来我嫌在外面访问不了,又不想掏钱买苹果的 HomePod,于是就装上了 Home Assistant,打算让设备们都连上 HA,这样就不用交苹果税了。 连接方式还是通过 Homekit,一般来说支持 Homekit 设备都能这样连接,先连上 iOS,然后在 Home App 中移除设备,这时候就能在 HA 中找到设备了:...

Nov 30, 20241 min read
Homekit + cozylife 插座连接 HA

找到了一台祖传的 Ccd 相机

开个玩笑,这台相机其实是我们家在 05 年的时候买的,发票都还在呢,当时花了 4000 块钱!搁现在我都受不了,更别说当年了,看到价格我都震惊了。 相机的型号是索尼的 Cybershot DSC-N1,属于小红书时尚单品 CCD 相机,由于一直放在包装盒里,现在还有 99 新呢。 机子还是正常的,能开机,能拍照,其中一个问题是日期,这款没有 WiFi 功能,所以时间只能保存在本地,不知道是不是 BUG,每次开机都让我重新设置,默认就定在 2005 年 1 月 1 日。 第二个问题是电池,电池应该...

Nov 28, 20241 min read
找到了一台祖传的 Ccd 相机

记录和 ffmpeg 与 LLM 搏斗的两天

要做的 最近在写一个制作视频的功能,就是把 N 个视频合并,然后把对应的 N 张图片,在视频开始的前 5 秒叠加显示出来。 第一口 - diffusion studio 本来我用的是 diffusion studio,这是一个 JS 库,但这玩意性能太差了,因为他要把视频每一帧都读到 canvas 里,数据一多页面就卡住了(为啥要折腾 DOM 呢?) 而且他的 API 十分不好用,作为浏览器脚本你无法读本地数据也就算了,你起码给一个接受纯数据的参数吧,比如 HTML 类型接受源代码,Image ...

Nov 16, 20242 min read
记录和 ffmpeg 与 LLM 搏斗的两天
V

void mian

39 posts