stock.dev
Astro + microCMSのブログをLighthouse56→100点にした話

Astro + microCMSのブログをLighthouse56→100点にした話

·9 min read

個人ブログ(息子の闘病記録)をAstro + microCMS + Cloudflare Pagesで構築したあと、 最後にLighthouseを測ったらモバイルのPerformanceが 56 でした。 ここから2段階の最適化で100まで持っていけたので、やったことと数字をまとめます。

公開URL: https://west-blog.pages.dev/


TL;DR

段階 Performance FCP LCP 総転送量
改善前 56 9.9 s 9.9 s 1,105 KiB
① Google Fontsを&text=でsubset 化 99 1.6 s 1.6 s 189 KiB
② フォントを自前ホスト+CSSインライン化 100 0.8 s 0.8 s 188 KiB
  • すべてLighthouse Mobileの数値
  • 3回連続で100が安定して出ているのを確認
  • デザインは一切変えていない(Noto Serif JP/Noto Sans JPのまま)

改善前の状態

最初の計測で出てきたのはこんな感じでした。 改善前の Lighthouse Mobile レポート: Performance 56、FCP/LCP 9.9s

Performance     : 56
Accessibility   : 100
Best Practices  : 100
SEO             : 100
 
FCP / LCP / Speed Index / TTI : 全部 9.9 s
TBT             : 0 ms
CLS             : 0

Accessibility / Best Practices / SEO は全部満点でしたがPerformanceで詰まりました。 全ての時間指標がきっかり 9.9 sで揃っているのが怪しかったので、ネットワークの内訳を見ました。

ネットワーク転送量の上位16リクエストのうち、15 個がfonts.gstatic.com です。

178KB  Google Fonts CSS(fonts.googleapis.com/css2?...)
105KB  Noto Serif JP subset(woff2)
 77KB  Noto Sans JP subset
 32KB  Noto Serif JP subset
 25KB  Noto Serif JP subset
 ...    あと10個くらいsubsetが続きました

主犯はCJK フォント

Google Fontsは Noto Sans JP / Noto Serif JPのような巨大な日本語フォントをunicode-range多数のサブセット (.woff2) に分割して配信してくれます。便利な仕組みですが、ページに出てくる漢字・かな・記号のsubset群を都度ダウンロードする結果、ファイル数が膨らみ、全部届くまでに時間がかかります。


ステップ① ― Google Fonts text=パラメータでsubset 化

Zennのこちらの記事 で知ったのですが、Google FontsのリクエストURLに&text=あいう...のように使われる文字を全部渡すと、その文字だけを含む小さなwoff2を1ファイルで返してくれます。

SSGならビルド時にdist/ のHTMLを全部読めば、使われている文字が確定できるので試してみようと思いました。

Astro Integrationとして実装

// src/integrations/google-fonts-subset.mjs (抜粋)
export default function googleFontsSubset() {
  return {
    name: 'google-fonts-subset',
    hooks: {
      'astro:build:done': async ({ dir, logger }) => {
        const distDir = fileURLToPath(dir);
        const htmlFiles = findHtmlFiles(distDir);
 
        // 1.全ページの<body>からユニーク文字を集める
        const chars = new Set();
        for (const file of htmlFiles) {
          const html = fs.readFileSync(file, 'utf8');
          const text = decodeEntities(extractBodyText(html));
          for (const ch of text) {
            if (/\s/.test(ch)) continue;
            chars.add(ch);
          }
        }
 
        const subset = [...chars].sort().join('');
        const encoded = encodeURIComponent(subset);
 
        // 2.各HTMLのGoogle Fonts URLに&text=<chars>を追記
        const fontsUrlRegex =
          /(href=")(https:\/\/fonts\.googleapis\.com\/css2[^"]*?)(")/g;
        for (const file of htmlFiles) {
          let html = fs.readFileSync(file, 'utf8');
          html = html.replace(fontsUrlRegex, (m, open, url, close) => {
            const sep = url.includes('&amp;') ? '&amp;' : '&';
            return `${open}${url}${sep}text=${encoded}${close}`;
          });
          fs.writeFileSync(file, html);
        }
      },
    },
  };
}

astro.config.mjs に追加します。

import googleFontsSubset from './src/integrations/google-fonts-subset.mjs';
 
export default defineConfig({
  integrations: [sitemap(), googleFontsSubset()],
});

結果(ステップ①時点)

Before After ①
Performance 56 99
FCP / LCP 9.9 s 1.6 s
Google Fonts のリクエスト数 15+ 1〜2
総転送量 1,105 KiB 189 KiB

Performanceは満点直前まで来ましたが、Lighthouseを3回連続で回すと 85 / 99 / 99 とぶれることが分かりました。 1回目は遅い、2回目以降は速い。初回はキャッシュ(コールド)の問題かな?と思いました。

PageSpeed Insights(PSI)の結果も87 / 携帯電話 だったので、これは「初回訪問の体感」を表しています。 さらにPSIの指摘で2つ追加課題が見えました。

  • レンダリングをブロックしているリクエスト — Google Fonts CSSを読みに行く 750msがrender-block
  • 効率的なキャッシュ保存期間を使用するfonts.gstatic.commax-age=86400(1日)。長くしたい。

ステップ② ― フォント自前ホスト + CSS インライン化

text=で軽くした CSSとwoff2をCloudflare Pagesでホストしてしまえば、

  1. CSSの取得待ち(render-block)が消える(HTMLに直接埋め込めばよい)
  2. キャッシュ期間を自分で設定できる(Cloudflare 経由でmax-age=31536000, immutable

の2つを一気に解決できるはず。Integrationを拡張しました。

やったこと

  1. ビルド時に**&text=... 付きのGoogle Fonts CSSをfetch**
  2. CSS 内のfonts.gstatic.comのURLを全部fetchしてwoff2ダウンロード
  3. woff2をdist/_fonts/font-<contentHash>.woff2として保存
  4. CSS の@font-facesrcURL を/_fonts/font-XXXX.woff2 に書き換え
  5. HTMLの<link rel="stylesheet" href="fonts.googleapis.com/..."><link rel="preconnect">全削除
  6. 書き換えたCSSを<style>でインライン化して</head>の直前に挿入
  7. public/_headers/_fonts/*1年キャッシュを設定
// public/_headers
/_fonts/*
  Cache-Control: public, max-age=31536000, immutable

ハッシュ付きファイル名なので、フォント内容が変わればURLごと変わる →無限キャッシュする。

Integrationのフロー(概念図)

わかりやすいように、フローをAIに書かせてみました。

[astro:build:done]

   ├─ dist/**/*.html を読む
   ├─ <body> から unique chars 抽出
   ├─ fonts.googleapis.com/css2?...&text=<chars> を fetch
   │     ↓
   │   CSS 取得 (各 @font-face は gstatic URL を指す)
   ├─ gstatic URL の woff2 を並列 fetch
   │     ↓
   │   dist/_fonts/font-<hash>.woff2 に保存
   ├─ CSS の URL を /_fonts/<hash>.woff2 に書き換え
   ├─ 各 HTML の <link href="fonts.googleapis.com|gstatic.com"> を削除
   └─ 書き換えた CSS を <style> として </head> 直前に挿入

結果(ステップ ② 時点)

3回連続で計測してすべて Performance 100 が出ました。 ステップ② 後の Lighthouse Mobile レポート: Performance 100、FCP/LCP 0.8s

改善前 After ① After ②
Performance 56 99 100
FCP 9.9 s 1.6 s 0.8 s
LCP 9.9 s 1.6 s 0.8 s
TBT 0 ms 0 ms 0 ms
CLS 0 0 0
総転送量 1,105 KiB 189 KiB 188 KiB
Google Fonts リクエスト数 15+ 1〜2 0(自前ホスト)

PageSpeed Insightsの警告も両方とも消えました!


計測中に気づいたこと

Lighthouse のスコアは1回では決まらない。 3回計測したら 85 / 99 / 99 という結果になったように±10 点くらいはぶれる。 特に「初回計測」と「2回目以降」で差が大きいので、 複数回計測して中央値で判断する。

PSI が出すスコアは「初回訪問相当」の状態を測っているので、 普段の自分が「2回目以降の速い体感」しか見ていなかったりすると、 PSI 結果といつもの感覚が食い違う。

参考リンク: