React Marquee 组件:用 requestAnimationFrame 实现无缝滚动
简介这是一份面向React前端开发者的跑马灯组件实现资源聚焦于使用requestAnimationFrame替代传统定时器来驱动Marquee元素滚动适合希望优化动画性能、减少卡顿与掉帧的初中级开发者参考。组件提供direction与speed两个核心属性direction支持left、right、up、down四种方向默认leftspeed为数值型默认2可灵活控制滚动方向与速度。压缩包共27个文件约86KB以JavaScript源码为主包含marquee.js、RAFWrapper.js、modifiers.js、constants.js等核心模块另有package.json、rollup.config.js等构建配置以及.storybook示例、__tests__测试用例和README说明文档结构完整便于二次开发。目前已有736人学习下载。通过阅读源码读者可掌握requestAnimationFrame封装、滚动方向与速度计算、修饰器逻辑及组件测试写法并借助Storybook示例快速验证效果适合直接集成到实际项目中。1. 滚动字幕这件小事为什么用 CSS 动画总翻车做前端的人迟早会碰到一个需求让一行文字或一组 Logo 在容器里无缝滚动。第一反应通常是keyframes加translateX几行 CSS 就能跑起来看起来很美。但真到业务里问题就来了——滚动速度要跟内容长度挂钩、鼠标悬停要暂停、动态增删内容后动画要重新计算、还要在低端设备上不掉帧。CSS 动画在这些场景下会变成一个黑匣子你没法在运行时精确控制进度也没法根据内容宽度动态调整时长。react-marquees这个方向要解决的就是这件事用requestAnimationFrame在 React 里手写一个 Marquee 组件把滚动这件事从 CSS 手里拿回来交给 JS 逐帧驱动。它适合两类人一类是被 CSS 动画的不可控性折磨过的业务开发者另一类是想搞懂 rAF 驱动动画到底怎么落地、怎么和 React 渲染周期配合的进阶学习者。接下来我会把这个组件的选型理由、核心实现、参数设计和踩坑记录完整拆一遍代码可以直接抄。2. 为什么放弃 CSS 动画改用 requestAnimationFrame 驱动2.1 CSS 动画在 Marquee 场景下的三个硬伤先说清楚为什么不用 CSS。第一个硬伤是时长与内容宽度解耦。CSS 动画的animation-duration是写死的但你的内容宽度是动态的——今天滚 10 个 Logo明天滚 30 个如果时长不变内容越多滚得越快视觉速度完全失控。要让它匀速时长必须等于内容宽度 / 目标速度这个计算只能在 JS 里做。第二个硬伤是暂停与恢复的精度。animation-play-state: paused确实能暂停但它暂停的是 CSS 引擎的时间轴你拿不到当前滚动到了哪个像素。如果暂停后要基于当前位置做逻辑比如点击某个 Logo 时让它停在中间CSS 方案就抓瞎了。第三个硬伤是动态内容的重新计算。React 里内容经常是异步来的图片加载完宽度才确定。CSS 动画在内容变化后不会自动重算你得手动重置animation属性而重置的瞬间会有一个跳变。用 rAF 驱动的话每一帧都读当前的真实宽度内容变了下一帧自然就对了。2.2 requestAnimationFrame 的逐帧模型与时间戳requestAnimationFrame的本质是浏览器在每次重绘前调用你传入的回调并给你一个高精度时间戳。这个时间戳是理解一切的关键——它让你能算出「距离上一帧过了多少毫秒」从而用「速度 × 时间」来推进位置而不是用「每帧固定移动多少像素」。// 核心推进逻辑基于时间差而非帧数 let lastTime 0; let offset 0; // 当前滚动偏移量单位 px function step(timestamp) { // 第一帧 lastTime 为 0初始化后直接跳过避免出现巨大 delta if (lastTime 0) { lastTime timestamp; rafId requestAnimationFrame(step); return; } const delta timestamp - lastTime; // 距上一帧的毫秒数 lastTime timestamp; // speed 单位是 px/sdelta 是 ms所以要除以 1000 offset (speed * delta) / 1000; // 超过一个内容周期就回绕实现无缝 if (offset contentWidth) { offset - contentWidth; } // 直接操作 DOM transform绕过 React 状态更新 trackRef.current.style.transform translateX(${-offset}px); rafId requestAnimationFrame(step); }这段代码里有两个关键决策。第一用 delta 而不是固定步长。如果写offset 2那在 120Hz 屏幕上速度就是 60Hz 的两倍这是新手最常翻的车。第二回绕用减法而不是取模。offset % contentWidth在 offset 为负或浮点误差下会有边界问题减法更稳。2.3 用 transform 而非 left把重排挡在门外滚动动画的性能命门在于你改的属性会不会触发重排reflow。改left、margin-left会触发重排浏览器要重新计算布局内容一多就掉帧。改transform: translateX()只触发合成composite走的是 GPU 层几乎不占主线程。所以整个组件的结构是外层容器overflow: hidden内层一条 track 用transform平移。track 里放两份完全相同的内容当第一份滚出视野时第二份刚好接上回绕的瞬间用户看不出接缝。这就是「无缝」的实现原理——不是真的无缝是两份内容首尾相接骗过了眼睛。提示track 必须设will-change: transform或让它处于独立合成层否则某些浏览器仍会走主线程绘制。但别滥用will-change用完记得在停止滚动时移除否则会一直占显存。3. 从零实现一个 rAF 驱动的 Marquee 组件3.1 组件骨架与 ref 的职责划分先把结构搭出来。这个组件需要三个 ref容器 ref 用来测可视宽度track ref 用来改 transform内容 ref 用来测单份内容的真实宽度。为什么内容宽度要单独测因为 track 里有两份内容track 的总宽度是内容的两倍你不能拿 track 宽度当周期。import React, { useRef, useEffect, useCallback } from react; function Marquee({ children, speed 50, pauseOnHover true, direction left }) { const containerRef useRef(null); // 可视窗口 const trackRef useRef(null); // 平移的轨道 const contentRef useRef(null); // 单份内容用于测宽 const rafIdRef useRef(null); // 保存 rAF id卸载时取消 const offsetRef useRef(0); // 当前偏移用 ref 避免触发重渲染 const lastTimeRef useRef(0); const pausedRef useRef(false); // ... 动画逻辑在下面 return ( div ref{containerRef} style{{ overflow: hidden, width: 100% }} onMouseEnter{() pauseOnHover (pausedRef.current true)} onMouseLeave{() pauseOnHover (pausedRef.current false)} div ref{trackRef} style{{ display: flex, willChange: transform }} div ref{contentRef} style{{ display: flex, flexShrink: 0 }} {children} /div {/* 第二份内容aria-hidden 避免读屏重复 */} div style{{ display: flex, flexShrink: 0 }} aria-hiddentrue {children} /div /div /div ); }这里有个容易忽略的点offsetRef、lastTimeRef、pausedRef全部用 ref 而不是 state。因为动画每帧都在变如果用 state每秒会触发 60 次重渲染React 的调和开销直接吃掉性能。ref 是可变对象改它不触发渲染这正是逐帧动画需要的。3.2 用 ResizeObserver 拿到真实内容宽度内容宽度不能只测一次。图片异步加载、字体加载完成、窗口 resize、内容动态增删都会改变宽度。用ResizeObserver监听内容元素宽度一变就更新周期值。const contentWidthRef useRef(0); useEffect(() { const el contentRef.current; if (!el) return; const observer new ResizeObserver((entries) { for (const entry of entries) { // 用 borderBoxSize 拿含 padding/border 的宽度更贴近实际占位 const width entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; contentWidthRef.current width; } }); observer.observe(el); return () observer.disconnect(); }, []);ResizeObserver的回调是异步的在布局之后、绘制之前触发所以拿到的宽度是准确的。这里用borderBoxSize而不是contentRect.width是因为如果内容项有 padding 或 bordercontentRect会漏掉这部分导致周期算小、回绕时出现缝隙。这个坑我在一个 Logo 墙项目里踩过排查了半天才发现是 padding 没算进去。3.3 动画主循环与暂停恢复的边界处理把主循环接上重点处理暂停恢复时的时间戳重置。如果暂停了 5 秒再恢复lastTime还停留在 5 秒前下一帧的 delta 会是 5000ms位置瞬间跳一大截。所以恢复时必须把lastTime重置为 0让下一帧重新初始化。const animate useCallback((timestamp) { const contentWidth contentWidthRef.current; if (contentWidth 0) { // 宽度还没测出来先空转等下一帧 rafIdRef.current requestAnimationFrame(animate); return; } if (lastTimeRef.current 0) { lastTimeRef.current timestamp; rafIdRef.current requestAnimationFrame(animate); return; } const delta timestamp - lastTimeRef.current; lastTimeRef.current timestamp; if (!pausedRef.current) { const dir direction left ? 1 : -1; offsetRef.current (speed * delta * dir) / 1000; // 处理回绕正负方向都要覆盖 if (offsetRef.current contentWidth) { offsetRef.current - contentWidth; } else if (offsetRef.current 0) { offsetRef.current contentWidth; } if (trackRef.current) { trackRef.current.style.transform translateX(${-offsetRef.current}px); } } else { // 暂停期间不推进但要把 lastTime 归零恢复时重新计时 lastTimeRef.current 0; } rafIdRef.current requestAnimationFrame(animate); }, [speed, direction]); useEffect(() { rafIdRef.current requestAnimationFrame(animate); return () { if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current); }; }, [animate]);暂停分支里把lastTimeRef.current 0是这段代码的灵魂。很多人写暂停只改pausedRef结果恢复时位置猛跳还以为是浏览器 bug。实际上就是 delta 累积导致的。另外cancelAnimationFrame一定要在 cleanup 里调用否则组件卸载后循环还在跑ref 指向的 DOM 已经没了控制台会刷一堆报错。3.4 参数设计speed、direction 与 gap 的取值建议参数不是随便定的每个都有实际约束。下面这张表是我在几个项目里总结出来的经验值。参数类型建议范围说明speednumber30 ~ 120单位 px/s。低于 30 显得卡顿高于 120 人眼跟不上文字会糊directionleft | right-方向。right 时 offset 递减回绕逻辑要对称处理pauseOnHoverbooleantrue悬停暂停交互友好几乎总该开gapnumber16 ~ 48两份内容之间的间距单位 px。太小会显得挤太大接缝明显speed这个值特别讲究。我见过有人设成 200觉得「快就是流畅」结果文字根本看不清用户投诉。滚动字幕的目的是「可读地移动」不是「炫技」。60 左右是个舒服的默认值长内容可以适当降到 40。gap要加在 track 的gap属性上而不是内容项内部否则两份内容之间的间距和内容项之间的间距不一致接缝会露馅。4. 避坑与排查那些让 Marquee 抖动的真凶4.1 现象滚动时整条轨道周期性抖动原因内容宽度测量不准回绕时 offset 减去的值和实际周期对不上导致每绕一圈就错位几个像素累积起来就是抖动。最常见的是用了contentRect.width漏算了 padding或者ResizeObserver还没触发就开始了动画。解决在动画循环里加一道保险contentWidth 0时直接空转不推进。同时用borderBoxSize测宽并在内容项上避免用marginmargin 不计入 borderBox改用gap或padding。4.2 现象鼠标移入暂停后移出时内容跳一下原因暂停期间lastTime没有重置恢复后第一帧的 delta 是暂停的总时长位置瞬间前进一大段。解决暂停分支里把lastTimeRef.current 0让恢复后的第一帧走初始化逻辑。这个改动只有一行但能解决 90% 的「暂停跳变」问题。4.3 现象低端安卓机上滚动一顿一顿的原因每帧都在改style.transform如果 track 没有独立合成层浏览器每帧都要走一次布局和绘制。另外如果内容里有大量图片或复杂 DOM合成开销也会上来。解决给 track 加will-change: transform提升为合成层内容项尽量用简单结构图片设固定宽高避免布局抖动如果还是卡考虑用translate3d强制 GPU 加速。但注意will-change别常驻滚动停止后移除。4.4 现象React 严格模式下动画跑了两遍原因开发环境的 StrictMode 会故意双调用 effectrequestAnimationFrame被启动了两次两个循环同时改 transform速度翻倍且互相打架。解决在 effect 的 cleanup 里确保cancelAnimationFrame被调用并且用一个 ref 标记当前循环是否已激活激活前先取消旧的。这是 React 18 之后必须处理的生产环境不会双调用但开发时看着闹心。4.5 现象内容动态增删后滚动出现空白段原因ResizeObserver监听的是内容元素但如果你增删的是内容元素的子节点且父元素宽度没变比如 flex 布局下子项换行observer 可能不触发。解决把 observer 挂到真正会变宽的那个元素上或者在内容变化时手动触发一次测量。更稳的做法是监听children的变化用一个useEffect依赖 children变化时重新读一次contentRef.current.scrollWidth。5. 进阶把 Marquee 做成可复用 Hook 与性能验证写到这儿组件已经能跑了。但如果你要在多个地方用把逻辑抽成useMarquee自定义 Hook 更划算。Hook 接收容器 ref、track ref、内容 ref 和配置项返回一个isPaused状态和手动控制方法。这样业务组件只负责渲染结构动画逻辑完全复用。function useMarquee({ containerRef, trackRef, contentRef, speed, direction, autoStart true }) { const stateRef useRef({ offset: 0, lastTime: 0, paused: !autoStart, width: 0 }); const rafRef useRef(null); const tick useCallback((ts) { const s stateRef.current; if (s.width 0) { rafRef.current requestAnimationFrame(tick); return; } if (s.lastTime 0) { s.lastTime ts; rafRef.current requestAnimationFrame(tick); return; } const delta ts - s.lastTime; s.lastTime ts; if (!s.paused) { const dir direction left ? 1 : -1; s.offset (speed * delta * dir) / 1000; if (s.offset s.width) s.offset - s.width; else if (s.offset 0) s.offset s.width; if (trackRef.current) trackRef.current.style.transform translateX(${-s.offset}px); } else { s.lastTime 0; } rafRef.current requestAnimationFrame(tick); }, [speed, direction, trackRef]); useEffect(() { const el contentRef.current; if (!el) return; const ro new ResizeObserver(([entry]) { stateRef.current.width entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; }); ro.observe(el); rafRef.current requestAnimationFrame(tick); return () { ro.disconnect(); if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [tick, contentRef]); return { pause: () { stateRef.current.paused true; }, resume: () { stateRef.current.paused false; stateRef.current.lastTime 0; }, }; }抽成 Hook 后暂停恢复的lastTime重置逻辑被封装在resume里调用方不用再关心这个细节。这就是把踩坑经验固化进 API 的价值。性能验证别靠感觉用 Chrome DevTools 的 Performance 面板录 5 秒看 FPS 曲线和主线程占用。一个健康的 rAF 滚动主线程每帧占用应该在 2ms 以内FPS 稳定在 60或屏幕刷新率。如果看到长任务黄色块频繁出现八成是 transform 没走合成层或者内容里有触发布局的属性在变。另一个验证方法是把speed调到 0此时动画循环还在跑但不推进如果 FPS 依然掉说明问题不在滚动本身而在渲染结构。最后说个我自己的习惯任何逐帧动画我都会先在tick里加一行console.count(frame)跑 3 秒看计数是不是接近 18060fps × 3s。如果只有 90说明有掉帧如果超过 200说明有多个循环在跑。这行代码上线前删掉但排查阶段比任何工具都快。逐帧动画这东西玄学少细节多把 delta、回绕、暂停重置这三件事做对基本就稳了。希望帮到你。本文还有配套的精品资源点击获取