「指標が改善 + 別の指標が悪化」のとき、本当に直すべきかどうかを判断するには、何が変わったかを読み解く必要がある。フォントを
@importからnext/font/googleに移したら、TBT は劇的に改善したけど LCP が悪化したように見えた。それを「失敗」と切り捨てる前に何を考えたかの記録。
この記事でわかること
- CSS の
@importでフォント読み込むと 何がブロックされるか next/font/googleがやっている3 つの最適化(self-host / preload / unicode-range)- 「指標が良くなった + 別の指標が悪くなった」ときの読み方
- LCP(Largest Contentful Paint)の判定要素はコンテンツによって動的に変わります
きっかけ:PageSpeed Insights が真っ赤だった
LP のパフォーマンス計測をしたら、こんな結果だった。
| 指標 | モバイル | デスクトップ |
|---|---|---|
| パフォーマンス | 57 | 56 |
| First Contentful Paint | 8.2 秒 | 1.7 秒 |
| Largest Contentful Paint | 10.3 秒 | 2.2 秒 |
| Total Blocking Time | 0 ミリ秒 | 430 ミリ秒 |
| Speed Index | 8.2 秒 | 3.9 秒 |
モバイルの LCP 10.3 秒 はかなりまずい。Google の基準(2.5 秒以内が「良い」)の 4 倍超え。
主犯特定:CSS の @import で 5 ファミリーの日本語フォント
globals.css の 1 行目にこれがあった。
@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=Noto+Serif+JP:wght@400;500;700&family=Klee+One:wght@400;600&family=Yomogi&family=Zen+Kurenaido&display=swap");問題点を整理すると、
1. CSS の @import はレンダリングをブロックする
ブラウザは HTML をパースしながら CSS を見つけ、その中の @import を 直列に解決します。@import を 1 つ挟むと「もう 1 ラウンドトリップ」が増えます。
2. fonts.googleapis.com への DNS lookup + 接続待ち
外部ドメインへの接続は
- DNS lookup
- TCP 接続
- TLS ハンドシェイク
のオーバーヘッドが乗ります。同じドメインなら接続を再利用できるが、初回訪問では数百ミリ秒取られます。
3. 5 ファミリー × 日本語
日本語フォントは1ファミリーで数百 KB〜MB 級。unicode-range で動的にサブセットが返ってくるが、それでも 5 ファミリー並べれば余裕で MB 級。
4. next/font 未使用 = self-host されない / preload されない
next/font/google を使うと、Google Fonts のファイルを ビルド時に取得して self-hostしてくれます。preload も自動。CSS variable 経由で参照するので Tailwind の --font-sans 等にもそのまま流せます。
対応:全フォントを next/font/google に移行
共通フォント(LP・blog 全体)
app/layout.tsx:
import { Zen_Maru_Gothic, Noto_Serif_JP } from "next/font/google";
const zenMaru = Zen_Maru_Gothic({
weight: ["400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-zen-maru",
preload: true,
});
const notoSerifJP = Noto_Serif_JP({
weight: ["400", "500", "700"],
subsets: ["latin"],
display: "swap",
variable: "--font-noto-serif",
preload: false, // 主役は Zen Maru、Noto Serif は preload しない
});
export default function RootLayout({ children }) {
return (
<html lang="ja" className={`${zenMaru.variable} ${notoSerifJP.variable}`}>
<body>{children}</body>
</html>
);
}ページスコープのフォント
精査したら、Klee One / Yomogi / Zen Kurenaido は /creation-notes/design ページでだけ使われていた。LP には不要なのに毎回 DL されていた構造。
app/creation-notes/design/layout.tsx を作って、そのページ配下でだけロードするようにした。
import { Klee_One, Yomogi, Zen_Kurenaido } from "next/font/google";
const klee = Klee_One({ weight: ["400", "600"], subsets: ["latin"], display: "swap", variable: "--font-klee", preload: false });
const yomogi = Yomogi({ weight: ["400"], subsets: ["latin"], display: "swap", variable: "--font-yomogi", preload: false });
const kurenaido = Zen_Kurenaido({ weight: ["400"], subsets: ["latin"], display: "swap", variable: "--font-kurenaido", preload: false });
export default function DesignLayout({ children }) {
return (
<div className={`${klee.variable} ${yomogi.variable} ${kurenaido.variable}`}>
{children}
</div>
);
}font-family 直書きを CSS variable 経由に書き換え
- fontFamily: "'Noto Serif JP', serif"
+ fontFamily: "var(--font-noto-serif), serif"next/font は名前ではなくハッシュ化されたクラス名でフォントを管理するため、直書きで 'Noto Serif JP' と書いてしまうと next/font 経由のフォントが当たりません。CSS variable 経由にすると確実に next/font のフォントが解決されます。
結果:改善した指標と悪化した指標が混ざった
Vercel にデプロイして PageSpeed を再計測したら、こうなった。
| 指標 | 旧 | 新 | 評価 |
|---|---|---|---|
| デスクトップ パフォーマンス | 56 | 73 | ✅ +17 |
| デスクトップ FCP | 1.7s | 1.0s | ✅ |
| デスクトップ TBT | 430ms | 50ms | ✅ -380ms |
| デスクトップ Speed Index | 3.9s | 1.8s | ✅ -2.1s |
| デスクトップ LCP | 2.2s | 5.0s | ❌ +2.8s |
| モバイル FCP | 8.2s | 5.8s | ✅ -2.4s |
| モバイル LCP | 10.3s | 29.4s | ❌❌ +19.1s |
| モバイル パフォーマンス | 57 | 50 | ❌ -7 |
最初は「LCP がモバイルで 10秒 → 29秒に悪化した!失敗だ!」と思った。
でも他の指標を見ると、
- TBT が 430ms → 50ms に劇的改善(フォント parser blocking が消えた決定的証拠)
- FCP が改善(モバイル -2.4 秒 / デスクトップ -0.7 秒)
- Speed Index が改善(デスクトップ -2.1 秒)
これら全部「実体が速くなってる」ことを示している。じゃあ LCP は何で悪化した?
仮説:LCP の判定要素が「テキスト」から「画像」にシフトした
LCP(Largest Contentful Paint)は、ビューポート内で最大の要素の描画完了時刻を測ります。判定対象になるのは
- 画像(
<img>/ 背景画像 /<picture>) - 動画のポスター画像
- ブロックレベル要素のテキストノード
何が「最大」になるかは、ページの構成によって動的に変わります。
旧(フォント未読込)
- 日本語フォントが読み込まれる前は、ブラウザはフォールバックフォントで描画します。フォールバックフォントが小さい・幅が違うと、テキスト要素のレイアウト面積が変わります。
- フォントが遅延すると、テキストの最終確定までの時間が伸びます。**「フォントが遅すぎて LCP がテキストの遅延として観測されていた」**可能性があります。
新(フォント即表示)
- フォントが preload + self-host で即座に描画されるため、テキスト要素はすぐに確定します。
- これまで隠れていた「画像が遅い」という問題が、フォント問題に隠れていたために観測されていなかっただけで、今回ようやく真の主犯が露出した。
学び:指標は「絶対値」ではなく「何を測っているか」で読む
1. 「指標が悪化 = 失敗」とは限らない
指標が悪化したように見えても、それまで隠れていた問題が観測可能になっただけのケースがあります。今回がまさにそれ。
判定するには、
- 他の指標がどう動いたか
- 改修で何の挙動が変わったか
- 指標の 判定要素自体が変わった可能性
をセットで見る必要があります。
2. パフォーマンス改善は「ボトルネックの可視化」
最適化は 1 回で終わりません。1 つ直すと次のボトルネックが見えてきます。フォントを直したら次は画像、画像を直したら次は JS バンドル、というように。
「全部直してから計測」ではなく、**「1 つ直して、計測して、次の主犯を探す」**のループが筋がいいです。
3. CSS @import は本当に避ける
CSS @import でフォントを読み込むのは「とにかく動かす」段階では便利だけど、本番運用では
- レンダリングブロッキング
- 直列 DL
- 外部ドメインへの DNS lookup
- preload が効かない
の 4 重苦になります。Next.js なら next/font/google が ほぼ無条件で正解。
次の一歩
Hero 画像(HeroSection.tsx の polaroid 8 枚)が次のフェーズの主犯候補。
- Lighthouse 詳細レポートで実 LCP 要素を特定する
- ソース画像のサイズが過剰(600×600)なら、表示サイズに合わせて縮小
- priority の設計見直し(現在は最初の 2 枚だけ priority)
- モバイルでカード枚数を絞る選択肢
このループで、たぶんあと 1〜2 回の最適化フェーズが必要になる。LCP の数値だけ追いかけずに、毎回「何の主犯が露出したか」を読むようにする。
まとめ
- Google Fonts を CSS
@importで読み込むのは性能的に最悪です。next/font/googleに移行すれば self-host + preload + unicode-range で 3 重に最適化されます。 - 改修後に「指標が改善 + 別の指標が悪化」が混在したら、LCP / FCP / TBT がそれぞれ何を測っているかを切り分けて読みます。
- 特に LCP は判定要素が動的に変わります。「テキストが LCP 要素」だったのが「画像が LCP 要素」にシフトすると、数値が悪化したように見えるが、それは真の主犯が露出した証拠。
- パフォーマンス改善は 1 つ直すたびに次の主犯が見える「層構造」のもの。1 回で終わらせようとしません。