零宽断言(环视)
这是正则的高级技巧。零宽断言(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] 比 /(?<=符号).+/ 更易读- 兼容性:后行断言在老引擎(IE、旧 Safari、JS<2018)不支持,生产代码要权衡。
- 定长限制:部分流派(如 Java)要求后行断言里的模式定长,JS 较宽松支持变长。
- 性能:嵌套断言可能拖慢匹配,大输入务必测试。
- 可读性:简单场景用捕获组更清晰,别为了"显得高级"硬用断言。
小结
- 四种断言:正向先行、负向先行、正向后行、负向后行。
- 核心性质"零宽":匹配不前进,只验证条件。
- 杀手级应用:千分位、密码校验、条件提取。
- 简单场景优先用捕获组,断言留到必要时刻。
下一章讲标志位——几个字母如何改变正则的整体行为。
← 上一篇 选择分支
下一篇 标志位 →