自社サイトのCSSを別件で触っていたときに、フォントのトークンが4つあることに気づいたんですよね。--font-display、--font-body、--font-heading、--font-sans。どれがどこに効いているのか、正直なところ自分もすぐには答えられませんでした。
トークンが増えるのはよくある話なので、それ自体は驚かなかったんです。気になったのは「じゃあこのフォント、実際に何バイト配信してるんだっけ」のほうでした。デザイン上の指定と、実際にユーザーの回線を通るバイトは、別の場所で管理されている。この2つがズレていても、見た目は何となく成立してしまうので誰も気づかない。
それで、指定と配信を突き合わせて数えてみることにしました。結果、自分が最初に立てた仮説はまるごと外れました。
「配信していない」と結論を出しかけた
まず、フォントの指定がどこにあるかを見ました。デザイントークンはこうなっています。
/* src/styles/theme.css */
:root {
--font-display: 'Barlow Condensed', sans-serif;
--font-body: 'Noto Sans JP', sans-serif;
}
/* src/styles/global.css */
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
}
body { font-family: var(--font-body); }
@layer base {
html { @apply font-sans; }
}
var(--font-display) は41ファイル・221箇所で使われていて、実質これがサイトの顔です。var(--font-body) は7箇所。--font-heading は定義されているだけで、参照は0件でした。
次に、この2つのフォントがどこから配信されているかを探しました。ここで自分は雑をやりました。
$ grep -rn "@font-face" src/ public/
(記事本文の中の1件のみ)
$ npx astro build # 112ページ / 7.73s
$ grep -c "@font-face" dist/_astro/*.css
3
$ grep -ho "@font-face{[^}]*}" dist/_astro/*.css \
| sed 's/.*font-family:\([^;]*\);.*/\1/' | sort | uniq -c
3 Geist Variable
ソースにも @font-face が無い。ビルド成果物のCSSにも Geist Variable の3件しか無い。dist に置かれているフォントファイルも Geist の woff2 が3つ(58,400バイト)と、public/fonts/ に残っていた別フォントの woff が2つ(46,572バイト)だけ。
ここで自分は「Noto Sans JP も Barlow Condensed も、指定してあるだけで1バイトも配信していない。だから日本語本文は全部OSのフォールバックで描かれている」という結論を書きかけました。話としても面白いし、証拠も揃っている気がしたんです。
書きかけて、一応ブラウザで開いてから出そう、と思い直しました。これが分かれ目でした。
実際には840,472バイト落ちていた
ローカルでビルド成果物をプレビューして(npx astro preview --host 127.0.0.1 --port 4321)、Chromeのネットワークを見たら、フォントのリクエストが40件並んでいました。全部 fonts.gstatic.com 宛です。
配信元はCSSではなく、レイアウトの <head> にありました。
<!-- src/layouts/BaseLayout.astro -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700;900&family=Barlow+Condensed:wght@400;600;700;800;900&display=swap" />
<!-- src/layouts/LpLayout.astro -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@300;400;500;600;700;900&family=Noto+Sans+JP:wght@400;500;700;900&display=swap" />
自分の静的解析は間違っていなかったんです。ビルド成果物のCSSに @font-face が無いのは事実。ただ、その事実から「配信していない」は導けなかった。<link> で外部CSSを読んでいれば、@font-face はビルド成果物のどこにも現れないまま、ブラウザには届く。今思えば当たり前なんですが、grepの結果が綺麗に揃っていたぶん、疑う気になれませんでした。
落ちていた量を数えます。トップページを1回開いたときの内訳です。
| 中身 | ファイル数 | バイト | 実際に使われたか |
|---|---|---|---|
| Google Fonts のCSS | 1 | 152,089(展開後 576,963) | 使われた |
| Noto Sans JP の woff2 | 35 | 730,124 | 使われた |
| Barlow Condensed の woff2 | 5 | 110,348 | 使われた |
| 自前ホストの Geist(ビルドに同梱) | 3 | 58,400 | リクエスト0件 |
| public/fonts/ に残っていた woff | 2 | 46,572 | 参照0件・リクエスト0件 |
| (参考)ページのHTML本体 | 1 | 55,615 | - |
woff2 だけで40ファイル・840,472バイト。CSSを足すと約99万バイトです。HTML本体の18倍近くをフォントに使っていた計算になります。
Google Fonts のCSSが152KBもあるのは、日本語フォントが unicode-range で細かく割られているからです。このCSSには @font-face が635件入っていました。Noto Sans JP が620件(5ウェイト×124サブセット)、Barlow Condensed が15件(5ウェイト×3サブセット)。ブラウザはこのうち、ページに出てくる文字が属するサブセットだけを落とすので、トップページでは35ファイルで済んでいます。ページが変われば別のサブセットを落とします。
そして自前ホストの分。dist に入っているフォントは合計104,972バイトあって、そのうちリクエストされたのは0バイトでした。つまりビルドに含まれる自前フォントは、全量が死んでいたことになります。
理由はCSSの上書き関係でした。Tailwind の @layer base にある html { @apply font-sans; } は Geist を指しているんですが、レイヤー内の宣言はレイヤー外の宣言に負けます。body { font-family: var(--font-body); } はレイヤー外なので、こちらが勝つ。テキストは全部 body の中にあるので、html に付いた Geist の指定は誰にも当たらない。ブラウザは使われないフォントを落とさないので、リクエストが0件になる。
public/fonts/ のほうは単純に、テンプレート由来のファイルが参照ごと消えずに残っていただけでした。grep -rn "atkinson" src/ public/ dist/_astro/ | wc -l の結果は0件。デプロイのたびに、誰も読まないフォントを46KB配っていたわけです。
使っているのに頼んでいないウェイトが2つあった
バイト数の話が一段落したので、次は「頼んでいるウェイトは合っているのか」を見ました。wght@300;400;500;700;900 のような指定は、書いた瞬間から実装とズレていきます。
実際に描画に使われている組み合わせを、ページ上で数えました。
// テキストノードを直接持つ要素だけを対象に、実効の font-family / font-weight を集計する
const hasText = el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const tally = {};
for (const el of document.querySelectorAll('body *')) {
if (!hasText(el)) continue;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const family = cs.fontFamily.split(',')[0].replace(/["']/g, '').trim();
const key = family + ' / ' + cs.fontWeight;
tally[key] = (tally[key] ?? 0) + 1;
}
console.table(tally);
// トップページ(テキストを持つ要素 216個)
// Noto Sans JP / 700 : 45 Noto Sans JP / 600 : 10
// Noto Sans JP / 400 : 39 Noto Sans JP / 500 : 8
// Noto Sans JP / 300 : 29 Noto Sans JP / 800 : 8
// Barlow Condensed / 900 : 34 Noto Sans JP / 900 : 7
// Barlow Condensed / 700 : 29 Barlow Condensed / 800 : 3
Noto Sans JP / 600 が10要素、Noto Sans JP / 800 が8要素。どちらも <link> の wght@300;400;500;700;900 に入っていません。頼んでいないウェイトを、18の要素が使っていたことになります。
LPのほうでも同じことが起きていました。LPは別のURLでフォントを読んでいるので、要求ウェイトのセットも違います。
| レイアウト / フォント | 要求しているウェイト | 実際に描画で使われたウェイト | ズレ |
|---|---|---|---|
| BaseLayout / Noto Sans JP | 300, 400, 500, 700, 900 | 300, 400, 500, 600, 700, 800, 900 | 600 と 800 が未配信 |
| BaseLayout / Barlow Condensed | 400, 600, 700, 800, 900 | 400, 600, 700, 800, 900 | 一致 |
| LpLayout / Noto Sans JP | 400, 500, 700, 900 | 400, 500, 600, 700, 900 | 600 が未配信 |
| LpLayout / Barlow Condensed | 300, 400, 500, 600, 700, 900 | 400, 500, 600, 700, 900 | 300 が余剰 |
未配信のウェイトを指定したとき、ブラウザはエラーを出しません。手元にあるウェイトのうち一番近いものを黙って使います。仕様上、600以上を要求したときは「まず自分より重い側を軽い順に探す」ので、600は700に、800は900に丸められるはずです。
そこまでは机上の話なので、実際に丸められているかを測りました。日本語のグリフは字幅が一定なので、幅の比較だとウェイトが変わっても差が出ません。ラテン文字での幅と、canvasに描いたときのアルファ値の合計(インクの量)を併用しています。
// (1) Noto Sans JP にラテン文字を描かせて、ウェイトごとの実測幅を取る
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;left:-9999px;white-space:nowrap;font-size:48px;font-family:"Noto Sans JP",sans-serif';
document.body.appendChild(probe);
for (const w of [300, 400, 500, 600, 700, 800, 900]) {
const span = document.createElement('span');
span.style.fontWeight = String(w);
span.textContent = 'Hamburgefonstiv XECIN 2026';
probe.appendChild(span);
console.log(w, span.getBoundingClientRect().width.toFixed(2));
probe.removeChild(span);
}
// 300 627.19 / 400 649.81 / 500 666.45 / 600 688.14 / 700 688.14 / 800 709.50 / 900 709.50
// (2) 日本語を canvas に描いて、アルファ値の合計で太さを見る
const cx = Object.assign(document.createElement('canvas'), { width: 900, height: 90 }).getContext('2d');
for (const w of [300, 400, 500, 600, 700, 800, 900]) {
cx.clearRect(0, 0, 900, 90);
cx.font = w + ' 64px "Noto Sans JP"';
cx.textBaseline = 'top';
cx.fillText('現場主義で伴走する', 0, 8);
const px = cx.getImageData(0, 0, 900, 90).data;
let ink = 0;
for (let i = 3; i < px.length; i += 4) ink += px[i];
console.log(w, ink);
}
// 300 1650988 / 400 2435458 / 500 2997410 / 600 3692802 / 700 3692802 / 800 4334504 / 900 4334504
幅もインク量も、600と700が完全に同じ値、800と900が完全に同じ値になりました。丸められている、で確定です。
このプローブは改善の余地があります。インク量はアンチエイリアスの差やレンダリング設定に影響されるので、環境が変われば絶対値は変わりますし、たまたま別ウェイト同士が同じ値になる可能性も理屈の上では残ります。本当は使用中のフォント実体を取り出して比較したいところなんですが、そこまでやる前に幅とインクの2つが完全一致したので、いったんこれで判断しました。もっと確実な確かめ方があれば知りたいです。
ついでに Barlow Condensed も同じ幅の測り方をしたら、トップページ側では 300 / 400 / 500 がすべて241.50pxで並びました。要求しているのは400以上なので、300と500は400に丸められている。LPは300を要求しているのに300で描画される要素が1つも無いので、こちらは逆に無駄です。デザイン上の「セミボールド」「エクストラボールド」は、指定を書いた時点では存在していたのに、配信の段階で消えていたことになります。
いま直すもの、まだ直さないもの
数えて分かったことを、直す優先度で並べ直しました。
まず、参照0件のフォント104,972バイト。これは消すだけなので迷いようがありません。public/fonts/ の2ファイルは削除、Geist は html に当たらない指定ごと整理する。ビルド成果物が軽くなるだけでなく、「入っているけど使われていないもの」がリポジトリから減ります。
次に、ウェイトの要求セット。トップページ側は 600 と 800 を追加するか、CSS側の font-weight: 600 を700に寄せるかの二択です。日本語フォントは1ウェイトあたりが大きいので、ウェイトを増やす選択は転送量に直結します。ここは好みが分かれるところですが、自分はCSS側を実在するウェイトに寄せるほうを取りたいと思っています。デザイン上の階調は300/400/500/700/900の5段で足りているはずで、600と800は「なんとなく足された」ものに見えるからです。ただこれはデザイン側の判断が要るので、勝手には決められません。
3つ目は、レイアウトごとにフォントのURLが違う件。BaseLayout と LpLayout で要求ウェイトのセットが違うので、CSSも別々に落ちます。実測で152,089バイトと121,698バイト。LPから会社サイトへ回遊した人は、これを両方踏むことになります。URLを1本に揃えるだけで片方が消えるので、費用対効果は高いはずです。
最後に、そもそも Noto Sans JP を730KB配ることの是非。これは今回は結論を出しませんでした。判断材料が足りないからです。フォントを外したときの見た目の変化と、LCPへの効き方をセットで測ってからでないと、「軽くなりました」だけの提案になってしまう。次はそこを測ります。
今回いちばん効いたのは、grepの結果が揃っているときほど一度ブラウザで開く、という当たり前の手順でした。ビルド成果物に無いから配信していない、という推論は、外部CSSを1行読むだけで崩れる。静的解析は「どこに書いてあるか」しか答えてくれなくて、「何バイト流れるか」は別の場所で測るしかないんですよね。
フォントの棚卸しをもっと機械的に回せる方法があったら教えてください。ビルド時に「要求ウェイト」と「CSS中の font-weight」を突き合わせて落とせないか、いま考えているところです。