顺带学TS语言,一起深入解读BetterScroll的源代码
开头
TypeScript
已经出来很多年了,现在用的人也越来越多,毋庸置疑,它会越来越流行,但是我还没有用过,因为首先是项目上不用,其次是我对强类型并不敏感,所以纯粹的光看文档看不了几分钟就心不在焉,一直就被耽搁了。
但是,现在很多流行的框架都开始用TypeScript
重构,很多文章的示例代码也变成TypeScript
,所以这就很尴尬了,你不会就看不懂,所以好了,没得选了。
既然目前我的痛点是看源码看不懂,那不如就在看源码的过程中遇到不懂的TypeScript
语法再去详细了解,这样可能比单纯看文档更有效,接下来我将在阅读BetterScroll源码的同时恶补TypeScript
。
BetterScroll
是一个针对移动端的滚动库,使用纯JavaScript
,2.0版本使用TypeScript
进行了重构,通过插件化将功能进行了分离,核心只保留基本的滚动功能。
方便起见,后续TypeScript
缩写为TS
,BetterScroll
缩写为BS
。
BS
的核心功能代码在/packages/core/
文件夹下,结构如下:
index.ts
文件只用来对外暴露接口,我们从BScroll.ts
开始阅读。
入口类
interface PluginCtor { pluginName: string applyOrder?: ApplyOrder new (scroll: BScroll): any }
interface
接口用来定义值的结构,之后TS
的类型检查器就会对值进行检查,上面的PluginCtor
接口用来对BS
的插件对象结构进行定义及限制,意思为需要一个必填的字符串类型插件名称pluginName
,?
的意思为可选,可有可不有的ApplyOrder
类型的调用位置,找到ApplyOrder
的定义:
export const enum ApplyOrder { Pre = 'pre', Post = 'post' }
enum
的意思是枚举,可以定义一些带名字的常量,使用枚举可以清晰的知道可选的选项是什么,枚举支持数字枚举和字符串枚举,数字枚举还有自增的功能,上述通过const
来修饰的枚举称为常量枚举,常量枚举的特点是在编译阶段会被删除而直接内联到使用的地方。
回到接口,interface
可以为类和实例来定义接口,这里有个new
意味着这是为类定义的接口,这里我们就可以知道BS
的插件主体需要是一个类,且有两个静态属性,构造函数入参是BS
的实例,any
代表任何类型。
再往下:
interface PluginsMap { [key: string]: boolean }
这里同样是个接口定义,[key: string]
的属性称作索引签名,因为TS
会对对象字面量进行额外属性检查,即出现了接口里没有定义的属性时会认为是个错误,解决这个问题的其中一个方法就是在接口定义里增加索引签名。
type ElementParam = HTMLElement | string
type
意为类型别名,相当于给一个类型起了一个别名,不会新建类型,是一种引用关系,使用的时候和接口差不多,但是有一些细微差别。
|
代表联合类型,表示一个值可以是几种类型之一。
export interface MountedBScrollHTMLElement extends HTMLElement { isBScrollContainer?: boolean }
接口是可以继承的,继承能从一个接口里复制成员到另一个接口里,增加可重用性。
export class BScrollConstructor<O = {}> extends EventEmitter {}
<o = {}>
,<>
称为泛型,即可以支持多种类型,不限制为具体的一种,为扩展提供了可能,也比使用any
严谨,<>
就像()
一样,调用的时候传入类型,<>
里的参数来接收,<>
里的参数称为类型变量,比如下面的泛型函数:
function fn<T>(arg: T): T {} fn<Number>(1)
表示入参和返回参数的类型都是Number
,除了<>
,入参里的T
和返回参数类型的T
可以理解为是占位符。
static plugins: PluginItem[] = []
[]
代表数组类型,定义数组有两种方式:
let list: number[] = [1,2,3]// 1.元素类型后面跟上[] let list: Array<number> = [1,2,3]// 2.使用数组泛型,Array<元素类型>
所以上面的意思是定义了一个元素类型是PluginItem
的数组。
BS
使用插件需要在new BS
之前调用use
方法,use
是BS
类的一个静态方法:
class BS { static use(ctor: PluginCtor) { const name = ctor.pluginName // 插件名称检查、插件是否已经注册检查... BScrollConstructor.pluginsMap[name] = true BScrollConstructor.plugins.push({ name, applyOrder: ctor.applyOrder, ctor, }) return BScrollConstructor } }
use
方法就是简单的把插件添加到plugins
数组里。
class BS { constructor(el: ElementParam, options?: Options & O) { super([ //注册的事件名称 ]) const wrapper = getElement(el)// 获取元素 this.options = new OptionsConstructor().merge(options).process()// 参数合并 if (!this.setContent(wrapper).valid) { return } this.hooks = new EventEmitter([ // 注册的钩子名称 ]) this.init(wrapper) } }
构造函数做的事情是注册事件,获取元素,参数合并处理,参数处理里进行了环境检测及浏览器兼容工作,以及进行初始化。BS
本身继承了事件对象,实例派发的叫事件,这里又创建了一个事件对象的实例hooks
,在BS
里为了区分叫做钩子,普通用户更关注事件,而插件开发一般要更关注钩子。
setContent
函数的作用是设置BS
要处理滚动的content
,BS默认是将
wrapper的第一个子元素作为
content`,也可以通过配置参数来指定。
class BS { private init(wrapper: MountedBScrollHTMLElement) { this.wrapper = wrapper // 创建一个滚动实例 this.scroller = new Scroller(wrapper, this.content, this.options) // 事件转发 this.eventBubbling() // 自动失焦 this.handleAutoBlur() // 启用BS,并派发对应事件 this.enable() // 属性和方法代理 this.proxy(propertiesConfig) // 实例化插件,遍历BS类的plugins数组挨个进行实例化,并将插件实例以key:插件名,value:插件实例保存到BS实例的plugins对象上 this.applyPlugins() // 调用scroller实例刷新方法,并派发刷新事件 this.refreshWithoutReset(this.content) // 下面的用来设置初始滚动的位置 const { startX, startY } = this.options const position = { x: startX, y: startY, } if ( // 如果你的插件要修改初始滚动位置,那么可以监听这个事件 this.hooks.trigger(this.hooks.eventTypes.beforeInitialScrollTo, position) ) { return } this.scroller.scrollTo(position.x, position.y) } }
init
方法里做了很多事情,一一来看:
{ private eventBubbling() { bubbling(this.scroller.hooks, this, [ this.eventTypes.beforeScrollStart, // 事件... ]) } } // 事件转发 export function bubbling(source,target,events) { events.forEach(event => { let sourceEvent let targetEvent if (typeof event === 'string') { sourceEvent = targetEvent = event } else { sourceEvent = event.source targetEvent = event.target } source.on(sourceEvent, function(...args: any[]) { return target.trigger(targetEvent, ...args) }) }) }
BS实例的构造函数里注册了一系列事件,有些是scroller实例派发的,所以需要监听scroller对应的事件来派发自己注册的事件,相当于事件转发。
{ private handleAutoBlur() { if (this.options.autoBlur) { this.on(this.eventTypes.beforeScrollStart, () => { let activeElement = document.activeElement as HTMLElement if ( activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') ) { activeElement.blur() } }) } } }
配置项里有一个参数:autoBlur,如果设为true会监听即将滚动的事件来将当前页面上激活的元素(input、textarea)失去焦点,document.activeElement
可以获取文档中当前获得焦点的元素。
另外这里出现了as
,TS
支持的数据类型有:boolean、number、string、T[]|Array、元组、枚举enum、任意any、空void、undefined、null、永不存在的值的类型never、非原始类型object,有时候你会确切的知道某个值是什么类型,可能会比TS
更准确,那么可以通过as
来指明它的类型,这称作类型断言,这样TS
就不再进行判断了。
{ proxy(propertiesConfig: PropertyConfig[]) { propertiesConfig.forEach(({ key, sourceKey }) => { propertiesProxy(this, sourceKey, key) }) } }
插件会有一些自己的属性和方法,proxy
方法用来代理到BS
实例,这样可以直接通过BS
的实例访问,propertiesConfig
的定义如下:
export const propertiesConfig = [ { sourceKey: 'scroller.scrollBehaviorX.currentPos', key: 'x' }, // 其他属性和方法... ]
export function propertiesProxy(target,sourceKey,key) { sharedPropertyDefinition.get = function proxyGetter() { return getProperty(this, sourceKey) } sharedPropertyDefinition.set = function proxySetter(val) { setProperty(this, sourceKey, val) } Object.defineProperty(target, key, sharedPropertyDefinition) }
通过defineProperty
来定义属性,需要注意的是sourceKey
的格式都是需要能让BS
的实例this
通过.
能访问到源属性才行,比如这里的
this.scroller.scrollBehaviorX.currentPos
可以访问到scroller
实例的currentPos
属性,如果是一个插件的话,你的propertiesConfig
需要这样:
{ sourceKey: 'plugins.myPlugin.xxx', key: 'xxx' }
plugins
是BS
实例上的一个属性,这样通过this.plugins.myPlugin.xxx
就能访问到你的源属性,也就能够直接通过this
修改到源属性的属性值。所以setProperty
和getProperty
的逻辑也就很简单了: