Vue.js 2からはVirtualDOMになったのでサーバー側でHTMLを組み立てられます。
サーバーサイドレンダリング(SSR)のメリットとしてjsを実行してくれないクローラー向けのSEO対策だったり、スペックがあまり高くない端末向けにjsの実行部分を減らしたいと言った用途に使います。
今回はvue-cli
のwebpack-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は以下です。
import Vue from 'vue'
import App from './App.vue'
new Vue({
el: '#app',
render: h => h(App)
})
これを以下のコードの様に書き換えます。
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 --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)
})
ポイントはtarget
をnode
にしてoutput.libraryTarget
をcommonjs2
にします。
最後にmodule.exports = [client, server]
を追加します。
ここまで来たらnpm run build
でビルドが通るか確認しておきましょう。
レンダリング
次にレンダリングするサーバーを用意します。
フレームワークはexpress.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
で立ち上げたサーバーと同じものが表示されると思います。
右クリックでソースを表示すると以下のようにレンダリングされっていることが確認できます。
<!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と一致しなくなってしまいます。
スクリーンショットは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