# 正则表达式

Source: https://codewiki.com/zh/javascript/regexp/

> - **what**: 正则表达式（regular expression）用一段模式描述文本的形状；JavaScript 通过 `RegExp` 以及字符串方法完成查找、提取和替换。
> - **trap**: 带 `g` 或 `y` 的表达式会修改 `lastIndex`，动态文本若未经 `RegExp.escape()` 就进入模式，还会改变模式含义或造成高代价回溯。
> - **fix**: 先写清匹配范围、Unicode 单位和状态所有权；把动态文本转义成字面量，并用失败输入与近似匹配输入测试模式。

## 是什么，为什么存在

正则表达式是一种文本模式。它可以回答“是否存在这种形状”“匹配从哪里开始”“哪些子串属于各个字段”，也可以把匹配结果交给替换函数。JavaScript 的 `RegExp` 对象保存模式、标志和匹配状态，`String` 方法决定怎样消费结果。

正则表达式适合局部、边界清楚的文本任务，例如从日志行提取字段、查找多个关键词、规范化固定格式，或把输入限制在一组字符中。它把字符选择、重复、分支和位置约束放进一个紧凑表达式，因此比一长串索引判断更容易与文本格式对应。

紧凑不等于适合所有解析任务。URL、HTML、JSON 和编程语言都有专用解析器与错误模型；用一个模式重建这些语法，往往会漏掉转义、嵌套或规范化规则。正则表达式应负责明确的局部契约，结构化格式则交给拥有完整语法知识的 API。

你会在字面量 `/pattern/flags`、`new RegExp(source, flags)`、表单校验、路由、编辑器搜索以及日志处理代码里遇到它。模式固定时使用字面量最清楚；模式包含运行时文本时才需要构造函数，并且要区分“这段文本就是正则语法”与“这段文本只应按字面匹配”。

JavaScript 正则表达式按 UTF-16 字符串工作。`u` 或 `v` 模式能让许多操作按 Unicode 码点解释，但这仍不等于按用户看到的字素簇、单词或语言规则处理文本。国际化需求必须先说明单位，再选择属性转义、`Intl.Segmenter` 或专用解析器。

## 工作原理

### 模式、标志与创建方式

正则字面量直接把模式写进源码，例如 `/invoice-(\d+)/i`。`RegExp` 构造函数接收字符串，适合插入运行时片段；普通字符串会先处理一次反斜杠，所以字面量 `/\d+/` 对应 `new RegExp("\\d+")`。`String.raw` 可以减少静态片段的双重转义，但不会自动保护插入值。

Node 24 支持下列八个标志。标志属于表达式对象，不能在一次执行中临时添加；需要另一组标志时，应创建另一个 `RegExp`。

| 标志 | 属性 | 对匹配的影响 |
| --- | --- | --- |
| `d` | `hasIndices` | 在结果的 `indices` 中返回完整匹配和捕获组的起止索引 |
| `g` | `global` | 从 `lastIndex` 开始搜索，并把成功位置留给下一次匹配 |
| `i` | `ignoreCase` | 使用大小写不敏感匹配，具体折叠规则受 Unicode 模式影响 |
| `m` | `multiline` | 让 `^` 与 `$` 也能匹配行首和行尾 |
| `s` | `dotAll` | 让 `.` 也匹配行终止符 |
| `u` | `unicode` | 启用 Unicode 感知语义、属性转义与更严格的模式语法 |
| `v` | `unicodeSets` | 启用 Unicode 集合模式、集合运算和字符串属性 |
| `y` | `sticky` | 只允许从 `lastIndex` 指定的位置开始匹配 |

`u` 与 `v` 不能同时使用。新代码若需要集合交集、集合差集或 Unicode 字符串属性，可以选择 `v`；只需要码点语义和字符属性时，`u` 仍然直接且常见。目标运行时不只由 Node 版本决定时，还应核对浏览器兼容范围。

### 构成模式的单元

模式把消费字符的单元和只检查位置的断言组合起来。下面的表描述常见构件，而不是一份可直接拼接的配方。

| 构件 | 示例 | 含义 |
| --- | --- | --- |
| 字面字符 | `cat` | 依次匹配 `c`、`a`、`t` |
| 字符类 | `[A-Z]`、`\p{Letter}` | 在当前位置匹配集合中的一个字符 |
| 否定字符类 | `[^,]` | 匹配不在集合中的一个字符 |
| 量词 | `+`、`*`、`?`、`{2,4}` | 控制前一个单元的重复次数 |
| 分支 | `WARN|ERROR` | 按从左到右的顺序尝试替代项 |
| 捕获组 | `(?<year>\d{4})` | 分组并记录成功分支匹配的子串 |
| 非捕获组 | `(?:ab)+` | 只分组，不增加结果中的捕获槽位 |
| 反向引用 | `\1`、`\k<name>` | 再次匹配某个捕获组已经匹配的文本 |
| 锚点 | `^`、`$` | 断言输入或行的边界，不消费字符 |
| 环视 | `(?=px)`、`(?<!\$)` | 检查后方或前方条件，不把条件文本纳入匹配 |

`\d` 只表示 ASCII 数字 `0` 到 `9`，`\w` 也以 ASCII 字母、数字和下划线为核心。需要跨语言字母或十进制数字时，应在 `u` 或 `v` 模式中明确使用 `\p{Letter}`、`\p{Decimal_Number}` 等 Unicode 属性，而不是假定简写字符类会随区域设置改变。

量词默认贪婪，会优先尝试更多重复；在量词后加 `?` 会改成优先尝试更少重复。两者在后续部分失败时都可能回溯，因此“惰性”不等于“没有回溯”或“必然更快”。选择哪一种取决于你要取得哪个边界。

### 左端优先的匹配

没有 `y` 时，引擎寻找最左侧能够开始成功匹配的位置。在同一个开始位置，分支顺序、贪婪程度和后续约束决定最终路径；后面的单元失败时，引擎可能回到较早的选择点尝试另一条路径。这是有序、左端优先的选择，不是从全部结果中挑最长字符串。

捕获只记录成功路径上的内容。可选分支没有参与时，对应的捕获值是 `undefined`；重复捕获组通常只保留最后一次迭代捕获的文本。若只需要控制优先级，应使用 `(?:...)`，避免给结果增加容易错位的编号。

锚点与环视不消费字符，所以它们可以约束完整输入或上下文。`^...$` 常用于格式校验；开启 `m` 后，这两个锚点改为允许行边界，便不再表示整段输入。环视适合做局部上下文限制，但层层嵌套的环视会迅速降低可读性。

### 方法决定结果形状

同一个模式通过不同方法执行时，返回值和状态行为不同。选方法时先决定你需要布尔值、一个详细结果、全部详细结果，还是替换后的字符串。

| 调用 | 结果 | 关键约束 |
| --- | --- | --- |
| `regexp.test(text)` | 布尔值 | 带 `g` 或 `y` 时会读写 `lastIndex` |
| `regexp.exec(text)` | 一个详细匹配或 `null` | 适合逐次读取捕获、索引与状态 |
| `text.match(regexp)` | 一个详细匹配，或全部匹配文本 | 有 `g` 时不返回逐项捕获详情 |
| `text.matchAll(regexp)` | 全部详细匹配的迭代器 | 传入 `RegExp` 时必须带 `g`，并使用其副本迭代 |
| `text.search(regexp)` | 首个匹配的索引或 `-1` | 只寻找一个位置 |
| `text.replace(regexp, value)` | 替换后的新字符串 | 字符串替换值会解释 `$` 替换记号 |
| `text.split(regexp)` | 子串数组 | 捕获组会被插入返回数组 |

`exec()` 的数组第 `0` 项是完整匹配，后面是编号捕获；`groups` 保存命名捕获，`index` 保存开始位置。使用 `d` 后，`indices` 按同一槽位结构给出 `[start, end]`，这些字符串索引以 UTF-16 代码单元计数。

`g` 会从 `lastIndex` 开始向后搜索，`y` 则要求匹配恰好从该位置开始。二者成功后把 `lastIndex` 更新到匹配结尾，失败时把它重置为 `0`。普通表达式的 `exec()` 不把 `lastIndex` 当作起点。

## 示例

下面四个示例依次展示结构化提取、动态字面量、连续扫描和 Unicode 单位。所有输出都来自 Node 24.14.0 实际执行对应文件。

### 提取命名字段和索引

日志模式使用 `m` 逐行应用锚点，使用 `g` 取得全部结果，再使用 `d` 读取消息字段的绝对范围。命名捕获让调用代码不依赖捕获顺序。

<!-- quick -->

```javascript
// file: log_extract.js
const log = `09:41 INFO cache warmed
09:42 WARN retry scheduled
09:43 ERROR upstream unavailable`;

const linePattern =
  /^(?<time>\d{2}:\d{2})\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.+)$/gmd;

const rows = [...log.matchAll(linePattern)].map(({ groups, indices }) => ({
  time: groups.time,
  level: groups.level,
  message: groups.message,
  messageSpan: indices.groups.message,
}));

console.log(JSON.stringify(rows, null, 2));
```

```text
[
  {
    "time": "09:41",
    "level": "INFO",
    "message": "cache warmed",
    "messageSpan": [
      11,
      23
    ]
  },
  {
    "time": "09:42",
    "level": "WARN",
    "message": "retry scheduled",
    "messageSpan": [
      35,
      50
    ]
  },
  {
    "time": "09:43",
    "level": "ERROR",
    "message": "upstream unavailable",
    "messageSpan": [
      63,
      83
    ]
  }
]
```


<!-- /quick -->

`matchAll()` 保留了每次匹配的捕获详情，并在内部副本上推进状态，所以 `linePattern.lastIndex` 不会被这次迭代留下变化。消息中出现非 BMP 字符时，`messageSpan` 仍然是 UTF-16 索引，不能直接当作用户感知字符数。

这个模式只验证日志行的形状，不验证时间是否真实存在。若小时与分钟有业务范围，应在捕获后把字段转成数字再做语义检查；继续把日历规则塞进模式只会让契约更难读。

### 安全插入动态字面量

关键词来自数据，而不是正则语法。`RegExp.escape()` 把每个词变成可以安全嵌入更大模式的源文本，再用分支连接它们。

```javascript
// file: dynamic_pattern.js
const keywords = ['C++', 'node.js', '[draft]'];
const escaped = keywords.map(RegExp.escape);
const keywordPattern = new RegExp(escaped.join('|'), 'gi');
const title = 'Move [draft] C++ addon to Node.js; keep C+ notes.';

console.log(escaped);
console.log(title.match(keywordPattern));
```

```text
[ '\\x43\\+\\+', '\\x6eode\\.js', '\\[draft\\]' ]
[ '[draft]', 'C++', 'Node.js' ]
```

输出中的首字母十六进制转义是有意设计的。它防止转义结果紧跟在 `\1`、`\x0` 等片段后时，被误解为前一个转义的一部分；手写“给几个元字符加反斜杠”的函数通常不会覆盖这种拼接上下文。

`RegExp.escape()` 只保证文本按字面解释，不负责授权、长度限制或匹配范围。关键词数组为空时，`escaped.join('|')` 会产生空模式并在每个位置匹配，因此调用方还需要为“没有关键词”定义独立行为。

### 用粘性匹配连续扫描

词法扫描要求每个新记号紧接上一个记号，不能悄悄跳过未知字符。`y` 标志把 `lastIndex` 变成必须满足的起点，因此很适合这种连续消费契约。

```javascript
// file: sticky_tokenizer.js
const tokenPattern =
  /(?<space>\s+)|(?<number>\d+(?:\.\d+)?)|(?<operator>[()+\-*/])/y;

function tokenize(expression) {
  tokenPattern.lastIndex = 0;
  const tokens = [];

  while (tokenPattern.lastIndex < expression.length) {
    const position = tokenPattern.lastIndex;
    const match = tokenPattern.exec(expression);
    if (!match) throw new SyntaxError(`Unexpected token at ${position}`);
    if (match.groups.space) continue;

    const type = match.groups.number === undefined ? 'operator' : 'number';
    tokens.push(`${type}:${match[0]}@${position}`);
  }

  return tokens;
}

console.log(tokenize('12 + 3.5*(7-2)').join('\n'));
try {
  tokenize('2 + @');
} catch (error) {
  console.log(error.message);
}
```

```text
number:12@0
operator:+@3
number:3.5@5
operator:*@8
operator:(@9
number:7@10
operator:-@11
number:2@12
operator:)@13
Unexpected token at 4
```

代码在每次 `exec()` 前保存 `position`，因为失败会把粘性表达式的 `lastIndex` 重置为 `0`。若这里误用 `g`，引擎会越过 `@` 继续寻找下一个可匹配记号，扫描器便可能接受本应拒绝的间隙。

这个函数只做词法切分，不计算表达式。括号配对、运算符位置、优先级和除零属于后续解析或求值阶段；把“能分成合法记号”误当作“整个表达式合法”是另一层契约错误。

### 区分代码单元、码点与字素簇

Unicode 属性可以描述跨文字系统的字母与组合标记。与此同时，`\w` 仍然不代表自然语言中的所有单词字符，而 Unicode 感知的点号也只前进一个码点。

```javascript
// file: unicode_words.js
const label = 'naïve 中文 cafe\u0301 👩‍💻';
const unicodeWords = label.match(/[\p{Letter}\p{Mark}]+/gu);
const asciiWords = label.match(/\w+/g);
const codePoints = '👩‍💻'.match(/./gu);
const graphemes = [
  ...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment('👩‍💻'),
].map(({ segment }) => segment);

console.log(unicodeWords);
console.log(asciiWords);
console.log(codePoints);
console.log(graphemes);
```

```text
[ 'naïve', '中文', 'café' ]
[ 'na', 've', 'cafe' ]
[ '👩', '‍', '💻' ]
[ '👩‍💻' ]
```

`[\p{Letter}\p{Mark}]+` 把字母和组合标记放在一个字符类中，所以能保留分解形式的 `e` 加重音符号。真实单词切分还涉及语言、标点与书写系统；需要用户可见文本边界时，应使用合适区域设置的 `Intl.Segmenter`。

`/./gu` 把代理对组成的 emoji 码点作为一个单位，却仍把零宽连接符序列拆成三个码点。最后一行按字素簇切分，才把整个职业 emoji 当作一个用户感知字符。

## 陷阱

### 复用带状态的表达式

> **陷阱:** 把同一个 `/.../g` 或 `/.../y` 对象保存在模块常量中，再从多个调用点执行 `test()` 或 `exec()`，会让调用结果依赖之前留下的 `lastIndex`。连续两次对同一字符串做布尔测试也可能得到不同结果。

**修复方法：** 无需逐次状态时去掉 `g` 或在函数内创建表达式。确实需要扫描时，由一个所有者控制循环，在入口明确设置 `lastIndex`；异步操作之间不要共享可变的匹配器。

### 把动态文本当成模式语法

> **陷阱:** `new RegExp(query)` 会把 `.`、`[`、`*` 和反向引用等内容当作语法。普通搜索词可能扩大匹配范围、抛出 `SyntaxError`，或与周围模式组合出昂贵路径。

**修复方法：** 只在输入明确被允许描述正则语法时直接编译。字面搜索使用 Node 24 的 `RegExp.escape(query)`，并分别处理空字符串、最大长度、标志和外层边界。

### 用形状检查代替语义验证

> **陷阱:** 一个模式能确认 `2026-99-99` 具有数字和连字符的形状，却不能因此证明日期存在。手写邮件、URL 或 IP 地址模式也常与真正协议规则逐渐分叉。

**修复方法：** 让正则只筛选明确的词法形状，再通过领域 API 或数值规则校验捕获值。URL 使用 `URL`，JSON 使用 `JSON.parse()`，HTML 使用解析器；错误信息应对应实际失败层次。

### 假定 `\w`、`\b` 和点号理解人类文本

> **陷阱:** 生成的国际化代码常用 `\b\w+\b` 提取姓名，或用 `/^.$/u` 限制一个用户感知字符。前者遗漏许多文字系统，后者会把由多个码点组成的单个字素簇判为多个字符。

**修复方法：** 指明目标单位是 UTF-16 代码单元、Unicode 码点、字素簇还是语言单词。字符属性使用 `\p{...}` 配合 `u` 或 `v`；字素簇与单词边界使用 `Intl.Segmenter` 或领域库。

### 忘记替换字符串也有语法

> **陷阱:** `text.replace(pattern, replacement)` 中的字符串替换值会解释 `$&`、`$1`、`$<name>` 和 `$$`。若 `replacement` 是用户提供的字面文本，输入中的美元记号可能被改写成匹配内容。

**修复方法：** 需要捕获插值时明确使用替换记号；需要原样返回动态文本时传入函数，例如 `text.replace(pattern, () => replacement)`。测试包含 `$&`、`$1` 和连续美元符号的输入。

### 用惰性量词掩盖回溯风险

> **陷阱:** 嵌套量词、重叠分支和接近成功但最终失败的长输入，可能让回溯路径急剧增加。把 `.*` 改成 `.*?` 只改变尝试顺序，并不自动移除歧义。

**修复方法：** 消除同一文本可被多种方式切分的重复结构，用互斥字符类和明确分隔符缩小选择，并限制不可信输入长度。用长的近似匹配失败样本做测试；无法清楚约束时改用线性扫描器或专用解析器。

<!-- deep -->

## 匹配状态与 API 契约

`RegExp` 对象不仅是不可变的模式说明。它的 `source` 与标志描述匹配规则，`lastIndex` 则是可写数据属性；带 `g` 或 `y` 的 `exec()` 会把它作为状态使用。把这种对象导出成共享常量，等于同时共享一台带游标的匹配器。

全局匹配允许从 `lastIndex` 向后搜索，所以成功位置可以晚于起点。粘性匹配只尝试该起点，适合不能跳过无效文本的扫描器。失败时二者都把 `lastIndex` 重置为 `0`，因此诊断失败位置必须在调用前保存游标。

零长度匹配需要额外关注。直接用 `while ((match = regexp.exec(text)))` 迭代能匹配空字符串的全局模式时，成功后的 `lastIndex` 可能没有前进，循环便会重复同一结果。`matchAll()` 的迭代协议会为零长度结果推进索引；手写 `exec()` 循环则必须明确检测并按 Unicode 模式正确前进。

`matchAll()` 要求传入的 `RegExp` 带 `g`，随后复制模式与当前 `lastIndex` 来创建内部匹配器。迭代会改变副本，不会把最终游标写回原对象。不要把这一行为推广到 `test()`、`exec()` 或所有字符串方法，它们各自遵循自己的协议。

`match()` 有一个容易误用的分叉：没有 `g` 时返回一个详细匹配，有 `g` 时返回全部完整匹配文本，却丢掉逐项捕获与索引。需要全部匹配和每项命名捕获时，`matchAll()` 的结果结构更稳定。

### 捕获参与和索引

一个捕获组（capturing group）为结果增加槽位。分支中的组没有参与成功路径时，其值与对应索引都是 `undefined`；这不同于成功匹配空字符串得到的 `""` 和相等起止索引。

编号捕获受左括号顺序影响，在模式中间插入一个捕获组会改变后续编号。业务字段优先使用命名捕获，纯结构分组使用 `(?:...)`。反向引用仍然会增加匹配路径之间的依赖，因此命名并不会自动改善复杂度。

`d` 标志产生的索引遵循 JavaScript 字符串索引，也就是 UTF-16 代码单元偏移。即使 `u` 或 `v` 让一个代理对按单个码点匹配，跨过该码点后的索引仍会增加两个。切片可以直接使用这些偏移，但界面显示列号可能需要另行换算。

### 替换函数的参数

字符串替换模板提供 `$&` 表示完整匹配、`$1` 等编号捕获、`$<name>` 表示命名捕获以及 `$$` 表示字面美元符号。这套小语言适合固定模板，不适合承载应原样输出的外部文本。

函数替换器会接收完整匹配、各捕获值、匹配偏移、原字符串，以及存在命名捕获时的 `groups` 对象。可选捕获可能为 `undefined`，而参数尾部形状会随是否存在命名捕获变化；通用包装器不能只靠固定倒数位置猜测字段。

替换函数的返回值会转成字符串并按字面插入，不会再次解释 `$` 替换记号。这使它适合动态字面替换，也方便在转换前验证命名捕获。函数仍可能产生副作用，审查时要考虑它会按匹配次数调用。

## Unicode 模式与集合

没有 `u` 或 `v` 时，很多模式单元按 UTF-16 代码单元前进，一个非 BMP 码点由代理对组成。Unicode 感知模式把合法代理对作为一个码点处理，并启用 `\p{...}` 属性转义，同时让一些含糊或遗留的转义成为语法错误。

`v` 标志也表示 Unicode 感知模式，并扩展字符类语法。它支持交集 `&&`、差集 `--` 和可以匹配有限长度字符串的 Unicode 字符串属性。`u` 与 `v` 的字符类解析规则并不完全相同，迁移不能只替换标志而不重新执行测试。

属性转义必须表达真正的集合需求。`\p{Letter}` 匹配 Unicode 字母，`\p{Decimal_Number}` 匹配十进制数字；`\p{Script=Han}` 按脚本属性选择字符。它们不会自动定义用户名政策、自然语言单词或允许的规范化形式。

同样可见的文本可能有预组字符与分解序列两种编码。正则表达式不会自动执行 Unicode 规范化，因此两个视觉相同的字符串可能给出不同结果。若契约允许等价形式，应在匹配前选择并记录 `normalize()` 形式，而不是在模式中枚举偶然遇到的写法。

大小写不敏感匹配也不是区域化比较。`i` 使用规范定义的大小写折叠，不能替代搜索产品中的语言排序与区域规则。需要面向用户的查找时，应先确定规范化、大小写、重音和分词分别由哪一层负责。

## 回溯与信任边界

回溯发生在先前选择导致后续失败时。引擎回到可选择位置，缩短或扩大量词，或者尝试后续分支；若许多路径消费同一段文本，失败输入可能迫使它探索大量组合。回溯（backtracking）本身是正常机制，歧义与不可信规模的组合才形成风险。

典型危险形状包括嵌套重复、可以匹配相同前缀的重复分支，以及在宽泛重复之后才出现的失败条件。真实引擎可能优化部分简单模式，但不能把某次快速运行当作所有输入与运行时上的复杂度保证。

审查应优先寻找文本的唯一消费路径。把“任意字符”改成排除分隔符的字符类，把重叠分支提取成共同前缀，并在进入同步匹配前限制输入规模。安全测试要包含长的失败或近似成功输入，因为普通成功样本往往很快就结束。

JavaScript 的常规正则方法同步返回，单次匹配没有标准的超时或取消参数。运行在事件循环线程上的高代价匹配会阻塞其他工作；不可信模式或无法约束的输入需要更强的隔离、受限引擎，或不同算法，而不只是 `try...catch`。

不要给未经测量的改写贴上“更快”标签。模式优化必须同时保持匹配语言、捕获内容和状态行为；修正安全复杂度时，可以说明结构消除了哪一种歧义，而无需编造吞吐数字。

## 正则表达式的职责边界

正则表达式擅长查找局部词法结构，但 API 选择应反映数据的真正语法。`URL` 处理 URL 的解析与规范化，`JSON.parse()` 处理转义和嵌套，HTML 解析器处理树结构，`Intl.Segmenter` 处理面向用户的文本边界。

这不表示结构化输入中不能使用正则。你可以先逐行定位候选记录，或在解析后的字段内检查一个局部格式；关键是不要让模式悄悄承担完整解析器的责任。把每层输入、输出与失败方式写清楚，组合通常比一个巨型模式更容易测试。

模式也是代码，应有具名常量、邻近说明和针对边界的测试。说明应解释格式契约、Unicode 单位和信任假设，不要逐字符复述语法。模式复杂到无法用两三句话描述时，拆成带名称的阶段通常已经更合适。

<!-- /deep -->

[检查点: javascript/regexp](https://codewiki.com/zh/javascript/regexp/#checkpoint)

## 延伸阅读

- [ECMAScript 2026：RegExp 对象](https://tc39.es/ecma262/multipage/text-processing.html#sec-regexp-regular-expression-objects)
- [MDN JavaScript 指南：正则表达式](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions)
- [MDN：`RegExp.escape()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape)
- [MDN：`unicodeSets` 属性](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/unicodeSets)
- [OWASP：正则表达式拒绝服务](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
