Skip to content

浏览器渲染流程

从 URL 到像素

当用户在地址栏输入 URL 并按下回车,到页面呈现在屏幕上,浏览器经历了以下完整流程:

URL 输入

DNS 解析(域名 → IP)

TCP 三次握手(+ TLS 握手,如果 HTTPS)

HTTP 请求/响应

━━━━━━━ 渲染流水线开始 ━━━━━━━

HTML 解析 → DOM Tree
  ↓                           并行
CSS 解析  → CSSOM Tree    ←──────── 可能阻塞渲染

DOM + CSSOM → Render Tree

Layout(布局 / 回流)

Paint(绘制)

Composite(合成)

像素显示在屏幕上

本文聚焦渲染流水线部分——从拿到 HTML 响应到像素上屏的全过程。


构建 DOM 树

浏览器收到 HTML 字节流后,经过以下步骤构建 DOM 树:

字节(Bytes)→ 字符(Characters)→ 令牌(Tokens)→ 节点(Nodes)→ DOM Tree

具体过程:
1. 解码:将字节流按编码(UTF-8)转为字符
2. 词法分析(Tokenizer):将字符流分割为 Token
   - 开始标签 Token:<div class="card">
   - 文本 Token:Hello World
   - 结束标签 Token:</div>
3. 语法分析(Parser):根据 Token 构建 DOM 节点
4. 构建树形结构:根据嵌套关系连接父子节点

           document
              |
            <html>
           /      \
        <head>    <body>
          |         |
        <link>    <div>
                 /    \
              <h1>   <p>

增量解析

HTML 解析器是增量式的——不需要等到整个 HTML 文档下载完毕才开始解析:

网络层返回第一个 chunk:  <html><head><link rel="stylesheet"...
                           ↓ 立即开始解析
解析器处理 <link>         → 发起 CSS 请求
                           
网络层返回第二个 chunk:  ></head><body><div>Hello</div>
                           ↓ 继续解析
解析器处理 <body><div>    → 构建 DOM 节点

解析阻塞

关键问题:什么会阻塞 HTML 解析?

1. <script>(无 async/defer)
   解析器遇到 <script> → 暂停 HTML 解析 → 下载 + 执行 JS → 恢复解析
   
   原因:JS 可能调用 document.write() 修改 DOM 结构

2. <script defer>
   不阻塞解析 → HTML 解析完毕后、DOMContentLoaded 之前执行
   按出现顺序执行

3. <script async>
   不阻塞解析 → 下载完立即执行(可能在解析过程中)
   执行顺序不确定

4. <link rel="stylesheet">
   不直接阻塞 HTML 解析
   但会阻塞后续 <script> 的执行(因为 JS 可能读取样式信息)
   会阻塞渲染(Render Tree 需要 CSSOM)
时间线对比:

同步 <script>:
HTML ████░░░░░░░████████  (中间暂停等待 JS)
JS        ████████         (下载 + 执行)

<script defer>:
HTML ████████████████████  (不阻塞)
JS                    ████ (HTML 解析完毕后执行)

<script async>:
HTML ████████████████████  (不阻塞)
JS          ████           (下载完立即执行,可能打断渲染)

构建 CSSOM 树

浏览器收到 CSS(外部样式表、<style> 标签、内联样式)后,构建 CSSOM(CSS Object Model):

CSS 字节 → 字符 → Token → 节点 → CSSOM Tree

body { font-size: 16px; }
.card { padding: 16px; background: #fff; }
.card .title { font-weight: bold; }

          CSSOM Tree:
          
            body
         font-size: 16px
              |
           .card
      padding: 16px
      background: #fff
              |
        .card .title
      font-weight: bold

CSS 阻塞渲染

CSS 被视为渲染阻塞资源(Render-Blocking Resource):

原因:
浏览器需要完整的 CSSOM 才能构建 Render Tree
→ 如果 CSS 未加载完,浏览器不会渲染任何内容
→ 避免出现"无样式内容闪烁"(FOUC)

优化策略:
1. 关键 CSS 内联到 <head> 的 <style> 中
2. 非关键 CSS 异步加载:
   <link rel="preload" href="non-critical.css" as="style"
         onload="this.rel='stylesheet'">
3. media 属性标记非匹配样式表为非阻塞:
   <link rel="stylesheet" href="print.css" media="print">

构建 Render Tree

DOM Tree + CSSOM Tree → Render Tree(渲染树):

DOM Tree                CSSOM               Render Tree
─────────               ─────               ───────────
html                                        (不在渲染树中)
├─ head                                     (不在渲染树中)
│  ├─ meta                                  (不在渲染树中)
│  └─ link                                  (不在渲染树中)
└─ body               body{font:16px}       body {font:16px}
   ├─ div.card        .card{padding:16px}   div.card {padding:16px}
   │  ├─ h1.title     .title{bold}          h1.title {bold, 16px}
   │  └─ p            p{color:#666}         p {color:#666, 16px}
   └─ div.hidden      .hidden{display:none} (不在渲染树中!)

关键规则

  1. 不可见元素不进入 Render Tree —— display: none 的元素(及其子树)不会出现在渲染树中
  2. visibility: hidden 仍在渲染树中 —— 占位但不可见,与 display: none 不同
  3. <head> 中的元素不在渲染树中 —— <meta><link><script>
  4. 伪元素会进入渲染树 —— ::before::after 不在 DOM 中但在渲染树中
  5. 样式继承在此阶段计算 —— 子节点从父节点继承 font-sizecolor

Layout(布局 / 回流 / Reflow)

计算每个渲染树节点的精确几何信息——位置(x, y)和尺寸(width, height):

Render Tree 节点

布局计算

Layout Tree(带几何信息)

.card {
  width: 50%;        → 父容器 800px → 计算为 400px
  padding: 16px;     → 实际内容区 400 - 32 = 368px
  margin: 0 auto;    → 左偏移 = (800 - 400) / 2 = 200px
}

触发回流(Reflow)的操作

回流是代价最高的渲染操作——修改元素几何属性会导致部分或整个渲染树重新布局:

必然触发回流的操作:

几何属性变化:
  width / height / padding / margin / border
  top / left / right / bottom
  font-size / line-height
  display / position / float

DOM 结构变化:
  添加/删除/移动 DOM 节点
  修改文本内容

窗口变化:
  resize 事件
  滚动(某些情况)

读取布局信息(强制同步布局):
  offsetTop / offsetLeft / offsetWidth / offsetHeight
  scrollTop / scrollLeft / scrollWidth / scrollHeight
  clientTop / clientLeft / clientWidth / clientHeight
  getComputedStyle()
  getBoundingClientRect()

强制同步布局(Layout Thrashing)

js
// ❌ 强制同步布局——每次循环都触发回流
const elements = document.querySelectorAll('.item');
for (const el of elements) {
  const width = el.offsetWidth;        // 读取 → 触发布局计算
  el.style.width = width * 2 + 'px';   // 写入 → 使布局失效
  // 下一次 offsetWidth 读取时,浏览器必须重新布局
}

// ✅ 批量读取,批量写入
const widths = [];
for (const el of elements) {
  widths.push(el.offsetWidth);          // 批量读取
}
for (let i = 0; i < elements.length; i++) {
  elements[i].style.width = widths[i] * 2 + 'px'; // 批量写入
}

Paint(绘制 / 重绘 / Repaint)

将布局信息转换为绘制指令列表(Paint Records):

Layout Tree

绘制指令列表(类似 Canvas 2D 的绘制命令)

[
  "drawRect(200, 100, 400, 300, #fff)",
  "drawBorder(200, 100, 400, 300, 1px, #e0e0e0)",
  "drawText(216, 116, 'Hello', bold 18px sans-serif, #333)",
  "drawText(216, 148, 'Description', 14px sans-serif, #666)",
]

绘制顺序

元素的绘制遵循层叠上下文的 7 层顺序(从底到顶):

1. 背景色(background-color)
2. 背景图(background-image)
3. 边框(border)
4. 子元素(按文档流顺序)
5. 轮廓(outline)
6. 浮动元素
7. 定位元素(按 z-index)

触发重绘但不触发回流的属性

仅触发重绘(不改变几何信息):
  color / background-color / background-image
  visibility / outline / box-shadow
  border-color / border-style(不改变 border-width)
  
  代价:比回流低得多(不需要重新计算布局)

回流 vs 重绘

回流(Reflow / Layout)        重绘(Repaint)
──────────────────            ──────────────
改变几何属性                    改变视觉属性(不影响布局)
必然触发重绘                    不一定触发回流
代价高(可能影响整棵子树)       代价较低
width/height/margin/padding    color/background/visibility

Composite(合成)

现代浏览器将页面分为多个合成层(Compositing Layer),各层独立光栅化,最终由 GPU 合成为最终画面:

Render Tree

分层(Layer)

┌─────────────┐  ┌──────────────┐  ┌──────────────┐
│  主文档层    │  │  固定导航栏层 │  │  弹出层       │
│  (大部分内容) │  │  (position:   │  │  (z-index,   │
│              │  │   fixed)      │  │   transform)  │
└─────────────┘  └──────────────┘  └──────────────┘
  ↓                    ↓                   ↓
  光栅化             光栅化              光栅化
  (Rasterize)       (Rasterize)         (Rasterize)
         ↓                ↓                  ↓
         └────────────────┼──────────────────┘

                    GPU 合成(Composite)

                       屏幕像素

创建独立合成层的条件

以下属性/条件会创建新的合成层:

1. will-change: transform / opacity / filter
2. transform: translateZ(0) / translate3d(0,0,0)(hack 方式)
3. position: fixed
4. <video> / <canvas> / <iframe>
5. 有 3D transform 的元素
6. 对 opacity / transform / filter 做动画
7. 有 CSS filter 的元素
8. 元素有一个包含合成层的后代节点(隐式合成)

合成层的优势

不使用合成层的动画:
用户交互 → JS → Style → Layout → Paint → Composite
                         ↑         ↑
                       可能触发    可能触发
                    (代价很高)  (代价中等)

使用合成层的动画(仅 transform / opacity):
用户交互 → JS → Style → Composite

                     只触发合成
                   (代价极低,GPU 处理)

关键:transform 和 opacity 的动画可以完全在合成线程中完成,
不需要回到主线程做 Layout 和 Paint

完整渲染流水线(Pixel Pipeline)

JavaScript → Style → Layout → Paint → Composite
    |          |        |        |         |
  修改 DOM   计算样式  计算布局  生成绘制  GPU 合成
  触发动画   匹配规则  几何信息  指令列表  最终画面
  
三种更新路径:

路径 1(最重):JS → Style → Layout → Paint → Composite
  触发条件:修改了几何属性(width/height/margin...)
  
路径 2(中等):JS → Style → Paint → Composite
  触发条件:修改了视觉属性(color/background/visibility...)
  跳过 Layout
  
路径 3(最轻):JS → Style → Composite
  触发条件:仅修改 transform / opacity
  跳过 Layout 和 Paint

关键渲染路径(Critical Rendering Path)

从收到 HTML 到首次渲染(First Paint)的最短路径:

关键资源:
HTML → 解析为 DOM
CSS  → 解析为 CSSOM(渲染阻塞)
JS   → 可能修改 DOM/CSSOM(解析阻塞)

                    ┌─── 渲染阻塞 ───┐
HTML ───→ DOM ──────┤                ├──→ Render Tree → Layout → Paint → FP
CSS  ───→ CSSOM ────┘                │
JS   ───→ 执行 ─→ 可能修改 DOM/CSSOM─┘
                    └── 解析阻塞 ──┘

优化关键渲染路径的目标:
1. 减少关键资源数量
2. 减少关键资源大小
3. 减少关键资源的往返次数(RTT)

优化策略

策略 1:关键 CSS 内联
<head>
  <style>
    /* 首屏需要的样式直接内联 */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 64px; background: #fff; }
    .hero { padding: 48px 0; }
  </style>
  <!-- 非关键 CSS 异步加载 -->
  <link rel="preload" href="full.css" as="style"
        onload="this.rel='stylesheet'">
</head>

策略 2:JS 延迟加载
<!-- defer:不阻塞解析,DOMContentLoaded 前按序执行 -->
<script defer src="app.js"></script>

<!-- async:不阻塞解析,下载完立即执行 -->
<script async src="analytics.js"></script>

策略 3:资源预加载
<link rel="preconnect" href="https://api.example.com">
<link rel="preload" href="hero-image.webp" as="image">
<link rel="dns-prefetch" href="https://cdn.example.com">

策略 4:减少关键资源大小
- HTML/CSS/JS 压缩(Gzip/Brotli)
- Tree-shaking 移除无用代码
- 代码分割(Code Splitting)首屏只加载必要代码

回流优化实战

方法 1:批量 DOM 操作

js
// ❌ 每次修改触发一次回流
for (let i = 0; i < 100; i++) {
  const el = document.createElement('div');
  el.textContent = `Item ${i}`;
  container.appendChild(el);  // 100 次回流
}

// ✅ DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const el = document.createElement('div');
  el.textContent = `Item ${i}`;
  fragment.appendChild(el);
}
container.appendChild(fragment);  // 1 次回流

方法 2:脱离文档流修改

js
// ✅ display: none → 修改 → display: block(2 次回流代替 N 次)
element.style.display = 'none';
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
element.style.display = 'block';

方法 3:使用 CSS class 替代多次 style 修改

js
// ❌ 多次修改 style(可能触发多次回流)
el.style.width = '100px';
el.style.height = '200px';
el.style.background = 'red';

// ✅ 一次性切换 class(1 次回流)
el.classList.add('active');

方法 4:使用 transform 代替几何属性

css
/* ❌ 触发 Layout + Paint */
.box {
  transition: left 0.3s;
}
.box:hover {
  left: 100px;
}

/* ✅ 只触发 Composite */
.box {
  transition: transform 0.3s;
}
.box:hover {
  transform: translateX(100px);
}

方法 5:requestAnimationFrame

js
// ❌ 在任意时机修改样式(可能导致强制同步布局)
window.addEventListener('scroll', () => {
  element.style.transform = `translateY(${window.scrollY}px)`;
});

// ✅ 在浏览器下一帧渲染前修改
window.addEventListener('scroll', () => {
  requestAnimationFrame(() => {
    element.style.transform = `translateY(${window.scrollY}px)`;
  });
});

浏览器渲染进程架构

现代 Chrome 浏览器采用多进程 + 多线程架构:

Browser Process          Renderer Process (每个 Tab 一个)
─────────────────        ────────────────────────────────
  UI 线程                  主线程(Main Thread)
  网络线程                   ├─ HTML 解析
  存储线程                   ├─ CSS 解析
                             ├─ JS 执行
                             ├─ Style 计算
                             ├─ Layout
                             └─ Paint(生成绘制指令)
                           
                           合成线程(Compositor Thread)
                             ├─ 分层(Layerize)
                             ├─ 分块(Tiling)
                             └─ 光栅化调度
                           
                           光栅化线程池(Raster Threads)
                             └─ 将图块光栅化为位图
                           
GPU Process
─────────────────
  执行最终合成 + 显示

关键认知

  • 主线程是瓶颈 —— JS 执行、样式计算、布局、绘制都在主线程
  • 合成线程独立于主线程 —— transform / opacity 动画在合成线程处理,不受主线程阻塞影响
  • 这就是为什么 JS 长任务会导致页面卡顿 —— 主线程被 JS 占用时无法处理布局和绘制

面试高频题

1. 浏览器渲染的完整流程是什么?

浏览器收到 HTML 后:①解析 HTML 构建 DOM 树(增量解析);②解析 CSS 构建 CSSOM 树;③DOM + CSSOM 合并为 Render Tree(不包含 display: none 的元素);④Layout 阶段计算每个节点的精确位置和尺寸;⑤Paint 阶段生成绘制指令列表;⑥Composite 阶段将页面分为多个合成层,交给 GPU 合成最终画面。其中 CSS 是渲染阻塞资源(没有 CSSOM 就不能构建 Render Tree),同步 JS 是解析阻塞资源(暂停 HTML 解析)。

2. 回流(Reflow)和重绘(Repaint)的区别?如何减少回流?

回流是重新计算元素的几何属性(位置、尺寸),代价高且会触发重绘。重绘是重新绘制视觉属性(颜色、背景),不涉及布局计算,代价较低。减少回流的方法:①使用 transform 代替 top/left;②批量 DOM 操作(DocumentFragment / 一次性切换 class);③避免强制同步布局(不要在循环中交替读写布局属性);④脱离文档流后再做大量修改;⑤使用 requestAnimationFrame 收敛帧内的样式修改。

3. 什么是合成层?为什么 transform 动画性能更好?

合成层是浏览器将页面分割成的独立图层,每个层独立光栅化,最终由 GPU 合成。transformopacity 动画性能好是因为:①它们不改变元素的布局信息,跳过了 Layout 和 Paint 阶段;②动画直接在合成线程(Compositor Thread)中处理,不占用主线程;③GPU 原生支持矩阵变换和透明度混合。创建合成层的方式包括 will-change: transformtransform: translateZ(0)position: fixed 等。但过多合成层会消耗显存,需要注意"层爆炸"问题。

4. <script> 的 async 和 defer 属性有什么区别?

两者都不会阻塞 HTML 解析(下载阶段并行)。区别在于执行时机:①defer:HTML 解析完成后、DOMContentLoaded 事件之前,按文档中的出现顺序执行。②async:下载完成后立即执行,可能在 HTML 解析过程中执行,执行顺序不确定。适用场景:defer 适合需要操作 DOM 的脚本(有序依赖);async 适合独立的第三方脚本(如统计、广告,无依赖关系)。

5. 什么是关键渲染路径?如何优化?

关键渲染路径是浏览器从收到 HTML 到完成首次渲染(First Paint)必须经过的步骤——包括构建 DOM、构建 CSSOM、构建 Render Tree、Layout、Paint。优化方向:①减少关键资源数量——合并文件、移除非首屏 CSS/JS;②减少关键资源大小——压缩(Gzip/Brotli)、Tree-shaking、代码分割;③减少阻塞时间——关键 CSS 内联、JS 使用 defer/async、使用 preconnect/preload 提前建立连接和加载资源;④减少渲染阻塞——非关键 CSS 异步加载、使用 media 属性标记打印样式表。


追问思考

  1. requestAnimationFrame 的回调在渲染流水线的哪个阶段执行?它和 setTimeout(fn, 0) 在时序上有什么本质区别?
  2. Chrome DevTools 的 Performance 面板中,如何识别"强制同步布局"(Forced Synchronous Layout)?它在火焰图中是什么样子的?
  3. content-visibility: auto 是如何优化大型页面渲染性能的?它跳过了渲染流水线的哪些阶段?
  4. React 的虚拟 DOM Diff 和批量更新机制,本质上是在优化渲染流水线的哪个环节?
  5. 为什么 CSS 动画比 JS 动画更流畅?从浏览器线程架构的角度解释。

用心学习,用代码说话 💻