図で分かるReact18のしくみ
これは何?
この記事はReact18がどのように動いているのかをまとめた記事です。なるべくコードの記載はせず、図を使用して読みやすさを重視しています。また、これからReactの内部のコードを読む予定の方のために、各セクションの終わりにアコーディオン形式でGitHubのリンクを貼っています。
※ この記事はnote株式会社 Advent Calendar 2022 の17日目の記事です。
対象読者
- Reactの内部コードを読む気は無いが、裏で何をしているのか把握しておきたい方
- これからReactの内部コードを読もうと思っている方
- 暇な方
Fiberについて
まず最初に、Reactのドキュメントを漁っていると度々出現する「Fiber」についてお伝えします。
そもそもFiberとは何か
一部例外はありますが、1個のFiberは1個のコンポーネント(<MyComponent>
や<div>
など)管理するオブジェクトです。 Reactは「DOMツリー」ならぬ「Fiberツリー」を生成してレンダリング処理に利用します。また作業単位でもあります。 ReactがFiberツリーから実際のHTMLを構築する際、必要に応じて、区切りの良いタイミングで作業を一時中断することができます。その区切りがFiber単位です。
なぜFiberを使うのか
実際のDOMノードをFiberというオブジェクトでラップして取り扱うことで、<MyComponent>
のようなユーザ定義のコンポーネント、優先順位の情報など、React独自の概念をセットで管理できます。
Fiberツリーはどんな構成をしているのか
では実際に以下のコードでどのようなFiberが作成されるか見てみます。
下記を実行すると、<div>Hello<span>world</span></div>
というHTMLがdiv#root
の中に生成され、Helloworld
の文字列が表示されます。説明の都合上、JSXを使用せず createElement
を利用しています。
<html>
<body>
<script src="./node_modules/react/umd/react.development.js"></script>
<script src="./node_modules/react-dom/umd/react-dom.development.js"></script>
<div id="root"><!-- ここにReactの結果が入る --></div>
<script>
const App = () => {
const child = React.createElement('span', null, 'world');
return React.createElement('div', null, 'Hello', child);
}
const root = ReactDOM.createRoot(
document.getElementById('root')
);
root.render(React.createElement(App, {}, null));
</script>
</body>
</html>
以下のようなFiberのツリーがReact内部で生成されます。
DOMツリーと大体同じであることがわかります。
DOMツリーとFiberツリーの大きな違いは下記の3つです。
-
<App>
のようなユーザ定義のコンポーネントもツリーの一部に含む。 - 最終的な表示適用先(今回は
div#root
)を保持するための「FiberRootNode」が存在する。 - ルートを表す「HostRoot」というノードが存在している。
上記のFiberツリーには2種類のオブジェクト構造が存在します。
FiberNode
緑色で表したノードです。child
、subling
、return
プロパティを利用することで自分の子、兄弟、親にアクセスできます。
FiberRootNode
赤色で表したノードです。「FiberNode」とは異なるオブジェクト構造をしています。このノードは最終的な表示先(今回はdiv#root
)を管理しており、containerInfo
というプロパティでdiv#root
にアクセスできます。
初期レンダリングの流れを追ってみる
ReactはレンダリングにFiberツリーを使っています。
では実際にどのようにFiberツリーを使ってレンダリングをしているのか、下記のコードを使って画面に表示されるまでを追ってみます。特記する箇所に「★」マークをつけています。
<html>
<body>
<script src="./node_modules/react/umd/react.development.js"></script>
<script src="./node_modules/react-dom/umd/react-dom.development.js"></script>
<div id="root"><!-- ここにReactの結果が入る --></div>
<script>
const App = () => {
const child = React.createElement('span', null, 'world');
return React.createElement('div', null, 'Hello', child);
}
// ★1 「FiberRootNode」と「HostRoot」を生成
const root = ReactDOM.createRoot(
document.getElementById('root')
);
// ★2 createElementでApp()のReact要素を作る
reactElement = React.createElement(App, {}, null)
// ★2 React要素からFiberツリーを作った後、レンダリングを実施。
root.render(reactElement);
</script>
</body>
</html>
★1 「FiberRootNode」を生成
ReactDOM.createRoot
を呼び出すと、内部でcreateFiberRoot
関数が呼び出され、
「FiberRootNode」と「HostRoot」が作成されます。
実際のコードを覗く
実行後は下記のような感じです。
★2 createElementでReact要素を作る
次に、React.createElement
で React要素 というオブジェクトを作ります。React要素はroot.render
の実行に必要になります。
似たような概念が多くて混乱しますが、FiberとReact要素は別のオブジェクトです。
JSXを使っている場合はReact.createElement
を書く機会はありませんが、JSXはトランスパイル時にReact.createElement
に変換されるので同くReact要素が作成される流れになります。[1]
★3 React要素からFiberツリーを作った後、レンダリングを実施。
root.render
にReact要素を渡して実行すると、いよいよレンダリングまで実行されます。
レンダリングまでのステップは下記の3つです。
- 末端のノード(葉)に辿りつくまでReact要素をFiberに変換しながら、ツリーを作成していく。
- 末端まで来たら、FiberをDOMノードに変換して
stateNode
に格納しながら引き返す。 - ルートまで戻ってきたら、完成したDOMノードを
div#root
にappendChild(追加)して完了。
分かりやすいように1.と2.の流れをGIFアニメーションにしました。
stateNode
はFiberが持つプロパティの1つであり、自分と自分の子要素の全てのDOMをマージしたものを保存しています。引き返すにつれて段々とDOMが構築されていくのが分かります。ただし、関数コンポーネント(今回でいうApp)はstateNode
を持たず、null
が入ります。(画像が縮小されて見づらい場合はクリックで拡大してご覧ください。)
1.と2.が完了した後、null
を除くルートから一番近いstateNode
をdiv#root
に追加します。
これでブラウザ上にHelloworldという文字が表示され、レンダリング完了です。
実際のコードを覗く
実際のコードでは、下記のworkLoopSync
で再帰的にツリーを構築しています。
workInProgress
という変数には現在処理中のFiberが入っており、performUnitOfWork
関数を実行する度に値が変わります。
performUnitOfWork
を見てみます。beginWork
が実際にFiberツリーを構築する関数です。その後、next
(次の作業すべき子Fiber)があればループを繰り返し、無ければcompleteUnitOfWork
関数を実行します。このcompleteUnitOfWork
関数が、3.の引き返しながらstateNode
を構築していくステップになります。
レンダリングフェーズについて
レンダリングには2つのフェーズがある
ここまで初期レンダリングの流れを見てきました。実は先程のレンダリングは 「Renderフェーズ」 「Commitフェーズ」 の2種類のフェーズ分類することができます。
「Renderフェーズ」はReact要素をFiberに変換したのち、stateNode
を構築して、後はDOMを書き換えるだけという段階まで作業することを指しています。そして「Commitフェーズ」は実際にDOMを書き換える作業です。
そして重要なのが、Reactは「Renderフェーズ」は途中で作業を中断・再開する機能を持っています。ブラウザのJavaScriptはシングルスレッドのため、「Renderフェーズ」の処理があまりに重いと(例えばページに大量のDOM要素が存在しているなど)、作業が完了するまでブラウザが固まってしまいます。しかし作業を一時中断することで、貴重な1個のスレッドをJavaScriptに譲ることができ、ユーザ操作のイベント、例えばクリックイベントを処理する時間を定期的に確保することができるのです。
一方、「Commitフェーズ」は一度始まると中断されません。「Commitフェーズ」が例えば激重な処理をするとブラウザが固まる可能性がある訳ですが、やることは事前に準備したstateNode
を使ってDOMを変更するという比較的短時間で終わる作業です。可能な限り「Renderフェーズ」で作業を行い、「Commitフェーズ」は短時間で終わらせる、という設計になっています。
とはいえ、殆どのRenderフェーズは中断されない?
では実際、どういうケースで「Renderフェーズ」は中断されうるのでしょうか。
色々調べてみたのですが、以下のIssueによると、実は、v18において中断されるケースは startTransition または Suspense を使用した場合だけのようです。
裏でよしなに中断してくれる訳ではなく、重たい処理をstartTransition
などで囲ってあげる必要があります。たとえば、初期レンダリングを中断可能にする場合は、下記のようにラップして書きます。(ただしこの場合でも、中断可能になるのは初期レンダリングだけで、その後の処理はやはり中断しないような動きをします。)
React.startTransition(() => {
root.render(React.createElement(App, {}, null));
})
React内部ではどうなっているのでしょうか。内部では「Renderモード」の中断を許可するパターンをworkLoopConcurrent
関数、許可しないパターンをworkLoopSync
関数として定義しています。
2つの関数の違いはshouldYield
を呼び出すかどうかだけです。
実際のコードを覗く
workLoopConcurrent
は下記で定義されています。
workLoopSync
は下記で定義されています。
shouldYield
では、「Renderフェーズ」が開始してからframeYieldMs
ミリ秒(おそらく固定値で5ミリ秒)経過したか判定して、その場合は「Renderフェーズ」を中断します。一方でworkLoopSync
関数は時間経過関係なく残りの作業(workInProgress、すなわちFiber)がある限り作業を続けます。
では、workLoopConcurrent
とworkLoopSync
はどういう条件で切り替わるのでしょうか。
ここで新しく「レーン」の概念が出てきます。
呼び出し元をみてみると、includesBlockingLane
の関数の中で、
現在の作業が下記のレーンに含まれる場合は中断できない扱いになるようです。
中断できないレーン
InputContinuousHydrationLane
InputContinuousLane
DefaultHydrationLane
DefaultLane
実際のコードを覗く
shouldTimeSlice
が中断可能かどうかの真偽値になります。厳密にはincludesExpiredLane
も判定に含まれていますが、おそらく長い間後回しされた場合は、中断せずに作業してあげる救済措置的な判定だと思われるので割愛しました。
includeBlockingLane
では、InputContinuousHydrationLane
InputContinuousLane
DefaultHydrationLane
DefaultLane
のどれかであれば中断できない作業であると判定しています。
レーンとは何でしょうか。
レーンについて
レーンは優先度を表す
レーンはReact内部でのみ使われる、32bitのビットマスクで作られたフラグです。
各ビットがそれぞれのどのようなタスクを表しているかを示しており、同時に優先度も表します。
0
に近いレーンほど高優先度であり、遠いほど優先度は低くなります。
特殊なレーンとして「NoLane」という全てのビットが0がレーンがあります。
これはJavaScriptでいう所のundefined
のような、レーンを何も設定していない際のデフォルト値であり、最も高い優先度です。(もっとも、「NoLane」を優先度として使うことは稀です)
これらの定数はReactFiberLane.js
で定義されています。
実際のコードを覗く
簡単にレーンの特徴をお伝えします。演算子を使うことでレーン同士をマージしたり、対象のレーンが含まれているか確認することができます。複数のレーンを持つビットマスクを作ることも可能です。
const SyncLane = 0b0000000000000000000000000000001;
const InputContinuousLane = 0b0000000000000000000000000000100;
// `|`でレーンをマージ
// `SyncLane`と`InputContinuousLane`を持つビットマスクになる
const merged = SyncLane | InputContinuousLane;
console.log(merged); // 0b0000000000000000000000000000101
// `&`でビットマスクが対象のレーンを含むか確認できる
console.log((merged & InputContinuousLane) != 0); // true
大量のフラグを扱うReactにとっては、フラグを1個1個の変数で管理するよりも、1つのビットマスクで管理した方が効率的にフラグを扱えるのでしょう。
レーンはイベントの発生元から決定される
ところで、レーンはどのように決定されるのでしょうか。
一番分かりやすいのはクリックイベントなどのイベントの発生元から決まるパターンです。
下記のコードで考えてみましょう。
ボタンをクリックするとボタン内の文字が「Click」から「Clicked」に変わります。
const App = () => {
const [test, setTest] = React.useState('Click')
const onClick = () => {
setTest('Clicked')
}
return React.createElement('button', { onClick }, test);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App, {}, null));
実はReactは、発生元のイベントが何処から来たかを判定するため、初期レンダリングのタイミングでlistenToAllSupportedEvents
という関数を呼び出し、事前にルート要素に大量のイベントリスナを追加しています。DevToolでも確認することができます。
ボタンをクリックすると、onClick
の処理が始まる前に上記のイベントリスナが作動し、イベントに対応した優先度、つまりレーンがセットされます。今回のクリックイベントの場合はSyncLane
がセットされます。クリックイベントはかなり高い優先度扱いということです。
これはユーザ視点で考えたときにクリックしたらすぐに反応が帰ってくるのが自然である、という観点からでしょう。一方で、マウス移動のイベントはInputContinuousLane
になります。クリックイベントよりは少し低い優先度です。
実際のコードを覗く
listenToAllSupportedEvents
関数は下記にあります。
どのイベントがどのレーンに対応するかは下記で定義されています。
末尾がPriority
から始まる変数の値を入れていることが分かります。
Priority
は下記で設定されてます。レーンと同じものであることが分かります。
以下にレーンの一覧と、どのようなイベントで使用されるかまとめました。
使用箇所が特定できなかったものは「不明」と記載しています。
レーン名 | 何処で使われるか |
---|---|
NoLane | レーンの初期値として使用。 |
SyncLane | Clickイベント、MouseDownイベントなどで使用。 |
InputContinuousHydrationLane | 不明。Suspenseとハイドレーションを組み合わせると使われる? |
InputContinuousLane | MouseEnterイベントなどで使用。 |
DefaultHydrationLane | 不明。Suspenseとハイドレーションを組み合わせると使われる? |
DefaultLane | 主に初期レンダリング時に使用される。 |
TransitionHydrationLane | 不明。Suspenseとハイドレーションを組み合わせると使われる? |
TransitionLane1~16 | startTransitionで使用される。使う度に番号が変わり、16まで使うと0に戻る。 |
RetryLane1~5 | Suspenseがまだ読込中の場合に使用される。使う度に番号が変わり、5まで使うと0に戻る。 |
IdleHydrationLane | 不明。Suspenseとハイドレーションを組み合わせると使われる? |
IdleLane | 不明。 |
OffscreenLane | 不明。今後実装予定のOffscreen機能で使用される?[2] |
レーンは何に使うのか
ではレーンは何に使われるでしょうか。至る所で使われており、全て理解することはできませんでしたが、目立つ使われ方は主に2つで、レンダリング中に使われます。
- 1個1個のFiberにレーンのフラグを付与して、更新のレンダリング時に作業が必要なFiberか、スキップしてよいFiberか判定する。
- 「Renderフェーズ」を中断できるかどうか判定する(先程紹介したものです)。
もう一度「Renderフェーズ」の中断可否を見てみる
では上記の説明を踏まえて、もう一度「Renderフェーズ」の中断可否である、includesBlockingLane
関数を見てみましょう。下記のレーンは中断できないのでした。
中断できないレーン
InputContinuousHydrationLane
InputContinuousLane
DefaultHydrationLane
DefaultLane
残ったのは(中断される可能性がある)のは下記のレーンです。
中断される可能性があるレーン
NoLane
SyncLane
TransitionHydrationLane
TransitionLane1~16
RetryLane1~5
IdleHydrationLane
IdleLane
OffscreenLane
NoLane
はデフォルト値なのでおそらくincludesBlockingLane
関数まで来ることはないでしょう。OffscreenLane
は実装予定のものなので無視するとして、IdleLane
とIdleHydrationLane
は若干怪しいですが「Idle」という文字を見る限り何もしなさそうなのでおそらく判定箇所まで来ないと信じます(希望的観測)。そして SyncLane
は実は特別扱いを受けており、全く別の経路で「Renderフェーズ」がトリガーされるため(スケジューリングのセクションで後述)、こちらも中断されるレーンの対象外です。
残ったのはTransitionHydrationLane
とTransitionLane1~16
(startTransitionで使用)、RetryLane1~5
(Suspenseで使用)なので、startTransitionとSuspenseのみ「Renderフェーズ」が中断される可能性がある、というのは正しいようです。
スケジューリングについて
「Renderフェーズ」はレーンによっては中断される可能性があることがわかりました。では、そもそも「Renderフェーズ」はどのようにしてトリガーされるのでしょうか。
Reactはタスクのスケジューリング機能を持っており、「Renderフェーズ」はタスクとしてスケジューラに積まれた後、遅延実行されます。積まれるタイミングは主に「初回レンダリング時」もしくは「イベント発生時(ボタンクリックなど)」のタイミングです。そして前述した通り、SyncLane
は特別扱いを受けており、「Renderフェーズ」が呼ばれる経路が異なります。SyncLane
ではないケースから順番に見ていきます。
SyncLane
ではない場合
ケース1. 1.タスクをバイナリヒープに追加
SyncLane
ではないケースから覗いてみましょう。「初回レンダリング時」もしくは「イベント(ボタンクリックなど)」が発生した後、「Renderフェーズ」を実行するためのタスクが生成されます。タスクは下記のようなオブジェクト構成になっています。
var newTask = {
id: // タスクのID。sortIndexが同じタスク同士はIDでソートされる,
callback: // 実際の作業を実行する関数が入る。ここでは「Renderフェーズ」を開始する関数,
priorityLevel: // レーンから導出したプライオリティ(何に使われるのか不明),
startTime: // 開始時間,
expirationTime: // タイムアウトするまでの時間。レーンの優先度が高いほど短い。レンダリングのタスクの場合、タイムアウトしたら「Renderフェーズ」を中断せずに実行される,
sortIndex: // ソートするための値。通常はexpirationTimeと同じ値が入る(遅延タスクの場合はstartTimeが入る)
};
このタスクをReact内部で持っているタスク管理用の配列に追加します。
この配列はバイナリヒープで構成されています。
バイナリヒープは値を追加する際、配列の先頭が最も高い優先度のタスクになるよう並び替えします。優先度はsortIndex
の値を元に決められます。(sortIndex
が同じ場合はid
が使われる)
タスクは高い優先度のものから順に処理したいため、配列の先頭さえ見れば最優先のタスクが取り出せるバイナリヒープは効率的です。
2.タスクを処理する関数をマクロタスクとして追加
バイナリヒープに格納した後は、すぐにそのタスク作業を開始する訳ではありません。
タスクを取り出して処理するflushWork
という関数を「キュー」に積んで遅延実行されます。ここでいう「キュー」というのは、Reactの独自実装ではありません。ブラウザのJavaScriptが元々持っている「マクロタスク」もしくは「マイクロタスク」という概念です。
どちらもキューのような動作をしますが、担当するイベントが異なります。「マクロタスク」は主にsetTimeout
mousemove
のようなイベントを処理するのに対し、「マイクロタスク」はPromise
の処理などを担当します。(より詳しい情報はjavaScript.infoの記事などをご参照ください。)
そして「マクロタスク」「マイクロタスク」、どちらのキューを使用するかは、現在のレーンがSyncLane
か否かで決まります。現在のレーンがSyncLane
であれば「マイクロタスク」、そうでなければ「マクロタスク」に積まれます。「マイクロタスク」は「マクロタスク」と比較して優先して実行される傾向があります。SyncLane
のタスクといえば、クリックイベントや文字入力のイベントで採用されるのでした。ユーザからするとこのようなイベントはすぐに反応が帰ってきてほしいため「マイクロタスク」を採用しています。
今回はSyncLane
ではないケースを見ているため「マクロタスク」として積まれます。
flushWork
関数が呼ばれる
3.イベントループ経由でその後、イベントループを経由して「マクロタスク」に積んだflushWork
関数が呼ばれます。
flushWork
関数では先程紹介したバイナリヒープからタスクを1個取り出し、タスクのcallback
関数(ここでは、Renderフェーズを開始する関数)を実行され、Renderフェーズが開始されることになります。
ここまでの流れを下記の図でまとめました。
実際のコードを覗く
ユーザ操作のイベントが発生するとunstable_scheduleCallback
関数が呼ばれます。
関数内でタスクがスタックに積まれた後、requestHostCallback(flushWork);
でflushWork
関数をマクロタスクとして追加します。
flushWork
関数内でworkLoop
関数を呼び出します。
workLoop
関数内では、バイナリヒープからタスクを取り出し、タスクのcallback
関数を実行します。
SyncLane
の場合
ケース2. 1.タスクを配列に追加
SyncLane
の場合、バイナリヒープではなくsyncQueue
という普通の配列に「Renderフェーズ」を実行する関数を追加します。バイナリヒープを使用しない理由は定かではありませんが、syncQueue
にはSyncLane
以外のタスクは積まれず、優先順位が関係ないためだと思われます。
2.タスクを処理する関数をマイクロタスクとして追加
flushSyncCallbacks
という関数を今度は「マイクロタスク」としてキューに追加します。この関数はsyncQueue
の配列にある全てのタスクを実行する関数です。
flushSyncCallbacks
が呼ばれる
3.イベントループ経由でその後、イベントループを経由して「マイクロタスク」に積んだflushSyncCallbacks
関数が呼ばれます。flushSyncCallbacks
関数の中で、syncQueue
の配列にある全ての関数を実行します。
syncQueue
には「Renderフェーズ」を実行する関数が積まれているため、「Renderフェーズ」が開始されることになります。
SyncLane
の場合の流れは下記の通りです。
更新時の流れを追ってみる
ここまでで「Renderフェーズ」「Commitフェーズ」「レーン」「スケジューリング」の機能をお伝えしました。では上記を踏まえて、次は更新時の流れを追ってみましょう。また合わせてuseState
のデータ構造についてもご紹介します。下記のサンプルコードで追っていきます。
更新を再現するには何かしらのフックとイベントが必要なので、onMouseMove
を用意しました。<div>
にマウスカーソルを当てると「Helloworld」という文字が「ABCworld」に変わります。
<html>
<body>
<script src="./node_modules/react/umd/react.development.js"></script>
<script src="./node_modules/react-dom/umd/react-dom.development.js"></script>
<div id="root"><!-- ここにReactの結果が入る --></div>
<script>
const App = () => {
const [test, setTest] = React.useState('')
const onMouseMove = () => {
setTest((test) => 'A')
setTest((test) => test + 'B')
setTest((test) => test + 'C')
}
const child = (test === '') ?
React.createElement('span', {}, 'Hello') :
React.createElement('b', {}, test)
return React.createElement('div', { onMouseMove },
child,
React.createElement('span', {}, 'world')
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App, {}, null));
</script>
</body>
</html>
初期レンダリングは終わった前提から進めます。下記のFiberツリーが出来ている状態です。
ここから、<div>
にマウスカーソルを当てたら何が起きるか見てみましょう。
1. フックから循環リストを作成
マウスカーソルを当てると、サンプルコードにあるonMouseMove
イベントが発動します。
const onMouseMove = () => {
setTest((test) => 'A')
setTest((test) => test + 'B')
setTest((test) => test + 'C')
}
setTest
はuseState
関数の戻り値であり、stateを変更するための関数です。
では内部で何をしているか見てみましょう。
setTest
を呼び出すとdispatchSetState
という関数が実行され、下記のようなupdate
オブジェクトが生成されます。
var update = {
lane: lane, // 現在のレーン、今回の例では`InputContinuousLane`
action: action, // `onMouseMove`の関数が入る
hasEagerState: false, // 効率化のためのものなので割愛
eagerState: null, // 効率化のためのものなので割愛
next: null // 次の`update`オブジェクトの参照
};
このupdate
オブジェクトは下記のような循環リストで管理されます。
setTest
が呼び出される度に循環リストの末尾にupdate
オブジェクトが追加されていき、今回の場合は以下のような循環リストが完成します。
この循環リストをフックの持ち主であるFiberのmemoizedState
に保存しておきます。
このタイミングでは最終的なstate
(ABC)はまだ計算しません。
2. スケジューリングされて「Renderフェーズ」が開始
その後、スケジューリングのセクションで説明した通り、「Renderフェーズ」を開始するマクロタスクをキューに積みます。適度なタイミングでイベントループがタスクを実行し、「Renderフェーズ」が開始されます。
3. 既存のFiberを有効活用するためコピーする
初期レンダリングと違うのは、すでに一度レンダリングが完了しているという点です。そのため、すでにReact内部ではFiberツリーが存在しています。更新時はこれを有効活用します。
今回変更が起きる箇所は何処でしょうか。マウスカーソルを当てたことにより、<div><span>Hello</span><span>world</span></div>
が<div><b>ABC</b><span>world</span></div>
に変更されるのでした。つまり、下記の作業が必要です。
-
<span>Hello</span>
の要素を削除 -
<b>ABC</b>
の要素を追加
上記のように何処を変更をするべきか解決する作業を「リコンシエーション[3]」と呼びます。
ただし、ここで注意したいのは「Renderフェーズ」のタイミングでは実際のDOMには変更を加えません。「Renderフェーズ」は途中で中断される可能性がありました。もし実際のDOMに変更を加えている最中に中断されると、中途半端な状態が画面上に表示されてしまう可能性があるためです。
そのため、「Renderフェーズ」ではFiber上に変更箇所をメモしておき、次の「Commitフェーズ」で実際のDOMに変更を加えます。
ここで登場するのが「ダブルバッファリング」という手法です。
Fiberツリーを最初から構築し直すのではなく、元々あるFiberツリーをコピーすることで再構築をします。ここでは分かりやすいように、コピー先である現在作業中のFiberツリーを「WIP(workInProgress)」、現在表示中のFiberツリーを「Current」と図で書いています。
注意点として、ここでいうコピーというのは「シャローコピー」を指します。端的にいうと、Fiber自体は別のオブジェクトですが、Fiber配下のプロパティ、例えばstateNode
やmemoizedState
はコピー元とコピー先で同じオブジェクトを共有しています。(スペースの都合上、memoizedState
は下記の図では省略しています)
そしてそのstateNode
は初期レンダリングのタイミングでdiv#root
にappendChild
で追加されているため、WIP側のstateNode
のDOMを書き換えてしまうと、変更が画面上に反映されてしまいます。それを避けるため、やはり実際のDOMの削除・変更作業は「Commitフェーズ」までお預けになります。
4. 新しいFiberを追加
次に、memoizedState
に保存しておいた循環リストを実行して、新しいstate
であるABC
の文字列を取得します。そこから<b>ABC</b>
のFiberを新規に作成して追加します。
このFiberはコピーではなく完全に新しいFiberなので、stateNode
はdiv#root
のDOM配下には追加されておらず、このタイミングでstateNode
を作っても画面に表示されることはありません。
5. 変更がないFiberをコピー
3.と同じように、変更がないFiberはシャローコピーで複製します。<span>world</span>
をシャローコピーしています。
6. 削除するべきFiberをマーキング
次に、削除しなければいけないFiberを記憶しておきます。今回削除するのは<span>Hello</span>
でした。親である<div>
のFiberが持つdeletion
配列に記憶しておきます。(図が見づらい場合は図をクリックして拡大してご覧ください。)
ここで「Renderフェーズ」は終了です。続けて「Commitフェーズ」に入ります。
7. 「Commitフェーズ」でDOMの更新を実施
「Commitフェーズ」では、先程まで準備したWIPのFiber、およびdeletion
の配列を使って実際のDOMの再構築を行います。ルート(HostRoot)から子要素を順に辿っていき、変更・削除・追加を適用していきます。まずはdeletion
配列で参照している<span>Hello</span>
のDOMを、removeChildで親FiberのstateNode
(赤字で記載している箇所)から取り除きます。
このタイミングで画面上には「world」の文字だけが表示されるようになります。(一瞬すぎてユーザは認知できませんが...)
次は、insertBefore
を使って新しい<b>ABC</b>
の要素を親FiberのstateNode
に追加します。
以上でDOMの更新が完了しました。
8. WIPを新しいCurrentにする
これで変更が確定したので、次のレンダリングのために、現状「WIP」であるFiberツリーを「Current」に変更しておきましょう。元「Current」のFiberツリーはお役御免です。「FiberRootNode」のcurrent
プロパティを、WIP側のHostRootを指すように変更します。
以上でレンダリングは完了です。
終わりに
ここまで見てきたとおり、Reactの内部は中々複雑な仕組みで動いています。
決して直感的に理解できるロジックではありませんが、「設計原則」にも記載がある通り、エレガントさよりも効率さ、開発スピードを優先したおかげで、ここまで人気のライブラリになれたのかなと思うと中々面白いです。
参考にした記事
Reactのコードを読むにあたり、下記の記事で勉強させていただきました。ありがとうございます。
- https://zenn.dev/convers39/articles/ac0ac2cc2710b9
- https://zenn.dev/villa_ak99/articles/af47a6601c877c
Discussion