阅读 242

一文详解JavaScript中的正则表达式

写在前面

本文是对JavaScript正则表达式较为完整的介绍,行文结构及部分内容参考了《JavaScript权威指南》(第6版)中《正则表达式的模式匹配》这一章节,此外还加入了ES6+中正则表达式的新特性和大量的示例代码,阐述细节较多。无论你是初学者还是正则高手,希望这篇文章都能给你带来收获。

正则表达式是什么

正则表达式是一个描述字符模式的对象,JavaScript中的RegExp类(内置对象之一)表示正则表达式。正则表达式具有强大的模式匹配文本检索与替换功能。

本文具体章节:

正则表达式的定义

JavaScript中主要有两种定义正则表达式的方式:

  • 特殊的直接量语法
  • 使用RegExp构造函数创建RegExp对象

举个例子,我们创建一个正则表达式对象pattern,用以匹配所有以"JavaScript"结尾的字符串。

// 🌰 使用 直接量语法 定义为包含在一对斜杠(/)之间:
const pattern = /JavaScript$/;

// 🌰 使用 构造函数 可以定义一个与之等价的正则表达式:
const pattern = new RegExp('JavaScript$');
复制代码

在JavaScript中,采用直接量语法的方式定义正则表达式更为常见,需要注意的是, 对于正则表达式的直接量,ES3和ES5规范作了相反的规定:

ES3规范规定一个正则表达式直接量在执行到它时会转换为一个RegExp对象,同一段代码所表示的正则表达式直接量的每次运算都会返回同一个对象

ES5规范则规定同一段代码所表示的正则表达式直接量的每次运算都会返回新对象

🔎 还有一种不常见的工厂模式创建正则表达式:const pattern = RegExp('JavaScript$');

正则表达式的模式规则组成

正则表达式的模式规则有字符序列组成,可分为一下6类:

  1. 直接量字符
  2. 字符类
  3. 重复
  4. 选择分组和引用
  5. 指定匹配的位置
  6. 修饰符

直接量字符

  • 所有的字母和数字都是直接量字符,即它们都是按照字面含义进行匹配的,如/ECMAScript5/可以匹配任何包含"ECMAScript5"的字符串
  • 正则表达式语法也支持非字母数字的字符匹配,只不过需要通过反斜线(\)进行转义,除了字母和数字字符,正则表达式的直接量字符还包括:
字符 匹配
\o NUL字符(\u0000)
\t 制表符(\u0009)
\n 换行符(\u000A)
\v 垂直制表符(\u000B)
\f 换页符(\u000C)
\r 回车符(\u000D)
\xnn 由十六进制数nn指定的拉丁字符,例如,\x0A等价于\n
\uxxxx 由十六进制数xxxx指定的Unicode字符,例如,\u0009等价于\t
\cX 控制字符^X,例如,\cJ等价于换行符\n
  • 此外, ^ $ . * + ? + ! : | \ / ( ) { } [ ]这些标点符号具有特殊含义。某些符号只有才特定的上下文环境中才具有某种特殊含义,如果想匹配这些字符的直接量则必须使用前缀\进行转义,如/JavaScript\$/将匹配包含"JavaScript$"的字符串。
  • 其他标点符号(如@、")没有特殊含义,这些符号的直接量即它们本身。

🔔 关于转义字符\的使用,有几点需要注意:

  • 如果不记得哪些标点符号需要\转义,可以在每个标点符号前都加\
  • 对于想按照直接量进行匹配的字母和数字,直接使用本身即可,尽量不要使用\转义,许多字母和数字在反斜线做前缀时也有特殊含义(如上文中提到的\n等,下文中的某些字符类也是这种情况)
  • 要想按照直接量匹配\本身,则必须使用\转义自身,即\\

字符类

将直接量字符放进[]内就组成了字符类(character class),一个字符类可以匹配它所包含的任意字符。例如/[Jj]avaScript/可以匹配:

/[Jj]avaScript/.test('JavaScript is fun');
// => true 任意包含"JavaScript"的字符串 

/[Jj]avaScript/.test('javaScript is fun');
// => true 任意包含"javaScript"的字符串 
复制代码

否定字符类

此外,^字符放在左方括号内的第一个字符来定义否定字符类,它匹配所有不包含[]内的字符,正则表达式/[^abc]/匹配的是"a"、"b"、"c"之外的所有字符。

/[^abc]/.test('')
// => false 无字符可匹配

/[^abc]/.test('a')
// => false

/[^abc]/.test('b')
// => false

/[^abc]/.test('c')
// => false

/[^abc]/.test('abc')
// => false

/[^abc]/.test('1')
// => true

/[^abc]/.test('abcde')
// => true 匹配字母d
复制代码

字符类范围表示

字符类还可以使用连字符-来表示字符范围,例如,可以使用/[a-z]/匹配拉丁字母表中的小写字母,连字符-也可以在字符类中使用多次,如/a-zA-Z0-9/匹配拉丁字母表中的任意字母和数字。

特殊转义字符

在JavaScript正则表达式中使用一些特殊字符的转义字符表示某些非常常用的字符类,具体如下所示:

字符 匹配
[...] 方括号内任意字符
[^...] 不在方括号内的任意字符
. 除换行符和其他Unicode行终止符之外的任意字符(不使用修饰符s的情况下)
\w 任何ASCII字符组成的单词,等价于[a-zA-Z0-9]
\W 任何不是ASCII字符组成的单词,等价于[^a-zA-Z0-9]
\s 任何Unicode空白符
\S 任何非Unicode空白符的字符
\d 任何ASCII数字,等价于[0-9]
\D 除了ASCII数字之外的任何字符,等价于[^0-9]
[\b] 特例,退格直接量(\b匹配单词边界)

在方括号之内也可以写这些特殊转义字符,比如/[\s\d]/匹配任意空白符或数字,🔔 这里有一个特例[\b],它表示一个退格符,而\b表示匹配单词边界。

重复

在正则表达式中可以使用重复字符表示正则表达式中某些元素重复出现的次数。重复字符及其含义如下:

字符 含义
{n,m} 匹配前一项n到m次,[n, m]
{n,} 匹配前一项至少n次, [n, +∞)
{n} 匹配前一项n次
? 匹配前一项0次或1次,即前一项是可选的,等价于{0, 1}
+ 匹配前一项1次或多次,等价于{1,}
* 匹配前一项0次或多次,等价于{0,}

值得注意的是,?*可以匹配前一项0次,换言之,它们可以什么都不匹配。如/\d?//\d*/可以匹配字符串"JavaScript",它匹配0个数字,即什么都不匹配。下面是一些示例:

// 匹配2~4个数字
/\d{2, 4}/.test('123');
// => true

// 匹配2~4个数字和一个单词
/\d{2, 4}\w/.test('12345abcd');
// => true 匹配'2345a'

// 匹配一个或多个非左括号字符
/[^(]*/.test('abcd');
// => true 匹配'abcd'

// 匹配0个或多个数字
/\d*/.test('JavaScript');
// => true 匹配0个数字
复制代码

非贪婪的重复

表格中所列出的匹配重复字符是尽可能多的匹配,并且允许后续的正则表达式继续匹配,因此将其称之为“贪婪的”匹配。我们同样可以使用正则表达式进行非贪婪匹配,即尽可能少的匹配,只需在待匹配的字符后跟随一个问号即可:??+?*?{1,5}?。比如,正则表达式/a+//a+?/都可以匹配一个或多个连续的字母"a",当使用"aaa"作为匹配字符串时,前者会尽可能匹配3个"a",而后者尽可能匹配第1个"a"

由于正则表达式模式的匹配总是会寻找字符串中第一个可能匹配的位置,因此使用非贪婪的匹配模式得到的结果可能和期望并不一致,如下例所示

// 🌰 贪婪模重复
/a+b/.test('aaab');
// => true 匹配'aaab'

// 🌰 非贪婪重复
/a+?b/.test('aaab');
// => true 匹配'aaab'

// 当前贪婪模式非非贪婪模式的匹配一样
// 我们期望它匹配最后两个字符'ab'
// 实际上它匹配了整个字符串
// 由于该匹配是从字符串第一个字符开始的
// 因此不考虑它的子串中更短的匹配
复制代码

选择、分组和引用

正则表达式的语法还包括制定选择项、子表达式分组和引用前一子表达式的特殊字符。

|

字符|用于分割供选择的字符,选择项尝试匹配的次序是从左到右,发现了匹配项会进行短路操作,忽略后续的匹配项。

/\d{3}|[a-z]{4}/.test('1234ER');
// => true 匹配'123'

/\d{3}|[a-z]{4}/.test('12ABER');
// => true 匹配'ABER'

/a|ab/.test('ab');
// => true 匹配'a'
复制代码

()

正则表达式中的()具有多种作用。

一个作用是将单独的项组合成子表达式,以便可以象处理一个独立的单元那样用?|*+等来对单元内的项进行处理。

/Java(Script)?/.test("Java");
// => true

/Java(Script)?/.test("JavaScript");
// => true

/(ECMA|Java)Script/.test('ECMAScript');
// => true

/(ECMA|Java)Script/.test('JavaScript');
// => true
复制代码

()的另一个作用是在完整的模式中定义子模式。当一个正则表达式成功地和目标字符串相匹配时,可以从目标串中抽出和圆括号中的子模式相匹配的部分。

  • 这是通过字符\后加数字实现的(下文将会讨论使用$的情况),这个数字指定了带圆括号子表达式在正则表达式中的位置
  • 计数从1开始,\1引用的是第一个带圆括号的子表达式
  • 因为子表达式可以嵌套另一个子表达式,所以它的位置是参与计数的左括号的位置
  • 对前一子表达式的引用不是对子表达式模式的引用,而是与模式相匹配的文本的引用
// 🌰 匹配位于单引号'或双引号"之间的0个或多个字符
/['"][^'"]*['"]/.test(`'javascript"`);
// => true ['"]匹配',第2个['"]匹配"

// 🌰 若要求第1个和第2个['"]同时匹配单引号'或双引号
/(['"])[^'"]*\1/.test(`'javascript"`);
// => false

/(['"])[^'"]*\1/.test(`"javascript"`);
// => true

/(['"])[^'"]*\1/.test(`'javascript'`);
// => true
复制代码

(?:)

使用(?:)也可以对子表达式进行分组,与()的区别在于它不生成引用(非引用分组),仅仅是对子表达式的分组。因此在正则表达式/(Java(?:Script)?)\sis\s(fun)/中,\2引用的是与(fun)相匹配的文本而非(?:Script)所引用的文本。

(?<name>) (ES2018)

ES2018允许命名捕获组使用符号?<name>,这样可以使用name来引用子表达式匹配的子串,与上文中使用数字相比,这样可以提高代码的可读性,下文中会再次提到命名捕获组的使用方法。

// 🌰 这里使用命名捕获组复写上个示例
// 引用方式是'\k<name>'而非'\1'(当然也可以写'\1')
/(?<quote>['"])[^'"]*\k<quote>/.test(`'javascript"`);
// => false

/(?<quote>['"])[^'"]*\k<quote>/.test(`"javascript"`);
// => true

/(?<quote>['"])[^'"]*\k<quote>/.test(`'javascript'`);
// => true
复制代码

指定匹配的位置

正则表达式的锚是一类特殊字符,它们不匹配某个可见的字符,它们指定匹配发生的合法位置,即将模式定位在搜索字符串的特定位置上。

^和$

锚元素^$的作用分别为匹配字符串的开头和结尾,在多行检索中可匹配每一行的开头和结尾。

/^JavaScript$/.test('JavaScript');
// => true

/^JavaScript$/.test('JavaScript is fun');
// => false 不以t为结尾

/^JavaScript$/m.test('JavaScript\nis fun');
// => true t为行尾
复制代码

\b和\B

\b匹配一个单词的边界,即字符\w\W之间的位置或字符\w与字符串开头或结尾之间的位置,值得注意的是[\b]匹配的是退格符。

\B\b恰好相反,它匹配非单词边界的位置。

// 🌰 \b示例
/\bJavaScript\b/.test('Oh, JavaScript is fun');
// => true 匹配'JavaScript'

/\bJavaScript\b/.test('Oh,JavaScript is fun');
// => true

/\bJavaScript\b/.test('JavaScript is fun');
// => true

// 🔔 \b与\s是不同的,\b不匹配可见字符,\s则匹配空白符
/\sJavaScript\s/.test('Oh, JavaScript is fun');
// => true 匹配' JavaScript '

/\sJavaScript\s/.test('JavaScript is fun');
// => false

// 🌰 \B示例
/\B[Ss]cript/.test('JavaScript');
// => true

/\B[Ss]cript/.test('Scripts');
// => false
复制代码

(?=)和(?!)

(?=p)称之为零宽正向先行断言,要求接下来的字符与p相匹配,但不能包括p匹配的那些字符(锚只是确定位置,而不匹配可见字符)

(?!) 为零宽负向先行断言,要求接下来的字符与p不匹配

// 🌰 零宽正向先行断言
/Java(?=Script)/.test('JavaScript');
// => true 匹配'Java',🔔 锚只是确定位置,'Script'不作为匹配结果的一部分

// 🌰 零宽负向先行断言
/Java(?!Script)/.test('JavaScript');
// => false

/Java(?!Script)/.test('JavaBean');
// => true 匹配'Java'

/Java(?!Script)([A-Z]\w*)/.test('JavaScript');
// => false 'Java'后面不可跟随'Script'

/Java(?!Script)([A-Z]\w*)/.test('JavaScripter');
// => false
复制代码

(?<=)和(?<!) (ES2018)

(?<=p)称之为零宽正向后行断言,要求之前的字符与p相匹配,但不能包括p匹配的那些字符(与先行断言相同,锚只是确定位置,而不匹配可见字符)

(?<!) 为零宽负向后行断言,要求之前的字符与p不匹配

// 🌰 零宽正向后行断言
/(?<=Java)Script/.test('JavaScript');
// => true 匹配'Script',🔔 锚只是确定位置,'Java'不作为匹配结果的一部分

/(?<!Java)Script/.test('JavaScript');
// => false

// 🌰 零宽负向后行断言
/(?!=Java)Script/.test('JScript');
// => true 匹配'Script'
复制代码

修饰符(标志)

正则表达式具有igmyus三个修饰符,正则表达式中的修饰符可以任意组合,如/JavaScript/gi可以全局不分大小写的匹配"JavaScript"

i

修饰符i表示模式匹配不区分大小写。如/javascript/i可以匹配"javascript""JavaScript""JAVASCRIPT""JAvascRipt"等等等等。

g

修饰符g表示模式匹配是全局的,即不是匹配到第一个就停止而是匹配全部。

// 🌰 将格式为"yyyy-MM-dd"的日期字符串格式化为"yyyy/MM/dd"
// 可使用修饰符"g"实现:

"yyyy-MM-dd".replace(/-/g, "/");
// => "yyyy/MM/dd"

// 🌰 若不使用g修饰符,结果则为
"yyyy-MM-dd".replace(/-/, "/");
// => "yyyy/MM-dd"
复制代码

m

修饰符m在多行模式中执行匹配,如果待检索的字符串是多行的,那么^$锚字符除了匹配整个字符串的开头和结尾,还能匹配每行的开头和结尾。

// 🌰 多行匹配
/JavaScript$/m.test('Oh, JavaScript\nis fun');
// => true

// 🌰 若不使用m修饰符,则$只会匹配整个字符串的开头和结尾
/JavaScript$/.test('Oh, JavaScript\nis fun');
// => false
复制代码

u (ES2015)

在ES2015之前,正则表达式默认对每一个字符按照16位编码单元处理。ES2015为正则表达式定义了代表Unicode字符的u修饰符。当一个正则表达式启用了u 修饰符时,它将切换字符模式以作用于字符,而不是代码单元,这样正则表达式就不会视代理对(surrogate pair,用两个16位编码单元表示一个码位)为两个字符,从而得到预期结果。

const text = '𠮷';

text.match(/./g).length;
// => 2

text.match(/./gu).length;
// => 1

/^.$/.test(text);
// => false

/^.$/u.test(text);
// => true
复制代码
Unicode Property Escapes(ES2018)

ES2018中正则支持了更强大的Unicode匹配方式,u修饰符可以识别所有大于0xFFFF的Unicode字符,包括表情符号,标点符号,字母(甚至包括来自特定语言或脚本的字母)等,你可以在👉这里了解更多。

// 🌰 \p{Number}匹配所有数字
const regex = /^\p{Number}+$/u;
regex.test("²³¹¼½¾");
// => true
regex.test("①②③④");
// => true
regex.test("ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ"); 
// => true


// 🌰 \p{Alphabetic} 可以匹配所有 Alphabetic 元素,包括汉字、字母等:
const zh = '中';
const en = 'E';

/\w/u.test(en);
// => true
/\w/u.test(zh);
// => false

/\p{Alphabetic}/u.test(zh);
// => true

/\p{Alphabetic}/u.test(en);
// => true


// 🌰 匹配西里尔字母
/\p{Script=Cyrillic}/u.test('Л');
// => true


// 🌰 匹配表情字符
/\p{Emoji_Presentation}/u.test('👌');
// => true

// 🌰 也可以使用"P"反向匹配
/\P{Emoji_Presentation}/u.test('👌');
复制代码

y (ES2015)

修饰符y(yylex)涉及正则表达式与检索相关的sticky属性,它表示从正则表达式设置的 lastIndex属性值的位置开始检索字符串中的匹配字符。如果以该位置为起点,之后没有相应的匹配,那么正则表达式将停止检索。

由于修饰符y涉及到正则表达式的lastIndex属性,且其粘滞行为只有在正则表达式对象上调用方法涉及到对lastIndex的修改 才会生效(例如exectest),本节详细内容将放在RegExp对象这一章节介绍。

s (ES2018)

上文中提到,.可以匹配除换行符和其他Unicode行终止符之外的任意字符,很长一段时间里匹配所有字符(包括换行符)的一种方法是使用一个包含两个短字符的字符类,比如[\s\S]。ES2018中正则表达式引入了dotAll模式,通过使用标记s选项,.就可以实现与[\s\S]相同的效果。

/ES5.ES6/.test('ES5\nES6');
// => false

/ES5.ES6/s.test('ES5\nES6');
// => true
复制代码

用于模式匹配的String方法

String对象包含一些执行正则表达式模式匹配和检索替换操作的方法。

String.prototype.search

search的参数是一个正则表达式,它返回第1个与之匹配的子串的起始位置,找不到将返回-1,值得注意的是:

  • 若传入的参数不是正则表达式,将会使用RegExp构造函数将其转换为正则表达式
  • 由于search查找第一个子串,因此它将不会进行全局检索,即忽略正则表达式中的修饰符g
'Oh, JavaScript is fun'.search(/javascript/i);
// => 4
复制代码

String.prototype.searchString.prototype.indexOf功能相近,不同之处在于indexOf接收一个字符串作为第1个参数,并且可以显示指定开始检索的位置。如果只是对一个具体的字符串进行检索,应优先考虑使用indexOfsearch更适合应对更加复杂的情况。

如果仅仅是检测字符串中是否包含某个特定子串,不关心子串位置且不必考虑兼容性问题,那么使用String.prototype.includes更加合适。

String.prototype.replace

replace方法用以执行检索和替换,第1个参数接收一个正则表达式或字符串,第2个参数是要进行替换的字符串或返回字符串的函数。值得注意的是:

  • replace的第1个参数在正则表达式不使用修饰符g或使用字符串的情况下,它仅仅替换第1个匹配到的子串,这点在修饰符一节中已经提到
  • replace的第1个参数若传入字符串,它将不会将其转换为正则表达式,这点与search不同
  • replace的第2个参数可以使用上文中提到的引用,只不过引用字符由\变为$
// 🌰 加密手机号中间4位
'12345678901'.replace(/(\d{3})(\d{4})(\d{4})/, '$1****$3');
// => '123****8901'
复制代码

replace中可以使用上文提到的命名捕获组?<name>,这里将使用$<name>引用匹配的子串。

// 🌰 日期格式转换
'01/31/2020'.replace(/(?<month>\d{2})\/(?<day>\d{2})\/(?<year>\d{4})/, '$<year>-$<month>-$<day>');
// => '2020-01-31'
复制代码

String.prototype.match

match方法是最常用的String正则表达式方法,它的唯一参数就是一个正则表达式(或通过RegExp构造函数将其转换为正则表达式),返回的是一个由匹配结果组成的数组。

  • 如果正则表达式设置了修饰符g,则方法返回的数组包含字符串中的所有匹配结果。
  • 如果正则表达式没有设置修饰符g,则只检索第1个匹配,但它仍然返回一个数组,数组的第一个元素就是匹配的字符串,余下的元素则是正则表达式中用圆括号括起来的子表达式所匹配的文本。
  • 若待检索字符串中不包含任何匹配结果,则返回null
const str = 'day1 day2 day3';

str.match();
// => ['', index: 0, input: 'day1 day2 day3', groups: undefined]

str.match(/date/);
// => null

// 🌰 在match中使用修饰符g
str.match(/(day(\d))/g);
// => ['day1', 'day2', 'day3']

// 🌰 在match中不使用修饰符g
str.match(/(day(\d))/);
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]

// 0: 'day1' 第一个与之匹配的子串
// 1: 'day1' \1
// 2: '1' \2
// index为第1个与之匹配的子串起始位置
// input为检索的字符串
// groups为分组信息,下文中将会提到
复制代码

match中也可以使用命名捕获组,这比使用索引来获取匹配结果更加具有可读性。

const res = '2020-01-01'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
// => ["2020-01-01", "2020", "01", "01", index: 0, input: "2020-01-01", groups: {…}]

res.groups.year;
// => '2020'
res.groups.month;
// => '01'
res.groups.day;
// => '01'
复制代码

String.prototype.matchAll (ES2020)

matchAll方法接受一个带有修饰符g的正则表达式对象作为它的唯一参数,如果所传参数不是一个正则表达式对象,则会隐式地使用new RegExp(obj)将其转换为一个正则表达式对象,它返回一个包含所有匹配信息的迭代器(迭代器不可重用,结果耗尽需再次调用方法获取一个新的迭代器)。

matchAllmatch的不同之处在于当它们接受一个带有修饰符g的正则表达式对象时,match只是简单的返回匹配结果的数组,matchAll返回的是一个包含所有匹配信息的迭代器。具体可以从下面的示例中看出它们的区别:

const str = 'day1 day2 day3';

// 🌰 在match中使用修饰符g
str.match(/(day(\d))/g);
// => ['day1', 'day2', 'day3']

// 🌰 在matchAll中使用修饰符g(matchAll只接受带修饰符g的正则表达式)
const result = str.matchAll(/(day(\d))/g);
// result是一个迭代器,将其转换为数组为:
[...result]
// => [
//  ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined],
//  ['day2', 'day2', '2', index: 5, input: 'day1 day2 day3', groups: undefined],
//  ['day3', 'day3', '3', index: 10, input: 'day1 day2 day3', groups: undefined],
// ]
复制代码

String.prototype.split

split方法将调用它的字符串拆分成一个子串组成的数组,使用的分隔符是split的参数。split方法支持传入字符串和正则表达式,传入正则表达式将使得split方法更加强大。

'1,2,3,4,5'.split(',');
// => ['1', '2', '3', '4', '5']

// 🌰 当字符串含有空格时,空格也成为子串的一部分
' 1,2,3, 4, 5'.split(',');
// => [' 1', '2', '3', ' 4', ' 5']

// 🌰 使用正则表达式将解决这一问题
'1,2,3,4,5'.split(/\s*,\s*/);
// => ['1', '2', '3', '4', '5']
复制代码

RegExp对象

正则表达式是通过RegExp对象来表示的,除了RegExp构造函数之外,RegExp包含其他属性和方法。本节主要介绍exectest两个方法。在RegExp的方法中也可以使用上文中提到的引用等特性,这里不再赘述。

RegExp构造函数

RegExp构造函数带有两个字符串参数,其中第二个参数是可选的,两个参数与字面量语法相对应,两者都是使用\做转义字符

/pattern/flags;
new RegExp(pattern[, flags]);
复制代码

创建正则表达式副本

可以利用正则表达式传递给RegExp构造函数来创建它的副本,ES2015还支持为构造函数提供第二个参数覆盖其修饰符。

const p = /JavaScript/g;
new RegExp(p);
// => /JavaScript/g

new RegExp(p, 'i');
// => /JavaScript/i ES5中会抛出错误
复制代码

RegExp.prototype.exec

RegExp最主要的执行模式匹配的方法是RegExp.prototype.exec,它与String.prototype.match相似,它对一个指定的字符串执行一个正则表达式,即在一个字符串中执行检索匹配。

  • 如果没有找到匹配结果,它将返回null
  • 如果它找到了一个匹配,它将返回一个数组,就像match方法为非全局检索返回的数组一样
  • match方法不同,不管正则表达式是否具有全局修饰符gexec都返回一样的数组,即提供本次匹配完整信息的一个数组。
  • 当调用exec的正则表达式对象具有修饰符g
    • 它将把当前正则表达式对象的lastIndex属性设置为紧挨着匹配子串的字符位置,当同一正则表达式时第二次调用exec时,它将从lastIndex属性所指示的字符处开始检索。
    • 如果exec没有发现任何匹配结果,它会将lastIndex重置为0
    • 任何时候都可以将lastIndex属性设置为0,每当在字符串中找到最后一个匹配项后,🔔 在这个RegExp对象开始新的字符串查找之前,都应当将lastIndex置为0
    • 这种特殊行为可以使我们反复调用exec,在matchAll出现之前,是通过在循环中调用exec来获取所有匹配项信息的
// 🌰 这里我们执行与上文介绍String.prototype.match时相同的例子
const str = 'day1 day2 day3';
const pattern = /(day(\d))/;

pattern.exec(str);
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]
// 执行结果与含义与match是相同的

pattern.exec(str);
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]
// 在不使用全局检索的情况下,
// 再次执行它仍只检索第一个匹配
// 返回相同的结果
复制代码

当我们使用修饰符g时,结果开始变得有意思...

// 🌰 使用修饰符g执行exec
const str = 'day1 day2 day3';
const pattern = /(day(\d))/g;

pattern.lastIndex;
// => 0 在开始执行匹配之前,lastIndex为0

pattern.exec(str);
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]
// 返回第1个匹配

pattern.lastIndex;
// => 4 指向紧跟在'day1'后面的' '


pattern.exec(str);
// => ['day2', 'day2', '2', index: 5, input: 'day1 day2 day3', groups: undefined]
// 执行第2次匹配

pattern.lastIndex;
// => 9 指向紧跟在'day2'后面的' '

pattern.exec(str);
// => ['day3', 'day3', '3', index: 10, input: 'day1 day2 day3', groups: undefined]

pattern.exec(str);
// => null
// 执行第3、4次匹配

pattern.lastIndex;
// => 0 第4次匹配无结果,lastIndex被重置为0

pattern.exec(str);
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]
// 再次执行将重新开始匹配,结果与第1次匹配相同
// 或者说再次执行了第1次匹配

pattern.lastIndex;
// => 4 指向紧跟在'day1'后面的' '

pattern.exec('day4');
// => null 
// 此时匹配检索另一字符串结果为null
// 由于上次lastIndex为4,本次匹配将从字符串索引4开始,
// 而当前检索的字符串长度小于4,不可能找到匹配,因此返回null
// 如果此时检索的字符串长度大于4且字符串索引4之后还有匹配结果则将其返回
// 因此在在这个pattern开始新的字符串查找之前,应当将lastIndex置为0
复制代码

RegExp.prototype.test

test方法比exec简单一些,它对某个字符串进行检测,如果包含正则表达式的匹配结果,则返回true,否则返回false。testexec的相同之处在于当涉及到修饰符g时,它们对lastIndex的操作会有相同的表现。

const pattern = /JavaScript/;
const str = 'JavaScript is fun';

pattern.test('java');
// => false

pattern.test(str);
// => true

pattern.test(str);
// => true
// 非全局匹配的正则表达式调用test时不会修改lastIndex
// 也不会从lastIndex处开始匹配
// 即它不受lastIndex影响
// 非全局匹配的正则表达式调用exec亦是如此
复制代码

当使用全局匹配的正则表达式执行test时,就又涉及到了对lastIndex的修改,test也是在找到匹配结果的时候将lastIndex值为匹配子串结束的位置。

const pattern = /JavaScript/g;
const str = 'JavaScript is fun';

pattern.test('java');
// => false

pattern.test(str);
// => true

pattern.lastIndex;
// => 10

pattern.test(str);
// => false

pattern.lastIndex;
// => 0

pattern.test(str);
// => true
复制代码

lastIndex小结

RegExp中的方法不同,String中的方法不涉及对lastIndex的修改(或者说它们每次都将lastIndex置为0)。

带有g修饰符的正则表达式使用exectest方法时,🔔 记得在合适的时机lastIndex置为0,否则在下一次字符串检索时可能出现执行结果与期望值不符的意外情况。

ES5规范规定同一段代码所表示的正则表达式直接量的每次运算都会返回新对象,这也从一定程度上减少了lastIndex属性对执行结果造成的影响。

再看修饰符y

修饰符yg在对lastIndex的处理上有一定程度的相似性,这里仍使用之前的例子,并将正则表达式的修饰符从g变为y,展现修饰符y的粘滞特性。

// 🌰 修饰符y粘滞性示例
const str = 'day1 day2 day3';
const pattern = /(day(\d))/y;

pattern.exec(str)
// => ['day1', 'day1', '1', index: 0, input: 'day1 day2 day3', groups: undefined]
// 第1次匹配

pattern.lastIndex
// => 4 得到匹配之后立即修改lastIndex,与修饰符g表现一致

pattern.exec(str)
// => null 无匹配结果
// 第2次匹配
// 本次匹配从紧跟在'day1'后面的' '开始'
// /(day(\d))/y要匹配的子串第1个元素为'd',与' '不符,匹配失败

pattern.lastIndex
// => 0 无匹配结果,lastIndex置为0

// /(day(\d))/y与/(day(\d))/g对'day1day2day3'执行exec方法,得到的结果是相同的
复制代码

🔔 这里还有一个细节要注意,当使用^来匹配字符串的首部时,粘滞正则表达式只会匹配整个字符串的首部位置:

// 🌰 在粘滞匹配中使用"^"
const pattern = /^(day(\d))/y;
const str = 'day1day2day3';

pattern.exec(str);
// => [ 'day1', 'day1', '1', index: 0, input: 'day1day2day3', groups: undefined ]

pattern.exec(str);
// => null 此时不能匹配'day2'
复制代码

RegExp其他属性

这部分内容比较简单,直接以代码的形式展现:

const pattern = /[Jj]ava[Ss]cript/gu;

pattern.flags;
// => 'gu' 启用的修饰符(ES2015)

pattern.global;
// => true 是否启用修饰符g

pattern.ignoreCase;
// => false 是否启用修饰符i

pattern.multiline;
// => false 是否启用修饰符m

pattern.dotAll;
// => false 是否启用修饰符s(ES2015)

pattern.unicode;
// => true 是否启用修饰符u(ES2015)

pattern.sticky;
// => false 是否启用修饰符y(ES2018)

pattern.source;
// => '[Jj]ava[Ss]cript' 构造函数的第一个参数

pattern.toString();
// => '/[Jj]ava[Ss]cript/gu'
复制代码

写在最后

本文的主要内容到这里已经结束了,十分感谢你能抽出宝贵的时间阅读这篇文章,相信你多多少少都有了一些收获,至少是入门正则表达式的入门者了。当然这也不意味着这是正则表达式的全部,不要指望靠一篇文章掌握正则表达式,正则表达式的世界里还有更多的技巧等待你的探索。

想必你也知晓像Regexper这样的可视化工具,如果你修过编译原理这一课程,会发现可视化后的正则表达式与有穷自动机非常相似。如果真的要从原理上吃透正则表达式,那么最好的方式之一是:👉 自己实现一个正则引擎。笔者水平一般,能力有限,这里就不讨论其算法实现了。