浏览器是如何渲染页面的?
从 HTML 解析到 GPU 上屏,详解渲染主线程与合成线程分工,以及 DOM 树、布局树、图层与位图如何串联成流水线。
浏览器是如何渲染页面的?
当网络进程把 HTML 文档下载完成后,渲染进程会收到字节流,并往 渲染主线程 的消息队列里塞入一个 渲染任务。主线程在 事件循环 里取出该任务后,才开始「把文档变成屏幕上的像素」。
这不是一次性的函数调用,而是一条 流水线:每个阶段有明确的输入和输出,上一阶段的产出是下一阶段的输入。可以把它想象成工厂里的装配线——材料从一端进去,成品从另一端出来。
总览:八个阶段
| 阶段 | 执行者 | 输入 | 输出 |
|---|---|---|---|
| HTML 解析 | 渲染主线程 | HTML 字节流 | DOM 树 + CSSOM 树 |
| 样式计算 | 渲染主线程 | DOM + CSSOM | 带 Computed Style 的节点 |
| 布局 Layout | 渲染主线程 | 样式树 | 布局树(几何信息) |
| 分层 Layer | 渲染主线程 | 布局树 | 图层树 |
| 绘制 Paint | 渲染主线程 | 图层 | 绘制指令集(display list) |
| 分块 Tiling | 合成线程 | 绘制指令 | 图层小块 |
| 光栅化 Raster | GPU 进程 | 块信息 | 位图(bitmap) |
| 画 Draw | 合成线程 + GPU | 位图 | 屏幕像素 |
记忆口诀:
- 主线程:Parse → Style → Layout → Layer → Paint
- 合成线程 + GPU:Tiling → Raster → Draw
主线程极其繁忙——事件循环 里还要执行 JS、处理事件、跑计时器。渲染只是它众多职责之一;一旦某段 JS 长时间占用主线程,整条渲染流水线都会暂停(见 reflow、repaint 与 transform)。
第一步:HTML 解析
解析 HTML 时,遇到 CSS 就解析 CSS,遇到 JS 就执行 JS。
为提高效率,浏览器在正式解析前还会启动 预解析(preload scanner),提前下载 HTML 里 <link>、<script> 引用的外部资源,减少主线程空等。
解析完成后得到两棵树:
- DOM 树:文档结构,对应 HTML 标签的嵌套关系
- CSSOM 树:汇总 用户代理默认样式、
<style>、外链 CSS、行内style等全部 CSS 规则
CSS 为什么不阻塞 HTML 解析?
主线程扫到 <link rel="stylesheet"> 时,即使 CSS 文件还没下载完,也不会停下来等,会继续解析后面的 HTML。CSS 的下载与解析由预解析等机制并行处理,因此 CSS 默认不阻塞 DOM 树的构建。
JS 为什么会阻塞 HTML 解析?
主线程扫到 同步 <script> 时,会 暂停 HTML 解析,先等 JS 下载并执行完。因为 JS 可能通过 document.write 等方式修改 DOM,必须保证 DOM 生成顺序与脚本执行顺序一致。
defer、async、type="module" 等会改变具体行为,但 「同步脚本阻塞解析」 是默认规则,也是 <script> 放 </body> 前、或把脚本移到底部的原因。
同步脚本阻塞 DOM 构建
html<p id="before">before</p>
<script>
document.querySelector('#before').textContent = 'changed'
</script>
<p id="after">after</p>
<!-- 解析器在遇到 script 时必须先执行完再继续 -->第二步:样式计算
主线程遍历 DOM,结合 CSSOM,为每个节点算出最终用于渲染的样式,即 Computed Style(计算样式)。
此阶段会把许多值「算实」:
red→rgb(255, 0, 0)em→px(在父级字号已知时)- 经过 层叠、继承、默认值 得到每个属性的最终声明
但 百分比宽高 等仍可能待定——它们依赖 包含块,要在 布局 阶段结合几何上下文才能定稿。
第三步:布局(Layout)
布局阶段遍历带样式的节点,计算 几何信息:宽高、位置(相对包含块)、行盒高度等,产出 布局树(Layout Tree)。
布局树 ≠ DOM 树
两棵树 常常不是一一对应:
| 情况 | DOM 树 | 布局树 |
|---|---|---|
display: none | 有节点 | 无几何信息,不进布局树 |
::before / ::after | 无对应节点 | 有几何信息,会进布局树 |
| 匿名行盒 / 块盒 | 无独立节点 | 布局算法自动生成 |
布局完成后,每个可见元素在屏幕上「占哪块地方」就确定了。改宽高、边距、display 等会触发 reflow(回流),需要重新跑 Layout 及之后阶段——详见 reflow 与 repaint。
第四步:分层(Layer)
主线程按策略把布局树 分成多个图层(Layer)。
分层是性能优化:将来只改某一层的样式或内容时,可以 只更新该层 的后续步骤,而不必重算整页。
常见影响因素:
- 滚动容器、滚动条
- 堆叠上下文(
z-index等) transform、opacitywill-change提示浏览器提前分层
分层策略因浏览器而异,属于实现细节;开发上只需知道:分层是为了增量更新更快。
第五步:绘制(Paint)
主线程为 每一层 单独生成 绘制指令集(display list),描述这一层该怎么画——类似 Canvas 的 fillRect、drawText 等调用序列。
渲染主线程的工作到这里为止。 之后把各层的绘制信息交给 合成线程(Compositor Thread)。
改 color、background 等可见但不改布局的样式,可能只需 repaint(重算绘制及之后),不必完整 reflow。
第六~八步:分块、光栅化、上屏
合成线程接手后:
1. 分块(Tiling)
把每一层划成更小的 图块(tile),交给 线程池 并行处理,避免单次任务过大。
2. 光栅化(Raster)
把块信息交给 GPU 进程,由 GPU 以极高速度转成 位图(bitmap)——每个块是一小块像素矩阵。靠近 视口 的块会 优先 光栅化,所以你滚动时远处内容可能稍晚才清晰。
3. 画(Draw)
合成线程为每块位图生成 指引(quad):在屏幕上的位置、旋转、缩放等。transform 的变形主要发生在此阶段,不经过主线程的 Layout/Paint,因此动画往往更顺滑。
最后 GPU 进程通过系统调用把结果交给显卡。渲染进程跑在沙盒里,不能直接碰硬件,崩溃也不会拖垮整个系统。
常见面试答法(精简版)
网络进程收到 HTML 后,产生渲染任务进入渲染主线程的消息队列;事件循环取出任务,开启流水线:解析 → 样式 → 布局 → 分层 → 绘制 → 分块 → 光栅化 → 画。主线程负责前五步,合成线程和 GPU 负责后三步。CSS 不阻塞 DOM 构建,同步 JS 会阻塞解析。改动样式可能触发 reflow/repaint,是流水线运行中的局部重跑。
小结
- 渲染由 事件循环调度,是主线程与合成线程协作的流水线。
- DOM + CSSOM → 样式 → 布局 → 图层 → 绘制指令 → 位图 → 屏幕。
- 同步 JS 阻塞解析;CSS 默认不阻塞 DOM。
- Layout 定几何,Paint 出指令,Compositor + GPU 上屏;
transform多在合成阶段完成。 - 与 CSS 属性计算、包含块、reflow/repaint 串联起来,就是前端性能优化的完整地图。