Svelte 5 コードベースアーキテクチャ:全体像を把握するための地図
前提知識
- ›Svelte コンポーネントの基本的な知識
- ›npm パッケージ構成と ES modules の理解
- ›フロントエンドフレームワークの一般的な動作に関する知識
Svelte 5 コードベースアーキテクチャ:全体像を把握するための地図
多くのフレームワークはコンパイラかランタイムのどちらか一方です。Svelte はその両方です。.svelte ファイルはコンパイラによって最適化された JavaScript に変換され、その JavaScript がランタイムを呼び出してリアクティビティ、DOM の更新、ライフサイクルを管理します。この二重構造と、2つの半分をつなぐ契約を理解することが、Svelte コードベースを読み解くカギです。この記事では、そのメンタルモデルを構築していきます。
モノレポ構成とパッケージレイアウト
Svelte のリポジトリはモノレポですが、少し変わっています。公開される npm パッケージはひとつだけです。packages/svelte ディレクトリに、コンパイラ・ランタイム・公開 API・関連インフラがすべて集約されています。プレイグラウンド、ドキュメントのスキャフォールディング、ベンチマークといったその他のものは、リポジトリルートや隣接ディレクトリに配置されています。
コアパッケージのディレクトリ構成は以下のとおりです。
| パス | 役割 |
|---|---|
packages/svelte/src/compiler/ |
三フェーズコンパイラ:parse → analyze → transform |
packages/svelte/src/internal/client/ |
クライアントサイドランタイム(リアクティビティ、DOM、エフェクト) |
packages/svelte/src/internal/server/ |
サーバーサイドランタイム(SSR レンダラー、コンテキスト) |
packages/svelte/src/internal/flags/ |
tree-shaking 用の feature flags |
packages/svelte/src/reactivity/ |
公開リアクティブユーティリティ(SvelteDate、SvelteSet など) |
packages/svelte/src/store/ |
Svelte 4 ストア互換レイヤー |
packages/svelte/src/index-client.js |
公開クライアント API のエントリーポイント |
packages/svelte/src/index-server.js |
公開サーバー API のエントリーポイント |
packages/svelte/messages/ |
Markdown で定義されたエラー・警告メッセージ |
packages/svelte/scripts/ |
ビルドスクリプト(メッセージ処理、型生成) |
packages/svelte/package.json では "type": "module" が指定されており、広範な exports マップが定義されています。この exports マップこそが、クライアント/サーバーの分離を実現する仕組みの核心です。
二重構造:コンパイラ + ランタイム
Svelte のアーキテクチャは一言で表せます。コンパイラがランタイムを呼び出すコードを生成する、それだけです。let count = $state(0) と書くと、コンパイラはそれを $.state(0) の呼び出しに変換します。ここで $ は import * as $ from 'svelte/internal/client' としてインポートされた内部ランタイムモジュールを指しています。
flowchart LR
A[".svelte file"] --> B["Compiler<br/>(parse → analyze → transform)"]
B --> C["JavaScript module<br/>(import * as $ from 'svelte/internal/client')"]
C --> D["Runtime<br/>($.state, $.effect, $.if, $.each, ...)"]
D --> E["DOM"]
packages/svelte/src/compiler/index.js にあるコンパイラのエントリーポイントは、compile()、compileModule()、parse()、migrate() の四つの公開関数を提供しています。compile() 関数は parse・analyze・transform の三フェーズパイプラインを制御し、JavaScript コード・ソースマップ・CSS 出力・警告をまとめた CompileResult を返します。
packages/svelte/src/internal/client/index.js にあるランタイムのエントリーポイントは、100 を超える関数を re-export する巨大なバレルファイルです。これらがいわゆる「ABI(アプリケーションバイナリインターフェース)」であり、コンパイル済み出力が依存する契約面を形成しています。コンパイル済みコード中の $.if()、$.each()、$.state()、$.derived() といった呼び出しは、すべてこのファイルのエクスポートに解決されます。
ヒント: コンパイル済みの Svelte 出力で
$.something()という参照を見かけたら、src/internal/client/index.jsで対応するエクスポートを検索すると、実装の本体にたどり着けます。
Conditional Exports とクライアント/サーバー分離
package.json の exports マップを見ると、Svelte の環境対応設計がはっきりと現れています。ルートエントリーポイントを確認してみましょう。
".": {
"types": "./types/index.d.ts",
"worker": "./src/index-server.js",
"browser": "./src/index-client.js",
"default": "./src/index-server.js"
}
import { mount } from 'svelte' を解決する際、bundler はターゲット環境に応じて適切なファイルを選択します。ブラウザ向けビルドには index-client.js が、Node.js や SSR 環境には index-server.js が使われます。このパターンはサブパスにも繰り返し適用されており、svelte/store、svelte/reactivity、svelte/legacy のいずれにもクライアント/サーバーの分離が存在します。
flowchart TD
Import["import { mount } from 'svelte'"]
Import -->|"browser condition"| Client["index-client.js<br/>Real mount(), hydrate(), onMount()"]
Import -->|"default condition"| Server["index-server.js<br/>Stubs: mount() throws, onMount = noop"]
packages/svelte/src/index-server.js のサーバーエントリーポイントを読むと、設計の意図がよくわかります。onMount、beforeUpdate、afterUpdate は noop としてエクスポートされており、サーバー上では何もしません。一方、mount() や hydrate() は SSR 中に呼び出された場合のバグを防ぐためにエラーをスローします。興味深いことに、onDestroy はサーバーでも実際に動作します(SSR レンダラーにフックされます)。また、getContext や setContext などのコンテキスト関数はサーバー側にも実装が存在します。
クライアントエントリー packages/svelte/src/index-client.js はもう少し込み入った話を示しています。runes モードにおける onMount は user_effect を生成します。つまり、独自のプリミティブとして実装されているのではなく、新しいシグナルベースのエフェクトシステムの上に構築されているのです。
この条件分岐パターンは内部モジュールにも適用されます。コンパイラはクライアントターゲットかサーバーターゲットかに応じて異なる出力を生成し、それぞれ svelte/internal/client または svelte/internal/server からインポートします。
内部ランタイム ABI
packages/svelte/src/internal/client/index.js は、コンパイル済みコードとランタイムの間の契約面です。エクスポートされる関数はカテゴリごとに整理されています。
| カテゴリ | 代表例 | ソース |
|---|---|---|
| 制御フローブロック | if_block, each, await_block, key |
dom/blocks/*.js |
| テンプレート生成 | from_html, from_tree, from_svg, text |
dom/template.js |
| リアクティビティプリミティブ | state, derived, effect, render_effect |
reactivity/*.js |
| DOM 操作 | set_attribute, set_class, set_style |
dom/elements/*.js |
| バインディング | bind_value, bind_checked, bind_this |
dom/elements/bindings/*.js |
| コンポーネントライフサイクル | push, pop, init |
context.js, dom/legacy/lifecycle.js |
| トランジション | transition, animation |
dom/elements/transitions.js |
コンパイル済みコンポーネントには必ず次のようなインポートが含まれます。
import * as $ from 'svelte/internal/client';
この名前空間インポートによって、bundler は未使用の関数を tree-shake できます。コンポーネントが {#each} を使っていなければ、$.each() の実装は最終バンドルから除去されます。
graph TD
subgraph "Compiled Component"
A["$.state(0)"]
B["$.template_effect(...)"]
C["$.if(node, fn)"]
end
subgraph "svelte/internal/client"
D["sources.js → state()"]
E["effects.js → template_effect()"]
F["blocks/if.js → if_block()"]
end
A --> D
B --> E
C --> F
Feature Flags とデッドコード除去
Svelte 5 には packages/svelte/src/internal/flags/index.js に定義された三つの feature flags があります。
export let async_mode_flag = false; // experimental.async=true
export let legacy_mode_flag = false; // Svelte 4 compatibility
export let tracing_mode_flag = false; // $inspect.trace debugging
これらはモジュールレベルの let 変数で、デフォルトは false です。必要に応じてコンパイラが有効化するインポートを生成します。たとえば Svelte 4 のコンポーネントを含むプロジェクトでは、コンパイル済み出力に import 'svelte/internal/flags/legacy' が追加され、enable_legacy_mode_flag() が呼ばれて true にセットされます。
flowchart LR
Compiler["Compiler detects<br/>legacy component"] --> Import["Generated: import 'svelte/internal/flags/legacy'"]
Import --> Enable["enable_legacy_mode_flag()<br/>legacy_mode_flag = true"]
Enable --> Runtime["Runtime code paths:<br/>if (legacy_mode_flag) { ... }"]
この仕組みの優れた点は tree-shaking にあります。legacy_mode_flag が一度も true にならなければ、bundler は if (legacy_mode_flag) で保護されたブランチをデッドコードと判断して除去できます。つまり、純粋な Svelte 5 プロジェクトは Svelte 4 互換コードのバンドルコストを一切払わずに済むのです。
ヒント: ランタイムのコードパスを調査していて
if (legacy_mode_flag && ...)のようなフラグガードを見かけたら、モダンな Svelte 5 の挙動を調べる際はそのブランチを読み飛ばして問題ありません。
Markdown ドリブンなエラー・警告メッセージ
Svelte におけるすべてのエラーと警告 — コンパイラの診断、クライアントランタイムエラー、サーバーエラー — は packages/svelte/messages/ 配下の Markdown ファイルで定義されています。たとえば messages/compile-errors/template.md には次のようなエントリーが含まれています。
## animation_duplicate
> An element can only have one 'animate' directive
packages/svelte/scripts/process-messages/index.js のビルドスクリプトがこれらの Markdown ファイルを読み込み、関数をエクスポートする JavaScript モジュールを生成します。各エラーコードは呼び出し可能な関数になります(例:e.animation_duplicate(node))。
flowchart TD
MD["messages/compile-errors/template.md"] --> Script["scripts/process-messages/index.js"]
Script --> JS["src/compiler/errors.js<br/>(generated)"]
Script --> Docs["documentation/.generated/<br/>compile-errors.md"]
JS --> Compiler["Compiler calls e.animation_duplicate()"]
このアプローチには三つの大きなメリットがあります。
- 信頼できる唯一の情報源 — エラーメッセージのテキスト・コード・ドキュメントがすべてひとつの Markdown ファイルから生成される
- 一貫したフォーマット — すべてのエラーが同じ構造(コード・メッセージ・任意の詳細)に従っている
- ドキュメントの自動生成 — 同じ Markdown がそのままドキュメントページにも変換される
メッセージのカテゴリはコードベースの構造に対応しており、compile-errors・compile-warnings・client-errors・client-warnings・server-errors・server-warnings、そしてそれらの共有バリアントがあります。
ビルドシステムとテスト概要
ビルドパイプラインはシンプルです。コンパイラは Rollup で CommonJS モジュールにバンドルされ(Node.js から require() で利用するため)、ランタイムはそのまま ES modules として出荷されます。ダウンストリームのツールがバンドルを担当するため、ランタイム自体はバンドル不要です。
テストは Vitest を使用しており、vitest.config.js の設定で conditional exports を再現するモジュール解決が構成されています。テストスイートは以下のカテゴリで構成されています。
| テストカテゴリ | テスト対象 |
|---|---|
runtime-runes |
Svelte 5 runes ベースのコンポーネント動作 |
runtime-legacy |
Svelte 4 互換モード |
compiler-errors |
コンパイル失敗のシナリオ |
compiler-warnings |
期待される診断警告 |
hydration |
クライアント/サーバーレンダリングの一致 |
signals |
低レベルリアクティビティエンジン |
snapshot |
コンパイル済み出力の安定性 |
flowchart LR
Test["vitest"] --> Resolve["Custom resolver:<br/>maps 'svelte/' imports<br/>to correct client/server files"]
Resolve --> Client["Browser tests<br/>→ src/index-client.js"]
Resolve --> Server["SSR tests<br/>→ src/index-server.js"]
Vitest の設定にある customResolver は、テストパスに _output/server が含まれるかどうかを基準に、svelte/* のインポートをクライアントファイルとサーバーファイルに振り分けます。
次のステップ
全体像を把握したところで、いよいよ .svelte ファイルがコンパイラパイプラインをどう通過するかを追っていきましょう。次の記事では、手書きのステートマシンパーサーがソースコードを AST に変換する過程を解説します。バインディングの解決と runes の検出を経て、先ほど見てきた $.xxx 関数群を呼び出す JavaScript 出力へと変換されるまでの一連の流れを追います。