本文是我学习 ElementUI 源码的第三篇文章,上一篇文章学习了 ElementUI 中 Collapse 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 Scrollbar(模拟滚动条)组件的。

目前常用的几款浏览器中只有 Chrome 开放了对内置滚动条的样式修改,而每款浏览器滚动条的宽度(或高度,但在未做修改的前提下,滚动条高度和宽度在浏览器内部是相同的)也不完全相同,这对于内容样式是有一定影响的。

因此为了让组件内部的滚动条样式在不同浏览器上都保持一样的效果,ElementUI 开发了 Scrollbar 组件,但这个组件并没有开放给用户使用,而是作为内部组件提供给 Select(选择器) 等组件的。

功能需求

在正式开始学习前,我们先思考一些问题,然后再带着问题去学习,效果会更好。

内置滚动条有哪些功能?大致有:

  1. 通过滚动鼠标滚轮实现内容滚动。
  2. 通过拖动滚动条中的滑块实现内容滚动。
  3. 通过点击滚动条的滑轨实现内容滚动。
  4. 通过点击滚动条前后的按钮实现内容滚动。
  5. 键盘控制滚动。

要统一样式,需要对内置滚动条的哪些东西进行模拟?应该有:

  1. 外层轨道。
  2. 滑块。
  3. 滚动条前后的按钮。
  4. 键盘控制。

实际上真的需要做这么多吗?

并不需要,多数情况下滚动条只是起到了指示位置以及通过妥当滑块快速切换位置的作用,所以只要模拟出这两个功能就可以了,毕竟并不是真的要制作一个滚动条 :trollface:。

组件实现

ElementUI 使用了 render 函数来实现 Scrollbar 组件,当然,用模版也是可以的。

Scrollbar 组件的源码大致分布在三个文件中:

scrollbar
├── index.js
└── src
    ├── bar.js
    ├── main.js
    └── util.js

其中 util.js 是起辅助作用的,我们重点分析 main.jsbar.js,在过程中顺带分析 util.js

main.js

Scrollbar 组件可分为三部分,最外层的 wrap,视图区 view和滚动条 bar,此文件主要是实现的 wrapview,我们依然大致按照书写顺序来做分析此文件。

首先在头部引入了一些辅助方法以及滑块:

import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';
import { toObject } from 'element-ui/src/utils/util';
import Bar from './bar';

在导出的对象中,Scrollbar 还留有 props 可以再对样式做一定程度上的调整:

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

有一点不解的是,ElementUI 并没有对设置样式部分的 props 做类型限制。

data 中的内容如下:

export default {
  //...
  data() {
    return {
      sizeWidth: '0',
      sizeHeight: '0',
      moveX: 0,
      moveY: 0
    };
  }
  //...
}

moveXmoveY 用于设置滚动条滑块的移动距离,sizeWidthsizeHeight 用于设置滚动条的宽度和高度。

computed 中返回了对此组件引用变量:

export default {
  //...
  computed: {
    wrap() {
      return this.$refs.wrap;
    }
  }
  //...
}

render 函数中是实现 Scrollbar 组件的主要逻辑,其内容如下:

export default {
  ...
  render(h) {
    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;
      }
    }
    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>
    );
    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);
  },
  ...
}



 






 
 
















 






 
























  • 第 4 行

定义了一个变量 gutter,它的作用是什么呢?前文中曾说过,每款浏览器的滚动条宽度是不等的,这个 gutter 就是指的浏览器内置滚动条的宽度。

我们来看看 ElementUI 是如何获取浏览器内置滚动条宽度的,scrollbarWidth 方法的源码如下:

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

可以看到 ElementUI 用了一种非常巧妙的方式来获取滚动条宽度:

  1. 首先创建一个空的 div,通过绝对定位将其移动到可见区域外部,以避免让用户看到,并将这个 divoffsetWidth 保存下来;
  2. 接着,在这个空的 div 内部再创建一个 div,设置外层 divoverflowscroll,让其产生滚动条,并将外层 div 此时的 offsetWidth 保存下来;
  3. 两者相减,得到滚动条的宽度,然后将整个 div 删除。

看了这个过程,茅塞顿开,不由惊叹 :plus1:。

当然,ElementUI 还做了两项优化,第一项是通过 Vue 的 $isServer 属性判断是否为服务器渲染;第二项是如果 scrollBarWidth 已经有值了,就不再执行后续代码,减少开销。

  • 第 11 行

这里对传入的 wrapStyle 做了判断,如果是一个数组,则使用 toObject 方法转化为对象,toObject 方法及其引用如下:

function extend(to, _from) {
  for (let key in _from) {
    to[key] = _from[key];
  }
  return to;
};

export function toObject(arr) {
  var res = {};
  for (let i = 0; i < arr.length; i++) {
    if (arr[i]) {
      extend(res, arr[i]);
    }
  }
  return res;
};

从代码来看,wrapStyle 数组中的元素需要是对象才行,如果是因为传入多属性的字符串太长,不方便阅读与维护的话,似乎传入一个对象更简单,有些不解 😕。

  • 第 36 行

可以看到,Scrollbar 还可以选择是否使用原生滚动条,当不使用原生滚动条时 wrap 上绑定了 onscroll 事件(当然,两者情况下的模版结构也不同),见 29 行。

handleScroll 的内容如下:

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

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

在滚动鼠标滚轮的时候获取到已滚动高度(宽度)与总体高度(宽度)之间的比例并传给滑块 bar,更新其位置。

需要注意的是,这里使用的是 clientHeightclientWidth,这个高度(宽度)是不包含内置滚动条高度(宽度)的。

mounted 钩子中的内容是用于处理用户调整大小的情况的:

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

beforeDestroyed 钩子中的内容就是取消事件监听:

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

我们先来看看 addResizeListenerremoveResizeListener 的内容:

import ResizeObserver from 'resize-observer-polyfill';

const isServer = typeof window === 'undefined';

/* istanbul ignore next */
const resizeHandler = function(entries) {
  for (let entry of entries) {
    const listeners = entry.target.__resizeListeners__ || [];
    if (listeners.length) {
      listeners.forEach(fn => {
        fn();
      });
    }
  }
};

/* istanbul ignore next */
export const addResizeListener = function(element, fn) {
  if (isServer) return;
  if (!element.__resizeListeners__) {
    element.__resizeListeners__ = [];
    element.__ro__ = new ResizeObserver(resizeHandler);
    element.__ro__.observe(element);
  }
  element.__resizeListeners__.push(fn);
};

/* istanbul ignore next */
export const removeResizeListener = function(element, fn) {
  if (!element || !element.__resizeListeners__) return;
  element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1);
  if (!element.__resizeListeners__.length) {
    element.__ro__.disconnect();
  }
};

我们都知道,浏览器原本提供的 resize 方法只能用于检测文档视图的大小调整,无法检测元素的大小调整。

ElementUI 使用的是一个尚处于试验阶段的接口 ResizeObserver,因此在头部可以看到引入的补丁。这个接口接口可以监听到 Element 的内容区域SVGElement 的边界框改变。

addResizeListener 接收两个参数,第一个是被检测元素,第二是被检测元素大小改变时的回调函数。在方法内部,首先在元素上定义了一个回调函数数组 __resizeListeners__,接着再定义了一个 ResizeObserver 对象 __ro__,并用这个对象的 observe 方法监听元素,需要注意的是 observe 方法只接收一个元素,但是可以多次调用 observe 方法以监听不同的元素。

new ResizeObserver 创建对象的时候,它接收一个回调函数(这里是 resizeHandler),并且会将被监听元素以数组形式作为参数传入,因此可以在 resizeHandler 中按顺序执行 __resizeListeners__ 中的所有回调函数。

removeResizeListener 在检测到已经没有回调函数的时候会调用 disconnect 方法结束 __ro__ 对象上所有对元素的监听。

提示

unobserve 方法是取消对某个元素的监听,注意区别

update 方法是为了完成在调整大小后修改模拟滚动条滑块的高度和宽度:

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

main.js 的内容就是这些,可以看到,ElementUI 实现模拟滚动条的方式是在右侧(或底部)通过绝对定位放置滚动条,然后利用内置滚动条的位置来控制模拟滚动条滑块的位置,还是比较复杂的。

bar.js

滑块 bar 也是使用 render 函数编写的,我们还是大致按照源码书写顺序一点一点分析。

首先还是在头部引入了一些辅助性的函数和对象,具体内容我们稍后再做分析:

import { on, off } from 'element-ui/src/utils/dom';
import { renderThumbStyle, BAR_MAP } from './util';

props 中定义了三个变量,分别用于设置滑块的长度(宽度)、移动距离,以及滚动条的方向:

export default {
  //...
  props: {
    vertical: Boolean,
    size: String,
    move: Number
  }
  //...
}

computed 中有两个变量:

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

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

其中,bar 最终得到的是一个与方向相对应的配置对象,BAR_MAP 的代码如下:

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'
  }
};

wrap 则是对父组件 DOM 的引用,在后面会用到。

render 中的内容就很关键了:

export default {
  //...
  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>
    );
  },
  //...
}








 



 
 






  • 第 9 行

clickTrackHandler 实现了点击滚动条滑轨时的滚动,其内容如下:

export default {
  //...
  methods: {
    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);
    },
  },
  //...
}




 








实现的重点是要获取正确的滚动距离并设置父组件的 scrollTopscrollLeft,为什么说是正确的滚动距离呢?因为 ElementUI 此处并没有完全和内置滚动条的行为保持一致。当点击内置滚动条的滑轨时,滑块和页面会等步长移动;而点击模拟滚动条的滑轨时,滑块的中心会直接移动到点击处(若已经很接近端点,则不一定处于滑块的中心)。

关键就是第 5 行中的 getBoundingClientRect,这个方法会返回元素的大小及其相对于视口的位置

getBoundingClientRect

不过,这里有一个小问题,可能是 ElementUI 没有发现或者写漏了,那就是没有对鼠标的左右键作区分,如果是点击的鼠标右键,应该无法滚动,但实际上也是可以滚动的。

  • 第 13 行

clickThumbHandler 实现了拖动滑块改变滚动条位置,其内容如下:

export default {
  //...
  methods: {
    clickThumbHandler(e) {
      // prevent click event of right button
      if (e.ctrlKey || e.button === 2) {
        return;
      }
      this.startDrag(e);
      this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
    },
  },
  //...
}








 
 




前文说过,ElementUI 在处理点击滚动条滑轨进行滚动的时候没有区分鼠标的左右键,而点击滑块的是做了区分的,同时还区分了是否按下了 Ctrl 键。

需要注意的是,假设滚动条为垂直方向,第 10 行中的 this[this.bar.axis] 也就是 this.Y 并没有在 data 中定义,且后文中的 cursorDown 也同样未定义。

startDrag 的内容如下:

export default {
  //...
  methods: {
    startDrag(e) {
      e.stopImmediatePropagation();
      this.cursorDown = true;

      on(document, 'mousemove', this.mouseMoveDocumentHandler);
      on(document, 'mouseup', this.mouseUpDocumentHandler);
      document.onselectstart = () => false;
    },
  },
  //...
}




 




 




重点关注第 5 行和第 10 行,第 5 行中的 stopImmediatePropagation 方法和 stopPropagation 是有区别的,stopPropagation 只是单纯的阻止事件向上冒泡,而 stopImmediatePropagation 不仅会阻止事件冒泡,还会阻止此事件的其他监听器被调用。

简单来说,就是在阻止事件冒泡的同时,当有多个监听器监听同一事件时,如果在某一监听器中调用了 stopImmediatePropagation,则还未被调用的监听器都不会被调用了。

第 10 行是为了防止用户随意拖动释放后因超过滚动条范围而造成的误选。

方法 on 的内容如下:

export const on = (function() {
  if (!isServer && document.addEventListener) {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.addEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event && handler) {
        element.attachEvent('on' + event, handler);
      }
    };
  }
})();

它和后文的 off 方法相对,专门用于监听器的绑定与移除,重点是为了做兼容,off 方法的内容如下:

export const off = (function() {
  if (!isServer && document.removeEventListener) {
    return function(element, event, handler) {
      if (element && event) {
        element.removeEventListener(event, handler, false);
      }
    };
  } else {
    return function(element, event, handler) {
      if (element && event) {
        element.detachEvent('on' + event, handler);
      }
    };
  }
})();

需要注意的是,拖动事件的监听器都是绑定在 document 上的,这样做的好处是,用户不用准确的在滚动条区域拖动才有效果,体验更好(由此造成的误选已经被处理了)。

提示

addEventListenerremoveEventListener 方法的第三个参数其实已经发生的改变,不再是仅仅表示是否使用捕获模式的 useCapture 了,而是一个包含了 userCapture 等属性的对象 options,感兴趣的朋友可以自行查看。

mouseMoveDocumentHandler 的内容如下:

export default {
  //...
  methods: {
    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);
    },
  },
  //...
}





 












这里的难点也同样是在于获取准确的拖动距离,前文已经讲过,指的一提的是 prevPage 就是前文所说的未在 data 中的定义的 Y(或 X)。

mouseUpDocumentHandler 的内容如下:

export default {
  //...
  methods: {
    mouseUpDocumentHandler(e) {
      this.cursorDown = false;
      this[this.bar.axis] = 0;
      off(document, 'mousemove', this.mouseMoveDocumentHandler);
      document.onselectstart = null;
    }
  },
  //...
}






 





在松开鼠标后就立马移除了对 mousemove 事件的监听,减少开销。

destroyed 钩子中的内容如下:

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

可以看到这里只移除了对 mouseup 事件的监听,因为 mousemove 已经在 mouseup 事件的监听器中移除了。

结语

通过本文的学习,我相信会有很多朋友和我一样,惊奇的发现,还有这么多原生的方法没使用过,甚至都不知道,这大概就是活到老学到老的完美诠释吧,总有东西值得学习。

同时我们也可以看到,获取良好的用户体验是需要付出很多的。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang