🤿

レガシーおじさん、Vue.jsでSSGを頑張るのを諦めてNuxt.jsにする

2020/12/29に公開

はじめに

Vue.jsのSPAだとTwitterのサムネイルに使うOGP等が指定できないと以前記事を書いたのですが、コメントの中にNuxt.jsやNext.jsを使うと良いよ、とありました。コメントありがとうございました。
https://zenn.dev/koduki/articles/0fe6cc5ada58e5600f75

ただ、既存のアプリを違うFWに移行させるのはちょっと面倒だと思いPrerender SPA Pluginとかを使って、下記あたりを参考にVue.js+プリレンダリングで出来ないかと頑張ってみたけど諦めた、という記録になります。
https://qiita.com/mio3io/items/bd2d91fc2a7785f9022c

具体的にはビルド時に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が構築されてないからです。

https://jp.vuejs.org/v2/guide/instance.html#ライフサイクルダイアグラム

上記を見ると分かりやすいですがライフサイクルフックポイントである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で活用するもので今回のケースにはあまり有効そうでは無さそうでした。

同じこと考えてた人は下記にもいてコメントで「やりたいことは出来ないよ」と諭されていました。

https://stackoverflow.com/questions/42586666/why-is-the-server-side-rendered-content-replaced-by-vue

解決案3: vue-server-rendererを使う

最後にvue-server-rendererを使うを使う方法。
https://ssr.vuejs.org/

この方法は試していませんが、たぶん上手くいきます。
サーバサイドでしか実行しない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