AI 辅助声明:本文由 AI 辅助撰写。所有 JavaScript 示例均通过配套的 Node.js 验证脚本执行;未进行人工编辑审查。

正则表达式在独立使用时可能是正确的,但在应用程序中仍可能失败。失败可能源于标志、Unicode 表示、用于消费表达式的 API,或共享 RegExp 对象的可变状态。

这就是为什么“尝试另一种模式”往往是一种糟糕的调试策略。更好的方法是将 bug 转化为一个小型测试矩阵。

该矩阵有四个有用的轴:

要测试的问题
输入 是否包含精确的失败字符串,包括空白、规范化以及不可见码点?
模式与标志 使用 ugimsy 时会发生什么变化?
消费 API 我们调用的是 testexecmatchAll 还是 replace
预期结果与状态 应该产生哪些匹配、分组、替换文本以及 lastIndex 值?

这将“正则在一个地方有效但在另一个地方无效”这样的轶事,转化为如下可重现的陈述:

对于这个精确输入,/^.$/ 失败,/^.$/u 成功,且该输入包含由两个 UTF-16 代码单元表示的一个 Unicode 码点。

从可执行矩阵开始

最小有用的测试工具只是数据加断言:

import assert from "node:assert/strict";

const cases = [
  {
    name: "an emoji needs Unicode code-point mode",
    run: () => /^.$/u.test("💡"),
    expected: true,
  },
  {
    name: "a decomposed character contains two code points",
    run: () => /^.$/u.test("e\u0301"),
    expected: false,
  },
  {
    name: "NFC normalization composes that character",
    run: () => /^.$/u.test("e\u0301".normalize("NFC")),
    expected: true,
  },
];

for (const testCase of cases) {
  assert.deepEqual(testCase.run(), testCase.expected, testCase.name);
}

Enter fullscreen mode Exit fullscreen mode

在矩阵中保留原始失败输入。稍后可添加精简示例,但不要用可能不再复现该 bug 的更简洁字符串替换证据。

当输入可疑时,请同时检查代码单元和码点:

const input = "💡";

console.log(input.length);            // 2 UTF-16 code units
console.log(Array.from(input).length); // 1 Unicode code point

Enter fullscreen mode Exit fullscreen mode

这两个长度解释了为什么不带 u 标志的点不表示“一个可见字符”。

Unicode 不只是添加 u 标志

u 标志会改变模式解析,并使 . 等结构在 Unicode 码点上操作,而不是在单个 UTF-16 代理对上操作:

console.log(/^.$/.test("💡"));  // false
console.log(/^.$/u.test("💡")); // true

Enter fullscreen mode Exit fullscreen mode

它还启用了 Unicode 属性转义,这通常比手写长范围更清晰:

const lettersOnly = /^\p{Letter}+$/u;

console.log(lettersOnly.test("東京Cafe")); // true
console.log(lettersOnly.test("東京2"));    // false

Enter fullscreen mode Exit fullscreen mode

u 不会对文本进行规范化。预组合字符串 "é" 与分解字符串 "e\u0301" 看起来相似,但包含不同的码点序列。请决定规范化是否属于应用程序契约的一部分,然后明确测试该决定:

const oneCodePoint = /^.$/u;

console.log(oneCodePoint.test("é"));                  // true
console.log(oneCodePoint.test("e\u0301"));            // false
console.log(oneCodePoint.test("e\u0301".normalize("NFC"))); // true

Enter fullscreen mode Exit fullscreen mode

除非规范允许,否则不要静默规范化标识符、密码、签名或其他协议数据。规范化是一项数据决策,而非通用的正则修复手段。

测试捕获形状,而非仅测试完整匹配

成功的完整匹配并不能证明下游捕获处理是正确的。可选捕获可能为 undefined,而重复组内的捕获仅存储最后捕获的值。

const optional = /(?<word>[A-Za-z]+)(?:-(?<suffix>\d+))?/;
const match = optional.exec("alpha");

console.log(match.groups.word);   // "alpha"
console.log(match.groups.suffix); // undefined

const repeated = /(ab)+/.exec("abab");
console.log(repeated[0]); // "abab"
console.log(repeated[1]); // "ab", not an array of both repetitions

Enter fullscreen mode Exit fullscreen mode

对于每个有意义的捕获,添加断言。如果应用程序代码期望某个值存在,也要测试其缺失分支。

命名组使矩阵更易读,因为断言描述的是领域而非位置:

assert.equal(match.groups.word, "alpha");
assert.equal(match.groups.suffix, undefined);

Enter fullscreen mode Exit fullscreen mode

替换是一种独立行为

匹配和替换应作为矩阵中的独立行。模式可能找到正确的跨度,而替换字符串却引用了错误的组或错误地处理了可选组。

命名捕获为替换模板提供了有用的自文档化能力:

const person = /(?<first>\p{Letter}+)\s+(?<last>\p{Letter}+)/gu;
const input = "Ada Lovelace; Grace Hopper";
const output = input.replace(person, "$<last>, $<first>");

console.log(output);
// Lovelace, Ada; Hopper, Grace

Enter fullscreen mode Exit fullscreen mode

当替换需要条件时,使用函数并断言最终字符串,而非仅测试正则:

const version = /v(?<major>\d+)(?:\.(?<minor>\d+))?/g;

const output = "v2 v3.7".replace(version, (...args) => {
  const groups = args.at(-1);
  return groups.minor === undefined
    ? `${groups.major}.0`
    : `${groups.major}.${groups.minor}`;
});

console.log(output); // 2.0 3.7

Enter fullscreen mode Exit fullscreen mode

回调参数列表较为笨拙,因此如果在生产代码中出现,应将其行为封装在命名函数之后。

将全局正则视为可变对象

gy 标志使 RegExp 具有状态。调用 testexec 会读写 lastIndex

const shared = /\d+/g;
const input = "a1 b2";

console.log(shared.test(input), shared.lastIndex); // true, 2
console.log(shared.test(input), shared.lastIndex); // true, 5
console.log(shared.test(input), shared.lastIndex); // false, 0

Enter fullscreen mode Exit fullscreen mode

当模块级正则在请求或验证调用之间复用时,这通常会导致交替的测试结果:

const hasNumber = /\d/g;

hasNumber.test("item 7"); // true
hasNumber.test("item 7"); // false: the same object starts from its old lastIndex

Enter fullscreen mode Exit fullscreen mode

三种可靠的修复方法是:

  1. 当操作是“是/否”测试时移除 g
  2. 为每次独立扫描构造一个新的 RegExp
  3. 故意重置 lastIndex 并断言状态转换。

对于收集所有匹配,matchAll 通常更易于理解。它使用克隆的正则状态进行迭代,且不会改变原始对象的 lastIndex

const digits = /\d+/g;
const values = [..."a1 b22".matchAll(digits)].map((match) => match[0]);

console.log(values);           // ["1", "22"]
console.log(digits.lastIndex); // 0

Enter fullscreen mode Exit fullscreen mode

还有一个值得测试的状态边界:零长度匹配。如果表达式成功但未消耗输入,手动 exec 循环可能会卡住。要么在空匹配后自行推进 lastIndex,要么使用其迭代行为已为此情况定义的 API:

const positions = [..."ab".matchAll(/(?=\w)/g)].map((match) => match.index);
console.log(positions); // [0, 1]

Enter fullscreen mode Exit fullscreen mode

故意比较 API

JavaScript 提供了多种正则消费者,因为它们回答不同的问题:

API 有用结果 状态关注点
re.test(input) 布尔存在性检查 使用 gy 时会改变 lastIndex
re.exec(input) 带捕获和索引的单个匹配 使用 gy 时会改变 lastIndex
input.matchAll(re) 所有详细匹配的可迭代对象 需要 g;不会改变原始正则
input.replace(re, value) 转换后的字符串 替换语义需要单独断言

当两个环境不一致时,首先确认它们使用的是相同的输入、标志和 API。将一处的 test 与另一处的 matchAll 进行比较,并不是一个受控实验。

在构建矩阵时,如果需要浏览器本地的临时工具,RegexRegex JavaScript 指南与在线检查器 可同时展示匹配、捕获组、替换预览和错误。它是可选的:本文所有示例均可仅用 Node.js 复现。

实用的调试流程

以下是我处理正则 bug 的工作流:

  1. 捕获精确输入。 保留转义、行尾、规范化和周围字符。
  2. 记录精确构造。 字面量与 new RegExp(string) 有不同的转义层。
  3. 冻结消费 API。 记录生产环境调用的是 testexecmatchAll 还是 replace
  4. 构建最小的相关标志矩阵。 每次更改一个标志,并说明该行存在的原因。
  5. 断言完整匹配和捕获。 包含可选和重复组分支。
  6. 单独断言替换输出。 正确的匹配仍可能产生错误的文本。
  7. gy 断言 lastIndex 对同一对象运行多次。
  8. 添加 Unicode 对抗用例。 当领域允许时,包含星体字符、组合标记和非 ASCII 字母。
  9. 仅在复现后最小化。 将原始用例作为永久回归测试保留。
  10. 在支持的运行时中运行。 引擎特性和 Unicode 数据在部署的浏览器和 Node 版本间可能存在差异。

目标不是测试每一种理论字符串,而是让每个假设可见。一旦输入表示、标志、API 语义、捕获、替换输出和可变状态成为独立行,大多数“神秘”的正则失败就会变成普通的失败断言。

保持矩阵可执行

将应用程序依赖的用例放入一个使用 node:assert/strict 的小型 regex-matrix.mjs 文件中,并在首次期望失败时使进程以非零退出。使用以下命令运行:

node regex-matrix.mjs

Enter fullscreen mode Exit fullscreen mode

将预期结果与代码一起存储,以便后续的正则编辑不会在不改变可执行期望的情况下悄然改变行为。