前言
最近在看 better-scroll
的 core
源码,于是就想自己试试写一个简化版 core
,这也是为了加深自己对于源码的理解。具体而言,需要实现如下三个功能:
-
能纵向滚动
-
能做到惯性滚动
-
超出边界有一个反弹效果
先来看看最终的实现效果:
思路
本质上来说,better-scroll
就是在模仿浏览器的 scroll
,通过 transform
模拟实现滚动,同时辅以 transition
模拟实现回弹等动画效果。
受 core
源码的启发,我新建了一个 BScroll.js
文件,在该文件里主要做了以下几件事情:
-
定义构造函数;
-
定义初始化函数,并在构造函数里调用;
-
通过初始化函数,添加监听事件;
-
定义滚动核心逻辑:
_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-scroll
的 core
还差得很多。最后贴上 GitHub
地址:BSroll.js,也希望大家能 star
一下表示鼓励😊。
需要注意的地方
-
这个滚动效果只在移动端上有效,因此在谷歌浏览器调试时开启手机模式。
-
和
better-scroll
一样,包裹元素需要设置overflow: hidden
属性才能遮住滚动元素。 -
只有当滚动元素尺寸大于包裹元素时,才会有滚动效果,否则会反弹回去。
总结
总体来看,BSroll.js
主要就做了这么几件事,定义构造函数,添加监听事件,滚动触发执行回调函数。滚动的直接执行者,其实就是通过 transform
结合 transition
来实现的,明白了这一点,再去阅读 better-scroll
源码就轻松多了。