Javascript中的正则表达式

919 阅读10分钟

何为正则

正则表达式是用于匹配字符串中字符组合的模式;在JS 中,正则也是一个对象,拥有 exec test 方法。

JS 中与正则相关的方法

  1. RegExp 对象的方法
  • test

test() 方法执行一个检索,用来查看正则表达式与指定的字符串是否匹配。返回 truefalse

  • exec

exec() 方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null,并更新正则表达式对象的 lastIndex 属性。

test 方法类似于 String.prototype.search() 方法,区别在于后者返回的是索引或者-1(找不到的话);如果想要获取更多信息,可以使用 exec 方法(执行较慢); exec 方法类似于 String.prototype.match() 方法,;在相同的正则表达式上重复调用 exec 或者 test,都会越过之前的匹配。

const reg = /foo/g;
reg.test('foo'); // true(匹配到了 foo)
reg.test('foo'); // false(第二次调用,越过了之前匹配到的foo)
reg.exec('foo'); // ["foo", index: 0, input: "foo", groups: undefined]
reg.exec('foo'); // null
  1. String 类型与正则相关的方法
  • match

match() 方法检索返回一个字符串匹配正则表达式的的结果。

  • 如果传入 match 方法的参数不是一个正则表达式,会隐式地调用 new RegExp(obj) 将其转换为正则;如果传入空参数,则返回 [""]
  • 如果使用g标志,则将返回与完整正则表达式匹配的所有结果,但不会返回捕获组。
  • 如果未使用g标志,则仅返回第一个完整匹配及其相关的捕获组(Array)。 在这种情况下,返回的项目将具有如下所述的其他属性。

如果正则表达式不包含 g 标志,str.match() 将返回与 RegExp.exec() 相同的结果。

  • matchAll 方法返回一个包含所有匹配正则表达式的结果及分组捕获组的迭代器。

match 方法相比,matchAll 方法能够更好地获取捕获组。因为使用 g 标志地时候,match 方法不会返回捕获组。

const regexp = /t(e)(st(\d?))/g;
const str = 'test1test2';

str.match(regexp); 
// Array ['test1', 'test2']
const array = [...str.matchAll(regexp)]; // matchAll 方法返回一个迭代器

array[0];
// ['test1', 'e', 'st1', '1', index: 0, input: 'test1test2', length: 4]
array[1];
// ['test2', 'e', 'st2', '2', index: 5, input: 'test1test2', length: 4]

如果正则表达式无 g 标志,则 matchAll 方法仅会返回首个匹配:

const regexp = RegExp('[a-c]','');
const str = 'abc';
Array.from(str.matchAll(regexp), m => m[0]);
  • replace

replace() 方法返回一个由替换值(replacement)替换一些或所有匹配的模式(pattern)后的新字符串。模式可以是一个字符串或者一个正则表达式,替换值可以是一个字符串或者一个每次匹配都要调用的回调函数。

replace() 的第一个参数可以为正则表达式或者是普通字符串;当参数为普通字符串时,只有第一个匹配项会被替换。第二个参数可以为字符串或者函数,当第二个参数为字符串时,可使用以下特殊变量名:

变量名 代表的值
? 插入一个 "$"。
$& 插入匹配的子串。
$` 插入当前匹配的子串左边的内容
$' 插入当前匹配的子串右边的内容。
$n 假如第一个参数是 RegExp对象,并且 n 是个小于100的非负整数,那么插入第 n 个括号匹配的字符串。提示:索引是从1开始

replace() 第二个参数是一个函数时,该函数的参数为:

变量名 代表的值
match 匹配的子串。(对应于上述的$&。)
p1,p2, ... 假如replace()方法的第一个参数是一个RegExp 对象,则代表第n个括号匹配的字符串。(对应于上述的$1,$2等。)例如,如果是用 /(\a+)(\b+)/ 这个来匹配,p1 就是匹配的 \a+,p2 就是匹配的 \b+。
offset 匹配到的子字符串在原字符串中的偏移量。(比如,如果原字符串是 'abcd',匹配到的子字符串是 'bc',那么这个参数将会是 1)精确的参数个数依赖于 replace() 的第一个参数是否是一个正则表达式(RegExp)对象,以及这个正则表达式中指定了多少个括号子串,如果这个正则表达式里使用了命名捕获, 还会添加一个命名捕获的对象
string 被匹配的原字符串。
NamedCaptureGroup 命名捕获组匹配的对象

示例:

function replacer(match, p1, p2, p3, offset, string) {
  // p1 is nondigits, p2 digits, and p3 non-alphanumerics
  return [p1, p2, p3].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer);
console.log(newString);  // abc - 12345 - #$*%
  • search 方法的参数为正则表达式;若传入的参数不是正则,会通过 new RegExp 转换为正则;返回值为第一个成功匹配的索引位置;如果无法匹配,返回 -1。
  • split 方法根据指定的分隔符将字符串对象分割为字符串数组。

语法为str.split([separator[, limit]]):

参数 separator可以是一个字符串,也可以是一个正则表达式,甚至可以是一个数组。当 separator 包含捕获括号,则其匹配结果将会包含在返回的数组中。

const myString = "Hello 1 word. Sentence number 2.";
splits = myString.split(/(\d)/); // [ "Hello ", "1", " word. Sentence number ", "2", "." ]

seperator 为一个数组时,它实际上会被转换为字符串:

const myString = 'ca,bc,a,bca,bca,bc';

myString.split(['a','b']); // ["c", "c,", "c", "c", "c"] 实际上就是以 a,b 为分隔符

limit 为一个整数,可省略,限定了返回的分割片段数量。'abcde'.split('',3) 返回值为 ["a", "b", "c"]

ES6 将以上四个方法,在语言内部全部调用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]

前端工作中的正则的应用

其实无论前端后端,正则最主要的作用都是过滤和校验;校验的工作,一般前后端要同时做(或者中台做,后端不做),前端为体验,后端为安全。 前端中正则的使用,比较特殊的地方是不同浏览器,对正则的支持是不一样的。ES6 对正则进行了部分扩展,但新时代的 IE —— Safari 对此支持很差;这意味着如果你的应用要考虑苹果用户,那就无法使用新增特性。

元字符

字符 含义
. 默认匹配除换行符之外的任何单个字符, dotAll 模式下匹配任意字符,比如 /foo.bar/.test('foo\nbar') 返回值为 false,而 /foo.bar/s.test('foo\nbar') 返回值为 true
\w 匹配字母或数字或下划线,等价于 [A-Za-z0-9_]
\W 匹配非单字字符,等价于 [^A-Za-z0-9_]
\s 匹配任意一个空白字符,包括空格、制表符、换页符和换行符
\S 匹配一个非空白字符
\d 匹配数字,即[0-9]
\D 匹配非数字,即[^0-9]
\b 匹配一个词的边界,/script\b/.test('javascript') 返回值为 true; /java\b/.test('javascript') 返回值为 false,因为 java 后面跟的不是边界;\w\b\w 不匹配任意字符
\B 匹配非单词边界
^ 匹配字符串的开始
$ 匹配字符串的结尾

以上总结的元字符中,除了. 之外,都是一一对应的,互为反义字符;另一种表示反义的方式是在字符类中使用^,例如 [^aeiou] 匹配所有非元音字母。 \b \B ^ $ 都是用来匹配一个位置;^ $ 很多时候都是成对出现的,比如,用来匹配 QQ 号是否合法的简单正则可写作 ^\d{5,12}$(QQ号为5——12位的数字)。 如果需要查找元字符本身的话,需要对其进行转义,比如,如果要匹配 github.com 的话,就需要对 . 做特殊处理 github\.com

字符类

如果是想要匹配字母,数字之类的字符串,直接使用预定义的元字符即可;如果想要匹配的字符集合不在元字符中,可以用方括号括起来,指定一个范围,例如 [0-9] 相当于 \d,匹配所有数字;[a-g] 只会匹配字母a到字母g之间的字符。如果我们想要获取一段文字中,所有以 a 或者 b 打头的英文单词,'apple basket coffee dog egg'.match(/\b[ab]\w+/g) 返回值为 ["apple", "basket"]

重复

字符 含义
* 重复零次或多次
+ 重复一次或多次
? 重复零次或一次
{n} 重复n次
{n,m} 重复n到m次
{n,} 重复n次或更多次

语法是字符后面跟上限定符即可,例如 \d{5,12} 可用来匹配5到12位到数字。如果我们需要一个匹配 IP 地址的正则表达式,只需要重复即可 /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/(目前是有问题的,因为类似 999.999.999.999的非法地址也会校验通过)。

分组

前面我们用来匹配 IP 地址的正则表达式中,\d{1,3}\. 这个子表达式重复了三次,我们可以使用 () 创建分组,后面跟上限定符:(\d{1,3}\.){3}\d{1,3}。一个正确的 IP 地址校验正则: ((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)

使用 () 进行分组之后,可以使用组号来引用被匹配的文本,组号分配的原则:

  • 分组0对应整个正则表达式;
  • 实际上组号分配过程是要从左向右扫描两遍的:第一遍只给未命名组分配,第二遍只给命名组分配--因此所有命名组的组号都大于未命名的组号;
  • 你可以使用(?:exp)这样的语法来剥夺一个分组对组号分配的参与权

例如,\b(\w+)\b\s+\1\b 可以用来匹配 go go 这类有重复结构的字符串。小括号常用分组语法:

  • 分组捕获:
字符 含义
(exp) 匹配exp,并捕获文本到自动命名的组里
(?exp) 匹配exp,并捕获文本到名称为name的组里,也可以写成(?'name'exp)
(?:exp) 匹配exp,不捕获匹配的文本,也不给此分组分配组号
  • 零宽断言
字符 含义
(?=exp) 匹配exp前面的位置
(?<=exp) 匹配exp后面的位置
(?!exp) 匹配后面跟的不是exp的位置
(?<!exp) 匹配前面不是exp的位置

以上四种语法,和 $ ^ 类似,都是用来匹配一个位置,这个位置满足某种条件(即断言)。除此之外,(?#comment) 可以为正则表达式添加注释。

(?=exp) 又称先行断言,它断言自身出现的位置后面能够匹配表达式 exp。比如 'doing reading learning'.match(/\b\w+(?=ing\b)/g) 运行的结果为 ["do", "read", "learn"]。这个表达式匹配了以 ing 结尾的单词(但排除了 ing 自身)。再举一个例子,'1234567890'.replace(/(?=(\B\d{3})+$)/g,',') 可以为数字字符串添加千份符;表达式 (\B\d{3})+$ 匹配的是一个位置,且这个位置满足以下条件:

  • 不是边界(为了避免给长度是 3 的倍数的字符串前面加上 ,
  • 后面至少跟随 3 个数字

但是如果我们只是想要确保某个字符没有出现,但并不想去匹配它时怎么办?例如,如果我们想查找这样的单词--它里面出现了字母q,但是q后面跟的不是字母u,我们可以尝试这样:

\b\w*q[^u]\w*\b 匹配包含后面不是字母u的字母q的单词。但是如果多做测试(或者你思维足够敏锐,直接就观察出来了),你会发现,如果q出现在单词的结尾的话,像Iraq,Benq,这个表达式就会出错。这是因为[^u]总要匹配一个字符,所以如果q是单词的最后一个字符的话,后面的[^u]将会匹配q后面的单词分隔符(可能是空格,或者是句号或其它的什么),后面的 \w*\b 将会匹配下一个单词,于是 \b\w*q[^u]\w*\b 就能匹配整个Iraq fighting。负向零宽断言能解决这样的问题,因为它只匹配一个位置,并不消费任何字符。现在,我们可以这样来解决这个问题:\b\w*q(?!u)\w*\b

零宽度负预测先行断言 (?!exp),断言此位置的后面不能匹配表达式exp。例如:\d{3}(?!\d) 匹配三位数字,而且这三位数字的后面不能是数字;\b((?!abc)\w)+\b 匹配不包含连续字符串abc的单词。

同理,我们可以用 (?<!exp),零宽度负回顾后发断言来断言此位置的前面不能匹配表达式exp:(?<![a-z])\d{7} 匹配前面不是小写字母的七位数字。

一个更复杂的例子:(?<=<(\w+)>).*(?=<\/\1>) 匹配不包含属性的简单HTML标签内里的内容。(?<=<(\w+)>) 指定了这样的前缀:被尖括号括起来的单词(比如可能是 <b> ),然后是.*(任意的字符串),最后是一个后缀 (?=<\/\1>)。注意后缀里的/,它用到了前面提过的字符转义;\1则是一个反向引用,引用的正是捕获的第一组,前面的(\w+)匹配的内容,这样如果前缀实际上是<b>的话,后缀就是</b>了。整个表达式匹配的是<b></b>之间的内容(再次提醒,不包括前缀和后缀本身)。

模式

  • 贪婪匹配:当使用限定符时,正则表达式通常都是去匹配尽量多的字符。比如 'java'.match(/j\w*a/g) 这个表达式返回的结果是 ["java"] 而不是 ["ja"];有时候,我们希望去匹配尽量少的字符,就需要用到懒惰匹配。
  • 懒惰匹配:使用限定符时,去匹配尽量少的字符。'java'.match(/j\w*?a/g) 这个表达式因为加入了 ?,使用懒惰匹配,返回结果为 ["ja"]
字符 含义
*? 重复任意次,但尽可能少重复
+? 重复1次或更多次,但尽可能少重复
?? 重复0次或1次,但尽可能少重复
{n,m}? 重复n到m次,但尽可能少重复
{n,}? 重复n次以上,但尽可能少重复

正则工具推荐

  • 正则可视化:为了更快地写出正确的正则,可以借助一些可视化工具,我在使用的是一个网页工具:Regexper
  • vscode 插件:涵盖了中文环境下常用的正则表达式;比如社保账号,身份证号,股票代码等;

参考链接: