PageSpeed Insights のモバイル結果を開くと、記事ページの FCP が 4.1 秒、トップページは FCP 7.7 秒・LCP 9.5 秒と出ていました。
最初に疑ったのは画像です。壁紙を扱う仕事柄、重い画像には慣れていて、LCP の要素もきっと画像だろうと決めてかかっていました。ところが要素を確かめると、画像ではなく文字でした。遅いのは、その文字を描くためのフォントの配信だったのです。
結論から書きます。和文の Web フォントを配信するのをやめたら、数字は大きく動きました。ただし、なぜ動いたのかという私の説明は、半分しか裏が取れていません。
効いた変更、逆効果だった変更、再現ページで確かめられなかった説明を、順に分けて書き残します。
数字が示していたのは、画像ではなく文字でした
Dolice で運用している Web サイトは、本文に Noto Sans JP を Google Fonts から読み込んでいました。重さは 300・400・500・700 の 4 ウェイトです。
PSI のモバイルは CPU も回線も遅く見積もる前提です。実機の体感より厳しい数字ですが、だからこそ「最初の文字が出るまでに何を待っているか」が浮き彫りになります。
LCP の要素が文字だとわかった時点で、見る場所は画像の最適化から、フォント関連の通信に移りました。
121KB の CSS と、496 個の @font-face
Google Fonts の CSS を、Chrome のユーザーエージェントで取得して大きさを見ます。ブラウザごとに返る CSS が違うので、必ず実ブラウザに近い値を付けます。
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
URL = "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700"
# 転送量(gzip)
curl -s -A " $UA " -H "Accept-Encoding: gzip" -o /dev/null -w "gzip transfer=%{size_download}B\n" " $URL "
# 展開後のサイズと @font-face の数
curl -s -A " $UA " -H "Accept-Encoding: identity" " $URL " | tee /tmp/css2.txt | wc -c
grep -c "@font-face" /tmp/css2.txt
この記事を書いた日に取り直した結果は、次のとおりです。
和文フォントは、文字数が多すぎて 1 ファイルにできません。そこで Google Fonts は unicode-range で細かく分割して配ります。実際にダウンロードされる woff2 は使った文字を含む分だけですが、どのサブセットが必要かを決めるために、496 個分の宣言を先に全部読み込む必要があります。
欧文フォントの感覚で「CSS は数 KB」と思っていると、ここで見積もりが外れます。
自己ホストにしたら、むしろ遅くなりました
外部ホストが遅いのなら、自分のドメインから配れば済むはずです。そう考えて next/font/google に切り替えました。ビルド時に取得して同じオリジンから配信する仕組みです。
結果は逆でした。生成された CSS は 375KB(br 圧縮で 33KB)になり、FCP は 6.8 秒に悪化しました。
原因は、サブセット宣言の数が減らないまま、自分のページの描画に必要な CSS として配られるようになったこと です。外部ホストへの接続は省けても、読み込んで解釈する CSS の量は変わりません。圧縮で転送は軽くなっても、ブラウザがパースする量は 375KB のままです。
ここで学んだ線引きは単純でした。「どこから配るか」を変える前に、「何を配るか」を見直すことです。
和文は端末のフォントに任せ、欧文だけ手元で配る
選んだ方針は、和文の Web フォントを配信しないことでした。本文のフォント指定は、端末に入っている和文フォントを順に探す形にしています。
body {
font-family : "Noto Sans JP" , "Hiragino Sans" , "Hiragino Kaku Gothic ProN" ,
"Yu Gothic Medium" , "Yu Gothic" , Meiryo, var ( --font-dm-sans ), system-ui , sans-serif ;
}
先頭の "Noto Sans JP" は、端末に入っていれば使われます。Web フォントとして取りに行くわけではありません。Mac は Hiragino、Windows は Yu Gothic や Meiryo が受け持ちます。
欧文の DM Sans と DM Mono は、Latin サブセットだけを同梱して配ります。いまのレイアウト定義は次のとおりです。
import localFont from "next/font/local" ;
const dmSans = localFont ({
src: [
{ path: "../../fonts/dm-sans-latin-300-normal.woff2" , weight: "300" , style: "normal" },
{ path: "../../fonts/dm-sans-latin-400-normal.woff2" , weight: "400" , style: "normal" },
{ path: "../../fonts/dm-sans-latin-500-normal.woff2" , weight: "500" , style: "normal" },
],
display: "swap" ,
variable: "--font-dm-sans" ,
adjustFontFallback: "Arial" , // 代替書体との字幅差を詰めて、入れ替わりの揺れを小さくする
});
const dmMono = localFont ({
src: [{ path: "../../fonts/dm-mono-latin-400-normal.woff2" , weight: "400" , style: "normal" }],
display: "swap" ,
variable: "--font-dm-mono" ,
adjustFontFallback: "Arial" ,
});
adjustFontFallback を付けておくと、Web フォントが届いて入れ替わるときの文字のずれ(CLS)を抑えられます。Google Fonts への preconnect・preload・スクリプトでの挿入・noscript の保険も、すべて外しました。
PSI のモバイルを取り直した結果が、次の表です。
トップページの FCP は、変更前の約 22%(1.7 ÷ 7.7)まで縮みました。記事ページも約 41%(1.7 ÷ 4.1)です。同じ PSI のモバイル条件で比べた値で、実機の体感を保証するものではありません。
「描画ブロックだから遅い」を、最小の再現ページに掛けてみた
実はここからが、この記事でいちばん書き残したかった部分です。
当時のメモには、遅さの理由をこう書いていました。「head 内のスクリプトが挿入した <link rel="stylesheet"> を、Blink は描画ブロックとして扱う」。Lighthouse の指摘には出てこないので、見逃しやすい罠だと考えていました。
数字は確かに良くなりました。けれど、この説明が正しいかどうかは、別の問題です。そこで、最小の再現ページを作りました。CSS の配信に 800 ミリ秒の遅延を入れる小さなサーバーです。
// server.js — node server.js で起動(localhost:8765)
const http = require ( 'http' );
const pad = '/*' + 'x' . repeat ( 120000 ) + '*/ \n body{font-family:sans-serif}' ; // 約 120KB の CSS
const pages = {
// スクリプトが head で <link rel=stylesheet> を挿入する
'/inserted' : `<!doctype html><html><head><meta charset=utf-8><script>
var l=document.createElement('link');l.rel='stylesheet';l.href='/slow.css';document.head.appendChild(l);
</script></head><body><h1>こんにちは</h1><p>本文です</p></body></html>` ,
// HTML に直接書いた <link>
'/plain' : `<!doctype html><html><head><meta charset=utf-8>
<link rel=stylesheet href=/slow.css></head><body><h1>こんにちは</h1><p>本文です</p></body></html>` ,
// media=print で読み込み、onload で all に切り替える定番の回避策
'/swap' : `<!doctype html><html><head><meta charset=utf-8>
<link rel=stylesheet href=/slow.css media=print onload="this.media='all'"></head>
<body><h1>こんにちは</h1><p>本文です</p></body></html>` ,
// 外部 CSS なし(端末フォントだけ)
'/none' : `<!doctype html><html><head><meta charset=utf-8>
<style>body{font-family:"Hiragino Sans","Yu Gothic",Meiryo,system-ui,sans-serif}</style></head>
<body><h1>こんにちは</h1><p>本文です</p></body></html>` ,
};
http. createServer (( q , r ) => {
if (q.url === '/slow.css' ) { // 800ms 遅らせて返す
setTimeout (() => { r. writeHead ( 200 , { 'content-type' : 'text/css' }); r. end (pad); }, 800 );
return ;
}
const p = pages[q.url];
if (p) { r. writeHead ( 200 , { 'content-type' : 'text/html; charset=utf-8' }); r. end (p); return ; }
r. writeHead ( 404 ); r. end ();
}). listen ( 8765 , () => console. log ( 'up' ));
計測側は Playwright です。FCP と、リソースごとの renderBlockingStatus(描画をブロックしたかどうかをブラウザ自身が申告する値)を読みます。
// probe.js — NODE_PATH を通して node probe.js
const { chromium } = require ( 'playwright' );
( async () => {
const b = await chromium. launch ();
for ( const path of [ 'inserted' , 'plain' , 'swap' , 'none' ]) {
for ( let i = 0 ; i < 3 ; i ++ ) { // 1 回では判断しない
const ctx = await b. newContext (); // 毎回まっさらなキャッシュで
const pg = await ctx. newPage ();
await pg. goto ( 'http://localhost:8765/' + path, { waitUntil: 'load' });
const r = await pg. evaluate (() => new Promise ( res => {
new PerformanceObserver ( l => {
const e = l. getEntriesByName ( 'first-contentful-paint' )[ 0 ];
if (e) res ({
fcp: Math. round (e.startTime),
rb: performance. getEntriesByType ( 'resource' ). map ( x => x.renderBlockingStatus). join ( ',' )
});
}). observe ({ type: 'paint' , buffered: true });
setTimeout (() => res ({ fcp: - 1 }), 4000 ); // 4 秒待っても出なければ -1
}));
console. log (path, i, JSON . stringify (r));
await ctx. close ();
}
}
await b. close ();
})();
手元(Playwright 同梱の Chromium 153、ヘッドレス)での結果は、3 回とも同じ傾向でした。
HTML に直接書いた link は、遅延の 800 ms をそのまま待ちました。一方、スクリプトが挿入した link は、CSS が届く前に最初の描画が済んでいました。 この最小構成では、「スクリプト挿入の stylesheet は描画ブロック」という説明は再現しなかったのです。
確かめられたことと、まだ言えないこと
整理すると、次のようになります。
最後の 1 行が、冒頭で「半分」と書いた部分です。実際のページでは、効いた経路が別にあったのかもしれません。挿入のタイミングや、ほかの CSS との順序、ハイドレーションとの兼ね合いなど、候補はありますが、どれも切り分けていません。
私はこの件で、効いた変更と、効いた理由の説明は別の主張として扱う ことにしました。数字が良くなったことは事実として残し、理由はわかった範囲までしか書かないことにしています。再現ページに掛けて落ちた説明を、そのまま「原因」として持ち歩いていたら、次の判断を誤ったと思います。
後日談:ビルド時にフォントを取りに行く設計も、外部依存でした
話はここで終わりませんでした。欧文フォントのほうを next/font/google で配っていたところ、10 月 21 日に Cloudflare の Workers Builds でビルドが落ちました。
エラーは next/font/google queries have exactly one entry です。手元の next build は通るのに、ビルド環境だけ失敗します。next/font/google はビルド時に Google Fonts へ取りに行く仕組みなので、取得結果の揺らぎがそのままビルドの失敗になります。
対応は、Latin の woff2 をリポジトリに同梱し、先ほどの next/font/local に切り替えることでした。自己ホストの本当の利点は、配信元が近いことではなく、ビルドが外部の応答に左右されなくなることでした。
同じ Cloudflare 上でも、配信側の「古いものが残る」問題は別の顔をしていました。気になる方は Cloudflare をパージしても古い壁紙が届き続けた原因は、オリジン側で JPEG を握っていた XPageSpeed も合わせて読んでみてください。
同じ取りこぼしが残っていないか、grep で確かめておきます。
# Google Fonts への参照が、実行時にもビルド時にも残っていないか
grep -rnE "fonts\.googleapis|fonts\.gstatic|next/font/google" src --include= * .tsx --include= * .ts --include= * .css
この記事を書く日にも流してみて、404 ページ専用のファイルに @import url('https://fonts.googleapis.com/...') が 1 行残っているのが見つかりました。通常のページでは読み込まれないので、上の数字には影響していません。けれど、直した気になっていた箇所が grep 一本で崩れる、という例にはなりました。
つまずきやすい落とし穴と、取り入れる前の線引き
端末フォントに任せる方針には、代償があります。先に知っておけば回避や対処ができるものを並べます。
見た目が環境で変わります。 Mac では Hiragino、Windows では Yu Gothic か Meiryo になり、字の太さや字面が揃いません。デザインが書体そのものに依存するサイトには向きません。
細いウェイトは出ないことがあります。 端末側に 300 が入っていないと、400 に置き換わります。
欧文との高さの差は、adjustFontFallback と size-adjust で詰める必要があります。
私の判断は、読み物のサイトでは本文の読みやすさと表示の速さを優先して端末フォントに任せ、書体が作品の一部になる場面(シェア用の画像や見出しの装飾)だけ、使う文字に絞ったサブセットで別に扱う、というものです。書体が主役のページで同じ判断をする保証はありません。
手順をまとめると、次の順になります。
外部フォント CSS の gzip 後の転送量と、展開後のサイズ、@font-face の数を測ります
自己ホストを試すなら、CSS の大きさと FCP を並べて、悪化していないかを見ます
「描画ブロックのせい」のような説明は、再現ページ(上の 2 つのスクリプトが雛形になります)で確かめてから書きます
直したら、Google Fonts への参照が残っていないかを grep で拾い、PSI を取り直します
次に PSI の数字が悪いときは、画像より先に、いちばん上の curl を叩いてみてください。CSS の大きさは、見る前と見たあとで、疑う場所をはっきり変えてくれます。