正则表达式测试工具

用真实文本测试正则,高亮匹配结果,并对灾难性回溯强制中断。

g

为什么一个正则测试器需要 worker

本页上的大部分机械装置,都是为了应付一种失败模式。看看 /(a+)+b/ 去匹配三十个 a、且没有 b 的情形。引擎必须决定怎样在内外两个量词之间划分这三十个字符,而划法有 2³⁰ 种。在能够宣告匹配不可能之前,每一种都要试一遍。

关键在于一个正在运行的正则是无法被中断的。JavaScript 是单线程的,所以 setTimeout 守卫在正则返回之前根本不会触发——而对那个输入来说,那个时刻大约在宇宙热寂之后。这个标签页就没了。

所以模式是在 Web Worker 里、在它自己的线程上运行的,而主页面握着一个计时器。计时器赢了的时候,worker.terminate() 会直接把那个线程杀掉。这是唯一真正管用的机制,也是为什么这个测试器会告诉你「你的模式很危险」,而不是变成一个卡死的标签页。

这一点值得记进心里,因为同样的模式出现在生产代码里就是一次拒绝服务:用户提交的输入被拿去匹配一个有缺陷的正则,一个请求就能把一个 CPU 核心无限期地钉住。这类缺陷叫作 ReDoS。

该避开的那些写法

危险写法为什么更安全的写法
(a+)+嵌套量词——指数级的划分方式a+
(\w|\s)*量词内部出现选择分支,且分支彼此重叠[\w\s]*
(\d+)*$带量词的分组加上一个锚点,逼着引擎穷尽搜索\d*$
.*.*=两个无界通配符争抢同一段文本[^=]*=

共同的因素是歧义:模式有不止一种方式去消耗同样的那些字符。字符类能干选择分支的活儿,却不带来歧义——这就是为什么「更安全」那一列基本上只是把 | 换成了 []

贪婪、懒惰,以及每个人都会写错一次的那个 bug

量词默认是贪婪的:.+ 会尽其所能地吃,而且只在抗议之下才把字符交回来。对 <b>bold</b> 来说:

模式匹配到
<.+><b>bold</b>——整个字符串
<.+?><b>——懒惰,遇到第一个 > 就停
<[^>]+><b>——而且更快,因为根本没有可回溯的东西

第三种才是你该伸手去拿的那个。取反的字符类不可能吃过头,所以也就没有回溯要撤销——它既把意图表达得更清楚,跑起来也比懒惰版本更便宜。

lastIndex,以及为什么每隔一个匹配就消失了

g 标志的 RegExp 是有状态的。它保存着一个 lastIndex,每次 exectest 之后都会前移,所以在多次调用之间复用同一个对象,就会从它上次停下的地方继续:

const re = /\d+/g;
re.test('123');  // true,  lastIndex is now 3
re.test('123');  // false! it resumed from index 3

在循环内部创建这个正则,或者把 lastIndex = 0 重置掉,或者用 matchAll,它会替你管好这件事。上面的测试器每次运行都会新建一个对象,所以你在这里看到的是「首次调用」的行为。

JavaScript 的正则有哪些不同

这里的结果与 Node 和浏览器代码完全一致,因为用的就是同一个引擎。而相对于其他语言,让人栽跟头的差异有:

  • 没有原子组,也没有占有型量词。 PCRE 有 (?>...)a++ 可以彻底阻止回溯;JavaScript 两样都没有,这就是为什么在这里更容易写出 ReDoS。
  • 不支持递归。 你没法用 JavaScript 正则匹配成对的括号。如果那是需求,你需要的是一个解析器。
  • 后行断言来得很晚。 (?<=...) 在当前浏览器里是支持的,但老版本 Safari 不支持,所以要看清你的目标环境。
  • Go 的 RE2 是另一台机器。 保证线性时间,没有反向引用,没有环视。如果你曾经纳闷 Go 为什么砍掉这些特性,本页就是答案。

相关

如果你要匹配的文本是 JSON,那么先把它格式化一下通常会让这个模式变得多余——面对结构化数据,解析器每一次都赢过正则。如果你要的是排程而不是匹配,cron 解析器是隔壁那件工具。

正则相关问题

为什么我的模式跑了两秒就被中止了?

因为它发生了灾难性回溯。某些写法——比如 (a+)+ 或 (\w|\s)* 这样的嵌套量词——会让引擎去尝试指数级数量的匹配路径,于是四十个字符的字符串就可能算得比宇宙的年龄还久。JavaScript 无法中断一个正在运行的正则,所以这里的模式是在 Web Worker 里跑的:worker 可以被终止,页面因此保持响应。放在主线程上的超时只会在正则跑完之后才触发,而对这类模式来说那一刻永远不会到来。

这里用的正则引擎和我代码里的一样吗?

用的是你浏览器的引擎,也就是 JavaScript 引擎——所以结果与 Node 和浏览器里的代码完全一致。但它未必与 PCRE、Python、Go 或 Java 一致。JavaScript 没有原子组,没有占有型量词,也不支持递归;后行断言在当前浏览器里已可用,但来得很晚。Go 的 RE2 则是完全不同的设计,并且刻意不支持反向引用,而这正是它避开上述回溯问题的方式。

为什么我的全局正则只找到了每隔一个的匹配?

带 g 标志的 RegExp 对象会保存一个 lastIndex,它随每次调用而前移,所以在多次 exec 或 test 调用之间复用同一个对象,就会从上次停下的位置继续。这是这个 API 里最稳定地令人困惑的部分之一。要么每次都新建正则,要么在使用前把 lastIndex 重置为 0,要么改用 matchAll,它会替你处理好。

贪婪量词和懒惰量词有什么区别?

贪婪量词会尽可能多地吃进字符,只有被迫时才吐回来;懒惰量词(写法是后面加一个 ?)则尽可能少地吃,只有不得不时才扩张。对 <b>bold</b> 来说,模式 <.+> 会匹配整个字符串,因为 .+ 把一切都吞了,然后只回退到刚好能找到一个结尾的 >。写成 <.+?> 就只匹配 <b>。这是一个模式匹配到的东西远超预期的最常见原因。

把真实数据粘进来安全吗?

安全。模式和文本会交给本页内部的一个 worker,并在那里执行。没有任何内容被发往服务器——这也正是失控的模式能被取消的原因,因为根本没有请求需要等待。

最后审校于 。发现有内容过时了? 告诉我们.