DOM 节点遍历:精准导航网页结构
理解 DOM 遍历
在一个复杂的网页中,HTML 元素就像一个庞大家族的成员,它们通过父子、兄弟关系彼此连接。有时候,你需要从一个元素出发,找到它的父亲、孩子,或者兄弟姐妹。这个在 DOM 树中游走、查找节点的过程,就是 DOM 遍历。
掌握 DOM 遍历技术,就像拥有了一张精确的家谱图。无论你想找谁,都能快速定位,甚至可以系统地访问整个家族的每一个成员。这对于动态操作页面内容、实现复杂的交互效果至关重要。
基础导航:父子兄弟关系
DOM 提供了一系列属性,让我们可以根据节点之间的关系进行导航。这些属性分为两套:一套返回所有类型的节点,另一套只返回元素节点。
访问父节点
每个节点(除了根节点 document)都有父节点。通过 parentNode 或 parentElement 可以访问到它:
const paragraph = document.querySelector(".intro");
// 获取父节点(可能是任何类型的节点)
console.log(paragraph.parentNode);
// 获取父元素(一定是元素节点)
console.log(paragraph.parentElement);大多数情况下,这两个属性返回相同的结果。但有一个例外:对于 document.documentElement(即 <html> 元素),它的 parentNode 是 document,而 parentElement 是 null,因为 document 不是一个元素节点。
实际应用中,如果你只关心元素节点,用 parentElement 更明确:
function findClosestSection(element) {
let current = element.parentElement;
while (current) {
if (current.tagName === "SECTION") {
return current;
}
current = current.parentElement;
}
return null;
}
// 使用示例:查找最近的 section 祖先
const button = document.querySelector(".submit-btn");
const section = findClosestSection(button);
console.log(section); // 最近的 <section> 元素这个函数向上遍历 DOM 树,直到找到一个 <section> 元素。这在需要根据上下文处理事件时特别有用。
访问子节点
获取子节点有两种主要方式:
const container = document.querySelector(".container");
// 方式一:获取所有子节点(包括文本节点、注释等)
console.log(container.childNodes); // NodeList
// 方式二:只获取元素子节点
console.log(container.children); // HTMLCollection看一个具体的例子来理解它们的区别:
<div class="container">
<h2>Title</h2>
<p>Content</p>
</div>const container = document.querySelector(".container");
console.log(container.childNodes);
// NodeList(5) [text, h2, text, p, text]
// 包含了换行符产生的文本节点
console.log(container.children);
// HTMLCollection(2) [h2, p]
// 只包含元素节点在实际开发中,children 更常用,因为我们通常只关心元素,而不是空白文本节点。
要访问第一个或最后一个子节点:
// 所有类型的节点
const firstChild = container.firstChild; // 可能是文本节点
const lastChild = container.lastChild;
// 只考虑元素节点
const firstElement = container.firstElementChild; // 第一个子元素
const lastElement = container.lastElementChild; // 最后一个子元素实际应用:高亮第一个段落
function highlightFirstParagraph(section) {
// 找到第一个 <p> 元素子节点
const firstPara = Array.from(section.children).find(
(child) => child.tagName === "P"
);
if (firstPara) {
firstPara.classList.add("highlight");
}
}
// 使用
const article = document.querySelector("article");
highlightFirstParagraph(article);访问兄弟节点
兄弟节点是拥有相同父节点的节点:
const currentItem = document.querySelector("#item-3");
// 获取前一个兄弟(所有节点类型)
console.log(currentItem.previousSibling);
// 获取前一个元素兄弟
console.log(currentItem.previousElementSibling);
// 获取后一个兄弟(所有节点类型)
console.log(currentItem.nextSibling);
// 获取后一个元素兄弟
console.log(currentItem.nextElementSibling);同样,带 Element 的版本会跳过文本节点和注释,只返回元素节点。
实际应用:实现标签页切换
function switchTab(clickedTab) {
// 移除所有兄弟标签的 active 类
const firstTab = clickedTab.parentElement.firstElementChild;
let tab = firstTab;
while (tab) {
tab.classList.remove("active");
tab = tab.nextElementSibling;
}
// 激活点击的标签
clickedTab.classList.add("active");
}
// 使用
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => switchTab(tab));
});导航属性对比总结
| 关系 | 所有节点 | 仅元素节点 |
|---|---|---|
| 父节点 | parentNode | parentElement |
| 子节点集合 | childNodes | children |
| 第一个子节点 | firstChild | firstElementChild |
| 最后一个子节点 | lastChild | lastElementChild |
| 前一个兄弟 | previousSibling | previousElementSibling |
| 后一个兄弟 | nextSibling | nextElementSibling |
经验法则:除非你有特殊需求(比如处理文本节点或注释),否则优先使用返回元素节点的版本,代码会更清晰。
遍历所有子节点
当你需要访问一个元素的所有子元素时,有多种遍历方法可选。
使用 for 循环
最直接的方式就是用传统的 for 循环:
const list = document.querySelector(".item-list");
for (let i = 0; i < list.children.length; i++) {
const item = list.children[i];
console.log(item.textContent);
}使用 for...of 循环
children 返回的 HTMLCollection 是可迭代对象,可以用 for...of:
for (const item of list.children) {
console.log(item.textContent);
}这种方式更简洁,而且不需要索引变量。
转换为数组使用数组方法
如果需要使用 map、filter 等数组方法,可以先转换:
// 方法一:Array.from()
const items = Array.from(list.children);
items.forEach((item) => {
console.log(item.textContent);
});
// 方法二:展开运算符
const items = [...list.children];
const activeItems = items.filter((item) => item.classList.contains("active"));实际应用:批量处理列表项
function processListItems(list, processor) {
Array.from(list.children).forEach((item, index) => {
processor(item, index);
});
}
// 使用:给每个列表项添加序号
const todoList = document.querySelector(".todo-list");
processListItems(todoList, (item, index) => {
const number = index + 1;
item.setAttribute("data-index", number);
// 如果还没有序号,添加一个
if (!item.querySelector(".item-number")) {
const numberSpan = document.createElement("span");
numberSpan.className = "item-number";
numberSpan.textContent = `${number}. `;
item.prependChild(numberSpan);
}
});注意"活的"集合
children 和 childNodes 返回的集合是"活的"(live),意味着当 DOM 改变时,集合会自动更新:
const container = document.querySelector(".container");
const children = container.children;
console.log(children.length); // 假设是 3
// 添加一个新元素
const newDiv = document.createElement("div");
container.appendChild(newDiv);
console.log(children.length); // 自动变成 4如果在遍历过程中修改 DOM,可能会产生意想不到的结果:
// ❌ 问题代码:无限循环
const container = document.querySelector(".container");
for (let i = 0; i < container.children.length; i++) {
// 每次添加元素,children.length 都会增加
container.appendChild(document.createElement("div"));
}
// ✅ 正确做法:先转换为静态数组
const container = document.querySelector(".container");
const childrenArray = Array.from(container.children);
for (let i = 0; i < childrenArray.length; i++) {
container.appendChild(document.createElement("div"));
}递归遍历 DOM 树
有时候,你需要访问一个元素的所有后代,而不仅仅是直接子元素。这时候递归是最自然的方法。
基础递归遍历
function traverseDOM(node, callback) {
// 先处理当前节点
callback(node);
// 再递归处理所有子节点
for (const child of node.children) {
traverseDOM(child, callback);
}
}
// 使用:打印所有元素的标签名
const root = document.body;
traverseDOM(root, (element) => {
console.log(element.tagName);
});这个函数采用深度优先遍历(Depth-First Search)。它先访问一个节点,然后递归访问它的每个子节点。
带深度信息的遍历
有时需要知道节点的层级深度:
function traverseWithDepth(node, callback, depth = 0) {
callback(node, depth);
for (const child of node.children) {
traverseWithDepth(child, callback, depth + 1);
}
}
// 使用:打印 DOM 树结构
traverseWithDepth(document.body, (element, depth) => {
const indent = " ".repeat(depth);
console.log(`${indent}<${element.tagName.toLowerCase()}>`);
});输出类似:
<body>
<header>
<h1>
<nav>
<ul>
<li>
<li>
<main>
<section>
<h2>
<p>条件遍历与搜索
可以在遍历过程中添加条件,提前终止搜索:
function findElement(root, predicate) {
if (predicate(root)) {
return root;
}
for (const child of root.children) {
const result = findElement(child, predicate);
if (result) {
return result;
}
}
return null;
}
// 使用:查找第一个带有特定属性的元素
const element = findElement(document.body, (el) => {
return el.hasAttribute("data-important");
});
console.log(element); // 第一个匹配的元素实际应用:收集所有外部链接
function collectExternalLinks(root) {
const externalLinks = [];
function traverse(node) {
if (node.tagName === "A" && node.href) {
// 检查是否是外部链接
const url = new URL(node.href);
if (url.hostname !== window.location.hostname) {
externalLinks.push({
url: node.href,
text: node.textContent.trim(),
element: node,
});
}
}
for (const child of node.children) {
traverse(child);
}
}
traverse(root);
return externalLinks;
}
// 使用
const links = collectExternalLinks(document.body);
console.log(`找到 ${links.length} 个外部链接`);
// 为所有外部链接添加图标
links.forEach(({ element }) => {
element.classList.add("external-link");
element.setAttribute("target", "_blank");
element.setAttribute("rel", "noopener noreferrer");
});TreeWalker API
对于复杂的遍历需求,DOM 提供了专门的 TreeWalker API。它提供了更强大、更灵活的遍历能力。
基础用法
const walker = document.createTreeWalker(
document.body, // 起始节点
NodeFilter.SHOW_ELEMENT, // 只显示元素节点
null // 过滤器函数(可选)
);
// 遍历所有节点
let currentNode = walker.currentNode;
while (currentNode) {
console.log(currentNode.tagName);
currentNode = walker.nextNode();
}createTreeWalker 的参数:
- root:遍历的起始节点
- whatToShow:要显示的节点类型
- filter:可选的过滤器函数
节点类型过滤
whatToShow 参数可以指定要访问的节点类型:
// 只显示元素节点
NodeFilter.SHOW_ELEMENT;
// 只显示文本节点
NodeFilter.SHOW_TEXT;
// 只显示注释节点
NodeFilter.SHOW_COMMENT;
// 显示所有节点
NodeFilter.SHOW_ALL;
// 组合多种类型(按位或)
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT;示例:只遍历文本节点
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let textNode;
while ((textNode = walker.nextNode())) {
const text = textNode.textContent.trim();
if (text) {
console.log(text);
}
}自定义过滤器
可以传入一个过滤器函数,进一步控制遍历行为:
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
// 只接受带有 data-searchable 属性的元素
if (node.hasAttribute("data-searchable")) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
},
}
);
// 遍历所有可搜索的元素
let node;
while ((node = walker.nextNode())) {
console.log(node);
}过滤器函数的返回值:
NodeFilter.FILTER_ACCEPT:接受此节点NodeFilter.FILTER_SKIP:跳过此节点(但会访问其子节点)NodeFilter.FILTER_REJECT:跳过此节点及其所有后代
实际应用:查找所有可见文本
function collectVisibleText(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
// 检查父元素是否可见
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const style = window.getComputedStyle(parent);
if (style.display === "none" || style.visibility === "hidden") {
return NodeFilter.FILTER_REJECT;
}
// 检查文本是否非空
const text = node.textContent.trim();
if (text.length === 0) {
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const texts = [];
let node;
while ((node = walker.nextNode())) {
texts.push(node.textContent.trim());
}
return texts.join(" ");
}
// 使用:提取页面可见文本
const visibleText = collectVisibleText(document.body);
console.log(visibleText);TreeWalker 的导航方法
除了 nextNode(),TreeWalker 还提供了其他导航方法:
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT
);
// 移动到下一个节点
walker.nextNode();
// 移动到上一个节点
walker.previousNode();
// 移动到第一个子节点
walker.firstChild();
// 移动到最后一个子节点
walker.lastChild();
// 移动到父节点
walker.parentNode();
// 移动到下一个兄弟节点
walker.nextSibling();
// 移动到上一个兄弟节点
walker.previousSibling();这些方法让 TreeWalker 成为一个灵活的"游标",可以在 DOM 树中自由移动。
NodeIterator API
NodeIterator 是另一个遍历 API,功能类似 TreeWalker,但更简单:
const iterator = document.createNodeIterator(
document.body,
NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
return node.classList.contains("highlight")
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
}
);
// 遍历所有高亮元素
let node;
while ((node = iterator.nextNode())) {
console.log(node);
}TreeWalker vs NodeIterator
两者的主要区别:
| 特性 | TreeWalker | NodeIterator |
|---|---|---|
| 导航方法 | 多种(父、子、兄弟、上下节点) | 仅前后节点 |
| 灵活性 | 更灵活 | 更简单 |
| 当前节点 | 可修改 currentNode | 只读 |
| 使用场景 | 需要复杂导航 | 简单顺序遍历 |
一般来说,如果只需要顺序遍历,用 NodeIterator;如果需要在树中灵活移动,用 TreeWalker。
实际应用场景
实现目录生成器
自动根据标题生成文章目录:
function generateTableOfContents(article) {
const headings = [];
const walker = document.createTreeWalker(article, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
return /^H[1-6]$/.test(node.tagName)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
});
let heading;
while ((heading = walker.nextNode())) {
const level = parseInt(heading.tagName[1]);
const text = heading.textContent;
const id = heading.id || text.toLowerCase().replace(/\s+/g, "-");
// 确保标题有 id
if (!heading.id) {
heading.id = id;
}
headings.push({ level, text, id });
}
return buildTOCHTML(headings);
}
function buildTOCHTML(headings) {
if (headings.length === 0) return "";
let html = '<nav class="toc"><ul>';
let currentLevel = headings[0].level;
headings.forEach(({ level, text, id }) => {
if (level > currentLevel) {
html += "<ul>".repeat(level - currentLevel);
} else if (level < currentLevel) {
html += "</ul>".repeat(currentLevel - level);
}
html += `<li><a href="#${id}">${text}</a></li>`;
currentLevel = level;
});
html += "</ul></nav>";
return html;
}
// 使用
const article = document.querySelector("article");
const toc = generateTableOfContents(article);
document.querySelector(".toc-container").innerHTML = toc;表单验证辅助
遍历表单找出所有必填但未填写的字段:
function findInvalidFields(form) {
const invalidFields = [];
function traverse(element) {
// 检查是否是必填字段
if (element.hasAttribute("required")) {
const value = element.value?.trim();
if (!value) {
invalidFields.push({
element,
name: element.name || element.id,
label: findLabel(element),
});
}
}
// 递归检查子元素
for (const child of element.children) {
traverse(child);
}
}
traverse(form);
return invalidFields;
}
function findLabel(input) {
// 尝试通过 for 属性查找 label
if (input.id) {
const label = document.querySelector(`label[for="${input.id}"]`);
if (label) return label.textContent.trim();
}
// 尝试查找父级 label
let current = input.parentElement;
while (current) {
if (current.tagName === "LABEL") {
return current.textContent.trim();
}
current = current.parentElement;
}
return input.name || input.id || "Unknown field";
}
// 使用
const form = document.querySelector("#signup-form");
const invalid = findInvalidFields(form);
if (invalid.length > 0) {
console.log("以下字段未填写:");
invalid.forEach(({ label }) => {
console.log(`- ${label}`);
});
}文本高亮搜索
在页面中搜索并高亮显示关键词:
function highlightText(root, searchTerm) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
// 跳过 script、style 等标签内的文本
const parent = node.parentElement;
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
// 检查文本是否包含搜索词
return node.textContent.toLowerCase().includes(searchTerm.toLowerCase())
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
});
const nodesToReplace = [];
let textNode;
// 先收集所有需要处理的节点
while ((textNode = walker.nextNode())) {
nodesToReplace.push(textNode);
}
// 替换文本并添加高亮
nodesToReplace.forEach((node) => {
const text = node.textContent;
const regex = new RegExp(`(${searchTerm})`, "gi");
const highlightedHTML = text.replace(regex, "<mark>$1</mark>");
// 创建临时容器
const temp = document.createElement("span");
temp.innerHTML = highlightedHTML;
// 替换原文本节点
node.parentElement.replaceChild(temp, node);
// 将 span 的内容移到父元素中
while (temp.firstChild) {
temp.parentElement.insertBefore(temp.firstChild, temp);
}
temp.remove();
});
}
// 使用
highlightText(document.body, "JavaScript");遍历性能优化
DOM 遍历可能成为性能瓶颈,特别是在大型文档中。
缓存查询结果
// ❌ 不好的做法:重复查询
function processItems() {
for (let i = 0; i < document.querySelectorAll(".item").length; i++) {
const item = document.querySelectorAll(".item")[i];
// 处理 item
}
}
// ✅ 更好的做法:缓存结果
function processItems() {
const items = document.querySelectorAll(".item");
for (let i = 0; i < items.length; i++) {
const item = items[i];
// 处理 item
}
}限制遍历深度
function traverseLimited(node, callback, maxDepth = 10, depth = 0) {
if (depth >= maxDepth) {
return; // 达到最大深度,停止遍历
}
callback(node, depth);
for (const child of node.children) {
traverseLimited(child, callback, maxDepth, depth + 1);
}
}使用 DocumentFragment 减少 DOM 操作
// 遍历并修改元素时,使用 fragment 减少重排
function batchUpdateItems(items, updateFn) {
const fragment = document.createDocumentFragment();
const parent = items[0].parentElement;
items.forEach((item) => {
updateFn(item);
fragment.appendChild(item);
});
parent.appendChild(fragment); // 一次性插入
}总结
DOM 遍历是前端开发的基础技能。掌握这些技术,你可以:
- 灵活导航:使用父子兄弟关系属性在 DOM 树中精准定位
- 高效遍历:根据需求选择合适的遍历方法(递归、TreeWalker、NodeIterator)
- 条件筛选:使用过滤器只访问符合条件的节点
- 性能优化:缓存查询结果、限制遍历深度、减少 DOM 操作
选择合适的遍历方法取决于具体场景:
- 简单导航:用父子兄弟属性
- 顺序遍历子节点:用
for...of或转数组 - 递归遍历:用自定义递归函数
- 复杂遍历:用
TreeWalker或NodeIterator
熟练运用这些技术,能让你在处理复杂 DOM 操作时游刃有余。