ホットリローディングの導入
React Nativeの目標は、可能な限り最高の開発者体験を提供することです。その大部分を占めるのが、ファイルを保存してから変更を確認できるまでの時間です。私たちの目標は、アプリが成長してもこのフィードバックループを1秒未満にすることです。
私たちは3つの主要な機能を通じてこの理想に近づきました。
- 言語としてJavaScriptを使用することで、長いコンパイルサイクルタイムがなくなります。
- Packagerと呼ばれるツールを実装しました。これはes6/flow/jsxファイルを、VMが理解できる通常のJavaScriptに変換します。これはサーバーとして設計されており、中間状態をメモリに保持することで高速な差分変更を可能にし、複数のコアを使用します。
- 保存時にアプリをリロードするLive Reloadという機能を構築しました。
現時点では、開発者のボトルネックはアプリのリロードにかかる時間ではなく、アプリの状態を失うことです。一般的なシナリオとして、起動画面から複数の画面離れた機能に取り組む場合があります。リロードするたびに、同じパスを何度もクリックして機能に戻る必要があり、サイクルが数秒長くなってしまいます。
ホットリロード
ホットリロードの背後にある考え方は、アプリを実行し続け、編集したファイルの新しいバージョンを実行時に注入することです。これにより、アプリの状態が失われず、特にUIを微調整している場合に便利です。
動画は百聞に一見に如かずです。Live Reload(現在)とHot Reload(新規)の違いをご覧ください。
よく見ると、レッドボックスから回復できること、また、以前は存在しなかったモジュールをフルリロードなしでインポートし始めることができることにお気づきでしょう。
注意:JavaScript は非常に状態を持つ言語であるため、ホットリロードを完全に実装することはできません。実際には、現在のセットアップは多くの一般的なユースケースでうまく機能しており、何か問題が発生した場合には常に完全なリロードが利用可能であることが分かりました。
ホットリロードは0.22から利用可能です。有効にするには
- 開発者メニューを開きます
- 「Enable Hot Reloading」をタップします
実装の概要
なぜそれが必要で、どのように使用するのかを見てきましたが、ここからが楽しい部分です:実際にどのように機能するのか。
ホットリロードは、ホットモジュールリプレースメント (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
のためのものがあります。
__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
が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`が要求されると、ランタイムは`log`と`time`のエクスポートをキャッシュします(ステップ1)。次に、`time`が変更されても、HMRプロセスは単に`time`のコードを置き換えた後では終了できません。もしそうすると、`log`が実行されたときに、`time`のキャッシュされたコピー(古いコード)で実行されてしまいます。
`log`が`time`の変更を認識するには、依存するモジュールの1つがホットスワップされたため、キャッシュされたエクスポートをクリアする必要があります(ステップ3)。最後に、`log`が再度要求されると、そのファクトリ関数が実行され、`time`が要求されて新しいコードが取得されます。
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 の更新を受け入れるだけでは不十分な場合があります。例えば、映画アプリの例の依存関係ツリーに、`MovieSearch` と `MovieScreen` ビューに依存するトップレベルの `MovieRouter` があり、これらが以前の例の `log` と `time` モジュールに依存していたとします。
ユーザーがムービー検索ビューにアクセスしたが、他のビューにはアクセスしなかった場合、`MovieScreen` を除くすべてのモジュールがエクスポートをキャッシュしています。`time` モジュールに変更が加えられた場合、ランタイムは `log` のエクスポートをクリアして、`time` の変更を認識させる必要があります。プロセスはそこで終わりません。ランタイムは、すべての親が受け入れられるまで、このプロセスを再帰的に繰り返します。つまり、`log` に依存するモジュールを取得し、それらを受け入れようとします。`MovieScreen` はまだ要求されていないため、スキップできます。`MovieSearch` の場合、そのエクスポートをクリアし、その親を再帰的に処理する必要があります。最後に、`MovieRouter` に対して同じことを行い、`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 の トランスフォーム を実装しました。要するに、彼の解決策は、_トランスフォーム時_にすべての React コンポーネントのプロキシを作成することで機能します。プロキシはコンポーネントの状態を保持し、ライフサイクルメソッドを実際のコンポーネントに委譲します。これがホットリロードされるものです。
プロキシコンポーネントを作成するだけでなく、このtransformはReactにコンポーネントの再レンダリングを強制するコードでaccept
関数も定義します。これにより、アプリの状態を失うことなくレンダリングコードをホットリロードできます。
React Native に付属するデフォルトのトランスフォーマーは、`babel-preset-react-native` を使用しています。これは、webpack を使用する React Web プロジェクトで `react-transform` を使用するのと同じ方法で `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;
};
レデューサーを変更すると、そのレデューサーを受け入れるコードがクライアントに送信されます。するとクライアントは、レデューサーが自分自身を受け入れる方法を知らないことに気づき、参照しているすべてのモジュールを検索して、それらを受け入れようとします。最終的に、フローは単一のストアである `configureStore` モジュールに到達し、これが HMR の更新を受け入れます。
結論
ホットリロードの改善に興味がある方は、Dan Abramov 氏のホットリロードの将来に関する記事を読んで貢献することを強くお勧めします。例えば、Johny Days は複数の接続されたクライアントで動作するようにする予定です。この機能の維持と改善は皆さんに頼っています。
React Nativeを使えば、アプリの構築方法を再考し、素晴らしい開発者体験を実現する機会があります。ホットリロードはパズルのピースの一つにすぎません。それをより良くするために、他にどんなクレイジーなハックができるでしょうか?