Read OSS

ワークループ — React のレンダリングの仕組み

上級

前提知識

  • 第 1 回:アーキテクチャ概観(モノレポ構造とフォークシステム)
  • 第 2 回:Fiber データ構造(Fiber フィールド、WorkTags、フラグ、レーンの概念)
  • レーン操作のためのビット演算に慣れていること

ワークループ — React のレンダリングの仕組み

ワークループは React の心臓部です。setState の呼び出しを画面上のピクセルへと変換するコードそのものです。約 5,600 行からなる ReactFiberWorkLoop.js は、コードベース全体の中で最も重要なファイルです。レンダーフェーズ(workInProgress ツリーの構築)、レーンモデル(優先度の決定)、そしてコミットフェーズ(ホストへのエフェクト適用)をすべてここが統括しています。

本記事では、状態更新の完全なライフサイクルを追います。setState を呼び出した瞬間から、レーンの割り当てとスケジューリング、Fiber を処理するワークループ、そして DOM を変更する 3 つのコミットサブフェーズまで、一連の流れを順を追って解説します。

レーンモデル — ビットマスクによる優先度管理

スケジューリングを理解するには、まずレーンを理解する必要があります。ReactFiberLane.js で定義されているレーンは、作業の優先度をエンコードする 31 ビットのビットマスクシステムです。

各ビット位置が異なる優先度レベルを表しています。

レーン ビット位置 用途
SyncHydrationLane 0 最優先のハイドレーション
SyncLane 1 同期更新(例:controlled input)
InputContinuousLane 3 連続イベント(例:ドラッグ、スクロール)
DefaultLane 5 通常の更新(例:useEffect 内の setState
GestureLane 6 ビュートランジションのジェスチャー
TransitionLane1-14 8-21 startTransition による更新(バッチ処理のため 14 レーン)
RetryLane1-4 22-25 Suspense の再試行
IdleLane 28 最低優先度の作業
OffscreenLane 29 非表示コンテンツのプリレンダリング

ビットマスクの強みはバッチ処理にあります。同じ優先度レベルの複数の更新は同じレーンビットを共有し、React は 1 回のレンダーパスでまとめて処理します。TransitionLanes には 14 レーンが割り当てられており、startTransition の呼び出しごとに独立したレーンを割り当てることで、それぞれのトランジションの進捗を独立して追跡できます。

graph LR
    subgraph "31-bit Lane Bitmask"
        SH["0: SyncHydration"]
        SY["1: Sync"]
        ICH["2: InputContinuousHydration"]
        IC["3: InputContinuous"]
        DH["4: DefaultHydration"]
        D["5: Default"]
        G["6: Gesture"]
        TH["7: TransitionHydration"]
        T["8-21: Transition × 14"]
        R["22-25: Retry × 4"]
        SEL["26: SelectiveHydration"]
        IH["27: IdleHydration"]
        ID["28: Idle"]
        OFF["29: Offscreen"]
        DEF["30: Deferred"]
    end

getNextLanes 関数は、次に処理すべきレーンを決定します。サスペンドされていない最高優先度のレーンを選択しつつ、エンタングルメント(一緒に処理しなければならないレーン)や有効期限(待たされ続けた低優先度の作業を昇格させる仕組み)も考慮に入れます。

更新のスケジューリング — setState から scheduleUpdateOnFiber へ

setState を呼び出すと、画面への旅は scheduleUpdateOnFiber から始まります。

sequenceDiagram
    participant User as User Code
    participant Dispatch as dispatchSetState
    participant Schedule as scheduleUpdateOnFiber
    participant Root as ensureRootIsScheduled
    participant Sched as Scheduler

    User->>Dispatch: setState(newValue)
    Dispatch->>Dispatch: Enqueue update on fiber
    Dispatch->>Dispatch: Request lane (requestUpdateLane)
    Dispatch->>Schedule: scheduleUpdateOnFiber(root, fiber, lane)
    Schedule->>Schedule: Mark root with pending lanes
    Schedule->>Root: ensureRootIsScheduled(root)
    Root->>Sched: scheduleCallback(priority, performWorkOnRoot)

更新はまず、循環連結リストのノードとして Fiber の更新キューに追加されます。割り当てられるレーンは現在のコンテキストによって決まります。startTransition コールバック内であれば TransitionLane、クリックハンドラーであれば SyncLaneuseEffect 内であれば DefaultLane が割り当てられます。

次に scheduleUpdateOnFiber がルートの pendingLanes に更新のレーンを記録し、ensureRootIsScheduled を呼び出します。

ルートスケジューラと Scheduler パッケージ

ReactFiberRootScheduler は、保留中の作業を持つルートの連結リストを管理します。React のレーンベースの優先度を Scheduler パッケージの 5 段階の優先度にマッピングし、Scheduler_scheduleCallback を通じてコールバックをスケジュールします。

Scheduler パッケージは React の協調スケジューリング層です。2 つの優先度キューを管理しています。

  • taskQueue: 実行準備ができたタスク。有効期限順のソート
  • timerQueue: まだ実行できない遅延タスク。開始時刻順のソート

どちらも、コンパクトな SchedulerMinHeap.js によるミニヒープとして実装されています。push、pop、siftUp、siftDown の操作が約 80 行に収まっています。比較関数は sortIndex(有効期限)を第一キー、id(挿入順)をタイブレーカーとして使います。

Scheduler は MessageChannel を使ってタスクのチャンク間にブラウザのイベントループへ制御を返します。これが並行レンダリングを可能にする仕組みです。React は作業を中断し、ブラウザがユーザー入力の処理、フレームの描画、その他のタスクを実行する余地を与えられます。

flowchart TD
    RC["Root Scheduler"] -->|"maps lanes to priority"| SC["Scheduler"]
    SC --> TQ["taskQueue (min-heap)<br/>Ready tasks"]
    SC --> TMQ["timerQueue (min-heap)<br/>Delayed tasks"]
    SC -->|"MessageChannel"| EL["Browser Event Loop"]
    EL -->|"next message"| SC
    SC -->|"calls"| PWR["performWorkOnRoot"]

performWorkOnRoot — レンダーのエントリーポイント

performWorkOnRoot はレンダリングを統括するメイン関数です。最も重要な判断は、同期か並行かという選択です。

const shouldTimeSlice =
  (!forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes)) ||
  checkIfRootIsPrerendering(root, lanes);

let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes, true);

同期レンダリングが使われるのは、SyncLane(即時応答が必要なユーザー操作)、有効期限切れの作業(スタベーション防止)、そして強制同期レンダリングの場合です。並行レンダリングはトランジションやその他のノンブロッキング更新に使われ、タイムスライシングを実現します。

レンダリング完了後、performWorkOnRoot は終了ステータスを確認します。並行レンダリング中に外部ストアが変更されていた場合(isRenderConsistentWithExternalStores で検出)、一貫性を保つために同期再レンダリングにフォールバックします。

内側のループ — workLoopSync と workLoopConcurrent

実際のレンダリングは、シンプルな while ループの中で行われます。同期版は 2750 行目 にあり、驚くほどシンプルです。

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

並行版は 3034 行目 にあり、制御の譲渡ロジックが加わっています。

function workLoopConcurrent(nonIdle: boolean) {
  if (workInProgress !== null) {
    const yieldAfter = now() + (nonIdle ? 25 : 5);
    do {
      performUnitOfWork(workInProgress);
    } while (workInProgress !== null && now() < yieldAfter);
  }
}

タイムスライスの間隔に注目してください。トランジションには 25ms のスライスが与えられます(アニメーションを意図的に約 30fps に制限してスタベーションを防ぐため)。一方、アイドル作業は 5ms ごとに制御を譲渡します。

performUnitOfWorkbeginWork を呼び出して現在の Fiber を処理し、その子 Fiber を受け取ります。子がある場合は新たな workInProgress になります。子がない場合は completeUnitOfWork が兄弟と親をたどりながら上へ戻ります。

flowchart TD
    PUW["performUnitOfWork(fiber)"]
    BW["beginWork(current, wip, lanes)<br/>Returns child or null"]
    CUW["completeUnitOfWork(fiber)"]
    CW["completeWork(current, wip, lanes)"]

    PUW -->|"call"| BW
    BW -->|"child exists"| PUW2["wip = child<br/>Loop continues"]
    BW -->|"null (leaf node)"| CUW
    CUW --> CW
    CW -->|"has sibling"| PUW3["wip = sibling<br/>Loop continues"]
    CW -->|"no sibling"| CUW2["wip = return<br/>Walk up"]

ポイント: ワークループが行うツリーの走査は、Fiber の連結リストポインターを使った非再帰の深さ優先探索です。beginWork が子へ下降し、completeUnitOfWork がバックトラックを担います。

beginWork — 各 Fiber の処理

beginWork は巨大な関数で、fiber.tag(第 2 回で取り上げた WorkTags)に基づいて専用の更新関数へとディスパッチします。

  • FunctionComponentupdateFunctionComponentrenderWithHooks を呼び出し(コンポーネント関数を実行)
  • HostComponentupdateHostComponent → 子を再調整
  • SuspenseComponentupdateSuspenseComponent → fallback とコンテンツの切り替えを管理
  • MemoComponentupdateMemoComponent → props の変更有無を確認

重要な最適化としてベイルアウトパスがあります。Fiber に保留中の作業がない(lanesrenderLanes が含まれていない)かつ props が変わっていない場合、beginWork はそのサブツリー全体をスキップできます。React.memouseMemo が効果的な理由はここにあります。ツリーの枝ごとスキップするベイルアウトを可能にするからです。

beginWork が Fiber を処理する際、ReactChildFiber.js を通じて子の再調整(いわゆる「差分検出」アルゴリズム)が走ります。ここでキーのマッチングが行われます。React は古い子と新しい子を比較し、キーが一致する Fiber を再利用しながら、挿入・削除・移動を適切なフラグで記録します。

completeWork — コミットの準備

completeWork は、ワークループがツリーを上へ戻る際に実行されます。ホストコンポーネント(DOM 要素)については、ここで次の処理が行われます。

  1. 新規インスタンスの作成 — 新たにマウントされる要素に対し、ホスト設定の createInstance を呼び出す
  2. 更新差分の計算 — 既存要素に対し、prepareUpdate / props の差分検出を行う
  3. フラグのバブルアップ — 親の subtreeFlags に、すべての子の flags | subtreeFlags を OR 演算で集約する

この subtreeFlags のバブルアップが、コミットフェーズの走査を効率化する仕組みです。completeWork が Fiber を完了するたびに returnFiber.subtreeFlags |= completedWork.subtreeFlags | completedWork.flags を実行します。これにより、祖先はサブツリー全体を走査することなく、いずれかの子孫が特定のエフェクトを持つかどうかを判断できます。

commitRoot — 変更の適用

ツリー全体のレンダリングが完了すると、commitRoot が変更をホストに適用します。3 つの同期サブフェーズと 1 つの非同期フェーズで構成されています。

sequenceDiagram
    participant BM as Before Mutation
    participant M as Mutation
    participant L as Layout
    participant P as Passive (async)

    Note over BM: Read DOM before changes
    BM->>BM: getSnapshotBeforeUpdate<br/>View Transition snapshots
    Note over M: Apply DOM changes
    M->>M: insertions (Placement)<br/>updates (Update)<br/>deletions (ChildDeletion)<br/>ref detachment
    Note over L: After DOM mutation
    L->>L: useLayoutEffect callbacks<br/>componentDidMount/Update<br/>ref attachment
    Note over P: Asynchronous
    P->>P: useEffect cleanup<br/>useEffect callbacks

各サブフェーズは第 2 回で取り上げたフェーズマスクを使って、処理が必要な Fiber をフィルタリングします。

  • commitBeforeMutationEffectsBeforeMutationMask(主に Snapshot)を使用
  • commitMutationEffectsMutationMask(Placement、Update、ChildDeletion、Ref、Hydrating、Visibility)を使用
  • commitLayoutEffectsLayoutMask(Update、Callback、Ref、Visibility)を使用
  • パッシブエフェクト — PassiveMask(Passive、Visibility、ChildDeletion)を使用。Scheduler 経由でスケジュール

ミューテーションフェーズの後、root.current が完成した作業ツリーを指すよう切り替わります。これにより、レイアウトエフェクトと ref は新しい DOM を参照し、ミューテーション前エフェクトは古い DOM を参照します。

ポイント: コミットを 3 フェーズに分ける設計は、DOM の変更タイミングを基準に各操作を適切な時点で実行するためです。getSnapshotBeforeUpdate は React が DOM を変更するに読み取る必要があり、componentDidMount は DOM 更新に実行されなければならず、useEffect はブラウザの描画をブロックしないよう遅延できます。

次回予告

これで、レンダー・コミットサイクルの全体像を追い終えました。状態更新からレーンの割り当て、スケジューリング、ワークループのツリー走査、そしてコミットフェーズの DOM 変更まで、一連の流れが見えたはずです。次回は、beginWork が関数コンポーネントに出会ったときに何が起きるかを深掘りします。テーマはフックシステムです。useStateuseEffect、ディスパッチャーパターンが、どのように関数コンポーネントに状態とサイドエフェクトをもたらすのかを解説します。