74
91

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Vue.jsAdvent Calendar 2016

Day 1

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

Last updated at Posted at 2016-11-30

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

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

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

環境

Macです。

$ 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でインストールができます。

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

$ 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

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

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

$ 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は以下です。

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

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

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

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.exportscreateAppをセットするようにしています。
elプロパティは書かないことでバインディングを自分の任意のタイミングに持っていけます。

webpack.config.js

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

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

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用の設定を追加します。

+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)
})

ポイントはtargetnodeにしてoutput.libraryTargetcommonjs2にします。

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

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

webpack.config.js全体

レンダリング

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

server.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'はレンダリングの性能を良くするために設定するといいようです。設定されてないと警告がでます。

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

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

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で立ち上げたサーバーと同じものが表示されると思います。

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

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を分割しておきます。

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)
  }
})()

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

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に追加することでスタイルが適応されるまえの状態を隠して置けます。

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

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

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

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

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

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

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

<table>
  <tr><td>hi</td></tr>
</table>

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

スクリーンショット 2016-10-31 17.10.05.png

スクリーンショットはChrome

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

bundleRenderer

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

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

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

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 --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を見てください。

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

プロダクションに必要なことは全てvue-hackernews-2.0に詰まってそうなのでこちらも合わせて参考にするとよさそうでした。

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

明日は @acairojuni さんです!

参考

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

74
91
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
91

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?