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つのヒューリスティクス
- 異なる型の要素は異なるツリーを生成する —
<div>が<span>に変わった場合、 Reactは古いサブツリー全体を破棄し、新しいツリーをゼロから構築します。 子孫要素の差分を調べるコストを完全にスキップできます。 - 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に反映"]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はリンクドリスト構造で接続されています。
従来の再帰的なツリー走査ではなく、child→sibling→return
のポインタをたどるループ処理でツリーを走査します。
これにより、任意のノードで処理を中断し、後から再開することが可能になりました。
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
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は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計算で「変更なし」と判定されるケースを大幅に削減します。
理解度チェック
Reactが一般的なツリー差分アルゴリズムのO(n³)をO(n)に削減するために使用しているヒューリスティクスはどれですか?
キーボード: 1〜4 で選択、Enter で回答