听风是风

学或不学,知识都在那里,只增不减。

导航

从零开始学正则(六),如何提升正则准确性与效率

 壹 ❀ 引

我在 从零开始学正则(五)这篇文章中介绍了正则常见结构与操作符,在了解操作符的优先级后,知晓了如何去拆分一个看似复杂的正则表达式。正则除了会看会读,会写一个正则往往更重要。那么要去写一个正则就面临了诸多问题,什么时候该用正则?怎么保证正则的准确性?正则如何提升性能?那么本篇文章将从这三个点出发,让我们在会写正则的前提下写的更好。

说在前面,正则学习系列文章均为我阅读 老姚《JavaScript正则迷你书》的读书笔记,文中所有正则图解均使用regulex制作。那么本文开始!

 贰 ❀ 该不该使用正则?

看到这个标题你肯定纳闷,学的就是正则,怎么还该不该用正则?但在实际开发中,一个问题可以用正则解决,其实也可以使用其它方法解决。我们学正则不一定要死板的想要用正则解决所有问题,或许使用其它做法更棒呢?

比如我们现在有字段 2019-12-24 ,我想分别取出年月日,使用正则可以使用match方法配合分组获取实现:

var result = '2019-12-24'.match(/^(\d{4})-(\d{2})-(\d{2})$/);
console.log(RegExp.$1, RegExp.$2, RegExp.$3); //2019 12 24

有没有其它做法呢?别忘了字符串的 split 切割方法,比如:

var arr = '2019-12-24'.split('-');
console.log(arr[0], arr[1], arr[2]);//2019 12 24

相比之下你觉得哪种更简单呢?

再如我们想验证字符串中是否包含“:”,我们可以使用正则实现:

var result = /\:/.exec('12:34');
console.log(result); //[":", index: 2, input: "12:34", groups: undefined]

更简单的做法,我们可以直接使用indexOf检查索引,如果没有返回-1,如果有返回第一个匹配的字符下标。

var result = '12:34'.indexOf(":");
console.log(result); //2

最后看个截止字段的例子,相比使用正则,使用字符串方法substrsubstring都会简单很多。当然若你对这两个方法有疑惑,可以读读博主这篇文章  substring和substr以及slice和splice的用法和区别。

var string = "hello,听风是风";
var result = /.{6}(.+)/.exec(string)[1];
console.log(result); //听风是风
var result = string.substr(6);
console.log(result); //听风是风
var result = string.substring(6);
console.log(result); //听风是风

通过以上三个例子可以看出,在一些更偏于字符操作的情况下,该使用字符串方法就得用,学会灵活变通。

 叁 ❀ 正则的准确性

何为准确性,一段正则除了能匹配我们所需要的,还得保证不会匹配那些我们不需要的,假设我们现在要匹配如下三种座机(固定电话)号码,该如何写这个正则呢:

var num1 = '055188888888';
var num2 = '0551-88888888';
var num3 = '(0551)88888888';

科普一下,座机号码由 区号+座机号 组成,且区号长度为3-4位数字且首位数字必须为0,而座机号由7-8位数字组成,且首数字不能为0。

尝试分析上面三种座机号码格式,第一种为区号直接拼号码,第二种使用了拼接符 - ,第三种使用了圆括号包裹区号,很明显这是三种分支情况,所以我们可以先写匹配数字的正则,再加分支条件。

只是匹配数字这也太简单了,不假思索的写出  /^\d{3,4}\d{7,8}$/ ,那么这段正则就是不具备准确性的正则,别忘了我们在前面有提到区号与号码首数字的问题,所以改改应该是这样:

var regexp = /^0\d{2,3}[1-9]\d{6,7}$/;

当然这个正则只能匹配区号直接紧接号码的情况,有拼接符的情况就是这样:

var regexp = /^0\d{2,3}-[1-9]\d{6,7}$/;

带圆括号的格式就是这样:

var regexp = /^\(0\d{2,3}\)[1-9]\d{6,7}$/;

我们仔细对比这三段正则,可以发现正则后半段是完全相同的,区别也只是在前半段,所以将前部分以分支表示,改写正则后应该是这样:

var regexp = /^(?:0\d{2,3}|0\d{2,3}-|\(0\d{2,3}\))[1-9]\d{6,7}$/;

还能不能简写?仔细观察前两种分支情况,一个是无拼接符一个是有拼接符,除此之外其它部分都一样,这不又可以组合成拼接符可有可无的情况了,所以我们再次简化:

var regexp = /^(?:0\d{2,3}-?|\(0\d{2,3}\))[1-9]\d{6,7}$/;

我们简单测试下,发现完全没问题

console.log(regexp.test(num1)); //true
console.log(regexp.test(num2)); //true
console.log(regexp.test(num3)); //true

说到拼接符可有可无,可能有的同学就想到了,我圆括号也可以写成可有可无,这样正则不是看着更精简了,像这样:

var regexp = /^\(?0\d{2,3}\)?-?[1-9]\d{6,7}$/;

但这样就造成了一个问题,你会发现同时有括号和拼接符,或者说有一半括号的格式都能匹配:

console.log(regexp.test('(0551-88888888')); //true
console.log(regexp.test('(0551)-88888888')); //true
console.log(regexp.test('0551)88888888')); //true

很明显这不是我们想要的情况,这段正则就缺失了很重要的精准性。

我们来看第二个例子,写一个匹配浮点数的正则,要求能匹配如下几种数据类型:

1.23、+1.23、-1.23
10、+10、-10
.2、+.2、-.2

我们结合这三种数据来做个分析,首先关于正负符号很明显是可有可无,毋庸置疑可以写成 [+-]?;然后是整数部分,可能是多位整数也可能没有,所以是 (\d+)?;最后是小数点部分,因为可能不存在小数点,所以可以写成 (\.\d+)?,所以结合起来就是:

var regexp = /^[+-]?(\d+)?(\.\d+)?$/;

这个正则有个最大的弊端,因为三个条件后面都有?表示可有可无,极端一点,三个都为无,所以这个正则可以匹配空白:

/^[+-]?(\d+)?(\.\d+)?$/.test("");//true

可能有同学敏锐的发现了,.2,+.2这种情况都是整数部分为0的情况,那能不能为写成这样 /^[+-]?(0?|[1-9]+)(\.\d+)?$/ ,很明显也不行,比如10,+10这种整数用到了0,所以无法通过分支来控制0的显示隐藏。

那怎么做呢?还是与匹配座机号码一样,我们针对三种情况分开写正则,比如匹配 "1.23"、"+1.23"、"-1.23",正则可以这样写:

var regexp = /^[+-]?\d+\.\d+$/;

匹配 "10"、"+10"、"-10" 的正则可以写成:

var regexp = /^[+-]?\d+$/;

匹配 ".2"、"+.2"、"-.2" 正则可以写成:

var regexp = /^[+-]?\.\d+$/;

我们提取三个正则的共用部分,很明显就是 [+-]? 这一部分,其它部分采用分支表示,综合起来就是这样:

var regexp = /^[+-]?(\d+\.\d+|\d+|\.\d+)$/;

简单测试,完全没问题:

regexp.test("+.2"); //true
regexp.test("-.2"); //true
regexp.test("10.2"); //true
regexp.test("+10.2"); //true

虽然这种分情况写,再抽出共用部分,将非共用分支表示的做法有点繁琐,但对于正则新手来说确实是最为稳妥保证精准性的做法。

 肆 ❀ 正则的效率

在确保正则的精准性之后,剩下的就是如何提升正则的效率性能了(当然对于我这样的新手,能写出来就不错了...)。

如何提升正则性能,我们一般从正则的运行阶段下手,正则完整的运行分为如下几个阶段:编译 --- 设定起始位置 --- 尝试匹配 --- 匹配失败的话,从下一位开始继续第 3 步 --- 最终结果:匹配成功或失败。

我们可以通过下面这个例子模拟这个过程:

var regex = /\d+/g;
console.log(regex.lastIndex, regex.exec("123abc34def")); //0 ["123", index: 0, input: "123abc34def", groups: undefined]
console.log(regex.lastIndex, regex.exec("123abc34def")); //3 ["34", index: 6, input: "123abc34def", groups: undefined]
console.log(regex.lastIndex, regex.exec("123abc34def")); //8 null
console.log(regex.lastIndex, regex.exec("123abc34def")); //0 ["123", index: 0, input: "123abc34def", groups: undefined]

是的你没看过,明明都是输出相同的东西,每次输出的内容居然还不一样。这是因为当使用 test 或者 exec 方法且正则尾部有 g 时,比如像上面执行多次,下次执行时匹配的起始位置是从上次失败的位置。说直白点,使用这两个方法就像有记忆功能一样,每次执行都是从上次结束的位置开始,比如我们用match方法就不会有这个问题:

var regex = /\d+/g;
console.log(regex.lastIndex, "123abc34def".match(regex));//0 ["123", "34"]
console.log(regex.lastIndex, "123abc34def".match(regex));//0 ["123", "34"]
console.log(regex.lastIndex, "123abc34def".match(regex));//0 ["123", "34"]
console.log(regex.lastIndex, "123abc34def".match(regex));//0 ["123", "34"]

我们就通过上面exec来分析正则执行阶段。第一次执行匹配从字符串索引0开始,因为是全局匹配,所以一直匹配到了3,所以匹配结果为123,匹配到a时因为不满足,所以失败了。

第二次开始就是从上次失败的地方开始,所以是从索引3开始,在经历了abc三次失败后,终于遇到了数字34,匹配成功,再往下走时是d,所以又失败了。

第三次匹配开始的起点就是索引8,但因为def都是字母,全部不符合,匹配结果,最后返回了一个null,此时索引被重置为0。

因为起始位置被重置,所以第四次匹配重复了第一次匹配的操作,又是一轮新的开始。

其实看上面exec的例子就反应出了一个问题,每次执行正则都有记录最后匹配失败的位置供下次匹配使用,回溯也是如此,正则会记录多种可能中未尝试过的状态以便回溯使用,这是非常消耗内存的。我们来综合给出几点优化建议:

1.尽量使用具体的字符来替代通配符,减少回溯

比如我们想匹配 123"abc"456 中的 "abc",使用正则 /"[^"]*"/ 的性能要远高于 /".*"/,使用/"\w{3}"/当然更好。

2.使用非捕获型分组

在介绍分组时我们已经说过,正则会记录每个分组的匹配结果。如果我们的分组只是为了单纯起到匹配的作用,而不喜欢正则默认去帮我们记录分组的匹配结果,可以使用非捕获型分组。

'123abc456'.match(/(\w{3})/);
console.log(RegExp.$1);//134

//使用非捕获型分组
'123abc456'.match(/(?:\w{3})/);
console.log(RegExp.$1);//为空,未记录

3.独立出确定字符

比如我们有正则 /a+/ 可以修改为 /aa*/,因为后者在匹配时能比前者多确定一个字符,不管是失败还是成功,都能更快一部=步确认。

4.提取分支

我们在介绍匹配座机号码与浮点数已经有阐述这一点,将正则共用部分抽离出来,不同部分作为分支,比如将 /this|that/ 修改为 /th(?:is|at)/,这样能减少重复匹配。

5.减少分支数量,缩小匹配范围

虽然推荐抽出共用后使用分支,但有些特殊分支情况能简写复用的还是推荐简写,比如 /red|read/ 可以修改成 /rea?d/。因为分支如果匹配失败,切换到另一条分支时也需要回溯。

 伍 ❀ 总

那么到这里,第六章节所有知识全部介绍完毕了。这一章节主要是站在能写正则的基础上,进一步优化正则写法,提升正则匹配的精准性,以及正则运行的性能。共用部分正则,将不同进行分支算是我读下来最大感触的地方,对于优化而言还是需要一定的实战积累,不过先建立优化的观念也不是坏事。那么就说到这里了,今天圣诞节,本来想早点睡觉,结果又写到12点了....晚安,圣诞快乐,本文结束。

posted on 2019-12-25 23:56  听风是风  阅读(771)  评论(0编辑  收藏  举报