如何模拟实现浏览器scroll?

1,249 阅读5分钟

前言

最近在看 better-scrollcore 源码,于是就想自己试试写一个简化版 core,这也是为了加深自己对于源码的理解。具体而言,需要实现如下三个功能:

  • 能纵向滚动

  • 能做到惯性滚动

  • 超出边界有一个反弹效果

先来看看最终的实现效果:

思路

本质上来说,better-scroll 就是在模仿浏览器的 scroll,通过 transform 模拟实现滚动,同时辅以 transition 模拟实现回弹等动画效果。

core 源码的启发,我新建了一个 BScroll.js 文件,在该文件里主要做了以下几件事情:

  1. 定义构造函数;

  2. 定义初始化函数,并在构造函数里调用;

  3. 通过初始化函数,添加监听事件;

  4. 定义滚动核心逻辑:_start_move_end

接下来我会一一展示每一步的代码,看看这些代码具体是怎么做的。

定义构造函数

在开始之前,需要知道构造函数需要接收什么参数。如封面大图所示,模拟实现滚动,需要一个包裹元素 wrapper,而包裹元素的第一个子元素是滚动元素 scroll。因此,构造函数需要接收一个 HTMLElement 实例对象,这里只需要传递包裹元素的引用即可。由于只是简单的模拟纵向滚动,这里就不需要传递配置项参数了。

完整的构造函数如下:

function BScroll (el, options = {}) {
  this.wrapper = typeof el === 'string' ? document.querySelector(el) : el
  this.scroller = this.wrapper.children[0]
  this.scrollerStyle = this.scroller.style
  this._init(el, options)
}

定义初始化函数

初始化函数做了 2 件事情:

  • 初始化坐标

  • 调用添加监听事件的函数

完整代码如下:

BScroll.prototype._init = function (el, options) {
  this._events = {}

  this.x = 0
  this.y = 0
  this.directionX = 0
  this.directionY = 0

  this._addDOMEvents()
}

添加监听事件

先来看看 _addDOMEvents 做了什么:

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

在这里,addEvent 其实是一个函数,是对平时我们添加监听事件的一次封装,它的源码如下:

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

使用这个函数需要传递 4 个参数:元素对象、事件类型、事件处理函数及 capture,然后函数内部注册了监听事件。在第三个参数中,passive: true 表示会调用 preventDefault(),capture 表示事件处理函数会在该类型的事件捕获阶段传播到该元素时触发。

接下来就是调用 _handleDOMEvents 函数,将封装过的添加监听事件的函数传递进去,让我们继续来看看当前步骤最重要的一环是怎么做的吧:

BScroll.prototype._handleDOMEvents = function (eventOperation) {
  let target = this.wrapper
  eventOperation(target, 'mousedown', this)
  eventOperation(target, 'mousemove', this)
  eventOperation(target, 'mousecancel', this)
  eventOperation(target, 'mouseup', this)

  eventOperation(target, 'touchstart', this)
  eventOperation(target, 'touchmove', this)
  eventOperation(target, 'touchcancel', this)
  eventOperation(target, 'touchend', this)
}

看到这里,相信有不少小伙伴会感到困惑,为什么第三个参数传进去的是一个 this ,而不是一个事件处理函数?

MDN 是这样说的,listener 必须是一个实现了 EventListener 接口的对象,或者是一个函数。BScroll 原型上存在事件处理函数 handleEvent:

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
  }
}

到了这里,就完成了关键性的工作了,只要有滚动,就会触发对应的处理函数。滚动的 3 大核心函数:_start_move_end,将会在滚动事件触发后频繁执行。

定义滚动核心逻辑

在这部分,我将会直接贴出代码和注释,便于更少的文字性描述,使得阅读体验更好。

_start

BScroll.prototype._start = function (e) {
  // 手指或鼠标的滑动距离
  this.distX = 0
  this.distY = 0

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

  // 滚动的开始位置
  this.startX = this.x
  this.startY = this.y
  this.absStartX = this.x      // 在 _end 函数里会用到
  this.absStartY = this.y
  // 鼠标或手指相对于页面位置(不是可视区域里的文档部分,而是滚动元素scroll部分)
  this.pointX = point.pageX
  this.pointY = point.pageY
}

_move

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     // 缓存滚动中手指或鼠标在页面的坐标(相对整个scroll滚动元素)
  this.pointY = point.pageY

  this.distX += deltaX      // 累计滚动量
  this.distY += deltaY

  // 计算新的滚动坐标
  let newX = this.x + deltaX
  let newY = this.y + deltaY

  this._translate(newX, newY)

  // 计算滚动条卷去的高度
  let scrollTop = document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop

  let pY = this.pointY - scrollTop   // 手指或鼠标相对于scroll滚动元素的坐标 - 滚动条向上卷去的距离 = 在可视区的坐标

  if (pY > document.documentElement.clientHeight) {     // 如果到了视窗边缘,停止滚动
    this._end(e)
  }
}

_end

BScroll.prototype._end = function (e) {
  let point = e.touches ? e.touches[0] : e

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

  let newX = Math.round(this.x)
  let newY = Math.round(this.y)

  let deltaY = newY - this.absStartY
  this.directionY = deltaY > 0 ? DIRECTION_DOWN : deltaY < 0 ? DIRECTION_UP : 0

  let wrapperRect = getRect(this.wrapper)
  let scrollerRect = getRect(this.scroller)
  
  if (newY > 0) {     // 向下滚动
    this._translate(newX, newY)
    this._translate(newX, 0)      // 滚动元素超出边界后重置(上边界)
  } else {    // 向上滚动
    if (wrapperRect.height >= scrollerRect.height) {   // 包裹元素高度 >= 滚动元素高度
      this._translate(newX, newY)
      this._translate(newX, 0)
    } else {
      if (Math.abs(newY) < scrollerRect.height)
        this._translate(newX, newY)
      else {
        this._translate(newX, newY)
        this._translate(newX, -(scrollerRect.height - wrapperRect.height))    // 滚动元素超出边界后重置(下边界)
      }
    }
  }
}

_translate位置改变

如果说 scrollTo 函数是滚动直接执行者,那么 _translate 函数则是实现滚动元素位置改变的最直接执行者。

BScroll.prototype._translate = function (x, y) {
  this.scrollerStyle.transform = `translateY(${y}px)`
  this.scrollerStyle.transition = `all 1s ease-out`

  this.x = x
  this.y = y
}

trigger事件派发

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))
    }
  }
}

getRect获取元素宽高及相对位置

function getRect(el) {
  if (el instanceof window.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
    }
  }
}

至此为止,整个 BScroll.js 就实现了,这里只是实现了一个简单的纵向滚动,比起 better-scrollcore 还差得很多。最后贴上 GitHub 地址:BSroll.js,也希望大家能 star 一下表示鼓励😊。

需要注意的地方

  • 这个滚动效果只在移动端上有效,因此在谷歌浏览器调试时开启手机模式。

  • better-scroll 一样,包裹元素需要设置 overflow: hidden 属性才能遮住滚动元素。

  • 只有当滚动元素尺寸大于包裹元素时,才会有滚动效果,否则会反弹回去。

总结

总体来看,BSroll.js 主要就做了这么几件事,定义构造函数,添加监听事件,滚动触发执行回调函数。滚动的直接执行者,其实就是通过 transform 结合 transition 来实现的,明白了这一点,再去阅读 better-scroll 源码就轻松多了。