跳到内容

DOM

深入探讨文档对象模型。

简介

文档对象模型(通常缩写为 DOM)是解析 HTML 后生成的树形数据结构。它由 Node 的一个或多个子类的实例组成,并表示文档的树形结构。解析一个简单的 HTML,如下所示:

<!DOCTYPE html>
<html>
<body>hi</body>
</html>

将生成以下六个不同的 DOM 节点

请注意,HTMLHeadElement(即 <head>)是 WebKit 根据 HTML 解析器的指定方式隐式创建的。

广义上讲,DOM 节点分为以下几类:

Document 节点,顾名思义,表示一个 HTML、SVG、MathML 或其他 XML 文档,并且是文档中每个节点的所有者。它是任何文档中创建的第一个节点,也是最后被销毁的节点。

请注意,一个网页 (page) 可能包含多个文档,因为 iframeobject 元素可能包含子,并形成帧树。由于 JavaScript 可以在用户手势下打开新窗口访问其打开者,因此多个标签页中的多个网页可能能够通过诸如 postMessage 等 JavaScript API 进行通信。

节点的类型和状态标志

每个节点都有一组TypeFlag,它们在构造时设置且不可变;以及一组StateFlag,它们可以在Node的整个生命周期中设置或取消设置。节点还使用EventTargetFlag来指示与其他对象的所有权和关系。例如,当NodeElement的子类时,TypeFlag::IsElement会被设置。当Node处于其子节点正在被解析的状态时,StateFlag::IsParsingChildren会被设置。当Node连接时,EventTargetFlag::IsConnected会被设置。这些标志在Node的每个子类中,在其生命周期内都会更新。请注意,这些标志是在特定函数中设置或取消设置的。例如,EventTargetFlag::IsConnectedNode::insertedIntoAncestor中设置。这意味着在给定Node上运行Node::insertedIntoAncestor之前运行的任何代码都将观察到EventTargetFlag::IsConnected的过时值。

DOM 节点的插入和移除

为了构建一个 DOM 树,我们创建一个 DOM Node 并将其插入ContainerNode 中,例如 DocumentElement。节点的插入始于验证,然后(如果存在旧父节点)将节点从其旧父节点中移除。这两个步骤中的任何一个都可以通过变动事件同步执行 JavaScript,从而可以同步改变树的状态。因此,在进行插入之前,我们需要再次检查其有效性

DOM Node 的实际插入是使用 executeNodeInsertionWithScriptAssertionexecuteParserNodeInsertionIntoIsolatedTreeWithoutNotifyingParent 实现的。首先,这些函数实例化一个 RAII 风格的对象 ScriptDisallowedScope,它在其生命周期内禁止 JavaScript 执行,然后进行插入,并用 insertedIntoAncestor 通知子节点及其后代。请注意,当给定 Node 连接Document 时,或它被插入到断开连接的子树中时,可以调用 insertedIntoAncestor。在 insertedIntoAncestor 中,假定 this Node 始终连接Document 是不正确的。若要仅在 Node 连接到文档时运行代码,请检查 InsertionTypeconnectedToDocument 布尔值。此外,此 Node 的直接父节点不一定发生了变化。可能是此 Node 的祖先被插入到了新的父节点中。若要仅在此 Node 的直接父节点发生变化时运行代码,请检查节点的父节点是否与 parentOfInsertedTree 匹配。在某些情况下,代码必须在其 TreeScopeShadowRootDocument)发生变化时运行。在这种情况下,请检查 InsertionTypetreeScopeChanged 布尔值。在所有情况下,insertedIntoAncestor 调用的任何代码都绝不能尝试同步执行 JavaScript,例如通过分派事件。这样做将导致发布断言(即崩溃)。如果一个元素必须分派事件或以其他方式执行任意作者 JavaScript,则从 insertedIntoAncestor 返回 NeedsPostInsertionCallback。这将导致调用 didFinishInsertingNode,与 insertedIntoAncestor 不同的是,它允许脚本执行(它仅在 ScriptDisallowedScope 超出作用域后才被调用)。但请注意,在调用 insertedIntoAncestor 和调用 didFinishInsertingNode 之间,树的状态可能已被其他脚本修改,因此在 didFinishInsertingNode 中假定 insertedIntoAncestor 期间为真的任何树状态条件都是不安全的。在 insertedIntoAncestor 结束时将节点留在不一致的状态也是不安全的,因为 JavaScript 可能会在 insertedIntoAncestordidFinishInsertingNode 之间调用该节点上的任何 API。在调用 insertedIntoAncestor 之后,这些函数在新父节点上调用 childrenChanged。此函数是第一个有机会响应子节点插入而执行任何 JavaScript 的。例如,HTMLScriptElement 可能会在其 childrenChanged 中执行其脚本。最后,这些函数将对从 insertedIntoAncestor 返回 NeedsPostInsertionCallbackNode 调用 didFinishInsertingNode,并触发变动事件,例如 DOMNodeInsertedEvent

从父节点中移除 DOM Node 是通过 ContainerNode::removeAllChildrenWithScriptAssertionContainerNode::removeChildWithScriptAssertion 实现的。这些函数首先分派变动事件,并检查子节点的父节点是否仍然是同一个容器节点。如果不是,我们停止并提前退出。接下来,它们会断开要移除的子树中的任何子帧。然后,这些函数会实例化一个 RAII 风格的对象 ScriptDisallowedScope,它与插入操作对应,在其生命周期内禁止 JavaScript 执行,并通知 Document 节点已被移除,以便 NodeIteratorRange 等对象可以更新。然后,函数将执行移除操作,并通过 removedFromAncestor 通知子节点及其后代。请注意,当给定 Node 断开连接Document 的连接时,或者它从已断开连接的子树中移除时,可以调用 removedFromAncestor。在 removedFromAncestor 中,假定 this Node 曾经连接Document 是不正确的。若要仅在 Node 断开连接与文档的连接时运行代码,请检查 RemovalTypedisconnectedFromDocument 布尔值。此外,此 Node 的直接父节点不一定发生了变化。可能是此 Node 的祖先从其旧父节点中被移除。若要仅在此 Node 的直接父节点发生变化时运行代码,请检查节点的父节点是否为 nullptr。若要在其 TreeScopeShadowRootDocument)发生变化时运行代码,请检查 RemovalTypetreeScopeChanged 布尔值。在所有情况下,removedFromAncestor 调用的任何代码都绝不能尝试同步执行 JavaScript,例如通过分派事件。这样做将导致发布断言(即崩溃)。如果一个元素必须分派事件或以其他方式执行任意作者 JavaScript,请将任务排入队列以执行此操作。在调用 removedFromAncestor 之后,这些函数在旧父节点上调用 childrenChanged

此外,在 insertedIntoAncestorremovedFromAncestor 中,某些 StateFlagEventTargetFlag 可能会过时。例如,EventTargetFlag::IsConnected 标志直到调用 Node::insertedIntoAncestorNode::removedFromAncestor 后才被设置或取消设置。访问其他节点的状态和成员函数甚至更复杂。由于这些节点上可能尚未调用 insertedIntoAncestorremovedFromAncestor,因此像 getElementByIdrootNode 这样的函数将为这些节点返回错误的结果。在这些函数内部运行的代码必须小心地避免这些陷阱