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 • 0 comments

上一篇文章里我们介绍了better-scroll滚动相关的核心代码,我们现在清楚的知道当我们的手指在屏幕上滑动的时候,better-scroll在后面都在做哪些事情。

今天我们继续看一下better-scroll是如何处理特殊的滑动操作的,如上拉下拉以及点击操作。没错,在better-scroll里点击也是一种特殊的滑动操作,我们下面会解释。

跟前一篇文章相比,今天需要阅读的代码量并不多,我们先看一下上拉下拉操作。

下拉操作

在第一篇文章里我们已经介绍过better-scroll初始化过程中有一步是初始化实现滚动以外的功能。如下面我们要重点分析的上拉下拉操作。

  BScroll.prototype._initExtFeatures = function () {
    ...
    if (this.options.pullUpLoad) {
      this._initPullUp()
    }
    if (this.options.pullDownRefresh) {
      this._initPullDown()
    }
    ...
  }

接下来的工作就是看一下,_initPullUp()_initPullDown()两个方法具体做了哪些事情。我们这里先看一下_initPullDown()方法的执行过程。下拉操作相关的代码都定义在src/scroll/pulldown.js文件中,代码不多,我们全部拿过来。

import { ease } from '../util/ease'
import { DIRECTION_DOWN } from '../util/const'

export function pullDownMixin(BScroll) {
  BScroll.prototype._initPullDown = function () {
    // must watch scroll in real time
    this.options.probeType = 3
  }

  BScroll.prototype._checkPullDown = function () {
    const {threshold = 90, stop = 40} = this.options.pullDownRefresh

    // check if a real pull down action
    if (this.directionY !== DIRECTION_DOWN || this.y < threshold) {
      return false
    }

    if (!this.pulling) {
      this.pulling = true
      this.trigger('pullingDown')
    }
    this.scrollTo(this.x, stop, this.options.bounceTime, ease.bounce)

    return this.pulling
  }

  BScroll.prototype.finishPullDown = function () {
    this.pulling = false
    this.resetPosition(this.options.bounceTime, ease.bounce)
  }
}

上面看到,_initPullDown()方法中只有一句话,设置probeType配置项。这里probeType=3表示元素在滚动过渡状态依然会实时派发"scroll"事件,相关的实现方式我们在前一篇文章对scrollTo()方法分析时已经介绍过了。

该文件中还定义了_checkPullDown()finishPullDown()两个方法。

先看_checkPullDown(),该方法会判断下拉是否超过阈值,是的话则派发一次"pullingdown"事件,并且调用scollTo()方法使元素回滚到(this.x, stop)的位置,stop可以通过配置项自定义。这时滚动元素上方会空余stop高度去显示一些信息,如加载动画或文案提示等。

另外,我们在上一篇文章分析_end()方法时,有看到当手指在屏幕上滑动结束后,会调用_checkPullDown()方法。

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

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

    ...

    // if configure pull down refresh, check it first
    if (this.options.pullDownRefresh && this._checkPullDown()) { 
      return
    }
    ...
  }

最后,finishPullDown()方法中只有一个操作,即调用resetPosition()重置滚动元素的位置。(关于resetPosition()我们在第一篇文章最后有介绍)。

这里需要注意的是,当下拉操作结束后,即下拉后数据更新完,一定要调用finishPullDown()方法,使滚动元素恢复到原始位置,否则滚动元素会停留在前面提到的(this.x, stop)的位置,导致滚动元素上方多出stop高度的区域。

上拉操作

看完下拉操作,接下来是上拉。有关上拉操作的代码放在src/scroll/pullup.js文件中。

import { DIRECTION_UP } from '../util/const'

export function pullUpMixin(BScroll) {
  BScroll.prototype._initPullUp = function () {
    // must watch scroll in real time
    this.options.probeType = 3

    this.pullupWatching = false
    this._watchPullUp()
  }

  BScroll.prototype._watchPullUp = function () {
    if (this.pullupWatching) {
      return
    }
    this.pullupWatching = true
    const {threshold = 0} = this.options.pullUpLoad

    this.on('scroll', checkToEnd)

    function checkToEnd(pos) {
      if (this.movingDirectionY === DIRECTION_UP && pos.y <= (this.maxScrollY + threshold)) { //判断是否是一次上拉操作
        this.trigger('pullingUp')
        this.pullupWatching = false 
        this.off('scroll', checkToEnd)
      }
    }
  }

  BScroll.prototype.finishPullUp = function () {
    if (this.isInTransition) {
      this.once('scrollEnd', () => {
        this._watchPullUp()
      })
    } else {
      this._watchPullUp()
    }
  }
}

可以看到在_initPullUp()方法中,会强制probeType=3,同时调用_watchPullUp()方法。

_watchPullUp()方法中,会监听"scroll"事件,并执行checkEnd()方法。即在滚动中会反复判断是否存在上拉操作,如果是,则派发"pullingup"事件,同时解绑"scroll"事件的回调函数checkEnd(),避免多次重复派发"pullingup"事件。

此时由于已经解绑checkEnd()回调函数`,即之后的滚动过程中,不会再监测是否有上拉操作。这就是很多同学使用better-scroll过程中,经常遇到下面问题的原因,better-scroll只触发一次"pullingup"事件后就不再触发第二次了

因此,在上拉操作结束后,一定要记得调用finishPullUp()方法,该方法会再次调用_watchPullUp()方法,为"scroll"事件添加checkEnd()回调函数,保证之后的"pullingup"事件能够顺利触发。

-----更新----- 上面代码可能会导致上拉时触发多次“pullingUp"的bug,在V1.7.0版本中已经修改。具体修改可看该次commit

问题思考

我们已经看完了上拉下拉操作的实现,现在有一个问题,为什么上拉操作没有向下拉操作一样,放在_end()方法中,即滑动结束后去监测,而是放在"scroll"的事件回调中?

这是由于,"pullingup"事件是可以通过惯性滚动触发的。即当向上的惯性滚动过程中如果满足上拉,会触发"pullingup"事件,但是向下的惯性滚动过程中如果满足下拉条件,不应该触发"pullingdown"事件。归根结底,是两种不同需求。

惯性滚动过程中,可以触发"pullingup"事件,因此需要强制probeType=3,即过渡状态,同样会实时派发"scroll"事件,不断监测是否存在上拉。

但是既然惯性滚动不应该触发"pullingdown"事件,那么为什么下拉初始化_initPullDown()中,也要强制probeType=3??这个我还没有想明白。

点击操作

默认情况下,除了input、textarea、button、select这些元素,better-scroll会阻止浏览器的"click"事件。 另外,better-scroll提供了一个preventDefaultException配置项,允许用户手动配置哪些元素可以接收到浏览器派发的原生"click"事件。

相关代码如下:

  BScroll.prototype.handleEvent = function (e) {
    switch (e.type) {
      ...
      case 'click':
        if (this.enabled && !e._constructed) {
          if (!preventDefaultException(e.target, this.options.preventDefaultException)) {
            e.preventDefault()
            e.stopPropagation()
          }
        }
        break
    }
  }

这里的e._constructed我们一会再解释。先看下,这里调用了preventDefaultException()方法,表示不满足相应条件的话,会阻止默认的点击事件,同时阻止事件的进一步传递。因为,"click"事件是被绑定在wapper元素上的,所以,如果wapper内部的元素受到点击,"click"事件会在捕获阶段被拦截,并停止传播。

再来看下关键步骤preventDefaultException(e.target, this.options.preventDefaultException),该函数中,第二个参数是用户提供的配置项preventDefaultException,该项是一个对象。

这是一个非常有用的配置,它的 key 是 DOM 元素的属性值,value 可以是一个正则表达式。比如我们想配一个 class 名称为 test 的元素,那么配置规则为 {className:/(^|\s)test(\s|$)/}。

符合正则表达式的元素,不会被阻止"click"事件。

preventDefaultException()方法的实现如下:

export function preventDefaultException(el, exceptions) {
  for (let i in exceptions) {
    if (exceptions[i].test(el[i])) {
      return true
    }
  }
  return false
}

默认情况下,preventDefaultException的值为{ tagName: /^(INPUT|TEXTAREA|BUTTON|SELECT)$/},即默认情况下,input、textarea、button、select这些元素的"click"事件不会被浏览器阻止。所以,如果想要让某些元素的"click"事件不被拦截,可以通过配置preventDefaultException选项实现。

另外,better-scroll还提供了另一个配置项click

作用:better-scroll 默认会阻止浏览器的原生 click 事件。当设置为 true,better-scroll 会派发一个 click 事件,我们会给派发的 event 参数加一个私有属性 _constructed,值为 true。

然后我们来看下,better-scroll是如何实现派发"click"事件的。在手指滑动结束后,bettr-scroll会执行一次_checkClick()操作。

  BScroll.prototype._end = function (e) {
    ...

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

    ...

    // check if it is a click operation
    if (this._checkClick(e)) {
      this.trigger('scrollCancel')
      return
    }
  }

_checkClick()方法中会判断,如果此次滑动小于15px(即this.moved === false),会被认为是一次点击操作,然后会执行click(e)函数。(为什么是15px,其实是由momentumLimitDistance配置项决定,在上一篇文章介绍_move()函数中,只有滑动距离大于momentumLimitDistance时,才会认为是一次有效的滑动操作,才会执行this.moved = ture)

  BScroll.prototype._checkClick = function (e) {
    // when in the process of pulling down, it should not prevent click
    let preventClick = this.stopFromTransition && !this.pulling
    this.stopFromTransition = false

    // we scrolled less than 15 pixels
    if (!this.moved) {
      if (this.options.wheel) {
        ... //不关心
      } else {
        if (!preventClick) {
          if (this.options.tap) {
            // 与click()函数类似,这里不再介绍,有兴趣可以去看代码 https://github.com/ustbhuangyi/better-scroll/blob/master/src/util/dom.js#L122
            tap(e, this.options.tap) 
          }

          if (this.options.click) {
            click(e)  // 关键步骤
          }
          return true
        }
        return false
      }
    }
    return false
  }

click()函数中,better-scroll会为除了select, input, textarea外的其他元素创建一个自定义的"click"事件,该函数中的前半部分主要是做"click"事件的属性设置,整个函数关键步骤即new MouseEvent()。同时,为自定义的"click"事件添加一个_constructed标识,作为和原生"click"事件的区分。最后通过dispatchEvent()触发一次自定义的"click"事件。

export function click(e) {
  let target = e.target

  if (!(/(SELECT|INPUT|TEXTAREA)/i).test(target.tagName)) {
    let eventSource
    if (e.type === 'mouseup' || e.type === 'mousecancel') {
      eventSource = e
    } else if (e.type === 'touchend' || e.type === 'touchcancel') {
      eventSource = e.changedTouches[0]
    }
    let posSrc = {}
    if (eventSource) {
      posSrc.screenX = eventSource.screenX || 0
      posSrc.screenY = eventSource.screenY || 0
      posSrc.clientX = eventSource.clientX || 0
      posSrc.clientY = eventSource.clientY || 0
    }
    let ev
    const event = 'click'
    const bubbles = true
    // cancelable set to false in case of the conflict with fastclick
    const cancelable = false
    if (typeof MouseEvent !== 'undefined') {
      ev = new MouseEvent(event, extend({
        bubbles,
        cancelable
      }, posSrc))
    } else {
      ev = document.createEvent('Event')
      ev.initEvent(event, bubbles, cancelable)
      extend(ev, posSrc)
    }
    ev._constructed = true // 为自定义"click"事件添加 _constructed标识
    target.dispatchEvent(ev) //触发自定义click事件
  }
}

从上面的分析可以得到,如果配置项clicktrue,那么除了select, input, textarea外的其他元素都会收到better-scroll派发的带有_constructed标识的自定义"click"事件。

由于peventDefaultException默认值为{tagName: /^(INPUT|TEXTAREA|BUTTON|SELECT)$/},即input, textarea, button, select元素的都会正常的收到原生的"click"事件。所以,这时候就有一个问题,button元素在配置项clicktrue时会收到两个"click"事件,一个是原生的浏览器派发的"click"事件,一个是better-scroll派发的带有_constructed标识的"click"事件。

因此,在实际开发过程中如果需要使用"click"事件,不建议设置click配置项为true。最好通过配置preventDefaultException配置项实现。

另外,当better-scroll派发自定义"click"事件时,为什么没有把button元素排除在外??或许这里有什么深意?还是黄老师漏掉的一个bug?

更新 在v1.7.0版本中,黄老师已经修复了这个bug。以后的版本里,click配置项为true时,button元素不再会收到两次"click"事件。

结语

好了,今天的任务已经完成,我们已经清楚better-scroll是如何处理上拉下拉和点击操作的。下一篇文章我们会分析实现picker组件的相关代码。

tank0317 avatar Dec 31 '17 16:12 tank0317