浏览器是怎么把 HTML 变成屏幕上的像素的?一文讲透渲染流水线
当你打开一个网页,从输入 URL 到看到页面,浏览器内部经历了一条完整的"渲染流水线"。这条流水线被拆成了多个阶段,每个阶段有明确的输入和输出。
用一个公式概括:render(HTML) → pixels
下面按阶段逐步拆解。
阶段一:解析(Parse)—— 产出 DOM 树和 CSSOM 树
网络线程拿到 HTML 字符串后,往渲染主线程的消息队列里丢一个"渲染任务"。主线程开始解析。
DOM 树
HTML 是一串字符串,主线程把它解析成一棵树。树的根节点是 document(对应 <html> 标签),然后根据标签的嵌套关系展开父子节点。这棵树叫 DOM 树。
CSSOM 树
CSS 的解析产物叫 CSSOM 树,是一个包含了所有 CSS 选择器及其对应样式属性的结构化对象。
CSSOM 有两个重要特性:
A. 层叠(Cascading)
"层叠样式表"的"层叠"就体现在这里。多个来源的样式(浏览器默认样式、外部样式表、内联样式、用户自定义样式)会按优先级规则合并,最终计算出每个节点的样式。
B. 渲染阻塞(Render-Blocking)
在 CSSOM 构建完成之前,浏览器不会开始渲染页面。换个角度说:CSS 会阻塞渲染。
但 CSS 的下载并不阻塞解析。过程分三步:
- 预解析:浏览器有一个"预解析线程"(Preload Scanner),它会快速扫描 HTML,找到
<img>、<link>、<script>等外部资源的 URL,提前通知网络线程去下载 - 网络下载:网络线程异步下载 CSS 文件
- 构建 CSSOM:下载完成后,主线程构建 CSSOM——只有这一步是阻塞渲染的
为什么 CSSOM 必须构建完才能渲染?因为页面绘制需要同时知道 DOM 结构和每个节点的样式。而且 JS 可能通过 getComputedStyle() 查询样式信息,所以 CSSOM 没准备好也会阻塞 JS 执行。
阶段二:样式计算(Style Recalculation)—— 产出 Computed Style
DOM 树和 CSSOM 树都构建完成后,浏览器把它们合并——这个过程叫 样式计算。
它会遍历 DOM 树的每一个节点,根据 CSS 选择器权重、层叠规则、继承规则,为每个节点确定最终样式。输出是一个"计算后的样式"(Computed Style)。
这个过程中有几个关键的转化:
red会变成rgb(255, 0, 0)(预设值 → 绝对值)em会变成px(相对单位 → 绝对单位)- 未显式设置的属性从父节点继承计算值
阶段三:布局(Layout)—— 产出 Layout 树
有了每个节点的样式,下一步是计算每个节点在屏幕上的位置和大小。这一步依次遍历 DOM 树节点,计算几何信息,产出 Layout 树(以前叫 Render Tree)。
Layout 树和 DOM 树不一定一致:
display: none的节点在 DOM 树里有,但在 Layout 树里没有(几何信息为零)- 伪元素(
::before、::after)在 DOM 树里不存在,但在 Layout 树里会生成对应节点 - 匿名块盒:如果一个
<div>里既有块级元素又有纯文本,浏览器会自动创建一个匿名块盒把文本包起来,保持布局模型一致 - 匿名行盒:同理,纯文本没有被行内标签包裹时会被视为匿名行盒
回流(Reflow)
计算几何信息的过程叫回流,也叫重排。任何导致几何信息变化的操作(改宽高、改位置、增删 DOM 节点)都会触发回流。
浏览器的优化策略是:不会你改一次它就算一次。它会批处理——等一段 JS 代码跑完后,统一做一次回流计算。所以回流是"异步"完成的。
但有一个例外:如果 JS 代码中间需要读取布局属性(比如 offsetHeight、getBoundingClientRect()),浏览器会立刻同步回流,否则 JS 读到的是旧数据。
阶段四:分层(Layering)—— 产出 Layer 树
主线程拿到 Layout 树后,会根据一套策略把页面"分层"。把页面拆成多个层的好处是:某一层发生变化时,只需要重绘这一层,其他层不动,大幅提升渲染效率。
触发自动分层的情况:
- 元素有层叠上下文的属性(
z-index+ 定位) - 硬件加速元素:
<video>、<canvas>、<iframe> - 带
overflow: scroll的容器 - 设置了
will-change属性
will-change:给浏览器的"预警"
will-change 告诉浏览器:"这个元素即将发生变化"。浏览器收到这个信号后,会提前为它创建一个独立的合成层。这样当变化发生时,浏览器不需要重新计算——这一层已经准备好了。
但不要滥用。每个合成层都占用 GPU 内存,给所有元素都加 will-change 会让内存爆炸。
层叠上下文:z-index 没你想的那么万能
层叠上下文是 HTML 元素在三维空间(Z 轴)上的分层概念。同一个层叠上下文内的元素按 z-index 排序;但不同层叠上下文的元素之间,z-index 不跨上下文比较。
<div class="relative z-1"> Box A
<div class="absolute z-999">999</div>
</div>
<div class="relative z-2"> Box B </div>
999 的 z-index 再高,也遮挡不了 Box B——因为它们的层叠上下文不同。999 的比较对象是 Box A 内部的兄弟元素,不涉及 Box B。
常见的创建层叠上下文的条件(很容易忽略的):
<html>根元素本身就带一个position: absolute/relative且z-index不为autoopacity < 1transform不为none(包括scale(1)这种"看起来没变"的值)filter不为none- Flex/Grid 容器的子元素,且
z-index不为auto
注意:z-index 只在 position 非 static 的元素或 Flex/Grid 子元素上才起作用。
阶段五:绘制(Paint)—— 产出绘制指令集
主线程为每个层单独生成一套绘制指令集,描述这一层该怎么画。指令集的格式类似于 Canvas API 的调用序列:"先画一个红色矩形,再画一段文字……"
为什么不直接画?因为后面的工作更适合交给合成器线程和 GPU 处理。主线程只负责"描述怎么画",然后通过 Commit 把指令集交给合成线程。主线程至此收工。
重绘(Repaint)
如果修改的样式不涉及几何信息(比如改颜色、改背景),浏览器可能跳过前面的 Layout 和 Layering,直接到绘制阶段。这就是重绘。
补充一个关系:回流一定会触发重绘(几何变了,外观肯定要重画),但重绘不一定触发回流。
阶段六:分块(Tiling)
从这一步开始,工作从主线程移交到合成线程。
如果一次性把整个页面(尤其是长页面)绘制成一张巨型位图,内存扛不住。所以合成线程会把每个图层切成很多小块(tiles),然后维护一个栅格化线程池,把这些瓦片任务分发给多个线程并发处理。
阶段七:光栅化(Raster)—— 产出位图
把每个瓦片变成位图(像素信息)。靠近视口的瓦片优先处理(你正在看的区域要先画出来)。这个过程使用 GPU 硬件加速。
阶段八:画(Draw)—— 最终呈现
合成线程拿到所有层、所有块的位图后,生成一个个"四边形指引"(Draw Quad),告诉 GPU:这张位图画在屏幕的哪个位置、有没有旋转、缩放、变形。
然后合成线程把这些 Quad 提交给 GPU 进程,GPU 完成最终渲染——你看到了屏幕上的像素。
为什么 transform 动画效率高?
变形(旋转、缩放、平移)是在合成线程里处理的,与主线程完全无关。这意味着:
- 不用重新 Layout
- 不用重新 Paint
- 主线程不参与
所以用 transform 做动画比用 left/top 做动画流畅得多——后者会触发回流,全程要重走主线程。
一条完整流水线回顾
主线程:
解析 → Layout树 → Layer树 → 绘制指令集
(DOM+CSSOM) (算位置) (分层级) (描述怎么画)
↕ Commit
合成线程:
分块 → 光栅化 → 合成
(切瓦片) (转位图) (出Quad)
GPU进程:
Quad → GPU → 屏幕像素
理解这条流水线,你就能解释为什么某些 CSS 操作"贵"(触发 Layout)、某些"便宜"(只到 Composite),从而写出更高性能的前端代码。