讲解

量词默认是贪婪的:在允许范围内尽可能多匹配。/a.c/ 作用于 abxcyc 时,. 会先一口气吃到字符串末尾,发现 c 对不上再逐步回退(这个过程叫回溯),最终匹配 abxcyc 整串而不是 abxc。贪婪不是 bug,是设计好的默认行为——但当你想要「到最近的结束点为止」时,它就帮倒忙了。

在量词后面加一个 ? 就变成懒惰(非贪婪)模式:?、+?、??、{n,m}? 表示「尽可能少匹配」,每匹配一个字符就问一次「后面能接上了吗」。经典对比是对引号内容的提取:/"."/ 遇到 'a "first" b "second" c' 会把从第一个引号到最后一个引号的整段都吞掉,而 /".*?"/ 只取 "first"。

第三个思路往往比懒惰更好:用排除型字符类精确描述「内容里不能有什么」。匹配引号内容用 /"[^"]*"/——引号、非引号任意多个、引号。它既不会越界,又几乎没有回溯,性能最好。选择顺序:能写排除型字符类就别用懒惰,能用懒惰就别硬扛贪婪带来的意外跨度。

示例

const s = 'a "first" b "second" c';

// 贪婪:.* 吞到最后一个引号才罢休
console.assert(s.match(/".*"/)[0] === '"first" b "second"');

// 懒惰:.*? 在最近的引号处收工
console.assert(s.match(/".*?"/)[0] === '"first"');
console.assert(s.match(/".*?"/g).length === 2); // 加 g 逐个取
console.assert(s.match(/".*?"/g)[1] === '"second"');
const s = 'a "first" b "second" c';

// 排除型字符类:内容里不允许出现引号,一步到位
console.assert(s.match(/"[^"]*"/g).join(',') === '"first","second"');

// 懒惰不是「全局最短答案」:引擎仍从最左边能成功的位置开始
const t = 'aXbXc';
console.assert(t.match(/a.*X/)[0] === 'aXbX'); // 贪婪到最后一个 X
console.assert(t.match(/a.*?X/)[0] === 'aX'); // 懒惰在第一个 X 收工

HTML 场景的经典对比(提取标签内容):

const html = '<p>第一段</p><p>第二段</p>';

// 贪婪会把两个标签之间的内容全吞了
console.assert(html.match(/<p>.*<\/p>/)[0] === '<p>第一段</p><p>第二段</p>');

// 懒惰逐个取;更稳的写法是排除型 [^<]*
console.assert(html.match(/<p>.*?<\/p>/g).length === 2);
console.assert(html.match(/<p>([^<]*)<\/p>/g).length === 2);
const contents = [...html.matchAll(/<p>([^<]*)<\/p>/g)].map((m) => m[1]);
console.assert(contents.join(',') === '第一段,第二段');

常见坑

  • 用 . 跨段匹配*:.* 默认不跨行但会横跨整行,在日志、HTML 里经常吞掉远超预期的内容。先想想「内容里不该出现什么字符」,优先排除型字符类。
  • 以为懒惰一定更快:懒惰模式每次前进一步都要试探后续能否匹配,结尾缺失时试探成本反而更高。性能差异取决于数据形态,不要想当然。
  • 忘记懒惰的语法位置:? 紧跟量词才是懒惰修饰(*?、+?),写在别处就成了「可选」量词本身。
  • 贪婪 + 嵌套量词引发灾难性回溯:(a+)+ 这类结构在特定输入上会让回溯次数指数爆炸,最佳实践一章专门讲。

小结

量词默认贪婪,加 ? 变懒惰,排除型字符类通常是最优解。下一节学习零宽断言:前瞻与后顾。