BetterScroll源码阅读顺便学习TypeScript
开头
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<T>、元组、枚举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
的逻辑也就很简单了:
const setProperty = (obj, key, value) => {
let keys = key.split('.')
// 一级一级进行访问
for(let i = 0; i < keys.length - 1; i++) {
let tmp = keys[i]
if (!obj[tmp]){
obj[tmp] = {}
}
obj = obj[tmp]
}
obj[keys.pop()] = value
}
const getProperty = (obj,key) => {
const keys = key.split('.')
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]]
if (typeof obj !== 'object' || !obj) return
}
const lastKey = keys.pop()
if (typeof obj[lastKey] === 'function') {
return function () {
return obj[lastKey].apply(obj, arguments)
}
} else {
return obj[lastKey]
}
}
获取属性时如果是函数的话要特殊处理,原因是如果你这么调用的话:
let bs = new BS()
bs.xxx()// 插件的方法
xxx
方法虽然是插件的方法,但是这样调用的时候this
是指向bs
的,但是显然,this
应该指向这个插件实例才对,所以需要使用apply
来指定上下文。
除上述之外,BS
实例还有几个方法:
class BS {
// 重新计算,一般当DOM结构发生变化后需要手动调用
refresh() {
// 调用setContent方法,调用scroller实例的刷新方法,派发相关事件
}
// 启用BS
enable() {
this.scroller.enable()
this.hooks.trigger(this.hooks.eventTypes.enable)
this.trigger(this.eventTypes.enable)
}
// 禁用BS
disable() {
this.scroller.disable()
this.hooks.trigger(this.hooks.eventTypes.disable)
this.trigger(this.eventTypes.disable)
}
// 销毁BS
destroy() {
this.hooks.trigger(this.hooks.eventTypes.destroy)
this.trigger(this.eventTypes.destroy)
this.scroller.destroy()
}
// 注册事件
eventRegister(names: string[]) {
this.registerType(names)
}
}
都很简单,就不细说了,总的来说实例化BS
时大致做的事情时参数处理、设置滚动元素、实例化滚动类,代理事件及方法,接下来看核心的滚动类/scroller/Scroller.ts
。
滚动类
export interface ExposedAPI {
scrollTo(
x: number,
y: number,
time?: number,
easing?: EaseItem,
extraTransform?: { start: object; end: object }
): void
}
上述为类定义了一个接口,scrollTo
是实例的一个方法,定义了这个方法的入参及类型、返回参数。
export default class Scroller implements ExposedAPI {
constructor(
public wrapper: HTMLElement,
public content: HTMLElement,
options: BScrollOptions
) {}
}
public
关键字代表公开,public
声明的属性或方法可以在类的外部使用,对应的private
关键字代表私有的,即在类的外部不能访问,比如:
class S {
public name: string,
private age: number
}
let s = new S()
s.name// 可以访问
s.age// 报错
另外还有一个关键字protected
,声明的变量不能在类的外部使用,但是可以在继承它的子类的内部使用,所以这个关键字如果用在constructor
上,那么这个类只能被继承,自身不能被实例化。
对于上面这个示例,它把成员的声明和初始化合并在构造函数的参数里,称作参数属性:
constructor(public wrapper: HTMLElement)
class Scroller {
constructor(
public wrapper: HTMLElement,
public content: HTMLElement,
options: BScrollOptions
) {
// 注册事件
this.hooks = new EventEmitter([
// 事件...
])
// Behavior类主要用来存储管理滚动时的一些状态
this.scrollBehaviorX = new Behavior()
this.scrollBehaviorY = new Behavior()
// Translater用来获取和设置css的transform的translate属性
this.translater = new Translater()
// BS支持使用css3 transition和requestAnimationFrame两种方式来做动画,createAnimater会根据配置来创建对应类的实例
this.animater = createAnimater()
// ActionsHandler用来绑定dom事件
this.actionsHandler = new ActionsHandler()
// ScrollerActions用来做真正的滚动控制
this.actions = new ScrollerActions()
// 绑定手机的旋转事件和窗口尺寸变化事件
this.resizeRegister = new EventRegister()
// 监听content的transitionend事件
this.registerTransitionEnd()
// 监听上述类的各种事件来执行各种操作
this.init()
}
}
上面是Scroller
类简化后的构造函数,可以看到做了非常多的事情,new
了一堆实例,这么多挨个打开看不出一会就得劝退,所以大致的知道每个类是做什么的后,我们来简单思考一下,要能实现一个最基本的滚动大概要做一些什么事,首先肯定要先获取一些基本信息,例如wrapper
和content
元素的尺寸信息,然后监听事件,比如触摸事件,然后判断是否需要滚动,怎么滚动,最后进行滚动,根据这个思路我们来挨个看一下。
初始信息计算
获取和计算尺寸信息的在new Behavior
的时候,构造函数里会执行refresh
方法,我们以scrollBehaviorY
的情况来看:
refresh(content: HTMLElement) {
// size:height、position:top
const { size, position } = this.options.rect
const isWrapperStatic =
window.getComputedStyle(this.wrapper, null).position === 'static'
// wrapper的尺寸信息
const wrapperRect = getRect(this.wrapper)
// wrapper的高
this.wrapperSize = wrapperRect[size]
// 设置content元素,如果有变化则复位一些数据
this.setContent(content)
// content元素的尺寸信息
const contentRect = getRect(this.content)
// content元素的高
this.contentSize = contentRect[size]
// content距wrapper的距离
this.relativeOffset = contentRect[position]
// getRect方法里获取普通元素信息用的是offset相关属性,所以top是相对于offsetParent来说的,如果wrapper没有定位那么content的offsetParent则还要在上层继续查找,那么top就不是相对于wrapper的距离,需要减去wrapper的offsetTop
if (isWrapperStatic) {
this.relativeOffset -= wrapperRect[position]
}
// 设置边界,即可以滚动的最大和最小距离
this.computeBoundary()
// 设置默认滚动方向
this.setDirection(Direction.Default)
}
export function getRect(el: HTMLElement): DOMRect {
if (el instanceof (window as any).SVGElement) {
let rect = el.getBoundingClientRect()
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}
} else {
return {
top: el.offsetTop,
left: el.offsetLeft,
width: el.offsetWidth,
height: el.offsetHeight,
}
}
}
看一下computeBoundary
方法,这个方法主要获取了能滚动的最大距离,也就是两个边界值:
computeBoundary() {
const boundary: Boundary = {
minScrollPos: 0,// 可以理解为translateY的最小值
maxScrollPos: this.wrapperSize - this.contentSize,// 可以理解为translateY的最大值
}
// wrapper的高小于content的高,那么显然是需要滚动的
if (boundary.maxScrollPos < 0) {
// 因为content是相对于自身的位置进行偏移的,所以如果前面还有元素占了位置的话即使滚动了maxScrollPos的距离后还会有一部分是不可见的,需要继续向上滚动relativeOffset的距离
boundary.maxScrollPos -= this.relativeOffset
// 这里属实没看懂,但是一般offsetTop为0的话这里也不影响
if (this.options.specifiedIndexAsContent === 0) {
boundary.minScrollPos = -this.relativeOffset
}
}
this.minScrollPos = boundary.minScrollPos
this.maxScrollPos = boundary.maxScrollPos
// 判断是否需要滚动
this.hasScroll =
this.options.scrollable && this.maxScrollPos < this.minScrollPos
if (!this.hasScroll && this.minScrollPos < this.maxScrollPos) {
this.maxScrollPos = this.minScrollPos
this.contentSize = this.wrapperSize
}
}
首先要搞明白的是滚动是作用在content
元素上的,https://better-scroll.github.io/examples/#/core/specified-content,这个示例可以很清楚的看到,wrapper
里非content
的元素是不会动的。
事件监听处理
接下来就是监听事件,这个在ActionsHandler
里,分pc
和手机端绑定了鼠标和触摸两套事件,处理函数其实都是同一个,我们以触摸事件来看,有start
触摸开始、move
触摸中、end
触摸结束三个事件处理函数。
private start(e: TouchEvent) {
// 鼠标相关事件的type为1,触摸为2
const _eventType = eventTypeMap[e.type]
// 避免鼠标和触摸事件同时作用?
if (this.initiated && this.initiated !== _eventType) {
return
}
// 设置initiated的值
this.setInitiated(_eventType)
// 如果检查到配置了某些元素不需要响应滚动,这里直接返回
if (tagExceptionFn(e.target, this.options.tagException)) {
this.setInitiated()
return
}
// 只允许鼠标左键单击
if (_eventType === EventType.Mouse && e.button !== MouseButton.Left) return
// 这里根据配置来判断是否要阻止冒泡和阻止默认事件
this.beforeHandler(e, 'start')
// 记录触摸开始的点距页面的距离,pageX和pageY会包括页面被卷去部分的长度
let point = (e.touches ? e.touches[0] : e) as Touch
this.pointX = point.pageX
this.pointY = point.pageY
}
触摸开始事件最主要的就是记录一下触摸点的位置。
private move(e: TouchEvent) {
let point = (e.touches ? e.touches[0] : e) as Touch
// 计算触摸移动的差值
let deltaX = point.pageX - this.pointX
let deltaY = point.pageY - this.pointY
this.pointX = point.pageX
this.pointY = point.pageY
// 页面被卷去的长度
let scrollLeft =
document.documentElement.scrollLeft ||
window.pageXOffset ||
document.body.scrollLeft
let scrollTop =
document.documentElement.scrollTop ||
window.pageYOffset ||
document.body.scrollTop
// 当前触摸的位置距离视口的位置,为什么不用clientX、clientY?
let pX = this.pointX - scrollLeft
let pY = this.pointY - scrollTop
// 如果你快速滑动幅度过大的时候可能手指会滑出屏幕导致没有触发touchend事件,这里就是进行判断,当你的手指位置距离边界小于某个值时就自动调用end方法来结束本次滑动
const autoEndDistance = this.options.autoEndDistance
if (
pX > document.documentElement.clientWidth - autoEndDistance ||
pY > document.documentElement.clientHeight - autoEndDistance ||
pX < autoEndDistance ||
pY < autoEndDistance
) {
this.end(e)
}
}
触摸中的方法主要做了两件事,记录和上次滑动的差值以及满足条件自动结束滚动。
private end(e: TouchEvent) {
// 复位initiated的值,这样move事件就不会再响应
this.setInitiated()
// 派发事件
this.hooks.trigger(this.hooks.eventTypes.end, e)
}
- 点赞
- 收藏
- 关注作者
评论(0)