鲁斯前端布鲁斯前端

文章中英模式

常见的前端面试题目 - 用户交互优化 - 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.7ms

setTimeout vs requestAnimationFrame

特性setTimeoutrequestAnimationFrame
执行时机指定时间后 (不精确)浏览器重绘前 (与显示器同步)
背景标签页继续执行 (浪费资源)自动暂停 (节省资源)
动画流畅度易掉帧减少掉帧
电池消耗较高较低

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