Compiler Output
หน้าที่ของ compiler ในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “หน้าที่ของ compiler ในหนึ่งประโยค”compiler ของ Astro แปลงไฟล์ .astro ให้เป็น JavaScript module ธรรมดาที่เรียก function ที่ export จาก astro/runtime/server/index.js ไม่มี framework runtime ไหนมา “แปลความ” component ของคุณตอน render — การแปลความเกิดขึ้นครั้งเดียวแล้ว ใน compiler package ที่ทำงานนี้จริง ๆ ใน Astro 7 คือ @astrojs/compiler-rs ซึ่งเป็น native Rust NAPI addon (transform()/parse()) — ไม่ใช่ @astrojs/compiler รุ่นเก่าที่เป็น WASM ล้วนซึ่ง tooling บางตัว (และ playground ในเบราว์เซอร์ของคอร์สนี้เองด้านล่าง) ยังใช้อยู่ ทุก claim ในบทเรียนนี้ได้จากการรัน transform() จริงของ package ที่ repo นี้ติดตั้งไว้ (@astrojs/[email protected] resolve จาก tools/probe/node_modules) — นี่คือ compiler ตัวเดียวกันเป๊ะที่ astro check และ astro build รันกับ code ของคุณ
component ที่มี prop, scoped style, expression และ slot
หัวข้อที่มีชื่อว่า “component ที่มี prop, scoped style, expression และ slot”---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>รัน transform() ของ compiler ที่ติดตั้งจริงตรง ๆ กับ source นั้นเป๊ะ (filename: 'src/components/Card.astro'):
import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>`;
test('transform(): props + scoped style + expression + slot', () => { const result = transform(source, { filename: 'src/components/Card.astro' });
expect(result.code).toContain('$$createComponent'); expect(result.code).toContain('$$maybeRenderHead'); expect(result.code).toContain('$$renderSlot'); expect(result.css[0]).toMatch(/\.card:where\(\.astro-[a-z0-9]+\)/); expect(result.code).toMatch(/class="card astro-[a-z0-9]+"/);});✓ transform(): props + scoped style + expression + slot
Test Files 1 passed (1) Tests 1 passed (1)compiled output จริงเต็ม ๆ ของ source นั้น:
import { render as $$render, createAstro as $$createAstro, createComponent as $$createComponent, maybeRenderHead as $$maybeRenderHead, renderSlot as $$renderSlot } from "astro/runtime/server/index.js";import "src/components/Card.astro?astro&type=style&index=0&lang.css";const $$Astro = $$createAstro("https://astro.build");const Astro = $$Astro;const $$Card = $$createComponent(($$result, $$props, $$slots) => { const Astro = $$result.createAstro($$props, $$slots); Astro.self = $$Card; const { name } = Astro.props; return $$render`${$$maybeRenderHead($$result)}<div class="card astro-3dcq3mxs"> <h2 class="astro-3dcq3mxs">Hello ${name}</h2> ${$$renderSlot($$result, $$slots["default"])}</div>`;}, "src/components/Card.astro", undefined);export default $$Card;ทุกส่วนทำงานจริงและเจาะจง:
$$createComponent(renderFn, filename, moduleId)— ห่อ render function ของคุณให้เป็น component จริงที่ runtime ของ Astro เรียก$$Astro.selfให้ component reference ตัวเอง (ใช้กับ recursive component)$$createAstro(site)— สร้าง$$Astroglobal ร่วมที่ identifierAstroเปล่า ๆ ใน frontmatter ของคุณ bind อยู่ ก่อนบรรทัดconst { name } = Astro.propsของคุณเองจะรัน$$maybeRenderHead($$result)— emit เนื้อหา<head>แบบ lazy ครั้งแรกที่ element ใดก็ตาม render — พิสูจน์ด้วยตำแหน่งของมัน: มันอยู่ตรงก่อน markup จริงตัวแรก ไม่ใช่บนสุดของ function$$render/ tagged template — body ทั้งหมดคือ tagged template literal เดียว ทุก${...}ที่ interpolate เป็นได้ทั้ง plain value (escape อัตโนมัติโดย runtime) หรือ renderer call อื่น ($$renderSlot,$$renderComponentสำหรับ child component)- scoped-style hash (
astro-3dcq3mxs) — ถูกเติมเป็น CSS class จริงบนทุก element ที่ selector ของ<style>block เข้าถึง และ hash เดียวกันปรากฏใน CSS ที่ emit ออกมาเป็น:where(.astro-3dcq3mxs)wrapper (:whereทำให้ scoping ไม่เพิ่ม specificity ของ selector — พิสูจน์ข้างบน:.card:where(.astro-3dcq3mxs) { color: red; }) $$renderSlot($$result, $$slots["default"])—<slot />กลายเป็น call ที่หา named slot ("default"ในที่นี้) จาก$$slotsที่ส่งเข้ามาโดยตัวที่ render<Card>
scoped-style hash มาจากชื่อไฟล์ — พิสูจน์ด้วยการเปลี่ยนชื่อ
หัวข้อที่มีชื่อว่า “scoped-style hash มาจากชื่อไฟล์ — พิสูจน์ด้วยการเปลี่ยนชื่อ”compile source string เดียวกันเป๊ะภายใต้ filename สองชื่อ ได้ hash สองแบบที่ต่างกัน:
import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>`;
test('renaming the file changes the scoped-style hash, for identical source', () => { const original = transform(source, { filename: 'src/components/Card.astro' }); const renamed = transform(source, { filename: 'src/components/RenamedCard.astro' });
const hashOf = (code: string) => code.match(/astro-([a-z0-9]+)/)?.[1];
expect(hashOf(original.code)).toBe('3dcq3mxs'); expect(hashOf(renamed.code)).toBe('izcegd4j'); expect(hashOf(original.code)).not.toBe(hashOf(renamed.code));});✓ renaming the file changes the scoped-style hash, for identical source
Test Files 1 passed (1) Tests 1 passed (1)ค่าจริงที่ capture ได้จากการรันนี้เป๊ะ: src/components/Card.astro → astro-3dcq3mxs; source เดียวกันเป๊ะที่บันทึกเป็น RenamedCard.astro → astro-izcegd4j นี่คือข้อมูลที่มีประโยชน์จริงตอนอ่าน diff: scoped-style hash ที่เปลี่ยนใน PR ที่แค่เปลี่ยนชื่อไฟล์ (ไม่ได้เปลี่ยน content) เป็นเรื่องที่คาดหวังได้ ไม่ใช่สัญญาณของการแก้ไขแปลก ๆ — และการ copy-paste compiled output ของ component ไปไฟล์อื่นโดยไม่ compile ใหม่จะพก hash ผิด ติดไปด้วย
dynamic attribute: $$addAttribute และ set:html เทียบกับ plain expression
หัวข้อที่มีชื่อว่า “dynamic attribute: $$addAttribute และ set:html เทียบกับ plain expression”---const { name, htmlContent } = Astro.props;const id = `card-${name}`;---<div id={id} class="card"> <p set:html={htmlContent} /> <p>{htmlContent}</p></div>import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
const source = `---const { name, htmlContent } = Astro.props;const id = \`card-\${name}\`;---<div id={id} class="card"> <p set:html={htmlContent} /> <p>{htmlContent}</p></div>`;
test('transform(): a dynamic attribute becomes $$addAttribute; set:html becomes $$unescapeHTML, a plain expression does not', () => { const result = transform(source, { filename: 'src/components/AttrCard.astro' });
expect(result.code).toContain('$$addAttribute(id, "id")');
// set:html's paragraph goes through $$unescapeHTML exactly once... const unescapedCalls = result.code.match(/\$\$unescapeHTML\(htmlContent\)/g) ?? []; expect(unescapedCalls).toHaveLength(1);
// ...while the plain {htmlContent} expression on the next line stays a bare, // auto-escaped interpolation — no $$unescapeHTML wrapping it at all. expect(result.code).toContain('<p>${htmlContent}</p>');});✓ transform(): a dynamic attribute becomes $$addAttribute; set:html becomes $$unescapeHTML, a plain expression does not
Test Files 1 passed (1) Tests 1 passed (1)compiled output จริงเต็ม ๆ:
import { render as $$render, createAstro as $$createAstro, createComponent as $$createComponent, maybeRenderHead as $$maybeRenderHead, unescapeHTML as $$unescapeHTML, addAttribute as $$addAttribute } from "astro/runtime/server/index.js";const $$Astro = $$createAstro("https://astro.build");const Astro = $$Astro;const $$AttrCard = $$createComponent(($$result, $$props, $$slots) => { const Astro = $$result.createAstro($$props, $$slots); Astro.self = $$AttrCard; const { name, htmlContent } = Astro.props; const id = `card-${name}`; return $$render`${$$maybeRenderHead($$result)}<div${$$addAttribute(id, "id")} class="card"> <p>${$$unescapeHTML(htmlContent)}</p> <p>${htmlContent}</p></div>`;}, "src/components/AttrCard.astro", undefined);export default $$AttrCard;สองข้อเท็จจริงที่พิสูจน์ตรง ๆ:
id={id}— attribute ไหนก็ตามที่ value เป็น expression (ไม่ใช่ string literal) compile เป็น$$addAttribute(value, "name")— เป็น runtime call ไม่ใช่ string interpolation เข้าไปใน tag เพราะมันต้องจัดการ boolean ด้วย (disabled={false}ต้อง ไม่ใส่ attribute เลย ไม่ใช่ renderdisabled="false") และ object แบบclass:listset:html={htmlContent}compile เป็น$$unescapeHTML(htmlContent)— string ถูกใส่เข้าไป เป็น raw HTML แบบไม่ escape บรรทัดถัดไปทันที{htmlContent}เป็น plain expression ไม่มี directive interpolate value เดียวกัน โดยไม่มี$$unescapeHTMLcall เลย — tagged-template renderer ของ runtime escape plain interpolation ให้อัตโนมัติ นี่คือความต่างทั้งหมดระหว่าง “render เป็น HTML” กับ “render เป็น text” ใน Astro: directive เดียว, compiled call เดียวที่เจาะจง
hydration directive metadata: client:load เทียบกับ client:only
หัวข้อที่มีชื่อว่า “hydration directive metadata: client:load เทียบกับ client:only”import { useState } from 'preact/hooks';
export default function Counter({ count }: { count: number }) { const [n, setN] = useState(count); return <button onClick={() => setN(n + 1)}>Count: {n}</button>;}---import Counter from '../components/Counter.tsx';---<Counter client:load count={5} />import { transform } from '@astrojs/compiler-rs';import { expect, test } from 'vitest';
test('transform(): client:load renders the component AND emits hydration metadata', () => { const source = `---import Counter from '../components/InternalsCounter.jsx';---<Counter client:load count={5} />`; const result = transform(source, { filename: 'src/pages/island-demo.astro', resolvePath: (s) => s });
expect(result.code).toContain('"client:load": true'); expect(result.code).toContain('"client:component-hydration": "load"'); expect(result.code).toContain('"client:component-path": "../components/InternalsCounter.jsx"'); expect(result.code).toContain('"client:component-export": "default"'); // The component itself IS passed to $$renderComponent (server-rendered, then hydrated): expect(result.code).toMatch(/\$\$renderComponent\(\$\$result, "Counter", Counter, \{/); expect(result.hydratedComponents).toHaveLength(1); expect(result.hydratedComponents[0].specifier).toBe('../components/InternalsCounter.jsx');});
test('transform(): client:only passes null instead of the component — no server render at all', () => { const source = `---import Counter from '../components/InternalsCounter.jsx';---<Counter client:only="preact" count={5} />`; const result = transform(source, { filename: 'src/pages/island-only-demo.astro', resolvePath: (s) => s });
expect(result.code).toContain('"client:only": "preact"'); expect(result.code).toContain('"client:component-hydration": "only"'); // The THIRD argument to $$renderComponent (the component reference) is null: expect(result.code).toMatch(/\$\$renderComponent\(\$\$result, "Counter", null, \{/); expect(result.clientOnlyComponents).toHaveLength(1);});✓ transform(): client:load renders the component AND emits hydration metadata✓ transform(): client:only passes null instead of the component — no server render at all
Test Files 1 passed (1) Tests 2 passed (2)compiled output จริงของ client:load:
import { render as $$render, createComponent as $$createComponent, renderComponent as $$renderComponent } from "astro/runtime/server/index.js";import Counter from "../components/InternalsCounter.jsx";const $$IslandDemo = $$createComponent(($$result, $$props, $$slots) => { return $$render`${$$renderComponent($$result, "Counter", Counter, { "client:load": true, "count": 5, "client:component-hydration": "load", "client:component-path": "../components/InternalsCounter.jsx", "client:component-export": "default" })}`;}, "src/pages/island-demo.astro", undefined);export default $$IslandDemo;…และของ client:only="preact" — สังเกต argument ตัวที่สามของ $$renderComponent เป็น null ไม่ใช่ Counter ที่ import มา:
$$render`${$$renderComponent($$result, "Counter", null, { "client:only": "preact", "count": 5, "client:component-hydration": "only", "client:component-path": "../components/InternalsCounter.jsx", "client:component-export": "default"})}`;null นั้นคือ compiled proof ที่อยู่เบื้องหลัง “client:only ข้าม server rendering ไปเลย” (ดู Client Directives): function renderComponent ของ runtime ไม่มี component reference จริงให้เรียกบน server เลยสำหรับ island ที่เป็น client:only — มีแค่ client:component-path ที่ browser จะ import() ทีหลัง Island Hydration รับต่อจากตรงนี้ ตรงจังหวะที่ metadata นี้กลายเป็น attribute จริงของ <astro-island> element ใน HTML ที่ render ออกมา
ลองเองดู
หัวข้อที่มีชื่อว่า “ลองเองดู”---interface Props { name: string;}const { name } = Astro.props;---<style> .card { color: red; }</style><div class="card"> <h2>Hello {name}</h2> <slot /></div>playground นี้ compile source เดียวกันกับที่แสดงข้างบน — แต่ผ่าน @astrojs/compiler (WASM package รุ่นเก่าที่โหลดใน browser ได้) ไม่ใช่ @astrojs/compiler-rs (native Rust compiler ที่ test transform() ในบทเรียนนี้รันจริง) ทั้งสองตัวเห็นด้วยกันใน รูปร่าง — ทั้งคู่ผลิต body แบบ $$createComponent/$$render พร้อม scoped astro-* class — แต่เป็นคนละ package กันจริง ๆ และ runtime import path ที่แน่นอนกับชื่อ internal helper อาจต่างกันได้ เชื่อ transform() output ที่ test แล้วข้างบนว่าเป็นสิ่งที่ astro check/astro build รันจริง ใช้ playground นี้เพื่อสำรวจ ไม่ใช่เป็น source of truth ที่สอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- คำอธิบายอ้างว่า Astro “แปลความ” ไฟล์
.astroตอน request time แบบ template engine ไหม? ไม่มีขั้นตอนแปลความตอน runtime เลย —transform()ผลิต plain JavaScript call ไปแล้ว พิสูจน์ตลอดบทเรียนนี้ request แค่เรียก function ที่ได้มา - diff แสดง scoped-style hash เปลี่ยนโดยไม่มีเหตุผลที่เห็นได้ไหม? พิสูจน์ข้างบน: hash เป็น function ของ path ของไฟล์ ไม่ใช่ (แค่) content — การเปลี่ยนชื่อ ย้ายไฟล์ หรือ copy-paste component ไปตำแหน่งใหม่ ล้วนเปลี่ยน hash และนั่นคือสิ่งที่คาดหวังได้ ไม่ใช่สัญญาณของความคลาดเคลื่อน
set:htmlถูกใช้กับ value ที่ยังไม่ถูก trust/sanitize เป็น HTML แล้วหรือเปล่า? พิสูจน์ข้างบน: มัน compile เป็น$$unescapeHTMLซึ่งข้าม escape อัตโนมัติของ runtime ไปเลย — กลไกเดียวกับที่{expression}เปล่า ๆ ใช้เพื่อความปลอดภัย- comment สมมติว่า component ที่เป็น
client:only“render บน server แล้วค่อย hydrate” ไหม? พิสูจน์ข้างบน: compiled call ส่งnullในตำแหน่งที่ควรเป็น component reference — ไม่มี server render ให้พูดถึงเลยสำหรับ island ที่เป็นclient:only
🐛 หา bug ในโค้ดนี้
---const { userComment } = Astro.props;---<!-- "using set:html so line breaks in the user's comment render properly" --><p set:html={userComment} />เฉลย
เจตนาของ comment (รักษา line break) ไม่จำเป็นต้องใช้ set:html เลย และการใช้ตรงนี้เป็น bug ที่จริงจังมาก: set:html compile เป็น $$unescapeHTML(userComment) พิสูจน์ข้างบนว่าข้าม HTML escape อัตโนมัติของ runtime ไปเลย ถ้า userComment เป็น input ที่ user ป้อนโดยไม่ trust นี่คือช่องโหว่ stored XSS จริง — <script> หรือ onerror handler ใดก็ตามที่ผู้เข้าชมพิมพ์ใส่ field นั้นจะถูกใส่เข้าไปในหน้าตรง ๆ และรันให้ผู้ชมคนอื่นทุกคน วิธีแก้คือใช้ plain expression ต่อไป (<p>{userComment}</p> ซึ่ง compiled output ข้างบนพิสูจน์ว่ายัง escape อยู่) แล้วจัดการ line break ด้วย CSS (white-space: pre-wrap) หรือ render แต่ละบรรทัดใน element ของตัวเอง — ไม่ใช่ปิด escape สำหรับ text ที่ไม่ trust
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”compiler คือตัวแปลครั้งเดียว ไม่ใช่ตัวแปลความตอน runtime: ทุกการอ่าน prop, ทุก scoped style, ทุก {expression}, ทุก directive (set:html, client:load, client:only) กลายเป็น plain JavaScript call ที่เจาะจงไปยัง astro/runtime/server — ตัดสินใจครั้งเดียวตอนเดินไฟล์ ไม่มีการตัดสินใจใหม่ตอน render การอ่าน compiled output คือการอ่านโปรแกรมจริงที่รัน ไม่มี layer ที่สองซ่อนอยู่ข้างใต้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
native Rust compiler (@astrojs/compiler-rs) แทนตัว WASM |
astro check/astro build เร็วขึ้น ไม่มี overhead ของการ instantiate WASM |
ไม่มี browser build เลย — Astro playground ที่รันในเบราว์เซอร์จริง ๆ (รวมถึง <AstroPlayground> ของบทเรียนนี้) ต้อง fallback ไปใช้ WASM package รุ่นเก่าซึ่งอาจต่างกันเล็กน้อย |
| scoped-style hash ที่มาจากชื่อไฟล์ | deterministic โดยไม่ต้องมี counter ทั้ง build — build สองครั้งของไฟล์เดียวกันได้ค่าตรงกันเสมอ | เปลี่ยนชื่อหรือย้ายไฟล์ทำให้ scoped class ทุกตัวของมันเปลี่ยน ซึ่งดูเป็น diff ใหญ่ใน HTML/CSS ที่ generate สำหรับสิ่งที่จริง ๆ ไม่เปลี่ยนอะไรเลย |
escape อัตโนมัติของ plain {expression} interpolation |
ปลอดภัยโดย default — text ที่ไม่ trust ใส่ markup ไม่ได้ เว้นแต่ directive จะปิดมันตรง ๆ | ตัวปิด (set:html) ดูเหมือนต่างจาก default ที่ปลอดภัยแค่คำเดียว ซึ่งทำให้เผลอใช้ตามความเคยชินได้ง่าย |
client:only ส่ง null สำหรับ component reference |
ไม่เสียเวลา render บน server สำหรับ component ที่รันบนนั้นไม่ได้อยู่แล้ว | HTML แรกของหน้าไม่มี fallback content สำหรับ slot นั้นเลย — network ช้าหมายถึงช่องว่างจริง ๆ จนกว่า client-only bundle จะโหลดเสร็จ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ไฟล์
.astroถูก runtime แปลความ เหมือน template engine อ่านไฟล์.ejs/.hbs” พิสูจน์ว่าผิดตลอดบทเรียนนี้:transform()ผลิต JavaScript module ที่สมบูรณ์ล่วงหน้า “runtime” ของ Astro (astro/runtime/server) เป็นแค่ set เล็ก ๆ ของ helper function ($$createComponent,$$render,$$addAttribute, …) ที่ compiled output เรียกเข้าไป — ไม่มีการ parse template ใหม่ต่อ request - “scoped style hash ที่เปลี่ยนระหว่างไฟล์สองเวอร์ชันแปลว่า CSS เปลี่ยนไปแล้ว” พิสูจน์ว่าผิดข้างบน: เปลี่ยนชื่อไฟล์ที่ content และ CSS เหมือนกันเป๊ะ ได้ hash ต่างกัน hash มาจาก path ไม่ใช่ content
- “
{expression}กับset:html={expression}คือสิ่งเดียวกัน แค่ syntax ต่างกัน” พิสูจน์ว่าผิดข้างบน: ตัวหนึ่ง compile เป็น plain interpolation ที่ escape อัตโนมัติ อีกตัว compile เป็น$$unescapeHTMLซึ่งเป็น call เฉพาะที่ข้าม escape ไปเลย ใช้ตัวผิดกับ input ที่ไม่ trust คือช่องโหว่ XSS จริง ไม่ใช่แค่ความชอบด้าน style