你写了一个
setState,React 更新了虚拟 DOM,然后呢?然后浏览器要经历解析、样式计算、布局、绘制、合成五个阶段,才能把像素画到屏幕上。理解这个过程,你才能真正理解"为什么这样做性能更好"。
一、先看一个故事
你的 test.html 里有这样一段代码:
js
// 方式一:逐个插入(慢)
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); // 一次性挂载,只渲染一次
为什么方式二更快?"渲染"到底发生了什么?
这篇文章把这个过程从头到尾讲清楚。
二、完整流程:五层漏斗
css
你写的代码
│
▼
┌─────────────────────────────────────────┐
│ 阶段一:网络 & 解析 │
│ HTML/CSS/JS 字节 → 解码 → Token → 树 │
│ 产出:DOM 树 + CSSOM 树 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 阶段二:样式计算 │
│ DOM + CSSOM → 合并 → Render Tree │
│ 产出:渲染树(去掉了 display:none 等) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 阶段三:Layout(布局/重排) │
│ 计算每个元素在页面上的精确位置和大小 │
│ 产出:盒模型数据(x, y, w, h, margin...) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 阶段四:Paint(绘制/重绘) │
│ 把每个元素画出来(颜色、边框、阴影、文字) │
│ 产出:位图(像素数据) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 阶段五:Composite(合成) │
│ 把多个图层叠在一起,输出到屏幕 │
│ 产出:屏幕上的像素 ✨ │
└─────────────────────────────────────────┘
三、阶段一:解析 --- 从字节到树
3.1 HTML → DOM 树
css
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 的诞生
markdown
DOM 树 CSSOM 树
│ │
└──────────────┬───────────────┘
▼
Render Tree(渲染树)
渲染树不是 DOM 的简单复制,它会过滤掉不可见的元素:
| 原始 DOM | 在渲染树中? |
|---|---|
<head>、<script>、<meta> |
❌ 不可见,不存在 |
display: none 的元素 |
❌ 不可见,不存在 |
visibility: hidden 的元素 |
✅ 在(占位,只是透明) |
opacity: 0 的元素 |
✅ 在 |
伪元素 ::before / ::after |
✅ 在(虽然不是 DOM 节点) |
五、阶段三:Layout --- 最贵的步骤
Layout(也叫 Reflow / 重排)做一件事:计算每个渲染树节点的几何信息。
css
对于渲染树中的每个可见节点:
→ 计算宽度(width)
→ 计算高度(height)
→ 计算位置(x, y 坐标)
→ 计算盒模型(margin, padding, border)
→ 处理文本换行
一个计算结果会影响周围的元素(比如一个元素变宽了,旁边的元素位置得跟着移动)。所以一个节点的 Layout 可能触发连锁反应------这也正是 Layout 昂贵的原因。
什么操作触发 Layout?
css
增删 DOM 节点 → Layout ✅
修改 width / height → Layout ✅
修改 margin / padding → Layout ✅
修改 left / top → Layout ✅
修改 font-size → Layout ✅(文字大小影响容器高度)
读取 offsetHeight 等 → Layout ✅(浏览器强制同步 Layout 以返回准确值)
六、阶段四:Paint --- 把它画出来
Layout 确定了位置和大小 ,Paint 确定长什么样。
css
对于每个元素:
→ 背景色(background-color)
→ 文字颜色和字体(color, font-family)
→ 边框(border)
→ 阴影(box-shadow, text-shadow)
→ 轮廓(outline)
浏览器会把页面分解成多个图层(Layers),每个图层独立进行光栅化(把矢量信息转成像素点)。
什么操作只触发 Paint(不触发 Layout)?
css
修改 color → Paint ✅(Layout ❌)
修改 background-color → Paint ✅
修改 box-shadow → Paint ✅
修改 visibility → Paint ✅
七、阶段五:Composite --- 合成为最终画面
浏览器有一个合成器线程(Compositor Thread),它把多个图层按照 z-index 顺序叠在一起,生成最终的位图,然后提交给 GPU 显示到屏幕。
css
图层 A(背景)
图层 B(内容) → Compositor → 合并 → GPU → 屏幕
图层 C(弹窗)
为什么这是最快的阶段
Composite 是唯一在 GPU 上执行 的阶段,而且不依赖主线程。你可以在 JS 主线程忙的时候,仍然有流畅的 CSS 动画。
什么操作只触发 Composite(最快!)
css
修改 transform → 只 Composite ✅(Layout ❌, Paint ❌)
修改 opacity → 只 Composite ✅
CSS 动画(transform) → 只 Composite ✅
这也是为什么"用 transform 做动画"是性能最佳实践------它跳过了 Layout 和 Paint 两个最贵的步骤。
八、一张总览表
| 你改了什么属性 | Layout | Paint | Composite | 代价 |
|---|---|---|---|---|
width、height |
✅ | ✅ | ✅ | 🔴 最高 |
margin、padding |
✅ | ✅ | ✅ | 🔴 最高 |
| 增/删 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 的次数。