NextjsでTOCを実装する

힘센캥거루
2025년 1월 23일(수정됨)
4
29
NextjsでTOCを実装する-1

今日はよくあるTOCを作ってみることにした。

私は現在、マークダウンで書いた記事をnext-mdx-remoteでコンパイルして表示している。

rehype-slugで既にタイトルタグごとにIDが付与されているので、半分は準備が整っている。

まずはuseEffectとquerySelectorAllで簡単に作ってみよう。

1. 簡単にTOCを作ってみる

NextjsでTOCを実装する-2

何か初めて試みることなので、道に迷ったときは質問する方法を知っていることも能力である。

アドバイスをくれる人がいない独学者の私は、ルィテン、クロード、GPTのような大きな兄さんたちにアドバイスを求めるのが最も速い方法。

大きな兄さんたちからもらったコードを少し修正して使ってみた。

"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);

  // まずコンポーネントがロードされたら1回実行する。
  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 && (
      // TOCは記事のすぐ横に来るようにcalcで位置を計算した。
      <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でオブザーバー変数を1つ作り、ここにインタラクションオブザーバーを割り当てよう。

コードはssoon-mさんのvelogを参考にした。

export default function TOC() {

...
  const observer = useRef<IntersectionObserver>();
  const [activeToc, setActiveToc] = useState("");

  useEffect(() => {

    ...
    // 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) => {
        //インタラクションオブザーバーで要素を監視する。
      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が付いているのを見て、いつか自分もやってみようと思っていたが、こうして作ることができて嬉しい。

次は投稿をすべてデータベースに入れる作業を試してみたい。

관련 글

Next.js フルスタックブログ開発記
Next.js フルスタックブログ開発記
Web開発に初めて触れてから1年ほど経った頃、自分だけのブログを持ちたいと思うようになった。そこで、およそ6か月これだけにかかりきりになって作ってみることにした。フロントエンド側の機能については、下記のキム・ドヒョンさんのブログを参考にするだけで十分だと思う。自分も mdx を使ってブログを作るだけ...
Caddyを利用した Next.js 無停止デプロイ(ローカルサーバー)
Caddyを利用した Next.js 無停止デプロイ(ローカルサーバー)
ホームページに何かをちょこちょこ載せたくなるたびにビルドしていたら、そのタイミング에 가끔 접속하는人がいるようだった。そうしているうちに、サーチコンソールでスコアがどんどん下がる現象が起きた。このままではまずいと思い、無停止デプロイをする方法を考えてみることにした。1. プロジェクトフォルダ2つ+...
Google検索インデックスの自動化 - Web Search Indexing API
Google検索インデックスの自動化 - Web Search Indexing API
前回の IndexNow に続いて、Google でも自動化してみることにした。調べてみると、Google は API として Web Search Indexing というものを提供していた。1. 対象範囲公式にこの API がサポートしている範囲は、求人情報とストリーミング動画サービスである。リ...
検索インデックス生成の自動化 - IndexNow
検索インデックス生成の自動化 - IndexNow
Bingにサイト登録をしている途中で知ったのだが、Bingでは IndexNow という機能を提供している。ポイントは、APIキーを使って、記事を書いた直後にすぐインデックス作成リクエストを飛ばせるということ。下のようなリクエストを fetch で作り、投稿機能に連動させておけば、記事をDBに保存す...
Nextjs、React サーバーがハッキングされた経験
Nextjs、React サーバーがハッキングされた経験
最初にセキュリティ問題に気づいたのは、12月5日の未明だった。React で認証なしにリモートコード実行が可能だということ。そのニュースを見て他の人には共有したものの、自分は大丈夫だろうと思って特に気にも留めなかった。1. ハッキングの痕跡を発見ところが、ブログのコードを更新しようとアクセスしてみる...
ブログに多言語機能を追加する(NextJS・next-intl・Vercel AI SDK)
ブログに多言語機能を追加する(NextJS・next-intl・Vercel AI SDK)
最近、ブログに多言語対応機能が必要だと感じた。そこで next-intl を使って多言語サービスを実装してみることにした。1.i18nまず多言語サービスを行うときには、守るべき原則がある。これを internationalization と呼ぶが、かなり長いので、先頭の i と末尾の n、そしてその...

댓글을 불러오는 중...