メインコンテンツまでスキップ

日本語 Web フォントと Core Web VitalsLCP と CLS を実測で直す

· 約14分
Software Engineer

このサイト (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 より)。

指標測るものgoodpoor
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 スロットリング) で計測しました。

ページPerformanceLCPCLSSEO
トップ (/)589.3s0100
ブログ記事624.4s0.287100
ドキュメント634.1s0.305100

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.logowidth/height を受け付けます。

docusaurus.config.ts
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> にタグを注入できます。

docusaurus.config.ts
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.3050.00
ドキュメント (本文 250KB)0.00

本文量の多いページでも 0.00。DevTools の CLSCulprits インサイトも「該当するレイアウトシフトなし」で、シフトそのものが消えました。

LCP の本番値について

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 の詰めです。

関連リソース

記事の感想・フィードバックは X でお気軽にどうぞ

𝕏 で感想をツイート