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 文档:
<!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 的子节点,同时也是 head 和 body 的父节点。head 和 body 互为兄弟节点。每个标签内的文字内容也是一个节点,叫做文本节点。
DOM 节点类型
DOM 定义了多种节点类型,每种类型都有对应的数字常量。在实际开发中,最常打交道的是以下几种:
元素节点(Element Node)
元素节点对应 HTML 标签,是最常见的节点类型。每个 HTML 标签都会创建一个元素节点。
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)
文本节点包含元素内的文字内容。即使只是一个换行符或空格,也会被当作文本节点。
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。要获取或修改文本内容,需要访问 nodeValue 或 textContent 属性。
注释节点(Comment Node)
HTML 中的注释也会成为 DOM 树的一部分:
<!-- This is a comment -->
<div id="app">Content</div>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 树的根节点。
console.log(document.nodeType); // 9
console.log(document.nodeName); // "#document"文档节点的 nodeType 值为 9,它是访问页面中任何元素的起点。
节点类型速查表
| 类型 | 常量名 | nodeType 值 | nodeName | nodeValue |
|---|---|---|---|---|
| 元素节点 | ELEMENT_NODE | 1 | 标签名(大写) | null |
| 文本节点 | TEXT_NODE | 3 | #text | 文本内容 |
| 注释节点 | COMMENT_NODE | 8 | #comment | 注释内容 |
| 文档节点 | DOCUMENT_NODE | 9 | #document | null |
| 文档类型节点 | DOCUMENT_TYPE_NODE | 10 | 文档类型名称 | null |
| 文档片段节点 | DOCUMENT_FRAGMENT_NODE | 11 | #document-fragment | null |
节点之间的关系
DOM 树中的节点通过一系列属性相互关联,就像家谱中的亲属关系一样。
父子关系
每个节点(除了 document)都有一个父节点,可能有零到多个子节点。
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,只包含元素节点,在大多数情况下更实用。
兄弟关系
同一个父节点下的节点互为兄弟:
const currentItem = document.querySelector("#item-2");
// 获取相邻兄弟
console.log(currentItem.previousSibling); // 前一个兄弟节点(可能是文本节点)
console.log(currentItem.previousElementSibling); // 前一个元素兄弟
console.log(currentItem.nextSibling); // 后一个兄弟节点
console.log(currentItem.nextElementSibling); // 后一个元素兄弟带 Element 的属性会跳过文本节点和注释节点,直接返回元素节点,更方便日常使用。
关系属性对比
| 属性 | 返回所有节点类型 | 仅返回元素节点 |
|---|---|---|
| 父节点 | parentNode | parentElement |
| 子节点列表 | childNodes | children |
| 第一个子节点 | firstChild | firstElementChild |
| 最后一个子节点 | lastChild | lastElementChild |
| 前一个兄弟 | previousSibling | previousElementSibling |
| 后一个兄弟 | nextSibling | nextElementSibling |
遍历 DOM 树
在实际开发中,经常需要遍历 DOM 树来查找或处理节点。下面介绍几种常用的遍历方法。
遍历子节点
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);
});需要注意的是,children 和 childNodes 返回的都是"活的"集合(Live Collection),当 DOM 发生变化时,集合内容会自动更新。如果在遍历过程中修改 DOM,可能导致意料之外的结果。稳妥的做法是先将集合转换为静态数组。
递归遍历所有后代节点
当需要遍历某个元素的所有后代时,递归是一个有效的方法:
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 树:
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:注释节点
还可以传入自定义过滤函数,更精细地控制遍历行为:
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 操作的入口点,它提供了许多有用的属性和方法。
常用属性
// 文档信息
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 有三个可能的值:
- loading:文档正在加载
- interactive:文档已解析完成,但资源(如图片)仍在加载
- complete:文档及所有资源都已加载完成
// 方法一:检查 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
// ❌ 不好的做法:每次循环都访问 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;
}使用文档片段进行批量操作
// ❌ 不好的做法:逐个添加元素
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 变化自动更新:
const divs = document.getElementsByTagName("div");
console.log(divs.length); // 假设是 3
// 添加一个新的 div
document.body.appendChild(document.createElement("div"));
console.log(divs.length); // 自动变成 4HTMLCollection 可以通过索引或 item() 方法访问元素,还可以通过 namedItem() 方法按 id 或 name 属性查找:
const forms = document.forms;
const loginForm = forms.namedItem("login");
// 或者直接使用
const loginForm = forms["login"];NodeList
NodeList 可能是实时的,也可能是静态的,取决于它是怎么获取的:
// 实时的 NodeList
const childNodes = document.body.childNodes;
// 静态的 NodeList
const queriedNodes = document.querySelectorAll("div");querySelectorAll 返回的是静态快照,不会随 DOM 变化而更新。
将集合转换为数组
无论是 HTMLCollection 还是 NodeList,都不是真正的数组,不能直接使用数组方法(如 map、filter)。需要先转换:
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 树结构以可读的方式打印出来:
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));查找特定节点的路径
有时需要获取从根节点到某个元素的完整路径:
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 的树形结构和节点关系,你可以:
- 准确定位元素:使用父子、兄弟关系在 DOM 树中导航
- 高效遍历节点:根据需要选择合适的遍历方法
- 理解节点类型:区分元素节点、文本节点等不同类型
- 优化性能:减少不必要的 DOM 操作,使用文档片段等技巧
后续章节将详细介绍如何选择、创建和修改 DOM 元素,以及更多实用的 DOM 操作技巧。