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

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

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

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

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

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

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

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

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

Cloud Functions for Firebase について

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

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

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

環境

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

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

今回のプロジェクトはGitHubに公開してますのでそちらを参考にしてみてください。

セットアップ

Firebase Consoleでまずプロジェクトを作成します。

次にvue-cliを使ってVueのプロジェクトを作ります。

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はクライアント、サーバ両方から参照するファイルです。

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

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

次にクライアントです。

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

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

次はサーバです。

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

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

webpac.config.jsの編集

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

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

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

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

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]'
    }),
  ])
}

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

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関数を作ります。

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

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

次にfunctions/index.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を次のように編集します。

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

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

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

ローカルでの確認

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

$ firebase serve --only hosting,functions

デプロイ

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

$ firebase deploy

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

まとめ

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

細かいAPIとかは公式のサーバサイドレンダリングのドキュメントを見るとよいでしょう。

VueRouterやVuexを使ったサンプルはVue HackaNews 2.0をHostingで動かしてみたのでそちらを参考にしてみてください。

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

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

1473681695
Go/JavaScript/Firebase/GCP/Securityとかが好きです。