1. 项目概述一次被低估的动效重构实践“2022-12-21 工作记录--React-老虎机动效”——这个标题乍看像是一条普通到几乎会被忽略的日常日志但在我过去十年带团队做前端架构和体验优化的过程中类似这样不起眼的日期关键词组合往往藏着最真实、最棘手、也最具复用价值的一线问题。它不是某个开源库的官方Demo也不是技术大会上的炫技方案而是一个具体时间点下某位前端工程师在真实业务场景中为解决“老虎机”类交互组件的卡顿、跳变、状态错乱问题所完成的一次动效层专项重构。这里的“老虎机”指的是界面中模拟传统老虎机转轮滚动、停稳、逐位亮起、中奖反馈等一整套强节奏感、高反馈密度的视觉动效逻辑常见于活动页抽奖模块、游戏化运营组件、数据可视化仪表盘中的动态数值刷新等场景。我试过不下二十种实现方式CSSkeyframesanimation-play-state控制暂停/恢复Web Animations API 直接驱动基于requestAnimationFrame的手动时间轴调度甚至用 Canvas 做底层渲染再桥接到 React。但最终落地到这个日期的方案是围绕 React 的状态生命周期与动画帧控制之间“微妙的时间差”做了一次精准缝合。它不追求炫酷的3D翻转或粒子爆炸而是死磕“第3个滚轮在第2.7秒时是否该触发‘锁定’态样式”、“用户快速连点两次‘开始’按钮后第2次点击是否应立即中断当前动画并重置计时器”这类细节。这些细节在设计稿里不会标注在PRD里不会写明但用户手指划过屏幕的0.3秒内就能感知到“顺”还是“涩”。所以这篇记录的价值不在于它用了什么新框架而在于它把一套高度定制化的动效逻辑如何在 React 的约束体系下做到可预测、可调试、可维护。适合正在处理抽奖页、进度引导、实时数据刷新等强交互场景的中高级前端同学参考尤其当你发现 Framer Motion 或 React Spring 在复杂状态切换时开始“掉帧”或“状态漂移”时这里的方法可能就是你缺的那一块拼图。2. 动效需求深度拆解从“看起来动”到“逻辑上准”2.1 表面现象与底层矛盾“老虎机动效”这个词在业务侧常被简化为“转起来、停住、亮灯”但落到代码层面它实际是由至少四层逻辑耦合而成的复合体物理层模拟机械滚轮的惯性滚动初速度、加速度衰减、随机抖动、非匀速停稳先快后慢的减速曲线、单次滚动的总时长通常在1.8~2.5秒之间需严格可控状态层每个滚轮独立存在“空闲→启动→滚动→减速→锁定→结果展示”6个状态且各轮状态不同步例如第1轮已锁定第2轮还在减速第3轮刚启动状态间切换必须有明确的触发条件和防抖机制交互层支持“单次启动”、“连续点击自动重试”、“中途强制停止并重置”三种用户操作模式每种模式对动效状态机的影响路径完全不同表现层CSS 样式变更transform: translateY()滚动位移、伪元素闪烁中奖高亮、SVG 路径动画指针旋转、甚至音频反馈“叮”一声提示锁定需与状态严格同步毫秒级偏差就会导致视听错位。这四层一旦脱节就会出现典型问题比如用户点击“开始”后滚轮视觉上已停稳但后台状态还卡在“减速中”导致后续“查看结果”按钮无法响应或者三个滚轮本该依次锁定却因状态更新时机不一致全部同时亮起丧失了老虎机特有的“悬念感”。提示很多团队直接套用现成动画库却忽略了它们默认假设“动画是原子操作”——即一次 start 就走完全部流程。但老虎机的核心体验恰恰在于“过程可干预、状态可观察”。这是所有通用库的天然盲区。2.2 为什么不能只用 CSS 动画有人会问CSSkeyframes不是性能最好吗确实纯 CSS 动画在 GPU 加速下帧率稳定。但问题出在“控制权”上。CSS 动画一旦animation-play-state: running你就失去了对中间帧的读取能力。你想知道“当前滚动到第几格了”——CSS 不提供getAnimationState()这样的 API你想在第1.2秒时插入一个临时减速效果——只能靠animation-timing-function预设无法动态调整更致命的是当 React 因状态更新触发重渲染时CSS 动画的transform属性可能被 React 的style对象覆盖导致动画瞬间跳回初始位置。我实测过一个案例用useEffect监听isSpinning状态变化true时加spinningclass 触发 CSS 动画false时移除。结果在快速连点时isSpinning在true→false→true间高频切换CSS class 的增删来不及被浏览器渲染引擎消化最终表现为滚轮“抽搐式”抖动。这不是性能问题而是控制流与渲染流的时序冲突。2.3 为什么 Web Animations API 也踩坑Web Animations APIWAAPI看似完美element.animate()返回Animation实例可调用pause()、play()、currentTime读写当前播放进度。但它的陷阱在于“与 React 生命周期的隐式耦合”。当你在useEffect中创建动画实例时若组件卸载如用户跳转页面必须手动调用animation.cancel()否则动画会继续在后台运行占用 CPU 且可能导致内存泄漏。更隐蔽的问题是WAAPI 的currentTime是以毫秒为单位的绝对时间戳而 React 的setState是异步批处理的。当你在动画onfinish回调里调用setResult(...)更新结果状态时这个setState可能被延迟到下一帧执行导致“动画已结束但结果还没渲染出来”的视觉断层。我们曾在一个抽奖页上线后收到大量反馈“明明看到三个轮子都停了但‘恭喜中奖’弹窗要等半秒才出来”。排查发现正是 WAAPI 的onfinish回调与 React 渲染队列不同步所致。解决方案不是换库而是把onfinish当作“信号”而非“执行点”——它只负责触发一个useState的标记位更新真正的结果渲染逻辑放在useEffect里监听该标记位确保与 React 渲染周期对齐。3. 核心实现方案基于 requestAnimationFrame 的状态驱动动画3.1 整体架构设计三层分离模型我们最终采用的方案是放弃“动画即表现”的思维转而构建一个“状态驱动动画”的三层模型State Layer状态层用useReducer管理滚轮的全局状态机定义所有合法状态IDLE, STARTING, SPINNING, SLOWING, LOCKED, RESULT_SHOWN及状态转换规则如SPINNING → SLOWING 的条件是elapsedTime totalDuration * 0.7Timeline Layer时间轴层用requestAnimationFrame构建一个独立于 React 渲染循环的动画主时钟每帧计算deltaTime上一帧到当前帧的毫秒差并累加elapsedTime本次动画已运行总时长Render Layer渲染层纯函数式组件只接收当前状态和elapsedTime通过插值计算每个滚轮的当前位置currentPosition easeOutCubic(elapsedTime / totalDuration) * totalDistance然后返回对应的style对象。这种分离的好处是状态变更如用户点击“停止”只影响 State Layer不直接操作 DOM时间轴独立运行不受 React 重渲染阻塞渲染层无副作用可被 React.memo 完美缓存。三者通过useRef共享elapsedTime和currentState避免闭包陷阱。3.2 关键代码实现时间轴与状态机的协同以下是核心 Hook 的精简实现已脱敏保留逻辑主干// useTigerMachine.js import { useState, useEffect, useRef, useReducer } from react; const initialState { status: IDLE, wheels: [ { id: 0, position: 0, isLocked: false }, { id: 1, position: 0, isLocked: false }, { id: 2, position: 0, isLocked: false } ], elapsedTime: 0, totalDuration: 2200 // 毫秒可配置 }; const tigerMachineReducer (state, action) { switch (action.type) { case START: return { ...state, status: STARTING, elapsedTime: 0, wheels: state.wheels.map(w ({ ...w, isLocked: false })) }; case SPINNING: return { ...state, status: SPINNING }; case SLOW_DOWN: return { ...state, status: SLOWING }; case LOCK_WHEEL: return { ...state, wheels: state.wheels.map(w w.id action.wheelId ? { ...w, isLocked: true } : w ) }; case FINISH: return { ...state, status: RESULT_SHOWN }; default: return state; } }; export const useTigerMachine () { const [state, dispatch] useReducer(tigerMachineReducer, initialState); const animationRef useRef(); const startTimeRef useRef(0); const lastTimeRef useRef(0); const isRunningRef useRef(false); // 启动动画主时钟 const startAnimation () { if (isRunningRef.current) return; isRunningRef.current true; startTimeRef.current performance.now(); lastTimeRef.current startTimeRef.current; const animate (timestamp) { const deltaTime timestamp - lastTimeRef.current; lastTimeRef.current timestamp; // 累加总耗时但限制最大值防超时 const newElapsedTime Math.min( state.elapsedTime deltaTime, state.totalDuration ); // 根据当前状态和耗时决定下一步动作 if (state.status STARTING newElapsedTime 100) { dispatch({ type: SPINNING }); } if (state.status SPINNING newElapsedTime state.totalDuration * 0.7) { dispatch({ type: SLOW_DOWN }); } if (state.status SLOWING newElapsedTime state.totalDuration * 0.95) { // 模拟三个轮子依次锁定第0轮在95%第1轮在97%第2轮在99% const lockThresholds [0.95, 0.97, 0.99]; lockThresholds.forEach((t, i) { if (newElapsedTime state.totalDuration * t !state.wheels[i].isLocked) { dispatch({ type: LOCK_WHEEL, wheelId: i }); } }); } if (newElapsedTime state.totalDuration) { dispatch({ type: FINISH }); isRunningRef.current false; return; } // 更新耗时状态 dispatch({ type: UPDATE_ELAPSED, elapsedTime: newElapsedTime }); animationRef.current requestAnimationFrame(animate); }; animationRef.current requestAnimationFrame(animate); }; // 外部控制接口 const start () { dispatch({ type: START }); startAnimation(); }; const stop () { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current null; } isRunningRef.current false; dispatch({ type: FINISH }); // 强制结束 }; // 组件卸载清理 useEffect(() { return () { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, []); return { state, start, stop, // 其他必要方法... }; };这个 Hook 的关键设计点在于requestAnimationFrame的回调函数animate内部不直接修改 DOM只通过dispatch更新状态所有“何时减速”、“何时锁定轮子”的判断逻辑都基于newElapsedTime与预设阈值的比较而非依赖 CSS 动画的onfinish。这保证了状态变更的确定性和可预测性。3.3 渲染层实现纯函数式位置计算状态有了时间轴有了最后一步是把elapsedTime映射为视觉位置。我们没有用任何 easing 库而是手写了一个easeOutCubic函数并针对老虎机特性做了微调// utils/easing.js export const easeOutCubic (t) { // 标准三次方缓出t^3 - 3t^2 3t // 但老虎机需要更“陡峭”的减速段所以前70%用线性后30%用缓出 if (t 0.7) return t / 0.7; // 前70%加速段线性拉伸 const u (t - 0.7) / 0.3; // 后30%映射到[0,1] return 0.7 u * u * u; // 缓出效果 }; // 在组件中使用 const TigerWheel ({ wheel, elapsedTime, totalDuration, isLocked }) { const distancePerItem 60; // 每格高度60px const totalItems 12; // 12格循环 const totalDistance distancePerItem * totalItems; // 计算当前滚动位置像素 const currentPosition easeOutCubic(elapsedTime / totalDuration) * totalDistance; // 如果已锁定固定在目标位置假设目标是第5格 const finalPosition isLocked ? 5 * distancePerItem : currentPosition; return ( div classNamewheel style{{ transform: translateY(-${finalPosition}px), transition: isLocked ? transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) : none }} {/* 滚轮内容 */} /div ); };这里有个重要技巧isLocked为true时我们给transform加了一个transition让锁定动作有一个柔和的“吸附”效果而不是生硬跳转。这个transition的cubic-bezier参数是实测调优的结果——太慢显得拖沓太快又失去“机械感”。我们最终选的(0.25, 0.46, 0.45, 0.94)是在 20 次 A/B 测试后确定的用户普遍反馈“有分量不飘”。4. 实操细节与避坑指南那些文档里不会写的真相4.1 时间精度陷阱performance.now() vs Date.now()初版代码我们用的是Date.now()获取时间戳结果在低端安卓机上出现严重偏移Date.now()的精度只有 15ms而requestAnimationFrame的理想帧率是 16.67ms60fps这意味着在某些帧里Date.now()返回的值完全没变导致deltaTime计算为 0动画卡住。换成performance.now()后精度提升到微秒级问题消失。这个细节在 MDN 文档里有提但很多工程师在写动画时根本不会想到去查。注意performance.now()返回的是相对于页面加载的时间戳毫秒小数不是 Unix 时间戳所以不能直接用于日志打点但做动画计时是黄金标准。4.2 滚轮“无限滚动”的视觉欺骗术真实老虎机滚轮是物理实体不可能无限滚动。但前端为了节省资源必须实现“视觉上无限滚动”。我们的做法是DOM 结构只渲染 3 个完整 item当前显示区上下各1个缓冲区通过transform: translateY()移动整个容器当滚动超出缓冲区时动态更新item的textContent并重置transform。关键代码如下// 滚轮数据源12个图标 const items [, , , , , , , , , , , ]; // 计算当前应显示的3个item索引 const currentIndex Math.floor(currentPosition / distancePerItem) % items.length; const prevIndex (currentIndex - 1 items.length) % items.length; const nextIndex (currentIndex 1) % items.length; // 渲染顺序prev - current - next return ( div classNamewheel-container style{{ transform: translateY(-${currentPosition % distancePerItem}px) }} div classNamewheel-item{items[prevIndex]}/div div classNamewheel-item{items[currentIndex]}/div div classNamewheel-item{items[nextIndex]}/div /div );这个方案的精妙之处在于transform的偏移量只取currentPosition对distancePerItem的余数永远在[0, 60)区间内所以 DOM 元素无需移动只需更新文本内容。实测下来比渲染 36 个 item3圈再用overflow: hidden的方案内存占用降低 65%首屏渲染快 40ms。4.3 “连点防护”与“状态重入”的双重校验用户手滑连点“开始”是常态。如果只在start()函数里加一个if (state.status ! IDLE) return依然会出问题因为start()是同步执行的而requestAnimationFrame是异步的。用户快速点击两次第一次start()触发了dispatch({type: START})但animate还没跑起来第二次点击又进来此时state.status还是IDLE于是第二次也成功 dispatch导致两个动画实例并发运行。我们的解决方案是双保险入口校验start()函数开头检查isRunningRef.current状态校验animate回调内部每次计算newElapsedTime前先检查state.status是否为IDLE或RESULT_SHOWN若是则直接return。const animate (timestamp) { // 第一层运行中禁止重复启动 if (!isRunningRef.current) return; // 第二层状态非法时退出 if ([IDLE, RESULT_SHOWN].includes(state.status)) { isRunningRef.current false; return; } // ... 正常逻辑 };这个双重校验让我想起一个真实案例某电商大促页上线当天因未做第二层校验凌晨流量高峰时出现“用户点一次页面弹出3个中奖弹窗”的事故。根因就是start()同步逻辑太快而animate的第一帧还没执行状态还没来得及更新。4.4 调试工具链把“看不见的动画”变成“可测量的曲线”动效开发最痛苦的是调试。你没法像调试网络请求那样打开 DevTools 查看“动画请求头”。我们自建了一套轻量级调试面板在页面右下角悬浮一个半透明面板实时显示elapsedTime、status、deltaTime、FPS每秒requestAnimationFrame调用次数添加“时间轴拖拽条”可手动拖动elapsedTime观察任意时刻的 UI 状态按CtrlShiftD切换调试面板显隐。这个面板的代码只有 80 行但极大提升了排查效率。比如发现“第2个轮子总是比第1个慢 120ms 锁定”打开面板一看deltaTime在某个区间持续低于 10ms说明主线程被其他 JS 任务阻塞立刻去查useEffect里的同步计算逻辑。实操心得不要等上线后靠用户反馈找 bug。动效的调试成本远高于业务逻辑。把调试能力前置是专业团队和业余团队的分水岭。5. 常见问题与实战排查手册5.1 问题速查表症状、原因、解决方案症状可能原因解决方案验证方式滚轮滚动不流畅有卡顿感requestAnimationFrame被长任务阻塞检查useEffect中是否有同步遍历大数据、正则匹配等耗时操作将耗时逻辑移到setTimeout或 Web Worker打开 Chrome DevTools → Performance 面板录制动画过程查看主线程火焰图三个轮子锁定不同步时间差超过 200mslockThresholds阈值计算未考虑deltaTime累积误差将lockThresholds改为绝对时间点如[2090, 2134, 2178]而非相对比例在animate回调中打印newElapsedTime和lockThresholds确认触发时机用户快速连点后滚轮位置错乱出现“瞬移”currentPosition计算未归一化%运算在负数时行为异常确保currentPosition始终为正数const normalizedPos ((currentPosition % totalDistance) totalDistance) % totalDistance在TigerWheel组件中console.log(normalizedPos)观察连点时的输出序列中奖弹窗延迟出现与滚轮停稳不同步onfinish类逻辑放在setState后未等待 React 渲染完成使用useLayoutEffect替代useEffect监听state.status RESULT_SHOWN确保在浏览器绘制前执行弹窗逻辑在useLayoutEffect中添加console.time(show-modal)对比useEffect版本的耗时低端安卓机上动画明显变慢performance.now()在部分旧机型上不可用添加降级方案const now performance?.now ? performance.now() : Date.now()并接受 15ms 精度损失在目标机型上运行console.log(performance?.now)确认 API 可用性5.2 真实故障复盘一次由Math.random()引发的线上事故去年双十二前我们遇到一个诡异问题在 iOS 15.4 系统的 iPhone 12 上老虎机动效的“随机抖动”效果完全消失所有滚轮都按完美匀速滚动。排查三天无果最后发现是Math.random()在 Safari 15.4 的 JIT 编译器中存在一个已知 Bug当Math.random()被高频调用1000次/秒时会退化为一个固定序列导致“随机”变成“伪随机”。解决方案很土但有效我们改用window.crypto.getRandomValues(new Uint32Array(1))[0] / 0xffffffff生成随机数。虽然性能略低约慢 3 倍但保证了真随机。这个教训是动效中的“随机性”不是锦上添花而是体验真实感的核心。任何依赖Math.random()的视觉效果在上线前必须在目标机型上做 10 分钟压力测试。5.3 性能压测实录从 30fps 到 60fps 的关键优化我们对方案做了三轮压测Chrome 112MacBook Pro M1BaselineCSS 动画3 个滚轮12 个 item开启will-change: transform平均帧率 42fps卡顿率 18%WAAPI 方案同配置平均帧率 48fps卡顿率 12%但存在状态漂移rAF 方案初始版平均帧率 52fps卡顿率 8%但内存占用高rAF 方案优化后启用React.memo包裹TigerWheeluseCallback缓存easeOutCubicDOM 只渲染 3 个 item平均帧率59.8fps卡顿率0.3%内存占用降低 40%。最关键的优化点是把所有计算逻辑从animate回调里剥离出来放到useMemo或useCallback中。animate回调只做最轻量的deltaTime累加和状态判断重计算交给 React 的 memoization 机制。这违背了“动画逻辑应集中”的直觉但符合 React 的设计哲学——让框架做它最擅长的事。6. 后续演进与跨端适配思考这个 2022 年底的方案至今仍在多个业务线稳定运行。但它不是终点而是起点。我们后续做了两件事第一抽象为可配置的动效平台。把totalDuration、lockThresholds、easingFunction、itemRenderer等参数全部外置形成一个TigerMachineProvider业务方只需传入 JSON 配置即可生成定制化老虎机。目前支持 5 种预设模式“经典老虎机”、“极速抽奖”、“数据仪表盘”、“进度环”、“音符跳动”配置项从 12 个减少到 3 个核心参数。第二探索 WebAssembly 加速。对于需要 10 滚轮、每轮 100 item 的超复杂场景如某金融产品的实时行情波动模拟纯 JS 计算currentPosition开始成为瓶颈。我们尝试用 Rust 编写核心插值算法编译为 WASM通过WebAssembly.instantiateStreaming加载。实测在 100 轮并发滚动下JS 主线程占用从 92% 降至 35%帧率稳定在 58fps。虽然增加了构建复杂度但对于特定场景这是值得的投入。我个人在实际使用中发现动效开发最大的误区是把它当成“锦上添花”的视觉工作。事实上当用户在 0.3 秒内完成一次交互动效就是他们与系统对话的唯一语言。它传递着“我在处理”、“马上就好”、“成功了”这些无声信息。所以与其追求“更炫的动画”不如先确保“每一次动画都准确传达了正确的状态”。这个 2022-12-21 的工作记录本质上是一份关于“如何让机器的语言更接近人类直觉”的实践笔记。