正则表达式语法详解

一、前言

正则表达式很有魅力,熟练使用正则表达式会让开发更高效,更优雅,同时一些开发工具在搜索和替换时也支持正则表达式,如idea, vscode等等。

在你学会正则之前,你只能看着那些正则大师们,写了一串外星文似的字符串,替代了你用一大篇幅的if else代码来做一些数据校验

这篇文章主要参考知乎文章: 你是如何学会正则表达式的?b站:后盾人正则表达式系列课程,本文可以作为一种文档来查阅,里面基本每个知识点都有例子支撑,所以内容可能有点多

推荐两个网站,正则可视化Github很火的一个正则学习网站

二、 领略正则的魅力

举一个例子来说明正则表达式的魅力

一个字符串 asjdhfalsjkdf1521521asldkfjalj454,想从这个字符串中取出所有的数字,怎么做?如果是通过js,那可以通过字符串操作,判断每一个字符是不是数字或者转为数组通过filter进行过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
let str = 'asjdhfalsjkdf1521521asldkfjalj454';
// 字符串操作
let result = "";
for (let x of str) {
if (!Number.isNaN(parseInt(x))) {
result += x;
}
}
// 或者
let result = [...str].filter(item =>
!Number.isNaN(parseInt(item))
)
console.log(result); // 1521521454

如果使用正则表达式呢?代码将非常简单

1
2
3
let str = 'asjdhfalsjkdf1521521asldkfjalj454';
let result = str.match(/\d/g).join("");
console.log(result); // 1521521454

是不是代码整洁了很多,学会正则表达式就不需要每次需要正则的时候都要去网上找了!!!

三、正文

1. 创建正则表达式方式

① 构造函数创建

1
var reg = new RegExp("abc|def", "g");

② 字面量创建(常用)

1
var reg = /abc|def/;

2. 字符匹配规则

字符 说明
\ 转义字符,如 \( 匹配左括号
\d 匹配一位数字 [0-9] ,\D则取反
\w 匹配字符数字下划线 [0-9a-zA-Z_],\W则取反
\s 匹配空格、水平制表符、垂直制表符、换行符、分页符、回车符 [\t\v\n\r\f],\S则取反
. 匹配除了换行、回车、行分隔符、段分隔符外的任意字符 【^\n\r\u2028\u2029】
\uxxxx 匹配十六进制
\f 换页符
\n 换行符
\r 匹配回车符
\t 水平制表符
\v 竖直制表符
\o 匹配NULL字符
[abc] abc任意一个
[^abc] abc中任何一个都不匹配,取反
[a-z] 匹配从a-z的任意字符

3. 运算符及优先级

优先级 运算符 描述
1 \ 转义字符
2 ()、(?:)、(?=)、[] 断言和原子组
3 *、+、?、{n}、{n,}、{n,m} 匹配量词
4 ^、$,\任何元字符(如\d,\w),任何字符
5 | 逻辑 “或” 操作

很多时候匹配结果和自己预想不一样,可以检查一下这个运算优先级,比如匹配量词(+ * {m,n})或者 | 这个运算符,因为他们的优先级比较低,举两个例子

1
2
3
let str = "abbbbbababab";
let reg = /ab+/g;
console.log(str.match(reg)); // abbbbb,ab,ab,ab

可能这段代码你的本意是要整体匹配 ab+ 代表ab可以出现多次,但是第一组匹配结果显然不是自己想要的,原因是因为字符匹配 ab 优先级低于 + 运算符,所以 ab 不能作为一个整体,因此 + 运算符只作用在 b 字符上,也就会匹配abbbb 这种

同理还有一个例子

1
2
3
let str = "abc,ac";
let reg = /a|bc/g;
console.log(str.match(reg)); // a,bc,a

因为字符匹配优先级比较高,因此正则理解的这个表达式应该是匹配字符 a 或者字符 bc 。而不是匹配 ab 或者 ac

4. 匹配量词

如果要重复匹配一些内容时我们要使用重复匹配修饰符,包括以下几种

字符 说明
* 零到多个
+ 一到多个
零到一个
{ n } 匹配 n 个
{ n,m } 匹配 n 到 m 个
{ n,} 匹配 n 到多个

在这些量词中也分为 贪婪懒惰(非贪婪) 两种,贪婪就是尽可能的多匹配,非贪婪就是懒,一个满足条件就停止

正则默认是贪婪的(仿佛讽刺了人性),如果要取消贪婪,可以在表达式后面加一个 ? 比如 a*? 则最少匹配0个 a

1
2
3
4
5
let str = "http://www.suhaoblog.cn";
let reg = /w+/g
let lessReg = /w+?/
console.log(str.match(reg)); // www
console.log(str.match(lessReg)); // w

再举一个例子,如果我想将main标签中所有span标签换成h1标签,对replace方法不太清楚的可以看看mdn中replace的api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<body>
<main>
<span>suhaoblog,cn</span>
<span>this is a test</span>
</main>
<script>
const main = document.querySelector("main");
let reg = /<span>([\s\S]+?)<\/span>/g;
main.innerHTML = main.innerHTML.replace(reg, (match, p0) => {
return `<h1 style="color:red">${p0}</h1>`;
})
</script>
</body>
</html>

此处的正则就需要使用贪婪模式,因为一次只匹配一对span标签,如果不用贪婪,则会从第一个span的开始匹配到第二个span的结束,整体换成h1标签,这样虽然看起来一样,但是通过审查元素发现只有一个h1标签,在h1内部还有一个span。而通过贪婪会精确匹配到每一对span,逐一替换

5. 位置匹配

① 开始结束位置

限定匹配和结束的边界,举个例子

1
2
3
var str = "123";
var reg = /\d/g;
console.log(reg.test(str)); // true

如果加上字符边界限定

1
2
3
var str = "123";
var reg = /^\d$/g;
console.log(reg.test(str)); // false

为什么结果是false呢,因为加上开始和结束限定,相当于整个字符串都要满足条件才返回true,str包含三个数字,而正则只匹配了一个字符,所以匹配失败

而不加 ^$ 限定就表示从字符串开头开始匹配,如果匹配到一个数字就返回true,显然满足

如果上面例子想返回true,可以加上 {} 限定匹配位数

1
var reg = /^\d{3}$/

匹配三位,返回true

② 边界匹配符

边界匹配符主要包括\b\B 两种,都是匹配位置而不是匹配字符,常用来获取字符

\b 单词边界,是指单词与符号之间的边界,是一个位置,不是空格或字符。(这里单词可以是中文字符,英文字符,数字。符号可以是中文符号,英文符号,空格,制表符,换行)。不能与匹配量词?+*{1}{2,5}等连用

\B 非单词边界,是指符号与符号,单词与单词的边界,不能与量词连用

1
2
3
let str = "https://www.suhaoblog.cn";
let reg = /\b/g;
console.log(str.replace(reg, "-")); // -https-://-www-.-suhaoblog-.-cn-

可以看到匹配到八个位置,都是单词和符号的边界,反之如果使用 \B

1
2
3
let str = "https://www.suhaoblog.cn";
let reg = /\B/g;
console.log(str.replace(reg, "-")); // h-t-t-p-s:-/-/w-w-w.s-u-h-a-o-b-l-o-g.c-n

两个对比应该就能清楚二者的区别和用法

6. 匹配模式

正则表达式中主要包含 igmusy 几种匹配模式

① ‘i’ 模式

忽略大小写,在该模式下所有字符会按照小写匹配

1
2
3
let str = "suHao1,SUHAO,Suhao,su1hao";
let res = str.match(/suhao/i, "test")
console.log(res); // suHao

② ‘g’ 模式

全局匹配,不加 g 修饰,第一个满足条件就会停止,而 g 修饰后不会停止,会继续匹配到字符串的结尾

还是上面的例子

1
2
3
let str = "suHao1,SUHAO,Suhao,su1hao";
let res = str.match(/suhao/ig, "test")
console.log(res); // suHao,SUHAO,Suhao

③ ‘u’ 模式

我知道的在 u 模式下有三种用法,匹配宽字节,语言系统匹配和字符属性匹配

首先是宽字节匹配,使用 u 模式可以正确处理四个字符的 UTF-16 字节编码

第二个用法时匹配Unicode类别,如 /\p{L}/ ,这种用法我见的比较少,初学不建议深入

在Unicode编码中,每个字符都有对应的类别(Unicode Categories),可以理解成属性,比如 L 表示单词(不全指英文单词),P 表示标点符号,N 表示数字,这种属性也需要在 u 模式下生效,Unicode Categories 文档地址

1
2
3
4
5
6
// 匹配数字
let str = "test,1234+?";
console.log(str.match(/\p{N}/ug)); // 1,2,3,4
// 使用L匹配单词
let str = "中文test,1234+?";
console.log(str.match(/\p{L}/ug)); // 中,文,t,e,s,t

这里有点不太确定,L文档中标注的是 any kind of letter from any language,中文这两个字符之所以被匹配到可能是因为中文被认为是中文的 单词,如果要匹配字母还是建议 /^[a-zA-Z]$/ 这种匹配方法

第三种用法是匹配Unicode中支持的语言系统,比如/\p{sc=Han}/ 这种写法匹配汉字

1
2
let str = "中1文test,1234+?";
console.log(str.match(/\p{sc=Han}/ug)); // 中,文

具体语言对应表可以查看这里 【配合chrome浏览器的翻译成中文功能效果更佳,不过chrome把汉语的Han翻译成了韩。。】

④ ‘y’ 模式

y 模式和 g 模式有点相似,也是全局匹配,但是 y 比较懒,如果第一个匹配失败,遇到困难了,匹配就停止了。且后一次匹配都是从上一次的匹配成功的下一个位置开始的,举个例子

1
2
3
4
5
6
7
8
9
let str = "苏浩的个人博客地址是:http://www.suhaoblog.cn,http://music.suhaoblog.cn"
let index = str.indexOf(":") + 1;
const reg = /(https?:\/\/\w+\.\w+\.\w+),?/y;
reg.lastIndex = index;
console.log(reg.lastIndex); // 11
console.log(reg.exec(str)[1]); // http://www.suhaoblog.cn
console.log(reg.lastIndex); // 35
console.log(reg.exec(str)[1]); // http://music.suhaoblog.cn
console.log(reg.lastIndex); // 60

通过设定正则表达式对象的 lastIndex 让匹配从指定位置开始,第一次匹配成功后 lastIndex 等于上一次匹配成功的位置,通过这种方式可以加快匹配速度,特别是大文本

⑤ ‘m’ 模式

多行匹配,会对每一行进行单独处理,举个例子

给定一个字符串,将这个字符串转换为[ {name: "js",price: "200元"} ]的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let str = `
#1 js,200元 %
#2 php,300元 %
#3 java,500元 %
`
// 将匹配结果的每一行通过map函数进行处理
let result = str.match(/^\s*#\d\s*\w+\s*.*$/gm).map(item => {
// 去掉前后空格,replace返回结果是个字符串,所以可以链式调用
let result = item.replace(/^\s*#\d\s*/, "").replace(/\s*%$/, "");
let [name, price] = result.split(",");
// es6 解构语法,精简处理
return {
name: name,
price: price
}
})
console.log(result);

⑥ ‘s’ 模式

单行匹配,将目标字符串当成单行处理, . 可以匹配所有字符,包括换行

1
2
3
4
5
6
7
8
let str = `
sd,
1212
dsf
,.
`;
let reg = /.+/s
console.log(str.match(reg))// ↵ sd,↵ 1212↵ dsf↵ ,.↵

可以匹配到回车

7. 组

组分为三类,分别是 捕获组分组引用非捕获组,这部分开发中用的比较多,需要认真理解

① 捕获组

首先是捕获组,通过括号 () 包裹起来的部分就是捕获组,比如

1
2
3
let str = "http://www.suhaoblog.cn";
let reg = /https?:\/\/(\w+\.\w+\.\w+)/
console.dir(str.match(reg))

结果为

image-20210627212503460

其中索引为0表示匹配的结果,1为匹配的原子组,也就是所说的捕获组了

② 分组引用

举个例子,日期格式一般是2020-12-12,也有2020/12/12这种格式,这两种都是可以的,但是不能混着用,比如2020-12/12,这显然不合适,最开始我的正则是这么写的

1
let reg = /\d{4}[\/-]\d{2}[\/-]\d{2}/

这个正则虽然能匹配到 2020-12-122020/12/12 但是同时 2020-12/12 也能匹配成功,这显示是不对的,因此需要用到分组引用(捕获组的引用),也就是 \n 这种形式,n代表第几次匹配结果,也就是前面通过捕获组捕获的结果,改写正则

1
let reg = /\d{4}([\/-])\d{2}\1\d{2}/

再次测试,发现 2020-12/12 这种无法通过测试,如果包含多个原子组,只需要数左括号,从左边数第n个左括号和对应闭合括号中包含的内容就是第n个引用组

还有个替换的例子

1
2
3
4
// 将(010)99999999转化为电话格式,即010-99999999
let str = "(010)99999999 (020)99999999";
let reg = /\((\d+)\)(\d+)/g;
let res = str.replace(reg, '$1-$2'); // 010-99999999 020-99999999

replace方法中的 $1$2 也是分组引用,当然也可以使用回调函数,不过不如这个简单,也贴一下代码,方便对比

1
2
3
4
5
let str = "(010)99999999 (020)99999999";
let reg = /\((\d+)\)(\d+)/g
let res = str.replace(reg, (match, p0, p1) => {
return `${p0}-${p1}`
})

③ 非捕获组

使用括号包裹的部分会被匹配到捕获组,如果不想匹配,可以使用 ?: 取消捕获,还是捕获组的例子,如果我不想匹配三级域名 www ,可以使用 ?: 取消捕获

1
2
3
let str = "http://www.suhaoblog.cn";
let reg = /https?:\/\/((\w+)\.\w+\.\w+)/
console.dir(str.match(reg))

不取消捕获,则捕获组中包括 www.suhaoblog.cnwww 两部分,如果修改正则

1
let reg = /https?:\/\/((?:\w+)\.\w+\.\w+)/

则此时匹配组只有 www.suhaoblog.cn

8. 断言

① 零宽先行断言

?=exp 匹配后面为 exp 的内容

1
2
3
4
5
6
let str = "苏浩的个人网站地址:。";
let reg = /:(?=。)/g
console.log(str.replace(reg, (match) => {
return match + "http://www.suhaoblog.cn"
}));
// 苏浩的个人网站地址:http://www.suhaoblog.cn。

在正则中匹配是从前向后匹配,因此这里的前和逻辑前是反过来的,先行断言也就是向后匹配。匹配到后面的句号,然后替换成你想替换的部分。

② 零宽后行断言

和先行断言效果相反,向前匹配,例子

1
2
3
4
let str = "苏浩的个人网站地址:。";
let reg = /(?<=:)/g
console.log(str.replace(reg, "http://www.suhaoblog.cn"));
// 苏浩的个人网站地址:http://www.suhaoblog.cn。

③ 零宽负向先行断言

(?!exp) 后面不能出现exp指定的内容,举个例子

比如匹配号码,号码可以是134,139,198,178等开头,但是以139开头后下一位不能是1,就可以使用这个零宽负向先行断言

1
2
3
4
5
let str = "19824578939";
let str1 = "13911111111";
let reg = /(134\d|139(?!1)|198\d|178\d)\d{7}/g;
console.log(reg.test(str)); // true
console.log(reg.test(str1)); // false

思路就是匹配前4位,如果是134,198和178开头,则多匹配任意一位数字,如果是139开头,则下一位数字通过 零宽负向先行断言 控制不为1,最后同意控制剩余部分为7位数字即可

④ 零宽负向后行断言

和③相似,不过是向前匹配。例子:网址前面不能出现空格

1
2
3
4
5
let str = "苏浩个人博客地址为 http://www.suhaoblog.cn";
let str1 = "苏浩个人博客地址为http://www.suhaoblog.cn";
let reg = /(?<!\s+)https?:\/\/\w+\.\w+\.\w+/g;
console.log(reg.test(str)); // false
console.log(reg.test(str1)); // true

四、总结

本文重点内容还是基本的匹配字符、匹配量词、匹配模式以及分组部分,正则的语法其实不是很难,重要的是多练习,灵活使用各种原子组和捕获组完成对字符串的验证和替换。这篇文章是边学习边总结的,可能有些地方不是特别准确,有问题欢迎大家指出