深入分析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编程起步(第五课)
Feb 27 Javascript
javascript定义类和类的实现实例详解
Dec 01 Javascript
快速使用Bootstrap搭建传送带
May 06 Javascript
jQuery根据name属性进行查找的用法分析
Jun 23 Javascript
js以及jquery实现手风琴效果
Apr 17 Javascript
JS中showModalDialog关闭子窗口刷新主窗口用法详解
Mar 25 Javascript
JS闭包的几种常见形式实例详解
Sep 16 Javascript
vueJs实现DOM加载完之后自动下拉到底部的实例代码
Aug 31 Javascript
vue移动端弹框组件的实例
Sep 25 Javascript
如何基于vue-cli3.0构建功能完善的移动端架子
Apr 24 Javascript
从零开始在vue-cli4配置自适应vw布局的实现
Jun 08 Javascript
JavaScript中的函数式编程详解
Aug 22 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写出自己的BLOG系统 2
2010/04/12 PHP
有道搜索和IP138的IP的API接口(PHP应用)
2012/11/29 PHP
9个实用的PHP代码片段分享
2015/01/22 PHP
PHP图形操作之Jpgraph学习笔记
2015/12/25 PHP
Laravel框架处理用户的请求操作详解
2019/12/20 PHP
Javascript的IE和Firefox兼容性汇编(zz)
2007/02/02 Javascript
20个非常棒的 jQuery 幻灯片插件和教程分享
2011/08/23 Javascript
JavaScript简单实现网页回到顶部功能
2013/11/12 Javascript
JavaScript中for..in循环陷阱介绍
2013/11/12 Javascript
Enter回车切换输入焦点实现思路与代码兼容各大浏览器
2014/09/01 Javascript
node.js集成百度UE编辑器
2015/02/05 Javascript
JavaScript中标识符提升问题
2015/06/11 Javascript
jQuery移动页面开发中的触摸事件与虚拟鼠标事件简介
2015/12/03 Javascript
深入理解jQuery layui分页控件的使用
2016/08/17 Javascript
巧用数组制作图片切换js代码
2016/11/29 Javascript
JS抛物线动画实例制作
2018/02/24 Javascript
opencv 识别微信登录验证滑动块位置
2018/08/07 Javascript
vue学习之Vue-Router用法实例分析
2020/01/06 Javascript
[59:35]DOTA2上海特级锦标赛主赛事日 - 3 败者组第三轮#1COL VS Alliance第二局
2016/03/04 DOTA
Python json模块使用实例
2015/04/11 Python
Python实现程序的单一实例用法分析
2015/06/03 Python
Python根据区号生成手机号码的方法
2015/07/08 Python
Python中read()、readline()和readlines()三者间的区别和用法
2017/07/30 Python
Python实现进程同步和通信的方法
2018/01/02 Python
Python中生成器和迭代器的区别详解
2018/02/10 Python
Django重装mysql后启动报错:No module named ‘MySQLdb’的解决方法
2018/04/22 Python
Python实现的微信好友数据分析功能示例
2018/06/21 Python
python常见字符串处理函数与用法汇总
2019/10/30 Python
python 和c++实现旋转矩阵到欧拉角的变换方式
2019/12/04 Python
python词云库wordCloud使用方法详解(解决中文乱码)
2020/02/17 Python
Pandas将列表(List)转换为数据框(Dataframe)
2020/04/24 Python
Python logging日志模块 配置文件方式
2020/07/12 Python
Python爬虫之爬取淘女郎照片示例详解
2020/07/28 Python
软件测试企业面试试卷
2016/07/13 面试题
自考毕业自我鉴定
2014/03/18 职场文书
Vite + React从零开始搭建一个开源组件库
2022/06/25 Javascript