杂记 › 技术笔记 › 带 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 JPG 转 PNG:27760000 个像素一位没变、体积涨到 15.881 倍,JPEG 的块效应被原样锁进无损容器 2026-10-28 20 行文件名拆成 18 行两列:粘进 Excel 只占 1 列,1e5 变成 100000 2026-10-28 拖进去一整个文件夹,候选池只多了 1 个名字:文件随机抽取读不到目录里的 2558 个文件 2026-10-28