
一批记录要逐个校验有没有四位数编号,20 条里每条都带一个。循环里反复调用同一个正则的 test(),跑完只过了 10 条,漏掉的正好是偶数位。把同一个模式改成在循环体里现建一个字面量,20 条全过。数据一样,模式也一样,差别只在正则对象身上的一个属性。
同一个正则连测六次
串是 20260927,里面有 2026 和 0927 两组四位数。同一个正则连着测六次,答案在 true、true、false 之间循环,第三次会说这个串里没有四位数。
== 1. 同一个 /g 正则,对同一个串连测六次 ==
第 1 次 test('20260927') -> true lastIndex=4
第 2 次 test('20260927') -> true lastIndex=8
第 3 次 test('20260927') -> false lastIndex=0
第 4 次 test('20260927') -> true lastIndex=4
第 5 次 test('20260927') -> true lastIndex=8
第 6 次 test('20260927') -> false lastIndex=0
带 g 的正则对象是有状态的:命中之后 lastIndex 停在匹配末尾的位置,下一次从那里往后找;找不到就把它归零,于是下一次又从 0 开始。上面那六行正好把一个长度为三的周期摆出来了,第二次命中的是 0927 那一段。
去掉 g 之后同样的六次全是 true,lastIndex 一直是 0。
== 2. 去掉 g 之后同样的六次 ==
第 1 次 test('20260927') -> true lastIndex=0
第 2 次 test('20260927') -> true lastIndex=0
第 3 次 test('20260927') -> true lastIndex=0
第 4 次 test('20260927') -> true lastIndex=0
第 5 次 test('20260927') -> true lastIndex=0
第 6 次 test('20260927') -> true lastIndex=0
错法一:正则提到模块顶层,全局共用
真实场景里的写法通常更整齐:正则提到模块顶层定义一次,校验函数里直接用它。20 条记录的对照结果是命中 10 条,逐条看下来是 o 和 x 交替出现的。
== 3. 批量校验:20 条记录里都含一个四位数 ==
预期:20 条全命中
共享一个 /g 对象 test -> 命中 10 条
每次写新的字面量 test -> 命中 20 条
match 取值(不用 lastIndex)-> 命中 20 条
逐条结果(o=命中,x=漏掉):
共享对象 oxoxoxoxoxoxoxoxoxox
新字面量 oooooooooooooooooooo
漏掉的下标:2,4,6,8,10,12,14,16,18,20
交替的原因不复杂:上一条命中之后停在串的中途,下一条从那个偏移开始找,长度不够就直接失败,失败之后归零,所以再下一条又对。丢的是整整一半,而且丢哪些跟输入顺序有关。
同一台机器上把正则写进循环体现建(/\d{4}/.test(r)),20 条全过。字面量不在循环外缓存,每次求值都是一个新对象,也就没有继承下来的位置。用 match 取值的写法同样是 20 条。
错法二:break 之后接着用同一个正则
提取场景里常见的是 while 加 exec,取够条数就跳出。跳出之后正则对象的 lastIndex 停在中间,下一轮再从同一个串上取,前面那两条就没了。
== 4. while (exec) 提前 break 之后再跑 ==
第一轮取两条后 break:拿到 11,22,此时 lastIndex=5
就在同一个串上再跑一遍:拿到 33,44(期望是 11,22,33,44)
这类丢数据的麻烦在于它不改值,只是让结果少几条。少的是谁跟上一轮取了几条有关,两次运行的结果看起来都不算错。
把 lastIndex 当成只读的第三种写法
有人以为只有 exec 会推进 lastIndex,test 不碰它。实测是两种都推进,而且失败的那一次会把它归零。归零这一步最容易骗人:出错之后下一次又对了,「有时对有时错」就被当成偶发,查不下去。
search 和 match 不走 lastIndex 这条路,同一个正则对象在被 test 推过之后,它们照样从头匹配。
== 6. search / match 不受影响的原因 ==
先跑一次 test,lastIndex=4
接着 '2026'.match(re3) -> ["2026"](match 会先把 lastIndex 归零)
'xx2026'.search(re3) -> 2(search 不看 lastIndex)
同一个正则既校验又提取
校验用 test,提取用 exec 或者 match,两处共用了一个正则对象。match 会先把 lastIndex 归零,于是加了提取逻辑之后校验突然正常了,把提取那行注释掉又不正常。改一处、坏一处,看着像玄学,实际是两段代码在抢同一个属性。
手动清零,位置没清干净
知道要清零之后,常见写法是在失败分支里写 re.lastIndex = 0。命中路径没清,下一条还是从中途开始。文件里如果有两个带 g 的正则(一个校验、一个提取),清了外面那个也不解决问题。
什么时候非要带 g
要从一段文本里取回所有命中,g 是必须的,matchAll 和 match 都要求带 g。这种用法一般是一次性的,正则不跨调用活着,没有状态问题。
const line = "单号 1001,单号 1002,单号 1003";
for (const m of line.matchAll(/\d{4}/g)) console.log(m[0]);
真正危险的是另一类:正则被提到顶层,跨请求活着,还带着 g。要么把 g 去掉,要么每次进出都显式管一次位置。
换掉之后
// 校验用的正则不带 g,天然无状态,可以放心提到顶层
const HAS_CODE = /\d{4}/;
// 提取时按调用新建,或者用 matchAll(它要求带 g,但对象是这次新建的)
function codes(line) {
return [...line.matchAll(/\d{4}/g)].map((m) => m[0]);
}
// 一定要复用带 g 的对象时,进出各管一次
function testOnce(re, s) {
re.lastIndex = 0;
try {
return re.test(s);
} finally {
re.lastIndex = 0;
}
}
我现在的规矩是长期活着的正则不带 g,带 g 的只出现在一次性的提取里。这条规矩的成本是零,省下的是那种「隔一条漏一条」的排查时间。
如果代码里已经有共享的带 g 正则,先别改逻辑,找个用例把同一批数据连测两遍:两遍结果不一样,就是状态问题。这个对照花不了两分钟,比盯着一次结果猜要快。