ウェブフォントとパフォーマンスの両立を諦めない

2024/11/20に公開
2

ウェブページのパフォーマンス向上を目指す場合、ウェブフォント(特に和文フォント)の使用は避けるべきと言われます。これは、ウェブフォントのファイルサイズが大きく、ページの読み込み速度に大きな影響を与えるためです。

しかし、デザイン上の要件やブランディングの観点から、ウェブフォントの使用が必要不可欠なケースもあるでしょう。本記事では、ウェブフォントを使用しながら、できる限りパフォーマンスを最適化するためのヒントをご紹介します。

検証環境

  • 使用フォント: Google Fonts の Noto Sans JP
  • 開発環境: Next.js
  • 測定ツール: Lighthouse
    • 説明の都合上、First Input Delay (FID) など、古い指標についても言及します

和文フォントが重いのはなぜか

欧文フォントと和文フォントには、収録文字数に違いがあります。欧文フォントはラテン文字と記号で約 100 文字程度であるのに対し、和文フォントはひらがな、カタカナ、漢字、記号を含め数千文字にも及びます。

このような収録文字数の違いに加え、文字の構造自体にも大きな差異があります。欧文は比較的単純な線や曲線で構成されているのに対し、漢字は複雑な字形構造を持っています。そのため一文字あたりのサイズも和文フォントの方が大きくなりがちです。

これらの要因により、欧文フォントが数十 KB 程度で済むのに対し、和文フォントは数 MB、場合によっては数十 MB にも及ぶファイルサイズとなります。

Lighthouse のモバイル環境テストでは、Slow 4G 回線を想定したエミュレーションが行われます。この条件下では、数 MB の和文フォントファイルのダウンロードに数十秒を要することがあります。

unicode-range によるウェブフォントの分割配信

Google フォントは、効率的なフォント配信のためにフォントファイルをサブセットに分割して提供しています。CSS の unicode-range プロパティを使用して、ページで実際に使用している文字のみを選択的にダウンロードすることが可能です。

https://developer.mozilla.org/ja/docs/Web/CSS/@font-face/unicode-range

download_fonts
分割したフォントファイルがダウンロードされている

unicode-range は CSS の @font-face ルールに指定できるプロパティで、フォントファイルの適用範囲を Unicode 符号位置で指定します。

例えば、unicode-range: U+30-39 と設定すると、アラビア数字(0-9)に対応するフォントのみがダウンロードされます。

Noto Sans JP の CSS ファイルは、以下のように unicode-range で特定の文字範囲を指定しています。

/* https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap */

/* [0] */
@font-face {
  font-family: "Noto Sans JP";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosansjp/v53/-F62fjtqLzI2JPCgQBnw7HFowwII2lcnk-AFfrgQrvWXpdFg3KXxAMsKMbdN.0.woff2)
    format("woff2");
  unicode-range: U+25ee8, U+25f23, U+25f5c, U+25fd4, U+25fe0, U+25ffb, U+2600c,
    U+26017, U+26060, U+260ed, U+26222, U+2626a, U+26270, U+26286, U+2634c,
    U+26402, U+2667e, U+266b0, U+2671d, U+268dd, U+268ea, U+26951, U+2696f,
    U+26999, U+269dd, U+26a1e, U+26a58, U+26a8c, U+26ab7, U+26aff, U+26c29,
    U+26c73, U+26c9e, U+26cdd, U+26e40, U+26e65, U+26f94, U+26ff6-26ff8,
    U+270f4, U+2710d, U+27139, U+273da-273db, U+273fe, U+27410, U+27449,
    U+27614-27615, U+27631, U+27684, U+27693, U+2770e, U+27723, U+27752,
    U+278b2, U+27985, U+279b4, U+27a84, U+27bb3, U+27bbe, U+27bc7, U+27c3c,
    U+27cb8, U+27d73, U+27da0, U+27e10, U+27eaf, U+27fb7, U+2808a, U+280bb,
    U+28277, U+28282, U+282f3, U+283cd, U+2840c, U+28455, U+284dc, U+2856b,
    U+285c8-285c9, U+286d7, U+286fa, U+28946, U+28949, U+2896b, U+28987-28988,
    U+289ba-289bb, U+28a1e, U+28a29, U+28a43, U+28a71, U+28a99, U+28acd,
    U+28add, U+28ae4, U+28bc1, U+28bef, U+28cdd, U+28d10, U+28d71, U+28dfb,
    U+28e0f, U+28e17, U+28e1f, U+28e36, U+28e89, U+28eeb, U+28ef6, U+28f32,
    U+28ff8, U+292a0, U+292b1, U+29490, U+295cf, U+2967f, U+296f0, U+29719,
    U+29750, U+29810, U+298c6, U+29a72, U+29d4b, U+29ddb, U+29e15, U+29e3d,
    U+29e49, U+29e8a, U+29ec4, U+29edb, U+29ee9, U+29fce, U+29fd7, U+2a01a,
    U+2a02f, U+2a082, U+2a0f9, U+2a190, U+2a2b2, U+2a38c, U+2a437, U+2a5f1,
    U+2a602, U+2a61a, U+2a6b2, U+2a9e6, U+2b746, U+2b751, U+2b753, U+2b75a,
    U+2b75c, U+2b765, U+2b776-2b777, U+2b77c, U+2b782, U+2b789, U+2b78b,
    U+2b78e, U+2b794, U+2b7ac, U+2b7af, U+2b7bd, U+2b7c9, U+2b7cf, U+2b7d2,
    U+2b7d8, U+2b7f0, U+2b80d, U+2b817, U+2b81a, U+2d544, U+2e278, U+2e569,
    U+2e6ea, U+2f804, U+2f80f, U+2f815, U+2f818, U+2f81a, U+2f822, U+2f828,
    U+2f82c, U+2f833, U+2f83f, U+2f846, U+2f852, U+2f862, U+2f86d, U+2f873,
    U+2f877, U+2f884, U+2f899-2f89a, U+2f8a6, U+2f8ac, U+2f8b2, U+2f8b6,
    U+2f8d3, U+2f8db-2f8dc, U+2f8e1, U+2f8e5, U+2f8ea, U+2f8ed, U+2f8fc,
    U+2f903, U+2f90b, U+2f90f, U+2f91a, U+2f920-2f921, U+2f945, U+2f947,
    U+2f96c, U+2f995, U+2f9d0, U+2f9de-2f9df, U+2f9f4;
}

/* [1] */
@font-face {
  font-family: "Noto Sans JP";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosansjp/v53/-F62fjtqLzI2JPCgQBnw7HFowwII2lcnk-AFfrgQrvWXpdFg3KXxAMsKMbdN.1.woff2)
    format("woff2");
  unicode-range: U+1f235-1f23b, U+1f240-1f248, U+1f250-1f251, U+2000b,
    U+20089-2008a, U+200a2, U+200a4, U+200b0, U+200f5, U+20158, U+201a2,
    U+20213, U+2032b, U+20371, U+20381, U+203f9, U+2044a, U+20509, U+2053f,
    U+205b1, U+205d6, U+20611, U+20628, U+206ec, U+2074f, U+207c8, U+20807,
    U+2083a, U+208b9, U+2090e, U+2097c, U+20984, U+2099d, U+20a64, U+20ad3,
    U+20b1d, U+20b9f, U+20bb7, U+20d45, U+20d58, U+20de1, U+20e64, U+20e6d,
    U+20e95, U+20f5f, U+21201, U+2123d, U+21255, U+21274, U+2127b, U+212d7,
    U+212e4, U+212fd, U+2131b, U+21336, U+21344, U+213c4, U+2146d-2146e,
    U+215d7, U+21647, U+216b4, U+21706, U+21742, U+218bd, U+219c3, U+21a1a,
    U+21c56, U+21d2d, U+21d45, U+21d62, U+21d78, U+21d92, U+21d9c, U+21da1,
    U+21db7, U+21de0, U+21e33-21e34, U+21f1e, U+21f76, U+21ffa, U+2217b,
    U+22218, U+2231e, U+223ad, U+22609, U+226f3, U+2285b, U+228ab, U+2298f,
    U+22ab8, U+22b46, U+22b4f-22b50, U+22ba6, U+22c1d, U+22c24, U+22de1,
    U+22e42, U+22feb, U+231b6, U+231c3-231c4, U+231f5, U+23372, U+233cc,
    U+233d0, U+233d2-233d3, U+233d5, U+233da, U+233df, U+233e4, U+233fe,
    U+2344a-2344b, U+23451, U+23465, U+234e4, U+2355a, U+23594, U+235c4,
    U+23638-2363a, U+23647, U+2370c, U+2371c, U+2373f, U+23763-23764, U+237e7,
    U+237f1, U+237ff, U+23824, U+2383d, U+23a98, U+23c7f, U+23cbe, U+23cfe,
    U+23d00, U+23d0e, U+23d40, U+23dd3, U+23df9-23dfa, U+23f7e, U+2404b,
    U+24096, U+24103, U+241c6, U+241fe, U+242ee, U+243bc, U+243d0, U+24629,
    U+246a5, U+247f1, U+24896, U+248e9, U+24a4d, U+24b56, U+24b6f, U+24c16,
    U+24d14, U+24e04, U+24e0e, U+24e37, U+24e6a, U+24e8b, U+24ff2, U+2504a,
    U+25055, U+25122, U+251a9, U+251cd, U+251e5, U+2521e, U+2524c, U+2542e,
    U+2548e, U+254d9, U+2550e, U+255a7, U+2567f, U+25771, U+257a9, U+257b4,
    U+25874, U+259c4, U+259cc, U+259d4, U+25ad7, U+25ae3-25ae4, U+25af1,
    U+25bb2, U+25c4b, U+25c64, U+25da1, U+25e2e, U+25e56, U+25e62, U+25e65,
    U+25ec2, U+25ed8;
}

/* [2] */
@font-face {
  font-family: "Noto Sans JP";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosansjp/v53/-F62fjtqLzI2JPCgQBnw7HFowwII2lcnk-AFfrgQrvWXpdFg3KXxAMsKMbdN.2.woff2)
    format("woff2");
  unicode-range: U+ffd7, U+ffda-ffdc, U+ffe0-ffe2, U+ffe4, U+ffe6, U+ffe8-ffee,
    U+1f100-1f10c, U+1f110-1f16c, U+1f170-1f1ac, U+1f200-1f202, U+1f210-1f234;
}

/* ... */

このような仕組みにより、Google フォントは必要な文字セットのみを含むフォントファイルを効率的に配信し、ページの読み込み時間とリソース使用量を最適化しています。

しかし、それでもなお、常用漢字でフォントファイルのサイズは合計数 MB に及びます。

フォント分割がもたらす CSS サイズへの影響

unicode-range を使用することで、フォントファイルの分割配信は可能ですが、トレードオフとして CSS ファイルのサイズが増加します。

例えば、Noto Sans JP の CSS ファイルは、圧縮前で約 120kB、gzip 圧縮後でも約 30kB になります。

30kB は一見して大きなサイズではありませんが、ページの初期ロード時にはすべてのリソースが帯域を奪い合うため、実際の影響は単純なファイルサイズ以上になることがあります。

CSS ファイルのダウンロード優先順位は Highest priority ではありますが、Lighthouse のモバイル環境における Slow 4G 回線エミュレーションでは、ダウンロードに数秒かかることもあります。

ブラウザは CSS の読み込みが完了するまでレンダリングを待機するため、CSS ファイルが大きいと First Contentful Paint (FCP) の遅延につながります。

FCP は CSS のロードが完了するまで遅延する
FCP は CSS のロードが完了するまで遅延する

ウェブフォントの優先ダウンロードによる他リソースへの影響

ウェブフォントは Highest priority でダウンロードされます。そのため、フォントファイルが大きい場合、限られた帯域幅の中で他のリソースのダウンロードが後回しになる可能性があります。

この影響は特に Lighthouse のモバイル環境で顕著になり、JavaScript ファイルのダウンロードの遅延を引き起こすことがあります。その結果、First Input Delay (FID) の悪化につながる可能性があります。

ウェブフォントの最適化

ウェブフォントの使用とパフォーマンスの両立を図るためのアプローチをご紹介します。

Brotli 圧縮による CSS ファイルの削減

Brotli は gzip よりも高い圧縮率を実現できる圧縮アルゴリズムです。一般的なファイルでは gzip との差は数パーセント程度ですが、Noto Sans JP の CSS ファイルでは顕著な効果が見られます。具体的には、圧縮前の約 120kB から、gzip での約 30kB に対し、Brotli では約 18kB まで圧縮することができます。

この高い圧縮率が実現できる理由は、@font-face 宣言を含む CSS ファイルの特徴にあります。このファイルには同様の記述パターンが繰り返し出現するため、辞書ベースの圧縮を採用する Brotli のアルゴリズムと相性が良く、効率的な圧縮が可能です。結果、CSS ファイルサイズが削減され、First Contentful Paint (FCP) の改善が期待できます。

ただし、Google Fonts の CDN は gzip 圧縮のみに対応しているため、Brotli 圧縮を利用するには、フォントの CSS ファイルを自前で配信する必要があります。

https://ja.wikipedia.org/wiki/Brotli

&text パラメータを利用した CSS ファイルの削減

Google Fonts の URL に &text パラメータを追加することで、指定した文字のみを含む CSS ファイルを生成できます。これにより CSS ファイルのサイズを大幅に削減できます。

<!-- 「Hello」の文字だけを含む CSS ファイルを取得 -->
<link
  href="https://fonts.googleapis.com/css?family=Inconsolata&text=Hello"
  rel="stylesheet"
/>
@font-face {
  font-family: "Inconsolata";
  font-style: normal;
  font-weight: 400;
  font-stretch: 100%;
  src: url(https://fonts.gstatic.com/l/font?kit=QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp4U8aR_8lleZ0cqrCTS7iYo&skey=20fa6569a31c71ee&v=v32)
    format("woff2");
}

https://developers.google.com/fonts/docs/getting_started#optimizing_your_font_requests

この機能を活用するために、次のような設計が考えられます:

  • ビルド時の静的解析
    • ビルド時に HTML ファイルを解析して使用される文字を抽出
    • 抽出した文字セットから &text パラメータを生成
    • Google Fonts のリンクタグを動的に生成
  • サーバーサイドでの動的生成
    • リクエスト時にページコンテンツを解析
    • 必要な文字セットを判断して Google Fonts の URL を生成
  • CDN エッジでの最適化
    • CDN のエッジコンピューティング機能を利用
    • リクエスト時にコンテンツを解析し、リンクタグを書き換え

この最適化は、コンテンツが静的に決まっている場合や、限られた文字だけにウェブフォントを使用する場合に有効です。ただし、JavaScript で動的にテキストを変更する場合や、ユーザー入力を表示する場合には、動的に追加される文字に対してフォントが適用されない可能性があるため注意が必要です。

CSS ファイルを非同期で読み込む

ブラウザは CSS の読み込みが完了するまでレンダリングを待機します。CSS ファイルを非同期でロードすることで、この挙動を回避し First Contentful Paint (FCP) の時間を短縮できます。

fcp
CSS のロードが完了前に FCP が発生する

以下の方法では、media 属性に print を指定することで CSS ファイルを非同期で読み込み、onload イベント時に media 属性を all に変更することで CSS を適用します。

<link
  rel="stylesheet"
  href="font.css"
  media="print"
  onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="font.css" /></noscript>

CSS の非同期読み込みには複数の手法があるため、プロジェクトの要件や環境に応じて最適な方法を選択してください。

font-display: swap を指定している場合、FCP は短縮されますが、フォールバックフォントが表示される時間が長くなる可能性があることに注意が必要です。

Google Fonts とキャッシュパーティショニング

Google Fonts は CDN を通じてフォントファイルを提供しており、多くのウェブサイトで同じフォントファイルが利用されています。以前は異なるサイト間でもブラウザキャッシュを共有できていましたが、現在はキャッシュパーティショニングの導入により、サイト間でのキャッシュ共有が制限されています。

ただし、同一ドメイン内に複数のアプリケーションが存在する場合は、それらの間でキャッシュを共有することが可能です。そのため、サイトのアーキテクチャによっては、現在でもキャッシュ共有のメリットを享受できる可能性があります。

https://developer.chrome.com/blog/http-cache-partitioning?hl=ja

content-visibility を利用したフォントの遅延ロード

unicode-range を使用することで、ページで必要な文字のみをダウンロードできますが、これらは即時ロードされます。画像の遅延ロードのように、テキストがビューポートに近づくまでフォントのロードを遅延できれば、初期ロード量を削減できます。

この課題に対しては、content-visibility が有効です。これはブラウザに要素のレンダリングを遅延する指示を与える CSS プロパティで、content-visibility: auto を指定することで、要素が必要になるまでレイアウトとレンダリングの処理を省略できます。

content-visibility の特徴:

  • 必要になった時点でレンダリングを再開
  • ページ内検索やタブ順序ナビゲーションなどのユーザーエージェント機能は通常通り利用可能
  • フォーカスや選択も通常通り機能

このプロパティは主にレンダリングの計算量削減のために利用されますが、レンダリングがスキップされた要素のフォントダウンロードも遅延されます。これにより、要素がビューポートに近づくまでフォントのダウンロードを遅延させ、初期ロード量を削減することができます。

https://developer.mozilla.org/ja/docs/Web/CSS/content-visibility

以下は、常用漢字のフォントを content-visibility プロパティを使用して遅延ロードする様子です。スクロール操作に合わせて、フォントファイルが読み込まれていく様子を確認できます。

https://youtu.be/TqTFD7tr-ys

CJK フォントと Next.js

Next.js はフォントの最適化機能を提供していますが、デフォルト設定は CJK(Chinese, Japanese, Korean)フォントには必ずしも適していません。これは、CJK 言語が他の言語と比べて文字数が圧倒的に多く、フォントファイルのサイズが大きくなる特徴があるためです。

特に注意が必要なのは、デフォルトで有効になっている preload 機能です。和文フォントのような大きなフォントファイルを preload すると、初期ロード時のネットワーク帯域の大部分がフォントのダウンロードに使用され、他のリソースの読み込みに影響を与える可能性があります。そのため、CJK フォントを使用する場合は preload 機能を無効にすることをおすすめします。

https://nextjs.org/docs/pages/building-your-application/optimizing/fonts

まとめ

この記事がウェブフォントとパフォーマンスを両立するための一助になれば幸いです。

IVRy では一緒に働いてくれるエンジニアを全方位で絶賛募集中です。もちろんフロントエンドエンジニアも募集しています。ご連絡お待ちしています!

https://ivry.jp/
https://herp.careers/v1/ivry/FYz0GaZHff7k
https://herp.careers/v1/ivry/l3tuyPDVEL81
https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

IVRyテックブログ

Discussion