Code Monkey home page Code Monkey logo

better-scroll-blog's People

Contributors

proc07 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

better-scroll-blog's Issues

momentum(惯性滚动实现原理)

何时进行惯性滚动?

作用:当快速在屏幕中滑动一段距离后(手指离开),根据这段滑动的时间和距离代入到公式,求出最后滚动到的位置,并进行滚动动画。

_end 方法中调用了 momentum 来计算最后的位置。

// start momentum animation if needed
/* 必须开启 momentum 配置,滑动的时间小于 momentumLimitTime,且滑动的距离要超过  momentumLimitDistance (px)位置 */
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 {
   // code...
 }
  • momentum 方法中传入了一大推参数,下面看如何使用!

momentum.js

/**
 *  current  结束位置
 *  start    开始位置
 *  time     时长
 *  lowerMargin 滚动区域 maxScroll
 *  wrapperSize 当滚动超过边缘的时候会有一小段回弹动画 (wrapperSize = bounce)
 *
 *  distance 距离
 *  speed    速度
 *  deceleration 减速度(系数) 0.001
 *  destination  目的地
 */
export function momentum(current, start, time, lowerMargin, wrapperSize, options) {
  let distance = current - start
  let speed = Math.abs(distance) / time

  let {deceleration, itemHeight, swipeBounceTime, wheel, swipeTime} = options
  let duration = swipeTime
  //  wheel: 为 picker 组件时,加大回弹系数 
  let rate = wheel ? 4 : 15
  //  公式:惯性拖拽 = 最后的位置 + 速度 / 摩擦系数 * 方向
  let destination = current + speed / deceleration * (distance < 0 ? -1 : 1)
  //  picker 组件时,将计算的位置取整
  if (wheel && itemHeight) {
    destination = Math.round(destination / itemHeight) * itemHeight
  }
  // 目的地(超过)最大的滚动范围 maxScroll
  if (destination < lowerMargin) {
    // 是否开启回弹
    destination = wrapperSize ? lowerMargin - (wrapperSize / rate * speed) : lowerMargin
    duration = swipeBounceTime
  } else if (destination > 0) {
    destination = wrapperSize ? wrapperSize / rate * speed : 0
    duration = swipeBounceTime
  }
  // 如果未触发以上两种条件(未到达边界),则使用最初计算出来的位置

  return {
    destination: Math.round(destination),
    duration
  }
}
  • 得出值后,在使用 scrollTo 进行滚动。

小结

现在,我们已经知道了这个 momentum 函数的实现,公式:惯性拖拽 = 最后的位置 + 速度 / 摩擦系数 * 方向。

observeDOM(监听DOM自动重新计算)

observeDOM

作用:会检测 scroller 内部 DOM 变化,自动调用 refresh 方法重新计算来保证滚动的正确性。它会额外增加一些性能开销,如果你能明确地知道 scroller 内部 DOM 的变化时机并手动调用 refresh 重新计算,你可以把该选项设置为 false。

MutationObserver 方法介绍

  • 用于观察DOM树结构,若发生改变时,则触发回调进行对应处理。 MO 监听DOM树变化
  • 在DOM4中定义的,在 IE 11 以下浏览器中是不支持的,源码使用 setTimeout 进行兼容。

_initDOMObserver

//  init.js 
BScroll.prototype._initDOMObserver = function () {
    // 判断浏览器支不支持 MutationObserver 方法
    if (typeof MutationObserver !== 'undefined') {
      let timer
      let observer = new MutationObserver((mutations) => {
        // 不要在过渡期间进行刷新,或者在边界之外进行刷新
        if (this._shouldNotRefresh()) {
          return
        }
        let immediateRefresh = false
        let deferredRefresh = false
        for (let i = 0; i < mutations.length; i++) {
          const mutation = mutations[i]
          // attributes 观察目标节点的属性节点(新增或删除了某个属性,以及某个属性的属性值发生了变化)
          if (mutation.type !== 'attributes') {
            immediateRefresh = true
            break
          } else {
            // dom 应该改变了
            if (mutation.target !== this.scroller) {
              deferredRefresh = true
              break
            }
          }
        }
        if (immediateRefresh) {
          this.refresh()
        } else if (deferredRefresh) {
          // attributes changes too often
          clearTimeout(timer)
          timer = setTimeout(() => {
            if (!this._shouldNotRefresh()) {
              this.refresh()
            }
          }, 60)
        }
      })
      const config = {
        attributes: true,
        childList: true,
        subtree: true
      }
      observer.observe(this.scroller, config)

      this.on('destroy', () => {
        observer.disconnect()
      })
    } else {
      // 兼容函数
      this._checkDOMUpdate()
    }
  }

_shouldNotRefresh

 BScroll.prototype._shouldNotRefresh = function () {
    let outsideBoundaries = this.x > 0 || this.x < this.maxScrollX || this.y > 0 || this.y < this.maxScrollY

    return this.isInTransition || this.stopFromTransition || outsideBoundaries
  }

_start、_move、_end(三大核心方法流程)

前言

首先介绍这三大方法之前,我们先去学习下它是如何绑定函数的(写法很精妙)。

在 init.js 中 _init 初始化方法中绑定了 _addDOMEvents 函数

BScroll.prototype._init = function (el, options) {
   // some code here...  

    this._addDOMEvents()

  // some code here...  
}

BScroll.prototype._addDOMEvents = function () {
    let eventOperation = addEvent
    this._handleDOMEvents(eventOperation)
}
BScroll.prototype._removeDOMEvents = function () {
    let eventOperation = removeEvent
    this._handleDOMEvents(eventOperation)
}

调用 _addDOMEvents 方法将全部的事件绑定到 Scroll 中。这里把 addEvent 变量传入到

_handleDOMEvents 方法中,而 _removeDOMEvents 方法也是如此。

  • 这样有个好处: 处理所需要的事件交给 _handleDOMEvents 方法就行。在外面传入 绑定 或者 解绑 指令就行。

在这里 addEvent 中却有一个新的参数,引起了我的注意,就是 passive

// dom.js 
export function addEvent(el, type, fn, capture) {
  el.addEventListener(type, fn, {passive: false, capture: !!capture})
}

export function removeEvent(el, type, fn, capture) {
  el.removeEventListener(type, fn, {passive: false, capture: !!capture})
}

我平常使用的时候,从未见过,表示很好奇,一搜索,才发现。passive监听器

  • passive: addEventListener 中浏览器不知道你有没有调用了 e.preventDefault() 方法来阻止默认事件,所以会在执行的时候进行监听,但在某些性能低的手机中可能出现卡顿的现象。为了解决这个问题 passive 参数就诞生了,设置为 true 时,浏览器不会去监听,即使你调用了也失效。
BScroll.prototype._handleDOMEvents = function (eventOperation) {
    let target = this.options.bindToWrapper ? this.wrapper : window
    eventOperation(window, 'orientationchange', this)
    eventOperation(window, 'resize', this)
    if (this.options.click) {
      eventOperation(this.wrapper, 'click', this, true)
    }
    if (!this.options.disableMouse) {
      eventOperation(this.wrapper, 'mousedown', this)
      eventOperation(target, 'mousemove', this)
      eventOperation(target, 'mousecancel', this)
      eventOperation(target, 'mouseup', this)
    }
    if (hasTouch && !this.options.disableTouch) {
      eventOperation(this.wrapper, 'touchstart', this)
      eventOperation(target, 'touchmove', this)
      eventOperation(target, 'touchcancel', this)
      eventOperation(target, 'touchend', this)
    }
    eventOperation(this.scroller, style.transitionEnd, this)
}

将所有的事件绑定到这里,eventOperation 中原本第3个参数是函数,为什么换成了 this 呢?

  • 在 addEventListener 如果绑定的是一个对象类型的话,则触发事件的时候会默认调用 this 这个对象下的一个叫 handleEvent 方法。
BScroll.prototype.handleEvent = function (e) {
    switch (e.type) {
      // code...
      case 'touchend':
      case 'mouseup':
      case 'touchcancel':
      case 'mousecancel':
        this._end(e)
        break
      case 'orientationchange':
      case 'resize':
        this._resize()
        break
      case 'transitionend':
      case 'webkitTransitionEnd':
      case 'oTransitionEnd':
      case 'MSTransitionEnd':
        this._transitionEnd(e)
        break
      // code...
      case 'wheel':
      case 'DOMMouseScroll':
      case 'mousewheel':
        this._onMouseWheel(e)
        break
    }
}
  • 这样写即可以兼容PC端、移动端、各个浏览器 事件,又减少了重复绑定的问题。perfect

_start 函数流程

BScroll.prototype._start = function (e) {
    let _eventType = eventType[e.type]
    /************ 1、判断 PC 端只允许左键点击 ***************/
    if (_eventType !== TOUCH_EVENT) {
      if (e.button !== 0) {
        return
      }
    }
    /************ 2、判断是否可操作、是否未销毁、但是第3个判断就有点让人不解了  ***************/
    // 在下面 _eventType = this.initiated,那如果 this.initiated 存在的话,
    // 什么情况下 this.initiated !== _eventType?
    if (!this.enabled || this.destroyed || (this.initiated && this.initiated !== _eventType)) {
      return
    }
    // _eventType 两种值:TOUCH_EVENT = 1,MOUSE_EVENT = 2
    this.initiated = _eventType

    /************ 3、是否阻止默认事件  ***************/
    // eventPassthrough 参数的设置会导致 preventDefault 参数无效,这里要注意!
    // preventDefaultException:input、textarea、button、select 原生标签默认事件则不阻止!
    if (this.options.preventDefault && !preventDefaultException(e.target, this.options.preventDefaultException)) {
      e.preventDefault()
    }

    // 作用:在第一次进入 `_move` 方法时触发 scrollStart 函数。
    this.moved = false 
    // 记录开始位置到结束位置之间的距离
    this.distX = 0
    this.distY = 0
    // _end 函数判断移动的方向 -1(左或上方向) 0(默认) 1(右或下方向)
    this.directionX = 0 
    this.directionY = 0
    this.movingDirectionX = 0
    this.movingDirectionY = 0
    // 滚动的方向位置(h=水平、v=垂直)
    this.directionLocked = 0

    /************ 4、设置 transition 运动时间,不传参数默认为0  ***************/  
    this._transitionTime()
    this.startTime = getNow()

    if (this.options.wheel) {
      this.target = e.target
    }

    /************ 5、若上一次滚动还在继续时,此时触发了_start,则停止到当前滚动位置 ***************/
    this.stop()

    let point = e.touches ? e.touches[0] : e
   
    // 在 _end 方法中用于计算快速滑动 flick
    this.startX = this.x
    this.startY = this.y
    // 在 _end 方法中给this.direction(X|Y) 辨别方向
    this.absStartX = this.x
    this.absStartY = this.y
   // 实时记录当前的位置
    this.pointX = point.pageX
    this.pointY = point.pageY

    /** 6、BScroll 还提供了一些事件,方便和外部做交互。如外部使用 on('beforeScrollStart') 方法来监听操作。 **/
    this.trigger('beforeScrollStart')
}

_move 函数流程

BScroll.prototype._move = function (e) {
    // 老样子,和 _start 方法中一样
    if (!this.enabled || this.destroyed || eventType[e.type] !== this.initiated) {
      return
    }
    if (this.options.preventDefault) {
      e.preventDefault()
    }

    let point = e.touches ? e.touches[0] : e
    // 每触发一次 touchmove 的距离
    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()

    /********  1、需要移动至少 (momentumLimitDistance) 个像素来启动滚动  **********/
    if (timestamp - this.endTime > this.options.momentumLimitTime && (absDistY < this.options.momentumLimitDistance && absDistX < this.options.momentumLimitDistance)) {
      return
    }

    /******* 2、如果你在一个方向上滚动锁定另一个方向 ******/
    if (!this.directionLocked && !this.options.freeScroll) {
      /** 
       *  absDistX:横向移动的距离
       *  absDistY:纵向移动的距离
       *  触摸开始位置移动到当前位置:横向距离 > 纵向距离,说明当前是在往水平方向(h)移动,反之垂直方向(v)移动。
       */
      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
      }
    }
   
    /**** 3、若当前锁定(h | v)方向, eventPassthrough 为 锁定方向 相反方向的话则阻止默认事件  ****/
    if (this.directionLocked === 'h') {
      if (this.options.eventPassthrough === 'vertical') {
        e.preventDefault()
      } else if (this.options.eventPassthrough === 'horizontal') {
        this.initiated = false
        return
      }
      deltaY = 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
    }

    // 如果没有开启 freeScroll,只允许一个方向滚动,另一个方向则要清零。
    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
    /**** 4、如果超过边界的话,设置了 bounce 参数就让它回弹过来 *****/
    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
      }
    }
    
    // 在 _start 方法中设置的 moved 变量,现在使用到了。
    if (!this.moved) {
      this.moved = true
      this.trigger('scrollStart')
    }
   // 进行滚动...
    this._translate(newX, newY)
    
   /***  5、大于 momentumLimitTime 是不会触发flick\momentum,赋值是为了重新计算,能够在_end函数中触发flick\momentum ***/
    if (timestamp - this.startTime > this.options.momentumLimitTime) {
      this.startTime = timestamp
      this.startX = this.x
      this.startY = this.y
      // 能进入这里的话,都是滚动的比较慢的(大于momentumLimitTime = 300ms)
      if (this.options.probeType === 1) {
        this.trigger('scroll', {
          x: this.x,
          y: this.y
        })
      }
    }
    // 实时的派发 scroll 事件
    if (this.options.probeType > 1) {
      this.trigger('scroll', {
        x: this.x,
        y: this.y
      })
    }

    /**
     * document.documentElement.scrollLeft  获取页面文档向右滚动过的像素数 (FireFox和IE中)
     * document.body.scrollTop 获取页面文档向下滚动过的像素数  (Chrome、Opera、Safari中)
     * window.pageXOffset (所有主流浏览器都支持,IE 8 及 更早 IE 版本不支持该属性)
     */
    let scrollLeft = document.documentElement.scrollLeft || window.pageXOffset || document.body.scrollLeft
    let scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop
    // 触摸点(pointX)相对于页面的位置(包括页面原始滚动距离scrollLeft)
    let pX = this.pointX - scrollLeft
    let pY = this.pointY - scrollTop
   
    /** 6、距离页面(上下左右边上)15px以内直接触发_end事件 **/
    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 函数流程

BScroll.prototype._end = function (e) {
    if (!this.enabled || this.destroyed || eventType[e.type] !== this.initiated) {
      return
    }
    this.initiated = false

    if (this.options.preventDefault && !preventDefaultException(e.target, this.options.preventDefaultException)) {
      e.preventDefault()
    }
   // 派发出 touchEnd 事件
    this.trigger('touchEnd', {
      x: this.x,
      y: this.y
    })
    // 属于一个状态:表示关闭(transition)过渡动画
    this.isInTransition = false

    //  取整
    let newX = Math.round(this.x)
    let newY = Math.round(this.y)
    // (下面辨别移动方向) =  结束位置 - 开始位置
    let deltaX = newX - this.absStartX
    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

    /**  1、配置下拉刷新  **/
    if (this.options.pullDownRefresh && this._checkPullDown()) {
      return
    }

    /** 2、检查它是否为单击操作 (里面对 Picker组件和Tap 点击事件进行处理) **/
    if (this._checkClick(e)) {
      this.trigger('scrollCancel')
      return
    }

    /** 3、如果超出滚动范围之外,则滚动回 0 或 maxScroll 位置 **/
    if (this.resetPosition(this.options.bounceTime, ease.bounce)) {
      return
    }
   /** 4、滚动到指定的位置 **/
    this.scrollTo(newX, newY)

    this.endTime = getNow()
   // startTime、startX、startY 在 _move 函数中移动超过( momentumLimitTime)会重新赋值
    let duration = this.endTime - this.startTime
    let absDistX = Math.abs(newX - this.startX)
    let absDistY = Math.abs(newY - this.startY)

    /** 5、flick 只有使用了snap组件才能执行 **/
    if (this._events.flick && duration < this.options.flickLimitTime && absDistX < this.options.flickLimitDistance && absDistY < this.options.flickLimitDistance) {
      this.trigger('flick')
      return
    }

    let time = 0
    /** 6、开启动量滚动 (当快速在屏幕上滑动一段距离的时候,会根据滑动的距离和时间计算出动量,并生成滚动动画)**/
    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) {
        newY = Math.round(newY / this.itemHeight) * this.itemHeight
        time = this.options.wheel.adjustTime || 400
      }
    }

    let easing = ease.swipe
    /** 7、在 snap 组件中,计算到最近的一页轮播图位置上 **/
    if (this.options.snap) {
      let snap = this._nearestSnap(newX, newY)
      this.currentPage = snap
      time = this.options.snapSpeed || Math.max(
          Math.max(
            Math.min(Math.abs(newX - snap.x), 1000),
            Math.min(Math.abs(newY - snap.y), 1000)
          ), 300)
      newX = snap.x
      newY = snap.y

      this.directionX = 0
      this.directionY = 0
      easing = this.options.snap.easing || ease.bounce
    }
   /** 8、进入执行滚动到最后的位置(启动了动量滚动) **/
    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
    }

    if (this.options.wheel) {
      this.selectedIndex = Math.round(Math.abs(this.y / this.itemHeight))
    }
    /** 9、在 snap 组件中触发了 scrollEnd 函数,用于无缝滚动 **/
    this.trigger('scrollEnd', {
      x: this.x,
      y: this.y
    })
}

疑点

  • 第3个条件语句中的 this.initiated !== _eventType,不知道是为了解决什么bug,感觉执行不到(应该可以忽略不写)
if (!this.enabled || this.destroyed || (this.initiated && this.initiated !== _eventType)) {
  return
}

总结

  • 这里只是简单介绍了 start move end 方法里面函数的用途。
  • 本篇文章虽然没有很详细的进行对每个部分进行解读,这也是我特意的。在我学习过程中发现,直接拿一个源码代码来解读,文章又长,有些地方可能有的解释的不清楚,所以我下面文章将以功能模块进行拆分解读。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.