Appearance
浏览器运行时与帧调度
返回专题索引:客户端渲染:从状态到画面。下一篇:浏览器内核渲染细节。
浏览器里的“渲染慢”通常不是一个绘制函数慢,而是一次交互从输入进入,到主线程有机会更新状态,再到合成器把结果送到屏幕的整条因果链被某一段占住。先把调度骨架建立起来,才能判断该优化 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/JS | Web 主线程被阻塞时,页面逻辑不能及时响应 |
| 主线程(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
-> 状态更新
-> 下一次可提交帧
-> presentationrequestAnimationFrame 的位置
requestAnimationFrame(rAF)回调是浏览器在下一次绘制前提供的动画更新位置。它适合读取上一帧已稳定的数据、更新每帧状态,并让浏览器统一做后续渲染;它不是保证 60fps 的时钟,也不是“立即绘制”。后台页和节能状态会被降频或暂停。
避免在同一 rAF 回调中反复“写 DOM 后读几何信息”。写入会使样式/布局变脏,随后 getBoundingClientRect()、offsetWidth 等读取可能迫使浏览器同步完成样式或布局,称为 forced synchronous layout。正确的批次是先读、计算、再写,必要时分帧。
渲染阻塞与帧预算
显示器刷新提供的是时间窗口,不是浏览器承诺。以 60Hz 为例,一帧间隔约 16.7ms,但输入、脚本、布局、绘制、栅格、合成和系统调度都要争用它;120Hz 时窗口更小。超过窗口未必丢失状态,只会复用旧画面或跳过中间状态,用户感到卡顿。
| 症状 | 常见被占用环节 | 首先验证 |
|---|---|---|
| 点击很久才有任何反应 | task 排队或长 JS | Performance 的 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若只是改变一个已独立合成层的 transform 或 opacity,主线程可能只需提交属性变化,后续帧可主要由合成器推进。若改变 width、字体、DOM 结构或依赖它的兄弟节点,合成器缺少新的几何/绘制内容,仍必须等待主线程重新计算。
这解释了一个常见误区:transform 不是魔法性能开关。它可能避免 layout/paint,但额外分层、纹理大小、滤镜和大面积透明混合也会增加内存与 GPU 合成成本。用 trace 确认成本在哪,而不是凭属性名判断。
6. 滚动、动画和输入延迟的职责分界
合成器可推进的路径
当滚动节点、命中测试数据、图层和可动画属性已经由主线程提交,合成器可在主线程忙时处理一部分滚动、transform/opacity 动画,随后重组现有纹理。这是“合成器滚动/动画”的价值:降低对主线程每帧工作的依赖。
必须回主线程的路径
- 非被动(non-passive)触摸或滚轮监听器可能需要先执行 JS,浏览器无法提前安全地滚动,因为监听器可能
preventDefault()。 - 需要脚本、DOM 查询、布局结果或新绘制内容的动画不能单靠合成器完成。
- 主线程需重新生成命中测试、可访问性或图层属性时,旧的合成器数据也会过期。
对 wheel/touch 监听器,只有确实需要阻止默认滚动时才使用可取消监听;其他场景使用 { passive: true } 是把“不会取消”这个事实交给浏览器,而非一种无条件的性能咒语。
动画选择的实用规则
| 目标 | 优先选择 | 原因与限制 |
|---|---|---|
| 位移/淡入淡出 | CSS/Web Animations 的 transform、opacity | 容易走合成器,但仍要检查分层与纹理成本 |
| 内容尺寸随文本变化 | 正常布局或明确测量策略 | 几何本身依赖 layout,不能假装是纯合成动画 |
| 与输入强耦合 | 尽量减少每次 move 的 JS/布局 | 高频事件与主线程争用最直接影响 INP |
| 大面积滤镜/阴影 | 先缩小影响区域或预渲染 | 常受 paint、raster 或 GPU bandwidth 限制 |
7. 从现象回到运行时:验证流程
不要以“感觉很慢”结束诊断。至少采一段可复现录制,保留设备、刷新率、网络和交互步骤。
- 在 Chrome DevTools Performance 录制一次真实操作,先看 interaction 到 presentation 的总跨度。
- 找到输入事件,区分等待开始处理(input delay)、处理本身(processing)和等待下一帧呈现(presentation delay)。INP 用这类端到端体验衡量,但单次 trace 不能代替真实用户分位数。
- 展开主线程:是否有长 JS、过多 microtask、style/layout/paint?用 call tree 追到具体调用。
- 对照 Compositor、Raster、GPU/Frames 轨道:主线程空闲仍未上屏时,检查图块、提交和 GPU 等待。
- 改动后在相同场景重录,确认是缩短了端到端交互,而不只是把工作挪到别的线程或推迟到下一次点击。
8. 小练习:把“立即更新”拆开
做一个按钮,点击后同步插入 2,000 行内容;再做一个版本把数据先分页或虚拟化。为两个版本录制性能轨迹,回答:
- 输入事件何时开始执行?长 JS、layout 还是 paint 占大头?
- DOM mutation 发生的时间与第一帧包含新内容的时间相差多少?
- 如果用
queueMicrotask把插入拆成递归批次,为什么可能反而更晚绘制? - 哪些工作可通过减少可见内容、改变数据结构或延后非关键任务消除,而非只换 CSS 属性?
完成后继续阅读 浏览器内核渲染细节,把这里的“样式、布局、绘制、合成”落到可失效和可观测的内部表示。
资料与时效边界
- task、microtask 与事件循环语义以 WHATWG HTML event loops 为准;动画帧语义见 Animation frames(访问于 2026-07-29)。
- Chromium 的帧提交、合成与显示职责参考 Life of a Frame 与 cc compositor overview(访问于 2026-07-29)。
- 交互响应和 DevTools 证据的解释参考 Interaction to Next Paint 与 Chrome DevTools Performance(访问于 2026-07-29)。
文中的进程、线程、队列优先级和合成器能力是 Chromium/Blink 的职责模型,不是跨浏览器或跨版本的稳定 ABI。标准定义语义;目标浏览器的 trace 才能证明一次具体交互实际跑在哪条路径。