正则断言
先行与后行断言:匹配"位置"而非"字符",从千分位格式化到 CSV 拆分的实战与陷阱。
前置:需先有正则基础,见正则表达式。
正则断言
场景:只要数字,不要”元”
商品页要提取价格列表。文本是”门票 180 元,内场 480 元”,你希望 match 结果就是 ['180', '480'],不要带”元”字。
不用断言的写法是捕获组:
const prices = [];
for (const m of text.matchAll(/(\d+)元/g)) {
prices.push(m[1]); // 数字在捕获组里,完整匹配"180元"是累赘
}能用,但每个提取场景都要写一层”匹配了再从组里捞”的样板。断言(lookaround)直接在模式里声明上下文:“我要匹配数字,条件是后面跟着’元’,但’元’本身不算进结果”:
'门票 180 元,内场 480 元'.match(/\d+(?= 元)/g); // ['180', '480']四种断言与”零宽”这一个核心概念
| 语法 | 名字 | 一句话语义 |
|---|---|---|
(?=...) | 正向先行 | 后面必须能匹配 |
(?!...) | 负向先行 | 后面不能匹配 |
(?<=...) | 正向后行 | 前面必须能匹配 |
(?<!...) | 负向后行 | 前面不能匹配 |
四种断言都是零宽的:成功时只确认一个”位置”,不消费任何字符。这就是它与捕获组的本质区别——捕获组是”拿内容”,断言是”查户口”。两个直接推论:
- 多个断言可以在同一位置叠加,各自独立检查,互不影响位置:
// 密码强度:四个条件各自断言,实际消费字符的只有最后的 .{8,}
const strongPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
strongPwd.test('Abc12345!'); // true
strongPwd.test('abc12345'); // false:缺大写与特殊字符这行的读法是:站在开头(^),先后问四个问题”后面某处有小写吗、大写吗、数字吗、非字母数字吗”,都点头才让 .{8,} 真正开始吃字符。要给用户具体反馈,就把四个条件拆成四个独立的小正则分别 test,比一个巨型正则可维护得多。
- 两个断言相邻连写等于”与”,比如
(?<=\d)(?=[a-zA-Z])表示”左边是数字且右边是字母”的那个位置——匹配结果永远是空串,典型用途是replace在这个位置插东西:
'abc123def456'.replace(/(?<=\d)(?=[a-zA-Z])|(?<=[a-zA-Z])(?=\d)/g, ' ');
// 'abc 123 def 456':在数字与字母的交界处插空格后行断言((?<=...)、(?<!...))是 ES2018 才进规范的,当年 V8 在 Chrome 62 首发实现。到 2026 年,主流浏览器与 Node 早已全覆盖,只有极老的 Safari(16.4 之前)是例外;兼容目标里还有它的项目才需要退回捕获组写法。
经典应用一:千分位格式化
前端面试与真实业务双高频,值得逐段读懂:
function formatNumber(num) {
return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
formatNumber(1234567); // '1,234,567'
formatNumber(1234567.89); // '1,234,567.89'拆解:\B 是非单词边界,排除字符串开头;(?=(\d{3})+(?!\d)) 站在每个数字之间的缝隙上问:“从我这里往右,是不是正好剩 3 的倍数个数字?“((\d{3})+ 要求整组 3 位,(?!\d) 确保再往后没有零头)。只有满足条件的缝隙被替换成逗号。反向操作也顺手:
// 只移除小数点前的千分位逗号:后面跟 3 位数字且到非数字/结尾的逗号
'1,234,567.89'.replace(/,(?=\d{3}(\D|$))/g, ''); // '1234567.89'生产代码里也可以用 Intl.NumberFormat(见Intl API),但读懂这条正则对理解断言物有所值。
经典应用二:按”不在引号内的逗号”切 CSV
function parseCsvLine(line) {
// 一个逗号是分隔符,当且仅当:它右边的引号个数为偶数(都成对了,说明它不在引号内)
return line.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/g)
.map(f => f.replace(/^"|"$/g, '').replace(/""/g, '"'));
}
parseCsvLine('name,"Smith, John",age,"30"');
// ['name', 'Smith, John', 'age', '30']split 与断言配合的妙处:断言零宽,所以分隔符本身不被吞掉。这个”数右边剩余引号的奇偶”技巧同样能用来剥离行注释时跳过字符串里的 //。
更多上下文提取的组合示范:
// 两侧都是引号的数字
'"123" abc "456"'.match(/(?<=")\d+(?=")/g); // ['123', '456']
// 提取 class 属性值
html.match(/(?<=class=")[^"]+(?=")/g);
// 前面不是"不"的"喜欢"(负向后行)
'我喜欢你,他不喜欢你'.match(/(?<!不)喜欢/g); // ['喜欢'] —— 只有一个
// Markdown 代码块内容
md.match(/(?<=```[a-z]*\n)[\s\S]+?(?=```)/g);坑点与自检
坑 1:负向断言只看”紧邻位置”,配贪婪量词会漏判。
'12px 34em'.match(/\d+(?!px)/g);
// ['1', '34'] —— 不是期望的 ['34']!
// \d+ 先吃掉 '12',回退一位在 '1' 后面试断言:'1' 后面是 '2',不是 px,断言通过正则引擎会回退到让断言通过的位置,于是数字的一部分也”逃过”了检查。修正思路是把”整段数字不能以 px 结尾”表达出来:
'12px 34em'.match(/\b\d+(?![\d]*px\b)/g); // ['34']
// 或者更直白:先断言后面不是"数字+px"
'12px 34em'.match(/\d+(?!\d*(?:px)\b)/g); // ['34']写完负向断言,务必用”最想排除的那个样例”测一遍。
坑 2:断言内的捕获组照样记录。/(?=(\d{4})-(\d{2}))/ 的 match 结果里 m[1]、m[2] 有值——断言自己零宽,里面的组照常捕获。不想污染结果就用 (?:...) 非捕获组。
坑 3:后行断言的可变长度。JS 允许 (?<=\d+)test(引擎往前扫),但 Python 的 re、Java 要求定长,PCRE2 也有额外限制。把正则跨语言移植时这是头号差异点;同理,别指望把 JS 的后行断言直接搬去 Go/Rust(它们的后行支持很弱)。
坑 4:嵌套断言加量词会触发回溯爆炸。(?=(?=(?=a*)a*)a*)a 这类模式在长文本上会让引擎原地起飞。断言深度超过两层时先问自己”这个条件能不能拆成两次独立 test”。对用户可控输入做正则操作的服务端代码,额外留意 ReDoS:可以用 safe-regex 之类的工具做静态检查。
坑 5:(?i) 这种行内开关在 JS 里不存在。PCRE/Python 风格的 /(?i)abc/ 会直接 SyntaxError。JS 的忽略大小写只能用 i 标志;较新的引擎支持作用域形式 (?i:...)(只对括号内生效,2024 年起进入主流引擎),写跨引擎正则时别用前缀式。
自检清单:
- 断言匹配成功时消费了几个字符?
- 千分位正则里
(?!\d)去掉会发生什么?(试formatNumber(1234567.89)) - 为什么
/\d+(?!px)/会匹配12px里的1? - 你的兼容目标里最老的 Safari 支持后行断言吗?
练习
- 写
highlight(text, query):把命中的关键词包上<mark>,但位于 HTML 标签内部(<与>之间)的文本不高亮。用负向断言实现,并想清楚它对嵌套标签为什么只是近似。 - 实现
separateCJK(text):在汉字与拉丁字母/数字的交界处插空格(中英文混排排版惯例)。用\p{Script=Han}加断言,参考本篇”交界处插空格”的写法。 - 写一个
formatCardNumber:每 4 位加一个空格(6222021234561234到6222 0212 3456 1234),删除时也要能逆操作。对比断言方案与纯循环方案的可读性。 - 把坑 1 的修正正则改成”排除以 em 结尾”,验证
\b与断言的组合。
下一步
- Unicode 属性转义:断言里最常填的”积木”就是
\p{...}属性。 - 具名捕获组:断言负责筛位置,具名组负责取内容,两者是搭档。
- 防抖与节流:如果问题是”事件太频繁”而不是”匹配太宽”,正则帮不上忙,该看这篇。