【Vue.js】検索フィルター付きリストの作成|computedで絞り込みを実装

【Vue.js】検索フィルター付きリストの作成|computedで絞り込みを実装 Vue.js

検索ボックスに入力した文字で、リストをその場で絞り込む機能は、Vueのcomputedを使うと簡潔に作れます。ただ、日本語のサイトでは、基本の書き方のままだと、検索にうまく引っかからなかったり、入力中に絞り込まれなかったりします。この記事では、Vue 3(<script setup>)を前提に、基本の作り方と、日本語向けの工夫を整理します。

スポンサーリンク

基本の実装

入力値をrefに持ち、computedで絞り込んだ配列を作ります。各項目には、v-forのkeyのために、固有のidを持たせておきます。

Vue:基本の検索フィルター
<script setup>
import { ref, computed } from 'vue'

const keyword = ref('')

const items = ref([
  { id: 1, name: '山田太郎' },
  { id: 2, name: '田中花子' },
  { id: 3, name: '佐藤健' },
  { id: 4, name: '鈴木一郎' },
  { id: 5, name: '高橋愛' }
])

const filteredList = computed(() => {
  const kw = keyword.value.trim().toLowerCase()
  if (!kw) return items.value
  return items.value.filter((item) => item.name.toLowerCase().includes(kw))
})
</script>

<template>
  <input v-model="keyword" type="text" placeholder="名前で検索...">

  <ul v-if="filteredList.length">
    <li v-for="item in filteredList" :key="item.id">{{ item.name }}</li>
  </ul>
  <p v-else>該当する項目がありません。</p>
</template>

これで動きますが、日本語の検索では、次の3つの問題があります。

  • 入力中(変換中)に絞り込まれない
  • 全角と半角、ひらがなとカタカナの違いで、検索に引っかからない
  • 検索ボックスにラベルがなく、結果の件数も読み上げられない

問題1:日本語の変換中は絞り込まれない

公式が注意しているとおり、IMEで入力する言語では、v-modelは変換中には更新されません。「やまだ」と打っている間は、リストが変わらず、変換を確定した時点で、初めて反映されます。変換中にも絞り込みたい場合は、v-modelを使わず、:valueと@inputで自前でバインドします。v-modelの仕組みはv-modelの仕組みを完全理解、フォームでの使い方は双方向データバインディングでフォームの入力値を反映させる方法をご覧ください。

問題2:全角・半角、ひらがな・カタカナの違いを吸収する

toLowerCase()で揃えられるのは、英字の大文字と小文字だけです。日本語のリストでは、全角と半角(ABCとABC、ヤマダとヤマダ)、ひらがなとカタカナ(やまだとヤマダ)の違いも吸収したいところです。次の関数で、検索用に文字列を揃えます。

JavaScript:検索用の正規化
// utils/normalize.js
// 検索用に、文字列を揃える
export function normalize(text) {
  return text
    .normalize('NFKC')                 // 全角英数→半角、半角カナ→全角カナ、全角スペース→半角
    .toLowerCase()                     // 英字の大文字・小文字
    .replace(/[ァ-ヶ]/g,       // カタカナ→ひらがな
      (ch) => String.fromCharCode(ch.charCodeAt(0) - 0x60))
}

// normalize('ABC アイウ カタカナ') → 'abc あいう かたかな'
// normalize('ヤマダ') と normalize('ヤマダ') → どちらも 'やまだ'
  • normalize('NFKC')は、全角の英数字を半角に、半角のカタカナを全角に、全角スペースを半角スペースにそろえます。
  • カタカナのコードポイントから0x60を引くと、対応するひらがなになります。

注意点として、検索する側(入力値)だけでなく、リスト側の文字列にも、同じ正規化をかける必要があります。漢字の読みを引っかけたい場合は、読み仮名(kana)の項目を別に持たせ、名前と読みを合わせて検索対象にします。

完成コード

上の工夫をまとめた完成版です。ポイントは次のとおりです。

  • 検索用のテキストを、computedで先に1度だけ作っておき、入力のたびに正規化し直さない。
  • スペースで区切った、すべての語を含む項目に絞り込む(AND検索)。
  • <label>とtype="search"、件数のrole="status"を付ける。
  • 0件のときは、次に何をすればよいかを示す。
Vue:日本語に対応した検索フィルター
<script setup>
import { ref, computed } from 'vue'
import { normalize } from './utils/normalize'

const keyword = ref('')

const items = ref([
  { id: 1, name: '山田太郎', kana: 'やまだたろう' },
  { id: 2, name: '田中花子', kana: 'たなかはなこ' },
  { id: 3, name: '佐藤健', kana: 'さとうけん' },
  { id: 4, name: '鈴木一郎', kana: 'すずきいちろう' },
  { id: 5, name: '高橋愛', kana: 'たかはしあい' }
])

// 検索用のテキストを、先に1度だけ作っておく(入力のたびに作り直さない)
const searchable = computed(() =>
  items.value.map((item) => ({
    ...item,
    text: normalize(`${item.name} ${item.kana}`)
  }))
)

const filteredList = computed(() => {
  // スペースで区切った、すべての語を含むものに絞り込む(AND検索)
  const words = normalize(keyword.value).split(/\s+/).filter(Boolean)
  if (words.length === 0) return searchable.value
  return searchable.value.filter((item) => words.every((word) => item.text.includes(word)))
})
</script>

<template>
  <label for="search">名前で検索</label>

  <!-- v-model ではなく :value + @input:日本語の変換中も絞り込む -->
  <input
    id="search"
    type="search"
    :value="keyword"
    placeholder="例:やまだ、田中 花子"
    @input="keyword = $event.target.value"
  >

  <!-- 件数を、読み上げソフトにも伝える -->
  <p role="status">{{ filteredList.length }}件</p>

  <ul v-if="filteredList.length">
    <li v-for="item in filteredList" :key="item.id">
      {{ item.name }}({{ item.kana }})
    </li>
  </ul>
  <p v-else>該当する項目がありません。別のキーワードをお試しください。</p>
</template>

computedの使い方はcomputedプロパティで動的なデータ処理を行う方法、keyにidを使う理由はv-forで配列やオブジェクトをループして表示する方法をご覧ください。

データが多いとき:デバウンスとサーバー側の検索

数千件を超えるデータでは、キーを押すたびに、全件を走査するのは重くなります。入力が止まってから絞り込む(デバウンス)と、無駄な計算を減らせます。

Vue:デバウンスして絞り込む
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')           // 入力欄の値
const debounced = ref('')         // 絞り込みに使う値(少し遅れて更新される)

let timer
watch(keyword, (value) => {
  clearTimeout(timer)
  timer = setTimeout(() => {
    debounced.value = value
  }, 300)
})

// filteredList の computed では、keyword の代わりに debounced を使う
</script>

データがさらに多い場合や、一部しか手元にない場合は、サーバー側で検索するのが現実的です。通信を伴う検索は、古い応答が新しい結果を上書きしないよう注意が必要です。詳しくは非同期APIの状態管理パターンをご覧ください。結果が多いときは、ページネーション付きリスト表示を実装する方法と組み合わせます。

よくある質問(FAQ)

Q日本語を入力している途中で、絞り込まれないのはなぜですか?
AIMEで変換している間は、v-modelが更新されないためです。変換中も絞り込むなら、v-modelの代わりに、:valueと@inputで自前でバインドしてください。
Q「ヤマダ」で検索して「やまだ」を見つけるには?
A検索語とリストの文字列の両方を、同じ関数でひらがなに揃えます。カタカナのコードポイントから0x60を引くと、ひらがなになります。全角・半角は、normalize('NFKC')で揃えられます。
Q漢字の名前を、読み仮名で検索したいです。
Aデータに読み仮名の項目を持たせ、名前と読みを合わせた文字列を、検索対象にします。漢字の読みを自動で判定するのは難しいため、あらかじめデータとして用意するのが確実です。
Q:keyにインデックスを使ってはいけませんか?
A絞り込みでリストの内容が変わると、インデックスと項目の対応がずれ、表示の不具合の原因になります。項目ごとに固有のidを持たせて、keyにしてください。

まとめ

検索フィルターは、computedで絞り込むだけなら、数行で作れます。日本語のサイトでは、変換中も絞り込むために:valueと@inputを使い、検索語とデータの両方を、全角・半角やひらがな・カタカナで揃えると、実用的になります。ラベルと件数の読み上げ、データが多いときのデバウンスにも気を配ってください。