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

Passing UI & Children

เมื่อคุณวาง markup ระหว่าง tag ของ component Svelte ส่งมันเข้า component เป็น snippet prop ชื่อ children component render มันด้วย {@render children()}

Card.svelte
<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"]
markup ที่ nest เข้ามาถึงเป็น children snippet

สำหรับ component ที่มีจุดแทรกหลายจุด (header, body, footer) ส่ง named snippet เป็น prop นิยามมันใน parent ด้วย {#snippet name()} ข้างใน tag ของ component แล้ว child จะได้แต่ละอันเป็น prop

Modal.svelte
<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 ได้ component จึงส่งข้อมูล ออก ไปให้ markup ที่ parent เตรียมไว้ได้ — pattern ที่ React เรียกว่า render prop child เรียก snippet พร้อมค่าหนึ่ง; snippet ของ parent รับค่านั้น:

List.svelte
<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

ด้วย 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:

src/lib/TypedList.svelte
<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 ต้องตรงกัน:

src/lib/UseTypedListGood.svelte
<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>
src/lib/typedlist.test.ts
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

การ 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 ถูกใช้สองครั้งในหน้าเดียวกัน

src/lib/LabeledField.svelte
<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>
src/lib/propsid.test.ts
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 ที่สองพัง

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 propwrapper 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 เขียน ถัดไป

เวลารีวิว 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 ในโค้ดนี้

Field.svelte
<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

component รับ markup ที่ nest ระหว่าง tag ของมันอย่างไร?
ให้จุดแทรกที่มีชื่อหลายจุดกับ component ได้อย่างไร?
component Svelte ส่งข้อมูลออกไปให้ markup ที่ parent เตรียมไว้ (render prop) อย่างไร?
Snippet<[T]> ตรงนี้แปลว่าอะไร?
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';
  let { data, row }: { data: T[]; row: Snippet<[T]> } = $props();
</script>
$props.id() การันตีอะไร?
parent ทำ <Field bind:value={name} /> เกิดอะไรขึ้นจริง (ยืนยันกับ Svelte 5.57.1)?
<!-- Field.svelte -->
<script>
  let { value } = $props(); // NOT $bindable()
</script>
<input bind:value={value} />