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
のような感じでツリーとして扱われる。
ブラウザ内部で何が起きている?
- HTML を受け取る(文字列)
- 解析(パース)してノードを作成する
- ノード同士を親子関係をつないでツリーにする(DOM ツリーの完成)
ブラウザの中には「div ノード」「h1 ノード」「p ノード」みたいなオブジェクトができている。
ツリーってどういう意味?
DOM は大体以下の形の参照(ポインタ)を持っていると思うとイメージしやすい。
- 親は子を知っている(children)
- 子は親を知っている(parentNode)
- 兄弟もたどれる(nextSibling/previousSibling)
だから、
-
divの子にh1とpがいる -
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 を触らずに、
- state を変える
- React がもう一度
render(=UI の計算)する - 変わったところだけ 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 を追加する?消す?更新する?」という命令を書く必要がなくなり、
「状態をどうしたいか」だけを考えればよくなる。