跳到主内容

零宽断言实战:密码校验与手机号脱敏精准定位 ​

为什么需要零宽断言:只定位、不消费字符 ​

普通正则的每个字符组都会“吃掉”匹配到的内容,匹配指针持续后移。但在很多业务里,我们真正想要的是一种位置判断:这个位置后面必须是什么、前面不能是什么,而判断本身不应该占用字符。零宽断言(Lookaround,又称环视断言)正是为此而生——它匹配的是一个“零宽度的位置”,断言成功后引擎指针不前进,因此可以在同一位置叠加多个条件。

典型场景有两个:

  • 密码强度校验:要求“至少 8 位、同时包含大小写字母和数字”,本质是对同一个字符串叠加多个并列条件,而不是匹配某一段具体文本。
  • 手机号中间脱敏:把 13812345678 变成 138****5678,需要精准定位“前 3 位之后、后 4 位之前”的中间 4 个数字,且替换时不能误伤首尾。

这两个场景一个用“肯定环视叠加条件”,一个用“环视锁定边界后替换”,非常适合用来彻底搞懂零宽断言。

四种环视断言对比:方向 × 肯定否定 ​

零宽断言按“看前面还是看后面”和“要求满足还是要求不满足”分成四种:

零宽断言:方向 × 肯定/否定肯定先行 (?=pattern)右侧必须满足,不消费用途:密码叠加条件否定先行 (?!pattern)右侧必须不满足用途:排除弱密码串肯定后行 (?<=pattern)左侧必须满足,不消费用途:锁定前缀后脱敏否定后行 (?<!pattern)左侧必须不满足用途:排除特定开头

记忆要点:= 表示肯定、! 表示否定;带 < 的是“回头看”(后行),不带的是“向前看”(先行)。四者都不消费字符。

方案对比:为什么不用 | 或多个正则 ​

实现密码强度,常见有三种思路:

  1. 多个正则分别 test:写 4 个正则循环判断,逻辑直观但代码分散,错误信息难聚合。
  2. 一个大正则用 |:| 表达的是“或”,而强度要求是“且”,语义根本对不上,极易写错。
  3. 多个肯定先行串联:在同一个 ^ 起点位置连续放置多个 (?=...),因为环视不移动指针,每个断言都从同一位置独立检查,天然表达“且”。

第三种正是工业界最常用的写法。

实战一:密码强度校验的环视叠加写法 ​

需求:长度 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}$ 做一次格式校验。

掌握“环视只定位不消费”这一核心心智模型后,密码的多条件叠加与脱敏的精准定位就都变成了对位置的描述,而不是对文本的蛮力切割。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。