Client Directives
เปลี่ยน component ให้เป็น island
หัวข้อที่มีชื่อว่า “เปลี่ยน component ให้เป็น island”framework component ที่ import เข้ามาในไฟล์ .astro จะ render เป็น static HTML — ไม่มี interactivity — จนกว่าเราจะเพิ่ม client directive ให้ directive บอก Astro สองอย่าง: hydrate component นี้บน client และ เมื่อไหร่ ให้ทำ
---import Counter from '../components/Counter.jsx';---<Counter /> <!-- static HTML, no JS ships --><Counter client:load /> <!-- an island: hydrates immediately -->คำว่า “เมื่อไหร่” คือหัวใจทั้งหมด hydrate เร็วขึ้นทำให้ component interactive เร็วขึ้น แต่แลกด้วย JavaScript ที่โหลดล่วงหน้าและเวลาบน main thread ส่วน hydrate ช้าลงทำให้หน้าเบา แต่ interactivity มาช้า directive แต่ละตัวคือจุดที่ต่างกันบนเส้น trade-off นี้
directive ทั้งห้าตัว
หัวข้อที่มีชื่อว่า “directive ทั้งห้าตัว”client:load— hydrate ทันที ตั้งแต่หน้าโหลด ใช้กับ control ที่อยู่เหนือ fold และต้องทำงานได้ทันทีที่หน้าใช้งานได้: search ที่ header, toggle สำคัญ ๆ เป็นตัวที่ขยันที่สุดและแพงที่สุดclient:idle— hydrate ตอน browser idle (ผ่านrequestIdleCallback) หลังงานหลักของการโหลดเสร็จแล้ว เหมาะกับ interactivity ที่สำคัญแต่ไม่ต้องมาทันที — รอจังหวะที่ browser ว่างก่อนclient:visible— hydrate ตอน component scroll เข้ามาในจอ (ผ่านIntersectionObserver) เป็น default ที่ดีที่สุดสำหรับอะไรก็ตามที่อยู่ใต้ fold: widget comment, ฟอร์ม newsletter ที่ footer, carousel ที่อยู่ลึกลงไปในหน้า JavaScript ของตัวเองจะไม่ถูกโหลดเลยถ้า user ไม่ scroll ไปถึงclient:media— hydrate เฉพาะตอน CSS media query match เช่นclient:media="(max-width: 50em)"ใช้กับ interactivity ที่มีเฉพาะบาง breakpoint — เมนู hamburger ที่มีเฉพาะบน mobile ซึ่งไม่มีความหมายบน desktopclient:only="react"— ข้าม server rendering ทั้งหมด และ render บน client เท่านั้น เราต้องระบุชื่อ framework ("react","vue","svelte", …) เพราะ Astro จะไม่ render component นั้นบน server เพื่อตรวจว่าเป็น framework อะไร ใช้กับ component ที่ render บน server ไม่ได้เลย — พวกที่แตะwindow,localStorageหรือ library ที่ใช้ได้แค่บน browser ตั้งแต่ render ครั้งแรก
flowchart LR start["page loads"] --> load["client:load — hydrate now"] load --> idle["client:idle — when browser is idle"] idle --> visible["client:visible — when scrolled into view"] visible --> media["client:media — when a breakpoint matches"] only["client:only — skip server render, client only"]
กลไกจริง: <astro-island>
หัวข้อที่มีชื่อว่า “กลไกจริง: <astro-island>”client directive ไม่ใช่แค่แนวคิดตอน build — มันคอมไพล์เป็น custom element จริง ๆ ที่ส่งมาใน HTML ลอง build หน้าที่มี <Counter start={0} client:visible={{ rootMargin: '200px' }} /> (Preact) แล้วอ่าน output จริง:
<astro-island uid="Z22rHo8" data-preact-island-id="1" component-url="/_astro/Counter.CzecfPIK.js" component-export="default" renderer-url="/_astro/client.N277o4ni.js" props="{"start":[0,0]}" ssr client="visible" opts="{"name":"Counter","value":{"rootMargin":"200px"}}" await-children>ทุก attribute ทำงานจริง (ตรวจสอบตรง ๆ กับ node_modules/astro/dist/runtime/server/astro-island.js ซึ่งเป็นตัว define custom element ฝั่ง client):
uid— id คงที่สำหรับ instance ของ island นี้component-url/renderer-url— สอง JS chunk ที่astro-island.jsสั่งimport(): ตัว component เอง กับ renderer สำหรับ hydrate ของ framework นั้น (hydrator) framework ต่างกันบนหน้าเดียวกันได้renderer-urlคนละไฟล์ (พิสูจน์ในบท framework-integrations)component-export— จะใช้ export ไหนของcomponent-url(default คือ"default")props— prop ที่ serialize แล้ว แท็กเป็น tuple เพื่อให้Date/Map/Set/RegExp/ฯลฯ รอดข้ามไปได้ ([0, value]ตรงนี้แปลว่า “ค่าธรรมดา”)ssr— มีอยู่จนกว่า hydration จะรันastro-island.jsเอาออกในhydrate()client— คือชื่อ directive ตรง ๆ ("visible","idle","load","media","only")start()ของastro-island.jsอ่าน attribute นี้ตรง ๆ แล้วเรียกAstro[directive](...)opts— ตัวเลือกของ directive เอง ส่งผ่านตรง ๆ — ตรงนี้แหละที่{ rootMargin: '200px' }จากตัวอย่างข้างบนไปถึง พิสูจน์ว่า option มาถึง browser จริงawait-children— บอกconnectedCallbackให้รอ streaming marker ของ Astro ก่อนเริ่ม เพื่อไม่ให้ child ที่ใช้ slot ที่ยัง stream มาไม่ถึง hydrate ใส่ DOM ที่ยังไม่มี
sequenceDiagram participant HTML as astro-island element participant Loader as Astro[client] (e.g. visible.js) participant JS as component-url + renderer-url HTML->>HTML: connectedCallback (waits for await-children) HTML->>Loader: start() reads the "client" attribute, calls Astro[directive] Loader->>Loader: wait for the real trigger (IntersectionObserver, requestIdleCallback, matchMedia...) Loader->>JS: import() component-url and renderer-url JS-->>HTML: hydrate(Component, props, slots) HTML->>HTML: remove "ssr" attribute, dispatch astro:hydrate
ตัว trigger ของแต่ละ directive เป็น script เล็ก ๆ ที่ Astro ส่งมาเป็น self.Astro.<directive> — คัดจากไฟล์จริงที่ติดตั้งอยู่ (node_modules/astro/dist/runtime/client/*.js, [email protected]):
const idleDirective = (load, options) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; const rawOptions = typeof options.value === "object" ? options.value : void 0; const idleOptions = { timeout: rawOptions?.timeout }; if ("requestIdleCallback" in window) { window.requestIdleCallback(cb, idleOptions); } else { setTimeout(cb, idleOptions.timeout || 200); }};const visibleDirective = (load, options, el) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; const rawOptions = typeof options.value === "object" ? options.value : void 0; const ioOptions = { rootMargin: rawOptions?.rootMargin }; const io = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; io.disconnect(); cb(); break; } }, ioOptions); for (const child of el.children) { io.observe(child); }};const mediaDirective = (load, options) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; if (options.value) { const mql = matchMedia(options.value); if (mql.matches) { cb(); } else { mql.addEventListener("change", cb, { once: true }); } }};client:load กับ client:only (load.js/only.js) ง่ายที่สุดในบรรดาทั้งหมด — await load(); await hydrate(); ไม่มี gate อะไรเลย — ยืนยันว่า “ทันที” หมายถึงทันทีจริง ๆ ไม่มี observer ไม่มี timer ไม่มีการเช็ค
ตัวเลือกของ directive: rootMargin กับ timeout
หัวข้อที่มีชื่อว่า “ตัวเลือกของ directive: rootMargin กับ timeout”ทั้ง client:visible และ client:idle รับ object เป็น argument ได้ ไม่ใช่แค่ flag เฉย ๆ — ตรวจสอบใน .d.ts ที่ติดตั้งจริง (astro/dist/types/public/elements.d.ts):
'client:load'?: boolean;'client:idle'?: IdleRequestOptions | boolean;'client:media'?: string;'client:visible'?: ClientVisibleOptions | boolean;'client:only'?: boolean | string;
export type ClientVisibleOptions = Pick<IntersectionObserverInit, 'rootMargin'>;IdleRequestOptions คือ type มาตรฐานของ DOM lib สำหรับ argument ตัวที่สองของ requestIdleCallback (ก็คือ { timeout } ตรงกับ idleOptions.timeout ของ idle.js ข้างบน) ClientVisibleOptions ถูก narrow ให้เหลือแค่ rootMargin — field เดียวที่ visible.js อ่านเข้า IntersectionObserverInit ของมันจริง ๆ หน้าที่ type-check ผ่านจริง ใช้ทั้งสองตัวเลือก:
---import DirectiveCounter from '../components/DirectiveCounter.tsx';---<DirectiveCounter start={0} client:visible={{ rootMargin: '200px' }} /><DirectiveCounter start={0} client:idle={{ timeout: 500 }} />import { useState } from 'preact/hooks';
export default function DirectiveCounter({ start }: { start: number }) { const [count, setCount] = useState(start); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;}npm run verify type-check ทั้งสอง fence นี้ผ่านสะอาด — rootMargin/timeout เป็น option จริงที่ type ไว้ใน 7.3.5 ไม่ได้เดามั่ว
client:only ต้องระบุชื่อ framework — พิสูจน์ด้วย error จริง
หัวข้อที่มีชื่อว่า “client:only ต้องระบุชื่อ framework — พิสูจน์ด้วย error จริง”client:only ข้าม server rendering ทั้งหมด ทำให้ Astro ไม่มีอะไรให้ render-แล้ว-ตรวจดู เพื่อเดาว่าเป็น framework อะไร ถ้าติดตั้ง UI integration แค่ ตัวเดียว Astro จะเดาให้เงียบ ๆ — client:only (ไม่ระบุชื่อ) build ผ่านสบาย ๆ แต่พอเพิ่ม integration ที่เป็น JSX ตัวที่ สอง (เช่น preact() กับ react() ในโปรเจกต์เดียวกัน) การเดาจะกลายเป็นกำกวมทันที และการ build client:only เปล่า ๆ จะ throw จริง:
[NoClientOnlyHint] Unable to render `Counter`. When using the `client:only` hydrationstrategy, Astro needs a hint to use the correct renderer. Hint: Did you mean to pass `client:only="react|preact|solid-js|vue (jsx)"`? Error reference: https://docs.astro.build/en/reference/errors/no-client-only-hint/(พิสูจน์จริงกับ astro build ที่ติดตั้งทั้ง @astrojs/preact และ @astrojs/react — โปรเจกต์เดียวกันนี้ยัง warn ตั้งแต่ตอนเพิ่ม integration ตัวที่สองด้วย: [@astrojs/preact] More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the include or exclude option.) ระบุชื่อ framework — client:only="preact" — แก้ได้ทันที เพราะมันบอก Astro ตรง ๆ ว่าต้องโหลด renderer-url ไหน โดยไม่ต้องมี server render เลย
สิ่งที่เกิดขึ้นจริงกับ prop ที่ไม่ serializable
หัวข้อที่มีชื่อว่า “สิ่งที่เกิดขึ้นจริงกับ prop ที่ไม่ serializable”prop ของ island เดินทางจากไฟล์ .astro ไป browser เป็น string แบบ JSON ใน attribute props ข้างบน จึงต้อง serializable ข้อจำกัดนี้จริง แต่ รูปแบบ ของความล้มเหลวขึ้นอยู่กับว่า type ของ prop นั้นซื่อสัตย์แค่ไหน
ถ้า Props type ของ DirectiveCounter คือ { start: number } แล้วผู้เขียนส่ง handler เข้าไปด้วย TypeScript จะจับได้ก่อนอะไรจะถูกส่งออกไป เป็น error จริงจาก astro check ไม่ใช่ lint เฉย ๆ:
---// @expect-error TS2322 — onPoint isn't declared on DirectiveCounter's Props, so the extra prop is rejected at the type levelimport DirectiveCounter from '../components/DirectiveCounter.tsx';---<DirectiveCounter start={0} onPoint={() => console.log('hi')} client:visible />src/pages/fnprop-demo.astro:5:20 - error ts(2322): Type '{ start: number; onPoint: () => void; "client:visible": true; }'is not assignable to type 'IntrinsicAttributes & { start: number; }'. Property 'onPoint' does not exist on type 'IntrinsicAttributes & { start: number; }'.แต่ถ้า prop type หลวม (.jsx ธรรมดา หรือ prop แบบ any) ไม่มีอะไรหยุด build ไว้เลย — และพฤติกรรมตอน runtime ก็ไม่ใช่ error เลย แต่เป็นการเสียข้อมูลแบบเงียบ ๆ ตัว serializer ของ Astro เอง (astro/dist/runtime/server/serialize.js) ตกไปใช้ JSON.stringify สำหรับอะไรก็ตามที่มันไม่รู้จักเป็นพิเศษ (Date, RegExp, Map, Set, array, typed array, Infinity…) — และ JSON.stringify ของ function ที่อยู่ใน array slot จะได้ null ไม่ใช่ exception attribute props ที่ build จริงของ call เดียวกัน โดยข้าม type check ไป:
props="{"start":[0,0],"onPoint":[0,null]}"function หายไปเลยฝั่ง client ถ้า DirectiveCounter เผลอเรียก props.onPoint() จะ throw onPoint is not a function ตอน hydrate — เป็น runtime error ที่งงว่าเกิดจากอะไร ทั้งที่จุดผิดจริงอยู่คนละที่
Hydration mismatch: Preact ไม่ warn เหมือน React
หัวข้อที่มีชื่อว่า “Hydration mismatch: Preact ไม่ warn เหมือน React”hydration mismatch คือตอนที่ HTML ที่ render บน server กับ render ครั้งแรกฝั่ง client ไม่ตรงกัน — เช่น component ที่แยกทางตาม typeof window build แล้วเสิร์ฟจริง (<p>{typeof window === 'undefined' ? 'server' : 'client'}</p>, client:load):
$ curl -s http://127.0.0.1:4601/mismatch-demo/ | grep -o '<p>[^<]*</p>'<p>server</p>พอ island hydrate ใน browser จริง DOM เปลี่ยนเงียบ ๆ เป็น:
document.querySelector('astro-island p').outerHTML// "<p>client</p>"โดยไม่มี console message แม้แต่อันเดียว — เช็คตรง ๆ ด้วย browser session จริง (ตัวฟัง console ของ Playwright บันทึกได้ 0 error, 0 warning ตลอดการ navigate) นี่คือความต่างจาก React ที่มีประโยชน์จริง: Preact (framework ของคอร์สนี้เอง) ไม่ได้ส่ง hydration-mismatch warning ใน production เลย แม้แต่ preact/debug — package diagnostics โหมด dev ของมัน — ก็ไม่ได้เช็ค text-content mismatch (ตรวจแล้ว: warning ของมันครอบคลุมแค่ prop ที่ไม่ถูกต้อง, key ที่หาย, กับ DOM node ที่ไม่ถูกต้อง ไม่ใช่เรื่องนี้) อย่าคาดหวังว่า console จะบอกตอน server กับ client render ไม่ตรงกัน — render ที่ต่างกันจริง ๆ ตอน paint ครั้งแรกคือสัญญาณเดียวที่เห็นได้
เลือกตัวที่ถูก
หัวข้อที่มีชื่อว่า “เลือกตัวที่ถูก”เส้นทางตัดสินใจง่าย ๆ ครอบคลุมเกือบทุกเคส:
- ต้องทำงานได้ทันทีที่หน้าโหลด อยู่เหนือ fold ไหม? →
client:load - อยู่ใต้ fold หรือไม่ต้องใช้ทันที? →
client:visible(default ที่เบาและปลอดภัยที่สุด) - สำคัญแต่รอจังหวะว่างได้? →
client:idle - เกี่ยวเฉพาะบางขนาดหน้าจอ? →
client:media - render บน server ไม่ได้เลย? →
client:only="react"
สัญชาตญาณที่จะคว้า client:load มาใส่ทุกอย่างคือสิ่งที่ island มีไว้ป้องกันพอดี ส่ง JavaScript ให้น้อยที่สุดเท่าที่ให้ interactivity ที่ต้องการ และปล่อยให้ island ส่วนใหญ่ของคุณเป็น client:visible
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ตอนเจอ snippet ที่ generate มาใช้ client directive เช็คสิ่งเหล่านี้ก่อนเชื่อ:
- component interactive ทุกตัวเป็น
client:loadไม่ว่าจะอยู่ตรงไหนของหน้าไหม? นี่คือ default ที่ LLM มักคว้ามาใช้ ส่ง JavaScript มากที่สุดแต่ได้ประโยชน์น้อยที่สุด เว้นแต่ component นั้นอยู่เหนือ fold จริง ๆ และต้องทำงานทันที - ถ้ามี
client:onlyปรากฏ มันระบุชื่อ framework ไหม (client:only="preact")?client:onlyเปล่า ๆ ทำงานได้แค่โดยบังเอิญ — พัง ทันทีที่ติดตั้ง UI integration ตัวที่สอง ด้วย errorNoClientOnlyHintจริงข้างบน <script>ที่มีis:inlineถูกเข้าใจผิดว่าเป็น island หรือเปล่า?is:inlineแค่บอก Astro ให้ปล่อย<script>ธรรมดาไว้เฉย ๆ (ไม่ bundle/ประมวลผล) — ไม่เกี่ยวกับ framework component, hydration หรือ client directive เลย และไม่มีทางออกมาเป็น<astro-island>- prop ที่ส่งให้ island serializable จริงไหม? function หรือ class instance จะพังตั้งแต่ระดับ type (พิสูจน์ข้างบน) หรือไม่ก็เงียบ ๆ กลายเป็น
nullบนสาย — ไม่มีทางไหน “ใช้ได้เฉย ๆ” client:visible/client:idleส่ง options object ({{ rootMargin: '...' }},{{ timeout: ... }}) ตอนตั้งใจจะ config มันไหม ไม่ใช่ string เปล่า ๆ (นั่นเป็นรูปแบบของclient:mediaไม่ใช่สองตัวนี้)?
🐛 หา bug ในโค้ดนี้
---// src/pages/index.astro — a project with both @astrojs/preact and @astrojs/react installedimport Counter from '../components/Counter.jsx';---<Counter start={0} client:only />เฉลย
โค้ดนี้ build ผ่านถ้าติดตั้ง integration ตัวเดียว แต่โปรเจกต์นี้มี สอง integration ที่เป็น JSX (preact() กับ react()) client:only ข้าม server rendering ทั้งหมด ทำให้ Astro ไม่มี output ที่ render แล้วให้ดูเพื่อเดา framework — และเมื่อมี renderer ที่เป็นไปได้มากกว่าหนึ่งตัว การเดาก็ไม่ปลอดภัยอีกต่อไป error จริงจาก build ที่ทำ setup นี้ซ้ำแล้วซ้ำอีก: [NoClientOnlyHint] Unable to render Counter. When using the client:only hydration strategy, Astro needs a hint to use the correct renderer. ทางแก้คือคำเดียว: client:only="react" (หรือ "preact" แล้วแต่ว่า Counter.jsx ตั้งใจจะ hydrate ด้วยตัวไหนจริง ๆ ให้ตรงกับ include/exclude ของแต่ละ integration — ดูบท framework-integrations)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”client directive ไม่ใช่ hint ให้ Astro runtime ในหน่วยความจำที่ไหน — มันคือขั้นตอนการ compile client:visible บน JSX component กลายเป็น tag <astro-island client="visible" ...> จริง ๆ ที่ curl ดูได้ ต่อสายเข้ากับ IntersectionObserver จริงในไฟล์ที่มี version จริงใต้ node_modules/astro/dist/runtime/client/ ถ้าเรียกชื่อ directive ได้ ก็ไปอ่านไฟล์หกสิบบรรทัดที่ implement มันได้เลย — ไม่มี layer ที่ซ่อนอยู่ข้างล่าง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”client:load |
client:idle |
client:visible |
client:media |
client:only |
|
|---|---|---|---|---|---|
| hydrate ตอนไหน | ทันที | ตอน browser idle (requestIdleCallback) |
ตอน scroll เข้ามาในจอ (IntersectionObserver) |
ตอน media query match | ทันที แต่ client-only |
| render บน server ไหม? | ได้ | ได้ | ได้ | ได้ | ไม่ — ข้ามไปเลย |
| ต้องระบุชื่อ framework ไหม? | ไม่ | ไม่ | ไม่ | ไม่ | ต้อง เมื่อติดตั้ง integration มากกว่า 1 ตัว |
| เหมาะกับ | control เหนือ fold ที่ต้องทำงานทันที | สำคัญแต่ไม่ด่วน | widget ใต้ fold (default ที่ปลอดภัย) | interactivity เฉพาะ breakpoint | API ที่ใช้ได้แค่ browser server จะ crash |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
client:loadคือ default ที่ปลอดภัย” มันคือตัวที่แพงที่สุด — พิสูจน์ข้างบนแล้วว่าload.jsไม่มี gate อะไรเลยclient:visibleคือ default ที่ปลอดภัยกว่า เก็บclient:loadไว้กับสิ่งที่ต้องทำงานทันทีจริง ๆ - “
client:onlyแบบไม่ระบุ framework ใช้ได้เสมอ” พิสูจน์ข้างบนแล้วว่าขึ้นอยู่กับจำนวน UI integration ที่ติดตั้งล้วน ๆ ตัวเดียวเดาได้ สองตัวขึ้นไปคือ build error จริง - “prop ที่ไม่ serializable จะ throw ตอน build” จะเป็นแบบนั้นก็ต่อเมื่อ
Propstype ของ component ซื่อสัตย์พอให้ TypeScript จับได้ (พิสูจน์แล้วว่าเป็นts(2322)จริง) ถ้า type หลวม/anyมันจะเงียบ ๆ กลายเป็นnullบนสายแทน — ไม่มี error เลย - “hydration mismatch จะ log console warning เหมือน React” พิสูจน์ข้างบนด้วย browser session จริงแล้วว่า Preact ไม่ warn เรื่อง text-content mismatch เลย ทั้งใน production และใต้
preact/debugDOM แค่เงียบ ๆ กลายเป็น version ของ client เท่านั้น