RAF 渲染帧速通:从渲染帧到性能优化

最近整理前端性能优化相关的知识,发现 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,一样掉帧。用的时候记得按时间算动画、记得清理、别拿它当后台任务跑。

学习笔记,如有错漏欢迎指正

寻码札记

相关推荐
无名猿7 小时前
移动构造与移动赋值:把资源偷过来
c++·性能优化·内存管理·现代c++·语法基础
yunwei378 小时前
eBPF 开发实践:使用 eBPF 隐藏进程或文件信息
linux·后端·性能优化
无名猿11 小时前
shared_mutex 读写锁该不该用,以及 volatile 的三大误解
c++·性能优化·并发编程·现代c++
无名猿1 天前
unique_ptr 完全指南:独占所有权与零开销
c++·性能优化·内存管理·标准库·现代c++
Helix2501 天前
Chrome 性能优化实战:内存节省、硬件加速与 Flags 设置,让浏览器更流畅
chrome·google·性能优化·内存占用·硬件加速·设置技巧·浏览器优化
事圆则缓1 天前
Android 性能优化常见工具与使用场景:从症状到证据的排查指南
android·性能优化
西红柿炖牛腩3541 天前
Three.js 模型体积优化:Draco/Meshopt 压缩与 DRACOLoader 配置的 4 个步骤
javascript·3d·性能优化
cindershade1 天前
首屏不是 dist:用“加载账本”治理 Vite 的大包
性能优化·vite
无名猿1 天前
map / set 完全指南:红黑树与有序容器
数据结构·c++·性能优化·stl·标准库