🔍

react-instantsearch & MeiliSearchで、ネストされたオブジェクトのHighlightをするカスタマイズをする

2024/08/14に公開

前提

InstantSearchとMeiliSearchについてを軽く説明します。
知ってる方は本題からで大丈夫です

InstantSearchとは

InstantSearchはJavaScriptでalgoliaなどの検索UIを簡単に作ることができるように設計されたライブラリです。

algoliaが作っているものの、algoliaだけじゃなくMeiliSearchなどでもsearchClientが提供されてさえいれば簡単に作れるので検索UIを作る時は素晴らしく使いやすいです。

InstantSearchのcomponentはpreactで実装されていて素のHtmlで使えるのも便利ですが、reactで使う場合はreact-instantsearchというwrapperがあるのでそっちを使います。

今回はあんまり需要ない気もしますが、MeiliSearchを使ってネストされたオブジェクトの検索をするときに、現状のreact-instantsearchだと困ることがあったのでどうすればいいのか書きます

MeiliSearchとは

MeiliSearchが何かを説明するなら、この記事がもっともMeiliSearchをわかりやすく説明してくれているのでそれ以上書くことがないです。

そこから引用させていただくなら

Meilisearch はフランス パリの検索エンジンスタートアップ Meilisearch 社が開発する Rust 製の全文検索サーバーです。読み方はメイリサーチです。

そしてalgoliaと決定的に違うのがオンプレ版のバイナリもOSSになっており、クラウドを使うならマネージドはしてくれるが、self-hostingであるなら無料で使えるのが重要です。
また、使ってみたらものすごく簡単で、pythonで適当に設定するだけでもデフォルトに存在するUIですぐ試せて、全文検索に関する知識ゼロからでも速攻で使えました

僕の場合は、クライアントがクラウドにあげたくない情報を簡単に全文検索できるものを作りたくて、探したら最も妥当そうだったのでこれを使いました。

self-hostingはmac,linux, windowsのOSに対応しているのですごく使いやすい上dockerも提供しているのですぐ使えます

例えばバイナリで起動する場合、

curl -L https://install.meilisearch.com | sh

で、バイナリができるので、それを以下のように実行
master-keyはフロントエンドでも使うので適当な文字列にする

./meilisearch --master-key="aSampleMasterKey"

あとはindexを作り、適当にjsonをadd_documentsするだけでとりあえず試せます。(この辺はさまざまなドキュメントあるので書きません)

MeiliSearch + InstantSearch

MeiliSearchは自分のアプリに組み込む方法として、InstantSearch用のclientを提供しているのでそれを使う方法を公式docsに書いてくれてます。

このQuick Startを真似するだけで、作ったMeiliSearchに対応したUIをすぐに作ることができます。

まずfrontend用のデフォルトAPI Keyができてるので、それを使えばいいです。
meilisearch起動しといて、以下のコマンド実行

curl   -X GET 'http://localhost:7700/keys'   -H 'Authorization: Bearer aSampleMasterKey' | jq

そうするとjsonで結果が見れます。このうちDefault Search API Keyの方を使っときましょう(frontend用にRead Onlyになってます)
以下例

{
  "results": [
    {
      "name": "Default Search API Key",
      "description": "Use it to search from the frontend",
      "key": "1f9d5473cc5b18f4866be6cbb4912ed658825fc1149ffb36e07f4a2c51bd8cf1",
      "uid": "6c798060-d617-4bf9-b5d9-9a5d91adb13c",
      "actions": [
        "search"
      ],
      "indexes": [
        "*"
      ],
      "expiresAt": null,
      "createdAt": "2024-08-13T03:44:20.323677Z",
      "updatedAt": "2024-08-13T03:44:20.323677Z"
    },
    {
      "name": "Default Admin API Key",
        "description": "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend",
     ...
  ],
  "offset": 0,
  "limit": 20,
  "total": 1
}

キーがわかったらReactアプリでライブラリインストールして

npm install react-instantsearch @meilisearch/instant-meilisearch instantsearch.css

以下のようにすると、searchClientを使えます(meilisearchが提供しているライブラリはこのsearchClientを提供するinstant-meilisearchだけで、あとはinstantSearchのライブラリを使います)

import React from 'react';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';

const { searchClient } = instantMeiliSearch(
  'http://localhost:7700',
  '1f9d5473cc5b18f4866be6cbb4912ed658825fc1149ffb36e07f4a2c51bd8cf1' // Default Search API Keyの方を使う
);

あとはサンプルのようにするだけでなんかいい感じになります

import React from 'react';
import { InstantSearch, SearchBox, InfiniteHits } from 'react-instantsearch';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import 'instantsearch.css/themes/satellite.css';
import type { Hit } from 'instantsearch.js';

const App = () => (
  <InstantSearch
    indexName="steam-videogames"// 
    searchClient={searchClient}
  >
    <SearchBox />
    <InfiniteHits hitComponent={Hit} />
  </InstantSearch>
);

const Hit = ({ hit }: {hit: Hit<{id:string,image:string,name:string,description:string}>}) => (
  <article key={hit.id}>
    <img src={hit.image} alt={hit.name} />
    <h1>{hit.name}</h1>
    <p>${hit.description}</p>
  </article>
);

これめちゃくちゃ便利で、InstantSearchのpropsでindexNameを指定し、InfiniteHitsのpropsにHitComponentの指定ができるのでアイテムの型をどう見せるか作ればいいだけです。

本題:Highlightコンポーネントとその問題

Highlightコンポーネント

InstantSearchはさまざまな便利コンポーネントを用意してくれており、自分で実装することもできなくはないのですが、地味にめんどくさい実装系をかなり揃えてあります。
そのうちHighlightコンポーネントは素晴らしく便利なコンポーネントです。
要するに検索で該当する箇所の色を変える機能なのですが、基本的にこれは入れといた方がいいくらいUIにすごく効果あります。

例えばdescriptionでHighlightするなら先ほどのコードを

import React from 'react';
import { InstantSearch, SearchBox, InfiniteHits } from 'react-instantsearch';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import 'instantsearch.css/themes/satellite.css';
import type { Hit } from 'instantsearch.js';

const App = () => (
  <InstantSearch
    indexName="steam-videogames"// 
    searchClient={searchClient}
  >
    <SearchBox />
    <InfiniteHits hitComponent={Hit} />
  </InstantSearch>
);

const Hit = ({ hit }: {hit: Hit<{id:string,image:string,name:string,description:string}>}) => (
  <article key={hit.id}>
    <img src={hit.image} alt={hit.name} />
    <h1>{hit.name}</h1>
    //<p>${hit.description}</p>
    <Highlight attribute="description" hit={hit} />// ここを変更
  </article>
);

ってやるだけで以下の感じでハイライトが入るようになります(便利すぎ!)

問題点

Hightlightコンポーネントは便利なのですが、このattiributeに指定できるものが少し制限があります。
MeiliSearchって結構優秀で、例えば

{
"title": "テスト記事",
"comments":[
    {
    "title": "いいね",
    "description":"めっちゃいいね"
    },
    {
    "title": "微妙",
    "description":"めっちゃ微妙"
    }
}

みたいなやつのcommentsのdescriptionだけを検索するとかもできます。
その場合searchable_attributesというものを設定すればいいのですが、ネストしてる場合.で繋ぐだけでいいです。

例:pythonでの設定

client.index('articles').update_searchable_attributes([
    'comments.description'
])

ってやれば、commentsのdescriptionだけを検索対象にできて非常に便利ではあります。
ただ、これをhighlightしようとした時に問題があります。

Highlightコンポーネントのattibute propsの説明を見ると

The attribute to highlight in the record.
For deeply nested objects, you can specify a dot-separated value like "actor.name".

と書いてあり、ネストされてる場合actor.nameのように.繋ぎでかけると書いてあります。

なので以下のように

<Highlight attribute="comments.description" hit={hit} />

って書けばいけるかと思って書くと、まずTypeScriptなら型エラーを起こします。理由はattributeがhitItemの型を見てるからです。

例えばさっきの例のHitComponentなら

const Hit = ({ hit }: {hit: Hit<{
id:string,
title:string,
comment: {title:string, description:string}[]}//こんな感じのHitのItem型指定をしている
>}
) => (
  <article key={hit.id}>
    <img src={hit.image} alt={hit.name} />
    <h1>{hit.name}</h1>
    <Highlight attribute="comments.description" hit={hit} />// comment.descriptionはItem型にはないと判定される
  </article>
);

という感じです。ただこれならattributeは配列を取れるので

 <Highlight attribute=["comments","description"] hit={hit} />

って書けばいいっぽい。
だがしかし、これでさっきのようなデータを表示してみると何もハイライトされない。どころか何も表示されない。

なんかHighlightコンポーネントの中身が少しおかしいかも?どうなってるか気になったのでgithubを見てみた

Highlightコンポーネントの中身を調べてみる

Highlightコンポーネントが記述されてるページはここ

主に必要そうな部分だけ抜き出すと以下

import {
  getHighlightedParts,
  getPropertyByPath,
  unescape,
} from 'instantsearch.js/es/lib/utils';
import React from 'react';

...

export function Highlight<THit extends Hit<BaseHit>>({
  hit,
  attribute,
  highlightedTagName,
  nonHighlightedTagName,
  separator,
  ...props
}: HighlightProps<THit>) {
  const property =
    getPropertyByPath(hit._highlightResult, attribute as string) || [];
  const properties = Array.isArray(property) ? property : [property];

  const parts = properties.map((singleValue) =>
    getHighlightedParts(unescape(singleValue.value || ''))
  );

  return (
    <HighlightUiComponent
      {...props}
      parts={parts}
      highlightedTagName={highlightedTagName}
      nonHighlightedTagName={nonHighlightedTagName}
      separator={separator}
    />
  );
}

みてみると、getPropertyByPathgetHighlightedPartsという関数がinstantsearch.js/es/lib/utilsでimportされており、それらがなんかしている

以下をみるとgetPropertyByPathには_highlightResultを入れている。

  const property =
    getPropertyByPath(hit._highlightResult, attribute as string) || [];

この_highlightResultが何をしているのかというと、これはMeiliSearchのレスポンスに入っており、単に該当するところに<mark></mark>を追加している。例えば「いいね」が検索ワードなら

{
"title": "テスト記事",
"comments":[
    {
    "title": "<mark>いいね</mark>",
    "description":"めっちゃ<mark>いいね</mark>"
    },
    {
    "title": "微妙",
    "description":"めっちゃ微妙"
    }
}

のようになる。この_highlightResultをごちゃごちゃしてるらしい。

getPropertyByPathを見てみる。ここが問題かも?

instantsearch.jsをみてみると
getPropertyByPath

export function getPropertyByPath(
  object: Record<string, any> | undefined,
  path: string | string[]
): any {
  const parts = Array.isArray(path) ? path : path.split('.');

  return parts.reduce((current, key) => current && current[key], object);
}

この部分は要するに_highlightResultのtitleを撮りたかったらJsonからその要素を取ってくるみたいだが、これ確かに"comments.title"みたいなネストに対応してそうに見えてよくみたらcommentsが配列の場合取れなくね? ということに気づいた。
ただこの場合普通に今回の場合なら_highlightResult.commentsをとってくればいいだけなので、この関数使わなきゃいいだけだ。(本当はプルリク投げたい)

getHighlightedPartsがこのコンポーネントのコア

さらにその_highlightResult.commentsの1要素ずつにgetHighlightedPartsをしているだけ。

getHighlightedParts

import { TAG_REPLACEMENT } from './escape-highlight';

export function getHighlightedParts(highlightedValue: string) {
  // @MAJOR: this should use TAG_PLACEHOLDER
  const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT;

  const splitByPreTag = highlightedValue.split(highlightPreTag);
  const firstValue = splitByPreTag.shift();
  const elements = !firstValue
    ? []
    : [{ value: firstValue, isHighlighted: false }];

  splitByPreTag.forEach((split) => {
    const splitByPostTag = split.split(highlightPostTag);

    elements.push({
      value: splitByPostTag[0],
      isHighlighted: true,
    });

    if (splitByPostTag[1] !== '') {
      elements.push({
        value: splitByPostTag[1],
        isHighlighted: false,
      });
    }
  });

  return elements;
}

使い方は

const properties = Array.isArray(property) ? property : [property];

  const parts = properties.map((singleValue) =>
    getHighlightedParts(unescape(singleValue.value || ''))
  );

というわけで、こう改造すればいい

  (hit._highlightResult?.comments)?.map((comment)=> {
                  // ライブラリ側でもunescapeが使われているので非推奨でも使う
                  const part = getHighlightedParts(unescape(comment.deescription.value))

これで得られるpartは

[{
"value":"めっちゃ", isHighlighted:false
},
{
"value":"いいね", isHighlighted:true
},
]

のような構造になる。
あとはHighlightUIComponentの中身の構造が分かれば自分で再現できる

HighlightUIComponentの中身はcreateHighlightComponent関数で、その中身を見ると

return function Highlight(userProps: HighlightProps) {
    const {
      parts,
      highlightedTagName = 'mark',
      nonHighlightedTagName = 'span',
      separator = ', ',
      className,
      classNames = {},
      ...props
    } = userProps;

    return (
      <span {...props} className={cx(classNames.root, className)}>
        {parts.map((part, partIndex) => {
          const isLastPart = partIndex === parts.length - 1;

          return (
            <Fragment key={partIndex}>
              {part.map((subPart, subPartIndex) => (
                <HighlightPart
                  key={subPartIndex}
                  classNames={classNames}
                  highlightedTagName={highlightedTagName}
                  nonHighlightedTagName={nonHighlightedTagName}
                  isHighlighted={subPart.isHighlighted}
                >
                  {subPart.value}
                </HighlightPart>
              ))}

              {!isLastPart && (
                <span className={classNames.separator}>
                  {/* @ts-ignore-next-line */}
                  {separator}
                </span>
              )}
            </Fragment>
          );
        })}
      </span>
    );
  };

こういう構造。要するにpartを普通にmapしてHilightPartにしてるだけ
HilightPartはcreateHilightPartComponent関数で作られてて、中身は

function createHighlightPartComponent({ createElement }: Renderer) {
  return function HighlightPart({
    classNames,
    children,
    highlightedTagName,
    isHighlighted,
    nonHighlightedTagName,
  }: HighlightPartProps) {
    const TagName = isHighlighted ? highlightedTagName : nonHighlightedTagName;

    return (
      <TagName
        className={
          isHighlighted ? classNames.highlighted : classNames.nonHighlighted
        }
      >
        {children}
      </TagName>
    );
  };
}

これ要するに以下でもいい。

const TagName = subPart.isHighlighted ? 'mark' : 'span';
                        return (
                          <TagName
                             style={
                              {
                                color: subPart.isHighlighted ? 'red' : 'black'
                              }
                            }
                            }
                          >
                            {subPart.value}
                          </TagName> 

isHilightedがtrueの時、<mark></mark>にして、そうでない時spanにしてるだけだ。

つまり別にpartが手に入った時点であとはisHilightedがtrueのやつだけhighlightのクラスつけて協調表示すればいい。

なので最終的にHighlightを作りたいなら

  1. _highlightResultにあるhighlightの結果表示したいプロパティのvalueを抜き出す
  2. それをinstantsearch.js/es/lib/utilsにあるgetHighlightedPartsに入れる
  3. getHighlightedPartsの結果をmapし、subPartごとにisHighlightedがtrueなら<mark></mark>とかで、falseならspanでreturnする

最終のコードは以下

import { InstantSearch,SearchBox, InfiniteHits ,RefinementList,Highlight} from 'react-instantsearch';
import type { Hit } from 'instantsearch.js';
import { getHighlightedParts } from 'instantsearch.js/es/lib/utils';
...
<span>
 {
            // ライブラリの型がネストされた型を想定していないので、ignoreしている
            // @ts-ignore
            (hit._highlightResult?.comments)?.map((comment)=> {
                  // ライブラリ側でもunescapeが使われているので非推奨でも使う
                  const part = getHighlightedParts(unescape(comment.description.value))
                    return (<>
                    {
                       part.map((subPart) => {
                        const TagName = subPart.isHighlighted ? 'mark' : 'span';
                        return (
                          <TagName
                            style={
                              {
                                color: subPart.isHighlighted ? 'red' : 'black'
                              }
                            }
                          >
                            {subPart.value}
                          </TagName> 
                        );
                      })
                    }
                    </>)
                   
            })
          }
</span>

こうなる。

結論

先ほども書いたが、最終的にHighlightを作りたいなら

  1. _highlightResultにあるhighlightの結果表示したいプロパティのvalueを抜き出す
  2. それをinstantsearch.js/es/lib/utilsにあるgetHighlightedPartsに入れる
  3. getHighlightedPartsの結果をmapし、subPartごとにisHighlightedがtrueなら<mark></mark>とかで、falseならspanでreturnする

っていう作り方でいくらでもカスタマイズ可能。というかほぼgetHighlightedParts関数で成立してるので、そいつをimportして作るだけいい。

これgetPropertyByPathをもう少しちゃんと作るだけで解消しそうだからプルリク投げたい。ただそのためにどうやらhit._highlightResultの型も改造する必要があり(この型がそもそもネストした配列を想定してない)結構影響範囲がデカそうなのでとりあえずは自分が使える形にしたまでで今回は終わる

あとこのHighlihgt以外にsnippetという該当箇所以外省略する便利コンポーネントがあるのだが同じ問題はありそう。
ただコードを読んだがこのコンポーネントはほぼHighlightと同じでどこで省略されてるコードがあるのかよくわからなかった・・・誰か詳しい人教えて

Discussion