[特殊字符] Vue3 高性能循环滚动 Hook(支持平滑模式与逐项滚动模式)
·
在做一些信息展示类的项目时(比如新闻公告滚动、弹幕展示、排行榜、列表轮播等),我们经常会遇到循环滚动的需求。
网上常见的方案要么依赖第三方库、要么性能不理想、要么在切换模式时卡顿。
本文带来一个纯 Vue3 + 原生 JS 实现的高性能 Hook —— useScroll,支持两种滚动模式:
-
🧩 逐项滚动模式:每项滚动后停顿几秒再滚动下一项;
-
🌊 平滑滚动模式:以固定速度连续无缝滚动。
✨ 功能特性
✅ 支持水平(x)与垂直(y)方向滚动
✅ 支持平滑模式与逐项滚动模式
✅ 无缝循环(克隆首项到末尾)
✅ 使用 requestAnimationFrame 驱动动画
✅ 使用 will-change 提示浏览器优化
✅ 缓存 DOM 尺寸,减少重排、提升性能
✅ 自动清理资源,避免内存泄漏
📦 Hook 代码实现
以下是完整的实现源码,可直接拷贝使用👇
import type { Ref } from 'vue'
import { nextTick, onUnmounted, ref } from 'vue'
interface UseScrollOptions {
pauseDuration?: number // 每项停留时间(毫秒)
scrollDuration?: number // 滚动动画时长(毫秒)
direction?: 'x' | 'y' // 滚动方向
speed?: number // 滚动速度(像素/秒),设置后将忽略pauseDuration
smooth?: boolean // 是否启用平滑连续滚动
}
interface UseScrollReturn {
content: Ref<HTMLElement | null>
start: () => void
stop: () => void
reset: () => void
}
/**
* 循环滚动 Hook (性能优化版)
* 支持两种模式:
* 1. 逐项滚动模式(默认): 滚动一项->暂停->滚动下一项
* 2. 平滑连续滚动模式(smooth:true): 匀速连续滚动
*/
export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
const {
pauseDuration = 3000,
scrollDuration = 500,
direction = 'y',
speed = 50,
smooth = false,
} = options
const content: Ref<HTMLElement | null> = ref(null)
let currentIndex = 0
let animationFrameId: number | null = null
let isScrolling = false
let lastTime = 0
let currentOffset = 0
let clonedElement: HTMLElement | null = null
// 缓存 DOM 尺寸
let cachedItemSize = 0
let cachedTotalSize = 0
/** 逐项滚动模式 */
const scrollByItem = () => {
const container = content.value
if (!container || container.children.length === 0) {
animationFrameId = requestAnimationFrame(scrollByItem)
return
}
const itemsLength = container.children.length - 1
if (!isScrolling) {
const now = Date.now()
if (now - lastTime >= pauseDuration) {
isScrolling = true
currentIndex += 1
// 缓存尺寸,减少重排
if (cachedItemSize === 0) {
const firstChild = container.children[0] as HTMLElement
cachedItemSize = direction === 'y' ? firstChild.offsetHeight : firstChild.offsetWidth
}
const translateValue = -cachedItemSize * currentIndex
const transformProp = direction === 'y' ? `translateY(${translateValue}px)` : `translateX(${translateValue}px)`
if (currentIndex > itemsLength) {
// 到达克隆项 -> 重置
container.style.transition = `transform ${scrollDuration}ms ease`
container.style.transform = transformProp
const handleTransitionEnd = () => {
currentIndex = 0
container.style.transition = 'none'
container.style.transform = 'translate(0)'
void container.offsetHeight // 强制重绘
isScrolling = false
lastTime = Date.now()
}
container.addEventListener('transitionend', handleTransitionEnd, { once: true })
} else {
container.style.transition = `transform ${scrollDuration}ms ease`
container.style.transform = transformProp
const handleTransitionEnd = () => {
isScrolling = false
lastTime = Date.now()
}
container.addEventListener('transitionend', handleTransitionEnd, { once: true })
}
}
}
animationFrameId = requestAnimationFrame(scrollByItem)
}
/** 平滑连续滚动模式 */
const scrollSmooth = (timestamp: number) => {
const container = content.value
if (!container || container.children.length === 0) {
animationFrameId = requestAnimationFrame(scrollSmooth)
return
}
if (lastTime === 0) lastTime = timestamp
const deltaTime = timestamp - lastTime
lastTime = timestamp
if (cachedTotalSize === 0) {
const firstChild = container.children[0] as HTMLElement
cachedItemSize = direction === 'y' ? firstChild.offsetHeight : firstChild.offsetWidth
cachedTotalSize = cachedItemSize * (container.children.length - 1)
}
currentOffset += (speed * deltaTime) / 1000
if (currentOffset >= cachedTotalSize) {
currentOffset %= cachedTotalSize
}
container.style.transition = 'none'
container.style.transform = direction === 'y'
? `translateY(-${currentOffset}px)`
: `translateX(-${currentOffset}px)`
animationFrameId = requestAnimationFrame(scrollSmooth)
}
/** 启动滚动 */
const start = async () => {
await nextTick()
const container = content.value
if (!container || animationFrameId !== null) return
if (clonedElement && container.contains(clonedElement)) {
container.removeChild(clonedElement)
}
clonedElement = container.children[0].cloneNode(true) as HTMLElement
container.appendChild(clonedElement)
currentIndex = 0
currentOffset = 0
lastTime = smooth ? 0 : Date.now()
isScrolling = false
cachedItemSize = 0
cachedTotalSize = 0
container.style.transform = 'translate(0)'
container.style.willChange = 'transform'
animationFrameId = requestAnimationFrame(smooth ? scrollSmooth : scrollByItem)
}
/** 停止滚动 */
const stop = () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (content.value) content.value.style.willChange = 'auto'
}
/** 重置滚动 */
const reset = () => {
stop()
if (content.value) {
const el = content.value
el.style.transition = 'none'
el.style.transform = 'translate(0)'
el.style.willChange = 'auto'
if (clonedElement && el.contains(clonedElement)) el.removeChild(clonedElement)
}
currentIndex = 0
currentOffset = 0
lastTime = 0
cachedItemSize = 0
cachedTotalSize = 0
}
onUnmounted(reset)
return { content, start, stop, reset }
}
🧠 使用示例
<template>
<div ref="scrollRef" class="scroll-container">
<div v-for="(item, index) in list" :key="index" class="scroll-item">
{{ item }}
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, onUnmounted } from 'vue'
import { useScroll } from '@/hooks/useScroll'
const list = ['公告1', '公告2', '公告3', '公告4']
const { content: scrollRef, start, stop } = useScroll({
pauseDuration: 2000,
scrollDuration: 600,
direction: 'y',
smooth: false,
})
onMounted(start)
onUnmounted(stop)
</script>
<style scoped>
.scroll-container {
overflow: hidden;
height: 40px;
}
.scroll-item {
height: 40px;
line-height: 40px;
text-align: center;
}
</style>
⚙️ 性能优化点解读
| 优化点 | 说明 |
|---|---|
requestAnimationFrame | 替代 setInterval,由浏览器统一调度,动画更流畅 |
will-change: transform | 提前告诉浏览器元素将变换,优化 GPU 渲染 |
| DOM 尺寸缓存 | 避免频繁读取 offsetHeight 导致回流 |
transitionend 事件 | 精确监听动画结束,避免 setTimeout 时间不准 |
| 自动清理 | 在组件卸载时停止动画、移除克隆元素,防止内存泄漏 |
🎬 小结
这个 Hook 非常轻量,几乎零依赖,适合在各种场景中直接使用。
通过 smooth 参数即可切换两种模式,灵活又高效。
如果你的项目中也有「公告滚动、排行榜滚动、弹幕循环」等需求,不妨试试这个高性能的实现方式 🚀
更多推荐


所有评论(0)