Skip to content

DOM 节点遍历:精准导航网页结构

理解 DOM 遍历

在一个复杂的网页中,HTML 元素就像一个庞大家族的成员,它们通过父子、兄弟关系彼此连接。有时候,你需要从一个元素出发,找到它的父亲、孩子,或者兄弟姐妹。这个在 DOM 树中游走、查找节点的过程,就是 DOM 遍历。

掌握 DOM 遍历技术,就像拥有了一张精确的家谱图。无论你想找谁,都能快速定位,甚至可以系统地访问整个家族的每一个成员。这对于动态操作页面内容、实现复杂的交互效果至关重要。

基础导航:父子兄弟关系

DOM 提供了一系列属性,让我们可以根据节点之间的关系进行导航。这些属性分为两套:一套返回所有类型的节点,另一套只返回元素节点。

访问父节点

每个节点(除了根节点 document)都有父节点。通过 parentNodeparentElement 可以访问到它:

javascript
const paragraph = document.querySelector(".intro");

// 获取父节点(可能是任何类型的节点)
console.log(paragraph.parentNode);

// 获取父元素(一定是元素节点)
console.log(paragraph.parentElement);

大多数情况下,这两个属性返回相同的结果。但有一个例外:对于 document.documentElement(即 <html> 元素),它的 parentNodedocument,而 parentElementnull,因为 document 不是一个元素节点。

实际应用中,如果你只关心元素节点,用 parentElement 更明确:

javascript
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> 元素。这在需要根据上下文处理事件时特别有用。

访问子节点

获取子节点有两种主要方式:

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

// 方式一:获取所有子节点(包括文本节点、注释等)
console.log(container.childNodes); // NodeList

// 方式二:只获取元素子节点
console.log(container.children); // HTMLCollection

看一个具体的例子来理解它们的区别:

html
<div class="container">
  <h2>Title</h2>
  <p>Content</p>
</div>
javascript
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 更常用,因为我们通常只关心元素,而不是空白文本节点。

要访问第一个或最后一个子节点:

javascript
// 所有类型的节点
const firstChild = container.firstChild; // 可能是文本节点
const lastChild = container.lastChild;

// 只考虑元素节点
const firstElement = container.firstElementChild; // 第一个子元素
const lastElement = container.lastElementChild; // 最后一个子元素

实际应用:高亮第一个段落

javascript
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);

访问兄弟节点

兄弟节点是拥有相同父节点的节点:

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

// 获取前一个兄弟(所有节点类型)
console.log(currentItem.previousSibling);

// 获取前一个元素兄弟
console.log(currentItem.previousElementSibling);

// 获取后一个兄弟(所有节点类型)
console.log(currentItem.nextSibling);

// 获取后一个元素兄弟
console.log(currentItem.nextElementSibling);

同样,带 Element 的版本会跳过文本节点和注释,只返回元素节点。

实际应用:实现标签页切换

javascript
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));
});

导航属性对比总结

关系所有节点仅元素节点
父节点parentNodeparentElement
子节点集合childNodeschildren
第一个子节点firstChildfirstElementChild
最后一个子节点lastChildlastElementChild
前一个兄弟previousSiblingpreviousElementSibling
后一个兄弟nextSiblingnextElementSibling

经验法则:除非你有特殊需求(比如处理文本节点或注释),否则优先使用返回元素节点的版本,代码会更清晰。

遍历所有子节点

当你需要访问一个元素的所有子元素时,有多种遍历方法可选。

使用 for 循环

最直接的方式就是用传统的 for 循环:

javascript
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

javascript
for (const item of list.children) {
  console.log(item.textContent);
}

这种方式更简洁,而且不需要索引变量。

转换为数组使用数组方法

如果需要使用 mapfilter 等数组方法,可以先转换:

javascript
// 方法一: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"));

实际应用:批量处理列表项

javascript
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);
  }
});

注意"活的"集合

childrenchildNodes 返回的集合是"活的"(live),意味着当 DOM 改变时,集合会自动更新:

javascript
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,可能会产生意想不到的结果:

javascript
// ❌ 问题代码:无限循环
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 树

有时候,你需要访问一个元素的所有后代,而不仅仅是直接子元素。这时候递归是最自然的方法。

基础递归遍历

javascript
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)。它先访问一个节点,然后递归访问它的每个子节点。

带深度信息的遍历

有时需要知道节点的层级深度:

javascript
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>

条件遍历与搜索

可以在遍历过程中添加条件,提前终止搜索:

javascript
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); // 第一个匹配的元素

实际应用:收集所有外部链接

javascript
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。它提供了更强大、更灵活的遍历能力。

基础用法

javascript
const walker = document.createTreeWalker(
  document.body, // 起始节点
  NodeFilter.SHOW_ELEMENT, // 只显示元素节点
  null // 过滤器函数(可选)
);

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

createTreeWalker 的参数:

  1. root:遍历的起始节点
  2. whatToShow:要显示的节点类型
  3. filter:可选的过滤器函数

节点类型过滤

whatToShow 参数可以指定要访问的节点类型:

javascript
// 只显示元素节点
NodeFilter.SHOW_ELEMENT;

// 只显示文本节点
NodeFilter.SHOW_TEXT;

// 只显示注释节点
NodeFilter.SHOW_COMMENT;

// 显示所有节点
NodeFilter.SHOW_ALL;

// 组合多种类型(按位或)
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT;

示例:只遍历文本节点

javascript
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);
  }
}

自定义过滤器

可以传入一个过滤器函数,进一步控制遍历行为:

javascript
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:跳过此节点及其所有后代

实际应用:查找所有可见文本

javascript
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 还提供了其他导航方法:

javascript
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,但更简单:

javascript
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

两者的主要区别:

特性TreeWalkerNodeIterator
导航方法多种(父、子、兄弟、上下节点)仅前后节点
灵活性更灵活更简单
当前节点可修改 currentNode只读
使用场景需要复杂导航简单顺序遍历

一般来说,如果只需要顺序遍历,用 NodeIterator;如果需要在树中灵活移动,用 TreeWalker

实际应用场景

实现目录生成器

自动根据标题生成文章目录:

javascript
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;

表单验证辅助

遍历表单找出所有必填但未填写的字段:

javascript
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}`);
  });
}

文本高亮搜索

在页面中搜索并高亮显示关键词:

javascript
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 遍历可能成为性能瓶颈,特别是在大型文档中。

缓存查询结果

javascript
// ❌ 不好的做法:重复查询
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
  }
}

限制遍历深度

javascript
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 操作

javascript
// 遍历并修改元素时,使用 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 遍历是前端开发的基础技能。掌握这些技术,你可以:

  1. 灵活导航:使用父子兄弟关系属性在 DOM 树中精准定位
  2. 高效遍历:根据需求选择合适的遍历方法(递归、TreeWalker、NodeIterator)
  3. 条件筛选:使用过滤器只访问符合条件的节点
  4. 性能优化:缓存查询结果、限制遍历深度、减少 DOM 操作

选择合适的遍历方法取决于具体场景:

  • 简单导航:用父子兄弟属性
  • 顺序遍历子节点:用 for...of 或转数组
  • 递归遍历:用自定义递归函数
  • 复杂遍历:用 TreeWalkerNodeIterator

熟练运用这些技术,能让你在处理复杂 DOM 操作时游刃有余。