Help us understand the problem. What is going on with this article?

expressにwebpack-dev-serverを組み込んでフロントエンドとサーバーサイドを同時にサクサク開発するハンズオン ~Auto Reloadで幸せに~

概要

expresswebpack-dev-serverを組み込むことにより以下のようなことを可能にします

  • フロントエンド側変更してもブラウザの手動リロードいらず
  • サーバー側変更しても、サーバー(express on node.js)の手動再起動いらず

つまり、「ソースを変更したら、フロントエンド側もサーバー側もオートリロードする」ような環境設定の方法を説明します。

これができると、フロントエンドもサーバーもコードを変更したらすぐに反映し動作確認ができるようになりプチハッピーです。

実現方法
webpack-dev-middlewarewebpack-hot-middlewareexpressに組み込むことにより、比較的カンタンに実現できます。

環境

  • サーバーはexpressが前提となります
  • フロントエンド開発にはバンドラーとしてwebpackを使います
  • ReactやVue.jsなど特定のLib/Frameworkに依存しません

想定読者

  • node.js+webpack4+webpack-dev-server 初級者

完動するソースコード一式
https://github.com/riversun/webpack-dev-server-on-express

本編

(0)プロジェクト作成

フロントエンド側とサーバー側を両方ふくんだプロジェクトをゼロからつくっていく。
適当な名前のディレクトリを作成し、そこにnpmプロジェクトをつくる。

mkdir webpack-dev-server-on-express
cd webpack-dev-server-on-express
npm init

(npm init後はエンター9回押せばOK)

(1)必要なモジュールのインストール

必要なモジュールをインストールする

1.webpack系モジュールのインストール

npm install --save-dev webpack webpack-cli webpack-dev-server webpack-dev-middleware webpack-hot-middleware

2.サーバー系モジュールのインストール

npm install --save-dev express multer babel-watch 

3.フロントエンド系(クライアント系)モジュールのインストール

npm install --save-dev @babel/core @babel/preset-env babel-loader core-js@3

package.jsondevDependenciesに以下が追加された状態となる

  "devDependencies": {
    "webpack": "^4.34.0",
    "webpack-cli": "^3.3.4",
    "webpack-dev-server": "^3.7.1",
    "webpack-dev-middleware": "^3.7.0",
    "webpack-hot-middleware": "^2.25.0",
    "express": "^4.17.1",
    "multer": "^1.4.1",
    "babel-watch": "^7.0.0",
    "@babel/core": "^7.4.5",
    "@babel/preset-env": "^7.4.5",
    "babel-loader": "^8.0.6",
    "core-js": "^3.1.4"
  }

今インストールしたモジュール達は
それぞれ、以下のような役割となる。

分類 モジュール名 役割 利用バージョン
webpack系 webpack バンドルJSを作るwebpack本体 ^4.34.0
webpack-cli webpackのコマンドラインインタフェース ^3.3.4
webpack-dev-server スタンドアロン版webpack-dev-server ^3.7.1
webpack-dev-middleware expressに組み込めるwebpack-dev-server ^3.7.0
webpack-hot-middleware expressに組み込めるHot Reloading用ミドルウェア ^2.25.0
サーバー系 express node.jsの定番webフレームワーク ^4.17.1
multer multipartのフォームデータを扱う ^1.4.1
babel-watch サーバー側の変更ウォッチ用。nodemonの代わり。 ^7.0.0
フロントエンド系 @babel/core 最新文法のJavaScript(ES)をレガシー文法に変換 ^7.4.5
@babel/preset-env babelの設定プリセット ^7.4.5
babel-loader webpackでbabelをつかうときのローダー ^8.0.6
core-js babelの文法変換を助けるpolyfill ^3.1.4

■ 現在のディレクトリ構成

ディレクトリ構成
webpack-dev-server-on-express
├── node_modules
├── package.json
└── package-lock.json

(2)フロントエンド側を実装する

さっそくフロントエンド側(クライアント)から実装する。

今回はサーバ側とフロントエンド側両方とも同じプロジェクトにいれるのでクライアント側のソースコード用にsrc_clientというディレクトリをつくり、そこにindex.jsを作成する。
また、index.htmlなどコンテンツの置き場所としてpublicディレクトリをつくり、index.htmlを作成する。

■ 現在のディレクトリ構成

ディレクトリ構成
webpack-dev-server-on-express
├── node_modules
├── public
│   └── index.html
├── src_client
│   └── index.js
├── package.json
└── package-lock.json

(2)-1 index.htmlを作る

フロントエンドとサーバーが連動した単純な足し算アプリを題材にすすめていく。

フロントエンド側で2つの値(足す数と足される数)を入力して足し算API(サーバー側の実装)POSTするとサーバー側で足し算処理をして返すというシンプルなもの。

足し算の入力フォームを持つ以下のような簡単なhtmlを作る

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>足し算</title>
</head>
<body>

<h3>足し算</h3>
<form action="" method="post" id="myform">
    <input type="number" name="firstValue"><br>
    <span>+</span><br>
    <input type="number" name="secondValue"><br>
</form>
<span>=</span><br>
<input type="number" id="result" readonly><br><br>

<button id="btn-clac">計算</button>
<script src="js/app.js"></script>
</body>
</html>

image.png

数値入力ボックスを2つ設置して、それぞれにfirstValueとsecondValueというパラメータを対応づけてる。

(2)-2 index.jsを作る

入力フォームに数値を入力して計算ボタンを押すとサーバーにフォームデータを送信するためのコードを書く。

(上のindex.htmlで<script src="js/app.js"></script>となっていたのは、babelでバンドル化するためで、その元となるコードはindex.js。)

index.js
const btnSend = document.querySelector('#btn-clac');

btnSend.addEventListener('click', evt => {

    const xhr = new XMLHttpRequest();

    xhr.addEventListener('load', evt => {

        if (xhr.status == 200) {
            const result = JSON.parse(xhr.response);
            const resultEle = document.querySelector('#result');
            resultEle.value = result.sum;
        }
    });

    xhr.addEventListener('error', evt => {
        console.error(evt);
    });

    xhr.open('post', 'api/add', true);

    const formEle = document.querySelector('#myform');
    const formData = new FormData(formEle);

    xhr.send(formData);

});


  • フォームの内容はJavaScriptから送信するため、XMLHttpRequestを使っている
  • api/addというURLにデータをポストする
  • new FromData()myformのフォームデータをFormDataオブジェクトにしている
  • FormDataオブジェクトをPOSTする場合は、enctype="multipart/form-data"となるので、サーバー側でもmultipart対応をする必要がある
 xhr.open('post', 'api/add', true);
 const formEle = document.querySelector('#myform');
 const formData = new FormData(formEle);
 xhr.send(formData);

(2)-3 webpack.config.jsを作る

次に、webpackの挙動を設定するwebpack.config.jsを作成する。

このwebpack.config.jsはフロントエンド用の設定として作っているが、後でサーバ-側にwebpack-dev-serverを組み込み際にも使う。

■ 現在のディレクトリ構成はこうなる

ディレクトリ構成
webpack-dev-server-on-express
├── node_modules
├── public
│   └── index.html
├── src_client
│   └── index.js
├── package.json
├── package-lock.json
└── webpack.config.js ←今、コレを追加
webpack.config.js
const path = require('path');

module.exports = {
    mode: 'development',
    devServer: {
        contentBase: path.join(__dirname, 'public'),
        port: 8080,
        host: `localhost`,
    },
    entry: {
        app: ['./src_client/index.js']
    },
    output: {
        path: path.join(__dirname, 'dist'),
        publicPath: '/js/',
        filename: `[name].js`,
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /(node_modules|bower_components)/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                [
                                    '@babel/preset-env',
                                    {
                                        'modules': 'false',//commonjs,amd,umd,systemjs,auto
                                        'useBuiltIns': 'usage',
                                        'targets': '> 0.25%, not dead',
                                        'corejs': 3
                                    }
                                ]
                            ]
                        }
                    }
                ]
            }
        ]
    },
    resolve: {
        alias: {}
    },
    plugins: [],

};

このwebpack.config.jsの意味は別記事webpack4対応webpack-dev-serverの主要な設定オプション(CLI,webpack.config.js)の意味と挙動にもまとめているので、ここではポイントだけみていく。


devServer: {
        contentBase: path.join(__dirname, 'public'),
        port: 8080,
        host: `localhost`,
    },

ここは、コンテンツ(htmlとか)のルートをpublicにして、webpack-dev-serverをポート8080で起動する設定となる。


    entry: {
        app: ['./src_client/index.js']
    },

ここは、フロントエンド側のJavaScriptソースコードのエントリポイントの設定で、
名前がappで、エントリポイント(実行開始する最初のモジュール)ソースコードが./src_client/index.jsであることを示している。
後に、サーバー側のexpressにwebpack-dev-serverを組み込むところで出てくるが、ここをapp: ['./src_client/index.js']の「['./src_client/index.js']」ように、配列表現にしておくことがポイント。
サーバー側ではこの配列に、名前appに対応するもう1つのエントリーポイントを追加する予定。


    output: {
        path: path.join(__dirname, 'dist'),
        publicPath: '/js/',
        filename: `[name].js`,
    },

publicPathwebpack-dev-serverがバンドルJSを配信するときのパス。
filename: [name].js[name]の部分には、appがはいってapp.jsというファイル名となる。appentry: {app:['./src_client/index.js']}から来ている。

つまり、webpack-dev-serverをつかうときはバンドルJSはjs/app.jsでアクセス可能になるので、上で示したindex.htmlでは

index.jsのscriptタグ
<script src="js/app.js"></script>

としている。

(2)-4 フロントエンドでwebpack-dev-serverを起動する

さて、これでフロントエンド側に必要になる3つのソースコードができたので
いったんwebpack-dev-serverを起動してみる。

ディレクトリ構成
フロントエンド側に必要となるコード
├── public
│   └── index.html
├── src_client
│   └── index.js
└── webpack.config.js

package.json に以下のようにstart:clientを追加する。

  "scripts": {
    "start:client": "webpack-dev-server --config webpack.config.js"
  },

コマンドラインで、

npm run start:client

と打てば、フロントエンド開発用にwebpack-dev-serverが起動して(ついでにブラウザも)、今つくったコードを早速ためすことができるし、コードを変更すればブラウザがオートリロードされる。

ここまでは、フツーのフロントエンド環境をつくっただけで、
やりたいことはこれではない。
(これじゃないけど、ここまでの作業はムダじゃなくて、そのまま流用する)

やりたいことは、サーバーとフロントエンドを同時開発 かつ サーバーの手動再起動いらず、フロントエンドの手動再読み込みいらずなので次にサーバー側をつくってやりたいことをやる。

(3)サーバ-側を実装する

フロントエンド側の準備ができたので、いよいよexpresswebpack-dev-serverを組み込む。

早速、作業ディレクトリにサーバー用のディレクトリsrc_serverを作成する。
次に、src_server以下にサーバー用のソースコードserver.jsを作成する。

■ 現在のディレクトリ構成

ディレクトリ構成
webpack-dev-server-on-express
├── node_modules
├── public
│   └── index.html
├── src_client
│   └── index.js
├── src_server  ←今、コレを追加
│   └── server.js  ←コレも追加
├── package.json
├── package-lock.json
└── webpack.config.js 

以下がserver.jsとなる。
当然だが、サーバー側のコードにノウハウがあるので詳しくみていく。

server.js
const express = require('express');
const multer = require('multer');
const multipart = multer();

const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const config = require('../webpack.config.js');

const app = express();
const port = 8080;

const devServerEnabled = true;

if (devServerEnabled) {
    config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000');

    config.plugins.push(new webpack.HotModuleReplacementPlugin());

    const compiler = webpack(config);

    app.use(webpackDevMiddleware(compiler, {
        publicPath: config.output.publicPath
    }));

    app.use(webpackHotMiddleware(compiler));
}

app.use(express.static('./public'));

app.post('/api/add', multipart.any(), function (req, res) {

    const firstValue = parseInt(req.body.firstValue);
    const secondValue = parseInt(req.body.secondValue);
    const sum = firstValue + secondValue;
    res.json({sum: sum, firstValue: firstValue, secondValue: secondValue});
});

app.listen(port, () => {
    console.log('Server started on port:' + port);
});

上からコードをみていく


const express = require('express');
const multer = require('multer');
const multipart = multer();

ここでは、express本体と、マルチパートのフォームデータを処理するためのmulterをインポートしている。
さきほどindex.jsでnew FormData()したFormDataオブジェクトをXMLHttpRequestをつかってサーバーに送信するコードを書いたが、FormDataオブジェクトをPOSTする場合にはmultipart/form-dataでエンコードされるため、multipart/form-dataを解釈できるモジュールmulterをつかっている。


const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const config = require('../webpack.config.js');

webpackwebpack-dev-middlewarewebpack-hot-middlewareをインポートする。
また、サーバー内でフロントエンドのwebpack設定を参照するためwebpack.config.jsもインポートする。


const app = express();
const port = 8080;

const devServerEnabled = true;

if (devServerEnabled) {

devServerEnabledは簡単なフラグ。
これがtrueのときにexpress上のwebpack-dev-server機能が有効になるようにコードを組んだ。
つまり、サーバー側のコードserver.jsのみでwebpack-dev-server機能をOn/Offできる、という仕様にしてみた。


config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000');
config.plugins.push(new webpack.HotModuleReplacementPlugin());

ここでconfigconfig = require('../webpack.config.js')なので、webpack.config.js(の実体export結果)を意味しているので、config.entry・・・config.plugins・・・というコードは、webpack.config.jsをサーバーのコード上で上書き編集してる考えてよい。

なので、config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000')では、config≒webpack.config.js にあるentry.appという配列の最初に'webpack-hot-middleware/client?reload=true&timeout=1000'を追加しますよという意味になる。

つまり、

config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000');

は、

webpack.config.js抜粋(変更前)
entry: {
    app: ['./src_client/index.js']
},

を↓のように更新したのと同じ意味となる。

webpack.config.js抜粋(変更後)
entry: {
    app: [
        'webpack-hot-middleware/client?reload=true&timeout=1000',
        './src_client/index.js'
    ]
},

なので、コードじゃなくて、webpack.config.jsのほうに書いてしまうのもアリ。
本稿ではserver.jsだけで制御したい、という思想をもっているのでserver.jsのコードに書いている。

webpack-hot-middleware/client?reload=true&timeout=1000自体はホットリロードの設定となる。

reload=true の意味

まぎらわしいが、「リロード」には以下の2通りあるが、このreload=trueは主に①のほうに関係がある。

①. 単純なブラウザ再読込≒オートリフレッシュ(自動更新)
②. HMR(Hot Module Replacement)

これについてはwebpack4対応webpack-dev-serverの主要な設定オプションの意味と挙動という記事にもまとめたが、
①の場合は、ブラウザ全体をリロードするのでフォームに入力していた値などはクリアされてしまう。

②の場合は、ブラウザ全体をリロードするのではなく、変更したモジュール(jsコード)だけを置き換えてくれるのがのがHMR(Hot Module Replacement)という機能だが、この機能を使うためにはこのサーバー側の設定だけではなく、アプリ側もHMRのお作法で設計する必要がある。

本稿では特定のフレームワークに特化しないサンプルコードにしているので、HMRのお作法で設計しているわけではない。(HMRのお作法で設計するのはそんなに難しくないが、カンタンでもない。Reactなどは対応している。)
そこで、reload=trueな設定をした本稿の例では、index.jsを変更すると①単純なブラウザ再読込が実行される(※)

ちなみに、webpack-hot-middleware本家のreadmeには以下のような記載がある。

reload - Set to true to auto-reload the page when webpack gets stuck.

シンプルに webpack gets stuck と書いてあるが、「HMRをやろうとしたがアップデートされたモジュールがみつからなかった場合、ブラウザをリロードしますよ」と解釈してOK。webpack処理で何らかの問題が発生したときにもリロードされるが、HMRに対応していない本稿のコードの場合、webpack-hot-middlewareさんからみると「何か更新されたみたいだけど、HMR的にアップデートされたモジュールが見当たらない・・・」となって結果的にリロードされるというロジックになっている。

ということで、長くなったがHMR非対応コードの場合、reload=true にしておくと、コードを変更するとブラウザでまるっと再読込が走る という動作となる。

timeout=1000の意味

フロントエンドのソースコード変更を検知して、フロントエンド側の再読込をうながすのはサーバー側のexpress上にあるwebpack-hot-middleミドルウェアの仕事となる。ソースコード変更を検知して、フロントエンド側に通知(サーバープッシュ)するためにwebpack-hot-middlewareSSE(server-sent-events)をつかっているが、例えばサーバー側が再起動したりしてしまうと、このSSEの接続が切れてしまう。
timeout=1000は、SSEの切断検知から再接続処理までの時間を定義する。

本家のreadmeでは↓のとおり

timeout - The time to wait after a disconnection before attempting to reconnect

本稿の例では、サーバー再起動がいちばん切断の原因となりそうなので、timeout=1000はサーバーを再起動したあと、フロントエンド側とサーバー側の再接続までの時間(ms)を設定する という動作となる

次はconfig.plugins.push(new webpack.HotModuleReplacementPlugin());の設定について。

config.plugins.push(new webpack.HotModuleReplacementPlugin());

これも、さきほど同様、

は、

webpack.config.js抜粋(変更前)
   plugins: [],

を↓のように更新したのと同じ意味となる。

webpack.config.js抜粋(変更後)
   plugins: [new webpack.HotModuleReplacementPlugin()],

これはHMRを実行可能にするプラグインで、さきほどのwebpack-hot-middlewareから利用されるもの。


const compiler = webpack(config);

app.use(webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath
}));

app.use(webpackHotMiddleware(compiler));

次はこの部分だが、上でインポートした2つのミドルウェアをexpressに登録する。

app.use(webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath
}));

ここでは、webpack-dev-middlewareの設定で、publicPathwebpack.config.jsから参照して設定している。直接書いてもOK。


あとは、特筆する部分は特にないので簡単に。

app.use(express.static('./public'));

ここは、コンテンツとなるindex.htmlpublicディレクトリでホストする設定

app.post('/api/add', multipart.any(), function (req, res) {

    const firstValue = parseInt(req.body.firstValue);
    const secondValue = parseInt(req.body.secondValue);
    const sum = firstValue + secondValue;
    res.json({sum: sum, firstValue: firstValue, secondValue: secondValue});
});

ここは、POSTメソッドを/api/addで受け取るAPI処理部分。足し算ロジックを実行している。

実行

ここまでで環境はできた。

ディレクトリ構成
webpack-dev-server-on-express
├── node_modules
├── public
│   └── index.html
├── src_client
│   └── index.js
├── src_server
│   └── server.js
├── package.json
├── package-lock.json
└── webpack.config.js 

最後に、サーバーを起動するためのスクリプトをpackage.jsonに書く

以下をpackage.jsonscript以下に追加して

"start": "babel-watch ./src_server/server.js"

package.json全体はこうなった

package.json
{
  "name": "webpack-dev-server-on-express",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start:client": "webpack-dev-server --config webpack.config.js",
    "start": "babel-watch ./src_server/server.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^4.34.0",
    "webpack-cli": "^3.3.4",
    "webpack-dev-server": "^3.7.1",
    "webpack-dev-middleware": "^3.7.0",
    "webpack-hot-middleware": "^2.25.0",
    "express": "^4.17.1",
    "multer": "^1.4.1",
    "babel-watch": "^7.0.0",
    "@babel/core": "^7.4.5",
    "@babel/preset-env": "^7.4.5",
    "babel-loader": "^8.0.6",
    "core-js": "^3.1.4"
  }
}

サーバー側のコードは babel-watchで監視させる。変更があればサーバー側のコードを再起動してくれる。

サーバーを起動して、Auto Reloadな開発環境を試す

コマンドラインで

npm start

これでwebpack-dev-server相当が組み込まれたexpressサーバーが起動した。

http://localhost:8080/ にアクセスすればアプリを試せる。
(ブラウザでdeveloper toolを起動すれば[HMR]connectedのログが表示されているのがわかる。)

image.png

これで/src_client以下にあるフロントエンド側のソースコードを変更すれば、フロントエンド(ブラウザ)がオートリロードされるし、/src_server以下にあるサーバー側のソースコードを変更すれば、サーバ-(express)が自動的に再起動されるようになった。

TIPS

WebpackOptionsValidationError が発生する場合

以下のようなエラーが発生する場合、

WebpackOptionsValidationError: Invalid configuration object. Webpack has been initialised using a configuration object that does not match the API schema.
 - configuration should be an object.

webpack.config.jsmodule.exportsがObjectではなく、functionを返しているときに発生する。

例えば以下のwebpack.config.jsは起動時に引数で分岐できるようラムダ関数を返す方式になっている。

webpack.config.js
const path = require('path');
module.exports = (env, argv) => {
    const conf = {
        mode: 'development',
        devServer: {
            contentBase: path.join(__dirname, 'public'),
            port: 8080,
            host: `localhost`,
        },
        entry: {
            app: ['./src_client/index.js']
        },
        output: {
            path: path.join(__dirname, 'dist'),
            publicPath: '/js/',
            filename: `[name].js`,
        },
        resolve: {
            alias: {}
        },
        plugins: [],
    }
    return conf;
};

こういうときは、webpack.config.jsがオブジェクトをexportするため、以下のように変更してあげれば良い。

変更前
const config = require('../webpack.config.js');

変更後
const webpackConfigJs = require('../webpack.config.js');
const config = webpackConfigJs();// 実行することで、オブジェクトを得る

webpack.config.jsに引数をいれるときは以下のようにすればいい

const config = webpackConfigJs(null, {mode: 'develop'});

また、以下のようなエラーがでる場合も同様の対策で修正可能

config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000');
             ^
TypeError: Cannot read property 'app' of undefined

まとめ

  • webpack-dev-middlewarewebpack-hot-middlewareexpressに組み込むことによりフロントエンドもサーバーもコードを変更したらすぐにオートリロードで反映し動作確認ができる環境をつくりました

ポイントは以下の部分です

const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const config = require('../webpack.config.js');

config.entry.app.unshift('webpack-hot-middleware/client?reload=true&timeout=1000');
config.plugins.push(new webpack.HotModuleReplacementPlugin());

const compiler = webpack(config);

app.use(webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath
}));

app.use(webpackHotMiddleware(compiler));
riversun
UX producer and Full-Stack developer with more than 15 years of experience.
https://github.com/riversun
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした