打底:它到底在做什么

正则引擎在干什么

为什么从这里开始

大多数正则教程从「元字符表」开始:\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)。两者的差异集中在 方言那篇,现在只需要知道: 你在命令行上和在代码里用的,可能不是同一套规则。

怎么用这个模型

以后遇到「它为什么匹配到了奇怪的东西」,按顺序问三句:

  1. 它从哪个起点开始的? —— 我有没有锚点把它钉住?
  2. 在那个起点上,有哪些选择? —— 哪个量词、哪个分支制造了分叉?
  3. 它默认先试哪一个? —— 贪婪先试最多,分支先试最左。

三句话走完,绝大多数「诡异行为」会变成「哦,它本来就该这样」。

下一步

《字符与字符类》——讲 .、[]、\d\w\s 这些「一个字符」的写法,以及否定字符类里那个几乎人人踩过的坑。

那是最小的一块积木;再往后的 《量词与贪婪/懒惰》 才是这一篇里「动作二、动作三」的正式展开。

本篇示例

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

  1. 匹配不上时,引擎会把起点往后挪一格再试

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

    「这个串里有没有」之所以成立,靠的就是这个挪动。锚点的作用正是禁止它。

  2. 贪婪是「先拿满,再一格一格退还」

    调用
    "aabab".match(/.*b/)
    结果
    ["aabab"]
    换成 /.*?b/
    ["aab"]

    两者拿到不同的 b,恰好说明引擎不是「一眼看出答案」,而是试出来的。

  3. 没有后续约束时,第一条走得通的分支就收工

    调用
    "ab".match(/(a|ab)/)
    结果
    ["a","a"]
    换成 /(ab|a)/
    ["ab","ab"]

    ⚠️ 这是回溯引擎的规则(JS/Python/Perl)。POSIX 那一系取最长,见[方言那篇](/regex/practice/dialects/)。

  4. 后面走不通,就退回岔路口换一条

    调用
    "abc".match(/(a|ab)c/)
    结果
    ["abc","ab"]
    换成 /(a)c/
    null

    ⭐ 这四个字母的例子里藏着后面所有内容的种子:贪婪、懒惰、灾难性回溯,都是这一步的放大版。