正则表达式:模式、捕获、状态与回溯
正则表达式描述文本模式。固定找一个词用 includes 就够了;当位置、字符类别和重复次数一起参与规则时,正则才开始省事。目标是读得出每个部分做什么,而不是攒一条传说中的万能表达式。
从一个可拆开的模式开始
const pattern = /^JS-\d{3}$/;
console.log(pattern.test('JS-204')); // true
console.log(pattern.test('xJS-204')); // false
console.log(pattern.test('JS-20')); // false^ 要求开头,JS- 是固定文本,\d 匹配 ASCII 数字,{3} 重复三次,$ 要求结束。不写锚点时,test 可以在较长文本中找到局部片段。
正则字面量 /.../ 适合静态模式;new RegExp(...) 适合动态构造。构造器字符串要先经过 JavaScript 字符串转义,所以同一数字模式写成 new RegExp('\\d+')。一层给字符串解析,一层给正则解析,两个反斜线各有工作。
字符类、量词与分组
| 写法 | 含义 |
|---|---|
[abc]、[a-z]、[^0-9] | 集合、范围、排除集合 |
\d/\D、\w/\W、\s/\S | 数字、单词字符、空白及各自补集 |
. | 默认匹配除行终止符外的字符 |
*、+、?、{m,n} | 0次以上、1次以上、0或1次、指定范围 |
(...)、(?:...)、(?<name>...) | 捕获、仅分组、命名捕获 |
| `a | b` |
量词作用于前一个字符、字符类或分组。ab+ 重复 b,(ab)+ 重复 ab。分支优先级较低,完整匹配两种词应写 ^(cat|dog)$,不是 ^cat|dog$。
const result = /^(?<topic>[A-Z]+)-(?<number>\d+)$/.exec('JS-204');
console.log(result[0], result.groups.topic, result.groups.number); // JS-204 JS 204
console.log(result.index); // 0exec 成功返回匹配数组,索引 0 是完整匹配,后面是捕获组,并含 index/input/groups 等信息;失败返回 null,读取属性前应判断。反向引用 \1、\k<name> 要求后面匹配先前捕获的文本,不只是“同一类字符”。
要求重复的是内容,不能只重复字符类别
假设记录要求代码在两端保持一致,接受 AB:AB,拒绝 AB:CD。写 /^[A-Z]+:[A-Z]+$/ 只要求两边都是大写字母,第二处字符类并不知道第一处读到了什么。
const loose = /^[A-Z]+:[A-Z]+$/;
const repeated = /^([A-Z]+):\1$/;
console.log(loose.test('AB:CD')); // true:只检查类别
console.log(repeated.test('AB:AB'), repeated.test('AB:CD')); // true false
const named = /^(?<code>[A-Z]+):\k<code>$/;
console.log(named.exec('XY:XY').groups.code); // XY第一组先捕获AB,冒号消耗分隔符,\1 再要求后面出现已捕获的AB。括号按左括号出现顺序编号,(?:...) 只分组、不占编号;命名引用能避免前面增加一个捕获组后编号意外改变。模式里用 \1,替换字符串里用 $1,两者处在不同的语法位置。
重复匹配也服从flags:/^([a-z]+):\1$/i 可以接受 ab:AB。若要求逐码元相同,就不要加入忽略大小写。还有一个容易漏掉的边界:没有参与匹配的捕获组,其反向引用按空文本成功:
console.log(/^(a)?\1$/.test('')); // true
console.log(/^(a)\1$/.test('')); // false第一行的组是可选的,输入为空时可以完全不参加。需要“必须先出现,再重复”时,不要把第一次捕获写成可选;应像代码记录那样让组必须匹配。
flags 改变匹配方式
flags作用在不同层次,不能只记成一排开关:i 决定大小写如何比较,m/s 改变边界和点号,u/v 改变Unicode解释,g/y 管理扫描位置,d 增加匹配结果的下标。flags不能重复,u与v不能同时使用。
m 改边界,s 改点号
const text = 'top\nbottom';
console.log(/^bottom$/.test(text)); // false:整个输入不只是bottom
console.log(/^bottom$/m.test(text)); // true:其中一行是bottom
console.log(/^top.bottom$/.test(text)); // false:点号不跨换行
console.log(/^top.bottom$/s.test(text)); // true
console.log(/^top.bottom$/m.test(text)); // false:m没有改变点号m 让 ^/$ 额外识别行终止符两侧的位置,它不让模式自动跨行;s 让点号覆盖行终止符,它不改变锚点。校验完整输入时不能为方便加上m,否则一行满足条件就可能通过。i 允许大小写折叠,例如 /^js$/i 接受JS,但并不负责语言翻译或所有文本规范化。
u 让一次匹配以码点理解文本
console.log(/^.$/.test('😀')); // false:默认点号只吃一个码元
console.log(/^.$/u.test('😀')); // true:u下吃一个码点
console.log(/^\p{Letter}+$/u.test('知识')); // true
console.log(/^\w+$/u.test('知识')); // false
console.log(/^.$/u.test('e\u0301')); // false:两个码点\w 不是全世界文字的集合,添加u也不会把它扩成中文词汇。Unicode属性转义 \p{Letter} 表达字母类别,\P{Letter} 表达其补集,需要u或v模式。u识别代理对,仍不把多个码点的字素簇当作一个字符;若需求是用户感知字符,回到前篇Intl.Segmenter。
v 把“同时属于”和“排除”写进字符集合
假设只允许ASCII小写辅音。[a-z] 先圈出候选,排除元音得到 [[a-z]--[aeiou]]。想表达两类属性同时成立,用交集 &&:
// Node.js 22可运行;构造器让不支持v的环境在执行这一行时报告错误。
const consonants = new RegExp('^[[a-z]--[aeiou]]+$', 'v');
console.log(consonants.test('rhythm'), consonants.test('read')); // true false
const asciiLetters = new RegExp('^[\\p{ASCII}&&\\p{Letter}]+$', 'v');
console.log(asciiLetters.test('JS'), asciiLetters.test('知识')); // true false第二个模式要求字符同时属于ASCII和Letter,因此数字虽属于ASCII仍被排除。v还可用 \q{...} 表示有限个字符串成员:
const pair = new RegExp('^[\\q{ch|ll}]$', 'v');
console.log(pair.test('ch'), pair.test('ll'), pair.test('c')); // true true false这里一个集合成员可以消费两个码点,不能再概括成“方括号必定只匹配一个字符”。部分Unicode字符串属性也有这个能力,例如表情序列属性;它仍是有限字符串集合,不是递归语法解析器。
范围作为差集操作数时先放进自己的方括号,所以写 [[a-z]--[aeiou]],不能省成 [a-z--[aeiou]]。同一层不要直接混写交集与差集,需要嵌套方括号说明分组;v中更多标点被保留,例如要匹配字面连字符时写 \-。迁移旧字符类应逐项核对,不能只把u改成v。需要兼容旧引擎时,将模式放在 new RegExp 字符串中才有机会在运行时检查;不支持的正则字面量会使整份文件解析失败。
d 的下标能直接用于字符串切片
const text = '😀JS-12';
const result = /(?<topic>JS)-(\d+)/du.exec(text);
const [start, end] = result.indices.groups.topic;
console.log(start, end, text.slice(start, end)); // 2 4 JS
console.log(result.indices[0]); // [2, 7]d不改变能否匹配,只增加完整匹配和捕获组的 [start, end],结束位置不包含。这里表情占两个UTF-16码元,所以JS起点是2;即使使用u,下标仍按码元计量,正好能传给slice。捕获组未参与匹配时,它的下标也可能为undefined,不能直接解构。
test 也可能有状态
const token = /JS/g;
console.log(token.test('JS'), token.lastIndex); // true 2
console.log(token.test('JS'), token.lastIndex); // false 0
console.log(token.test('JS'), token.lastIndex); // true 2带 g/y 的正则会使用并更新 lastIndex,不是每次从零开始。g 可以从该位置向后搜索,y 必须恰好从该位置匹配;失败会把 lastIndex 重置为 0。只做反复校验时通常不要带 g。
用 exec 循环处理可匹配空字符串的模式时,如果匹配后没有推进,会卡在同一位置。初学批量取结果可优先使用 matchAll,避免自己维护游标。
g 可以越过空隙,y 要求当前位置就成功
const input = '12 ? 34';
const search = /\d+/g;
const sticky = /\d+/y;
search.lastIndex = 3;
sticky.lastIndex = 3;
console.log(search.exec(input)[0], search.lastIndex); // 34 7
console.log(sticky.exec(input), sticky.lastIndex); // null 0
sticky.lastIndex = 5;
console.log(sticky.exec(input)[0], sticky.lastIndex); // 34 7从位置3的问号开始,g向后寻找,在位置5找到34;y就在问号处失败,并把lastIndex重置为0。若在逐段读取格式化文本,跳过问号会隐藏非法片段,这时应明确位置并用y;若只想收集散落的数字,g恰好合适。两种模式都不会自动替你校验跳过的分隔符。
不带g/y时,exec会忽略人为设置的lastIndex;带g也不代表一次exec返回全部结果,一次仍只交出一个匹配。扫描位置与一次返回多少结果是两项独立规则。
方法的返回形状决定如何消费
const text = 'JS-12 JS-34';
console.log(text.match(/\d+/g)); // ['12', '34']
const matches = [...text.matchAll(/JS-(\d+)/g)];
console.log(matches.map(match => match[1])); // ['12', '34']
console.log(text.search(/34/)); // 9
console.log(text.replace(/JS-(\d+)/g, (_, n) => `第${n}节`)); // 第12节 第34节不带 g 的 match 返回类似 exec 的一次结果;带 g 返回所有完整匹配,但没有各次捕获细节。matchAll 返回一次性迭代器,每个元素包含捕获信息;不是数组,展开后才是数组。传入正则时需要 g,无匹配时为空迭代器。
search 返回首个位置或 -1;split 按模式切开,捕获组可能进入结果数组;replace 支持替换函数和 $&/$1/$<name> 等替换语法。函数返回文本不再被当作这些占位符解释。
matchAll 不修改原游标,但会采用开始时的游标
const pattern = /a/g;
pattern.lastIndex = 1;
const matches = 'aba'.matchAll(pattern);
console.log([...matches].map(match => match.index)); // [2]
console.log(pattern.lastIndex); // 1
console.log([...matches]); // [],同一个迭代器已消费完matchAll用内部匹配器扫描,不把扫描进度写回原正则;但内部匹配从原正则当前lastIndex开始,不是无条件从零开始。第一处a位于0,被起点1跳过,最终只得到位置2。
迭代器消费一次后已经到末尾,再展开不会自动重放。要重新扫描,应重新调用matchAll;若只需要复用结果,就在第一次扫描时收集成数组。这样才能解释为什么for...of适合单次流式处理,而数组适合多次访问。
对于批量匹配,match(/.../g)只收集完整文本,matchAll保留每次的分组信息,exec则允许你逐次管理正则游标。根据是否需要捕获信息和游标控制来选择,不是越新的方法就适合所有任务。
断言检查位置,不消费文本
console.log('12px 8em'.match(/\d+(?=px)/g)); // ['12']
console.log('¥12 $8'.match(/(?<=¥)\d+/g)); // ['12']
console.log('cat dog'.match(/\b\w+\b/g)); // ['cat', 'dog'](?=...) 前瞻,(?!...) 否定前瞻,(?<=...) 后顾,(?<!...) 否定后顾。它们检查左右条件,却不把所检查部分纳入消费范围。可读性变差时,把校验拆成几步通常比堆叠断言更容易维护。
贪婪、惰性和灾难性回溯
console.log('<a><b>'.match(/<.*>/)[0]); // <a><b>
console.log('<a><b>'.match(/<.*?>/)[0]); // <a>
console.log('<a><b>'.match(/<[^>]*>/)[0]); // <a>默认量词尽量多匹配,后面加 ? 变惰性,尽量少匹配;两者都可能回溯。能用明确排除字符表达终点时,通常比任意字符量词更清楚。
类似 ^(a+)+$ 的嵌套重复,在很长的 a 后追加一个不匹配字符时,可能尝试大量拆分方式。这是回溯爆炸,不要运行巨大输入来“看看有多慢”。约束输入长度、消除歧义重复,必要时采用解析器。惰性量词并不能自动解决这类复杂度问题。
动态插入外部文本时需转义正则元字符。现代 RegExp.escape 专门处理字面文本,但不是 Node 22 全小版本的统一假设;固定字符串搜索可以直接用 includes,避免不必要地构造正则。RegExp.prototype.compile 等旧接口不适合新的代码。
回溯是退回一个选择,尝试另一条路径
console.log(/^(?:ab|a)b$/.test('ab')); // true
console.log(/^a.+b$/.test('axxb')); // true
console.log(/a|ab/.exec('ab')[0]); // a第一行先尝试分支ab,已经用完整个输入,但分组后还要求一个b,于是失败;回到分支起点改试a,再由分组后的b消费最后一位,整体成功。这说明先选中的分支可以因后续条件失败而被放弃。
第二行的 .+先尽量吃掉xxb,末尾b没有字符可用,于是退回一个字符,让量词只吃xx,尾部b终于匹配。贪婪不是“绝不退让”,而是优先尝试更多,再在后续失败时让出字符。
第三行却直接得到a:第一个分支已经足以让整个模式成功,没有末尾锚点或其他条件要求继续,因此不会为了更长结果再试ab。正则按路径找到首个成功匹配,不是自动在所有成功结果中挑最长的那个。
嵌套重复的问题也由路径数量产生。对于一串aaaa,(a+)+可以按aaaa、a+aaa、aa+aa等方式拆成多组;若后缀持续失败,就可能反复尝试大量拆分。把内层量词变惰性只改变尝试顺序,不消除这些组合。修改模式应消除不必要的歧义,而不是只给量词加一个问号。
这些推导使用短输入观察规则,不将某引擎的具体优化路径当作语言保证。正则用于局部文本模式很方便,遇到递归嵌套结构或复杂格式,使用解析器更能明确语法边界。
练习
写出匹配
NOTE-加 2~4 位 ASCII 数字的完整输入模式,并给出两个应拒绝的例子。使用同一个
const pattern = /a/g连续调用pattern.test('a'),结果为什么会变化?若只做校验怎样修改?从
'NOTE-12; NOTE-345; bad'提取所有NOTE后面的数字文本,返回['12', '345']。用matchAll配合本章已学的数组方法实现,并验证没有匹配时返回空数组;这里是搜索片段,不是验证整个输入格式。解释/^(?:ab|a)b$/匹配ab时怎样退回分支;为什么/a|ab/却只得到a?matchAll第二次展开为何为空?
修复
/^[A-Z]+:[A-Z]+$/,要求两侧代码完全相同且非空。测试'JS:JS'、'JS:TS'、':'、'JS:js';如果需求改为忽略大小写,只应改变哪里?再为v模式写一个仅接受ASCII数字且排除0的集合,拒绝全角数字。