ちょっとした技術メモを忘れないうちに書いていく

GatsbyJS 検索機能を実装する(JsSearchを利用)

2019-11-10

プラグインを作る方はすごいと思いました。


公式ドキュメントの実装方法を参考にマークダウンファイルを検索できるようにする。 検索の仕組みは

  1. 検索したいキーを JSON に出力
  2. 画面読み込み時に JSON のロード
  3. 入力された検索ワードでフィルター

という感じです。

まずはサンプルページを作成

こちらを参考に一覧ページを詳細ページを作成。

マークダウンファイルを少し変更

data ディレクトリを新しく作成し、検索キー用に search を追加します。

data/file1.md
---
path: "/file1"
date: "2019-06-16"
title: "マークダウンファイル その1"
search: "検索その1"
---

## マークダウンファイルの内容
data/file2.md
---
path: "/file2"
date: "2019-06-19"
title: "マークダウンファイル その2"
search: "検索その2箇条書き"
---

+ 箇条書き
+ 箇条書き
+ 箇条書き
data/file3.md
---
path: "/file2"
date: "2019-06-19"
title: "マークダウンファイル その2"
search: "検索その3テーブル"
---

|A  |B  |C  |
|:--|:-:|--:|
|1  |2  |3  |
|4  |5  |6s  |

一覧ページ(index.js)を修正

検索結果用に日付とタイトルのみ表示します。

src/pages/index.js
import React from "react"

import { Link,graphql } from "gatsby"

export default ({data}) => {
    return(
        <div>
        {data.allMarkdownRemark.edges.map(({ node }, index) => (
        <div key={index}>
            <span>{node.frontmatter.date}</span>
            <Link to={node.fields.slug} >{node.frontmatter.title}</Link>
        </div>
        ))}
        </div>
    )
}

export const query = graphql`
query MyQuery {
    allMarkdownRemark(sort: {fields: frontmatter___date, order: ASC}) {
      totalCount
      edges {
        node {
          frontmatter {
            title
            path
            date
          }
          excerpt
          fields {
            slug
          }
        }
      }
    }
  }
`

とりあえずサンプルページが完成

サーバをきどうして確認すると 一覧画面と gatsby_search_1.png

リンク先の詳細画面が表示できる。 gatsby_search_2.png

検索したいキーを JSON に出力

サーバー起動時に JSON を出力するように gatsby-node.js を変更

  1. ファイル出力用に fs を読み込む
  2. graphql で frontmatter の titile、search、date も取得する
  3. 取得した titile、search、slug を JSON に出力する(表示用に date も出力する)
gatsby-node.js
exports.onCreateNode = ({ node,actions }) => {
    if (node.internal.type === `MarkdownRemark`) {
        const { createNodeField } = actions
        createNodeField({
            node,//このノードに
            name: `slug`,//slugという名前で
            value: node.frontmatter.path//pathを設定
        })
    }
}

//ページ生成
const path = require(`path`)
//1. ファイル出力用にfsを読み込む
var fs=require("fs");
exports.createPages = ({ graphql, actions }) => {
    const blogTemplate = path.resolve(`src/templates/blog.js`)
    const { createPage } = actions

    // マークダウンファイルのslugを取得
    return graphql(`
        {
            allMarkdownRemark {
                edges {
                    node {
                        fields {
                            slug
                        }
#                     2.検索用に追加
                        frontmatter {
                            title
                            search
                            date
                        }
                    }
                }
            }
        }
    `).then(
        result => {
            // 検索用JSONの内容を格納する
            const search = [];
            result.data.allMarkdownRemark.edges.forEach(edge  => {

                createPage({
                    path: edge.node.fields.slug,
                    component: blogTemplate,
                    context: {
                        slug: edge.node.fields.slug,
                    },
                })

                // 3.slug、タイトル、検索キー、日付を配列に入れていく
                search.push({
                    slug: edge.node.fields.slug,
                    title: edge.node.frontmatter.title,
                    search: edge.node.frontmatter.search,
                    date: edge.node.frontmatter.date
                })
            })

            // JSONファイルに出力
            fs.writeFileSync('./static/search.json', JSON.stringify(search, null, 4))

        }
    )
}

サーバを起動すると static 配下に search.json が作成されます。

static/search.json
[
    {
        "slug": "/file2",
        "title": "マークダウンファイル その2",
        "search": "検索その2箇条書き",
        "date": "2019-10-28"
    },
    {
        "slug": "/file1",
        "title": "マークダウンファイル その1",
        "search": "検索その1",
        "date": "2019-10-12"
    },
    {
        "slug": "/file3",
        "title": "マークダウンファイル その3",
        "search": "検索その3テーブル",
        "date": "2019-11-01"
    }
]

とりあえず検索機能を実装してみる

の前に index.js で SearchContainer を呼び出して、出力するように修正します。

index.js
import React from "react"
import Search from "../components/SearchContainer"

// 表示はすべてSearchで行う
export default () => {
    return(
        <div>
            <Search />
        </div>
    )
}

検索を行う SearchContainer.js を src/components/に作成します。 ※公式ドキュメントの内容を一部消しています(エラー処理など)。

src/components/SearchContainer.js
import React, { Component } from "react"
import Axios from "axios"
import * as JsSearch from "js-search"
import { Link } from "gatsby"

class Search extends Component {
  state = {
    bookList: [],//JSONの内容を格納
    search: [],//JsSearchインスタンスを格納
    searchResults: [],//検索結果を格納
    searchQuery: "",//検索ワード
  }
  /**
   * 初期化 search.jsonを読み込む
   */
  async componentDidMount() {
    Axios.get("search.json")
      .then(result => {
        const bookData = result
        this.setState({ bookList: bookData.data })
        this.rebuildIndex()
      })
      .catch(err => {

      })
  }

  /**
   * 検索方法の設定
   */
  rebuildIndex = () => {
    const { bookList } = this.state;
    // JsSearchインスタンス作成(検索対象のリストでユニークとなるキーを指定する)
    const dataToSearch = new JsSearch.Search("slug");

    // 検索ワードをいい感じに変換する(とりあえずスペースで分割し、複数文字列で検索)
    dataToSearch.tokenizer = {
      tokenize( text ) {
        return text.split(/\s+/i);
      }
    };

    // 部分一致で検索する
    dataToSearch.indexStrategy = new JsSearch.AllSubstringsIndexStrategy();

    // 検索ワードを小文字変換、trimする(記述しなくてもデフォルトで設定されている)
    dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer();

    //検索方法の設定
    dataToSearch.searchIndex = new JsSearch.TfIdfSearchIndex("slug");

    // 検索を行うキー
    dataToSearch.addIndex("title");
    dataToSearch.addIndex("search");

    // 検索対象となるリストを設定
    dataToSearch.addDocuments(bookList);
    this.setState({ search: dataToSearch });
  }

  /**
   * 検索ワード変更時に検索を行う
   */
  searchData = e => {
    const { search } = this.state
    const queryResult = search.search(e.target.value);
    this.setState({ searchQuery: e.target.value, searchResults: queryResult })
  }

  render() {
    const { bookList, searchResults, searchQuery } = this.state
    const queryResults = searchQuery === "" ? bookList : searchResults
    return (
      <div>
        <div>
            <div>
            検索ワード:<input
                id="Search"
                value={searchQuery}
                onChange={this.searchData}
              />
            </div>
          <div>
            検索結果:{queryResults.length}
                {queryResults.map(item => {
                  return (
                      <div key={item.slug}>
                        <span>{item.date}</span>
                        <Link to={item.slug} >{item.title}</Link>
                      </div>
                  )
                })}
          </div>
        </div>
      </div>
    )
  }
}
export default Search

画面の表示すると、最初は 3 件表示されていますが、検索ワードで絞り込みができます。 gatsby_search_3.png 「その 2」で検索。 gatsby_search_4.png 画面には表示されていないけど、検索キー search に指定した「テーブル」で検索。 gatsby_search_5.png 「2019」は画面に表示されているが、検索キーに指定していないので該当なし。 gatsby_search_6.png

SearchContainer コンポーネントは何してるのか

コメントもいっぱい書いたけど、せっかくなので SearchContainer のまとめ。

componentDidMount 内の処理

コンポーネントの配置後に 1 回だけ実行される。 search.json の内容を取得して、state の bookList に格納した後、rebuildIndex を実行。

rebuildIndex

JsSearch インスタンスのオプションや検索キーを設定し、state の search に格納。 各オプションは自前で実装することも可能です。

  • tokenizer 入力された検索ワードの分割方法を指定オプション。 デフォルトでは SimpleTokenizer が設定されているが、マルチバイトに対応していなかったので自前でスペース分割するように実装。 文書を検索したときに「a」や「the」などを除く StopWordsTokenizer も用意されているが英語のみ対応。

  • indexStrategy 検索部分を指定する(デフォルトは前方一致)。 前方一致:PrefixIndexStrategy 部分一致:AllSubstringsIndexStrategy 完全一致:ExactWordIndexStrategy

  • sanitizer 検索ワードを変換する(デフォルトは小文字変換と trim)。 小文字変換後、trim:LowerCaseSanitizer trim のみ:CaseSensitiveSanitizer

  • searchIndex 検索インデックスの指定(デフォルトは TfIdfSearchIndex)。 検索対象の登場回数などを考慮:TfIdfSearchIndex 順番に表示:UnorderedSearchIndex

  • addIndex 検索対象の JSON 内で検索を行うキーを指定。

  • addDocuments 検索対象となる JSON を指定する。

searchData

state から JsSearch インスタンスを取得し、入力された検索ワードで検索を行う。

参考

JsSearch の公式ドキュメント