引言
文档对象模型(DOM)是 Web 开发中最基础也最重要的概念之一。它将 XML 或 HTML 文档表示为一棵树形结构,为开发者提供了操作网页内容的标准化接口。理解 DOM 的树形解剖结构,掌握各种节点类型以及如何在树中穿行,是成为合格前端开发者的必经之路。本文将系统地介绍 DOM 树的基本结构、节点接口及其子类、各类节点的数据存储方式,以及在实际开发中常用的节点比较方法。
一、树形结构的基本概念
在深入 DOM 之前,我们需要先理解树这种数据结构的几个核心概念。一棵树由多个节点组成,节点之间形成严格的层级关系:每个节点有且只有一个父节点(根节点除外),并且可以拥有零个或多个子节点。
下面这个简单的对象结构可以直观地展示树的基本概念:
javascript
// 用对象模拟一棵简单的树结构
const tree = {
data: "根节点", // 根节点:没有父节点的节点
children: [
{
data: "叶子节点A", // 叶子节点:没有子节点的节点
children: []
},
{
data: "分支节点", // 拥有子节点的中间节点
children: [
{
data: "叶子节点B", // 与叶子节点C是兄弟节点(siblings)
children: []
},
{
data: "叶子节点C",
children: []
}
]
}
]
};
// 验证几个树形结构的规则
// 规则1:每个节点关联唯一的根节点
// 规则2:如果A是B的父节点,则B是A的子节点
// 规则3:树中不允许存在循环引用
树的遍历遵循前序深度优先原则,即先访问节点本身,然后按照顺序递归访问其每一个子节点。DOM 树中的节点顺序正是按照这种方式排列的。
二、Node 接口及其核心属性
DOM 中的所有节点都是实现了 Node 接口的对象。Node 接口封装了树形结构的通用操作,使得我们能够在文档树中进行导航。以下代码展示了 Node 接口中最常用的导航属性和方法:
html
<div id="parent">
<p id="first">第一段文字</p>
<p id="second">第二段文字</p>
<p id="third">第三段文字</p>
</div>
<script>
const parent = document.getElementById('parent');
const first = document.getElementById('first');
const second = document.getElementById('second');
const third = document.getElementById('third');
// parentNode: 获取父节点
console.log(first.parentNode === parent); // true
// childNodes: 获取所有子节点(包括文本节点)
console.log(parent.childNodes.length);
// 可能是7,因为标签之间的空白也会生成文本节点
// firstChild 和 lastChild: 获取第一个和最后一个子节点
console.log(parent.firstChild);
console.log(parent.lastChild);
// hasChildNodes(): 判断是否有子节点
console.log(parent.hasChildNodes()); // true
console.log(first.hasChildNodes()); // true(文本节点是子节点)
// getRootNode(): 获取根节点
console.log(first.getRootNode() === document); // true
// previousSibling 和 nextSibling: 获取相邻的兄弟节点
console.log(second.previousSibling);
console.log(second.nextSibling);
// contains(): 判断一个节点是否是另一个节点的后代
console.log(parent.contains(second)); // true
console.log(first.contains(parent)); // false
</script>
需要注意的是,childNodes 返回的是 NodeList 对象,其中不仅包含元素节点,还包含文本节点。HTML 源代码中的换行和缩进都会在 DOM 树中产生文本节点,这一点在实际开发中经常容易被忽视。
三、DOM 树的节点类型详解
一个完整的 HTML 文档在 DOM 中由多种不同类型的节点共同组成。以下是一个典型的 HTML 文档及其在 DOM 中的表示:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>DOM 树示例</title>
</head>
<body>
<h1>你好,世界!</h1>
<p>这是一个段落。</p>
</body>
</html>
javascript
// 查看 DOM 树的根节点------Document 节点
console.log(document.nodeType); // 9 (Node.DOCUMENT_NODE)
console.log(document.nodeName); // "#document"
// doctype: DocumentType 节点,代表文档类型声明
console.log(document.doctype);
console.log(document.doctype.nodeType); // 10 (Node.DOCUMENT_TYPE_NODE)
console.log(document.doctype.name); // "html"
// DocumentType 始终是叶子节点,没有子节点
// documentElement: 根元素节点,对于 HTML 文档通常是 <html> 元素
console.log(document.documentElement);
console.log(document.documentElement.nodeType); // 1 (Node.ELEMENT_NODE)
console.log(document.documentElement.tagName); // "HTML"
在这个结构中,Document 节点处于最顶层,代表整个文档。它有两个重要的子节点:DocumentType 节点(如果声明了 doctype)和根元素节点。DocumentType 永远是叶子节点,而 Element 节点则承载了文档的绝大部分内容。
四、各类节点的数据存储方式
不同类型的节点以不同的方式存储自身的数据。Node 接口定义了 nodeName、nodeValue 和 textContent 三个通用属性,但它们在不同节点类型上的表现各不相同。
Document 节点与 DocumentType 节点
javascript
// Document 节点的数据属性
console.log(document.nodeName); // "#document"
console.log(document.nodeValue); // null
console.log(document.textContent); // null
// Document 节点携带的文档元数据
console.log(document.URL); // 当前文档的完整 URL
console.log(document.documentURI); // 与 URL 相同
console.log(document.characterSet); // 字符编码,如 "UTF-8"
console.log(document.compatMode); // "CSS1Compat"(标准模式)或 "BackCompat"(怪异模式)
console.log(document.contentType); // "text/html"
// DocumentType 节点的数据属性
const doctype = document.doctype;
console.log(doctype.name); // "html"
console.log(doctype.publicId); // ""(HTML 文档通常为空字符串)
console.log(doctype.systemId); // ""(HTML 文档通常为空字符串)
Element 节点
Element 节点本身不存储文本数据,它的 nodeValue 始终为 null。textContent 属性会返回其所有文本后代节点在树顺序下串联起来的字符串:
html
<div id="container">
你好,
<span>世界</span>
!
</div>
<script>
const container = document.getElementById('container');
console.log(container.nodeValue); // null
console.log(container.textContent);
// "你好,世界!" ------ 所有文本节点的串联
console.log(container.tagName); // "DIV"(HTML 元素始终大写)
</script>
CharacterData 及其子类
Text、Comment、CDATASection 和 ProcessingInstruction 都继承自 CharacterData 接口,该接口的核心是 data 属性。以下示例展示了 Comment 和 Text 节点的数据:
html
<!-- 这是一条注释 -->
<div>这是一段文本内容</div>
<script>
// 遍历 body 的子节点,识别不同的 CharacterData 类型
const bodyChildren = document.body.childNodes;
bodyChildren.forEach(node => {
if (node.nodeType === Node.COMMENT_NODE) {
console.log('找到注释节点:', node.data); // " 这是一条注释 "
console.log('注释长度:', node.length);
}
if (node.nodeType === Node.TEXT_NODE) {
console.log('找到文本节点:', node.data);
}
});
// 获取元素内的文本节点
const div = document.querySelector('div');
const textNode = div.firstChild;
console.log(textNode.nodeName); // "#text"
console.log(textNode.nodeValue); // "这是一段文本内容"
console.log(textNode.data); // "这是一段文本内容"
console.log(textNode.length); // 9
// substringData 方法
console.log(textNode.substringData(0, 2)); // "这是"
</script>
Text 节点和 CDATASection 节点始终是叶子节点。Comment 节点虽然在树的遍历中存在,但它始终是叶子节点,不能包含子节点。
五、元素及其属性的操作
Element 节点的属性由 Attr 节点表示,这些节点存储在一个独立的命名节点映射(NamedNodeMap)中,并非直接作为元素的子节点存在。Attr 节点的 parentNode 始终为 null。
以下代码展示了多种操作元素属性的方式:
html
<p class="note highlight" id="intro" data-category="frontend">
这是一段带有属性的段落。
</p>
<script>
const paragraph = document.querySelector('p');
// 通过 attributes 属性访问 Attr 节点集合
console.log(paragraph.attributes.length); // 3
console.log(paragraph.attributes.item(0).name); // "class"
console.log(paragraph.attributes.item(0).value); // "note highlight"
// getNamedItem 方法
const idAttr = paragraph.attributes.getNamedItem('id');
console.log(idAttr.value); // "intro"
console.log(idAttr.ownerElement === paragraph); // true
// Element 接口提供的便捷方法
console.log(paragraph.getAttribute('class')); // "note highlight"
console.log(paragraph.getAttributeNode('id').value); // "intro"
console.log(paragraph.hasAttribute('data-category')); // true
console.log(paragraph.getAttributeNames()); // ["class", "id", "data-category"]
console.log(paragraph.hasAttributes()); // true
// 特殊属性 id 和 class 的快捷访问
console.log(paragraph.id); // "intro"
console.log(paragraph.className); // "note highlight"
// classList 属性返回 DOMTokenList 对象
console.log(paragraph.classList); // DOMTokenList
console.log(paragraph.classList.contains('note')); // true
console.log(paragraph.classList.length); // 2
</script>
通过 getAttribute 直接获取属性值是最常见的做法。当需要更精细地操作 class 属性时,classList 提供的 add、remove、toggle 等方法比操作 className 字符串更加强大和安全。
六、在元素树中高效导航
由于 Element 节点构成了文档结构的主干,DOM 提供了一套专门用于在元素树中导航的属性,可以跳过文本节点和注释节点:
html
<div id="grandparent">
<!-- 这条注释会被元素导航属性跳过 -->
<section id="parent-a">
<p>AAA</p>
<p>BBB</p>
</section>
一些游离的文本
<section id="parent-b">
<span>CCC</span>
</section>
</div>
<script>
const grandparent = document.getElementById('grandparent');
const parentA = document.getElementById('parent-a');
const parentB = document.getElementById('parent-b');
// parentElement: 获取父元素节点(跳过非元素父节点)
console.log(parentA.parentElement === grandparent); // true
// children: 只包含子元素节点,忽略文本和注释
console.log(grandparent.children.length); // 2
console.log(grandparent.childNodes.length); // 可能为 5 或更多
// firstElementChild 和 lastElementChild
console.log(grandparent.firstElementChild === parentA); // true
console.log(grandparent.lastElementChild === parentB); // true
// childElementCount: 子元素的数量
console.log(grandparent.childElementCount); // 2
// previousElementSibling 和 nextElementSibling
console.log(parentA.nextElementSibling === parentB); // true
console.log(parentB.previousElementSibling === parentA); // true
// 对比:包含所有节点类型的 sibling 属性
// nextSibling 可能会返回文本节点
console.log(parentA.nextSibling);
</script>
在实际开发中,优先使用带 Element 字样的导航属性可以避免因空白文本节点而导致的意外行为,代码的意图也更加清晰。
七、节点比较的三大利器
DOM 提供了三个用于比较节点的重要方法:isSameNode()、isEqualNode() 和 compareDocumentPosition()。它们分别在不同的层面上判断节点之间的关系。
isSameNode 与 isEqualNode
html
<div id="box-a">
<p>内容一</p>
</div>
<div id="box-b">
<p>内容一</p>
</div>
<script>
const boxA = document.getElementById('box-a');
const boxB = document.getElementById('box-b');
// isSameNode(): 判断是否为同一个对象(现已不推荐,直接用 === )
console.log(boxA.isSameNode(boxA)); // true
console.log(boxA.isSameNode(boxB)); // false
console.log(boxA === boxA); // true(等效写法)
// isEqualNode(): 结构性相等判断
// 需要类型相同、数据相同、子节点递归相等
console.log(boxA.isEqualNode(boxB)); // true(结构完全一致)
console.log(boxA.isEqualNode(boxA)); // true
// 修改属性后会打破相等性
boxB.setAttribute('data-x', '1');
console.log(boxA.isEqualNode(boxB)); // false
</script>
isEqualNode 执行的是深度比较,会递归检查所有子节点。对于比较大的 DOM 子树,这个操作可能会有一定的性能开销。
compareDocumentPosition 位掩码比较
compareDocumentPosition 方法返回一个位掩码,用于精确判断两个节点在树中的相对位置:
html
<div id="outer">
<div id="inner">
<span id="deep">深层节点</span>
</div>
<div id="sibling"></div>
</div>
<script>
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const deep = document.getElementById('deep');
const sibling = document.getElementById('sibling');
// a 是 b 的祖先
let result = outer.compareDocumentPosition(deep);
console.log(result);
// 10: Node.DOCUMENT_POSITION_CONTAINS (8) + Node.DOCUMENT_POSITION_PRECEDING (2)
console.log(!!(result & Node.DOCUMENT_POSITION_CONTAINS)); // true
// a 是 b 的后代
result = deep.compareDocumentPosition(outer);
console.log(result);
// 20: Node.DOCUMENT_POSITION_CONTAINED_BY (16) + Node.DOCUMENT_POSITION_FOLLOWING (4)
console.log(!!(result & Node.DOCUMENT_POSITION_CONTAINED_BY)); // true
// a 在 b 之前(树顺序)
result = inner.compareDocumentPosition(sibling);
console.log(result); // 4: Node.DOCUMENT_POSITION_FOLLOWING
console.log(!!(result & Node.DOCUMENT_POSITION_FOLLOWING)); // true
// 实用封装:判断 a 是否位于 b 之前
function isBefore(a, b) {
return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
}
console.log(isBefore(inner, sibling)); // true
console.log(isBefore(sibling, inner)); // false
</script>
位掩码的优势在于可以同时表示多种关系。通过使用按位与运算符,我们能够简洁地检测特定的位置关系,这是在使用 compareDocumentPosition 时的标准做法。
八、总结
DOM 树是 Web 开发中最基础的数据结构,深入理解它的解剖结构能够让我们在处理复杂交互时更加游刃有余。本文涵盖的核心知识点包括:
在树形结构中,每个节点最多有一个父节点,根节点是唯一没有父节点的节点,叶子节点则没有子节点。DOM 中所有节点都实现了 Node 接口,通过 parentNode、childNodes、firstChild、lastChild 等属性可以在树中进行基本导航。完整文档由 Document 节点作为根,包含可选的 DocumentType 节点和必须的根元素节点。不同类型的节点(Element、Text、Comment、Attr 等)以各自的方式存储数据,nodeName、nodeValue 和 textContent 的表现各不相同。Attr 节点独立于主树存在,存储在 NamedNodeMap 中,Element 接口提供了 getAttribute、setAttribute 等便捷方法操作属性。针对元素树的导航属性(children、parentElement、nextElementSibling 等)可以跳过文本和注释节点,让元素间的遍历更加直接。节点比较方面,isEqualNode 用于深度结构相等判断,而 compareDocumentPosition 则通过位掩码精确描述两个节点在树中的位置关系。
系统性地掌握这些概念,将为后续学习 DOM 的动态构建、事件处理和性能优化打下坚实的基础。
想要解锁更多HTML 核心标签实战、前端零基础入门干货、开发避坑全指南吗?
持续关注,后续将更新CSS 布局实战、JavaScript 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!