4、帧的生命周期:一个Frame从开始到结束的完整旅程
你有没有想过,屏幕上那个流畅的动画,背后到底经历了什么?
我刚开始做前端的时候,总觉得浏览器渲染是个黑盒。代码写下去,画面就出来了。直到有一次,我负责一个数据大屏项目,图表一多,页面直接卡成PPT。老板站在我身后,盯着那个每秒5帧的折线图,空气安静得可怕。
从那天起,我开始死磕帧的生命周期。说白了,一个帧就是浏览器从接收到指令到把像素画到屏幕上的全过程。你只有搞懂它每一步在干什么,才能知道卡顿到底卡在哪。
核心认知:一个帧的生命周期 = 浏览器完成一次画面更新所需的所有步骤。60fps 意味着每帧只有 16.67ms 可用。超过这个时间,帧就会丢失,卡顿就来了。
4.1 帧的起点:VSync 信号
一切从显示器的 VSync(垂直同步)信号开始。显示器以固定频率刷新,比如 60Hz 就是每秒发 60 次信号。每次信号到来,浏览器就知道:该准备下一帧了。
我习惯把这个信号想象成发令枪。枪响之前,浏览器可以悠闲地处理 JS 任务、解析 HTML、执行回调。但枪一响,所有事情都得停下来,优先处理渲染。
个人经验:我曾经在 Chrome DevTools 的 Performance 面板里看到过一种情况——帧率稳定在 30fps,但 CPU 占用率很低。后来发现是显示器刷新率被系统设成了 30Hz。VSync 信号频率直接决定了帧率上限,这个坑我踩过。
4.2 帧的五个阶段
一个完整的帧,从开始到结束,大致经历五个阶段。我用一张图帮你理清脉络:
嗯,这张图我建议你多看两眼。每次排查卡顿,我都会在心里过一遍这五个阶段,然后问自己:现在是哪一步慢了?
4.3 深入每个阶段
4.3.1 VSync 信号——帧的触发器
显示器以固定频率发出 VSync 信号。浏览器的主线程在收到信号后,开始执行帧任务。这里有个关键点:VSync 是硬件级别的信号,浏览器无法加速或跳过它。
你想想看,如果显示器是 60Hz,那每秒最多只能显示 60 帧。就算你的 JS 跑得再快,渲染再高效,最终也只能输出 60 帧。这就是为什么帧率上限由硬件决定。
注意:有些浏览器(比如 Chrome)在后台标签页或窗口被遮挡时,会主动降低 VSync 频率甚至暂停帧循环。这就是为什么你切到其他标签页再回来,动画会突然加速——浏览器在补帧。
4.3.2 输入事件处理
VSync 信号之后,浏览器第一件事就是处理积压的用户输入事件。比如 touch、click、scroll、wheel 等。这些事件如果在上一帧没来得及处理,就会在这一帧的开头被批量执行。
我遇到过一种情况:页面滚动时特别卡,打开 Performance 一看,Input 阶段占了 8ms。仔细排查,发现是某个 touchmove 监听器里做了大量 DOM 操作。说白了,事件回调里别干重活,尤其是滚动这种高频触发的事件。
避坑指南:我曾经在 scroll 事件里直接操作了 200 个 DOM 元素的样式。结果每帧光 Input 阶段就花了 12ms,剩下 4ms 根本不够渲染。后来改用 passive: true 加上 requestAnimationFrame 节流,才把帧时间压回 16ms 以内。
4.3.3 JavaScript 执行
处理完输入事件,浏览器开始执行 JavaScript。这里包括:
- requestAnimationFrame 回调——这是最接近渲染的 JS 执行时机
- 微任务(Promise.then、MutationObserver 等)
- 定时器回调(setTimeout、setInterval)
- 其他异步任务
注意顺序:rAF 回调在微任务之后、渲染之前执行。这意味着你在 rAF 里修改 DOM,浏览器会在同一帧里完成渲染,不会拖到下一帧。
// 推荐的做法:在 rAF 里做动画相关的 DOM 修改
function updateAnimation(timestamp) {
// 计算新位置
element.style.transform = `translateX(${offset}px)`;
// 浏览器会在同一帧完成渲染
requestAnimationFrame(updateAnimation);
}
// 不推荐的做法:在 setTimeout 里改样式
setTimeout(() => {
element.style.transform = `translateX(100px)`;
// 可能错过当前帧,拖到下一帧才渲染
}, 16);
4.3.4 渲染管线
JS 执行完后,进入渲染管线。这是最耗时的阶段,包含三个子步骤:
| 步骤 | 做什么 | 触发条件 | 性能影响 |
|---|---|---|---|
| Style | 计算元素的最终样式(CSS 选择器匹配 + 属性计算) | 修改 class、style 属性、CSS 变量 | 选择器越复杂越慢 |
| Layout | 计算元素的位置和尺寸(回流) | 修改宽高、边距、定位、字体等 | 最昂贵,会触发整棵树重排 |
| Paint | 将元素绘制成位图(重绘) | 修改颜色、背景、阴影、边框等 | 比 Layout 轻,但仍需消耗 GPU 内存 |
我习惯把渲染管线比作做饭:Style 是看菜谱,Layout 是切菜摆盘,Paint 是下锅炒。你想想看,如果每次只改个颜色(Paint),那很快;但如果改了宽度(Layout),整桌菜都得重新摆。
关键原则:尽量只触发 Composite(合成),避免触发 Layout 和 Paint。使用 transform 和 opacity 做动画,因为它们只影响合成阶段。
4.3.5 合成与 GPU 提交
渲染管线的最后一步是合成。浏览器把各个绘制层(比如主内容层、固定定位层、滚动层)合并成一张最终画面,然后提交给 GPU 显示。
这里有个重要概念:层(Layer)。浏览器会把页面拆成多个层,每个层独立绘制。合成时只需要把各层叠在一起就行,不需要重新绘制。
我做过一个实验:一个页面有 50 个独立动画元素,如果不做分层,每帧都要重绘 50 次。但如果用 will-change: transform 让每个元素独立成层,合成阶段只需要把 50 张位图叠在一起,速度飞快。
/* 主动创建合成层 */
.animated-element {
will-change: transform;
/* 或者 */
transform: translateZ(0);
}
小心:层不是越多越好。每个层都会占用 GPU 内存。移动端 GPU 内存有限,层数过多会导致内存溢出或合成变慢。我见过一个极端案例:一个页面创建了 200 多个层,结果合成阶段反而比绘制还慢。
4.4 帧超时与丢帧
如果某个阶段耗时超过 16.67ms,会发生什么?
答案是:丢帧。浏览器不会把不完整的帧显示出来。它会直接跳过这一帧,等下一帧 VSync 信号到来时再尝试。
举个例子:
- 第 1 帧:JS 执行花了 20ms,渲染来不及做,丢帧
- 第 2 帧:VSync 到来,浏览器继续尝试
- 用户看到的是:第 0 帧的画面停留了 33ms,然后跳到第 2 帧
这就是卡顿的直观感受——画面突然停顿,然后跳跃。你想想看,如果每秒丢 10 帧,用户就会觉得页面「一卡一卡的」。
个人习惯:我在做性能优化时,会先用 Performance 面板录一段 5 秒的帧数据。然后看哪些帧的耗时超过了 16ms。如果连续丢帧超过 3 帧,基本就能确定是某个长任务在阻塞主线程。
4.5 帧生命周期的实际意义
搞懂帧的生命周期,不是为了背概念。它直接指导我们的优化策略:
| 卡顿现象 | 可能原因 | 优化方向 |
|---|---|---|
| 滚动卡顿 | Input 阶段事件处理过重 | 使用 passive 监听器,减少 DOM 操作 |
| 动画掉帧 | JS 执行超时或 Layout 频繁触发 | 使用 rAF,避免强制同步布局 |
| 页面闪烁 | Paint 阶段重绘面积过大 | 使用合成层,减少重绘区域 |
| 整体帧率低 | 渲染管线全流程都慢 | 减少 DOM 规模,简化 CSS 选择器 |
说白了,帧生命周期就是一张地图。卡顿的时候,你拿着这张地图,就能定位到具体是哪个路口堵了。是 JS 跑太久?还是 Layout 太频繁?还是 Paint 面积太大?
嗯,这一章的内容就到这里。记住那个五阶段流程图,后面每一章都会用到它。