从 HTML 到像素:浏览器渲染的秘密全流程(通俗讲解)

239 阅读19分钟

前言

理解浏览器如何解析 HTML 文件,不仅能帮助我们分析性能问题,还能更深入地理解浏览器的工作机制。

过去网上的讲解五花八门、各执一词,所以这篇文章基于我自己的理解与验证整理而成( + GPT 指正),如有不当之处,欢迎指正。

update: 2025.12 - 最近看了下渡一关于这块的讲解,把里面的一些细节补充了进来。

好了,咱们就直接看看浏览器拿到 HTML 文件后究竟干了什么。

HTML 字符解析

从字符到 DOM 树

网络进程(Network Process)下载完 HTML 文件后,会产生一个渲染任务,推给渲染进程(Renderer Process)的主线程(Main Thread)的消息队列,在事件循环机制的作用下,取出任务,开始解析 HTML。

整个流程大概是这样:

  1. 主线程读取 HTML 字符串;
  2. 按照自上而下的顺序进行词法分析(一会解释);
  3. 根据节点的层级关系生成对应的 DOM 节点;
  4. 最终形成一棵 DOM 树(Document Object Model Tree)。

冷知识:
DOM 节点在底层是 C++/Rust 实现的对象。JavaScript 访问 DOM 时,通过浏览器的 WebIDL/V8 Bindings 层生成 wrapper(包装对象),这个 wrapper 才是 JS 世界里的可操作对象。DOM wrapper 通常是惰性创建的。(知道这个意思就行了)

此时,DOM 树只是文档的结构信息,还没有任何样式。

举个例子吧,假设 HTML 文件是这样的:

<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <div class="container">
      <h1>Welcome</h1>
      <p>Let's learn how the DOM works.</p>
    </div>
  </body>
</html>

🌳 那么 DOM 树的伪代码表示:

Document
 └── html
      ├── head
      │     └── title
      │           └── "Hello World"
      └── body
            └── div(class="container")
                  ├── h1
                  │     └── "Welcome"
                  └── p
                        └── "Let's learn how the DOM works."

是不是一目了然~

词法分析

刚刚还提到了“词法分析”,什么是词法分析?

浏览器内部有一个叫做 HTML 解析器(HTML Parser) 的东西。 它会:

  1. 读取一小段 HTML 文本;
  2. 判断这部分内容的“类型”;
  3. 生成相应的 token 对象
  4. 把这些 token 交给 DOM 构建器(DOM Tree Builder) 去拼出树。

你可以把“词法分析”理解成:

🧩 “分词” + “打标签”的过程。

比如浏览器读一句话:

<div class="container">Hello</div>

就像语文老师在批改作文时做标注:

<(开始标签)
div(标签名)
class(属性名)
=
"container"(属性值)
>(标签结束符)
Hello(文本内容)
</div>(结束标签)

总结一句话:词法分析是浏览器把 HTML 源码从一串纯文本,分割成可以理解的最小语法单元(token)的过程。

这些 token 之后会被“语法分析器(Parser)”拼装成 DOM 树结构

同时生成 CSSOM 树

主线程在解析 DOM 的同时,会解析样式相关的内容,包括:

  • <link> 引用的外部样式;
  • <style> 标签内的样式;
  • DOM 节点上的行内样式;
  • 浏览器默认样式。

解析的结果会形成一棵 CSSOM(CSS Object Model)树,表示所有选择器与样式声明的关系。

用伪代码或树状结构来表示,大致是这样的:

StyleSheetList
├── CSSStyleSheet (样式表 1)
  ├── CSSRule (规则 1)
       ├── selectorText: "body"
       └── style:
            ├── color: black
            └── font-size: 16px
  
  ├── CSSRule (规则 2)
       ├── selectorText: ".container"
       └── style:
            ├── display: flex
            └── justify-content: center
  
  └── CSSRule (规则 3)
        ├── selectorText: "#app .title"
        └── style:
              ├── font-weight: bold
              └── color: red
├── CSSStyleSheet (样式表 2)

🔍 小技巧:如何查看 CSSOM?

虽然浏览器内部的 CSSOM 不直接暴露给开发者,但可以通过 JavaScript 的 document.styleSheets 对象窥探到一部分:

// 查看所有样式表
console.log(document.styleSheets);

// 查看第一个样式表的规则
console.log(document.styleSheets[0].cssRules);

// 动态修改样式
document.styleSheets[0].addRule("p", "color: green");

注意:

  • CSSOM 只是存储规则本身,此时还没算出哪个元素最终用什么样式。
  • 主线程在解析 HTML 构建 DOM 树的同时,也会解析 CSS 来构建 CSSOM 树。这两个过程是并行且相互独立的

解析过程中的阻塞与优化

不阻塞解析的:图片、预解析线程

当主线程在解析 HTML 时,还会有一个 预解析线程(Preload Scanner) 并行运行。

它会快速扫描 HTML,提前发现可并行加载的资源,比如 CSS、JS、图片等。
预解析线程不会构建 DOM 或 CSSOM,只是让网络进程先去下载,减少等待时间。

图片的加载也不会阻塞 HTML 解析,只会影响最终渲染显示。

阻塞解析的:<script>

HTML 规范规定,当解析器遇到 未标记为 asyncdefer<script> 时,必须:

  • 暂停 DOM 解析;
  • 执行脚本(带连接的要等到下载完成后才执行脚本);
  • 执行完后再继续解析 HTML。

这是因为 JS 代码可能会修改前面已构建的 DOM。

示例:

<body>
  <div>1</div>
  <script>
    debugger;
  </script>
  <div>2</div>
</body>

页面会先显示 “1”,然后因为脚本执行暂停,导致 “2” 暂时不会显示。

同样:

<body>
  <div>1</div>
  <script src="外部链接"></script>
  <div>2</div>
</body>

如果执行到 script 标签的时候,还没完成下载,那么同样也会阻塞解析。

至于为什么会显示 1 在页面上,后面会有解释。

CSS 是否阻塞解析?

CSS 下载不会阻塞 HTML 解析,但会阻塞渲染

也就是说,页面结构能先构建,但浏览器一定要等 CSSOM 构建完成后,才能与 DOM 树一起完成后续工作。

所以如果 <link> 的 CSS 文件太大或加载太慢,页面就会白屏很久。

样式计算(Style Computation)

当浏览器有了:

  • 一棵 DOM 树(元素结构)
  • 一棵 CSSOM 树(样式规则)

它就会进入样式计算阶段,算出每个元素的最终样式(也就是控制台里的 Computed 栏里看到的):

  1. 解析所有 CSS 规则(选择器 + 声明),在 CSSOM 里已经准备好了,直接用。

  2. 匹配选择器 & 计算优先级(specificity) 浏览器会遍历每个 DOM 节点,去匹配 CSSOM 中的所有选择器。
    当多个规则命中同一个元素时:

  • 先看优先级(specificity,也就是我们在编辑器里的样式选择器里看到的提示,例如 (0,1,1),是一个很重要的知识点,建议不了解的可以学习下)
  • 再看顺序(后写的覆盖前写的)
  1. 应用继承关系(Inheritance),某些属性(比如 colorfont-family)会自动从父节点继承。浏览器在这一阶段会判断是否需要继承。

  2. 用浏览器默认样式(User Agent Stylesheet),对于没被定义的属性,浏览器会从自己的默认样式表中拿值。比如 <a> 默认是蓝色有下划线,<div> 默认是 display: block

此阶段会把诸如 red 这样的相对值转换为绝对值(如 rgb(255,0,0))。

算完后,这些样式信息都会在每个 DOM 节点上以属性的形式保存起来。

布局阶段(Layout phase)

最终样式计算出来了,其实还差一些几何信息(高度,宽度、位置),这个时候就需要计算出一个布局树(Layout Tree)的东西。

大概过程如下:

  • 遍历每个 DOM 的节点;
  • 确定包含块(containing block);
  • 根据盒模型计算 margin、border、padding;
  • 把相对值(%、em)换算成绝对像素;

它只包含 可见的元素,并为每个节点附上计算好的样式。

举个例子:

<body>
  <div style="display:none;">Hidden</div>
  <p>Hello</p>
</body>

此时 Layout Tree 会长这样:

LayoutRoot
 └── LayoutBody
       └── LayoutParagraph (computedStyle: {...}, geometry:{...})

<div>display: none 掉了,所以它不会进入布局树

当你在 F12 中,选中一个元素,看 Computed 面板和 Box Model,显示的是这个元素在页面上的 最终几何信息(宽、高、margin、padding、border 等)以及上面提到过的最终样式信息。

F12 看到的可视化 DOM 不是 Layout Tree,你可以看到各种被 display: none 掉的元素(head、link 等),但是它们在 Layout Tree 中是不存在的。GPT 说这是 DOM 树的实时视图,它展示的是内存中的 DOM 对象结构,会随着 JS 的操作实时更新。

DOM 树和布局树不是一一对应的

除了上面说的 display 问题,还有其他说明:

  1. 伪元素:伪元素在 DOM 树不存在,但是在 Layout Tree 中存在。

  2. 内容必须在行盒中:例如一个<p>a</p>,在布局树中会是这样的:

p 块盒
 └── 匿名行盒
       └── a

匿名行盒不是任何 HTML 标签对应的元素类型,而是浏览器在构建布局树(Layout Tree)时临时生成的一种“没有对应 DOM 节点的行级盒子”。

  1. 行盒和块盒不能相邻:例如一个<p></p>a,在布局树中会是这样的:
p 块盒

匿名块盒
 └── 匿名行盒(内容必须在行盒中)
       └── a

这里的匿名块盒同理。


Layout Tree 的节点也是 C++ 实现的内部结构,但它没有任何 JS 暴露的包装对象,也不能通过 JavaScript 直接访问。

当布局阶段完成后,布局树中的每个节点就同时拥有了:

  1. 计算好的样式
  2. 计算好的几何信息

这棵树就是浏览器用来后续绘制和合成的最终蓝图。

分层(Compositing Layer)

浏览器会将布局树中的某些节点 提升为独立的合成层(Compositing Layer),类似于 Photoshop 的图层系统。

为什么要分层?
为了加速后续的绘制与合成。当某个图层的内容发生变化(如动画),浏览器只需重新光栅化该层,并由合成线程独立处理,无需重排或重绘其他层。

哪些情况会触发分层?

  • 用了 transform(比如移动、旋转)、opacity(透明度)、filter(滤镜)这些属性;
  • 显式告诉浏览器“我可能会变”:比如加了 will-change: transform;这就告诉浏览器我将来可能会改变 transform 属性,你看着要不要分层,浏览器会自己决定。
  • 是视频(<video>)、画布(<canvas>)、iframe 这类自带图层的元素;
  • 用了 position: fixedsticky 的定位;
  • 创建了新的“堆叠上下文”(比如有 z-index 的定位元素),并且和其他内容有重叠。

🔍 附加小知识:

  • 可在 Chrome DevTools 的 Layers 面板(需在 Rendering 面板中开启)查看页面的分层情况;
  • 使用 transform: translateZ(0)translate3d(0,0,0) 是过去常用的“强制硬件加速” hack,但现代浏览器已不推荐,优先用 will-change
  • 每个合成层都会占用额外的内存(存储位图),并增加合成阶段的开销。过度分层反而会导致性能下降,应避免滥用。

绘制(Paint)

主线程会为每个图层生成一份“画画说明书”——比如“这里画个红色方块,那里写一行文字”。
这份说明书叫 绘制记录(Paint Records) ,它不直接画图,只是告诉别人“该怎么画”,例如:

  • 第一条:“在 (100, 100) 位置画一个红色方块,宽度 100px,高度 100px”。
  • 第二条:“在 (200, 200) 位置写一行文字,内容为 'Hello World',字体为 16px 宋体,颜色为 #000000”。
  • ...

每一个图层的说明书叫做指令集

小知识:

  • 如果一个元素只是在动(比如用 transform 移动),但内容没变,那它甚至不需要重新生成“画画说明书”,直接跳过这一步!
  • canvas 其实就是用了浏览器内置的绘制。

分块(Tiling)

主线程将每个图层的指令集交给合成线程(Compositor Thread),主线程后面不参与了。

合成线程是属于浏览器的渲染进程(Render Process)。

合成线程会根据视口(viewport)的位置和缩放比例,计算出哪些层(Layers)很大,需要被切分成一块一块的小方格(通常是 256x256 或 512x512 像素)。

光栅化(Rasterization)

接着,把这些小块交给多个 光栅化线程(Raster Threads) ,它们通常跑在 GPU 进程里。会优先把视口内要显示的分块处理成像素图(位图) 。每个分块就和那些 jpg 图片一样,例如一个 8080 的图片,有 8080 的像素。

光栅化线程在 GPU 进程中。

🔍 补充:光栅化可以用 CPU 也可以用 GPU,取决于设备性能。低端手机可能用 CPU,高端机用 GPU 加速。

合成 (DrawQuad/Compositing)

所有小块都变成图片后,合成线程会根据它们的位置、大小、旋转、缩放等信息,生成一套“拼图指令”(叫 Draw Quads)。 有的也直接叫 Compositing。 然后,它把这些指令交给 GPU,GPU 把所有图层像拼图一样叠在一起,最终合成一帧完整的画面,显示到你的屏幕上。

其他重要知识点补充

重排(Reflow)与重绘(Repaint)

重排(Reflow,也叫回流)

  • 定义:当页面的布局(Layout)发生变化时,浏览器需要重新计算元素的几何信息(如位置、宽高),也就是要从布局阶段往后走,这个过程叫 重排

  • 触发条件(只要影响“布局”的操作):

    • 改变元素的 widthheightpaddingmarginborder
    • 添加/删除 DOM 节点;
    • 改变 font-size(可能影响行高和宽度);
    • 浏览器窗口大小改变;
    • 调用 offsetWidthclientHeight强制同步读取布局属性(会触发“刷新队列”)。

不过浏览器也是会自动做一些优化的,例如:

box.style.width = "200px";
box.style.height = "200px";
box.style.backgroundColor = "red";
console.log(box.offsetWidth);

浏览器的历程会是这样的:读第一行,哦,改元素属性啊,先记录在本子上;读第二行,也改元素属性,继续记录在本子上;读第三行,也改元素属性,继续记录在本子上;读到第四行,发现卧槽要读取布局属性了,必须重排了,这时候浏览器会先把前三条合并一起处理,只重排一次,然后生成一个渲染任务推入消息队列,但这个时候第四行代码的打印其实还是旧的内容,因为此时渲染任务排在它后面。

重绘(Repaint)

  • 定义:当元素的外观样式改变但不影响布局时,浏览器只从分层或者绘制阶段往后走,这个过程叫 重绘

  • 触发条件(只改“样子”,不改“位置/大小”):

    • 改变 colorbackground-colorvisibilitybox-shadow
    • 改变伪类样式(如 :hover);
    • 不包括 transformopacity(它们走合成,连重绘都跳过!)。

⚠️ 重绘不一定触发重排(比如只改颜色,布局没变)。

对比

它们的关系:包含 vs 独立

操作是否重排是否重绘说明
width✅ 是✅ 是布局变了 → 重排 → 必然重绘
color❌ 否✅ 是外观变但布局不变 → 只重绘
transform❌ 否❌ 否由合成线程处理,跳过重排和重绘
添加 DOM 节点✅ 是✅ 是布局结构变化 → 重排 → 重绘

结论

  • 重排 ⇒ 一定重绘
  • 重绘 ⇏ 一定重排

为什么大家说“尽量避免重排”?

  • 性能开销大:重排需要重新计算整个文档或部分子树的布局,复杂度高;
  • 连锁反应:一个元素重排,可能引发父元素、兄弟元素甚至整个页面重排;
  • 阻塞主线程:重排/重绘都在主线程执行,会和 JS 抢时间,导致卡顿。

📌 举例:
如果你在 1 秒内频繁修改一个元素的 left 值(用 JS 循环),浏览器会反复重排 → 卡成幻灯片。
但如果改 transform: translateX(),则由合成线程处理 → 60fps 丝滑。

如何优化?

  • 批量修改样式:用 class 一次性切换,而不是逐个改属性;
  • 避免强制同步布局:不要在循环中读取 offsetWidth 等;
  • 使用 transform/opacity 做动画:完全绕过重排重绘;
  • 离线操作 DOM:用 DocumentFragmentdisplay: none(此时修改不触发重排);
  • 利用 BFC 隔离布局影响

🌟 一句话总结关系:

重排是“重新算位置”,重绘是“重新画颜色”;
位置一变,颜色肯定要重画;但颜色变了,位置未必动。

而现代高性能动画的核心思路就是:能用合成(Compositing)解决的,就别碰重排和重绘!

补充

对于重绘和重排,浏览器非常智能,假设某个重排的影响范围很小,例如只影响到一个 layer,其他 layer 都没有受到影响,那么浏览器会只重排这一个 layer,其他 layer 都不会重排。

为什么 transform 动画特别流畅?

因为它只改“拼图指令”里的位置或角度,不需要重新画图、不需要主线程参与,全由合成线程搞定。哪怕你的 JS 正在疯狂计算,动画也不会卡!

它最极致的情况就是只走最后一步合成 (DrawQuad)。

滚动条也是只是在合成线程中处理的,不会阻塞主线程,所以你会发现有时候页面卡了,但是滚动条还可以继续滚动。

渲染可能会增量执行

浏览器渲染不是固定一次性全量生成。就拿前面脚本标签阻塞 HTML 解析的例子来试试就知道了:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        color: red;
      }
    </style>
  </head>

  <body>
    <div class="box">1</div>
    <script>
      debugger;
    </script>
    <div>2</div>
  </body>
</html>

运行下发现,虽然断点卡住了,但是页面上仍然正确的显示出了红色的 1,难道不需要等待整个页面的 HTML 解析,CSSOM 创建,Render Tree 创建...才能显示页面内容吗?

答案是在这种情况下是不需要的,当浏览器执行到 <script>debugger</script> 时:

  • DOM 节点 已经生成
  • Render Tree 节点 已经生成
  • Layout Tree 节点 对于 <div class="box">1</div> 已经生成(至少几何信息已经计算好)可以绘制
  • 所以你看到的 “1” 能显示,并且颜色是红色

那是不是只要解析到哪就 Paint 下?答案不是的。

浏览器 不会每解析一个节点就立即 Paint。通常会有以下机制:

  • 解析到一定量遇到 script/style 等阻塞点 时,才可能触发一次“增量渲染”(incremental rendering)。
  • 但很多浏览器(尤其移动端)会 延迟首次渲染,直到关键资源(如首屏 CSS)加载完成,避免“闪屏”。

如果一个按钮发生变化,背后会怎样?

情况 1:元素没有独立合成层(最常见)

button {
  background-color: blue;
}
button:hover {
  background-color: red;
}

流程

  1. Paint:重新绘制 button 的显示列表(display list)
  2. Rasterization:重新光栅化该元素的位图
  3. Compositing:重新合成整个页面(但浏览器很聪明,因为这种情况比较简单,所以只更新变化区域

这种情况下会经历 Paint → Rasterization → Compositing


情况 2:元素被提升为独立合成层

button {
  background-color: blue;
  /* 触发层提升的属性 */
  will-change: transform;
  /* 或者 */
  transform: translateZ(0);
}

流程

  1. Paint:重新绘制该层的显示列表
  2. Rasterization:重新光栅化该层
  3. Compositing:只重新合成这个特定图层

优势:不会影响其他图层的光栅化


情况 3:浏览器的进一步优化

某些情况下,浏览器可能会:

  • 跳过重新光栅化:如果只是颜色变化且使用了 GPU 加速,可能直接在 GPU 上修改纹理
  • 增量更新:只更新发生变化的像素区域

JS 执行与动画优化

如果主线程被 JS 长时间占用(比如死循环),页面的重排与重绘会被推迟,导致卡顿。

解决方式:

  • 使用 requestAnimationFrame() 让 JS 与渲染节奏同步;
  • 避免在动画中频繁读取和写入布局属性;
  • 尽量把动画放在合成线程执行,例如用 transformopacity

拥有独立合成层的元素(如 <video><canvas>)在 GPU 上单独绘制,不占用主线程,因此不会触发重排重绘。

defer 和 async 属性

<script defer src="..."></script>:

  • 加载:与 HTML 解析并行进行(异步加载)。
  • 执行:在 HTML 解析完成之后,DOMContentLoaded 事件触发之前执行。
  • 顺序:多个 defer 脚本会按照它们在页面中出现的顺序依次执行。
  • 结论:defer 脚本非常适合需要操作 DOM 的脚本,它保证了执行时 DOM 是可用的,且不会阻塞页面渲染。

<script async src="..."></script>`:

  • 加载:与 HTML 解析并行进行(异步加载)。
  • 执行:加载完毕后立即执行,此时 HTML 解析可能还未完成。
  • 顺序:多个 async 脚本的执行顺序是不确定的,取决于谁先加载完。
  • 结论:async 适合独立的脚本,比如统计代码或广告脚本,它们不依赖 DOM,也不被其他脚本依赖。

DOMContentLoaded vs window.onload

特性DOMContentLoadedwindow.onload
触发时机DOM 树(HTML 结构)解析完成。页面所有资源(DOM, CSS, JS, 图片, iframe 等)加载完成。
等待资源不等待样式表、图片、iframe 等。必须等待所有外部资源加载完成。
触发速度更早更晚

例子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Event Load Order</title>
    <script>
      // 1. 这里的脚本会立即执行,此时 body 还未解析
      console.log("1. Script in head is executing.");

      // 2. 注册 DOMContentLoaded 事件
      document.addEventListener("DOMContentLoaded", () => {
        console.log("3. DOM is fully loaded and parsed.");
      });

      // 3. 注册 window.onload 事件
      window.onload = () => {
        console.log("5. Everything (including images) is fully loaded.");
      };
    </script>
  </head>
  <body>
    <h1>Test Page</h1>
    <p>Check the console for the order of events.</p>

    <!-- 使用一个大图或一个不存在的图片来模拟加载延迟 -->
    <img src="https://via.placeholder.com/3000x3000.png" alt="A large image" />

    <script>
      // 4. 这里的脚本在 body 解析到这里时执行
      console.log("2. Script at the end of body is executing.");
    </script>
  </body>
</html>

控制台输出顺序:

  1. Script in head is executing.
  2. Script at the end of body is executing.
  3. DOM is fully loaded and parsed. (此时 HTML 已解析完,但图片可能还在加载)
  4. (等待图片加载…)
  5. Everything (including images) is fully loaded. (图片加载完成后触发)

如何优化加载与阻塞

  • <script> 使用 deferasync
  • <link> 资源合理拆分,剔除无用的 CSS,压缩 CSS 代码;
  • 将关键 CSS(Critical CSS)内联在 <head>;让浏览器尽早开始下载和解析 CSS,这样当遇到 JS 时,CSSOM 很可能已经准备好了,减少了等待时间。
  • 图片定义宽高;
  • 避免频繁修改样式,可一次性批量修改;
  • 使用 DocumentFragment 批量操作 DOM;
  • 利用 BFC 减少不必要的布局影响。

主线程阻塞示例

一个简单的阻塞函数:

function delay(duration = 1000) {
  const start = Date.now();
  while (Date.now() - start < duration) {}
}
delay(3000); // 阻塞 3 秒

执行时页面完全无响应,因为主线程被 JS 占满。

结语

浏览器从 HTML 到屏幕成像,是一个复杂但高效的流程。

理解这些原理不仅能帮我们优化性能,也能让我们写出更“懂浏览器”的代码。

唉呀妈呀,真不容易写完!

要是这篇帮你理清了渲染机制,别忘了点个赞~