组装:把模式搭起来

选择分支与优先级

它在解决什么

| 就是「或」:cat|dog 匹配 cat 或者 dog。

看起来没什么可讲的,但它有两个性质会稳定地坑到人,而且两个都不报错:

  1. 它的优先级低到超出直觉——低到会把你以为是一个整体的模式劈成两半。
  2. 它选分支的规则是「第一个能匹配的赢」,不是「最长的赢」。

这一篇基本就是围绕这两件事。

🚨 | 的优先级最低

先看这条模式,猜一下它匹配什么:

/^cat|dog$/

多数人读成「整串是 cat 或者 dog」。它实际读作:

(^cat) | (dog$)          ← 真实含义
^(cat|dog)$              ← 你以为的含义

所以 /^cat|dog$/.test('cat food') 返回 true—— 'cat food' 确实以 cat 开头,左边那一支成立了。而正确写法 /^(cat|dog)$/ 对同一个输入返回 false。

这条模式作为校验用是完全失效的:它会放行任何以 cat 开头的东西, 以及任何以 dog 结尾的东西。而它不报错、不警告,对 'cat' 和 'dog' 这两个测试输入还都是对的——写测试的人很容易就此收工。

⚠️ 注意 | 的优先级比锚点还低。量词那篇说的「只管紧挨着的一个元素」 是同一类问题的另一面:| 恰好相反,它管得太宽,一直吃到模式的两端为止。

括号是控制它的唯一办法

| 没有「只作用于这几个字符」的写法,圈定范围只能靠括号:

/\b(cat|dog)s\b/g 从 'cats dogs cows' 里取出 cats 和 dogs。 去掉那对括号之后,模式读作 (\bcat) | (dogs\b)——前一支不要求后面有 s, 后一支不要求前面有边界,你以为的「公共部分」\b 和 s 被两支各自吃掉了。

👉 拿不准就加括号。 这是本篇唯一需要背的一条:多余的括号最多让模式长一点 (用 (?:…) 连编号都不会占),而漏掉的括号会安静地改变整条模式的含义。

分支和锚点那篇的词边界经常要一起用。 'cat category dog'.replace(/\b(cat|dog)\b/g, 'pet') 得到 'pet category pet', 而少了 \b 的版本会把 category 改成 petegory——批量替换是最容易出这种事故的地方, 它不报错,只是悄悄改坏几个不该动的词。

🚨 第一个能匹配的赢,不是最长的赢

'category'.match(/cat|category/) 得到的是 'cat', 不是 'category'。

引擎在同一个起点从左往右逐个试分支,第一支成功就收工,根本不会去看第二支 有没有可能匹配得更长。把两支调换顺序,结果就变成 'category'—— 所以分支的顺序是有语义的,不是随便排的。

👉 判据:把长的、具体的放前面,短的、笼统的放后面。 /category|cat/ 是对的,/cat|category/ 几乎总是 bug。

📌 这条只对回溯引擎成立(JavaScript、Python、Perl、Java 都是)。 POSIX 那一系的工具规定的是「最左最长」匹配,同一条模式结果正好相反:

$ echo category | grep -oE 'cat|category'
category                      ← grep 给最长的

$ node -e "console.log('category'.match(/cat|category/)[0])"
cat                           ← JS 给最先匹配上的

(实测环境:/usr/bin/grep = BSD grep 2.6.0-FreeBSD、node v22、Python 3.14; python3 的结果与 node 一致。最左最长是 POSIX 规定的行为,所以 GNU grep 同理。) 这类差异会在方言那一篇集中讲,这里只需要记住一件事: 「分支顺序不重要」这个念头在任何一边都是错的,只是错的方式不同。

[…] 和 (…|…) 不能互换

两者读起来都是「或」,但能表达的东西差一个量级:

挑的是 能否多字符 是否捕获
[cd] 一个字符 不能 不捕获
(c|d) 一整段 能 捕获(除非写 (?:)

/^(cat|dog)$/ 匹配 'dog',而 /^[catdog]$/ 匹配不了—— 后者说的是「一个字符,且它属于 c/a/t/d/o/g 这个集合」,而 'dog' 有三个字符。

⚠️ [catdog] 这个写法本身是合法的,也不会报错。它只是几乎肯定不是你要的意思—— 方括号里没有「或」的概念,也不认识「单词」,它眼里只有一堆互不相干的字符。

👉 判据:候选项都是单个字符就用 […](更快、更短、不占编号), 只要有一个候选项超过一个字符,就必须用 (…|…)。

优先级速查

从高到低:

1. \        转义
2. ( )  [ ] 分组与字符类
3. * + ? {} 量词
4. 序列、锚点      abc  ^  $  \b
5. |        分支          ← 最低

这张表只有最后一行需要记——前四行基本符合直觉,而第 5 行是本篇所有坑的来源。

怎么选

你要表达的 写法
这一个字符是 a、b 或 c [abc]
这一段是 cat 或 dog (?:cat|dog),要取内容才写 (cat|dog)
整串正好是 cat 或 dog ^(cat|dog)$ —— 别写 ^cat|dog$
只替换独立的词 \b(cat|dog)\b
候选项有长有短 长的写前面

一条实用的自查:写完带 | 的模式,把它读成「(…) 或 (…)」再看一遍。 如果你读出来的两支跟你想的不一样,就是括号加漏了。

下一步

《先行与后行》——本章最后一块。它们和锚点一样是零宽的, 但能表达的条件远不止「行首行尾」:比如「后面跟着的不是汉字」这种, 正是\b 对中文无效那一节留下的问题。

本篇示例

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

  1. 🚨 | 的优先级低到会把整条模式劈成两半

    调用
    /^cat|dog$/.test("cat food")
    结果
    true
    换成 /^(cat|dog)$/
    false

    | 的优先级比所有东西都低,包括锚点。拿不准就加括号 —— 加括号从来不会让事情变糟。

  2. 分支是「第一个能匹配的赢」,不是「最长的赢」

    调用
    "category".match(/cat|category/)
    结果
    ["cat"]
    换成 /category|cat/
    ["category"]

    🚨 正则不做最长匹配。写分支时把长的、具体的放前面,短的、笼统的放后面。

  3. […] 和 (…|…) 不能互换

    调用
    "dog".match(/^(cat|dog)$/)
    结果
    ["dog","dog"]
    换成 /^[catdog]$/
    null

    字符类挑的是一个字符,分支挑的是一整段。看起来都是「或」,能表达的东西差一个量级。

  4. 括号决定分支「管到哪为止」

    调用
    [..."cats dogs cows".matchAll(/\b(cat|dog)s\b/g)]
    结果
    ["cats","dogs"]
    换成 /\bcat|dogs\b/g
    ["cat","dogs"]

    没有括号时,| 两侧各自吃掉它能吃到的一切,包括你以为是「公共部分」的 \b 和 s。

  5. 分支配上词边界,否则会在词内部命中

    调用
    "cat category dog".replace(/\b(cat|dog)\b/g, "pet")
    结果
    "pet category pet"
    换成 /cat|dog/g
    "pet petegory pet"

    批量替换是最容易出这个事故的地方 —— 它不报错,只是悄悄改坏了几个不该动的词。

练习

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

  1. 校验文件名以 .jpg、.jpeg 或 .png 结尾

    用 re.test(输入) 判断

    输入期望
    "a.jpg"应匹配
    "a.jpeg"应匹配
    "a.png"应匹配
    "a.gif"不应匹配
    "a.jpgx"不应匹配
    提示

    先把三种后缀圈成一组,再让 $ 管住整组。

    参考答案

    /\.(?:jpe?g|png)$/

    🚨 | 的优先级比锚点还低,不加括号时那个 $ 只管住 png 这一支 —— a.jpgx 会被放行。拿不准就加括号。

    常见错解 /\.jpe?g|png$/ —— 它在"a.jpgx"这条上就错了。

  2. 从 category 里取出完整的 category(同一条模式也要能取 cat)

    用 输入.match(re) 取结果

    输入期望
    "category"["category"]
    "cat"["cat"]
    "the category here"["category"]
    参考答案

    /category|cat/

    🚨 回溯引擎是「第一个能匹配的赢」,不是「最长的赢」。所以分支顺序有语义:长的、具体的放前面。⚠️ POSIX 那一系(grep)规则相反,见[方言那篇](/regex/practice/dialects/)。

    常见错解 /cat|category/ —— 它在"category"这条上就错了。

  3. 校验:整串正好是 yes 或 no

    用 re.test(输入) 判断

    输入期望
    "yes"应匹配
    "no"应匹配
    "yesno"不应匹配
    "maybe"不应匹配
    参考答案

    /^(?:yes|no)$/

    不加括号时它读作 (^yes)|(no$) —— 放行任何以 yes 开头或以 no 结尾的串。这是 | 最经典的坑,而且对 yes、no 这两个测试输入都是对的。

    常见错解 /^yes|no$/ —— 它在"yesno"这条上就错了。