Signals & Scheduling
บท Compiler Output แสดงให้เห็นว่า $state, $derived, $effect ต่างก็กลายเป็น call ที่สร้าง plain object — Source, Derived, Effect บทนี้จะพูดถึงว่า object พวกนั้นคืออะไรจริง ๆ ต่อกันยังไง และเมื่อไหร่การเขียนค่าตัวหนึ่งถึงทำให้อีกตัวรัน ทุกฟังก์ชันที่ยกมาข้างล่างเป็น source จริง เวอร์ชันปัจจุบันจาก node_modules/[email protected]’s packages/svelte/src/internal/client/ — ให้ชื่อไฟล์และชื่อฟังก์ชันไว้ เพื่อให้คุณ diff กับเวอร์ชันที่คุณกำลังอ่านอยู่ได้
Node สามชนิด กราฟเดียว
หัวข้อที่มีชื่อว่า “Node สามชนิด กราฟเดียว”Source (สิ่งที่ $.state คืนกลับมา) คือ object จากบท Compiler Output: { f, v, reactions, equals, rv, wv } Derived ขยาย shape นั้นด้วย deps (สิ่งที่มันอ่านครั้งล่าสุดที่รัน) กับ fn (thunk ที่จะ re-run) Effect ก็มี deps เหมือนกัน บวกสิ่งที่ต้อง ทำจริง (fn) เมื่อมันรันใหม่ ทั้งสามชนิดมี field f (flags) เป็น bitmask นิยามไว้ใน packages/svelte/src/internal/client/constants.js:
// packages/svelte/src/internal/client/constants.js (trimmed to this lesson's flags)export const DERIVED = 1 << 1;export const EFFECT = 1 << 2;export const RENDER_EFFECT = 1 << 3;export const CLEAN = 1 << 10;export const DIRTY = 1 << 11;export const MAYBE_DIRTY = 1 << 12;export const USER_EFFECT = 1 << 20;$effect(fn) ตั้ง EFFECT | USER_EFFECT; $effect.pre(fn) ตั้ง RENDER_EFFECT | USER_EFFECT; ส่วน reactive binding ของ template เอง — $.template_effect จากบท Compiler Output — เป็น RENDER_EFFECT เปล่า ๆ ไม่มี bit USER_EFFECT bit เดียวนี้แหละคือความต่างทั้งหมดระหว่าง “effect ของคุณ” กับ “effect ของ compiler เองที่ update DOM” และระหว่าง $effect กับ $effect.pre:
// packages/svelte/src/internal/client/reactivity/effects.js (trimmed)export function create_user_effect(fn) { return create_effect(EFFECT | USER_EFFECT, fn);}export function user_pre_effect(fn) { return create_effect(RENDER_EFFECT | USER_EFFECT, fn);}export function template_effect(fn, sync = [], async = [], blockers = []) { flatten(blockers, sync, async, (values) => { create_effect(RENDER_EFFECT, () => { fn(...values.map(get)); }); });}Render effect (RENDER_EFFECT — $effect.pre และทุก template binding) รันก่อน plain effect (EFFECT — $effect) เสมอในการ flush เดียวกัน พิสูจน์ตรง ๆ:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('effect order: $effect.pre (render effect) runs before $effect (user effect)', () => { const log: string[] = []; const destroy = $effect.root(() => { let count = $state(0);
$effect.pre(() => { log.push(`pre:${count}`); }); $effect(() => { log.push(`effect:${count}`); });
flushSync(); log.length = 0;
count = 1; flushSync(); }); destroy();
expect(log).toEqual(['pre:1', 'effect:1']);});✓ effect order: $effect.pre (render effect) runs before $effect (user effect)
Test Files 1 passed (1) Tests 1 passed (1)การเขียน source: mark_reactions กระจาย DIRTY ไม่ใช่ “re-run ตอนนี้เลย”
หัวข้อที่มีชื่อว่า “การเขียน source: mark_reactions กระจาย DIRTY ไม่ใช่ “re-run ตอนนี้เลย””set(source, value) (reactivity/sources.js) ไม่เรียกอะไรเลย — มันแค่บันทึกค่าใหม่แล้วเดินผ่าน source.reactions (ทุกอย่างที่เคยอ่าน source นี้) แล้ว mark แต่ละตัว นี่คือฟังก์ชัน propagate จริง:
// packages/svelte/src/internal/client/reactivity/sources.js — mark_reactions (trimmed)function mark_reactions(signal, status, updated_during_traversal) { var reactions = signal.reactions; if (reactions === null) return;
for (var i = 0; i < reactions.length; i++) { var reaction = reactions[i]; var flags = reaction.f; var not_dirty = (flags & DIRTY) === 0;
if (not_dirty) set_signal_status(reaction, status);
if ((flags & DERIVED) !== 0) { // deriveds propagate onward as MAYBE_DIRTY — they don't re-run yet mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY, updated_during_traversal); } else if (not_dirty) { // effects get scheduled for the next flush schedule_effect(/** @type {Effect} */ (reaction)); } }}มีสองจุดที่ควรสังเกต: ผู้อ่านที่เป็น Derived จะถูก mark เป็น MAYBE_DIRTY แล้วการเดินจะต่อผ่านมันไปยังสิ่งที่อ่าน derived นั้น ต่อ — derived ไม่เคยถูก “schedule” เอง เพราะมันไม่มี side effect ให้รัน มีแค่ค่าที่ต้อง recompute ถ้าและเมื่อ มีคนมาอ่านมัน ส่วนผู้อ่านที่เป็น Effect จะถูกเรียก schedule_effect(effect) — ซึ่งก็ไม่ได้รันมันเช่นกัน แค่เข้าคิว (ส่วนถัดไป) ไม่มีอะไรในฟังก์ชันนี้เรียกฟังก์ชันของ user แบบ synchronous เลย นี่คือเหตุผลทางกลไกที่ทำไมการรัน $effect แบบ synchronous ทันทีหลัง set ถึงไม่มีวันเกิดขึ้น
$derived เป็น lazy: update_derived รันแค่ตอนอ่าน ผ่าน is_dirty
หัวข้อที่มีชื่อว่า “$derived เป็น lazy: update_derived รันแค่ตอนอ่าน ผ่าน is_dirty”Derived ที่เพิ่งสร้างเริ่มต้นด้วย DIRTY และค่า UNINITIALIZED — derived(fn) (reactivity/deriveds.js) ไม่เคยเรียก fn เอง:
// packages/svelte/src/internal/client/reactivity/deriveds.js — derived (trimmed)export function derived(fn) { var flags = DERIVED | DIRTY; const signal = { deps: null, effects: null, equals, f: flags, fn, reactions: null, rv: 0, v: UNINITIALIZED, wv: 0 }; return signal;}สิ่งเดียวที่เรียก fn คือ execute_derived และสิ่งเดียวที่เรียก นั่น คือ update_derived และสิ่งเดียวที่เรียก update_derived คือ is_dirty — ซึ่งรันเมื่อ (และเฉพาะเมื่อ) มีคนอ่าน derived ผ่าน $.get:
// packages/svelte/src/internal/client/runtime.js — is_dirty (trimmed)export function is_dirty(reaction) { if ((reaction.f & DIRTY) !== 0) return true;
if ((reaction.f & MAYBE_DIRTY) !== 0) { for (var dep of /** @type {Value[]} */ (reaction.deps)) { if (is_dirty(/** @type {Derived} */ (dep))) { update_derived(/** @type {Derived} */ (dep)); // <- only recomputed HERE, on read } if (dep.wv > reaction.wv) return true; } set_signal_status(reaction, CLEAN); } return false;}พิสูจน์ด้วย test นับจำนวนครั้งที่เรียก — การเขียนค่าที่ derived ขึ้นกับไม่เคย evaluate มันเลย มีแค่การ อ่าน เท่านั้นที่ทำ และอ่านสองครั้งติดกัน (ไม่มีอะไรเปลี่ยนระหว่างนั้น) ก็ไม่ re-run ครั้งที่สอง:
import { expect, test } from 'vitest';
test('$derived is evaluated lazily: the callback does not run until the value is READ, and is cached after that', () => { const calls: number[] = []; const destroy = $effect.root(() => { let count = $state(0); let doubled = $derived.by(() => { calls.push(count); return count * 2; });
count = 5; // write only — no read anywhere yet expect(calls).toEqual([]); // NOT evaluated just from writing the source
function read() { return doubled; } expect(read()).toBe(10); // first read expect(calls).toEqual([5]); // exactly one evaluation
expect(read()).toBe(10); // second read, nothing changed since expect(calls).toEqual([5]); // still one — cached, not re-run }); destroy();});✓ $derived is evaluated lazily: the callback does not run until the value is READ, and is cached after that
Test Files 1 passed (1) Tests 1 passed (1)นี่คือเหตุผลจริงว่าทำไม $derived กับ legacy $: x = ... ถึงใช้แทนกันไม่ได้ (บท Compiler Output แสดงว่า legacy statement คอมไพล์เป็น effect ที่ re-run แบบ eager): $derived ที่ไม่เคยถูกอ่านจะไม่รัน expression ของมันเลย ไม่ว่า dependency จะเปลี่ยนกี่ครั้งก็ตาม
Flush: Batch, microtask เดียว และ flushSync
หัวข้อที่มีชื่อว่า “Flush: Batch, microtask เดียว และ flushSync”schedule_effect (ที่ถูกเรียกจาก mark_reactions ข้างบน) ทำแค่อย่างเดียว: Batch.ensure().schedule(effect) (reactivity/batch.js) การเขียนครั้งแรกในหนึ่ง tick สร้าง Batch แล้วเข้าคิว microtask หนึ่ง อันเพื่อ flush มัน:
// packages/svelte/src/internal/client/reactivity/batch.js — Batch.ensure (trimmed)static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); if (!is_processing && !is_flushing_sync) { queue_micro_task(() => { if (!batch.#started) batch.flush(); }); } } return current_batch;}queue_micro_task (dom/task.js) เป็น wrapper ของ queueMicrotask จริง ที่ dedupe ไว้แล้ว ทำให้การเขียนหลาย ๆ ครั้งแบบ synchronous ติดกันเข้าคิว flush แค่ครั้งเดียว:
// packages/svelte/src/internal/client/dom/task.js — queue_micro_task (trimmed)export function queue_micro_task(fn) { if (micro_tasks.length === 0 && !is_flushing_sync) { var tasks = micro_tasks; queueMicrotask(() => { if (tasks === micro_tasks) run_micro_tasks(); }); } micro_tasks.push(fn);}นั่นคือเรื่องราวทั้งหมดของ batching: ทุก set() ในช่วง synchronous เดียวกันแชร์ Batch เดียวกันและ microtask ที่ต่อคิวไว้อันเดียวกัน — นี่คือเหตุผลว่าทำไม count++ ห้าครั้งติดกันถึงเกิด flush แค่ครั้งเดียว ไม่ใช่ห้าครั้ง flushSync(fn) (ไฟล์เดียวกัน) รัน fn แล้ว drain microtask แบบ synchronous แล้ว flush batch ที่ค้างอยู่วนไปจนกว่าจะไม่มีอะไรค้าง — harness ของคอร์สนี้ใช้มันตลอดเพื่อให้ update เกิดขึ้น ตอนนี้เลย ในเทสต์ แทนที่จะรอ tick หนึ่งรอบ:
// packages/svelte/src/internal/client/reactivity/batch.js — flushSync (trimmed)export function flushSync(fn) { is_flushing_sync = true; try { var result; if (fn) { if (current_batch !== null && !current_batch.is_fork) current_batch.flush(); result = fn(); } while (true) { flush_tasks(); if (current_batch === null) return result; current_batch.flush(); } } finally { is_flushing_sync = false; }}flowchart TD write["`set(`source, value`)`"] --> mark["mark_reactions DIRTY (effects) / MAYBE_DIRTY (through deriveds)"] mark --> sched["schedule_effect`(`effect`)` Batch.ensure`()`.schedule`(`effect`)`"] sched --> micro["queue_micro_task ONE queueMicrotask per batch"] micro --> flush["batch.flush`()` or flushSync`()` right now"] flush --> dirty["is_dirty`(`effect`)` recurses into MAYBE_DIRTY deps"] dirty -->|"a dep is a dirty Derived"| recompute["update_derived: fn`()` runs NOW (lazy — only because something read it)"] dirty --> run["update_effect: your `$effect`/`$effect.pre` fn runs"]
untrack: อ่านโดยไม่ subscribe
หัวข้อที่มีชื่อว่า “untrack: อ่านโดยไม่ subscribe”$.get(signal) คือจุดที่ dependency ถูกลงทะเบียนจริง ๆ — มัน push signal เข้า active_reaction.deps (runtime.js) untrack(fn) รัน fn โดยพัก bookkeeping ของ active-reaction ไว้ชั่วคราว ดังนั้น $.get ใดก็ตามข้างในมันจะอ่านค่าปัจจุบัน โดยไม่ เพิ่ม dependency:
import { flushSync, untrack } from 'svelte';import { expect, test } from 'vitest';
test('untrack: reading $state inside untrack() does not register a dependency', () => { const log: string[] = []; const destroy = $effect.root(() => { let a = $state(0); let b = $state(0);
$effect(() => { log.push(`a=${a} b(untracked)=${untrack(() => b)}`); });
flushSync(); log.length = 0;
b = 1; // b was only read via untrack — should NOT re-run the effect flushSync(); expect(log).toEqual([]);
a = 1; // a IS a real, tracked dependency flushSync(); expect(log).toEqual(['a=1 b(untracked)=1']); // re-ran, and b's CURRENT value is visible }); destroy();});✓ untrack: reading $state inside untrack() does not register a dependency
Test Files 1 passed (1) Tests 1 passed (1)สังเกตว่า effect ยังเห็นค่าล่าสุดของ b (1) ในครั้งถัดไปที่มันรันจริง (ถูก trigger โดย a) — untrack ไม่ได้ซ่อนค่า มันแค่ทำให้การอ่านครั้งนั้นไม่ถูกเพิ่มเข้า deps
state_unsafe_mutation: การป้องกันจริงที่ตั้งใจใส่ไว้
หัวข้อที่มีชื่อว่า “state_unsafe_mutation: การป้องกันจริงที่ตั้งใจใส่ไว้”การเขียนค่า $state จากข้างใน evaluator ของ $derived ไม่ใช่แค่คำเตือนเรื่อง style — set() (reactivity/sources.js) เช็ค flag ของ reaction ที่ active อยู่ตอนนี้ แล้ว throw ถ้ามันเป็น DERIVED (หรือ reaction ชนิดอื่นอีกไม่กี่แบบ) ที่กำลังเขียนลง source อื่น แบบไม่ได้ขอ ในขณะที่มันควรจะเป็น pure computation:
// packages/svelte/src/internal/client/reactivity/sources.js — set (trimmed)export function set(source, value, should_proxy = false) { if ( active_reaction !== null && (!untracking || ...) && is_runes() && (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 && (current_sources === null || !current_sources.has(source)) ) { e.state_unsafe_mutation(); } // ...}import { expect, test } from 'vitest';
test('state_unsafe_mutation: writing to $state from inside a $derived is a real thrown error', () => { const destroy = $effect.root(() => { let count = $state(0); let bad = $derived.by(() => { count = count + 1; // @expect-error state_unsafe_mutation return count; });
expect(() => bad).toThrow(/state_unsafe_mutation|Writing to state/i); }); destroy();});✓ state_unsafe_mutation: writing to $state from inside a $derived is a real thrown error
Test Files 1 passed (1) Tests 1 passed (1)นี่คือ runtime ที่บังคับใช้กฎ “derived คือ pure computation ของ dependency ของมัน” — กฎเดียวกับที่ svelte.dev เขียนไว้เป็นข้อความ แต่ที่นี่มี error ที่ถูก throw จริงและมีชื่อรองรับ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- comment อ้างว่า
$effect“รันแบบ synchronous ทันทีหลังset()ที่ trigger มัน” หรือเปล่า?mark_reactionsแค่ schedule เท่านั้น การรันจริงเกิดขึ้นตอน microtask flush รอบถัดไป (หรือflushSync()ที่เรียกตรง ๆ) ไม่เคยเกิดใน call stack เดียวกับการเขียน เว้นแต่โค้ดจะเรียกflushSyncเอง - โค้ดสมมติว่า
$derivedที่มี computation แพง “รันทุกครั้งที่ dependency เปลี่ยน” แล้วเลยเพิ่ม manual memoization ทับเข้าไปอีกชั้นหรือเปล่า? มัน lazy และ cache ไว้แล้ว — พิสูจน์ข้างบน: การเขียน dependency ไม่เคย evaluate มัน มีแค่การอ่าน derived เท่านั้นที่ทำ และอ่านซ้ำโดยไม่มีการเขียนคั่นก็ไม่ re-run untrackถูกใช้เพื่อ “อ่านค่าล่าสุด” ข้างใน effect โดยคาดว่า effect จะยัง re-run เมื่อค่า นั้น เปลี่ยนทีหลังหรือเปล่า? มันจะไม่ re-run —untrackตัดการอ่านนั้นออกจากdepsถาวรสำหรับรอบนั้นของ effect- การ migrate จาก
$: doubled = x * 2ไป$derived(x * 2)เปลี่ยน behavior ไหมถ้า statement เดิมมี side effect อยู่ข้างใน (มีconsole.log, มี mutation)?$derivedexpression ถูกคาดหวังว่าต้องเป็น pure — การป้องกันstate_unsafe_mutationข้างบนคือ runtime ที่จับสมมติฐานนั้นถูกละเมิดแบบตรง ๆ เลย
🐛 หา bug ในโค้ดนี้
<script>let user = $state({ name: 'Ada', visits: 0 });
// "log a visit every time the greeting is computed"let greeting = $derived.by(() => { user.visits += 1; return `Hello, ${user.name}`;});</script>
<p>{greeting}</p>เฉลย
user.visits += 1 mutate $state จากข้างใน evaluator function ของ $derived reaction ที่ active อยู่ตอนที่ thunk นี้รันคือ Derived เอง (มี flag DERIVED) และการป้องกันของ set() เช็คตรงกรณีนี้เป๊ะ — โค้ดนี้ throw state_unsafe_mutation error จริงทันทีที่ derived ถูกอ่านครั้งแรก (พิสูจน์ข้างบนด้วย shape เดียวกันเป๊ะ: derived ที่เขียน $state ระหว่าง evaluate ตัวเอง) ทางแก้คือไม่ปฏิบัติกับ evaluator ของ derived เหมือนเป็นที่ใส่ side effect เลย — การนับจำนวนครั้งที่เยี่ยมชมควรอยู่ใน $effect (ซึ่งได้รับอนุญาตให้เขียน state ภายใต้กฎการ re-run ของตัวเอง) ไม่ใช่ในค่าที่ควรจะเป็นฟังก์ชัน pure ของ input ของมัน