正则表达式总结

291 阅读6分钟

正则表达式在日常工作中使用的频率还是比较高的, 并且可以高效地解决一些常见的字符串转换相关的问题, 但是很多时候往往使用得并不太顺, 归根到底, 还是它独有的元字符, 就像'乱码'一样, 让人头大, 所以这次针对正则元字符,做了一个总结.

内容概况

范围匹配

所谓的范围匹配, 其实就是一种模糊匹配, 他没有准确的说要匹配哪个字符或者哪几个字符, 它往往允许一个区间或者一定的数量范围.

量词

基本概念

正则中的量词主要是表达一个字符分组的数量, 常见的量词表达有以下几种

表达式描述
*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规定了三条'隔离政策':

  1. \w(数字,字母,下划线)与\W(非数字,非字母,非下划线)的边界
  2. \w与^的边界
  1. \w与$的边界
let bPos = /\b/g
let text = 'hello world'
console.log(text.replace(bPos, '🚩'))
// 🚩hello🚩 🚩world🚩

内部斗争\B

而\B的则刚好相反, 简单来说就是**\b之外的位置**

\B 规定的'隔离政策':

  1. \W(非数字,非字母,非下划线)与^的边界
  2. \W与$的边界
  1. \W与\W的边界
  2. \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), 这个其实不用记, 大家发现没有, 其实来来去去就四个字符(?, =, !, <), 规律也很明显:

  1. ?是必然存在部分,不解释.
  2. = 强调'是'的逻辑
  1. ! 强调'否'的逻辑
  2. < 强调'左右'的逻辑
    1. 没有它, 匹配到的全部都是pattern左侧位置
    2. 有了它, 匹配到的全部都是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

  1. 如果没有(?!^), 那么输出的字符串首位, 将会多一个逗号(,123,456,789); 因此, 此处 我们可以利用(?!pattern)来实现排除首位
  2. 注意^本身指的就是首位字符前面的间隙,所以: ‘首位之前以外的位置’, 其实就是首位之后的所有位置, 也就将首位逗号去除了.
  1. 我们要注意到(?=(\d{3})+)这里,为何(\d3)+后面要跟一个)这里, 为何(\d{3})+ 后面要跟一个? 试想一下, 如果没有这个会怎样?如果没有会怎样? 如果没有那么匹配过程应该是这样的:
    1. 第一次将789作为一组, 7前面的位置, 将是要被匹配到
    2. 第二次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

本案例中, 我们使用1,1, 2, 2,分别代替年月日.注意,前两种写法中的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就成为了一个普通的字符串, 而不具备变量的作用了!