仮想 DOM

実際の DOM の軽量なコピーをメモリ上に保持し、差分だけを効率的に更新する仕組み

フロントエンドReact

仮想 DOM とは

仮想 DOM (Virtual DOM) は、画面の構造を軽量な JavaScript オブジェクトのツリーとしてメモリ上に持ち、状態が変わるたびに新しいツリーを作って前回のツリーと突き合わせ (diff)、差があった箇所だけを実際の DOM に反映する仕組みである。React が 2013 年に公開された際の中心的な仕組みとして広く知られるようになったが、React だけのものではなく、Vue も仮想 DOM を前提にしたレンダリングを行っている。

なぜ差分計算を挟むのか

DOM 操作が重いと言われる理由は、プロパティへの書き込みそのものの速さではなく、書き込みが引き起こす後続処理にある。幅や高さ、位置に関わるプロパティを変えるとレイアウトの再計算が走り、その対象は多くの場合ドキュメント全体に及ぶ。さらに書き込んだ直後に offsetHeight のようなレイアウト値を読むと、ブラウザーは前フレームの値を使えず、その場で再計算せざるを得ない。読み取りと書き込みを交互に繰り返す書き方が遅いのはこのためで、読み取りを先にまとめ、書き込みを後にまとめれば避けられる。

仮想 DOM が効くのは、まさにこの「まとめる」を仕組みとして強制する点にある。更新をメモリ上のツリーで組み立ててから一度に適用するので、書き込みと読み取りが交互になりにくく、適用する範囲も差分に絞られる。

素朴な更新 (innerHTML で描き直す):
  状態変更 → 該当領域の HTML を作り直す
           → 既存ノードを捨てて作り直す (入力欄の状態やフォーカスも失う)
           → レイアウト再計算 → 再描画

仮想 DOM:
  状態変更 → 新しい仮想 DOM ツリーを作る (メモリ上のオブジェクト操作)
           → 前回のツリーと比較 (diff)
           → 差分があったノードだけ書き換える (既存ノードは残る)

仮想 DOM の仕組み

仮想 DOM のノードは特別な構造ではなく、要素の種類 / 属性 / 子を持つただのオブジェクトである。

// 仮想 DOM ノード (単なる JavaScript オブジェクト)
const vdom = {
  type: 'div',
  props: { className: 'card' },
  children: [
    { type: 'h1', props: {}, children: ['Hello'] },
    { type: 'p', props: {}, children: ['World'] },
  ],
};
1. 状態変更 → コンポーネントを呼び直して新しい仮想 DOM ツリーを生成
2. 新旧の仮想 DOM を比較 (Reconciliation)
3. 差分を計算 (例: テキストが "Hello" → "Hi" に変更)
4. 差分だけ実際の DOM に適用 (textContent = "Hi")

ここで区別しておきたいのは、コンポーネントが呼び直されること (再レンダリング) と、実際に DOM が書き換わることが別の段階だという点である。再レンダリングされても前回と同じツリーが返れば、適用される DOM 操作は 0 件になる。逆に、DOM 操作が 0 件でも 1 と 2 の仕事は毎回発生する。「再レンダリングされた」と「画面の書き換えが起きた」を同じものとして測ると、最適化の当てどころを外す。

差分計算はヒューリスティックである

2 つのツリーの最小の変換手順を厳密に求める既知のアルゴリズムは、要素数 n に対して O(n³) 程度の計算量になる。React の公式ドキュメントは、1000 要素の表示に 10 億回規模の比較が必要になり現実的でないため、代わりに 2 つの仮定に基づく O(n) のヒューリスティックを実装していると説明している。仮定は「型が異なる 2 つの要素は異なるツリーを生む」と「どの子要素が再描画をまたいで同一かは開発者が key で示せる」の 2 つである。

前者の帰結として、要素の型が変われば中身が似ていても古いツリーは捨てて作り直される。条件分岐で同じ入力欄を別の型の要素に包み替えると、入力中の値が失われるのはこの仮定の副作用である。

後者が key の役割である。key が無いと子要素は位置で照合されるため、先頭に 1 件挿入しただけで以降の要素がすべて書き換え対象になる。React の公式ドキュメントも、既存の子のサブツリーをそのまま保てることに気付けず、すべての子を書き換えてしまう例として同じ状況を挙げている。

// ❌ key が無いと位置で照合される: 先頭に 1 件挿入すると以降がすべて書き換わる
{items.map(item => <li>{item.name}</li>)}

// ✅ key で同一性を示すと、挿入された 1 件だけが DOM 操作になる
{items.map(item => <li key={item.id}>{item.name}</li>)}

key に配列の添字を渡すと、位置で照合するのと同じ状態に戻る。並べ替えや先頭挿入を行うリストでは、データ側の識別子を使う。

仮想 DOM を前提としない実装

更新の届け方はフレームワークごとに違い、仮想 DOM の有無だけで単純に二分できるものでもない (以下は 2026 年 8 月時点)。

フレームワーク仮想 DOM更新の届け方
React使う新旧ツリーの差分計算で書き換える箇所を決める
Vue使うコンパイラーが静的部分を再利用対象として印付けし、更新の種別を patch flags で渡して差分の対象を絞る
Svelte使わないコンパイル時に更新コードを生成する。5 系は反応性の基盤に signals を使うが、直接触る API としては公開していない
SolidJS使わないシグナルが値ごとに更新先と結び付く。コンポーネント関数は 1 度だけ実行され、変わった値に対応する箇所だけが更新される

仮想 DOM を使わない実装では diff の仕事は無くなるが、代わりに値と更新先の依存関係を登録 / 追跡するコストが乗る。どちらが速いかは画面の作り方と実装の質で決まるので、仮想 DOM の有無を性能の等級として扱わない方がよい。Vue の例は、仮想 DOM を使いながらコンパイラーの情報で差分の対象を絞るという中間の設計が成立することを示している。

Server Components との関係

React の Server Components はサーバー側 (ビルド時またはリクエスト時) でレンダリングされ、コンポーネントの実装ではなくレンダリング済みのツリーを、クライアント側コンポーネントへ渡す props と一緒に送る。レンダリングにだけ使ったライブラリはクライアントのバンドルに含まれなくなる。詳細は React 側で扱う。

性能を考えるときの順序

仮想 DOM は「DOM 操作を減らす」仕組みであって、速さを保証する仕組みではない。差分の計算自体は追加の仕事であり、必要な DOM 操作を人が最小限に書き切った場合より速くなる保証はない。得られるのは、更新経路を人が管理しなくても表示と状態がずれにくいという性質である。

実務で効く順序はおおむね決まっている。まず状態を置く位置を見直す (上に置きすぎると無関係な範囲まで再レンダリングされる)、次にリストの key を正す、そして表示件数が多い画面では差分計算をどう速くするかではなく描画する件数自体を減らす。React.memo や useMemo、useCallback は比較のコストと引き換えなので、計測して効く場所に絞って使う。全体に機械的に付けると、比較の仕事が増えるうえに依存配列の管理漏れという別の不具合を招く。

仮想 DOM の背景や設計思想は関連書籍に詳しい。

共有:

この記事は役に立ちましたか?

関連用語

関連する記事