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

ホットリローディングの導入

·9分で読めます
Martín Bigio
Instagram ソフトウェアエンジニア

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

私たちは3つの主要な機能を通じてこの理想に近づきました。

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

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

ホットリロード

ホットリロードの背後にある考え方は、アプリを実行し続け、編集したファイルの新しいバージョンを実行時に注入することです。これにより、アプリの状態が失われず、特にUIを微調整している場合に便利です。

動画は百聞に一見に如かずです。Live Reload(現在)とHot Reload(新規)の違いをご覧ください。

よく見ると、レッドボックスから回復できること、また、以前は存在しなかったモジュールをフルリロードなしでインポートし始めることができることにお気づきでしょう。

注意: JavaScriptは非常にステートフルな言語であるため、ホットリロードは完全には実装できません。実際には、現在の設定は多くの一般的なユースケースでうまく機能しており、何かがおかしくなった場合には常にフルリロードが利用可能です。

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

  • 開発者メニューを開きます
  • 「Enable Hot Reloading」をタップします

実装の概要

なぜそれが必要で、どのように使用するのかを見てきましたが、ここからが楽しい部分です:実際にどのように機能するのか。

ホットリロードはHot Module Replacement(HMR)という機能の上に構築されています。これはwebpackによって最初に導入され、私たちはReact Native Packager内に実装しました。HMRにより、Packagerはファイルの変更を監視し、HMRアップデートをアプリに含まれる薄いHMRランタイムに送信します。

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

HMRアップデートには、変更したいモジュールのコードだけでなく、もう少し多くの情報が含まれています。なぜなら、それを置き換えるだけでは、ランタイムが変更を認識するのに十分ではないからです。問題は、モジュールシステムが更新したいモジュールの*exports*をすでにキャッシュしている可能性があることです。例えば、以下の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のためのものがあります。

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

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

では、アプリを起動してlogをrequireするとします。この時点では、logtimeのファクトリ関数も実行されていないため、exportsはキャッシュされていません。その後、ユーザーがtimeを修正して日付をMM/DD形式で返すようにします。

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

module.exports = bar;

Packagerはtimeの新しいコードをランタイムに送信し(ステップ1)、最終的にlogがrequireされると、エクスポートされた関数が実行され、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がrequireされると、ランタイムはそのexportsとtimeのexportsをキャッシュします(ステップ1)。その後、timeが変更された場合、HMRプロセスは単にtimeのコードを置き換えただけでは完了できません。もしそうした場合、logが実行されるとき、timeのキャッシュされたコピー(古いコード)で実行されてしまいます。

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

HMR API

React NativeのHMRは、hotオブジェクトを導入することでモジュールシステムを拡張します。このAPIはwebpackのものに基づいています。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アップデートを受け入れるだけでは不十分な場合があります。例えば、moviesアプリの例の依存関係ツリーに、トップレベルのMovieRouterがあり、それがMovieSearchMovieScreenビューに依存し、さらにそれらが前の例のlogtimeモジュールに依存していたとします。

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

依存関係ツリーをたどるために、ランタイムはHMRアップデートでPackagerから逆依存関係ツリーを受け取ります。この例では、ランタイムは次のような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 transformを実装しました。要するに、彼の解決策は、*トランスフォーム時*にすべてのReactコンポーネントのプロキシを作成することによって機能します。プロキシはコンポーネントの状態を保持し、ライフサイクルメソッドを実際のコンポーネントに委任します。この実際のコンポーネントがホットリロードされるものです。

プロキシコンポーネントを作成するだけでなく、このtransformはReactにコンポーネントの再レンダリングを強制するコードでaccept関数も定義します。これにより、アプリの状態を失うことなくレンダリングコードをホットリロードできます。

React Nativeに付属するデフォルトのtransformerbabel-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を使えば、アプリの構築方法を再考し、素晴らしい開発者体験を実現する機会があります。ホットリロードはパズルのピースの一つにすぎません。それをより良くするために、他にどんなクレイジーなハックができるでしょうか?