6.1 Virtual DOM のアイデア
React の説明で必ず出てくる「Virtual DOM」という言葉。この章ではこの言葉を、何のためにあるのか と 何をしていないのか の両面から、肩の力を抜いて整理していきます。
Virtual DOM とは何か
ひとことで言うと、Virtual DOM は UI を JavaScript のオブジェクトツリーとして表現したもの です。React は実際の DOM に直接書き込む代わりに、まずメモリ上にこの軽い表現を作り、それを元に実 DOM を更新します。
React 公式の初期ブログには、こんな一文があります。
The data returned from
renderis neither a string nor a DOM node — it's a lightweight description of what the DOM should look like. (renderの戻り値は文字列でも DOM ノードでもなく、DOM がどうあるべきかの軽量な記述である) — Why did we build React? (2013)
React で <div>Hello</div> と書いたとき、その裏側では次のような「React Element」というプレーンな JavaScript オブジェクトが作られています。
// 擬似的な React Element のイメージ
{
type: 'div',
props: { children: 'Hello' },
key: null
}コンポーネントを使った場合は、type に関数自身が入ります。
{
type: MyButton, // 関数参照
props: { label: '押す' },
key: null
}この「タグやコンポーネントをオブジェクトに変えたもの」が木構造でつながっているもの、それが React における Virtual DOM の実体です。
図 6.1.1 実 DOM と Virtual DOM の対比
左がブラウザの世界、右が React の世界と覚えておくと、これから先の話がすっきりします。
なぜ 2 つ持つのか
実 DOM と Virtual DOM、なぜわざわざ 2 つ持つのでしょうか。答えは 差分を計算するため です。
実 DOM には、ブラウザがレイアウト計算やスタイル計算と結びつけて管理している情報がぎっしり詰まっています。1 つの属性を書き換えただけでも、連鎖的に処理が走ることがあります。一方で、プレーンな JavaScript オブジェクトの比較は単なるメモリ上の処理で済みます。軽いものどうしで差分を取ってから、重い方には最小限しか触らない、というアイデアです。
React は以下の流れで動きます。
- コンポーネントを呼び出して、新しい Virtual DOM ツリーを作る
- 前回の Virtual DOM ツリーと比較して、差分を計算する
- 差分 だけ を実 DOM に反映する
Part 2 で Vanilla JS の画面更新を手で書いたのを覚えているでしょうか。あのとき「どこをどう書き換えるか」を毎回自分で考えていたはずです。Virtual DOM は、この「どこを書き換えるか」を React に任せるための中間表現なのです。
図 6.1.2 Virtual DOM が仲介する更新フロー
触って確かめる: 再レンダーの 4 ステップ
文字だけだと抽象的なので、下のデモで実際に 1 ステップずつ進めてみてください。ボタンを押すと「state 更新 → 新しい Virtual DOM → 差分 → 実 DOM 更新」の順に、真ん中のステージで動きが見えるはずです。
4 つのステップのうち、ブラウザの表示が実際に変わるのは最後の 1 つだけ、という点に注目してみてください。残りの 3 ステップはぜんぶメモリ上の話です。
よくある誤解: 「Virtual DOM だから速い」
「Virtual DOM があるから React は速い」という説明を見かけることがありますが、これは少し正確ではありません。メモリ上にツリーを作って差分を取る処理は、純粋に速度を稼ぐためのテクニックではない ので、むしろ実 DOM を直接触るより処理ステップは増えているとも言えます。
Virtual DOM が担っているのは、主に次の 3 つです。
- 宣言的 UI を可能にする: 開発者は「こういう結果の UI を見せたい」と書けばよく、手順を書かなくて済む
- バッチ更新: 複数の状態更新をまとめて、1 回の描画に合流させる
- 差分適用: 毎回全体を作り直さず、変わった部分だけを DOM に反映する
つまり Virtual DOM は「速くするため」というより、手続き的に書いていた UI 同期処理を、宣言的に書けるようにするための仕掛け と捉えるのが正確です。手書きで最小の DOM 操作ができる熟練者なら、特定のケースで React より速いコードを書けることは十分あり得ます。
ここはけっこう誤解されがちなので、「宣言的 UI のための仕掛け」と覚えておくと、後でチーム内での会話でも迷いにくくなると思います。
差分アルゴリズムの考え方
2 つの木を厳密に比較するアルゴリズムは、計算量が O(n³) になることが知られています。React 公式ドキュメント (legacy) にはこう書かれています。
the state of the art algorithms have a complexity in the order of O(n^3) where n is the number of elements in the tree. — Reconciliation (legacy docs)
n が 1000 なら 10 億回の比較です。画面更新のたびにこれを回していたら、現実的な速さになりません。React はこれを O(n) に抑えるため、2 つのヒューリスティクス(経験則)を採用しています。
React implements a heuristic O(n) algorithm based on two assumptions:
- Two elements of different types will produce different trees.
- The developer can hint at which child elements may be stable across different renders with a
keyprop.
日本語で言い直すと、こんな感じです。
- 型が違う要素は、subtree を丸ごと作り直す たとえば
<div>から<span>に変わったら、その下にぶらさがる子孫はすべて破棄して作り直します。中身が似ていても、型が違えば同じものとはみなしません。 - リストは
keyで同一性を判断する 配列で並ぶ要素は、keyが同じなら「同じもの」、違えば「別物」とみなします。keyがないと、React は位置だけを頼りにするため、先頭に挿入したときにすべての子を作り直してしまいます。
この挙動の詳細は 3.7 リストと key と 4.4 Context の周辺で扱った通りです。内部実装としては、この 2 つの仮定が現実的な時間計算量を支える土台になっています。
Render Phase と Commit Phase
React の画面更新は、大きく 2 つのフェーズに分かれています。
- Triggering a render (delivering the guest's order to the kitchen)
- Rendering the component (preparing the order in the kitchen)
- Committing to the DOM (placing the order on the table) — Render and Commit
- Render Phase: コンポーネント関数を呼び出し、新しい Virtual DOM を計算するフェーズ。副作用を起こしてはいけない純粋な計算 として扱われます
- Commit Phase: 計算結果を実 DOM に反映するフェーズ。ここで初めてブラウザの見た目が変わります
Render Phase の純粋性について、公式はこう述べています。
Same inputs, same output. Given the same inputs, a component should always return the same JSX. It minds its own business. It should not change any objects or variables that existed before rendering.
この分離があるおかげで React は、Render Phase を やり直したり、途中で破棄したり できます。結果が気に入らなければ捨てて描き直せばよい、という自由度は、次章で扱う Fiber アーキテクチャの前提でもあります。
図 6.1.3 レンダーサイクルの 2 フェーズ
まとめ
- Virtual DOM は UI を JavaScript オブジェクトで表した軽量な記述 です
{type, props, key}の木構造として表現されます- 2 つ持つのは 差分を計算して、実 DOM への書き込みを最小化する ため
- 「Virtual DOM = 速い」ではなく、本質は 宣言的 UI・バッチ更新・差分適用 の仕掛け
- 差分アルゴリズムは O(n³) ではなく、2 つのヒューリスティクスで O(n) に抑えている
- レンダーは Render Phase(純粋) と Commit Phase(副作用) の 2 段階
次章では、この差分計算を実際に担うエンジンである Reconciler と、その内部構造 Fiber を見ていきます。