前言
正则表达式也是一门语言,从语言的角度来讲解正则表达式。
一门语言最基本的要素包括 基本元素,条件判断语句,循环语句,关键词(特殊的词),变量等,这其实就是正则表达式的的基本构成。我们可以从循环、条件等多个角度来写正则。
一个例子
写一个正则可以获取到第一个大括号和最后一个大括号中间的所有内容。这个的需求其实是,一段日志中除了 JSON 之外,会在 JSON 前后都会有一些内容,产品经理希望可以只获取到 JSON 的部分,而剔除掉前后的内容。举个例子:
<158>Dec 03 11:20:02 skyeye SyslogClient[1]: 2021-12-03 11:20:02|Unable to render embedded object: File (k) not found.{"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": ""} <tail>
首先想到的是匹配从开头到 { 的部分,这个的正则是 /^.*{/。但是这里有个坑,需要匹配的是第一个 {。
然后是从最后一个 } 到结尾的部分,这个的正则是 /}.*$/。这里也有个坑,就是需要匹配的是最后一个 }。
然后把他们两个组合起来,用一个 |连接,然后再加上全局修饰符 g,得到 /^.*{|}.*$/g,用 replace 的方式去掉前后部分,str.replace(/^.*{|}.*$/g),但是这里会把前后括号也去掉。
我们尝试一下,str.replace(re, '') 可以得到:
"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": ""
结果和我们想的一样,拿到的是括号内部的内容。
那如何不去掉 { 和 } 呢?大名鼎鼎的 assertion 来了,在 mdn 中可以看到专门的说明,这里粘贴一个链接,细节大家自己可以去察看,不做过多说明。
在我们的情况中,需要用到的是 Lookahead assertion 和 Lookbehind assertion。
Lookahead assertion 的定义是:Matches "x" only if "x" is followed by "y",表达式是 x(?=y)。
Lookbehind assertion 的定义是:Matches "x" only if "x" is preceded by "y",表达式是 (?<=y)x。
通过这种语义化的解释就能很清楚的知道 lookahead assertion 和 lookbehide assertion 很符合现在的情况。
我们不想匹配 { 和 },所以把 | 左右两边的部分都用 assertion 改造一下,就是 /^.*(?={)|(?<=}).*$/g。
我们再用 str.replace(re) 尝试一下,可以拿到:
{"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": ""}
果然,这结果如我所愿。但其实这里有个问题,就是如果是嵌套的 JSON 对象,可能就会出问题。我们把 str 改一下,改成嵌套的:
<158>Dec 03 11:20:02 skyeye SyslogClient[1]: 2021-12-03 11:20:02|Unable to render embedded object: File (k) not found.{"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": "", "nest_prop": {"a": 1}} <tail>
可以看到在最后加了一个 nest_prop 的属性,我们再尝试用上面的 replace 方法获取一下结果,可以得到:
{"a": 1}
这个时候拿到的结果就只是嵌套的部分了,显然这个不是我们想要的结果。因为正则的默认匹配是贪婪的,就是说 .* 可以表示任何内容,包括 { 和 },所以一直匹配到了最内层的 { 和 },就拿到了上面的结果。
这时候我们再来考虑如何匹配到第一个 { 和 最后一个 } 的问题。第一个就意味着不能是 {,也就是说除了 { 之外的所有内容。最后一个 } 意味着不能是 },也就是除了 } 之外的所有内容。所以我们把前后部分的 .* 用排除字符组的方式再来改造一下([^x]表示排除字符组,也就是除了 x 之外的所有字符)。最终得到的结果就是 /^[^{]*(?={)|(?<=})[^}]*$/g,然后再用这个正则来做一次尝试,还是用 replace 方法,最终拿到如下结果:
{"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": "", "nest_prop": {"a": 1}}
撒花!这就是最终我们想要的结果!
不过我依然不满足于此!我们既然知道正则是贪婪匹配的,那么其实也有惰性匹配的写法,所以尽管上面对于 .* 的改造是没有问题的,但是我们能否告诉正则表达式我们不要贪婪匹配,而是惰性匹配呢?显然是可以的!
我们通过在量词后面加个问号就可以实现惰性匹配,因此我们换一种形式来改造 .*,把正则改造为 /^.*?(?={)|(?<=}).*?$/g,然后再用 replace 方式尝试一下:
{"access_time": 1632709097000, "file_name": "xxx.htm", "md5": "xxxxxx", "file_type": "gz", "proto": "HTTP", "status": 0, "group_id": 1, "host_state": 0, "repeat_count": 1, "dimension": "", "nest_prop": {"a": 1}
Hooray!果然拿到了和上面一样的结果!但是后面的正则表达式更短一些,显然从代码的角度而言,我是更愿意选择后一种的。
对于这个结果我们满意吗?显然我们依然不能满足于此,两种表达式都可以的情况下,我就要比较性能了。接下来,我们尝试匹配 1000 次,然后看下哪个更快。编写函数
function test(str, re, count) {
var start = performance.now();
for(let i = 0; i < count; i++) {
str.replace(re, '')
};
console.log(performance.now() - start);
}
test(str, re, 1000);
通过在浏览器端的试验,你会发现惰性匹配的方式性能更差。第一种正则的方式大概是 13 秒,而用惰性匹配的正则表达式大概是 39 秒不等。虽然这个范围幅度很大,但是能明显看出来惰性匹配的性能是更差的,所以我们要选哪个表达式就显而易见了。
这就是一次正则表达式的写和优化的过程,希望能对大家有帮助!
万万没想到
上面的正则表达式在 safari 会有兼容性问题报错,具体看这个 stack overflow 的问答works-in-chrome-but-breaks-in-safari-invalid-regular-expression-invalid-group,按说这个属于 safari 的 bug,所以我还是把上面的正则表达式改了一下。这个思路就是说捕获第一个大括号和最后一个大括号中间的内容,然后获取捕获之后的内容,再给捕获的内容添加上前面和后面的大括号。
const re = /^[^{]*{(.*)}[^}]*$/;
str.replace(re, '{$1}');
这种方式也可以,并且解决了 safari 兼容性的问题。性能测试了一下,基本也都是 1 秒左右,性能还可以。
不匹配某个模式之外的任意内容
平时写正则的机会其实挺少的,时间一长,很多内容就容易忘掉。最近想写一个不匹配某个模式的所有其他内容就一下子想不起来了。这里记录一下。
字符串内容是 xxx->xxx->xxx,其中 xxx 表示任意内容,简单来说就是由 -> 连接的多个内容。现在想要实现的效果是,将这种类型的字符串中最后一个 -> 及其后面的所有内容都去掉。举个例子,字符串 abc->def->hig 通过 replace 的方式获取 abc->def。如果不是最后一个 -> 的话就简单了,直接 /->.+$/ 就可以,但是这个正则表示的是第一个 -> 及其后面的所有内容都被去掉。因为 . 包含了 -> 这种可能性。然后受制于上面所说的 safari 兼容性问题,我一直不敢考虑用括号的方式。然后查了下资料,发现 safari 只是不兼容 lookbehide 和 negative lookbehide ,也就是 (?<=y)x 和 (?<!y)x 这种形式,对于 look ahead 和 negative lookahead 还是可以的,也就是 x(?=y) 和 x(?!y) 这种形式。所以这里可以写成的正则是 /->(.(?!->))+$/,用可视化的方式展示一下就是
也就是表示任意从 -> 到行尾,但是中间不能有 -> 的内容,换言之就是最后一个 -> 到结尾的任意内容。
可以再抽象一下:我们定义一个 pattern,假设一个字符串中有很多个这样的 pattern,如果我们想要实现替换最后一个 pattern 到行尾的所有内容,正则就是 pattern(.(?!pattern))+$ 这样的形式。