浏览器 API性能优化动画

requestAnimationFrame
vs
requestIdleCallback

两个鲜为人知却极其强大的浏览器调度 API。一个让动画丝滑如油, 一个让后台任务不阻塞用户交互。理解它们,你就掌握了浏览器渲染管线的精髓。

刷新率同步
16.6ms / 帧
空闲调度阈值
~50ms
核心目标
60fps 丝滑体验

为什么不用 setTimeout(fn, 0) 做动画?

setTimeout 的最小延迟为 4ms(嵌套时甚至 10ms+),且无法与浏览器的刷新周期同步。 你的回调可能在帧的中间被触发,导致上一帧还没绘制就被覆盖 → 画面撕裂requestAnimationFrame 保证回调在每帧渲染前精确执行一次,与显示器刷新率完美同步。

requestAnimationFrame

rAF

核心特性

  • 与浏览器刷新率同步(通常 60fps = 16.6ms/帧)
  • 页面不可见时自动暂停,节省 CPU 和电量
  • 返回一个唯一的 ID,可用于 cancelAnimationFrame()
  • 回调接收高精度时间戳 DOMHighResTimeStamp
  • 保证每帧最多执行一次,避免冗余计算

基本 API

基本用法
// 请求动画帧
const id = requestAnimationFrame(callback);

// callback 在每帧渲染前调用
function callback(timestamp) {
  // timestamp: DOMHighResTimeStamp
  // 通常是 performance.now() 的同义词
  
  // 执行动画计算...
  updatePositions(timestamp);
  
  // 递归调用以维持动画循环
  requestAnimationFrame(callback);
}

// 取消动画帧
cancelAnimationFrame(id);

平滑动画

CSS 无法实现的复杂 JS 动画、canvas 绑定、物理模拟

滚动监听

配合 scroll 事件做节流,实现视差、懒加载等效果

帧率监控

通过计算两次 rAF 回调的时间差来测量实际 FPS

requestAnimationFrame 实验场
FPS: 0
帧数: 0
高级模式:带 deltaTime 的动画循环
let lastTime = 0;

function gameLoop(currentTime) {
  const deltaTime = currentTime - lastTime; // ms since last frame
  lastTime = currentTime;

  // 用 deltaTime 做帧无关的运动计算
  // 这样即使帧率波动,运动速度也一致
  player.x += player.speed * (deltaTime / 1000);
  player.y += player.gravity * (deltaTime / 1000);

  render(ctx);
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);
VS

requestIdleCallback

rIC

核心特性

  • 仅在浏览器空闲时执行,绝不阻塞用户输入和渲染
  • 回调接收 IdleDeadline 对象,含 timeRemaining() 方法
  • timeRemaining() 返回本帧剩余空闲时间(通常 ≤ 50ms)
  • 支持 timeout 选项:设置最大等待时间,到期后强制执行
  • 适合低优先级任务:分析、预加载、数据上报等

基本 API

基本用法
// 请求空闲回调
const id = requestIdleCallback(callback, {
  timeout: 2000  // 最多等 2 秒,之后强制执行
});

function callback(deadline) {
  // deadline.timeRemaining() → 本帧剩余空闲 ms
  // deadline.didTimeout   → 是否因 timeout 触发
  
  while (deadline.timeRemaining() > 0 && tasks.length) {
    // 每次循环检查剩余时间,执行一个任务
    processTask(tasks.shift());
  }
  
  // 还有剩余任务?再次请求空闲回调
  if (tasks.length) {
    requestIdleCallback(callback);
  }
}

// 取消
cancelIdleCallback(id);

大数据渲染

将长列表分批渲染,每帧只处理一批,避免页面卡顿

资源预加载

在空闲时预加载图片、字体或下一页的数据

数据同步

埋点发送、本地存储同步、日志清理等后台操作

requestIdleCallback 实验场
点击「添加任务」模拟 requestIdleCallback 任务队列 ✦
▸ 系统就绪,等待任务入队…
兼容性注意:requestIdleCallback 目前在 Safari 中不受支持。常用 polyfill 方案是用 setTimeout 模拟,或使用 React 的 MessageChannel 方案(即 React Scheduler 的核心原理)。
浏览器一帧中的执行顺序
Step 1宏任务队列

setTimeout / setInterval / I/O 回调

每轮事件循环的起点,执行最早排队的宏任务。
Step 2渲染帧

样式计算 → 布局 → 绘制

Step 3requestAnimationFrame

在渲染前执行,与浏览器刷新率同步

Step 4requestIdleCallback

在帧末尾的空闲期执行

对比一览

维度requestAnimationFramerequestIdleCallback
调用时机每帧渲染前(同步刷新率)帧末尾空闲期(不保证何时)
频率≈ 60次/秒(依刷新率)不确定,可能几秒才触发一次
优先级高 — 影响视觉流畅度低 — 可被无限延后
典型用途动画、Canvas 绘制、滚动预加载、分析、数据同步
时间控制无内置超时机制支持 timeout 选项
可见性页面不可见时暂停页面不可见时也可能执行
取消方式cancelAnimationFrame(id)cancelIdleCallback(id)
兼容性✅ 所有主流浏览器⚠️ 不支持 Safari
React 中的使用useEffect 中绑定动画内部 Scheduler 基于类似原理

最佳实践 & 避坑指南

rAF 中使用 deltaTime

不要假设每帧间隔固定为 16.6ms。用回调的时间戳计算 deltaTime,确保动画在不同刷新率设备上一致。

rIC 中检查 timeRemaining

永远在循环中检查 deadline.timeRemaining()。单个任务太重时应拆分,让出主线程给用户交互。

组件卸载时取消

在 React 的 useEffect cleanup 中调用 cancelAnimationFrame / cancelIdleCallback,避免内存泄漏。

不要在 rAF 中做重计算

rAF 回调直接决定帧率。把密集计算(如物理模拟)放到 Web Worker 中,rAF 只负责更新渲染。

不要用 rIC 处理用户交互

rIC 可能延迟数秒甚至不执行。需要及时响应的任务(点击、输入)绝不能依赖它。

💡

组合使用:rAF + rIC

动画逻辑用 rAF 渲染,同时用 rIC 在空闲期预计算下一帧所需的数据,实现流水线并行。

组合模式:流水线调度

rAF + rIC 流水线
// 🎯 核心思路:
// rAF 负责渲染当前帧
// rIC 在空闲期预计算下一帧数据

let currentData = null;
let pendingData = null;

// ① rIC:空闲时预计算
function precompute(deadline) {
  while (deadline.timeRemaining() > 0 
         && rawData.length) {
    const item = rawData.shift();
    pendingData = transform(item); // 重计算
  }
  
  // 还有数据?继续排队
  if (rawData.length) {
    requestIdleCallback(precompute);
  }
}

// ② rAF:每帧只做轻量渲染
function render(time) {
  // 如果预计算好了,切换数据
  if (pendingData) {
    currentData = pendingData;
    pendingData = null;
  }
  
  if (currentData) {
    draw(currentData); // 快速渲染
  }
  
  requestAnimationFrame(render);
}

// 启动
requestIdleCallback(precompute);
requestAnimationFrame(render);

🧠 为什么这个模式强大?

想象你在看一部电影。rAF 是放映机——保证每秒播放 24 帧(或 60 帧)。 rIC 是后台的胶片准备员——在放映机不需要新胶片时,提前裁剪和准备好下一卷。 这样放映机永远不会等待,观众看到的画面永远流畅。

📦 谁在用这种模式?

  • React Scheduler — 用 MessageChannel 模拟类似 rIC 的调度
  • Google Maps — 空闲期加载远处瓦片
  • Figma — Canvas 渲染引擎的帧调度
  • Virtualized Lists — 空闲时预渲染即将进入视口的行

一句话总结

requestAnimationFrame
“在每一帧渲染之前,给我一个执行机会。”
→ 动画、绘制、一切与视觉相关的事。
requestIdleCallback
“在每一帧有空闲时,帮我做点杂活。”
→ 预加载、分析、一切不紧急的事。

掌握了这两个 API,你就拥有了对浏览器主线程的精细调度能力 ✦