正则表达式

基本概念

概念

正则表达式(Regular Expression),简称 regex,是一种特殊的文本模式(特定的规则、模板或结构),用于在字符串中搜索、匹配和替换文本。

正则表达式在计算机领域的发展

发展史

  • 20 世纪 40 年代,两个神经生理学家研究出了一种用数学方式来描述神经网络的方法,可以将神经系统中的神经元描述成小而简单的自动控制元。

  • 20 世纪 50 年代,Stephen Kleene 在以上两位的基础上发表了《神经网络事件表示法和有穷自动机》 论文。这篇论文描述了一种叫做 "正则集合(Regular Sets)" 的数学符号,引入了正则表达式的概念。

  • 20 世纪 60 年代,Unix 之父 Ken Thompson 发表了 《正则表达式搜索算法》 论文。并应用在文本搜索工具 grep 中。

  • 20 世纪 70 年代,由于 grep 支持的功能不多,出现了 egrep 、 awk 、 lex 、 sed 等,每个程序所支持的正则表达式都有差别。

  • 20 世纪 80 年代,POSIX(Portable Operating System Interface)标准公布,其中就包含不同操作系统都需要遵守的正则表达式规范。

  • 20 世纪 90 年代,在 80 年代发布的 Perl 编程语言,引入了正则表达式功能,并随着语言的发展越来越强大,为了把 Perl 语言的正则表达式功能移植到其他语言中,诞生了开源项目 PCRE(Perl Compatiable Regular Expressions)

正则表达式规范分类

遵循 POSIX 规则的正则表达式,称为 POSIX 派系的正则表达式。Unix 系统或类 Unix 系统上的大部分工具,如 grep 、sed 、awk 等都属于 POSIX 派系。

遵循 PCRE 规范的正则表达式,成为 PCRE 派系的正则表达式。现代编程语言如 Python , Ruby , PHP , C / C++ , Java 等正则表达式,大部分都属于 PCRE 派系,或参照 PCRE 派系发展而来。

POSIX 派系分为两个标准

  • BRE 标准(Basic Regular Expression 基本正则表达式)

  • ERE 标准(Extended Regular Expression 扩展正则表达式)

PCRE 跟 POSIX 相比有哪些优势?

PCRE 使用起来更加易用简洁(不需要转义,有更简洁字符组),功能更加丰富(非捕获组,环顾断言,非贪婪)。如果没有特殊原因,应尽可能使用 PCRE 派系,让正则匹配的结果更符合我们预期。

不同正则表达式规格和引擎的区别概览

正则表达式引擎/风味对比

基础语法

创建方式

RegExp 字面量

const reg = /hello/i

RegExp() 构造函数

const reg = new RegExp('hello', 'i')

new RegExp() 创建字面量形式

var regex = new RegExp(/xyz/i)
// 等价于
var regex = /xyz/i

其实还有一种形式的字面量 new RegExp(/xyz/i) ,注意第一个参数不是字符串,而是正则表达式字面量。

这样的写法就会存在正则表达式字面量中修饰符和 RegExp 构造函数第二个参数冲突的情况。在 ES5 中不允许此时使用第二个参数添加修饰符,否则会报错。

var regex = new RegExp(/xyz/, 'i');
// Uncaught TypeError: Cannot supply flags when constructing one RegExp from another

ES6 改变了这种行为。如果 RegExp 构造函数第一个参数是一个正则对象,那么可以使用第二个参数指定修饰符。而且,返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。

new RegExp(/abc/ig).flags // gi
new RegExp(/abc/ig, 'i').flags // i ,原有正则对象的修饰符是ig,它会被第二个参数i覆盖

字面量和构造函数的区别

  • 反斜杠 \ 进行转义时,字面量语法需要一个反斜杠 \ ,而 RegExp 构造函数需要两个反斜杠 \\(因为字符串内部会先转义一次)

例如:匹配字符串 1+1=2

const str = '1+2=3'
const reg1 = /1\+2=3/
const reg2 = new RegExp('1\\+2=3') // 两个反斜杠
reg1.test(str)
reg2.test(str)

以下是常见需要转义的字符:

  1. 反斜杠 (\):用于开始一个转义序列。

  2. 点 (.):匹配除换行符之外的任何单个字符。

  3. 星号 (*):表示零次或多次匹配前面的元素。

  4. 加号 (+):表示一次或多次匹配前面的元素。

  5. 问号 (?):表示零次或一次匹配前面的元素;也用于表示非贪婪匹配。

  6. 圆括号 (()):用于分组。

  7. 方括号 ([]):用于定义字符集。

  8. 花括号 ({}):用于指定数量限定符。

  9. 竖线 (|):表示选择(或)。

  10. 脱字符 (^):表示行的开始;在字符集中用来表示否定。

  11. 美元符号 ($):表示行的结束。

  12. 正斜杠 (/):如果你在 / 之间使用正则表达式,也需要转义。

  • 字面量语法在性能方面更有优势,如果正则表达式在代码中是固定的,不需要动态生成,那么使用字面量语法(如 /pattern/flags)通常更高效,因为 JavaScript 引擎会在脚本加载时编译它们。相比之下,使用 RegExp 构造函数创建的正则表达式可能会在每次使用时都重新编译。

  • 字面量语法更简洁方便和直观

  • RegExp 构造函数的最大的优势是能够动态地生成正则表达式。你可以在运行时根据变量或表达式构造一个正则表达式,这在使用字面量形式时做不到。

字面量字符

如果在正则表达式中,某个字符只表示它字面的含义,那他们就叫做字面量字符(Literal Characters)

例如下面代码中正则表达式的 d、 o 、g 三个字符,就是字面量字符

/dog/.test('old dog') // true

元字符

有一部分字符有特殊含义,不代表字面的意思,这些字符就是元字符(Meta Characters),元字符有以下几种:

  1. 反斜杠 (\):用于开始一个转义序列。

  2. 点 (.):匹配除换行符之外的任何单个字符。

  3. 星号 (*):表示零次或多次匹配前面的元素。

  4. 加号 (+):表示一次或多次匹配前面的元素。

  5. 问号 (?):表示零次或一次匹配前面的元素;也用于表示非贪婪匹配。

  6. 圆括号 (()):用于分组。

  7. 方括号 ([]):用于定义字符集。

  8. 花括号 ({}):用于指定数量限定符。

  9. 竖线 (|):表示选择(或)。

  10. 脱字符 (^):表示行的开始;在字符集中用来表示否定。

  11. 美元符号 ($):表示行的结束。

转义符

正则表达式中那些有特殊含义的元字符,如果要匹配它们本身,就需要在它们前面要加上反斜杠。比如要匹配字符串 +,就要写成 \+

正则表达式中,需要反斜杠转义的,一共有 12 个字符:反斜杠 (\)、点 (.)、星号 (*)、加号 (+)、问号 (?)、圆括号 (())、方括号 ([])、花括号 ({})、竖线 (|)、脱字符 (^)、美元符号 ($)。需要特别注意的是,如果使用 RegExp 构造函数生成正则对象,转义需要使用两个斜杠,因为字符串内部会先转义一次。

怎么理解因为字符串内部会先转义一次

在字符串内部,反斜杠也是转义字符,所以它需要先被反斜杠转义一次。

var str = 'ab\c\*'
var str1 = 'ab\\c\\*'
console.log(str) // abc* 结果是不带转译符的
console.log(str1) // ab\c\*

点字符(.

点字符(.)匹配除回车()、换行() 、行分隔符(\u2028)和段分隔符(\u2029)以外的所有字符。

/c.t/.test('cat') // true

注意:对于码点大于 0xFFFF 的 Unicode 字符,点字符不能正确匹配,会认为这是两个字符。

选择符(|

竖线符号(|)在正则表达式中表示“或关系”(OR)。

选择符会包括它前后的多个字符,注意,是多个字符

比如/ab|cd/指的是匹配 ab 或者 cd,而不是指匹配 b 或者 c

'abd'.match(/ab|cd/g) // 返回 ['ab']

如果想要修改成单个字符,可以使用圆括号

'abd'.match(/a(b|c)d/g) // 返回 ['abd']

锚字符

锚字符包含两个位置字符和两个位置预定义模式

  • ^:匹配字符串的开头,在多行检索中,匹配一行的开头

  • $:匹配字符串的结尾,在多行检索中,匹配一行的结尾

  • \b:匹配词的边界

  • \B:匹配非词边界,即在词的内部

// \b 的例子
/\bworld/.test('hello world') // true
/\bworld/.test('hello-world') // true
/\bworld/.test('helloworld') // false

// \B 的例子
/\Bworld/.test('hello-world') // false
/\Bworld/.test('helloworld') // true

上面代码中,\b 表示词的边界,所以 world 的词首必须独立(词尾是否独立未指定),才会匹配。同理,\B 表示非词的边界,只有 world 的词首不独立,才会匹配。

其实零宽正向先行断言 (?=) 和零宽负向先行断言 (?!) 也属于锚字符的范畴。

量词

量词用来描述前面的相邻的(单个或一组)字符匹配规则出现的次数

  • {n,m}:匹配前一项至少 n 次,但不能超过 m 次

  • {n,}:匹配前一项 n 次及以上

  • {n}:匹配前一项 n 次

  • ?:匹配前一项 0 次或 1 次,也就是说前一项是可选的,等价于 {0,1}

  • +:匹配前一项 1 次或多次,等价于 {1,}

  • *:匹配前一项 0 次或多次,等价于 {0,}

预定义模式

  • \d:匹配 0-9 之间的任一数字,相当于[0-9]

  • \D:匹配所有 0-9 以外的字符,相当于[^0-9]

  • \w:匹配任意的字母、数字和下划线,相当于[A-Za-z0-9_]

  • \W:除所有字母、数字和下划线以外的字符,相当于[^a-za-z0-9_]

  • \s:匹配空格(包括换行符、制表符、空格符等),相等于[ \t\r\n\v\f]

  • \S:匹配非空格的字符,相当于[^ \t\r\n\v\f]

  • \b:匹配词的边界(边界包括:空格、起始、结束)

  • \B:匹配非词边界,即在词的内部

扩展:跟 [^] 类似,[\S\s] 指代一切字符。

字符类

字符类(class)又被称为 字符组 或 字符集合,表示有一系列字符可供选择,只要匹配其中一个就可以了。所有可供选择的字符都放在方括号内,比如 [xyz] 表示 x、y、z 之中任选一个匹配

方括号内的脱字符(^

^ 在方括号外用来匹配行的开头,但是在方括号内(开头第一个位置),则表示除了字符类之中的字符,其他字符都可以匹配。

//字符串bbc news包含a、b、c以外的其他字符,所以返回true
/[^abc]/.test('bbc news') // true

//字符串bbc不包含a、b、c以外的其他字符,所以返回false
/[^abc]/.test('bcc')

注意:如果方括号内没有其他字符,即只有[^],就表示匹配一切字符,其中包括换行符。相比之下,点号作为元字符(.)是不包括换行符的。

var s = 'Please yes\nmake my day!';

s.match(/yes.*day/) // null
s.match(/yes[^]*day/) // [ 'yes\nmake my day']

注意,脱字符只有在字符类的第一个位置才有特殊含义,否则就是字面含义。

/[a^b]/.test('ccc^')// true

方括号内的连字符(-

- 在方括号外匹配普通连字符号,但是在方括号内(不在开头),则表示字符的连续范围。比如,[abc] 可以写成 [a-c][0123456789] 可以写成 [0-9],同理[A-Z]表示 26 个大写字母。

/a-z/.test('b') // false
/[a-z]/.test('b') // true

以下都是合法的字符类简写形式

[0-9,]
[0-9a-fA-F]
[a-zA-Z0-9-] // 注意:最后一个 - ,只是代表普通连字符号
[1-31] //注意:不代表1到31,只代表1到3

连字符还可以用来指定 Unicode 字符的范围

var str = "\u0130\u0131\u0132";
/[\u0128-\uFFFF]/.test(str)
// true

注意:不要过分使用连字符,设定一个很大的范围,否则很可能选中意料之外的字符

最典型的例子就是[A-z],表面上它是选中从大写的 A 到小写的 z 之间 52 个字母,但是由于在 ASCII 编码之中,大写字母与小写字母之间还有其他字符,结果就会出现意料之外的结果。

// 由于反斜杠(’\‘)的ASCII码在大写字母与小写字母之间,结果会被选中
/[A-z]/.test('\\') // true

如果想让方括号中的连字符匹配一个普通的连字符,连字符必须放在字符组的开头,保证它是一个普通字符。

方括号内的其他需要转义的元字符

需要转义的元字符,除了\ 需要转义,不能直接写在方括号中外, . + ? * { } / ( ) $ | [ ]在方括号内只是匹配普通字符

/[.+?*{}/()$|]/.test('*') // true

组匹配

组匹配相关的有分组字符、非捕获分组、引用字符

分组字符

正则表达式的括号表示分组匹配,括号中的模式可以用来匹配分组的内容。

可通过 *+? 和 竖线| 等符号修饰

/hi+/.exec('hihi') // 只能匹配 'hi'
/(hi)+/.exec('hihi') // 可以匹配 'hihi'

可以进行分组捕获

// 正则表达式/(.)b(.)/一共使用两个括号,第一个括号捕获a,第二个括号捕获c。
'abcabc'.match(/(.)b(.)/) // // ['abc', 'a', 'c']

注意:使用分组捕获时,不宜同时使用 g 修饰符,否则 match 方法不会捕获分组的内容。

'abcabc'.match(/(.)b(.)/g) // ['abc', 'abc']

上面代码使用带 g 修饰符的正则表达式,结果 match 方法只捕获了匹配整个表达式的部分。这时必须使用正则表达式的 exec 方法,配合循环,才能读到每一轮匹配的组捕获。

var str = 'abcabc'
var reg = /(.)b(.)/g
while (true) {
  var result = reg.exec(str)
  if (!result) break
  console.log(result)
}
// ["abc", "a", "c"]
// ["abc", "a", "c"]

可以跟引用字符配合使用

/(.)b(.)\1b\2/.test("abcabc")

上面的代码中,\1 表示第一个括号匹配的内容(即 a),\2 表示第二个括号匹配的内容(即 c)

非捕获分组

(?:x) 称为非捕获组(Non-capturing group),表示不在捕获分组返回该组匹配的内容,即匹配的捕获分组的结果中不计入该分组的数据。

非捕获组的作用可能就是需要分组匹配,但是不需要在捕获分组中展示出来的情况。

// 一共使用了两个括号。其中第一个括号是非捕获组,所以最后返回的捕获分组的结果中没有第一个括号,只有第二个括号匹配的内容。
'abc'.match(/(?:.)b(.)/); //

注意:非捕获分组是不计入引用字符的编码的

引用字符

正则表达式内部,还可以用 引用括号匹配的内容,n 是从 1 开始的自然数,表示对应顺序的括号。

// \1表示第一个括号匹配的内容(即a),\2表示第二个括号匹配的内容(即c)
/(.)b(.)\1b\2/.test("abcabc") // true

// 引用字符的顺序可以按照实际情况调整
/y(..)(.)\2\1/.test('yabccab') // true

// 括号还可以嵌套,\1指向外层括号,\2指向内层括号
/y((..)\2)\1/.test('yabababab') // true

具名分组(ES2018)

ES2018 引入了具名组匹配(Named Capture Groups),允许为每一个组匹配指定一个名字,既便于阅读代码,又便于引用。

语法是:“具名组匹配” 在圆括号内部,模式的头部添加 “问号 + 尖括号 + 组名”,例如 ?<year>

  • 捕获分组的改变

组匹配的一个问题是,每一组的匹配含义不容易看出来,而且只能用数字序号(比如 matchObj[1])引用,要是组的顺序变了,引用的时候就必须修改序号。

const RE_DATE = /(\d{4})-(\d{2})-(\d{2})/

const matchObj = RE_DATE.exec('1999-12-31')
const year = matchObj[1] // 1999
const month = matchObj[2] // 12
const day = matchObj[3] // 31

具名组匹配可以在 exec 方法返回结果的 groups 属性上引用该组名。同时,数字序号(matchObj[1])依然有效。

const RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/

const matchObj = RE_DATE.exec('1999-12-31')
const year = matchObj.groups.year // "1999"
const month = matchObj.groups.month // "12"
const day = matchObj.groups.day // "31"

具名组匹配等于为每一组匹配加上了 ID,便于描述匹配的目的。如果组的顺序变了,也不用改变匹配后的处理代码。

如果具名组没有匹配,那么对应的 groups 对象属性会是 undefined

const RE_OPT_A = /^(?<as>a+)?$/
const matchObj = RE_OPT_A.exec('')

matchObj.groups.as // undefined
'as' in matchObj.groups // true

上面代码中,具名组 as 没有找到匹配,那么 matchObj.groups.as 属性值就是 undefined,并且 as 这个键名在 groups 是始终存在的。

  • 引用字符的改变

具名组匹配的引用字符的语法是 \k开始,后面添加“尖括号 + 组名”,例如 \k<first>

/(.)b(.)\1b\2/.test("abcabc") // true
/(?<first>.)b(?<last>.)\k<first>b\k<last>/.test("abcabc") // true


// 引用字符的顺序可以按照实际情况调整
/y(..)(.)\2\1/.test('yabccab') // true
/y(?<first>..)(?<last>.)\k<last>\k<first>/.test('yabccab') // true
  • replace 方法的改变

字符串替换时,使用$<组名>引用具名组。

let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;

'2015-01-02'.replace(re, '$<day>/$<month>/$<year>')
// '02/01/2015'

replace 方法的第二个参数也可以是函数,具名组匹配在原来的基础上,新增了最后一个函数参数:具名组构成的一个对象。函数内部可以直接对这个对象进行解构赋值。

'2015-01-02'.replace(re, (
   matched, // 整个匹配结果 2015-01-02
   capture1, // 第一个组匹配 2015
   capture2, // 第二个组匹配 01
   capture3, // 第三个组匹配 02
   position, // 匹配开始的位置 0
   S, // 原字符串 2015-01-02
   groups // 具名组构成的一个对象 {year, month, day}
 ) => {
 let {day, month, year} = groups;
 return `${day}/${month}/${year}`;
});

实际应用的例子

  1. 匹配网页标签

let reg = /<([^>]+)>[^<]*<\/\1>/
  1. 捕获带有属性的标签

let reg = /<(\w+)\s([^>]*)>(.*?)<\/\1>/
  1. 匹配网址并且捕获 host

let reg = /^(https?:\/\/)?([\w-]+\.)+[\w-]+/

修饰符

修饰符(modifier)表示模式的附加规则,放在正则表达式的最尾部。

修饰符可以单个使用,也可以多个一起使用。

// 单个修饰符
var regex = /test/i;

// 多个修饰符
var regex = /test/ig;

g 修饰符

g 修饰符表示全局匹配(global)。默认情况下,第一次匹配成功后,正则对象就停止向下匹配了。加上它以后,正则对象将匹配全部符合条件的结果,主要用于搜索和替换。

以下代码不含 g 修饰符,每次都是从字符串头部开始匹配。所以,连续做了三次匹配,都返回 true。

var regex = /b/;
var str = 'abba';

regex.test(str); // true
regex.test(str); // true
regex.test(str); // true

以下正则模式含有 g 修饰符,每次都是从上一次匹配成功处,开始向后匹配。因为字符串 abba 只有两个 b,所以前两次匹配结果为 true,第三次匹配结果为 false。

var regex = /b/g;
var str = 'abba';

regex.test(str); // true
regex.test(str); // true
regex.test(str); // false

i 修饰符

i 修饰符表示忽略大小写(ignorecase)。

m 修饰符

m 修饰符表示多行模式(multiline),会修改 ^$ 的行为。

默认情况下(即不加 m 修饰符时),^$ 匹配字符串的开始处和结尾处,加上 m 修饰符以后,^$ 还会匹配行首和行尾,即 ^$ 会识别换行符( )。

/world$/.test('hello world\n') // false
/world$/m.test('hello world\n') // true
/^b/m.test('a\nb') // true 加上m修饰符以后,换行符\n也会被认为是一行的开始

u 修饰符(ES6)

含义为“Unicode 模式”,用来正确处理大于\uFFFF 的 Unicode 字符。也就是说,会正确处理四个字节的 UTF-16 编码。

点字符

点(.)字符在正则表达式中,含义是除了换行符以外的任意单个字符。对于码点大于 0xFFFF 的 Unicode 字符,点字符不能识别,必须加上 u 修饰符。

var s = '𠮷';

/^.$/.test(s) // false
/^.$/u.test(s) // true

Unicode 字符表示法

ES6 新增了使用大括号表示 Unicode 字符,这种表示法在正则表达式中必须加上 u 修饰符,才能识别当中的大括号,否则会被解读为量词。

/\u{61}/.test('a') // false
/\u{61}/u.test('a') // true
/\u{20BB7}/u.test('𠮷') // true

上面代码表示,如果不加 u 修饰符,正则表达式无法识别 \u{61} 这种表示法,只会认为这匹配 61 个连续的 u。

预定义模式

u 修饰符也影响到预定义模式,能否正确识别码点大于0xFFFF的 Unicode 字符。

/^\S$/.test('𠮷') // false
/^\S$/u.test('𠮷') // true

上面代码的 \S 是预定义模式,匹配所有非空白字符。只有加了 u 修饰符,它才能正确匹配码点大于 0xFFFF 的 Unicode 字符。

利用这一点,可以写出一个正确返回字符串长度的函数。

function codePointLength(text) {
  var result = text.match(/[\s\S]/gu)
  return result ? result.length : 0
}

var s = '𠮷𠮷'

s.length // 4
codePointLength(s) // 2

量词

使用 u 修饰符后,所有量词都会正确识别码点大于 0xFFFF 的 Unicode 字符。

/a{2}/.test('aa') // true
/a{2}/u.test('aa') // true
/𠮷{2}/.test('𠮷𠮷') // false
/𠮷{2}/u.test('𠮷𠮷') // true

转义

没有 u 修饰符的情况下,正则中没有定义的转义(如逗号的转义,)无效,而在 u 模式会报错。

/\,/ // 输出 /\,/
/\,/u // 报错

y 修饰符(ES6)

也叫做“粘连”(sticky)修饰符。

y 修饰符的作用与 g 修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于,g 修饰符只要剩余位置中存在匹配就可,而 y 修饰符确保匹配必须从剩余的第一个位置开始,这也就是“粘连”的涵义。

var s = 'aaa_aa_a'
var r1 = /a+/g
var r2 = /a+/y

r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]

r1.exec(s) // ["aa"]
r2.exec(s) // null y修饰符要求匹配必须从头部开始,所以返回null

实际上,y 修饰符号隐含了头部匹配的标志 ^

/b/y.exec('aba') // null

单单一个 y 修饰符对 match 方法,只能返回第一个匹配,必须与 g 修饰符联用,才能返回所有匹配。

'a1a2a3'.match(/a\d/y) // ["a1"]
'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"]

s 修饰符:dotAll 模式(ES2018)

dotAll 模式,即点(dot)代表一切字符。

正则表达式中,点(.)是一个特殊字符,代表任意的单个字符,

但是有两个例外:

  • 一个是四个字节的 UTF-16 字符,这个可以用 u 修饰符解决;

  • 另一个是行终止符(line terminator character)。这个就需要 s 修饰符解决

所谓行终止符,就是该字符表示一行的终结。以下四个字符属于“行终止符”。

  • U+000A 换行符()

  • U+000D 回车符()

  • U+2028 行分隔符(line separator)

  • U+2029 段分隔符(paragraph separator)

/foo.bar/.test('foo\nbar') // false

上面代码中,因为 . 不匹配 ,所以正则表达式返回 false。

但是,很多时候我们希望匹配的是任意单个字符,这时有一种变通的写法。

/foo[^]bar/.test('foo\nbar') // true

这种解决方案毕竟不太符合直觉,ES2018 引入 s 修饰符,使得 . 可以匹配任意单个字符。

/foo.bar/s.test('foo\nbar') // true

s 修饰符和多行修饰符 m 不冲突,两者一起使用的情况下,. 匹配所有字符,而 ^$ 匹配每一行的行首和行尾。

d 修饰符:正则匹配索引

ES2022 新增了 d 修饰符,这个修饰符可以让 exec()、match()的返回结果添加 indices 属性,在该属性上面可以拿到匹配的开始位置和结束位置。

注意,开始位置包含在匹配结果之中,相当于匹配结果的第一个字符的位置。但是,结束位置不包含在匹配结果之中,是匹配结果的下一个字符。

如果正则表达式包含组匹配,那么 indices 属性对应的数组就会包含多个成员,提供每个组匹配的开始位置和结束位置。

const text = 'zabbcdef';
const re = /ab+(cd)/d;
const result = re.exec(text);

result.indices // [ [ 1, 6 ], [ 4, 6 ] ]

上面例子中,正则表达式 re 包含一个组匹配(cd),那么 indices 属性数组就有两个成员,第一个成员是整个匹配结果(abbcd)的开始位置和结束位置,第二个成员是组匹配(cd)的开始位置和结束位置。

如果正则表达式包含具名组匹配,indices 属性数组还会有一个 groups 属性。该属性是一个对象,可以从该对象获取具名组匹配的开始位置和结束位置。

const text = 'zabbcdef';
const re = /ab+(?<Z>cd)/d;
const result = re.exec(text);

result.indices.groups // { Z: [ 4, 6 ] }

面例子中,exec()方法返回结果的 indices.groups 属性是一个对象,提供具名组匹配 Z 的开始位置和结束位置。

如果获取组匹配不成功,indices 属性数组的对应成员则为 undefined,indices.groups 属性对象的对应成员也是 undefined。

特殊字符

正则表达式对一些不能打印的特殊字符,提供了表达方法。

  • \cX:表示 Ctrl-[X],其中的 X 是 A-Z 之中任一个英文字母,用来匹配控制字符。

  • [\b]:匹配退格键(\u0008),不要与 \b 混淆

  • :匹配换行键(\u000A)

  • :匹配回车键(\u000D)

  • :匹配制表符 tab(\u0009)

  • \v:匹配垂直制表符(\u000B)

  • \f:匹配换页符(\u000C)

  • \0:匹配 null 字符(\u0000)

  • \xhh:匹配一个以两位十六进制数(\x00-\xFF)表示的字符

  • \uhhhh:匹配一个以四位十六进制数(\u0000-\uFFFF)表示的 Unicode 字符

其中有一些可以进行替代:

  • \x0A等价于

  • \u0009等价于

  • \cJ 等价于

属性方法

RegExp 实例方法

RegExp.prototype.test()

正则实例对象的 test 方法返回一个布尔值,表示当前模式是否能匹配参数字符串。

/cat/.test('cats and dogs') // true

正则表达式带有 g 修饰符

如果正则表达式带有 g 修饰符,则每一次 test 方法都从上一次结束的位置开始向后匹配。

var r = /x/g;
var s = '_x_x';

r.lastIndex // 0
r.test(s) // true

r.lastIndex // 2
r.test(s) // true

r.lastIndex // 4
r.test(s) // false

带有 g 修饰符时,可以通过正则对象的 lastIndex 属性指定开始搜索的位置。

var r = /x/g;
var s = '_x_x';

r.lastIndex = 4;
r.test(s) // false lastIndex 属性重置为 0

r.lastIndex // 0
r.test(s) // true

注意:带有 g 修饰符时,正则表达式内部会记住上一次的 lastIndex 属性,这时不应该更换所要匹配的字符串,否则会有一些难以察觉的错误。

var r = /bb/g
r.test('bb') // true
// 由于正则表达式r是从上一次的lastIndex位置开始匹配,导致第二次执行test方法时出现预期以外的结果
r.test('-bb-') // false

注意:lastIndex 属性只对同一个正则表达式有效,所以下面这样写是错误的。

// 下面代码会导致无限循环,因为while循环的每次匹配条件都是一个新的正则表达式,导致lastIndex属性总是等于0。
var count = 0;
while (/a/g.test('babaa')) count++;

// 如果需要达到预期的效果,可以使用变量存放正则对象
var count = 0;
var reg = /a/g
while (reg.test('babaa')) count++;

如果正则模式是一个空字符串,则匹配所有字符串。

new RegExp('').test('abc')

如果想使用正则表达式字面量模式匹配所有字符串,不能用 //,这个是错误的,需要使用 /(?:)/

/(?:)/.test('abc')

// 因为
new RegExp('').toString() // '/(?:)/'

另外 /^$/ 可以匹配空字符串

/^$/.test('')

RegExp.prototype.exec()

正则实例对象的 exec 方法,用来返回匹配结果。如果发现匹配,就返回一个数组,成员是匹配成功的子字符串,否则返回 null

var s = '_x_x';
var r1 = /x/;
var r2 = /y/;

r1.exec(s) // ["x"] 其实返回的对象全部属性时这样的 0:"x" groups:undefined index:1 input:"_x_x" length: 1
r2.exec(s) // null

如果正则表示式包含圆括号(即含有“组匹配”),则返回的数组会包括多个成员。 第一个成员是整个匹配成功的结果,后面的成员就是圆括号对应的匹配成功的组。也就是说,第二个成员对应第一个括号,第三个成员对应第二个括号,以此类推。整个数组的 length 属性等于组匹配的数量再加 1。

var s = '_x_x';
var r = /_(x)/;

r.exec(s) // ["_x", "x"] length 是 2

返回值中的 input 属性和 index 属性

其实返回的数组全部属性时这样的 ['_x', 'x', index: 0, input: '_x_x', groups: undefined]

  • input:整个原字符串。

  • index:整个模式匹配成功的开始位置(从 0 开始计数)。

var r = /a(b+)a/;
var arr = r.exec('_abbba_aba_');

arr // ["abbba", "bbb"]

arr.index // 1
arr.input // "_abbba_aba_"

如果正则表达式加上 g 修饰符

如果正则表达式加上 g 修饰符,则可以使用多次 exec 方法,下一次搜索的位置从上一次匹配成功结束的位置开始。

var reg = /a/g;
var str = 'abc_abc_abc'

while(true) {
  var match = reg.exec(str);
  if (!match) break;
  console.log('#' + match.index + ':' + match[0]);
}
// #0:a
// #4:a
// #8:a

RegExp 对象属性

source

只读字符串,包含正则表达式的文本。

global

只读布尔值,是否带修饰符 g

ignoreCase

只读布尔值,是否带修饰符 i

multiline

只读布尔值,是否带修饰符 m

unicode

ES6 新增,只读布尔值,表示是否设置了 u 修饰符。

sticky

ES6 新增,只读布尔值,表示是否设置了 u 修饰符。

dotall

返回一个布尔值,表示该正则表达式是否处在 dotAll 模式。

flags

返回一个字符串,包含了已经设置的所有修饰符,按字母排序。

var r = /abc/gimuy

r.ignoreCase // true
r.global // true
r.multiline // true
r.unicode // true
r.sticky // true
r.flags // 'gimuy'

lastIndex

可读写整数,如果带 g 修饰符,这个属性储存在整个字符串中下一次检索开始的位置,这个属性会被 exec()test() 方法用到。

当调用 exec()test() 的正则表达式具有修饰符 g 时,它将把当前正则表达式对象的 lastIndex 属性设置为紧挨着匹配子串的字符位置。如果没发现任何匹配结果,lastIndex 将重置为 0

字符串实例方法

String.prototype.match()

对字符串进行正则匹配,匹配成功返回一个数组,成员是所有匹配的子字符串,匹配失败返回 null。

var s = '_x_x';
var r1 = /x/;
var r2 = /y/;

s.match(r1) // ["x"]
s.match(r2) // null

如果正则表达式带有 g 修饰符

如果正则表达式带有 g 修饰符,则该方法与正则对象的 exec 方法行为不同,会一次性返回所有匹配成功的结果。

var s = 'abba';
var r = /a/g;

s.match(r) // ["a", "a"]
r.exec(s) // ["a"]

设置正则表达式的 lastIndex 属性,对 match 方法无效,匹配总是从字符串的第一个字符开始

var r = /a|b/g;
r.lastIndex = 7;
'xaxb'.match(r) // ['a', 'b']
r.lastIndex // 0

String.prototype.matchAll()

ES2020 增加了 String.prototype.matchAll()方法,可以一次性取出所有匹配。不过,它返回的是一个迭代器(Iterator),而不是数组。

const string = 'test1test2test3'
const regex = /t(e)(st(\d?))/g

for (const match of string.matchAll(regex)) {
  console.log(match)
}
// ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"]
// ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"]
// ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]

上面代码中,由于 string.matchAll(regex) 返回的是迭代器,所以可以用 for...of 循环取出。相对于返回 match() 数组,返回迭代器的好处在于,如果匹配结果是一个很大的数组,那么迭代器比较节省资源。

迭代器转为数组是非常简单的,使用...运算符和 Array.from()方法就可以了。

// 转为数组的方法一
[...string.matchAll(regex)]

// 转为数组的方法二
Array.from(string.matchAll(regex))

String.prototype.search()

返回第一个满足条件的匹配结果在整个字符串中的位置。如果没有任何匹配,则返回-1

'_x_x'.search(/x/) // 1

String.prototype.replace()

字符串对象的 replace 方法可以替换匹配的值。它接受两个参数,第一个是正则表达式,表示搜索模式,第二个是替换的内容。

正则表达式由 g 修饰符

正则表达式如果不加 g 修饰符,就替换第一个匹配成功的值,如果有替换所有匹配成功的值。

'aaa'.replace('a', 'b') // "baa"
'aaa'.replace(/a/, 'b') // "baa"
'aaa'.replace(/a/g, 'b') // "bbb"

第二个参数可以使用美元符号 $

replace 方法的第二个参数可以使用美元符号 $,用来指代所替换的内容。

  • $&:匹配的子字符串。

  • $`:匹配结果前面的文本。

  • $':匹配结果后面的文本。

  • $n:匹配成功的第 n 组内容,n 是从 1 开始的自然数。

  • $$:指代美元符号 $

'hello world'.replace(/(\w+)\s(\w+)/, '$2 $1')
// "world hello"

'abc'.replace('b', '[$`-$&-$\'$$]')
// 'a[a-b-c$]c'

第二个参数还可以是一个函数

replace 方法的第二个参数还可以是一个函数,将每一个匹配内容替换为函数返回值

作为 replace 方法第二个参数的替换函数,可以接受多个参数。

  • 第一个参数是捕捉到的内容

  • 第二个参数是捕捉到的组匹配(有多少个组匹配,就有多少个对应的参数)。

此外,最后还可以添加两个参数,倒数第二个参数是捕捉到的内容在整个字符串中的位置,最后一个参数是原字符串。

'6 and 10'
'3 and 5'.replace(/[0-9]+/g, function (...args) {
  console.log(args)
  return 2 * args[0];
})
//'6 and 10'
// ['3', 0, '3 and 5'] 注意倒数第二个和倒数第一个参数
// ['5', 6, '3 and 5']

replace 应用

  1. 将价格插入到模板中

var prices = {
  'p1': '$1.99',
  'p2': '$9.99',
  'p3': '$5.00'
};

var template = '<span id="p1"></span>'
  + '<span id="p2"></span>'
  + '<span id="p3"></span>';

template.replace(
  /(<span id=")(.*?)(">)(<\/span>)/g,
  function(match, $1, $2, $3, $4){
    return $1 + $2 + $3 + prices[$2] + $4;
  }
);
  1. 消除字符串首尾两端的空格

function customTrim(str) {
  // 正则表达式:匹配字符串开头和结尾的空白字符
  // ^\s+ 匹配字符串开头的一个或多个空白字符
  // \s+$ 匹配字符串结尾的一个或多个空白字符
  const regExp = /^\s+|\s+$/g

  // 使用 replace 方法和正则表达式移除字符串两端的空白字符
  return str.replace(regExp, '')
}

String.prototype.split()

字符串对象的 split 方法按照正则规则分割字符串,返回一个由分割后的各个部分组成的数组。

// 非正则分隔
'a,  b,c, d'.split(',')
// [ 'a', '  b', 'c', ' d' ]

// 正则分隔,去除多余的空格
'a,  b,c, d'.split(/, */)
// [ 'a', 'b', 'c', 'd' ]

// 指定返回数组的最大成员
'a,  b,c, d'.split(/, */, 2)
[ 'a', 'b' ]

贪婪匹配下的分割

// 例一
'aaa*a*'.split(/a*/)
// [ '', '*', '*' ]

// 例二
'aaa**a*'.split(/a*/)
// ["", "*", "*", "*"]

上面代码的分割规则是 0 次或多次的 a,由于正则默认是贪婪匹配,所以例一的第一个分隔符是 aaa,第二个分割符是 a,将字符串分成三个部分,包含开始处的空字符串。例二的第一个分隔符是 aaa,第二个分隔符是 0 个 a(即空字符),第三个分隔符是 a,所以将字符串分成四个部分。

如果正则表达式带有括号,则括号匹配的部分也会作为数组成员返回

'aaa*a*'.split(/(a*)/)
// [ '', 'aaa', '*', 'a', '*' ]

ES6 的扩展

ES6 出现之前,字符串对象共有 4 个方法,可以使用正则表达式:match()replace()search()split()

ES6 将这 4 个方法,在语言内部全部调用 RegExp 的实例方法,从而做到所有与正则相关的方法,全都定义在 RegExp 对象上。

  • String.prototype.match 调用 RegExp.prototype[Symbol.match]

  • String.prototype.replace 调用 RegExp.prototype[Symbol.replace]

  • String.prototype.search 调用 RegExp.prototype[Symbol.search]

  • String.prototype.split 调用 RegExp.prototype[Symbol.split]

例如:

var re = /[0-9]+/g
var str = '2022-01-02'
var result = re[Symbol.match](str)
console.log(result) // ["2022", "01", "02"]

进阶概念

匹配模式

贪婪模式

正则表达式的匹配模式,默认情况下都是最大可能匹配,即匹配直到下一个字符不满足匹配规则为止。这被称为贪婪模式。

var s = 'aaa';
s.match(/a+/) // ["aaa"]

上面代码中,模式是 /a+/,表示匹配 1 个 a 或多个 a,那么到底会匹配几个 a 呢?因为默认是贪婪模式,会一直匹配到字符 a 不出现为止,所以匹配结果是 3 个 a。

非贪婪模式

非贪婪模式又称懒惰模式,只需要在待匹配的量词字符后面跟随一个问号即可:??+?*?{1,5}?,一旦条件满足,就不再往下匹配。

var s = 'aaa';
s.match(/a+?/) // ["a"]

再举一个例子

'aabab'.match(/a.*?b/g) // ['aab', 'ab']

a.*?b 匹配最短的,以 a 开始,以 b 结束的字符串。把它应用于 aabab 的话,它会匹配 aab(第一到第三个字符)和 ab(第四到第五个字符)。

为什么第一个匹配是 aab(第一到第三个字符)而不是 ab(第二到第三个字符)?简单地说,因为正则表达式有另一条规则,比懒惰/贪婪规则的优先级更高:最先匹配到的拥有最高的优先权——The match that begins earliest wins。

独占模式(JS 暂不支持)

独占模式需要在待匹配的量词字符后面跟随一个加号即可:?+++*+{1,5}+。同贪婪模式一样,独占模式一样会匹配最长。不过在独占模式下,正则表达式尽可能长地去匹配字符串,一旦匹配不成功就会结束匹配而不会回溯。

匹配顺序

通常情况下,正则将会从左到右地测试每个条件。

使用分枝条件时,要注意各个条件的顺序。

如果满足了某个分枝的话,就不会去再管其它的条件了。

例如:美国邮编的规则是 5 位数字,或者用连字号间隔的 9 位数字。

/\d{5}-\d{4}|\d{5}/ // 正确方式
/\d{5}|\d{5}-\d{4}/ // 错误方式,只会匹配5位的邮编(以及9位邮编的前5位)

断言

断言(Assertion)也被称为零宽断言或环视(lookaround),它代表的是一个位置,这个位置应该满足一定的条件(断言),只有当断言为真时才会认为其之前或之后的正则匹配成功。

它自身只进行子表达式的匹配,不占有字符,匹配到的内容不保存到最终的匹配结果。

先行断言

先行断言(lookahead)有时也被称作正向断言,语法是:(?=exp),用于检查某个特定模式是否出现在另一个模式的后面(右边),通俗点说,在某个位置往右(往后)看是否匹配 ?= 后面的子表达式。

var m = 'abcabd'.match(/b(?=c)/) // ["b"]

上面代码中,匹配 b 的地方有两个,然后从这两个位置往右看是否匹配正向断言的子表达式 c,所以只有第一个 b 是真正匹配成功的。

密码强度校验(举例)

至少有一个大写字母。至少有一个小写字母。至少有一个数字。至少有 8 个字符。

/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/.test('123456aA') // true

这是一个特殊的例子,用于检查字符串中是否至少有一个大写字母等。

(?=.*[A-Z]):用于检查字符串中是否存在至少一个大写字母。其中:

  • ?= 是先行断言的开始。

  • .* 代表任意数量的任意字符,这意味着大写字母可以出现在字符串中的任意位置。

  • [A-Z] 匹配任意一个大写字母。

先行否定断言

先行否定断言(negative lookahead)有时也被称为正向否定断言,语法是:(?!=exp),用于确保某个模式后面不跟随另一个特定的模式

/\d+(?!\.)/.exec('3.14') // ["14"]

上面代码中,正则表达式指定,只有不在小数点前面的数字才会被匹配,因此返回的结果就是 14。

数字格式化(举例)

1234567890 格式化为 1,234,567,890

let test = '1234567890'
let reg = /\B(?=(\d{3})+(?!\d))/g
console.log(test.replace(reg, ','))

主要看一下正则表达式部分:

  • \B 非词边界,即在词内部的间隔处

  • ?= 先行断言,后面必须匹配

    • (\d{3})+ 1 个或多个连续的三个数字

    • ?! 先行否定断言,多个连续的三个数字后不能有数字

后行断言

后行断言(lookbehind)有时也被称为反向断言,语法是:(?<=exp),用于检查某个特定模式是否出现在另一个模式的前面(左边)。

/(?<=\$)\d+/.exec('123$100') // 100

“后行断言”的实现,需要先匹配 /(?<=y)x/x,然后再回到左边,匹配 y 的部分。这种“先右后左”的执行顺序(后行),与所有其他正则操作相反,导致了一些不符合预期的行为。

后行断言的组匹配

/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]

没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是 105 和 3。

/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]

有“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是 1 和 053。

后行断言的引用字符

如果后行断言的反斜杠引用字符(\1)放在括号的后面,就不会得到匹配结果,必须放在前面才可以。因为后行断言是先从左到右扫描,发现匹配以后再回过头,从右到左完成反斜杠引用。

/(?<=(o)d\1)r/.exec('hodor')  // null
/(?<=\1d(o))r/.exec('hodor')  // ["r", "o"]

后行否定断言

后行否定断言(negative lookbehind)有时也被称为反向否定断言,语法是:(?<!exp),用于确保某个模式前面不是另一个特定的模式。

/(?<!\$)\d+/.exec('123$100') // 123

正则表达式引擎

正则表达式的执行需要正则引擎,正则引擎主要分为两类:

  • DFA(Deterministic finite automaton) 确定型有穷自动机

    • 使用 DFA 引擎的程序:awk(大多数版本)、egrep(大多数版本)、flex、lex、MySQL 等

  • NFA(Non-deterministic finite automaton)不确定型有穷自动机

    • 使用 NFA 引擎的程序:Java、Perl、PCRE library、less、more、sed(大多数版本)、Python、Ruby 等

    • 一般指的是 Traditional NFA

其实:NFA 还能继续分为 Traditional NFA 和 POSIX NFA,这就跟 PCRE 流派和 POSIX 流派有些关系了,另外还有一些程序支持 DFA 和 NFA 的结合体,例如 GNU awk

确定型和不确定型

假设有一个字符串 abc 需要匹配,在没有编写正则表达式的前提下,直接可以确定字符匹配顺序的就是确定型,不能确定字符匹配顺序的就是不确定型

有穷

有穷即表示有限的意思,这里表示有限的次数内能得到结果

自动机

自动机就是自动完成,在我们设置好匹配规则后由引擎会自动完成,不需要人为干预。

DFA 引擎

DFA 是从匹配文本入手,从左到右,每个字符不会匹配两次,它的时间复杂度是多项式的,所以通常情况下,它的速度更快,但支持的特性很少,不支持捕获组、各种引用等等

正则里面的 DFA 引擎实际上就是把正则表达式转换成一个图的邻接表,然后通过跳表的形式判断一个字符串是否匹配该正则。

DFA 的特点

  • 先看文本,再看正则,以文本为主导

  • 匹配过程,字符串只看一次,不管正则表达式写得多烂,匹配速度都很快

  • 不支持捕获组、断言等高级功能

NFA 引擎

NFA 是从正则表达式入手,不断读入字符,尝试是否匹配当前正则,不匹配则吐出字符重新尝试,通常它的速度比较慢,最优时间复杂度为多项式的,最差情况为指数级的。但 NFA 支持更多的特性,因而绝大多数编程场景下(包括 Java,JS),就是 NFA。

正则里面 NFA 引擎实际上就是在语法解析的时候,构造出的一个有向图。然后通过深搜的方式,去一条路径一条路径的递归尝试。

NFA 的特点

  • 先看正则,再看文本,以正则为主导

  • 匹配过程中,可能会发生回退,字符串同一部分会比较多次(通常将回退称为“回溯”)

  • 功能强大,可以拿到匹配的上下文信息,支持捕获组、断言等功能

灾难性回溯真实案例

大部分语言的正则引擎都是 NFA 的,JS 也是,如果写出了有性能问题的正则表达式,容易造成灾难性回溯。

使用 regex101 统计 step

let regexp = /^(\w+\s?)*$/;

alert( regexp.test("A good string") ); // true 一共 14 steps
alert( regexp.test("Bad characters: $@#") ); // false 一共 16412 steps,如果字符串再长一点会执行更多 step

如何避免

  • 改用 DFA 的正则引擎(速度快,功能弱,没有捕获组断言等功能)

  • 提高对正则性能问题的重视,开发的时候少写模糊匹配,越精确越好,因为模糊匹配、贪婪匹配、懒惰匹配都可能带来回溯问题

  • 使用独占模式可以有效避免回溯问题(JS 暂时没有此模式)

  • 不要滥用括号和字符类

  • 拆分表达式,有时候,多个小正则表达式的速度比一个大正则表达式的速度要快

性能

基本概述

正则表达式的性能高于常规的循环遍历字符串操作

如果只需要匹配几个字母的大小写,可以直接写明,如果使用 i修饰符,会每个字母都匹配一遍大小写

/[MmSs]/.test('mS')

尽量使用正则表达式字面量形式

因为 JavaScript 引擎会在脚本加载时编译正则表达式字面量。相比之下,使用 RegExp 构造函数创建的正则表达式可能会在每次使用时都重新编译

使用行或者字符串的开始、结束符

如果是从行首或者行尾匹配,使用 ^$ 能更精准匹配

扩展

匹配中文字符

  • [\u4e00-\u9fa5] 是错的,不要用二十年前的正则表达式了

  • /\p{Unified_Ideograph}/u 是正确的,不需要维护,匹配所有汉字。这里 \p 是 Unicode 属性转义正则表达式,并且需要使用 u 修饰符。

  • /\p{Ideographic}/u/\p{Script=Han}/u 匹配除了汉字以外的其他一些字符,太宽泛,在「汉字匹配正则表达式」这个需求下,是错的。

  • 目前只有 Chrome 支持 Unicode 属性转义正则表达式。对其他环境,使用 @babel/plugin-proposal-unicode-property-regex 和 regexpu-core 进行优雅降级。

具体细节可以看

链接

技术文章

验证和联系

Last updated

Was this helpful?