ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 3 实现无缝循环滚动公告栏:requestAnimationFrame 与 CSS 动画方案对比

Vue 3 实现无缝循环滚动公告栏:requestAnimationFrame 与 CSS 动画方案对比

文字公告横向循环滚动这个需求,几乎每个做后台系统或者门户页面的前端都躲不开。产品经理的原话通常是“就一行字,让它自己慢慢滚,鼠标放上去停一下”,听起来像五分钟的活,真动手写的时候才发现坑一个接一个:滚到末尾突然跳回开头、滚动速度忽快忽慢、窗口一缩放就错位、公告文字长短不一时容器宽度算不准。我在好几个管理后台和数据看板项目里都实现过这个组件,从最早用定时器改margin-left的土办法,到后来用requestAnimationFrame配合双份内容做无缝循环,中间踩过的坑足够写一篇完整的复盘。

这篇内容面向的是已经会用 Vue 做基础组件、但在这个具体场景上反复调试不顺畅的开发者。我会把“无缝循环”这件事拆开讲清楚:为什么简单的 CSS 动画在文字长度动态变化时会失效、双份内容拼接的临界点怎么算、requestAnimationFrame和 CSStransform各自适合什么情况、鼠标悬停暂停的正确实现姿势,以及那些只有真跑起来才会暴露的边界问题。所有代码基于 Vue 3 组合式 API 编写,Vue 2 的读者把ref和onMounted换成对应的选项式写法即可,逻辑完全一致。

1. 先想清楚“循环滚动”到底在循环什么

很多人一上来就写animation: scroll 10s linear infinite,写完发现文字滚完一遍之后画面会闪一下,或者干脆整段文字消失再从头出现。问题不在动画本身,而在于没有想明白循环的边界在哪里。

1.1 单份内容滚动的死结

假设容器宽度 600px,公告文字实际宽度 400px。文字比容器短,根本不需要滚动,直接居中显示就行。但如果公告文字宽度是 1200px,容器只能显示 600px,那么文字从右往左移动,当它完全移出左边界时,屏幕上就空了,此时必须瞬间把文字拉回初始位置。这个“瞬间拉回”就是肉眼可见的跳变。

有人会说,那我让动画从translateX(0)走到translateX(-100%),-100%是相对文字自身宽度算的,文字刚好完全移出,然后infinite让它重新开始。逻辑上没错,但视觉上就是一次硬跳,因为观众看到的是“文字走光了,然后新文字从右边冒出来”,中间有一段空白期。公告栏这种需要持续有信息展示的场景,空白期是不能接受的。

提示:判断要不要滚动,不能只看文字长度,还要考虑容器是否有内边距、是否有前置图标(比如喇叭图标)。实际可滚动距离 = 文字实际宽度 + 图标宽度 + 间距 - 容器可视宽度,这个值小于等于 0 时就应该关闭滚动,否则会出现“文字明明放得下却还在动”的怪异现象。

1.2 双份内容拼接才是无缝的本质

真正无缝的做法只有一个:把同一段公告内容渲染两份,首尾相接排成一行。第一份滚出去的同时,第二份正好补上来,当第二份滚到第一份的初始位置时,动画重置,因为此时画面和初始状态完全一致,所以重置是隐形的。

这里的关键参数是位移距离。设单份内容(含间距)宽度为W,那么动画的位移终点就是translateX(-W),而不是-100%。因为容器里现在有两份内容,总宽度是2W,-100%会移动2W,那就滚过头了。这个细节是绝大多数实现出错的根源,我见过不少代码写着translateX(-100%)却配了两份内容,结果滚动距离翻倍,速度看起来莫名其妙地快。

用表格把这个关系理清楚:

方案容器内内容位移终点视觉效果
单份 +-100%1 份文字自身宽度滚完有空白,硬跳
双份 +-100%2 份两份总宽度滚过头,速度异常
双份 +-W2 份单份宽度无缝衔接,正确

所以核心公式就一句:位移距离等于单份内容宽度,动画在这个距离上线性循环。剩下的所有工作,都是围绕“怎么准确拿到这个 W”以及“怎么让动画稳定执行”展开的。

1.3 为什么不用纯 CSS 动画一把梭

纯 CSS 方案(@keyframes+animation)的优点是不占主线程、性能好,对于内容固定不变的公告栏完全够用。但它的短板也很明显:动画时长是写死的,而公告文字长度是后端下发的,今天三条短公告、明天一条长公告,如果时长固定,长公告滚得飞快、短公告慢得像蜗牛。要动态改时长,就得在运行时计算W并写入style,这本身又回到了 JS 计算的路上。

更麻烦的是暂停恢复。CSS 的animation-play-state: paused确实能暂停,但恢复时是从暂停处继续,这个没问题;可一旦你在暂停期间修改了内容或者窗口尺寸变了,动画的进度和实际布局就对不上了,只能整个重启动画,视觉上会跳一下。所以我的选择是:内容静态、追求极致性能的场景用 CSS 动画;内容动态、需要精确控制暂停和重置的场景用requestAnimationFrame手动驱动。下面两套方案都会给出来,你按项目实际情况挑。

2. 用 requestAnimationFrame 手写一个可控的滚动引擎

先讲手动驱动方案,因为它把每一帧的逻辑都摊开在你面前,理解了它,CSS 方案的那些参数你自然就懂了。

2.1 组件骨架与双份渲染

模板部分很直接,外层容器负责裁剪溢出,内层轨道放两份相同内容:

<template> <div class="notice-bar" ref="containerRef" @mouseenter="pause" @mouseleave="resume" > <div class="notice-track" ref="trackRef" :style="trackStyle"> <div class="notice-item" ref="itemRef"> <span class="notice-icon">公告</span> <span class="notice-text">{{ text }}</span> </div> <div class="notice-item" aria-hidden="true"> <span class="notice-icon">公告</span> <span class="notice-text">{{ text }}</span> </div> </div> </div> </template>

几个设计点值得说明。第二份内容加了aria-hidden="true",因为对屏幕阅读器来说重复朗读两遍公告是干扰,这个属性让它只读第一份。两份内容都带图标,是为了保证拼接处的视觉连续性——如果只有第一份带图标,第二份开头没有,滚动到衔接点时图标会突然消失一下,很突兀。

样式上,容器overflow: hidden,轨道用display: flex让两份内容横向排列且不换行,white-space: nowrap防止文字内部折行:

.notice-bar { overflow: hidden; width: 100%; position: relative; } .notice-track { display: flex; will-change: transform; } .notice-item { display: flex; align-items: center; flex-shrink: 0; padding-right: 60px; white-space: nowrap; }

flex-shrink: 0是关键,不加的话 flex 容器空间不够时会压缩子项宽度,导致测量出来的W偏小,滚动距离算错。padding-right就是两份内容之间的间距,它会被算进单份宽度W里,这样衔接处才有呼吸感,不会两份文字贴在一起。

2.2 测量宽度:等 DOM 真正布局完成

宽度测量必须在 DOM 渲染并完成布局之后,onMounted里直接读offsetWidth有时拿到 0,尤其是组件在弹窗、标签页里延迟显示的时候。稳妥的做法是等一帧:

import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue' const containerRef = ref(null) const trackRef = ref(null) const itemRef = ref(null) const trackStyle = ref({}) const text = ref('这是一条比较长的系统公告内容,用于演示横向循环滚动效果') let itemWidth = 0 let offset = 0 let rafId = null let lastTime = 0 let paused = false const SPEED = 60 // 像素每秒 async function measure() { await nextTick() if (!itemRef.value) return itemWidth = itemRef.value.offsetWidth const containerWidth = containerRef.value?.offsetWidth || 0 if (itemWidth <= containerWidth) { // 内容放得下,不滚动 stop() trackStyle.value = { transform: 'translateX(0)' } return } start() }

这里有个容易忽略的判断:itemWidth <= containerWidth时直接不滚。注意itemWidth已经包含了padding-right的间距,所以这个比较是偏保守的,实际文字可能比容器短一点点但加上间距就超了,此时滚动也合理,因为两份内容拼接后确实需要移动才能看全。如果你希望更精确,可以单独测量文字节点宽度再加间距,但对公告栏来说这个精度足够了。

2.3 逐帧推进的核心循环

requestAnimationFrame的回调会传入一个高精度时间戳,用它算时间差,才能保证不同刷新率屏幕(60Hz、120Hz、144Hz)上滚动速度一致:

function step(timestamp) { if (!lastTime) lastTime = timestamp const delta = timestamp - lastTime lastTime = timestamp if (!paused) { offset += (SPEED * delta) / 1000 if (offset >= itemWidth) { offset -= itemWidth } trackStyle.value = { transform: `translateX(${-offset}px)` } } rafId = requestAnimationFrame(step) }

这段代码是整个组件的灵魂,逐行拆解一下。delta是距上一帧的毫秒数,SPEED * delta / 1000就是这一帧应该移动的像素数,这样速度单位是像素每秒,和帧率解耦。当offset超过单份宽度itemWidth时,减去itemWidth而不是归零,因为此时第二份内容正好滚到了第一份的起始位置,画面和offset = 0时完全一样,减去itemWidth后继续累加,视觉上就是无限循环,没有任何跳变。

注意:offset -= itemWidth而不是offset = 0,这个减法保留了超出部分的余量。如果某帧因为卡顿delta特别大,offset可能一次超出itemWidth很多,用减法能保证不丢失这段位移,滚动依然平滑。用取模offset %= itemWidth效果相同,但减法在数值上更直观。

lastTime在暂停时也要更新,否则恢复的第一帧delta会是一个巨大的值,画面会瞬移一大段。所以暂停逻辑里要把lastTime重置为 0 或者持续更新,我选择在step里无论是否暂停都更新lastTime,这样恢复时delta就是正常的一帧间隔。

2.4 暂停、恢复与清理

鼠标悬停暂停是公告栏的标配交互,实现上只是改一个标志位,循环本身不停,这样恢复时无需重新计算时间基准:

function pause() { paused = true } function resume() { paused = false } function start() { stop() offset = 0 lastTime = 0 rafId = requestAnimationFrame(step) } function stop() { if (rafId) { cancelAnimationFrame(rafId) rafId = null } } onMounted(measure) onBeforeUnmount(stop)

onBeforeUnmount里必须cancelAnimationFrame,否则组件销毁后循环还在跑,持有对已卸载 DOM 的引用,既浪费性能又可能报错。这个清理在单页应用里尤其重要,路由切换频繁时漏掉清理,内存会一点点涨上去。

3. 内容变化与窗口缩放:动态场景的重新测量

静态公告栏跑通只是及格线,真实项目里公告内容会轮换、浏览器窗口会被拖动、侧边栏会折叠,这些都会让之前测好的itemWidth失效。

3.1 监听内容变化重新起跑

公告文字来自接口,可能在组件挂载后才更新。用watch监听文本,变化后重新测量并重置滚动:

import { watch } from 'vue' watch(text, async () => { stop() offset = 0 trackStyle.value = { transform: 'translateX(0)' } await measure() })

这里先stop再measure,避免旧循环还在跑的时候新宽度已经生效,出现一两帧的错位。重置offset和transform是为了让新内容从起点开始滚,否则会从旧内容的进度位置接着滚,看起来像跳了一段。

如果公告是数组轮播(多条公告依次显示),逻辑要更复杂一些:每条公告单独测量宽度,切换时重置。我的建议是每条公告都走一遍“测量-判断-启动”的流程,不要试图复用上一条的宽度,因为不同公告长度差异可能很大。

3.2 ResizeObserver 处理容器尺寸变化

窗口缩放用resize事件也能做,但它只监听窗口,容器自身尺寸变化(比如侧边栏折叠导致内容区变宽)监听不到。ResizeObserver更精准:

let resizeObserver = null function setupObserver() { if (typeof ResizeObserver === 'undefined') return resizeObserver = new ResizeObserver(() => { stop() offset = 0 measure() }) if (containerRef.value) { resizeObserver.observe(containerRef.value) } }

在onMounted里调用setupObserver,onBeforeUnmount里resizeObserver?.disconnect()。注意回调里要防抖,容器尺寸变化可能连续触发多次,每次都重启滚动会造成明显卡顿。简单加个setTimeout延迟 100ms 执行测量即可,或者用requestAnimationFrame合并同一帧内的多次回调。

提示:ResizeObserver回调里直接读offsetWidth可能拿到的是变化前的值,因为观察者回调触发时机和布局完成的顺序不保证。稳妥做法是在回调里再套一层requestAnimationFrame,等下一帧布局稳定后再测量。

3.3 字体加载完成后的二次校准

这个坑比较隐蔽:如果公告文字用了自定义字体,组件挂载时字体可能还没加载完,浏览器先用后备字体渲染,测出来的宽度和最终字体下的宽度不一样。字体加载完成后文字宽度变了,但滚动距离还是旧的,衔接处就会出现缝隙或重叠。

处理办法是监听document.fonts.ready:

if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => { stop() offset = 0 measure() }) }

这个 Promise 在所有字体加载完成后 resolve,此时重新测量一次,宽度就准了。对于字体文件较大、首屏加载慢的项目,这一步能避免上线后用户看到的滚动错位问题。

4. 纯 CSS 方案的适用边界与参数换算

有些项目对性能敏感,或者公告内容完全静态,用 CSS 动画更省心。但参数必须算对,否则前面说的那些问题一个不少。

4.1 keyframes 与动态时长的换算

CSS 方案同样需要双份内容,位移终点是单份宽度。由于 CSS 里拿不到 JS 测的像素值,通常用 CSS 变量把宽度传进去:

.notice-track { display: flex; animation: notice-scroll var(--duration) linear infinite; } @keyframes notice-scroll { from { transform: translateX(0); } to { transform: translateX(calc(-1 * var(--item-width))); } }

--item-width和--duration由 JS 在测量后写入trackStyle。时长换算公式是duration = itemWidth / SPEED,比如单份宽度 800px、期望速度 60px/s,时长就是 13.33s。这个换算必须做,否则不同长度的公告速度不一致。

暂停用animation-play-state:

.notice-bar:hover .notice-track { animation-play-state: paused; }

一行 CSS 搞定悬停暂停,比 JS 方案简洁。但要注意,如果暂停期间内容变了,动画进度和新的布局对不上,必须通过改key或者移除再添加动画类的方式强制重启动画。

4.2 两种方案的取舍对照

维度requestAnimationFrameCSS animation
性能占主线程,但逻辑轻量合成层动画,性能更优
速度控制像素每秒,精确需换算时长,间接控制
暂停恢复标志位控制,灵活animation-play-state,简单
动态内容重新测量即可需强制重启动画
调试难度逻辑透明,好排查参数分散在 CSS 和 JS
适用场景内容动态、交互复杂内容静态、追求性能

我的实际选择是:公告条这种组件,内容动态是常态,用requestAnimationFrame更省心;如果是纯展示的跑马灯、且内容写死,CSS 方案更合适。两者没有绝对优劣,看你的内容更新频率和交互复杂度。

5. 那些只有真跑起来才会暴露的边界问题

代码写完能滚,不代表就完事了。下面这些情况我在不同项目里都遇到过,每一个都值得单独处理。

5.1 首帧闪烁与初始位移

组件刚挂载时,如果trackStyle初始为空,第一份内容会从容器左边缘开始显示,等测量完成、动画启动的瞬间,transform突然变成translateX(0),如果此时offset已经有值,画面会跳一下。解决办法是初始就把transform: translateX(0)写进trackStyle,并且测量完成前不启动动画,让内容静止显示,测量完再平滑起步。

另一个细节是will-change: transform要加在轨道上,提前告诉浏览器这个元素会变,让它提升为合成层,减少重绘。但不要滥用,一个页面里will-change元素太多反而增加内存开销,公告条这种单个元素用是合适的。

5.2 文字含富文本或图片时的宽度陷阱

如果公告内容不是纯文本,而是带<a>链接、<strong>加粗、甚至小图标,offsetWidth依然能测到总宽度,但要注意内联元素的行高和垂直对齐可能让两份内容高度不一致,拼接处出现上下错位。统一给.notice-item设align-items: center和固定line-height能解决大部分情况。

更麻烦的是内容里有图片且图片懒加载,测量时图片还没加载,宽度是占位符的宽度,图片加载后宽度变了,滚动距离就错了。这种场景要么给图片设固定宽高,要么在图片load事件后重新测量。公告栏里放图片本身就不常见,如果真有,建议限制图片尺寸并预加载。

5.3 快速连续悬停导致的计时漂移

用户鼠标在公告条上快速移入移出,pause和resume被频繁调用。因为我的实现里循环不停、只改标志位,所以不会有计时问题。但如果你的实现是在pause时cancelAnimationFrame、resume时重新requestAnimationFrame,那么每次恢复lastTime都要重置,否则第一帧delta异常大导致瞬移。这也是我选择“循环不停、标志位控制”的原因,少一个状态就少一类 bug。

5.4 无障碍与减弱动画偏好

系统设置里开启“减弱动态效果”的用户,不应该看到持续滚动的公告。用媒体查询尊重这个偏好:

@media (prefers-reduced-motion: reduce) { .notice-track { animation: none; transform: none; } }

JS 方案里则用window.matchMedia('(prefers-reduced-motion: reduce)').matches判断,为真时直接不启动滚动,让公告静态显示并允许横向滚动查看。这既是体验问题,也是无障碍的基本要求,很多团队会漏掉。

6. 把它封装成可复用组件的几个接口设计

最后聊聊组件化。这个滚动逻辑在多个页面复用,接口设计得好,后面维护省一半力气。

6.1 props 与事件的合理划分

核心 props 就三个:text(公告内容)、speed(滚动速度,默认 60)、direction(滚动方向,默认从右往左)。speed用像素每秒而不是“几秒滚完”,因为后者在内容长度变化时速度会变,前者才是符合直觉的物理量。direction支持反向滚动,实现上就是把offset的累加改成递减,位移取正值即可。

事件方面,暴露item-click让外部处理公告点击跳转,比在组件内部写死跳转逻辑灵活得多。如果公告是多条轮播,再加一个change事件抛出当前索引。

6.2 暴露方法而非只靠 props

有些场景需要外部主动控制,比如“点击按钮让公告重新从头滚”。用defineExpose暴露restart方法:

defineExpose({ restart() { stop() offset = 0 lastTime = 0 measure() } })

外部通过模板引用调用noticeRef.value.restart()。这比通过 prop 传一个递增的key强制重建组件要轻量,不会触发完整的挂载卸载流程。

6.3 一个容易忽略的细节:容器宽度为 0 的情况

组件放在v-show="false"的容器里,或者放在还没展开的折叠面板里,挂载时容器宽度是 0,itemWidth <= containerWidth的判断会误判为“需要滚动”(因为 0 宽度下任何内容都超宽),启动滚动后容器显示出来,宽度变了但滚动参数没更新。处理办法是在measure里加一个判断:容器宽度为 0 时直接返回,不启动也不停止,等ResizeObserver检测到宽度变化后再测量。这个细节在弹窗里的公告条场景特别常见,不处理的话弹窗一打开公告就是错位的。

我在最近一个数据看板项目里就栽在这上面:公告条放在一个默认收起的侧边抽屉里,抽屉展开后公告滚动距离明显偏大,文字衔接处有大段空白。排查了半天才定位到是挂载时容器宽度为 0 导致的测量错误,加上宽度判断后问题消失。这类问题不会在开发环境的常规路径下暴露,只有把组件放到真实的布局容器里、走完整的交互流程才会出现,所以组件写完后一定要在“延迟显示”的场景里验证一遍。

滚动公告这个组件本身不复杂,复杂的是各种边界条件和动态场景。把双份内容拼接的位移公式记牢,把测量时机和重新测量的触发条件想全,剩下的就是按项目实际需求在 JS 驱动和 CSS 驱动之间做选择。我个人的习惯是默认用requestAnimationFrame版本,因为它把控制权完全交给你,遇到奇怪问题时能逐帧打印offset和delta定位,比调试 CSS 动画的合成层行为直观得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进