概要
■ expressにwebpack-dev-serverを組み込むことにより以下のようなことを可能にします
- フロントエンド側変更してもブラウザの手動リロードいらず
- サーバー側変更しても、サーバー(express on node.js)の手動再起動いらず
つまり、「ソースを変更したら、フロントエンド側もサーバー側もオートリロードする」ような環境設定の方法を説明します。
これができると、フロントエンドもサーバーもコードを変更したらすぐに反映し動作確認ができるようになりプチハッピーです。
■ 実現方法
webpack-dev-middlewareとwebpack-hot-middlewareをexpressに組み込むことにより、比較的カンタンに実現できます。
■ 環境
■ 想定読者
- 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.jsonのdevDependenciesに以下が追加された状態となる
"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を作る
<!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>
数値入力ボックスを2つ設置して、それぞれにfirstValueとsecondValueというパラメータを対応づけてる。
(2)-2 index.jsを作る
入力フォームに数値を入力して計算ボタンを押すとサーバーにフォームデータを送信するためのコードを書く。
(上のindex.htmlで<script src="js/app.js"></script>
となっていたのは、babelでバンドル化するためで、その元となるコードは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 ←今、コレを追加
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`,
},
publicPathはwebpack-dev-serverがバンドルJSを配信するときのパス。
filename: [name].js
の**[name]
の部分には、appがはいってapp.js**というファイル名となる。appはentry: {
app:['./src_client/index.js']}
から来ている。
つまり、webpack-dev-serverをつかうときはバンドルJSはjs/app.js
でアクセス可能になるので、上で示したindex.htmlでは
<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)サーバ-側を実装する
フロントエンド側の準備ができたので、いよいよexpressにwebpack-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となる。
当然だが、サーバー側のコードにノウハウがあるので詳しくみていく。
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');
webpack、webpack-dev-middleware、webpack-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());
ここでconfigはconfig = 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');
は、
entry: {
app: ['./src_client/index.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-middlewareはSSE(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());
これも、さきほど同様、
は、
plugins: [],
を↓のように更新したのと同じ意味となる。
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の設定で、publicPathをwebpack.config.jsから参照して設定している。直接書いてもOK。
あとは、特筆する部分は特にないので簡単に。
app.use(express.static('./public'));
ここは、コンテンツとなるindex.htmlを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});
});
ここは、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.jsonのscript以下に追加して
"start": "babel-watch ./src_server/server.js"
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のログが表示されているのがわかる。)
これで**/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.jsのmodule.exports
がObjectではなく、functionを返しているときに発生する。
例えば以下の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-middlewareとwebpack-hot-middlewareをexpressに組み込むことによりフロントエンドもサーバーもコードを変更したらすぐにオートリロードで反映し動作確認ができる環境をつくりました
ポイントは以下の部分です
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));
- 本稿で紹介したソースコード一式は https://github.com/riversun/webpack-dev-server-on-express にあげています