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