vue封装一个自定义样式的滚动条

2,542 阅读6分钟

众所周知,当容器高度固定而内容部分高度超出容器高度时,浏览器会渲染出一个可以滚动并用于显示剩余界面的条 -- 滚动条。它可以简单的样式修改,但是位置是固定的,无法移动。而我们需要改变位置的时候,它就不能满足我们的需求了,这时就需要封装一个

我本想在网上找一个拿过来用的,搜了半天也没找到小伙伴封装好的,无奈我只能手搓一个了,话不多说,步入正题,先创建测试文件,先实现一个手写滚动条,测试是否可用

// test.vue
<template>
<div class="scrollLayout">
    <!-- 内容 -->
    <div class="content" ref="content" @scroll="scroll">
        <template v-for="i in 30">
            <div style="width: 10rem; text-align: center;">{{ i }}</div>
        </template>
    </div>
    <!-- 滚动条 -->
    <div class="scrollBar" ref="scrollBar">
        <div class="scrollBar-slider" :style="sliderStyle"></div>
    </div>
</div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue';

const content = ref(null); // ref绑定的内容元素
const scrollBar = ref(null); // ref绑定的手写滚动条

const sliderHeight = ref(0); // 滑块高度
const position = ref(0); // 滚动条滚动距离

const contentCH = ref(0); // content盒子高度
const contentSH = ref(0); // content内容高度
const scrollBarCH = ref(0); // 滚动条高度
const activeScrollDistance = ref(0); // 滑块可滚动的距离
const contentScrollDistance = ref(0); // 内容可滚动的距离

const sliderStyle = computed(() => `height:${sliderHeight.value}%;margin-top:${position.value}px;`);

onMounted(() => {
    const { clientHeight, scrollHeight } = content.value;
    contentCH.value = clientHeight;
    contentSH.value = scrollHeight;
    scrollBarCH.value = scrollBar.value.clientHeight;
    sliderHeight.value = (clientHeight / scrollHeight) * 100;
    activeScrollDistance.value = scrollBarCH.value - scrollBarCH.value * (sliderHeight.value / 100);
    contentScrollDistance.value = contentSH.value - contentCH.value;
});
// 内容滚动时
const scroll = () => {
    const { scrollTop } = content.value;
    position.value = (scrollTop * activeScrollDistance.value) / contentScrollDistance.value; // 滑块需要滑动的距离
};
</script>

<style lang="scss" scoped>
.scrollLayout {
    position: relative;
    font-size: 1rem;
    margin: 1rem;
    height: 15rem;
}

.content {
    height: 100%;
    overflow: auto;
    background: skyblue;

    &::-webkit-scrollbar {
        display: none;
    }
}

.scrollBar {
    position: absolute;
    top: 0;
    right: 1rem;
    width: 5px;
    height: 100%;

    &-slider {
        width: 100%;
        background: black;
        border-radius: 1rem;
    }
}
</style>

看下效果

QQ20231027-111332-HD.gif

原理:获取元素的clientHeightscrollHeight来计算滑块的高度以及可滚动距离,通过scrollTop获取滚动的距离通过scroll事件来监听内容的滚动,从而实现一个简单的手搓滚动条

效果还不错,可以进行下一步了

既然是基于内容元素获取的属性去计算,那就可以把内容dom通过provide传递给组件,组件里inject可以拿到。开始封装组件

父页面 test.vue

<script setup>
import { ref, provide } from 'vue';
import scroll from '../components/scroll.vue';

const content = ref(null);

provide('contentDom', content);
</script>

<style lang="scss" scoped>
.scrollLayout {
    position: relative;
    font-size: 1rem;
    margin: 1rem;
    height: 15rem;
}

.content {
    height: 100%;
    overflow: auto;
    background: skyblue;

    &::-webkit-scrollbar {
        display: none;
    }
}

.scrollBar {
    position: absolute;
    top: 0;
    right: 1rem;
    width: 5px;
    height: 100%;
}
</style>

组件部分 scrollBar.vue

<template>
    <div class="scrollBar" ref="scrollBar" :style="scrollBarStyle">
        <div class="slider" :style="sliderStyle"></div>
    </div>
</template>

<script setup>
import { ref, onMounted, inject, computed } from 'vue';

const content = inject('contentDom'); // 父级内容元素
const { scrollColor, sliderColor } = defineProps({
    scrollColor: {
        type: String,
        default: '',
    },
    sliderColor: {
        type: String,
        default: 'black',
    },
});

const scrollBar = ref(null); // 滚动条元素

const sliderHeight = ref(0); // 滑块高度
const position = ref(0); // 滚动条的位置
const activeScrollDistance = ref(0); // 滑块可滑动距离
const contentScrollDistance = ref(0); // 内容可滑动距离

const scrollBarStyle = computed(() => `background:${scrollColor};`);
const sliderStyle = computed(() => `height:${sliderHeight.value}%;margin-top:${position.value}px;background:${sliderColor};`);

// 初始化手写滚动条
onMounted(() => {
    const { clientHeight: contentCH, scrollHeight: contentSH } = content.value;
    const { clientHeight: scrollBarCH } = scrollBar.value;
    sliderHeight.value = (contentCH / contentSH) * 100;
    activeScrollDistance.value = scrollBarCH - scrollBarCH * (sliderHeight.value / 100);
    contentScrollDistance.value = contentSH - contentCH;
    // 监听父组件内容dom滚动
    content.value.addEventListener('scroll', () => {
        const { scrollTop } = content.value;
        position.value = (scrollTop * activeScrollDistance.value) / contentScrollDistance.value;
    });
});
</script>

<style scoped>
.scrollBar {
    display: flex;
    width: 100%;
    height: 100%;
}

.slider {
    width: 100%;
    border-radius: 10px;
}
</style>

测试了一下封装没啥问题,效果跟上面差不多,就不上图了

但是还有要考虑到的问题:

首先是可否在同一页面多次复用?如果在一个面有两个地方要用,可以provide传递一个dom对象,再父传子一个标识,根据标识取对应dom就可以实现多次复用

还有一个比较重要的问题,内容容器一般都是调接口数据进行遍历渲染,而v-for在渲染每个条目时是逐个插入到DOM中的,这说明vue会先创建一个空的父元素,并将每个条目插入到该父元素中,这意味着 通过provide把内容dom传递给子组件,子组件inject接收并获取scrollHeight 这种方法在数据更新过程中,浏览器可能还没有完成布局计算和绘制,所以获取到的scrollHeight值可能是不准确的或者不一致的;而且watch是监听不到dom元素变化的

那该如何解决呢?既然不能监听dom元素,可我们能监听到接口数据的变化;并且每次调用组件都要provide和传标识很麻烦,那就换个路子。

我是这样想的:父页面把内容通过slot给子组件把接口数据父传子,在子组件可以拿到接口数据和内容dom的scrollHeight,然后用watch监听props的接口数据,若发生变化,重新获取scrollHeight

直接开整,父页面使用setTimeout模拟接口:

<template>
  <div class="scrollLayout">
    <scroll :data="arrList">
      <template v-for="i in arrList">
        <div style="width: 10rem; text-align: center">num: {{ i.num }}</div>
      </template>
    </scroll>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import scroll from '../components/scroll.vue';

const arrList = ref([]);

setTimeout(() => {
  for (let i = 1; i <= 30; i++) {
    const obj = { num: i < 10 ? '0' + i : i };
    arrList.value.push(obj);
  }
}, 3000);
</script>

<style lang="scss" scoped>
.scrollLayout {
  height: 10rem;
  background: pink;
}
</style>

组件部分:

<template>
  <div class="scrollable">
    <div class="content" ref="content" @scroll="scroll">
      <slot></slot>
    </div>
    <div class="scrollBar" ref="scrollBar" :style="scrollBarStyle">
      <div class="slider" :style="sliderStyle"></div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue';

const props = defineProps({
  scrollColor: {
    type: String,
    default: '',
  },
  sliderColor: {
    type: String,
    default: '#000',
  },
  data: {
    type: Array,
    default: [],
  },
  right: {
    type: String,
    default: '0',
  },
});

const content = ref(null); // ref内容
const scrollBar = ref(null); // ref滚动条

const contentCH = ref(0); // content盒子高度
const contentSH = ref(0); // content内容高度
const scrollBarCH = ref(0); // 滚动条高度
const activeScrollDistance = ref(0); // 滑块可滚动的距离
const contentScrollDistance = ref(0); // 内容可滚动的距离
const sliderHeight = ref(0); // 滑块高度
const position = ref(0); // 滚动条滑动距离
const scrollBarStyle = computed(() => `right:${props.right}px;background:${props.scrollColor};`);
const sliderStyle = computed(() => `height:${sliderHeight.value}%;margin-top:${position.value}px;background:${props.sliderColor};`);

onMounted(() => {
  watch(
    () => props.data,
    () => {
      // nextTick确保在DOM更新完毕后再执行
      nextTick(() => {
        const { clientHeight, scrollHeight } = content.value;
        console.log('容器的高度:', clientHeight, '内容高度:', scrollHeight);
        contentCH.value = clientHeight;
        contentSH.value = scrollHeight;
        scrollBarCH.value = scrollBar.value.clientHeight;
        sliderHeight.value = (clientHeight / scrollHeight) * 100;
        activeScrollDistance.value = scrollBarCH.value - scrollBarCH.value * (sliderHeight.value / 100);
        contentScrollDistance.value = contentSH.value - contentCH.value;
      });
    },
    { immediate: true, deep: true }
  );
});
// 监听滚动
const scroll = () => {
  const { scrollTop } = content.value;
  position.value = (scrollTop * activeScrollDistance.value) / contentScrollDistance.value;
};
</script>

<style lang="scss" scoped>
.scrollable {
  position: relative;
  display: flex;
  height: 100%;
  overflow: hidden;
}

.content {
  height: 100%;
  overflow: auto;

  &::-webkit-scrollbar {
    display: none;
  }
}
.scrollBar {
  position: absolute;
  top: 0;
  width: 5px;
  height: 100%;
  border-radius: 5px;

  .slider {
    width: 100%;
    border-radius: 3px;
  }
}
</style>

这样就可以解决初始获取的scrollHeight是内容插入前的高度——即容器高度的问题。

剩下的就是一些小问题了。我们都知道滚动条是可以拖动滑块来实现内容区域滚动的,但这个滚动条只是单向的跟着内容滑动来动态展示的,而不是双向的;在桌面端,视口尺寸是可以通过拖动来改变的,但是我们通过dom获取的数据是px,不是响应式。

添加鼠标和触控拖动功能、响应式:

<template>
    <div class="scrollable">
        <div class="content" ref="content" @scroll="scroll">
            <slot></slot>
        </div>
        <div class="scrollBar" ref="scrollBar" :style="scrollBarStyle">
            <div class="slider" :style="sliderStyle" @mousedown="clickDowm" @touchstart="clickDowm"></div>
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue';

const props = defineProps({
    scrollColor: {
        type: String,
        default: '',
    },
    sliderColor: {
        type: String,
        default: '#000',
    },
    data: {
        type: Array,
        default: [],
    },
    right: {
        type: String,
        default: '0',
    },
});

const content = ref(null); // ref内容
const scrollBar = ref(null); // ref滚动条

const contentCH = ref(0); // content盒子高度
const contentSH = ref(0); // content内容高度
const scrollBarCH = ref(0); // 滚动条高度
const activeScrollDistance = ref(0); // 滑块可滚动的距离
const contentScrollDistance = ref(0); // 内容可滚动的距离
const sliderHeight = ref(0); // 滑块高度
const position = ref(0); // 滚动条滑动距离
const isShowScrollBar = computed(() => `${contentCH.value == contentSH.value ? 'opacity:0;' : ''}`); // 是否显示滚动条
const scrollBarStyle = computed(() => `right:${props.right}px;background:${props.scrollColor};${isShowScrollBar.value}`);
const sliderStyle = computed(() => `height:${sliderHeight.value}%;margin-top:${position.value}rem;background:${props.sliderColor};`);

const isDragging = ref(false); // 是否正在拖动滑块
const isMouse = ref(false); // 是否用鼠标点击(true:鼠标 / false:触屏)
const dragStartY = ref(0); // 拖动起始位置的垂直坐标
const dragStartPos = ref(0); // 拖动起始时滑块的位置

onMounted(() => {
    watch(
        () => props.data,
        () => {
            // nextTick确保在DOM更新完毕后再执行
            nextTick(() => {
                const { clientHeight, scrollHeight } = content.value;
                console.log('容器的高度:', clientHeight, '内容高度:', scrollHeight);
                contentCH.value = pxToRem(clientHeight);
                contentSH.value = pxToRem(scrollHeight);
                scrollBarCH.value = pxToRem(scrollBar.value.clientHeight);
                sliderHeight.value = (clientHeight / scrollHeight) * 100;
                activeScrollDistance.value = scrollBarCH.value - scrollBarCH.value * (sliderHeight.value / 100);
                contentScrollDistance.value = contentSH.value - contentCH.value;
            });
        },
        { immediate: true, deep: true }
    );
});

// 监听滚动
const scroll = () => {
    const { scrollTop } = content.value;
    position.value = (pxToRem(scrollTop) * activeScrollDistance.value) / contentScrollDistance.value;
};

// 按住滑块时
const clickDowm = (e) => {
    isMouse.value = e?.touches ? false : true;
    isDragging.value = true;
    dragStartY.value = pxToRem(isMouse.value ? e.clientY : e.touches[0].clientY);
    dragStartPos.value = position.value;
    if (isMouse.value) {
        document.addEventListener('mousemove', startMove);
        document.addEventListener('mouseup', clickUp);
    } else {
        document.addEventListener('touchmove', startMove);
        document.addEventListener('touchend', clickUp);
    }
};

// 松开滑块时
const clickUp = () => {
    isDragging.value = false;
    removeListener();
};

// 拖动滑块
const startMove = (e) => {
    if (isMouse.value) changeSlider(e.clientY);
    else changeSlider(e.touches[0].clientY);
};

const changeSlider = (val) => {
    if (isDragging.value) {
        position.value = Math.max(0, Math.min(dragStartPos.value + pxToRem(val) - dragStartY.value, activeScrollDistance.value));
        content.value.scrollTop = remToPx((position.value * contentScrollDistance.value) / activeScrollDistance.value);
    }
};

// 移除监听
const removeListener = () => {
    if (isMouse.value) {
        document.removeEventListener('mousemove', startMove);
        document.removeEventListener('mouseup', clickUp);
    } else {
        document.removeEventListener('touchmove', startMove);
        document.removeEventListener('touchend', clickUp);
    }
};

// px ⇌ rem
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize);
const pxToRem = (px) => +(px / rootFontSize()).toFixed(3);
const remToPx = (rem) => rem * rootFontSize();
</script>

利用@mousedown事件和@touchstart事件来监听按住滑块,并获取到拖动起始位置的坐标拖动起始时滑块的位置,在通过按住滑块时监听移动来计算并修改滚动条滑动距离内容滚动距离以实现内容与滚动条的双向绑定,松开滑块时移除监听

获取到跟元素字体大小,据此实现pxrem的转化,实现响应式

以上就是滚动条的功能实现,若需要横向滚动和滚动条,只需要获取clientWidth容器宽度、scrollWidth内容宽度、scrollLeft横向滚动距离 即可,原理跟纵向是一样的

希望能帮助到您 (^▽^)