跳到主要内容

动画性能优化

问题

前端如何实现高性能动画?CSS 动画和 JavaScript 动画有什么区别?什么是 GPU 加速?

面试速答版

前端如何实现高性能动画? 目标是跟上设备刷新率:60Hz 屏幕一帧约 16.67ms,120Hz 屏幕约 8.33ms,而业务代码能使用的时间还要扣除浏览器渲染开销:

  • 优先动画 transformopacity——它们通常可以避免 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());
}

验证动画是否真的合成友好

  1. Performance 面板录制真实交互,不只录空页面动画。
  2. 查看 Main 是否出现 Recalculate Style、Layout、Paint 和长任务。
  3. 查看 Frames、Raster、GPU 与 Layers,确认图层数量和纹理面积没有异常增长。
  4. 在中低端移动设备、60Hz/120Hz 屏幕和 prefers-reduced-motion 下验证。

常见面试问题

Q1: CSS 动画和 JS 动画的区别?

答案

特性CSS 动画JS 动画
执行位置可合成属性有机会由合成线程推进动画逻辑通常在主线程;WAAPI 可下放部分工作
性能声明式、通常更容易优化控制灵活,回调过重时会阻塞
控制性有限完全控制
兼容性需要 polyfill

使用建议

  • 简单动画 → CSS transition/animation
  • 复杂交互 → WAAPI 或 framer-motion
  • 游戏/高频更新 → Canvas/WebGL

Q2: 什么是合成层?为什么 transform/opacity 通常更流畅?

答案

浏览器可以把元素绘制成独立纹理,后续只改变 transformopacity 时复用纹理完成合成,从而避免重复布局和绘制。是否分层由浏览器决定,开发者只能通过 will-change 提示,不能保证“开启 GPU”。

/* 通常合成友好的属性 */
transform: translate/scale/rotate;
opacity: 0.8;

/* 表达即将变化的意图,不保证一定创建图层 */
.element {
will-change: transform;
}

Q3: requestAnimationFrame 和 setTimeout 的区别?

答案

特性requestAnimationFramesetTimeout
执行时机下次重绘前指定延迟后
帧率与刷新率同步不稳定
后台标签暂停继续执行
精度可能有偏差
// RAF:按 timestamp 计算进度,适配不同刷新率
function animate(timestamp: number) {
updateByTime(timestamp);
requestAnimationFrame(animate);
}

// setTimeout - 可能卡顿
function animateBad() {
// 可能与刷新率不同步,导致丢帧
setTimeout(animateBad, 16);
}

Q4: will-change 的作用和注意事项?

答案

/* 作用:提示浏览器准备动画优化 */
.box {
will-change: transform, opacity;
}

注意事项

  1. 不要滥用:每个元素都创建合成层会消耗内存
  2. 动画前添加,动画后移除
  3. 不要用于静态元素
  4. 不要用在太多元素上

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 中进行

相关链接