メインコンテンツへスキップ

ホットリローディングの紹介

9分間の読書
Martín Bigio
Instagramのソフトウェアエンジニア

React Nativeの目標は、可能な限り最高の開発者エクスペリエンスを提供することです。その大きな部分を占めるのは、ファイルの保存から変更を確認できるようになるまでの時間です。アプリが成長しても、このフィードバックループを1秒以内にすることを目標としています。

私たちは、主に3つの機能によってこの理想に近づきました。

  • JavaScriptを言語として使用することで、長いコンパイルサイクル時間を回避します。
  • es6/flow/jsxファイルをVMが理解できる通常のJavaScriptに変換するPackagerと呼ばれるツールを実装しました。これは中間状態をメモリに保持して高速な増分変更を可能にし、複数のコアを使用するサーバーとして設計されました。
  • 保存時にアプリをリロードするライブリロード機能を構築しました。

この時点では、開発者のボトルネックはもはやアプリのリロードにかかる時間ではなく、アプリの状態を失うことです。よくあるシナリオは、起動画面から複数の画面離れた機能に取り組むことです。リロードするたびに、同じパスを何度もクリックして機能に戻らなければならず、サイクルが数秒長くなります。

ホットリローディング

ホットリローディングの考え方は、アプリを実行し続け、編集したファイルの新しいバージョンをランタイムで注入することです。これにより、特にUIを調整している場合は、状態を失うことはありません。

百聞は一見に如かず。ライブリロード(現在)とホットリロード(新規)の違いを確認してください。

よく見ると、レッドボックスから復旧することができ、完全なリロードを行うことなく、以前は存在しなかったモジュールのインポートを開始することもできることがわかります。

警告:JavaScriptは非常に状態を保持する言語であるため、ホットリローディングは完璧に実装できません。実際には、現在の設定は多くの一般的なユースケースでうまく機能しており、何かがうまくいかない場合は常に完全なリロードが可能です。

ホットリローディングは0.22から利用可能です。有効にするには

  • 開発者メニューを開きます。
  • 「ホットリローディングを有効にする」をタップします。

実装の概要

なぜホットリローディングが必要なのか、そしてどのように使用するのかを見てきたので、楽しい部分に入ります。それは実際どのように機能するのでしょうか。

ホットリローディングは、ホットモジュール置換(HMR)と呼ばれる機能を基盤として構築されています。これは最初にwebpackによって導入され、React Native Packager内で実装されました。HMRにより、Packagerはファイルの変更を監視し、アプリに含まれる薄いHMRランタイムにHMRアップデートを送信します。

簡単に言うと、HMRアップデートには変更されたJSモジュールの新しいコードが含まれています。ランタイムがそれらを受け取ると、古いモジュールのコードを新しいコードに置き換えます。

HMRアップデートには、変更したいモジュールのコード以上のものも含まれています。なぜなら、コードを置き換えるだけでは、ランタイムが変更を検出するのに十分ではないからです。問題は、モジュールシステムが既に更新したいモジュールの*エクスポート*をキャッシュしている可能性があることです。例えば、次の2つのモジュールで構成されるアプリがあるとします。

// log.js
function log(message) {
const time = require('./time');
console.log(`[${time()}] ${message}`);
}

module.exports = log;
// time.js
function time() {
return new Date().getTime();
}

module.exports = time;

モジュール`log`は、モジュール`time`によって提供される現在の日付を含む、提供されたメッセージを出力します。

アプリがバンドルされると、React Nativeは`__d`関数を使用して各モジュールをモジュールシステムに登録します。このアプリでは、多くの`__d`定義の中で、`log`の定義が1つあります。

__d('log', function() {
... // module's code
});

この呼び出しは、一般的にファクトリ関数と呼ばれる匿名関数に各モジュールのコードをラップします。モジュールシステムランタイムは、各モジュールのファクトリ関数が既に実行されているかどうか、およびその実行結果(エクスポート)を追跡します。モジュールが要求されると、モジュールシステムは既にキャッシュされたエクスポートを提供するか、またはモジュールのファクトリ関数を初めて実行し、その結果を保存します。

アプリを起動して`log`を要求するとします。この時点では、`log`と`time`のファクトリ関数のどちらも実行されていないため、エクスポートはキャッシュされていません。次に、ユーザーが`time`を修正して、`MM/DD`で日付を返すようにします。

// time.js
function bar() {
const date = new Date();
return `${date.getMonth() + 1}/${date.getDate()}`;
}

module.exports = bar;

Packagerは`time`の新しいコードをランタイムに送信し(ステップ1)、`log`が最終的に要求されると、エクスポートされた関数が実行され、`time`の変更が反映されます(ステップ2)。

次に、`log`のコードが最上位レベルのrequireとして`time`を要求するとします。

const time = require('./time'); // top level require

// log.js
function log(message) {
console.log(`[${time()}] ${message}`);
}

module.exports = log;

`log`が要求されると、ランタイムはそれ自身のエクスポートと`time`のエクスポートをキャッシュします(ステップ1)。次に、`time`が変更されると、HMRプロセスは`time`のコードを置き換えた後も単純に終了することはできません。もしそうした場合、`log`が実行されると、`time`のキャッシュされたコピー(古いコード)を使用して実行されます。

`log`が`time`の変更を反映するには、依存するモジュールの1つがホットスワップされたため、キャッシュされたエクスポートをクリアする必要があります(ステップ3)。最後に、`log`が再び要求されると、そのファクトリ関数が実行され、`time`を要求し、新しいコードを取得します。

HMR API

React NativeにおけるHMR(Hot Module Replacement)は、`hot`オブジェクトを導入することでモジュールシステムを拡張します。このAPIはwebpackのAPIに基づいています。`hot`オブジェクトは`accept`という関数を公開しており、モジュールのホットスワップが必要になった際に実行されるコールバック関数を定義できます。例えば、`time`モジュールのコードを以下のように変更した場合、`time`を保存するたびにコンソールに「time changed」と表示されます。

// time.js
function time() {
... // new code
}

module.hot.accept(() => {
console.log('time changed');
});

module.exports = time;

ただし、このAPIを手動で使用する必要があるのは稀なケースです。ほとんどの一般的なユースケースでは、ホットリローディングはすぐに動作します。

HMRランタイム

これまで見てきたように、HMRアップデートを受け入れるだけでは不十分な場合があります。ホットスワップされるモジュールを使用するモジュールは既に実行されていて、そのインポートがキャッシュされている可能性があるためです。例えば、映画アプリの例で、トップレベルの`MovieRouter`が`MovieSearch`と`MovieScreen`というビューに依存し、それらが前の例の`log`と`time`モジュールに依存しているとします。

ユーザーが映画の検索ビューにアクセスし、他のビューにアクセスしなかった場合、`MovieScreen`を除くすべてのモジュールはエクスポートをキャッシュしています。`time`モジュールに変更を加えると、ランタイムは`time`の変更を反映するために`log`のエクスポートをクリアする必要があります。このプロセスはそこで終わりません。ランタイムは、すべての親モジュールが受け入れられるまで、このプロセスを再帰的に繰り返します。そのため、`log`に依存するモジュールを取得し、それらを قبولしようとします。`MovieScreen`については、まだ要求されていないため、処理を中断できます。`MovieSearch`については、エクスポートをクリアし、その親を再帰的に処理する必要があります。最後に、`MovieRouter`についても同様の処理を行い、それ以降に依存するモジュールがないためそこで終了します。

依存関係ツリーを辿るために、ランタイムはHMRアップデート時にパッケージャーから逆依存関係ツリーを受け取ります。この例では、ランタイムは次のようなJSONオブジェクトを受け取ります。

{
modules: [
{
name: 'time',
code: /* time's new code */
}
],
inverseDependencies: {
MovieRouter: [],
MovieScreen: ['MovieRouter'],
MovieSearch: ['MovieRouter'],
log: ['MovieScreen', 'MovieSearch'],
time: ['log'],
}
}

Reactコンポーネント

Reactコンポーネントは、ホットリローディングで動作させるのが少し難しいです。問題は、古いコードを新しいコードで単純に置き換えることができないことです。そうすると、コンポーネントの状態が失われてしまうからです。React Webアプリケーションでは、Dan AbramovがwebpackのHMR APIを使用してこの問題を解決するbabel トランスフォームを実装しました。簡単に言うと、彼の解決策は、トランスフォーム時にすべてのReactコンポーネントのプロキシを作成することによって機能します。プロキシはコンポーネントの状態を保持し、ライフサイクルメソッドを実際のコンポーネント(ホットリロードするコンポーネント)に委譲します。

プロキシコンポーネントを作成することに加えて、トランスフォームはReactにコンポーネントの再レンダリングを強制するコードを使用して`accept`関数を定義します。このようにして、アプリケーションの状態を失うことなく、レンダリングコードをホットリロードできます。

React Nativeに付属するデフォルトのトランスフォーマーは`babel-preset-react-native`を使用しており、これは設定されており、webpackを使用するReact Webプロジェクトと同様に`react-transform`を使用します。

Reduxストア

Reduxストアでホットリローディングを有効にするには、webpackを使用するWebプロジェクトで行うのと同様にHMR APIを使用するだけです。

// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';

export default function configureStore(initialState) {
const store = createStore(
reducer,
initialState,
applyMiddleware(thunk),
);

if (module.hot) {
module.hot.accept(() => {
const nextRootReducer = require('../reducers/index').default;
store.replaceReducer(nextRootReducer);
});
}

return store;
};

Reducerを変更すると、そのReducerを受け入れるコードがクライアントに送信されます。その後、クライアントはReducerが自身を受け入れる方法を知らないことに気づき、それを参照するすべてのモジュールを探して、それらを قبولしようとします。最終的に、フローは単一のストアである`configureStore`モジュールに到達し、そこでHMRアップデートを受け入れます。

結論

ホットリローディングの改善に協力することに興味がある場合は、Dan Abramovによるホットリローディングの未来に関する投稿を読んで、貢献することをお勧めします。例えば、Johny Daysは複数の接続されたクライアントで動作するようにする予定です。この機能の維持と改善には、皆様のご協力が必要です。

React Nativeを使用すると、開発者にとって優れたエクスペリエンスにするために、アプリの構築方法を再考する機会があります。ホットリローディングはパズルのほんの一部です。他にどのような素晴らしいハックで改善できるでしょうか?