最近使用vue在做pc端项目,需要处理滚动条样式外加滚动加载。
使用了betterscroll和perfectscroll发现前者还是更偏向于移动端(也可能我比较着急没用明白),
后者在ie11上面拖拽滚动条的时候会闪动,会有兼容性问题。
经同事点播,最终使用了element-ui里面的scrollbar,发现还真是不错,用着简单而且兼容性好。
所以,来看看源码是怎么实现的,学习学习,进步进步。
先看一下怎么使用,便于理解组件的设计思路
<el-scrollbar class="content-table" :tag="'div'">
<div v-for="(info, idx) in tab.humanInfo" :key="idx">
<div></div>
</div>
</el-scrollbar>
如上图代码,直接把会很长的需要滚动的内容放到el-scrollbar组件里面就好,其他细节不做赘述。
生成的html代码如下,这张图pic-html的内容后面会多次提及。
根据对照页面上的效果可知,div.el-scrollbar是最外层的容器,可以通过写自己的class来添加出现滚动条的高度。
.el-scrollbar{
overflow: hidden;
position: relative;
}
div.el-scrollbar__wrap是实际上真正用来包裹内容并产生滚动条的容器,高度100%,div.el-scrollbar__view里面就是我们自己写的需要滚动的内容。
.el-scrollbar__wrap{
overflow: scroll;
height: 100%;
}
div.el-scrollbar__bar是滚动条的轨道,分为横向和纵向,横向为is-horizontal,纵向为is-vertical,两者其实差不多,就只关注纵向了。鼠标划入划出滚动区域,滚动条会随之显示隐藏。
.el-scrollbar__bar {
position: absolute;
right: 2px;
bottom: 2px;
z-index: 1;
border-radius: 4px;
opacity: 0;
-webkit-transition: opacity 120ms ease-out;
transition: opacity 120ms ease-out;
}
.el-scrollbar__bar.is-vertical {
width: 6px;
top: 2px;
}
div.el-scrollbar__thumb在div.el-scrollbar__bar内部,作为滚动条,hover会产生背景色的变化
.el-scrollbar__thumb {
position: relative;
display: block;
width: 0;
height: 0;
cursor: pointer;
border-radius: inherit;
background-color: rgba(144,147,153,.3);
-webkit-transition: .3s background-color;
transition: .3s background-color;
}
ok,到这里就把html结构大致分析完了,接着来看源码了,看看到底咋回事儿了
scrollbar/src/main.js就是组件主入口文件了,首先看一下里面写了什么
props: {
native: Boolean,
wrapStyle: {},
wrapClass: {},
viewClass: {},
viewStyle: {},
noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
tag: {
type: String,
default: 'div'
}
},
props主要传入内联样式和自定义class,都以默认值来分析代码降低不必要的复杂度
data() {
return {
sizeWidth: '0',
sizeHeight: '0',
moveX: 0,
moveY: 0
};
},
computed: {
wrap() {
return this.$refs.wrap;
}
},
data中存了4个状态,是用来存储滚动条长度和滚动条移动距离,滚动条的长度是要随着滚动内容变长而变短的
computed中存储了ref=wrap这个dom节点,其实就是div.el-scrollbar__wrap这个产生滚动条的容器
methods: {
handleScroll() {
const wrap = this.wrap;
this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
},
update() {
let heightPercentage, widthPercentage;
const wrap = this.wrap;
if (!wrap) return;
heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
widthPercentage = (wrap.clientWidth * 100 / wrap.scrollWidth);
this.sizeHeight = (heightPercentage < 100) ? (heightPercentage + '%') : '';
this.sizeWidth = (widthPercentage < 100) ? (widthPercentage + '%') : '';
}
},
methods中存储了2个方法,以下均以纵向滚动条分析
handleScroll顾名思义,就是滚动事件的监听函数。这个函数用 内容的滚动距离/视口高度 算出滚动条滚动的百分比。之所以用滚动距离和视口高度相比,举例说明一下,假如滚动内容scrollHeight为2个视口高度,那么滚动距离scrollTop为1个视口高度的时候,滚动条应该是滚到底部,滚动条最上方距离滚动条轨道最上方应该是自身的高度,也就是说moveY为100%,滚动条高度为滚动轨道的50%
update,用于随着滚动内容的改变,来更新滚动条的高度,滚动条高度的计算方法为,视口高度/滚动区域高度,依旧可以用上面的例子来解释。
如果所得百分比不小于100%,则滚动条高度为空,即没有可滚动空间,滚动条不出现
mounted() {
if (this.native) return;
this.$nextTick(this.update);
!this.noresize && addResizeListener(this.$refs.resize, this.update);
},
beforeDestroy() {
if (this.native) return;
!this.noresize && removeResizeListener(this.$refs.resize, this.update);
}
mouted执行了一次update方法,用于初始化滚动条长度,并为div.el-scrollbar__view添加resize事件监听,只要其中的内容改变引起高度变化,则更新滚动条高度
beforeDestroy用于组件销毁时干掉resize事件
下面进入main.js的主要实现逻辑部分,采用了渲染函数和jsx混用的方式实现
render(h) {
....
}
由于render函数里面内容很多,这里拆开来分析
let gutter = scrollbarWidth();
let style = this.wrapStyle;
if (gutter) {
const gutterWith = `-${gutter}px`;
const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
if (Array.isArray(this.wrapStyle)) {
style = toObject(this.wrapStyle);
style.marginRight = style.marginBottom = gutterWith;
} else if (typeof this.wrapStyle === 'string') {
style += gutterStyle;
} else {
style = gutterStyle;
}
}
gutter获取了浏览器的滚动条宽度,并且对props传入的样式进行分情况处理,最终style获得到一个样式,其中主要是要包含margin-bottom=横向滚动条高度;margin-right=纵向滚动条宽度
const view = h(this.tag, {
class: ['el-scrollbar__view', this.viewClass],
style: this.viewStyle,
ref: 'resize'
}, this.$slots.default);
const wrap = (
<div
ref="wrap"
style={ style }
onScroll={ this.handleScroll }
class={ [this.wrapClass, 'el-scrollbar__wrap',
gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
{ [view] }
</div>
);
使用渲染函数的方式,拼接出div.el-scrollbar__view,里面包含滚动的内容,ref=‘resize’,前面提到的resize的监听函数就绑在这个方法上面
并且将滚动事件绑在wrap上,将refs.resize放在wrap容器的内部,style样式放在了wrap上面
wrap的样式并没有width,而是直接进行流体布局,这样的话就可以通过添加style样式中的margin-right的值来改变wrap的外部尺寸特性,使得wrap的长度向右拉伸一个滚动条的宽度那么大的距离。而wrap的外部容器的overflow为hidden,所以多出来的距离刚好被外部容器隐藏。那个距离就是原生滚动条产生的位置,这样就实现了隐藏真正的滚动条。这样处理的好处是没有兼容性问题。
let nodes;
if (!this.native) {
nodes = ([
wrap,
<Bar
move={ this.moveX }
size={ this.sizeWidth }></Bar>,
<Bar
vertical
move={ this.moveY }
size={ this.sizeHeight }></Bar>
]);
} else {
nodes = ([
<div
ref="wrap"
class={ [this.wrapClass, 'el-scrollbar__wrap'] }
style={ style }>
{ [view] }
</div>
]);
}
return h('div', { class: 'el-scrollbar' }, nodes);
将wrap和bar组件放在容器div.el-scrollbar中
moveY和sizeHeight的数据传入到bar组件中,bar组件为滚动条组件,滚动事件通过操作moveY和sizeHeight的值来操作滚动条的位置和长度
下面来看一下bar组件的源码
props: {
vertical: Boolean,
size: String,
move: Number
},
传入的props上面已经说到了,只是还有一个用来区分横纵滚动条的一个入参
computed: {
bar() {
return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
},
wrap() {
return this.$parent.wrap;
}
},
computed返回了bar,这个主要是一些配置,wrap就还是上面说到的外面的那个wrap容器
export const BAR_MAP = {
vertical: {
offset: 'offsetHeight',
scroll: 'scrollTop',
scrollSize: 'scrollHeight',
size: 'height',
key: 'vertical',
axis: 'Y',
client: 'clientY',
direction: 'top'
},
horizontal: {
offset: 'offsetWidth',
scroll: 'scrollLeft',
scrollSize: 'scrollWidth',
size: 'width',
key: 'horizontal',
axis: 'X',
client: 'clientX',
direction: 'left'
}
};
下面我们来看一下bar中的主要逻辑
render(h) {
const { size, move, bar } = this;
return (
<div
class={ ['el-scrollbar__bar', 'is-' + bar.key] }
onMousedown={ this.clickTrackHandler } >
<div
ref="thumb"
class="el-scrollbar__thumb"
onMousedown={ this.clickThumbHandler }
style={ renderThumbStyle({ size, move, bar }) }>
</div>
</div>
);
},
这段代码主要生成了滚动条的html,包含了点击滚动条轨道,拖拽滚动条,改变滚动条位置的逻辑
先来看一下renderThumbStyle函数的作用
export function renderThumbStyle({ move, size, bar }) {
const style = {};
const translate = `translate${bar.axis}(${ move }%)`;
style[bar.size] = size;
style.transform = translate;
style.msTransform = translate;
style.webkitTransform = translate;
return style;
};
这个函数用来生成一段css样式,根据size、move的值动态控制滚动条位置。实现原理主要是依靠css的translate根据百分比来调整滚动条位置,调整height来动态改变滚动条的长度。
继续看一下div.el-scrollbar__bar轨道上绑定的点击事件监听函数clickTrackHandler
clickTrackHandler(e) {
const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] -
e[this.bar.client]);
const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
这里面的很多变量都是取自上面介绍的那个配置文件里面的配置
先获取点击的轨道div.el-scrollbar__bar距离整个document视口最上方的距离 减去 鼠标点击的位置距离视口最上方的距离,得到鼠标点击位置距离滚动条轨道最上方的距离,取绝对值赋值给offset
获取滚动条的offsetHeight的一半赋值给thumbHalf
offset 减去 thumbHalf可获取当滚动条中心在鼠标点击位置时,滚动条最上方距离滚动条轨道最上方的距离,用这个值比上滚动条滚到的长度,获取一个相对于滚动条轨道长度的百分比,将这个百分比赋值给thumbPositionPercentage
用thumbPositionPercentage乘以wrap的scroolHeight获取scrollTop的值,赋值给wrap的scrollTop,实现内容的滚动,内容滚动之后触发滚动事件,执行监听函数,改变move和size的值执行renderThumbStyle,从而改变滚动条的位置。
下面是div.el-scrollbar__thumb上的点击拖拽滚动条事件监听函数clickThumbHandler
clickThumbHandler(e) {
this.startDrag(e);
this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] -
e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
clickThumbHandler中包含一个拖拽函数startDrag,以及一个赋值操作,对于纵向滚动条来讲,就是this.Y = 滚动条的高度 减去 鼠标点击位置距离滚动条最上方位置的距离,即鼠标点击位置到滚动条最下方位置的距离
startDrag
startDrag(e) {
e.stopImmediatePropagation();
this.cursorDown = true;
on(document, 'mousemove', this.mouseMoveDocumentHandler);
on(document, 'mouseup', this.mouseUpDocumentHandler);
document.onselectstart = () => false;
},
阻止事件冒泡,此处的stopImmediatePropagation和stopPropagation不太一样
-----------------------------------------------------------分割线------------------------------------------------------------------------
1、stopImmediatePropagation方法:stopImmediatePropagation方法作用在当前节点以及事件链上的所有后续节点上,目的是在执行完当前事件处理程序之后,停止当前节点以及所有后续节点的事件处理程序的运行
2、stopPropagation方法:stopPropagation方法作用在后续节点上,目的在执行完绑定到当前元素上的所有事件处理程序之后,停止执行所有后续节点的事件处理程序
var div = document.getElementById("div1"); var btn = document.getElementById("button1"); div.addEventListener("click" , function(){alert("第一次执行");} , true); //1 div.addEventListener("click" , function(){alert("第二次执行");} , true); //2 btn.addEventListener("click" , function(){alert("button 执行");}); //3
在这里,给 1 函数alert后加上stopImmediatePropagation, 那么之后弹出窗口“第一次执行”
但是如果给 1 函数alert后加上stopPropagation , 那么之后会弹出窗口“第一次执行”,“第二次执行”两个窗口
----------------------------------------------------------------分割线-----------------------------------------------------------------
拉回来,继续看这个拖拽函数
阻止冒泡之后,设置鼠标点击状态为true,监听mousemove和mouseup,一般的拖拽逻辑都是这种写法
document.onselectstart = () => false;是为了在点击页面时不会选中文本出现蓝色选中
mouseMoveDocumentHandler(e) {
if (this.cursorDown === false) return;
const prevPage = this[this.bar.axis];
if (!prevPage) return;
const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) *
-1);
const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 /
this.$el[this.bar.offset]);
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
mouseUpDocumentHandler(e) {
this.cursorDown = false;
this[this.bar.axis] = 0;
off(document, 'mousemove', this.mouseMoveDocumentHandler);
document.onselectstart = null;
}
mouseMoveDocumentHandler为mousemove的监听函数,其中做了2个判断,cursorDown === false,this.Y为空的时候不会执行函数,也就是说当没有点击状态,没有识别到鼠标点到滚动条上的时候不执行代码。如果点到滚动条,获取鼠标点击位置距离滚动条轨道最上方的距离赋值给offset,将鼠标点击位置距离滚动条最上方的距离赋值给thumbClickPosition,两者相减,得到滚动条距离滚动条轨道最上方的距离,将这个距离比上滚动条轨道的长度,获得一个百分比,将这个百分比赋值给thumbPositionPercentage。用thumbPositionPercentage乘以scrollHeight获取滚动距离scrollTop,实现内容滚动,滚动条随之移动,实现拖拽效果。
mouseUpDocumentHandler为mouseup监听函数,当鼠标完成拖拽擡起时,执行此函数。将cursorDown,this.Y重置回默认值,解绑mousemove监听函数,解绑document.onselectstart监听函数。
destroyed() {
off(document, 'mouseup', this.mouseUpDocumentHandler);
}
组件销毁时解绑mouseup事件
将上面的这一大堆的源码解读串起来的整体逻辑是:
鼠标直接滚动时,触发滚动事件监听函数,根据scrollTop、clientHeight、scrollHeigth计算滚动条的滚动距离的百分比,即move。
move作为滚动条组件的props,传入bar组件,动态计算滚动条的translateY,实现滚动条相对于滚动条轨道的位置改变。
鼠标直接作用在滚动条轨道和滚动条上时,根据鼠标位置,滚动条位置计算出滚动距离的百分比,根据百分比计算出scrollTop并赋值给wrap.scrollTop,触发滚动事件监听函数改变move,从而改变滚动条的位置。
初始化或者滚动内容改变时,触发update函数,根据clientHeight、scrollHeight计算出滚动条高度百分比size,传入bar组件,计算height,进而改变滚动条的高度。