发布订阅,简单好使

不到一百行TS实现一个类型安全的发布订阅库
typescript
design-pattern
Author

XU HUI

Published

October 30, 2024

发布订阅的实现相当简单,回调就是,但往往大家写的时候都不注意,随意糊弄,连类型安全都没有,尤其是 JS 项目中,大脑和代码是完全解耦的,所以我在某次项目中也自己写了个,想起了给发个包同时记录下心得。

常见的事件主线实现

Pub/Sub 最常见的实现是暴露出一个 EventBus 也就是事件主线,而后在这个主线上处理事件,但这样做就有两个坏处:

  1. 需要花费精力维护“事件”本身。通过事件主线发布订阅,势必就要通过“事件”这个东西来进行类型推导,比如说

    export interface MyEvent<Payload, Result> extends String { }

    我们用字符串作为事件名,就需要定义一个类型和一个被声明为某种事件的字符串变量,如果我们将发布订阅当做应用的核心机制,这些字符串变量将会迅速膨胀,然后就有了数十上百个奇怪的字符串变量,这是没有必要的。

  2. 过于复杂的API。在基于事件主线的发布订阅中,我们定义一个行为需要用到多少API呢?

    1. 定义一个字符串变量作为事件,全局变量或局部变量

      export const event1 = "event1" as MyEvent<string, string>
    2. 从事件主线上执行 发布/订阅/退订 操作,

      useEffect(() => {}
          const callback = (s: string) => s;
      
          EventBus.subscribe(event1, callback);
          return EventBus.unsubscribe(event1, callback)
      , [])

    用到了预先定义的 EventBus、MyEvent 和自行定义的 event1,同时需要储存 callback 变量,不算太复杂,但我们可以更简单。

充分利用 JS 的第一等函数简化API

JS 的一个好处是函数作为一等公民被支持,我们完全可以将事件主线的 unsubscribe 作为 subscribe 的返回值,如此则简化了取消订阅的API,同时,因为闭包的存在 callback 将不再需要储存。

再看这个碍事的事件名,取消订阅不再需要他了,剩下的就是发布和订阅了,我们完全也可以通过闭包将事件名封起来,只需要返回发布订阅两个函数就是了。

再考虑下异步支持和单播多播的问题,我们就得到了这个定义,没有事件主线也没有事件。

type MaybePromise<T> = T | Promise<T>;

type Handler<Payload = any, Result = any> = (payload: Payload) => MaybePromise<Result>;

export type Sender<P = any, R = any> = {
  subscribe: (handler: Handler<P, R>) => () => void;
  publish: (payload: P) => MaybePromise<R> | void;
};

export type Notifier<P = any> = { 
  subscribe: (handler: Handler<P, void>) => () => void; 
  publish: (payload: P) => MaybePromise<void> 
};

接下来是具体的实现,在底层我们仍然需要储存一个字符串和回调的映射表,但就没必要定义 class 了,这是 JS 的世界那玩意不必要。

const registry = new Map<string, Handler | Handler[]>();

const subscribe = <Payload, Result>(cmd: string, handler: Handler<Payload, Result>, multicast = false) => {
  multicast ? registry.set(cmd, [...((registry.get(cmd) as Handler[]) || []), handler]) : registry.set(cmd, handler);
  return () => {
    multicast ? (registry.get(cmd) as Handler[]).splice((registry.get(cmd) as Handler[]).indexOf(handler), 1) : registry.delete(cmd);
  };
};

至于单播多播两种类型其实只是在映射表的订阅上包了一层,到这里,我们可以数一下我们现在执行一个发布订阅操作需要多少API,一个而已:创建一个单播组或者多播组。拢共不到一百行,SO EASY !

export function createSender<Payload, Result>(cmd: string): Sender<Payload, Result> {
  return {
    subscribe: (handler: Handler<Payload, Result>) => subscribe(cmd, handler, false),
    publish: (payload: Payload) => {
      return (registry.get(cmd) as Handler<Payload, Result>)?.(payload);
    },
  };
}

export function createNotifier<Payload>(cmd: string): Notifier<Payload> {
  return {
    subscribe: (handler: Handler<Payload, void>) => subscribe(cmd, handler, true),
    publish: async (payload: Payload) => {
      const results = (registry.get(cmd) as Handler<Payload, void>[] | undefined)?.map((h) => h(payload));
      results && (await Promise.allSettled(results));
    },
  };
}