做者:嵇智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 的提示會有一點區別。
區別就在於,對於 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
}
}
複製代碼
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 等庫裏面常常看到。
最後,若是你對咱們感興趣,歡迎投遞簡歷,參考 滴滴網約車-業務前端招聘高級/資深工程師。