雜記 › 技術筆記 › 帶 g 的正規表示式是有狀態的:同一個 RegExp 連打 4 次 test 只過 2 個

帶 g 的正規表示式是有狀態的:同一個 RegExp 連打 4 次 test 只過 2 個

四個都合法的字串,兩次被判 false

手上是 ['123','456','789','0'] 四個都該通過的候選,用同一條 /^\d+$/g 連著打 4 次 test(),拿到的是 [true,false,true,false]:4 個全部合法的字串,有 2 個被判不合法。換成不帶 g 的同一條模式,結果才是 [true,true,true,true]。

模式一個字沒改,改的是物件狀態。帶 g 的 RegExp 是有狀態的:每命中一次,lastIndex 就停在終點那一格,下一次 test() 從那一格往後讀;錨定模式在那之後沒有剩餘空間,只能返回 false 並把 lastIndex 歸 0。第二次為什麼必輸,答案在第一次的結尾。

八個 API 裡,只有三個真的讀起點

把同一個帶 g 的物件放在不同起點上跑,讀寫關係是可以列乾淨的。

呼叫與 lastIndex 的關係實測
test()/exec()讀起點,命中後寫回終點起點 2 時 test 終點 4;exec 隨起點 1、3、5 移動
matchAll()讀起點,不寫回原物件同一條輸入給出 1+3+5、3+5、5 三種切片
match()/replace()/replaceAll()忽略起點,結束時歸 0三種起點輸出一致
split()/search()完全不讀結果與 lastIndex 無關

驗證程式碼裡最常呼叫的 test(),恰好是這一類裡最容易被當成純函式的另一個。

同一條 /cat/g 連打 6 次 test() lastIndex 終點 3 11 20 0 3 11 true true true false true true

圖 1:同一條 /cat/g 在 "cat dog cat bird cat" 上連打 6 次的終點。第 4 次返回 false 不是沒有這個字,是起點越過最後一個 cat。

這個坑在測試頁上踩不到,只在抄回程式碼那一刻成立

如果你只是在正規表示式測試裡改模式、看高亮,多半碰不上上面那條曲線:範本第 69 行是 new RegExp(pattern, …? flags : flags+'g'),每按一個鍵新建一個物件,並強制補上 g。旗標框留空實測得到 g,填 i 得到 gi,填 y 得到 gy;填 d 或 v 會被第 60 行的白名單 [^gimsuy] 靜靜刪成一個 g。物件每次都是新的,lastIndex 每次都是 0。

坑真正咬人的時刻,是你把頁面上那條 /cat/g 抄回自己的驗證函式、而且只 new 一次之後;「旗標框填 d 沒反應」同理不是瀏覽器不支援,而是被白名單吃了。旗標本身怎麼改變比對範圍,上一篇講即時高亮的已經寫過,這裡只談狀態。

0 寬比對加 exec 迴圈,是真的不收斂

狀態的另一面是遊標可能一步都不走。用 exec 配 while 遍歷 /a*/g 於 "banana",在 Node v24 裡跑到 1000001 輪、102.9ms 還沒結束;同一段在 Chrome 154 裡 300001 輪只花 34.7ms,折算 8645562 次每秒——主執行緒等於永久卡住。換成 \d* 是 132.6ms、(?=) 是 131.0ms、\b(x*) 是 211.4ms,全都不收斂。

救它只要一行:if (m.index === re.lastIndex) re.lastIndex++,加上之後同一條模式 7 輪結束。頁面第 78 行用的是 matchAll,這個保護自帶,所以 a* 於 banana 給出 7 項:["","a","","a","","a",""],4 個空字串就是原地踏步被推進的位置。

替換串的兩條分岔,和一個不解析的入口

把 "2026-10-28" 用 /(\d{4})-(\d{2})/ 去取代時,$1、$2 分別是 2026 與 10,剩下的三件事是站內沒測過的分岔。

  • 分組編號不存在時保留字面:$3 得 "$3-28",$1$2$3$4 得 "202610$3$4-28"。但 $10 在只有 2 個分組時不會當成第 10 分組,它退化為 $1 再黏上一個字面 0,得 "20260-28";$01 才與 $1 等價。
  • 尖括號寫法 $<zz> 有兩種命運:模式裡有命名分組(寫了 (?<year>\d{4}) 之類)時渲染成空字串,輸出 "-28";模式裡沒有命名分組時整段字面保留,輸出 "$<zz>-28"。同一個錯寫法,結果取決於模式而不是取決於替換串。
  • 第二個實際引數換成函式時,回傳值原樣落地,$ 序列一概不再解析——$& 與 $` 在字串寫法裡各有含義,在函式回傳值裡只是普通文字的一部分。

所以改用函式並不會多拿一層更寬鬆的跳脫,它只是把回傳值當成字面值。

頁面只截分組,不截長度

實測 1 萬字配 /\d+/g 是 5.6ms、667 個 mark、1335 個節點、HTML 字串 79368 個位元組;10 萬字同一條模式 36.5ms、6667 個 mark、13335 個節點、793368 個位元組。真正貴的是 0 寬:10 萬字配 /(?=x)/g 花 439.8ms,生成 100000 個 mark、200000 個節點、10500000 個位元組,每個 mark 固定約 105 個位元組的開銷,分段是 matchAll 35.6ms、拼字串 146.3ms、寫 innerHTML 409.7ms。頁面對於輸入長度沒有任何截斷。

mark 數量(對數刻度) 1 萬字 10 萬字 0 寬 10 萬 667 個 6667 個 100000 個

圖 2:同一張頁面在三檔輸入下寫進 DOM 的 mark 數量,條長按對數刻度畫。前兩檔是 \d+,第三檔換成 0 寬的 /(?=x)/g,字數一樣卻多吐出一個數量級。

唯一的截斷在第 104 行的 matches.slice(0,20):21 項時明細少 1 列,100 項時少 80 列,10 萬字那 6667 項少 6647 列,而狀態列照樣寫滿總數。錯誤文案和分組明細都只改 display 不清空(第 70 行與第 112 行),實測會殘留上一次的整句報錯,這也是「明明改對了還在報錯」的一種成因。

Q&A

Q:怎麼讓一個帶 g 的模式安全地重複用?

三選一:呼叫前手動把 lastIndex 寫回 0;命中後先取欄位,別把下一次 test() 當純函式;或去掉 g,全量遍歷交給 matchAll。驗證場景最省事的一條是——正規表示式在驗證函式內部新建,別提到模組頂層。

Q:test() 返回 false 一定是沒比對上嗎?

不一定。帶 g 時它還可能是「起點之後沒有比對」,圖 1 的第 4 次就是這種情況:整條字串裡確實有 3 個 cat,但從 lastIndex 20 之後什麼都沒有。

Q:為什麼我把模式貼回程式碼,行為和網頁上不一樣?

網頁每次按鍵都新建物件(第 69 行),還強行補了 g;你的程式碼裡同一個物件可能被 exec、test、matchAll 輪流呼叫。先把「一個物件、多次呼叫」拆開試一次,再懷疑模式本身。

📎 相關文章

文字比對的代價全在 (m+1)×(n+1) 這張表:6000 行還能跑,8000 行直接堆疊記憶體崩潰 2026-10-28 10 萬個鍵全進 DOM:JSON 檢視的渲染規模、爆棧深度與複製重排 2026-10-28 27760000 個像素零差異、體積漲到 15.881 倍:JPG 轉 PNG 的三層位元組帳 2026-10-28 20 行檔名拆成 18 行兩列:貼進 Excel 只佔 1 列,1e5 變成 100000 2026-10-28 整個資料夾拖進來,候選池只多了 1 個名字:檔案隨機抽取讀不到目錄裡的 2558 個檔案 2026-10-28