本文是我学习 ElementUI 源码的第三篇文章,上一篇文章学习了 ElementUI 中 Collapse 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 Scrollbar(模拟滚动条)组件的。
目前常用的几款浏览器中只有 Chrome 开放了对内置滚动条的样式修改,而每款浏览器滚动条的宽度(或高度,但在未做修改的前提下,滚动条高度和宽度在浏览器内部是相同的)也不完全相同,这对于内容样式是有一定影响的。
因此为了让组件内部的滚动条样式在不同浏览器上都保持一样的效果,ElementUI 开发了 Scrollbar 组件,但这个组件并没有开放给用户使用,而是作为内部组件提供给 Select(选择器) 等组件的。
功能需求
在正式开始学习前,我们先思考一些问题,然后再带着问题去学习,效果会更好。
内置滚动条有哪些功能?大致有:
- 通过滚动鼠标滚轮实现内容滚动。
- 通过拖动滚动条中的滑块实现内容滚动。
- 通过点击滚动条的滑轨实现内容滚动。
- 通过点击滚动条前后的按钮实现内容滚动。
- 键盘控制滚动。
要统一样式,需要对内置滚动条的哪些东西进行模拟?应该有:
- 外层轨道。
- 滑块。
- 滚动条前后的按钮。
- 键盘控制。
实际上真的需要做这么多吗?
并不需要,多数情况下滚动条只是起到了指示位置以及通过妥当滑块快速切换位置的作用,所以只要模拟出这两个功能就可以了,毕竟并不是真的要制作一个滚动条 :trollface:。
组件实现
ElementUI 使用了 render
函数来实现 Scrollbar 组件,当然,用模版也是可以的。
Scrollbar 组件的源码大致分布在三个文件中:
scrollbar
├── index.js
└── src
├── bar.js
├── main.js
└── util.js
其中 util.js
是起辅助作用的,我们重点分析 main.js
和 bar.js
,在过程中顺带分析 util.js
。
main.js
Scrollbar 组件可分为三部分,最外层的 wrap
,视图区 view
和滚动条 bar
,此文件主要是实现的 wrap
和 view
,我们依然大致按照书写顺序来做分析此文件。
首先在头部引入了一些辅助方法以及滑块:
import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';
import { toObject } from 'element-ui/src/utils/util';
import Bar from './bar';
在导出的对象中,Scrollbar 还留有 props
可以再对样式做一定程度上的调整:
export default {
//...
props: {
native: Boolean,
wrapStyle: {},
wrapClass: {},
viewClass: {},
viewStyle: {},
noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
tag: {
type: String,
default: 'div'
}
},
...
}
有一点不解的是,ElementUI 并没有对设置样式部分的 props
做类型限制。
data
中的内容如下:
export default {
//...
data() {
return {
sizeWidth: '0',
sizeHeight: '0',
moveX: 0,
moveY: 0
};
}
//...
}
moveX
和 moveY
用于设置滚动条滑块的移动距离,sizeWidth
和 sizeHeight
用于设置滚动条的宽度和高度。
computed
中返回了对此组件引用变量:
export default {
//...
computed: {
wrap() {
return this.$refs.wrap;
}
}
//...
}
render
函数中是实现 Scrollbar 组件的主要逻辑,其内容如下:
export default {
...
render(h) {
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;
}
}
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>
);
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);
},
...
}
- 第 4 行
定义了一个变量 gutter
,它的作用是什么呢?前文中曾说过,每款浏览器的滚动条宽度是不等的,这个 gutter
就是指的浏览器内置滚动条的宽度。
我们来看看 ElementUI 是如何获取浏览器内置滚动条宽度的,scrollbarWidth
方法的源码如下:
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;
};
可以看到 ElementUI 用了一种非常巧妙的方式来获取滚动条宽度:
- 首先创建一个空的
div
,通过绝对定位将其移动到可见区域外部,以避免让用户看到,并将这个div
的offsetWidth
保存下来; - 接着,在这个空的
div
内部再创建一个div
,设置外层div
的overflow
为scroll
,让其产生滚动条,并将外层div
此时的offsetWidth
保存下来; - 两者相减,得到滚动条的宽度,然后将整个
div
删除。
看了这个过程,茅塞顿开,不由惊叹 :plus1:。
当然,ElementUI 还做了两项优化,第一项是通过 Vue 的 $isServer
属性判断是否为服务器渲染;第二项是如果 scrollBarWidth
已经有值了,就不再执行后续代码,减少开销。
- 第 11 行
这里对传入的 wrapStyle
做了判断,如果是一个数组,则使用 toObject
方法转化为对象,toObject
方法及其引用如下:
function extend(to, _from) {
for (let key in _from) {
to[key] = _from[key];
}
return to;
};
export function toObject(arr) {
var res = {};
for (let i = 0; i < arr.length; i++) {
if (arr[i]) {
extend(res, arr[i]);
}
}
return res;
};
从代码来看,wrapStyle
数组中的元素需要是对象才行,如果是因为传入多属性的字符串太长,不方便阅读与维护的话,似乎传入一个对象更简单,有些不解 😕。
- 第 36 行
可以看到,Scrollbar 还可以选择是否使用原生滚动条,当不使用原生滚动条时 wrap
上绑定了 onscroll
事件(当然,两者情况下的模版结构也不同),见 29 行。
handleScroll
的内容如下:
export default {
methods: {
handleScroll() {
const wrap = this.wrap;
this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
},
},
}
在滚动鼠标滚轮的时候获取到已滚动高度(宽度)与总体高度(宽度)之间的比例并传给滑块 bar
,更新其位置。
需要注意的是,这里使用的是 clientHeight
和 clientWidth
,这个高度(宽度)是不包含内置滚动条高度(宽度)的。
mounted
钩子中的内容是用于处理用户调整大小的情况的:
export default {
//...
mounted() {
if (this.native) return;
this.$nextTick(this.update);
!this.noresize && addResizeListener(this.$refs.resize, this.update);
}
//...
}
而 beforeDestroyed
钩子中的内容就是取消事件监听:
export default {
//...
beforeDestroy() {
if (this.native) return;
!this.noresize && removeResizeListener(this.$refs.resize, this.update);
}
//...
}
我们先来看看 addResizeListener
和 removeResizeListener
的内容:
import ResizeObserver from 'resize-observer-polyfill';
const isServer = typeof window === 'undefined';
/* istanbul ignore next */
const resizeHandler = function(entries) {
for (let entry of entries) {
const listeners = entry.target.__resizeListeners__ || [];
if (listeners.length) {
listeners.forEach(fn => {
fn();
});
}
}
};
/* istanbul ignore next */
export const addResizeListener = function(element, fn) {
if (isServer) return;
if (!element.__resizeListeners__) {
element.__resizeListeners__ = [];
element.__ro__ = new ResizeObserver(resizeHandler);
element.__ro__.observe(element);
}
element.__resizeListeners__.push(fn);
};
/* istanbul ignore next */
export const removeResizeListener = function(element, fn) {
if (!element || !element.__resizeListeners__) return;
element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1);
if (!element.__resizeListeners__.length) {
element.__ro__.disconnect();
}
};
我们都知道,浏览器原本提供的 resize
方法只能用于检测文档视图的大小调整,无法检测元素的大小调整。
ElementUI 使用的是一个尚处于试验阶段的接口 ResizeObserver
,因此在头部可以看到引入的补丁。这个接口接口可以监听到 Element 的内容区域或 SVGElement 的边界框改变。
addResizeListener
接收两个参数,第一个是被检测元素,第二是被检测元素大小改变时的回调函数。在方法内部,首先在元素上定义了一个回调函数数组 __resizeListeners__
,接着再定义了一个 ResizeObserver
对象 __ro__
,并用这个对象的 observe
方法监听元素,需要注意的是 observe
方法只接收一个元素,但是可以多次调用 observe
方法以监听不同的元素。
用 new ResizeObserver
创建对象的时候,它接收一个回调函数(这里是 resizeHandler
),并且会将被监听元素以数组形式作为参数传入,因此可以在 resizeHandler
中按顺序执行 __resizeListeners__
中的所有回调函数。
removeResizeListener
在检测到已经没有回调函数的时候会调用 disconnect
方法结束 __ro__
对象上所有对元素的监听。
提示
unobserve
方法是取消对某个元素的监听,注意区别
而 update
方法是为了完成在调整大小后修改模拟滚动条滑块的高度和宽度:
export default {
methods: {
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 + '%') : '';
}
},
}
main.js
的内容就是这些,可以看到,ElementUI 实现模拟滚动条的方式是在右侧(或底部)通过绝对定位放置滚动条,然后利用内置滚动条的位置来控制模拟滚动条滑块的位置,还是比较复杂的。
bar.js
滑块 bar
也是使用 render
函数编写的,我们还是大致按照源码书写顺序一点一点分析。
首先还是在头部引入了一些辅助性的函数和对象,具体内容我们稍后再做分析:
import { on, off } from 'element-ui/src/utils/dom';
import { renderThumbStyle, BAR_MAP } from './util';
在 props
中定义了三个变量,分别用于设置滑块的长度(宽度)、移动距离,以及滚动条的方向:
export default {
//...
props: {
vertical: Boolean,
size: String,
move: Number
}
//...
}
computed
中有两个变量:
export default {
//...
computed: {
bar() {
return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
},
wrap() {
return this.$parent.wrap;
}
}
//...
}
其中,bar
最终得到的是一个与方向相对应的配置对象,BAR_MAP
的代码如下:
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'
}
};
而 wrap
则是对父组件 DOM 的引用,在后面会用到。
render
中的内容就很关键了:
export default {
//...
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>
);
},
//...
}
- 第 9 行
clickTrackHandler
实现了点击滚动条滑轨时的滚动,其内容如下:
export default {
//...
methods: {
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);
},
},
//...
}
实现的重点是要获取正确的滚动距离并设置父组件的 scrollTop
或 scrollLeft
,为什么说是正确的滚动距离呢?因为 ElementUI 此处并没有完全和内置滚动条的行为保持一致。当点击内置滚动条的滑轨时,滑块和页面会等步长移动;而点击模拟滚动条的滑轨时,滑块的中心会直接移动到点击处(若已经很接近端点,则不一定处于滑块的中心)。
关键就是第 5 行中的 getBoundingClientRect
,这个方法会返回元素的大小及其相对于视口的位置:
不过,这里有一个小问题,可能是 ElementUI 没有发现或者写漏了,那就是没有对鼠标的左右键作区分,如果是点击的鼠标右键,应该无法滚动,但实际上也是可以滚动的。
- 第 13 行
clickThumbHandler
实现了拖动滑块改变滚动条位置,其内容如下:
export default {
//...
methods: {
clickThumbHandler(e) {
// prevent click event of right button
if (e.ctrlKey || e.button === 2) {
return;
}
this.startDrag(e);
this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
},
//...
}
前文说过,ElementUI 在处理点击滚动条滑轨进行滚动的时候没有区分鼠标的左右键,而点击滑块的是做了区分的,同时还区分了是否按下了 Ctrl
键。
需要注意的是,假设滚动条为垂直方向,第 10 行中的 this[this.bar.axis]
也就是 this.Y
并没有在 data
中定义,且后文中的 cursorDown
也同样未定义。
startDrag
的内容如下:
export default {
//...
methods: {
startDrag(e) {
e.stopImmediatePropagation();
this.cursorDown = true;
on(document, 'mousemove', this.mouseMoveDocumentHandler);
on(document, 'mouseup', this.mouseUpDocumentHandler);
document.onselectstart = () => false;
},
},
//...
}
重点关注第 5 行和第 10 行,第 5 行中的 stopImmediatePropagation
方法和 stopPropagation
是有区别的,stopPropagation
只是单纯的阻止事件向上冒泡,而 stopImmediatePropagation
不仅会阻止事件冒泡,还会阻止此事件的其他监听器被调用。
简单来说,就是在阻止事件冒泡的同时,当有多个监听器监听同一事件时,如果在某一监听器中调用了 stopImmediatePropagation
,则还未被调用的监听器都不会被调用了。
第 10 行是为了防止用户随意拖动释放后因超过滚动条范围而造成的误选。
方法 on
的内容如下:
export const on = (function() {
if (!isServer && document.addEventListener) {
return function(element, event, handler) {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
};
} else {
return function(element, event, handler) {
if (element && event && handler) {
element.attachEvent('on' + event, handler);
}
};
}
})();
它和后文的 off
方法相对,专门用于监听器的绑定与移除,重点是为了做兼容,off
方法的内容如下:
export const off = (function() {
if (!isServer && document.removeEventListener) {
return function(element, event, handler) {
if (element && event) {
element.removeEventListener(event, handler, false);
}
};
} else {
return function(element, event, handler) {
if (element && event) {
element.detachEvent('on' + event, handler);
}
};
}
})();
需要注意的是,拖动事件的监听器都是绑定在 document
上的,这样做的好处是,用户不用准确的在滚动条区域拖动才有效果,体验更好(由此造成的误选已经被处理了)。
提示
addEventListener
和 removeEventListener
方法的第三个参数其实已经发生的改变,不再是仅仅表示是否使用捕获模式的 useCapture
了,而是一个包含了 userCapture
等属性的对象 options
,感兴趣的朋友可以自行查看。
mouseMoveDocumentHandler
的内容如下:
export default {
//...
methods: {
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);
},
},
//...
}
这里的难点也同样是在于获取准确的拖动距离,前文已经讲过,指的一提的是 prevPage
就是前文所说的未在 data
中的定义的 Y
(或 X
)。
mouseUpDocumentHandler
的内容如下:
export default {
//...
methods: {
mouseUpDocumentHandler(e) {
this.cursorDown = false;
this[this.bar.axis] = 0;
off(document, 'mousemove', this.mouseMoveDocumentHandler);
document.onselectstart = null;
}
},
//...
}
在松开鼠标后就立马移除了对 mousemove
事件的监听,减少开销。
destroyed
钩子中的内容如下:
export default {
//...
destroyed() {
off(document, 'mouseup', this.mouseUpDocumentHandler);
}
//...
}
可以看到这里只移除了对 mouseup
事件的监听,因为 mousemove
已经在 mouseup
事件的监听器中移除了。
结语
通过本文的学习,我相信会有很多朋友和我一样,惊奇的发现,还有这么多原生的方法没使用过,甚至都不知道,这大概就是活到老学到老的完美诠释吧,总有东西值得学习。
同时我们也可以看到,获取良好的用户体验是需要付出很多的。
本文分析基于 ElementUI 2.12.0 版本。