メインコンテンツへ移動

webpack HMR

無料2020-05-24#Tool#webpack hot module replacement#webpack hot reload#webpack hot#HMR#热模块替换

HMRとは何か、何の役に立つのか?

一. HMR

Hot Module Replacement(HMR)機能は webpack によって初めて提供され、実行時の JavaScript モジュールをホット更新することができます(フルリロードなしで、モジュールの置換、追加、削除が可能です):

Hot Module Replacement (HMR) exchanges, adds, or removes modules while an application is running, without a full reload.

Hot Module Replacement Concepts より引用)

ページ全体のフルリロードと比較して、モジュールレベルのホット更新の最大の意義は、アプリケーションの現在の実行状態を保持できることにあり、より効率的な Hot Reloading 開発モードを可能にしました。

P.S. その後、他のビルドツールも同様のメカニズムを実装しました。例えば Browserify や、React Native Packager などです。

しかし、ソースコードの編集によって生じるファイルの変化はコンパイル時であり、モジュールの置換は実行時に行われます。これら2つはどのように結びついているのでしょうか?

二. 基本原理

ファイルの変化を検知した後、ビルドツール(HMR プラグイン)に通知され、変化したファイル(モジュール)がアプリケーション内で動作しているランタイムフレームワーク(HMR Runtime)に送信されます。そして、HMR Runtime がこれらのモジュールをモジュールシステムに組み込みます(追加/削除、または既存モジュールの置換)。

ここで、HMR Runtime はビルドツールによってコンパイル時に注入されます。統一されたモジュール ID を通じてコンパイル時のファイルと実行時のモジュールを対応させ、React や Vue などのアプリケーション層フレームワークが連携するための一連の API を公開します。

三. HMR API

最もよく使われるのは accept です:

  • module.hot.accept(dependencies, callback):指定された依存モジュールの更新を監視します。

例えば:

import printMe from './print.js';

if (module.hot) {
  module.hot.accept('./print.js', function() {
    console.log('Accepting the updated printMe module!');
    printMe();
  })
}

accept(コールバック)がトリガーされたときは、新しいモジュールがすでにモジュールシステムに組み込まれたことを意味し、それ以降にアクセスされるのはすべて新しいモジュールのインスタンスになります。

P.S. 完全な例については、Hot Module Replacement Guides を参照してください。

しかし、実際のシナリオではモジュール間に多層的な依存関係が存在することが一般的であり、1つのモジュールを置換すると、それに(直接的または間接的に)依存しているすべてのモジュールに影響が及びます:

となると、すべてのモジュールに同様の更新処理ロジックを追加しなければならないのでしょうか?

通常はその必要はありません。なぜなら、モジュールの更新イベントにはバブリング(冒泡)メカニズムがあるからです。accept で処理されなかった更新イベントは、依存関係チェーンを遡って伝播します。そのため、いくつかの重要なノード(例えば Router コンポーネントなど)で集中的に処理するだけで済みます。

accept 以外にも、以下が提供されています:

  • module.hot.decline(dependencies):依存項目を更新不可としてマークします(ページ全体のフルリロードを期待する場合)。

  • module.hot.dispose/addDisposeHandler(data => {}):現在のモジュールが置換されるときにトリガーされます。リソースのクリーンアップや、(data 引数を通じて)新しいモジュールに状態を引き継ぐために使用されます。

  • module.hot.invalidate():現在のモジュールを無効化し、強制的に更新させるために使用します。

  • module.hot.removeDisposeHandler(callback):モジュール置換イベントの監視を解除します。

P.S. webpack HMR API の詳細については、Hot Module Replacement API を参照してください。

四. HMR Runtime

アプリケーションの視点から見ると、モジュールの置換プロセスは以下の通りです:

  1. アプリケーションが HMR Runtime に更新のチェックを要求します。

  2. HMR Runtime が非同期で更新をダウンロードし、アプリケーションに通知します。

  3. アプリケーションが HMR Runtime にこれらの更新の適用を要求します。

  4. HMR Runtime が同期的に更新を適用します。

(ビルドツールからの)モジュール更新通知を受け取ると、HMR Runtime は Webpack Dev Server に更新マニフェスト(manifest)を問い合わせます。続いて各更新モジュールをダウンロードし、すべての新しいモジュールのダウンロードが完了すると、準備が整い適用フェーズに入ります。

更新マニフェストに含まれるすべてのモジュールを無効としてマークします。無効とマークされた各モジュールについて、そのモジュール内で accept イベント処理が見つからない場合は、上層へバブリングし、その親モジュールも無効としてマークします。これがアプリケーションのエントリモジュールに到達するまで繰り返されます。

その後、すべての無効なモジュールが破棄(dispose)され、モジュールシステムからアンロードされます。最後にモジュールのハッシュ(hash)を更新し、関連するすべての accept イベント処理関数を呼び出します。

五. 実装詳細

実装上、アプリケーションは初期化時に Webpack Dev Server と WebSocket 接続を確立します:

Webpack Dev Server はアプリケーションに対して一連のメッセージを送信します:

o
a["{"type":"log-level","data":"info"}"]
a["{\"type\":\"hot\"}"]
a["{"type":"liveReload"}"]
a["{"type":"hash","data":"411ae3e5f4bab84432bf"}"]
a["{"type":"ok"}"]

ファイルの内容が変化すると、Webpack Dev Server はアプリケーションに通知します:

a["{"type":"invalid"}"]
a["{"type":"invalid"}"]
a["{"type":"hash","data":"a0b08ce32f8682379721"}"]
a["{"type":"ok"}"]

続いて、HMR Runtime が HTTP リクエストを発行してモジュールの更新マニフェストを取得します:

XHR GET http://localhost:8080/411ae3e5f4bab84432bf.hot-update.json
{"h":"a0b08ce32f8682379721","c":{"main":true}}

script タグを通じてすべてのモジュール更新を「ダウンロード」します:

SCRIPT SRC http://localhost:8080/main.411ae3e5f4bab84432bf.hot-update.js
webpackHotUpdate("main", {
  "./src/App.js": (function(module, __webpack_exports__, __webpack_require__) {
    // (新しい)ファイル内容
  })
})

このようにして、実行時の HMR Runtime はコンパイル時のファイルの変化を無事に取得できました。次に、新しいモジュールをモジュールシステム(modules 管理テーブル)に組み込みます:

// insert new code
for (moduleId in appliedUpdate) {
  if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
    modules[moduleId] = appliedUpdate[moduleId];
  }
}

最後に accept イベントを通じて、新しいモジュールを使用して「部分更新」を行うようアプリケーション層に通知します:

// call accept handlers
for (moduleId in outdatedDependencies) {
  module = installedModules[moduleId];
  if (module) {
    moduleOutdatedDependencies = outdatedDependencies[moduleId];
    var callbacks = [];
    for (i = 0; i < moduleOutdatedDependencies.length; i++) {
      dependency = moduleOutdatedDependencies[i];
      cb = module.hot._acceptedDependencies[dependency];
      if (cb) {
        if (callbacks.indexOf(cb) !== -1) continue;
        callbacks.push(cb);
      }
    }
    for (i = 0; i < callbacks.length; i++) {
      // 触发accept模块更新事件
      cb(moduleOutdatedDependencies);
    }
  }
}

これで、全容が明らかになりました。

参考文献

コメント

コメントはまだありません

コメントを書く