BetterScroll2.0 TS类型推导实践

avatar
@滴滴出行

作者:嵇智

如何在 BetterScroll 2.0 里面合理使用 TypeScript,并且能够做到友好的 IDE 智能提示,在以 class 为基础的架构中,我们费了相当多的功夫让 TypeScript 提示更智能、更完善。在这个过程中,我们要解决的主要是以下三个问题:

  • 是否能兼容 1.x 的 API 或者属性,将内部职能类的属性或者方法代理至 BS 实例

  • 是否能根据引用的 Plugin,来动态提示实例化 BS 的选项对象

    如果引入插件,那就必须出现提示:

    如果不引入插件,不希望有对应的选项配置提示:

  • 是否能根据引用的 Plugin,来提示插件代理至 BS 实例上的方法

    如果引入插件,需要智能提示插件暴露在 BS 实例上的方法:

既然知道了问题所在,我们便开始对症下药。

属性与方法代理

BScroll 的内部结构是

BScrollConstructor
  |
  |--Scroller
      |
      |--ActionsHandler
      |
      |--Translater
      |
      |--Animater
      |
      |--Behavior
      |
      |--ScrollerActions

要将以下内部类的属性代理至 bs 实例上来兼容 1.x。

let bs = new BScroll('.wrapper', {})

bs.(x|y) -> bs.behavior.currentPos
bs.(hasHorizontalScroll|hasVerticalScroll) -> bs.behavior.hasScroll
bs.pending -> bs.animater.pending

对于 BScrollConstructor 这个类,其实是没有 x, y 等实例属性的,在 TypeScript 里面会报错。

那么在 TypeScript 里面我采用的解决方案就是穷举的方案。

// 首先声明需要暴露至 BScroll 实例上的属性或者方法
export interface BScrollInstance
  extends ExposedAPIByScroller,
    ExposedAPIByAnimater {
  [key: string]: any
  x: Behavior['currentPos']
  y: Behavior['currentPos']
  hasHorizontalScroll: Behavior['hasScroll']
  hasVerticalScroll: Behavior['hasScroll']
  scrollerWidth: Behavior['contentSize']
  scrollerHeight: Behavior['contentSize']
  maxScrollX: Behavior['maxScrollPos']
  maxScrollY: Behavior['maxScrollPos']
  minScrollX: Behavior['minScrollPos']
  minScrollY: Behavior['minScrollPos']
  movingDirectionX: Behavior['movingDirection']
  movingDirectionY: Behavior['movingDirection']
  directionX: Behavior['direction']
  directionY: Behavior['direction']
  enabled: Actions['enabled']
  pending: Animater['pending']
}

export interface ExposedAPIByScroller {
  scrollTo(
    x: number,
    y: number,
    time?: number,
    easing?: EaseItem,
    extraTransform?: { start: object; end: object }
  ): void
  scrollBy(
    deltaX: number,
    deltaY: number,
    time?: number,
    easing?: EaseItem
  ): void
  scrollToElement(
    el: HTMLElement | string,
    time: number,
    offsetX: number | boolean,
    offsetY: number | boolean,
    easing?: EaseItem
  ): void
  resetPosition(time?: number, easing?: EaseItem): boolean
}

// 声明 BScroll 这个类,也就是用来 new BScrollConstructor()
class BScrollConstructor extends EventEmitter {
    static plugins: PluginItem[];
    static pluginsMap: PluginsMap;
    scroller: Scroller;
    options: OptionsConstructor;
    hooks: EventEmitter;
    plugins: {
        [name: string]: any;
    };
    wrapper: HTMLElement;
    content: HTMLElement;
    [key: string]: any;
    static use(ctor: PluginCtor): typeof BScrollConstructor;
    constructor(el: ElementParam, options?: Options);
    setContent(wrapper: MountedBScrollHTMLElement): {
        valid: boolean;
        contentChanged: boolean;
    };
    private init;
    private applyPlugins;
    private handleAutoBlur;
    private eventBubbling;
    private refreshWithoutReset;
    proxy(propertiesConfig: PropertyConfig[]): void;
    refresh(): void;
    enable(): void;
    disable(): void;
    destroy(): void;
    eventRegister(names: string[]): void;
}

// 接着通过 extends 关键字来扩展 BScrollConstructor 的属性和方法
export interface BScrollConstructor extends BScrollInstance {}

// 因此就可以保证如下的语法不会在 TypeScript 里面报错
let bs = new BScrollConstructor('.wrapper', {})
console.log(bs.x) // 不报错
console.log(bs.y) // 不报错
bs.scrollTo(0, -200, 300) // 不报错

这里有一个比较有趣的点,就是

// 为啥需要 ExposedAPIByScroller?(方案一,也就是当前方案)
export interface BScrollInstance extends ExposedAPIByScroller {}

// 而不是直接像其他属性一样,罗列在 BScrollInstance 的类型声明?(方案二,也就是早期方案)
export interface BScrollInstance {
  scrollTo: Scroller['scrollTo']
  scrollBy: Scroller['scrollBy']
  scrollToElement: Scroller['scrollToElement']
  resetPosition: Scroller['resetPosition']
}

其实最开始采用的就是下面的方法,但是对于 IDE 的提示会有一点区别。

  1. 方案一

  1. 方案二

区别就在于,对于 IDE,一个是提示成方法,一个是提示成属性,但是它本来就是方法,提示成属性会显得很怪异,因此我花费了一些力气,发现方案一的这种方式更加标准,实现了更友好的提示。

动态选项提示

BetterScroll v2 是根据传入的配置项来决定是否实例化 Plugin,所以我们希望在引入插件的时候,能够在键入对应选项对象的时候,出现智能提示,例如:

如何根据引用一个库,就能确定选项对象就得存在这个 key 呢?目前唯一的解决方式就是 module-augmentation,举个例子,很好理解。

// observable.ts
export class Observable<T> {
  // ... implementation left as an exercise for the reader ...
}

// map.ts
import { Observable } from "./observable";
// 增强 Observable 原型对象上的 map 类型声明
declare module "./observable" {
  interface Observable<T> {
    map<U>(f: (x: T) => U): Observable<U>;
  }
}
Observable.prototype.map = function (f) {
  
};

// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
// 使用它
o.map((x) => x.toFixed());

正因为这个功能,赋予了我们更多的操作空间,我们得从 BetterScrollCore 以及它的 Plugin 双管齐下。

/* BetterScrollCore */

// 先声明最基本的 DefOptions 类型,这个声明是不包括插件的配置项的
export interface DefOptions {
  [key: string]: any
  startX?: number
  startY?: number
  scrollX?: boolean
  scrollY?: boolean
  freeScroll?: boolean
  directionLockThreshold?: number
  eventPassthrough?: string
  click?: boolean
  tap?: Tap
  bounce?: BounceOptions
  bounceTime?: number
  momentum?: boolean
  momentumLimitTime?: number
  momentumLimitDistance?: number
  swipeTime?: number
  swipeBounceTime?: number
  deceleration?: number
  flickLimitTime?: number
  flickLimitDistance?: number
  resizePolling?: number
  probeType?: number
  stopPropagation?: boolean
  preventDefault?: boolean
  preventDefaultException?: {
    tagName?: RegExp
    className?: RegExp
  }
  tagException?: {
    tagName?: RegExp
    className?: RegExp
  }
  HWCompositing?: boolean
  useTransition?: boolean
  bindToWrapper?: boolean
  bindToTarget?: boolean
  disableMouse?: boolean
  disableTouch?: boolean
  autoBlur?: boolean
  translateZ?: string
  dblclick?: DblclickOptions
  autoEndDistance?: number
  outOfBoundaryDampingFactor?: number
  specifiedIndexAsContent?: number
}

// 接着声明定制化的 CustomOptions,这个是供 Plugin 来做 module-augmentation 的。
export interface CustomOptions {}

// 然后暴露出去的 Options 类通过 extends 来继承上面两个类型声明
export interface Options extends DefOptions, CustomOptions {}

// 最后约束 new BScroll 的第二个参数类型
export class BScrollConstructor extends EventEmitter {
  constructor (el: ElementParam, options?: Options) {

  }
}

/* Plugin */

// 通过 module-augmentation 来一步到位
export type PullUpLoadOptions = Partial<PullUpLoadConfig> | true
export interface PullUpLoadConfig {
  threshold: number
}

declare module '@better-scroll/core' {
  interface CustomOptions {
    pullUpLoad?: PullUpLoadOptions
  }
}

插件的方法代理至 BS 实例

BetterScroll 2.x 当中每个插件都是一个 class,如果想要将插件实例上的方法代理至 BetterScroll 实例上,我们要怎么办呢,类似于:

import BScroll from '@better-scroll/core'
import PullUp from '@better-scroll/pull-up'

BScroll.use(PullUp)

const bs = new BScroll('.wrapper', {
  pullUpLoad: true
})
// finishPullUp 方法并不存在 BScroll 实例上,而是 PullUp 插件内部的方法
bs.finishPullUp()

这个问题核心在于TypeScript 里面怎样根据第二个 option 参数的配置来动态的给 class 添加成员方法或者属性的声明

答案是仅仅靠 class 是无解的

然而,我们需要转变一下思维——在 TypeScript 里面,函数是最好推导的,类似于官方 Static Property Mixins 的思路,我们需要的是一个工厂函数,接下来我们以动态选项提示为基础继续深入下去。

// 1. 我们提供一个工厂函数来产出 BS 实例
// 2. 它接收一个 泛型 O 参数,并且通过 Options & O 来约束第二个参数
  // 2.1 根据 TypeScript 强大的 infer 能力,能够逆向推导出真正传入的 options 的类型,比如
  /* let bs = createBScroll('.wrapper', {
      scrollX: true
      pullUpLoad: {
        threshold: 0.1
      }
    })
  */
  // 2.2 这个时候 O 的类型就是 
  /*
  O -> {
    scrollX: boolean,
    pullUpLoad: {
      threshold: number
    }
  }
  */
// 3. 是时候拿着 O 的类型来做编程啦
  // 3.1 ExtractAPI<O> 得到联合类型(Union)
  // 3.2 UnionToIntersection<ExtractAPI<O>>
    // 通过 UnionToIntersection 方法将 Union 类型转成 Intersection 类型
    // eg: UnionToIntersection<{ a: number } | {b: number}> = { a: number } & { b: number }
  // 3.3 利用 unknown 通过 & 符号交叉至 BScrollConstructor 实例上。
export function createBScroll<O = {}>(
  el: ElementParam,
  options?: Options & O
): BScrollConstructor & UnionToIntersection<ExtractAPI<O>> {
  const bs = new BScrollConstructor(el, options)
  return (bs as unknown) as BScrollConstructor &
    UnionToIntersection<ExtractAPI<O>>
}

// 暴露给插件,插件如果想要代理方法至 bs 实例,就需要通过
// module-augmentation 来拓展,为了 ExtractAPI 根据
// 传入的真实 option 的类型来决定是否往 bs 实例上混入方法
export interface CustomAPI {
  [key: string]: {}
}

// 为了得到插件代理至 bs 实例上的方法的类型,
// 拿 PullUp 和 PullUp 插件举例
/*
  O -> {
    scrollX: boolean,
    pullUpLoad: {
      threshold: number
    },
    pullDownRefresh: true
  }
*/
/* 得到的类型就是一个联合类型(Union)
type ret = 
{
  finishPullUp(): void
  openPullUp(config?: PullUpLoadOptions): void
  closePullUp(): void
  autoPullUpLoad(): void
} | {
  finishPullDown(): void
  openPullDown(config?: PullDownRefreshOptions): void
  closePullDown(): void
  autoPullDownRefresh(): void
}
*/
type ExtractAPI<O> = {
  [K in keyof O]: K extends string
    ? DefOptions[K] extends undefined
      ? CustomAPI[K]
      : never
    : never
}[keyof O]

// 联合类型转成交叉类型,比如
// type Inter = UnionToIntersection<{ a: number } | {b: number}>
// Inter 类型变成了 { a: number } & { b: number }
type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never

// PullUp 插件基于 CustomAPI 来拓展 API
declare module '@better-scroll/core' {
  interface CustomAPI {
    pullUpLoad: PluginAPI
  }
}
interface PluginAPI {
  finishPullUp(): void
  openPullUp(config?: PullUpLoadOptions): void
  closePullUp(): void
  autoPullUpLoad(): void
}

注意:UnionToIntersection 是一个非常实用的类型功能函数,它的原理是处于逆变位置的类型变量,可以推断成交叉类型

type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
  ? U
  : never;

type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;
//   ^ = type T1 = string
type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;
//   ^ = type T2 = never


type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type T3 = UnionToIntersection<{ a: (x: string) => void } | { b: (x: string) => void }>
/*   ^ = type T3 = {
          a: (x: string) => void;
        } & {
          b: (x: string) => void;
        }
*/
// 因为传入 UnionToIntersection 的泛型是一个联合类型,
// 所以分别会对 { a: (x: string) => void } 以及 { b: (x: string) => void } 进行类型运算,
// 对于前者,I 类型被推断成 { a: (x: string) => void },
// 对于后者,I 类型被推断成 { b: (x: string) => void },
// 再根据逆变推断成交叉类型的原理,最后 I 的类型就变成了如上 T3 的类型

至此,我们的目标已经完成了 99%,但是上面的解决方案是一个工厂函数,我们之前的使用方式是直接 new BScroll(),而不是 createBScroll(),因此我们还是得绕一绕。

// 拿到 createBScroll 类型
type createBScroll = typeof createBScroll

// 声明支持使用 new 方式调用或者直接使用工厂函数调用
export interface BScrollFactory extends createBScroll {
  new <O = {}>(el: ElementParam, options?: Options & O): BScrollConstructor &
    UnionToIntersection<ExtractAPI<O>>
}

// 类型暴露出去,供 Plugin 使用它的类型
export type BScroll<O = Options> = BScrollConstructor<O> &
  UnionToIntersection<ExtractAPI<O>>

/* 最后暴露出去的就是 BScroll,使用方式如下
  import BScroll, { createBScroll } from '@better-scroll/core'
  const bs = new BScroll('.wrapper', {})
  const bs2 = BScroll('.wrapper', {})
  const bs3 = createBScroll('.wrapper', {})
*/
export const BScroll = (createBScroll as unknown) as BScrollFactory

至此,BetterScroll 2.0 的类型声明已经完美实现了,也达到了我们的预期,相应的代码组织方式也发生了很多变化。这里面涵盖了 TypeScript 绝大部分的高阶知识,不管是泛型,逆向推断,module-augmentation,以及 Union,Intersection、分布式条件判断以及突破 class 的局限性的这种方案都是值得深入推敲的,这些知识你可以在 vuex、redux、vue-next 等库里面经常看到。

最后,如果你对我们感兴趣,欢迎投递简历,参考 滴滴网约车-业务前端招聘高级/资深工程师