Skip to content

浏览器运行时与帧调度

返回专题索引:客户端渲染:从状态到画面。下一篇:浏览器内核渲染细节

浏览器里的“渲染慢”通常不是一个绘制函数慢,而是一次交互从输入进入,到主线程有机会更新状态,再到合成器把结果送到屏幕的整条因果链被某一段占住。先把调度骨架建立起来,才能判断该优化 JavaScript、样式计算、布局、绘制,还是根本不该让主线程参与这次更新。

本文以 Chromium/Blink 的术语讲主例子;renderer、合成器和 GPU 这些职责在主流浏览器中都存在,但进程数、线程名、跨进程边界及调度策略并不是 Web 标准,也不能假定 Firefox/WebKit 的内部实现相同。

1. 一帧的运行时骨架

从用户点击到可见画面,可以先压缩成下面这条路径:

text
操作系统输入
  -> 浏览器进程路由(确定标签页/渲染进程)
  -> renderer 的输入处理与事件分派
  -> JavaScript task:事件处理器改变应用状态/DOM
  -> microtask checkpoint:Promise / MutationObserver 等收尾
  -> render opportunity:样式、布局、绘制记录(需要时)
  -> compositor 提交、栅格、合成
  -> GPU 提交到显示系统
  -> 用户看到新帧;下一次输入回流

这里的关键不是“每次都有一帧”。没有可见变更、页面被隐藏、帧率受显示器/节流策略影响时,浏览器可以不产生渲染机会;多个状态变更也可能合并进同一帧。Web 代码能表达意图,不能要求某个固定时刻立刻上屏。

2. 进程和线程:职责心智模型

把以下名称当作可定位问题的职责,而不是所有内核都保证存在的一一对应线程:

概念在 Chromium/Blink 中常见的职责对用户体验的影响
浏览器进程标签管理、导航决策、权限、网络协调、输入路由和跨进程 IPC导航/权限/进程切换迟滞可在这里出现
渲染进程(renderer)一个或多个站点/页面的 Web 内容执行环境,隔离 HTML/CSS/JSWeb 主线程被阻塞时,页面逻辑不能及时响应
主线程(main thread)JS、DOM、样式、布局、绘制记录、部分输入处理和可访问性更新长任务直接推高输入延迟,也常阻塞首帧
合成器线程(compositor)接收可合成的层树、处理部分滚动/动画、协调栅格与提交不依赖主线程的滚动/变换可继续平滑
栅格线程/工作线程将绘制指令或图块转成位图纹理,常可并行图块来不及准备会出现棋盘格、掉帧或低分辨率回退
GPU 进程/线程管理图形命令、纹理与显示合成,隔离驱动风险GPU 饱和或同步等待会让合成阶段超预算

典型 Chromium 的简化关系如下。实际的 OOPIF、worker、service worker、GPU 进程和 Viz 显示合成会使图更复杂。

text
Browser process
  input/navigation/permissions/network coordination
          | IPC
Renderer process
  main: JS -> DOM/style/layout -> paint artifacts
          | commit
  compositor: layer tree -> tiles/raster scheduling -> frame submission
          | GPU commands
GPU / display compositor -> scanout

边界结论: 主线程负责理解 Web 内容并产出更新;合成器负责在已知的图层、属性和纹理范围内尽快把画面拼出来。合成器不能执行任意事件处理器,也不能替你重新计算依赖 DOM/CSS 的布局。

3. 事件循环不是“一个队列”

HTML 事件循环在规范层面由任务(task)、微任务(microtask)及渲染机会等组成;各队列优先级、输入预测与呈现调度是浏览器实现策略。把它理解成单个 FIFO 队列会误判延迟。

text
选择可运行 task
  -> 运行 JS(click、timer、网络回调、message...)
  -> microtask checkpoint
       -> Promise reactions / queueMicrotask / MutationObserver
  -> 浏览器在合适时机可执行渲染更新
       -> requestAnimationFrame callbacks
       -> style/layout/paint(若脏)
       -> submit frame
  -> 进入下一轮

task、microtask 与饥饿

  • task 是一段可被事件循环选择的工作。一个点击处理器和一个定时器回调通常各在 task 中运行。
  • microtask 在当前 JS 执行结束后、浏览器转去下一项工作前清空。Promise 回调常在这里运行。
  • 微任务不等于“更快上屏”。递归地继续排 microtask,会延后渲染机会和下一次输入处理,形成 microtask starvation。
  • await 是否让出到渲染取决于等待对象:已解决 Promise 会在微任务续跑,未必让浏览器先绘制;不要把它当作通用的“让 UI 刷新”工具。

可观察失败:点击后 CPU 占用高、所有动画停止、Performance 面板里一个很长的黄色 Scripting block。此时把 CSS 改成 transform 通常没有用,根因是主线程还没有离开当前 task。

4. 输入、帧与 render opportunity

输入路径同时关心两个时间:浏览器何时开始处理事件,用户何时看见包含结果的帧。后者才是交互完成。

text
pointer down / wheel
   -> 排队等待 main 或 compositor
   -> hit test(命中目标)
   -> listener / default action
   -> 状态更新
   -> 下一次可提交帧
   -> presentation

requestAnimationFrame 的位置

requestAnimationFrame(rAF)回调是浏览器在下一次绘制前提供的动画更新位置。它适合读取上一帧已稳定的数据、更新每帧状态,并让浏览器统一做后续渲染;它不是保证 60fps 的时钟,也不是“立即绘制”。后台页和节能状态会被降频或暂停。

避免在同一 rAF 回调中反复“写 DOM 后读几何信息”。写入会使样式/布局变脏,随后 getBoundingClientRect()offsetWidth 等读取可能迫使浏览器同步完成样式或布局,称为 forced synchronous layout。正确的批次是先读、计算、再写,必要时分帧。

渲染阻塞与帧预算

显示器刷新提供的是时间窗口,不是浏览器承诺。以 60Hz 为例,一帧间隔约 16.7ms,但输入、脚本、布局、绘制、栅格、合成和系统调度都要争用它;120Hz 时窗口更小。超过窗口未必丢失状态,只会复用旧画面或跳过中间状态,用户感到卡顿。

症状常见被占用环节首先验证
点击很久才有任何反应task 排队或长 JSPerformance 的 input delay、Long task
点击马上变色但列表稍后更新状态被拆到后续 task/异步结果事件、state 日志与各帧时间线
滚动卡住主线程滚动、布局/绘制或栅格压力Scrolling performance issues、Frames 轨道
动画断续每帧 JS/layout/paint 超预算,或 GPU/栅格迟到rAF 间隔、Rendering/Compositor 轨道

Long Task 通常指主线程中持续超过 50ms 的任务,是 Web 性能观测中很有用的警报线,不是“低于 50ms 就不会掉帧”的保证。

5. 一次状态更新如何到达屏幕

以点击“展开详情”为例,框架与原生 DOM 的 API 不同,但宿主链条相同:

text
click task
  -> handler: expanded[id] = true
  -> UI 描述/DOM mutation
  -> style invalidation:匹配规则或继承值变化
  -> layout:详情内容影响几何时重算
  -> paint recording:记录受影响区域的绘制指令
  -> commit 给 compositor
  -> tiles rasterize(必要时)
  -> compositor 合成 layer/quads
  -> display presentation

若只是改变一个已独立合成层的 transformopacity,主线程可能只需提交属性变化,后续帧可主要由合成器推进。若改变 width、字体、DOM 结构或依赖它的兄弟节点,合成器缺少新的几何/绘制内容,仍必须等待主线程重新计算。

这解释了一个常见误区:transform 不是魔法性能开关。它可能避免 layout/paint,但额外分层、纹理大小、滤镜和大面积透明混合也会增加内存与 GPU 合成成本。用 trace 确认成本在哪,而不是凭属性名判断。

6. 滚动、动画和输入延迟的职责分界

合成器可推进的路径

当滚动节点、命中测试数据、图层和可动画属性已经由主线程提交,合成器可在主线程忙时处理一部分滚动、transform/opacity 动画,随后重组现有纹理。这是“合成器滚动/动画”的价值:降低对主线程每帧工作的依赖。

必须回主线程的路径

  • 非被动(non-passive)触摸或滚轮监听器可能需要先执行 JS,浏览器无法提前安全地滚动,因为监听器可能 preventDefault()
  • 需要脚本、DOM 查询、布局结果或新绘制内容的动画不能单靠合成器完成。
  • 主线程需重新生成命中测试、可访问性或图层属性时,旧的合成器数据也会过期。

对 wheel/touch 监听器,只有确实需要阻止默认滚动时才使用可取消监听;其他场景使用 { passive: true } 是把“不会取消”这个事实交给浏览器,而非一种无条件的性能咒语。

动画选择的实用规则

目标优先选择原因与限制
位移/淡入淡出CSS/Web Animations 的 transformopacity容易走合成器,但仍要检查分层与纹理成本
内容尺寸随文本变化正常布局或明确测量策略几何本身依赖 layout,不能假装是纯合成动画
与输入强耦合尽量减少每次 move 的 JS/布局高频事件与主线程争用最直接影响 INP
大面积滤镜/阴影先缩小影响区域或预渲染常受 paint、raster 或 GPU bandwidth 限制

7. 从现象回到运行时:验证流程

不要以“感觉很慢”结束诊断。至少采一段可复现录制,保留设备、刷新率、网络和交互步骤。

  1. 在 Chrome DevTools Performance 录制一次真实操作,先看 interaction 到 presentation 的总跨度。
  2. 找到输入事件,区分等待开始处理(input delay)、处理本身(processing)和等待下一帧呈现(presentation delay)。INP 用这类端到端体验衡量,但单次 trace 不能代替真实用户分位数。
  3. 展开主线程:是否有长 JS、过多 microtask、style/layout/paint?用 call tree 追到具体调用。
  4. 对照 Compositor、Raster、GPU/Frames 轨道:主线程空闲仍未上屏时,检查图块、提交和 GPU 等待。
  5. 改动后在相同场景重录,确认是缩短了端到端交互,而不只是把工作挪到别的线程或推迟到下一次点击。

8. 小练习:把“立即更新”拆开

做一个按钮,点击后同步插入 2,000 行内容;再做一个版本把数据先分页或虚拟化。为两个版本录制性能轨迹,回答:

  1. 输入事件何时开始执行?长 JS、layout 还是 paint 占大头?
  2. DOM mutation 发生的时间与第一帧包含新内容的时间相差多少?
  3. 如果用 queueMicrotask 把插入拆成递归批次,为什么可能反而更晚绘制?
  4. 哪些工作可通过减少可见内容、改变数据结构或延后非关键任务消除,而非只换 CSS 属性?

完成后继续阅读 浏览器内核渲染细节,把这里的“样式、布局、绘制、合成”落到可失效和可观测的内部表示。

资料与时效边界

文中的进程、线程、队列优先级和合成器能力是 Chromium/Blink 的职责模型,不是跨浏览器或跨版本的稳定 ABI。标准定义语义;目标浏览器的 trace 才能证明一次具体交互实际跑在哪条路径。

学习规划与研究资料库