15

el-scrollbar 是啥?

Element-UI,作为一套非常出名 Vue 的 UI 组件库,玩 Vue 人几乎都认识它。最近在翻看 Element 的源码时,发现了一个有趣的现象,怎么 autocomplete 组件的联想列表组件 -> autocomplete-suggestions 里面,还包了一个 el-scrollbar 组件,这是用来做什么的?
经过一番了解,原来是 Element 自己写的一个滚动条组件(但却没有公开发布出来),它屏蔽了原生的滚动条,使用了一个统一的样式来代替,解决了滚动条的兼容性问题。

如何使用?

关于 el-scrollbar 的使用方式,可以看 Github 上的 issues,这里也简单展示一下:在 el-scrollbar 的默认 slot 中填入一个列表,并设定最外层的包裹元素的高度,这样就能顺利产生滚动条了。

<template>
    // 这里的 tag 属性可以先忽略,它用于控制生成的view元素具体是什么类型的元素
  <el-scrollbar style="width: 150px; height: 50px" tag="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
  </el-scrollbar>
</template>

效果如下:
image.png

如何实现?

先来看刚刚的代码渲染出来的DOM:
image.png
可以看到,我们的 li 被包裹在了 .el-scrollbar -> .&__wrap -> .&__view 里面,而底下还有两个 DOM:.is-horizontal 和 .is-vertical ,每个元素都有他自己的作用:

<div class="el-scrollbar"> //根元素,包裹所有元素
  <div class="el-scrollbar__wrap"> // wrap 元素,是视觉视口元素,它代表着元素最终展示的窗口大小
    <ul class="el-scrollbar__view"> // 布局视口元素,它代表着整个列表(以及他们的宽高),通过调整 wrap 的scrollTop/left,显示不同的 view 内容
      // 默认插槽里的内容会被放在这里
    </ul> 
  </div>
  <div class="el-scrollbar__bar is-horizontal">...</div> //横向滚动条
  <div class="el-scrollbar__bar is-vertical">...</div> // 竖向滚动条
</div>

隐藏原有滚动条

了解了wrap/view/bar这几个概念之后,我们直接来看源码: element/packages/scrollbar/src/main.js 这个文件是 scrollbar 组件的入口文件,它定义了一些/components/data/接受的 props,以及最重要的:render 函数。render 函数在被调用的时候,首先调用了 scrollbarWidth 函数:

let gutter = scrollbarWidth();

这个 gutter 的意思是当前浏览器的滚动条宽度,element 通过 scrollbarWidth 这个方法来获取到这个宽度,点击这个方法,可以看到其实它做了三件事情:

  1. 创建了一个 outer 元素,设置了宽度,拿到此时的 offsetWidth
  2. 把 outer 元素 overflow 设置为 visible,再创建一个inner元素,append 到 outer 上(此时会产生滚动条),再拿到 inner 的 offsetWidth。
  3. 两者相减即是滚动条的宽度
/* eslint-disable no-debugger */
import Vue from 'vue';

let scrollBarWidth;

export default function() {
  if (Vue.prototype.$isServer) return 0;
  if (scrollBarWidth !== undefined) return scrollBarWidth;

  // 创建外层的div,此时是一个普通的dom
  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);

  // 获取这个dom的实际宽度
  const widthNoScroll = outer.offsetWidth;
  // 修改外层 dom 的css,设置为 overflow: scroll(默认产生滚动条)
  outer.style.overflow = 'scroll';
    // 创建内层的 div,并 append 到 outer 上
  const inner = document.createElement('div');
  inner.style.width = '100%';
  outer.appendChild(inner);
  // 计算内层 div 的实际宽度
  const widthWithScroll = inner.offsetWidth;
  outer.parentNode.removeChild(outer);
  // 通过「无滚动条时的宽度」减去「有滚动条时的宽度」来算出滚动条的具体宽度
  scrollBarWidth = widthNoScroll - widthWithScroll;

  return scrollBarWidth;
};

拿到了滚动条最主要的目的就是为了把它隐藏掉,这也是 render 函数接下来做的事情。

const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;

// 根据传入的 wrapStyle 的不同类型,把 gutterStyle 加入进去
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;
  }
}

创建 DOM

紧接着就是 DOM 的创建过程,先后创建了 view/wrap(监听其滚动事件),以及非原生版本/原生版本的根元素。如果你传入了 native: true,就代表着使用了原生滚动条版本的 scrollbar。

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

在 wrap 窗口滚动时,handleScroll 方法会被执行,更新 data 中的 moveY 和 moveX 属性。这两者会被传入滚动条组件 Bar ,更新它的 translateY()/translateX() ,Bar 组件我们后面会讲到。

mount/beforeDestroy 钩子

在 mounted 的时候还做了一件事,就是给 view 元素添加了 resize 事件的监听器(beforeDestroy 时取消监听):

!this.noresize && addResizeListener(this.$refs.resize, this.update);

值得注意的是,addResizeListener 并不是简单地设置了 window.resize 回调,而是使用了一个船新的 api 来监听 DOM 元素的 resize:ResizeObserver API(具体可看这里的介绍)。总的来说,ResizeObserver 可以直接给 DOM 绑定事件,专门用来观察 DOM 元素的尺寸是否发生了变化,减少了 window.resize 带来的多余监听。
为了给某个元素实现多个 resize 事件的监听,element 还使用了观察者模式,给 DOM 元素绑定了一个 __resizeListeners__ 数组,当有 resize 事件被触发时,执行整个 _ _resizeListeners__ 数组的所有回调。

DOM 元素一旦 resize,就会执行 update 回调。那么 update 的时候做了什么事情呢?

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

update 方法负责更新 Bar 的滑块长度(可能是横向/竖向滚动条),我们以竖向滚动条为例:首先通过 clientHeight * 100/scrollHeight 得到 resize 后的 wrap 展示高度和总高度的比例,这也是 scrollbar 滑块长度的比例,再把它传入给表示滚动条的 Bar 组件,更新滚动条的 height。
这个时候如果比例值大于 100,说明已经不需要滚动条了,则传一个空字符串给 Bar 。

点击/拖动滚动条

到了这一步,我们的滚动条组件已经创建完成了,但是我们点击滚动条或者拖动滚动条的时候,这个组件如何处理呢?还得看 element/packages/scrollbar/src/bar.js 这个组件。
Bar 组件负责展示滚动条,我们直接来看它的 render 函数:

  render(h) {
    // move 属性用于控制滚动条的滚动位置
    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>
    );
  }

我们可以看到重点在于 clickTrackHandler/clickThumbHandler 这两个函数,他们分别用于控制滚动条 container 被点击时的行为,以及滚动条本身被点击的时候产生的行为。

clickTrackHandler:快速跳到某个区间

clickTrackHandler(e) {
    /**
     * 0. 以垂直滚动条为例:
     * this.bar.direction = "top"/this.bar.client = "clientY"/this.bar.offset="offsetHeight"/this.bar.scrollSize="scrollHeight"
     * 1. getBoundingClientRect()[this.bar.direction] 返回元素的 top 值(距离浏览器视口的高度值)
     * 2. 用 1 的值减去 e.clientY(鼠标当前位置), 再用 Math.abs 得出相对值,这个值就是鼠标在滚动条 container 上的相对偏移量。
     * 3. 计算出滚动条滑块的一半位置 thumbHalf
     * 4. offset - thumbHalf 得到具体偏移量,并除以整个 bar 的 offsetHeight,得到了滑块新的位置的百分比。
     * 5. 接下来就可以愉快地更新 wrap 元素的 scrollTop,显示新的内容啦~
     * 6. wrap 滚动后会触发 handleScroll 方法,回过头来更新 Bar 组件的 move 值,从而更新滚动条位置。
     */
    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]);
    //  设置外壳的 scrollHeight 或 scrollWidth 新值。达到滚动内容的效果
    this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

clickThumbHandler:拖动滚动条滑块更新视图

这里主要是计算拖动时滑块的高度与整个滚动条的比例,从而更新 wrap 元素的 scrollTop 值,具体代码与 clickTrackHandler 较为相似,由于篇幅所限,就不赘述了。
这里有一个小点,我们是给滑块元素绑定 onMousedown 事件的,但是 mousemove 和 mouseup 却是绑定在 document 上的,这是因为鼠标在移动过程中,会比滑块的移动要快,此时滑块元素会失去 onMousemove 事件,所以绑定 mousemove 的时候不能绑定在对应元素上。

总结

我们从整个滚动条元素的生命周期,看到 element 是如何创建出一个滚动条,如何监听元素的变化,如何控制滚动条的滑动等等。源码的阅读到这里就全部结束了,如有什么错漏,请帮忙指出来;如你有所收获,是我莫大的荣幸。

感谢:
Element-ui el-scrollbar 源码解析
ResizeObserver API

竹之同学
506 声望183 粉丝

人在囧途