uniapp 组件开发实录:一个数字滚动动画,我为什么坚决不用 setInterval

2026-08-02 0 621

本来想找个现成的数字滚动组件放进项目,结果搜了一圈,大部分都基于 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 整明白了,以后各种动画都不是事儿。希望能帮你少走点弯路。

uniapp 组件开发实录:一个数字滚动动画,我为什么坚决不用 setInterval
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uniapp 组件开发实录:一个数字滚动动画,我为什么坚决不用 setInterval https://www.taomawang.com/web/uniapp/2472.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务