今天决定制作一个大家都在使用的 TOC。
我现在使用 next-mdx-remote 编译并展示用 Markdown 写的文章。
因为 rehype-slug 已经为每个标题标签分配了 ID,所以已经完成了一半的准备。
首先,使用 useEffect 和 querySelectorAll 简单实现一下。
1. 简单创建 TOC

在尝试新事物而找不到方向时,知道如何提问也是一种能力。
像我这样的自学者,找不到人可以询问时,向像 LLM、Claude、ChatGPT 这样的“大哥”寻求建议是最快的方法。
我稍微修改了一下从这些“大哥”那里获得的代码并进行了尝试。
"use client";
import { useEffect, useState } from "react";
interface StringKeyValue {
[key: string]: string;
}
export default function TOC() {
const [headings, setHeadings] = useState<StringKeyValue[]>([]);
const [toc, setToc] = useState(false);
// 首先组件加载时执行一次。
useEffect(() => {
// 使用 querySelectorAll 找到 h2、h3、h4 级别的标签。
const hTags = document.querySelectorAll<HTMLHeadingElement>("h2, h3, h4");
// 将元素转换为数组。
const headings = [...hTags];
if (headings.length == 0) {
return;
}
setToc(true);
// 从元素中提取创建 TOC 所需的内容
const allHeadings = headings.map((heading) => ({
text: heading.innerText,
id: heading.id || heading.innerText.replace(/\s+/g, "-").toLowerCase(),
tagName: heading.tagName,
}));
setHeadings(allHeadings);
}, []);
return (
toc && (
// 通过 calc 设置 TOC 位置紧邻文章旁边。
<div className="fixed hidden xl:block top-40 w-64 p-4 left-[calc(50%+390px)]">
<div className="text-2xl mb-4">Contents</div>
<div className="pl-2">
<ul className="prose">
{headings.map((heading) => (
// 由于没有使用 h4 级别的必要,只为 h3 级标签设置了类。
<li
key={heading.id}
className={`${
heading.tagName == "H3" && "pl-6"
} border-l-2 border-gray-200`}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2`}
>
// 自动为标题加上 1. 2. 这样的数字,因此用 replace 和正则表达式去掉。
{heading.text.replace(/^\d+\.\s*/, "")}
</a>
</li>
))}
</ul>
</div>
</div>
)
);
}在 globals.css 中为滚动设置 smooth 选项以使导航更平滑。
我直接以全局方式添加了它,因为无用的卡顿滚动不太有用。
@layer base {
html {
scroll-behavior: smooth;
}
}进行了点击测试。
效果不错。

现在用 intersection observer 来强调进入视窗的标签。
2. 使用 Intersection Observer
函数的参数如下所示。
const options = {
root: null, // 默认是视窗,也可注册为用作基准的 HTMLElement。
rootMargin: '0px', // 设置视窗边界。
threshold: 0.5 // 当 50% 进入视窗时调用回调
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} 可见。`);
} else {
console.log('${entry.target.id} 不可见。');
}
});
}, options);
// 选择要观察的元素
const target = document.querySelector('#target');
observer.observe(target);用 useRef 创建一个观察器变量,并将 Intersection Observer 分配给它。
代码参考了 ssoon-m 的 velog。
export default function TOC() {
...
const observer = useRef<IntersectionObserver>();
const [activeToc, setActiveToc] = useState("");
useEffect(() => {
...
// 将 Intersection Observer 分配给 observer.current
observer.current = new IntersectionObserver(
(entries) => {
// 在监视的元素中,将正在交互的元素 ID 注册为 activeToc
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
setActiveToc(entry.target.id);
});
},
{
// 监视从最顶部到最底部减去 70% 的区域
rootMargin: "0px 0px -70% 0px",
threshold: 0.8,
}
);
...
const allHeadings = headings.map((heading) => {
// 在 Intersection Observer 中监视元素。
observer.current?.observe(heading);
...
});
...
// 在 return 调用匿名函数时,分配 cleanup 函数。
return () => observer.current?.disconnect();
}, []);
return (
toc && (
...
// 粗略地为当前标题赋予高亮类
{headings.map((heading) => (
<li
key={heading.id}
className={`${
heading.tagName === "H3" && "pl-6"
} ${activeToc == heading.id ? "border-l-4 border-gray-500":"border-l-2 border-gray-200"} `}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2 ${
activeToc == heading.id ? "text-black font-black" : "text-gray-500"
}`}
>
...
);
}完成后滚动查看 TOC。
根据点击或滚动均能正常响应。

3. 结论
虽然想用 SSR 来创建 TOC,但由于是响应式的,客户端生成更适合我的心理健康。
看到其他博客附带 TOC,总想有朝一日实现它,终于完成了,感到很高兴。
接下来打算把所有帖子放入数据库中尝试一下。
댓글을 불러오는 중...