この記事で作るもの
この記事ではContentfulの全文検索を使って、ブログ内検索を実装します。
完成イメージ
具体的な実装手順
以下の流れで実装を行います。
- 共通ヘッダーの作成
- ヘッダー検索フォームの作成
- 検索表示ページの作成
- ヘッダー検索フォームからクエリーを投げる設定
- 検索フォームのバリデーション設定
- Contentfulの全文検索メソッド追加
- 検索メソッドの呼び出し
- 検索表示ページのフォームバリデーション設定
- 検索結果の表示
実装デモサイト
実際の動作は下記URLよりご確認ください。
https://demo-blog.cloud-acct.com/
最終的なコード
記事下部に掲載してあります。
前提条件
この記事を実装するにあたって以下の条件を満たしている必要があります。
- Nuxt.js × Contentfulのブログプロジェクトが存在する
- Contentfulに複数の記事が存在する
- Nuxt.jsでContentfulの記事を取得できている
なお、以上の条件をまだ満たしていない方は「ブログ構築」カテゴリーを完走して、またここにいらしてください。
ブログ構築カテゴリーはNuxt.js v2.13未満での実装を推奨しています。
ダウングレードの方法はこの下記に記載しています。
Vuetifyをまだ導入されていない方
今回の検索フォームにはCSSフレームワーク「Vuetify」を使用します。
Vuetifyをまだ導入されていない方は導入をおすすめします。
Vuetifyの導入方法
モジュールをインストールします。
$ yarn add --dev @nuxtjs/vuetify
buildModules
へVuetifyを登録します。
nuxt.config.js
...
{
buildModules: [
'@nuxtjs/vuetify',
]
}
以上でVuetifyが使えるようになります。
Nuxt.js v2.13以上で実装される方
import
文は必要ありません。
nuxt.config.js
components: true
今回の記事内のimport
文は無視してください。
それでは参りましょう。
1. 共通ヘッダーを作成する
まず、検索フォームを設置するヘッダーを作成しましょう。
「components」ディレクトリに「shared」ディレクトリを作成し、配下に
$ mkdir components/shared && touch $_/myHeader.vue
myHeader.vueの編集
作成された
components/sherred/myHeader.vue
<template>
<div>
<v-app-bar
dense
>
<nuxt-link
to="/"
>
{{ siteName }}
</nuxt-link>
</v-app-bar>
</div>
</template>
<script>
export default {
data() {
return {
siteName: 'SiteName'
}
}
}
</script>
-
<v-app-bar>
... Vuetifyのツールバーを使用しています。
myHeader.vueの呼び出し
「layout」ディレクトリの
layouts/default.vue
<template>
<v-app>
<my-header />
<nuxt />
</v-app>
</template>
<script>
import myHeader from '~/components/shared/myHeader'
export default {
components: {
myHeader
}
}
</script>
ヘッダーが表示されました。
サイト名にCSSを追加する
ただちょっとリンク表示がダサい。。。CSSを追加しましょう。
components/shared/myHeader.vue
...
<!-- class 追加 -->
<nuxt-link
to="/"
class="site-name"
>
{{ siteName }}
</nuxt-link>
...
<!-- styleタグの追加 -->
<style lang="scss">
a.site-name {
text-decoration: none;
color: rgba(0, 0, 0, 0.87);
font-size: 1.25rem;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
このCSSを追加すると、サイト名が長くなってもいい感じに表示してくれます。
これで検索フォームを置くヘッダーが作成できました。
2. ヘッダーに検索フォームを作成する
それではVuetifyの<v-text-field>
を使って検索フォームを作成します。
「components/ui」ディレクトリ(無ければ作成)配下に
$ touch components/ui/searchForm.vue
検索フォームを作成する
components/ui/searchForm.vue
<template>
<div>
<v-form>
<v-text-field
hide-details
placeholder="キーワードを入力"
dense
/>
</v-form>
</div>
</template>
<script>
export default {}
</script>
-
<v-form>
...<v-text-field>
をこのタグで囲むことでイベントを発火させることができます。(後述) -
hide-details
... Vuetifyが用意しているプロパティで、フォーム下に表示されるエラーメッセージなどを非表示にします。エラーメッセージが必要ない場合や狭い場所にフォームを設置する場合に使用します。
検索フォームを呼び出す
それでは検索フォームコンポーネントを
components/shared/myHeader.vue
<template>
...
<!-- 追加 -->
<v-spacer />
<search-form />
</v-app-bar>
</div>
</template>
<script>
// 追加
import searchForm from '../ui/searchForm'
export default {
// 追加
components: {
searchForm
},
data() {
...
}
</script>
うまく行きましたね。
3. 検索キーワードを受け取るsearch.vueを作成する
検索フォームへ入力されたキーワードを受け取るページを作成します。
ここで作成するページは3つの役目を持ちます。
- 検索キーワードを受け取る
- 受け取ったキーワードから記事検索を行う
- 検索結果を一覧で表示する
それでは「pages」ディレクトリ配下に
$ touch pages/search.vue
検索キーワードを受け取る
- ヘッダーの検索キーワード =>
search.vue のフォームに設置
pages/search.vue
<template>
<v-container fluid>
<v-form>
<v-row
align="center"
>
<v-col
cols="12"
sm="10"
md="8"
>
<v-text-field
v-model="query"
outlined
hide-details
placeholder="キーワードを入力"
autofocus
/>
</v-col>
<v-col
cols="12"
sm="2"
md="4"
>
<v-btn
color="primary"
>
検索する
</v-btn>
</v-col>
</v-row>
</v-form>
</v-container>
</template>
<script>
export default {
data() {
return {
query: ''
}
},
watch: {
'$route.query.q': {
handler(newVal) {
this.query = newVal
},
immediate: true
}
}
}
</script>
ごちゃごちゃとありますが、重要なのは<script>
タグ内のコードです。
data
-
query
... このページで使用する検索キーワードが入る変数です。この変数をContentfulに渡し、全文検索を実行します。
$route.query.q
Nuxt.jsのthis.$route
を使ってURLに埋め込まれたクエリーを取得しています。
クエリーとは下記のようなURLのq=
の後の部分です。
http://localhost:3000/search?q=aaa
watch
値を監視し、変化があれば命令を実行するVueの機能です。
-
'$route.query.q'
... URLに埋め込まれたクエリーをwatch
のターゲットにしています。この値に変化があれば
handler()
内の命令が実行されます。 -
handler(newVal)
... ターゲットに変化があればこのhandler
内の命令が実行されます。newVal
には変化があった後の値が入り、第二引数には変化前の古い値が入ります。つまりここには、ヘッダーの検索フォームから渡された検索キーワードが入っています。
-
this.query = newVal
...query
変数にキーワードを代入しています。これにより、このページ内で検索キーワードを
this.query
として扱うことができます。
immediate: true
watch
のオプションです。
通常watch
は、ページが描画された後に監視を始め、その後変化があった場合に命令を実行します。
通常のwatch(ターゲットがqueryだった場合)
// この段階で監視対象とされる
data() {
return {
query: ''
}
}
// 代入の段階で変化があったと認識される
this.query = 'aaa'
// watchの発火
watch: {
query: {
handler(newVal) {
this.query = newVal
},
immediate: true
}
}
immediate: true
オプションを付けると描画前に監視を始めます。
immediateオプションをつけた場合
// この段階でwatchが発火する
data() {
return {
query: ''
}
},
// watchの発火
watch: {
query: {
handler(newVal) {
this.query = newVal
},
immediate: true
}
}
これによりターゲットの$route.query.q
が宣言された直後の値も受け取ることができます。
4. ヘッダー検索フォームからクエリーを投げる設定を行う
上記でキーワードを受け取る設定はできたので、次は
components/ui/searchForm.vue
<template>
<div>
<!-- @submitの追加 -->
<v-form
@submit.prevent="submit"
>
<!-- ref・v-modelの追加 -->
<v-text-field
ref="searchForm"
v-model="query"
hide-details
placeholder="キーワードを入力"
dense
/>
</v-form>
</div>
</template>
<script>
export default {
// 追加
data() {
return {
query: ''
}
},
methods: {
submit() {
this.$router.push({ path: '/search', query: { q: this.query } })
this.query = ''
this.$refs.searchForm.blur()
}
}
}
</script>
@submit.prevent="submit"
フォーム上でエンターキーが押されたタイミングでイベントが発火します。
-
prevent
... これはHtmlの仕様では、エンターキーのタイミングでフォームが送信され、ページがリロードされます。このプロパティを追加するとフォームを送信させないようにでき、ページのリロードを防ぐことができます。
-
submit
... エンターキーが押されたタイミングで呼び出されるメソッドです。
this.$router.push
メソッド内でページ遷移を実行したい場合に使用します。
path: '/search'
... 遷移先のパスを指定します。query: { q: this.query }
... クエリーを渡したい場合のオプションです。this.query
にはユーザーが入力した検索キーワードが入ります。
this.$refs.searchForm.blur()
ページ遷移後に検索フォームのフォーカスを外しています。
-
this.$refs.<キー>
... DOM(Htmlタグのこと)を直接操作したい場合に使用します。操作したいDOMには
ref=<任意のキー>
を設定する必要があります。
操作したいDOM(Html)
<v-text-field ref="searchForm">
操作する時(Javascript)
this.$refs.searchForm.<メソッド>
blur()
... フォームのフォーカスを外すVue.jsの命令です。
実際に検索キーワードを投げてみよう
ヘッダーの検索フォームに文字を入力してエンターキーを押してみましょう。
- URLにクエリーが紐づいていていますね。
- 入力したヘッダーの検索フォームは、フォーカスが外れ空になっています。
- そして
search.vue では、検索フォームでクエリーを受け取ることができています。
この3つが確認できれば成功です。
5. 空白を許容しないバリデーションを設定する
ただ今のままでは空白の状態でも検索キーワードが投げられます。
試しにスペースを入力してエンターを押してみてください。
これでは無駄な動作なので、
components/ui/searchForm.vue
...
<script>
export default {
// 追加
computed: {
// 検索キーワードが有効な場合にtrueを返す
validQuery() {
return !!this.query && // 入力必須
!/^\s+$/.test(this.query) && // 空白のみ禁止
this.$route.query.q !== this.query // 値の変化
}
},
methods: {
submit() {
// if文でメソッド内を囲む
if (this.validQuery) {
this.$router.push({ path: '/search', query: { q: this.query } })
this.query = ''
this.$refs.searchForm.blur()
}
}
}
}
</script>
-
validQuery()
... 以下の条件に当てはまるとエンターキーを押しても検索が実行されません。- 何も入力されていない =>
false
- 空白だけの入力 =>
false
- 前と同じ検索ワードの場合 =>
false
- 何も入力されていない =>
これでヘッダーの検索フォームが完成です。
6. Contentfulの全文検索メソッドを追加する
それではContentfulの全文検索機能を実装しましょう。
まずは
pages/search.vue
<template>
...
<!-- 追加 -->
<v-col
cols="12"
>
検索結果{{ posts.length }}件
</v-col>
</v-row>
</v-form>
</v-container>
</template>
<script>
// 追加
import client from '~/plugins/contentful'
export default {
data() {
return {
query: '',
// 追加
posts: []
}
},
...
// 追加
methods: {
async getPosts() {
await client.getEntries({
content_type: process.env.CTF_BLOG_POST_TYPE_ID,
query: this.query
})
.then(({ items }) => (this.posts = items))
.catch(console.error)
}
}
}
</script>
-
getEntries()
... Contentfulからコンテンツを取得するメソッドです。content_type: <タイプ>
... Contentfulのコンテンツタイプを指定します。process.env.CTF_BLOG_POST_TYPE_ID
には、「blogPost」という文字列が入っています。query: '検索キーワード'
... 全文検索を実行する際に使用します。
-
.then(({ items }) => (this.posts = items))
... Contentfulからはキーワードに一致する記事の配列が返されます。その配列を予め用意したposts
配列に代入しています。
7. 検索メソッドを実行する
検索のタイミングは3つあります。
- クエリーが投げられた一番最初(ヘッダー検索フォーム)
- 検索フォーム上でエンターを押した時
- 検索するボタンをクリックした時
上記を踏まえてメソッドを呼び出しましょう。
pages/search.vue
...
<!-- 2. 検索フォーム上でエンターを押した時/@submit追加 -->
<v-form
@submit.prevent="getPosts"
>
...
<!-- 3. 検索するボタンをクリックした時/@click追加 -->
<v-btn
color="primary"
@click="getPosts"
>
検索する
</v-btn>
...
</template>
<script>
import client from '~/plugins/contentful'
export default {
...
watch: {
'$route.query.q': {
handler(newVal) {
this.query = newVal
// 1. クエリーが投げられた一番最初
this.getPosts()
},
immediate: true
}
},
...
</script>
全ての実装を確認してみてください。
- ヘッダーの検索フォームから
- 検索ページの検索フォームから
- 検索するボタンから
全ての検索に結果が表示されていますね。
8. 検索フォームにバリデーションを設定する
ただ今のままでは空白時にも検索が実装され、無駄なAPI通信が発生します。
this.query
が未入力の場合、空白のみの場合には検索をしないようバリデーションを設定しましょう。
pages/search.vue
...
<script>
import client from '~/plugins/contentful'
export default {
data() {
return {
query: '',
posts: []
}
},
// 追加
computed: {
isRequired() {
return !!this.query && !/^\s+$/.test(this.query)
}
},
...
methods: {
async getPosts() {
// if文でメソッド内を囲む
if (this.isRequired) {
await client.getEntries({
content_type: process.env.CTF_BLOG_POST_TYPE_ID,
query: this.query
})
.then(({ items }) => (this.posts = items))
.catch(console.error)
}
}
}
}
</script>
これで未入力時と空白時に無駄な通信を行わないようになりました。
9. 検索結果を表示する
検索できた記事たちを表示しましょう。
Vuetifyの<v-list>
を使ってposts
の配列をループします。
pages/search.vue
...
<v-col
cols="12"
>
<!-- 以下追加 -->
<v-list-item>
<v-list-item-content>
<v-list-item-title>
検索結果{{ posts.length }}件
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<template v-if="loading">
<div class="text-center">
<v-progress-circular
indeterminate
color="grey"
/>
</div>
</template>
<template v-else>
<v-list class="py-0">
<template v-if="posts.length">
<v-list-item
v-for="(post, i) in posts"
:key="i"
:to="$store.getters.linkTo('posts', post)"
>
<v-list-item-content>
<v-list-item-title>
{{ post.fields.title }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<template v-else>
<v-list-item class="justify-center">
<div class="text-center">
<p>
キーワードに一致する投稿がありません。
</p>
<v-icon>
mdi-emoticon-cry-outline
</v-icon>
</div>
</v-list-item>
</template>
</v-list>
</template>
<!-- ここまで -->
</v-col>
</v-row>
</v-form>
</v-container>
</template>
<script>
import client from '~/plugins/contentful'
export default {
data() {
return {
query: '',
posts: [],
loading: false // 追加
}
},
...
methods: {
async getPosts() {
if (this.isRequired) {
this.loading = true // 追加
await client.getEntries({
content_type: process.env.CTF_BLOG_POST_TYPE_ID,
query: this.query
})
.then(({ items }) => (this.posts = items))
.catch(console.error)
this.loading = false // 追加
}
}
}
}
</script>
-
loading
... 表示にタイムログがあるため、フラグを設置しています。エンドユーザーに対して検索中であることを明確にできます。
実際に検索してみましょう。
投稿記事のリンクが表示されてますね。
検索結果がない場合はどうでしょう。
うまく表示されました!
終わりに
以上で実装が完了しました。
ただこのままでは、全ての検索結果が表示されるのでページべネーションの設置も必要ですね。
ここまでできたあなたはもうその力があるはずです!
ぜひカスタマイズしてみてください:cowboy_hat_face:
この記事は、ココナラで出会った方の相談をきっかけに書き始めました。
人に頼るって本当に大切です。
頼られた方も嬉しいですから、遠慮せず気軽にslackやココナラからご相談くださいね。
最終的なコード
ヘッダー
components/shared/myHeader.vue
<template>
<div>
<v-app-bar
dense
>
<nuxt-link
to="/"
class="site-name"
>
{{ siteName }}
</nuxt-link>
<v-spacer />
<search-form />
</v-app-bar>
</div>
</template>
<script>
import searchForm from '../ui/searchForm'
export default {
components: {
searchForm
},
data() {
return {
siteName: 'SiteName'
}
}
}
</script>
<style lang="scss">
a.site-name {
text-decoration: none;
color: rgba(0, 0, 0, 0.87);
font-size: 1.25rem;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
ヘッダー検索フォーム
components/ui/searchForm.vue
<template>
<div>
<v-form
@submit.prevent="submit"
>
<v-text-field
ref="searchForm"
v-model="query"
hide-details
placeholder="キーワードを入力"
dense
/>
</v-form>
</div>
</template>
<script>
export default {
data() {
return {
query: ''
}
},
computed: {
// 検索キーワードが有効な場合にtrueを返す
validQuery() {
return !!this.query && // 入力必須
!/^\s+$/.test(this.query) && // 空白のみ禁止
this.$route.query.q !== this.query // 値の変化
}
},
methods: {
submit() {
if (this.validQuery) {
this.$router.push({ path: '/search', query: { q: this.query } })
this.query = ''
this.$refs.searchForm.blur()
}
}
}
}
</script>
検索結果表示ページ
pages/search.vue
<template>
<v-container fluid>
<v-form
@submit.prevent="getPosts"
>
<v-row
align="center"
>
<v-col
cols="12"
sm="10"
md="8"
>
<v-text-field
v-model="query"
outlined
hide-details
placeholder="キーワードを入力"
autofocus
/>
</v-col>
<v-col
cols="12"
sm="2"
md="4"
>
<v-btn
color="primary"
@click="getPosts"
>
検索する
</v-btn>
</v-col>
<v-col
cols="12"
>
<v-list-item>
<v-list-item-content>
<v-list-item-title>
検索結果{{ posts.length }}件
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<template v-if="loading">
<div class="text-center">
<v-progress-circular
indeterminate
color="grey"
/>
</div>
</template>
<template v-else>
<v-list class="py-0">
<template v-if="posts.length">
<v-list-item
v-for="(post, i) in posts"
:key="i"
:to="$store.getters.linkTo('posts', post)"
>
<v-list-item-content>
<v-list-item-title>
{{ post.fields.title }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<template v-else>
<v-list-item class="justify-center">
<div class="text-center">
<p>
キーワードに一致する投稿がありません。
</p>
<v-icon>
mdi-emoticon-cry-outline
</v-icon>
</div>
</v-list-item>
</template>
</v-list>
</template>
</v-col>
</v-row>
</v-form>
</v-container>
</template>
<script>
import client from '~/plugins/contentful'
export default {
data() {
return {
query: '',
posts: [],
loading: false
}
},
computed: {
isRequired() {
return !!this.query && !/^\s+$/.test(this.query)
}
},
watch: {
'$route.query.q': {
handler(newVal) {
this.query = newVal
this.getPosts()
},
immediate: true
}
},
methods: {
async getPosts() {
if (this.isRequired) {
this.loading = true
await client.getEntries({
content_type: process.env.CTF_BLOG_POST_TYPE_ID,
query: this.query
})
.then(({ items }) => (this.posts = items))
.catch(console.error)
this.loading = false
}
}
}
}
</script>