← 所有文章

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

重點摘要: 一個個人作品集網站從行動裝置Lighthouse效能分數76分、CLS 0.493,進步到全類別完美的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 breakdown中有2,460毫秒的「Element render delay」。罪魁禍首:一個同步載入的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,460毫秒降至約300毫秒。


第三階段: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.6秒 0.8秒

證據

桌面版審核顯示四個類別皆為滿分:

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

行動版審核——最重要的那個——同樣達到完美分數:

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


重要收穫

1. CSS變數可能導致CLS

CSS自訂屬性的媒體查詢覆寫在DevTools的計算樣式面板中是不可見的——您只會看到最終值。如果您的關鍵CSS未包含變數覆寫,當完整樣式表載入時就會產生版面位移。這一點在排版系統中格外危險,因為字級與行高變數會層層串連到每一個文字元素。

支撐本站感知色彩系統的色彩科學也牽涉其中:造成CLS的那批CSS變數覆寫,除了間距變數之外還包含色彩變數,修正方式對兩者同樣適用。

2. 關鍵CSS提取並不簡單

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

3. media="print"技巧確實有效

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

4. 除錯CLS需要幻燈片檢視

Lighthouse的幻燈片檢視能精確顯示位移發生的時間點。沒有它,您只能盲猜。被標記為「位移」的元素可能只是表象——要尋找造成它位移的根本原因。

5. 100vh的Hero區段不僅是美學考量

如果您的hero填滿了整個視窗,首屏以下的內容就不會影響CLS,因為在使用者捲動之前不會被測量。這與編輯式設計原則不謀而合——鋪滿視窗的hero區段同時帶來視覺衝擊力與效能上的優勢。


技術堆疊

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

整套技術堆疊不依賴任何建置工具——沒有webpack,沒有Vite,沒有打包器。無建置宣言完整記錄了這種做法的各項指標與取捨。這份簡潔也讓設計系統更容易稽核:您寫下的CSS與瀏覽器實際收到的內容之間,中間層更少。本文屬於彙整全站工藝與實作交會之處的設計工程專題


常見問題

Lighthouse分數多少才算好?

依Google的標準,90分以上即屬良好。要在四個類別(效能、無障礙、最佳做法、SEO)全部拿到100分,在行動裝置上相當罕見:網路速度浮動、CPU降頻、第三方腳本等真實環境條件,讓每一項指標都難以完全掌控。多數正式站台的行動裝置效能分數落在50到80之間。

累積版面位移(CLS)是什麼造成的?

CLS衡量的是首次渲染之後,可見內容位移的幅度。常見成因包括:未標明尺寸的圖片、動態插入的內容、與後備字型互換的網頁字型(FOIT/FOUT),以及本文所記錄的這一種——延遲載入的樣式表改變了CSS自訂屬性的值。Google將CLS高於0.1視為「不佳」,低於0.1視為「良好」。

關鍵CSS如何改善Lighthouse效能?

關鍵CSS會擷取首屏內容所需的樣式,並內嵌到HTML的<head>中。如此便能免去阻塞渲染的來回:瀏覽器不必先下載並剖析整份樣式表才能繪製畫面。其餘CSS透過media="print"技巧非同步載入,瀏覽器會將其視為不阻塞的資源。在本例中,LCP元素的渲染延遲從2,460毫秒降到約300毫秒。

不使用建置工具也能拿到Lighthouse滿分嗎?

可以。本站使用純CSS,沒有預處理器、沒有打包器、也沒有框架,只有FastAPI提供Jinja2樣板。關鍵CSS的提取由一支Python腳本在部署時完成。少了建置工具,原始CSS與瀏覽器實際收到的內容之間抽象層更少,效能稽核反而更單純。

為什麼行動裝置的Lighthouse分數比桌機更重要?

Google採用行動優先索引,也就是說搜尋排名取決於站台的行動版本。行動裝置的Lighthouse稽核會套用CPU降頻(4倍減速)與網路限速(模擬慢速4G),把桌機上看不出來的效能問題暴露出來。在桌機拿到100分的站台,在這些限制下的行動裝置往往會低上20到30分。


使用的工具

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

相關文章

iOS 27 中的 SwiftUI 效能與互通

iOS 27 的 SwiftUI 如何處理 lazy stack 捲動、GPU 著色器效果,以及與 AppKit/UIKit 的互通,內容取自三場官方 WWDC26 UI Frameworks 議程。

15 分鐘閱讀

Apple 效能團隊在 WWDC26 實驗室裡說了什麼

Apple 的 Power & Performance 團隊在 WWDC26 現場回答開發者的問題。關於 MetricKit、Power Profiler、熱管理與 AI 資源競用的實戰指引。

13 分鐘閱讀

設計工程師的 Agent 技術堆疊

設計工程師需要能強制執行視覺一致性、字型紀律、色彩合規性與品味的 agent 基礎架構。以下是六大核心元件。

11 分鐘閱讀