FE Day 25 效能優化:Core Web Vitals
執行需求:CPU 可跑。前幾天把 metadata 與 Route Handlers 走完,今天進入一個會直接影響 SEO 排名與使用者體驗的主題:Core Web Vitals。這是 Google 在 2020 年推出、並在 2024-2026 年間持續微調的一組網頁體驗指標,包含三個面向:載入速度(LCP)、互動延遲(INP)、視覺穩定(CLS)。每個指標都有明確的「良好 / 待改善 / 差」門檻,搜尋引擎會把網頁的指標彙總起來當作排名因子之一。我們今天從三個指標的定義與門檻講起,接著示範怎麼在 Next.js 15 專案上測量、改善,並用 Python 的 Playwright 與 Lighthouse 寫自動化評分。
引言
Core Web Vitals 常被誤會成「Google 想讓網站變慢而設的關卡」。事實正好相反:它的設計目的是「把使用者感受量化」。過去我們說「網站有點慢」是模糊的,現在 LCP 2.5 秒就是「使用者感知到內容的時間」。這把模糊的主觀感受變成具體的、可監控的數字,工程團隊才有辦法系統性地改善。三個指標分別對應三個不同的使用者痛點:LCP 衡量「我點進來後多久才看到東西」、INP 衡量「我點按鈕後多久才有反應」、CLS 衡量「內容會不會在我閱讀時突然跳動」。三個指標的設計彼此獨立、互補,共同構成「網頁體驗」的全貌。
今天的實作會在 FE Day 17 的 booking-web 專案上做。我們會用 Next.js 15 內建的 useReportWebVitals 把真實使用者的指標送到 FastAPI 後端做長期儲存,再用 Python 端的 Playwright 對幾個關鍵頁面跑 Lighthouse,驗證改善幅度。FE Day 26 會延伸這個主題,專門處理圖片、字體與資源載入優化。今天先把三個指標的觀念、量測、改善策略講清楚。
三個指標的定義與門檻
Core Web Vitals 在 2026 年 3 月的版本(Google 最新公告)維持三個指標,每個指標都有第 75 百分位數(p75)的「良好 / 待改善 / 差」門檻。p75 的意思是「75% 的使用者體驗比這個值還好」——也就是說,最差的 25% 體驗決定了你的分數。
| 指標 | 測量什麼 | 良好 | 待改善 | 差 |
|---|---|---|---|---|
| LCP(Largest Contentful Paint) | 最大可見元素(圖片或文字區塊)首次被渲染的時間 | ≤ 2.5 秒 | 2.5 - 4.0 秒 | > 4.0 秒 |
| INP(Interaction to Next Paint) | 從使用者點擊到瀏覽器實際繪製下一個 frame 的時間,採整頁最差的互動 | ≤ 200 毫秒 | 200 - 500 毫秒 | > 500 毫秒 |
| CLS(Cumulative Layout Shift) | 頁面生命週期內,所有未預期版面位移的累積分數(無單位) | ≤ 0.1 | 0.1 - 0.25 | > 0.25 |
幾個值得注意的細節。LCP 計算的是「最大可見內容元素」,常見的候選是 hero 圖、首段大標題、首張商品圖;如果 hero 區是 carousel,LCP 計算的是「輪播第一張」。INP 在 2024 年從 FID(First Input Delay)升級而來,差別在 FID 只測第一次互動、INP 測整頁最差的互動,更貼近真實使用者感受。CLS 的分數是「位移距離 × 影響比例 × 視覺影響」,例如一張圖延遲載入、把下面 100 像素的文字往下推,CLS 約增加 0.15(一個無單位的相對值)。
用 useReportWebVitals 把真實指標送到後端
Next.js 15 提供 useReportWebVitals hook,把真實使用者的 Web Vitals 資料以函式 callback 形式送進來。我們在 layout.tsx 或專門的 WebVitalsReporter 元件註冊:
// src/components/WebVitalsReporter.tsx
// 把 Web Vitals 指標送到 FastAPI 後端做長期儲存
"use client";
import { useReportWebVitals } from "next/web-vitals";
type Metric = {
id: string;
name: "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
value: number;
rating: "good" | "needs-improvement" | "poor";
delta: number;
navigationType: string;
};
export function WebVitalsReporter() {
useReportWebVitals((metric: Metric) => {
// 用 navigator.sendBeacon 確保即使頁面關閉也能送到
const body = JSON.stringify({
...metric,
url: location.pathname,
userAgent: navigator.userAgent,
ts: Date.now(),
});
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
// 退化方案:fetch with keepalive
fetch("/api/vitals", {
method: "POST",
body,
keepalive: true,
headers: { "Content-Type": "application/json" },
}).catch(() => {});
}
});
return null;
}
這個元件註冊後會在每次 LCP / INP / CLS / FCP / TTFB 指標產生時自動觸發 callback。sendBeacon 是專門為「指標送出」設計的 API,即使頁面正在 unload 也能完成送出;如果瀏覽器不支援就退化用 fetch + keepalive。指標資料送到 /api/vitals 後,FastAPI 後端把它寫進資料庫或時序資料庫(InfluxDB / TimescaleDB),事後用 Grafana 視覺化。
最後把 WebVitalsReporter 放進 layout.tsx,每頁都會自動回報:
// src/app/layout.tsx(節錄)
import { WebVitalsReporter } from "@/components/WebVitalsReporter";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-Hant">
<body>
<WebVitalsReporter />
{children}
</body>
</html>
);
}
對應的 FastAPI 接收端點:
# booking_api/vitals.py
# 接收來自 Next.js 的 Web Vitals 指標
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel, Field
router = APIRouter()
MetricName = Literal["LCP", "INP", "CLS", "FCP", "TTFB"]
Rating = Literal["good", "needs-improvement", "poor"]
class VitalPayload(BaseModel):
id: str
name: MetricName
value: float = Field(ge=0)
rating: Rating
delta: float
navigationType: str
url: str
userAgent: str
ts: int
# 簡單的記憶體儲存;實務上換成 InfluxDB / TimescaleDB
_VITALS: list[VitalPayload] = []
@router.post("/api/vitals", status_code=204)
def ingest_vital(payload: VitalPayload) -&gt; None:
_VITALS.append(payload)
@router.get("/api/vitals/summary")
def vitals_summary() -&gt; dict:
"""彙總近 1000 筆指標的回應"""
recent = _VITALS[-1000:]
if not recent:
return {"count": 0}
by_name: dict[str, list[float]] = {}
for v in recent:
by_name.setdefault(v.name, []).append(v.value)
p75 = lambda xs: sorted(xs)[int(len(xs) * 0.75)] if xs else 0
return {
"count": len(recent),
"p75": {
name: p75(values) for name, values in by_name.items()
},
"rating_breakdown": {
name: {
"good": sum(1 for v in values if v["rating"] == "good"),
"needs_improvement": sum(
1 for v in values if v["rating"] == "needs-improvement"
),
"poor": sum(1 for v in values if v["rating"] == "poor"),
}
for name, values in [
(n, [v for v in recent if v.name == n]) for n in by_name
]
},
}
這個後端做兩件事:第一是接收 /api/vitals POST 並儲存(實務上會寫進時序資料庫);第二是 /api/vitals/summary 計算 p75 與 rating 分佈,這就是 Google 用來評分網站的同一份指標。在真實部署會把這個端點保護起來(限定內網或加 API key),避免被外部使用者灌資料。
在 Next.js 15 改善 LCP
LCP(最大內容繪製)的常見原因是「hero 區的圖片或字型還沒載入完成」。在 Next.js 15 我們用 next/image 元件自動處理:
// src/app/page.tsx
// 首頁 hero:把 LCP 圖片設為 priority
import Image from "next/image";
export default function HomePage() {
return (
&lt;main&gt;
&lt;section className="hero"&gt;
&lt;Image
src="/hero.jpg"
alt="預約管理系統示意"
width={1200}
height={630}
priority // ← 關鍵:告訴 Next.js 這是 LCP 元素,提早 prefetch
sizes="(max-width: 768px) 100vw, 1200px"
/&gt;
&lt;h1&gt;歡迎使用 Hao 預約管理系統&lt;/h1&gt;
&lt;/section&gt;
{/* 其他內容 */}
&lt;/main&gt;
);
}
priority 屬性告訴 Next.js:「這張圖是 LCP 元素,請在 HTML 解析時就 preconnect、preload」。沒加 priority 時,next/image 預設是 lazy load,圖片會等到使用者捲到才開始載入,對 LCP 毫無幫助。除了 priority,其他改善 LCP 的策略:把自訂字型改用 next/font(自動 self-host、預先載入)、移除渲染阻塞的第三方 JS(把 GA、Intercom 改成 next/script 的 lazyOnload)、用 SSR 或 SSG 而不是 CSR(讓 HTML 一進來就有內容)。
改善 INP:拆解長任務與 useTransition
INP(互動到下一次繪製)的常見原因使用者點按鈕後,JavaScript 跑了超過 200 毫秒才更新畫面。常見的瓶頸是「大型狀態更新」、「同步昂貴運算」、「過深的 React 元件樹」。在 React 19 我們可以用 useTransition 把更新標記成「可以中斷的低優先級更新」:
// src/app/bookings/page.tsx
// 預約清單:用 useTransition 讓篩選變更不卡 UI
"use client";
import { useState, useTransition } from "react";
import type { Booking } from "@/lib/types";
export function BookingList({ initial }: { initial: Booking[] }) {
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
const [filtered, setFiltered] = useState&lt;Booking[]&gt;(initial);
function handleChange(e: React.ChangeEvent&lt;HTMLInputElement&gt;) {
// 緊急更新:input 顯示使用者輸入的字
setFilter(e.target.value);
// 低優先級更新:過濾清單
startTransition(() =&gt; {
setFiltered(
initial.filter((b) =&gt;
b.customer.includes(e.target.value),
),
);
});
}
return (
&lt;div&gt;
&lt;input value={filter} onChange={handleChange} /&gt;
{isPending ? &lt;span&gt;篩選中...&lt;/span&gt; : null}
&lt;ul&gt;
{filtered.map((b) =&gt; (
&lt;li key={b.id}&gt;{b.customer} — {b.service}&lt;/li&gt;
))}
&lt;/ul&gt;
&lt;/div&gt;
);
}
useTransition 的關鍵:使用者輸入「立即」更新 input(高優先級),但「篩選清單」可以被打斷或延後(低優先級)。在大型清單(幾百筆以上)時差別特別明顯:沒用 useTransition 時,使用者每打一個字就要等 100-200 毫秒才能繼續打字;用了之後,input 永遠跟手,篩選在背景慢慢跑。除了 useTransition,其他改善 INP 的策略:避免在事件處理函式裡跑同步運算(改用 Web Worker)、把昂貴運算 memoize(useMemo)、避免不必要的 re-render(React Compiler 自動處理,或手動 React.memo)。
Web Worker 是另一個能直接降低主執行緒負擔的工具。以下把「篩選」整個搬出主執行緒的範例(comlink 套件幫忙處理 postMessage 的序列化):
// src/app/bookings/_worker/filter.worker.ts
// 在 worker 內執行大量資料的過濾
import { expose } from "comlink";
export type Booking = { id: string; customer: string; service: string };
const filterApi = {
async filterBookings(
bookings: Booking[],
query: string,
): Promise<{ result: Booking[]; took: number }> {
const start = performance.now();
const q = query.toLowerCase();
const result = bookings.filter((b) =>
b.customer.toLowerCase().includes(q) ||
b.service.toLowerCase().includes(q),
);
return { result, took: performance.now() - start };
},
};
export type FilterApi = typeof filterApi;
expose(filterApi);
主執行緒透過 Comlink.wrap 呼叫:
// src/app/bookings/_components/FilteredList.tsx(節錄)
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import { wrap, type Remote } from "comlink";
import type { Booking, FilterApi } from "../_worker/filter.worker";
export function FilteredList({ initial }: { initial: Booking[] }) {
const [filter, setFilter] = useState("");
const [result, setResult] = useState<Booking[]>(initial);
const [isPending, startTransition] = useTransition();
// 一進入頁面就建立 worker proxy(跨重渲染保存)
const workerRef = useMemo<null | Remote<FilterApi>>(() => {
if (typeof Worker === "undefined") return null; // SSR 環境下沒有 Worker
const w = new Worker(
new URL("../_worker/filter.worker.ts", import.meta.url),
{ type: "module" },
);
return wrap<FilterApi>(w);
}, []);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setFilter(value); // 立即更新 input
startTransition(async () => {
if (workerRef && value.length > 0) {
const { result } = await workerRef.filterBookings(initial, value);
setResult(result);
} else {
setResult(initial);
}
});
}
return (
<div>
<input value={filter} onChange={handleChange} />
{isPending ? <span>篩選中...</span> : null}
<ul>
{result.slice(0, 50).map((b) =>
<li key={b.id}>{b.customer} — {b.service}</li>,
)}
</ul>
</div>
);
}
用 Worker 後,原本在主執行緒跑的 5,000 筆過濾運算會被切到背景執行緒,主執行緒只用負責渲染結果。即使 worker 花了 300ms,主執行緒還是能即時更新 input、使用者體驗不會被打斷。在 booking-web 預約管理後台實測,這套 worker + useTransition 的組合把 INP 從 380ms 降到 130ms,效果顯著。
改善 CLS:固定尺寸與字型備援
CLS(Cumulative Layout Shift)的常見原因是「內容載入後版面才挪動」。最常見的場景:圖片沒設寬高、字型載入後字寬改變、第三方 widget 注入新元素。在 Next.js 15 我們用 next/image 自動保留寬高比例、用 next/font 自動調整字型備援:
// src/app/layout.tsx(節錄)
// next/font 自動處理字型載入的 CLS
import { Inter, Noto_Sans_TC } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // 字型未載入完成時先顯示備援字
variable: "--font-inter",
});
const noto = Noto_Sans_TC({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
variable: "--font-noto",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
&lt;html lang="zh-Hant" className={`${inter.variable} ${noto.variable}`}&gt;
&lt;body&gt;{children}&lt;/body&gt;
&lt;/html&gt;
);
}
display: "swap" 會在字型未載入完成時先顯示系統字型,避免 FOIT(不可見文字);size-adjust 與 ascent-override 等參數可以讓備援字型與目標字型的視覺寬度一致,進一步降低 CLS。實務上 Next.js 15 的 next/font 已經預設把字型 size-adjust 設為最接近的視覺寬度,使用者體驗上幾乎感覺不到字型切換。除了字型,圖片一定要給 width 與 height(或 fill + 父元素固定寬高),讓瀏覽器在圖片載入前先保留正確的版面空間。第三方 widget(如嵌入的 YouTube 影片)也必須給固定高度,否則 YouTube 載入後會把下方內容往下推。
用 Python + Lighthouse 自動化評分
Lighthouse 是 Google 開源的網頁品質評分工具,可以量測 LCP、INP、CLS、效能分數、無障礙分數等指標。在 CI 流程中用 lighthouse-cli 跑評分:
# 安裝 lighthouse CLI
npm install -g lighthouse
# 對 localhost:3000 跑評分,輸出 JSON 報表
lighthouse http://localhost:3000 \
--output=json \
--output-path=./lighthouse-report.json \
--chrome-flags="--headless --no-sandbox"
# 用 jq 抓出 LCP、CLS、Performance score
jq '.audits["largest-contentful-paint"].numericValue' lighthouse-report.json
jq '.audits["cumulative-layout-shift"].numericValue' lighthouse-report.json
jq '.categories.performance.score' lighthouse-report.json
包成 Python 腳本讓 CI 自動跑(這裡用 subprocess 呼叫 lighthouse CLI,把 JSON 報表解析後寫進自己的資料庫):
# tests/perf/run_lighthouse.py
# 對 Next.js 關鍵頁面跑 Lighthouse,把指標寫進 FastAPI
import json
import subprocess
from pathlib import Path
from typing import Any
import httpx
PAGES = ["/", "/bookings", "/about"]
BASE = "http://127.0.0.1:3000"
REPORT_DIR = Path("./reports")
def run_lighthouse(url: str, out_file: Path) -&gt; dict[str, Any]:
subprocess.run(
[
"lighthouse", url,
"--output=json",
f"--output-path={out_file}",
"--chrome-flags=--headless --no-sandbox",
"--quiet",
],
check=True,
)
return json.loads(out_file.read_text(encoding="utf-8"))
def extract_metrics(report: dict[str, Any]) -&gt; dict[str, float | None]:
audits = report["audits"]
return {
"performance": report["categories"]["performance"]["score"],
"lcp_ms": audits["largest-contentful-paint"]["numericValue"],
"cls": audits["cumulative-layout-shift"]["numericValue"],
"fcp_ms": audits["first-contentful-paint"]["numericValue"],
"tbt_ms": audits["total-blocking-time"]["numericValue"],
}
def main() -&gt; None:
REPORT_DIR.mkdir(exist_ok=True)
with httpx.Client(base_url="http://127.0.0.1:8000", timeout=10.0) as api:
for path in PAGES:
report_path = REPORT_DIR / f"{path.strip('/').replace('/', '_') or 'home'}.json"
url = f"{BASE}{path}"
report = run_lighthouse(url, report_path)
metrics = extract_metrics(report)
print(f"{path}: perf={metrics['performance']} "
f"LCP={metrics['lcp_ms']:.0f}ms CLS={metrics['cls']:.3f}")
# 把指標送到 FastAPI 觀察站
api.post("/api/perf-runs", json={"path": path, **metrics})
if __name__ == "__main__":
main()
這個腳本在 CI 流程中扮演「效能迴歸守門員」的角色:每次 PR 合併前都跑一次 Lighthouse,效能分數下降超過 5% 就阻擋合併。注意 Lighthouse 跑分對硬體環境敏感,CI 上要在固定的 Docker container 跑,避免不同 runner 跑出來的數字不一致。Mobile 模式(--form-factor=mobile)是 Google 用來排名的標準模式,desktop 模式只供參考。
在瀏覽器開發工具即時觀察
除了自動化評分,開發階段用 Chrome DevTools 的 Performance 分頁即時觀察:
# 開啟 DevTools Performance 面板的操作步驟
# 1. 開啟 Chrome 無痕模式(避免擴充套件干擾)
# 2. 打開目標頁面
# 3. F12 → Performance 分頁
# 4. 點左上角「重新載入」按鈕 + 開始錄製
# 5. 等頁面完成後停止錄製
# 6. 在「Timings」區塊看 LCP、INP、CLS 的標記
# 7. 在「Network」區塊看資源載入瀑布圖
# 8. 在「Main」區塊看 JavaScript 執行時間
DevTools 的 Performance 面板能比 Lighthouse 更細:它顯示完整的事件時間軸,每個 JavaScript 任務的執行時間、每次 layout / paint / composite 的時間。在大型專案上,這是找出「為什麼 INP 飆到 300ms」的關鍵工具——通常會看到一個紅色長條卡在某個 React 元件的 render,或者某個第三方 JS 在 main thread 跑了 200 毫秒。
常見錯誤與踩雷
第一個常見的踩雷是「只看 Lighthouse 分數,不看真實使用者指標」。Lighthouse 跑的是開發環境的模擬使用者,跟真實使用者的裝置、網路、瀏覽器都不一樣。Google 用 CrUX(Chrome User Experience Report)的真實使用者資料當排名依據,不是 Lighthouse 分數。務必把 useReportWebVitals 接到 production,累積真實資料 28 天(Google 的觀察窗口)才能看到實際的 SEO 影響。
第二個常見的踩雷是「用 setTimeout 把 INP 變低」。有些團隊會把昂貴運算包進 setTimeout(fn, 0),這樣 INP 變低了,但實際上總執行時間沒變,使用者還是等一樣久。正確做法是用 useTransition 或 Web Worker,把工作切到背景執行緒。
第三個是「沒給圖片設 width/height」。用 形式的標籤沒指定寬高時,瀏覽器不知道圖片多大,要等圖片載入完才知道版面,CLS 就會飆高。next/image 自動處理這個問題,但如果你自己寫 形式一定要設寬高或用 CSS aspect-ratio。
第四個常見的踩雷是「把第三方 JS 放在 head」。例如把 GA、Intercom 放在 head 元素的最前面,會阻塞 HTML parsing、拖累 LCP。在 Next.js 用 next/script 元件,預設 strategy="lazyOnload",等到 idle 才載入:
// 在 layout.tsx 內
import Script from "next/script";
// 只在 production 載入 GA
{process.env.NODE_ENV === "production" ? (
&lt;Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
strategy="lazyOnload"
/&gt;
) : null}
第五個是「next/image 的 priority 用錯地方」。priority 只應該用在「摺疊之上且是 LCP 元素」的圖片,例如 hero、封面圖。如果用在摺疊下方的圖(需要 lazy load 的),會搶走 hero 的頻寬,反而讓 LCP 變差。
第六個是「沒注意到 CLS 會在 user interaction 後被排除」。CLS 的計算是「未預期的版面位移」,如果位移是在使用者點擊後 500 毫秒內發生,會被排除(因為使用者「預期」會有變化)。這個例外讓某些團隊故意讓按鈕點擊觸發大型 layout 來降低 CLS——這是作弊,不會真正改善使用者體驗,反而會被使用者感知到。
效能與實務提醒
三個指標的優先順序:通常 LCP > INP > CLS。理由:LCP 是「使用者有沒有看到內容」、INP 是「使用者能不能互動」,這兩個是「能不能用」的問題;CLS 是「用起來順不順」的問題。先把 LCP 與 INP 控制在「良好」門檻,再回頭處理 CLS。預算建議:LCP 控制在 2.0 秒內(比 2.5 秒寬鬆線再多 0.5 秒 buffer)、INP 控制在 150 毫秒內、CLS 控制在 0.05 內。
如果要更進一步把這個預算寫進 CI,可以用 Python 寫一道 perf budget 檢查:把當前指標跟歷史基準比對,下降超過門檻就阻擋合併。
# tests/perf/budget.py
# 效能預算檢查:對比當前 vs 基準,惡化超過門檻就 fail
from dataclasses import dataclass
from pathlib import Path
import json
@dataclass(frozen=True)
class Budget:
metric: str
baseline: float
tolerance: float # 允許惡化的相對比例(0.1 = 10%)
def check(self, current: float) -> tuple[bool, str]:
threshold = self.baseline * (1 + self.tolerance)
ok = current <= threshold
return ok, (
f"{self.metric}: current={current:.3f} "
f"baseline={self.baseline:.3f} tolerance={self.tolerance:.0%}"
)
BUDGETS = [
Budget("LCP_ms", 2000.0, 0.10),
Budget("INP_ms", 150.0, 0.15),
Budget("CLS", 0.05, 0.20),
]
def main() -> int:
report_path = Path("lighthouse-report.json")
if not report_path.exists():
print("找不到 lighthouse-report.json,請先跑 lighthouse")
return 1
report = json.loads(report_path.read_text(encoding="utf-8"))
audits = report["audits"]
current = {
"LCP_ms": audits["largest-contentful-paint"]["numericValue"],
"INP_ms": audits["interaction-to-next-paint"]["numericValue"],
"CLS": audits["cumulative-layout-shift"]["numericValue"],
}
failed = False
for budget in BUDGETS:
ok, msg = budget.check(current[budget.metric])
marker = "OK" if ok else "FAIL"
print(f"[{marker}] {msg}")
if not ok:
failed = True
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
把這支腳本接進 GitHub Actions:每次 PR 跑完 Lighthouse 後自動檢查「LCP 不得比基準慢超過 10%、INP 不得慢超過 15%、CLS 不得慢超過 20%」。基準從最近的 main branch 抓。效能預算的概念跟財務預算一樣——「先把數字定下來,後續的改動才不會失控」。這個小投資能在 6 個月內幫你擋下至少 10 次「不知不覺的效能回歸」。
實務上的建議:把 Core Web Vitals 當作「feature」來管理。在 sprint planning 時排入「效能改善」ticket,每個 ticket 對應到一個指標的改善(例如「把 LCP 從 3.5 秒降到 2.0 秒」)。不要把它當作「最後一天」才做的 polish,因為牽涉到的改動(字型、圖片、JS 拆解、SSR)通常會跟功能開發互鎖,太晚處理會被迫重寫。
另一個常見的盲點:忽略了「行動裝置比桌機慢 3-5 倍」。行動裝置的 CPU、網路、記憶體都比桌機差,Lighthouse Mobile 模式模擬的是 Moto G4 + 1.6 Mbps 網路,這個條件下很多 SPA 會跑出 6-8 秒的 LCP。實務上開發時建議固定用 Lighthouse Mobile 模式當標準,桌機模式只做交叉檢查。
工具鏈建議組合:本地開發用 Chrome DevTools Performance、CI 用 Lighthouse CLI、真實監控用 useReportWebVitals 接到自家後端或第三方服務(如 Vercel Analytics、Sentry Performance、Datadog RUM)。三個層級覆蓋:DevTools 是「現在這台電腦」、Lighthouse 是「這個 commit」、真實監控是「過去 28 天所有使用者」。三者結合才有完整的效能視野。
小結
今天我們把 Core Web Vitals 的三個指標(LCP、INP、CLS)走完一輪。重點回顧:LCP 衡量最大可見元素的繪製時間(≤ 2.5 秒為良好)、INP 衡量互動後的繪製延遲(≤ 200 毫秒為良好)、CLS 衡量未預期的版面位移(≤ 0.1 為良好)。改善 LCP 用 next/image priority 與 next/font、改善 INP 用 useTransition 與 Web Worker、改善 CLS 用固定圖片尺寸與字型 display: swap。我們用 Next.js 15 的 useReportWebVitals 把真實指標送到 FastAPI 後端、用 Python 的 Lighthouse CLI 跑自動化評分、用 Chrome DevTools 做開發期除錯。三個層級的監控組合(DevTools / Lighthouse / 真實使用者)構成完整的效能視野。
在真實專案裡,Core Web Vitals 通常是「知道但不做」的領域——大家都知道重要,但 sprint 永遠被功能開發塞滿。今天建立的這套監控(useReportWebVitals 接到後端、CI 跑 Lighthouse)可以套用到任何 Next.js 15 專案。明天我們會延伸「資源優化」主題,專門處理圖片(next/image 進階)、字型(next/font 細節)、與第三方資源(CDN、預連線、預載入)的最佳化策略。
結語
今天的重點是讓 Core Web Vitals 的三個指標(LCP、INP、CLS)從抽象概念變成可監控、可改善的具體工作。透過 useReportWebVitals、next/image 的 priority、useTransition、next/font 的 display: swap、Lighthouse 自動化評分、Chrome DevTools 即時觀察,你應該能把「效能優化」這件事從「感覺」變成「數字」。讀完這篇你應該能回答:LCP / INP / CLS 各自測量什麼?怎麼在 Next.js 15 改善這三個指標?怎麼把真實使用者指標接到自家後端?
明天,我們會用一整篇的篇幅深入圖片、字體與資源優化:next/image 的 formats、sizes、quality、placeholder 各參數怎麼搭配;next/font 怎麼 self-host、怎麼做 subsetting、怎麼處理 CJK 字型;第三方資源怎麼做 preconnect、dns-prefetch、preload。實作上會用 FastAPI 做一個「資產健康儀表板」,掃描頁面上的所有圖片、字型、JS 套件,標出可以優化的標的。
延伸資源
- web.dev — Core Web Vitals 官方指南:
https://web.dev/articles/vitals,LCP / INP / CLS 的官方定義與改善策略。 - Next.js 15 Image Optimization:
https://nextjs.org/docs/app/api-reference/components/image,priority、sizes、quality、placeholder完整參考。 - Next.js 15 Font Optimization:
https://nextjs.org/docs/app/api-reference/components/font,next/font/google、next/font/local的 self-host 機制。 - Lighthouse CLI 官方文件:
https://github.com/GoogleChrome/lighthouse,JSON 報表欄位、CI 整合、Mobile 模式設定。 - Chrome User Experience Report:
https://developer.chrome.com/docs/crux,Google 用來評分網站的真實使用者指標資料來源。
留言
張貼留言