目次
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()
}
}, []);
この方法はシンプルですが、遅延時間の調整が必要で、環境によっては動作しない可能性があります。
