组装:把模式搭起来

分组、捕获与反向引用

它在解决什么

量词那篇留了一条规则:量词只管左边紧挨着的那一个元素。 于是 ab+ 说的是「一个 a,后面一个或多个 b」,而不是「ab 重复若干次」。

想让量词管住一整段,就得先把那一段变成「一个元素」——这就是括号的第一个作用: /^(ab)+$/ 匹配 'abab',而 /^ab+$/ 不匹配。

但括号同时还在做第二件事,而且那件事往往是你真正要的:把匹配到的内容存下来。

() 在做两件事

(ab)+        ← 作用一:分组。让 + 管住整个 ab
(\d{4})-     ← 作用二:捕获。把匹配到的四位数字存进「第 1 组」

两件事默认捆在一起:写了括号就既分组又捕获。大多数时候这没问题, 但它有代价,下面「(?:)」那一节会讲。

⚠️ 这两个作用经常被混为一谈,而它们的失败方式完全不同:分组写错了通常当场不匹配 (容易发现),捕获写错了往往照样匹配,只是取到的东西不对(不容易发现)。

捕获组:结果里多出来的那几项

非全局的 match() 返回的数组,第 0 项是完整匹配,后面依次是各个捕获组:

'2026-09-11'.match(/(\d{4})-(\d{2})-(\d{2})/) 得到 4 项—— ['2026-09-11', '2026', '09', '11']。

组的编号规则只有一条:按左括号出现的先后数。嵌套也照这个规则, 外层的左括号在前,所以外层是小号:

/((\d{4})-(\d{2}))/
  ^^      ^      ^
  1       2      3        ← 数左括号,不数右括号,也不看嵌套层数

🚨 编号是位置决定的,所以它会漂。 在前面插一个新的捕获组, 后面所有组的编号全部往后推一位——而你代码里那句 m[2] 不会报错, 只是开始指向另一个东西。这正是下一节那个东西存在的理由。

(?:):只要分组,不要捕获

在左括号后面加 ?:,就得到一个只分组、不捕获的括号。 优先级作用完全不变,但它不占编号、不出现在结果里:

对照上面那条,把年份那组换成 (?:\d{4}) 之后结果只剩 3 项, 而且原来的第 2、3 组变成了第 1、2 组。

👉 实用判据:这一对括号我打算用它的内容吗? 不打算就写 (?:。 纯粹为了让量词管住一段而加的括号,几乎都该是非捕获的。

命名组:别再数括号

编号会漂,那就别用编号。(?<名字>…) 给组起个名:

'2026-09-11'.replace(/(?<y>…)-(?<m>…)-(?<d>…)/, '$<d>/$<m>/$<y>') 得到 '11/09/2026'。

位置 编号写法 命名写法
替换串里 $1 $<y>
匹配结果里 m[1] m.groups.y

命名组同时也是编号组——m[1] 和 m.groups.y 拿到的是同一个东西, 所以改用命名不会破坏已有的按编号取值的代码。

🚨 那条示例的对照行值得单独看一眼:把命名组全换成非捕获组之后, 替换串里的 $<d> 不报错,JS 直接把它当成普通字面量写进结果, 于是你得到一个字面写着 $<d>/$<m>/$<y> 的字符串。 又一个「不报错的坑」——只有比对输出才看得出来。

配合 matchAll 可以一次取出全部匹配,每一项仍然是一个完整的 match 数组, groups 也在里面:

for (const m of text.matchAll(/(?<y>\d{4})-(?<m>\d{2})/g)) {
  // m[0] 完整匹配、m[1] 与 m.groups.y 同值
}

\1 与 $1:两个位置、两套语法

\1 写在模式里,意思是「这里要出现和第 1 组匹配到的内容完全相同的东西」—— 它叫反向引用:

/^(\w)\1$/ 匹配 'aa',而少一个反斜杠写成 /^(\w)1$/ 就变成了「一个字符后面跟字面量 1」,'aa' 当然不匹配。

这两个符号最容易搞混的地方是它们长得像但不在同一个地方用:

模式里    \1     「和第 1 组一样的内容」          /(\w)\1/
替换串里  $1     「把第 1 组的内容填到这里」      str.replace(re, '$1$1')

⚠️ 用错位置不会报错。在模式里写 $1,它就是「字面量 $ 后面跟个 1」; 在替换串里写 \1,它就是「反斜杠 1」。两种都只是默默匹配不到 / 输出怪字符。

反向引用的典型用途是找重复:/(\w+) \1/ 能从 'the the cat' 里抓出 'the the', 校对文稿时很好用。注意它只认完全相同的重复,The the 得配 i 修饰符才抓得到。

🚨 (a)? 和 (a?) 不是一回事

这一条是分组里最容易踩且最难查的坑。两种写法都表示「a 可有可无」, 但组没匹配上时拿到的东西不同:

'b'.match(/^(a)?(b)$/) 的第 1 组是 undefined,而 /^(a?)(b)$/ 的第 1 组是 ''。

(a)?    问号在括号外 → 这一组整个「没上场」   → undefined
(a?)    问号在括号内 → 上场了,匹配到空串     → ''

在 if (m[1]) 这种判断里两者行为相同(都是假值),所以坑会潜伏很久。 出事的地方是:

  • m[1].length —— undefined 上直接抛 TypeError
  • m[1] === '' —— 一个成立一个不成立
  • JSON.stringify —— undefined 会被整个吞掉,数组里变成 null

👉 判据:打算对组的值做字符串操作,就把问号放进括号里((a?)), 这样它永远是个字符串。只想知道「有没有」就放外面((a)?),语义更直白。

怎么选

你要的 写法
让量词管住一整段 (?:…) —— 不打算用内容就别捕获
取出一段内容 (…),然后 m[1]
取出好几段,还想改正则 (?<名字>…),编号会漂、名字不会
要求两处内容相同 模式里用 \1
把取出的内容拼回去 替换串里用 $1 或 $<名字>
组的值一定要是字符串 问号放括号内:(a?)

还有一条和上一章连起来的:提取成对标记之间的内容时,分组决定取哪一段, 量词决定取多长。"(.+?)" 里那个懒惰量词不是可选的装饰—— 换成贪婪的 "(.+)",组里会一路吃到最后一个引号。

下一步

《选择分支与优先级》—— | 的优先级低到超出直觉, 而括号恰好是控制它的唯一办法。这两篇是连着的。

本篇示例

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

  1. 括号让量词管住一整段,而不只是一个字符

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

    这正是上一章那句「量词只管左边紧挨着的一个元素」的解法:把那一段用括号包成一个元素。

  2. 捕获组会出现在结果里,非捕获组不会

    调用
    "2026-09-11".match(/(\d{4})-(\d{2})-(\d{2})/)
    结果
    ["2026-09-11","2026","09","11"]
    换成 /(?:\d{4})-(\d{2})-(\d{2})/
    ["2026-09-11","09","11"]

    ⚠️ 加一个不打算用的捕获组,会把它后面所有组的编号推后一位 —— $2 悄悄指向了别的东西。

  3. 命名组:用名字重排,而不是数括号

    调用
    "2026-09-11".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>")
    结果
    "11/09/2026"
    换成 /(?:\d{4})-(?:\d{2})-(?:\d{2})/
    "$<d>/$<m>/$<y>"

    🚨 对照那行值得看一眼:正则里没有命名组时,$<d> 不报错,直接原样输出。又一个不报错的坑。

  4. \1 是「和第 1 组匹配到的内容相同」

    调用
    /^(\w)\1$/.test("aa")
    结果
    true
    换成 /^(\w)1$/
    false

    📌 \1 用在模式里,$1 用在替换串里 —— 两个位置、两套语法,混用不会报错,只会不匹配。

  5. 反向引用的典型用途:找叠词

    调用
    "the the cat".match(/(\w+) \1/)
    结果
    ["the the","the"]
    换成 /(\w+)\1/
    null

    校对文稿时很好用。⚠️ 它只认完全相同的重复,The the 要配 i 修饰符才抓得到。

  6. (a)? 和 (a?) 不是一回事

    调用
    "b".match(/^(a)?(b)$/)
    结果
    ["b",null,"b"]
    换成 /^(a?)(b)$/
    ["b","","b"]

    前者是「这一组没上场」,后者是「上场了但匹配到空」。取值时一个是 undefined、一个是 ''。

  7. 提取引号里的内容:分组要配懒惰

    调用
    "说 \"hello\" 和 \"world\"".match(/"(.+?)"/)
    结果
    ["\"hello\"","hello"]
    换成 /"(.+)"/
    ["\"hello\" 和 \"world\"","hello\" 和 \"world"]

    组里那个 .+? 就是上一章的懒惰量词 —— 分组决定取哪一段,量词决定取多长。

练习

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

  1. 把 2026-09-11 改写成 11/09/2026

    用 输入.replace(re, "$3/$2/$1") 替换

    输入期望
    "2026-09-11""11/09/2026"
    "2020-01-02""02/01/2020"
    提示

    替换串里 $1 $2 $3 按左括号出现的顺序编号。

    参考答案

    /(\d{4})-(\d{2})-(\d{2})/

    🚨 少一个捕获组时 $3 不会报错,它会被当成普通文字原样输出。这类错误只有比对输出才看得见 —— 同[分组那篇](/regex/structure/groups/)里 $<d> 那个坑。

    常见错解 /(\d{4})-\d{2}-(\d{2})/ —— 它在"2026-09-11"这条上就错了。

  2. 找出重复的相邻单词(the the 这种)

    用 输入.match(re) 取结果

    输入期望
    "the the cat"["the the","the"]
    "a a"["a a","a"]
    提示

    怎么表达「和前面那一组一模一样的内容」?

    参考答案

    /(\w+) \1/

    \1 是反向引用:要求这里出现和第 1 组匹配到的内容完全相同的东西。⚠️ \1 用在模式里,$1 用在替换串里 —— 两个位置两套语法。

    常见错解 /(\w+)\1/ —— 它在"the the cat"这条上就错了。

  3. 校验:整串是 ab 重复三次

    用 re.test(输入) 判断

    输入期望
    "ababab"应匹配
    "abab"不应匹配
    "abababab"不应匹配
    参考答案

    /^(?:ab){3}$/

    量词只管紧挨着的一个元素 —— ab{3} 是「a 后面三个 b」。要让它管住一整段,得先用括号把那段变成一个元素。⭐ 用 (?: 而不是 (:这里不需要取内容。

    常见错解 /^ab{3}$/ —— 它在"ababab"这条上就错了。