1. 背景
工作中的项目用到了大数据(高达1W+)的结构树,我在自己实现了一个虚拟树之后,发现拉动滚动条从一个区域一下子拖了很大一段距离,出现了闪烁和短暂的白屏现象,即便我给虚拟树增加了缓冲区也不能解决。现象如下图:
模拟实现一个滚动条,能够解决这个问题,效果如下:
我还想补充:
- 增加缓冲区数据是有效的,如果拖动的距离很小,或者是通过鼠标滚轮滚动,能够解决一定程度的白屏、闪烁。
- 但是当鼠标拖动滚动条很快、很大段距离时,白屏依然会出现,这时模拟的滚动条就派上用场了。
2. 原因
2.1 为什么原生的滚动条会导致出现白屏?
从现象来看,是滚动条滚动了,但是dom还没渲染好导致的白屏,dom渲染是异步的有时候就跟不上。如果浏览器设计为等dom渲染好了再去修改滚动条的位置,滚动行为将是不流畅的。
而不管是虚拟树还是虚拟滚动,最终的本质都是一样的,拖动滚动条时,需要计算滚动的scrollTop值,这个值 / 每一项数据的高度得到开始索引,再加上可视区域可以展示的数据量,得到结束索引。利用开始索引和结束索引去截取数组的值。这个计算的过程其实是非常快的,就是dom渲染慢了。我写了一个代码的debug过程,如下图:
可以看到右侧js执行完毕,但是左侧页面还没渲染。
2.2 为什么手写一个滚动条能够解决白屏?
在手写的滚动条中,因为拖动的是我们自己模拟的div,拖动的时候不会立刻触发scroll事件,而是会计算拖动的距离,进而设置容器的scrollTop值(此时dom会开始渲染更新),scrollTop值变化就会触发scroll事件,进而修改滚动条滑块的位置。
过程:
1.计算鼠标拖动的距离
2.修改容器scrollTop值(dom异步开始渲染)
3.触发容器scroll事件
4.更新滚动条滑块的位置
对比使用原生滚动条:
1. 拖动滑块,滑块位置更新,触发原生scroll事件,scrollTop值也更新了
2. JS计算好,dom开始异步渲染
这样你能看出,因为执行顺序不同,所以手写的滚动条能够避免白屏的问题出现了吧。在手写的滚动条中,dom很早就开始渲染了,滚动条位置的更新反而是属于最后一步的事情。
3. 如何实现一个手写的滚动条呢?
3.1 准备条件
virtualTree-my.vue组件
3.2 隐藏默认的滚动条
3.2.1 方法1:使用CSS属性
目标是隐藏该容器的默认滚动条:
.viewport {
width: 300px;
height: 600px;
overflow: auto;
// 记得这里设置相对定位
position: relative;
margin-right: -17px;
margin-bottom: -17px;
+ scrollbar-width: none; /* 适用于 Firefox */
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
scrollbar-width: none;属性同时支持多种浏览器,但是兼容器稍微差一点,如下图比如对Chrome对120版本以后的浏览器才支持;对火狐浏览器的兼容性较好
webkit-scrollbar属性,不支持火狐浏览器,所以必须搭配scrollbar-width: none;属性一起使用
3.2.2 方法2:借助margin-right属性
该方法我未能实现,但还是讲一下逻辑思路
在mounted方法中调用:
mounted () {
this.getScrollWidth()
},
计算浏览器默认滚动条的宽度,给到自己的滚动条,下面有图分析
getScrollWidth () {
const outer = document.createElement("div");
outer.className = "el-scrollbar__wrap";
outer.style.width = '100px';
outer.style.visibility = "hidden";
outer.style.position = "absolute";
outer.style.top = "-9999px";
document.body.appendChild(outer);
// 没有滚动条外部容器的宽度
const widthNoScroll = outer.offsetWidth;
// 当设置scroll时,此时outer有滚动条了,内容会往里面挤一点
outer.style.overflow = "scroll";
// inner是内容,就是挤占后的宽度,
const inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
// 没有滚动条的宽度 - 有滚动条的宽度就是滚动条的宽度
this.padRight = widthNoScroll - widthWithScroll;
},
不同浏览器的宽度不尽相同,比如chrome的是17px,edge的是15px;拿到这个宽度,给wrap容器设置margin-right:-17px;
比如element-ui效果:
3.3 准备结构
增加滚动条结构:
- bar是滑轨
- thumb是滑块
+<div class="virtualTree">
<div class="viewport" ref="viewport" @scroll="handleScroll">
<ul>
<li>
……
</li>
</ul>
</div>
+ <div
ref="bar"
class='scrollbar-bar'
>
<div
ref="thumb"
class="scrollbar-thumb"
></div>
+ </div>
</div>
增加样式:
- thumb要增加cursor: pointer,而bar不用
- thumb的高度默认设置为0,后续会变高
- 通过给bar设置绝对定位,将滚动条设置在右边
.virtualTree {
position: relative;
width: 300px;
}
.scrollbar-bar {
position: absolute;
right: 2px;
bottom: 2px;
z-index: 1;
width: 6px;
height: 100%;
border-radius: 4px;
transition: opacity 120ms ease-out;
}
.scrollbar-thumb {
position: relative;
display: block;
width: 100%;
height: 0;
border-radius: inherit;
background-color: rgba(135,141,153,.3);
transition: .3s background-color;
cursor: pointer;
}
通过检查元素能看到滚动条的位置:
3.4 计算滑块的高度
目标效果:注意观察右边滚动条的高度变化
data里面增加数据
data () {
return {
thumbHeight: 0, // 滑块高度
}
},
更新滚动条高度的函数
// 更新滚动条高度和位置
updateThumb () {
try {
+ let wrap = this.$refs.viewport
+ // 滚动条高度 = 元素内容高度 clientHeight * 100 / 元素滚动的高度scrollHeight
+ let heightPercent = wrap.clientHeight * 100 / wrap.scrollHeight
+ this.thumbHeight = heightPercent
} catch (error) {
console.log(error, 'error');
}
},
为什么滚动条的高度 = wrap.clientHeight * 100 / wrap.scrollHeight + '%'?
首先要理解clientHeight是可视区域的高度,这里我就固定设置为600px;scrollHeight是所有已经展示和未展示的数据项的数量 * 每一项的高度。
如下图,如果视口的可视区域的高度是600px,所有的scrollHeight的高度假设是1200px,那么600 * 100 / 1200就是50%,滚动条就是50%,这个是不是很好理解,因为下面还有一半的数据未展示
如下图,如果可视区域的高度是600px,暂时下面没有待展示的数据,scrollHeight也是600px,这时overflow值为scroll; 滚动条的高度就是100%,600 / 600 * 100 = 100
计算好后赋值
<div
ref="bar"
class='scrollbar-bar'
>
<div
ref="thumb"
class="scrollbar-thumb"
:style="{
+ height: `${thumbHeight}%`
}"
></div>
</div>
调用时机:
初始化时需要调用,注意使用nextTick,等下一次视图更新好再调用,不然拿不到wrap包围容器
// 1. 构建数据
buildData () {
……省略很多代码
this.treeData = data;
// 2. 拍平数据
this.flattenTree = this.flatten(this.treeData)
// 3. 更新可视区域的数据
this.updateVisibleData()
// 4. 更新滚动条
this.$nextTick(() => {
+ this.updateThumbHeight()
})
},
展开节点时需要调用
// 点击某个节点
handleNodeClick (item) {
// 展开或者关闭取反
this.$set(item, 'showChildren', !item.showChildren)
// 递归修改子元素的visible
this.recursionVisible(item, item.showChildren)
// 更新视图
this.updateView()
// 更新滑块高度
this.$nextTick(() => {
+ this.updateThumbHeight()
})
},
3.5 鼠标滚轮滚动,更新滚动条的位置
目标效果:注意关注右侧滚动条的位置
求滚动条的滚动比例scrollThuPer:
updateThumbPosition () {
let wrap = this.$refs.viewport
// wrap.scrollTop是滚动的距离
// this.allVisibleDataLength * this.option.itemHeight就是整个scrollHeight
// clientSize就是视口高度,就是600px
// this.allVisibleDataLength * this.option.itemHeight - this.clientSize就是剩余可以滚动的距离
let scrollThuPer = wrap.scrollTop / (this.allVisibleDataLength * this.option.itemHeight - this.clientSize)
this.scrollThuPer = scrollThuPer > 1 ? 1 : scrollThuPer // 防止越界
},
如下图,假设滚动了200px,而待滚动的区域是1200-600=600px,200/600就是1/3,那么滚动条应该移动剩余可移动距离的1/3
求出滚动条剩余的可移动距离:
- 在计算属性中设置totalSteps的值,表示可以移动的步数,见下图
- this.clientSize是视口的高度
- this.thumbHeight / 100是滑块高度的百分比
- this.thumbHeight / 100 * this.clientSize就是最终滑块的高度
- 为什么要写在计算属性中呢?因为该值依赖滑块的高度值
computed: {
allVisibleDataLength () {
return this.allVisibleData.length
},
totalSteps () {
// 视口高度减去滚动条滑块高度,就是可以滑动的距离
return this.clientSize - this.thumbHeight / 100 * this.clientSize
},
},
最终滚动条移动距离:
- 侦听器中监听滚动比例
- 滚动比例 * 剩余可以移动总距离 = 此次应该移动的距离
- 为什么要侦听呢?因为scrollThuPer的值依赖容器的scrollTop的值的变化
watch: {
allVisibleDataLength: {
handler (newVal, oldVal) {
if (newVal) {
// 监听可视数据的变化,去更新占位符的高度,展开或者折叠的时候滚动条发生变化
this.updatePlaceHolderHeight()
}
},
deep: true
},
+ scrollThuPer (newV, oldV) {
+ // 滚动比例 * 可以滚动的距离,就是位移的距离
+ this.traveled = Math.ceil(newV * this.totalSteps)
+ }
},
最终给dom设置:
<div
ref="bar"
class='scrollbar-bar'
>
<div
ref="thumb"
class="scrollbar-thumb"
:style="{
height: `${thumbHeight}%`,
+ transform: `translateY(${traveled}px)`
}"
></div>
</div>
3.6 点击滚动条滑轨的空白区域,滚动条直接位移,并且视图能够更新
目标效果:
实现方式:
给滑轨组件绑定mouesedown事件
<div
ref="bar"
class='scrollbar-bar'
+ @mousedown="clickTrackHandle"
>
<div
ref="thumb"
class="scrollbar-thumb"
:style="{
height: `${thumbHeight}%`,
transform: `translateY(${traveled}px)`
}"
></div>
</div>
clickTrackHandle函数内部执行:
// 点击轨道某个位置,更新视图和滚动条
clickTrackHandle (e) {
// 点击位置距离滑轨顶部的距离
const offset = Math.abs(e.target.getBoundingClientRect().top - e.clientY)
// 滑块高度的一半
const thumbHalf = this.$refs.thumb.offsetHeight / 2
// 计算滚动条在滚动框的百分比位置
const thumbPositionPercent = (offset - thumbHalf) / this.$refs.bar.offsetHeight
// 改变包围容器的scrollTop
let wrap = this.$refs.viewport
wrap.scrollTop = thumbPositionPercent * wrap.scrollHeight
},
offset的值:
- e.target是滑轨,e.target.getBoundingClientRect().top是滑轨距离浏览器顶部
- e.clientY是点击位置距离浏览器顶部,我们希望滑块的中间位置移动到这里
- 上面两个相减就是点击位置距离滑轨顶部,如下图:
offset - thumbHalf的值:
- 通过上面求的offset减去滑块高度的一半,就是滑块要移动的距离
(offset - thumbHalf) / bar.offsetHeight * 100:
- 滑块移动的距离占整个滑轨的高度,如下图
wrap.scrollTop = thumbPositionPercent * wrap.scrollHeight
- wrap容器的srcollTop表示他要设置的滚动距离
- thumbPositionPercent是滚动条滚动距离占滑轨比例
- wrap.scrollHeight是总的高度,包括没有显示的数据,注意一定是scrollHeight,而不是offsetHeight
注意设置阻止冒泡:
如下图,当点击滑块时,也触发了滑轨的点击事件,导致滚动条位置发生变化
给thumb滑块绑定点击事件
<div
ref="bar"
class='scrollbar-bar'
@mousedown="clickTrackHandle"
>
<div
ref="thumb"
class="scrollbar-thumb"
+ @mousedown="clickThumbHandle"
:style="{
height: `${thumbHeight}%`,
transform: `translateY(${traveled}px)`
}"
></div>
阻止冒泡后解决
clickThumbHandle (e) {
e.stopPropagation()
},
如下图效果
3.7 鼠标拖动滚动条,滚动条发生位移,同时视图要能够更新数据
效果如下:
在点击滑块的clickThumbHandle方法中补充:
// 点击滑块时触发
clickThumbHandle (e) {
e.stopPropagation()
// 判断是否按ctrl键或者鼠标中键、右键
if (e.ctrlKey || [1, 2].includes(e.button)) {
return
}
this.cursorDown = true
// 计算鼠标距离滑块底部
this.state.y = (
e.currentTarget.offsetHeight // 滑块高度
-
(
e.clientY // 鼠标点击距离页面顶部
-
e.currentTarget.getBoundingClientRect().top // 滑块距离页面顶部
) // 鼠标点击距离滑块顶部
)
this.attachEvents()
},
- 如上代码中,e.ctrlKey为true表示此时是否按下键盘ctrl键,e.button如果为1表示鼠标此时点的是中键,为2代表右键,0是左键 ==> 拖动时不能按ctrl键,且鼠标不能是按下中键或者右键
- cursorDown用于判断是否点击了鼠标的滑块
- this.state.y用于计算
鼠标点击位置距离滑块底部的距离,如下图
attachEvents方法:
- 监听鼠标移动和鼠标抬起的方法,监听
mousemovemouseup事件 document.onselectstart = () => false:拖拽时,禁止长文本移动,
attachEvents () {
// 监听鼠标的移动和抬起事件
window.addEventListener('mousemove', this.onMouseMoveThumb)
window.addEventListener('mouseup', this.onMouseUpThumb)
// 拖拽时禁止长文本移动
onSelectStart = document.onselectstart
document.onselectstart = () => false
},
mousemove事件中:
// 鼠标的移动
onMouseMoveThumb (e) {
// 非按下状态
if (this.cursorDown === false) return
// 鼠标拖动时鼠标距离滑轨顶部的距离
const offset = (
this.$refs.bar.getBoundingClientRect().top - e.clientY
) * -1
if (offset > this.$refs.bar.offsetHeight) return
// 鼠标距离滑块顶部 这个值可能一直不变
// 用e.target或者e.currentTarget可能都有问题
const thumbPosition = this.$refs.thumb.offsetHeight - this.state.y
// 鼠标移动到底部边界继续移动,不触发
// 滚动距离
const distance = offset - thumbPosition
// 滚动距离占滚动条的比例
const thumbPositionPercentage = distance * 100 / this.$refs.bar.offsetHeight
// 滚动距离的比例 * 滚动的高度 / 100
this.$refs.viewport.scrollTop = thumbPositionPercentage * this.$refs.viewport.scrollHeight / 100
},
if (this.cursorDown === false) return如果是非按下状态,则下面的代码都不执行- offset值如下图,滚动过程中,该值会一直变大,但是不能超出边界值
- 求offset值时注意判断是否超过容器高度,超过则不能继续向下执行代码
- thumbPosition = 滑块的高度 - 鼠标距离顶部的距离
- distance滑块累计移动的距离 = 鼠标距离滑轨顶部 - 鼠标距离滑块顶部
- distance / bar.offsetHeight * viewport.scrollHeight
1. 滑块滚动的距离 / 滑轨的高度 ===> 滑块滚动的距离占比
2. 滑块滚动的距离占比 * 整个容器的scrollHeight ===> 容器应该设置的scrollTop值
至此,就能实现如下效果了
效果如下:
3.8 鼠标抬起事件
// 鼠标的抬起
onMouseUpThumb () {
this.cursorDown = false
this.detachEvents()
}
如下事件中,清空事件监听,恢复onSelectStart的原生事件。 (该变量我是用一个全局变量存储)
detachEvents() {
// 移除事件
window.removeEventListener('mousemove', this.onMouseMoveThumb)
window.removeEventListener('mouseup', this.onMouseUpThumb)
document.onselectstart = onSelectStart
},
4. 源码分析
4.1 源码地址
我在实际项目中用的是vue2,对应UI库是element-ui,element-ui里面是没有虚拟树的,所以我是看element-plus的源码,因为他实现了虚拟树,且他的虚拟树的滚动条也是自己封装的滚动条,没有白屏的效果
element-plus里面有一个单独的scrollBar文件夹,如下:
但是虚拟树没有直接用这个组件,而是单独有一个文件,地址packages/components/virtual-list,里面专门有个scrollbar.ts,这是虚拟树专门用的组件,源码地址
4.2 滚动条高度的计算
滑块的高度设置:
- 如下通过h函数渲染组件,高亮部分是滑块的样式设置
return () => {
return h(
'div',
{
role: 'presentation',
ref: trackRef,
class: [
nsVirtualScrollbar.b(),
props.class,
(props.alwaysOn || state.isDragging) && 'always-on',
],
style: trackStyle.value,
onMousedown: withModifiers(clickTrackHandler, ['stop', 'prevent']),
onTouchstartPrevent: onThumbMouseDown,
},
h(
'div',
{
ref: thumbRef,
class: nsScrollbar.e('thumb'),
+ style: thumbStyle.value,
onMousedown: onThumbMouseDown,
},
[]
)
)
}
thumbStyle值来自于thumbSize的值
const thumbStyle = computed<CSSProperties>(() => {
if (!Number.isFinite(thumbSize.value)) {
return {
display: 'none',
}
}
+ const thumb = `${thumbSize.value}px`
const style: CSSProperties = renderThumbStyle(
{
bar: bar.value,
size: thumb,
move: state.traveled,
},
props.layout
)
return style
})
thumbSize的值:
- ratio的值是
(clientSize * 100) / this.estimatedTotalSize, 就是上面3.4节中的wrap.clientHeight * 100 / wrap.scrollHeight + '%' - 如果
ratio >= 100:滚动条不显示,所有内容在可视区域都可见 - 如果
ratio >= 50:假设可视区域是200,总高度scrollHeight是400,那么200 * 100 / 400 = 50,可视区域占总的50%,50 * 200 / 100 = 100,滚动条高度应该是100 - 如果
ratio < 50:element-ui给他设置了最小的高度,这里通常情况就是取值clientSize / 3,官方示例的是高度69
const thumbSize = computed(() => {
const ratio = props.ratio!
const clientSize = props.clientSize!
if (ratio >= 100) {
return Number.POSITIVE_INFINITY
}
if (ratio >= 50) {
return (ratio * clientSize) / 100
}
+ const SCROLLBAR_MAX_SIZE = clientSize / 3 // ratio < 50,通常就是用这个值作为滚动条高度
return Math.floor(
Math.min(
Math.max(ratio * clientSize, SCROLLBAR_MIN_SIZE),
SCROLLBAR_MAX_SIZE
)
)
})
ratio的值来自于父组件:packages/components/virtual-list/src/build-list
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
onScroll: onScrollbarScroll,
+ ratio: (clientSize * 100) / this.estimatedTotalSize,
scrollFrom:
states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
})
4.3 鼠标滚轮滚动,更新滑块位置
对照上面3.5节的说明看:
packages/components/virtual-list/src/components/scrollBar.ts里面的state.traveled决定了滑块的位移,如下代码是关键
// 监听scrollFrom属性的变化,更新滑块位置
watch(
() => props.scrollFrom,
(v) => {
if (state.isDragging) return
/**
* this is simply mapping the current scrollbar offset
*
* formula 1:
* v = scrollOffset / (estimatedTotalSize - clientSize)
* traveled = v * (clientSize - thumbSize - GAP) --> v * totalSteps
*
* formula 2:
* traveled = (v * clientSize) / (clientSize / totalSteps) --> (v * clientSize) * (totalSteps / clientSize) --> v * totalSteps
*/
+ state.traveled = Math.ceil(v! * totalSteps.value)
}
)
totalSteps.value是剩余可以滚动的距离,来自下面的props.clientSize视口高度-滚动条的高度-滑块的间距
// 计算总的滚动步数 ==> 还能够滚动的距离
const totalSteps = computed(() => {
return Math.floor(props.clientSize! - thumbSize.value - unref(GAP))
})
scrollFrom是滚动的比例,值来自build-list.ts, 这里就是我们上面求的scrollThuPer值
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
onScroll: onScrollbarScroll,
ratio: (clientSize * 100) / this.estimatedTotalSize,
+ scrollFrom:
+ states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
})
4.4 点击滑轨空白区域,更新滑块和视图
scrollBar.ts文件:
const clickTrackHandler = (e: MouseEvent) => {
const offset = Math.abs(
(e.target as HTMLElement).getBoundingClientRect()[bar.value.direction] -
e[bar.value.client]
)
const thumbHalf = thumbRef.value![bar.value.offset] / 2
const distance = offset - thumbHalf
state.traveled = Math.max(0, Math.min(distance, totalSteps.value))
emit('scroll', distance, totalSteps.value)
}
- offset是鼠标点击距离滑轨顶部 = getBoundingClientRect().top滑轨距离浏览器顶部 - e.clientY鼠标距离浏览器顶部
- thumbHalf是滑块的一半,我们希望滑块的一半滑动到鼠标这个位置,
- distance是滑块要修改的top值,直接赋值给traveled;Math.min(distance, totalSteps.value)表示distance值不能超过totalSteps.value的值
- emit触发scroll事件,
触发父组件build-list.ts
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
+ onScroll: onScrollbarScroll,
ratio: (clientSize * 100) / this.estimatedTotalSize,
scrollFrom:
states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
})
下面offset的距离是wrap容器应该设置的scrollTop值
const onScrollbarScroll = (distanceToGo: number, totalSteps: number) => {
// estimatedTotalSize.value => allVisibleData.length * itemSize
// clientSize.value => 视口高度(可视区域的高度)
// estimatedTotalSize.value - clientSize.value ==> 需要滚动的总距离
// totalSteps => bar.offsetHeight - thumb.offsetHeight
// (estimatedTotalSize.value - clientSize.value) / totalSteps ==> 需要滚动的距离 / 总步骤数 = 每一步的滚动距离
// distanceToGo => 当前位置滚动到目标为止的距离
// 每一步的距离 * 当前位置滚动到目标为止的距离 = wrap的scrollTop的值
const offset =
((estimatedTotalSize.value - (clientSize.value as number)) /
totalSteps) *
distanceToGo
scrollTo(
Math.min(
estimatedTotalSize.value - (clientSize.value as number),
offset
)
)
}
在onUpdated函数里面触发修改,windowElement就是虚拟列表组件
onUpdated(() => {
const { direction, layout } = props
const { scrollOffset, updateRequested } = unref(states)
const windowElement = unref(windowRef)
if (updateRequested && windowElement) {
if (layout === HORIZONTAL) {
if (direction === RTL) {
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
// So we need to determine which browser behavior we're dealing with, and mimic it.
switch (getRTLOffsetType()) {
case RTL_OFFSET_NAG: {
windowElement.scrollLeft = -scrollOffset
break
}
case RTL_OFFSET_POS_ASC: {
windowElement.scrollLeft = scrollOffset
break
}
default: {
const { clientWidth, scrollWidth } = windowElement
windowElement.scrollLeft =
scrollWidth - clientWidth - scrollOffset
break
}
}
} else {
windowElement.scrollLeft = scrollOffset
}
} else {
+ windowElement.scrollTop = scrollOffset
}
}
})
4.5 拖动滚动条
scrollBar.ts文件中:
const onThumbMouseDown = (e: Event) => {
// 阻止冒泡,上文我们也写了
e.stopImmediatePropagation()
// 避免拖动时按了ctrl键,鼠标必须是左键
if (
(e as KeyboardEvent).ctrlKey ||
[1, 2].includes((e as MouseEvent).button)
) {
return
}
state.isDragging = true
// bar.value.offset => el-scrollbar__thumb.offsetHeight 滑块的高度
// e[bar.value.client] => e.clientY 鼠标距离顶部的距离
// el.getBoundingClientRect()[bar.value.direction]) => el-scrollbar__thumb.getBoundingClientRect().top 滑块距离页面顶部的距离
// bar.value.axis => Y
state[bar.value.axis] =
e.currentTarget![bar.value.offset] -
(e[bar.value.client] -
(e.currentTarget as HTMLElement).getBoundingClientRect()[
bar.value.direction
])
emit('start-move')
attachEvents()
}
按下那一刻开始处理事件
const attachEvents = () => {
// 监听鼠标的移动和抬起事件
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
const thumbEl = unref(thumbRef)
if (!thumbEl) return
// 拖拽时,禁止鼠标长按长文本选中
onselectstartStore = document.onselectstart
document.onselectstart = () => false
thumbEl.addEventListener('touchmove', onMouseMove, { passive: true })
thumbEl.addEventListener('touchend', onMouseUp)
}
鼠标移动方法
// 处理鼠标移动事件,更新滑块的位置并发出滚动事件
const onMouseMove = (e: Event) => {
const { isDragging } = state
// 判断是否是拖动
if (!isDragging) return
// 判断滑块和滑轨dom是否存在
if (!thumbRef.value || !trackRef.value) return
// prevPage就是state.y,是鼠标距离滑块顶部的距离
const prevPage = state[bar.value.axis]
if (!prevPage) return
cAF(frameHandle!)
// using the current track's offset top/left - the current pointer's clientY/clientX
// to get the relative position of the pointer to the track.
// 鼠标距离滑轨顶部 = (滑轨距离浏览器顶部 - 鼠标距离浏览器顶部) * (-1)
const offset =
(trackRef.value.getBoundingClientRect()[bar.value.direction] -
e[bar.value.client]) *
-1
// find where the thumb was clicked on.
// 鼠标点击距离滑块顶部 = 滑块高度 - 鼠标点击距滑块底部
const thumbClickPosition = thumbRef.value[bar.value.offset] - prevPage
/**
* +--------------+ +--------------+
* | - <--------- thumb.offsetTop | |
* | |+| <--+ | |
* | - | | |
* | Content | | | |
* | | | | |
* | | | | |
* | | | | -
* | | +--> | |+|
* | | | -
* +--------------+ +--------------+
*/
// using the current position - prev position to
// 滑块距离滑轨顶部 = 鼠标距离滑轨顶 - 鼠标距离滑块顶
const distance = offset - thumbClickPosition
// get how many steps in total.
// gap of 2 on top, 2 on bottom, in total 4.
// using totalSteps ÷ totalSize getting each step's size * distance to get the new
// scroll offset to scrollTo
frameHandle = rAF(() => {
// 更新滑块的位置,通常是distance的值
state.traveled = Math.max(
props.startGap,
Math.min(
distance,
totalSteps.value // 2 is the top value
)
)
emit('scroll', distance, totalSteps.value)
})
}
执行父组件的这个
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
+ onScroll: onScrollbarScroll,
ratio: (clientSize * 100) / this.estimatedTotalSize,
scrollFrom:
states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
})
下面的逻辑和之前的就很类似了
const onScrollbarScroll = (distanceToGo: number, totalSteps: number) => {
// estimatedTotalSize.value => allVisibleData.length * itemSize
// clientSize.value => 视口高度(可视区域的高度)
// estimatedTotalSize.value - clientSize.value ==> 需要滚动的总距离
// totalSteps => bar.offsetHeight - thumb.offsetHeight
// (estimatedTotalSize.value - clientSize.value) / totalSteps ==> 需要滚动的距离 / 总步骤数 = 每一步的滚动距离
// distanceToGo => 当前位置滚动到目标为止的距离
// 每一步的距离 * 当前位置滚动到目标为止的距离 = wrap的scrollTop的值
const offset =
((estimatedTotalSize.value - (clientSize.value as number)) /
totalSteps) *
distanceToGo
scrollTo(
Math.min(
estimatedTotalSize.value - (clientSize.value as number),
offset
)
)
}
这里也几乎类似,这之后再执行onUpdated就好了
const scrollTo = (offset: number) => {
offset = Math.max(offset, 0)
if (offset === unref(states).scrollOffset) {
return
}
states.value = {
...unref(states),
scrollOffset: offset,
scrollDir: getScrollDir(unref(states).scrollOffset, offset),
updateRequested: true,
}
nextTick(resetIsScrolling)
}
5. 参考文章
阅读 element-ui 源代码来分析 Scrollbar(滚动条) 组件的实现
elementUI 源码-打造自己的组件库,系列五:Scrollbar组件