本文假设你已经有了 typescript 的基础知识

需求

我们想要实现一个最简单的订阅者模式,其中在 dispatch 和 subscribe 方法调用时,希望传入的参数都有 types 验证。

先来看个 js 实现的例子(非常简单)

//  javascript 代码
class Subscribe {
  private eventList = {}

  public subscribe(eventName, handler) {
    if (this.eventList[eventName]) {
      this.eventList[eventName].push(handler)
    } else {
      this.eventList[eventName] = [handler]
    }
  }

  public unSubscribe(eventName, handler) {
    if (this.eventList[eventName]) {
      let index = this.eventList[eventName].indexOf(handler)
      if (~index) {
        this.eventList[eventName].splice(index, 1)
      }
    }
  }

  public dispatch(eventName, ...payload) {
    if (this.eventList[eventName]) {
      for (let i = 0; i < this.eventList[eventName].length; i++) {
        this.eventList[eventName][i].call(null, payload)
      }
    }
  }
}

我们来看下调用的例子

class App extends Subscribe {
  constructor() {
    this.bindEvent();
  }

  bindEvent() {
    this.subscribe("loaded", (time) => {
      console.log(time, "loaded");
    });

    this.subscribe("destroy", (isSkip) => {
      if (isSkip) {
        console.log(isSkip);
      }
    });

    document.body.addEventListener("resize", () => {
      this.dispatch("resize", Date.now());
    });
  }
}

从调用的代码中我们可以看到这个 App 实例中,会监听 loadeddestroy 方法,

并且这两个方法对应使用的 dispatch 应该是这样的

this.dispatch("loaded", Date.now());
this.dispatch("destroy", true);

App 实例中当触发 resize 事件时还会 dispatch resize 传递的参数是 Date.now()

那么我们就可以知道订阅 resize 这个方法的处理函数一定是长这样的

this.subscribe("resize", (time) => {
  todo();
});

就如开头提到的,这是一个非常简单的例子,但是实际到我们项目中时,当需要订阅的事件逐渐增多,再加上需要暴露接口给外部,

那么就很容易出现参数填错或者事件名写错的问题。

比如这个例子中,如果仅仅是看文档的话就很墨迹,而且也不能保证代码书写的正确。

//  你是怎么知道有 loaded 这个事件? 为何它又传递了一个 number?
this.subscribe("loaded", this.handleLoaded);
//  你是怎么知道有 destroy 这个事件? 为何它又传递了一个 bool?
this.subscribe("destroy", this.handleDestroy);

因此我们来思考下 🤔, 如果我们使用 typescript 来验证调用 dispatchsubscribe 的参数,这将再好不过了 👍

让我们看看下面通过 typescript 改写调用后的例子。

//  typescript
declare type AppEvent = {
  loaded: (time: number) => void;
  destroy: (isSkip: boolean) => void;
  resize: (time: number) => void;
  foo: (bar: string) => string;
};

class App extends Subscribe<AppEvent> {
  constructor() {
    this.bindEvent();
  }

  bindEvent() {
    //
    this.subscribe("foo", (bar: string) => {
      //  type error 未返回 AppEvent 中 foo 类型的闭包形式,缺少返回值 string
    });

    //  type errpr “bar” 不适应 AppEvent 这个类型
    //  原因是我们未在 AppEvent 中定义 bar
    this.subscribe("bar", (isSkip, tiem) => {});

    this.subscribe("loaded", () => {
      //  正常执行
    });

    this.subscribe("destroy", (isSkip, tiem) => {
      //  type error,destory 对应的闭包中,缺少参数 time
    });

    document.body.addEventListener("resize", () => {
      //  type error, 缺少参数 time:number
      this.dispatch("resize");

      //  正常工作
      this.dispatch("resize", Date.new());
    });
  }
}

要是能这样调用,那真是太棒了 👍

💪 实现

让我们通过 typescript 来实现这个订阅者模式.

下面是实现的所有代码,如果你理解能力非常好的话 👌,可以跳过后面的解释。

declare type EventObj<T> = {
  [K in keyof T]: (...args: any) => void;
};
 
class Subscribe<T extends EventObj<T>> {
  private eventList: Partial<Record<keyof T, Array<T[keyof T]>>> = {};

  public subscribe<K extends keyof T>(eventName: K, handler: T[K]) {
    if (this.eventList[eventName]) {
      this.eventList[eventName].push(handler);
    } else {
      this.eventList[eventName] = [handler];
    }
  }

  public unSubscribe<K extends keyof T>(eventName: K, handler: T[K]) {
    if (this.eventList[eventName]) {
      let index = this.eventList[eventName].indexOf(handler);
      if (~index) {
        this.eventList[eventName].splice(index, 1);
      }
    }
  }

  public dispatch<K extends keyof T>(
    eventName: K,
    ...payload: Parameters<T[K]>
  ) {
    if (this.eventList[eventName]) {
      for (let i = 0; i < this.eventList[eventName].length; i++) {
        this.eventList[eventName][i].call(null, payload);
      }
    }
  }
}

下面我们来一步步分解代码

泛型 T 的类型约束

declare type EventObj<T> = {
  [K in keyof T]: (...args: any) => void;
};

class Subscribe<T extends EventObj<T>> {
  //  ...
}

这里我们针对传入的泛型 T 使用了 EventObj 来进行约束,因为我们需要保证,在传递泛型 T 的时候,必须是一个 EventObj 类型的对象

比如下面这样

declare type AppEvent = {
  loaded: (time: number) => void;
  destroy: (isSkip: boolean) => void;
  resize: (time: number) => void;
  foo: (bar: string) => string;
};

class App extends Subscribe<AppEvent> {}

而不是像下面这样 😂

declare type AppEvent = void | null;

class App extends Subscribe<AppEvent> {}

所以必须通过 EventObj 进行类型约束

eventList 的定义

根据我们传入的泛型 T,我们希望 eventList 是长这样的

class Subscribe {
  private eventList: eventList = {
    loaded: [(time: number) => {}, (time: number) => {}],
    destroy: [(isSkip: boolean) => {}, (time: boolean) => {}],
  };
}

因此我们对其这样定义

class Subscribe {
  private eventList: Partial<Record<keyof T, Array<T[keyof T]>>> = {};
}

让我们从内往外进行拆解,

首先 Record 类型的第一个泛型为 keyof T 这意味着 key 的值只能是传入进来的泛型 T(AppEvent)中的 key,

也就是 loaded | destroy | resize | foo

然后它对应的 value 应该是一个 handler 的数组,因此我们定义为 Array<T[keyof T]>

接着我们使用 Partial 类型来让整个 eventList 中的 key 变成可选的,

因为我们可能只监听了 loaded 或者 destroy,所以整体的 key 都需要变成可选的。

subscribe 方法定义

class Subscribe {
  public subscribe<K extends keyof T>(eventName: K, handler: T[K]) {}
}

当用户使用 this.subscribe 方法时,我们希望他传入的 eventName 必须是来自于泛型 T(AppEvent)中的 key。

也就是只能为 loaded | destroy | resize | foo

所以我们这里用了一个泛型 K 来表示当前用户传入的 eventName,并且将其约束为 keyof T

那么对应的 handler 也就理所应当的为 T[K] 了,

注意这里的 T 指的是 AppEvent 因此 T[K] 就相当于 AppEvent[loaded] (假设传入的 eventName 为 loaded

dispatch 方法定义

class Subscribe<T> {
  public dispatch<K extends keyof T>(
    eventName: K,
    ...payload: Parameters<T[K]>
  ) {
    if (this.eventList[eventName]) {
      for (let i = 0; i < this.eventList[eventName].length; i++) {
        this.eventList[eventName][i].call(null, payload);
      }
    }
  }
}

这里主要的问题是在用户传入的参数,也就是 handler 回调接收的参数。

首先我们使用 Parameters 这个工具类型,来获取 T[K] 的参数,比如说 AppEvent[loaded] 的参数就是 [time:number]

也就是意味着,如果用户这样调用时,参数必须是 [time:number]

this.dispatch("loaded", [Date.new()]);

这个 Parameters 这个工具类型就是用来获取 function 的参数的,他会把传入的 Parameters<Fn>Fn 接受的参数变成一个

元组返回(当然 javascirpt 里面元组就是 [ ] 了)

因此我们这里用 ... 语法将这个元组进行了解构。

总结

到这里我们就解决了需求,这里主要用到的知识点就是以下三个 keyof , extends (类型约束)Parameters 如果你还不是太理解的话,

建议先看官方文档对这三个知识点熟悉后再回来阅读,一定会恍然大悟的 😳