5/11 に Google Fonts の
@importをnext/font/googleに置き換えて、PageSpeed の真っ赤を一段抜けた。続編としてもう一度計測したら、まだ 3 つ問題が並んでいた。順番に潰した記録。前提は 前回の記事 を参照。
この記事でわかること
next/font/googleで日本語フォントをpreload: trueにすると、HTML に 何百個もの<link rel="preload">が注入されること- LCP 対象画像を
motionで fade-in(initial: { opacity: 0 })すると、SSR HTML 時点で opacity 0 になって LCP が確定しない問題 next/font/googleが自動でやってくれること / やってくれないこと(fallback フォントスタック)- 「指標を 1 つ直すと次の主犯が見える」という前回の続きの実演
前提:LCP / FCP / TBT って何
PageSpeed Insights / Lighthouse の数字を読むには、まず Web Vitals の 3 指標を押さえておく必要があります。
| 指標 | 何を測ってる | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| FCP (First Contentful Paint) | 最初の文字/画像が画面に出るまで | ≤ 1.8 s | ≤ 3.0 s | > 3.0 s |
| LCP (Largest Contentful Paint) | ページの 最大コンテンツ要素が描画されるまで | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| TBT (Total Blocking Time) | JS がメインスレッドをブロックした合計時間 | ≤ 200 ms | ≤ 600 ms | > 600 ms |
FCP が早くても LCP が遅ければ「白から灰色になっただけで本体が出てこない」状態に見えます。だから今回も LCP を主役に据えて読んでいく。
LCP の対象になるのは
- 画像 (
<img>/ 背景画像 /<picture>) - 動画のポスター画像
- ブロックレベル要素のテキストノード
のうち、ビューポート内で面積が最大のもの。ページ構成によって動的に変わります。今回の LP だと「中央の綴Photo ロゴ画像」が LCP 対象。
補足:Web Vitals には他に CLS(Cumulative Layout Shift、ガタつき度合い)と INP(Interaction to Next Paint、操作応答性)もある。今回のサイトはこの 2 つはほぼ問題ないので割愛。
計測結果の整理:3 つの主犯
PageSpeed Insights のデスクトップ結果を開いたら、こうなっていた。
| 指標 | 値 |
|---|---|
| パフォーマンス | 89 |
| FCP | 0.9 秒 |
| LCP | 4-5 秒台(要素のレンダリング遅延 3,430ms) |
| TBT | 60 ms |
| CLS | 0 |
問題セクションには 3 つ並んでいた。
- レンダリングをブロックしているリクエスト ─ 推定削減 780ms
- 強制リフロー ─ 合計 623ms
- LCP の内訳 ─ 要素のレンダリング遅延が 3,430ms
スコアだけ見ると 89 で「悪くない」のだが、LCP 4 秒超は体感としても訪問者に「遅い」と感じさせるライン。1 つずつ精査することにした。
主犯 ①:HTML に <link rel="preload"> が 240 個入っていた
本番の HTML をそのまま curl で取ってきて、<head> 内の preload を数えたら 240 個あった。
curl -sL https://tsuzuphoto.com/ \
| grep -oE '<link[^>]+rel="preload"[^>]+as="font"[^>]+>' \
| wc -l
# => 240HTML の総バイト数は 136KB。中身はほぼ preload リンクで埋まっている状態。
なぜこうなったか
layout.tsx の next/font の設定はこうだった。
const zenMaru = Zen_Maru_Gothic({
weight: ["400", "500", "700"], // 3 weights
subsets: ["latin"],
display: "swap",
variable: "--font-zen-maru",
preload: true, // ← これ
});日本語フォントは Google Fonts 側で unicode-range を使って 80 個前後のサブセットに分割されています。文字の集合(CJK 統合漢字 拡張 A、ひらがな、カタカナ、…)ごとに別ファイルになっています。
preload は本当に必要か?
preload: true の目的は「フォントの発見を早めること」。ヒーローセクションに出る文字は数十文字で足ります。ブラウザは preload を「絶対使う」と解釈するので、メリットよりデメリットが大きいです。
対応:preload: false に変える
- preload: true,
+ preload: false,これでブラウザは「CSS 読んでから、ページに出ている文字だけ取りに行く」挙動になります。確認すると、実際にダウンロードされるフォントファイルは 1 個だけだった。
余談:「preload なしだと無駄なフォントまで取られるのでは?」と最初は思ったが、逆だった。preload なしの方が ブラウザが必要なものだけ取りに行く ので無駄がない。preload は「最初の1枚を早く見せるための加速」であって、「対象を絞る仕組み」ではない。
副次:weight を絞ると CSS チャンクも軽くなる
Zen_Maru_Gothic の CSS チャンクサイズを weight 別に見ると、
| weight | CSS チャンクサイズ |
|---|---|
| 400 / 500 / 700 | 273 KB |
| 400 / 700 | 約 180 KB |
| 400 のみ | 91 KB |
LP では font-bold を font-serif 無しで使っている箇所がゼロ(= Zen Maru の 700 は実質未使用)だったので、weight: ["400"] のみに絞った。
主犯 ②:LCP 対象画像を motion で fade-in していた
「要素のレンダリング遅延 3,430ms」をどう読むか。
LCP の内訳を見ると
| 内訳 | 値 |
|---|---|
| TTFB | 0 ms |
| リソース読み込みの遅延 | 250 ms |
| リソースの読み込み時間 | 350 ms |
| 要素のレンダリング遅延 | 3,430 ms |
画像(綴Photo ロゴ)のファイル取得は 600ms 以内に終わっている。にもかかわらず、画面に出るまで 3.4 秒待たされている。
原因:motion の initial opacity 0 が SSR HTML に焼き込まれている
HeroSection.tsx のロゴ画像周辺がこうなっていた。
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.8, delay: textStart - 0.2 }}
>
<Image
src="/images/lp/ios_icon.jpeg"
alt="綴Photo"
priority
...
/>
</motion.div>textStart は 0.3 + 8 * 0.12 + 0.1 = 1.36s。delay 1.16s、duration 0.8s で opacity 1 到達は 1.96s。
PageSpeed の「要素のレンダリング遅延 3,430ms」とほぼ一致する。
対応:LCP 対象だけ initial opacity を 1 に
- initial={{ opacity: 0, scale: 0.9, y: 6 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- transition={{ duration: 0.8, delay: textStart - 0.2 }}
+ initial={{ scale: 0.94 }}
+ animate={{ scale: 1 }}
+ transition={{ duration: 0.6, delay: 0 }}opacity は最初から 1。scale だけ 0.94→1 のさりげない演出を残す。LCP は SSR HTML 時点で確定するため、数値が一気に下がります。
ロゴ以外(テキスト・カード・CTA)は fade-in 演出をそのまま残す。これらは LCP 判定対象ではないので、演出を保ったまま OK。
副次:「全アニメ完了を 2.5 秒以内」のリデザイン
LCP 自体は SSR で確定するが、訪問者の体感として「ヒーローが完成するまで」が遅すぎた。せわしなく感じない範囲で全アニメ完了を 2.5 秒以内に着地させる、と方針を決めて定数を組み直した。
| 定数 | 旧 | 新 |
|---|---|---|
CARD_BASE_DELAY |
0.3 | 0.1 |
CARD_STAGGER |
0.12 | 0.06 |
CARD_ENTER_DURATION |
1.1 | 0.85 |
CAPTION_BASE_DELAY |
1.45 | 0.5 |
CAPTION_CARD_STAGGER |
0.7 | 0.25 |
CAPTION_CHAR_STAGGER |
0.07 | 0.04 |
DATE_OFFSET_AFTER_CAPTION |
0.4 | 0.20 |
全アニメ完了が約 4.9 秒 → 2.9 秒に。一発のアニメ duration は 0.5〜1秒に収まっているので「ふわっと感」は残せている範囲。
主犯 ③:fallback フォントスタックが無かった
ここは PageSpeed の指標ではなく、体感の問題。
preload: false にすると本フォント到着までに数百ms 遅れます。その間ブラウザは fallback で描画するが、globals.css を見たらこうなっていた。
--font-sans: var(--font-zen-maru), "Zen Maru Gothic", serif;最後の serif が fallback の終着点。serif は OS の 明朝体を意味します。Zen Maru Gothic は丸ゴシック。丸ゴシック ↔ 明朝体の切替が走ることになります。
next/font がやってくれること / やってくれないこと
next/font/google は次のことを自動でやってくれます。
- self-hosting(フォントファイルをビルド時に取得)
- フォントメトリクス調整(
adjustFontFallback: trueがデフォルト。fallback の ascent/descent を本フォントに揃えます) - preload リンク注入(
preload: trueの時)
やってくれないのは、
- fallback フォントファミリ名の指定
これは自分で font-family スタックに書く必要があります。
対応:本フォントに近い OS フォントを並べる
--font-sans: var(--font-zen-maru),
"Hiragino Maru Gothic ProN", "ヒラギノ丸ゴ ProN W4",
"Meiryo", "メイリオ",
system-ui, sans-serif;
--font-serif: var(--font-noto-serif),
"Hiragino Mincho ProN", "ヒラギノ明朝 ProN W3",
"Yu Mincho", "游明朝", "YuMincho",
serif;- Mac / iOS は OS に Hiragino Maru Gothic が入っているので 最初から丸ゴシック相当で描画されます。本フォント到着時の切替がほぼ視認できません。
- Win は丸ゴシックが OS に無いので Meiryo(角ゴシック)→ Zen Maru Gothic で多少の切替感が残ります。これは諦め。
「最終的に見えるフォント」は変えていない
ここを誤解しやすいので明示しておくと、
| 変更前 | 変更後 | |
|---|---|---|
| 本フォント(最終) | Zen Maru Gothic | 変えていない |
| fallback(つなぎ) | OS の明朝体(serif) | OS の丸ゴシック系 |
最終的なフォントは変えていない。つなぎの代打を本フォントに近い見た目に揃えただけ。
結果:Before / After
3 つの主犯を順に潰した結果。
| 指標 | Before | After | 変化 |
|---|---|---|---|
| HTML サイズ | 136 KB | 77 KB | -43% |
| フォント preload リンク数 | 240 個 | 1 個 | -99% |
| CSS チャンク合計 | 627 KB | 351 KB | -44% |
| デスクトップ スコア | 89 | 90 | +1 |
| デスクトップ LCP | 〜3.4s 台 | 1.5s | -1.9s |
| デスクトップ FCP | - | 0.9s | - |
| デスクトップ Speed Index | 2.2s | 1.9s | -0.3s |
LCP は PageSpeed の「Good」基準(2.5 秒)以内に着地。
モバイル:別の世界の話だった
スコアが 90 になった一方で、PageSpeed のモバイル結果は別ものだった。
| 指標 | モバイル |
|---|---|
| パフォーマンス | 43 |
| FCP | 5.4 秒 |
| LCP | 8.1 秒 |
| TBT | 710 ms |
低速 4G + 中位スマホ(Moto G Power)でのエミュレーション。同じサイトとは思えない数字。
ここから先は Phase 3 として、もう一段の最適化に進んだ。
Phase 3 ①:画像圧縮で 30 MB → 1.6 MB
ヒーローの実写真がそのまま public/images/lp/ に置かれていた。
| ファイル | 元サイズ |
|---|---|
| photo-2024-forest.jpg | 8.3 MB |
| photo-2014-sunset.jpg | 5.7 MB |
| demo-sea.jpg | 3.9 MB |
| photo-2024-mimosa.jpg | 2.6 MB |
| ios_icon.jpeg | 624 KB |
LP で実際に使う 12 枚を合計すると 約 30 MB。next/image は配信時に最適化するが、ソース画像が重いとビルド時の variant 生成も遅く、低速回線下では配信側の最適化の効きも弱いです。
sharp で一括 WebP 化
Next.js はすでに sharp を依存に持っているので、これを使ってビルド前の整地スクリプトを AI に書いてもらった。
// scripts/optimize-lp-images.mjs
import sharp from "sharp";
const TARGETS = [
{ src: "public/images/lp/photo-2024-mimosa.jpg", maxSize: 1800, quality: 82 },
{ src: "public/images/lp/demo-sea.jpg", maxSize: 1800, quality: 82 },
// ...
];
for (const t of TARGETS) {
const dst = t.src.replace(/\.(jpe?g|png)$/i, ".webp");
await sharp(t.src)
.resize(t.maxSize, t.maxSize, { fit: "inside", withoutEnlargement: true })
.webp({ quality: t.quality })
.toFile(dst);
}結果はこうなった。
| ファイル | Before | After | 削減 |
|---|---|---|---|
| photo-2024-forest | 8.3 MB | 573 KB | -93% |
| photo-2014-sunset | 5.7 MB | 126 KB | -98% |
| demo-sea | 3.9 MB | 70 KB | -98% |
| photo-2024-mimosa | 2.6 MB | 98 KB | -96% |
| ios_icon | 624 KB | 93 KB | -85% |
| 合計(12 枚) | ~30 MB | 1.6 MB | -95% |
WebP は PNG(圧縮効率)と JPEG(広い互換性)のちょうど中間の規格で、現代ブラウザならどれも対応しています。
結果:TBT が劇的に改善、LCP は据え置き
これで再計測すると、
| 指標 | Phase 2 後 | Phase 3 ① 後 | 変化 |
|---|---|---|---|
| パフォーマンス | 43 | 60 | +17 |
| TBT | 710 ms | 90 ms | -620 ms ✅ |
| LCP | 8.1 秒 | 8.1 秒 | ±0 ❌ |
TBT が劇的に下がった一方、LCP が変わらなかったのが面白い結果だった。「画像を 95% 軽くしたのに LCP が動かない」のは何故か?
Phase 3 ②:LCP は画像ではなく "CSS 取得時間" でブロックされていた
PageSpeed の「LCP リクエストの検出」セクションを開くと、次の指摘があった。
❌
fetchpriority=highを使用する必要があります
そして「レンダリングをブロックしているリクエスト」を見ると、
| ブロック要因 | 継続時間(モバイル 4G) |
|---|---|
| HTML 本体 78 KB | 2,250 ms |
| Zen Maru CSS 31 KB | 750 ms |
| Tailwind CSS 14 KB | 600 ms |
| Noto Serif CSS 33 KB | 900 ms |
| 合計 | ~4,500 ms |
低速 4G では同じ KB でも数倍の時間がかかります(4G が 1.6 Mbps スロットリングのため)。デスクトップだと一瞬で取れる 78 KB の HTML が 2.25 秒持っていかれます。
fetchPriority="high" を画像に明示
Next.js Image の priority プロパティは <link rel="preload" as="image"> を head に注入します。しかし fetchpriority 属性は Next.js のバージョンによって自動付与されたりされなかったりする(v16 でも今回付いていなかった)。
PageSpeed が指摘していた通り、明示的に書く。
<Image
src="/images/lp/ios_icon.webp"
alt="綴Photo"
priority
fetchPriority="high" // ← 追加
...
/>priority と fetchPriority の役割の違い
| プロパティ | 書き出されるもの | 役割 |
|---|---|---|
priority |
<head> に <link rel="preload" as="image"> |
「いますぐ取りに行って」(先読み命令) |
fetchPriority="high" |
<img fetchpriority="high"> |
「他より先に帯域を割り当てて」(順位付け) |
低速回線では「同時に複数のリソースを取得 → 帯域を分け合う」状態になるので、fetchPriority で取得順を明示的にコントロールできます。
結果:Speed Index がさらに改善、LCP は微減
| 指標 | Phase 3 ① 後 | Phase 3 ② 後 | 変化 |
|---|---|---|---|
| パフォーマンス | 60 | 62 | +2 |
| FCP | 5.6 s | 5.3 s | -0.3 s |
| LCP | 8.1 s | 7.8 s | -0.3 s |
| TBT | 90 ms | 50 ms | -40 ms |
| Speed Index | 6.4 s | 5.3 s | -1.1 s ✅ |
fetchPriority の効果は Speed Index に最も強く効いた(-1.1 s)。LCP は -0.3 s と限定的。
なぜ LCP がまだ 7.8 秒なのか
ここで打ち切りを判断した。理由は、
残る主犯:CSS の取得時間
fetchPriority で画像取得は最優先になったが、その前段の CSS が依然 2.25 秒のブロックを作っている。CSS の中で最も重いのは Noto Serif JP(189 KB)。これを削減すれば LCP は 6 秒台に入る見込み。
ただし Noto Serif JP は LP の見出しフォントで、サイトの世界観に直結します。「フォントを軽量明朝に置き換える」「使用範囲を縮小する」のどちらも世界観への影響が読みづらく、ローンチ前マスト完了の優先度を考えるとリスクを取りに行く局面ではないと判断した。
実ユーザーの計測条件
PageSpeed Insights のモバイル計測は **低速 4G + 中位スマホ(Moto G Power)**で行われます。これは「最悪に近い条件」での参考値。実ユーザーの多くは
- WiFi(家・職場)
- 5G・高速 4G
- もう少し新しいスマホ
で見ます。そこでの LCP は 2-3 秒台に収まる可能性が高いです(デスクトップが 1.6 秒なので、回線が同等なら近い数字になります)。
※ もちろん「最悪条件の数字」を改善する価値はあるが、フォント世界観 vs スコアのトレードオフは今回はフォント側を残した。
最終スコア
| デスクトップ | モバイル | |
|---|---|---|
| パフォーマンス | 92 | 62 |
| LCP | 1.6 s | 7.8 s |
| FCP | 0.8 s | 5.3 s |
| TBT | 10 ms | 50 ms |
| Speed Index | 1.6 s | 5.3 s |
デスクトップは Good 圏内。モバイルは Needs Improvement だが、出発点(43, LCP 8.1 s)からは確実に前進。
学び
1. next/font の preload: true は日本語に対しては罠
英語フォントなら preload は素直に効くが、日本語フォントは unicode-range で多数に分割されています。HTML 本体が肥大化して preload の旨味より副作用が勝ちます。日本語フォントは preload なしの方が良いケースが多いです。
2. motion の initial は SSR HTML に焼き込まれる
これは next/font の話ではありません。SSR する Next.js で motion / framer-motion を使う場合に起きます。
LCP 対象になる要素を fade-in する設計は、LCP 確定をそのアニメ完了まで遅らせます。LCP 対象だけは初期 opacity 1 で出す設計にします。「LCP 要素は素早く、周辺はゆったり」のフレーズで覚えておくと良いです。
3. fallback フォントスタックは自前で用意する
next/font/google は便利だが、fallback の フォントファミリ名は自動指定してくれません。var(--font-xxx), serif のような書き方だと、本フォント到着までの間に明朝体で表示されてしまいます(Zen Maru Gothic を読み込んでいる時に)。
本フォントに近い OS フォントを fallback として並べると、切替の体感がほぼ消えます。
4. 画像最適化が効くのは「画像がボトルネックの間」だけ
Phase 3 ① で LP 画像を 30 MB → 1.6 MB に圧縮した。TBT は 710 ms → 90 ms に劇的に下がったが、LCP は 8.1 秒のまま動かなかった。
「重そうなものから順に潰す」は正しいが、「直したのに数字が動かない」のは失敗じゃなく、ボトルネックが別のレイヤーに移った合図。次の主犯を探しに行くタイミング。
5. priority だけでは fetchpriority 属性は付かない
next/image の priority プロパティは「画像を <link rel="preload"> で先読みする」指示で、これと「fetchpriority="high" 属性を画像タグに書く」のは別の話。
| プロパティ | 役割 |
|---|---|
priority |
<head> に <link rel="preload" as="image"> を注入(先読み命令) |
fetchPriority="high" |
<img> に fetchpriority="high" 属性を書く(順位付け) |
低速回線では「複数リソースが帯域を取り合う」状況になるので、後者で順位を明示しないとブラウザが判断に迷います。Next.js のバージョンによって priority が自動で fetchpriority="high" を付与するケースもあるが、明示的に書く方が確実。
PageSpeed Insights の指摘事項に「fetchpriority=high を使用する必要があります」と出たら、まずここを疑います。
6. 「指標を 1 つ直すと次の主犯が見える」のループ
前回の Phase 1 では LCP の判定要素がテキスト → 画像にシフトした話を書いた。Phase 2 では preload を消したら motion の遅延が主犯になり、それを直したら今度はモバイルが丸ごと遅い(LCP 8.1s)と分かった。Phase 3 では重そうな画像を疑って圧縮したが LCP は動かず、本当の主犯は CSS 取得時間だった。
最適化は「総当たり」ではなく「ボトルネックを可視化していく層構造」のもの。1 回で全部直そうとせず、計測 → 対応 → 再計測のループで進めます。
7. 「最悪条件の数字」と「実ユーザー体感」は別物
PageSpeed Insights のモバイル計測は 低速 4G + 中位スマホ が前提。これは「最悪に近い条件」での参考値。実ユーザーの多くは WiFi や 5G で見るので、スコア 62 でも体感は十分速いことが多いです。
「スコア 90 まで上げる」ことを目的にすると、世界観を壊してまでフォントを置換する判断になりかねません。スコアは目的じゃなく診断ツールとして扱うのが筋がいいです。
まとめ
- next/font の
preload: trueは日本語に対しては罠。preload: falseにして「使う文字だけ取りに行く」挙動に任せます - LCP 対象要素を motion で fade-in しません。SSR HTML 時点で opacity 0 になり LCP が確定しません。LCP 要素だけは初期から見える状態に
- fallback フォントスタックは自前で用意します。next/font は本フォント名しか入れてくれません
- 画像が遅いと思っても、本当の主犯は CSS の取得時間かもしれません。画像最適化で LCP が動かなかったら次のレイヤーへ
priorityとfetchPriorityは別物。低速回線で LCP を効かせたいなら両方明示します- 数字が動かないのは失敗じゃなくボトルネックが移った合図。次の主犯を探します
- 最終: HTML -43%、CSS -44%、画像 -95%、デスクトップ LCP 3.4s → 1.6s、モバイル LCP 8.1s → 7.8s(スコア 43 → 62)。フォント世界観 vs スコアのトレードオフでフォント側を残した。スコアは目的じゃなく診断ツール