Firebase で Vue.js(QuasarFramework) の SSR をして Google にインデックスされるようにする


Firebase + Vue.js の構成で SSR (ServerSideRendering) をする実例や紹介記事はそれなりに出てきているが、たいていは Nuxt.js を使ったもの。今回は Vue.js をベースにしたフレームワーク QuasarFramework を使って Firebase で SSR する構成例を紹介する。

QuasarFramework v0.17.0 は SSR に対応しているので、それをうまいこと Cloud Functions で動かすようにすれば良い。しかも QuasarFramework の SSR は内部的には `vue-server-renderer` と `express` を使っているだけで、特別なことはしていない。つまり Nuxt.js と同じようなアプローチになる。

## 今回のデモとソース

- デモ https://quasar-ssr.firebaseapp.com/posts
- ソース https://github.com/zaru/quasar_ssr_firebase_sample

## QuasarFramework の SSR 対応

QuasarFramework で SSR に対応するには前回書いた記事があるので、それを参照してほしい。

- [QuasarFramework v0.17 で SSR を試す](https://qiita.com/zaru/items/0666c847a37d854b4cfc)

## Cloud Functions の作成

Cloud Functions の中に QuasarFramework でビルドした SSR 用のファイルを含める必要がある。そのためビルド先を `./functions` 以下に変更する。

### quasar.conf.js

QuasarFramework のビルド先を `functions/dist` に指定。

```js

module.exports = function (ctx) {
  return {
    build: {
      scopeHoisting: true,
      vueRouterMode: 'history',
      distDir: 'functions/dist', // 追加
    }
  }
}
```

### functions/index.js

続いて Cloud Functions を作っていく。

```js
const functions = require('firebase-functions');
const server = require('./server/server.js');

exports.server = functions.https.onRequest(server.handler);
```

### functions/server/server.js

これは QuasarFramework を SSR モードでビルドしたときに自動生成される `src-ssr/index.js` をほぼコピーして持ってきている。変更した点といえばビルドファイルの読み込みパスと express のサーバ起動をやめてアプリケーションを export しているくらい。

```js
const
  express = require('express'),
  compression = require('compression')

const
  ssr = require('../dist/ssr'),
  app = express(),
  port = process.env.PORT || 3000

const serve = (path, cache) => express.static(ssr.resolveWWW(path), {
  maxAge: cache ? 1000 * 60 * 60 * 24 * 30 : 0
})

// gzip
app.use(compression({ threshold: 0 }))

// serve this with no cache, if built with PWA:
if (ssr.settings.pwa) {
  app.use('/service-worker.js', serve('service-worker.js'))
}

// serve "www" folder
app.use('/', serve('.', true))


// this should be last get(), rendering with SSR
app.get('*', (req, res) => {
  res.setHeader('Content-Type', 'text/html')
  ssr.renderToString({ req, res }, (err, html) => {
    console.log('error', err)
    if (err) {
      if (err.url) {
        res.redirect(err.url)
      }
      else if (err.code === 404) {
        res.status(404).send('404 | Page Not Found')
      }
      else {
        // Render Error Page or Redirect
        res.status(500).send('500 | Internal Server Error')
        if (ssr.settings.debug) {
          console.error(`500 on ${req.url}`)
          console.error(err)
          console.error(err.stack)
        }
      }
    }
    else {
      res.send(html)
    }
  })
})

exports.handler = app;
```

### functions の package

QuasarFramework の SSR には色々なパッケージに依存しているので、それを Cloud Functions で使えるようにインストールする。

```bash
cd functions
npm install --save @babel/runtime axios compression core-js express \
  lru-cache regenerator-runtime vue vue-i18n vue-router vue-server-renderer vuex
```

### デプロイスクリプト

QuasarFramework のビルドで生成される `package.json` は必要ないため削除する。手動で削除するのが面倒くさいので、ビルドスクリプトを用意する。

```bash
#!/usr/bin/env bash

quasar build -m ssr
rm -rf ./functions/dist/package.json
firebase deploy
```

### Firebase の rewrite 設定

あとは Firebase にアクセスした際に Functions へ引き渡すように設定すれば完了。

#### firebase.json

`public` を QuasarFramework がビルドする静的ファイルのディレクトリ `functions/dist/www` を指定。 `rewrites` でアクセスを `server` function に渡すように設定。

```json
{
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint"
    ]
  },
  "hosting": {
    "public": "functions/dist/www",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "server"
      }
    ]
  }
}
```

これでデプロイが無事完了すれば SSR されるはず。

## Google クローラーの確認

Google SearchConsole で fetch してもらった HTML を念の為確認する。SSR しているものと通常の Ajax でレンダリングしたものを分けるために以下のように実装したもので試す。

```Post.vue
<template>
  <q-page padding>
    <qo-menu></qo-menu>
    <h1>Posts page</h1>
    <h2>SSR contents</h2>
    <ul>
      <li v-for="(tag, index) in getTags" :key="index">
        {{tag.id}}
      </li>
    </ul>
    <h2>Ajax contents</h2>
    <ul>
      <li v-for="(post, index) in getPosts" :key="index">
        {{post.id}}: {{post.title}}
      </li>
    </ul>
  </q-page>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
import QoMenu from '../components/QoMenu.vue'

export default {
  components: {
    QoMenu
  },
  preFetch ({ store, currentRoute, previousRoute, redirect, ssrContext }) {
    return store.dispatch('post/fetchTags')
  },
  computed: {
    ...mapGetters('post', [
      'getTags',
      'getPosts'
    ])
  },
  methods: {
    ...mapActions('post', [
      'fetchZaruPosts'
    ])
  },
  mounted () {
    this.fetchZaruPosts()
  }
}
</script>

<style>
</style>
```

`preFetch` で取得したデータは SSR の対象になるが、 `mounted` などで取得しているデータは SSR はされない。つまり一般的な SPA などと変わらない。

![SSR_Firebase_Sample.png](https://qiita-image-store.s3.amazonaws.com/0/2152/52aaa21b-1654-9325-182b-743c72fce57b.png "SSR_Firebase_Sample.png")

これを Google SearchConsole から手動クロールした結果の HTML を確認する。

![URL_検査.png](https://qiita-image-store.s3.amazonaws.com/0/2152/e1f4015f-69a0-0334-9a15-343321adc49b.png "URL_検査.png")

細かくてわかりにくいが `preFetch` で取得したデータに関してはちゃんと HTML としてレンダリングされ Google のクローラにも認識されている。Good!

### リンクを `<a>` タグにする

あとは Google クローラーが巡回しやすいように重要な動線を `<a>` タグを使い `href` で URL 指定すれば OK 。VueRouter であれば `<router-link>` を使えば良い。QuasarFramework は `<router-link>` をラップしたコンポーネントがたくさんあるので同じようにオプションを指定する。

```html
<router-link :to="{ name: 'home' }">Home</router-link>

<q-item link tag="a" :to="{ name: 'Home' }">
  <q-item-main>
    Home
  </q-item-main>
</q-item>
```

こうすることで以下のようにレンダリングされる。

```html
<a href="/">Home</a>
```

このようにしておかないと、せっかくクロールされても単体の孤立したページとして認識されてしまうので気をつけたい。

## Cloud Functions をキャッシュする

Cloud Functions はしばらくアクセスされないとインスタンスが削除されスリープ状態になる。つまり遅い。SEO やユーザ体験的に SSR したコンテンツの表示が遅いのは困るので、キャッシュさせることにする。幸いなことに Cloud Functions は Firebase Hosting の CDN に対応しているので、単純にキャッシュコントロールを指定すれば良い。キャッシュされては困るようなものは `preFetch` ではなく通常の Ajax で取得するようにすれば良い。

```js
app.get('*', (req, res) => {
  res.setHeader('Content-Type', 'text/html')
  res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=600')
  // 略
})
```

これで600秒間のキャッシュが作成され高速にレスポンスされる。最高。