ホットリローディングの導入
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するとします。この時点では、log
もtime
のファクトリ関数も実行されていないため、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
のキャッシュされたコピー(古いコード)で実行されてしまいます。
log
がtime
の変更を反映するためには、依存するモジュールの一つがホットスワップされたため、キャッシュされた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
があり、それがMovieSearch
とMovieScreen
ビューに依存し、さらにそれらが前の例のlog
とtime
モジュールに依存していたとします。
ユーザーが映画の検索ビューにはアクセスしたが、もう一方にはアクセスしていない場合、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に付属するデフォルトのtransformerは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を使えば、アプリの構築方法を再考し、素晴らしい開発者体験を実現する機会があります。ホットリロードはパズルのピースの一つにすぎません。それをより良くするために、他にどんなクレイジーなハックができるでしょうか?