レンダリング、コミット、マウント
このドキュメントは、現在展開中の新しいアーキテクチャに関するものです。
React Nativeのレンダラーは、Reactのロジックをホストプラットフォームにレンダリングするために一連の作業を行います。この一連の作業はレンダーパイプラインと呼ばれ、初回レンダリングとUIステートの更新時に発生します。このドキュメントでは、レンダーパイプラインと、これらのシナリオでの違いについて説明します。
レンダーパイプラインは、大きく3つのフェーズに分けられます。
- レンダー (Render): Reactが製品ロジックを実行し、JavaScriptでReact要素ツリーを作成します。このツリーから、レンダラーはC++でReactシャドウツリーを作成します。
- コミット (Commit): Reactシャドウツリーが完全に作成されると、レンダラーはコミットをトリガーします。これにより、React要素ツリーと新しく作成されたReactシャドウツリーの両方が、マウントされる「次のツリー」として昇格 (promote) されます。また、レイアウト情報の計算もスケジュールされます。
- マウント (Mount): レイアウト計算の結果を含むReactシャドウツリーが、ホストビューツリーに変換されます。
レンダーパイプラインの各フェーズは、異なるスレッドで実行されることがあります。詳細については、スレッドモデルのドキュメントを参照してください。
初回レンダー
以下をレンダリングしたいと想像してください。
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}
// <MyComponent />
上の例では、<MyComponent />
はReact要素です。Reactは、このReact要素を再帰的に呼び出す(またはJavaScriptクラスで実装されている場合はそのrender
メソッドを呼び出す)ことで、最終的なReactホストコンポーネントに縮約します。この処理は、すべてのReact要素がそれ以上縮約できなくなるまで続きます。これにより、ReactホストコンポーネントからなるReact要素ツリーが完成します。
フェーズ1. レンダー
この要素縮約のプロセス中、各React要素が呼び出されるたびに、レンダラーは同時にReactシャドウノードも作成します。これはReactホストコンポーネントに対してのみ行われ、React複合コンポーネントに対しては行われません。上の例では、<View>
はViewShadowNode
オブジェクトの作成につながり、<Text>
はTextShadowNode
オブジェクトの作成につながります。注目すべきは、<MyComponent>
を直接表すReactシャドウノードは存在しないということです。
Reactが2つのReact要素ノード間に親子関係を作成するたびに、レンダラーは対応するReactシャドウノード間に同じ関係を作成します。こうしてReactシャドウツリーが組み立てられます。
追加の詳細
- (Reactシャドウノードの作成、2つのReactシャドウノード間の親子関係の作成などの)操作は、同期的かつスレッドセーフな操作であり、React(JavaScript)からレンダラー(C++)へ、通常はJavaScriptスレッド上で実行されます。
- React要素ツリー(およびその構成要素であるReact要素ノード)は永続的に存在するわけではありません。これはReactの「ファイバー」によって具現化される一時的な表現です。ホストコンポーネントを表す各「ファイバー」は、JSIによって可能になったReactシャドウノードへのC++ポインタを保持しています。「ファイバー」についての詳細は、このドキュメントで学べます。
- Reactシャドウツリーは不変 (immutable) です。どのReactシャドウノードを更新する場合でも、レンダラーは新しいReactシャドウツリーを作成します。しかし、レンダラーはステート更新のパフォーマンスを向上させるためのクローン操作を提供しています(詳細はReactのステート更新を参照)。
上の例では、レンダーフェーズの結果は次のようになります。
Reactシャドウツリーが完成すると、レンダラーはReact要素ツリーのコミットをトリガーします。
フェーズ2. コミット
コミットフェーズは、レイアウト計算とツリーの昇格の2つの操作で構成されます。
- レイアウト計算: この操作は、各Reactシャドウノードの位置とサイズを計算します。React Nativeでは、これにはYogaを呼び出して各Reactシャドウノードのレイアウトを計算することが含まれます。実際の計算には、JavaScriptのReact要素に由来する各Reactシャドウノードのスタイルが必要です。また、Reactシャドウツリーのルートのレイアウト制約も必要で、これにより結果として得られるノードが占有できる利用可能なスペースの量が決まります。
- ツリーの昇格 (新しいツリー → 次のツリー): この操作は、新しいReactシャドウツリーをマウントされる「次のツリー」として昇格させます。この昇格は、新しいReactシャドウツリーがマウントに必要なすべての情報を持ち、React要素ツリーの最新の状態を表していることを示します。「次のツリー」は、UIスレッドの次の「ティック」でマウントされます。
追加の詳細
- これらの操作は、バックグラウンドスレッドで非同期に実行されます。
- レイアウト計算の大部分は、完全にC++内で実行されます。しかし、一部のコンポーネント(例:
Text
、TextInput
など)のレイアウト計算は、ホストプラットフォームに依存します。テキストのサイズと位置は各ホストプラットフォームに固有であり、ホストプラットフォーム層で計算される必要があります。このため、Yogaはホストプラットフォームで定義された関数を呼び出して、コンポーネントのレイアウトを計算します。
フェーズ3. マウント
マウントフェーズでは、Reactシャドウツリー(現在はレイアウト計算のデータを含んでいる)を、画面上にピクセルがレンダリングされたホストビューツリーに変換します。念のため、React要素ツリーは次のようになっています。
<View>
<Text>Hello, World</Text>
</View>
大まかに言うと、React Nativeレンダラーは各Reactシャドウノードに対応するホストビューを作成し、画面にマウントします。上の例では、レンダラーは<View>
に対してandroid.view.ViewGroup
のインスタンスを、<Text>
に対してandroid.widget.TextView
のインスタンスを作成し、「Hello World」をセットします。同様にiOSではUIView
が作成され、テキストはNSLayoutManager
への呼び出しによってセットされます。各ホストビューは、そのReactシャドウノードのpropsを使用するように設定され、そのサイズと位置は計算されたレイアウト情報を使用して設定されます。
より詳細には、マウントフェーズは次の3つのステップで構成されます。
- ツリーの差分検出 (Diffing): このステップでは、「以前にレンダリングされたツリー」と「次のツリー」との差分を完全にC++で計算します。結果は、ホストビューで実行されるべきアトミックな変更操作のリスト(例:
createView
、updateView
、removeView
、deleteView
など)になります。このステップは、不要なホストビューの作成を避けるためにReactシャドウツリーがフラット化される場所でもあります。このアルゴリズムの詳細については、ビューのフラット化を参照してください。 - ツリーの昇格 (次のツリー → レンダー済みツリー): このステップでは、「次のツリー」を「以前にレンダリングされたツリー」にアトミックに昇格させ、次のマウントフェーズで適切なツリーに対して差分が計算されるようにします。
- ビューのマウント: このステップでは、アトミックな変更操作を対応するホストビューに適用します。このステップは、ホストプラットフォームのUIスレッドで実行されます。
追加の詳細
- これらの操作はUIスレッドで同期的に実行されます。コミットフェーズがバックグラウンドスレッドで実行される場合、マウントフェーズはUIスレッドの次の「ティック」にスケジュールされます。一方、コミットフェーズがUIスレッドで実行される場合、マウントフェーズは同じスレッドで同期的に実行されます。
- マウントフェーズのスケジューリング、実装、および実行は、ホストプラットフォームに大きく依存します。例えば、マウント層のレンダラーアーキテクチャは現在、AndroidとiOSで異なります。
- 初回レンダー時には、「以前にレンダリングされたツリー」は空です。そのため、ツリーの差分検出ステップの結果は、ビューの作成、プロパティの設定、およびビューの相互追加のみからなる変更操作のリストになります。ツリーの差分検出は、Reactのステート更新を処理する際にパフォーマンス上より重要になります。
- 現在の本番テストでは、Reactシャドウツリーは通常、約600〜1000個のReactシャドウノードで構成されています(ビューのフラット化前)。ビューのフラット化後、ツリーは約200ノードに削減されます。iPadやデスクトップアプリでは、この量は10倍に増える可能性があります。
Reactのステート更新
React要素ツリーのステートが更新されたときの、レンダーパイプラインの各フェーズを見ていきましょう。初回レンダーで次のコンポーネントをレンダリングしたとします。
function MyComponent() {
return (
<View>
<View
style={{backgroundColor: 'red', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
);
}
初回レンダーのセクションで説明したことを適用すると、次のツリーが作成されることが期待されます。
ノード3が赤い背景のホストビューに、ノード4が青い背景のホストビューに対応していることに注目してください。JavaScriptの製品ロジックでのステート更新の結果として、最初のネストされた<View>
の背景が'red'
から'yellow'
に変わったとします。これが新しいReact要素ツリーの見た目です。
<View>
<View
style={{backgroundColor: 'yellow', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
この更新はReact Nativeによってどのように処理されるのでしょうか?
ステート更新が発生すると、レンダラーはすでにマウントされているホストビューを更新するために、概念的にReact要素ツリーを更新する必要があります。しかし、スレッドセーフティを維持するために、React要素ツリーとReactシャドウツリーの両方が不変でなければなりません。これは、現在のReact要素ツリーとReactシャドウツリーを変更するのではなく、Reactは新しいprops、スタイル、および子を取り込んだ各ツリーの新しいコピーを作成する必要があることを意味します。
ステート更新中のレンダーパイプラインの各フェーズを見ていきましょう。
フェーズ1. レンダー
Reactが新しいステートを取り込んだ新しいReact要素ツリーを作成するとき、変更の影響を受けるすべてのReact要素とReactシャドウノードをクローンする必要があります。クローン後、新しいReactシャドウツリーがコミットされます。
React Nativeのレンダラーは、不変性に伴うオーバーヘッドを最小限に抑えるために構造的共有を活用します。React要素が新しいステートを含むようにクローンされると、ルートまでのパス上にあるすべてのReact要素がクローンされます。Reactは、props、スタイル、または子の更新が必要な場合にのみReact要素をクローンします。 ステート更新によって変更されないReact要素は、古いツリーと新しいツリーで共有されます。
上の例では、Reactは次の操作を使用して新しいツリーを作成します。
- CloneNode(ノード3,
{backgroundColor: 'yellow'}
) → ノード3' - CloneNode(ノード2) → ノード2'
- AppendChild(ノード2', ノード3')
- AppendChild(ノード2', ノード4)
- CloneNode(ノード1) → ノード1'
- AppendChild(ノード1', ノード2')
これらの操作の後、ノード1' は新しいReact要素ツリーのルートを表します。「以前にレンダリングされたツリー」をT、「新しいツリー」をT'とします。
TとT'が両方ともノード4を共有していることに注意してください。構造的共有はパフォーマンスを向上させ、メモリ使用量を削減します。
フェーズ2. コミット
Reactが新しいReact要素ツリーとReactシャドウツリーを作成した後、それらをコミットする必要があります。
- レイアウト計算: 初回レンダー時のレイアウト計算と同様です。重要な違いの1つは、レイアウト計算によって共有されているReactシャドウノードがクローンされる可能性があることです。これは、共有されているReactシャドウノードの親がレイアウト変更を受けた場合、共有されているReactシャドウノードのレイアウトも変更される可能性があるために発生します。
- ツリーの昇格 (新しいツリー → 次のツリー): 初回レンダー時のツリーの昇格と同様です。
フェーズ3. マウント
- ツリーの昇格 (次のツリー → レンダー済みツリー): このステップでは、「次のツリー」を「以前にレンダリングされたツリー」にアトミックに昇格させ、次のマウントフェーズで適切なツリーに対して差分が計算されるようにします。
- ツリーの差分検出 (Diffing): このステップでは、「以前にレンダリングされたツリー」(T) と「次のツリー」(T') との差分を計算します。結果は、ホストビューで実行されるべきアトミックな変更操作のリストになります。
- 上の例では、操作は次のようになります:
UpdateView(**ノード3**, {backgroundColor: 'yellow'})
- 差分は、現在マウントされている任意のツリーと新しいツリーの間で計算できます。レンダラーは、ツリーの中間バージョンをいくつかスキップすることができます。
- 上の例では、操作は次のようになります:
- ビューのマウント: このステップでは、アトミックな変更操作を対応するホストビューに適用します。上の例では、ビュー3の
backgroundColor
のみが(黄色に)更新されます。
React Nativeレンダラーのステート更新
シャドウツリー内のほとんどの情報については、Reactが唯一の所有者であり、信頼できる唯一の情報源(single source of truth)です。すべてのデータはReactから発生し、データの流れは一方向です。
しかし、1つの例外と重要なメカニズムがあります。C++のコンポーネントは、JavaScriptに直接公開されていない状態を持つことができ、JavaScriptが信頼できる情報源ではありません。C++とホストプラットフォームがこのC++ステートを制御します。一般的に、これはC++ステートを必要とする複雑なホストコンポーネントを開発している場合にのみ関係します。ホストコンポーネントの大部分は、この機能を必要としません。
例えば、ScrollView
はこのメカニズムを使用して、現在のオフセットをレンダラーに知らせます。この更新はホストプラットフォームから、具体的にはScrollView
コンポーネントを表すホストビューからトリガーされます。オフセットに関する情報は、measureのようなAPIで使用されます。この更新はホストプラットフォームから発生し、React要素ツリーには影響を与えないため、このステートデータはC++ステートによって保持されます。
概念的には、C++ステートの更新は、上記で説明したReactのステート更新と似ています。ただし、2つの重要な違いがあります。
- Reactが関与しないため、「レンダーフェーズ」をスキップします。
- 更新は、メインスレッドを含む任意のスレッドで発生し、実行される可能性があります。
フェーズ2. コミット
C++ステートの更新を実行する際、コードブロックはShadowNode
(N) の更新を要求し、C++ステートを値Sに設定します。React Nativeレンダラーは、Nの最新のコミット済みバージョンを取得し、それを新しいステートSでクローンし、N’をツリーにコミットしようと繰り返し試みます。この間にReactや別のC++ステート更新が別のコミットを実行した場合、C++ステートのコミットは失敗し、レンダラーはコミットが成功するまで何度もC++ステートの更新を再試行します。これにより、信頼できる情報源の衝突や競合を防ぎます。
フェーズ3. マウント
マウントフェーズは、Reactのステート更新のマウントフェーズと実質的に同じです。レンダラーは依然としてレイアウトを再計算し、ツリーの差分を検出し、などを行う必要があります。詳細については、上記のセクションを参照してください。