4、帧的生命周期:一个Frame从开始到结束的完整旅程

你有没有想过,屏幕上那个流畅的动画,背后到底经历了什么?

我刚开始做前端的时候,总觉得浏览器渲染是个黑盒。代码写下去,画面就出来了。直到有一次,我负责一个数据大屏项目,图表一多,页面直接卡成PPT。老板站在我身后,盯着那个每秒5帧的折线图,空气安静得可怕。

从那天起,我开始死磕帧的生命周期。说白了,一个帧就是浏览器从接收到指令到把像素画到屏幕上的全过程。你只有搞懂它每一步在干什么,才能知道卡顿到底卡在哪。

核心认知:一个帧的生命周期 = 浏览器完成一次画面更新所需的所有步骤。60fps 意味着每帧只有 16.67ms 可用。超过这个时间,帧就会丢失,卡顿就来了。

4.1 帧的起点:VSync 信号

一切从显示器的 VSync(垂直同步)信号开始。显示器以固定频率刷新,比如 60Hz 就是每秒发 60 次信号。每次信号到来,浏览器就知道:该准备下一帧了。

我习惯把这个信号想象成发令枪。枪响之前,浏览器可以悠闲地处理 JS 任务、解析 HTML、执行回调。但枪一响,所有事情都得停下来,优先处理渲染。

个人经验:我曾经在 Chrome DevTools 的 Performance 面板里看到过一种情况——帧率稳定在 30fps,但 CPU 占用率很低。后来发现是显示器刷新率被系统设成了 30Hz。VSync 信号频率直接决定了帧率上限,这个坑我踩过。

4.2 帧的五个阶段

一个完整的帧,从开始到结束,大致经历五个阶段。我用一张图帮你理清脉络:

帧生命周期流程图 帧生命周期:从 VSync 到像素 ① VSync 信号 发令枪响 ② 输入事件 touch/click/scroll ③ JS 执行 requestAnimationFrame ④ 渲染管线 Style→Layout→Paint ⑤ 合成 GPU 总预算:16.67ms(60fps)—— 每个阶段都要抢时间 各阶段详解: ① VSync:显示器发出垂直同步信号,浏览器开始新一帧 ② Input:处理用户交互事件(触摸、点击、滚动) ③ JS:执行 rAF 回调、定时器、微任务等 JavaScript 代码 ④ 渲染:计算样式 → 布局 → 绘制(分层) ⑤ 合成:将各层提交给 GPU,合成最终画面并显示 ⚠ 任何阶段超时,都会导致丢帧

嗯,这张图我建议你多看两眼。每次排查卡顿,我都会在心里过一遍这五个阶段,然后问自己:现在是哪一步慢了?

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 面积太大?

嗯,这一章的内容就到这里。记住那个五阶段流程图,后面每一章都会用到它。