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

Slots & Composition

component มักต้องห่อ content ที่ยังไม่รู้ล่วงหน้า markup ที่ parent วางไว้ระหว่าง tag ของ component จะถูก render ตรงที่ <slot /> อยู่ — นั่นคือ default slot

src/components/Panel.astro
---
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>

default slot อันเดียวไม่พอสำหรับ layout จริง ๆ named slot ให้ช่องที่มีป้ายชื่อหลายช่อง component ประกาศ <slot name="x" /> ส่วน parent เติมช่องเหล่านั้นด้วย slot="x" บน child

src/components/Shell.astro
<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:

src/components/FragmentNamedSlot.astro
<header><slot name="header" /></header>
<main><slot /></main>
src/components/FragmentNamedSlotHost.astro
---
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>

<slot> เก็บ content เริ่มต้น ที่จะ render เฉพาะตอน parent ไม่ส่งอะไรมาให้ slot นั้นได้

src/components/FallbackButton.astro
<button class="btn"><slot>Click me</slot></button>
src/components/FallbackHost.astro
---
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>

ภายใน 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 ว่างออกมา

<slot> เดี่ยว ๆ ส่งต่อแค่ markup — มันส่งค่าให้ parent ไป render ไม่ได้ สำหรับกรณีนั้น parent ส่งfunctionเป็น content ของ slot แทน แล้ว component เรียก Astro.slots.render('default', args) พร้อม argument อะไรก็ตามที่อยากเปิดเผย (item จาก loop, state ที่คำนวณแล้ว) — แนวคิดเดียวกับ “render prop” / “scoped slot” ใน Vue กับ Svelte:

src/components/ScopedChild.astro
---
const rendered = await Astro.slots.render('default', ['scoped-arg']);
---
<div id="scoped" set:html={rendered} />
src/components/ScopedParent.astro
---
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

layout ที่ห่อ layout อีกตัวต้อง relay slot ที่มันได้รับ ไม่ใช่แค่ body ของตัวเอง — <slot name="x" slot="x" /> อ่านจาก slot ชั้นนอกแล้วเสนอมันต่อทันทีในฐานะ slot ชื่อเดียวกันของ component ชั้นใน:

src/layouts/InnerLayout.astro
<header><slot name="head" /></header>
<main><slot /></main>
src/layouts/OuterLayout.astro
---
import InnerLayout from './InnerLayout.astro';
---
<InnerLayout>
<slot name="head" slot="head" />
<slot />
</InnerLayout>
src/pages/page-using-outer.astro
---
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> เป็น 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 mistake
export default function ReactBoxWithSlot() {
return <div class="react-box"><slot /></div>;
}
src/components/ReactBoxWithSlotHost.astro
---
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:

src/components/ReactBox.tsx
export default function ReactBox({ children }: { children?: any }) {
return <div class="react-box">{children ?? 'no children received'}</div>;
}
src/pages/react-box-host.astro
---
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 คือ component ที่นิยาม shell ของหน้า — <html>, <head>, nav, footer — พร้อม <slot /> สำหรับ body ของหน้า ทุกหน้าห่อ content ของตัวเองใน layout ดังนั้น shell อยู่ที่เดียว

src/layouts/BaseLayout.astro
---
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>
src/pages/about.astro
---
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
layout คือ shell ที่มี slot; หน้าต่าง ๆ เติม slot

ด้วย 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 ตัดสินใจว่าจะใส่อะไรข้างใน

  • 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 ในโค้ดนี้

src/components/UserCard.tsx
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 ส่งมาให้

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
src/components/FallbackButton.test.ts
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');
});
});
คุณทำจุดฉีดมากกว่าหนึ่งจุดใน component ยังไง?
พิสูจน์ด้วย Container API แล้ว <FallbackButton /> ที่ไม่มี children เลย render อะไร?
---
// src/components/FallbackButton.astro
---
<button><slot>Click me</slot></button>
component เช็คว่า slot ได้รับ content ยังไง?
การใช้งานหลักของ slot ในไซต์ Astro จริง ๆ คืออะไร?
พิสูจน์ด้วย build จริง เกิดอะไรขึ้นเมื่อ markup ของ Astro ถูกส่งเป็น children ให้ component นี้?
// src/components/ReactBoxWithSlot.tsx
export default function ReactBoxWithSlot() {
  return <div class="react-box"><slot /></div>;
}
หน้าที่ใช้ OuterLayout ยังส่ง slot ชื่อ head มาด้วย เกิดอะไรขึ้นกับมัน พิสูจน์แล้วข้างบน?
---
// src/layouts/OuterLayout.astro
import InnerLayout from './InnerLayout.astro';
---
<InnerLayout>
  <slot />
</InnerLayout>
Astro.slots.render('default', ['scoped-arg']) เปิดใช้อะไรได้ พิสูจน์แล้วในบทนี้?