有状态的全局匹配:g、lastIndex 与只在第二次调用才暴露的状态问题
g 标志不只是「找全部」——它让一个 regex 对象背上了一个可变状态 lastIndex。
这个藏在对象里的游标,是一连串经典 bug 的源头,也同时藏着两个「为什么」的答案:
空匹配为什么不会死循环、matchAll 为什么强制要 g。
这页从一个「同样的调用、同样的输入,结果却在变」的现象开始,一路拆到底。
现象:re.test(s) 连续调用两次,结果会变
下面的 re 是同一个对象(只在你改 pattern/输入时才重建)。反复点「再调用一次」,
喂的是同一个字符串——可结果在 true / false 间循环跳变。
根因:带 g(或 y)时,test / exec 会从 lastIndex 处往后找,
并把它推到这次命中的末尾;找不到了就归零。把 g 关掉,游标恒为 0,结果就稳定了。
游标位置(▸ = 当前 lastIndex · 绿框 = 这次命中的子串)
g 的 regex 提到循环外当「常量」复用,然后在循环里
RE.test(x) 当布尔判断——它会因为残留的 lastIndex 产生间歇性误判。
结论:只想判断是否包含就别加 g(用 str.includes 或无 g 的 test);
真要复用带 g 的对象做迭代,每轮自己 re.lastIndex = 0 复位。
空匹配为什么不死循环:"aaabc".replace(/a*/g, "|") 单步走
a* 能匹配零个 a,也就是能匹配「空」(空匹配从何而来、零宽匹配的全貌见
零宽匹配页)。全局替换靠不停 exec 推进 lastIndex 来扫完整串——
可一旦某次命中是空的,lastIndex 就原地不动,下次还从这里命中空……死循环。
规范的破法:命中空匹配后,强制把 lastIndex 再 +1。逐步看这条规则怎么把
aaabc 变成 ||b|c|(注意开头那个双 | 正是空匹配留下的)。
/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); // 拼上尾部剩余
RegExp.prototype[Symbol.replace] / matchAll 内部都有这条
「若本次匹配长度为 0,则 lastIndex 前进一格」的推进规则——否则任何能匹配空的 pattern
(a*、\b、(?=))都会让全局操作卡死。
g vs y(sticky):跳着找 vs 锚定不跳
两者都用 lastIndex,区别只在失配时怎么办:g 允许「从 lastIndex 起往后扫,
跳过不匹配的字符去找下一处」;y 则要求必须正好在 lastIndex 处命中,差一个字符就直接失败、归零。
下面对同一个 pattern 跑满 exec 循环,看两边各停在哪:
y:token 必须首尾相连、不许中间跳过任何字符。
y 锚定 lastIndex 的特性正好把「这里要么接上一个合法 token,要么就是语法错误」表达了出来;
用 g 反而会悄悄跳过非法字符,把错误吞掉。本仓库 自研引擎的 token 推进就是这个语义。
用 matchAll 取代手写 while (exec) 循环
要拿到全部命中(含捕获组),老写法是手动 while ((m = re.exec(s))) 循环——你得自己管 lastIndex、
自己处理空匹配 +1、还共享着那个有状态的对象,稍有疏漏就会重现前两节的问题。matchAll 把这些全托管了,
每次 yield 一个独立的 match(带 index / groups / 命名组),且不污染你传入的对象状态:
matchAll 为什么强制要 g?没有 g 的 regex 语义上「只找一处」,
可 matchAll 的名字与返回(一个遍历所有命中的迭代器)却承诺「找全部」——两者直接矛盾。
ECMAScript 选择当场抛 TypeError把这个歧义显式暴露,而不是猜你到底想要哪个。下面是实际运行得到的报错:
收尾:换个引擎 / 换门语言,「全局推进」的规则就变了
「命中后怎么推进 lastIndex」并非天经地义,只是 ECMAScript 的一种选择。
换 sed、换 Rust、换 Raku,同一条 pattern 能跑出不一样的命中集合。下面两组对照,
第一组 JS 跑不出(只能讲解 + 列出结果),第二组用 JS 实现两种推进策略实际运行对比:
A. 紧邻空匹配:sed / Rust 会「吞掉」它
同样 a* 配 aaabc,JS 在「aaa 命中结束处」还会再产出一个空匹配(于是开头是双 |);
而 sed / Rust regex 规定空匹配不能紧贴上一个匹配的末尾,那个空匹配被跳过:
/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 把「全局推进」这个本可有多种选择的策略,固化成了一个挂在对象上的可变游标——好用,但记得它有状态。