从数据看板到KPI大屏,把数字“动”起来:CountUp.js 平滑数字动画实战
如果你做前端可视化、数据大屏或者后台统计页面,一定遇到过这种需求:页面加载或者滚动到某个区域时,数字从0开始匀速滚动到目标值,比如“本季度营收 ¥2,560,000”“用户总数 128,000”“在线率 99.98%”。这种动画的实用价值并不只是“好看”,它能抓住浏览者的视线焦点,让数据的变化过程变得可感知。
我最早接触这个需求是在做一个销售数据大屏项目,当时甲方提了个让人头疼的要求:“数字要滚动,速度不能太快,也不能太慢,要有节奏感”。我第一反应是手写 requestAnimationFrame 定时器去凑,结果写了半天,边界情况一堆:精度丢失、千分位格式不对、反复触发动画、低端手机上掉帧。后来换了 CountUp.js,十几行代码搞定,而且效果极其稳定。
这个库到底怎么用?什么时候选它、什么时候不如自己写?滚动到可视区域后才触发的动画怎么做?为什么别人做的数字滚动自然顺滑,你写的就生硬卡顿?这篇我一次性讲透,直接对着抄就行。
1. CountUp.js 到底是什么:核心能力与适用场景拆解
1.1 核心功能:一个函数搞定“数字滚动动画”
CountUp.js 是一个轻量级的 JavaScript 动画库,核心能力就是让数字从指定起始值平滑过渡到目标值。听起来简单,但它在内部把很多细节处理掉了:缓动函数的计算、动画帧的调度、小数位精度控制、千分位分隔符、前缀后缀、以及动画过程中的实时回调。
它的极简用法长这样:
javascript复制import { CountUp } from 'countup.js';
const countUp = new CountUp('myNumber', 2560000, {
duration: 2.5,
separator: ',',
prefix: '¥'
});
countUp.start();
也就是说,你不需要关心每一帧数字是多少,不需要手动管理定时器,只需要告诉它“DOM 元素是谁”“目标值是多少”“动画跑多久”,剩下的交给它处理。
我在多个场景里用它:数据大屏的指标卡、企业官网的“多年经验/服务客户数/行业证书数”展示区块、后台管理系统的统计数据概览页。凡是数字需要“动态登场”的场合,它都适用。
1.2 同类需求为什么选 CountUp.js 而不是手写
有人会说,数字滚动动画而已,我写个 setInterval 每 30 毫秒把数字加一点不就行了?确实能实现,但代码很快就会失控。
拿一个简单例子说:从 0 滚到 10000,2 秒完成。用 setInterval 每隔 16ms 执行一次,那你得自己算步长:10000 / (2000 / 16) = 80,每次加 80 没问题。但如果用户中途切换浏览器标签页,setInterval 会掉帧,回来之后数字可能卡在中间;如果目标值不是整数,比如 9999.98,你还得处理小数精度;如果要求先快后慢的缓动效果,你得手动引入 easing 公式。这些工作量加起来,已经远超“简单动画”的预期了。
CountUp.js 解决的核心问题有这么几个:
第一,raf 驱动,动画帧调度稳定。 新版 CountUp.js 基于 requestAnimationFrame 实现,浏览器自然会优化渲染时机,页面切后台会自动暂停,切回来动画继续,不会出现 setInterval 那种堆积回调导致的“数字瞬移”。
第二,内置缓动函数,动画节奏有高级感。 默认的 easing 是 easeInExpo,也就是先慢后快再缓冲的节奏,观感上比匀速滚动舒服太多。这在数据大屏上尤其明显——匀速滚动在长数字下会显得机械,缓动滚动则让人感觉“数字是被算出来的”。
第三,格式化能力完整。 千分位分隔符、小数位、前缀、后缀、负数处理,这些全都有现成的配置项。手写的时候最容易漏掉的就是千分位,1000000 显示为 1000000 和 1,000,000,专业感完全不同。
提示:CountUp.js 并不是唯一选择,但它是同类里体积小、API 简单、无依赖的典型代表。压缩后大约 20KB 左右,对于不需要完整图表库的项目非常友好。
1.3 一个挑剔场景的补充说明:数字精度与数据响应式更新
除了上面的基础能力,CountUp.js 还支持 update 方法,可以在动画结束后更新为新的目标值。比如做实时刷新指标卡,每 5 秒从后端拉一次最新数据,数字能平滑过渡到新值,而不是突然跳变。
这个能力在监控大屏、实时交易看板里特别实用。我做过一个订单交易额的看板,每 3 秒刷新一次,刚开始用的 setInterval 直接改 DOM,数字跳来跳去,甲方反馈“看着不专业”。换成 CountUp.js 的 update 之后,配合缓动效果,每次数值更新都是从当前值“滑”到新值,观感提升非常明显。
适用场景总结一下:
- 数据可视化大屏 / 数据看板中的核心指标数字
- 营销官网中展示公司发展成果的统计区块
- 后台管理系统中的概览数字
- 实时数据的增量展示(配合 update 方法)
不适用或者需要谨慎使用的场景,后面在踩坑部分专门讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零上手:基础用法与 API 逐项解析
2.1 安装与引入方式
CountUp.js 支持 npm 安装和 CDN 引入,按项目实际环境来。
npm 方式:
bash复制npm install countup.js
然后在组件里引入:
javascript复制import { CountUp } from 'countup.js';
CDN 方式适合纯静态页面或者快速验证:
html复制<script src="https://cdn.jsdelivr.net/npm/countup.js@2.8.0/dist/countUp.umd.js"></script>
注意 2.x 版本的导入方式和 1.x 有差异。1.x 是直接实例化 CountUp,2.x 需要用解构方式拿 { CountUp }。如果你在旧项目里看到 new CountUp(...) 直接可用,那是 1.x 的全局导出方式,2.x 不要写错。
2.2 核心构造函数与参数逐项拆解
javascript复制const countUp = new CountUp(target, endVal, options);
三个参数分别是:
target:目标元素,可以传 DOM id 字符串,也可以直接传 HTMLElement 对象。比如 'myNumber' 或 document.getElementById('myNumber')。注意这个元素是数字直接渲染的位置,一般是一个 <span> 或 <div>。
endVal:最终要滚动到的目标值。
options:配置对象,下面这张表是常用配置项。
| 配置项 | 默认值 | 作用说明 |
|---|---|---|
| startVal | 0 | 起始数字,动画从这个值开始滚 |
| duration | 2 | 动画时长,单位秒 |
| decimalPlaces | 0 | 保留小数位数 |
| separator | '' | 千分位分隔符,比如 ',' 或 ' ' |
| decimal | '.' | 小数点符号 |
| prefix | '' | 数字前缀,比如 '¥'、'$' |
| suffix | '' | 数字后缀,比如 '%'、'+' |
| useEasing | true | 是否启用缓动效果 |
| useGrouping | true | 是否启用千分位分组 |
| easingFn | 内置 | 自定义缓动函数 |
| formattingFn | 无 | 完全自定义数字格式化回调 |
我常用的几个配置组合:
场景一:金额展示,加人民币符号、千分位分隔、保留两位小数
javascript复制const countUp = new CountUp('amount', 1234567.89, {
startVal: 0,
duration: 2.5,
decimalPlaces: 2,
separator: ',',
decimal: '.',
prefix: '¥'
});
countUp.start();
场景二:百分比数据,带 % 后缀,整数
javascript复制const countUp = new CountUp('rate', 99.8, {
duration: 1.8,
decimalPlaces: 1,
suffix: '%'
});
countUp.start();
2.3 核心方法:start / pauseResume / reset / update
CountUp 实例上有几个关键方法,使用频率非常高。
start():开始动画。如果已经调用过,再次调用不会重复启动,但如果是 reset 之后调用可以重新开始。
pauseResume():暂停/恢复动画。这个在没有配置自动播放的场景下很有用,比如用户点击某个按钮暂停数字滚动查看细节时。
reset():重置到起始值。配合滚动触发动画时经常用,比如元素移出可视区域后重置,下次进入再播放。
update(targetVal):直接更新到新目标值,并从当前显示值平滑过渡。这个方法在实时数据刷新场景中是神器。
javascript复制// 深拷贝 update 的用法
const countUp = new CountUp('amount', 1000, { duration: 2 });
countUp.start();
// 5秒后更新为 2000
setTimeout(() => {
countUp.update(2000);
}, 5000);
注意:update 是从当前显示值开始动画,不是从 startVal。如果你希望每次都从 0 开始,得先调用 reset() 再 update。
2.4 获取当前值的辅助能力
在开发过程中,有时候需要拿当前动画值去做别的逻辑,比如联动展示图表的进度条。CountUp 实例上有 countUp.getEndVal() 和内部状态,但要注意新版没有直接暴露“当前值”的 getter,实现联动可以通过回调方式:
javascript复制const countUp = new CountUp('amount', 1000, {
duration: 3,
onComplete: () => {
console.log('动画完成,最终值:', countUp.getEndVal());
}
});
3. 进阶实战:滚动到目标值才触发动画的核心方案
3.1 为什么需要“滚动触发”
默认情况下,CountUp 在调用 start() 后立即开始动画。但在官网、专题页这类长页面场景里,数字区块可能处于首屏之外。如果页面加载时所有数字同时开跑,等用户滚到那个区块时动画早播完了,看到的是静止终值,效果直接归零。
所以最常见的需求是:元素进入浏览器视口(可视区域)时,数字才开始滚动,并且最好每个数字滚动一次就停住,不要反复播。
3.2 方案一:IntersectionObserver 最佳实践
现代浏览器里,监听元素是否进入可视区域,最优雅的方案是 IntersectionObserver 而不是 scroll 事件。原因很明显:
- scroll 事件高频触发,容易造成性能浪费,即使节流也有多余计算
- IntersectionObserver 是浏览器原生的异步观察机制,性能开销极小
- 代码可读性好,状态管理集中
基础实现:
javascript复制const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 进入可视区域时启动动画
countUp.start();
// 动画只播一次,启动后取消观察
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('amount'));
threshold 设为 0.5 表示元素至少 50% 进入视口才触发。这个值可以根据动画区块的高度调节。区块较高时建议用 0.3,避免用户还没看清就开播;区块较矮时 0.5 没问题。
3.3 方案二:兼容旧浏览器的 scroll + 节流方式
如果项目要兼容较老的浏览器(比如不支持 IntersectionObserver 的旧 WebView),用 scroll 监听 + rAF 节流实现:
javascript复制let started = false;
function checkInView() {
const rect = document.getElementById('amount').getBoundingClientRect();
const windowHeight = window.innerHeight;
if (rect.top < windowHeight && rect.bottom > 0 && !started) {
countUp.start();
started = true;
window.removeEventListener('scroll', onScroll);
}
}
function onScroll() {
requestAnimationFrame(checkInView);
}
window.addEventListener('scroll', onScroll, { passive: true });
checkInView();
这段代码关键在于被动监听(passive: true),告诉浏览器不调用 preventDefault,提升滚动性能。requestAnimationFrame 在这里不直接控制帧率,而是把判断逻辑合并到浏览器渲染周期里,避免 scroll 高频回调带来的布局抖动。
3.4 一个可复用的通用封装函数
在实际项目里,页面上有多个数字需要滚动,我不建议每个都写一遍监听。封装一个通用函数最省心:
javascript复制function initCountUpOnScroll(elementId, endVal, options = {}) {
const el = document.getElementById(elementId);
if (!el) return null;
const countUp = new CountUp(el, endVal, {
duration: 2,
separator: ',',
...options
});
// 这里不做动画启动,交给 observer 管理
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
countUp.start();
observer.unobserve(entry.target);
}
});
}, { threshold: 0.4 });
observer.observe(el);
return { countUp, observer };
}
// 页面里初始化多个数字
initCountUpOnScroll('users', 128000, { suffix: '+' });
initCountUpOnScroll('orders', 2560, { prefix: '¥' });
initCountUpOnScroll('rate', 99.98, { decimalPlaces: 2, suffix: '%' });
这样一个函数同时处理了初始化、滚动监听、动画启动和清理。如果放在单页应用中,组件卸载时记得调用 observer.disconnect() 释放内存。
3.5 在 Vue 3 + React 中的组件化适配
Vue 3 中封装成自定义指令或组件很顺手。以 Vue 3 为例,做一个 CountUp 组件:
vue复制<template>
<span :ref="setRef" class="count-up-number"></span>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { CountUp } from 'countup.js';
const props = defineProps({
endVal: { type: Number, required: true },
duration: { type: Number, default: 2 },
options: { type: Object, default: () => ({}) }
});
const el = ref(null);
let countUp = null;
let observer = null;
function setRef(e) {
el.value = e;
}
onMounted(() => {
countUp = new CountUp(el.value, props.endVal, {
duration: props.duration,
separator: ',',
...props.options
});
// 进入视口再启动
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
countUp.start();
observer.unobserve(entry.target);
}
}, { threshold: 0.4 });
observer.observe(el.value);
});
onBeforeUnmount(() => {
if (observer) observer.disconnect();
});
</script>
React 中也可以用 useRef + useEffect 做类似封装,核心原理完全一致,只是在组件生命周期处理上略有差异。思路就是:创建实例、观察视口、卸载时清理。这里不做展开,React 读者按照同样逻辑迁移即可。
4. 踩坑记录:我实际遇到过的典型问题与排查思路
4.1 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 数字不动 | target 元素没找到或实例化失败 | 检查 id 是否存在;在 DOM 渲染完成后再实例化 |
| 动画直接跳到终值 | duration 设置过短或出现 JS 错误 | 检查 console 报错;尝试调大 duration 验证 |
| 千分位不生效 | separator 没配置 | 显式传 separator: ',' |
| 小数显示乱 | decimalPlaces 和 decimal 没配套设置 | decimalPlaces: 2 时 decimal: '.' 保持一致 |
| 百分比显示不对 | 数值本身是小数但没乘 100 | 确认输入数值是 99.8 还是 0.998,统一口径 |
| 滚动触发只生效一次 | observer.unobserve 后被卸载 | 如果想重复播放,用 reset 而不是重新实例化 |
| 单页应用路由切换后报错 | 组件卸载后动画仍请求更新 | 卸载时调用 countUp.reset() 或设置全局销毁标志 |
| 大屏低端机掉帧 | duration 过长 + 大量实例同时播放 | 控制同时播放入口或降低 duration |
4.2 坑一:SSR 或组件初始化时报 “window is not defined”
CountUp.js 底层依赖 requestAnimationFrame,而服务端渲染环境里没有 window 对象。在 Next.js 或 Nuxt 中服务端执行到 new CountUp 时会直接抛错。
排查方法:把实例化和 start 放到 useEffect / onMounted 等客户端钩子里,或者在代码里判断 typeof window 是否存在:
javascript复制if (typeof window !== 'undefined' && document.getElementById('amount')) {
const countUp = new CountUp('amount', 1000);
countUp.start();
}
4.3 坑二:实时数据更新时数字跳动不自然
我遇到过一个运营平台的需求,每 5 秒刷新一次用户数。一开始直接在回调里 new 一个新实例,结果每次数字都从 0 重新滚,用户反馈特别突兀。后来改成不管数据怎么变,始终只保留一个 CountUp 实例,数据更新时调用 update(新值)。这样是从当前显示值开始平滑过渡,体验明显改善。
javascript复制// 错误示范:每次都新建实例
function refreshData(value) {
const c = new CountUp('amount', value);
c.start(); // 每次从 0 开始,跳动明显
}
// 正确示范:复用同一个实例
let countUpInstance = null;
function initCountUp() {
countUpInstance = new CountUp('amount', 0, { duration: 1.5 });
countUpInstance.start();
}
function refreshData(value) {
if (countUpInstance) {
countUpInstance.update(value);
}
}
这里的逻辑关键是“一个数字一个实例,持续复用”,而不是“每次数据更新就建一个新实例”。
4.4 坑三:多数字大屏同时启动导致卡顿
数据大屏通常有 6~10 个数字卡。如果所有数字都在同一帧启动动画,低端机上容易出现卡顿。解决思路有三层:
第一层:错峰启动。 给每个数字的启动时间加一点延迟,比如第 N 个数字延迟 N * 200ms。
javascript复制const cards = [
{ id: 'card1', value: 1000, delay: 0 },
{ id: 'card2', value: 2000, delay: 200 },
{ id: 'card3', value: 3000, delay: 400 }
];
cards.forEach(item => {
setTimeout(() => {
new CountUp(item.id, item.value, { duration: 2 }).start();
}, item.delay);
});
第二层:只播可视区域内的动画。 上文的 IntersectionObserver 方案天然满足这一点,首屏外的数字不启动,滚动到才启动,CPU 开销自然小。
第三层:降低 duration。 大屏演示场景里,2.5 秒和 1.8 秒的观感差距不大,但对性能影响不小,特别在低端机或大屏盒子(机顶盒)上更明显。把不重要的辅助数字设置为 1.5 秒,保留核心大数字 2.5 秒即可。
4.5 坑四:数字更新精度问题
有段时间我处理订单金额,金额从 2536.6 滚动到 2536.63,因为 decimalPlaces 默认是 0,看起来就不动了。实际数字确实有在变,但因为精度不够,显示没变化。
解决方案很简单:设置 decimalPlaces 为 2,并且把 startVal 和 endVal 保持在同一数量级。如果 endVal 是小数,startVal 也设置成小数,比如 0.0,否则起始 0 到 0.01 的过渡过程中可能因为浮点精度产生微小跳动。
4.6 交互体验细节:动画时长与缓动节奏的调参心得
这里分享一些我调过很多次之后沉淀的经验值:
- 官网轻展示型数字:1.5~2 秒,缓动用默认 easeInExpo 就行
- 数据大屏核心大数字:2.5~3 秒,可以让数字“稳稳地爬上去”,配合大屏氛围
- 实时刷新数据:1.2~1.8 秒,太长了会让人感觉数据卡顿
- 整屏滚动的页面:优先保证滚动顺畅,动画时长偏短为妙,因为用户滚动节奏快,动画没播完就被滚出视口,体验反而差
提示:如果希望数字先慢后快,可以自定义 easingFn。CountUp 里传入一个函数即可,函数接收 (t, b, c, d) 四个参数,t 是当前时间,b 是起始值,c 是变化量,d 是总时长。参考常见的缓动公式就能定制。
5. 项目落地与扩展:数据看板数字动画的整体设计思路
5.1 数字动画在可视化项目中的定位
在项目里,数字动画属于“锦上添花”的部分,绝不是核心功能。核心指标数据本身的准确性、实时性、视觉层级才是更优先的设计点。动画的作用是引导视线、提升感知,不应该让人等太久,也不应该喧宾夺主。
我一般会遵循一个原则:一屏里的动效数量适中,核心数字(营收、用户量、订单量)用动画突出,辅助数字(如增长率、均值)尽量静态或轻微效果。全屏数字都在滚,反而没有重点。
5.2 与图表库配合时的注意事项
在实际项目中,CountUp.js 常和 ECharts、AntV 等图表库配合。比如仪表盘里的数值显示可以用 CountUp,同时 ECharts 图表更新数据。这里有一个小坑:图表容器尺寸发生变化时,ECharts 会重新渲染,但 CountUp 所在的 DOM 如果被图表容器包裹,重绘时可能把 CountUp 写入的文本覆盖掉。
建议把数字动画的 DOM 独立出来,不要和图表 canvas 混在一起。例如 ECharts 的 title 里不要放 CountUp 的 span,否则每次 setOption 都会重置。
html复制<!-- 推荐结构 -->
<div class="dashboard-card">
<div class="card-title">今日营收</div>
<div class="card-value"><span id="revenue"></span></div>
<div class="chart-container" id="revenueChart"></div>
</div>
5.3 无障碍与降级处理
动画对某些用户可能造成干扰,特别是开启了“减少动效”系统设置的用户。贴心的做法是检测 prefers-reduced-motion,如果用户偏好减少动态效果,就直接渲染最终值,不做动画。
javascript复制const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
const el = document.getElementById('amount');
el.textContent = 2560000.toLocaleString();
} else {
new CountUp('amount', 2560000, { duration: 2 }).start();
}
这段代码虽然简单,但在企业级项目评审时经常是加分项,也体现了对用户体验细节的重视。
6. 写在最后的个人经验
做可视化项目这几年,我的体感是:小库小工具用得好不好,关键不在 API 背得熟不熟,而在于你踩过多少坑、有没有沉淀出自己的使用套路。CountUp.js 的坑不算多,但只要踩到一个,排查起来都挺费时间。
我给新人的建议是:先把官方 demo 完整跑一遍,再看一遍 options 源码里的默认值,最后再把滚动触发和 update 复用的案例自己写一遍——这三步做完,基本能覆盖绝大多数真实业务场景。
最后分享一个亲身经历的小技巧。在做一个官网的“服务客户数量”区块时,客户希望能展示“已服务客户 + 正在新增客户”的感觉。我用 CountUp 展示了累计客户数,然后在旁边做了一个小的“近 1 小时新增”的数字,每 30 秒调用 update(新增值) 从 0 过渡到新值。这种“静态大数字 + 动态小数字”的搭配,比两个大数字都滚动效果更好,因为用户的视觉焦点不会分散。这个思路后来也被我用在了好几个后台看板上,反馈都很好。
数字动画说到底是为数据叙事服务的,方法千万条,场景匹配才是第一条。希望这篇内容能帮你把数字“转得更顺”,少走几步我当年走过的弯路。
