跳到主要内容

断言与回溯

断言(Assertion)和回溯(Backtracking)是正则中"分水岭"级别的内容。会用断言,意味着你能写出"在某个条件下匹配"的精准模式;理解回溯,意味着你能解释为什么一行正则会让页面卡死。这两块都是高级前端面试的高频考点。

断言

断言用来匹配一个位置,而不是字符。它要求这个位置满足某种条件,但不消耗任何字符。先看一下分类:

类型写法含义
正向先行断言(?=...)右边必须出现 ...
负向先行断言(?!...)右边不能出现 ...
正向后行断言(?<=...)左边必须出现 ...
负向后行断言(?<!...)左边不能出现 ...

注意"先行/后行"对应"右边/左边","正/负"对应"必须出现/不能出现"。这四个组合是面试常考的口诀。

记忆技巧:

  • lookahead = 看前方 = 右边
  • lookbehind = 看后方 = 左边
  • positive = 必须满足
  • negative = 不能满足

先行断言

正向先行 (?=...)

匹配一个位置,要求其右边是指定模式:

// 匹配后面跟着数字的字母
"a1 b2 c3".match(/[a-z](?=\d)/g); // ['a', 'b', 'c']
// 注意:数字本身没有被消耗
"a1 b2 c3".match(/[a-z]\d/g); // ['a1', 'b2', 'c3']

经典应用:在金额中加千分位

"123456789".replace(/\B(?=(\d{3})+$)/g, ",");
// '123,456,789'

原理分析:

  • \B:非单词边界,确保不是开头位置
  • (?=(\d{3})+$):右边必须是 3 的倍数个数字直到结尾
  • g:所有满足条件的位置都替换

负向先行 (?!...)

匹配一个位置,要求其右边不是指定模式:

// 匹配不以数字开头的单词
"abc 1abc xyz".match(/\b(?!\d)\w+/g); // ['abc', 'xyz']
// 排除某个词
const re = /\b(?!cat\b)\w+/g;
"cat dog category".match(re); // ['dog', 'category']
// 注意 cat 仍然在 category 里出现,但负向断言只看完整词边界

后行断言

ES2018 引入,浏览器支持已经较好。

正向后行 (?<=...)

匹配一个位置,要求其左边是指定模式:

// 匹配 ¥ 后面的数字
"价格 ¥100,数量 ¥50".match(/(?<=)\d+/g); // ['100', '50']

负向后行 (?<!...)

匹配一个位置,要求其左边不是指定模式:

// 匹配不在 ¥ 后面的数字
"¥100 和 50".match(/(?<!)\b\d+\b/g); // ['50']

后行断言的经典场景是"基于上下文做条件匹配",比如把 foo 替换为 bar,但只在 import 语句里:

"import foo;\nfoo();".replace(/(?<=import\s)foo/g, "bar");
// 'import bar;\nfoo();'

断言的"不消耗"特性

断言最关键的特点是不消耗字符。这意味着同一个字符可以被多个断言同时检查:

// 匹配既有数字又有大写字母又有特殊字符的位置(密码强度示例)
const re = /^(?=.*\d)(?=.*[A-Z])(?=.*[!@#$]).+$/;
re.test("aB3!"); // true
re.test("abc123"); // false,缺少大写和特殊字符

每个 (?=.*...) 都从头开始匹配,互不影响,最终要求整个字符串同时满足三个条件。这是断言最常见的组合用法。

回溯原理

正则引擎默认是 NFA(非确定有限自动机) 回溯引擎,它尝试用"试错"的方式匹配:当前路径走不通时,回退到上一个决策点,尝试其他可能。

一次完整匹配的过程

"aaa"!/.a.a/ 为例(这里用更清晰的例子):

const re = /a.*a/;
re.exec("a b c a x");

执行过程:

  1. 引擎在位置 0 匹配到 a
  2. .* 是贪婪的,一路匹配到字符串末尾 x
  3. 接下来正则要求一个 a,但已经到末尾,匹配失败。
  4. 回溯.* 吐出一个字符,尝试从倒数第二个位置匹配 a,失败。
  5. 继续回溯,直到 .* 回退到 c a 之间的 a,匹配成功。

这就是回溯:当后续匹配失败时,前面的量词"退还"已经匹配的字符,重新尝试。

贪婪量词的回溯

"aabaa".match(/a.*a/);
// .* 先吞掉 'abaa',然后回溯找到最后一个 a
// 结果:['aabaa']

懒惰量词的回溯

懒惰量词是"先尝试最少,不够再增加":

"aabaa".match(/a.*?a/);
// .*? 先匹配 0 个字符,然后正则需要 a,刚好下一个就是 a
// 结果:['aa']

懒惰量词同样会回溯,只是方向相反:从少到多。

灾难性回溯

当多个贪婪量词嵌套,且存在多种"分配方式"都看起来可能成功时,引擎会尝试指数级的回溯路径,导致性能爆炸。

经典灾难案例

// 一个看似无害的正则
const re = /^(a+)+$/;

// 输入稍微特殊就会卡死
re.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"); // 数十秒甚至更久

执行过程:

  • (a+)+ 有两层量词,外层和内层都可以"吞"或"吐"字符。
  • 对于一个长度为 n 的 aaa... 字符串,匹配失败时,引擎会尝试所有可能的内层/外层分组方式,数量是 2 的 n 次方
  • 最后那个 ! 让整个匹配必然失败,但引擎要把所有路径都试完才能确认失败。

这就是ReDoS(Regular Expression Denial of Service) 攻击的原理。一些真实的历史漏洞(如著名的 Cloudflare ReDoS)都源于此。

如何避免

  1. 避免嵌套量词(a+)+(a*)*(a|a)* 这类写法是高危信号。
  2. 使用具体字符类:用 [^,]+ 而不是 .* 来匹配字段。
  3. 限定范围:用 {1,100} 替代 *+,限制回溯次数。
  4. 优先懒惰:能用 .*? 就别用 .*
  5. 使用原子组(见下文)。
  6. 预处理输入:对超长输入直接拒绝,或先做长度校验。

一个真实例子:URL 校验

// 反例:嵌套量词,长 URL 容易卡死
const bad = /^(\w+:\/\/)?[\w.-]+\.[a-z]{2,}(\/.*)*$/;

// 改进:去掉不必要的嵌套,限定重复次数
const good = /^https?:\/\/[\w.-]+\.[a-z]{2,}(\/[^\s]*)?$/;

原子组

原子组 (?>(...)) 是一种特殊分组:匹配成功后,内部量词的回溯点会被丢弃,引擎不能再回退到原子组内部尝试其他路径。

// 普通分组:会回溯
/a(a|b)+c/.test("aabb"); // false,但会尝试很多回溯路径

// 原子组(部分引擎支持):内部匹配后丢弃回溯点
/a(?>a|b)+c/;

遗憾的是,JavaScript 长期不支持原子组语法 (?>...)。直到 ES2025 才正式加入。在此之前,可以用以下方式模拟:

  1. 更具体的字符类:用 [^,]+ 代替 (a|b|c)+
  2. 占有量词(部分支持):a*+ 等价于 (?>a*)
  3. 限制长度:用 {0,100} 替代 *

占有量词示例

// ES2025 起支持占有量词
const re = /^\s*+[\w-]+@[a-z]+\.[a-z]{2,}$/i;
// \s*+ 一旦匹配完空白就不再回溯

断言实战示例

密码强度校验

function isStrongPassword(pw) {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/.test(pw);
}
isStrongPassword("Abc123!@"); // true
isStrongPassword("abc123!@"); // false,缺大写
isStrongPassword("Abcdefgh!@"); // false,缺数字

千分位格式化

"1234567890".replace(/\B(?=(\d{3})+$)/g, ",");
// '1,234,567,890'

去除字符串中的 HTML 标签(保留内容)

"<div>hello</div>".replace(/<\/?[^>]+>/g, "");
// 'hello'

面试常见提问

  1. 先行断言和后行断言的区别? —— 前者看右边,后者看左边。
  2. 断言会消耗字符吗? —— 不会,这就是它能在同一字符上叠加多个条件的原因。
  3. 什么是回溯? —— 后续匹配失败时,前面的量词退回字符重新尝试。
  4. 什么是灾难性回溯?怎么避免? —— 嵌套量词导致指数级路径,避免嵌套、限定长度、用原子组。
  5. (?=.*\d)(?=.*[a-z]) 为什么能同时校验"既含数字又含字母"? —— 两个先行断言都从当前位置(开头)开始且不消耗字符。

小结

概念关键点
先行断言看右边,(?=) 正向 (?! 负向
后行断言看左边,(?<=) 正向 (?<!) 负向
断言特性不消耗字符,可叠加
回溯失败时量词退还字符重试
灾难性回溯嵌套量词 → 指数级路径
原子组丢弃回溯点,避免回溯(ES2025)

正则的高级用法远不止于此,下一篇 常用正则示例 会给出邮箱、手机号、身份证、URL 等实战正则,把所有概念串起来用。