JavaScript 正規表現の g フラグを使ったときの test() の挙動

JavaScript 正規表現の g フラグを使ったときの test() の挙動

さて、突然ですがクイズです。

JavaScript で const regexp = /[abc]/g のあと regexp.test('aa') を何度か実行すると戻り値はどうなるでしょうか。

globalフラグを設定した正規表現でtestを実行
const regexp = /[abc]/g
console.log(regexp.test('aa'))
// ???
console.log(regexp.test('aa'))
// ???
console.log(regexp.test('aa'))
// ???

test の想定外の挙動

正解は… true が2回出力されたあと false が1回出力される です。パッと正解できる人はどれだけいるでしょうか。私は無理でした😂

globalフラグを設定した正規表現のtest実行結果
const regexp = /[abc]/g
console.log(regexp.test('aa'))
// true
console.log(regexp.test('aa'))
// true
console.log(regexp.test('aa'))
// false
// 以下繰り返し

動作を確認してみる

testexec に変えて試してみると原因がわかります。ちなみに test はマッチしたかどうかをブール値で返すのに対し、 exec はマッチした場合に結果オブジェクトを返します。

globalフラグを設定した正規表現のexec実行結果
const regexp = /[abc]/g
regexp.exec('aa')
// ['a', index: 0, input: 'aa', groups: undefined]
regexp.exec('aa')
// ['a', index: 1, input: 'aa', groups: undefined]
regexp.exec('aa')
// null

よく見てみると index が加算され、入力文字列の長さになると null になっています。これが true true false に相当するわけですね。

この動作は仕様通り

MDN を見てみるとこの動作は仕様通りであることがわかります。

グローバルフラグを持つ正規表現の test() の使用

正規表現にグローバルフラグが設定されている場合、 test() は正規表現が所有する lastIndex の値を加算します。

その後にさらに test(str) を呼び出すと、 str を lastIndex から検索します。 lastIndex プロパティは test() が true を返すたびに増え続けます。

メモ: test() が true を返す限り、 lastIndex は別な文字列をテストした場合であっても、リセットされません

RegExp.prototype.test() - JavaScript | MDN

MDNのサンプル
const regex = /foo/g; // "global" フラグを設定

// regex.lastIndex は 0 です。
regex.test('foo')     // true

// regex.lastIndex は 3 です。
regex.test('foo')     // false

// regex.lastIndex は 0 です。
regex.test('barfoo')  // true

// regex.lastIndex は 6 です。
regex.test('foobar')  //false

// regex.lastIndex は 0 です。
// (...以下略)

呼び出しごとに RegExp (regexp) オブジェクトの中で lastIndex が加算され、次はテスト文字列にかかわらずその位置から検索が始まるため、このような挙動になります。

事例紹介

私の場合、下記のように、文字列に特定の文字が含まれるかどうかを判断するときにループ内で test を呼び出していました。

意図したとおりに動かないコード
const regexp = /[-#&]/g // 特殊文字
for (const target of values) {
    if (regexp.test(target)) {
        // 🆖 特殊文字が含まれていても通らないときがある
    }
}

指定した文字が含まれていても if 文を通らないことがあるため、バグになっていました。

解決策

解決策は 2 つです。

  1. g (global) フラグを外す
  2. "文字列".match() に変える

1 が最もシンプルでしょう。仕様のとおり、「グローバルフラグを持つ正規表現」の挙動なのですから、グローバルフラグを付けなければいいのです。余分なものを付けてしまったのがそもそもの間違いです😂

gフラグを除いただけ
const regexp = /[-#&]/ // 特殊文字for (const target of values) {
    if (regexp.test(target)) {
        // 👌 特殊文字が含まれているときに通る
    }
}

2 は少し表現が変わりますが、これでも所望の挙動が得られます。

match関数に変更
for (const target of values) {
    if (target.match(/[-#&]/g)) { // g の有無はあまり関係ない        // 👌 特殊文字が含まれているときに通る
    }
}

おそらく実行速度は 1 のほうが速いでしょう。

まとめ

今回は JavaScript のグローバルフラグを付けた正規表現で、 test 関数や exec 関数を使った場合、予期せぬ挙動になることがあることを紹介しました。

g 1 つで大幅に挙動が変わってしまい、発見も難しいので、正規表現のフラグには気を付けましょう👍

どなたかのお役に立てれば幸いです。

kenzauros