前戏:最近在工作中经常碰到正则的问题,同事也经常会碰到正则的问题。在遇到这类问题的时候经常性的会通过百度、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 中的第二个参数里 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>') );
// => "<div>my name</div>";
// 这里使用了用构造函数生成的正则,然后替换相应的格式就行了,了解一下
// 反转
// 实体字符转换为等值的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('<div>my name</div>') );
// => "<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)+ | |
| 分支 | 多个子表达式多选一,比如 ab | cd ,表示匹配 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效率
在保证准确性后,才需要考虑是否需要优化。大多数情形是不需要优化的,除非运行的非常慢。什么情形正则表达式运行慢?需要考察正则表达式的运行过程(原理)。
正则表达式的运行分为如下阶段:
- 编译
- 设定起始位置
- 尝试匹配
- 匹配失败的话,从下一位开始继续第 3 步
- 最终结果:匹配成功或失败
举例分析,看每个阶段做了什么:
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()
这里具体的用法细节可以查看 阮大大 的教程。
至此,恭喜。
写代码就是一个慢慢变好的过程,希望未来的我们都能写出更好的代码,变得更加的优秀。
纸上得来终觉浅,绝知此事要躬行。
以上学习都是通过老姚的 正则表达式迷你书 来学习的,添加了一些自己的理解。
最后再说三句:
正则表达式是匹配模式,要么匹配字符,要么匹配位置
正则表达式是匹配模式,要么匹配字符,要么匹配位置
正则表达式是匹配模式,要么匹配字符,要么匹配位置