1. 目录索引到底解决了什么问题:从一次“翻不到内容”的吐槽说起
这事得从我自己写博客的体验说起。早几年我做了一个技术博客,文章越写越长,好几篇都逼近五六千字,结果自己翻回去想改一段内容都得滚动半天。读者更直接,有朋友留言说“你文章里那个配置项在哪儿来着?翻了三分钟没找到”。那会儿我意识到一个事:长页面如果没有清晰的导航结构,内容质量再高也会被阅读成本拖垮。
目录索引功能,本质上就是给长文页面加一个“可点击、可定位、可感知当前位置”的导航骨架。它做的事情很朴素:把页面上各级标题抽取出来,生成一个树状目录,用户点击目录项就平滑滚动到对应章节;用户滚动页面时,目录里对应章节高亮,实时告诉读者“你现在读到哪了”。听起来简单,但真正做好、做稳、做到像大厂文档站点那样顺滑,里面有不少细节值得拆开讲。
这篇文章我会按我实际做的过程来讲:先聊需求边界和方案选型,再做核心实现,最后展开那些容易翻车的边界情况和排错过程。适合三类人看:给自己博客或团队文档站点加导航的前端开发者,刚接触前端工程化想练手的中级学习者,以及想评估“手写目录还是引插件”的维护者。不管你是哪种,看完都能直接落到代码里。
先说明白一点:我讲的“目录索引”,指的是页面内正文标题的自动抽取与导航定位,不是搜索引擎那个索引,更不是文件系统的目录服务。两者的核心差异在于场景:我们面对的是已经渲染好的HTML结构,需要的是“读取DOM-生成目录-联动滚动”这一条链路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:现成插件与手写实现的取舍逻辑
很多朋友第一反应是“这功能不有现成插件吗,干嘛自己写”。确实,市面上优秀的TOC(Table of Contents)组件不少,像GitHub上star很高的tocbot、mdBook和VuePress内置的目录组件、百度搜索里一抓一把的jQuery右侧目录插件。但我在对比之后,还是选择了手写。原因不是现成方案不好,而是要评估几个关键维度。
2.1 现成方案的三个典型局限
第一个局限是样式锁定和定制成本。大多数TOC插件自带一套样式,无论是胶囊形高亮、左侧竖线还是折叠箭头,想改成符合自己站点设计语言的样子,往往要写一堆覆盖样式,或者在插件配置项里纠结半天。tocbot算好的,提供了很多配置项和CSS变量,但复杂定制时依然受限于它内部的DOM结构和类名设计。
第二个局限是标题来源绑定。很多插件依赖markdown渲染引擎输出的目录数据,比如VuePress可以直接从markdown解析阶段拿到标题树,但这意味着你被绑死在特定框架或特定构建链路上。如果你的页面是从接口动态渲染的HTML,标题结构在后端模板里生成,那么这类插件很难直接接入。
第三个局限是滚动容器假设。大部分插件默认监听document滚动。实际项目里,正文常被放在一个overflow: auto的局部容器中,比如带固定顶栏和侧边栏的后台管理系统。插件假设改了,很容易出现目录高亮始终不触发或者点击滚动错位的怪问题。
2.2 手写前先想清楚:你要的能力清单
决定手写之前,我列了一个能力清单,确保实现时不至于漏项:
- 从正文容器中提取指定级别标题(比如h2和h3,或h1-h4),并保持文档顺序
- 为没有id的标题自动生成稳定且唯一的锚点
- 生成嵌套树状的目录数据,而不是扁平列表
- 渲染目录DOM,支持点击平滑滚动到目标标题,并处理固定顶栏的偏移量
- 滚动过程中正确高亮当前阅读章节
- 处理局部滚动容器,而不只支持window滚动
- 动态内容(比如前端路由切换、异步加载正文)后能重新扫描
- 目录本身较长时,目录区域内部也能滚动定位到当前高亮项
这个清单本身就是需求文档。你对照一下就会发现,很多插件败在第5和第6条上,而这两个恰恰是“用起来舒不舒服”的关键。
2.3 我的选型结论
我的结论是:如果项目是纯markdown博客,框架又自带目录能力,先用自带方案,省心;如果项目是动态页面、定制需求多、又要适配局部滚动容器,手写一个百来行的小模块反而比改插件更快。我现在手写的这套目录索引大概是200行左右(含样式),没有引入任何依赖,且可以封装成ES模块塞进任何工程。这里不是鼓吹“一切必须手写”,而是建议你对着上面的能力清单判断:插件的学习成本和定制成本加起来超过了手写成本,就别硬用插件。
3. 手写目录索引的核心实现:标题提取、锚点注入与滚动高亮
这一节是主菜。我会按数据流顺序拆开讲:先拿到标题,再生成锚点,再构建目录树,最后做滚动联动。代码用原生JavaScript,框架无关,你迁移到Vue或React里也就多一步生命周期处理。
3.1 标题提取:从DOM里捞内容骨架
第一步是从正文容器中拿到需要索引的标题节点。这里有一个关键设计:不要直接document.querySelectorAll('h1, h2, h3'),而是先确定一个正文容器,限定搜索范围。原因有两个:一是避免把侧边栏组件里的标题也抓进来;二是局部容器环境下,后续计算滚动位置需要依赖容器边界。
javascript复制function collectHeadings(container, selectors = ['h2', 'h3']) {
if (!container) return [];
const nodes = Array.from(container.querySelectorAll(selectors.join(',')));
return nodes;
}
这里还有个细节:同一篇文章里,h1一般是页面标题(大标题),正文内部通常从h2开始。所以我会把selectors设成['h2', 'h3', 'h4'],而不是从h1开始。具体选哪些级别,取决于你的页面结构,建议用配置项控制,不要写死。
标题节点的顺序很关键。querySelectorAll返回的本身就是文档顺序,这点不用额外排序,但要注意:如果你后面用Array.prototype.filter过滤掉隐藏标题或空标题,顺序依然保持,所以filter要放心用。
哪些标题应该被过滤掉?我总结了几种:
display: none或visibility: hidden的标题(比如折叠面板里的标题)textContent.trim()为空的标题- 已经被某个“目录容器”包含的标题(防止递归把目录自身的标题也抓进去,通常用容器隔离就可以规避)
3.2 锚点注入:让标题可以被定位
浏览器默认的锚点跳转依赖id,所以第二步是确保每个目标标题都有id。如果后端模板里已经写了id,直接用;如果没写,就自动生成。
javascript复制function ensureHeadingIds(headings) {
headings.forEach((heading, index) => {
if (!heading.id) {
const base = heading.textContent.trim()
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
heading.id = `${base}-${index}`;
}
});
}
这里有几个容易踩的坑,我逐个说明。
第一,中英文混排场景。\w是匹配不到中文的,所以我在正则里显式加了\u4e00-\u9fa5,这个范围覆盖常用汉字。如果你处理的是日文、韩文,需要追加对应的Unicode范围,或者干脆转用更
