818 字
4 分鐘

RegExp test 一下 true 一下 false?檢查 g、y 與 lastIndex

同一個 /foo/g,第一次 test('foo') 是 true,第二次卻是 false。把它放進 filter(),兩筆相同資料也可能只留下其中一筆。先查看正則物件的 lastIndex:帶 g 或 y 的一般 RegExp 會保存搜尋位置;驗證不同欄位時,通常應使用不帶這兩個旗標的獨立規則。

把結果和 lastIndex 一起記錄#

MDN test 文件說明,global 正則成功後會推進 lastIndex,失敗則歸零;換一個輸入字串也不會在呼叫前自動歸零。以下可存成獨立的 regexp-state.mjs,執行 node regexp-state.mjs:

import assert from 'node:assert/strict';
const pattern = /foo/g;
const trace = [];
for (let i = 0; i < 3; i += 1) {
const matched = pattern.test('foo');
trace.push([matched, pattern.lastIndex]);
}
assert.deepEqual(trace, [[true, 3], [false, 0], [true, 3]]);
const shared = /foo/g;
assert.equal(shared.test('foo'), true);
assert.equal(shared.test('foo!'), false); // 從索引 3 開始找,沒有 foo
assert.equal(shared.lastIndex, 0);
const inFilter = /foo/g;
assert.deepEqual(['foo', 'foo'].filter(text => inFilter.test(text)), ['foo']);
console.log('重複呼叫、跨字串與 filter 的狀態已重現');

這些輸入沒有隨機性,問題也不需要先歸因於 React、Vue 或非同步更新。除錯時先記錄 source、flags、呼叫前後的 lastIndex 與輸入長度;真實資料含敏感內容時,不必把整個欄位印到 log。

欄位驗證用獨立規則,掃描器保留游標#

若需求只是確認每個值是否符合規則,在建立規則時就移除 g/y。還要決定「包含」和「整欄符合」:/foo/ 會接受 xfoo;下面的商品碼契約則要求整欄為兩個大寫英文字母、連字號和三位數字,並拒絕換行。

import assert from 'node:assert/strict';
const sku = /^[A-Z]{2}-[0-9]{3}$/;
function isSku(value) {
return typeof value === 'string' &&
!/[\r\n\u2028\u2029]/u.test(value) && sku.test(value);
}
assert.deepEqual(['AB-001', 'AB-001'].map(isSku), [true, true]);
for (const value of ['xAB-001', 'AB-001x', 'ab-001', 'AB-001\n', '', null, 123]) {
assert.equal(isSku(value), false);
}
console.log('獨立欄位驗證通過');

這個規則只定義範例商品碼,不是通用帳號、email 或 Unicode 字母驗證器。typeof 檢查也避免 test() 把非字串值自動轉成字串後意外通過。

若既有掃描器確實需要 g 或 y,不要直接刪旗標,否則會改掉它的搜尋語意。需要借用同一個內建正則做一次從頭檢查時,可以保存再還原游標;以下限定普通、可寫入 lastIndex 的 RegExp,沒有處理自訂 exec()、Proxy 或非同步共用:

import assert from 'node:assert/strict';
function testFromStart(pattern, text) {
const savedIndex = pattern.lastIndex;
try {
pattern.lastIndex = 0;
return pattern.test(text);
} finally {
pattern.lastIndex = savedIndex;
}
}
const scanner = /foo/g;
scanner.lastIndex = 7;
assert.equal(testFromStart(scanner, 'foo'), true);
assert.equal(testFromStart(scanner, 'bar'), false);
assert.equal(scanner.lastIndex, 7);
const sticky = /foo/y;
sticky.lastIndex = 1;
assert.equal(sticky.test('xfoo'), true);
assert.equal(sticky.lastIndex, 4);
sticky.lastIndex = 0;
assert.equal(sticky.test('xfoo'), false);
assert.equal(sticky.lastIndex, 0);
console.log('借用游標還原與 sticky 邊界通過');

lastIndex 文件區分 sticky 與 global:y 必須在指定位置成功,g 可以繼續向後搜尋。因此「先歸零」只解決起點,不會讓兩種旗標等價,也不會自動把搜尋變成整欄驗證。一般欄位 validator 優先使用自己的無游標規則,避免和掃描器分享狀態。

三個獨立程式區塊由維護流程於 2026-10-28 在 macOS、Node.js v24.13.0 執行。沒有測試瀏覽器表單、框架或自訂 RegExp subclass;這裡不提供效能比較。若問題是使用者輸入的 +、括號被當成正則語法,改看 RegExp.escape 的動態規則處理,那是規則內容的另一個問題。

參考資料:

MDN:RegExp.prototype.test()

MDN:RegExp.lastIndex

ECMAScript:RegExpBuiltinExec

RegExp test 一下 true 一下 false?檢查 g、y 與 lastIndex
https://laplusda.com/posts/javascript-regexp-test-lastindex/
作者
Zero
發佈於
2026-10-28
許可協議
CC BY-NC-SA 4.0