Vue3滚动条(Scrollbar)

简介: 这是一个基于 Vue 的自定义滚动条组件 Scrollbar.vue,提供了丰富的配置选项和方法。通过参数如 `contentClass`、`size` 和 `trigger` 等,可以灵活控制滚动条的样式和行为。

效果如下图:在线预览

在这里插入图片描述
在这里插入图片描述

APIs

Scrollbar

参数 说明 类型 默认值
contentClass 内容 div 的类名 string undefined
contentStyle 内容 div 的样式 CSSProperties {}
size 滚动条的大小,单位 px number 5
trigger 显示滚动条的时机,'none' 表示一直显示 ‘hover’ | ‘none’ ‘hover’
autoHide 是否自动隐藏滚动条,仅当 trigger: 'hover' 时生效,true: hover且不滚动时自动隐藏,滚动时自动显示;false: hover时始终显示 boolean true
delay 滚动条自动隐藏的延迟时间,单位 ms number 1000
horizontal 是否使用横向滚动 boolean false

Methods

名称 说明 类型
scrollTo 滚动内容 (options: { left?: number, top?: number, behavior?: ScrollBehavior }): void & (x: number, y: number) => void
scrollBy 滚动特定距离 (options: { left?: number, top?: number, behavior?: ScrollBehavior }): void & (x: number, y: number) => void

ScrollBehavior Type

说明
smooth 平滑滚动并产生过渡效果
instant 滚动会直接跳转到目标位置,没有过渡效果
auto 或缺省值表示浏览器会自动选择滚动时的过渡效果

Events

名称 说明 类型
scroll 滚动的回调 (e: Event) => void

创建滚动条组件Scrollbar.vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { CSSProperties } from 'vue'
import { debounce, useEventListener, useMutationObserver } from '../utils'
interface Props {
  contentClass?: string // 内容 div 的类名
  contentStyle?: CSSProperties // 内容 div 的样式
  size?: number // 滚动条的大小,单位 px
  trigger?: 'hover' | 'none' // 显示滚动条的时机,'none' 表示一直显示
  autoHide?: boolean // 是否自动隐藏滚动条,仅当 trigger: 'hover' 时生效,true: hover且不滚动时自动隐藏,滚动时自动显示;false: hover时始终显示
  delay?: number // 滚动条自动隐藏的延迟时间,单位 ms
  horizontal?: boolean // 是否使用横向滚动
}
const props = withDefaults(defineProps<Props>(), {
  contentClass: undefined,
  contentStyle: () => ({}),
  size: 5,
  trigger: 'hover',
  autoHide: true,
  delay: 1000,
  horizontal: false
})
const scrollbarRef = ref()
const containerRef = ref()
const contentRef = ref()
const railVerticalRef = ref()
const railHorizontalRef = ref()
const showTrack = ref(false)
const containerScrollHeight = ref(0) // 滚动区域高度,包括溢出高度
const containerScrollWidth = ref(0) // 滚动区域宽度,包括溢出宽度
const containerClientHeight = ref(0) // 滚动区域高度,不包括溢出高度
const containerClientWidth = ref(0) // 滚动区域宽度,不包括溢出宽度
const containerHeight = ref(0) // 容器高度
const containerWidth = ref(0) // 容器宽度
const contentHeight = ref(0) // 内容高度
const contentWidth = ref(0) // 内容宽度
const railHeight = ref(0) // 滚动条高度
const railWidth = ref(0) // 滚动条宽度
const containerScrollTop = ref(0) // 垂直滚动距离
const containerScrollLeft = ref(0) // 水平滚动距离
const trackYPressed = ref(false) // 垂直滚动条是否被按下
const trackXPressed = ref(false) // 水平滚动条是否被按下
const mouseLeave = ref(false) // 鼠标在按下滚动条并拖动时是否离开滚动区域
const memoYTop = ref<number>(0) // 鼠标选中并按下垂直滚动条时已滚动的垂直距离
const memoXLeft = ref<number>(0) // 鼠标选中并按下水平滚动条时已滚动的水平距离
const memoMouseY = ref<number>(0) // 鼠标选中并按下垂直滚动条时的鼠标 Y 坐标
const memoMouseX = ref<number>(0) // 鼠标选中并按下水平滚动条时的鼠标 X 坐标
const horizontalContentStyle = { width: 'fit-content' } // 水平滚动时内容区域默认样式
const trackHover = ref(false) // 鼠标是否在滚动条上
const trackLeave = ref(false) // 鼠标在按下滚动条并拖动时是否离开滚动条
const emit = defineEmits(['scroll'])
const autoShowTrack = computed(() => {
  return props.trigger === 'hover' && props.autoHide
})
const isYScroll = computed(() => {
  // 是否存在垂直滚动
  return containerScrollHeight.value > containerClientHeight.value
})
const isXScroll = computed(() => {
  // 是否存在水平滚动
  return containerScrollWidth.value > containerClientWidth.value
})
const isScroll = computed(() => {
  // 是否存在滚动,水平或垂直
  return isYScroll.value || (props.horizontal && isXScroll.value)
})
const trackHeight = computed(() => {
  // 垂直滚动条高度
  if (isYScroll.value) {
    if (containerHeight.value && contentHeight.value && railHeight.value) {
      const value = Math.min(
        containerHeight.value,
        (railHeight.value * containerHeight.value) / contentHeight.value + 1.5 * props.size
      )
      return Number(value.toFixed(4))
    }
  }
  return 0
})
const trackTop = computed(() => {
  // 滚动条垂直偏移
  if (containerHeight.value && contentHeight.value && railHeight.value) {
    return (
      (containerScrollTop.value / (contentHeight.value - containerHeight.value)) *
      (railHeight.value - trackHeight.value)
    )
  }
  return 0
})
const trackWidth = computed(() => {
  // 横向滚动条宽度
  if (props.horizontal && isXScroll.value) {
    if (containerWidth.value && contentWidth.value && railWidth.value) {
      const value = (railWidth.value * containerWidth.value) / contentWidth.value + 1.5 * props.size
      return Number(value.toFixed(4))
    }
  }
  return 0
})
const trackLeft = computed(() => {
  // 滚动条水平偏移
  if (containerWidth.value && contentWidth.value && railWidth.value) {
    return (
      (containerScrollLeft.value / (contentWidth.value - containerWidth.value)) * (railWidth.value - trackWidth.value)
    )
  }
  return 0
})
useEventListener(window, 'resize', updateState)
const options = { childList: true, attributes: true, subtree: true }
useMutationObserver(scrollbarRef, updateState, options)
const debounceHideEvent = debounce(hideScrollbar, props.delay)
onMounted(() => {
  updateState()
})
function hideScrollbar() {
  if (!trackHover.value) {
    showTrack.value = false
  }
}
function updateScrollState() {
  containerScrollTop.value = containerRef.value.scrollTop
  containerScrollLeft.value = containerRef.value.scrollLeft
}
function updateScrollbarState() {
  containerScrollHeight.value = containerRef.value.scrollHeight
  containerScrollWidth.value = containerRef.value.scrollWidth
  containerClientHeight.value = containerRef.value.clientHeight
  containerClientWidth.value = containerRef.value.clientWidth
  containerHeight.value = containerRef.value.offsetHeight
  containerWidth.value = containerRef.value.offsetWidth
  contentHeight.value = contentRef.value.offsetHeight
  contentWidth.value = contentRef.value.offsetWidth
  railHeight.value = railVerticalRef.value.offsetHeight
  railWidth.value = railHorizontalRef.value.offsetWidth
}
function updateState() {
  updateScrollState()
  updateScrollbarState()
}
function onScroll(e: Event) {
  if (autoShowTrack.value) {
    showTrack.value = true
    if (!trackXPressed.value && !trackYPressed.value) {
      debounceHideEvent()
    }
  }
  emit('scroll', e)
  updateScrollState()
}
function onMouseEnter() {
  if (trackXPressed.value || trackYPressed.value) {
    mouseLeave.value = false
  } else {
    if (!autoShowTrack.value) {
      showTrack.value = true
    }
  }
}
function onMouseLeave() {
  if (trackXPressed.value || trackYPressed.value) {
    mouseLeave.value = true
  } else {
    if (!autoShowTrack.value) {
      showTrack.value = false
    }
  }
}
function onEnterTrack() {
  trackHover.value = true
}
function onLeaveTrack() {
  if (trackXPressed.value || trackYPressed.value) {
    trackLeave.value = true
  } else {
    trackHover.value = false
    debounceHideEvent()
  }
}
function onTrackVerticalMouseDown(e: MouseEvent) {
  trackYPressed.value = true
  memoYTop.value = containerScrollTop.value
  memoMouseY.value = e.clientY
  window.onmousemove = (e: MouseEvent) => {
    const diffY = e.clientY - memoMouseY.value
    const dScrollTop =
      (diffY * (contentHeight.value - containerHeight.value)) / (containerHeight.value - trackHeight.value)
    const toScrollTopUpperBound = contentHeight.value - containerHeight.value
    let toScrollTop = memoYTop.value + dScrollTop
    toScrollTop = Math.min(toScrollTopUpperBound, toScrollTop)
    toScrollTop = Math.max(toScrollTop, 0)
    containerRef.value.scrollTop = toScrollTop
  }
  window.onmouseup = () => {
    window.onmousemove = null
    trackYPressed.value = false
    if (props.trigger === 'hover' && mouseLeave.value) {
      showTrack.value = false
      mouseLeave.value = false
    }
    if (autoShowTrack.value && trackLeave.value) {
      trackLeave.value = false
      trackHover.value = false
      debounceHideEvent()
    }
  }
}
function onTrackHorizontalMouseDown(e: MouseEvent) {
  trackXPressed.value = true
  memoXLeft.value = containerScrollLeft.value
  memoMouseX.value = e.clientX
  window.onmousemove = (e: MouseEvent) => {
    const diffX = e.clientX - memoMouseX.value
    const dScrollLeft =
      (diffX * (contentWidth.value - containerWidth.value)) / (containerWidth.value - trackWidth.value)
    const toScrollLeftUpperBound = contentWidth.value - containerWidth.value
    let toScrollLeft = memoXLeft.value + dScrollLeft
    toScrollLeft = Math.min(toScrollLeftUpperBound, toScrollLeft)
    toScrollLeft = Math.max(toScrollLeft, 0)
    containerRef.value.scrollLeft = toScrollLeft
  }
  window.onmouseup = () => {
    window.onmousemove = null
    trackXPressed.value = false
    if (props.trigger === 'hover' && mouseLeave.value) {
      showTrack.value = false
      mouseLeave.value = false
    }
    if (autoShowTrack.value && trackLeave.value) {
      trackLeave.value = false
      trackHover.value = false
      debounceHideEvent()
    }
  }
}
function scrollTo(...args: any[]) {
  containerRef.value?.scrollTo(...args)
}
function scrollBy(...args: any[]) {
  containerRef.value?.scrollBy(...args)
}

defineExpose({
  scrollTo,
  scrollBy
})
</script>
<template>
  <div
    ref="scrollbarRef"
    class="m-scrollbar"
    :style="`--scrollbar-size: ${size}px;`"
    @mouseenter="isScroll && trigger === 'hover' ? onMouseEnter() : () => false"
    @mouseleave="isScroll && trigger === 'hover' ? onMouseLeave() : () => false"
  >
    <div ref="containerRef" class="scrollbar-container" @scroll="onScroll">
      <div
        ref="contentRef"
        class="scrollbar-content"
        :class="contentClass"
        :style="[horizontal ? { ...horizontalContentStyle, ...contentStyle } : contentStyle]"
      >
        <slot></slot>
      </div>
    </div>
    <div ref="railVerticalRef" class="scrollbar-rail rail-vertical">
      <div
        class="scrollbar-track"
        :class="{ 'track-visible': trigger === 'none' || showTrack }"
        :style="`top: ${trackTop}px; height: ${trackHeight}px;`"
        @mouseenter="autoShowTrack ? onEnterTrack() : () => false"
        @mouseleave="autoShowTrack ? onLeaveTrack() : () => false"
        @mousedown.prevent.stop="onTrackVerticalMouseDown"
      ></div>
    </div>
    <div ref="railHorizontalRef" v-show="horizontal" class="scrollbar-rail rail-horizontal">
      <div
        class="scrollbar-track"
        :class="{ 'track-visible': trigger === 'none' || showTrack }"
        :style="`left: ${trackLeft}px; width: ${trackWidth}px;`"
        @mouseenter="autoShowTrack ? onEnterTrack() : () => false"
        @mouseleave="autoShowTrack ? onLeaveTrack() : () => false"
        @mousedown.prevent.stop="onTrackHorizontalMouseDown"
      ></div>
    </div>
  </div>
</template>
<style lang="less" scoped>
.m-scrollbar {
  overflow: hidden;
  position: relative;
  z-index: auto;
  height: 100%;
  width: 100%;
  .scrollbar-container {
    width: 100%;
    overflow: scroll;
    height: 100%;
    min-height: inherit;
    max-height: inherit;
    scrollbar-width: none;
    &::-webkit-scrollbar,
    &::-webkit-scrollbar-track-piece,
    &::-webkit-scrollbar-thumb {
      width: 0;
      height: 0;
      display: none;
    }
    .scrollbar-content {
      box-sizing: border-box;
      min-width: 100%;
    }
  }
  .scrollbar-rail {
    position: absolute;
    pointer-events: none;
    user-select: none;
    background: transparent;
    -webkit-user-select: none;
    .scrollbar-track {
      z-index: 1;
      position: absolute;
      cursor: pointer;
      opacity: 0;
      pointer-events: none;
      background-color: rgba(0, 0, 0, 0.25);
      transition:
        background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      &:hover {
        background-color: rgba(0, 0, 0, 0.4);
      }
    }
    .track-visible {
      opacity: 1;
      pointer-events: all;
    }
  }
  .rail-vertical {
    inset: 2px 4px 2px auto;
    width: var(--scrollbar-size);
    .scrollbar-track {
      width: var(--scrollbar-size);
      border-radius: var(--scrollbar-size);
      bottom: 0;
    }
  }
  .rail-horizontal {
    inset: auto 2px 4px 2px;
    height: var(--scrollbar-size);
    .scrollbar-track {
      height: var(--scrollbar-size);
      border-radius: var(--scrollbar-size);
      right: 0;
    }
  }
}
</style>

在要使用的页面引入

<script setup lang="ts">
import Scrollbar from './Scrollbar.vue'
function onScroll(e: Event) {
  console.log('scroll:', e)
}
</script>
<template>
  <div>
    <h1>{
  { $route.name }} {
  { $route.meta.title }}</h1>
    <h2 class="mt30 mb10">基本使用</h2>
    <Scrollbar style="max-height: 120px" @scroll="onScroll">
      我们在田野上面找猪<br />
      想象中已找到了三只<br />
      小鸟在白云上面追逐<br />
      它们在树底下跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      想象中我们是如此的疯狂<br />
      我们在城市里面找猪<br />
      想象中已找到了几百万只<br />
      小鸟在公园里面唱歌<br />
      它们独自在想象里跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      许多年之后我们又开始想象<br />
      啦啦啦啦啦啦啦啦咧
    </Scrollbar>
    <h2 class="mt30 mb10">横向滚动</h2>
    <Scrollbar horizontal>
      <div style="white-space: nowrap; padding: 12px">
        我们在田野上面找猪 想象中已找到了三只 小鸟在白云上面追逐 它们在树底下跳舞 啦啦啦啦啦啦啦啦咧 啦啦啦啦咧
        我们在想象中度过了许多年 想象中我们是如此的疯狂 我们在城市里面找猪 想象中已找到了几百万只 小鸟在公园里面唱歌
        它们独自在想象里跳舞 啦啦啦啦啦啦啦啦咧 啦啦啦啦咧 我们在想象中度过了许多年 许多年之后我们又开始想象
        啦啦啦啦啦啦啦啦咧
      </div>
    </Scrollbar>
    <h2 class="mt30 mb10">hover 时不自动隐藏</h2>
    <Scrollbar style="max-height: 120px" :auto-hide="false">
      我们在田野上面找猪<br />
      想象中已找到了三只<br />
      小鸟在白云上面追逐<br />
      它们在树底下跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      想象中我们是如此的疯狂<br />
      我们在城市里面找猪<br />
      想象中已找到了几百万只<br />
      小鸟在公园里面唱歌<br />
      它们独自在想象里跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      许多年之后我们又开始想象<br />
      啦啦啦啦啦啦啦啦咧
    </Scrollbar>
    <h2 class="mt30 mb10">触发方式</h2>
    <Scrollbar style="max-height: 130px" trigger="none">
      我们在田野上面找猪<br />
      想象中已找到了三只<br />
      小鸟在白云上面追逐<br />
      它们在树底下跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      想象中我们是如此的疯狂<br />
      我们在城市里面找猪<br />
      想象中已找到了几百万只<br />
      小鸟在公园里面唱歌<br />
      它们独自在想象里跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      许多年之后我们又开始想象<br />
      啦啦啦啦啦啦啦啦咧
    </Scrollbar>
    <h2 class="mt30 mb10">自定义内容样式</h2>
    <Scrollbar
      style="max-height: 120px; border-radius: 12px;"
      :content-style="{ backgroundColor: '#e6f4ff', padding: '16px 24px', fontSize: '16px' }"
    >
      我们在田野上面找猪<br />
      想象中已找到了三只<br />
      小鸟在白云上面追逐<br />
      它们在树底下跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      想象中我们是如此的疯狂<br />
      我们在城市里面找猪<br />
      想象中已找到了几百万只<br />
      小鸟在公园里面唱歌<br />
      它们独自在想象里跳舞<br />
      啦啦啦啦啦啦啦啦咧<br />
      啦啦啦啦咧<br />
      我们在想象中度过了许多年<br />
      许多年之后我们又开始想象<br />
      啦啦啦啦啦啦啦啦咧
    </Scrollbar>
  </div>
</template>
相关文章
|
30天前
|
JavaScript 前端开发 安全
Vue 3
Vue 3以组合式API、Proxy响应式系统和全面TypeScript支持,重构前端开发范式。性能优化与生态协同并进,兼顾易用性与工程化,引领Web开发迈向高效、可维护的新纪元。(238字)
469 139
|
25天前
|
缓存 JavaScript 算法
Vue 3性能优化
Vue 3 通过 Proxy 和编译优化提升性能,但仍需遵循最佳实践。合理使用 v-if、key、computed,避免深度监听,利用懒加载与虚拟列表,结合打包优化,方可充分发挥其性能优势。(239字)
183 1
|
6月前
|
缓存 JavaScript PHP
斩获开发者口碑!SnowAdmin:基于 Vue3 的高颜值后台管理系统,3 步极速上手!
SnowAdmin 是一款基于 Vue3/TypeScript/Arco Design 的开源后台管理框架,以“清新优雅、开箱即用”为核心设计理念。提供角色权限精细化管理、多主题与暗黑模式切换、动态路由与页面缓存等功能,支持代码规范自动化校验及丰富组件库。通过模块化设计与前沿技术栈(Vite5/Pinia),显著提升开发效率,适合团队协作与长期维护。项目地址:[GitHub](https://github.com/WANG-Fan0912/SnowAdmin)。
892 5
|
2月前
|
开发工具 iOS开发 MacOS
基于Vite7.1+Vue3+Pinia3+ArcoDesign网页版webos后台模板
最新版研发vite7+vue3.5+pinia3+arco-design仿macos/windows风格网页版OS系统Vite-Vue3-WebOS。
337 11
|
1月前
|
JavaScript 安全
vue3使用ts传参教程
Vue 3结合TypeScript实现组件传参,提升类型安全与开发效率。涵盖Props、Emits、v-model双向绑定及useAttrs透传属性,建议明确声明类型,保障代码质量。
224 0
|
3月前
|
缓存 前端开发 大数据
虚拟列表在Vue3中的具体应用场景有哪些?
虚拟列表在 Vue3 中通过仅渲染可视区域内容,显著提升大数据列表性能,适用于 ERP 表格、聊天界面、社交媒体、阅读器、日历及树形结构等场景,结合 `vue-virtual-scroller` 等工具可实现高效滚动与交互体验。
406 1
|
3月前
|
缓存 JavaScript UED
除了循环引用,Vue3还有哪些常见的性能优化技巧?
除了循环引用,Vue3还有哪些常见的性能优化技巧?
226 0
|
4月前
|
JavaScript
vue3循环引用自已实现
当渲染大量数据列表时,使用虚拟列表只渲染可视区域的内容,显著减少 DOM 节点数量。
130 0
|
6月前
|
JavaScript API 容器
Vue 3 中的 nextTick 使用详解与实战案例
Vue 3 中的 nextTick 使用详解与实战案例 在 Vue 3 的日常开发中,我们经常需要在数据变化后等待 DOM 更新完成再执行某些操作。此时,nextTick 就成了一个不可或缺的工具。本文将介绍 nextTick 的基本用法,并通过三个实战案例,展示它在表单验证、弹窗动画、自动聚焦等场景中的实际应用。
557 17
|
7月前
|
JavaScript 前端开发 算法
Vue 3 和 Vue 2 的区别及优点
Vue 3 和 Vue 2 的区别及优点