最近整理前端性能优化相关的知识,发现 requestAnimationFrame(RAF)这个 API 看着简单,但真要讲清楚它和浏览器渲染、事件循环、性能优化的关系,还是有不少细节。这篇是我自己的学习笔记,把之前搞混的点梳理一下。
01RAF 和 setTimeout 的区别
一开始我以为 RAF 就是"更精确的 setTimeout",其实不是。setTimeout 是定时器任务,表达的是"至少经过指定时间后可以执行",具体什么时候跑还要看事件循环和主线程空不空闲。比如 setTimeout(fn, 16) 并不保证 16ms 后一定执行,如果主线程在忙,回调会继续往后排,而且执行时机和浏览器绘制不对齐。
RAF 是浏览器在下一次绘制前统一调度的,回调通常会在浏览器准备画下一帧之前跑。所以做动画、改 Canvas 内容、或者任何需要跟视觉更新对齐的操作,用 RAF 更合适。
另外一个容易踩的坑:RAF 不是固定 16ms 一次。60Hz 屏幕一帧约 16.67ms,120Hz 约 8.33ms,RAF 跟着实际刷新率走。所以动画不能写成"每帧移动 2px",不然 120Hz 屏幕上速度直接翻倍。

图:RAF 和 setTimeout 的调度时机对比
还有一点:页面切到后台后,浏览器通常会暂停或大幅降低 RAF 的频率,因为没必要继续画了。setTimeout 后台也会被节流,但策略不一样。
02RAF 在渲染流程里的位置
浏览器一帧的流程大概是:处理任务 → RAF → Style → Layout → Paint → Composite。当然不同浏览器细节有差异,JS 里某些 DOM 读取也可能提前触发布局,但大概可以这么理解。

图:RAF 在渲染流水线中的位置
我之前误以为 RAF 是"调用栈里的一个阶段",其实不是。调用栈只是 JS 的执行结构,RAF 是浏览器提供的调度机制,在渲染机会到来时把回调排到主线程执行。
还有个细节:同一帧注册的多个 RAF 回调,拿到的 timestamp 是一样的,因为这个时间戳表示的是当前渲染帧的时间,不是每个回调实际开始跑的时间。
03为什么 RAF 适合做动画
核心不是 RAF 让 JS 跑得更快,而是让更新时机更合理。如果用 setTimeout 做动画,回调可能刚好在一次绘制刚结束的时候触发,那这次 DOM 修改就要等下一帧才能显示,白白浪费一次更新机会。RAF 把更新安排在浏览器准备绘制的时候,减少不必要的中间状态。
但要注意:RAF 不会自动解决卡顿。如果一个 RAF 回调跑了 30ms,主线程还是被占住,浏览器照样没法按时画下一帧。60Hz 的 16.67ms 不是全给 JS 的,还要留时间给 Style、Layout、Paint、Composite,真正能用在 JS 上的时间更少。
04动画要基于时间,不是帧数
我之前写动画就是每帧固定移动 2px,后来发现 120Hz 屏幕上速度直接翻倍。正确做法是用 RAF 给的 timestamp 算两帧之间过了多久,然后按时间差算位移:
let lastTime = null
let position = 0
const speed = 100 // px/s
function step(timestamp) {'
if (lastTime === null) {'
lastTime = timestamp
}
const delta = timestamp - lastTime
lastTime = timestamp
position += speed * delta / 1000
box.style.transform = translateX(${position}px)
requestAnimationFrame(step)
}
requestAnimationFrame(step)
这样不管是 60Hz、120Hz 还是 144Hz,理论上一秒都移动约 100px。直接用 RAF 传进来的 timestamp 就行,不用再单独调 performance.now()。
05性能陷阱:长任务和 Layout Thrashing
之前我以为用了 RAF 就一定流畅,后来发现不是。RAF 还是跑在主线程上,如果回调里有 50ms 的长任务,浏览器照样卡成狗。RAF 解决的是"什么时候执行",不是"执行得多快"。
还有个坑:写在 RAF 里也会 Layout Thrashing。比如改完样式立刻读 offsetWidth,浏览器为了返回最新布局,还是会被迫同步 Layout。优化原则还是老样子:把 DOM 读和 DOM 写分开,先批量读,再统一改。
CPU 密集的计算可以放到 Web Worker,能拆分的活就分批做,DOM 场景还要避免强制同步布局和大范围重渲染。
06实战:滚动优化和流式渲染
scroll、pointermove 这些事件触发频率很高,每次都直接更新 DOM 肯定不行。用 RAF 把多次事件合并到一帧只更新一次:
let scheduled = false
let latestY = 0
window.addEventListener('scroll', () => {
latestY = window.scrollY
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
updateUI(latestY)
scheduled = false
})
})
更有意思的是 React 流式渲染的场景。SSE 持续返回 Markdown token,如果每收到一小段就 setContent,网络层可能在很短时间内触发多次状态更新。我自己的做法是先把数据暂存到 Buffer,每一帧最多提交一次:

图:RAF 按帧合并 SSE chunk 的原理
RAF 在这里的作用不是让 SSE 变快,而是把"网络事件触发频率"和"UI 渲染频率"解耦。一帧内到了十个 chunk,最终只触发一次 UI 更新。再配合 memo、稳定 Props、消息级状态隔离,就不会因为一条消息变化导致整个列表重渲染。
07高频面试追问速答
Q:RAF 一定是 60FPS 吗?
不是。受刷新率、浏览器调度、主线程负载影响。60Hz 屏幕理想约 60 次/秒,120Hz 可能到 120 次。动画按 timestamp 算进度就行。
Q:RAF 属于宏任务还是微任务?
严格说不该这么归类。宏任务/微任务是事件循环的任务模型,RAF 是浏览器渲染更新机制的一部分,在合适的渲染机会执行。
Q:RAF 能防抖节流吗?
可以实现按帧合并,效果接近节流,但不完全等于时间节流。它保证每帧最多一次,不是每隔固定 100ms 一次。
Q:RAF 和 requestIdleCallback 区别?
RAF 关注"下一帧绘制前",适合视觉更新;requestIdleCallback 关注空闲时间,适合低优先级、可延迟的任务。一个偏渲染,一个偏空闲调度。
Q:RAF 会内存泄漏吗?
本身不会,但递归 RAF 如果组件卸载或任务结束时没取消,回调会一直跑,还持有组件引用,记得在 cleanup 里 cancel。
总结一下:RAF 本质是浏览器面向渲染帧的调度 API,回调在下一次绘制前执行,跟随实际刷新率。它解决的是更新时机的问题,不是让代码跑得更快------回调里有长任务、强制同步 Layout、大量 React Render,一样掉帧。用的时候记得按时间算动画、记得清理、别拿它当后台任务跑。
学习笔记,如有错漏欢迎指正
寻码札记