BetterScroll2.0 TS類型推導實踐

做者:嵇智html

如何在 BetterScroll 2.0 裏面合理使用 TypeScript,而且可以作到友好的 IDE 智能提示,在以 class 爲基礎的架構中,咱們費了至關多的功夫讓 TypeScript 提示更智能、更完善。在這個過程當中,咱們要解決的主要是如下三個問題:前端

  • 是否能兼容 1.x 的 API 或者屬性,將內部職能類的屬性或者方法代理至 BS 實例vue

  • 是否能根據引用的 Plugin,來動態提示實例化 BS 的選項對象git

    若是引入插件,那就必須出現提示:github

    若是不引入插件,不但願有對應的選項配置提示:vuex

  • 是否能根據引用的 Plugin,來提示插件代理至 BS 實例上的方法typescript

    若是引入插件,須要智能提示插件暴露在 BS 實例上的方法:編程

既然知道了問題所在,咱們便開始對症下藥。redux

屬性與方法代理

BScroll 的內部結構是markdown

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 等庫裏面常常看到。

最後,若是你對咱們感興趣,歡迎投遞簡歷,參考 滴滴網約車-業務前端招聘高級/資深工程師

相關文章
相關標籤/搜索