打底:它到底在做什么
正则引擎在干什么
为什么从这里开始
大多数正则教程从「元字符表」开始:\d 是数字,+ 是一个或多个,背下来就能写。
这条路能让你写出第一条正则,但撑不过第三条。因为真正卡住人的从来不是
「\d 是什么意思」,而是:
我明明写对了每个符号,它为什么匹配到了一大坨东西?
这类问题没法靠查表回答,只能靠知道引擎在做什么。 好在那个模型非常小——小到一节就能讲完,而且它能解释这本教程里后面所有的坑。
一次匹配的三个动作
引擎做的事只有三件,循环往复:
1. 挑一个起点 从下标 0 开始
2. 从这个起点往后逐个对 模式里的每一部分,能对上就往前走一格
3. 对不上就回头 先退回最近的岔路口换条路;没路可换就换下一个起点
就这些。下面把三件事各拆一下。
动作一:起点是会挪的
/b/ 能匹配 'ab',不是因为正则「在串里搜索」,
而是因为引擎在位置 0 失败之后,把起点挪到了位置 1 再试一次:
/b/ 对 'ab' 返回 true,而 /^b/ 返回 false。
^ 的作用就是禁止这个挪动——它要求匹配必须从位置 0 开始。
👉 这解释了一个很多人踩过的坑:test() 问的是「串里有没有」,不是「串是不是」。
想问后者,你得用锚点把起点和终点都钉死。这条在锚点那篇
会展开,那里它是校验类 bug 的头号来源。
动作二:在一个起点上,有选择就得挑一个
如果模式里每一部分都只有一种可能(比如 /abc/),匹配就是纯粹的逐字符比对,
没什么可说的。有意思的是有选择的时候——而制造选择的东西只有两类:
- 量词:
a+可以匹配 1 个、2 个、3 个…… - 分支:
(a|ab)可以走左边,也可以走右边。
引擎必须当场挑一个。它的挑法是固定的:
| 遇到 | 默认先试 |
|---|---|
贪婪量词 + * |
尽可能多 |
懒惰量词 +? *? |
尽可能少 |
分支 | |
最左边那一支 |
(a|ab) 对 'ab' 匹配到的是 'a'——
第一支走通了,引擎不会再去看第二支会不会更长。
把顺序换成 (ab|a),结果就变成 'ab'。
⚠️ 所以分支顺序是有语义的,不是随便排的。
(这是回溯引擎的规则。grep 那一系的规则不同,取最长的那个——
见方言那篇。)
动作三:走不通就退回岔路口
这是整个模型里最关键的一步,也是「回溯」这个词的来源。
引擎每遇到一个选择,就在心里记下「这里还有别的路」。 如果沿着当前这条路往前走,后面某一步失败了,它不会直接宣告失败—— 而是退回最近的那个岔路口,换一条路再试。
(a|ab)c 能匹配 'abc',而且第 1 组里是 'ab':
起点 0
├ 试第一支 a → 对上了,往前走
│ └ 接下来要 c,实际是 b → ✗ 失败
├ 退回岔路口,试第二支 ab → 对上了,往前走
│ └ 接下来要 c,实际是 c → ✓ 成功
└ 结果 'abc',组里是 'ab'
组里那个 'ab' 就是「它真的退回来过」的证据。
而把第二条路砍掉(写成 (a)c),同一个输入就彻底失败了——
反过来证明上面那次成功,靠的正是「还有另一条路可退」。
量词上的回溯是同一件事。/.*b/ 对 'aabab' 匹配到整串:
.* 先吃光 "aabab" → 后面还要一个 b,没字符了 ✗
.* 退还一格 "aaba" → 下一个字符是 b ✓
它拿到的是最后一个 b。换成懒惰的 /.*?b/,方向反过来:从最少开始加,
停在第一个 b。两者拿到不同的结果,恰好说明引擎不是「一眼看出答案」,
而是试出来的。
这个模型能解释什么
后面十篇里的现象,几乎都是这三个动作的直接推论:
| 现象 | 它其实是 |
|---|---|
.+ 总是吃太多 |
贪婪先拿满,只退到「刚好能成」为止 |
加个 ? 就对了 |
把「先试最多」改成「先试最少」 |
| 分支顺序换一下结果就变 | 最左优先,第一支成功就收工 |
先行 (?=…) 不出现在结果里 |
它检查完把指针退回原地,等于没走过 |
(a+)+ 会跑到天荒地老 |
岔路口的数量是 2ⁿ,而它要一个个试完才敢说失败 |
最后一行值得单独留意:回溯是正则的能力来源,也是它最大的风险来源。 一条看起来完全正常的模式,可能在某个输入上把所有岔路口都走一遍—— 那个数量是指数级的。什么时候不该用正则 那篇会专门讲它,以及怎么写才不会撞上。
两种引擎,两套规则
顺带说清一件会造成困惑的事:「正则引擎」不是一种东西,是两种。
| 回溯引擎(NFA) | POSIX 引擎(DFA) | |
|---|---|---|
| 代表 | JS、Python、Perl、Java | grep、sed、awk 默认 |
| 怎么选 | 最左优先,按你写的顺序试 | 最左最长,取最长的那个答案 |
| 支持反向引用、先行后行 | 是 | 否 |
| 会灾难性回溯 | 是 | 否 |
本教程讲的是第一种(JavaScript)。两者的差异集中在 方言那篇,现在只需要知道: 你在命令行上和在代码里用的,可能不是同一套规则。
怎么用这个模型
以后遇到「它为什么匹配到了奇怪的东西」,按顺序问三句:
- 它从哪个起点开始的? —— 我有没有锚点把它钉住?
- 在那个起点上,有哪些选择? —— 哪个量词、哪个分支制造了分叉?
- 它默认先试哪一个? —— 贪婪先试最多,分支先试最左。
三句话走完,绝大多数「诡异行为」会变成「哦,它本来就该这样」。
下一步
《字符与字符类》——讲 .、[]、\d\w\s
这些「一个字符」的写法,以及否定字符类里那个几乎人人踩过的坑。
那是最小的一块积木;再往后的 《量词与贪婪/懒惰》 才是这一篇里「动作二、动作三」的正式展开。
本篇示例
下面每一条都由 npm run test:regex 在每次构建前实跑验证, 结果是现算的,不是抄进数据里的副本。正文里的代码块只用来演示匹配过程和写法对照,不进闸门; 凡是「这个模式配这个输入得到这个结果」的断言,只存在于这里。
匹配不上时,引擎会把起点往后挪一格再试
- 调用
/b/.test("ab")- 结果
true- 换成
/^b/ false
「这个串里有没有」之所以成立,靠的就是这个挪动。锚点的作用正是禁止它。
贪婪是「先拿满,再一格一格退还」
- 调用
"aabab".match(/.*b/)- 结果
["aabab"]- 换成
/.*?b/ ["aab"]
两者拿到不同的 b,恰好说明引擎不是「一眼看出答案」,而是试出来的。
没有后续约束时,第一条走得通的分支就收工
- 调用
"ab".match(/(a|ab)/)- 结果
["a","a"]- 换成
/(ab|a)/ ["ab","ab"]
⚠️ 这是回溯引擎的规则(JS/Python/Perl)。POSIX 那一系取最长,见[方言那篇](/regex/practice/dialects/)。
后面走不通,就退回岔路口换一条
- 调用
"abc".match(/(a|ab)c/)- 结果
["abc","ab"]- 换成
/(a)c/ null
⭐ 这四个字母的例子里藏着后面所有内容的种子:贪婪、懒惰、灾难性回溯,都是这一步的放大版。