零宽断言(环视)

这是正则的高级技巧。零宽断言(lookaround,也叫环视)让你"看一下但不消费字符"——相当于匹配时的条件判断,只判断位置是否符合,本身不占匹配长度。前后分别叫"先行断言"和"后行断言",正负(肯定/否定)共四种。掌握它,你的正则就跨过了"会用"到"精通"的门槛。

先行断言(向右看)

先行断言(lookahead)检查当前位置右侧是否符合某个模式,但匹配成功后位置不前进。正向表示"必须是",负向表示"必须不是"。

// 先行断言(lookahead):向右看,不消费字符

// 正向先行断言 (?=...):后面跟着 X
// 找后面跟着 "元" 字的数字
/\d+(?=元)/.exec("价格 100 元 或 200 美元");
// ["100"]  (匹配 100,因为它后面是"元")

// 负向先行断言 (?!...):后面不跟着 X
// 匹配后面不是 "元" 的数字
/\d+(?!元)/.exec("价格 100 元 或 200 美元");
// ["20"]   (匹配 200 里的 20,因为 20 后面不是"元")
// 注意:贪婪导致 200 也被尝试,200 后面是空格不是元,但匹配位置不同
// 更准确的写法:加单词边界 /\b\d+\b(?!元)/

// 关键:断言"零宽"——匹配成功后位置不前进
"abc".replace(/b(?=c)/, "X");    // "aXc"  (替换了 b,但 c 没被消费)
"abc".replace(/b(?!c)/, "X");    // "abc"  (b 后面是 c,断言失败,没替换

断言最关键的性质是零宽:它只验证条件,不消耗字符。所以替换时,断言匹配的内容不会出现在替换结果里——这一点在千分位、密码校验等场景里极其有用。

后行断言(向左看)

后行断言(lookbehind)检查当前位置左侧是否符合某个模式。语法上比先行多一个小于号。注意它是较新的特性,JS 直到 2018 才支持,部分老引擎(IE、旧 Safari)完全不支持。

// 后行断言(lookbehind):向左看,JS 2018+ / PCRE / Python 3.7+ 支持

// 正向后行断言 (?<=...):前面是 X
// 匹配人民币符号后面的数字
/(?<=¥)\d+/.exec("¥100");     // ["100"]
/(?<=¥)\d+/.exec("$100");     // null ($ 不匹配)

// 负向后行断言 (?<!...):前面不是 X
// 匹配前面不是 $ 的数字
/(?<!\$)\d+/.exec("price $100");   // ["00"]  (100 里的 00,前面不是 $)
/(?<!\$)\d+/.exec("count 100");    // ["100"]

// 实例:提取 key=value 里的 value
/(?<==\s*).+/.exec("name = Alice");   // ["Alice"]
// 断言匹配 "= " 这个位置,但 = 不算进结果

// 注意:JS 老版本(<2018)、Safari 早期版不支持后行
// 兼容方案:用捕获组代替 /=(\s*.+)/  取 m[1]

后行断言的杀手级用法是"提取某个符号后面的内容"——以前必须用捕获组取 m[1],现在可以直接断言取出。但要权衡兼容性:面向老浏览器或老 Python 的代码,捕获组写法更安全。

经典应用一:千分位

这是零宽断言最出名的应用——给一串数字每三位加一个逗号,一行代码搞定。看上去像天书,但理解每个部分后会发现它简洁优雅。

// 经典应用:数字千分位格式化
// "1234567" → "1,234,567"

"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",");
// "1,234,567"

// 拆解这个看似复杂的正则:
//   \B                当前位置不是单词边界(避免在最前面加逗号)
//   (?=...)           正向先行断言:右侧必须满足条件
//   (\d{3})+         一个或多个"3 位数字"
//   (?!\d)           负向先行断言:这串 3 位数字后面不能再有数字
//
// 引擎在每个"右侧恰好是 3 的倍数个数字"的位置插入逗号
// 12|3456|7 在 | 处满足条件(右侧 6 位,是 3 的倍数)
// 1|234|567 在 | 处也满足(右侧 3 位)

// 简化版:不处理小数
function format(n) {
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
format(1234567);      // "1,234,567"
format(1000000000);   // "1,000,000,000"

核心思路:在每个"右侧恰好是 3 的倍数个数字"的位置插入逗号。两个断言组合实现这个判断:先行断言确定右侧有 3 的倍数个数字,负向断言确定这串数字后面不再有数字(即整个数字串的尾巴)。这是面试常考题,理解后写起来得心应手。

经典应用二:密码强度

这是另一个高频应用——密码必须同时满足多个条件(长度、大小写、数字、符号)。用先行断言的组合可以一行搞定,每个条件一个断言,互不干扰。

// 经典应用:密码强度校验(多重断言组合)
// 要求:8 位以上,含大小写字母、数字、特殊字符

const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
strong.test("Abc123!@");      // true
strong.test("abcdefgh");      // false (没大写、没数字、没符号)
strong.test("ABCDEFGHIJ1");   // false (没小写、没符号)

// 拆解:每个 (?=...) 是一个独立检查,顺序无所谓
//   (?=.*[a-z])        后面某处有小写字母
//   (?=.*[A-Z])        后面某处有大写字母
//   (?=.*\d)          后面某处有数字
//   (?=.*[!@#$%^&*])   后面某处有特殊符号
//   .{8,}             最终匹配 8 位以上任意字符
//
// 关键:每个先行断言都从开头开始"向右扫描",互不干扰
// 这是先行断言最优雅的用法——多个独立条件叠加

关键洞察:每个先行断言都从开头独立扫描,因为断言零宽,匹配完不前进,下一个断言又从同一位置开始。这样多个条件可以叠加,而顺序无所谓。不用断言的话,只能用多步校验(先匹配一种、再匹配另一种),代码会冗长很多。

限制与陷阱

零宽断言虽强大,但有几个限制必须知道,免得上线踩坑:

// 零宽断言的限制与陷阱

// 1. 老引擎不支持后行断言(IE、旧 Safari、JS<2018)
//    兼容方案:用捕获组 + 提取 m[1]
const m = "¥100".match(/¥(\d+)/);
// m[1] = "100"  (虽然没有断言优雅,但兼容性好)

// 2. 部分流派要求后行断言里的模式"定长"
//    JS 的后行断言支持变长(先进),Java 不支持
/(?<=ab|abc)\d/.exec("abc123");   // JS 支持

// 3. 嵌套断言可能影响性能,大输入要测试

// 4. 别滥用:简单场景用捕获组更清晰
//    "提取某个符号后的内容" 这种简单需求
//    /符号(.+)/ 的 m[1] 比 /(?<=符号).+/ 更易读

小结

下一章讲标志位——几个字母如何改变正则的整体行为。

← 上一篇 选择分支

下一篇 标志位

✈️💬