Vue 應該說是很火的一款前端庫了,和 React 同樣的高熱度,今天就來用它寫一個輕量的滾動條組件;css
知識儲備:要開發滾動條組件,須要知道知識點是如何計算滾動條的大小和位置,還有一個問題是如何監聽容器大小的改變,而後更新滾動條的位置;html
先把樣式貼出來:前端
.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; }
而後,把模板貼出來:vue
<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 事件,這樣就能夠作到實時響應高度變化了,貼上代碼:web
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中的工具方法:app
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; }, // ------------------------------------------------------------------------------ }, };