在 element-ui
组件库中,存在一个 Scrollbar
组件,Scrollbar
组件的作用是隐藏了原先的滚动条,创建了样式更优雅的滚动条。
不过该组件并没有在 element-ui
组件库的文档中写明。此篇将从 Scrollbar
组件的应用和 Scrollbar
组件的原理这两个维度来分析 Scrollbar
组件。
一. Scrollbar 组件的应用
Scrollbar
组件在 element-ui
组件库中,有很多地方都使用到了。例如:Select
组件下拉选项中显示的滚动条应用了 Scrollbar
组件。
1.1 Scrollbar 组件提供的属性
通过阅读 Scrollbar
组件的源代码,发现其提供以下属性:
参数 | 说明 | 类型 | 可选值 | 默认值 |
---|---|---|---|---|
native | 是否在鼠标移入时显示滚动条 | Boolean | true/false | -- |
wrapStyle | 元素外包裹样式(元素可视区域样式) | Array/String | -- | -- |
wrapClass | 元素外包裹类名(元素可视区域类名) | Array/String | -- | -- |
viewStyle | 元素内包裹样式(元素滚动区域样式) | Array/String | -- | -- |
viewClass | 元素内包裹类名(元素滚动区域类名) | Array/String | -- | -- |
noresize | 区域的尺寸是否会发生变化 | Boolean | true/false | -- |
tag | 元素内包裹渲染的标签 | String | section/ul 等标签 | -- |
1.2 Scrollbar 组件提供的方法
方法名 | 说明 | 参数 |
---|---|---|
update | 更新滚动条(此方法会更新滚动条的长度) | -- |
1.3 Scrollbar 组件的应用案例
1.3.1 引入 Scrollbar 组件
如果需要使用 Scrollbar
组件,则需要先引入该组件,引入 Scrollbar
组件包含两种方式:
- 如果整体引入了
element-ui
组件库,则可以直接使用Scrollbar
组件,因为该组件库中注册了Scrollbar
组件。如同以下代码整体引入了element-ui
组件库,则可以直接使用Scrollbar
组件:
js
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);
- 单独引入
Scrollbar
组件。
js
import { Scrollbar } from 'element-ui';
Vue.use(Scrollbar);
1.3.2 使用 Scrollbar 组件
- 一个基本的
Scrollbar
组件,鼠标移入滚动条会显示
给 Scrollbar
组件的样式设置一个高度,超出高度会滚动,鼠标移入之后会显示滚动条。
html
<template>
<el-scrollbar :wrapStyle="wrapStyle">
<div v-for="i in 50" :key="i">测试{{ i }}</div>
</el-scrollbar>
</template>
<script>
export default {
data() {
return {
wrapStyle: "height: 200px;"
}
},
}
</script>
- 滚动条隐藏的
Scrollbar
组件
设置 native
属性为 true
,鼠标移入之后滚动条不显示。
html
<template>
<el-scrollbar :wrapStyle="wrapStyle" :native="true">
<div v-for="i in 50" :key="i">测试{{ i }}</div>
</el-scrollbar>
</template>
<script>
export default {
data() {
return {
wrapStyle: "max-height: 200px;"
}
},
}
</script>
- 给
Scrollbar
组件设置样式style
分别传入 wrapStyle
和 viewStyle
参数去设置元素的外包裹样式和内包裹样式。传参支持字符串或者数组形式:
(1)传入字符串:传入字符串时需要注意末尾要增加分号,因为源代码中针对传入字符串的处理是采用的字符串拼接的形式,组件内部在传入的样式之后继续去拼接组件内部定义的样式,如果不增加分号会导致拼接后的 style
格式不符,从而失效。
例如:
js
export default {
data() {
return {
wrapStyle: "max-height: 200px;" // 传入这种形式的字符串
}
},
}
(2)传入数组:在给元素设置多个样式时,以数组的形式传入会更加清晰。
例如:
js
export default {
data() {
return {
wrapStyle: [
{ "max-height": "200px"},
{ "background": "#0088cc" }
]
}
},
}
- 给
Scrollbar
组件设置类名class
分别传入 wrapClass
和 viewClass
可以设置元素的外包裹类名和内包裹类名。传参支持字符串或者数组形式:
(1)传入字符串:如果只设置一个类名可以传入字符串。
例如:
js
export default {
data() {
return {
wrapClass: "classnameA"
}
},
}
(2)传入数组:如果需要设置多个类名可以传入数组。
例如:
js
export default {
data() {
return {
wrapClass: ["classnameA", "classnameB"]
}
},
}
- 让
Scrollbar
组件的内包裹div
渲染为ul
传入 tag
参数,可以自定义组件内包裹的标签,默认为 div
,如果传入 ul
则会渲染成 ul
列表。例如以下代码会渲染成为一个 ul
列表。
html
<template>
<el-scrollbar :wrapStyle="wrapStyle" tag="ul">
<li v-for="i in 50" :key="i">测试{{ i }}</li>
</el-scrollbar>
</template>
<script>
export default {
data() {
return {
wrapStyle: "max-height: 200px;"
}
},
}
</script>
二. Scrollbar 组件的原理
为了了解 Scrollbar
组件的原理和实现方式,阅读了 Scrollbar
组件的源代码,整理了以下几个方面去解析 Scrollbar
组件的实现原理:
- 组件的
HTML
结构和CSS
样式。 - 滚动条组件的实现。
- 监听滚动内容区域的变化(比如:原先滚动 50 条数据,之后动态变成了 25 条数据)。
2.1 组件的 HTML 结构和 CSS 样式
组件的 HTML
结构是通过 render
函数进行渲染的,在其函数内部根据传入的 native
参数,去判断鼠标移入是否显示滚动条,默认显示。
具体逻辑:
- 获取到滚动条的宽度。
- 将原生的滚动条隐藏。
- 整理传入的元素外包裹样式,将样式融合。
- 根据鼠标移入是否显示滚动条去渲染不同的
DOM
结构。
第一步:获取到滚动条的宽度
在源代码的 src/utils/scrollbar-width.js
中,有获取滚动条宽度的方法。
方法说明:
- 先创建一个隐藏的
div
,使用offsetWidth
获取没有滚动条的宽度。 - 将其
overflow
设置为scroll
,并且在这个隐藏div
的内部再放入一个div
,设置内部的div
宽度为 100%,再获取内部div
的offsetWidth
。 - 使用外部
div
的offsetWidth
减去内部div
的offsetWidth
就是滚动条的宽度。
js
import Vue from 'vue';
let scrollBarWidth;
export default function() {
if (Vue.prototype.$isServer) return 0;
if (scrollBarWidth !== undefined) return scrollBarWidth;
const outer = document.createElement('div');
outer.className = 'el-scrollbar__wrap';
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.position = 'absolute';
outer.style.top = '-9999px';
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
};
第二步:将原生的滚动条隐藏
通过两层 div
的方式将原生的滚动条隐藏,外层的 div
设置 overflow: hidden
,内层的 div
将 margin-right
和 margin-bottom
设置为滚动条的负值,这样可以将内层的 div
向右、向下延伸,再与外层的 overflow: hidden
结合,从而遮盖住滚动条。
代码类似这样:
html
<template>
<div class="el-scrollbar">
<div class="el-scrollbar__wrap"></div>
</div>
</template>
<style>
.el-scrollbar {
position: relative;
overflow: hidden;
}
.el-scrollbar__wrap {
margin-right: -17px;
margin-bottom: -17px;
}
</style>
第三步:整理传入的元素外包裹样式,将样式融合
js
if (Array.isArray(this.wrapStyle)) {
// 如果传入的是数组,则整理成 style[key] = value 的形式
style = toObject(this.wrapStyle);
style.marginRight = style.marginBottom = gutterWith;
} else if (typeof this.wrapStyle === 'string') {
// 如果传入的是字符串,则直接连接起来
style += gutterStyle;
} else {
// 如果是其他的情况,则直接赋值 margin-right 和 margin-bottom
style = gutterStyle;
}
js
// 将数组形式整理成对象的 key-value 形式
export function toObject(arr) {
var res = {};
for (let i = 0; i < arr.length; i++) {
if (arr[i]) {
extend(res, arr[i]);
}
}
return res;
};
function extend(to, _from) {
for (let key in _from) {
to[key] = _from[key];
}
return to;
};
第四步:根据鼠标移入是否显示滚动条去渲染不同的 DOM 结构
- 鼠标移入不显示滚动条,则不需要额外的逻辑,只需要将原生的滚动条隐藏即可。
- 鼠标移入显示滚动条,需要增加滚动条组件,处理滚动时的逻辑。
js
// 内层滚动区域的 DOM 结构
const view = h(this.tag, {
class: ['el-scrollbar__view', this.viewClass],
style: this.viewStyle,
ref: 'resize'
}, this.$slots.default);
// 包裹滚动区域的外层 wrap 的 DOM 结构
const wrap = (
<div
ref="wrap"
style={ style }
onScroll={ this.handleScroll }
class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
{ [view] }
</div>
);
let nodes;
if (!this.native) {
// 如果没有传入 native 参数,则需要显示滚动条,需要增加 Bar 组件。
nodes = ([
wrap,
<Bar
move={ this.moveX }
size={ this.sizeWidth }></Bar>,
<Bar
vertical
move={ this.moveY }
size={ this.sizeHeight }></Bar>
]);
} else {
// 如果传入了 native 参数,则只需要将原生的滚动条隐藏即可
nodes = ([
<div
ref="wrap"
class={ [this.wrapClass, 'el-scrollbar__wrap'] }
style={ style }>
{ [view] }
</div>
]);
}
// 最外层增加 class 为 el-scrollbar 的 div 包裹
return h('div', { class: 'el-scrollbar' }, nodes);
render() 函数整体代码:
js
// rander 函数渲染 Scrollbar 组件
render(h) {
// 获取滚动条的宽度
let gutter = scrollbarWidth();
// 获取到传入的 wrapStyle
let style = this.wrapStyle;
// 如果获取到了滚动条的宽度,则将传入的 wrapStyle 参数与隐藏滚动条的样式进行融合,从而整理出包裹滚动区域的 div 的样式
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;
}
}
// 内层滚动区域的 DOM 结构
const view = h(this.tag, {
class: ['el-scrollbar__view', this.viewClass],
style: this.viewStyle,
ref: 'resize'
}, this.$slots.default);
// 包裹滚动区域的外层 wrap 的 DOM 结构
const wrap = (
<div
ref="wrap"
style={ style }
onScroll={ this.handleScroll }
class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
{ [view] }
</div>
);
let nodes;
if (!this.native) {
// 如果没有传入 native 参数,则需要显示滚动条,需要增加 Bar 组件。
nodes = ([
wrap,
<Bar
move={ this.moveX }
size={ this.sizeWidth }></Bar>,
<Bar
vertical
move={ this.moveY }
size={ this.sizeHeight }></Bar>
]);
} else {
// 如果传入了 native 参数,则只需要将原生的滚动条隐藏即可
nodes = ([
<div
ref="wrap"
class={ [this.wrapClass, 'el-scrollbar__wrap'] }
style={ style }>
{ [view] }
</div>
]);
}
// 最外层增加 class 为 el-scrollbar 的 div 包裹
return h('div', { class: 'el-scrollbar' }, nodes);
},
2.2 滚动条组件的实现
通过阅读滚动条组件 bar.js
的源代码,将从如下几个方面去解析滚动条组件:
- 滚动条的基本属性。
- 滚动组件的基本构成。
- 滚动的实现。
2.2.1 滚动条的基本属性
滚动条分为横向滚动条和竖向滚动条。横向滚动条绝对定位到滚动区域的下方,竖向滚动条绝对定位到滚动区域的右方。
在源代码 packages/scrollbar/src/util.js
中定义了滚动条的基本属性:
js
export const BAR_MAP = {
// 竖向滚动条
vertical: {
offset: 'offsetHeight', // 返回该元素的像素高度,高度包含内边距(padding)和边框(border),不包含外边距(margin)
scroll: 'scrollTop', // 滚动条到元素顶部的距离
scrollSize: 'scrollHeight', // 返回包含滚动部分的内容的实际高度,在加上 padding
size: 'height', // 元素的高度
key: 'vertical', // 竖直滚动条
axis: 'Y', // Y 轴(垂直移动)
client: 'clientY', // 置或获取鼠标指针位置相对于窗口客户区域的 x 坐标
direction: 'top' // 获取的 getBoundingClientRect() 的方向
},
// 横向滚动条
horizontal: {
offset: 'offsetWidth', // 返回该元素的像素宽度,宽度包含内边距(padding)和边框(border),不包含外边距(margin)
scroll: 'scrollLeft', // 滚动条到元素左边的距离
scrollSize: 'scrollWidth', // 返回包含滚动部分的内容的实际宽度,在加上 padding
size: 'width', // 元素的宽度
key: 'horizontal', // 水平滚动条
axis: 'X', // X 轴(水平移动)
client: 'clientX', // 置或获取鼠标指针位置相对于窗口客户区域的 y 坐标
direction: 'left' // 获取的 getBoundingClientRect() 的方向
}
};
2.2.2 滚动组件的基本构成
接收的参数
滚动组件 bar.js
接收三个参数:
vertical
:是否是竖直滚动条。size
:滚动条的长度(竖直滚动条指的是高度,水平滚动条指的是宽度)。move
:滚动条的位移(竖直滚动条指的是纵向位移,水平滚动条指的是横向位移)。
js
props: {
vertical: Boolean,
size: String,
move: Number
},
滚动条的长度计算
计算思路:
滚动条的长度是用百分比去表示的。用外部包裹元素的 clientHeight(clientWidth)
除以 scrollHeight(scrollWidth)
再乘以 100 得到百分比的高度。
clientHeight(clientWidth)
指的是元素的content
+padding
的高度。scrollHeight(scrollWidth)
指的是元素的content
+ 溢出的不可见的部分 +padding
的高度。
计算方法:
js
update() {
let heightPercentage, widthPercentage;
const wrap = this.wrap;
if (!wrap) return;
// 用元素的可见高度(content + padding)的高度去除以元素的可见高度+不可见高度,得到其所占的百分比
heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
// 用元素的可见宽度(content + padding)的宽度去除以元素的可见宽度+不可见宽度,得到其所占的百分比
widthPercentage = (wrap.clientWidth * 100 / wrap.scrollWidth);
// 如果得到小于 100 的数字,则表示有超出的部分,需要显示滚动条,否测不需要显示滚动条
this.sizeHeight = (heightPercentage < 100) ? (heightPercentage + '%') : '';
this.sizeWidth = (widthPercentage < 100) ? (widthPercentage + '%') : '';
}
然后再把计算出来的百分比传入给 Bar
组件:
html
<Bar
move={ this.moveX }
size={ this.sizeWidth }></Bar>,
<Bar
vertical
move={ this.moveY }
size={ this.sizeHeight }></Bar>
滚动条的位移
滚动条的位移初始值是0,随着鼠标滚动和拖拽滚动会更新滚动条的位移,在后面的如何实现滚动条部分会说明如何计算滚动条的位移。
滚动条的渲染
DOM
结构:
滚动条的 DOM
结构分为两层,外层 div
是滚动的轨道,内层 div
是滚动条。
js
computed: {
bar() {
return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
},
...
},
render() {
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>
);
},
内层 div
的样式渲染:
js
/**
*
* @param {*} move 滚动条的位移
* @param {*} size 滚动条的长度
* @param {*} bar 滚动条属性
* @returns 样式
*/
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;
};
2.2.3 滚动的实现
想要实现滚动,需要先梳理触发滚动的情况:
- 鼠标的滚轮去触发滚动时,计算滚动条的实时位移。
- 拖拽滚动条进行滚动时,滚动条的位置和内容的位置实时改变。
- 在滚动的轨道上,按下鼠标,从而直接将滚动条置于某个位置来触发滚动,此时内容的位置也会更新。
鼠标滚轮触发滚动
鼠标的滚轮去触发滚动时,需要计算滚动条的实时位移,计算的位移通过百分比来表示。
需要给外层 wrap
绑定 scroll
事件,onScroll={ this.handleScroll }
handleScroll
的处理如下:
js
handleScroll() {
const wrap = this.wrap;
// 用滚动条到元素顶部的距离乘以100,再去除以元素的可见高度,从而得到滚动条距离顶部的百分比
this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
// 用滚动条到元素左侧的距离乘以100,再去除以元素的可见宽度,从而得到滚动条距离左侧的百分比
this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
},
通过 handleScroll
方法的处理,从而实时的去计算 moveY
和 moveX
。
拖拽滚动条进行滚动
拖拽滚动条分为三个步骤:鼠标按下 → 拖拽过程 → 鼠标抬起
鼠标按下的处理:
在滚动条上绑定 mousedown
事件,onMousedown={ this.clickThumbHandler }
clickThumbHandler
方法:
js
clickThumbHandler(e) {
// 如果按下了 ctrl 键,或者按下的是鼠标的右键,则直接 return
if (e.ctrlKey || e.button === 2) {
return;
}
// 处理拖拽
this.startDrag(e);
// 更新滚动条的 axis 属性,对应的是 Y 或者 X
// 这里计算的是点击的位置距离滚动条底部(或者右侧)的距离
// this[this.bar.axis] = 滚动条的高度(宽度)- (鼠标距离页面可视区域的位置 - 滚动条距离页面顶端或者左侧的位置)
// 鼠标距离页面可视区域的位置 - 滚动条距离页面顶端或者左侧的位置 = 鼠标距离滚动条顶部(或者左侧的距离)
this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
以竖向滚动条举例,如下图所示:
拖拽过程的处理:
开始拖拽 startDrag
事件:
js
startDrag(e) {
// 阻止事件冒泡并且阻止该元素上同事件类型的监听器被触发
e.stopImmediatePropagation();
// 用 cursorDown 变量记录鼠标被按下
this.cursorDown = true;
// 监听鼠标移动事件
on(document, 'mousemove', this.mouseMoveDocumentHandler);
// 监听鼠标抬起事件
on(document, 'mouseup', this.mouseUpDocumentHandler);
// 禁止选中
document.onselectstart = () => false;
},
鼠标移动实现拖拽的事件:
js
// 鼠标移动事件的处理
mouseMoveDocumentHandler(e) {
// 如果没有监听到鼠标按下,则直接 return
if (this.cursorDown === false) return;
// 这里是计算出来的点击的位置距离滚动条底部(或者右侧)的距离
const prevPage = this[this.bar.axis];
// 不存在 prevPage 则直接 return
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]);
// 计算出需要滚动的距离 = 滚动条距离滚动轨道最上方的百分比 * 元素的实际高度(content + padding + 滚动部分) / 100 => 得到实际需要滚动的距离
// 给 wrap 的 scrollTop 设置成需要滚动的距离,从而触发拖拽滚动的效果
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
如下几张图是计算过程的拆解:
offset
的计算:
thumbClickPosition
的计算:
thumbPositionPercentage
的计算:
最后需要计算 wrap
的 scrollTop
的值,使用 wrap
的 scrollHeight
和计算出的 thumbPositionPercentage
(滚动条距离滚动轨道最上方或者最左侧的百分比)相乘即可。
鼠标抬起的处理:
js
// 鼠标抬起事件的处理
mouseUpDocumentHandler() {
// 将 cursorDown 重置为 false
this.cursorDown = false;
// 将 this[this.bar.axis] 重置为 0
this[this.bar.axis] = 0;
// 取消监听 mousemove
off(document, 'mousemove', this.mouseMoveDocumentHandler);
// 取消禁止选中
document.onselectstart = null;
}
组件销毁时的处理:
取消监听 mouseup
事件。
js
destroyed() {
off(document, 'mouseup', this.mouseUpDocumentHandler);
}
在滚动的轨道上按下鼠标触发滚动
在外层滚动轨道上面绑定 mousedown
事件:onMousedown={ this.clickTrackHandler }
。
clickTrackHandler
方法的处理:
以鼠标的点击位置作为滚动条的中心,计算出 wrap
的 scrollTop
。
js
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]);
// 计算出需要滚动的距离 = 滚动条距离滚动轨道最上方的百分比 * 元素的实际高度(content + padding + 滚动部分) / 100 => 得到实际需要滚动的距离
// 给 wrap 的 scrollTop 设置成需要滚动的距离,从而触发拖拽滚动的效果
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
2.4 监听滚动内容区域的变化
滚动内容的多少发生变化是指原先滚动 50 条数据,动态变化为 25 条数据。
在 Scrollbar
组件中引入了 resize-observer-polyfill
去监听元素尺寸变化,当元素的尺寸发生变化时调用 update 方法,重新计算滚动条的长度。
当组件被销毁时,取消对元素尺寸变化的监听。
三. 总结
通过对 element-ui
组件库的阅读,了解到了其内部的 Scrollbar
组件,以及该组件的用法。然后通过阅读 Scrollbar
组件的源代码,梳理出来了实现滚动条的要素。
阅读组件库的源代码,是一个很有价值的过程,可以了解到组件结构,滚动机制等。同时,这些知识也可以应用到开发项目中,提高开发效率和代码质量。