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; } .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-x: hidden!important; overflow-y: hidden!important; width: 100%; height: 100%; } .scrollbar-container--auto { overflow-x: visible!important; overflow-y: visible!important; } .scrollbar-container .scrollbar-view { width: 100%; height: 100%; -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-vertical, .scrollbar-container .scrollbar-horizontal { position: absolute; opacity: 0; cursor: pointer; transition: opacity 0.25s linear; background: rgba(0, 0, 0, 0.2); } .scrollbar-container .scrollbar-vertical { top: 0; left: auto; right: 0; width: 12px; } .scrollbar-container .scrollbar-horizontal { top: auto; left: 0; bottom: 0; height: 12px; } .scrollbar-container:hover .scrollbar-vertical, .scrollbar-container:hover .scrollbar-horizontal, .scrollbar-container .scrollbar-vertical.scrollbar-show, .scrollbar-container .scrollbar-horizontal.scrollbar-show { opacity: 1; } .scrollbar-container.cssui-scrollbar--s .scrollbar-vertical { width: 6px; } .scrollbar-container.cssui-scrollbar--s .scrollbar-horizontal { height: 6px; }
然后,把模板贴出来:
<template> <div :style="containerStyle" :class="containerClass" @mouseenter="quietUpdate" @mouseleave="quietOff" > <div ref="scroll" :style="scrollStyle" :class="scrollClass" @scroll.stop.prevent="realUpdate" > <div ref="content" v-resize="resizeHandle" > <slot /> div> div> <div v-if="yBarShow" :style="yBarStyle" :class="yBarClass" @mousedown="downVertical" /> <div v-if="xBarShow" :style="xBarStyle" :class="xBarClass" @mousedown="downHorizontal" /> 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; } }, });
还有用到 tools js中的工具方法:
if (!Date.now) { Date.now = function () { return new Date().getTime(); }; } const vendors = ['webkit', 'moz']; if (!window.requestAnimationFrame) { for (let i = 0; i < vendors.length; ++i) { const vp = vendors[i]; window.requestAnimationFrame = window[`${vp}RequestAnimationFrame`]; window.cancelAnimationFrame = (window[`${vp}CancelAnimationFrame`] || window[`${vp}CancelRequestAnimationFrame`]); } } if (!window.requestAnimationFrame || !window.cancelAnimationFrame) { let lastTime = 0; window.requestAnimationFrame = callback => { const now = Date.now(); const nextTime = Math.max(lastTime + 16, now); return setTimeout(() => { callback(lastTime = nextTime); }, nextTime - now); }; window.cancelAnimationFrame = clearTimeout; } let scrollWidth = 0; // requestAnimationFrame 封装 export const ref = (fn) => { window.requestAnimationFrame(fn); }; // 检测 class export 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 export 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; } }; // 获取滚动条宽度 export const getScrollWidth = () => { if (scrollWidth > 0) { return scrollWidth; } const block = docu.createElement('div'); block.style.cssText = 'position:absolute;top:-1000px;width:100px;height:100px;overflow-y:scroll;'; body.appendChild(block); const { clientWidth, offsetWidth } = block; body.removeChild(block); scrollWidth = offsetWidth - clientWidth; return scrollWidth; };
下面是 js 功能的部分,代码还是不少,有一些方法做了节流处理,用了一些 lodash 的方法,主要还是上面提到的滚动条计算的原理,大小的计算,具体看 toUpdate 这个方法,位置的计算,主要是 horizontalHandler,verticalHandler,实际滚动距离的计算,看mouseMoveHandler 这个方法:
import { raf, addClass, removeClass, getScrollWidth } from 'src/tools'; const SCROLLBARSIZE = getScrollWidth(); /** * ---------------------------------------------------------------------------------- * UiScrollBar Component * ---------------------------------------------------------------------------------- * * @author zhangmao * @change 2019/4/15 */ 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 { enter: false, yRatio: 0, xRatio: 0, lastPageY: 0, lastPageX: 0, realWidth: 0, realHeight: 0, yBarTop: 0, yBarHeight: 0, xBarLeft: 0, xBarWidth: 0, scrollWidth: 0, scrollHeight: 0, containerWidth: 0, containerHeight: 0, cursorDown: false, }; }, computed: { xLimit() { return this.width > 0 || this.maxWidth > 0; }, yLimit() { return this.height > 0 || this.maxHeight > 0; }, yBarShow() { return this.getYBarShow(); }, xBarShow() { return this.getXBarShow(); }, yBarStyle() { return { top: `${this.yBarTop}%`, height: `${this.yBarHeight}%` }; }, yBarClass() { return ['scrollbar-vertical', { 'scrollbar-show': this.cursorDown }]; }, xBarStyle() { return { left: `${this.xBarLeft}%`, width: `${this.xBarWidth}%` }; }, xBarClass() { return ['scrollbar-horizontal', { 'scrollbar-show': this.cursorDown }]; }, scrollClass() { return ['scrollbar-view', { 'scrollbar-view-x': this.xBarShow, 'scrollbar-view-y': this.yBarShow, }]; }, scrollStyle() { const hasWidth = this.yBarShow && this.scrollWidth > 0; const hasHeight = this.xBarShow && this.scrollHeight > 0; return { width: hasWidth ? `${this.scrollWidth}px` : '', height: hasHeight ? `${this.scrollHeight}px` : '', }; }, containerClass() { return ['scrollbar-container', { 'cssui-scrollbar--s': this.size === 'small', 'scrollbar-container--auto': !this.xBarShow && !this.yBarShow, }]; }, containerStyle() { const showSize = this.xBarShow || this.yBarShow; const styleObj = {}; if (showSize) { if (this.containerWidth > 0) { styleObj.width = `${this.containerWidth}px`; } if (this.containerHeight > 0) { styleObj.height = `${this.containerHeight}px`; } } return styleObj; }, }, watch: { show: 'showChange', width: 'initail', height: 'initail', maxWidth: 'initail', maxHeight: 'initail', }, created() { this.dftData(); this.initEmiter(); }, mounted() { this.$nextTick(this.initail); }, methods: { // ------------------------------------------------------------------------------ // 外部调用方法 refresh() { this.initail(); }, // 手动更新滚动条 scrollX(x) { this.$refs.scroll.scrollLeft = x; }, scrollY(y) { this.$refs.scroll.scrollTop = y; }, scrollTop() { this.$refs.scroll.scrollTop = 0; }, getScrollEl() { return this.$refs.scroll; }, scrollBottom() { this.$refs.scroll.scrollTop = this.$refs.content.offsetHeight; }, // -------------------------------------------------------------------------- quietOff() { this.enter = false; }, // ------------------------------------------------------------------------------ quietUpdate() { this.enter = true; this.scrollUpdate(); }, // ------------------------------------------------------------------------------ realUpdate() { this.quietOff(); this.scrollUpdate(); }, // ------------------------------------------------------------------------------ resizeHandle() { this.initail(); }, // ------------------------------------------------------------------------------ // 默认隐藏 异步展示的情况 showChange(val) { if (val) { this.initail(); } }, // ------------------------------------------------------------------------------ // 组件渲染成功后的入口 initail() { this.setContainerSize(); this.setScrollSize(); this.setContentSize(); this.realUpdate(); }, // ------------------------------------------------------------------------------ // 设置整个容器的大小 setContainerSize() { this.setContainerXSize(); this.setContainerYSize(); }, // ------------------------------------------------------------------------------ // 设置滚动容器的大小 setScrollSize() { this.scrollWidth = this.containerWidth + SCROLLBARSIZE; this.scrollHeight = this.containerHeight + SCROLLBARSIZE; }, // ------------------------------------------------------------------------------ // 设置内容区域的大小 setContentSize() { const realElement = this.$refs.content.firstChild; if (realElement) { const { offsetWidth = 0, offsetHeight = 0 } = realElement; this.realWidth = this.lodash.round(offsetWidth); this.realHeight = this.lodash.round(offsetHeight); } }, // ------------------------------------------------------------------------------ setContainerXSize() { if (this.xLimit) { this.containerWidth = this.width || this.maxWidth; return; } if (this.yLimit) { this.containerWidth = this.lodash.round(this.$el.offsetWidth); } }, // ------------------------------------------------------------------------------ setContainerYSize() { if (this.yLimit) { this.containerHeight = this.height || this.maxHeight; return; } if (this.xLimit) { this.containerHeight = this.lodash.round(this.$el.offsetHeight); } }, // ------------------------------------------------------------------------------ downVertical(e) { this.lastPageY = e.pageY; this.cursorDown = true; addClass(document.body, 'disable-selection'); document.addEventListener('mousemove', this.moveVertical, false); document.addEventListener('mouseup', this.upVertical, false); document.onselectstart = () => false; return false; }, // ------------------------------------------------------------------------------ downHorizontal(e) { this.lastPageX = e.pageX; this.cursorDown = true; addClass(document.body, 'disable-selection'); document.addEventListener('mousemove', this.moveHorizontal, false); document.addEventListener('mouseup', this.upHorizontal, false); document.onselectstart = () => false; return false; }, // ------------------------------------------------------------------------------ moveVertical(e) { const delta = e.pageY - this.lastPageY; this.lastPageY = e.pageY; raf(() => { this.$refs.scroll.scrollTop += delta / this.yRatio; }); }, // ------------------------------------------------------------------------------ moveHorizontal(e) { const delta = e.pageX - this.lastPageX; this.lastPageX = e.pageX; raf(() => { this.$refs.scroll.scrollLeft += delta / this.xRatio; }); }, // ------------------------------------------------------------------------------ upVertical() { this.cursorDown = false; removeClass(document.body, 'disable-selection'); document.removeEventListener('mousemove', this.moveVertical); document.removeEventListener('mouseup', this.upVertical); document.onselectstart = null; }, // ------------------------------------------------------------------------------ upHorizontal() { this.cursorDown = false; removeClass(document.body, 'disable-selection'); document.removeEventListener('mousemove', this.moveHorizontal); document.removeEventListener('mouseup', this.upHorizontal); document.onselectstart = null; }, // ------------------------------------------------------------------------------ scrollUpdate() { const { clientWidth = 0, scrollWidth = 0, clientHeight = 0, scrollHeight = 0, } = this.$refs.scroll; this.yRatio = clientHeight / scrollHeight; this.xRatio = clientWidth / scrollWidth; raf(() => { if (this.yBarShow) { this.yBarHeight = Math.max(this.yRatio * 100, 1); this.yBarTop = this.lodash.round((this.$refs.scroll.scrollTop / scrollHeight) * 100, 2); // 只更新不触发事件 if (this.enter) { return; } const top = this.$refs.scroll.scrollTop; const left = this.$refs.scroll.scrollLeft; const cHeight = this.$refs.scroll.clientHeight; const sHeight = this.$refs.scroll.scrollHeight; // trigger event this.debounceScroll({ top, left }); if (top === 0) { this.debounceTop(); } else if (top + cHeight === sHeight) { this.debounceBottom(); } } if (this.xBarShow) { this.xBarWidth = Math.max(this.xRatio * 100, 1); this.xBarLeft = this.lodash.round((this.$refs.scroll.scrollLeft / scrollWidth) * 100, 2); // 只更新不触发事件 if (this.enter) { return; } const top = this.$refs.scroll.scrollTop; const left = this.$refs.scroll.scrollLeft; const cWidth = this.$refs.scroll.clientWidth; const sWidth = this.$refs.scroll.scrollWidth; // trigger event this.debounceScroll({ top, left }); if (left === 0) { this.debounceLeft(); } else if (left + cWidth === sWidth) { this.debounceRight(); } } }); }, // ------------------------------------------------------------------------------ dftData() { this.debounceLeft = null; this.debounceRight = null; this.debounceTop = null; this.debounceBottom = null; this.debounceScroll = null; }, // ------------------------------------------------------------------------------ // 初始化触发事件 initEmiter() { this.turnOn('winResize', this.initail); this.debounceTop = this.lodash.debounce(() => this.$emit('top'), 500); this.debounceLeft = this.lodash.debounce(() => this.$emit('left'), 500); this.debounceRight = this.lodash.debounce(() => this.$emit('right'), 500); this.debounceBottom = this.lodash.debounce(() => this.$emit('bottom'), 500); this.debounceScroll = this.lodash.debounce(obj => this.$emit('scroll', obj), 250); }, // ------------------------------------------------------------------------------ // 是否展示垂直的滚动条 getYBarShow() { if (this.yLimit) { if (this.height > 0) { return this.realHeight > this.height; } if (this.maxHeight > 0) { return this.realHeight > this.maxHeight; } return this.realHeight > this.containerHeight; } return false; }, // ------------------------------------------------------------------------------ // 是否展示横向的滚动条 getXBarShow() { if (this.xLimit) { if (this.width > 0) { return this.realWidth > this.width; } if (this.maxWidth > 0) { return this.realWidth > this.maxWidth; } return this.realWidth > this.containerWidth; } return false; }, // ------------------------------------------------------------------------------ }, };