# 正则表达式

正则表达式是用于匹配字符串中字符组合的模式。在 JavaScript 中,正则表达式也是对象。

所以,你可以调用 RegExp 对象的构造函数来构建一个正则表达式:

var patt = new RegExp('pattern', 'flags')

# 创建正则表达式

除了使用构造函数创建正则表达式外,你还可以更简单地使用一个正则表达式字面量:

var patt = /pattern/afgls

脚本加载后,正则表达式字面量就会被编译。而用构造函数创建的正则表达式,会在脚本运行过程中被编译

当正则表达式保持不变时,使用正则表达式字面量可获得更好的性能。

如果正则表达式将会改变,或者它将会从用户输入等来源中动态地产生,就需要使用构造函数来创建正则表达式。

另外,需要注意的是,由于传递给构造函数的变量是字符串,而字符串中原本就存在转义字符,所以如果你要传递一个真正的反斜杠的话,你应该书写两次。

/*
var patt = new RegExp('\d')
console.log(patt.test(9)) // false
console.log(patt.test('d')) // true
*/

var patt = new RegExp('\\d')
console.log(patt.test(9)) // true
console.log(patt.test('d')) // false

# 基础结构

一个正则表达式通常由元字符和修饰符两部分组成。

修饰符包括:

修饰符 描述
i 执行对大小写不敏感的匹配
g 执行全局匹配(查找所有匹配而非在找到第一个匹配后停止)
m 执行多行匹配
s 允许 . 匹配换行符
u 使用 unicode 码的模式进行匹配
y 执行“粘性(sticky)”搜索,匹配从目标字符串的当前位置开始

元字符又包含量词、断言、字符类、Unicode 属性转义、组和范围。

量词元字符包括:

元字符 描述
n+ 匹配任何包含至少一个 n 的字符串
n* 匹配任何包含零个或多个 n 的字符串
n? 匹配任何包含零个或一个 n 的字符串
n{X} 匹配包含 X 个 n 的序列的字符串
n{X,} X 是一个正整数。前面的模式 n 连续出现至少 X 次时匹配
n{X,Y} X 和 Y 为正整数。前面的模式 n 连续出现至少 X 次,至多 Y 次时匹配

断言包含边界类断言和其他断言。

边界类断言包括;

元字符 描述
^n 匹配任何开头为 n 的字符串
n$ 匹配任何结尾为 n 的字符串
\b 匹配单词边界
\B 匹配非单词边界

其他断言包括:

元字符 描述
x(?=y) 向前断言
x(?!y) 向前否定断言
(?<=y)x 向后断言
(?<!y)x 向后否定断言

字符类包括:

元字符 描述
. 查找单个字符,除了换行和行结束符
\w 查找单词字符
\W 查找非单词字符
\d 查找数字
\D 查找非数字字符
\s 查找空白字符
\S 查找非空白字符
\0 查找 NULL 字符
\n 查找换行符
\f 查找换页符
\r 查找回车符
\t 查找制表符
\v 查找垂直制表符
[\b] 匹配一个退格(U+0008)
\cX 使用脱字符表示法匹配一个控制字符
\xdd 查找以十六进制数 dd 规定的字符
\uxxxx 查找以十六进制数 xxxx 规定的 Unicode 字符
\u{hhhh} or \u{hhhhh} 将字符与 Unicode 值 U+hhhh 或 U+hhhhh(十六进制数字)匹配
\ 转义符号

组和范围包括:

元字符 描述
x | y 匹配 "x" 或 "y" 任意一个字符
[xyz] 字符集。 匹配任何一个包含的字符。您可以使用连字符来指定字符范围,但如果连字符显示为方括号中的第一个或最后一个字符,则它将被视为作为普通字符包含在字符集中的文字连字符。也可以在字符集中包含字符类
[^xyz] 一个否定的或被补充的字符集。也就是说,它匹配任何没有包含在括号中的字符
(x) 捕获组: 匹配 x 并记住匹配项
\n 其中 n 是一个正整数。对正则表达式中与 n 括号匹配的最后一个子字符串的反向引用(计算左括号)
(?<Name>x) 具名捕获组: 匹配 "x" 并将其存储在返回的匹配项的 groups 属性中,该属性位于 <Name> 指定的名称下。尖括号(< 和 >) 用于组名。
(?:x) 非捕获组: 匹配 “x”,但不记得匹配。不能从结果数组的元素中收回匹配的子字符串

最后的 Unicode 属性转义 正则表达式支持根据 Unicode 属性进行匹配。

例如我们可以用它来匹配出表情、标点符号、字母(甚至适用特定语言或文字)等。同一符号可以拥有多种 Unicode 属性,属性则有 binary ("boolean-like") 和 non-binary 之分。

更多信息可点击查看 Unicode property escapes - JavaScript | MDN (opens new window)

# 常用元字符

^n:匹配任何开头为 n 的字符串。

n$:匹配任何结尾为 n 的字符串。

// 当不使用这两个字符时,只需要包含符合规则的内容
var str1 = 'Test666test'
var str2 = '666'
var patt = /\d+/ // 匹配连续的数字

console.log(patt.test(str1)) // true
console.log(patt.test(str2)) // true

// 加上两者后则必须完全符合规则
var str1 = 'Test666test'
var str2 = '666'
var patt = /^\d+$/ // 匹配以数字开头和结尾的数字(也就是全是数字)

console.log(patt.test(str1)) // false
console.log(patt.test(str2)) // true

转义符号(\):在特殊字符之前的反斜杠表示下一个字符不是特殊字符,应该按照字面理解。

var str1 = '2.3'
var str2 = '2@3'
var patt = /2.3/ // 由于 . 默认匹配除换行符之外的任何单个字符,所以不仅仅会匹配小树 2.3

console.log(patt.test(str1)) // true
console.log(patt.test(str2)) // true

// 如果想要精确的匹配小树 2.3,我们需要对 . 进行转义
var str1 = '2.3'
var str2 = '2@3'
var patt = /2\.3/

console.log(patt.test(str1)) // true
console.log(patt.test(str2)) // false

x|y|z:查找任何指定的选项。

// 单独使用可能会造成令人疑惑的结果
var patt = /^12|34$/

console.log(patt.test('12')) // true
console.log(patt.test('34')) // true
console.log(patt.test('1234')) // true
console.log(patt.test('124')) // true
console.log(patt.test('134')) // true
console.log(patt.test('123')) // true
console.log(patt.test('234')) // true

// 所以通常会和分组一起使用
var patt = /^(12|34)$/

console.log(patt.test('12')) // true
console.log(patt.test('34')) // true
console.log(patt.test('1234')) // false
console.log(patt.test('124')) // false
console.log(patt.test('134')) // false
console.log(patt.test('123')) // false
console.log(patt.test('234')) // false

在中括号中的字符通常都表示字符本身(但 \d 和连字符仍然具备特俗的含义),而且不存在多位数。

var patt1 = /^[@+]$/

console.log(patt1.test('@')) // true
console.log(patt1.test('+')) // true
console.log(patt1.test('@@')) // false

var patt2 = /^[\d]$/

console.log(patt2.test('1')) // true
console.log(patt2.test('@')) // false

var patt3 = /^[a-c]$/

console.log(patt3.test('b')) // true
console.log(patt3.test('z')) // false

# 常用正则表达式

验证是否为有效数字,规则分析:

  • 可能存在正负号
  • 主体内容由数字组成,多位数是不能以零开头
  • 可能存在小数点,存在时后面必须跟着数字
var patt = /^[+-]?(\d|([1-9]\d+))(\.\d+)?$/

验证密码是否合法,基础规则:

  • 由数字、字母和下划线组成
  • 长度在 6-16 位之间
var patt = /^\w{6,16}$/

验证中文名,基本规则:

  • 应当仅包含中文
  • 长度在 2-10 之间
  • 可能会存在译名(用 · 连接),并且最多只能出现两次
var patt = /^[\u4E00-\u9FA5]{2,10}(·[\u4E00-\u9FA5]{2,10}){0,2}$/

验证邮箱,规则分析:

  • 左边部分可以有数字、字母、下划线和英语句号
  • 右边部分是域名,按照域名的规则,可以有数字、字母、短横线和英语句号
  • 域名可能存在多级
var patt = /^\w+((-\w+)|(\.\w+))*@[A-z\d]+((\.|-)[A-z\d]+)*\.[A-z\d]+$/

// 纯数字
console.log(patt.test('1234@qq.com'))
// 纯字母
console.log(patt.test('test@126.com'))
// 数字、字母混合
console.log(patt.test('test123@126.com'))
// 多级域名
console.log(patt.test('test123@vip.163.com'))
// 含下划线 _
console.log(patt.test('test_email@outlook.com'))
// 含英语句号 .
console.log(patt.test('test.email@gmail.com'))

# 正则的懒惰性

以正则的 exec() 方法为例,默认情况下,无论该方法执行多少次都只能捕获到第一个匹配的数据,这种现象就叫正则的懒惰性。

// 运行代码,会发现两次的匹配结果完全一致
const str = 'kisstar2020@2021start'
const reg = /\d+/
console.log(reg.lastIndex) // 0
console.log(reg.exec(str))
console.log(reg.lastIndex) // 0
console.log(reg.exec(str))

在上例中,我们发现匹配完成后 lastIndex 属性的值并没有发生改变,所以下一次依然会从字符串开头开始查找,所以找的永远都是第一个匹配到的数据。

我们尝试手动更改 lastIndex 属性的值,但默认情况下这是无效的:

// 运行代码,会发现两次的匹配结果完全一致
const str = 'kisstar2020@2021start'
const reg = /\d+/
console.log(reg.lastIndex) // 0
console.log(reg.exec(str))
reg.lastIndex = 11 // 尝试手动更改
console.log(reg.lastIndex) // 11
console.log(reg.exec(str))

如果使用修饰符 g 则每次匹配会自动更改 lastIndex 属性的值,如此就解决了正则懒惰性的问题。

const str = 'kisstar2020@2021start'
const reg = /\d+/g
console.log(reg.lastIndex) // 0
console.log(reg.exec(str)[0]) // 2020
console.log(reg.lastIndex) // 11
console.log(reg.exec(str)[0]) /// 2021

当全局捕获都已完成以后,再次捕获的结果是 null,此时 lastIndex 的值将回归到初始值 0。

同理,如果正则表达式设置了全局标志,test() 的执行也会改变正则表达式 lastIndex 属性,但匹配失败时会重置 lastIndex 的值。

# 正则的贪婪性

默认情况下,像 *+ 这样的量词是“贪婪的”,这意味着它们试图匹配尽可能多的字符串。

const str = '2020'
const reg = /\d+/g
console.log(str.match(reg))

在上面的案列中,我们想要匹配一个以上的数字,也就是说每遇到一个数字就符合要求,想象中的结果可能是 [ '2', '0', '2', '0' ],但事实上却是 [ '2020' ]

由于正则的贪婪性,既然一个数字也行,两个也行...,那么正则就尽可能多的匹配,有多少就取多少。但是如何匹配到想象中的结果呢?

如果在这些量词的后面加上 ? 则会使量词变得“非贪婪”:意思是它一旦找到匹配就会停止。

const str = '2020'
const reg = /\d+?/g
console.log(str.match(reg))

所以说做人不能太正则,又懒又贪(滑稽)。

# 分组和捕获

在正则表达式中你可以用小括号来指定一个子表达式(捕获组)。形如 (x),表示匹配 “x”,并记住匹配项。

正则表达式可以有多个捕获组,匹配的结果通常会被放置到一个数组中,你可以使用结果元素的索引([1], ..., [n])或从预定义的 RegExp 对象的属性($1, ..., $9)进行获取。

const personList = `First_Name: John, Last_Name: Doe
First_Name: Jane, Last_Name: Smith`
const regexpNames = /First_Name: (\w+), Last_Name: (\w+)/gm
const match = regexpNames.exec(personList)

console.log(match[1], match[2]) // John Doe
console.log(RegExp.$1, RegExp.$2) // John Doe

捕获组会带来性能损失。如果不需要收回匹配的子字符串,请选择使用非捕获组。形如 (?:x),表示匹配 “x”,但不捕获。

const personList = `First_Name: John, Last_Name: Doe
First_Name: Jane, Last_Name: Smith`
const regexpNames = /First_Name: (?:\w+), Last_Name: (?:\w+)/gm
const match = regexpNames.exec(personList)

console.log(match[1], match[2]) // undefined undefined
console.log(RegExp.$1, RegExp.$2) // "" ""

除了普通的捕获外,你还可以使用具名捕获组,形如 (?<Name>x),表示匹配 "x" 并将其存储在返回的匹配项的 groups 属性中,该属性位于 <Name> 指定的名称下。

const personList = `First_Name: John, Last_Name: Doe
First_Name: Jane, Last_Name: Smith`
const regexpNames = /First_Name: (?<first>\w+), Last_Name: (?<last>\w+)/gm
const match = regexpNames.exec(personList)

console.log(`Hello ${match.groups.first} ${match.groups.last}`) // Hello John Doe

此时你依然可以使用结果元素的索引或从预定义的 RegExp 对象的属性来获取分组捕获的结果。

另外,常用分组语法还包括:

分类 语法 说明
捕获 (exp) 匹配 exp,并捕获文本到自动命名的组里
(?<name>exp) 匹配 exp,并捕获文本到名称为 name 的组里
(?:exp) 匹配 exp,不捕获匹配的文本,也不给此分组分配组号
零宽断言 (?=exp) 匹配 exp 前面的位置
(?<=exp) 匹配 exp 后面的位置
(?!exp) 匹配后面跟的不是 exp 的位置
(?<!exp) 匹配前面不是 exp 的位置
注释 (?#comment) 此类分组不对正则表达式的处理产生任何影响,用于提供注释让人阅读

# 分组引用

后向引用以分组作为前提,所以如果想要实现后向引用,则必须先进行分组。

默认情况下,每个分组会自动拥有一个组号,规则是:从左向右,以分组的左括号为标志,第一个出现的分组的组号为 1,第二个为 2,以此类推。

后向引用用于重复搜索前面某个分组匹配的文本。例如:\1 代表分组 1 匹配的文本。

const str = 'apple, orange, cherry, peach'
const reg = /apple(,)\sorange\1/ //其中 \1 引用了之前使用()捕获的逗号
console.log(str.match(reg)[0]) //apple, orange,

在这里,你也可以自己指定子表达式的组名,要反向引用这个分组捕获的内容,你可以使用形如 \k<Word> 的语法:

const str = 'apple, orange, cherry, peach'
const reg = /apple(?<comma>,)\sorange\k<comma>/ //其中 \1 引用了之前使用()捕获的逗号
console.log(str.match(reg)[0]) //apple, orange,

# 零宽断言

正向预查形如 x(?=y),也叫零宽度正预测先行断言,它断言 x 出现的位置的后面能匹配表达式 y,换句话说,xy 跟随时则匹配 x

const str = 'I am singing while you are dancing.'
const reg = /\b\w+(?=ing\b)/g
console.log(reg.exec(str))
console.log(reg.exec(str))

示例中将会匹配以 ing 结尾的单词的前半部分,结果中并不会包含分组中的内容。

与此相关的一个招聘题目要求对一串数字使用千分制表示,利用正向预查可以很容易做到:

const str = '99999999999'
const ret = str.replace(/\d{1,3}(?=(\d{3})+$)/g, '$&,')
console.log(ret) // 99,999,999,999

向后断言形如 (?<=y)x,也叫零宽度正回顾后发断言,它断言 x 出现的位置的前面能匹配表达式 y,换言之,x 跟随 y 的情况下匹配 x

# 负向零宽断言

向前否定断言形如 x(?!y),也叫零宽度负预测先行断言,它断言 x 的后面不能匹配表达式 y,换言之,x 没有被 y 紧随时匹配 x

// 例如,对于 `/\d+(?!\.)/`,数字后没有跟随小数点的情况下才会得到匹配
console.log(/\d+(?!\.)/.exec(3.141)[0]) // 141

同理,我们也可以用向后否定断言(零宽度负回顾后发断言,形如 (?<!y)x)来断言 x 的前面不能匹配表达式 y,也就是说 x 不跟随 y 时匹配 x

// 对于 /(?<!-)\d+/,数字紧随-符号的情况下才会得到匹配
console.log(/(?<!-)\d+/.exec(3)[0]) // 3
console.log(/(?<!-)\d+/.exec(-3)) // null

# 参考