从一行 JSX 到屏幕像素:前端开发者必须懂的浏览器渲染管线

36 阅读5分钟

你写了一个 setState,React 更新了虚拟 DOM,然后呢?然后浏览器要经历解析、样式计算、布局、绘制、合成五个阶段,才能把像素画到屏幕上。理解这个过程,你才能真正理解"为什么这样做性能更好"。


一、先看一个故事

你的 test.html 里有这样一段代码:

// 方式一:逐个插入(慢)
for (const task of data) {
    const item = document.createElement('li');
    item.innerText = task;
    oList.appendChild(item);  // 每次都触发页面重新渲染!
}

// 方式二:批量插入(快)
const fragment = document.createDocumentFragment();
for (const task of data) {
    const item = document.createElement('li');
    fragment.appendChild(item);  // 在内存中,不触发渲染
}
oList.appendChild(fragment);     // 一次性挂载,只渲染一次

为什么方式二更快?"渲染"到底发生了什么?

这篇文章把这个过程从头到尾讲清楚。


二、完整流程:五层漏斗

你写的代码
    │
    ▼
┌─────────────────────────────────────────┐
│  阶段一:网络 & 解析                      │
│  HTML/CSS/JS 字节 → 解码 → Token → 树    │
│  产出:DOM 树 + CSSOM 树                  │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│  阶段二:样式计算                         │
│  DOM + CSSOM → 合并 → Render Tree        │
│  产出:渲染树(去掉了 display:none 等)      │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│  阶段三:Layout(布局/重排)              │
│  计算每个元素在页面上的精确位置和大小         │
│  产出:盒模型数据(x, y, w, h, margin...) │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│  阶段四:Paint(绘制/重绘)               │
│  把每个元素画出来(颜色、边框、阴影、文字)    │
│  产出:位图(像素数据)                     │
└─────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────┐
│  阶段五:Composite(合成)                │
│  把多个图层叠在一起,输出到屏幕              │
│  产出:屏幕上的像素 ✨                     │
└─────────────────────────────────────────┘

三、阶段一:解析 — 从字节到树

3.1 HTML → DOM 树

HTML 字节流
  → 解码成字符(UTF-8)
  → Tokenizer(词法分析器):切分成 token
     <div class="box">Hello</div>
       ↓
     [StartTag:div] [Attr:class="box"] [Text:Hello] [EndTag:div]
  → 构建 DOM 节点
  → 连接成 DOM 树

DOM 树是内存中的一棵树,它描述了文档的结构,但没有任何样式信息

3.2 CSS → CSSOM 树

遇到 <style><link> 时,浏览器解析 CSS,构建 CSSOM 树。

CSS 文本
  → 解析选择器、属性、值
  → 计算继承关系
  → 构建 CSSOM 树

CSSOM 记录了每个元素最终应用的所有 CSS 属性(包括继承来的和浏览器默认的)。

⚠️ CSS 解析会阻塞渲染——浏览器不会开始 Layout,因为后面的 CSS 可能会覆盖前面的样式。但它不阻塞 DOM 构建

3.3 JavaScript 执行

遇到 <script> 时:

⚠️ JS 执行会阻塞 HTML 解析——因为 JS 可能 document.write() 改变 HTML 结构,所以解析器要停下来等 JS 跑完。

这也是为什么现代前端工程会把 <script> 放在 <body> 底部或用 async/defer


四、阶段二:样式计算 — Render Tree 的诞生

DOM 树                        CSSOM 树
    │                              │
    └──────────────┬───────────────┘
                   ▼
            Render Tree(渲染树)

渲染树不是 DOM 的简单复制,它会过滤掉不可见的元素:

原始 DOM在渲染树中?
<head><script><meta>❌ 不可见,不存在
display: none 的元素❌ 不可见,不存在
visibility: hidden 的元素✅ 在(占位,只是透明)
opacity: 0 的元素✅ 在
伪元素 ::before / ::after✅ 在(虽然不是 DOM 节点)

五、阶段三:Layout — 最贵的步骤

Layout(也叫 Reflow / 重排)做一件事:计算每个渲染树节点的几何信息

对于渲染树中的每个可见节点:
  → 计算宽度(width)
  → 计算高度(height)
  → 计算位置(x, y 坐标)
  → 计算盒模型(margin, padding, border)
  → 处理文本换行

一个计算结果会影响周围的元素(比如一个元素变宽了,旁边的元素位置得跟着移动)。所以一个节点的 Layout 可能触发连锁反应——这也正是 Layout 昂贵的原因。

什么操作触发 Layout?

增删 DOM 节点           → Layout ✅
修改 width / height     → Layout ✅
修改 margin / padding   → Layout ✅
修改 left / top         → Layout ✅
修改 font-size          → Layout ✅(文字大小影响容器高度)
读取 offsetHeight 等    → Layout ✅(浏览器强制同步 Layout 以返回准确值)

六、阶段四:Paint — 把它画出来

Layout 确定了位置和大小,Paint 确定长什么样

对于每个元素:
  → 背景色(background-color)
  → 文字颜色和字体(color, font-family)
  → 边框(border)
  → 阴影(box-shadow, text-shadow)
  → 轮廓(outline

浏览器会把页面分解成多个图层(Layers),每个图层独立进行光栅化(把矢量信息转成像素点)。

什么操作只触发 Paint(不触发 Layout)?

修改 color              → Paint  ✅(Layout ❌)
修改 background-color   → Paint  ✅
修改 box-shadow         → Paint  ✅
修改 visibility         → Paint  ✅

七、阶段五:Composite — 合成为最终画面

浏览器有一个合成器线程(Compositor Thread),它把多个图层按照 z-index 顺序叠在一起,生成最终的位图,然后提交给 GPU 显示到屏幕。

图层 A(背景)
图层 B(内容)      →  Compositor → 合并 → GPU → 屏幕
图层 C(弹窗)

为什么这是最快的阶段

Composite 是唯一在 GPU 上执行的阶段,而且不依赖主线程。你可以在 JS 主线程忙的时候,仍然有流畅的 CSS 动画。

什么操作只触发 Composite(最快!)

修改 transform          → 只 Composite ✅(Layout ❌, Paint ❌)
修改 opacity            → 只 Composite ✅
CSS 动画(transform)   → 只 Composite ✅

这也是为什么"用 transform 做动画"是性能最佳实践——它跳过了 Layout 和 Paint 两个最贵的步骤。


八、一张总览表

你改了什么属性LayoutPaintComposite代价
widthheight🔴 最高
marginpadding🔴 最高
增/删 DOM 节点🔴 最高
font-size🔴 最高
background-color🟡 中等
color🟡 中等
box-shadow🟡 中等
visibility🟡 中等
transform🟢 最低
opacity🟢 最低
cursor🟢 无

九、React 做了什么

理解了浏览器渲染管线,你就能理解 React 的很多设计选择:

9.1 虚拟 DOM + Reconciliation

你调用三次 setState
  → React 不立即操作 DOM
  → 在虚拟 DOM 中 diff
  → 算出最小变更集
  → 一次性应用到真实 DOM
  → 浏览器只触发一次 Layout → Paint → Composite

这就是你 test.html 中 DocumentFragment 做的事情,但 React 把它自动化了。

9.2 批处理(Batching)

React 18 之后,所有 setState 调用都会自动批处理——不管它们在事件处理函数、setTimeout、还是 Promise 中。这确保了一次渲染帧中只触发一次浏览器渲染管线

9.3 React Fragment(<></>

减少无意义的 DOM 层级 → 减少 Layout 中需要计算的节点数 → 更快。


十、一句话总结

浏览器从你写的代码到显示画面,经历了:
解析(构建树)→ 样式计算(合并树)→ Layout(算位置)→ Paint(画出来)→ Composite(叠图层)

Layout 最贵,Paint 次之,Composite 最便宜。
React 的所有性能优化(虚拟 DOM、批处理、Fragment)本质上都是在减少 Layout 和 Paint 的次数。