正则表达式(regular expression) 用一段模式描述文本的形状;JavaScript 通过 RegExp 以及字符串方法完成查找、提取和替换。
带 g 或 y 的表达式会修改 lastIndex,动态文本若未经 RegExp.escape() 就进入模式,还会改变模式含义或造成高代价回溯。
先写清匹配范围、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 读取消息字段的绝对范围。命名捕获让调用代码不依赖捕获顺序。
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));[
{
"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
]
}
]matchAll() 保留了每次匹配的捕获详情,并在内部副本上推进状态,所以 linePattern.lastIndex 不会被这次迭代留下变化。消息中出现非 BMP 字符时,messageSpan 仍然是 UTF-16 索引,不能直接当作用户感知字符数。
这个模式只验证日志行的形状,不验证时间是否真实存在。若小时与分钟有业务范围,应在捕获后把字段转成数字再做语义检查;继续把日历规则塞进模式只会让契约更难读。
安全插入动态字面量
关键词来自数据,而不是正则语法。RegExp.escape() 把每个词变成可以安全嵌入更大模式的源文本,再用分支连接它们。
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));[ '\\x43\\+\\+', '\\x6eode\\.js', '\\[draft\\]' ]
[ '[draft]', 'C++', 'Node.js' ]输出中的首字母十六进制转义是有意设计的。它防止转义结果紧跟在 \1、\x0 等片段后时,被误解为前一个转义的一部分;手写“给几个元字符加反斜杠”的函数通常不会覆盖这种拼接上下文。
RegExp.escape() 只保证文本按字面解释,不负责授权、长度限制或匹配范围。关键词数组为空时,escaped.join('|') 会产生空模式并在每个位置匹配,因此调用方还需要为“没有关键词”定义独立行为。
用粘性匹配连续扫描
词法扫描要求每个新记号紧接上一个记号,不能悄悄跳过未知字符。y 标志把 lastIndex 变成必须满足的起点,因此很适合这种连续消费契约。
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);
}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 感知的点号也只前进一个码点。
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);[ 'naïve', '中文', 'café' ]
[ 'na', 've', 'cafe' ]
[ '👩', '', '💻' ]
[ '👩💻' ][\p{Letter}\p{Mark}]+ 把字母和组合标记放在一个字符类中,所以能保留分解形式的 e 加重音符号。真实单词切分还涉及语言、标点与书写系统;需要用户可见文本边界时,应使用合适区域设置的 Intl.Segmenter。
/./gu 把代理对组成的 emoji 码点作为一个单位,却仍把零宽连接符序列拆成三个码点。最后一行按字素簇切分,才把整个职业 emoji 当作一个用户感知字符。
陷阱
复用带状态的表达式
修复方法: 无需逐次状态时去掉 g 或在函数内创建表达式。确实需要扫描时,由一个所有者控制循环,在入口明确设置 lastIndex;异步操作之间不要共享可变的匹配器。
把动态文本当成模式语法
修复方法: 只在输入明确被允许描述正则语法时直接编译。字面搜索使用 Node 24 的 RegExp.escape(query),并分别处理空字符串、最大长度、标志和外层边界。
用形状检查代替语义验证
修复方法: 让正则只筛选明确的词法形状,再通过领域 API 或数值规则校验捕获值。URL 使用 URL,JSON 使用 JSON.parse(),HTML 使用解析器;错误信息应对应实际失败层次。
假定 \w、\b 和点号理解人类文本
修复方法: 指明目标单位是 UTF-16 代码单元、Unicode 码点、字素簇还是语言单词。字符属性使用 \p{...} 配合 u 或 v;字素簇与单词边界使用 Intl.Segmenter 或领域库。
忘记替换字符串也有语法
修复方法: 需要捕获插值时明确使用替换记号;需要原样返回动态文本时传入函数,例如 text.replace(pattern, () => replacement)。测试包含 $&、$1 和连续美元符号的输入。
用惰性量词掩盖回溯风险
修复方法: 消除同一文本可被多种方式切分的重复结构,用互斥字符类和明确分隔符缩小选择,并限制不可信输入长度。用长的近似匹配失败样本做测试;无法清楚约束时改用线性扫描器或专用解析器。
匹配状态与 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 单位和信任假设,不要逐字符复述语法。模式复杂到无法用两三句话描述时,拆成带名称的阶段通常已经更合适。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题