beter-scroll-source-code-reading icon indicating copy to clipboard operation
beter-scroll-source-code-reading copied to clipboard

better-scroll源码分析(二):滚动相关的核心代码

Open tank0317 opened this issue 8 years ago • 1 comments

better-scroll源码分析(二):滚动相关的核心代码

上一篇文章我们分析了better-scroll的初始化过程,同时排除多余的功能,只关心如何实现滚动的初始化。今天的重点即滚动相关的核心代码,主要是手指在屏幕上滑动开始,滑动中,滑动结束三个时间点better-scroll都做了哪些事情。

这里先明确两件事:

  • 文章中提到的“滑动”都是指手指或鼠标在屏幕上的滑动,提到“滚动”都是指手指滑动后页面跟着上下/左右滚动。
  • 文章中提到惯性滚动,指的是,手指滑动结束后,页面依然会随着原来的滚动方向继续滚动的现象。

在进入主题之前,先做点准备工作。

  • 了解scrollTo()方法的实现
  • 了解自定义事件的实现

scrollTo()方法是模拟滚动的直接实现者,其他的工作主要是在计算要滚动到哪里,或者为计算滚动到哪里做准备,然而真正的滚动操作是scrollTo()方法承担的。另外,在滚动过程的三个时间节点会分别触发一些自定义事件,用户在使用better-sroll过程中可以通过订阅这些事件完成一些重要工作。因此这里也会简单介绍一下自定义事件的实现。

scrolTo()方法的实现

scrollTo()函数内部有两个主要操作_translate(x, y)_startProbe()。其他的操作主要是设置过渡动画时间,设置过渡动画加速度曲线,以及为了兼容不同浏览器设置transition前缀等操作,这些我们暂不关心,只看主要操作。

  BScroll.prototype.scrollTo = function (x, y, time = 0, easing = ease.bounce) {
    this.isInTransition = this.options.useTransition && time > 0 && (x !== this.x || y !== this.y)
    
    //如果过渡时间为0或者使用transition
    if (!time || this.options.useTransition) {
      this._transitionProperty() // 设置使用transform
      this._transitionTimingFunction(easing.style) // 设置过渡动画加速度曲线,暂不关心
      this._transitionTime(time) // 设置过渡时间
      this._translate(x, y) // 滚动的最直接实现者

      if (time && this.options.probeType === 3) { //如果存在过渡动画(time>0即有动画),且指定过渡过程中也要派发"scroll"事件
        this._startProbe() //实现在浏览器每次刷新时派发"scroll"事件
      }

      if (this.options.wheel) { // 实现picker组件相关
        ... //不关心
      }
    } else {
        //如果不支持使用transition会使用requestAnimationFrame来实现滚动动画,暂时不关心,只关心使用transition的情况
      this._animate(x, y, time, easing.fn) 
    }
  }

_translate()方法中主要做了两件事:设置tranform,实现元素位置变化;记录滚动位置。如果只有_translate()方法会实现页面位置变化,但不会有过渡动画。

  BScroll.prototype._translate = function (x, y) {
    if (this.options.useTransform) {
      //主要操作
      this.scrollerStyle[style.transform] = `translate(${x}px,${y}px)${this.translateZ}` 
    } else {
      x = Math.round(x)
      y = Math.round(y)
      this.scrollerStyle.left = `${x}px` // 使用绝对定位控制位置,我们主要关心transform的情况
      this.scrollerStyle.top = `${y}px`
    }

    if (this.options.wheel) { // picker相关
      ... //暂不关心
    }

    //记录现在滚动的位置
    this.x = x
    this.y = y
    
    if (this.indicators) { // scrollbar相关
      ... // 暂不关心
    }
  }

然后看一下_startProbe()方法。这里主要是通过requestAnimationFrame请求浏览器在每次刷新时执行回调probe()派发"scroll"事件,同时在回调中判断,如果过渡状态结束,不再请求执行回调。这里还调用了getComputedPosition()方法,主要是用于获取此时的滚动位置,有兴趣的可以自己看下实现过程,这里不再介绍。

  BScroll.prototype._startProbe = function () {
    cancelAnimationFrame(this.probeTimer)
    this.probeTimer = requestAnimationFrame(probe) 

    let me = this

    function probe() {
      let pos = me.getComputedPosition() // 调用window.getComputedStyle获取此时transform属性中x, y值
      me.trigger('scroll', pos)
      if (!me.isInTransition) {
        return
      }
      me.probeTimer = requestAnimationFrame(probe)
    }
  }

自定义事件

实际上自定义事件的实现就是一个典型的发布-订阅模式的实现过程。如果不了解的同学可以直接看下面的代码,同时自己要能够独立实现这个过程。很经典很实用。另外,关于设计模式,推荐一本书JavaScript设计模式与开发实践

export function eventMixin(BScroll) {
  BScroll.prototype.on = function (type, fn, context = this) {
    if (!this._events[type]) {
      this._events[type] = []
    }

    this._events[type].push([fn, context])
  }

  BScroll.prototype.once = function (type, fn, context = this) {
    function magic() {
      this.off(type, magic)

      fn.apply(context, arguments)
    }
    // To expose the corresponding function method in order to execute the off method
    magic.fn = fn

    this.on(type, magic)
  }

  BScroll.prototype.off = function (type, fn) {
    let _events = this._events[type]
    if (!_events) {
      return
    }

    let count = _events.length
    while (count--) {
      if (_events[count][0] === fn || (_events[count][0] && _events[count][0].fn === fn)) {
        _events[count][0] = undefined
      }
    }
  }

  BScroll.prototype.trigger = function (type) {
    let events = this._events[type]
    if (!events) {
      return
    }

    let len = events.length
    let eventsCopy = [...events]
    for (let i = 0; i < len; i++) {
      let event = eventsCopy[i]
      let [fn, context] = event
      if (fn) {
        fn.apply(context, [].slice.call(arguments, 1))
      }
    }
  }
}

_start()执行过程

从这里开始,就进入我们今天的核心环节:弄明白手指在屏幕上滑动开始,滑动中,滑动结束三个时间点better-scroll都做了哪些事情

首先回顾一下,better-scroll在初始化的时候,监听了一些原生事件:

  BScroll.prototype.handleEvent = function (e) {
    switch (e.type) {
      case 'touchstart':
      case 'mousedown':
        this._start(e)
        break
      case 'touchmove':
      case 'mousemove':
        this._move(e)
        break
      case 'touchend':
      case 'mouseup':
      case 'touchcancel':
      case 'mousecancel':
        this._end(e)
        break
      ... //其他不关心
    }
  }

从上面可以看到,滑动开始,滑动中和滑动结束分别对应了_start(), _move(), _end()三个回调函数。所以接下来我们的工作就是去看看这三个函数做了哪些事情。

先看一下_start()方法。主要是为实现页面滚动而做的初始化工作。

  BScroll.prototype._start = function (e) {
    let _eventType = eventType[e.type]
    if (_eventType !== TOUCH_EVENT) {
      if (e.button !== 0) { //不是鼠标左键,直接退出
        return
      }
    }
    // 未开启滚动,已销毁都直接退出
    if (!this.enabled || this.destroyed || (this.initiated && this.initiated !== _eventType)) {
      return
    }
    this.initiated = _eventType

    if (this.options.preventDefault && !preventDefaultException(e.target, this.options.preventDefaultException)) {
      e.preventDefault() //阻止浏览器默认行为
    }

    //初始化数据
    this.moved = false
    this.distX = 0 // 手指或鼠标的滑动距离
    this.distY = 0
    this.directionX = 0 // 滑动方向(左右) -1 表示从左向右滑,1 表示从右向左滑,0 表示没有滑动。
    this.directionY = 0 // 滑动方向(上下) -1 表示从上往下滑,1 表示从下往上滑,0 表示没有滑动。
    this.movingDirectionX = 0 // 滑动过程中的方向(左右) -1 表示从左向右滑,1 表示从右向左滑,0 表示没有滑动。
    this.movingDirectionY = 0 // scroll 滑动过程中的方向(上下)-1 表示从上往下滑,1 表示从下往上滑,0 表示没有滑动。
    this.directionLocked = 0 // 

    this._transitionTime() // 初始化过渡时间为0
    this.startTime = getNow() // 初始化开始时间

    if (this.options.wheel) { // 暂不关心
      this.target = e.target
    }

    this.stop() // 如果此时处于惯性滚动过程中,停止惯性滚动。

    let point = e.touches ? e.touches[0] : e

    this.startX = this.x // 滚动的开始位置
    this.startY = this.y
    this.absStartX = this.x 
    this.absStartY = this.y
    this.pointX = point.pageX // 鼠标相对于页面位置
    this.pointY = point.pageY

    this.trigger('beforeScrollStart')
  }

除了一些准备工作外,这里有个stop()方法。我们看一下它做了什么?

  BScroll.prototype.stop = function () {
    if (this.options.useTransition && this.isInTransition) {
      this.isInTransition = false 
      let pos = this.getComputedPosition() //获取当前位置
      this._translate(pos.x, pos.y) //根据当前位置,设置页面最终滚动位置。
      if (this.options.wheel) { 
        ... //暂不关心
      } else {
        this.trigger('scrollEnd', { //触发"scrollEnd"事件
          x: this.x,
          y: this.y
        })
      }
      this.stopFromTransition = true
    } else if (!this.options.useTransition && this.isAnimating) {
      ... // 暂不关心
    }
  }

如果没有处于惯性滚动过程中,这个方法什么都不会做(我们只考虑使用transition的情况)。 如果处于惯性滚动过程中,会获取元素当前位置,重新调用_translate()设置最终滚动位置为当前位置。为什么说是重新调用?因为由于过渡动画的存在,上一次的滚动还没有结束,即还没有滚动要目标位置,我们现在又重新设置目标位置为当前位置。同时调用stop()前调用了_transitionTime()设置过渡时间为0,所以过渡动画会马上结束。最终的效果就是,当我们手指接触屏幕的时候,原来的惯性滚动马上停止。

_move()执行过程

当我们的手指在屏幕上滑动过程中,better-scroll都在做什么?大致总结为:

  1. 计算鼠标或者手指的滑动距离
  2. 根据横向纵向滑动距离锁定一个滚动方向(前提freeScroll === false)
  3. 根据滑动距离计算滚动距离,并执行滚动
  4. 派发"scroll"事件
  5. 判断手指滑动到视窗(visual viewport)边缘,执行_end()

每个步骤相应的操作做了标识,直接看代码注释吧。

  BScroll.prototype._move = function (e) {
    ... // 是否开启滚动,是否已销毁的判断,阻止浏览器默认行为操作

    /************下面部分计算滑动距离************/
    let point = e.touches ? e.touches[0] : e
    let deltaX = point.pageX - this.pointX
    let deltaY = point.pageY - this.pointY

    this.pointX = point.pageX // 缓存手指/鼠标坐标
    this.pointY = point.pageY

    this.distX += deltaX
    this.distY += deltaY

    let absDistX = Math.abs(this.distX)
    let absDistY = Math.abs(this.distY)
    /************上面部分计算滑动距离************/

    let timestamp = getNow()

    // 只有滑动超过一定距离才会认为是一次有效的滑动
    // todo 这里不应该是this.startTime? 
    if (timestamp - this.endTime > this.options.momentumLimitTime && (absDistY < this.options.momentumLimitDistance && absDistX < this.options.momentumLimitDistance)) {
      return
    }
    
    /****************下面部分锁定滚动方向******************************/
    // 如果没有开启freeScroll,会根据手指滑动方向,锁定一个滚动方向
    if (!this.directionLocked && !this.options.freeScroll) {
      if (absDistX > absDistY + this.options.directionLockThreshold) {
        this.directionLocked = 'h'		// lock horizontally
      } else if (absDistY >= absDistX + this.options.directionLockThreshold) {
        this.directionLocked = 'v'		// lock vertically
      } else {
        this.directionLocked = 'n'		// no lock
      }
    }

    if (this.directionLocked === 'h') {
      if (this.options.eventPassthrough === 'vertical') {
        e.preventDefault() //取消滚动锁定方向的浏览器默认行为
      } else if (this.options.eventPassthrough === 'horizontal') {
        this.initiated = false //如果滚动锁定方向和eventPassthrough方向相同,不允许滚动,并直接退出
        return
      }
      deltaY = 0 // 滚动锁定的另一个方向,手指滑动距离重置为0
    } else if (this.directionLocked === 'v') {
      if (this.options.eventPassthrough === 'horizontal') {
        e.preventDefault()
      } else if (this.options.eventPassthrough === 'vertical') {
        this.initiated = false
        return
      }
      deltaX = 0
    }
    /****************上面部分锁定滚动方向******************************/

    /************以下部分计算新滚动坐标**********/
    deltaX = this.hasHorizontalScroll ? deltaX : 0
    deltaY = this.hasVerticalScroll ? deltaY : 0
    this.movingDirectionX = deltaX > 0 ? DIRECTION_RIGHT : deltaX < 0 ? DIRECTION_LEFT : 0
    this.movingDirectionY = deltaY > 0 ? DIRECTION_DOWN : deltaY < 0 ? DIRECTION_UP : 0

    let newX = this.x + deltaX
    let newY = this.y + deltaY

    // Slow down or stop if outside of the boundaries
    if (newX > 0 || newX < this.maxScrollX) {
      if (this.options.bounce) {
        newX = this.x + deltaX / 3
      } else {
        newX = newX > 0 ? 0 : this.maxScrollX
      }
    }
    if (newY > 0 || newY < this.maxScrollY) {
      if (this.options.bounce) {
        newY = this.y + deltaY / 3
      } else {
        newY = newY > 0 ? 0 : this.maxScrollY
      }
    }

    if (!this.moved) {
      this.moved = true
      this.trigger('scrollStart') //出发"scrollStart"事件
    }

    this._translate(newX, newY) //滚动到指定位置
    /***********以上部分计算新的滚动坐标**********/

    /***********下面这部分负责滑动过程中派发"scroll"事件**********/
    if (timestamp - this.startTime > this.options.momentumLimitTime) {
      this.startTime = timestamp
      this.startX = this.x
      this.startY = this.y

      if (this.options.probeType === 1) {
        this.trigger('scroll', {
          x: this.x,
          y: this.y
        })
      }
    }

    if (this.options.probeType > 1) {
      this.trigger('scroll', {
        x: this.x,
        y: this.y
      })
    }
    /***********上面这部分负责滑动过程中派发"scroll"事件**********/

    /**********下面是如果滑动到视窗的四个边缘执行滑动结束****************/
    let scrollLeft = document.documentElement.scrollLeft || window.pageXOffset || document.body.scrollLeft
    let scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop

    let pX = this.pointX - scrollLeft //距离可视窗口左边缘距离
    let pY = this.pointY - scrollTop //距离可视窗口上边缘距离

    if (pX > document.documentElement.clientWidth - this.options.momentumLimitDistance || pX < this.options.momentumLimitDistance || pY < this.options.momentumLimitDistance || pY > document.documentElement.clientHeight - this.options.momentumLimitDistance
    ) {
      this._end(e) // 滑动结束
    }
    /**********上面是如果滑动到视窗的四个边缘执行滑动结束****************/
  }

_end()执行过程

_end()方法主要做了哪些事情?

  1. 如果滚动超出边界,重置滚动位置,并退出函数
  2. 如果是一次轻拂操作,退出函数
  3. 计算整个滑动过程的时间和距离
  4. 判断是否满足惯性滚动条件,并计算惯性滚动距离和时间
  5. 执行惯性滚动,并退出
  BScroll.prototype._end = function (e) {
    ... // 是否开启滚动,是否已销毁的判断,阻止浏览器默认行为操作

    this.trigger('touchEnd', {
      x: this.x,
      y: this.y
    })

    this.isInTransition = false //初始化为false
    //todo 这里为什么没有获取滑动结束的指针坐标,不懂。
    // ensures that the last position is rounded
    let newX = Math.round(this.x)
    let newY = Math.round(this.y)

    let deltaX = newX - this.absStartX //todo 这部分没用?
    let deltaY = newY - this.absStartY
    this.directionX = deltaX > 0 ? DIRECTION_RIGHT : deltaX < 0 ? DIRECTION_LEFT : 0
    this.directionY = deltaY > 0 ? DIRECTION_DOWN : deltaY < 0 ? DIRECTION_UP : 0

    // if configure pull down refresh, check it first
    if (this.options.pullDownRefresh && this._checkPullDown()) { // pullDown相关,暂不关心
      return
    }

    // check if it is a click operation
    if (this._checkClick(e)) { // 暂不关心,关于点击事件,我们后续文章会介绍
      this.trigger('scrollCancel')
      return
    }

    // 执行resetPositon,如果超出边界会重置滚动位置,文章开头有介绍
    if (this.resetPosition(this.options.bounceTime, ease.bounce)) {
      return
    }

    this.scrollTo(newX, newY) //这个操作是必须的吗?

    /***************下面这部分主要是_end()方法核心********************/

    //计算整个滑动过程的距离和时间
    this.endTime = getNow() 
    let duration = this.endTime - this.startTime
    let absDistX = Math.abs(newX - this.startX)
    let absDistY = Math.abs(newY - this.startY)

    // 判断是否是一次轻拂操作
    if (this._events.flick && duration < this.options.flickLimitTime && absDistX < this.options.flickLimitDistance && absDistY < this.options.flickLimitDistance) {
      this.trigger('flick')
      return
    }

    let time = 0
    // 判断是否开始惯性滚动,利用momentum方法计算惯性滚动的距离和滚动时间,得到新的滚动位置
    if (this.options.momentum && duration < this.options.momentumLimitTime && (absDistY > this.options.momentumLimitDistance || absDistX > this.options.momentumLimitDistance)) {
      let momentumX = this.hasHorizontalScroll ? momentum(this.x, this.startX, duration, this.maxScrollX, this.options.bounce ? this.wrapperWidth : 0, this.options)
        : {destination: newX, duration: 0}
      let momentumY = this.hasVerticalScroll ? momentum(this.y, this.startY, duration, this.maxScrollY, this.options.bounce ? this.wrapperHeight : 0, this.options)
        : {destination: newY, duration: 0}
      newX = momentumX.destination
      newY = momentumY.destination
      time = Math.max(momentumX.duration, momentumY.duration)
      this.isInTransition = true
    } else {
      if (this.options.wheel) {
        ... //不关心
      }
    }

    let easing = ease.swipe
    if (this.options.snap) {
      ... // 不关心
    }

    if (newX !== this.x || newY !== this.y) {
      // change easing function when scroller goes out of the boundaries
      if (newX > 0 || newX < this.maxScrollX || newY > 0 || newY < this.maxScrollY) {
        easing = ease.swipeBounce
      }
      this.scrollTo(newX, newY, time, easing) //执行惯性滚动过渡动画
      return //这里直接退出了,不会触发"scrollEnd"事件
    }
    /***************上面这部分是_end()方法核心********************/

    if (this.options.wheel) { // 不关心
      this.selectedIndex = Math.round(Math.abs(this.y / this.itemHeight))
    }
    this.trigger('scrollEnd', { //什么情况下会触发"scrollEnd"事件??
      x: this.x,
      y: this.y
    })
  }

上面即_end()方法执行过程的大致内容,当然我们今天最关心的还是这里面滚性滚动的部分。可以看到在滑动结束的时候,better-scroll会判断是否需要惯性滚动,计算要滚动到哪里。至于怎么计算的,可以查看src/scroll/momentum.js文件。

另外,滚动结束后会触发"scrollEnd"事件吗?可以看到better-scroll初始化的时候,有监听"transitionend"事件:

  BScroll.prototype.handleEvent = function (e) {
    switch (e.type) {
      ... 
      case 'transitionend':
      case 'webkitTransitionEnd':
      case 'oTransitionEnd':
      case 'MSTransitionEnd':
        this._transitionEnd(e)
        break
      ...
    }
  }
  BScroll.prototype._transitionEnd = function (e) {
    if (e.target !== this.scroller || !this.isInTransition) {
      return
    }

    this._transitionTime() //默认设置过渡时间0
    if (!this.pulling && !this.resetPosition(this.options.bounceTime, ease.bounce)) {
      this.isInTransition = false
      this.trigger('scrollEnd', {
        x: this.x,
        y: this.y
      })
    }
  }

滚动结束后还会执行resetPosition(),如果滑动结束后,元素滚动距离已经超出边界值,即上一篇提到的dis < maxScrollX/Y或者dis > 0,这时会重置元素位置(带有过渡动画)。这也是我们看到的,当better-scroll惯性滚动超出边界值时,会有一个回弹的效果。同时,当重置位置动画结束后会再次触发"transitionend"事件,然后再次执行_transitionEnd()方法,此时会触发"scrollEnd"事件。

另外还有个疑问?

  • 为什么一进函数的时候,没有获取滑动结束的手指坐标?而是利用上一次保存的滑动中的指针坐标来计算滚动位置?虽然最后一个滑动中的指针坐标和滑动结束的指针坐标一般情况下不会有很大误差。

这个我还没有想明白,先放一放。

好了到这里,better-scroll关于滚动的核心代码我们基本都已经看过了。现在我们对手指在屏幕上滑动时better-scroll都做了什么有了大致了解。下一次,我们会看一下,better-scroll如何实现上拉加载,下拉刷新的。

tank0317 avatar Dec 27 '17 12:12 tank0317

scrollEnd 事件我可以捕获到,但是为啥拿不到{x: '', y: ''}对象?

this.scroller.on('scrollEnd', x => {
          console.log(x)
        })
output: undefined

zhangcunxin avatar Jun 11 '20 11:06 zhangcunxin