PageSpeed Insights 90点超えのリファクタリング
どーも、ちょりんだです。
このブログでは資産形成を「リファクタリング」と呼んでいますが、サイトそのものも同じです。一度作れば終わりではなく、計測、改善、運用を繰り返す資産のようなものです。
今回は PageSpeed Insights / Lighthouse で 90点超えを達成するまでに行った改善を、技術ログとしてまとめます。読者の方が自分のサイトでも再現できるように、なぜその施策に至ったかの論理も含めて書きます。
PageSpeed Insights は Google が提供するWebページ計測ツールです。Lighthouse の実験室データに加え、実際の Chrome ユーザーから集計された CrUX(Chrome User Experience Report)も参照します。URL を入力するだけで、LCP(最大コンテンツ描画)、INP(次のペイントまでのインタラクション遅延)、CLS(累積レイアウトシフト)などの Core Web Vitals 指標が確認できます。数値化されたフィードバックを得られるため、改善の優先順位をつける出発点として最適です。
はじめに:パフォーマンス改善をリファクタリングと呼ぶ理由
コードをリファクタリングする目的は、動作を変えずに構造を良くすることです。サイトのパフォーマンス改善も同じで、見た目や機能はそのままで、内部の配信・レンダリング・実行を整えていきます。
資産形成では「作って放置」ではなく、定期的に見直しと再投資を行います。Webサイトも同様に、一度公開したあとに計測して修正を続けないと、時間とともに劣化します。PageSpeed Insightsのスコアは、まさにその劣化を数値化した指標です。
PSIで指摘された課題
改善前に計測したところ、以下のような指摘が出ていました。
- レンダリングブロックリソース: Google Fonts の CSS が head でブロッキングになっている
- CLS(Cumulative Layout Shift): ヒーローセクション、広告コンテナ、画像の読み込みでレイアウトがずれる
- TBT(Total Blocking Time): 初期読み込み時に複数のスクリプトがメインスレッドを占有している
- アクセシビリティ: コントラスト比が WCAG 基準を満たしていない箇所が多数
- コンソールエラー: 検索用 JS の MIME タイプ不整合で 404 エラーが出ていた
これらは独立した問題ではなく、HTML・CSS・JS・インフラが絡み合って発生していました。なので個別に潰すのではなく、配信パイプライン全体を見直す必要がありました。
アプローチと実装
1. robots.txt、sitemap、SEO基盤の整備
まずは検索エンジンに正しくクロールしてもらうための基盤から整えました。
public/robots.txt には、公開ページをすべて許可し、sitemap の URL を明示します。クロール対象外にしたい内部パスは、適切に Disallow しておきます。
User-agent: *
Allow: /
Disallow: /private/
Sitemap: https://refactor-life.com/sitemap-index.xml
sitemap は src/pages/sitemap-index.xml.ts で動的に生成しています。Astro の SSR/SSG に依存せず、ビルド時に確実に出力される形にしています。
これはパフォーマンスの直接的な指標ではありませんが、検索からの流入を意識した運用の土台です。
2. Rocket Loader の排除と JS の遅延実行
Cloudflare を通している場合、Rocket Loader が <script> タグの読み込み方式を書き換えてしまうことがあります。結果として type="text/rocketscript" などに変換され、意図した実行タイミングが崩れ、Console にエラーが出ることもあります。
今回は 2 段階で対策しました。
2.1 レスポンスヘッダで無効化
public/_headers に以下を追加し、Cloudflare 側で Rocket Loader を停止させます。
/*
X-Cloudflare-Rocket-Loader: disabled
2.2 すべての script タグに data-cfasync="false" を注入
万が一 Rocket Loader が有効化された場合でも、個別の <script> を対象外にするための保険です。Astro の astro:build:done フックを使い、dist 内のすべての HTML ファイルを走査して属性を注入しています。
// astro.config.mjs 内
function cfasyncIntegration() {
return {
name: 'cfasync-inject',
hooks: {
'astro:build:done'({ dir }) {
const distPath = dir.pathname.replace(/\/$/, '');
function processDir(dirPath) {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
processDir(full);
} else if (entry.name.endsWith('.html')) {
const html = fs.readFileSync(full, 'utf8');
const patched = html.replace(
/<script(?!\s[^>]*data-cfasync)(\s|>)/g,
(_, p1) => `<script data-cfasync="false"${p1}`
);
if (patched !== html) fs.writeFileSync(full, patched);
}
}
}
processDir(distPath);
},
},
};
}
当初は Vite の transformIndexHtml を使っていましたが、SSG では効かないことが分かったため Astro フックに移行しました。すべてのビルド済み HTML に適用されることが検証済みです。
3. JavaScript の遅延初期化
Astro はデフォルトで JS をほとんど出力しないため、サイト全体としては軽量です。ただし、検索、テーマ切り替え、読書進捊バー、コードコピーなどのクライアントスクリプトは存在します。
これらをページ読み込み直後に一斉に初期化すると、メインスレッドを占有し TBT が悪化します。そこで次の 2 つに分けました。
3.1 requestIdleCallback で非クリティカルな初期化を遅らせる
BackToTop、CopyCodeButton、DarkModeToggle、ReadingProgress などは、ユーザーの操作に直接影響しないものが多いため、requestIdleCallback で初期化を遅らせています。
const initThemeToggle = () => {
document.getElementById('theme-toggle')?.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
};
if ('requestIdleCallback' in window) {
requestIdleCallback(initThemeToggle, { timeout: 2000 });
} else {
setTimeout(initThemeToggle, 200);
}
タイムアウトを 2 秒に設定しており、低スペック端末でもあまり遅延しないようにしています。
3.2 ユーザーインタラクション駆動で重い処理を初期化する
検索機能は Pagefind を使っていますが、初期ロード時にインデックスを読み込むとコストが高いため、ボタンクリックやホバーまで初期化を遅らせています。
let searchReady = false;
const setupSearch = () => {
if (searchReady) return;
searchReady = true;
// Pagefind の読み込みとイベント紐付け
};
const btn = document.getElementById('search-open');
btn?.addEventListener('click', setupSearch, { once: true });
btn?.addEventListener('pointerenter', () => {
if (!searchReady && 'requestIdleCallback' in window) {
requestIdleCallback(setupSearch, { timeout: 3000 });
}
}, { once: true });
これにより、トップページの初期ロード時には検索インデックスを一切読み込まず、必要な瞬間までコストを支払わない形にしました。Astro の client:idle のようなフレームワーク機構も検討しましたが、本サイトは静的にほぼ完結する構成のため、直接イベントリスナーで制御する方がシンプルでした。
4. CSS の再設計:レイアウトシフト対策と描画最適化
CLS は見た目の安定性を左右する重要な指標です。今回は主に 3 つの対策を行いました。
4.1 ヒーローセクションの固定高さ
ヒーローは背景に大きなグラデーションを持ち、テキスト量によって高さが変わる可能性があります。そこで min-h-[640px] を与え、描画領域を確保しました。背景側には contain: strict を適用し、要素内部の変更が他のレイアウトに影響しないようにしています。
<section class="relative text-center py-20 pb-24 overflow-hidden min-h-[640px]">
<div class="absolute inset-0 -z-10" style="contain: strict;">
<div class="absolute top-1/2 left-1/2 ... w-[600px] h-[600px] bg-gradient-to-br ... rounded-full blur-3xl"></div>
</div>
<!-- ... -->
</section>
4.2 広告コンテナのサイズ確保
外部から読み込む画像広告は、読み込み完了までサイズが不定になりがちです。コンテナ側に min-height: 250px と contain: layout を指定し、読み込み前からスペースを確保しました。
<div class="ad-container flex justify-center" style="min-height: 250px; contain: layout;">
<img ... loading="lazy" decoding="async" style="width:250px;height:250px;" />
</div>
loading="lazy" と decoding="async" も併用し、LCP への影響を抑えています。
4.3 フォント読み込みの CLS 対策
Google Fonts を読み込む場合、display=swap を使うと FOUT(Flash of Unstyled Text)が発生し、レイアウトシフトの原因になります。そこで display=optional にして、読み込みタイミングを制御しました。さらにサブセットを latin に限定し、転送量を抑えています。
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=optional&subset=latin" media="print" onload="this.media='all'" fetchpriority="low" />
さらにフォールバックフォントの寸法を Inter に近づけるため、size-adjust を使った @font-face を定義しています。
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90.20%;
descent-override: 22.48%;
line-gap-override: 0%;
size-adjust: 107.40%;
}
@font-face {
font-family: 'Inter Fallback JP';
src: local('Hiragino Kaku Gothic ProN'), local('Hiragino Sans'), local('Yu Gothic'), local('Meiryo');
ascent-override: 90.20%;
descent-override: 22.48%;
line-gap-override: 0%;
size-adjust: 107.40%;
}
フォントスタックにも両方を含めています。
--font-sans: 'Inter', 'Inter Fallback', 'Inter Fallback JP', 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'BIZ UDPGothic', 'Meiryo', 'Yu Gothic', system-ui, sans-serif;
4.4 content-visibility で描画範囲を制限
長いページではビューポート外の要素もレンダリングコストになります。content-visibility: auto を適用し、表示領域に入るまで描画処理を遅らせました。同時に contain-intrinsic-size で概算サイズを与え、スクロールバーの長さが極端に変わらないようにしています。
<!-- トップページの各セクション -->
<section class="py-16" style="content-visibility: auto; contain-intrinsic-size: auto 800px;">
<!-- ... -->
</section>
<!-- ブログ記事本文 -->
<div class="prose mb-12" style="content-visibility: auto; contain-intrinsic-size: auto 2000px;">
<slot />
</div>
画像にも同様に content-visibility: auto を適用しています。
img {
content-visibility: auto;
}
ただし、contain-intrinsic-size に固定値を入れすぎると逆に CLS が悪化するため、固定値は使わず auto で運用しています。実際に計測しながら調整しました。
5. アクセシビリティの修正
Lighthouse のアクセシビリティスコアを 100 にするため、コントラスト比を全体的に見直しました。
具体的には以下のような修正です。
text-slate-500→text-slate-600またはtext-slate-700text-indigo-500→text-indigo-600- ダークモードでは
text-slate-300などを併用 - プレースホルダー文字にも
dark:placeholder-slate-300を適用
これらは個別のクラス変更に見えますが、本質的にはデザインシステムのトークン選定を見直した作業です。WCAG AA 基準を満たす色の組み合わせを再定義し、各コンポーネントに反映しました。
6. コンソールエラーの解消
検索機能で Pagefind の JS ファイルが見つからず、ブラウザコンソールに MIME タイプ関連のエラーが出ていました。原因は、Astro のビルド後に Pagefind のインデックスが生成されていなかったことです。
package.json のビルドスクリプトを修正し、Astro ビルド後に Pagefind のインデックス生成を実行するようにしました。
"build": "node scripts/generate-og-images.js && astro build && npx pagefind --site dist"
これで dist/pagefind/pagefind.js が確実に存在し、検索機能が正しく動作するようになりました。Console エラーは 0 件になりました。
計測結果と運用の視点
上記の改善を積み重ねた結果、PageSpeed Insights の各項目は 90 点超えを安定して達成できました。特に効果が大きかったのは以下です。
- Rocket Loader 排除と
data-cfasync="false": 外部サービスの挙動を予測可能にした - CLS 対策: フォント、ヒーロー、広告の 3 箇所を押さえたことでスコアが大きく改善
- JS 遅延初期化: TBT の削減に直接寄与
- content-visibility: 長いページのレンダリングコストを減らせた
- コントラスト修正: Lighthouse アクセシビリティ 100 を達成
エンジニアとして得た視点は、「パフォーマンスは実装時の問題ではなく、運用の問題だ」ということです。新機能を追加するたびにスコアは変わります。 Lighthouse を定期的に計測し、基盤を観察し続けることが、実はコード以上に重要です。
資産形成と同じく、サイトも「作って終わり」ではなく、継続的に手を入れていくものです。今回のリファクタリングは、その運用サイクルの一環に過ぎません。