Next.jsとzenn-markdown-htmlでtocbotの目次が空になる問題

公開日:
目次

Next.jsブログでzenn-markdown-htmlとtocbotを組み合わせて目次を自動生成しようとしたところ、目次が空になる問題が発生しました。原因と解決策を備忘録として残します。

環境

今回の問題が発生した環境は以下の通りです。

  • Next.js(Pages Router)
  • zenn-markdown-html:マークダウンをHTMLに変換
  • tocbot:見出しから目次を自動生成

問題の症状

tocbotを設定しても、目次が表示されませんでした。<nav class="toc"> の中身が空のままになります。

contentSelectorのネスト問題

tocbotの contentSelector で指定した要素の直下ではなく、ネストされた位置に見出しがある場合、tocbotが見出しを検出できません。

以下のようなHTML構造を例にします。

<div class="body">
  <div class="znc">
    <div class="markdown">
      <h2 id="section1">見出し</h2>
    </div>
  </div>
</div>

contentSelector.body に設定していると、2階層ネストされた .markdown 内のh2を検出できません。zenn-markdown-htmlは .znc クラスを持つラッパー要素を生成するため、このような構造になります。

解決策として、contentSelector を見出しにより近い親要素に変更します。上記の例では .znc を指定します。

コンポーネントのマウントタイミング問題

Next.jsでハイドレーションエラーを避けるため、以下のようなパターンを使うことがあります。

const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null

この場合、tocbot.init() が実行されるタイミングでは、まだコンテンツがDOMに存在しません。

解決策は2つあります。

tocbot.refresh を使う方法(推奨)

コンテンツを表示するコンポーネントで、マウント後に tocbot.refresh() を呼びます。

目次を表示するコンポーネント(SideBar等)では通常通り初期化します。

tocbot.init({
  tocSelector: '.toc',
  contentSelector: '.znc',
  headingSelector: 'h2, h3',
})

コンテンツを表示するコンポーネント(PostBody等)では、マウント後にrefreshを呼びます。

useEffect(() => {
  if (mounted) {
    tocbot.refresh()
  }
}, [mounted])

この方法は、tocbotの公式APIを使用しており、コンポーネント間の責務が明確になります[1]

setTimeoutを使う方法

setTimeout で遅延させてから tocbot.init() を実行する方法もあります。

useEffect(() => {
  const timer = setTimeout(() => {
    tocbot.init({
      tocSelector: '.toc',
      contentSelector: '.znc',
      headingSelector: 'h2, h3',
    })
  }, 300);

  return () => {
    clearTimeout(timer)
    tocbot.destroy()
  }
}, []);

この方法はシンプルですが、遅延時間の調整が必要で、環境によっては動作しない可能性があります。

脚注
  1. tocbot - npm ↩︎