用requestAnimationFrame实现平滑精准的Number动画效果
「Number动画滚动时卡成PPT,最后发现是requestAnimationFrame没对齐」
今天上线前 QA 一通猛点,说数据看板里那个「累计成交额」从 0 动起来的时候,数字跳得跟老式电梯楼层灯一样——一顿一顿的,还偶尔回退。我当场打开控制台调了下,发现动画帧率掉到 10fps 左右,performance.now() 打印出来的时间戳间隔动不动就 80ms……行吧,又来了。
先说结论:问题出在 requestAnimationFrame 和数值插值逻辑没对齐,再加上我手欠加了个 Math.round() 在错误的位置。改完之后流畅了,但顺带暴露了另一个小问题:iOS Safari 下快速触屏滚动时,动画会短暂暂停(不是 bug,是浏览器策略),这个我后面补了个兜底降级,不完美,但用户感知不到。
这里我踩了个坑:一开始以为是 CSS 的 transition 搞鬼,把数字包进 <span> 里加了 transform: translateZ(0) 强制硬件加速。结果没用——Number 动画压根不走 CSS 渲染管线,它是靠 JS 不停 set innerText 实现的,跟 transform 毫无关系。白配了两分钟 GPU 层级优化。
后来试了下发现,最简单的复现方式就是:在滚动中触发动画,同时用 console.timeLog 打印每次 rAF 回调的时间。一看就明白了:rAF 回调被浏览器 throttle 了,尤其在 scroll 事件密集触发时,它会合并、延迟甚至丢弃部分回调。而我原来的插值逻辑是「每帧都按当前时间算进度」,时间轴一抖,进度就来回跳,数字自然抽风。
排查过程也挺典型:我先换成了 setTimeout(..., 16),想手动模拟 60fps,结果更糟——iOS 上直接锁死主线程,页面卡住;又试了 setInterval,还是抖;最后翻 MDN 看 requestAnimationFrame 的文档,注意到一句:“回调执行时间由浏览器决定,可能早于或晚于预期帧时间”。好家伙,这不就是问题根源么。
解决方案其实很土:不用「当前时间」做插值基准,改用「起始时间 + 上一次真实执行时间差」作为进度依据。也就是记录第一次 rAF 触发的时间戳,在后续每一帧里,用 (now - startTime) / duration 计算归一化进度,再 clamp 到 [0, 1]。这样哪怕某帧被 delay 了 50ms,下一帧也不会补偿式狂追,而是平滑接上。数值变化就稳了。
另外,Math.round() 我原来放在了插值函数内部,导致 99.4 → 99.5 → 99.6 → 100 这种过渡里,中间三帧全显示 99,最后一帧突然蹦到 100。改成只在最终渲染前 round 一次,中间插值保持小数,视觉上就顺滑多了。
下面是最终精简后的核心代码(Vue 3 setup script 风格,但逻辑通用):
function animateNumber(el, from, to, duration = 1000) {
const startTime = performance.now();
let currentVal = from;
const isUp = to >= from;
function step(now) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
// 使用 easeOutCubic 缓动,避免结尾突兀
const eased = 1 - Math.pow(1 - progress, 3);
currentVal = from + (to - from) * eased;
// 仅在最终渲染时取整,中间保留小数保证过渡连续
el.innerText = isUp
? Math.floor(currentVal)
: Math.ceil(currentVal);
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// 调用示例
const numEl = document.querySelector('.stat-number');
animateNumber(numEl, 0, 12847, 1200);
顺带提一嘴缓动函数:我一开始用的 linear,数字上升太机械,像计算器按等号。换成 easeOutCubic 后,开头快、结尾慢,更符合人眼对「增长感」的直觉。你也可以换 easeInOutQuad 或者直接抄 CSS 的 cubic-bezier(.25,.46,.45,.94),只要别用 easeInExpo 这种开头拖沓的就行——数字动画不是 loading,不需要「酝酿感」。
还有个细节:如果目标数字特别大(比如百万级),直接用 Math.floor 会导致前三帧都是 0,建议加个最小显示阈值,或者对大数做缩写(12847 → 1.28w)。不过这不是动画问题,是产品需求,我就没塞进核心逻辑里。
至于 iOS 滚动暂停的问题,目前没找到完美解法。我试过监听 scroll 事件,检测是否在滚动中,如果是,就暂停动画,滚完再 resume。但体验反而更差——用户手指一抬,数字就停住,再抬一下又开始动,比原生的「轻微卡顿」更干扰注意力。最后决定放弃:iOS 的 rAF throttle 是浏览器为了省电做的主动限制,强行绕过等于跟系统对着干。我们加了个 fallback:如果检测到连续两帧间隔 > 60ms,就切到「步进式 setTimeout」(每 100ms 更新一次),虽然不够丝滑,但至少不跳变。代码就三行,不贴了,需要的评论区喊一声。
踩坑提醒:这三点一定注意:
① 别在插值过程中 round 数字,只在最终渲染时取整;
② 进度计算必须基于 startTime,别信 now – lastTime,lastTime 不可靠;
③ 动画启动前确保 DOM 元素已挂载且可写,Vue 里尤其要注意 onMounted 时机,我有次因为组件异步加载,querySelector 拿到 null,整个动画静默失败,查了半小时。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用 Web Worker 做插值计算、或者结合 IntersectionObserver 控制启停,欢迎评论区交流。这个技巧的拓展用法还有很多,比如加单位前缀(¥、%)、支持负数、中断重置,后续会继续分享这类博客。
