组装:把模式搭起来
分组、捕获与反向引用
它在解决什么
量词那篇留了一条规则:量词只管左边紧挨着的那一个元素。
于是 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上直接抛 TypeErrorm[1] === ''—— 一个成立一个不成立JSON.stringify——undefined会被整个吞掉,数组里变成null
👉 判据:打算对组的值做字符串操作,就把问号放进括号里((a?)),
这样它永远是个字符串。只想知道「有没有」就放外面((a)?),语义更直白。
怎么选
| 你要的 | 写法 |
|---|---|
| 让量词管住一整段 | (?:…) —— 不打算用内容就别捕获 |
| 取出一段内容 | (…),然后 m[1] |
| 取出好几段,还想改正则 | (?<名字>…),编号会漂、名字不会 |
| 要求两处内容相同 | 模式里用 \1 |
| 把取出的内容拼回去 | 替换串里用 $1 或 $<名字> |
| 组的值一定要是字符串 | 问号放括号内:(a?) |
还有一条和上一章连起来的:提取成对标记之间的内容时,分组决定取哪一段,
量词决定取多长。"(.+?)" 里那个懒惰量词不是可选的装饰——
换成贪婪的 "(.+)",组里会一路吃到最后一个引号。
下一步
《选择分支与优先级》—— | 的优先级低到超出直觉,
而括号恰好是控制它的唯一办法。这两篇是连着的。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
括号让量词管住一整段,而不只是一个字符
- 调用
/^(ab)+$/.test("abab")- 结果
true- 换成
/^ab+$/ false
这正是上一章那句「量词只管左边紧挨着的一个元素」的解法:把那一段用括号包成一个元素。
捕获组会出现在结果里,非捕获组不会
- 调用
"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悄悄指向了别的东西。命名组:用名字重排,而不是数括号
- 调用
"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>不报错,直接原样输出。又一个不报错的坑。\1是「和第 1 组匹配到的内容相同」- 调用
/^(\w)\1$/.test("aa")- 结果
true- 换成
/^(\w)1$/ false
📌
\1用在模式里,$1用在替换串里 —— 两个位置、两套语法,混用不会报错,只会不匹配。反向引用的典型用途:找叠词
- 调用
"the the cat".match(/(\w+) \1/)- 结果
["the the","the"]- 换成
/(\w+)\1/ null
校对文稿时很好用。⚠️ 它只认完全相同的重复,
The the要配i修饰符才抓得到。(a)?和(a?)不是一回事- 调用
"b".match(/^(a)?(b)$/)- 结果
["b",null,"b"]- 换成
/^(a?)(b)$/ ["b","","b"]
前者是「这一组没上场」,后者是「上场了但匹配到空」。取值时一个是
undefined、一个是''。提取引号里的内容:分组要配懒惰
- 调用
"说 \"hello\" 和 \"world\"".match(/"(.+?)"/)- 结果
["\"hello\"","hello"]- 换成
/"(.+)"/ ["\"hello\" 和 \"world\"","hello\" 和 \"world"]
组里那个
.+?就是上一章的懒惰量词 —— 分组决定取哪一段,量词决定取多长。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
把
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"这条上就错了。找出重复的相邻单词(
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"这条上就错了。校验:整串是
ab重复三次用 re.test(输入) 判断
输入 期望 "ababab"应匹配 "abab"不应匹配 "abababab"不应匹配 参考答案
/^(?:ab){3}$/量词只管紧挨着的一个元素 ——
ab{3}是「a 后面三个 b」。要让它管住一整段,得先用括号把那段变成一个元素。⭐ 用(?:而不是(:这里不需要取内容。常见错解
/^ab{3}$/—— 它在"ababab"这条上就错了。