直接用 queryselectorall 遍历 h1–h6 不够用,因为真实 html 中标题常被语义容器包裹且受 display:none、aria-hidden 等影响,需递归判断可见性、可访问性与嵌套关系,并动态计算逻辑深度以准确映射层级。

为什么直接用 querySelectorAll 遍历 h1–h6 不够用
因为真实 HTML 文档里,标题可能被包裹在 section、article 或自定义 div 里,还可能有 display: none、aria-hidden="true" 或注释节点干扰;单纯按标签名提取会漏掉语义层级,也分不清哪些是“有效标题”。递归算法必须从根节点出发,逐层判断可见性、可访问性和嵌套关系。
实操建议:
- 跳过
style、script、noscript和注释节点(nodeType === 8) - 对每个元素检查
getComputedStyle(node).display !== 'none'且node.hasAttribute('aria-hidden') === false - 只把
h1–h6且满足上述条件的节点作为候选标题 - 记录其
node.compareDocumentPosition(parent)判断是否为后代,而非仅靠parentNode
如何用递归确定标题层级缩进关系
层级不是由标签名(h2 比 h3 高)决定的,而是由 DOM 树中最近公共祖先的深度决定。比如两个 h3 分属不同 section,它们应是同级而非嵌套。
实操建议:
- 给每个候选标题打上「逻辑深度」:初始为 0,每进入一个语义容器(如
section、article、nav)就 +1 - 遇到
h1–h6时,取它当前逻辑深度,并重置后续子树的基准深度为该标题级别(例如遇到h2就设基准为 2,后面h3算作 3 级,h1则触发回退到顶层) - 不用硬编码
h1=1, h2=2,而是用parseInt(tagName.slice(1))动态读取 - 缓存每个标题的
offsetTop和父容器getBoundingClientRect(),用于 fallback 判断视觉嵌套(当语义结构缺失时)
generateTocTree() 函数的关键参数设计
这个函数不能只接收一个 root 参数,否则无法控制生成粒度或过滤行为。常见错误是把所有 h2–h4 全塞进去,结果目录又长又散。
实操建议:
- 必选参数:
root: Element(起始容器,通常为main或article) - 可选参数:
minLevel: number = 2(默认从h2开始,避免h1占满首屏) - 可选参数:
maxLevel: number = 4(限制最深只到h4,防止琐碎条目) - 可选参数:
idGenerator: (text: string) => string = (t) => t.trim().toLowerCase().replace(/\s+/g, '-')(避免重复 ID 冲突) - 返回值应是扁平数组(含
level、text、id、element),而非嵌套对象——嵌套留到渲染时做,更灵活
生成锚点链接时 scrollIntoView 的兼容性坑
目录项点击后跳转,看似简单,但 Safari 对 scrollIntoView({ block: 'start' }) 支持滞后,iOS WebView 甚至忽略 behavior: 'smooth';更麻烦的是,如果标题前有 position: sticky 导航栏,直接跳转会遮挡。
实操建议:
- 不要依赖
element.scrollIntoView()默认行为,统一用element.scrollIntoView({ block: 'start', behavior: 'auto' }) - 跳转前先调用
element.scrollIntoView({ block: 'nearest' })测一下是否已在视口内,避免无谓滚动 - 计算偏移量:若存在
header[role="banner"],取其offsetHeight,然后window.scrollTo({ top: element.offsetTop - offset }) - 给目标
h2–h6加tabindex="-1",确保键盘用户也能聚焦
递归构建目录的本质,是把 DOM 树的物理嵌套和标题语义层级解耦再重映射。最容易被忽略的,是「何时终止递归」——不是看到 h6 就停,而是当当前节点的逻辑深度已超过 maxLevel,且其所有子元素都不再包含有效标题时才退出。
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!











