Styles & Scripts
scoped style เป็น default พิสูจน์จริง
หัวข้อที่มีชื่อว่า “scoped style เป็น default พิสูจน์จริง”บล็อก <style> ในไฟล์ .astro จะถูก scope ไว้เฉพาะ component นั้น โดยอัตโนมัติ Astro เขียน selector ของคุณใหม่ (เติม hash ต่อ component) ดังนั้น rule ของ h1 ตรงนี้จะไปเลอะ h1 ของอีก component ไม่ได้
---const { accent = '#883AEA' } = Astro.props;---<div class="card">Scoped card</div><style define:vars={{ accent }}> .card { border: 2px solid var(--accent); } h1 { color: red; }</style>ใช้ สองครั้ง ในหน้าเดียวกันด้วย props ต่างกัน (<ScopedCard accent="#ff0000" /> กับ <ScopedCard accent="#00ff00" />) build output จริง:
<style>.card[data-astro-cid-aoqubwov]{border:2px solid var(--accent)}h1[data-astro-cid-aoqubwov]{color:red}</style><div class="card" style="--accent: #ff0000;" data-astro-cid-aoqubwov>Scoped card</div><div class="card" style="--accent: #00ff00;" data-astro-cid-aoqubwov>Scoped card</div>พิสูจน์สองอย่างพร้อมกัน: selector .card ใน source กลายเป็น .card[data-astro-cid-aoqubwov] ใน CSS ที่ build ออกมา และทุก instance ที่ render มี attribute data-astro-cid-aoqubwov ตรงกัน — นั่นคือกลไก scoping เป็น attribute selector บวก attribute ที่ตรงกัน ไม่ใช่การเปลี่ยนชื่อ class และแม้ component ถูกใช้สองครั้ง CSS text ของ <style> block ของมันก็โผล่ใน stylesheet ของหน้าครั้งเดียว — Astro dedupe style ของ component ที่เหมือนกันข้าม instance ไม่ซ้ำ rule ต่อการใช้งาน
คุณเขียน selector ธรรมดาตรงไปตรงมา แล้วได้ CSS เฉพาะ component — ไม่ต้องมี naming convention ไม่ต้อง CSS-in-JS นี่คือ default ที่คุณจะใช้แทบทุกที่
ลองเลย: ดู hash ถูกเติมสดในเบราว์เซอร์ playground นี้โหลด @astrojs/compiler รุ่นคลาสสิกที่ทำมาให้รันในเบราว์เซอร์ได้ (ดู caveat ที่บทเรียน components/astro-syntax-deep — Rust compiler จริงที่ astro check/astro build ของคอร์สนี้รันอยู่ โหลดในเบราว์เซอร์ไม่ได้เลย) เมื่อสั่งให้ใช้ scoping strategy แบบ attribute มันให้ผลลัพธ์ตรงกับที่พิสูจน์ไว้ข้างบนเป๊ะ — attribute selector data-astro-cid-* ไม่ใช่การเปลี่ยนชื่อ class:
scoped style ของ .card compile สดให้ดู
------<style> .card { border: 2px solid var(--accent); } h1 { color: red; }</style><div class="card">Scoped card</div>กด Compile: JS ที่ compile แล้วในแผง output จะมี attribute data-astro-cid- ติดอยู่บน <div> และ suffix เดียวกันก็โผล่ใน CSS selector ด้วย — กลไกเดียวกับที่อธิบายไว้ข้างบนเป๊ะ แค่ผลิตสดแทนที่จะ paste จาก build ครั้งก่อน
opt out ด้วย is:global กับ :global() พิสูจน์จริง
หัวข้อที่มีชื่อว่า “opt out ด้วย is:global กับ :global() พิสูจน์จริง”บางครั้งคุณอยากให้ rule มีผลทั้งไซต์จริง ๆ — CSS reset, typography พื้นฐาน, theme เติม is:global เพื่อ opt ทั้งบล็อกออกจาก scoping:
<style is:global> .reset-me { margin: 0; }</style><style> p { color: blue; } :global(.markdown-body a) { color: green; }</style>CSS ที่ build จริง อยู่ติดกับ output ของ ScopedCard ที่ scoped ข้างบนในหน้าเดียวกัน:
.reset-me{margin:0}p[data-astro-cid-yididndw]{color:#00f}.markdown-body a{color:green}.reset-me (block is:global) ไม่มี data-astro-cid-* เลย — มีผลทุกที่ที่ใช้ class นี้ ทั้งไซต์ ส่วน p ในบล็อกที่สองที่ scoped ปกติ มี hash — scoping ใช้ทีละ rule ตามปกติ :global(.markdown-body a) ข้างในบล็อกที่ scoped เดียวกันนั้นคือ rule เดียวที่หนี scoping — พิสูจน์เคียงข้างกับ p ที่ scoped ใน <style> element เดียวกันเป๊ะ
ส่งค่าเข้า CSS ด้วย define:vars พิสูจน์จริง
หัวข้อที่มีชื่อว่า “ส่งค่าเข้า CSS ด้วย define:vars พิสูจน์จริง”ค่าใน frontmatter อยู่ฝั่ง server ส่วน CSS เป็น text นิ่ง ๆ เพื่อเชื่อมสองอย่างนี้ define:vars จะฉีดค่า JavaScript เข้าไปเป็น CSS custom property ออกมาเป็น attribute style แบบ inline จริง ๆ บน element — พิสูจน์แล้วข้างบน: <div class="card" style="--accent: #ff0000;" ...> เป็นวิธีสะอาด ๆ ในการทำให้สีหรือ spacing ของ component ปรับได้ผ่าน props โดยไม่ต้องมี JavaScript ฝั่ง client เลย
<script> tag: ถูก bundle ครั้งเดียวต่อหน้า ไม่ใช่ต่อ component instance
หัวข้อที่มีชื่อว่า “<script> tag: ถูก bundle ครั้งเดียวต่อหน้า ไม่ใช่ต่อ component instance”<script> ในไฟล์ .astro รันบน browser — แต่ Astro มองเป็น module: script จะถูก process, bundle, compile TypeScript และ resolve import เหมือน JS อื่น ๆ ใน build ของคุณ ที่สำคัญคือ เมื่อ component ที่มี <script> ถูกใช้หลายครั้งในหน้าเดียว script จะถูก emit ครั้งเดียว ไม่ใช่ครั้งละ instance:
<div class="counter">0</div><script> console.log('COUNTER_SCRIPT_RAN'); (window as any).__counterRuns = ((window as any).__counterRuns || 0) + 1;</script>ใช้สามครั้งในหน้าเดียว (<CounterScript /><CounterScript /><CounterScript />) build output จริง:
<div class="counter">0</div><script type="module">console.log(`COUNTER_SCRIPT_RAN`),window.__counterRuns=(window.__counterRuns||0)+1;</script><div class="counter">0</div><div class="counter">0</div>มี <div class="counter"> สามตัว (ต่อ instance) แต่มี <script type="module"> เพียงหนึ่งtag — ดังนั้น COUNTER_SCRIPT_RAN log ครั้งเดียวและ __counterRuns จบที่ 1 ไม่ใช่ 3 script ของ component initialize behavior ที่ใช้ร่วมกันของหน้า ไม่ใช่ behavior ต่อ instance ถ้าต้องการ state ต่อ instance ให้ query document.querySelectorAll แล้ววนลูป (แบบตัวอย่าง data-* ข้างล่าง) คุณไม่ได้ execution แยกสามครั้งฟรี ๆ
opt out ด้วย is:inline พิสูจน์ว่าซ้ำ
หัวข้อที่มีชื่อว่า “opt out ด้วย is:inline พิสูจน์ว่าซ้ำ”ถ้าคุณต้องการให้ script ออกมา ตรงตามที่เขียนเป๊ะ ๆ — ไม่ถูก bundle หรือ process และไม่ถูก dedupe (เช่น snippet analytics แบบ inline หรือ script ที่ต้องรันก่อน hydration) ให้เติม is:inline:
<div class="inline-marker"></div><script is:inline> console.log('INLINE_SCRIPT_RAN');</script>ใช้สองครั้งในหน้าเดียวกัน build output จริง — เทียบกับ CounterScript ที่ dedupe แล้วข้างบน:
<div class="inline-marker"></div><script> console.log('INLINE_SCRIPT_RAN');</script><div class="inline-marker"></div><script> console.log('INLINE_SCRIPT_RAN');</script>มี <script> สองtag ตรงตัวเป๊ะ ตัวละ instance — is:inline script ข้ามทั้ง bundling และ deduplication ใช้เฉพาะตอนที่คุณต้องการ tag ดิบ ๆ ซ้ำตรงจุดที่เขียนไว้จริง ๆ
attribute data-*: ส่งค่าฝั่ง server เข้า script ที่ bundle แล้ว
หัวข้อที่มีชื่อว่า “attribute data-*: ส่งค่าฝั่ง server เข้า script ที่ bundle แล้ว”script ที่ bundle แล้ว ไม่ใช่ inline อ่าน frontmatter ตรง ๆ ไม่ได้ (มันรันบน browser นานหลังจาก frontmatter รันบน server เสร็จไปแล้ว) — สะพานมาตรฐานคือ attribute data-* บน element ที่ script query:
---interface Props { userId: string }const { userId } = Astro.props;---<button class="like-btn" data-user-id={userId}>Like</button><script> document.querySelectorAll<HTMLButtonElement>('.like-btn').forEach((btn) => { const userId: string = btn.dataset.userId!; btn.addEventListener('click', () => console.log('liked by', userId)); });</script>ใช้ในสองหน้าที่ต่างกันด้วย props ต่างกัน (userId="u1" กับ userId="u2") build output จริงของแต่ละหน้า:
<!-- page A --><button class="like-btn" data-user-id="u1">Like</button><script type="module">document.querySelectorAll(`.like-btn`).forEach(e=>{let t=e.dataset.userId;e.addEventListener(`click`,()=>console.log(`liked by`,t))});</script><!-- page B --><button class="like-btn" data-user-id="u2">Like</button><script type="module">document.querySelectorAll(`.like-btn`).forEach(e=>{let t=e.dataset.userId;e.addEventListener(`click`,()=>console.log(`liked by`,t))});</script>TypeScript (querySelectorAll<HTMLButtonElement>, annotation : string) compile หายไป — script ที่ ship ออกมาเป็น JS ธรรมดา — แต่มัน type-check ตอน build จริง จับ typo ของ key dataset หรือ element type ผิดได้ก่อนที่จะ ship
define:vars บน <script>: กลไกคนละแบบจากบน <style>
หัวข้อที่มีชื่อว่า “define:vars บน <script>: กลไกคนละแบบจากบน <style>”define:vars ใช้กับ <script> ได้ด้วย — แต่มันฉีดค่าเป็น JavaScript const จริง ๆ ไว้บนสุดของ script ห่อด้วย IIFE ไม่ใช่ CSS custom property:
---const apiUrl = 'https://example.com/api';---<div id="marker"></div><script define:vars={{ apiUrl }}> console.log('API_URL_IS', apiUrl);</script>build output จริง:
<script>(function(){const apiUrl = "https://example.com/api";
console.log('API_URL_IS', apiUrl);})();</script>สังเกตความต่างของรูปแบบจาก data-*: script นี้ถูก emit แบบ inline (ไม่มี type="module" ห่อด้วย IIFE ของตัวเอง) แทนที่จะ bundle — define:vars บังคับให้ script emit แบบ inline ต่อ instance เหมือนกับที่มันบังคับ attribute style แบบ inline ต่อ instance ถ้าต้องการ script ที่ bundle และ dedupe แล้ว (กรณี CounterScript ข้างบน) ให้ส่งค่าผ่าน data-* attribute แทน ใช้ define:vars บน script เฉพาะตอนที่ต้องการค่านั้น bake เข้าไปใน inline tag ของ instance นี้จริง ๆ เท่านั้น
จุดสำคัญ: script ไม่ใช่ island
หัวข้อที่มีชื่อว่า “จุดสำคัญ: script ไม่ใช่ island”จุดนี้ทำมือใหม่งงบ่อย <script> รันบน browser แต่ ไม่ ทำให้ component รอบ ๆ กลายเป็น island ไม่มีการ hydrate component ไม่มี framework ไม่มี re-render — เป็นแค่ script ธรรมดาที่แก้ DOM ที่ Astro ผลิตออกมาแล้ว
<!-- This runs, but nothing is "hydrated". It's a script, not an island. --><div id="counter">0</div><script> let n = 0; const el = document.getElementById('counter'); document.getElementById('inc')?.addEventListener('click', () => { el.textContent = String(++n); });</script>เมื่อคุณอยากได้ความ interactive ระดับ component — component React/Vue/Svelte ที่มี state แล้ว hydrate — นั่นคือ island สร้างด้วย client directive และเป็นเนื้อหาทั้งโมดูลถัดไป กฎง่าย ๆ: หยิบ <script> มาใช้กับงาน DOM แบบ imperative เล็ก ๆ; หยิบ island มาใช้เมื่ออยากได้ UI component ที่มี state รันบน client
script กับ ClientRouter: รันใหม่ตอน navigate ด้วย astro:page-load
หัวข้อที่มีชื่อว่า “script กับ ClientRouter: รันใหม่ตอน navigate ด้วย astro:page-load”<script> ธรรมดารันแค่ครั้งเดียว ตอนหน้าที่มันอยู่โหลดครั้งแรก ถ้าไซต์ใช้ ClientRouter (page transition ฝั่ง client แบบ opt-in ของ Astro, astro:transitions) การ navigate ไปหน้าใหม่จะสลับ DOM โดยไม่ reload หน้าเต็ม — ดังนั้น script ที่ฟังแค่ DOMContentLoaded จะไม่ยิงอีกเลยในการ navigate ครั้งถัด ๆ ไป ยืนยันตรงกับ source ของ [email protected] ที่ติดตั้งจริง (node_modules/astro/dist/transitions/events.d.ts) event ที่ยิงเป็นแค่ string name ธรรมดา — Astro 7 เอา constant TRANSITION_* ที่ export ออกไปแล้ว:
type Events = 'astro:after-preparation' | 'astro:after-swap' | 'astro:page-load';ฟัง astro:page-load เพื่อให้ setup code ของคุณรันใหม่หลังทุกการ navigate รวมถึงครั้งแรกด้วย:
<script> function setup() { document.getElementById('inc')?.addEventListener('click', () => {/* ... */}); } document.addEventListener('astro:page-load', setup);</script>astro:page-load ยิงครั้งเดียวตอนโหลดครั้งแรก และยิงอีกครั้งหลังทุกการสลับฝั่ง client — script ที่รอแค่ DOMContentLoaded อย่างเดียวจะหยุดทำงานเงียบ ๆ หลัง navigate ครั้งแรกบนหน้าที่ใช้ ClientRouter เพราะ event นั้นไม่ยิงอีกเลย
flowchart TB scr["plain script tag"] --> dom["runs in browser, manipulates existing DOM"] dom --> nohy["no component hydration"] isl["island with a client directive"] --> hyd["framework component hydrates"] hyd --> state["has its own state and re-renders"]
class:list กับ scoped style: interplay กันได้สะอาด
หัวข้อที่มีชื่อว่า “class:list กับ scoped style: interplay กันได้สะอาด”scoping มีผลกับelement ไม่ว่า attribute class ของมันถูกสร้างมายังไง — scoped selector ก็ยังจับ class ที่มาจาก class:list ได้เหมือนเดิม:
---const isActive = true;---<div class:list={['card', { active: isActive }]}>x</div><style> .card { border: 1px solid; } .active { font-weight: bold; }</style>build output จริง — class ที่มาจาก class:list ทั้งคู่ได้ scoping attribute และ selector ทั้งคู่ก็ scoped:
<style>.card[data-astro-cid-sr4bmj7r]{border:1px solid}.active[data-astro-cid-sr4bmj7r]{font-weight:700}</style><div class="card active" data-astro-cid-sr4bmj7r>x</div>ไม่มีอะไรพิเศษต้องจำนอกเหนือจากที่พิสูจน์แล้ว: class:list ตัดสินใจแค่ค่าสุดท้ายของ attribute class ส่วน scoping เป็น attribute data-astro-cid-* ที่ apply เสมอแยกต่างหาก บวก CSS selector ที่ match กัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- บล็อก
<style>target selector ที่คาดว่าจะ cascade เข้าไปใน markup ของ component ลูก ไหม (.parent .child-heading { ... })? scoped style เขียน selector ใหม่เฉพาะที่ match element ที่ render โดย template ของcomponent นี้เอง — ไปถึง component ลูกต้องใช้:global()พิสูจน์แล้วข้างบน is:inlineถูกใช้กับ script ที่ตั้งใจจะแชร์/bundle ครั้งเดียวต่อหน้าไหม? พิสูจน์แล้วข้างบน:is:inlineซ้ำตรงตัวต่อ component instance และข้าม import resolution — ตรงข้ามกับสิ่งที่ script แบบ “รันครั้งเดียว” มักต้องการ- script พึ่งแค่
DOMContentLoaded/window.onloadบนไซต์ที่ใช้ClientRouterไหม? พิสูจน์แล้วข้างบน: มันไม่ยิงอีกหลัง client-side navigation — ฟังastro:page-loadด้วย - script สมมติไหมว่าอ่านค่าจาก frontmatter ได้ตรง ๆ (
{someServerValue}พิมพ์ตรงเข้าไปใน body ของ script) แทนที่จะส่งผ่านdata-*หรือdefine:vars?<script>ที่ไม่ inline รันบน browser หลังจาก server ทำงานเสร็จแล้ว มันต้องมีสะพานส่งค่า ไม่ใช่ reference ตรง ๆ
🐛 หา bug ในโค้ดนี้
---const productId = Astro.props.productId;---<button class="rate-btn">Rate this</button><script>document.querySelector('.rate-btn')?.addEventListener('click', () => { fetch('/api/rate', { method: 'POST', body: JSON.stringify({ productId }) });});</script>เฉลย
productId เป็นตัวแปร frontmatter (ฝั่ง server) แต่บล็อก <script> รันบน browser ใน JavaScript scope ที่แยกกันโดยสิ้นเชิง — มันมองไม่เห็น productId เลย มันจะ compile ไม่ผ่าน (script ธรรมดาที่ไม่ใช้ define:vars อ้างถึง identifier ที่ไม่ได้ประกาศ) หรือถ้า productId บังเอิญมีอยู่เป็น global ด้วยความบังเอิญ ก็จะใช้ค่าผิดเงียบ ๆ ทางแก้จริงสองแบบ: <script define:vars={{ productId }}> เพื่อฉีดมันเป็น const แบบ inline (พิสูจน์แล้วข้างบน) หรือ emit เป็น <button class="rate-btn" data-product-id={productId}> แล้วอ่าน .dataset.productId ข้างใน script bundled ปกติ (พิสูจน์แล้วข้างบนด้วย) — แบบที่สองดีกว่าในกรณีนี้เพราะยังคง script นี้ไว้แบบ bundle/dedupe แทนที่จะถูกบังคับเป็น inline
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”<style> และ <script> ของ component ทั้งคู่มี default เป็น “เป็นของ component นี้ ไม่ใช่ของหน้า” — แต่บังคับใช้กันคนละวิธี <style> เขียน selector ใหม่และประทับ attribute ที่ตรงกันลงบน markup ของทุก instance (พิสูจน์แล้วข้างบน: instance ถูกทำเครื่องหมาย ไม่ใช่ CSS ที่ dedupe ต่อ instance — rule เขียนครั้งเดียว) <script> กลับ dedupeโค้ดเองข้าม instance ในหน้าเดียวกัน (พิสูจน์แล้วข้างบน: สาม instance, script tag เดียว) — เพราะต่างจาก CSS การรัน JS เดียวกันสามครั้งมักไม่ใช่สิ่งที่คุณต้องการ directive ที่ opt out ทั้งคู่ (is:global, is:inline) แลก behavior อัตโนมัติระดับหน้าตรงนี้ ไปเป็น “apply/run ตามที่เขียนเป๊ะ ๆ ทุกที่ที่ถูกอ้างถึง”
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”scoped <style> (default) |
is:global / :global() |
<script> bundled (default) |
script is:inline |
|
|---|---|---|---|---|
| มีผลกับ | markup ของ component นี้เท่านั้น | ทั้งไซต์ | รันครั้งเดียวต่อหน้า ไม่ว่าจำนวน instance | รันครั้งเดียวต่อ instance ตรงตัว |
| import | n/a | n/a | resolve และ bundle | ไม่ resolve |
| เหมาะกับ | CSS เฉพาะ component (กรณี default) | reset, theme, style เนื้อหาที่ slot/child | setup logic ที่แชร์, event delegation | analytics snippet, script ก่อน hydration |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
<style>ของ component cascade เข้าไปใน component ลูกที่มัน render” พิสูจน์แล้วข้างบน: scoping แตะแค่ template ของ component ปัจจุบันเอง ไปถึงลูกต้องใช้:global() - “ใช้ component ที่มี
<style>block สามครั้ง CSS นั้นจะเพิ่มสามเท่าใน output” พิสูจน์แล้วข้างบน: rule เขียนครั้งเดียวใน stylesheet ของหน้า มีแค่ attributedata-astro-cid-*ที่ซ้ำ บน markup ของแต่ละ instance - “ใช้ component ที่มี
<script>สามครั้ง script นั้นรันสามครั้ง” พิสูจน์แล้วข้างบน: มี<script>tag เดียวไม่ว่าจำนวน instance — ตรงข้ามกับกรณีis:inlineที่ซ้ำจริง - “script แค่ต้องฟัง
DOMContentLoadedก็ทำงานถูกต้อง” พิสูจน์แล้วข้างบน (จาก source ของastro:transitionsที่ติดตั้งจริง): บนหน้าที่ใช้ClientRouterการ navigate ครั้งถัด ๆ ไปไม่ reload document — ต้องรัน setup ใหม่บนastro:page-loadด้วย
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import ScopedCard from './ScopedCard.astro';
describe('scoped style hashing', () => { it('stamps a data-astro-cid-* attribute and the define:vars inline style onto the rendered element', async () => { // Container renderToString returns only this component's own markup — the // extracted <style data-astro-cid-*> stylesheet is assembled separately at // real page/build time (proven in this lesson with real built CSS output); // what the container proves directly is the per-instance scoping attribute // and the define:vars inline style landing on the element itself. const container = await AstroContainer.create(); const html = await container.renderToString(ScopedCard, { props: { accent: '#ff0000' } }); expect(html).toMatch(/data-astro-cid-[a-z0-9]+/); expect(html).toContain('<div class="card"'); expect(html).toContain('style="--accent: #ff0000;"'); });});