AlgoliaのInstantSearch.jsのconnectorsを使ったFederated Search

この記事は Algolia Advent Calendar 13日目の記事になります。


InstantSearch.js version 4

時が経つのは早いもので、InstantSearch.jsのバージョン4がリリースされてから既に1年以上が経過しています。

algolia community に投稿されている InstantSearch.js v4 is released! のアナウンスの一部を紹介させていただくと、

InstantSearch.js v4 is now fully released 🎉

This release is focused on two main features: Federated search, and bundle size reduction.

このリリースは2つのメインの機能にフォーカスしています: Federated search と バンドルサイズの軽量化 です。

Federated search, is the feature where you search through multiple types of content with the same experience, but with separate result lists. In the past we have also called this feature “multi-index search”. This feature helps you make more efficient UIs with multiple result lists, autocomplete, nested interfaces and query suggestions. You can read more about the new index widget in the documentation.

Federated searchは、複数のタイプのコンテンツを同じ検索体験であるが別のリストとして結果を表示するというものです。過去に私たちはこれを”multi-index search”と呼んでおり、この機能は複数の検索結果リスト、オートコンプリート、ネストしたインターフェース、そして Query Suggestionsといった効果的なUIを構築する手助けをしてきました。こちらの機能の詳細に関しては私たちの新しい index widget のドキュメントをご覧ください。

https://discourse.algolia.com/t/instantsearch-js-v4-is-released/8827

上記のように、Federated Searchの対応がこちらのメイントピックの一つであったことが分かります。そして、このバージョンが出る前までは、複数のInstantSearchのインスタンスを生成してやりくりする必要がありましたが、index widgetのおかげでとても簡単に複数インデックスに対するアクセスが出来るようになったのです。

InstantSearch.jsのconnectors

たまにInstantSearch.jsを使ったフロントエンドの実装をお見かけして、なんとなくゴチャゴチャするなって思うのは、メインとなるJavaScriptファイル(例えばapp.js)でFederated Searchのコードを書いていく時に、描画するところがあんまり長くなるとコードの見通しが良くないのかな?といったところだったりします。

InstantSearch.jsのWidgetにおいては、 Create Your Own Widgets に書かれているように👇こんな感じになっているのですが、、そこまででもないケースが大半かなと思っています。大体の場合は render をちょっとイジるくらい。

search.addWidgets([{
  init(initOptions) {
    // initOptions contains three keys:
    //   - helper: to modify the search state and propagate the user interaction
    //   - state: which is the state of the search at this point
    //   - templatesConfig: the configuration of the templates
  },
  render(renderOptions) {
    // renderOptions contains four keys:
    //   - results: the results from the last request
    //   - helper: to modify the search state and propagate the user interaction
    //   - state: the state at this point
    //   - createURL: if the url sync is active, will make it possible to create new URLs
  },
  dispose(disposeOptions) {
    // disposeOptions contains one key:
    //   - state: the state at this point to
    //
    // The dispose method should return the next state of the search,
    // if it has been modified.
  },
  getWidgetState(uiState, widgetStateOptions) {
    // widgetStateOptions contains two keys:
    //   - searchParameters: to compute the next uiState
    //   - helper: to get information about the state of the search
    //
    // The function must return the next uiState
  },
  getWidgetSearchParameters(searchParameters, widgetSearchParametersOptions) {
    // widgetSearchParametersOptions contains one key:
    //   - uiState: to compute the next SearchParameters
    //
    // The function must return the next SearchParameters
  }
}]);

そこで、今回のこの記事のメインとなる connectors を使っていこうぜ、という話です。

Algoliaのドキュメントの Customize the complete UI of the widgets に詳細が書かれていますので、今回はこれに沿ってやっていきたいと思います。

connectorsとは

Connectorsはwidget factoryを作る、つまり、新しくwidgetインスタンスが作れるということになります。新しくcustom widgetを作りたい場合は、このconnectorsを使って行うと以下のようになります。(ただ、ヒットしたものを1行ずつログ出力しているだけですが、addWidgets出来るようになっています)

const makeHits = instantsearch.connectors.connectHits(
  function renderHits({ hits }, isFirstRendering) {
    hits.forEach(hit => {
      console.log(hit);
    });
  }
);

const search = instantsearch(/* options */);

search.addWidgets([makeHits()]);

InstantSearch.jsそのものはオープンソースなので、connectorsが中で何をしているか知りたい人は、Github上のソースコードを読んでみるのも良いかもしれません。

Federated Searchを実装する

Algolia Advent Calendar 2020 では、InstantSearch.js で Algolia をはじめようAlgolia の InstantSearch.js を使った Shifter Static と Shifter Headless の Federated Search のように、ローカルのHTMLとJavaScriptファイルでWebサーバーを立てずにやりくりしてきましたが、今回はプロダクトのHitsと、QuerySuggestionsのHitsを別のWidgetとして外部ファイル化してimportしようと思っていて、そうするとCORSやら何やらが出てくるので、Webサーバーを立てていきたいと思います。

ということで、一番手っ取り早い方法として、AlgoliaドキュメントのGetting Started にある👇でデモアプリを立ち上げていきたいと思います。

npx create-instantsearch-app ais-ecommerce-demo-app \
  --template "InstantSearch.js" \
  --app-id "B1G2GM9NG0" \
  --api-key "aadef574be1f9252bb48d4ea09b5cfe5" \
  --index-name demo_ecommerce \
  --attributes-to-display name

そうすると👇のようにプロジェクトが出来ますので、

npm startとかyarn startとかすると localhost:3000 でWebサーバーが起動して商品検索デモが見えるようになるかと思います。

ということで、今回は上記で生成された index.html と src/app.js に変更を入れていきながら、productHits.js と suggestionHits.js という2つのJavaScriptのファイルをCustom Widget用に追加していきます。

index.html

bodyの中身をガラっと変えました。ポイントとしては以下の点です。

  1. search-results – 検索結果全体
  2. suggestion-hits – Query Suggestionsを左側に表示
  3. product-hits – ヒットした商品を真ん中に表示。pagination出来るように
  4. refinement-container – ブランドでの絞り込みを右側に表示
  <div class="ais-InstantSearch">
    <h1>Federated Search Demo</h1>
    <div class="federated-search-container">
      <div id="searchbox"></div>
      <div class="search-results"> // 1.
        <div class="suggestion-hits"> // 2.
          <h2>Suggestions</h2>
          <div id="query-suggestions"></div>
        </div>
        <div class="product-hits"> // 3.
          <h2>Products</h2>
          <div id="product-hits"></div>
        </div>
        <div class="refinement-container"> // 4.
          <h2>Brands</h2>
          <div id="brand-refinement"></div>
        </div>
    </div>
  </div>

src/app.js

index.htmlの中で👇のように定義して呼び出されるJavaScriptファイルになります。

<script src="./src/app.js"></script>

suggestionHitsとproductHitsを外部ファイル化したCustom Widgetな感じにしたいので、それをimportして、使うindex周りの設定をしていきます。(プロジェクトを作成した時に用いたアプリケーションIDにはQuery Suggestions用のインデックスが無かったので、今回はそれが存在するものにしました)

import {productHits} from './productHits.js';
import {suggestionHits} from './suggestionHits.js';

const searchClient = algoliasearch(アプリケーションID, Search Only APIキー);

const search = instantsearch({
  indexName: 'instant_search',
  searchClient,
});

あとは、Widgetを追加して組み立てていきます。どうですか!この見通しの良さ(ドヤ)。productHitsとsuggestionHitsを別出しにしたことでレンダリング周りのループしてulとliで回しながらHTMLタグを〜みたいなコードが無くなってスッキリした印象になりました。やっていることは以下です。

  1. searchBox: 検索ボックス用のウィジェット
  2. configure: 1ページにヒットさせる件数
  3. productHits: 外部ファイル化。パラメータとしてどのdivに表示させるかを渡す
  4. refinementList: ブランドでの絞り込み用
  5. index: こちらがFederated Searchの肝になる部分でココではQuery Suggestions用のインデックスを指定
  6. configure: Query Suggestionsを表示させる件数
  7. suggestionHits: 外部ファイル化。パラメータとしてどのdivに表示させるかを渡す
search.addWidgets([
  instantsearch.widgets.searchBox({ // 1.
    container: '#searchbox',
  }),
  instantsearch.widgets.configure({ // 2.
    hitsPerPage: 4
  }),
  productHits({  // 3.
    container: '#product-hits'
  }),
  instantsearch.widgets.refinementList({  // 4.
    container: '#brand-refinement',
    attribute: 'brand',
    limit: 6
  }),
  instantsearch.widgets.index({   // 5.
    indexName: 'query_suggestions'
  }).addWidgets([
    instantsearch.widgets.configure({  // 6.
      hitsPerPage: 16,
    }),
    suggestionHits({ // 7.
      container: '#query-suggestions'
    })
  ])
]);

最後に search.start()。たまにコレを忘れてて、アレっ?ってなる時があったりなかったり…笑

search.start();

src/app.css

コレといって何かをしているわけではないのですが、ちょっとだけ変更したので。src/index.cssは何も手を加えていません。

.ais-InstantSearch {
  max-width: 1100px;
  margin: 0 auto;
  padding: 1rem;
}
.searchbox {
  margin-bottom: 2rem;
}
.suggestion-hits{
  padding-left: 10px;
  width:300px;
  height:800px;
  float: left;
}
.product-hits{
  padding-left: 10px;
  width:500px;
  height:800px;
  float: left;
}
.refinement-container{
  padding-left: 30px;
  width:200px;
  height:800px;
  float: left;
}

src/productHits.js

widgetParams.containerで渡ってきた #product-hits というdivタグに、Algoliaから取得した商品の検索結果を ul と li でリスト表示するように整形するわけですが、インポートしたInstantSearch.jsのconnectHitsを使ってexportすることで、app.jsでそのまま扱えます。

あまり解説するようなところもありませんが、 h2 で商品名(name)、p で カテゴリー(categories[0])、pで価格(price)、画像用のdivの中でimgタグのsrc属性に画像URL(image)を指定しています。

import { connectHits } from 'instantsearch.js/es/connectors';

const products = ({widgetParams, hits}, isFirstRender) => {

  document.querySelector(widgetParams.container).innerHTML = `
    <ul class="federated-products">
      ${hits
        .map(
          hit => 
            `<li>
              <article class="hit">
              <h2>${hit.name}</h2>
              <p class="hit-category">category: ${hit.categories[0]}</p>
              <p>
                price: <span class="hit-em">$</span>
                <strong>${hit.price}</strong>
              </p>
              <div class="hit-image-container">
                <img src="${hit.image}" class="hit-image">
              </div>
            </li>` 
        ).join('')}
    </ul>
  `;
};

export const productHits = connectHits(products)

特にカテゴリーのリストの0番目とかちょっとわかりにくいかもしれないのですが、Algolia Indexに入っているデータがリストになっているというだけです。

実際にビジネスで使おうとした場合は、クリックした時に商品詳細ページに飛んだり、直接検索結果から買い物かごに入れたり、お気に入りボタンに追加したりとか、そういったところもこの部分に入ってくるので、結構行数的に長くなりがちなところかなと思います。

src/suggestionHits.js

こちらもimport/export含めて商品検索と実装はほぼ同様です。index widgetで指定された query_suggestions というAlgoliaが作ってくれる(Standard/Premiumどちらのプランでも利用可能です)Query Suggestions用のインデックスに対してクエリを投げた結果を単純に表示しているだけです。

import { connectHits } from 'instantsearch.js/es/connectors';

const suggestions = (renderOptions, isFirstRender) => {
  const {hits, widgetParams} = renderOptions;

  document.querySelector(widgetParams.container).innerHTML = `
    <ul class="suggestions-lint">
      ${hits
        .map(
          item => 
            `<li class="suggestions-hit">
              ${item.query}
            </li>` 
        ).join('')}
    </ul>
  `;
};

export const suggestionHits = connectHits(suggestions)

クエリサジェストについては、ハイライトを入力した言葉以外にするとか、モバイルの場合はTap-Aheadとか色々あると思うのですが、今回は一旦端折らせていただければと思います。(なので、サジェストされた文字列をクリックしても何も起こりません…)

出来上がったもの

例えば amazon で検索した時に、左側のSuggestionsと右側のProducts&Brandsは、それぞれ異なるインデックスにアクセスをしにいっていますが、一文字タイプするごとに両方の検索結果が変わっていきます。

2020年7月の価格改定から、こういった複数インデックスに対するアクセスでも1HTTPリクエストで扱われるものに関しては1回の検索リクエストとなっているので、Federated Search化してアクセスするインデックスが増えても料金的にも安心です。


ということで、今回は InstantSearch.js v4のindex widgetを活用してお手軽にFederated Searchを導入しつつ、connectorsを活用してコードの見通しの良い実装をしてみました。

モダンな検索ユーザー体験を構築するのに日頃から様々な工夫をされている方も多いと思いますので、よろしければご参考にしていただければ幸いです。

最後までご覧いただきありがとうございました。

コメント

タイトルとURLをコピーしました