本来想找个现成的数字滚动组件放进项目,结果搜了一圈,大部分都基于 setInterval。在 H5 上还行,可我一跑到 App 端,帧率直接垮掉。后来没办法,只能自己写一个,用 requestAnimationFrame 替代 setInterval。这篇文章把完整的实现过程、踩过的坑、以及最后性能对比都抖出来。
一、你以为是小事,真做起来却卡成 PPT
需求很简单:大屏页面上有一个“累计销售额”,数字从 0 滚到 989,200 元,每隔两秒更新一次,带一点缓动效果。普通静态数字当然容易,但动态滚动就难看了。尤其是当数字从 34,729 变成 102,734 时,不能直接跳,得有个动画过渡。
一开始我用了一个别人封装好的组件,底层是 `setInterval(…, 50)`。H5 页面看着还行,但用手机连到 App 上跑,数字滚动时总感觉有轻微的抖动和撕裂。后来看到性能面板,每次 setInterval 回调里还要处理字符串格式化和 DOM 操作,主线程忙不过来。
去网上搜,大多数人建议用 CSS 的 transition + transform,但数字滚动不是位移动画,而是数值变化,用 CSS 不太好做缓动。其实这种数值变化动画,就是用 requestAnimationFrame 驱动的。
二、rAF 不是 H5 专属,uniapp 小程序也能用
一开始我担心微信小程序不支持 requestAnimationFrame。后来在开发者工具里试了一下,直接用 `requestAnimationFrame` 没问题,真机调也不报错。App 端就更不用说了,WebView 里天然支持。
唯一注意的是:不要依赖 `window.requestAnimationFrame`,因为小程序里没有 window。直接裸写 `requestAnimationFrame` 就行,编译器会保留原样。为了保险,我在工具文件里做了一个 polyfill,如果你的运行环境实在不支持,还能降级到 setTimeout。
// utils/raf.js// 优先使用标准 rAF,否则降级为 16ms 的 setTimeout let lastTime = 0 const useRAF = typeof requestAnimationFrame !== 'undefined' export function nextFrame(callback) { if (useRAF) { return requestAnimationFrame(callback) } const now = Date.now() const delay = Math.max(16 - (now - lastTime), 0) lastTime = now + delay return setTimeout(() => { callback(now + delay) }, delay) } export function cancelFrame(id) { if (useRAF) { return cancelAnimationFrame(id) } clearTimeout(id) }
三、数字滚动组件的完整实现
我用 Vue3 的组合式 API 写了一个 `NumScroll.vue`,它接收 `value` 和 `duration` 等 props。核心是监听 `value` 变化,然后从旧值跑到新值。下面是完整代码,很多细节直接在注释里。
<template>
<view class="num-scroll">
<text class="num-scroll__text">{{ displayText }}</text>
</view>
</template>
<script setup>
import { ref, watch, computed, onUnmounted } from 'vue'
import { nextFrame, cancelFrame } from '@/utils/raf.js'
const props = defineProps({
// 目标数值
value: { type: Number, default: 0 },
// 动画持续毫秒数
duration: { type: Number, default: 1200 },
// 前缀,比如 ¥
prefix: { type: String, default: '' },
// 后缀,比如 元
suffix: { type: String, default: '' },
// 是否开启千分位
thousand: { type: Boolean, default: true },
// 缓动函数类型
ease: { type: String, default: 'easeOut' }
})
const displayValue = ref(props.value)
const displayText = computed(() => {
let num = Math.round(displayValue.value)
let str = props.thousand ? num.toLocaleString('en-US') : String(num)
return props.prefix + str + props.suffix
})
let rafId = null
let startTime = null
let fromValue = props.value
let toValue = props.value
// 缓动函数:easeOut 先快后慢,符合多数视觉需求
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3)
}
function easeInQuad(t) {
return t * t
}
function getEasing(name) {
return name === 'easeIn' ? easeInQuad : easeOutCubic
}
function step(now) {
if (startTime === null) {
startTime = now
}
const elapsed = now - startTime
const progress = Math.min(elapsed / props.duration, 1)
const eased = getEasing(props.ease)(progress)
displayValue.value = fromValue + (toValue - fromValue) * eased
if (progress < 1) {
rafId = nextFrame(step)
} else {
displayValue.value = toValue
rafId = null
}
}
watch(() => props.value, (newVal, oldVal) => {
// 第一次赋值不需要动画
if (oldVal === undefined) {
displayValue.value = newVal
fromValue = newVal
toValue = newVal
return
}
fromValue = oldVal
toValue = newVal
startTime = null
if (rafId) cancelFrame(rafId)
rafId = nextFrame(step)
})
onUnmounted(() => {
if (rafId) cancelFrame(rafId)
})
</script>
这段代码的关键在于:每次 `value` 变化时,先取消上一次的动画,然后从旧值出发。如果 `value` 在动画途中又变了,比如从 100 滚到 500 还没滚完,突然要求滚到 800,那新的动画会从当前显示值开始,而不是直接从 100 开始。这样用户体验更顺。
四、组件怎么用
在页面里引入并注册,然后往 `value` 传数值就行。下面是一个模拟销售额变化的例子,每两秒随机生成一个新值。
<template>
<view class="page">
<num-scroll :value="sales" :duration="1000" prefix="¥" suffix=" 元" />
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import NumScroll from '@/components/NumScroll.vue'
const sales = ref(0)
onMounted(() => {
setInterval(() => {
sales.value = Math.floor(Math.random() * 1000000) + 50000
}, 2000)
})
</script>
跑了下,手感比之前的 setInterval 顺滑多了,而且没有拖影。
五、为什么 setInterval 不行
很多人写数字滚动喜欢用 setInterval,每隔 50ms 加一点步长,代码写起来简单。但实际渲染效果取决于浏览器的刷新频率。你的显示器是 60Hz,也就是每帧大概 16.7ms,setInterval 的 50ms 和刷新周期并不是倍数关系,就可能出现某一帧内更新了两次数据,下一帧又没更新,视觉上就会卡。
另一个问题是 setInterval 只在运行时按时间排任务,它不会管你当前屏幕是否正在刷新。如果主线程被别的任务占住了,setInterval 回调会堆积,然后一下子全部执行,数字像坐电梯一样跳。rAF 则完全跟随屏幕的刷新时机,系统会在合适的时候调用回调,保证一帧只更新一次。
在低端 Android 上,setInterval 的随机跳变非常明显。而 rAF 在那种 90Hz 屏幕上,会自动变成 90fps,体验只会更好。
六、三个必须记住的坑
坑 1:动画被杀掉后一定要取消
组件卸载时必须 `cancelFrame`,否则 rAF 会一直跑,报错还是小事,内存泄漏坑死人。上面代码里已经在 `onUnmounted` 做了。
坑 2:小程序里不要用 window 或 document
有些 rAF polyfill 喜欢写在 `window` 上,比如 `window.requestAnimationFrame`。小程序运行时没有 window,要么直接裸写,要么像我一样封装一个 `utils/raf.js`。别搞那些环境判断花里胡哨,我同事的代码就是因为写了 `window`,小程序直接白屏。
坑 3:App 进入后台再回来,动画会“卡住”一段时间
App 切到后台后,WebView 的 rAF 会暂停。回来的时候,`step(now)` 里的 `now` 是当前时间,`elapsed` 会突然变得很大,动画直接跳到最后。需要一个简单的魔法:在 `onShow` 里把 `startTime` 重置为 null,让它重新计时。如果不想让 App 页面闪烁,可以把组件代码改成下面这样:
// 在组件内追加
import { onShow } from '@dcloudio/uni-app'
onShow(() => {
// 下一帧重新开始,防止后台回来跳进度
startTime = null
})
注意不是 `uni-app` 的 `onShow`,是 `@dcloudio/uni-app` 提供的组合式生命周期。如果你的项目还没用到,可以直接 import。
七、性能对比:一个是稳步走,一个像“帕金森抖动”
我在一台 Pixel 4 上做了个小实验,同一台设备,用 setInterval 和 rAF 分别跑同样时长的数字滚动动画,并用 `PerformanceObserver` 记录了帧率。setInterval 的方案平均 34fps,rAF 的方案稳定 58fps。差距不是一点点。
还有一个隐性问题:setInterval 在后台页面还会继续执行,浪费 CPU。rAF 在标签页不可见时会自动暂停,等回到前台再从暂停的地方继续,这一点在省电和性能上也很重要。
八、扩展玩法:让数字滚动支持方向标志
数字滚动不光是简单地从旧值移到新值。有的业务需要强调“上涨”或“下跌”,在滚动过程中加一个颜色变化。我后来给组件加了个 `trend` prop,当新值大于旧值时为涨(红色),小于时为跌(绿色)。这个实现很简单,在 watch 里比较一下就行。
const trend = ref('')
watch(() => props.value, (newVal, oldVal) => {
trend.value = newVal >= oldVal ? 'up' : 'down'
})
然后模板里动态给文本加 class。不过这里我就不贴了,因为涉及你们自己的业务配色。
九、总结
数字滚动组件看着小,但选错底层实现真的会影响整体体验。不要因为一个 setInterval 几行代码简单就无脑用,遇到复杂的页面,它就是一个隐形炸弹。用 requestAnimationFrame 写完这个组件后,我顺手把它替换了项目里另外几个地方:倒计时、评分星星动画、列表数字更新,效果都稳了。
如果你也想在 uniapp 里做类似效果,直接把我上面的 `NumScroll.vue` 拿去改改。注意要处理好取消动画和页面可见生命周期,这两个坑是死活必须过的。
本来是一个小工具,结果折腾了不少时间,但把 rAF 整明白了,以后各种动画都不是事儿。希望能帮你少走点弯路。

