Talk is cheap. Show me your code

说起来你可能不信,一个正则就能让页面卡死

某个阳光明媚的下午,我正悠闲的品着刚买的滇红,测试小姐姐突然急匆匆的找到我:

“快看一下群里,文章编辑器出问题了!”

我手中的滇红瞬间不香了,抓了抓所剩无几的头发,开始了漫长的 Debug 环节

经过排查,发现问题的根源居然是一段正则表达式...

 

一、问题重现

// 在浏览器控制台中运行下面的代码
// 放心,不会卡死的

const reg = /^<del>(.|\s)*<\/del>$/;
const str = '<del>hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! </del><ins>hello wrold!</ins>';

start = Date.now();
const res = reg.test(str);
end = Date.now();

console.log('耗时:' + (end - start));

上面就是出问题的正则,但是字符串更加复杂(不然一执行代码,浏览器就崩溃了)

这段正则本身的目的是为了匹配 <del> 标签的内容。由于 . 不包含换行符,所以用 (.|\s)* 来指代内容

而由于 . 和 \s 有重合的部分,再加上或运算符 | 和贪婪匹配 * ,让正则表达式的运算量指数级增加,最终呈现出页面崩溃的结果

 

 

二、正则引擎

为了解释这个问题,就得了解正则表达式的工作原理

正则有两种工作方式:用文本去匹配正则(DFA)、正则去匹配文本(NFA)

举个例子:

/ja(cket|vate|vascr)/.test('javascript')

如果是 DFA,会用字符串去匹配,过程是这样的:

在匹配到第三个字符 v 的时候,会有三个备选分支,但 cket 分支不满足规则,被排除。

所以在匹配第四个字符 a 的时候,只有两个备选分支,直到第五个字符 s,排除掉 vate 分支,最后只剩下一个备选分支 vascr,最终完成匹配。

 

而对于 NFA,是用正则来匹配文本:

 

在匹配到 ja 之后,会先匹配 cket 分支,发现 c 不匹配,返回上一个节点。

返回节点之后会进入下一个分支,即 vate 分支,直到匹配到 t 才会不匹配,然后返回上一节点。

由于上一节点 a 并没有别的分支,所以继续返回,直到返回最开始的 ja 节点,进入最后一个 vascr 分支,最后完成匹配。

 

从这两个分析可以看出, DFA 在用文本来匹配正则的时候,会逐渐排除不满足条件的备选项。

而 NFA 会匹配每个分支,如果分支不匹配,则回到上一个节点,进入当前节点的另一个分支继续匹配。

也就是说 NFA 就像是在走迷宫,遇到岔路的时候,先选择第一条路走到头。如果走不通,则返回岔路口,进入下一条路继续探索。这个返回岔路口的过程叫做回溯

所以 DFA 引擎的效率比 NFA 更高,但很可惜的是,JavaScript 的正则的引擎是 NFA 类型。

 

 

三、回溯

上面已经提到,NFA 在匹配某一个分支失败时,会返回节点,尝试另一条分支,这种行为被称作回溯。

如果只是上面举的简单例子,回溯并不会造成严重的性能问题,可如果是有多个备选状态,再加上贪婪匹配,这个过程就很恐怖了。

比如这样一个正则:

/(.*)+\d/.test('abcd')

这里的 .* 可以匹配任意字符(\n除外)任意次数,再加上贪婪特性,第一次匹配时 .* 会直接吃掉 abcd ,然后匹配 \d 失败,进行第一次回溯:

然后 .* 将 d 吐出来,本身只匹配 abc。但由于 + 的原因, .* 会进行第二次匹配,然后 \d 匹配失败,再次回溯:

第三次匹配的时候, + 重新记为 1, .* 依然为 abc,剩下一个 d 交给 \d 匹配。由于 \d 需要匹配数字,所以匹配失败,继续回溯:

以此类推,正则会在经过很多次回溯之后,才会得出匹配失败的结论

 

 

四、优化方案

由于回溯机制的存在,我们在写正则的时候一定要牢记:

尽可能的减少备选分支的数量。

比如上例的正则: /(.*)+\d/ ,这里的 + 和 * 存在重复匹配

如果我们最终的期望是匹配 test1、hello123 这种以数字结尾,总长度不小于 2 的字符串

将正则表达式改为  /.+\d/ 就能满足我们的需求

而在文章一开始提到的线上暴雷的正则: /^<del>(.|\s)*<\/del>$/ 

其实也是因为 . 和 \s 有重合的匹配规则,改为 (.|\n) 即可

 

另外,如果我们能预期目标字符串的构成,将备选分支更少的规则写在前面,这样正则就能更早的返回结果

所以调整备选分支的顺序也是一个优化方案

最后,在可以选择的情况,使用 DFA 才能从根本上解决问题。

 

posted @ 2021-01-23 19:07  Wise.Wrong  阅读(1212)  评论(1编辑  收藏  举报