FORMA

正则表达式

JavaScript 使用 ECMAScript RegExp 实现,支持贪婪/惰性量词、断言与 matchAll 等。

一、贪婪与惰性匹配

1. 贪婪匹配(默认)

量词(*+?{m,n} 等)在默认情况下会匹配尽可能多的字符。

js
const str = "<div>content</div><span>text</span>";
const greedy = /<.*>/; // 匹配从第一个 < 到最后一个 >
console.log(greedy.exec(str)[0]);
// 输出: "<div>content</div><span>text</span>" (整个字符串)

2. 惰性匹配(非贪婪)

在量词后添加 ? 使其匹配尽可能少的字符。常用语提取 HTML 标签内的内容。

js
const lazy = /<.*?>/; // 匹配最短的 <...>
console.log(lazy.exec(str)[0]);
// 输出: "<div>" (只匹配第一个标签)

常用惰性量词*?+???{m,n}?

二、反向引用(Backreference)

反向引用允许你在正则表达式中引用前面捕获组匹配的内容。语法为 \1, \2 ...(数字对应捕获组顺序)。

1. 匹配重复单词

js
const str = "hello hello world";
const regex = /\b(\w+)\s+\1\b/;
console.log(regex.test(str)); // true

2. 匹配成对标签

js
const html = "<div>content</div><span>text</p>";
const tagRegex = /<([a-z]+)>.*?<\/\1>/i;
console.log(tagRegex.test(html)); // false,因为第二个标签不匹配

3. 在替换中使用 $1

js
const date = "2025-04-25";
const newDate = date.replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
console.log(newDate); // "25/04/2025"

三、前瞻与后顾(Lookahead & Lookbehind)

断言匹配位置而不消耗字符,分为正向(肯定)和负向(否定)。

类型语法含义
正向前瞻(?=...)后面必须跟着 ...
负向前瞻(?!...)后面不能跟着 ...
正向后顾(?<=...)前面必须是 ...(ES2018 支持)
负向后顾(?<!...)前面不能是 ...(ES2018 支持)

1. 正向前瞻:匹配后面是数字的字母

js
const str = "a1 b2 c3 d";
const regex = /[a-z](?=\d)/g;
console.log(str.match(regex)); // ['a','b','c']

2. 负向前瞻:匹配后面不是数字的字母

js
const regex2 = /[a-z](?!\d)/g;
console.log(str.match(regex2)); // ['d']

3. 正向后顾:匹配前面是 @ 的用户名

js
const text = "email: user@example.com, contact@test.org";
const regex = /(?<=@)[a-z0-9.-]+\.[a-z]+/g;
console.log(text.match(regex)); // ['example.com', 'test.org']

4. 负向前瞻:匹配协议不是 https 的 URL

js
const urls = "http://example.com https://secure.com ftp://files.com";
const regex = /\b(?!https:\/\/)\w+:\/\/[^\s]+/g;
console.log(urls.match(regex)); // ['http://example.com', 'ftp://files.com']

注意:此处用负向前瞻而非后顾——\b 定位在协议名开头,紧接着判断后面是否为 https://。若改用后顾 (?<!https:\/\/)\b...,断言检查的是 \b 前面的内容,而字符串开头或空格处并不是 https://,因此该断言恒为真,不会起到排除作用,实际会匹配出全部三个 URL。

四、execmatchAll 的迭代使用

1. RegExp.prototype.exec

每次调用返回下一个匹配结果(带捕获组),直到 null。需要手动维护 lastIndex

js
const regex = /(\d{4})-(\d{2})-(\d{2})/g;
const str = "2025-04-25, 2026-01-01";
let match;
while ((match = regex.exec(str)) !== null) {
    console.log(
        `Full: ${match[0]}, Year: ${match[1]}, Month: ${match[2]}, Day: ${match[3]}`
    );
}

2. String.prototype.matchAll

ES2020 引入,返回可迭代对象,更简洁。必须在正则上设置 /g 标志

js
const regex = /(\d{4})-(\d{2})-(\d{2})/g;
const str = "2025-04-25, 2026-01-01";
for (const match of str.matchAll(regex)) {
    console.log(`Year: ${match[1]}, Month: ${match[2]}, Day: ${match[3]}`);
}

优势:无需手动处理 lastIndex,且每次迭代返回的数组结构与 exec 相同(包含捕获组)。

五、常见性能陷阱:灾难性回溯(Catastrophic Backtracking)

当正则表达式包含嵌套量词重叠的选择分支时,引擎可能尝试指数级的路径,导致 CPU 飙升甚至挂起。

1. 危险模式示例

js
// 极其危险的表达式
const dangerous = /^(a+)+$/;
// 匹配 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX" 时,会逐层回溯,耗时极长

典型危险模式

  • (a+)+(a|a)+(a*)*
  • (a+)*b 与长串不带 ba
  • (\w+)+ 配合长度较大的字符串

2. 为什么会发生?

正则引擎(如 PCRE、JavaScript 的 V8)在进行分支选择时,会尝试所有可能路径。遇到 (a+)+ 匹配完 a 后,由于外层的 + 可以重复,回退时会产生大量排列组合。

3. 如何避免?

  • 使用原子分组(JavaScript 本身不支持,但可通过前瞻模拟)
    js
    // 模拟原子分组 (?=(a+))\1
    const safe = /^(?=(a+))\1+$/;
    
  • 占有量词(JS 也不支持,但可以转换为非回溯结构)
  • 简化表达式:避免不必要的嵌套量词。
    js
    // 坏:/^(a+)+$/
    // 好:/^a+$/(如果只需要匹配一个或多个 a)
    
  • 限制输入长度:在应用层面限制字符串长度。
  • 使用 String.prototype.searchindexOf 替代正则(对于简单的子串匹配)。
  • 使用多个独立正则代替一个复杂正则。
  • 工具检测:使用 safe-regex 库或在线分析工具(如 regex101.com)查看回溯次数。

4. 真实案例:检测空格的 /\s*,\s*/ 是安全的,但 /(a|aa)*b/ 匹配 aaaaaaaa...(无 b)时可能灾难。

总结

概念关键点
贪婪 vs 惰性默认贪婪(尽可能多),加 ? 变惰性(尽可能少)
反向引用\1 引用捕获组内容;替换中 $1 使用
前瞻/后顾零宽断言:(?=...)(?!...)(?<=...)(?<!...)。后顾需 ES2018+
迭代匹配exec 需手动循环;matchAll 返回迭代器,更现代
性能陷阱避免嵌套量词和重叠选择,警惕灾难性回溯;可用原子组模拟或简化表达式

复杂 HTML/XML 解析优先用专用解析器,勿用正则勉强匹配嵌套结构。

参考文献

以下链接在编写时均可正常访问:

资料说明
MDN:RegExp正则对象
MDN:Regular expressions指南
MDN:matchAll迭代匹配
ECMA-262:RegExp规范(英文)