跟着 MDN 学 HTML day_26:(DOM 的树形结构与节点导航)

引言

文档对象模型(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 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!

相关推荐
YWamy1 小时前
音视频SDK:从定义演进到行业未来的全方位深度解析
音视频
2601_953465612 小时前
纯前端高性能!m3u8live.cn 重新定义 M3U8 在线播放与调试体验
开发语言·前端·javascript·m3u8
天若有情6732 小时前
从零搭建局域网手机遥控电脑网页项目,吃透工程化与架构设计思维
服务器·前端·数据库·算法·开源·node·工程化
H_unique2 小时前
Trae实现Web UI自动化测试
python·ui·ai编程·trae
weiabc2 小时前
整数最接近等因数分解函数(汇编优化版)
开发语言·前端·javascript
小满zs2 小时前
Next.js身份验证(better-auth)
前端·seo·next.js
华科大胡子2 小时前
HTML头部元信息避坑指南
html
孙高飞2 小时前
万字长文:如何用 harness 的理念设计一个 AI 驱动的 UI 自动化工程
人工智能·ui·自动化
IMPYLH2 小时前
Linux 的 truncate 命令
linux·运维·服务器·前端·bash