深入分析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 相关文章推荐
jquery教程ajax请求json数据示例
Jan 13 Javascript
深入理解JavaScript系列(49):Function模式(上篇)
Mar 04 Javascript
JS动态显示表格上下frame的方法
Mar 31 Javascript
jquery实现鼠标点击后展开列表内容的导航栏效果
Sep 14 Javascript
jQuery滚动新闻实现代码
Jun 26 Javascript
解析jQueryEasyUI的使用
Nov 22 Javascript
jQuery验证表单格式的使用方法
Jan 10 Javascript
浅谈js中function的参数默认值
Feb 20 Javascript
javascript 的变量、作用域和内存问题
Apr 19 Javascript
用node开发并发布一个cli工具的方法步骤
Jan 03 Javascript
JavaScript错误处理操作实例详解
Jan 04 Javascript
JavaScript 禁止用户保存图片的实现代码
Apr 28 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
千呼万唤始出来,DOTA2勇士令状不朽宝藏Ⅱ现已推出
2020/08/25 DOTA
建立文件交换功能的脚本(三)
2006/10/09 PHP
PHP隐形一句话后门,和ThinkPHP框架加密码程序(base64_decode)
2011/11/02 PHP
thinkphp3查询mssql数据库乱码解决方法分享
2014/02/11 PHP
destoon实现商铺管理主页设置增加新菜单的方法
2014/06/26 PHP
php实现的用户查询类实例
2015/06/18 PHP
总结PHP删除字符串最后一个字符的三种方法
2016/08/30 PHP
window.onload 加载完毕的问题及解决方案(下)
2009/07/09 Javascript
监控 url fragment变化的js代码
2010/04/19 Javascript
JavaScript 错误处理与调试经验总结
2010/08/10 Javascript
解决jQuery插件tipswindown与hintbox冲突
2010/11/05 Javascript
JS+CSS实现的简单折叠展开多级菜单效果
2015/09/12 Javascript
angularjs表格ng-table使用备忘录
2016/03/09 Javascript
荐书|您有一份JavaScript书单待签收
2017/07/21 Javascript
Vue实现购物车场景下的应用
2017/11/27 Javascript
微信小程序实现带缩略图轮播效果
2018/11/04 Javascript
详解微信小程序入门从这里出发(登录注册、开发工具、文件及结构介绍)
2020/07/21 Javascript
python 域名分析工具实现代码
2009/07/15 Python
python的dict,set,list,tuple应用详解
2014/07/24 Python
Python缩进和冒号详解
2016/06/01 Python
详解python中executemany和序列的使用方法
2017/08/12 Python
Python2比较当前图片跟图库哪个图片相似的方法示例
2019/09/28 Python
Python3实现配置文件差异对比脚本
2019/11/18 Python
Python计算指定日期是今年的第几天(三种方法)
2020/03/26 Python
挪威户外活动服装和装备购物网站:Bergfreunde挪威
2016/10/20 全球购物
西班牙香水和化妆品购物网站:Arenal Perfumerías
2019/03/01 全球购物
德国最大的网上足球商店:11teamsports
2019/09/11 全球购物
俄罗斯大型在线书店:Читай-город
2019/10/10 全球购物
工作中的自我评价如何写好
2013/10/28 职场文书
《美丽的小兴安岭》教学反思
2014/02/26 职场文书
2014入党积极分子批评与自我批评思想汇报
2014/09/20 职场文书
实名检举信范文
2015/03/02 职场文书
婚宴领导致辞
2015/07/28 职场文书
遗嘱格式范本
2015/08/07 职场文书
2016应届大学生自荐信模板
2016/01/28 职场文书
2016年少先队活动总结
2016/04/06 职场文书