共计 5965 个字符,预计需要花费 15 分钟才能阅读完成。
el-scrollbar 是啥?
Element-UI,作为一套非常出名 Vue 的 UI 组件库,玩 Vue 人几乎都认识它。最近在翻看 Element 的源码时,发现了一个有趣的现象,怎么 autocomplete 组件的联想列表组件 -> autocomplete-suggestions 里面,还包了一个 el-scrollbar 组件,这是用来做什么的?
经过一番了解,原来是 Element 自己写的一个滚动条组件 (但却没有公开发布出来),它屏蔽了原生的滚动条,使用了一个统一的样式来代替,解决了滚动条的兼容性问题。
如何使用?
关于 el-scrollbar 的使用方式,可以看 Github 上的 issues,这里也简单展示一下:在 el-scrollbar 的默认 slot 中填入一个列表,并设定最外层的包裹元素的高度,这样就能顺利产生滚动条了。
<template>
// 这里的 tag 属性可以先忽略,它用于控制生成的 view 元素具体是什么类型的元素
<el-scrollbar style="width: 150px; height: 50px" tag="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</el-scrollbar>
</template>
效果如下:
如何实现?
先来看刚刚的代码渲染出来的 DOM:
可以看到,我们的 li
被包裹在了 .el-scrollbar -> .&__wrap -> .&__view
里面,而底下还有两个 DOM:.is-horizontal
和 .is-vertical
,每个元素都有他自己的作用:
<div class="el-scrollbar"> // 根元素,包裹所有元素
<div class="el-scrollbar__wrap"> // wrap 元素,是视觉视口元素,它代表着元素最终展示的窗口大小
<ul class="el-scrollbar__view"> // 布局视口元素,它代表着整个列表(以及他们的宽高),通过调整 wrap 的 scrollTop/left,显示不同的 view 内容
// 默认插槽里的内容会被放在这里
</ul>
</div>
<div class="el-scrollbar__bar is-horizontal">...</div> // 横向滚动条
<div class="el-scrollbar__bar is-vertical">...</div> // 竖向滚动条
</div>
隐藏原有滚动条
了解了 wrap/view/bar 这几个概念之后,我们直接来看源码:element/packages/scrollbar/src/main.js
这个文件是 scrollbar 组件的入口文件,它定义了一些 /components/data/ 接受的 props,以及最重要的:render 函数。render 函数在被调用的时候,首先调用了 scrollbarWidth 函数:
let gutter = scrollbarWidth();
这个 gutter 的意思是当前浏览器的滚动条宽度,element 通过 scrollbarWidth 这个方法来获取到这个宽度,点击这个方法,可以看到其实它做了三件事情:
- 创建了一个 outer 元素,设置了宽度,拿到此时的 offsetWidth
- 把 outer 元素 overflow 设置为 visible,再创建一个 inner 元素,append 到 outer 上(此时会产生滚动条),再拿到 inner 的 offsetWidth。
- 两者相减即是滚动条的宽度
/* eslint-disable no-debugger */
import Vue from 'vue';
let scrollBarWidth;
export default function() {if (Vue.prototype.$isServer) return 0;
if (scrollBarWidth !== undefined) return scrollBarWidth;
// 创建外层的 div,此时是一个普通的 dom
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);
// 获取这个 dom 的实际宽度
const widthNoScroll = outer.offsetWidth;
// 修改外层 dom 的 css,设置为 overflow: scroll(默认产生滚动条)
outer.style.overflow = 'scroll';
// 创建内层的 div,并 append 到 outer 上
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
// 计算内层 div 的实际宽度
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
// 通过「无滚动条时的宽度」减去「有滚动条时的宽度」来算出滚动条的具体宽度
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
};
拿到了滚动条最主要的目的就是为了把它隐藏掉,这也是 render 函数接下来做的事情。
const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
// 根据传入的 wrapStyle 的不同类型,把 gutterStyle 加入进去
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
紧接着就是 DOM 的创建过程,先后创建了 view/wrap(监听其滚动事件),以及非原生版本 / 原生版本的根元素。如果你传入了 native: true
,就代表着使用了原生滚动条版本的 scrollbar。
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>
]);
}
在 wrap 窗口滚动时,handleScroll 方法会被执行,更新 data 中的 moveY 和 moveX 属性。这两者会被传入滚动条组件 Bar
,更新它的 translateY()/translateX()
,Bar 组件我们后面会讲到。
mount/beforeDestroy 钩子
在 mounted 的时候还做了一件事,就是给 view 元素添加了 resize 事件的监听器(beforeDestroy 时取消监听):
!this.noresize && addResizeListener(this.$refs.resize, this.update);
值得注意的是,addResizeListener 并不是简单地设置了 window.resize 回调,而是使用了一个船新的 api 来监听 DOM 元素的 resize:ResizeObserver API(具体可看这里的介绍)。总的来说,ResizeObserver 可以直接给 DOM 绑定事件,专门用来观察 DOM 元素的尺寸是否发生了变化,减少了 window.resize 带来的多余监听。
为了给某个元素实现多个 resize 事件的监听,element 还使用了观察者模式,给 DOM 元素绑定了一个 __resizeListeners__
数组,当有 resize 事件被触发时,执行整个 _
_resizeListeners_
_ 数组的所有回调。
DOM 元素一旦 resize,就会执行 update 回调。那么 update 的时候做了什么事情呢?
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 + '%') : '';
}
update 方法负责更新 Bar 的滑块长度(可能是横向 / 竖向滚动条),我们以竖向滚动条为例:首先通过 clientHeight * 100/scrollHeight
得到 resize 后的 wrap 展示高度和总高度的比例,这也是 scrollbar 滑块长度的比例,再把它传入给表示滚动条的 Bar
组件,更新滚动条的 height。
这个时候如果比例值大于 100,说明已经不需要滚动条了,则传一个空字符串给 Bar
。
点击 / 拖动滚动条
到了这一步,我们的滚动条组件已经创建完成了,但是我们点击滚动条或者拖动滚动条的时候,这个组件如何处理呢?还得看 element/packages/scrollbar/src/bar.js
这个组件。
Bar 组件负责展示滚动条,我们直接来看它的 render 函数:
render(h) {
// move 属性用于控制滚动条的滚动位置
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>
);
}
我们可以看到重点在于 clickTrackHandler/clickThumbHandler 这两个函数,他们分别用于控制滚动条 container 被点击时的行为,以及滚动条本身被点击的时候产生的行为。
clickTrackHandler:快速跳到某个区间
clickTrackHandler(e) {
/**
* 0. 以垂直滚动条为例:* this.bar.direction = "top"/this.bar.client = "clientY"/this.bar.offset="offsetHeight"/this.bar.scrollSize="scrollHeight"
* 1. getBoundingClientRect()[this.bar.direction] 返回元素的 top 值(距离浏览器视口的高度值)* 2. 用 1 的值减去 e.clientY(鼠标当前位置), 再用 Math.abs 得出相对值,这个值就是鼠标在滚动条 container 上的相对偏移量。* 3. 计算出滚动条滑块的一半位置 thumbHalf
* 4. offset - thumbHalf 得到具体偏移量,并除以整个 bar 的 offsetHeight,得到了滑块新的位置的百分比。* 5. 接下来就可以愉快地更新 wrap 元素的 scrollTop,显示新的内容啦~
* 6. wrap 滚动后会触发 handleScroll 方法,回过头来更新 Bar 组件的 move 值,从而更新滚动条位置。*/
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]);
// 设置外壳的 scrollHeight 或 scrollWidth 新值。达到滚动内容的效果
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
clickThumbHandler:拖动滚动条滑块更新视图
这里主要是计算拖动时滑块的高度与整个滚动条的比例,从而更新 wrap 元素的 scrollTop 值,具体代码与 clickTrackHandler 较为相似,由于篇幅所限,就不赘述了。
这里有一个小点,我们是给滑块元素绑定 onMousedown 事件的,但是 mousemove 和 mouseup 却是绑定在 document 上的,这是因为鼠标在移动过程中,会比滑块的移动要快,此时滑块元素会失去 onMousemove 事件,所以绑定 mousemove 的时候不能绑定在对应元素上。
总结
我们从整个滚动条元素的生命周期,看到 element 是如何创建出一个滚动条,如何监听元素的变化,如何控制滚动条的滑动等等。源码的阅读到这里就全部结束了,如有什么错漏,请帮忙指出来;如你有所收获,是我莫大的荣幸。
感谢:
Element-ui el-scrollbar 源码解析
ResizeObserver API