组装:把模式搭起来
先行与后行
它在解决什么
锚点那篇讲过「零宽」:^ $ \b 匹配的是位置,
不消耗字符,所以不出现在结果里。但那三个能表达的条件很有限——
只有「串首」「串尾」「单词边界」这么几种。
先行和后行是同一个家族里能力强得多的成员:条件可以是任意一条正则。
「后面跟着『元』的那串数字」「前面是
¥的那串数字」 「这一段里必须同时含数字和小写字母」「前后都不是汉字的『北京』」
这些都是位置上的条件,而不是要拿走的内容。这一篇讲怎么写它们。
先说一句术语
中文社区通常管这四个叫零宽断言。本站正文里一律叫先行(lookahead) 和后行(lookbehind),原因是本站其他地方(脚本输出、提交信息、测试)的 「断言」一律指测试断言,同一个词两个意思会让句子没法读。
📌 但你在别处一定会遇到「零宽断言」这个说法,看到时知道说的是这四个就行。 英文文档里统称 lookaround。
四种写法
(?= … ) 正向先行 后面「要」满足…
(?! … ) 负向先行 后面「不能」满足…
(?<= … ) 正向后行 前面「要」满足…
(?<! … ) 负向后行 前面「不能」满足…
记法只有两条:
- 有
<的是往回看(后行),没有的是往前看(先行)。 !是否定,=是肯定。
四个共同的性质:匹配的是位置,宽度为零,不出现在结果里,也不消耗字符。
先行:用后文作条件,不把后文算进答案
想从 '价格 100元' 里取出数字,不要那个「元」:
/\d+(?=元)/ 得到 ['100'],而 /\d+元/ 得到 ['100元']。
两者都能定位到那串数字,区别在于**「元」有没有被拿走**。
如果你只是要判断,用哪个都行;但只要结果要接着用(写进表单、做计算),
差别就是要不要再写一行 .replace('元','')。
否定版本用来排除:
/\b\d+\b(?!元)/g 从 '100元 200米 300' 里取出 200 和 300,
跳过了后面跟着「元」的那个。
后行:用前文作条件
写法把 = 或 ! 前面加个 <:
/(?<=¥)\d+/g 从 '¥100 和 $200' 里只取出 100。
⚠️ 后行有个兼容性坑,而且失败方式很难看:它是 ES2018 才进标准的。
在不支持的老环境里,整条正则编译不过——抛的是 SyntaxError,
不是「匹配不到」。这意味着一个只在旧浏览器上出现的白屏,而不是一个错误的结果。
如果目标环境不确定,替代写法是用捕获组把前缀吃掉再取组:
/¥(\d+)/ 然后取 m[1]。多一步,但到处都能跑。
⭐ 叠加:三个条件检查的是同一段文本
这是先行最有价值的用法,也是它和普通分组差别最大的地方。
密码校验要「至少 8 位,且含数字,且含小写字母」。这三个条件不是先后关系, 是同时关系——而普通的正则是线性往前推进的,天然表达不了「同时」。
先行能做到,因为它检查完指针回到原地:
/^(?=.*\d)(?=.*[a-z]).{8,}$/ 匹配 'abc12345'
^ 站在串首
(?=.*\d) 从这里往后看:某处有数字吗?有 → 通过,指针退回串首
(?=.*[a-z]) 从这里往后看:某处有小写吗?有 → 通过,指针退回串首
.{8,}$ 从串首开始真正地匹配:至少 8 个字符,直到串尾
把先行换成普通分组 ^(.*\d)(.*[a-z]).{8,}$,含义就变成「先有一段含数字、
再有一段含小写、后面还要至少 8 个字符」——一个总长才 8 的串根本不够分。
👉 判据:要表达「同时满足若干条件」,就把每个条件写成一个先行,全堆在 ^ 后面。
条件之间没有顺序关系,加一条就多写一个 (?=…),互不干扰。
两个实用场景
千分位。 整条模式匹配的是零宽的位置,所以「替换」它就是插入——
和锚点那篇用 ^ 插前缀是同一个套路:
'123456'.replace(/\B(?=(\d{3})+$)/g, ',') 得到 '123,456'
(?=(\d{3})+$) 的意思是「从这个位置往后,剩下的数字个数正好是 3 的整数倍」。
前面那个 \B 不能省:串首也满足这个条件,省了会得到 ',123,456'。
中文的词边界。 锚点那篇留过一个问题:\b 认不出中文,
因为 \w 里根本没有汉字。当时说「得用先行/后行显式写出来」,就是这个:
/(?<![一-龥])北京(?![一-龥])/ 对 '去北京玩' 返回 false,
因为「去」和「玩」都是汉字。同一条模式对 '去 北京 玩' 就返回 true——
差别全在那两个空格上。
[一-龥] 是常用汉字的码点区间(U+4E00–U+9FA5)。它不完整——
不含扩展区的生僻字,也不含日文假名——但对大多数中文文本够用。
要更严谨可以用 \p{Script=Han} 配 u 修饰符,那是修饰符那篇的内容。
怎么选
| 你要的 | 写法 |
|---|---|
| 取 A 但要求它后面是 B | A(?=B) |
| 取 A 但要求它后面不是 B | A(?!B) |
| 取 A 但要求它前面是 B | (?<=B)A |
| 同时满足好几个条件 | ^(?=条件1)(?=条件2)真正的模式$ |
| 往某个位置插入东西 | 让整条模式只由零宽部分组成,然后 replace |
| 中文的「词边界」 | 自己写 (?<![一-龥])…(?![一-龥]) |
| 老环境,不敢用后行 | 用捕获组吃掉前缀,取 m[1] |
一条判据可以统一前四行:问自己「这部分要不要出现在结果里」。 要 → 普通模式;不要但它是条件 → 先行/后行。
下一步
《修饰符》——structure 这一章到此结束,接下来是 practice。
修饰符那篇的重点不是列出 g i m s u y 六个字母,而是 g 带来的 lastIndex
状态:把一个带 g 的正则存成常量反复 test(),结果会在 true 和 false 之间交替。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
先行只检查、不拿走 —— 「元」没有进结果
- 调用
"价格 100元".match(/\d+(?=元)/)- 结果
["100"]- 换成
/\d+元/ ["100元"]
这就是「零宽」的实用价值:用后文作条件,但不把后文算进答案。
(?!…)排除掉后面跟着某东西的那些- 调用
[..."100元 200米 300".matchAll(/\b\d+\b(?!元)/g)]- 结果
["200","300"]- 换成
/\b\d+\b/g ["100","200","300"]
负向先行读作「这个位置后面不能是…」。它同样不消耗字符。
(?<=…)用前文作条件- 调用
[..."¥100 和 $200".matchAll(/(?<=¥)\d+/g)]- 结果
["100"]- 换成
/\d+/g ["100","200"]
⚠️ 后行(lookbehind)是 ES2018 才进标准的,老环境里可能直接抛语法错 —— 它不是「匹配不到」,是整条正则编译不过。
叠加多个先行 = 「同时满足」而不是「依次出现」
- 调用
/^(?=.*\d)(?=.*[a-z]).{8,}$/.test("abc12345")- 结果
true- 换成
/^(.*\d)(.*[a-z]).{8,}$/ false
⭐ 三个条件检查的是同一段文本,因为先行不消耗字符、检查完指针回到原地。密码强度校验就靠这个。
千分位:往「位置」上插逗号
- 调用
"123456".replace(/\B(?=(\d{3})+$)/g, ",")- 结果
"123,456"- 换成
/(?=(\d{3})+$)/g ",123,456"
整条模式匹配到的是零宽的位置,所以「替换」它就是插入 —— 同[锚点那篇](/regex/structure/anchors/)的
^插前缀。补上
\b对中文无效留下的那个洞- 调用
/(?<![一-龥])北京(?![一-龥])/.test("去北京玩")- 结果
false- 换成
/北京/ true
\b认不出中文(\w里没有汉字),但「前后不能是汉字」这个条件可以自己写出来。同一条模式,条件成立时照常工作
- 调用
/(?<![一-龥])北京(?![一-龥])/.test("去 北京 玩")- 结果
true- 换成
/(?<=[一-龥])北京/ false
和上一条是同一条模式、不同输入 —— 差别全在那两个空格上。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
取出金额数字,不要后面的「元」字
用 输入.match(re) 取结果
输入 期望 "价格100元"["100"] "共 25元"["25"] 提示
「后面跟着元」是个条件,不是要取的内容。
参考答案
/\d+(?=元)/先行
(?=…)只检查、不拿走。区别在于结果要不要接着用 —— 拿到"100元"就还得再写一行.replace('元','')。常见错解
/\d+元/—— 它在"价格100元"这条上就错了。校验密码:至少 8 位,且同时含小写字母和数字
用 re.test(输入) 判断
输入 期望 "abc12345"应匹配 "abcdefgh"不应匹配 "12345678"不应匹配 "abc123"不应匹配 提示
条件之间没有先后关系 —— 想想什么东西检查完能让指针回到原地。
参考答案
/^(?=.*[a-z])(?=.*\d).{8,}$/⭐ 「同时满足」用叠加的先行表达:每个
(?=…)检查完指针都退回原地,所以三个条件查的是同一段文本。换成普通分组就变成「一段接一段」,长度根本不够分。常见错解
/^.{8,}$/—— 它在"abcdefgh"这条上就错了。给纯数字串加千分位逗号(
1234567→1,234,567)用 输入.replace(re, ",") 替换
输入 期望 "1234567""1,234,567" "123""123" 提示
要插入而不是替换,模式本身就不能消耗任何字符。
参考答案
/\B(?=(\d{3})+$)/g整条模式匹配的是零宽的位置,所以「替换」它等于插入。
\B排除串首那个位置 —— 少了它,123会变成,123。常见错解
/(?=(\d{3})+$)/g—— 它在"123"这条上就错了。