Nuxt.js で payload を使って静的サイト生成を高速化する

Nuxt.js
公開日:

各テンプレートで Headless CMS などからデータを取得しながら静的サイト生成を行なうというのがデフォルトの動作ですが、記事ごとにデータを取得しに行っていたのでは時間がかかってしまううえ、SPA 実行時には不要なコードが残ってしまいます。

この記事では、payload を使って静的サイト生成を行なう方法を解説します。

payload の使い方

nuxt.config.js の generate.routes プロパティに以下のようなオブジェクトの配列を渡すと、asyncData の コンテキストからデータを受け取ることができます。

// nuxt.config.js
export default {
  generate: {
    routes: [
      {
        route: '/foo', // trailing slash は無い方がよい
        payload: {
          post: {
            title: 'foo’s title',
            content: '<p>foo’s content</p>',
            publishedAt: '2021-01-23T12:34:56.789Z',
          },
        },
      },
      {
        route: '/bar',
        payload: {
          post: {
            title: 'bar’s title',
            content: '<p>bar’s content</p>',
            publishedAt: '2021-01-24T12:34:56.789Z',
          },
        },
      },
    ],
  },
}
<!-- pages/_postId/index.vue -->
<script>
export default {
  asyncData({ payload }) {
    // npx nuxt generate 時にデータが渡される
    return payload // data に post が設定される
  },
}
</script>

実際には、API から取得したデータなどからこのような配列を作ることになります。

generate.routes プロパティを async function にして配列を返すこともできますが、nuxt.config.js がごちゃごちゃしてしまうので、ビルドモジュールにまとめることをお勧めします。

generateRoutes モジュールを作る

モジュールでは、nuxt.config.js の設定を取得したり、書き換えたりすることができます。

このモジュールでは、API からデータを取得して、それを元に routes の配列を作って、generate.routes プロパティに代入します。

// buildModules/generateRoutes.js
const fetch = require('node-fetch') // このスクリプトは Node.js で実行されるので window.fetch が使えない

export default function() {
  // Nuxt.js のフックを使う
  this.nuxt.hook('generate:before', async () => {
    // nuxt.config.js の設定値には this.options でアクセスできる
    const { apiUrl, apiKey, 'postsLimit': limit } = this.options.privateRuntimeConfig

    const routes = []
    // API への問い合わせが殺到しないように寝る
    const sleep = (ms = 500) => new Promise(resolve => setTimeout(resolve, ms))

    // URL 作って res.ok 見て res.json() するまでを関数にまとめておく
    const fetchApi = (path, params) => {
      const url = new URL(path, apiUrl)
      for (const [key, value] of Object.entries(params)) {
        switch (value) {
          case null:
          case undefined:
            continue
          default:
            url.searchParams.set(key, value)
        }
      }

      const promise = fetch(url.href, { headers: { 'X-API-KEY': apiKey }})
      .then(res => {
        if (res.okreturn res.json()
        throw new Error(`${res.status} ${res.statusText}`)
      })
      return promise
    }

    // API からデータを取得して配列を作る処理

    this.options.generate.routes = routes
  })
}

URL API では search プロパティにクエリ文字列を入れることができるので、router.stringifyQuery プロパティを設定している場合は、以下のように書くこともできます。

const fetchApi = (path, params) => {
  const url = new URL(path, apiUrl)
  url.search = this.options.router.stringifyQuery(params) // これで for 文の箇所を置き換え
  // 以下略
}

API からデータを取得して配列を作る処理

コメントの「API からデータを取得して配列を作る処理」の箇所では、各ルートに必要なデータを payload として渡していきます。

このブログではネストされたルートを使っているので、まずは親テンプレートで必要なカテゴリデータを取得します。親テンプレートでは payload.aside 、子テンプレートでは payload.main を受け取るようにしています。

microCMS からデータを取得することを想定したコードなので、使用するサービスに応じて適宜変更してください。

const { 'contents': categs } = await fetchApi('categories', { limit: 128, fields: 'id,name' }) // カテゴリは 128 件もない想定
const aside = { categs } // 後で使う

await sleep() // fetch したので寝る

次に、記事一覧と記事詳細ページで使うデータを取得します。記事は何件になるか分からないので、ページング処理しながら取得していきます。

私は for 文で書いていますが、最低 1 回は実行されるなら、do...while 文でも再帰関数でも何でも良いです。

for (let p = 1, maxPage = 1; p <= maxPage; p++) {
  const offset = limit * p - limit
  const { 'contents': posts, totalCount } = await fetchApi('posts', { offset, limit, fields: 'id,categ,title,content,publishedAt,revisedAt' })

  if (p === 1) maxPage = Math.ceil(totalCount / limit) // 一度取得しないと最大ページ数が分からない

  // 記事一覧ページ用のデータ
  routes.push({
    route: p === 1 ? '/' : `/page/${p}`,
    payload: {
      main: {
        posts: posts.map(({ content, ...post }) => post), // 一覧では内容は要らない
      },
      aside,
    },
  })

  // 記事詳細ページ用のデータ
  routes.push(...posts.map(post => {
    // ここでコードハイライトなどの処理をすることもできる
    return {
      route: `/${post.id}`,
      payload: {
        main: { post },
        aside,
      },
    }
  }))
  
  await sleep() // fetch したので寝る
}

カテゴリアーカイブページも同様に、必要なデータを取得して渡すだけです。記事詳細ページのデータはもう作ったので必要ありません。

for (const categ of categs) {
  const categId = categ.id
  const filters = `categ[equals]${categId}`
  for (let p = 1, maxPage = 1; p <= maxPage; p++) {
    const offset = limit * p - limit
    const { 'contents': posts, totalCount } = await fetchApi('posts', { offset, limit, filters, fields: 'id,categ,title,publishedAt,revisedAt' })

    if (p === 1) maxPage = Math.ceil(totalCount / limit)

    routes.push({
      route: p === 1 ? `/categories/${categId}` : `/categories/${categId}/page/${p}`,
      payload: {
        main: { posts },
        aside,
      },
    })

    await sleep() // fetch したので寝る
  }
}

最後に、その他のページにも payload を渡して、generate.routes を書き換えます。

// その他のページ
routes.push(
  {
    route: '/disclaimer', // 免責事項ページ
    payload: { aside },
  },
  {
    route: '/search', // 検索結果ページ
    payload: { aside },
  },
)

this.options.generate.routes = routes

モジュールができたら、nuxt.config.js に追加します。

// nuxt.config.js
export default {
  buildModules: [
    '~/buildModules/generateRoutes',
  ],
}

ついでにサイトマップも作る

@nuxtjs/sitemap モジュールを使うと、簡単にサイトマップを作ることができます。まずはインストールします。

$ npm install @nuxtjs/sitemap

次に、nuxt.config.js の modules プロパティに追加します。他のモジュールがある場合は、最後に追加してください。

更に、sitemap プロパティで設定を変更します。sitemap.hostname は必須なので、環境変数を使うなりベタ書きするなりで設定してください。

// nuxt.config.js
export default {
  modules: [
    '@nuxtjs/sitemap',
  ],
  sitemap: {
    hostname: process.env.DOMAIN || 'http://localhost:3000',
    gzip: true, // sitemap.xml.gz(圧縮されたサイトマップ)を生成する
    trailingSlash: true, // URL の後ろにスラッシュがなければ付ける
    exclude: [
      '/search', // 検索結果ページはサイトマップに要らない
    ],
  },
}

sitemap.exclude プロパティは曲者で、ここに設定したからといって、/search がサイトマップから綺麗さっぱり無くなる訳ではありません。

モジュールから設定した generate.routes には含まれているので、サイトマップに出力されてしまいます。

これを防ぐには sitemap.routes を設定し、/search が含まれないルート配列を用意します。ついでに、書式も @nuxtjs/sitemap モジュールに合ったものに変更しましょう。

generateRoutes モジュールの // その他のページ コメントがあるコードを修正します。

// その他のページ
routes.push(
  {
    route: '/disclaimer', // 免責事項ページ
    payload: { aside },
  },
)

this.options.sitemap.routes = routes.map(route => {
  return {
    url: route.route,
    lastmod: route.payload.main?.post?.revisedAt, // 最終更新日時
    // priority, changefreq は Google に無視されるので要らない
  }
})

// サイトマップに載せないページ
routes.push(
  {
    route: '/search', // 検索結果ページ
    payload: { aside },
  },
)

this.options.generate.routes = routes

これで /search はサイトマップから取り除かれます。sitemap.routes に含まれないなら sitemap.exclude も設定しなくて良いような気がするかも知れませんが、sitemap.routes を設定してもなお generate.routes を見に行っているらしく、sitemap.exclude を設定しておかないと /search が復活します。