前置知识: JavaScript

正则断言

5 min高级

先行与后行断言:匹配"位置"而非"字符",从千分位格式化到 CSV 拆分的实战与陷阱。

前置:需先有正则基础,见正则表达式。

正则断言

场景:只要数字,不要”元”

商品页要提取价格列表。文本是”门票 180 元,内场 480 元”,你希望 match 结果就是 ['180', '480'],不要带”元”字。

不用断言的写法是捕获组:

javascript
const prices = [];
for (const m of text.matchAll(/(\d+)元/g)) {
  prices.push(m[1]); // 数字在捕获组里,完整匹配"180元"是累赘
}

能用,但每个提取场景都要写一层”匹配了再从组里捞”的样板。断言(lookaround)直接在模式里声明上下文:“我要匹配数字,条件是后面跟着’元’,但’元’本身不算进结果”:

javascript
'门票 180 元,内场 480 元'.match(/\d+(?= 元)/g); // ['180', '480']

四种断言与”零宽”这一个核心概念

语法名字一句话语义
(?=...)正向先行后面必须能匹配
(?!...)负向先行后面不能匹配
(?<=...)正向后行前面必须能匹配
(?<!...)负向后行前面不能匹配

四种断言都是零宽的:成功时只确认一个”位置”,不消费任何字符。这就是它与捕获组的本质区别——捕获组是”拿内容”,断言是”查户口”。两个直接推论:

  1. 多个断言可以在同一位置叠加,各自独立检查,互不影响位置:
javascript
// 密码强度:四个条件各自断言,实际消费字符的只有最后的 .{8,}
const strongPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
strongPwd.test('Abc12345!'); // true
strongPwd.test('abc12345');  // false:缺大写与特殊字符

这行的读法是:站在开头(^),先后问四个问题”后面某处有小写吗、大写吗、数字吗、非字母数字吗”,都点头才让 .{8,} 真正开始吃字符。要给用户具体反馈,就把四个条件拆成四个独立的小正则分别 test,比一个巨型正则可维护得多。

  1. 两个断言相邻连写等于”与”,比如 (?<=\d)(?=[a-zA-Z]) 表示”左边是数字且右边是字母”的那个位置——匹配结果永远是空串,典型用途是 replace 在这个位置插东西:
javascript
'abc123def456'.replace(/(?<=\d)(?=[a-zA-Z])|(?<=[a-zA-Z])(?=\d)/g, ' ');
// 'abc 123 def 456':在数字与字母的交界处插空格

后行断言((?<=...)、(?<!...))是 ES2018 才进规范的,当年 V8 在 Chrome 62 首发实现。到 2026 年,主流浏览器与 Node 早已全覆盖,只有极老的 Safari(16.4 之前)是例外;兼容目标里还有它的项目才需要退回捕获组写法。

经典应用一:千分位格式化

前端面试与真实业务双高频,值得逐段读懂:

javascript
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) 确保再往后没有零头)。只有满足条件的缝隙被替换成逗号。反向操作也顺手:

javascript
// 只移除小数点前的千分位逗号:后面跟 3 位数字且到非数字/结尾的逗号
'1,234,567.89'.replace(/,(?=\d{3}(\D|$))/g, ''); // '1234567.89'

生产代码里也可以用 Intl.NumberFormat(见Intl API),但读懂这条正则对理解断言物有所值。

经典应用二:按”不在引号内的逗号”切 CSV

javascript
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 与断言配合的妙处:断言零宽,所以分隔符本身不被吞掉。这个”数右边剩余引号的奇偶”技巧同样能用来剥离行注释时跳过字符串里的 //。

更多上下文提取的组合示范:

javascript
// 两侧都是引号的数字
'"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:负向断言只看”紧邻位置”,配贪婪量词会漏判。

javascript
'12px 34em'.match(/\d+(?!px)/g);
// ['1', '34'] —— 不是期望的 ['34']!
// \d+ 先吃掉 '12',回退一位在 '1' 后面试断言:'1' 后面是 '2',不是 px,断言通过

正则引擎会回退到让断言通过的位置,于是数字的一部分也”逃过”了检查。修正思路是把”整段数字不能以 px 结尾”表达出来:

javascript
'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 支持后行断言吗?

练习

  1. 写 highlight(text, query):把命中的关键词包上 <mark>,但位于 HTML 标签内部(< 与 > 之间)的文本不高亮。用负向断言实现,并想清楚它对嵌套标签为什么只是近似。
  2. 实现 separateCJK(text):在汉字与拉丁字母/数字的交界处插空格(中英文混排排版惯例)。用 \p{Script=Han} 加断言,参考本篇”交界处插空格”的写法。
  3. 写一个 formatCardNumber:每 4 位加一个空格(6222021234561234 到 6222 0212 3456 1234),删除时也要能逆操作。对比断言方案与纯循环方案的可读性。
  4. 把坑 1 的修正正则改成”排除以 em 结尾”,验证 \b 与断言的组合。

下一步

  • Unicode 属性转义:断言里最常填的”积木”就是 \p{...} 属性。
  • 具名捕获组:断言负责筛位置,具名组负责取内容,两者是搭档。
  • 防抖与节流:如果问题是”事件太频繁”而不是”匹配太宽”,正则帮不上忙,该看这篇。