はじめに
chibivueをやっており仮想DOMの内容についてふんわりとした理解しかなく
イマイチ理解が進まなかったので理解深めるために調べたので備忘録です。
仮想DOMとは?
最初に結論から言うと、仮想DOMとは
「JavaScriptオブジェクトで表現されたDOMのコピー」
です。
実際のDOM(ブラウザに表示される要素)とは別に、JavaScriptのメモリ上にDOM構造を表すオブジェクトを持っておき、それを使って効率的に画面を更新する仕組みです。
[実際のDOM] [仮想DOM]
ブラウザに表示 ⇄ JavaScriptオブジェクト
変更が重い 変更が軽い
直接操作 メモリ上で操作
コード例で見てみる
例えば、このようなHTMLがあるとします:
<div id="app">
<h1>Hello</h1>
</div>
仮想DOMでは、これを以下のようなJavaScriptオブジェクトで表現します:
{
type: 'div',
props: { id: 'app' },
children: [
{
type: 'h1',
props: {},
children: 'Hello'
}
]
}
なぜ仮想DOMが必要なのか
仮想DOMが登場した背景には、従来のDOM操作の問題点がありました。実際のコード例を見ながら理解していきましょう。
従来のDOM操作の問題点
カウンターアプリを例に考えてみます。
// カウンターアプリを直接DOM操作で実装
let count = 0;
function updateCounter() {
// 毎回DOM要素を取得
const counterElement = document.getElementById('counter');
counterElement.textContent = count;
// 条件によって表示を変える
if (count > 10) {
counterElement.style.color = 'red';
} else {
counterElement.style.color = 'black';
}
}
document.getElementById('button').addEventListener('click', () => {
count++;
updateCounter();
});
一見問題なさそうに見えますが、以下の課題があります:
- DOM操作が散らばる → どこで何を変更しているか追いにくい
- 何度もDOM要素を取得 → パフォーマンスが悪化
- 「どう変更するか」を全て書く必要がある → コードが長く複雑になる
仮想DOMの基本的な仕組み
仮想DOMは、以下の3ステップで動作します。図解とコード例で理解していきましょう。
ステップ1: 仮想DOMを作る
状態(データ)に基づいて、仮想DOMオブジェクトを生成します。
// 状態に基づいて仮想DOMを生成
function render(count) {
return {
type: 'div',
props: {},
children: [
{
type: 'p',
props: {},
children: `Count: ${count}`
},
{
type: 'button',
props: {},
children: '+1'
}
]
};
}
// 初回レンダリング
const vdom1 = render(0);
ステップ2: 差分を検出する
状態が変わったら、新しい仮想DOMを作成し、前回のものと比較します。
// countが0から1に変わった
const vdom1 = render(0); // Count: 0
const vdom2 = render(1); // Count: 1
// 仮想DOM同士を比較
// → pタグのテキストだけが変わったことを検出
// → buttonは変わっていないので無視
ステップ3: 必要な部分だけ実DOMを更新
差分検出で見つかった変更点だけを、実際のDOMに反映します。
// 変更があった部分だけを実DOMに反映
document.querySelector('p').textContent = 'Count: 1';
// ↑ボタンは変わってないので触らない!
全体の流れを図解
【状態変化】
count: 0 → 1
【ステップ1: 仮想DOM生成】
vdom1 vdom2
Count: 0 → Count: 1
【ステップ2: 差分検出】
比較して変更点を検出
→ pタグのテキストだけ変わった!
【ステップ3: 実DOM更新】
変わった部分だけを更新
→ パフォーマンスが向上
ポイントは、全体を作り直すのではなく、差分だけ更新することです。これが効率的な理由です。
そして、この一連の処理をフレームワークが自動でやってくれるのが、Vue.jsやReactなどの強みです。
Vue.jsでの使われ方
ここからは、実際にVue.jsでどのように仮想DOMが使われているかを見ていきましょう。
テンプレート構文での例
Vue.jsでは、普段テンプレート構文を使ってUIを記述します。
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
const increment = () => {
count.value++;
};
</script>
このテンプレートは、内部的に仮想DOMを生成する関数に変換されます。
// テンプレートが内部的に変換されたイメージ
function render() {
return {
type: 'div',
children: [
{ type: 'p', children: `Count: ${count.value}` },
{ type: 'button', props: { onClick: increment }, children: '+1' }
]
};
}
動作の流れ
1. count.value++が実行される
↓
2. リアクティブシステムが変更を検知
↓
3. Vue.jsが新しい仮想DOMを生成
↓
4. 前回の仮想DOMと比較(差分検出)
↓
5. pタグだけを実DOMで更新
開発者がやることは**count.value++だけ**。後はVue.jsが自動的に画面を更新してくれます。
h関数(render関数)での例
より明示的に仮想DOMを扱いたい場合は、h関数を使います。
import { ref, h } from 'vue';
export default {
setup() {
const count = ref(0);
const increment = () => {
count.value++;
};
// render関数でVNode(仮想DOMノード)を直接作成
return () => h('div', [
h('p', `Count: ${count.value}`),
h('button', { onClick: increment }, '+1')
]);
}
};
h関数の基本構文は以下の通りです:
h(タグ名, プロパティ, 子要素)
// 例
h('div', { class: 'container' }, [
h('h1', 'タイトル'),
h('p', 'テキスト')
])
ポイント:
-
h関数は仮想DOMノード(VNode)を作成する関数 - テンプレート構文も内部的にはh関数に変換される
- より細かい制御が必要な時に直接使用する
リアクティブシステムとの連携
Vue.jsの強みは、リアクティブシステムと仮想DOMが連携していることです。
【Vue.jsの内部動作】
ref/reactive リアクティブシステム
↓ 変更検知 ↓
状態変更 → 仮想DOM生成
↓ ↓
count.value++ 新しいVNode作成
↓
差分検出(Diff)
↓
実DOM更新(Patch)
例えば:
import { ref, watchEffect } from 'vue';
const count = ref(0);
// countが変わるたびに自動実行される
watchEffect(() => {
console.log('countが変わりました:', count.value);
// この中で仮想DOMが生成・比較・更新される
});
count.value++; // 自動的に再レンダリングが走る
開発者は状態(ref)を変えるだけで、Vue.jsが:
- 変更を自動検知
- 新しい仮想DOMを生成
- 差分を検出
- 必要な部分だけ更新
というプロセスを全て自動で行ってくれます。
仮想DOMのメリット・デメリット
仮想DOMは便利ですが、完璧ではありません。メリットとデメリットを理解しておきましょう。
メリット
1. パフォーマンスの最適化
従来:全体を作り直す → 遅い
仮想DOM:差分だけ更新 → 速い
特に複雑なUIや大量のデータを扱う場合、差分更新によって大幅にパフォーマンスが向上します。
2. 宣言的なUI記述が可能
従来の方法(命令的)では「手順」を書く必要がありました:
// 命令的(手順を書く)
if (isLoggedIn) {
showUserInfo();
hideLoginButton();
} else {
hideUserInfo();
showLoginButton();
}
仮想DOMを使うと「結果」を書くだけでOKです(宣言的):
<!-- 宣言的(結果を書く) -->
<UserInfo v-if="isLoggedIn" />
<LoginButton v-else />
「どうあるべきか」だけ記述すれば、「どう変更するか」はフレームワークが考えてくれます。
デメリット・注意点
1. オーバーヘッドが存在する
仮想DOMも万能ではありません:
- 仮想DOM生成のコスト
- 差分検出のコスト
- 非常にシンプルなアプリでは逆に遅い場合も
例えば、単純なテキスト更新だけなら、直接DOMの方が速いこともあります:
// これだけなら直接DOMの方が速い
document.getElementById('text').textContent = 'Hello';
ただし、UIが複雑になればなるほど、仮想DOMの恩恵は大きくなります。
2. 学習コストがある
- フレームワーク特有の概念を理解する必要がある
- key属性などのベストプラクティスを学ぶ必要がある
3. デバッグが難しい場合がある
- 実際のDOMと仮想DOMの乖離を理解する必要がある
- フレームワークの内部動作を知っていると役立つ
よくある誤解
誤解1: 「仮想DOMは常に速い」
これは誤解です。
仮想DOMは「最適化の手段」であって「魔法」ではありません。
// 単純なテキスト更新
// これだけなら直接DOMの方が速い
document.getElementById('text').textContent = 'Hello';
// でも複雑な更新になると仮想DOMが有利
// - 複数の要素の追加・削除
// - 条件分岐による表示切り替え
// - リストの並び替え
仮想DOMが真価を発揮するのは、複雑なUI更新が頻繁に発生する場合です。
誤解2: Vue.jsだけが仮想DOMではない
多くのフレームワークが仮想DOMを採用しています:
- React: 仮想DOMの先駆け
- Preact: Reactの軽量版
- Vue.js: テンプレート構文と仮想DOMの組み合わせ
一方で、Svelteは仮想DOMを使わず、コンパイル時に最適化する別のアプローチを取っています。
「仮想DOMが唯一の正解」ではなく、目的に応じて選択できるということです。
誤解3: key属性は適当でいい
これは大きな誤解です。key属性は非常に重要です。
<!-- ❌ keyなし -->
<li v-for="item in items">{{ item.name }}</li>
<!-- ✅ keyあり -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
なぜkeyが必要?
keyがないと、Vue.jsは要素の対応関係を正しく判断できません:
- 無駄な再レンダリングが発生
- アニメーションが正しく動かない
- フォームの入力状態が混ざる
// ❌ 配列のインデックスをkeyにするのは避ける
v-for="(item, index) in items" :key="index"
// ✅ 一意のIDを使う
v-for="item in items" :key="item.id"
まとめ
- 仮想DOM = JavaScriptオブジェクトで表現されたDOMのコピー
- 差分検出によって最小限の更新を実現
- 宣言的なUI記述が可能になる
- 完璧ではないが、多くの場合で開発体験とパフォーマンスを向上させる