四個都合法的字串,兩次被判 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(),恰好是這一類裡最容易被當成純函式的另一個。
圖 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。頁面對於輸入長度沒有任何截斷。
圖 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 輪流呼叫。先把「一個物件、多次呼叫」拆開試一次,再懷疑模式本身。