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

The Component Model

Astro component คือไฟล์ .astro ที่คุณ import และใช้ในอีกไฟล์ได้ รับ props เข้ามาแล้วคืน HTML ออกไป เพราะ render บน server component จึงเป็น function จาก props ไปเป็น markup — ไม่มี lifecycle ฝั่ง client

src/components/Card.astro
---
interface Props {
title: string;
href: string;
}
const { title, href } = Astro.props; // props arrive on Astro.props
---
<a class="card" href={href}>
<h3>{title}</h3>
</a>

การใช้งาน:

---
import Card from '../components/Card.astro';
---
<Card title="Hello" href="/hello" />

props เข้ามาผ่าน Astro.props และคุณกำหนด type ของ props ด้วยการประกาศ interface Props ใน frontmatter — Astro ใช้ interface นี้ type-check การใช้งาน ที่นี่ไม่มี useState component รันครั้งเดียวเพื่อผลิต HTML

ลืม prop ที่จำเป็นไปเป็น type error จริงที่เช็คได้ — ไม่ใช่เรื่องเซอร์ไพรส์ตอน runtime พิสูจน์ด้วย astro check จริงกับ Card component ตัวเดียวกันข้างบน เรียกโดยมีแค่ title:

---
// @expect-error deliberately omits the required `href` prop, proven below
import Card from '../components/Card.astro';
---
<Card title="Hello" />
$ npx astro check
src/pages/card-missing-prop.astro:4:2 - error ts(2322): Type '{ title: string; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'href' is missing in type '{ title: string; }' but required in type 'Props'.
<Card title="Hello" />
~~~~

นั่นคือประโยชน์เชิงปฏิบัติของการกำหนด type ให้ props เลย: prop ที่หายไป (หรือ type ผิด) ถูกจับได้ก่อนที่หน้าจะ render เลยด้วยซ้ำ ตรงจุดที่เรียกใช้เป๊ะ ๆ

ถ้า component เป็น function จาก props ไปเป็น HTML จริง ๆ โดยไม่มี state ซ่อนอยู่ การเรียกมันสองครั้งด้วย props เดียวกันต้องได้ output เดียวกันเป๊ะ — ไม่ต้องมี client ไม่ต้องมี browser แค่ render เฉย ๆ Astro Container API (experimental_AstroContainer พูดถึงเต็ม ๆ ในบท testing) render component เป็น string แบบแยกตัว ทำให้เทสแบบนี้ทำได้ตรง ๆ:

src/__tests__/card.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from '../components/Card.astro';
test('Card is a pure function of props: same props, same output', async () => {
const container = await AstroContainer.create();
const a = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } });
const b = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } });
expect(a).toBe(b);
expect(a).toContain('Hello');
expect(a).toContain('href="/hello"');
});
$ npx vitest run src/__tests__/card.test.ts
✓ src/__tests__/card.test.ts (1 test) 1 passed

นอกเหนือจาก interface Props ที่เขียนเอง astro/types export helper สามตัวสำหรับ pattern ของ props ที่เจอบ่อยมาก — แต่ละตัว verify ตรง ๆ จาก types.d.ts ของ [email protected] ที่ติดตั้งจริง:

  • HTMLAttributes<Tag> — attribute จริงในตัวของ tag HTML นั้น ๆ ทำให้ wrapper component รับทุกอย่างที่ element จริงรับได้ (onclick, aria-*, data-*, …) โดยไม่ต้องประกาศเองใหม่ทีละตัว:
src/components/Button.astro
---
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'button'> {
variant?: 'primary' | 'secondary';
}
const { variant = 'primary', ...attrs } = Astro.props;
---
<button class={`btn btn-${variant}`} {...attrs}>
<slot />
</button>
  • Polymorphic<P> — กำหนด type ให้ component ที่ render เป็น tag อื่นได้ผ่าน prop as (<Heading as="h2"> ที่ต้องการ attribute จริงของ h2 ไม่ใช่ element ตายตัว):
src/components/Heading.astro
---
import type { HTMLTag, Polymorphic } from 'astro/types';
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }>;
const { as: Tag = 'h2' as const, ...attrs } = Astro.props;
---
<Tag {...attrs}><slot /></Tag>
  • ComponentProps<T> — ดึง type ของ props จาก function component (framework island) มีประโยชน์เวลาอยากรับ “props อะไรก็ตามที่ SomeIsland รับได้” โดยไม่ต้อง import แล้วเขียน interface Props ซ้ำ:
import type { ComponentProps } from 'astro/types';
import type Counter from '../components/Counter.tsx';
type CounterProps = ComponentProps<typeof Counter>;
flowchart LR
  a["HTMLAttributes<'button'>"] --> u1["สืบ attribute ของ element จริง"]
  b["Polymorphic<{ as: Tag }>"] --> u2["กำหนด type ให้ component แบบ as=..."]
  c["ComponentProps<typeof Fn>"] --> u3["reuse prop type ของ function component"]
สามวิธีกำหนด type ให้ props นอกเหนือจาก interface ธรรมดา

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

src/components/Panel.astro
---
const { heading } = Astro.props;
---
<section class="panel">
<h2>{heading}</h2>
<slot /> <!-- children go here -->
</section>
<Panel heading="Notes">
<p>Any markup here lands in the slot.</p>
</Panel>

ถ้าต้องการจุดแทรกมากกว่าหนึ่งจุด ใช้ named slots:

Layout.astro
<header><slot name="header" /></header>
<main><slot /></main> <!-- the default slot -->
<footer><slot name="footer" /></footer>
<Layout>
<h1 slot="header">Title</h1>
<p>Body goes in the default slot.</p>
<small slot="footer">© 2026</small>
</Layout>
flowchart LR
  parent["Parent ส่ง markup
ระหว่าง tag"] --> slot["component render มัน
ที่ <slot />"]
  slot --> out["HTML ที่ประกอบแล้ว"]
Slots ให้ parent แทรก markup เข้าไปใน component

Slots คือกระดูกสันหลังของ layouts: Layout.astro นิยามเชลล์ของหน้า (head, nav, footer) พร้อม <slot /> สำหรับ body ของหน้า และทุกหน้าก็ห่อ content ของตัวเองไว้ใน layout นั้น

tag <style> ภายใน .astro component เป็น scoped ให้ component นั้นโดย default — Astro เขียน selector ใหม่เพื่อไม่ให้ style รั่วออกไปชนกับ component อื่น

<h1>Title</h1>
<style>
/* This h1 rule applies ONLY to this component's h1, not others on the page. */
h1 { color: rebeccapurple; }
</style>

คุณได้ CSS แบบ component-local ด้วย selector ธรรมดา — ไม่มี CSS-in-JS ไม่ต้องมีพิธีตั้งชื่อแบบ BEM เมื่อคุณ อยาก ให้ rule ใช้แบบ global คุณ opt out ด้วย <style is:global> (พูดถึงในบท styles)

.astro component snippet ที่ AI generate มา มักพกสมมติฐานจาก client framework มาด้วย เช็คสิ่งเหล่านี้:

  • มันเรียก useState, useEffect, หรือ hook อื่นที่ top level ของ frontmatter หรือเปล่า? ไม่มีอะไรแบบนั้นอยู่ใน script ฝั่ง server-only ของ component .astro เลย — hook พวกนั้นอยู่ใน framework component ที่ hydrate แล้ว (island) เท่านั้น ไม่ใช่ไฟล์ .astro
  • มันประกาศ Props แต่ template destructure field ที่ไม่เคยอยู่ใน interface หรือเปล่า? astro check จับ field ที่จำเป็น หายไป ได้ (พิสูจน์แล้วข้างบนด้วย Card) — แต่ field แบบ optional ที่พิมพ์ผิดใน template (hraf แทน href) จะอ่าน undefined เงียบ ๆ ที่ระดับ template เว้นแต่ field นั้นจะถูกอ้างชื่อผิดใน Astro.props ด้วย
  • component แบบ Polymorphic/prop as hard-code tag ที่ render ไว้ในตัว logic ของมันเองที่ไหนหรือเปล่า (เช่น เช็ค as === 'button' เพื่อตัดสินใจอะไรบางอย่าง) ซึ่งขัดกับจุดประสงค์ของการทำให้ polymorphic?
  • มันใช้ <slot /> ใน component ที่ render เป็น framework island (เช่น client:load) หรือเปล่า? named/default slot เป็น feature ของ Astro template — “สิ่งที่เทียบเท่า slot” ของ framework component คือ children/named props ไม่ใช่ <slot />
  • block <style> แบบ scoped ถูกสันนิษฐานว่ามีผลกับ markup ของ component ลูก หรือเปล่า (เช่น style <h3> ภายในของ <Card> จาก parent)? scoped style มีผลแค่กับ selector ที่ match element ใน ไฟล์เดียวกัน เท่านั้น การเอื้อมเข้าไปใน child ต้องใช้ :global() หรือ class ที่ขับเคลื่อนด้วย prop

🐛 หา bug ในโค้ดนี้

src/components/UserBadge.astro
---
interface Props {
name: string;
role?: 'admin' | 'member';
}
const { name, role = 'member' } = Astro.props;
---
<span class={`badge badge-${role}`}>{nam}</span>
เฉลย

template อ่าน {nam} — พิมพ์ผิดจาก {name} — และมันเป็น astro check error จริง แต่ไม่ใช่ error แบบ props-shape ที่คาดไว้จากกรณี “prop หายไป” ข้างบน พิสูจน์กับ snippet นี้เป๊ะ ๆ:

src/pages/userbadge-test.astro:8:38 - error ts(2552): Cannot find name 'nam'. Did you mean 'name'?
<span class={`badge badge-${role}`}>{nam}</span>
~~~

nam ไม่ใช่ identifier ที่ถูกประกาศไว้เลย TypeScript จึง report ts(2552) (“cannot find name” พร้อมคำแนะนำ “did you mean”) — ไม่ใช่ ts(2322) แบบ interface Props ไม่ตรงกันเหมือนข้างบน เป็น bug คนละประเภทจากกรณี prop ที่จำเป็นหายไป: อันนั้นคือ props ผิด ส่วนอันนี้คือพิมพ์ผิดใน template ที่อ้างถึงสิ่งที่ไม่เคยถูก destructure ทั้งคู่เป็น astro check error จริง แต่คนละเหตุผล — นี่แหละคือเหตุผลที่ควรอ่านให้ลึกกว่า “astro check ผ่านไหม” ไปถึง “astro check ผ่าน ด้วยเหตุผลที่คาดไว้ ไหม”

คิดถึง .astro component เหมือน pure server-side template function: render(props) -> html props ไหลเข้ามาทิศทางเดียว ผ่าน Astro.props ไม่มีอะไรไหลกลับออกไปนอกจาก markup ที่ render แล้ว Slots ก็แค่ input อีกแบบหนึ่ง — markup แทนที่จะเป็นข้อมูล — ส่งแบบตำแหน่ง (default) หรือแบบชื่อ ไม่มี lifecycle ให้คิดเพราะไม่มี “ทีหลัง”: function รันครั้งเดียว คืน HTML แล้วจบ Container API test ข้างบนคือหลักฐานตรง ๆ: input เดียวกัน output เดียวกัน ทุกครั้ง

.astro component Framework island (.tsx/.vue/…)
รันที่ไหน server เท่านั้น (function ของ props ไม่มี lifecycle) server (render แรก) และ client (ถ้า hydrate)
State ไม่มี — re-run ใหม่จากศูนย์ทุกครั้งที่ render useState/reactive state คงอยู่ข้าม re-render
Slots <slot /> / named slots children / props ตาม convention ของ framework
Styling <style> แบบ scoped มีในตัว, selector ธรรมดา convention ของ framework เอง (CSS modules, styled-components, …)
กำหนด type ให้ props interface Props หรือ HTMLAttributes/Polymorphic/ComponentProps จาก astro/types convention การกำหนด type props ของ framework เอง
  • “prop ที่จำเป็นหายไปจะเป็น undefined ตอน runtime จับได้ (ถ้าจับได้) ตอนหน้าพัง” พิสูจน์แล้วข้างบน: astro check จับได้ตรงจุดที่เรียกใช้เป๊ะ ๆ ก่อนที่หน้าจะ render เลยด้วยซ้ำ — ts(2322), “Property ‘href’ is missing”
  • “.astro component เก็บ state ข้าม render ได้ เหมือน class ที่มี instance field” เก็บไม่ได้ — พิสูจน์ด้วย Container API test ข้างบน: props เดียวกันได้ output เหมือนกันทุก byte เสมอ เพราะ component ถูก re-run ใหม่จากศูนย์ทุกครั้ง ไม่มีอะไรคงอยู่เลย
  • “HTMLAttributes<'button'> ก็แค่ { [key: string]: any }” มันคือ attribute set จริงและเฉพาะเจาะจงของ tag นั้น (verify ตรง ๆ จาก astro/types.d.ts) — ส่ง attribute ที่ไม่มีจริงบน <button> (เช่น href ตรง ๆ) ก็ยังเป็น type error อยู่ดี
Astro component รับ props ของตัวเองยังไง?
slot ใช้ทำอะไร?
โดย default block <style> ใน .astro component มีผลที่ไหน?
คุณจะมีจุดแทรกหลายจุดใน component เดียวยังไง?
ใช้เป็น <Card title="Hello" /> (ไม่มี href) astro check จริงจะ report อะไร?
---
// src/components/Card.astro
interface Props {
  title: string;
  href: string;
}
const { title, href } = Astro.props;
---
<a class="card" href={href}><h3>{title}</h3></a>
test นี้ผ่านจริง มันพิสูจน์อะไรเกี่ยวกับ .astro component?
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
const container = await AstroContainer.create();
const a = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } });
const b = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } });
// expect(a).toBe(b);