requestAnimationFrame 动画优化实践指南

一、什么叫"优化好"的 requestAnimationFrame 动画?

对于常见 60Hz 屏幕,你每一帧可用时间大约 16ms。只要:

  • 每个 requestAnimationFrame 回调里的 总工作时间 < 16ms(更理想是 10--15ms)
  • 避免频繁重排(reflow)和重绘(repaint)
  • 尽可能把计算和 DOM 更新控制在必要的最小范围内

你的动画在视觉上就会是"平滑的"(60fps 或被浏览器稳定地降到一个一致的帧率,如 30fps)。


二、核心原则

  1. 用 requestAnimationFrame,而不是 setTimeout / setInterval 来驱动动画

    • requestAnimationFrame 会在浏览器准备重绘前调用回调,自动跟随不同设备刷新率,并在后台标签页暂停,整体更节能、也更平滑[1][3][8]。
  2. 优先使用 CSS 动画/过渡(transform / opacity)

    • 很多简单的位移、缩放、淡入淡出,只用 CSS 就能用 GPU 做"合成层动画",几乎不占 JS 时间。
    • 当需要复杂逻辑、交互驱动或时间轴控制时,再用 requestAnimationFrame 做 JS 动画。
  3. 把 requestAnimationFrame 回调做"瘦身":轻计算 + 少 DOM 操作

    • 大块计算逻辑不要放在 requestAnimationFrame 内部,而是拆分、延后或放到 Worker 里。
    • requestAnimationFrame 回调内只做:
      • 基于当前时间计算下一状态
      • 对 DOM 做最小量、批量化的样式更新

三、具体优化策略

1. 使用 GPU 友好的 CSS 属性:transform / opacity

正确做法:

js 复制代码
// 在 requestAnimationFrame 中
function step(timestamp) {
  // 计算新的位移
  box.style.transform = `translateX(${x}px)`;  // 位移
  box.style.opacity   = alpha;                // 透明度
  requestAnimationFrame(step);
}

避免:

js 复制代码
// 这些属性经常触发布局和重绘
div.style.left   = x + 'px';
div.style.top    = y + 'px';
div.style.width  = w + 'px';
div.style.height = h + 'px';
div.style.margin = m + 'px';

原因:

  • transform / opacity 通常只触发合成阶段,可在 GPU 合成层上处理,明显降低主线程压力。
  • width / left 等会触发布局(layout)和绘制(paint),容易导致 LoAF(长动画帧 > 50ms)。

2. 统一动画循环:单一 requestAnimationFrame 管理多动画

问题模式(反例):

js 复制代码
function animateBox1(t) { /* ... */ requestAnimationFrame(animateBox1); }
function animateBox2(t) { /* ... */ requestAnimationFrame(animateBox2); }
function animateBox3(t) { /* ... */ requestAnimationFrame(animateBox3); }
  • 多个独立 requestAnimationFrame 回调增加了调度负担,也更难统一限流/暂停。

推荐模式:集中管理 + 统一循环

js 复制代码
class AnimationManager {
  constructor() {
    this.tasks = new Set();
    this.animationId = null;
    this.fps = 60;  // 可调
    this.lastFrameTime = performance.now();
  }

  registerTask(task) {
    this.tasks.add(task);
    if (this.tasks.size === 1) {
      this.animationId = requestAnimationFrame(this.run.bind(this));
    }
  }

  unregisterTask(task) {
    this.tasks.delete(task);
    if (this.tasks.size === 0 && this.animationId !== null) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }

  run(currentTime) {
    const deltaTime = currentTime - this.lastFrameTime;
    const frameInterval = 1000 / this.fps;

    if (deltaTime > frameInterval) {
      this.tasks.forEach(task => task(currentTime, deltaTime));
      this.lastFrameTime = currentTime;
    }

    this.animationId = requestAnimationFrame(this.run.bind(this));
  }
}

使用:

js 复制代码
const manager = new AnimationManager();

function scaleTask(time)   { /* 只做缩放样式更新 */ }
function colorTask(time)   { /* 只做颜色样式更新 */ }
function rotateTask(time)  { /* 只做旋转样式更新 */ }

manager.registerTask(scaleTask);
manager.registerTask(colorTask);
manager.registerTask(rotateTask);
// 停止某个动画时: manager.unregisterTask(scaleTask);

好处:

  • 所有动画共用一条"时脉",便于统一控制 FPS 和暂停/恢复。
  • 浏览器只管理一个 requestAnimationFrame 调度点,减少冗余回调开销。

3. 控制 FPS:有意识地"少渲染"

不是所有动画都需要 60fps,例如背景装饰动画完全可以 30fps 或更低。

在上面的 AnimationManager 里,通过修改 this.fps 控制目标帧率:

js 复制代码
const manager = new AnimationManager();
manager.fps = 30; // 背景动画 30fps,明显降低 CPU 占用
  • 多数用户难以分辨 30fps 的轻量动画与 60fps 的差别,但 CPU/GPU 开销可以明显下降(移动设备、电池尤为受益)。

4. 拆分重任务,避免"长动画帧"(LoAF)

LoAF 定义:单帧时长超过约 50ms 即可视作"长动画帧"[5],很容易造成明显卡顿。

不要这样:

js 复制代码
requestAnimationFrame(() => {
  // 这里做了一个 50ms 的大循环运算
  for (let i = 0; i < 1_000_000; i++) { /* ...重运算... */ }
  // 然后再更新 DOM
});

拆成小块跨帧执行

js 复制代码
function heavyTaskChunk(start, done) {
  const CHUNK_SIZE = 1000;
  for (let i = start; i < 1_000_000 && i < start + CHUNK_SIZE; i++) {
    // 批量处理数据
  }
  if (start + CHUNK_SIZE < 1_000_000) {
    // 把剩余任务交给后续帧 / 空闲时间继续跑
    setTimeout(() => heavyTaskChunk(start + CHUNK_SIZE, done), 0);
  } else if (done) {
    done();
  }
}

// 在动画外部安排重任务
setTimeout(() => heavyTaskChunk(0, () => console.log('完成')), 0);

要点:

  • requestAnimationFrame 回调只负责动画状态与 DOM 渲染 ;重计算要么拆分到 setTimeout / requestIdleCallback,要么放到 Web Worker。
  • 对交互来说,更重要的是让主线程"经常有机会空转",以便随时处理用户输入。

5. 避免 Layout Thrashing:先读后写

错误模式:

js 复制代码
// 每次循环既读又写,会不断强制浏览器重新布局
for (const el of elements) {
  el.style.width = el.offsetWidth + '10px'; // 读 offsetWidth 立刻触发布局
}

正确模式:批量读 / 批量写

js 复制代码
const widths = [];

// 1. 先把所有读操作做完
for (let i = 0; i < elements.length; i++) {
  widths[i] = elements[i].offsetWidth;
}

// 2. 再统一写
for (let i = 0; i < elements.length; i++) {
  elements[i].style.width = widths[i] + 10 + 'px';
}

在 requestAnimationFrame 里尤其要遵守这个模式:

  • 先在一段逻辑中读所有需要的布局信息 (如 offsetWidthscrollTop 等)
  • 然后一段逻辑中统一更新样式(写 style)

6. 观察者回调中不要做重工作

ResizeObserverIntersectionObserver 的回调本身就是在接近绘制阶段调用的,如果在里面做重计算,会直接"吃掉这一帧的预算"。

改进:

js 复制代码
const resizeObserver = new ResizeObserver(entries => {
  // 推迟到下一轮 event loop / 下一帧处理
  setTimeout(() => {
    dealWithResize(entries);
  }, 0);
});

7. 善用 CSS 动画,减少 JS 参与

  • 尽量让"可预测、无需交互控制"的动画使用 CSS(@keyframestransition)。
  • 某些浏览器在 JS 繁忙时,仍可让 CSS 动画在合成线程上相对平滑地继续运行,这是 JS requestAnimationFrame 做不到的。

例如:

css 复制代码
.box {
  animation: float 2s ease-in-out infinite alternate;
}

@keyframes float {
  from { transform: translateY(0); }
  to   { transform: translateY(-10px); }
}

此时 JS 完全不参与动画过程,只负责业务逻辑。


8. 初级性能排查流程(实战向)

  1. 用 Chrome DevTools Performance 录制动画场景

    • 检查 Frames 时间条,是否大量帧超出 16ms,尤其 > 50ms。
  2. 找出最重的 requestAnimationFrame 回调 / 事件处理

    • 重点看:
      • JavaScript 运行时间(JS flame chart)
      • layout / paint 时间是否过长
      • 有无紧接着的强制布局操作(读写交错)
  3. 按优先级优化

    1. 把影响最大的 JS 计算拆分或移出 requestAnimationFrame(见第 4 条)。
    2. 把所有动画相关样式改成 transform/opacity(见第 1 条)。
    3. 合并 requestAnimationFrame 调用,使用统一动画管理器,并按需限 FPS(见第 2、3 条)。
    4. 检查并修正 layout thrashing(见第 5 条)。
    5. 确认观察者和输入事件中没有重任务(见第 6 条)。

九、简要实践清单

编写 requestAnimationFrame 动画时,请保证:

  • 动画驱动全部使用 requestAnimationFrame,不用 setInterval 做高频更新
  • requestAnimationFrame 回调内只做:轻量状态计算 + transform/opacity 更新
  • 不在 requestAnimationFrame 回调中执行大循环或复杂算法
  • 如果有多个动画,尽量通过统一的 AnimationManager 来集中调度
  • 非关键动画(装饰类)使用较低 FPS(如 30fps)
  • 所有布局读写分离(先读后写),避免反复触发布局
  • 尽量让"可预测"的动画交给 CSS transitions/animations 完成
  • 通过 Performance/DevTools 确认没有明显长帧(> 50ms)

References

1\] CSS and JavaScript animation performance. [developer.mozilla.org/en-US/docs/...](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FPerformance%2FGuides%2FCSS_JavaScript_animation_performance "https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/CSS_JavaScript_animation_performance") \[2\] Optimize requestAnimationFrame Like a Pro. [dev.to/josephciull...](https://link.juejin.cn?target=https%3A%2F%2Fdev.to%2Fjosephciullo%2Fsupercharge-your-web-animations-optimize-requestanimationframe-like-a-pro-22i5 "https://dev.to/josephciullo/supercharge-your-web-animations-optimize-requestanimationframe-like-a-pro-22i5") \[3\] Jank busting for better rendering performance. [web.dev/articles/sp...](https://link.juejin.cn?target=https%3A%2F%2Fweb.dev%2Farticles%2Fspeed-rendering "https://web.dev/articles/speed-rendering") \[5\] Fixing Long Animation Frames (LoAF). [requestmetrics.com/web-perform...](https://link.juejin.cn?target=https%3A%2F%2Frequestmetrics.com%2Fweb-performance%2Ffixing-long-animation-frame-loaf%2F "https://requestmetrics.com/web-performance/fixing-long-animation-frame-loaf/")

相关推荐
sophie旭9 小时前
性能监控之首屏性能监控小实践
前端·javascript·性能优化
Amumu121389 小时前
React 前端请求
前端·react.js·okhttp
UrbanJazzerati9 小时前
统计学基础与数据可视化实战——基本图表(1)
面试
38242782710 小时前
JS表单提交:submit事件的关键技巧与注意事项
前端·javascript·okhttp
Kagol10 小时前
深入浅出 TinyEditor 富文本编辑器系列2:快速开始
前端·typescript·开源
interception10 小时前
js逆向之京东原型链补环境h5st
javascript·爬虫·网络爬虫
木土雨成小小测试员10 小时前
Python测试开发之前端二
javascript·python·jquery
小二·10 小时前
Python Web 开发进阶实战:Flask-Login 用户认证与权限管理 —— 构建多用户待办事项系统
前端·python·flask
浩瀚之水_csdn10 小时前
python字符串解析
前端·数据库·python