外观
零宽断言实战:密码校验与手机号脱敏精准定位
为什么需要零宽断言:只定位、不消费字符
普通正则的每个字符组都会“吃掉”匹配到的内容,匹配指针持续后移。但在很多业务里,我们真正想要的是一种位置判断:这个位置后面必须是什么、前面不能是什么,而判断本身不应该占用字符。零宽断言(Lookaround,又称环视断言)正是为此而生——它匹配的是一个“零宽度的位置”,断言成功后引擎指针不前进,因此可以在同一位置叠加多个条件。
典型场景有两个:
- 密码强度校验:要求“至少 8 位、同时包含大小写字母和数字”,本质是对同一个字符串叠加多个并列条件,而不是匹配某一段具体文本。
- 手机号中间脱敏:把
13812345678变成138****5678,需要精准定位“前 3 位之后、后 4 位之前”的中间 4 个数字,且替换时不能误伤首尾。
这两个场景一个用“肯定环视叠加条件”,一个用“环视锁定边界后替换”,非常适合用来彻底搞懂零宽断言。
四种环视断言对比:方向 × 肯定否定
零宽断言按“看前面还是看后面”和“要求满足还是要求不满足”分成四种:
记忆要点:= 表示肯定、! 表示否定;带 < 的是“回头看”(后行),不带的是“向前看”(先行)。四者都不消费字符。
方案对比:为什么不用 | 或多个正则
实现密码强度,常见有三种思路:
- 多个正则分别 test:写 4 个正则循环判断,逻辑直观但代码分散,错误信息难聚合。
- 一个大正则用
|:|表达的是“或”,而强度要求是“且”,语义根本对不上,极易写错。 - 多个肯定先行串联:在同一个
^起点位置连续放置多个(?=...),因为环视不移动指针,每个断言都从同一位置独立检查,天然表达“且”。
第三种正是工业界最常用的写法。
实战一:密码强度校验的环视叠加写法
需求:长度 8~20 位;至少一个大写、一个小写、一个数字;允许常见特殊字符;不得包含连续空格。
javascript
// 可直接在浏览器控制台 / Node 运行
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/;
function checkPassword(pwd) {
if (passwordRegex.test(pwd)) return '强度合格';
return '不符合要求:需8-20位,含大小写字母、数字与特殊字符';
}
console.log(checkPassword('Abcdef1!')); // 强度合格
console.log(checkPassword('abcdef1!')); // 不符合要求(缺大写)
console.log(checkPassword('Abcdefg1')); // 不符合要求(缺特殊字符)逐段解析:
^:把匹配锚定在字符串起点,所有环视都从这个零宽位置开始检查。(?=.*[a-z]):肯定先行,从起点向右看,.*贪婪扫到任意位置后能找到一个小写字母即通过;不消费字符,指针仍在起点。(?=.*[A-Z])、(?=.*\d)、(?=.*[!@#$%^&*]):同理分别要求大写、数字、特殊字符,四个断言形成逻辑“与”。[A-Za-z\d!@#$%^&*]{8,20}$:环视全部通过后,才真正消费字符,限定字符集合与长度,$封口防止多余字符。
如果还想拒绝 123456、password 这类弱口令,可在起点追加否定先行:
javascript
// (?!...) 表示向右看不得出现这些弱串(忽略大小写可加 i 标志)
const strongRegex = /^(?!.*(?:123456|password|qwerty))(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d!@#$%^&*]{8,20}$/;
console.log(strongRegex.test('Password1!')); // false:命中弱口令(i 语义下)实战二:手机号中间四位脱敏
需求:保留前 3 位与后 4 位,中间统一替换为 ****。这里后行断言用于锁定前缀边界,先行断言用于保证右侧恰好剩 4 位。
python
import re
# 肯定后行锁定前3位之后的位置;中间4位;肯定先行保证其后恰有4位数字
pattern = r'(?<=^\d{3})\d{4}(?=\d{4}$)'
def mask_phone(phone: str) -> str:
return re.sub(pattern, '****', phone)
print(mask_phone('13812345678')) # 138****5678
print(mask_phone('15900001111')) # 159****1111逐段解析:
(?<=^\d{3}):肯定后行,要求当前位置左侧正好是“开头 + 3 个数字”,从而把替换起点钉在第 4 位数字之前。\d{4}:真正要被替换掉的中间 4 位,这是唯一被消费的部分。(?=\d{4}$):肯定先行,要求这 4 位右侧恰好还剩 4 个数字并到结尾,避免在长度异常号码上误替换。
JavaScript 从 ES2018 起支持定长后行断言,写法等价:
javascript
const maskPhone = phone =>
phone.replace(/(?<=^\d{3})\d{4}(?=\d{4}$)/, '****');
console.log(maskPhone('13812345678')); // 138****5678需要兼容不支持后行断言的旧环境时,可退回到“捕获分组 + 替换字符串”这一更通用的方案,用分组引用 $1、$2 保留首尾:
javascript
// 不依赖后行断言:用捕获组保留前3、后4,仅替换中间
const maskPhoneSafe = phone =>
phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');避坑总结:来自实战的具体注意事项
1. 环视里漏写 .*,导致条件永远不满足。 常见错误是把 (?=.*[a-z]) 写成 (?=[a-z])。现象是 Abcdef1! 也被判为不合格——因为后者只检查“起点紧接的第一个字符”是否为小写,而首字符是大写。修正方法:凡是“字符串中任意位置出现”的需求,环视内部都要带 .*(或 .+)让引擎能向右扫描。
2. 忘记锚定 ^ 和 $,长度与字符限制被绕过。 若写成 (?=.*\d)[A-Za-z\d]{8,20},输入 abc1ABCD!@#extra 这类含非法字符或超长串时,引擎可能在字符串中间找到一段合法子串而误判通过。务必在最外层用 ^...$ 整体封口,让环视与长度约束作用于整个字符串。
3. 后行断言使用了不定长量词,在部分引擎直接报错。 Python 的 re 与多数 JS 引擎要求后行 (?<=...) 内部为定长模式。写成 (?<=^\d+) 会抛 look-behind requires fixed-width pattern 之类错误。修正方法:后行里使用 \d{3} 这类定长表达;变长需求改用捕获分组方案。
4. 脱敏正则不校验右侧位数,污染异常号码。 只写 (?<=^\d{3})\d{4} 时,对 10 位、14 位等异常号码也会替换前 4 位中间数字,产生看似正常实则错误的结果。加上 (?=\d{4}$) 双向锁定,可确保只对标准 11 位号码生效;上线前还应先用 ^1[3-9]\d{9}$ 做一次格式校验。
掌握“环视只定位不消费”这一核心心智模型后,密码的多条件叠加与脱敏的精准定位就都变成了对位置的描述,而不是对文本的蛮力切割。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。