正则表达式学习之路

313 阅读20分钟

前戏:最近在工作中经常碰到正则的问题,同事也经常会碰到正则的问题。在遇到这类问题的时候经常性的会通过百度、google来解决问题,然而有时候靠搜索引擎得到的答案并不是我想要的最佳答案。而自己又对这方面的技能知识点太过薄弱。基本上能应付自己的业务就敷衍了事了。

因此下定决心大干一场,彻底解决掉正则这块难题。

废话少说,直接进入正题。

在学习正则之前先了解一个概念,这个概念很重要,正则所有的知识点都是围绕这个概念来展开的。

正则是匹配模式,要么匹配字符,要么匹配位置。

一、正则表达式匹配攻略

再次强调:正则是匹配模式,要么匹配字符,要么匹配位置。

1.1两种模糊匹配

为什么是模糊匹配,因为如果正则是精准匹配,那么将没有任何意义

/hello/.test('hello');// 只能匹配 hello 字符串,还有什么意义

模糊匹配分为两个方向:横向模糊和纵向模糊

1.1.1横向模糊匹配

就是一个正则可匹配的字符串长度不是固定的,可以是多种情况的。实现方式是使用量词。

如:{m,n} 表示连续出现最少 m 次 最多 n 次

const regex = /ab{2,5}c/g;
const string = 'abc abbc abbbc abbbbc abbbbbc abbbbbbc';
string.match(regex);// ... 这里打印什么

案例中的 g 是一个正则的修饰符。表示全局匹配,即在目标字符串中按顺序找到满足匹配模式的所有字符串,强调的是 ‘所有’,而不是第一个。g 是单词 global 的缩写。

1.1.2纵向模糊匹配

指一个正则匹配的字符串,具体到某一位字符时,它可以不是某个确定的字符,可以有多种可能。

[123] 表示该字符可以是 1 2 3 中的任意一个

const regex = /a[123]b/g;
const str = 'a0b a1b a2b a3b a12b a23b';
str.match(regex)//

1.2字符组

虽然叫做字符组,但是只匹配其中的一个。

比如 [123] 只匹配 1 2 3 其中之一

1.2.1范围表示法

如果字符组里面字符特别多可以使用范围表示法。

[12345abcdeABCDE] 可以写成 [1-5a-eA-E] 使用连接符来省略和简写

连接符 - 有特殊的作用。

那么思考一下要匹配 'a' '-' 'z' 怎么处理?

[a-z] 肯定是不对的,这个表达式表示小写字符中的任意一个。

|

|

|

|

|

// [-az][az-][a-z] (可以放开头、结尾或转译,不让引擎认为是范围表示法就可以)
1.2.2排除字符组

纵向模糊匹配,还有一种情形就是,某位字符串可以是除了 a b c 之外的任意字符。这时就是排除字符组的概念。[^abc],表示是一个除了 a b c 之外的任意一个字符。字符组的第一位放 ^(脱字符),表示求反的概念。

1.2.3常见的简写形式

在了解过字符组的概念之后,一些常见的符号也就理解了。他们都是正则中自带的简写形式。

字符组概念(表示含义)
\d数字
\D非数字
\w数字,字母,下划线 [0-9a-zA-Z]
\W\w 取反
\s表示 [ \t\v\n\r\f]。表示空白符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页 符。记忆方式:s 是 space 的首字母,空白符的单词是 white space。
\S表示 [^ \t\v\n\r\f]。 \s 取反
\n换行
.表示 [^\n\r\u2028\u2029]。通配符,表示几乎任意字符。换行符、回车符、行分隔符和段分隔符 除外。记忆方式:想想省略号 … 中的每个点,都可以理解成占位符,表示任何类似的东西。

思考一下,如果要匹配任意字符怎么写?

|

|

|

|

|

[\d\D] [\w\W] [\s\S] [^]

1.3量词

量词就是数量的意思,也称重复。上面讲了 {m,n} 的含义,这里要记一些简写形式。

1.3.1量词的简写形式
量词表示含义
{m,}表示至少出现 m 次
{m}等价{m,m} ,表示出现 m 次
?等价 {0,1} 表示出现或不出现记忆方式:问号的意思表示,有吗?
+等价{1,} 表示至少出现一次记忆方式:加号表示追加的意思,得先有一个,然后才能追加。
*等价 {0,} 表示出现任意次,也可能不出现记忆方式:天上的星星,可能一颗都没有,可能几颗,可能多的数不过来。
// 看一个正则,解读一下
const regex = /a{1,2}b{3,}c{4}d?e+f*/;

1.3.2贪婪匹配与惰性匹配

const regex = /\d{2,5}/g;
const str = '123 1234 12345 123456 1234567';
str.match(regex);// 6个

正则表示数字连续出现 2 到 5 次。

但是其是贪婪的,他会尽可能多的匹配。能匹配到多少就匹配多少。

而惰性匹配,就是尽可能少的匹配:

const regex = /\d{2,5}?/g;
const str = '123 1234 12345 123456 1234567';
str.match(regex);// 11个

这里的正则表示,2 到 5 个都可以匹配。但是当 2 个就够的时候,就不再往下尝试了。

通过在量词后面加问好就能实现惰性匹配,所有惰性匹配情形如下:

贪婪量词惰性量词
{m,n}{m,n}?
{m,}{m,}?
???
++?
**?

对惰性匹配的记忆方式是:量词后面加个问号,问一问你知足了吗,很贪婪吗?

1.4 多选分支

一个模式可以实现横向和纵向模糊匹配。而多选分支可以支持多个子模式任选其一。

具体形式: (p1|p2|p3),其中 p1、p2、p3 是子模式,用 | 管道符分割,表示其中任何之一。

例如匹配字符串 'ab' 和 'cd' 可以使用 /ab|cd/

const regex = /ab|cd/g;
const str = 'ab sdfs, cd sagfasf';
str.match(regex);// ..

const regex2 = /good|goodbay/g;
const str2 = 'goodbay';
str2.match(regex2);// ..


const regex2 = /goodbay|good/g;
const str2 = 'goodbay';
str2.match(regex2);// ..

// 这说明了什么?

上面的实例,

说明分支结构也是惰性的,当前面的匹配上了,后面的就不再尝试了

1.5案例分析

以上讲了匹配字符,无非就是字符组,量词和分支结构的组合而已。

1.5.1匹配 16 进制颜色值
// 匹配 颜色值 
// 分析 16 进制字符,可以使用字符组 [0-9a-fA-F]
// 字符可以出现 3 次或 6 次,需要使用量词和分支结构(这里一定要注意先后顺序)
|
|
|
|
const regex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})/g;
const str = '#ddd #1fsd34 #FFF #fc0DED';
str.match(regex);
1.5.2匹配时间
// 要求匹配 24 小时制  23:59    01:07
// 分析: 四位数字,第一位为0-2
// 当第一位为 2 第二位为 0 1 2 3 其他情况 第二位为 [0-9]
// 第三位数字 [0-5] 第四位 [0-9]
//
//
//
//
//
//
//
//
//
const regex = /^([01][0-9]|[2][0-3]):[0-5][0-9]$/;
regex.test('23:59');
regex.test('01:07');


// 可以匹配 1:7 
const regex = /^(0?[0-9]|1[0-9]|[2][0-3]):(0?[0-9]|[1-5][0-9])$/;

以上使用了 ^ 和 $ ,分别表示字符串的开头和结尾。客观不要着急,下一大章节会学习到。

1.5.3匹配日期

比如 yyyy-mm-dd 格式匹配 :2022-08-02

// 分析:年 四位数字 可用 [0-9]{4}
// 月:(0[1-9]|1[0-2])
// 日:(0[1-9]|[12][0-9]|3[01])
//
//
//
//
//
//
//
//
//
const regex = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/;
regex.test('2022-08-02');
1.5.4window 操作系统文件路径
// D:\study\react-admin-app\package.json
// D:\study\react-admin-app
// D:\study
// D:\

// 分析: 磁盘:D:\     使用 [a-zA-Z]:\  (磁盘名称不区分大小写,这里注意 \ 需要转义)
// 文件夹名: 不能包括一些特殊字符 可以使用排除字符组 [^\:*<>|"?\r\n/]
// 		而且名字不能为空,至少有一个字符,也就是要使用量词 + 。
//    因此匹配文件夹\ ,可用 [^\:*<>|"?\r\n/]+\
//    而且 文件夹可以出现任意次,([^\:*<>|"?\r\n/]+\)*
//    最后一部分可以是 文件夹没有\ 因此需要添加 ([^\:*<>|"?\r\n/]+)?
//
//
//
//
//
//
//
//
//
//
const regex = /^[a-zA-Z]:\([^\:*<>|"?\r\n/]+\)*([^\:*<>|"?\r\n/]+)?$/;
regex.test('D:\study\react-admin-app\package.json')
regex.test('D:\study\react-admin-app')
regex.test('D:\study\')
regex.test('D:\')
1.5.5 匹配 id
// <div id="myId" class="my-class"></div>
// 提取出 id="container"

// 最开始的想法
const regex = /id=".*"/g; // 思考下有什么缺点
//
//
//
//
//
//
//
//
//
//
// . 是通配符,本身就匹配双引号,而量词 * 又是贪婪的,直到遇到最后一个"才会停下来。
//    会把class 一块匹配进来。怎么解决?
//
//
//
//
//
// 使用惰性匹配
const regex2 = /id=".*?"/
const str = '<div id="myId" class="my-class"></div>';
str.match(regex2)[0];

//
//
//
//
//
//
//
// 这里虽然可以完成需求,但是效率太低了,因为匹配原理会涉及到 “回溯” 这个概念。
// (后面会学习到的,不要心急,骨头一点一点啃,才更香)

const regex3 = /id="[^"]*"/;
str.match(regex3)[0];

在学习到这里的时候,我们已经学会了字符组和量词,已经可以解决大部分常见的情形。到这里的学习,JavaScript 正则已经算是入门了。

\

二、 正则表达式位置匹配攻略

再次强调一下,正则表达式是匹配模式,要么匹配字符,要么匹配位置。

这一节的学习,主要就是讲正则匹配位置的知识点。

2.1 什么是位置?

位置(锚)是相邻字符之间的位置。就像下面箭头所指的地方。

2.2 如何匹配位置?

在 ES5 中,共有 6 个锚:

^、$、\b、\B、(?=p)、(?!p)

2.2.1 ^ 和 $

^(脱字符)匹配开头,在多行匹配中匹配行开头。

$(美元符号)匹配结尾,在多行匹配中匹配行结尾。

// 在字符串开头和结尾加上 # 
const str = 'hello'.replace(/^|$/g, '#');
// 


// 多行匹配模式(修饰符m)时,二者是行的概念,需要注意
const str = 'my\nname\nning'.replace(/^|$/gm, '#');
// 
2.2.2 \b 和 \B

\b 是单词边界,具体就是 \w 和 \W 之间的位置,也包括 \w 与 ^ 之间的位置,和 \w 与 $ 之间的位置。

看个案例

// 说说 [JS] hello_01.png 中的 \b

const str = '[JS] hello_01.png'.replace(/\b/g, '#');
// \w 是 字符组 [0-9a-zA-Z] 的简写形式
// 结果是什么呢,猜一下
//
//
//
//
//
// 结尾也会匹配,前面字符 4 是 \w ,即 \w 与 $ 之间的位置

上面的案例应该能理解 \b 的概念了,那么 \B 也很好理解,就是 \b 取反的意思。

// 说说 [JS] hello_01.png 中的 \B

const str = '[JS] hello_01.png'.replace(/\B/g, '#');
// 这里结果又是什么呢
//
2.2.3 (?=p) 和 (?!p)

(?=p) ,其中 p 是一个子模式,就是 p 前面的位置(或者说该位置后面的字符要匹配 p)

const str = 'hello'.replace(/(?=l)/g, '#');
// 结果是什么
// 

(?!p),就是 (?=p) 反面的意思,(就是表示不是 p 前面的位置)

const str = 'hello'.replace(/(?!l)/g, '#');
// 结果又是什么呢
//

以上两个的学名分别是 positive lookahead 和 negative lookahead

正向先行断言 和 负向先行断言

扩展:(?<=p) 和 (?<!p)

(?<=p) 就是匹配符合 p 子模式后面的那个位置((?=p) 表示的是前面,这里表示后面)

(?<!p) 和上面相反,表示不匹配 p 子模式后面的那个位置。

'love_study_1.mp4'.replace(/(?<=love)/g, '#');
// ?

这里可能理解的时候会有点混乱,记住一点就很好理解,就是 (?=p),表示的就是 p 前面的那个位置。

2.3位置的特性

对于位置的理解,我们可以理解成空字符 ''.

// 比如 hello 等价于
"hello" == "" + "h" + "" + "e" + "" + "l" + "" + "l" + "" + "o" + "";

// 也等价于:
"hello" == "" + "" + "hello"

// 因此,把 /^hello$/ 写成 /^^hello$$$/,是没有任何问题的:
const res = /^^hello$$$/.test('hello');
console.log(res)

// 也可以写成 \w 表示数字字母下划线 \b 表示单词边界
const res2 = /(?=he)^^he(?=\w)llo$\b\b$/.test('hello');
console.log(res2);

以上实例就是说明,字符之间的位置,可以写成多个。

在理解位置的时候,把位置理解成空字符,是对位置很有效的一个方式。

2.4案例

2.4.1 不匹配任何东西的正则
const reg = /.^/;

// 这个正则要求只有一个字符,但字符后面是开头,而这样的字符是不存在的
2.4.2 数字千位分隔符表示法
// 把 12345678 变为 12,345,678 ,把相应的位置替换为 ,
const str = '12345678';

// 思路:
// 1.先把最后一个逗号加上 
let res = str.replace(/(?=\d{3}$)/, ',');

// 2.把所有的,都加上(要求后面三个数字为一组,就是 \d{3} 至少出现一次)
let res = str.replace(/(?=(\d{3})+$)/g, ',');

// 3.在 str 为 1234356789 或 123456 这样的数据的时候会发现在最前面也有一个 ,
'123456'.replace(/(?=(\d{3})+$)/g, ',');// ',123,456'
// 该正则仅仅表示从结尾向前数,一旦是 3 的倍数,就把其前面的位置替换成逗号。
// 才会出现这个问题.

// 处理方法就是要求正则匹配的这个位置不能是开头 开头使用的是 ^ ,不是开头当然是 (?!^)
let res = str.replace(/(?!^)(?=(\d{3})+$)/g, ',');



// 如果要把 12345678 123456789 替换成 12,345,678 123,456,789 怎么写正则
// 就是把 ^ 和 $ 改成 \b
const regex = /(?!\b)(?=(\d{3})+\b)/g;

// 优化一下 (?!\b) 的意思就是 \B  
const regex1 = /\B(?=(\d{3})+\b)/g; 
2.4.3验证密码问题

要求:密码长度6-12位,由数字小写字符和大写字母组成,但必须至少包括两种字符

该题如果写成多个正则进行判断还是比较容易的。但是写成一个还有有一丢丢难度。开搞--->

// 只考虑 密码长度6-12位,由数字小写字符和大写字母组成
const regex = /^[0-9a-zA-Z]{6,12}$/;

// 判断是否包含有某一种字符,必须包含数字
const regex = /(?=.*[0-9])^[0-9a-zA-Z]{6,12}$/;

// 同时包含两种字符
// 包含数字和小写字母
const regex = /(?=.*[0-9])(?=.*[a-z])^[0-9a-zA-Z]{6,12}$/;

// 所有的情况  (数字和小写字母)(数字和大写字母)(小写字母和大写字母)
const regex = 
      /((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[AZ]))^[0-9A-Za-z]{6,12}$/;

理解:以上只需要理解 (?=.*[0-9])^ 就可以理解所有的内容了

分开来看就是 (?=.&[0-9]) 和 ^

表示开头前面还有一个位置(当然也是开头,上面有讲)

(?=.[0-9]) 表示该位置后面的字符匹配 .[0-9] ,即有任意个任意字符,后面再跟个数字

大白话就是:接下来的字符,必须包含一个数字

// 还有另一种解法
// 至少包含两种字符  意思就是说,
// 不能全部都是数字,也不能全部都是小写字母,也不能全部都是大写字母
const regex = /(?!^[0-9]{6-12})^[0-9a-zA-Z]{6-12}$/;

// 最终
const regex = 
      /(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/;

三、正则表达式括号的作用

好像任何语言都有括号。正则也是一门语言,而括号使这门语言更为强大。

括号的作用也很简单,就是提供了分组,便于我们引用它。

引用某个分组,会有两种情形:在 JavaScript 中引用,在正则表达式里引用。

3.1分组和分支结构

3.1.1分组

之前学到 连续出现 a 用正则表示 /a+/ ,而连续出现 ab 时,使用 /(ab)+/

括号提供分组功能,使量词 + 作用于 ab 这个整体

const regex = /(ab)+/g;
const string = "ababa abbb ababab";
console.log( string.match(regex) );
// ?
3.1.2分支结构

在多选分支结构 (p1|p2) 中,此处括号的作用也是不言而喻的,提供了分支表达式的所有可能。

// 比如要匹配 i love js    和 i love react vue
const regex = /^i love (js|react vue)$/;
console.log(regex.test('i love js'));
console.log(regex.test('i love react'));
// ?

// 去除括号就不对了

这里扩展一下非捕获型括号 (非捕获型分组)(后面会用到):(?:p)

在获取到括号中的数据,可以对数据进行引用,所以称为捕获型分组和捕获型分支

如果想要括号最原始的功能,但不会引用它,就是既不会出现在 API 引用里,也不会出现在正则引用里,可以使用非捕获型括号 (?:p)

看下 3.2.2 中的案例就可以理解了。

3.2 分组引用

这是括号一个重要的作用,有了它,我们就可以进行数据提取,以及别的替换操作

// 以日期为例
const regex = /\d{4}-\d{2}-\d{2}/;

// 加上括号 就实现了分组,可以通过可视化工具进行查看 https://jex.im/regulex
const regex = /(\d{4})-(\d{2})-(\d{2})/;
// 下面两张图片也可以看出差别,多了个 group 分组

// 正则中,为每一个分组都开辟了一个空间,用来存储每一个分组匹配到的数据。
// 分组可以捕获到数据,那么就可以使用它们

\

3.2.1 提取数据
// 比如提取年月日
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const str = '2022-08-07';
str.match(regex);
// ['2022-08-07', '2022', '08', '07', index: 0, input: '2022-08-07', groups: undefined]

// 也可以使用正则实例对象的 exec 方法
regex.exec(string)
// ['2022-08-07', '2022', '08', '07', index: 0, input: '2022-08-07', groups: undefined]

// 也可以使用构造函数的全局属性 $1 - $9 来获取,(只到 $9)
regex.test(str); // 正则操作即可,例如
// regex.exec(str);
// str.match(regex);
RegExp.$1

match 返回一个数组,第一个元素是整体匹配的结果,然后是各个分组(括号里的)匹配的内容,然后就是匹配下标,最后是输出文本。另外正则表达式是否有修饰符 g,match 返回的数据格式是不一样的。

3.2.2 替换
// 把 yyyy-mm-dd 换成 mm/dd/yyyy
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const str = '2022-08-07';
str.replace(regex, '$2/$3/$1')
// ?

// 看下非捕获性分组会是什么样
const regex = /(\d{4})-(\d{2})-(?:\d{2})/;
const str = '2022-08-07';
str.replace(regex, '$2/$3/$1')



// 等价于
str.replace(regex, function (match, year, month, day) {
  return month + '/' + day + '/' + year;
})

在 replace 中的第二个参数里 11、2、$3 指代相应的分组。

3.3 反向引用

除了使用相应 API 来引用分组,也可以在正则本身里引用分组。但只能引用之前出现的分组,即反向引用。

// 写一个正则支持三种形式: 2022-08-07  2022/08/07  2022.08.07

// 最先想到的正则  / . 需要转义
const regex = /\d{4}(-|/|.)\d{2}(-|/|.)\d{2}/;

// 以上符合要求,但是也匹配了 2022-08/07 这种异常数据





// 这个时候就需要使用反向引用
const regex = /\d{4}(-|/|.)\d{2}\1\d{2}/;
// 可以在 https://jex.im/regulex 查看可视化形式

// \1 ,表示引用之前的分组 (-|/|.)。
// 不管它匹配到什么,\1都匹配那个同样的具体字符。
// 以后碰到 \2 \3 的意思也很明了,分别指 第二个和第三个分组

\

\

3.3.1 如果碰到括号嵌套怎么办?

这里以左括号(开括号为准)

const regex = /^((\d)(\d(\d)))\1\2\3\4$/;
const str = "1231231233";
console.log( regex.test(str) ); // true
console.log( RegExp.$1 ); // 123
console.log( RegExp.$2 ); // 1
console.log( RegExp.$3 ); // 23
console.log( RegExp.$4 ); // 3

其可视化形式:

3.3.2 \10 表示什么?

表示 第 10 个分组。不是\1 和0,这种情况比较罕见

const regex = /(1)(2)(3)(4)(5)(6)(7)(8)(9)(#) \10+/;
const str = "123456789# ######"
console.log(regex.test(str));

如果真的要匹配 \1 和 0 的话,请使用 (?:\1)0 或者 \1(?:0)

\

3.3.3引用不存在的分组会怎样?

反向引用时引用前面的分组。但是在引用不存在的分组时,此时正则不会报错,只是匹配反向引用的字符本身。\2 就匹配 '\2'。\2 表示对 2 进行了转义。

var regex = /\1\2\3\4\5\6\7\8\9/;
console.log(regex.test("\1\2\3\4\5\6\7\8\9"));
// ?

'\1\2\3\4\5\6\7\8\9'.split(''); 
// ['\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '8', '9']
3.3.4分组后面有量词会怎样

分组后面有量词的话,分组最终捕获到的数据是最后一次的匹配。

var regex = /(\d)+/;
var str = "12345";
console.log(str.match(regex));
// ?



// 可以看到分组 (\d) 捕获的是 5

// 同理对于反向引用也是这样
const regex = /(\d)+ \1/;
console.log( regex.test("12345 1") );
// ?
console.log( regex.test("12345 5") );
// ?

3.5案例

3.5.1 模拟 trim 方法(去除头尾空格)
// 方法1:匹配开头和结尾字符,替换成空格
function trim(str) {
  return str.replace(/^\s+|\s+$/g, '');
}


// 方法2:匹配整个字符串,使用引用提取相应的数据
function trim2(str) {
  return str.replace(/^\s*(.*?)\s*$/g, '$1');
}

方法2 中使用了 *? 惰性匹配,不然也会匹配最后一个空格之前的所有空格。

3.5.2 将每个单词的首字母转换为大写
// 思路就是找到每个单词的首字母,进行替换
function bigFirst(str) {
  return str.toLowerCase().replace(/(?:^|\s)\w/g, function(c){
    return c.toUpperCase();  
  })
}

bigFirst('i love js')
3.5.3 驼峰化

-my-name -> MyName

function aaa(str) {
  return str.replace(/[-_\s]+(.)?/g, function(match, c){
    return c ? c.toUpperCase() : '';
  })
}

aaa('-my-name')


// 尝试以下反转 中划线化
function bbb(str) {
  return str.replace(/([A-Z])/g, '-$1').replace(/-_\s+/g, '-').toLowerCase();
}
// 就是 驼峰化 的逆过程

分组 (.) 表示首字母。单词的界定是前面字符可以是多个连字符下划线以及空白符。正则后面的 ? 的目的,是为了应对 str 尾部的字符可能不是单词字符,比如 :'-my-name '

3.5.4 HTML 转义和反转义
// 将HTML特殊字符转换成等值的实体
function escapeHTML (str) {
  var escapeChars = {
    '<' : 'lt',
    '>' : 'gt',
    '"' : 'quot',
    '&' : 'amp',
    ''' : '#39'
  };
  return str.replace(new RegExp('[' + Object.keys(escapeChars).join('') +']', 'g'),
    function (match) {
      return '&' + escapeChars[match] + ';';
    }
  );
}
console.log( escapeHTML('<div>my name</div>') );
// => "&lt;div&gt;my name&lt;/div&gt";

// 这里使用了用构造函数生成的正则,然后替换相应的格式就行了,了解一下



// 反转
// 实体字符转换为等值的HTML。
function unescapeHTML (str) {
  var htmlEntities = {
    nbsp: ' ',
    lt: '<',
    gt: '>',
    quot: '"',
    amp: '&',
    apos: '''
  };
  return str.replace(/&([^;]+);/g, function (match, key) {
    if (key in htmlEntities) {
      return htmlEntities[key];
    }
    return match;
  });
}
console.log( unescapeHTML('&lt;div&gt;my name&lt;/div&gt;') );
// => "<div>my name</div>"

// 通过 key 获取相应的分组引用,然后作为对象的键
3.5.5 匹配成对标签
// 分析
// 匹配开标签,使用正则 <[^>]+>
// 匹配闭合标签 </[^>]+>

const regex = /<([^>]+)>[\d\D]*</\1>/;
regex.test('<div>123123</div>');// ?
regex.test('<a>123123</a>');// ?
regex.test('<a>123123</div>');// ?

这里开标签中使用括号的目的就是为了后面使用反向引用,

[\d\D] 是什么意思? 思考下

四、正则表达式回溯法原理

看到回溯两个字是不是感觉很高大上,其实这个词的意思很容易理解。下面的学习就会解释这个词的含义。

正则的学习,是需要懂那么点儿的匹配原理的。在研究匹配原理的时候,我们会经常碰到这个单词 回溯

4.1 没有回溯的匹配

首先我们在 jex.im/regulex/#!f… 网站看下 /ab{1,3}c/ 的可视化形式

当字符串是 'abbbc' 时,就没有所谓的 回溯。匹配过程是

其中子表达式 b{1,3} 表示 b 字符联系出现 1 到 3 次。

4.2 有回溯的匹配

如果目标字符串是 abbc ,中间就有回溯。

第五步有红颜色,表示匹配不成功。这时 b{1,3} 已经匹配到了 2 个字符 b,尝试匹配第三个时,发现接下来的字符是 c。那么就认为 b{1,3} 已经匹配完毕。然后状态回到之前的状态,(6和4一样),最后再用子表达式c去匹配c。当然,最后表达式匹配成功了。第 6 步就是 回溯。

再看一个例子。正则是 /ab{1,3}bbc/ ,目标字符串 abbbc ,匹配过程是

第 7 步和第 10 步是回溯。第 7 步与第 4 步一样,此时 b{1,3} 匹配了两个 b,10 和 3 一样匹配了一个 b,这也是 b{1,3} 的最终匹配结果。

再看一个回溯 。正则 /".*"/ ,匹配 字符串 "abc"de 。匹配过程:

图中省略了匹配双引号失败的过程。可以看出 .* 是非常影响效率的。

为了减少一些不必要的回溯,正则可以修改为 /"[^"]"/

4.3常见的回溯形式

正则表达式匹配字符串的这种方式,有个学名叫:回溯法

回溯法也称试探法,它的基本思想就是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有 状态,当一条路走到 尽头 的时候(不能再前进),再后退一步或若干步,从另一种可能 状态 触发,继续搜索,直到所有的 路径 (状态)都试探过。这种不断 前进 、不断 回溯 寻找解的方法,就称为 回溯法

本质上就是深度优先搜索算法。其中退到之前的某一步这一过程,称为 ‘回溯’。以上几个示例可以发现,在路走不通的时候,就会发生‘回溯’。即,尝试匹配失败时,接下来的一步通常就是回溯。

概念已经理解了,可是在 JavaScript 中正则表达式会产生回溯的地方都有哪些呢?

4.3.1 贪婪量词

上面的例子都是贪恋量词相关的。比如 b{1,3} ,因为他是贪婪的,会尽可能多的去尝试匹配。首先尝试 bbb ,看整个正则是否可以匹配,不能匹配时,就匹配 bb ,还不行 就匹配 b ,最后还不行就匹配失败了。

这个时候可能有一个疑问:如果多个贪婪量词挨着,并且互相有冲突时,会怎么样?

答案就是:先下手为强。因为深度优先搜索。

看例子

const str = '12345';
const regex = /(\d{1,3})(\d{1,3})/;
console.log( str.match(regex) );
// => ['12345', '123', '45', index: 0, input: '12345', groups: undefined]

// 思考一下 如果 regex 改为这样呢
const regex = /(\d{1,3})(\d{1,3})(\d{1,3})/;
// ??
4.3.2 惰性量词

上面学习了贪婪量词。惰性量词也能更好的理解了,就是尽可能少匹配的意思。

惰性量词就是在贪婪量词后面加一个 ?

看个示例

const str = '12345';
const regex = /(\d{1,3}?)(\d{1,3})/;
console.log( str.match(regex) );
// => ['1234', '1', '234', index: 0, input: '12345', groups: undefined]

// 思考一下 如果 regex 改为这样呢
const regex = /^(\d{1,3}?)(\d{1,3})$/;
// ??

虽然贪恋量词不贪,但也会有回溯的现象。比如正则:

const regex = /^(\d{1,3}?)(\d{1,3})$/;

验证结果会发现 \d{1,3}? 最终匹配的数据是 12 ,是两个数字而不是一个,虽然不贪,但是为了整体匹配成功,也没有办法,只能多匹配一点。

4.3.3分支结构

上面讲了分支也是惰性的,比如 /ab|abcd/ ,去匹配字符串 'abcd' 是,得到的是 ab,因为分支会一个一个尝试,如果前面满足了,就不会再去匹配了。

分支结构,可能前面的子模式会形成局部匹配,如果接下来表达式整体不匹配时,仍会继续尝试剩下的分支。也可以看成一种回溯。

比如 :

const regex = /^(?:can|candy)$/;

5 虽然没有回到之前的状态,但仍然回到了分支结构,尝试下一种可能。所以可以认为他是一种回溯。

回溯法还是很容易掌握的。

总结就是,正因为有多种可能,所以要一个一个尝试。直到某一步时整体匹配成功了;要么多有的情况都尝试完,发现匹配不成功。

既然有回溯的过程,那么匹配效率肯定低一点。

为什么有时候正则匹配慢,大多数语言还都是用正则呢?

因为正则虽然有时候匹配会慢一点,但是它编译快啊。(有兴趣的小伙伴,可以了解一下 NFA 和 DFA 的概念)

五、正则表达式的拆分

正则和别的语言一样,横向掌握一门语言的程度就是:读和写

不仅要求自己能解决问题,还要能看懂别人的解决方案。

5.1结构和操作符

编程语言都有操作符。有操作符就会有一些问题,一大堆的操作符在一起,先后顺序是什么样的呢?就像 +-*/ 在一起的顺序怎么定义呢,就是所谓的优先级。

在正则中,操作符都体现在结构中,由特殊字符和普通字符所代表的一个个特殊整体。

先了解下正则中有哪些结构?(其实上面已经都学过了,哈哈)。

字符字面量、字符组、量词、锚、分组、选择分支、反向作用。

再来回顾以下,这些结构的定义

结构说明
字面量匹配一个具体字符,包括不用转义的和需要转义的。a 匹配 a、\n 匹配换行符、. 匹配小数点 等等
字符组匹配一个字符,可以是多种可能之一,比如 [1-9] ,表示匹配一个数字
量词表示一个字符连续出现 。比如 a{1,3} 。表示 a 字符连续出现 1 - 3 次
匹配一个位置,^ 匹配开头 $ 匹配结尾 \b 匹配单词边界 (?=\d) 表示数字前面的位置
分组用括号表示一个整体 。(ab)+,表示 ab 连续出现多次,也可以使用非捕获分组 (?:ab)+
分支多个子表达式多选一,比如 abcd ,表示匹配 ab 或者 cd 字符子串。反向引用,\2 表示引用第 2 个分组

正则中的操作符有:(操作符优先级从上至下,由高到低)

操作符操作符优先级
转义符\1
括号和方括号(...)、(?:...)、(?=...)、(?!...)、[...]2
量词限定符{m}、{m,}、{m,n}、?、*、+3
位置和序列^、$、\元字符、一般字符4
管道符|5

看一个正则案例:

/ab?(c|de*)+|fg/

分析一下:

由于括号存在 (c|de*) 是一个整体

在 (c|de*) 中,有量词 * ,因此 e 是一个整体

又因为分支结构 | 优先级最低,因此 c 是一个整体、而 de* 是另一个整体

因此正则分成了 a 、b?、(...)+、f、g。由于分支的原因 又分成 ab?(c|de*)+ 和 fg 两部分

具体看下可视化图就明白了

扩展:

记录下需要转义的元字符

^、$、.、*、+、?、|、\、/、(、)、[、]、{、}、=、!、:、- ,

在匹配 [abc] 和 {3,5} 时,

// 可以写成 /[abc]/,也可以写成 /[abc]/
const str = '[abc]';
const regex = /[abc]/g;
console.log(str.match(regex)[0]);
// '[abc]'

// 只需要在第一个方括号转义即可,因为后面的方括号构不成字符组,
// 正则不会引发歧义,自然不需要转义
// 同理 {3,5} 字符串匹配是一样的道理,

// 量词有特殊情况,里面有简写形式 {m,}  ,却没有 {,n} 。所以后面这种情况不构成量词的形式,
// 所以使用不使用转义都可以

const string = "{,3}";
const regex = /{,3}/g;
console.log(string.match(regex)[0]);
// => {,3}

在熟悉正则中的优先级之后,再看别人写的复杂的正则应该就有信心分析下去了。

提两点:

1.记住 | 竖杠优先级最低,最后运算

2.在关于元字符转义的问题,当不确定是否需要转义的时候,尽量都去转义,不会出错的。

六、正则表达式的构建

在学习完正则表达式基础之后,对正则的运用,最重要的应该就是:如何针对问题,构建一个合适的正则表达式?

6.1平衡法则

构建正则有一点非常重要,需要做到以下几点平衡:

  • 匹配预期的字符串
  • 不匹配非预期的字符串
  • 可读性和可维护性
  • 效率

6.2构建正则的前提

6.2.1是否能使用正则

虽然说到这里已经学会如何使用正则了,但是在我们遇到一个操作字符串的问题时,也要注意有些操作时正则做不到的,不要一味的想用正则去解决问题。

比如匹配字符串:1010010001...

虽然规律性很强,但是没有办法用正则进行匹配。

6.2.2是否有必要使用正则

要认识到正则的局限,不要去研究正则完不成的任务。有时候也并不是所有的字符串操作都要用正则去完成。能用字符串 API 解决的问题,就不要使用正则。

// 比如提取年月日 就没有必要使用正则
const str = '2022-08-14';
const regex = /^(\d{4})-(\d{2})-(\d{2})/;
console.log(str.match(regex));
// => ['2022-08-14', '2022', '08', '14', index: 0, input: '2022-08-14', groups: undefined]

// 这里使用字符串更为简单
const res = str.split("-");
// ['2022', '08', '14']
6.2.3是否有必要构建一个复杂的正则

就像 2.4.3 中匹配密码的案例,其实没有必要写一个那么长的正则来进行匹配。

// 验证密码正则
const regex = 
      /((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[AZ]))^[0-9A-Za-z]{6,12}$/;

// 完全可以拆分成多个小正则进行使用
const regex1 = /^[0-9A-Za-z]{6,12}$/;
const regex2 = /^[0-9]{6,12}$/;
const regex3 = /^[A-Z]{6,12}$/;
const regex4 = /^[a-z]{6,12}$/;
function checkPassword (str) {
  if (!regex1.test(str)) return false;
  if (regex2.test(str)) return false;
  if (regex3.test(str)) return false;
  if (regex4.test(str)) return false;
  return true;
}

6.3准确性

就是能匹配预期的目标,并且不匹配非预期的目标。

预期的目标就是想要匹配的字符串。

6.4效率

在保证准确性后,才需要考虑是否需要优化。大多数情形是不需要优化的,除非运行的非常慢。什么情形正则表达式运行慢?需要考察正则表达式的运行过程(原理)。

正则表达式的运行分为如下阶段:

  1. 编译
  2. 设定起始位置
  3. 尝试匹配
  4. 匹配失败的话,从下一位开始继续第 3 步
  5. 最终结果:匹配成功或失败

举例分析,看每个阶段做了什么:

const regex = /\d+/g;
console.log(regex.lastIndex, regex.exec('123abc34def'));
console.log(regex.lastIndex, regex.exec('123abc34def'));
console.log(regex.lastIndex, regex.exec('123abc34def'));
console.log(regex.lastIndex, regex.exec('123abc34def'));
// 0  ['123', index: 0, input: '123abc34def', groups: undefined]
// 3  ['34', index: 6, input: '123abc34def', groups: undefined]
// 8  null
// 0  ['123', index: 0, input: '123abc34def', groups: undefined]


// 分析
const regex = /\d+/g;
// 当生成一个正则时,引擎会对其进行编译。报错与否出现在这个阶段。

regex.exec('123abc34def');

// 当尝试匹配时,需要确定从哪一位置开始匹配。一般情形都是字符串的开头。即第 0 位。
// 但当使用 test 和 exec 方法,且正则有 g 时,起始位置是从正则对象的 lastIndex 属性开始。
// 因此第一次 exec 是从第 0 位开始,而第二次是从 3 开始的。
// 设定好起始位置后,就开始尝试匹配了。
// 比如第一次 exec,从 0 开始,去尝试匹配,并且成功地匹配到 3 个数字。此时结束时的下标是 2,
// 因此下一次的起始位置是 3。
// 而第二次,起始下标是 3,但第 3 个字符是 "a",并不是数字。但此时并不会直接报匹配失败,而是移动到
// 下一位置,即从第 4 位开始继续尝试匹配,但该字符是 "b",也不是数字。再移动到下一位,是 "c" 仍不
// 行,再移动一位是数字 "3",此时匹配到了两位数字 "34"。此时,下一次匹配的位置是 "d" 的位置,即第8 位。
// 第三次,是从第 8 位开始匹配,直到试到最后一位,也没发现匹配的,因此匹配失败,返回 null。同时设
// 置 lastIndex 为 0,即,如要再尝试匹配的话,需从头开始。

// 可以看出,匹配会出现效率问题,主要出现在 3 4 阶段。因此优化手法也是针对这两个阶段的。

学习一下 exec() 方法 和 lastIndex 方法:

用于检索字符串中的正则表达式的匹配。该函数返回一个数组,存放匹配的结果,如果未找到匹配,则返回值为 null。

RegExp.prototype.lastIndex:返回一个数值,表示下一次开始搜索的位置。该属性可读写,但是只在进行连续搜索时有意义。

具体详情可以查看 阮大大 的教程javascript.ruanyifeng.com/stdlib/rege…

6.4.1使用具体字符来代替通配符,消除回溯

第三阶段最大的问题就是回溯。

举例:匹配双引号之间的字符. 匹配 123"abc"456 中的 "abc"

使用 /".*"/ ,会出现四次回溯。

因为回溯的存在,需要引擎保存多种可能中为尝试过的状态,以便后续回溯时使用.(会占用一定的内存)。

此时要使用具体化的字符组,来匹配,以便消除不必要的字符。

正则改为 /"[^"]*"/

6.4.2 使用非捕获型分组

括号的作用之一是可以捕获分组和分支里的数据,那么就需要内存来保存。

当不需要使用分组应用和反向引用时,就可以使用非捕获型分组。

例如 /^[-]?(\d.\d+|.\d+)$/

可以修改为 /^[-]?(?:\d.\d+|.\d+)$/

6.4.3 独立出确定字符

/a+/ 可以修改为 /aa*/

后者比前者多确定了字符 a 。这样在第四步中,加快判断是否匹配失败,进而加快移位的速度。

6.4.4提取分支公共部分

/^abc|^def/ 修改为 /^(?:abc|def)/

/this|that/ 修改为 /th(?:is|at)/

可以减少匹配过程中可消除的重复

6.4.5 减少分支的数量,缩小范围

/red|read/ 可以修改为 /rea?d/

此时分支和量词产生的回溯的成本是不一样的。但这样优化后,可能可读性没那么好

七、正则表达式编程

7.1正则表达式编程

正则表达式是匹配模式,不管如何使用正则表达式,万变不离其宗,都需要先匹配。

有匹配操作之后才有其他操作:验证、切分、提取、替换。

7.1.1验证

验证是表达式最直接的应用,比如表单验证。

这里先说一下匹配的概念:就是看目标字符串里是否有匹配的子串。因此 匹配 本质就是 查找。

有没有匹配,是否匹配上,称之为 验证。

// 判断字符串中是否有数字
const regex = /\d/;
const str = 'abc123';
// 使用 search
str.search(regex);// 返回的是匹配到的下标

// 使用 test
regex.test(str);// 返回 boolean 值

// 还可以使用 match exec
str.match(regex)// 返回第一次匹配到的字符和下标
regex.exec(str)// 同上
7.1.2切分

匹配上之后,就可以进行别的操作,比如 切分

切分:就是把目标字符串切成一段一段的,在 js 中使用的是 split。

const regex = /,/;
const str = "html,css,js";
str.split(regex);
// 使用,切分

// 切分年月日
const regex = /\D/;
'2017/06/26'.split(regex);
'2017.06.26'.split(regex);
'2017-06-26'.split(regex);
7.1.3 提取

在整体匹配上之后,有时需要提取部分匹配的数据。

这时通常使用分组引用(分组捕获)功能,还需要配合使用 API 。

// 提取年月日
const regex = /^(\d{4})\D(\d{2})\D(\d{2})$/;
const str = '2022-08-14';
// 使用 match
str.match(regex);

// 使用 exec
regex.exec(regex);

// 使用 test
regex.test(str);
RegExp.$1;
RegExp.$2;
RegExp.$3;

// 使用 search 
str.search(regex);
RegExp.$1;
RegExp.$2;
RegExp.$3;

// 使用 replace
const date = [];
str.replace(regex, function (match, year, month, day) {
  date.push(year, month, day);
});
7.1.4 替换

通常就是使用 replace 方法进行替换

const str = '2022-08-14';
const today = new Date(str.replace(/-/g, '/'));

7.2相关 API 注意点

用于正则的操作方法,基本上有6个 ,字符串实例 4 个,正则实例 2 个。

search()
split()
match()
replace()


// 正则的 RegExp
test()
exec()

这里具体的用法细节可以查看 阮大大 的教程。

至此,恭喜。

写代码就是一个慢慢变好的过程,希望未来的我们都能写出更好的代码,变得更加的优秀。

纸上得来终觉浅,绝知此事要躬行。

以上学习都是通过老姚的 正则表达式迷你书 来学习的,添加了一些自己的理解。

最后再说三句:

正则表达式是匹配模式,要么匹配字符,要么匹配位置

正则表达式是匹配模式,要么匹配字符,要么匹配位置

正则表达式是匹配模式,要么匹配字符,要么匹配位置