Gatsby.js + Contentfulに爆速な検索フォームを実装する!

2021.01.22

Gatsby.jsで構築したJamstackなウェブサイトに、検索フォームを埋め込みます。

今回はバックエンドにヘッドレスCMSのContentfulを使っていて、Contentfulで入稿した記事をフロントから全検索できるような機能を目指します。

やろうとしてること

よくあるような、検索ボタンをタップして、モーダル内で記事をテキスト検索できるフォームを、SaaSを利用せず自前で実装します。

Gatsbyアプリではビルドする際にContentfulから全記事を読み込み済みなので、検索フォームのonChangeイベントを検知して、リアルタイムで(=フロント側のみで)検索結果を書き換えるため、爆速で記事を表示できます。

Contentful+Gatsbyアプリの立ち上げ

Contentfulでの記事投稿、Gatsbyアプリの立ち上げについては以前記事を書いていますので、詳しくは下記の「1. Contentfulの準備」&「2. Gatsbyアプリの立ち上げ」」をご参照ください。

CircleCI × Contentful × S3で作るJamstackなブログ環境。

今回は検索機能のみですので、アプリを立ち上げるのはローカルだけでOKです。

モーダルの実装

上記の立ち上げとセットアップが終わった段階で http://localhost:8000 を立ち上げると、トップページに記事の一覧が表示されていると思います(スタイルはご容赦ください)。

このページに、まずはモーダルウインドウとその呼び出しボタンを組み込みます。

Reactでモーダルウインドウを簡単に実装できるパッケージ'react-modal'をインストールします。

$ npm install react-modal

次にcomponents配下にファイルを新規作成します。

src/components/modalSearch.js

import React from 'react';
import Modal from 'react-modal';
import Search from "./search";

Modal.setAppElement('#___gatsby')  //public/htmlのid参照
class ModalWindow extends React.Component {
  constructor() {
    super();
    this.state = {
      modalIsOpen: false
    };
    this.openModal = this.openModal.bind(this);
    this.closeModal = this.closeModal.bind(this);
  }
  openModal() {
    this.setState({modalIsOpen: true});
  }
  closeModal() {
    this.setState({modalIsOpen: false});
  }
  render() {
    return (
      <div className="modalWrapper">
        <button onClick={this.openModal}>検索する</button>
        <Modal
          isOpen={this.state.modalIsOpen}
          onRequestClose={this.closeModal}
          contentLabel="Seach Modal"
          className="modalSearchWindow"
          overlayClassName="modalSearchOverlay"
        >
          <Search />
          <button onClick={this.closeModal}>閉じる</button>
        </Modal>
      </div>
    );
  }
}
export default ModalWindow;

※あとで作る検索コンポーネントもすでに呼び出しているので、この時点でアプリをbuildしてもエラーになります。

上記、Modal.setAppElement('#___gatsby')id部分は適宜読み替える必要があります。Gatsbyの場合は#___gatsbyで良いかと思いますが、一応public/htmlのidを参照してください。

次に、トップページ(index.js)でmodalSearchコンポーネントを呼び出します。

src/pages/index.js

import ModalSeach from "../components/modalSearch";

で、好きな場所に呼び出しタグを記述します。

<ModalSeach />

CSSはこんな感じで適当です。必要最低限なので適宜スタイリングしてあげてください。

.modalWrapper{
  text-align: center;
  padding: 20px 0;
}
.modalWrapper button,
.modalSearchWindow button{
  padding: 10px 20px;
  font-family: helvetica;
}
.modalSearchOverlay{
  z-index: 960;
  position: fixed;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  background-color: rgba(0, 0, 0, 0.75);
}
.modalSearchWindow{
  z-index: 965;
  background: #fff;
  margin: 4vw auto 0;
  margin-right: -50%;
  padding: 40px;
  inset: 50% auto auto 50%;
  transform: translate(-50%, -50%);
  position: absolute;
  border: 1px solid rgb(204, 204, 204);
  overflow: auto;
  border-radius: 4px;
  outline: none;
  width: 750px;
  height: 600px;
}

検索機能コンポーネントの実装

検索機能を実装します。

再びcomponents配下にファイルを新規作成します。

src/components/search.js

import React, { useState } from "react"
import { useStaticQuery, graphql } from "gatsby"

const SearchResult = props => {
	const tempData = useStaticQuery(graphql`
			query SearchData {
				allContentfulSamplePostsForSeach: allContentfulSamplePosts( sort: {fields: createdAt, order: DESC}) {
				  edges {
				    node {
				      title
				      slug
				      createdAt(formatString: "YYYY-MM-DD")
				    }
				  }
				}
		}
	`)

	const className = useState("")
	const allPosts = tempData.allContentfulSamplePostsForSeach.edges
	const emptyQuery = ""
	const [state, setState] = useState({
	  filteredData: [],
	  query: emptyQuery,
	})
	const handleInputChange = event => {
	  console.log(event.target.value)
	  const query = event.target.value
	  const posts = tempData.allContentfulSamplePostsForSeach.edges || []

	  const filteredData = posts.filter(post => {
	    const title = post.node.title
	    return (
	      title.toLowerCase().includes(query.toLowerCase())
	    )
	  })
	  setState({
	    query,
	    filteredData,
	  })
	}
	const { filteredData, query } = state
	const hasSearchResults = filteredData && query !== emptyQuery
	const result = hasSearchResults ? filteredData : allPosts

	return (
		<div className={className}>
			<div className="result-inner">
				<input
					type="text"
					aria-label="Search"
					placeholder="検索ワードを入力..."
					onChange={handleInputChange}
				/>
				<div className="result-inner__res">
					{query !== "" ?
						query + " の検索結果: " + result.length + "件"
						: result.length + "件の記事があります"
					}
				</div>
				<ul className="result-inner__search">
					{result && result.map(({ node: post }) => {
						return (
							<li key={post.slug}>
								<a href={`/post/${post.slug}/`}>
									<div className="result-inner__title">
										{post.title}
									</div>
									<div className="result-inner__info">
										<div className="result-inner__info-date">{post.createdAt}</div>
									</div>
								</a>
							</li>
						)
					})}
				</ul>
			</div>
		</div>
	)
}

export default SearchResult

inputフィールドの検索値の変更をトリガーに、各記事のタイトルに対して都度フィルタリングを実施しています。デフォルトでは記事を全件表示し、検索ワードに合わせて検索結果件数も同時に更新されます。

今回の検索コンポーネントは特定のページではなく、モーダルを呼び出してコンポーネントの中から記事を呼び出す想定なので、useStaticQueryを利用し、ページコンポーネント以外からクエリを叩けるようにしています。

また、allContentfulSamplePostsも各自ご利用中のContentfulでのコンテンツタイプ名に合わせてください。

これで準備は完了です。

TOPページで検索ボタンからモーダルを開き、検索を実行してみてください。

Contentfulとは通信を行なっておらず、フロントのjs側で検索機能が完結しているため、ほぼリアルタイムで検索結果が表示される爆速仕様で実装できました。

最後に

Contentfulからは記事読み取り用のDelivery APIが提供されていますが、ある程度の記事数までの検索はフロント側で実装してしまったほうが良いパフォーマンスが出ると思います(私の試した限りでは少なくとも200〜300記事までは爆速)。

今回はテストなので記事タイトルのみを検索対象にしていますが、本文やタグなどにも簡単に対象を広げることができるので、ぜひ使ってみてください。

Contentfulのご利用をお考えの方がいましたら、ぜひお気軽に弊社までお問い合わせください。

参考にさせていただいた記事(ありがとうございます!)

https://qiita.com/keyyang0723/items/08c96a5cbc02ef741796

https://www.aboutmonica.com/blog/create-gatsby-blog-search-tutorial