跳到主要内容

requestAnimationFrame 动画

问题

什么是 requestAnimationFrame?与 setTimeout/setInterval 有什么区别?如何用它实现高性能动画?

答案

requestAnimationFrame(rAF)是浏览器提供的专门用于动画的 API,它会在下一次重绘之前调用回调函数,确保动画流畅且高效。

核心概念

工作原理

基本使用

function animate(time: DOMHighResTimeStamp): void {
// time: 回调被调用的时间戳
console.log('当前时间:', time);

// 更新动画
updateAnimation();

// 继续下一帧
requestAnimationFrame(animate);
}

// 启动动画
requestAnimationFrame(animate);

// 取消动画
const id = requestAnimationFrame(animate);
cancelAnimationFrame(id);

与定时器的区别

特性requestAnimationFramesetTimeout/setInterval
执行时机下一帧渲染前固定延迟后
刷新率与屏幕同步(60fps/144fps)固定间隔
后台标签页暂停继续执行(节流)
性能高,与渲染同步低,可能掉帧
电池消耗

对比示例

// ❌ 使用 setInterval - 可能导致掉帧
let position = 0;
setInterval(() => {
position += 2;
element.style.transform = `translateX(${position}px)`;
}, 16); // 约 60fps,但不精确

// ✅ 使用 requestAnimationFrame - 流畅动画
let position = 0;
function animate(): void {
position += 2;
element.style.transform = `translateX(${position}px)`;

if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);

帧率控制

基于时间的动画

class Animation {
private startTime: number = 0;
private lastFrameTime: number = 0;
private rafId: number = 0;
private isRunning: boolean = false;

constructor(
private update: (deltaTime: number, elapsed: number) => boolean
) {}

start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = performance.now();
this.lastFrameTime = this.startTime;
this.rafId = requestAnimationFrame(this.frame.bind(this));
}

private frame(time: number): void {
if (!this.isRunning) return;

const deltaTime = time - this.lastFrameTime; // 距离上一帧的时间
const elapsed = time - this.startTime; // 总运行时间
this.lastFrameTime = time;

// update 返回 false 表示动画结束
const shouldContinue = this.update(deltaTime, elapsed);

if (shouldContinue) {
this.rafId = requestAnimationFrame(this.frame.bind(this));
} else {
this.isRunning = false;
}
}

stop(): void {
this.isRunning = false;
cancelAnimationFrame(this.rafId);
}
}

// 使用:匀速移动
const element = document.querySelector('.box') as HTMLElement;
const speed = 0.5; // 每毫秒移动 0.5px
let x = 0;

const animation = new Animation((deltaTime, elapsed) => {
x += speed * deltaTime; // 基于时间,帧率无关
element.style.transform = `translateX(${x}px)`;
return x < 500; // 到达 500px 时停止
});

animation.start();

限制帧率

class FPSLimiter {
private lastFrameTime: number = 0;
private frameInterval: number;
private rafId: number = 0;
private callback: (deltaTime: number) => void;

constructor(fps: number, callback: (deltaTime: number) => void) {
this.frameInterval = 1000 / fps;
this.callback = callback;
}

start(): void {
const animate = (time: number): void => {
this.rafId = requestAnimationFrame(animate);

const deltaTime = time - this.lastFrameTime;

if (deltaTime >= this.frameInterval) {
// 对齐帧时间,避免漂移
this.lastFrameTime = time - (deltaTime % this.frameInterval);
this.callback(deltaTime);
}
};

this.rafId = requestAnimationFrame(animate);
}

stop(): void {
cancelAnimationFrame(this.rafId);
}
}

// 限制为 30fps
const limiter = new FPSLimiter(30, (deltaTime) => {
updateGame(deltaTime);
render();
});

limiter.start();

缓动函数

常用缓动函数

const easings = {
// 线性
linear: (t: number): number => t,

// 缓入
easeIn: (t: number): number => t * t,
easeInCubic: (t: number): number => t * t * t,

// 缓出
easeOut: (t: number): number => 1 - (1 - t) * (1 - t),
easeOutCubic: (t: number): number => 1 - Math.pow(1 - t, 3),

// 缓入缓出
easeInOut: (t: number): number =>
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,

// 弹性
easeOutElastic: (t: number): 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;
},

// 回弹
easeOutBounce: (t: number): number => {
const n1 = 7.5625;
const d1 = 2.75;

if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
},
};

type EasingFunction = (t: number) => number;

实现补间动画

interface TweenOptions {
from: number;
to: number;
duration: number;
easing?: EasingFunction;
onUpdate: (value: number) => void;
onComplete?: () => void;
}

function tween(options: TweenOptions): { stop: () => void } {
const { from, to, duration, easing = easings.linear, onUpdate, onComplete } = options;

const startTime = performance.now();
let rafId: number;

function animate(time: number): void {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1); // 0 到 1
const easedProgress = easing(progress);
const value = from + (to - from) * easedProgress;

onUpdate(value);

if (progress < 1) {
rafId = requestAnimationFrame(animate);
} else {
onComplete?.();
}
}

rafId = requestAnimationFrame(animate);

return {
stop: () => cancelAnimationFrame(rafId),
};
}

// 使用
const box = document.querySelector('.box') as HTMLElement;

tween({
from: 0,
to: 300,
duration: 1000,
easing: easings.easeOutCubic,
onUpdate: (x) => {
box.style.transform = `translateX(${x}px)`;
},
onComplete: () => {
console.log('动画完成');
},
});

复杂动画示例

多属性动画

interface AnimationTarget {
element: HTMLElement;
properties: {
[key: string]: { from: number; to: number; unit?: string };
};
duration: number;
easing?: EasingFunction;
delay?: number;
}

function animateElement(target: AnimationTarget): Promise<void> {
return new Promise((resolve) => {
const { element, properties, duration, easing = easings.easeInOut, delay = 0 } = target;

setTimeout(() => {
const startTime = performance.now();

function frame(time: number): void {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easing(progress);

// 更新所有属性
for (const [prop, { from, to, unit = '' }] of Object.entries(properties)) {
const value = from + (to - from) * easedProgress;

if (prop === 'opacity') {
element.style.opacity = String(value);
} else if (prop === 'transform') {
element.style.transform = `${unit}(${value}px)`;
} else {
(element.style as any)[prop] = `${value}${unit}`;
}
}

if (progress < 1) {
requestAnimationFrame(frame);
} else {
resolve();
}
}

requestAnimationFrame(frame);
}, delay);
});
}

// 使用
const card = document.querySelector('.card') as HTMLElement;

await animateElement({
element: card,
properties: {
opacity: { from: 0, to: 1 },
transform: { from: -20, to: 0, unit: 'translateY' },
},
duration: 500,
easing: easings.easeOut,
});

序列动画

async function animateSequence(animations: AnimationTarget[]): Promise<void> {
for (const animation of animations) {
await animateElement(animation);
}
}

// 使用
const items = document.querySelectorAll<HTMLElement>('.item');

const animations: AnimationTarget[] = Array.from(items).map((item, index) => ({
element: item,
properties: {
opacity: { from: 0, to: 1 },
transform: { from: 20, to: 0, unit: 'translateY' },
},
duration: 300,
delay: index * 100, // 错开动画
easing: easings.easeOut,
}));

await animateSequence(animations);

requestIdleCallback

requestIdleCallback 在浏览器空闲时执行回调,适合处理低优先级任务。

// 基本使用
requestIdleCallback((deadline: IdleDeadline) => {
// deadline.timeRemaining(): 剩余空闲时间(毫秒)
// deadline.didTimeout: 是否超时

while (deadline.timeRemaining() > 0 || deadline.didTimeout) {
// 执行低优先级任务
doBackgroundWork();
}
}, { timeout: 2000 }); // 最长等待时间

// 取消
const id = requestIdleCallback(callback);
cancelIdleCallback(id);

任务调度器

type Task = () => void;

class TaskScheduler {
private tasks: Task[] = [];
private isRunning: boolean = false;

add(task: Task): void {
this.tasks.push(task);
this.run();
}

private run(): void {
if (this.isRunning || this.tasks.length === 0) return;
this.isRunning = true;

requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && this.tasks.length > 0) {
const task = this.tasks.shift();
task?.();
}

this.isRunning = false;

if (this.tasks.length > 0) {
this.run();
}
});
}
}

// 使用
const scheduler = new TaskScheduler();

// 非紧急任务
scheduler.add(() => prefetchImages());
scheduler.add(() => loadAnalytics());
scheduler.add(() => initNonCriticalFeatures());

rAF vs rIC 对比

特性requestAnimationFramerequestIdleCallback
执行频率每帧(~60次/秒)空闲时才执行
优先级
用途动画、视觉更新后台任务、统计
时间限制有(deadline)

性能优化技巧

1. 避免布局抖动

// ❌ 读写交替 - 导致强制同步布局
function bad(): void {
requestAnimationFrame(() => {
items.forEach((item) => {
const height = item.offsetHeight; // 读
item.style.height = `${height + 10}px`; // 写
});
});
}

// ✅ 批量读取,批量写入
function good(): void {
requestAnimationFrame(() => {
// 先读取所有
const heights = items.map((item) => item.offsetHeight);

// 再批量写入
items.forEach((item, i) => {
item.style.height = `${heights[i] + 10}px`;
});
});
}

2. 使用 transform 和 opacity

// ❌ 触发布局 - 性能差
function animateWithLayout(): void {
let top = 0;
function frame(): void {
top += 2;
element.style.top = `${top}px`; // 触发布局
requestAnimationFrame(frame);
}
frame();
}

// ✅ 使用 transform - 只触发合成
function animateWithTransform(): void {
let y = 0;
function frame(): void {
y += 2;
element.style.transform = `translateY(${y}px)`; // 只触发合成
requestAnimationFrame(frame);
}
frame();
}

3. 使用 will-change 提示

.animated-element {
will-change: transform, opacity;
}

/* 动画结束后移除 */
.animated-element.done {
will-change: auto;
}

常见面试问题

Q1: requestAnimationFrame 和 setTimeout/setInterval 的区别?

答案

特性requestAnimationFramesetTimeout/setInterval
执行时机下一帧渲染前指定延迟后
帧率与屏幕刷新同步不同步,可能掉帧
后台暂停继续(节流)
精度
回调参数时间戳

Q2: 为什么 rAF 比 setInterval 更适合做动画?

答案

  1. 同步刷新率:rAF 与屏幕刷新率同步,不会掉帧
  2. 自动节流:后台标签页暂停,节省资源
  3. 时间戳参数:可实现基于时间的平滑动画
  4. 性能优化:浏览器可以合并多个 rAF 回调
// rAF 保证每次回调都在正确的渲染时机
requestAnimationFrame((time) => {
// time 精确到微秒
updateAnimation(time);
});

Q3: 如何实现一个暂停/恢复的动画?

答案

class PausableAnimation {
private rafId: number = 0;
private isPaused: boolean = false;
private pausedAt: number = 0;
private totalPausedTime: number = 0;
private startTime: number = 0;

constructor(private animate: (elapsed: number) => boolean) {}

start(): void {
this.startTime = performance.now();
this.loop();
}

private loop(): void {
if (this.isPaused) return;

this.rafId = requestAnimationFrame((time) => {
const elapsed = time - this.startTime - this.totalPausedTime;
const shouldContinue = this.animate(elapsed);

if (shouldContinue) {
this.loop();
}
});
}

pause(): void {
if (this.isPaused) return;
this.isPaused = true;
this.pausedAt = performance.now();
cancelAnimationFrame(this.rafId);
}

resume(): void {
if (!this.isPaused) return;
this.totalPausedTime += performance.now() - this.pausedAt;
this.isPaused = false;
this.loop();
}
}

Q4: requestIdleCallback 有什么用?

答案

在浏览器空闲时执行低优先级任务,不影响关键渲染路径:

// 适合:
// - 数据预取
// - 统计上报
// - 懒加载
// - 缓存预热

requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift();
task();
}
});

Q5: 如何检测页面帧率?

答案

let frameCount = 0;
let lastTime = performance.now();
let fps = 0;

function measureFPS(): void {
frameCount++;
const now = performance.now();

if (now - lastTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastTime));
console.log('FPS:', fps);
frameCount = 0;
lastTime = now;
}

requestAnimationFrame(measureFPS);
}

measureFPS();

相关链接