在不少落地页或数据展示板块中,我们常看到数字从零滚动到目标值的效果。如果页面加载后动画立刻执行,而该模块位于首屏下方,用户滚动过去时动画早已结束,完全起不到强调作用。合理的交互应该是:当包含计数器的区块真正进入用户视野时才启动数字递增过程。

为什么用 IntersectionObserver 而不是 scroll 事件
早期做法是在 window 上监听 scroll,然后在回调里用 getBoundingClientRect 判断元素位置。这种方式最大的问题是 scroll 触发极其频繁,哪怕加了节流也可能造成主线程卡顿,尤其在低端设备上。IntersectionObserver 是浏览器原生提供的异步观察接口,它会在目标元素与视口交叉比例达到设定阈值时,才向开发者抛出回调,整个过程不阻塞滚动。
另一个容易被忽略的点是,Observer 能准确告知元素是进入还是离开视口。我们只需要在进入时启动动画,并调用 unobserve 取消观察即可,逻辑比手动算坐标清晰很多。下面的代码展示了如何观察一个容器:
const counterEl = document.querySelector('.counter');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
startCount();
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
observer.observe(counterEl);
用 requestAnimationFrame 驱动数字变化
拿到启动信号后,不能只用 setInterval 去改文字,因为定时器频率固定且与屏幕刷新不同步,容易出现跳动。requestAnimationFrame 会在浏览器每次重绘前执行回调,通常每秒六十次,能天然匹配显示器的刷新节奏,让数字过渡更顺滑。
我们可以在动画里记录开始时间戳,根据已过去的时间占比计算当前应显示的数值,并乘以缓动系数。下面这段函数演示了从零加到目标值的完整过程:
function startCount() {
const target = 1000;
const duration = 2000;
const startTime = performance.now();
function tick(now) {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
counterEl.textContent = Math.floor(eased * target);
if (progress < 1) {
requestAnimationFrame(tick);
} else {
counterEl.textContent = target;
}
}
requestAnimationFrame(tick);
}
这里用了三次方缓出公式,数字前期增长快、后期变慢,比线性递增更接近真实物理感。若你的目标值来自后端接口,只需把 target 换成动态变量即可。
兼容性与细节处理
IntersectionObserver 在现代浏览器中支持良好,但在一些老旧版本里可能缺失。生产环境建议做特性检测,不支持时直接执行动画或借助 scroll 降级方案。同时要注意,如果容器本身有 display:none 或者父级被隐藏,Observer 可能不会触发,需要确认 DOM 可见性。
此外,为了避免用户快速来回滚动导致重复绑定,务必在首次进入视口后调用 unobserve。若页面有多个独立计数器,可为每个元素单独创建 Observer,或者在一个回调里根据 entry.target 区分处理。下表列出两种方案的差异:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 单个 Observer 观察所有 | 代码集中,内存占用低 | 回调内需判断目标,逻辑稍杂 |
| 每个元素独立 Observer | 逻辑隔离清晰 | 元素多时对象数量上升 |
综合来看,中等规模页面用单个 Observer 遍历 entries 已足够。只要把握好触发时机与帧循环,数字计数器就能在滚动到指定区域时自然动起来。
IntersectionObserverrequestAnimationFrame数字计数动画修改时间:2026-08-07 03:03:23