Nuxt.js で payload を使って静的サイト生成を高速化する
- 公開日:
各テンプレートで 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.ok) return 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
が復活します。