目录为网站浏览者提供了页面内容的摘要,使他们可以通过点击所需的标题快速浏览页面的各个部分。通常情况下,目录是在文档和博客中实现的。
在本教程中,我们将学习如何创建一个粘性目录,它将动态地列出页面上的可用标题,突出显示活动的标题。当我们滚动浏览我们的文章时,当一个标题在屏幕上变得可见时,它将在TOC中被突出显示,如下面的GIF所示。

要继续学习本教程,你应该熟悉React和React Hooks。你还应该在你的系统上安装Node.js。本教程的完整代码可以在GitHub上找到。让我们开始吧!
设置React
在本教程中,我创建了一个初始版本,其中包括我们用来创建目录的代码。首先,我们需要克隆这个 repo。要做到这一点,在终端运行以下命令:
$ git clone -b starter https://github.com/Tammibriggs/table-of-content.git
$ cd table-of-content
$ npm install
当我们用$ npm start 命令启动应用程序时,我们应该看到以下页面:

创建一个TOC组件
让我们开始创建我们的TOC组件,它将是粘性的,将位于我们屏幕的右侧。
在我们之前克隆的应用程序中,在src 目录中创建一个TableOfContent.js 文件和一个tableOfContent.css 文件。在TableOfContent.js 文件中添加以下几行代码。
// src/TableOfContent.js
import './tableOfContent.css'
function TableOfContent() {
return (
<nav>
<ul>
<li>
<a href='#'>A heading</a>
</li>
</ul>
</nav>
)
}
export default TableOfContent
在上面的代码中,注意到我们正在将文本包裹在一个锚标签中<a></a> 。在我们的TOC中,我们将添加功能,以便当我们点击一个标题时,它将把我们带到页面上的相应部分。
通过在href 属性中传递我们想要导航的章节的ID,我们可以很容易地用锚标签做到这一点。因此,我们页面上的所有章节都必须包含一个ID,我已经在Content.js 文件中包含了这个ID。
接下来,在tableOfContent.css 文件中添加以下几行代码。
// src/tableOfContent.css
nav {
width: 220px;
min-width: 220px;
padding: 16px;
align-self: flex-start;
position: -webkit-sticky;
position: sticky;
top: 48px;
max-height: calc(100vh - 70px);
overflow: auto;
margin-top: 150px;
}
nav ul li {
margin-bottom: 15px;
}
现在,为了显示这个组件,前往App.js 文件,添加以下导入。
import TableOfContent from './TableOfContent';
接下来,修改App 组件,使其看起来像下面这样:
// src/App.js
function App() {
return (
<div className="wrapper">
<Content />
<TableOfContent />
</div>
);
}
有了上面的代码,我们将在我们的应用程序的右侧看到一个粘性组件。
找到页面上的标题
要找到我们页面上的所有标题,我们可以使用querySelectorAll document方法,它返回一个 [NodeList](https://developer.mozilla.org/en-US/docs/Web/API/NodeList)代表符合指定选择器组的元素列表。
下面的例子显示了我们将如何使用querySelectorAll 方法。
const headings = document.querySelectorAll(h2, h3, h4)
我们已经指定了h2,h3, 和h4 作为选择器,这些都是文章中可能使用的标题。我们不包括h1 ,因为它主要用于页面的标题,而我们希望我们的TOC只包含我们页面的子章节。
现在为了找到标题,在TableOfContent.js 文件中添加以下导入。
import { useEffect, useState } from 'react';
接下来,在该组件中,在返回语句前添加以下几行代码。
// src/TableOfContent.js
const [headings, setHeadings] = useState([])
useEffect(() => {
const elements = Array.from(document.querySelectorAll("h2, h3, h4"))
.map((elem) => ({
text: elem.innerText,
}))
setHeadings(elements)
}, [])
上面的代码将找到我们页面上所有指定的标题元素,然后将文本内容存储在状态中。
在上面的代码中,我们正在使用 [Array.from](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from)方法从querySelectorAll 返回的NodeList 创建一个数组。我们这样做是因为有些函数,比如我们上面使用的map ,并没有在NodeList 。为了方便处理找到的标题元素,我们把它们转换成一个数组。
现在,为了在TOC中显示标题,修改组件的返回语句,使其看起来像以下代码:
// src/TableOfContent.js
return (
<nav>
<ul>
{headings.map(heading => (
<li key={heading.text}>
<a href='#'>{heading.text}</a>
</li>
))}
</ul>
</nav>
)
现在,当我们在浏览器中打开这个应用程序时,我们会看到以下情况。

层次结构中的链接和列表标题
现在,当我们点击TOC中的一个标题时,它并没有把我们带到正确的章节。你会注意到,它们都在同一行中,没有指示哪个是主标题或副标题。让我们来解决这个问题。
在TableOfContent 组件中,修改useEffect Hook,使其看起来像以下代码。
// src/TableOfContent.js
useEffect(() => {
const elements = Array.from(document.querySelectorAll("h2, h3, h4"))
.map((elem) => ({
id: elem.id,
text: elem.innerText,
level: Number(elem.nodeName.charAt(1))
}))
setHeadings(elements)
}, [])
除了我们发现的标题中的文本外,我们还将为状态添加一个ID和一个level 属性。我们将把ID传递给TOC文本的锚标签,这样当我们点击它时,我们就会被带到页面的相应部分。然后,我们将使用level 属性,在TOC中创建一个层次结构。
修改TableOfContent 组件的返回语句中的ul 元素,使其看起来像下面这样:
// src/TableOfContent.js
<ul>
{headings.map(heading => (
<li
key={heading.id}
className={getClassName(heading.level)}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault()
document.querySelector(`#${heading.id}`).scrollIntoView({
behavior: "smooth"
})}}
>
{heading.text}
</a>
</li>
))}
</ul>
在上面的代码中,除了将ID添加到锚标签<a></a> 的href 属性中,我们还添加了一个onClick 事件,该事件触发后,调用scrollIntoView ,使浏览器顺利滚动到相应的章节。
在li 元素中,我们在className 属性中调用getClassName(heading.level) 。我们将使用这个我们很快就会创建的功能,根据level 属性的值来设置不同的类名。因此,我们可以给TOC中的副标题设置不同于主标题的样式。
接下来,为了创建getClassName 功能,在TableOfContent 组件外添加以下代码:
// src/TableOfContent.js
const getClassName = (level) => {
switch (level) {
case 2:
return 'head2'
case 3:
return 'head3'
case 4:
return 'head4'
default:
return null
}
}
现在,在tableOfContent.css 文件中添加以下几行代码。
// src/tableOfContent.css
.head3{
margin-left: 10px;
list-style-type: circle;
}
.head4{
margin-left: 20px;
list-style-type: square;
}
有了上面的代码,当我们点击TOC中的一个标题或副标题时,我们就会被带到相应的章节。现在,我们的TOC中的标题有了一个层次结构。

查找并突出显示当前活动的标题
当一个标题在我们的页面上可见时,我们要突出显示TOC中的相应文本。
为了检测标题的可见性,我们将使用Intersection Observer API,它提供了一种监视目标元素的方法,当该元素达到预定义的位置时执行一个函数。
用交叉观察者API观察活动的标题
使用Intersection Observer API,我们将创建一个自定义Hook,它将返回活动标题的ID。然后,我们将使用返回的ID来突出显示我们TOC中的相应文本。
要做到这一点,在src 目录中,创建一个hook.js 文件,并添加以下几行代码。
// src/hooks.js
import { useEffect, useState, useRef } from 'react';
export function useHeadsObserver() {
const observer = useRef()
const [activeId, setActiveId] = useState('')
useEffect(() => {
const handleObsever = (entries) => {}
observer.current = new IntersectionObserver(handleObsever, {
rootMargin: "-20% 0% -35% 0px"}
)
return () => observer.current?.disconnect()
}, [])
return {activeId}
}
在上面的代码中,我们创建了一个新的交叉点观察者的实例。我们传递了handleObsever 回调和一个options 对象,在这里我们指定了观察者的回调在什么情况下执行。
在object 使用rootMargin 属性,我们将根元素的顶部缩小20%,也就是目前我们的整个页面,底部缩小35%。因此,当一个标题位于我们页面的顶部20%和底部35%时,它将不会被计算为可见。
让我们指定我们想要观察的标题,把它们传递给交叉点观察者的observe 方法。我们还将修改handleObsever 回调函数,以设置状态中被交叉的标题的ID。
要做到这一点,请修改useEffect Hook,使其看起来像下面的代码。
// src/hooks.js
useEffect(() => {
const handleObsever = (entries) => {
entries.forEach((entry) => {
if (entry?.isIntersecting) {
setActiveId(entry.target.id)
}
})
}
observer.current = new IntersectionObserver(handleObsever, {
rootMargin: "-20% 0% -35% 0px"}
)
const elements = document.querySelectorAll("h2, h3", "h4")
elements.forEach((elem) => observer.current.observe(elem))
return () => observer.current?.disconnect()
}, [])
在TableOfContent.js 文件中,用下面的代码导入创建的Hook。
// src/TableOfContent.js
import { useHeadsObserver } from './hooks'
现在,在TableOfContent 组件中的headings 状态之后调用该Hook。
// src/TableOfContent.js
const {activeId} = useHeadsObserver()
通过上面的代码,当一个标题元素相交时,它将与activeId 。
突出显示活动的标题
为了突出显示我们TOC中的活动标题,在TableOfContent 组件的返回语句中修改li 元素的锚标签<a></a> ,添加以下样式属性。
style={{
fontWeight: activeId === heading.id ? "bold" : "normal"
}}
现在,我们的锚定标签将看起来像下面这样:
// src/TableOfContent.js
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault()
document.querySelector(`#${heading.id}`).scrollIntoView({
behavior: "smooth"
})}}
style={{
fontWeight: activeId === heading.id ? "bold" : "normal"
}}
>
{heading.text}
</a>
现在,当一个标题被激活时,它将变成粗体。这样,我们就完成了带有标题高亮的目录的创建。
高亮显示TOC项目的缺点
在为TOC添加项目高亮时,有一些注意事项需要注意。首先,在TOC中添加这一功能并没有标准的方法。因此,在不同的网站上,实现方式是不同的,这意味着我们网站的用户必须学习我们的TOC如何工作。
此外,由于每个目录根据其下的文本,在每个标题之间有不同的间距,我们对高亮功能的实现可能对所有的标题都不准确。
总结
在你的博客或文章中添加一个目录,可以为网站访问者创造更好的体验。在本教程中,我们学习了如何创建一个带有项目高亮的目录,以指示每个活动的标题,帮助你的用户浏览你的网站,改善你的整体用户体验。