主题
CSS 动画
浏览器渲染流水线
理解 CSS 动画性能的前提是理解浏览器的渲染流水线。每一帧的渲染经历以下阶段:
JavaScript → Style → Layout → Paint → Composite
(JS) (样式) (布局) (绘制) (合成)| 阶段 | 做什么 | 触发条件 |
|---|---|---|
| Style | 计算每个元素最终的 CSS 属性值 | 任何样式变化 |
| Layout | 计算元素的几何信息(位置、大小) | 改变影响布局的属性(width、height、margin、padding、top、left 等) |
| Paint | 将元素绘制为像素(文字、颜色、边框、阴影等) | 改变外观属性(color、background、box-shadow、border-radius 等) |
| Composite | 将多个图层合成为最终画面 | 改变 transform、opacity |
关键理解:流水线是瀑布式的——触发 Layout 必然触发 Paint 和 Composite,触发 Paint 必然触发 Composite。只有 Composite 阶段可以独立执行。
改变 width/height → Layout → Paint → Composite(最慢)
改变 background → Paint → Composite(中等)
改变 transform → Composite(最快)
改变 opacity → Composite(最快)这就是为什么 transform 和 opacity 是性能最好的动画属性——它们只触发合成阶段,跳过了昂贵的布局和绘制计算。
transition 过渡
基本语法
transition 让属性值的变化在一段时间内平滑过渡,而不是瞬间跳变。
css
.button {
background: #3498db;
transform: scale(1);
transition: background 0.3s ease, transform 0.2s ease;
}
.button:hover {
background: #2980b9;
transform: scale(1.05);
}四个子属性
css
.element {
transition-property: transform, opacity; /* 要过渡的属性 */
transition-duration: 0.3s; /* 过渡持续时间 */
transition-timing-function: ease; /* 缓动函数 */
transition-delay: 0s; /* 延迟时间 */
/* 简写 */
transition: transform 0.3s ease 0s, opacity 0.3s ease 0s;
/* 所有可过渡属性 */
transition: all 0.3s ease;
}transition: all 的问题:看起来方便,但会监听所有属性变化,可能导致意外的过渡效果和性能浪费。推荐明确指定要过渡的属性。
transition-timing-function 缓动函数
缓动函数控制动画的速度曲线。
预设关键字
ease (默认): 慢 → 快 → 慢 cubic-bezier(0.25, 0.1, 0.25, 1.0)
linear: 匀速 cubic-bezier(0, 0, 1, 1)
ease-in: 慢 → 快 cubic-bezier(0.42, 0, 1, 1)
ease-out: 快 → 慢 cubic-bezier(0, 0, 0.58, 1)
ease-in-out: 慢 → 快 → 慢 cubic-bezier(0.42, 0, 0.58, 1)位移
↑
│ ╱──── linear
│ ╱
│ ╱ ╱──── ease-out(快开始,慢结束)
│ ╱ ╱
│ ╱ ╱
│ ╱ ╱ ╱────── ease-in(慢开始,快结束)
│ ╱╱ ╱
│╱ ╱
└──────────────→ 时间cubic-bezier() 自定义曲线
贝塞尔曲线由 4 个控制点定义,cubic-bezier(x1, y1, x2, y2) 定义了中间两个控制点(P1 和 P2)。
css
.element {
/* 弹性效果:y 值超过 1 表示过冲 */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}推荐使用 cubic-bezier.com 可视化调试。
steps() 阶梯函数
用于逐帧动画(如精灵图动画):
css
.sprite {
width: 64px;
height: 64px;
background: url('sprite-sheet.png');
animation: walk 0.6s steps(8) infinite;
}
@keyframes walk {
from { background-position: 0 0; }
to { background-position: -512px 0; } /* 8 帧 × 64px */
}steps(4, jump-start): │_│ │_│ │_│ │_│
steps(4, jump-end): │_│ │_│ │_│ │_│ (默认)
steps(4, jump-both): │_│ │_│ │_│ │_│_│
steps(4, jump-none): _│ │_│ │_│ │_哪些属性可以过渡
并非所有 CSS 属性都可以过渡。可过渡属性必须有可插值的中间值。
css
/* ✅ 可过渡:数值型属性 */
width, height, margin, padding
opacity, color, background-color
transform, border-radius, box-shadow
font-size, line-height, letter-spacing
/* ❌ 不可过渡 */
display /* block → none 没有中间状态 */
font-family /* 字体之间没有中间状态 */
position /* static → absolute 没有中间状态 */display: none 过渡的解决方案:
css
/* 方案一:使用 opacity + visibility */
.element {
opacity: 1;
visibility: visible;
transition: opacity 0.3s, visibility 0.3s;
}
.element.hidden {
opacity: 0;
visibility: hidden;
}
/* 方案二:CSS 新特性 transition-behavior(2024+) */
.element {
transition: opacity 0.3s, display 0.3s allow-discrete;
}
.element.hidden {
opacity: 0;
display: none;
}
/* 方案三:@starting-style(2024+) */
.dialog[open] {
opacity: 1;
transition: opacity 0.3s;
}
@starting-style {
.dialog[open] {
opacity: 0;
}
}transform 变换
transform 对元素进行 2D/3D 空间变换,不影响文档流(元素原来的位置仍然保留)。
2D 变换
css
.element {
/* 平移 */
transform: translateX(100px);
transform: translateY(50px);
transform: translate(100px, 50px);
transform: translate(-50%, -50%); /* 百分比相对于元素自身尺寸 */
/* 缩放 */
transform: scale(1.5); /* 等比缩放 1.5 倍 */
transform: scaleX(2); /* 水平缩放 */
transform: scale(2, 0.5); /* 水平 2 倍,垂直 0.5 倍 */
/* 旋转 */
transform: rotate(45deg); /* 顺时针 45 度 */
transform: rotate(-90deg); /* 逆时针 90 度 */
/* 倾斜 */
transform: skewX(15deg);
transform: skew(15deg, 5deg);
}组合变换
多个变换函数可以用空格连接,执行顺序从右到左(数学上是矩阵右乘)。
css
.element {
transform: translate(100px, 0) rotate(45deg) scale(1.5);
/* 执行顺序:先 scale → 再 rotate → 最后 translate */
}顺序不同,结果不同:
css
/* 先旋转再平移 → 沿旋转后的方向平移 */
transform: rotate(45deg) translateX(100px);
/* 先平移再旋转 → 沿原始方向平移后旋转 */
transform: translateX(100px) rotate(45deg);先 rotate 再 translateX: 先 translateX 再 rotate:
↗ 100px → 100px
╱ ╲
╱ 45° ↘ 45°
● ● ●
原点 原点 目标3D 变换
css
.element {
transform: translateZ(100px); /* Z 轴平移(朝屏幕外) */
transform: translate3d(x, y, z);
transform: rotateX(45deg); /* 绕 X 轴旋转(上下翻转) */
transform: rotateY(45deg); /* 绕 Y 轴旋转(左右翻转) */
transform: rotateZ(45deg); /* 绕 Z 轴旋转(平面旋转,同 rotate) */
transform: rotate3d(1, 1, 0, 45deg);
transform: perspective(500px) rotateY(30deg); /* 透视 + 旋转 */
}transform-origin
变换的原点,默认是元素中心(50% 50%)。
css
.element {
transform-origin: center; /* 默认 */
transform-origin: top left; /* 左上角 */
transform-origin: 0 0; /* 左上角 */
transform-origin: 100% 100%; /* 右下角 */
}transform-origin: center transform-origin: top left
rotate(45deg): rotate(45deg):
┌───────┐ ●───────┐
│ ╲ │ ╲ │
│ ● ←原点 ╲ │
│ ╱ │ ╲ │
└───────┘ ╲ │
原点在左上角perspective 透视
perspective 定义观察者与 z=0 平面的距离,数值越小透视效果越强烈。
css
/* 方式一:在父容器上设置(影响所有子元素) */
.container {
perspective: 800px;
perspective-origin: 50% 50%;
}
/* 方式二:在元素自身的 transform 中设置 */
.element {
transform: perspective(800px) rotateY(30deg);
}两种方式的区别:容器上的 perspective 让所有子元素共享同一个消失点;transform 中的 perspective() 每个元素有独立的消失点。
animation 动画
@keyframes 定义关键帧
css
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0% { transform: translateY(0); }
25% { transform: translateY(-30px); }
50% { transform: translateY(0); }
75% { transform: translateY(-15px); }
100% { transform: translateY(0); }
}animation 属性
css
.element {
animation-name: fadeIn; /* 关键帧名称 */
animation-duration: 0.5s; /* 持续时间 */
animation-timing-function: ease; /* 缓动函数 */
animation-delay: 0s; /* 延迟 */
animation-iteration-count: 1; /* 播放次数(infinite = 无限) */
animation-direction: normal; /* 播放方向 */
animation-fill-mode: forwards; /* 结束后保持状态 */
animation-play-state: running; /* 播放/暂停 */
/* 简写 */
animation: fadeIn 0.5s ease 0s 1 normal forwards running;
/* 常用简写 */
animation: fadeIn 0.5s ease forwards;
}animation-direction
css
.element {
animation-direction: normal; /* 正向播放 */
animation-direction: reverse; /* 反向播放 */
animation-direction: alternate; /* 奇数次正向,偶数次反向 */
animation-direction: alternate-reverse; /* 奇数次反向,偶数次正向 */
}normal: 1→2→3 1→2→3 1→2→3
reverse: 3→2→1 3→2→1 3→2→1
alternate: 1→2→3 3→2→1 1→2→3
alternate-reverse: 3→2→1 1→2→3 3→2→1alternate 常用于连续循环动画,让动画自然地来回运动,避免每次从头开始的跳跃感。
animation-fill-mode
控制动画开始前和结束后,元素应用哪个关键帧的样式。
css
.element {
animation-fill-mode: none; /* 默认:动画前后不保持 */
animation-fill-mode: forwards; /* 动画结束后保持最后一帧 */
animation-fill-mode: backwards; /* 动画开始前就应用第一帧 */
animation-fill-mode: both; /* forwards + backwards */
}时间线: [delay] [animation] [after]
none: 原始 → 动画 → 原始 (动画前后都是原始状态)
forwards: 原始 → 动画 → 最后一帧 (保持结束状态)
backwards: 第一帧 → 动画 → 原始 (delay 期间就应用第一帧)
both: 第一帧 → 动画 → 最后一帧 (两头都保持)实际开发:大多数场景使用 forwards(入场动画结束后保持最终状态)或 both。
多动画组合
css
.element {
animation:
fadeIn 0.5s ease forwards,
slideUp 0.5s ease 0.2s forwards;
}通过 JavaScript 控制动画
css
.element {
animation: spin 2s linear infinite;
animation-play-state: paused;
}
.element.playing {
animation-play-state: running;
}监听动画事件:
javascript
element.addEventListener('animationstart', (e) => {
console.log('动画开始:', e.animationName);
});
element.addEventListener('animationend', (e) => {
console.log('动画结束:', e.animationName);
});
element.addEventListener('animationiteration', (e) => {
console.log('动画循环:', e.animationName);
});transition vs animation
| 维度 | transition | animation |
|---|---|---|
| 触发方式 | 需要状态变化触发(:hover、class 切换等) | 可自动播放,不需要触发 |
| 关键帧 | 只有起始和结束两个状态 | 可定义多个关键帧(@keyframes) |
| 循环 | 不支持 | 支持(infinite) |
| 方向 | 移除触发条件时自动反向 | 可配置 direction |
| 控制粒度 | 较低 | 高(暂停、延迟、fill-mode) |
| 适用场景 | 交互反馈(hover、focus、active) | 入场动画、加载动画、持续动效 |
选择策略:
- 需要用户交互触发(hover、click)→
transition - 需要自动播放或多步骤动画 →
animation - 简单的 A→B 变化 →
transition - 复杂的 A→B→C→D 变化 →
animation
GPU 加速与合成层
什么是合成层
浏览器在渲染时会将页面分为多个图层(Layer)。普通元素在默认图层中渲染,某些条件会将元素提升到独立的合成层,由 GPU 直接处理。
普通渲染: 合成层渲染:
┌──────────────────┐ ┌──────────────────┐ ← GPU 合成
│ 所有元素在同一层 │ │ Layer 1(背景) │
│ CPU 计算所有绘制 │ ├──────────────────┤
│ │ │ Layer 2(动画) │ ← GPU 独立处理
└──────────────────┘ ├──────────────────┤
│ Layer 3(文字) │
└──────────────────┘当动画元素在独立图层上时,GPU 只需移动/旋转/缩放该图层的纹理,不需要重新计算布局和重新绘制——这就是 GPU 加速的核心原理。
触发合成层提升的条件
| 条件 | 说明 |
|---|---|
transform: translateZ(0) 或 translate3d() | 最经典的触发方式 |
will-change: transform 或 will-change: opacity | 现代推荐方式 |
opacity 过渡/动画 | 正在进行 opacity 动画时 |
position: fixed | 固定定位元素 |
<video>、<canvas>、<iframe> | 特殊元素 |
| 有后代元素在合成层中且需要裁剪 | 层压缩相关 |
will-change
will-change 告诉浏览器元素即将发生的变化,让浏览器提前做优化准备(如提升到合成层)。
css
.animated-element {
will-change: transform;
}
.fade-element {
will-change: opacity;
}正确使用 will-change:
css
/* ✅ 在父元素 hover 时提前声明(给浏览器准备时间) */
.parent:hover .child {
will-change: transform;
}
.parent .child:active {
transform: scale(1.2);
}
/* ✅ JavaScript 中动态添加和移除 */
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
/* ❌ 不要滥用:全局声明会占用大量 GPU 内存 */
* {
will-change: transform;
}
/* ❌ 不要长期保持:应在动画结束后移除 */
.element {
will-change: transform; /* 即使不动画也占用 GPU 内存 */
}will-change 的副作用:
- 创建新的层叠上下文(影响 z-index)
- 创建新的包含块(影响 fixed 定位的后代)
- 占用 GPU 内存(每个合成层都需要额外内存)
层爆炸(Layer Explosion)
如果大量元素被提升到合成层,GPU 内存可能被耗尽,反而导致性能下降。
css
/* ❌ 可能导致层爆炸 */
.list-item {
will-change: transform; /* 1000 个列表项 = 1000 个合成层 */
}浏览器有层压缩(Layer Squashing)机制来缓解这个问题,但不能完全依赖。最佳实践是只对实际需要动画的元素使用 will-change。
性能优化最佳实践
规则一:只动画 transform 和 opacity
css
/* ✅ 高性能:只触发 Composite */
.element {
transition: transform 0.3s, opacity 0.3s;
}
.element:hover {
transform: translateY(-4px) scale(1.02);
opacity: 0.9;
}
/* ❌ 低性能:触发 Layout + Paint + Composite */
.element {
transition: top 0.3s, width 0.3s;
}
.element:hover {
top: -4px;
width: 102%;
}规则二:用 transform 替代位置属性
css
/* ❌ 触发 Layout */
.element:hover {
top: 10px;
left: 20px;
margin-top: 10px;
}
/* ✅ 只触发 Composite */
.element:hover {
transform: translate(20px, 10px);
}规则三:避免动画触发 Layout
以下属性的变化会触发 Layout(回流),尽量避免动画化:
width, height, min-width, max-width
margin, padding
top, right, bottom, left
border-width
font-size, line-height规则四:使用 contain 限制影响范围
css
.card {
contain: layout style paint;
/* layout: 内部布局变化不影响外部 */
/* style: 计数器等不影响外部 */
/* paint: 内部绘制不溢出 */
}contain 告诉浏览器这个元素是一个独立的"岛屿",内部变化不会影响外部,允许浏览器做更激进的优化。
规则五:尊重用户偏好
css
@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;
}
}部分用户(前庭功能障碍)对动画敏感,操作系统提供了"减少动画"选项。使用 prefers-reduced-motion 媒体查询尊重用户设置。
实用动画示例
入场动画
css
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fadeInUp 0.5s ease both;
}
/* 交错入场 */
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
.card:nth-child(4) { animation-delay: 0.3s; }Loading 旋转
css
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #eee;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}弹性缩放
css
@keyframes popIn {
0% { transform: scale(0); opacity: 0; }
70% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
.modal {
animation: popIn 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55) both;
}骨架屏闪光
css
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease infinite;
}打字机效果
css
.typing {
width: 0;
white-space: nowrap;
overflow: hidden;
border-right: 2px solid;
animation:
typing 3s steps(20) forwards,
blink 0.5s step-end infinite;
}
@keyframes typing {
to { width: 20ch; } /* ch 单位 = 字符 "0" 的宽度 */
}
@keyframes blink {
50% { border-color: transparent; }
}Web Animations API
CSS 动画的 JavaScript 对应物,提供更精细的控制:
javascript
const animation = element.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: 'translateY(-30px)', opacity: 0.5 },
{ transform: 'translateY(0)', opacity: 1 }
], {
duration: 1000,
iterations: Infinity,
easing: 'ease-in-out'
});
animation.pause();
animation.play();
animation.reverse();
animation.cancel();
animation.playbackRate = 2; // 2 倍速
animation.currentTime = 500; // 跳到 500ms 处
animation.finished.then(() => {
console.log('动画结束');
});WAAPI vs CSS animation 的选择:
- 固定的、不需要动态控制的动画 → CSS animation
- 需要暂停/倍速/反转/动态修改的动画 → WAAPI
- 需要精确同步多个动画 → WAAPI
- 需要根据用户交互动态创建 → WAAPI
经典面试题解析
题目一:为什么 transform 动画比 left/top 动画性能好?
left/top 的变化会触发浏览器渲染流水线的 Layout → Paint → Composite 三个阶段。Layout 阶段需要重新计算该元素及其周围元素的几何位置(回流),Paint 阶段需要重新绘制像素,这两个阶段都在 CPU 主线程执行,开销很大。
transform 的变化只触发 Composite 阶段。浏览器将元素提升到独立的合成层,GPU 直接对该层的纹理进行矩阵变换(平移、旋转、缩放),不需要重新计算布局和重新绘制。
关键区别:
left/top改变了元素在文档流中的位置 → 影响周围元素 → 需要重排transform不改变文档流 → 不影响周围元素 → 只需 GPU 合成
题目二:CSS 动画和 JS 动画哪个性能更好?
没有绝对的答案,取决于具体场景:
CSS 动画更优的场景:
- 简单的属性过渡(transform、opacity)
- 浏览器可以将 CSS 动画放到合成线程(Compositor Thread)执行
- 即使主线程被 JavaScript 阻塞,CSS 动画仍然流畅
JS 动画更优的场景:
- 需要精确控制(暂停、倍速、反转、动态修改)
- 需要基于物理模型的动画(弹簧、惯性)
- 需要同步多个动画
- 需要根据用户交互实时计算
WAAPI 是两者的结合——用 JavaScript API 声明动画,由浏览器引擎(可能在合成线程)执行。
题目三:will-change 是什么?为什么不能滥用?
will-change 提前告知浏览器元素即将变化的属性,让浏览器做优化准备(通常是提升到合成层)。
不能滥用的原因:
- 内存开销:每个合成层都需要 GPU 内存,大量合成层会导致"层爆炸"
- 创建层叠上下文:改变元素的层叠行为,可能导致 z-index 意外
- 创建包含块:影响
position: fixed后代的定位 - 过早优化:浏览器已经有自己的优化策略,过度使用
will-change反而干扰浏览器优化
题目四:如何检测和优化动画卡顿?
检测工具:
- Chrome DevTools → Performance 面板 → 录制并分析帧率
- Chrome DevTools → Rendering → 勾选 "Paint flashing"(绿色闪烁区域表示重绘)
- Chrome DevTools → Rendering → 勾选 "Layer borders"(查看合成层边界)
- Chrome DevTools → Layers 面板 → 查看合成层数量和内存占用
优化步骤:
- 确认动画属性:是否使用 transform/opacity
- 检查是否触发了 Layout:Performance 面板中的紫色条
- 检查合成层数量:是否有层爆炸
- 检查主线程负载:是否有长任务阻塞
- 考虑使用 CSS
contain限制影响范围
题目五:animation-fill-mode 的 forwards 和 both 有什么区别?
| fill-mode | 动画前(delay 期间) | 动画后 |
|---|---|---|
none | 原始样式 | 原始样式 |
forwards | 原始样式 | 保持最后一帧 |
backwards | 应用第一帧 | 原始样式 |
both | 应用第一帧 | 保持最后一帧 |
如果动画没有 animation-delay,forwards 和 both 的效果相同——因为 backwards 只在 delay 期间有效。有 delay 时,both 会在 delay 期间就应用第一帧样式,而 forwards 在 delay 期间仍显示原始样式。
追问思考
- 为什么说 CSS 动画可以在合成线程执行?什么情况下会"降级"到主线程?
transform: translateZ(0)和will-change: transform都能触发合成层提升,它们有什么区别?哪个更推荐?- CSS
contain属性对动画性能的影响原理是什么?content-visibility: auto又是如何优化性能的? - 在 React/Vue 中实现列表交错入场动画,CSS 方案和 JS 方案各有什么优劣?
prefers-reduced-motion: reduce时,应该完全移除动画还是降级为简单动画?业界的最佳实践是什么?