Slots & Composition
ทวน slot: การฉีด markup
หัวข้อที่มีชื่อว่า “ทวน slot: การฉีด markup”component มักต้องห่อ content ที่ยังไม่รู้ล่วงหน้า markup ที่ parent วางไว้ระหว่าง tag ของ component จะถูก render ตรงที่ <slot /> อยู่ — นั่นคือ default slot
---const { heading } = Astro.props;---<section class="panel"> <h2>{heading}</h2> <slot /></section><Panel heading="Notes"> <p>This paragraph lands in the default slot.</p></Panel>named slot: จุดฉีดหลายจุด
หัวข้อที่มีชื่อว่า “named slot: จุดฉีดหลายจุด”default slot อันเดียวไม่พอสำหรับ layout จริง ๆ named slot ให้ช่องที่มีป้ายชื่อหลายช่อง component ประกาศ <slot name="x" /> ส่วน parent เติมช่องเหล่านั้นด้วย slot="x" บน child
<header><slot name="header" /></header><main><slot /></main> <!-- default slot --><footer><slot name="footer" /></footer><Shell> <h1 slot="header">Page title</h1> <p>Body content fills the default slot.</p> <small slot="footer">© 2026</small></Shell>slot="x" บน named slot ยังใช้กับ <Fragment> ได้ด้วย — มีประโยชน์เมื่อ named slot เดียวต้องเก็บ sibling elementหลายตัวโดยไม่เพิ่ม wrapper node:
<header><slot name="header" /></header><main><slot /></main>---import FragmentNamedSlot from './FragmentNamedSlot.astro';---<FragmentNamedSlot> <Fragment slot="header"> <h1>Title</h1> <p>Subtitle</p> </Fragment> <p>Body</p></FragmentNamedSlot>พิสูจน์ด้วย Container API — ทั้งสอง element ข้างใน Fragment ไปลง slot header โดยไม่มี wrapper element เพิ่มเลย:
<header><h1>Title</h1><p>Subtitle</p></header><main><p>Body</p></main>fallback content พิสูจน์จริงทั้งกรณีส่งกับไม่ส่ง
หัวข้อที่มีชื่อว่า “fallback content พิสูจน์จริงทั้งกรณีส่งกับไม่ส่ง”<slot> เก็บ content เริ่มต้น ที่จะ render เฉพาะตอน parent ไม่ส่งอะไรมาให้ slot นั้นได้
<button class="btn"><slot>Click me</slot></button>---import FallbackButton from './FallbackButton.astro';---<FallbackButton /><FallbackButton>Save</FallbackButton>Render ผ่าน Container API — instance แรก (ไม่ส่ง children มา) ตกไปใช้ fallback “Click me” ส่วน instance ที่สอง (ส่ง children มา) แทนที่ด้วย “Save”:
<button class="btn">Click me</button><button class="btn">Save</button>เช็คว่ามี slot ถูกส่งมาไหม
หัวข้อที่มีชื่อว่า “เช็คว่ามี slot ถูกส่งมาไหม”ภายใน component คุณถามได้ว่า slot ได้รับ content หรือไม่ ผ่าน Astro.slots.has แล้ว render โครงสร้างแบบมีเงื่อนไข
---const hasAside = Astro.slots.has('aside');---<div class:list={["layout", { "has-aside": hasAside }]}> <main><slot /></main> {hasAside && ( <aside><slot name="aside" /></aside> )}</div>แบบนี้ทำให้ component ปรับ markup ได้ — เช่น render wrapper aside เฉพาะตอนมี aside จะโชว์จริง ๆ เพื่อไม่ให้ปล่อย element ว่างออกมา
Scoped-style slot: Astro.slots.render(name, args)
หัวข้อที่มีชื่อว่า “Scoped-style slot: Astro.slots.render(name, args)”<slot> เดี่ยว ๆ ส่งต่อแค่ markup — มันส่งค่าให้ parent ไป render ไม่ได้ สำหรับกรณีนั้น parent ส่งfunctionเป็น content ของ slot แทน แล้ว component เรียก Astro.slots.render('default', args) พร้อม argument อะไรก็ตามที่อยากเปิดเผย (item จาก loop, state ที่คำนวณแล้ว) — แนวคิดเดียวกับ “render prop” / “scoped slot” ใน Vue กับ Svelte:
---const rendered = await Astro.slots.render('default', ['scoped-arg']);---<div id="scoped" set:html={rendered} />---import ScopedChild from './ScopedChild.astro';---<ScopedChild> {(arg: string) => <em>arg was {arg}</em>}</ScopedChild>พิสูจน์ผ่าน Container API — string 'scoped-arg' ที่ส่งเป็น argument ตัวที่สองของ Astro.slots.render ไปถึง parameter arg ของ function child:
<div id="scoped"><em>arg was scoped-arg</em></div>ใช้แบบนี้เมื่อ component ต้องส่งข้อมูลที่คำนวณแล้วให้ parent และ ให้ parent ตัดสินใจ markup เอง — เช่น component แถวตารางที่เปิดเผยข้อมูลของแถวให้ custom cell renderer
การ relay slot ผ่าน layout ที่ซ้อนกัน
หัวข้อที่มีชื่อว่า “การ relay slot ผ่าน layout ที่ซ้อนกัน”layout ที่ห่อ layout อีกตัวต้อง relay slot ที่มันได้รับ ไม่ใช่แค่ body ของตัวเอง — <slot name="x" slot="x" /> อ่านจาก slot ชั้นนอกแล้วเสนอมันต่อทันทีในฐานะ slot ชื่อเดียวกันของ component ชั้นใน:
<header><slot name="head" /></header><main><slot /></main>---import InnerLayout from './InnerLayout.astro';---<InnerLayout> <slot name="head" slot="head" /> <slot /></InnerLayout>---import OuterLayout from '../layouts/OuterLayout.astro';---<OuterLayout> <link slot="head" rel="canonical" href="https://example.com/x" /> <p>page body</p></OuterLayout>พิสูจน์ตั้งแต่ต้นจนจบ — <link> ที่อยู่ใน slot head และ <p> ที่อยู่ใน default slot ของหน้า ไปถึง InnerLayout ถูกต้องทั้งคู่ ลึกลงไปสองชั้น:
<header><link rel="canonical" href="https://example.com/x"></header><main><p>page body</p></main>ทำไม <slot> ใน framework component ไม่ทำงาน
หัวข้อที่มีชื่อว่า “ทำไม <slot> ใน framework component ไม่ทำงาน”<slot> เป็น syntax ของ Astro template compile โดย Rust compiler ของ Astro component React/Preact/Vue ถูก compile โดย compiler ของตัวมันเอง ซึ่งไม่รู้จัก <slot> ของ Astro เลย — ดังนั้นเขียนมันในไฟล์ .tsx/.jsx จะไม่ forward อะไรเลย มันจะ render เป็น native <slot> HTML element ที่เฉื่อยชา ตัวเปล่า (native <slot> tag ของ browser ที่ไม่มี style) และ markup ที่คุณส่งมาจริง ๆ จะไปโผล่ที่อื่นแทน พิสูจน์ด้วย build จริง:
// src/components/ReactBoxWithSlot.tsx — a common AI-generated mistakeexport default function ReactBoxWithSlot() { return <div class="react-box"><slot /></div>;}---import ReactBoxWithSlot from './ReactBoxWithSlot.tsx';---<ReactBoxWithSlot client:load> <p>This will NOT appear</p></ReactBoxWithSlot>markup ที่ build จริง (ตัด script bootstrap ของ island ออก) — <p> ที่ส่งมาจบลงเฉื่อยชาข้างใน <template> ไม่เคยอยู่ใน <div> ที่ component render เลย และ <slot></slot> ถูกทิ้งไว้เป็น native element เปล่า ๆ ตรงตัว:
<astro-island ... component-url=".../ReactBoxWithSlot.tsx" ssr client="load" ...> <div class="react-box"><slot></slot></div> <template data-astro-template><p>This will NOT appear</p></template></astro-island>วิธีที่ถูกต้องให้ framework component รับ markup ที่ Astro ส่งมาคือกลไก children ของframework ตัวมันเอง — {children} ใน React/Preact, <slot /> ใน SFC compiler ของ Vue เอง (คนละตัวกับ <slot> นี้) หรือ {$$slots.default?.()} ใน Svelte:
export default function ReactBox({ children }: { children?: any }) { return <div class="react-box">{children ?? 'no children received'}</div>;}---import ReactBox from '../components/ReactBox.tsx';---<ReactBox client:load> <p>Hello from Astro markup</p></ReactBox>พิสูจน์แล้ว — Astro serialize markup ที่ส่งมาเข้า wrapper <astro-slot> แล้วส่งให้ component เป็น prop children (ตัด script bootstrap ของ island ออก):
<astro-island ... component-url=".../ReactBox.tsx" ssr client="load" ...> <div class="react-box"><astro-slot><p>Hello from Astro markup</p></astro-slot></div></astro-island>slot สร้าง layout
หัวข้อที่มีชื่อว่า “slot สร้าง layout”นี่คือสิ่งที่ slot มีไว้ เพื่อ ในทางปฏิบัติ layout คือ component ที่นิยาม shell ของหน้า — <html>, <head>, nav, footer — พร้อม <slot /> สำหรับ body ของหน้า ทุกหน้าห่อ content ของตัวเองใน layout ดังนั้น shell อยู่ที่เดียว
---const { title } = Astro.props;---<html lang="en"> <head> <meta charset="utf-8" /> <title>{title}</title> <slot name="head" /> </head> <body> <nav>…site nav…</nav> <main><slot /></main> <footer>…site footer…</footer> </body></html>---import BaseLayout from '../layouts/BaseLayout.astro';---<BaseLayout title="About"> <link slot="head" rel="canonical" href="https://example.com/about" /> <h1>About us</h1> <p>Page-specific content in the default slot.</p></BaseLayout>flowchart TB layout["BaseLayout: html, head, nav, footer with slots"] --> page["Page fills the default slot with its body"] page --> head["Page fills the head slot with meta tags"] layout --> out["one composed HTML document"] page --> out head --> out
รูปแบบ composition
หัวข้อที่มีชื่อว่า “รูปแบบ composition”ด้วย slot คุณประกอบ component เล็ก ๆ ให้เป็นหน้าได้แบบเดียวกับที่ประกอบฟังก์ชัน เพราะทั้งหมด render บน server composition แทบไม่มีต้นทุนตอน runtime — เป็นแค่การประกอบ HTML เท่านั้น
| รูปแบบ | โครงสร้าง | ตัวอย่าง |
|---|---|---|
| Wrapper | default slot อย่างเดียว | Card — กล่องมีเส้นขอบรอบ body content ที่ parent ส่งมา |
| Container | default slot เก็บ child ที่ซ้ำ ๆ | Grid ที่ default slot เก็บ Card หลายตัว |
| Multi-region | named slot หลายตัว | Section ที่มี named slot header กับ body |
| Shell | named + default slot อยู่บนสุดของ tree | Layout — head, default (body หน้า) และมักมี footer |
| Relay | slot ที่ถูกเสนอต่อให้ component ที่ซ้อนอยู่ข้างใน | layout ที่ห่อ layout อีกตัว — พิสูจน์แล้วข้างบนด้วย <slot name="x" slot="x" /> |
| Render prop | function ที่ส่งเป็น content ของ slot | component ที่เปิดเผยข้อมูลที่คำนวณแล้วผ่าน Astro.slots.render(name, args) — พิสูจน์แล้วข้างบน |
คุณเก็บแต่ละชิ้นให้โฟกัสแล้วให้ parent ตัดสินใจว่าจะใส่อะไรข้างใน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- framework component แบบ
.tsx/.jsxใช้<slot />โดยคาดว่า children ที่ Astro ส่งมาจะไปโผล่ตรงนั้นไหม? พิสูจน์แล้วข้างบน: มัน render เป็น native<slot>element ที่เปล่าและเฉื่อยชา ส่วน content จริงถูกทิ้งไว้ใน<template>ที่เฉื่อยชาแทน — ทางแก้คือกลไกchildrenของ framework เอง - layout ที่ห่อ layoutอีกตัวแค่ render
<slot />/<slot name="x" />สำหรับ body ของตัวเองเงียบ ๆ ทิ้ง named slot อื่นที่ผู้เรียกชั้นนอกส่งมาไหม? named slot ทุกตัวที่ layout ชั้นนอกไม่ relay ชัด ๆ (<slot name="x" slot="x" />) จะหายไป Astro.slots.has('x')ถูกเช็คก่อน render wrapper element สำหรับ slot นั้นไหม หรือ component render wrapper (<aside></aside>เปล่า) เสมอแม้ไม่มีอะไรส่งมา?- function ของ scoped slot ได้รับ argument ที่ component ส่งให้
Astro.slots.render(name, args)จริงไหม หรือ pattern function-as-child ถูกใช้แค่เพื่อ “ส่ง markup มา” (default slot ธรรมดาจะง่ายและชัดกว่า)?
🐛 หา bug ในโค้ดนี้
export default function UserCard({ user }: { user: { name: string } }) {return ( <div class="card"> <h2>{user.name}</h2> <slot /> </div>);}เฉลย
UserCard เป็นไฟล์ .tsx compile ด้วย JSX ของ framework เอง ไม่ใช่ Astro — <slot /> เดี่ยว ๆ ตรงนี้ไม่ใช่กลไก slot ของ Astro เลย พิสูจน์แล้วก่อนหน้านี้ในบทนี้: เขียน <slot /> ในไฟล์ framework component จะ render เป็น native <slot> HTML element ที่เฉื่อยชาและเปล่า ส่วน markup ที่ผู้เรียกวางไว้ระหว่าง tag <UserCard>...</UserCard> ถูก serialize เข้า <template> (หรือ <astro-slot> แล้วแต่วิธี consume) แทนที่จะไปโผล่ใน <div> นี้เลย ทางแก้คือรับ children เป็น prop แล้ว render {children} ตรงที่ <slot /> อยู่ตอนนี้ — นั่นคือกลไกที่ framework component ใช้จริง ๆ ในการรับ markup ที่ Astro ส่งมาให้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”slot คือช่องว่างที่ถูกเติมตรง call site ไม่ใช่ prop ที่ resolve ข้างใน component — parent ตัดสินใจ markup, component ตัดสินใจว่ามันไปอยู่ตรงไหน (และด้วย Astro.slots.has ตัดสินใจได้ด้วยว่าจะสร้างโครงสร้างอะไรรอบมันหรือเปล่า) สัญญานั้นมีอยู่แค่ข้างใน compiler ของ Astro เอง — ข้ามขอบไปยัง framework component แล้ว <slot> จะไม่มีความหมายอะไรเลย — พิสูจน์แล้วข้างบน มันเป็นแค่ HTML element ที่เฉื่อยชาตรงนั้น และกลไก children ของframework เองเข้ามาทำหน้าที่แทน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| default slot ธรรมดา | named slot | scoped slot (Astro.slots.render) |
|
|---|---|---|---|
| parent ควบคุม | markup | markup ไหนไปอยู่ตรงไหน | markup และรับข้อมูลกลับจาก component ด้วย |
| component ควบคุม | ไม่มีอะไรเกี่ยวกับ content | โครงสร้างรอบแต่ละ region ที่มีชื่อ | จะเปิดเผยข้อมูลอะไรให้ function ของ parent |
| ความซับซ้อน | ต่ำที่สุด | กลาง — ต้องบันทึกชื่อ slot แต่ละตัว | สูงที่สุด — parent เขียน function ไม่ใช่แค่ markup |
| เหมาะกับ | ห่อ region เดียว (body ของ Card) |
layout shell ที่มีหลาย region | component ที่คำนวณอะไรบางอย่างที่ parent ต้อง render ด้วย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
<slot />ทำงานเหมือนกันข้างใน React/Preact/Vue component เหมือนที่ทำในไฟล์.astro” พิสูจน์แล้วข้างบน: ข้างใน framework component มันเป็นแค่ native HTML<slot>element ที่เฉื่อยชาตรงตัว — กลไกchildren(หรือเทียบเท่า) ของ framework เองต่างหากที่รับ markup ที่ Astro ส่งมาจริง ๆ - “layout ที่ห่อ layout อีกตัวจะ forward slot ทุกตัวที่ผู้เรียกชั้นนอกส่งมาโดยอัตโนมัติ” พิสูจน์แล้วข้างบน: named slot แต่ละตัวต้องถูก relay ชัด ๆ ด้วย
<slot name="x" slot="x" />ไม่งั้นจะไม่มีทางไปถึง layout ชั้นใน - “
Astro.slots.has('x')เหมือนกับการเช็ค prop” มันสะท้อนว่ามีmarkupถูกส่งมาสำหรับชื่อ slot นั้นที่ call site หรือเปล่า แยกจาก prop ใด ๆ — component อาจมี prop แต่ไม่มี slotted content หรือกลับกันก็ได้ - “fallback content ข้างใน
<slot>ใช้ได้แค่กับ text” มันคือ markup ธรรมดา — element ไหนก็ตามที่วางระหว่าง<slot>กับ</slot>จะ render เมื่อ parent ไม่ส่งอะไรมา พิสูจน์แล้วข้างบนด้วย fallback button
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import FallbackButton from './FallbackButton.astro';
describe('slot fallback content', () => { it('renders the fallback when no children are passed', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(FallbackButton); expect(html).toContain('Click me'); });
it('renders the passed children instead of the fallback', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(FallbackButton, { slots: { default: 'Save' }, }); expect(html).toContain('Save'); expect(html).not.toContain('Click me'); });});