TS 泛型:写一个有提示的发布订阅类

前言

常规的发布订阅无论是在类型校验还是在语法提示上都是一大痛点,对于不同的事件名以及传递的参数数据类型,都犹如一个盲盒,对于不熟悉它的人来说,维护它同于维护si山。

初级运用(基于内置规则)
GIF 2021-7-9 21-51-04.gif

高级运用(在基础规则之上进行自定义扩展)

GIF 2021-7-9 22-04-10.gif

写个普通的发布订阅类

interface EventBusItem {
  fn: Function
  once: boolean
}

export default class EventBus {
  public pool: Map<string, EventBusItem[]> = new Map()

  /**
   * 事件绑定(once 模式)
   * @param type 事件名
   * @param fn 回调函数
   * @param once 回调函数只执行一次
   * @returns
   */
  public on(type: string, fn: Function, once: boolean = false) {
    let row = this.pool.get(type)
    if (!row) {
      this.pool.set(type, (row = []))
    }
    row.push({ fn, once })
    return this
  }

  /**
   * 事件解绑
   * @param type 事件名
   * @param fn 回调函数
   * @returns
   */
  public off(type: string, fn: Function) {
    const row = this.pool.get(type)
    if (row) {
      for (let i = 0; i < row.length; i++) {
        if (row[i].fn === fn) {
          row.splice(i--, 1)
        }
      }
    }
    return this
  }

  /**
   * 事件发布
   * @param type 事件名
   * @param e 回调函数的参数
   * @returns
   */
  public emit(type: string, arg: any) {
    const row = this.pool.get(type)
    if (row) {
      for (let i = 0; i < row.length; i++) {
        const { fn, once } = row[i]
        fn(arg)
        if (once) {
          row.splice(i--, 1)
        }
      }
    }
    return this
  }
}
复制代码

然后我们来看一下它的实际使用体验,我们会发现不仅没有语法提示,在传递数据上也毫无约束力

GIF 2021-7-9 22-11-12.gif

写一个有语法提示的发布订阅

经常写原生的同学应该有注意到,DOM 原素的 addEventListener 方法不仅有语法提示的,回调方法的参数还有类型限制,比如:

GIF 2021-7-9 22-26-31.gif

然后我查看了下它的声明文件

image.png

image.png

那么接下来我们就模仿 addEventListener 对我们的发布订阅类进行优化

interface EventBusItem {
  fn: Function
  once: boolean
}

interface EventBusMap {
  render: number[]
  connected: undefined
  attributeChaged: { attr: string; nval: string | null; oval: string | null }
}

export default class EventBus {
  // 此处的 keyof 原示例请查看 
  // https://www.typescriptlang.org/docs/handbook/2/keyof-types.html
  // https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
  public pool: Map<keyof EventBusMap, EventBusItem[]> = new Map()

  // 此处的 extends 原示例请查看
  // https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
  public on<K extends keyof EventBusMap>(type: K, fn: (arg: EventBusMap[K]) => void, once: boolean = false) {}

  // 此处的 extends keyof 原示例请查看
  // https://www.typescriptlang.org/docs/handbook/2/generics.html#using-type-parameters-in-generic-constraints
  public off<K extends keyof EventBusMap>(type: K, fn: (arg: EventBusMap[K]) => void) {}

  // 此处的 EventBusMap[K] 原示例请查看
  // https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
  public emit<K extends keyof EventBusMap>(type: K, arg: EventBusMap[K]) {}
}
复制代码

我们来体验一下优化后的发布订阅

GIF 2021-7-9 23-27-55.gif

让发布订阅支持自定义扩展

为了此发布订阅的通用性,我们还得继续对其进行优化

interface EventBusItem {
  fn: Function
  once: boolean
}

// 禁止在此接口(或继承此接口的接口)中使用 [key: string]: any 或 [propName: string]: any
// 如果使用必将导致提示失效,这将使定义的泛型失去意义
// 同时此接口必须要包含内容
export interface EventBusMap {
  render: number[]
  connected: undefined
  attributeChaged: { attr: string; nval: string | null; oval: string | null }
}

// 此处的 extends 原示例请查看
// https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-classes
export default class EventBus<M extends EventBusMap> {
  public pool: Map<keyof M, EventBusItem[]> = new Map()

  public on<K extends keyof M>(type: K, fn: (arg: M[K]) => void, once: boolean = false) {}

  public off<K extends keyof M>(type: K, fn: (arg: M[K]) => void) {}

  public emit<K extends keyof M>(type: K, arg: M[K]) {}
复制代码

效果预览

GIF 2021-7-9 23-58-22.gif

完整代码

interface EventItem {
  fn: Function
  once: boolean
}

// 使用 * 解决此接口不能为空的问题
export interface EventsMap {
  '*': any
}

// 将回调函数的类型进行抽离
type EventFN<E extends EventsMap, K extends keyof E> = (e: E[K]) => void

/**
 * 通用发布订阅管理器
 * @example
 * // --------- 常规使用 ---------
 * const events = new Events<EventsMap>()
 * events.on('render', function(e) {
 *   // e 的类型将自动判定为 HTMLAst
 * })
 *
 * // ----- 添加自定义事件类型 -----
 * interface CumtomEventsMap extends EventsMap {
 *    click: MouseEvent
 * }
 * const events = new Events<CumtomEventsMap>()
 * events.on('click', function(e) {
 *   // e 的类型将自动判定为 MouseEvent
 * })
 */
export default class Events<E extends EventsMap> {
  public pool: Map<keyof E, EventItem[]> = new Map()

  /**
   * 事件绑定
   * @param type 事件名
   * @param fn 回调函数
   * @param once 回调函数只执行一次
   * @returns
   */
  public on<K extends keyof E>(type: K, fn: EventFN<E, K>, once: boolean = false) {
    let row = this.pool.get(type)
    if (!row) {
      this.pool.set(type, (row = []))
    }
    row.push({ fn, once })
    return this
  }

  /**
   * 事件绑定(once 模式)
   * @param type 事件名
   * @param fn 回调函数
   * @param once 回调函数只执行一次
   * @returns
   */
  public once<K extends keyof E>(type: K, fn: EventFN<E, K>) {
    return this.on(type, fn, true)
  }

  /**
   * 事件解绑
   * @param type 事件名
   * @param fn 回调函数
   * @returns
   */
  public off<K extends keyof E>(type: K, fn: EventFN<E, K>) {
    const row = this.pool.get(type)
    if (row) {
      for (let i = 0; i < row.length; i++) {
        if (row[i].fn === fn) {
          row.splice(i--, 1)
        }
      }
    }
    return this
  }

  /**
   * 事件发布
   * @param type 事件名
   * @param e 回调函数的参数
   * @param thisArg 回调函数的 this 指向
   * @returns
   */
  public emit<K extends keyof E>(type: K, e: E[K], thisArg?: any) {
    const row = this.pool.get(type)
    if (row) {
      for (let i = 0; i < row.length; i++) {
        if (row[i].fn === fn) {
          row.splice(i--, 1)
        }
      }
    }
    return this
  }

  /**
   * 是否对某个事件进行了回调函数的绑定
   * @param type 事件名
   * @returns
   */
  public has<K extends keyof E>(type: K) {
    return this.pool.get(type)?.length ? true : false
  }

  /**
   * 在指定 Events 实例的基础上进行类型扩展(内部的事件缓存池将进行深度克隆)
   * @param target 被扩展的 Events 实例
   * @returns
   */
  public static extends<M extends EventsMap, N extends M>(target: Events<M>) {
    const events = new Events<N>()
    target.pool.forEach(function (row, type) {
      events.pool.set(
        type,
        row.map(({ fn, once }) => ({ fn, once }))
      )
    })
    return events
  }
}
复制代码

总结

虽然实现了发布订阅的语法提示和类型检查,但是同样的,在被允许的类型之外的都将会报错,对于这一点暂时未想到好的办法…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享