Nuxtブログの検索ページ軽量化 - FlexSearch削除とselect()による最適化

開発mdx-playgroundメモ

Nuxtブログの検索ページ軽量化 - FlexSearch削除とselect()による最適化

この記事の概要

Nuxt Content製ブログの検索ページ(/search)が遅かった。原因は全記事のbody AST(約20MB)を一括取得していたこと。FlexSearchライブラリを削除し、.select() による必要フィールドのみの取得と filter + includes ベースの部分一致検索に切り替えた。結果として、payloadが20MBから数十KBに減り、ページ表示速度が改善された。

改善前の問題

何が遅かったのか

検索ページでは queryCollection('pages').all() で全記事を取得していた。

// 改善前: body ASTを含む全フィールドを取得
const { data: allArticles } = await useAsyncData('search-articles', () =>
  queryCollection('pages').all()
)

この呼び出しが返すデータには、各記事のMarkdown本文をパースしたAST(Abstract Syntax Tree)が含まれる。471記事分のASTを合わせると約20MBになり、それが _payload.json としてブラウザに転送されていた。

FlexSearchの問題

取得した全記事データを使って、クライアント側でFlexSearchのインデックスを構築していた。

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

FlexSearchのインデックス構築では、ASTからテキストを再帰的に抽出する処理が走る。471記事分のAST→テキスト変換がページ表示をブロックし、検索ページを開くまで数秒から十数秒かかることがあった。

ビルドへの影響

ビルド時間にも影響していた。/search をプリレンダリング対象にしていたとき、_payload.json が20MBに膨らみ、Server Buildに264.8秒かかっていた。プリレンダリングからの除外で138.1秒に短縮(48%削減)できたが、ランタイムの読み込み速度は改善されていなかった。

改善方針の検討

前日に作成した検索ページパフォーマンス改善計画で、2つの方針を比較した。

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

結論として、SSGのまま .select() による軽量化を採用した。理由は以下の通り。

  • 現在のプリセットは cloudflare-pages-static(SSG)で、サーバーAPIは使えない
  • SSR化にはCloudflare Pagesのプリセット変更やD1の設定が必要で、変更が大きい
  • このブログで全文検索がどれだけ必要かは不明。タイトルと説明文の検索で十分な可能性が高い
  • 全文検索が必要になった時点でSSR化を検討すればよい

実装の詳細

1. .select() による必要フィールドのみの取得

最も効果の大きい変更。queryCollection.select() を追加して、必要な5フィールドだけを取得するようにした。

// 改善後: body ASTを除外して軽量に取得(約20MB → 数十KB)
const { data: allArticles, error, pending: loading } = await useAsyncData('search-articles', () =>
  queryCollection('pages')
    .select('title', 'description', 'path', 'publishedAt', 'tags')
    .all()
)

.select() はNuxt ContentのSQLiteクエリで SELECT 句を制御するメソッド。指定したカラムだけを取得するので、body ASTがレスポンスから完全に除外される。

効果:

  • payload: 約20MB → 数十KB(99%以上削減)
  • SQLiteクエリ自体も高速化(bodyカラムの読み取りが不要になるため)

2. FlexSearch依存の完全削除

FlexSearchはクライアント側の全文検索ライブラリで、body ASTからテキストを抽出してインデックスを構築していた。.select() でbodyを取得しなくなったため、FlexSearchを使う意味がなくなった。

# package.json から flexsearch を削除
# pnpm install で整合性を更新
pnpm install

package.json から flexsearch の依存を削除し、pnpm install でロックファイルの整合性を更新した。これにより、バンドルサイズも削減された。

3. タイトル・説明文の部分一致検索

FlexSearchの代わりに、シンプルな filter + includes ベースの部分一致検索を実装した。

// フィルタリングとソート済みの記事一覧
const filteredArticles = computed(() => {
  if (!allArticles.value) return []

  let results = [...allArticles.value]
  const query = debouncedQuery.value.trim()

  // タイトル・説明文の部分一致検索
  if (query) {
    const lowerQuery = query.toLowerCase()
    results = results
      .filter(article => {
        const title = (article.title || '').toLowerCase()
        const description = (article.description || '').toLowerCase()
        return title.includes(lowerQuery) || description.includes(lowerQuery)
      })
      .map(article => {
        const description = article.description || ''
        // 説明文にヒットしている場合、ハイライト付きスニペットを生成
        if (description.toLowerCase().includes(query.toLowerCase())) {
          return {
            ...article,
            snippet: highlightKeyword(description, query)
          }
        }
        return { ...article, snippet: null }
      })
  }

  // 日付ソート
  results.sort((a, b) => {
    const dateA = a.publishedAt || a.date || ''
    const dateB = b.publishedAt || b.date || ''

    if (sortOrder.value === 'desc') {
      return dateB.localeCompare(dateA)
    } else {
      return dateA.localeCompare(dateB)
    }
  })

  return results
})

ポイントは以下の通り。

  • 大文字小文字を区別しない: toLowerCase() で正規化してから比較
  • タイトルと説明文の両方を検索対象: どちらかにヒットすれば結果に含める
  • ハイライト付きスニペット: 説明文にヒットした場合、<mark> タグでキーワードをハイライト表示
  • デバウンス維持: 入力から300ms後に検索を実行し、タイピング中の無駄な再計算を避ける
  • イミュータブル: filtermap で新しい配列を生成し、元データを変更しない

4. キーワードハイライト

検索キーワードが説明文に含まれる場合、該当箇所を <mark> タグで囲んでハイライト表示する。

// キーワードをハイライト
function highlightKeyword(text, query) {
  if (!text || !query) return text

  const regex = new RegExp(`(${escapeRegex(query)})`, 'gi')
  return text.replace(regex, '<mark class="highlight">$1</mark>')
}

// 正規表現のエスケープ
function escapeRegex(str) {
  return str
    .replace(/\\/g, '\\\\')
    .replace(/\./g, '\\.')
    .replace(/\*/g, '\\*')
    .replace(/\+/g, '\\+')
    .replace(/\?/g, '\\?')
    .replace(/\^/g, '\\^')
    .replace(/\$/g, '\\$')
    .replace(/\{/g, '\\{')
    .replace(/\}/g, '\\}')
    .replace(/\(/g, '\\(')
    .replace(/\)/g, '\\)')
    .replace(/\|/g, '\\|')
    .replace(/\[/g, '\\[')
    .replace(/\]/g, '\\]')
}

escapeRegex はユーザー入力に正規表現の特殊文字(., *, + など)が含まれていてもエラーにならないようにエスケープする。

5. 検索プレースホルダーの更新

検索方式の変更に合わせて、プレースホルダーテキストも更新した。

<!-- 改善前 -->
<input placeholder="タイトル、本文でフリーワード検索..." />

<!-- 改善後 -->
<input placeholder="タイトル、説明文で検索..." />

全文検索ではなくなったので、「フリーワード検索」という表現をやめ、実際の検索対象を明示するようにした。

6. 計画記事のステータス更新

前日に作成した計画記事 (search-page-performance-plan.md) の todo フィールドを "active" から "done" に更新し、実装が完了したことを記録した。

# 変更前
todo: "active"

# 変更後
todo: "done"

改善結果のまとめ

指標改善前改善後
payload サイズ約20MB(body AST含む)数十KB(5フィールドのみ)
FlexSearch依存あり(バンドルに含まれる)削除済み
検索方式クライアント側FlexSearch全文検索filter + includes 部分一致
検索対象タイトル + 本文ASTタイトル + 説明文
ページ表示速度数秒〜十数秒ほぼ即時
クライアント側メモリFlexSearchインデックス + 全記事AST検索結果の軽量データのみ

改善後の search.vue 全体構成

最終的な search.vue の構成は以下のようになっている。

search.vue
├── template
│   ├── 検索フォーム(キーワード入力 + ソート選択 + クリアボタン)
│   ├── 検索結果リスト(タイトル、日付、タグ、スニペット/説明文)
│   └── ページネーション(20件ずつ表示)
└── script setup
    ├── データ取得: queryCollection().select().all()
    ├── 検索ロジック: filter + includes(デバウンス300ms)
    ├── ハイライト: highlightKeyword()
    ├── ソート: publishedAt による昇順/降順
    ├── ページネーション: computed で算出
    └── SEO: useSeoMeta + useHead

外部ライブラリへの依存がなく、Nuxt Contentの組み込み機能と標準のJavaScript配列メソッドだけで検索機能が実現されている。

今後の検討事項

  • 全文検索が必要になった場合: SSR化してサーバーAPIエンドポイントを作成し、SQLiteのFTS(Full-Text Search)を使う方式に移行する
  • 記事数の増加への対応: 現在471記事で数十KBのpayloadだが、数千記事になった場合はサーバー側ページネーション(.limit() + .offset())の導入を検討する
  • タグ検索の追加: 現在はキーワード検索のみだが、タグによるフィルタリング機能を追加する余地がある