作者:wanago翻译:疯狂的技术宅
原文:https://wanago.io/2019/09/23/...
未经允许严禁转载
前文:
正则表达式可以解决许多问题,但也有可能是使我们头痛的根源。 最近 Cloudfare 的一次停机事故就是由于正则表达式导致全球大量机器上的 CPU 峰值飙升至100%。在本文中,我们将会学习需要注意的情况,例如灾难性的回溯。为了帮助我们理解问题,还分析了贪婪和懒惰量词以及为什么 lookahead 可能会有所帮助。
有些人遇到问题时会想:“我知道,我将使用正则表达式。”现在他们有两个问题了。Jamie Zawinski
深入研究量词
正则表达式引擎非常复杂。尽管我们可以用 regexp 创造奇迹,但需要考虑可能会遇到的一些问题。所以需要更深入地研究如何去执行某些正则表达式。
贪婪量词
在本系列文章的前几部分中,我们使用了 +
之类的量词。它告诉引擎至少匹配一个。
const expression = /e+/;
expression.test('Hello!'); // true
expression.test('Heeeeello!'); // true
expression.test('Hllo!'); // false
让我们仔细看看第二个例子: /e+/.test('Heeeeello!')
。我们可能想知道用这个表达式匹配多少个字母。
由于默认情况下量词是贪婪的,因此我们会匹配尽可能多的字母。可以用 match函数来确认这一点。
'Heeeeello!'.match(/e+/);
// ["eeeee", index: 1, input: "Heeeeello!", groups: undefined]
另一个不错的例子是处理一些 HTML 标签:
const string = 'Beware of <strong>greedy</strong> quantifiers!';
/<.+>/.test(string); // true
最初的猜测可能是它与 <strong>
之类的东西匹配。不完全是!
string.match(/<.+>/);
// ["<strong>greedy</strong>" (...) ]
如你所见,贪婪的量词与最长的字符串匹配!
惰性量词
在本系列中,我们还将介绍 ?
量词。这意味着匹配零或一次。
function wereFilesFound(string) {
return /[1-9][0-9]* files? found/.test(string);
}
wereFilesFound('0 files found'); // false
wereFilesFound('No files found'); // false
wereFilesFound('1 file found'); // true
wereFilesFound('2 files found'); // true
有趣的是,通过将其添加到贪婪的量词中,我们告诉它重复尽可能少的次数,因此使其变得懒惰。
const string = 'Beware of <strong>greedy</strong> quantifiers!';
string.match(/<.+?>/);
// ["<strong>", (...) ]
灾难性的回溯
要了解量词如何影响正则表达式的行为,我们需要仔细研究被称为回溯的过程。
先让我们看一下这段看似清白的代码!
const expression = /^([0-9]+)*$/;
乍一看,它可以成功检测到一系列数字。让我们分解一下它的工作方式。
expression.test('123456789!');
- 首先,引擎处理
[0-9]+
。它是贪婪的,所以它会首先尝试匹配尽可能多的数字。首先匹配的是123456789
-
然后引擎尝试应用
*
量词,但没有其他数字了- 因为用的是
$
符号,所以我们希望字符串以数字结尾——!
符号不会发生这种情况
- 因为用的是
- 现在由于回溯,在
[[0-9]+]
中匹配的字符数量减少了。它匹配12345678
。 -
然后使用
*
量词,因此([0-9]+)*
产生两个子字符串:12345678
和9
- 由于上述子字符串均不在字符串末尾,因此与
$
匹配失败
- 由于上述子字符串均不在字符串末尾,因此与
- 引擎通过减少
[0-9]+
匹配的位数来保持回溯
上述过程会产生多种不同的组合。
我们的字符串以 !
符号结尾。因此,正则表达式引擎尝试回溯,直到在提供的字符串的末尾找到数字为止。
[12345678][9]!
[1234567][89]!
[1234567][8][9]!
[123456][789]!
[123456][7][89]!
[123456][78][9]!
经过了大量的计算,但是没有找到匹配的结果。这可能会导致性能大幅下降。如果使用非常长的字符串,浏览器可能会挂起,从而破坏用户体验。
通过将贪婪量词更改为惰性量词,有时可以提高性能,但是这个特定的例子并不属于这种情况。
先行断言(Lookahead)
要解决上述问题,最直接方法是完全重写正则表达式。上面的解决方案并不总是很容易,而且有可能会造成很大的痛苦。解决上述问题的方法是使用先行断言(lookahead)。
在最基本的形式中,它声明 x 仅会在其后跟随 y 时才匹配。
const expression = /x(?=y)/;
expression.test('x'); // false
expression.test('xy'); // true
我们将其称为正向先行断言。仅当 x 后面不跟随 y 时,用负向先行断言匹配 x
const expression = /x(?!y)/;
expression.test('x'); // true
expression.test('xy'); // false
先行断言很酷的地方在于它是原子性的。在满足条件后,引擎将不会回溯并尝试其他排列。
回溯引用(Backreference)
我们在这里需要涉及到的的另一个问题是回溯引用。
const expression = /(a|b)(c|d)\1\2/;
上面的 \1
表示第一个捕获组的内容,而 \2
表示第二个捕获组的内容。
expression.test('acac'); // true
expression.test('adad'); // true
expression.test('bcbc'); // true
expression.test('bdbd'); // true
expression.test('abcd'); // false
我们可以结合使用先行断言和回溯引用来处理回溯问题:
const expression = /^(?=([0-9]+))\1*$/
这看起来很复杂。让我们对它进行分解。
- 表达式
(?=([0-9]+))
寻找最长的数字字符串,因为+
是贪婪的 - 引擎不会回溯寻找不同的组合
- 表达式
(?=([0-9]+))\1
的回溯引用指出,先行查找的内容需要出现在字符串中
由于上述所有原因,我们可以安全地测试很长的字符串,而不会产生性能问题。
const expression = /^(?=([0-9]+))\1*$/;
expression.test('5342193376141170558801674478263705216832 D:'); //false
expression.test('7558004377221767420519835955607645787848'); // true
总结
在本文中,我们更深入地研究了量词。可以将它们分为贪婪和懒惰两种量词,并且它们可能会对性能产生影响。我们还讨论了量词可能导致的另一个问题:灾难性回溯。我们还学习了如何使用 先行断言(lookahead) 来改善性能,而不仅仅是去重写表达式。有了这些知识,我们可以编写更好的代码,避免出现Cloudflare这样的问题。
本文首发微信公众号:前端先锋
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章
欢迎继续阅读本专栏其它高赞文章:
- 12个令人惊叹的CSS实验项目
- 必须要会的 50 个React 面试题
- 世界顶级公司的前端面试都问些什么
- 11 个最好的 JavaScript 动态效果库
- CSS Flexbox 可视化手册
- 从设计者的角度看 React
- 过节很无聊?还是用 JavaScript 写一个脑力小游戏吧!
- CSS粘性定位是怎样工作的
- 一步步教你用HTML5 SVG实现动画效果
- 程序员30岁前月薪达不到30K,该何去何从
- 14个最好的 JavaScript 数据可视化库
- 8 个给前端的顶级 VS Code 扩展插件
- Node.js 多线程完全指南
- 把HTML转成PDF的4个方案及实现
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。