RegExp 代码中的代码

正则一直是一块硬骨头,有很多写了几年代码的人依然搞不定这玩意, 今天我来换个思路学正则。 先给出“正则能做什么”,再传授“解析语法与技巧”, 然后给出我收集整理的一些常用正则, 最后从“原理与性能”出发对各种正则方案优中选优。

正则能做什么

我们先抛开语法与技巧,看看此兽究竟有什么神通。 (此节中只做展示,技术细节会放在下一节分析)

检测格式

这是最常用的一个功能,比如下面判断某字符串是否全部由数字组成

/^\d+$/.test('0123');   // true
/^\d+$/.test('123.');   // false

内容替换

这个也比较常用,比如下面将字符串中的数字替换成#号

'1a2b3c'.replace(/\d/g, '#');   // "#a#b#c"

还有一些更灵活的字符串替换用法

'a11,a12'.replace(/[a-zA-Z]+(\d+)/g, function (matchStr, $1) {
    return $1;
});  // 运行结果:11,12

提取内容

字符串的 match 方法可以将匹配到的字符串片段复制到数据中并返回

'<div><span>aa</span><span>bb</span></div>'.match(/<span>.*?<\/span>/g);
// ["<span>aa</span>", "<span>bb</span>"]

还有正则的 exec 方法在下面介绍。

确定位置

用下面两种方法可以获得被匹配字符串片段的起始和终止位置。 当然存在一个正则可以匹配到多个字符串片段这种情况,但是只用原有字符串不能提取到每个字符串片段的起始位置。

var regexp = /<span>.*?<\/span>/g;
regexp.exec('<div><span>aa</span><span>bb</span></div>');
// 运行结果是 ["<span>aa</span>"] ,regexp.lastIndex的值是 20(从1数起,第一个span结尾>的位置)
'<div><span>aa</span><span>bb</span></div>'.search(regexp);
// 5 (从0数起,第一个span的<的位置)

分割字符串

一般的字符串分割都是基于固定的分隔符,但是在某些场景下需要用正则做更复杂的处理, 比如用空格来分割字符串,如果有两个连续的空格出来的结果可能不是我们期待的, 这时就要用正则来处理,如下例:

'a  b c'.split(' ');    // ["a", "", "b", "c"]
'a  b c'.split(/ +/);   // ["a", "b", "c"]

解析语法与技巧

上面的5项内容其实可以分为两类, “检测格式” 和 “确定位置”都是在确定有还是没有,如果有的话位置在什么地方, 其他的三项功能是和匹配内容密切相关的。 下面我们将正则的各种语法与技巧溶入实例中来逐项的说明:

新建正则

两种方式,没什么好讲的,直接上代码:

new RegExp('\\d', 'g')
/\d/g

字符类

我们以“检测合格”为例来探究匹配的技巧。

\d :匹配数字,相当于 [0-9]

\w :匹配数字和字母,相当于 [0-9a-zA-Z]

. :除换行外的其他字符

[...] :方括号内的任意字符

[^...] :不在方括号内的任意字符

^ :写在正则开头时只匹配字符串的起始位置

$ :写在正则结尾时只匹配字符串的结尾位置

当不使用 ^$约束时,正则匹配的是局部,否则匹配全部。 看下面两个示例:

// 示例一:判断字符串中是否有数字
/\d/.test('a');    // false
/\d/.test('a1a');  // true

// 示例二:判断字符串是否由数字组成
/^\d+$/.test('a1a');    // false
/^\d+$/.test('123');    // true

细心的你可能注意到了示例二中的正则多了一个加号,对于这个加号在下一节中有详解。

重复与修饰符

{n, m} :匹配前一项至少n次,但不能超过m次

{n,} :匹配前一项n次或更多次

{n} :匹配前一项n次

? :等价于{0, 1}

+ :等价于{1, }

* :等价于{0, }

再来一个:“判断字符串中是否有3个连续数字”。

/\d{3}/.test('-123a-');    // true
/\d{3}/.test('-12a3-');    // false
/\d{3}/.test('-1234-');    // true

虽然上面都满足了需求,但是最后一个好像有点问题 (按照逻辑是讲得通的,4个数字连续当然3个肯定连续), 那有没有办法把4个及4个以上连续这种情况排除在外呢?请接着往后看。

再来个难点的需求:“判断某字符串是否全部由数字组成”(其实就是最开始的那个)。

这个需求我们需要换种说法才能动手写正则:“以数字开头以数字结尾且中间没有其他字符”。

要完成上面的正则还需补充如下两条语法:

^ :匹配字符串的开头;在多行检索中匹配一行的开头

$ :匹配字符串的结尾;在多行检索中匹配一行的结尾

/^\d+$/.test('0123');   // true
/^\d+$/.test('123.');   // false

上面提到多行检索,下面介绍一下修饰符:

i :执行不区分大小写的匹配

g :执行全局匹配,即找到所有的匹配,而不是找到第一个就停止

m :多行匹配模式,对有换行符的字符串进行多行匹配

多行匹配模式不太好理解,我们紧接着上面的例子给出解释:

/^\d+$/m.test('123.\n123');     // true
// \n是换行符,上面的正则在第一行(\n前面的部分)没有找到匹配的字符串就接着在第二行(\n后面的部分)找
// 并且找到了可以匹配的字符串,所以返回true

贪婪与断言

先上用到的语法

. :除换行外的其他字符

StringObject.match 字符串的 match 方法:可以将匹配到的字符串片段复制到数据中并返回

'<div><span>aa</span><span>bb</span></div>'.match(/<span>.*?<\/span>/g);
// ["<span>aa</span>", "<span>bb</span>"]

如果正则有修饰符 g(全局匹配),exec 方法是每次提取一部分,然后改变 lastIndex 的值, 下次继续从 lastIndex 的地方开始,没有匹配到或者匹配结束返回null,并将 lastIndex 的值置为0。 如果正则无修饰符 g(全局匹配),exec 方法只提取匹配到的第一段并返回(没有匹配到返回 null), 此时特别注意: lastIndex 的值恒定为0。

var regexp = /<span>.*?<\/span>/g; // 此时 regexp.lastIndex; 的值为0
regexp.exec('<div><span>aa</span><span>bb</span></div>');
// ["<span>aa</span>"]
// 此时 regexp.lastIndex 的值是 20

regexp.exec('<div><span>aa</span><span>bb</span></div>');
// ["<span>bb</span>"]
// 此时 regexp.lastIndex 的值是 35

regexp.exec('<div><span>aa</span><span>bb</span></div>');
// null
// 此时 regexp.lastIndex 的值是 0

贪婪匹配是尽可能多的匹配(不加问号),非贪婪匹配是尽可能少的匹配(加问号), 看下面的例子

// 贪婪匹配
'<div><span>aa</span><span>bb</span></div>'.match(/<span>.*<\/span>/g);
// ["<span>aa</span><span>bb</span>"]
// 非贪婪匹配
'<div><span>aa</span><span>bb</span></div>'.match(/<span>.*?<\/span>/g);
// ["<span>aa</span>", "<span>bb</span>"]

非贪婪匹配有一个容易让人误解的地方,就是“尽可能少的匹配”是基于正则原理的“尽可能少”。 先看下面例子:

'aaab'.match(/a+?b/);   // ["aaab"]

我们解释一下这种现象:“尽可能少的匹配”可以被理解成只匹配 "ab",但是正则是从左到右的匹配, “a+”匹配1到多个“a”再组合一个“b”,于是就得到了上面结果。 在这种“非贪婪 + 固定匹配”模式的匹配中非贪婪部分会呈现出贪婪性,所以上面的正则与下面的正则结果相同。

'aaab'.match(/a+b/);    // ["aaab"]

再看断言的语法:

(?=p) :零宽正向先行断言,要求接下来的字符串都与 p 匹配,但不能包含匹配 p 的那些字符

(?!p) :零宽负向先行断言,要求接下来的字符串不与 p 匹配

多数的正则书籍或文档都会给出如上的语法解释,尤其是“零宽负向先行断言”这句的翻译就太难理解了, 先看下面的英文原版描述(上下两段内容均出自《JavaScript权威指南》):

(?=p) :A positive lookahead assertion. Require that the following characters match the pattern p, but do not includethose characters in the match.

(?!p) :negative lookahead assertion. Require that the following characters do not match the pattern p.

我解释给大家听:

上面的断言是对写在其之前的正则的约束,即向后约束,也就是要求后面必须有什么(或没什么), 如下面正则中括号内的部分是对 bed 做约束, 即 bad 之后必须是 room 才能形成匹配。

'bedding'.match(/bed(?=room)/g);    // null
'bedroom'.match(/bed(?=room)/g);    // ["bed"]

下面是只有 bad 之后不是 room 才能形成匹配

'bedding'.match(/bed(?!room)/g);    // ["bed"]
'bedroom'.match(/bed(?!room)/g);    // null

再补充一点,js不支持向前约束的语法 ?<=?<! 。 说一个只适合部分情况的解决方案吧:

这种方案只能提取一个匹配,并且不能加修饰符 g,如下面的例子是实现“提取提取字母后的数字”。 如果提取到结果数组的 length 是2,取最后一个就是要提取的数字了。

'a11,a12'.match(/[a-zA-Z]+(\d+)/);  //["a11", "11"]
'11,12'.match(/[a-zA-Z]+(\d+)/);    // null
'a11,a12'.match(/[a-zA-Z]+(\d+)/g); // ["a11", "a12"]

提取示例:

var result = 'a11,a12'.match(/[a-zA-Z]+(\d+)/);    // 如没找到,赋值 null
if (result && result.length === 2) {
    result = result[1];                            // 如找到,赋值字符串
}

选择、分组和引用

选择最容易理解,就是条件“或”的意思,语法上采用 | (竖线),如下示例,正则匹配“字母或数字”

'a1b2c345#-!d67'.match(/\d+|[a-zA-Z]+/g);   // ["a", "1", "b", "2", "c", "345", "d", "67"]
// 语法解释:匹配多个数字 或 多个字母
// 在从左向右的匹配中“数字”和“字母”都是贪婪匹配

分组使正则更为强大,可以处理复杂的“条件”和“重复”等逻辑,

'a1b2c345#-!d67'.match(/(\d+|[a-zA-Z]+)+/g);    // ["a1b2c345", "d67"]
// 语法解释:匹配由数字 和 字母组成的字符串片段

引用是一个妙用无穷的语法,由括号的使用引起,在正则中使用的格式是 \数字, 在字符串的 replace 方法中以参数的形式出现,引用从1算起,以左括号作为计数参照; 引用的是前面模式匹配到的文本的引用; 另外用 (?: 代替左括号时次对括号不计入引用序列。 上面这段话不好理解,下面用示例来解释:

先看一个“匹配最内层引号(单引号和双引号)及其中内容”的正则:

'"a\'b\'123"'.match(/(['"])[^'"]*\1/g);    //["'b'"]

首先说明一下字符串中的 \' 是应为字符串是以单引号创建的, 直接使用单引号会引起语法错误,所以需要用转译的形式引入,并且 \' 是一个字符。

然后是过程:

第一步从第一个双引号开始匹配,与第一个中括号匹配,继续向前;

第二步到 a 符合正则中第二个中括号(匹配非引号)与其重复限制符 *的匹配逻辑;

第三步第二个中括号依然在起作用,这时单引号不符合中括号的模式要求,所以中括号的匹配范围结束, \1的匹配开始(明星登场了),将上面那句话放进来“引用的是前面模式匹配到的文本的引用”, 也就是第一步匹配到的是双引号,所以此时是用单引号匹配双引号,匹配失败,跳出此次匹配; (*是匹配0到多个,所以第二个中括号匹配完 a 继续尝试匹配单引号)

第四步从 a 开始,遇到第一个中括号就匹配失败,跳出再继续;

第五步第一个单引号与第一个中括号匹配成功,继续向前, b 与第二个中括号匹配,继续向前, 第二个单引号与中括号匹配失败,中括号范围结束,第二个单引号继续向前匹配, 此时的 \1 是前面匹配到的单引号,所以单引号匹配单引号,成功,返回字符串 'b'

第六步从 b开始匹配,之后的都会失败,不再叙述。

再看一个引用在字符串替换中的例子:

'a11,a12'.replace(/([a-zA-Z]+)(\d+)/g, function (matchStr, $1, $2) {
    return $2 + $1;
});// "11a,12a"

上面是匹配字母和数字的有序单一组合 (像前面一段是字母后面一段是数字并且没有其他类型字符参与的 这种情况), 我们利用回调函数来交换字母段与数字段的顺序,还有一种更简单的写法:

'a11,a12'.replace(/([a-zA-Z]+)(\d+)/g, '$2$1');
// "11a,12a"
'a11,a12'.replace(/(?:[a-zA-Z]+)(\d+)/g, '$2$1');
// "$211,$212"可以看到排除引用语法(?:的作用,如果没有第二个引用$2被当做普通的字符串处理

实践

因为理论讲多了看完也忘的差不多了,所以我们直接进入实践,这里的例子都是我工作中收集的,也还将继续收集下去,当然欢迎补充和指正。 有些例子会有方案的进化,这就涉及到原理和优化技巧了,如果对其中提到的东西有疑问可以去下一节看看。

正则的语法与技巧就如上面所言了,编写正则还有很重要的一环就是翻译, 比如有个需求是“匹配电话号码”,那么就要先细化什么样的字符串才能算是电话号码, 11位数字?那以0或9开头可以吗?10位的400电话算吗?极端一点110是电话号码吗? 我在这里给不出标准答案,只有结合业务场景才能做出合理的取舍。

其实翻译分为两个阶段,第一个阶段是范围的确定,上面一段说的就是此段, 第二个阶段是语法的翻译阶段,这一阶段考验对语法的熟练程度和使用技巧, 如上节提到的“字符串是否全部由数字组成”,翻译成“以数字开始,以数字结尾,且中间无其他字符”。 这一节会给出很多日常开发中能用到实例,先看第一个:

匹配浮点数

先稍微简单一点:“匹配正浮点数”

我们先进行第一阶段的翻译,即确定范围: 十进制的浮点数,1,0.1是合法的, 另外确定001也是合法的,因为001参与运算时和1相同且这种用户输入形式比较边缘, 1.和.1是非法的

再进行第二阶段翻译,语法翻译: 以数字开头,并且数字不少于一个,后面一个点+多数字的组合形式 以零到多个结尾

/^\d+(\.\d+)?$/.test('234.3');       // true
/^\d+(\.\d+)?$/.test('234.3.2');     // false

再加上正负号的匹配规则就是“匹配浮点数”了

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

匹配静态资源

范围:

语法:

/\.+[html|css|js|png|ico]+(\?.*)?$/ig.test('dfsdf.html?e');    // true
/\.+[html|css|js|png|ico]+(\?.*)?$/ig.test('dfsdf.html.xml?e');// false

原理与性能

待续...

参考

w3school的regexp部分

ECMA-262号官方文档