Nuxtブログの検索ページ軽量化 - FlexSearch削除とselect()による最適化
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後に検索を実行し、タイピング中の無駄な再計算を避ける
- イミュータブル:
filterとmapで新しい配列を生成し、元データを変更しない
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())の導入を検討する - タグ検索の追加: 現在はキーワード検索のみだが、タグによるフィルタリング機能を追加する余地がある