跳到主要内容

零宽断言

零宽断言(Lookaround Assertions)是正则表达式中极其强大但也容易被误解的特性。掌握零宽断言,能让你在不"消耗"字符的情况下检查上下文,解决许多传统正则表达式难以处理的问题。

什么是零宽断言

理解零宽断言的关键在于"零宽"和"断言"这两个词。

零宽(Zero-width):指的是这些构造匹配的是位置而不是字符。普通正则模式匹配字符后会"消耗"这些字符,匹配位置向前移动。而零宽断言匹配成功后,匹配位置不会移动——它就像一个"探头",只检查条件是否满足,不"吃掉"任何字符。

断言(Assertion):指的是检查某个条件是否成立。正则引擎在当前位置进行检查,如果条件满足则继续匹配,否则匹配失败。

形象比喻

假设你在阅读一本书:

  • 普通正则模式:你在"阅读"文字,眼睛从左到右扫描,每读一个字就前进一位
  • 零宽断言:你在某个位置停下来"检查",确认这里是否满足某个条件,但眼睛并没有移动到下一个字
普通模式:匹配并"消耗"字符
abc 匹配 "abc" 后,位置移动到 'c' 之后

零宽断言:只检查不"消耗"
\b 在单词边界检查,位置不变
(?=X) 检查后面是否是 X,位置不变

视觉理解示例

const text = "hello world";

// 理解零宽:断言不消耗字符
const result = text.replace(/(?=world)/, "beautiful ");
console.log(result); // "hello beautiful world"

/*
发生了什么?
1. 引擎从位置 0 开始,检查 (?=world)
2. 在位置 0-5 都不匹配(后面不是 "world")
3. 在位置 6(空格后),(?=world) 成功!
4. 但位置仍然是 6,没有移动
5. 替换操作在位置 6 插入 "beautiful "
*/

四种零宽断言

零宽断言分为四种类型,根据检查方向和肯定/否定来区分:

断言名称检查方向含义助记
(?=...)正向先行断言向前看后面是..."往后看是不是"
(?!...)负向先行断言向前看后面不是..."往后看是不是不"
(?<=...)正向后行断言向后看前面是..."往前看是不是"
(?<!...)负向后行断言向后看前面不是..."往前看是不是不"

方向理解

  • 先行(Lookahead):从当前位置向字符串末尾方向看(向后看/向前看)
  • 后行(Lookbehind):从当前位置向字符串开头方向看(向前看/向后看)
术语说明

中文翻译中,"先行"和"后行"容易混淆。可以这样理解:

  • 先行断言 = 往字符串后面的方向看 = 看后面
  • 后行断言 = 往字符串前面的方向看 = 看前面

正向先行断言 (?=...)

正向先行断言检查当前位置后面是否跟着指定的模式。如果匹配,断言成功,但不会消耗这些字符。

基本语法

// 基本示例
const str = "fooBar fooBaz";
str.match(/foo(?=Bar)/g); // ["foo"]

/*
匹配过程:
1. 第一个 "foo" 后面是 "Bar",(?=Bar) 成功
2. 但 Bar 没有被"消耗",匹配结果是 "foo"
3. 继续搜索,第二个 "foo" 后面是 "Baz",(?=Bar) 失败
*/

应用一:提取特定后缀前的内容

const words = "running jumping walking eating";
words.match(/\w+(?=ing)/g); // ["runn", "jump", "walk", "eat"]

/*
这个模式匹配:
- 后面跟着 "ing" 的单词部分
- "ing" 本身不被包含在匹配结果中
*/

应用二:密码强度验证

这是零宽断言的经典应用场景——同时验证多个条件:

// 密码要求:至少8位,包含大写字母、小写字母、数字
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;

/*
解析:
^ 从开头开始
(?=.*[a-z]) 先行断言:后面某处有小写字母
(?=.*[A-Z]) 先行断言:后面某处有大写字母
(?=.*\d) 先行断言:后面某处有数字
.{8,} 实际匹配:8个或更多任意字符
$ 到结尾

关键理解:每个 (?=...) 都是"零宽"的
- 它们在同一个位置(开头)进行检查
- 检查完成后,位置仍在开头
- 最后的 .{8,} 才真正"消耗"字符
*/

console.log(passwordRegex.test("Password123")); // true
console.log(passwordRegex.test("password")); // false(缺大写)
console.log(passwordRegex.test("PASSWORD123")); // false(缺小写)
console.log(passwordRegex.test("Pass1")); // false(不够8位)

应用三:添加千位分隔符

// 给数字添加逗号分隔符
function formatNumber(num) {
return num.toString().replace(/(?<=\d)(?=(\d{3})+(?!\d))/g, ",");
}

/*
(?<=\d) 后行断言:前面是数字
(?=(\d{3})+) 先行断言:后面是3的倍数个数字
(?!\d) 先行断言:后面不是数字(确保在正确的位置)

匹配的位置:每三位数字之间的"空隙"
*/

formatNumber(1234567890); // "1,234,567,890"
formatNumber(12345); // "12,345"

应用四:匹配特定上下文中的内容

// 匹配 HTML 标签内的文本内容(简化版)
const html = "<div>内容1</div><span>内容2</span>";
const texts = html.match(/(?<=>)[^<]+(?=<)/g);
console.log(texts); // ["内容1", "内容2"]

/*
(?<=>) 后行断言:前面是 >
[^<]+ 匹配非 < 的内容
(?=<) 先行断言:后面是 <
*/

负向先行断言 (?!...)

负向先行断言检查当前位置后面不跟着指定的模式。

基本语法

// 匹配后面不是 "Bar" 的 "foo"
const str = "fooBar fooBaz";
str.match(/foo(?!Bar)/g); // ["foo"] - 只匹配第二个

/*
第一个 "foo" 后面是 "Bar",(?!Bar) 失败
第二个 "foo" 后面是 "Baz",(?!Bar) 成功
*/

应用一:排除特定模式

// 匹配不以 .txt 结尾的文件名
const files = ["readme.txt", "script.js", "style.css", "data.json"];
const notTxt = files.filter(f => /^(?!.*\.txt$).*$/.test(f));
console.log(notTxt); // ["script.js", "style.css", "data.json"]

/*
(?!.*\.txt$) 在开头检查:
- 后面是否包含 ".txt" 结尾
- 如果是,断言失败,整个模式不匹配
*/

应用二:匹配不在引号内的单词

// 匹配不在双引号内的单词(简化版,不处理嵌套引号)
const text = 'hello "world" foo "bar" baz';

// 这需要更复杂的模式,这里展示基本思路
// 实际应用中可能需要多次替换或其他方法

应用三:防止过度匹配

// 匹配不包含某个子串的字符串
const strings = ["hello world", "hello test", "goodbye world"];
const filtered = strings.filter(s => /^(?!.*test).*$/.test(s));
console.log(filtered); // ["hello world", "goodbye world"]

正向后行断言 (?<=...)

正向后行断言检查当前位置前面是否跟着指定的模式。

兼容性说明

JavaScript 在 ES2018 中引入了后行断言,旧版浏览器可能不支持。Python、Java、PCRE 等较早支持后行断言。

基本语法

// 匹配前面是 $ 的数字
const str = "$100 €200 ¥300";
str.match(/(?<=\$)\d+/g); // ["100"]

/*
(?<=\$) 检查当前位置前面是否是 $
如果是,继续匹配 \d+,得到 "100"
*/

应用一:提取括号内的内容

// 提取括号内的内容
const text = "hello(world) and foo(bar)";
const contents = text.match(/(?<=\()[^)]+(?=\))/g);
console.log(contents); // ["world", "bar"]

/*
(?<=\() 后行断言:前面是 (
[^)]+ 匹配非 ) 的内容
(?=\)) 先行断言:后面是 )
*/

应用二:提取引号内的内容

const text = 'He said "hello" and she said "hi"';
const quoted = text.match(/(?<=")[^"]+(?=")/g);
console.log(quoted); // ["hello", "hi"]

/*
(?<=") 检查前面是引号
[^"]+ 匹配非引号内容
(?=") 检查后面是引号
*/

应用三:提取货币金额

const prices = "价格:$100, €200, ¥300";
const dollars = prices.match(/(?<=\$)\d+/g); // ["100"]
const euros = prices.match(/(?<=)\d+/g); // ["200"]
const yuan = prices.match(/(?<=¥)\d+/g); // ["300"]

// 提取所有货币金额(带货币符号)
const allPrices = prices.match(/(?<=$||¥)\d+/g);
console.log(allPrices); // ["100", "200", "300"]

负向后行断言 (?<!...)

负向后行断言检查当前位置前面不跟着指定的模式。

基本语法

// 匹配前面不是 $ 的数字
const str = "$100 200 ¥300";
str.match(/(?<!\$)\d+/g); // ["00", "200", "300"]

/*
注意结果包含 "00":
- 在 $100 中,'1' 前面是 $,不匹配
- '0' 前面是 '1'(不是 $),匹配
- 另一个 '0' 前面是 '0'(不是 $),匹配
所以 "00" 被匹配了
*/

// 正确的写法:确保整个数字前面不是 $
str.match(/(?<!\$)\b\d+/g); // ["200", "300"]

应用一:选择性替换

// 只替换不在特定上下文中的内容
const code = "foo(bar) foo baz(foo)";

// 替换不在括号内的 foo
const result = code.replace(/(?<!\()foo(?!\))/g, "FOO");
console.log(result); // "foo(bar) FOO baz(foo)"

应用二:排除特定前缀的单词

// 匹配不以 un 开头的单词
const text = "happy unhappy lucky unlucky";
const positive = text.match(/\b(?<!un)\w+/gi);
console.log(positive); // ["happy", "lucky"]

断言组合使用

零宽断言的强大之处在于可以灵活组合使用。

多条件验证

// 密码验证:至少8位,包含大小写字母、数字和特殊字符
const strongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;

/*
四个先行断言在开头位置检查四个不同条件:
- (?=.*[a-z]) 有小写字母
- (?=.*[A-Z]) 有大写字母
- (?=.*\d) 有数字
- (?=.*[@$!%*?&]) 有特殊字符

检查完成后,.{8,} 匹配实际内容
*/

精确边界控制

// 匹配完整的单词(不包含在其他单词中)
const text = "the cat in the category catalog";
const fullWord = text.match(/\b(?<!\w)cat(?!\w)\b/g);
console.log(fullWord); // ["cat"]

/*
(?<!\w) 前面不是单词字符
(?!\w) 后面不是单词字符
这样确保 "cat" 是独立单词,不是 "category" 或 "catalog" 的一部分
*/

提取特定上下文内容

// 提取 HTML 注释内容
const html = "<!-- 注释1 --><div>内容</div><!-- 注释2 -->";
const comments = html.match(/(?<=<!--\s*).+?(?=\s*-->)/g);
console.log(comments); // ["注释1", "注释2"]

/*
(?<=<!--\s*) 后面是 <!-- 加可选空白
.+? 非贪婪匹配内容
(?=\s*-->) 前面是可选空白加 -->
*/

零宽断言的限制

后行断言的长度限制

在某些正则引擎中,后行断言的模式必须是固定长度的:

// 旧版 JavaScript 可能不支持变长后行断言
/(?<=a+)/.test("aaa"); // 可能报错

// 现代 JavaScript (ES2018+) 支持变长后行断言
/(?<=a+)b/.test("aaab"); // true

Python 的支持情况

import re

# Python 支持变长后行断言(3.5+)
re.search(r'(?<=a+)b', 'aaab') # 匹配成功

# 但模式必须是有限长度
# (?<=.*) 不被支持,因为 .* 是无限长度

性能考虑

零宽断言(特别是复杂的组合)可能影响性能:

// 复杂的断言组合可能导致性能问题
const complex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])(?!.*password)(?!.*123).{8,}$/;

// 建议:在应用层进行验证可能更高效
function validatePassword(pwd) {
return pwd.length >= 8 &&
/[a-z]/.test(pwd) &&
/[A-Z]/.test(pwd) &&
/\d/.test(pwd) &&
/[@$!%*?&]/.test(pwd) &&
!/password/i.test(pwd);
}

各语言支持情况

特性JavaScriptPythonJavaGoRust
正向先行 (?=...)
负向先行 (?!...)
正向后行 (?<=...)ES2018+
负向后行 (?<!...)ES2018+
变长后行断言ES2018+3.5+-

实用技巧总结

1. 密码强度验证

const password = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;

2. 千位分隔符

const formatNumber = num => num.toString().replace(/(?<=\d)(?=(\d{3})+(?!\d))/g, ",");

3. 提取引号内容

const extractQuoted = text => text.match(/(?<=["']).*?(?=["'])/g);

4. 排除特定模式

const notEndsWith = pattern => new RegExp(`^(?!.*${pattern}$).*$`);

5. 匹配独立单词

const wholeWord = word => new RegExp(`\\b(?<!\\w)${word}(?!\\w)\\b`, 'g');

常见错误与解决方案

错误1:混淆先行和后行

// 错误:想匹配 $ 后面的数字,但用错了方向
const wrong = /\d+(?=\$)/g; // 匹配前面是 $ 的数字
"$100".match(wrong); // null

// 正确
const correct = /(?<=\$)\d+/g; // 匹配后面是 $ 的数字(后行断言)
// 或者
const alt = /\$(\d+)/; // 使用捕获分组
"$100".match(alt)[1]; // "100"

错误2:断言内部使用量词不当

// 可能的问题:后行断言使用无限量词
const problematic = /(?<=.*@)\w+/;
// 某些引擎不支持

// 解决方案:使用具体长度或捕获分组
const solution = /@(\w+)/;
"test@example".match(solution)[1]; // "example"

错误3:忽略零宽特性

// 错误理解:以为断言会消耗字符
const text = "hello world";
text.replace(/(?=world)/, "beautiful ");
// 结果: "hello beautiful world"
// 不是: "hello beautiful"(断言没有消耗 world)

小结

零宽断言是正则表达式中强大的上下文检查工具:

  • 正向先行 (?=...):检查后面是否匹配,不消耗字符
  • 负向先行 (?!...):检查后面是否不匹配,不消耗字符
  • 正向后行 (?<=...):检查前面是否匹配,不消耗字符
  • 负向后行 (?<!...):检查前面是否不匹配,不消耗字符

掌握零宽断言,能够让你在不改变匹配内容的情况下,精确控制匹配的上下文条件,解决许多复杂的文本处理问题。

参考资源