T-CREATOR

Svelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由

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 つの関数で構成されます。

#関数名役割
1create_fragment初期 DOM 構造を生成
2instanceコンポーネントのロジックとリアクティブな変数を定義
3update状態変更時に必要な 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 の内部関数で、以下の処理を行います。

  1. 第一引数(0):変更された変数のインデックス
  2. 第二引数:新しい値
  3. 内部で 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 が以下のような最適化を行っていることがわかります。

#状態処理内容
1showListtruetrue変更があれば該当 DOM のみ更新
2showListfalsetrueif ブロックを新規作成してマウント
3showListtruefalseif ブロックを破棄
4showListfalsefalse何もしない

このように、現在の状態と次の状態に応じて、必要最小限の 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 個のリストアイテムを持つコンポーネント
  • 各アイテムに状態変更ボタンを配置
  • ボタンクリック時の更新時間を計測

結果

#フレームワーク初期レンダリング単一アイテム更新全体再レンダリングバンドルサイズ
1Svelte12ms0.8ms15ms3.2KB
2React28ms2.1ms45ms42.7KB
3Vue 322ms1.5ms32ms34.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 のようなコンパイラファーストのアプローチは、ますます重要になっていくでしょう。

関連リンク