组装:把模式搭起来

先行与后行

它在解决什么

锚点那篇讲过「零宽」:^ $ \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 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. 先行只检查、不拿走 —— 「元」没有进结果

    调用
    "价格 100元".match(/\d+(?=元)/)
    结果
    ["100"]
    换成 /\d+元/
    ["100元"]

    这就是「零宽」的实用价值:用后文作条件,但不把后文算进答案。

  2. (?!…) 排除掉后面跟着某东西的那些

    调用
    [..."100元 200米 300".matchAll(/\b\d+\b(?!元)/g)]
    结果
    ["200","300"]
    换成 /\b\d+\b/g
    ["100","200","300"]

    负向先行读作「这个位置后面不能是…」。它同样不消耗字符。

  3. (?<=…) 用前文作条件

    调用
    [..."¥100 和 $200".matchAll(/(?<=¥)\d+/g)]
    结果
    ["100"]
    换成 /\d+/g
    ["100","200"]

    ⚠️ 后行(lookbehind)是 ES2018 才进标准的,老环境里可能直接抛语法错 —— 它不是「匹配不到」,是整条正则编译不过。

  4. 叠加多个先行 = 「同时满足」而不是「依次出现」

    调用
    /^(?=.*\d)(?=.*[a-z]).{8,}$/.test("abc12345")
    结果
    true
    换成 /^(.*\d)(.*[a-z]).{8,}$/
    false

    ⭐ 三个条件检查的是同一段文本,因为先行不消耗字符、检查完指针回到原地。密码强度校验就靠这个。

  5. 千分位:往「位置」上插逗号

    调用
    "123456".replace(/\B(?=(\d{3})+$)/g, ",")
    结果
    "123,456"
    换成 /(?=(\d{3})+$)/g
    ",123,456"

    整条模式匹配到的是零宽的位置,所以「替换」它就是插入 —— 同[锚点那篇](/regex/structure/anchors/)的 ^ 插前缀。

  6. 补上 \b 对中文无效留下的那个洞

    调用
    /(?<![一-龥])北京(?![一-龥])/.test("去北京玩")
    结果
    false
    换成 /北京/
    true

    \b 认不出中文(\w 里没有汉字),但「前后不能是汉字」这个条件可以自己写出来。

  7. 同一条模式,条件成立时照常工作

    调用
    /(?<![一-龥])北京(?![一-龥])/.test("去 北京 玩")
    结果
    true
    换成 /(?<=[一-龥])北京/
    false

    和上一条是同一条模式、不同输入 —— 差别全在那两个空格上。

练习

先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。

  1. 取出金额数字,不要后面的「元」字

    用 输入.match(re) 取结果

    输入期望
    "价格100元"["100"]
    "共 25元"["25"]
    提示

    「后面跟着元」是个条件,不是要取的内容。

    参考答案

    /\d+(?=元)/

    先行 (?=…) 只检查、不拿走。区别在于结果要不要接着用 —— 拿到 "100元" 就还得再写一行 .replace('元','')。

    常见错解 /\d+元/ —— 它在"价格100元"这条上就错了。

  2. 校验密码:至少 8 位,且同时含小写字母和数字

    用 re.test(输入) 判断

    输入期望
    "abc12345"应匹配
    "abcdefgh"不应匹配
    "12345678"不应匹配
    "abc123"不应匹配
    提示

    条件之间没有先后关系 —— 想想什么东西检查完能让指针回到原地。

    参考答案

    /^(?=.*[a-z])(?=.*\d).{8,}$/

    ⭐ 「同时满足」用叠加的先行表达:每个 (?=…) 检查完指针都退回原地,所以三个条件查的是同一段文本。换成普通分组就变成「一段接一段」,长度根本不够分。

    常见错解 /^.{8,}$/ —— 它在"abcdefgh"这条上就错了。

  3. 给纯数字串加千分位逗号(1234567 → 1,234,567)

    用 输入.replace(re, ",") 替换

    输入期望
    "1234567""1,234,567"
    "123""123"
    提示

    要插入而不是替换,模式本身就不能消耗任何字符。

    参考答案

    /\B(?=(\d{3})+$)/g

    整条模式匹配的是零宽的位置,所以「替换」它等于插入。\B 排除串首那个位置 —— 少了它,123 会变成 ,123。

    常见错解 /(?=(\d{3})+$)/g —— 它在"123"这条上就错了。