Fine-Grained DOM
บท Signals & Scheduling พูดถึง เมื่อไหร่ effect รัน บทนี้พูดถึง มันทำอะไรกับ DOM จริง ๆ — และทำไมมันถึงเป็น operation ที่เล็กที่สุดเท่าที่จะเป็นไปได้เสมอ ตัดสินใจไว้ตอนคอมไพล์ ไม่เคยเป็นการเปรียบเทียบ tree ตอน runtime
Template cloning: parse ครั้งเดียว clone ต่อการ mount
หัวข้อที่มีชื่อว่า “Template cloning: parse ครั้งเดียว clone ต่อการ mount”$.from_html(content, flags) (packages/svelte/src/internal/client/dom/template.js) parse template string เป็น <template> element จริง ครั้งเดียว ตอนที่ template function ของ component ถูกเรียกครั้งแรก แล้ว cache ไว้ใน closure variable:
// packages/svelte/src/internal/client/dom/template.js — from_html (trimmed)export function from_html(content, flags) { var node; // parsed once, cached in this closure return () => { if (node === undefined) { node = create_fragment_from_html(content); if (!(flags & TEMPLATE_FRAGMENT)) node = get_first_child(node); } var clone = use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true); assign_nodes(clone, clone); return clone; };}ทุกการ mount ครั้งถัดไปของ component เดียวกันคือ cloneNode(true) ของ node ที่ cache ไว้และ parse แล้ว — เบราว์เซอร์ parse HTML string แค่ครั้งเดียวไม่ว่าจะมีกี่ instance ก็ตาม และก็ไม่มี “virtual node tree” ที่อธิบาย template ด้วย — clone คือ DOM จริงทันที
$.set_text แตะ node เดียวเป๊ะ — พิสูจน์ด้วย spy ไม่ใช่แค่ output ที่เท่ากัน
หัวข้อที่มีชื่อว่า “$.set_text แตะ node เดียวเป๊ะ — พิสูจน์ด้วย spy ไม่ใช่แค่ output ที่เท่ากัน”<script> let a = $state('a0'); let b = $state('b0');</script>
<p>{a}</p><p>{b}</p><script> let a = $state('a0'); let b = $state('b0');</script>
<p>{a}</p><p>{b}</p> {a}/{b} แต่ละตัวได้ $.template_effect(() => $.set_text(text, $.get(a))) ของตัวเอง การเทียบ output ที่เป็น text เฉย ๆ ก่อน/หลังพิสูจน์ได้แค่ว่า ผลลัพธ์ที่มองเห็น ถูกต้อง — มันจะจับไม่ได้เลยถ้า framework แอบ re-render ทุกอย่างใหม่แล้วบังเอิญได้ string เดียวกัน เพื่อพิสูจน์จริง ๆ ว่ามี node เดียวเท่านั้นที่ถูกแตะ เทสต์นี้แทนที่ setter ของ nodeValue ของ text node b ด้วย spy ก่อนเปลี่ยน a แล้ว assert ว่ามันไม่เคยถูกเรียกเลย:
import { mount, flushSync, unmount } from 'svelte';import { compile } from 'svelte/compiler';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndMount(src: string, target: HTMLElement) { const result = compile(src, { generate: 'client', runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `settext-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const { default: Component } = await import(/* @vite-ignore */ file); return mount(Component, { target });}
test('$.set_text touches only the ONE text node whose $state actually changed', async () => { const target = document.createElement('div'); document.body.appendChild(target);
const instance = await compileAndMount( `<script> let a = $state('a0'); let b = $state('b0'); export function setA(v) { a = v; } </script> <p id="pa">{a}</p> <p id="pb">{b}</p>`, target, );
const pb = target.querySelector('#pb')!; const bTextNode = pb.firstChild as Text; let bWrites = 0; const setter = Object.getOwnPropertyDescriptor(Node.prototype, 'nodeValue')!.set!; Object.defineProperty(bTextNode, 'nodeValue', { set(v) { bWrites++; setter.call(this, v); }, get() { return bTextNode.data; }, configurable: true, });
(instance as any).setA('a1'); flushSync();
expect(target.querySelector('#pa')!.textContent).toBe('a1'); expect(target.querySelector('#pb')!.textContent).toBe('b0'); expect(bWrites).toBe(0); // b's text node nodeValue setter was NEVER invoked expect(pb.firstChild).toBe(bTextNode); // and it's the exact same node object, never recreated
unmount(instance); target.remove();});✓ $.set_text touches only the ONE text node whose $state actually changed
Test Files 1 passed (1) Tests 1 passed (1)Keyed {#each}: การสลับลำดับ ย้าย node; unkeyed สลับ เนื้อหา
หัวข้อที่มีชื่อว่า “Keyed {#each}: การสลับลำดับ ย้าย node; unkeyed สลับ เนื้อหา”{#each items as item (item.id)} และ {#each items as item} คอมไพล์เป็น call $.each(node, flags, get_collection, get_key, render_fn) เดียวกัน (บท Compiler Output โชว์ทั้งสองแบบ) — ความต่างมีแค่ get_key เป็นอะไร ถ้าไม่มี (expr) compiler จะส่งฟังก์ชัน index ของ Svelte เองเข้าไป:
// packages/svelte/src/internal/client/dom/blocks/each.js — the default (positional) keyexport function index(_, i) { return i; }ภายใน each() เก็บ Map<key, EachItem> (items) ไว้ การ reconcile array ใหม่จะเดินตาม key ใหม่: entry เดิมใน map ถูก ย้าย (move()/link() เชื่อม linked list ของ item-effect ใหม่แล้วย้าย DOM node จริงด้วย dest.before(node)); key ที่ไม่มี entry เดิมจะได้ EachItem ใหม่เอี่ยม ผ่าน create_item() (effect branch() ใหม่และ DOM ใหม่) ถ้า get_key = index “key ที่ตำแหน่ง 2” จะเป็น 2 เสมอ ไม่ว่า array element ตัวไหนจะอยู่ตรงนั้นตอนนี้ก็ตาม — การสลับลำดับ array เลยไม่ trigger การย้ายเลย DOM node สามตัวเดิมอยู่ในตำแหน่งสามตำแหน่งเดิม แล้วแค่เนื้อหา $.set_text เปลี่ยนแทน เหมือน section ก่อนหน้าเป๊ะ ถ้ามี key ต่อ item จริง การสลับลำดับคือการเปลี่ยน key เทียบกับตำแหน่ง ดังนั้น each() จะย้าย node เดิม (พร้อมทุกอย่างที่ reactive แนบอยู่กับมัน) ไปตำแหน่งใหม่แทน:
import { mount, flushSync, unmount } from 'svelte';import { compile } from 'svelte/compiler';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndMount(src: string, target: HTMLElement) { const result = compile(src, { generate: 'client', runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `each-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const { default: Component } = await import(/* @vite-ignore */ file); return mount(Component, { target });}
const KEYED = `<script> let items = $state([{ id: 1, text: 'one' }, { id: 2, text: 'two' }, { id: 3, text: 'three' }]); export function reverse() { items = [...items].reverse(); }</script>{#each items as item (item.id)}<p>{item.text}</p>{/each}`;
const UNKEYED = `<script> let items = $state([{ id: 1, text: 'one' }, { id: 2, text: 'two' }, { id: 3, text: 'three' }]); export function reverse() { items = [...items].reverse(); }</script>{#each items as item}<p>{item.text}</p>{/each}`;
test('keyed {#each}: reversing the array MOVES the existing <p> nodes (same objects, new order)', async () => { const target = document.createElement('div'); document.body.appendChild(target); const instance = await compileAndMount(KEYED, target);
const before = Array.from(target.querySelectorAll('p')); (instance as any).reverse(); flushSync(); const after = Array.from(target.querySelectorAll('p'));
expect(after.map((p) => p.textContent)).toEqual(['three', 'two', 'one']); expect(after[0]).toBe(before[2]); // the SAME node that used to render "three" moved to the front expect(after[2]).toBe(before[0]); unmount(instance); target.remove();});
test('unkeyed {#each} (positional key): reversing the array does NOT move nodes — same nodes, only their text updates', async () => { const target = document.createElement('div'); document.body.appendChild(target); const instance = await compileAndMount(UNKEYED, target);
const before = Array.from(target.querySelectorAll('p')); (instance as any).reverse(); flushSync(); const after = Array.from(target.querySelectorAll('p'));
expect(after.map((p) => p.textContent)).toEqual(['three', 'two', 'one']); expect(after[0]).toBe(before[0]); // the node at position 0 never moved — its CONTENT changed instead expect(after[1]).toBe(before[1]); expect(after[2]).toBe(before[2]); unmount(instance); target.remove();});✓ keyed {#each}: reversing the array MOVES the existing <p> nodes (same objects, new order)✓ unkeyed {#each} (positional key): reversing the array does NOT move nodes — same nodes, only their text updates
Test Files 1 passed (1) Tests 2 passed (2)นี่คือกลไกจริงเบื้องหลังบั๊กคลาสสิก “list ไม่มี key ที่มีแถวถือ state ของตัวเอง”: state ของ node เอง ($state ของ component ตัวหนึ่ง, ค่า <input> แบบ uncontrolled, CSS transition state) อยู่บน DOM node/effect ไม่ใช่บน array item ถ้าย้าย node (มี key จริง) state นั้นจะเดินทางไปพร้อมค่าที่มันเป็นของอย่างถูกต้อง ถ้าปล่อย node อยู่กับที่ (positional key) state จะติดอยู่กับ ตำแหน่ง ในขณะที่ค่าที่ไหลผ่านมันเปลี่ยนไปข้างใต้
Key ที่ซ้ำกันก็เป็นกรณีที่ถูกเช็คจริง ไม่ใช่ปล่อยผ่านเงียบ ๆ: validate_each_keys (ไฟล์เดียวกัน) เดิน array แล้ว throw error each_key_duplicate จริงทันทีที่ item สองตัวให้ key เดียวกัน — พิสูจน์ว่า “แค่ใช้ (item.id)” ยังไม่พอถ้า id ไม่ได้ unique จริง
{#if}: branch effect ที่ถูกสร้างและทำลาย ไม่ใช่แค่ซ่อน
หัวข้อที่มีชื่อว่า “{#if}: branch effect ที่ถูกสร้างและทำลาย ไม่ใช่แค่ซ่อน”{#if} block (บท Compiler Output โชว์ว่ามันคอมไพล์เป็น $.if(node, ($$render) => { if (cond) $$render(consequent); else $$render(alternate, -1); })) ไม่ได้ render ทั้งสอง branch แล้วสลับ display: none — มีแค่ template ของ branch ที่ active เท่านั้นที่ถูก clone เข้า DOM ในฐานะ branch effect ของตัวเองพร้อม lifecycle ของตัวเอง การสลับ branch ทำลาย branch effect เก่า (รัน cleanup ของ $effect/onMount ที่ลงทะเบียนไว้ข้างในนั้น) แล้วสร้างอันใหม่สำหรับ branch ใหม่ นี่คือเหตุผลว่าทำไม state ข้างใน {#if} branch (local $state ของ child component เช่น) ไม่รอดจากการสลับ branch — subtree ทั้งหมดถูกทำลายจริง ไม่ใช่แค่ซ่อน
bind: เป็นสอง effect โดยย่อ
หัวข้อที่มีชื่อว่า “bind: เป็นสอง effect โดยย่อ”bind:value แบบสองทางไม่ใช่มายากลชิ้นเดียว — มันคือสอง effect อิสระที่ผูกกับ source เดียวกัน: effect หนึ่ง (template effect แบบเดียวกับ dynamic attribute อื่น ๆ) เขียนค่า $state เข้า DOM property ทุกครั้งที่ state เปลี่ยน และ event listener ธรรมดาอีกตัวเขียนค่าปัจจุบันของ DOM กลับเข้า $state source ทุกครั้งที่ DOM event ที่เกี่ยวข้องเกิดขึ้น ไม่มีอันไหนเป็น “binding primitive” พิเศษในระดับ signal graph เลย — มันคือ write-effect ธรรมดากับ event handler ธรรมดาที่บังเอิญเล็ง source เดียวกัน ซึ่งก็เป็นเหตุผลว่าทำไม bind:value={get, set} แบบ function form (พูดถึงใน Bindings) ถึงใช้งานได้: มันแค่ส่ง read/write function คู่ต่างไปให้ effect สองตัวเดิมนั่นเอง
Fine-grained update เทียบกับ VDOM diff ในหนึ่งย่อหน้า
หัวข้อที่มีชื่อว่า “Fine-grained update เทียบกับ VDOM diff ในหนึ่งย่อหน้า”VDOM framework re-run render function สร้าง tree description ใหม่แล้ว diff กับอันก่อนหน้าตอน runtime — คำถาม “อะไรเปลี่ยนไปบ้าง” ถูกตอบทุกครั้งที่ update โดยการเดิน (บางส่วนของ) tree Svelte ตอบคำถาม “อะไรเปลี่ยน” ครั้งเดียวใน compiler: ทุกค่า dynamic ใน template มี $.template_effect ของตัวเองที่ compiler สร้างไว้แล้วที่เรียก DOM API ที่ค่านั้นต้องการเป๊ะ ($.set_text, $.set_attribute, keyed $.each reconciliation) — ไม่มี tree ที่อธิบาย render ปัจจุบันให้ diff เพราะไม่มี “render ปัจจุบัน” ในรูป data structure เลย มีแค่ DOM จริงบวกกราฟของ effect ที่รู้เป๊ะว่าตัวเองเป็นเจ้าของส่วนไหน
flowchart LR
subgraph vdom["VDOM framework, every update"]
r1["re-run render`()`"] --> tree["new tree description"]
tree --> diff["diff against previous tree
(runtime work, every time)"]
diff --> patch["apply the discovered patches"]
end
subgraph svelte["Svelte, every update"]
w["`set(`source`)`"] --> known["compiler already generated
the ONE effect that owns this value
(Compiler Output)"]
known --> apply["`$.set_text`/`$.set_attribute`/keyed `each` move
(no diff step — already known)"]
end อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- คำอธิบายบอกว่า Svelte “diff DOM” หรือ “diff virtual tree” หรือเปล่า? ไม่มีทั้งสองอย่างเลย — ทุกค่า dynamic มี effect ที่คอมไพล์ไว้แล้วที่รู้เป๊ะว่าตัวเองเป็นเจ้าของ node ไหน พิสูจน์ข้างบนด้วย spy ที่โชว์ว่าไม่มีการเขียนไปที่ node ที่ไม่เกี่ยวเลย
{#each}บน list ของcomponent ที่มี local state ของตัวเอง (แถวฟอร์ม, การ์ดที่ขยายได้, animation) ไม่มี key หรือ key ด้วย index หรือเปล่า? พิสูจน์ข้างบน: ถ้าไม่มี key ต่อ item จริง การสลับลำดับจะปล่อย node อยู่กับที่แล้วสลับเนื้อหาแทน — local state ต่อ node ก็เลยไปติดกับค่าผิดตัว- โค้ดสมมติว่า state ของ
{#if}branch (local$stateของ child component,setTimeoutที่ schedule ข้างใน) รอดจากการสลับ branch ที่มันไม่ได้ trigger เองหรือเปล่า? การสลับ branch ทำลาย branch effect เก่าทั้งหมด ไม่มีอะไรข้างในรอดเลยเว้นแต่จะถูกยกขึ้นไปที่ parent - comment review อ้างว่า
{#each}สองตัวที่ key ด้วย{item.id}ที่idเท่ากันโดยไม่ตั้งใจ “แค่ render ตามลำดับใดลำดับหนึ่ง” หรือเปล่า? มันจะ throw erroreach_key_duplicateจริง ไม่ถูกปล่อยผ่านเงียบ ๆ
🐛 หา bug ในโค้ดนี้
<script>let rows = $state([ { id: 1, label: 'First' }, { id: 2, label: 'Second' }, { id: 3, label: 'Third' },]);
function moveUp(i) { if (i === 0) return; // "swap the two rows" [rows[i - 1], rows[i]] = [rows[i], rows[i - 1]];}</script>
{#each rows as row, i}<div> <input placeholder={row.label} /> <button onclick={() => moveUp(i)}>Move up</button></div>{/each}เฉลย
{#each} นี้ไม่มี key จึง key ด้วยตำแหน่ง (i) การสลับลำดับ array ข้างใต้ (การ swap ใน moveUp) ไม่ย้าย DOM node เลย — <div> ที่แต่ละตำแหน่งอยู่ตรงเดิม แล้วแค่ ค่า ที่ไหลผ่านมัน (แล้วก็ placeholder) เปลี่ยนแทน ดูเผิน ๆ ก็ไม่เป็นไรสำหรับ placeholder แต่ <input> ตรงนี้เป็นแบบ uncontrolled (ไม่มี bind:value): อะไรก็ตามที่ user พิมพ์เข้าไปใน <input> จริง ๆ จะยังอยู่บน DOM node ที่ไม่ได้ย้าย — “เลื่อนแถวขึ้น” จึงทิ้งข้อความที่พิมพ์ไว้แบบเงียบ ๆ ไว้ที่ตำแหน่งเดิม ในขณะที่ label สลับแถวไปข้างใต้มัน ทางแก้คือใส่ key ต่อ item จริง {#each rows as row, i (row.id)} ซึ่ง — พิสูจน์ข้างบน — ทำให้ Svelte ย้าย <div>/<input> node จริง (พร้อมสิ่งที่ user พิมพ์ไว้) ไปพร้อมแถวของมัน แทนที่จะย้าย label ผ่าน input ที่อยู่กับที่
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ไม่มี tree ให้ diff จึงไม่มีอะไรต้อง “คิด” ตอน runtime — ทุกส่วน dynamic ของ template มี effect ที่ compiler สร้างไว้แล้วโดยเฉพาะซึ่งเป็นเจ้าของ DOM operation เดียวเป๊ะ การ update หมายถึง: effect นี้ dirty ไหม ถ้าใช่ก็รัน DOM call เดียวที่มันเป็นเจ้าของ การตัดสินใจจริงเดียวของ {#each} คือต่อ-key: key นี้มี node อยู่แล้วไหม (ย้ายมัน) หรือไม่มี (สร้างใหม่) — พิสูจน์ข้างบนด้วย node-identity assertion จริง ไม่ใช่แค่ text ที่ตรงกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| DOM effect ที่รู้ตั้งแต่ตอนคอมไพล์ (ไม่มีขั้นตอน diff) | update มีต้นทุนแค่ DOM operation ที่ค่าที่เปลี่ยนต้องการเป๊ะ — พิสูจน์ข้างบน ไม่มีการเขียนส่วนเกินเลย | compiler ต้องมองเห็น shape ของ template แบบ static ได้; markup ที่ dynamic/generate มากเกินไปไม่มี equivalent ใน model นี้ |
Keyed {#each} (Map<key, EachItem>, ย้ายหรือสร้าง) | การสลับลำดับ แทรก และลบ รักษา state และ DOM identity ต่อ item ไว้ตรงตำแหน่งที่ key บอกเป๊ะ | เลือก key ผิด (หรือไม่มี key) ทำให้ตกลงมาที่ positional reuse แบบเงียบ ๆ — ไม่มี error มีแค่ state ที่ติดกับค่าผิดตัว |
{#if} เป็นการสร้าง/ทำลาย branch effect เต็มรูปแบบ (ไม่ใช่การสลับการแสดงผล) | รัน cleanup (onDestroy, effect teardown) ถูกต้องบน branch ที่กำลังจะออกไปทุกครั้ง | state ข้างใน branch ที่แพ้หายไปจริง ๆ ไม่ใช่ “พัก” ไว้ — โค้ดที่คาดว่ามันจะอยู่รอดข้ามการสลับซ่อน/แสดงต้องยกมันขึ้นไปเก็บที่ parent |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Svelte diff virtual DOM ที่เร็วมาก เร็วกว่า React” ไม่มี virtual DOM diff เลย — พิสูจน์ข้างบนด้วย spy ที่โชว์ว่า
nodeValuesetter ของ node ที่ไม่เกี่ยวไม่เคยถูกเรียกเลย เพราะ compiler รู้ล่วงหน้าอยู่แล้วว่ามันไม่ต้อง - “
{#each}ที่ไม่มี key ก็ยังทำงานถูกต้อง แค่ performance แย่กว่านิดหน่อย” พิสูจน์แล้วว่าผิดเมื่อ item มี state ของตัวเอง: การสลับลำดับ list ที่ไม่มี key สลับค่าใต้ node ที่อยู่กับที่ ซึ่งเป็นบั๊กด้าน correctness (state ผิดตัวติดกับแถวผิด) ไม่ใช่แค่ทางช้าลง - “
{#if}แค่ show/hide เนื้อหา เหมือนdisplay: none” มันสร้างและทำลาย branch effect จริง — อะไรก็ตามที่มี lifecycle ของตัวเองข้างใน{#if}branch จะถูกรื้อและสร้างใหม่จริงทุกครั้งที่สลับ branch พิสูจน์โดยข้อเท็จจริงที่ per-item effect ของ{#each}เองก็ทำงานแบบเดียวกันเมื่อ key หายไป