动画是 Web 体验里最“廉价”也最“昂贵”的东西:写起来只需几行 @keyframes,跑起来却可能让整台设备的电量与帧率一起崩塌。很多开发者以为“动画卡”是因为代码写得不够多,于是堆砌 will-change、translateZ(0)、backface-visibility,结果反而更卡。高性能动画的本质,不是让浏览器做更多,而是让它做得更少。本篇 35 分钟深度长文,从浏览器渲染流水线讲起,穿过合成层、帧预算、缓动数学、FLIP、滚动驱动动画与 Web Animations API,配合多个可实时交互的实验台,帮你把 CSS 动画从“能跑”升级到“丝滑且省电”。
动画性能的本质:帧预算只有 16.7 毫秒
人眼对连续画面的感知阈值大约是每秒 60 帧。这意味着在 60Hz 的屏幕上,浏览器每 16.7 毫秒就必须完成一帧的全部工作:解析样式、计算布局、绘制图层、合成上屏。只要其中任何一环超时,这一帧就会被丢弃,用户看到的就是“卡顿”“掉帧”“一抖一抖”。
更残酷的是,这 16.7ms 并不全归你的动画使用。浏览器还要处理输入事件、执行 JavaScript、做垃圾回收、更新其他 DOM。真正留给样式与布局的时间往往只有 5~10ms。所以性能优化的第一原则是:让动画尽量不参与前三个环节,只做最后的合成。
/* 总预算:16.7ms @ 60Hz */
输入事件处理 ≈ 1ms
JavaScript ≈ 3ms
样式计算 Style ≈ 1ms
布局 Layout ≈ 2ms
绘制 Paint ≈ 2ms
合成 Composite ≈ 1ms
/* 剩余 ≈ 6.7ms 作为缓冲 */
/* 一旦某个环节超支,整帧就会被丢弃 */
/* 掉一帧 = 用户感知到一次轻微卡顿 */
/* 连续掉 3 帧 = 用户明确感觉到“卡了” */
高刷新率屏幕(120Hz / 144Hz)会把预算压缩到 8.3ms / 6.9ms, 这就是为什么同一个动画在手机上流畅、在电竞显示器上反而更容易露馅。
60fps 不是终点,而是起点
- 60fps:流畅的及格线,绝大多数交互式动画的目标。
- 120fps:高端设备与 ProMotion 屏幕的追求,预算减半,对代码要求更苛刻。
- 30fps:仅适用于低优先级的装饰性动效,例如背景渐变呼吸。
- 低于 30fps:用户会认为“页面卡住了”,而不是“动画在播放”。
值得注意的是,掉帧的伤害远大于动画本身。一个掉了 5 帧的 300ms 动画,用户的主观感受是“这个网站很糙”;而一个稳定 60fps 的 300ms 动画,用户甚至会忽略它的存在——这正是好动画的标准。
认识浏览器渲染流水线
要写出高性能动画,必须先知道浏览器把一帧画面拆成了哪几步。点击下面的每个阶段,看看它具体做了什么,以及哪些 CSS 属性会触发它。
width、height、top、left、margin、padding 的变化都会触发,代价最高。color、background、box-shadow、border-radius 的变化都会触发。transform 与 opacity 只影响这一步,是最高效的动画属性。.slow {
transition: width 0.3s, left 0.3s;
}
/* 跳过 Layout:Style → Paint → Composite */
.medium {
transition: background-color 0.3s, box-shadow 0.3s;
}
/* 只走 Composite:Style → Composite(最快) */
.fast {
transition: transform 0.3s, opacity 0.3s;
}
/* 同一个“从左滑到右”的效果,三种实现代价天差地别 */
/* ❌ 触发 Layout */
.slide-a { left: 0; }
.slide-a:hover { left: 200px; }
/* ✅ 只触发 Composite */
.slide-b { transform: translateX(0); }
.slide-b:hover { transform: translateX(200px); }
属性代价清单:哪些能动画,哪些不能
并不是所有 CSS 属性都适合做动画。下面这张表按“触发的流水线阶段”把常见属性分成三档,绿色代表放心用,橙色代表谨慎用,红色代表尽量避免。
| 属性 | 触发阶段 | 代价 | 建议 |
|---|---|---|---|
transform |
Composite | 低 | 位移、缩放、旋转的首选 |
opacity |
Composite | 低 | 淡入淡出的首选 |
filter |
Paint + Composite | 中 | 模糊、亮度变化可用,但别大面积长时运行 |
background-color |
Paint + Composite | 中 | 小面积安全,大面积慎用 |
box-shadow |
Paint + Composite | 中 | 用伪元素 + opacity 替代更划算 |
border-radius |
Paint + Composite | 中 | 静态使用没问题,动画时注意重绘范围 |
width / height |
Layout + Paint + Composite | 高 | 改用 transform: scale() |
top / left |
Layout + Paint + Composite | 高 | 改用 transform: translate() |
margin / padding |
Layout + Paint + Composite | 高 | 几乎不该出现在动画里 |
font-size |
Layout + Paint + Composite | 高 | 改用 transform: scale() 或 SVG |
transition: left 0.3s ease;每次移动都要重新计算布局,父容器越大、兄弟节点越多,开销越高。
transition: transform 0.3s ease;元素被提升为合成层,浏览器只需重新拼接图层,主线程几乎不参与。
一个容易忽略的细节:不要动画 display
display 是不可动画属性,从 none 到 block 没有中间态,会直接跳变,且触发整棵子树的布局重算。需要“显示/隐藏 + 过渡”时,正确的做法是:
.dropdown {
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
}
.dropdown.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* 现代方案:transition-behavior: allow-discrete */
.popover {
display: none;
opacity: 0;
transition: opacity 0.25s, display 0.25s allow-discrete;
}
.popover.open {
display: block;
opacity: 1;
@starting-style { opacity: 0; }
}
transition-behavior: allow-discrete 与 @starting-style 是较新的规范,Chrome 117+ 已支持。它们让“从 display: none 到可见”的过渡第一次变得优雅。在不支持的环境里,退化方案就是上面的 opacity + visibility。
交互实验室:亲手调一调动画参数
下面这个调参台把 animation-duration、timing-function、iteration-count、direction、fill-mode 全部暴露出来。改变任意一项,方块会立刻用新的参数重新播放,下方同步生成对应的 CSS 代码——这是理解动画属性最快的方式。
观察要点:
fill-mode: both会让元素在延迟期间就呈现第一帧状态,避免“闪一下再开始”;direction: alternate让动画来回往复,比手动写两段 keyframes 更省事;steps()会产生阶跃效果,常用于打字机、精灵图、机械感 UI;cubic-bezier(0.34, 1.56, 0.64, 1)的第二个值大于 1,会产生“冲过头再回弹”的弹性手感。
缓动函数:动画的“手感”从哪来
同样位移 200px、同样耗时 1.8 秒,linear 看起来像机器人在移动,ease-out 看起来像有惯性,而带过冲的 cubic-bezier 则像有弹簧。缓动函数决定动画的“物理感”,它的重要性不亚于动画时长。
/* ease-out:起步快、结尾慢 —— 元素“进入”视野 */
.enter { animation-timing-function: ease-out; }
/* ease-in:起步慢、结尾快 —— 元素“离开”视野 */
.leave { animation-timing-function: ease-in; }
/* ease-in-out:两端慢、中间快 —— 元素在两地之间移动 */
.move { animation-timing-function: ease-in-out; }
/* 自定义回弹曲线(P2 > 1 表示过冲) */
.bounce {
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 推荐一套可直接复用的曲线变量 */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
时长同样重要:不要超过 500ms
Material Design 的研究结论是:移动端动画时长应控制在 200~300ms,桌面端可以到 300~400ms。超过 500ms 的动画会让用户感觉“慢”“拖沓”,除非它是刻意的叙事性动效(如页面转场、加载完成庆祝)。
- 微交互(按钮反馈、图标切换):100~150ms
- 常规过渡(下拉菜单、提示框):150~250ms
- 较大位移(抽屉、模态框):250~350ms
- 全屏转场:350~500ms,再长就需要理由
布局抖动:动画性能的头号杀手
比“用了昂贵属性”更糟的,是在动画过程中反复读写布局信息,导致浏览器被迫同步重排——这就是 Layout Thrashing(布局抖动)。
for (let i = 0; i < items.length; i++) {
/* 读取布局(强制同步重排) */
const h = items[i].offsetHeight;
/* 写入样式(使缓存失效) */
items[i].style.height = (h + 10) + 'px';
}
/* 结果:循环 N 次 → 强制重排 N 次 */
/* ✅ 正解一:先全部读,再全部写 */
const heights = [];
for (let i = 0; i < items.length; i++) {
heights.push(items[i].offsetHeight);
}
for (let i = 0; i < items.length; i++) {
items[i].style.height = (heights[i] + 10) + 'px';
}
/* ✅ 正解二:用 transform 完全绕开布局 */
item.style.transform = 'translateY(10px)';
/* ✅ 正解三:把动画交给 CSS,不经过 JS */
.item { transition: transform 0.25s ease-out; }
.item.active { transform: translateY(-4px); }
一个实用的经验法则是:在任何一帧内,尽量只做“读”或者只做“写”,不要交替。如果确实需要,把它们放进不同的 requestAnimationFrame 回调里,或者用 queueMicrotask 分包处理。
will-change 的正确用法与三大陷阱
will-change 是给浏览器的“预告函”:告诉它某个属性即将变化,请提前做好准备。用得好,它能消除动画开始瞬间的掉帧;用得滥,它会吃掉大量显存,反而让页面变慢。
.card {
transition: transform 0.3s ease;
}
.card:hover {
will-change: transform;
transform: translateY(-4px);
}
/* ✅ 更好:用 JS 在动画前短暂添加 */
el.addEventListener('pointerenter', () => {
el.style.willChange = 'transform';
});
el.addEventListener('animationend', () => {
el.style.willChange = 'auto'; /* 及时释放 */
});
/* ❌ 陷阱一:全局通配符 */
* { will-change: transform; }
/* ❌ 陷阱二:常驻不撤销 */
.always { will-change: transform, opacity, filter; }
/* ❌ 陷阱三:声明了根本不用的属性 */
.never-used { will-change: width, height, top, left; }
will-change 都可能创建一个合成层,每层都要占显存。几十上百个层会让 GPU 内存暴涨,在低端设备上直接崩溃或闪退。
auto 才是常态。
三个必须记住的规则
- 不要提前优化:只有当动画确实出现首帧抖动时,才考虑加
will-change。 - 不要写多个属性:写
transform或opacity就够了,写一串只会浪费显存。 - 不要写不触发的属性:给
will-change声明left毫无意义,它本来就不该被动画。
合成层不是免费的:图层爆炸的代价
很多人以为“提升为合成层 = 性能更好”,于是到处加 transform: translateZ(0)。真相是:合成层能加速合成,但会消耗显存、增加合成开销。图层数量超过一定规模后,收益会迅速转为负数。
.promote-1 { transform: translateZ(0); }
.promote-2 { backface-visibility: hidden; }
.promote-3 { will-change: transform; }
/* 一个层的显存占用 ≈ 宽 × 高 × 4 字节 */
/* 1920×1080 的层 ≈ 8.3 MB */
/* 50 个这样的层 ≈ 415 MB —— 移动端直接崩 */
/* ✅ 正确心态:让浏览器自己决定 */
/* 只动画 transform / opacity,浏览器会按需提升 */
.card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* ✅ 需要精确控制时,用 contain 隔离 */
.widget {
contain: layout paint;
}
动画与页面其他部分耦合在一起重绘
合成开销上升,低端设备掉帧
只提升真正需要独立合成的元素
DevTools 中查看图层数量与显存占用
contain 与 content-visibility:把重绘范围锁在局部
contain: layout paint 告诉浏览器:这个元素内部的布局与绘制不会影响外部。浏览器因此可以跳过大量无谓的计算。对于列表项、卡片、侧边栏这类独立组件,收益非常明显。
.product-card {
contain: layout paint;
}
/* 长列表:跳过屏幕外元素的渲染 */
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 240px; /* 占位高度 */
}
/* 注意:content-visibility 会让元素内部暂时不可见 */
/* 因此不适合用于需要被搜索、锚点跳转的内容 */
实战一:高性能的骨架屏与加载动画
骨架屏(Skeleton)是典型的“长时间运行的循环动画”。它会在页面上持续播放数秒甚至数十秒,因此对性能格外敏感。
.skeleton-bad {
background: linear-gradient(90deg, #e2e8f0, #f1f5f9, #e2e8f0);
background-size: 400% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
from { background-position: 100% 50%; }
to { background-position: 0 50%; }
}
/* ✅ 高效:用伪元素 + transform 位移 */
.skeleton {
position: relative;
overflow: hidden;
background: #e2e8f0;
border-radius: 6px;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent);
transform: translateX(-100%);
animation: sweep 1.4s ease-in-out infinite;
will-change: transform;
}
@keyframes sweep {
to { transform: translateX(100%); }
}
上方骨架屏使用 transform 扫光,主线程几乎零压力
骨架屏的三个性能要点
- 用
transform而非background-position:前者只走合成,后者每次都要重绘整块区域。 - 限制同时播放的骨架数量:一屏超过 30 个元素同时做扫光动画,即使是合成层也会吃力。
- 数据到达后立即移除动画:卸载 DOM 或移除 class,避免“看不见的动画”继续消耗资源。
实战二:列表与卡片的错峰入场动画
“所有元素一起淡入”看起来很呆板,“依次错峰淡入”则显得高级。但错峰动画最容易踩的坑是:为每个元素写一段独立的 CSS,导致样式表膨胀且难以维护。
.item:nth-child(1) { animation-delay: 0.05s; }
.item:nth-child(2) { animation-delay: 0.10s; }
.item:nth-child(3) { animation-delay: 0.15s; }
/* ……一直写到第 20 个 */
/* ✅ 巧办法:用 CSS 变量 + calc 计算延迟 */
.item {
--i: 0;
opacity: 0;
transform: translateY(16px);
animation: fadeUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes fadeUp {
to { opacity: 1; transform: translateY(0); }
}
/* JS 只需给索引 */
items.forEach((el, i) => el.style.setProperty('--i', i));
错峰的节奏:60~80ms 最舒服
延迟间隔太小(< 30ms)视觉上会糊成一片,太大(> 120ms)会让用户觉得“怎么还没加载完”。60~80ms 是公认最舒适的区间。此外要注意:
- 总时长应控制在 600ms 以内,超过就该改用“先显示一部分,滚动时再触发”。
- 对首屏关键内容,让前 3 个元素优先出现,其余延后。
- 使用
animation-fill-mode: forwards保证动画结束后元素停在最终状态,避免“闪回”。
实战三:FLIP 技术实现丝滑的位置动画
当一个元素从位置 A 移动到位置 B 时,直接修改 left/top 会触发布局,而单纯用 transform 又难以得知“起点与终点差多少”。FLIP 正是为解决这个矛盾而生的技术。
/* 1. First:记录元素初始位置 */
const first = el.getBoundingClientRect();
/* 2. Last:改变布局,让元素到达新位置 */
el.classList.add('moved');
const last = el.getBoundingClientRect();
/* 3. Invert:用 transform 把元素“拉回”初始位置 */
const dx = first.left - last.left;
const dy = first.top - last.top;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = 'none';
/* 4. Play:下一帧解除 transform,触发过渡 */
requestAnimationFrame(() => {
el.style.transition = 'transform 0.35s cubic-bezier(0.2, 0, 0, 1)';
el.style.transform = '';
});
/* 对应的 CSS:只允许 transform 参与 */
.flip-item {
will-change: transform;
}
.flip-item.settled {
will-change: auto;
}
transform 反推回起点,视觉上“没动”
FLIP 的精髓:布局变化瞬间发生,视觉过渡交给 GPU
FLIP 的适用场景
- 列表排序 / 拖拽:元素在列表中换位时的平滑过渡。
- 卡片展开为详情页:从缩略图位置放大到全屏。
- 标签页切换指示器:下划线滑动到新标签下方。
- 过滤 / 搜索后的重排:列表项重新排列时的优雅动画。
需要注意:FLIP 依赖 getBoundingClientRect(),这是一个强制同步布局的读取操作。因此必须批量处理——先把所有元素的 First 读完,再做所有 Last 的修改,最后统一 Invert & Play。
实战四:滚动驱动动画(Scroll-driven Animations)
过去做“滚动到某个位置时播放动画”,必须监听 scroll 事件再用 JS 计算,稍不注意就会掉帧。现在 CSS 原生提供了 animation-timeline,让滚动直接驱动动画,全程不占用主线程。
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: #2563eb;
transform-origin: 0 50%;
animation: grow linear;
animation-timeline: scroll();
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* 元素进入视口时播放 */
.reveal {
animation: fadeUp linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
/* 退化方案:旧浏览器用 IntersectionObserver */
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) e.target.classList.add('in-view');
});
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
与传统的 scroll 事件 + JS 计算相比,滚动驱动动画的最大优势是运行在合成线程上。即使主线程正在执行繁重的 JS,滚动动画依然保持流畅——这是过去任何 JS 方案都做不到的。
目前 animation-timeline 在 Chrome 115+ 已默认开启,Firefox 与 Safari 正在推进中。生产环境使用时,务必保留 IntersectionObserver 退化路径。
Web Animations API:用 JS 精确控制动画
CSS 动画胜在声明式、性能好;JS 动画胜在可控性强。Web Animations API(WAAPI)把两者的优势合二为一:用 JS 描述动画,但由浏览器合成线程执行。
const anim = el.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: 'translateY(-20px)', opacity: 0 }
], {
duration: 300,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'forwards'
});
/* 播放控制 */
anim.pause();
anim.play();
anim.reverse();
anim.playbackRate = 1.5; /* 1.5 倍速 */
anim.currentTime = 150; /* 跳到 150ms */
/* 事件监听 */
anim.onfinish = () => {
el.style.display = 'none';
};
/* 组合多个动画(GroupEffect 思路) */
const a1 = el.animate(fadeOut, { duration: 200 });
const a2 = el.animate(slideOut, { duration: 300 });
Promise.all([a1.finished, a2.finished]).then(cleanup);
setInterval 或 setTimeout 每 16ms 改一次 style.left。这既不准时,又会阻塞主线程。
element.animate() 交给浏览器调度,或者干脆用纯 CSS 动画,让合成线程接管。
什么时候必须用 WAAPI?
- 需要动态计算终点:例如拖拽结束后根据速度决定滑出距离。
- 需要中途暂停 / 变速 / 反向:CSS 动画做不到这些实时控制。
- 需要精确同步多个元素:用
currentTime对齐时间轴。 - 需要读取动画进度做联动:配合
requestAnimationFrame读取anim.currentTime。
性能对比实验:left 与 transform 的真实差距
理论讲了这么多,不如做一次实测。下面两条跑道同时播放“从左滑到右”的动画:上面那条动画 left(触发布局),下面那条动画 transform(只走合成)。点击开始,观察右上角的实时帧率。
这个实验想说明的核心事实是:
- 布局开销具有“传染性”:一个元素改变
left,可能引发祖先与兄弟节点的连锁重排。 - 合成开销几乎恒定:
transform只是改变图层的位置矩阵,与元素内部复杂度无关。 - 差距随规模放大:列表越长、DOM 越深,
left方案的劣势越明显。
用 DevTools 找出掉帧的元凶
性能问题最忌讳“凭感觉猜”。Chrome DevTools 提供了一整套工具链,能精确定位到具体是哪一行代码、哪一个属性造成了掉帧。
/* Step 1:Performance 面板录制 */
/* 录制 3~5 秒的动画过程,观察帧条 */
/* 红色三角 = 长任务,绿色条 = 绘制 */
/* Step 2:展开 Main 线程火焰图 */
/* 找到 Recalculate Style / Layout / Paint */
/* 看它们的耗时与调用来源 */
/* Step 3:Rendering 面板开启辅助 */
/* ✅ Paint flashing —— 高亮重绘区域 */
/* ✅ Layout Shift Regions —— 标记布局偏移 */
/* ✅ Frame Rendering Stats —— 实时帧率 */
/* ✅ Layer borders —— 显示合成层边界 */
/* Step 4:Layers 面板检查图层数量 */
/* 图层过多 → 检查 will-change / translateZ */
/* 图层过少 → 考虑提升关键动画元素 */
常见症状与对应诊断
- 动画一开始就卡一下 → 首帧创建合成层的开销,考虑提前加
will-change。 - 动画全程都不流畅 → 检查是否动画了
left/width/margin等布局属性。 - 滚动时掉帧 → 检查是否有
scroll事件监听触发了大量计算。 - 动画越跑越卡 → 检查是否有动画元素不断堆积、图层数量持续增长。
- 只有低端设备卡 → 检查合成层数量与显存占用,减少不必要的图层。
无障碍:尊重 prefers-reduced-motion
前庭功能障碍、偏头痛、眩晕症用户会对大幅度动效产生生理不适。操作系统提供了“减少动态效果”开关,CSS 通过 prefers-reduced-motion 媒体查询读取这一偏好。忽略它,不仅是技术问题,更是伦理问题。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 更优雅:保留淡入淡出,去掉位移与缩放 */
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: fadeOnly 0.2s ease both;
}
.parallax {
transform: none !important;
}
}
/* 只给明确想要的元素关闭 */
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
另外,在 JavaScript 中也可以读取同一偏好:
if (mq.matches) {
/* 跳过 JS 驱动的动画,直接呈现最终状态 */
element.style.transform = 'none';
}
/* 监听偏好变化 */
mq.addEventListener('change', (e) => {
/* 用户中途切换了系统开关 */
});
兼容性与渐进增强
动画相关的现代 CSS 特性更新速度很快,不同浏览器的支持程度差异明显。下面是关键特性的支持现状与退化策略。
/* transform / opacity 动画 —— 全平台,无需担心 */
/* @keyframes / animation —— 全平台 */
/* transition —— 全平台 */
/* will-change —— Chrome 36+ / Safari 9.1+ */
/* contain —— Chrome 52+ / Safari 15.4+ */
/* content-visibility —— Chrome 85+ / Safari 18+ */
/* animation-timeline —— Chrome 115+ */
/* transition-behavior —— Chrome 117+ */
/* @starting-style —— Chrome 117+ / Safari 17.5+ */
/* 渐进增强:先保证基础可用 */
.card {
/* 基础状态:元素本来就在正确位置 */
opacity: 1;
transform: none;
}
/* 支持时才加动画 */
@supports (animation-timeline: view()) {
.card {
animation: fadeUp linear both;
animation-timeline: view();
}
}
/* 用特性检测决定是否走 JS 路径 */
if (CSS.supports('animation-timeline', 'view()')) {
/* 原生滚动驱动,什么都不用做 */
} else {
initIntersectionObserverFallback();
}
transform / opacity / transition
will-change / contain
animation-timeline,需退化方案
allow-discrete / @starting-style
实践建议:把新特性当成“锦上添花”,而不是“雪中送炭”。基础体验在所有浏览器上都要能正常工作,新特性只在支持的浏览器上提供额外的手感提升。
高性能动画最佳实践清单
把全文的核心结论浓缩成一份可以贴在工位上的清单。
- 只动画 transform 与 opacity:这两个属性只走合成,是性能的底线。
- 用 transform 替代 top/left/width/height:把布局开销变成合成开销。
- 控制动画时长:微交互 100~150ms,常规过渡 200~300ms,上限 500ms。
- 选对缓动函数:进入用 ease-out,离开用 ease-in,位移用 ease-in-out。
- will-change 按需添加、及时移除:不要全局声明,不要常驻,不要写多个属性。
- 避免图层爆炸:图层不是越多越好,显存占用与合成开销都会上升。
- 读写分离:同一帧内不要交替读写布局信息,防止 Layout Thrashing。
- 用 gap 替代 margin 做间距:margin 会参与布局计算,gap 不会。
- 错峰动画用 CSS 变量 + calc:而不是手写 N 条 nth-child 规则。
- 长列表用 contain 或 content-visibility:把重绘范围锁在局部。
- 滚动动画优先用 animation-timeline:让合成线程接管,主线程零负担。
- 尊重 prefers-reduced-motion:减少位移与旋转,而不是粗暴地全部关闭。
- 用 DevTools 定位问题:先看清火焰图与重绘区域,再改代码。
- 在低端真机上测试:模拟器无法反映真实的 GPU 与内存压力。
/* 1. 全局缓动与时长变量 */
:root {
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 2. 统一的过渡类 */
.anim-transform {
transition: transform var(--dur-base) var(--ease-standard);
}
.anim-fade {
transition: opacity var(--dur-base) var(--ease-standard);
}
.anim-both {
transition: transform var(--dur-base) var(--ease-standard), opacity var(--dur-base) var(--ease-standard);
}
/* 3. 进入视口动画(带退化) */
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity var(--dur-slow) var(--ease-decelerate), transform var(--dur-slow) var(--ease-decelerate);
}
.reveal.in-view {
opacity: 1;
transform: translateY(0);
}
/* 4. 无障碍降级 */
@media (prefers-reduced-motion: reduce) {
.reveal {
transform: none;
transition: opacity var(--dur-fast) linear;
}
}
/* 5. 局部隔离,减少重绘范围 */
.card, .list-item, .widget {
contain: layout paint;
}
动画性能从来不是靠“多加几行优化代码”解决的,而是靠正确的属性选择、正确的层级结构、正确的心理预期。当一个动画只触发合成、时长控制在 300ms 以内、缓动符合直觉、并且在低端设备上依然稳定 60fps 时,用户甚至不会意识到它的存在——这才是它最成功的状态。
希望这篇长文能帮你建立一套完整的动画性能心智模型。如果你在项目中遇到过更刁钻的掉帧问题,欢迎在实践中反复回到上面的实验台,亲手改一改属性、比一比帧率——那比读十篇文章都管用。