Passing UI & Children
Snippet children
หัวข้อที่มีชื่อว่า “Snippet children”เมื่อคุณวาง markup ระหว่าง tag ของ component Svelte ส่งมันเข้า component เป็น snippet prop ชื่อ children component render มันด้วย {@render children()}
<script> let { children } = $props();</script>
<div class="card"> {@render children()} <!-- whatever the parent nested goes here --></div><!-- Parent --><Card> <h2>Title</h2> <p>Any markup here becomes the card's children.</p></Card>นี่คือสิ่งที่เทียบเท่า children ของ React ใน Svelte 5 (หรือ default <slot> ของ Svelte 4): wrapper component ที่ใช้ซ้ำได้โดยไม่ต้องรู้ว่าห่ออะไรอยู่ข้างใน
flowchart LR
parent["<Card> ...markup... </Card>"] --> prop["child receives children snippet"]
prop --> render["{@render children()}"]
render --> out["markup rendered inside the card"] Named snippet prop สำหรับหลายจุดแทรก
หัวข้อที่มีชื่อว่า “Named snippet prop สำหรับหลายจุดแทรก”สำหรับ component ที่มีจุดแทรกหลายจุด (header, body, footer) ส่ง named snippet เป็น prop นิยามมันใน parent ด้วย {#snippet name()} ข้างใน tag ของ component แล้ว child จะได้แต่ละอันเป็น prop
<script> let { header, children, footer } = $props();</script>
<div class="modal"> <div class="modal-header">{@render header()}</div> <div class="modal-body">{@render children()}</div> <div class="modal-footer">{@render footer()}</div></div><Modal> {#snippet header()}<h2>Confirm</h2>{/snippet} <p>Are you sure?</p> <!-- default children --> {#snippet footer()}<button>OK</button>{/snippet}</Modal>ส่งข้อมูลกลับด้วย snippet parameter
หัวข้อที่มีชื่อว่า “ส่งข้อมูลกลับด้วย snippet parameter”เพราะ snippet รับ parameter ได้ component จึงส่งข้อมูล ออก ไปให้ markup ที่ parent เตรียมไว้ได้ — pattern ที่ React เรียกว่า render prop child เรียก snippet พร้อมค่าหนึ่ง; snippet ของ parent รับค่านั้น:
<script> let { items, row } = $props();</script>{#each items as item (item.id)} {@render row(item)} <!-- pass each item back to the parent's snippet -->{/each}<List items={products}> {#snippet row(product)} <li>{product.name} — ${product.price}</li> <!-- parent decides the markup --> {/snippet}</List>child เป็นเจ้าของการ iterate; parent เป็นเจ้าของ markup ต่อ item การกลับด้านนี้ — child ให้ data, parent ให้ presentation — คือ render-prop pattern ที่เขียนสะอาด ๆ ด้วย snippet
Typing snippet prop ด้วย Snippet<[T]>
หัวข้อที่มีชื่อว่า “Typing snippet prop ด้วย Snippet<[T]>”ด้วย lang="ts" snippet prop (children หรือ render-prop snippet อย่าง row ด้านบน) ถูก type ด้วย Snippet interface ที่ import จาก 'svelte' Snippet (ไม่มี argument) แปลว่า “ไม่รับ parameter”; Snippet<[T]> แปลว่า “รับ parameter หนึ่งตัวชนิด T” — array คือ tuple เพราะ snippet รับหลาย parameter ได้ ผสมกับ generic บนตัว component เอง TypeScript ผูก type ของ item ใน data เข้ากับ type ของ parameter ใน snippet:
<script lang="ts" generics="T"> import type { Snippet } from 'svelte'; let { data, row }: { data: T[]; row: Snippet<[T]> } = $props();</script><ul> {#each data as item} {@render row(item)} {/each}</ul>ใช้ถูกต้อง T จะถูก infer จาก data และ parameter ของ row ต้องตรงกัน:
<script lang="ts"> import TypedList from './TypedList.svelte'; let products = [{ name: 'Tea', price: 2 }];</script><TypedList data={products}> {#snippet row(item)} <li data-testid="row">{item.name} — ${item.price}</li> {/snippet}</TypedList>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import UseTypedListGood from './UseTypedListGood.svelte';
test('a correctly-typed row snippet renders each item', () => { const { container } = render(UseTypedListGood); expect(container.querySelector('[data-testid="row"]')?.textContent).toBe('Tea — $2');});✓ a correctly-typed row snippet renders each itemและ row snippet ที่ type ผิดรูปแบบเป็น type error จริงที่ถูกจับได้ — ไม่ใช่เรื่องเซอร์ไพรส์ตอน runtime:
<!-- @expect-error Type '{ name: string; price: number; }[]' is not assignable to type '{ wrongShape: boolean; }[]'. Property 'wrongShape' is missing in type '{ name: string; price: number; }' but required in type '{ wrongShape: boolean; }'. --><script lang="ts"> import TypedList from './TypedList.svelte'; let products = [{ name: 'Tea', price: 2 }];</script><TypedList data={products}> {#snippet row(item: { wrongShape: boolean })} <li>{item.wrongShape}</li> {/snippet}</TypedList>svelte-check บนไฟล์นั้นรายงานตรงตัวนี้ (ยืนยันกับ probe ของคอร์สนี้ Svelte 5.57.1 + TypeScript):
Type '{ name: string; price: number; }[]' is not assignable to type '{ wrongShape: boolean; }[]'. Property 'wrongShape' is missing in type '{ name: string; price: number; }' but required in type '{ wrongShape: boolean; }'.ถ้าไม่มี Snippet<[T]> row snippet ที่ mismatch จะโผล่แค่เป็นค่า undefined หรือ crash ตอน runtime ข้างใน body ของ snippet เท่านั้น — การ type มันเปลี่ยนสิ่งนั้นให้เป็นการจับได้ตอน compile
$props() rest, renaming, defaults — และ $props.id() สำหรับ id ที่ SSR-safe
หัวข้อที่มีชื่อว่า “$props() rest, renaming, defaults — และ $props.id() สำหรับ id ที่ SSR-safe”การ destructure $props() สำหรับ prop ที่เป็น data (default, renaming, rest ด้วย {...rest}) และ type ด้วย Props interface มีสอนครบใน Props & $bindable — $props() call เดียวกันนี้แหละที่ให้ snippet prop (children, header, row) ที่บทนี้พูดถึง
rune ตระกูล $props อีกตัวที่สำคัญเฉพาะกับ markup ที่คุณส่งลง: $props.id() (เพิ่มใน 5.20) สร้าง id ที่ unique ต่อ component instance ปัจจุบัน และที่สำคัญคือ สอดคล้องกันระหว่าง server กับ client ตอน hydration — ตรงกับที่คุณต้องการเพื่อผูก <label for> เข้ากับ <input id> โดยไม่ต้อง hardcode string ที่จะชนกันถ้า component ถูกใช้สองครั้งในหน้าเดียวกัน
<script lang="ts"> const uid = $props.id();</script><label for="{uid}-name" data-testid="label">Name</label><input id="{uid}-name" data-testid="input" /><span data-testid="uid">{uid}</span>import { test, expect } from 'vitest';import { render } from '@testing-library/svelte';import LabeledField from './LabeledField.svelte';
test('$props.id() is unique per component instance and links label to input', () => { const first = render(LabeledField); const uid = first.container.querySelector('[data-testid="uid"]')!.textContent; const label = first.container.querySelector('label')!; const input = first.container.querySelector('input')!; expect(label.getAttribute('for')).toBe(`${uid}-name`); expect(input.id).toBe(`${uid}-name`);
// A second instance on the same page gets a DIFFERENT id — no collision. const second = render(LabeledField); const uid2 = second.container.querySelector('[data-testid="uid"]')!.textContent; expect(uid2).not.toBe(uid);});✓ $props.id() is unique per component instance and links label to input<LabeledField> สองตัวในหน้าเดียวกันได้ uid ต่างกัน (c1, c2 ใน probe นี้ พิสูจน์แล้วด้านบน) — ถ้า hardcode id="name" จะได้ DOM id ซ้ำและทำให้การผูก label ของ instance ที่สองพัง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”snippet prop ก็แค่ prop ธรรมดาที่ value ของมันบังเอิญเป็น function แบบ “render markup นี้” — children, header, row ไม่ใช่ syntax พิเศษ มันคือชื่อที่คุณเลือกเอง type ได้เหมือน prop อื่น ๆ (Snippet / Snippet<[T]>) การส่ง UI ลงกับส่ง data ลงเป็นกลไกเดียวกัน ($props()) สิ่งที่ต่างกันคือแค่ type ของ value ที่ส่งไป two-way binding ($bindable()) เป็นกลไกแยกต่างหากที่ตั้งใจให้ opt-in ซ้อนอยู่บนนั้น — ไม่มีอะไรใน snippet prop หรือ data prop ธรรมดาที่บ่งบอกว่ามันมีสิ่งนั้นอยู่
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
children/named snippet prop | wrapper component ใช้ซ้ำได้โดยไม่ต้องรู้เนื้อหาข้างใน | child ต้องจำ {@render} snippet prop ทุกตัวที่รับมา ไม่งั้น markup นั้นจะไม่โผล่เลยแบบเงียบ ๆ |
Snippet<[T]> + generic ของ component | รูปแบบ row/data ที่ไม่ตรงกันเป็น compile error พิสูจน์แล้วด้านบน | ป้องกันได้แค่เท่าที่ generic แข็งแรง — snippet prop ที่ไม่ type หรือเป็น any ไม่ได้รับการป้องกันเลย |
$props.id() | id ที่ไม่ชนกันและ hydration-safe โดยไม่ต้องจดเอง พิสูจน์แล้วด้านบน | ใช้ได้แค่ top level ของ component เท่านั้น (เรียกแบบมีเงื่อนไขหรือข้างใน function ไม่ได้) |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “snippet param ที่ type หลวม ๆ (หรือไม่ type เลย) ปลอดภัยพอ ๆ กับ
Snippet<[T]>” ไม่จริง — พิสูจน์แล้วด้านบน มีแค่เวอร์ชันที่ type ด้วย generic ถูกต้องเท่านั้นที่เปลี่ยนความไม่ตรงกันของ shape ให้เป็น compile error ที่จับได้ แทนที่จะเป็นเซอร์ไพรส์undefinedตอน runtime - “string ที่ดูเหมือน unique ใช้เป็น
idของ label/input ได้เสมอ ตราบใดที่ไม่ใช้ component ซ้ำ” hardcoded id พังทันทีที่ component ถูกใช้ซ้ำในหน้าเดียวกัน —$props.id()พิสูจน์แล้วด้านบนว่าแจก id ต่างกันต่อ instance โดยอัตโนมัติ รวมถึงข้าม server/client ตอน hydration - “ถ้า child ไม่ทำเครื่องหมาย prop เป็น
$bindable()การbind:ของ parent จะ throw ทันที” ไม่จริงในเวอร์ชันที่ติดตั้งอยู่นี้ — ดูพฤติกรรมจริงที่ไม่ตรงกับที่คาดในส่วนอ่านโค้ดที่ AI เขียน ถัดไป
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลารีวิว component ที่ generate มาซึ่งส่ง UI ลง ให้เช็ค:
children(หรือ named snippet prop ใด ๆ) ถูก render ด้วย{@render}ที่ไหนสักที่จริงไหม หรือรับมาแล้วทิ้งเงียบ ๆ?- render-prop snippet (
rowในรูปแบบด้านบน) มี type กว้างเกินไป (anyหรือไม่ type เลย) จนความไม่ตรงกันของ shape จริง ๆ จะไม่ถูกสังเกตจนกว่าจะถึง runtime ไหม? - คู่
id/forของฟอร์มถูก hardcode เป็น string literal แทนที่จะสร้างจาก$props.id()เสี่ยงชนกันทันทีที่ component โผล่สองครั้งในหน้าเดียวไหม? - component
bind:เข้า prop ของ child โดยสมมติว่า Svelte จะ error ดัง ๆ ถ้า prop นั้นไม่ใช่$bindable()ไหม? พิสูจน์ด้านล่าง: ใน Svelte 5.57.1 ที่ยืนยันของคอร์สนี้ มัน ไม่ error — sync แค่ไม่เกิดขึ้นเลยแบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
<script>let { value } = $props(); // NOT $bindable()</script><input bind:value={value} />เฉลย
โค้ดนี้ดูเหมือน input wrapper แบบ bind สองทางปกติ และ parent ที่ทำ <Field bind:value={name} /> ก็ไม่ throw หรือ warn เลย — render ได้ปกติ แต่ value ตรงนี้ไม่เคยถูกทำเครื่องหมาย $bindable() ยืนยันกับ probe ของคอร์สนี้ (Svelte 5.57.1): การพิมพ์ใน input update value ที่เป็นตัวแปร local ของ input เอง (เพราะ bind:value ข้างใน Field เป็น two-way binding จริงเข้าตัวแปร local) แต่ name ของ parent ไม่เคยเปลี่ยนเลย — ไม่มี error ไม่มี console warning ไม่มีอะไรเลย การ sync สองทางแค่ไม่ข้ามขอบเขต component ไปเลยแบบเงียบ ๆ ซึ่งแย่กว่า error เสียอีกเพราะไม่มีอะไรบอกว่ามันพัง วิธีแก้: let { value = $bindable() } = $props(); ใน child ตามรูปแบบใน Props & $bindable