Qlitre's Blog

2022.04.30 Nuxt.js /microCMS

Nuxt3とmicroCMSで作るブログ ⑤キーワード検索

Nuxt3とmicroCMSで作るブログシリーズの5番目の記事です。
今回はキーワード検索を実装していきます。

検索の流れ

以前ですがNuxt2でブログの記事にしたことがありました。
Nuxt.js + microCMS キーワード検索を実装する方法 (Netlify Function 使用)

Nuxt3でも似たような流れで行っていきます。

  • 検索ボックスコンポーネントを作る
  • 検索を実行したら結果表示ページに遷移
  • クエリをサーバーに投げて絞り込み


Nuxt2では結構ややこしくて、Netlify Functionを使ったりしたのですが、Nuxt3では自前でサーバーサイドのAPIを作れます。
なので、非常に処理がシンプルになったという印象です。

検索フォームコンポーネント

まずはSearchForm.vueを作成して以下のようにします。

<!-- client/components/SearchForm.vue -->
<script setup lang="ts">
const router = useRouter()
const query = ref('')

// 検索できるか判定
// キーワードがある且つ、空白じゃない時にTrueを返す
function canSubmit() {
  return !!query.value && !/^\s+$/.test(query.value)
}

// 検索表示ページに遷移する
function submit() {
  if (canSubmit()) {
    return navigateTo({
      path: '/search',
      query: {
        q: query.value
      }
    })
  }
}

</script>

<template>
  <div>
    <form class="search-form" @submit.prevent="submit">
      <input type="text" v-model="query" ref="searchForm" placeholder="サイト内検索">
    </form>   
  </div>
</template>

<style scoped>
input[type=text] {
  font-size: 14px;
  padding: 4px 8px;
  box-sizing: border-box;
  border-radius: 10px;
  border: solid 1px #ccc;
  background-color: #fff;
  font-family: "Ubuntu", "Noto Sans JP", sans-serif;
  display: inline-block;
  width: 100%;
  height: 30px;
}

input[type=text]:focus {
  outline: 0;
  box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
</style>


色々と書いていますが、テキストボックスを表示して、検索ようのsearch.vueに遷移させるだけのコンポーネントです。

// 検索できるか判定
// キーワードがある且つ、空白じゃない時にTrueを返す
function canSubmit() {
  return !!query.value && !/^\s+$/.test(query.value)
}


ここの部分は空白繰り返しではない時にtrueとなる正規表現です。

`!/^\s+$/.test(query.value)


基本的な正規表現一覧

また、nuxt3ではページを遷移させる際に、router.push()ではなくて、navigateTo()と書くようです。

return navigateTo({
      path: '/search',
      query: {
        q: query.value
      }
    })


変数queryはscript内で宣言された値なので、query.valueとして値を取り出す点が注意です。
次に親コンポーネントから読み込んでおきましょう。

<!-- client/pages/index.vue -->
...
<template>
    <div>
        <div class="divider">
            <section class="container">
                <!-- 記事一覧 -->
                <PostList :posts="posts" />
            </section>
            <aside class="aside">
                <!-- キーワード検索、タグ一覧 -->
                <SearchForm />
            </aside>
        </div>
        <!-- 追加 -->
        <Pagination :numPages="numPages" :current="page" />
    </div>
</template>
...


検索結果表示ページの作成

次にpagesディレクトリにsearch.vueを作成します。

<!-- client/pages/search.vue -->
<script setup lang="ts">
const route = useRoute()
const query: string | string[] = route.query.q

const params: object = {
  q: query,
}

const { data: posts, refresh } = await useFetch('/api/search',
  {
    params: params
  }
)

// 再検索
function submit(q: string) {
  return navigateTo({
    path: '/search',
    query: {
      q: q
    }
  })
}

// queryが変化した場合にページをリロードする
// これを記述しないと再検索ができない
watch(() => route.query, () => location.reload())

</script>

<template>
  <div class="main">
    <div class="search-form">
      <input v-model="query" type="text" @keyup.enter="(e) => submit((e.target as HTMLInputElement).value)" />
    </div>
    <PostList :posts="posts" />
  </div>
</template>

<style>
.search-form {
  margin-top: 2rem;
  position: relative;
  margin-bottom: 2rem;
}

input[type=text] {
  border: 1px solid #ccc;
  width: 100%;
  box-sizing: border-box;
  border-radius: 5px;
  height: 40px;
  font-size: 16px;
  padding-left: 10px;
  box-shadow: none;
  -webkit-appearance: none;
  transition: box-shadow 0.2s ease;
}

input[type=text]:focus {
  outline: 0;
  box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}

</style>


検索APIの作成

search.tsを作成します。

// client/sever/api/search.ts
import type { IncomingMessage, ServerResponse } from 'http'
import client from './client'
import { Post } from './types'
import * as url from "url";

export default async (req: IncomingMessage, res: ServerResponse) => {
    const params = url.parse(req.url as string, true).query
    const queries = {
        fields: 'id,title,publishedAt,tag',
        orders: '-publishedAt',
        q: String(params.q)
    }

    const data = client.getList<Post>({
        endpoint: 'post',
        queries: queries
    })

    return data
}


まずsearch.vueでは、SearchForm.vueで入力された検索キーワードを受け取っています。

const route = useRoute()
const query: string | string[] = route.query.q


そして、ここの部分で検索用のAPIを呼び出します。

const params: object = {
  q: query,
}
const { data: posts, refresh } = await useFetch('/api/search',
  {
    params: params
  }
)


サーバーAPIは受け取ったキーワードをmicroCMSに投げている、ということになります。
試しにアルバムと入力してトップページから検索を実行すると、以下のように表示がされます。



場合によってはこのページからさらに検索したいということがあると思います。
なので、表示されているsearch.vueで以下のようにすることで、クエリが変化した際に再検索するようにしています。
テンプレートのinput boxでenterキーが押された際にsubmitファンクションが呼ばれます。

// 再検索
function submit(q: string) {
  return navigateTo({
    path: '/search',
    query: {
      q: q
    }
  })
}

// queryが変化した場合にページをリロードする
// これを記述しないと再検索ができない
watch(() => route.query, () => location.reload())





検索結果もページングする

Nuxt2ではこの実装が難しくて挫折した経験があるのですが、Nuxt3では比較的簡単に実装ができました。
記事一覧ページと同様にlimitとoffsetを伴ってクエリを投げるようにしていきます。

まず、search.vueを以下のように変更します。

<!-- client/pages/search.vue -->
<script setup lang="ts">
const route = useRoute()

const page: number = Number(route.query.page || 1)
const query: string | string[] = route.query.q
const limit: number = 5
const offset: number = (page - 1) * limit
const params: object = {
  q: query,
  limit: limit,
  offset: offset
}

const { data: posts, refresh } = await useFetch('/api/search',
  {
    params: params
  }
)

// ページ数の計算
const totalCount: number = posts.value.totalCount
const numPages: number = Math.ceil(totalCount / limit)

// 再検索
function submit(q: string) {
  return navigateTo({
    path: '/search',
    query: {
      q: q
    }
  })
}

// queryが変化した場合にページをリロードする
// これを記述しないと再検索ができない
watch(() => route.query, () => location.reload())

</script>


次にAPIにlimitとoffsetも付記します。

export default async (req: IncomingMessage, res: ServerResponse) => {
    const params = url.parse(req.url as string, true).query
    const queries = {
        fields: 'id,title,publishedAt,tag',
        orders: '-publishedAt',
        limit: Number(params.limit),
        offset: Number(params.offset),
        q: String(params.q)
    }

    const data = client.getList<Post>({
        endpoint: 'post',
        queries: queries
    })

    return data
}


後はページネーションコンポーネントを作ります。
通常のページネーションと少し処理が異なるので、PaginationWithSearch.vueとします。

<!-- client/components/PaginationWithSearch.vue -->
<script setup lang="ts">

const props = defineProps({
    numPages: Number,
    current: Number,
    q: String,
})

function getPath(p: number) {
    return `/search?q=${props.q}&page=${p}`
}

</script>

<template>
    <div class="pagination">
        <NuxtLink v-for="num in numPages" :key="num" :class="[num == current ? 'current' : 'link']" :to="getPath(num)">
            {{ num }}
        </NuxtLink>
        <router-view />
    </div>
</template>

<style scoped>
.pagination {
    position: relative;
    width: 100%;
    margin: 8em 0 8rem;
    font-family: 'Open Sans', sans-serif;
    font-weight: 300;
    line-height: 1.1;
    text-align: center;
    vertical-align: middle;
}
.current,
.link {
    display: inline-block;
    margin: 0 2rem;
    padding: 2px 0;
    text-align: center;
    font-size: 3rem;
    font-weight: lighter;
}
.current {
    color: #000;
}
.link {
    color: #A2A2A6;
}
</style>


script内でpropsした変数にアクセスするときはprops.qというようにpropsを頭につけるのが注意です。

つぎにsearch.vueでコンポーネントを読み込みます。

<template>
  <div class="main">
    <div class="search-form">
      <input v-model="query" type="text" @keyup.enter="(e) => submit((e.target as HTMLInputElement).value)" />
    </div>
    <PostList :posts="posts" />
    <PaginationWithSearch :numPages="numPages" :current="page" :q="String(query)" />
  </div>
</template>


ページリンクをクリックした場合、PaginationWithSearchのgetPath関数が呼ばれます。

function getPath(p: number) {
    return `/search?q=${props.q}&page=${p}`
}


例えばと入力して3ページ目のリンクをクリックしたら、/search?q=あ&page=3というpathが返ります。
なので、search.vueのここの部分でpageが取得ができるということになります。

const page: number = Number(route.query.page || 1)


あとは一覧ページと同様にoffsetを計算してサーバーに投げれば応じた結果が返ってきます。



次回はタグでの絞り込みを実装していきます。

TOPページへ