Nuxt.jsでvueファイルに書いた記事に動的に目次を付けるゾ

シェアする:

この記事をおすすめしたい人

  • Nuxt.jsでvueファイルに直接記事を書いている人
  • vueファイルに書いた記事に動的に目次を付けたい人
  • つまりオレ

何も知らない超初心者が脱WordPressしたくてNuxt.jsでサイト構築していくシリーズです。

今回はNuxt.jsで書いた記事に対して動的に目次を作る方法を考えます。

いや、これ実は以前に似た記事を書いていて、「Nuxt 動的に目次」とかで検索するとなんと1位でした。 1位だったんですが…

そういう意味じゃねー!

となったので、今回の条件に合うように動的に目次を作っていきますよー。

今回やりたいこと

まず記事は手動でvueファイルに直書きします。

いや、分かってますって。非効率なんでしょこれ…。

一般的にNuxt.jsで記事を書く場合はヘッドレスCMSに書いてAPIで取得するようです。

それかnuxt-contentモジュールを使ってマークダウン?みたいな形式で書いたり。

ですがデータベースを利用するのが難しかったり、二言語で書く書き方が好みじゃなかったりしたので、結局直書きに戻ってしまいました。

こういう条件で動的に目次を付けるのが今回の目的です。

何がどう難しいのか

えー、目次を作るにはvueファイル内のscriptの部分でtemplate部分(HTML)にアクセスできればいいだけです。

ところがNuxt.jsというかVue.jsの仕様?みたいなので、script部分の処理>template部分のレンダリングの順なので、アクセスする方法がないっぽいんですよ。 その中でどうにかやりくりしていくお。

失敗例①:refを使う

Vue.jsかな?にrefという機能があるようです。

この↓ように書くとthisやappからアクセスできる「$refs」というオブジェクトのrefで指定したキーにHTMLエレメントが追加されます。


<h2 id="title1" ref="h2_1">タイトル①</h2>

上の例だと「this.$refs.h2_1」。

実行される順序の都合、Nuxt.js(たぶんVue.js)ではscriptからtemplateの中身を知ることができないんですが、それを知れる唯一?の方法のようです。

で、ChatGPTによると得られる値はDOM要素だとかで、「id」で指定したidを(this.$refs.h2_1.id)、「innerText」でタグの内側のテキスト(this.$refs.h2_1.innerText)が得られます。 これで目次を作る要素が揃うわけです。

ところが!

例えばこんな↓書き方で


<h2 id="title1" ref="h2[]">タイトル①</h2>

h2キーにエレメントの配列を作ってくれないかなーと思ったんですが、どうもできなくてですね。 最後の要素だけが入るみたいです。 []なしで書いても同様。

ChatGPTは出来るって言ったんですけどね。

今回やりたいのはメンテナンス性向上のために自動で目次を作る方法です。 いちいちrefに一意の値を割り振ってーとかやりたくないんです僕は。

data()で定義する変数を静的変数扱いして、関数を呼ぶたびに数値が増えていくこんな↓書き方もしてみましたが、


<h2 id="title1" :ref="'h2_'+refNum()">タイトル①</h2>

数値が300とかになってですね、まぁそれはいいんですけど、「無限ループおきてますよー」みたいなエラーも出てて諦めました。

あとですね、ChatGPTが出してくる用例がことごとくbuttonを使ったrefの操作なんですね。

違うねんけど?computedとかで処理したいねんけど?

どうもこの$refsに値がちゃんと入るのが、scriptの処理>templateのレンダリングのさらに後のようなんです。

script内で使えないなら意味がありません。

というわけで、この方法は今回の要件を満たしません。

失敗例②:JavaScriptでDOM要素から取得

えー、僕は基本的なJavaScriptの知識がすっからかんなので全然意味が分かってないんですが、よく見かける「getElementById」みたいなのを使う方法です。

ChatGPTにほぼ全任せで、意図した挙動になるよう調整した最終的なコードがこんな↓感じ。


computed: {
  items() {
    let list = []
    
    if ( process.browser ) {
      const article = document.getElementById('article')
      
      if ( article ) {
        const h2 = article.querySelectorAll('h2')
        
        h2.forEach(row => {
          list.push({
            href: row.id,
            name: row.textContent,
          })
        })
      }
    }
    
    return list
  },
},

これで、最終的にレンダリングされたHTMLの#article内にあるh2を全て拾ってきて目次作成用のオブジェクト配列ができあがります。

「if ( process.browser ) {」はサーバサイドで実行した場合?にエラーが出てたので追加しました。 「if ( article ) {」も実行タイミング?によってはエラーが出たので追加。

後はこれを目次生成部分でv-for使って目次にするだけです。

実際に意図した通りに目次ができました!

ただですね…。

F5を押すと必ず目次が表示されたんですが、ページ移動時やページアップデート時など、よく目次生成にコケてるんですよね。

JavaScriptに詳しい人なら解決できそうにも思えましたが、仮にうまくいったとしてもおそらくgenerateしたHTMLファイルには目次が入らないと思うのでSEO的にいくらか不安が残ります。

というわけで、候補として残しはするけどこの方法も不採用。

失敗例③:HTMLを別ファイルに分離

イメージとしてはヘッドレスCMSと同じことをローカルでやる感じです。

どこかテキトーにjsファイルかjsonファイルにtemplateに書いていたものを移植します。

それをimportすると、templateのレンダリング前にtemplateの中身を知ることができます。

後はそれを正規表現とかcheerioモジュールを使ってh2を引っこ抜いてやれば目次が作れます。

このやり方は付随するメリットも大きくて、記事ごとのふるい分けを動的ルーティング(_id.vueみたいな)に任せて、ルートによって読み込むjsファイルを切り替えると、共通処理が_id.vueの1ファイルで管理できるのでメンテナンス性が上がります。

さらに、jsファイル側にタイトルとかデスクリプションも持たせてやると、記事個別の部分だけをjsファイルに隔離できます。 vueファイルでこれをやると他の処理やらなんやらも記述されているので視認性が低い。

ええやん!

と思ってテストをしていると気付きました。

これ、ヘッドレスCMSと同じ問題を抱えてるやん?

ヘッドレスCMSを使う方法だと独自コンポーネントをロードするのにひと手間必要で、そのページだけデータベースからあのデータを持って来てーみたいなのを柔軟にやりにくいんです。 なのでこれも不採用。

コンポーネントとかデータベースとか関係なしにベタHTMLで記事を書く場合はこのやり方はアリだと思います。 でもそれだとヘッドレスCMSとかマークダウンで書けばいいような気もします。

最終的な選択

結局ですね、script部分でtemplateの中身を知るのが設計上難しいっぽいんですよね。

ChatGPTに

テキストのままでいいからtemplateをよこせ!

ってだだこねたりしたんですが、どうも絶望的に無理そうでした。

というわけで僕が採用したのはref方式と一部妥協。

ref方式で解決させるよ

ref方式の問題点は手動でrefのキーを指定しないといけないことです。

でもこれ、IDと同じでもいいんですよね。

タイトルの内容からidを決めるのはそんなに脳みそ消費しないから、それと同じでいいならそこまで苦ではないです。

できればやりたくないけど仕様上あきらめました。

実装テストしたコード

まずHTMLはこんな↓感じになりました。


<h2 ref="xbox">まずはXbox本体を手に入れろ!</h2>
<h2 ref="software">次にソフトを手に入れろ!</h2>
<h2 ref="hdmi">XboxとテレビをHDMIで接続しよう</h2>
<h2 ref="nine">メガテンNINEは高解像度対応か</h2>
<h2 ref="rec">配信や実況をするにはどうすればいいか</h2>
<h2 ref="button">決定ボタンの位置問題</h2>

いろいろ試してidは消しました。 本当ならrefとidが必要で、同じ値にするので1回しか書きたくなくて、 コンポーネント化してコンポーネント側でidとrefをふると階層がひとつ深くなるのでrefの値を拾ってこれません。 そのコンポーネントにrefだけ振るとコンポーネント側でその値を受け取れず。 なのでh2にrefだけ振って目次を作る際にrefの値でidを追加します。

後はこれを$refsでアクセスするだけなんですが、置く場所が大事です。

上でも触れましたがrefの部分も事前に内容を知れるわけじゃなくて、templateレンダリングの後みたいなんですよ。 このせいでtemplate内で{{$refs}}とかしても中身が変です。

しかしmounted↓ならレンダリング後に処理を追加できるようでした。


data() {
  return {
    items: [],
  }
},
mounted() {
  let list = []
  
  for ( const key in this.$refs ) {
    this.$refs[key].id = key
    
    list.push({ href: '#' + key, name: this.$refs[key].innerText })
  }
  
  this.items = list
},

呼び出し元がmountedであればそこからcomputedやmethodsを呼び出しても大丈夫みたいです。

ここで定義したitemsを目次表示部分↓でv-for回して完了です。


<ul>
<li v-for="item in items">
<nuxt-link :to="item.href">{{ item.name }}</nuxt-link>
</li>
</ul>

ページごとにこれを置いて、記事の数だけメンテナンスしていくのは面倒な問題が残ります。

目次表示部分はコンポーネントにしてpropsで渡せばおkです。

mountedの部分は処理部分をVue.mixinのmethodsにおいて関数を呼び出すだけにすればおk。 Vue.mixinは全てのコンポーネントからアクセスできるcomputedやmethodsを追加できるんですが、面白いことに「this」が呼び出し元の「this」になるんですよね。 なので共通部分で定義したはずなのに「this.$refs」がちゃんと意図した通りに動きます。

この方法のデメリット

まずidを動的にふっていないので記事の規模が大きくなるとidの重複が発生する可能性がないわけではないです。 あとめんどい。

被らないようにがんばれ!

もひとつ、ちょっと記憶がおぼろげなんですが、昔、mountedとasyncDataの比較で、mountedに書いたものは出力したHTMLに記載されなかった…ような気がします。

となると、SEO上はやや不安です。

今はbotもJavaScript実行するらしいし平気、平気。

(都合よく変わるbotのJavaScriptレンダリング能力)

まとめ

結局ですね、「できない」ことが分かっただけで最後にできあがったものは何やら普通の目次になってしまいました。 これが唯一可能な方法な気がしないでもないです。

そこそこの妥協点で、そこそこの仕上がりなので、そこそこ満足です。

同じことで時間を浪費する人の助けになれば幸い

以上、WordPressからお届けしました!

シェアする: