Reactで、正規表現を試せる入力欄を作る方法

​​React​Playwright,TypeScript,Web Worker,正規表現

正規表現の入力欄と試す文字列を用意しておけば、入力するたびに一致した部分とキャプチャグループの中身を表示できます。作るときに気をつけるのは、書きかけの正規表現で部品が消えないこと、長さ 0 の一致で処理が無限に続かないこと、時間のかかるパターンでページが固まらないことの3つです。

ここでは、ライブラリを使わずに RegexTester という部品を作りました。正規表現の実行は runRegex という関数にまとめ、String.prototype.matchAll() で一致をすべて集めます。後半では、同じ関数を Web Worker で動かし、1秒たっても終わらなければ止める形に置き換えました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。Firefox と Safari、スマートフォンでの入力、日本語入力(IME)の変換中の動き、スクリーンリーダーでの読み上げ、d フラグ(一致した位置を返す)は試していません。

正規表現の欄に「/ne/g」、試す文字列に「Neko neko NEKO」と「ねこ ネコ」が入っている。正規表現の欄に「ko」を1文字ずつ入力すると「neko」の1件が強調され、i のチェックボックスを押すと「Neko」「neko」「NEKO」の3件になる。続けて「(」を入力すると一致の表示が消えて赤いエラーが出て、Backspace で消すと3件の表示に戻る。下の帯には「操作 n回目/4回」と一致の件数が出る
正規表現の入力、i フラグの切り替え、書きかけの「(」でエラーが出て消すと戻る様子

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。件数が変わった瞬間だけ、その表示をピンクにしています。撮影のときは、フォントと文字の大きさを記事の CSS から変えています。

正規表現の実行を runRegex にまとめる

画面の部品から切り離して、正規表現を作って実行する関数を先に書きました。後で Worker から呼ぶときも、同じ関数を使います。

ts
export type MatchInfo = {
  index: number; // 一致した位置(UTF-16 の単位)
  end: number;
  text: string;
  captures: (string | undefined)[]; // $1, $2, … の中身
  named: Record<string, string | undefined>; // (?<名前>…) の中身
};

export type RunResult =
  | { ok: true; matches: MatchInfo[]; truncated: boolean }
  | { ok: false; error: string };

export const MAX_MATCHES = 500;

export function runRegex(pattern: string, flags: string, text: string): RunResult {
  let regExp: RegExp;
  try {
    // 一覧を作るために g は必ず付ける。g がオフなら、最初の1件だけを返す
    regExp = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
  } catch (e) {
    return { ok: false, error: e instanceof SyntaxError ? e.message : String(e) };
  }

  const limit = flags.includes('g') ? MAX_MATCHES : 1;
  const matches: MatchInfo[] = [];
  for (const m of text.matchAll(regExp)) {
    if (matches.length === limit) return { ok: true, matches, truncated: limit === MAX_MATCHES };
    matches.push({
      index: m.index,
      end: m.index + m[0].length,
      text: m[0],
      captures: m.slice(1),
      named: { ...m.groups },
    });
  }
  return { ok: true, matches, truncated: false };
}

matchAll() に正規表現を渡すときは、g フラグが必要です。MDN の matchAll() の説明にあるとおり、g がないと TypeError になります(以前の記事「Reactで、テキストエリアの文章を検索して置換する方法」でも確かめました)。そこで、利用者が g を外したときも内部では g を付けて実行し、最初の1件だけを返しています。MDN の match() の説明にあるとおり、match() も g がなければ最初の一致だけを返すので、それと同じ見え方になります。

一致の数には上限を設けました。. のように1文字ずつ一致するパターンを長い文章に当てると、一覧の行がその数だけ並ぶからです。検証環境で . を800文字の文字列に当てたところ、「500件一致(ここで打ち切りました)」と出て、強調は500か所、一覧は500行で止まりました。

m.slice(1) は $1 以降のキャプチャグループの中身で、m.groups は名前付きグループの中身です。MDN のキャプチャグループの説明にあるとおり、(ab)|(cd) の選ばれなかったほうのように、一致に使われなかったグループには undefined が入ります。表示では、空文字列 "" と区別するために「(なし)」と書いています。

入力欄とフラグのチェックボックス

画面の部品は次のとおりです。正規表現の文字列、フラグの文字列、試す文字列の3つを state に持ち、useMemo で runRegex を呼びます。

tsx
import { useMemo, useState, type ReactNode } from 'react';
import { runRegex, type MatchInfo, type RunResult } from './runRegex';

const FLAGS = ['g', 'i', 'm', 's', 'u', 'v', 'y'] as const;

export function RegexTester() {
  const [pattern, setPattern] = useState('(?<year>\\d{4})-(\\d{2})-(\\d{2})');
  const [flags, setFlags] = useState('g');
  const [text, setText] = useState('2026-11-05 猫の健康診断\n2027-02-22 猫の日');
  const result: RunResult | null = useMemo(() => runRegex(pattern, flags, text), [pattern, flags, text]);

  const toggleFlag = (flag: string) =>
    setFlags((prev) =>
      FLAGS.filter((f) => (f === flag ? !prev.includes(f) : prev.includes(f))).join(''),
    );

  return (
    <div className="rx">
      <label className="rx-pattern">
        <span>/</span>
        <input
          aria-label="正規表現"
          value={pattern}
          onChange={(e) => setPattern(e.target.value)}
          spellCheck={false}
          autoCapitalize="off"
          autoComplete="off"
        />
        <span>/{flags}</span>
      </label>
      <fieldset className="rx-flags">
        <legend>フラグ</legend>
        {FLAGS.map((f) => (
          <label key={f}>
            <input type="checkbox" checked={flags.includes(f)} onChange={() => toggleFlag(f)} />
            {f}
          </label>
        ))}
      </fieldset>
      <textarea aria-label="試す文字列" value={text} onChange={(e) => setText(e.target.value)} rows={4} />

      {result === null ? (
        <p className="rx-status">調べています…</p>
      ) : !result.ok ? (
        <p role="alert" className="rx-error">{result.error}</p>
      ) : (
        <>
          <p className="rx-status" aria-live="polite">
            {result.matches.length}件一致{result.truncated && '(ここで打ち切りました)'}
          </p>
          <Highlighted text={text} matches={result.matches} />
          <MatchList matches={result.matches} />
        </>
      )}
    </div>
  );
}

function Highlighted({ text, matches }: { text: string; matches: MatchInfo[] }) {
  const parts: ReactNode[] = [];
  let pos = 0;
  matches.forEach((m, i) => {
    if (m.index > pos) parts.push(text.slice(pos, m.index));
    // 長さ 0 の一致(^ や \b など)は、文字がないので縦線で位置だけを示す
    parts.push(
      m.end === m.index ? <mark key={i} className="rx-empty" /> : <mark key={i}>{m.text}</mark>,
    );
    pos = m.end;
  });
  parts.push(text.slice(pos));
  return <pre className="rx-preview">{parts}</pre>;
}

function MatchList({ matches }: { matches: MatchInfo[] }) {
  return (
    <ol className="rx-list">
      {matches.map((m, i) => (
        <li key={i}>
          <code>{JSON.stringify(m.text)}</code>({m.index}〜{m.end})
          {m.captures.map((c, j) => (
            <div key={j} className="rx-group">${j + 1}: {c === undefined ? '(なし)' : JSON.stringify(c)}</div>
          ))}
          {Object.entries(m.named).map(([name, c]) => (
            <div key={name} className="rx-group">{name}: {c === undefined ? '(なし)' : JSON.stringify(c)}</div>
          ))}
        </li>
      ))}
    </ol>
  );
}

正規表現の欄は、new RegExp() に渡す文字列をそのまま入力する欄です。/…/ で囲む書き方ではないので、\d は \d と打てば済みます。/ もそのまま打てます。検証環境では、a/b で x a/b y の中の a/b に一致しました。前後の / とフラグは、見た目をそろえるために横に添えているだけです。入力欄には、スペルチェックを止める spellCheck={false}(spellcheck 属性の説明)と、自動の大文字化を止める autoCapitalize="off"(autocapitalize 属性の説明)を付けました。正規表現は英単語ではないので、綴りの確認や、スマートフォンの仮想キーボードなどで先頭の文字を大文字にする機能は邪魔になります。

フラグは文字列で持ち、チェックボックスを押すたびに FLAGS の並びで作り直します。押した順に足していくと ig と gi のように表示が変わってしまうので、いつも同じ順になるようにしました。

最初の値では、2つの日付が一致し、一覧には次のように出ました。$1 と year は同じグループです。名前付きのグループにも、ほかのグループと一緒に番号が振られるので、両方に出ます。

text
1. "2026-11-05"(0〜10) / $1: "2026" / $2: "11" / $3: "05" / year: "2026"
2. "2027-02-22"(18〜28) / $1: "2027" / $2: "02" / $3: "22" / year: "2027"

一致した部分の強調は、文字列を一致の前後で切り分け、一致した部分だけを <mark> で囲んで並べています。HTML の文字列を組み立てて dangerouslySetInnerHTML に渡すことはしていません。CSS は次のとおりです。

css
.rx { display: grid; gap: 10px; max-width: 520px; font-size: 15px; color: #1f2a30; }
.rx-pattern { display: flex; align-items: center; gap: 4px; font-family: ui-monospace, monospace; }
.rx-pattern input { flex: 1; font: inherit; padding: 6px 8px; }
.rx-flags { display: flex; flex-wrap: wrap; gap: 12px; border: 1px solid #c8dde3; font-family: ui-monospace, monospace; }
.rx textarea { font: 14px/1.6 ui-monospace, monospace; padding: 6px 8px; }
.rx-preview { margin: 0; padding: 8px; background: #eefafd; font: 14px/1.6 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.rx-preview mark { background: #b8f0fa; color: inherit; border-radius: 2px; }
.rx-preview mark.rx-empty { display: inline-block; width: 0; height: 1.2em; vertical-align: text-bottom; border-left: 2px solid #d85878; background: none; }
.rx-error { color: #b03050; font-family: ui-monospace, monospace; }
.rx-list { margin: 0; padding-left: 1.6em; font-family: ui-monospace, monospace; font-size: 14px; }
.rx-group { padding-left: 1em; color: #067a98; }

フラグを切り替えたときの一致の違い

各フラグの意味は、MDN の正規表現ガイドの「フラグを用いた高度な検索」の表にまとまっています。この部品で切り替えて、一致がどう変わるかを確かめました。

パターンと試す文字列 フラグ 一致
neko と Neko neko NEKO g neko の1件
同上 gi Neko、neko、NEKO の3件
^\d+ と 12 ねこ(改行)34 いぬ g 12 の1件
同上 gm 12 と 34 の2件
a.b と a(改行)b g 0件
同上 gs 改行をはさんだ a\nb の1件
\p{L}+ と p{L} 猫cat gu p、L、猫cat の3件
同上 g p{L} という文字そのものの1件

最後の2行は、間違えても気づきにくい例です。\p{L} は「文字」を表す書き方ですが、MDN の unicode フラグの説明にあるとおり、u がないと \p は単なる p として扱われます。エラーにならず、思わぬ文字列に一致するだけなので、間違いに気づきにくくなります。

書きかけの正規表現は、エラーとして表示する

入力欄では、( を打った直後のように、正規表現として正しくない状態が必ず通ります。MDN の RegExp() コンストラクターの説明にあるとおり、new RegExp() は、正規表現として読めないパターンや、使えないフラグを受け取ると SyntaxError を投げます。runRegex はこれを try / catch で受けて、メッセージを返します。

try / catch がないと、描画中の例外で部品ごと画面から消えてしまいます。検索欄の入力をそのまま正規表現にしたときに起きることは、以前の記事「Reactで、検索した言葉を結果の中で強調して表示する方法」で確かめました。

検証環境の Chromium では、次のメッセージが出ました。エラーのあいだは、一致の強調と一覧を表示しません。冒頭の GIF のように、( を消すと元の表示に戻ります。

入力 表示されたメッセージ
( Invalid regular expression: /(/g: Unterminated group
[a- Invalid regular expression: /[a-/g: Unterminated character class
a{2,1} Invalid regular expression: /a{2,1}/g: numbers out of order in {} quantifier
(?<n>a)(?<n>b) Invalid regular expression: /(?a)(?b)/g: Duplicate capture group name
\(最後が \) Invalid regular expression: //g: \ at end of pattern
a に u と v を両方付けた Invalid flags supplied to RegExp constructor 'guv'

u と v は、MDN の unicodeSets の説明にあるとおり、同じパターンを違う規則で読むので、両方を付けると SyntaxError になります。パターンが正しくても、チェックボックスの組み合わせでエラーになります。そのため、エラーはパターンの欄の横ではなく、一致の結果を出す場所に表示しています。

長さ 0 の一致は、matchAll に進めてもらう

a*、\b、^ のように、文字を1つも含まない一致(長さ 0 の一致)を返すパターンがあります。この部品では、そうした一致を縦線で示しています。検証環境で a* を baac に当てたところ、4件になりました。

text
1. ""(0〜0)
2. "aa"(1〜3)
3. ""(3〜3)
4. ""(4〜4)

MDN の matchAll() の説明にあるとおり、matchAll() がなかったころは、exec() を while で回して一致を集めていました。ただ、長さ 0 の一致では lastIndex が進まないので、同じ位置で一致し続けます。Node.js で、8回で止める条件を入れて試した結果です。

text
exec の while: 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" …(8回で打ち切り)  lastIndex = 0
matchAll    : 0:"" 1:"aa" 3:"" 4:""

exec() のほうは、この条件がなければ終わりません。MDN の exec() の説明でも、長さ 0 の一致がありうるときは、自分で lastIndex を進めるよう注意しています。入力欄には利用者がどんなパターンを書くか分からないので、自分で進めずに済む matchAll() を使いました。MDN の RegExp の Symbol.matchAll の説明にあるとおり、matchAll() は一致が空文字列のとき、lastIndex を1つ先へ進めます。

その「1つ」の数え方は、u フラグで変わります。空のパターンを 🐈a に当てると、u なしでは位置 0、1、2、3 の4件、u ありでは 0、2、3 の3件でした。🐈 は UTF-16 で2つ分の長さがあり、u なしでは、その2つのあいだにも一致します。一覧に出す位置も UTF-16 の単位なので、絵文字のうしろでは、見た目の文字数より大きな数になります。

時間のかかるパターンを入力すると、ページが固まる

正規表現には、文字列によって実行時間が急に長くなるパターンがあります。OWASP の ReDoS の説明に例として出ている ^(a+)+$ に、a を n 個並べて最後に ! を付けた文字列を渡すと、Node.js では次のように時間が延びました。最初の1回には準備の時間が混ざるので、測る前に1回実行しておき、そのあとの時間を測っています。

n 時間
20 11ms
22 26ms
24 105ms
26 419ms
28 1755ms

試す文字列に a を28個と ! を入れておき、パターンの欄を ^(a+)+$ にしたところ、検証環境の Chromium では、ページが次に応答するまで 4228ms かかりました。そのあいだ、ページはほかの操作に応えません。本番用にビルドしたものでは 2158ms でした。useMemo のリファレンスにも、開発環境での測定は正確ではなく、Strict Mode では計算の関数が2回呼ばれる、と書かれています。

入力のたびに実行する部品では、利用者が書いたパターンと文字列の組み合わせしだいで、ページがこのように止まります。

Worker で実行して、1秒で止める

ページを固めないために、runRegex を Web Worker の中で動かしました。MDN の Web Worker の使い方にあるとおり、Worker のスクリプトは画面とは別のスレッドで動きます。Worker 側は、受け取った値で runRegex を呼んで、結果を返すだけです。

ts
/// <reference lib="webworker" />
import { runRegex } from './runRegex';

self.onmessage = (e: MessageEvent<{ pattern: string; flags: string; text: string }>) => {
  const { pattern, flags, text } = e.data;
  self.postMessage(runRegex(pattern, flags, text));
};

これを esbuild で regex-worker.js として別にビルドし、ページと同じ場所に置きました。部品からは、次のフックで呼びます。

ts
import { useEffect, useState } from 'react';
import type { RunResult } from './runRegex';

const TIMEOUT_MS = 1000;

// 正規表現の実行を Worker に任せ、時間がかかりすぎたら Worker ごと止める
// 次の結果が届くまでは、前の結果をそのまま返す(最初だけ null)
export function useRegexWorker(pattern: string, flags: string, text: string): RunResult | null {
  const [result, setResult] = useState<RunResult | null>(null);

  useEffect(() => {
    const worker = new Worker('regex-worker.js');
    const timer = setTimeout(() => {
      worker.terminate();
      setResult({ ok: false, error: `${TIMEOUT_MS / 1000}秒たっても終わらないので、止めました` });
    }, TIMEOUT_MS);

    worker.onmessage = (e: MessageEvent<RunResult>) => {
      clearTimeout(timer);
      worker.terminate();
      setResult(e.data);
    };
    worker.postMessage({ pattern, flags, text });

    // 入力が変わったら、前の実行は結果を待たずに止める
    return () => {
      clearTimeout(timer);
      worker.terminate();
    };
  }, [pattern, flags, text]);

  return result;
}

前の節で確かめたとおり、同じスレッドで正規表現を実行しているあいだは、ほかの処理が動けません。Worker なら画面のスレッドは空いているので、タイマーで時間を計り、実行中の Worker ごと terminate() で止めます。MDN の terminate() の説明にあるとおり、Worker は処理の途中でもすぐに止まります。このフックでは、入力が変わるたびに新しい Worker を作ります。

部品の側で変えるのは、result を作る1行と import だけです。

diff
- const result: RunResult | null = useMemo(() => runRegex(pattern, flags, text), [pattern, flags, text]);
+ const result: RunResult | null = useRegexWorker(pattern, flags, text);

result が null になるのは、最初の結果が届く前だけで、部品はそのあいだ「調べています…」と書くようにしてあります。2回目以降は前の結果を残したまま、次の結果を待ちます。

前の節と同じ条件で ^(a+)+$ を入力したところ、ページは 21ms で応答しました。入力した直後も前の結果(a 1文字ずつの28件)が表示されたままで、約1.3秒後(1320ms)に「1秒たっても終わらないので、止めました」に変わりました。続けてパターンを ^(a+)+!$ に直すと、40ms で1件の一致が表示されました。

時間のかかるパターンを入力して 300ms 後に、すぐ終わるパターンへ書き換える操作も試しました。最初に入力してから約1.5秒たっても、表示は書き換えたあとのパターンの結果のままで、「止めました」のエラーは出ませんでした。入力が変わると、useEffect の片付けの関数で、前の Worker を止めてタイマーも消しているためです。

Worker は最後にまとめて結果を返すので、止めたときは途中までの一致を受け取れず、表示は「止めました」だけになります。長い文章で試すときに1秒では足りなければ、TIMEOUT_MS を延ばします。

​Posted by 86