NuxtにAlgoliaを導入する(TS使用、コンポーネントは自分で用意ver)

2021.01.10
NuxtにAlgoliaを導入する(TS使用、コンポーネントは自分で用意ver)

AlgoliaをNuxtに導入する記事を以前に書きました。

上記の記事では検索用のコンポーネントもAlgoliaが用意したコンポーネントを使っていましたが、

CSR/SSRのDOMの違いに関するwarningメッセージがコンソールに表示されていました。




そのため、今回はコンポーネントは自分で用意しつつ、Algoliaの検索機能を実装してみます。

検証した環境

1 nuxt 2.14.12
2 @nuxt/content 1.11.1

Algoliaを使った検索機能を実装する

実装前の準備

Algoliaには以下のようなJSONをアップロードしています。

記事のタイトルと本文の一部を記載した配列になります。

search.json
[
  {
    "title": "Nuxtに全文検索サービスAlgoliaを導入する",
    "body": "ブログやサービスを作っていると、往々にして検索機能が欲しくなる事があります。いざ実装しようとするとどの部分に検索をかけるか、部分一致検索にするか完全文字一致にするか...などなど意外とめんどくさい作業ですよねそんな中、Algoliaは検索に特化したサービスです!検索機能を自身のサービスに簡単に実装する事が出来ますし、しかも色んな言語やフレームワークをサポートしています。フロントであれば今時のReactやVue、バックエンドはRubyにPythonと幅広く対応しています!実際にどのようなサービスかについては公式の動画があるんですが、それ以上に公式サイトのWidgetページを見ると、出来る事のイメージがしやすいです。Widgets Showcase | Vue InstantSearch | Building Search UI | Guide | Algolia DocumentationここではNuxt.jsにAlgoliaを導入します。Nuxt ContentというNuxtでブログなどSSGを作るのに特化したライブラリ上での解説ですが、Nuxt.jsで作った場合と差異はないはず。"
  },
  {
    "title": "react-scriptsで読み込む環境変数を変える",
    "body": "react-scripts startでローカルサーバ、STGサーバそれぞれコマンドによって別のサーバに繋げたい、ということがありました。ここでは違う .env ファイルを読み込んで接続先を変える方法をご紹介します。そのために dotenv-cliというライブラリ を使用します"
  },
  {
    "title": "Nuxt ContentのPrism.jsに行番号を表示する",
    "body": "Nuxt ContentでPrims.jsを使ったハイライトに対して↓のように行数を表示します。"
  },
  ・・・
]

ライブラリのインストール

前回はAlgoliaのコンポーネント用のパッケージも追加しましたが、
今回はAlgoliaを使った検索を実現するためのalgoliasearch のみ

$ yarn add algoliasearch

index名・Application ID・API Keyを.envに(オプション)

API KeyなどはGit管理から外したいので.envにAlgoliaの検索時に必要な値を記載します

.env
ALGOLIA_INDEX_NAME=INDEX_NAME
ALGOLIA_APPLICATION_ID=HOGEHOGE
ALGOLIA_SEARCH_ONLY_API_KEY=abcdefg12345



コンポーネントでも使えるようにnuxt.config.jsにも追記します

nuxt.config.js
export default {
  env: {
    ALGOLIA_INDEX_NAME: process.env.ALGOLIA_INDEX_NAME || '',
    ALGOLIA_APPLICATION_ID: process.env.ALGOLIA_APPLICATION_ID || '',
    ALGOLIA_SEARCH_ONLY_API_KEY: process.env.ALGOLIA_SEARCH_ONLY_API_KEY || '',
  },
}

コンポーネントを実装する

最終的に以下のようなものを作ります

検索出来るようにする

まずinputに文言を入力したらAlgoliaで検索出来るようにします。

私は VuetifyTypeScriptnuxt-property-decorator を使用しています。


そのためinput要素にはVuetifyのv-text-filedを使用します。

<template>
  <div>
    <v-text-field v-model="searchWord" placeholder="サイト内検索" outlined />
  </div>
</template>

<script lang="ts">
import { Component, Watch, Vue } from 'nuxt-property-decorator'
import algoliasearch from 'algoliasearch'

const algoliaSearch = algoliasearch(
  process.env.ALGOLIA_APPLICATION_ID || '',
  process.env.ALGOLIA_SEARCH_ONLY_API_KEY || ''
)
const algoliaClient = algoliaSearch.initIndex(process.env.ALGOLIA_INDEX_NAME || '')

@Component
export default class Search extends Vue {
  searchWord: string | null = null

  @Watch('searchWord') async changeSearchWord() {
    if (!this.searchWord) return
    const searchResult = await algoliaClient.search(this.searchWord!)
    console.log(searchResult)
  }
}
</script>



検索を行うとsearchWordの内容が変わります。変更を@Watch()で検知しAlgoliaで検索します。


検索を行っているのは以下の部分です。

const searchResult = await algoliaClient.search(this.searchWord!)

Algroliaから検索結果が返ってきました!




searchResultはオブジェクトとして返ってきて、その中のhitsの中に検索結果が配列で格納されています。

配列の1つの中身は例えば以下のようになります。

{
  body: "私のサイトの表示速度がやたら遅い。。構成としてはNuxt Contentを使ってSSG、UIフレームワークとしてVuetify、デプロイ先としてNetlifyを使っています。どうにか改善出来ないものか、と思い試してみました。先にこのブログの制作環境と合わせ、遅くなっている原因になっている可能性のあるライブラリも合わせて記載します。",
  objectID: "2913367002",
  title: "Nuxt Contentで作ったサイトの表示パフォーマンスを改善する",
  _highlightResult: {
    body: {
      fullyHighlighted: false,
      matchLevel: "full",
      value: "私のサイトの表示速度がやたら遅い。。構成としては<em>Nux</em>t Contentを使ってSSG、UIフレームワークとしてVuetify、デプロイ先としてNetlifyを使っています。どうにか改善出来ないものか、と思い試してみました。先にこのブ..."
    },
    title: {
      fullyHighlighted: false,
      matchLevel: "full",
      matchedWords: ["nux"],
      value: "<em>Nux</em>t Contentで作ったサイトの表示パフォーマンスを改善する"
    }
  }
}



TypeScriptで書く

searchメソッドは以下のように定義されています

readonly search: <TObject>(query: string, requestOptions?: RequestOptions & SearchOptions) => Readonly<Promise<SearchResponse<TObject>>>;

TObjectにはAlgoliaで設定した属性を型定義して渡してあげます。

<script lang="ts">
import { Component, Watch, Vue } from 'nuxt-property-decorator'
import algoliasearch from 'algoliasearch'
import { SearchResponse } from '@algolia/client-search'

const algoliaSearch = algoliasearch(
  process.env.ALGOLIA_APPLICATION_ID || '',
  process.env.ALGOLIA_SEARCH_ONLY_API_KEY || ''
)
const algoliaClient = algoliaSearch.initIndex(process.env.ALGOLIA_INDEX_NAME || '')

// Algoliaで設定した属性を型定義しておく
type SearchType = {
  title: string
  body: string
}

@Component
export default class Search extends Vue {
  searchWord: string | null = null

  @Watch('searchWord') async changeSearchWord() {
    if (!this.searchWord) return
    const searchResult: SearchResponse<SearchType> = await algoliaClient.search<SearchType>(
      this.searchWord!
    )
    console.log(searchResult)
  }
}
</script>



そうする事で予測変換も有効になります!便利!




検索結果を表示する

検索結果はDataのsearchResultsに格納します。

こちらもHit<SearchType>[]としっかり型定義出来るのが嬉しいですね!

<script lang="ts">
import { Component, Watch, Vue } from 'nuxt-property-decorator'
import algoliasearch from 'algoliasearch'
import { SearchResponse, Hit } from '@algolia/client-search'

・・・

@Component
export default class Search extends Vue {
  searchWord: string | null = null
  searchResults: Hit<SearchType>[] = []

  @Watch('searchWord') async changeSearchWord() {
    if (!this.searchWord) {
      this.searchResults = []
      return
    }
    const searchResult: SearchResponse<SearchType> = await algoliaClient.search<SearchType>(
      this.searchWord!
    )
    console.log(searchResult)
    this.searchResults = searchResult.hits
  }
}
</script>



表示部分は以下のように、cssは適当です。笑

<template>
  <div>
    <v-text-field v-model="searchWord" placeholder="サイト内検索" outlined />
    <div v-for="result in searchResults" :key="result.objectID" :class="$style.resultItem">
      <p :class="$style.title">{{ result.title }}</p>
      <p>{{ result.body }}}</p>
    </div>
  </div>
</template>

<style lang="scss" module>
.resultItem {
  border: 1px solid #969696;
  border-radius: 10px;
  padding: 16px;

  &:not(:last-child) {
    margin-bottom: 10px;
  }
}
.title {
  font-size: 1.3rem;
  font-weight: bold;
  margin-bottom: 1rem;
}
</style>

そして「nux」で検索すると

検索結果が表示出来ました!



ハイライト表示する

Algoliaのいいところの1つとして、検索結果に対して簡単にハイライト表示出来るような工夫がなされているところです。

設定を特にしていない場合は<em>タグで囲われています。

{
  _highlightResult: {
    body: {
      value: "私のサイトの表示速度がやたら遅い。。構成としては<em>Nux</em>t Contentを使ってSSG、UIフレームワークとしてVuetify、デプロイ先としてNetlifyを使っています。どうにか改善出来ないものか、と思い試してみました。先にこのブ..."
    },
    ・・・
  }
}

タグを変更する

AlgoliaのWebサイトに行き、設定を変更したいIndexを選択し、

Configuration > Highlightingの「Highlight prefix tag」「Highlight postfix tag」を変更します。


↑のようにした場合<em class="highlight">で囲まれるようになります。



画面にハイライトして表示する

ここまでくればあと少し!ハイライト表示します。

<template>
  <div>
    <v-text-field v-model="searchWord" placeholder="サイト内検索" outlined />
    <div v-for="result in searchResults" :key="result.objectID" :class="$style.resultItem">
      <p :class="$style.title" v-html="result._highlightResult.title.value" />
      <p :class="$style.body" v-html="result._highlightResult.body.value" />
    </div>
  </div>
</template>

<style lang="scss" module>
//先ほどのCSSに以下を追記
.title,
.body {
  [class='highlight'] {
    background: linear-gradient(transparent 50%, #ffff66 50%);
    font-style: unset;
  }
}
</style>
  • v-htmlを用いてAlgoliaから検索部分に<em class="highlight">が付与されたhtmlをそのまま表示
  • CSSで装飾

という流れです。


私はCSS Moduleでstyleを記載しているため [class='highlight'] と回りくどい書き方をしています。




ここまで記載すると、

無事ハイライト表示してくれるようになりました!



サニタイズする

私のサイトが技術ブログという特性もあるので、サイトによってサニタイズは必須ではないです



v-htmlを使ってハイライト表示出来た、良かった!と思っていた矢先、

コンソールに良く分からないエラーが表示されました。



こんなファイルを読み込んだ覚えも設定した覚えもなかったので「??」となって、


Algoliaの検索結果の要素を確認してみると

なるほど、、v-htmlを使っているが故にブログの本文に書いた<code>タグの内容などを元に勝手装飾してimportくれていたのか、、


それがありがた迷惑な感じになってしまっていたわけですね。



サニタイズの設定をする

以下のサイトを参考に
【Vue/Nuxt】sanitize-htmlのインストールと使い方 | Awesome Blog


サニタイズに用いるためのパッケージを追加

$ yarn add sanitize-html @types/sanitize-html

TypeScriptを使っていない場合@types/sanitize-htmlは不要です。



templateでも使えるようにVue.prototype.$sanitize = sanitizeHtmlでセットし、

<script lang="ts">
import { Component, Watch, Vue } from 'nuxt-property-decorator'
import algoliasearch from 'algoliasearch'
import { SearchResponse, Hit } from '@algolia/client-search'
import sanitizeHtml from 'sanitize-html'

Vue.prototype.$sanitize = sanitizeHtml
・・・

v-htmlを使っていた部分を以下のように$sanitize()でくくります。

<p :class="$style.title" v-html="$sanitize(result._highlightResult.title.value)" />
<p :class="$style.body" v-html="$sanitize(result._highlightResult.body.value)" />



この場合だと問題点があって、
サニタイズされる際にAlgoliaで設定していたクラス名も削除されてしまいます 😅


あちらを立てればこちらが立たず、ですね。笑


そのため、CSSの記述を以下のように変更

<style lang="scss" module>
.title,
.body {
  em {
    background: linear-gradient(transparent 50%, #ffff66 50%);
    font-style: unset;
  }
}
</style>

これで無事ハイライト表示が出来るようになります!

作成したコンポーネント全文

最後に、今回作ったコンポーネントの全文です

Search.vue
<template>
  <div>
    <v-text-field v-model="searchWord" placeholder="サイト内検索" outlined />
    <div v-for="result in searchResults" :key="result.objectID" :class="$style.resultItem">
      <p :class="$style.title" v-html="$sanitize(result._highlightResult.title.value)" />
      <p :class="$style.body" v-html="$sanitize(result._highlightResult.body.value)" />
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Watch, Vue } from 'nuxt-property-decorator'
import algoliasearch from 'algoliasearch'
import { SearchResponse, Hit } from '@algolia/client-search'
import sanitizeHtml from 'sanitize-html'

Vue.prototype.$sanitize = sanitizeHtml

const algoliaSearch = algoliasearch(
  process.env.ALGOLIA_APPLICATION_ID || '',
  process.env.ALGOLIA_SEARCH_ONLY_API_KEY || ''
)
const algoliaClient = algoliaSearch.initIndex(process.env.ALGOLIA_INDEX_NAME || '')

type SearchType = {
  title: string
  body: string
}

@Component
export default class Search extends Vue {
  searchWord: string | null = null
  searchResults: Hit<SearchType>[] = []

  @Watch('searchWord') async changeSearchWord() {
    if (!this.searchWord) {
      this.searchResults = []
      return
    }
    const searchResult: SearchResponse<SearchType> = await algoliaClient.search<SearchType>(
      this.searchWord!
    )
    console.log(searchResult)
    this.searchResults = searchResult.hits
  }
}
</script>

<style lang="scss" module>
.resultItem {
  border: 1px solid #969696;
  border-radius: 10px;
  padding: 16px;

  &:not(:last-child) {
    margin-bottom: 10px;
  }
}
.title {
  font-size: 1.3rem;
  font-weight: bold;
  margin-bottom: 1rem;
}

.title,
.body {
  em {
    background: linear-gradient(transparent 50%, #ffff66 50%);
    font-style: unset;
  }
}
</style>

おすすめ