- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
导读
本文围绕《Modern JavaScript Tutorial》仓库中"Count descendants(统计后代数量)"这一经典练习题展开,讲解如何用少量原生 DOM API 遍历任意深度的嵌套ul/li列表:既能取出每个<li>自身的标题文本(不含其子树内容),又能统计其下嵌套的所有<li>后代总数。读完本文你将掌握querySelectorAll、firstChild.data、getElementsByTagName的组合用法,并理解文本节点data属性与 DOM 遍历的核心原理,可直接复用于目录树、菜单折叠、分类计数等常见前端场景。
任务背景:要解决什么问题
在仓库的 任务描述文件 中,题目给出了一棵由多层嵌套ul/li构成的树形结构,要求编写代码,对每一个<li>输出:
- 它内部的文本(仅自身标题,不含子树的文字);
- 它嵌套的后代
<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: 7Mammals: 4Cows: 0Donkeys: 0Dogs: 0Tigers: 0Other: 3Snakes: 0、Birds: 0、Lizards: 0Fishes: 3Aquarium: 2、Guppy: 0、Angelfish: 0Sea: 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 + ']'; } }这里展示了两个延伸点:
- 字符串拼接写回:
li.firstChild.data += ' [...]'直接修改文本节点内容,等价于data = data + ' [...]',是安全且高效的文本更新方式(不会像innerHTML+=那样整段重写并触发资源重载)。 - 动态集合的连带效应:外层用
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 的完整成品进行对照调试。
小结:这道题教给我们的四件事
- 选择器天然支持任意层级:
querySelectorAll('li')/getElementsByTagName('li')都是递归搜索,处理不定深度的树无需手写递归。 - 文本节点用
data读:firstChild.data是提取"节点自身裸文本"的标准手法,配合trim()处理空白;前提是确认第一个子节点确实是文本节点(可用nodeType === 3校验)。 textContent与data分工不同:前者取整块纯文本(含后代),后者取单个文本节点内容,选错会导致标题与子树文字混淆。- 动态集合与静态集合的差异:
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
相关推荐
Brackets 单元测试指南:基于 Jasmine 的 SpecRunner 测试体系与用例编写实践
Brackets 单元测试指南:基于 Jasmine 的 SpecRunner 测试体系与用例编写实践 本文以 test/README.md https://l
文档/教程前端现代JavaScript迭代器:遍历数据结构的终极指南
现代JavaScript迭代器:遍历数据结构的终极指南 现代JavaScript迭代器是遍历数据结构的强大工具,它提供了一种统一的方式来访问集合中的元素,无论集
MaLiang API完全指南:掌握iOS Metal绘图的核心类与最佳实践
MaLiang API完全指南:掌握iOS Metal绘图的核心类与最佳实践 MaLiang是一款基于Metal的iOS绘图库,为开发者提供了高效、灵活的涂鸦和
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考