Coolkid mascot CoolkidLab

新手教學 · Build 踩坑 debug · 最後更新:2026-05-19

PageSpeed 93 → 83 又修回:typewriter 動畫遇到 LCP 元素的真實 debug

為了讓 Lab 有點動感 我加了一個 hero 文字逐字 fade-in 的 typewriter 進場動畫。結果 PageSpeed 行動裝置從 93 掉到 83、LCP 從 2.3s 拖到 4.0s(會不會太離譜。

5 分鐘 debug 找到 root cause:typewriter 把 hero h1 設成 opacity:0 等 IntersectionObserver 觸發再 fade in 0.45s。hero h1 = LCP 元素。LCP 計算「pixel 達最終值」的時間。opacity 0 → 1 全程都算延遲。

拿掉 typewriter 後 perf 回到 90+。這篇紀錄完整 debug 過程 + 教訓 + 替代方案。給加了 JS 動畫後 perf 掉分不知為何的非工程師。

1. 我加了什麼動畫

想做的效果:hero h1「AI SEO 新手成長」進頁面時 文字一個一個跳出來 像 AI 對話的打字機。

技術實作:

  1. 寫一個 typewriter.js 在 DOMContentLoaded 後 把 hero h1 內容 wrap 成 per char
  2. 每個 span CSS opacity:0 + translateY(8px) + animation-delay 遞增
  3. 用 IntersectionObserver 觀察 hero h1 → viewport 內 add .is-visible class 觸發 animation
  4. 考量到 hero h1 內含 包不下去 改用 element-level fade-in (opacity 0 → 1 + transition 0.45s)

效果上 hero h1 進場有「淡入感」OK 但有個副作用我沒想到 — 這個淡入會被 PageSpeed 算到 LCP。

2. PageSpeed 報告(加 typewriter 後)

PageSpeed 加 typewriter 後 行動裝置 83 分 LCP 4.0s 紅燈
加 typewriter 後 - 效能 83 / FCP 2.2s 橘 / LCP 4.0s 紅 / TBT 100ms / CLS 0.059

改後:效能 83、FCP 2.2s 橘、LCP 4.0s 紅、TBT 100ms 綠、CLS 0.059 綠。

比起加 typewriter 前(效能 93、LCP 2.3s) LCP 多了 1.7 秒 整體效能掉了 10 分。

3. 5 分鐘 debug:LCP 元素是什麼

PageSpeed 報告往下滑會有「最大內容繪製元素」這個區塊 — 直接告訴你哪個元素是頁面 LCP 候選。

我的 hero h1「AI SEO 新手成長」是 LCP 元素。檢查它的 CSS:

.typewriter-fade {
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.45s ease-out;
}
.typewriter-fade.is-visible {
  opacity: 1;
  transform: translateY(0);
}

問題在這裡。LCP 是 Largest Contentful Paint — Google 計算「最大元素達最終 pixel 值」的時間。

Hero h1 在 typewriter 接管後的時序:

  1. 頁面 load 完 hero h1 文字在 DOM 但 opacity:0(看不見)
  2. 等 DOMContentLoaded + JS init(~200-300ms)
  3. IntersectionObserver callback 觸發 add .is-visible class
  4. transition opacity 0 → 1 過 0.45s
  5. 最終 hero h1 pixel 達 opacity:1 → LCP 時刻

= LCP 多出 ~1.5-2 秒 vs 沒 typewriter 直接 paint 出來。

4. 為什麼 JS 動畫遇到 LCP 元素要小心

LCP 是 Core Web Vitals 三大指標之一(LCP / INP / CLS)。Google 把它當搜尋排名訊號 — LCP > 4s 差、2.5-4s 中、< 2.5s 好。

JS 動畫常見會踩到 LCP 的 3 個坑:

  1. opacity 0 → 1 fade-in — LCP 算「最終 pixel」 fade 過程都不算 done
  2. transform translate / scale 進場 — 同上 final transform 達成才算 done
  3. JS 延遲注入內容 — 例如 React 渲染前的 placeholder 也會被算 LCP 直到真實內容 mount

對「想看動畫」vs「LCP」這對矛盾 通常選 LCP — perf 是搜尋排名直接訊號 動畫只是 nice-to-have。

5. 拿掉 typewriter 完整 step(5 分鐘)

  1. 刪 assets/typewriter.js 檔案
  2. 拿掉 build.py 內 TYPEWRITER_JS_V 變數 +