簡単にVue.jsでサーバーサイドレンダリングを試してみた

Vue.js 2からはVirtualDOMになったのでサーバー側でHTMLを組み立てられます。 :tada: 

サーバーサイドレンダリング(SSR)のメリットとしてjsを実行してくれないクローラー向けのSEO対策だったり、スペックがあまり高くない端末向けにjsの実行部分を減らしたいと言った用途に使います。

今回は`vue-cli`の`webpack-simple`をSSRで実行してみたいと思います。

# 環境

Macです。

```bash
$ uname -mprsv
Darwin 16.1.0 Darwin Kernel Version 16.1.0: Thu Oct 13 21:26:57 PDT 2016; root:xnu-3789.21.3~60/RELEASE_X86_64 x86_64 i386
$ node -v
v7.0.0
$ npm -v
3.10.8
```

# 雛形を作る

vueには雛形を作るためのツールとして`vue-cli`が提供されています。
webpackやbrowserifyを使ったテンプレートが用意されています。
また自分でテンプレートを作ることもできます。

今回は用意されている`webpack-simple`というテンプレートからやってみます。

インストールは`npm install -g vue-cli`でインストールができます。

雛形は以下のコマンドで作成できます。

```bash
$ vue -V
2.4.0
$ vue init webpack-simple vue-server-renderer-test

  This will install Vue 2.x version of template.

  For Vue 1.x use: vue init webpack-simple#1.0 vue-server-renderer-test

? Project name vue-server-renderer-test
? Project description A Vue.js project
? Author Kazuhiro Kubota <k2.wanko@gmail.com>

   vue-cli · Generated "vue-server-renderer-test".

   To get started:

     cd vue-server-renderer-test
     npm install
     npm run dev
```

幾つか入力を求められますので必要に応じて入力してください。
今回はエンター連打で良いと思います。

作成できたら以下のコマンド動作確認までしましょう

```bash
$ cd vue-server-renderer-test
$ npm install
$ npm run dev
```

`npm run dev`を打つとデフォルトのブラウザが立ち上がり、`http://localhost:8080/`にアクセスされます。

# SSR用に書き換える

ここからSSR用にファイルを幾つか書き換えていきます。

## src/main.js

main.jsはエントリーポイントとなるjsでVueをmountしている箇所です。
修正前のmain.jsは以下です。

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

new Vue({
  el: '#app',
  render: h => h(App)
})

```

これを以下のコードの様に書き換えます。

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

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

if (typeof window !== 'undefined') {
  createApp().$mount('#app')
} else if (typeof module !== 'undefined' && module.exports) {
  module.exports = createApp
}

```

Vueのインスタンスを作るところを`createApp`で囲って
ブラウザが読み込んだ場合はインスタンスを作ってマウントします。
Node.jsで読み込んだ場合は`module.exports`に`createApp`をセットするようにしています。
`el`プロパティは書かないことでバインディングを自分の任意のタイミングに持っていけます。

## webpack.config.js

webpack.config.jsはwebpackの設定ファイルです。
現在の状態はブラウザだけなので、ここにserver用の設定を加えます。

まずは`module.exports`となってるところをclientに置き換えます。

```diff
diff --git a/webpack.config.js b/webpack.config.js
index 5eef813..462ae0f 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,7 +1,7 @@
 var path = require('path')
 var webpack = require('webpack')
 
-module.exports = {
+const client = {
   entry: './src/main.js',
   output: {
     path: path.resolve(__dirname, './dist'),
@@ -44,9 +44,9 @@ module.exports = {
 }
 
 if (process.env.NODE_ENV === 'production') {
-  module.exports.devtool = '#source-map'
+  client.devtool = '#source-map'
   // http://vue-loader.vuejs.org/en/workflow/production.html
-  module.exports.plugins = (module.exports.plugins || []).concat([
+  client.plugins = (client.plugins || []).concat([
     new webpack.DefinePlugin({
       'process.env': {
         NODE_ENV: '"production"'
@@ -62,3 +62,22 @@ if (process.env.NODE_ENV === 'production') {
     })
   ])
 }

```

次にserver用の設定を追加します。

```js
+var server = Object.assign({}, client, {
  target: 'node',
  devtool: false,
  output: Object.assign({}, client.output, {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  }),
  externals: Object.keys(require('./package.json').dependencies)
})

```

ポイントは`target`を`node`にして`output.libraryTarget`を`commonjs2`にします。

最後に`module.exports = [client, server]`を追加します。

ここまで来たら`npm run build`でビルドが通るか確認しておきましょう。

[webpack.config.js全体](https://github.com/k2wanko/vue-server-renderer-test/blob/master/webpack.config.js)

# レンダリング

次にレンダリングするサーバーを用意します。
フレームワークはexpress.jsを使います。

server.jsを作成します。

```js:server.js
process.env.VUE_ENV = 'server'

const fs = require('fs')
const path = require('path')

const express = require('express')
const app = module.exports = express()


app.use('/dist', express.static(
  path.resolve(__dirname, 'dist')
))

const layout = fs.readFileSync('index.html', 'utf-8')
const createApp = require('./dist/server-bundle.js')
const renderer = require('vue-server-renderer').createRenderer()

app.get('*', (req, res) => {
  const vm = createApp()
  renderer.renderToString(vm, (err, html) => {
    if (err) {
      throw err
    }
    res.send(layout.replace('<div id="app"></div>', html))
  })
})

const port = process.env.PORT || 3000
app.listen(port, err => {
  if (err) {
    throw err
  }
  console.log(`Server is running at localhost:${port}`)
})

```

`process.env.VUE_ENV = 'server'`はレンダリングの性能を良くするために設定するといいようです。設定されてないと警告がでます。

```js
app.use('/dist', express.static(
  path.resolve(__dirname, 'dist')
))
```

この部分はクライアントのjsを配信するためのコードです。webpack-simpleに合わせています。

```js
const layout = fs.readFileSync('index.html', 'utf-8')
const createApp = require('./dist/server-bundle.js')
const renderer = require('vue-server-renderer').createRenderer()

app.get('*', (req, res) => {
  const vm = createApp()
  renderer.renderToString(vm, (err, html) => {
    if (err) {
      throw err
    }
    res.send(layout.replace('<div id="app"></div>', html))
  })
})
```

`require('vue-server-renderer').createRenderer()`で`renderer`を作ります。

`renderToString`は第一引数にVueのインスタンスを渡してコールバックでレンダリングしたhtmlを受け取れます。

requestを元に受け取ったhtmlを元のindex.htmlのバインディングしてる部分を置き換えます。

`node server`で実行できます。

`http://localhost:3000/`にアクセスすると`npm run dev`で立ち上げたサーバーと同じものが表示されると思います。

右クリックでソースを表示すると以下のようにレンダリングされっていることが確認できます。

```html:view-source
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>vue-server-renderer-test</title>
  </head>
  <body>
    <div id="app" server-rendered="true"><img src="/dist/logo.png?82b9c7a5a3f405032b1db71a25f67021"> <h1>Welcome to Your Vue.js App</h1> <h2>Essential Links</h2> <ul><li><a href="https://vuejs.org" target="_blank">Core Docs</a></li> <li><a href="https://forum.vuejs.org" target="_blank">Forum</a></li> <li><a href="https://gitter.im/vuejs/vue" target="_blank">Gitter Chat</a></li> <li><a href="https://twitter.com/vuejs" target="_blank">Twitter</a></li></ul> <h2>Ecosystem</h2> <ul><li><a href="http://router.vuejs.org/" target="_blank">vue-router</a></li> <li><a href="http://vuex.vuejs.org/" target="_blank">vuex</a></li> <li><a href="http://vue-loader.vuejs.org/" target="_blank">vue-loader</a></li> <li><a href="https://github.com/vuejs/awesome-vue" target="_blank">awesome-vue</a></li></ul></div>
    <script src="/dist/build.js"></script>
  </body>
</html>

```
## レンダーストリーム

`renderToString`はレンダリングが終わるのを待つ必要がありますが
`renderToStream`を使えば順次データを送ることができます。

まず元となるhtmlを分割しておきます。

```js
const html = (() => {
  const target = '<div id="app"></div>'
  const i = layout.indexOf(target)
  return {
    head: layout.slice(0, i),
    tail: layout.slice(i + target.length)
  }
})()
```

次にハンドラーを以下の様に書き換えます。

```js
app.get('*', (req, res) => {
  const vm = createApp()
  const stream = renderer.renderToStream(vm)

  res.write(html.head)
  stream.on('data', chunk => {
    res.write(chunk)
  })
  stream.on('end', () => {
    res.end(html.tail)
  })
  stream.on('error', err => {
    console.error(err)
    res.status(500).send('Server Error')
  })
})
```

`res.write(html.head)`で先頭のhtmlを送り、`stream.on('data', callback)`で受け取ったデータをクライアントに流していきます。`stream.on('end', callback)`で最後のhtmlを送っています。


# Tips

## スタイルが適応されるまえのhtmlが表示される時の対策

vueファイルに`<style>`を書いている場合レンダリングするとスタイルが適応されるまえのhtmlが表示されてしまいます。
これを抑制するために`index.html`に以下のcssをheadに追加することでスタイルが適応されるまえの状態を隠して置けます。

```css
#app[server-rendered=true] {
  display: none;
}
```

vue側でmountされるとserver-renderedが削除されるのでスタイルが準備できた段階で自動的に表示されるようになります。

## クライアントサイド ハイドレーションとその注意点

ハイドレーションとは足りないDOMを補う的な意味合い使われているようです。

サーバーサイドレンダリングでルートのエレメントに`server-rendered="true"`が追加されているのは、これを元にVueが新しいDOMを生成する代わりに既存のDOMを取り込むためです。

開発環境ではVirtual DOMと差分を比較して差異があれば再構築します。
プロダクション環境では比較しないようです。

注意点として以下のようなhtmlの場合

```html
<table>
  <tr><td>hi</td></tr>
</table>
```

ブラウザは`<tbody>`を自動挿入するのでVirtual DOMと一致しなくなってしまいます。


<img width="152" alt="スクリーンショット 2016-10-31 17.10.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/15818/3d5245a2-26af-4270-1818-8ad1698feec3.png">

スクリーンショットはChrome

なのでブラウザの挙動に合わせて正しいhtmlを書きましょう。

# bundleRenderer

単純にcreateRendererを使うやり方だとインスタンス化されたモジュールが全てのリクエストで共有されてしまい、他のリクエストに影響を与える可能性があります。

`bundleRenderer`はVueをサンドボックス化してくれるので全体でモジュールを共有しないようにできます。

main.jsとserver.jsを以下の様に書き換えます。

```diff
diff --git a/src/main.js b/src/main.js
index 26be880..b3b31d3 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,5 +10,8 @@ function createApp() {
 if (typeof window !== 'undefined') {
   createApp().$mount('#app')
 } else if (typeof module !== 'undefined' && module.exports) {
-  module.exports = createApp
+  const app = createApp()
+  module.exports = context => {
+    return Promise.resolve(app)
+  }
 }

```

```diff
diff --git a/server.js b/server.js
index f49db9d..00a9352 100644
--- a/server.js
+++ b/server.js
@@ -12,8 +12,8 @@ app.use('/dist', express.static(
 ))
 
 const layout = fs.readFileSync('index.html', 'utf-8')
-const createApp = require('./dist/server-bundle.js')
-const renderer = require('vue-server-renderer').createRenderer()
+const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
+const renderer = createBundleRenderer(fs.readFileSync('./dist/server-bundle.js', 'utf-8'))
 
 const html = (() => {
   const target = '<div id="app"></div>'
@@ -25,8 +25,7 @@ const html = (() => {
 })()
 
 app.get('*', (req, res) => {
-  const vm = createApp()
-  const stream = renderer.renderToStream(vm)
+  const context = {}
+  const stream = renderer.renderToStream(context)
 
   res.write(html.head)
   stream.on('data', chunk => {

```


`npm run build && node server`で問題なく実行できればOKです。
今回はcontextに特に渡すものがないので空ですが
vue-routerを使っていればurlをセットするのがいいようです。

# まとめ

- SSRは導入は結構簡単そう
    - 大規模に使う場合はどうなのかちょっとわからないけど
    - でもStaticなCDNで配信もできそうだしいいかもしれない
- SSRにすべきサイトかどうかは考えて使ったほうが良さそう（ダッシュボード系のSEO対策もモバイル向け対策もいらない場合はなくてもいいと思う）
- 詳しくは[GitHub](https://github.com/k2wanko/vue-server-renderer-test)を見てください。

他にもコンポーネントのキャッシュなどの機能もあります。

プロダクションに必要なことは全て[vue-hackernews-2.0](https://github.com/vuejs/vue-hackernews-2.0)に詰まってそうなのでこちらも合わせて参考にするとよさそうでした。

来年もVue.jsを使って色々やっていって勉強会とかで発表できればいいなと思います！

明日は @acairojuni さんです！

# 参考

https://vuejs.org/guide/ssr.html
https://www.npmjs.com/package/vue-server-renderer