背景
CJKフォントの読み込みが重い理由
そもそもCJK(中国語・日本語・韓国語)フォントは、英語フォントと比べて文字数が多い。Noto Sans JPには約7,000の漢字とひらがな・カタカナが含まれており、その全てをダウンロードすると15MBを超えるサイズになる。全てをダウンロードしていてはパフォーマンスに大きな影響を与え、特にモバイル環境では致命的なパフォーマンス低下を招く。
Google Fontsのtext
パラメータという解決策
Google Fontsはtext
パラメータを提供しており、これを使うことで必要な文字のみを含むサブセットフォントを取得できる。
https://fonts.googleapis.com/css2?family=Noto+Sans+JP&text=こんにちは
このURLは「こんにちは」の文字のみを含むフォントファイルを返す。しかし、このパラメータにサイト全体で使用する文字全てを指定するのは、現実的ではない。
https://b.0218.jp/20150620044014.html
技術スタック
以下の技術を使用する。
実装
実装は大きく2つのステップに分けられる。
- 記事データから事前にタイトルと本文から使用する文字を抽出する
- 抽出した文字を使用してGoogle Fontsのリンクを生成する
1. 使用文字の抽出
まず、記事データから使用されている文字を抽出する関数を実装する。
- タイトルと記事データから文字を収集
- コードブロック内の文字は除外
- 重複する文字を除去
- 文字をカテゴリごとに分類・ソート
以下の通り。
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>();
addCharRange(0x0030, 0x0039, uniqueCharsSet);
addCharRange(0x0041, 0x005a, uniqueCharsSet);
addCharRange(0x0061, 0x007a, uniqueCharsSet);
addCharRange(0x0021, 0x002f, uniqueCharsSet);
addCharRange(0x003a, 0x0040, uniqueCharsSet);
addCharRange(0x005b, 0x0060, uniqueCharsSet);
addCharRange(0x007b, 0x007e, uniqueCharsSet);
addCharRange(0x3041, 0x3096, uniqueCharsSet);
addCharRange(0x30a1, 0x30fa, uniqueCharsSet);
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) {
if (insideCodeTag === 0) {
addCharactersToSet(str.substring(lastIndex, match.index), charSet);
}
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);
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);
})();
2. Google Fontsリンクの生成
抽出した文字列(uniqueChars
)を使用して、Reactコンポーネントを作成する。
import uniqueChars from '~/dist/uniqueChars';
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) {
if (encodeURIComponent(currentChunk + char).length > MAX_URL_LENGTH) {
links.push(`${BASE_URL}&text=${encodeURIComponent(currentChunk)}`);
currentChunk = '';
}
currentChunk += char;
}
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" />
</>
);
};
- URL長の制限を考慮して複数のリンクに分割
- モダンブラウザだとURLの長さは足りるが、Google Fontsの
text
パラメータにはURL長の制限があるため、文字列を複数のチャンクに分割する必要がある
preconnect
を使用してドメインへの接続を事前に確立preload
でスタイルシートを先行読み込みfetchPriority="high"
で読み込み優先度を高く設定
これにより、必要最小限の文字だけを含むフォントファイルを効率的に読み込むことができる。
パフォーマンス最適化のポイント
1. コード内文字の除外
技術ブログなどでは、コードブロック内の文字はコードフォント(monospace
)で表示されるため、Noto Sans JP に含める必要がない。<code>
タグ内の文字を除外することで、不要な文字をフォントから取り除いている。
2. リソースヒントの活用
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: ハイパーテキストマークアップ言語 | MDNpreload は <link> 要素の rel 属性の値で、その HTML の <head> の中developer.mozilla.org
fetchPriority="high"
による読み込み優先度の制御
fetchPriority="high"
は比較的新しい属性で、ブラウザのリソース取得キューにおける優先度を明示的に高く設定する。この属性を指定することで、他のリソースよりも優先的にフォントスタイルシートを取得するようブラウザに指示している。
Chrome | Edge | Firefox | Safari |
---|
v101 | v101 | v132 | v17.2 |
https://caniuse.com/mdn-html_elements_img_fetchpriority
preload
とfetchPriority="high"
を組み合わせることで、最重要リソースとしてフォントを扱うようブラウザに指示し、ページのレンダリングパフォーマンスを向上させることができる。
HTMLImageElement: fetchPriority プロパティ - Web API | MDNfetchPriority は HTMLImageElement インターフェイスのプロパティで、ブdeveloper.mozilla.org
3. フォントリンクの分割
フォントリンクを複数に分割することで大量の文字を扱うことができる。Google Fonts側の制限もあるが、分割することで各リクエストが並列に処理され、全体の読み込み時間が短縮されるケースもある。
参考リンク