こんにちは、技術戦略室の安田です。
今回はHtmlを描画してjsonの値として返信するAPIサーバが必要になり、Node.js、Vue.js、それとWebpackを使用してサーバサイドレンダリングサーバを実装しました。
2021年の現在、まだWebのトレンドはVue.jsやReactJSを駆使したSPAサイトのように感じます。
スマホやPCの性能もここ数年で向上し、クライアント側でHtmlを描写させることがページ表示速度のボトルネックになることも減ってきました。その上で、Htmlをサーバサイドでレンダリングする事がメリットはあるのでしょうか?実はあります。
Webページのメタタグの中にOGPというSNSでWebページをシェアされた時にサムネイルやコンテンツの一部を表示させる為のプロトコルがあります。SNS流入を意識した時にファクターになりうるこのプロトコルの記述にはサーバサイドレンダリングが必要になります。というのも、OGPを確認するBOTはクライアント処理を待たないからです。
ダイナミックレンダリング(ユーザーエージェントを確認してサーバサイドレンダリングとクライアントサイドレンダリングを切り替える方法)で対応する事も可能ですが、外形監視システムが整っていないとバグで片方だけ表示されていないという自体が起きた時に発見が遅れそうです。
なのでSPAを導入しているサイトでもSSRの方法を覚えておくと、役立つ時がくるかもしれません。
読者対象は node.js、Webpack、 Vue.js を齧ったことのある人向けです。
必要なモジュールをインストールします。
node.js のバージョンは14。
モジュールのバージョン管理には npmを使用しています。
ここで一点注意なのが、実装時点の2020年11月時点で、最新のWebpackバージョン5とvue-server-rendererの最新のバージョンにはエラーがあり、バンドルができません。なので、インストールする時はWebpackバージョン4をインストールするようにしてください。
※ https://github.com/vuejs/vue/issues/11718
mkdir ssr-test
cd ssr-test
npm init .
# Express & Vue
npm i express vue vue-server-renderer
# webpack(バージョン4をインストール)
npm install --save-dev webpack@4 webpack-cli
# babel
npm i -D @babel/core @babel/preset-env babel-loader
# loader
npm i -D css-loader node-sass sass-loader style-loader vue-loader vue-template-compiler file-loader html-webpack-plugin
# ファイル更新でwebpackバンドルをホットリロード
npm i -D webpack-dev-middleware webpack-hot-middleware
# その他
npm i -D webpack-merge webpack-node-externals friendly-errors-webpack-plugin url url-loader
npm i core-js
# エントリーポイント
touch server.js
# 開発時のwebpackホットリロード用のファイル
touch setup-dev-server.js
# webpack
touch webpack.config.js
# webpackでバンドルするファイル群を入れるディレクトリ
mkdir src
# Vueクライアントサイドレンダリング用のjsをバンドルする為のエントリーポイント
touch src/entry-client.js
# Vueサーバサイドレンダリング用のjsをバンドルする為のエントリーポイント
touch src/entry-server.js
# Vueのコンポーネント。各バンドルのエントリーポイントからインポートされてコンパイルされる。
touch src/app.js
touch src/App.vue
Vueコンポーネントに纏わるファイルを記述していきます。
// App.vue
<template>
<div id="app">こんにちは from App.vue</div>
</template>
<script>
export default {
name: "App",
}
</script>
// app.js
import Vue from 'vue'
import App from './App.vue'
// 新しいアプリケーション。コールされるたびにインスタンスが作成される。
// store、router、vuexを使っているなら初期化する処理を追加して、returnする。
export function createApp(context) {
let app = new Vue({
render: h => h(App)
})
return { app }
}
クライアント用とサーバ用のバンドルエントリーポイントを記述していきます。Webpackが参照するファイルです。
// entry-server.js
import { createApp } from './app'
// この関数は bundleRenderer から呼び出す。
// データフェッチは非同期であるため、この関数はPromiseを返す。
export default context => {
return new Promise((resolve, reject) => {
const { app } = createApp(context)
resolve(app)
})
}
// entry-crient.js
import { createApp } from './app'
const { app } = createApp()
// これは App.vue テンプレートのルート要素が id="app" だから
app.$mount('#app')
Webpackでバンドルする為の設定を記述します。
クライアント用の設定オブジェクト、サーバレンダリング用の設定オブジェクト、それぞれを配列に入れてエクスポートします。
コードを要約すると、npmでインストールしたvue-server-renderモジュールからそれぞれ、client-pluginとserver-pluginを設定に盛り込んでいます。
私はサーバとクライアントのバンドル設定を一つのファイルにまとめて書き、配列でエクスポートしていますが、ファイルを分ける事も可能です。
この設定は本番バンドルするときだけでなく、Webpackでホットリロードする際にも読み込まれます。
// webpack.config.js
const path = require('path')
const webpack = require('webpack')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const { merge } = require('webpack-merge')
// webpack or npm コマンドを叩く際に環境変数を追加
// ビルドの設定を変更できる
const env = process.env.NODE_ENV || 'development'
const isProd = env === 'production'
if (isProd) {
console.log("Webpack本番コンパイル");
} else {
console.log("Webpack開発コンパイル");
}
/*
* ベースのコンフィグ
* クライアントとサーバのバンドルで使用する共通オブジェクト
**/
const baseConfig = {
mode: env,
devtool: isProd
? false
: 'source-map',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
filename: '[name].js'
},
module: {
noParse: /es6-promise\.js$/,
rules: [
{
test: /\.(sa|sc|c)ss$/,
use: ['vue-style-loader', 'css-loader', 'sass-loader']
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-env'],
}
},
{
test: /\.(png|jpg|gif|svg|jpeg)$/,
loader: 'url-loader',
options: {
limit: 10000,
name: '[name].[ext]?[hash]'
}
},
],
},
performance: {
hints: false
},
plugins: isProd
? [
new VueLoaderPlugin()
]
: [
new VueLoaderPlugin(),
new FriendlyErrorsPlugin() // エラーを見やすく
]
}
/*
* クライアント用のバンドルの設定
**/
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const VueSSRClientConfig = merge(baseConfig, {
entry: {
app: './src/entry-client.js'
},
resolve: {
alias: {
'create-api': './create-api-client.js',
},
extensions: ['.js', '.vue']
},
plugins: [
// Vueファイルに対してクライアントのコンパイルかサーバのコンパイルか教える(クライアント)
new webpack.DefinePlugin({
'process.env.NODE_ENV': env,
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin()
]
})
/*
*サーバ用のバンドルの設定
**/
const nodeExternals = require("webpack-node-externals")
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRServerConfig = merge(baseConfig, {
target: 'node',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
path: path.resolve(__dirname, 'dist'),
libraryTarget: 'commonjs2'
},
resolve: {
alias: {
'create-api': './create-api-server.js',
},
extensions: ['.js', '.vue']
},
externals: nodeExternals({
allowlist: /[\.css|\.scss]$/
}),
plugins: [
// Vueファイルに対してクライアントのコンパイルかサーバのコンパイルか教える(サーバ)
new webpack.DefinePlugin({
'process.env.NODE_ENV': env,
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
// 設定をエクスポート
// [0] サーバ用設定、 [1] クライアント用設定
module.exports = [VueSSRServerConfig, VueSSRClientConfig]
Expressサーバを立ち上げてlocalhostでアクセスできるようにします。ポート番号は8080。
また円滑に開発できるようsrcディレクトリの中身が編集された時、都度 webpack.config.js に基づいてバンドルを実行しメモリに保存する setup-dev-server.jsを用意します。
// server.js
const path = require('path')
const express = require('express')
const app = express()
const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 8080
const { createBundleRenderer } = require('vue-server-renderer')
function createRenderer(bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
basedir: resolve('./dist'),
runInNewContext: false,
inject: false,
}))
}
let renderer
let readyPromise
if (isProd) {
console.log('本番起動設定')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, { clientManifest })
} else {
console.log('開発環境設定')
readyPromise = require('./setup-dev-server')(
app,
(bundle, options) => {
renderer = createRenderer(bundle, options)
}
)
}
/**
* 所要時間をサーバログに返す
*/
app.use(express.json()).use(
(req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const duration = Date.now() - start
console.info("Vueサーバーサイドレンダリングの所要時間 %s ms", duration)
})
next()
}
)
/**
* レスポンス処理
*/
function render(req, res) {
const context = req.body || {}
renderer.renderToString(context, (err, html) => {
if (err) {
console.error("Error in vue-server renderer", err)
res.status(500).json({
message: `Internal Server Error: ${err.message}`,
})
} else {
res.json({ html })
}
})
}
/**
* パスの設定
*/
app.get('/', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
/**
* サーバー起動
*/
app.listen(port, function () {
console.log(`port:${port} サーバを起動しました!\n`);
})
// setup-dev-server.js
const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const serverConfig = require('./webpack.config')[0] // webpack.config.jsで0番でエクスポートしている設定(サーバ)
const clientConfig = require('./webpack.config')[1] // webpack.config.jsで1番でエクスポートしている設定(クライアント)
// fs: メモリーファイルシステム もしくは 純粋なファイルシステム
// バンドルされたファイルを読み込む関数
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
} catch (e) {}
}
module.exports = function setupDevServer(app, cb) {
console.log('セットアップ開始')
let bundle
let clientManifest
let ready
const readyPromise = new Promise(r => { ready = r })
const update = () => {
if (bundle && clientManifest) {
ready()
cb(bundle, { clientManifest })
}
}
// Webpackに書かれている設定オブジェクトにホットミドルウェアに関する追記をして、ホットリロードができるようにする。
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
)
// 開発用のミドルウェアを作成
const clientCompiler = webpack(clientConfig)
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
})
app.use(devMiddleware)
clientCompiler.hooks.done.tap('done', stats => {
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
clientManifest = JSON.parse(readFile(
devMiddleware.fileSystem,
'vue-ssr-client-manifest.json'
))
update()
})
// ホットミドルウェアを追加
app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 1000 }))
// ファイル更新時にサーバサイドレンダリング側も再コンパイルさせる
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
update()
})
console.log('セットアップ完了')
return readyPromise
}
package.jsonに起動用のスクリプトを追記します。`npm server` コマンドで http://localhost:8080 にて動作を確認できます。
"scripts": {
"server": "webpack && node ./server.js",
"server:prd": "webpack --mode=production && node NODE_ENV=production ./server.js",
"build": "webpack",
"build:prd": "webpack --mode=production"
},
元々はWebpackの勉強がてらにVue SSR サーバを作ったのですが、Vue SSRの日本語版の公式ドキュメント がいまいち読みづらく、所々苦戦しました。
最終的に、Vue.jsの公式githubからウェブサイトのサンプル からコードを引っ張ってきてWebpackのホットリロードの問題などを解決しました。こちらでは上で説明していない vuexやvue-routerも網羅したサンプルが用意されているので、気になる方は一読をしてみるのをお勧めします。
レンダリング速度に関しては他のレンダリングサーバと比較していませんが、早いと思います。一本の平均的な文字数のブログ記事で大体 30ms ~ 50ms でした。
長くなりましたが、もしフロントエンドで既にVue.jsを使っていてWebpackでバンドルをしているのなら比較的簡単にSSR用のファイルもバンドルできるかと思います。是非試してみてください。
※2021年2月4日時点