JS高级 - 正则表达式

185 阅读16分钟

正则表达式(英语:Regular Expression,常简写为regex、regexp或RE)是计算机科学的一个概念,许多程序设计语言都支持利用正则表达式进行字符串操作。

正则表达式是一种字符串匹配利器,可以帮助我们搜索、获取、替代字符串

在JavaScript中,正则表达式使用RegExp类来创建,也有对应的字面量的方式

正则表达式主要由两部分组成:模式(patterns)和修饰符(flags)

// 参数1 为 pattern 参数2 为 flags
const reg1 = new RegExp('foo', 'ig')

// 可以省略new 不推荐
const reg2 = RegExp('foo', 'gi')

// /pattern/flags
const reg3 = /foo/gi

const str = 'foobazbar'

console.log(reg1.test(str)) // => true
console.log(reg2.test(str)) // => true
console.log(reg3.test(str)) // => true

基本使用

  1. JavaScript中的正则表达式被用于 RegExp 的 exec 和 test 方法
  2. 也包括 String 的 match、matchAll、replace、search 和 split 方法

image.png

// 使用方式一: 使用正则的方法
const reg = /Hello/i
const str = 'Hello World'
console.log(reg.test(str)) // => true
// 使用方式二 使用字符串的方法
const str = 'Lorem23 ipsum dol323or sit 4312amet consec321334tetur adipis3213icing elit. '

// 如果replace或replaceAll方法对应的正则匹配失败,那么对应的方法会静默失效
console.log(str.replace(/\d+/g, '')) // => Lorem ipsum dolor sit amet consectetur adipisicing elit.

// 对于replaceAll方法 第一个传入的参数的类型为正则时,必须是全局查找的正则
// 否则正则表达式无法查找到所有的匹配项,并依次进行替换
console.log(str.replaceAll(/\d+/gi, '')) // => Lorem ipsum dolor sit amet consectetur adipisicing elit.
const str1 = 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.'
const str2 = 'Lorem'
const reg = /\d+/ig

// exec 使用正则表达式来解析一个字符串
// exec方法返回的是第一个匹配到的结果的详细信息
// exec无论是否开启全局匹配,都只能匹配到第一个符合条件的结果
// 所以实际很少使用exec,而是使用match或matchAll方法
console.log(reg.exec(str1))
/*
  =>
    [
      '3232',
      index: 6,
      input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
      groups: undefined
    ]
*/

// 如果没有匹配到 exec方法会返回null
console.log(reg.exec(str2)) // => null
const str1 = 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.'
const str2 = 'Lorem234'
const str3 = 'Lorem'
const reg1 = /\d+/ig
const reg2 = /\d+/i

// 如果匹配到的结果是全局匹配 那么会输出匹配到的对应结果组成的数组
console.log(str1.match(reg1)) // => [ '3232', '32132', '3123', '3213123' ]
// 即使匹配到的只有一个结果时候,也会输出由匹配结果组成的数组
console.log(str2.match(reg1)) // => ['234']

// 如果匹配字符串没有开启全局匹配,那么会输出第一个符合条件的结果的详细信息,
// 此时match方法的功能和exec方法的功能类似
console.log(str1.match(reg2))
/*
  =>
    [
      '3232',
      index: 6,
      input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
      groups: undefined
    ]
*/

// 如果没有匹配到 就会返回null
console.log(str3.match(reg2)) // => null
const str1 = 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.'
const str2 = 'Lorem234'
const str3 = 'Lorem'
const reg1 = /\d+/ig
const reg2 = /\d+/i

// matchAll方法 传入的 正则表达式 必须开始全局匹配
// 该方法返回的结果是一个 迭代器
// 返回的迭代器自身是一个可迭代对象(可以使用for-of迭代),其也是一个迭代器(可以调用对应的next方法)
console.log(str1.matchAll(reg1)) // => Object [RegExp String Iterator] {}

// 我们可以调用迭代器的next方法进行遍历
// 或者我们也可以使用for-of方法来遍历迭代器中每一个元素
// matchAll方法返回的迭代器中存放的元素 是 每一个 匹配项的详细匹配信息
for (const item of str1.matchAll(reg1)) {
  console.log(item)
}
/*
  =>
    [
    '3232',
    index: 6,
    input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
    groups: undefined
  ]
  [
    '32132',
    index: 21,
    input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
    groups: undefined
  ]
  [
    '3123',
    index: 32,
    input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
    groups: undefined
  ]
  [
    '3213123',
    index: 42,
    input: 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.',
    groups: undefined
  ]
*/
const str1 = 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.'
const str2 = 'Lorem'
const reg = /\d+/ig

// search方法会返回 第一个匹配项所在的第一个元素的索引值
// 也就是说无论是否开启全局匹配,其表现行为都是非全局匹配
// ps: 只存在search方法,不存在searchAll方法
console.log(str1.search(reg)) // => 6

// 如果没有符合条件的匹配项 那么就会直接返回-1
console.log(str2.search(reg)) // => -1
const str = 'Lorem 3232ipsum dolor32132 sit a3123met co3213123nsectetur.'

// split进行分割的时候 不管正则表达式是否开启全局匹配,其表现行为一定是 开启全局匹配的效果
console.log(str.split(/\d+/i)) // => [ 'Lorem ', 'ipsum dolor', ' sit a', 'met co', 'nsectetur.' ]
console.log(str.split(/\d+/ig)) // => [ 'Lorem ', 'ipsum dolor', ' sit a', 'met co', 'nsectetur.' ]

修饰符

默认情况下,正则表达式是非全局匹配,且大小写敏感的

image.png

多行匹配

有的时候字符串文本是有多行的,例如字符串使用转义字符包裹的时候, 这类字符串被称之为多行字符串

JavaScript 中的正则表达式默认是单行匹配模式,也就是说,它们只能匹配在同一行上的文本

如果我们需要对多行字符串中的每一行都进行正则匹配,此时就可以使用m标识来开启多行匹配

多行模式在进行正则匹配的时候会将多行字符串中的每一行视为一个单独的字符序列,而不是将所有的文本视为一个长字符序列

const str = `
Hello World
Hello Vue
Hello React
`

// str在进行多行匹配的时候,会被识别为五条字符串
// 这五行字符串分别为 [ '', 'Hello World', 'Hello Vue', 'Hello React', '' ]
// 也就是说首尾存在两个空白字符串

// 正则引擎会依次对每一行字符串进行匹配,如果匹配成功会将对应的结果放入数组中
// 如果什么都没有匹配上 则返回null
console.log(str.match(/^Hello (.*)$/mg)) // => [ 'Hello World', 'Hello Vue', 'Hello React' ]
const str = `
Hello World
Hello Vue
Hello React
`

// 如果没有开启多行匹配,多行字符串会被作为整体进行识别
console.log(str.match(/^Hello (.*)$/g)) // => null
console.log(str.match(/Hello (.*)/g)) // => [ 'Hello World', 'Hello Vue', 'Hello React' ]

规则

字符类(Character classes)

字符类(Character classes) 是一个特殊的符号,匹配特定集中的任何符号中的一个

image.png

反向类(Inverse classes)

字符含义
\D 非数字除 \d 以外的任何字符
\S 非空格符号除 \s 以外的任何字符
\W 非单字字符除 \w 以外的任何字符
# 伪代码
$ \d === [0-9] # 0~9中的任意一个数字
$ \s # 任意特殊字符中的一个
$ \w === [0-9a-zA-Z_] # 字母数字下划线中的任意一个
$ . # 除换行符外的任意字符
# 注意: .表示匹配除换行符外的任意字符,而\s匹配的是任何一个特殊字符(也就a行符的)

$ \D === [^0-9]
$ \S === [^\s]
$ \W === [^0-9a-zA-Z_]

回溯引用

正则表达式的回溯引用是指在模式匹配中使用前面捕获分组中匹配的文本

回溯引用有两种方式

\n --- 在模式中使用,表示匹配和第n个分组一样的内容

$n --- 在替换方法中使用,表示将匹配到的内容替换成第n个分组中的内容

const str1 = `<div>loren</div>`
const str2 = `<div>loren</span>`

// 默认情况下 \w+ 只能匹配到任意个字符, 但并不要求多次匹配的时候对应的值必须相同
console.log(/<\w+>.*<\/\w+>/.test(str1)) // => true
console.log(/<\w+>.*<\/\w+>/.test(str2)) // => true
const str1 = `<div>loren</div>`
const str2 = `<div>loren</span>`

// 这里的\1 指代的就是第一个捕获组中被捕获的内容 -- 在这里就是div
// 同样 \2 表示第二个捕获组中被捕获的内容
// \3, \4, ... \n 同理
console.log(/<(\w+)>.*<\/\1>/.test(str1)) // => true
console.log(/<(\w+)>.*<\/\1>/.test(str2)) // => false
const str = '<p>2333</p>'
const regExp = /<\/?(.*?)>/g

// $1, $2 等在replace方法或replaceAll方法中使用
// 用于表示其第一个参数所捕获到的对应分组
// ps: 这里的$1需要使用引号包裹起来
console.log(str.replace(regExp, '$1')) // => p2333p

锚点(Anchors)

符号 ^ 和符号 $ 在正则表达式中具有特殊的意义,它们被称为“锚点”

符合没有开启多行匹配时含义开启多行匹配时含义
符号^字符串开头每一行的行头
符号 $字符串结尾每一行的行尾

词边界(Word boundary)

词边界 \b 是一种检查,就像 ^ 和 $ 一样,它会检查字符串中的位置是否是词边界

词边界测试 \b 检查位置的一侧是否匹配 \w,而另一侧则不匹配 “\w“

例如, 在字符串 Hello, Java! 中,以下位置对应于 \b:

image.png

const str = 'time is 12:45, number is 123:342'
const reg = /\b\d\d:\d\d\b/g
console.log(str.match(reg)) // => [ '12:45' ]

转义字符

如果要把特殊字符作为常规字符来使用,需要对其进行转义, 即只需要在它前面加个反斜杠

常见的需要转义的字符: [] \ ^ $ . | ? * / + ( )

斜杠符号 ‘/’ 并不是一个特殊符号,但是在字面量正则表达式中也需要转义

const files = ['index.html', 'demo.css', 'index.js', 'foo.jsx']
console.log(files.filter(file => /\.jsx?$/.test(file))) // => [ 'index.js', 'foo.jsx' ]

集合(Sets)和范围(Ranges)

有时候我们只要选择多个匹配字符的其中之一就可以, 在方括号 [...] 中的几个字符或者字符类意味着“搜索给定的字符中的任意一个”

集合(Sets): 比如说,[eao] 意味着查找在 3 个字符 ‘a’、‘e’ 或者 `‘o’ 中的任意一个

范围(Ranges): 集合方也可以包含字符范围

  • 比如说,[a-z] 会匹配从 a 到 z 范围内的字母,[0-5] 表示从 0 到 5 的数字
  • [0-9A-F] 表示两个范围:它搜索一个字符,满足数字 0 到 9 或字母 A 到 F
  • \d —— 和 [0-9] 相同
  • \w —— 和 [a-zA-Z0-9_] 相同

排除范围:除了普通的范围匹配,还有类似 [^...] 的“排除”范围匹配

const phone = '13166093325'
const reg1 = /^1[3-9]\d{9}$/
const reg2 = /^1[^0-2]\d{9}$/

console.log(reg1.test(phone)) // => true
console.log(reg2.test(phone)) // => true

量词(Quantifiers)

假设我们有一个字符串 +7(903)-123-45-67,并且想要找到它包含的所有数字,因为它们的数量是不同的,所以我们需要给与数量一个范围

用来形容我们所需要的数量的词被称为量词( Quantifiers )

基本使用

数量 {n}

  • 确切的位数: {5}
  • 某个范围的位数: {3,5}
    • {3,5} === x >= 3 && x <= 5
    • 5和前面的逗号之间不能有空格
    • 第二个量词可以省略,表示无穷
      • 例如 {3,} === x >= 3

简写

符号说明
+代表“一个或多个”,相当于 {1, }
代表“零个或一个”,相当于 {0,1} 换句话说,它使得符号变得可选
*代表着“零个或多个”,相当于 {0, } 也就是说,这个字符可以多次出现或不出现
const str = `<p>
  <h1>this is title</h1>
  <div>this is message</div>
</p>`

console.log(str.match(/<\/?[a-z][a-z0-9]*>/gi)) // => [ '<p>', '<h1>', '</h1>', '<div>', '</div>', '</p>' ]

贪婪(Greedy)和惰性(lazy)模式

默认情况下的匹配规则是查找到匹配的内容后,会继续向后查找,一直找到最后一个匹配的内容, 这种匹配的方式,我们称之为贪婪模式(Greedy)

而如果我们只需要获取到对应的内容后,就不再继续向后匹配。我们可以在量词后面再加一个问号 ‘?’ 来启用它。例如 +?, *?, ??等,这种匹配模式被称之为惰性模式

const str = '<div>foo</div><div>bar</div><div>baz</div>'
const reg1 = /<.+>/gi
const reg2 = /<.+?>/gi

// 使用量词匹配的时候,默认使用的是贪婪模式,即尽可能的匹配更多的内容
console.log(str.match(reg1)) // => [ '<div>foo</div><div>bar</div><div>baz</div>' ]
// 在量词后边加上? 可以使用 惰性模式 即使用更为精准的匹配
console.log(str.match(reg2)) // => [ '<div>', '</div>', '<div>', '</div>', '<div>', '</div>' ]

捕获组(capturing group)

模式的一部分可以用括号括起来 (...),这称为“捕获组(capturing group)”

捕获组会将括号视为一个整体,并允许将匹配的一部分作为结果数组中的单独项

const str = '有以下书籍: 《朝花夕拾》, 《群己权界论》, 《小王子》'
const reg = /(《)(.+?)》/gi

// 分组的内容 需要在 匹配结果的详细信息中才可以查看到
const books = str.matchAll(reg)

for (const book of books) {
  console.log(book)
}
/*
  =>
    [
      '《朝花夕拾》',  // => 匹配到的内容
      '《',   // => 第一个分组中的内容
      '朝花夕拾',  // => 第二个分组中的内容,依次类推
      index: 7, // => 匹配到的字符串的首字母对应的索引值
      input: '有以下书籍: 《朝花夕拾》, 《群己权界论》, 《小王子》', // => 匹配的字符串
      groups: undefined // => 具名分组会以对象的形式存放在这个属性中,默认值为undefined
    ]

    ...
*/
const str = 'Loremabcabc, ipsumabc dolor sit amet conseabcabcabcctetur adipisicing elit.'

// 使用小括号 以表示在该正则表达式中 abc是一个不可分割的整体
// abc{2,} => ab + 两个或以上连续的c
// (abc){2,} => 两个或以上连续的abc
const reg = /(abc){2,}/gi

console.log(str.match(reg)) // => [ 'abcabc', 'abcabcabc' ]

命名组

默认情况下命名组是按照索引来进行区分的,这样很难进行记忆

对于更复杂的模式,计算括号很不方便。我们有一个更好的选择:给括号起个名字

这是通过在开始括号之后立即放置 ?<name> 来完成的

const str = '有以下书籍: 《朝花夕拾》, 《群己权界论》, 《小王子》'
// 捕获组的命名方式是在捕获组开头加上 ?<捕获组名称>
const reg = /(《)(?<bookname>.+?)》/gi

const books = str.matchAll(reg)

for (const book of books) {
  // 对应的具名捕获组会存放在详细信息的groups属性中
  console.log(book.groups.bookname)
}
/*
  =>
    朝花夕拾
    群己权界论
    小王子
*/

非捕获组

有时我们需要括号才能正确应用量词,但我们不希望它们的内容出现在结果中

可以通过在开头添加 ?: 来排除组

const str = '有以下书籍: 《朝花夕拾》, 《群己权界论》, 《小王子》'

// 在捕获组开头加上  ?: 表示对应的捕获组信息不要出现在最终的详细信息中
const reg = /(?:《)(.+?)(?:》)/gi

const books = str.matchAll(reg)

for (const book of books) {
  console.log(book)
}
/*
  =>
    [
      '《朝花夕拾》',
      // 《 和 》 都 在捕获组中,但是因为他们设置了非捕获组 所以对应捕获组内容不会出现在详细信息中
      '朝花夕拾',
      index: 7,
      input: '有以下书籍: 《朝花夕拾》, 《群己权界论》, 《小王子》',
      groups: undefined
    ]

    ...
*/

or是正则表达式中的一个术语,实际上是一个简单的“或”

在正则表达式中,它用竖线 | 表示,需要和捕获组一起来使用,在其中表示多个值

const str = 'Hello World, Hello JavaScript, Hello React'

// 多个或值是使用小括号进行包裹的,也就是说或是使用在捕获组中的
console.log(str.match(/(World|JavaScript|React)/gi)) // => [ 'World', 'JavaScript', 'React' ]

断言

断言是正则中必须匹配到的部分,但是这部分不会被包含在匹配到的结果中

先行断言 --- 所有现代浏览器都支持

后行断言 --- 除Safari外的所有现代浏览器都支持

正向先行断言 positive lookahead assertion

(?=表达式) ----> 保证右边必须出现表达式

console.log(/Hello\s(?=World)/.test('Hello World')) // => true
console.log(/Hello\s(?=World)/.test('Hello Vue')) // => false

反向先行断言 negative lookahead assertion

`(?!表达式) ----> 保证右边不能出现表达式

console.log(/.+\s(?!Vue)/.test('Hello World')) // => true
console.log(/.+\s(?!Vue)/.test('Hello Vue')) // => false

正向后行断言 positive lookbehind assertion

(?<=表达式) ----> 保证左边必须出现表达式

console.log(/(?<=Hello).+/.test('Hello World')) // => true
console.log(/(?<=Hello).+/.test('New World')) // => false

反向后行断言 negative lookbehind assertion

(?<!表达式) ----> 保证左边不能出现表达式

console.log(/(?<!Hello)\s.+/.test('Hello World')) // => false
console.log(/(?<!Hello)\s.+/.test('New World')) // => true

综合案例

歌词解析

/*
  [00:22.240]天空好想下雨
  [00:24.380]我好想住你隔壁
  [00:26.810]傻站在你家楼下

  -->
   表示的含义是 00:22.240 ~ 00:24.380 时候 显示歌词 天空好想下雨
    00:24.380 ~ 00:26.810 时候 显示歌词 我好想住你隔壁
    依次类推

  需求 [00:22.240]天空好想下雨 -> { time: 22240, content: '天空好想下雨' }
*/
const lyricArr = [
  '[00:22.240]天空好想下雨',
  '[00:24.380]我好想住你隔壁',
  '[00:26.810]傻站在你家楼下',
  '[00:29.500]抬起头数乌云',
  '[00:31.160]如果场景里出现一架钢琴',
  '[00:33.640]我会唱歌给你听',
  '[00:35.900]哪怕好多盆水往下淋',
  '[00:41.060]夏天快要过去',
  '[00:43.340]请你少买冰淇淋',
  '[00:45.680]天凉就别穿短裙',
  '[00:47.830]别再那么淘气',
  '[00:50.060]如果有时不那么开心',
  '[00:52.470]我愿意将格洛米借给你',
  '[00:55.020]你其实明白我心意',
  '[00:58.290]为你唱这首歌没有什么风格',
  '[01:02.976]它仅仅代表着我想给你快乐',
  '[01:07.840]为你解冻冰河为你做一只扑火的飞蛾',
  '[01:12.998]没有什么事情是不值得',
  '[01:17.489]为你唱这首歌没有什么风格',
  '[01:21.998]它仅仅代表着我希望你快乐',
  '[01:26.688]为你辗转反侧为你放弃世界有何不可',
  '[01:32.328]夏末秋凉里带一点温热有换季的颜色'
]

function parseLyric(lyricArr = []) {
  return lyricArr.map(lyric => {
    const lyricInfo = lyric.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)/i)

    // 如果没有匹配项,match方法会返回null 导致后续代码执行出错
    if (!lyricInfo) throw new Error('歌词解析失败')

    return {
      // mm:ss.xx -> 毫秒可能为2位也可能为3位
      // mm:ss.12 -> 对应的ms为 12 * 10 = 120ms
      // mm:ss.123 -> 对应的ms为 123ms
      time: +lyricInfo[1] * 1000 * 60 + +lyricInfo[2] * 1000 + +lyricInfo[3].padEnd(3, '0'),
      content: lyricInfo[4]
    }
  })
}

const lyric = parseLyric(lyricArr)

console.log(lyric)
/*
  =>
    [
      { time: 22240, content: '天空好想下雨' },
      { time: 24380, content: '我好想住你隔壁' },
      { time: 26810, content: '傻站在你家楼下' },
      { time: 29500, content: '抬起头数乌云' },
      { time: 31160, content: '如果场景里出现一架钢琴' },
      { time: 33640, content: '我会唱歌给你听' },
      { time: 35900, content: '哪怕好多盆水往下淋' },
      { time: 41060, content: '夏天快要过去' },
      { time: 43340, content: '请你少买冰淇淋' },
      { time: 45680, content: '天凉就别穿短裙' },
      { time: 47830, content: '别再那么淘气' },
      { time: 50060, content: '如果有时不那么开心' },
      { time: 52470, content: '我愿意将格洛米借给你' },
      { time: 55020, content: '你其实明白我心意' },
      { time: 58290, content: '为你唱这首歌没有什么风格' },
      { time: 62976, content: '它仅仅代表着我想给你快乐' },
      { time: 67840, content: '为你解冻冰河为你做一只扑火的飞蛾' },
      { time: 72998, content: '没有什么事情是不值得' },
      { time: 77489, content: '为你唱这首歌没有什么风格' },
      { time: 81998, content: '它仅仅代表着我希望你快乐' },
      { time: 86688, content: '为你辗转反侧为你放弃世界有何不可' },
      { time: 92328, content: '夏末秋凉里带一点温热有换季的颜色' }
    ]
*/

时间格式化

function formatTime(timeStamp, formatStr = 'YYYY-MM-DD HH:mm:ss') {
  // 定义一个结果字符串,目的是避免直接修改formatStr
  let dateStr = formatStr

  const date = new Date(timeStamp)

  const keywords = {
    'Y{4}': date.getFullYear(),
    'M{2}': date.getMonth() + 1, // 月份是从0开始计算的
    'D{2}': date.getDate(), // getDate是月份中的第几天 getDay是一周中的第几天
    'H{2}': date.getHours(),
    'm{2}': date.getMinutes(),
    's{2}': date.getSeconds()
  }

  // 在ES6+ 中 对对象进行了特殊的处理
  // 使对象可以解构,也可以使用for-in进行遍历
  for (const key in keywords) {
    const reg = new RegExp(key)

    // 如果调用者有写对应的匹配字符串
    if (reg.test(formatStr)) {
      // 至少2位 不足2位时候,前置补0
      dateStr = dateStr.replace(reg, (keywords[key] + '').padStart(2, '0'))
    }
  }

  return dateStr
}

console.log(formatTime(Date.now())) // => 2022-07-05 12:35:29
console.log(formatTime(Date.now(), 'YYYY-MM-DD')) // => 2022-07-05

密码强度验证

对用户输入的密码进行验证,要求满足如下条件:

  • 至少一个大写字母
  • 至少一个小写字母
  • 至少一个数字
  • 至少8个字符
// (?=.*[a-z])位于整个正则最前面,所以任意字符后有小写字母 该模式就可以被正常匹配,所以该模式表示的是 字符串中必须存在小写字母
// 同理 (?=.*[A-Z]) --- 字符串中需要有大写字母
// (?=.*[0-9]) --- 字符串中需要存在数字
// .{8,} --- 表示的是任意的8个及以上的字符串
// 所以该表达式可以符合密码强度的认证
console.log(/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{8,}/.test('Admin123456')) // => true