正则表达式在日常工作中使用的频率还是比较高的, 并且可以高效地解决一些常见的字符串转换相关的问题, 但是很多时候往往使用得并不太顺, 归根到底, 还是它独有的元字符, 就像'乱码'一样, 让人头大, 所以这次针对正则元字符,做了一个总结.
内容概况
范围匹配
所谓的范围匹配, 其实就是一种模糊匹配, 他没有准确的说要匹配哪个字符或者哪几个字符, 它往往允许一个区间或者一定的数量范围.
量词
基本概念
正则中的量词主要是表达一个字符分组的数量, 常见的量词表达有以下几种
| 表达式 | 描述 |
|---|---|
| * | 0到正无穷 |
| ? | 0或1 |
| + | 1到正无穷 |
| a{m,} | 至少包含m个a字符 |
| a{m, n} | 包含m到n个a字符 |
| a{m} | 匹配m个a字符 |
这些都是很简答概念, 只是简单的记忆问题, 我们接下来要讨论的, 是因量词而产生的贪婪匹配和非贪婪匹配
贪婪匹配
在整体匹配成功的条件下, 尽可能多匹配;
说白了, 就是匹配成功了还要匹配,贪得无厌, 直到最后
我们正常的量词修饰下, 就是默认的贪婪匹配
'abaabaaabaaaab'.match(/.*b/g)
// 匹配结果: [ 'abaabaaabaaaab' ]
'aaa'.match(/a+/g)
// 匹配结果: [ 'aaa']
惰性匹配
在整体匹配成功的条件下, 尽可能少匹配;
也很简单, 就是一种匹配到一个, 那就算成功了
我们只需在量词后面加上问好, 就算开启了惰性匹配了
'abaabaaabaaaab'.match(/.*?b/g)
// 匹配结果: [ 'ab', 'aab', 'aaab', 'aaaab' ]
'aaa'.match(/a+?/g)
// 匹配结果: [ 'a', 'a', 'a' ]
小结:
- 默认量词修饰, 就是贪婪匹配
- 量词后加上问号, 就是惰性匹配
- 还要注意, 所谓的惰性匹配, 是单次匹配尽可能少
方括号
说到方括号, 其实很简道, 无非就是两个作用
- 表示一个范围
- 排除某个字符
| 表达式 | 描述 |
|---|---|
| [abc] | 表示匹配a,b,c三个字母中的一个 |
| [^abc] | 表示非a,b,c三个字母之外的字母 |
| [a-z] | 表示小写字母a-z |
| [A-Z] | 表示小写字母A-Z |
| [0-9] | 表示数字0-9 |
区间匹配
区间匹配是方括号的主要用途, 主要展示一个匹配的范围, 列举出备选项
// 校验手机号码是否正确
/1[23456789]\d{9}/.test('13222221234')
// true
区间非匹配
在方括号中使用脱字符^, 不是表示开头, 而是非的逻辑, 也就是不能有括号中展示的备选项
// 将非数字的全部去除
'123abc456'.replace(/[^0-9]+/, '')
// 123456
管道符
管道符, 也就是'|', 表达的是'或'的逻辑, 并从左至右匹配, 看似比较简单, 其实也有些'坑'
按正则顺序查找
正则中管道符两侧顺序不同, 会导致查找的顺序有所不同
// 案例一
'helloworld'.match(/hello|helloworld/)
// [ 'hello', index: 0, input: 'helloworld', groups: undefined ]
// 案例二
'helloworld'.match(/helloworld|hello/)
// [ 'helloworld', index: 0, input: 'helloworld', groups: undefined ]
由此我们可以总结: 用顺序不同的管道符正则表达式, 来查找同样的字符串, 结果会有所不同! 究其原因如下:
- 案例一中, 字符串helloworld先用正则左侧hello来查找, 根据从左到右的原则, 由于左侧是hello, 所以会率先查找hello, 因此结果为hello
- 而在案例二中, 左侧是helloworld, 所以就是helloworld
按字符串的顺序返回
前面一个点比较好理解, 那就是以‘|’来分割正则, 谁在更左, 就先匹配谁, 那什么叫做按字符串顺序返回? 我们再来看一个案例
// 案例一
'aaabbbccc'.match(/aa|bb|cc/g)
// [ 'aa', 'bb', 'cc' ]
// 案例一
'aaabbbccc'.match(/cc|bb|aa/g)
// [ 'aa', 'bb', 'cc' ]
我们可以看到, 尽管正则的顺序不同, 但是, 由于待匹配的字符串的顺序是一样的, 所以, 返回的次序,仍然是[ 'aa', 'bb', 'cc' ], 也就是说, 我先找到了某个字符, 不代表我要先返回它
匹配剩余
通过以上两点的学习, 我们知道, 管道符匹配的查找和返回是两回事! 那么, 这个匹配剩余, 便可以充分证明这点!匹配剩余, 意思就是, 当一个子模式匹配掉了字符串的某一部分的时候, 下一个子模式只能匹配剩余的部分, 而非从全部开始重新匹配! 具体请看案例:
// 案例一
'aaabbb'.match(/ab|a/g)
// [ 'a', 'a', 'ab' ]
// 案例二
'aaabbb'.match(/a|ab/g)
// [ 'a', 'a', 'a' ]
那么为何会出现这个结果呢?
- 案例一中, 按照之前的说法, 我们先查找到ab, 然后,原字符串相当于就‘只剩下’2个a了!, 此时管道符后面的a开始匹配, 也就是有2个a, 然后, 再按照字符串的顺序返回, 就变成了: [ 'a', 'a', 'ab' ]
- 而案例二, 我们直接先查找到了a, 此时, 会一直循环匹配, 将三个a先行'匹配殆尽', 到了管道符后一个子模式的时候, 也就相当于只剩下: 3个b了, 所以, 再想匹配ab, 已经匹配不到了, 于是结果就剩下了3个a
位置匹配
这部分要总结的都是以'位置'为核心的匹配,这里的'位置'指的就是字符间的空隙, 这类匹配方式往往不是说精确匹配某个字符, 而是以这些字符间的空隙作为匹配的依据.
首位标兵^
let startPos = /^/
let str = 'hello world'
console.log(str.replace(startPos, '🚩'))
// 🚩hello world
末尾成员$
let endPos = /$/
console.log(str.replace(endPos, '🚩'))
// hello world🚩
'种族'隔离\b
\b匹配的是一个位置, 而这个位置,通常是\w([0-9a-zA-Z_])和其他字符的边界, 所以我们可以理解为, \b将\w和其他的字符'隔离'开了, 它的核心是\w!
\b规定了三条'隔离政策':
- \w(数字,字母,下划线)与\W(非数字,非字母,非下划线)的边界
- \w与^的边界
- \w与$的边界
let bPos = /\b/g
let text = 'hello world'
console.log(text.replace(bPos, '🚩'))
// 🚩hello🚩 🚩world🚩
内部斗争\B
而\B的则刚好相反, 简单来说就是**\b之外的位置**
\B 规定的'隔离政策':
- \W(非数字,非字母,非下划线)与^的边界
- \W与$的边界
- \W与\W的边界
- \w与\w的边界
let wish = '123_..._abc'
let BPos = /\B/g
console.log(wish.replace(BPos, '🚩'))
// 1🚩2🚩3🚩_.🚩.🚩._🚩a🚩b🚩c
前后查找
'断言家族'主要成员: (?=pattern)(?!pattern)(?<=pattern)(?<!pattern), 这个其实不用记, 大家发现没有, 其实来来去去就四个字符(?, =, !, <), 规律也很明显:
- ?是必然存在部分,不解释.
- = 强调'是'的逻辑
- ! 强调'否'的逻辑
- < 强调'左右'的逻辑
-
- 没有它, 匹配到的全部都是pattern左侧位置
- 有了它, 匹配到的全部都是pattern右侧位置
小结: 所以我们实际上只需要记住 (?=pattern)和(?<=pattern)的位置就可以了
如果还是不理解, 通过以下案例我们来熟悉下
(?=pattern)
匹配到pattern的左侧开头位置
let xianPos = /(?=初一)/g
let happlyNewYearTips = '初一要去拜年, 初一能拿红包'
console.log(happlyNewYearTips.replace(xianPos, '(前一天是除夕)'))
// (前一天是除夕)初一要去拜年, (前一天是除夕)初一能拿红包
(?!pattern)
匹配到pattern的左侧开头以外的位置
let antiXianPos = /(?!初一)/g
console.log(happlyNewYearTips.replace(antiXianPos, '🚩'))
// 初🚩一🚩要🚩去🚩拜🚩年🚩,🚩 初🚩一🚩真🚩开🚩心🚩
(?<=pattern)
匹配到pattern的右侧结尾位置
let houXingPos = /(?<=初一)/g
console.log(happlyNewYearTips.replace(houXingPos, '(后一天是初二)'))
// 初一(后一天是初二)要去拜年, 初一(后一天是初二)能拿红包
(?<!pattern)
匹配到pattern的右侧结尾以外的位置
let antHouXingPos = /(?<!初一)/g
console.log(happlyNewYearTips.replace(antHouXingPos, '🚩'))
// 🚩初🚩一要🚩去🚩拜🚩年🚩,🚩 🚩初🚩一能🚩拿🚩红🚩包🚩
案例
在实际工作中,我们可以看到很多需要以'位置'作为匹配依据的案例,诸如: 千分位分割, 手机号码分割, url参数获取等等.我们来一个个看看吧.
千分位分割
我们都知道, 千分位是从右到左, 每三位加上一个逗号, 这在现实开发中也比较常见.
// (\d{3})+$ 表示3个数字一组
let num = '123456789'
let qianfenweiReg = /(?=(\d{3})+$)/g
qianfenweiReg = /(?!^)(?=(\d{3})+$)/g
结果: 123,456,789
- 如果没有(?!^), 那么输出的字符串首位, 将会多一个逗号(,123,456,789); 因此, 此处 我们可以利用(?!pattern)来实现排除首位
- 注意^本身指的就是首位字符前面的间隙,所以: ‘首位之前以外的位置’, 其实就是首位之后的所有位置, 也就将首位逗号去除了.
- 我们要注意到(?=(\d{3})+? 试想一下, 如果没有这个那么匹配过程应该是这样的:
-
- 第一次将789作为一组, 7前面的位置, 将是要被匹配到
- 第二次678也是一组! 这样问题就来了, 后续匹配结果就成了: 1,2,3,4,5,6,7,890! 这显然不符合我们的要求, 所以, 加上了$, 也就意味着给正则划定了一个边界, 确定了一种模式: 即: (分组)(分组)...末位
手机号码分割
同样的道理, 我们可以利用(?=pattern)来给手机号码增加分割符
let phone = '18888888888'
let phoneReg = /(?=(\d{4})+$)/g
console.log(phone.replace(phoneReg, '-'))
// 输出结果: 188-8888-8888
url参数获取
url参数的获取可以说也是一个很常见的功能点, 也是很多面试题中经常出现的考点.解决方法有很多, 但我们这里既然是讨论正则, 那自然是用正则的方式解决.
// 带参数的url路径
let url = 'https://www.xxx.com/path/?name=jack&age=18&gender=male#'
// 键的正则
let regKey = /(?<=(?|&))(.*?)(?==)/g
// 值的正则
let valueKey = /(?<==)(.*?)(?=(&|#|$))/g
// 使用分支结构将两种逻辑结合
let reg = /((?<=(?|&))(.*?)(?==)|(?<==)(.*?)(?=(&|#|$)))/g
// 匹配
let arr = url.match(reg)
console.log(arr) // [ 'name', 'jack', 'age', '18', 'gender', 'male' ]
子模式匹配
我们在写代码中, 经常喜欢使用变量, 来表示一个反复出现的字符/逻辑等, 在正则中也不例外, 对于一些反复出现的字符我们可以将它们编组为一个'子模式',然后对其进行一些修改或者提取的操作, 通常, 这个工作由元字符括号来完成
分组
分组非常好理解, 就是我们将某几个字符, 组合成一个'分组', 有利于我们能使用一个正则匹配多种情况的字符
- 可以让重复项更加优雅地表达出来
'aaabababbbb'.match(/(ab){3}/g)
// [ 'ababab' ]
- 配合管道符, 将可变和不可变部分分开, 实现多种情况的匹配
let xiaoming = 'I am come from hebei'
let xiaohong = 'I am come from jiangxi'
let reg = /I am come from (hebei|jiangxi)/
console.log(reg.test(xiaoming)) // true
console.log(reg.test(xiaohong)) // true
本案例中, 将可变部分可变部分用括号+管道符来进行分组, 这样,我们就无需写两个正则来匹配了
回溯引用
如果我们只是像上面那样, 简单使用分组, 那么, 可以说起的作用也不是很大, 无非就是展示所谓的‘优雅’而已. 所以, 这里还要介绍一下回溯引用!用了他,我们才能写出更高级的正则表达式!
回溯引用是一个非常强大的功能, 其实就是将已经匹配到的字符通过变量的形式表达出来
- 代表已经匹配了的字符
// 假如我们需要将以下文字中, 包裹文字内容的标签全部换成p标签
let html = `
<header>小明的简历</header>
<main>
<div>
<span>姓名:</span>
<span>小明</span>
</div>
<div>
<span>职位:</span>
<span>web前端开发</span>
</div>
</main>
<footer>我是有底线的</footer>
`
let reg = /<(header|span|footer|[h][1-9])>(.*)</\1>/g
let result = html.replace(reg, (_, $0, $1) => {
// _: 匹配到的文字部分, 诸如: <b>小明的简历</b>, <span>小明</span>等
// $0: 标签名
// $1: 中文部分, 也就是夹在标签中间的部分
return `<p>${$1}</p>`
})
console.log('🚀 ->', result)
// 输出: 🚀 ->
// <p>小明的简历</p>
// <main>
// <div>
// <p>姓名:</p>
// <p>小明</p>
// </div>
// <div>
// <p>职位:</p>
// <p>web前端开发</p>
// </div>
// </main>
// <p>我是有底线的</p>
注意,这里的\1,所代表的就是第一个分组匹配到的字符, 在本案例中就是匹配到的各种标签, 我们的标签有很多种header,span,footer等等, 而有了回溯引用,我们就无需把后面的闭标签再写上那么多情况, 只需要用'</\1>'代替即可!
- 分组捕获
正则支持分组捕获, 分组的结果会被存储在RegExp对象的$1-9的静态属性中, 注意, 最多只能匹配到9个分组!
若使用replace方法来做正则分组捕获
// 本案例中, 我们的需求是将'年-月-日'格式改为'月/日/年'格式
let date = '2022-02-25'
let reg = /(\d{4})-(\d{2})-(\d{2})/
// 写法1
let newDate = date.replace(reg, () => {
return RegExp.$2 + '/' + RegExp.$3 + '/' + RegExp.$1
})
// 写法2
let newDate = date.replace(reg, '$2/$3/$1')
// 写法3
let newDate = date.replace(reg, (_, $1, $2, $3) => {
return $2 + '/' + $3 + '/' + $1
})
//输出结果: 02/25/2022
本案例中, 我们使用2, 变量, 都是从'1'开始, 而非0! 第三种写法实际是利用了replace第二个参数为函数的写法, 注意, 该函数的第一个参数并非分组的变量, 而是匹配到的整个字符串!
- 非捕获匹配
有时候, 我们可能只需要匹配某个或某几个字符, 但是不需要捕获它, 所以, 非捕获匹配就派上用场了!
// 先看这个案例
let str = 'abc123def'
let reg = /(abc)(\d*)(def)/
let result = str.replace(reg, '$1,$2,$3')
console.log('🚀 ~ file: index.js ~ line 3 ~ result', result)
// 通过前面的学习,我们应该很轻松得出答案:
// 🚀 -> abc,123,def
// 但是如果我们不想捕获数字部分呢?
let reg = /(abc)(?:\d*)(def)/
// 🚀 -> abc,def,$3
由此可见, 当我们使用(?:)非捕获匹配的时候, 分组变量'自动向前靠了一位', $3就成为了一个普通的字符串, 而不具备变量的作用了!