开箱即用的 Vue3 无限平滑滚动组件

Vue3 无限平滑滚动组件

这是一款开箱即用的 Vue3 无限平滑滚动组件,支持四个方向(左、右、上、下)自由切换,具有良好的可配置性。组件默认开启鼠标悬停暂停和滚轮滚动交互,滚动过程自然平滑,可用于新闻轮播、公告栏、商品展示等场景。通过 slidesPerView 控制一次显示的条目数,itemGap 控制每项之间的间距,支持自动复制内容避免滚动断层。使用简单,插槽式内容传入,代码结构清晰,适合二次封装或项目直接使用。本文贴出完整实现代码,拷贝即可使用。

先看效果

(gif不清晰,大概看看是这么个意思)

话不多说,直接贴代码

xml 复制代码
<template>
  <div class="scroll-wrapper" ref="wrapper">
    <div class="scroll-content" ref="scrollWrapper">
      <slot />
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'

const props = defineProps({
  direction: {
    type: String,
    default: 'left',
    validator: (val) => ['left', 'right', 'up', 'down'].includes(val),
  },
  step: {
    type: Number,
    default: 1, // 每帧滚动多少像素
  },
  pauseOnHover: {
    type: Boolean,
    default: true,
  },
  scrollOnWheel: {
    type: Boolean,
    default: true, //监听鼠标滚动的事件
  },
  slidesPerView: {
    type: Number,
    default: 1, // 每次显示几条
  },
  itemGap: {
    type: Number,
    default: 2, // 每项之间的间距(单位 px)
  },
})

const wrapper = ref(null)
const scrollWrapper = ref(null)

let isHovering = false
let scrollAmount = 0
let animationFrameId = 0
const COPYNUM = 2

// copy一份内容,防止循环一半切换时视觉空白
const duplicateContent = () => {
  const originalChildren = Array.from(scrollWrapper.value.children)
  // 先清空可能存在的克隆元素
  const clones = scrollWrapper.value.querySelectorAll('.cloned')
  clones.forEach((clone) => clone.remove())

  // 复制内容并插入到前面
  originalChildren.forEach((child) => {
    const clone = child.cloneNode(true)
    clone.classList.add('cloned')
    scrollWrapper.value.appendChild(clone)
  })
}

// 边界情况处理,尤其在鼠标滚动的时候
const resetIfOutOfBounds = () => {
  const sw = scrollWrapper.value
  const horizontal = ['left', 'right'].includes(props.direction)
  const scrollSize = horizontal ? sw.scrollWidth : sw.scrollHeight
  const clientSize = horizontal ? sw.clientWidth : sw.clientHeight
  const halfLimit = scrollSize / COPYNUM
  const maxScroll = scrollSize - clientSize
  if (['left', 'up'].includes(props.direction)) {
    if (scrollAmount >= halfLimit || scrollAmount <= 0) {
      scrollAmount = 0
    }
  } else {
    const minScroll = maxScroll - halfLimit
    if (scrollAmount >= maxScroll || scrollAmount <= minScroll) {
      scrollAmount = maxScroll
    }
  }
}
const scroll = () => {
  if (!(props.pauseOnHover && isHovering)) {
    if (['left', 'up'].includes(props.direction)) {
      scrollAmount += props.step
    } else {
      scrollAmount -= props.step
    }
  }
  if (props.direction === 'left' || props.direction === 'right') {
    scrollWrapper.value.style.transform = `translateX(${-scrollAmount}px)`
  } else {
    scrollWrapper.value.style.transform = `translateY(${-scrollAmount}px)`
  }
  resetIfOutOfBounds()
  animationFrameId = requestAnimationFrame(scroll)
}

const onMouseEnter = () => (isHovering = true)
const onMouseLeave = () => (isHovering = false)

const onWheel = (e) => {
  if (!props.scrollOnWheel) return
  e.preventDefault()
  const delta = e.deltaY || e.deltaX
  scrollAmount += delta
}

const setItemSize = () => {
  const wrapperSize =
    props.direction === 'left' || props.direction === 'right'
      ? wrapper.value.clientWidth
      : wrapper.value.clientHeight
  const itemSize = (wrapperSize - props.itemGap * (props.slidesPerView - 1)) / props.slidesPerView

  const isVertical = ['up', 'down'].includes(props.direction)
  scrollWrapper.value.style.flexDirection = isVertical ? 'column' : 'row'
  scrollWrapper.value.style.gap = props.itemGap + 'px'

  const items = Array.from(scrollWrapper.value.children).filter(
    (el) => !el.classList.contains('cloned'),
  )
  Array.from(items).forEach((el) => {
    if (isVertical) {
      el.style.height = itemSize + 'px'
    } else {
      el.style.width = itemSize + 'px'
    }
  })
}
const setInitialScrollAmount = () => {
  if (props.direction === 'left') {
    scrollAmount = 0
  } else if (props.direction === 'up') {
    scrollAmount = 0
  } else if (props.direction === 'down') {
    scrollAmount = scrollWrapper.value.scrollHeight - scrollWrapper.value.clientHeight
  } else if (props.direction === 'right') {
    scrollAmount = scrollWrapper.value.scrollWidth - scrollWrapper.value.clientWidth
  }
}

onMounted(() => {
  nextTick(() => {
    setItemSize()
    duplicateContent()
    setInitialScrollAmount()
    wrapper.value.addEventListener('mouseenter', onMouseEnter)
    wrapper.value.addEventListener('mouseleave', onMouseLeave)
    if (props.scrollOnWheel) {
      wrapper.value.addEventListener('wheel', onWheel, { passive: false })
    }
    animationFrameId = requestAnimationFrame(scroll)
  })
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  wrapper.value.removeEventListener('mouseenter', onMouseEnter)
  wrapper.value.removeEventListener('mouseleave', onMouseLeave)
  if (props.scrollOnWheel) {
    wrapper.value.removeEventListener('wheel', onWheel)
  }
})
</script>

<style scoped>
.scroll-wrapper {
  overflow: hidden;
  width: 100%;
  height: 100%;
}

.scroll-content {
  display: flex;
  width: 100%;
  height: 100%;
}
.scroll-content > * {
  flex-shrink: 0;
}
</style>

使用

ini 复制代码
<SeamlessScroll direction="up" :slidesPerView="4">
    <div class="upload-item" v-for="(item, index) in uploadList" :key="index">
      <div class="left-wrap">
        <SvgIcon
          name="pdf"
          :color="statusColorMap[item.status]?.color || '#157EFB'"
          size="23px"
          class="pdf-icon"
        />
        <p class="title">{{ item.title }}</p>
      </div>
      <span>{{ item.size }}</span>
      <span>{{ item.time }}</span>
      <p class="status" :style="{ color: statusColorMap[item.status]?.color }">
        {{ item.statusText }}
      </p>
    </div>
 </SeamlessScroll>
相关推荐
加减法原则1 小时前
Vue3 组合式函数:让你的代码复用如丝般顺滑
前端·vue.js
yanlele1 小时前
我用爬虫抓取了 25 年 6 月掘金热门面试文章
前端·javascript·面试
lichenyang4531 小时前
React移动端开发项目优化
前端·react.js·前端框架
天若有情6731 小时前
React、Vue、Angular的性能优化与源码解析概述
vue.js·react.js·angular.js
你的人类朋友1 小时前
🍃Kubernetes(k8s)核心概念一览
前端·后端·自动化运维
web_Hsir1 小时前
vue3.2 前端动态分页算法
前端·算法
烛阴2 小时前
WebSocket实时通信入门到实践
前端·javascript
草巾冒小子2 小时前
vue3实战:.ts文件中的interface定义与抛出、其他文件的调用方式
前端·javascript·vue.js
DoraBigHead2 小时前
你写前端按钮,他们扛服务器压力:搞懂后端那些“黑话”!
前端·javascript·架构
eggcode3 小时前
Vue+Openlayers加载OSM、加载天地图
vue.js·openlayers·webgis