Edited at

モジュールをHMRに対応するための実装について

More than 3 years have passed since last update.

HMR(Hot Module Replacement)はWebpackが提供する、ブラウザのリロードをすること無くアプリケーションのJSを更新する開発ツールです。

ReactではBabelやWebpackのプラグインでアプリケーションコードに注入することで実現していますが、React以外でもHMRを行うことができます。ただし、HMRに対応させるためにはいくつかモジュールやアプリケーションに合わせた実装が必要です。

ReactがどうやってHMRに対応しているかを理解するため、Reactでないアプリケーションで必要な実装について、そしてReactの場合何をやっているかをまとめました。

このエントリの内容については、(そのままではありませんが)githubにあげているので参考にして下さい。

また、HMRを含めた環境のセットアップはReact開発環境を構築する時に出てくるbabelやwebpackの設定を0から行うためのハンズオンに、HMRの設定が何をやっているかについては、Hot Module Replacementの設定と仕組みを理解するを書いているので、興味があればそちらも見て下さい。


自分が差し替え可能である事を明示する

HMRに対応させるには、そのモジュールが差し替え可能であることをwebpackに伝える必要があります。もし変更されたモジュールが差し替え可能でない場合、webpackは呼び出しモジュールを遡って直近の差し替え可能なモジュールを探します。デフォルトでは差し替え可能なモジュールが無い場合は、画面を再描画されます。

モジュールが差し替え可能であることを明示するには、以下のようなコードを書きます。


hello.js

module.exports = 'hello';

if (module.hot) {
// このモジュールが差し替え可能とする
module.hot.accept(function(err) {
if (err) {
console.error(err);
}
});
}


HMRを起動した状態で、hello.jsを修正すると、モジュールが差し替わります。

ただし、このモジュールが更新されたことが親階層に伝わらないため、requireのされ方によっては変更を適用できません。たとえば、以下のようにように利用されている場合、sayHelloは既にhelloモジュールを変数に取得しているため、変更を適用することができないのです。

基本的にはexportしているモジュールでacceptを呼ぶべきではありません。


sayHello.js

var message = require('hello');

// helloモジュールの変更が適用されない
function sayHello() {
window.alert(message);
}

// これなら大丈夫
function sayHello2() {
window.alert(require('hello'));
}



副作用に対応する

イベントハンドラの登録や、画面への描画等を行うような、副作用のあるモジュールを差し替えた場合はさらに実装が必要です。以下のような実装をしていると、モジュールがリロードされるたびにonclickのイベントが登録されてしまいます。そのため、モジュールのリロード時に副作用を破棄するために、module.hot.disposeを実装する必要があります。

上の例と同様に、exportしている場合はacceptするのは基本的にはNGです。


eventHandler.js

var hello = require('./hello');

function handleOnClick() {
window.alert(hello + '!');
}

document.getElementById('hello').addEventListener('click', handleOnClick);

if (module.hot) {
module.hot.accept(function(err) {
if (err) {
console.error(err);
}
});
module.hot.dispose(function() {
// eventHandlerを解除する
document.getElementById('hello').removeEventListener('click', handleOnClick);
})
}



モジュールの状態を引き継ぐ

モジュールが状態を持つ場合、モジュールの差し替え時に失われてしいます。module.hot.dataを利用すれば、状態を古いモジュールから新しいモジュールに引き継ぐために利用できます。


store.js

var list = [];

export.module = function(value) {
list.push(value);
}

if (module.hot) {
// module.hot.dataから以前のモジュールの状態にアクセスして取得する
if (moudle.hot.data) {
list = module.hot.data.list;
}
module.hot.dispose(function(data) {
// 新しいモジュールに引き継ぐ状態を渡す
data.list = list;
})
}



実装の基本的な方針について

実装の方針としては、


  1. アプリケーションのエントリーポイントでは、acceptを実装

  2. 副作用がないモジュールでは、何もしない

  3. 副作用のあるモジュールは、disposeを実装

  4. exportしていないモジュールでは、acceptを実装(適宜)

という感じでしょうか。実装次第で幾つかやり方があるかと思います。特に4. については、acceptするモジュールが多ければ多いほど細かい単位でモジュールの差し替えができるためオーバーヘッドは少なくなると思います。


Reactのコンポーネントを対応させる

Reactでは以前のエントリに書いたようにbabelのプラグインで対応させることができますが、自分で実装するとこんな感じです。

コンポーネントのstateを保持させるためには上の状態を引き継ぐ例のように書くこともできると思いますが、、すでにインスタンス化されているコンポーネントに対してstateを引き継がせるために、違うやり方でやってみます。

方針としては、

1. 既にインスタンス化をされたものはそのまま利用することでstateを保持させる

2. 変更した振る舞い(メソッドなど)は、プロトタイプチェーンを用いて新しい振る舞いをさせる

です。


index.js

import React from 'react';

import ReactDOM from 'react-dom'
import Todo from './Todo';

const rendered = ReactDOM.render(<Todo buttonLabel='click'/>, document.getElementById('content'));

// 最上部のインスタンスを保持しておく
window.rootComponent = rendered;



todo.js

import React from 'react';

class Todo extends React.Component {

constructor(props) {
super(props);

this.state = {
list: []
};
}

refCallback(ref) {
this.inputRef = ref;
}

handleOnClick() {
this.setState({
list: [...this.state.list, this.inputRef.value]
})
}

render() {
const { buttonLabel } = this.props;
return (
<div>
<input ref={::this.refCallback} type='text'/>
<button onClick={::this.handleOnClick}>{buttonLabel}</button>
<ul>
{this.state.list.map((value, index) => <li key={index}>{value}</li>)}
</ul>
</div>
)
}
}

window.proxy = window.proxy || {};
window.proxy.Todo = window.proxy.Todo || {};
window.proxy.Todo.proto = window.proxy.Todo.proto || {};

const proto = window.proxy.Todo.proto;

// プロトタイプチェーンで常に最新のTodo.prototypeを参照させる
proto.__proto__ = Todo.prototype;

// グローバルにキャッシュしたオブジェクトをコンポーネントのprototypeに渡す
Todo.prototype = proto;

export default Todo;

if (module.hot) {
// モジュールが読み込まれた際に、最上部から強制的にリレンダリングさせる
window.rootComponent && window.rootComponent._reactInternalInstance._instance.forceUpdate();

module.hot.accept(function(err) {
if (err) {
console.error(err);
}
});
}


通常Reactアプリケーションで利用するreact-transform-hmrなどでは上記のような処理の注入をbabelのコンパイル時に各モジュールについて行うことで、HMRを実現しています。