写过一段时间正则的人,多少都遇到过这种尴尬:明明只想匹配满足某个条件的字符串,但条件本身又不能被吃进匹配结果里。举个例子,要从一篇文章里找出所有后面跟着逗号的单词,但又不想把逗号包含在结果中;或者要匹配所有不以 http 开头的链接。这类需求靠普通的分组和字符类很难优雅地解决,而这正是零宽断言(Lookaround)发挥作用的地方。除了断言之外,RegExp 对象还藏着不少容易被忽视的高级特性,比如具名捕获组、sticky 标志、dotAll 模式等,这篇文章就把它们挨个讲清楚。

零宽断言:只判断位置,不消耗字符
零宽断言的核心思想是“只看不匹配”。它在某个位置上检查后面的内容是否符合条件,如果符合或者不符合,断言成立,但正则引擎的匹配指针不会移动,被检查的那段文本不会被纳入最终的匹配结果。JavaScript 提供四种断言:正向先行断言(lookahead)(?=...)、负向先行断言 (?!...),以及 ES2018 引入的正向后行断言 (<=...) 和负向后行断言 (<!...)。
先看正向先行断言。假设要匹配后面紧跟着数字的字母,可以这样写:
const str = "a1 b2 c3 dd"; const re = /[a-z](?=\d)/g; console.log(str.match(re)); // ["a", "b", "c"]
注意结果里只有字母,没有数字——数字被断言“看”过一眼但没吃掉,所以同一个数字还可以被后续匹配继续检查。再来看负向先行断言,它匹配的是“后面不是某内容”的位置。比如过滤掉所有不以 http 开头的协议链接:
const urls = "https://a.com ftp://b.com http://c.com"; const re = /\b(?!http)[a-z]+(?=:)/g; console.log(urls.match(re)); // ["ftp"]
这个特性在实际业务里最常见的用途是“多条件同时校验”,比如密码必须同时包含大写字母和数字:
function isStrongPassword(pwd) {
return /^(?=.*[A-Z])(?=.*\d).{8,}$/.test(pwd);
}
console.log(isStrongPassword("abc123")); // false
console.log(isStrongPassword("Abcdef1234")); // true这里两个正向先行断言各自从字符串开头“眺望”一遍,分别确认大写字母和数字存在,而最后的 .{8,} 才是真正消耗字符的部分。如果不使用断言,这类需求往往要拆成多次 test 调用,代码会啰嗦不少。
后行断言与兼容性注意事项
ES2018 之前,JavaScript 只支持向前看,不支持向后看,这让很多从其他语言迁移过来的正则写法直接报错。后行断言解决的是“前面必须是或不是某内容”的问题。比如要提取 ¥ 符号后面的金额:
const text = "价格¥199,运费¥15"; const re = /(?<=¥)\d+/g; console.log(text.match(re)); // ["199", "15"]
配合负向后行断言 (?<!...),还能实现“排除某个前缀”的匹配。一个经典应用是金额千分位格式化,只在“后面还有 1 到 3 位数字的倍数位置”插入逗号:
function formatMoney(num) {
return String(num).replace(/(?<=\d)(?=(\d{3})+$)/g, ",");
}
console.log(formatMoney(1234567)); // 1,234,567需要特别提醒的是兼容性。后行断言在旧版 Safari(16.4 之前)和 IE 中完全不支持,直接抛出 SyntaxError。如果项目需要兼容这些环境,只能在正则外面用捕获分组加回调函数变通。另外,后行断言内部使用量词时行为和其他语言有细微差别,引擎会从右往左匹配,写复杂后行断言时要格外小心回溯陷阱。
具名捕获组与反向引用
传统捕获组靠下标取值,match[1]、match[2] 一旦正则改了顺序,所有下标都要跟着改,维护成本很高。ES2018 的具名捕获组允许给每个分组起名字:
const dateRe = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const { year, month, day } = "2024-06-15".match(dateRe).groups;
console.log(`${year}年${month}月${day}日`);分组数据挂在 groups 属性上,语义一目了然。在 replace 的替换字符串里还能用 $<name> 语法直接引用:
console.log( "2024-06-15".replace(dateRe, "$<month>/$<day>/$<year>") ); // 06/15/2024
与之配套的是反向引用 \1、\2,它引用的是前面捕获组实际匹配到的内容,而不是模式本身。典型用途是匹配成对的 HTML 标签或者重复单词:
const html = "<div>hello</div> <span>world</i>"; const tagRe = /<(\w+)>[\s\S]*?<\/\1>/g; console.log(html.match(tagRe)); // ["<div>hello</div>"]
\1 保证了开闭标签名一致,所以错误的 <span>...</i> 被正确排除了。具名组也有对应的反向引用语法 \k<name>,在处理引号配对、括号配对时非常实用。
不可忽略的标志位:sticky 与 dotAll
除了常见的 g、i、m,还有两个标志经常被低估。y(sticky)标志要求匹配必须从 lastIndex 指定的位置开始,不能像 g 那样跳过中间的字符。这个特性非常适合写词法分析器:
const input = "let x = 42";
const re = /\s*(let|const|var|[A-Za-z_]\w*|\d+|=)/y;
re.lastIndex = 0;
let token;
while ((token = re.exec(input)) !== null) {
console.log(token[1]);
re.lastIndex = re.lastIndex; // sticky 匹配后 lastIndex 已指向下一位置
}s(dotAll)标志则让点号 . 也能匹配换行符。解析多行日志、HTML 片段时,没有它就得写 [\s\S] 来绕路:
const multiline = "start\nmiddle\nend"; console.log(/start.middle/.test(multiline)); // false console.log(/start.middle/s.test(multiline)); // true
另外,RegExp 构造函数上的 source、flags 属性,以及实例上的 lastIndex 行为,也值得花时间弄明白。特别是复用带 g 标志的正则对象时,lastIndex 会在多次 test 之间“记住”位置,导致断断续续返回 false,这是实际开发中高频踩坑点,最简单的规避办法是每次匹配前把 lastIndex 重置为 0,或者干脆不复用同一个正则实例。
掌握这些特性之后,很多原本需要循环加字符串切割的逻辑,都能压缩成一条声明式的正则表达式。当然,正则不是越复杂越好,可读性永远是第一位的——当代码里出现三四层嵌套的断言时,不妨停下来想想是否有更清晰的替代方案。