跳到主要内容

requestAnimationFrame 详解

你是否遇到过这样的问题:用 setInterval 做的动画在页面卡顿时变得忽快忽慢?或者滚动事件中频繁操作 DOM 导致页面掉帧?requestAnimationFrame(简称 rAF)正是浏览器提供的高性能动画与渲染调度 API,它能让你的代码与浏览器的刷新节奏完美同步,实现丝滑的 60fps 动画效果。

1. 什么是 requestAnimationFrame?

requestAnimationFrame 是浏览器提供的一个 API,用于在下一次重绘(repaint)之前执行指定的回调函数。它的核心思想是:让 JavaScript 的执行时机与浏览器的渲染周期对齐。

核心价值

requestAnimationFrame 让你的代码跟着浏览器的节奏走,不多不少,每一帧恰好执行一次,避免了无效计算和丢帧问题。

2. 基本语法

2.1 请求动画帧

const id = requestAnimationFrame(callback);
  • callback:在下一次重绘前调用的函数,接收一个 DOMHighResTimeStamp 参数(高精度时间戳)
  • 返回值:一个整数 ID,用于取消该请求

2.2 取消动画帧

cancelAnimationFrame(id);

2.3 最简示例

function animate(timestamp) {
// timestamp 是从页面加载开始的高精度时间(毫秒)
console.log('当前时间戳:', timestamp);

// 递归调用,形成动画循环
requestAnimationFrame(animate);
}

// 启动动画
requestAnimationFrame(animate);
关于 timestamp 参数

回调函数接收的 timestamp 是一个 DOMHighResTimeStamp,精度可达微秒级别。它表示从 performance.timeOrigin(通常是页面导航开始时)到当前帧的时间,与 performance.now() 同源。

3. 浏览器渲染机制与 rAF 的关系

要理解 requestAnimationFrame 为什么好用,我们需要先了解浏览器的一帧里发生了什么。

3.1 一帧的生命周期

大多数显示器刷新率为 60Hz,即每秒刷新 60 次,一帧约 16.67ms。浏览器需要在这个时间窗口内完成所有工作:

3.2 rAF 的执行时机

从上面的流程可以看出,requestAnimationFrame 的回调在以下时刻执行:

  1. 在 JavaScript 主任务之后 — 不会被其他脚本抢占
  2. 在样式/布局计算之前 — 你对 DOM 的修改能在当前帧中生效
  3. 在绘制之前 — 确保用户看到的是最新的状态
注意

requestAnimationFrame 的回调不属于宏任务,也不属于微任务,它是浏览器渲染流水线中一个独立的阶段。不要将它与 setTimeout(fn, 0) 混淆。

4. 为什么不用 setInterval / setTimeout?

4.1 对比分析

对比维度setTimeout / setIntervalrequestAnimationFrame
执行时机与渲染周期无关,可能在帧的任何时刻精确在下一次渲染前
帧率同步无法自动同步显示器刷新率自动匹配刷新率(60Hz/120Hz/144Hz)
后台行为继续执行,浪费 CPU自动暂停,节省资源
时间精度受最小延迟限制(嵌套 ≥ 4ms)高精度时间戳
丢帧问题可能在一帧内多次执行或跳帧每帧最多执行一次
电池影响持续消耗电量后台自动停止,省电

4.2 定时器的问题演示

// ❌ 使用 setInterval 的动画 — 有很多问题
let position = 0;
setInterval(() => {
position += 2;
element.style.transform = `translateX(${position}px)`;
}, 16); // 试图模拟 60fps,但实际并不精确

// 问题 1: setInterval 的最小间隔并非精确的 16ms
// 问题 2: 标签页切到后台时仍然执行,浪费资源
// 问题 3: 如果回调执行时间 > 16ms,会导致任务堆积
// ✅ 使用 requestAnimationFrame — 完美方案
let position = 0;

function move() {
position += 2;
element.style.transform = `translateX(${position}px)`;

if (position < 300) {
requestAnimationFrame(move);
}
}

requestAnimationFrame(move);

// 优势 1: 精确同步浏览器刷新率
// 优势 2: 标签页不可见时自动暂停
// 优势 3: 每帧只执行一次,不会堆积

4.3 高刷新率屏幕下的差异

现代设备的屏幕刷新率越来越高(120Hz、144Hz),requestAnimationFrame 能自动适配,而定时器只能以固定频率运行。

5. 核心用法与实战示例

5.1 基于时间的动画(推荐)

直接根据位移量递增会导致动画速度受帧率影响。正确做法是**基于时间差(deltaTime)**来计算动画进度:

function createAnimation(element, duration, distance) {
let startTime = null;

function animate(timestamp) {
// 记录动画开始时间
if (!startTime) startTime = timestamp;

// 计算经过的时间
const elapsed = timestamp - startTime;

// 计算进度(0 ~ 1)
const progress = Math.min(elapsed / duration, 1);

// 应用动画效果
element.style.transform = `translateX(${progress * distance}px)`;

// 动画未结束则继续
if (progress < 1) {
requestAnimationFrame(animate);
}
}

requestAnimationFrame(animate);
}

// 使用:让元素在 1 秒内向右移动 300px
const box = document.querySelector('.box');
createAnimation(box, 1000, 300);
为什么基于时间?

无论是 60Hz 还是 144Hz 的屏幕,动画总时长都是一样的。在 60Hz 屏幕上每帧移动更多,在 144Hz 屏幕上每帧移动更少,但最终效果一致。这就是**帧率无关(frame-rate independent)**动画。

5.2 添加缓动函数

线性动画看起来很机械,通过缓动函数可以让动画更自然:

// 常用缓动函数
const easings = {
// 线性
linear: (t) => t,
// 缓入(慢 → 快)
easeIn: (t) => t * t,
// 缓出(快 → 慢)
easeOut: (t) => t * (2 - t),
// 缓入缓出
easeInOut: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// 弹性效果
easeOutBounce: (t) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
return n1 * (t -= 2.625 / d1) * t + 0.984375;
},
};

function animateWithEasing(element, duration, distance, easing = 'easeOut') {
let startTime = null;
const easingFn = easings[easing];

function step(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const linearProgress = Math.min(elapsed / duration, 1);

// 将线性进度传入缓动函数,得到缓动后的进度
const easedProgress = easingFn(linearProgress);

element.style.transform = `translateX(${easedProgress * distance}px)`;

if (linearProgress < 1) {
requestAnimationFrame(step);
}
}

requestAnimationFrame(step);
}

// 使用缓出效果
animateWithEasing(box, 800, 300, 'easeOut');

5.3 可控制的动画对象

在实际项目中,我们经常需要暂停、恢复、取消动画:

function createControllableAnimation(element, options) {
const { duration, from, to, easing = 'linear', onUpdate, onComplete } = options;

let startTime = null;
let pausedTime = null;
let rafId = null;
let totalPausedDuration = 0;
const easingFn = easings[easing] || easings.linear;

function step(timestamp) {
if (!startTime) startTime = timestamp;

const elapsed = timestamp - startTime - totalPausedDuration;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easingFn(progress);
const currentValue = from + (to - from) * easedProgress;

if (onUpdate) {
onUpdate(currentValue, easedProgress);
}

if (progress < 1) {
rafId = requestAnimationFrame(step);
} else if (onComplete) {
onComplete();
}
}

// 返回控制对象
return {
// 开始动画
start() {
startTime = null;
totalPausedDuration = 0;
rafId = requestAnimationFrame(step);
return this;
},
// 暂停动画
pause() {
if (rafId) {
cancelAnimationFrame(rafId);
pausedTime = performance.now();
rafId = null;
}
return this;
},
// 恢复动画
resume() {
if (!rafId && pausedTime) {
totalPausedDuration += performance.now() - pausedTime;
pausedTime = null;
rafId = requestAnimationFrame(step);
}
return this;
},
// 取消动画
cancel() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
return this;
},
};
}

// 使用示例
const anim = createControllableAnimation(box, {
duration: 2000,
from: 0,
to: 500,
easing: 'easeInOut',
onUpdate(value) {
box.style.transform = `translateX(${value}px)`;
},
onComplete() {
console.log('动画完成!');
},
});

anim.start();

// 1 秒后暂停
setTimeout(() => anim.pause(), 1000);
// 2 秒后恢复
setTimeout(() => anim.resume(), 2000);

5.4 滚动事件节流

requestAnimationFrame 的一个高频应用场景是事件节流,避免在一帧内多次触发 DOM 操作:

// ❌ 不好的做法:每次滚动事件都操作 DOM
window.addEventListener('scroll', () => {
// 滚动事件每秒可能触发几十上百次
header.style.transform = `translateY(${window.scrollY}px)`;
updateParallax();
checkLazyLoad();
});
// ✅ 使用 rAF 节流:每帧只执行一次
let ticking = false;

window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
header.style.transform = `translateY(${window.scrollY}px)`;
updateParallax();
checkLazyLoad();

ticking = false;
});
ticking = true;
}
});

封装成通用的节流函数:

function rafThrottle(callback) {
let ticking = false;

return function (...args) {
if (!ticking) {
requestAnimationFrame(() => {
callback.apply(this, args);
ticking = false;
});
ticking = true;
}
};
}

// 使用
window.addEventListener('scroll', rafThrottle((e) => {
console.log('每帧只执行一次', window.scrollY);
}));

window.addEventListener('resize', rafThrottle(() => {
recalculateLayout();
}));

window.addEventListener('mousemove', rafThrottle((e) => {
tooltip.style.left = e.clientX + 'px';
tooltip.style.top = e.clientY + 'px';
}));

5.5 批量 DOM 操作优化

在需要一次性更新大量 DOM 元素时,requestAnimationFrame 可以将操作集中到一帧内执行:

// ❌ 不好的做法:散落在各处的 DOM 操作可能导致多次重排
function updateItems(items) {
items.forEach((item) => {
item.element.style.width = item.width + 'px'; // 触发重排
item.element.style.height = item.height + 'px'; // 再次重排
item.element.style.transform = `translate(${item.x}px, ${item.y}px)`;
});
}
// ✅ 好的做法:读写分离 + rAF 集中写入
function updateItems(items) {
// 第一步:集中读取(不会触发重排)
const measurements = items.map((item) => ({
rect: item.element.getBoundingClientRect(),
data: item,
}));

// 第二步:在下一帧集中写入
requestAnimationFrame(() => {
measurements.forEach(({ data }) => {
data.element.style.width = data.width + 'px';
data.element.style.height = data.height + 'px';
data.element.style.transform = `translate(${data.x}px, ${data.y}px)`;
});
});
}

6. 高级技巧

6.1 帧率检测器(FPS Monitor)

利用 requestAnimationFrame 可以实时检测页面帧率:

function createFPSMonitor() {
let frameCount = 0;
let lastTimestamp = performance.now();
let fps = 0;

function tick(timestamp) {
frameCount++;

// 每秒统计一次
if (timestamp - lastTimestamp >= 1000) {
fps = Math.round((frameCount * 1000) / (timestamp - lastTimestamp));
frameCount = 0;
lastTimestamp = timestamp;
console.log(`当前 FPS: ${fps}`);
}

requestAnimationFrame(tick);
}

requestAnimationFrame(tick);

return {
getFPS: () => fps,
};
}

const monitor = createFPSMonitor();

6.2 基于 rAF 的倒计时器

function createCountdown(seconds, onTick, onComplete) {
const endTime = performance.now() + seconds * 1000;

function tick(timestamp) {
const remaining = Math.max(0, endTime - timestamp);
const remainingSeconds = Math.ceil(remaining / 1000);

onTick(remainingSeconds);

if (remaining > 0) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}

requestAnimationFrame(tick);
}

// 使用:10 秒倒计时
createCountdown(
10,
(seconds) => {
display.textContent = `${seconds}`;
},
() => {
display.textContent = '时间到!';
}
);

6.3 Canvas 动画循环

requestAnimationFrame 是 Canvas 动画的标配:

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 粒子系统示例
const particles = [];

function createParticle() {
return {
x: Math.random() * canvas.width,
y: canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: -Math.random() * 3 - 1,
size: Math.random() * 4 + 1,
life: 1, // 1 = 刚创建,0 = 消失
};
}

let lastTime = 0;

function gameLoop(timestamp) {
// 计算时间差(秒)
const deltaTime = (timestamp - lastTime) / 1000;
lastTime = timestamp;

// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);

// 每帧添加新粒子
if (particles.length < 200) {
particles.push(createParticle());
}

// 更新并绘制粒子
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];

// 更新位置
p.x += p.vx * deltaTime * 60;
p.y += p.vy * deltaTime * 60;
p.life -= deltaTime * 0.5;

// 移除死亡粒子
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}

// 绘制
ctx.globalAlpha = p.life;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = '#4fc3f7';
ctx.fill();
}

ctx.globalAlpha = 1;
requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

6.4 与 CSS 动画配合

有些场景下需要在 JavaScript 修改样式后等待浏览器重新计算,可以利用双 rAF 技巧

// 双 rAF 技巧:确保浏览器完成一次渲染后再修改
function afterNextPaint(callback) {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
}

// 场景:先设置初始状态,再触发 CSS 过渡动画
function showElement(el) {
// 第一步:设置初始状态(浏览器还没渲染)
el.style.opacity = '0';
el.style.transform = 'translateY(20px)';
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.display = 'block';

// 第二步:等浏览器渲染初始状态后,再切换到最终状态
afterNextPaint(() => {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
});
}
为什么需要双 rAF?

单个 requestAnimationFrame 的回调可能在浏览器计算样式之前执行,如果你在同一个 rAF 中先设置 opacity: 0 再设置 opacity: 1,浏览器可能会将两次修改合并,导致过渡动画不触发。双 rAF 确保第一次修改已经渲染到屏幕后,第二次修改才开始。

7. 常见陷阱与注意事项

7.1 回调只执行一次

// ❌ 错误理解:以为会持续调用
requestAnimationFrame(() => {
element.style.left = parseInt(element.style.left) + 1 + 'px';
// 只会执行一次!不会自动循环!
});

// ✅ 正确做法:手动递归调用
function animate() {
element.style.left = parseInt(element.style.left) + 1 + 'px';
requestAnimationFrame(animate); // 需要手动递归
}
requestAnimationFrame(animate);

7.2 标签页不可见时暂停

当用户切换到其他标签页时,requestAnimationFrame 的回调会停止执行(大多数浏览器会降低到每秒 0~1 次)。这是一个优点,但也需要注意:

// ❌ 问题:切回标签页后动画可能"跳跃"
let lastTime = 0;
function animate(timestamp) {
const delta = timestamp - lastTime;
lastTime = timestamp;

// 如果标签页不可见 5 秒后切回,delta 会是 5000ms
// 角色可能一下子移动了很远!
player.x += speed * delta;

requestAnimationFrame(animate);
}
// ✅ 解决方案:限制最大 delta 值
function animate(timestamp) {
const delta = timestamp - lastTime;
lastTime = timestamp;

// 限制最大时间差,防止标签页切换导致的跳跃
const clampedDelta = Math.min(delta, 100); // 最多当作 100ms

player.x += speed * clampedDelta;

requestAnimationFrame(animate);
}

7.3 内存泄漏

如果不在适当的时候停止 rAF,可能造成内存泄漏:

// ❌ 组件卸载后仍在执行
class AnimatedComponent {
start() {
const animate = () => {
this.update();
requestAnimationFrame(animate); // 永远不会停止
};
requestAnimationFrame(animate);
}
}
// ✅ 在组件销毁时取消
class AnimatedComponent {
constructor() {
this.rafId = null;
}

start() {
const animate = () => {
this.update();
this.rafId = requestAnimationFrame(animate);
};
this.rafId = requestAnimationFrame(animate);
}

destroy() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}

在 React 中的正确用法:

import { useEffect, useRef } from 'react';

function AnimatedComponent() {
const rafIdRef = useRef(null);

useEffect(() => {
function animate(timestamp) {
// 动画逻辑...
rafIdRef.current = requestAnimationFrame(animate);
}

rafIdRef.current = requestAnimationFrame(animate);

// 清理:组件卸载时取消
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);

return <canvas id="myCanvas" />;
}

7.4 避免在 rAF 回调中执行耗时操作

// ❌ 危险:耗时操作会阻塞渲染
requestAnimationFrame(() => {
// 如果这个操作耗时超过 16ms,就会导致丢帧
heavyComputation();
updateDOM();
});

// ✅ 将耗时计算拆分到多帧中执行
function processInChunks(data, chunkSize, processFn, onComplete) {
let index = 0;

function processChunk() {
const end = Math.min(index + chunkSize, data.length);

for (let i = index; i < end; i++) {
processFn(data[i], i);
}

index = end;

if (index < data.length) {
requestAnimationFrame(processChunk);
} else {
onComplete();
}
}

requestAnimationFrame(processChunk);
}

// 将 10000 条数据分成每帧处理 100 条
processInChunks(
largeArray,
100,
(item) => renderItem(item),
() => console.log('全部处理完成')
);

8. rAF 与其他调度 API 的对比

API执行时机适用场景帧率感知后台行为
setTimeout至少延迟 N 毫秒后延时逻辑、去抖继续执行(可能被限流)
setInterval每隔 N 毫秒定时轮询继续执行(可能被限流)
requestAnimationFrame下次渲染前动画、DOM 操作暂停
requestIdleCallback浏览器空闲时非紧急计算、预加载暂停
queueMicrotask当前任务末尾Promise 后续、状态同步与宏任务一起执行

9. 浏览器兼容性

requestAnimationFrame 已被所有现代浏览器广泛支持:

浏览器支持版本
Chrome24+
Firefox23+
Safari6.1+
Edge12+
IE10+
iOS Safari7+
Android Browser4.4+

如果需要兼容极旧浏览器,可以使用 polyfill:

// 简易 polyfill(了解原理即可,现代项目基本不需要)
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = function (callback) {
return setTimeout(callback, 1000 / 60);
};
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
}

10. 面试高频问答

Q1: requestAnimationFrame 是宏任务还是微任务?

都不是。 requestAnimationFrame 回调既不属于宏任务队列,也不属于微任务队列。它属于浏览器渲染流水线中的一个独立步骤,在微任务清空之后、浏览器执行样式计算和布局之前执行。

执行顺序:宏任务 → 微任务(全部清空)→ rAF 回调 → 渲染

console.log('1 - 同步代码');

setTimeout(() => console.log('2 - setTimeout(宏任务)'), 0);

Promise.resolve().then(() => console.log('3 - Promise(微任务)'));

requestAnimationFrame(() => console.log('4 - rAF'));

// 输出顺序:1 → 3 → 4 → 2
// 注意:rAF 和 setTimeout 的顺序取决于浏览器的具体调度,
// 但 rAF 通常在同一帧内先于下一轮宏任务执行

Q2: requestAnimationFrame 和 setTimeout(fn, 0) 有什么区别?

区别点requestAnimationFramesetTimeout(fn, 0)
执行时机下一次渲染前下一轮事件循环(最少约 4ms 延迟)
调用频率与显示器刷新率一致不受刷新率限制
后台标签页暂停执行继续执行(可能被限流到 1 次/秒)
时间参数高精度 timestamp
主要用途动画、视觉更新异步任务调度

Q3: 为什么 requestAnimationFrame 比 setInterval 更适合做动画?

  1. 帧率同步:rAF 自动与显示器刷新率对齐,不需要手动猜测间隔时间
  2. 不会丢帧:每帧最多执行一次回调,不会出现定时器堆积问题
  3. 自动省电:标签页不可见时自动暂停,而 setInterval 会持续消耗 CPU
  4. 高精度时间戳:回调接收精确的时间戳参数,方便做基于时间的动画
  5. 浏览器优化:浏览器可以将多个 rAF 回调合并优化,提升渲染性能

Q4: 什么是双 rAF 技巧(Double rAF)?

双 rAF 是指嵌套两层 requestAnimationFrame

requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 在这里执行操作
});
});

用途: 确保浏览器至少完成了一次完整的渲染后再执行操作。常见场景是先设置 CSS 初始状态,等浏览器渲染后再修改为目标状态,从而触发 CSS 过渡动画。

第一个 rAF 的回调在当前帧渲染前执行,第二个 rAF 的回调在下一帧渲染前执行,这中间浏览器一定完成了一次完整的渲染。

Q5: requestAnimationFrame 在高刷新率(120Hz/144Hz)屏幕上表现如何?

requestAnimationFrame 会自动适配显示器的刷新率:

  • 60Hz 屏幕:回调约每 16.67ms 调用一次(60 次/秒)
  • 120Hz 屏幕:回调约每 8.33ms 调用一次(120 次/秒)
  • 144Hz 屏幕:回调约每 6.94ms 调用一次(144 次/秒)

这就是为什么基于时间(deltaTime)做动画比基于固定步长更重要——帧率不同意味着每帧的时间间隔不同。

Q6: 如何用 requestAnimationFrame 实现节流?

function rafThrottle(fn) {
let ticking = false;
return function (...args) {
if (!ticking) {
requestAnimationFrame(() => {
fn.apply(this, args);
ticking = false;
});
ticking = true;
}
};
}

原理:利用布尔标志位 ticking,在一帧内只允许注册一次 rAF 回调。回调执行完毕后重置标志位,允许下一帧的调度。这样保证了每帧最多执行一次回调函数。

Q7: requestAnimationFrame 回调中如何避免内存泄漏?

关键做法:

  1. 保存 rAF ID,在组件销毁时调用 cancelAnimationFrame(id) 取消
  2. 在 React 中使用 useEffect 的清理函数取消 rAF
  3. 在 Vue 中使用 onUnmounted 生命周期钩子取消 rAF
  4. 设置终止条件,避免无限递归调用
// Vue 3 示例
import { onMounted, onUnmounted, ref } from 'vue';

const rafId = ref(null);

onMounted(() => {
function animate() {
// 动画逻辑
rafId.value = requestAnimationFrame(animate);
}
rafId.value = requestAnimationFrame(animate);
});

onUnmounted(() => {
if (rafId.value) {
cancelAnimationFrame(rafId.value);
}
});

Q8: requestAnimationFrame 和 CSS 动画/transition 该如何选择?

场景推荐方案
简单的状态过渡(颜色、位置、透明度)CSS transition
循环播放的关键帧动画CSS @keyframes + animation
需要根据用户输入实时计算的动画requestAnimationFrame
Canvas/WebGL 渲染循环requestAnimationFrame
需要精确控制(暂停/恢复/反转)的动画requestAnimationFrame 或 Web Animations API
页面滚动联动效果requestAnimationFrame + 滚动事件

原则: 能用 CSS 实现的优先用 CSS(浏览器可以在 compositor 线程中处理,不阻塞主线程)。需要 JavaScript 逻辑参与计算的,用 requestAnimationFrame

11. 总结

requestAnimationFrame 是前端性能优化的基石之一。记住它的核心原则:让浏览器决定何时执行你的代码,而不是用定时器去猜测渲染时机。掌握它,你就掌握了流畅动画的钥匙。