Data Fetching
การ fetch ข้อมูลก็แค่ top-level await
หัวข้อที่มีชื่อว่า “การ fetch ข้อมูลก็แค่ top-level await”component script ของ .astro รันบน server เราจึง fetch ข้อมูลภายนอกด้วยวิธีที่ตรงไปตรงมาที่สุด คือ await fetch() แบบ top-level ไม่มี useEffect, ไม่มี loading state, ไม่มี round-trip ไปฝั่ง client ข้อมูล resolve เสร็จก่อนที่ HTML จะมีอยู่ด้วยซ้ำ
---const res = await fetch('https://api.example.com/products');if (!res.ok) throw new Error(`API returned ${res.status}`);const products: { id: string; name: string; price: number }[] = await res.json();---<ul> {products.map((p) => <li>{p.name} — ${p.price}</li>)}</ul>pattern เดียวกันใช้ได้กับ REST API, database client, หรือ SDK ของ headless CMS เพราะรันบน server ความลับ (API key) จึงอยู่ฝั่ง server ไม่ถูกส่งไป browser
build-time vs request-time: ความต่างที่สำคัญมาก
หัวข้อที่มีชื่อว่า “build-time vs request-time: ความต่างที่สำคัญมาก”ตอนไหน ที่ fetch นั้นรัน ขึ้นกับ rendering mode ของหน้า และนี่คือเรื่องสำคัญที่สุดที่ต้องเข้าใจเกี่ยวกับข้อมูลใน Astro:
- หน้า static (ค่า default) —
fetchรัน ครั้งเดียว ตอน build response ถูกอบเข้าไปในไฟล์ HTML ทุกคนที่เข้ามาได้ snapshot เดียวกันจนกว่าจะ build ใหม่ เหมาะกับ product list, blog index, docs คือข้อมูลที่เปลี่ยนตามรอบ deploy - หน้า on-demand (
prerender = false) —fetchรัน ต่อ request บน server แต่ละคนได้ข้อมูลสดและ personalize ได้ ทั้ง user ปัจจุบัน, inventory สด, ผลค้นหา ต้องใช้ adapter (ดูโมดูล rendering)
---// This page renders per request, so the fetch is live on every visit.export const prerender = false;
const res = await fetch('https://api.example.com/inventory');const inventory = await res.json();---<p>{inventory.inStock} in stock right now</p>แบบทดสอบในใจ: ข้อมูลนี้ต้องสดต่อผู้ชมแต่ละคน หรือ snapshot ตอน build ก็พอ? snapshot พอ → ปล่อยให้ static ไว้ (เร็ว, cache ได้) ต้องสด → เปลี่ยน route นั้นให้ render แบบ on-demand การรีบไปใช้ on-demand ทั้งที่ snapshot ตอน build ก็พอ เป็นวิธีที่คนทิ้งความเร็วของ Astro ไปเปล่า ๆ บ่อยที่สุด
flowchart TB q["ข้อมูลเปลี่ยนต่อ request ไหม?"] q -->|ไม่| static["Static: fetch รันครั้งเดียวตอน build อบเข้า HTML"] q -->|ใช่| ondemand["On-demand: prerender = false, fetch รันต่อ request"] static --> cdn["เสิร์ฟจาก CDN เหมือนกันทุกคน"] ondemand --> server["render บน server ต่อผู้ชม"]
ทางเลือกที่สาม: live content collection
หัวข้อที่มีชื่อว่า “ทางเลือกที่สาม: live content collection”static-กับ-on-demand ไม่ใช่ภาพทั้งหมด live content collection (เพิ่มมาใน Astro 7.0) ให้ข้อมูลตอน request มี shape ที่ validate แล้วและมี type แบบเดียวกับที่ collection ปกติให้ content ตอน build — เป็นทางสายกลางระหว่าง “raw fetch แบบ on-demand” กับ “collection ที่ bake ไว้ตอน build”
มันอยู่ในไฟล์ config แยกต่างหาก src/live.config.ts ประกาศด้วย defineLiveCollection() แทน defineCollection():
import { defineLiveCollection } from 'astro:content';import { productsLoader } from './lib/products-loader';
const products = defineLiveCollection({ loader: productsLoader() });
export const collections = { products };live loader มีหน้าตาต่างจาก loader ปกติ — แทนที่จะมี load() เดียวที่เติม store ครั้งเดียว มันมี loadCollection กับ loadEntry ที่ถูกเรียกใหม่ทุกครั้งที่มี request:
import type { LiveLoader } from 'astro/loaders';
interface Product { id: string; name: string; price: number }
const CATALOG: Product[] = [ { id: 'sku-1', name: 'Sticker Pack', price: 5 }, { id: 'sku-2', name: 'Mug', price: 12 },];
// A tiny in-memory live loader: proves loadCollection/loadEntry run per request,// not at build time (no filesystem/network involved).export function productsLoader(): LiveLoader<Product, { id: string }> { return { name: 'products-loader', loadCollection: async () => { return { entries: CATALOG.map((p) => ({ id: p.id, data: p })) }; }, loadEntry: async ({ filter }) => { const product = CATALOG.find((p) => p.id === filter.id); if (!product) return { error: new Error(`Product ${filter.id} not found`) }; return { id: product.id, data: product }; }, };}query ด้วย getLiveCollection/getLiveEntry (จาก astro:content คู่กับ getCollection/getEntry ฝั่ง build-time) — ผลลัพธ์เป็น shape { data, error } แทนที่จะ throw exception ดังนั้น fetch ที่ fail คือสิ่งที่เราจัดการเอง ไม่ใช่สิ่งที่ crash หน้าเพจ:
---// src/pages/shop/[id].astro — requires an adapter (on-demand rendering)export const prerender = false; // not needed if the whole project is output: 'server'import { getLiveEntry } from 'astro:content';
if (!Astro.params.id) return Astro.redirect('/404');
const { entry, error } = await getLiveEntry('products', Astro.params.id);if (error || !entry) return new Response(error?.message ?? 'Not found', { status: 404 });---<p>{entry.data.name}: ${entry.data.price}</p>พิสูจน์ครบวงจรในโพรบ — build ด้วย Node adapter แล้วยิง curl จริง getLiveCollection/getLiveEntry คืนข้อมูลจริง และ error “not found” ของ loader เองตอนใช้ id ที่ไม่มี:
$ curl http://127.0.0.1:4501/shop/<!DOCTYPE html><ul><li>Sticker Pack — $5</li><li>Mug — $12</li></ul>$ curl http://127.0.0.1:4501/shop/sku-1<!DOCTYPE html><p>Sticker Pack: $5</p>$ curl -w " [status %{http_code}]" http://127.0.0.1:4501/shop/nopeProduct nope not found [status 404]นอกจาก error ของ loader เอง getLiveCollection/getLiveEntry ยังคืน LiveEntryNotFoundError (ไม่เจอ entry ที่ตรงกัน), LiveCollectionValidationError (data ไม่ผ่าน schema ที่ตั้งไว้ให้ live collection), หรือ LiveCollectionCacheHintError ได้ด้วย — เช็คด้วย instanceof เมื่อต้องแยกว่าเกิด error แบบไหน
live collection ต้องมี adapter เหมือน on-demand rendering ทั่วไป — มันรัน loadCollection/loadEntry ตอน request ดังนั้น build แบบ static ล้วน ๆ (output: 'static' ไม่มี adapter) ไม่มีที่ให้มันรันต่อ request เลย แต่ถึงอย่างนั้น Astro ก็ไม่ปฏิเสธการ build หน้าที่เรียก getLiveCollection ถ้าหน้านั้นสุดท้ายกลายเป็น prerendered เอง (ไม่ว่าจะทั้งโปรเจกต์เป็น static หรือหน้านั้นตั้ง prerender = true) มันแค่รัน loader ครั้งเดียวตอน build เหมือนแหล่งข้อมูลอื่น ๆ — เงียบ ๆ โดยไม่มี warning เลยว่าการรับประกันความ “live” หายไปแล้ว พิสูจน์แล้วในโพรบ: src/pages/shop/prerendered.astro ที่เหมือนกันทุกอย่างแต่ตั้ง export const prerender = true build ผ่านสะอาดและ bake catalog ลง static HTML ตอน build ไม่ใช่ต่อ request live collection จะ “live” จริงก็ต่อเมื่ออยู่บน route ที่ render on-demand จริง ๆ เท่านั้น
fetch เทียบกับ live collection เทียบกับ collection ปกติ
หัวข้อที่มีชื่อว่า “fetch เทียบกับ live collection เทียบกับ collection ปกติ”ตอนนี้มีสามเครื่องมือตอบคำถาม “ข้อมูลนี้มาจากไหน” และมันหยิบผิดได้ง่ายจากความเคยชินมากกว่าความเหมาะสม:
fetchตรง ๆ — มีโครงสร้างน้อยที่สุด ไม่มี schema ไม่มี entry ที่มี type ต้อง parse และ validate response เอง เหมาะกับ call ที่ใช้ครั้งเดียวไม่ reuse- content collection (
getCollection/getEntry) — build-time, validate ด้วย schema, มี type ตัวเลือกที่ถูกสำหรับ content ที่เปลี่ยนตามรอบ deploy - live collection (
getLiveCollection/getLiveEntry) — request-time, shape ที่ validate/มี type เหมือน collection แต่จัดการ{ data, error }ตรง ๆ ตัวเลือกที่ถูกสำหรับข้อมูลต่อผู้ชมหรือที่เปลี่ยนบ่อย ที่ยังอยากได้โครงสร้างและ validation แบบ collection ไม่ใช่แค่fetchดิบ ๆ
Headless CMS
หัวข้อที่มีชื่อว่า “Headless CMS”headless CMS (Contentful, Sanity, Storyblok, WordPress API) ก็เป็นแค่แหล่งข้อมูลอีกแหล่ง fetch ใน component script ได้เลย บ่อยครั้งผ่าน SDK ของ CMS แล้ว map response ลง template สำหรับ content ที่เราเป็นเจ้าของและแก้เป็นไฟล์เอง ให้ใช้ content collection ที่มี loader ส่วน content ที่จัดการในระบบภายนอก ให้ fetch เอาเอง (หรือใช้ community loader ที่ห่อ CMS ไว้หลัง collections API) ไม่ว่าทางไหนโค้ดหน้าเพจก็หน้าตาเหมือนกัน คือดึงข้อมูลบน server แล้ว render HTML
แล้ว Astro.glob() ล่ะ?
หัวข้อที่มีชื่อว่า “แล้ว Astro.glob() ล่ะ?”โค้ด Astro รุ่นเก่าใช้ Astro.glob() ดึงไฟล์ในเครื่องหลายไฟล์ (Markdown ทั้งหมดในโฟลเดอร์) พร้อมกัน Astro 6 ถอดมันออกไปทั้งหมด (แหล่งที่มา: Astro v6 upgrade guide) — ตัวแทนคือ content collection ซึ่งให้พลัง “โหลดหลายไฟล์” แบบเดียวกัน บวก schema validation และ type ที่ generate ให้ (ถ้าต้องการ glob ไฟล์ดิบ ๆ ที่ไม่ใช่ content ให้ใช้ import.meta.glob() ของ Vite แทน) สำหรับ content ในเครื่อง ให้ประกาศ collection ด้วย glob loader ส่วน fetch ดิบ ๆ เก็บไว้ใช้กับข้อมูลภายนอกจริง ๆ
พิสูจน์แล้วในโพรบบน Astro 7.3.5 — Astro.glob ไม่มีอยู่บน global เลยแล้ว astro check เลยจับได้ตั้งแต่ระดับ type ก่อน build จะรันด้วยซ้ำ:
error ts(2339): Property 'glob' does not exist on type'Readonly<AstroGlobal<Record<string, any>, AstroComponentFactory, Record<string, string | undefined>>>'.
const posts = await Astro.glob('../../data/blog/*.md'); ~~~~Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มี timeline การ render สามแบบที่ตอบคำถามต่างกันสามแบบ และการสับสนกันคือความผิดพลาดเรื่อง data-fetching ที่พบบ่อยที่สุดใน Astro fetch ธรรมดากับ content collection ทั้งคู่ resolve ครั้งเดียว ก่อนจะส่ง response — collection แค่ทำแบบนั้นพร้อม validation และ type ทับไว้ live collection resolve ใหม่ทุก request — แต่จะเกิดขึ้นก็ต่อเมื่อ route ที่เรียกมันจริง ๆ render on-demand เรียกมันจาก route ที่สุดท้ายถูก prerender อยู่ดี (ด้วย config โปรเจกต์หรือ prerender = true ระดับหน้า) แล้วมันจะยุบเงียบ ๆ ไปเป็น “resolve ครั้งเดียว” เหมือนอีกสองแบบ โดยไม่มี error บอกว่าข้อมูล “live” ของเรากลายเป็นข้อมูลค้างตั้งแต่ตอน build แล้ว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
fetch ดิบ |
ไม่ต้องเขียน schema ใช้ได้ทุกที่ | validate และใส่ type ให้ response เอง ไม่มี reuse ข้ามหน้าถ้าไม่เขียน helper เอง |
| content collection | validate ด้วย schema, มี type, generate ตอน build | เป็นแค่ snapshot — ไม่มีทางสดกว่า build ล่าสุด ไม่ว่าผู้ชมต้องการอะไร |
| live collection | shape validate/มี type เหมือน collection แต่ resolve ต่อ request | ต้องมี adapter และ route ที่ on-demand จริง — เงียบ ๆ ตกไปเป็น build-time ถ้า route ถูก prerender |
{ data, error } จาก getLiveCollection/getLiveEntry |
ความล้มเหลวเป็นค่าที่เช็คได้ ไม่ใช่ exception ที่ crash หน้าเพจ | ทุกจุดที่เรียกต้องจำเช็ค error เอง ลืมเช็คจะได้ data เป็น undefined แทนที่จะ fail ดัง ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “live collection ก็แค่ fetch ที่มีขั้นตอนเพิ่ม” — live collection ให้ schema validation และ
dataที่มี type แบบเดียวกับ collection ตอน build ให้ ส่วนfetchไม่ให้อะไรทั้งสองอย่างถ้าไม่เขียนเอง ส่วนที่ “live” คือ ตอนไหน ที่มันรัน ไม่ใช่ ว่ามัน validate หรือเปล่า - “live collection สดเสมอ ไม่ว่าจะเรียกจากที่ไหน” — มันสดแค่บน route ที่ render on-demand จริง ๆ บน route ที่ prerender (ทั้งโปรเจกต์เป็น
output: 'static'หรือหน้านั้นตั้งprerender = true) Astro รัน loader ครั้งเดียวตอน build โดยไม่มี warning — หน้ายังทำงานได้ แค่ไม่ live แล้ว - “
getLiveEntrythrow เหมือน async function ปกติเมื่อไม่เจอ entry” — มันคืน{ error }แทนที่จะ throw โค้ดที่เช็คแค่if (entry)โดยไม่เช็คerrorก่อน จะอ่านundefinedออกมาจาก call ที่ดูเหมือนสำเร็จ - “
Astro.glob()ยังใช้ได้อยู่ แค่ไม่แนะนำ” — มันถูก deprecate ตั้งแต่ Astro 5 และถอดออกเต็ม ๆ ใน Astro 6/7 การเรียกมันไม่ใช่แค่เรื่องสไตล์ แต่เป็น type error (Property 'glob' does not exist) ที่astro checkจับได้ก่อนจะ build ด้วยซ้ำ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ snippet ที่ generate มา fetch ข้อมูล — ด้วย fetch, collection, หรือ live collection — ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- มันเรียก
Astro.glob(...)หรือเปล่า? training data ก่อน Astro 6 ยังผลิตแบบนี้บ่อยมาก — มันไม่มีอยู่แล้ว แทนที่ด้วย content collection (หรือimport.meta.globสำหรับกรณีที่ไม่ใช่ content) - call ไปที่
getLiveCollection/getLiveEntryเช็คerrorที่คืนมาก่อนใช้data/entriesไหม หรือสมมติว่าสำเร็จแบบที่fetch().then()chain อาจทำ? - loader ของ live collection (หรือหน้าที่เรียกมัน) ขาด adapter/การตั้ง on-demand หรือเปล่า? มันจะไม่ error — แค่เงียบ ๆ resolve ครั้งเดียวตอน build ทำลายจุดประสงค์โดยไม่มี warning
- หน้าที่ต้องการความสดต่อผู้ชมจริง ๆ พึ่ง
getCollectionแบบ build-time ธรรมดาอยู่หรือเปล่า ทั้งที่ข้อมูลเปลี่ยนเร็วกว่ารอบ deploy? - ความลับ (API key, token) ที่ใช้ใน top-level
fetchหลุดไปอยู่ใน script ฝั่ง client หรือ prop ของ component หรือเปล่า แทนที่จะอยู่แค่ใน component script ฝั่ง server เท่านั้น?
🐛 หา bug ในโค้ดนี้
---const posts = await Astro.glob('../data/blog/*.md');---<ul>{posts.map((p) => <li>{p.frontmatter.title}</li>)}</ul>เฉลย
Astro.glob() ถูก deprecate ตั้งแต่ Astro 5 และ ถอดออกไปทั้งหมด ใน Astro 6 — มันไม่มีอยู่บน Astro global เลยแล้ว พิสูจน์แล้วในโพรบบน Astro 7.3.5: astro check fail ตั้งแต่ระดับ type ด้วย Property 'glob' does not exist on type 'Readonly<AstroGlobal<...>>' มันจึงไม่ถึง build ด้วยซ้ำ — เป็น compile-time error ไม่ใช่ runtime surprise ทางแก้คือ content collection: ประกาศด้วย glob loader ใน src/content.config.ts แล้ว query ด้วย getCollection('blog') ซึ่งยัง validate และใส่ type ให้ทุก entry แทนที่จะคืน frontmatter แบบไม่มี type