使用 JavaScript 从标题创建目录
冗长且没有导航的页面会让读者感到沮丧。他们漫无目的地滚动,迷失阅读位置,最终离开。动态目录通过将现有的标题结构自动转换为可点击的锚点链接来解决这个问题——无需任何库或框架。
本文将向你展示如何使用现代原生 JavaScript 构建一个目录、处理常见的边界情况、生成无障碍可访问的目录,以及在用户滚动时高亮显示当前章节(可选)。
核心要点
- 使用带作用域的选择器查询标题,避免误抓取页头、侧边栏或页脚中无关的标题。
- 通过将标题文本进行 slug 化处理,并使用计数器跟踪重复项,生成不冲突且 URL 友好的 ID。
- 将生成的列表包装在带有
aria-label的<nav>元素中,以创建一个无障碍可访问的导航地标。 - 使用
IntersectionObserver替代滚动事件监听器,高效地高亮当前章节。 - 通过一条 CSS 规则即可启用平滑滚动:
scroll-behavior: smooth。
DOM 标题导航的工作原理
核心思路非常直观:
- 从 DOM 中查询所有标题元素。
- 为每个标题生成安全的
id。 - 构建一组指向这些 ID 的锚点链接。
- 将列表注入到指定的容器中。
不需要用正则解析原始 HTML,不需要 jQuery,只需 querySelectorAll 和标准的 DOM 方法。
选择并准备标题
const headings = document.querySelectorAll('article h2, article h3, article h4');
将选择器作用域限定在内容区域内——article、main 或特定容器——这样你就不会意外地抓取到站点页头或侧边栏中的标题。
生成安全的锚点 ID
标题文本通常包含空格、特殊字符或标点符号,这会让 URL 片段难以阅读,或在选择器中使用时显得别扭。在使用之前,先对每个标题的 id 进行规范化处理:
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '') || 'section';
}
通过跟踪已使用的 slug 并附加计数器来处理重复项:
const usedIds = new Map();
function uniqueSlug(text) {
const base = slugify(text);
const count = usedIds.get(base) ?? 0;
usedIds.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
使用一个简单的判断跳过空标题或仅包含空白字符的标题:if (!heading.textContent.trim()) return;。
Discover how at OpenReplay.com.
构建 JavaScript 目录
分配好 ID 之后,构建 TOC 列表并将其注入到页面中:
function buildTOC(containerSelector, headingSelector) {
const container = document.querySelector(containerSelector);
const headings = document.querySelectorAll(headingSelector);
if (!container || !headings.length) return;
const nav = document.createElement('nav');
nav.setAttribute('aria-label', 'Table of contents');
const ol = document.createElement('ol');
headings.forEach(heading => {
const text = heading.textContent.trim();
if (!text) return;
const id = heading.id || uniqueSlug(text);
heading.id = id;
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${id}`;
a.textContent = text;
li.appendChild(a);
ol.appendChild(li);
});
nav.appendChild(ol);
container.appendChild(nav);
}
document.addEventListener('DOMContentLoaded', () => {
buildTOC('#toc', 'article h2, article h3');
});
使用 DOMContentLoaded 可确保在脚本运行之前标题已经存在。
无障碍可访问的目录生成
将列表包装在带有 aria-label="Table of contents" 的 <nav> 元素中,可以创建一个命名导航地标。屏幕阅读器会直接呈现这些地标,让用户无需逐个 Tab 浏览页面即可跳转到目录。
在内容中保持合理的标题层级——不要从 h2 直接跳到 h4。目录反映了文档的结构,所以标题层级的跳跃会让所有用户的导航体验都变得令人困惑。
使用 IntersectionObserver 高亮当前章节
要高亮显示当前可见的章节,请使用 IntersectionObserver API 而不是滚动事件监听器:
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.getAttribute('id');
const link = document.querySelector(`nav a[href="#${id}"]`);
if (link) link.classList.toggle('active', entry.isIntersecting);
});
}, { rootMargin: '0px 0px -80% 0px' });
document.querySelectorAll('article h2, article h3').forEach(h => observer.observe(h));
rootMargin 缩小了检测区域,从而只在标题接近视口顶部时才被标记为活动状态。这种方法比节流的滚动处理函数性能更好,并且对于大多数静态页面无需进行清理操作。所有现代浏览器都支持 IntersectionObserver。
平滑滚动
添加这一条 CSS 规则即可在整个页面启用平滑滚动行为,无需任何 JavaScript:
html {
scroll-behavior: smooth;
}
所有现代常青浏览器都支持 scroll-behavior 属性。
结语
一个可用的 JavaScript 目录需要四样东西:作用域内的标题选择、不冲突的 ID 生成、用于无障碍可访问 TOC 生成的语义化 <nav> 包装,以及 DOMContentLoaded 时机控制。IntersectionObserver 这一增强是可选的,但能用极少的代码带来有意义的用户体验提升。
这一模式适用于任何静态站点、文档页面或博客——无需构建步骤、无需依赖、无需框架。
常见问题
两者都可行,但客户端生成更简单,并能将逻辑集中在一处。对于 SEO 关键页面或 JavaScript 被禁用的情况,更推荐服务端渲染,因为锚点链接和标题 ID 会出现在初始 HTML 中。对于大多数博客和文档站点,使用 DOMContentLoaded 的客户端生成方式已经足够。
在生成新 id 之前先检查是否已有 id。如果某个标题已被手动指定了 id,则复用它,以便外部链接和书签继续可用。添加一个条件判断,比如 if heading.id 就使用现有值,否则调用 uniqueSlug。这样可以尊重作者的意图,避免破坏指向特定章节的入站链接。
滚动事件每秒会触发数十次,每次调用都会强制进行布局计算,这会损害性能,尤其是在移动设备上。IntersectionObserver 异步地观察可见性变化,只有在可见性实际发生变化时才触发。同时它还可以以声明方式配置阈值和边距,无需自己编写节流或防抖逻辑。
在迭代过程中跟踪当前的标题层级。当下一个标题更深时,在上一个 li 中创建嵌套的 ol。当下一个标题更浅时,沿父节点链向上回溯。使用按标题层级索引的列表元素栈可以保持逻辑清晰,并支持任意嵌套深度,无需对每一级进行硬编码。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.