滚动条组件实现

相信用过Element-UI的都接触过<el-scrollbar>这个组件,该组件在对应文档中并没有出现,所以在使用上都是靠摸索,并且会出现一些让人抓狂的问题,比如:

  1. 滚动的尺寸是在组件上设置还是父容器上设置又或者是组件内的容器上设置?
  2. 分不清设置宽高的容器导致出现的原生滚动条需要额外的定义overflow-x: hiddenoverflow-y: hidden强制去除。
  3. 弹性布局的宽高无法自动适配滚动。
  4. <el-scrollbar><div>动态添加节点或者宽高动态变化时</div><el-scrollbar>滚动条尺寸没有对应更新。

对于以上等等问题,使用感官上并不友好(可能就是没有出现在文档的原因),但是并不代表这个滚动组件的设计不好,相反,个人十分同意这种用节点来模拟滚动条的方法来抹平各个浏览器上的兼容样式,所以手动实现一个相对好用、适配场景比较完善的轮子。

代码地址:vue 3.x | vue 2.x

预览效果:自定义滚动条组件

布局思路

虽然是参考<el-scrollbar>,但我觉得它布局还可以精简,所以就不仿照了,直接用自己的方式来实现;先来看下最终的盒子排版:

scroll-2.png

简单说一下布局,总共就4个节点:整体盒子内容包裹盒子横向滚动按钮竖向滚动按钮

  1. 整体盒子尺寸根据父容器的宽高来自动适配,然后超出隐藏(重点):宽高100%即可;
  2. 内容包裹盒子也是宽高100%,但是要在右边和下边加上滚动条的宽度,再设置overflow: scroll,这样就会一直出现滚动条,又因为父容器超出隐藏了,所以视觉上看不到。
  3. 最后两个横竖滚动按钮就根据滚动条的尺寸去动态设置样式即可。

基础HTML片段

<div class="the-scrollbar">
    <div class="the-scrollbar-wrap">
        <slot></slot>
    </div>
    <button class="the-scrollbar-thumb" title="滚动条-摁住拖拽Y轴"></button>
    <button class="the-scrollbar-thumb" title="滚动条-摁住拖拽X轴"></button>
</div>
复制代码
.the-scrollbar {
    width: 100%;
    height: 100%;
    overflow: hidden;
    position: relative;
    .the-scrollbar-wrap {
        overflow: scroll;
    }
    .the-scrollbar-thumb {
        position: absolute;
        z-index: 10;
        outline: none;
        border: none;
    }
}
复制代码

滚动条的厚度获取

这里要获取一个滚动条的变量,再去设置the-scrollbar-wrap的宽度值,因为每个浏览器的滚动条宽度可能不一样,所以这里不能用css写死一个值,只能用js去获取:

/** 滚动条的厚度 */
const scrollbarSize = (function() {
    const el = document.createElement("div");
    el.style.width = "100px";
    el.style.height = "100px";
    el.style.overflow = "scroll";
    document.body.appendChild(el);
    const width = el.offsetWidth - el.clientWidth;
    el.remove();
    return width;
})();
复制代码

什么时候更新虚拟滚动条的样式

这里我选择 检测the-scrollbar-wrap滚动条的尺寸然后设置the-scrollbar-thumb样式 的触发条件有:

  1. 鼠标移出移入,移入时更新样式和显示the-scrollbar-thumb,鼠标移出时隐藏the-scrollbar-thumb
  2. addEventListener("scroll"),滚动时更新。
  3. document.addEventListener("mousedown")document.addEventListener("mousemove")document.addEventListener("mouseup");监听鼠标的移动事件,拖拽the-scrollbar-thumb的时候也要更新设置样式,同时也要算出移动的距离,这里移动的距离有个细节,就是以当前the-scrollbar-wrap的宽高比例来换算出最终移动的距离。

要处理的事件基本上就这些,剩下的都是些细节上的处理,这里就不展开细说了。

最终代码片段

<template>
    <div class="the-scrollbar" ref="el" @mouseenter="onEnter()" @mouseleave="onLeave()">
        <div ref="wrap" class="the-scrollbar-wrap" :style="wrapStyle">
            <slot></slot>
        </div>
        <transition name="fade">
            <button
                class="the-scrollbar-thumb"
                ref="thumbY"
                title="滚动条-摁住拖拽Y轴"
                :style="thumbStyle.y"
                v-show="showThumb"
            ></button>
        </transition>
        <transition name="fade">
            <button
                class="the-scrollbar-thumb"
                ref="thumbX"
                title="滚动条-摁住拖拽X轴"
                :style="thumbStyle.x"
                v-show="showThumb"
            ></button>
        </transition>
    </div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, reactive, onUnmounted } from "vue";

/** 滚动条的厚度 */
const scrollbarSize = (function() {
    const el = document.createElement("div");
    el.style.width = "100px";
    el.style.height = "100px";
    el.style.overflow = "scroll";
    document.body.appendChild(el);
    const width = el.offsetWidth - el.clientWidth;
    el.remove();
    return width;
})();

/**
 * 滚动条组件
 */
export default defineComponent({
    name: "Scrollbar",
    props: {
        /** 滚动条颜色 */
        thumbColor: {
            type: String,
            default: "rgba(147, 147, 153, 0.45)"
        },
        /** 滚动条厚度 */
        thumbSize: {
            type: Number,
            default: 8
        },
        /**
         * 内部有点击事件时,延时更新滚动条的时间,0为不执行,单位毫秒
         * - 使用场景:内部有子节点尺寸变动撑开包裹器的滚动尺寸时,并且带有动画的情况,这时设置的延迟就为动画持续时间
         */
        clickUpdateDelay: {
            type: Number,
            default: 0
        },
    },
    setup(props) {
        /** 组件整体节点 */
        const el = ref<HTMLElement>();
        /** 包围器节点 */
        const wrap = ref<HTMLElement>();
        /** 滚动条节点X */
        const thumbX = ref<HTMLElement>();
        /** 滚动条节点Y */
        const thumbY = ref<HTMLElement>();
        /** 包围器节点样式 */
        const wrapStyle = reactive({
            height: "",
            width: ""
        })
        /** 滚动条节点样式 */
        const thumbStyle = reactive({
            x: {
                width: "",
                height: "",
                left: "",
                bottom: "",
                transform: "",
                borderRadius: "",
                backgroundColor: props.thumbColor
            },
            y: {
                width: "",
                height: "",
                top: "",
                right: "",
                transform: "",
                borderRadius: "",
                backgroundColor: props.thumbColor
            }
        })
        const showThumb = ref(false);

        /**
         * 更新包裹容器样式
         * - !!!注意:如果是动态设置组件父容器的边框时,需要手动执行该方法,
         * 原因是父容器的边框会影响当前设置的包围盒宽度,导致滚动条的高度有所变化,也就是跟`css`中设置
         * `box-sizing: border-box;`的原理一样
         */
        function updateWrapStyle() {
            const parent = el.value!.parentNode as HTMLElement;
            parent.style.overflow = "hidden"; // 这里一定要将父元素设置超出隐藏,不然弹性盒子布局时会撑开宽高
            const css = getComputedStyle(parent);
            // console.log("父元素边框尺寸 >>", css.borderLeftWidth, css.borderRightWidth, css.borderTopWidth, css.borderBottomWidth);
            wrapStyle.width = `calc(100% + ${scrollbarSize}px + ${css.borderLeftWidth} + ${css.borderRightWidth})`;
            wrapStyle.height = `calc(100% + ${scrollbarSize}px + ${css.borderTopWidth} + ${css.borderBottomWidth})`;
        }

        /** 初始化滚动指示器样式 */
        function initThumbStyle() {
            thumbStyle.y.right = thumbStyle.y.top = "0px";
            thumbStyle.y.width = props.thumbSize + "px";
            thumbStyle.x.bottom = thumbStyle.x.left = "0px";
            thumbStyle.x.height = props.thumbSize + "px";
            thumbStyle.x.borderRadius = thumbStyle.y.borderRadius = `${props.thumbSize / 2}px`;
        }

        /**
         * 更新滚动指示器样式
         * - 可以外部主动调用
         */
        function updateThumbStyle() {
            const wrapEl = wrap.value;
            if (wrapEl) {
                let height = wrapEl.clientHeight / wrapEl.scrollHeight * 100;
                if (height >= 100) {
                    height = 0;
                }
                thumbStyle.y.height = height + "%";
                thumbStyle.y.transform = `translate3d(0, ${wrapEl.scrollTop / wrapEl.scrollHeight * wrapEl.clientHeight}px, 0)`;

                // console.log("scrollWidth >>", wrapEl.scrollWidth);
                // console.log("scrollLeft >>", wrapEl.scrollLeft);
                // console.log("clientWidth >>", wrapEl.clientWidth);
                // console.log("offsetWidth >>", wrapEl.offsetWidth);
                let width = (wrapEl.clientWidth / wrapEl.scrollWidth) * 100;
                if (width >= 100) {
                    width = 0;
                }
                thumbStyle.x.width = width + "%";
                thumbStyle.x.transform = `translate3d(${wrapEl.scrollLeft / wrapEl.scrollWidth * wrapEl.clientWidth}px, 0, 0)`;
                // console.log("------------------------------------");
            }
        }

        /** 是否摁下开始拖拽 */
        let isDrag = false;
        /** 是否垂直模式 */
        let vertical = false;
        /** 摁下滚动条时的偏移量 */
        let deviation = 0;
        /** 更新延时器 */
        let timer: NodeJS.Timeout;

        function onDragStart(event: MouseEvent) {
            // console.log("摁下 >>", event);
            const _thumbX = thumbX.value!;
            const _thumbY = thumbY.value!;
            const target = event.target as HTMLElement;
            if (_thumbX.contains(target)) {
                isDrag = true;
                vertical = false;
                deviation = event.clientX - _thumbX.getBoundingClientRect().left;
            }
            if (_thumbY.contains(target)) {
                isDrag = true;
                vertical = true;
                deviation = event.clientY - _thumbY.getBoundingClientRect().top;
            }
        }

        function onDragMove(event: MouseEvent) {
            if (!isDrag) return;
            // console.log("拖拽移动 >>", event.offsetY, event.clientY, event);
            const wrapEl = wrap.value!;
            if (vertical) {
                const wrapTop = wrapEl.getBoundingClientRect().top;
                const wrapHeight = wrapEl.clientHeight;
                let value = event.clientY - wrapTop;
                wrapEl.scrollTop = (value - deviation) / wrapHeight * wrapEl.scrollHeight;
            } else {
                const wrapLeft = wrapEl.getBoundingClientRect().left;
                const wrapWidth = wrapEl.clientWidth;
                let value = event.clientX - wrapLeft;
                wrapEl.scrollLeft = (value - deviation) / wrapWidth * wrapEl.scrollWidth;
            }
        }

        function onDragEnd(event: MouseEvent) {
            // console.log("抬起");
            isDrag = false;
            if (el.value!.contains(event.target as HTMLElement)) {
                if (props.clickUpdateDelay > 0) {
                    // console.log("执行");
                    timer && clearTimeout(timer);
                    timer = setTimeout(updateThumbStyle, props.clickUpdateDelay);
                }
            } else {
                showThumb.value = false;
            }
        }

        function onEnter() {
            showThumb.value = true;
            updateThumbStyle();
        }

        function onLeave() {
            if (!isDrag) {
                showThumb.value = false;
            }
        }

        onMounted(function() {
            // console.log("onMounted >>", el.value!.clientHeight);
            // console.log("scrollbarSize >>", scrollbarSize);
            updateWrapStyle();
            initThumbStyle();
            wrap.value && wrap.value.addEventListener("scroll", updateThumbStyle);
            document.addEventListener("mousedown", onDragStart);
            document.addEventListener("mousemove", onDragMove);
            document.addEventListener("mouseup", onDragEnd);
        });

        onUnmounted(function() {
            wrap.value && wrap.value.removeEventListener("scroll", updateThumbStyle);
            document.removeEventListener("mousedown", onDragStart);
            document.removeEventListener("mousemove", onDragMove);
            document.removeEventListener("mouseup", onDragEnd);
            timer && clearTimeout(timer);
        });

        return {
            el,
            wrap,
            thumbX,
            thumbY,
            wrapStyle,
            thumbStyle,
            showThumb,
            updateThumbStyle,
            onEnter,
            onLeave
        }
    }
})
</script>
<style lang="scss">
.the-scrollbar {
    width: 100%;
    height: 100%;
    overflow: hidden;
    position: relative;
    .the-scrollbar-wrap {
        overflow: scroll;
    }
    .the-scrollbar-thumb {
        position: absolute;
        z-index: 10;
        outline: none;
        border: none;
    }
}
</style>
复制代码

使用上也是十分简单,直接定义父容器的宽度来做滚动即可;现在可以使用flex: 1max-widthmax-height这里自适应布局来使用了。另外有个细节就是props.clickUpdateDelay,有些时候滚动组件内部做的点击事件去改变滚动尺寸时,因为没有可以触发updateThumbStyle的事件,所以特意加的一个延迟更新操作(边缘情况);当前,也可以在外部手动调用Scrollbar.updateThumbStyle()来达到一个异步更新滚动条操作。

Guess you like

Origin juejin.im/post/7068617486186479653
Recommended