# 正则表达式

Source: https://codewiki.com/zh/foundations/regular-expressions/

> - **what**: 正则表达式（regular expression）是一段搜索文本并能提取匹配部分的小型模式程序。
> - **trap**: 形状匹配不代表语义正确；面对恶意输入，有歧义的重复还可能让一段短模式产生高昂开销。
> - **fix**: 明确文本契约，校验时要求完整消费输入，转义动态字面量，限制输入，并同时测试成功案例与近似失败案例。

## 是什么，为什么存在

正则表达式通常简称 regex，它描述一组文本序列。正则引擎尝试让模式匹配输入字符串，并报告是否匹配以及匹配位置。带括号的分组还可以提取选定子串，交给后续代码使用。

正则表达式很简洁，因为一段模式能把字面文本与选择、重复、边界和字符类运算符组合在一起。它适合处理具有局部规则形状的文本，例如标识符、日志前缀、文档中的词元或机械改写。正是这种高密度表达能力，让未说明的契约也很容易藏在模式里。

四种常见任务使用同一个引擎，但成功标准各不相同：

| 任务 | 成功结果 | 需要追问的问题 |
| --- | --- | --- |
| 校验 | 整个输入都符合允许的形状 | 匹配是否消费了每个代码单元？ |
| 搜索 | 找到至少一个相关子串 | 使用哪个匹配项和哪些标志？ |
| 捕获 | 匹配结果返回了命名部分 | 可选分组是否可能缺失？ |
| 替换 | 转换匹配项而不改变其他文本 | 替换文本按字面量还是模板解释？ |

捕获组（capturing group）会记录某段括号模式所消费的子串。`(?<year>\d{4})` 这样的命名组比编号组更容易审查，尤其适合仍会变化的模式。若括号只用于控制选择或重复，应使用非捕获组 `(?:...)`。

正则表达式识别的是形状，不是领域事实。月份片段可以把文本限制在 `01` 到 `12`，但能接受 `2025-02-29` 的模式并没有验证日历。应先用正则建立安全且有用的形状，再由普通代码解析并验证语义规则。

正则语法存在多种方言，并非一种通用语言。本主题使用 Node 24 上的 ECMAScript 正则表达式。来自 PCRE、Python、Java 或线性时间引擎的构造可能不存在，也可能具有不同的转义、Unicode、锚点和替换行为。

## 工作原理

引擎接收模式、标志、输入字符串，通常还会接收起始位置。它在模式中寻找一条能按顺序消费输入的路径。成功时返回整体范围和捕获内容；失败时，调用 API 返回 `null` 或 `false`。

阅读模式时应从外到内。先识别锚点和标志，再看顶层选择，随后分析分组，最后检查各原子所带的量词。这个顺序能揭示模式是在搜索子串，还是声称校验整个输入。

### 原子、选择与数量

原子会消费一个单位，或者断言某个位置。字面量 `A`、字符类 `[A-Z]`、转义 `\d`、通配符 `.` 与括号分组都是常见原子。`^`、`$`、`\b` 或环视等断言只检查位置，不消费文本。

量词只作用于它紧前面的原子：

| 形式 | 含义 |
| --- | --- |
| `x?` | 零个或一个 `x` |
| `x*` | 零个或多个 `x` |
| `x+` | 一个或多个 `x` |
| `x{2,5}` | 两到五个 `x` |
| `x+?` | 一个或多个，最初优先选择较短结果 |

贪婪与惰性描述的是引擎先尝试哪个候选长度，不是安全保证。模式后部失败时，贪婪量词可以交还字符；出于同样原因，惰性量词也可以反复扩展。

选择运算 `left|right` 会在候选起点按源码顺序尝试各分支。括号决定它的作用范围：`cat|dog food` 与 `(?:cat|dog) food` 含义不同。若分支重叠，即使两种形式都报告匹配，其顺序也可能改变捕获结果。

### 捕获与匹配结果

整体匹配位于结果元素 `0`。编号捕获按照左括号出现顺序排列，命名捕获则位于 `match.groups` 中。某个分组在量词下多次参与匹配时，通常只保留最后一次捕获的子串。

可选分组可能产生 `undefined`。后续代码不能假定每个声明过的分组都参与了匹配。若代码只需判断结构是否存在，应把结构性分组设为非捕获，让捕获结果保持精简且意图明确。

模式中的 `\1` 或 `\k<name>` 等反向引用要求后续输入等于先前捕获。替换 API 在匹配完成后解释的 `$1` 和 `$<name>` 则属于替换引用。审查时很容易混淆这两个上下文。

### 标志与状态

标志会改变模式语言和搜索过程。`u` 会启用 Unicode 感知的解析方式，并让若干构造按码点处理。`i` 忽略大小写，`m` 改变行锚点行为，`s` 允许点号匹配行终止符，`g` 执行全局迭代，`y` 则要求匹配必须从 `lastIndex` 开始。

`g` 与 `y` 标志会让 `RegExp` 对象通过 `lastIndex` 携带状态。调用 `exec()` 或 `test()` 可能推进或重置该属性。因此，把同一个全局对象复用为校验器，会让两个相同的有效输入交替得到 `true` 和 `false`。

应根据所需结果选择 API：

| API | 适用结果 | 状态问题 |
| --- | --- | --- |
| `pattern.test(text)` | 一个布尔值 | 带 `g` 或 `y` 时修改 `lastIndex` |
| `pattern.exec(text)` | 一个匹配项及其捕获 | 带 `g` 或 `y` 时修改 `lastIndex` |
| `text.matchAll(pattern)` | 包含全部详细匹配的迭代器 | 要求全局正则表达式 |
| `text.replace(pattern, value)` | 转换后的字符串 | 回调可避免替换模板歧义 |

### 两层转义

JavaScript 会把 `/\d+/u` 这样的正则字面量直接解析成正则语法。传给 `new RegExp("\\d+", "u")` 的字符串先按 JavaScript 字符串解析，再按正则语法解析。双反斜杠属于字符串层，并不表示另一种正则含义。

动态文本是数据，不是正则源码。在 Node 24 中，`RegExp.escape(userText)` 会把任意字面片段转换为可安全嵌入较大模式的源码。只靠手工转义点号和星号，会漏掉其他标点及依赖上下文的情况。

### Unicode 契约

JavaScript 字符串使用 UTF-16 代码单元。启用 `u` 后，点号与若干正则操作会把表示一个码点的代理项对视为一个单位，但仍不能识别用户感知的完整字素簇。组合序列或连接 emoji 可以包含多个码点。

即使启用 `u`，ECMAScript 中的简写 `\d` 仍只匹配 ASCII 数字。若契约允许其他书写系统的十进制数字，应结合 `u` 使用 `\p{Decimal_Number}`，并确认后续数值解析是否支持它们。若契约本来就是 ASCII 协议词元，`\d` 反而可能完全正确。

`\p{Script=Greek}` 等 Unicode 属性转义比大段复制的范围更清楚地表达字符意图。它们要求 Unicode 感知模式，但仍需测试组合标记、规范化形式和混合书写系统输入。正则匹配不会自动规范化文本。

## 示例

以下示例依次展示完整输入校验、搜索、基于捕获的替换，以及经过测量的对抗行为。下面每段输出都来自 Node `v24.14.0` 实际执行对应文件的结果。

### 校验并解析订单引用

第一段模式匹配前缀，并捕获两个命名字段。函数另外要求匹配范围与完整输入相等，因此会拒绝有效前缀之后仍有其他文本的情况。

<!-- quick -->

```javascript
// file: parse_reference.js
const referencePattern = /^(?<region>[A-Z]{2})-(?<number>\d{6})/u;

function parseReference(input) {
  const match = referencePattern.exec(input);
  if (!match || match[0].length !== input.length) return null;

  return match.groups;
}

const samples = [
  "FR-004219",
  "fr-004219",
  "FR-4219",
  "FR-004219\n",
];

for (const sample of samples) {
  const parsed = parseReference(sample);
  const result = parsed
    ? `region=${parsed.region}, number=${parsed.number}`
    : "invalid";
  console.log(`${JSON.stringify(sample)} => ${result}`);
}
```

```text
"FR-004219" => region=FR, number=004219
"fr-004219" => invalid
"FR-4219" => invalid
"FR-004219\n" => invalid
```


<!-- /quick -->

把编号保留为文本可以保存前导零。若应用随后要把它转换为数值，这项转换属于另一份契约。正则表达式也没有断言所引用的订单确实存在。

JavaScript 的 `$` 断言还可以在末尾行终止符之前匹配，因此只用 `^...$` 很容易被夸大成绝对完整消费。把整体匹配与原输入比较，能明确表达预期边界。另一种设计可以先拒绝行终止符，再使用经过仔细说明的锚点策略。

### 用命名捕获寻找告警

这里的 `m` 让 `^` 与 `$` 在行边界工作，`g` 则找出每一个匹配行。`matchAll()` 会返回匹配索引和命名组，无需手写 `exec()` 循环。

```javascript
// file: find_alerts.js
const log = [
  "2026-09-04T10:30:00Z [INFO] worker started",
  "2026-09-04T10:31:08Z [WARN] queue depth is 42",
  "2026-09-04T10:31:11Z [ERROR] payment timed out",
].join("\n");

const alertPattern =
  /^(?<time>\S+) \[(?<level>WARN|ERROR)\] (?<message>.+)$/gmu;

for (const match of log.matchAll(alertPattern)) {
  const { time, level, message } = match.groups;
  console.log(`${level} at ${time}: ${message}`);
  console.log(`  match starts at index ${match.index}`);
}
```

```text
WARN at 2026-09-04T10:31:08Z: queue depth is 42
  match starts at index 43
ERROR at 2026-09-04T10:31:11Z: payment timed out
  match starts at index 89
```

这段模式只识别当前任务需要的日志外壳。`\S+` 不是时间戳校验器，`.+` 也有意把消息解释留给后续代码。只有当消费方确实存在规则时，才应进一步收窄字段。

索引采用 UTF-16 代码单元偏移，因为 JavaScript 就用这种方式索引字符串。它们适合对同一个字符串调用 `slice()`，但不能自动当作 UTF-8 文件的字节偏移或用户看到的列号。

### 用回调替换匹配项

这个示例寻找两个带格式的 16 位数字序列，并利用命名捕获只保留首尾两组。回调会显式构造替换结果，避免替换模板中的特殊 `$` 序列产生歧义。

```javascript
// file: redact_cards.js
const note = [
  "primary=4111 1111 1111 1111",
  "backup=5555-4444-3333-2222",
  "reference=20260904",
].join("; ");

const cardPattern =
  /(?<!\d)(?<first>\d{4})[ -]?\d{4}[ -]?\d{4}[ -]?(?<last>\d{4})(?!\d)/gu;

let replacements = 0;
const redacted = note.replace(cardPattern, (...arguments_) => {
  const groups = arguments_.at(-1);
  replacements += 1;
  return `${groups.first}-••••-••••-${groups.last}`;
});

console.log(redacted);
console.log(`replacements=${replacements}`);
```

```text
primary=4111-••••-••••-1111; backup=5555-••••-••••-2222; reference=20260904
replacements=2
```

后行断言与先行断言检查数字边界，但不消费相邻文本。八位引用无法满足完整的 16 位形状，因此保持不变。若模式包含命名捕获，回调最后一个参数就是命名组对象。

这只是格式处理演示，不是完整的支付数据控制。真实系统应避免接收不需要的卡片数据，在文本进入通用日志之前执行存储与日志策略，并测试所有受支持输入格式。数据已经写入日志后再脱敏为时已晚。

### 测量有歧义的重复

嵌套模式可以用多种方式切分同一串 `a`，最后的 `!` 才会证明匹配失败。有界模式则直接表达示例的真实策略：只能包含一到 24 个 `a`，不能有其他内容。

```javascript
// file: measure_backtracking.js
const nestedPattern = /^(a+)+$/u;
const boundedPattern = /^a{1,24}$/u;

for (const size of [12, 16, 20, 24]) {
  const adversarial = `${"a".repeat(size)}!`;
  const started = performance.now();
  const nestedMatch = nestedPattern.test(adversarial);
  const elapsed = performance.now() - started;
  const boundedMatch = boundedPattern.test(adversarial);

  console.log(
    `${adversarial.length} chars: nested=${nestedMatch} ` +
      `${elapsed.toFixed(3)}ms, bounded=${boundedMatch}`,
  );
}
```

```text
13 chars: nested=false 0.156ms, bounded=false
17 chars: nested=false 0.335ms, bounded=false
21 chars: nested=false 4.683ms, bounded=false
25 chars: nested=false 73.597ms, bounded=false
```

这些数据来自审查机器上的一次 Node 24 运行，不是可移植的基准常量。关键证据是失败案例，以及每增加四个字符时迅速增长的工作量。生产测试应执行宽松且适合具体机器的预算，不应断言这些精确毫秒数。

有界表达式还通过执行 24 字符策略改变了所接受的语言。若真正需要任意长度，应采用最坏情况边界可靠的构造或引擎，而不是隐藏上限。绝不能用无界输入运行有意设计的危险基准。

## 陷阱

### 把形状当作含义

> **陷阱:** 日期、电子邮件地址、URL 或标识符可能匹配一个看似合理的正则表达式，但在所属领域中仍然无效。不断扩充模式以编码所有语义规则，往往会让契约更难检查。

**修复方法：** 用正则处理有文档说明的词法边界，再通过领域 API 解析并执行语义检查。测试一个形状有效但含义无效的值，例如不存在的日历日期，避免混淆两个阶段。

### 只校验子串

> **陷阱:** 除非模式与调用代码要求完整消费，否则只要某个允许的子串匹配，`test()` 就会成功。在 JavaScript 中，`$` 可以在末尾行终止符之前匹配，而 `m` 会有意把锚点改成行边界。

**修复方法：** 比较整体匹配范围与完整输入，或者使用经过明确审查的绝对边界构造。校验正则不要带 `g` 与 `y`，并加入前导文本、尾随文本和末尾换行符案例。

### 混淆源码与字面数据

> **陷阱:** 把租户名称、文件扩展名或搜索词直接插入 `new RegExp()`，会让标点改变分组、重复或选择。面对两层解析，只靠手写反斜杠替换很容易出错。

**修复方法：** 固定模式应写成正则字面量。确实需要动态组合时，在 Node 24 上让字面片段先经过 `RegExp.escape()`，把可信正则源码与数据分开，并测试 `.`、`-`、`(`、`]` 和 `\` 等标点。

### 假定只有一种字符含义

> **陷阱:** 点号、`\d`、字符串偏移、Unicode 属性转义和用户感知字符采用不同单位或集合。添加 `u` 能修正若干码点行为，却不会让点号消费整个字素簇，也不会让 `\d` 匹配所有书写系统的十进制数字。

**修复方法：** 在需求中明确单位与字符范围：ASCII 数字、Unicode 十进制数字、码点、字素、字节或 UTF-16 代码单元。契约需要时使用 Unicode 属性转义与 `Intl.Segmenter`，并把规范化作为单独策略处理。

### 编写有歧义的重复选择

> **陷阱:** 嵌套量词与重叠分支可能产生多条消费同一前缀的等价路径。某个直到末尾才失败的近似匹配，可能迫使回溯引擎重新尝试这些选择，使攻击者可控输入变成拒绝服务风险。

**修复方法：** 消除有歧义的嵌套，提取公共前缀，并明确限制输入与重复次数。在隔离测试进程中，于截止时间内执行长近似失败案例；面对外部可达的复杂模式，可考虑解析器或具有合适最坏情况保证的引擎。

### 信任全局正则状态

> **陷阱:** 带 `g` 或 `y` 的共享正则会让 `test()` 和 `exec()` 调用通过 `lastIndex` 传递状态。先测试再执行的代码可能跳过目标匹配，表面上并行的使用方也可能通过同一个对象相互干扰。

**修复方法：** 单次校验不要使用有状态标志；完整迭代优先使用 `matchAll()`；也可以只控制一个明确的 `exec()` 循环，并处理零长度行为。不要把 `test()` 当作真正需要捕获内容的 `exec()` 的预检。

<!-- deep -->

## 回溯、状态与有界工作

多数实用正则引擎提供最左优先搜索策略。它们从左到右扫描候选起点；在某个起点上，有序分支与量词偏好决定先尝试哪条路径。这项策略解释了为什么接受相同字符串的两段模式可能返回不同捕获。

### 选择点

量词或选择可以产生选择点。后续模式元素失败时，回溯引擎会恢复先前位置并尝试另一项选择。这就是回溯（backtracking）；它是正常匹配行为，并不自动构成缺陷。

当多条路径消费同一个前缀时，问题才会出现。在 `^(a+)+$` 中，内外两层 `+` 都会决定如何切分同一串 `a`。追加一个禁用的 `!` 后，引擎只有在探索许多先前看似可行的切分方式之后才能拒绝输入。

提取公共前缀可以减少选择。把宽泛的 `.*` 区域替换成 `[^,]*` 这样的分隔符感知字符类，也能让意图更清楚，但机械改写无法证明所有外围模式都安全。必须结合所用引擎审查完整表达式。

### 测量证明了什么

实际执行的示例得到以下观察结果：

| 输入长度 | 嵌套模式失败耗时 | 有界模式结果 |
| --- | --- | --- |
| 13 个代码单元 | 0.156 ms | `false` |
| 17 个代码单元 | 0.335 ms | `false` |
| 21 个代码单元 | 4.683 ms | `false` |
| 25 个代码单元 | 73.597 ms | `false` |

这张表只能证明某个引擎版本、机器、模式与输入族上的行为。它不能给出通用阈值，也不能精确说明所有正则表达式的复杂度类别。JIT 预热、引擎优化、CPU 负载和不同字符串都会改变数值。

有效的回归测试应记录宽松的耗时预算，并运行在测试框架可以终止的工作线程或子进程中。JavaScript 正则调用本身没有可移植的匹配中途取消接口。只有在同步匹配返回后才检查的超时，无法中断已经阻塞事件循环的工作。

### 边界属于契约

输入大小限制不能取代模式审查，但可以把无界风险变成容量决策。应在匹配前执行限制，并采用外围协议所规定的单位。入口处的字节限制与 JavaScript 内部的代码单元限制解决不同问题，两者可能都需要。

若领域本来就有最大值，应限制各处重复。恰好包含六个 ASCII 数字的订单编号应写成 `{6}`，而不是 `+`。最多 64 个代码单元的字段不应使用 `.*`，再依赖后续截断。

模式所有权同样重要。对有界输入运行固定且经过审查的模式，与运行管理员提供的模式具有不同威胁模型；两者又都不同于执行普通用户提供的原始正则源码。转义片段只能让它成为字面量，不能让有意提供的正则表达式自动变得安全。

### 有状态迭代

使用 `g` 或 `y` 时，成功的 `exec()` 会把 `lastIndex` 更新到匹配末尾，失败则将其重置为零。若某个循环可以返回零长度匹配，就必须保证仍能前进，否则在需要手工推进的 API 中可能反复观察同一个位置。

`matchAll()` 封装了全局迭代，并产出每个详细结果。需要所有匹配与捕获时，它是很好的默认选择。对于词法分析器，粘连标志 `y` 可以表达「下一个词元必须从此处开始」，但调用方必须拥有 `lastIndex`，并在该位置没有词元时报告错误。

不要把可变迭代状态作为隐藏模块配置共享。应在操作附近构造正则表达式，在确实需要独立游标时复制它，或者使用把迭代状态放在返回迭代器内部的 API。测试应交错执行两次扫描，以暴露意外共享。

### 捕获边界

捕获属于程序的输出模式。应给业务相关字段命名，把只用于分组的括号设为非捕获，并明确说明可选组可以为空。否则，改变模式中的括号可能悄悄重排所有后续 `$1` 或 `match[1]` 使用方。

捕获保存的是原 JavaScript 字符串中的子串与偏移。它不会解析数字、规范化 Unicode、解码转义或验证引用完整性。应显式执行这些转换，并在诊断或审计需要时保留原始文本。

替换回调把输出模式明确展示为函数参数，并返回字面替换文本。替换字符串自身还有一套解释 `$&`、`$1`、`$<name>` 等词元的小语言。若替换文本包含不可信美元符号，使用回调返回该文本可以避免按模板解释。

### 建立可信度的测试语料库

先准备应当匹配的示例，以及只在一个边界上不同的示例。加入空输入、允许的最短值与最长值、刚超过每项限制的值、前后多余文本、行终止符和可选组缺失情况。这样检验的是接受语言，而不只是正常路径。

再根据声明的字符契约加入 Unicode 案例：补充平面码点、组合序列、混合书写系统、非 ASCII 十进制数字，以及相关时规范等价的形式。无需把所有 Unicode 奇例塞给每段模式，只选择可能推翻既定策略的案例。

最后，从每个重复或重叠区域构造近似失败输入，只在安全测试上限内逐步增大。测量时记录运行时与硬件环境。只要模式守卫外部可达的请求路径，就应把这些对抗案例保留在回归测试中。

<!-- /deep -->

[检查点: foundations/regular-expressions](https://codewiki.com/zh/foundations/regular-expressions/#checkpoint)

## 延伸阅读

- [ECMAScript 规范：RegExp 对象](https://tc39.es/ecma262/multipage/text-processing.html#sec-regexp-regular-expression-objects)
- [MDN：正则表达式指南](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions)
- [MDN：`RegExp` 参考](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp)
- [MDN：Unicode 字符类转义](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape)
- [OWASP：正则表达式拒绝服务](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
