likes
comments
collection
share

正则表达式那些事【基础篇】

作者站长头像
站长
· 阅读数 15

基本概念

正则表达式中的元素由字符元字符组成。字符又叫做普通字符即普通的计算机编码,如我们常用的英文字符、数字、中文等等;元字符又被称为特殊字符即在正则表达式中存在特殊意义的字符,如\*$等等,详情可见MDN 文档

精准匹配

正则表达式与目标字符一对一匹配。涉及:\

当我们想匹配a字符,正则表达式可以直接写成/a/。但是匹配的是元字符时,如$,直接写成/$/是无法匹配到成功的,因为$在正则中存在特殊意义,此时我们需要使用\对元字符进行转义,即消除元字符的特殊意义,要想匹配$可写成/\$/。个别普通字符在\的修饰下也会拥有特殊含义,如\n(换行)、\r(回车)等等。

小结:匹配普通字符:/普通字符/;匹配元字符:/\元字符/;匹配特定字符:/\个别普通字符/

正则表达式描述&举例关联
/普通字符/匹配普通字符-
/\元字符/匹配元字符-
/\n/匹配换行符(LF)换行和回车
/\r/匹配回车符(CR)换行和回车
/\f/匹配换页符-
/\t/匹配制表符(Tab)-
/\v/匹配垂直制表符-

多字匹配

正则表达式之所以强大,就是其能进行模糊匹配。使用集合区间通配符的方式实现一对多的匹配,实现字符模糊。涉及:[]-通配符

集合

假如我们想同时匹配 1 到 9 之间全部的奇数,如果使用精准匹配肯定无法满足当前的要求,此时我们可以使用用集合的方式来处理,使用[]元字符将需要匹配的字符全部列举出来,这时的正则可以写成/[13579]/

/[13579]/.test("1"); // true
/[13579]/.test("2"); // false

区间

当我们想要同时匹配大于 4 的数字时,使用集合的方式可以写成/[56789]/,看着其实还好,但是假如要求同时匹配全部小写字母呢,全部列举出来肯定是不科学的,这时我们需要想到区间的写法。当我们想要同时匹配一段连续的数字、字母或者中文时我们可以使用区间的写法,上述问题可以写成/[5-9]/

  • /[a-z]/:同时匹配所有小写字母;
  • /[\u4e00-\u9fa5]/:同时匹配全部汉字;
/[\u4e00-\u9fa5]/.test("我"); // true
/[\u4e00-\u9fa5]/.test("I"); // false

通配符

在开发中时常会遇到仅允许用户输入数字的场景,用区间方式可写成/[0-9]/,但是区间的写法相对来说还是比较低效,所以正则提供更简便的写法:通配符写法,使用\d通配符即可同时匹配 0 至 9 的数字,所以懒惰才是人类文明进步的真正动力呀;

/\d/.test("1"); // true
/\d/.test("壹"); // false

集合、区间和通配符并不是水火不容的,当他们同时存在时会取它们的并集,现想同时匹配全部数字、全部小写字母以及_,可直接写成/[\da-z_]/

/[\da-z_]/.test("a"); // true
/[\da-z_]/.test("A"); // false

小结:集合写法:/[列举字符]/;区间写法:/[开始-结束]/;通配符写法:/通配符/

正则表达式描述&举例关联
/[13579]/[]列举的全部字符-
/[5-9]/[]内区间的全部字符-
/\d/0 至 9 之间的字符等价于[0-9]
/\D/0 至 9 以外的任何字符等价于 [^\d]
/\w/0 至 9、a 至 z、A 至 Z 以及_等价于 [a-zA-Z_\d]
/\W/0 至 9、a 至 z、A 至 Z 以及_以外字符等价于 [^\w]
/\s/全部空白字符,如:空字符、\n、\r、\f、\t、\v等价于[\n\r\f\t\v ]
/\S/全部非空白字符等价于[^\s]
/./除\n 和\r 以外的全部字符等价于[^\n\r]

次数限定

根据多字匹配一节我们写成的正则可以匹配某一类字符,但是匹配的长度依然是固定的,本节就来讲讲设置字符重复的次数,实现长度模糊。涉及:?{}*+

?元字符

我们常见的传输协议有两种:http 和 https,假如我们现想写一个同时匹配这两种协议的正则该如何写呢?分析一下需求:http 后有没有 s 都能匹配成功,这时我们可以使用?这一元字符修饰s,表示s可匹配 0 次或者 1 次。正则可以写成/https?/

/https?/.test("http"); // true
/https?/.test("https"); // true

{}元字符

再原来的基础上又出了一种协议:httpss,这时要同时匹配这三种协议应该如何写呢?此时需求是:http 后面有 0 至 2 个 s 都能匹配成功,用前面说的?元字符可以写成/https?s?/,但是这样的写法显然很冗长,这次时我们可以用到{}这个元字符,就上面问题正则可以写成/https{0,2}/{}有四种写法:

  • {count}:count 次;

  • {min,max}:min 次到 max 次;

  • {min,}:至少 min 次;

  • {0,max}:至多 max 次。

/https{1}/.test("http"); // false
/https{1}/.test("https"); // true

*元字符

假如以后每更新一次协议都是往后添加一个 s,如果要兼容以后可能出现的每一个协议又该如何写呢?此时的需求变成了:http 后面不管出现几个 s 都能匹配成功,此时我们可以使用*这个元字符,正则可写为:/https*/*修饰的字符出现 0 次或者多次都能匹配成功。当然正则也写成/https{0,}/

/https*/.test("http"); // true
/https*/.test("https"); // true

+元字符

在微信小程序为了安全考虑发布上线的小程序访问的接口必须为 https 协议,假如 httpss 等多 s 协议小程序都认为是安全的,这时想过滤出全部安全协议的正则又如何写呢?此时就可以使用+这个元字符了,正则可写为:/https+/+修饰的字符出现 1 次以上都能匹配成功,当然这时的正则可以写成/https{1,};

/https+/.test("http"); // false
/https+/.test("https"); // true

小结?:匹配 0 至 1 次;{}:匹配指定次数或者指定区间;*:匹配大于等于 0 次;+:匹配大于等于 1 次。

正则表达式描述&举例关联
/https?/出现 0 次或者 1 次如:http、https等价于/https{0,1}/
/https{0,2}/重复 0 次至 2 次 如:http、https、httpss- {count}:count 次;- {min,max}:min 次到 max 次;- {min,}:至少 min 次 ;- {0,max}:至多 max 次
/https*/重复大于等于 0 次 如:http、https 等等价于/https{0,}/
/https+/重复大于等于 1 次 如:https、httpss 等等价于/https{1,}/

边界限制

用于限制查询过程中的单词边界限制以及句子边界限制。涉及:\b\B$^

单词边界\b

现在有 day 和 today 2 个单词,假如我们只想匹配 day ,如果正则写成/day/,测试后会发现 2 个单词都能匹配上,因为/day/无法限制单词的边界,如果想限制单词边界就需要使用\b。当一个区域的前后位置有且仅有一个\w([0-9a-zA-Z_])时该区域就存在单词边界,如上面的例子可以写成/\bday\b/

"It's a nice day today".replace(/\b/g, "\b");
// 控制台打印:\bIt\b'\bs\b \ba\b \bnice\b \bday\b \btoday\b 每个\b就是一个单词边界

非单词边界\B

还是上面 2 个词,假如现在我们想匹配以 day 结尾的单词呢?此时的需求变成了:day 结尾,匹配 today,但是不匹配 day,这时我们可以用到非单词边界\B,单词边界以外的区域均为非单词边界,即一个区域内前后位置都为\w或者前后位置都不存在\w。上述例子可以写成/\Bday\b/,当然正则也可以写成/\w+day\b/

"It's a nice day today".replace(/\B/g, "_");
// 控制台打印:I_t's a n_i_c_e d_a_y t_o_d_a_y 每个_就是一个非单词边界

字符串边界^$

在开发过程中我们时常需要判断一个域名是否是以 https 开头 com 结尾的域名,现在有一个域名https://com.hellochange.cn,我们想判断它是否符合正则应该如何写?如果仅用前面提到的知识无法解决当前问题,因为我们无法限制 https 和 com 所在的位置,要解决这个问题我们需要引入两个元字符:^$^:匹配的句子以什么开头、$:匹配的句子应该以什么结尾,此时正则可以写成/^https.+com$/

/https.+com/.test("https://com.hellochange.cn"); // true
/^https.+com$/.test("https://com.hellochange.cn"); // false

小结\b:单词边界;\B:非单词边界;^:内容以什么开头;$:内容以什么结尾。

正则表达式描述&举例关联
/\bday\b/匹配day单词-
/\Bday\b匹配以day结尾的单词如:today-
/^我/匹配以开头的句子如:我是谁-
/你$/匹配以结尾的句子如:看见你-
/^我.+你$/匹配以开头结尾的句子如:我爱你-

修饰符

修饰符也叫标志,用于指定额外的匹配策略。涉及:mgis

// 等价于:https://hellochange.cn\nhttps://baidu.com\nHTTPS://qq.com\nhttps://weibo.com\nhttp://taobao.com
https://com.a.cn
https://b.com
HTTPS://c.com
https://d.com
http://e.com

如上多行地址,我们现在想匹配找出其中一个以 https 开头 com 结尾的域名,正则又该如何写呢?如果仍然写成/^https.+com$/测试后会发现无法匹配到内容,因为它看上去是多个句子,但是正则仍把他当成一个句子来处理,如果我们想把它当成多个句子进行处理,这时就需要用到修饰符的功能了。常用的修饰符有四个:mgis,下面一一介绍其用法。

  • m:多行匹配(multiple),以回车符和换行符做为句子分割点。由此可知上题正则可以写成:/^https.+com$/m
  • g:全局匹配(global),查找所有匹配而非在找到第一个匹配后停止,如上题如果写成/^https.+com$/m,正则最终只会匹配https://b.com,现在我们想要匹配全部内容正则就可以写成/^https.+com$/mg
  • i:忽略大小写(ignore),正则匹配默认是区分大小写的,然而有时我们并不需要在意结果的大小写。如上题如果写成/^https.+com$/mg,最终HTTPS://c.com不会被匹配到,如果想要到HTTPS://c.com正则就需要写成/^https.+com$/mg
  • s.元字符可匹配回车符\r和换行符\n。如果正则写成/^https.+com$/是无法匹配到内容的,因为.元字符无法匹配回车符和换行符,此时正则改写成/^https.+com$/s就可以匹配到整行句子(等价于的后面那一串)。
正则表达式描述&举例关联
/^https.+com$/m多行匹配以 https 开头 com 结尾的句子-
/^https.+com$/g匹配全部以 https 开头 com 结尾的句子-
/^https.+com$/i匹配以 https 开头 com 结尾的句子且不区分大小写-
/^.+$/s匹配任何字符串-

逻辑关系

正则中可用的逻辑关系,即「或|」和「非^」。涉及:|^

逻辑或

现在有一个用于输入用户性别的输入框,如何用正则限制用户输入?需求分析:判断输入内容是否是“男”字或者是一个“女”字。字数限制可以使用字符串边界^$,”或者“就可以用到正则中的逻辑“或”元字符,正则可以写成/^男$|^女$/,当然也可以写成/^[男女]$/,也可以用下一节会提的分组写法:/^(男|女)$/

逻辑非

社交软件、购物软件中一般都会有评论功能,有评论肯定需要做敏感词过滤功能,不然分分钟下架。假如现在有敏感词 a、b、c,只要用户评论中带有 a、b、c 都不能通过审核,此时正则该如何写?需求分析:匹配的字符串不能携带 a、b、c 字符。此时就可以用到正则中的逻辑元字符,正则可写为/[^abc]/。注意:正则中“非”和“以什么开头”都是用的^,当^[]元字符内时表示“非”,其他都是表示以什么开头。

正则表达式描述&举例关联
/^男|^女$/匹配“男”或者“女”等价于 /^[男女]$/或者/^(男|女)$/
/[^abc]/匹配不包含 a、b、c 的字符串-

分组

分组基础

使用()元字符将正则表达式进行分组,每一个分组都是一个子表达式。涉及:(pattern)

逻辑或小结中提到的正则表达式:/^(男|女)$/,此正则可以匹配“男”字或者“女”字,因为()能将男|女当成一个整体来处理,如果我们将它改写成/^男|女$/,会发现该正则可以匹配全部以“男”开头或者以“女”结尾的字符串。

const str1 = "男";
const str2 = "女";
const str3 = "男孩";
const str4 = "美女";
const reg = /^男|女$/;
console.log(reg.test(str1)); // true
console.log(reg.test(str2)); // true
console.log(reg.test(str3)); // true
console.log(reg.test(str4)); // true

次数限定那一节提过 https 协议的例子,假如现在我们只想匹配 s 为单数的协议,即 https、httpsss 等,此时正则表达式又该如何写?需求分析可知,我们需要写一个能匹配 https 后有偶数个 s 的正则。此时我们可以使用分组的功能解决写成:/^https(ss)*$/,如果用数学计算表述该正则:只要 值 = https + ss*任意大于等于 0 的数字 该值就满足条件。

const str1 = "https";
const str2 = "httpss";
const str3 = "httpsss";
const reg = /^https(ss)*$/;
console.log(reg.test(str1)); // true
console.log(reg.test(str2)); // false
console.log(reg.test(str3)); // true

提取转换

使用()进行分组,各分组匹配的内容也会被提取,使用这一特性可以很轻松的实现匹配内容的提取和转换

假设想匹配格式为YYYY-MM-DD的日期,正则可以写成:/\d{4}-\d{2}-\d{2}/或者/(\d{4})-(\d{2})-(\d{2})/,加括号与不加括号有什么区别呢?

const str = "2023-02-14";
const reg1 = /\d{4}-\d{2}-\d{2}/;
const reg2 = /(\d{4})-(\d{2})-(\d{2})/;
// ['2023-02-14', index: 0, input: '2023-02-14', groups: undefined]
console.log(reg1.exec(str));
// ['2023-02-14', '2023', '02', '14', index: 0, input: '2023-02-14', groups: undefined]
console.log(reg2.exec(str));

根据测试结果我们可以知道,使用()进行分组,各分组匹配的内容也会被提取。假如现在需要将YYYY-MM-DD的数据转换为YYYY年MM月DD日,掌握了分组提取特性该需求就很好处理了,实现如下:

// 方式一
function fn1(str) {
	const reg = /^(\d{4})-(\d{2})-(\d{2})$/;
	const result = reg.exec(str);
	if (result) {
		return `${result[1]}${result[2]}${result[3]}日`;
		// 也可以使用构造函数属性,该属性只要执行正则操作就有有值,如使用reg.test(str)、str.match(reg)等
		// return `${RegExp.$1}年${RegExp.$2}月${RegExp.$3}日`;
	}
	return str;
}

// 方式二
function fn2(str) {
	const reg = /^(\d{4})-(\d{2})-(\d{2})$/;
	return str.replace(reg, (match, year, month, day) => {
		return `${year}${month}${day}日`;
		// 当然这里也可以使用前面提到的构造函数全局属性
	});
}

// 方式三
function fn2(str) {
	const reg = /^(\d{4})-(\d{2})-(\d{2})$/;
	// $1等价于RegExp.$1
	return str.replace(reg, `$1年$2月$3日`);
}

非捕获分组

不捕获分组匹配的内容,涉及:(?:)

提取转换小节提到过,使用()进行分组,各分组匹配的内容也会被提取,但是有时我们并不会用到分组匹配的内容,提取各分组的内容或多或少会造成资源的浪费。如果我们需要用到分组但是又不需要使用到各分组匹配的内容,我们可以使用非捕获分组(?:)

const str = "2023-02-14";
const reg1 = /(?:\d{4})-(?:\d{2})-(?:\d{2})/;
const reg2 = /(\d{4})-(\d{2})-(\d{2})/;
// ['2023-02-14', index: 0, input: '2023-02-14', groups: undefined]
console.log(reg1.exec(str));
// ['2023-02-14', '2023', '02', '14', index: 0, input: '2023-02-14', groups: undefined]
console.log(reg2.exec(str));

回溯引用

在正则表达式用使用\1\2\3等等表示前面第一个分组、第二个分组、第三个分组匹配的内容

上面的例子我们再改造一下,如果想让YYYY-MM-DDYYYY/MM/DD格式的日期都能匹配成功,正则表达式应该如何写呢?如果直接写成/^(\d{4})(-|\/)(\d{2})(-|\/)(\d{2})$/

const str1 = "2023-02-14";
const str2 = "2023/02/14";
const str3 = "2023-02/14";
const reg = /^(\d{4})(-|\/)(\d{2})(-|\/)(\d{2})$/;
console.log(reg.test(str1)); // true
console.log(reg.test(str2)); // true
console.log(reg.test(str3)); // true!!

根据测试结果我们可以看出,虽然该正则能匹配YYYY-MM-DDYYYY/MM/DD格式的日期,但是YYYY-MM/DD格式的数据也能匹配,所以这种写法不符合要求。如果想要控制分割符前后一致,就需要用到分组回溯引用的功能写成/^(\d{4})(-|\/)(\d{2})\2(\d{2})$/

const str1 = "2023-02-14";
const str2 = "2023/02/14";
const str3 = "2023-02/14";
// \2代表第二个分组,和前面提到的 RegExp.$2 是一个意思
const reg = /^(\d{4})(-|\/)(\d{2})\2(\d{2})$/;
// 或
// const reg = /^\d{4}(-|\/)\d{2}\1\d{2}$/;
console.log(reg.test(str1)); // true
console.log(reg.test(str2)); // true
console.log(reg.test(str3)); // false

零宽先行断言

零宽先行断言分为零宽正向先行断言(?=pattern)零宽负向先行断言(?!pattern)

零宽正向先行断言(?=pattern)代表字符串中一个零宽度的位置,该位置后面字符串pattern匹配。举个例子:字符串为peoples,正则为/peo(?=ple)/ peoples.match(/peo(?=ple)/)(?=ple) 其实就是代表people之间的一个零宽的位置,为了方便描述我们假定这个位置为#,字符串就可以理解成peo#ples,正则就可以理解成/peo#/,正则匹配得到的结果也就是字符串peo#,因为#只是一个零宽度的位置标识,所以最终得到的结果是peo

const str = "peoples";
const reg1 = /peo(?=ple)/;
const reg2 = /peo(?=ple)s/;
const reg3 = /peo(?=ple)ples/;
// 理解成:"peo#ples".match(/peo#/)
console.log(str.match(reg1)); // 打印结果:['peo', index: 0, input: 'peoples', groups: undefined]
// 理解成:"peo#ples".match(/peo#s/)
console.log(str.match(reg2)); // 打印结果:null
// 理解成:"peo#ples".match(/peo#ples/)
console.log(str.match(reg3)); // 打印结果:['peoples', index: 0, input: 'peoples', groups: undefined]

零宽负向先行断言(?!pattern)也代表字符串中一个零宽度的位置,但是与正向相反的是该位置后面字符串不能pattern匹配。

const str1 = "peoples";
const str2 = "peony";
const reg1 = /peo(?!ple)/;
const reg2 = /peo(?!ple)ny/;

// 理解成:"peoples".match(/peo#/)
console.log(str1.match(reg1)); // 打印结果:null
// 理解成:"peo#ny".match(/peo#/)
console.log(str2.match(reg1)); // 打印结果:['peo', index: 0, input: 'peony', groups: undefined]
// 理解成:"peo#ny".match(/peo#ny/)
console.log(str2.match(reg2)); // 打印结果:['peony', index: 0, input: 'peony', groups: undefined]

零宽后行断言

零宽后行断言分为零宽正向后行断言(?<=pattern)零宽负向后行断言(?<!pattern)

注意 ⚠️:零宽后行断言存在兼容性问题,在 safari 浏览器和一些老版本浏览器中无法识别,所以一般不会直接使用后行断言,而是使用替代方案

零宽正向后行断言(?=pattern)代表字符串中一个零宽度的位置,该位置前面字符串pattern匹配。和前行断言一样也举个例子:字符串为apple,正则为/(?<=app)le/(?<=app)代表的其实就是apple之间的一个零宽的位置,和前面一样为了方便描述把这个位置假定为#,字符串就可以理解成app#le,正则也就可以理解成#le,所以最终得到的结果是le

const str = "apple and people";
const reg = /(?<=app)le/;
// 理解成 'app#le and people'.match(/#le/);
console.log(str.match(reg)); // 打印结果:['le', index: 3, input: 'apple and people', groups: undefined]

零宽负向后行断言(?<!pattern)代表字符串中一个零宽度的位置,该位置前面字符串不能pattern匹配。

const str = "apple and people";
const reg = /(?<!app)le/;
// 理解成 'apple and people'.match(/#le/);
console.log(str.match(reg)); // 打印结果:['le', index: 3, input: 'apple and people', groups: undefined]

零宽后行断言替代方案

零宽后行断言存在兼容性问题,所以一般项目中使用替代方案处理:将字符串翻转然后使用前行断言匹配

const str = "apple and people";
const reg = /(?<=app)le/;
// 将字符串翻转
const reverseStr = str.split("").reverse().join(""); // 'elpoep dna elppa'
// 将正则由后行断言转化为前行断言
const reverseReg = /el(?=ppa)/;
// 翻转后的匹配结果
const reverseResult = reverseStr.match(reverseReg); //['el', index: 11, input: 'elpoep dna elppa', groups: undefined]
// 转化为真实结果
const result = reverseResult
	? reverseResult.map((item) => item.split("").reverse().join(""))
	: null; // ['le']

正则表达式那些事【基础篇】

后记

最近接触到一个新的概念:“以教为学,教学相长” ,最好的学莫过于教,能用自己的话把一个复杂的知识点表述清楚,说明自己大概已经掌握了它。以写博客的形式记笔记,不但是教更是学,因为 99%的知识都是来自于归纳法,我认为正确的认知,很有可能是错误的,只有反复的接纳别人的建议和改正别人指出的错误,最终收获的知识才是真正的知识。

第一次认真的写一篇博客,其中肯定存在很多纰漏,各位如果有发现什么错误之处或者觉得哪块表述的不清楚或者在写作上和表述上有什么建议的请留言告诉我,Thanks♪(・ω・)ノ!

巨人的肩膀