# 浏览器渲染管线详解

用生活化的比喻,让你彻底理解浏览器是如何把 HTML/CSS/JS 变成屏幕上的像素的

前置知识:2_1_v8Learn(JS执行原理)+ 2_2_h5-rendering-mastery(GPU渲染管线)


阅读指南(初学者必看)

为什么你需要学习浏览器渲染管线?

你已经学了 V8 引擎(JS 怎么被编译和执行)和 GPU 渲染管线(GPU 怎么把顶点数据变成像素)。但这两个知识之间有一条裂缝:

  • V8 项目讲了 JS 执行,但没讲浏览器拿到 JS 执行结果后怎么渲染
  • GPU 渲染项目讲了 GPU 的工作原理,但没讲浏览器怎么把 HTML/CSS 变成 GPU 能理解的数据
  • 你修改 DOM 后页面变卡了,但不知道是重排还是重绘导致的

学完本章,你能回答:

  • 浏览器从收到 HTML 到渲染出像素,经过了哪些步骤?
  • 为什么修改 DOM 会卡?哪些操作导致重排,哪些只导致重绘?
  • 合成层是什么?为什么 100 个合成层会占 800MB 显存?

本文结构

第一部分:从 URL 到像素的完整流程(建立全景认知)
第二部分:DOM 构建(HTML 怎么变成 DOM 树)
第三部分:CSSOM 与样式计算(CSS 怎么影响渲染)
第四部分:Render Tree 与布局(怎么计算元素的位置和大小)
第五部分:绘制与合成(怎么把元素变成像素)
第六部分:合成层深度解析(H5 游戏性能优化的核心概念)

一、从 URL 到像素:完整流程

生活类比:浏览器渲染就像一条汽车生产线。

原材料(URL) → 采购(网络请求) → 拆箱(HTML解析) → 
组装骨架(DOM) → 喷漆方案(CSSOM) → 设计图纸(Render Tree) → 
计算尺寸(Layout) → 喷漆(Paint) → 贴膜合成(Composite) → 成品(像素)
阶段 做了什么 耗时在哪 游戏中的影响
导航 DNS → TCP → TLS → HTTP 网络延迟 首屏加载速度
解析 HTML → DOM,CSS → CSSOM 大文档解析 初始化时间
渲染树 DOM + CSSOM → Render Tree 不可见节点过滤
布局 计算 DOM 元素位置和大小 复杂布局 DOM 操作导致重排
绘制 生成绘制指令列表 大面积/复杂样式 重绘开销
合成 GPU 合成各层输出像素 层数/纹理大小 显存占用

浏览器渲染管线的 8 大阶段

HTML/CSS/JS
    |
    v
1. HTML解析 ----> DOM树
    |
2. CSS解析 -----> CSSOM树
    |
3. 合并 --------> Render树(渲染树)
    |
4. 布局 --------> 几何信息(位置和大小)
    |
5. 分层 --------> 层树(Layer Tree)
    |
6. 绘制 --------> 绘制指令列表
    |
7. 光栅化 ------> 位图(Bitmap)
    |
8. 合成 --------> 屏幕像素
    |
    +-----------> 显示到屏幕

关键认知:不是每次页面变化都要走完整的 8 个阶段。浏览器非常聪明,会根据变化的类型跳过不必要的阶段。

变化类型 需要重新执行的阶段 跳过的阶段
改变元素颜色 6绘制 + 7光栅化 + 8合成 1,2,3,4,5
改变元素尺寸 4布局 + 5分层 + 6,7,8 1,2,3
使用 transform 移动 8合成 1,2,3,4,5,6,7
display:none→block 3,4,5,6,7,8 1,2

为什么 transform 只需要重新合成? 因为 transform 只影响元素的位置变换,不改变元素的内容、大小或样式。浏览器可以把元素预先绘制在一个独立的层(Layer)上,transform 只是改变这个层的位置或变换矩阵。 类比:你有一张已经画好的画(绘制完成),现在你只是把画框移动到房间的另一面墙上(transform)。你不需要重新画画,只需要移动画框。


二、DOM 构建

生活类比:DOM 构建就像搭积木。HTML 是说明书,浏览器按顺序一块一块搭。

HTML 字符串
  ↓ tokenizer(切词)
Token 流(<div>, <p>, "文本", </p>, </div>)
  ↓ tree construction(建树)
DOM 树

关键细节1:预扫描器

浏览器不会等 JS 下载完再继续解析 HTML。预扫描器会提前扫描后面的 <link><script>,并行下载资源。

HTML 解析器遇到 <script src="app.js">:
  ├─ 主解析器:暂停,等 JS 下载并执行
  └─ 预扫描器:继续扫描后面的 HTML,发现 <link> 和 <script> 就提前下载

对游戏开发的影响

  • 游戏加载页面要把关键 JS 放在 <head> 里加 defer
  • 非关键的资源(广告SDK、统计SDK)用 async 异步加载

关键细节2:async vs defer

<!-- 方式1:阻塞解析,下载完立即执行 -->
<script src="critical.js"></script>

<!-- 方式2:不阻塞解析,下载完立即执行(执行顺序不保证) -->
<script async src="analytics.js"></script>

<!-- 方式3:不阻塞解析,等 HTML 解析完再执行(按顺序) -->
<script defer src="game-engine.js"></script>

<!-- 方式4:默认 defer 行为 -->
<script type="module" src="game.js"></script>
属性 下载时机 执行时机 执行顺序 适用场景
遇到就下载 下载完立即执行 按出现顺序 关键脚本
async 遇到就下载(并行) 下载完立即执行 不保证顺序 统计/广告SDK
defer 遇到就下载(并行) HTML解析完后执行 按出现顺序 游戏引擎/框架
type="module" 遇到就下载(并行) HTML解析完后执行 按依赖关系 现代JS模块

关键细节3:DOM 操作很慢

每次修改 DOM 都可能触发重排或重绘。这不是 DOM API 本身慢,而是浏览器的渲染管线很重

// ❌ 每次修改都触发重排
for (let i = 0; i < 100; i++) {
  element.style.left = i + 'px';  // 100次重排!
}

// ✅ 批量修改,只触发1次重排
element.style.cssText = 'left: 100px;';  // 只1次重排

// ✅ 使用 DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
list.appendChild(fragment);  // 只1次重排

三、CSSOM 与样式计算

生活类比:CSSOM 就像装修方案表。每个 DOM 节点对应一份"怎么装修"的说明。

CSS 选择器匹配:从右到左

/* 浏览器是这样匹配的:先找所有 .item,再看祖先有没有 .list */
.list .item { color: red; }

/* 所以这个选择器很慢:先找所有 div,再看祖先有没有 .list */
/* 页面里有1000个div,每个都要检查祖先 → 1000次检查! */
.list div { color: red; }

性能建议

  • 避免使用通配选择器 *
  • 避免标签选择器 .list div,改用类选择器 .list-item
  • 选择器层级不要太深(3层以内)

样式级联顺序

!important > 内联 style > #id > .class / :pseudo > tag > 继承
   ↑ 优先级最高                  ↑ 最常用              ↑ 最低

CSS 阻塞渲染

为什么 CSS 会阻塞渲染?因为不知道样式就没法计算布局。

HTML 解析中 ──遇到 <link>──▶ 下载 CSS ──▶ 解析 CSSOM ──▶ 继续解析 HTML
                              │
                              └── 这段时间页面白屏!

优化建议

  • 关键 CSS 内联到 <head> 中(首屏渲染需要的样式)
  • 非关键 CSS 用 media 属性延迟加载
<link rel="stylesheet" href="critical.css">  <!-- 阻塞渲染 -->
<link rel="stylesheet" href="print.css" media="print">  <!-- 只在打印时加载 -->

四、Render Tree 与布局(Layout/Reflow)

生活类比:Render Tree 是"真正要画的东西"。display:none 的元素就像被划掉的名字,不在名单上。

Render Tree 的构成

DOM 树:                           Render Tree:
├── html                           ├── RenderView (viewport)
│   ├── head                       │   ├── RenderBody
│   │   └── ... (不渲染)           │   │   ├── RenderDiv (header)
│   └── body                       │   │   └── RenderDiv (main)
│       ├── div#header             │   │       ├── RenderText ("Hello")
│       │   └── "Hello"           │   │       └── RenderDiv (sidebar)
│       ├── div#main               │   │           ├── RenderText ("Content")
│       │   └── "Content"         │   │           └── ...
│       └── div.hidden             │   └── ...
│           └── "Hidden" ← 不在   │
               Render Tree 中      │

Layout(布局/重排):计算元素的位置和大小

触发重排的操作

操作类型 具体操作 影响范围
添加/删除元素 appendChildremoveChild 全局或局部
修改尺寸 widthheightmarginpadding 全局或局部
读取布局属性 offsetWidthgetBoundingClientRect() 强制同步布局!
修改字体 fontSizefontFamily 全局
修改内容 textContentinnerHTML 局部
窗口变化 resize 事件 全局

强制同步布局(Layout Thrashing):最危险的重排模式!

// ❌ 强制同步布局:读→写→读→写,每次读都强制浏览器立即计算布局
for (let i = 0; i < 100; i++) {
  const width = element.offsetWidth;  // 读:强制布局计算
  element.style.width = width * 2 + 'px';  // 写:使布局失效
  // 下一次循环的 offsetWidth 又会强制布局 → 100次重排!
}

// ✅ 批量读取,批量写入
const width = element.offsetWidth;  // 读1次
element.style.width = width * 2 + 'px';  // 写1次
// 只有1次重排

布局边界(Layout Boundary)

修改某个元素的布局不会影响祖先时,这个元素就是布局边界。

创建布局边界的方法

  • position: absoluteposition: fixed
  • overflow: hidden(不是 visible
  • display: flex / display: grid
// 游戏中的最佳实践:
// 把会频繁变化的元素放在 absolute 容器中
// 这样变化不会影响其他元素的布局
const popup = document.createElement('div');
popup.style.position = 'absolute';  // 创建布局边界
popup.style.left = '100px';
popup.style.top = '100px';

五、绘制与合成

生活类比:绘制就像画家一笔一笔画,合成就像把多张透明胶片叠在一起。

绘制(Paint)

绘制是生成绘制指令列表的过程。这些指令包括:

  • 画矩形、画圆、画文字
  • 填充颜色、画边框
  • 画图片、画阴影

触发重绘的操作(不会触发重排):

  • 修改 colorbackground-color
  • 修改 box-shadow
  • 修改 visibility: hidden(注意不是 display:none

重排 vs 重绘的代价

重排(Layout)= 重新计算位置 + 重新绘制
重绘(Paint)= 只重新绘制

所以:重排一定导致重绘,重绘不一定导致重排

合成(Composite)

合成是把各层的绘制结果交给 GPU,由 GPU 把多层叠在一起输出最终像素。

合成的优势

  • 只需要 GPU 参与,不需要 CPU 重新计算布局和绘制
  • transformopacity 的变化只触发合成,不触发重排和重绘
  • 这就是为什么 CSS 动画推荐用 transform 而不是 left/top
/* ❌ 触发重排+重绘 */
.animate-bad {
  transition: left 0.3s, top 0.3s;
}

/* ✅ 只触发合成,性能最佳 */
.animate-good {
  transition: transform 0.3s, opacity 0.3s;
}

六、合成层深度解析

这是 H5 游戏性能优化的核心概念!

什么元素会成为合成层?

触发条件 说明 游戏中的场景
will-change: transform 显式声明将要变化 游戏角色动画
will-change: opacity 显式声明将要变化 淡入淡出效果
3D transform translate3dtranslateZ(0) 最常见的hack:强制GPU加速
opacity 动画 CSS opacity 动画 UI 淡入淡出
<video> 视频元素 游戏CG
<canvas> Canvas 元素 WebGL游戏本身
<iframe> 内嵌页面 广告、支付
position: fixed 固定定位 游戏HUD
隐式提升 与合成层重叠 ⚠️ 可能导致层爆炸!

合成层的内存成本

每层纹理大小 = width × height × 4 bytes(RGBA8)
加上 Mipmap ≈ 额外 33%

例子:一个 1920×1080 的合成层
= 1920 × 1080 × 4 = 8,294,400 bytes ≈ 8MB
100 个合成层 ≈ 800MB 显存!

对 H5 游戏的影响

  • 一个 Canvas 游戏本身就是1个合成层
  • 如果你给每个 UI 元素都加了 will-change: transform……
  • 50 个 UI 元素 = 50 个合成层 = 400MB 显存!
  • 低端手机可能只有 1~2GB 总内存,400MB 纯给合成层就直接爆了

层爆炸

层爆炸:隐式提升导致合成层数量失控。

正常情况:
  div.container(普通层)
  └── div.animated(合成层,will-change: transform)

如果 container 和 animated 重叠,浏览器可能需要把 container 也提升为合成层
→ 2个合成层

如果有 20 个这样的 animated 元素互相重叠呢?
→ 20+ 个合成层!这就是层爆炸

如何检测层爆炸

  1. 打开 Chrome DevTools → More tools → Layers
  2. 查看层数量和每层的大小
  3. 或者:DevTools → Rendering → Layer borders(显示层的边框)

如何修复层爆炸

  1. 移除不必要的 will-change
  2. 给需要动画的元素加 z-indexposition: relative/absolute(隔离层叠上下文)
  3. 减少合成层之间的重叠区域
  4. 使用 transform: translateZ(0) 代替 will-change(更精确地控制)

H5 游戏中的合成层策略

✅ 推荐做法:
1. Canvas/WebGL 游戏本身就是1个合成层,不需要额外优化
2. UI 元素不要随意加 will-change
3. 只有频繁动画的元素才需要提升为合成层
4. 动画结束后移除 will-change(节省显存)

❌ 常见错误:
1. 给所有元素加 will-change "以防万一" → 层爆炸
2. 用 translateZ(0) 给所有元素"GPU加速" → 每个都是合成层
3. 不清理动画结束后的合成层 → 显存不释放

自问自答

Q:重排和重绘哪个更严重? A:重排更严重。重排 = 重新计算布局 + 重新绘制,重绘 = 只重新绘制。重排一定导致重绘,但重绘不一定导致重排。

Q:为什么 CSS 动画推荐用 transform 而不是 left/top? A:transform 的变化只触发合成(Composite),不触发重排(Layout)和重绘(Paint)。而 left/top 的变化会触发重排+重绘。合成只在 GPU 上执行,不占用 CPU。

Q:display:nonevisibility:hidden 有什么区别? A:display:none 让元素不在 Render Tree 中(不占空间,不渲染)。visibility:hidden 让元素在 Render Tree 中(占空间,但不可见)。前者不参与任何渲染步骤,后者仍然参与布局和绘制,只是不显示。

Q:游戏里应该用 DOM 还是 Canvas? A:Canvas/WebGL 游戏只有1个合成层,内存占用可控。DOM 游戏如果 UI 元素多,容易层爆炸。但 DOM 对文本渲染和交互更友好。最佳实践:游戏画面用 Canvas,UI 用 DOM 但严格控制合成层。


实践任务

  • 任务1:用 Chrome DevTools Layers 面板分析一个网页的合成层数量和显存占用
  • 任务2:制造"层爆炸"——创建多个重叠元素 + will-change: transform,观察显存变化
  • 任务3:修复层爆炸——移除不必要的 will-change,用 z-index 隔离层叠上下文
  • 任务4:对比 transform 动画和 left/top 动画的性能差异(用 Performance 面板录制)
  • 任务5:制造强制同步布局,用 Performance 面板中的红色警告标记定位问题代码

与其他章节的关联

本章内容 关联章节 关联点
合成层 第02章 合成层占用的显存是游戏内存的大头
重排/重绘 第03章 长任务阻塞渲染导致掉帧
DOM 构建 2_1_v8Learn V8 解析执行 JS 可能阻塞 DOM 构建
GPU 合成 2_2_h5-rendering-mastery GPU 渲染管线的最后一步

下一章:02-合成层与GPU加速