使用 Vue 开发 scrollbar 滚动条组件

时间:2022-01-22 11:17:58

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:;
left:;
height: 100%;
width: 100%;
overflow: hidden;
pointer-events: none;
z-index: -1;
opacity:;
} .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:;
cursor: pointer;
transition: opacity 0.25s linear;
background: rgba(0, 0, 0, 0.2);
} .scrollbar-container .scrollbar-vertical {
top:;
left: auto;
right:;
width: 12px;
} .scrollbar-container .scrollbar-horizontal {
top: auto;
left:;
bottom:;
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:;
} .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;
}, // ------------------------------------------------------------------------------ },
};