どうも初めまして。こんかりんと申します。
ちゃんとした技術記事を書くのは初めてになります。誤った点等がありましたら、ご指摘頂けますと幸いです。
さて、今回は表題の通り、以前Vue+Firebaseで作ったポートフォリオサイト(SPA)を、Nuxt.jsとCircleCIでJamstackなブログとしてリニューアルしました。
その技術構成と苦労した点を綴っていこうと思います。
作ったサイトとリポジトリ
サイト:https://konkarin.photo
GitHub:https://github.com/konkarin/portfolio
なぜやったか?
- Jamstackすごい!やってみたい!と思った
- 気軽に自由にアウトプットできる場が欲しかった
- 勉強がてら実用性のあるものを作りたかった
元々は転職する際のポートフォリオとして作ったVue+FirebaseのSPAでしたが、仕事でNuxtを使うようになり、Nuxtの幅の広さを知りプライベートでも何か作ってみたいと考えていました。
そこで、仕事ばかりでアウトプットする機会が全く無く、気軽にアウトプットできる場所が欲しかったので、なら自分で作ってみようと動き始めました。
また、JamstackはcontentfulやmicroCMSなどのヘッドレスCMSを使うことが多いかと思いますが、Firebase(Firestore)でもできるやろ!の精神で頑張ってみました。
技術スタック&構成図
大まかな処理フロー
- リモートリポジトリに変更をPush、GitHubでPR承認
- CircleCIがMergeを検知してビルド、Firebase Hostingへデプロイ
- アプリ側からFirestoreの記事データを更新
- Firestoreの更新を検知して、Cloud FunctionsがCircleCIのジョブトリガーAPIを叩く
- Firebase Hostingにのみ再デプロイ
ざっくりとこんな流れです。
何をやったか?
- Vue CLI→Nuxt.js
- SPA→SSG
- nuxt.configの
generate.routes()
でFirestoreからpayloadを取得 - 取得したpayloadをpagesコンポーネントの
asyncData()
で取得 - nuxtServerInitアクションで各ページ用のstateを取得
- Cloud FunctionsのFirestore関数トリガーでCircleCIのジョブトリガーAPIを叩く
1と2については割愛します。
Nuxt.jsの公式ドキュメントをご確認ください。
generate.routes()
でFirestoreからデータを取得
記事ページは/articles/_article.vue
のような動的ルーティングとなるので、SSGでは全て事前に生成する必要があります。
generate.routes()
で下記のような配列を返します。
routes() {
return [
{
route: `/articles/${article.id}`,
payload: article,
},
....
]
}
詳細は下記のリポジトリを参照ください。
nuxt.config.ts
routes.ts
また、これらpayloadはnuxt generate時に、各ページのディレクトリ配下にpayload.js
として、下記のような形で静的化されます。
取得したpayloadをpagesコンポーネントのasyncData()
で取得
先程取得したpayloadをasyncData({ payload })
で取得します。
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
アクション内で最新記事やタグを取得します。
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タグで読み込まれます。
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
を表示するように対応しました。
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などで、普段の生活などは自身のブログにて発信していこうと思います。
ありがとうございました。