文章中英模式
常见的前端面试题目 - 用户交互优化 - RequestAnimationFrame 避免动画掉帧
深入解析 RequestAnimationFrame API 如何优化前端动画效能、避免掉帧问题,及与传统计时器的关键区别。附带实用范例与面试常见问题解析。
文章中英模式

懒得看文章?那就来看视频吧
基本概念
RequestAnimationFrame (rAF) 是浏览器提供的用于优化动画效能的 API,它让你的动画能在最适合的时间点执行,确保流畅的视觉体验。与传统的 setTimeout 和 setInterval 相比,rAF 可以有效避免动画掉帧问题。
传统计时器动画:
可能在任意时间执行 → 与浏览器渲染周期不同步 → 视觉不流畅、掉帧
RequestAnimationFrame:
与浏览器渲染流程同步 → 在下一次重绘(Repaint)前执行 → 动画流畅、效能更好浏览器渲染流程与掉帧原理
浏览器的渲染流程通常每秒执行约 60 次(60FPS),若每个渲染周期超过约 16.7ms (1000ms/60),就会出现「掉帧」现象。
浏览器执行时期的渲染周期:
每一帧 (约每 16.6ms)
→ 处理 JS 任务队列(micro/macro tasks)
→ 执行 requestAnimationFrame callbacks ✅
→ 开始 layout(reflow)& paint(repaint)
→ Composite(合成显示)
→ 下一帧循环
理想情况 (60FPS):
[画面1] → [画面2] → [画面3] → [画面4] → [画面5]
16.7ms 16.7ms 16.7ms 16.7ms 16.7ms
掉帧情况:
[画面1] → [掉帧!] → [画面3] → [画面4] → [画面5]
25ms X 16.7ms 16.7ms 16.7mssetTimeout vs requestAnimationFrame
| 特性 | setTimeout | requestAnimationFrame |
|---|---|---|
| 执行时机 | 指定时间后 (不精确) | 浏览器重绘前 (与显示器同步) |
| 背景标签页 | 继续执行 (浪费资源) | 自动暂停 (节省资源) |
| 动画流畅度 | 易掉帧 | 减少掉帧 |
| 电池消耗 | 较高 | 较低 |
requestAnimationFrame 基本用法
// 基本使用方式
function animate() {
// 更新动画状态
element.style.left = `${position++}px`;
// 递归调用,创建动画循环
requestAnimationFrame(animate);
}
// 开始动画
requestAnimationFrame(animate);
// 取消动画
const animationId = requestAnimationFrame(animate);
cancelAnimationFrame(animationId);实际应用案例:平滑滚动
以下是使用 requestAnimationFrame 实现平滑滚动到页面顶部的范例:
// 平滑滚动到页面顶部
function scrollToTop() {
const currentPosition = window.pageYOffset;
if (currentPosition > 0) {
// 以每次滚动距离的 10% 速度平滑滚动
window.scrollTo(0, currentPosition - currentPosition / 10);
requestAnimationFrame(scrollToTop);
}
}
// 点击按钮启动平滑滚动
document.querySelector('.scroll-top-button').addEventListener('click', function() {
requestAnimationFrame(scrollToTop);
});高级应用:动画性能优化技巧
1. 避免布局抖动 (Layout Thrashing)
减少强制重排和重绘是提升动画效能的关键,应该分离读取和写入操作:
// 不好的做法:多个元素交替读写,导致多次强制重排
function badAnimation() {
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
// 读取 DOM (强制浏览器计算最新布局)
const width = box.offsetWidth;
// 写入 DOM (改变布局)
box.style.width = `${width + 1}px`;
// 再次读取 (又强制重新计算布局)
const height = box.offsetHeight;
// 再次写入 (又改变布局)
box.style.height = `${height + 1}px`;
});
requestAnimationFrame(badAnimation);
}
// 好的做法:先批量读取所有数据,再批量写入
function goodAnimation() {
const boxes = document.querySelectorAll('.box');
const measurements = [];
// 读取阶段 - 一次性读取所有需要的数据
boxes.forEach(box => {
measurements.push({
width: box.offsetWidth,
height: box.offsetHeight,
element: box
});
});
// 写入阶段 - 一次性完成所有 DOM 修改
measurements.forEach(data => {
data.element.style.width = `${data.width + 1}px`;
data.element.style.height = `${data.height + 1}px`;
});
requestAnimationFrame(goodAnimation);
}
/*
布局抖动可视化比较:
不好的做法 (多次重排):
┌─────────────────────────────────┐
│ 读取元素1尺寸 → 重排 #1 │
│ 修改元素1尺寸 │
│ 读取元素1高度 → 重排 #2 │
│ 修改元素1高度 │
│ 读取元素2尺寸 → 重排 #3 │
│ 修改元素2尺寸 │
│ 读取元素2高度 → 重排 #4 │
│ 修改元素2高度 │
│ ... │
└─────────────────────────────────┘
结果:一帧内多次强制重排,性能下降
好的做法 (批量处理):
┌─────────────────────────────────┐
│ 读取所有元素尺寸 → 重排 #1 │
│ 修改所有元素尺寸和高度 │
└─────────────────────────────────┘
结果:一帧内只有一次重排,性能提升
*/2. 使用 CSS transform 代替修改位置
优先使用影响合成层而非布局的属性:
// 不好的做法:修改 left/top (触发布局)
function inefficientMove() {
box.style.left = `${xPos}px`;
box.style.top = `${yPos}px`;
}
// 好的做法:使用 transform (只触发合成)
function efficientMove() {
box.style.transform = `translate(${xPos}px, ${yPos}px)`;
}常见面试题与解答
(一)requestAnimationFrame 与 setTimeout/setInterval 的主要区别是什么?
A: requestAnimationFrame 与浏览器的绘制周期同步,确保动画在最佳时间执行;而 setTimeout/setInterval 是固定时间间隔,不考虑浏览器渲染流程,容易造成掉帧。另外,rAF 在非可见标签页会自动暂停,节省资源;还会自动适应不同刷新率的显示器。
(二)为什么 requestAnimationFrame 比 setTimeout 更适合实现动画?
A: rAF 能减少掉帧现象,降低能源消耗,且与浏览器重绘同步;而 setTimeout 的计时不够精确,可能导致一个周期内多次重绘或完全错过重绘时机。
(三)如何使用 requestAnimationFrame 实现动画节流?
// 使用 flag 变量控制是否已有待处理的动画帧
let ticking = false;
function onScroll(e) {
// 检查是否已经有排程的动画帧
if (!ticking) {
// 使用 rAF 确保处理函数在下一次重绘前执行
// 而不是每次滚动事件触发都执行(可能一帧内触发多次)
requestAnimationFrame(() => {
doSomethingWithScrollPosition(); // 处理滚动位置的实际逻辑
ticking = false; // 重设 flag,允许下一帧处理
});
ticking = true; // 标记已有待处理的动画帧
}
// 如果 ticking 为 true,则忽略此次滚动事件
// 直到前一个 rAF 执行完成
}
// 监听滚动事件
window.addEventListener('scroll', onScroll);
/*
┌─────────────────────────────────────────────────┐
│ 无节流时: │
│ 滚动事件 → 处理 → 滚动事件 → 处理 → 滚动事件 → 处理 │
│ (可能一帧内触发10+次,造成效能问题) │
│ │
│ 使用 rAF 节流后: │
│ 滚动事件 → 滚动事件 → 滚动事件 → rAF(处理一次) → ... │
│ └─── 一个渲染周期/帧 ─────┘ │
└─────────────────────────────────────────────────┘
*/(四)requestAnimationFrame 在不同刷新率的显示器上会有什么表现?
A: rAF 会自动适应显示器的刷新率。在 60Hz 的显示器上约每 16.7ms 执行一次,而在 120Hz 的显示器上约每 8.3ms 执行,确保动画在各种设备上都有最佳表现。
(五)requestAnimationFrame 与 Event Loop 的关系是什么?
A: requestAnimationFrame 在事件循环中有特殊的执行时机。它会在每一帧的微任务(microtasks)执行完毕后、渲染前被调用,具体顺序为:
1. 执行同步代码
2. 执行微任务 (Promise, MutationObserver 等)
3. 执行 requestAnimationFrame 回调
4. 执行渲染 (Layout, Paint, Composite)
5. 执行宏任务 (setTimeout, setInterval 等)
6. 重复步骤 1-5