在 Nextjs 中实现 TOC

힘센캥거루
2025년 1월 23일(수정됨)
24
nextjs
在 Nextjs 中实现 TOC-1

今天决定制作一个大家都在使用的 TOC。

我现在使用 next-mdx-remote 编译并展示用 Markdown 写的文章。

因为 rehype-slug 已经为每个标题标签分配了 ID,所以已经完成了一半的准备。

首先,使用 useEffect 和 querySelectorAll 简单实现一下。

1. 简单创建 TOC

在 Nextjs 中实现 TOC-2

在尝试新事物而找不到方向时,知道如何提问也是一种能力。

像我这样的自学者,找不到人可以询问时,向像 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;
    }
}

进行了点击测试。

效果不错。

在 Nextjs 中实现 TOC-3

现在用 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。

根据点击或滚动均能正常响应。

在 Nextjs 中实现 TOC-4

3. 结论

虽然想用 SSR 来创建 TOC,但由于是响应式的,客户端生成更适合我的心理健康。

看到其他博客附带 TOC,总想有朝一日实现它,终于完成了,感到很高兴。

接下来打算把所有帖子放入数据库中尝试一下。

댓글을 불러오는 중...