Vue.js サーバサイドレンダリング on Cloud Functions for Firebase

去年の今頃にNode.jsとGoでサーバサイドレンダリングをしました。
２０１７年になって自分の中でこうやってサーバサイドレンダリングすればいいなという一つの答えが出たことを書きます。

# サーバサイドレンダリングの必要性

サーバサイドレンダリングをやる理由に２つ理由があると思います。
１つはSEO、もう１つが表示の高速化です。

もしGoogleからのアクセスのみを考えているのであればあまりサーバサイドレンダリングするメリットは薄いかもしれません。

GoogleのクローラはJavaScriptも解釈してくれると言われています。
それとsitemapを設定してもindexをしてくれるので個人的にSEOのためにサーバサイドレンダリングすることはとくにないです。

次に表示の高速化のためですがこれはファーストペイントの高速化のためにサーバサイドレンダリングするべきかですが

これはその通りでサイトアクセスした時に白い画面が表示されてからコンテンツが表示されるよりアクセスした瞬間からなにかしらのレイアウトやコンテンツが見えたほうがユーザ体験はよいと考えています。

サーバサイドレンダリングをするということはレンダリングサーバを用意しなければいけないのでその分サーバのコストも掛かりますしスケールをさせる方法も考える必要があります。

サーバサイドレンダリングをするかどうかはこの辺を元に判断するべきと言う話を今年はよく聞きました。

# Cloud Functions for Firebase について

Cloud Functionsはイベント駆動のNode.jsのホスティングサービスです。

2016年と2017年の違いはまさにこれでCloud Functionsがあるかないかで、
５月のGoogle IOでオープンベータで使えるようになりなおかつFirebase Hostingというホスティングサービスと連携することで動的にページを生成することができるようになりました。

この登場で金さえあればスケールするNode.jsの環境は簡単に手に入るようになりました。

# 環境

サーバサイドレンダリングを試した環境です。

Mac OSX: Sierra
Node.js: v8.9.1
Vue: 2.5.9

今回のプロジェクトは[GitHub](https://github.com/k2wanko/vue-webpack-simple-rendering)に公開してますのでそちらを参考にしてみてください。

# セットアップ

[Firebase Console](https://console.firebase.google.com)でまずプロジェクトを作成します。

次に[vue-cli](https://npmjs.org/package/vue-cli)を使ってVueのプロジェクトを作ります。

```bash
npm install --global vue-cli firebase-tools

# Setup for Firebase
$ firebase login

# Setup for Vue Project
$ mkdirp ssr-project; cd ssr-project
$ vue init webpack-simple
$ firebase init # FunctionsとHostingにチェックを入れる
```

# エントリーポイントの作成

次にクライアント用とサーバサイド用のエントリーファイルを作成します。

まずmain.jsはクライアント、サーバ両方から参照するファイルです。

```js:main.js
import Vue from 'vue'
import App from './App.vue'

export function createApp() {
  return new Vue({
    render: h => h(App)
  })
}

```

次にクライアントです。

```js:client.js
import { createApp } from './main'

const app = createApp()
app.$mount('#app')
```

次はサーバです。

```js:server.js
import { createApp } from './main'

export default context => {
    return new Promise((resolve, reject) => {
        const app = createApp()
        resolve(app)
    })
}
```

# webpack.config.jsの編集

次はwebpackでサーバサイドレンダリングに必要なバンドルファイルをビルドします。

まずは必要なパッケージのインストールです。

```bash
$ npm install --save-dev vue-server-renderer extract-text-webpack-plugin webpack-merge html-webpack-plugin webpack-node-externals
```

次にクライアント側のマニフェストファイルを生成してくれるwebpackです。

```js:webpack.config.js
var path = require('path')
var webpack = require('webpack')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
  entry: './src/client.js',
  output: {
    path: path.resolve(__dirname, 'functions/public', './dist'),
    publicPath: '/dist/',
    filename: 'build.js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
          }
          // other vue-loader options go here
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'file-loader',
        options: {
          name: '[name].[ext]?[hash]'
        }
      },
      {
        test: /\.css$/,
        use: process.env.NODE_ENV === 'production'
          ? ExtractTextPlugin.extract({
            use: 'css-loader?minimize',
            fallback: 'vue-style-loader'
          })
          : ['vue-style-loader', 'css-loader']
      },
    ]
  },
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  },
  devServer: {
    historyApiFallback: true,
    noInfo: true
  },
  performance: {
    hints: false
  },
  devtool: '#eval-source-map',
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin(),
  ],
}

if (process.env.NODE_ENV === 'production') {
  module.exports.devtool = '#source-map'
  // http://vue-loader.vuejs.org/en/workflow/production.html
  module.exports.plugins = (module.exports.plugins || []).concat([
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"'
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
      compress: {
        warnings: false
      }
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    }),
    new ExtractTextPlugin({
      filename: '[name].css?[hash]'
    }),
  ])
}

```

次にサーバサイドで使うバンドルファイルの生成です。

```js:webpack.config.server.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const nodeExternals = require('webpack-node-externals')

const clientConfig = Object.assign({}, require('./webpack.config'))
clientConfig.plugins = []

module.exports = merge(clientConfig, {
    target: 'node',
    devtool: 'none',
    entry: './src/server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2',
    },
    externals: nodeExternals({
        whitelist: /\.css$/
    }),
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"server"'
        }),
        new VueSSRServerPlugin(),
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: 'src/index.template.html',
            inject: false,
            minify: process.env.NODE_ENV === 'production' ? {
                html5: true,
                collapseWhitespace: true,
            } : false,
        }),
    ],
})
```

HtmlWebpackPluginなどはVueSSRServerPluginより後に書かないと一緒にバンドルされてしまうので注意しましょう。

# Cloud Functionsの関数の作成

次にCloud Functionsで`index関数`を作ります。

必要なパッケージのインストールは次の通りです。

```bash
$ cd functions
$ npm install --save lru-cache vue vue-server-renderer
```

次に`functions/index.js`を編集します。

```js:index.js
const functions = require('firebase-functions')

const fs = require('fs')
const path = require('path')
const { createBundleRenderer } = require('vue-server-renderer')
const LRU = require('lru-cache')
const resolve = file => path.resolve(__dirname, file)
const template = fs.readFileSync(resolve('./public/dist/index.html'), 'utf-8')


const bundle = require('./public/dist/vue-ssr-server-bundle.json')
const clientManifest = require('./public/dist/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(bundle, {
    clientManifest,
    template,
    cache: LRU({
        max: 1000,
        maxAge: 1000 * 60 * 15
    }),
    basedir: resolve('./public/dist'),
    runInNewContext: false
})


// // Create and Deploy Your First Cloud Functions
// // https://firebase.google.com/docs/functions/write-firebase-functions
//
exports.index = functions.https.onRequest((req, res) => {
    res.setHeader("Content-Type", "text/html")

    const context = {
        url: req.url
    }

    renderer.renderToString(context, (err, html) => {
        const handleError = err => {
            res.status(500).end('500 | Internal Server Error')
            console.error(`error during render : ${req.url}`)
            console.error(err.stack)
        }
        if (err) {
            return handleError(err)
        }
        res.end(html)
    })
})

```


# firebase.jsonの修正

firebase.jsonを次のように編集します。

```json
{
  "hosting": {
    "public": "functions/public",
    "rewrites": [
      {
        "source": "**",
        "function": "index"
      }
    ]
  }
}

```

次の`rewrites`の書き方は全てのRequestをindex関数を呼ぶようにします。

```
    "rewrites": [
      {
        "source": "**",
        "function": "index"
      }
    ]
```

# ローカルでの確認

まず`npm run build`で必要なファイルを書き出します。
次のコマンドで開発サーバを立ち上げると`http://localhost:5000`にサーバが立つので確認してみてください。

```bash
$ firebase serve --only hosting,functions
```

# デプロイ

ここまで行けばデプロイは次のコマンドで完了です。

```bash
$ firebase deploy
```

たったこれだけでデプロイが完了します。

# まとめ

細かいことはあまり書かずにどうしたらFirebaseでサーバサイドレンダリングできるかだけ書きました。

細かいAPIとかは公式のサーバサイドレンダリングの[ドキュメント](https://ssr.vuejs.org)を見るとよいでしょう。

VueRouterやVuexを使ったサンプルは[Vue HackerNews 2.0](https://github.com/k2wanko/vue-hackernews-2.0/tree/firebase-hosting)をHostingで動かしてみたのでそちらを参考にしてみてください。

ちなみに今年のサーバサイドレンダリングで得た知見は「Node.js以外でサーバサイドレンダリングはつらい」です。

Firebase Hostingでサーバサイドレンダリングすると楽ちんなので使っていきましょう！