🐈

仮想 DOM とは?

に公開

React における仮想 DOM について調べました。

DOM とは

DOM(Document Object Model)は、HTML を JavaScript から操作できる
オブジェクトの木として表したもの。

HTML はただの文字列なので、そのままでは JavaScript から
「ここに <p> がある」「この要素を変更したい」といった操作を
簡単に行うことができない。

そこでブラウザは HTML を解析し、
ノード(Node)と呼ばれるオブジェクトを作成する。
これらのノードはブラウザ内部で管理され、
親子関係でつながったツリー構造として保持される。

このツリー構造全体を DOM と呼ぶ。

<div>
  <h1>Hello</h1>
  <p>World</p>
</div>

⬇︎ ブラウザ内部では

div
 ├─ h1
 └─ p

のような感じでツリーとして扱われる。

ブラウザ内部で何が起きている?

  1. HTML を受け取る(文字列)
  2. 解析(パース)してノードを作成する
  3. ノード同士を親子関係をつないでツリーにする(DOM ツリーの完成)

ブラウザの中には「div ノード」「h1 ノード」「p ノード」みたいなオブジェクトができている。

ツリーってどういう意味?

DOM は大体以下の形の参照(ポインタ)を持っていると思うとイメージしやすい。

  • 親は子を知っている(children)
  • 子は親を知っている(parentNode)
  • 兄弟もたどれる(nextSibling/previousSibling)

だから、

  • divの子にh1pがいる
  • h1の次の兄弟がp
    みたいな構造を JS でたどれる。

「Hello」や「World」もノード

<div>
  <h1>Hello</h1>
  <p>World</p>
</div>

実は DOM 的には 「Hello」や「World」もノード。

ざっくりこうなる:

Document
└─ html
   └─ body
      └─ div
         ├─ h1
         │  └─ #text "Hello"
         └─ p
            └─ #text "World"
  • h1は Element Node
  • Helloは Text Node
  • 全体の起点はdocument(Document Node)
    なので、「DOM = タグのツリー」ではなく、「DOM = ノード(要素・テキスト・コメント等)全体のツリー」

DOM があると何ができる

DOM はオブジェクトなので、JavaScript から次のような操作ができます。

1. 取得できる

const h1 = document.querySelector("h1");

2. 中身を変えられる

h1.textContent = "Hi!";
h1.classList.add("title");

3. ノードを追加・削除できる

const li = document.createElement("li");
li.textContent = "New";
document.querySelector("ul").appendChild(li);

DOM は、ブラウザが持つ画面の構造データであり、JS はそれを操作して UI を変える

DOM と画面は同じ?

DOM = 画面そのものではない。

  • DOM は「構造(何があるか)」のデータ
  • そこからブラウザは「どう見えるか」を計算して描画する

DOM を変更すると、ブラウザ内部では:

  • レイアウト計算
  • 再描画(ペイント)
  • 合成(コンポジット)

といった処理が発生することがあり、これが DOM 操作が高コストになりやすい と言われる理由です。

Vanilla JS で DOM を扱うと何が大変か

Vanilla JS では、「状態 → UI」の一致を人間が手作業で維持する必要があります。

「状態 → UI」とは

  • 状態(state) = アプリの今の情報
  • UI = その状態を画面に見せたもの

状態が変わったら、それに合わせて DOM を正しく操作しなければならない。

例:TODO リストで追加する場合

addBtn.addEventListener("click", () => {
  todos.push(input.value); // 状態が変わる

  // UIも更新しなきゃいけない(命令)
  const li = document.createElement("li");
  li.textContent = input.value;
  list.appendChild(li);

  input.value = ""; // UI更新(命令)
  emptyMsg.style.display = "none"; // UI更新(命令)
});

怖いポイントは、

  • 空表示を消し忘れる
  • input をクリアし忘れる
  • リスト更新だけされて状態と UI がずれる
  • 「削除」「フィルタ」「並び替え」も増えると、分岐が爆発的に増える。

この「状態と UI の一致を人間が手作業で維持」する必要をなくしたのが仮想 DOM

仮想 DOM とは

仮想 DOM とは、「今の state なら UI はこうあるべき」という構造を表した JavaScript オブジェクトのツリー。
React では直接 DOM を触らずに、

  1. state を変える
  2. React がもう一度render(=UI の計算)する
  3. 変わったところだけ DOM に反映する

という流れ UI を更新する。

React は「状態を変える」だけでいい

setTodos([...todos, text]);
setText("");
  • DOM 操作を書かない
  • 表示・非表示を命令しない
  • 「どう見せたいか」ではなく「どういう状態か」だけを書く

これが **「状態 → UI を自動で一致させる」**という意味。

例:TODO リストで追加する場合

function App() {
  const [todos, setTodos] = useState<string[]>([]);
  const [text, setText] = useState("");

  const add = () => {
    setTodos([...todos, text]); // 状態を更新
    setText("");                // 状態を更新
  };

  return (
    <>
      <h1>TODO</h1>

      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={add}>追加</button>

      {todos.length === 0 ? (
        <p>まだありません</p>
      ) : (
        <ul>
          {todos.map((t) => <li key={t.id}>{t}</li>)}
        </ul>
      )}
    </>
  );
}

ここでのポイントは:

  • liを作って append していない
  • empty 表示の切り替えも命令していない
  • input をクリアするのも DOM 命令じゃない

「状態をこうしたい」だけを書いたら、UI が勝手に一致する
→ これが、「状態 → UI を自動で一致」の意味。

Vanilla JS で TODO リストに機能を増やすと

先ほどの例の TODO リストに「削除」「完了チェック」「フィルタ」を入れると、「状態の組み合わせ × UI 操作命令」を人間が全部管理することになるため、
とても大変。

状態が増える

todos = [
  { id: 1, text: "A", done: false },
  { id: 2, text: "B", done: true },
];

filter = "all" | "active" | "done";

ここまでは問題ない。

削除処理

Vanilla JS だと最低でも以下をやる必要がある。

deleteBtn.addEventListener("click", () => {
  // ① 状態を更新
  todos = todos.filter((t) => t.id !== id);

  // ② DOMからliを削除
  li.remove();

  // ③ 空メッセージ表示切替
  if (todos.length === 0) {
    emptyMsg.style.display = "block";
  }

  // ④ フィルタ中なら再判定
  applyFilter();
});
  • 「状態更新」と「DOM 操作」が分離している
  • どれか一つ忘れると UI が壊れる

完了チェック

checkbox.addEventListener("change", () => {
  todo.done = checkbox.checked;

  // 見た目変更
  li.classList.toggle("done");

  // フィルタが active のとき
  if (filter === "active" && todo.done) {
    li.style.display = "none";
  }

  // フィルタが done のとき
  if (filter === "done" && !todo.done) {
    li.style.display = "none";
  }
});
  • filter 状態を毎回考慮
  • 「今表示すべきか?」を命令で書く
  • チェック ON/OFF の両方を考える必要がある

フィルタ処理

filterBtns.forEach((btn) => {
  btn.addEventListener("click", () => {
    filter = btn.dataset.filter;

    todos.forEach((todo) => {
      const li = findLi(todo.id);

      if (filter === "all") {
        li.style.display = "block";
      } else if (filter === "active") {
        li.style.display = todo.done ? "none" : "block";
      } else {
        li.style.display = todo.done ? "block" : "none";
      }
    });
  });
});
  • DOM とデータの紐づけ管理
  • 表示/非表示をすべて命令
  • 削除・完了・完了の影響をすべて考慮

React で TODO リストに機能を増やすと

const visibleTodos = todos.filter((t) => {
  if (filter === "active") return !t.done;
  if (filter === "done") return t.done;
  return true;
});

return (
  <ul>
    {visibleTodos.map((t) => (
      <li key={t.id}>
        <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
        {t.text}
        <button onClick={() => remove(t.id)}>削除</button>
      </li>
    ))}
  </ul>
);
  • 「表示/非表示」を命令していない
  • フィルタは計算結果
  • UI は state の写像
    → 状態が正しければ UI も必ず正しい

仮想 DOM の価値は「速さ」ではない

仮想 DOM の最大の価値は「速さ」ではなく、人間が安全に UI を書けること。

速度だけで見ると仮想 DOM は必須ではない

  • ブラウザはもともと差分描画をする
  • Vanilla JS でも正しく書けば最小限の再描画

仮想 DOM が活きるときと、本当の仕事

人間が DOM 差分を管理するのが破綻するような

  • DOM 数:数千~数万
  • 状態が複雑に絡む
  • 条件分岐・非同期更新が多い
    の時に、仮想 DOM が効く

で、仮想 DOM の本当の仕事は、

1. DOM 操作の抽象化

// React
{
  isLoggedIn && <Profile />;
}

react では上記のように記載するが、実際には

  • 追加
  • 削除
  • 並び替え
  • イベントを維持
    これらを全部 React 側が担当する

2. 安全な全体再計算

React は「今の state なら UI はこうあるべき」ということを考えてくれるため、人間は「どこを消す?」「input は残す?」「イベントは?」を考えなくていい

3. バグを防ぐ

  • 消し忘れ
  • 二重追加
  • 状態ずれ
    といったものを防ぐ

仮想 DOM は遅い?

仮想 DOM 生成、差分比較の処理があるため、JS の計算コストは確実にある
けれど、DOM の操作や人的ミスのコストはより高い。

まとめ

  • DOM は、ブラウザが HTML を解析して作る オブジェクトのツリー
  • DOM は JavaScript の普通のオブジェクトではなく、ブラウザが管理する構造データ
  • Vanilla JS では「状態 → UI」の一致を人間が手作業で維持する必要がある
  • 仮想 DOM は「今の state なら UI はこうあるべき」という構造を表した JavaScript オブジェクトのツリー
  • React は仮想 DOM を使って UI を再計算し、差分だけを実 DOM に反映する
  • 仮想 DOM の最大の価値は「速さ」ではなく、人間が安全に UI を書けること

仮想 DOM によって、
「どの DOM を追加する?消す?更新する?」という命令を書く必要がなくなり、
「状態をどうしたいか」だけを考えればよくなる。

Discussion