当你去寻求帮助时,大佬甩给你的一句“用正则啊”,你是否会一脸无奈?
当你做合法校验时,打开谷歌搜索正则基本语法时,你是否会自责自怪?
当你 codereview 时,几行正则映入一心挑刺的你眼帘中,你是否会顿感语塞?
“正则没必要学,遇到的时候再查就行了。”
???
同志们,快醒醒,我们的目标是星辰大海,不是拧螺丝啊。
从明天起,做一个幸福的人,喂马、劈柴,学会正则。
正则表达式是对字符串操作的一种逻辑公式,它使用一些描述性的语言来表达对字符串的一种匹配策略,以实现对字符串的查找、校验、提取以及修改等目的。
要不再看看基本语法?
佛说:“每一次信誓旦旦的背后,都有一次看完基本语法就结束的正则表达式学习之旅。”
或许只有你,懂得我,所以你知道,正则有两种创建方式
字面量
字面量的方式由包裹在两个斜杠内的模式组成
const reg = /ab+c/
正则表达式字面量在 JS 脚本加载后就会被编译。
构造函数
调用 RegExp 对象的构建函数生成
const reg = new RegExp("ab+c")
用构造函数创建的正则表达式会在 JS 脚本运行过程中被编译,所以如果你的正则是动态地产生,推荐使用构造函数来创建。
不管是以前,还是现在,亦或是未来,我们知道了正则的组成结构
斜杆 + 匹配的模式 + 斜杆 + 修饰符
/ pattern / flags
先说简单的修饰符。
修饰符
修饰符一般也被称为标记(flags),用于修饰出特定的匹配策略。
常见的修饰符有:
标志 | 描述 |
---|---|
i | ignore,将匹配策略标记为忽略大小写 |
g | global,查找所有的匹配项 |
m | mult-line,使边界字符^ 和$ 匹配每一行的开头和结尾 |
s | 修饰. 点运算符(详见后文),加上 s 修饰符之后, . 可匹配包括换行符内的任意字符 |
而至于匹配模式,除了包含普通字符 ‘abc’ 、‘中国’、 ‘123’等,剩下的就不得不提那些在正则中发光发彩的特殊字符,她的名字,叫做小薇,哦不对,是元字符。
元字符
元字符并不代表他们本身的字面意思,他们都有特殊的含义。一些元字符写在方括号中的时候有一些特殊的意思。
元字符 | 描述 |
---|---|
. | 匹配除换行符外的任意单个字符 |
[ ] | 字符集,匹配方括号内的任意字符 |
[^ ] | 负值(否定)字符集,匹配除了方括号里的任意字符 |
* | 匹配前面的子模式出现 ≥0 次 |
+ | 匹配前面的字模式出现 ≥1 次 |
? | 匹配前面的子模式出现 0 次或 1 次 |
{n,m} | 匹配 num 个大括号之前的字符或字符集 (n ≤ num ≤ m) |
(xyz) | 字符集,匹配与 xyz 完全相等的字符串 |
| | 或运算符,匹配符号前或后的字符 |
\ | 转义字符,用于匹配一些保留的字符 [ ] ( ) { } . * + ? ^ $ \ | |
^ | 脱字符,匹配字符开始处 |
$ | 美元符,匹配字符结尾处 |
.
点运算符
.
匹配除换行符(\n
、\r
)之外的任何单个字符。 比如:
'The car parked in the garage.'.match(/.ar/g)
// .ar 匹配一个任意字符后面跟着是 a 和 r 的字符串
// ['car', 'par', 'gar']
如果需要匹配包括\n
在内的任何字符,可以使用/.|\n)/
的模式,或者使用修饰符s
(详见上文修饰符章节)。
字符集
方括号[]
用来指定一个字符集,可以使用连字符-
来指定字符集范围([12345abcd]
→ [1-5a-d]
),字符集的顺序无关。比如:
'The car parked in the garage.'.match(/[tT]he/g)
// [tT]he 匹配 t 或 T 开头,后面跟着 he 的字符串
// ['The', 'the']
需要注意,特殊字符在方括号内失去特殊意义,比如[(a)+]
将会匹配(
、a
、)
、+
这四个字符。
但如果特殊字符^
出现在方括号的开头时,表示这个字符集是否定的。
'The car parked in the garage.'.match(/[^c]ar/g)
// [^c]ar 匹配除 c 外后面跟着 ar 的字符串
// ['par', 'gar']
正则其实提供了常见的字符集简写:
简写 | 描述 |
---|---|
\d | 匹配数字: [0-9] |
\D | 匹配非数字: [^\d] |
\w | 匹配所有字母数字,等同于 [a-zA-Z0-9_] |
\W | 匹配所有非字母数字,即符号,等同于: [^\w] |
\s | 匹配所有空格字符,等同于: [\t\n\f\r\p{Z}] |
\S | 匹配所有非空格字符: [^\s] |
你可能没有见过极光出现的村落,也没有见过有人在深夜放烟火,但你可能见到过有人使用类似/(\d\D)/
这样的组合来匹配任意字符。
限定符
限定符用来限定正则匹配子模式的次数,*
、+
、?
、{n,m}
这些都是限定符。
简单点,说话的方式简单点,就是要想匹配上,就必须出现限定多次。
特别的爱给特别的你,{n,m}
还有个名字叫量词,注意逗号前后可没有空格,,
与m
可省略。
- 如果写作
{n}
,表示匹配固定次数n
'The number was 9.9997 but we rounded it off to 10.0.'.match(/[\d]{3}/g)
// [\d]{3} 匹配 3 位数字
// ['999']
- 如果写作
{n,}
,表示至少匹配n
次
'The number was 9.9997 but we rounded it off to 10.0.'.match(/[\d]{1,}/g)
// [\d]{1,} 匹配至少 1 位数字,等同于 **[\d]+,**类似的 {0,} 就等同于 *****
// ['9', '9997', '10', '0']
- 如果写作
{n,m}
,表示匹配至少 n 次最多 m 次
'The number was 9.9997 but we rounded it off to 10.0.'.match(/[\d]{2,3}/g)
// [0-9]{2,3} 匹配最少 2 位最多 3 位数字
// ['999', '10']
()
特征标群
特征标群是一组写在圆括号()
中的子模式,其中包含的内容将会被看成一个整体。
()
在一定意义上作为衡量对正则的掌握水平的一个侧面标准,它提供了分组,正则玩得转其实很多的功能都是基于此展开。
分组
表达式 (ab)*
匹配连续出现 0 或更多个 ab
。如果没有使用 ()
,那么表达式 ab*
将匹配连续出现 0 或更多个 b
。
'ababa abbb'.match(/(ab)*/)
// (ab)* 匹配连续出现 ab 的字符串,其中结果的第二个元素 ab 表示匹配到符合 (...) 的结果
// ['abab', 'ab', index: 0, input: 'ababa abbb', groups: undefined]
对分组的引用一般就两种场景:
在 JS 里引用
我们使用正则除了验证数据合法性之外,另一个大场景就是数据提取与替换了。
- 数据提取
const reg = /(\d{4})-(\d{2})-(\d{2})/
// 1. 使用不带修饰符 g 的正则 match
'2021-12-31'.match(reg)
// 返回的数组,第一个元素是整体匹配结果,然后在 index 前的就是捕获到的各分组(括号)匹配的内容
// ['2021-12-31', '2021', '12', '31', index: 0, input: '2021-12-31', groups: undefined]
// 2. 使用构造函数的全局属性 $1 至 $9 来获取
reg.test('2021-12-31')
// RegExp.$1 -> '2021', RegExp.$2 -> '12', RegExp.$3 -> '31'
- 数据替换
'2021-12-31'.replace(/(\d{4})-(\d{2})-(\d{2})/, function(match, year, month, day) {
return month + "/" + day + "/" + year
})
// year, month, day 则按顺序分别代表第 n 个括号匹配的字符串
// '12/31/2021'
在正则表达式中引用
不同于使用 API 来引用分组,在正则表达式本身里直接引用分组的方式称为反向引用。
比如要写一个正则支持匹配如下三种格式:
- 2021-12-31
- 2021/12/31
- 2021.12.31
const reg = /\d{4}(-|\/|\.)\d{2}\1\d{2}/
// \1 表示的引用之前的那个分组 (-|\/|\.),其中因为 / 和 . 需要转义,前面加符号 \
// 不管它匹配到什么(比如-),\1 都匹配那个同样的具体某个字符
// regex.test('2021-12-31') // true
// regex.test('2021-12.31') // false
需要注意,如果引用了不存在的组,正则并不会报错,而是匹配字符本身。比如/\1\2/
就表示匹配\1
\2
。
非捕获型分组
()
匹配产生的分组不管是使用 match 亦或是 构造函数等,都将记录每个()
匹配的结果,所以一般也称他们为捕获型分组。当然,这必然会增加开销,对性能与效率产生或多或少的影响。
所以如果我们只是单纯的想使用括号最原始的功能,但不引用它,即:既不在API里引用,也不在正则里反向引用。那么我们就可以使用非捕获分组(?:p)
。
'ababa abbb'.match(/(?:ab)*/)
// (?:ab)* 仍匹配连续出现 ab 的字符串,作用同 (ab)*,但与上文分组章节的相比,结果中不再记录捕获结果
// ['abab', index: 0, input: 'ababa abbb', groups: undefined]
分支结构
()
另一个比较重要且常用的功能就是使用符号|
表示或,构成分支结构,所以|
也被称为或运算符。
'The car is parked in the garage.'.match(/(T|t)he|car/g)
// (T|t)he|car 匹配 (T|t)he 或 car
// ['The', 'car', 'the']
\
特殊字符转义
如果想要匹配{ } [ ] () / \ + * . $ ^ | ?
这些特殊字符则要在其前面加上反斜线 \
。
'The car is parked in the garage.'.match(/\w+e\.?/g)
// /\w+e\.? 匹配以 e 或 . 结尾的字符串
// ['The', 'parke', 'the', 'garage.']
锚点
在正则表达式中,想要匹配指定开头或结尾的字符串时使用到锚点,^
指定开头,$
指定结尾。
当我们需要对一个字符串进行校验的时候,比如校验手机号码、身份证号码、密码等等,就需要加上^
和$
来确保完整的字符串开头结尾都被校验到。
而如果只是匹配替换数据的话,一般是不需要的,因为我们要匹配的数据往往有可能出现在一大串文本中的任何位置上。
位置位置位置
正则表达式是匹配模式,要么匹配字符,要么匹配位置。
那老姚对我说,说我是一个小偷,偷他的回忆,塞进我的脑海里。
不得不说,搞懂位置对我们使用正则进行查找替换的帮助不要太大。
其实说到位置,除了前面提到的前后锚点^
与$
之外,还有更多锚点:
符号 | 描述 |
---|---|
\b | 匹配单词边界 |
\B | 匹配非单词边界 |
?= | positive lookahead,正向先行断言, |
?! | positive lookahead,负向先行断言 |
?<= | positive lookabehind,正向后发断言 |
?<! | negative lookbehind,负向后发断言 |
\b
和\B
我就想让你翻译翻译,什么叫单词边界?
\w
和\W
之间的位置^
与\w
之间的位置\w
与$
之间的位置
'[Regex] Lesson_01.mp4'.replace(/\b/g, '#')
// \w 匹配 [a-zA-Z0-9_],所以结合上述单词边界的定义,可以得出答案
// '[#Regex#] #Lesson_01#.#mp4#'
理解了\b
,再看\B
就很好理解了。
'[Regex] Lesson_01.mp4'.replace(/\B/g, '#')
// 与上例 \b 的结果完全相反
// '#[R#e#g#e#x]# L#e#s#s#o#n#_#0#1.m#p#4'
零宽断言
先行断言和后发断言(合称 lookaround)都属于非捕获组(所以需要使用()
)。当我们需要在匹配模式的前面或后面有另一个特定的模式时,就可以使用它们。
(?=pattern)
正向先行断言
匹配 pattern 之前的位置,即:要想满足匹配,后面得跟着 pattern 。
'The fat cat sat on the mat.'.match(/(T|t)he(?=\sfat)/g)
// (T|t)he(?=\sfat) 匹配在 \sfat 前的 The 或 the,\s 表示空格
// ['The']
(?!pattern)
负向先行断言
匹配不含 pattern 之前的位置,即:要想满足匹配,后面不能跟着 pattern 。
'The fat cat sat on the mat.'.match(/(T|t)he(?!\sfat)/g)
// (T|t)he(?!\sfat) 匹配不在 \sfat 前的 The 或 the,\s 表示空格
// ['the']
(?<=pattern)
正向后发断言
匹配 pattern 之后的位置,即:要想满足匹配,前面得跟着 pattern 。
'The fat cat sat on the mat.'.match(/(?<=(T|t)he\s)(fat|mat)/g)
// (?<=(T|t)he\s)(fat|mat) 匹配在 The\s 或 the\s 后面的 fat 或 mat,\s 表示空格
// ['fat', 'mat']
(?<!pattern)
负向后发断言
匹配不含 pattern 之后的位置,即:要想满足匹配,前面不能跟着 pattern 。
'The cat sat on cat.'.match(/(?<!(T|t)he\s)(cat)/g)
// (?<!(T|t)he\s)(cat) 匹配在 The\s 或 the\s 后面的 cat,\s 表示空格
// ['cat'']
偷偷告诉你一个我发现的规律:
- 所谓”正”,即字符中需要出现 pattern
- 所谓“负”,即字符中不能出现 pattern
- 所谓“先”,即匹配在 pattern 前的位置
- 所谓“后”,即匹配在 pattern 后的位置
别贪了,再贪我就是狗
我们都知道,止损重要,止盈同样也很重要,它指当盈利时如遇下跌及时出局,保住一定利润。这当然就需要我们做到“不要太贪”。
那么,我们具体要如何做好止盈呢?
(好像有点跑题🤦♂️)
贪婪匹配
需要注意的是,正则默认采用的是贪婪匹配,也就意味着它会尽可能长的去匹配子串。
+
,?
,*
,{n}
,{n,}
,{n,m}
匹配时,如果遇到上述限定符,代表是贪婪匹配,比如:
'The fat cat sat on the mat.'.match(/(.*at)/g)
// 贪婪匹配到整个字符串的最后一个带 at 的单词 mat 为止
// ['The fat cat sat on the mat']
非贪婪匹配
顾名思义,就是”我不贪了🤷♂️”,也就是尽可能少的去匹配子串,所以也被成为惰性匹配。
+?
,??
,*?
,{n}?
,{n,}?
,{n,m}?
相应的在限定符后面加一个?
即代表非贪婪模式,比如:
'The fat cat sat on the mat.'.match(/(.*?at)/g)
// 非贪婪匹配到第一个带 at 的单词即止
// ['The fat', ' cat', ' sat', ' on the mat']
就这?
听说你觉得自己都学会了?
单词首字母大写
点解?
我们首先得知道怎样找到每个单词的首字母。
懂了, 位置,用\b
。
const titleize = (str) => {
str.toLowerCase().replace(/\b\w/g, (matched) => matched.toUpperCase())
}
// \b\w 匹配每个单词的首字母
其实很简单,其实很自然,两个人的爱由两人分担。
匹配成对标签
什么叫成对标签?
你确定<p>Regular Expression</div>
这就是爱吗?
<p>Regular Expression</p>
,这,就是爱(破音)!
懂了,前后<>
内的标签名要一致,用反向引用。
const pairedTags = (str) => {
return /<([^>]+)>.*?<\/\1>/gs.test(str)
}
其实并不难,是你太悲观,隔着一道墙不跟谁分享。
<([^>]+)>
匹配开标签,<
与>
不是特殊字符,可以不用\
转义,([^>]+)
匹配任意一个以上的非>
字符,也就限定<>
不为空标签- 第一个括号
()
成全\1
.
与修饰符s
一起出现,匹配任何字符,中间允许出现换行。上文提到,我们还可以用\w\W
等类似的表达*
与?
一起出现,表示惰性匹配,就近找闭合标签
数字千分位分割
什么叫千分位分割?
就是将1234567890
转化为1,234,567,890
。
观察,
出现的位置,每 3 个数字前出现一次。
懂了,在 pattern 之前,用正向先行断言。
const division = (str) => {
return str.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
其实他们的招数我们都懂,没有什么不同。
\B
在此用来匹配字符间的位置,如果不加限制,将会在字符间的每个位置都插入逗号,
(\d{3})+
匹配以 3 个一组出现的数字,可以是 123 或 123456 等 3 个或 6 个连续数字等(?!\d)
表示负向先行断言,匹配后面不跟着数字的一个位置(?=(\d{3})+(?!\d))
将(\d{3})+(?!\d)
作为正向先行断言的 pattern,匹配一个后面以 3 的倍数连续出现的数字,且在这(d{3})+
后面不能跟着数字 的位置/\B(?=(\d{3})+(?!\d))/g
表示替换整个字符串中以 3 的倍数连续出现的数字,且其后面不能再出现数字的\B
的位置
本来想见好就收,以上 5 点留给你们细细品味,但又怕你们懒得仔细拆解推敲,何况我自己写的时候都晕。
那好啦,我代劳吧。
// 以 1234567.00 为例
const str = '123456.00'
str.replace(/\B/g, ',')
// '1,2,3,4,5,6,7.0,0'
// 在这个字符串中 \B 的效果就是匹配字符间的位置,因为没有任何限制,所以除了非字符 . 外的其他位置都插入了逗号,
str.replace(/\B(?=\d{3})/g, ',')
// '1,2,3,4,567.00'
// 没有限定结束条件,所以从后往前看,4, 3 2 1 的后面都满足有连续 3 个数字,所以它们之后都加了逗号,
str.replace(/\B(?=(\d{3})+)/g, ',')
// '1,2,3,4,567.00'
// 期望是限制每三个为一组,但效果跟之前一样,因为比如 3 可以看作后面跟着 456 3个连续数字
str.replace(/\B(?=\d{3}(?!\d))/g, ',')
// '1234,567.00'
// 因为我们的痛点是要找出连续 3 个数字后的那个位置,所以使用负向先行断言限制后面不能再是数字,但还没做对
str.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
// '1,234,567.00'
// 恭喜你发财,只要以 3 的倍数出现的连续数字前都得加逗号 ,
校验密码
光看小标题,一定有人就直接关标签页了,然后赞也不点😭(说的就是你👉)。
“这个简单,就不看了”
Really ?
那我加点限制性的描述:密码长度 6-12 位,由数字、小写字母和大写字母组成,但必须至少包括 2 种字符。
来吧,黑板给你,笔也给你,聚光灯也打给你,你写吧。
const checkPassword = (str) => {
return /^[a-zA-Z0-9]{6,12}$/.test(str)
// 因为有首尾锚点,所以不用修饰符 g
]
如果这是你的答案,那麻烦把笔还给我吧。
以上答案,如果不看“至少包括 2 种字符”的限定的话,确实没毛病。
那,至少包含 2 种字符,怎么“至少”呢?
懂了,某个位置一定要有数字,小写字母或大写字母,位置,零宽断言。
(感觉还是一步步推敲的写法比较有意思)
包含其中一种字符
比如,我想密码中必须包含数字,那么就是(?=.*[0-9])
。
这可以理解吧?
正向先行断言,pattern 为 .*[0-9]
,表示匹配一个位置,后面必须带有一个数字,数字前可以有任意个非换行字符。
所以,正则可以暂时改写为
let reg = /^(?=.*[0-9])[a-zA-Z0-9]{6,12}$/
不知道你有没有问题,反正我有两个问题:
- 为什么要加
.*
? - 为什么
(?=.*[0-9])
要放前面?
好,自问自答:
- 如果密码有要求必须以数字开头,那你不加
.*
确实没问题。 - 需要明白,零宽断言不改变位置,所以
[a-zA-Z0-9]{6,12}$
始终作用于整个字符串。
至少 2 种
比如,我想密码中必须包含数字与小写字母,那么就是(?=.*[0-9])(?=.*[a-z])
,那么接下来要做的就是排列组合任意两种就完成任务啦。
reg = /((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-Z]))^[0-9A-Za-z]{6,12}$/
(好像有点长啊!!!)
当然,我想聪明的你(如果你还没想到,这里换成“聪明的我”)一定想到了:“至少 2 种”的意思不就是“不能全是其中 1 种”吗?
reg = /(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/
// 这样写好像字少点
// (?!^[0-9]{6,12}$) 匹配不全是数字
所以最终答案可以是
const checkPassword = (str) => {
return /(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/.test(str)
]
我就只是站在巨人的肩膀上啦
其实本文的绝大部分内容都是出自以下两篇文章,我只是站在他们的肩膀上,稍稍润色而已。
陌生人,我也为你祝福
愿你有一个灿烂的前程
愿你有情人终成眷属
愿你在尘世获得幸福