零宽断言
零宽断言(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);
}
各语言支持情况
| 特性 | JavaScript | Python | Java | Go | Rust |
|---|---|---|---|---|---|
正向先行 (?=...) | ✓ | ✓ | ✓ | ✓ | ✓ |
负向先行 (?!...) | ✓ | ✓ | ✓ | ✓ | ✓ |
正向后行 (?<=...) | 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)
小结
零宽断言是正则表达式中强大的上下文检查工具:
- 正向先行
(?=...):检查后面是否匹配,不消耗字符 - 负向先行
(?!...):检查后面是否不匹配,不消耗字符 - 正向后行
(?<=...):检查前面是否匹配,不消耗字符 - 负向后行
(?<!...):检查前面是否不匹配,不消耗字符
掌握零宽断言,能够让你在不改变匹配内容的情况下,精确控制匹配的上下文条件,解决许多复杂的文本处理问题。