四个都合法的字符串,两次被判 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 轮流连着调用。先把「一个对象、多次调用」拆开试一次,再怀疑模式本身。