日本語・中国語サイトのモバイル Lighthouse を 77→100 にした話:フォントとロケールの落とし穴
/ja はモバイル Lighthouse で 77 点、CLS 0.179。/zh は見出しの描画まで 3.84 秒。最大の原因は CJK フォントではなく、ページデータが届く前に en を返していたロケール store でした。何を変え、何を計測し、計測した結果どの案を捨てたかをまとめます。
私たちのサイトは SvelteKit で作っていて、英語・繁体字中国語・日本語の 3 言語で同じページを配信しています。英語のトップページはモバイル Lighthouse でもそこそこの点数でしたが、中国語と日本語のページは明らかに遅れていました。/zh は 86 点で LCP 3.84 秒、/ja は 77 点で、さらに CLS が 0.179 ありました。
真っ先に疑うのはフォントです。CJK の Web フォントは大きく、Noto Sans TC だけでも多数のサブセットに分かれて数 MB あります。私たちもそこから調べ始めました。ところが /ja のレイアウトが動き、/zh の見出しの描画が大きく遅れていた主な原因は、フォントとはほとんど関係のないバグでした。この記事は調査の順番に沿って書いています。数字はすべて実測値で、コードは現在サイトで動いているもの(抜粋)です。
まず結果
| ページ | モバイルスコア | モバイル LCP(秒) | モバイル CLS |
|---|---|---|---|
| /(英語) | 92 → 100 | 3.01 → 1.66 | 0.001 → 0.001 |
/zh | 86 → 100 | 3.84 → 1.66 | 0 → 0 |
/ja | 77 → 100 | 3.91 → 1.66 | 0.179 → 0 |
/zh/pricing | 93 → 100 | 3.01 → 1.65 | 0.003 → 0.003 |
| テンプレート¹ | 91 → 100 | 3.16 → 1.65 | 0 → 0 |
//zh/ja/zh/pricing/zh/templates/…//zh/ja/zh/pricing/zh/templates/…計測方法:Lighthouse 12.8 のモバイルシミュレーション(既定のスロットリング)で、ローカルの本番ビルドに対して各ページ 3 回計測した中央値です。変更前と変更後は同じマシン、同じ時間帯で計測しました。リリース後に本番でもスポット計測し、/zh と /ja はどちらも 97 点、LCP 1.3 秒、CLS 0 でした(各 1 回)。変更前にも本番で計測していましたが、1 回ごとのばらつきが大きく、同じ /zh が 1 回目は 63 点、数分後には 89 点でした。そのため変更前後の比較はローカルの中央値で行っています。
症状:LCP のほとんどが「レンダー遅延」
Lighthouse の LCP 内訳を見ると問題の形がはっきりします。/zh の LCP 要素は見出しのテキストで画像ではないので、リソース読み込みのフェーズはありません。3.84 秒のうち 0.45 秒が TTFB、残りの 3.39 秒(88%)はすべてレンダー遅延でした。HTML は届いているのに、テキストがなかなか描画されない状態です。
その間ブラウザは何をしていたのか。同じレポートのリクエスト一覧を見ると:
- Noto Serif TC と Noto Sans TC の Google Fonts スタイルシート(転送量 135 KiB、展開後は約 490 KiB、優先度 VeryHigh)
- 欧文フォント(Newsreader、Inter、JetBrains Mono)の Google Fonts スタイルシート(同じく VeryHigh)
- 約 50 本の JS
modulepreload - さらに回線が速く JS がすでに手元にある場合、ブラウザはサーバーで描画済みのページを表示する前にハイドレーションを最後まで実行していた
これらを一つずつ片付けました。ただ、フォントの話の前に、いちばん意外だったものから。
落とし穴 1:ページデータが届く前にロケール store が en を返していた
きっかけは小さな違和感でした。/ja の読み込み中、言語切り替えの国旗が「日本 → アメリカ → 日本」と一瞬変わるのです。
サイトの locale は SvelteKit の $page から派生させた store で、言語はサーバーの load が決めてページデータに入れています。問題はハイドレーション開始直後、最初のページデータがまだない時間でした。store は値を得られないので既定値の 'en' を返します。そしてこの store の購読側が <html lang> を同期していました。
// 変更前
export const locale = derived(page, ($page): Locale => {
const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
return raw === 'zh' ? 'zh' : raw === 'ja' ? 'ja' : 'en';
});
その結果、/zh と /ja の <html lang> が数百ミリ秒のあいだ en になっていました。CJK フォントは html[lang="ja"] のようなセレクタで CSS 変数を切り替えていて、見出しの文字サイズも言語によって変わります。この間、見出しは英語のフォントとサイズで組み直され、ページデータが届くと元に戻って、もう一度組み直されていました。
- /ja:2 回の組み直しで CLS 0.179(デスクトップでは 0.094)
- /zh:最大のテキストブロックが戻るときに再描画され、LCP が後ろにずれる
- 言語に依存するブロックがいったん英語でハイドレーションされ、その後中国語・日本語に戻る
<html lang="ja"><html lang="en"><html lang="ja"><html lang="ja"><html lang="ja"><html lang="ja">修正は小さなものです。ページデータがまだなければ URL から言語を読みます。サーバーも URL のプレフィックスで言語を決めているので、同じルールを使えば両者は食い違いません。
// 変更後(src/lib/i18n/locale.ts)
export const locale = derived(page, ($page): Locale => {
const raw = ($page?.data as { locale?: unknown } | undefined)?.locale;
if (raw === 'zh' || raw === 'ja' || raw === 'en') return raw;
// No page data yet: the URL already says which language the page is.
if (browser) return localeFromPath(location.pathname);
return 'en';
});
これだけで /ja の CLS は 0 になりました。教訓は、多言語 SSR サイトの「既定値」はすべて、ハイドレーション初期にサーバーの出力と食い違わないかを確認すること。<html lang> はアクセシビリティや SEO のためだけのものではなく、CSS を通じてフォントまで切り替えてしまいます。
落とし穴 2:135 KiB のフォント CSS が最優先で帯域を奪う
CJK フォントは Google Fonts から display=optional で読み込み、言語ごとに分割もしていました(/zh は TC だけ、/ja は JP だけ、英語ページは読み込まない)。問題は読み込み方です。レンダーをブロックしないように rel="preload" as="style" で読み、読み込み後にスクリプトで stylesheet に切り替えていました。
// 変更前(src/lib/seo/fonts.ts)
return `<link id="nx-fonts-cjk" rel="preload" as="style" href="${href}" /><noscript><link rel="stylesheet" href="${href}" /></noscript>`;
preload はレンダーをブロックしませんが、最高の優先度で取得されます。Google Fonts の CJK スタイルシートは数百の unicode-range サブセットを列挙するので、転送量だけで 135 KiB あります。これと、そこから続けて取得される約 350 KB のフォントファイルが、初回描画の前に最優先でダウンロードされ、描画に本当に必要なページの CSS とスマホの回線を取り合っていました。
変更点:フォントのスタイルシートはすべて初回描画の後に追加します。欧文フォントはセルフホストに切り替え(Google Fonts と同じファイルと @font-face ルール、display=swap)、英語ページはフォントのために外部へ接続しなくなりました。CJK は引き続き Google Fonts で、言語ごとの分割と display=optional を維持します。フォントがほぼ即座に用意できなければ、そのページビューではシステムの CJK フォントのまま表示し、後から差し替えないので遅い LCP も発生しません。フォントはキャッシュされ、次のページで使われます。CSS 変数ではシステムの CJK フォントを Noto より前に置いているので、もともと CJK フォントを持つ端末ではダウンロード自体が起きません。
落とし穴 3:フォントのスタイルシートをいつ追加するか、計測で決めたトレードオフ
サーバーは 2 つのスタイルシートを不活性な <template> に入れて返します。ブラウザはこれを取得しません。JavaScript が動かない環境向けには <noscript> に同じものを置きます。
// src/lib/seo/fonts.ts
const links = (media: string) => sheets.map((href) => `<link rel="stylesheet"${media} href="${href}" />`).join('');
return `${preconnect}<template id="nx-font-sheets">${links(' media="print"')}</template><noscript>${links('')}</noscript>`;
<head> の小さなインラインスクリプトが first-contentful-paint を待ってから、template の link を複製して追加します。media="print" で最低優先度でダウンロードさせ、読み込み後に all に切り替えます。CSP でインラインの onload="…" 属性は使えないので、リスナーは JS で付けています。
最初の版のレビューで退行が見つかりました。初回訪問は問題ないのですが、再訪問ではフォントがキャッシュ済みでも、毎回まずフォールバックフォント(Georgia やシステムフォント)で描画され、その後 Newsreader や Inter に切り替わっていました。全ページでちらつくわけです。
解決策は localStorage のフラグです。欧文のスタイルシートが一度読み込まれたら nx-font-sheets-seen を記録し、次回からはそのスタイルシートを blocking="render" 付きで即座に追加します。これで初回描画からキャッシュ済みのフォントが使われます。
// src/app.html(抜粋)
function start(early) {
// …clone each <link> out of the <template>…
if (early && own) {
el.media = 'all';
el.setAttribute('blocking', 'render');
} else {
el.addEventListener('load', function () { el.media = 'all'; }, { once: true });
}
// …and set nx-font-sheets-seen once the self-hosted sheet has loaded
}
if (seen) return start(true);
new PerformanceObserver(function (list, obs) {
if (list.getEntriesByName('first-contentful-paint').length) {
obs.disconnect();
setTimeout(start, 0);
}
}).observe({ type: 'paint', buffered: true });
setTimeout(start, 3000); // a background tab never paints
この部分は Lighthouse ではなく、スロットリングをかけた実際の Chromium で、初回訪問(コールド)と再訪問(ウォーム)を計測しました。
| ビルド | コールド FCP / · /zh | コールド:FCP 時点のフォント | ウォーム FCP / · /zh | ウォーム:FCP 時点のフォント |
|---|---|---|---|---|
| 変更前 | 708 / 700 ms | 0/3 | 356 / 280 ms | 3/3 |
| 最初の版(遅延のみ) | 660 / 628 ms | 0/3 | 308 / 340 ms | 0/3(ちらつき) |
| 最終版(再訪問フラグ) | 632 / 608 ms | 0/3 | 312 / 328 ms | 3/3 |
@font-face をインライン化(不採用) | 704 / 612 ms | 0/3 | 356 / 340 ms | 3/3 |
最後の行はいちばん素直な案で、@font-face ルールを全ページにインラインで埋め込むものです。再訪問のちらつきは同じく解消しますが、初回訪問では初回描画の前にフォントファイルを取りに行ってしまい、モバイル Lighthouse は / で 100 → 78(LCP 1.85 → 4.35 秒)、/zh で 99 → 87 に下がりました。そこで localStorage のフラグを採用しています。
落とし穴 4:50 本の modulepreload と、描画前に終わるハイドレーション
SvelteKit は既定で、ページが使う JS モジュールごとに modulepreload を出します。トップページでは約 50 本あり、すべて初回描画の前に高い優先度で要求され、描画を待つ CSS と回線を共有していました。ページはサーバーレンダリングされていて JS なしでも表示できるので、preload は CSS だけにしました。
// src/hooks.server.ts
const response = await resolve(event, {
preload: ({ type }) => type !== 'js'
// …
});
<body> 末尾のエントリースクリプトはこれまでどおりすぐに読み込まれ、各ルートのモジュールはハイドレーション開始時にまとめて取得されます。
もう半分はハイドレーションのタイミングです。JS がすでに手元にあると(再訪問や速い回線)、ブラウザはサーバーで描画済みのページを表示する前にハイドレーションを最後まで実行していました。SvelteKit の hooks.client.ts では init を export でき、これが resolve するまでハイドレーションは始まりません。そこで初回描画を待つようにしました。
// src/hooks.client.ts
export const init: ClientInit = () =>
new Promise<void>((resolve) => {
const go = () => setTimeout(resolve, 0);
try {
if (!PerformanceObserver.supportedEntryTypes.includes('paint')) throw new Error('no paint timing');
new PerformanceObserver((list, observer) => {
if (list.getEntriesByName('first-contentful-paint').length) {
observer.disconnect();
go();
}
}).observe({ type: 'paint', buffered: true });
} catch {
requestAnimationFrame(go);
}
setTimeout(resolve, 1500);
});
最後の setTimeout(resolve, 1500) はバックグラウンドタブ用です。バックグラウンドタブは描画しないので、ハイドレーションが永遠に始まらないのを防ぎます。
ハイドレーションを遅らせるときに心配なのは操作できるまでの時間です。同じスロットリング条件で、ページのハイドレーションが終わる時刻を計測しました。/ は 2.77 秒(変更前 2.75 秒)、/zh は 3.16 秒(変更前 4.25 秒)。英語のトップはほぼ同じで、中国語ページはリソース同士の取り合いがなくなったぶん 1 秒以上早くなりました。
副作用として、load イベントがハイドレーションより先に来ることがあるため、E2E テストで goto 直後に JS のコントロールをクリックできなくなりました。ルートのレイアウトがマウント時に <html data-hydrated> を付け、クリックするテストはそれを待ちます。
小さいけれど効いた 2 つの変更
ロゴを表示サイズの WebP に。 トップページの連携サービスのマーキーとタスクアイコンは元のロゴファイル(最大幅 3301 px、1 つ 8〜57 KB)をそのまま使い、表示は最大 44 px の高さでした。最大高さ 96 px・最大幅 300 px(表示サイズの 2〜3 倍)の WebP にしたところ、16 ファイルで約 438 KB から約 37 KB になりました。width と height は新しいファイルに合わせたので、確保される領域の形は変わりません。
記事本文をトップページのバンドルから外す。 トップページのあるセクションが $lib/content から formatDate を import していたため、Markdown パーサーの marked(36 KB)がトップページに入っていました。さらにニュースの見出しを並べるための ?raw の glob が、3 言語すべての記事本文を同梱していました。formatDate を別ファイルに移し、?frontmatter での import には先頭の --- ブロックだけを返す小さな Vite プラグインを書きました。
// vite.config.ts
function frontMatterOnly(): Plugin {
return {
name: 'nx-front-matter-only',
enforce: 'pre',
load(id) {
const [file, query] = id.split('?');
if (query !== 'frontmatter' || !file.endsWith('.md')) return;
this.addWatchFile(file);
const block = readFileSync(file, 'utf8').match(/^---\n[\s\S]*?\n---/);
return `export default ${JSON.stringify(block ? block[0] : '')};`;
}
};
}
トップページのルートの JS は非圧縮で 470 KB から 409 KB になりました。
やっていないこと、この数字の限界
blocking="render"は Chromium だけ。 Safari と Firefox でも再訪問時にスタイルシートはすぐ追加されますが、レンダーはブロックされないので、ごく短いフォントの切り替えが起きることがあります。- CJK フォントはまだ Google Fonts。 Lighthouse ではこのスタイルシートが「未使用・未圧縮の CSS」として引き続き表示されます。初回訪問では初回描画の後に読むようになりましたが、CJK サブセットのセルフホストはしていません。
- キャッシュが消えてフラグだけ残った場合、初回描画は同一オリジンへのリクエスト 1 本(約 2 KB)を待ちます。
- フォールバックフォントのメトリクス上書き(
size-adjustなど)はしていません。 /ja の CLS の原因はロケールのちらつきで、フォント差し替えによるシフトは約 0.0001 でした。 - JS モジュールの数は減っていません。 Rollup の
experimentalMinChunkSizeも試しましたが、遅延読み込みしていたパッケージが初期の依存グラフに入ってしまったため、preload を外す方法にしました。ハイドレーション時には今も約 47 個の小さなモジュールを読みますが、初回描画の後です。 - ラボの数字は実ユーザーの数字ではありません。 表はローカルの本番ビルド(HTTP/1.1、localhost)でのモバイルシミュレーションで、本番の 97 点・1.3 秒は 1 回ずつのスポット計測です。フィールドの Core Web Vitals は、実際のトラフィックをしばらく見てから判断する必要があります。
日本語・中国語のサイトを運用している方へ
- まず LCP の内訳を見る。 LCP 要素がテキストで、レンダー遅延が大半を占めるなら、ダウンロードが遅いのではなく、描画の前に何かがブロックしているか帯域を奪っています。
- ハイドレーション初期に、ページデータに依存する値をすべて確認する。 とくに
<html lang>に書き込まれるロケール。簡単な確認方法は、遅いスロットリングで英語以外のページを開き、言語切り替えや<html lang>がちらつかないかを見ることです。 rel="preload"は「控えめに読む」ではなく「最優先で読む」。 最初の画面に必要ないものは preload しない。- Google Fonts の CJK スタイルシートはそれだけで 100 KB を超える。 言語ごとに分け、
display=optionalにして、初回描画の後に追加する。 - 「明らかに良さそう」な案も必ず計測する。
@font-faceのインライン化は簡単そうに見えましたが、計測すると初回訪問の LCP が 1.85 秒から 4.35 秒になりました。
NEXUPIRA は小さな会社のための AI 同僚をつくっています。LINE で価格を聞かれたら返信と見積書の下書きを用意し、経費の整理や予定の調整も手伝います。この記事で扱ったのはそのサイトです。よければトップページものぞいてみてください。