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

Why Svelte, the Compiler

UI framework ส่วนใหญ่เป็น runtime library คุณส่ง framework ไปที่ browser แล้ว framework นั้นคอยดูแล virtual DOM ตอน runtime, re-run component เมื่อ state เปลี่ยน, diff tree ใหม่กับ tree เก่า แล้วนำความต่างไปใช้ กลไกพวกนั้น — reconciler, scheduler — คือ code ที่ user ต้องดาวน์โหลดและ CPU ต้องรันทุกครั้งที่อัปเดต

Svelte ย้ายงานออกจาก browser ไปไว้ที่ build step Svelte compiler อ่าน component ของคุณล่วงหน้า หาให้ชัดว่า DOM node ไหนขึ้นกับ state ตัวไหน แล้ว emit JavaScript ธรรมดาที่อัปเดตเฉพาะ node เหล่านั้น ไม่มี virtual DOM ให้ส่ง ไม่มีการ diff ตอน runtime

flowchart TB
  subgraph runtime["Runtime framework"]
    ship["ship framework + components"] --> vdom["runtime keeps a virtual DOM"]
    vdom --> diff["diff on every state change"]
  end
  subgraph svelte["Svelte (compiler)"]
    build["compile components at build time"] --> emit["emit precise update code"]
    emit --> tiny["ship tiny JS, no diffing"]
  end
งานเกิดที่ไหน: runtime เทียบกับ compile time
  • bundle เล็กลง คุณไม่ต้องส่ง reconciler ไป Svelte component compile เป็น code แบบ imperative ที่ใกล้เคียงกับที่คุณจะเขียนเองเพื่ออัปเดต node เหล่านั้น จึงมี framework overhead ติดมาน้อย
  • runtime ทำงานน้อยลง เมื่อ count เปลี่ยน ไม่มี tree ให้สร้างใหม่และ diff — code ที่ generate ไว้อัปเดต text node เดียวที่แสดง count การอัปเดตเลยเจาะจงโดยธรรมชาติ
  • indirection น้อยลง ช่องว่างระหว่าง “สิ่งที่ฉันเขียน” กับ “สิ่งที่รัน” นั้นเล็ก: output ของ compiler ใกล้เคียงกับที่คุณจะเขียนเอง

นี่คือเหตุผลที่บางครั้งเรียก Svelte ว่า “the disappearing framework” — ส่วนใหญ่ของตัวเอง compile หายไป เหลือแค่ logic ของคุณบวก helper layer บาง ๆ

ให้ component ที่มี let count = $state(0) และ <p>{count}</p> compiler เห็นแบบ static ว่า text node นี้ขึ้นกับ count แล้ว generate code ที่ (1) สร้าง <p> หนึ่งครั้ง และ (2) register ว่าเมื่อ count เปลี่ยน ให้อัปเดตเนื้อหาของ text node เฉพาะจุดนี้ — ไม่มีอย่างอื่น คูณด้วยทั้งแอปแล้วคุณจะได้การอัปเดตที่แตะเฉพาะสิ่งที่เปลี่ยน โดยไม่มี runtime bookkeeping มาคอยหาว่า อะไร เปลี่ยน

<script>
let count = $state(0);
</script>
<!-- The compiler knows this text node — and only this one — depends on count. -->
<button onclick={() => count++}>clicked {count} times</button>

คุณเขียน component แบบ declarative ส่วน compiler เขียน code DOM แบบ imperative ให้ การแบ่งงานแบบนี้คือคุณค่าทั้งหมด

svelte/compiler คือ package แบบ isomorphic ตัวเดียวกับที่ tooling ของ Svelte ใช้ข้างใน และมัน run ได้ทุกที่ที่ JavaScript run ได้ — รวมถึงใน Node script และ test ของคุณเอง compile(source, options) return JavaScript ที่ generate มาให้ตรง ๆ ดังนั้นแทนที่จะเชื่อคำว่า “compiler emit code อัปเดตที่แม่นยำ” เฉย ๆ คุณอ่านสิ่งที่มัน emit จริงสำหรับ component หนึ่งได้เลย:

src/lib/compile-tiny-output.test.ts
import { test, expect } from 'vitest';
import { compile } from 'svelte/compiler';
const source = `<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(count);
});
</script>
<button onclick={() => count++}>{count} / {doubled}</button>
`;
test('compile() output names the exact runtime primitives each rune becomes', () => {
const { js } = compile(source, { filename: 'Richer.svelte' });
// $state becomes a call to $.state(); reading it goes through $.get().
expect(js.code).toMatch(/let count = \$\.state\(0\);/);
// $derived becomes $.derived(() => ...), lazily re-reading its own dependency.
expect(js.code).toMatch(/let doubled = \$\.derived\(\(\) => \$\.get\(count\) \* 2\);/);
// $effect becomes $.user_effect(...) — "user_effect" to distinguish it
// internally from the effects the compiler generates for template updates.
expect(js.code).toMatch(/\$\.user_effect\(/);
// The template's own reactive text update is a template_effect calling set_text —
// this is the "surgical update" from the mental model, made concrete.
expect(js.code).toMatch(/\$\.template_effect\(\(\) => \$\.set_text\(text, /);
});
✓ compile() output names the exact runtime primitives each rune becomes

อ่าน output นั้นทีละบรรทัด: let count = $.state(0) คือ rune ที่กลายเป็น reactive cell; $.get(count) คือทุกการ อ่าน count ที่กลายเป็นการเรียก function ตรง ๆ (ดังนั้น compiler รู้เป๊ะทุกครั้งว่า dependency ถูกอ่านที่ไหน); $.derived(() => ...) ห่อ computation ไว้แทนที่จะ run แบบ eager; $.template_effect(() => $.set_text(text, ...)) คือบรรทัดเดียวที่อัปเดต DOM เมื่อ count หรือ doubled เปลี่ยน — ไม่มี update path อื่นเลย และไม่มีการ diff ที่ไหนใน output นี้เลย นี่ คือ “compiler ตาม dependency graph แล้วเขียนโค้ดอัปเดตที่แม่นยำ” ไม่ใช่คำเปรียบเทียบ

ทุกไฟล์ .svelte compile ในโหมดใดโหมดหนึ่งจากสองโหมด และ compiler เลือกให้อัตโนมัติ:

src/lib/legacy-detect.test.ts
import { test, expect } from 'vitest';
import { compile } from 'svelte/compiler';
test('a component with no runes compiles in legacy mode by default', () => {
const source = `<script>\n let count = 0;\n</script>\n<button on:click={() => count += 1}>{count}</button>`;
const { metadata } = compile(source, { filename: 'Legacy.svelte' });
expect(metadata.runes).toBe(false);
});
test('any rune usage switches the whole file to runes mode', () => {
const source = `<script>\n let count = $state(0);\n</script>\n<button onclick={() => count++}>{count}</button>`;
const { metadata } = compile(source, { filename: 'Runes.svelte' });
expect(metadata.runes).toBe(true);
});
test('the `runes` compiler option forces runes mode even with no runes present', () => {
const source = `<script>\n let count = 0;\n</script>\n<p>{count}</p>`;
const { metadata } = compile(source, { filename: 'Forced.svelte', runes: true });
expect(metadata.runes).toBe(true);
});
✓ a component with no runes compiles in legacy mode by default
✓ any rune usage switches the whole file to runes mode
✓ the `runes` compiler option forces runes mode even with no runes present

metadata.runes ที่ compile() return เป็นการตรวจจับตัวเดียวกับที่ tooling (svelte-check, editor extension) แสดงให้คุณเห็นเป็น diagnostic — มันคือการตัดสินใจ ต่อไฟล์ จากเนื้อหาของไฟล์นั้นเอง (หรือบังคับทั้งโปรเจกต์ผ่าน compilerOptions.runes ใน svelte.config.js / Vite plugin) นี่คือเหตุผลที่ codebase ใหญ่ ๆ migrate ทีละ component ได้ — ไฟล์ Svelte 4 ที่ยังไม่แตะ ยัง compile เป็น legacy mode อยู่ข้าง ๆ ไฟล์ที่แปลงเป็น runes mode แล้ว และ compiler ไม่ต้องเดาข้ามไฟล์เลย

flowchart TB
  file[".svelte file"] --> scan["compiler scans for rune usage"]
  scan -->|"a rune is used, or runes: true"| runes["runes mode: $state/$derived/$effect only"]
  scan -->|"no runes found"| legacy["legacy mode: export let, top-level reactive let, $:"]
  runes --> out["compiled output (this lesson's tests, above)"]
  legacy --> out
compiler เดียว สองโหมดที่ตรวจจับได้

สองความคิดทำงานเกือบทั้งหมดในบทนี้: compiler เป็น static analyzer ก่อนอื่นเลย (มันตัดสินใจเรื่อง delegation, dependency tracking และไฟล์ไหนอยู่โหมดไหน ทั้งหมดก่อนที่ code ของคุณจะ run เลยด้วยซ้ำ) และ output ของมันคือ source ที่ generate มา ไม่ใช่ black box — compile() พิสูจน์เรื่องนี้ตรง ๆ และ โมดูล Svelte Internals เดิน pipeline เต็ม (signal, scheduling, fine-grained DOM update, hydration) สำหรับใครที่อยากไปไกลกว่า “ไฟล์นี้ไฟล์เดียว generate อะไรออกมา”

ได้อะไรประโยชน์ต้นทุน
อ่าน output ของ compile() ตรง ๆคำตอบที่ชัดที่สุดสำหรับ “จริง ๆ แล้วมันทำอะไร” — ไม่มี documentation ของ framework ไหนแม่นยำไปกว่า source ที่ generate มาเองชื่อ helper และรูปร่างที่แน่นอนเป็นเรื่องภายในและเปลี่ยนได้ระหว่างเวอร์ชัน (พิสูจน์แล้วด้วย Callout ด้านบน) — อย่าสร้าง logic ของแอปจริงรอบ ๆ มัน
ตรวจจับโหมดต่อไฟล์อัตโนมัติmigrate ทีละไฟล์ได้ทีละขั้น โดยไม่ต้อง config อะไรเลยไฟล์ที่ผสม reactivity primitive ของทั้งสองโหมด compile ไม่ผ่านเลย (ตารางการจำโค้ด legacy ในบท events & attributes) แทนที่จะเลือกโหมดใดโหมดหนึ่งแบบเงียบ ๆ
  • “output ของ compiler เป็น API ที่ documented และ stable ให้ import ได้” ไม่ใช่ — พิสูจน์แล้วด้านบนผ่าน Callout: export และรูปร่างของ svelte/internal/client เป็นของ private และเคยเปลี่ยนข้ามเวอร์ชันมาแล้ว อ่านมันเพื่อเรียนรู้ อย่าพึ่งพามันใน code ที่ ship จริง
  • “runes mode เป็น setting ระดับโปรเจกต์ที่พลิกครั้งเดียว” ปกติมันถูกตรวจจับ ต่อไฟล์ อัตโนมัติ จากเนื้อหาของไฟล์นั้นเอง — พิสูจน์แล้วด้านบนด้วย metadata.runes จาก source สามแบบ compilerOptions.runes บังคับทั้งโปรเจกต์ได้ แต่นั่นคือข้อยกเว้น ไม่ใช่ทางเดินปกติ
  • “$derived(() => count * 2) คำนวณแบบ eager เหมือนเรียก function ธรรมดา” compiled output ห่อมันไว้ และการอ่านมันเสมอผ่าน $.get(doubled) — มันคือ reactive cell ที่อ่านแบบ lazy ไม่ใช่ code ที่ rerun แบบไม่มีเงื่อนไขทุกครั้งที่มีการเปลี่ยนแปลง (บท $derived พูดเรื่อง push/pull evaluation เต็ม ๆ)

เวลารีวิว code ที่แตะ compiler หรือ output ของมันตรง ๆ ให้เช็ค:

  • โค้ดของแอป import อะไรจาก svelte/internal/client (หรือ svelte/internal/* โดยทั่วไป) แทนที่จะใช้ entry point สาธารณะอย่าง svelte / svelte/store / svelte/attachments / svelte/events หรือเปล่า? นั่นคือการ import implementation detail ที่พิสูจน์แล้วด้านบนว่าไม่ stable
  • test assert รูปร่าง ที่แน่นอน ของ compiled output ของ component แทนที่จะ assert พฤติกรรมที่สังเกตได้ (สิ่งที่ render, สิ่งที่ user ทำได้) หรือเปล่า? compiled output เปลี่ยนรูปร่างได้ระหว่าง patch version
  • โหมดของไฟล์ถูกสมมติแทนที่จะตรวจสอบจริงหรือเปล่า — เช่น comment ใน code review อ้างว่า “นี่คือ runes-mode component” ทั้งที่ไม่มี rune ตัวไหนอยู่จริง เมื่อไฟล์ตรงหน้าคุณยังเป็น legacy mode ตามกฎการตรวจจับเดียวกับที่พิสูจน์แล้วด้านบน?

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

// app.ts — a "performance debugging" helper someone added
import { proxy, get } from 'svelte/internal/client';
export function inspectState(value: unknown) {
// reach into Svelte's internal state representation directly
return get(proxy(value));
}
เฉลย

นี่ import ตรงจาก svelte/internal/client — module เดียวกับที่ Callout ของบทนี้บอกว่าไม่ stable, private และเปลี่ยนได้ระหว่างเวอร์ชันโดยไม่มี deprecation warning เลย เพราะมันไม่เคยตั้งใจให้ใช้นอกเหนือจาก code ที่ compiler generate มา มันอาจทำงานวันนี้แล้วพังตอน patch release ถัดไปของ Svelte โดยไม่มีคำเตือนใน changelog ที่มุ่งไปหาคนเขียนแอป เพราะในมุมของ maintainer ไม่มีอะไร public เปลี่ยนเลย

วิธีแก้คือไม่ต้องแตะ internal เลย — ทุกอย่างที่แอปต้องการจริง ๆ (อ่าน state, ตรวจสอบค่า, debug) มีให้ผ่าน public API: $inspect(value) ข้างใน component ตอน development หรือ $state.snapshot(value) เพื่อได้ plain object ที่ debug ได้ออกมาจาก reactive state ทั้งคู่ documented และ stable ข้ามเวอร์ชัน

runtime framework ทำงาน diffing ที่ไหน?
ทำไม bundle ของ Svelte ถึงเล็กโดยทั่วไป?
เมื่อ count เปลี่ยนใน Svelte เกิดอะไรขึ้น?
ทำไมถึงเรียก Svelte ว่า "the disappearing framework"?
สิ่งนี้ print อะไรจริง ๆ?
const { js } = compile(source, { filename: 'X.svelte' });
console.log(js.code);
ไฟล์นี้ compile เป็น runes mode หรือ legacy mode?
<script>
  let count = 0;
</script>
<button on:click={() => count += 1}>{count}</button>
ทำไม code ของแอปไม่ควร import จาก svelte/internal/client?