结尾

TypeScript曾经进去很多年了,当初用的人也越来越多,毋庸置疑,它会越来越风行,然而我还没有用过,因为首先是我的项目上不必,其次是我对强类型并不敏感,所以纯正的光看文档看不了几分钟就心不在焉,始终就被耽误了。

然而,当初很多风行的框架都开始用TypeScript重构,很多文章的示例代码也变成TypeScript,所以这就很难堪了,你不会就看不懂,所以好了,没得选了。

既然目前我的痛点是看源码看不懂,那不如就在看源码的过程中遇到不懂的TypeScript语法再去具体理解,这样可能比单纯看文档更无效,接下来我将在浏览BetterScroll源码的同时恶补TypeScript

BetterScroll是一个针对挪动端的滚动库,应用纯JavaScript,2.0版本应用TypeScript进行了重构,通过插件化将性能进行了拆散,外围只保留根本的滚动性能。

不便起见,后续TypeScript缩写为TSBetterScroll缩写为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办法,useBS类的一个静态方法:

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能够获取文档中以后取得焦点的元素。

另外这里呈现了asTS反对的数据类型有: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'  }

pluginsBS实例上的一个属性,这样通过this.plugins.myPlugin.xxx就能拜访到你的源属性,也就可能间接通过this批改到源属性的属性值。所以setPropertygetProperty的逻辑也就很简略了:

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了一堆实例,这么多挨个关上看不出一会就得劝退,所以大抵的晓得每个类是做什么的后,咱们来简略思考一下,要能实现一个最根本的滚动大略要做一些什么事,首先必定要先获取一些根本信息,例如wrappercontent元素的尺寸信息,而后监听事件,比方触摸事件,而后判断是否须要滚动,怎么滚动,最初进行滚动,依据这个思路咱们来挨个看一下。

初始信息计算

获取和计算尺寸信息的在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)  }

滚动逻辑

以上仍只是绑定了事件,还没到滚动那一步,接下来看ScrollerActions,构造函数里调用了bindActionsHandler办法,这个办法里监听了方才actionsHandler里绑定的那些事件:

private bindActionsHandler() {    // [mouse|touch]触摸开始事件    this.actionsHandler.hooks.on(      this.actionsHandler.hooks.eventTypes.start,      (e: TouchEvent) => {        if (!this.enabled) return true        return this.handleStart(e)      }    )    // [mouse|touch]触摸中事件    this.actionsHandler.hooks.on(      this.actionsHandler.hooks.eventTypes.move,      ({ deltaX, deltaY, e}) => {        if (!this.enabled) return true        return this.handleMove(deltaX, deltaY, e)      }    )    // [mouse|touch]触摸完结事件    this.actionsHandler.hooks.on(      this.actionsHandler.hooks.eventTypes.end,      (e: TouchEvent) => {        if (!this.enabled) return true        return this.handleEnd(e)      }    )  }

接下来是下面三个事件对应的处理函数:

private handleStart(e: TouchEvent) {    // 获取触摸开始的工夫戳    const timestamp = getNow()    this.moved = false    this.startTime = timestamp    // directionLockAction次要是用来做方向锁定的,比方判断某次滑动时应该进行程度滚动还是垂直滚动等,reset办法是复位锁定的方向变量    this.directionLockAction.reset()    // start办法同样也是做一些初始化或复位工作,包含滑动的间隔、滑动方向    this.scrollBehaviorX.start()    this.scrollBehaviorY.start()    // 强制完结上次滚动    this.animater.doStop()    // 复位滚动开始的地位    this.scrollBehaviorX.resetStartPos()    this.scrollBehaviorY.resetStartPos()  }

这个办法次要是做一系列的复位工作,毕竟是开启一次新的滚动。

private handleMove(deltaX: number, deltaY: number, e: TouchEvent) {    // deltaX和deltaY记录的是move事件每次触发时和上一次的差值,getAbsDist办法是用来记录以后和触摸开始的相对间隔    const absDistX = this.scrollBehaviorX.getAbsDist(deltaX)    const absDistY = this.scrollBehaviorY.getAbsDist(deltaY)    const timestamp = getNow()    // 要么滑动间隔大于阈值,要么在上次滑动完结后又立刻滑动,否则不认为要进行滚动    /**/        private checkMomentum(absDistX: number, absDistY: number, timestamp: number) {            return (              timestamp - this.endTime > this.options.momentumLimitTime &&              absDistY < this.options.momentumLimitDistance &&              absDistX < this.options.momentumLimitDistance            )          }    /**/    if (this.checkMomentum(absDistX, absDistY, timestamp)) {      return true    }    // 这里用来依据eventPassthrough配置项来判断是否要进行锁定,保留原生滚动    // 如果本次检测到你是进行程度滚动,那么程度方向上会进行锁定,如果你这个配置设置的也是horizontal,这个办法会返回true,就相当于这次不进行模仿滚动而间接应用原生滚动,如果你传的是vertical,就会调用e.preventDefault()来阻止原生滚动    if (this.directionLockAction.checkMovingDirection(absDistX, absDistY, e)) {      this.actionsHandler.setInitiated()      return true    }    // 这个办法会把锁定的那个方向的另外一个方向的delta值设为0,即另外那个方向不进行滚动    const delta = this.directionLockAction.adjustDelta(deltaX, deltaY)    // move办法做了两件事,1是设置本次滑动的方向值,把右->左、下->上作为正向1,反之作为负向-1;2是调用阻尼办法,这个阻尼是啥意思呢,就是没到边界的话滑动的时候你能感觉到页面是跟你的手指同步滑动的,阻尼之后你就会感觉到有阻力,页面滑动变慢跟不上你的手指了:    /**/        performDampingAlgorithm(delta: number, dampingFactor: number) {            // 滑动开始的地位加上本次滑动偏移量即以后滑动到的地位            let newPos = this.currentPos + delta            // 曾经滑动到了边界            if (newPos > this.minScrollPos || newPos < this.maxScrollPos) {              if (                (newPos > this.minScrollPos && this.options.bounces[0]) ||                (newPos < this.maxScrollPos && this.options.bounces[1])              ) {                  // 阻尼原理很简略,将本次滑动的间隔乘一个小于1的小数就能够了                newPos = this.currentPos + delta * dampingFactor              } else {                  // 如果配置敞开了阻尼成果,那么本次滑动就到头了,滑不动了                newPos =                  newPos > this.minScrollPos ? this.minScrollPos : this.maxScrollPos              }            }            return newPos          }    /**/    const newX = this.scrollBehaviorX.move(delta.deltaX)    const newY = this.scrollBehaviorY.move(delta.deltaY)    // 无论是应用css3 transition还是requestAnimationFrame做动画,实际上扭转的都是css的transform属性的值,这里的translate最终调用的是上述this.translater实例的translate办法    /**/        //point:{x:10,y:10}        translate(point: TranslaterPoint) {            let transformStyle = [] as string[]            Object.keys(point).forEach((key) => {              if (!translaterMetaData[key]) {                return              }              // translateX/translateY              const transformFnName = translaterMetaData[key][0]              if (transformFnName) {                  // px                const transformFnArgUnit = translaterMetaData[key][1]                // x,y的值                const transformFnArg = point[key]                transformStyle.push(                  `${transformFnName}(${transformFnArg}${transformFnArgUnit})`                )              }            })            this.hooks.trigger(              this.hooks.eventTypes.beforeTranslate,              transformStyle,              point            )            // 赋值            this.style[style.transform as any] = transformStyle.join(' ')            this.hooks.trigger(this.hooks.eventTypes.translate, point)          }    /**/    // 能够看到间接调用这个办法是没有设置transition的值或是应用requestAnimationFrame来扭转位移,所以是没有动画的,到这里content元素就曾经会跟着你的触摸进行滚动了    this.animater.translate({      x: newX,      y: newY    })    // 这个办法次要是用来重置startTime的值以及依据probeType配置来判断如何派发scroll事件    /**/        private dispatchScroll(timestamp: number) {            // 每momentumLimitTime工夫派发一次事件            if (timestamp - this.startTime > this.options.momentumLimitTime) {              // 刷新起始工夫和地位,这个用来判断是否要进行momentum动画              this.startTime = timestamp              // updateStartPos会将元素以后滚动到的新地位作为起始地位startPos              this.scrollBehaviorX.updateStartPos()              this.scrollBehaviorY.updateStartPos()              if (this.options.probeType === Probe.Throttle) {                this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())              }            }            // 实时派发事件            if (this.options.probeType > Probe.Throttle) {              this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())            }          }    /**/    this.dispatchScroll(timestamp)  }

到这个函数内容就会跟着咱们的触摸开始滚动了,其实这样就能够完结了,然而呢,还有两件事要做,一是个别如果咱们滑动一个货色,滑动较快的时候,即便手松开了物体也还会持续滚动一会,不会你一松开它也立马停下来,所以要判断是否是疾速滑动以及如何进行这个松开后的动量动画;二是如果开启了回弹动画,这里须要判断是否要回弹。

动量动画及回弹动画

先来看触摸完结的处理函数:

private handleEnd(e: TouchEvent) {    if (this.hooks.trigger(this.hooks.eventTypes.beforeEnd, e)) {        return    }    // 调用scrollBehaviorX和scrollBehaviorY的同名办法来获取以后currentPos的值    const currentPos = this.getCurrentPos()    // 更新本次的滚动方向    this.scrollBehaviorX.updateDirection()    this.scrollBehaviorY.updateDirection()    if (this.hooks.trigger(this.hooks.eventTypes.end, e, currentPos)) {        return true    }    // 更新元素地位到完结触摸点的地位    this.animater.translate(currentPos)    // 计算最初一次区间耗时    this.endTime = getNow()    const duration = this.endTime - this.startTime    this.hooks.trigger(this.hooks.eventTypes.scrollEnd, currentPos, duration)}

这个函数就派发了几个事件,具体做了什么还要找到订阅了这几个事件的中央,那么就要回到Scroller.ts

Scroller类构造函数最初的init办法里会执行一系列事件的订阅,找到end事件的中央:

actions.hooks.on(    actions.hooks.eventTypes.end,    (e: TouchEvent, pos: TranslaterPoint) => {        this.hooks.trigger(this.hooks.eventTypes.touchEnd, pos)        if (this.hooks.trigger(this.hooks.eventTypes.end, pos)) {            return true        }        // 判断是否是点击操作        if (!actions.moved) {            this.hooks.trigger(this.hooks.eventTypes.scrollCancel)            if (this.checkClick(e)) {                return true            }        }        // 这里这里,这个就是用来判断是否越界及进行调整的办法        if (this.resetPosition(this.options.bounceTime, ease.bounce)) {            this.animater.setForceStopped(false)            return true        }    })

resetPosition办法:

resetPosition(time = 0, easing = ease.bounce) {    // checkInBoundary办法用来返回边界值及是否刚好在边界,具体逻辑看上面    const {        position: x,        inBoundary: xInBoundary,    } = this.scrollBehaviorX.checkInBoundary()    const {        position: y,        inBoundary: yInBoundary,    } = this.scrollBehaviorY.checkInBoundary()    // 如果都刚好在边界那么阐明不须要回弹    if (xInBoundary && yInBoundary) {        return false    }    // 超过边界了那么就滚回去~(诶,你怎么骂人呢),scrollTo办法详见上面    this.scrollTo(x, y, time, easing)    return true}/*scrollBehavior的相干办法*/checkInBoundary() {    const position = this.adjustPosition(this.currentPos)    // 如果边界值和本次地位一样那么阐明刚好在边界    const inBoundary = position === this.getCurrentPos()    return {        position,        inBoundary,    }}// 越界调整地位adjustPosition(pos: number) {    let roundPos = Math.round(pos)    if (        !this.hasScroll &&        !this.hooks.trigger(this.hooks.eventTypes.ignoreHasScroll)    ) {// 满足条件返回最小滚动间隔        roundPos = this.minScrollPos    } else if (roundPos > this.minScrollPos) {// 越过最小滚动间隔了则须要回弹到最小间隔        roundPos = this.minScrollPos    } else if (roundPos < this.maxScrollPos) {// 超过最大滚动间隔了则须要回弹到最大间隔        roundPos = this.maxScrollPos    }    return roundPos}/**/

上述的最初就是调用scrollTo办法进行滚动,那么接下来就来看动画相干的逻辑。

scrollTo(    x: number,    y: number,    time = 0,    easing = ease.bounce,    extraTransform = {        start: {},        end: {},    }) {    // 依据是应用transition还是requestAnimationFrame来判断是应用css cubic-bezier还是函数    /*    bounce: {        style: 'cubic-bezier(0.165, 0.84, 0.44, 1)',        fn: function(t: number) {          return 1 - --t * t * t * t        }      }    */    const easingFn = this.options.useTransition ? easing.style : easing.fn    const currentPos = this.getCurrentPos()    // 动画开始地位    const startPoint = {        x: currentPos.x,        y: currentPos.y,        ...extraTransform.start,    }    // 动画完结地位    const endPoint = {        x,        y,        ...extraTransform.end,    }    this.hooks.trigger(this.hooks.eventTypes.scrollTo, endPoint)    // 终点起点雷同当然就不须要动画了    if (isSamePoint(startPoint, endPoint)) return    // 调用动画办法    this.animater.move(startPoint, endPoint, time, easingFn)}

这个办法的最初终于调用了动画的办法,因为反对两种动画办法,所以咱们先来简略思考一下这两种的原理别离是什么。

动画

应用css3的transition来做动画是很简略的,只有设置好过渡属性transition的值,接下来扭转transform的值本人就会利用动画,transition是个简写属性,蕴含四个属性,一般来说咱们次要设置它的transition-property(指定你要利用动画的css属性名称,如transform,不设置则默认利用到所有能够利用的属性)、transition-duration(过渡工夫,必须要设置,不然为0没有过渡)、transition-timing-function(动画曲线)。

应用requestAnimationFrame的话就须要本人来设置计算每次的地位了,配合一些罕用的动画曲线函数这个也是很简略的,比方上述的函数,更多函数可拜访http://robertpenner.com/easing/:

function(t: number) {    return 1 - --t * t * t * t}

你只有把动画曾经进行了的时长和过渡工夫的比例传入,返回的值你再和本次动画的间隔相乘,即可失去此刻的位移。

接下来看具体的实现,须要先阐明的是这两个类都继承了一个基类,因为它们存在很多的独特操作。

1.css3形式

move(    startPoint: TranslaterPoint,    endPoint: TranslaterPoint,    time: number,    easingFn: string | EaseFn) {    // 设置一个pending变量,用来判断以后是否正在动画中    this.setPending(time > 0)    // 设置transition-timing-function属性    this.transitionTimingFunction(easingFn as string)    // 设置transition-property的值为transform    this.transitionProperty()    // 设置transition-duration属性    this.transitionTime(time)    // 调用上述提到过的this.translater的translate办法来设置元素的transform值    this.translate(endPoint)    // 如果工夫不存在,那么在一个事件周期里里扭转属性值不会触发transitionend事件,所以这里通过触发回流强制更新    if (!time) {        this._reflow = this.content.offsetHeight        this.hooks.trigger(this.hooks.eventTypes.move, endPoint)        this.hooks.trigger(this.hooks.eventTypes.end, endPoint)    }}

2.requestAnimationFrame形式

move(    startPoint: TranslaterPoint,    endPoint: TranslaterPoint,    time: number,    easingFn: EaseFn | string) {    // time为0间接调用translate办法设置地位就能够了    if (!time) {        this.translate(endPoint)        this.hooks.trigger(this.hooks.eventTypes.move, endPoint)        this.hooks.trigger(this.hooks.eventTypes.end, endPoint)        return    }    // 不为0再进行动画    this.animate(startPoint, endPoint, time, easingFn as EaseFn)}private animate(    startPoint: TranslaterPoint,    endPoint: TranslaterPoint,    duration: number,    easingFn: EaseFn) {    let startTime = getNow()    const destTime = startTime + duration    // 动画办法,会被requestAnimationFrame递归调用    const step = () => {        let now = getNow()        // 以后工夫大于本次动画完结的工夫示意动画完结了        if (now >= destTime) {            // 可能距目标值有一点小误差,手动设置一下进步准确度            this.translate(endPoint)            this.hooks.trigger(this.hooks.eventTypes.move, endPoint)            this.hooks.trigger(this.hooks.eventTypes.end, endPoint)            return        }        // 工夫耗时比例        now = (now - startTime) / duration        // 调用缓动函数        let easing = easingFn(now)        const newPoint = {} as TranslaterPoint        Object.keys(endPoint).forEach((key) => {            const startValue = startPoint[key]            const endValue = endPoint[key]            // 失去本次动画的指标地位            newPoint[key] = (endValue - startValue) * easing + startValue        })        // 执行滚动        this.translate(newPoint)        if (this.pending) {            this.timer = requestAnimationFrame(step)        }    }    // 设置标记位    this.setPending(true)    // 基本操作,开始新的定时器或requestAnimationFrame时先做一次革除操作    cancelAnimationFrame(this.timer)    // 开始动画    step()}

下面的代码里都只有设置pendingtrue,而没有重置为false的中央,聪慧的你肯定能想到必定是通过事件订阅在其余中央进行重置了,是的,让咱们回到Scroller.tsScroller类外面绑定了content元素的transitionend事件和订阅了end事件:

// 这是transitionend的处理函数private transitionEnd(e: TouchEvent) {    if (e.target !== this.content || !this.animater.pending) {        return    }    const animater = this.animater as Transition    // 删除transition-duration的属性值    animater.transitionTime()    // 这里也调用了resetPosition来进行边界回弹,之前是在触摸完结后的end事件调用了,因为间接调用translate办法时是不会触发transitionend事件的,以及触摸完结后可能会有回弹动画,所以这里也须要调用    if (!this.resetPosition(this.options.bounceTime, ease.bounce)) {        this.animater.setPending(false)    }}
this.animater.hooks.on(    this.animater.hooks.eventTypes.end,    (pos: TranslaterPoint) => {        // 同上,边界回弹        if (!this.resetPosition(this.options.bounceTime)) {            this.animater.setPending(false)            this.hooks.trigger(this.hooks.eventTypes.scrollEnd, pos)        }    })

当然,上述边界回弹的函数里最初动画实现后又会触发这两个事件,就又走到了resetPosition的判断逻辑,然而因为它们曾经回弹实现在边界上了,所以会间接返回false。

回弹逻辑看完了,然而动量动画还是没看到,别急,下面说了个别是当你松开手指的时候才判断是否要进行动量静止,所以回到下面的handleEnd办法,发现最初触发了一个scrollEnd事件,在Scroller里找到订阅该事件的处理函数:

actions.hooks.on(    actions.hooks.eventTypes.scrollEnd,    (pos: TranslaterPoint, duration: number) => {        // 这个duration=this.endTime - this.startTime,然而startTime在一次触摸中每超过momentumLimitTime都会进行重置的,所以不是从手指触摸到手指来到的总工夫        // 最初这段时间片段滚动的间隔        const deltaX = Math.abs(pos.x - this.scrollBehaviorX.startPos)        const deltaY = Math.abs(pos.y - this.scrollBehaviorY.startPos)        // 判断是否是轻拂动作,应该是为插件服务的,这里不论        /**/        private checkFlick(duration: number, deltaX: number, deltaY: number) {            const flickMinMovingDistance = 1 // distinguish flick from click            if (                this.hooks.events.flick.length > 1 &&                duration < this.options.flickLimitTime &&                deltaX < this.options.flickLimitDistance &&                deltaY < this.options.flickLimitDistance &&                (deltaY > flickMinMovingDistance || deltaX > flickMinMovingDistance)            ) {                return true            }        }        /**/        if (this.checkFlick(duration, deltaX, deltaY)) {            this.animater.setForceStopped(false)            this.hooks.trigger(this.hooks.eventTypes.flick)            return        }        // 判断是否进行momentum动画        if (this.momentum(pos, duration)) {            this.animater.setForceStopped(false)            return        }    })
private momentum(pos: TranslaterPoint, duration: number) {    const meta = {        time: 0,        easing: ease.swiper,        newX: pos.x,        newY: pos.y,    }    // 判断是否满足动量条件,满足则计算动量数据,也就是最初要滚动到的地位,这个办法代码较多,就不放进去了,反正做的事件时依据配置来判断是否满足动量条件,满足再依据配置判断是否在某个方向上容许回弹,最初再动用另一个办法momentum来计算动量数据,这个办法见上面    const momentumX = this.scrollBehaviorX.end(duration)    const momentumY = this.scrollBehaviorY.end(duration)    // 做一下判断    meta.newX = isUndef(momentumX.destination)        ? meta.newX    : (momentumX.destination as number)    meta.newY = isUndef(momentumY.destination)        ? meta.newY    : (momentumY.destination as number)    meta.time = Math.max(        momentumX.duration as number,        momentumY.duration as number    )    // 地位变了,那么意味着要进行动量动画    if (meta.newX !== pos.x || meta.newY !== pos.y) {        this.scrollTo(meta.newX, meta.newY, meta.time, meta.easing)        return true    }}
// 计算动量数据private momentum(    current: number,    start: number,    time: number,    lowerMargin: number,    upperMargin: number,    wrapperSize: number,    options = this.options) {    // 最初滑动的工夫片段    const distance = current - start    // 最初滑动的速度    const speed = Math.abs(distance) / time    const { deceleration, swipeBounceTime, swipeTime } = options    const momentumData = {        // 指标地位计算形式:手指松开后元素最初的地位+额定间隔        // deceleration代表减速度,默认值是0.0015,如果distance = 15px,time = 300ms,那么speed = 0.05px/ms,则speed / deceleration = 33,即从以后间隔持续滑动33px,你速度越快或deceleration设置的越小,滑动的越远        destination: current + (speed / deceleration) * (distance < 0 ? -1 : 1),        duration: swipeTime,        rate: 15,    }    // 超过最大滑动间隔    if (momentumData.destination < lowerMargin) {        // 如果用户配置容许该方向回弹,那么再次计算动量间隔,为什么??否则最多只能滚动到最大间隔        momentumData.destination = wrapperSize            ? Math.max(            lowerMargin - wrapperSize / 4,            lowerMargin - (wrapperSize / momentumData.rate) * speed        )        : lowerMargin        momentumData.duration = swipeBounceTime    } else if (momentumData.destination > upperMargin) {// 超过最小滚动间隔,同上        momentumData.destination = wrapperSize            ? Math.min(            upperMargin + wrapperSize / 4,            upperMargin + (wrapperSize / momentumData.rate) * speed        )        : upperMargin        momentumData.duration = swipeBounceTime    }    momentumData.destination = Math.round(momentumData.destination)    return momentumData}

动量逻辑其实也很简略,就是依据最初时刻的耗时和间隔来进行一下判断,再依据肯定算法来计算动量数据也就是最终要滚动到的地位,而后滚过来。

到这里,外围的滚动逻辑曾经全副完结了,最初来看一下如何强制完结transition滚动,因为requestAnimationFrame完结很简略,调用一下cancelAnimationFrame就能够了。

doStop(): boolean {    const pending = this.pending    if (pending) {        // 复位标记位        this.setPending(false)        // 获取content元素以后的translateX和translateY的值        const { x, y } = this.translater.getComputedPosition()        // 将transition-duration的值设为0        this.transitionTime()        // 设置到以后地位        this.translate({ x, y })    }    return pending}

首先获取到元素此刻的地位,而后删除过渡工夫,最初再批改目标值为此刻的地位,因为不批改,即便你把过渡工夫改回0了过渡动画依然会持续,此时你强制批改一下地位,它立马就会完结。

例行总结

因为是第一次认真的浏览一份源码,所以可能会有很多问题,通篇就像在给这个源码加正文,而且因为是凭空浏览并没有通过运行代码进行断点调试,所以难免会存在谬误。

首先说说TypeScript,后半局部根本没有再介绍过它,所以能够发现想要浏览一份TypeScript代码是并不难的,只有理解一些罕用的语法根本就没有阻碍了,然而离本人能纯熟的应用那还是存在很远的间隔,很多货色就是这样,你能够看的懂,然而你本人写就不会了,也没啥捷径,归根结底还是要多用多思考。

而后是BetterScroll,代码总体来说还是比拟清晰的,因为是插件化,所以事件机制是少不了的,长处是性能解耦,各局部独立,毛病也不言而喻,首先是每个类都有本人的事件,很多事件还是同名的,所以很容易看着看着就晕了,其次是因为事件订阅公布,很难分明的了解事件流,所以这也是比方vue更提倡通过属性来显示传递和接管。

总的来说,这个库的外围滚动是一个很简略的性能,本人实现什么都不思考的话一百多行代码可能也就够了,然而并不障碍能够将它扩大成一个功能强大的库,这样要思考的事件就比拟多了,首先要思考到各种边界状况,其次是要思考兼容性,比方css款式,可能还会遇到特定机型的bug,代码如何组织也很重要,要尽量的复用,比方BetterScroll里两种动画形式就存在很多独特操作,那么就能够把这些提取到公共的父类里,又比方程度滚动和垂直滚动必定也是大量代码都是一样的,所以也须要进行形象提炼,因为设计成插件化,所以还要思考插件的开发和集成,最初还须要欠缺的测试,所以一个优良的开源我的项目都是不容易的。