做数据可视化大屏的朋友应该都体会过这种尴尬:后端数据千辛万苦接好了,图表库也渲染出来了,结果页面上一串干巴巴的数字杵在那儿,跟旁边带坐标轴、带动画曲线的图表放在一起,像是两个时代的东西。数字没有动效,用户的视线根本停不下来。后来我在几个可视化项目里反复用 CountUp.js 来解决这个问题——让数据分析面板里的关键指标从 0 或者任意指定值平滑递增到目标值,页面滚动到数字区域时再触发播放,配合大屏整体的入场节奏,观感完全不一样。
这篇文章不是简单翻译一遍官方 README,而是把我从第一个 Demo 到多个生产项目里沉淀下来的经验完整写出来。会覆盖它的工作原理、在原生 / Vue / React 三种环境下的接入姿势、滚动触发、格式化、异步数据的坑、多实例性能优化,以及我会在什么场景下故意不用它。适合正在做数据可视化大屏、数据分析后台、营销活动页数字动效的同学,尤其是被"数字动起来"这个需求逼过一把的人。
1. 数字动画在数据可视化里的价值与适用场景
1.1 为什么静态数字撑不起可视化大屏
很多人会觉得数字动画是锦上添花的"花活",项目排期紧的时候第一个砍掉的就是它。但如果你真正盯过一块数据大屏或者一个数据分析报表页面,会发现数字是用户注意力停留最久的地方。
人眼对静止的大数字是麻木的。屏幕上一个"3,284,761"和旁边一个"3,284,762",如果不仔细看,你根本不知道数据在变。但数字从 3,284,000 平滑跳到 3,284,761 的过程中,人的视觉系统会自动捕捉这个变化轨迹,大脑会把这个动态过程解读为"数据正在增长、系统是活着的"。这个感知差异在运营监控大屏、实时销售看板、年度报告页面上体现得非常明显。
另外一个常被忽略的点是叙事节奏。数据分析页面通常是一组指标 + 一组图表,如果所有元素都在同一刻静态呈现,信息层级是平的。给核心指标加上递增动画,就相当于给页面加了一个"先说哪个数字"的视线引导。配合图表动画一起播放,用户的浏览顺序就跟着你的设计意图走了。
1.2 CountUp.js 到底解决什么问题
CountUp.js 的核心能力非常聚焦:让一个数字从起始值平滑过渡到目标值,期间你可以控制时长、缓动曲线、千分位分隔符、前缀后缀、小数位,还可以在滚动到可视区域时自动启动。
它解决的并不是"数字变换"本身——一个 setInterval 加个更新函数也能做——而是把抖动、卡顿、格式化、边界情况这些细节都处理好的问题。我记得早期自己写过一个类似的计数器,看起来很简单,实际跑起来全是坑:setInterval 在浏览器标签页切到后台时会掉帧甚至暂停;数字到了目标值之后没清定时器导致内存泄漏;千分位格式化之后再做减法直接返回 NaN;小数位数在递增过程中四舍五入导致最后一位永远到不了目标值……这些问题 CountUp.js 在设计时基本都想到了,而且源码就 2000 多行,读起来不费劲,出了问题也容易排查。
它的第二个价值是体积和零依赖。整个库压缩后只有不到 20KB,不依赖 jQuery、不依赖任何图表库,原生 JavaScript 写的,任何前端项目都能塞进去。这一点在可视化大屏项目里特别重要,因为大屏往往已经把 ECharts、地图、视频流塞了一堆,再引入一个重的动画库,性能上得不偿失。
1.3 什么场景适合用,什么场景别硬用
我用下来觉得适合用 CountUp.js 的场景有几类:
- 指标卡 / KPI 面板:销售额、用户数、转化率这类核心指标,单数字展示,动画效果最直接。
- 年度报告、数据简报类 H5 页面:滚动驱动的叙事型页面,数字随滚动逐段出现,节奏感很强。
- 可视化大屏的入场动效:大屏首次加载时,让多项指标依次递增,营造数据流动的仪式感。
不建议硬用的场景也很多。首先是实时刷新的高频数据,比如每秒都在变的股票价格、延时数据,这种场景用平滑递增动画反而会造成认知负担,用户需要的是实时的数字刷新而不是动画。其次是超大数字跨度且时长很短的情况,比如从 0 到 99 亿只用 0.5 秒,动画会糊成一团,观感极差。最后是长列表里每个单元格都用同样的动画,看起来很花哨,而且性能吃不消,这种一般只给首屏或者最顶部的几条做动画就够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂它的原理:一个计数器是怎么动起来的
2.1 核心机制:requestAnimationFrame 驱动的插值循环
CountUp.js 的底层逻辑其实不复杂:拿到起始值 startVal 和目标值 endVal,在指定的 duration 秒内,每一帧根据当前时间进度算出一个中间值,然后更新到 DOM 里。
它的核心循环用的是 requestAnimationFrame(rAF),而不是 setInterval。这个差别很关键。rAF 是浏览器原生提供的动画帧回调机制,它会在浏览器每次重绘之前调用你注册的回调函数,频率通常匹配屏幕刷新率(60Hz 或 120Hz)。好处有三:一是动画帧数和屏幕刷新率同步,不会出现丢帧或撕裂;二是浏览器标签页隐藏或切到后台时,rAF 会自动暂停,不会像 setInterval 那样在后台空转消耗 CPU;三是多个 rAF 会被浏览器统一调度,性能更可控。
每一帧里做的事可以用这段简化的代码理解:
javascript复制function tick(timestamp) {
// 根据当前时间和总时长计算进度,范围 0~1
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1);
// 把进度套进缓动函数,得到实际应该展示的插值进度
const easedProgress = easingFn(progress);
// 在起始值和目标值之间做线性插值
const currentValue = startVal + (endVal - startVal) * easedProgress;
// 格式化并写入 DOM
element.textContent = formattingFn(currentValue);
// 没到终点就继续下一帧
if (progress < 1) {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
插值公式 startVal + (endVal - startVal) * easedProgress 是最容易理解的线性插值,但因为套了一层 easingFn,所以最终呈现出来的效果不是匀速,而是带加速减速的流畅曲线。
2.2 为什么不用 CSS transition 就完事
你可能会问:既然只是数字从 A 变到 B,CSS transition 不是也能做吗?比如对 textContent 肯定不行,因为 CSS 只能过渡可插值的 CSS 属性,比如 transform、opacity,而对元素文本内容的数字变化无能为力。
如果把数字渲染到 canvas 或者 SVG 的 <text> 里,确实可以用 CSS 变量配合 transition 做,但那是另一套复杂度的工程:得先把数字拆成单个字符或者用 Web Animations API 操作 textContent 的快照,成本和收益完全不成比例。
而且 CSS transition 的插值只支持线性或预设的贝塞尔曲线,没法做到"快到目标值时平滑减速停在整数上"这种细腻控制。CountUp.js 里的 easingFn 是任意 JavaScript 函数,你甚至可以自研一条完全贴合品牌气质的缓动曲线。再加上格式化输出(千分位、前缀、后缀、小数位)本身就是 JavaScript 的活,所以这个场景下专门用一个轻量库是合理的。
2.3 v1 与 v2 的 API 变化,老项目迁移要注意
CountUp.js 目前在 npm 上的稳定大版本是 2.x,和早期的 1.x 在 API 上有一些明显差异,如果你接手的是老项目,这一点必须先确认清楚。
v1 时代最常用的写法是 new CountUp(elementId, endVal, options),然后调用 start()、pauseResume()、reset()、update(endVal) 这些方法,这些在 v2 里大部分还保留着,但细节有变化。v2 的构造参数改成了 new CountUp(target, endVal, options),第一个参数除了元素 ID,也可以传 DOM 元素、甚至一个回调函数。最重要的是 v2 引入了一些新选项,比如 enableScrollSpy 和 scrollSpyDelay,可以内置完成滚动到可视区域再播放的逻辑,不用自己写 IntersectionObserver 了。
如果你之前用过 v1,迁移时最需要注意的就是 update() 方法的语义变化。v1 里 update(newVal) 会从当前值平滑过渡到新值;v2 里 update(newVal) 的默认行为同样是平滑过渡,但它还多了第三个参数 forceDuration,可以强制指定这次更新的动画时长。另外 v2 对 startVal 的处理更严格,如果你的起始值带了千分位字符串或者非法字符,构造实例后会有一个 error 标记,后续调用 start() 会静默失败,这个坑我们后面专门讲。
3. 从零接入:原生、Vue、React 三种环境的落地方式
3.1 原生 HTML 页面五分钟跑通
如果你的页面就是个纯静态 HTML,或者用 CDN 方式引入脚本,接入是最快的。记得用 v2 的 UMD 包:
html复制<script src="https://cdn.jsdelivr.net/npm/countup.js@2.8.0/dist/countUp.umd.js"></script>
然后写一个承载数字的元素和一个启动脚本:
html复制<span id="totalSales">0</span>
<button id="startBtn">开始</button>
<script>
const countUp = new CountUp('totalSales', 3284761, {
duration: 2.5,
separator: ',',
});
// 构造完成后一定要检查 error
if (!countUp.error) {
countUp.start();
} else {
console.error(countUp.error);
}
</script>
看到这段代码,第一反应别急着复制,先理解几个点。new CountUp 的第二个参数是目标值,默认的起始值是 0,duration 单位是秒,separator 是千分位分隔符。构造实例时如果目标元素找不到、或者 endVal 不是合法数字,countUp.error 会被赋值成错误信息,所以永远要在调用 start() 前检查它,否则动画会静默失败,页面完全看不出问题,只在控制台有一条警告。
这里有个小技巧:如果你希望数字从任意指定值开始,比如从 1000 递增到 5000,就把 startVal 放进 options:
javascript复制const countUp = new CountUp('totalSales', 5000, {
startVal: 1000,
duration: 2,
});
countUp.start();
3.2 Vue 组件里怎么封装更省心
Vue 里用 CountUp.js 的关键在于把实例的生命周期交给 Vue 管。我用 Vue 2 和 Vue 3 都写过,核心逻辑一致,但 Vue 3 组合式 API 更适合复用。
一个基础的做法是在 mounted 里创建实例、在 watch 里监听数据变化并调用 update(),同时组件销毁时把动画停了防止内存泄漏:
javascript复制// Vue 3 组合式封装示例
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { CountUp } from 'countup.js';
export function useCountUp(target, endVal, options = {}) {
const instance = ref(null);
onMounted(() => {
instance.value = new CountUp(target, endVal.value || 0, options);
if (!instance.value.error) {
instance.value.start();
}
});
watch(endVal, (newVal) => {
if (instance.value && !instance.value.error) {
instance.value.update(newVal);
}
});
onBeforeUnmount(() => {
// 组件销毁时停止动画并解绑
if (instance.value) {
instance.value.reset();
instance.value = null;
}
});
return instance;
}
在一个可视化大屏项目里,这种封装可以配合一个指标卡片组件用。每个指标卡片接收 title、value、format 这些 props,内部调用 useCountUp,就完成了数据层和展示层的解耦。
Vue 里最容易踩的坑是目标元素还没渲染完成就去构造实例。用 onMounted 没问题,但如果你在父组件里把 endVal 从异步接口拿回来,在模板里的数字元素是 v-if 条件渲染的,得等真渲染完成再创建 CountUp 实例,不然拿到 null 会报错。这种情况下可以配合 nextTick 或者把 CountUp 的创建放到数据返回之后的回调里,而不是放在组件的 mounted 里。
3.3 React 函数组件的 hooks 封装
React 的做法思路类似,但需要注意 React 18 严格模式在开发环境下会执行两次 useEffect,导致 CountUp 实例被创建两次,动画重复启动。
我的推荐写法是把创建实例的逻辑放进 useEffect,并在清理函数里完整销毁:
jsx复制import { useEffect, useRef } from 'react';
import { CountUp } from 'countup.js';
function MetricCard({ value }) {
const spanRef = useRef(null);
const countUpRef = useRef(null);
useEffect(() => {
if (!spanRef.current) return;
countUpRef.current = new CountUp(spanRef.current, value, {
duration: 2,
separator: ',',
});
if (!countUpRef.current.error) {
countUpRef.current.start();
}
return () => {
if (countUpRef.current) {
countUpRef.current.reset();
countUpRef.current = null;
}
};
}, [value]);
return <span ref={spanRef}>0</span>;
}
这里把 value 放进依赖数组,每次数据变化都会重新创建实例并播放动画,这在指标卡场景里是符合预期的——每一次数据刷新都值得一次动画提示。但要注意,如果 value 变化非常频繁,这种写法会频繁销毁重建实例,性能上不划算,更优的做法是创建一次实例,在单独的 effect 里调用 countUpRef.current.update(value),我们后面性能优化部分还会提到。
4. 核心选项逐个过一遍:格式化、缓动、滚动触发
4.1 常用参数表与真实使用案例
CountUp.js 的 options 选项不少,但实际项目里高频用到的基本就这些:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
startVal |
number | 0 | 起始值,可以指定任意数字 |
duration |
number | 2 | 动画时长,单位秒 |
decimalPlaces |
number | 0 | 保留小数位数 |
separator |
string | '' | 千分位分隔符,例如 , |
decimal |
string | '.' | 小数点符号 |
prefix / suffix |
string | '' | 前缀 / 后缀,例如 ¥ / % |
useEasing |
boolean | true | 是否使用缓动函数 |
easingFn |
function | easeOutExpo | 自定义缓动函数 |
useGrouping |
boolean | true | 是否启用千分位分组 |
formattingFn |
function | - | 完全自定义格式化的函数 |
enableScrollSpy |
boolean | false | 滚动到可视区域再启动 |
scrollSpyDelay |
number | 0 | 滚动触发后的延迟毫秒数 |
smartEasingThreshold |
number | 999 | 平滑缓动智能阈值,联动下面这个参数 |
smartEasingAmount |
number | 333 | 大跨度数字时自动缩短动画时长 |
我给一个实际的配置例子,比如电商后台销售数据面板里的"今日销售额",需求是显示 ¥ 1,234,567.89,数字从 0 开始滚到目标值,带缓动,后缀不带任何单位,因为标题上已经写了"销售额":
javascript复制const countUp = new CountUp('sales', 1234567.89, {
startVal: 0,
duration: 2.2,
decimalPlaces: 2,
separator: ',',
decimal: '.',
prefix: '¥ ',
useEasing: true,
});
countUp.start();
这个配置覆盖了 90% 的货币类指标需求。如果你做的是大盘监控,数字经常在小数位之间切换,decimalPlaces 建议固定设置,否则数值会显示成 1,234,567.8 这种不一致的格式,很掉档次。
4.2 滚动到目标值才播放的两种实现
需求方最常提的一句话是:"希望用户滚动到这个数字的位置时,它再开始滚动增长。"
这个需求在 v2 之前需要自己写 IntersectionObserver,v2 之后有内置方案。先看内置方案:
javascript复制const countUp = new CountUp('users', 8848, {
enableScrollSpy: true,
scrollSpyDelay: 300,
duration: 2,
});
countUp.start();
注意,即使启用了 enableScrollSpy,你依然要调用 start(),只是它会等你滚动到目标元素进入可视区域后才真正开始播放。scrollSpyDelay 的 300ms 意思是元素进入可视区后先等 0.3 秒再播,给用户一个"看到数字 -> 意识到要动 -> 动起来"的认知窗口,体验上比一进可视区立刻播放更从容。
但如果你的触发条件更复杂——比如希望数字是页面滚动到某个百分比时触发,或者一个数字要在一个视频播放到特定片段时触发——内置的 scrollSpy 就不够用了,我需要自己写 IntersectionObserver:
javascript复制const target = document.getElementById('users');
const countUp = new CountUp(target, 8848, { duration: 2 });
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
countUp.start();
// 动画只播一次,播完就断开观察,避免重复触发
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.3 }
);
observer.observe(target);
这里最关键的一步是 observer.unobserve(entry.target)。如果不做这一步,用户滚动离开再滚回来,CountUp 会再次从 0 开始播——很多场景下这不是你想要的效果。如果确实希望每次滚回来都重播,可以不清除 observer,但要在回调里先 countUp.reset() 再 countUp.start(),重置后重新播放。这个逻辑我用下来最稳妥的调法是"首次进入播放一次 + 离开可视区重置 + 再次进入播放",适合年度报告那种长页面。
4.3 自定义格式化:数字动了,格式不能乱
最容易被忽略的是格式化问题。默认情况下 separator 只做整数部分的千分位分组,decimalPlaces 控制小数位数,但当你的数字带单位、带百分比、或者要做国际化时,就得用 formattingFn 自控格式。
下面是一个典型场景:目标值是个百分比,比如 87.5%,希望动画过程中始终显示两位小数和一个 % 后缀,但 prefix / suffix 和 decimalPlaces 的分工在极端情况下不够灵活,直接用 formattingFn 反而更清晰:
javascript复制const countUp = new CountUp('rate', 87.586, {
duration: 2,
decimalPlaces: 2,
formattingFn: (value) => {
// 在这里做完全自定义的格式化
return value.toFixed(2).replace('.', '.') + '%';
},
});
countUp.start();
格式化还有一个隐藏坑:startVal 和 endVal 如果带小数,而 decimalPlaces 不设置,动画过程中数字可能显示成一长串小数。比如 new CountUp('el', 3.14, { duration: 2 }),动画中间值会显示 1.5700000000000002 这种结构,这是因为 JavaScript 浮点数运算的经典问题。解决方式很粗暴:要么 decimalPlaces 设置成合理值,要么在 formattingFn 里用 toFixed() 统一处理。这个坑几乎所有第一次用 CountUp.js 的人都会踩到。
5. 实战踩坑记录:更新、销毁、时序问题排查
5.1 数据异步回来后数字不动的真因
这是我见过最多的使用问题:接口数据异步返回之后,CountUp 的数字纹丝不动。
先看错误写法,很多人会这样写:
javascript复制let countUp;
fetch('/api/data')
.then((res) => res.json())
.then((data) => {
countUp = new CountUp('el', data.value, { duration: 2 });
countUp.start();
});
这段代码本身逻辑没问题,问题往往出在 data.value 的类型上。如果接口返回的是字符串 "3284761" 而不是数字 3284761,CountUp 在 v2 里会对 endVal 做类型校验,error 就会被赋值,start() 调用了但不会执行动画。排查方法是先打印 countUp.error:
javascript复制.then((data) => {
countUp = new CountUp('el', data.value, { duration: 2 });
console.log(countUp.error); // 这里会明确告诉你问题在哪
if (!countUp.error) {
countUp.start();
}
})
常见错误信息里有一类是 endVal is not a number 或者精度校验不通过,直接把接口返回值用 Number() 转一下就好。还有一个隐蔽情况:接口返回的是 null 或者空字符串,Number(null) 会变成 0,Number('') 也是 0,动画看起来"没动"是因为本来就是 0 到 0。这种建议在业务层对数据合法性做判断,而不是让 CountUp 吞掉。
5.2 路由切换、组件销毁后的定时器残留
CountUp.js 内部用的是 rAF,如果你在单页应用(SPA)里把它挂在一个组件上,组件销毁了但 rAF 还在跑,浏览器找不到目标元素就会持续报错,严重的情况下会导致整页卡顿。
我记得有一个大屏项目切换路由后整页卡了十几秒,控制台刷出一大堆 Cannot read properties of null,最后定位到是因为 CountUp 实例挂在了一个弹窗组件上,弹窗关闭时组件销毁了但实例没有 reset(),rAF 还在继续尝试更新已经不存在的 DOM。
解决方式其实很简单,就是在组件的卸载生命周期里做四件事:停止动画、重置实例、解绑事件、清空引用。上面 Vue 和 React 封装示例里我已经写了 onBeforeUnmount / useEffect 清理函数的写法,这里再强调一次:reset() 不仅仅是把数字归零,它还会取消当前的 rAF 循环。千万不能只做过期的 instance = null 而不调用 reset(),因为 instance 置空只是释放引用,rAF 的回调里可能还持有旧的闭包。
另外,如果你用了自定义的 IntersectionObserver 做滚动触发,组件销毁时记得也要调用 observer.disconnect(),否则 observer 会持续持有对已销毁 DOM 的引用,在长页面里会造成内存泄漏。
5.3 千分位与小数位组合时的格式坑
千分位分隔符好理解,但"分隔符 + 小数位 + 前缀后缀"组合在一起时,很容易出现格式错乱。
有个真实案例:需求方要求数字显示成 1,234,567.89,同时保留两位小数。我一开始的配置是:
javascript复制const countUp = new CountUp('el', 1234567.891, {
duration: 2,
decimalPlaces: 2,
separator: ',',
});
countUp.start();
动画过程中显示的数字是没问题的,但动画结束后,数字变成了 1,234,567.89 吗?不一定。如果 endVal 是 1234567.891,因为 decimalPlaces 是 2,最终显示会被截断成 1,234,567.89,看起来没问题。但如果 endVal 是 1234567.999,四舍五入后显示为 1,234,568.00,这个数字会比你期望的"当前值"大一号,而且如果你后续把 countUp.update(newVal) 和旧值做差运算,拿到的还是原始值,两者对不上。
这类问题的本质是"展示值"和"内部计算值"分离,不要假设 textContent 里显示的数字就是实例内部持有的值。如果需要拿当前值做业务判断,用 CountUp 实例的 countUp.endVal 或者自己保存目标值,而不是去解析 DOM 文本。
5.4 多实例同时更新时的卡顿问题
大屏页面往往有十几个指标卡,如果每个指标卡都独立创建一个 CountUp 实例,然后同时启动,底层的 rAF 循环会有十几个,加上页面里的 ECharts 图表动画、地图、视频,性能确实会吃紧。
我实测过一个中等规模大屏:硬件配置普通的办公笔记本,页面同时渲染 18 个 CountUp 实例 + 4 个 ECharts 实例,所有数字同时启动动画时,FPS 明显下降,鼠标滚动开始有粘滞感。
解决策略有几个。第一,区分优先级,首屏的核心指标先播放,次要指标用 setTimeout 错峰播放,比如间隔 200ms 依次启动,既避免了同帧渲染压力,又形成了视觉上的信息层级。第二,如果数字本身跨度不大,可以适度缩短 duration,减少动画帧数。第三,用 pauseResume() 在页面不可见时暂停动画,回到页面再恢复,这个配合 VIsibility API 可以做。
5.5 一次完整排错:从控制台报错到最小复现
分享一次印象比较深的排错过程。现象是:在大屏里某个指标点击筛选条件后,数字会从目标值跳到 0 再重新递增,需求方觉得"跳 0"很突兀,希望数字直接从上一次的值平滑过渡到新值。
我当时的排查链路是:
第一步,在 watch 回调里打印 newVal 和 countUp.endVal,确认识别到的新值没问题,但问题出在组件内部每次筛选变化时,整个指标组件被 v-if 销毁重建了,所以 CountUp 实例相当于重新 new 了一次,startVal 自然是默认的 0。
第二步,把组件的 v-if 改成 v-show,让 DOM 和实例在筛选变化时得以保留,数字就不会被重置。
第三步,用 countUp.update(newVal) 替换掉销毁重建的方案,这样实例会从当前展示值平滑过渡到新值,效果完全符合诉求。
这个问题的根因不是 CountUp 的行为有 bug,而是组件生命周期设计和动画实例的复用方式不匹配。我把这个案例记录下来之后,后续所有大屏项目里都默认采用"实例只创建一次,数值变化走 update()"的约定,再也没有出现过跳 0 的尴尬。
6. 工程化落地:多数字大屏的性能优化与扩展思路
6.1 批量实例管理与统一调度
当你需要管理十几个 CountUp 实例时,散落的 new CountUp 会变成维护噩梦。我的做法是建立一个简单的实例注册表,统一管理创建、启动、暂停、销毁:
javascript复制class CountUpManager {
constructor() {
this.instances = new Map();
}
register(key, target, endVal, options = {}) {
const instance = new CountUp(target, endVal, options);
if (instance.error) {
console.warn(`[CountUpManager] ${key} 创建失败:`, instance.error);
return instance;
}
this.instances.set(key, instance);
return instance;
}
startAll(delay = 0) {
let index = 0;
this.instances.forEach((instance) => {
setTimeout(() => {
if (!instance.error) {
instance.start();
}
}, delay * index);
index += 1;
});
}
update(key, newVal) {
const instance = this.instances.get(key);
if (instance && !instance.error) {
instance.update(newVal);
}
}
destroy() {
this.instances.forEach((instance) => instance.reset());
this.instances.clear();
}
}
这个管理器配合错峰启动,已经足够应付中小规模大屏。如果你做的项目里实例数量超过 30 个,还要引入"虚拟化"策略,只对可视区域内的指标做动画,区域外的数字直接静态渲染成目标值,等滚动进入视口再补动画。这个策略和列表虚拟化的思路一致,对超长指标滚动页非常管用。
6.2 自定义缓动:让动画贴合品牌气质
CountUp.js 默认的缓动是 easeOutExpo,效果是"快速起步、急剧减速、稳稳停在目标值",适合绝大多数数字增长场景。但有些品牌调性偏温和,这种"嗖"一下冲上去的动效就太猛了。这时候可以自定义 easingFn。
CountUp.js 源码内置了几个缓动函数示例,包括 easeOutExpo、easeOutCubic 等。我经常用的是 easeOutQuart,它在起步阶段比 Expo 温和,减速过程更平滑:
javascript复制const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4);
const countUp = new CountUp('el', 10000, {
duration: 2.5,
easingFn: easeOutQuart,
});
countUp.start();
如果你想要更专业的缓动库,可以引入 bezier-easing 这样的模块来做贝塞尔曲线控制。但我个人的建议是别过度设计——数字动画不是界面动效的核心,只要不突兀、能准确传达"数值在变化"就够了,默认的 easeOutExpo 在 90% 的场景都是最优解。
6.3 与 ECharts 等可视化库的组合使用
数据分析可视化项目里 CountUp.js 通常不是单独出现的,它和 ECharts 的配合是最常见的组合。我的做法是把 CountUp.js 负责的数字指标卡和 ECharts 负责的图表区域做成两套部件,通过同一份数据源驱动,保证"图表更新时,数字同步更新"。
比如一个销售趋势页面,左侧是柱状图展示月度销量,右侧是几个关键指标卡。当用户切换"本月/上月"时,图表用 chart.setOption 更新,指标卡走 countUp.update(newVal) 更新,两边节奏同步。这里有一个体验细节:图表切换时可以用 chart.clear() 先清空再重绘,但数字指标卡如果每次切换都从 0 重新递增,视觉上会显得很拖沓,所以指标卡更适合做"从当前值过渡到新值"的 update(),而不是重新 start()。这个逻辑一定要和 UI 方案对齐,别等到开发完再改。
还有一类场景是和地图类可视化组合:地图上点击某个省份,下面的指标卡数字对应变化。CountUp 的 update() 天然支持这种联动,因为地图点击事件本身是异步的,等事件回调触发时调用 update() 即可。注意这时候不要再重置 startVal,否则地图每点一次数字就跳一次 0,交互观感非常差。
6.4 我在两个真实项目里的实践配置
第一个项目是某零售企业的销售数据大屏,显示当日销售额、订单量、客单价、退款金额四个核心指标。我的最终配置是:四个 CountUp 实例,启动顺序错峰 200ms,依次是销售额、订单量、客单价、退款金额,duration 分别为 2.2 / 2.0 / 1.8 / 1.6,整体有一种"信息逐层展开"的节奏。数据源每 30 秒轮询一次,变化后所有实例调用 update(),而不是重建实例。退款的数字本身是负的逻辑,我用 prefix 配置成 - ¥ 来处理,没有额外写判断。
第二个项目是某 App 的年度报告 H5,长页面滚动驱动,每个章节的数据数字都是"滚动到可视区域后播放一次"。这个项目我用了 enableScrollSpy: true 加 scrollSpyDelay: 150,并且在滚动出可视区域后又回到可视区域时不会重播——因为内置 scrollSpy 默认就是"首次进入播放一次"的语义,正好符合需求。在这个项目里我额外踩了一个坑:部分数字在 iOS Safari 里播放时会有闪烁,排查后确认是 decimalPlaces: 2 配合二进制浮点误差导致的显示抖动,最终在大数字上用 formattingFn 统一 toFixed(2) 解决。
从我自己的体会来说,CountUp 这类库最大的价值不是"让数字动起来"这个表象,而是它逼着你去思考:数字在数据可视化里到底承担什么角色?是信息的载体,还是注意力的锚点?想清楚这一点,你自然知道什么时候该让它动、怎么动、动多快。这也是为什么我在前面反复强调 update() 和 start() 的区别、复用和销毁的区别——这些细节加起来,决定了一个可视化页面是"酷炫但轻浮"还是"专业且高级"。最后再分享一个小技巧:在项目里给 CountUp 实例统一加一层 console.debug 日志,把 start、update、reset 的关键时刻打出来,上线前排查动画问题会省非常多的时间。
