概要
サイトのパフォーマンス最適化において、ウェブフォントの読み込みは無視できない。特に日本語フォントは文字数が多いため、Google Fontsから提供されるNoto Sans JPのような日本語フォントであっても、依然として読み込みが重くなりがちである。
Noto Sans Japanese - Google Fontsfonts.google.com
必要な文字だけを抽出してGoogle Fontsのリンクを生成する最適化手法を解説する。
サイトのパフォーマンス最適化において、ウェブフォントの読み込みは無視できない。特に日本語フォントは文字数が多いため、Google Fontsから提供されるNoto Sans JPのような日本語フォントであっても、依然として読み込みが重くなりがちである。
Noto Sans Japanese - Google Fontsfonts.google.com
必要な文字だけを抽出してGoogle Fontsのリンクを生成する最適化手法を解説する。
そもそもCJK(中国語・日本語・韓国語)フォントは、英語フォントと比べて文字数が多い。Noto Sans JPには約7,000の漢字とひらがな・カタカナが含まれており、その全てをダウンロードすると15MBを超えるサイズになる。全てをダウンロードしていてはパフォーマンスに大きな影響を与え、特にモバイル環境では致命的なパフォーマンス低下を招く。
text
パラメータという解決策Google Fontsはtext
パラメータを提供しており、これを使うことで必要な文字のみを含むサブセットフォントを取得できる。
https://fonts.googleapis.com/css2?family=Noto+Sans+JP&text=こんにちは
このURLは「こんにちは」の文字のみを含むフォントファイルを返す。しかし、このパラメータにサイト全体で使用する文字全てを指定するのは、現実的ではない。
https://b.0218.jp/20150620044014.html
以下の技術を使用する。
実装は大きく2つのステップに分けられる。
まず、記事データから使用されている文字を抽出する関数を実装する。
以下の通り。
/**
* 指定されたコードポイント範囲の文字をSetに追加する補助関数
* @param start - 開始コードポイント
* @param end - 終了コードポイント
* @param charSet - 文字を追加するSet
*/
function addCharRange(start: number, end: number, charSet: Set<string>): void {
for (let codePoint = start; codePoint <= end; codePoint++) {
const char = String.fromCodePoint(codePoint);
charSet.add(char);
}
}
function extractUniqueChars(data: { title: string; content: string }[]): string {
const uniqueCharsSet = new Set<string>();
// 基本的なASCII文字、ひらがな、カタカナなど
// 基本セットとして一般的な文字を追加
addCharRange(0x0030, 0x0039, uniqueCharsSet);
// 英大文字 (A-Z): U+0041 - U+005A
addCharRange(0x0041, 0x005a, uniqueCharsSet);
// 英小文字 (a-z): U+0061 - U+007A
addCharRange(0x0061, 0x007a, uniqueCharsSet);
// ASCII記号: U+0020 - U+002F, U+003A - U+0040, U+005B - U+0060, U+007B - U+007E
addCharRange(0x0021, 0x002f, uniqueCharsSet); // !"#$%&'()*+,-./
addCharRange(0x003a, 0x0040, uniqueCharsSet); // :;<=>?@
addCharRange(0x005b, 0x0060, uniqueCharsSet); // [\]^_`
addCharRange(0x007b, 0x007e, uniqueCharsSet); // {|}~
// ひらがな: U+3041 - U+3096
addCharRange(0x3041, 0x3096, uniqueCharsSet);
// カタカナ: U+30A1 - U+30FA
addCharRange(0x30a1, 0x30fa, uniqueCharsSet);
// 全角英数記号(必要な場合): U+FF01 - U+FF5E
addCharRange(0xff01, 0xff5e, uniqueCharsSet);
// データから文字を抽出
for (let i = 0; i < data.length; i++) {
processStringAndAddToSet(data[i].title, uniqueCharsSet);
processStringAndAddToSet(data[i].content, uniqueCharsSet);
}
// 文字をカテゴリごとに分類・ソート
const sortedChars = Array.from(uniqueCharsSet).sort((a, b) => {
return getCharPriority(a) - getCharPriority(b);
});
return sortedChars.join('');
}
次に文字列をセットする。
以下のコードでは、コードブロック内の文字を除外するために、<code>
タグを検出して、そこで利用している文字をスキップする処理を実装する。これはあえて実施しなくても良いが、コードブロック内はmonospaceフォントを利用しており、含める必要がない(本文で利用しない)場合にスキップしても良い。
function processStringAndAddToSet(str: string, charSet: Set<string>): void {
let insideCodeTag = 0; // ネスト対応のためカウンタを使用
let match;
let lastIndex = 0;
while ((match = REGEX_CODE_TAG.exec(str)) !== null) {
// <code> までの部分を処理
if (insideCodeTag === 0) {
addCharactersToSet(str.substring(lastIndex, match.index), charSet);
}
// <code> の場合はカウント増加, </code> の場合は減少
if (match[0].startsWith('</')) {
insideCodeTag = Math.max(0, insideCodeTag - 1);
} else {
insideCodeTag += 1;
}
lastIndex = REGEX_CODE_TAG.lastIndex;
}
// 残りの文字列を処理
if (insideCodeTag === 0) {
addCharactersToSet(str.substring(lastIndex), charSet);
}
}
/** 絵文字を判定する関数 */
const isEmoji = (char: string): boolean => /\p{Emoji}/u.test(char);
/**
* 文字列から個別の文字をSetに追加(大文字小文字を区別)
* @param str - 追加する文字列
* @param charSet - 文字を格納するSet
*/
function addCharactersToSet(str: string, charSet: Set<string>): void {
for (const char of str) {
if (!/\s/.test(char) && !isEmoji(char)) {
charSet.add(char); // 大文字小文字をそのまま維持
}
}
}
抽出した文字列は後続の処理で利用するため、ESモジュールとして出力する。
(async () => {
const data = getPosts();
const uniqueStrings = extractUniqueChars(data);
const file = `export default ${JSON.stringify(uniqueStrings)};`;
await writeFile(`${PATH.to}/uniqueChars.ts`, file);
})();
抽出した文字列(uniqueChars
)を使用して、Reactコンポーネントを作成する。
import uniqueChars from '~/dist/uniqueChars';
/** Google Fontsが受け付けられる限界文字数があるため8000文字程度に制限する */
const MAX_URL_LENGTH = 8000;
const BASE_URL = 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400..900&display=swap';
export const GoogleFontLinks = () => {
const links: string[] = [];
let currentChunk = '';
for (const char of uniqueChars) {
// 現在の chunk に新しい文字を追加した場合、エンコード後の長さが制限を超えるかをチェック
if (encodeURIComponent(currentChunk + char).length > MAX_URL_LENGTH) {
links.push(`${BASE_URL}&text=${encodeURIComponent(currentChunk)}`);
currentChunk = ''; // 新しい chunk を開始
}
currentChunk += char;
}
// 最後の chunk を追加
if (currentChunk) {
links.push(`${BASE_URL}&text=${encodeURIComponent(currentChunk)}`);
}
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{links.map((url, index) => (
<link
key={`preload-${index}`}
href={url}
rel="preload"
as="style"
fetchPriority="high"
crossOrigin="anonymous"
/>
))}
{links.map((url, index) => (
<link key={`stylesheet-${index}`} href={url} rel="stylesheet" />
))}
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono&display=swap" rel="stylesheet" />
</>
);
};
text
パラメータにはURL長の制限があるため、文字列を複数のチャンクに分割する必要があるpreconnect
を使用してドメインへの接続を事前に確立preload
でスタイルシートを先行読み込みfetchPriority="high"
で読み込み優先度を高く設定これにより、必要最小限の文字だけを含むフォントファイルを効率的に読み込むことができる。
技術ブログなどでは、コードブロック内の文字はコードフォント(monospace
)で表示されるため、Noto Sans JP に含める必要がない。<code>
タグ内の文字を除外することで、不要な文字をフォントから取り除いている。
preconnect
によるネットワーク接続の最適化<link rel="preconnect" href="https://fonts.googleapis.com" />
上記のようにpreconnect
を指定することで、ブラウザにフォントファイルを提供するドメインへの接続を事前に確立するよう指示できる。これにより、実際にリソースをリクエストする際に必要なDNSルックアップ、TCP接続、TLSネゴシエーションの時間を節約できる。
Google Fontsの場合、fonts.googleapis.com
からCSSを、fonts.gstatic.com
から実際のフォントファイルを取得するため、両方のドメインに対してpreconnect
を指定している。これにより、特に初回訪問時のネットワークレイテンシを大幅に削減できる。
preload
による優先的なリソース読み込み<link key={`preload-${index}`} href={url} rel="preload" as="style" fetchPriority="high" crossOrigin="anonymous" />
rel="preload"
属性を使用することで、ブラウザがHTMLを解析する早い段階でフォントのスタイルシートを検出し優先的に読み込むよう指示できる。これは特に重要なリソースに対して使用し、ページのレンダリングをブロックすることなく早期にリソースの取得を開始させることができる。
通常のスタイルシート読み込み(rel="stylesheet"
)だけでは、ブラウザのHTMLパーサがその要素に到達するまで読み込みが開始されないが、preload
を使うことでより早いタイミングでのリソース取得を開始できる。
rel=preload - HTML: ハイパーテキストマークアップ言語 | MDNdeveloper.mozilla.org
fetchPriority="high"
による読み込み優先度の制御fetchPriority="high"
は比較的新しい属性で、ブラウザのリソース取得キューにおける優先度を明示的に高く設定する。この属性を指定することで、他のリソースよりも優先的にフォントスタイルシートを取得するようブラウザに指示している。
Chrome | Edge | Firefox | Safari |
---|---|---|---|
v101 | v101 | v132 | v17.2 |
HTML element: img: fetchpriority | Can I use... Support tables for HTML5, CSS3, etccaniuse.com
preload
とfetchPriority="high"
を組み合わせることで、最重要リソースとしてフォントを扱うようブラウザに指示し、ページのレンダリングパフォーマンスを向上させることができる。
HTMLImageElement: fetchPriority プロパティ - Web API | MDNdeveloper.mozilla.org
フォントリンクを複数に分割することで大量の文字を扱うことができる。Google Fonts側の制限もあるが、分割することで各リクエストが並列に処理され、全体の読み込み時間が短縮されるケースもある。