以前のブログ記事「静的サイトに特化した検索ライブラリ Pagefind を試す」では、Pagefind を使用した本ブログ記事の検索機能の実装方法を紹介しました。

この Pagefind を使用するにあたり、Pagefind の UI 関連の JS ファイルを読み込んでから初期化(インスタンス作成)をする必要があります。

Pagefind UI の読み込みとインスタンス作成
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
  new PagefindUI({ element: "#search" });
});
</script>

ここで指定した JS はページを開いた直後に実行されるため、機能を使用せずにページを離れた場合でも、本来不要な JS ファイルのダウンロードと解析が実行されてしまいます。

ホームページのファーストビュー(Above the fold)からブログ記事検索までの位置を表す図
ホームのページ後半に位置するブログ記事検索

この状態で Lighthouse のツリーマップを確認すると、Pagefind UI の JS のサイズが圧倒的に大きいことがわかります。

サイズが大きいのは Pagefind UI が重いというよりは、相対的にほかの JS が軽いためです。

Lighthouse ツリーマップのスクリーンショット
Lighthouse ツリーマップで JS のサイズと比率を確認

ローカルサーバ上での測定ですが、JS 全体のファイルサイズ 69.3 KiB 1に対して pagefind-ui.js が 65.2 KiB と、実に 94% もの割合を占めています。

これは、検索機能を使わないユーザに不要なファイルをダウンロードさせていることになるので、この問題を解消するために、動的インポート(Dynamic Import)を使用します。

JavaScript には静的なインポート(Static Import)と、動的なインポート(Dynamic Import)があります。以下のエクスポートされた JS ファイル(greetings.js)をベースに、この 2 つのインポートの例を比較します。

greetings.js
export function hello(user) {
  return `Hello, ${user}!`;
}

まず、静的インポート(Static Import)は import キーワードで読み込みます。

静的インポート(Static Import)の例
import { hello } from './greetings.js';

console.log(hello('world')); // Hello, world!

この静的インポートでは、すぐさまファイルがインポートされ、スクリプトが実行可能な状態になります。つまり、ファイルを読み込むタイミングは選べません。

対して、以下の動的インポートでは、import() 構文を使用して非同期に指定できるため、読み込むタイミングを任意に指定できます。

Promise の then() メソッドを使用する方法と、async/await を使用する方法がありますが、この記事では後者の async/await で進めます。

動的インポート(Dynamic Import)の例
// then()
import('./greetings.js').then(({ hello }) => {
  console.log(hello('world')); // Hello, world!
});

// async/await
(async () => {
  const { hello } = await import('./greetings.js');
  console.log(hello('world')); // Hello, world!
})();

ここで Pagefind UI に話を戻して、インポートするタイミングを考えます。

  • A. 検索フォームがビューポートに出現したとき(IntersectionObserver
  • B. 検索フォームにホバーしたとき(mouseenter
  • C. テキストフィールドにフォーカスしたとき(focus

「A」は、ファイルをインポートしてから、検索機能を使用するまでの時間の余裕があるので安定性はありますが、依然として不要なファイルを読み込ませてしまう問題が残ります。

「B」は「A」よりも具体的で、検索機能を使用するまでの時間の余裕も若干ありますが、マウス以外のデバイスの処理を別途検討しなければなりません。

「C」は、さらに具体的で多くの場面で改善を見込めるので、この「C. テキストフィールドにフォーカスしたとき(focus)」で実装していきます。

ただ、検索機能を使用するまでの余裕がほとんどないので、読み込むファイルサイズが大きかったり、通信速度が十分ではない場合には、応答性の問題が発生する可能性があります。

以下は、先ほどの async/await に反映したコードです。なお、エラーハンドリングをするための try...catch 文も追加しています。

テキストフィールドにフォーカスしたときに動的インポート(Dynamic Import)する例
(() => {
  const search = document.getElementById('search');
  if (!search) return;

  const input = search.querySelector('input');
  if (!input) return;

  input.addEventListener('focus', async () => {
    try {
      // 動的インポート(Dynamic Import)
      await import('/blog/pagefind/pagefind-ui.js');

      // Pagefind を初期化する関数
      initPagefind(search, input);
    } catch (e) {
      console.error(e);
    }
  }, { once: true });
})();

これで、テキストフィールドにフォーカスするまでは、Pagefind UI の JS ファイルが読み込まれることは無くなりました。

改めて Lighthouse のツリーマップを確認すると、JS 全体のファイルサイズは 69.3 KiB から 4.3 KiB まで大幅に下がりました。

Lighthouse ツリーマップのスクリーンショット

もちろん、テキストフィールドにフォーカスすれば、結果的には同程度のサイズのリソースが読み込まれるのですが、訪問時の読み込み時間の短縮と、検索機能を使用しないユーザに対して、不要なファイルの読み込みを回避することができました。

プレースホルダーの用意と差し替え

見出し「プレースホルダーの用意と差し替え」

2023/12/14 追記

前述のようにテキストフィールドにフォーカスしたときに動的インポートする場合、事前に対象となる <input> 要素が必要になります。

そのため、以下のようなコードで、プレースホルダーとなる空のテキストフィールドを用意しておき、Pagefind を初期化するタイミングで差し替えます。

Pagefind 初期化のタイミングでプレースホルダーを差し替える例
<div id="search">
  <!-- レイアウトシフトを防止するためのプレースホルダー -->
  <div class="pagefind-ui">
    <div class="pagefind-ui__form">
      <!-- フォーカスの対象となるテキストフィールド -->
      <input type="text" placeholder="ブログ記事を検索" class="pagefind-ui__search-input">
    </div>
  </div>
</div>

<script>
const initPagefind = (search, input) => {
  // プレースホルダーを空にする
  search.textContent = '';

  // Pagefind 初期化
  new PagefindUI({
    element: '#search',
  });
};

(() => {
  const search = document.getElementById('search');
  if (!search) return;

  const input = search.querySelector('input');
  if (!input) return;

  input.addEventListener('focus', async () => {
    try {
      await import('/blog/pagefind/pagefind-ui.js');
      initPagefind(search, input);
    } catch (e) {
      console.error(e);
    }
  }, { once: true });
})();
</script>

Pagefind では UI の JS ファイルを読み込んで初期化した後に、関連する HTML 要素が生成されるので、このようにプレースホルダーを用意しておくことで領域が確保され、CLS(Cumulative Layout Shift)の悪化を防ぐこともできます。

Google が提唱する重要なパフォーマンス指標である Core Web Vitals の 1 つである FID(First Input Delay)は、2024 年 3 月に INP(Interaction to Next Paint)に置き換わる予定です。

この INP は、ウェブページ内のインタラクティブな要素の応答性を評価しますが、ユーザが操作をしてから、視覚的なフィードバックが得られるまで(インタラクションを開始してから次のフレームが描画されるまで)の時間が対象となります。

また、FID の場合には最初のインタラクションのみが対象となりますが、INP ではページを訪問したときのすべてのインタラクションが対象となります。

INP(Interaction to Next Paint)は、インタラクションを開始してから次のフレームが描画されるまでの時間が対象となり、200 ms 以下が良い、200 ms を超えて 500 ms 以下が改善が必要、500 ms を超えると悪いと評価される
INP が優良と評価されるためには、インタラクションを開始してから次のフレームが描画されるまでの時間を 200 ms 以下にする必要がある

特にサイズの大きな JS があると、パースやコンパイル時にメインスレッドを占有してしまい、ユーザの操作に対して遅延が発生したり、反応しないといった状況が発生してしまいます。

改善策としては、ページ読み込み時に優先度の低い JS ファイルを分割して遅延読み込みさせる Code Splitting(コード分割)の手法が有効ですが、先ほど取り上げた、動的インポート(Dynamic Import)がまさに代表例です。

Lighthouse の診断結果で「JavaScript の実行にかかる時間の低減」が指摘されたり、デベロッパーツールの Coverage で未使用の JS の割合が大きい場合には、試してみる価値があります。

Lighthouse の診断結果のスクリーンショット
Lighthouse の診断結果で「JavaScript の実行にかかる時間の低減」が指摘されるケース
デベロッパーツールの Coverage のスクリーンショット
デベロッパーツールの Coverage で未使用の JS の割合が大きいケース

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. 国際規格においては、kB(キロバイト)は 1000 バイト、KiB(キビバイト)は 1024 バイトと定められていますが、大文字の KB(キロバイト)が 1024 バイトを表すケースもあり、これらの表記は混在しています(キロバイト | Wikipedia)。本記事においては、ツールで使用されている表記に統一しています。