☰
现代 JavaScript 教程:遍历嵌套 ul/li 树,统计每个节点的后代数量
2026/10/6 18:45:19 网站建设 项目流程
  • 文档/教程
  • 前端

【免费下载链接】en.javascript.info

Modern JavaScript Tutorial

项目地址:https://gitcode.com/gh_mirrors/en/en.javascript.info
点击查看免费下载

导读

本文围绕《Modern JavaScript Tutorial》仓库中"Count descendants(统计后代数量)"这一经典练习题展开,讲解如何用少量原生 DOM API 遍历任意深度的嵌套ul/li列表:既能取出每个<li>自身的标题文本(不含其子树内容),又能统计其下嵌套的所有<li>后代总数。读完本文你将掌握querySelectorAll、firstChild.data、getElementsByTagName的组合用法,并理解文本节点data属性与 DOM 遍历的核心原理,可直接复用于目录树、菜单折叠、分类计数等常见前端场景。

任务背景:要解决什么问题

在仓库的 任务描述文件 中,题目给出了一棵由多层嵌套ul/li构成的树形结构,要求编写代码,对每一个<li>输出:

  1. 它内部的文本(仅自身标题,不含子树的文字);
  2. 它嵌套的后代<li>数量(包含任意深度的所有后代)。

初始 HTML 结构与仓库 source.view/index.html 一致,是一个三层嵌套的动物分类树,例如:

<ul> <li>Animals <ul> <li>Mammals <ul> <li>Cows</li> <li>Donkeys</li> <li>Dogs</li> <li>Tigers</li> </ul> </li> <li>Other <ul> <li>Snakes</li> <li>Birds</li> <li>Lizards</li> </ul> </li> </ul> </li> <li>Fishes <!-- 同样嵌套 Aquarium / Sea 等分支 --> </li> </ul>

题目的难点在于:标题文本与子树文本混在同一个<li>的 DOM 子节点中,且树的深度不固定,必须用通用方法而非硬编码层级。

官方解答:三行核心代码

解答文档(solution.md)给出的思路非常精炼,完整可运行版本见 solution.view/index.html:

for (let li of document.querySelectorAll('li')) { // 1. 从第一个子节点(文本节点)读取标题 let title = li.firstChild.data; title = title.trim(); // 去掉首尾多余空白 // 2. 统计后代 <li> 数量(含任意深度的嵌套) let count = li.getElementsByTagName('li').length; alert(title + ': ' + count); }

运行结果依次为:

  • Animals: 7
  • Mammals: 4
  • Cows: 0
  • Donkeys: 0
  • Dogs: 0
  • Tigers: 0
  • Other: 3
  • Snakes: 0、Birds: 0、Lizards: 0
  • Fishes: 3
  • Aquarium: 2、Guppy: 0、Angelfish: 0
  • Sea: 1、Sea trout: 0

叶子节点(如Cows)没有后代,计数为 0;Animals直接与间接后代共 7 个。

第一步:querySelectorAll('li')收集全部节点

for (let li of document.querySelectorAll('li')) {

document.querySelectorAll('li')返回文档中所有<li>元素的静态 NodeList(包括任意嵌套深度的后代),结果可以直接用for...of遍历。这正是解决"深度不固定"问题的关键——不需要递归自己实现遍历,选择器天然支持任意层级。

与getElementsByTagName不同,querySelectorAll返回的是静态集合:在遍历期间即使 DOM 被修改,集合内容也不会变化,遍历是安全的。而getElementsByTagName('li')返回动态集合,会实时反映 DOM 变化,这在下文统计后代数量时会带来微妙的差异。

第二步:firstChild.data读取标题文本

let title = li.firstChild.data;

这是本解法的精华所在。对于一个形如下面的<li>:

<li>Animals <ul>...</ul> </li>

其 DOM 子节点序列为:文本节点"Animals\n"→ 元素节点<ul>。也就是说,第一个子节点恰好就是保存标题文字的文本节点,因此li.firstChild拿到的就是文本节点,而.data取出其中的字符串内容。

关于data属性,本仓库的配套章节 05-basic-dom-node-properties/article.md 有专门讲解:

  • data与nodeValue几乎等价,仅存在极细微的规范差异,日常使用更推荐更短的data;
  • innerHTML只对元素节点有效,而文本节点、注释节点等非元素节点要用data/nodeValue读取内容;
  • data既可以读也可以写,是可修改属性。

同样的技巧在仓库另一道题 3-tag-in-comment/solution.md 中也出现过:body.firstChild.data用于读取<body>后紧跟注释节点时的文本内容。可见"第一个子节点 +data"是从 DOM 树中取"裸文本"的通用手段。

另外,解答中的title.trim()很重要:HTML 源码中<li>Animals换行后才有<ul>,浏览器解析时文本节点会带上换行与缩进空白(如"Animals\n")。trim()去掉首尾空白后,标题才是干净的"Animals"。若不处理,alert输出会出现多余空行。

边界:firstChild不是文本节点时怎么办

如果<li>写成<li><a href="#">Animals</a> ...</li>,那么第一个子节点是<a>元素而非文本节点,firstChild.data就会得到undefined。此时应根据结构改用:

  • li.querySelector(':scope > a').textContent—— 取直接子链接的文字;
  • 或li.firstElementChild.textContent—— 取第一个元素子节点;
  • 或li.childNodes[0]并先通过nodeType === 3判断其确为文本节点。

nodeType的数值约定在 article.md 中有明确说明:元素节点为1、文本节点为3、document 为9。在结构不固定的场景,先检查li.firstChild.nodeType再取data是更稳妥的写法。

第三步:getElementsByTagName('li').length统计后代

let count = li.getElementsByTagName('li').length;

li.getElementsByTagName('li')以当前<li>为根,在其后代(不含自身)中查找所有<li>,返回一个动态 HTMLCollection;取.length即得到后代总数。由于它天然递归搜索所有层级,所以Animals一类的高层节点能一次性数出全部间接后代,无需自己写递归函数。

注意这里不能写成li.querySelectorAll('li')的等价替代吗?可以,两者都能数出后代数量,但有一个细节差异:getElementsByTagName返回动态集合,querySelectorAll返回静态集合。在本例中我们只是立刻读取.length,两种写法结果一致;但若在读取长度之前修改了 DOM(比如边遍历边删除节点),动态集合的.length会实时变化,可能造成计数与直觉不符。因此"读取后再操作"是安全习惯。

一个容易踩的坑:为什么标题里不含子树文字

有读者可能会想:li.textContent不也能拿到文字吗?是的,但它会连同所有后代文本一起拼接。例如对Animals这个<li>,textContent会返回"Animals\nMammals\nCows\nDonkeys..."一长串,根本无法分离出"仅自身标题"。

这正是本解法选择firstChild.data的原因:它只读第一个文本节点,天然把标题与子树隔离。对照 article.md 中textContent的定义——"元素内部的所有文本,去掉所有标签但保留文本"——textContent适合取"整块纯文本",data适合取"单个文本节点的内容",两者分工不同。

扩展实战:把计数直接写进 DOM

仓库中有一道进阶练习题 8-tree-count/solution.view/index.html,逻辑几乎同源,但输出方式从alert变成了直接改写 DOM,让计数即时显示在树中:

let lis = document.getElementsByTagName('li'); for (let li of lis) { let descendantsCount = li.getElementsByTagName('li').length; if (descendantsCount !== 0) { li.firstChild.data += ' [' + descendantsCount + ']'; } }

这里展示了两个延伸点:

  1. 字符串拼接写回:li.firstChild.data += ' [...]'直接修改文本节点内容,等价于data = data + ' [...]',是安全且高效的文本更新方式(不会像innerHTML+=那样整段重写并触发资源重载)。
  2. 动态集合的连带效应:外层用getElementsByTagName(动态),当内层循环修改了li的文本后,由于修改的是文本节点而非元素结构,集合本身不受影响,遍历仍正确。这也反过来印证了动态/静态集合差异的实际影响范围。

完整可运行示例

将 source.view/index.html 中的<script>// ... your code...</script>替换为下面代码,即可在浏览器中直接验证:

<script> for (let li of document.querySelectorAll('li')) { let title = li.firstChild.data.trim(); let count = li.getElementsByTagName('li').length; alert(title + ': ' + count); } </script>

也可以参考 solution.view/index.html 的完整成品进行对照调试。

小结:这道题教给我们的四件事

  1. 选择器天然支持任意层级:querySelectorAll('li')/getElementsByTagName('li')都是递归搜索,处理不定深度的树无需手写递归。
  2. 文本节点用data读:firstChild.data是提取"节点自身裸文本"的标准手法,配合trim()处理空白;前提是确认第一个子节点确实是文本节点(可用nodeType === 3校验)。
  3. textContent与data分工不同:前者取整块纯文本(含后代),后者取单个文本节点内容,选错会导致标题与子树文字混淆。
  4. 动态集合与静态集合的差异:getElementsByTagName动态、querySelectorAll静态;只读取length时两者等效,但边修改 DOM 边遍历时行为不同,需要根据场景选择。

这套"遍历 + 文本提取 + 后代计数"的组合拳,是后续实现折叠菜单、面包屑、目录自动编号、树形组件计数等功能的基础,值得反复练习直到形成条件反射。

延伸阅读:本章完整知识体系见 05-basic-dom-node-properties/article.md(nodeType、tagName/nodeName、innerHTML、outerHTML、data、textContent、hidden等节点属性全览);同组练习 2-lastchild-nodetype-inline/solution.md 揭示了"脚本执行时最后一个节点是<script>本身"的解析时序陷阱,值得一并阅读。

  • 文档/教程
  • 前端

【免费下载链接】en.javascript.info

Modern JavaScript Tutorial

项目地址:https://gitcode.com/gh_mirrors/en/en.javascript.info
点击查看免费下载
上一篇:别再凭感觉找线:NetBox 网络真相源快速上手
下一篇:Upscayl AI 图像放大:三步拿到第一张高清图

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询