一次性搞懂JavaScript正则表达式之引擎
GitHub地址:https://github.com/veedrin/horseshoe
博客地址(文章排版真的很漂亮):https://veedrin.com
如果觉得对你有帮助,欢迎来GitHub点Star或者来我的博客亲口告诉我
我们说正则表达式是语言无关的,是因为驱动正则表达式的引擎是相似的。鉴于正则表达式是一种古老的语法,它的引擎也在历史长河中衍生出了几个大的分支。
我会关注到正则表达式引擎这样比较底层的实现,缘起于在一次业务实践中,追踪到一个由正则引起的BUG。业务中使用的一个markdown解析库Remarkable
在解析一段不规则文本时引起浏览器崩溃,调试之后发现是某一个正则在匹配时陷入了死循环,严格的说(后来才知道)是匹配花费了过多时间导致浏览器卡死。
我当时很震惊,正则匹配的性能不是很高的么?匹配到就是匹配到,没匹配到就是没匹配到,怎么会在里面走不出来了呢?
有限自动机
什么叫有限自动机(Finite Automate)呢?
我们把有限自动机理解为一个机器人,在这个机器人眼里,所有的事物都是由有限节点组成的。机器人按照顺序读取有限节点,并表达成有限状态,最终机器人输出接受
或者拒绝
作为结束。
关注它的两个特点:
- 有限状态集。
- 只根据当前状态和当前输入来决定下一个状态。它有点一板一眼。
怎么理解第二个特点?我们看一个例子:
'aab'.match(/a*?b/); // ["aab", index: 0, input: "aab", groups: undefined]
我们知道*?
是非贪婪匹配,按照我们人类灵活的尿性,直接把匹配结果ab
甩他脸上。
但有限自动机不会。第一步它用a
匹配a
非常完美,然后发现对于a
是非贪婪模式,于是试着用b
匹配下一个a
,结果非常沮丧。于是它只能继续用a
匹配,匹配成功后依然没忘非贪婪特性,继续试着用b
匹配下一个字符b
,成功,收官。
其实写出这段代码的开发者想要的结果应该是ab
,但有限自动机从来不仰望星空,只低头做事,一板一眼的根据当前状态和当前输入来决定下一个状态。
DFA与NFA
有限自动机大体上又可以分为两类:DFA是确定性有限自动机
的缩写,NFA是非确定性有限自动机
的缩写。
我没办法告诉你DFA与NFA在原理上的差别,但咱们可以探讨一下它们在处理正则上的表现差异。
总的来说,DFA可以称为文本主导的正则引擎,NFA可以称为表达式主导的正则引擎。
怎么讲?
我们常常说用正则去匹配文本,这是NFA的思路,DFA本质上其实是用文本去匹配正则。哪个是攻,哪个是受,大家心里应该有个B数了吧。
我们来看一个例子:
'tonight'.match(/to(nite|knite|night)/);
如果是NFA引擎,表达式占主导地位。表达式中的t
和o
不在话下。然后就面临三种选择,它也不嫌累,每一种都去尝试一下,第一个分支在t
这里停止了,接着第二个分支在k
这里也停止了。终于在第三个分支柳暗花明,找到了自己的归宿。
换作是DFA引擎呢,文本占主导地位。同样文本中的t
和o
不在话下。文本走到n
时,它发现正则只有两个分支符合要求,经过i
走到g
的时候,只剩一个分支符合要求了。当然,还要继续匹配。果然没有令它失望,第三个分支完美符合要求,下班。
大家发现什么问题了吗?
只有正则表达式才有分支和范围,文本仅仅是一个字符流。这带来什么样的后果?就是NFA引擎在匹配失败的时候,如果有其他的分支或者范围,它会返回,记住,返回,去尝试其他的分支。而DFA引擎一旦匹配失败,就结束了,它没有退路。
这就是它们之间的本质区别。其他的不同都是这个特性衍生出来的。
首先,正则表达式在计算机看来只是一串符号,正则引擎首先肯定要解析它。NFA引擎只需要编译就好了;而DFA引擎则比较繁琐,编译完还不算,还要遍历出表达式中所有的可能。因为对DFA引擎来说机会只有一次,它必须得提前知道所有的可能,才能匹配出最优的结果。
所以,在编译阶段,NFA引擎比DFA引擎快。
其次,DFA引擎在匹配途中一遍过,溜得飞起。相反NFA引擎就比较苦逼了,它得不厌其烦的去尝试每一种可能性,可能一段文本它得不停返回又匹配,重复好多次。当然运气好的话也是可以一遍过的。
所以,在运行阶段,NFA引擎比DFA引擎慢。
最后,因为NFA引擎是表达式占主导地位,所以它的表达能力更强,开发者的控制度更高,也就是说开发者更容易写出性能好又强大的正则来,当然也更容易造成性能的浪费甚至撑爆CPU。DFA引擎下的表达式,只要可能性是一样的,任何一种写法都是没有差别(可能对编译有细微的差别)的,因为对DFA引擎来说,表达式其实是死的。而NFA引擎下的表达式,高手写的正则和新手写的正则,性能可能相差10倍甚至更多。
也正是因为主导权的不同,正则中的很多概念,比如非贪婪模式、反向引用、零宽断言等只有NFA引擎才有。
所以,在表达能力上,NFA引擎秒杀DFA引擎。
当今市面上大多数正则引擎都是NFA引擎,应该就是胜在表达能力上。
测试引擎类型
现在我们知道正则表达式的驱动引擎分为两大类:DFA引擎与NFA引擎。
但是因为NFA引擎比较灵活,很多语言在实现上有细微的差别。所以后来大家弄了一个标准,符合这个标准的正则引擎就叫做POSIX NFA引擎,其余的就只能叫做传统型NFA引擎咯。
我们来看看JavaScript到底是哪种引擎类型吧。
'123456'.match(/\d{3,6}/); // ["123456", index: 0, input: "123456", groups: undefined] '123456'.match(/\d{3,6}?/); // ["123", index: 0, input: "123456", groups: undefined]
《精通正则表达式》书中说POSIX NFA引擎不支持非贪婪模式,很明显JavaScript不是POSIX NFA引擎。
TODO: 为什么POSIX NFA引擎不支持也没有必要支持非贪婪模式?区分DFA引擎与传统型NFA引擎就简单咯,捕获组你有么?花式零宽断言你有么?
结论就是:JavaScript的正则引擎是传统型NFA引擎。
回溯
现在我们知道,NFA引擎是用表达式去匹配文本,而表达式又有若干分支和范围,一个分支或者范围匹配失败并不意味着最终匹配失败,正则引擎会去尝试下一个分支或者范围。
正是因为这样的机制,引申出了NFA引擎的核心特点——回溯。
首先我们要区分备选状态和回溯。
什么是备选状态?就是说这一个分支不行,那我就换一个分支,这个范围不行,那我就换一个范围。正则表达式中可以商榷的部分就叫做备选状态。
备选状态是个好东西,它可以实现模糊匹配,是正则表达能力的一方面。
回溯可不是个好东西。想象一下,面前有两条路,你选择了一条,走到尽头发现是条死路,你只好原路返回尝试另一条路。这个原路返回的过程就叫回溯,它在正则中的含义是吐出已经匹配过的文本。
我们来看两个例子:
'abbbc'.match(/ab{1,3}c/); // ["abbbc", index: 0, input: "abbbc", groups: undefined] 'abc'.match(/ab{1,3}c/); // ["abc", index: 0, input: "abc", groups: undefined]
第一个例子,第一次a
匹配a
成功,接着碰到贪婪匹配,不巧正好是三个b
贪婪得逞,最后用c
匹配c
成功。
正则 | 文本 |
---|---|
/a/ | a |
/ab{1,3}/ | ab |
/ab{1,3}/ | abb |
/ab{1,3}/ | abbb |
/ab{1,3}c/ | abbbc |
第二个例子的区别在于文本只有一个b
。所以表达式在匹配第一个b
成功后继续尝试匹配b
,然而它见到的只有黄脸婆c
。不得已将c
吐出来,委屈一下,毕竟贪婪匹配也只是尽量匹配更多嘛,还是要臣服于匹配成功这个目标。最后不负众望用c
匹配c
成功。
正则 | 文本 |
---|---|
/a/ | a |
/ab{1,3}/ | ab |
/ab{1,3}/ | abc |
/ab{1,3}/ | ab |
/ab{1,3}c/ | abc |
请问,第二个例子发生回溯了吗?
并没有。
诶,你这样就不讲道理了。不是把c
吐出来了嘛,怎么就不叫回溯了?
为了让大家更好的理解,我举一个例子:
你和一个女孩子(或者男孩子)谈恋爱,接触了半个月后发现实在不合适,于是提出分手。这不叫回溯,仅仅是不合适而已。你和一个女孩子(或者男孩子)谈恋爱,这段关系维持了两年,并且已经同居。但由于某些不可描述的原因,疲惫挣扎之后,两人最终还是和平分手。这才叫回溯。
虽然都是分手,但你们应该能理解它们的区别吧。
网络上有很多文章都认为上面第二个例子发生了回溯。至少根据我查阅的资料,第二个例子发生的情况不能被称为回溯。当然也有可能我是错的,欢迎讨论。
我们再来看一个真正的回溯例子:
'ababc'.match(/ab{1,3}c/); // ["abc", index: 2, input: "ababc", groups: undefined]
匹配文本到ab
为止,都没什么问题。然而苍天饶过谁,后面既匹配不到b
,也匹配不到c
。引擎只好将文本ab
吐出来,从下一个位置开始匹配。因为上一次是从第一个字符a
开始匹配,所以下一个位置当然就是从第二个字符b
开始咯。
正则 | 文本 |
---|---|
/a/ | a |
/ab{1,3}/ | ab |
/ab{1,3}/ | aba |
/ab{1,3}/ | ab |
/ab{1,3}c/ | aba |
/a/ | a b |
/a/ | ab a |
/ab{1,3}/ | ab ab |
/ab{1,3}/ | ab abc |
/ab{1,3}/ | ab ab |
/ab{1,3}c/ | ab abc |
一开始引擎是以为会和最早的ab
走完余生的,然而命运弄人,从此天涯。
这他妈才叫回溯!
还有一个细节。上面例子中的回溯并没有往回吐呀,吐出来之后不应该往回走嘛,怎么往后走了?
我们再来看一个例子:
'"abc"def'.match(/".*"/); // [""abc"", index: 0, input: ""abc"def", groups: undefined]
因为.*
是贪婪匹配,所以它把后面的字符都吞进去了。直到发现目标完不成,不得已往回吐,吐到第二个"
为止,终于匹配成功。这就好比结了婚还在外面养小三,几经折腾才发现家庭才是最重要的,自己的行为背离了初衷,于是幡然悔悟。
正则 | 文本 |
---|---|
/"/ | " |
/".*/ | "a |
/".*/ | "ab |
/".*/ | "abc |
/".*/ | "abc" |
/".*/ | "abc"d |
/".*/ | "abc"de |
/".*/ | "abc"def |
/".*"/ | "abc"def |
/".*"/ | "abc"de |
/".*"/ | "abc"d |
/".*"/ | "abc" |
我想说的是,不要被回溯
的回
字迷惑了。它的本质是把已经吞进去的字符吐出来。至于吐出来之后是往回走还是往后走,是要根据情况而定的。
回溯引发的浏览器卡死惨案
现在我邀请读者回到文章开始提起的正则BUG。
` <img src=# onerror=’alert(document.cookie)/><!--‘ <img src=https://avatar.veedrin.com /> `.match(/<!--([^-]+|[-][^-]+)*-->/g);
这是测试妹子用于测试XSS攻击的一段代码,测试的脑洞你不要去猜。正则是Remarkable
用于匹配注释的,虽然我没搞清楚到底为什么这样写。src我篡改了一下,不影响效果。
不怕事大的可以去Chrome开发者工具跑上一跑。
不卖关子。它会导致浏览器卡死,是因为分支和范围太多了。[^-]+
是一个范围,[-][^-]+
是一个范围,[^-]+|[-][^-]+
是一个分支,([^-]+|[-][^-]+)*
又是一个范围。另外注意,嵌套的分支和范围生成的备选状态是呈指数级增长的。
我们知道这段语句肯定会匹配失败,因为文本中压根就没有-->
。那浏览器为什么会卡死呢?因为正则引擎的回溯实在过多,导致浏览器的CPU进程飙到98%
以上。这和你在Chrome开发者工具跑一段巨大运算量的for循环是一个道理。
但是呢,正则永远不会走入死循环。正则引擎叫有限状态机,就是因为它的备选状态是有限的。既然是有限的,那就一定可以遍历完。10的2次方叫有限,10的200000000次方也叫有限。只不过计算机的硬件水平有限,容不得你进行这么大的运算量。我以前也以为是正则进入了死循环,其实这种说法是不对的,应该叫浏览器卡死或者撑爆CPU。
那么,怎么解决?
最粗暴也最贵的方式当然是换一台计算机咯。拉一台超级计算机过来肯定是可以打服它的吧。
第二就是减少分支和范围,尤其是嵌套的分支和范围。因为分支和范围越多,备选状态就越多,早早的就匹配成功还好,如果匹配能成功的备选状态在很后头或者压根就无法匹配成功,那你家的CPU就得嗷嗷叫咯。
我们来看一下:
` <img src=# onerror=’alert(document.cookie)/><!--‘ <img src=https://avatar.veedrin.com />--> `.match(/<!--([^-]+|[-][^-]+)*-->/g); // ["<!--‘↵<img src=https://avatar.veedrin.com />-->"]
你看,备选状态再多,我已经找到了我的白马王子,你们都歇着去吧。
这个正则我不知道它这样写的用意何在,所以也不知道怎么优化。明白备选状态是回溯的罪魁祸首就好了。
第三就是缩减文本。会发生回溯的情况,其实文本也是一个变量。你想想,总要往回跑,如果路途能短一点是不是也不那么累呢?
'<!--<img src=https://jd.com>'.match(/<!--([^-]+|[-][^-]+)*-->/g); // null
试的时候悠着点,不同的浏览器可能承受能力不一样,你可以一个个字符往上加,看看极限在哪里。
当然,缩减文本是最不可行的。正则正则,就是不知道文本是什么才用正则呀。
优化正则表达式
现在我们知道了控制回溯是控制正则表达式性能的关键。
控制回溯又可以拆分成两部分:第一是控制备选状态的数量,第二是控制备选状态的顺序。
备选状态的数量当然是核心,然而如果备选状态虽然多,却早早的匹配成功了,早匹配早下班,也就没那么多糟心事了。
至于面对具体的正则表达式应该如何优化,那就是经验的问题了。思考和实践的越多,就越游刃有余。无他,唯手熟尔。
工具
[regex101 ]是一个很多人推荐过的工具,可以拆分解释正则的含义,还可以查看匹配过程,帮助理解正则引擎。如果只能要一个正则工具,那就是它了。
[regexper ]是一个能让正则的备选状态可视化的工具,也有助于理解复杂的正则语法。
本文是『horseshoe·Regex专题』系列文章之一,后续会有更多专题推出GitHub地址:https://github.com/veedrin/horseshoe
博客地址(文章排版真的很漂亮):https://veedrin.com
如果觉得对你有帮助,欢迎来GitHub点Star或者来我的博客亲口告诉我