ARTICLE DETAIL

资讯详情

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

CSS3动效实战:数字滚动与大屏scale缩放方案

CSS3动效实战:数字滚动与大屏scale缩放方案 CSS3 这个标签我接触了快十年从最早玩圆角、阴影到后来项目里真正把 transform、animation、IntersectionObserver 这些东西组合起来做业务动效每次回看都还能挖出新东西。这篇笔记其实不是教科书式的语法罗列而是我最近重新梳理 CSS3 时攒下来的两块硬货一块是元素可见时的数字展示动效另一块是 scale 缩放在不同场景下的落地解法。无论你是刚接手可视化大屏的前端还是想优化详情页数据展示的同学这两块都值得花点时间过一遍。文中所有方案我都跑过真实项目给出的是可以直接抄作业的版本。1. 数字滚动展示元素可见时再动体验才到位1.1 为什么要等“可见了”才开始滚写数据大屏、后台仪表盘、活动页数字面板的时候产品最常提的需求是“数字要滚起来”。但很多人第一步就翻车了在页面初始化时执行数字滚动动画页面加载完数据一刷新动画从头到尾播一遍然后用户滑到目标区域时看到的只是静止数字。用户并没有看到滚动过程这个动效等于白做还白白消耗了性能。正确的触发时机是元素真正进入视口。这里的关键手段现在当然是用 IntersectionObserver 判断元素与视口的交叉情况而不是老式地监听 scroll 事件再手动计算 offsetTop。老式方案需要监听 scroll、resize、orientationchange每次滚动都要读取一堆元素的位置信息要是列表页有几十个数字面板性能肉眼可见地下降。IntersectionObserver 把交叉检测交给浏览器内部处理回调是异步的滚动过程中不会因为频繁触发 JS 计算导致掉帧。从产品体验角度讲“数字滚动”的核心作用不是花哨而是引导注意力。人眼对动态变化比对静态数值敏感得多滚动过程在向用户传递“这个数在增长/更新”的信号。如果滚动发生在用户还没看到元素的时刻信息就丢失了。所以实现时我第一件事就是确定“触发时机”这比动画本身还重要。1.2 用 IntersectionObserver 判断进入视口IntersectionObserver 的用法非常直接核心代码就是创建观察器、注册目标元素、在回调里判定是否相交。const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { entry.target.dataset.visible true; startCountUp(entry.target); observer.unobserve(entry.target); } }); }, { root: null, threshold: 0.3, }); document.querySelectorAll(.counter).forEach((el) observer.observe(el));参数说明必须讲清楚root默认为视口不传也基本没问题。threshold表示目标元素有多大面积进入 root 才触发0.3 意思是 30% 面积可见如果页面滚动很快面积计算有时会先到 0.3 才回调。unobserve很重要执行过一次后立刻取消观察防止反复进入退出视口导致动画重播也能避免不必要的内存占用。如果担心页面滚动速度太快用户看不到动画可以配合rootMargin提前触发但一般业务里我不推荐因为提前触发就和“可见才展示”的初衷背离了。另外有一点要注意threshold 的百分比是元素面积不是视口面积后面章节我会专门讲这个坑。1.3 用 requestAnimationFrame 驱动数值变化数字滚动本质上是一个时间轴动画给定起始值、目标值、时长、缓动函数在每一帧把当前数值写入 DOM。用 requestAnimationFrameRAF而不是 setInterval有两点原因。RAF 的触发频率和显示器刷新率对齐高刷屏 120Hz 下动画会明显更平滑而 setInterval 固定 16ms 或 33ms 会显得一顿一顿。另一个是浏览器在页面切到后台时会自动暂停 RAF省电也避免后台空转。核心代码长这样function animateCountUp(el, to, duration 1500, easing easeOutExpo) { const from 0; const startTime performance.now(); function frame(now) { const progress Math.min((now - startTime) / duration, 1); const value from (to - from) * easing(progress); el.textContent formatNumber(value); if (progress 1) { requestAnimationFrame(frame); } else { el.textContent formatNumber(to); } } requestAnimationFrame(frame); } function easeOutExpo(t) { return t 1 ? 1 : 1 - Math.pow(2, -10 * t); }这里从 0 开始滚到目标值。用performance.now()而不是Date.now()因为前者基于页面启动时间不会受系统时间改动影响。而且用时间差算 progress即使浏览器掉帧或用户切回页面动画也不会跳秒只会平滑补帧这一点很重要。1.4 缓动函数与数字格式化缓动函数的选择会直接影响观感。数字滚动最常用的是 easeOutExpo刚才代码里写过进度到 0.25 时数值已经到达最终值的 94% 左右从视觉上看就是“数字先飞快冲上去后面小幅跳动逼近真实值”非常有数据增长的味道。如果换成线性缓动整个过程节奏均匀会显得平淡。缓动函数特点适用场景easeOutExpo开头极快结尾极慢数据增长类数字滚动easeOutCubic开头较快结尾缓和通用场景兼顾节奏感linear匀速进度条、计数器easeInOutQuad慢-快-慢图表动画、翻页效果数字格式化有两个点很容易疏忽千分位和小数位。toFixed(decimal)之后用正则补千分位function formatNumber(value, decimal 0) { const fixed value.toFixed(decimal); return fixed.replace(/\B(?(\d{3})(?!\d))/g, ,); }注意浮点精度问题。比如目标值是 103.9动画过程中中间值可能变成 103.90000000000001所以每次都要先 toFixed 再输出否则界面上会出现一长串小数。格式化逻辑我放在组件内部外部只管传原始数值避免在上层到处处理。2. CSS3 scale 缩放方案不只有“变大变小”2.1 先搞懂 transform: scale 在干什么transform: scale(sx, sy) 是对元素做二维缩放它和 width/height 有本质区别。缩放的坐标系是元素自身缩放后元素的布局占位尺寸不会改变——文档流里该占多大还是多大。这个特性在交互层面是优点比如给列表项做 hover 放大兄弟元素不会跟着“跳”布局纹丝不动但在整屏适配层面就成了坑后面会说到。性能上scale 也有明显优势。修改 width/height 会触发 layout布局和 paint绘制两个阶段元素一多就会掉帧。而 transform 只触发 composite合成阶段GPU 参与变换动画期间浏览器不必重新计算布局。我测过 50 个卡片同时做 scale 动画合成器一路稳定 60 帧。所以能用 transform 解决的动效不要用会触发布局回流的属性。transform: scale 还有几个变体scaleX、scaleY、scaleZ 和 scale3d。业务上最常用的是两个方向都放大的 scale偶尔需要单方向拉伸时会用 scaleX 或 scaleY。注意 scale 默认的原点是元素正中心transform-origin: 50% 50%改了 origin 会直接影响缩放后的位置这是最容易踩的坑。2.2 用 scale 解“1px 边框”难题移动端和高分屏下border: 1px 实际渲染会占用 2 个物理像素甚至更多视觉上显得粗笨。标准解法是伪元素 scale 缩小。原理在 DPR 为 2 的屏幕上先画一个物理宽度 1px 的伪元素再整体缩小 0.5让它在屏幕上精确覆盖 1 个物理像素视觉上就是真正的“发丝线”。.hairline { position: relative; } .hairline::after { content: ; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; background: #e5e5e5; transform: scaleY(0.5); transform-origin: bottom center; }为什么 transform-origin 要设成 bottom center因为默认的缩放中心是 50% 50%即高度一半的位置缩放后线条会被拉向中心而不贴底部。改成 bottom center 后线条底部保持不动向上收缩这样它就一直紧贴容器底部。同理右边框用 right center上边框用 top center。还可以用 devicePixelRatio 判断是否需要启用这个方案。DPR 为 1 时0.5 的缩放会渲染出半透明模糊线所以最好在根节点动态加 classif (window.devicePixelRatio 2) { document.documentElement.classList.add(hairlines); }CSS 里限定.hairlines .hairline::after { transform: scaleY(0.5); }这个方案比 border-image 和 box-shadow 模拟都更稳兼容性也好是我实际项目里最常用的做法。在 DPR 为 3 的设备上scaleY(0.5) 后还剩 1.5 物理像素视觉上稍粗但完全在可接受范围内没必要为了极致去写 DPR 3 专用样式。2.3 大屏适配scale 一层容器省心但不省位可视化大屏页面设计稿通常固定 1920×1080目标屏幕可能是 2560×1440、3840×2160甚至 1366×768。如果每套尺寸都重排布局工作量太大。一个经典方案是把内容画在一个 1920×1080 的舞台里然后用 scale 等比缩放到适配当前窗口。实现步骤分成两块。页面结构包一层舞台容器div idscreen classscreen stylewidth:1920px;height:1080px;transform-origin:left top; !-- 大屏内容 -- /divJS 计算缩放比例并应用function fitScreen() { const designWidth 1920; const designHeight 1080; const ratio Math.min(window.innerWidth / designWidth, window.innerHeight / designHeight); const screen document.getElementById(screen); screen.style.transform scale(${ratio}); screen.style.height ${designHeight * ratio}px; } window.addEventListener(resize, fitScreen); fitScreen();这里最关键的一步是同步设置舞台高度。因为 transform 缩放不影响文档流占位如果只设置 scale页面原本 1080px 的高度不会变在 2560×1440 屏幕上底部会空出一大块页面也撑不起来。把容器 height 手动设为 1080 * ratio文档流才会真正撑满视口。如果目标屏幕比例和 16:9 不一致直接用 scale 等比缩放会出现上下或左右留黑边。这种场景我一般分两种处理上面代码里 ratio 取 min 会保持等比并居中适合严谨比例场景如果要求满屏铺满无黑边但允许内容轻微裁切ratio 从 min 改成 max同时配合 overflow: hidden。真实项目里我推荐前者留黑边但比例正常产品更容易接受。2.4 scale 与 transition 的组合技巧卡片悬停放大是最典型的组合场景。transition 让 scale 的变化过程平滑scale 让放大动作不影响布局。基础代码很简洁.card { transition: transform 0.25s ease; will-change: transform; } .card:hover { transform: scale(1.05); }这里有个细节will-change 告诉浏览器提前准备合成层动画会更流畅但别在大量元素上滥用。如果一个列表有几百张卡片全部声明 will-change 会创建大量图层内存和合成压力反而上升。我的经验是一般项目里直接不写transform 动画本身已经很顺非要优化就在 hover 触发瞬间动态添加 will-change动画结束后再移除。还要注意子元素的影响。scale 后子元素的圆角、边框会跟着等比缩放如果 1px 边框在放大后变成 1.05px视觉上不会有大问题但阴影会跟着变大有时候观感会偏重。想保持阴影相对稳定可以考虑把 box-shadow 移到伪元素上或者接受这种缩放造成的自然变化看产品预期。3. 把两件事合起来数字滚动组件的完整实现3.1 组件结构设计前两节分别讲了数字滚动的时机控制和 scale 的实际应用真实项目里它们常常是同一个组件的两面大屏上有一个数字指标卡区块进入视口后数字滚动卡片 hover 时整体放大。我来拆一个可复用的“数字指标卡”组件把三件事合在一起。组件结构这样定HTML一个 .metric-card 容器内含标题区域、数值区域、单位。CSS容器使用 transform transition 处理 hover 缩放数值区域使用大字号和等宽数字。JSIntersectionObserver 控制数字滚动的触发RAF 驱动动画。HTML 结构div classmetric-card>.metric-card { position: relative; width: 320px; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); transition: transform 0.25s ease; cursor: pointer; } .metric-card:hover { transform: scale(1.05); } .metric-value { font-size: 36px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.2; margin-top: 8px; }font-variant-numeric: tabular-nums这里值得单独说一句。计数器从 0 滚到 38620 时数字宽度一直在变如果字体里没有等宽数字每一帧数字框宽度跳动文字会左右抖动。tabular-nums 让所有数字使用等宽字形滚动过程中视觉上很稳。这是数字动画很容易被忽略的细节也是观感提升最明显的单行样式。hover 缩放配合 transition 已经足够。如果卡片内容里有图片会连带放大这是符合预期的。如果只想放大数值区域把 scale 挂在.metric-value上而不是容器上这个根据需求来。3.3 JS 完整逻辑组件脚本我按模块化写保持 IIFE 包裹防止变量污染(function () { use strict; function easeOutExpo(t) { return t 1 ? 1 : 1 - Math.pow(2, -10 * t); } function formatNumber(value, decimal) { return value .toFixed(decimal) .replace(/\B(?(\d{3})(?!\d))/g, ,); } function animateCounter(el, to, duration, decimal) { const from 0; const start performance.now(); function frame(now) { const progress Math.min((now - start) / duration, 1); const current from (to - from) * easeOutExpo(progress); el.textContent formatNumber(current, decimal); if (progress 1) { requestAnimationFrame(frame); } else { el.textContent formatNumber(to, decimal); } } requestAnimationFrame(frame); } const observer new IntersectionObserver( (entries) { entries.forEach((entry) { if (entry.isIntersecting) { const card entry.target; const valueEl card.querySelector(.counter); if (!valueEl) return; const to parseFloat(card.dataset.target || 0); const decimal parseInt(card.dataset.decimal || 0, 10); const duration parseInt(card.dataset.duration || 1500, 10); animateCounter(valueEl, to, duration, decimal); observer.unobserve(card); } }); }, { threshold: 0.3 } ); document.querySelectorAll(.metric-card).forEach((card) { observer.observe(card); }); })();observer.unobserve(card)放在动画触发后确保一次进入只播一次。这里有业务考虑如果用户反复滚动同一区域每次重播只会造成视觉噪音多数场景一次播完更合适。如果确实需要重新进入时重播可以把 unobserve 逻辑去掉并加一个“是否已播过”的状态判断配合再次离场再进场的重置时机。3.4 可配置参数与扩展点参数设计直接影响组件的复用广度做一张配置说明表参数数据结构默认值说明>
返回列表