Svelteコンパイラの内側:.svelteソースからJavaScript出力まで
前提知識
- ›第1回:アーキテクチャとコードベースマップ
- ›抽象構文木(AST)とESTree形式の基礎知識
- ›ツリー走査におけるビジターパターンの理解
- ›コンパイラの基本概念(パース・解析・コード生成)の知識
Svelteコンパイラの内側:.svelteソースからJavaScript出力まで
SvelteコンパイラはSvelte全体を支える土台です。HTML・CSS・JavaScriptが混在する .svelte ファイルを受け取り、svelte/internal/client(またはサーバー向けなら svelte/internal/server)をインポートする最適化済みJavaScriptモジュールを出力します。コンパイルはパース・解析・変換の3フェーズで進み、各フェーズを経るごとにコンポーネントの表現が豊かになっていきます。ソースコードが最終的なJavaScriptになるまでの全行程を追ってみましょう。
オーケストレーターとしてのcompile()
エントリーポイントとなるトップレベルのcompile() 関数は、ソースコードとオプションを受け取り、3つのフェーズ全体を調整する役割を担っています。
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_component_options(options, '');
let parsed = _parse(source);
const analysis = analyze_component(parsed, source, combined_options);
const result = transform_component(analysis, source, combined_options);
result.ast = to_public_ast(source, parsed, options.modernAst);
return result;
}
冒頭の state.reset() 呼び出しに注目してください。各フェーズを詳しく見る前に、この呼び出しが何のためにあるのかを理解しておきましょう。
なお、runesを使うが完全なコンポーネントではない .svelte.js ファイル向けに、兄弟関数の compileModule() も存在します。こちらも同じパターン — analyze_module() → transform_module() — に従っています。
flowchart TD
Source[".svelte source"] --> Parse["Phase 1: Parse<br/>→ AST (Root node)"]
Parse --> TS{"TypeScript?"}
TS -->|yes| Strip["remove_typescript_nodes()"]
TS -->|no| Analyze
Strip --> Analyze["Phase 2: Analyze<br/>→ ComponentAnalysis"]
Analyze --> Transform["Phase 3: Transform<br/>→ ESTree Program"]
Transform --> Print["esrap.print()<br/>→ JavaScript + source map"]
Print --> Result["CompileResult<br/>{js, css, warnings, ast}"]
フェーズ1 — パース:ソースをASTへ
パーサーは手書きで実装されており、PEGジェネレーターや既製のツールは使用していません。テンプレートを1文字ずつ消費するステートマシンです。
Parser クラスは、indexカーソル、開いているノードのstack、ツリーを組み立てるfragmentsスタックといったパース状態を管理しています。コンストラクタがこのステートマシンを駆動します。
let state = fragment;
while (this.index < this.template.length) {
state = state(this) || fragment;
}
各状態は現在位置を調べる関数で、次の状態を返すか、voidを返してfragmentにフォールバックします。fragment 状態は振り分けのハブで、<を見つければelementへ、{を見つければtagへ、それ以外はtextへとディスパッチします。
export default function fragment(parser) {
if (parser.match('<')) return element;
if (parser.match('{')) return tag;
return text;
}
stateDiagram-v2
[*] --> fragment
fragment --> element: "<"
fragment --> tag: "{"
fragment --> text: other
element --> fragment: element closed
tag --> fragment: tag closed
text --> fragment: done
パーサーが生成する AST.Root ノードは、3つの重要な子を持っています。テンプレートマークアップを表す fragment と、JavaScriptのパースにAcornを使用する instance・module のスクリプトブロックです。TypeScriptを使用しているコンポーネントでは、解析フェーズの前に remove_typescript_nodes() パスがJS ASTから型アノテーションを除去します。
ヒント: パーサーには
looseモード(parse(source, true))があり、不正な入力からでもASTの生成を試みます。ユーザーが入力中の不完全なコードを部分的にパースする必要があるエディタツールに役立ちます。
フェーズ2 — 解析:スコープ・バリデーション・メタデータ
解析フェーズでは、コンパイラがコンポーネントの「意味」を理解します。analyze_component() 関数はスコープの階層構造を構築し、変数バインディングを解決し、runesを検出し、コンポーネント構造を検証します。
最初のステップはスコープの作成です。scope.js の create_scopes() 関数がASTを走査し、ScopeRoot / Scope の階層を構築します。{#if}・{#each}・関数ボディなど、ブロックが現れるたびに子スコープが生成されます。バインディングは変数の宣言箇所とその種類を追跡し、種類には state・derived・prop・bindable_prop・normal・legacy_reactive などがあります。
runeの検出は get_rune() によって行われます。この関数はある呼び出し式が既知のrune($state・$derived・$effect・$props など)に該当するかを判定します。これにより、$state(0) という記述が、$state という名前の通常の関数呼び出しと区別されます。
次にメインの解析ウォークが始まります。アナライザーはzimmerframeライブラリの walk() 関数を使い、ほぼすべてのノード型に対応した約60個のビジターを含むオブジェクトを渡します。
flowchart TD
AST["Parsed AST"] --> Scopes["create_scopes()<br/>→ ScopeRoot + Scope hierarchy"]
Scopes --> Walk["walk(ast, state, visitors)<br/>~60 analysis visitors"]
Walk --> CSS["analyze_css() + prune()"]
CSS --> Result["ComponentAnalysis<br/>{runes, scope, bindings, metadata}"]
解析ビジターはバリデーション($state の正しい使い方の確認など)、メタデータの収集(イベント委譲に使われるイベントの追跡など)、そしてノードへのアノテーション付与を担います。phases/2-analyze/index.js#L92-L145 にある _ キャッチオールビジターは、すべてのノードで svelte-ignore コメントの処理とスコープの切り替えを担当しています。
フェーズ3 — 変換:ASTをJavaScriptへ
変換フェーズでは実際の出力コードが生成されます。transform_component() 関数は generate オプションの値によって処理を分岐します。
const program =
options.generate === 'server'
? server_component(analysis, options)
: client_component(analysis, options);
クライアント向け出力では、client_component() がASTに対して3つのウォークを順に実行します。各ウォークはコンポーネントの論理的なセクションに対応しています。
- モジュールウォーク —
<script context="module">(トップレベルのモジュールスコープ)を処理 - インスタンスウォーク —
<script>(コンポーネントインスタンスのスコープ)を処理 - テンプレートウォーク — HTMLテンプレートを処理
flowchart LR
A["module AST"] -->|"walk 1"| M["Module output"]
B["instance AST"] -->|"walk 2"| I["Instance output"]
C["template AST"] -->|"walk 3"| T["Template output"]
M --> Program["Combined ESTree Program"]
I --> Program
T --> Program
Program --> esrap["esrap.print() → JS + sourcemap"]
3つのウォークは同じビジターオブジェクトを使いますが、渡されるstateが異なります。インスタンスウォークは is_instance: true を受け取り、インスタンスのスコープを使用します。テンプレートウォークは変数参照を正しく書き換えるためにインスタンスの変換stateを共有しつつ、テンプレート自身のスコープマップを使います。
client_component() の148行目で初期化されるstateオブジェクトは読む価値があります。import * as $ from 'svelte/internal/client' を先頭に持つ hoisted 配列、変数の書き換えを管理する transform レコード、イベント委譲の追跡に使う events セットなどが含まれています。
3つのウォークが完了すると、esrap.print() がESTree programをソースマップ付きのJavaScriptに変換します。esrap はSvelte独自のプリンタで、TypeScript対応の出力と正確なソースマップ生成を理由に採用されています。
runeのコンパイル:$stateが$.state()になるまで
$state というruneがパイプラインをどのように通過するか、具体的に追ってみましょう。次のコードを書いたとします。
<script>
let count = $state(0);
</script>
フェーズ1(パース) では、Acornがこれを VariableDeclaration としてパースします。初期化子は CallExpression で、呼び出し先のcalleeは $state という名前の Identifier です。
フェーズ2(解析) では、get_rune() がこれを $state runeと識別します。count のバインディングは kind: 'state' で作成されます。
フェーズ3(変換) では、VariableDeclaration ビジターがruneを検出し、適切なランタイム呼び出しを生成します。
if (rune === '$state' || rune === '$state.raw') {
const is_state = is_state_source(binding, context.state.analysis);
if (rune === '$state' && is_proxy) {
value = b.call('$.proxy', value);
}
if (is_state) {
value = b.call('$.state', value);
}
}
コンパイラは変数が再代入されるかどうかに基づいて、フルのリアクティブソース($.state())が必要か、単純なプロキシ($.proxy())で済むかを判断します。count.x = 1 のようなミューテーションのみが行われる場合は $.state() は不要です。count = 5 のような再代入が行われる場合は必要です。
sequenceDiagram
participant Source as .svelte source
participant Parse as Parser
participant Analyze as Analyzer
participant Transform as Transformer
participant Output as JS Output
Source->>Parse: let count = $state(0)
Parse->>Analyze: VariableDeclaration { init: CallExpression { callee: $state } }
Analyze->>Analyze: get_rune() → "$state"
Analyze->>Analyze: binding.kind = "state"
Analyze->>Transform: ComponentAnalysis
Transform->>Transform: is_state_source(binding)?
Transform->>Output: let count = $.state($.proxy(0))
ヒント:
#compiler/buildersとしてインポートされるbモジュールは、ESTREEノードを構築するためのfluentなAPIを提供します。b.call('$.state', value)は$.stateを対象とするCallExpressionノードを生成します。複雑なAST構造を生成しているにもかかわらず、変換ビジターのコードが読みやすく保たれているのはこのためです。
テンプレートビルダーとDOM構造の生成
テンプレートの変換フェーズで静的なHTMLに出会ったとき、コンパイラは createElement の呼び出しを生成しません。代わりに、Template クラスを使ってテンプレートの記述を積み上げていきます。
Template クラスはDOMの静的構造を表すノード(要素・テキスト・コメント)のツリーを維持します。このツリーが確定すると、次のいずれかにシリアライズされます。
$.from_html(content, flags)—innerHTMLで一度パースされ、コンポーネントインスタンスごとにcloneNode(true)でクローンされるHTML文字列$.from_tree(structure)— SVGやMathMLのような名前空間でHTMLパースが不適切な場合に使われる、プログラム的なツリー記述
Template は <script> タグ(特殊な処理が必要)の有無や、importNode が必要かどうか(Firefoxへの対応策)も追跡しています。テンプレートビジターがASTを走査する過程でツリーを段階的に構築できるよう、push_element()・push_text()・pop_element() といったメソッドが公開されています。
{count} のような式タグや {#if} のような制御フローブロックといった動的なコンテンツは、テンプレートに境界を作ります。静的な部分はクローンされるテンプレートになり、動的な部分はクローン後に特定のノードを更新するeffect関数になります。
flowchart TD
Template["Template class"]
Template --> Static["Static HTML string:<br/>'<div><span>Hello</span> <!></div>'"]
Template --> FromHTML["$.from_html(string, flags)"]
FromHTML --> Clone["cloneNode(true)<br/>per instance"]
Clone --> Effects["$.template_effect(() => {<br/> $.set_text(node, count);<br/>})"]
モジュールレベルの状態:state.jsパターン
コンパイラ全体に共通する設計上の選択として、モジュールレベルのミュータブルな変数の使用があります。state.js モジュールは warnings・filename・source・dev・runes といったミュータブルな let バインディングをエクスポートしています。
export let warnings = [];
export let filename;
export let source;
export let dev;
export let runes = false;
139行目の reset() 関数は、各コンパイルの前にすべてをリセットします。
export function reset(state) {
dev = false;
runes = false;
source = '';
filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/');
warnings = [];
}
なぜすべての関数にパラメーターとして渡さないのでしょうか。パフォーマンスが理由です。コンパイラのビジター関数は1回のコンパイルで何千回も呼び出されます。すべての呼び出しにコンテキストオブジェクトを渡し続けるのはホットループにおいてオーバーヘッドになります。一方、モジュールレベルの変数へのアクセスはただの変数読み取りであり、実質的にコストがありません。トレードオフとして、コンパイラは設計上シングルスレッド(同時コンパイルなし)に限定されますが、ビルドツールはファイルを逐次処理するか別々のworkerで処理するため、これは何ら問題になりません。
adjust() 関数はパース後と初期解析後に呼び出され、devモード・runeモード・rootDirからの相対ファイル名を設定します。この2段階の初期化パターン — reset() してから adjust() — が採用されているのは、TypeScriptの使用有無など、パース中に収集される情報に依存するstateがあるためです。
次回の予告
コンパイラが出力するJavaScriptモジュールは、svelte/internal/client のランタイムに大きく依存しています。次回の記事では、それらのランタイム呼び出しを支えるリアクティビティエンジンを深掘りします。ソース・derived・effectのシグナルグラフ、高性能な状態追跡のためのビットフラグシステム、そして更新を統括するバッチスケジューラを探っていきましょう。