← 所有文章

從 76 到 100:達成完美的 Lighthouse 分數

重點摘要: 一個個人作品集網站從 76 分的行動裝置 Lighthouse 效能分數和 0.493 的 CLS,達到了全部類別 100/100/100/100 的完美分數。這段旅程揭露了微妙的 CSS 載入問題、正規表達式錯誤,以及一個特別隱蔽的 CSS 變數覆寫問題,該問題在行動裝置上造成了版面位移。


起始點

這個網站是一個使用 FastAPI + Jinja2 建構的作品集,搭配 HTMX 和 Alpine.js 實現互動功能。初始的 Lighthouse 行動裝置審核結果:

指標 分數
效能 76
無障礙 91
最佳實踐 100
SEO 100
CLS 0.493

那個 CLS 數字相當糟糕。Google 認為任何超過 0.1 的數值都是「差」。在 0.493 的情況下,頁面在載入時會明顯地跳動。


第一階段:無障礙快速修復

在處理效能之前,我先清理了無障礙問題以建立基準:

表單標籤

將裝飾性的 span 改為適當的 <label> 元素:

<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">

<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">

對比度

頁尾標題和表單標籤使用了 --color-text-tertiary(40% 不透明度的白色)。提升到 --color-text-secondary(65% 不透明度)以符合 WCAG AA 4.5:1 對比度要求。

多餘的替代文字

社群媒體圖示有像「LinkedIn icon」這樣的替代文字,但它們已經在具有 aria-labels 的連結內。改為 alt="" 並加上 aria-hidden="true" 以防止螢幕閱讀器重複朗讀。

相同的連結文字

多個「View Case Study」連結對螢幕閱讀器來說無法區分。添加了包含專案名稱的 aria-label 屬性:

<a href="/work/introl" aria-label="View Introl Branding case study">
  View Case Study
</a>

結果: 無障礙從 91 提升到 100。


第二階段:阻塞渲染的 CSS 問題

Lighthouse 在 LCP 細項中顯示了 2,460ms 的「元素渲染延遲」。罪魁禍首:一個同步載入的 CSS 檔案阻塞了首次繪製。

<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">

瀏覽器必須先下載並解析整個 25KB 的樣式表才能繪製任何內容。

解決方案:關鍵 CSS + 非同步載入

步驟一: 將關鍵(首屏以上)CSS 提取到單獨的檔案中,並內嵌在 <head> 裡。

步驟二: 使用 media="print" 技巧非同步載入完整樣式表:

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>

media="print" 屬性告訴瀏覽器「這個樣式表不是螢幕渲染所需的」,所以不會阻塞。一旦載入完成,onload 處理器會將其切換為 media="all"

關鍵 CSS 提取腳本

我寫了一個 Python 腳本來自動提取首屏元件的關鍵 CSS:

CRITICAL_PREFIXES = [
    ".container",
    ".header",
    ".nav",
    ".hero",
    ".personal-photos",
]

def extract_critical_css(css_content: str) -> str:
    # Strip comments to prevent regex pollution
    css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

    # Extract :root variables
    # Extract base reset rules
    # Extract component rules by prefix
    # Extract media queries containing critical selectors
    ...

結果: LCP 元素渲染延遲從 2,460ms 降到約 300ms。


第三階段:CLS 噩夢開始

實作非同步 CSS 後,CLS 實際上變得更糟——跳升到 0.119。Lighthouse 顯示 <main> 是發生位移的元素。

錯誤 #1:CSS 註解污染正規表達式

提取腳本使用正規表達式來匹配選擇器:

rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"

問題:在提取之前沒有先移除 CSS 註解。像這樣的註解:

/* Hero Section - Editorial */
.hero { ... }

會導致正規表達式從「Hero Section」匹配到左大括號,產生格式錯誤的選擇器而無法通過前綴檢查。關鍵的 .hero 樣式被默默地丟棄了。

修復: 在任何正規表達式操作之前先移除註解:

css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

錯誤 #2:媒體查詢內的規則被提取為獨立規則

正規表達式匹配到媒體查詢內部的規則,並將它們提取為獨立規則:

/* Full CSS structure */
.hero__title { font-size: 5rem; }           /* Desktop */

@media (max-width: 768px) {
  .hero__title { font-size: 1.875rem; }     /* Mobile */
}

腳本將兩個規則都提取到頂層:

/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; }  /* Overrides desktop! */

在行動裝置上,標題以關鍵 CSS 的行動版大小渲染,然後⋯⋯保持不變,因為完整 CSS 有相同的規則。但在桌面版,行動版的覆寫錯誤地套用了。

修復: 追蹤媒體查詢的位置並跳過其內部的規則:

# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
    media_ranges.append((match.start(), match.end()))

def is_inside_media_query(pos: int) -> bool:
    return any(start <= pos < end for start, end in media_ranges)

# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
    if is_inside_media_query(match.start()):
        continue  # Will be included with full media query block
    # ... extract rule

第四階段:100vh 假設

CLS 仍然在 0.116。理論:如果首屏以下的內容在初始繪製時不可見,就不會對 CLS 造成影響。

將 hero 區塊從 85vh 改為 100vh:

.hero {
  /* 100vh ensures nothing below fold is visible on initial paint */
  min-height: 100vh;
}

結果: 沒有變化。CLS 仍然是 0.116。位移發生在視窗內部


第五階段:關鍵證據

Lighthouse 影格圖顯示 hero 文字在影格之間明顯位移。不是淡入淡出——是水平移動

深入研究哪些樣式會影響水平位置: - .hero__content 使用 padding: 0 var(--gutter) - --gutter:root 中定義為 48px

然後我找到了:

/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 24px */
  }
}

行動裝置上的執行順序:

  1. 關鍵 CSS 載入:--gutter: 48px
  2. Hero 以 48px 側邊內距渲染
  3. 完整 CSS 非同步載入
  4. 媒體查詢將 --gutter 設為 24px
  5. Hero 內距從 48px 縮小為 24px
  6. 文字重排並位移 = CLS 0.116

修復方式

:root 媒體查詢作為關鍵 CSS 的一部分提取:

# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
    critical_rules.append(match.group())

現在關鍵 CSS 包含:

:root { --gutter: 48px; /* ... */ }

@media (max-width: 768px) {
  :root { --gutter: var(--spacing-md); }
}

行動裝置從一開始就以正確的 24px 間距渲染。當完整 CSS 載入時,--gutter 已經是 24px——沒有變化,沒有位移。


第六階段:CSP 和 Alpine.js

還有一個問題:Alpine.js 拋出了關於內容安全政策違規的主控台錯誤。Alpine 內部使用動態表達式評估。

# In security headers middleware
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

Alpine.js 的表達式解析需要 'unsafe-eval' 指令。替代方案是使用 Alpine 的 CSP 相容版本,但有一些限制。


最終結果

指標 之前 之後
效能 76 100
無障礙 91 100
最佳實踐 100 100
SEO 100 100
CLS 0.493 0.033
LCP 2.6s 0.8s

證明

桌面審核顯示全部四個類別都達到完美分數:

Lighthouse desktop audit showing 100/100/100/100 scores

行動裝置審核——最重要的那個——也達到了完美分數:

Lighthouse mobile audit showing 100/100/100/100 scores


關鍵要點

1. CSS 變數可能導致 CLS

CSS 自訂屬性的媒體查詢覆寫在 DevTools 的計算樣式面板中是不可見的——你只會看到最終值。如果你的關鍵 CSS 不包含變數覆寫,當完整樣式表載入時版面就會位移。

2. 關鍵 CSS 提取很棘手

簡單的正規表達式方法會在以下情況失敗: - CSS 註解(可能污染選擇器匹配) - 媒體查詢內的規則(必須保持在其區塊內) - :root 媒體查詢(變數變更會影響所有內容)

3. media="print" 技巧有效

使用 media="print" onload="this.media='all'" 載入非關鍵 CSS 是一種合法的延遲樣式表載入方式,無需複雜的 JavaScript。

4. CLS 除錯需要影格圖

Lighthouse 的影格圖檢視可以精確顯示位移發生的時間。沒有它,你只是在猜測。被標記為「位移」的元素可能只是症狀——要找出導致它位移的原因。

5. 100vh Hero 區塊的好處不只是美觀

如果你的 hero 填滿整個視窗,首屏以下的內容就不會對 CLS 造成影響,因為在使用者捲動之前不會被測量。


技術堆疊

  • 後端: FastAPI + Jinja2
  • 前端: HTMX + Alpine.js(自行託管,無 CDN)
  • CSS: 純 CSS 搭配自訂屬性,無預處理器
  • 最佳化: 自訂 Python 腳本進行關鍵 CSS 提取
  • 託管: Railway,搭配 GZip 壓縮和不可變快取

使用的工具

  • Lighthouse(Chrome DevTools)
  • Lighthouse 影格圖檢視用於 CLS 除錯
  • grepregex101.com 用於 CSS 分析
  • Python 用於關鍵 CSS 提取腳本
  • iTerm 2
  • Claude Code CLI 搭配 Opus 4.5 和大量的 ultrathink