shiodaifuku.io

Nuxt.jsのstaticモードにおいて、単一ルートのパラメータによるコンテンツ出し分けを行う際の問題を回避する

この記事はしおだいふく Advent Calendar 2020 6日目の記事です。

昨日の公約を果たすため、Nuxt.jsのstaticモードを使って変な問題を踏んだ話とその解決策を書き残しておきます。 内容をなるべく正確に記述しようと思ったら無駄に長くなってしまいました。

先に書いておくと、Nuxt.jsやNuxt Contentの仕様やそれらの組み合わせが悪いわけではなく、 僕がたまたまちょっと変わったことをやろうとして沼にハマっただけなので、Nuxt.jsやNuxt Contentを使うにあたってビクビクする必要はありません。

Nuxt.jsのstaticモード

Nuxt.jsのstaticモードでは、asyncDatafetchによるAPIの呼び出しをビルド時に呼び出して取得できるデータをJavaScriptファイルに書き出します。 アプリケーションの実行時には、APIを呼び出す代わりにこのファイルを利用してビルド時に取得したデータを復元します。 実行時にAPIを呼び出すことがなくなり、API通信がなくなるぶん高速に動作したりコンテンツのキャッシュが効くようになったりします。

余談ですが、ここで呼び出すAPIは与えるパラメータが同じであれば常に結果が同じになる性質を持っていなければいけません。 この性質を満たさないAPIはstaticモードには適しません。

onMountedやbeforeUpdateなどのライフサイクルフックでは、Promiseの解決を待機してくれません。 静的サイト生成の出力結果が不安定になるので非同期APIの呼び出しは避けるべきです。 Nuxt.jsのstaticモードで非同期APIを利用する際は、なんとかしてasyncDatafetchの中にページレンダリングに必要なデータの取得を詰め込まなければなりません。

staticモードのめんどくさい問題

ところが、単一のRouteに対するコンテンツが複数存在するケースではasyncDatafetchを使うと問題が発生します。 staticモードでパラメータによってコンテンツを出し分けるページで、データを取得する非同期処理の解決をasyncDatafetchで行うと、 nuxt buildによる本番環境用のビルド結果とnuxt devによるローカルでのアプリ実行結果に差分が生じてしまいます。

例えばパスパラメータやクエリパラメータによって参照するデータが変わるといった、まさに弊ブログサイトのようなWebサイトがこの例に該当しています。 このブログでは、記事ページを /articles/<記事ID> というパスに設置しています。 記事IDの部分がパスパラメータになっていて、このIDをコンテンツを取得するためのクエリに利用しています。

何も考えずにコードを書いていた時代には、記事の内容は以下のようにuseFetch1を使って取得していました。

export default defineComponent({
  setup: () => {
    const { params, $content } = useContext()
    const page = ref(null)
    useFetch(async () => {
      // ここがパスパラメータを使った非同期処理の呼び出し
      page.value = await $content(params.value.articleID).fetch()
    })
    return { page }
  }
})

これは一見うまく動くように見えます。 実際に、nuxt buildによるビルド時は期待通りに動作します。2 ビルド時はサーバアクセスされた状態を想定してコンテンツを生成するため、各記事ページを生成する際に都度$content().fetch()が呼び出されます。

ところが、このコードをnuxt devでテストすると問題が起こります。 先に説明したとおり、staticモードではfetchがクライアントサイド・ナビゲーション時に動作しません。 したがって、記事Aのページにアクセスした後でリンクをたどって記事Bのページに遷移しても、 記事Bのコンテンツを取得するAPI呼び出しが行われず、 ビルド時に生成したペイロード(API呼び出し結果のモック)を参照して記事Aの内容が表示されてしまいます。

サーバサイドにリクエストを投げればAPIを呼び出してくれるので、ページ遷移したあとでリロードすれば正しい内容が表示されるのですが、 サーバ上にデプロイされるWebサイトと動作が異なるため、テスト環境としてはイマイチ信頼できないものになってしまいます。 だからといってnuxt devを使わずに都度ビルドを挟むのは(特に細かな修正が多くなるブログサイト)では非効率です。

staticモード(SSG)でパスパラメータを見てコンテンツを出し分けるというユースケース自体がそんなに多くない3のですが、 ブログ作ってたらうっかりこのケースを踏み抜いてしまったわけです。

asyncDataとfetchを避けることによって問題を回避する

この問題を解決するには、staticモードでビルド時にasyncDatafetchを使わずに非同期APIの呼び出し完了を待機することが必要です。

Nuxt.jsの世界では、asyncDatafetchのほかにもプラグインやミドルウェアがPromiseの解決を待機してくれます。 僕は嫌Vuex派なので、まっとうなデータ受け渡し手段がVuexくらいしかないミドルウェアは今回使いません。 プラグインとComposition APIのprovide/injectを使って、データをページコンポーネントまで持っていきます。

今回の例で使うプラグインは次のようになります。 すべての記事をまとめてフェッチして、onGlobalSetupの中でIDごとにprovideします。 ここでは、Nuxt Contentにより都合よくprovide用のキーが提供されていますが、一般的にはデータベースなどからパスパラメータに指定されうるIDのリストを取得します。

export default defineNuxtPlugin(async ({ $content }) => {
  const contents = await $content().fetch()
  onGlobalSetup(() => {
    contents.forEach((page) => {
      // page.slug がパスパラメータの<記事ID>にあたります
      provide(page.slug, page)
    })
  })
})

記事ページのsetup関数では、Nuxt Contentの呼び出しを行う代わりにinjectでデータを取得します。

export default defineComponent({
  setup: () => {
    const { params } = useContext()
    const page = inject(params.value.slug)
    if (page === undefined) {
      throw new Error(`page:${params.value.slug} has not provided`)
    }
    return { page }
  }
})

こうすることで、各ページで行われていた非同期API呼び出しを同期的な処理にすり替えることが可能になり、 asyncDatafetchがクライアントサイド・ナビゲーションで呼び出されないことによって発生する問題を見なかったことにできます。

この方法の懸念事項

ちゃんと調べたわけではありませんが、この記事で紹介した手法ではプラグイン内ですべてのデータをフェッチしてprovideしているため、 おそらく取得したデータがメモリに全部載ります。 このブログ程度であれば全く問題ありませんが、データの規模が多くなるとメモリ食い過ぎ問題が起こると思われます。

その場合は、ミドルウェアでページごとにデータを取得してVuexにしまってページを離れるときに破棄して...を繰り返せばよさそうですが、 現代のマシンのメモリに載せられない規模になったらちょっといろいろ他にも見直すべきところがありそうなので一旦忘れることにします。

さいごに

この記事が似たようなことをやろうとしている誰かの役に立つことを願っております。


  1. fetchのComposition API版です。Nuxt.jsはまだVue 3.0をサポートしていないので、Nuxt Composition APIを使っています。
  2. 本番環境ではちゃんと動くので、読者の皆様は問題が起こっていることに気が付かなかったことでしょう。
  3. 一般的には動的サイトの領分なので、staticモードを使うべきではないため。

最新記事

  1. クソお世話になりました
  2. Cloud RunでgRPC streamingができるようになったので動かしてみたりした
  3. 2020年の執筆業まとめ
  4. アーキテクチャを設計する仕事とは
  5. 決済システムを設計するときに忘れてはならないたった1つの大切なこと