CSS 动画与编排:合成层、FLIP 与动效性能
本文目录
动效做不做是产品决策,做得卡不卡是工程问题。同一个展开动画,用 width 驱动和用 transform 驱动,在低端手机上的表现可以差一个量级。这篇文章从渲染管线讲起,回答四个问题:哪些属性适合做动画、will-change 什么时候该用、布局变化怎么用 FLIP 变成合成动画、多元素动效什么时候值得引入 GSAP 编排。所有结论都能在 DevTools 的 Performance 与 Layers 面板里复现验证。
渲染管线:动画成本发生在哪一层
浏览器渲染一帧要经过四个阶段:样式计算 → 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。动画每帧的成本,取决于改的属性会把流水线触发到哪一段:
| 属性类别 | 常见例子 | 触发阶段 | 动画中的定位 |
|---|---|---|---|
| 触发布局 | width、height、top/left、margin、font-size | 样式 → 布局 → 绘制 → 合成 | 全流程重跑,尽量避免 |
| 触发绘制 | color、background-color、box-shadow | 样式 → 绘制 → 合成 | 小面积可用,大面积谨慎 |
| 依赖实现 | filter、backdrop-filter | 绘制与合成,视浏览器实现 | 不等于"GPU 免费" |
| 合成友好 | transform、opacity | 合成 | 首选 |
/* 反例:宽度动画每帧都触发布局,列表一长帧率先扛不住 */
.drawer--bad { transition: width 240ms ease; }
.drawer--bad.is-open { width: 320px; }
/* 正例:布局一次到位,用 transform 演出"展开"过程,内容另做淡入 */
.drawer--good {
transform: scaleX(0);
transform-origin: left center;
transition: transform 240ms ease;
}
.drawer--good.is-open { transform: scaleX(1); }
两个常见误判:
transform/opacity不是零成本。每个合成层对应一张 GPU 纹理,层数增加会带来显存占用与合成耗时,后面"层爆炸"一节展开。- 大面积阴影动画很贵。
box-shadow过渡会反复触发大区域重绘;替代方案是预先放好一个带阴影的伪元素,动画期间只对它做opacity过渡。
验证方法:Performance 面板录制一段动画,看每帧的 Layout/Paint 色块。理想状态是动画期间整帧只有合成,没有紫色(布局)与深绿(绘制)条。性能结论必须来自录制数据,不要来自"感觉变流畅了"。
三种驱动方式:transition、animation 与 WAAPI
| 驱动方式 | 定位 | 可控性 | 典型适用 |
|---|---|---|---|
CSS transition | 两个状态间的插值 | 弱,只能切换状态 | 悬停、展开、选中态 |
CSS animation | 多关键帧、循环 | 中,有取消与结束事件 | 加载旋转、入场循环 |
WAAPI(element.animate) | 用 JS 创建动画 | 强,可暂停/跳转/反转 | FLIP、动态目标动画 |
requestAnimationFrame 手写 | 完全自定义 | 最强,但要自己管调度 | 物理模拟、惯性跟手 |
// WAAPI:JS 创建、可控制的动画,返回 Animation 实例
const anim = el.animate(
[
{ opacity: 0, transform: 'translateY(16px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 240, easing: 'ease-out', fill: 'both' }
);
anim.finished
.then(() => el.classList.add('is-settled'))
.catch(() => {
// 动画被取消(元素移除、偏好设置)会 reject,业务状态要能自愈
el.classList.add('is-settled');
});
选型规则一句话说清:两三个状态间的切换交给 transition;固定循环交给 animation;需要 JS 创建、取消、反转、串联的交给 WAAPI;只有每帧值不可预知(物理模拟、惯性)才手写 rAF。手写 rAF 意味着帧调度、页面隐藏暂停、时间步长这些细节全部自己负责,能用前三种就别用第四种。
合成层与 will-change:提示要克制
合成层不会凭空产生,常见来源有这些:
| 机制 | 例子 | 结果 |
|---|---|---|
| 3D 变换 | translateZ(0)、rotate3d(...) | 强制提升 |
| 显式提示 | will-change: transform | 强制提升 |
| 运行中的合成动画 | 浏览器对动画中的 transform/opacity | 自动提升,动画结束回落 |
| 布局特性 | position: fixed、某些固定背景 | 视浏览器策略 |
will-change 是给浏览器的提示:"这个元素马上要动,请提前为它准备独立层"。用得对,它消除动画开始瞬间的卡顿;用错,就是层爆炸:
- 症状:内存占用上涨、低端机滚动掉帧、Layers 面板里满屏色块。
- 规则一:只在动画即将发生时设置,结束后移除,让层回落。
- 规则二:永远不要对长列表的每一项都加
will-change。 - 规则三:拿不准就先不加,用 Performance 录制对比加与不加的差异再决定。
// 交互开始前提示,结束后回收:把层的生命周期绑定到交互生命周期
function hintLayer(el: HTMLElement): void {
el.style.willChange = 'transform, opacity';
}
function releaseLayer(el: HTMLElement): void {
el.style.willChange = '';
}
handle.addEventListener('pointerdown', () => hintLayer(card));
handle.addEventListener('pointerup', () => releaseLayer(card));
DevTools 的 Layers(或 Rendering 面板的 Layer borders)能直接看到层数量与每层尺寸。移动端显存有限,层数量是比 JS 执行时间更容易被忽略的性能上限。
FLIP:把布局变化变成 transform 动画
列表重排、卡片换位、面板尺寸变化,这类动画的麻烦在于布局本身变了——直接动画 top/left 会每帧触发布局。FLIP 的思路是:布局瞬间跳到终点,再用 transform 把元素从起点"演"过去:
- First:改动前记录每个元素的位置
getBoundingClientRect()。 - Last:改 DOM,布局一步到位,再记录新位置。
- Invert:对新位置施加反向
transform,让视觉回到旧位置。 - Play:把
transform过渡回none,浏览器在合成器上完成动画。
type Rect = { left: number; top: number; width: number; height: number };
function rectOf(el: HTMLElement): Rect {
const r = el.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}
/** 对一批元素做 FLIP:mutate 里执行任意 DOM 改动,前后各量一次 */
function flipAll(
elements: HTMLElement[],
mutate: () => void,
duration = 240
): void {
const firsts = new Map(elements.map((el) => [el, rectOf(el)]));
mutate(); // First → Last:布局一步到位
for (const el of elements) {
const first = firsts.get(el)!;
const last = rectOf(el);
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) continue; // 没动的元素不动画
el.animate( // Invert + Play
[
{ transform: `translate(${dx}px, ${dy}px)` },
{ transform: 'translate(0px, 0px)' },
],
{ duration, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
}
// 用法:列表项上移一位,所有受影响元素一起过渡
function moveItem(list: HTMLElement, from: number, to: number): void {
const items = [...list.children] as HTMLElement[];
const moving = items[from];
flipAll(items, () => {
const ref = to < from ? items[to] : items[to].nextSibling;
list.insertBefore(moving, ref);
});
}
FLIP 的边界,写代码前先想清楚:
getBoundingClientRect()的返回值包含 transform。所以 First 必须在元素没有残留 transform 时测量;如果元素自带动画,先等动画结束或暂时禁用。- 涉及尺寸变化(scale)时,文字会被非均匀拉伸,常见做法是只对容器做位移 FLIP,内容用
opacity淡入淡出掩盖形变。 - 长列表只对视口内(
IntersectionObserver判定)的元素做 FLIP,视口外的直接跳到位。 - WAAPI 动画不依赖 transition 类名,元素卸载时动画自动取消,不需要手动清理类。
滚动驱动动画:把进度交给合成器
传统做法是监听 scroll 事件,在回调里读布局、写样式,主线程一忙就掉帧;而且滚动事件本质是"事后通知",动画永远滞后一帧。CSS 滚动驱动动画(scroll-driven animations)把"滚动进度"直接映射为动画进度,滚动跟随在合成器上完成,主线程卡死也能继续动:
/* 页面滚动进度 → 顶栏进度条,scaleX 随滚动从 0 到 1 */
.progress-bar {
transform-origin: left center;
animation: grow-progress linear both;
animation-timeline: scroll(nearest block);
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* 元素穿过视口的过程 → 卡片入场;只取"进入视口 0%~70%"这段进度 */
.reveal-card {
animation: reveal-card linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 70%;
}
@keyframes reveal-card {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
注意这里的 animation-duration 不再表示时间,滚动进度就是时长来源(常见写法是保持 linear,由滚动决定快慢)。兼容性方面,Chromium 系已稳定支持,Firefox 与 Safari 的支持状态需要按发布时的兼容性数据确认,上线必须带能力检测和降级:
const supportsScrollTimeline = CSS.supports('animation-timeline: scroll()');
if (supportsScrollTimeline) {
document.documentElement.classList.add('scroll-timeline');
} else {
// 降级一:IntersectionObserver 加 class,触发普通 transition(够用且便宜)
// 降级二:rAF + scroll 监听做位移,注意 passive: true 与节流
document.documentElement.classList.add('no-scroll-timeline');
}
降级一优先:大多数"滚动入场"用 IntersectionObserver 切 class + transition 就足够,没必要为每个场景都上精确的滚动进度映射。滚动进度真正不可替代的场景是"吸顶工具栏随滚动逐渐收窄""长页面的阅读进度条"这类与滚动位置强耦合的效果。
GSAP:什么时候值得引入
先回答"原生能不能做",再回答"GSAP 做得是不是明显更好":
| 场景 | 建议 |
|---|---|
| 单元素两三态切换、入场淡入 | CSS transition/animation 足够 |
| 列表重排、位置过渡 | WAAPI + FLIP,自己写约 30 行 |
| 多元素时序编排、互相错峰、整体快放/回退 | GSAP timeline 明显省事 |
| 动画进度与滚动位置绑定、需要 scrub | ScrollTrigger 是成熟方案 |
| 文本拆字、专门的 Flip/形变能力 | 对应插件解决专门问题 |
GSAP 的价值不是"跑得更快"——合成友好属性的原生动画同样在合成器上跑——而是编排:相对时间定位、stagger 批量错峰、统一的 pause/seek/timeScale,以及动画状态与框架渲染周期解耦。
import gsap from 'gsap';
// 一条时间线描述整屏入场:标题 → 副标题 → 列表项错峰,可整体暂停与重放
const tl = gsap.timeline({ defaults: { duration: 0.5, ease: 'power2.out' } });
tl.from('.hero-title', { y: 28, opacity: 0 })
.from('.hero-sub', { y: 16, opacity: 0 }, '-=0.35')
.from('.hero-item', { y: 12, opacity: 0, stagger: 0.08 }, '-=0.3')
.pause();
document.querySelector('.hero-replay')?.addEventListener('click', () => {
tl.restart();
});
引入成本要如实评估:额外的依赖体积(以实际构建产物为准)、版本升级与维护、团队需要理解 timeline 模型。授权方面,GSAP 已宣布核心与插件免费开放,商用细节以官方网站的许可说明为准,不要依赖二手转述。
简单规则:动效元素不超过 3 个、没有滚动联动、没有跨元素时序编排 → 原生足够;存在"多条时间线互相配合、需要全局播放控制、复杂滚动联动"再上 GSAP。为两处淡入引入一个动画库,属于为工具找需求。
尊重 prefers-reduced-motion:统一封装,而不是各写各的
前庭功能受损的用户会因为大幅位移、视差、闪烁产生不适。prefers-reduced-motion 是用户在系统级表达的偏好,工程上要避免每个组件各写一份判断。做法是 CSS 与 JS 各留一个统一入口:
const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
export function prefersReduced(): boolean {
return motionQuery.matches;
}
// 偏好变化是运行时事件:用户在系统设置里切换时页面要即时响应
motionQuery.addEventListener('change', () => {
document.documentElement.dataset.reducedMotion = String(prefersReduced());
});
@media (prefers-reduced-motion: reduce) {
.reveal-card,
.hero-item {
animation: none;
transition-duration: 1ms; /* 状态仍要切换,只是不演过程 */
}
.parallax-layer { transform: none; }
}
JS 侧所有动效入口统一走 prefersReduced() 判断:reduce 时直接 finish 或跳过 WAAPI 动画、GSAP timeline 直接 progress(1)。注意 reduce 不等于全部砍光:跨屏幕位移、视差、闪烁要降级;小幅 opacity 淡入淡出通常可以保留,因为它们不涉及前庭刺激。判断依据是"运动是否携带信息",不是"是否在动"。
注意事项与边界
- 不要依据桌面 Chrome 的流畅度下结论,动画性能的差距在低端 Android 与长页面上才暴露,验收要在目标最低端设备上做。
will-change写进 CSS 常驻不动的元素(如全局* { will-change: transform })是明确反模式,层会只增不减。- FLIP 依赖测量,元素在
display: none容器、字体晚加载时 rect 会量不准;关键路径动画要等布局稳定后再触发。 - 滚动驱动动画依赖的
animation-timeline是渐进增强能力,主流程功能不得依赖它——没有它时用户必须仍能获得完整信息。 - WAAPI 的
finished在动画被取消时 reject,别把它当"必定到达"的回调;组件卸载时业务状态必须独立于动画完成。 - GSAP 的 timeline 与框架状态(如 React 的 render)是两套节奏,动画结束态要回写到状态,否则重渲染会"闪回"。
- 无限循环动画(呼吸、闪烁)要在页面
hidden时暂停,visibilitychange是可靠信号。 - 合成层、
filter的实际成本因浏览器版本与 GPU 差异很大,结论以目标设备实测为准。
总结
动效性能的决策链可以压缩成三句话:属性上,优先 transform 和 opacity,把布局动画用 FLIP 换成合成动画;层上,will-change 只做交互期间的临时提示,层数量在 Layers 面板里盯住;编排上,原生能力(transition/animation/WAAPI + 滚动时间线)覆盖大多数场景,GSAP 留给真正的多元素时序与滚动联动。最后一条底线不变:所有动效都要在 prefers-reduced-motion 下有可用的降级形态。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。