Virtual DOM — メモリ上のUIツリー

ReactがUIライブラリとして成功した最大の要因のひとつがVirtual DOMです。 Virtual DOMとは、実際のDOM(Document Object Model)のメモリ上の軽量コピーであり、 UIの「理想的な状態」をJavaScriptオブジェクトとして表現したものです。

ブラウザのDOMは非常にコストの高いAPIです。ノードの生成・属性変更・レイアウト再計算は すべてメインスレッドをブロックします。Reactはこの問題に対し、 「まずメモリ上で差分を計算し、必要最小限の変更だけを実DOMに反映する」 というアプローチを取りました。

Reconciliation — 差分検出と同期

ReactがVirtual DOMの新旧ツリーを比較し、実DOMへの最小限の更新を計算するプロセスを Reconciliation(差分調停)と呼びます。 このアルゴリズムがReactの心臓部です。

一般的なツリーの差分アルゴリズムはO(n³)の計算量を持ちます。 1000個のノードを持つツリーでは10億回の比較が必要になり、UIの更新には到底使えません。 Reactはこれを2つのヒューリスティクスによってO(n)まで削減しました。

2つのヒューリスティクス

  1. 異なる型の要素は異なるツリーを生成する<div><span>に変わった場合、 Reactは古いサブツリー全体を破棄し、新しいツリーをゼロから構築します。 子孫要素の差分を調べるコストを完全にスキップできます。
  2. key属性で要素の同一性を追跡する — リスト内の要素が並べ替えられた場合、key属性により どの要素が移動・追加・削除されたかを正確に特定します。 keyがなければ、Reactは全アイテムを再レンダリングしてしまいます。
graph TD
  A["状態更新発生"] --> B["新しいVirtual DOMツリーを生成"]
  B --> C["旧ツリーと新ツリーをDiff"]
  C --> D{"要素の型が同じか?"}
  D -->|同じ| E["属性のみ更新"]
  D -->|異なる| F["サブツリー全体を再構築"]
  E --> G["子要素を再帰的に比較"]
  G --> H["最小限のDOM操作を生成"]
  F --> H
  H --> I["実DOMに反映"]
Reconciliationの処理フロー

React Fiber — Reconcilerの全面刷新

React 16(2017年)で導入されたFiberは、 Reconcilerを根本から書き直した新しいアーキテクチャです。 旧Reconciler(Stack Reconciler)は同期的にツリー全体を処理するため、 大規模なUIの更新でメインスレッドが長時間ブロックされ、 アニメーションのカクつきや入力の遅延を引き起こしていました。

Fiberの設計思想は「React専用の仮想スタックフレーム」です。 JavaScriptのコールスタックは一度開始すると途中で中断できませんが、 Fiberは処理単位を細かく分割し、ブラウザに制御を返しながら段階的にレンダリングを進めます。

Fiber Nodeの構造

Fiberツリーの各ノード(Fiber Node)は、コンポーネントやDOM要素1つに対応する JavaScriptオブジェクトです。以下の主要プロパティを持ちます:

プロパティ 役割 説明
type 要素の種類 "div"MyComponentなど
key 同一性識別子 Reconciliationでの要素追跡に使用
child 最初の子ノード 子要素の先頭へのポインタ
sibling 次の兄弟ノード 同じ親の次の要素へのポインタ
return 親ノード 処理完了後に戻る先
pendingProps 新しいProps 今回のレンダリングで適用されるProps
memoizedState Hooks状態 useState等の状態が格納されるリンクドリスト
alternate 対のFiber ダブルバッファリングの相手方

Fiber Nodeはリンクドリスト構造で接続されています。 従来の再帰的なツリー走査ではなく、childsiblingreturn のポインタをたどるループ処理でツリーを走査します。 これにより、任意のノードで処理を中断し、後から再開することが可能になりました。

graph TD
  App["App"] -->|child| Header["Header"]
  Header -->|sibling| Main["Main"]
  Main -->|sibling| Footer["Footer"]
  Header -->|return| App
  Main -->|return| App
  Footer -->|return| App
  Main -->|child| Article["Article"]
  Article -->|sibling| Sidebar["Sidebar"]
  Article -->|return| Main
  Sidebar -->|return| Main
Fiber Nodeのリンクドリスト構造(child / sibling / return)

2フェーズモデル — Render と Commit

Fiberアーキテクチャの核心は、レンダリングを2つのフェーズに明確に分離したことです。

特性 Renderフェーズ Commitフェーズ
中断 可能(優先度の高いタスクに譲る) 不可(一気に完了する)
副作用 なし(純粋な計算のみ) あり(DOM操作、Effect実行)
処理内容 Fiber Nodeの生成・更新・Diff計算 実DOMへの反映、ref設定、ライフサイクル呼び出し
実行環境 メモリ上(ユーザーに不可視) ブラウザDOM上(ユーザーに可視)
繰り返し 可能(中断後に最初からやり直し得る) 1回のみ
graph LR
  A["状態更新"] --> B["Render Phase"]
  B -->|"中断可能 / Work Loop"| C{"全Fiberの\n処理完了?"}
  C -->|No| D["ブラウザに制御を返す"]
  D -->|"次のフレームで再開"| B
  C -->|Yes| E["Commit Phase"]
  E -->|"中断不可"| F["DOM更新"]
  F --> G["useEffect実行"]
  G --> H["完了"]
Fiberの2フェーズモデルとWork Loop

優先度制御とスケジューリング

FiberはPullベースのスケジューリングを採用しています。 フレームワーク側が更新のタイミングと優先度を制御し、 最適なタイミングでレンダリングを実行します。

内部的には、更新に優先度(Lane)が割り当てられます。 ユーザー入力(クリック、タイピング)は最高優先度で即座に処理される一方、 データフェッチ結果の反映などは低優先度として遅延実行されます。 これがReact 18以降のConcurrent Renderingの基盤となっています。

JSXの仕組み — シンタックスシュガーの裏側

JSXはReactで最も目にする構文ですが、その正体は React.createElement()のシンタックスシュガーです。 ブラウザもNode.jsもJSXを直接実行できません。 BabelやTypeScriptなどのトランスパイラが、ビルド時にJSXを通常のJavaScript関数呼び出しに変換します。

旧方式 — Classic JSX Transform

React 17以前の方式では、JSXは以下のように変換されていました:

// JSX
<h1 className="title">Hello, React!</h1>

// トランスパイル後
React.createElement('h1', { className: 'title' }, 'Hello, React!')

この方式では、JSXを使うすべてのファイルで import React from 'react'が必要でした。 React.createElementを呼ぶためにReactがスコープに存在する必要があったためです。

新方式 — Automatic JSX Runtime

React 17で導入された新しいJSXトランスフォーム(Automatic Runtime)では、 トランスパイラが自動的にランタイムをインポートします:

// JSX(React 17+)
<h1 className="title">Hello, React!</h1>

// トランスパイル後
import { jsx as _jsx } from 'react/jsx-runtime';
_jsx('h1', { className: 'title', children: 'Hello, React!' })

この変更により、Reactのインポートが不要になりました。 バンドルサイズのわずかな削減に加え、 将来的なJSXランタイムの変更を容易にする設計上のメリットもあります。

Virtual DOMのトレードオフ

Virtual DOMは強力な抽象化ですが、万能ではありません。 そのアプローチには固有のオーバーヘッドがあります。

構造的なオーバーヘッド

Reactはコンポーネントのどこが変更されたかを正確に知りません。 状態が更新されると、そのコンポーネントとすべての子孫コンポーネントを再レンダリングし、 Virtual DOM上でDiff計算を行います。 たとえ結果が「変更なし」であっても、この計算コストは発生します。

対照的に、SvelteやSolidが採用するSignal方式は、 変更された値を使用しているUI部分を直接特定し、ピンポイントで更新します。 Virtual DOMを経由しないため、理論上のオーバーヘッドはゼロに近づきます。

比較項目 Virtual DOM(React) Signal(Svelte / Solid)
変更追跡 コンポーネント単位 値単位(リアクティブ変数)
更新粒度 サブツリー全体を再計算 依存するDOM箇所のみ更新
中間表現 Virtual DOMツリーが必要 不要(直接DOM操作)
INPベンチマーク ~68ms(React 19) ~24ms(Svelte 5)
出典: Krausest JS Framework Benchmark
メモリ使用量 2つのFiberツリーを保持 軽量なリアクティブグラフ

React Compilerによる対策

ReactチームはこのオーバーヘッドをReact Compiler(旧React Forget)で構造的に解決しようとしています。 React Compilerはビルド時にコードを解析し、 自動的にメモ化(useMemoやuseCallback相当の最適化)を挿入します。 開発者が手動でメモ化を管理する必要がなくなり、 Virtual DOMのDiff計算で「変更なし」と判定されるケースを大幅に削減します。

理解度チェック

問題 0 / 40%
Q1

Reactが一般的なツリー差分アルゴリズムのO(n³)をO(n)に削減するために使用しているヒューリスティクスはどれですか?

キーボード: 1〜4 で選択、Enter で回答