使用 Vue 开发 scrollbar 滚动条组件

Posted zhangmao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用 Vue 开发 scrollbar 滚动条组件相关的知识,希望对你有一定的参考价值。

Vue 应该说是很火的一款前端库了,和 React 一样的高热度,今天就来用它写一个轻量的滚动条组件;

知识储备:要开发滚动条组件,需要知道知识点是如何计算滚动条的大小和位置,还有一个问题是如何监听容器大小的改变,然后更新滚动条的位置;

先把样式贴出来:

/*禁用选择文本*/
.disable-selection {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
/*object 触发器的样式*/
.resize-trigger {
  position: absolute;
  display: block;
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
  overflow: hidden;
  pointer-events: none;
  z-index: -1;
  opacity: 0;
}

.scrollbar-container {
  position: relative;
  overflow: hidden !important;
  width: 100%;
  height: 100%;
}

.scrollbar-box {
  position: absolute;
  right: 0;
  bottom: 0;
  z-index: 1;
}

.scrollbar-box.scrollbar-box-vertical {
  width: 12px;
  top: 0;
}

.scrollbar-box.scrollbar-box-horizontal {
  height: 12px;
  left: 0;
}

.cssui-scrollbar--s .scrollbar-box.scrollbar-box-vertical {
  width: 6px;
}

.cssui-scrollbar--s .scrollbar-box.scrollbar-box-horizontal {
  height: 6px;
}

.scrollbar-box .scrollbar-thumb {
  position: relative;
  display: block;
  cursor: pointer;
  background-color: rgba(0, 0, 0, 0.2);
  transform: translate3d(0, 0, 0);
}

.scrollbar-box .scrollbar-thumb:hover,
.scrollbar-box .scrollbar-thumb:active {
  background-color: rgba(0, 0, 0, 0.3);
}

.scrollbar-box.scrollbar-box-vertical .scrollbar-thumb {
  width: 100%;
}

.scrollbar-box.scrollbar-box-horizontal .scrollbar-thumb {
  height: 100%;
}

.scrollbar-container .scrollbar-view {
  width: 100%;
  height: 100%;
  transform: translate3d(0, 0, 0);
  -webkit-overflow-scrolling: touch;
}

.scrollbar-container .scrollbar-view-x {
  overflow-x: scroll!important;
}

.scrollbar-container .scrollbar-view-y {
  overflow-y: scroll!important;
}

.scrollbar-container.scrollbar-autoshow .scrollbar-box {
  opacity: 0;
  transition: opacity 120ms ease-out;
}

.scrollbar-container.scrollbar-autoshow:hover > .scrollbar-box,
.scrollbar-container.scrollbar-autoshow:active > .scrollbar-box,
.scrollbar-container.scrollbar-autoshow:focus > .scrollbar-box {
  opacity: 1;
  transition: opacity 340ms ease-out;
}

然后,把模板贴出来:

<template>
  <div
    :style="containerStyle"
    :class="containerClass"
  >
    <div
      ref="scrollEl"
      :style="scrollStyle"
      :class="scrollClass"
      @scroll.stop.prevent="scrollHandler"
    >
      <div
        ref="contentEl"
        v-resize="resizeHandle"
      >
        <slot />
      </div>
    </div>
    <div
      v-if="yBarShow"
      ref="vertical"
      class="scrollbar-box scrollbar-box-vertical"
      @mousedown="verticalHandler"
    >
      <div
        ref="verticalBar"
        :style="yBarStyle"
        class="scrollbar-thumb"
        @mousedown="verticalBarHandler"
      />
    </div>
    <div
      v-if="xBarShow"
      ref="horizontal"
      class="scrollbar-box scrollbar-box-horizontal"
      @mousedown="horizontalHandler"
    >
      <div
        ref="horizontalBar"
        :style="xBarStyle"
        class="scrollbar-thumb"
        @mousedown="horizontalBarHandler"
      />
    </div>
  </div>
</template>

上面的代码中,我用到了 v-resize 这个指令,这个指令就是封装容器大小改变时,向外触发事件的,看到网上有通过 MutationObserver 来监听的,这个问题是监听所有的属性变化,好像还有兼容问题,还有一种方案是用 GitHub 的这个库:resize-observer-polyfill,上面的这些方法都可以,我也是尝试了一下,但我觉得始终是有点小题大做了,不如下面这个方法好,就是创建一个看不见的 object 对象,然后使它的绝对定位,相对于滚动父容器,和滚动条容器的大小保持一致,监听 object 里面 window 对象的 resize 事件,这样就可以做到实时响应高度变化了,贴上代码:

import Vue from ‘vue‘;
import { throttle, isFunction } from ‘lodash‘;

Vue.directive(‘resize‘, {
  inserted(el, { value: handle }) {
    if (!isFunction(handle)) { return; }

    const aimEl = el;
    const resizer = document.createElement(‘object‘);

    resizer.type = ‘text/html‘;
    resizer.data = ‘about:blank‘;
    resizer.setAttribute(‘tabindex‘, ‘-1‘);
    resizer.setAttribute(‘class‘, ‘resize-trigger‘);
    resizer.onload = () => {
      const win = resizer.contentDocument.defaultView;
      win.addEventListener(‘resize‘, throttle(() => {
         const rect = el.getBoundingClientRect();
          handle(rect);
      }, 500));
    };

    aimEl.style.position = ‘relative‘;
    aimEl.appendChild(resizer);
    aimEl.resizer = resizer;
  },

  unbind(el) {
    const aimEl = el;

    if (aimEl.resizer) {
      aimEl.style.position = ‘‘;
      aimEl.removeChild(aimEl.resizer);
      delete aimEl.resizer;
    }
  },
});

下面是 js 功能的部分,代码还是不少,有一些方法做了节流处理,用了一些 lodash 的方法,主要还是上面提到的滚动条计算的原理,大小的计算,具体看  toUpdate  这个方法,位置的计算,主要是 horizontalHandler,verticalHandler,实际滚动距离的计算,看mouseMoveHandler 这个方法:

import {trim, delay, round, throttle } from "lodash";

// ------------------------------------------------------------------------------

// 检测 class
const hasClass = (el = null, cls = ‘‘) => {
  if (!el || !cls) { return false; }
  if (cls.indexOf(‘ ‘) !== -1) { throw new Error(‘className should not contain space.‘); }
  if (el.classList) { return el.classList.contains(cls); }
  return ` ${el.className} `.indexOf(` ${cls} `) > -1;
};

// ------------------------------------------------------------------------------

// 添加 class
const addClass = (element = null, cls = ‘‘) => {
  const el = element;
  if (!el) { return; }
  let curClass = el.className;
  const classes = cls.split(‘ ‘);

  for (let i = 0, j = classes.length; i < j; i += 1) {
    const clsName = classes[i];
    if (!clsName) { continue; }

    if (el.classList) {
      el.classList.add(clsName);
    } else if (!hasClass(el, clsName)) {
      curClass += ‘ ‘ + clsName;
    }
  }
  if (!el.classList) {
    el.className = curClass;
  }
};

// ------------------------------------------------------------------------------

// 删除 class
const removeClass = (element, cls) => {
  const el = element;
  if (!el || !cls) { return; }
  const classes = cls.split(‘ ‘);
  let curClass = ` ${el.className} `;

  for (let i = 0, j = classes.length; i < j; i += 1) {
    const clsName = classes[i];
    if (!clsName) { continue; }

    if (el.classList) {
      el.classList.remove(clsName);
    } else if (hasClass(el, clsName)) {
      curClass = curClass.replace(` ${clsName} `, ‘ ‘);
    }
  }
  if (!el.classList) {
    el.className = trim(curClass);
  }
};

// ------------------------------------------------------------------------------

// 获取滚动条宽度
let scrollWidth = 0;
const getScrollWidth = () => {
  if (scrollWidth > 0) { return scrollWidth; }

  const block = document.createElement(‘div‘);
  block.style.cssText = ‘position:absolute;top:-1000px;width:100px;height:100px;overflow-y:scroll;‘;
  document.body.appendChild(block);
  const { clientWidth, offsetWidth } = block;
  document.body.removeChild(block);
  scrollWidth = offsetWidth - clientWidth;

  return scrollWidth;
};

// scrollSize 值
const SCROLLBARSIZE = getScrollWidth();

/**
 * UiScrollbar Component
 * @author zhangmao 19/4/3
 */
export default {
  name: ‘UiScrollbar‘,

  props: {
    size: { type: String, default: ‘normal‘ }, // small
    // 主要是为了解决在 dropdown 隐藏的情况下无法获取当前容器的真实 width height 的问题
    show: { type: Boolean, default: false },
    width: { type: Number, default: 0 },
    height: { type: Number, default: 0 },
    maxWidth: { type: Number, default: 0 },
    maxHeight: { type: Number, default: 0 },
  },

  data() {
    return {
      prevPageX: 0, // 缓存的鼠标横向位置
      prevPageY: 0, // 缓存的鼠标垂直位置
      cursorDown: false, // 鼠标拖拽标记
      minBarSize: 5, // 滚动条的最小快读和高度
      xScroll: 0, // 当前滚动条的横向位置
      yScroll: 0, // 当前滚动条的垂直位置
      realWidth: 0, // 内容的真实宽度
      realHeight: 0, // 内容的真实高度
      xBarWidth: 0, // 水平滚动条发宽度
      yBarHeight: 0, // 垂直滚动条的高度
      xBarLastWidth: 0, // 水平滚动条的最终宽度
      yBarLastHeight: 0, // 垂直滚动条最终的高度
      containerWidth: 0, // 容器的宽度
      containerHeight: 0, // 容器的高度
      scrollWidth: 0, // 滚动容器的宽度
      scrollHeight: 0, // 滚动容器的高度
      scrollTopMax: 0, // 垂直最大滚动距离限制
      scrollLeftMax: 0, // 水平最大滚动距离限制
      trackTopMax: 0, // 垂直步长最大限制
      trackLeftMax: 0, // 水平步长最大限制
    };
  },

  computed: {
    yBarShow() { return this.getYBarShow(); },
    xBarShow() { return this.getXBarShow(); },
    yBarStyle() {
      return {
        height: `${this.yBarLastHeight}px`,
        msTransform: `translateY(${this.yScroll}px)`,
        webkitTransform: `translate3d(0, ${this.yScroll}px, 0)`,
        transform: `translate3d(0, ${this.yScroll}px, 0)`,
      };
    },
    xBarStyle() {
      return {
        width: `${this.xBarLastWidth}px`,
        msTransform: `translateX(0, ${this.xScroll}px, 0)`,
        webkitTransform: `translate3d(${this.xScroll}px, 0, 0)`,
        transform: `translate3d(${this.xScroll}px, 0, 0)`,
      };
    },
    scrollClass() {
      return [‘scrollbar-view‘, {
        ‘scrollbar-view-x‘: this.xBarShow,
        ‘scrollbar-view-y‘: this.yBarShow,
      }];
    },
    scrollStyle() {
      // 注意这里是相反的
      const hasWidth = this.yBarShow || this.realWidth > this.containerWidth;
      const hasHeight = this.xBarShow || this.realHeight > this.containerHeight;
      return {
        width: hasWidth && this.scrollWidth > 0 ? `${this.scrollWidth}px` : ‘‘,
        height: hasHeight && this.scrollHeight > 0 ? `${this.scrollHeight}px` : ‘‘,
      };
    },
    containerClass() {
      return [‘scrollbar-container scrollbar-autoshow‘, {
        ‘cssui-scrollbar--s‘: this.size === ‘small‘,
      }];
    },
    containerStyle() {
      if (this.xBarShow || this.yBarShow) {
        return {
          width: this.containerWidth > 0 ? `${this.containerWidth}px` : ‘‘,
          height: this.containerHeight > 0 ? `${this.containerHeight}px` : ‘‘,
        };
      }
      return {};
    },
  },

  watch: {
    show: ‘showChange‘,
    width: ‘initail‘,
    height: ‘initail‘,
    maxWidth: ‘initail‘,
    maxHeight: ‘initail‘,
  },

  created() {
    this.dftData();
    this.initEvent();
  },

  mounted() { this.delayInit(); },

  methods: {

    // ------------------------------------------------------------------------------

    // 外部调用方法
    scrollX(x) { this.$refs.scrollEl.scrollLeft = x; },
    scrollY(y) { this.$refs.scrollEl.scrollTop = y; },
    scrollTop() { this.$refs.scrollEl.scrollTop = 0; },
    scrollBottom() { this.$refs.scrollEl.scrollTop = this.$refs.contentEl.offsetHeight; },

    // ------------------------------------------------------------------------------

    // 默认隐藏 异步展示的情况
    showChange(val) { if (val) { this.delayInit(); } },

    // ------------------------------------------------------------------------------

    delayInit() {
      this.$nextTick(() => { delay(() => { this.initail(); }, 10); });
    },

    // ------------------------------------------------------------------------------

    // 检测是否需要展示垂直的滚动条
    getYBarShow() {
      if (this.height > 0) { return this.realHeight > this.height; }
      if (this.maxHeight > 0) { return this.realHeight > this.maxHeight; }
      return this.realHeight > this.containerHeight;
    },

    // ------------------------------------------------------------------------------

    // 检测是否需要展示横向的滚动条
    getXBarShow() {
      if (this.width > 0) { return this.realWidth > this.width; }
      if (this.maxWidth > 0) { return this.realWidth > this.maxWidth; }
      return this.realWidth > this.containerWidth;
    },

    // ------------------------------------------------------------------------------

    // 内容大小改变
    resizeHandle({ width, height }) {
      this.realWidth = width;
      this.realHeight = height;
      this.delayInit();
    },

    // ------------------------------------------------------------------------------

    // 设置容器大小 初始化滚动条位置
    initail() {
      this.setContainerSize();
      this.setScrollSize();
      this.setContentSize();
      this.toUpdate();
    },

    // ------------------------------------------------------------------------------

    // 设置整个容器的大小
    setContainerSize() {
      const { offsetWidth = 0, offsetHeight = 0 } = this.$el;
      this.containerHeight = this.height || this.maxHeight || offsetHeight;
      this.containerWidth = this.width || this.maxWidth || offsetWidth;
    },

    // ------------------------------------------------------------------------------

    // 设置滚动容器的大小
    setScrollSize() {
      this.scrollWidth = this.containerWidth + SCROLLBARSIZE;
      this.scrollHeight = this.containerHeight + SCROLLBARSIZE;
    },

    // ------------------------------------------------------------------------------

    // 设置内容区域的大小
    setContentSize() {
      if (this.$refs.contentEl) {
        const { offsetWidth = 0, offsetHeight = 0 } = this.$refs.contentEl;
        this.realWidth = offsetWidth;
        this.realHeight = offsetHeight;
      }
    },

    // ------------------------------------------------------------------------------

    // 更新滚动条相关的大小位置
    toUpdate() {
      if (this.realWidth > 0) {
        // 水平滚动条的宽度
        this.xBarWidth = round(this.containerWidth / this.realWidth * this.containerWidth);
        this.scrollLeftMax = this.realWidth - this.containerWidth;
      }

      if (this.realHeight > 0) {
        // 垂直方向滚动条的高度
        this.yBarHeight = round(this.containerHeight / this.realHeight * this.containerHeight);
        this.scrollTopMax = this.realHeight - this.containerHeight;
      }

      // 设置滚动条最终的大小
      this.xBarLastWidth = Math.max(this.xBarWidth, this.minBarSize);
      this.yBarLastHeight = Math.max(this.yBarHeight, this.minBarSize);

      this.trackTopMax = this.containerHeight - this.yBarLastHeight;
      this.trackLeftMax = this.containerWidth - this.xBarLastWidth;

      this.scrollHandler();
    },

    // ------------------------------------------------------------------------------

    scrollHandler() {
      if (this.$refs.scrollEl) {
        const {
          scrollLeft = 0,
          scrollTop = 0,
          clientHeight = 0,
          scrollHeight = 0,
          clientWidth = 0,
          scrollWidth = 0,
        } = this.$refs.scrollEl;

        this.xScroll = round(scrollLeft * this.trackLeftMax / this.scrollLeftMax) || 0;
        this.yScroll = round(scrollTop * this.trackTopMax / this.scrollTopMax) || 0;

        this.triggerEvent(scrollLeft, scrollTop, scrollWidth, scrollHeight, clientWidth, clientHeight);
      }
      return false;
    },

    // ------------------------------------------------------------------------------

    // 触发事件
    triggerEvent(sLeft, sTop, sWidth, sHeight, cWidth, cHeight) {
      this.throttledScroll();
      if (this.xBarShow) {
        if (sLeft === 0) {
          this.throttleLeft();
        } else if (sLeft + cWidth === sWidth) {
          this.throttleRight();
        }
      }
      if (this.yBarShow) {
        if (sTop === 0) {
          this.throttleTop();
        } else if (sTop + cHeight === sHeight) {
          this.throttleBottom();
        }
      }
    },

    // ------------------------------------------------------------------------------

    verticalHandler({ target, currentTarget, offsetY }) {
      if (target !== currentTarget) { return; }

      const offset = offsetY - this.yBarHeight / 2;
      const barTop = offset / this.containerHeight * 100;

      this.$refs.scrollEl.scrollTop = round(barTop * this.realHeight / 100);
    },

    // ------------------------------------------------------------------------------

    horizontalHandler({ target, currentTarget, offsetX }) {
      if (target !== currentTarget) { return; }

      const offset = offsetX - this.xBarWidth / 2;
      const barLeft = offset / this.containerWidth * 100;

      this.$refs.scrollEl.scrollLeft = round(barLeft * this.realWidth / 100);
    },

    // ------------------------------------------------------------------------------

    verticalBarHandler(e) {
      this.startDrag();
      this.prevPageY = this.yBarLastHeight - e.offsetY;
    },

    // ------------------------------------------------------------------------------

    horizontalBarHandler(e) {
      this.startDrag();
      this.prevPageX = this.xBarLastWidth - e.offsetX;
    },

    // ------------------------------------------------------------------------------

    startDrag() {
      this.cursorDown = true;
      addClass(document.body, ‘disable-selection‘);
      document.addEventListener(‘mousemove‘, this.throttleMoving, false);
      document.addEventListener(‘mouseup‘, this.mouseUpHandler, false);
      document.onselectstart = () => false;
    },

    // ------------------------------------------------------------------------------

    mouseUpHandler() {
      this.cursorDown = false;
      this.prevPageY = 0;
      this.prevPageX = 0;
      removeClass(document.body, ‘disable-selection‘);
      document.removeEventListener(‘mousemove‘, this.throttleMoving);
      document.removeEventListener(‘mouseup‘, this.mouseUpHandler);
      document.onselectstart = null;
    },

    // ------------------------------------------------------------------------------

    mouseMoveHandler({ clientY, clientX }) {
      let offset;
      let barPosition;

      if (this.yBarShow && this.prevPageY) {
        offset = clientY - this.$refs.vertical.getBoundingClientRect().top;
        barPosition = this.yBarLastHeight - this.prevPageY;
        const top = this.scrollTopMax * (offset - barPosition) / this.trackTopMax;
        this.$refs.scrollEl.scrollTop = round(top);
      }

      if (this.xBarShow && this.prevPageX) {
        offset = clientX - this.$refs.horizontal.getBoundingClientRect().left;
        barPosition = this.xBarLastWidth - this.prevPageX;
        const left = this.scrollLeftMax * (offset - barPosition) / this.trackLeftMax;
        this.$refs.scrollEl.scrollLeft = round(left);
      }
    },

    // ------------------------------------------------------------------------------

    dftData() {
      this.throttledScroll = null;
      this.throttleLeft = null;
      this.throttleRight = null;
      this.throttleTop = null;
      this.throttleBottom = null;
      this.throttleMoving = throttle(this.mouseMoveHandler, 10);
    },

    // ------------------------------------------------------------------------------

    // 注册事件
    initEvent() {
      const opt = { trailing: false };
      this.turnOn(‘winResize‘, this.initail);
      this.throttleTop = throttle(() => this.$emit(‘top‘), 1000, opt);
      this.throttleLeft = throttle(() => this.$emit(‘left‘), 1000, opt);
      this.throttleRight = throttle(() => this.$emit(‘right‘), 1000, opt);
      this.throttleBottom = throttle(() => this.$emit(‘bottom‘), 1000, opt);
      this.throttledScroll = throttle(() => this.$emit(‘scroll‘), 1000, opt);
    },

    // ------------------------------------------------------------------------------
  },
};

以上是关于使用 Vue 开发 scrollbar 滚动条组件的主要内容,如果未能解决你的问题,请参考以下文章

el-scrollbar element的滚动条 为什么一直隐藏 如何显现?

【Element-UI的隐藏组件】<el-scrollbar>自定义滚动条

Unity3D-UGUI系列Scrollbar 滚动条组件详解

Element scrollbar 使用封装

elementui的隐藏组件el-scrollBar的使用

Vue element-ui:滚动条 分页 禁用选项