dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

React Hot Loader 3 と webpack 3 でHot Module Replacement(react-hmreからの移行)

経緯

dackdive.hateblo.jp

これまで webpack-dev-server の Hot Module Replacement を React/Redux でも有効にするために babel-preset-react-hmre を使っていたが、いつの間にか GitHubリポジトリがなくなっていて deprecated ぽいので別のプラグインを探していた。

公式ドキュメント を読む限り React Hot Loader が一番有力のようなので、そちらへ移行する。

サンプルコード(GitHub

先に、今回の移行後のコードが確認できる GitHub リポジトリを載せておく。

PR は #4, #6


設定手順

ここを読む。
Getting Started · React Hot Loader

※以下、(Step X) は上記ドキュメント中の Step と対応している

React Hot Loader のインストール

2017/08/09 時点で v3 はベータ版のようなので、 @next をつけてインストールする。

$ npm install --save-dev react-hot-loader@next


webpack 側の設定をする (Step1)

ここについては webpack-dev-server をどのように利用しているかによってやることが異なる。

(Option 1) webpack-dev-server をスタンドアロンなサーバーとしてそのまま利用している場合

これまで通り --hot オプションつきで実行すれば OK。

// package.json
"scripts": {
    "start": "webpack-dev-server --hot"
  },
(Option 2) webpack-dev-server をカスタマイズして使っている場合

未検証。ドキュメントの通りにやればいいのでは。

(Option 3) の webpackDevMiddleware, webpackHotMiddleware として Express に組み込んで使っている場合

自分の場合はこれ。
参考:webpack-dev-serverをExpressに組み込んで使う(webpack-dev-middleware, webpack-hot-middleware) - dackdive's blog

ドキュメントは TODO になっていたが、以下ができていれば特に設定変更は不要だった。

  • webpack.config.js の entry'webpack-hot-middleware/client' を追加する
  • webpack.config.js の pluginsnew webpack.HotModuleReplacementPlugin() を追加する
  • Express 側で以下のように middleware を組み込む
import express from 'express';
import path from 'path';

const app = express();
const port = process.env.PORT || 8080;

if (process.env.NODE_ENV !== 'production') {
  /* eslint-disable global-require, import/no-extraneous-dependencies */
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const webpackHotMiddleware = require('webpack-hot-middleware');
  const config = require('./webpack.config.dev.babel').default;
  const compiler = webpack(config);

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

// Serve static files
app.use(express.static(path.join(__dirname, 'public')));

console.log(`Served: http://localhost:${port}`);
app.listen(port, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`);
  }
});

詳細は リポジトリ を参照。


Babel または webpack に react-hot-loader を追加する (Step 3 の 2)

2 通りの方法がある。
.babelrc

{
  "plugins": [
    "react-hot-loader/babel"
  ]
}

を追加するか、または webpack.config.js 側に

    module: {
        loaders: [{
            test: /\.js$/,
            loaders: ['react-hot-loader/webpack', 'babel'],
            include: path.join(__dirname, 'src')
        }]
    }

を追加する。

いずれも、 NODE_ENV === development のときだけ、とは書かれていなかった。


アプリのエントリーポイントに react-hot-loader/patch を追加する (Step 3 の 3)

こちらも 2 通りの方法がある。
アプリのルートコンポーネントとなる JS ファイルの1行目に

import 'react-hot-loader/patch';

を追加するか、または webpack.config.js の entry

  entry: [
    'react-hot-loader/patch', // これを追加
    './scripts/index' // アプリのエントリーポイント
  ]

を追加する。

自分は前者の方にした。


ルートコンポーネントに HMR の仕組みを入れる (Step 2)

最後に、アプリのルートコンポーネントに HMR の仕組みを入れる。

https://github.com/zaki-yama/redux-express-template/blob/master/public/index.js

import 'react-hot-loader/patch';
import React from 'react';
import { Provider } from 'react-redux';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import configureStore from './store/configureStore';
import App from './containers/App';

import './styles/index.scss';

const store = configureStore();

const rootEl = document.getElementById('root');

render(
  <AppContainer>
    <Provider store={store}>
      <App />
    </Provider>
  </AppContainer>,
  rootEl,
);

if (module.hot) {
  module.hot.accept('./containers/App', () => {
    render(
      <AppContainer>
        <Provider store={store}>
          <App />
        </Provider>
      </AppContainer>,
      rootEl,
    );
  });
}

babel-preset-react-hmre のときはこのあたり自前で実装する必要はほぼなかったんだけど、React Hot Loader だと if (module.hot) { ... } 部分の処理が必要になるらしい。

<Provider store={store}>
  <App />
</Provider>

部分は Redux を使っているときのお決まりの書き方だが、これをさらに React Hot Loader が提供している <AppContainer /> というコンポーネントでラップする。


余談:ハマったポイント

ドキュメントでは

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

render(RootContainer);

if (module.hot) {
  module.hot.accept('./containers/rootContainer', () => { render(RootContainer) });
}

というように記載されていたので、最初は以下のようにした。

// index.js
import 'react-hot-loader/patch';
import React from 'react';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import Root from './containers/Root';

const renderApp = (Component) => {
  render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('root'),
  );
};

renderApp(Root);

if (module.hot) {
  module.hot.accept('./containers/Root', () => {
    renderApp(Root);
  });
}
// ./containers/Root.js
import React from 'react';
import { Provider } from 'react-redux';

import configureStore from '../store/configureStore';
import App from './App';

import '../styles/index.scss';

const store = configureStore();

export default class Root extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <App />
      </Provider>
    );
  }
}

が、Redux の store が絡むとうまくいかないのか、上記のようにすると HMR 時にこのようなエラーが発生した。

does not support changing store on the fly. It is most likely that you see this error because you updated to Redux 2.x and React Redux 2.x which no longer hot reload reducers automatically. See https://github.com/reactjs/react-redux/releases/tag/v2.0.0 for the migration instructions.

調べてみたところ
Hot reload not working · Issue #502 · reactjs/react-redux
こちらの Issue が見つかり、さらにそこから redux-devtools の TodoMVC の example更新されていた のを見つけたので
example のコードを確認し、解決した。

また React Hot Loader の Starter Kit で紹介されている大量のリポジトリの中から

なんかを読むと、(src/client/index.js の部分)

import 'react-hot-loader/patch';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Provider } from 'react-redux';
import { AppContainer } from 'react-hot-loader';

import configureStore from './store/configureStore';
import App from './containers/App';

import './styles/index.scss';

const store = configureStore();

const rootEl = document.getElementById('root');
const renderApp = () => {
  render(
    <AppContainer>
      <Provider store={store}>
        <App />
      </Provider>
    </AppContainer>,
    rootEl,
  );
};

renderApp();

if (module.hot) {
  module.hot.accept('./containers/App', () => {
    setImmediate(() => {
      unmountComponentAtNode(rootEl);
      renderApp();
    });
  });
}

というように一旦アンマウントしてから再描画でもうまくいくらしい。本当にこれが正しいのかは不明。