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では、ページが読み込み中に目に見えて跳ね回っている状態でした。
フェーズ1:アクセシビリティの即効改善
パフォーマンスに取り組む前に、基準を確立するためにアクセシビリティの問題を修正しました:
フォームラベル
装飾的な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%不透明度の白)を使用していました。WCAG AA 4.5:1のコントラスト要件を満たすため、--color-text-secondary(65%不透明度)に変更しました。
冗長な代替テキスト
ソーシャルメディアアイコンに「LinkedIn icon」のような代替テキストがありましたが、すでにaria-labelを持つリンク内にありました。スクリーンリーダーの繰り返しを防ぐため、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に向上。
フェーズ2:レンダーブロッキングCSSの問題
LighthouseはLCPの内訳で2,460msの「Element render delay」を表示しました。原因:最初の描画をブロックする同期的なCSSファイル。
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
ブラウザは何も描画する前に、25KBのスタイルシート全体をダウンロードして解析する必要がありました。
解決策:クリティカルCSS + 非同期読み込み
ステップ1: クリティカル(ファーストビュー)CSSを別ファイルに抽出し、<head>にインライン化。
ステップ2: 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抽出スクリプト
ファーストビューコンポーネント用のクリティカルCSSを自動抽出するPythonスクリプトを作成しました:
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に低下。
フェーズ3: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
フェーズ4:100vhの仮説
CLSはまだ0.116でした。理論:初期描画でファーストビュー以下が見えなければ、CLSに寄与できない。
ヒーローを85vhから100vhに変更:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
結果: 変化なし。CLSは依然として0.116。シフトはビューポート内部で発生していました。
フェーズ5:決定的証拠
Lighthouseのフィルムストリップは、ヒーローテキストがフレーム間で目に見えてシフトしていることを示しました。フェードではなく、水平方向に移動していました。
水平位置に影響するスタイルを深く調査:
- .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 */
}
}
モバイルでのシーケンス:
- クリティカルCSSが読み込まれる:
--gutter: 48px - ヒーローが48pxのサイドパディングでレンダリング
- フルCSSが非同期で読み込まれる
- メディアクエリが
--gutter: 24pxを設定 - ヒーローのパディングが48pxから24pxに縮小
- テキストがリフローしてシフト = 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—変更なし、シフトなし。
フェーズ6:CSPとAlpine.js
もう一つの問題:Alpine.jsがContent Security Policy違反についてコンソールエラーを出していました。Alpine.jsは内部で動的な式評価を使用しています。
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
'unsafe-eval'ディレクティブはAlpine.jsの式解析に必要です。代替手段はAlpine.jsのCSP互換ビルドを使用することですが、いくつかの制限があります。
最終結果
| 指標 | 以前 | 以後 |
|---|---|---|
| パフォーマンス | 76 | 100 |
| アクセシビリティ | 91 | 100 |
| ベストプラクティス | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0.493 | 0.033 |
| LCP | 2.6s | 0.8s |
証拠
全4カテゴリで完璧なスコアを示すデスクトップ監査:

最も重要なモバイル監査も完璧なスコアを達成:

重要な教訓
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ヒーローセクションには見た目以上のメリットがある
ヒーローがビューポートを埋めると、ファーストビュー以下のコンテンツはユーザーがスクロールするまで測定されないため、CLSに寄与できません。
技術スタック
- バックエンド: FastAPI + Jinja2
- フロントエンド: HTMX + Alpine.js(セルフホスト、CDNなし)
- CSS: カスタムプロパティを使用したプレーンCSS、プリプロセッサなし
- 最適化: クリティカルCSS抽出用のカスタムPythonスクリプト
- ホスティング: GZip圧縮と不変キャッシングを備えたRailway
使用ツール
- Lighthouse(Chrome DevTools)
- CLSデバッグ用のLighthouseフィルムストリップビュー
- CSS分析用の
grepとregex101.com - クリティカルCSS抽出スクリプト用のPython
- iTerm 2
- Claude Code CLI(Opus 4.5と大量のultrathinkを使用)