Skip to content

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(最快)

这就是为什么 transformopacity 是性能最好的动画属性——它们只触发合成阶段,跳过了昂贵的布局和绘制计算。


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→1

alternate 常用于连续循环动画,让动画自然地来回运动,避免每次从头开始的跳跃感。

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

维度transitionanimation
触发方式需要状态变化触发(: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: transformwill-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 的副作用

  1. 创建新的层叠上下文(影响 z-index)
  2. 创建新的包含块(影响 fixed 定位的后代)
  3. 占用 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 提前告知浏览器元素即将变化的属性,让浏览器做优化准备(通常是提升到合成层)。

不能滥用的原因:

  1. 内存开销:每个合成层都需要 GPU 内存,大量合成层会导致"层爆炸"
  2. 创建层叠上下文:改变元素的层叠行为,可能导致 z-index 意外
  3. 创建包含块:影响 position: fixed 后代的定位
  4. 过早优化:浏览器已经有自己的优化策略,过度使用 will-change 反而干扰浏览器优化

题目四:如何检测和优化动画卡顿?

检测工具

  • Chrome DevTools → Performance 面板 → 录制并分析帧率
  • Chrome DevTools → Rendering → 勾选 "Paint flashing"(绿色闪烁区域表示重绘)
  • Chrome DevTools → Rendering → 勾选 "Layer borders"(查看合成层边界)
  • Chrome DevTools → Layers 面板 → 查看合成层数量和内存占用

优化步骤

  1. 确认动画属性:是否使用 transform/opacity
  2. 检查是否触发了 Layout:Performance 面板中的紫色条
  3. 检查合成层数量:是否有层爆炸
  4. 检查主线程负载:是否有长任务阻塞
  5. 考虑使用 CSS contain 限制影响范围

题目五:animation-fill-mode 的 forwards 和 both 有什么区别?

fill-mode动画前(delay 期间)动画后
none原始样式原始样式
forwards原始样式保持最后一帧
backwards应用第一帧原始样式
both应用第一帧保持最后一帧

如果动画没有 animation-delayforwardsboth 的效果相同——因为 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 时,应该完全移除动画还是降级为简单动画?业界的最佳实践是什么?

用心学习,用代码说话 💻