express + webpack + VueでHMRしながらSSRの開発をする

この記事は、「express + webpack + VueでSSRする」の記事にHMRを追加で組み合わせるための記事であり、重複する内容があります。

概要

expressとwebpackで、Vueのサーバーサイドレンダリングを実装するときの開発環境を構築します。
前回はファイル更新時に毎回webpackコマンドでビルドしてやる必要がありましたが、今回開発サーバーからファイルの更新を監視し、即ブラウザへ反映させることができるHMRも実装します。

手を動かす

今回のデモのリポジトリ
github.com

以下が手順概要になります。

  1. npmでプロジェクトを作成する
  2. express、webpack、Vue等をインストールする
  3. 必要なファイルを手動で作成する
  4. コマンドラインからWEBサーバー(express)を立ち上げる
  5. ブラウザでアクセスして確認する
  6. Vueのソースコードを変更する
  7. ブラウザにリロード無しで変更が反映されたか確認する

1.npmでプロジェクトを作成する

任意のディレクトリで以下のコマンドを実行します

mkdir express-webpack-vue-ssr-hmr-demo
cd express-webpack-vue-ssr-hmr-demo
npm init -y

2. express、webpack、Vue等をインストールする

以下のコマンドを実行します(--save-devコマンドは1つにまとめても大丈夫です)。

npm install --save-dev chokidar css-loader friendly-errors-webpack-plugin
npm install --save-dev vue-loader vue-template-compiler
npm install --save-dev webpack webpack-dev-middleware webpack-hot-middleware webpack-merge
npm install --save express vue vue-server-renderer

3. 必要なファイルを手動で作成、編集する

以下のファイルは新規作成します。

  1. ./server.js
  2. ./build/setup-dev-server.js
  3. ./build/webpack.base.config.js
  4. ./build/webpack.client.config.js
  5. ./build/webpack.server.config.js
  6. ./src/app.js
  7. ./src/entry-client.js
  8. ./src/entry-server.js
  9. ./src/index.template.html
  10. ./src/App.vue
  11. ./src/components/Bar.vue
  12. ./src/components/Foo.vue

以下のようなディレクトリ構造になるはずです。

│  server.js
│  package.json
│ 
├─build
│   setup-dev-server.js
│   webpack.base.config.js
│   webpack.client.config.js
│   webpack.server.config.js
│
├─node_modules
│   etc...
│
└─src
    app.js
    App.vue
    entry-client.js
    entry-server.js
    index.template.html
    └─components
        Bar.vue
        Foo.vue

package.json

{
  "name": "express-webpack-vue-ssr-hmr-demo",
  "description": "",
  "author": "",
  "private": true,
  "scripts": {
    "dev": "node server"
  },
  "engines": {
    "node": ">=7.0",
    "npm": ">=4.0"
  },
  "dependencies": {
    "express": "^4.16.2",
    "vue": "^2.5.22",
    "vue-server-renderer": "^2.5.22"
  },
  "devDependencies": {
    "chokidar": "^1.7.0",
    "css-loader": "^0.28.7",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "vue-loader": "^15.3.0",
    "vue-template-compiler": "^2.5.22",
    "webpack": "^3.8.1",
    "webpack-dev-middleware": "^1.12.0",
    "webpack-hot-middleware": "^2.20.0",
    "webpack-merge": "^4.2.1"
  }
}
server.js
const express = require('express')
const setupDevServer = require('./build/setup-dev-server')
const { createBundleRenderer } = require('vue-server-renderer')

const app = express()

let renderer
let initRenderer = (bundle, options) => {
  renderer = createBundleRenderer(bundle, options)
}

// レスポンスを返すためのレンダラーができていることを保証するプロミスを取得
let readyRendererPromise = setupDevServer(app, initRenderer)

app.get('*', (req, res) => {
  // レンダラーが準備済みの時はHTMLを描画してレスポンスを返す
  readyRendererPromise.then(() => {
    renderer.renderToString({
      title: 'express + webpack + Vue + SSR + HMR',
      url: req.url
    },
    (err, html) => {
      res.send(html)
    })
  })
})

// サーバーを待ち受け状態にする
const port = process.env.PORT || 8080
app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})

./build/配下

buildディレクトリの配下には、webpackの設定やフックへの登録等の、アプリケーションをビルドするための処理が収められています。
SSRやHMRの設定もこのディレクトリ内で実施しています。

./build/setup-dev-server.js バンドルファイルの作成、更新をする。
また、レスポンスにHTMLを描画するレンダラーの作成と更新等。
./build/webpack.base.config.js サーバー及びクライアントのバンドルファイルに共通する設定
./build/webpack.client.config.js クライアントのバンドルファイルを作成する設定
./build/webpack.server.config.js サーバーのバンドルファイルを作成する設定
./build/setup-dev-server.js
const fs = require('fs')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')

// レンダラーを作成
// クライアント及びサーバーのバンドルファイル、HTMLテンプレートの更新を発火地点として、レンダラーを更新する
module.exports = function setupDevServer (app, initRenderer) {
  let bundle
  let template
  let clientManifest
  const templatePath = resolve('../src/index.template.html')

  // レスポンスのHTMLを生成するためのレンダラーが準備済みであることを保証する
  let ready
  const readyRendererPromise = new Promise(r => { ready = r })

  // レスポンスのHTMLを生成するためのレンダラーを初期化、更新する
  const updateRenderer = () => {
    if (bundle && clientManifest) { // サーバー及びクライアントのバンドルが作成済みであれば
      ready() // thenの発火を許し、

      // createBundleRenderlerを実行し、server.js内のrenderer変数に対して新しいレンダラーを渡す
      initRenderer(bundle, //Vueをレンダリングするバンドル
        {
          basedir: resolve('../dist'),
          runInNewContext: false,
          template, // レンダリングしたVueを挿入するHTMLテンプレート
          clientManifest // HTMLテンプレートにバンドルを読む<script>タグを挿入する
        })
    }
  }

  // SSRに用いるHTMLファイルを更新した時に、レンダラーも更新する
  template = fs.readFileSync(templatePath, 'utf-8')
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    console.log('index.html template updated.')
    updateRenderer()
  })

  // クライアント用にバンドルされるファイルの更新を監視
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    noInfo: true
  })
  app.use(devMiddleware)

  // ファイルの更新をクライアントに通知
  app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

  // ファイルの更新時に、レンダラーも更新する
  clientCompiler.plugin('done', stats => {
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    updateRenderer()
  })

  // SSR用のバンドル
  const serverCompiler = webpack(serverConfig)
  const mfs = new MFS()
  serverCompiler.outputFileSystem = mfs
  // SSR用にバンドルされるファイルの更新時に、レンダラーも更新する
  serverCompiler.watch({}, (err, stats) => {
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) // vue-ssr-webpack-pluginによって生成される
    updateRenderer()
  })

  return readyRendererPromise
}

// バンドル、マニフェストファイル取得のヘルパー
const readFile = (fs, file) => {
  try {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') // serverConfig.output.pathでも同じパスが取得できる
  } catch (e) {}
}
./build/webpack.base.config.js

サーバー及びクライアントのバンドルファイルに共通する設定を記述

const path = require('path')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  devtool: '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
        new VueLoaderPlugin(),
        new FriendlyErrorsPlugin()
    ]
}
./build/webpack.client.config.js

クライアントのバンドルファイルの設定を記述

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
  entry: {
    app: ['webpack-hot-middleware/client', './src/entry-client.js']
  },
  output: {
    filename: '[name].js'
  },
  plugins: [
    new VueSSRClientPlugin(), // clientmanifestを出力
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  ]
})

module.exports = config
./build/webpack.server.config.js

サーバーのバンドルファイルの設定を記述

const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
  target: 'node',
  entry: './src/entry-server.js',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin()
  ]
})

./src/配下について

srcディレクトリ配下には、Vueアプリケーションに必要なファイルを配置しています。 また、サーバーとクライアントにおいてそれぞれ起点になるファイルを用意しています。

./src/app.js サーバー及びクライアントにおいて共通する処理
./src/entry-client.js クライアントにおいて起点になるファイル
./src/entry-server.js サーバーにおいて起点になるファイル
./src/app.js

サーバー及びクライアントのアプリケーションファイルに共通する処理を記述。

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

export function createApp () {
  const app = new Vue({
    render: h => h(App)
  })

  return { app }
}
./src/entry-client.js

クライアント用のアプリケーションファイルの処理を記述。 クライアントにおいては、このファイルがアプリケーションの起点となります。

import { createApp } from './app'

const { app } = createApp()

app.$mount('#app')
./src/entry-server.js

サーバー用のアプリケーションファイルの処理を記述。 サーバーにおいては、このファイルがアプリケーションの起点となります。

import { createApp } from './app'

export default context => {
  return new Promise((resolve) => {
    const { app } = createApp()
    resolve(app)
  })
}
./src/index.template.html

SSR時にレンダリング時に使用するHTMLテンプレート。 このHTMLにレンダリングされたVueと、clientManifestを元にした\<script>タグ等が挿入され、レスポンスとして送信されます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <title>{{ title }}</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
  </head>
  <body>
  <!--vue-ssr-outlet-->
  </body>
</html>
./src/App.vue

通常のVueコンポーネントです。開発サーバーがHMRを実装しているため、リロード不要で画面に反映されます。

<template>
  <div id="app">
    {{ message }}
    <foo></foo>
    <bar></bar>
  </div>
</template>

<script>
import Foo from './components/Foo.vue'
import Bar from './components/Bar.vue'

export default {
  components: { Foo, Bar }, 
  data () {
    return {
      message: 'express + webpack + Vue + SSR + HMR!!'
    }
  }
}
</script>

./src/components/Bar.vue

通常のVueコンポーネントです。開発サーバーがHMRを実装しているため、リロード不要で画面に反映されます。

<template>
    <div class="bar">{{ bar }}</div>
</template>

<script>
export default {
   data () {
       return {
           bar: 'from bar!!'
       }
   }
}
</script>
./src/components/Foo.vue

通常のVueコンポーネントです。開発サーバーがHMRを実装しているため、リロード不要で画面に反映されます。

<template>
    <div class="foo">{{ foo }}</div>
</template>

<script>
export default {
   data () {
       return {
           foo: 'from foo!!'
       }
   }
}
</script>

4. コマンドラインからWEBサーバー(express)を立ち上げる

以下のコマンドを実行します。

node server

すると、開発用のWEBサーバー(express)が待ち受け状態になります。

5. ブラウザでアクセスして確認する

ブラウザで以下のURLにアクセスします。

http://localhost:8080/

すると、以下のように表示されています。

express + webpack + Vue + SSR + HMR!!
from foo!!
from bar!!

6. Vueのソースコードを変更する

HMRが動作することを確認するため、以下のvueファイルのソースコードを変更します。

  • ./src/components/Bar.vue
<template>
    <div class="bar">{{ bar }}</div>
</template>

<script>
export default {
   data () {
       return {
           bar: 'from bar!!'
       }
   }
}
</script>

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓


<template>
    <div class="bar">{{ bar }}</div>
</template>

<script>
export default {
   data () {
       return {
           bar: 'FROM BAR!!'
       }
   }
}
</script>

データに含まれる文字列を大文字にしてみました。

7. ブラウザにリロード無しで変更が反映されたか確認する

HMRが正しく動作すれば、ブラウザをリロードしなくても、ブラウザに表示される文字列変更されています。 上記では、文字列を大文字に変更したので以下のように表示されていれば成功です。

express + webpack + Vue + SSR + HMR!!
from foo!!
FROM BAR!!

感想

HMRを実装できたことでようやくSSRの開発環境として形になってきた気がします。 ただ、今のソースだけでも思ったより複雑ですが、これにキャッシュ機能だったり非同期処理の入るルーティングなどが絡んでくるともっと複雑なソースになってきます。

本当に製品でSSRが必要な時は学習コストを考えると、こういったソースをラップしてくれるNuxtなどを使うのが正解な気がしました。 ただ、NuxtであってもSSRの開発に時に気を付けることは似ているような予感がします。