资讯详情

React 中用 requestAnimationFrame 重写 Marquee:无缝滚动与性能优化实战

📅 2026/10/13 20:26:27 | 华诺云谱 👁 阅读
React 中用 requestAnimationFrame 重写 Marquee:无缝滚动与性能优化实战
简介这份资源面向需要在 React 项目中实现跑马灯Marquee滚动效果的开发者尤其适合希望摆脱 CSS 动画局限、追求更精细控制的初中级前端工程师。它基于 requestAnimationFrame 封装滚动逻辑支持 left、right、up、down 四个方向速度参数可调默认值为 2能灵活适配公告栏、通知条、轮播文字等常见场景。压缩包共 27 个文件约 86KB以 14 个 JavaScript 源码文件为核心涵盖 marquee、RAFWrapper、modifiers、constants 等模块另有 json 配置、css 样式、md 说明及 eslint、prettier、babel 等工程化配置文件目录结构清晰便于按模块阅读与二次开发。目前已有 736 人学习下载说明该方案在实际项目中具备一定参考价值。读者可从中掌握 requestAnimationFrame 驱动动画的实现思路、方向与速度参数的设计方式以及组件拆分与工程配置的完整组织方法适合直接引入项目或作为自定义滚动组件的学习范本。1. 用 requestAnimationFrame 重写 Marquee为什么 CSS 动画在 React 里总翻车做过滚动公告、跑马灯、无缝轮播的人大概都有同一个血泪经验CSSkeyframes写起来三行上线后却总在某个机型上抖一下、卡一下或者内容一长就露馅。react-marquees这个方向要解决的就是这件事——在 React 里用requestAnimationFrame下称 rAF驱动一个 Marquee 元素把滚动这件事从「交给浏览器猜」变成「自己算每一帧的位置」。它适合三类人需要做公告栏、弹幕条、无缝 logo 墙的前端被 CSS 动画在低端机上掉帧折磨过的工程师以及想搞清楚 rAF 和 React 渲染节奏怎么配合的人。核心结论先放这Marquee 的难点从来不是「怎么动」而是「怎么无缝、怎么不抖、怎么在 React 的 re-render 里活下来」。2. 先想清楚Marquee 到底在动什么2.1 位移、循环、无缝三个必须拆开的概念很多人一上来就写transform: translateX(-100%)然后发现滚到头会「跳」一下。问题在于把三件事混成了一件。位移是「当前这一帧内容往左挪了多少像素」循环是「挪到什么时候把位置重置」无缝是「重置的瞬间用户看不出接缝」。这三者里位移是每帧都要算的连续量循环是一个阈值判断无缝则靠内容复制来兜底。常见做法是把内容渲染两份容器宽度等于单份内容宽度当位移量达到单份宽度时把位移减去单份宽度。因为两份内容完全一样减去的瞬间画面完全重合肉眼看不到跳变。这就是无缝的本质——不是动画做得平滑而是重置点选在了视觉等价的位置。用 CSS 做这件事你得靠animation-iteration-count: infinite加translateX(-50%)硬凑一旦内容动态变化比如公告条数变了百分比基准就变了接缝立刻暴露。用 rAF 自己做位移量是你手里的一个数字内容变了重新测宽度就行可控性完全不一样。2.2 为什么是 rAF而不是 setInterval 或 CSSsetInterval的问题是老生常谈它和浏览器刷新节奏不同步16.7ms 的间隔在 60Hz 屏幕上会累积误差在 120Hz 屏幕上又浪费一半帧。更麻烦的是页面切到后台setInterval还在跑回来时位移量已经飞出去了。CSS 动画本身是走合成层的性能其实不差但它的短板在「不可干预」。你想根据鼠标悬停减速、想根据滚动位置联动、想在内容更新时平滑过渡CSS 就得靠一堆 class 切换和animation-play-state硬撑状态一多就乱。rAF 的好处是它由浏览器在「下一次重绘之前」调用天然对齐刷新率页面不可见时自动暂停。代价是你得自己管时间戳、自己算位移、自己在组件卸载时取消。react-marquees这类实现的价值就是把这套样板封装成一个能复用的 hook 或组件。2.3 一个最小可跑的 rAF 位移循环先不碰 React把纯逻辑跑通。下面这段是核心骨架// 纯函数式的最小位移循环先脱离 React 验证逻辑 function startMarquee(el, speed 50) { let offset 0; // 当前位移单位 px let lastTime null; // 上一帧时间戳 let rafId null; function frame(now) { if (lastTime null) lastTime now; const delta now - lastTime; // 距上一帧的毫秒数 lastTime now; // speed 是 px/sdelta 是 ms换算成这一帧该走的像素 offset (speed * delta) / 1000; const singleWidth el.scrollWidth / 2; // 内容复制了两份 if (offset singleWidth) { offset - singleWidth; // 无缝重置 } el.style.transform translate3d(${-offset}px, 0, 0); rafId requestAnimationFrame(frame); } rafId requestAnimationFrame(frame); return () cancelAnimationFrame(rafId); // 返回取消函数 }逻辑说明delta用真实时间差而不是固定 16.7ms这样在 120Hz 或掉帧时速度都一致不会出现「高刷屏滚得快」的玄学。translate3d而不是translateX是为了强制走 GPU 合成层减少重排。singleWidth取scrollWidth / 2前提是内容确实复制了两份这个前提后面在 React 里要小心维护。参数说明speed单位是 px/s比「几秒滚完一轮」更好调因为内容宽度变化时速度不变用户感知一致。如果你想要「固定时长滚完」那就得在内容变化时重算 speed这是两种不同的产品语义先想清楚要哪个。3. 把它接进 Reacthook 设计与渲染节奏3.1 用 useRef 拿 DOM用 useEffect 管生命周期React 里最容易翻车的地方是「rAF 循环里读了 state」。一旦你在frame里读speed或offset这类 state就得把整个循环放进useEffect依赖里speed 一变循环重启位移归零画面直接跳回起点。正确姿势是把可变的东西放进 ref循环只读 ref。import { useEffect, useRef } from react; function useMarquee({ speed 50, paused false }) { const containerRef useRef(null); const offsetRef useRef(0); const speedRef useRef(speed); const pausedRef useRef(paused); // 外部 prop 变化时只更新 ref不重启循环 useEffect(() { speedRef.current speed; }, [speed]); useEffect(() { pausedRef.current paused; }, [paused]); useEffect(() { const el containerRef.current; if (!el) return; let lastTime null; let rafId null; function frame(now) { if (lastTime null) lastTime now; const delta now - lastTime; lastTime now; if (!pausedRef.current) { offsetRef.current (speedRef.current * delta) / 1000; } const singleWidth el.scrollWidth / 2; if (singleWidth 0 offsetRef.current singleWidth) { offsetRef.current - singleWidth; } el.style.transform translate3d(${-offsetRef.current}px, 0, 0); rafId requestAnimationFrame(frame); } rafId requestAnimationFrame(frame); return () cancelAnimationFrame(rafId); }, []); // 空依赖循环只启动一次 return containerRef; }逻辑说明这个 hook 的关键是「循环只启动一次」。speed和paused通过 ref 透传循环内部永远读最新值但不会因为 prop 变化而重建。offsetRef也放在 ref 里这样即使组件因为别的原因 re-render位移也不会丢。参数说明paused用 ref 而不是直接cancelAnimationFrame是因为暂停后还要能恢复且恢复时lastTime需要重置否则暂停期间累积的 delta 会让画面「瞬移」。这里有个细节暂停时lastTime仍在更新所以恢复时 delta 是正常的单帧间隔不会跳。如果你在暂停时也停止 rAF那恢复时必须把lastTime置 null两种写法都行但别混。3.2 内容复制两份DOM 结构和宽度测量无缝的前提是内容有两份。React 里别用dangerouslySetInnerHTML复制直接渲染两次 childrenfunction Marquee({ children, speed 50, paused false }) { const ref useMarquee({ speed, paused }); return ( div style{{ overflow: hidden, whiteSpace: nowrap }} div ref{ref} style{{ display: inline-flex, willChange: transform }} div style{{ display: inline-flex }}{children}/div div style{{ display: inline-flex }} aria-hiddentrue{children}/div /div /div ); }逻辑说明外层overflow: hidden裁掉溢出内层inline-flex让两份内容横向排列scrollWidth就是两份的总宽。第二份加aria-hidden避免屏幕阅读器把内容读两遍。参数说明willChange: transform提示浏览器提前提升合成层但别滥用元素多了反而吃内存。whiteSpace: nowrap是防止内容换行导致宽度测量出错如果你要的是多行滚动这套逻辑得改不在本文范围。宽度测量有个坑如果 children 里有图片或异步字体首次渲染时scrollWidth可能偏小导致重置点算错。稳妥做法是在useEffect里加一个ResizeObserver监听内层宽度变化时更新一个singleWidthRef循环里读这个 ref 而不是每次读scrollWidth。3.3 用 ResizeObserver 兜住动态内容useEffect(() { const el containerRef.current; if (!el) return; const inner el.firstElementChild; // 第一份内容 if (!inner) return; const ro new ResizeObserver(() { singleWidthRef.current inner.scrollWidth; }); ro.observe(inner); return () ro.disconnect(); }, []);逻辑说明只观察第一份内容的宽度因为它就是单份宽度。ResizeObserver在内容、字体、图片变化时都会触发比window.resize靠谱得多。参数说明singleWidthRef初始为 0循环里判断 0才做重置避免首帧宽度未测时误重置。如果你的内容宽度永远不变这个 observer 可以省但公告类场景内容经常变建议留着。4. 避坑与排查五个真实会踩的坑4.1 现象高刷屏上滚得比 60Hz 快原因用了固定offset speed / 60这种按帧数累加的写法120Hz 屏幕每秒跑 120 帧速度直接翻倍。解决一律用时间戳差值算位移也就是(speed * delta) / 1000。这是 rAF 动画的铁律别偷懒。4.2 现象内容更新后画面跳一下原因offsetRef还停在旧内容的位移上新内容宽度变了重置点对不上。解决在内容变化时把offsetRef归零或按比例缩放。简单做法是监听 children 变化offsetRef.current 0。更平滑的做法是按新旧宽度比例缩放 offset但多数场景归零就够了。4.3 现象组件卸载后控制台报错原因rAF 没取消回调里还在访问已经卸载的 DOM。解决useEffect的清理函数里必须cancelAnimationFrame(rafId)。注意rafId要在每次frame里更新否则取消的是第一帧的 id等于没取消。4.4 现象低端机上滚动一顿一顿原因每帧都在改left或margin-left触发重排。解决只用transform: translate3d别碰布局属性。另外检查有没有在滚动容器上加了box-shadow或filter这些会让合成层失效。4.5 现象暂停恢复后画面瞬移原因暂停期间lastTime没更新恢复时 delta 是暂停的总时长位移一下冲出去。解决暂停时仍然更新lastTime如 3.1 的写法或者在恢复时把lastTime置 null 让它重新初始化。两种都行关键是别让 delta 累积。5. 进阶让 Marquee 更聪明的两个技巧5.1 用 IntersectionObserver 做「进视口才跑」页面里可能有多个 Marquee全都在跑纯属浪费。用IntersectionObserver监听容器是否可见不可见时把pausedRef置 true可见时恢复。这样既省电又省 CPU尤其在长列表里效果明显。useEffect(() { const el containerRef.current; if (!el) return; const io new IntersectionObserver(([entry]) { pausedRef.current !entry.isIntersecting; }, { threshold: 0 }); io.observe(el); return () io.disconnect(); }, []);逻辑说明threshold: 0表示只要有一像素进入视口就算可见。注意这里直接改pausedRef和外部传入的pausedprop 会打架实际项目里建议用一个pausedRef同时承载「外部暂停」和「不可见暂停」两个条件用位运算或布尔或来合并。5.2 速度自适应内容越长滚得越快固定 px/s 在内容很短时显得慢很长时又显得快。一个讨巧的做法是按「单份宽度 / 目标时长」算 speed让每轮滚动时长恒定。但要注意内容特别长时速度会很快可能影响可读性所以最好设一个速度上限。参数含义建议值speed滚动速度 px/s3080targetDuration单轮目标时长 s1530maxSpeed速度上限 px/s120pauseOnHover悬停暂停按需用法是speed Math.min(maxSpeed, singleWidth / targetDuration)在ResizeObserver回调里重算并写入speedRef。这样内容变化时速度自动调整用户感知更稳定。我自己的习惯是任何 rAF 动画先把纯逻辑在浏览器控制台里跑通确认位移、重置、取消都对再往 React 里搬。搬的时候记住一条——循环里只读 ref不读 state循环只启动一次。这条守住了Marquee 这类需求基本不会再翻车。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑