动画性能优化
问题
前端如何实现高性能动画?CSS 动画和 JavaScript 动画有什么区别?什么是 GPU 加速?
答案
流畅的动画需要达到 60fps(每帧约 16.67ms)。动画性能优化的核心是减少主线程工作,利用 GPU 加速,避免重排重绘。
动画性能基础
浏览器渲染流水线
| 阶段 | 工作 | 耗时 | 优化目标 |
|---|---|---|---|
| JavaScript | 动画逻辑 | 高 | 减少计算量 |
| Style | 计算样式 | 中 | 减少选择器复杂度 |
| Layout | 布局计算 | 高 | 避免触发 |
| Paint | 绘制像素 | 中 | 避免触发 |
| Composite | 合成图层 | 低 | 只触发这步 |
动画帧时间预算
60fps = 1000ms / 60 = 16.67ms 每帧
帧时间预算分配:
├── JavaScript: ~3ms
├── Style: ~1ms
├── Layout: ~2ms(避免)
├── Paint: ~2ms(避免)
├── Composite: ~1ms
└── 浏览器开销: ~2ms
剩余缓冲: ~5ms
CSS 动画 vs JavaScript 动画
| 特性 | CSS 动画 | JavaScript 动画 |
|---|---|---|
| 性能 | GPU 加速 | 主线程执行 |
| 控制性 | 有限 | 完全控制 |
| 复杂动画 | 困难 | 灵活 |
| 暂停/恢复 | CSS 变量 | 容易 |
| 动态值 | 不支持 | 支持 |
| 调试 | 困难 | 容易 |
CSS 动画最佳实践
/* ✅ 使用 transform 和 opacity - 只触发 Composite */
.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 关键帧动画
/* GPU 加速动画 */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animated {
animation: slideIn 0.5s ease-out forwards;
/* 提示浏览器开启 GPU 加速 */
will-change: transform, opacity;
}
/* 动画结束后移除 will-change */
.animated.finished {
will-change: auto;
}
GPU 加速属性
只触发 Composite 的属性
| 属性 | 说明 | GPU 加速 |
|---|---|---|
| transform: translate | 位移 | ✅ |
| transform: scale | 缩放 | ✅ |
| transform: rotate | 旋转 | ✅ |
| transform: skew | 倾斜 | ✅ |
| opacity | 透明度 | ✅ |
| filter | 滤镜(部分) | ✅ |
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;
}
requestAnimationFrame
requestAnimationFrame 是 JavaScript 动画的最佳选择,它与浏览器刷新率同步。
// 基础用法
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}`);
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;
}
/* ✅ 只触发 Composite */
.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;
}
常见面试问题
Q1: CSS 动画和 JS 动画的区别?
答案:
| 特性 | CSS 动画 | JS 动画 |
|---|---|---|
| 执行线程 | 合成线程(GPU) | 主线程 |
| 性能 | 更好 | 可能阻塞 |
| 控制性 | 有限 | 完全控制 |
| 兼容性 | 好 | 需要 polyfill |
使用建议:
- 简单动画 → CSS transition/animation
- 复杂交互 → WAAPI 或 framer-motion
- 游戏/高频更新 → Canvas/WebGL
Q2: 什么是 GPU 加速?哪些属性可以 GPU 加速?
答案:
GPU 加速是将元素提升到独立合成层,由 GPU 处理渲染。
/* GPU 加速属性 */
transform: translate/scale/rotate
opacity
filter
/* 触发 GPU 加速 */
.element {
will-change: transform;
/* 或 */
transform: translateZ(0);
}
Q3: requestAnimationFrame 和 setTimeout 的区别?
答案:
| 特性 | requestAnimationFrame | setTimeout |
|---|---|---|
| 执行时机 | 下次重绘前 | 指定延迟后 |
| 帧率 | 与刷新率同步 | 不稳定 |
| 后台标签 | 暂停 | 继续执行 |
| 精度 | 高 | 可能有偏差 |
// RAF - 60fps
function animate() {
// 每帧执行一次,与显示器刷新同步
requestAnimationFrame(animate);
}
// setTimeout - 可能卡顿
function animateBad() {
// 可能与刷新率不同步,导致丢帧
setTimeout(animateBad, 16);
}
Q4: will-change 的作用和注意事项?
答案:
/* 作用:提示浏览器准备动画优化 */
.box {
will-change: transform, opacity;
}
注意事项:
- 不要滥用:每个元素都创建合成层会消耗内存
- 动画前添加,动画后移除
- 不要用于静态元素
- 不要用在太多元素上
Q5: 如何实现 60fps 流畅动画?
答案:
// 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 处理计算
// 复杂计算在 Worker 中进行