本文适合谁?

  • 初中级前端开发者,想系统理解浏览器渲染原理。

  • 希望写出高性能页面、减少卡顿和重排的开发者。

  • 对 defer / async / DOMContentLoaded 一知半解,想彻底搞懂的人。

读完本文,你将能解释:
  • 浏览器如何一步步把 HTML/CSS 变成页面。

  • 为什么 transform 比 left 性能好得多。

  • 怎样批量操作 DOM 才能不卡。

  • defer 和 async 到底怎么选。

一、概览:一次页面请求的“流水线”

想象浏览器是一个高度自动化的工厂,输入是 HTML、CSS、JavaScript,输出是屏幕上的像素。这条流水线大致分八个步骤:

1. 下载 HTML  →  2. 解析 HTML(构建 DOM 树)
                        ↓
3. 解析 CSS(构建 CSSOM 树) ← 4. 执行脚本(可能阻塞)
                        ↓
5. 合并为渲染树(只包含可见元素)
                        ↓
6. 布局(计算每个元素的位置和大小)
                        ↓
7. 绘制(填充像素,生成绘制指令)
                        ↓
8. 合成(GPU 合并图层,显示到屏幕)

整个过程也叫 关键渲染路径 。优化页面,本质上就是缩短这条路径的时间。

通俗易懂流程图

具体流程:

  1. 下载 HTML → 同时触发预加载扫描器(提前请求图片、CSS、脚本等)。

  2. 解析 HTML → 生成 DOM 树(页面的结构骨架)。
    解析 CSS → 生成 CSSOM 树(页面的样式规则)。

  3. 合并为渲染树 → 只保留可见元素及其最终计算后的样式。

  4. 布局(Reflow) → 计算每个元素精确的几何尺寸和位置。

  5. 绘制(Paint) → 生成绘制指令(颜色、文字、边框等)。

  6. 合成(Composite) → GPU 将多个图层合并成最终图像。

  7. 显示到屏幕

二、DOM 树:页面的骨架

2.1 解析过程不是“等全部下载完再开始”

浏览器非常聪明,它采用 流式解析 :一边从网络接收 HTML 字节流,一边解析。这样首屏能更快出现。

解析流程:

字节流 → 字符流 → Token(标签/文本/注释) → DOM 节点 → DOM 树
2.2 预加载扫描器:提前“偷看”未来资源

主解析器在构建 DOM 时,另一个叫 预加载扫描器 的线程会扫描后续的 HTML 源码,一旦发现 <img>、<link>、<script> 等,就会立刻向网络层发起请求,不阻塞主解析。这就是为什么即使 script 放在后面,图片也能提前加载。

<!-- 预加载扫描器会并行请求 style.css 和 logo.png -->
<link rel="stylesheet" href="style.css">
<img src="logo.png" alt="logo">
2.3 什么会阻塞 DOM 解析?
  • 普通的 <script>(没有 defer 或 async):必须下载并执行完,才会继续解析。因为脚本可能用 document.write 修改 HTML。

  • CSS 文件 不阻塞 DOM 解析 ,但会阻塞 脚本执行(因为脚本可能需要知道样式)。

2.4 小口诀:DOM 树是结构的树
<html>
  <head><title>我的页面</title></head>
  <body>
    <div class="box">
      <h1>Hello</h1>
      <p>World</p>
    </div>
  </body>
</html>

对应的 DOM 树(简化):

Document
 └─ html
    ├─ head
    │  └─ title → "我的页面"
    └─ body
       └─ div.box
          ├─ h1 → "Hello"
          └─ p → "World"

三、CSSOM 树:页面的妆扮

3.1 CSS 解析独立但重要

CSS 文本被解析成 CSSOM 树(CSS Object Model)。它和 DOM 树结构并不完全一致,而是一组规则树,每个 DOM 元素最终会匹配到一组 CSS 声明。

body { font-size: 16px; color: #333; }
.box { width: 80%; margin: 0 auto; }
h1 { font-size: 2em; color: blue; }
3.2 继承与计算
  • 子元素会继承父元素的部分属性(如 color、font-size)。

  • 相对单位(em、rem、%)会被转换成 绝对像素值。比如 h1 的 2em,在 body 字体为 16px 时,最终变成 32px。

3.3 重要事实:CSS 阻塞渲染

浏览器必须等 CSSOM 构建完成才能进入渲染阶段,因为“没有样式,怎么画?”所以首屏 CSS 越小越好,非关键 CSS 可以延迟加载。

四、渲染树:只有“可见”元素才上场

DOM 树 + CSSOM 树 → 渲染树。渲染树只包含 那些会显示在屏幕上的元素 :

元素/样式

是否进入渲染树

<head> 及其子元素

display: none

visibility: hidden

是,占位但看不见

<body>、<div>、<p>等普通元素

::before 伪元素

DOM 树              CSSOM 规则                   渲染树
html                body { display: block }      body
 ├─head (忽略)    .box { width: 100px }       └─ div.box
 └─body           h1 { color: red }             ├─ h1 "Hi"
    └─div.box     p { margin: 0 }               └─ p "there"
       ├─h1
       └─p

注意:渲染树中的每个节点(通常叫 RenderObject)同时包含了从 CSSOM 计算来的样式。

五、布局(Layout / Reflow):计算精确位置

现在浏览器知道每个元素长什么样,但还不知道它们 具体在屏幕的哪个坐标 。布局阶段就是计算每个元素的宽度、高度、x、y 值。

5.1 触发布局的“雷区”

以下操作会触发整个页面或部分页面的重新布局(reflow),代价很高:

  • 修改元素的 width、height、margin、padding、border。

  • 改变 font-size、display(比如从 none 变成 block)。

  • 添加或删除 DOM 节点。

  • 读取布局属性(如 offsetTop、getBoundingClientRect())——这可能会 强制浏览器立即重新计算布局

5.2 重排 vs 重绘 vs 合成

操作

触发重排

触发重绘

性能开销

改 width / height

极高

改 color / background

中等

改 transform / opacity

低(仅合成)

重排一定导致重绘,但重绘不一定导致重排。

5.3 实战:避免“强制同步布局”
// 灾难性写法:读 → 写 → 读 → 写...
const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
  const width = box.offsetWidth;   // 读取,可能触发之前写入的布局
  box.style.width = width + 10 + 'px'; // 写入,标记布局失效
}

// 正确写法:先批量读取,再批量写入
const widths = [];
for (let box of boxes) {
  widths.push(box.offsetWidth);
}
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = widths[i] + 10 + 'px';
}

六、绘制(Paint)与合成(Composite)

6.1 绘制:生成“绘画说明书”

布局结束后,渲染引擎把每个可见元素转换成具体的绘制操作:画一个矩形、描一段文字、贴一张图片……这些指令被记录在 Display List 中。

6.2 分层:像 Photoshop 图层

浏览器会把页面拆成多个 图层。拥有以下特性的元素会获得独立图层:

  • 根元素(html)。

  • 设置了 transform: translateZ(0) 或 will-change: transform。

  • <video>、<canvas>、WebGL。

  • 有 3D 变换或滤镜效果。

  • 透明度动画(opacity 变化)。

独立图层的好处是:该图层的变化不会影响其他图层,且可以由 GPU 直接处理

6.3 合成:GPU 加速的魔法

合成线程将各个图层栅格化(变成位图),然后通过 GPU 快速合并,输出到屏幕。

最佳性能动画属性:transform 和 opacity。因为它们不需要触发布局或绘制,只走合成。

/* 高性能动画 */
.card {
  transition: transform 0.2s;
  will-change: transform;
}
.card:hover {
  transform: translateY(-5px);
}

七、脚本与事件:defer、async 与 DOMContentLoaded

很多初学者搞不清脚本执行时机,这里用一个表格讲透。

7.1 三种脚本对比

类型

下载时机

执行时机

阻塞 HTML 解析

执行顺序

普通 <script>

立即下载,阻塞解析

下载后立刻执行

阻塞

按文档顺序

<script defer>

并行下载(不阻塞)

HTML 解析完成后,DOMContentLoaded 之前

不阻塞

按文档顺序

<script async>

并行下载(不阻塞)

下载完成后立刻执行(可能还没解析完)

可能阻塞

不保证顺序

7.2 两个关键事件
  • DOMContentLoaded:整个 HTML 解析完成,并且所有 defer 脚本执行完毕 后触发。不等待 async、图片、样式表。

  • load:所有资源(图片、样式表、异步脚本、iframe 等)都加载完成后触发。

<head>
  <script defer src="defer.js"></script>
  <script async src="async.js"></script>
  <link rel="stylesheet" href="style.css">
</head>

<body>
  <img src="big.jpg">
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      console.log('DOM 就绪,且所有 defer 脚本执行完了');
    });
    window.addEventListener('load', () => {
      console.log('所有资源(含图片)加载完了');
    });
    console.log('内联脚本:此时 DOM 还在解析中');
  </script>
</body>

典型输出顺序:

内联脚本:此时 DOM 还在解析中
…(解析继续)…
(defer.js 执行)
DOM 就绪,且所有 defer 脚本执行完了
(async.js 可能在这之前或之后)
(图片加载完)
所有资源(含图片)加载完了

八、初中级开发者必须掌握的优化技巧

8.1 关键渲染路径优化
  • 内联首屏关键 CSS,避免额外网络请求阻塞渲染。

  • 脚本尽量用 defer,除非确实需要尽早执行且不依赖 DOM(此时用 async)。

  • 非关键 CSS 延迟加载

<link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">
8.2 减少重排与重绘
  • 使用 document.createDocumentFragment 批量添加多个子节点。

  • 改用 classList 切换整类,而不是逐个修改 style。

  • 动画元素设置为 position: absolute 或 fixed,使其脱离文档流,减少对其他元素的影响。

8.3 使用现代 CSS 特性

属性

作用

will-change: transform

提前告知浏览器该元素会变化,浏览器会为其创建独立层

contain: layout style paint

告诉浏览器该元素的内部变化不会影响外部,可限制重排范围

content-visibility: auto

自动跳过视口外元素的渲染(谨慎使用)

8.4 资源提示帮你提速
<!-- 提前连接第三方来源 -->
<link rel="preconnect" href="https://api.example.com">
<!-- 预加载下一个页面需要的资源 -->
<link rel="prefetch" href="next.html">

总结

概念

一句话记住

DOM 树

HTML 解析出的页面结构树

CSSOM 树

CSS 解析出的样式规则树,与 DOM 结合才能渲染

渲染树

DOM + CSSOM,但剔除不可见元素

布局(重排)

计算每个元素的位置和大小,开销最大

绘制

生成绘制指令,填充颜色、文字等

合成

GPU 合并图层,transform/opacity 动画不走重排

普通脚本

阻塞 HTML 解析,应避免放在 <head>

defer

不阻塞解析,顺序执行,在 DOMContentLoaded 前完成

async

不阻塞解析,下载完就执行,顺序不定

优化核心

减少重排、使用合成属性、批量 DOM 操作、合理放置脚本

把浏览器的渲染流水线装进心里,写出的代码会自然避开性能陷阱。如果你现在能回答“为什么 transform 更快”,以及“怎样避免强制同步布局”,恭喜你,已经超越了大部分初中级开发者。

想要更深入?可以阅读 Chrome 官方渲染性能指南CSS Triggers 这个经典参考。