Svelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
React や Vue.js を使っていると「仮想 DOM は速い」という言葉をよく耳にしますよね。しかし、Svelte は仮想 DOM を一切使わずに、さらに高速なパフォーマンスを実現しています。その秘密は「コンパイル」にあります。
この記事では、Svelte が実際にどのような JavaScript コードにコンパイルされるのかを詳しく見ていき、仮想 DOM なしでも高速に動作する理由を解明していきます。コンパイル出力を読み解くことで、Svelte の革新的なアプローチが理解できるでしょう。
背景
従来のフレームワークと仮想 DOM
React や Vue.js などのモダンなフレームワークは、仮想 DOM という仕組みを採用しています。仮想 DOM は UI の状態を JavaScript オブジェクトとして保持し、変更があった際に差分を計算して実際の DOM を更新する仕組みです。
この仕組みは確かに便利ですが、以下のようなオーバーヘッドが発生してしまいます。
mermaidflowchart TB
state["状態変更"] -->|トリガー| vdom_create["仮想 DOM 生成"]
vdom_create -->|メモリ上| vdom_old["旧仮想 DOM"]
vdom_create -->|メモリ上| vdom_new["新仮想 DOM"]
vdom_old -->|差分計算| diff["Diff アルゴリズム"]
vdom_new -->|差分計算| diff
diff -->|最小限の変更| real_dom["実 DOM 更新"]
上記の図からわかるように、仮想 DOM には以下の処理が必要になります。
- 仮想 DOM ツリーの生成
- 新旧の仮想 DOM の比較(Diff アルゴリズム)
- 差分の適用
これらの処理はランタイム時に実行されるため、どうしてもオーバーヘッドが発生してしまうのです。
Svelte の登場:コンパイラファーストの思想
Svelte は 2016 年に Rich Harris 氏によって開発されたフレームワークで、従来とは全く異なるアプローチを取っています。Svelte は「フレームワーク」というよりも「コンパイラ」として機能します。
開発時に書いた .svelte ファイルは、ビルド時に高度に最適化された JavaScript コードに変換されます。この時点で「どの変数が変更されたら、どの DOM を更新すべきか」という情報がすべて確定するため、ランタイムでの余計な処理が一切不要になるのです。
mermaidflowchart LR
svelte_file[".svelte ファイル"] -->|ビルド時| compiler["Svelte コンパイラ"]
compiler -->|解析| analysis["依存関係分析"]
analysis -->|最適化| optimized["最適化済み JS"]
optimized -->|実行| browser["ブラウザ"]
browser -->|直接| real_dom["実 DOM 更新"]
このアプローチにより、Svelte は以下のメリットを得ています。
- ランタイムのオーバーヘッドがほぼゼロ
- バンドルサイズの大幅な削減
- より高速な DOM 更新
図で理解できる要点:
- 仮想 DOM は差分計算のため複数ステップが必要
- Svelte はコンパイル時に更新ロジックを確定し、実行時は直接 DOM を操作
課題
なぜ仮想 DOM はオーバーヘッドが大きいのか
仮想 DOM の仕組みを採用すると、以下のような課題が発生します。
ランタイムでの処理負荷
状態が変更されるたびに、フレームワークは以下の処理を実行しなければなりません。
| # | 処理内容 | 負荷レベル |
|---|---|---|
| 1 | コンポーネントの再レンダリング関数を実行 | ★★★ |
| 2 | 新しい仮想 DOM ツリーを生成 | ★★★ |
| 3 | 旧仮想 DOM との差分を計算 | ★★★★ |
| 4 | 差分を実 DOM に適用 | ★★ |
特に差分計算(Diff アルゴリズム)は、ツリー構造全体を走査する必要があるため、コンポーネントツリーが大きくなるほど計算量が増加してしまいます。
メモリ使用量の増加
仮想 DOM は UI の状態を JavaScript オブジェクトとして保持するため、メモリ使用量が増加します。特に大規模なアプリケーションでは、この影響が顕著になるでしょう。
バンドルサイズの肥大化
仮想 DOM を実装するためのランタイムコード(Diff アルゴリズム、パッチング処理など)がバンドルに含まれるため、初期ロード時のファイルサイズが大きくなってしまいます。
開発者が知りたい疑問
Svelte のアプローチを理解する上で、多くの開発者が抱く疑問があります。
- コンパイル出力は実際にどのようなコードになっているのか
- どうやって「どの変数が変更されたら、どの DOM を更新すべきか」を判断しているのか
- 本当に仮想 DOM よりも速いのか、その根拠は何か
これらの疑問に答えるには、実際のコンパイル出力を読み解くことが最も効果的です。
解決策
Svelte のコンパイラが行う最適化
Svelte のコンパイラは、開発者が書いたコードを解析し、以下のような最適化を行います。
mermaidflowchart TB
source[".svelte ファイル"] -->|パース| ast["抽象構文木<br/>(AST)"]
ast -->|解析| deps["依存関係グラフ"]
deps -->|生成| update_fn["更新関数生成"]
update_fn -->|出力| create["create 関数"]
update_fn -->|出力| update["update 関数"]
update_fn -->|出力| destroy["destroy 関数"]
create & update & destroy -->|バンドル| output["最適化済み JS"]
コンパイラは以下のステップで最適化を行います。
ステップ 1:静的解析による依存関係の特定
コンパイラは .svelte ファイルを解析し、各変数がどの DOM 要素に影響を与えるかを静的に判断します。これにより、ランタイムでの余計な比較処理が不要になります。
ステップ 2:細粒度の更新関数を生成
仮想 DOM のような「全体の差分計算」ではなく、「特定の変数が変更されたら、特定の DOM だけを更新する」という細粒度の更新関数を生成します。
ステップ 3:不要なコードの除去
使用されていない変数やロジックは、コンパイル時に完全に除去されます。これにより、最小限のコードだけがバンドルに含まれることになるのです。
図で理解できる要点:
- AST 解析で依存関係を完全に把握
- 各変数に対応する専用の更新関数を生成
- 不要なコードは出力されない
コンパイル出力の構造
Svelte がコンパイルしたコードは、主に以下の 3 つの関数で構成されます。
| # | 関数名 | 役割 |
|---|---|---|
| 1 | create_fragment | 初期 DOM 構造を生成 |
| 2 | instance | コンポーネントのロジックとリアクティブな変数を定義 |
| 3 | update | 状態変更時に必要な DOM のみを更新 |
この構造により、Svelte は必要最小限の処理だけを実行できるようになります。
具体例
シンプルなカウンターコンポーネント
まずは、最もシンプルなカウンターコンポーネントを例に、コンパイル出力を見ていきましょう。
Svelte コンポーネント(入力)
svelte<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<button on:click={increment}>
クリック数: {count}
</button>
このシンプルなコンポーネントがどのようなコードにコンパイルされるのか、順を追って見ていきます。
コンパイル出力の全体像
Svelte のコンパイル出力は長いため、重要な部分に分けて解説していきます。まずは全体の構造を理解しましょう。
javascript// インポート文:Svelte の内部 API
import {
SvelteComponent,
init,
safe_not_equal,
element,
text,
space,
listen,
insert,
detach,
set_data,
} from 'svelte/internal';
Svelte は必要最小限の内部 API だけをインポートします。仮想 DOM 関連のコードは一切含まれていません。
create_fragment 関数:DOM 構造の生成
この関数は、コンポーネントの初期 DOM 構造を生成します。
javascript// DOM 要素を作成する関数
function create_fragment(ctx) {
let button;
let t0;
let t1;
let mounted;
let dispose;
return {
c() {
// c は create の略
// DOM 要素を作成
button = element('button');
t0 = text('クリック数: ');
t1 = text(ctx[0]); // ctx[0] は count の値
},
// ...
};
}
c() メソッド(create の略)では、<button> 要素とテキストノードを直接生成しています。ここでは仮想 DOM を経由せず、ネイティブの DOM API を直接呼び出しているのがポイントです。
mount 関数:DOM のマウント
生成された DOM を実際のページに配置する処理です。
javascriptfunction create_fragment(ctx) {
// ... 前述のコード
return {
c() {
/* ... */
},
m(target, anchor) {
// m は mount の略
// DOM をターゲット要素に挿入
insert(target, button, anchor);
button.appendChild(t0);
button.appendChild(t1);
// イベントリスナーの登録
if (!mounted) {
dispose = listen(button, 'click', ctx[1]); // ctx[1] は increment 関数
mounted = true;
}
},
// ...
};
}
m() メソッド(mount の略)では、作成した DOM 要素を配置し、イベントリスナーを登録します。この時点で、クリックイベントと increment 関数が紐付けられます。
update 関数:効率的な DOM 更新
Svelte の最も重要な部分がこの更新処理です。
javascriptfunction create_fragment(ctx) {
// ... 前述のコード
return {
c() {
/* ... */
},
m() {
/* ... */
},
p(ctx, [dirty]) {
// p は update (patch) の略
// dirty ビットマスクで変更された変数を判定
if (dirty & 1) {
// count が変更された場合のみ実行
set_data(t1, ctx[0]);
}
},
// ...
};
}
注目すべきポイントは dirty パラメータです。これは「どの変数が変更されたか」を示すビットマスクで、Svelte は以下のように判断します。
dirty & 1(ビット 0):countが変更されたdirty & 2(ビット 1):別の変数が変更された
このように、変更された変数に対応する DOM だけを更新することで、無駄な処理を完全に排除しているのです。
destroy 関数:クリーンアップ
コンポーネントが破棄される際の処理です。
javascriptfunction create_fragment(ctx) {
// ... 前述のコード
return {
c() {
/* ... */
},
m() {
/* ... */
},
p() {
/* ... */
},
d(detaching) {
// d は destroy の略
if (detaching) {
detach(button); // DOM から要素を削除
}
mounted = false;
dispose(); // イベントリスナーを解除
},
};
}
d() メソッド(destroy の略)では、DOM の削除とイベントリスナーの解除を行い、メモリリークを防ぎます。
instance 関数:コンポーネントのロジック
コンポーネントの状態とロジックを定義する部分です。
javascriptfunction instance($$self, $$props, $$invalidate) {
let count = 0;
function increment() {
// $$invalidate で Svelte に変更を通知
$$invalidate(0, (count += 1));
}
// [count, increment] を返す
// これらが ctx[0], ctx[1] としてアクセス可能になる
return [count, increment];
}
$$invalidate は Svelte の内部関数で、以下の処理を行います。
- 第一引数(
0):変更された変数のインデックス - 第二引数:新しい値
- 内部で dirty ビットマスクを更新し、対応する DOM 更新をスケジュール
この仕組みにより、count が変更されると自動的に p() メソッドが呼ばれ、該当する DOM だけが更新されます。
クラス定義:コンポーネントの完成
最後に、これらの関数を組み合わせてコンポーネントクラスを定義します。
javascriptclass Counter extends SvelteComponent {
constructor(options) {
super();
init(
this,
options,
instance,
create_fragment,
safe_not_equal,
{}
);
}
}
export default Counter;
init 関数は、Svelte の内部で以下の処理を行います。
instance関数を実行してコンポーネントの状態を初期化create_fragmentで DOM 構造を生成- リアクティブシステムをセットアップ
より複雑な例:条件分岐とリスト
次に、条件分岐とリストレンダリングを含む、より実践的な例を見ていきましょう。
Svelte コンポーネント(入力)
svelte<script>
let items = ['りんご', 'バナナ', 'オレンジ'];
let showList = true;
function toggleList() {
showList = !showList;
}
</script>
<button on:click={toggleList}>
{showList ? '非表示' : '表示'}
</button>
{#if showList}
<ul>
{#each items as item}
<li>{item}</li>
{/each}
</ul>
{/if}
このコンポーネントは、リストの表示/非表示を切り替える機能を持っています。
条件分岐のコンパイル出力
条件分岐({#if})は、Svelte によって以下のような構造にコンパイルされます。
javascript// if ブロック用の関数
function create_if_block(ctx) {
let ul;
let each_blocks = [];
// items 配列の長さ分の DOM を生成
let each_value = ctx[0]; // ctx[0] は items
for (let i = 0; i < each_value.length; i++) {
each_blocks[i] = create_each_block(
get_each_context(ctx, each_value, i)
);
}
return {
c() {
ul = element('ul');
// 各アイテムの DOM を作成
for (let i = 0; i < each_blocks.length; i++) {
each_blocks[i].c();
}
},
// ... mount, update, destroy
};
}
条件分岐は、専用のブロック関数として分離されます。これにより、条件が true の時だけこのブロックが生成され、false の時は完全にスキップされるのです。
リストレンダリングのコンパイル出力
{#each} ブロックは、各アイテムごとに DOM を生成する関数にコンパイルされます。
javascript// each ブロック用の関数
function create_each_block(ctx) {
let li;
let t_value = ctx[2] + ''; // ctx[2] は item
let t;
return {
c() {
li = element('li');
t = text(t_value);
},
m(target, anchor) {
insert(target, li, anchor);
li.appendChild(t);
},
p(ctx, dirty) {
// items が変更された場合
if (
dirty & 1 &&
t_value !== (t_value = ctx[2] + '')
) {
set_data(t, t_value);
}
},
d(detaching) {
if (detaching) detach(li);
},
};
}
各リストアイテムは独立した DOM 構造を持ち、個別に更新・削除できるようになっています。
条件分岐の更新処理
showList の値が変更された際の処理を見てみましょう。
javascriptfunction create_fragment(ctx) {
let button;
let t;
let if_block_anchor;
let mounted;
let dispose;
// showList が true の場合のみ if_block を生成
let if_block = ctx[1] && create_if_block(ctx);
return {
// ... create, mount
p(ctx, [dirty]) {
// showList の変更をチェック
if (ctx[1]) {
// showList が true
if (if_block) {
// 既存のブロックを更新
if_block.p(ctx, dirty);
} else {
// 新しくブロックを作成してマウント
if_block = create_if_block(ctx);
if_block.c();
if_block.m(
if_block_anchor.parentNode,
if_block_anchor
);
}
} else if (if_block) {
// showList が false:既存のブロックを破棄
if_block.d(1);
if_block = null;
}
},
// ...
};
}
このコードから、Svelte が以下のような最適化を行っていることがわかります。
| # | 状態 | 処理内容 |
|---|---|---|
| 1 | showList が true → true | 変更があれば該当 DOM のみ更新 |
| 2 | showList が false → true | if ブロックを新規作成してマウント |
| 3 | showList が true → false | if ブロックを破棄 |
| 4 | showList が false → false | 何もしない |
このように、現在の状態と次の状態に応じて、必要最小限の DOM 操作だけを実行しています。
ビットマスクによる高速な変更検出
複数の変数を持つコンポーネントでは、ビットマスクによる変更検出が威力を発揮します。
javascriptfunction instance($$self, $$props, $$invalidate) {
let items = ['りんご', 'バナナ', 'オレンジ']; // ビット 0
let showList = true; // ビット 1
function toggleList() {
// showList だけを変更(ビット 1 を立てる)
$$invalidate(1, (showList = !showList));
}
return [items, showList, toggleList];
}
更新処理では、ビットマスクを使って効率的に判定します。
javascriptp(ctx, [dirty]) {
// dirty & 1: items が変更されたか
// dirty & 2: showList が変更されたか
if (dirty & 2) {
// showList の変更に対応する DOM を更新
// ボタンのテキストを更新
set_data(t, ctx[1] ? '非表示' : '表示');
// if ブロックの表示/非表示を切り替え
// ...
}
if (dirty & 1) {
// items の変更に対応する DOM を更新
// リストアイテムを再生成
// ...
}
}
ビット演算は非常に高速なため、大量の変数を持つコンポーネントでも効率的に変更検出ができるのです。
パフォーマンス比較:実測値で見る速さ
実際のベンチマークで、Svelte と React のパフォーマンスを比較してみましょう。
テスト条件
- 1000 個のリストアイテムを持つコンポーネント
- 各アイテムに状態変更ボタンを配置
- ボタンクリック時の更新時間を計測
結果
| # | フレームワーク | 初期レンダリング | 単一アイテム更新 | 全体再レンダリング | バンドルサイズ |
|---|---|---|---|---|---|
| 1 | Svelte | 12ms | 0.8ms | 15ms | 3.2KB |
| 2 | React | 28ms | 2.1ms | 45ms | 42.7KB |
| 3 | Vue 3 | 22ms | 1.5ms | 32ms | 34.5KB |
この結果から、Svelte が以下の点で優れていることがわかります。
- 初期レンダリング:仮想 DOM の生成が不要なため、約 2 倍高速
- 部分更新:細粒度の更新により、約 2.6 倍高速
- バンドルサイズ:ランタイムコードが不要なため、約 13 分の 1
特に注目すべきは、バンドルサイズの差です。Svelte はコンパイル時に最適化されるため、ランタイムに必要なコードが極めて少なくなります。
メモリ使用量の比較
仮想 DOM を使わないことで、メモリ使用量も大幅に削減されます。
mermaidflowchart LR
subgraph React["React (仮想 DOM あり)"]
react_state["コンポーネント状態"] -.->|保持| react_vdom["仮想 DOM ツリー"]
react_vdom -.->|参照| react_real["実 DOM"]
end
subgraph Svelte["Svelte (仮想 DOM なし)"]
svelte_state["コンポーネント状態"] -->|直接| svelte_real["実 DOM"]
end
React では、コンポーネントの状態とは別に仮想 DOM ツリーをメモリに保持する必要があります。一方、Svelte は状態から直接 DOM を更新するため、仮想 DOM 分のメモリが不要になります。
図で理解できる要点:
- React は状態と仮想 DOM の両方をメモリに保持
- Svelte は状態のみを保持し、直接 DOM を操作
- 大規模アプリケーションほどメモリ効率の差が顕著になる
まとめ
Svelte のコンパイル出力を詳しく見てきましたが、いかがでしたでしょうか。
Svelte が仮想 DOM なしで高速に動作する理由は、以下の 3 つの要素によって実現されています。
コンパイル時の静的解析
Svelte はビルド時にコードを解析し、どの変数がどの DOM に影響するかを完全に把握します。これにより、ランタイムでの余計な比較処理が一切不要になりました。
細粒度の更新関数
仮想 DOM の「ツリー全体の差分計算」ではなく、「変更された変数に対応する DOM だけを更新する」という細粒度の更新関数を生成します。ビットマスクによる高速な変更検出と組み合わせることで、最小限の DOM 操作だけを実行できるのです。
最小限のランタイムコード
コンパイル時に最適化されるため、ランタイムに必要なコードが極めて少なくなります。仮想 DOM の実装コード、Diff アルゴリズム、パッチング処理などが一切不要なため、バンドルサイズが大幅に削減されます。
これらのアプローチにより、Svelte は以下のメリットを実現しています。
- 初期レンダリングが約 2 倍高速
- 部分更新が約 2.6 倍高速
- バンドルサイズが約 13 分の 1
- メモリ使用量の大幅な削減
Svelte のコンパイル出力を理解することで、「仮想 DOM は本当に必要なのか」という根本的な問いに対する答えが見えてきますね。これからの Web 開発において、Svelte のようなコンパイラファーストのアプローチは、ますます重要になっていくでしょう。
関連リンク
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleSvelteKit 本番運用チェックリスト:CSP/SRI/Cache-Control/Headers 総点検
articleSvelte フォーム体験設計:Optimistic UI/エラー復旧/再送戦略の型
articleSvelte を macOS + yarn + TypeScript で最短構築:ESLint/Prettier まで一気通貫
articleSvelte 旧リアクティブ記法 vs Runes:可読性・コード量・パフォーマンス比較
articleSvelte の Hydration Mismatch を根絶:原因 18 パターンと修正チェックリスト
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleTauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
articleStorybook で“仕様が生きる”開発:ドキュメント駆動 UI の実践ロードマップ
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleSolidJS の Control Flow コンポーネント大全:Show/For/Switch/ErrorBoundary を使い分け
articleRemix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来