YSU
YSU 内核

DOM

内核里的文档树。用一个 arena 而不是指针树,这一条决定了它用起来的样子。

内核里的文档树。用一个 arena 而不是指针树,这一条决定了它用起来的样子。

为什么用 arena

//! 文档对象模型。
//!
//! 节点存在一张表里,彼此之间用 [`NodeId`] 互相引用而不是指针。这样树里
//! 不会出现引用计数成环,样式表与布局表也都能直接拿 `NodeId` 当键。

三个好处,都是实际的:

不会成环。 DOM 里父子互指是常规操作,用 Rc<RefCell<>> 的话每一对父子都是一个环,引用计数永远归不了零。用编号就不存在这个问题——编号是数字,不是所有权。

可以直接当键。 样式表是 HashMap<NodeId, ComputedStyle>,布局树里也存 NodeId。用指针的话得先分配一个稳定的地址,或者套一层 id。

借用检查好过。 想同时读父节点和子节点,在 arena 里就是两次 document.node(id),不涉及可变借用的冲突。

代价是删节点要手动维护索引,以及节点编号不能跨文档使用。

节点

pub struct NodeId(pub usize);

pub enum Namespace { Html, Svg, MathMl }

pub enum NodeData {
    /* 元素、文本、注释、文档 */
}

Namespace 是必须的——SVG 与 MathML 的元素名调整、属性调整、跳出规则都要看它。

每个节点提供一组判定与取值:

impl Node {
    pub fn as_element(&self) -> Option<&Element>;
    pub fn as_element_mut(&mut self) -> Option<&mut Element>;
    pub fn as_text(&self) -> Option<&str>;
    pub fn as_comment(&self) -> Option<&str>;
    pub fn tag_name(&self) -> Option<&str>;
    pub fn is_element(&self, name: &str) -> bool;
    pub fn has_attribute(&self, name: &str) -> bool;
}

元素与属性

impl Element {
    pub fn new(name: impl Into<String>) -> Self;
    pub fn get_attribute(&self, name: &str) -> Option<&str>;
    pub fn set_attribute(&mut self, name: &str, value: &str);
    pub fn remove_attribute(&mut self, name: &str);
    pub fn has_attribute(&self, name: &str) -> bool;
    pub fn has_token(&self, name: &str, token: &str) -> bool;
    pub fn id(&self) -> Option<&str>;
    pub fn tokens(&self, name: &str) -> Vec<&str>;
    pub fn is_html(&self, name: &str) -> bool;
}

两个方法值得单独说:

has_token 属性值是空格分隔的词表时用它判成员。class="note wide"has_token("class", "note") 判,而不是 get_attribute("class") == Some("note")——后者对多类名会漏。选择器匹配里的 .note 就是靠它。

元素上不存一份单独的 class 集合。分开存要维护同步,而且 class 属性是可以被 set_attribute 直接改的。

is_html 判断元素名在 HTML 命名空间下的匹配。外来内容里的同名元素要区别对待。

文档

impl Document {
    pub fn new() -> Self;
    pub fn root(&self) -> NodeId;
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;

    pub fn node(&self, id: NodeId) -> &Node;
    pub fn node_mut(&mut self, id: NodeId) -> &mut Node;
    pub fn element(&self, id: NodeId) -> Option<&Element>;
    pub fn element_mut(&mut self, id: NodeId) -> Option<&mut Element>;

    pub fn create(&mut self, data: NodeData) -> NodeId;
    pub fn create_element(&mut self, element: Element) -> NodeId;
    pub fn create_text(&mut self, text: impl Into<String>) -> NodeId;
    pub fn create_comment(&mut self, text: impl Into<String>) -> NodeId;

    pub fn append_child(&mut self, parent: NodeId, child: NodeId);
    pub fn insert_before(&mut self, parent: NodeId, child: NodeId, reference: NodeId);
    pub fn detach(&mut self, child: NodeId);
    pub fn reparent_children(&mut self, from: NodeId, to: NodeId);
}

reparent_children 是给收养机构算法用的——把一个节点的全部子节点搬到另一个节点下面。

遍历

impl Document {
    pub fn parent(&self, id: NodeId) -> Option<NodeId>;
    pub fn children(&self, id: NodeId) -> &[NodeId];
    pub fn child(&self, id: NodeId, index: usize) -> Option<NodeId>;
    pub fn last_child(&self, id: NodeId) -> Option<NodeId>;
    pub fn next_sibling(&self, id: NodeId) -> Option<NodeId>;
    pub fn previous_sibling(&self, id: NodeId) -> Option<NodeId>;
    pub fn first_element_child(&self, id: NodeId) -> Option<NodeId>;
    pub fn last_element_child(&self, id: NodeId) -> Option<NodeId>;

    pub fn descendants(&self, root: NodeId) -> Vec<NodeId>;
    pub fn descendants_of_kind(&self, root: NodeId, name: &str) -> Vec<NodeId>;
}

children 返回切片,descendants 返回 Vec。前者是零成本的,后者要分配——遍历热路径上用前者的那组。

注意「元素子节点」与「子节点」是分开的:first_element_child 会跳过文本节点,child 不会。DOM 里这两者在写代码时特别容易混,<div>\n <p>x</p>\n</div>firstChild 是一个换行文本节点。

查询

impl Document {
    pub fn find_element(&self, /* ... */) -> Option<NodeId>;
    pub fn find_elements(&self, root: NodeId, predicate: impl Fn(&Element) -> bool) -> Vec<NodeId>;
    pub fn text_content(&self, node: NodeId) -> String;
    pub fn title(&self) -> Option<String>;
}

没有 querySelector find_elements 接受一个谓词闭包,调用方自己写判定条件。这是有意的取舍:选择器匹配需要 ElementState(hover、visited 这些)和文档上下文,把它挂在 Document 上会让这两层耦合起来。

现在需要按选择器找元素的地方是样式层——cascade.rs 遍历整棵树,对每个元素跑一遍选择器匹配。它不需要 querySelector 这个接口。

title() 是从文档里取 <title> 的内容,外壳用它设窗口标题。

文本内容

pub fn text_content(&self, node: NodeId) -> String;

按文档顺序把子树里所有文本节点拼起来。注释不算,元素之间的空白算。

测试

14 条,覆盖创建、插入、移动、遍历的各个方向,以及 text_content 的拼接顺序。

树构建的 1709 条 html5lib 用例间接覆盖了 DOM 的构造路径——那些用例断言的就是树的形状,比对靠的是按 html5lib 格式把 DOM 打印成文本。

On this page