[CODE]

在 Astro 靜態頁面追 LCP 與 CLS

Lighthouse Mobile 76,業主 Quote 寫的是 ≥ 90。Hero image 274ms 就下完了,瀏覽器還等了 1,756ms 才能畫面。三個根本原因,兩個跟圖片無關——CSS、preload scanner 盲點、Google Fonts。

3 min read AI 生成
astro performance core-web-vitals cloudflare css

Hero image 274ms 下完了。瀏覽器還要再等 1,756ms 才能畫面。

Lighthouse Mobile 給首頁 76 分。業主的報價單寫的是「Lighthouse ≥ 90」。這不是一個可以選擇性達成的數字。

分數為什麼是基礎建設

這個 intro page 是一間恆春民宿 Google Ads 廣告的落地頁。網站存在的目的是把付費流量轉換成直訂——透過 LINE 或電話聯絡,不走 OTA,因為 OTA 的抽成比多數人以為的高很多。每一筆直訂在結構上都比走 OTA 值錢,但前提是廣告能用合理的 CPC 跑。

Google Ads Quality Score 裡面有 landing page experience 這個維度,而 landing page experience 把 Core Web Vitals 算進去。廣告直打的那個 URL 在 Lighthouse Mobile 拿到 76,等於從第一天起就在每一次點擊上徵稅,整個旺季累下來差距不小。把三個指標推進「良好」範圍,不是在優化報表——是讓廣告的成本前提成立。

原因一:圖片到了,CSS 還沒到,瀏覽器只能等

Astro 的 build.inlineStylesheets 預設是 'auto':小於 ~4KB 的 stylesheet 內聯進 HTML,大於的單獨輸出 /_astro/index.*.css,再用 <link> 引入。這個網站的共用 bundle 是 12KB,超過門檻,所以 Astro 輸出了一個獨立的 CSS 檔。瀏覽器必須另發一個 request、拿到 CSS、parse 完,才能建 render tree,才能把圖畫出來。

加好 preload 之後(見原因二),這件事就可以被單獨量測了:就算 hero AVIF 只花 274ms 就下載完成,「element render delay」——圖片到 memory 到實際畫面之間的空檔——量到 1,756ms。瀏覽器在等 CSS。

astro.config.mjs 加一行解掉了這件事:

build: {
  inlineStylesheets: 'always',
}

所有 stylesheet 現在都以 <style> tag 形式內聯在 HTML 裡,跟 HTML 一起回來,不需要額外的 round trip。Element render delay 從 1,756ms 降到 50ms 以下。

代價:每個頁面的 HTML 多了 ~12KB inline CSS,不能跨頁快取複用。48 個靜態頁面加起來 dist 多了約 576KB。可接受——Cloudflare 在 edge 壓縮 HTML(gzip 約 70% reduction),wire 上每頁多 3-4KB,換回 1,700ms render delay 是划算的。

原因二:Preload scanner 看不見 <picture><source>

瀏覽器的 preload scanner 是一個在 HTML 下載期間並行運作的次要 parser,它往 byte stream 前面看、提前找資源去 fetch。它看得到 <img src><link rel="preload">,但看不懂 <picture><source> 的選擇邏輯——它不知道最後要選 AVIF 還是 WebP,所以整個 block 跳過。

Hero image 包在 <picture> 裡,<source type="image/avif"> 加 WebP fallback <img>,這是標準的 responsive multi-format 寫法。但 preload scanner 完全沒看到它。瀏覽器要等到 HTML parser 解析到 <picture> 那一行,才知道要去抓哪個 AVIF——這個發現在 critical path 上來得太晚。

修法是在 <head> 加明確的 responsive AVIF preload,由 layout component 的 heroPreload prop 驅動:

<link
  rel="preload"
  as="image"
  type="image/avif"
  imagesrcset="/images/home/hero-coast-480.avif 480w,
               /images/home/hero-coast-960.avif 960w,
               /images/home/hero-coast-1600.avif 1600w,
               /images/home/hero-coast-2400.avif 2400w"
  imagesizes="100vw"
  fetchpriority="high"
/>

imagesrcset 讓 preload scanner 在 HTML parse 階段就能挑對的尺寸並行下載。等瀏覽器後來解析到 <picture> 的 source selection 邏輯,那個 AVIF 已經在 cache 裡了,直接畫。

Preload 裡的寬度清單和 ResponsivePicture 吐出的 srcset 寬度必須保持一致,兩邊各留了一個 comment 強制這個 coupling。

原因三:Google Fonts 多了 250ms,換的東西不值這個錢

Cormorant Garamond 加 Nunito,<head> 裡要放四個 <link>:preconnect ×2、font CSS preload、stylesheet。就算有 font-display: swap,Lighthouse 在 mobile 上量到整個鏈多了 ~250ms——font CSS 本身需要一個 round trip,瀏覽器才知道要去拿哪些字型檔案。

這個網站是繁體中文優先。Cormorant 的 italic 只渲染在英文標題上,全站加起來幾行字。250ms 換一套顯示字型,在這個使用情境下說不過去。

兩套字型改成 system stack:顯示字型用 Iowan Old Style → Cambria → Georgia → serif,內文用 -apple-system → BlinkMacSystemFont → Segoe UI → system-ui。視覺代價是真實的——桌機上仔細看得出 Cormorant 的 italic 消失了——但這個損失集中在少數英文 headline,不影響主要的中文閱讀體驗。

Hero AVIF 二次壓縮:137KB 對 LCP 位置來說太保守

CSS 和 preload 問題解掉之後,960w AVIF(手機載的那個——412px viewport × DPR 1.75 ≈ 720px,srcset 選中 960w)還有 137KB。Slow 4G 1.6Mbps,光傳輸就要 ~685ms。

用 quality 38(原本 q60)重壓,降到 57KB,小了 58%,同樣連線省了約 343ms。代價:turquoise 漸層在桌機 100% zoom 下有輕微色帶。在 412px 的手機螢幕看不出來。業主看過 before/after 確認接受。

其他照片不動。壓縮幅度看不見是浪費,壓到看得見但壓錯地方更糟——LCP hero 是每次造訪都全版寬呈現的圖,這裡的視覺代價才是需要業主判斷的。

Cloudflare cache:沒設規則比沒有 Cloudflare 還慢

Cloudflare 是因為另一件事才加進來的——Facebook Open Graph 爬蟲的 domain reputation flag,這是 Meta 在 2026 年 4 月的政策改變對新註冊網域的影響,最後用 DNS TXT Domain Verification 解掉的。CF 解不了那個問題,但留下來了。

沒有 cache rule 的狀態下,CF 反而讓圖片更慢。Railway origin 預設的 Cache-Control: max-age=14400 告訴 CF 每四小時要 revalidate,每次 cold cache 都多了 CF edge → Railway origin 的來回。Hero AVIF 的 TTFB 透過 CF 冷啟動量到 2,028ms;Railway 直連是 438ms。

兩條 cache rule 修掉了這件事:

  • /images/*:Edge TTL 30 天
  • /_astro/*:Edge TTL + Browser TTL 各 1 年(Astro 的 hash-named bundle,內容換了 URL 就換,永久 cache 安全)

Hero AVIF TTFB 從 2,028ms 降到 180ms,11 倍。

CLS:Masonry grid 裡的 viewport 寬度假設

同一個 audit 窗口,一個姊妹物件的相簿頁量到 CLS 0.6。原因是 grid-auto-rows: 10px masonry,每格的 span 用 getBoundingClientRect() 在執行期計算。Span 的結果跟 viewport 寬度有關。Lighthouse mobile 用 412px;那個頁面開發時都在 360px 下測。412px 跑出來的 span 比 360px 大 2,圖片 load 後自我修正——每張都在 layout 之後 shift。

改成固定 aspect-ratio: 3/4object-fit: cover,不需要 JS。CLS 0.6 降到 0.001。

那個相簿頁還有第二個 shift:一個在 paint 之後包裝圖片 DOM 的 security script,加 protected-image class 的動作本身就觸發 layout shift。改成 CSS 版 grid、在 render 時就加好 class,兩個 shift 一起消掉了。

數字

改動前改動後
Lighthouse Mobile7694
Mobile FCP2.9s1.1s
Mobile LCP4.6s2.0–3.2s
Element render delay1,756ms< 50ms
Desktop Performance9498

最大的改進來自 astro.config.mjs 一行 config,不是圖片格式,不是 CDN,不是 JavaScript 砍掉。圖片早就到了。瓶頸是一個 12KB 的 CSS bundle 超過了 Astro 預設的 inline 門檻,觸發了一個多餘的 round trip,讓瀏覽器就算圖片已在 memory 裡還是沒辦法畫面。

競業的 mobile 分數是 64。拿到 94 不只是領先那個數字——是把壓著廣告曝光份額的那個 Quality Score 天花板拿掉了。