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

Images & Assets

ในหน้าเว็บส่วนใหญ่ image คือ bytes ที่ใหญ่ที่สุดบนสาย — hero photo ที่ไม่ optimize อาจหนักกว่า HTML, CSS และ JavaScript รวมกัน Astro มี image pipeline อยู่ในโมดูล astro:assets ที่มีมาให้ในตัว เราจึงได้ format สมัยใหม่, dimension ที่ถูกต้อง และ lazy loading โดยไม่ต้องทำเอง

เมื่อ import local image เข้ามาแล้ว render ด้วย <Image /> Astro จะ process ภาพนั้นตอน build time — แปลงเป็น format ที่มีประสิทธิภาพ (เช่น WebP/AVIF) และบังคับให้ระบุ width/height เพื่อให้ browser จองพื้นที่ไว้ (ไม่มี layout shift):

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.png'; // a local import
---
<Image src={heroImage} alt="A mountain at sunrise" width={800} height={400} />

สิ่งที่ <Image /> ทำแต่ <img> ธรรมดาไม่ทำ:

  • optimize ไฟล์ — re-encode เป็น format สมัยใหม่ที่เล็กกว่าอัตโนมัติ
  • ป้องกัน layout shift — บังคับ width/height (infer มาจาก local import) พื้นที่จึงถูกจองก่อน image โหลด
  • lazy-load โดย default — ใส่ loading="lazy" และ decoding="async" ให้ image ที่อยู่นอกจอไม่ block การ render แรก

สำหรับ local import Astro รู้ dimension จริงอยู่แล้ว เราจึงละ width/height ได้และจะเติมให้เอง

<Image /> และ <Picture /> รับ prop layout — 'constrained', 'fixed', 'full-width' หรือ 'none' (ค่า default) — และมี key image.layout ใน astro.config.mjs เอาไว้ตั้งค่า default ทั้งเว็บ ไม่ต้องเซ็ตซ้ำทุก image เมื่อ layout ไม่ใช่ 'none' Astro จะ generate srcset จริงที่ breakpoint width หลายค่า บวก attribute sizes และประทับ data-astro-image="<layout>" บน <img> ให้ CSS เฉพาะ layout นั้น (จัดการ object-fit/object-position)

พิสูจน์กับ [email protected] ที่ติดตั้งจริง — source image 800×400 หนึ่งรูป กับค่า layout สามแบบ build ด้วย astro build:

<!-- layout="constrained" -->
<img src="/_astro/hero.BFDgznys_miIz9.webp"
srcset="/_astro/hero.BFDgznys_ZIPmIq.webp 640w, /_astro/hero.BFDgznys_h6wjP.webp 750w, /_astro/hero.BFDgznys_miIz9.webp 800w"
alt="constrained" loading="lazy" decoding="async"
sizes="(min-width: 800px) 800px, 100vw"
data-astro-image="constrained" data-astro-image-fit="cover" data-astro-image-pos="center"
width="800" height="400">
<!-- layout="fixed" — a single width in the srcset, sizes is a fixed pixel value -->
<img src="/_astro/hero.BFDgznys_miIz9.webp"
srcset="/_astro/hero.BFDgznys_miIz9.webp 800w"
alt="fixed" loading="lazy" decoding="async"
sizes="800px"
data-astro-image="fixed" data-astro-image-fit="cover" data-astro-image-pos="center"
width="800" height="400">
<!-- layout="full-width" — sizes is 100vw, and the 800w step is dropped (image always fills the viewport) -->
<img src="/_astro/hero.BFDgznys_miIz9.webp"
srcset="/_astro/hero.BFDgznys_ZIPmIq.webp 640w, /_astro/hero.BFDgznys_h6wjP.webp 750w"
alt="full-width" loading="lazy" decoding="async"
sizes="100vw"
data-astro-image="full-width" data-astro-image-fit="cover" data-astro-image-pos="center"
width="800" height="400">
  • constrained — ไม่เกิน dimension ของตัวเอง แต่ย่อได้บน viewport เล็ก (ตัวเลือกทั่วไป)
  • fixed — render ที่ intrinsic size เสมอ; srcset ยังมี density variant ไม่ใช่ width variant
  • full-width — เต็ม viewport เสมอ (hero banner) ดังนั้น sizes เป็น 100vw ตรง ๆ

เมื่ออยากให้ browser เลือก format เอง หรือ serve crop ต่างกันในแต่ละ breakpoint ให้ใช้ <Picture /> ซึ่ง render ออกมาเป็น element <picture> ที่มีหลาย <source>:

---
import { Picture } from 'astro:assets';
import hero from '../assets/hero.png';
---
<Picture src={hero} formats={['avif', 'webp']} alt="Sunrise" width={800} height={400} />

output จริงของ fence นี้ (astro build, source image เดียวกัน):

<picture>
<source srcset="/_astro/hero.BFDgznys_2jcwc1.avif" type="image/avif">
<source srcset="/_astro/hero.BFDgznys_Z2gYy6G.webp" type="image/webp">
<img src="/_astro/hero.BFDgznys_VT9BX.png" alt="Sunrise" loading="lazy" decoding="async" width="800" height="400">
</picture>

browser เลือก <source> แรกที่รองรับ (AVIF แล้วค่อย WebP) และ fallback ไปที่ <img> ธรรมดา (format ต้นฉบับ re-encode แล้ว) — ได้ compression ที่ดีที่สุดเท่าที่ browser ของผู้ชมแต่ละคนรับได้

import ไฟล์ .svg ตรง ๆ แล้ว Astro จะแปลงมันเป็น component จริง — render แบบ <Logo /> แทนที่จะเป็น <img src={logoUrl} /> prop เพิ่มเติมที่ส่งเข้าไปจะกลายเป็น literal HTML attribute บน root <svg> (Astro ไม่ได้ map prop ให้ viewBox/scaling อย่างฉลาด):

---
import Logo from '../assets/logo.svg';
---
<Logo size={32} class="brand" />

markup จริงที่ build ออกมา (src/assets/logo.svg ต้นฉบับคือ <svg viewBox="0 0 24 24" width="24" height="24">):

<svg viewBox="0 0 24 24" width="24" height="24" size="32" class="brand"><circle cx="12" cy="12" r="10" /></svg>

class merge เข้าไปตามที่คาดไว้ แต่ size="32" เป็นแค่ attribute เฉย ๆ บน <svg> — มันไม่ทำให้ขนาดเปลี่ยนเลย เพราะ width/height เดิม (24) ของ SVG ยังชนะอยู่ ถ้าอยากให้ prop คุมขนาด render จริง ต้องแก้ source SVG ให้ reference มัน (เช่น author SVG ด้วย width={size} height={size} ก่อน import หรือถอด width/height แบบ hardcode ออกแล้วคุมขนาดด้วย CSS แทน)

  • local image (import จาก src/) ถูก optimize เต็มที่และรู้ dimension ตอน build — ควรใช้แบบนี้เสมอ
  • image ใน public/ ถูก serve ตามเดิม ไม่ optimize (Astro ไม่แตะ public/ เลย) ใช้เฉพาะกับของที่ต้องคง path/ชื่อไว้เป๊ะ ๆ
  • remote image (URL) optimize ได้เหมือนกัน แต่ Astro ไม่รู้ dimension ล่วงหน้า จะส่ง width/height เองก็ได้ หรือตั้ง inferSize แล้ว Astro จะ fetch image ตอน build (หรือตอน request สำหรับหน้า on-demand) เพื่ออ่าน dimension จริง

พิสูจน์แล้ว — remote PNG พร้อม inferSize ไม่มี width/height ระบุเอง:

<Image src="https://raw.githubusercontent.com/github/explore/main/topics/astro/astro.png" inferSize alt="remote infer" />
<img src="/_astro/astro_FfxqH.webp" alt="remote infer" loading="lazy" decoding="async" width="288" height="288">

Astro fetch ไฟล์จริง อ่านค่า 288×288 จากมัน แล้ว optimize เข้า build output — และ fetch นั้นแหละคือต้นทุน: inferSize หมายถึง network round trip เพิ่มอีกครั้ง (ตอน build สำหรับหน้า prerender หรือตอน request สำหรับหน้า on-demand) ก่อนที่ image จะเริ่ม optimize ได้ด้วยซ้ำ เทียบกับไม่มี request เพิ่มเลยถ้าเรารู้และส่ง dimension เองอยู่แล้ว

docs อธิบาย image.domains/image.remotePatterns ว่าเป็น allowlist สำหรับ remote image ซึ่งจริง — แต่พิสูจน์กับ compiler ที่ติดตั้งจริงแล้ว จุดที่เช็คไม่ใช่ตามที่คนส่วนใหญ่คิด:

<!-- astro.config.mjs only allows "raw.githubusercontent.com" -->
<Image src="https://not-allowed.example.com/photo.jpg" width={300} height={200} alt="bad host" />

เมื่อมี width/height ระบุเอง (ไม่มี inferSize) ตัวนี้ build ผ่าน และรันแบบ on-demand ได้ โดยไม่มี error เลย — image service ของ Astro (local) ข้าม optimize เงียบ ๆ แล้วปล่อย URL เดิมออกไปตรง ๆ:

<img src="https://not-allowed.example.com/photo.jpg" alt="bad host" loading="lazy" decoding="async" width="300" height="200">

แต่ถ้าใส่ inferSize เข้าไปกับ URL ที่ไม่อนุญาตตัวเดียวกัน มัน throw ทันที — เพราะการอ่าน dimension จริงต้อง fetch URL ก่อน และ Astro เช็ค allowlist ก่อน fetch:

[RemoteImageNotAllowed] Remote image https://not-allowed.example.com/photo.jpg is not allowed by your image configuration.
Hint:
Update `image.domains` or `image.remotePatterns`, or remove `inferSize` for this image.
Error reference:
https://docs.astro.build/en/reference/errors/remote-image-not-allowed/

ดังนั้น allowlist มีจริง แต่มันคือ gate บน path ที่ต้อง fetch (optimize หรือ infer dimension) ไม่ใช่ gate การ render <Image> แบบ remote ทั้งหมด — width/height แบบ hardcode บน host ที่ไม่อนุญาตจะเงียบ ๆ ส่ง <img> ที่ไม่ optimize ไปยัง host นั้นแทนที่จะทำให้ build fail เรื่องนี้ควรจับใน review (ดู checklist ด้านล่าง)

priority (boolean) เป็น prop จริงบน <Image />/<Picture /> ใน 7.3.5 ตรวจสอบตรงจาก astro/dist/assets/services/service.js และ astro/dist/assets/internal.js มันไม่แตะ CSS หรือลำดับ element เลย — แค่พลิก attribute HTML สามตัวให้เป็นค่า “โหลดเดี๋ยวนี้”:

ไม่ตั้ง priority (default) ตั้ง priority
loading lazy eager
decoding async sync
fetchpriority (ไม่ตั้ง) high

ใช้กับ image เดียวที่สำคัญต่อ Largest Contentful Paint (ปกติคือ hero) — ไม่ใช่ทุก image ไม่งั้นเท่ากับปิด lazy loading ทั้งเว็บ

font pipeline ของ Astro เป็น array config fonts แบบ stable ระดับบนสุด ใน 7.3.5 — ตรวจสอบตรงจาก schema ที่ติดตั้งจริง (astro/dist/core/config/schemas/base.js: fonts: z.array(FontFamilySchema).optional() ไม่มี wrapper experimental เลย) ตั้ง font family ด้วย provider (built-in: adobe, bunny, fontshare, fontsource, google, googleicons, local, npm — จาก fontProviders ที่ import จาก astro/config) แล้ว render ด้วย <Font /> จาก astro:assets:

astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
export default defineConfig({
fonts: [
{ provider: fontProviders.google(), name: 'Inter', cssVariable: '--font-inter' },
],
});
---
import { Font } from 'astro:assets';
---
<Font cssVariable="--font-inter" preload />

พิสูจน์ด้วย build จริง (provider google() download font ตอน build จริง ๆ — log ตอน build ขึ้น [assets] Copying fonts (2 files)... มีไฟล์ .woff2 จริงสองไฟล์ลงใน dist/client/_astro/fonts/):

<style>
@font-face{font-family:Inter-d43fecb2c6d8ceb0;src:url("/_astro/fonts/863c6d4ebf55aa09.woff2") format("woff2");font-display:swap;unicode-range:U+0000-00FF,...;font-weight:400;font-style:italic;}
@font-face{font-family:Inter-d43fecb2c6d8ceb0;src:url("/_astro/fonts/233f952ab2955996.woff2") format("woff2");font-display:swap;unicode-range:U+0000-00FF,...;font-weight:400;font-style:normal;}
@font-face{font-family:"Inter-d43fecb2c6d8ceb0 fallback: Arial";src:local("Arial");font-display:swap;font-weight:400;font-style:italic;size-adjust:107.7766%;ascent-override:89.885%;descent-override:22.3806%;line-gap-override:0%;}
@font-face{font-family:"Inter-d43fecb2c6d8ceb0 fallback: Arial";src:local("Arial");font-display:swap;font-weight:400;font-style:normal;size-adjust:107.7766%;ascent-override:89.885%;descent-override:22.3806%;line-gap-override:0%;}
:root{--font-inter:Inter-d43fecb2c6d8ceb0,"Inter-d43fecb2c6d8ceb0 fallback: Arial",sans-serif;}
</style>
<link rel="preload" href="/_astro/fonts/863c6d4ebf55aa09.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/_astro/fonts/233f952ab2955996.woff2" as="font" type="font/woff2" crossorigin>

font ที่ self-host ถูก download ครั้งเดียวตอน build (ไม่มี request third-party ตอน runtime) และ Astro ยัง generate local fallback @font-face ที่ปรับขนาดให้เข้ากัน (size-adjust/ascent-override/descent-override) ให้ font ระบบที่ fallback กินพื้นที่กล่องเดียวกับ font จริงก่อนมันจะโหลดเสร็จ — ตัด layout shift ที่ web font แบบ swap มักทำให้เกิด

flowchart TB
  local["Imported from src/"] --> opt["Optimized, dimensions known"]
  remote["Remote URL, explicit width/height"] --> silent["Optimized if domain allowed, else silently unoptimized — no error"]
  inferred["Remote URL, inferSize"] --> checked["Domain checked before fetch — throws RemoteImageNotAllowed if not"]
  pub["In public/"] --> asis["Served as-is, not optimized"]
ที่มาของรูปเป็นตัวกำหนดวิธีจัดการ

เวลา review snippet ที่ AI generate มาเกี่ยวกับ image หรือ font เช็คสิ่งเหล่านี้:

  • เป็น <img> ธรรมดาสำหรับ asset local ที่อยู่ใน src/assets/ หรือเปล่า? แบบนั้นข้าม optimize, ป้องกัน layout-shift และ lazy-loading ไปเลย — ควรเป็น <Image src={imported} />
  • <Image> แบบ remote มี width กับ height hardcode ทั้งคู่ บน host ที่ไม่ได้อยู่ใน image.domains/remotePatterns หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: มันไม่ error แต่จะเงียบ ๆ ส่ง <img> ที่ไม่ optimize ไปยัง URL remote ตรง ๆ — เป็น regression เงียบ ไม่ใช่ build fail
  • inferSize ถูกใช้กับ asset ใน public/ หรือ local import หรือเปล่า? พิสูจน์ไปก่อนหน้านี้แล้ว (FailedToFetchRemoteImageDimensions): inferSize มีไว้กับ URL remote จริง ๆ เท่านั้น
  • component .svg ที่ import มา ได้รับ prop width/height/size ที่ผู้เขียน คาดหวัง ว่าจะ resize มัน ทั้งที่ markup ของ SVG เอง hardcode attribute พวกนั้นอยู่หรือเปล่า? พิสูจน์ข้างบนแล้วว่า: prop เพิ่มเติมกลายเป็น attribute เฉย ๆ และไม่ทำอะไรเลยนอกจาก SVG เองจะถูก author ให้ใช้มัน
  • priority ถูกโรยไปมากกว่าหนึ่ง image ต่อหน้าหรือเปล่า? มัน map ไปที่ fetchpriority="high" — ใส่ทุกที่เท่ากับทำลายจุดประสงค์ของมันเอง (ไม่เหลืออะไรให้ deprioritize)
  • config fonts หรือการใช้ <Font /> ถูก gate ไว้หลัง experimental.fonts หรือเปล่า? flag นั้นไม่มีใน 7.3.5 แล้ว — fonts เป็น key ระดับบนสุดที่ stable แล้ว

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

src/pages/product/[slug].astro
---
import { Image } from 'astro:assets';
const { product } = Astro.props;
---
<Image src={product.imageUrl} width={600} height={400} alt={product.name} />
เฉลย

product.imageUrl เป็น URL remote (จาก CMS หรือ API) ไม่มี inferSize และ image.domains/remotePatterns ใน config ของเว็บนี้ไม่ได้ list host asset ของ CMS ไว้ พิสูจน์ข้างบนแล้วว่า: มันไม่ทำให้ build fail หรือ throw ตอน request — image service (local) ของ Astro เช็ค isRemoteAllowed ใน getURL() และเมื่อ false มันแค่ return src เดิมกลับไป หน้านี้จึงเงียบ ๆ ส่ง <img src="https://cms-host/..."> ที่ไม่ optimize ให้ทุก product จาก host นั้น ทางแก้คือเพิ่ม domain ของ CMS เข้า image.domains/remotePatterns (เพื่อให้ optimize จริง) หรือถ้าตั้งใจไม่ optimize host นี้จริง ๆ ก็ใช้ <img> ธรรมดาไปเลยแทนที่จะใช้ <Image> ที่ดูเหมือน optimize แต่จริง ๆ ไม่ได้ทำ

<Image />/<Picture /> คือ transform ตอน build (หรือตอน request สำหรับหน้า on-demand) ไม่ใช่ component ที่ทำงานตอน runtime — prop ทุกตัวที่ส่งเข้าไป (layout, formats, priority, inferSize) เปลี่ยน HTML ที่ generate ออกมา ครั้งเดียว ไม่ใช่สิ่งที่เกิดขึ้นใน browser หลังจากนั้น allowlist image.domains/remotePatterns ถูกเช็คเฉพาะ path ที่ต้อง fetch ไฟล์ remote (optimize มัน หรือ infer dimension) — remote image ที่เราอธิบายเองครบแล้ว (width+height ไม่มี inferSize) ไม่ต้อง fetch เลย จึงไม่โดนเช็คเลย แล้วก็ผ่านไปตรง ๆ

Local import public/ Remote, ระบุ dimension เอง Remote, inferSize
Optimize ใช่ ไม่ เฉพาะถ้า domain อนุญาต เฉพาะถ้า domain อนุญาต
Dimension รู้อัตโนมัติ N/A เราระบุเอง Astro fetch มาอ่าน
Request เพิ่ม ไม่มี ไม่มี ไม่มี หนึ่งครั้ง ตอน build หรือต่อ request
Domain ไม่อนุญาต N/A N/A ไม่ optimize เงียบ ๆ ไม่ error throw RemoteImageNotAllowed
  • “image.domains/remotePatterns ป้องกัน host ที่ไม่ได้รับอนุญาตเสมอ” พิสูจน์ข้างบนแล้วว่า: มัน gate เฉพาะ path ที่ต้อง fetch (inferSize หรือ fetch เพื่อ optimize) — <Image> แบบ remote ที่ hardcode width/height บน host ที่ไม่อนุญาต build ผ่านสบาย ๆ แล้วก็แค่ serve image แบบไม่ optimize
  • “ส่ง prop size หรือ width ให้ SVG-as-component แล้วมันจะ resize” พิสูจน์ข้างบนแล้วว่า: prop เพิ่มเติมกลายเป็น attribute เฉย ๆ บน root <svg>; ถ้า width/height ของ SVG เอง hardcode ไว้ มันก็ยังชนะอยู่ดี
  • “priority เป็น toggle ทำให้ image โหลดไวขึ้น โรยได้ทั่วไป” มันเซ็ต loading="eager"/decoding="sync"/fetchpriority="high" ตรง ๆ — มีประโยชน์กับ image above-the-fold ตัวเดียว แต่ถ้าใส่หลายที่จะสวนทางกับจุดประสงค์
  • “Fonts API เป็น experimental ต้องมี flag experimental.fonts” ตรวจสอบตรงกับ schema 7.3.5 ที่ติดตั้งจริงแล้วว่า: fonts เป็น array config ระดับบนสุดที่ stable ธรรมดา
prop layout (constrained/fixed/full-width) เปลี่ยนอะไรจริง ๆ ใน output ที่ build ออกมา?
not-allowed.example.com ไม่ได้อยู่ใน image.domains/remotePatterns เกิดอะไรขึ้นตอน build จริง ๆ?
<Image src="https://not-allowed.example.com/photo.jpg" width={300} height={200} alt="x" />
host ที่ไม่อนุญาตตัวเดิม แต่คราวนี้ใช้ inferSize แทน width/height ที่ระบุเอง เกิดอะไรขึ้น?
source SVG คือ <svg viewBox="0 0 24 24" width="24" height="24"> size={32} ทำอะไรใน HTML ที่ build ออกมา?
---
import Logo from '../assets/logo.svg';
---
<Logo size={32} class="brand" />
prop priority เซ็ตอะไรบน <Image /> เทียบกับ default?
Fonts API ของ Astro (config fonts + <Font />) เป็น experimental ใน 7.3.5 หรือเปล่า?