🌠

Reactが初回マウントされるまでの仕組みを理解する

2024/09/02に公開

今回はReactが初回マウントされるまでの実装を私自身が学習した流れに沿って解説したいと思います。「React Internals Deep Dive」というブログ記事がReactの内部実装を知るのに大変参考になります。

https://jser.dev/series/react-source-code-walkthrough/

また、「React Internals Explorer」を使うとReactが実行するプロセスを視覚的に理解することができるため、大変おすすめです。

https://jser.pro/ddir/rie?reactVersion=18.3.1&snippetKey=hq8jm2ylzb9u8eh468

はじめに

本記事では以下の構成に従って解説をしていきます。

  1. 前提として理解するべき要素
    • FiberNodeの種類
    • 4つの実行フェーズ
    • currentとworkInProgress
  2. Trigger フェーズの実装
  3. Render フェーズの実装
  4. Commit フェーズの実装

初回マウントに関する内容は主にこちらのブログを参照しています。

https://jser.dev/2023-07-14-initial-mount/

なぜ初回マウントに限定するのか

今回はReactの実行の中でも初回マウントに限定して解説をします。Reactといえば再レンダリング時に最小限の差分を検知してDOMを操作する仕組み(Reconciliation)を備えています。しかし、再レンダリングの挙動を考慮すると実装の理解が複雑になってしまうことを懸念しました。

そこで、まずはReactの初回マウントに限定して解説することで、Reactの内部実装に関して土台となる重要な要素を把握することをこの記事の目的とします。

また同様にHydrationやConcurrent Modeなどの機能は理解を難しくすると考えるため今回の記事の中では扱いません。

加えて、私自身も現在Reactの内部構造について勉強中の身ですので、本記事の中に誤解を招く記述がありましたらご指摘いただけますと幸いです。

前提として理解するべき要素

まず、Reactの内部実装を理解する前提として把握するべきポイントを解説します。これらの知識をすでにご存知の方は本章はスキップしていただいて良いかと思います。

1. FiberNodeの種類

まずはFiberNodeの種類と要素について整理します。

React Fiberは、アプリケーションの状態を内部的に表現するためのアーキテクチャです

参考: https://jser.dev/2023-07-14-initial-mount/#1-brief-introduction-on-fiber-architecture

FiberRootNodeFiberNodeのいずれかで構成されたツリー状の構造になっています。ReactのランタイムはこのFiber Treeを効率的に扱い、DOMへの更新を最小限に抑えるよう努力しています。

FiberRootNode

FiberRootNodeはルートとして機能する特別なノードで、アプリ全体に関する必要なメタ情報を保持します。そのcurrentがFiber Treeの実体を指し、新しいFiber Treeが構築されるとcurrentが新しいHostRootを指し直します。

FiberNode

FiberRootNode以外は全てFiberNodeです。FiberNodeは大まかに以下の要素を持ちます。

tag

  • tagには多数種類がありHostRootFunctionComponentなどそれぞれが識別されます。実際にこのtagを見て処理を分岐させるコードがいくつか見られます。
  • HostRootFiberRootNode直下に位置し、Fiber Treeの起点になる特別なtagです。

stateNode

  • tagがHostComponentの場合、実際のDOM Nodeを管理します。stateNodeによって実際のDOM Nodeと効率的に関連付けをしています。

child, sibling, return

flags

  • Commit フェーズで更新を適用するかを判定するフラグ。

lanes, childLanes

  • 更新の優先度を示すために使用されます。
  • childLanesはその子孫ノード全てを含むサブツリーの更新の優先度を保持します。
  • Laneは32ビットの整数値で表現されています

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberLane.js#L36-L88

memoizedState

  • tagがFunctionComponentの場合、memoizedStateはフックに関連するデータを保持します。

2. 大きく4つの実行フェーズに分けられる

次にReactがレンダリングしUIが描画されるまでに実行されるフェーズを整理します。

Reactの実行フェーズをどのように分けるかは記事によってばらつきが見られますが、ここでは以下の4つの実行フェーズが存在する前提で解説をします。

  • Trigger フェーズ
  • Schedule フェーズ
  • Render フェーズ
  • Commit フェーズ

参考:https://jser.dev/2023-07-11-overall-of-react-internals#3-the-overview-of-react-internals

こちらの画像に掲載されているフェーズと主要な関数について簡単に見ていきます。

Trigger フェーズ

初回マウントと再レンダリング時で共通してTrigger フェーズが全ての作業の始点であり、ReactDOMRoot.render()もしくはsetState()がTrigger フェーズの起因になります。

そして、Trigger フェーズの中でscheduleUpdateOnFiber()が実行され、レンダリングする対象をReactのランタイムに伝えます。

Schedule フェーズ

React SchedulerはタスクをPriority Queueに格納し、優先順位に基づいて効率的に処理します。scheduleCallback()タスクをスケジュールするための関数で、実行されるタスクに優先順位を設定します。

workLoop()実際にタスクを実行するループです。このループは、Priority Queueからタスクを取り出し順に実行します。

今回は初回マウントにのみ焦点を当てるため、Schedule フェーズの役割である優先度の管理には着目しません。タスクは中断や優先度の変更を考慮せずに連続的に処理が行われる前提で解説を進めます。

Render フェーズ

Render フェーズではスケジュールされたタスクを実行し、新しいFiber Treeを構築してDOMに変更が必要な対象を整理します。

performConcurrentWorkOnRoot()はTrigger フェーズで作成され、Schedule フェーズによって優先づけされてRender フェーズにて実行されます。

Commit フェーズ

Render フェーズによって新しいFiber Treeが構築され、変更をするべき箇所を把握することができました。Commit フェーズではこの更新対象をDOMに適用します。

このように、Reactの実行は大きく4つの段階に分けることができます。後の章にてこれらのフェーズをそれぞれ詳しく確認していきます。

3. currentとworkInProgress

先ほどFiberNodeの各要素について重要なものを抜粋して解説しました。最後にReact Fiberの構築において度々出てくるcurrentworkInProgressが何を意味しているのか整理します。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberBeginWork.js#L3861-L3865

上記のコードは各FiberNodeごとに実行される関数ですが、currentworkInProgressをそれぞれ引数に取っています。このように双方を引数に受け取る実装は度々出てきます。

currentはUIに描画される現在のバージョンのFiberNodeであり、workInProgressは新たに構築しているFiberNodeになります。

Reactはこれらを比較することによって差分を検知し、DOMの更新を最小限に抑える努力をしています。

そして注目するべき点はcurrentNullableであるという点です。特に初回マウント時は現在描画されているUIがないため、currentはNullになります。

Trigger フェーズの実装

これまででReactの内部実装を読み進めるために必要な知識を整理しました。ここからはTrigger フェーズ、Render フェーズ、Commit フェーズと実装を見ていきます。

先ほど説明した通りConcurrent Modeを本記事では扱わないため、タスクは中断や優先度の変更を考慮せずに連続的に処理が行われるものとし、Schedule フェーズの解説をスキップします。

createRoot

https://ja.react.dev/reference/react-dom/client/createRoot

普段Reactを使う際に以下のようなコードを書いています。まずはcreateRootがどのような役割を持っているのかを整理しましょう。

const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App />);

createRootの第一引数にはDOM要素を渡します。そしてこのDOM要素に対応するルートを作成し、renderとunmountの2つのメソッドを持つオブジェクトを返します。

createRootの内部でFiberRootNodeを作成します。FiberRootNodeは再レンダリングが発生したとしても新しく作り直されることはありません。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-dom/src/client/ReactDOMRoot.js#L221-L232

またcreateRootの中でFiberRootNodeに加えてダミーのHostRootを作成し、FiberRootNodeのcurrentをダミーのHostRootへ向けています。

これはHostRootとFiberRootNodeとの間に双方向リンクを確立する目的や、初回マウントと再レンダリングでロジックを共通化するための工夫かと推測します。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberRoot.js#L174-L196

root.render()

先ほどcreateRootで作成したrootからrenderを実行して更新をスケジュールします。root.render()が実行されると、内部でupdateContainerが呼ばれます。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-dom/src/client/ReactDOMRoot.js#L101-L128

updateContainerを見ていきましょう。引数にはroot.render(<App />)で指定したReactNodeのList<App />)と、createRootで作成されたrootのインスタンスが渡されます。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberReconciler.js#L335-L339

簡略化したupdateContainerの実装は以下の流れになります。更新オブジェクトを作成し、キューに追加して更新をスケジュールします。

updateContainer
// 更新オブジェクトを作成する
const update = createUpdate(lane);
// 更新オブジェクトのpayloadにelementを設定する
update.payload = {element};

// 生成した更新オブジェクトをFiberノードの更新キューに追加
const root = enqueueUpdate(current, update, lane);

if (root !== null) {
    // scheduleUpdateOnFiber関数を呼び出して、Fiberツリーの更新をスケジュール
    scheduleUpdateOnFiber(root, current, lane);
    entangleTransitions(root, current, lane);
}

Trigger フェーズの役割はここまでです。

Render フェーズ

先ほどのTrigger フェーズにて基本になるFiberNodeを作成し、更新するための処理をキューにスケジュールしました。次にReactがどのようにFiber Treeを構築していくのかを確認します。

performConcurrentWorkOnRoot

performConcurrentWorkOnRootは初回マウント、再レンダリングに関わらずRender フェーズのエントリーポイントになる関数です。

関数名にconcurrentと入っていますが必ずしも並行に処理されるわけではなく、初回マウント時などは同期的に処理されることに注意です。初回マウント時にはレンダリングが中断されることはないため、最短でUIを描画することに集中しています。

performConcurrentWorkOnRoot
function performConcurrentWorkOnRoot(root, didTimeout) {
  // ...
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes) &&
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    // ↓ ⭐️ 初回マウントではこちらが実行される
    : renderRootSync(root, lanes);
  // ...
}

renderRootSync

performConcurrentWorkOnRootの中でrenderRootConcurrentを実行するかrenderRootSyncを分岐していました。

初回マウントでは同期的にレンダリングがされるため、renderRootSyncの中を見ていきます。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1967

重要な処理を抜粋して整理したものが下のコードになります。

renderRootSync
function renderRootSync(root: FiberRoot, lanes: Lanes) {
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    // ...
    // ⭐️ Fiber Treeの構築を開始するための準備
    prepareFreshStack(root, lanes);
  }

  // ⭐️ Fiber Treeの構築が完了するまでLoopする
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
}

function workLoopSync() {
  while (workInProgress !== null) {
    // ⭐️ FiberNode単位で実行
    performUnitOfWork(workInProgress);
  }
}

renderRootSyncが実行する内容は大きく2つあります。

  1. prepareFreshStackによってFiber Treeの構築を開始するために準備をすること。
  2. workInProgressがnullになる(次に処理するFiber Nodeが無くなる)までperformUnitOfWorkを実行してFiber Treeを構築していくこと。

それぞれの関数の中身を見ていきます。

prepareFreshStack

prepareFreshStackのコードを見るとFiber Treeを構築する前提として必要な設定を行なっていることがわかります。

prepareFreshStack
function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
  // ...
  if (workInProgress !== null) {
    let interruptedWork = workInProgress.return;
    while (interruptedWork !== null) {
      unwindInterruptedWork(interruptedWork, workInProgressRootRenderLanes);
      interruptedWork = interruptedWork.return;
    }
  }
  workInProgressRoot = root;
  // ⭐️ workInProgressを作成する
  workInProgress = createWorkInProgress(root.current, null);
  workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
  workInProgressRootExitStatus = RootIncomplete;
  workInProgressRootFatalError = null;
  workInProgressRootSkippedLanes = NoLanes;
  workInProgressRootInterleavedUpdatedLanes = NoLanes;
  workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
  workInProgressRootPingedLanes = NoLanes;

  enqueueInterleavedUpdates();
  // ...
}

この中でも特にcreateWorkInProgressのコードに注目します。

すると、workInProgressがnullの場合は新しいFiberNodeを作成しますが、すでに存在する際には既存のworkInProgress(FiberNode)を再利用するようにしています。

Reactはこのようにインスタンスを再利用することで、必要以上のメモリ使用を回避する最適化を行なっています。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiber.js#L336-L443

performUnitOfWork

renderRootSyncにて、prepareFreshStackが完了したのちにFiber Treeの構築が完了するまでperformUnitOfWorkをWhileループで実行していました。

performUnitOfWorkFiberNodeごとに実行されます。

workLoopSync
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

workLoopSyncで実行されるperformUnitOfWork終了条件はworkInProgressがnullになることでした。

細かい処理を省略しますが、performUnitOfWorkではworkInProgress(処理対象のFiberNode)に対してbeginWorkを実行し、next(次に処理をしたいFiberNode)があればworkInProgressに代入しています。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberWorkLoop.js#L2374-L2419

performUnitOfWork
let next;
// ...
next = beginWork(current, unitOfWork, subtreeRenderLanes);

if (next === null) {
    // ⭐️ 次に処理する対象がない場合は完了の処理をする
    completeUnitOfWork(unitOfWork);
} else {
    workInProgress = next;
}

そしてnextが存在しない、つまりFiber Treeのchildが無くなるまで処理が完了した場合はcompleteUnitOfWorkを実行してworkLoopSyncから抜け出すのがperformUnitOfWorkの役割になります。

beginWork

ではperformUnitOfWorkでFiberNodeごとに実行されているbeginWorkはどのような処理を実行しているのでしょうか?

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberBeginWork.js#L3861-L3865

beginWork
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...
  // ⭐️ FiberNodeのtagごとに処理を分岐
  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
        renderLanes,
      );
    }
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
  }
}

beginWorkのコードは非常にわかりやすくworkInProgressのtagを見て実行する処理を振り分けています。

例えばtagがFiberRootNode直下のHostRootの場合はupdateHostRootが、HTML Tagなどの要素を表すHostComponentの場合はupdateHostComponentが実行されるといった流れです。

Fiber Treeの構造上、FiberRootNodeの下に存在するのはHostRootのtagを持つFiberNodeでした。

つまり、初回のbeginWorkではHostRootのtagを持つFiberNodeが処理されるため、まずはupdateHostRootが実行されます。

renderRootSyncの内部ではprepareFreshStackでFiber Tree構築の準備をし、workInProgressがnullになるまでperformUnitOfWorkを実行して、完了次第completeUnitOfWorkを呼んでいました。

この流れを一度整理すると下の画像のようになるかと思います。

updateHostRoot

ではbeginWorkのtagで分岐されていたコードの内部では何が実行されているのでしょうか?

まずHostRootのFiberNodeが処理されるということで、updateHostRootの内容を見ていきましょう。下記のコードは少し省略して書いています。

updateHostRoot
function updateHostRoot(
  current: null | Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  pushHostRootContext(workInProgress);
  const nextProps = workInProgress.pendingProps;
  const prevState = workInProgress.memoizedState;
  const prevChildren = prevState.element;

  // 更新キューをクローンして処理する
  cloneUpdateQueue(current, workInProgress);
  processUpdateQueue(workInProgress, nextProps, null, renderLanes);

  const nextState: RootState = workInProgress.memoizedState;
  const nextChildren = nextState.element;

  if (nextChildren === prevChildren) {
    // 変更がない場合は、早期に処理を終了
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  // 変更がある場合は差分を調整する
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);

  // 更新されたFiberNodeのchildを返却
  return workInProgress.child;
}

processUpdateQueueが実行されると対象のFiberNodeの状態を更新し、新しい状態を計算します。

その後、変更前後のelementを比較することによって差分が存在するかを検知しています。変更がない場合は、早期に処理を終了することが可能なため、bailoutOnAlreadyFinishedWorkを実行して処理を完了します。

反対に差分が生じた場合にはreconcileChildrenを実行して差分を調整します。

reconcileChildren

reconcileChildren差分を調整する非常に重要な役割を担っています。この関数はupdateHostRoot以外のupdate...関数でも使用されています。

内部ではcurrentがnullか否かによってmountChildFibersreconcileChildFibersのどちらを実行するか分岐させています。

reconcileChildren
export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // ⭐️ 初回マウントでは基本↓になる
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // ⭐️ HostRootのみ↓になる
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

先ほどから度々登場するcurrentのnullチェックですが、初回マウント時はHostRoot以外currentが存在しないためnullになります。

”HostRoot以外”という点が重要で、冒頭で解説したようにcreateRootによってFiberRootNodeに加えてダミーのHostRootを作成していました。

画像の通り、HostRootのみcurrentが存在するためmountChildFibersではなくreconcileChildFibersが実行されます。

HostRootでreconcileChildFibersが実行されることで、レンダリングプロセスの起点が固定され初回マウントと再レンダリングのロジックが共通化しやすくなるメリットがあります。

reconcileChildFibersmountChildFibers違いはcreateChildReconcilerの引数に渡すBooleanのみです。これによってshouldTrackSideEffectsの値がtrueかfalseか決まります。

createChildReconciler
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);

function createChildReconciler(
  shouldTrackSideEffects: boolean,
): ChildReconciler {
  // ...
  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
    // ⭐️ reconcileの実行
    const firstChildFiber = reconcileChildFibersImpl(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes,
    );
    return firstChildFiber;
  }
  return reconcileChildFibers;
}

createChildReconcilerの内部ではさらにreconcileChildFibersImplという関数が定義されています。この関数が実際にreconcileするための処理を担っています。

reconcileChildFibersImpl
function reconcileChildFibersImpl(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  // ...
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      case REACT_PORTAL_TYPE:
        return placeSingleChild(
          reconcileSinglePortal(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      case REACT_LAZY_TYPE:
        const payload = newChild._payload;
        const init = newChild._init;
        return reconcileChildFibers(
          returnFiber,
          currentFirstChild,
          init(payload),
          lanes,
        );
    }
    // ...
  }
  // ...
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

更に内部では$$typeofというReact Elementのtypeによって処理をswitchさせています。

それぞれ具体的な処理は異なりますが、おおよそreconcile...(例: reconcileSingleElement)で必要な調整処理を実行し、placeSingleChildでDOMの挿入が必要であることをマークする実装になっています。

reconcileSingleElement

ここでは$$typeofがREACT_ELEMENT_TYPEだったと仮定してreconcileSingleElementを見ていきます。

reconcileSingleElement
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  // ...
  let child = currentFirstChild;
  while (child !== null) {
    // ...
    // すでにchildが存在する場合は別途処理が入りますが、初回マウント時にはchildが存在しません。
  }
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
  // ...
}

すると、createFiberFromElement関数を使用してReact Elementから新しいFiberNodeを作成していることがわかります。

そしてcoerceRef適当な形へ変換し、createdのreturnに親Fiberを登録します。

placeSingleChild

次にplaceSingleChildです。

placeSingleChildは非常に単純で、新しく作成されたFiberNodeに対して、DOMへの挿入が必要かどうかを判断し、必要な場合はPlacement flagを設定します。

ここでまた、createRootでダミーのHostRootを作成していた実装が関係してきます。

初回マウントではHostRoot以外はcurrentが存在しないためshouldTrackSideEffectsがfalseでした。しかし、ダミーのHostRootがあることによって、updateHostRootで実行されるreconcileChildrenのみreconcileChildFibersが実行され、shouldTrackSideEffectsがtrueになります。

createChildReconciler
// 再掲
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);

function createChildReconciler(
  shouldTrackSideEffects: boolean,
): ChildReconciler {
  // ...
}

これにより、初回マウント時も再レンダリング時と共通の処理でHostRootのchildに対してPlacementのflagを設定することができる仕組みになっています。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactChildFiber.js#L497-L504

このようにして、先ほどのreconcileChildFibersImplで行われていた処理が実行されます。

そしてこの処理を繰り返すことにより、最終的には以下のようなFiber Treeが構築されます。

completeWork

これまででperformUnitOfWorkを繰り返し、React ElementからFiber Treeを構築してきました。

Render フェーズでは最後に実際に挿入するためのDOM Nodeを作成してstateNodeに追加する処理を行います。

再度performUnitOfWorkのコードを掲載します。

performUnitOfWork
let next;
// ...
next = beginWork(current, unitOfWork, subtreeRenderLanes);

if (next === null) {
    completeUnitOfWork(unitOfWork);
} else {
    workInProgress = next;
}

すると、nextがnullになった際にcompleteUnitOfWorkを実行していることがわかります。

completeUnitOfWorkのコードはこちらです。注目するべきは内部で呼ばれているcompleteWorkです。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberWorkLoop.js#L2618-L2693

completeWork

completeWorkの内部を見ていきましょう。

completeWork
function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  popTreeContext(workInProgress);
  switch (workInProgress.tag) {
    ...
    case HostComponent: {
      popHostContext(workInProgress);
      const type = workInProgress.type;
      if (current !== null && workInProgress.stateNode != null) {
        // ...
      } else {
        ...
        if (wasHydrated) {
          ...
        } else {
          const rootContainerInstance = getRootHostContainer();
          // ⭐️ 実際のDOM Nodeを作成
          const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
          );
          // ⭐️ サブツリーのDOM Nodeを含めてappend。この時点ではまだInsertしていない
          appendAllChildren(instance, workInProgress, false, false);
          // ⭐️ stateNodeに代入
          workInProgress.stateNode = instance;
          // ...
        }
      }
      ...
      return null;
    }
    ...
  }
}

解説が必要な箇所のみに省略をしていますが、createInstanceによって実際のDOMのインスタンスが作成されていることがわかります。

そして、appendAllChildrenでサブツリーのDOM Nodeを全てappendしたDOM Nodeを作成し、workInProgresのstateNodeに代入しています。

この処理をbeginWorkが完了したのち、Fiber Treeを逆に戻るように実行することで必要なDOM Nodeのインスタンスを作成します。

Commit フェーズ

これまでの処理によって、workInProgressのFiber Treeが完成し、必要なDOM Nodeが作成され、DOMの操作が必要なFiberNodeにフラグをつけることができました。

ここまで来れば、あとはDOMを実際に挿入する処理が行えます。

commitMutationEffects

performConcurrentWorkOnRoot関数をもう一度確認します。すると、renderRootSyncによってRender フェーズが実行され、Fiber Treeが構築されたのちにfinishConcurrentRenderという関数が呼ばれています。

function performConcurrentWorkOnRoot(root, didTimeout) {
  // ...
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    // ↓ 初回マウントではこちらが実行される
    : renderRootSync(root, lanes);
  // ...

  root.finishedWork = finishedWork;
  root.finishedLanes = lanes;
  // ⭐️ ↓の関数がrenderRoot...の後に実行
  finishConcurrentRender(root, exitStatus, finishedWork, lanes);
  // ...
}

そして、finishConcurrentRenderの中でcommitMutationEffectsが実行されており、この関数が実際にDOMの操作をしています。

commitMutationEffectsではcommitMutationEffectsOnFiberが実行されています。

commitMutationEffects
export function commitMutationEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
) {
  inProgressLanes = committedLanes;
  inProgressRoot = root;
  commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
  inProgressLanes = null;
  inProgressRoot = null;
}

commitMutationEffectsOnFiberでは処理を再帰的に実行しており、FiberNodeのtagを見て処理をswitchしています。

また、下記のコードを見て分かる通り共通してrecursivelyTraverseMutationEffectscommitReconciliationEffectsが実行されています。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberCommitWork.js#L2583-L2587

commitMutationEffectsOnFiber
function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      ...
      return;
    }
    ...
    case HostComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      ...
      return;
    }
    case HostText: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      ...
      return;
    }
    case HostRoot: {
      if (enableFloat && supportsResources) {
        prepareToCommitHoistables();
        const previousHoistableRoot = currentHoistableRoot;
        currentHoistableRoot = getHoistableRoot(root.containerInfo);
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        currentHoistableRoot = previousHoistableRoot;
        commitReconciliationEffects(finishedWork);
      } else {
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        commitReconciliationEffects(finishedWork);
      }
      ...
      return;
    }
    ...
    default: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      return;
    }
  }
}

recursivelyTraverseMutationEffects

ではrecursivelyTraverseMutationEffectsを見ていきます。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberCommitWork.js#L2540-L2544

recursivelyTraverseMutationEffects
function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      try {
        // ⭐️ 要素をremoveChildする
        commitDeletionEffects(root, parentFiber, childToDelete);
      } catch (error) {
        captureCommitPhaseError(childToDelete, parentFiber, error);
      }
    }
  }

  const prevDebugFiber = getCurrentDebugFiberInDEV();
  if (parentFiber.subtreeFlags & MutationMask) {
    // 再帰的に実行
    let child = parentFiber.child;
    while (child !== null) {
      setCurrentDebugFiberInDEV(child);
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
  setCurrentDebugFiberInDEV(prevDebugFiber);
}

recursivelyTraverseMutationEffectsという名前の通り、この関数も再帰的に実行されます。

そして、parentFiberのdeletionsから削除される予定の子要素を取得して、commitDeletionEffectsによって要素をremoveChildします。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberCommitWork.js#L2134-L2137

commitReconciliationEffects

recursivelyTraverseMutationEffectsDOMの変異に対応したのに対し、commitReconciliationEffectsは実際にDOMの挿入などを行います。

ここではRender フェーズの際に付与したflagを確認して、Placementがmarkされていた場合はcommitPlacementを実行します。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberCommitWork.js#L3135-L3155

commitPlacementではparentFiberからstateNodeを取得して、insertOrAppendPlacementNodeを実行してDOMに挿入しています。

https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-reconciler/src/ReactFiberCommitWork.js#L1832

このようにCommit フェーズではRender フェーズまでで用意されたFiber Treeを元に実際にDOMの操作を実施する役割を持っています。

そしてDOMが実際に描画されFiberRootNodeのcurrentが、今までworkInProgressとして扱われていたFiber Treeを指すことによって、次のレンダリングではcurrentが現在画面に描画されているFiber Treeになります。

まとめ

今回はReactの初回マウントが実行されてUIが描画されるまでを見てきました。本来はこれに加えて再レンダリングやConcurrent Modeを考慮した、React SchedulerやReact Laneによる優先付けなども絡んできます。

ですが、基本になる流れは初回マウントで最もシンプルにレンダリングされるケースであると考えています。今回の内容をベースに様々なケースでどのようにレンダリングが実行されていくかを今後とも見ていきたいなと思います!

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)

【面談フォームはこちら】

https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion