打底:它到底在做什么
量词与贪婪/懒惰
它在解决什么
没有量词的正则只能描述长度固定的东西。想匹配「三个数字」可以写 \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 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
?管的是「它紧挨着的那一个元素」- 调用
/^colou?r$/.test("color")- 结果
true- 换成
/^colo?ur$/ false
量词永远只管左边紧挨着的那一个元素 —— 一个字符、一个字符类,或者一个括号分组。
*允许零个,+至少要一个- 调用
/^ab*$/.test("a")- 结果
true- 换成
/^ab+$/ false
两者只差在「零个算不算」。绝大多数「我的正则匹配到了空字符串」都源自误用
*。贪婪的
.+会一路吃到最后一个>- 调用
"<b>粗体</b>".match(/<.+>/)- 结果
["<b>粗体</b>"]- 换成
/<.+?>/ ["<b>"]
这不是 bug,是
+的定义:先吃到不能再吃,再往回吐到刚好能匹配为止。加一个
?变懒惰,停在第一个>- 调用
"<b>粗体</b>".match(/<.+?>/)- 结果
["<b>"]- 换成
/<.+>/ ["<b>粗体</b>"]
+?不是「可选的加号」—— 这个?是量词的后缀,把「尽量多」改成「尽量少」。懒惰 +
g:把每个标签单独取出来- 调用
[..."<b>粗体</b>".matchAll(/<.+?>/g)]- 结果
["<b>","</b>"]- 换成
/<.+>/g ["<b>粗体</b>"]
⚠️ 能这么取标签,只是因为这段输入足够规整。真要解析 HTML 见「什么时候不该用正则」。
{3,4}限的是重复次数,不是「到此为止」- 调用
/^\d{3,4}$/.test("12345")- 结果
false- 换成
/^\d{3,4}/ true
量词只说「重复几次」,说不了「后面没别的了」。那是锚点的活,见「位置:锚点与零宽」。
+在替换里的意义:把连续空白压成一个- 调用
" a b ".replace(/\s+/g, " ")- 结果
" a b "- 换成
/\s/g " a b "
把
+漏掉不会报错,也不会改变结果,只是白跑一趟 —— 这类 bug 最难在 code review 里看出来。
练习
先自己写,再看答案。读懂和写得出是两件事,而这一节练的是后者。每道题的参考答案都由 npm run test:exercises-regex 实跑验证: 答案必须通过全部用例,「常见错解」必须至少被一条用例抓住, 而且 /.*/ 这类万能写法必须过不了 —— 否则这道题就没有区分度。
取出字符串里第一个尖括号标签
用 输入.match(re) 取结果
输入 期望 "<b>x</b>"["<b>"] "<i>y</i><b>"["<i>"] 提示
默认的量词是「尽可能多」,你要的是「尽可能少」。
参考答案
/<.+?>/贪婪的
.+会吃到最后一个>。加?变懒惰,停在第一个。⭐ 更好的写法其实是<[^>]+>—— 不依赖回溯,见[实战那篇](/regex/practice/validate-extract-replace/)。常见错解
/<.+>/—— 它在"<b>x</b>"这条上就错了。校验协议前缀:
http://或https://,整串就这么多用 re.test(输入) 判断
输入 期望 "http://"应匹配 "https://"应匹配 "htt://"不应匹配 "httpss://"不应匹配 参考答案
/^https?:\/\/$/?表示「前面那一个元素可有可无」—— 这里管的正好是那个s。⚠️ 它只管紧挨着的一个字符,想让它管一整段得用括号。常见错解
/^http:\/\/$/—— 它在"https://"这条上就错了。校验:整串是 3 到 5 个字母 a
用 re.test(输入) 判断
输入 期望 "aaa"应匹配 "aaaaa"应匹配 "aa"不应匹配 "aaaaaa"不应匹配 参考答案
/^a{3,5}$/{3,5}是「3 到 5 次」。⚠️ 少了$的话,六个 a 会匹配前五个就收工 —— 又一次「半锚定放行了过长的输入」。常见错解
/^a{3,5}/—— 它在"aaaaaa"这条上就错了。