侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 135 篇文章
  • 累计收到 7 条评论

共享一个 /g 正则,20 条记录只测出 10 条

2026-9-27 / 0 评论 / 7 阅读

共享一个 /g 正则,20 条记录只测出 10 条

一批记录要逐个校验有没有四位数编号,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 正则,先别改逻辑,找个用例把同一批数据连测两遍:两遍结果不一样,就是状态问题。这个对照花不了两分钟,比盯着一次结果猜要快。

    🤞 分享