YSU
YSU 内核

树构建

把记号流组织成一棵 DOM 树。这一层是内核里最复杂的部分——WHATWG 规范里插入模式、格式化元素、表格寄养、收养机构这几节加起来有上百页,实现下来两千八百行。

把记号流组织成一棵 DOM 树。这一层是内核里最复杂的部分——WHATWG 规范里插入模式、格式化元素、表格寄养、收养机构这几节加起来有上百页,实现下来两千八百行。

入口

pub struct TreeBuilder<'a> { /* ... */ }

impl<'a> TreeBuilder<'a> {
    pub fn new(input: &'a str) -> Self;
    pub fn new_fragment(input: &'a str, context_name: &str, namespace: Namespace) -> Self;
    pub fn parse(mut self) -> Document;
    pub fn is_quirks_mode(&self) -> bool;
}

便利函数:

pub fn parse_document(input: &str) -> Document;
pub fn parse_fragment(input: &str, context_name: &str, namespace: Namespace) -> Document;

十九个插入模式

树构建是个状态机。当前模式决定一个记号该怎么处理——同样是 <td>,在 tr 里面和在段落里面含义完全不同。

enum InsertionMode {
    Initial,          // 处理最开始的 DOCTYPE 与注释
    BeforeHtml,       // 建立 html 之前
    BeforeHead,       // 建立 head 之前
    InHead,           // head 内部
    AfterHead,        // head 结束、body 开始之前
    InBody,           // body 内部,绝大部分内容在这里
    Text,             // 原始文本元素内部
    InTable,          // 表格内部
    InTableText,      // 表格里的非表格文本,攒起来判定
    InCaption,        // caption 内部
    InColumnGroup,    // colgroup 内部
    InTableBody,      // tbody / thead / tfoot 内部
    InRow,            // tr 内部
    InCell,           // td / th 内部
    InSelect,         // select 内部
    InSelectInTable,  // select 里出现了表格标签
    AfterBody,        // body 结束之后
    AfterAfterBody,   // 整个文档结束之后
}

Text 模式是词法层交给它的:进 <script><title> 时切模式,出结束标签时切回来。

活动格式化元素表

<b>1<p>2</b>3</p> 这类输入里,标签是交错闭合的。规范要求把它们修成正确嵌套的树,修的过程要用到一张「活动格式化元素表」:

enum FormattingEntry {
    Marker,                                    // 作用域分界标记
    Element { id: NodeId, name: String, attributes: Vec<Attribute> },
}

Marker 是分界:表格单元格会插入一个标记,之后重建格式化元素时不会跨过它。这条规则挡的是「表格外面的 <b> 被重建到表格里面」这类错。

Element 里存着建立时的属性,因为重建时要照着再建一个一模一样的。

收养机构算法是处理交错闭合的那段。它复杂到规范单独给它起了一个名字,处理的是「格式化元素的结束标签出现时,它在栈里的位置与当前节点之间的那一段怎么办」。

表格的寄养

在表格上下文里遇到不该出现在表格中的元素时(比如 <table><div>x</div></table>),规范要求把 div 插到表格前面,而不是里面。这个行为叫寄养。

struct PendingTableText {
    has_non_whitespace: bool,
    text: String,
}

表格里的文本还要单独攒一层。InTableText 模式先把连续文本收进 PendingTableText,等看清里面有没有非空白字符再决定:全是空白就当没事,有内容就触发寄养。

外来内容

SVG 与 MathML 的子树的处理规则:

// crates/ysu/src/html/foreign.rs
pub fn adjust_svg_tag_name(name: &str) -> &str;
pub fn adjust_svg_attributes(attributes: &mut [Attribute]);
pub fn adjust_mathml_attributes(attributes: &mut [Attribute]);
pub fn breaks_out(name: &str, attributes: &[Attribute]) -> bool;
pub fn breaks_out_end_tag(name: &str) -> bool;

标签名调整。 HTML 源码里的 foreignobject 要变成 foreignObject——SVG 的元素名是驼峰式的,但 HTML 词法会把属性名和标签名都转成小写。

属性调整。 attributename 变成 attributeNameviewbox 变成 viewBoxgradienttransform 变成 gradientTransform,等等。SVG 与 MathML 各有各的对照表。

跳出外来内容。 在 SVG 子树里遇到一个 HTML 标签(<p><div> 这类),要跳出 SVG 回到 HTML 内容。breaks_out 判的就是这件事。有一条例外:<font> 只有在带着 colorfacesize 属性之一时才跳出,不带就是 SVG 的 font 元素。

怪异模式

pub fn is_quirks_mode(&self) -> bool;

缺少 DOCTYPE、或者 DOCTYPE 写坏了时,文档进入怪异模式。规范里这个模式改变一堆东西的行为;在内核里它现在被记录下来备查。

片段解析

pub fn parse_fragment(input: &str, context_name: &str, namespace: Namespace) -> Document;

片段解析与整篇解析的区别只在开头:没有 html / head / body 那一套,起始插入模式由上下文元素决定,内容全部挂在一个新建的 html 元素下面。产物里那个 html 元素的子节点就是片段的内容——这是规范定的形状。

上下文元素决定两件事:起始插入模式,以及记号该怎么切。上下文是 table 时,一段 <tr> 才不会被当成无主的行丢掉;上下文是 title 时,内容按 RCDATA 读。

这个入口的用途是 innerHTML 那类场景。它还没有接到公开的 DOM 接口上,也没有纳入 html5lib 的片段用例比对。

验证

html5lib 的树构建用例是这一层唯一的规模化验证:tree-construction/ 下 57 个文件、1709 条用例,涵盖规范里几乎所有分支。

cargo test -p ysu --test html5lib_tree

#document-fragment 的 192 条现在单列成「尚未支持」,不参与断言。判据详见测试

单元测试

36 条,覆盖的是那些用一条用例就能说清楚的规则:隐式闭合(<p> 遇到块级元素自动闭合)、<form> 的嵌套禁止、表格结构与寄养、外来内容的标签名调整、怪异模式的判定。

两千八百行的代价

这一层的代码量在整个内核里排第二(第一是布局)。它长的原因是规范本身就长——插入模式有十九个,每个模式下每种记号都要有分支,加起来就是几百个分支。

想读它的话,从 process_token 那个总入口进去,看它怎么按当前模式分派,然后挑一个模式(InBody 最有代表性)看下去。

On this page