BetterScroll2.0 TS类型推导实践
作者:嵇智
如何在 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 的提示会有一点区别。
- 方案一
- 方案二
区别就在于,对于 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 等库里面经常看到。
转载自:https://juejin.cn/post/6896645702421020686