零弐壱蜂

[Google Fonts] 日本語フォントの読み込みを高速化する実装方法

概要

サイトのパフォーマンス最適化において、ウェブフォントの読み込みは無視できない。特に日本語フォントは文字数が多いため、Google Fontsから提供されるNoto Sans JPのような日本語フォントであっても、依然として読み込みが重くなりがちである。

Noto Sans Japanese - Google FontsNoto is a global font collection for writing in alfonts.google.com

必要な文字だけを抽出してGoogle Fontsのリンクを生成する最適化手法を解説する。

背景

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

技術スタック

以下の技術を使用する。

  • TypeScript
  • React

実装

実装は大きく2つのステップに分けられる。

  1. 記事データから事前にタイトルと本文から使用する文字を抽出する
  2. 抽出した文字を使用してGoogle Fontsのリンクを生成する

1. 使用文字の抽出

まず、記事データから使用されている文字を抽出する関数を実装する。

  • タイトルと記事データから文字を収集
  • コードブロック内の文字は除外
  • 重複する文字を除去
  • 文字をカテゴリごとに分類・ソート
    • 利用頻度の高い文字を優先的に上部に配置する

以下の通り。

/**
 * 指定されたコードポイント範囲の文字を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);
})();

2. Google Fontsリンクの生成

抽出した文字列(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" />
    </>
  );
};
  1. URL長の制限を考慮して複数のリンクに分割
    • モダンブラウザだとURLの長さは足りるが、Google FontsのtextパラメータにはURL長の制限があるため、文字列を複数のチャンクに分割する必要がある
  2. preconnectを使用してドメインへの接続を事前に確立
  3. preloadでスタイルシートを先行読み込み
  4. 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"は比較的新しい属性で、ブラウザのリソース取得キューにおける優先度を明示的に高く設定する。この属性を指定することで、他のリソースよりも優先的にフォントスタイルシートを取得するようブラウザに指示している。

ChromeEdgeFirefoxSafari
v101v101v132v17.2

https://caniuse.com/mdn-html_elements_img_fetchpriority


preloadfetchPriority="high"を組み合わせることで、最重要リソースとしてフォントを扱うようブラウザに指示し、ページのレンダリングパフォーマンスを向上させることができる。

HTMLImageElement: fetchPriority プロパティ - Web API | MDNfetchPriority は HTMLImageElement インターフェイスのプロパティで、ブdeveloper.mozilla.org

3. フォントリンクの分割

フォントリンクを複数に分割することで大量の文字を扱うことができる。Google Fonts側の制限もあるが、分割することで各リクエストが並列に処理され、全体の読み込み時間が短縮されるケースもある。

参考リンク