33

正则全攻略使用手册,你确定不进来看看吗

 5 years ago
source link: https://segmentfault.com/a/1190000018042746?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

正则表达式是软件领域为数不多的伟大创作。与之相提并论是分组交换网络、Web、Lisp、哈希算法、UNIX、编译技术、关系模型、面向对象等。正则自身简单、优美、功能强大、妙用无穷。

学习正则表达式,语法并不难,稍微看些例子,多可照葫芦画瓢。但三两篇快餐文章,鲜能理解深刻。再遇又需一番查找,竹篮打水一场空。不止正则,其他技术点同样,需要系统的学习。多读经典书籍,站在巨人肩膀前行。

这里涉及的东西太多,我就着重讲日常开发中可能会用到的内容,如果像深入理解的话推荐翻阅书籍《精通正则表达式》

(所以简单来说,学习正则就是投入高,收益低)(起初一看简单易懂,深入了解过后感叹正则的强大)

全文略长,可以选择感兴趣的部分看

1、介绍正则

正则表达式严谨来讲,是一种描述字符串结构模式的形式化表达方法。起始于数学领域,流行于 Perl 正则引擎。JavaScript 从 ES 3 引入正则表达式,ES 6 扩展

对正则表达式支持。

正则原理

对于固定字符串的处理,简单的字符串匹配算法(类 KMP 算法)相较更快;但如果进行复杂多变的字符处理,正则表达式速度则更胜一筹。那正则表达式具体匹配原理是什么?这就涉及到编译原理的知识(编译原理着实是我大三里面最头疼的课程了)

正则表达式引擎实现采用一种特殊理论模型:有穷自动机( Finite Automata )也叫有限状态自动机( finite-state machine )具体的细节见文章底部的参考文档

字符组

字符组 含义 [ab] 匹配 a 或 b [0-9] 匹配 0 或 1 或 2 ... 或 9 匹配 除 a、b 任意字符 字符组 含义 d 表示 [0-9],数字字符 D 表示 [^0-9],非数字字符 w 表示 [_0-9a-zA-Z],单词字符,注意下划线 W 表示 [^_0-9a-zA-Z],非单词字符 s 表示 [ tvnrf],空白符 S 表示 [^ tvnrf],非空白符 . 表示 [^nru2028u2029]。通配符,匹配除换行符、回车符、行分隔符、段分隔符外任意字符

量词

匹配优先量词 忽略优先量词 含义 {m,n} {m,n}? 表示至少出现 m 次,至多 n 次 {m,} {m,}? 表示至少出现 m 次 {m} {m}? 表示必须出现 m 次,等价 {m,m} ? ?? 等价 {0,1} + +? 等价 {1,} * *? 等价 {0,}

锚点与断言

正则表达式中有些结构并不真正匹配文本,只负责判断在某个位置左/右侧的文本是否符合要求,被称为锚点。常见锚点有三类:行起始/结束位置、单词边界、环视。在 ES5 中共有 6 个锚点。

锚点 含义 ^ 匹配开头,多行匹配中匹配行开头 $ 匹配结尾,多行匹配中匹配行结尾 b 单词边界,w 与 W 之间位置 B 非单词边界 (?=p) 该位置后面字符要匹配 p (?!p) 该位置后面字符不匹配 p

需要注意, \b 也包括 \w^ 之间的位置,以及 \w$ 之间的位置。如图所示。

RrmUJz3.png!web

修饰符

修饰符是指匹配时使用的模式规则。ES5 中存在三种匹配模式:忽略大小写模式、多行模式、全局匹配模式,对应修饰符如下。

修饰符 含义 i 不区分大小写匹配 m 允许匹配多行 g 执行全局匹配 u Unicode 模式,用来正确处理大于 \uFFFF 的 Unicode 字符,处理四个字节的 UTF-16 编码。 y 粘连模式,和g相似都是全局匹配,但是特点是:后一次匹配都从上一次匹配成功的下一个位置开始,必须从剩余的第一个位置开始,这就是“粘连”的涵义。 s dotAll 模式,大部分情况是用来处理行终止符的

2、正则的方法

字符串对象共有 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]

String.prototype.match

q6BNBn7.png!web

String.prototype.replace

字符串的replace方法,应该是我们最常用的方法之一了,这里我给详细的说一下其中的各种使用攻略。

3IVnm2y.png!web

replace 函数的第一个参数可以是一个正则,或者是一个字符串(字符串没有全局模式,仅匹配一次),用来匹配你想要将替换它掉的文本内容

第二个参数可以是字符串,或者是一个返回字符串的函数。这里请注意,如果使用的是字符串,JS 引擎会给你一些 tips 来攻略这段文本:

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

如果你不清楚捕获组的顺序,给你一个简单的法则:从左到右数 >>> 第几个 '(' 符号就是第几个捕获组

(特别适用于捕获组里有捕获组的情况)(在函数模式里,解构赋值时会特别好用)

iayeUz3.png!web

$`:就是相当于正则匹配到的内容的左侧文本

$':就是相当于正则匹配到的内容右侧文本

$&:正则匹配到的内容

$1 - $n:对应捕获组

如果参数使用的是函数,则可以对匹配的内容进行一些过滤或者是补充

下面是该函数的参数:

变量名 代表的值 match 匹配的子串。(对应于上述的$&。) p1,p2, ... 假如replace()方法的第一个参数是一个 RegExp 对象,则代表第n个括号匹配的字符串。(对应于上述的$1,$2等。)例如, 如果是用 /(\a+)(\b+)/ 这个来匹配, p1 就是匹配的 \a+ , p2 就是匹配的 \b+。 offset 匹配到的子字符串在原字符串中的偏移量。(比如,如果原字符串是“abcd”,匹配到的子字符串是“bc”,那么这个参数将是1) string 被匹配的原字符串。

一个示例,从富文本里面,匹配到里面的图片标签的地址

aiUbqi7.png!web

可以说,使用函数来替换文本的话,基本上你想干嘛就干嘛

String.prototype.search

RNniqyR.png!web

String.prototype.split

2U7FBfN.png!web

RegExp.prototype.test

BFzA3mB.png!web

和String.prototype.search 的功能很像,但是这个是返回布尔值,search返回的是下标,这个从语义化角度看比较适合校检

RegExp.prototype.exec

BVRnIr7.png!web

ayY7VzN.png!web

3、正则常见的使用

主要内容是ES6 里新增的修饰符(u,y,s)(g,m,i 就不说了)、贪婪和非贪婪模式、先行/后行断言

'u' 修饰符

ES6 对正则表达式添加了 u 修饰符,含义为“Unicode 模式”,用来正确处理大于 \uFFFF 的 Unicode 字符。也就是说,会正确处理四个字节的 UTF-16 编码。少说废话,看图

aaq6vuf.png!web

但是很可惜的是 MDN给出的浏览器兼容性如下:(截止至2019.01.24),所以离生产环境上使用还是有点时间

ii2y6b7.png!web

VV7bAbE.png!web

'y' 修饰符

除了 u 修饰符,ES6 还为正则表达式添加了 y 修饰符,叫做“粘连”(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

上面代码有两个正则表达式,一个使用 g 修饰符,另一个使用 y 修饰符。这两个正则表达式各执行了两次,第一次执行的时候,两者行为相同,剩余字符串都是 _aa_a 。由于 g 修饰没有位置要求,所以第二次执行会返回结果,而 y 修饰符要求匹配必须从头部开始,所以返回 null

如果改一下正则表达式,保证每次都能头部匹配, y 修饰符就会返回结果了。

var s = 'aaa_aa_a';
var r = /a+_/y;

r.exec(s) // ["aaa_"]
r.exec(s) // ["aa_"]

上面代码每次匹配,都是从剩余字符串的头部开始。

使用 lastIndex 属性,可以更好地说明 y 修饰符。

const REGEX = /a/g;

// 指定从2号位置(y)开始匹配
REGEX.lastIndex = 2;

// 匹配成功
const match = REGEX.exec('xaya');

// 在3号位置匹配成功
match.index // 3

// 下一次匹配从4号位开始
REGEX.lastIndex // 4

// 4号位开始匹配失败
REGEX.exec('xaya') // null

上面代码中, lastIndex 属性指定每次搜索的开始位置, g 修饰符从这个位置开始向后搜索,直到发现匹配为止。

y 修饰符同样遵守 lastIndex 属性,但是要求必须在 lastIndex 指定的位置发现匹配。

const REGEX = /a/y;

// 指定从2号位置开始匹配
REGEX.lastIndex = 2;

// 不是粘连,匹配失败
REGEX.exec('xaya') // null

// 指定从3号位置开始匹配
REGEX.lastIndex = 3;

// 3号位置是粘连,匹配成功
const match = REGEX.exec('xaya');
match.index // 3
REGEX.lastIndex // 4

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

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

上面代码由于不能保证头部匹配,所以返回 nully 修饰符的设计本意,就是让头部匹配的标志 ^ 在全局匹配中都有效。

下面是字符串对象的 replace 方法的例子。

const REGEX = /a/gy;
'aaxa'.replace(REGEX, '-') // '--xa'

上面代码中,最后一个 a 因为不是出现在下一次匹配的头部,所以不会被替换。

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

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

y 修饰符的一个应用,是从字符串提取 token(词元), y 修饰符确保了匹配之间不会有漏掉的字符。

const TOKEN_Y = /\s*(\+|[0-9]+)\s*/y;
const TOKEN_G  = /\s*(\+|[0-9]+)\s*/g;

tokenize(TOKEN_Y, '3 + 4')
// [ '3', '+', '4' ]
tokenize(TOKEN_G, '3 + 4')
// [ '3', '+', '4' ]

function tokenize(TOKEN_REGEX, str) {
  let result = [];
  let match;
  while (match = TOKEN_REGEX.exec(str)) {
    result.push(match[1]);
  }
  return result;
}

上面代码中,如果字符串里面没有非法字符, y 修饰符与 g 修饰符的提取结果是一样的。但是,一旦出现非法字符,两者的行为就不一样了。

tokenize(TOKEN_Y, '3x + 4')
// [ '3' ]
tokenize(TOKEN_G, '3x + 4')
// [ '3', '+', '4' ]

上面代码中, g 修饰符会忽略非法字符,而 y 修饰符不会,这样就很容易发现错误。

很遗憾,这个的浏览器兼容性也不咋地

aeqiYbI.png!web

但是,如果你的项目里有集成了babel,就可以使用以上的两个修饰符了,他们分别是

@babel-plugin-transform-es2015-sticky-regex

@babel-plugin-transform-es2015-unicode-regex

's' 修饰符

正则表达式中,点( . )是一个特殊字符,代表任意的单个字符,但是有两个例外。一个是四个字节的 UTF-16 字符,这个可以用 u 修饰符解决;另一个是行终止符(line terminator character)。

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

\n
\r

虽然这个浏览器兼容性也很差,但是我们有方法来模拟它的效果,只是语义化上有点不友好

/foo.bar/.test('foo\nbar')    // false
/foo[^]bar/.test('foo\nbar')    // true
/foo[\s\S]bar/.test('foo\nbar')        // true 我喜欢这种

贪婪模式和非贪婪模式(惰性模式)

贪婪模式:正则表达式在匹配时会 尽可能多地匹配 ,直到匹配失败,默认是贪婪模式。

非贪婪模式:让正则表达式仅仅匹配满足表达式的内容,即一旦匹配成功就不再继续往下,这就是非贪婪模式。在 量词后面加? 即可。

6BVvaaV.png!web

在某些情况下,我们需要编写非贪婪模式场景下的正则,比如捕获一组标签或者一个自闭合标签

quM3Mvf.png!web

这时捕获到了一组很奇怪的标签,如果我们的目标是只想捕获img标签的话,显然是不理想的,这时非贪婪模式就可以用在这里了

mEBfuu3.png!web

只需要在量词后加 ? 就会启用非贪婪模式,在特定情况下是特别有效的

先行/后行(否定)断言

有时候,我们会有些需求,具体是:匹配xxx前面/后面的xxx。很尴尬的是,在很久之前,只支持先行断言(lookahead)和先行否定断言(negative lookahead),不支持后行断言(lookbehind)和后行否定断言(negative lookbehind),在ES2018 之后才引入后行断言

名称 正则 含义 先行断言 /want(?=asset)/ 匹配在asset前面的内容 先行否定断言 /want(?!asset)/ want只有不在asset前面才匹配 后行断言 /(?<=asset)want/ 匹配在asset后面的内容 后行否定断言 /(?<!asset)want/ want只有不在asset后面才匹配

老实说,根据我的经验,后行断言的使用场景会更多,因为js 有很多的数据存储是名值对的形式保存,所以很多时候我们想要通过"name="来取到后面的值,这时候是后行断言的使用场景了

先行断言:只匹配 在/不在 百分号之前的数字

IVfqmyY.png!web

后行断言:

这里引例 @玉伯也叫射雕 的一篇 博文 的内容

EJVJjej.png!web

这里可以用后行断言

(?<=^|(第.+[章集])).*?(?=$|(第.+[章集]))

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

首先,后行断言的组匹配,与正常情况下结果是不一样的。

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

上面代码中,需要捕捉两个组匹配。没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是 1053 。而“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是 1053

其次,“后行断言”的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前。

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

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

另外,需要提醒的是,断言部分是 不计入返回结果 的。

具名组匹配

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

fMzEV3N.png!web

上面代码中,“具名组匹配”在圆括号内部,模式的头部添加“问号 + 尖括号 + 组名”( ?<year> ),然后就可以在 exec 方法返回结果的 groups 属性上引用该组名。同时,数字序号( matchObj[1] )依然有效。

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

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

具名组匹配 × 解构赋值

F7buymv.png!web

具名组引用

如果要在正则表达式内部引用某个“具名组匹配”,可以使用 \k<组名> 的写法。

zqmqUbA.png!web

4、常用正则

我这里比较推荐一个正则可视化的网站: https://regexper.com/ 在上面贴上你的正则,会以图形化的形式展示出你的正则匹配规则,之后我们就可以大致上判断我们的正则是否符合预期(貌似需要科学上网)

如果想通过字符串来生成正则对象的话,有两种方式,一种是字面量方式,另一种是构造函数

构造函数: new Regexp('content', 'descriptor')

字面量模式(请做好try-catch处理):

const input = '/123/g'
const regexp = eval(input)

校验密码强度

密码的强度必须是包含大小写字母和数字的组合,不能使用特殊字符,长度在8-10之间。

^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,10}$

非全数字 全字母的 6-15位密码 先行否定断言

/^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,15}$/

校验中文

字符串仅能是中文。

^[\u4e00-\u9fa5]{0,}$

校验身份证号码

15位

^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$

18位

^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$

校验日期

“yyyy-mm-dd“ 格式的日期校验,已考虑平闰年。

^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$

提取URL链接

下面的这个表达式可以筛选出一段文本中的URL。

^(f|ht){1}(tp|tps):\/\/([\\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?

提取图片标签的地址

假若你想提取网页中所有图片信息,可以利用下面的表达式。

/<img [^>]*?src="(.*?)"[^>]*?>/g;
\*[img][^\>]*[src] *= *[\"\']{0,1}([^\"\'\ >]*)

5、注意事项

使用非捕获型括号

如果不需要引用括号内文本,请使用非捕获型括号 (?:...) 。这样不但能够节省捕获的时间,而且会减少回溯使用的状态数量。

消除不必要括号

非必要括号有时会阻止引擎优化。比如,除非需要知道 .* 匹配的最后一个字符,否则请不要使用 (.)*

不要滥用字符组

避免单个字符的字符组。例如 [.][*] ,可以通过转义转换为 \.\*\

使用起始锚点

除非特殊情况,否则以 .* 开头的正则表达式都应该在最前面添加 ^ 。如果表达式在字符串的开头不能匹配,显然在其他位置也不能匹配。

从量词中提取必须元素

xx* 替代 x+ 能够保留匹配必须的 “x”。同样道理, -{5,7} 可以写作 -----{0,2} 。(可读性可能会差点)

提取多选结构开头的必须元素

th(?:is|at) 替代 (?:this|that) ,就能暴露除必须的 “th”。

忽略优先还是匹配优先?

通常,使用忽略优先量词(惰性)还是匹配优先量词(贪婪)取决于正则表达式的具体需求。举例来说, /^.*:/ 不同于 ^.*?: ,因为前者匹配到最后的冒号,而后者匹配到第一个冒号。总的来说,如果目标字符串很长,冒号会比较接近字符串的开头,就是用忽略优先。如果在接近字符串末尾位置,就是用匹配优先量词。

拆分正则表达式

有时候,应用多个小正则表达式的速度比单个正则要快的多。“大而全”的正则表达式必须在目标文本中的每个位置测试所有表达式,效率较为低下。典型例子可以参考前文, 去除字符串开头和结尾空白。

将最可能匹配的多选分支放在前头

多选分支的摆放顺序非常重要,上文有提及。总的来说,将常见匹配分支前置,有可能获得更迅速更常见的匹配。

避免指数级匹配

从正则表达式角度避免指数级匹配,应尽可能减少 + * 量词叠加,比如 ([^\\"]+)* 。从而减少可能匹配情形,加快匹配速度。

6、小结

正则表达式想要用好,需要一定的经验,个人经验来看,需要把你想法中的需要写出来,然后通过搭积木的形式,把一个个小的匹配写出来,然后再组合出你想要的功能,这是比较好的一种实现方法。

如果说遇到了晦涩难懂的正则,也可以贴到上面提到的正则可视化网站里,看下它的匹配机制。

对于前端来说,正则的使用场景主要是用户输入的校检,富文本内容的过滤,或者是对一些url或者src的过滤,还有一些标签的替换之类的,掌握好了还是大有裨益的,起码以前雄霸前端的 jQ 的选择器 sizzle 就是用了大量正则。

最后,如果大家觉得我有哪里写错了,写得不好,有其它什么建议(夸奖),非常欢迎大家指出和斧正。也非常欢迎大家跟我一起讨论和分享!

写在最后

感谢以下参考文档的作者的分享

精通正则表达式(第三版)

前端正则二三事 @代码君的自白

ES6 入门 -- 正则的拓展 @阮一峰


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK