regex 总览待审核
g · lastIndex · 有状态匹配

有状态的全局匹配:glastIndex 与只在第二次调用才暴露的状态问题

g 标志不只是「找全部」——它让一个 regex 对象背上了一个可变状态 lastIndex。 这个藏在对象里的游标,是一连串经典 bug 的源头,也同时藏着两个「为什么」的答案: 空匹配为什么不会死循环matchAll 为什么强制要 g。 这页从一个「同样的调用、同样的输入,结果却在变」的现象开始,一路拆到底。

现象:re.test(s) 连续调用两次,结果会变

下面的 re同一个对象(只在你改 pattern/输入时才重建)。反复点「再调用一次」, 喂的是同一个字符串——可结果在 true / false 间循环跳变。 根因:带 g(或 y)时,test / exec 会从 lastIndex 处往后找, 并把它推到这次命中的末尾;找不到了就归零。把 g 关掉,游标恒为 0,结果就稳定了。

试着删掉 g 看变化 · y 同样有状态

游标位置( = 当前 lastIndex · 绿框 = 这次命中的子串)

实际工程中的问题:把一个带 g 的 regex 提到循环外当「常量」复用,然后在循环里 RE.test(x) 当布尔判断——它会因为残留的 lastIndex 产生间歇性误判。 结论:只想判断是否包含别加 g(用 str.includes 或无 gtest); 真要复用带 g 的对象做迭代,每轮自己 re.lastIndex = 0 复位。

空匹配为什么不死循环:"aaabc".replace(/a*/g, "|") 单步走

a* 能匹配零个 a,也就是能匹配「空」(空匹配从何而来、零宽匹配的全貌见 零宽匹配页)。全局替换靠不停 exec 推进 lastIndex 来扫完整串—— 可一旦某次命中是的,lastIndex原地不动,下次还从这里命中空……死循环。 规范的破法:命中空匹配后,强制把 lastIndex 再 +1。逐步看这条规则怎么把 aaabc 变成 ||b|c|(注意开头那个 | 正是空匹配留下的)。

pattern 固定 /a*/g

输入纸带(绿 = 这次 exec 命中区间 · 红竖线 = 命中的是空匹配 ∅)

let out = '', last = 0, m;
re.lastIndex = 0;
while ((m = re.exec(s)) !== null) {
  out += s.slice(last, m.index) + '|';  // 命中点之前的原文 + 替换符
  last = m.index + m[0].length;
  if (m[0] === '') re.lastIndex++;      // ← 空匹配! 不 +1 就原地死循环
}
out += s.slice(last);                    // 拼上尾部剩余
所以「空匹配 +1」不是 hack,是规范行为。ECMAScript 的 RegExp.prototype[Symbol.replace] / matchAll 内部都有这条 「若本次匹配长度为 0,则 lastIndex 前进一格」的推进规则——否则任何能匹配空的 pattern (a*\b(?=))都会让全局操作卡死。

g vs y(sticky):跳着找 vs 锚定不跳

两者都用 lastIndex,区别只在失配时怎么办:g 允许「从 lastIndex往后扫, 跳过不匹配的字符去找下一处」;y 则要求必须正好在 lastIndex 处命中,差一个字符就直接失败、归零。 下面对同一个 pattern 跑满 exec 循环,看两边各停在哪:

为什么写词法分析器(lexer / tokenizer)都用 y:token 必须首尾相连、不许中间跳过任何字符。 y 锚定 lastIndex 的特性正好把「这里要么接上一个合法 token,要么就是语法错误」表达了出来; 用 g 反而会悄悄跳过非法字符,把错误吞掉。本仓库 自研引擎的 token 推进就是这个语义。

matchAll 取代手写 while (exec) 循环

要拿到全部命中(含捕获组),老写法是手动 while ((m = re.exec(s))) 循环——你得自己管 lastIndex、 自己处理空匹配 +1、还共享着那个有状态的对象,稍有疏漏就会重现前两节的问题。matchAll 把这些全托管了, 每次 yield 一个独立的 match(带 index / groups / 命名组),且不污染你传入的对象状态:

两种写法 · 同样的结果matchAll 无需手动管理状态、无副作用

      

    
matchAll 为什么强制要 g?没有 g 的 regex 语义上「只找一处」, 可 matchAll 的名字与返回(一个遍历所有命中的迭代器)却承诺「找全部」——两者直接矛盾。 ECMAScript 选择当场抛 TypeError把这个歧义显式暴露,而不是猜你到底想要哪个。下面是实际运行得到的报错:
"a1b2".matchAll(/\d/) 少了 g

  

收尾:换个引擎 / 换门语言,「全局推进」的规则就变了

「命中后怎么推进 lastIndex」并非天经地义,只是 ECMAScript 的一种选择。 换 sed、换 Rust、换 Raku,同一条 pattern 能跑出不一样的命中集合。下面两组对照, 第一组 JS 跑不出(只能讲解 + 列出结果),第二组用 JS 实现两种推进策略实际运行对比:

A. 紧邻空匹配:sed / Rust 会「吞掉」它

同样 a*aaabc,JS 在「aaa 命中结束处」还会再产出一个空匹配(于是开头是双 |); 而 sed / Rust regex 规定空匹配不能紧贴上一个匹配的末尾,那个空匹配被跳过:

差别只在一条规则:JS——命中后无条件推进,空匹配照样产出(仅靠 +1 防死循环); sed / Rust(及 PCRE2 默认)——「刚结束一个匹配,紧接着的零宽匹配作废」。 所以同一个 /a*/,JS 给 ||b|c|,它们给 |b|c|。规则不同,不是谁错。

B. Raku 三副词::g / :ov / :ex

Raku 把「推进策略」直接做成可选副词::g 全局(不重叠,= JS 的默认)、 :ov 重叠(每个起点都试一次)、:ex 穷举(每个起点 × 每种长度)。 JS 没有后两个,但它们本质就是怎么挪起点 / 收不收长度——我现写 overlap()exhaustive() 跑出同样结果:

:g 全局 · 不重叠(matchAll)

:ov 重叠 · 每个起点取一次(sticky y 逐位)

:ex 穷举 · 每个起点 × 每种长度

串起来看:第一节那个「从 lastIndex 往后找、命中后推进」的循环,正是 syntax 页顶部那个实时匹配器内部在做的事(它也手动 +1 防空匹配死循环); 而当 pattern 带回溯结构((a+)+ 之类)时,单次 exec 本身就可能炸开成指数级步数—— 那是 catastrophic 页的主题。一句话收束:lastIndex 是 JS 把「全局推进」这个本可有多种选择的策略,固化成了一个挂在对象上的可变游标——好用,但记得它有状态。