ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CSS3与IntersectionObserver实现元素可见时数字滚动动画

CSS3与IntersectionObserver实现元素可见时数字滚动动画 接手一个运营活动页时产品提了个需求页面底部有三组数据累计参与人数、发放优惠券数量、成交金额用户滚动到那个区域时数字要从 0 快速滚到目标值并且滚动到之后就不能再动。第一反应是这还不简单找个数字滚动插件不就完了。但真做起来才发现CSS3 技术拓展学习笔记这种东西网上教程一大堆能直接把元素可见时数字展示这个场景讲透的却没几个。等我把 IntersectionObserver、CSS3 transition、requestAnimationFrame 这些东西揉到一起踩完一圈坑才敢说这主题值得单独写一篇。这篇笔记不只是给前端开发看只要是跟页面打交道的人——切图仔、独立开发者、运营里懂点代码的——应该都能用上。我会把它拆成方案选型、核心实现、踩坑实录、延伸玩法四个层面来写最后附上一些我在真实项目里调参的经验。这篇文章的核心就是一个问题如何用 CSS3 加一点点原生 JS实现一个只在元素可见时才触发的数字滚动动画还要让它稳、顺、不闪、可复用。1. 这个需求为什么绕不开可见性这件事1.1 数字展示动画的真实应用场景先别急着写代码想想这类数字动画到底出现在哪些地方。数据大屏是最典型的一堆交易额、在线人数、订单量数字要求像新闻联播里的实时数据一样跳动活动营销页也很多累计参与、已发放奖励、限时优惠的剩余名额后台管理面板同样常见今日新增、本月流水、转化率这些关键指标做在首页卡片上滚动到卡片区域数字从 0 涨上去视觉上确实比干巴巴的最终值更有冲击力。不同场景对动画的诉求其实不一样。数据大屏追求一直在动的鲜活感数据更新时数字要平滑变化偏向实时流活动页要的是首屏吸引或滚到即播偏向一次性表演后台面板介于两者之间既要有点缀效果又不能耽误用户获取信息所以通常动画速率要快1 秒内完成是合理范围。这些差异就决定了数字滚动动画不能只做一个从 0 到目标值的玩具 Demo你必须考虑触发机制、播放次数、格式化、性能、无障碍这些才是能被复用到生产环境的拓展学习内容。1.2 为什么元素可见时成了刚需如果数字在页面加载时就直接放最终值用户根本不会有感知。常规做法是让动画从页面加载就开始跑这个方案在小页面上没问题但内容比较长的落地页用户滚到目标区域时动画早就播完了看到的只是一个静止的数字效果等于零。所以必须把播放和可见绑定起来。早年的做法是给 window 挂 scroll 事件在回调里用getBoundingClientRect判断元素是否进入视口。这在 PC 端勉强能用移动端就比较难受了——scroll 事件触发频率极高你需要在里面做大量计算即使加了节流throttle主线程也被反复打断。而且 getBoundingClientRect 每调用一次都要触发强制重排forced reflow连续滚动的场景下肉眼可见地卡顿。IntersectionObserver 就是为解决这个问题而生的浏览器原生 API。它把元素是否进入视口进入了多少比例这些检测工作下沉到浏览器内部以异步回调的方式通知你性能开销比监听 scroll 小一两个数量级。这是整个方案的地基现代浏览器对它的支持已经很完善基本可以无脑使用。后面我们的核心实现就建立在这个 API 之上。1.3 这篇笔记设定的适用范围先说明白边界省得后面代码让你困惑。我默认你面向的是现代浏览器环境不需要兼容 IE11也不需要兼容十年老旧的 WebView。如果你的项目还在用 jQuery 插件那套思路本篇也值得看因为思路是通用的——把插件换成自研组件可以少踩一半坑。另外我不打算引任何重量级第三方库连 lodash 都不用全部原生实现。这样做有两个好处一是不依赖库版本代码放哪儿都能跑二是库里的实现往往做了大量兼容处理看源码容易迷失而自己的代码逻辑完全可控出了问题也知道去哪里排查。2. 动手之前先把数字滚动拆成六个设计决策与其直接上手写代码不如先把这个功能拆解开。我发现很多实现跑起来效果不对不是因为代码写错而是因为设计决策没定清楚——动画时长多少、触发阈值设多少、要不要上千分位、结尾是瞬间定格还是减速停顿这些问题含糊着开始后面必然反复返工。2.1 触发阈值threshold 和 rootMargin 怎么搭配IntersectionObserver 的构造参数里threshold表示元素可见比例达到多少才触发回调。0 意味着只要有 1 像素进入视口就触发1 意味着元素完全进入才触发。在我们这个场景里threshold 设 0.3 或 0.5 比较合理——保证用户确实看到了大部分数字才开播而不是刚露个头就开始跑等跑到一半元素又滚出屏幕。如果你希望元素进入视口一段距离之后再触发可以用rootMargin: 0px 0px -10% 0px来把判定区域向上收窄效果类似。有个容易被忽略的问题如果目标元素初始就在首屏视口内IntersectionObserver 在 observe 之后也会立刻回调一次而且isIntersecting会是 true正好满足可见即播的诉求。所以不用特殊处理首屏元素这是一个原生优点。2.2 播放策略只播一次还是每次进入都播我见过不少项目在这里摇摆。运营希望用户来回滚动时数字每次都跳一下增强仪式感但技术往往会说这样太打扰建议只播一次。我的经验是分场景数据大屏通常一次都不播直接实时刷新活动长页推荐每次进入都播但要配合焦点判断避免快速滚动时动画叠成灾难仪表盘推荐只播一次重点强调首屏或首次滚到。代码层面这个决策很便宜播一次触发回调后立即observer.unobserve(el)。每次播只在entry.isIntersecting为 true 时重置播放false 时不做处理。实测下来每次播的体验并不差只要保证动画时长在 1.2 秒以内用户在来回滚动时反而会觉得数据活着。不过要注意如果元素在视口边缘反复进出动画会被频繁打断处理办法是加一个冷却时间比如 500ms 内不重复播放后面踩坑部分我会细说。2.3 两条渲染路线JS 插值还是 CSS3 transform 滚动这是整个方案里最核心的技术分叉。第一条路线是JS 逐帧插值。用 requestAnimationFrame 在动画时长内不断计算当前值直接更新 DOM 的textContent。它的优点是完全可控支持任意格式千分位、小数、百分比前后缀数值更新可以跟数据源挂钩缺点则是数值跳动的颗粒感跟真实电子计数器比少了点味道。第二条路线是CSS3 transform 驱动轮盘滚动。每位数字单独占一个盒子盒子里面放一条 0→9 的纵向数字条动画就是让 translateY 把数字条移动到目标位置transition 交给 CSS3 处理。它的优点是具有非常自然的翻牌或计程表滚动质感而且动画过程完全由 GPU 合成不占用主线程缺点是扩展性差数字位数变化、百分比符号、小数点的对齐处理都会让你头疼。两条路线不冲突实际项目里我用得更多的是第一条——JS 插值稳定可靠后面加需求比如结算金额小数后两位也容易扩展。第二条适合对视觉品质要求高、数字位数固定的展示页。我先把第一条的完整方案写透再讲第二条怎么玩。2.4 千分位与小数位格式化到底该放哪层这是很多人忽略的一个致命细节。数字在滚动过程中是连续变化的比如目标值 3286动画中间会出现 328.6399 这种带多位小数的中间值。如果你直接toLocaleString()小数位会来回跳动千分位的逗号会忽现忽隐导致整个数字宽度不断变化文字左右抖动。解法是分层处理动画计算层只负责产出浮点数格式化层在每一帧把浮点数变成展示文本。两个原则要守住动画的中间值统一保留 2~4 位小数用于计算但展示时用Math.floor或Math.round取整后再格式化如果必须显示小数比如转化率 23.6%小数位固定在格式化层用toFixed(1)控制保证在整个动画过程中位数一致从视觉上杜绝抖动。2.5 时长与缓动曲线时长定在 1.2~1.6 秒是最舒服的区间。短于 0.8 秒用户还没反应过来就结束了毫无仪式感长于 2.5 秒用户会产生怎么还没数完的焦虑尤其在运营页转化场景反而劝退。数据大屏如果做定期刷新时长缩短到 0.6~1 秒更好。缓动曲线我固定用easeOutCubic公式是1 - Math.pow(1 - t, 3)t 是从 0 到 1 的归一化进度。它的特点是前期加速快、后期减速明显数字先冲得快快到目标时慢慢停住非常像汽车的刹车过程看着舒服。CSS3 transition 自带的cubic-bezier(0.22, 1, 0.36, 1)也有类似效果实际上这就是 easeOutQuint 的近似值可以记下来供版本二使用。2.6 数据来源与异步加载数字滚动动画有个天然的出场前必须值已到位问题。如果目标值是异步加载的而接口还没返回IntersectionObserver 已经触发了就会出现一个尴尬场景——数字从 0 滚到 0接口回来了也没动静。稳妥做法是等数据到位后再 observe 元素。换句话说observer 的注册时机不是页面加载而是数据渲染完成后。这个时序问题非常关键我见过两个项目都死在这个细节上后面排错章节会专门讲。3. 版本一requestAnimationFrame 插值驱动的核心实现3.1 HTML 结构与基本样式假设页面上有这样一个数据卡片div classstat-card h2 classstat-title累计参与人数/h2 div classstat-num>.stat-num { font-size: 48px; font-weight: 700; font-family: Barlow Condensed, DIN Alternate, sans-serif; font-variant-numeric: tabular-nums; letter-spacing: 0.5px; white-space: nowrap; }font-variant-numeric: tabular-nums这一行很值得写。它把所有数字字形切换成等宽数字避免1和8宽度不同导致的抖动。这是排版细节但直接影响动画观感强烈建议加上。3.2 IntersectionObserver 观测器封装先写一个工具函数负责监听元素可见性并触发回调。这里我直接给出完整可用的版本function createVisibilityTrigger(el, onVisible, options {}) { const { threshold 0.3, once true } options; const observer new IntersectionObserver((entries, obs) { entries.forEach(entry { if (entry.isIntersecting) { onVisible(entry); if (once) obs.unobserve(entry.target); } }); }, { threshold }); observer.observe(el); return observer; // 返回实例外部可以随时 disconnect }threshold 设 0.3 意味着数字区域至少有 30% 露出来才触发在实际长页面里体感是滚到差不多看到数字就开始滚不会出现只露个边就急着播的情况。once 参数对应前面说的播放策略默认 true想每次进入都播就传 false。3.3 数字插值动画函数接下来是核心的动画逻辑。我特别强调一点进度计算用绝对时间差不要用每帧增量累加原因后面排错部分再展开这里直接给正确写法function animateNumber(el, onComplete) { const target parseFloat(el.dataset.target) || 0; const duration parseInt(el.dataset.duration, 10) || 1400; const decimals parseInt(el.dataset.decimals, 10) || 0; const startTime performance.now(); let rafId null; function tick(now) { const progress Math.min((now - startTime) / duration, 1); const eased easeOutCubic(progress); const current target * eased; el.textContent formatNumber(current, decimals); if (progress 1) { rafId requestAnimationFrame(tick); } else { el.textContent formatNumber(target, decimals); if (onComplete) onComplete(); } } rafId requestAnimationFrame(tick); } function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function formatNumber(value, decimals) { const fixed value.toFixed(decimals); const [intPart, fracPart] fixed.split(.); const intFormatted intPart.replace(/\B(?(\d{3})(?!\d))/g, ,); return fracPart ? ${intFormatted}.${fracPart} : intFormatted; }progress 用performance.now()和 startTime 的差值算出来天然免疫浏览器标签页被挂起的情况。formatNumber 手写了千分位逻辑其实也可以直接用toLocaleString(zh-CN)但叠加上指定小数位时不如toFixed 正则来的直观我就自己写了四行代码的事。3.4 挂载与整体调用把 observer 和动画组合起来的完整调用是这样的document.addEventListener(DOMContentLoaded, () { const nodes document.querySelectorAll(.stat-num); nodes.forEach(el { // 如果数据是异步加载的一定要等数据填进>async function loadStats() { const data await fetchStats(); renderStat(data); // 把>div classroller>.roller-digit { overflow: hidden; height: 60px; width: 48px; display: inline-block; } .roller-digit li { height: 60px; line-height: 60px; text-align: center; font-size: 48px; list-style: none; } .roller-digit .roller-strip { padding: 0; margin: 0; transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); will-change: transform; }给 roller-strip 加上 transition 之后只要 JS 改变它的 transformCSS3 就会自动插值滚动。4.3 动态生成与移动距离计算用前端代码动态生成每位数字的结构更实用function buildRoller(el, targetNum) { const digits String(targetNum).split(); el.innerHTML digits.map(digit span classroller-digit ul classroller-strip li0/lili1/lili2/lili3/lili4/li li5/lili6/lili7/lili8/lili9/li /ul /span ).join(); return el.querySelectorAll(.roller-digit); }让数字到达目标位时我们计算位移。关键点是数字条第 0 位对应translateY(0)第 1 位对应translateY(-100%)。如果当前数字是 2目标数字是 7距离就是-(7 - 2)个-100%即translateY(-500%)function spinTo(el, target) { const digits String(target).split(); el.querySelectorAll(.roller-digit).forEach((digitEl, i) { const current parseInt(digitEl.dataset.current, 10) || 0; const next parseInt(digits[i], 10) || 0; const rawDistance next - current; // 处理 9-1 的情况让数字条向前多转一圈保持滚动方向一致 const distance rawDistance 0 ? rawDistance : rawDistance 10; const strip digitEl.querySelector(.roller-strip); strip.style.transform translateY(-${(current distance) * 100}%); digitEl.dataset.current String(next); }); }这个做法的好处是不需要考虑数字条的总长度因为 translateY 的百分比值是相对条本身高度计算的位移到当前位 距离位即可。要注意的是如果把 9 滚到 1 处理为距离 10 在前方补一位的话数字条需要在 0 之前再补一个 9 或者循环结构否则滚出边界会露出空白。生产环境我最常用的替代方案是当 distance 为 10 时把当前数字丝滑地回退再快速滚到下一圈视觉上完全看不出“多滚一圈”。4.4 两条路线怎么选实际项目里我给一个简单判断标准对比维度rAF 插值方案CSS3 轮盘方案实现复杂度低核心函数 40 行中需要动态生成结构视觉效果颗粒度平滑渐变类似电视计数器逐位滚动机械计程表质感数据位变化天然支持任意位数位数变化需要重建 DOM动画主线程占用每帧修改 textContent纯合成器动画几乎不占主线程配合数据流刷新灵活随时改 target需要重新计算每个 digit 距离典型适用场景仪表盘、数据面板、通用组件活动页视觉主角、大屏展示如果项目只有一个数字要滚用 rAF 足够如果做数据大屏好几组数字同时滚动而且数字位数不变轮盘方案更出效果。5. 踩坑实录从数字闪一下结束到稳定滚动的完整排查链路这部分写真实经历。我最初做的版本有不少问题每一个都是排查了很久才找到根因记录下来能帮你少走很多弯路。5.1 坑一数字在首屏直接闪到最终值根本没看到滚动现象是这样的首屏内的数字模块加载完成的那一刻页面一闪数字直接变成了最终值完全没有动画过程。第一反应是 IntersectionObserver 触发太早——按理说 observe 后立即回调 isIntersecting 为 true我们应该在动画之前把初始态设置好但实际没生效。排查链路先确认初始数字是否为 0。我用 HTML 写死了0不可能是 0 的问题再确认动画函数是否执行。加了 console.log发现动画函数确实执行了但时间极短。进一步发现>.progress-bar .bar-fill { width: 0%; transition: width 1s cubic-bezier(0.22, 1, 0.36, 1); } .progress-bar.is-visible .bar-fill { width: var(--target); }同理SVG 环形图可以用stroke-dashoffset实现。只要用 JS 在元素可见时给 SVG 加上.is-visible类CSS3 的 transition 就会自动把圆环填充到目标百分比。这类动画的好处是主线程零压力全交给合成器。6.2 数字卡片整体入场与小元素交错动效很多运营页不满足于数字单独滚还希望整个卡片浮上来、列表项一条条进场。CSS3 animation 配合 animation-delay 可以很轻松地实现交错效果.stagger-item { opacity: 0; transform: translateY(20px); transition: opacity 0.5s, transform 0.5s; transition-delay: var(--delay); } .stagger-item.is-enter { opacity: 1; transform: translateY(0); }然后在 JS 里给每个子元素设置--delay: 0.1s, 0.2s...可见时加上.is-enter。这种效果在长页面上很耐看而且不需要任何动画库。6.3 性能与无障碍的两个必做打磨性能方面长页面如果模块特别多可以给非可视区域的模块加content-visibility: auto这是 CSS3 家族里被低估的属性可以跳过视口外模块的渲染。但注意布局抖动和滚动锚定会受一点影响需要实测。无障碍方面纯视觉动画对屏幕阅读器是噪音。我给数字模块加上aria-livepolite后读屏软件会在动画结束后读出最终值而不是中途读一堆中间数字。另外尊重prefers-reduced-motion的用户设置media (prefers-reduced-motion: reduce) { .stat-num, .roller-digit, .bar-fill { transition: none !important; } }JS 侧也要判断如果用户偏好减少动态直接跳到最终值不播动画。6.4 CSS property 的新边界最后提一个探索方向CSSproperty允许注册自定义属性并声明其类型例如property --num { syntax: integer; initial-value: 0; inherits: false; }配合 counter 就能让文本内容跟 CSS 变量产生关联。浏览器支持propertytransition之后存在纯 CSS 实现数字滚动的可能性。不过现阶段兼容性和复杂场景支持还不够稳我暂时只在个人实验项目里玩过生产项目还是老老实实用 rAF 方案。但这个方向值得持续关注等到支持面够广数字展示会有更优雅的解法。这个数字可见再滚动的组件我在三个项目里迭代过从最初 80 行的玩具到后来封成通用组件核心收益就是那一堆踩坑记录。现在每次接类似需求我会默认先用 rAF 插值方案1.2 秒时长开 adapter等数据到手后再绑定 observer一切都顺了。最后分享一个看起来不起眼但特别实用的设置给数字容器加min-width并固定字号配合tabular-nums能解决 80% 的滚动时抖动反馈。做这类组件稳定性往往比炫技重要能把基础版本打磨到不闪、不跳、不打断已经跑赢大多数线上案例了。
返回列表