本文目录
舞台灯光师不会在演员每说一句话时随意开关灯。他通常等到下一次换景,把这一帧需要的灯光、布景和投影一起准备好。浏览器也有类似的节奏:JavaScript 修改状态后,画面不会因为某行代码结束就立刻出现在屏幕上,而要等渲染流程找到合适的机会。
先猜输出:画面什么时候看到新位置
box.style.transform = 'translateX(200px)'
console.log('脚本结束')
requestAnimationFrame(() => {
console.log('下一帧前')
})“脚本结束”会先出现在控制台。requestAnimationFrame(请求下一次绘制前回调)会在浏览器准备更新下一帧时调用,不是当前同步代码的下一行。回调里做的修改通常更容易和浏览器这一帧的节奏对齐,但它也不是“强制立即绘制”的按钮。
一帧里发生了什么
一帧的关键不是“代码跑完就显示”,而是要在浏览器提交这一帧之前完成这些工作。
简化地看,一帧可能经历 JavaScript、样式计算、layout(布局/重排)、paint(绘制/重绘)和 compositing(合成)。不同浏览器会合并、跳过或改变某些阶段,这张图是排障模型,不是规范保证的固定流水线。
JavaScript 先更新状态;样式系统根据 DOM、CSSOM(CSS 对象模型)计算规则;布局决定盒子的位置和大小;绘制生成文字、背景和边框的像素;合成把多个图层叠成屏幕上的最终图像。上一章讲过,改动不一定走完所有阶段:只改合成属性可能轻一些,改变宽度却可能牵连整棵布局。
如果某一帧里的 JavaScript 任务占用 80 毫秒,浏览器即使想画,也没有机会插入。人眼可能感觉到点击没有响应、滚动一顿一顿,Performance 面板里通常能看到长任务。
requestAnimationFrame 不是 setTimeout 的别名
let x = 0
function move(time) {
x += 2
box.style.transform = `translateX(${x}px)`
if (x < 200) requestAnimationFrame(move)
}
requestAnimationFrame(move)setTimeout(move, 16) 只是请求一个稍后执行的任务。它可能在浏览器刚错过一帧时运行,也可能在页面不可见时被节流;它没有直接表达“请在下一次绘制前给我一次机会”。requestAnimationFrame 会把回调和浏览器的绘制节奏对接,并给出一个时间戳,方便按真实时间计算进度。
这仍不等于每秒永远有 60 次回调。显示器可能是 120Hz,页面可能在后台,主线程可能被长任务占满,系统也可能降低调度频率。动画应根据时间戳而不是假定每次固定增加几个像素:
let start = 0
function animate(time) {
if (!start) start = time
const progress = Math.min((time - start) / 500, 1)
box.style.transform = `translateX(${progress * 200}px)`
if (progress < 1) requestAnimationFrame(animate)
}
requestAnimationFrame(animate)Promise、微任务和下一帧的位置
现有事件循环文章已经讲过,Promise reaction 和 queueMicrotask 会在微任务检查点运行。它们适合完成当前逻辑链,却不等同于“下一帧”:
Promise.resolve().then(() => console.log('微任务'))
requestAnimationFrame(() => console.log('绘制机会'))
console.log('同步')通常会先看到“同步”,然后是“微任务”,随后才有下一次绘制前的回调。但浏览器是否在这次任务后真的画一帧,仍受页面状态、渲染机会和主线程负载影响。不要把 requestAnimationFrame 当成普通的延迟函数,也不要用无穷微任务把绘制饿死。
一帧预算不是语言规则
常听到 60Hz 屏幕每帧约 16.7 毫秒,这是工程上用于估算的预算。浏览器还要处理输入、样式、布局和合成,留给你的脚本时间更少。120Hz 的屏幕约 8.3 毫秒一帧;低刷新率设备又不同。
因此“我的循环只跑 10 毫秒,应该没问题”并不可靠。用户输入、解码图片、其他脚本和浏览器自己的工作都在竞争同一主线程。最有用的做法是录制实际场景,观察长任务、输入延迟和帧时间,而不是只背一个数字。
当计算本身很重时,连续 requestAnimationFrame 也救不了它。可以把工作切成小批次,在批次之间把执行权还给浏览器;如果工作与 DOM 无关且 CPU 密集,应该考虑 Worker。动画回调只负责轻量的状态提交,不承担整段数据处理。
页面不可见时会怎样
切到另一个标签页后,浏览器通常没有理由维持高频动画。requestAnimationFrame 可能暂停或降频,定时器也可能被节流。页面恢复时,时间戳可能已经跳了很远,所以基于时间的动画应该把进度限制在结束点,不能每次恢复都补跑成几十秒的循环。
如果动画只是装饰,可以在 document.visibilityState === 'hidden' 时暂停;如果它代表计时、下载或业务状态,就不能简单把“没有绘制”当成“业务停止”。视觉循环和业务时钟要分开设计。
CSS 动画还是 JavaScript
固定的过渡、淡入、旋转通常适合 CSS transition 或 animation。浏览器可以更清楚地知道它是视觉变化,代码也少。需要根据滚动位置、拖拽距离或物理模型连续计算时,JavaScript 才更有优势。
两者也可以配合:JavaScript 更新一个 CSS 自定义属性,CSS 负责最终过渡;或者 JavaScript 只在交互开始和结束时改 class,中间帧交给 CSS。不要为了展示一个简单 hover 效果写一条手动 rAF 循环,也不要因为“CSS 更快”就把复杂交互硬塞进 CSS。
容易踩的坑
16ms 的 setTimeout 并不等于 60 帧,它没有和显示器刷新对齐。调用 requestAnimationFrame 也不保证立刻绘制——主线程长任务会把回调和绘制一起堵住。微任务更不是“下一帧再跑”:它们通常属于当前任务结束后的检查点,会在渲染机会之前被清空。