如果你做过数据可视化、后台报表、运营大屏这类项目,十有八九会遇到这个需求:页面加载完,一组核心指标从0开始滚动增长到目标值,或者用户把页面往下滚到某块区域时,数字才在进入视口的一瞬间“跳”起来。这种效果叫数字滚动动画,在数据分析页面里尤其常见,配合缓动曲线,整个页面看起来像是有生命一样。
CountUp.js 就是专门干这件事的轻量级JavaScript库。它的核心能力只有一项,但做得非常极致:把一个数字从指定的起始值平滑递增到目标值,期间自动处理千分位分隔、小数位、前后缀、缓动函数,既可以用在商品销量、GMV、用户数、完成率这些KPI卡片上,也能接在图表刷新的数值变更上。不管你是做管理后台的前端,还是专职数据可视化,或者只是想在个人博客里加一点数字动效,这个库都值得放进你的工具箱。
1. 项目概述与核心思路
1.1 数字为什么需要“动起来”
先聊一个看似多余的问题:页面上的统计数字,直接渲染出来不行吗?答案是不太够。
从用户体验的角度看,一个静态的数字只是结果,但人对“变化过程”更敏感。用户从500万跳到600万,大脑需要时间去理解这个数字有多重要,而动画的作用就是把这个“变化过程”可视化:用一种可控的节奏引导用户的视线,让用户盯着数字从0涨到500万,实际上是在告诉用户“这个数据正在实时增长,非常关键”。
这在数据分析场景里尤其重要。KPI大屏、经营报表、用户增长看板,核心指标往往就那么几个数字。用户第一眼看到的是数字的“跳动过程”,这比一张静态表格更有说服力,也更符合大屏展示的仪式感。我做过的一个项目里,产品经理的原话是:“数字静态放在那里,大家扫一眼就过了;做成滚动动画之后,领导进来看第一眼就会被这几个核心指标吸引住。”本质上,数字滚动动画是一种注意力引导工具。
1.2 为什么要选 CountUp.js 而不是自己写
你可能会有疑问:这个动画效果,用 setInterval 或者 requestAnimationFrame 自己写一个,也不难啊。确实,一个简单的数字递增循环只要几十行代码就能写完。但真实项目里,数字动画远不止“循环加一”这么简单:
- 0.1 + 0.2 这种浮点精度问题,处理不好数字会变成一长串小数;
- 大数字需要千分位分隔,直接 toLocaleString 在不同浏览器行为不一样;
- 数字可能带前缀(¥、$)和后缀(%),还可能带小数位;
- 动画全程用户可能暂停、刷新数据、切换组件,你需要提供对应的控制方法;
- 缓动曲线不同,观感差异巨大,自己调很难调到舒服的手感。
CountUp.js 把这堆问题全部封装好了。核心库没有任何依赖,gzip 后大约 2.5KB 左右,支持 script 标签引入,也支持 ESM、CommonJS,甚至配套了 Vue、React、Angular 的封装版本。API 设计得也比较干净,初始化一个实例,调 start() 开始动画,调 update() 更新目标值,调 reset() 重置回起点,没有学习成本。
我自己在项目里用过不少动画库,这类数字滚动的库不算多,CountUp.js 是维护最活跃、API 最稳定的一个。它的定位就是“小而专”,不引入一堆用不上的功能,正好符合“只解决一个问题,并且解决好”的原则。
1.3 边界:它能做什么,不能做什么
说得务实一点,CountUp.js 只负责数字本身的动画,它不是一个图表库。你不能拿它画折线图、柱状图,它也不会自动去请求接口。它更适合作为可视化体系里的一层“皮肤”,跟 ECharts、Chart.js 这类图表库搭配使用。
举几个实际场景:
- 页面顶部的核心指标卡(PV、UV、订单量、销售额)从0滚动到真实值;
- 某个图表局部变化后,图表上的数字标签跟着做一次 update 动画;
- 排行榜或者进度条旁边的数值,随滚动进入视口触发动画;
- 前端做 A/B 实验时,用 update() 平滑过渡到新的目标值。
这个库的适用人群也很明确:前端开发者、可视化工程师、做数据大屏的项目团队,以及任何想在页面上加数字动效的技术爱好者。你不需要懂图形学,也不需要数学基础,它会用默认的 easeOutExpo 缓动函数给你一个很舒服的动画手感。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 一次初始化和它的全部参数
CountUp.js 的用法可以浓缩成一行核心逻辑:实例化的时候指定目标元素、目标值、可选配置,然后调用 start()。构造函数签名长这样:
js复制const countUp = new CountUp(target, endVal, options);
第一个参数 target 可以是元素的选择器字符串,也可以是 DOM 元素本身。需要注意一点:目标元素内部需要有一段文本节点,因为库会通过 textContent 来替换显示内容。如果元素是空的或者内容不是一个数字字符串,虽然不一定报错,但动画初始值会不太可控。
第二个参数 endVal 是最终要滚到的数字。第三个参数 options 是可选的,下面的表格整理了我在实际项目里最常用的几个配置项:
| 配置项 | 默认值 | 作用说明 |
|---|---|---|
| startVal | 0 | 动画的起始值,通常保持默认即可 |
| duration | 2 | 动画总时长,单位秒 |
| decimalPlaces | 0 | 保留的小数位数 |
| separator | '' | 千分位分隔符,常见 ',' |
| decimal | '.' | 小数点符号,某些国家用 ',' |
| prefix | '' | 数字前面的字符,如 '¥' |
| suffix | '' | 数字后面的字符,如 '%' |
| useGrouping | true | 是否启用千分位分组 |
| useEasing | true | 是否使用缓动函数 |
| easingFn | easeOutExpo | 自定义缓动函数 |
| formattingFn | undefined | 完全自定义数字格式化逻辑 |
| useIndianSeparators | false | 印度数字分组格式(3-2-2规则) |
说白了,起始值决定从哪开始动,duration 决定跑多快,separator 和 prefix/suffix 决定数字怎么“打扮”。这些参数组合能覆盖绝大多数业务场景,不用自己写一行格式化代码。
2.2 几个关键参数背后的“为什么”
有些参数看着简单,但实际用起来有讲究,我挑几个容易踩坑的展开说。
duration 的单位是秒,但这个值并不是越短越好。大屏展示场景 2 秒左右比较合适,太短用户看不清数字变化,太长用户会失去耐心。品牌展示页可以调到 2.5 到 3 秒,显得更从容。我自己测试下来,超过 3 秒的动画会让人明显感觉“有点拖”,所以默认 2 秒其实是一个很稳妥的手感。
decimalPlaces 决定小数位数。很多做占比类指标的人容易漏掉这个。比如要显示完成率 98.56%,期望是 98.56% 而不是 99%,因为默认 decimalPlaces 是 0,数字会被四舍五入成整数,动画过程中会看到 0、33、66、99 这样的大跨度跳变。配合 separator 和 decimal 一起设置,才能让数字的增长看起来又细又顺。
还有两个容易忽略但实用的参数:smartEasingThreshold 和 smartEasingAmount。这两个是配合用的,它们解决的是“数字从0涨到几百万,easeOutExpo 会前几帧增长太猛”的问题。默认的 easeOutExpo 缓动曲线是“先快后慢”,但如果终点值特别大,比如 1000 万,动画前 200ms 可能直接就跳到了 500 万,视觉上跟“唰”地一下没什么区别,完全失去了滚动感。smartEasing 就是先把前段的一部分动画切换成线性增长,后面再切回缓动,让大数字也能慢慢爬上去。设置方法:
js复制{
smartEasingThreshold: 999, // 目标值超过这个值就启用智能缓动
smartEasingAmount: 333 // 前333ms用线性增长
}
这个组合我强烈建议大屏项目都加上,效果差距非常明显。
2.3 四个核心方法,控制动画的整个生命周期
CountUp.js 实例化之后并不是立刻跑起来,而是提供了一组控制方法,让你完全掌控动画节奏:
- start():启动动画。这个方法返回一个 Promise,动画结束后 resolve,很适合在动画完成后接一些后续逻辑。
- pauseResume():暂停或恢复动画。比如用户鼠标悬停时暂停,移开后再继续。
- reset():重置回起始值。组件卸载或者重新展示时经常用到。
- update(newVal):将目标值改为 newVal 并重新执行动画,这是数据刷新场景的核心方法。
实际业务里,start() 和 update() 的使用频次最高。start() 负责初次进入页面时的展示动画,update() 负责解决“数据变化后怎么平滑过渡”的问题。比如一个图表下方显示当前选中区域的总销售额,用户切换筛选条件时,数值应该从旧值平滑过渡到新值,这就是 update() 的典型用法,而不是重新 new 一个实例。
start() 之后检查实例的 error 属性也很重要。如果初始化的元素找不到、endVal 不是合法数字等,错误会被记录在 error 里,不会抛异常中断页面。官方文档的示例代码也有这句判断:
js复制const countUp = new CountUp('counter', 5200);
if (!countUp.error) {
countUp.start();
}
我习惯把这个 error 判断也用在 update() 之前,保证数据链路的确安全。
3. 实操演示:从零实现一个带滚动动画的 KPI 卡片
3.1 安装与引入:两条路线自己选
CountUp.js 内置了两个版本。一个是带框架封装的,另一个就是纯核心库。纯核心库既可以通过 npm 安装,也可以通过 CDN 引入。
先看 npm 方式:
bash复制npm install countup.js
然后在模块代码里引入:
js复制import { CountUp } from 'countup.js';
如果是简单的 HTML 页面,或者只是想快速验证效果,直接用 CDN 更省事:
html复制<script src="https://cdn.jsdelivr.net/npm/countup.js@2.8.0/dist/countUp.min.js"></script>
注意版本号建议写死,不要用 latest 之类的不固定版本,避免 CDN 缓存或者升级带来的兼容性问题。引入之后,全局会挂一个 CountUp 类,直接使用即可。
3.2 最简实现:一个从0涨到5200的计数器
现在动手写一个最小的例子。页面上放一个 <span> 元素,给它一个 id:
html复制<span id="counter">0</span>
然后执行:
js复制const countUp = new CountUp('counter', 5200, {
duration: 2,
separator: ','
});
if (!countUp.error) {
countUp.start();
}
这里做了三个关键设置:目标值是 5200,动画时长 2 秒,千分位分隔符是逗号。动画跑起来后,用户会看到数字从 0 开始,经过一个“先快后慢”的加速过程,最终停在 5,200。
这段代码虽然短,但已经覆盖了 CountUp.js 的完整工作流程:示例化参数 → 检查 error → 启动动画。真实项目里,endVal 通常来自接口返回值,而不是写死的。只要把 5200 替换成 fetch 出来的数据就行。
3.3 滚动进入视口时触发动效:用 IntersectionObserver
页面顶部放一个数字卡片没问题,但如果页面上有多个数字模块,分布在首屏、中部、底部,你觉得一进入页面就全部同时开播好看,还是滚到哪个模块哪个模块再动好看?答案显然是后者。一次只让用户关注一个动画,视觉焦点更清晰,也更有“层层递进”的感觉。
传统做法是监听 window 的 scroll 事件,然后手动判断元素位置。这个方案有两个问题:一是 scroll 事件触发频率极高,需要自己加节流;二是手动计算位置逻辑通用性差。现在更推荐用 IntersectionObserver,浏览器原生 API,不用自己监听滚动,性能开销也更小。
实现逻辑不复杂:页面加载时不立刻 start(),而是先用 IntersectionObserver 观察目标元素,当元素进入视口且超过 50% 可见时,才触发 start(),然后立即 disconnect 停止观察。
完整代码:
html复制<span id="kpi" class="kpi-value">0</span>
js复制const countUp = new CountUp('kpi', 86400, {
duration: 2.5,
separator: ','
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
countUp.start();
observer.disconnect();
}
});
}, {
threshold: 0.5
});
observer.observe(document.getElementById('kpi'));
threshold 控制触发时机,0.5 表示元素一半可见时触发。使用 0.1 或者 0.2 会更早触发,具体看你的页面结构。我一般选 0.3 到 0.5 之间,太早的话,用户还没看清内容数字就开始跳了;太晚的话,用户滚动到元素时看到的是已经结束的静态数字,少了那一下“跳起来”的冲击力。
3.4 动态刷新:接口返回新数据后平滑过渡
另一个高频场景是数据实时刷新。比如运营大屏每 5 秒请求一次接口,拿到新的订单量之后,数字要从当前值平滑过渡到新值,而不是整个组件重建。
这里的关键是“同一个实例,调用 update()”。看代码:
js复制const countUp = new CountUp('realtime-order', 0, {
duration: 1.5,
separator: ',',
prefix: '¥'
});
countUp.start(); // 首次启动,从0到初始值
// 之后每次接口返回新数据
async function refreshData() {
const res = await fetch('/api/orders');
const data = await res.json();
countUp.update(data.totalAmount);
}
update() 会从当前显示的数字开始,在一段新动画里平滑移动到新目标值。这个过程中,起始值不需要你手动传,库内部会取“当前渲染值”作为起点,体验上就是“从 120 万涨到了 135 万”,而不是从头再滚一遍。
要注意的点是接口刷新频率和动画时长的匹配。如果接口每 2 秒返回一次数据,而动画时长为 3 秒,那么动画还没播完,下一次 update 就来了。这时候 CountUp.js 会中断上一次动画,按新的目标值重新开始,视觉上数字会有一个突然跳变,反而不如不做动画。我的经验是 duration 尽量设置为刷新间隔的 1/3 到 1/2,既有足够的滚动过程,又确保动画能在下一轮数据到来前结束。
3.5 接入图表库:把 CountUp 塞进 ECharts 的 formatter
单独的数字滚动只是最基础的需求,更常见的是图表里的数字标签也跟着动。这里给一个和 ECharts 结合的思路,我实际用过的方案。
场景举例:ECharts 柱状图顶部显示每个柱子的具体数值,希望这些数值在图表初始化时从 0 滚动到最终值。
新建一个实例,在 ECharts 的 label.formatter 里返回当前显示值,然后利用 CountUp 的 update 去更新:
js复制const chart = echarts.init(document.getElementById('chart'));
// 模拟每个柱子对应的 CountUp 实例
const counters = data.map((value) => {
const counter = new CountUp(0, value, {
duration: 1.5, separator: ','
});
return counter;
});
// 在 formatter 中通过回调使用计数器当前值
// 注意:formatter 是渲染阶段的函数,不能在这里直接执行动画
// 实际做法是:先开启动画,动画结束时再 setOption 刷新一次
counters.forEach((counter) => {
counter.start().then(() => {
chart.setOption({
series: [{
label: { formatter: () => counter.endVal.toLocaleString() }
}]
});
});
});
为了让动画和图表同步,我通常的做法是:动画进行中时用一个 interval 更新 chart 的 option,用计数器当前值作为 label 文本;动画结束后清除 interval。这个方案稍微绕,但效果很好。
4. 常见问题与排查技巧实录
4.1 错误速查表
我这几年在实际项目里用 CountUp.js,前前后后踩过不少坑,整理成一张表格,希望你能少走点弯路:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 控制台报 “Target element not found” | 实例化时元素还没渲染完成 | 把初始化放到 DOMContentLoaded 或 onMounted 之后 |
| 数字显示为 NaN | endVal 不是数字类型 | 检查接口返回值,做 Number() 转换和 isNaN 判断 |
| 数字不滚动直接跳到最终值 | 元素初始内容为空或不是数字 | 在目标元素里预填一个起始数值,如 0 |
| 千分位没生效 | 忘记设置 separator | options 里加 separator: ',' |
| 小数显示不全 | decimalPlaces 没设置 | 设置 decimalPlaces: 2 |
| 数字从负数开始滚 | startVal 配置失误 | 显式指定 startVal: 0 |
| 动画在部分低版本浏览器不执行 | requestAnimationFrame 兼容问题 | 引入 rAF polyfill,或升级浏览器 |
| update() 没反应 | 调用的不是同一个实例 | 把实例保存到变量或 ref 中复用 |
这里面最常见的还是元素没渲染完成就初始化,导致实例找不到目标。在 Vue 的 mounted 里初始化,在 React 的 useEffect 里初始化,一般就能避开。
4.2 组件化和 SSR 场景的两个注意点
如果你在 React 或 Vue 项目里用 CountUp.js,有两个细节值得注意。
第一个是组件卸载时停掉动画。如果组件在动画进行中被销毁,实例里的 requestAnimationFrame 还在跑,就会报 “setState on unmounted component” 之类的警告,极端情况下还会内存泄漏。解决办法是销毁前调用 reset() 或 cancelAnimationFrame。比如 React 的 useEffect cleanup 函数里:
js复制useEffect(() => {
const counter = new CountUp('counter', 1000);
counter.start();
return () => counter.reset();
}, []);
Vue 里在 onUnmounted 钩子里做同样的事。
第二个是服务端渲染(SSR)场景。CountUp.js 依赖浏览器环境,在服务端执行时会报 window is not defined。解决办法很简单:只在客户端执行初始化逻辑。Next.js 里可以放在 useEffect 里,Vue 里可以放在 onMounted 里,不要放在模块顶层执行。也可以动态导入,确保代码只在客户端运行。
4.3 大屏和后台环境的细节优化
最后分享几个我多次测试后总结的优化习惯。
第一,动画结束后停在最终值。CountUp.js 本身不会循环播放,这其实是个优点。千万不要在回调里又调一次 start(),做成了无限循环动画会很烦人。数据大屏上每次接口返回新数据,用 update() 更新一次就够了。
第二,浏览器标签页切走再切回来时,动画可能直接跳到结束。因为 requestAnimationFrame 在标签页不可见时会暂停,切回来时浏览器可能直接追帧到当前时间。如果你希望用户切回来时看到完整动画,可以监听 visibilitychange 事件:
js复制document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
countUp.reset();
countUp.start();
}
});
不过这个要看业务需求,很多场景下“切回来直接显示最终值”反而是更合理的行为,毕竟用户关心的是结果,不是过程。
第三,不要在一屏里同时开太多动画。十几个 KPI 卡片同时滚动,虽然技术上没问题,但视觉上会很乱。我一般会用 IntersectionObserver 控制不同区域的动画触发时间,或者给每个实例指定不同的起始 delay,错峰启动。
第四,格式化函数可以把数字变成你想要的任何文本,不只是加前缀后缀。比如用 formattingFn 把 12000 显示成“1.2万”,这个在大屏上很实用,因为原始数字太长了会撑破布局:
js复制const countUp = new CountUp('count', 12000, {
formattingFn: (value) => (value / 10000).toFixed(1) + '万'
});
我个人在实际项目里的习惯是:能用 CountUp.js 的配置项解决的,不写自定义函数;但遇到“万”“亿”这种中文单位需求时,formattingFn 几乎是必须的。这也是这个库做得好的地方——它没有把所有格式化逻辑硬编码,留了一个口子让你介入。
最后再分享一个真实体会:数字动画是加分项,不是必需品,别滥用。核心指标卡、数据变化反馈、大屏展示,这几个场景用起来很加分;但如果你只是在一个普通表格里给排序序号加动画,用户反而会觉得莫名其妙。做设计取舍的时候,优先考虑“用户在不在看”“数据变化有没有意义”这两个问题。动画不是为了炫技,是为了让用户更快地理解数据的重要性。
