better-scroll源码分析(一):初始化过程
better-scroll 源码阅读笔记
better-scroll 是一款重点解决移动端(未来可能会考虑 PC 端)各种滚动场景需求的插件。由于工作中需要使用better-scroll进行开发,然而better-scroll有着非常多的配置选项和可配置的功能,因此希望通过阅读它的源码,从而在开发中能够得心应手使用。
话不多说直接进入正题。
先看一下源码的目录结构:

example文件夹下是项目提供的demo,对于刚开始使用better-scroll的同学非常有用。而我们主要关注src目录下的文件。其中src/index.js是代码的入口文件,src/scroll文件夹下是项目的核心代码,src/util即项目的中使用的工具方法或常量设置。
入口文件
我们先来看下入口文件src/index.js。这个文件比较小,直接拿过来,内容如下。
import { eventMixin } from './scroll/event'
import { initMixin } from './scroll/init'
import { coreMixin } from './scroll/core'
import { snapMixin } from './scroll/snap'
import { wheelMixin } from './scroll/wheel'
import { scrollbarMixin } from './scroll/scrollbar'
import { pullDownMixin } from './scroll/pulldown'
import { pullUpMixin } from './scroll/pullup'
import { warn } from './util/debug'
//创建better-scroll的构造函数
function BScroll(el, options) {
this.wrapper = typeof el === 'string' ? document.querySelector(el) : el
if (!this.wrapper) {
warn('can not resolve the wrapper dom')
}
this.scroller = this.wrapper.children[0]
if (!this.scroller) {
warn('the wrapper need at least one child element to be scroller')
}
// cache style for better performance
this.scrollerStyle = this.scroller.style
this._init(el, options)
}
//将分散的方法都挂载到BScroll上。
initMixin(BScroll)
coreMixin(BScroll)
eventMixin(BScroll)
snapMixin(BScroll)
wheelMixin(BScroll)
scrollbarMixin(BScroll)
pullDownMixin(BScroll)
pullUpMixin(BScroll)
BScroll.Version = '1.6.3'
export default BScroll
可以看到入口文件主要就做了一件事情,创建BScroll构造函数。构造函数内部设置了容器wrapper和可滚动元素scroller后就进入了this.init(el, options)方法。这个init()方法定义在src/sroll/init.js文件中,通过initMixin(BScrol)方法把init()等方法挂载到BScroll构造函数原型上。类似的操作还有,coreMinxin(BScrol)(滚动相关的核心代码), eventMinxin(BScrol)(实现自定义事件)等。可以看到作者把不同的功能放在的分立的文件中,像插件一样把各个功能添加到better-scroll的主体上。
我们今天的任务就是,弄清楚better-scroll的初始化过程都做了哪些事情,即this.init(el, options)的执行过程。
better-scroll的初始化过程
先大致看下初始化方法的代码:
BScroll.prototype._init = function (el, options) {
this._handleOptions(options) //处理配置参数
// init private custom events
this._events = {} //初始化自定义事件缓存,自定义事件的实现在src/scroll/event.js中
this.x = 0 //scroll 横轴坐标。
this.y = 0 //scroll 纵轴坐标。
this.directionX = 0 //判断 scroll 滑动结束后相对于开始滑动位置的方向(左右)。-1 表示从左向右滑,1 表示从右向左滑,0 表示没有滑动。
this.directionY = 0 //判断 scroll 滑动结束后相对于开始滑动位置的方向(上下)。-1 表示从左向下滑,1 表示从右向上滑,0 表示没有滑动。
this._addDOMEvents() //添加原生事件监听
this._initExtFeatures() //根据配置参数初始化betterScroll的额外功能
this._watchTransition() //监听是否处于过渡状态
if (this.options.observeDOM) {
this._initDOMObserver() //会检测 scroller 内部 DOM 变化,自动调用 refresh 方法重新计算来保证滚动的正确性。
}
this.refresh() // 重新计算容器和可滚动元素(this.scroller)宽高,可滚动最大距离,并重置滚动元素的位置
if (!this.options.snap) {
this.scrollTo(this.options.startX, this.options.startY) // 滚动到初始位置
}
this.enable() //开启滚动功能
}
初始化过程中主要做了以下工作:
- 处理配置参数
- 初始化自定义事件缓存
- 添加原生事件监听回调
- 根据配置参数初始化better-scroll的额外功能
- 调用_watchTransition(),监听是否处于滚动过渡动画状态,做相应操作
- 初始化DOMObserver,检测 scroller 内部 DOM 变化,自动调用 refresh 方法
- 调用refresh()方法,重新计算容器和可滚动元素(this.scroller)宽高,可滚动最大距离,并重置滚动元素的位置
- 滚动到初始位置(默认情况下,snap配置为false,我们这里关注默认状态)
- 开启滚动功能。
我们前两篇文章只关注better-scroll实现滚动过程的核心功能。所以这里我们重点关注1、3、7的过程,其他部分会简单带过,以后再具体分析。
处理配置参数
_handleOptions()过程,主要初始化配置参数。合并默认配置和用户配置项,判断浏览器是否支持CSS3的transition, transform属性,另外可以看到eventPassthrough属性会影响到其他配置。相关的一些配置项的含义可以查看better-scroll文档
BScroll.prototype._handleOptions = function (options) {
this.options = extend({}, DEFAULT_OPTIONS, options) //将默认参数与用户配置合并
//HWCompositing 是否开启硬件加速,开启它会在 scroller 上添加 translateZ(0) 来开启硬件加速从而提升动画性能,有很好的滚动效果。
this.translateZ = this.options.HWCompositing && hasPerspective ? ' translateZ(0)' : '' //暂时不关心
//是否支持transition transform属性
this.options.useTransition = this.options.useTransition && hasTransition
this.options.useTransform = this.options.useTransform && hasTransform
//是否阻止浏览器默认行为,eventPassthrough preventDefault属性涵义可以查看[文档](https://ustbhuangyi.github.io/better-scroll/doc/zh-hans/options.html#preventdefault)
this.options.preventDefault = !this.options.eventPassthrough && this.options.preventDefault
// 如果启用eventPassthrough,配置相应滚动方向
this.options.scrollX = this.options.eventPassthrough === 'horizontal' ? false : this.options.scrollX
this.options.scrollY = this.options.eventPassthrough === 'vertical' ? false : this.options.scrollY
// With eventPassthrough we also need lockDirection mechanism
this.options.freeScroll = this.options.freeScroll && !this.options.eventPassthrough
this.options.directionLockThreshold = this.options.eventPassthrough ? 0 : this.options.directionLockThreshold
if (this.options.tap === true) { //暂不关心
this.options.tap = 'tap'
}
}
这里有些属性可能还不清楚具体作用,暂时略过,只需要知道这里只是做初始化配置参数。
添加原生事件监听回调
先看_addDOMEvents内容:
BScroll.prototype._addDOMEvents = function () {
let eventOperation = addEvent
this._handleDOMEvents(eventOperation)
}
在看addEvent和_handleDOMEvents,由于这里eventOperation为addEvent,所以_handleDOMEvents主要是调用了addEventListener添加事件监听回调。
export function addEvent(el, type, fn, capture) {
el.addEventListener(type, fn, {passive: false, capture: !!capture})
}
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(window, 'orientationchange', this)的第三个参数是this,这是怎么回事?这里不应该是一个函数吗?实际上,查看addEventListener的介绍,可以得到,这里不仅可以是一个函数,还可以是一个对象,只要这个对象提供了一个handleEvent属性即可。相应的我们可以在src/scroll/init.js文件中找到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
case 'orientationchange':
case 'resize':
this._resize()
break
case 'transitionend':
case 'webkitTransitionEnd':
case 'oTransitionEnd':
case 'MSTransitionEnd':
this._transitionEnd(e)
break
case 'click':
if (this.enabled && !e._constructed) {
if (!preventDefaultException(e.target, this.options.preventDefaultException)) {
e.preventDefault()
e.stopPropagation()
}
}
break
}
}
可以看到这里定义了不同事件下相应的处理函数。这些处理函数是better-scroll实现的核心代码,也是我们下一篇文章分析的关键内容。然后有一个问题是addEventListener中不是函数而是提供一个具有handleEvent方法的对象,这么做的好处是什么?我们知道addEventListener中如果提供的是一个函数,那么使用removeEventListener清除这个事件监听时要提供一个完全相同的函数才可以。因为我们这里"add"了很多事件回调,因此在最终清除操作的时候,仅仅是因为每个事件的处理函数不同,我们要重复写很多个"remove"的方法。而如果提供的是一个对象的话,即上面的做法,那么清除事件监听时,只需要把addEventListener改为removeEventListener即可,因此_handleDOMEvents把这个操作写成了一个变量,从而在清除事件监听时,可以重用_handleDOMEvents的代码。我们可以在src/scroll/init.js中找到相应的_removeDOMEvents和removeEvent`方法。
BScroll.prototype._removeDOMEvents = function () {
let eventOperation = removeEvent
this._handleDOMEvents(eventOperation)
}
export function removeEvent(el, type, fn, capture) {
el.removeEventListener(type, fn, {passive: false, capture: !!capture})
}
在src/scroll/core.js中的destory()方法中会看到清除事件监听的操作。当然我们这里暂时不关心,先了解一下。
BScroll.prototype.destroy = function () {
this.destroyed = true
this.trigger('destroy')
this._removeDOMEvents() //清除所有事件监听
// remove custom events
this._events = {}
}
初始化滚动外功能
_initExtFeature方法会根据配置项初始化除滚动功能外的其他特性,如上拉加载,下拉刷新功能,这部分我们在下一篇中会介绍,另外还有是否开启模拟滚动条,是否用作slide组件,是否用作piker组件等会做一些额外的初始化工作。这里只是简单了解一下,具体过程我们暂不关心。
BScroll.prototype._initExtFeatures = function () {
if (this.options.snap) { //slide组件需要用
this._initSnap() //暂不关心
}
if (this.options.scrollbar) { //如果开启滚动条功能
this._initScrollbar() //初始化滚动条,暂不关心
}
if (this.options.pullUpLoad) { //如果开启上拉加载功能
this._initPullUp() //初始化上拉加载,暂不关系,下一篇文章分析
}
if (this.options.pullDownRefresh) { //如果开启下拉刷新功能
this._initPullDown() //初始化下拉加载,暂不关系,下一篇文章分析
}
if (this.options.wheel) { //picker组件需要用
this._initWheel() //暂不关心
}
}
监听是否处于过渡动画状态
_watchTransition方法会通过Object.defineProperty将isInTransition属性设置为getter/setter,然后当isInTransition属性值变化的时候,响应式去改变滚动元素的ponterEvents属性,即当处于过渡动画时,禁止鼠标事件。有一个问题是这里为什么解决了issue #359的问题,我还没弄明白,有兴趣的可以看一下当时代码的改动部分。
BScroll.prototype._watchTransition = function () {
if (typeof Object.defineProperty !== 'function') { //如果浏览器不支持defineProperty,直接返回
return
}
let me = this
let isInTransition = false
Object.defineProperty(this, 'isInTransition', {
get() {
return isInTransition
},
set(newVal) {
isInTransition = newVal
// fix issue #359
let el = me.scroller.children.length ? me.scroller.children : [me.scroller]
let pointerEvents = (isInTransition && !me.pulling) ? 'none' : 'auto' //如果处于过渡动画状态,禁止鼠标事件
for (let i = 0; i < el.length; i++) {
el[i].style.pointerEvents = pointerEvents
}
}
})
}
refresh()方法
refresh()方法是一个很重要的方法。作用是重新计算 better-scroll,当 DOM 结构发生变化的时候务必要调用确保滚动的效果正常。原因可以从代码中看到,它主要做了:获取容器和滚动元素的宽高,计算最大滚动距离。因此DOM结构变化后应该调用重新计算。
BScroll.prototype.refresh = function () {
let wrapperRect = getRect(this.wrapper) //获取容器的宽高
this.wrapperWidth = wrapperRect.width
this.wrapperHeight = wrapperRect.height
let scrollerRect = getRect(this.scroller) //获取滚动元素的宽高
this.scrollerWidth = scrollerRect.width
this.scrollerHeight = scrollerRect.height
const wheel = this.options.wheel
if (wheel) {
... //暂时不关心
} else {
this.maxScrollX = this.wrapperWidth - this.scrollerWidth //计算最大的横向滚动距离
this.maxScrollY = this.wrapperHeight - this.scrollerHeight //计算最大的纵向滚动距离
}
this.hasHorizontalScroll = this.options.scrollX && this.maxScrollX < 0 //是否可以横向滚动
this.hasVerticalScroll = this.options.scrollY && this.maxScrollY < 0 //是否可以纵向滚动
if (!this.hasHorizontalScroll) {
this.maxScrollX = 0
this.scrollerWidth = this.wrapperWidth
}
if (!this.hasVerticalScroll) {
this.maxScrollY = 0
this.scrollerHeight = this.wrapperHeight
}
//初始化内部使用参数
this.endTime = 0 //滚动结束时间
this.directionX = 0 //横向滚动方向, 0表示没有滚动
this.directionY = 0 //纵向滚动方向, 0表示没有滚动
this.wrapperOffset = offset(this.wrapper)
this.trigger('refresh') //触发"refresh"事件
this.resetPosition() //重置滚动元素位置
}
这里需要注意的是,在web页面中,坐标系的原点在左上角。即如果向上滑动,滑动的距离是一个负值。因此maxScrollX和maxScrollY为负值时,表示可以滚动。

然后看一下resetPosition()方法做了哪些事情。
BScroll.prototype.resetPosition = function (time = 0, easeing = ease.bounce) {
let x = this.x
let roundX = Math.round(x)
if (!this.hasHorizontalScroll || roundX > 0) { //如果没有横向滚动或者向右滚动了
x = 0
} else if (roundX < this.maxScrollX) { //如果向左滚动超过了最大横向滚动距离,
x = this.maxScrollX
}
let y = this.y
let roundY = Math.round(y)
if (!this.hasVerticalScroll || roundY > 0) { //如果没有纵向滚动或者向下滚动了
y = 0
} else if (roundY < this.maxScrollY) { //如果向上滚动超过了最大纵向滚动距离
y = this.maxScrollY
}
if (x === this.x && y === this.y) {
return false
}
this.scrollTo(x, y, time, easeing) //滚动到指定x, y的位置,time和easeing指定过渡时间和动画
return true
}
可以看到resetPosition()方法只做了一件事情,调用scrollTo()方法重置滚动元素位置。什么情况下需要重置滚动元素位置呢?先说一下我们在滚动过程中一个合理的滚动距离dis,应该满足maxScrollX/Y <= dis <= 0。所以滚动的距离如果超出了边界值:
-
dis>0,向下或者向右滚动超过边界值,会重置为0。 -
dis<maxScrollX/Y,向上或者向左滚动超出边界值,会重置为maxScrollX/Y。
如果滚动距离没有超过边界值,那么resetPosition不会做任何事情,保持原来的位置。
到现在为止,上面的过程已经完成了我们今天的任务。我们已经大致看完了better-scroll的初始化过程。下一篇文章我们将从scrollTo()方法的实现开始,然后分析better-scroll的核心代码,手指在屏幕上滑动开始,滑动中,滑动结束这三个时间点better-scroll都做了哪些事情。
这里暂时给出scrollTo()的实现,大致看一下内容。
BScroll.prototype.scrollTo = function (x, y, time = 0, easing = ease.bounce) {
this.isInTransition = this.options.useTransition && time > 0 && (x !== this.x || y !== this.y)
if (!time || this.options.useTransition) {
this._transitionProperty()
this._transitionTimingFunction(easing.style)
this._transitionTime(time)
this._translate(x, y) // 这一句实现了滚动,我们下次再说。
if (time && this.options.probeType === 3) {
this._startProbe()
}
if (this.options.wheel) {
... //暂不关心
}
} else {
this._animate(x, y, time, easing.fn)
}
}