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
原理与性能
待续...