デバウンス処理と本文検索の実装ガイド

開発blog-platform完了

デバウンス処理と本文検索の実装ガイド

このドキュメントでは、Nuxt Contentサイトに実装した高度な検索機能について、初心者向けに詳しく解説します。

実装の概要

今回の実装では、以下の機能を追加しました:

  1. 全文検索機能 - タイトル、説明文、本文から検索
  2. デバウンス処理 - 入力中の無駄な検索を防ぐ
  3. MDC ASTからのテキスト抽出 - Nuxt Contentの特殊な形式に対応
  4. 検索結果のハイライト - 見つかったキーワードを強調表示
  5. スニペット生成 - 本文でヒットした場合に前後の文脈を表示
  6. ページネーション - 大量の検索結果を分割表示

追加されたファイルとパッケージ

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待つことで無駄な検索実行を防ぎます。

使い方

基本的な検索

  1. /searchページにアクセス
  2. 「キーワード検索」欄に検索したい言葉を入力
  3. 300ms後に自動的に検索が実行されます
  4. 結果一覧が表示されます

検索対象

  • タイトル:記事のタイトル
  • 説明文:記事の説明(frontmatterのdescription)
  • 本文:記事の全文

検索結果の表示

  • タイトル・説明文にヒット:そのまま表示
  • 本文にヒット:キーワード周辺のスニペットを表示し、キーワードをハイライト

並び順の変更

「並び順」ドロップダウンで以下を選択できます:

  • 新しい順(デフォルト)
  • 古い順

ページ送り

検索結果が20件を超える場合、「前へ」「次へ」ボタンでページを切り替えられます。

トラブルシューティング

検索結果が表示されない

  1. キーワードのスペルを確認してください
  2. より短いキーワードで試してください
  3. フォールバック検索も動作するため、部分一致は必ず検出されるはずです

検索が遅い

  • 初回のインデックス構築には時間がかかる場合があります
  • 記事数が多い場合(1000件以上)は検索に数秒かかる可能性があります

ハイライトが表示されない

  • 本文以外(タイトル・説明文)でヒットした場合はハイライトは表示されません
  • これは仕様です

まとめ

この実装により、以下が実現できました:

高速な全文検索 - FlexSearchによる効率的なインデックス検索
快適な入力体験 - デバウンスによる無駄な検索の削減
柔軟な検索 - タイトル、説明文、本文すべてから検索
わかりやすい結果表示 - スニペットとハイライトで視認性向上
大量データ対応 - ページネーションによる快適な閲覧

これらの技術を組み合わせることで、ユーザーフレンドリーで高性能な検索機能を実現しています。