Vue3怎么做左右上下通用的无缝滚动?原生方法+插件组合适配各种场景行不行?
最近翻技术群,很多刚上手Vue3的同学都在问:“项目要做一个卡片横向轮播、公告纵向滚动的组合栏,用Vue3怎么实现通用化的无缝效果?”还有人纠结是自己手写原生逻辑练手,还是直接找成熟插件省时间,其实这俩选择完全不冲突——通用逻辑可以自己搭个基础钩子,复杂场景再套插件补全细节,今天就从新手友好的核心原理说起,再一步步写通用的原生useSeamlessScroll钩子,最后说说什么时候加插件、加哪些插件更靠谱,顺便踩踩我之前踩过的坑。
无缝滚动的核心原理是什么?别被“无缝”唬住
不管是左右还是上下,不管是匀速、间歇、还是触底加载的滚动,无缝的本质都是“视觉欺骗”——这是很多技术文档和视频里都会提到的,但具体是怎么骗眼睛的?举个最直观的例子:你有5张横向排列的卡片要做轮播,每张宽200px,那你要做的不是直接把这5张排满,而是在第一张前面复制最后一张,最后一张后面复制第一张,凑成7张,宽度变成1400px。
接下来逻辑就顺了:初始状态显示中间的“真5张”;当滚动到最后一张真卡片(也就是原来的第5张,现在的第6张)的时候,立马瞬间把位置拉回真5张的开头(也就是原来的第1张,现在的第2张);如果是倒着滚到第一张真卡片(现在的第1张),就瞬间拉回真5张的结尾(现在的第6张),因为这两个瞬间切换是发生在“视觉复制区”的,没有内容变化,所以用户完全看不出破绽——就像无限循环一样。
纵向滚动同理:复制头部到尾部,复制尾部到头部,滚动到复制区就瞬间归位,这个原理不管是原生实现还是插件,底层都是通用的,搞懂这个,不管遇到什么花里胡哨的无缝需求,心里都有底了。
手写Vue3通用useSeamlessScroll钩子要注意什么?新手避坑指南
很多新手直接百度找Vue3的无缝代码,复制过来要么方向不能换,要么间歇滚动卡顿,要么响应式失效——这些问题我之前全踩过,今天写的这个钩子,支持左右/上下方向切换、匀速/间歇滚动、鼠标悬停暂停、触摸滑动、响应式容器尺寸更新,完全能覆盖80%的基础场景,练手也很合适。
先理清楚钩子的输入和输出
写组合式API的第一步,肯定是明确“我要给钩子传什么参数”和“钩子要给我返回什么东西”。 输入参数(props):
direction:滚动方向,默认‘horizontal’(左右),可选‘vertical’(上下)speed:滚动速度,数字,默认50(单位可以理解为px/秒,数值越大越快)isInterval:是否启用间歇滚动,布尔值,默认falseintervalDuration:间歇停留时间,毫秒,默认2000isMouseEnterPause:是否鼠标悬停暂停,布尔值,默认trueisTouchMove:是否支持触摸滑动,布尔值,默认truelist:要滚动的数组,必填,用来判断是否有必要复制(比如数组长度小于等于1,就不需要滚动了) return):seamlessList:处理好的(添加了复制项)数组containerRef:外层容器的ref引用wrapperRef:内层滚动容器的ref引用containerStyle:外层容器的动态样式(主要是overflow: hidden、高度/宽度自适应?不对,外层容器最好让用户自己定宽高,这里只给必要的overflow和position)wrapperStyle:内层滚动容器的动态样式(主要是display、宽度/高度,因为横向滚动的话wrapper宽度要是所有卡片的总和,纵向同理,还有transform用来控制滚动位置)
核心逻辑分步写,每一步都避坑
处理复制数组:什么时候该复制?复制多少?
首先判断:如果list.length <= 1,直接返回原数组,不需要做任何滚动。 否则,复制逻辑是:头尾各复制一份——1,2,3]变成[3,1,2,3,1]?不对不对,刚才举的例子是头尾各复制1份,但新手很容易在这里出错,以为只复制一份就够了,但如果是多列显示或者单张卡片没有占满容器的情况,复制一份可能不够,哦对,通用钩子要考虑这个!
等下,换个更严谨的复制判断标准:我们可以先获取容器的可视尺寸(横向是clientWidth,纵向是clientHeight)和单个滚动项的总尺寸(横向是offsetWidth + margin,纵向是offsetHeight + margin,不过这里最好不要直接获取单个项的样式,因为可能有padding、flex-gap?不对,flex-gap会导致wrapper的总尺寸不好算,所以我建议外层设置container,内层设置wrapper用flex布局,给每个item加上间距类,然后wrapper的样式用flex-shrink: 0、white-space: nowrap(横向)或者flex-direction: column(纵向),这样获取单个item的offsetWidth/offsetHeight再加上itemCount乘以间隙?或者更简单的——*先挂载一次,获取wrapper的原始总尺寸,然后再判断需要复制多少份,直到复制后的wrapper尺寸 >= 可视尺寸 2 + 单个滚动项尺寸**,对,这个逻辑更通用,不管是单张、多列、有间隙还是没间隙,都能保证视觉欺骗不穿帮。
不过新手可以先简化,只针对单张滚动(每个滚动项占满可视尺寸)或者多张但单个滚动项尺寸已知的情况,先练核心的滚动和归位逻辑,等熟练了再补上动态判断复制数量的代码,这里为了演示通用,简化成:单方向、头尾各复制1份的情况,间隙的话让用户用flex-gap在item类里设置,并且要求每个item的尺寸一致。
处理动态样式:外层和内层分别怎么设置?
外层容器containerStyle:
const containerStyle = computed(() => ({
overflow: 'hidden',
position: 'relative',
width: props.width || '100%', // 可以让用户传固定宽高,也可以自适应
height: props.height || 'auto'
}))
这里width默认100%没问题,但纵向滚动的话height最好让用户传固定值,否则自适应的话可能会撑开页面,达不到滚动效果。
内层滚动容器wrapperStyle: 这个是核心,要设置display、方向相关的布局、transform,还要有过渡效果吗?不对,匀速滚动的时候不能加transition,否则归位的时候会有动画,视觉就穿帮了;只有间歇滚动的时候,滚动到目标位置可以加transition,归位的时候去掉transition——这个细节很重要,很多新手复制的代码卡顿就是因为这个!
const wrapperStyle = computed(() => {
const isHorizontal = props.direction === 'horizontal'
const size = isHorizontal ? itemWidth.value * seamlessList.value.length : itemHeight.value * seamlessList.value.length
const translate = isHorizontal ? `translateX(-${scrollOffset.value}px)` : `translateY(-${scrollOffset.value}px)`
return {
display: 'flex',
flexDirection: isHorizontal ? 'row' : 'column',
width: isHorizontal ? `${size}px` : '100%',
height: isHorizontal ? '100%' : `${size}px`,
transform: translate,
// 只有间歇滚动且不是归位状态才加过渡
transition: isIntervalScrolling.value ? (isResetting.value ? 'none' : `transform ${props.intervalScrollSpeed || 0.3}s ease-in-out`) : 'none'
}
})
这里面的itemWidth、itemHeight、scrollOffset、isIntervalScrolling、isResetting都是响应式变量,后面会定义。
滚动和归位逻辑:requestAnimationFrame比setTimeout更流畅
很多新手用setInterval来做匀速滚动,但setInterval的精度很低,容易受浏览器任务队列的影响,导致滚动卡顿。requestAnimationFrame(RAF)是专门用来做动画的API,它会在浏览器每次重绘之前执行回调,精度更高,而且当页面隐藏的时候会自动暂停,节省性能——这个是性能优化的关键点!
首先定义响应式变量:
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
// 假设用户传的props已经解构好了
const { direction, speed, isInterval, intervalDuration, isMouseEnterPause, isTouchMove, list, itemWidth: propsItemWidth, itemHeight: propsItemHeight } = defineProps({
// 这里补全props的类型定义,用TypeScript更规范,不用的话也没关系
})
const containerRef = ref(null)
const wrapperRef = ref(null)
const seamlessList = ref([])
const scrollOffset = ref(0) // 当前滚动的偏移量
const rafId = ref(null) // RAF的ID,用来取消动画
const isMouseEnter = ref(false) // 鼠标是否悬停
const isResetting = ref(false) // 是否在归位状态
const isIntervalScrolling = ref(isInterval) // 间歇滚动的状态
const currentIndex = ref(0) // 间歇滚动当前的索引
const itemWidth = ref(propsItemWidth || 0) // 单个滚动项的宽度
const itemHeight = ref(propsItemHeight || 0) // 单个滚动项的高度
const lastTime = ref(0) // 上次RAF执行的时间,用来计算时间差,保证速度一致
然后处理复制数组:
const handleSeamlessList = () => {
if (list.length <= 1) {
seamlessList.value = list
return
}
// 头尾各复制1份
seamlessList.value = [...list.slice(-1), ...list, ...list.slice(0, 1)]
}
watch(() => list, handleSeamlessList, { immediate: true, deep: true })
接下来获取单个滚动项和容器的尺寸:
const handleResize = () => {
if (!containerRef.value || !wrapperRef.value || seamlessList.value.length <= 1) return
const container = containerRef.value
const wrapper = wrapperRef.value
const firstItem = wrapper.children[1] // 取真数组的第一个元素(因为复制了一个在最前面)
itemWidth.value = propsItemWidth || firstItem.offsetWidth
itemHeight.value = propsItemHeight || firstItem.offsetHeight
// 如果是横向滚动,初始偏移量是1个itemWidth,纵向同理
const isHorizontal = direction === 'horizontal'
scrollOffset.value = isHorizontal ? itemWidth.value : itemHeight.value
// 重置currentIndex
currentIndex.value = 0
}
onMounted(() => {
handleResize()
window.addEventListener('resize', handleResize)
// 这里开始滚动
startScroll()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopScroll()
})
然后是匀速滚动的核心逻辑:
const startScroll = () => {
if (seamlessList.value.length <= 1 || (isMouseEnterPause && isMouseEnter.value)) return
lastTime.value = performance.now()
const animate = (currentTime) => {
// 计算时间差,保证速度一致,不管浏览器刷新频率是60Hz还是120Hz
const deltaTime = currentTime - lastTime.value
lastTime.value = currentTime
// 速度是px/秒,所以每秒移动speed px,deltaTime是毫秒,要除以1000
const distance = (speed / 1000) * deltaTime
scrollOffset.value += distance
// 判断是否需要归位
const isHorizontal = direction === 'horizontal'
const singleSize = isHorizontal ? itemWidth.value : itemHeight.value
const totalTrueSize = singleSize * list.length
// 正向滚动归位:scrollOffset >= 单个复制项 + 所有真项的尺寸
if (scrollOffset.value >= singleSize + totalTrueSize) {
scrollOffset.value -= totalTrueSize
}
// 反向滚动?暂时先做正向的,反向的可以后面加
rafId.value = requestAnimationFrame(animate)
}
rafId.value = requestAnimationFrame(animate)
}
const stopScroll = () => {
if (rafId.value) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
}
补全细节:鼠标悬停、触摸滑动、间歇滚动
鼠标悬停很简单,给外层容器加@mouseenter和@mouseleave事件:
const handleMouseEnter = () => {
if (!isMouseEnterPause) return
isMouseEnter.value = true
stopScroll()
}
const handleMouseLeave = () => {
if (!isMouseEnterPause) return
isMouseEnter.value = false
startScroll()
}
触摸滑动稍微复杂一点,要记录touchStart的位置、touchMove的位置、touchEnd的位置,判断是左滑/右滑/上滑/下滑,然后调整scrollOffset,还要处理滑动后的归位,不过新手可以先跳过触摸滑动,等把前面的逻辑写熟练了再补。
间歇滚动的逻辑:可以用setInterval来定时切换currentIndex,然后根据currentIndex计算scrollOffset,滚动到目标位置后停留intervalDuration毫秒,然后继续切换,切换到最后一个真索引的时候,触发归位逻辑。
这样一套下来,一个基础的、通用的Vue3无缝滚动钩子就写好了,练手的话可以再加上反向滚动、自动播放、点击切换按钮这些功能。
什么时候用原生钩子?什么时候加插件?
很多同学觉得“手写原生代码更厉害,用插件就是菜”——其实完全不是这样,技术选型的核心是“性价比”:如果你的需求很简单(比如公告栏纵向匀速滚动、3张卡片横向轮播),手写原生钩子完全没问题,还能练手;但如果你的需求很复杂(比如多列瀑布流无缝滚动、卡片无限加载+无缝切换、带动画特效的3D无缝滚动),手写原生代码不仅耗时间,还容易出bug,这时候用成熟的插件更香。
那Vue3有哪些靠谱的无缝滚动插件?
- vue3-seamless-scroll:这个插件的GitHub星数很高,维护得也比较频繁,支持左右/上下/斜向滚动、鼠标悬停暂停、触摸滑动、响应式、单步滚动、多列滚动,覆盖了大部分复杂场景,API也很简单,新手容易上手。
- vue-seamless-scroll-next:这个是Vue2版本vue-seamless-scroll的Vue3升级版,API和Vue2版本几乎一致,如果你之前用过Vue2的那个插件,迁移过来很方便。
- swiper:这个插件不是专门做无缝滚动的,但它的无缝轮播功能非常强大,支持各种动画特效、触摸滑动、响应式、分页器、导航按钮,如果你需要做带特效的无缝轮播,用swiper是最好的选择。
手写原生钩子VS插件的实测对比
我之前在同一个项目里试过两种方案:一个是用刚才写的简化版原生钩子做公告栏,另一个是用vue3-seamless-scroll做产品卡片轮播。
- 性能对比:简化版原生钩子的性能略好一点,因为代码量更少,没有多余的功能;但复杂场景下,插件的性能优化可能比你自己写的更好,比如swiper用了虚拟滚动,处理大量数据的时候更流畅。
- 开发效率对比:简单场景下,原生钩子和插件的开发效率差不多;但复杂场景下,插件的开发效率至少是原生的3倍以上。
- 维护成本对比:原生钩子的维护成本更低,因为代码是你自己写的,你知道每一行的作用;但插件的维护成本取决于插件的更新频率,如果插件停止更新了,遇到问题你可能需要自己修改源码。
无缝滚动的最佳实践
- 第一步:搞懂无缝滚动的核心原理——视觉欺骗,头尾复制,滚动到复制区就瞬间归位。
- 第二步:评估需求的复杂度,如果是简单场景,手写原生useSeamlessScroll钩子练手;如果是复杂场景,直接用成熟的插件。
- 第三步:不管是手写还是用插件,都要注意性能优化——用requestAnimationFrame代替setInterval,页面隐藏的时候暂停动画,处理大量数据的时候用虚拟滚动。
- 第四步:补全细节——鼠标悬停暂停、触摸滑动、响应式、无障碍访问(比如添加aria-label)。
最后再提醒一句:不要过度设计,如果你的需求只是一个简单的公告栏,不要为了炫技去写一堆复杂的代码,用最简单的方案解决问题就好。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



