⚙️

図で分かるReact18のしくみ

2022/12/17に公開約23,100字

これは何?

この記事は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

緑色で表したノードです。childsublingreturnプロパティを利用することで自分の子、兄弟、親にアクセスできます。

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.createElementReact要素 というオブジェクトを作ります。React要素はroot.renderの実行に必要になります。

似たような概念が多くて混乱しますが、FiberとReact要素は別のオブジェクトです。
JSXを使っている場合はReact.createElementを書く機会はありませんが、JSXはトランスパイル時にReact.createElementに変換されるので同くReact要素が作成される流れになります。[1]

★3 React要素からFiberツリーを作った後、レンダリングを実施。

root.renderにReact要素を渡して実行すると、いよいよレンダリングまで実行されます。
レンダリングまでのステップは下記の3つです。

  1. 末端のノード(葉)に辿りつくまでReact要素をFiberに変換しながら、ツリーを作成していく。
  2. 末端まで来たら、FiberをDOMノードに変換してstateNodeに格納しながら引き返す。
  3. ルートまで戻ってきたら、完成したDOMノードをdiv#rootappendChild(追加)して完了。

分かりやすいように1.と2.の流れをGIFアニメーションにしました。
stateNodeはFiberが持つプロパティの1つであり、自分と自分の子要素の全てのDOMをマージしたものを保存しています。引き返すにつれて段々とDOMが構築されていくのが分かります。ただし、関数コンポーネント(今回でいうApp)はstateNodeを持たず、nullが入ります。(画像が縮小されて見づらい場合はクリックで拡大してご覧ください。)

1.と2.が完了した後、nullを除くルートから一番近いstateNodediv#rootに追加します。
これでブラウザ上にHelloworldという文字が表示され、レンダリング完了です。

実際のコードを覗く

実際のコードでは、下記のworkLoopSyncで再帰的にツリーを構築しています。
workInProgressという変数には現在処理中のFiberが入っており、performUnitOfWork関数を実行する度に値が変わります。

https://github.com/facebook/react/blob/edbfc6399ffa31267a2e884a63194b5ea17514ff/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2131-L2138

performUnitOfWorkを見てみます。beginWorkが実際にFiberツリーを構築する関数です。その後、next(次の作業すべき子Fiber)があればループを繰り返し、無ければcompleteUnitOfWork関数を実行します。このcompleteUnitOfWork関数が、3.の引き返しながらstateNodeを構築していくステップになります。

https://github.com/facebook/react/blob/edbfc6399ffa31267a2e884a63194b5ea17514ff/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2314-L2340

レンダリングフェーズについて

レンダリングには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 を使用した場合だけのようです。

https://github.com/facebook/react/issues/24392

裏でよしなに中断してくれる訳ではなく、重たい処理をstartTransitionなどで囲ってあげる必要があります。たとえば、初期レンダリングを中断可能にする場合は、下記のようにラップして書きます。(ただしこの場合でも、中断可能になるのは初期レンダリングだけで、その後の処理はやはり中断しないような動きをします。)

React.startTransition(() => {
  root.render(React.createElement(App, {}, null));
})

React内部ではどうなっているのでしょうか。内部では「Renderモード」の中断を許可するパターンをworkLoopConcurrent関数、許可しないパターンをworkLoopSync関数として定義しています。
2つの関数の違いはshouldYieldを呼び出すかどうかだけです。

実際のコードを覗く

shouldYieldでは、「Renderフェーズ」が開始してからframeYieldMsミリ秒(おそらく固定値で5ミリ秒)経過したか判定して、その場合は「Renderフェーズ」を中断します。一方でworkLoopSync関数は時間経過関係なく残りの作業(workInProgress、すなわちFiber)がある限り作業を続けます。

では、workLoopConcurrentworkLoopSyncはどういう条件で切り替わるのでしょうか。

ここで新しく「レーン」の概念が出てきます。

呼び出し元をみてみると、includesBlockingLaneの関数の中で、
現在の作業が下記のレーンに含まれる場合は中断できない扱いになるようです。

中断できないレーン

  • InputContinuousHydrationLane
  • InputContinuousLane
  • DefaultHydrationLane
  • DefaultLane
実際のコードを覗く

shouldTimeSliceが中断可能かどうかの真偽値になります。厳密にはincludesExpiredLaneも判定に含まれていますが、おそらく長い間後回しされた場合は、中断せずに作業してあげる救済措置的な判定だと思われるので割愛しました。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L866-L878

includeBlockingLaneでは、InputContinuousHydrationLane InputContinuousLane DefaultHydrationLane DefaultLane のどれかであれば中断できない作業であると判定しています。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/react-reconciler/src/ReactFiberLane.old.js#L466-L480

レーンとは何でしょうか。

レーンについて

レーンは優先度を表す

レーンは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 関数は下記にあります。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/react-dom/src/events/DOMPluginEventSystem.js#L386-L412

どのイベントがどのレーンに対応するかは下記で定義されています。
末尾がPriorityから始まる変数の値を入れていることが分かります。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/react-dom/src/events/ReactDOMEventListener.js#L410-L517

Priorityは下記で設定されてます。レーンと同じものであることが分かります。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/react-reconciler/src/ReactEventPriorities.old.js#L24-L28

以下にレーンの一覧と、どのようなイベントで使用されるかまとめました。
使用箇所が特定できなかったものは「不明」と記載しています。

レーン名 何処で使われるか
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は実装予定のものなので無視するとして、IdleLaneIdleHydrationLaneは若干怪しいですが「Idle」という文字を見る限り何もしなさそうなのでおそらく判定箇所まで来ないと信じます(希望的観測)。そして SyncLaneは実は特別扱いを受けており、全く別の経路で「Renderフェーズ」がトリガーされるため(スケジューリングのセクションで後述)、こちらも中断されるレーンの対象外です。

残ったのはTransitionHydrationLaneTransitionLane1~16(startTransitionで使用)、RetryLane1~5(Suspenseで使用)なので、startTransitionとSuspenseのみ「Renderフェーズ」が中断される可能性がある、というのは正しいようです。

スケジューリングについて

「Renderフェーズ」はレーンによっては中断される可能性があることがわかりました。では、そもそも「Renderフェーズ」はどのようにしてトリガーされるのでしょうか。

Reactはタスクのスケジューリング機能を持っており、「Renderフェーズ」はタスクとしてスケジューラに積まれた後、遅延実行されます。積まれるタイミングは主に「初回レンダリング時」もしくは「イベント発生時(ボタンクリックなど)」のタイミングです。そして前述した通り、SyncLaneは特別扱いを受けており、「Renderフェーズ」が呼ばれる経路が異なります。SyncLaneではないケースから順番に見ていきます。

ケース1. SyncLaneではない場合

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ではないケースを見ているため「マクロタスク」として積まれます。

3.イベントループ経由でflushWork関数が呼ばれる

その後、イベントループを経由して「マクロタスク」に積んだflushWork関数が呼ばれます。
flushWork関数では先程紹介したバイナリヒープからタスクを1個取り出し、タスクのcallback関数(ここでは、Renderフェーズを開始する関数)を実行され、Renderフェーズが開始されることになります。

ここまでの流れを下記の図でまとめました。

実際のコードを覗く

ユーザ操作のイベントが発生するとunstable_scheduleCallback関数が呼ばれます。
関数内でタスクがスタックに積まれた後、requestHostCallback(flushWork);flushWork関数をマクロタスクとして追加します。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/scheduler/src/forks/Scheduler.js#L308-L388

flushWork関数内でworkLoop関数を呼び出します。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/scheduler/src/forks/Scheduler.js#L147-L187

workLoop関数内では、バイナリヒープからタスクを取り出し、タスクのcallback関数を実行します。
https://github.com/facebook/react/blob/9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e/packages/scheduler/src/forks/Scheduler.js#L189-L244

ケース2. SyncLaneの場合

1.タスクを配列に追加

SyncLaneの場合、バイナリヒープではなくsyncQueueという普通の配列に「Renderフェーズ」を実行する関数を追加します。バイナリヒープを使用しない理由は定かではありませんが、syncQueueにはSyncLane以外のタスクは積まれず、優先順位が関係ないためだと思われます。

2.タスクを処理する関数をマイクロタスクとして追加

flushSyncCallbacksという関数を今度は「マイクロタスク」としてキューに追加します。この関数はsyncQueueの配列にある全てのタスクを実行する関数です。

3.イベントループ経由でflushSyncCallbacksが呼ばれる

その後、イベントループを経由して「マイクロタスク」に積んだ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')
}

setTestuseState関数の戻り値であり、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配下のプロパティ、例えばstateNodememoizedStateはコピー元とコピー先で同じオブジェクトを共有しています。(スペースの都合上、memoizedStateは下記の図では省略しています)

そしてそのstateNodeは初期レンダリングのタイミングでdiv#rootappendChildで追加されているため、WIP側のstateNodeのDOMを書き換えてしまうと、変更が画面上に反映されてしまいます。それを避けるため、やはり実際のDOMの削除・変更作業は「Commitフェーズ」までお預けになります。

4. 新しいFiberを追加

次に、memoizedStateに保存しておいた循環リストを実行して、新しいstateであるABCの文字列を取得します。そこから<b>ABC</b>のFiberを新規に作成して追加します。
このFiberはコピーではなく完全に新しいFiberなので、stateNodediv#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のコードを読むにあたり、下記の記事で勉強させていただきました。ありがとうございます。

脚注
  1. https://ja.reactjs.org/docs/react-api.html#createelement ↩︎

  2. https://jser.dev/react/2022/04/17/offscreen-component.html#reconciling-offscreen-component ↩︎

  3. https://ja.reactjs.org/docs/reconciliation.html ↩︎

Discussion

ログインするとコメントできます