ReactのHMR環境を作る

昨今のトランスパイルやバンドル作業が行われるWEBフロント開発では書いたコードがそのままブラウザで動かないために、WEBブラウザで実行確認するためにはビルド・ブラウザリロードの手間が必要です。HMR(Hot Module Replacement)環境を構築すると、コードが書き変えを監視していて必要時に自動的にビルドとブラウザリロードがなされて最新の状態を見ることができるようになります。webpackではHMR用途にwebpack-dev-serverが用意されていて、その構成や設定は結構シンプルになっています。

ES6のHMR

$ npm install -save-dev webpack-dev-server

npmで取ってきたら最新安定バージョンは2.7.1でした。

/* webpack.config.babel.js */
import path from 'path';
import { HotModuleReplacementPlugin, NoEmitOnErrorsPlugin } from 'webpack';

export default {
    entry: './js/App.jsx',
    output: {
        path: path.resolve('static/js'),
        filename: 'bundle.js',
        /* バンドルファイルを /bundle.js から /js/bundle.js へ調整 */
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(js|jsx)$/,
            exclude: /node_modules/,
            use: ['babel-loader', 'eslint-loader'],
        }],
    },
    resolve: {
        extensions: ['.js', '.jsx'],
    },
    devServer: {
        /* hot設定大事 */
        hot: true,
        /* APIサーバのポート競合回避のために追加 */
        port: 9000,
        /* 全部プロクシする設定。バンドルファイルだけプロクシされない */
        proxy: {
            '/': {
                target: 'http://localhost:8080',
                secure: false,
            },
        },
    },
    plugins: [
        /* devserver.hot=trueの設定とでホット環境を作る */
        new HotModuleReplacementPlugin(),
        new NoEmitOnErrorsPlugin(),
    ],
};

プロジェクトの構成がwebpack-dev-serverを意識した作りにしていればnpmインストールだけで何も設定せずとも動くのですが、私の場合は既存のプロジェクトが以下の調整を必要としました。

  • バンドルされたJSコードが、サーバルートではなく、/js/bundle.js と一階層下に配置していた。outputエントリの中に「publicPath」設定で/js/をURLに挿入する。
  • APIサーバがあり、ポート8080番で動いているのでwebpack-dev-serverとそのままではポート競合する。devServerエントリの中に、「port」設定でとりあえず9000番に設定した。
  • さらにwebpack-dev-serverで受けたAPIリクエストをAPIサーバにそのまま渡すためにプロクシの設定を行う。「proxy」設定で転送先の情報を記述する。この設定でバンドルファイル以外は全部プロクシされる
  • HotModuleReplacementPluginはドキュメントによってはあったりなかったりするんだけど、いれないとHMRにならない。HotModuleReplacementPlugin登録は必要。

ReactのHMR

上記までで、ES6を用いたWEBフロントのHMR環境は整います。私はdirenvを入れて/node_module/.binにパスを通しているのでおもむろに

$ webpack-dev-server --open

でターミナルから起動すればビルドが走り、自動でWEBブラウザも開きます。npmスクリプト設定にも書いておきました。 --openスイッチはブラウザを開くかどうかだけなので付けなくても支障ない。

// package.jsonにも「start」で登録しておく
{
  "scripts": {
    "build": "rimraf ./static/js/*; webpack",
    "start": "webpack-dev-server",
    "test": "jest",
    "lint": "eslint --ext [.jsx,.js] js"
  },
}

しかし、これだけでは普通のアプリならいいらしいけど、私のReact使ったアプリでは問題あります。ソースを書き換えるとブラウザをリロードしてしまうために画面が一旦フラッシュされてしまう。そのためアプリの状態などを失ってしまいます。Reactのアプリケーションはさらに設定を重ねて該当Reactコンポーネントだけ再描画するHMRの特別な環境を上乗せしないといけません。

$ npm install -save react-hot-loader@next
  • 必要なものをnpmでインストールします。アプリケーションを書き換える必要があり、その際にeslintが怒るのでreact-hot-loaderはdependenciesのほうに入れます(-saveスイッチ)。まだ開発バージョンのものを@nextでインストール。現バージョンは 3.0.0-beta.7 でした。
// package.json
{
  "babel": {
    "presets": [
      ["es2015", {"modules": false}],
      "stage-1",
      "react"
    ],
    "plugins": [
      "react-hot-loader/babel",
      "transform-decorators-legacy"
    ]
  }
}

結論としてBabelの設定を今回のReact HMRのために設定を変えないといけないのでこまりました。この設定はeslintやJestとも共有していて、package.jsonに書いてましたが、他に影響しないようにwebpack.config.babel.jsのほうに設定するようにしてもダメだった。初回のビルド自体は問題なくともソース変更時にうまく表示更新がされなくなってしまいます(たぶん今後の修正で治ってくる雰囲気の問題のように思います)。es2015プリセットでmoduleをfalseにしているのはwebpack2(私の実際は3.5.5)だからです。これと、react-hot-loader/babelプラグインを足すのが必要要件。そしてこのように大元のBabel設定を書き換えてしまったために、webpackのconfigは非Babelに直しました。

/* webpack.config.js に非Babel書き換え。*/
const path = require('path');
const webpack = require('webpack');

module.exports = {
    /* React HMRのためにビルド対象に追加 */
    entry: [
        'react-hot-loader/patch',
        'webpack-dev-server/client?http://localhost:9000',
        'webpack/hot/only-dev-server',
        './js/App.jsx',
    ],
    output: {
        path: path.resolve('static/js'),
        filename: 'bundle.js',
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(js|jsx)$/,
            exclude: /node_modules/,
            use: [loader: 'babel-loader', 'eslint-loader'],
        }],
    },
    resolve: {
        extensions: ['.js', '.jsx'],
    },
    devServer: {
        hot: true,
        port: 9000,
        proxy: {
            '/': {
                target: 'http://localhost:8080',
                secure: false,
            },
        },
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin(),
    ],
};
  • 現バージョンだとwebpackのconfig自体にBabelを利用するとHMRが効かなくなるため非Babelでやる。
  • webpack.config.babel.jsでバンドル対象に、react-hot-loader/patchなどを追加します。
// App.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import { AppContainer } from 'react-hot-loader';

// import Root from './Root';
import Root from './Root2';

const render = () => {
    ReactDOM.render(
        <AppContainer><Root /></AppContainer>, 
        document.getElementById('application'),
    );
};

render();

if (module.hot) {
    // module.hot.accept('./Root', () => { render(); });
    module.hot.accept('./Root2', () => { render(); });
}
  • アプリケーションのメインルーチンのところでコードを書き換える必要があります。react-hot-loaderのAppContainerコンポーネントで元のアプリを囲うのと、module.hot関連のこちらもおまじない的コード。

これでReactのアプリがコード書き換えに即時反映するようになりました。

// Root2.jsx: 非react-router3アプリとしてHMR確認用。
import React, { Component } from 'react';

class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = { counter: 0 };
    }

    componentDidMount() {
        this.interval = setInterval(this.tick.bind(this), 1000);
    }

    componentWillUnmount() {
        clearInterval(this.interval);
    }

    tick() {
        this.setState({ counter: this.state.counter + 1 });
    }

    render() {
        return <h2>Counter: {this.state.counter}</h2>;
    }
}

export default () => (
    <div>
        <h1>Hello, world!</h1>
        <Counter />
    </div>
);

こちらのh1タグTEXTを書き換えて保存すると、ビルドが自動で走り、ブラウザが全部再読み込みされず変更箇所だけ変わる真のHMRが確認できます。カウンターの値が0に戻らずにインクリメントし続けてちょっと感動。ここまではOK。ちゃんと動く。

しかし全画面再描画される

シンプルなReactアプリではこれでOKですが、私のはダメ。react-router使ったものはブラウザの全部再読み込みが発生してしまう。先の例ではRoot2.jsxを使ってますが本来のコードは下記のようなものです。バリバリreact-router3かつredux。

// Root.jsx: react-router3アプリ
import React from 'react';
import injectTapEventPlugin from 'react-tap-event-plugin';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { Router, Route, browserHistory, IndexRedirect } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
import { reducer } from 'redux-form';

import { BookList, BookReader, NotFound, Navigation } from './containers';
import * as reducers from './reducers';
import cookiesMiddleware from './middlewares/CookiesMiddleware';

injectTapEventPlugin();

const store = createStore(
    combineReducers({ ...reducers, form: reducer }),
    applyMiddleware(cookiesMiddleware()),
);

export default () => (
    <Provider store={store}>
        <Router history={syncHistoryWithStore(browserHistory, store)}>
            <Route path="/" component={Navigation}>
                <IndexRedirect to="list" />
                <Route path="list" component={BookList} />
                <Route path=":title/:vol/:page" component={BookReader} />
                <Route path="*" component={NotFound} />
            </Route>
        </Router>
    </Provider>
);

react-router 4.xにしないといけないのか?react-router 4にあげていなかった理由はreact-router-reduxがベータ版のためでしたが、react-router-reduxは実装コード量がとても少ないライブラリなのでなんかあっても読めばいいかと。重い腰あげてマイグレーション作業をやるとしますか。

github.com

react-router 3のバージョン問題は、しっかりreact-hot-loaderのドキュメントにKnown limitationsとして書かれていた。「React Router v3 is not fully supported 」どころではなく、私の場合は全くホットじゃない。