深入分析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 相关文章推荐
JS 建立对象的方法
Apr 21 Javascript
jQuery实现点击按钮弹出可关闭层的浮动层插件
Sep 19 Javascript
探究Javascript模板引擎mustache.js使用方法
Jan 26 Javascript
JS组件Bootstrap实现下拉菜单效果代码
Apr 26 Javascript
jQuery实现三级联动效果
Mar 02 Javascript
Vue 创建组件的两种方法小结(必看)
Feb 23 Javascript
React全家桶环境搭建过程详解
May 18 Javascript
微信小程序中使用自定义图标(阿里icon)的方法
Aug 20 Javascript
JS实现的简单分页功能示例
Aug 23 Javascript
vue-cli3中配置alias和打包加hash值操作
Sep 04 Javascript
vue Element-ui表格实现树形结构表格
Jun 07 Vue.js
vite+vue3.0+ts+element-plus快速搭建项目的实现
Jun 24 Vue.js
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
微信公众平台开发关注及取消关注事件的方法
2014/12/23 PHP
浅谈PHP错误类型及屏蔽方法
2017/05/27 PHP
清除网页历史记录,屏蔽后退按钮!
2008/12/22 Javascript
jQuery的deferred对象使用详解
2011/08/20 Javascript
jQuery实现锚点scoll效果实例分析
2015/03/10 Javascript
jQuery插件jcrop+Fileapi完美实现图片上传+裁剪+预览的代码分享
2015/04/22 Javascript
JS实现仿新浪黄色经典滑动门效果代码
2015/09/27 Javascript
实例讲解jquery中mouseleave和mouseout的区别
2016/02/17 Javascript
Angular表单验证实例详解
2016/10/20 Javascript
函数四种调用模式以及其中的this指向
2017/01/16 Javascript
JQuery 获取Dom元素的实例讲解
2017/07/08 jQuery
js实现日期显示的一些操作(实例讲解)
2017/07/27 Javascript
详解基于Angular4+ server render(服务端渲染)开发教程
2017/08/28 Javascript
3种vue组件的书写形式
2017/11/29 Javascript
基于webpack.config.js 参数详解
2018/03/20 Javascript
security.js实现的RSA加密功能示例
2018/06/06 Javascript
Vue结合后台导入导出Excel问题详解
2019/02/19 Javascript
vue打开其他项目页面并传入数据详解
2020/11/25 Vue.js
[06:09]辉夜杯主赛事开幕式
2015/12/25 DOTA
Python中多线程的创建及基本调用方法
2016/07/08 Python
django model去掉unique_together报错的解决方案
2016/10/18 Python
python 调用HBase的简单实例
2016/12/18 Python
Python面向对象程序设计之继承与多继承用法分析
2018/07/13 Python
Python 实现异步调用函数的示例讲解
2018/10/14 Python
Python 3.3实现计算两个日期间隔秒数/天数的方法示例
2019/01/07 Python
CSS3圆角和渐变2种常用功能详解
2016/01/06 HTML / CSS
利用CSS3把图片变成灰色模式的实例代码
2016/09/06 HTML / CSS
Omio俄罗斯:一次搜索公共汽车、火车和飞机的机票
2018/11/17 全球购物
运动会通讯稿400字
2014/01/28 职场文书
先进集体事迹材料
2014/02/17 职场文书
保险内勤岗位职责
2014/04/05 职场文书
材料化学专业求职信
2014/07/15 职场文书
学校群众路线专项整治方案
2014/10/31 职场文书
实习护士自荐信
2015/03/25 职场文书
告知书格式
2015/07/01 职场文书
Redis监控工具RedisInsight安装与使用
2022/03/21 Redis