JavaScript 中带 g(global)flag 的正则表达式是有状态的:每次调用 .test() 或 .exec() 后,lastIndex 属性会移动到上次匹配的末尾。如果对不同字符串连续调用同一个全局正则的 .test(),lastIndex 不会自动重置,导致匹配结果不可预测。
问题机制
const RE = /\{\{(\w+)\}\}/g
RE.test('hello {{name}}') // true, lastIndex = 14
RE.test('hi {{age}}') // false! lastIndex 14 > 字符串长度 10,直接返回 false
RE.test('hi {{age}}') // true, 上一次 false 后 lastIndex 重置为 0
关键:.test() 匹配失败时会把 lastIndex 重置为 0,但匹配成功后 lastIndex 停在匹配末尾。这意味着交替成功/失败的模式会产生看似随机的结果。
MindGym 中的实际 bug
i18n/useStrings.ts 中用全局正则检测翻译字符串是否含插值参数 {{key}}:
const INTERPOLATION_RE = /\{\{(\w+)\}\}/g
// 遍历 bundle 的每个 key
for (const [key, value] of Object.entries(strings)) {
if (INTERPOLATION_RE.test(value)) { // ← lastIndex 在迭代间累积
resolved[key] = (params) => value.replace(INTERPOLATION_RE, ...)
} else {
resolved[key] = value
}
}
zh 的 instruction: '按 1 到 {{max}} 的顺序点击' 匹配成功后 lastIndex = 17,下一次对 en 的 instruction: 'Click numbers 1 to {{max}} in order' 调用 .test() 时从位置 17 开始搜索,恰好跳过了 {{max}}。
修复方式
在每次 .test() 前手动重置:
INTERPOLATION_RE.lastIndex = 0
if (INTERPOLATION_RE.test(value)) { ... }
或者不用 g flag(如果只需要检测是否存在而不需要全局匹配):
const HAS_INTERPOLATION = /\{\{(\w+)\}\}/ // 无 g flag,无状态
何时需要 g flag
.replace(regex, ...)替换所有匹配 → 需要g.test()仅检测是否存在 → 不需要g.matchAll()→ 必须有g
如果同一个正则既用于 .test() 又用于 .replace(),要么用两个不同的正则实例,要么在 .test() 前 reset lastIndex。
参见
- 类型安全的 i18n 设计 — MindGym 的 i18n 架构
参考
- MDN: RegExp.prototype.lastIndex
- MindGym
i18n/useStrings.ts:47— 修复位置