JavaScript即学即用教程[5]-正则表达式

在前端领域,大部分时间在跟字符串操作打交道,例如表单校验、字符串替换等操作、编辑器批量替换。而一旦遇到复杂的字符串匹配(即找出并返回)、查找(即找对其坐标)、判定(即判断是否包含),便需要进行编写大量的代码逻辑来实现。而通过正则表达式来进行字符串的匹配判断或子串位置查找则能大大简化字符串的相关操作。

正则元字符

除了直接用字面量(Literal)来匹配之外。还可以用一些元字符。元字符即一些特殊的字符,可以表达出特殊的含义。下面分别介绍:

  • 字符类的转义字符 如
1
2
3
4
5
6
\n // 换行
\r // 回车
\t // 水平制表符
\f // 换页, ASC码的12,表示换页符。十六进制字符表示法: \x0c
\v // 垂直制表符,ASC码的11,十六进制表示法 \x0b
\0 // ASC码的0,表示空字符

关于这些转义字符的对应 ASCII 码值,大家可以参考百科 里的相关图表。其实转义字符本质上就是在表达一个普通字符而已,只是我们无法直接敲出来。

另外要注意一点,上面的 \n 是换行,其 ASC码值是 10,一般还有个名字叫: LF (NL line feed, new line) 。而回车 \r 的 ASC 码值是 13,一般叫做 CR (carriage return)。大家使用 vscode 等编辑器的时候,可以看到可以切换文本的换行模式,如果用 LF 则表示你敲一下回车会输入换一个字符 \n;而如果你用 CRLF 模式,则表示 \r\n。一般 *unix 系统都采用 LF 模式。

  • 枚举字符或白名单排除枚举的字符。 用中括号可以实现枚举 如
1
/[gtr]/g // 表示匹配g或t或r这3个字符

枚举字符时也可以使用字符串的十六进制表示法来枚举某个字符, 如 [\x0B\x0C]

  • 排除枚举。在中括号里加^符号,可以排除枚举
1
/[^gtr]/g // 表示匹配排除掉 g或t或r 这3个字符的。比如a就能匹配
  • 范围。 如果枚举不适合,可以直接指定字母或数字区间。 如
1
2
3
[0-9] // 表示数字0到9之间的都可以。
[0-9a-zA-Z] // 表示所有的字母和数字, 这个就相当于 \w了
[A-z] // 是[a-zA-Z]的简写
  • 预定义的元字符。学习正则要记住这些
1
2
3
4
5
6
7
. // 表示除了 回车换行 之外的所有字符,可以枚举表示为 [^\n\r] 所以这个点表示的范围还是比较广的,跟其他几个相比,这个描述应该是最大的一个字符集合了。
\d // 表示数字,可以这样表示 [0-9]
\D // 大写的一般表示取反。这个相当于 [^0-9],那就是 字母、中英文、下划线在内的各种特殊符号、空白符(tab换行等等)
\s // 表示空白符。包含制表符\t、换行符\n、垂直制表符\v 即\x0b、换页符\f 即\x0c、回车符\r、 普通空格' ' 即 0x20、 表象空格 即 0xa0、以及unicode字符集中的全角空格 \u3000 . 因此可以枚举表示为 [\t\n\x0B\f\r\x0C\u3000 ]
\S // 非空白字符咯 [^\t\n\x0B\f\r\x0C\u3000 ]。那几乎涵盖了所有不空白的字符,
\w // 数字字母下划线 [0-9a-zA-Z_]。 比[a-Z]多几个数字。
\W // 取反咯。除了英文字母、数字以外的,应该包含了所有的空白、中文及非英文语言文字、各种特殊符号(如&%等)

这里可以思考一点,如果希望表示几乎所有字符,应该怎么表示呢?比如html标签内的所有内容(可以有中文、英文、回车换行、制表符,全角半角空格等)。
我觉得可以考虑使用 [\w\W],应该也可以使用 [\s\S]

  • 边界控制

所谓边界控制就是他用来描述要匹配内容的边界,但实际待匹配的字符串中,是不存在这样一个真实的字符的。匹配结果也不会返回带有边界字符的结果。
所以边界字符仅仅是用来描述要匹配内容的边界的一种规则描述符,不像前面的 \d 就代表了一个匹配成功后要返回的字符内容。

1
2
3
4
\b // 单词的边界。如果非要明确定义,则可以说是: 匹配一个字符,他的前一个字符或后一个字符不全是\w。如 'a bc'或 'i come, then go',其中的bc和come都是单词,差不多意思就是前后不全是字母。
\B // 不是单词的边界. 如果要定义,则是: 这个位置的前和后一个字符全是\w,差不多就是: 前后必须全是字母。
^ // 表示字符串的开头
$ // 表示字符串的结尾

举个 \B 的例子吧: /\B[a-z]\B/.exec('a b ecd') ,这段正则,会返回 [ 'c', index: 5, input: 'a b ecd', groups: undefined ]。 因为 c 的左右都是字母,而 a 的左边是个 ^,b的两边有空格,e的左边有空格,d的右边有 $

量词

1
2
3
4
5
6
? // 表示 0个或1个
+ // 表示1或1个以上
* // 表示0个或0个以上
{n} // 表示n个
{n,m} // 表示n到m个。数量区间表示法,千万不要在逗号后面加空格
{n,} // 表示n或n个以上

量词都是闭区间哦(即包含最开始和最末尾的字符)。
特别注意: 写正则用 {} 这种量词的时候,千万不要给花括号和量词之间加空格,比如 { n, m } 这样的话在JavaScript里会出问题。
比如: /a{1,2}/.exec('aa') 这个是能匹配到 aa 的,但 /a{ 1,2 }/.exec('aaa') 这么玩你就啥都匹配不到了。

另外要注意上面这个例子,a重复1次或2次,他返回的是 aa。为什么返回了 2 个a,而不返回 1 个 a 呢。原因在于正则默认是贪婪匹配,尽可能匹配到最远的那个能满足要求的字符作为结果。后面我们会讲如何改为懒惰匹配。

分组应用量词

如果你的量词不是希望应用在前面一个元字符身上,而是用在一个组上面的话。你可将你要应用量词的部分括起来.

1
/(abc)+/ 就表示的是abc要重复三次,而不是c要重复三次

正则相关的几个js函数

正则对象有2个函数:

  • regExp.exec 用于匹配
  • regExp.test 用于判定

String字符串有4个正则相关的方法:

  • str.match (相当于regExp.exec函数,用于匹配)
  • str.search (相当于str.indexOf,只是search可以支持正则;用于查找,也可以用找出坐标与否来进行判定)
  • str.replace (用于替换)
  • str.split (可以用正则表达式来定义分割符号)

其中regExp.exec跟str.match一样,所以只需讲解exec即可。执行exec后,该函数会返回一个数组,数组的长度length是正则能够匹配到的完整内容+所有捕获组的数量。 数组的元素分别是: 匹配到的完整内容+各个捕获组的匹配内容。举个栗子:

1
2
var p = /(.)a(t)/
p.exec('bat') // 会返回 ['bat', 'b', 'a', index: 0, input: 'bat', groups: undefined]

请注意返回结果中,其数组元素只有 'bat','b','a',其 index, input, groups 是数组对象的属性,并不是元素。
exec返回的数组,具有一个length属性,其值便是匹配到的结果以及捕获组的数量。通过判断 length是否大于0 可以判断是否有命中的结果。

可见exec返回的就是匹配结果,加各个捕获组。捕获组的概念就是正则中用括号括起来的部分,其括号出现的顺序跟返回数组中的顺序一一对应。
如果exec函数匹配不到,exec函数会返回null.

另外,exec返回的这个数组,还自带有2个属性,index和input。 index是当前查找到匹配时候,匹配到的子串在源串中的起始坐标位置;input表示源串. 因此可以发现利用结果的index属性,exec是可以起到查找子串位置的目的(这里用str.search可能更合适)。

另外 正则对象p 自身也有几个属性:

1
2
3
4
5
p.lastIndex // 从哪个坐标开始匹配的。 对于g标识后会变化
p.source // 正则表达式的源串
p.global // 全局g标识
p.ignoreCase // 区分大小写标识
p.multiline // 多行标识

正则表达式exec执行一次,只能匹配到字符串中的首个结果。如果匹配到后面的结果呢?只需:

  1. 正则中带上 g 全局标识
  2. 对同一个正则表达式进行多次执行;每次执行便会从上一次匹配结果的下一个字符开始匹配,直到匹配不到返回一次null,开始位置会重置为0.

例如:

1
2
3
4
5
6
const p = /(.)a(t)/g
// 执行前p.lastIndex是0
p.exec('bat bat') // 首次执行返回 [ 'bat', 'b', 't', index: 0, input: 'bat bat', groups: undefined ]。 此时 p.lastIndex是3
p.exec('bat bat') // 2 次执行返回 [ 'bat', 'b', 't', index: 4, input: 'bat bat', groups: undefined ],此时 p.lastIndex 是7
p.exec('bat bat') // 3次执行,是从7下标开始匹配,匹配不到,所以返回null. 此时 p.lastIndex变成了0
p.exec('bat bat') // 4 次执行,此时又是从0坐标匹配了,因此再次返回 [ 'bat', 'b', 't', index: 0, input: 'bat bat', groups: undefined ]

正则的 test 函数也跟exec类似,在 global 模式下,游标会往前走,直到 test 返回false,再归零。
这里要注意string字符串的match方法其实在global模式下,跟exec效果不太一样,match会返回所有匹配结果组成的数组(不包含捕获组)。而不是每次游标前进一次。

捕获组

假如你希望找到某个 源串里面 符合某个正则规则的 子串,但不想取得这个子串,而是想取得子串里面的某一部分。此时可以利用正则的捕获组。 例如: 找到符合 `区号+电话` 规则的内容,并取得其中的电话部分.

举个栗子:

1
var p = /((\(\d{3,4}\)|\d{3,4})(-|\s))?\d{7,8}/ // 这是中国固话正则。
这里 前面的 `\(\d{3,4}\)|\d{3,4}` 表示 3-4位数字的区号,可以是 `(0543)` 这种带括号的 或者 `0543` 这种不带括号的。 然后是 `-|\s` 表示区号和号码之间用空格或横线分割。 前面整个区号部分是可选的,所以后面用一个问号表示0次或1次。 `((\(\d{3,4}\)|\d{3,4})(-|\s))?` 最后就是7或8位的电话号码 `\d{7,8}` 这个正则,如果你执行 p.exec('0543-4613888') 会得到 `['0543-4613888', '0543', '-']`。 可以看到第一项是匹配结果,第二项是第一个捕获组(我们在里面为了让区号可选,所以用括号写了一个捕获组)。 但我们如果想拿到电话部分,就要再加一个捕获组: `((\(\d{3,4}\)|\d{3,4})(-|\s))?(\d{7,8})`。 这样就能捕获到电话部分了: `['0543-4613888', '0543-', '0543', '-', '4613888']` 这里有些捕获组是为了 `或` 运算而增加的,我们实际上结果中并不需要。后面会讲如何去掉。 关于捕获组还有个知识点,像这种 `/(\w%)+/.exec('a%b%c%')`,量词控制了该捕获组匹配多次。由于默认是贪婪的,所以js会返回`a%b%c%` 的匹配结果, 但最终返回的结果中,这个 `一号捕获组` 只有一个,我们看该捕获组的定义 `(\w%)`,其含义是指代一个字母加一个百分号。那么实际返回结果是 `c%`,可见它返回的是捕获过程中最后一次该捕获组所捕获到的结果值。

g全局标识

当正则使用了g全局标识,会导致exec函数执行的变化。 对于没有g的情况,函数每次执行只匹配一次且index不会发生偏移,每次执行exec都是一次完全一样的匹配。不会在第二次执行时返回null(相当于每次匹配判断都是从源串的0坐标开始)。

而当使用了g标识后,每次执行exec,则正则会在源串的上一次匹配的坐标之后进行匹配。这样的话,如果源串中有不止一个匹配的子串,带上g标识就可以找到后面的子串; 不过,当找到最后一个子串后,再执行exec会返回一次null。然后下一次执行exec才会再次从源串的开始进行匹配。举个例子:

1
2
3
4
5
var p = /(.)a(t)/g
p.exec('bat cat') // 会返回 ['bat', 'b', 't'] 此时该数组的index属性是0
p.exec('bat cat') // 返回 ['cat', 'c', 't'] 此时数组的index是4. p.lastIndex 是7.
p.exec('bat cat') // 返回null (因为匹配是从 p.lastIndex 即 7 的位置开始找的,所以找不到)
p.exec('bat cat') // 再次从头开始 会返回 ['bat', 'b', 't'] 此时该数组的index属性是0

几个高级但很有用的用法

  • 的用法

    有时候要写2种完全不同的表达式来匹配一个东西。此时需要用 .

    默认情况下,或 | 符号会应用在它左右两边的正则表达式(准确的说,应该是应用到 这个符号自身所在的捕获组的最边缘的位置)。
    例如: /a|(bd)ef/.exec('a') 可以匹配返回 a,这个 便是: 左边应用到a的最左侧,右边应用到f末尾。意寓: 一个a字符或者连续的bdef

    知道了原理,那么我们便知道: 如果你想控制或的范围,你需要把 作用的左右范围都用括号括起来。例如本文中讲到的电话区号有用到或,则需要把区号部分括起来:

    1
    (\(\d{3,4}\)|\d{3,4}) // 其目的是为了不要让或的作用范围,影响到后面的其他字符 `(-|\s)`,不然会变成: `带括号的区号 或 不带括号的区号加横线`. 我们实际的意思是:带括号的区号 或 不带括号的区号, 再加 横线。
  • 括号对量词的影响

    另外,括号除了能控制 的作用范围,还可以控制量词的作用范围。比如还是上面电话区号的例子

    1
    ((\(\d{3,4}\)|\d{3,4})(-|\s))? // 我们在前面一坨区号规则的末尾加了个 问号

    如果前面整块不加括号的话,最后的问号会作用在 (-|\s) 这里的字符上。而把前面整块加了括号的话,问号会作用在整个 区号 规则上。

  • 捕获组高级用法-排除捕获

有时你可能用到了捕获组(比如上面电话区号需要用到 或 运算),但你不希望这个捕获组被分配组号也不希望在结果中看到这个捕获组。你可以这样(?:exp)排除掉该捕获组:

1
2
var p = /(?:(\(\d{3,4}\)|\d{3,4})(?:-|\s))?(\d{7,8})/
p.exec('0543-4613340') // 返回 [ '0543', '0543-4613340']
  • 反向引用

如果正则中想重复利用前面写过的某个捕获组,可以用 \n 的方式引用它

1
2
// 表示一个单词后,跟着一些空白符,再跟上一个同样的单词
/\b(\w+)\b\s+\1/

特别注意: 这里的引用指的是在正则运行时引用前面的捕获结果,而不是引用的正则表达式里的元字符。 因此它适用于html标签匹配的时候(因为标签开头和结尾必然是同一个字符),但不适用于你的这种表达:

假如你希望匹配: 开头2个字母,末尾2个字母,中间是一堆数字。 你可以这样写 /\w{2}\d+\w{2}/, 但不能 /(\w{2})\d+\1/
这会导致正则运行时,只能匹配到 ‘ab123ab’, 而不能匹配 ‘ab123cd’

  • 零宽断言

我的理解就是: 前面或后面符合某个匹配,但结果中去掉(不要)这个匹配。 (?=exp)表示后面匹配 或 (?<=)表示前面匹配。

我们在思考时,可以用句首句尾的 ^ $ 这两个概念来辅助理解。即,所谓零宽就是指的字符本身没有宽度,即字符本身不是字符,只是个边界,哈哈。

假设一个需求是找出图片url里的图片文件名(不包括扩展名)。 我们有个办法就是给正则进行分组,然后在匹配结果中找到对应分组的内容。但是用零宽断言,我们可以直接抛弃掉零宽匹配到的部分。

1
2
3
// 需求: 找出图片url地址里的图片文件名
var p = /(?<=http:.+\/)\w+(?=\.(?:png|jpg|jpeg|gif))/g.exec('http://www.baidu.com/beautygirl.png')
// 返回结果为beautygirl

其实这个零宽断言就像是创建了一个自定义的类似 ^ 或 $ 这样的分隔符表示. 其中 (?<=http:.+\/) 类似于我们的 ^ 这样的标识,它表示匹配到类似 http://www.baidu.com/ 这样的作为我匹配的开头,像文章开头。
然后匹配到 (?=\.(?:png|jpg|jpeg|gif)) 这样的作为文章结尾。

接下来,我中间件写的 \w+ 正则,才是我希望返回的真正的字符内容。因此就返回了图片文件名啦。

?: 的区别是:?: 仅仅是把结果中的子捕获组排除,而零宽断言是直接把整体结果中排除掉。

  • 负向零宽断言

这个跟上面的零宽断言相反。指的是 “不应符合这样的开头”,“不应符合这样的结尾”

  • 中括号内枚举时问号、叹号、空格、引号可以不转义

因为中括号内只有逗号、中括号以及少量元字符会有歧义。而这些量词、引号之类的不会有歧义。例如 [.?!] 可以匹配问号叹号或点号。

但是 \w \d 等元字符在中括号内都是可以作为枚举用的,转义字符 \ 也是要用的,[ [ 这个符号也是要用的所以要转义。 而 $ ( ) * ? / ! . = : 这些是不需要转义的。

方括号又叫字符组,注意某些元字符在字符组外和字符组内的意义不同。例如:^在字符组外匹配行的开头,在字符组内表示排除型字符;-在字符组外匹配普通连字符号,在字符组内(不在开头)表示一个范围;问号和点号在字符组外通常是元字符,但在字符组内只是匹配普通字符而已。

像中划线这种,在 [a-z] 这样使用时有特殊含义,但 [-az] 这样使用时就表示普通一个中划线哈哈。

  • 贪婪和懒惰模式

如果用 a.*b 来匹配 ‘aabab’ 那么,默认是贪婪模式会匹配到 ‘aabab’. 如果希望使用懒惰模式,即匹配到一个短的符合要求的就结束,则可以使用 a.*?b

贪婪和懒惰主要是应用于 量词 上面。 所以有如下的设置方式:

1
2
3
4
5
*?  重复任意次,但尽可能少重复
+? 重复1次或更多次,但尽可能少重复
?? 重复0次或1次,但尽可能少重复
{n,m}? 重复n到m次,但尽可能少重复
{n,}? 重复n次以上,但尽可能少重复

g模式正则的lastIndex会在每次执行exec时前移;但一定要注意前提是使用同一个正则实例。下面这种写法会导致你每次都使用了一个新的正则实例:

1
/.at/g.exec('bat cat')

正确的做法应该把正则提到前面赋值给一个变量再使用。

1
2
const p = /.at/g
p.exec('bat cat')

应用

以字母a开头的单词

1
/\ba\w*\b/

匹配QQ号码

1
2
// QQ号是5-12位数字
/^\d{5,12}$/

匹配IP地址

ip的要求是 0.0.0.1 或 10.123.159.28 或 255.255.255.0 这样类似的。要求就是每一段是个0-255的数字。因此我们第一步要构造出这个表达0-255数字的正则,这个有点麻烦:

1
2
// 用到了 或 符号。 第一位是2的时候,要控制第二位; 前两位是25的时候要控制第三位;  第一位是0或1的时候不需要控制后面的。
/2[0-4]\d|25[0-5]|[01]?\d\d?/

之后就比较简单了,只需要重复上面的这个 (数字+点号) 3次, 再加上一段上面的数字即可。我们假设上面的正则是变量 cyj

1
((cyj)\.){3}cyj

利用零宽断言找html标签内容

1
var p = /(?<=<(\w+)>).*(?=<\/\1>)/

分析:

  • 第一步,我们要构造出标签的正则。 标签开头是类似这样的 <a> , 标签结尾是类似这样的 </a>。 因此,标签的正则是 <(\w+)>, 标签结尾的正则是 <\/\1>. 这里标签结尾引用了前面定义的 \w+
  • 第二步,我们要构造标签内部内容的正则。由于内容包括了回车换行,中英文字符等。所以简单来说,直接 .* 即可 (注: 实际上点号是排除了回车换行的, 所以最好是 [\s\S])
  • 第三步,我们的目标是获取标签中间的内容,而不要标签。所以可以考虑使用零宽断言。把标签开头和结尾当做一个类似 $、\b 这样的分隔符来使用。就能让正则的结果里面不包含标签自身了。所以标签开头要这样: (?<=<(\w+)>);而标签结尾要这样: (?=<\/\1>)

邮件正则

邮件的规则:前面一堆字符,中间一个at,末尾一个域名结构.
精确描述(可能也不太对):字母开头 + 数字字母下划线中划线 + @符号 + 数字字母下划线中划线 + (.xxx)形式一到两个

1
/^[A-z][A-z0-9_-]+@[A-z0-9_-]+(\.[a-z0-9]{2,5}){1,2}$/

感觉可以换成简便点的 [-\w]+@([-\w]\.)*(\w{2,5}\.){1,2}\w{2,5}

我们看at后面: ([-\w]\.)* 表示域名前缀,如 cuiyongjian 或 www.cuyongjian,或demo.bbs.cuiyongjian, 后面的 (\w{2,5}\.){1,2}\w{2,5} 则表示 零次的 .com 或一次的 .com.cn

2018手机号正则

手机规则是: 前三位代表运营商,中间四位代表地区,最后四位随机。 由于只有前三位我们是可以从官方数据中看到的,因此正则也仅控制前三位即可:

1
/^((13[0-9])|(14[5-9])|(15[0-3])|(15[5-9])|(166)|(17[0-8])|(18[0-9])|(19[8-9]))\\d{8}$/

中文匹配

1
/[\u4E00-\u9FA5\uF900-\uFA2D]/

上面是中文文字所在的unicode编码字符集中的位置。

另外:u4e00-u9fbf : unicode CJK(中日韩)统一表意字符。u9fa5后至u9fbf为空. uF900-uFAFF: 为unicode CJK 兼容象形文字。uFA2D后至uFAFF为空.

css的RGB颜色值转换

rgb(xxx,xxx,xxx) 替换为 #xxyyzz

即找到括号内3个RGB颜色值,将他们转为十六进制,再前面加个井号完成。

  • 匹配颜色值: /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(str) (其实也可以把括号内全部字符匹配出来之后,再split为3个元素的数组)
  • 将3个值分别转为16进制 num.toString(16).toUpperCase() ,借用 toString的16进制转换功能

代码:

1
2
3
4
5
6
const p = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/
p.exec('rgb(255,0, 0)')
if (p && p.length === 3) {
cosnt rgb = p.slice(1, 4)
const result = '#' + rgb.map(item=>Number(item).toString(16).toUpperCase().padStart(2, '0')).join('')
}

如果是反过来先找 16 进制的颜色值的话,就要 ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$,即 6位十六进制值或3位十六进制值。

写一个正则匹配所有顶级域名是 9agame.cn的网址,包括子目录的。例如 http://abc.9game.cn/sname/ivew

如果这个匹配非常重要的话,如何进行严谨的防范呢?

正则相关工具

https://regex101.com/ 这个可以用来查看你的表达式中各个表达的含义,会给你用语言描述出来。
https://regexper.com 这个会用图像给你描述出正则表达式的各个含义和流程。

refer

30分钟正则教程
常用正则表达式