DOM¶
深入探讨文档对象模型。
简介¶
文档对象模型(通常缩写为 DOM)是解析 HTML 后生成的树形数据结构。它由 Node 的一个或多个子类的实例组成,并表示文档的树形结构。解析一个简单的 HTML,如下所示:
<!DOCTYPE html>
<html>
<body>hi</body>
</html>
将生成以下六个不同的 DOM 节点
请注意,HTMLHeadElement(即 <head>
)是 WebKit 根据 HTML 解析器的指定方式隐式创建的。
广义上讲,DOM 节点分为以下几类:
- 容器节点,例如 Document、Element 和 DocumentFragment。
- 叶节点,例如 DocumentType、Text 和 Attr。
Document 节点,顾名思义,表示一个 HTML、SVG、MathML 或其他 XML 文档,并且是文档中每个节点的所有者。它是任何文档中创建的第一个节点,也是最后被销毁的节点。
请注意,一个网页 (page) 可能包含多个文档,因为 iframe 和 object 元素可能包含子帧,并形成帧树。由于 JavaScript 可以在用户手势下打开新窗口并访问其打开者,因此多个标签页中的多个网页可能能够通过诸如 postMessage 等 JavaScript API 进行通信。
节点的类型和状态标志¶
每个节点都有一组TypeFlag
,它们在构造时设置且不可变;以及一组StateFlag
,它们可以在Node
的整个生命周期中设置或取消设置。节点还使用EventTargetFlag
来指示与其他对象的所有权和关系。例如,当Node
是Element
的子类时,TypeFlag::IsElement
会被设置。当Node
处于其子节点正在被解析的状态时,StateFlag::IsParsingChildren
会被设置。当Node
连接时,EventTargetFlag::IsConnected
会被设置。这些标志在Node
的每个子类中,在其生命周期内都会更新。请注意,这些标志是在特定函数中设置或取消设置的。例如,EventTargetFlag::IsConnected
在Node::insertedIntoAncestor
中设置。这意味着在给定Node
上运行Node::insertedIntoAncestor
之前运行的任何代码都将观察到EventTargetFlag::IsConnected
的过时值。
DOM 节点的插入和移除¶
为了构建一个 DOM 树,我们创建一个 DOM Node
并将其插入到 ContainerNode
中,例如 Document
和 Element
。节点的插入始于验证,然后(如果存在旧父节点)将节点从其旧父节点中移除。这两个步骤中的任何一个都可以通过变动事件同步执行 JavaScript,从而可以同步改变树的状态。因此,在进行插入之前,我们需要再次检查其有效性。
DOM Node
的实际插入是使用 executeNodeInsertionWithScriptAssertion
或 executeParserNodeInsertionIntoIsolatedTreeWithoutNotifyingParent
实现的。首先,这些函数实例化一个 RAII 风格的对象 ScriptDisallowedScope
,它在其生命周期内禁止 JavaScript 执行,然后进行插入,并用 insertedIntoAncestor
通知子节点及其后代。请注意,当给定 Node
连接到 Document
时,或它被插入到断开连接的子树中时,可以调用 insertedIntoAncestor
。在 insertedIntoAncestor
中,假定 this
Node
始终连接到 Document
是不正确的。若要仅在 Node
连接到文档时运行代码,请检查 InsertionType
的 connectedToDocument
布尔值。此外,此 Node
的直接父节点不一定发生了变化。可能是此 Node
的祖先被插入到了新的父节点中。若要仅在此 Node
的直接父节点发生变化时运行代码,请检查节点的父节点是否与 parentOfInsertedTree
匹配。在某些情况下,代码必须在其 TreeScope
(ShadowRoot
或 Document
)发生变化时运行。在这种情况下,请检查 InsertionType
的 treeScopeChanged
布尔值。在所有情况下,insertedIntoAncestor
调用的任何代码都绝不能尝试同步执行 JavaScript,例如通过分派事件。这样做将导致发布断言(即崩溃)。如果一个元素必须分派事件或以其他方式执行任意作者 JavaScript,则从 insertedIntoAncestor
返回 NeedsPostInsertionCallback
。这将导致调用 didFinishInsertingNode
,与 insertedIntoAncestor
不同的是,它允许脚本执行(它仅在 ScriptDisallowedScope
超出作用域后才被调用)。但请注意,在调用 insertedIntoAncestor
和调用 didFinishInsertingNode
之间,树的状态可能已被其他脚本修改,因此在 didFinishInsertingNode
中假定 insertedIntoAncestor
期间为真的任何树状态条件都是不安全的。在 insertedIntoAncestor
结束时将节点留在不一致的状态也是不安全的,因为 JavaScript 可能会在 insertedIntoAncestor
和 didFinishInsertingNode
之间调用该节点上的任何 API。在调用 insertedIntoAncestor
之后,这些函数在新父节点上调用 childrenChanged
。此函数是第一个有机会响应子节点插入而执行任何 JavaScript 的。例如,HTMLScriptElement
可能会在其 childrenChanged
中执行其脚本。最后,这些函数将对从 insertedIntoAncestor
返回 NeedsPostInsertionCallback
的 Node
调用 didFinishInsertingNode
,并触发变动事件,例如 DOMNodeInsertedEvent
。
从父节点中移除 DOM Node
是通过 ContainerNode::removeAllChildrenWithScriptAssertion
和 ContainerNode::removeChildWithScriptAssertion
实现的。这些函数首先分派变动事件,并检查子节点的父节点是否仍然是同一个容器节点。如果不是,我们停止并提前退出。接下来,它们会断开要移除的子树中的任何子帧。然后,这些函数会实例化一个 RAII 风格的对象 ScriptDisallowedScope
,它与插入操作对应,在其生命周期内禁止 JavaScript 执行,并通知 Document
节点已被移除,以便 NodeIterator
和 Range
等对象可以更新。然后,函数将执行移除操作,并通过 removedFromAncestor
通知子节点及其后代。请注意,当给定 Node
断开连接与 Document
的连接时,或者它从已断开连接的子树中移除时,可以调用 removedFromAncestor
。在 removedFromAncestor
中,假定 this
Node
曾经连接到 Document
是不正确的。若要仅在 Node
断开连接与文档的连接时运行代码,请检查 RemovalType
的 disconnectedFromDocument
布尔值。此外,此 Node
的直接父节点不一定发生了变化。可能是此 Node
的祖先从其旧父节点中被移除。若要仅在此 Node
的直接父节点发生变化时运行代码,请检查节点的父节点是否为 nullptr
。若要在其 TreeScope
(ShadowRoot
或 Document
)发生变化时运行代码,请检查 RemovalType
的 treeScopeChanged
布尔值。在所有情况下,removedFromAncestor
调用的任何代码都绝不能尝试同步执行 JavaScript,例如通过分派事件。这样做将导致发布断言(即崩溃)。如果一个元素必须分派事件或以其他方式执行任意作者 JavaScript,请将任务排入队列以执行此操作。在调用 removedFromAncestor
之后,这些函数在旧父节点上调用 childrenChanged
。
此外,在 insertedIntoAncestor
和 removedFromAncestor
中,某些 StateFlag
和 EventTargetFlag
可能会过时。例如,EventTargetFlag::IsConnected
标志直到调用 Node::insertedIntoAncestor
或 Node::removedFromAncestor
后才被设置或取消设置。访问其他节点的状态和成员函数甚至更复杂。由于这些节点上可能尚未调用 insertedIntoAncestor
或 removedFromAncestor
,因此像 getElementById
和 rootNode
这样的函数将为这些节点返回错误的结果。在这些函数内部运行的代码必须小心地避免这些陷阱。