検索ページ(/search)パフォーマンス改善計画

開発blog-platform完了

検索ページ(/search)パフォーマンス改善計画

現状の問題

検索ページ(/search)の読み込みが遅い。原因は search.vue の以下の処理にある。

const { data: allArticles } = await useAsyncData('search-articles', () =>
  queryCollection('pages').all()
)

queryCollection('pages').all()body ASTを含む全471記事の全フィールドを一括取得している。

何が起きているか

  1. サーバー側: SQLiteから471記事の全データ(本文ASTを含む)をクエリしてJSON化
  2. クライアント転送: その全データが _payload.json としてブラウザに送られる(約20MB)
  3. クライアント側: 受け取った全データでFlexSearchインデックスを構築(watchimmediate: true

FlexSearchのインデックス構築では、ASTからテキストを再帰的に抽出する extractTextFromAST も走る。471記事分のAST→テキスト変換がページ表示をブロックしている。

ビルドへの影響

ビルド時間にも影響していた。/search をプリレンダリング対象にしていたとき _payload.json が20MBに膨らみ、Server Buildに264.8秒かかっていた。プリレンダリングからの除外で138.1秒に短縮済み(48%削減)。

ただしランタイムの読み込み速度は改善されていない。

改善方針

全文検索をサーバーAPI側に移す。 クライアントには検索結果(タイトル・パス・スニペット)だけを返す。

現在のアーキテクチャ

ブラウザ → 全記事取得(20MB) → クライアントでFlexSearch構築 → 検索実行

改善後のアーキテクチャ

ブラウザ → 検索APIにクエリ送信 → サーバーでSQLite検索 → 結果のみ返却(数KB)

実装計画

ステップ1: サーバーAPIエンドポイントの作成

server/api/search.get.ts を作成する。

  • クエリパラメータ q(検索キーワード)、sort(並び順)、page(ページ番号)を受け取る
  • @nuxt/content のサーバー側APIでSQLiteを直接クエリ
  • title, description, path, publishedAt, tags のみ返却(bodyは返さない)
  • 全文検索はSQLiteの LIKE または FTS(Full-Text Search)を使う
// server/api/search.get.ts のイメージ
export default defineEventHandler(async (event) => {
  const { q, sort = 'desc', page = 1, limit = 20 } = getQuery(event)

  // @nuxt/content のサーバーAPI で検索
  // body を除外して軽量なレスポンスを返す
})

ステップ2: search.vue のリファクタリング

  • queryCollection('pages').all() を削除
  • FlexSearchの依存を削除
  • 検索入力時にサーバーAPIを呼び出す方式に変更
  • デバウンス(300ms)は維持
// search.vue のイメージ
const { data: results } = await useFetch('/api/search', {
  query: { q: debouncedQuery, sort: sortOrder, page: currentPage }
})

ステップ3: 本文スニペットの生成

  • サーバー側で検索キーワード周辺のテキストを抽出してスニペットを返す
  • クライアントではスニペットを表示するだけにする
  • ハイライト処理はサーバー側で済ませるか、クライアントで軽量に実施

ステップ4: 一覧表示(検索キーワードなし)

検索キーワードが空のときは、記事一覧を返す。

  • select('title', 'description', 'path', 'publishedAt', 'tags') でbodyを除外
  • ページネーションはサーバー側で .limit() .offset() を使う
  • 転送量は20MB → 数十KB程度になる

期待される効果

指標現状改善後(見込み)
初回payload約20MB数十KB
ページ表示速度数秒〜十数秒1秒以内
クライアント側メモリFlexSearchインデックス + 全記事AST検索結果のみ
FlexSearch依存あり削除可能

注意点

  • Cloudflare Pages (SSG) の場合、サーバーAPIは使えない。現在のプリセットは cloudflare-pages-static
  • サーバーAPIを使うには、cloudflare-pages プリセット(SSR対応)への変更か、Cloudflare Workers経由でのD1利用が必要
  • SSGのままで select() によるbody除外 + クライアント側の軽量検索に留める方法もある

SSGのまま改善する場合

全文検索は諦めて、タイトル・説明文の部分一致検索に限定する。

const { data: allArticles } = await useAsyncData('search-articles', () =>
  queryCollection('pages')
    .select('title', 'description', 'path', 'publishedAt', 'tags')
    .all()
)

この変更だけでbodyのAST転送がなくなり、payloadが大幅に減る。全文検索が不要ならFlexSearchも削除できる。

判断ポイント

方式メリットデメリット
SSGのまま .select() で軽量化変更が小さい、プリセット変更不要全文検索不可
SSR化してサーバーAPI全文検索可能、最も高速プリセット変更が必要、D1設定

まず .select() による軽量化をSSGのまま行い、全文検索が必要になった時点でSSR化を検討する段階的アプローチが現実的。