深入分析element ScrollBar滚动组件源码


Posted in Javascript onJanuary 22, 2019

scrollbar组件根目录下包括index.js文件和src文件夹,index.js是用来注册Vue插件的地方,没什么好说的,不了解的童鞋可以看一下Vue官方文档中的插件,src目录下的内容才是scrollbar组件的核心代码,其入口文件是main.js。

在开始分析源码之前,我们先来说一下自定义滚动条的原理,方便大家更好的理解。

深入分析element ScrollBar滚动组件源码

如图,黑色wrap为滚动的可显示区域,我们的滚动内容就是在这个区域中滚动,view是实际的滚动内容,超出wrap可显示区域的内容都将被隐藏。右侧track是滚动条的滚动滑块thumb上下滚动的轨迹

当wrap中的内容溢出的时候,就会产生各浏览器的原生滚动条,要实现自定义滚动条,我们必须将原生滚动条消灭掉。假设我们给wrap外面再包一层div,并且把这个div的样式设为 overflow:hidden ,同时我们给wrap的marginRight,marginBottom设置一个负值,值得大小正好等于原生滚动条的宽度,那么这个时候由于父容器的overflow:hidden属性,正好就可以将原生滚动条隐藏掉。然后我们再将自定义的滚动条绝对定位到wrap容器的右侧和下侧,并加上滚动、拖拽事件等滚动逻辑,就可以实现自定义滚动条了。

接下来我们从main.js入口开始,详细分析一下element是如何实现这些逻辑的。

main.js文件中直接导出一个对象,这个对象采用render函数的方式渲染scrollbar组件,组件对外暴漏的接口如下:

props: {
 native: Boolean, // 是否采用原生滚动(即只是隐藏掉了原生滚动条,但并没有使用自定义的滚动条)
 wrapStyle: {}, // 内联方式 自定义wrap容器的样式
 wrapClass: {}, // 类名方式 自定义wrap容器的样式
 viewClass: {}, // 内联方式 自定义view容器的样式
 viewStyle: {}, // 类名方式 自定义view容器的样式
 noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
 tag: { 				// view容器用那种标签渲染,默认为div
 type: String,
 default: 'div'
 }
}

可以看到,这就是整个ScrollBar组件对外暴露的接口,主要包括了自定义wrap,view样式的接口,以及用来优化性能的noresize接口。

然后我们再来分析一下render函数:

render(){
	let gutter = scrollbarWidth(); // 通过scrollbarWidth()方法 获取浏览器原生滚动条的宽度
 let style = this.wrapStyle;

 if (gutter) {
 const gutterWith = `-${gutter}px`;
 
 // 定义即将应用到wrap容器上的marginBottom和marginRight,值为上面求出的浏览器滚动条宽度的负值
 const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;

 // 这一部分主要是根据接口wrapStyle传入样式的数据类型来处理style,最终得到的style可能是对象或者字符串
 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;
 }
 }
 
 ...
}

这一块代码中最重要的知识点就是获取浏览器原生滚动条宽度的方式了,为此element专门定义了一个方法scrllbarWidth,这个方法是从外部导入进来的 import scrollbarWidth from 'element-ui/src/utils/scrollbar-width'; ,我们一起来看一下这个函数:

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;
};

其实也很简单,就是动态创建一个body的子元素outer,给固定宽度100px,并且将overflow设置为scroll,这样wrap就产生滚动条了,这个时候再动态创建一个outer的子元素inner,将其宽度设置为100%。由于outer有滚动条存在,inner的宽度必然不可能等于outer的宽度,此时用outer的宽度减去inner的宽度,得出的就是浏览器滚动条的宽度了。是不是也很简单啊,最后记得从body中销毁动态创建outer元素哦。

回过头来我们接着看render函数,在根据浏览器滚动条宽度及wrapStyle动态生成样式变量style之后,接下来就是在render函数中生成ScrollBar组件的 HTML了。

// 生成view节点,并且将默认slots内容插入到view节点下
const view = h(this.tag, {
 class: ['el-scrollbar__view', this.viewClass],
 style: this.viewStyle,
 ref: 'resize'
}, this.$slots.default);

// 生成wrap节点,并且给wrap绑定scroll事件
const wrap = (
 <div
 	ref="wrap"
 	style={ style }
		onScroll={ this.handleScroll }
		class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
 		{ [view] }
	</div>
);

接着是根据native来组装wrap,view生成整个HTML节点树了。

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);

可以看到如果native为false,则使用自定义的滚动条,如果为true,则不使用自定义滚动条。简化上面的render函数生成的HTML如下:

<div class="el-scrollbar">
 <div class="el-scrollbar__wrap">
 <div class="el-scrollbar__view">
 	this.$slots.default
 </div>
 </div>
 <Bar vertical move={ this.moveY } size={ this.sizeHeight } />
 <Bar move={ this.moveX } size={ this.sizeWidth } />
</div>

最外层的el-scrollbar设置了overflow:hidden,用来隐藏wrap中产生的浏览器原生滚动条。使用ScrollBar组建时,写在ScrollBar组件中的内容都将通过slot分发到view内部。另外这里使用move,size和vertical三个接口调用了Bar组件,这个组件就是原理图上的Track和Thumb了。下面我们来看一下Bar组件:

props: {
 vertical: Boolean, // 当前Bar组件是否为垂直滚动条
 size: String, // 百分数,当前Bar组件的thumb长度 / track长度的百分比 
 move: Number // 滚动条向下/向右发生transform: translate的值
},

Bar组件的行为都是由这三个接口来进行控制的,在前面的分析中,我们可以看到,在scrollbar中调用Bar组件时,分别传入了这三个props。那么父组件是如何初始化以及更新这三个参数的值,从而达到更新Bar组件的呢。首先在mounted钩子中调用update方法对size进行初始化:

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 + '%') : '';
}

可以看到,这里核心的内容就是计算thumb的长度heightPercentage/widthPercentage。这里使用wrap.clientHeight / wrap.scrollHeight得出了thumb长度的百分比。这是为什么呢

分析前面我们画的那张scrollbar的原理图,thumb在track中上下滚动,可滚动区域view在可视区域wrap中上下滚动,可以将thumb和track的这种相对关系看作是wrap和view相对关系的一个 微缩模型 (微缩反应),而滚动条的意义就是用来反映view和wrap的这种相对运动关系的。从另一个角度,我们可以将view在wrap中的滚动反过来看成是wrap在view中的上下滚动,这不就是一个放大版的滚动条吗?

根据这种相似性,我们可以得出一个比例关系: wrap.clientHeight / wrap.scrollHeight = thumb.clientHeight / track.clientHeight。在这里,我们并不需要求出具体的thumb.clientHeight的值,只需要根据thumb.clientHeight / track.clientHeight的比值,来设置thumb 的css高度的百分比就可以了。

另外还有一个需要注意的地方,就是当这个比值大于等于100%的时候,也就是wrap.clientHeight(容器高度)大于等于 wrap.scrollHeight(滚动高度)的时候,此时就不需要滚动条了,因此将size置为空字符串。

接下来我们再来看一下move,也就是滚动条滚动位置的更新。

handleScroll() {
 const wrap = this.wrap;

 this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
 this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
}

moveX/moveY用来控制滚动条的滚动位置,当这个值传给Bar组件时,Bar组件render函数中会调用 renderThumbStyle 方法将它转化为trumb的样式 transform: translateX(${moveX}%) / transform: translateY(${moveY}%) 。由之前分析的相似关系可知,当wrap.scrollTop正好等于wrap.clientHeight的时候,此时thumb应该向下滚动它自身长度的距离,也就是transform: translateY(100%)。所以,当wrap滚动的时候,thumb应该向下滚动的距离正好是 transform: translateY(wrap.scrollTop / wrap.clientHeight )。这就是wrap滚动函数handleScroll中的逻辑所在。

现在我们已经完全弄清楚了scrollbar组件中的所有逻辑,接下来我们再看看Bar组件在接收到props之后是如何处理的。

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>
 );
}

render函数获取父组件传递的size,move之后,通过 renderThumbStyle 来生成thumb,并且给track和thumb分别绑定了onMousedown事件。

clickThumbHandler(e) {
 this.startDrag(e);
 // 记录this.y , this.y = 鼠标按下点到thumb底部的距离
 // 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
 this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
 
// 开始拖拽函数
startDrag(e) {
 e.stopImmediatePropagation();
 // 标识位, 标识当前开始拖拽
 this.cursorDown = true;

 // 绑定mousemove和mouseup事件
 on(document, 'mousemove', this.mouseMoveDocumentHandler);
 on(document, 'mouseup', this.mouseUpDocumentHandler);
 
 // 解决拖动过程中页面内容选中的bug
 document.onselectstart = () => false;
},
 
mouseMoveDocumentHandler(e) {
 // 判断是否在拖拽过程中,
 if (this.cursorDown === false) return;
 // 刚刚记录的this.y(this.x) 的值
 const prevPage = this[this.bar.axis];

 if (!prevPage) return;

 // 鼠标按下的位置在track中的偏移量,即鼠标按下点到track顶部(左侧)的距离
 const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
 // 鼠标按下点到thumb顶部(左侧)的距离
 const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
 // 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
 const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);
	// wrap.scrollHeight / wrap.scrollLeft * thumbPositionPercentage得到wrap.scrollTop / wrap.scrollLeft
 // 当wrap.scrollTop(wrap.scrollLeft)发生变化的时候,会触发父组件wrap上绑定的onScroll事件,
 // 从而重新计算moveX/moveY的值,这样thumb的滚动位置就会重新渲染
 this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

mouseUpDocumentHandler(e) {
 // 当拖动结束,将标识位设为false
 this.cursorDown = false;
 // 将上一次拖动记录的this.y(this.x)的值清空
 this[this.bar.axis] = 0;
 // 取消页面绑定的mousemove事件
 off(document, 'mousemove', this.mouseMoveDocumentHandler);
 // 清空onselectstart事件绑定的函数
 document.onselectstart = null;
}

上面的代码就是thumb滚动条拖拽的所有处理逻辑,整体思路就是在拖拽thumb的过程中,动态的计算thumb顶部(左侧)到track顶部(左侧)的距离占track本身高度(宽度)的百分比,然后利用这个百分比动态改变wrap.scrollTop的值,从而触发页面滚动以及滚动条位置的重新计算,实现滚动效果。

深入分析element ScrollBar滚动组件源码

上一个图方便大家理解吧( ̄? ̄)"

  • track的onMousedown和trumb的逻辑也差不多,有两点需要注意:
  • track的onMousedown事件回调中不会给页面绑定mousemove和mouseup事件,因为track相当于click事件 在track的onmousedown事件中,我们计算thumb顶部到track顶部的方法是,用鼠标点击点到track顶部的距离减去thumb的二分之一高度,这是因为点击track之后,thumb的中点刚好要在鼠标点击点的位置。

至此,整个scrollbar源码就分析结束了,回过头来看看,其实scrollbar的实现并不难,主要还是要理清各种滚动关系、thumb的长度以及滚动位置怎么通过wrap,view之间的关系来确定。这一部分可能比较绕,没搞懂的同学建议自己手动画画图研究一下,只要搞懂这个滚动原理,实现起来就很简单了。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
在Javascript中为String对象添加trim,ltrim,rtrim方法
Sep 22 Javascript
获取任意Html元素与body之间的偏移距离 offsetTop、offsetLeft (For:IE5+ FF1 )[
Dec 22 Javascript
jquery解决图片路径不存在执行替换路径
Feb 06 Javascript
使用js检测浏览器是否支持html5中的video标签的方法
Mar 12 Javascript
javascript实现复制与粘贴操作实例
Oct 16 Javascript
jQuery网页版打砖块小游戏源码分享
Aug 20 Javascript
Javascript iframe交互并兼容各种浏览器的解决方法
Jul 12 Javascript
angular bootstrap timepicker TypeError提示怎么办
Jun 13 Javascript
JS实现定时任务每隔N秒请求后台setInterval定时和ajax请求问题
Oct 15 Javascript
微信小程序App生命周期详解
Jan 31 Javascript
详解关于vue2.0工程发布上线操作步骤
Sep 27 Javascript
Vue根据条件添加click事件的方式
Nov 09 Javascript
js实现京东秒杀倒计时功能
Jan 21 #Javascript
vue.js的vue-cli脚手架中使用百度地图API的实例
Jan 21 #Javascript
JavaScript使用Math.random()生成简单的验证码
Jan 21 #Javascript
详解一个基于react+webpack的多页面应用配置
Jan 21 #Javascript
js中对象和面向对象与Json介绍
Jan 21 #Javascript
详解vuex中action何时完成以及如何正确调用dispatch的思考
Jan 21 #Javascript
JavaScript常用事件介绍
Jan 21 #Javascript
You might like
PHP开发的一些注意点总结
2010/10/12 PHP
PHP安全的URL字符串base64编码和解码
2014/06/19 PHP
PHP记录和读取JSON格式日志文件
2016/07/07 PHP
JavaScript constructor和instanceof,JSOO中的一对欢喜冤家
2009/05/25 Javascript
jquery $(document).ready() 与window.onload的区别
2009/12/28 Javascript
JS面向对象编程浅析
2011/08/28 Javascript
js 弹出菜单/窗口效果
2011/10/30 Javascript
jQuery 选择器项目实例分析及实现代码
2012/12/28 Javascript
基于JavaScript实现全屏透明遮罩div层锁屏效果
2016/01/26 Javascript
原生js实现下拉框功能(支持键盘事件)
2017/01/13 Javascript
jQueryMobile之窗体长内容的缺陷与解决方法实例分析
2017/09/20 jQuery
js 客户端打印html 并且去掉页眉、页脚的实例
2017/11/03 Javascript
JS 实现缓存算法的示例(FIFO/LRU)
2018/03/20 Javascript
微信小程序实现人脸识别登陆的示例代码
2019/04/02 Javascript
使用js在layui中实现上传图片压缩
2019/06/18 Javascript
jQuery中DOM常见操作实例小结
2019/08/01 jQuery
微信小程序实现简单文字跑马灯
2020/05/26 Javascript
微信小游戏中three.js离屏画布的示例代码
2020/10/12 Javascript
SpringBoot+Vue 前后端合并部署的配置方法
2020/12/30 Vue.js
python获取当前日期和时间的方法
2015/04/30 Python
Python中的字符串替换操作示例
2016/06/27 Python
Django Admin 实现外键过滤的方法
2017/09/29 Python
将python文件打包exe独立运行程序方法详解
2020/02/12 Python
Python编程快速上手——选择性拷贝操作案例分析
2020/02/28 Python
Python自动化测试笔试面试题精选
2020/03/12 Python
使用CSS3实现圆角,阴影,透明
2014/12/23 HTML / CSS
澳大利亚家具和家居用品在线商店:Interiors Online
2018/03/05 全球购物
警察思想汇报
2014/01/04 职场文书
护理专业自我鉴定
2014/01/30 职场文书
企业后勤岗位职责
2014/02/28 职场文书
政治思想表现评语
2014/05/04 职场文书
英语专业毕业论文答辩开场白
2015/05/27 职场文书
《敬重卑微》读后感3篇
2019/11/26 职场文书
关于Javascript闭包与应用的详解
2021/04/22 Javascript
用python修改excel表某一列内容的操作方法
2021/06/11 Python