跳到主要內容

FE Day 25 效能優化:Core Web Vitals

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) -&amp;gt; None:
    _VITALS.append(payload)


@router.get("/api/vitals/summary")
def vitals_summary() -&amp;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 (
    &amp;lt;main&amp;gt;
      &amp;lt;section className="hero"&amp;gt;
        &amp;lt;Image
          src="/hero.jpg"
          alt="預約管理系統示意"
          width={1200}
          height={630}
          priority // ← 關鍵:告訴 Next.js 這是 LCP 元素,提早 prefetch
          sizes="(max-width: 768px) 100vw, 1200px"
        /&amp;gt;
        &amp;lt;h1&amp;gt;歡迎使用 Hao 預約管理系統&amp;lt;/h1&amp;gt;
      &amp;lt;/section&amp;gt;
      {/* 其他內容 */}
    &amp;lt;/main&amp;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&amp;lt;Booking[]&amp;gt;(initial);

  function handleChange(e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) {
    // 緊急更新:input 顯示使用者輸入的字
    setFilter(e.target.value);
    // 低優先級更新:過濾清單
    startTransition(() =&amp;gt; {
      setFiltered(
        initial.filter((b) =&amp;gt;
          b.customer.includes(e.target.value),
        ),
      );
    });
  }

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;input value={filter} onChange={handleChange} /&amp;gt;
      {isPending ? &amp;lt;span&amp;gt;篩選中...&amp;lt;/span&amp;gt; : null}
      &amp;lt;ul&amp;gt;
        {filtered.map((b) =&amp;gt; (
          &amp;lt;li key={b.id}&amp;gt;{b.customer} — {b.service}&amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;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 (
    &amp;lt;html lang="zh-Hant" className={`${inter.variable} ${noto.variable}`}&amp;gt;
      &amp;lt;body&amp;gt;{children}&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;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) -&amp;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]) -&amp;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() -&amp;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" ? (
  &amp;lt;Script
    src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
    strategy="lazyOnload"
  /&amp;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) -&gt; tuple[bool, str]:
        threshold = self.baseline * (1 + self.tolerance)
        ok = current &lt;= 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() -&gt; 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 用來評分網站的真實使用者指標資料來源。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...