使用 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; } .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;
},
// ------------------------------------------------------------------------------
},
};

浙公网安备 33010602011771号