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 这个方法来获取到这个宽度,点击这个方法,可以看到其实它做了三件事情:
/* 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 的创建过程,先后创建了 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 组件我们后面会讲到。
在 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(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); },
这里主要是计算拖动时滑块的高度与整个滚动条的比例,从而更新 wrap 元素的 scrollTop 值,具体代码与 clickTrackHandler 较为相似,由于篇幅所限,就不赘述了。
这里有一个小点,我们是给滑块元素绑定 onMousedown 事件的,但是 mousemove 和 mouseup 却是绑定在 document 上的,这是因为鼠标在移动过程中,会比滑块的移动要快,此时滑块元素会失去 onMousemove 事件,所以绑定 mousemove 的时候不能绑定在对应元素上。
我们从整个滚动条元素的生命周期,看到 element 是如何创建出一个滚动条,如何监听元素的变化,如何控制滚动条的滑动等等。源码的阅读到这里就全部结束了,如有什么错漏,请帮忙指出来;如你有所收获,是我莫大的荣幸。
感谢:
Element-ui el-scrollbar 源码解析
ResizeObserver API