Advanced Runes
$inspect: debug แบบ reactive
หัวข้อที่มีชื่อว่า “$inspect: debug แบบ reactive”$inspect log ค่า reactive และ log ใหม่ทุกครั้งที่เปลี่ยน — เป็นเครื่องมือ dev-only (กลายเป็น no-op ใน production build) ต่างจาก console.log ธรรมดาที่ fire ครั้งเดียว $inspect track argument ของตัวเองและรายงานทุก update
<script> let count = $state(0); let doubled = $derived(count * 2);
$inspect(count, doubled); // logs on every change, dev only</script>คุณ chain .with(fn) เพื่อรัน logic เองในแต่ละการเปลี่ยนได้ (เช่น console.trace ว่าการเปลี่ยนมาจากไหน)
untrack: อ่านโดยไม่ depend
หัวข้อที่มีชื่อว่า “untrack: อ่านโดยไม่ depend”ภายใน $derived หรือ $effect ทุกค่า reactive ที่คุณอ่านจะกลายเป็น dependency บางครั้งคุณอยากอ่านค่า โดยไม่ สร้าง dependency นั้น — เพื่อให้ effect ไม่รันซ้ำตอนค่านั้นเปลี่ยน untrack (import จาก svelte) ทำแบบนั้น
<script> import { untrack } from 'svelte';
let count = $state(0); let config = $state({ verbose: true });
$effect(() => { // re-run when count changes, but NOT when config.verbose changes: const verbose = untrack(() => config.verbose); if (verbose) console.log(count); });</script>บท Effect Rune พิสูจน์เรื่องนี้ด้วย test จริงที่รันได้ รวมถึงเคส “control” ที่โชว์ว่าถ้า ไม่ ใช้ untrack จะเกิดอะไรขึ้น
$effect.tracking, $effect.root และ $effect.pre
หัวข้อที่มีชื่อว่า “$effect.tracking, $effect.root และ $effect.pre”rune ตระกูล $effect อีกสามตัวเติมเต็มชุดเครื่องมือสำหรับควบคุมแบบ manual/advanced:
$effect.tracking()คืนค่าtrueถ้าโค้ดตอนนี้กำลังรันอยู่ใน tracking context (effect, derived หรือ template) — และfalseถ้าไม่ใช่ มีไว้สำหรับสร้าง reactive abstraction เองที่ต้องรู้ว่ากำลังถูกอ่านแบบ reactive อยู่หรือเปล่า$effect.root()สร้าง effect scope ที่ ไม่ ถูกเก็บกวาดอัตโนมัติโดย parent — คุณจะได้ฟังก์ชันdestroyกลับมาและต้องเรียกเองมันคือทางออกสำหรับ effect ที่สร้างนอก component lifecycle ปกติ (state library, test หรือการ integrate แบบ imperative)$effect.pre()— อธิบายเต็มพร้อมพิสูจน์ลำดับ cleanup ในบท Effect Rune สั้น ๆ: มันคือฝาแฝดของ$effectที่รัน “ก่อน DOM update” และเป็นสิ่งที่ต้องใช้พอดีเมื่อ effect ที่ root เองต้องอ่านอะไรบางอย่างก่อน DOM จะเขียน
<script> console.log($effect.tracking()); // false — plain component setup, not tracked
$effect(() => { console.log($effect.tracking()); // true — inside an effect });</script>
<p>in template: {$effect.tracking()}</p> <!-- true -->พิสูจน์แล้ว — $effect.tracking() สลับค่าระหว่าง context จริง:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$effect.tracking() is false outside a tracking context, true inside an effect', () => { const results: boolean[] = []; const destroy = $effect.root(() => { results.push($effect.tracking()); // false: plain top-level call, no tracking context $effect(() => { results.push($effect.tracking()); // true: inside an effect }); flushSync(); }); destroy(); expect(results).toEqual([false, true]);});✓ $effect.tracking() is false outside a tracking context, true inside an effect
Test Files 1 passed (1) Tests 1 passed (1)และ $effect.root() ต้องเรียก destroy() เองจริง ๆ — ไม่มีอะไรมาเก็บกวาดให้:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$effect.root() creates a scope with manual cleanup — not auto-destroyed', () => { const log: string[] = []; let count = $state(0);
const destroy = $effect.root(() => { $effect(() => { log.push(`run ${count}`); return () => log.push(`cleanup ${count}`); }); });
flushSync(); expect(log).toEqual(['run 0']);
count = 1; flushSync(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1']);
// Nothing destroys this root automatically — only calling destroy() does. destroy(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1', 'cleanup 1']);
// After destroy, further state changes do not re-run the effect. count = 2; flushSync(); expect(log).toEqual(['run 0', 'cleanup 0', 'run 1', 'cleanup 1']);});✓ $effect.root() creates a scope with manual cleanup — not auto-destroyed
Test Files 1 passed (1) Tests 1 passed (1)$inspect.trace: สืบว่าทำไมถึงรันใหม่
หัวข้อที่มีชื่อว่า “$inspect.trace: สืบว่าทำไมถึงรันใหม่”$inspect.trace(label?) เป็นเครื่องมือ debug ที่เจาะจงกว่า $inspect: วางเป็น statement แรก ในตัว effect หรือ derived มันจะ print ว่าการอ่าน reactive ตัวไหนที่ทำให้รอบนี้รันใหม่ — มีประโยชน์เมื่อฟังก์ชันมี dependency เยอะและคุณต้องรู้เป๊ะ ๆ ว่าตัวไหนเปลี่ยน
<script> let a = $state(1); let b = $state(2);
$effect(() => { $inspect.trace('sum effect'); console.log(a + b); });</script>พิสูจน์แล้ว — มัน log label ออกมาตอน effect รันใหม่จาก dependency ที่เปลี่ยน:
import { flushSync } from 'svelte';import { expect, test, vi } from 'vitest';
test('$inspect.trace() prints which dependency caused a derived/effect to re-run', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); const destroy = $effect.root(() => { let a = $state(1); let b = $state(2);
$effect(() => { $inspect.trace('sum effect'); void (a + b); // read both — both become dependencies });
flushSync(); spy.mockClear();
a = 10; // only `a` changed flushSync(); }); destroy();
const logged = spy.mock.calls.flat().join(' '); expect(logged).toContain('sum effect'); spy.mockRestore();});✓ $inspect.trace() prints which dependency caused a derived/effect to re-run
Test Files 1 passed (1) Tests 1 passed (1)เหมือน $inspect, $inspect.trace เป็น dev-only — หายไปจาก production build
$host(): host element ของ custom element ตัวจริง
หัวข้อที่มีชื่อว่า “$host(): host element ของ custom element ตัวจริง”เมื่อ component ถูก compile เป็น custom element (native web component) ผ่าน <svelte:options customElement="..."> จะไม่มี parent component คอยรับ callback prop หรือรับ Svelte event ปกติ — การเชื่อมต่อเดียวของ component กับโลกภายนอกคือ DOM element ที่ host มันอยู่ $host() คืนค่า host element ตัวนั้น ทำให้คุณ dispatch CustomEvent จริงหรือเรียก DOM API ได้ตรง ๆ
<svelte:options customElement="my-stepper" />
<script> function dispatch(type) { $host().dispatchEvent(new CustomEvent(type)); }</script>
<button onclick={() => dispatch('decrement')}>decrement</button><button onclick={() => dispatch('increment')}>increment</button>ผู้ใช้ <my-stepper> — ใน framework ไหนก็ได้ หรือไม่ใช้ framework เลย — ฟัง event แบบเดียวกับ DOM element อื่น ๆ:
document.querySelector('my-stepper').addEventListener('increment', () => { console.log('incremented!');});พิสูจน์แล้ว — compile ด้วย customElement: true จะ register custom element จริง และ $host() compile หายไปหมด (ไม่มี runtime function call เหลืออยู่ใน output):
import { compile } from 'svelte/compiler';import { expect, test } from 'vitest';
const source = `<svelte:options customElement="my-counter" /><script> let { start = 0 } = $props(); let count = $state(start); function bump() { count += 1; $host().dispatchEvent(new CustomEvent('bump', { detail: count })); }</script><button onclick={bump}>{count}</button>`;
test('compiling with <svelte:options customElement> + $host() registers a native custom element', () => { const { js } = compile(source, { customElement: true, generate: 'client' }); expect(js.code).toContain('customElements.define'); expect(js.code).toContain("'my-counter'"); // $host() compiles away to `this` inside the custom element class — no // runtime function named "$host" survives in the emitted JS. expect(js.code).not.toMatch(/\$host\s*\(/);});✓ compiling with <svelte:options customElement> + $host() registers a native custom element
Test Files 1 passed (1) Tests 1 passed (1)และการใช้ $host() นอก component ที่เป็น custom element คือ compile error จริงทันที:
$ node -e "compile(badSource, { generate: 'client' })"host_invalid_placement`$host()` can only be used inside custom element component instanceshttps://svelte.dev/e/host_invalid_placementClass field ที่เป็น rune (และกับดัก #private)
หัวข้อที่มีชื่อว่า “Class field ที่เป็น rune (และกับดัก #private)”rune ไม่ได้ผูกอยู่กับการประกาศ let เท่านั้น — คุณวางมันตรง ๆ บน class field ได้เลย ทั้ง public และ private Svelte ไม่ proxy class instance แบบที่ proxy plain object/array ที่ส่งเข้า $state; แทนที่จะเป็นแบบนั้น class field ที่ประกาศด้วย $state หรือ $derived ถูก compiler แปลงเป็น accessor ที่ backed ด้วย private field จริง
<script> class Counter { count = $state(0); #step = $state(1); // private fields can be runes too
get double() { return this.count * 2; } increment() { this.count += this.#step; } setStep(n) { this.#step = n; } }
const counter = new Counter();</script>
<button onclick={() => counter.increment()}>{counter.double}</button>มันทำงานเหมือน $state/$derived ที่อื่นทุกอย่าง — อ่าน counter.double ในเอฟเฟกต์หรือ template จะ track มัน และเรียก counter.increment() จะ trigger update:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
class Counter { count = $state(0); #step = $state(1);
get double() { return this.count * 2; } increment() { this.count += this.#step; } setStep(n: number) { this.#step = n; }}
test('class fields declared with $state/$derived-style getters are reactive', () => { const log: number[] = []; const destroy = $effect.root(() => { const counter = new Counter(); $effect(() => { log.push(counter.double); }); flushSync(); counter.increment(); flushSync(); counter.setStep(5); counter.increment(); flushSync(); }); destroy(); expect(log).toEqual([0, 2, 12]); // 0*2, 1*2, (1+5)*2});✓ class fields declared with $state/$derived-style getters are reactive
Test Files 1 passed (1) Tests 1 passed (1)กับดัก: เพราะ compiler แปลง class field ที่เป็น $state เป็น accessor แบบ get/set ที่ backed ด้วย private field, property นี้จึง ไม่ enumerable — ไม่โผล่ใน Object.keys, loop for...in หรือ JSON.stringify โค้ดที่คาดว่า class instance จะทำตัวเหมือน plain data object (serialize มัน, iterate key ของมัน) จะเจอเซอร์ไพรส์:
test('gotcha: a public $state class field is a non-enumerable accessor, not a plain property', () => { const counter = new Counter(); expect(Object.keys(counter)).not.toContain('count'); expect(JSON.stringify(counter)).toBe('{}'); // nothing serializes // It still reads/writes normally from the outside: expect(counter.count).toBe(0); counter.count = 3; expect(counter.count).toBe(3);});✓ gotcha: a public $state class field is a non-enumerable accessor, not a plain property
Test Files 1 passed (1) Tests 1 passed (1)ถ้าต้องการ snapshot แบบ plain และ serialize ได้ของ reactive class instance (หรือค่า $state ใด ๆ) ให้ใช้ $state.snapshot(x) — อธิบายเต็มในบท $state Rune
Reactive state ที่ไปไกลกว่า component
หัวข้อที่มีชื่อว่า “Reactive state ที่ไปไกลกว่า component”rune ไม่ได้ผูกอยู่กับไฟล์ .svelte เท่านั้น ใน module .svelte.js หรือ .svelte.ts คุณใช้ $state และ $derived เก็บ reactive state ที่ component ไหนก็ import ได้ — รากฐานของ shared state ทั้งแอปโดยไม่ต้องใช้ store
export function createCounter() { let count = $state(0); return { get count() { return count; }, // expose via a getter to preserve reactivity increment() { count++; }, };}<script> import { createCounter } from './counter.svelte.js'; const counter = createCounter();</script>
<button onclick={counter.increment}>{counter.count}</button>flowchart LR mod[".svelte.js module: $state + getters"] --> a["Component A imports it"] mod --> b["Component B imports it"] a --> sync["both stay in sync"] b --> sync
จุดสำคัญ: export ผ่าน getter (หรือ object/class) ไม่ใช่ค่าที่ reassign เปล่า ๆ primitive ที่ export ตรง ๆ จะถูกก็อปปี้ตอน import และเสีย reactivity; getter อ่าน reactive source ตัวจริงทุกครั้ง ถ้า module-level state นั้น export ตรง ๆ (ไม่ห่อด้วย factory function แบบ createCounter ข้างบน) — object เดียวที่ทุกคน import ใช้ร่วมกันตลอดอายุของ process — อ่าน คำเตือนเรื่อง server-side rendering ในบท $state Rune ก่อนทำแบบนั้นในแอปที่รันบน server
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้คือ manual control สำหรับสถานการณ์ที่ trio $state/$derived/$effect ปกติเองยังไม่ครอบคลุม: $effect.root/$effect.tracking สำหรับสร้าง abstraction เองนอก component lifecycle ปกติ, $inspect/$inspect.trace สำหรับสังเกต dependency graph โดยไม่แก้มัน, $host() สำหรับเคสเดียวที่ component ไม่มี parent เลย (custom element), และ class field สำหรับตอนที่ reactive state ของคุณมี method และ encapsulation ตามธรรมชาติ ไม่มีตัวไหนเปลี่ยนวิธีทำงานของ reactivity — มันแค่ให้จุดเสียบเพิ่มเข้าไปในระบบเดิม
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
$effect.root() สำหรับ scope แบบ manual | คุมชีวิตของ effect เต็มที่นอก component | คุณต้องดูแล cleanup เอง — ลืม destroy() แล้ว effect leak ตลอดไป |
| Class field ที่เป็น rune | Encapsulation, method, รูปแบบ OOP ที่คุ้นเคย | Public $state field ไม่ enumerable — พัง serialization/iteration แบบ naive |
.svelte.js module state | Shared state แบบไม่ต้องมี boilerplate หรือ store API | Reactive value ที่ export ตรง ๆ คือ instance เดียวทั้ง process — อันตรายภายใต้ SSR |
$host() สำหรับ custom element | Event contract ที่ไม่ขึ้นกับ framework (native CustomEvent) | ใช้ได้เฉพาะใน component ที่เป็น customElement — ที่อื่น compile error |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ห่อ
new Foo()ด้วย$state(...)จะทำให้ instance ทั้งก้อน reactive แบบ deep” ไม่จริง — class instance ไม่เคยถูก proxy มีแค่ field ที่ประกาศในตัว classด้วย$state/$derivedเท่านั้นที่ reactive - “reactive field ของ class จะโผล่ใน
Object.keysหรือJSON.stringifyเหมือน plain object” ไม่โผล่ — พิสูจน์แล้วข้างบน compiler backed มันด้วย private field และ accessor ที่ไม่ enumerable - “
$effect.root()เก็บกวาดตัวเองตอน component unmount เหมือน$effectปกติ” ไม่จริงเลย — นั่นคือประเด็นทั้งหมดของ rune ตัวนี้ คุณต้องเรียกdestroy()ที่ได้กลับมาเอง - “
$host()ใช้ได้ใน component ไหนก็ได้ ขอแค่เรียกใน handler” มันคือ compile error (host_invalid_placement) ทุกที่ที่ไม่ใช่ component ที่ compile ด้วย<svelte:options customElement>— พิสูจน์แล้วข้างบน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อเจอ class ที่มี rune หรือ shared state ระดับ module ให้เช็คสิ่งเหล่านี้:
- factory (
createCounter(),createStore()) expose reactive field ผ่าน getter หรือ export primitive แบบ reassign เปล่า ๆ ที่จะเสีย reactivity ตอน import? - module-scope
$stateถูก mutate ระหว่าง server-side rendering หรือเปล่า? ถ้าแอปนี้รันบน server state นั้นถูกแชร์ข้ามทุก request ไม่ได้ private ต่อ user - โค้ดคาดว่า class instance ที่มี
$statefield จะ serialize ด้วยJSON.stringifyหรือ enumerate ด้วยObject.keys/spread ได้ — ทั้งที่ compiler ทำให้ field พวกนั้นไม่ enumerable? - ค่าถูก destructure ออกจาก reactive class instance (
const { count } = counter) แล้วเอาไปใช้ทีหลัง โดยคาดว่ามันจะ live? destructure ก็อปปี้ค่า ณ ตอนนั้น ไม่ใช่ live binding $host()ถูกใช้ที่อื่นนอกจาก component ที่ compile ด้วย<svelte:options customElement>หรือเปล่า?
🐛 หา bug ในโค้ดนี้
class Counter {count = $state(0);increment() { this.count += 1;}}
function Stats({ counter }) {// Pull the current count out for "convenience"...const { count } = counter;$effect(() => { console.log('count changed to', count);});}เฉลย
การ destructure const { count } = counter อ่าน ค่าปัจจุบันครั้งเดียว ของ class field แล้วก็อปปี้ลงตัวแปร local ธรรมดา — มันไม่ใช่ live reference กลับไปหา field ที่ reactive $effect ข้างบนจึง depend อยู่กับตัวแปร local count นั้นเท่านั้น ซึ่งไม่มีการ reassign อีกเลยหลังจาก destructure ดังนั้น effect รันแค่ครั้งเดียวแล้วไม่รันอีกเลย ไม่ว่าจะเรียก counter.increment() อีกกี่ครั้งหลังจากนั้น พิสูจน์ในโปรบแล้ว: อ่าน counter.double ตรง ๆ ในเอฟเฟกต์ (ไม่ destructure) ได้ผลลัพธ์ [0, 2, 12] ตลอดสาม increment ในขณะที่ destructure ก่อนแล้วอ่านค่าที่ก็อปปี้ได้แค่ [0] — effect ไม่รันซ้ำเลย ทางแก้คืออ่านผ่าน instance ต่อภายใน tracked scope เสมอ — counter.count (หรือ getter อย่าง counter.double) — อย่า destructure reactive class field ก่อนใช้ใน effect หรือ derived