动画性能优化
问题
前端如何实现高性能动画?CSS 动画和 JavaScript 动画有什么区别?什么是 GPU 加速?
前端如何实现高性能动画? 目标是跟上设备刷新率:60Hz 屏幕一帧约 16.67ms,120Hz 屏幕约 8.33ms,而业务代码能使用的时间还要扣除浏览器渲染开销:
- 优先动画
transform和opacity——它们通常可以避免 Layout/Paint,并有机会在合成线程执行;但元素首次绘制、图层分配、滤镜和超大纹理仍可能占用主线程、栅格线程或显存。 - 用
requestAnimationFrame替代setTimeout/setInterval,跟浏览器刷新率同步,避免掉帧。 - 复杂计算丢给 Web Worker,避免主线程被 JS 阻塞导致掉帧。
- 用 DevTools Performance 面板录制,结合 Frames、Main、Raster、GPU 和 Long Animation Frames 判断瓶颈,不只看一个 FPS 数字。
CSS 动画和 JavaScript 动画有什么区别? 两者各有适用场景,不是「谁更好」而是「什么时候用谁」:
- CSS 动画(
transition/@keyframes):声明式、引擎容易优化,适合简单状态切换、hover 效果、loading;只有满足可合成条件时才可能脱离主线程。 - JS 动画(
requestAnimationFrame/WAAPI):完全可控、能根据数据/事件实时调整、容易做缓动、暂停、反向;适合复杂交互动画、物理动效、需要根据用户输入响应的场景。 - Web Animations API(
element.animate()) 是折中方案:JS 负责创建和控制动画,符合条件的属性可由浏览器下放到合成线程,性能通常接近对应 CSS 动画。 - 一般原则:能用 CSS 就用 CSS,复杂逻辑用 WAAPI 或 Framer Motion / GSAP。
什么是 GPU 加速? 通常指浏览器把元素放入独立合成层,在属性变化时复用已经栅格化的纹理完成合成:
will-change: transform是优化提示,不是强制命令;translateZ(0)、backface-visibility属于实现技巧,不应作为稳定 API 依赖。- 好处:满足条件时,后续帧可以跳过 Layout/Paint;但样式计算、图层创建、首次 Paint、Raster 和纹理上传仍有成本。
- 代价:每个合成层都吃显存,移动端尤其敏感——不要给
*加will-change,会让浏览器创建大量层、吃光显存反而更卡。 - 正确用法:动画开始前加
will-change: transform,动画结束后移除(设回auto),按需启用。
答案
流畅动画的目标是跟上当前设备刷新率,并保持稳定的帧间隔。动画性能优化的核心是减少关键帧内的主线程、布局、绘制和栅格化工作,而不是单纯“开启 GPU”。
动画性能基础
浏览器渲染流水线
| 阶段 | 工作 | 耗时 | 优化目标 |
|---|---|---|---|
| JavaScript | 动画逻辑 | 高 | 减少计算量 |
| Style | 计算样式 | 中 | 减少选择器复杂度 |
| Layout | 布局计算 | 高 | 避免触发 |
| Paint | 绘制像素 | 中 | 避免触发 |
| Composite | 合成图层与纹理 | 通常较低,但受图层数量和面积影响 | 尽量复用已绘制内容 |
动画帧时间预算
60Hz:1000ms / 60 = 16.67ms 每帧
120Hz:1000ms / 120 = 8.33ms 每帧
帧时间预算分配:
├── JavaScript: ~3ms
├── Style: ~1ms
├── Layout: ~2ms(避免)
├── Paint: ~2ms(避免)
├── Composite: ~1ms
└── 浏览器开销: ~2ms
剩余缓冲: ~5ms
CSS 动画 vs JavaScript 动画
| 特性 | CSS 动画 | JavaScript 动画 |
|---|---|---|
| 性能 | 可合成属性有机会脱离主线程 | 通常由主线程驱动,也可创建 WAAPI |
| 控制性 | 有限 | 完全控制 |
| 复杂动画 | 困难 | 灵活 |
| 暂停/恢复 | CSS 变量 | 容易 |
| 动态值 | 可用 CSS 变量等方式传入,但控制能力有限 | 支持 |
| 调试 | 困难 | 容易 |
CSS 动画最佳实践
/* ✅ 优先使用 transform 和 opacity,通常可避免 Layout/Paint */
.box {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.box:hover {
transform: translateX(100px) scale(1.1);
opacity: 0.8;
}
/* ❌ 避免使用这些属性 - 触发 Layout/Paint */
.box-bad:hover {
width: 200px; /* Layout */
height: 200px; /* Layout */
margin-left: 50px; /* Layout */
background: red; /* Paint */
box-shadow: 0 0 10px; /* Paint */
}
CSS 关键帧动画
/* 合成友好的动画 */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animated {
animation: slideIn 0.5s ease-out forwards;
/* 提示浏览器这个属性即将变化;浏览器仍会自行决定是否分层 */
will-change: transform, opacity;
}
/* 动画结束后移除 will-change */
.animated.finished {
will-change: auto;
}
合成友好属性与图层
通常可以只更新 Composite 的属性
| 属性 | 常见表现 | 注意事项 |
|---|---|---|
transform: translate/rotate/skew | 通常只需合成 | 首次分层和栅格化仍有成本 |
transform: scale | 通常只需合成 | 放大低分辨率纹理可能模糊,也可能触发重新栅格化 |
opacity | 通常只需合成 | 大面积半透明内容会增加混合成本 |
filter | 取决于滤镜和浏览器实现 | 模糊、阴影等可能昂贵,不应默认视为“免费 GPU 属性” |
是否创建合成层由浏览器启发式决定。层越多,显存、管理、栅格和合成成本越高;超大元素或频繁变更的内容即使只改 transform,仍可能掉帧。最终结论要以 Performance 和 Layers 面板为准。
will-change 使用
/* ✅ 正确使用:在动画开始前添加 */
.box {
will-change: transform;
}
/* ❌ 避免滥用 */
* {
will-change: transform, opacity; /* 消耗大量内存 */
}
/* ✅ 动态添加/移除 */
.box:hover {
will-change: transform;
}
.box:active {
transform: scale(1.1);
}
不要依赖“强制创建合成层”技巧
/* 这些写法可能影响分层,但浏览器不保证一定创建独立层 */
.gpu-layer {
transform: translateZ(0);
/* 或 */
will-change: transform;
/* 或 */
backface-visibility: hidden;
}
优先表达真实意图:短时间即将发生动画时设置 will-change,结束后移除。不要为了“开启 GPU”长期给所有元素添加 3D transform。
requestAnimationFrame
requestAnimationFrame 是 JavaScript 动画的最佳选择,它与浏览器刷新率同步。
高刷新率设备会更频繁地调用回调,后台标签页又可能暂停回调。动画进度必须使用回调提供的 timestamp 计算,这样 60Hz、120Hz 和偶发掉帧下的总时长才一致。
// 基础用法
function animate() {
// 更新动画状态
element.style.transform = `translateX(${x}px)`;
// 继续下一帧
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// 带时间控制
function animateWithTime(timestamp: number) {
if (!startTime) startTime = timestamp;
const progress = (timestamp - startTime) / duration;
if (progress < 1) {
const value = easeOut(progress);
element.style.transform = `translateX(${value * 100}px)`;
requestAnimationFrame(animateWithTime);
}
}
封装动画函数
interface AnimationOptions {
duration: number;
easing?: (t: number) => number;
onUpdate: (progress: number) => void;
onComplete?: () => void;
}
function animate({
duration,
easing = (t) => t, // 线性
onUpdate,
onComplete,
}: AnimationOptions): () => void {
let startTime: number | null = null;
let rafId: number;
function frame(timestamp: number) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easing(progress);
onUpdate(easedProgress);
if (progress < 1) {
rafId = requestAnimationFrame(frame);
} else {
onComplete?.();
}
}
rafId = requestAnimationFrame(frame);
// 返回取消函数
return () => cancelAnimationFrame(rafId);
}
// 常用缓动函数
const easings = {
linear: (t: number) => t,
easeIn: (t: number) => t * t,
easeOut: (t: number) => t * (2 - t),
easeInOut: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeOutElastic: (t: number) => {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// 使用示例
const cancel = animate({
duration: 1000,
easing: easings.easeOut,
onUpdate: (progress) => {
element.style.transform = `translateX(${progress * 200}px)`;
},
onComplete: () => {
console.log('Animation complete');
},
});
Web Animations API
Web Animations API (WAAPI) 结合了 CSS 动画和 JavaScript 控制的优点。
// 基础用法
const animation = element.animate(
[
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0.5 }
],
{
duration: 1000,
easing: 'ease-out',
fill: 'forwards',
}
);
// 控制动画
animation.play();
animation.pause();
animation.reverse();
animation.cancel();
// 监听事件
animation.onfinish = () => console.log('Finished');
animation.oncancel = () => console.log('Cancelled');
// 异步等待
await animation.finished;
复杂动画编排
// 序列动画
async function sequenceAnimation(elements: HTMLElement[]) {
for (const element of elements) {
await element.animate(
[
{ transform: 'scale(0)', opacity: 0 },
{ transform: 'scale(1)', opacity: 1 }
],
{ duration: 300, fill: 'forwards' }
).finished;
}
}
// 并行动画
function parallelAnimation(elements: HTMLElement[]) {
return Promise.all(
elements.map(element =>
element.animate(
[
{ transform: 'translateY(-20px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{ duration: 300, fill: 'forwards' }
).finished
)
);
}
// 交错动画
function staggerAnimation(elements: HTMLElement[], stagger = 50) {
elements.forEach((element, index) => {
element.animate(
[
{ transform: 'translateY(-20px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{
duration: 300,
delay: index * stagger,
fill: 'forwards',
}
);
});
}
动画性能分析
Chrome DevTools Performance
// 使用 Performance API 测量
performance.mark('animation-start');
// 执行动画...
performance.mark('animation-end');
performance.measure('animation', 'animation-start', 'animation-end');
const measure = performance.getEntriesByName('animation')[0];
console.log(`Animation took ${measure.duration}ms`);
帧率监控
class FPSMonitor {
private frames = 0;
private lastTime = performance.now();
private fps = 0;
private rafId: number | null = null;
start() {
const loop = (time: number) => {
this.frames++;
if (time - this.lastTime >= 1000) {
this.fps = this.frames;
this.frames = 0;
this.lastTime = time;
this.onFPSUpdate(this.fps);
}
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
}
private onFPSUpdate(fps: number) {
console.log(`FPS: ${fps}`);
// 这里只是针对 60Hz 测试环境的演示阈值;生产分析应结合设备刷新率和帧间隔
if (fps < 50) {
console.warn('Low frame rate detected');
}
}
}
检测动画卡顿
function detectJank() {
let lastFrameTime = performance.now();
let jankCount = 0;
function frame(time: number) {
const delta = time - lastFrameTime;
// 超过 50ms 认为卡顿(低于 20fps)
if (delta > 50) {
jankCount++;
console.warn(`Jank detected: ${delta.toFixed(2)}ms frame`);
}
lastFrameTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
return () => jankCount;
}
React 动画优化
import { useRef, useEffect, useCallback } from 'react';
// 使用 CSS + className
function CSSAnimation() {
const [isVisible, setIsVisible] = useState(false);
return (
<div className={`box ${isVisible ? 'visible' : ''}`}>
Content
</div>
);
}
// 使用 WAAPI
function WAAPIAnimation() {
const ref = useRef<HTMLDivElement>(null);
const animate = useCallback(() => {
ref.current?.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.1)' },
{ transform: 'scale(1)' },
],
{ duration: 200, easing: 'ease-in-out' }
);
}, []);
return <div ref={ref} onClick={animate}>Click me</div>;
}
// 使用 framer-motion
import { motion, useAnimationControls } from 'framer-motion';
function FramerMotionExample() {
const controls = useAnimationControls();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
Content
</motion.div>
);
}
常见优化技巧
1. 使用 transform 代替位置属性
/* ❌ 触发 Layout */
.bad {
left: 100px;
top: 50px;
}
/* ✅ 通常可以避免 Layout/Paint,是否独立合成以浏览器实际结果为准 */
.good {
transform: translate(100px, 50px);
}
2. 避免动画期间读取布局
// ❌ 强制同步布局
function animateBad(element: HTMLElement) {
const height = element.offsetHeight; // 强制布局
element.style.height = `${height + 10}px`; // 触发布局
}
// ✅ 批量读写
function animateGood(element: HTMLElement) {
// 批量读取
const height = element.offsetHeight;
// 使用 RAF 批量写入
requestAnimationFrame(() => {
element.style.height = `${height + 10}px`;
});
}
3. 使用 CSS 变量动态更新
.box {
--x: 0;
--y: 0;
transform: translate(var(--x), var(--y));
}
// 高效更新
function updatePosition(element: HTMLElement, x: number, y: number) {
element.style.setProperty('--x', `${x}px`);
element.style.setProperty('--y', `${y}px`);
}
4. 使用 contain 隔离重绘
.animated-card {
/* 隔离重绘影响 */
contain: layout style paint;
}
现代动画实践:可访问性与页面过渡
尊重减少动态效果设置
动画不仅是性能问题,也是可访问性问题。大幅缩放、视差和连续位移动画可能让部分用户不适,应提供降级。
.panel {
transition: transform 240ms ease, opacity 240ms ease;
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition-duration: 1ms;
animation-iteration-count: 1;
}
}
View Transitions API
页面或路由级过渡可优先评估 View Transitions API,由浏览器捕获旧/新状态并生成伪元素动画。它减少了手工克隆 DOM 的复杂度,但仍需做特性检测、控制快照面积,并提供无动画降级。
function updateRoute(render: () => void): void {
if (!document.startViewTransition) {
render();
return;
}
document.startViewTransition(() => render());
}
验证动画是否真的合成友好
- Performance 面板录制真实交互,不只录空页面动画。
- 查看 Main 是否出现 Recalculate Style、Layout、Paint 和长任务。
- 查看 Frames、Raster、GPU 与 Layers,确认图层数量和纹理面积没有异常增长。
- 在中低端移动设备、60Hz/120Hz 屏幕和
prefers-reduced-motion下验证。
常见面试问题
Q1: CSS 动画和 JS 动画的区别?
答案:
| 特性 | CSS 动画 | JS 动画 |
|---|---|---|
| 执行位置 | 可合成属性有机会由合成线程推进 | 动画逻辑通常在主线程;WAAPI 可下放部分工作 |
| 性能 | 声明式、通常更容易优化 | 控制灵活,回调过重时会阻塞 |
| 控制性 | 有限 | 完全控制 |
| 兼容性 | 好 | 需要 polyfill |
使用建议:
- 简单动画 → CSS transition/animation
- 复杂交互 → WAAPI 或 framer-motion
- 游戏/高频更新 → Canvas/WebGL
Q2: 什么是合成层?为什么 transform/opacity 通常更流畅?
答案:
浏览器可以把元素绘制成独立纹理,后续只改变 transform 或 opacity 时复用纹理完成合成,从而避免重复布局和绘制。是否分层由浏览器决定,开发者只能通过 will-change 提示,不能保证“开启 GPU”。
/* 通常合成友好的属性 */
transform: translate/scale/rotate;
opacity: 0.8;
/* 表达即将变化的意图,不保证一定创建图层 */
.element {
will-change: transform;
}
Q3: requestAnimationFrame 和 setTimeout 的区别?
答案:
| 特性 | requestAnimationFrame | setTimeout |
|---|---|---|
| 执行时机 | 下次重绘前 | 指定延迟后 |
| 帧率 | 与刷新率同步 | 不稳定 |
| 后台标签 | 暂停 | 继续执行 |
| 精度 | 高 | 可能有偏差 |
// RAF:按 timestamp 计算进度,适配不同刷新率
function animate(timestamp: number) {
updateByTime(timestamp);
requestAnimationFrame(animate);
}
// setTimeout - 可能卡顿
function animateBad() {
// 可能与刷新率不同步,导致丢帧
setTimeout(animateBad, 16);
}
Q4: will-change 的作用和注意事项?
答案:
/* 作用:提示浏览器准备动画优化 */
.box {
will-change: transform, opacity;
}
注意事项:
- 不要滥用:每个元素都创建合成层会消耗内存
- 动画前添加,动画后移除
- 不要用于静态元素
- 不要用在太多元素上
Q5: 如何让动画在不同刷新率设备上保持流畅?
答案:
// 1. 优先使用通常合成友好的 transform 和 opacity
element.style.transform = 'translateX(100px)';
// 2. 使用 requestAnimationFrame
function animate() {
updateAnimation();
requestAnimationFrame(animate);
}
// 3. 避免强制同步布局
const rect = element.getBoundingClientRect();
requestAnimationFrame(() => {
element.style.transform = `translateX(${rect.x}px)`;
});
// 4. 减少 DOM 操作
// 使用 CSS 变量批量更新
// 5. 使用 Web Workers 处理纯计算,并尊重 prefers-reduced-motion
// 复杂计算在 Worker 中进行