6、HTML 解析器(Tokenizer):HTML 规范与解析算法、Tokenization 过程、构建 DOM 树、解析中的错误处理与容错机制

好,咱们今天来聊聊 WebKit 里一个特别有意思的模块——HTML 解析器。说实话,我当年刚接触浏览器内核时,觉得解析 HTML 不就是读标签嘛,能有多复杂?直到我真正去读 WebKit 源码,才发现这里面门道深得很。

你想想看,浏览器每天要处理多少“不干净”的 HTML?有些标签没闭合,有些属性写错了,有些甚至标签名都拼错了。如果解析器不够健壮,那网页基本就崩了。所以,HTML 解析器的核心任务就两个:一是把字节流变成 Token,二是把 Token 变成 DOM 树。中间还得处理各种“脏数据”。

核心观点:HTML 解析器不是简单的“读一行解析一行”,而是一个带有状态机的、容错性极强的流式解析引擎。

6.1 HTML 规范与解析算法

HTML 的解析规范和 XML 完全不同。XML 要求严格闭合,标签必须嵌套正确。但 HTML 呢?<p>hello<b>world</p> 这种代码,XML 解析器直接报错,但 HTML 解析器能正确处理。为什么?因为 HTML 规范里专门有一套容错算法

WebKit 的 HTML 解析器遵循的是 WHATWG HTML 规范。这套规范把解析过程分成了两大阶段:

  • Tokenization(词法分析):把字节流转换成一个个 Token。比如 <div> 变成一个开始标签 Token。
  • Tree Construction(树构建):把 Token 按照规范插入到 DOM 树中。这一步会维护一个“插入模式”状态机。

我个人习惯把解析器想象成一个“流水线”。上游是网络层源源不断送来的字节,下游是 DOM 树。解析器在中间做转换。而且这个流水线是流式的——不需要等整个 HTML 下载完才开始解析,收到一部分就解析一部分。这样可以大大提升首屏渲染速度。

小提示:WebKit 的解析器是“投机性”的。它不会等所有字节都到齐再解析,而是边接收边解析。如果后续字节导致之前的解析有误,它会回退调整。这种设计在性能上非常关键。

6.2 Tokenization 过程

Tokenization 说白了就是把 HTML 文本拆成一个个有意义的“词”。比如:

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

这段代码会被拆成以下 Token:

Token 类型 内容
StartTag div
Attribute class="container"
Character Hello
EndTag div

Tokenization 的核心是一个状态机。它维护一个当前状态,每读一个字符就根据当前状态决定下一步做什么。状态机大概有 80 多个状态,我挑几个关键的说说:

  • Data State:默认状态。遇到 < 就切换到 TagOpen 状态,遇到 & 就切换到 CharacterReference 状态。
  • TagOpen State:遇到 / 表示结束标签,遇到字母表示开始标签名。
  • AttributeName State:解析属性名,遇到 = 就准备读属性值。
  • AttributeValue State:解析属性值,支持单引号、双引号和无引号三种模式。

我记得有一次在项目中排查一个性能问题,发现某些页面在解析时特别慢。后来定位到是 Tokenizer 在处理大量 & 字符实体时频繁切换状态。优化方案是加了一个快速路径——如果当前字符不是 <&,就直接批量生成 Character Token,不用一个一个字符走状态机。这个优化让解析速度提升了 30%。

注意:Tokenization 是单线程的,而且不能阻塞。如果某个 Token 需要依赖后续字节才能确定(比如 &lt; 需要看后面是不是 ;),解析器会先“投机”处理,如果猜错了再回退。这种机制叫“speculative tokenization”。

6.3 构建 DOM 树

Token 生成后,下一步就是构建 DOM 树。这一步由 TreeBuilder 负责。TreeBuilder 维护了一个,叫“开放元素栈”(Open Element Stack)。栈顶元素就是当前正在解析的元素。

举个例子,解析 <div><p>text</p></div> 的过程:

  1. 遇到 <div> StartTag Token,创建一个 HTMLDivElement,压入栈。
  2. 遇到 <p> StartTag Token,创建一个 HTMLParagraphElement,压入栈。
  3. 遇到 text Character Token,创建一个文本节点,挂到栈顶元素(p)下。
  4. 遇到 </p> EndTag Token,弹出栈顶元素(p)。
  5. 遇到 </div> EndTag Token,弹出栈顶元素(div)。

这里有个关键点:插入模式。TreeBuilder 会根据当前上下文切换插入模式。比如在 <select> 内部,很多标签是被忽略的。在 <table> 内部,<form> 会被自动关闭。这些规则都在 HTML 规范里写得清清楚楚。

我曾经踩过一个坑:在 <table> 里直接写 <div>,结果发现 <div> 被自动移到了 <table> 前面。当时我以为是 bug,后来查规范才知道,这是 HTML 解析器的“foster parenting”机制——<div><table> 里是非法的,解析器会自动把它“寄养”到 <table> 的父元素下。

关键点:DOM 树的构建不是简单的“标签嵌套”,而是由一套复杂的插入模式控制的。这套模式保证了即使 HTML 写得乱七八糟,也能生成一棵“合理”的 DOM 树。

6.4 解析中的错误处理与容错机制

这部分是我觉得最精彩的地方。HTML 解析器的容错机制,说白了就是“你随便写,我尽量给你兜底”。

常见的错误类型和 WebKit 的处理方式:

错误类型 示例 WebKit 处理方式
标签未闭合 <p>text 遇到块级标签或父标签结束时自动闭合
标签交叉嵌套 <b><i>text</b></i> 自动调整嵌套顺序,变成 <b><i>text</i></b>
非法标签 <div><foo>text</div> 忽略 <foo>,只解析文本
属性值未加引号 <div class=container> 遇到空格或 > 时结束属性值
DOCTYPE 写错 <!DOCTYPE html5> 忽略错误,按标准模式处理

我曾经在项目中遇到一个极端案例:某个第三方广告脚本动态插入了一段 HTML,里面标签嵌套了 20 多层,而且很多标签没闭合。结果 WebKit 的解析器硬是把它解析成了一棵完整的 DOM 树,页面没有崩溃。我当时就在想,这要是换成 XML 解析器,页面早白屏了。

容错机制的核心逻辑在 TreeBuilder 的“adoption agency algorithm”(收养机构算法)里。这个算法专门处理标签交叉嵌套的情况。它的思路是:

  1. 找到当前栈中与结束标签匹配的开始标签。
  2. 如果这个开始标签不在栈顶,就把中间的元素“收养”到正确的位置。
  3. 重复这个过程,直到所有嵌套都合理。

嗯,这里要注意:这个算法最多执行 8 次。如果 8 次还没解决,就直接放弃,把结束标签当作错误处理。这是为了防止恶意构造的 HTML 导致死循环。

避坑指南:我曾经在写自定义组件时,动态生成 HTML 字符串,结果因为标签嵌套顺序写错了,导致 DOM 树结构异常。后来我养成了一个习惯:永远用 DOM API(如 document.createElement)来构建复杂结构,而不是拼接 HTML 字符串。这样可以绕过解析器的容错逻辑,避免意外行为。

6.5 知识体系总览

下面这张图展示了 HTML 解析器的整体流程和核心模块。我把它画成了 SVG,方便你理解各个部分的关系。

HTML 解析器核心流程 HTML 字节流 Tokenizer(词法分析器) 状态机:Data → TagOpen → AttributeName → ... Token 流 StartTag / EndTag / Character / Comment / ... TreeBuilder(树构建器) 开放元素栈 + 插入模式 + 收养机构算法 DOM 树 容错机制 • 标签未闭合自动补全 • 交叉嵌套自动调整 • 非法标签忽略 • 属性值无引号兼容 • Foster Parenting 寄养 • 收养机构算法 流式解析 边下载边解析 投机性 Tokenization 支持回退调整

从这张图你可以看到,整个流程是单向的:字节流 → Tokenizer → Token 流 → TreeBuilder → DOM 树。但实际实现中,Tokenizer 和 TreeBuilder 是协同工作的。TreeBuilder 在某些情况下会反过来影响 Tokenizer 的行为。比如在 <script> 标签内部,Tokenizer 会切换到“Script Data State”,不再解析 HTML 标签,直到遇到 </script>

好了,关于 HTML 解析器的核心内容就这些。记住一句话:HTML 解析器是浏览器里最“宽容”的模块,它用复杂的容错机制换来了 Web 的兼容性。下次你写 HTML 时,可以稍微“任性”一点,但别太任性——毕竟容错也是有代价的,性能损耗和意外行为都可能出现。