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の「要素レンダー遅延」が表示されていました。原因は、同期的な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
もう1つの問題がありました。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"トリックは有効
非クリティカルCSSをmedia="print" onload="this.media='all'"で読み込む方法は、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)