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 開始找,沒有 fooassert.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 的動態規則處理,那是規則內容的另一個問題。
參考資料: