Vue.jsとWebpackを使ってサーバサイドレンダリングをする

VueとWebpackでサーバサイドレンダリング

こんにちは、技術戦略室の安田です。
今回はHtmlを描画してjsonの値として返信するAPIサーバが必要になり、Node.js、Vue.js、それとWebpackを使用してサーバサイドレンダリングサーバを実装しました。

  1. サーバサイドレンダリング(SSR)...今更やる必要ある?
  2. モジュールをインストール
  3. 必要なファイルの作成
  4. Vueのコンポーネント周りを記述
  5. バンドルのエントリーポイントを記述
  6. Webpackのバンドル設定を用意
  7. Expressサーバと開発用ホットリロードサーバを用意
  8. 起動スクリプトを追記
  9. Vue SSRを作ってみての所感

サーバサイドレンダリング(SSR)...今更やる必要ある?

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のコンポーネント周りを記述

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のバンドル設定を用意

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サーバと開発用ホットリロードサーバを用意

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"
  },

Vue SSRを作ってみての所感

元々はWebpackの勉強がてらにVue SSR サーバを作ったのですが、Vue SSRの日本語版の公式ドキュメント がいまいち読みづらく、所々苦戦しました。

最終的に、Vue.jsの公式githubからウェブサイトのサンプル からコードを引っ張ってきてWebpackのホットリロードの問題などを解決しました。こちらでは上で説明していない vuexやvue-routerも網羅したサンプルが用意されているので、気になる方は一読をしてみるのをお勧めします。

レンダリング速度に関しては他のレンダリングサーバと比較していませんが、早いと思います。一本の平均的な文字数のブログ記事で大体 30ms ~ 50ms でした。

長くなりましたが、もしフロントエンドで既にVue.jsを使っていてWebpackでバンドルをしているのなら比較的簡単にSSR用のファイルもバンドルできるかと思います。是非試してみてください。

Techブログ 新着記事一覧