🗜️

Web フォントをサブセット化してバンドル容量を削減する Vite プラグインを作った

2023/11/04に公開

概要

日本語Webフォントのバンドルサイズは、時に数MiBにもなります。イベントなどでユーザの回線が細いことが想定される場合、大容量のフォントを読み込むことは避けたいものです。

一方、ランディングページ(LP)のように、事前にレンダリングすべき文字の集合がビルド時にわかるケースも少なくありません。このような場合、Webフォントがそれらの文字のみを含むようにサブセット化すれば、読み込みははるかに高速化できます。

本稿では、このような場合を考慮し、バンドル結果のhtml/css/jsに含まれる文字の集合に基づいてフォントをサブセット化するViteプラグインを作ったことについて書きます。今回作成したプラグインはkymok/vite-plugin-font-subsetterです。

なお、Google Fontsは本稿の方法とは逆に、おそらく文字の出現頻度と種類に基づいてフォントを細かく切り分けるアプローチをとっています。良い切り分けを設計できる場合はGoogle Fonts方式のほうが優れた結果を得られそうです。

サブセット化の必要性

日本語Webフォントは、多いときには数万個のグリフを含みます。たとえばNoto Sans JPの場合、グリフ数は17,808個です(2023年11月現在)。Noto Sans Regularをwoff2形式に圧縮すると、容量は約2.2MiBです。

いっぽうで、典型的なLPの文字数は多くありません。文章として「現代日本の開化」(夏目漱石)を例にとると、これを表示するのに必要な文字数は1,129文字です。これはNoto Sansのグリフ数と比較すると6-7%程度です。

後述のように、Google Fontsは公式からダウンロードできるCSSを使うと、細かく切り分けられたフォントを必要な分だけロードできるようになっています。そのため、あらためて本稿で書いたようなサブセット化を行う必要はありません。

サブセット化が有効なのは、Google Fontsに載っていないフォントを使いたい場合です。近年では、SILオープンフォントライセンスで高品質なフォントが公開されており、角に丸みを付けたり字幅を変えたりした派生フォントが多く入手できます。しかし、それらはほとんどの場合Google Fontsからは配信されていません。そうしたフォントを使用したい場合、サブセット化をして容量を減らすと、ユーザにとって親切です。

サブセット化の方針

フォントのサブセット化の方針は以下のようなものが考えられます。本稿では、なるべくコストパフォーマンスの良い方法をとりたいので、最後の方法を使いました。

フォントを文字コードで細かく切り分け、分割してロードする

Google Fonts が採用している方法です。Google Fontsでフォントを読み込むためのCSSを観察してみると、unicode-rangeをかなり細かく切り分けていることがわかります。たとえばNoto Sans JPは2023年11月現在、120個の範囲に切り分けられ、それぞれにunicode-rangeが指定されています。

この方式のメリットは、unicode-rangeで欲しい範囲を列挙しておけば、その範囲の文字は必ず表示できるという点です。デメリットはunicode-rangeの範囲を設計するのが難しいことでしょうか。おそらくGoogleのほうで文字の出現頻度の統計を持っており、それに基づいてフォントの切り分けを設計しているのではないかと考えられます。

Google FontsでNoto Sansを使うためのCSSの例
/* [0] */
@font-face {
  font-family: 'Noto Sans JP';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj757Y0rw_qMHVdbR2L8Y9QTJ1LwkRmR5GprQAe69m.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: 400;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj757Y0rw_qMHVdbR2L8Y9QTJ1LwkRmR5GprQAe69m.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;
}

フォントが表示すべき文字を厳密に計算する

この方針では、各フォントにつき、そのフォントが使われる要素で表示される文字の集合を計算します。たとえば、見出しにしか使われないフォントは見出しに使われる文字の集合のみを含むようにします。この方法は削減率を高くできる一方、スクリプトで動的にスタイルを付け外しするような場合を考えると実装は簡単ではありません。

フォントがソースコード全体を表示できるようにする

この方針では、Webアプリが表示すべき文字はすべてバンドルされたリソースに含まれるという仮定をおき、それらの文字を含むようにフォントをサブセット化します。この場合、容量は厳密に計算する場合と比べて増加します(たとえば、見出し用のフォントが本文の内容やソースコードで使われる記号などを含むようになります)。しかし、実装が簡単であり、また上の仮定が満たされるかぎりフォントの表示は破綻しません。

この方式のデメリットは、正しく実装しないと一部の合字の表示に失敗することです。たとえば、執筆時点ではst合字 (st) の表示に失敗しています。私のユースケースでは日本語だけ表示できればよいので大きな問題にはなっていませんが、言語によっては大きな問題になりえます。

実装

以下のことができれば、ここまで議論した機能が実装できそうです。

  • バンドルされたリソース(html/css/js)に含まれる文字の集合を知る
  • 得られた文字の集合を使って、使用しているフォントをサブセット化する

サブセット化をライブラリに頼ることでコード自体は20行程度で済みます。今回は Vite が内部的に使っているRollupのプラグインとして実装しました。

Rollupプラグインで使用するフック

Rollupプラグインは、フックによりバンドルの生成過程に介入できます。今回は、バンドルの内容が決定した後にそれを書き換えたいため、generateBundleというフックを使います。

今回実装したフックの中身は以下のようになっています。フォントのサブセット化はsubset-fontを使いました。

async function generateBundle(
  options: OutputOptions,
  bundle: { [fileName: string]: AssetInfo | ChunkInfo }
) {
  // 必要なファイルのリストを得る
  const fontFiles = Object.keys(bundle).filter((fileName) => {
    return fileName.match(/\.woff2$/)
  })
  const sourceFiles = Object.keys(bundle).filter((fileName) => {
    return fileName.match(/\.(js|css|htm|html)$/)
  })

  // 文字の集合を計算する
  const glyphSet = sourceFiles.map(
    (fileName) => extractCharacterSet(bundle[fileName])
  ).reduce(
    (acc, set) => new Set([...acc, ...set]), new Set<string>()
  );
  const glyphSetString = Array.from(glyphSet).join('')

  // フォントをサブセット化する
  for (const fileName of fontFiles) {
    const font = bundle[fileName]
    if (IsAssetInfo(font) && typeof (font.source) !== 'string') {
      const subset = await subsetFont(
        Buffer.from(font.source),
        glyphSetString,
        { targetFormat: 'woff2' }
      )
      // 引数として渡されたバンドルの中身を改変する
      font.source = new Uint8Array(subset)
    }
  }
}

コードの全体はkymok/vite-plugin-font-subsetterをご覧ください。

実際に使ってみた

2-3MiBのフォントを3種類使うランディングページでこのプラグインを使用したところ、フォントの容量を各50KiB前後まで減らせました。

スクリーンショット

結論

今回作ったプラグインは、実装が簡単なわりに容量削減の効果は大きく、減った容量でフォントやウエイトの種類を増やしてみるなどが可能になります。特に、Google Fontsにないフォントでも容量を気にせず使えるようになるのは便利です。

とはいえ、世の中のサイトの読み込みを観察していると、Google Fontsを使っているサイトとダウンロード量の差はあまりありません。それどころか、さらにフォントの種類を増やしていった場合、Google Fonts方式なら種類ごとのダウンロード量が減っていくことが期待されるので、最終的にはGoogle Fonts方式のほうが転送量が少なくなりそうです。そのうちGoogle Fonts方式の切り分けも観察してみたいですね。

Discussion