原创

同一正则校验结果交替变化:RegExp.lastIndex 把上次状态带进来了

同一个用户名,第一次校验通过,第二次却失败。输入没变,正则对象也没重新创建,问题可能正是最后这一点:带g或y标志的RegExp保存lastIndex,下一次匹配会从这份状态继续。

校验一个完整字段,通常只需要每次独立判断;扫描一段输入中的多个片段,才需要连续游标。把两种使用方式共用一个全局正则,会让业务布尔结果受上一次调用影响。

把对象状态打印出来,而不只打印 true 或 false

下面程序在现代浏览器控制台或Node.js运行。保存为regex-cursor.js即可。

'use strict';
const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};
const stateful = /^[a-z]+$/g;
const outcomes = [];
for (let i = 0; i < 3; i++) {
  const accepted = stateful.test('alice');
  outcomes.push(accepted);
  console.log('shared validation:', accepted, 'next:', stateful.lastIndex);
}
assert(outcomes.join(',') === 'true,false,true', 'global validation state');

const independent = /^[a-z]+$/;
assert(independent.test('alice') && independent.test('alice'), 'independent validation');
console.log('without g:', independent.test('alice'), independent.test('alice'));

const searching = /\d+/g;
searching.lastIndex = 0;
const found = searching.exec('x12');
assert(found.index === 1, 'global search may skip prefix');
const sticky = /\d+/y;
sticky.lastIndex = 0;
assert(sticky.exec('x12') === null && sticky.lastIndex === 0, 'sticky cannot skip');
sticky.lastIndex = 1;
const exact = sticky.exec('x12');
assert(exact.index === 1 && sticky.lastIndex === 3, 'explicit sticky cursor');
console.log('search index:', found.index, 'sticky exact:', exact[0]);

const empty = /(?:)/gu;
const first = empty.exec('A😀B');
const second = empty.exec('A😀B');
assert(first.index === 0 && second.index === 0 && empty.lastIndex === 0, 'empty exec no progress');
const boundaries = [...'A😀B'.matchAll(/(?:)/gu)].map(match => match.index);
assert(boundaries.join(',') === '0,1,3,4', 'matchAll Unicode advancement');
console.log('empty matchAll indices:', boundaries.join(','));

const original = /[a-z]+/g;
original.lastIndex = 2;
const matches = [...'ab cd'.matchAll(original)].map(match => match[0]);
assert(matches.join(',') === 'cd' && original.lastIndex === 2, 'matchAll copied cursor');
console.log('matchAll starts at copied cursor:', matches, 'original:', original.lastIndex);

前三次共享校验得到true、false、true。第一次匹配到字符串末尾,lastIndex变成5;第二次从这个位置尝试完整字段模式,失败后状态回到0;第三次于是再次成功。它不是随机问题,而是一份完全可重现的状态机。

去掉g以后,重复校验才表达了这里真正需要的合同。每次手工把lastIndex清零也能得到独立结果,但会继续留下一个不必要的共享可变对象,后续有人忘记重置,问题就再次出现。

g 会向后寻找,y 要求在游标位置开始

程序用x12区分搜索与粘连匹配。g从0出发可以跳过x,在1找到数字;y从0开始必须就在0匹配,因此失败。把y的lastIndex明确设为1,才匹配到12。

这对词法扫描有实际意义。解析器若要求输入由连续合法token组成,不能用g悄悄跳过非法前缀,再宣称找到了合法数字。y能表达当前位置匹配,但仍需要调用方验证覆盖范围、推进游标并处理失败。

lastIndex的单位也不是用户眼中的字符数,而是UTF-16代码单元位置。程序中的表情占两个代码单元,因此后面的边界是3和4。若从外部把按码点计算的位置直接赋给lastIndex,就可能把游标放在另一种单位下。

失败会改变状态,因此出错后再读取lastIndex,不一定仍能知道原来的扫描位置。需要错误定位时,先保存本轮起始游标,再调用exec。否则用户看见的报错位置可能总是0,真正失败点已经被重置覆盖。

空匹配必须有进展策略

空模式在同一位置就能匹配成功,直接用exec写while循环时,lastIndex可能一直不动。循环没有错误,却不会结束。不要用“匹配成功会自动向前”作为所有模式的假设。

matchAll在这里按相应规则推进空匹配,Unicode模式下不会把表情的两个代理代码单元当成两个独立推进点。程序验证的是这一份具体空模式与u标志,不能据此认为所有手写扫描器都可以随意加一。

若手写循环确实允许空token,需要明确何时推进、按何种单位推进、是否允许输入末尾产生结果。很多词法器更适合直接禁止空token,让每次成功消费至少一个有意义单元,而不是在循环里补一个看似通用的lastIndex++。

matchAll 没有修改原对象,也不代表总从零开始

最后一个样本先把原正则游标设为2,再调用matchAll。它使用相应复制状态开始扫描,找到cd,原对象仍保持2。复制隔离了后续推进,却没有替调用者清空起始状态。

因此想做独立扫描,应创建起点明确的新正则或明确重置。不要因为换成迭代器接口,就假定上游留下的游标不再重要。扫描结果少了前面的词时,除了检查表达式,也应检查传入对象在调用前的状态。

验证字段失败时也不应该自动再试一次。共享模式第二次失败后重置了游标,第三次可能通过,重试只是碰巧改变状态,会让日志看起来像偶发输入问题。应当让每次判断从独立、已知起点出发,而不是让失败操作顺带准备下一次成功。

想保留预编译规则的组织方式,可以缓存规则文本与标志,在需要扫描的流程内建立专属实例;也可以让一个扫描器明确独占其对象。是否重新创建不是唯一重点,能否证明没有其他流程插入改变游标,才是共享方案成立的条件。本文没有测量构造开销,不以想象的性能收益替代正确性合同。

共享正则还可能在不同字符串之间串状态。上一份长字符串匹配后留下较大位置,下一份短字符串第一次调用就失败。把正则缓存到模块级是常见优化,但只有不携带此类推进状态,或有严格独占协议,才能当成可随处重复使用的校验工具。

如果代码进入异步回调,两个逻辑流程轮流使用同一对象,lastIndex会在两者之间交错。JavaScript单线程执行也不会自动消除逻辑共享状态,问题不要求真正并行写入才会发生。

排查时记录输入长度、调用前游标、调用后游标和匹配位置,通常就能把“偶尔校验失败”变成明确步骤。删除g只修复这份推进状态,不替规则决定大小写、换行和Unicode接受范围;其余标志仍应按字段合同分别核对,并为每类边界输入保留断言。

字段校验回到无g的完整模式,扫描则显式拥有游标,两种用途分开以后,不需要靠多调用一次来碰运气。

正文到此结束
评论插件初始化中...
Loading...