Google Fontsの日本語読み込みを最適化する終わりなき戦い〜Astro(SSG)編〜
はじめに
日本語圏のウェブサイト制作においてNoto Sans JPは広く使われるようになりましたが、それに伴って思考停止でNoto Sans JPを使うことへの是非もよく議論になっている印象です。
Figmaと同様の状態を再現したいというデザイナー視点の意見も理解できつつ、エンジニアとしてはNoto Sans JPの水平ラインが微妙にズレるといったクセに悩まされる事も多いため全面的に肯定できないのも事実です。
とは言えエンジニアの意思だけで使う/使わないの判断が出来ることでも無く、向き合っていく上で改善する余地があればやっていきたいですよね。
重い
Noto Sans JPに限った話では無いのですが、ウェブフォントで日本語を扱う上で大きな課題になるのが転送量です。
Google Fontsでは日本語フォントが複数のファイルに分割されているため、例えばNoto Sans JPを使おうとするだけで以下のように無数のリクエストが走ることになります。
光回線や5G通信が日常になった今では大して気になることも無くなりましたが、ウェブサイトを表示する上で可能な限りリクエスト数は減らした方が良いですし、無駄に読み込んでいるリソースがあるのも気持ち悪いので何とかしたい所です。
text=パラメータによるリクエスト最適化
Google Fontsには、読み込み時にtext=というパラメータを付与することであらかじめ使用するフォントを伝え、必要なフォントデータだけをレスポンスとして返して貰うことで転送量を最適化する機能があります。
事前にページ内に含まれる文字が分かっている場合、これを利用することでダウンロードされるフォントを大幅に減らすことが出来ます。
しかし、ウェブサイトのトップページやよくあるAboutページのように中身が決まっている静的なページならまだしも、クライアントがCMSからコンテンツを入力するページは事前に内容を把握出来ないため、少し工夫が必要です。
Integrationを使った実験
Astro Integration APIのHooksは、ビルドの開始・終了といった任意のタイミングで何らかの処理を実行できる仕組みです。
Astroは基本的にSSGであり、CMSから取得したデータを元に全てのhtmlファイルを静的に生成するため、ビルドが完了した段階で「そのhtmlファイルに必要な文字」は把握することが出来るはずです。
そこで、以下のようなカスタムインテグレーションを用意してみました。
こちらのインテグレーションを有効化し、実際にCloudflare Pages上でビルド・デプロイされたサンプルがこちらです。
(サンプルテキストとして青空文庫のデータを使用させて頂いています。)
文字量を削ったバージョン
以下のように、text=パラメータに渡す文字列はそれぞれ違った物になっており、そのページで必要な文字だけがリクエストされている状態になっています。
処理の流れとしては以下のようになっており、Astroが生成したファイルをわざわざ加工してしまう泥臭いパワープレイです。
- ビルドが完了した段階で全てのhtmlファイルを読み込む
- body内のテキストを取得する
- Google Fontsタグにtext=パラメータとして追加する
- ファイルを上書き保存する
1. ビルドが完了した段階で全てのhtmlファイルを読み込む
astro:build:doneフックに渡されるroutesから生成した全てのファイルのパスが取得出来るので、Node.js上でファイルを読み込んで処理を始めます。
Astroはjsonも生成出来てしまうので、この際htmlファイルにのみ操作対象を絞っています。
2. body内のテキストを取得する
extractBodyTextAndEncode
関数で読み込んだhtmlファイルをJSDOMで解析し、bodyタグの中から余計なタグやスペース等を除外した上で重複した文字を削除し、最終的なテキストを抽出しています。textパラメータに渡す際にはURLエンコードする必要があるので、オリジナルの文字列とURLエンコードした文字列を両方返す関数になっています。(オリジナルの文字列も返す理由については後述します。)
3. Google Fontsタグにtext=パラメータとして追加する
html内から
const linkTag = document.querySelector(
'link[href*="https://fonts.googleapis.com/css2?family="]'
);
でGoogle Fontsの埋め込みコードを検索し、(2)で抽出した文字列をtext=パラメータとして追加する処理を行います。
この際、渡す文字列が多ければ多いほどtext=パラメータを使わずに普通にリクエストした方が早くなってしまう可能性もあるため、全ての状況において一概にこの方法が最適というわけではありません。
また、text=パラメータに渡せる文字数には上限があるという言及もあり、数百文字程度が限度という説もあるのですが試してみた所1,000文字くらいでは特に問題なく最適化されたレスポンスが返ってきました。
しかし、今回サンプルテキストとして使用さえて頂いた"夏目漱石 - 私の個人主義"の文章を丸々リクエストに追加した所、text=パラメータが無い時と同じレスポンスになってしまったので、明確に調べることは出来ていませんが確かに上限はありそうです。
ということで、エンコード前の文字数が1,000文字(根拠の無い数字ですが)を越えている場合はtext=パラメータの付与を行わない、という形にしています( extractBodyTextAndEncode
関数からオリジナルの文字列も返しているのはこのチェックのためです)。
結果
Google Fontsへは1回しかリクエストが行われておらず、Lighthouseの計測でもパフォーマンスは99が出せています。
レンダリングを妨げるリソースの除外の警告は出ていますが、Google Fontsからの転送サイズは2.5KiBとわずかな物です。
使わなかった場合
良い状態の結果だけ提示しても意味が無いので、逆に作成したインテグレーションを使わず普通にデプロイした場合はどうでしょうか。
以下がインテグレーションを無効化した状態のページです。
パフォーマンスはここまでスコアが下がり、Google Fontsからの転送サイズも30.6KiBまで肥大化し、ネットワークを確認しても大量のリクエストが行われていることが分かります。
他のアプローチ
Astro Integrationsで検索してみると、以下のような物もあり沢山の方が使われているようです。
Google Fontsは、例えば元のコードが以下のような形だった場合、
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
実際にこのURLにアクセスしてみると分かりますがそのファイルで@font-face定義が行われ、更に具体的なフォントファイルへのリクエストが始まるという処理の流れになっています。
Astro Google Fonts Optimizerは、「発行されたコードに含まれているURLにアクセスする」という最初のリクエストをビルド時に行ってしまい、@font-face定義をインラインに展開しておいてくれるという挙動になっています。
しかし、実際にtext=パラメータで行うようなリクエスト文字列の絞り込みといったことをするわけでは無いので、最終的に読み込まれるフォントファイルが削減出来るわけでは無いことと、全てのページのビルド時に https://fonts.googleapis.com/css2?
へのアクセスが行われてしまうため、生成されるページ数が多くなるとビルド時間が長くなってしまう弊害がありました。
ページ数が多くないサイトや、日本語フォントを使わないサイトであれば既に公開されているこちらのインテグレーションが有効なケースももちろんあるかと思います。
まとめ
今回、SSGで静的なhtmlをビルドする際、動的にhtmlの内容からテキストを抽出しtext=パラメータに付与する、という事を試してみました。
もっとスマートな方法もありそうですし落とし穴もあるかと思うのですが、ある程度効果は見込めそうな結果になりました。
掲載しているコードは自己責任において自由に使用・改変して頂いて構いません。
何らかミスや把握できていないリスク等ありましたらご指摘頂けると幸いです。
Discussion