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

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

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

その技術構成と苦労した点を綴っていこうと思います。
# 作ったサイトとリポジトリ
サイト：[https://konkarin.photo](https://konkarin.photo)
GitHub：[https://github.com/konkarin/portfolio](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
![configuration.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1245658/f9be89dc-ca86-69d2-ba1c-9bcae0aa82b9.png)


## 大まかな処理フロー
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の公式ドキュメント](https://ja.nuxtjs.org/)をご確認ください。
## `generate.routes()`でFirestoreからデータを取得
記事ページは`/articles/_article.vue`のような動的ルーティングとなるので、SSGでは全て事前に生成する必要があります。

[payload-による動的ルーティング生成の高速化](https://ja.nuxtjs.org/docs/2.x/configuration-glossary/configuration-generate/#payload-%E3%81%AB%E3%82%88%E3%82%8B%E5%8B%95%E7%9A%84%E3%83%AB%E3%83%BC%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E7%94%9F%E6%88%90%E3%81%AE%E9%AB%98%E9%80%9F%E5%8C%96)

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

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

詳細は下記のリポジトリを参照ください。
[nuxt.config.ts](https://github.com/konkarin/portfolio/blob/develop/nuxt.config.ts#L99)
[routes.ts](https://github.com/konkarin/portfolio/blob/develop/routes.ts#L6)


また、これらpayloadはnuxt generate時に、各ページのディレクトリ配下に`payload.js`として、下記のような形で静的化されます。
![payload.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1245658/7e8886a9-51e7-85a1-8d51-8d43183089c1.png)

## 取得したpayloadをpagesコンポーネントの`asyncData()`で取得
先程取得したpayloadを`asyncData({ payload })`で取得します。

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

こうすることで、nuxt generate時に`asyncData()`が実行され、取得したpayloadのデータが各ページで生成される`index.html`に反映されます。
## `nuxtServerInit`で各ページ用の共通のstateを取得
今回は記事一覧ページと記事ページがあり、それぞれのページのサイドメニューに共通で、最新記事や記事に付けたタグ一覧などを用意しています。

[記事一覧ページ](https://konkarin-photo.web.app/articles)

![state.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1245658/17b12af1-64e9-b342-c1d2-dd6141528707.png)


これらを表示するために、nuxt generate時にStoreにstateを持たせておく必要があります。
Nuxtには`nuxtServerInit`というサーバーサイドで使えるStoreのアクションがあり、nuxt generate時には、すべてのページで実行されます。
[nuxtServerInitアクション - NuxtJS](https://ja.nuxtjs.org/docs/2.x/directory-structure/store/#nuxtserverinit-%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3)

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

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

詳細は下記リポジトリをご覧ください。
[store/index.ts](https://github.com/konkarin/portfolio/blob/develop/app/store/index.ts#L71)

ここで取得したデータは、ページごとの`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](https://s3.us-west-2.amazonaws.com/secure.notion-static.com/f57d906c-6b44-4ac3-b38c-c73cc879252b/Untitled.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAT73L2G45O3KS52Y5%2F20210326%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20210326T155242Z&X-Amz-Expires=86400&X-Amz-Signature=c2473c8b4a5715eed3a6b8e92d0bca19d439ef41e653a2bc3a5cb8cbeda5d319&X-Amz-SignedHeaders=host&response-content-disposition=filename%20%3D%22Untitled.png%22)
## Cloud FunctionsのFirestore関数トリガーでCircleCIのジョブトリガーAPIを叩く
今回はすべてのページをSSGで静的かしているため、記事を更新する度に記事ページを生成し、Firebase Hostingへデプロイする必要があります。

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

[Cloud Firestore関数トリガー | Firebase](https://firebase.google.com/docs/functions/firestore-events?hl=ja#function_triggers)
[Trigger a new Job with a Branch - CircleCI API Reference](https://circleci.com/docs/api/#trigger-a-new-job-with-a-branch)

# 苦労した点
### `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`に遷移します。

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

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

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

```ts:_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などで、普段の生活などは自身のブログにて発信していこうと思います。
ありがとうございました。