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);
与定时器的区别
| 特性 | requestAnimationFrame | setTimeout/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 对比
| 特性 | requestAnimationFrame | requestIdleCallback |
|---|---|---|
| 执行频率 | 每帧(~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 的区别?
答案:
| 特性 | requestAnimationFrame | setTimeout/setInterval |
|---|---|---|
| 执行时机 | 下一帧渲染前 | 指定延迟后 |
| 帧率 | 与屏幕刷新同步 | 不同步,可能掉帧 |
| 后台 | 暂停 | 继续(节流) |
| 精度 | 高 | 低 |
| 回调参数 | 时间戳 | 无 |
Q2: 为什么 rAF 比 setInterval 更适合做动画?
答案:
- 同步刷新率:rAF 与屏幕刷新率同步,不会掉帧
- 自动节流:后台标签页暂停,节省资源
- 时间戳参数:可实现基于时间的平滑动画
- 性能优化:浏览器可以合并多个 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();
相关链接
- MDN - requestAnimationFrame
- MDN - requestIdleCallback
- CSS Triggers - 查看哪些 CSS 属性触发布局/绘制
- Easing Functions Cheat Sheet