打底:它到底在做什么

量词与贪婪/懒惰

它在解决什么

没有量词的正则只能描述长度固定的东西。想匹配「三个数字」可以写 \d\d\d, 想匹配「一串数字」就没办法了 —— 你不知道它有多长。

量词补的就是这一块:它跟在某个元素后面,说明那个元素重复几次。

/\d\d\d/.test('123')   // 只认三位,多一位少一位都不行
/\d+/.test('123456')   // 任意位数都认

这一篇讲两件事:五个量词分别管多少次,以及一个几乎人人踩过的默认行为 —— 量词默认尽可能多拿。

五个量词

写法 重复次数 记法
* 零次或多次 「有没有都行,有多少要多少」
+ 一次或多次 「至少得有一个」
? 零次或一次 「可有可无」
{n} 恰好 n 次
{n,} 至少 n 次
{n,m} n 到 m 次 注意逗号后面不能有空格

前三个是后面那种写法的简写:* 就是 {0,},+ 就是 {1,},? 就是 {0,1}。 知道这层对应关系,遇到拿不准的情况可以把简写还原回去看。

🚨 量词只管左边紧挨着的那一个元素 —— 一个字符、一个字符类,或者一个括号分组。 它不管「前面那一串」。这是位置类错误的来源:^colou?r$ 和 ^colo?ur$ 差的只是 ? 挪了一格,能匹配的词却完全不同。

* 和 + 的区别只在「零个算不算」,但这一个字之差会直接决定 空字符串能不能通过。绝大多数「我的正则匹配到了空字符串」 都源自该用 + 的地方写了 *。

🚨 贪婪:.+ 会一路吃到最后

这是本篇最该记住的一段。

假设要从 <b>粗体</b> 里取出第一个标签,很自然会写 <.+> —— 一个左尖括号, 中间若干字符,一个右尖括号。跑出来拿到的却是整个字符串:

/<.+>/ 对 <b>粗体</b> 的结果是 <b>粗体</b>,不是 <b>。

这不是 bug,是 + 的定义。引擎的动作分两步:

第 1 步  .+ 先吃到不能再吃
         <b>粗体</b>
          ^^^^^^^^^^   .+ 拿走了 "b>粗体</b>",已经到字符串末尾
         此时模式里还剩一个 > 没匹配,但后面没字符了 —— 失败

第 2 步  往回吐一个,再试
         <b>粗体</b>
          ^^^^^^^^^    .+ 退成 "b>粗体</b",下一个字符是 ">" —— 成功

结果:<b>粗体</b>

关键在于它是从右往左退的,退到第一个能成功的位置就停。而字符串里最后一个 > 恰好在最右边,所以它退了一步就成功了 —— 第一个 > 压根没被考虑过。

👉 「贪婪」这个词容易让人以为是某种启发式或者优化。它不是。它就是字面意思: 先拿满,再按需退还,而且退还的时候一退到能用为止就立刻停手。

懒惰:在量词后面加一个 ?

把动作反过来:先拿最少的,不够再多拿一个。写法是在量词后面加 ?。

/<.+?>/ 对同一串输入拿到的就是 <b>。

第 1 步  .+? 只拿最少的一个
         <b>粗体</b>
          ^          .+? 拿走 "b",下一个字符是 ">" —— 成功,立即停手

结果:<b>

配上 g 修饰符就能把每个标签单独取出来: /<.+?>/g 得到 ['<b>', '</b>'], 而贪婪版在同样的 g 下只会得到一条(那个吃掉整串的匹配)。

⚠️ +? 不是「可选的加号」。 这个 ? 是量词的后缀,不是一个独立的量词 —— 它的作用是把前面那个量词从「尽量多」改成「尽量少」。五个量词都能加: *? +? ?? {n,}? {n,m}?。

(?? 看起来很怪,但它有用:a?? 的意思是「a 可有可无,而且优先当作没有」。)

实测:同一串输入,两种写法各自拿到什么

下面四行是拿 <b>粗体</b> 这一串跑出来的:

模式 调用 结果
/<.+>/ match ['<b>粗体</b>']
/<.+?>/ match ['<b>']
/<.+>/g matchAll ['<b>粗体</b>']
/<.+?>/g matchAll ['<b>', '</b>']

📌 这张表和本篇里每一条带链接的结论,都存在下方「本篇示例」区块里, 由 npm run test:regex 在每次构建前跑一遍。改错一个字符,闸门会红。

这件事值得单独说一句:站内其他板块的「实测」数字是一次性手跑出来的, 脚本没有入库 —— 内容漂移了没有任何东西会报警。正则是唯一能把这件事 自动化的内容类型,因为它的结果是确定的、与机器无关的。

⭐ 闸门里还有一条判据是反着来的:每条示例除了正确写法,还必须附一个 似是而非的写法,闸门断言拿它跑出来的结果必须不一样。听起来多此一举, 作用是防住一种很隐蔽的失败 —— 如果上面这张表用的输入串里只有一个标签, 贪婪和懒惰会跑出完全相同的结果,这一整节就什么都没演示,而正文照样言之凿凿。

两个常见误会

「我明明限定了 3 到 4 位,为什么 5 位也过?」

因为量词只说「重复几次」,说不了「后面没别的了」。 /^\d{3,4}/ 对 '12345' 返回 true —— 它说的是 「开头有 3 到 4 位数字」,而 12345 的开头确实有。要表达「整串就这么长」 得靠结尾锚点 $,那是下一章的内容。

「+ 加不加好像都一样。」

有时候真的一样,这才是麻烦的地方。 把 /\s+/g 写成 /\s/g 去压缩连续空白, 结果是每个空白字符各自被替换成一个空格 —— 输入输出一模一样, 一个看起来在工作、实际什么也没干的替换。它不报错、不抛异常、 在 code review 里也看不出来,只有比对输入输出才会发现。

怎么选

你想要的 用
从这里一直到最后一个某某 贪婪(默认)
从这里到最近的一个某某 懒惰(量词后加 ?)
提取成对标记之间的内容(引号、括号、标签) 几乎总是懒惰
整串校验(配 ^ 和 $) 两者结果相同,用默认的

最后一行值得留意:在两端都锚定的模式里,贪婪和懒惰通常给出同样的答案, 因为能匹配的范围本来就被锁死了。所以「该用哪个」这个问题其实只在提取 的时候才真正要紧。

⚠️ 还有一个选项是这张表没写的:别用 .。想取一对尖括号之间的东西, 比 <.+?> 更准的写法是 <[^>]+> —— 直接说「不是 > 的字符」, 既不依赖贪婪懒惰,回溯也少得多。等读到「什么时候不该用正则」那一篇, 这个写法会再出现一次,理由那时候会更充分。

下一步

《位置:锚点与零宽》—— 本篇留了个尾巴:量词管不了「到此为止」, 那是锚点的活。下一篇讲 ^ $ \b 这类「匹配位置而不是字符」的东西。

本篇示例

下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。

  1. ? 管的是「它紧挨着的那一个元素」

    调用
    /^colou?r$/.test("color")
    结果
    true
    换成 /^colo?ur$/
    false

    量词永远只管左边紧挨着的那一个元素 —— 一个字符、一个字符类,或者一个括号分组。

  2. * 允许零个,+ 至少要一个

    调用
    /^ab*$/.test("a")
    结果
    true
    换成 /^ab+$/
    false

    两者只差在「零个算不算」。绝大多数「我的正则匹配到了空字符串」都源自误用 *。

  3. 贪婪的 .+ 会一路吃到最后一个 >

    调用
    "<b>粗体</b>".match(/<.+>/)
    结果
    ["<b>粗体</b>"]
    换成 /<.+?>/
    ["<b>"]

    这不是 bug,是 + 的定义:先吃到不能再吃,再往回吐到刚好能匹配为止。

  4. 加一个 ? 变懒惰,停在第一个 >

    调用
    "<b>粗体</b>".match(/<.+?>/)
    结果
    ["<b>"]
    换成 /<.+>/
    ["<b>粗体</b>"]

    +? 不是「可选的加号」—— 这个 ? 是量词的后缀,把「尽量多」改成「尽量少」。

  5. 懒惰 + g:把每个标签单独取出来

    调用
    [..."<b>粗体</b>".matchAll(/<.+?>/g)]
    结果
    ["<b>","</b>"]
    换成 /<.+>/g
    ["<b>粗体</b>"]

    ⚠️ 能这么取标签,只是因为这段输入足够规整。真要解析 HTML 见「什么时候不该用正则」。

  6. {3,4} 限的是重复次数,不是「到此为止」

    调用
    /^\d{3,4}$/.test("12345")
    结果
    false
    换成 /^\d{3,4}/
    true

    量词只说「重复几次」,说不了「后面没别的了」。那是锚点的活,见「位置:锚点与零宽」。

  7. + 在替换里的意义:把连续空白压成一个

    调用
    " a b ".replace(/\s+/g, " ")
    结果
    " a b "
    换成 /\s/g
    " a b "

    把 + 漏掉不会报错,也不会改变结果,只是白跑一趟 —— 这类 bug 最难在 code review 里看出来。

练习

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

  1. 取出字符串里第一个尖括号标签

    用 输入.match(re) 取结果

    输入期望
    "<b>x</b>"["<b>"]
    "<i>y</i><b>"["<i>"]
    提示

    默认的量词是「尽可能多」,你要的是「尽可能少」。

    参考答案

    /<.+?>/

    贪婪的 .+ 会吃到最后一个 >。加 ? 变懒惰,停在第一个。⭐ 更好的写法其实是 <[^>]+> —— 不依赖回溯,见[实战那篇](/regex/practice/validate-extract-replace/)。

    常见错解 /<.+>/ —— 它在"<b>x</b>"这条上就错了。

  2. 校验协议前缀:http:// 或 https://,整串就这么多

    用 re.test(输入) 判断

    输入期望
    "http://"应匹配
    "https://"应匹配
    "htt://"不应匹配
    "httpss://"不应匹配
    参考答案

    /^https?:\/\/$/

    ? 表示「前面那一个元素可有可无」—— 这里管的正好是那个 s。⚠️ 它只管紧挨着的一个字符,想让它管一整段得用括号。

    常见错解 /^http:\/\/$/ —— 它在"https://"这条上就错了。

  3. 校验:整串是 3 到 5 个字母 a

    用 re.test(输入) 判断

    输入期望
    "aaa"应匹配
    "aaaaa"应匹配
    "aa"不应匹配
    "aaaaaa"不应匹配
    参考答案

    /^a{3,5}$/

    {3,5} 是「3 到 5 次」。⚠️ 少了 $ 的话,六个 a 会匹配前五个就收工 —— 又一次「半锚定放行了过长的输入」。

    常见错解 /^a{3,5}/ —— 它在"aaaaaa"这条上就错了。