【React】文字列ハイライトを自前で実装してみた



# 概要
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806222/f0cac3d6-ac61-43ff-5dcf-ff9d1ecce62d.png)
今回は React で文字列のハイライトロジックを作成してみます。

[react-highlight-words](https://www.npmjs.com/package/react-highlight-words) とか便利なものがありますが、お勉強のために自前で組んでみようと思います！

# 実現する機能
- 検索対象が同一単語で複数ある場合でもハイライトする
    - 検索キーワード　　：おはよう
    - 検索検索元の文字列：おはようおはよう
    - ハイライト後の文字：**おはようおはよう**
- 検索文字列が複数個でもハイライトする
    - 検索キーワード①　：おはよう
    - 検索キーワード②　：こんばんは
    - 検索検索元の文字列：おはよう。こんばんは。
    - ハイライト後の文字：**おはよう**。**こんばんは**。
- 検索文字列が複数かつ重複があってもハイライトする
    - 検索キーワード①　：おはよう
    - 検索キーワード②　：よう。こん
    - 検索検索元の文字列：おはよう。こんばんは。
    - ハイライト後の文字：**おはよう。こん**ばんは。

# 実際のコード

```jsx
import _ from "lodash";
import parser from "react-html-parser";
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <h1>ハイライトサンプル</h1>
      <div>
        <Highlighter
          originStr={"僕は行きつけの定食屋でいつも半ライス大盛りと頼みます。"}
          keywords={["僕は", "半ライス", "ライス大盛り"]}
        />
      </div>
    </div>
  );
}

/**
 * キーワードに応じてハイライト開始～終了位置を返却する
 * @param {String} originStr 検索元の文字列
 * @return {Array} keywords 検索キーワード
 */
function makeHighlightIndexes(originStr = "", keywords = []) {
  const getHighlightIndexes = (keyword = "") => {
    const indexes = [];
    for (let i = 0; i < originStr.length; i++) {
      i = originStr.indexOf(keyword, i);
      if (i === -1) break; // ハイライト対象がなければbreak
      for (let j = 0; j < keyword.length; j++) indexes.push(i + j);
    }

    return indexes;
  };

  const highlightIndexes = keywords.flatMap((keyword) =>
    getHighlightIndexes(keyword)
  );

  // ハイライト対象の開始～終了位置を重複削除&昇順にして返却する
  return _.sortBy(_.uniq(highlightIndexes));
}

/**
 * ハイライト処理を実施するコンポーネント
 * @param {String} originStr 検索元の文字列
 * @return {Array} keywords 検索キーワード
 */
function Highlighter({ originStr = "", keywords = [] }) {
  let isInsertStartHtmlTag = true; // HTML開始タグ挿入用フラグ
  const highlightIndexes = makeHighlightIndexes(originStr, keywords);

  const highlightHtmlElement = [...originStr].reduce(
    (accumulator, currentValue, currentIndex) => {
      const isHighlight = highlightIndexes.includes(currentIndex);
      if (isHighlight && isInsertStartHtmlTag) {
        isInsertStartHtmlTag = false;

        return `${accumulator}<mark>${currentValue}`;
      }

      // 次の要素が連番ではない = ハイライト対象ではないのでHTML終了タグを挿入する
      const isInsertEndHtmlTag = !highlightIndexes.includes(currentIndex + 1);
      if (isHighlight && isInsertEndHtmlTag) {
        isInsertStartHtmlTag = true; // 次の要素にHTML開始タグを挿入できようにtrueにする
        return `${accumulator}${currentValue}</mark>`;
      }

      return accumulator + currentValue;
    },
    ""
  );

  // 文字列をHTMLに変換して返却する
  return parser(highlightHtmlElement);
}
```

https://codesandbox.io/p/sandbox/boring-dewdney-9r6qqm

codesandbox でも公開しているので、自由にご参照ください！

# 解説
### makeHighlightIndexes関数

```jsx
/**
 * キーワードに応じてハイライト開始～終了位置を返却する
 * @param {String} originStr 検索元の文字列
 * @return {Array} keywords 検索キーワード
 */
function makeHighlightIndexes(originStr = "", keywords = []) {
  const getHighlightIndexes = (keyword = "") => {
    const indexes = [];
    for (let i = 0; i < originStr.length; i++) {
      i = originStr.indexOf(keyword, i);
      if (i === -1) break; // ハイライト対象がなければbreak
      for (let j = 0; j < keyword.length; j++) indexes.push(i + j);
    }

    return indexes;
  };

  const highlightIndexes = keywords.flatMap((keyword) =>
    getHighlightIndexes(keyword)
  );

  // ハイライト対象の開始～終了位置を重複削除&昇順にして返却する
  return _.sortBy(_.uniq(highlightIndexes));
}
```
キーワードに応じてハイライト開始～終了位置を返却する関数です。

例えば、各引数の値が以下の場合だと戻り値は `[0, 1, 2]` を返却します。
- originStr（検索元の文字列）
    - "おはよう"
- keywords（検索キーワード）
    - ["おは", "よう"]

### Highlighterコンポーネント

```jsx
/**
 * ハイライト処理を実施するコンポーネント
 * @param {String} originStr 検索元の文字列
 * @return {Array} keywords 検索キーワード
 */
function Highlighter({ originStr = "", keywords = [] }) {
  let isInsertStartHtmlTag = true; // HTML開始タグ挿入用フラグ
  const highlightIndexes = makeHighlightIndexes(originStr, keywords);

  const highlightHtmlElement = [...originStr].reduce(
    (accumulator, currentValue, currentIndex) => {
      const isHighlight = highlightIndexes.includes(currentIndex);
      if (isHighlight && isInsertStartHtmlTag) {
        isInsertStartHtmlTag = false;

        return `${accumulator}<mark>${currentValue}`;
      }

      // 次の要素が連番ではない = ハイライト対象ではないのでHTML終了タグを挿入する
      const isInsertEndHtmlTag = !highlightIndexes.includes(currentIndex + 1);
      if (isHighlight && isInsertEndHtmlTag) {
        isInsertStartHtmlTag = true; // 次の要素にHTML開始タグを挿入できようにtrueにする
        return `${accumulator}${currentValue}</mark>`;
      }

      return accumulator + currentValue;
    },
    ""
  );

  // 文字列をHTMLに変換して返却する
  return parser(highlightHtmlElement);
}
```

ハイライト処理を実施するコンポーネントです。

少し複雑なので細かめに説明します。
例として各引数の値が以下の場合を想定して進めます。
- originStr（検索元の文字列）
    - "おはようございます"
- keywords（検索キーワード）
    - ["おは", "ござ"]


```jsx
const highlightIndexes = makeHighlightIndexes(originStr, keywords);
```

まず、`highlightIndexes` は `[0, 1, 4, 5]` を返却します。
これは「おはようございます」に対して「おは」と「ござ」がハイライト対象ということになります。

```jsx
const highlightHtmlElement = [...originStr].reduce(
    (accumulator, currentValue, currentIndex) => {/* 中略 */}, "")
```

ハイライトの開始と終了位置に mark タグを挿入して（この部分は後述で説明します）、最終的に一つの文字列にしたいので reduce関数を用いてます。


```jsx
const isHighlight = highlightIndexes.includes(currentIndex);
if (isHighlight && isInsertStartHtmlTag) {
    isInsertStartHtmlTag = false;
    return `${accumulator}<mark>${currentValue}`;
}
```
ハイライト対象（おは or ござ）かつ、その先頭文字（お or ご）の場合は mark 開始タグを挿入します。

```jsx
// 次の要素が連番ではない = ハイライト対象ではないのでHTML終了タグを挿入する
const isInsertEndHtmlTag = !highlightIndexes.includes(currentIndex + 1);
if (isHighlight && isInsertEndHtmlTag) {
    isInsertStartHtmlTag = true; // 次の要素にHTML開始タグを挿入できようにtrueにする
    return `${accumulator}${currentValue}</mark>`;
}
```

ハイライト対象（おは or ござ）かつ、次の文字が（よ or い）の場合は mark 終了タグを挿入します。

```jsx
return accumulator + currentValue;
```
ハイライト対象以外の文字は単純に累積します。


```jsx
// 文字列をHTMLに変換して返却する
return parser(highlightHtmlElement);
```

`highlightHtmlElement` は mark タグ混みの文字列となっているので、HTML オブジェクトに変換が必要になります。
なので、`react-html-parser` を用いて変換をしています。

以上。

# 終わりに
もっと複雑になるかなと思ってましたが、意外とシンプルに実装できました。
久々にこんなに頭を使いました笑
今度は全角 ⇔ 半角を変換してハイライト表示できたらなと考えてます！
今回も見ていただきありがとうございました🙇‍♂️