デバウンス処理と本文検索の実装ガイド
デバウンス処理と本文検索の実装ガイド
このドキュメントでは、Nuxt Contentサイトに実装した高度な検索機能について、初心者向けに詳しく解説します。
実装の概要
今回の実装では、以下の機能を追加しました:
- 全文検索機能 - タイトル、説明文、本文から検索
- デバウンス処理 - 入力中の無駄な検索を防ぐ
- MDC ASTからのテキスト抽出 - Nuxt Contentの特殊な形式に対応
- 検索結果のハイライト - 見つかったキーワードを強調表示
- スニペット生成 - 本文でヒットした場合に前後の文脈を表示
- ページネーション - 大量の検索結果を分割表示
追加されたファイルとパッケージ
1. 新規ファイル
apps/web/app/pages/search.vue
検索機能の全てを含むページコンポーネントです。
2. 追加パッケージ
{
"flexsearch": "^0.8.212"
}
FlexSearchとは?
- 高速で軽量な全文検索ライブラリ
- クライアントサイド(ブラウザ上)で動作
- 日本語を含む多言語に対応
- サジェスト(おすすめ検索)機能付き
主要機能の詳細解説
1. デバウンス処理の仕組み
デバウンスとは、連続して発生するイベントを制限する技術です。
問題点(デバウンスなし)
ユーザーが「検索」と入力すると:
け → 検索実行
けん → 検索実行
けんさ → 検索実行
けんさく → 検索実行
4回も検索が実行されてしまいます!
解決策(デバウンスあり)
300ミリ秒(0.3秒)待ってから検索します:
け → 待機中...
けん → 待機中...
けんさ → 待機中...
けんさく → 待機中... → 300ms経過 → 検索実行!
実装コード
// 検索キーワードの状態管理
const searchQuery = ref('') // ユーザーが入力中の文字列
const debouncedQuery = ref('') // 実際に検索に使う文字列
const isSearching = ref(false) // 「検索中...」表示の制御
let debounceTimeout = null
watch(searchQuery, (newQuery) => {
isSearching.value = true // 「検索中...」を表示
// 前のタイマーがあればキャンセル
if (debounceTimeout) {
clearTimeout(debounceTimeout)
}
// 300ms後に検索を実行
debounceTimeout = setTimeout(() => {
debouncedQuery.value = newQuery // 検索実行
isSearching.value = false // 「検索中...」を非表示
}, 300)
})
ポイント:
searchQuery:入力フォームにバインド(即座に更新)debouncedQuery:実際の検索に使用(300ms遅延)setTimeoutで遅延実行、clearTimeoutで前の予約をキャンセル
2. FlexSearchインデックスの構築
FlexSearchは事前に「インデックス」を作る必要があります。インデックスとは検索用の目次のようなものです。
インデックスの初期化
const searchIndex = ref(null)
// FlexSearchのDocumentインデックスを作成
searchIndex.value = new Document({
document: {
id: 'path', // 記事を識別するキー
index: ['title', 'description', 'body'] // 検索対象のフィールド
},
tokenize: 'full', // 完全なトークン化(柔軟な検索)
cache: true // 検索結果をキャッシュして高速化
})
tokenizeオプション:
'forward':前方一致のみ(例:「検索」で「検索エンジン」は見つかるが「全文検索」は見つからない)'full':部分一致も可能(例:「検索」で「全文検索」も見つかる)
記事データの登録
articles.forEach(article => {
// MDC ASTから本文テキストを抽出
const bodyText = extractTextFromAST(article.body.value)
// FlexSearchインデックスに追加
searchIndex.value.add({
path: article.path,
title: article.title || '',
description: article.description || '',
body: bodyText
})
})
3. MDC ASTからのテキスト抽出
Nuxt Contentの記事本文はAST(抽象構文木)という特殊な形式で保存されています。
ASTとは?
Markdown/MDXを構造化したデータ形式です:
元のMarkdown:
# 見出し
これは本文です。**太字**もあります。
ASTの形式:
[
["h1", {}, "見出し"],
["p", {},
"これは本文です。",
["strong", {}, "太字"],
"もあります。"
]
]
テキスト抽出関数
function extractTextFromAST(nodes) {
if (!nodes || !Array.isArray(nodes)) return ''
return nodes.map(node => {
// 1. 文字列ノード(直接的なテキスト)
if (typeof node === 'string') {
return node
}
// 2. 配列ノード(MDC形式: ["タグ名", {属性}, ...子要素])
if (Array.isArray(node)) {
const children = node.slice(2) // 3番目以降が子要素
return extractTextFromAST(children) // 再帰的に処理
}
// 3. オブジェクトノード(従来のAST形式)
if (node.type === 'text') {
return node.value || ''
}
if (node.children) {
return extractTextFromAST(node.children) // 再帰的に処理
}
return ''
}).join(' ') // スペースで連結
}
再帰処理の流れ:
["p", {}, "これは", ["strong", {}, "太字"], "です"]
↓
"これは" + extractTextFromAST([["strong", {}, "太字"]]) + "です"
↓
"これは" + "太字" + "です"
↓
"これは 太字 です"
4. 検索の実行とフォールバック
FlexSearchによる検索
const searchResults = searchIndex.value.search(query, {
limit: 1000, // 最大1000件まで検索
suggest: true // サジェスト機能を有効化
})
// 検索結果のパスを集約
const matchedPaths = new Set()
searchResults.forEach(fieldResult => {
if (fieldResult.result) {
fieldResult.result.forEach(path => matchedPaths.add(path))
}
})
フォールバック検索
FlexSearchで見つからない場合、シンプルな部分一致検索にフォールバックします:
if (matchedPaths.size === 0) {
const lowerQuery = query.toLowerCase()
results.forEach(article => {
const title = (article.title || '').toLowerCase()
const description = (article.description || '').toLowerCase()
const bodyText = articleBodies.value.get(article.path) || ''
const lowerBody = bodyText.toLowerCase()
// いずれかにキーワードが含まれていれば追加
if (title.includes(lowerQuery) ||
description.includes(lowerQuery) ||
lowerBody.includes(lowerQuery)) {
matchedPaths.add(article.path)
}
})
}
5. スニペット生成とハイライト
スニペット生成
検索キーワード周辺の文章を切り出します:
function generateSnippet(text, query, maxLength = 200) {
if (!text || !query) return ''
// キーワードの位置を探す
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const index = lowerText.indexOf(lowerQuery)
if (index === -1) return ''
// キーワードを中心に前後100文字ずつ取得
const start = Math.max(0, index - Math.floor(maxLength / 2))
const end = Math.min(text.length, index + query.length + Math.floor(maxLength / 2))
let snippet = text.slice(start, end)
// 省略記号を追加
if (start > 0) snippet = '...' + snippet
if (end < text.length) snippet = snippet + '...'
return snippet
}
キーワードのハイライト
function highlightKeyword(text, query) {
if (!text || !query) return text
// 正規表現で検索(大文字小文字を区別しない)
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi')
// <mark>タグで囲む
return text.replace(regex, '<mark class="highlight">$1</mark>')
}
結果の表示:
<!-- v-htmlで安全にHTMLとして表示 -->
<p v-if="article.snippet" class="result-snippet" v-html="article.snippet"></p>
CSSスタイル:
.result-snippet :deep(mark.highlight) {
background: #fef3c7; /* 黄色の背景 */
color: #92400e; /* 茶色の文字 */
font-weight: 600; /* 太字 */
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
}
6. ページネーション
大量の検索結果を20件ずつ分割表示します:
const currentPage = ref(1)
const itemsPerPage = 20
// 総ページ数の計算
const totalPages = computed(() => {
return Math.ceil(filteredArticles.value.length / itemsPerPage)
})
// 現在のページの記事のみ取得
const paginatedResults = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage // 開始位置
const end = start + itemsPerPage // 終了位置
return filteredArticles.value.slice(start, end)
})
// 検索クエリが変わったらページをリセット
watch(debouncedQuery, () => {
currentPage.value = 1
})
計算例(全45件の場合):
- ページ1:
slice(0, 20)→ 1〜20件目 - ページ2:
slice(20, 40)→ 21〜40件目 - ページ3:
slice(40, 60)→ 41〜45件目
UIの変更
index.vueへのリンク追加
トップページに検索ページへのリンクを追加しました:
<p class="search-link-wrapper">
<NuxtLink to="/search" class="search-link">
🔍 コンテンツを検索する
</NuxtLink>
</p>
スタイル:
.search-link {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #3b82f6; /* 青色の背景 */
color: white;
text-decoration: none;
border-radius: 0.375rem;
font-weight: 600;
transition: background 0.2s; /* ホバー時のアニメーション */
}
.search-link:hover {
background: #2563eb; /* ホバー時は少し濃い青 */
}
パフォーマンスの最適化
1. 記事本文のキャッシュ
const articleBodies = ref(new Map())
// インデックス構築時にキャッシュ
articles.forEach(article => {
const bodyText = extractTextFromAST(article.body.value)
articleBodies.value.set(article.path, bodyText)
// ...
})
理由: ASTからのテキスト抽出は計算コストが高いため、一度だけ実行して結果を保存します。
2. FlexSearchのキャッシュ機能
searchIndex.value = new Document({
// ...
cache: true // 検索結果をキャッシュ
})
効果: 同じキーワードを再検索する際に高速化されます。
3. デバウンスによる検索回数の削減
前述の通り、300ms待つことで無駄な検索実行を防ぎます。
使い方
基本的な検索
/searchページにアクセス- 「キーワード検索」欄に検索したい言葉を入力
- 300ms後に自動的に検索が実行されます
- 結果一覧が表示されます
検索対象
- タイトル:記事のタイトル
- 説明文:記事の説明(frontmatterのdescription)
- 本文:記事の全文
検索結果の表示
- タイトル・説明文にヒット:そのまま表示
- 本文にヒット:キーワード周辺のスニペットを表示し、キーワードをハイライト
並び順の変更
「並び順」ドロップダウンで以下を選択できます:
- 新しい順(デフォルト)
- 古い順
ページ送り
検索結果が20件を超える場合、「前へ」「次へ」ボタンでページを切り替えられます。
トラブルシューティング
検索結果が表示されない
- キーワードのスペルを確認してください
- より短いキーワードで試してください
- フォールバック検索も動作するため、部分一致は必ず検出されるはずです
検索が遅い
- 初回のインデックス構築には時間がかかる場合があります
- 記事数が多い場合(1000件以上)は検索に数秒かかる可能性があります
ハイライトが表示されない
- 本文以外(タイトル・説明文)でヒットした場合はハイライトは表示されません
- これは仕様です
まとめ
この実装により、以下が実現できました:
✅ 高速な全文検索 - FlexSearchによる効率的なインデックス検索
✅ 快適な入力体験 - デバウンスによる無駄な検索の削減
✅ 柔軟な検索 - タイトル、説明文、本文すべてから検索
✅ わかりやすい結果表示 - スニペットとハイライトで視認性向上
✅ 大量データ対応 - ページネーションによる快適な閲覧
これらの技術を組み合わせることで、ユーザーフレンドリーで高性能な検索機能を実現しています。