0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-04

概要

image.png
今回は React で文字列のハイライトロジックを作成してみます。

react-highlight-words とか便利なものがありますが、お勉強のために自前で組んでみようと思います!

実現する機能

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

実際のコード

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);
}

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

解説

makeHighlightIndexes関数

/**
 * キーワードに応じてハイライト開始~終了位置を返却する
 * @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コンポーネント

/**
 * ハイライト処理を実施するコンポーネント
 * @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(検索キーワード)
    • ["おは", "ござ"]
const highlightIndexes = makeHighlightIndexes(originStr, keywords);

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

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

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

const isHighlight = highlightIndexes.includes(currentIndex);
if (isHighlight && isInsertStartHtmlTag) {
    isInsertStartHtmlTag = false;
    return `${accumulator}<mark>${currentValue}`;
}

ハイライト対象(おは or ござ)かつ、その先頭文字(お or ご)の場合は mark 開始タグを挿入します。

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

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

return accumulator + currentValue;

ハイライト対象以外の文字は単純に累積します。

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

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

以上。

終わりに

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?