Vue.js+Firebaseで作ったSPAをNuxt.jsとCircleCIでJamstack化した


どうも初めまして。こんかりんと申します。
ちゃんとした技術記事を書くのは初めてになります。誤った点等がありましたら、ご指摘頂けますと幸いです。

さて、今回は表題の通り、以前Vue+Firebaseで作ったポートフォリオサイト(SPA)を、Nuxt.jsとCircleCIでJamstackなブログとしてリニューアルしました。

その技術構成と苦労した点を綴っていこうと思います。

作ったサイトとリポジトリ

サイト:https://konkarin-photo.web.app/
GitHub:https://github.com/konkarin/portfolio

なぜやったか?

  • Jamstackすごい!やってみたい!と思った
  • 気軽に自由にアウトプットできる場が欲しかった
  • 勉強がてら実用性のあるものを作りたかった

元々は転職する際のポートフォリオとして作ったVue+FirebaseのSPAでしたが、仕事でNuxtを使うようになり、Nuxtの幅の広さを知りプライベートでも何か作ってみたいと考えていました。

そこで、仕事ばかりでアウトプットする機会が全く無く、気軽にアウトプットできる場所が欲しかったので、なら自分で作ってみようと動き始めました。

また、JamstackはcontentfulやmicroCMSなどのヘッドレスCMSを使うことが多いかと思いますが、Firebase(Firestore)でもできるやろ!の精神で頑張ってみました。

技術スタック&構成図

  • Nuxt.js 2.14.6
  • Firebase 8.2.1
  • CircleCI 2.0
  • GitHub

大まかな処理フロー

  1. リモートリポジトリに変更をPush、GitHubでPR承認
  2. CircleCIがMergeを検知してビルド、Firebase Hostingへデプロイ
  3. アプリ側からFirestoreの記事データを更新
  4. Firestoreの更新を検知して、Cloud FunctionsがCircleCIのジョブトリガーAPIを叩く
  5. Firebase Hostingにのみ再デプロイ

ざっくりとこんな流れです。

何をやったか?

  1. Vue CLI→Nuxt.js
  2. SPA→SSG
  3. nuxt.configのgenerate.routes()でFirestoreからpayloadを取得
  4. 取得したpayloadをpagesコンポーネントのasyncData()で取得
  5. nuxtServerInitアクションで各ページ用のstateを取得
  6. Cloud FunctionsのFirestore関数トリガーでCircleCIのジョブトリガーAPIを叩く

1と2については割愛します。
Nuxt.jsの公式ドキュメントをご確認ください。

generate.routes()でFirestoreからデータを取得

記事ページは/articles/_article.vueのような動的ルーティングとなるので、SSGでは全て事前に生成する必要があります。

payload-による動的ルーティング生成の高速化

generate.routes()で下記のような配列を返します。

nuxt.config.ts
routes() {
  return [
    {
      route: `/articles/${article.id}`,
      payload: article,
    },
    ....
  ]
}

詳細は下記のリポジトリを参照ください。
nuxt.config.ts
routes.ts

また、これらpayloadはnuxt generate時に、各ページのディレクトリ配下にpayload.jsとして、下記のような形で静的化されます。

取得したpayloadをpagesコンポーネントのasyncData()で取得

先程取得したpayloadをasyncData({ payload })で取得します。

pages/_article.vue
asyncData({ payload }) {
  if (payload) {
    return {
      article: payload,
    }
  }
}

こうすることで、nuxt generate時にasyncData()が実行され、取得したpayloadのデータが各ページで生成されるindex.htmlに反映されます。

nuxtServerInitで各ページ用の共通のstateを取得

今回は記事一覧ページと記事ページがあり、それぞれのページのサイドメニューに共通で、最新記事や記事に付けたタグ一覧などを用意しています。

記事一覧ページ

これらを表示するために、nuxt generate時にStoreにstateを持たせておく必要があります。
NuxtにはnuxtServerInitというサーバーサイドで使えるStoreのアクションがあり、nuxt generate時には、すべてのページで実行されます。
nuxtServerInitアクション - NuxtJS

nuxtServerInitアクション内で最新記事やタグを取得します。

store/index.ts
export const actions = {
  async nuxtServerInit({ commit }) {
    const articles = await getArticles()
    commit('updateArticles', articles)
  }
}

詳細は下記リポジトリをご覧ください。
store/index.ts

ここで取得したデータは、ページごとのstate.jsとしてpayload.jsと同様に静的化され、nuxt generate時にindex.htmlに反映されます。
また、各ページのindex.html内のscriptタグで読み込まれます。
https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f57d906c-6b44-4ac3-b38c-c73cc879252b/Untitled.png

Cloud FunctionsのFirestore関数トリガーでCircleCIのジョブトリガーAPIを叩く

今回はすべてのページをSSGで静的かしているため、記事を更新する度に記事ページを生成し、Firebase Hostingへデプロイする必要があります。

記事更新(=Firestoreを更新)時に、Cloud FunctionsのFirestore関数トリガーのonWriteでCircleCIジョブ実行APIを叩き、Firebase Hostingへのデプロイ用のジョブを実行します。

Cloud Firestore関数トリガー | Firebase
Trigger a new Job with a Branch - CircleCI API Reference

苦労した点

generate.routes()のデバッグがめんどくさい

毎回buildやgenerateをして見たい値をconsole.log()で確認する手間があったので、便利なやり方があれば是非コメント等で教えていただけると嬉しいです。

asyncData, nuxtServerInitのデバッグがめんどくさい

上記と同様。

nuxt generate & startでデバッグしたら、Firebase Hosting上と挙動が違った

Nuxt.jsでは、generateされたファイル群がdist/に吐き出されます。
dist/はnuxt startでlocalhostにて確認することができます。

nuxt startは、あくまでも静的化されたindex.html等をlocalhostで表示しているだけで、Firebaseなどのホスティングサービスで設定しているrewritesの設定は無く、デプロイした状態とは全く違う挙動になっていました。

当然でした、愚かなり。

404の処理がSPAと違う

SPAでは全てが一つのindex.html上で完結しますが、SSGでは各ルーティングごとにindex.htmlが存在します。
Firebaseの設定は以下のようになっており、404の時にルートのindex.htmlに遷移します。

"rewrites": [
  {
    "source": "**",
    "destination": "/index.html"
  }
]

上記の設定で存在しない記事のページにアクセスした場合、ルートのindex.htmlに遷移するのですが、なぜかルートのindex.htmlの上に記事ページのサイドメニューなどが表示される現象が起きました。

詳細な挙動は整理できていないのですが、一旦はmounted()フックで、存在しない記事の場合に$nuxt.error()layouts/error.vueを表示するように対応しました。

_article.vue
mounted() {
  // 存在しない記事にアクセスしたらエラー
  const existsArtcile = this.articles.some(
    (article) => this.$route.params.article === article.id
  )
  if (!existsArtcile) this.$nuxt.error({ message: 'ページが見つかりません' })
}

懸念・改善点

  • 記事更新時、ビルド&デプロイに1分かかるため、更新後に記事が即反映されない。
  • 1つの記事更新のために、毎回全部generateしてデプロイするのはちょっとコストが高く感じる。
  • FirebaseSDKによってバンドルサイズが肥大化する

ちなみにCircleCIは週に250分程度であれば無料で利用できます。
stagingではガンガンCI/CD回していましたが、今回の用途くらいだと余裕で無料枠に収まるようです。
規模が大きくなってくるとやはりコスト面が気になってきそうですね。

次はGitHub Actionsや他のCI/CDツールも検討したいです🤔

また、FirebaseSDKのバンドルサイズに関しては、以前からGitHub上で議論があるようですが、現状回避策はないようです。
そのため、Cloud FunctionsやWeb APIなどを経由して読み書きするのが良いのかなと思っています。

まとめ

コードを書くこと自体はとても好きでしたが、なかなか機会とやる気が出ずアウトプットを怠っていました。
実際にやってみると自分自身の理解が深まり、復習にもなるのでやってよかったと思いました。

これからも技術情報は主にQiitaやZennなどで、普段の生活などは自身のブログにて発信していこうと思います。
ありがとうございました。