ข้ามไปยังเนื้อหา

Compiler Output

compile() ของ svelte/compiler แปลงไฟล์ .svelte เป็น JavaScript module ธรรมดาที่เรียกฟังก์ชันที่ export มาจาก svelte/internal/client (หรือ svelte/internal/server สำหรับ SSR) ไม่มี framework runtime ไหน “ตีความ” component ของคุณตอน render เลย — การตีความเกิดขึ้นไปแล้วครั้งเดียวใน compiler ทุก claim ข้างล่างนี้มาจากการรัน compile() จริงด้วยเวอร์ชันที่คอร์สนี้อ้างอิงตรง ๆ (node_modules/svelte ใน harness ของ repo นี้คือ 5.57.1 เวอร์ชันเดียวกับที่ SveltePlayground โหลดจาก esm.sh) — กด คอมไพล์ บน embed ข้างล่างเพื่อลองทำซ้ำเองได้เลย

<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>

คอมไพล์อันนี้ (client target, runes mode, dev: false) ได้:

import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<button> </button>`);
export default function Playground($$anchor) {
let count = $.state(0);
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(count)));
$.delegated('click', button, () => $.update(count));
$.append($$anchor, button);
}
$.delegate(['click']);

การ map rune ไปยัง call จริงของ compiler เป็น switch ตรง ๆ บนชื่อ rune ที่จำได้ — นี่คือ handler จริง ตัดมาจาก packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js:

// packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js — CallExpression (trimmed)
export function CallExpression(node, context) {
const rune = get_rune(node, context.state.scope);
switch (rune) {
case '$state':
case '$state.raw': {
// ...
const callee = b.id('$.state', node.callee.loc);
return b.call(callee, value);
}
case '$derived':
case '$derived.by': {
let fn = context.visit(node.arguments[0]);
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
}
case '$effect':
case '$effect.pre': {
const callee = rune === '$effect' ? '$.user_effect' : '$.user_pre_effect';
// ...
}
}
}

ส่วน count++ — เป็น UpdateExpression ไม่ใช่ call — ไปผ่าน visitor คนละตัว คือ .../visitors/UpdateExpression.js ซึ่งเช็คว่า identifier ที่ถูก update มี transform แบบ reactive ที่ลงทะเบียนไว้หรือเปล่า (ตั้งไว้ตอนวิเคราะห์ binding ว่าเป็น $state) ถ้ามีก็จะใช้ transform นั้นแทนการปล่อย ++ เฉย ๆ สำหรับ $state binding ผลลัพธ์คือ $.update(count) — พิสูจน์ตรง ๆ ใน compiled output ข้างบน และด้วย compile() assertion จริง:

src/lib/compiler-state.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $state becomes $.state, count++ becomes $.update, template read becomes $.get inside $.template_effect/$.set_text', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
`;
const result = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' });
const js = result.js.code;
expect(js).toContain('let count = $.state(0);');
expect(js).toContain('$.template_effect(() => $.set_text(text, $.get(count)));');
expect(js).toContain("$.delegated('click', button, () => $.update(count));");
expect(js).toContain("$.delegate(['click']);");
});
✓ compile(): $state becomes $.state, count++ becomes $.update, template read becomes $.get inside $.template_effect/$.set_text
Test Files 1 passed (1)
Tests 1 passed (1)

$.state(0) (นิยามใน packages/svelte/src/internal/client/reactivity/sources.js) คือ constructor จริงของ reactive cell — ตัดมาเฉพาะส่วนที่สำคัญ:

// packages/svelte/src/internal/client/reactivity/sources.js — source / state (trimmed)
export function source(v, stack) {
var signal = { f: 0, v, reactions: null, equals, rv: 0, wv: 0 };
return signal;
}
/*#__NO_SIDE_EFFECTS__*/
export function state(v, stack) {
const s = source(v, stack);
push_reaction_value(s);
return s;
}

ค่า $state คือ plain object ที่มีค่าปัจจุบัน (v) รายชื่อสิ่งที่ขึ้นกับมัน (reactions) และตัวนับเวอร์ชัน (rv/wv) — ไม่มีอะไร exotic ไปกว่านี้ บท Signals & Scheduling จะพูดถึงว่า reactions, rv/wv และ field f (flags) ทำอะไรจริง ๆ

<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{doubled}</button>

คอมไพล์ได้:

let count = $.state(0);
let doubled = $.derived(() => $.get(count) * 2);
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(doubled)));
$.delegated('click', button, () => $.update(count));

มีสองจุดที่ควรสังเกต: rune $derived(expr) ห่อ expression ด้วย thunk (b.thunk(fn) ใน compiler source ข้างบน) ก่อนเรียก $.derived — expression ยังไม่ถูก evaluate ตอนนี้ มีแค่ฟังก์ชันที่จะ evaluate มันทีหลังถูกสร้างขึ้นมาเท่านั้น และทุกการ อ่าน count หรือ doubled ที่อื่นใน compiled output จะผ่าน $.get(...) เสมอ — การอ่านไม่เคยเป็น property access เฉย ๆ เพราะ $.get คือจุดที่ dependency ถูกลงทะเบียนด้วย (ดู Signals & Scheduling)

src/lib/compiler-derived.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $derived becomes $.derived(() => ...), reading it elsewhere is $.get(derivedName)', () => {
const src = `<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{doubled}</button>
`;
const js = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('let doubled = $.derived(() => $.get(count) * 2);');
expect(js).toContain('$.template_effect(() => $.set_text(text, $.get(doubled)));');
});
✓ compile(): $derived becomes $.derived(() => ...), reading it elsewhere is $.get(derivedName)
Test Files 1 passed (1)
Tests 1 passed (1)
src/lib/compiler-effect.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $effect becomes $.user_effect', () => {
const src = `<script>
let count = $state(0);
$effect(() => { console.log('count is', count); });
</script>
<button onclick={() => count++}>{count}</button>
`;
const js = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('$.user_effect(() => {');
});
✓ compile(): $effect becomes $.user_effect
Test Files 1 passed (1)
Tests 1 passed (1)

compiled body เต็มของ snippet นั้นคือ:

let count = $.state(0);
$.user_effect(() => {
console.log('count is', $.get(count));
});
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(count)));
$.delegated('click', button, () => $.update(count));

สังเกตว่าฟังก์ชันที่สร้าง component ทั้งตัวถูกห่อด้วย $.push($$props, true) / $.pop(...) (ตัดออกข้างบนเพื่อความกระชับ) ทันทีที่มี $effect อยู่ — compiler ต้องการ component context เพื่อรู้ว่าจะรัน top-level $effect เมื่อไหร่ (deferred จนกว่าจะ mount; ดู user_effect ใน packages/svelte/src/internal/client/reactivity/effects.js) ส่วน $effect.pre คอมไพล์เป็น $.user_pre_effect ซึ่งเป็น RENDER_EFFECT | USER_EFFECT (รันช่วง render phase ก่อน DOM ถูก update) แทนที่จะเป็น EFFECT | USER_EFFECT ธรรมดา — ความต่างของ flag ตรงนี้แหละที่ Signals & Scheduling ใช้พิสูจน์ลำดับการรันจริง

string <button> </button> ที่ส่งเข้า $.from_html คือ template — ถูก parse และ clone ครั้งเดียวต่อการ mount ไม่เคย parse ซ้ำ $.only_child(button, true) หยิบ text-node child ตัวเดียวของ button (true หมายถึง “เอา child เป็น text”) และทุกค่าที่ dynamic จะมี $.template_effect ของตัวเองที่เขียนลงใน node นั้นด้วย $.set_text ลองดูด้วยสองค่าที่เป็นอิสระต่อกัน:

<script>
let a = $state('a0');
let b = $state('b0');
</script>
<p>{a}</p>
<p>{b}</p>

ดู compiled output: มี $.template_effect แยกกันสองอัน อันละ <p> แต่ละอันเรียก $.set_text บน text node ของตัวเอง บท Fine-Grained DOM พิสูจน์ตรง ๆ ว่าการเปลี่ยน a ไม่มีวันไปแตะ text node ของ b — ไม่ใช่แค่ “ได้ text ที่มองเห็นเหมือนกัน” แต่คือไม่เคยเรียก setter ของ nodeValue เลยจริง ๆ

onclick={...} บน element ธรรมดาคอมไพล์เป็น handler แบบ delegated ไม่ใช่การเรียก addEventListener ตรง ๆ event visitor ของ compiler (packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js, ฟังก์ชัน build_event) เลือกระหว่างสองแบบนี้:

// .../visitors/shared/events.js — build_event (trimmed)
export function build_event(context, event_name, handler, capture, passive, delegated) {
// ...
return b.call(
delegated ? '$.delegated' : '$.event',
b.literal(event_name),
context.state.node,
fn,
capture && b.true,
passive === undefined ? undefined : b.literal(passive),
);
}

สำหรับ event ที่ delegate ได้ (click เป็นหนึ่งในนั้น) ฟังก์ชัน delegated() ของ runtime — packages/svelte/src/internal/client/dom/elements/events.js — ไม่ attach listener เข้า element เลย:

// packages/svelte/src/internal/client/dom/elements/events.js — delegated (trimmed)
export function delegated(event_name, element, handler) {
(element[event_symbol] ??= {})[event_name] = handler;
}

มันแค่เก็บ handler ไว้บน element เอง ภายใต้ symbol key addEventListener จริงเกิดขึ้น ครั้งเดียวต่อ event type ที่ root ลงทะเบียนโดย $.delegate(['click']) (ถูก emit ที่ module scope — ดู compiled output ข้างบน) เมื่อมีคลิกเกิดขึ้นที่ไหนก็ตามในแอป listener ตัวเดียวที่ root (handle_event_propagation) จะเดิน event.composedPath() หา ancestor ที่ใกล้ที่สุดที่มี handler สำหรับ event type นั้นภายใต้ event_symbol แล้วเรียกมัน นี่คือ event delegation จริงและตั้งใจ: N ปุ่มมีต้นทุน listener ที่ root แค่ตัวเดียว ไม่ใช่ N ตัว

src/lib/compiler-server.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): generate: "server" produces svelte/internal/server output with a plain string template, no reactivity graph', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>clicks: {count}</button>
`;
const js = compile(src, { generate: 'server', runes: true, filename: 'Playground.svelte' }).js.code;
expect(js).toContain("import * as $ from 'svelte/internal/server';");
expect(js).toContain('let count = 0;'); // NOT $.state — server output has no reactivity graph
expect(js).toContain('$$renderer.push(`<button>clicks: ${$.escape(count)}</button>`);');
});
✓ compile(): generate: "server" produces svelte/internal/server output with a plain string template, no reactivity graph
Test Files 1 passed (1)
Tests 1 passed (1)

server output เต็ม ๆ สั้นพอที่จะโชว์ทั้งหมด:

import * as $ from 'svelte/internal/server';
export default function Playground($$renderer) {
let count = 0;
$$renderer.push(`<button>clicks: ${$.escape(count)}</button>`);
}

ไม่มี $.state ไม่มี signal graph ไม่มี event delegation — count เป็นตัวแปรธรรมดา render ครั้งเดียวเข้าไปใน string แบบ template literal (บท Hydration & SSR จะพูดถึงว่า render() จาก svelte/server คืนอะไรจริง ๆ รวมถึง hydration marker <!--[--> ที่ snippet นี้ไม่โชว์เพราะไม่มี block-level content)

src/lib/compiler-dev.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): dev: true adds $.tag()/$.add_locations()/component-name tracking that dev: false omits', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
`;
const dev = compile(src, { generate: 'client', runes: true, dev: true, filename: 'Playground.svelte' }).js.code;
const prod = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(dev).toContain("$.tag($.state(0), 'count')");
expect(dev).toContain('$.add_locations(');
expect(dev).toContain('Playground[$.FILENAME]');
expect(prod).not.toContain('$.tag(');
expect(prod).not.toContain('$.add_locations(');
});
✓ compile(): dev: true adds $.tag()/$.add_locations()/component-name tracking that dev: false omits
Test Files 1 passed (1)
Tests 1 passed (1)

dev: true ห่อ $.state(0) ด้วย $.tag(..., 'count') (เพื่อให้ devtools และ error message โชว์ชื่อตัวแปรจริงได้) บันทึก source location ของทุก template ด้วย $.add_locations(...) (สำหรับ browser extension แบบ “คลิกเพื่อเปิดใน editor”) และ stamp Playground[$.FILENAME] = 'Playground.svelte' ลงบนฟังก์ชัน component ไม่มีสิ่งเหล่านี้อยู่ใน production build เลย — มันคือ development ergonomics ล้วน ๆ ควบคุมด้วย option dev ตัวเดียว — ตรงกับ checkbox ที่ embed SveltePlayground ข้างบนให้คุณสลับได้

src/lib/compiler-legacy.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): legacy mode ($: reactive statement, runes: false) compiles to $.legacy_pre_effect, not $.derived', () => {
const src = `<script>
export let x = 1;
$: doubled = x * 2;
</script>
<p>{doubled}</p>
`;
const js = compile(src, { generate: 'client', runes: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('$.legacy_pre_effect(');
expect(js).toContain('$.legacy_pre_effect_reset();');
expect(js).not.toContain('$.derived(');
});
✓ compile(): legacy mode ($: reactive statement, runes: false) compiles to $.legacy_pre_effect, not $.derived
Test Files 1 passed (1)
Tests 1 passed (1)

legacy-mode output เต็ม ๆ:

import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<p> </p>`);
export default function Playground($$anchor, $$props) {
$.push($$props, false);
const doubled = $.mutable_source();
let x = $.prop($$props, 'x', 8, 1);
$.legacy_pre_effect(() => ($.deep_read_state(x())), () => {
$.set(doubled, x() * 2);
});
$.legacy_pre_effect_reset();
var p = root();
var text = $.only_child(p, true);
$.template_effect(() => $.set_text(text, $.get(doubled)));
$.append($$anchor, p);
$.pop();
}

$: doubled = x * 2 ไม่ใช่ $.derived — มันคือ effect ชนิดพิเศษ ($.legacy_pre_effect) ที่ recompute แล้ว $.set ลงใน mutable_source ทุกครั้งที่ dependency ที่ประกาศไว้ ($.deep_read_state(x())) เปลี่ยน นี่คือความต่างจริงและมีความหมายจาก $derived: legacy reactive statement เป็น effect ที่ re-run แบบ eager แล้ว assign ค่าเข้าไป ในขณะที่ $derived เป็น computation แบบ lazy pull-based จริง ๆ (บท Signals & Scheduling พิสูจน์ความ lazy ด้วย test นับจำนวนครั้งที่เรียก) สองอย่างนี้ ดู เหมือนใช้แทนกันได้ในตัวอย่างง่าย ๆ ซึ่งนี่แหละคือเหตุผลว่าทำไม migration จาก legacy ไป runes ที่ AI ทำบางครั้งถึงได้ semantics ผิดเพี้ยนไปนิดหน่อย

  • คำอธิบายว่า “ทำไม Svelte ถึงเร็ว” พูดถึง virtual DOM diff หรือเปล่า? ไม่มี virtual DOM อยู่ใน compiled output นี้เลย — compiler สร้าง call $.set_text/$.set_attribute ที่แต่ละค่าต้องใช้ไว้แล้ว
  • โค้ดสมมติว่า $derived(expr) evaluate expr ทันที เหมือนการเรียกฟังก์ชันธรรมดาหรือเปล่า? compiled output ข้างบนห่อมันด้วย thunk — มันเป็น lazy ไม่ใช่ eager (พิสูจน์อย่างละเอียดใน Signals & Scheduling)
  • การ migrate จาก $: doubled = x * 2 ไป let doubled = $derived(x * 2) ถูกมองว่าเป็นแค่การสลับ syntax หรือเปล่า? สองอย่างนี้คอมไพล์เป็น runtime construct ที่ต่างกันจริง (effect ที่ re-run แบบ eager กับ computation แบบ lazy) — ส่วนใหญ่เท่ากัน แต่ไม่เสมอไป โดยเฉพาะถ้าใน expression มี side effect
  • comment อ้างว่า onclick handler “attach ตรงเข้ากับปุ่มโดยตรง” หรือเปล่า? สำหรับ event ที่ delegate ได้ ไม่ใช่แบบนั้น — handler ถูกเก็บไว้บน element แล้วถูกเรียกโดย listener ที่ root ตัวเดียว ($.delegate([...])) ซึ่งมีผลถ้าโค้ดพยายาม removeEventListener handler “ตัวเดิม” ออกจาก DOM node ในภายหลัง

🐛 หา bug ในโค้ดนี้

<script>
let count = $state(0);
</script>
<!-- calling the handler through a spread should be identical to a literal onclick -->
<button {...{ onclick: () => { count += 1; } }}>{count}</button>
เฉลย

เรื่องนี้ไม่ใช่บั๊กใน reactivity — count += 1 ก็ยังผ่าน transform แบบ $.update เหมือนเดิมและ update text ของปุ่มได้ปกติ ปัญหาจริงอยู่ที่สมมติฐานใน comment: การ spread onclick ผ่าน {...{ onclick: ... }} (spread/computed attribute) ถูกคอมไพล์ผ่านคนละ code path จาก onclick={...} แบบ literal — spread attribute ถูก apply ด้วย $.set_attributes/$.event ไม่ใช่คู่ $.delegated/$.delegate ที่ compiled output ของบทนี้โชว์สำหรับ handler แบบ literal มันยังทำงานได้ปกติ แต่ทำให้ handler ตัวนี้หลุดออกจาก event delegation แบบเงียบ ๆ (มี addEventListener จริงที่ไม่ delegate ถูก attach เข้าปุ่มนี้โดยเฉพาะ แทนที่จะถูกจัดการโดย listener ที่ root ตัวเดียว) ซึ่งมองไม่เห็นจาก rendered output เลย และจะโผล่ให้เห็นก็ต่อเมื่อมีอะไรพึ่งพา delegation โดยเฉพาะ (เช่น โค้ดที่คาดว่ามี click listener ที่ root แค่ตัวเดียว หรือพึ่งพา registered-events set ของ $.delegate)

Compiler คือนักแปลครั้งเดียว ไม่ใช่ runtime interpreter: ทุก rune, ทุก {expression} ใน template และทุก event handler กลายเป็น call ของ JavaScript ธรรมดาที่เจาะจงไปที่ svelte/internal/client (หรือ /server) — ตัดสินใจครั้งเดียวตอน walk AST ไม่เคยตัดสินใจใหม่ตอน render การอ่าน compiled output คือการอ่านโปรแกรมจริงที่รันอยู่ ไม่มี layer ที่สองซ่อนอยู่ข้างใต้

ทางเลือกข้อดีต้นทุน
การ resolve rune ตอนคอมไพล์ (เทียบกับ reactivity library แบบ runtime)runtime ไม่ต้องมานั่งคิดว่า “ค่านี้ reactive ไหม” “ค่านี้แตะ node ไหน” — ตัดสินใจไว้แล้ว; runtime เล็กและคาดเดาได้มากกว่าcompiler ต้องเข้าใจ shape ของโค้ดคุณเต็มที่ (destructuring, pattern การ reassign); pattern แปลก ๆ อาจเจอ compiler error จริงแทนที่จะ “ทำงานเฉย ๆ” ตอน runtime
Event delegation เป็นค่า default ($.delegated/$.delegate)listener ที่ root ตัวเดียวแทนที่จะเป็นหนึ่งต่อ element — ถูกกว่าสำหรับ list แถวแบบ interactive ขนาดใหญ่handler ที่ attach ผ่าน spread/computed attribute จะหลุดออกจาก delegation แบบเงียบ ๆ — มองไม่เห็นถ้าไม่เช็คโดยเฉพาะ
dev/generate เป็น compile option ไม่ใช่ runtime flagproduction build ต้นทุนศูนย์ (ไม่ ship $.tag/$.add_locations); source เดียวคอมไพล์เป็น server/client code ที่ต่างกันจริงไฟล์ .svelte เดียวกันเป๊ะ ๆ ให้ output ที่ต่างกันอย่างมีนัยสำคัญได้สามแบบ (client/server/legacy) — “โค้ดนี้ทำอะไร” ขึ้นกับว่า compile target ไหนสร้าง JS ที่คุณกำลังอ่านอยู่เสมอ
  • “$derived(expr) รัน expr ทันที เหมือน assignment ธรรมดา” พิสูจน์แล้วว่าผิด: compiler ห่อมันด้วย thunk (() => ...) และไม่มีอะไรเรียก thunk นั้นจนกว่าจะมีการอ่านค่า derived จริง ๆ
  • “$: x = ... กับ let x = $derived(...) คือของเดียวกัน แค่ syntax เก่ากับใหม่” สองอย่างนี้คอมไพล์เป็น runtime primitive คนละตัว — effect ที่ re-run แบบ eager ($.legacy_pre_effect) กับ computation แบบ lazy pull-based ($.derived) — พิสูจน์ด้วย compiled body สองแบบที่ต่างกันมากข้างบน
  • “onclick ทุกตัวได้ addEventListener ของตัวเอง” onclick={...} แบบ literal บน element ธรรมดาถูก delegate: handler ถูกเก็บไว้บน element และ listener ตัวเดียวที่ root เป็นคน dispatch ให้ — พิสูจน์ตรง ๆ จาก compiled output และ runtime source ของ delegated()
let count = $state(0); คอมไพล์เป็นอะไร (client target, runes mode)?
จาก compiled output นี้ count * 2 ถูก evaluate ตอนที่บรรทัดนี้รันเลยไหม?
let doubled = $.derived(() => $.get(count) * 2);
compiler emit อะไรออกมาสำหรับ click handler นี้?
<button onclick={() => count++}>{count}</button>
generate: 'server' output ต่างจาก client target ยังไง สำหรับ let count = $state(0)?
legacy $: doubled = x * 2 (คอมไพล์ด้วย runes: false) ต่างจาก let doubled = $derived(x * 2) ยังไงในระดับ runtime construct?