在做一些信息展示类的项目时(比如新闻公告滚动、弹幕展示、排行榜、列表轮播等),我们经常会遇到循环滚动的需求。
网上常见的方案要么依赖第三方库、要么性能不理想、要么在切换模式时卡顿。
本文带来一个纯 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 参数即可切换两种模式,灵活又高效。

如果你的项目中也有「公告滚动、排行榜滚动、弹幕循环」等需求,不妨试试这个高性能的实现方式 🚀

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐