日本語 Web フォントと Core Web VitalsLCP と CLS を実測で直す
このサイト (notes.rewheel.dev) の SEO を点検したとき、技術的な項目はほぼ満点でした。sitemap も robots.txt も canonical も OGP も JSON-LD も揃っていて、Lighthouse の SEO スコアは全ページ 100。それなのに Performance だけが 58〜63 で止まっていました。
調べていくと、足を引っ張っていたのは Core Web Vitals の 2 つ — LCP (読み込みの速さ) と CLS (レイアウトのずれ) でした。そして犯人は、最初に疑った場所とは違うところにいました。LCP は日本語 Web フォントの読み込み方、CLS は本文の画像ではなくヘッダーのロゴ。この記事は、その犯人を実測で特定し、効かない対策を入れずに直すまでの記録です。
Web フロントエンドの基本 (HTML/CSS、ブラウザの読み込み) が分かる方を想定しています。Docusaurus 固有の話も出ますが、render-blocking や CLS の考え方は静的サイト全般に通用します。
なぜ Core Web Vitals を直すのか
Core Web Vitals は Google が定義したユーザー体験の指標で、検索ランキングのシグナルにもなっています。3 つの指標があり、2026 年時点の「良好 (good)」のしきい値は次のとおりです (web.dev より)。
| 指標 | 測るもの | good | poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | 主要コンテンツが描画されるまでの時間 | ≤ 2.5s | > 4.0s |
| CLS (Cumulative Layout Shift) | 読み込み中の累積レイアウトずれ | < 0.1 | > 0.25 |
| INP (Interaction to Next Paint) | 操作への応答の速さ | < 200ms | > 500ms |
「技術的 SEO が満点なら十分では」とも思えますが、SEO スコアはあくまで 機械可読性 の点検です。タイトルや構造化データが正しくても、ページが遅かったり読み込み中にガクッとずれたりすれば、ユーザーは離脱しランキングも伸びません。CWV は、その 体感 の側を数値化したものです。
Docusaurus v3.10 で構築した静的サイトで、本文フォントに日本語の Klee One を使っています。日本語 Web フォントはグリフ数が多くファイルが大きいため、CWV では英語サイトと違うクセが出ます。そこが今回の肝でした。
ベースライン何が「未達」だったのか
まず本番 (notes.rewheel.dev) を Lighthouse のモバイル設定 (CPU 4x スロットリング) で計測しました。
| ページ | Performance | LCP | CLS | SEO |
|---|---|---|---|---|
| トップ (/) | 58 | 9.3s | 0 | 100 |
| ブログ記事 | 62 | 4.4s | 0.287 | 100 |
| ドキュメント | 63 | 4.1s | 0.305 | 100 |
LCP は全ページが poor (4 秒超)、CLS はブログとドキュメントが poor (0.25 超)。SEO は満点。問題の所在がはっきりしました。Performance、それも LCP と CLS です。
ここから 2 つの犯人を別々に追いました。
犯人 1LCP を遅らせていた render-blocking なフォント
Lighthouse の render-blocking-insight が指していたのは、<head> で同期読み込みしている CSS でした。中でも重かったのが Google Fonts の CSS です。
当時の指定はこの 4 ファミリを 1 つの URL でまとめて読み込んでいました。
https://fonts.googleapis.com/css2?family=Klee+One&family=Noto+Sans+JP&family=Inter&family=JetBrains+Mono
問題は日本語フォント (Klee One / Noto Sans JP) です。日本語は常用漢字だけで 2,000 字を超え、フォントは数百のサブセットファイルに分割して配信されます。CSS 本体だけで約 150KB あり、しかもこれが render-blocking — つまりブラウザはこの CSS を取得し終えるまで本文を描画できません。CPU とネットワークが絞られたモバイルでは、ここで数秒が溶けていました。
ブラウザの読み込みを図にするとこうなります。同期読み込みでは、フォント CSS が First Paint の手前に割り込んでいます。
さらに 2 つの無駄がありました。
- Inter は読み込んでいるのに使っていなかった。
font-familyのどこにも Inter は登場せず、純粋に転送量を増やすだけのファミリでした。 - KaTeX の CSS も全ページで同期読み込みしていました。数式は一部の記事にしか出ないのに、トップページでも render-blocking になっていました。
犯人 2CLS を起こしていたのは画像ではなくロゴ
CLS の方は、最初フォントを疑いました。Web フォントは読み込み完了時にフォールバックから切り替わる (swap) ので、字幅が変わって行が組み直され、レイアウトがずれる — というのは典型的な CLS 要因です。
ところが数字が合いません。CLS が高いのはブログ (0.287) とドキュメント (0.305) で、ほぼ同じ値。もしフォント swap が主犯なら本文量に応じて差が出るはずですが、そうなっていない。そして決定的だったのが、hero 画像を持たないドキュメントページでも CLS 0.305 だったことです。本文中の画像が犯人なら、画像の無いページでは下がるはず。下がっていない。
つまり犯人は 全ページに共通する要素。それは <head> の次に来る navbar、その中のロゴでした。
navbar のロゴ <img> に width/height が無いと、ブラウザは読み込み前にロゴの占める領域を確保できません。いったん高さ 0 で本文を上に詰めて描画し、ロゴ画像が届いた瞬間に領域を確保して本文を下へ押し下げる。これが累積されて CLS になっていました。
CLS の調査は「本文の派手な要素」から疑いがちですが、まず 全ページ共通のヘッダー/フッターに寸法未指定の画像が無いかを見るほうが早いことが多いです。共通要素は全ページの CLS を底上げします。
フォント swap については、この時点では「副次的に効いているかもしれない」と保留にしました。後で実測して決着をつけます (伏線)。
対策の実装
特定できた原因に対して、5 つ手を入れました。前半 3 つが CWV へ直接効く本丸です。
1. 未使用の Inter を削除
font-family に存在しない Inter を Google Fonts の URL から外しました。見た目はまったく変わらず、転送量だけが減ります。リスクゼロの純利益です。
2. navbar ロゴに width/height を付与
CLS の主犯への直接の対処です。Docusaurus の navbar.logo は width/height を受け付けます。
navbar: {
logo: {
alt: 'Reinvent Notes Logo',
src: 'img/logo.svg',
// CLS 対策: 読み込み前に領域を確保する (実測した CLS の主因)
width: 32,
height: 32,
},
// ...
}
これでブラウザは読み込み前にロゴ領域を予約でき、画像到着時の押し下げが起きなくなります。
3. フォントと KaTeX の CSS を非同期化
LCP の主犯への対処です。render-blocking を解消するため、preload で取得しておき、読み込み完了時に rel="stylesheet" へ差し替える定番パターンに移しました。Docusaurus では headTags で <head> にタグを注入できます。
headTags: [
{
tagName: 'link',
attributes: {
rel: 'preload',
as: 'style',
href: 'https://fonts.googleapis.com/css2?family=Klee+One:wght@400;600&family=Noto+Sans+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap',
// 読み込み完了時に stylesheet へ昇格させ、render-blocking を回避する
onload: "this.onload=null;this.rel='stylesheet'",
},
},
// KaTeX CSS も同じ要領で非同期化(数式の無いページの render-blocking を解消)
// JS 無効環境向けに <noscript> で同期版のフォールバックも併記する
],
読み込みのタイムラインは、同期から非同期へこう変わります。
修正後はフォールバックの system-ui でまず描画し、フォントが届いてから差し替えます。display=swap を付けているので、フォント取得中もテキストは見えたままです (FOIT を避けられます。詳しくは MDN: font-display)。
あわせて、これまで custom.css の @import で読んでいたフォントも headTags 側へ寄せ、CSS 内 @import による直列読み込みを解消しました。
4. 画像を WebP 化
トップの hero 画像と OGP の social-card を PNG から WebP へ差し替え、旧 PNG は削除しました。hero は約 90% の軽量化です。LCP 候補になりうる大きな画像の転送量を下げる効果があります。
5. llms.txt の生成(おまけ)
CWV とは別軸ですが、AI クローラ向けに /llms.txt をビルド時生成するプラグインも足しました。生成 AI 経由の流入 (GEO/AEO) を意識した対応です。
検証スロットリングなしでは CLS は測れない
修正の効果は、測り方を間違えると見えません。最初、ローカルのビルドを制限なしで計測したら CLS は 0.01。これでは元の 0.3 がどこへ行ったのか分かりません。
種明かしは CLS はネットワーク/CPU の遅さに強く依存する点にあります。回線が速いとフォントもロゴも一瞬で届き、シフトが起きる前に描画が固まってしまう。本番のモバイル相当の遅さを再現しないと、そもそも問題が再現しないのです。
そこで Chrome DevTools (MCP 経由) の performance trace で、本番相当のスロットリングをかけて計測しました。
CPU: 4x スロットリング
Network: Slow 4G
Viewport: 360 x 640 @2x(モバイル・タッチ)
この条件で、ロゴに寸法を付けたあとのドキュメントページを再計測した結果がこれです。
| ページ | 修正前 CLS | 修正後 CLS |
|---|---|---|
| ドキュメント (カテゴリ) | 0.305 | 0.00 |
| ドキュメント (本文 250KB) | — | 0.00 |
本文量の多いページでも 0.00。DevTools の CLSCulprits インサイトも「該当するレイアウトシフトなし」で、シフトそのものが消えました。
LCP もローカルでは大きく改善しましたが、localhost には CDN までの往復遅延が無いため、この数値を本番の LCP と直接比べることはできません。本番 after の LCP は別途あらためて計測する必要があります (この記事では断定しません)。確実に言えるのは「render-blocking だったフォント/KaTeX CSS を critical path から外した」という構造の変化までです。
size-adjust を入れなかった理由
ここで、保留にしていたフォント swap の話に戻ります。
日本語フォントの CLS 対策としてよく挙がるのが、フォールバックフォントに size-adjust を効かせて Web フォントとメトリクスを揃える手法です。swap の瞬間に字幅・行高が変わらないようにして、組み直しによるシフトを防ぐものです。「日本語フォント + CLS」と来れば、これを入れるのが定石にも見えます。
しかし今回は 入れませんでした。理由は、実測でフォント swap が CLS にほぼ寄与していないと分かったからです。ロゴに寸法を付けただけで、本文量の多いページでも CLS は 0.00。swap で組み直しが起きているなら、ここに値が残るはずですが、残っていません。
構造的にも説明がつきます。本文の行の高さは、custom.css でこう決めています。
:root {
--ifm-font-size-base: 16px;
--ifm-line-height-base: 1.8; /* 単位なし */
}
line-height が 単位なし (1.8) で、font-size が固定 (16px)。すると 1 行の高さは常に 16px × 1.8 = 28.8px で、どのフォントに swap しても行ボックスの高さは変わりません。縦方向の組み直しが起きにくい構造になっていたわけです。
size-adjust は正しい手法ですが、それが効くのは「フォント swap が実際に CLS を起こしている」ときだけです。今回はその前提が成立していませんでした。効くか分からない対策を「念のため」入れると、検証されないコードが残り、メンテナンスの負債になります。 目的の指標 (CLS) を計測し、達成済みなら追加の防御は入れない — そう判断しました。
ひとつ正直に補足すると、今回の after 計測はフォントがキャッシュ済みの状態 (warm cache) での swap を見ています。完全な初回訪問 (cold) では、字幅の差で行が再ワラップして微小な CLS が出る理論的な余地は残ります。ただし以前 cold に近い高速条件で測ったときも CLS は 0.01 で、good のしきい値 0.1 の 1/10。実害が出る水準ではないと判断しています。気になる場合は cold cache を分離した計測でさらに詰められます。
まとめ
このサイトの Core Web Vitals を直して分かったことは、突き詰めると 3 点です。
- LCP の主犯は日本語 Web フォントの render-blocking だった。150KB の同期 CSS を
preload → swapで非同期化し、未使用の Inter を外して critical path から退けた。 - CLS の主犯はフォントではなく、寸法未指定の navbar ロゴ だった。画像の無いページでも CLS が高いことが決め手で、
width/heightを付けるだけでシフトが消えた。 - size-adjust は計測の結果として入れなかった。単位なし
line-heightのおかげで swap による組み直しが小さく、CLS は既に 0。効かない対策を足さないことも最適化のうち。
「日本語フォントが重い → とにかくフォント対策」と短絡せず、LCP と CLS をそれぞれ計測して別々の犯人を特定したのが効きました。次にやるなら、本番 after の LCP 再計測と、cold cache を分離した CLS の詰めです。
- Web Vitals (web.dev):各指標の定義としきい値
- Largest Contentful Paint (web.dev):LCP の内訳と改善
- font-display (MDN):swap と FOIT/FOUT
- Docusaurus: head metadata:
headTagsでの<head>注入