DevKitLab Logo DevKitLab
正则表达式 / 调试 / JavaScript

为什么我的正则匹配不上?先搞懂引擎在做什么

正则“不匹配”十有八九不是没匹配,而是匹配到了你没料到的那一段。与其逐条背 tips,不如理解引擎如何逐位置行走与回溯——贪婪、非贪婪、锚点、前瞻、灾难性回溯,都是这一个机制的侧面。

你写了一个正则,盯着它看了半天,越看越确定它应该能匹配。可结果要么一个都没匹配到,要么匹配到的东西离你想要的差着十万八千里。于是你开始往上加东西——多一个 .*、多一个 \、把某段再包一层括号——每加一次,它离对的方向反而更远了一点。

大多数正则教程不肯先说的一句实话是:引擎从不出错,它一字不差地照你写的做了,只是你写的和你以为的不是同一回事。所以“为什么不匹配”这个问题,问法本身就偏了。更有用的问法是:引擎此刻正走在文本的哪个位置,它试过了什么,又为什么在那里停下。 一旦你能像引擎那样思考,下面每一种“灵异现象”都会退化成一个可以预测的结果。

这篇文章不打算给你一张 tips 清单。它先花几分钟把引擎的工作方式讲清楚,然后用同一个例子一路演进——从一个错得离谱的模式,改到一个正确、健壮、还不会拖垮性能的模式。贪婪与非贪婪、忘了开的 flag、lastIndex 那个反复横跳的坑、锚点、转义的两层世界、前瞻的零宽、Unicode 的三个层次,最后是会让 CPU 直接烧起来的灾难性回溯——你会看到它们其实是同一台机器的不同侧面。

引擎到底在做什么

JavaScript(以及 PCRE、Python re、Java 等大多数你日常用的)背后是一台回溯式引擎。它的工作方式其实很朴素:引擎带着一个“当前位置”,从字符串最左端起,一个字符一个字符地往右读,每到一个位置就试一次——看你的模式能不能从这里开始匹配成功。

关键就在“试”这个字上。每当引擎遇到 *+? 这类量词,它就得做个选择:是再多吃一个字符,还是就此打住?贪婪量词(默认的 *+)永远先选“多吃”,尽可能一口气吞下更多字符;与此同时,它会把沿途每一个“其实也可以在这儿停下”的位置都记进一个回溯栈里。等模式往后走、在某处卡住匹配不下去时,引擎不会立刻认输,而是退回到最近记下的那个位置,吐回一个字符,换一种可能再试。把当前位置上所有可能都试遍了还不行,它才认输,把当前位置往右挪一格,从头再来一遍。

这套动作——先贪心地吃,卡住了就回溯着往回吐——是看懂正则行为的总钥匙。后面你会一次次看到它:贪婪为什么会吃过头,是因为它先吞后吐;非贪婪为什么正好相反,是因为它先忍着不吃、被逼急了才吃;前瞻为什么不占字符,是因为它只是探头看一眼、并不真的往前读;灾难性回溯为什么能烧掉 CPU,是因为“换一种可能再试”的组合数会指数级爆炸。同一台机器,同一套规则。

顺带一个值得知道的分野:也有一类引擎回溯——像 Go 的 regexp、Rust 的 regexripgrep 背后就是它),它们基于有限自动机,把匹配变成一次线性扫描,因此天生免疫灾难性回溯,代价是放弃了反向引用这类需要回溯才能实现的特性。这解释了一个常见的困惑:同一个危险模式,在 ripgrep 里跑得飞快,搬进 Node.js 却能把进程挂死。你的引擎属于哪一类,决定了哪些坑对你成立。 本文讲的是 JavaScript 这台回溯引擎。

我们要修的这段

整篇文章用同一段文本当靶子,它离真实场景很近——两条挨在一起的链接:

<a href="/products/12">Boots</a> <a href="/products/34">Hat</a>

目标很朴素:把两个 href 的值都抽出来,也就是 /products/12/products/34。这个需求简单到你觉得随手就能写对,可它恰好能把上面那台引擎的每一个脾气都勾出来。在动手之前先记住一件事:排查正则,第一步永远不是改模式,而是看清它现在到底落在哪里。把模式、flag、测试文本三样凑到一起,让它把匹配到的每一段直接高亮在原文上——这正是正则测试工具的用处,它把抽象的模式还原成你眼睛能读的一段染色文本。下面每一节,你都可以把那节的模式粘进去,亲眼看清它到底匹配到了哪一段。

贪婪:它不是没匹配,是回溯得太晚

先上一个几乎人人写过的第一版:

<a href="(.*)">

你期待 (.*) 框住 /products/12。可它框住的是——

/products/12">Boots</a> <a href="/products/34

从第一个 href=" 之后,一路吃到最后一个 "> 前面才停。用引擎的模型看,这一点都不神秘:.* 贪婪地一口气吞掉了从这里到行尾的所有字符,然后引擎发现模式还剩一个 "> 没交代,于是开始回溯——从最右端往左,一个字符一个字符地吐。它吐到倒数第一个 "> 时,模式就凑齐、宣告成功了,于是它停在那儿,再也不往回吐了。它不是“想吃那么多”,而是“回溯得太晚,才遇到第一个能收尾的地方”。

修法是给量词加一个 ?,把它从贪婪翻成非贪婪(懒惰)

<a href="(.*?)">

.*? 把选择的默认方向反过来——每一步都先选“不吃”,只在下一步走不通、被逼无奈时才勉强吞一个字符。于是它从空开始,一点点往外扩,在遇到第一个 "> 时就收手,(.*?) 正好框住 /products/12。把这两个模式先后粘进正则测试工具,你会看到高亮范围“唰”地一下缩回来——这是理解贪婪与非贪婪最直观的一眼。

不过,最利落的写法既不是贪婪也不是懒惰,而是从一开始就不给引擎回溯的机会

<a href="([^"]*)">

[^"]* 的意思是“除双引号外的任意字符”。它压根碰不到那个结束引号,所以吞到引号前自然就停了——不需要先吃过头再吐回来,中间没有任何回溯。这不只是更快,还更安全。这里藏着一个 JavaScript 的硬限制,值得你早点知道:有些引擎提供原子组 (?>...)占有量词 a++,能明确告诉引擎“这段吃下去就别再吐了”,从根上锁死回溯。JavaScript 到今天都没有这两样。所以在 JS 里,控制回溯的唯一手段就是像上面这样把模式重写成不产生歧义的形状——用取反字符类划死边界,而不是靠 .*? 硬撑。记住这条,等下讲灾难性回溯时它就是解药。

flag 与状态:模式没错,是引擎的开关和记忆在作怪

假设你已经用上了 <a href="([^"]*)">,可它只匹配到第一条链接,第二条 /products/34 死活不出来。模式没问题,问题在 flag——那几个默认全部关闭、忘了开也不报错、只会安静地给你半个结果的开关。

  • g(global)——它让 replace 处理全部命中,也是 matchAll 的前提。String.prototype.match() 的返回形态也会随它改变:不开 g 时拿到首个匹配及捕获组,开了 g 时拿到所有完整匹配。想稳定遍历每一处及其捕获组,用 matchAll 最清楚。
  • i(ignore case)——/hat/ 匹配不到 Hat,开了 i 才大小写一视同仁。
  • m(multiline)——只改变 ^$ 的含义,下一节讲。
  • s(dotall)——默认下 . 不匹配换行符。如果你的模式里有 .*,而待匹配内容跨了行,. 会在换行处停下;开了 s,它才会把换行也当作可匹配字符。

到这儿都还是常识。但 g 这个 flag 还藏着一个更深的坑:g(或 y)的正则是有状态的。 同一个 RegExp 对象会记住一个 lastIndex——上次匹配结束的位置——下次从那里继续。当你把同一个对象反复喂给 .test().exec(),就会得到这种看似反常的结果:

const re = /href/g;
re.test('href');  // true —— lastIndex 前进到 4
re.test('href');  // false —— 从位置 4 开始找,没了
re.test('href');  // true —— 到头,lastIndex 归零,又从头来

同一个字符串、同一个正则,test 却在 truefalse 之间反复横跳。凶手就是那个被悄悄改写的 lastIndex。复用带 g 的正则本身没有问题——用 exec() 有意逐个遍历时正是如此——但在不需要延续位置的 .test() 判断前,应重置 lastIndex = 0,或新建正则。尤其不要把它提成模块级常量后到处 test。想读取全部匹配及捕获组,优先用 String.prototype.matchAll();它从原正则派生迭代器,不会改动原对象的 lastIndex。顺带说一句:本站的正则测试工具为了便于查看,会在“命中详情”中枚举所有位置;替换预览则仍按你是否开启 g 执行。

锚点与边界:它们匹配的是位置,不是字符

锚点是回溯模型里的一个特例——它们零宽,匹配的是字符与字符之间的“缝”,而不是字符本身。这是它们最反直觉的地方。

^$ 默认锚的是整个字符串的首尾,不是每一行。看这段多行文本:

error: disk full
error: timeout

你写 ^error: 想匹配每行开头,可默认下 ^ 只认整个字符串最开头那一个位置,于是只有第一行命中。想让 ^$每一行首尾都生效,得开 m(multiline)。这俩几乎总是成对出现,忘了开 m,是“为什么只匹配到第一行”的头号原因。

\b(单词边界)是另一个常被误读的零宽断言。它匹配“单词字符和非单词字符之间”的那道缝。\bcat\b 能框住 a cat sat 里的 cat,却不会匹配 category 里的 cat——因为 cat 后面紧跟着 e,那里没有边界。很多人把 \b 当成“空格”,其实标点、行首行尾都算边界。还有一层更深的坑:\b 判定“什么算单词字符”用的是 ASCII 定义([A-Za-z0-9_]),即便你开了 u 也不例外。所以对中文、带重音的拉丁字母,\b 的行为会和你的直觉对不上——汉字在它眼里根本不是“单词字符”,边界自然落错地方。

转义:一个点号,两层世界

正则里 . + * ? ( ) [ ] { } ^ $ | \ 这批字符有特殊含义,想匹配它们本身,得加 \ 转义。最常见的受害者是点号:你想匹配版本号里的 .,写成 \d+\.\d+;偷懒写成 \d+.\d+,那个裸 . 就成了“任意字符”,1X21 21a2 全会被它匹配——它比你想要的宽松太多,而且不报错,只在某天喂进脏数据时悄悄匹配到不该匹配的东西。

但真正让人抓狂的,是转义的两层世界。测试工具的输入框吃的是原始正则,一个反斜杠就是一个 \;可代码里的正则常常活在字符串里,而字符串本身也拿 \ 做转义,于是同一个模式在两个世界里写法不同:

// 正则字面量里,一个反斜杠就够:
/\d+\.\d+/

// 但从字符串构造时,反斜杠要翻倍——因为字符串先吃掉一层:
new RegExp('\\d+\\.\\d+')

“在测试工具里明明好好的,一放进代码就失灵”,十有八九就是这层反斜杠没对齐——你从某段 JSON、某个日志字段里拷出来的 \\d,粘进测试框时其实该是 \d;塞回代码字符串时又得加倍回去。正则测试工具的导出功能会替你把这层差别处理好,直接给出目标语言里能粘的那份写法,省掉手动数反斜杠。

还有一个进阶场景:当你要用变量拼正则时——比如把用户输入当成待匹配的字面串——那段输入里任何一个 .(? 都会被当成元字符,轻则匹配错,重则让外部输入改变你的匹配逻辑。RegExp.escape() 已在 ES2025 标准化,可把字符串安全地变成字面匹配片段;现代浏览器已支持它,但若要兼容较旧的浏览器或运行时,先确认目标环境,必要时采用经过维护的 polyfill。动态拼正则时,变量部分必须先转义,不要直接把外部字符串塞进模式里。

前瞻与后顾:只检查条件,不消费字符

到这一步你可能想做件更精细的事:把 href 的值抽出来,但不带那对引号。前瞻和后顾正是为此而生,而它们身上藏着一个几乎人人会栽的认知偏差。

回到引擎模型:前瞻 (?=...) 让引擎“往前探一眼”,检查接下来是不是符合某个模式,但看完这一眼,当前位置并不往前移。它是零宽的——条件成立与否会影响匹配成败,但被它检查的那些字符不算进匹配结果。所以:

\d+(?=px)

12px,它匹配到的是 12不含 px。如果你打印 match[0] 发现里面没有 px,那不是 bug,正是前瞻的定义。同理,负前瞻 (?!...)、后顾 (?<=...)、负后顾 (?<!...) 全是零宽的——它们框定上下文,却不消费上下文。

这个“不占字符”的性质恰恰是它们的价值所在。想把引号之间的值精确抠出来、又不带引号,可以写:

(?<=href=")[^"]*(?=")

后顾断言“我前面是 href="”,前瞻断言“我后面是 "”,夹在中间的 [^"]* 才是真正被消费、被返回的部分——引号只作条件,不进结果,省得你事后再 slice 掉两头。把它粘进正则测试工具会看到很直观的一幕:条件区域不被高亮,只有真正被消费的那段染上色——“零宽”这个抽象词,一眼就具象了。(一个 JavaScript 的加分项:它的后顾支持变长模式,不像有些引擎只允许固定长度的后顾,所以 (?<=\w+=") 这种写法在 JS 里是合法的。)

Unicode 的三个层次:ASCII、码位、字素簇

只要文本里有中文、emoji 或任何非 ASCII 字符,就有一整层坑在等着,而且它比大多数人以为的深——“支持 Unicode”其实分三个层次,每一层都可能咬你。

第一层,别再假设 \d\w 认得非 ASCII。 在 JavaScript 里,\d 默认只匹配 ASCII 的 0-9\w 只认 [A-Za-z0-9_],所以 \w+ 一个汉字都框不住。想按 Unicode 类别匹配,得开 u flag,并改用 \p{...} 属性转义:\p{L} 是任意语言的字母、\p{N} 是数字、\p{Han} 专指汉字,/\p{L}+/u 才能匹配中文词。这里有个极其反直觉的细节:开了 u不会\d\w\b 变得懂 Unicode——它们照旧是 ASCII 语义。想匹配包括全角、阿拉伯-印度数字在内的“任意数字”,你得写 \p{Nd},而不是指望 u\d 升级。

第二层,u 让引擎按码位而非 UTF-16 编码单元行走。 不开 u 时,一个占两个编码单元的字符(比如很多 emoji)会被 . 拦腰截成半个,切出来就是乱码。开了 u. 才按完整码位处理,\u{1F600} 这种写法也才被认。

第三层,也是最容易被忽略的:码位仍然不等于“一个字符”。 人眼看到的一个字符,术语叫字素簇(grapheme cluster),可能由好几个码位拼成——带肤色的 emoji、国旗、👨‍👩‍👧 这种用零宽连接符拼起来的家庭 emoji,都是多个码位的组合。即便开了 u. 匹配的也只是其中一个码位,于是你还是可能把一个 emoji 切碎。真要按“人眼里的字符”处理,有两条路:ES2024 的 v flag(unicodeSets)带来了集合运算和 \p{RGI_Emoji} 这类可整体匹配复合 emoji 的字符串属性;要做通用的字素分割,则用 Intl.Segmenter——它不是正则,但更适合按人类认知切分文本。本站测试器当前提供 u,尚未提供 v 开关;需要 v 时请在目标环境中验证。处理 emoji 或多语言文本时,先想清楚你要的是哪一层,能省掉一大类“匹配到半个字符”的问题。

它匹配得对,却把页面卡死了:灾难性回溯

最后一种“不对”,症状不是匹配错,而是匹配一下就卡住——标签页无响应、CPU 直接拉满。这叫灾难性回溯(catastrophic backtracking),也是 ReDoS 拒绝服务攻击的原理。有了前面那台引擎模型,你现在能从第一性原理看懂它。

最经典的形态是嵌套量词

(a+)+$

拿它去匹配一串 aaaaaaaaaaX(结尾偏偏不是它要的)。内层 a+ 和外层 + 能匹配同一批字符,于是这串 a 存在指数级多种被拆分成组的方式:(a)(a)(a)…(aa)(a)…(a)(aa)………而末尾那个 $ 永远无法在 X 前成立,逼得引擎把每一种拆法都回溯着试一遍才敢宣告失败。输入稍微变长,失败匹配的耗时就会急剧增长;具体多久取决于浏览器、硬件和引擎实现,不能用固定的字符数断言。凶手的画像很固定:一个量词套着另一个量词,且两者能匹配同一批字符——(a+)+(.*)*(\d+)* 都是这个家族;(.*?)* 这种把懒惰嵌进贪婪的也一样危险。

这也正好回收了前面那个伏笔:为什么讲贪婪时我劝你能用 [^"]* 就别用 .*?。取反字符类先划定了边界,不需要在所有位置之间反复试探。JavaScript 目前没有原子组和占有量词这两种直接限制回溯的语法,因此首选做法是重写模式以消除歧义:让各个分支的匹配范围尽量不重叠、在合适的位置加锚点、把无边界的 .* 换成明确的字符类。本站的正则测试工具会异步检查潜在 ReDoS 风险;检测出风险时会显示攻击样本,无法完成判定时也会明确提示。它适合尽早发现问题,但不能代替真实输入规模下的性能测试。

一份可以照着走的排查清单

下次正则不听话,别急着往上堆字符。回到引擎的视角,按这个顺序过一遍,几乎每次都能定位:

  1. 先看它现在匹配到哪。 把模式和文本粘进正则测试工具看高亮,别急着猜。八成问题到这一步就现形了。
  2. 匹配过头? 贪婪的 .* 回溯得太晚,改成 .*?,或更好的取反字符类 [^…]*
  3. 只匹配到第一个 / 第一行? 你多半忘了开 gm
  4. 同一段代码时灵时不灵? 检查是不是复用了带 g 的正则,lastIndex 在作怪。
  5. . ( [ 没按字面匹配? 该转义的加 \,并确认反斜杠没在字符串那层被吞或加倍。
  6. 结果多了或少了你没预期的部分? 想想是不是把零宽的前瞻/后顾当成了普通分组。
  7. 中文、emoji 匹配不上或被切碎? 分清 ASCII、码位、字素簇三层:开 u,用 \p{...},必要时上 v flag 或 Intl.Segmenter
  8. 一跑就卡死? 找嵌套量词,那是灾难性回溯——在 JS 里靠重写模式解决。

正则写对之后,活儿往往才开始。把匹配到的内容成批替换成别的形态,是文本替换工具用正则捕获组干的事;确认改动前后两段文本到底差在哪,交给文本对比工具。但每一步的起点都一样——一个你看得清它到底匹配到哪、每一步为什么这么走的正则,而不是一个你只能盯着干猜的正则。