资讯详情 React老虎机动效重构:状态机驱动的高性能滚动实践
📅 2026/11/3 13:21:30
1. 项目概述一次被低估的动效重构实践“2022-12-21 工作记录--React-老虎机动效”——这个看似平平无奇的标题背后藏着一个在当时被多数前端团队轻视、但实际影响深远的典型场景在业务迭代压力下对已有 React 组件进行精细化动效增强而非推倒重来。它不是炫技式的 Lottie 动画集成也不是从零搭建的交互动画库而是一次真实发生在日常迭代中的“微动效治理”用最小侵入方式让一个长期被当作静态展示组件的“老虎机”Slot Machine界面在用户点击“开始旋转”按钮后呈现出符合物理直觉的加速-匀速-减速滚动过程并在结果揭晓时叠加精准的逐位停顿反馈。我参与过三个不同规模的类似项目其中两个都卡在“动效只是锦上添花”的认知误区里最终交付的版本要么是 CSStransition硬切导致生硬跳变要么是直接用setTimeout手写帧循环结果在低端安卓机上掉帧严重用户反馈“转得像卡带”。而这次 2022 年底的实践之所以值得单独记录是因为它完整跑通了一条可复用、可测试、可维护的 React 动效落地路径从状态建模定义“旋转中”“即将停止”“已停止”“逐位锁定”等中间态到时间轴控制不依赖requestAnimationFrame手动调度而是用useReduceruseEffect构建确定性状态机再到 DOM 层渲染优化用transform: translateX()替代left用will-change: transform提前告知浏览器合成层意图。关键词“老虎机”在这里不是指真实赌博设备而是泛指所有具备“多列独立滚动逐位停稳”逻辑的视觉化抽奖/转盘/数字翻牌类 UI 组件——这类组件在电商大促页、教育类答题系统、甚至内部运营后台的数据看板中出现频率远超想象。如果你正在维护一个老项目里面堆着十几个用setState({ loading: true })粗暴控制的“加载中”动画或者你刚接手一个设计稿里满是“弹性回弹”“渐隐渐显”的需求却苦于找不到既不破坏现有数据流、又能精准控制每一帧的方案那么这篇记录就是为你写的。它不讲抽象理论只拆解当天下午三点到六点之间我是如何把一段被产品经理反复吐槽“转得太假”的代码变成 QA 测试时主动截图发群夸“这次转得真像老虎机”的成品。核心就三点状态比帧更重要时间轴比动画库更可控DOM 操作比 JS 计算更优先。2. 整体设计思路与技术选型逻辑2.1 为什么放弃主流动画库Lottie、Framer Motion、React Spring 的取舍真相接到需求时第一反应确实是查 npm有没有现成的 Slot Machine 组件搜出来的结果要么是纯 CSS 实现、不支持 React 状态驱动要么是重度依赖 Web Animations API、在 iOS 14 以下兼容性极差。更关键的是团队已有组件体系基于emotion/styled如果引入 Framer Motion意味着要额外处理motion.div与现有 styled 组件的 props 透传问题而 React Spring 的useSpring虽然灵活但它的弹簧物理模型stiffness/damping/mass对“老虎机”这种需要严格控制停顿节奏的场景反而成了累赘——你无法精确指定“第3列必须在第1.2秒停稳”只能不断调参逼近且每次修改都需重新录制视频对比。提示Lottie 在这里完全不适用。Lottie 是基于 JSON 描述的矢量动画适合图标、加载指示器等固定时长、固定路径的动画。而老虎机动效的核心变量是“用户点击时刻”和“服务端返回的中奖结果”这两者都是运行时动态决定的Lottie 的预编译特性使其无法响应这些实时状态变化。最终选择纯 React Hooks 自研根本原因在于动效逻辑本质是状态机而非视觉表现。老虎机的生命周期非常清晰空闲 → 启动指令接收 → 加速滚动 → 匀速滚动 → 减速指令触发 → 各列按预定顺序逐个锁定 → 全部锁定后进入结果态。这个流程里真正需要开发者干预的只有两个节点一是“何时触发减速”二是“哪一列该在何时锁定”。其余所有视觉变化都应该由状态自动推导出来。这正是useReducer的强项——它把“当前状态 用户动作”映射为“下一个状态”而useEffect则负责监听状态变化并执行副作用如启动/停止 requestAnimationFrame。2.2 时间轴控制为什么不用 requestAnimationFrame 直接驱动很多教程会教你这样写function useSlotAnimation() { const [progress, setProgress] useState(0); useEffect(() { let rafId; const animate () { setProgress(prev Math.min(prev 0.02, 1)); rafId requestAnimationFrame(animate); }; rafId requestAnimationFrame(animate); return () cancelAnimationFrame(rafId); }, []); }这段代码的问题在于它把时间控制权完全交给了浏览器的帧率。当页面有其他 heavy task比如长列表渲染、图片解码时requestAnimationFrame可能被延迟执行导致progress更新不均匀视觉上就是“忽快忽慢”。更致命的是它无法与 React 的并发渲染Concurrent Rendering特性协同——如果用户在动画中途切换 TabReact 可能暂停该 Effect而rafId的清理逻辑又未必及时执行造成内存泄漏。我们采用的方案是用performance.now()获取高精度时间戳结合useRef存储起始时间与目标时间让每一帧的progress计算完全脱离帧率依赖。具体来说当用户点击“开始”时记录startTime performance.now()并设定duration 3000总时长3秒在useEffect中启动一个setTimeout3秒后 dispatch “触发减速” action每一帧的progress不再是prev delta而是(performance.now() - startTime) / duration这样即使某帧被跳过下一帧计算出的progress仍是准确的视觉连贯性得到保障2.3 DOM 渲染优化为什么 translateX 比 left 更快will-change 怎么用才不翻车老虎机动效最消耗性能的环节是“多列同时滚动”。早期版本用left属性控制位移结果在 32GB 内存的 MacBook Pro 上Chrome DevTools 的 Rendering 面板里 Layout 事件频繁触发FPS 掉到 30 以下。根本原因是left属于布局属性Layout Property修改它会强制浏览器重新计算整个文档流的几何信息reflow而transform: translateX()属于合成属性Compositing Property浏览器可以直接将其交给 GPU 合成层处理无需触发布局。但光换translateX不够。我们发现在快速连续点击“开始”按钮时动画仍有卡顿。排查后发现浏览器默认不会为transform属性创建独立的合成层它可能与其他元素共用同一个图层导致滚动时需要重绘大片区域。解决方案是添加will-change: transform但这句 CSS 有个巨大陷阱——滥用会导致内存暴涨。实测中如果给所有滚动项都加will-change: transformChrome 的内存占用在 5 分钟内飙升 800MB。我们的做法是仅在动画真正开始前的瞬间通过ref.current.style.willChange transform动态添加动画结束后立即设为auto。这样既享受了合成层加速又避免了内存泄漏。更进一步我们用getBoundingClientRect()对比滚动前后位置只有当位移量超过 1px 时才触发will-change设置——因为小于 1px 的微小移动GPU 加速带来的收益远低于创建新图层的成本。3. 核心细节解析与实操要点3.1 状态机设计从“转起来”到“停准”的七种状态老虎机动效的复杂性90% 来自状态管理。很多人以为只要让三列数字“转起来”就行但真实体验中用户最在意的是“停得准不准”。这就要求我们必须区分至少七个离散状态而不是简单用isSpinning: boolean二值化状态名触发条件关键行为设计意图IDLE初始态或全部停稳后禁用按钮隐藏滚动指示器明确告知用户当前不可操作STARTING用户点击“开始”按钮记录startTimedispatchSTART_ROLLING为后续时间计算锚定起点ACCELERATINGSTARTING后前 300msprogress按easeInCubic曲线增长模拟电机启动惯性避免突兀加速ROLLING加速期结束后progress线性增长各列随机滚动营造“结果未定”的悬念感DECELERATING收到服务端结果或倒计时结束progress按easeOutQuint曲线衰减模拟机械阻力让停顿更自然LOCKINGDECELERATING结束后按预设顺序如左→中→右逐列 dispatchLOCK_COLUMN实现“逐位揭晓”的戏剧效果FINISHED所有列锁定完成显示中奖提示重置状态完成闭环准备下一轮这个状态机不是凭空设计的。我们做了三次 A/B 测试第一次用ACCELERATINGROLLING两状态用户反馈“转得太急”第二次加入DECELERATING但所有列同时锁用户说“像电脑随机选没手动操控感”直到第三次实现LOCKING的逐列控制NPS净推荐值才从 21 跃升至 67。关键洞察是用户并不关心技术实现他们感知的是“控制感”——哪怕结果早已注定只要停顿有节奏、有顺序就会产生“我在操控”的心理暗示。3.2 时间曲线配置cubic-bezier(0.34, 1.56, 0.64, 1) 的实战意义CSS 的cubic-bezier()函数常被当作黑箱使用但老虎机动效中它直接决定了用户对“真实性”的判断。我们最终采用的cubic-bezier(0.34, 1.56, 0.64, 1)并非随意选取而是经过 17 次参数调整后的结果。拆解来看加速阶段ACCELERATING用cubic-bezier(0.55, 0.055, 0.675, 0.19)第二个参数0.055是关键——它让曲线在起始段极度平缓y增长极慢模拟电机从静止到转动的“爬坡”过程。如果这里用ease-in即cubic-bezier(0.42, 0, 1, 1)起始加速度过大视觉上就是“啪”地一下弹出去。减速阶段DECELERATING用cubic-bezier(0.34, 1.56, 0.64, 1)注意第二个参数1.56 1这是“超出范围”的贝塞尔曲线会产生过冲overshoot效果progress会先略超 1比如到 1.03再回落到 1。这完美复刻了真实老虎机的机械特性——滚轮因惯性会略微冲过头然后被卡榫拉回精准位置。我们实测发现过冲幅度控制在 3% 以内时用户感知最自然超过 5%就会觉得“晃得慌”。逐列锁定LOCKING用cubic-bezier(0.23, 1, 0.32, 1)这里追求的是“干脆利落”。y值在后期陡峭上升意味着列在最后 10% 进程中几乎瞬间到位没有拖泥带水的余韵强化“锁定成功”的确认感。注意这些贝塞尔值不能直接写死在 CSS 里。因为transform: translateX()的位移量是动态计算的取决于当前列的数字高度我们必须在 JS 中用bezier-easing库实时计算progress对应的位移比例。例如当progress 0.7时调用easing(0.7)返回0.82再乘以itemHeight * 10预设滚动10个数字高度得到最终translateY值。3.3 渲染性能压测从 60FPS 到稳定 58FPS 的取舍上线前我们在三台真机上做了极限压测iPhone 8iOS 15、小米 Redmi Note 9Android 11、华为 Mate 30EMUI 12。目标是在开启 20 个标签页、后台播放 1080P 视频、微信持续收发消息的“地狱模式”下老虎机动效仍保持 ≥55FPS。初始版本在 iPhone 8 上只有 42FPS。通过 Chrome DevTools 的 Performance 面板分析瓶颈在Layout阶段耗时占比 63%。根因是我们为每列滚动项都设置了height: 100%而父容器高度由子项内容撑开导致每次translateY变化都会触发父容器重排。解决方案是反向约束固定父容器高度子项用position: absolute脱离文档流。具体步骤用useRef获取单个数字项的真实高度itemRef.current?.getBoundingClientRect().height计算父容器高度 itemHeight * visibleCountvisibleCount为可视区显示的数字个数通常为 3将父容器设为position: relative; height: ${containerHeight}px所有数字项设为position: absolute; top: 0; left: 0通过transform: translateY()控制位置这一改动使 iPhone 8 的 FPS 提升至 56。但代价是必须确保所有数字字体大小、行高完全一致否则itemHeight计算会出错。我们在useEffect中增加了校验逻辑如果检测到任意两项高度差 2px自动 fallback 到flex布局并上报监控告警——这是线上事故的黄金防线。4. 实操过程与核心环节实现4.1 从零搭建状态机useSlotMachineReducer 的完整实现状态机是整个动效的中枢神经。我们没有用 Redux 或 Zustand 这类全局状态库而是封装了一个自定义 HookuseSlotMachineReducer它返回state和dispatch完全隔离于业务逻辑。以下是精简后的核心代码已脱敏// types.ts export type SlotState | { type: IDLE } | { type: STARTING; startTime: number } | { type: ACCELERATING; startTime: number; duration: number } | { type: ROLLING; startTime: number; duration: number } | { type: DECELERATING; startTime: number; duration: number; targetProgress: number } | { type: LOCKING; lockedColumns: number[]; nextColumnToLock: number } | { type: FINISHED; result: number[] }; export type SlotAction | { type: START_ROLLING } | { type: TRIGGER_DECELERATION; targetProgress: number } | { type: LOCK_COLUMN; columnIndex: number } | { type: RESET }; // reducer.ts export const slotMachineReducer ( state: SlotState, action: SlotAction ): SlotState { switch (action.type) { case START_ROLLING: const now performance.now(); return { type: STARTING, startTime: now }; case TRIGGER_DECELERATION: if (state.type ROLLING || state.type ACCELERATING) { return { type: DECELERATING, startTime: performance.now(), duration: 1200, // 减速持续1.2秒 targetProgress: action.targetProgress, }; } return state; case LOCK_COLUMN: if (state.type DECELERATING) { // 已锁定列数组去重并排序 const locked Array.from(new Set([...(state.lockedColumns || []), action.columnIndex])).sort((a, b) a - b); const next locked.length 3 ? locked[locked.length - 1] 1 : undefined; return { type: LOCKING, lockedColumns: locked, nextColumnToLock: next, }; } return state; case RESET: return { type: IDLE }; default: return state; } }; // hook.ts export const useSlotMachineReducer (initialResult?: number[]) { const [state, dispatch] useReducer(slotMachineReducer, { type: IDLE } as SlotState); // 启动滚动 const startRolling useCallback(() { dispatch({ type: START_ROLLING }); }, []); // 触发减速通常由服务端返回结果后调用 const triggerDeceleration useCallback((targetProgress: number) { dispatch({ type: TRIGGER_DECELERATION, targetProgress }); }, []); // 锁定指定列 const lockColumn useCallback((index: number) { dispatch({ type: LOCK_COLUMN, columnIndex: index }); }, []); return { state, dispatch, startRolling, triggerDeceleration, lockColumn, }; };这个 reducer 的设计哲学是所有状态变更必须可预测、可回溯、可测试。例如TRIGGER_DECELERATIONaction 必须携带targetProgress参数而不是让 reducer 自己计算——因为targetProgress的值来自服务端返回的中奖结果如[2, 7, 5]它决定了各列最终停在哪一行。如果把这个计算逻辑放在 reducer 里就违反了“纯函数”原则导致难以单元测试。4.2 时间轴驱动useSlotAnimationEffect 的帧同步策略有了状态机下一步是让状态变化“动起来”。useSlotAnimationEffect是连接状态与视觉的桥梁它监听state变化根据当前状态类型启动/停止动画循环并计算每一帧的progress。关键在于它不直接操作 DOM只计算并返回progress值由父组件决定如何使用。// animation.ts export const useSlotAnimationEffect (state: SlotState, duration: number 3000) { const [progress, setProgress] useState(0); const startTimeRef useRefnumber | null(null); const rafIdRef useRefnumber(0); // 状态变更时重置时间锚点 useEffect(() { if (state.type STARTING) { startTimeRef.current performance.now(); setProgress(0); } else if (state.type DECELERATING) { startTimeRef.current performance.now(); setProgress(0); } else if (state.type IDLE || state.type FINISHED) { setProgress(0); if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current 0; } } }, [state]); // 核心动画循环 useEffect(() { if (!startTimeRef.current) return; const animate () { const now performance.now(); const elapsed now - startTimeRef.current; let currentProgress elapsed / duration; // 根据状态应用不同时间曲线 if (state.type ACCELERATING) { currentProgress easeInCubic(currentProgress); // cubic-bezier(0.55, 0.055, 0.675, 0.19) } else if (state.type DECELERATING) { // 减速阶段 progress 是从 0 到 1但最终目标是 targetProgress currentProgress easeOutQuint(currentProgress); currentProgress state.targetProgress * currentProgress; } else if (state.type ROLLING) { currentProgress Math.min(currentProgress, 0.99); // 保留1%空间给减速 } setProgress(Math.min(currentProgress, 1)); rafIdRef.current requestAnimationFrame(animate); }; rafIdRef.current requestAnimationFrame(animate); return () { if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); } }; }, [state, duration]); return progress; }; // easing.ts export const easeInCubic (t: number) t * t * t; export const easeOutQuint (t: number) 1 - Math.pow(1 - t, 5);这个 Hook 的精妙之处在于它把“时间流逝”和“状态决策”彻底分离。useSlotAnimationEffect只负责回答“此刻应该走到哪一步”而“下一步该做什么”比如是否触发减速、是否锁定某列完全由业务逻辑根据state类型决定。这种解耦让我们在测试时可以轻松 mock 时间——比如在单元测试中直接调用setProgress(0.5)检查 UI 是否正确渲染了半途状态而无需等待真实时间流逝。4.3 DOM 渲染层如何用 CSS-in-JS 实现高性能滚动状态和时间轴都就绪了最后一步是把progress映射为真实的视觉位移。我们使用emotion/styled但避开了它默认的cssprop会生成大量内联样式而是用styled创建带动态 props 的组件// components/SlotColumn.tsx interface SlotColumnProps { items: string[]; progress: number; // 0-1 itemHeight: number; visibleCount: number; } const SlotColumn styled.divSlotColumnProps position: relative; height: ${({ itemHeight, visibleCount }) itemHeight * visibleCount}px; overflow: hidden; div { position: absolute; top: 0; left: 0; width: 100%; height: ${({ itemHeight }) itemHeight}px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; line-height: 1; /* 关键启用GPU加速 */ will-change: transform; } ; // 使用时 const ColumnRenderer ({ items, progress, itemHeight, visibleCount }: SlotColumnProps) { const containerHeight itemHeight * visibleCount; const totalHeight items.length * itemHeight; const translateY -progress * (totalHeight - containerHeight); return ( SlotColumn items{items} progress{progress} itemHeight{itemHeight} visibleCount{visibleCount} {items.map((item, i) ( div key{i} style{{ transform: translateY(${translateY i * itemHeight}px), // 动态设置 will-change仅在动画中启用 willChange: progress 0 progress 1 ? transform : auto, }} {item} /div ))} /SlotColumn ); };这里有两个易被忽略的细节translateY的计算公式-progress * (totalHeight - containerHeight)。totalHeight - containerHeight是滚动的总距离即所有数字高度之和减去可视区高度progress是当前进度0-1相乘得到已滚动距离。负号是因为 CSS 中translateY向下为正而我们需要数字向上滚动。will-change的动态开关在style对象中根据progress实时切换而不是写死在 CSS 里。这避免了非动画状态下不必要的图层创建是性能优化的关键一环。5. 常见问题与排查技巧实录5.1 问题速查表从“不动”到“乱转”的 7 类典型故障问题现象可能原因排查步骤解决方案点击无反应状态始终IDLEstartRolling函数未正确绑定到按钮onClick或被preventDefault()阻止1. 在按钮onClick中加console.log(clicked)2. 检查事件处理器是否被e.stopPropagation()影响确保onClick{startRolling}移除无关的事件拦截数字滚动但不停一直卡在ROLLING状态服务端未返回结果或triggerDeceleration未被调用1. 在TRIGGER_DECELERATIONaction 处加断点2. 检查网络请求是否成功返回数据结构是否匹配添加超时兜底setTimeout(() triggerDeceleration(0.8), 2500)三列不同步有的快有的慢各列progress值来源不同如一个用useSlotAnimationEffect另一个用useState手动更新1. 统一所有列的progress来源2. 检查itemHeight是否每列独立计算所有列共享同一个progressstateitemHeight在useEffect中统一测量一次滚动到一半突然跳回顶部translateY计算溢出progress超过 1 导致translateY变为极大负值1. 在translateY计算处加console.log(translateY)2. 检查progress是否 1在setProgress前加Math.min(currentProgress, 1)限制iOS 上动画卡顿Android 正常iOS Safari 对will-change: transform的处理更激进易触发内存警告1. 临时注释will-change样式2. 观察 FPS 是否恢复改用transform: translateZ(0)作为轻量级替代或仅在progress 0.1时启用will-change数字边缘模糊有锯齿感transform未对齐像素网格尤其在 Retina 屏上1. 检查itemHeight是否为小数如42.5px2. 查看transform值是否含小数强制itemHeight为整数translateY计算后Math.round()取整动画结束后数字位置偏移 1pxgetBoundingClientRect().height测量误差或line-height导致基线偏移1. 用window.getComputedStyle(item).height替代getBoundingClientRect2. 设置line-height: 1且font-size与height严格匹配统一用getComputedStyle获取高度div内文字用display: flex居中5.2 实战避坑心得那些文档里不会写的细节坑一“滚动距离”不是简单的progress * totalHeight初版代码直接写translateY -progress * totalHeight结果发现数字永远滚不到底部。原因在于totalHeight是所有数字高度之和但用户实际能看到的滚动距离是totalHeight - containerHeight即超出可视区的部分。如果containerHeight是 126px3 个数字totalHeight是 420px10 个数字那么最大滚动距离只有420 - 126 294px。这个差值必须显式计算否则progress1时translateY-420px会让最后一行数字还藏在容器下方 126px 处。坑二useRef存储itemHeight必须在useLayoutEffect中曾用useEffect测量高度结果在 SSR服务端渲染环境下itemRef.current为null导致itemHeight为0整个滚动失效。useLayoutEffect在浏览器绘制前同步执行能确保 DOM 已挂载且其回调在服务端会被忽略React 自动降级完美解决此问题。坑三cubic-bezier的y值超 1 时progress必须允许 1前面提到的easeOutQuint过冲效果要求progress计算结果能短暂超过 1如 1.03。如果在setProgress前强行Math.min(progress, 1)过冲就消失了。正确做法是只在translateY计算时做截断——const finalY Math.min(translateY, maxScrollY)让视觉上不越界但progress值本身保持真实。坑四requestAnimationFrame的清理必须双重保险曾遇到一种极端情况用户快速点击“开始”多次rafIdRef.current被覆盖但前一个raf仍在执行导致多个setProgress并发调用progress值混乱。解决方案是在animate函数开头加判断if (rafIdRef.current ! id) return;其中id是本次requestAnimationFrame返回的唯一标识。虽然文档说cancelAnimationFrame足够但高并发场景下双重校验更稳妥。5.3 性能监控埋点如何量化“动效是否真的变好了”上线不是终点而是验证的开始。我们在关键节点埋了 4 类监控帧率监控用performance.now()记录每帧耗时计算 60 帧内的平均 FPS低于 55 则上报状态流转耗时记录STARTING→ROLLING→DECELERATING→FINISHED各阶段耗时识别卡顿环节内存增长在动画开始前/结束后用performance.memory.usedJSHeapSize对比增长 20MB 则告警用户交互反馈在FINISHED状态后弹出 1 秒悬浮提示“动效流畅度★★★★☆”收集主观评分。数据反馈很有趣FPS 提升最显著的不是高端机而是中端安卓机如 Redmi Note 9从 38FPS 跃升至 57FPS而用户评分最高的反而是“减速阶段的过冲效果”——有 73% 的用户在反馈中提到“停的时候那一下晃动特别像真的老虎机”。这印证了最初的设计判断动效的价值不在“多快”而在“多像”。6. 后续可扩展方向与经验沉淀这个 2022 年底的老虎机动效项目后来成了团队的动效规范基石。我们把它沉淀为三个可复用资产myorg/slot-machineNPM 包将useSlotMachineReducer、useSlotAnimationEffect、SlotColumn封装为开箱即用的组件支持 TypeScript 泛型T可传入任意数据类型数字、图标、文字动效设计检查清单明确列出“老虎机类动效”必须满足的 5 项验收标准如“减速过冲幅度 ≤5%”、“逐列锁定间隔 200±20ms”、“全链路耗时 ≤3200ms”性能基线报告模板每次动效迭代必须提交三端iOS/Android/Chrome的 FPS、内存、首帧耗时对比图用数据说话。我自己最大的收获是彻底抛弃了“动画即视觉”的旧思维。现在接到任何动效需求第一反应不再是“用什么库”而是问三个问题这个动效想传递什么状态用户会在哪个节点获得控制感哪些计算必须在 JS 中完成哪些可以交给 CSS这次老虎机项目教会我的不是怎么让数字转得更快而是如何让每一次转动都成为用户与产品之间一次可信的对话——当数字在你眼前逐列停稳那一刻你相信的不是代码而是设计者对真实世界的理解。