Skip to content

DOM 介绍与树结构:理解网页的骨架

认识 DOM

打开任何一个网页,你看到的每一个标题、段落、图片、按钮,背后都有一套结构化的数据在支撑。浏览器在加载 HTML 文档时,会将这些标签转换成一个可以用 JavaScript 操作的对象集合——这就是 DOM(Document Object Model,文档对象模型)。

如果把网页比作一棵大树,HTML 标签就是树上的枝叶,而 DOM 则是这棵树的完整地图。有了这张地图,JavaScript 就能精确地找到任何一片"叶子",修改它的颜色、形状,甚至把它摘下来或者种上新的枝叶。

DOM 不是 JavaScript 语言的一部分,也不是 HTML 的一部分。它是由 W3C 制定的一套标准接口,让编程语言能够访问和操作 HTML 或 XML 文档的结构、样式和内容。浏览器按照这个标准实现了 DOM API,JavaScript 通过这些 API 来与页面进行交互。

DOM 的本质

DOM 把整个文档表示为一个由节点(Node)组成的树形结构。每个 HTML 元素、文本内容、甚至注释,都会成为这棵树上的一个节点。

来看一个简单的 HTML 文档:

html
<!DOCTYPE html>
<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <h1>Welcome</h1>
    <p>Hello, world!</p>
  </body>
</html>

浏览器解析这段代码后,会构建出这样的树形结构:

document
└── html
    ├── head
    │   └── title
    │       └── "My Page" (文本节点)
    └── body
        ├── h1
        │   └── "Welcome" (文本节点)
        └── p
            └── "Hello, world!" (文本节点)

在这棵树中,document 是根节点,代表整个文档。html 元素是 document 的子节点,同时也是 headbody 的父节点。headbody 互为兄弟节点。每个标签内的文字内容也是一个节点,叫做文本节点。

DOM 节点类型

DOM 定义了多种节点类型,每种类型都有对应的数字常量。在实际开发中,最常打交道的是以下几种:

元素节点(Element Node)

元素节点对应 HTML 标签,是最常见的节点类型。每个 HTML 标签都会创建一个元素节点。

javascript
const heading = document.querySelector("h1");
console.log(heading.nodeType); // 1
console.log(heading.nodeName); // "H1"
console.log(heading.nodeType === Node.ELEMENT_NODE); // true

元素节点的 nodeType 值为 1,nodeName 返回大写的标签名。

文本节点(Text Node)

文本节点包含元素内的文字内容。即使只是一个换行符或空格,也会被当作文本节点。

javascript
const paragraph = document.querySelector("p");
const textNode = paragraph.firstChild;

console.log(textNode.nodeType); // 3
console.log(textNode.nodeName); // "#text"
console.log(textNode.nodeValue); // "Hello, world!"

文本节点的 nodeType 值为 3。要获取或修改文本内容,需要访问 nodeValuetextContent 属性。

注释节点(Comment Node)

HTML 中的注释也会成为 DOM 树的一部分:

html
<!-- This is a comment -->
<div id="app">Content</div>
javascript
const app = document.getElementById("app");
const commentNode = app.previousSibling.previousSibling; // 可能需要跳过空白文本节点

console.log(commentNode.nodeType); // 8
console.log(commentNode.nodeValue); // " This is a comment "

注释节点的 nodeType 值为 8。

文档节点(Document Node)

document 对象代表整个文档,是 DOM 树的根节点。

javascript
console.log(document.nodeType); // 9
console.log(document.nodeName); // "#document"

文档节点的 nodeType 值为 9,它是访问页面中任何元素的起点。

节点类型速查表

类型常量名nodeType 值nodeNamenodeValue
元素节点ELEMENT_NODE1标签名(大写)null
文本节点TEXT_NODE3#text文本内容
注释节点COMMENT_NODE8#comment注释内容
文档节点DOCUMENT_NODE9#documentnull
文档类型节点DOCUMENT_TYPE_NODE10文档类型名称null
文档片段节点DOCUMENT_FRAGMENT_NODE11#document-fragmentnull

节点之间的关系

DOM 树中的节点通过一系列属性相互关联,就像家谱中的亲属关系一样。

父子关系

每个节点(除了 document)都有一个父节点,可能有零到多个子节点。

javascript
const list = document.querySelector("ul");

// 获取父节点
console.log(list.parentNode); // 父元素
console.log(list.parentElement); // 父元素(必定是元素节点)

// 获取子节点
console.log(list.childNodes); // 所有子节点(包括文本节点)
console.log(list.children); // 仅元素子节点

// 第一个和最后一个子节点
console.log(list.firstChild); // 第一个子节点(可能是文本节点)
console.log(list.firstElementChild); // 第一个元素子节点
console.log(list.lastChild); // 最后一个子节点
console.log(list.lastElementChild); // 最后一个元素子节点

childNodes 返回一个 NodeList,包含所有类型的子节点,包括元素之间的空白文本节点。而 children 返回 HTMLCollection,只包含元素节点,在大多数情况下更实用。

兄弟关系

同一个父节点下的节点互为兄弟:

javascript
const currentItem = document.querySelector("#item-2");

// 获取相邻兄弟
console.log(currentItem.previousSibling); // 前一个兄弟节点(可能是文本节点)
console.log(currentItem.previousElementSibling); // 前一个元素兄弟
console.log(currentItem.nextSibling); // 后一个兄弟节点
console.log(currentItem.nextElementSibling); // 后一个元素兄弟

Element 的属性会跳过文本节点和注释节点,直接返回元素节点,更方便日常使用。

关系属性对比

属性返回所有节点类型仅返回元素节点
父节点parentNodeparentElement
子节点列表childNodeschildren
第一个子节点firstChildfirstElementChild
最后一个子节点lastChildlastElementChild
前一个兄弟previousSiblingpreviousElementSibling
后一个兄弟nextSiblingnextElementSibling

遍历 DOM 树

在实际开发中,经常需要遍历 DOM 树来查找或处理节点。下面介绍几种常用的遍历方法。

遍历子节点

javascript
const container = document.querySelector(".container");

// 方法一:使用 for 循环
for (let i = 0; i < container.children.length; i++) {
  console.log(container.children[i]);
}

// 方法二:使用 for...of 循环
for (const child of container.children) {
  console.log(child);
}

// 方法三:转换为数组后使用 forEach
Array.from(container.children).forEach((child) => {
  console.log(child);
});

// 方法四:使用展开运算符
[...container.children].forEach((child) => {
  console.log(child);
});

需要注意的是,childrenchildNodes 返回的都是"活的"集合(Live Collection),当 DOM 发生变化时,集合内容会自动更新。如果在遍历过程中修改 DOM,可能导致意料之外的结果。稳妥的做法是先将集合转换为静态数组。

递归遍历所有后代节点

当需要遍历某个元素的所有后代时,递归是一个有效的方法:

javascript
function walkDOM(node, callback) {
  callback(node);

  for (const child of node.children) {
    walkDOM(child, callback);
  }
}

// 使用示例:打印所有元素的标签名
const root = document.body;
walkDOM(root, (node) => {
  console.log(node.tagName);
});

这个函数从给定的节点开始,先对当前节点执行回调,然后递归处理每个子元素。

使用 TreeWalker

DOM 提供了 TreeWalker API,专门用于高效地遍历 DOM 树:

javascript
const walker = document.createTreeWalker(
  document.body, // 起始节点
  NodeFilter.SHOW_ELEMENT, // 只显示元素节点
  null, // 可选的过滤器函数
  false // 是否扩展实体引用(已废弃)
);

// 遍历所有元素
let currentNode;
while ((currentNode = walker.nextNode())) {
  console.log(currentNode.tagName);
}

NodeFilter 可以指定要遍历的节点类型:

  • NodeFilter.SHOW_ALL:所有节点
  • NodeFilter.SHOW_ELEMENT:元素节点
  • NodeFilter.SHOW_TEXT:文本节点
  • NodeFilter.SHOW_COMMENT:注释节点

还可以传入自定义过滤函数,更精细地控制遍历行为:

javascript
const walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_ELEMENT,
  {
    acceptNode(node) {
      // 只接受带有 data-interactive 属性的元素
      if (node.hasAttribute("data-interactive")) {
        return NodeFilter.FILTER_ACCEPT;
      }
      return NodeFilter.FILTER_SKIP;
    },
  }
);

document 对象

document 是 DOM 操作的入口点,它提供了许多有用的属性和方法。

常用属性

javascript
// 文档信息
console.log(document.title); // 页面标题
console.log(document.URL); // 当前 URL
console.log(document.domain); // 域名
console.log(document.referrer); // 来源页面

// 文档状态
console.log(document.readyState); // "loading" | "interactive" | "complete"

// 核心元素访问
console.log(document.documentElement); // <html> 元素
console.log(document.head); // <head> 元素
console.log(document.body); // <body> 元素

// 特殊集合
console.log(document.forms); // 所有表单
console.log(document.images); // 所有图片
console.log(document.links); // 所有带 href 的 <a> 和 <area>
console.log(document.scripts); // 所有脚本

文档就绪状态

在操作 DOM 之前,需要确保文档已经加载完成。document.readyState 有三个可能的值:

  1. loading:文档正在加载
  2. interactive:文档已解析完成,但资源(如图片)仍在加载
  3. complete:文档及所有资源都已加载完成
javascript
// 方法一:检查 readyState
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}

function init() {
  console.log("DOM 已就绪");
}

// 方法二:使用 DOMContentLoaded 事件
document.addEventListener("DOMContentLoaded", () => {
  console.log("DOM 内容已加载");
});

// 方法三:等待所有资源加载(包括图片、样式表等)
window.addEventListener("load", () => {
  console.log("页面完全加载");
});

DOMContentLoaded 在 HTML 解析完成后立即触发,不等待样式表、图片等资源。load 事件则要等所有资源都加载完毕。在大多数情况下,使用 DOMContentLoaded 就足够了,而且页面响应更快。

DOM 操作的性能考量

DOM 操作是前端开发中相对昂贵的操作。每次读取或修改 DOM,浏览器可能需要重新计算样式、布局,甚至重绘页面。

避免频繁访问 DOM

javascript
// ❌ 不好的做法:每次循环都访问 DOM
for (let i = 0; i < 1000; i++) {
  document.getElementById("counter").textContent = i;
}

// ✅ 更好的做法:缓存 DOM 引用
const counter = document.getElementById("counter");
for (let i = 0; i < 1000; i++) {
  counter.textContent = i;
}

使用文档片段进行批量操作

javascript
// ❌ 不好的做法:逐个添加元素
const list = document.getElementById("list");
for (let i = 0; i < 100; i++) {
  const item = document.createElement("li");
  item.textContent = `Item ${i}`;
  list.appendChild(item); // 每次都触发 DOM 更新
}

// ✅ 更好的做法:使用 DocumentFragment
const list = document.getElementById("list");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const item = document.createElement("li");
  item.textContent = `Item ${i}`;
  fragment.appendChild(item); // 不触发 DOM 更新
}
list.appendChild(fragment); // 只触发一次 DOM 更新

DocumentFragment 是一个轻量级的文档容器,它存在于内存中,不属于实际的 DOM 树。在 fragment 中进行的操作不会影响页面渲染,只有当整个 fragment 被插入到 DOM 时,才会触发一次更新。

NodeList 与 HTMLCollection

在操作 DOM 时,经常会遇到这两种集合类型,它们的行为略有不同。

HTMLCollection

HTMLCollection 是一个实时(live)的集合,会随着 DOM 变化自动更新:

javascript
const divs = document.getElementsByTagName("div");
console.log(divs.length); // 假设是 3

// 添加一个新的 div
document.body.appendChild(document.createElement("div"));
console.log(divs.length); // 自动变成 4

HTMLCollection 可以通过索引或 item() 方法访问元素,还可以通过 namedItem() 方法按 idname 属性查找:

javascript
const forms = document.forms;
const loginForm = forms.namedItem("login");
// 或者直接使用
const loginForm = forms["login"];

NodeList

NodeList 可能是实时的,也可能是静态的,取决于它是怎么获取的:

javascript
// 实时的 NodeList
const childNodes = document.body.childNodes;

// 静态的 NodeList
const queriedNodes = document.querySelectorAll("div");

querySelectorAll 返回的是静态快照,不会随 DOM 变化而更新。

将集合转换为数组

无论是 HTMLCollection 还是 NodeList,都不是真正的数组,不能直接使用数组方法(如 mapfilter)。需要先转换:

javascript
const divs = document.querySelectorAll("div");

// 方法一:Array.from()
const divsArray = Array.from(divs);

// 方法二:展开运算符
const divsArray = [...divs];

// 方法三:Array.prototype.slice.call()(兼容旧浏览器)
const divsArray = Array.prototype.slice.call(divs);

// 现在可以使用数组方法了
divsArray
  .filter((div) => div.classList.contains("active"))
  .forEach((div) => console.log(div));

实际应用示例

构建 DOM 树可视化工具

下面的代码可以将 DOM 树结构以可读的方式打印出来:

javascript
function visualizeDOM(element, indent = 0) {
  const padding = "  ".repeat(indent);
  let output = "";

  // 打印元素节点
  if (element.nodeType === Node.ELEMENT_NODE) {
    const tagName = element.tagName.toLowerCase();
    const id = element.id ? `#${element.id}` : "";
    const classes = element.className
      ? `.${element.className.split(" ").join(".")}`
      : "";

    output += `${padding}<${tagName}${id}${classes}>\n`;

    // 递归处理子节点
    for (const child of element.childNodes) {
      output += visualizeDOM(child, indent + 1);
    }

    output += `${padding}</${tagName}>\n`;
  }
  // 打印文本节点(非空白)
  else if (element.nodeType === Node.TEXT_NODE) {
    const text = element.textContent.trim();
    if (text) {
      output += `${padding}"${text}"\n`;
    }
  }
  // 打印注释节点
  else if (element.nodeType === Node.COMMENT_NODE) {
    output += `${padding}<!-- ${element.nodeValue.trim()} -->\n`;
  }

  return output;
}

// 使用示例
console.log(visualizeDOM(document.documentElement));

查找特定节点的路径

有时需要获取从根节点到某个元素的完整路径:

javascript
function getNodePath(element) {
  const path = [];
  let current = element;

  while (current && current !== document) {
    let selector = current.tagName.toLowerCase();

    if (current.id) {
      selector += `#${current.id}`;
    } else if (current.className) {
      selector += `.${current.className.split(" ").join(".")}`;
    }

    // 如果有同类型的兄弟,添加索引
    if (current.parentElement) {
      const siblings = current.parentElement.children;
      const sameTypeSiblings = [...siblings].filter(
        (s) => s.tagName === current.tagName
      );
      if (sameTypeSiblings.length > 1) {
        const index = sameTypeSiblings.indexOf(current) + 1;
        selector += `:nth-of-type(${index})`;
      }
    }

    path.unshift(selector);
    current = current.parentElement;
  }

  return path.join(" > ");
}

// 使用示例
const element = document.querySelector(".target");
console.log(getNodePath(element));
// 输出类似:"html > body > div#app > main.content > article:nth-of-type(2) > p.target"

总结

DOM 是 JavaScript 与网页内容交互的基础。通过理解 DOM 的树形结构和节点关系,你可以:

  1. 准确定位元素:使用父子、兄弟关系在 DOM 树中导航
  2. 高效遍历节点:根据需要选择合适的遍历方法
  3. 理解节点类型:区分元素节点、文本节点等不同类型
  4. 优化性能:减少不必要的 DOM 操作,使用文档片段等技巧

后续章节将详细介绍如何选择、创建和修改 DOM 元素,以及更多实用的 DOM 操作技巧。