element-ui ScrollBar组件源码深入分析

element-ui ScrollBar组件源码深入分析

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

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

scrollbar.png

如图,黑色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滚动的时候,thubm应该向下滚动的距离正好是 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的值,从而触发页面滚动以及滚动条位置的重新计算,实现滚动效果。


微信图片_20190121160845.jpg

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

track的onMousedown和trumb的逻辑也差不多,有两点需要注意:

  1. track的onMousedown事件回调中不会给页面绑定mousemove和mouseup事件,因为track相当于click事件
  2. 在track的onmousedown事件中,我们计算thumb顶部到track顶部的方式是,用鼠标点击点到track顶部的距离减去thumb的二分之一高度,这是因为点击track之后,thumb的中点刚好要在鼠标点击点的位置。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,022评论 4 62
  • 组件要求 1 .直接引入就可以使用,新传入的值可以和原来的值复用2 .默认显示全屏,然后自适应高度 layout ...
    skoll阅读 654评论 0 0
  • 2015年4月9日雨天,看后感。 月岛雯是个很努力的女孩子,她很爱看书,一次偶然的机会她看到了借书证上天泽圣司的名...
    王伯弦阅读 576评论 0 4
  • 1.感恩志超早起为顾客做售后,谢谢谢谢谢谢 2.感恩减肥的顾客们积极的配合。谢谢谢谢谢谢 3.感恩积极配合的经销商...
    阳光之旅001阅读 167评论 0 1
  • 文/十月云 1. 每个人来到这个世界上都有自己的专属使命,我们要做的是: 找到它,完成它。 2. 有趣的事情都有意...
    十月云阅读 486评论 2 49