element-ui之el-scrollbar源码解析学习

最近使用vue在做pc端项目,需要处理滚动条样式外加滚动加载。

使用了betterscroll和perfectscroll发现前者还是更偏向于移动端(也可能我比较着急没用明白),

后者在ie11上面拖拽滚动条的时候会闪动,会有兼容性问题。

经同事点播,最终使用了element-ui里面的scrollbar,发现还真是不错,用着简单而且兼容性好。

所以,来看看源码是怎么实现的,学习学习,进步进步。

先看一下怎么使用,便于理解组件的设计思路

<el-scrollbar class="content-table" :tag="'div'">
	<div v-for="(info, idx) in tab.humanInfo" :key="idx">
		<div></div>
	</div>
</el-scrollbar>

如上图代码,直接把会很长的需要滚动的内容放到el-scrollbar组件里面就好,其他细节不做赘述。

生成的html代码如下,这张图pic-html的内容后面会多次提及。

根据对照页面上的效果可知,div.el-scrollbar是最外层的容器,可以通过写自己的class来添加出现滚动条的高度。

.el-scrollbar{
  overflow: hidden;
  position: relative;
}

div.el-scrollbar__wrap是实际上真正用来包裹内容并产生滚动条的容器,高度100%,div.el-scrollbar__view里面就是我们自己写的需要滚动的内容。

.el-scrollbar__wrap{
  overflow: scroll;
  height: 100%;
}

div.el-scrollbar__bar是滚动条的轨道,分为横向和纵向,横向为is-horizontal,纵向为is-vertical,两者其实差不多,就只关注纵向了。鼠标划入划出滚动区域,滚动条会随之显示隐藏。

.el-scrollbar__bar {
    position: absolute;
    right: 2px;
    bottom: 2px;
    z-index: 1;
    border-radius: 4px;
    opacity: 0;
    -webkit-transition: opacity 120ms ease-out;
    transition: opacity 120ms ease-out;
}
.el-scrollbar__bar.is-vertical {
    width: 6px;
    top: 2px;
}

div.el-scrollbar__thumb在div.el-scrollbar__bar内部,作为滚动条,hover会产生背景色的变化

.el-scrollbar__thumb {
    position: relative;
    display: block;
    width: 0;
    height: 0;
    cursor: pointer;
    border-radius: inherit;
    background-color: rgba(144,147,153,.3);
    -webkit-transition: .3s background-color;
    transition: .3s background-color;
}

ok,到这里就把html结构大致分析完了,接着来看源码了,看看到底咋回事儿了

scrollbar/src/main.js就是组件主入口文件了,首先看一下里面写了什么

props: {
    native: Boolean,
    wrapStyle: {},
    wrapClass: {},
    viewClass: {},
    viewStyle: {},
    noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
    tag: {
      type: String,
      default: 'div'
    }
  },

props主要传入内联样式和自定义class,都以默认值来分析代码降低不必要的复杂度

data() {
    return {
      sizeWidth: '0',
      sizeHeight: '0',
      moveX: 0,
      moveY: 0
    };
  },
computed: {
    wrap() {
      return this.$refs.wrap;
    }
},

data中存了4个状态,是用来存储滚动条长度和滚动条移动距离,滚动条的长度是要随着滚动内容变长而变短的

computed中存储了ref=wrap这个dom节点,其实就是div.el-scrollbar__wrap这个产生滚动条的容器

methods: {
    handleScroll() {
      const wrap = this.wrap;

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

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

methods中存储了2个方法,以下均以纵向滚动条分析

handleScroll顾名思义,就是滚动事件的监听函数。这个函数用  内容的滚动距离/视口高度  算出滚动条滚动的百分比。之所以用滚动距离和视口高度相比,举例说明一下,假如滚动内容scrollHeight为2个视口高度,那么滚动距离scrollTop为1个视口高度的时候,滚动条应该是滚到底部,滚动条最上方距离滚动条轨道最上方应该是自身的高度,也就是说moveY为100%,滚动条高度为滚动轨道的50%

update,用于随着滚动内容的改变,来更新滚动条的高度,滚动条高度的计算方法为,视口高度/滚动区域高度,依旧可以用上面的例子来解释。

如果所得百分比不小于100%,则滚动条高度为空,即没有可滚动空间,滚动条不出现

mounted() {
    if (this.native) return;
    this.$nextTick(this.update);
    !this.noresize && addResizeListener(this.$refs.resize, this.update);
  },

beforeDestroy() {
    if (this.native) return;
    !this.noresize && removeResizeListener(this.$refs.resize, this.update);
}

mouted执行了一次update方法,用于初始化滚动条长度,并为div.el-scrollbar__view添加resize事件监听,只要其中的内容改变引起高度变化,则更新滚动条高度

beforeDestroy用于组件销毁时干掉resize事件

下面进入main.js的主要实现逻辑部分,采用了渲染函数和jsx混用的方式实现

render(h) {
 ....
}

由于render函数里面内容很多,这里拆开来分析

let gutter = scrollbarWidth();
let style = this.wrapStyle;
if (gutter) {
  const gutterWith = `-${gutter}px`;
  const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;
  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;
  }
}

gutter获取了浏览器的滚动条宽度,并且对props传入的样式进行分情况处理,最终style获得到一个样式,其中主要是要包含margin-bottom=横向滚动条高度;margin-right=纵向滚动条宽度

const view = h(this.tag, {
      class: ['el-scrollbar__view', this.viewClass],
      style: this.viewStyle,
      ref: 'resize'
 }, this.$slots.default);
const wrap = (
    <div
        ref="wrap"
        style={ style }
        onScroll={ this.handleScroll }
        class={ [this.wrapClass, 'el-scrollbar__wrap',
                 gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
        { [view] }
    </div>
);

使用渲染函数的方式,拼接出div.el-scrollbar__view,里面包含滚动的内容,ref=‘resize’,前面提到的resize的监听函数就绑在这个方法上面

并且将滚动事件绑在wrap上,将refs.resize放在wrap容器的内部,style样式放在了wrap上面

wrap的样式并没有width,而是直接进行流体布局,这样的话就可以通过添加style样式中的margin-right的值来改变wrap的外部尺寸特性,使得wrap的长度向右拉伸一个滚动条的宽度那么大的距离。而wrap的外部容器的overflow为hidden,所以多出来的距离刚好被外部容器隐藏。那个距离就是原生滚动条产生的位置,这样就实现了隐藏真正的滚动条。这样处理的好处是没有兼容性问题。

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

将wrap和bar组件放在容器div.el-scrollbar中

moveY和sizeHeight的数据传入到bar组件中,bar组件为滚动条组件,滚动事件通过操作moveY和sizeHeight的值来操作滚动条的位置和长度

下面来看一下bar组件的源码

props: {
    vertical: Boolean,
    size: String,
    move: Number
  },

传入的props上面已经说到了,只是还有一个用来区分横纵滚动条的一个入参

computed: {
    bar() {
      return BAR_MAP[this.vertical ? 'vertical' : 'horizontal'];
    },

    wrap() {
      return this.$parent.wrap;
    }
},

computed返回了bar,这个主要是一些配置,wrap就还是上面说到的外面的那个wrap容器

export const BAR_MAP = {
  vertical: {
    offset: 'offsetHeight',
    scroll: 'scrollTop',
    scrollSize: 'scrollHeight',
    size: 'height',
    key: 'vertical',
    axis: 'Y',
    client: 'clientY',
    direction: 'top'
  },
  horizontal: {
    offset: 'offsetWidth',
    scroll: 'scrollLeft',
    scrollSize: 'scrollWidth',
    size: 'width',
    key: 'horizontal',
    axis: 'X',
    client: 'clientX',
    direction: 'left'
  }
};

下面我们来看一下bar中的主要逻辑

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

这段代码主要生成了滚动条的html,包含了点击滚动条轨道,拖拽滚动条,改变滚动条位置的逻辑

先来看一下renderThumbStyle函数的作用

export function renderThumbStyle({ move, size, bar }) {
  const style = {};
  const translate = `translate${bar.axis}(${ move }%)`;

  style[bar.size] = size;
  style.transform = translate;
  style.msTransform = translate;
  style.webkitTransform = translate;

  return style;
};

这个函数用来生成一段css样式,根据size、move的值动态控制滚动条位置。实现原理主要是依靠css的translate根据百分比来调整滚动条位置,调整height来动态改变滚动条的长度。

继续看一下div.el-scrollbar__bar轨道上绑定的点击事件监听函数clickTrackHandler

clickTrackHandler(e) {
    const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] -
                      e[this.bar.client]);
    const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
    const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
    this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

这里面的很多变量都是取自上面介绍的那个配置文件里面的配置

先获取点击的轨道div.el-scrollbar__bar距离整个document视口最上方的距离 减去 鼠标点击的位置距离视口最上方的距离,得到鼠标点击位置距离滚动条轨道最上方的距离,取绝对值赋值给offset

获取滚动条的offsetHeight的一半赋值给thumbHalf

offset 减去 thumbHalf可获取当滚动条中心在鼠标点击位置时,滚动条最上方距离滚动条轨道最上方的距离,用这个值比上滚动条滚到的长度,获取一个相对于滚动条轨道长度的百分比,将这个百分比赋值给thumbPositionPercentage

用thumbPositionPercentage乘以wrap的scroolHeight获取scrollTop的值,赋值给wrap的scrollTop,实现内容的滚动,内容滚动之后触发滚动事件,执行监听函数,改变move和size的值执行renderThumbStyle,从而改变滚动条的位置。

下面是div.el-scrollbar__thumb上的点击拖拽滚动条事件监听函数clickThumbHandler

clickThumbHandler(e) {
      this.startDrag(e);
      this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - 
      e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},

clickThumbHandler中包含一个拖拽函数startDrag,以及一个赋值操作,对于纵向滚动条来讲,就是this.Y = 滚动条的高度 减去 鼠标点击位置距离滚动条最上方位置的距离,即鼠标点击位置到滚动条最下方位置的距离

startDrag

startDrag(e) {
      e.stopImmediatePropagation();
      this.cursorDown = true;
      on(document, 'mousemove', this.mouseMoveDocumentHandler);
      on(document, 'mouseup', this.mouseUpDocumentHandler);
      document.onselectstart = () => false;
},

阻止事件冒泡,此处的stopImmediatePropagation和stopPropagation不太一样

-----------------------------------------------------------分割线------------------------------------------------------------------------

1、stopImmediatePropagation方法:stopImmediatePropagation方法作用在当前节点以及事件链上的所有后续节点上,目的是在执行完当前事件处理程序之后,停止当前节点以及所有后续节点的事件处理程序的运行

2、stopPropagation方法:stopPropagation方法作用在后续节点上,目的在执行完绑定到当前元素上的所有事件处理程序之后,停止执行所有后续节点的事件处理程序

var div = document.getElementById("div1");
var btn = document.getElementById("button1");
div.addEventListener("click" , function(){alert("第一次执行");} , true);        //1
div.addEventListener("click" , function(){alert("第二次执行");} , true);        //2
btn.addEventListener("click" , function(){alert("button 执行");});            //3

在这里,给 1 函数alert后加上stopImmediatePropagation, 那么之后弹出窗口“第一次执行”
但是如果给 1 函数alert后加上stopPropagation , 那么之后会弹出窗口“第一次执行”,“第二次执行”两个窗口

----------------------------------------------------------------分割线-----------------------------------------------------------------

拉回来,继续看这个拖拽函数

阻止冒泡之后,设置鼠标点击状态为true,监听mousemove和mouseup,一般的拖拽逻辑都是这种写法

document.onselectstart = () => false;是为了在点击页面时不会选中文本出现蓝色选中

mouseMoveDocumentHandler(e) {
      if (this.cursorDown === false) return;
      const prevPage = this[this.bar.axis];
      if (!prevPage) return;
      const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * 
                       -1);
      const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
      const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / 
                                         this.$el[this.bar.offset]);
      this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
    },
mouseUpDocumentHandler(e) {
      this.cursorDown = false;
      this[this.bar.axis] = 0;
      off(document, 'mousemove', this.mouseMoveDocumentHandler);
      document.onselectstart = null;
}

mouseMoveDocumentHandler为mousemove的监听函数,其中做了2个判断,cursorDown === false,this.Y为空的时候不会执行函数,也就是说当没有点击状态,没有识别到鼠标点到滚动条上的时候不执行代码。如果点到滚动条,获取鼠标点击位置距离滚动条轨道最上方的距离赋值给offset,将鼠标点击位置距离滚动条最上方的距离赋值给thumbClickPosition,两者相减,得到滚动条距离滚动条轨道最上方的距离,将这个距离比上滚动条轨道的长度,获得一个百分比,将这个百分比赋值给thumbPositionPercentage。用thumbPositionPercentage乘以scrollHeight获取滚动距离scrollTop,实现内容滚动,滚动条随之移动,实现拖拽效果。

mouseUpDocumentHandler为mouseup监听函数,当鼠标完成拖拽擡起时,执行此函数。将cursorDown,this.Y重置回默认值,解绑mousemove监听函数,解绑document.onselectstart监听函数。

destroyed() {
    off(document, 'mouseup', this.mouseUpDocumentHandler);
}

组件销毁时解绑mouseup事件

将上面的这一大堆的源码解读串起来的整体逻辑是:

鼠标直接滚动时,触发滚动事件监听函数,根据scrollTop、clientHeight、scrollHeigth计算滚动条的滚动距离的百分比,即move。

move作为滚动条组件的props,传入bar组件,动态计算滚动条的translateY,实现滚动条相对于滚动条轨道的位置改变。

鼠标直接作用在滚动条轨道和滚动条上时,根据鼠标位置,滚动条位置计算出滚动距离的百分比,根据百分比计算出scrollTop并赋值给wrap.scrollTop,触发滚动事件监听函数改变move,从而改变滚动条的位置。

初始化或者滚动内容改变时,触发update函数,根据clientHeight、scrollHeight计算出滚动条高度百分比size,传入bar组件,计算height,进而改变滚动条的高度。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章