PageSpeed Insightsを走らせると、mobile scoreは55点、FCP(First Contentful Paint)は17.2秒でした。ユーザーは最初の画面を見るまで17秒待つ必要があります。
問題の根本はフォントでした。画像でもJavaScriptでもありません。
CJKユーザー向けのサイトを作るなら、繁体字中国語でも日本語でも韓国語でも、この問題は関係します。stackのほかの部分を最適化していても、CJKフォントだけでfirst paintを支配することがあります。
先に結論
| 項目 | 最適化前 | 最適化後 |
|---|---|---|
| fonts.css | 1,757行(161KB) | 39行(~2KB) |
| @font-face宣言 | 216個 | 3個 |
| フォントファイル数 | 213個のwoff2 | 3個のwoff2 |
| CJK body font | Noto Sans TC(web font) | system font |
| CJK heading font | Noto Serif TC(web font) | system serif |
やったことはかなり強引です。CJK web fontをすべて削り、system fontへ切り替えました。残したのはLatin見出し用のCrimson Proだけです。
後退に聞こえますか?知乎、Medium、Mattersも同じ方向です。
CJK web fontはなぜ重いのか
Latin fontのwoff2はだいたい20-50KBです。必要な文字が数十個だからです。
CJKは違います。繁体字中国語だけでも常用字が数千あります。Noto Sans CJKのような完全なPan-CJK fontは6万文字以上を含み、単一weightの元ファイルが15-40MBになることもあります。
Google Fontsの解法は、大きなファイルを数百個の小さなchunkへ分割し、CSSのunicode-rangeでbrowserにページ上に出た文字だけdownloadさせることです。この仕組みは賢いです。Googleは機械学習で、どの文字が一緒に出やすいかを分析し、それらを同じchunkに入れます。一般的なpageなら5-15個ほどのsubset downloadで済み、だいたい250KB-1MBです。
ただし問題は別の場所にあります。
構造的なbottleneck:フォントファイルではなく161KBのCSS
fonts.cssは1,757行あり、@font-face宣言だけで216個ありました。このCSS file自体が161KBです。
browserはこのCSSをparseし終えるまで、何もrenderし始めません。mobileの4G networkでは、このCSSをdownload + parseするだけで数秒食うことがあります。フォントファイルのdownloadはその後です。
3種類のフォントを合わせるとこうなります。
- Noto Sans TC:body用に107個のunicode-range subset
- Noto Serif TC:CJK見出し用に110個のsubset
- Crimson Pro:Latin見出し用に3個のsubset
前2つはどちらも@font-faceが100個を超えます。browserが実際にdownloadするのはその一部だとしても、巨大なCSS manifestだけでもfirst paintを遅くするには十分でした。
大規模サイトはCJKフォントをどう扱っているのか
変更前にDeep Researchを一通り回し、4つのAI modelに同じ質問をしました。結論は驚くほど一致しました。
trafficの大きいCJKサイトは、ほぼ本文にsystem fontを使っています。
- 知乎:PingFang SC → Microsoft YaHei → system sans-serif。web fontはゼロ
- Medium:Latin本文はCharter(custom font)、CJKはsystem fallback
- Matters.news:system font中心、一部だけcustom font
2022年以降、Chrome、Safari、Firefoxはいずれもcache partitioningを実装しました。AサイトでdownloadしたGoogle Fontsのcacheは、Bサイトから見えません。かつての「みんなでCDN cacheを共有する」利点はなくなりました。
サイトをまたいだcacheがなくなると、CJK web fontをself-hostするcostは、強いbrand上の理由がない限り、純粋なperformance負担になります。
font-display: optional vs swap
フォント読み込みの挙動には、間違えやすい選択があります。
font-display: swapは多くのtutorialで標準的に勧められます。先にsystem fontを表示し、web fontのdownloadが終わったら「差し替える」方式です。問題は、CJK web fontとsystem fontでは文字幅がかなり違うことです。差し替えの瞬間、layout全体が少し跳ねます。GoogleはこれをCLS(Cumulative Layout Shift)と呼び、Core Web Vitalsのscoreに直接響きます。
font-display: optionalは違います。fontに100msの猶予を与えます。その100ms以内にdownloadできた、またはすでにcacheされていたならweb fontを使います。間に合わなければ、そのままずっとsystem fontを使い、layoutは跳ねません。
CJK本文では、optionalとsystem font fallbackの組み合わせが最も安全です。layoutは跳ねず、ユーザーもフォント差をほとんど気にしません。
残したCrimson Pro(Latin見出し用)は、optional + preloadにします。ファイルは数十KBだけなので、多くの場合100ms以内に読み込めます。
実際にどう変えたか
4つのファイルで、変更量は多くありません。
1. font stack
/* body:CJKはsystem sans-serif */
--font-sans: 'PingFang TC', 'Noto Sans CJK TC', 'Microsoft JhengHei',
system-ui, -apple-system, sans-serif;
/* 見出し:Crimson Pro (Latin) + system serif (CJK) */
--font-serif: 'Crimson Pro', 'Songti TC', 'PMingLiU', Georgia, serif;
macOS/iOSはPingFang TC、WindowsはMicrosoft JhengHei、AndroidはNoto Sans CJK TCになります。3つとも各platformで品質の高い繁体字中国語sans-serifで、ユーザー端末に最初からあるためdownloadはゼロです。
2. fonts.cssを軽くする
1,757行から39行へ削りました。Crimson Proの3つのsubset(Latin、Latin-ext、Vietnamese)だけを残し、すべてfont-display: optionalにしました。
3. preloadを追加
<head>の最初に1行追加します。
<link rel="preload" href="/fonts/crimson-pro-latin.woff2"
as="font" type="font/woff2" crossorigin />
これにより、browserはCSS parse前にCrimson Proのdownloadを開始できます。optionalの100ms windowと組み合わせると、多くのユーザーにCrimson Proの見出しが表示されます。
4. cleanup
213個のnoto-*.woff2ファイルを削除しました。public/fonts/は200個超のファイルから3個になりました。
tradeoff
このやり方は完璧ではありません。
Windows見出しのCJK部分はPMingLiUにfallbackし、見た目はあまりよくありません。macOSのSongti TCはかなりきれいです。この差を許容できないなら、見出しもsans-serifへ寄せるか、cn-font-splitのようなtoolで数百個の常用字だけsubset化する方法があります。
brandの一貫性は少し下がります。端末ごとに見えるフォントが完全には一致しません。ただ、多くの読者は小さなフォント差には気づきません。17秒待たされることには気づきます。
2026年版 CJKフォントchecklist
- CJK本文はsystem font優先。PingFang TC / Microsoft JhengHei / Noto Sans CJK TCの品質はすでに十分です
- custom fontはbrand elementだけに使う。見出し、logo、特殊UIなどに絞り、積極的にsubset化する
- self-hostはCDNより有利になりやすい。cache partitioningによりGoogle Fontsのサイト横断cache利点はなくなりました
- CJK本文には
font-display: optional。swapは、本当にcustom fontが必要なbrand elementだけに使う - 重要fontはpreloadする。ただし本当に必要な1-2ファイルだけ
- fonts.cssの大きさを見る。数百個の
@font-face宣言は、それ自体がperformance bottleneckです。browserが実際にdownloadするsubsetが少なくても関係します - tool推薦:本当にcustom CJK fontが必要なら、
cn-font-split(Rust/WASM)でGoogle Fonts級の賢い分割ができます。Vite/Webpack統合にも対応します
関連記事
こぺんぎんの体験談
penchan.coはself-hostedなサイトで、CJKフォント処理は本当に踏みました。最初は何気なくGoogle Fontsを入れましたが、font fileが大きく、first paintが遅いことに気づきました。その後、system font + self-hosted subsetへ変えてLCPを戻しました。中国語サイトでは、すべてをcustom fontでそろえようとするより、PingFang / Noto Sans CJKを標準にするほうがはるかに実用的です。
よくある質問
Q: なぜGoogle Fonts CDNを使わないのですか?
2022年以降、主要browserはcache partitioningを実装し、サイトをまたいだcacheの利点は消えました。現在はself-host + system fontがperformance面で有利になりやすいです。
Q: system fontは端末ごとに見た目が変わりますか?
変わりますが、差は大きくありません。macOS/iOSはPingFang、WindowsはMicrosoft JhengHei、AndroidはNoto Sans CJKです。どれも高品質なCJK sans-serifで、読み心地の差はかなり小さいです。
Q: font-display: optionalとswapは何が違いますか?
swapは先にsystem fontを表示し、web fontの読み込み後に切り替えるため、layout shift(CLS)が起きます。optionalは100msだけfont読み込みを待ち、間に合わなければsystem fontを使い続けるので、layoutが跳ねません。
— Penchan