靜下心来--重温正则表达式(一)

  关于正则表达式,大家都要这样的经历:看完之后觉得懂了,但是过一段时间就忘记,自己写的时候更是到处查资料。鉴于这种情况,总结了自己关于正则表达式的学习心得,以供大家参考。本文将从正则表达式的基础概念讲起,紧接着总结跟正则表达式相关的一些常用方法,以及正则表达式的匹配方式。


1、基础概念

  正则表达式(Regular Expression)是一种用来描述规则的表达式,其目的是为了根据特定的规则对字符串匹配,从而实现替换和搜索的功能。正则表达式的匹配其实不外乎两种:匹配字符(匹配内容)和匹配位置(断言匹配)。如何构建准确的正则表达式呢?

   有2种方式可以创建一个正则表达式RegExp对象:字面量、构造函数。其中,pattern正则表达式的文本,flag 匹配标志,包含 g 、i、m 等

  • 字面量:/pattern/flags
 const regx = /abc/gi
  • 构造函数: new RegExp(pattern[, flags])
 const regx = new RegExp('adc', 'gi')

   构造一个正则表达式,首先要有能代表匹配内容的特定字符,也就是元字符;其次,还要确定元字符的数目,这就需要限定符,也叫量词。如果需要获取匹配字符串中的局部内容时,还需要引入原子组(括号)的概念,针对复杂的正则表达式,还需要多选分支。

1.1、元字符

   元字符也有叫字符组,元字符是构造正则表达式的一种基本元素,代表一种字符的可能。

元字符说明
.通配符,匹配除了少数字符(\n)之外的任意字符
\d(digital),匹配数字,等价于 [0-9]
\D匹配非数字,等价于 [^0-9]
\w(word)匹配字母或数字或下划线,等价于[a-zA-Z0-9_]
\W匹配非单词字符,等价于 [^a-zA-Z0-9_]
\s(space)匹配空白符,等价于 [ \t\v\n\r\f]
\S匹配非空白符,等价于 [^ \t\v\n\r\f]]
^匹配字符串的开始
$匹配字符串的结束
[abc]匹配 “a”、“b”、“c” 其中任何一个字符。
[^abc]匹配除了 “a”、“b”、“c” 之外的任何一个字符

1.2、限定符

   有了元字符就能很方便的匹配对应的字符,而限定符(也叫量词)的作用就是确定元字符的数量,也即重复的次数。常用的元字符如下:

限定符说明
*重复零次或更多次, 等价于 {0,}
+重复一次或更多次,等价于 {1,n}
?重复零次或一次,等价于 {0,1}
{m}重复m次
{m,}至少重复出现m次
{m,n}连续出现 m 到 n 次

1.3、修饰符

   修饰符对正则表达式匹配过程中进一步设置。

符号说明
g全局匹配,找到所有满足匹配的子串
i匹配过程中,忽略英文字母大小写
m多行匹配,把 ^ 和 $ 变成行开头和行结尾

1.4、原子组

   原子组简单来讲就是通过括号提供分组,每一个括号都是一个子表达式。如下例所示,存在2个原子组(\d{1-2})、(\d{3-4}),他们分别代表匹配 1-2 数字、匹配 3-4 数组,由于正则表达式默认采用贪婪匹配(下文会有介绍),也就是尽可能多的匹配内容。因此最终的匹配结果是 617628,第一个原子组匹配的内容是 61, 第二个原子匹配的内容是 7628。实际操作过程中,原子组通常与字符串的 match 、正则的 exec 配合使用,这2个方法下文也会详细介绍。

// 原子组 
const str = '61762828 176 2991 87132425'
const regx = /(\d{1,2})(\d{3,4})/
const result = str.match(regx)

   // 0: "617628"   value
   // 1: "61"       $1
   // 2: "7628"     $2
   // groups: undefined
   // index: 0
   // input: "61762828 176 2991 87132425"

   如果存在多个原子组,且有嵌套的情况下,如何确定原子组的排序啦? 采用的是深度优先的匹配方法,通俗的讲就是从左到右数左括号,第一个括号记 1 ,第二个记 2 ,依次类推。为什么计数从 1 开始,因为在 match 等方法的匹配中,第 0 个代表的是正则正则匹配的结果。
  改造上述实例,在整个正则表达式上添加括号,从左到右数括号,原子组分别为 ((\d{1,2})(\d{3,4}))、(\d{1,2})、(\d{3,4})

// 原子组 
const str = '61762828 176 2991 87132425'
const regx = /((\d{1,2})(\d{3,4}))/
const result = str.match(regx)

    // 0: "617628"   value
    // 1: "617628"   $1
    // 2: "61"       $2
    // 3: "7628"     $3
    // groups: undefined
    // index: 0
    // input: "61762828 176 2991 87132425"

1.4.1、反向引用

  反向引用就是对正则表达式里已有的分组引用。
  例如,通过正则表达式匹配 html 标签,如下例所示,当前正则表达式对于 str2 的检测也是通过的,显然不能满足我们的要求。

const regx = /<\w*>.*<\/\w*>/
const str1 = '<div>哈哈</div>'
const str2 = '<div>哈哈</span>'
const result1 = regx.test(str1)  // true
const result2 = regx.test(str2)  // true

   针对上述问题,原子组的反向引用就派上用场了。如下所示, \1 标识与第一个原子组 (\w*) 匹配的内容相同, 此时str2 就不能通过正则的检验。

const regx = /<(\w*)>.*<\/\1>/
const str1 = '<div>哈哈</div>'
const str2 = '<div>哈哈</span>'
const result1 = regx.test(str1)  // true
const result2 = regx.test(str2)  // false

1.4.2、原子组别名

   原子组别名是对原子组重新命名,默认情况下原子组编号是从 1 递增。当存在多个原子的时候,原子组命名有极大的应用场景。原子组别名的语法如下:

    原子命名:?<key>    
    原子组引用: \k<key>

   其中 key 值为原子组的别名,前后保持一致。

   例如将第一个分组命名为 tagName,在后续就可以通过 \k 对原子组进行反向引用。对原子组命名后,执行 match 方法在返回结果的 groups 字段中也有体现。

const regx = /<(?<tagName>\w*)>.*<\/(\k<tagName>)>/
const str1 = '<div>哈哈</div>'
const result1 = str1.match(regx)

// 0: "<div>哈哈</div>"
// 1: "div"
// 2: "div"
// groups: {tagName: 'div'}
// index: 0
// input: "<div>哈哈</div>"
// length: 3

1.4.3、非捕获组

上面使用的括号都会匹配他们匹配到的数据,以便后续引用,所以也可以称为捕获型分组和捕获型分支。如果想要括号最原始的功能,但不会引用它,也就是既不会出现在API引用里,也不会出现在正则引用里,可以使用非捕获组。

  通俗讲:非捕获分组,只会匹配内容,但不是出现在反向引用中(不会出现在原子组编号中,即原子组编号时跳过当前括号),或者对应的一些 API (match、replace 等)的结果中。

    (?:pattern)

   如下例所示,有 2 个括号,但是第 2 个括号被设置为非捕获组,因此,在最终的结果中没有索引为 2 内容。

const regx = /<(\w*)>(?:.*)<\/\1>/
const str1 = '<div>哈哈</div>'
const result1 = str1.match(regx)

// 0: "<div>哈哈</div>"
// 1: "div"
// groups: undefined
// index: 0
// input: "<div>哈哈</div>"
// length: 2

1.5、多选分支

   多选分支意味着正则表达式有多个子模式可以匹配,用 |(管道符)分隔,表示其中任何之一。

const regex = /good|nice/g;   // 存在字符串 “good” 或者 “nice”
const string = "good idea, nice try.";
console.log( string.match(regex) );
// => ["good", "nice"]

   下一个例子说明,正则表达式的分支匹配是惰性的,也即当前的子表达式匹配成功后,后边的就不再尝试。有点类似 js 的短路运算符。

const regex = /good|goodbye/g;
const string = "goodbye";
console.log( string.match(regex) );
// => ["good"]

const regex = /goodbye|good/g;
const string = "goodbye";
console.log( string.match(regex) );
// => ["goodbye"]

2、常用方法

   对正则表达式的基础知识有一定认知之后,我们有必要了解与正则表达式相关的一些常用方法,其中 repalce、match是定义在 String 对象的原型上,exec、test 是定义在 RegExp 的对象原型上。

2.1、 String.prototype.replace

replace() 方法返回一个由替换值(replacement)替换部分或所有的模式(pattern)匹配项后的新字符串。模式可以是一个字符串或者一个正则表达式,替换值可以是一个字符串或者一个每次匹配都要调用的回调函数。

使用方式 :str.replace(regexp|substr, newSubStr|function)

   上面这句话来自 MDN 中对 replace 方法的说明。通俗来说,字符串的replace方法接受2个参数,第一个参数是需要被替换的内容,可以是字符也可以是正则表达式;第二个参数即将被替换的内容,可以是字符串,也可以是函数返回值(返回字符串)。

   主要针对第二个参数分 2 中情况讨论;

  1. 第二个参数是字符串。

const str = 'abcddcba';

// 第一个参数、第二个参数都是字符串
const newStr1 = str.replace('a', 'A')  // Abcddcba

// 第一个参数是正则,第二个参数都是字符串
const newStr2 = str.replace(/a/, 'A')  // Abcddcba

// 第一个参数是正则,全局多次匹配,相当于 String.prototype.replaceAll
const newStr2 = str.replace(/a/g, 'A')  // 'AbcddcbA'

  2. 第二个参数是函数,此时主要针对第一个参数是正则表达式进行处理的。

const str = 'abc12345#$*%11111111111';
// p0为正则表达式匹配的内容,p1 表示第一个原子组匹配的内容,p2 为第二个原子匹配的内容
function replacer(p0, p1, p2, p3, offset, string) {
  return [p1, p2, p3].join(' - ');
}

const newStr = str.replace(/([^\d]*)(\d*)([^\w]*)/, replacer);  // 'abc - 12345 - #$*%11111111111'

  此时需要重点关注的是 replacer 的入参,

1、第一个入参 p0, 代表正则表达式匹配的字符串,此处为 “abc12345#$*%”
2、p1, p2, p3 ... pn, 代表的是正则表达式中各个原子组匹配的值,当前实例中有 3 个原子组,因此代表原子组的入参也有 3 个,分别是 p1、p2、p3, 他们匹配的值分别为 “abc”、“12345”、“#$*%”
3、offset : 匹配子字符串在整个字符串表达式中位置;
4、string : 被匹配的原字符串,及当前实例中的 str

   函数返回值,就是对应替换正则表达式匹配的内容,当前正则表达式匹配的是 “abc12345#KaTeX parse error: Undefined control sequence: \* at position 1: \̲*̲%",替换后变成 "abc -…*%”,因此最终返回 “abc - 12345 - #$*%11111111111”。

2.2、 String.prototype.match

   match 方法检索返回一个字符串匹配正则表达式的结果。

使用方式 :str.match(regexp)

   根据正则表达式是否使用了 g 标志,match 方法返回的结果有所不同,通过实例分别来看下。

  1. 使用了 g 标志,则返回与正则表达式匹配的所有结果,不会返回每一个匹配的具体详情(例如原子组信息)

const str = 'aabcdaaaadcbaa';
const result = str.match(/(a+)/g)  //  ['aa', 'aaaa', 'aa']

  2. 没有使用了 g 标志,则仅返回第一个完整匹配及其相关的捕获组信息。 其返回的数据是一个 Array,第一元素是完全匹配的结果(索引 0 ),第二个元素是第一个原子组匹配的结果(索引 1 ),以此类推;同时返回结果还有额外属性。

  • groups : 命名的原子组统计;
  • index :匹配的结果的开始位置
  • input : 搜索的字符串
const str = 'aabcdaaaadcbaa';
const result = str.match(/(a+)/) 
// 0: "aa"
// 1: "aa"
// groups: undefined
// index: 0
// input: "aabcdaaaadcbaa"
// length: 2   // array 的长度

   如果你想要获得捕获组,并且设置了全局标志,有 2 种实现方式:

  1. 通过 RegExp.prototype.exec 方法,并配合 while 循环;
  2. 将 match 方法替换为 matchAll 方法。但是需要注意 matchAll 方法存在浏览器兼容性问题,具体可以查看:matchAll 兼容性

2.3、 RegExp.prototype.test

   test 方法相对其他方法来说比较简单,来查看正则表达式与指定的字符串是否匹配,结果返回 boolean 值。

使用方式 :regexObj.test(str)
const str = 'hello world!';
const result = /^hello/.test(str);  // true

   当正则表达式设置了全局标志 g ,test() 的执行会改变正则表达式 lastIndex 属性。连续的执行test()方法,后续的执行将会从 lastIndex 处开始匹配字符串。

lastIndex 是正则表达式的一个可读可写的整型属性,用来指定下一次匹配的起始索引。
如果 lastIndex 大于字符串的长度,则 regexp.test 和 regexp.exec 将会匹配失败,然后 lastIndex 被设置为 0。
如果 lastIndex 等于或小于字符串的长度,则该正则表达式匹配从 lastIndex 位置开始的字符串。
– 如果 regexp.test 和 regexp.exec 匹配成功,lastIndex 会被设置为紧随最近一次成功匹配的下一个位置
– 如果 regexp.test 和 regexp.exec 匹配失败,lastIndex 会被设置为 0

const str = 'hello world hello world';
const regx = /hello/g
regx.test(str);                // true
console.log(regx.lastIndex)    // 5
regx.test(str);                // true
console.log(regx.lastIndex)    // 17
regx.test(str);                // false 
console.log(regx.lastIndex)    // 0 匹配失败重新设置为0

2.4、 RegExp.prototype.exec

   在上文中有提到过,如果设置了全局的标记 g,又想要匹配的捕获组详情,则可以通过 exec 方法配合 while 循环来实现。
   exec() 方法在一个指定字符串中执行一个搜索匹配,返回一个结果数组或 null,其返回的格式总是包含捕获组的详情,即包含匹配结果、原子组匹配结果、groups 属性等,与 match 没有设置全局标记g 时返回的格式完成一致。

  1. 没有设置全局标记 g ,则此时 exec 方法返回第一个匹配的内容, 且不改变 lastIndex 的值。

const str = 'aabcdaaaadcbaa';
const regx = /(a+)/
const result = regx.exec(str)  
console.log(regx.lastIndex)   // 0
// 0: "aa"
// 1: "aa"
// groups: undefined
// index: 0
// input: "aabcdaaaadcbaa"
// length: 2   // array 的长度

  2. 设置全局标记 g ,则此时 exec 方法返回当前匹配的内容, 且将上次成功匹配后的位置记录在 lastIndex 属性中,这一点与 test 方法一致。

const str = 'aabcdaaaadcbaa';
const regx = /(a+)/g
const result = regx.exec(str)  
console.log(regx.lastIndex)   // 2
// 0: "aaaa"
// 1: "aaaa"
// groups: undefined
// index: 5
// input: "aabcdaaaadcbaa"
// length: 2   // array 的长度

const result = regx.exec(str)  
console.log(regx.lastIndex)   // 9

  了解了 exec 的特性之后,就可以通过配合 while 循环实现全局匹配,并能得到每一次匹配成功后的捕获组详情。

const str = 'aabcdaaaadcbaa';
const regx = /(a+)/g
let result = null 
while((result = regx.exec(str)) !== null ){
  console.log(regx.lastIndex, result)
}

// 2 ['aa', 'aa', index: 0, input: 'aabcdaaaadcbaa', groups: undefined]
// 9 ['aaaa', 'aaaa', index: 5, input: 'aabcdaaaadcbaa', groups: undefined]
// 4 ['aa', 'aa', index: 12, input: 'aabcdaaaadcbaa', groups: undefined]

3、匹配方式

3.1、贪婪匹配

  当正则表达式中包含能接受重复的限定符时,在使整个表达式能得到匹配的前提下,通常的行为是匹配尽可能多的字符,这种匹配方式叫做贪婪匹配。

const str = '61762828 176 2991 87132425'
const regx = /(\d{1,2})(\d{3,4})/g
const result = str.match(regx)

// "617628" 是前面的\d{1,2}匹配出了61,后面的匹配出了7628
// "2991" 是前面的\d{1,2}匹配出了2 ,后面的匹配出了991(满足匹配优先,再最大程度的贪婪)
// "871324"是前面的\d{1,2}匹配出了87,后面的匹配出了1324

   贪婪匹配满足1个前提,2个原则:

   前提: 满足匹配优先
   原则: 1、最大程度贪婪;2、“先下手为强”。

   针对第一点,在例子上 “2991”能够匹配就是先满足的优先匹配,然后再贪婪的。上述正则表达式可以理解为匹配数字 4-6 位,也就是说满足 4-6 的数字能被匹配,这是前提。然后再满足各个原子组尽可能的贪婪,如果第一个原子组贪婪得到 2 位,则后一个原子组无法匹配成功,这就无法满足匹配优先,因此第一个原子组的匹配 1 位,后一个原子组匹配 3 位。

   针对第二点,来看下例。首先字符串 “12345” 能够匹配正则表达式,满足优先匹配,根据第二点,“先下手为强”,此时第一个原子组匹配的结果就是 3 位,后一个就是 2 位。

var string = "12345";
var regex = /(\d{1,3})(\d{1,3})/;
console.log( string.match(regex) );
// => ["12345", "123", "45", index: 0, input: "12345"]

3.2、惰性匹配

  当正则表达式中包含能接受重复的限定符时,在使整个表达式能得到匹配的前提下,匹配尽可能少的字符,这种匹配方式叫做懒惰匹配。懒惰量词是在贪婪量词后面加个 ? 。

   贪婪匹配满足1个前提,1个原则:

   前提: 满足匹配优先
   原则:尽可能的少

  在下面的例子,前一个原子组是惰性匹配,后一个原子组是贪婪匹配。最终的匹配结果是 “1234”, 此时满足匹配优先的前提,同时第一个原子尽可能的少匹配,后一个原子组是尽可能多匹配。

const string = "12345";
const regex = /(\d{1,3}?)(\d{1,3})/;
// => ["1234", "1", "234", index: 0, input: "12345"]

  在下面的例子,前一个原子是惰性匹配,后一个原子组是贪婪匹配,但因为正则表达式包含匹配开始位置和结束位置,尽管前一个是贪婪匹配,如果继续匹配 1 位,那就无法满足整个表达式匹配成功。但是为了满足优先匹配的前提,因此第一个原子组匹配的结果是 “12”,第二个原子组的匹配结果是 “345”

const string = "12345";
const regex = /^(\d{1,3}?)(\d{1,3})$/;
// => ['12345', '12', '345', index: 0, input: '12345', groups: undefined]
//知道你不贪、很知足,但是为了整体匹配成,没办法,也只能给你多塞点了。因此最后 \d{1,3}? 匹配的字
//符是 "12",是两个数字,而不是一个。

3.3、小结

  不管是贪婪匹配还是惰性匹配,其前提都是优先满足匹配成功。很多时候为了满足优先匹配的前提,贪婪匹配也不一定能够匹配到最多的字符,同样的,为了满足优先匹配的前提,惰性匹配也不一定匹配的是最少的字符。
  为什么会有上述这些现象,就需要了解一定的匹配原理 - 正则表达式回溯法,这个会在下一篇文章中详细介绍;

4、总结

  通过这些文章的学习,你应该可以了解到正则表达式的一些基本知识,并通过配合正则表达式常用的方法,能够解决一些日常开发中的问题。但对于正则表达式的断言匹配,正则表达式的匹配原理 - 回溯法,以及如何优化正则表达式,这些内容将在下一个篇章中详细介绍,敬请关注。


参考文献:

  1. https://github.com/qdlaoyao/js-regex-mini-book/blob/master/JavaScript正则表达式迷你书(1.1版).pdf
  2. https://juejin.cn/post/6844903677119954958#heading-1
  3. https://juejin.cn/post/6844903680349585422#heading-0
  4. https://juejin.cn/post/7021672733213720613#heading-23
  5. https://www.bilibili.com/video/BV12J41147fC
  6. https://blog.csdn.net/ybdesire/article/details/78255427
  7. https://www.jianshu.com/p/fb3afbf8da10
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值