深入理解JavaScript正则表达式

1,283 阅读8分钟

一、RegExp 介绍

ECMAScript通过RegExp类型支持正则表达式。正则表达式使用简洁语法来创建:

const expression = /pattern/flags

这个正则表达式的pattern(模式)可以是任何简单或复杂的正则表达式,包括字符类限定符分组向前查找反向引用。每个正则表达式可以带零个或多个flags(标记),用于控制正则表达式的行为。下面给出了表示匹配模式的标记。

  • g:全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束。

  • i:不区分大小写,表示在查找匹配时忽略pattern和字符串的大小写。

  • m:多行模式,表示查找到一行文本末尾时会继续查找。

  • y:粘附模式,表示只查找从lastIndex开始及之后的字符串。

  • uUnicode模式,启用Unicode匹配。

  • sdotAll模式,表示元字符.匹配任何字符(包括\n\r)。

使用不同模式和标记可以创建出各种正则表达式,比如:

// 匹配字符串中所有的at
const pattern1 = /at/g

// 匹配第一个bat或cat,忽略大小写
const pattern2 = /[bc]at/i

// 匹配所有以at结尾的三字符组合,忽略大小写
const pattern3 = /.at/gi

与其他语言中的正则表达式类似,所有元字符在模式中也必须转义,包括:

( [ { \ ^ $ | ) ] } ? * + .

元字符在正则表达式中都有一种或多种特殊功能,所以要匹配上面这些字符本身,就必须使用反斜杠来转义。下面是几个例子:

// 匹配第一个bat或cat,忽略大小写
const pattern1 = /[bc]at/i

// 匹配第一个[bc]at,忽略大小写
const pattern2 = /\[bc\]at/i

// 匹配所有以at结尾的三字符组合,忽略大小写
const pattern3 = /.at/gi

// 匹配所有.at,忽略大小写
const pattern4 = /\.at/gi

这里的pattern1匹配batcat,不区分大小写。要直接匹配[bc]at,左右中括号都必须像pattern2中那样使用反斜杠转义。在pattern3中,点号表示at前面的任意字符都可以匹配。如果想匹配.at,那么要像pattern4中那样对点号进行转义。

前面例子中的正则表达式都是使用字面量形式定义的。正则表达式也可以使用RegExp构造函数来创建,它接收两个参数:模式字符串和(可选的)标记字符串。任何使用字面量定义的正则表达式也可以通过构造函数来创建,比如:

// 匹配第一个bat或cat,忽略大小写
const pattern1 = /[bc]at/i

// 跟pattern1一样,只不过是用构造函数创建的
const pattern2 = new RegExp('[bc]at', 'i')

这里的pattern1pattern2是等效的正则表达式。

RegExp构造函数的两个参数都是字符串。

因为RegExp的模式参数是字符串,所以在某些情况下需要二次转义。所有元字符都必须二次转义,包括转义字符序列,如\n\转义后的字符串是\\,在正则表达式字符串中则要写成\\\\)。下表展示了几个正则表达式的字面量形式,以及使用RegExp构造函数创建时对应的模式字符串。

字面量模式对应的字符串
/\[bc\]at/"\\[bc\\]at"
/\.at/"\\.at"
/name\/age/"name\\/age"
/\d.\d{1,2}/"\\d.\\d{1,2}"
/\w\\hello\\123/"\\w\\\\hello\\\\123"

使用RegExp也可以基于已有的正则表达式实例,并可选择性地修改它们的标记:

const re1 = /cat/g
console.log(re1) // "/cat/g"

const re2 = new RegExp(re1)
console.log(re2) // "/cat/g"

const re3 = new RegExp(re1, 'i')
console.log(re3) // "/cat/i"

二、RegExp 实例属性

每个RegExp实例都有下列属性,提供有关模式的各方面信息。

  • global:布尔值,表示是否设置了g标记。
  • ignoreCase:布尔值,表示是否设置了i标记。
  • unicode:布尔值,表示是否设置了u标记。
  • sticky:布尔值,表示是否设置了y标记。
  • lastIndex:整数,表示在源字符串中下一次搜索的开始位置,始终从0开始。
  • multiline:布尔值,表示是否设置了m标记。
  • dotAll:布尔值,表示是否设置了s标记。
  • source:正则表达式的字面量字符串(不是传给构造函数的模式字符串),没有开头和结尾的斜杠。
  • flags:正则表达式的标记字符串。

通过这些属性可以全面了解正则表达式的信息,不过实际开发中用得并不多,因为模式声明中包含这些信息。下面是一个例子:

const pattern1 = /\[bc\]at/i
console.log(pattern1.global) // false
console.log(pattern1.ignoreCase) // true
console.log(pattern1.multiline) // false
console.log(pattern1.lastIndex) // 0
console.log(pattern1.source) // \[bc\]at
console.log(pattern1.flags) // i

const pattern2 = new RegExp('\\[bc\\]at', 'i')
console.log(pattern2.global) // false
console.log(pattern2.ignoreCase) // true
console.log(pattern2.multiline) // false
console.log(pattern2.lastIndex) // 0
console.log(pattern2.source) // \[bc\]at
console.log(pattern2.flags) // i

虽然第一个模式是通过字面量创建的,第二个模式是通过RegExp构造函数创建的,但两个模式的sourceflags属性是相同的。sourceflags属性返回的是规范化之后可以在字面量中使用的形式。

三、选择(OR)

选择是正则表达式中的一个术语,实际上是一个简单的

在正则表达式中,它用竖线 | 表示。

例如,我们需要找出编程语言:htmlcss

对应的正则表达式为:html|css

const reg = /html|css/gi
let str = 'First HTML appeared, then CSS'
console.log(str.match(reg)) // ['HTML', 'CSS']

四、量词

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

  • {m,} 表示至少出现m次。

  • {m} 等价于{m,m},表示出现m次。

  • ? 等价于{0,1},表示出现或者不出现。

  • + 等价于{1,},表示出现至少一次。

  • *等价于{0,},表示出现任意次,有可能不出现。

五、贪婪匹配和惰性匹配

正则匹配默认是贪婪匹配,也就是匹配尽可能多的字符。如下面的例子匹配出数字后面的0

const reg = /^(\d+)(0*)$/
const res = reg.exec('102300')
console.log(res) //  ['102300', '102300', '', index: 0, input: '102300', groups: undefined]

由于\d+采用贪婪匹配,直接把后面的0全部匹配了,结果0*只能匹配空字符串了。必须让\d+采用非贪婪匹配(也就是尽可能少匹配),才能把后面的0匹配出来,加个?就可以让\d+采用非贪婪匹配:

const reg = /^(\d+?)(0*)$/
const res = reg.exec('102300')
console.log(res) //  ['102300', '1023', '00', index: 0, input: '102300', groups: undefined]

六、字符组

[]字符组表示在同一个位置可能出现的各种字符,也就是说它的匹配结果只能是一个字符,不能是多个。

6.1、字符范围

方括号也可以包含字符范围。如果字符组里的字符特别多的话,可以使用字符范围。

比如[123456abcdefGHIJKLM],可以写成[1-6a-fG-M]。用连字符-来省略和简写。

因为连字符有特殊用途,要匹配a-z这三者中任意一个字符。可以写成如下的方式:

[-az][az-][a-z]。要么放在开头,要么放在结尾,要么转义。总之不会让引擎认为是范围表示法就行了。

6.2、排除范围

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

它们通过在匹配查询的开头添加插入符号 ^ 来表示,它会匹配所有除了给定的字符之外的任意字符。

例如[^abc],表示是一个除abc之外的任意一个字符。字符组的第一位放^(脱字符),表示求反的概念。

6.3、常见的简写形式

\d[0-9]:表示是一位数字。

\D[^0-9]:表示除数字外的任意字符。

\w[0-9a-zA-Z_]:表示数字、大小写字母和下划线。w是word的简写,也称单词字符。

\W[^0-9a-zA-Z_]:表示非数字、大小写字母和下划线。

\s[ \t\v\n\r\f]:表示空白符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页符。

\S[^ \t\v\n\r\f]:非空白符。

.就是[^\n\r\u2028\u2029]:通配符,表示几乎任意字符。换行符、回车符、行分隔符和段分隔符除外。

七、位置匹配

正则表达式是匹配的模式之一:位置匹配。这点很重要。

位置是相邻字符之间的位置。比如,下图中箭头所指的地方:

image.png

ES5中,共有6个锚字符匹配位置:

^   $  \b \B  (?=p) (?!p)

7.1、字符串开始 ^ 和末尾 $

插入符号 ^ 和美元符号 $ 在正则表达式中具有特殊的意义。它们被称为锚点

插入符号 ^ 匹配文本开头,而美元符号 $则匹配文本末尾。

举个例子,让我们测试一下文本是否以 html 开头:

const result = /^html/.test('html js')
console.log(result) // true

该模式 ^html 的意思是:字符串开始,接着是html

与此类似,我们可以用 js$ 来测试文本是否以 js 结尾:

const result = /js$/.test('html js')
console.log(result) // true

^...$ 放在一起常常被用于测试一个字符串是否完全匹配一个模式。比如,测试用户的输入是否符合正确的格式。

让我们测试一下一个字符串是否属于 12:34 格式的时间。两个数字,然后一个冒号,接着是另外两个数字。

const success = '12:34'
const fail = '12:345'

const reg = /^\d\d:\d\d$/
console.log(reg.test(success)) // true
console.log(reg.test(fail)) // false

整个字符串必须准确地符合这一个格式。如果其中有任何偏差或者额外的字符,结果将为 false

7.2、m 多行模式

当修饰符 m 出现时,锚点将会有不同的行为。通过 flag /.../m 可以开启多行模式。它只会影响 ^ 和 $ 锚符的行为。

在多行模式下,它们不仅仅匹配文本的开始与结束,还匹配每一行的开始与结束。

7.2.1、行的开头

在这个有多行文本的例子中,正则表达式 /^\d+/gm 将匹配每一行的开头数字:

const str = '1st\n2nd\n33rd'
console.log(str.match(/^\d+/gm)) // ['1', '2', '33']

没有/.../m时,仅仅是第一个数字被匹配到:

const str = '1st: html\n2nd: js\n3rd: css'
console.log(str.match(/^\d+/g)) // ['1']

这是因为默认情况下,锚符 ^ 仅仅匹配文本的开头,在多行模式下,它匹配行的开头。

7.2.2、行的结尾

正则表达式 \w+$ 会找到每一行的最后一个单词:

const str = '1st: html\n2nd: js\n3rd: css'
console.log(str.match(/\w+$/gm)) //  ['html', 'js', 'css']

没有 /.../m的话,美元符 $ 将会仅仅匹配整个文本的结尾,所以只有最后的一个单词会被找到。

const str = '1st: html\n2nd: js\n3rd: css'
console.log(str.match(/\w+$/g)) //  ['css']

7.3、\b 词边界

当正则表达式引擎遇到 \b 时,它会检查字符串中的位置是否是词边界。具体就是\w\W之间的位置,也包括\w^之间的位置,也包括\w$之间的位置。

有三种不同的位置可作为词边界:

  • 在字符串开头,如果第一个字符是单词字符 \w
  • 在字符串中的两个字符之间,其中一个是单词字符 \w,另一个不是。
  • 在字符串末尾,如果最后一个字符是单词字符 \w
// @Hello@,@JavaScript@!
console.log('Hello,JavaScript!'.replace(/\b/g, '@')) 

我们可以看@Hello@,@JavaScript@!是怎么来的:

  1. 第一个@,位置是开头,第一个字符是H\w。是^H之间的位置。
  2. 第二个@,两边是o,,也就是\w\W之间的位置。
  3. 第三个@,两边是,与"J",也就是\w\W之间的位置。
  4. 第四个@,两边是t!,也就是\w\W之间的位置。

在字符串 Hello, JavaScript! 中,以下位置对应于 \b

image.png

7.4、\B非词边界

\B就是\b的反面的意思,非单词边界。例如在字符串中所有位置中,扣掉\b,剩下的都是\B的。

具体说来就是\w\w\W\W^\W\W$之间的位置。

比如上面的例子Hello,JavaScript!,把所有\B替换成@

// H@e@l@l@o,J@a@v@a@S@c@r@i@p@t!@
console.log('Hello,JavaScript!'.replace(/\B/g, '@')) 

7.5、零宽断言

在使用正则表达式时,有时我们需要捕获的内容前后必须是特定内容,但又不捕获这些特定内容的时候,零宽断言就起到作用了。

零宽断言正如它的名字一样,是一种零宽度的匹配,它匹配到的内容不会保存到匹配结果中去,最终匹配结果只是一个位置而已(不会占用index宽度)。

零宽断言分为4类:

  1. 正向零宽先行断言(?=exp):目标字符出现的位置的右边必须匹配到exp这个表达式,也就是exp前面的那个位置。
  2. 负向零宽先行断言(?!exp):目标字符出现的位置的右边不能匹配到exp这个表达式。
  3. 正向零宽后行断言(?<=exp):目标字符出现的位置的左边必须匹配到exp这个表达式。
  4. 负向零宽后行断言(?<!exp):目标字符出现的位置的左边不能匹配到exp这个表达式。

ES5支持先行断言(即支持(?=exp)?!exp),ES2018才支持后行断言。

下面是正向零宽先行断言的例子:

const str = 'abCD123'
const reg = /ab(?=[A-Z])/
console.log(str.match(reg)) // ['ab', index: 0, input: 'abCD123', groups: undefined]

在以上代码中,正则表达式的语义是:匹配后面跟随任意一个大写字母的字符串ab。最终匹配结果是ab,因为零宽断言(?=[A-Z])并不匹配任何字符,只是用来规定当前位置的后面必须是一个大写字母。

下面是负向零宽先行断言的例子:

const str = 'abCD123'
const reg = /ab(?![A-Z])/
console.log(str.match(reg)) // null

以上代码中,正则表达式的语义是:匹配后面不跟随任意一个大写字母的字符串ab。正则表达式没能匹配任何字符,因为在字符串中,ab的后面跟随有大写字母。

八、分组(捕获组)

在正则表达式中,使用()进行分组,一对圆括号括起来的表达式就是一个分组,也叫捕获组。

使用分组会有两个作用:

  1. 将匹配的一部分作为结果数组中的单独项。
  2. 如果我们将量词(比如:(html)+)放在括号后,则它将括号视为一个整体。

下面看一个示例:不带括号,模式go+表示g字符,其后o重复一次或多次。例如 goooo 或 gooooooooo。括号可以将字符组合,所以(go)+匹配 gogogogogogo等:

'Gogogo now!'.match(/(go)+/gi) // ['Gogogo']

还可以使用括号加选择的方式进行组合(如:(html|css))。比如,要匹配如下的字符串:

I Love html
I Love css

可以使用下面正则:

const reg = /^I Love (html|css)$/
reg.test('I Love html') // true
reg.test('I Love css') // true

8.1、数据提取

除了简单地判断是否匹配之外,正则表达式还有提取子串的强大功能。用()表示的就是要提取的分组(Group)。

比如^(\d{3})-(\d{3,8})$分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:

const reg = /^(\d{3})-(\d{3,8})$/
console.log(reg.exec('010-12345')) // ['010-12345', '010', '12345', index: 0, input: '010-12345', groups: undefined]
console.log(reg.exec('010 12345')) // null

8.2、嵌套组

括号可以嵌套。在这种情况下,编号从左到右。

例如,在搜索 html 123 时我们可能需要得到下面的内容:

  1. 整个内容:html 123
  2. 英文:html
  3. 数字:123

让我们为它们添加括号:(([a-z]+)\s*(\d+))

这是它们的编号方式(从左到右,由左括号开始):

image.png

const str = 'html 123'
const reg = /(([a-z]+)\s*(\d+))/
const res = reg.exec(str)

// 匹配到的整个字符串
console.log(res[0]) // html 123
// 匹配成功的子串
console.log(res[1]) // html 123
// 匹配成功的子串
console.log(res[2]) // html
// 匹配成功的子串
console.log(res[3]) // 123

九、非捕获组

非捕获组的语法是在捕获组的基础上,在左括号的右侧加上?:就可以了,那就是 (?:)

(\d)表示捕获组,而(?:\d)表示非捕获组。既然是非捕获组,那它就不会把正则匹配到的内容保存到分组里面。

一旦使用了(),就会默认为是普通捕获组,从而将()内表达式匹配的内容捕获到组里。但是有些情况下,不得不用(),但并不关心()中匹配的内容是什么,后面也不会引用捕获到的内容,这带来了一个副作用,记录这些捕获组就会占用内存,降低匹配效率。

设计非捕获组的目的就是为了抵消这种副作用。 只进行分组,并不将子表达式匹配到的内容捕获到组里。

例如,如果我们要查找 (go)+,但不希望括号内容(go)作为一个单独的数组项,则可以编写:(?:go)+。在下面的示例中,我们仅将名称 JS 作为匹配项的单独成员:

const str = 'Gogogo John!'
// ?: 从捕获组中排除 'go'
const regexp = /(?:go)+ (\w+)/i
const result = str.match(regexp)

console.log(result[0]) // Gogogo John(完全匹配)
console.log(result[1]) // John
console.log(result.length) // 2(数组中没有更多项)

9.1、什么时候该用非捕获组

  1. 不需要用到分组里面的内容的时候,用非捕获组,主要是为了提升效率,因为捕获组多了一步保存数据的步骤,所以一般会多耗费一些时间,虽然时间很短。

  2. 不考虑效率的场合,可以不用非捕获组,以提高正则表达式的可读性。

  3. 一些非常简单的正则中,如果使用了非捕获组,因为要解析这种语法,反而可能会降低匹配效率。

十、回溯

目前实现正则表达式引擎的方式有两种:DFA自动机(Deterministic Final Automata 确定有限状态自动机)和NFA自动机(Non deterministic Finite Automaton 非确定有限状态自动机)。

NFA自动机的优势是支持更多功能。例如,捕获group、环视、占有优先量词等高级功能。这些功能都是基于子表达式独立进行匹配,因此在编程语言里,使用的正则表达式库都是基于NFA实现的。

上面这些概念作为了解即可。

NFA自动机到底是怎么进行匹配的呢?

NFA自动机会读取正则表达式的每一个字符,拿去和目标字符串匹配,匹配成功就换正则表达式的下一个字符,反之就继续和目标字符串的下一个字符进行匹配。

text="aabcab" regex="bc"字符和表达式来举例说明:

首先,读取正则表达式的第一个匹配符和字符串的第一个字符进行比较,ba,不匹配。继续换字符串的下一个字符,也是a,不匹配。继续换下一个,是b,匹配。

image.png

然后同理,读取正则表达式的第二个匹配符和字符串的第四个字符进行比较,cc,匹配。继续读取正则表达式的下一个字符,然而后面已经没有可匹配的字符了,结束。

image.png

这就是NFA自动机的匹配过程,虽然在实际应用中,碰到的正则表达式都要比这复杂,但匹配方法是一样的。

10.1、NFA自动机的回溯

NFA自动机实现的比较复杂的正则表达式,在匹配过程中经常会引起回溯问题。大量的回溯会长时间地占用CPU,从而带来系统性能开销。以下面代码举例说明:

text="abbc" regex="ab{1,3}c"

这个例子,匹配目的比较简单。匹配以a开头,以c结尾,中间有1-3b字符的字符串。NFA自动机对其解析的过程是这样的:

  1. 首先,读取正则表达式第一个匹配符a和字符串第一个字符a进行比较,aa,匹配。

image.png

  1. 然后,读取正则表达式第二个匹配符b{1,3}和字符串的第二个字符b进行比较,匹配。但因为 b{1,3}表示1-3b字符串,NFA自动机又具有贪婪特性,所以此时不会继续读取正则表达式的下一个匹配符,而是依旧使用b{1,3}和字符串的第三个字符b进行比较,结果还是匹配。

image.png

  1. 接着继续使用b{1,3}和字符串的第四个字符c进行比较,发现不匹配了,此时将要发生回溯。

image.png

  1. 发生回溯,已经读取的字符串第四个字符c将被吐出去,指针回到第三个字符b的位置。

image.png

  1. 那么发生回溯以后,匹配过程怎么继续呢?程序会读取正则表达式的下一个匹配符c,和字符串中的第四个字符c进行比较,结果匹配,结束。

image.png

十一、JS RegExp实例方法

11.1、exec()

如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。这个方法只接收一个参数,返回字符串str中的regexp匹配项。

exec()方法在匹配成功后,会返回一个Array,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串(在第一个元素匹配到的整个字符串的基础上)。如果没找到匹配项,则返回null

返回的数组虽然是Array的实例,但包含三个额外的属性:indexinputindex是字符串中匹配模式的起始位置,input是要查找的字符串。groups是一个新的属性,用来存储命名捕获组的信息。

下面是提取出年、月、日的例子:

const reg = /(\d{4})-(\d{2})-(\d{2})/
const str = '2020-02-11'
const result = reg.exec(str)
console.log(result.index) // 0
console.log(result.input) // 2020-02-11
console.log(result[0]) // 2020-02-11
console.log(result[1]) // 2020
console.log(result[2]) // 02
console.log(result[3]) // 11

在上面这个例子中,模式包含三个捕获组:(\d{4})(\d{2})(\d{2}),因为整个字符串匹配,所以matchs数组的index属性就是0。数组的第一个元素是匹配的整个字符串,第二个元素是匹配第一个捕获组的字符串,第三个元素是匹配第二个捕获组的字符串。第四个元素是匹配第三个捕获组的字符串。

11.1.1、无全局标记 g

如果没有设置全局标记g,则无论对同一个字符串调用多少次exec(),也只会返回第一个匹配的信息。

const str = 'cat, bat, sat'
const reg = /.at/

let res = reg.exec(str)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 0

res = reg.exec(str)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 0

上面例子中的模式没有设置全局标记,因此调用exec()只返回第一个匹配项catlastIndex在非全局模式下始终不变。

11.1.2、有全局标记 g

如果模式设置了全局标记g,则每次调用exec()方法会返回一个匹配的信息。每次调用exec()都会在字符串中向前搜索下一个匹配项,如下面的例子所示:

const str = 'cat, bat, sat'
let reg = /.at/g
let res = reg.exec(str)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 3

res = reg.exec(str)
console.log(res[0]) // bat
console.log(res.index) // 5
console.log(reg.lastIndex) // 8

res = reg.exec(str)
console.log(res[0]) // sat
console.log(res.index) // 10
console.log(reg.lastIndex) // 13

res = reg.exec(str)
console.log(res) // null

这次模式设置了全局标记g,那么会按照下面方式执行:

  • 调用 regexp.exec(str) 会返回第一个匹配项,并将紧随其后的位置保存在属性 regexp.lastIndex 中。

  • 下一次同样的调用会从位置regexp.lastIndex开始搜索,返回下一个匹配项,并将其后的位置保存在regexp.lastIndex中。

  • …以此类推。

  • 如果没有匹配项,则regexp.exec返回null,并将regexp.lastIndex重置为 0

我们可以通过手动设置lastIndex,用regexp.exec从指定位置进行搜索。例如:

const str = 'Hello, world!'
const regexp = /\w+/g
// 从第 5 个位置搜索(从逗号开始)
regexp.lastIndex = 5
console.log(regexp.exec(str)[0]) // world

11.1.3、粘附标记 y

如果模式设置了粘附标记y,则每次调用exec()就只会精确的在lastIndex的位置上寻找匹配项。粘附标记覆盖全局标记。

const str = 'cat, bat, sat, fat'
let reg = /.at/gy
let res = reg.exec(str)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 3

res = reg.exec(str)
// 以索引3对应的字符开头找不到匹配项,因此exec()返回null
console.log(res) // null
// exec()没找到匹配项,于是将 lastIndex 设置为 0
console.log(reg.lastIndex) // 0

// 设置 lastIndex 可以让粘附的模式通过 exec()找到下一个匹配项
reg.lastIndex = 5
res = reg.exec(str)
console.log(res[0]) // bat
console.log(res.index) // 5
console.log(reg.lastIndex) // 8

11.2、test()

正则表达式的另一个方法是test(),它接收一个字符串参数。如果输入的文本与模式匹配,则参数返回true,否则返回false。这个方法适用于只想测试模式是否匹配,而不需要实际匹配内容的情况。test()经常用在if语句中:

const str = '000-00-0000'
const reg = /\d{3}-\d{2}-\d{4}/
if (reg.test(str)) {
  console.log('success') // success
}

在这个例子中,如果输入的文本与模式匹配,则显示匹配成功的消息。这个用法常用于验证用户输入。

11.3、toLocaleString()、toString()和valueOf()

无论正则表达式是怎么创建的,继承的方法toLocaleString()toString()都返回正则表达式的字面量表示。比如:

const reg = new RegExp('\\[bc\\]at', 'gi')
console.log(reg.toString()) // /\[bc\]at/gi
console.log(reg.toLocaleString()) // /\[bc\]at/gi

这里的模式是通过RegExp构造函数创建的,但toLocaleString()toString()返回的都是其字面量的形式。

正则表达式的valueOf()方法返回正则表达式本身:

const reg = new RegExp('\\[bc\\]at', 'gi')
console.log(reg.valueOf()) // /\[bc\]at/gi
console.log(Object.prototype.toString.call(reg.valueOf())) // [object RegExp]

十二、JavaScript字符串(String)的方法

本章节将探讨与字符串和正则表达式配合使用的各种方法。

12.1、replace()

replace()这个方法接收两个参数:

  1. 第一个参数:可以是一个字符串或者是一个正则表达式。

  2. 第二个参数:是一个字符串也可以是一个函数。

注意:原字符串不会改变。

StringObject.replace(searchValue,replaceValue):

  1. StringObject:字符串

  2. searchValue:字符串或正则表达式

  3. replaceValue:字符串或者函数

  4. 返回值:一个部分或全部匹配新的字符串。

上述介绍后,replace()方法就有如下的4种使用方式:

  1. 第一个参数为字符串,第二个参数也是字符串
  2. 第一个参数是字符串,第二个参数是函数
  3. 第一个参数是正则表达式,第二参数是字符串
  4. 第一个参数是正则表达式,第二个参数是函数

下面会逐一介绍这四种使用方式。

12.1.1、第一个参数传入字符串,第二个参数传入字符串

如果第一个参数是字符串,第二个参数也是字符串,只会替换第一个子字符串。要想替换所有子字符串,唯一的办法就是提供一个正则表达式,并且要指定全局g标志。

const text = 'cat,bat,sat,fat';
const result = text.replace('at','ond');
console.log(result) // cond,bat,sat,fat

12.1.2、第一个参数传入字符串,第二个参数传入函数

第一个参数是字符串,第二个参数是函数的方式的情况下,在只有一个匹配项时(第一个参数是字符串所以只有一个匹配项)会向这个函数传递三个参数,函数的返回值将替换字符串的匹配部分:

  1. 模式的匹配项。
  2. 模式的匹配项在字符串中的位置。
  3. 原始字符串。
const text = 'cat,bat,sat,fat'
const result = text.replace('at', function (match, pos, orginalText) {
  console.log(match) // at
  console.log(pos) // 1
  console.log(orginalText) // cat,bat,sat,fat
  return 'ond' // 返回值为需要替换的新值
})

console.log(result) // cond,bat,sat,fat

12.1.3、第一个参数传入正则表达式,第二个参数传入字符串

如果第一个参数是正则表达式并带有g,就会替换所有子字符串。

const text = 'cat,bat,sat,fat'
const result = text.replace(/at/g, 'ond')
console.log(result) // cond,bond,sond,fond

如果第二个参数是字符串的话,还可以用特殊的字符序列,将正则表达式匹配得到的值插入结果字符串中。

字符序列替换文本
$&匹配整个模式的子字符串。与RegExp.lastMatch相同
$匹配子字符串之前的字符串 (匹配结果前面的文本)
$' 匹配子字符串之后的字符串 (匹配结果后面的文本)
$n匹配第n个捕获组的子字符串(n = 0~9)。如:$1匹配第一个捕获的字符串,$2是匹配第二个捕获的字符串,以此类推。如果没有捕获组,则值为空字符串。
$nn匹配第nn个捕获组的字符串,(n = 01~99)。如:$01匹配第一个捕获的字符串,$02是匹配第二个捕获的字符串,以此类推。如果没有捕获组,则值为空字符串。
  • 使用$&字符可以重复引用匹配的文本:
// $& 表示匹配的字符串,即`b`本身
const text = 'abab'
const result = text.replace('b', '{$&}')
console.log(result) //a{b}ab
  • 使用$'和$`字符替换内容:
const text = 'abbc'
// $`表示匹配结果之前的字符串
// 第一个`b`,$` 指代`a`
const result1 = text.replace('b', '{$`}')
console.log(result1) // a{a}bc

// $'表示匹配结果之后的字符串
// 第一个`b`,$' 指代`bc`
const result2 = text.replace('b', "{$'}")
console.log(result2) // a{bc}bc
  • 使用分组匹配组合新的字符串($n $nn):交换一个字符串中两个单词的位置,这个脚本使用$1$2代替替换文本。
const reg = /(ab)(bc)/g
const text = 'abbc'
// $1 表示正则表达式的第一个匹配组,指ab
// $2 表示正则表达式的第二个匹配组,指bc
const result = 'abbc'.replace(/(ab)(bc)/, '$2$1')
console.log(result) // bcab

12.1.4、第一个参数传入正则表达式和第二个参数传入函数

第一个参数传入正则表达式,第二个参数传入函数的方式的时候,在每次执行只有一个匹配项时会向这个函数传递三个参数,函数的返回值将替换字符串的匹配部分:

  1. 模式的匹配项。
  2. 模式的匹配项在字符串中的开始位置。
  3. 原始字符串。

下面是一个过滤特殊字符的例子:

function htmlEscape(text) {
  return text.replace(/[<>"&]/g, function (match, pos, originalText) {
    // ['<', 0, '<div class="root">Hello world!</div>']
    // ['"', 11, '<div class="root">Hello world!</div>']
    // ['"', 16, '<div class="root">Hello world!</div>']
    // ['>', 17, '<div class="root">Hello world!</div>']
    // ['<', 30, '<div class="root">Hello world!</div>']
    // ['>', 35, '<div class="root">Hello world!</div>']
    console.log(Array.from(arguments)) // 一共有6个匹配项,函数会执行6次,所以会依次输出6次
    switch (match) {
      case '<':
        return '&lt;'
      case '>':
        return '&gt;'
      case '&':
        return '&amp;'
      case '"':
        return '&quot;'
    }
  })
}
const result = htmlEscape(`<div class="root">Hello world!</div>`)
console.log(result) // &lt;div class=&quot;root&quot;&gt;Hello world!&lt;/div&gt;

函数htmlEscape()用于将一段HTML中的4个字符替换成对应的实体:小于号、大于号、 和号,还有双引号,都必须经过转义。这个函数返回一个字符串,表示应该把匹配项替换成什么。根据匹配的每个字符分别返回特定的HTML实体。

在正则表达式定义了多个捕获组的情况下,传递给函数的参数依次是func(match, p1, p2, ..., pn, offset, input, groups)

  • match:模式的匹配项。
  • p1, p2, ..., pn:第一个捕获组的匹配项、第二个捕获组的匹配项……。
  • offset:与整个模式匹配的开始位置。
  • input:原始字符串。

在下面的示例中,有两对括号,因此将使用5个参数调用替换函数:第一个是完全匹配项,然后是2对括号,然后是匹配位置和源字符串:

const str = 'html@css'
const result = str.replace(/(\w+)@(\w+)/, function (match, p1, p2, offset, input, groups) {
  console.log(Array.from(arguments)) //  ['html@css', 'html', 'css', 0, 'html@css']
  return p1 // html
})
console.log(result) // html

如果有许多组,用rest参数()可以很方便的访问:

const str = 'html@css'
const result = str.replace(/(\w+)@(\w+)/, function (...args) {
  console.log(args) //  ['html@css', 'html', 'css', 0, 'html@css']
  return args[2] // css
})
console.log(result) // css

12.2、replaceAll()

ES2021引入了replaceAll()方法,可以一次性替换所有匹配。它的用法与replace()相同,返回一个新字符串,不会改变原字符串。

replaceAll()这个方法接收两个参数:

  1. 第一个参数:可以是一个字符串或者是一个正则表达式。

  2. 第二个参数:是一个字符串也可以是一个函数。

StringObject.replaceAll(searchValue,replaceValue):

  1. StringObject:字符串

  2. searchValue:字符串或正则表达式

  3. replaceValue:字符串或者函数

  4. 返回值:一个部分或全部匹配新的字符串。

12.2.1、第一个参数传入字符串,第二个参数传入字符串

如果第一个参数是字符串,第二个参数也是字符串,会一次性替换所有匹配。

const text = 'cat,bat,sat,fat'
const result = text.replaceAll('at', 'ond')
console.log(result) // cond,bond,sond,fond

12.2.2、第一个参数传入字符串,第二个参数传入函数

第一个参数是字符串,第二个参数是函数的方式的情况下,会向这个函数传递三个参数,函数的返回值将替换字符串的匹配部分:

  1. 模式的匹配项。
  2. 模式的匹配项在字符串中的位置。
  3. 原始字符串。
const text = 'cat,bat,sat,fat'
const result = text.replaceAll('at', function (match, pos, orginalText) {
  // ['at', 1, 'cat,bat,sat,fat']
  // ['at', 5, 'cat,bat,sat,fat']
  // ['at', 9, 'cat,bat,sat,fat']
  // ['at', 13, 'cat,bat,sat,fat']
  console.log(Array.from(arguments)) // 一共有4个匹配项,函数会执行4次,所以会依次输出4次

  return 'ond' // 返回值为需要替换的新值
})
console.log(result) // cond,bond,sond,fond

12.1.3、第一个参数传入正则表达式,第二个参数传入字符串

如果第一个参数是正则表达式的时候,replaceAll必须设置全局g标识,否则replaceAll()会报错。

const text = 'cat,bat,sat,fat'
// 报错
const result = text.replaceAll(/at/, 'ond') // Uncaught TypeError:

设置全局g标识就不会有问题:

const text = 'cat,bat,sat,fat'
const result = text.replaceAll(/at/g, 'ond')
console.log(result) // cond,bond,sond,fond

如果第二个参数是字符串的话,还可以用特殊的字符序列,将正则表达式匹配得到的值插入结果字符串中(和replace()相似)。

字符序列替换文本
$&匹配整个模式的子字符串。与RegExp.lastMatch相同
$匹配子字符串之前的字符串 (匹配结果前面的文本)
$' 匹配子字符串之后的字符串 (匹配结果后面的文本)
$n匹配第n个捕获组的子字符串(n = 0~9)。如:$1匹配第一个捕获的字符串,$2是匹配第二个捕获的字符串,以此类推。如果没有捕获组,则值为空字符串。
$nn匹配第nn个捕获组的字符串,(n = 01~99)。如:$01匹配第一个捕获的字符串,$02是匹配第二个捕获的字符串,以此类推。如果没有捕获组,则值为空字符串。
  • 使用$&字符可以重复引用匹配的文本:
// $& 表示匹配的字符串,即`b`本身
const text = 'abab'
const result = text.replaceAll('b', '{$&}')
console.log(result) //a{b}a{b}
  • 使用$'和$`字符替换内容:
const text = 'abbc'
// $`表示匹配结果之前的字符串
// 第一个`b`,$` 指代`a`
// 第二个`b`,$` 指代`ab`
const result1 = text.replaceAll('b', '{$`}')
console.log(result1) // a{a}{ab}c

// $'表示匹配结果之后的字符串
// 第一个`b`,$' 指代`bc`
// 第二个`b`,$' 指代`c`
const result2 = text.replaceAll('b', "{$'}")
console.log(result2) // a{bc}{c}c
  • 使用分组匹配组合新的字符串($n $nn):交换一个字符串中两个单词的位置。使用$1$2代替替换文本。
const reg = /(ab)(bc)/g
const text = 'abbc'
// $1 表示正则表达式的第一个匹配组,指ab
// $2 表示正则表达式的第二个匹配组,指bc
const result = 'abbc'.replaceAll(/(ab)(bc)/g, '$2$1')
console.log(result) // bcab

12.1.4、第一个参数传入正则表达式和第二个参数传入函数

第一个参数传入正则表达式,第二个参数传入函数的方式的时候,在每次执行只有一个匹配项时会向这个函数传递三个参数,函数的返回值将替换字符串的匹配部分:

  1. 模式的匹配项。
  2. 模式的匹配项在字符串中的开始位置。
  3. 原始字符串。

下面是一个过滤特殊字符的例子:

function htmlEscape(text) {
  return text.replace(/[<>"&]/g, function (match, pos, originalText) {
    // ['<', 0, '<div class="root">Hello world!</div>']
    // ['"', 11, '<div class="root">Hello world!</div>']
    // ['"', 16, '<div class="root">Hello world!</div>']
    // ['>', 17, '<div class="root">Hello world!</div>']
    // ['<', 30, '<div class="root">Hello world!</div>']
    // ['>', 35, '<div class="root">Hello world!</div>']
    console.log(Array.from(arguments)) // 一共有6个匹配项,函数会执行6次,所以会依次输出6次
    switch (match) {
      case '<':
        return '&lt;'
      case '>':
        return '&gt;'
      case '&':
        return '&amp;'
      case '"':
        return '&quot;'
    }
  })
}
const result = htmlEscape(`<div class="root">Hello world!</div>`)
console.log(result) // &lt;div class=&quot;root&quot;&gt;Hello world!&lt;/div&gt;

函数htmlEscape()用于将一段HTML中的4个字符替换成对应的实体:小于号、大于号、 和号,还有双引号,都必须经过转义。这个函数返回一个字符串,表示应该把匹配项替换成什么。根据匹配的每个字符分别返回特定的HTML实体。

在正则表达式定义了多个捕获组的情况下,传递给函数的参数依次是func(match, p1, p2, ..., pn, offset, input, groups)

  • match:模式的匹配项。
  • p1, p2, ..., pn:第一个捕获组的匹配项、第二个捕获组的匹配项……。
  • offset:与整个模式匹配的开始位置。
  • input:原始字符串。

在下面的示例中,有两对括号,因此将使用5个参数调用替换函数:第一个是完全匹配项,然后是2对括号,然后是匹配位置和源字符串:

const str = 'html@css'
const result = str.replaceAll(/(\w+)@(\w+)/g, function (match, p1, p2, offset, input, groups) {
  console.log(Array.from(arguments)) //  ['html@css', 'html', 'css', 0, 'html@css']
  return p1 // html
})
console.log(result) // html

如果有许多组,用rest参数()可以很方便的访问:

const str = 'html@css'
const result = str.replaceAll(/(\w+)@(\w+)/g, function (...args) {
  console.log(args) //  ['html@css', 'html', 'css', 0, 'html@css']
  return args[2] // css
})
console.log(result) // css

12.3、search()

search()是查找模式的字符串方法。这个方法传入一个参数:正则表达式或RegExp对象。

这个方法返回第一个匹配的位置索引,如果没找到则返回-1。始终从字符串开头向后匹配模式。

const text = 'cat, bat, sat, fat'
const pos = text.search(/at/)
console.log(pos) // 1

search(/at/)返回1,即"t的第一个字符在字符串中的位置。

search仅查找第一个匹配项。

12.4、match()

match()方法接收一个参数,可以是一个正则表达式,也可以是一个RegExp对象。在字符串str中找到匹配regexp的字符。

match()的使用方式和exec()的使用方式基本一致,有一点需要注意,是否带有标志g,它们的输出结果就不一样。

12.4.1、无全局标记 g

如果没有设置全局标记g,那么str.match(regexp) 返回的第一个匹配和regexp.exec(str)完全相同。

const str = 'cat, bat, sat'
const reg = /.at/

let res = str.match(reg)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 0

res = str.match(reg)
console.log(res[0]) // cat
console.log(res.index) // 0
console.log(reg.lastIndex) // 0

12.4.2、有全局标记 g

如果设置全局标记g,则会返回所有匹配上的内容:

const str = 'cat, bat, sat, fat'
let reg = /.at/g
let res = str.match(reg)
console.log(res) // ['cat', 'bat', 'sat', 'fat']

res = str.match(reg)
console.log(res) // ['cat', 'bat', 'sat', 'fat']

res = str.match(reg)
console.log(res) // ['cat', 'bat', 'sat', 'fat']

如果没有匹配项,则无论是否带有全局标记g,都将返回null

12.5、matchAll()

ES2020增加了String.prototype.matchAll()方法,可以一次性取出所有匹配。它返回的是一个可迭代对象,而不是数组。可以用for...of循环取出。

const str = 'cat, bat, sat, fat'
const reg = /.at/g
const res = str.matchAll(reg)
console.log(res) // RegExpStringIterator {}

for (const m of res) {
  // ['cat', index: 0, input: 'cat, bat, sat, fat', groups: undefined]
  // ['bat', index: 5, input: 'cat, bat, sat, fat', groups: undefined]
  // ['sat', index: 10, input: 'cat, bat, sat, fat', groups: undefined]
  // ['fat', index: 15, input: 'cat, bat, sat, fat', groups: undefined]
  console.log(m)
}

如果没有结果,则返回的不是 null,而是一个空的可迭代对象。

12.6、split()

使用正则表达式(或子字符串)作为分隔符来分割字符串。

我们可以用split来分割字符串,如下所示:

console.log('12-34-56'.split('-')) // ['12', '34', '56']

我们也可以用正则表达式来做:

console.log('12, 34, 56'.split(/,\s*/)) // ['12', '34', '56']

十三、使用小案例

使用自定义函数将字符串大写字母改为小写字母:

const result = 'JAVASCRIPT'.replace(/[A-G]/g,function(match,pos,orginalText){ 
  return arguments[0].toLowerCase(); 
})
//JaVaScRIPT
console.log(result)

简单的JavaScript渲染模板:

function substitute (str, obj) {
  return str.replace(/\{([^{}]+)\}/g, function(match, key) {
    var value = obj[key];
    return value !== undefined ? `${value}` : '';
  });
}
const obj = {
    url: 'http://www.163.com',
    title: '我是标题',
    text: '我是文本'
};
const link = '<a href="{url}" title="{title}">{text}</a>';
const result = substitute(link, obj);
//<a href="http://www.163.com" title="我是标题">我是文本</a>
console.log(result)

数字转为千分位:

const text = '1234567890';
const result = text.replace(/(\d)(?=(\d{3})+(?!\d))/g,'$1,');
//1,234,567,890           
console.log(result)

驼峰和-互相转换:

const text = 'myName'
const result = text.replace(/([A-Z])/g,'-$1').toLowerCase()
// my-name
console.log(result)
const text = 'my-name'
const result = text.replace(/-(\w)/g,function(match,matches,pos){
  return matches.toUpperCase()
})
// myName
console.log(result)

把手机号第4位到第7位替换成****:

const text = '15560088888'
const result = text.replace(/1(\d{2})\d{4}(\d{4})/g,'1$1****$2')
// 155****8888
console.log(result)

十四、RegExp 构造函数属性

RegExp构造函数的所有属性都没有任何Web标准出处,因此不要在生产环境中使用它们。这里只做介绍。

RegExp构造函数本身也有几个属性。这些属性适用于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。这些属性还有一个特点,就是可以通过两种不同的方式访问它们。换句话说,每个属性都有一个全名和一个简写。下表列出了RegExp构造函数的属性。

全名简写说明
input$_最后搜索的字符串(非标准特性)
lastMatch$&最近一次匹配的文本
lastParen$+最近一次捕获的文本(非标准特性)
leftContext$`字符串中出现在 lastMatch 前面的文本
rightContext$'字符串中出现在 lastMatch 后面的文本

通过这些属性可以提取出与exec()test()执行的操作相关的信息。来看下面的例子:

let str = 'this has been a short summer'
let reg = /(.)hort/g
if (reg.test(str)) {
  console.log(RegExp.input) // this has been a short summer

  // 注意有空格
  console.log(RegExp.leftContext) // "this has been a "
  console.log(RegExp.rightContext) // " summer"
  console.log(RegExp.lastMatch) //  short
  console.log(RegExp.lastParen) // s
}

以上代码创建了一个模式,用于搜索任何后跟hort的字符,并把第一个字符放在了捕获组中。不同属性包含的内容如下:

  • input属性中包含原始的字符串。
  • leftConext属性包含原始字符串中short之前的内容,rightContext属性包含short之后的内容。
  • lastMatch属性包含匹配最近一次匹配的文本,即short
  • lastParen属性包含最近一次捕获组的文本,即s

这些属性名也可以替换成简写形式,只不过要使用中括号语法来访问,如下面的例子所示,因为大多数简写形式都不是合法的ECMAScript标识符:

let str = 'this has been a short summer'
let reg = /(.)hort/g
if (reg.test(str)) {
  /*
   * 注意:Opera 不支持简写属性名
   * IE 不支持多行匹配
   */
  console.log(RegExp.$_) // this has been a short summer

  // 注意有空格
  console.log(RegExp['$`']) // "this has been a "
  console.log(RegExp["$'"]) // " summer"
  console.log(RegExp['$&']) //  short
  console.log(RegExp['$+']) // s
}

RegExp还有其他几个构造函数属性,可以存储最多9个捕获组的匹配项。这些属性通过 RegExp.$1-RegExp.$9来访问,分别包含第1~9个捕获组的匹配项。在调用exec()test()时,这些属性就会被填充,然后就可以像下面这样使用它们:

let str = 'this has been a short summer'
let reg = /(..)mm(..)/g
if (reg.test(str)) {
  console.log(RegExp.$1) // su
  console.log(RegExp.$2) // er
}

在这个例子中,模式包含两个捕获组。调用test()搜索字符串之后,因为找到了匹配项所以返回true,而且可以打印出通过RegExp构造函数的$1$2属性取得的两个捕获组匹配的内容。

十五、参考

www.cnblogs.com/onepixel/ar…

zh.javascript.info/regular-exp…

juejin.cn/post/684490…

dailc.github.io/2017/08/01/…

segmentfault.com/a/119000001…

learn.lianglianglee.com/%E4%B8%93%E…

segmentfault.com/a/119000002…

regexlearn.com/zh-cn/learn…

book.douban.com/subject/351…