4. HTML解析与DOM构建

好,咱们今天聊聊渲染进程里最基础的一步——HTML解析。

很多人觉得这步很简单,不就是读标签吗?其实不然。我见过不少性能问题,根子就出在解析阶段。你想想看,浏览器拿到的是纯文本,要变成内存里一棵结构化的树,这中间的门道可不少。

4.1 HTML解析器:它不是正则匹配器

先纠正一个常见误区。有人以为HTML解析就是拿正则表达式去匹配标签。嗯,千万别这么干。HTML的容错性极强,语法又松散,正则根本搞不定。

真正的HTML解析器,是一个状态机。它逐字节读取数据,根据当前状态决定下一步动作。我在Chromium源码里看过,那个解析器有80多个状态,处理各种奇葩写法。

核心要点:HTML解析器是增量式的。它不需要等整个文档下载完才开始解析。边下载边解析,边解析边构建DOM树。这也是为什么浏览器能“流式”渲染页面。

4.2 词法分析:把字符流变成Token流

解析的第一步,是词法分析。说白了,就是把一堆字符拆成有意义的“词”。在HTML里,这些“词”就是Token。

Token有几种类型:

  • 开始标签:比如 <div>
  • 结束标签:比如 </div>
  • 自闭合标签:比如 <br />
  • 注释:比如 <!-- 注释 -->
  • 文本内容:标签之间的纯文字
  • DOCTYPE:文档类型声明

词法分析器怎么工作?它维护一个输入流指针,不断往前扫描。每遇到一个 < 字符,就进入“标签开始”状态。然后继续读,直到遇到 > 才结束。

举个例子,对于这段HTML:

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

词法分析器会吐出这些Token:

  1. StartTag: div(附带属性 class="container")
  2. Character: "Hello World"
  3. EndTag: div

我的经验:有一次排查页面白屏问题,发现是词法分析阶段卡住了。原因是一个标签属性值里包含了未转义的 > 字符。解析器以为标签结束了,结果后面全乱了。所以,属性值里的特殊字符一定要转义,这不是小事。

4.3 语法分析:从Token到DOM树

词法分析完了,得到一堆Token。接下来语法分析器登场,它要把这些Token组织成一棵树——DOM树。

语法分析的核心是栈结构。解析器维护一个“开放元素栈”,遇到开始标签就入栈,遇到结束标签就出栈。栈顶元素永远是当前正在解析的元素。

过程大致是这样:

  1. 遇到 StartTag: html,创建 html 节点,入栈。
  2. 遇到 StartTag: head,创建 head 节点,入栈。此时 head 成为 html 的子节点。
  3. 遇到 EndTag: head,head 出栈。栈顶回到 html。
  4. 遇到 StartTag: body,创建 body 节点,入栈。
  5. 遇到 Character: "Hello",创建文本节点,挂到 body 下。
  6. 遇到 EndTag: body,body 出栈。
  7. 遇到 EndTag: html,html 出栈。解析完成。

最终生成的DOM树:

Document
 └── html
      ├── head
      └── body
           └── "Hello"

注意:HTML的语法分析有容错机制。比如你忘了写 </p>,浏览器不会报错,它会自动帮你补上。但千万别依赖这个。我曾经遇到一个线上bug,就是因为某个页面少写了一个结束标签,Chrome和Safari的容错处理方式不同,导致布局错乱。嗯,血的教训。

4.4 特殊标签的处理:script与css

解析过程中,有两个特殊角色会影响流程:

标签类型 行为 影响
<script>(无async/defer) 遇到后暂停HTML解析,下载并执行JS 阻塞DOM构建
<script async> 异步下载,下载完成后立即执行 可能阻塞,执行顺序不确定
<script defer> 异步下载,HTML解析完成后执行 不阻塞
<link rel="stylesheet"> 下载CSS,不阻塞解析 但会阻塞后续脚本的执行

这里有个细节很多人不知道。CSS虽然不阻塞HTML解析,但它会阻塞脚本执行。为什么?因为脚本可能读取样式信息(比如 getComputedStyle),如果CSS没加载完,脚本拿到的值就是错的。所以浏览器做了这个权衡。

建议:把 <script> 放在 </body> 之前,或者使用 defer 属性。把CSS放在 <head> 里尽早加载。这是最基础的性能优化,但很多人就是记不住。

4.5 DOM树构建完成之后

当解析器处理完所有Token,开放元素栈清空,DOM树就构建完成了。此时会触发 DOMContentLoaded 事件。

注意,DOMContentLoaded 和 load 事件不同:

  • DOMContentLoaded:DOM树构建完成,但图片、样式表等资源可能还没加载完。
  • load:所有资源都加载完成了。

我个人习惯把初始化逻辑放在 DOMContentLoaded 里,而不是 load。这样用户能更快看到可交互的页面。

避坑指南:我曾经在 DOMContentLoaded 里操作了图片的尺寸,结果发现图片还没加载完,拿到的宽高是0。后来才意识到,图片的尺寸要在 load 事件里获取,或者用 ResizeObserver 监听。嗯,细节决定成败。

4.6 总结一下

HTML解析与DOM构建,说白了就是三步:

  1. 词法分析:字符流 → Token流
  2. 语法分析:Token流 → DOM树(用栈结构)
  3. 特殊处理:处理 script 和 style 的阻塞逻辑

整个过程是增量的、流式的。浏览器不会傻等,它一边下载一边解析一边渲染。理解了这个机制,你就能解释很多现象:为什么 script 放头部会白屏?为什么CSS要提前加载?为什么 DOMContentLoaded 比 load 早?

嗯,这些问题的答案,都在这一章里了。