正则表达式可以说是一个程序员进阶的必备技能,它可能很久才用一次,不过一旦使用,其威力无穷,不管是解决业务问题,还是面试(主要是面试)。
0 写在前面
本文定位为 JS 正则表达式的进阶文章,对于基础的正则表达式语法,可能不会进行详细介绍,这里推荐老姚的 JS 正则迷你书,助你快速入门掌握 JS 正则表达式。
本文是笔者全面系统学习 JS 正则表达式,结合自己的经验和理解整理出来的。主要内容包括以下四个部分:
- 总结 JS 正则表达式中所有特殊字符含义及使用。让你全面深入理解 JS 正则表达式语法及使用。
- 介绍并对比 JS 中与正则表达式相关的 API。要想发挥正则表达式威力,相关 API 必不可少。
- 如何编写高性能的正则表达式。一个正则表达式可能是你程序的性能瓶颈。
- 最后来一道展示真正实力的面试题:数字的千位分隔格式化。带你一步步写出该表达式,不再只会 copy 和死记硬背。
如果你能写出实现下面需求的表达式,并理解其中原理,那就爷请回了。
// 数字的千位分隔格式化
"123456789" => "123,456,789"
复制代码
下文会一步步介绍如何写出这个表达式。
我相信,接下来花费 10 分钟,你的 JS 正则表达式能力会更上一个台阶。我们开始吧!
1 特殊字符含义及使用
多长多复杂的正则表达式都是由以下特殊字符组成的,表示的功能可以总结为以下 9 种。
字符组相关:[]、-、^、.、\d、\D、\w、\W、\s、\S
。
空白字符:\r、\n、\f、\t、\v
。分别为回车符、换行符、换页符、水平制表符、垂直制表符。
重复匹配:*、+、?、{}
。
贪婪与懒惰:?
。
分组:()、(?:p)
。
反向引用:\1 ~ \10
。
分支:(p|p)
。
位置匹配:^、$、\b、\B 、(?=p)、(?!p)
。
转义:\
。
1.1 Unicode 模式
我们比较熟悉正则表达式的模式有 g、m、i。ES6 新增了 Unicode 模式,我们可以用 Unicode 来引用特殊字符。比如匹配汉字 /\p{Unified_Ideograph}/u
。
const isChinese = (text) => {
return /^\p{Unified_Ideograph}+$/u.test(text);
}
复制代码
1.2 字符组
定义字符组使用特殊字符[]
。
需求: 匹配 cat、cet、cft。
'cat cbt cct cdt cet cft'.match(/c[aec]t/g); // ["cat", "cct", "cet"]
复制代码
注意:字符组
[]
所在位置是占用一个字符,也就是该位置的字符可以是字符组中的任意一个字符,是或逻辑。
1.2.1 范围字符组
对于连续的字符,我们可以使用特殊字符 -
来表示一个范围组成的字符组。-
字符的两边可以是任意的字符。这里是根据 ASCII 字符码来圈定范围的。
而我们常用的字符组有: [0-9]、[a-z]、[A-Z]
。也可以一起使用[0-9a-zA-Z]
。由于 A 到 z 直接有其他字符,所以大小写需要分开写。
注意:- 字符必须位于两个字符中间,如果不是,则该字符丧失特殊功能,相当于普通的 – 字符。
'#ffffff #a00eff #09efaz'.match(/#[0-9a-zA-Z]{6}/g); // ["#ffffff", "#a00eff", "#09efaz"]
'cat cbt cct cdt cet cft c-t'.match(/c[-az]t/g); // ["cat", "c-t"]
复制代码
1.2.2 排除字符组
有时候,我们需要该位置的字符除了某些字符,可以是其他任意字符,这种情况就可以使用到排除字符组:^
。
^ 也可以匹配开始位置,在正则表达式中,同个字符在不同位置,会有不同的语法作用。
该取反字符位置字符组的开头,表示对整个字符组进行取反,并不是仅仅取反其后面的那一个字符。
注意:^ 字符必须位于字符组的开头位置,如果不是,则该字符丧失特殊功能,相当于普通的 ^ 字符。
'cat cbt cct cdt cet cft'.match(/c[^aec]t/g); // ["cbt", "cdt", "cft"]
'cat cbt cct cdt cet cft c^t'.match(/c[a^ec]t/g); // ["cat", "cct", "cet", "c^t"]
复制代码
1.2.3 预定义字符组
系统中预定义了一些常见的字符组,我们可以直接使用。
\d
:任何一个数字字符,等价于 [0-9]
。
\D
:任何一个非数字字符,等价于 [^0-9]
。
\w
:任何一个字母(大小写均可)数字字符或非下划线字符,等价于 [0-9a-zA-F_]
。
\W
:任何一个非字母(大小写均可)数字字符或非下划线字符,等价于 [^0-9a-zA-F_]
。
\s
:任意一个空白字符,等价于 [ \f\n\r\t\v]
。
\S
:任何一个非空白字符,等价于 [^\f\n\r\t\v]
。
.
:表示 [^\n\r\u2028\u2029]
,几乎代表了任意字符。换行符、回车符、行分隔符和段分隔符除外。
那如何表示匹配任意字符的字符组?/[\s\S]/
,还有其他很多表达方式,比如 /[\d\D]/
。
字符组到此介绍完了,有一点再提一下,一个字符组只会匹配一个字符。
1.3 重复匹配
前面我们说到,不管是绝对匹配还是字符组匹配,只能匹配一个字符。
正则表达式提供重复匹配的能力,避免每个位置的匹配规则都需要重复写一遍。
重复匹配主要的操作字符是:{m, n}。也就是该操作符前面的字符(分组)可以重复出现 m 到 n 次。
重复匹配量词的作用对象是前面的字符或者分组。
'a1b a12b a123b a1234b a12345b a123456b'.match(/a\d{2,4}b/g); // ["a12b", "a123b", "a1234b"]
复制代码
系统提供了几个简写情况:
*
:表示可以出现任意次,等价于 {0,}
。
?
:表示出现 0 次或一次,等价于 {0,1}
。
+
:表示至少出现一次,等价于 {1,}
。
{m}
:表示出现 m 次,这是固定次数。
{n,}
:表示至少出现 n 次。
// 匹配 url
/ht{2}ps?:\/\/[-\w.\/]+/
复制代码
1.3.1 贪婪模式
这里需要注意的是,重复匹配默认是贪婪模式,也就是在匹配的时候,它会匹配所有可能的字符。
const regex = /\d{2,5}/g;
const string = "123 1234 12345 123456";
console.log( string.match(regex) );
// ["123", "1234", "12345", "12345"]
复制代码
不过有时候,我们希望它只匹配最小满足字符就可以了,就需要手动将其设置为非贪婪模式,在量词后面加上 ?
即可。
非贪婪就是有匹配结果就结束了。
const regex = /\d{2,5}?/g;
const string = "123 1234 12345 123456";
console.log( string.match(regex) );
// ["12", "12", "34", "12", "34", "12", "34", "56"]
复制代码
再举个例子,比如我们希望匹配获取 div 标签的 id 属性值。
<div id="container" class="className">text</div>
复制代码
默认贪婪模式
'<div id="container" class="className">text</div>'.match(/id=".*"/g);
// ['id="container" class="className"']
复制代码
非贪婪模式
'<div id="container" class="className">text</div>'.match(/id=".*?"/g) ;
// ['id="container"']
复制代码
虽然重复匹配默认是贪婪的,但是为了最终结果能匹配成功,会放弃最大匹配。
'abbc abbbc abbbbc'.match(/ab{1,3}bc/g); // ["abbc", "abbbc", "abbbbc"]
复制代码
上述例子就涉及到了回溯,后面我们会详细介绍。
1.4 分组
只要给子表达式加上括号()
,则该子表达式匹配的内容就会独立保存到一个组(group)中。
给一个子表达式分组,就可以视所匹配到的结果为单一的实体来使用。
/(\d{4})-(\d{2})-\d{2}/
复制代码
分组是正则表达式重要的能力之一,有了组以后,我们就可以获取到组的内容。子表达式加上括号以后,就成一个捕获组,其匹配的内容,我们就能引用和获取到。
给子表达式加上括号进行分组,对匹配结果并没有影响(注意量词的作用对象),只是增加了分组独立存储,理论上我们是可以给任意子表达式加上括号进行分组,不过这会影响性能和可读性,并不建议这么做.
我们还可以给分组命名,在分组的开头加上 ?即可命名。不过名字的使用就需要结合相关的 API 了。
组的使用分为两种:捕获分组匹配结果、反向引用分组匹配结果。
1.4.1 捕获组
捕获主要在匹配结果中使用,配合相关 API 使用,比如 String 的 match 方法 或者 RegExp 的 exec 方法。
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const string = "2021-03-12";
string.match(regex);
// => ["2021-03-12", "2021", "03", "12", index: 0, input: "2021-03-12"]
复制代码
String 的 match 方法返回的数组结果,第一项为匹配结果,之后是捕获组匹配结果,比如上述示例中的 2021、03、12,都是捕获组的结果。
另外调用正则表达式对象的 test 和 exec 方法后,RegExp 全局构造函数的静态属性 $1-$9
也保存了上次匹配结果的分组信息。
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const string = "2021-03-12";
regex.exec(string);
RegExp.$1; // "2021"
RegExp.$2; // "03"
RegExp.$3; // "12"
复制代码
1.4.2 反向引用
反向引用则是在表达式中使用。主要使用场景是:确保之后要匹配的字符与先前已匹配到的字符一致。
比如我们想匹配 DOM 字符串中所有的标题。
// 表达式
const regex = /<[Hh][1-6]>.*?<\/[Hh][1-6]>/gm;
const str = `
<h1>this is h1</h1>
<h2>this is h2</h2>
<h1>this is a error title</h6>
`;
str.match(regex);
// ["<h1>this is h1</h1>", "<h2>this is h2</h2>", "<h1>this is a error title</h6>"]
复制代码
上面例子中,闭合标签应该和开标签是一致的,显然第三个结果是不符合我们期望的。这种需要在动态匹配过程中,让之后匹配的字符与先前匹配到的字符保持一致,这就可以使用反向引用,反向,就是向后看,已经匹配过的字符。
// 表达式
const regex = /<([Hh][1-6])>.*?<\/\1>/gm;
const str = `
<h1>this is h1</h1>
<h2>this is h2</h2>
<h1>this is a error title</h6>
`;
str.match(regex);
// ["<h1>this is h1</h1>", "<h2>this is h2</h2>"]
复制代码
/<([Hh][1-6])>.*?<\/\1>/gm
我们使用 \1
来引用前面出现的第一个分组。
上述例子中,我们先将需要引用的子表达式独立分组([Hh][1-6])
,然后在之后的子表达式中,就可以使用\1 - \10
来引用先前匹配到的分组。
1.4.3 非捕获组
分组默认就是捕获组,也就是正则表达式引擎需要记忆和保存各个组的相关信息。
然而,在某些情况下,如果我们只是为了分组,以便使用量词和分支,并没有相关的捕获和反向引用需求,将分组设置为非捕获组是一个小的优化。
另外,使用某些 API 时,捕获组会出现在匹配结果中,如果不想某些分组影响匹配结果,就可以将该组设置为非捕获组。
只需要在分组的开头加上?:
即可。
/(?:\d{4})-(\d{2})-\d{2}/
复制代码
我们可以看到,当将分组设置为非捕获组后,正则引擎则不会单独存储该分组相关信息,达到了性能优化的目的。
1.5 分支
当我们匹配的目标文本(或者子字符串)存在多种可能(路径),一个正则(子)表达式无法满足时,就可以使用正则表达式提供的或|
操作符,将其分解为多条路径,也就是分支。
1.5.1 分支的分割是基于”组”
我们来看个例子:
const regex = /There is a cat|dog|pig/gm;
const str = `
There is a cat
There is a dog
There is a pig
`;
str.match(regex);
// ["There is a cat", "dog", "pig"]
复制代码
分支操作符|
会将左右两边分为两个独立的正则表达式。
而分割的范围是基于分组的(所在的最近分组),上述的例子中,没有显性使用括号进行分组,其实整个正则表达式就是一个分组,所以上述例子中正则表达式被分解为三个独立的表达式/There is a cat/
、 /dog/
和/pig/
。
其实整个正则表达式就是一个大分组的:const regex = /(There is a cat|dog|pig)/g
。
如果我们想在某个子表达式中使用分支,则需要先将该子表达式分组。
const regex = /There is a (cat|dog|pig)/gm;
const str = `
There is a cat
There is a dog
There is a pig
`;
str.match(regex);
// ["There is a cat", "There is a dog", "There is a pig"]
复制代码
1.5.2 分支是懒惰模式
// 表达式
const regex = /java|javascript/g;
'java javascript'.match(regex); // ["java", "java"]
复制代码
我们可以看到,对于字符串 javascript 是没有匹配到,仅仅匹配到了前面的 java 子串。
这是因为分支是懒惰的,匹配时,先使用第一个分支的表达式进行匹配,如果成功则结束不再尝试其他分支;如果失败,则使用第二个分支的表达式进行匹配,以此类推,直到所有分支都失败,则匹配失败。
所以在使用分支时,我们需要注意各个分支的顺序。
我们来看下匹配 IP 地址的例子。
// 表达式
const regex = /((\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])\.){3}(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])/;
'12.159.46.200'.match(regex) // ["12.159.46.20"]
复制代码
所以我们需要范围小的匹配规则放在前面,来避免分支懒惰。
/((25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\.){3}(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})/
1.6 位置匹配
位置可以简单理解为字符之间的空隙。
JS 正则表达式目前有6个操作符可以用来匹配位置:^、$、\b、\B 、(?=p)、(?!p)
。
^
:匹配开头,在多行匹配中匹配行开头。
$
:匹配结尾,在多行匹配中匹配行结尾。
\b
:是单词边界,具体就是 \w
与 \W
之间的位置,也包括 \w
与 ^
之间的位置,和 \w
与 $
之间的位置。
\B
:就是 \b
的反面的意思,非单词边界。
(?=p)
:肯定式向前查看( positive lookahead)。匹配子表达式 p 匹配到的子字符串的前面位置。
(?!p)
:否定式向前查看( negative lookahead)。匹配不是子表达式 p 匹配到的子字符串的前面位置。
位置匹配可以理解为有两个作用:插入和匹配过滤。
1.6.1 位置插入
^ 和 $
const result = "hello\nhello".replace(/^|$/g, '#');
// #hello
// #hello#
const result = "hello\nhello".replace(/^|$/mg, '#');
// #hello#
// #hello#
复制代码
^
:匹配开头,在多行匹配模式下(m)中会匹配每一行开头。
$
:匹配结尾,在多行匹配模式下(m)中会匹配每一行结尾。
\b 和 \B
const result = "hello word".replace(/\b/g, '#');
// #hello# #word#
const result = "hello word".replace(/\B/g, '#');
// h#e#l#l#o w#o#r#d
复制代码
\b
匹配单词与非单词字符之间的位置。\B
与 \b
功能相反,匹配单词与单词字符之间的位置。
(?=p) 和 (?!p)
const result = "hello".replace(/(?=ll)/g, '#');
// he#llo
const result = "hello".replace(/(?:ll)/g, '#');
// #h#el#l#o#
复制代码
(?=p)
可以理解为子表达式 p 的匹配,只不过匹配结果不是返回表达式匹配的结果,而是返回该结果前面的位置。
(?!p)
同理,先看子表达式 p 的匹配结果,返回所有非结果的子字符串的前面位置。
(?=p)
和 (?!p)
有些地方翻译为环视(能够前后查看),其实,这个理解为匹配表达式 p 前面的位置就可以了。
JS 中还没有实现向后查看,也就是无法获取表达式 p 后面的位置。
1.6.2 位置匹配过滤
^ 和 $
需求:我们想匹配每一行最后一个单词。
const regex = /\w+$/gm;
const str = `
hello world
this is a dog
I love javascript
`;
str.match(regex); // ["world", "dog", "javascript"]
复制代码
可以这么理解:在搜索匹配时,这些位置特殊字符起到了过滤效果。上述正则表达式中,如果去掉 $
,变成 /\w+/
,将会匹配所有的单词,当我们加上 $
后,这就要求这些单词后面必须是结尾位置。
\b 和 \B
需求:匹配完整的单词.
const regex = /cap/g;
const str = 'The captain wore his cap and cape proudly';
str.match(regex); // ["cap", "cap", "cap"]
复制代码
模式 /cap/
可以匹配文本中所有的 cap
,包括能匹配单词 captain
和 cape
中的 cap
。
这时候我们就可以把单词边界 \b 派上用场。
const regex = /\bcap\b/g;
const str = 'The captain wore his cap and cape proudly';
str.match(regex); // ["cap"]
复制代码
\b
要求了 cap
子串的前后位置必须是单词边界,因此能匹配完整的单词。
(?=p) 和 (?!p)
需求:获取链接中的协议。
方法一:使用捕获组。
const regex = /(\w+):\/\//gm;
const str = `
http://www.baidu.com
https://www.baidu.com
ftp://www.baidu.com
`;
str.match(regex); // ["http://", "https://", "ftp://"]
复制代码
方法二:使用位置匹配过滤。
\w+(?=:\/\/)
const regex = /\w+(?=:\/\/)/gm;
const str = `
http://www.baidu.com
https://www.baidu.com
ftp://www.baidu.com
`;
str.match(regex); // ["http://", "https://", "ftp://"]
复制代码
还有一种说法叫做”不消耗”。对于普通的正则表达式,会匹配并返回匹配到的文本,这就是”消耗”。
而对于 (?=p)
和 (?!p)
,不返回匹配到的文本,只会向前查看(返回前面的位置),叫做”不消耗”。
上面的例子中,(?=:\/\/)
中的模式匹配到了文本://
,但是不会返回到匹配结果中,而是返回其前面的位置,为前面的子表达式增加了一个过滤条件:需要在该模式前面。
1.7 转义
在正则表达式的特殊字符发挥其特殊语法的位置,需要使用到该字符本身,则需要进行转义,使用 \
,对于 \
,则是 \\
。
/a[a^c]t/ // 无需转义
/\^cat/ // 想要匹配 ^cat 则需要转义
复制代码
2 JS 正则表达式相关 API
要想发挥正则表达式的威力,必须借助 JS 提供的相关 API,我们需要了解各个 API 的使用,根据特定场景选择对应的 API。
在JS中,与正则表达式相关方法的有 7 个,String 实例 5 个,RegExp 实例 2 个。
RegExp.prototype.exec
RegExp.prototype.test
String.prototype.search
String.prototype.match
String.prototype.matchAll
String.prototype.split
String.prototype.replace
2.1 RegExp.prototype.exec
regexObj.exec(str)
复制代码
在一个指定字符串中执行一次搜索匹配(不管是否开启全局模式,匹配到一个结果就会停止)。返回一个结果数组或 null。
返回结果包括匹配的子串和捕获组。
同时返回的数组还具有一下属性:
- groups: 一个捕获组数组或 undefined(如果没有定义命名捕获组)。
- index: 匹配的结果的开始位置。
- input: 搜索的字符串。
示例:
const regex = /(?<year>\d{4})-(\d{2})-\d{2}/;
const string = "2021-03-12 2012-01-15";
const res = regex.exec(string);
res; // ["2021-03-12", "2021", "03"]
res.group; // { year:"2021" }
res.index; // 0
res.input; // "2021-03-12 2012-01-15"
regex.lastIndex // 0
复制代码
需要注意的是,当正则表达式开启全局模式后,每次调用 exec 也只会匹配一次,并更新 lastIndex,也就是下一次开始匹配的位置。如果不开启全局模式,则 lastIndex 总是为 0,也就是每次都是从文本开头开始搜索匹配。
const regex = /(\d{4})-(\d{2})-\d{2}/g;
const string = "2021-03-12 2012-01-15";
// 第一次执行
regex.exec(string);
// ["2021-03-12", "2021", "03", index: 0, input: "2021-03-12 2012-01-15", groups: undefined]
regex.lastIndex // 10
// 第二次执行
regex.exec(string);
// ["2012-01-15", "2012", "01", index: 11, input: "2021-03-12 2012-01-15", groups: undefined]
regex.lastIndex; // 21
复制代码
所以,如果想匹配所有的结果,我们需要写个循环。从 lastIndex 位置开始匹配,如果没有匹配成功则返回 null,重置 lastIndex 为 0。
const regex = /(\d{4})-(\d{2})-\d{2}/g;
const string = "2021-03-12 2012-01-15";
let result;
while (result = regex.exec(string)) {
console.log( result, regex.lastIndex );
}
复制代码
显然,使用 exec 来获取正则表达式全局模式下所有的匹配结果是有点繁琐的。
所以,如果我们只是简单地想获取所有的匹配结果,使用 string 的 match 方法更加方便。
不过 exec 通过每次匹配,可以获取到匹配结果在源字符串中的位置。
2.2 String.prototype.match
str.match(regex))
复制代码
当正则表达式没有开启全局模式时,每次调用都会执行一次匹配,有匹配结果则返回,没有则返回 null。返回的结果数组和 exec 方法一致。
前面我们提到,要想获取全局模式下所有的匹配结果,如果使用 exec 需要使用循环。而 match 弥补了这个不足。不过也有一些信息获取不到,就是没有返回捕获组的相关信息了。
示例:
"2021-03-12 2012-01-15".match(/(\d{4})-(\d{2})-\d{2}/g);
// ["2021-03-12", "2012-01-15"]
复制代码
所以如果需要获取捕获组结果,就不能使用全局模式。
没有开启全局模式的情况下,使用 exec 和 match 都可,也能获取到所有想要的信息。
开启全局模式后,如果只是想简单获取所有的匹配结果,那使用 match 更加高效。如果还想获取相关的捕获组和匹配结果位置信息,就只能使用 exec 了。
另外 string 还有一个 matchAll 方法,返回值是所有匹配结果的迭代器。这是完全抄 exec 作业啊。
2.3 String.prototype.replace
exec 和 match 是比较常用和强大的方法了,不过 replace 能力也很强,我们可以假借替换之名,做些其他事情。
str.replace(regexp|substr, newSubStr|function)
复制代码
第二个参数为函数
当正则表达式使用全局模式时,每匹配到一个结果都会执行一次第二个参数的函数,函数返回值为匹配结果子串的替换值。
replacer(match, (p1, p2, p3, ...), offset, string, namedCaptureGroup) => string
复制代码
match
: 本次匹配结果子串。
p1, p2,...
: 捕获组子串。
offset
: 匹配结果在原字符串的偏移值。
string
: 被匹配的原字符串。
namedCaptureGroup
: 命名捕获组匹配的对象。
"2021-03-12 2012-01-15".replace(/(?<year>\d{4})-(\d{2})-\d{2}/g, (...arr) => console.log(arr))
/*
0:"2021-03-12"
1:"2021" // 第一个捕获组
2:"03"// 第二个捕获组
3:0
4:"2021-03-12 2012-01-15"
5:{year:"2021"}
length:6
*/
/*
"2012-01-15"
1:"2012"
2:"01"
3:11
4:"2021-03-12 2012-01-15"
5:{year:"2012"}
length:6
*/
复制代码
第二个参数为字符串
当第二个参数为字符串时,可以通过一些特殊字符来引用到匹配的相关信息。
$1,$2,…,$99
:获取第 1-99 个捕获组的文本。
$&
:匹配到的子串文本。
$`:匹配到的子串的左边文本。
$'
:匹配到的子串的右边文本。
$$
:美元符号。
"start2021-03-12end".replace(/(\d{4})-(\d{2})-(\d{2})/g, "([$&][$1][$2][$3][$`][$'][$$1]");
// "start([2021-03-12][2021][03][12][start][end][$1]end"
复制代码
示例:将每个单词的首字母转换为大写。
'my name is epeli' => My Name Is Epeli
复制代码
// 写法一
function titleize (str) {
return str.toLowerCase().replace(/(?:^|\s)\w/g, (c) => {
return c.toUpperCase();
});
}
console.log( titleize('my name is epeli') );
// 写法二
function titleize (str) {
return str.toLowerCase().replace(/\b\w/g, (c) => {
return c.toUpperCase();
});
}
console.log( titleize('my name is epeli') );
复制代码
2.4 String.prototype.search
str.search(regexp)
复制代码
参数:
regexp
,一个正则表达式对象。如果传入一个非正则表达式对象 regexp
(正则的特殊字符需要转义),则会使用 new RegExp(regexp)
隐式地将其转换为正则表达式对象。
返回值:
如果匹配成功,则返回正则表达式匹配的结果在字符串中首次出现的索引;没有匹配到则返回 -1。
不管正则表达式是否为全局模式,都只会返回第一个匹配的子串的开始索引。
示例:
"a ab aba abab ababab".search(/ab/g); // 2
复制代码
如果是查找绝对匹配的子串,使用 String.prototype.indexOf 更加方便和高效。当需要查找不确定的需要通过正则表达式去匹配的子串,则可以借助 search,indexof 不支持正则表达式入参。
search 方法每次搜索都是从字符串开头向后查找,不会记录上一次的搜索结果。
2.5 RegExp.prototype.test
regexObj.test(str)
复制代码
测试该正则表达式是否可以在字符串 str 中匹配到结果,只要有一个结果则成功返回 true,否则返回 false。
/(\d{4})-(\d{2})-\d{2}/.test("2021-03-12 2012-01-15"); // true
复制代码
与 search 功能一样,只是返回值不同而已。
2.6 String.prototype.split
split 比较常用,用来分割字符串。比如:
'java,javascript,php'.split(','); // ["java", "javascript", "php"]
复制代码
这里提两个点:
- 第二个参数可以指定返回结果长度。
- 使用正则表达式分组时,分割结果中会包含分割符。
'java,javascript,php'.split(',', 2) // ["java", "javascript"]
'java,javascript,php'.split(/(,)/) // ["java", ",", "javascript", ",", "php"]
复制代码
使用正则表达式,我们可以对字符串进行验证、切分、提取、替换。
验证:RegExp.prototype.test、String.prototype.search。
切分:String.prototype.split。
提取:RegExp.prototype.exec、String.prototype.match、String.prototype.matchAll。
替换:String.prototype.replace。
3 高性能表达式
正则表达式看起来很简单,我们往往不会留意其性能问题,只要能工作就行。
然而,如果写了一个槽糕的正则表达式,也是能拖累整个应用的性能的,这是有血的教训的。
以下是笔者总结的几种提升正则表达式性能的方法。
3.1 提前编译
JS 正则表达式主要有阶段:编译和执行。
编译阶段发生在正则表达式被创建的时期。执行阶段发生正则表达式进行匹配字符串的时期。
在编译过程中,表达式经过 Javascript 引擎的解析,转换为内部代码。解析和转换的过程发生在正则表达式创建时期,也就是编辑过程发生在正则表达式创建时。
const regex1 = /test/i; // 通过字面量创建正则,编辑阶段
const regex2 = new RegExp('test', 'i'); // 通过构造函数创建正则,编辑阶段
regex1.test('Test'); // 执行阶段
复制代码
每次创建正则表达式都会执行编译操作,所以,对于重复使用的正则表达式,将其保存在变量中是很重要的优化过程。
const divs = document.querySelectorAll('div');
// 每次都会创建个正则表达式,每次都会执行编辑操作。
const res = divs.filter(div => /title/.test(div.className));
// 优化
const regex = /title/; // 只创建一次,只编译一次
const divs = document.querySelectorAll('div');
const res = divs.filter(div => regex.test(div.className));
复制代码
3.2 减少回溯发生
当我们使用量词进行重复匹配时,默认是贪婪模式,也就是执行匹配时,会尽可能多地匹配,指定无法匹配了,才继续下一个子表达式的匹配。如果后面的子表达式也能匹配成功,皆大欢喜,不用回溯,匹配成功。
比如下面的例子就没发生回溯。
/ab{1,3}c/.exec('abbbc')
复制代码
但如果下一个子表达式匹配失败了,那就会触发回溯,尝试其他路径是否成功,也就是前面不能最大匹配了。
/ab{1,3}bbc/.exec('abbbc')
复制代码
通过对比可以看到,当正则表达式频繁回溯时,其执行耗时会增长很快。
回溯时影响正则表达式性能最大的因素,所以在编写正则表达式时,我们有意识地避免或减少回溯的产生。
3.3 优化总结
- 对于重复使用的正则表达式,保存在变量中,减少编辑耗时。
- 避免回溯,在写正则表达式时,考虑避免回溯,尽量避免使用重复量词。根据实际场景,让其变为懒惰模式。
- 不需要引用分组信息时,使用非捕获组。
4 实践
写一个能匹配预期内容的正则表达式并不难。但是写一个能够考虑到所有可能场景,确保将不需要匹配的内容排除在外的正则表达式可就难多了。
掌握正则表达式语法不难,写出满足需求的表达式需要多实践。这里推荐几个工具:
可视化看到正则表达式结构:Regulex。
正则表达式在线测试,并且还有语法解析:regex101、中文版。
4.1 数字的千位格式化
测试用例:
- “1234567” => “1,234,567”
- “12345678” => “12,345,678”
- “123456789” => “123,456,789”
看到这个需求,结合上面介绍的 JS 正则表达式所有能用的语法,你觉得我们可以使用哪个语法?
我第一个想到是位置插入,结合 replace API,在匹配到的位置,插入分隔符,
即可。
位置匹配 + replace
"hello".replace(/(?=ll)/g, '#'); // he#llo
复制代码
先直接给出写法:/(?!^)(?=(\d{3})+$)/g
,供大家参考。
"1234567".replace(/(?!^)(?=(\d{3})+$)/g, ','); // "1,234,567"
"12345678".replace(/(?!^)(?=(\d{3})+$)/g, ','); // "12,345,678"
"123456789".replace(/(?!^)(?=(\d{3})+$)/g, ','); // "123,456,789"
复制代码
我们来解析下这个表达式。
我们希望匹配3个数字前面的位置,所以可以使用肯定式向前查看(?=p)
,我们很快就可以写下下面的表达式:
var reg = /(?=\d{3})/g;
"12345678".replace(reg, ','); // ",1,2,3,4,5,678"
复制代码
然而,匹配结果并不是我们想要的,因为该表达式会匹配任意三个数字前面的位置,678 前面的位置,567 前面的位置等都会匹配到。
我们需要细化规则,也就是需要更多的限制条件。不仅仅是3个数字前面的位置,也要求,该位置后面的数字是3的倍数。倍数?想到了什么,这不就是重复匹配嘛。这里要求至少三个数字,所以使用 +
。
var reg = /(?=(\d{3})+)/g;
"12345678".replace(reg, ','); // ",1,2,3,4,5,678"
复制代码
然而,匹配结果还是不如我们意。为什么不对呢?我们来分析下上面我们写的表达式。我们只限制了位置后面必须有3倍数个的数字,所以,对于1234
,1
前面的位置也是符合要求,因为有123
三个数字了,虽然还多出了个4
。
所以还需要增加个限制条件:位置后面所有数字的个数必须是3的倍数,如何限制全部?还是位置匹配符号,还记得 $
吗?
var reg = /(?=(\d{3})+$)/g;
"12345678".replace(reg, ','); // "12,345,678"
复制代码
欢呼吧少年,终于看到了我们期望的结果。我们过下所有测试用例,发现有个123456789
没有通过:
var reg = /(?=(\d{3})+$)/g;
"123456789".replace(reg, ','); // ",123,456,789"
复制代码
我们还要加个限制条件,不能匹配开始的位置,这个需要用到(?!p)
和^
,都是位置匹配相关的字符,上面都有介绍过。
所以,最后的写法为:/(?!^)(?=(\d{3})+$)/g
。
至此,该写法的神秘面纱就被揭开了。