Author: Ji Zhi
We’ve done a lot of work to make TypeScript prompts smarter and more polished in the Class-based architecture. In this process, we should mainly solve the following three problems:
-
Whether it is compatible with 1.x APIS or attributes, and proxies the attributes or methods of internal functional classes to BS instances
-
Whether the BS option object can be dynamically prompted to be instantiated based on the referenced Plugin
If a plug-in is introduced, a prompt must appear:
If no plug-in is introduced, do not want to have the corresponding option configuration prompt:
-
Whether plug-ins can be prompted to proxy methods on BS instances based on the referenced Plugin
If a plug-in is introduced, you need to intelligently hint how the plug-in is exposed on the BS instance:
Now that we knew what the problem was, we began to address it.
Property and method proxies
The internal structure of BScroll is
BScrollConstructor
|
|--Scroller
|
|--ActionsHandler
|
|--Translater
|
|--Animater
|
|--Behavior
|
|--ScrollerActions
Copy the code
To proxy the properties of the following inner classes to bs instances for 1.x compatibility.
let bs = new BScroll('.wrapper'. {}) bs.(x|y) -> bs.behavior.currentPos bs.(hasHorizontalScroll|hasVerticalScroll) -> bs.behavior.hasScroll bs.pending -> bs.animater.pendingCopy the code
The BScrollConstructor class does not have instance attributes such as x, y, etc., and will report errors in TypeScript.
So the solution I use in TypeScript is the exhaustive solution.
// First declare the properties or methods that need to be exposed on the BScroll instance
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 ):voidresetPosition(time? :number, easing? : EaseItem):boolean
}
// Declare BScroll class (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;
}
// The properties and methods of BScrollConstructor are then extended via the extends keyword
export interface BScrollConstructor extends BScrollInstance {}
// Therefore, the following syntax is guaranteed not to report errors in TypeScript
let bs = new BScrollConstructor('.wrapper', {})
console.log(bs.x) / / is not an error
console.log(bs.y) / / is not an error
bs.scrollTo(0, -200.300) / / is not an error
Copy the code
One of the interesting points here is that
// Why ExposedAPIByScroller? (Plan 1, the current plan)
export interface BScrollInstance extends ExposedAPIByScroller {}
// Instead of listing the type declaration in BScrollInstance as other attributes do? (Plan 2, the early plan)
export interface BScrollInstance {
scrollTo: Scroller['scrollTo']
scrollBy: Scroller['scrollBy']
scrollToElement: Scroller['scrollToElement']
resetPosition: Scroller['resetPosition']}Copy the code
In fact, the following method is used initially, but the IDE hints are a little different.
- Plan a
- Scheme 2
The difference is that for an IDE, one is a hint as a method and one as a hint as a property, but it is a method and a hint as a property can be weird, so I did a bit of work and found that this approach of scheme 1 is more standard and provides a friendlier hint.
Dynamic option prompt
BetterScroll V2 determines whether or not to instantiate the Plugin based on the incoming configuration items. Therefore, we hope that when importing the Plugin, we will see an intelligent prompt when typing the corresponding option object. For example:
How do you know, by referring to a library, that the option object has to have that key? The only solution now is module-augmentation, for example, which is easy to understand.
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
// Enhance the Map type declaration on the Observable prototype
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>;
/ / use it
o.map((x) = > x.toFixed());
Copy the code
Because of this feature, which gives us more room to maneuver, we have to work with both BetterScrollCore and its Plugin.
/* BetterScrollCore */
// Declare the basic DefOptions type, which does not include plug-in configuration items
export interface DefOptions {
[key: string] :anystartX? :numberstartY? :numberscrollX? :booleanscrollY? :booleanfreeScroll? :booleandirectionLockThreshold? :numbereventPassthrough? :stringclick? :booleantap? : Tap bounce? : BounceOptions bounceTime? :numbermomentum? :booleanmomentumLimitTime? :numbermomentumLimitDistance? :numberswipeTime? :numberswipeBounceTime? :numberdeceleration? :numberflickLimitTime? :numberflickLimitDistance? :numberresizePolling? :numberprobeType? :numberstopPropagation? :booleanpreventDefault? :booleanpreventDefaultException? : { tagName? :RegExpclassName? :RegExp} tagException? : { tagName? :RegExpclassName? :RegExp} HWCompositing? :booleanuseTransition? :booleanbindToWrapper? :booleanbindToTarget? :booleandisableMouse? :booleandisableTouch? :booleanautoBlur? :booleantranslateZ? :stringdblclick? : DblclickOptions autoEndDistance? :numberoutOfBoundaryDampingFactor? :numberspecifiedIndexAsContent? :number
}
// Then declare the customized CustomOptions, which are available to the Plugin for module-augmentation.
export interface CustomOptions {}
// The exposed Options class then extends from the above two type declarations
export interface Options extends DefOptions, CustomOptions {}
// Finally constrain the second parameter type of new BScroll
export class BScrollConstructor extends EventEmitter {
constructor (el: ElementParam, options? : Options) {}}/* Plugin */
// They can be implemented in one step through module-augmentation
export type PullUpLoadOptions = Partial<PullUpLoadConfig> | true
export interface PullUpLoadConfig {
threshold: number
}
declare module '@better-scroll/core' {
interfaceCustomOptions { pullUpLoad? : PullUpLoadOptions } }Copy the code
The plug-in’s methods are proxied to the BS instance
BetterScroll 2.x is a class for each plugin. If you want to delegate a method from a plugin to a BetterScroll instance, do something like this:
import BScroll from '@better-scroll/core'
import PullUp from '@better-scroll/pull-up'
BScroll.use(PullUp)
const bs = new BScroll('.wrapper', {
pullUpLoad: true
})
// The finishPullUp method does not exist on the BScroll instance, but is an internal PullUp method
bs.finishPullUp()
Copy the code
The crux of the problem is how TypeScript dynamically adds member method or property declarations to a class based on the configuration of the second option parameter.
The answer is that class alone is insoluble.
However, we need to change our thinking — in TypeScript, functions are best derived, similar to the official Static Property Mixins mentality, we need a factory function, and we’ll continue with dynamic option hints.
// 1. We provide a factory function to produce the BS instance
// 2. It takes a generic O argument and constrains the second argument with Options & O
// 2.1 From TypeScript's powerful infer capability, we can infer the types of real incoming options, e.g
/* let bs = createBScroll('.wrapper', {scrollX: true pullUpLoad: {threshold: 0.1}}) */
// the type of O is 0
/* O -> { scrollX: boolean, pullUpLoad: { threshold: number } } */
// 3. It is time to program with type O
// 3.1 ExtractAPI
/ / 3.2 UnionToIntersection < ExtractAPI < O > >
// Convert the Union type to Intersection using the UnionToIntersection method
// eg: UnionToIntersection<{ a: number } | {b: number}> = { a: number } & { b: number }
// use unknown to cross over to BScrollConstructor instance.
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>>
}
// exposed to plugins that want to delegate methods to bs instances
// Module-augmentation to the ExtractAPI basis
// The type of the real option passed in determines whether methods should be mixed into the BS instance
export interface CustomAPI {
[key: string]: {}
}
// To get the type of method that the plug-in proxies on the BS instance,
// Take the PullUp and PullUp plugins as examples
/* O -> { scrollX: boolean, pullUpLoad: { threshold: number }, pullDownRefresh: true } */
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]
// Join type to cross type, for example
// type Inter = UnionToIntersection<{ a: number } | {b: number}>
// The Inter type becomes {a: number} & {b: number}
type UnionToIntersection<U> = (
U extends any ? (k: U) = > void : never
) extends (k: infer I) => void
? I
: never
// The PullUp plug-in extends the API based on the CustomAPI
declare module '@better-scroll/core' {
interface CustomAPI {
pullUpLoad: PluginAPI
}
}
interface PluginAPI {
finishPullUp(): voidopenPullUp(config? : PullUpLoadOptions):void
closePullUp(): void
autoPullUpLoad(): void
}
Copy the code
Note: UnionToIntersection is a very useful type function. It works by deducing type variables in contravariant positions into intersecting types
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; } * /
// Because the generic type passed to UnionToIntersection is a union type,
{a: (x: string) => void}; {b: (x: string) => void};
{a: (x: string) => void};
// For the latter, type I is inferred as {b: (x: string) => void},
// Finally, the type of I becomes the type of T3
Copy the code
At this point, we’re 99% of the way there, but the above solution is a factory function, and we used it directly new BScroll() instead of createBScroll(), so we’ll have to go around it.
// Get createBScroll type
type createBScroll = typeof createBScroll
// Declarations support calls using new or directly using factory functions
export interface BScrollFactory extends createBScroll {
new<O = {}>(el: ElementParam, options? : Options & O): BScrollConstructor & UnionToIntersection<ExtractAPI<O>> }// The type is exposed for Plugin to use
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
Copy the code
At this point, the BetterScroll 2.0 type declaration has been implemented perfectly and as expected, and there have been many changes in the way the code is organized. This encompasses the vast majority of TypeScript’s higher-order knowledge, which is worth examining, from genericity, backward inference, to module-augmentation, to Union, Intersection, distributed conditional judgment, and solutions that break through the limitations of classes. You can see this in libraries like Vuex, Redux, VuE-Next, etc.
Finally, if you are interested in us, please send your resume and refer to Didi business Front End Recruitment for senior/senior engineer.