レガシーおじさん、Vue.jsでSSGを頑張るのを諦めてNuxt.jsにする
はじめに
Vue.jsのSPAだとTwitterのサムネイルに使うOGP等が指定できないと以前記事を書いたのですが、コメントの中にNuxt.jsやNext.jsを使うと良いよ、とありました。コメントありがとうございました。
ただ、既存のアプリを違うFWに移行させるのはちょっと面倒だと思いPrerender SPA Pluginとかを使って、下記あたりを参考にVue.js+プリレンダリングで出来ないかと頑張ってみたけど諦めた、という記録になります。
具体的にはビルド時にAPI呼び出しをしてるのに、ブラウザで表示するタイミングで値が初期化されてしまう 「APIが2回呼び出される問題」 を解決できなかったので。Nuxt.jsなら簡単に出来たので素直にそっちを使うことにしました。
この記事では他の誰かあるいは過去を忘れた未来の自分が同じ過ちを繰り返さないように、「Vue.js + Prerender SPA Plugin」で「ビルド時のAPI呼び出しを含むSSG」をどのようにするのかと、その問題点及びNuxt.jsでの対応方法を書きます。備忘録的に自分の思考を追ってるだけなので、結論としては 「Nuxt.jsを使っとけ」 というだけで新しい知見とかは特にないです。
Vue.js + Prerender SPA PluginでSSGをする
Vue.jsプロジェクトの作成
まずはvue-cliを使ってプロジェクトを作成します。
$ vue create example-vue-ssg
$ cd example-vue-ssg
$ npm install axios vue-axio
続いてAPIを呼ぶようにコードを改修します。新規に作っても良いですが面倒なのでsrc/views/About.vue
を以下のように改修します。
<template>
<div class="about">
<h1>About</h1>
<p id="my-message">{{ message }}</p>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
message: "",
};
},
created: function () {
this.axios.get("http://localhost:5000/").then((response) => {
console.log(response.data);
this.message = response.data.message;
});
}
};
</script>
ダミーAPIの作成
localhost:5000
にダミーのAPIサーバが必要なので以下のコマンドでダミーAPIを作ります。自作のhttpd4testを使ってますが、準備方法は何でも構いません。
とりあえず、以下のコマンドでインストール
$ sudo sh -c 'curl https://raw.githubusercontent.com/koduki/httpd4test/main/cli/httpd4test > /usr/bin/httpd4test && chmod a+x /usr/bin/httpd4test'
$ httpd4test -h
つづいて、以下のようなシンプルなJSONを返すサーバを立てます。
$ httpd4test -p 5000 -m '{"message":"Hello, API"}' --json
SPAで動作確認
とりあえず準備は出来たので実行してみます。
$ npm run serve
以下のように表示されれば成功です。
ただし、これはSPAモードなのでAPIコールの結果で書き換えたDOMの内容はもちろんHTMLファイルに反映されていません。これだとSEOやOGPで困るのでプリレンダリングしたいというのがもともとの動機です。
<body>
<noscript>
<strong>We're sorry but example-vue-ssg doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="text/javascript" src="/js/chunk-vendors.js"></script><script type="text/javascript" src="/js/app.js"></script>
</body>
Prerender SPA Pluginで静的ファイルの生成を行う
つづいて上記のSPAにPrerender SPA Pluginを導入してSSG出来るようにします。
Prerender SPA Pluginはビルド時にローカルサーバを立て、そこに内部的に持っているchromeをヘッドレスモードでアクサスさせて表示したHTMLを出力するというシンプルな作りになっているようです。
まずはプライグインをインストール。
$ npm install prerender-spa-plugin
つづいてvue.config.js
を作成してprerender-spa-plugin
の挙動を定義します。production
での条件判定はしなくても動きますが、指定しないとproductionモードで動くnpm run build
以外のnpm run server
などdevモードで動く処理でも動作してしまって面倒なので条件判定を入れた方が良いです。
const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer
module.exports = {
pages: {
index: {
entry: "src/main.js",
title: "Example of SSG",
},
},
configureWebpack: () => {
if (process.env.NODE_ENV === 'production') {
return {
plugins: [
new PrerenderSPAPlugin({
staticDir: path.join(__dirname, 'dist'),
routes: ['/about']
})
]
}
}
},
};
準備が出来たので静的ファイルを生成しましょう。
$ npm run build
$ ls -l dist/about/index.html
ファイルが出来ましたね。ローカルサーバを立ててホスティングしてみます。
$ httpd4test -w dist -p 8000
ブラウザで問題なく表示されたと思います。HTMLの中身を見てみると以下のようにAPIの実行結果もちゃんと含まれていることが分かります。
これで、GoogleやTwitterのクローラーが適切にHTMLを読み込むのでSEOやOGPの観点でもちゃんとJSの実行結果が反映されます。
<body>
<noscript>
<strong>We're sorry but Example of SSG doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app">
<div id="nav">
<a href="/" class="router-link-active">Home</a>
| <a href="/about" class="router-link-exact-active router-link-active" aria-current="page">About</a>
</div>
<div class="about">
<h1>About</h1>
<p id="my-message">Hello, API</p>
</div>
</div>
<script src="/js/chunk-vendors.de7b539e.js"></script>
<script src="/js/index.c7547cd1.js"></script>
</body>
renderAfterDocumentEventで遅い非同期処理に対応
実は上記は不備があります。それはAPI呼び出しなどの非同期処理に時間が掛かってしまうと、プリレンダリングに使われてるChromeがAPIの結果反映を待たずにHTMLを出力してしまうからです。
試してみましょう。ダミーAPIのコマンドを以下のように変えて5秒ほど待ち時間を入れます。
$ httpd4test -p 5000 -m '{"message":"Hello, API"}' --json --interval 5000
この状態でビルドするとHTMLの結果は以下のようになります。
$ npm run build
$ cat dist/about/index.html|tidy
...
<div class="about">
<h1>About</h1>
<p id="my-message"></p>
</div>
...
これに対応するためにはrenderAfterDocumentEvent
を使います。これは任意のイベントが発生するまでプリレンダリングの処理を待つメソッドです。類似のメソッドにrenderAfterElementExists
とかrenderAfterTime
もあります。vue.config.js
を以下のように改修します。
const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer
module.exports = {
pages: {
index: {
entry: "src/main.js",
title: "Example of SSG",
},
},
configureWebpack: () => {
if (process.env.NODE_ENV === 'production') {
return {
plugins: [
new PrerenderSPAPlugin({
staticDir: path.join(__dirname, 'dist'),
routes: ['/about'],
renderer: new Renderer({
renderAfterDocumentEvent: 'on-render-my-api'
})
})
]
}
}
},
};
続いて、APIコールが完了した時点でon-render-my-api
イベントを発火するようにsrc/views/About.vue
を改修します。
created: function () {
axios.get("http://localhost:5000/").then((response) => {
console.log(response.data);
this.message = response.data.message;
document.dispatchEvent(new Event("on-render-my-api"));
});
},
この書き方だと厳密にはレンダリングに時間が掛かり過ぎたときにプリレンダリングが上手くいかない事がありますが、まあ今回はいったんOKとします。
それではこれをビルドします。
$ npm run build
$ cat dist/about/index.html|tidy
...
<div class="about">
<h1>About</h1>
<p id="my-message">Hello, API</p>
</div>
...
APIの完了を待つのでビルドの時間が伸びていますが、適切にAPIの実行結果がHTMLに吐かれたのが分かると思います。
当初の課題であった 「APIの呼び出し結果がTwitterやGoogleから見えない」 というSPAでの課題はこれで無事に解決です。
APIの二重呼び出し問題
発生事象
「APIの呼び出し結果がTwitterやGoogleから見えない」 という当初の課題はPrerender SPA Pluginで無事解決したのですが人間欲が出るもので、別な問題が気になり始めました。
それは 「APIの二重呼び出し問題」 です。
上記の生成したHTMLをブラウザで確認してみます。
$ httpd4test -w dist -p 8000
HTMLには「Hello API」とビルド時に呼び出したAPIの結果が反映されている のですが、ブラウザの実際のレンダリングでは空白として表示 されています。少し時間を空けると表示されるのでどうやらHTMLの内容を無視してAPI呼び出しを行っているようです。
原因
原因は簡単というか当然で、静的HTMLとして生成されたファイルでもVue.jsのコードはそのまま実行されるのでレンダリングはVue.jsのテンプレートをベースに行われます。元のVue.jsのテンプレートは以下のようになっているのでdata.message
の値で常に上書きされる分けです。
<template>
<div class="about">
<h1>About</h1>
<p id="my-message">{{ message }}</p>
</div>
</template>
そしてdata
の値はビルド時から状態を引継いでる分けではないので常に空です。なので、HTMLに値が既に含まれていようがなかろうがDOMにレンダリングされる値は空になるわけですね。考えれ見れば当然の挙動です。
そもそも問題なの?
「ビルド時に呼ばれたAPIがHTMLに結果が含まれているにもかかわらずもう一度呼ばれること」 は問題なのでしょうか?
実のところ、問題になるケースはほとんどないと思います。APIコールコストがかかるといってもそれはSPAと同程度ですし、SEOやOGPなどクローラー対応もされているので問題になるケースはあまり無いんじゃないかな、と思います。
ただ私が作ってる個人プロジェクトではサーバレスな環境にAPIをデプロイしてるのですが初回アクセスに数秒かかるので、せっかくSSGしてるならなんとかならないかなー、というだけなんですよね。
仕事でやる場合はその時間が許容できないならAPI側の初回アクセスを速くする方に実際は力を注ぐと思います。やれば出来る方法もいくつかあるので。
まあ、なので最悪解決できなくても良いのですが、勉強も兼ねていくつか試してみました。
解決案1: DOMが空の時だけAPIコールをする
まず最初に思いついたのは「DOMが空の時だけAPIコールをする」というものです。すでにDOMに値が含まれているのでDOMに値があるか無いかをベースにAPIコールの有無を決めれば良いと考えました。
具体的には以下のようなコード.
created: function () {
if (document.getElementById("my-message").textContent == "") {
this.axios.get("http://localhost:5000/").then((response) => {
this.message = response.data.message;
});
} else {
this.message = document.getElementById("my-message").textContent;
}
},
なんか上手くいきそうな気がしませんか? しかし、こえはDOMが見つからないため実行時エラーになります。なぜならばcreated
時点ではDOMが構築されてないからです。
上記を見ると分かりやすいですがライフサイクルフックポイントであるcreated
ではVue.jsのコンポーネントは初期化されていますが、DOMはまだ構築されていません。であればmounted
ではどうかというと、このタイミングではすでにdata
の値がDOMにmountされているためtextContent
は空白を返します。
軽く調べてみた限りだと、仮想DOMにdataの値がmountされるタイミングをフックするようなメソッドは用意されてなさそうなので、仮にできてもかなりダーティなハックになりそうです。というわけでこの方向性は断念。
解決案2: Client Side Hydrationを使う
Vue.jsにはClient Side Hydrationという仕様がありdata-server-rendered=true
を指定することで、すでにサーバサイドでDOMを作ってるのでクライアント側での処理をスキップすることができるようです。
これを指定すれば行けるのでは? と思ったのですがそれでもdata.message
の値で初期化されてしまいます。
どうもClient Side Hydrationはcreated
はあくまでDOMの作成をしないだけの仕様のようなのでクライアント側でdataの値をバインドされてれば正しくバインドされた内容に置き換えてしまいます。あくまでSSRで活用するもので今回のケースにはあまり有効そうでは無さそうでした。
同じこと考えてた人は下記にもいてコメントで「やりたいことは出来ないよ」と諭されていました。
解決案3: vue-server-rendererを使う
最後にvue-server-rendererを使うを使う方法。
この方法は試していませんが、たぶん上手くいきます。
サーバサイドでしか実行しないJSを定義して場合によってはステートをクライアントに伝搬させることでそもそもクライアントサイドにはAPIコール自体実装しない 方式です。
つまりSSRな環境をまず作り、それをプリレンダリングしてHTMLを出力することでSSGを実現するわけですね。この方法ならAPIはサーバサイドにあるのでビルド時にしか呼ばれませんし、dataのbinding周りもうまく処理できそうです。なんかの記事で 「SSGには基礎技術としてSSRが必要」 的なのを見た記憶があるのですが「こういうことかー」という感じ。
ただ、vue-server-rendererでユニバーサルなコード書いたりするのも中々めんどそうだったので、大人しくそういう目的に最適化されているNuxt.jsを試すことにしました。
Nuxt.jsでのSSG
プロジェクトの作成
以下でNuxt.jsのプロジェクトを作成しています。選択肢は好みだと思いますが、レンダリングモードとデプロイターゲットはおそらく下記にする必要があります。
$ npm init nuxt-app example-nuxt
...
? Nuxt.js modules: Axios
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/JAMStack hosting)
...
Nuxt.jsでビルド時のみのAPIコールを実装
つづいてpages/index.vue
を以下のように書き換えてAPIコールを実装します。
ポイントはcreated
ではなくasyncData
にフックポイントを変えている事です。「asyncDataは JAMStack指定ならビルド時に実行されることが保証」 されてるようなのでこちらを使うことで難なく 2重API呼び出し問題を解決 する事ができます。
<template>
<div class="about">
<h1>About</h1>
<p id="my-message">{{ message }}</p>
</div>
</template>
<script>
export default {
data() {
return {
message: "",
};
},
async asyncData({ $axios }) {
// $ prefixed methods is shortcuts to directly get data
let data = await $axios.$get("http://localhost:5000/");
console.log(data);
return { message: data.message };
},
};
</script>
それではrun dev
モードで起動します。
$ npm run dev
...
ℹ Initial build may take a while 00:59:59
✔ Builder initialized 00:59:59
✔ Nuxt files generated 00:59:59
✔ Client
Compiled successfully in 2.00s
✔ Server
Compiled successfully in 1.69s
...
クライアントとサーバサイドの両方がビルドされているのが分かりますね。
ブラウザで確認すると以下のようにHTMLの結果がそのまま表示されているのが分かります。開発モードではアクセス時にビルドが走るのでのタイミングでAPI呼び出しの時間を待ちます。
また、サーバ側のログでconsole.log(data)
がサーバサイドで実行されたことも分かります。
ℹ Listening on: http://localhost:3000/
Hello, API
静的ファイルを生成する
つづいて、開発モードではなく完全な静的ファイルを生成します。
$ npm run build
$ npm run generate
npm run start
でローカルWebサーバを立ち上げることができるのですが、node.jsに依存してないことを確認するためにあえてhttpd4test
でホスティングしてみます。
$ httpd4test -w dist -p 8000
HTMLを単純に表示しているうえに、特にJSが何も実行されてないことも確認できました。
めちゃくちゃ簡単ですね! これで生成されたファイルをCDNなどにホスティングするだけでAPIコールもなしにコンテンツを表示する事が出来ます。
まとめ
Vue.jsを利用したSPAとしてアプリケーションを作り始めましたが、やはりSSGをするならVue.jsをがんばるよりNuxt.jsを使う方が圧倒的に簡単そうです。
最終的に作りたいものはJAMStackになると思うので、Nuxt.jsを使うからGridsomeなど別なVue.js互換のFWを使うかは別途検討ですがとりあえずはNuxt.jsを試してみたいと思います。
Next.jsの方が評判は良さそうですが今のコードがすでにVue.jsベースなので今回はいったんVue系で。
しかしNuxt.jsは本当に少し触っただけですがVueだと色々組み合わせたり頑張らないといけないところが足されてますし、流行るのも納得という感じですね。その分、魔法も多く正しく挙動を理解するのは大変そうですがそれは正直Vue.jsの時点で。。。 という感じもしますしね。
もしかしたら他言語のFWの歴史を見るにアンチテーゼ的なシンプルなものが今後一定のシェアを取るのかも?
それではHappy Hacking!
Discussion