前端必经之路:带你读懂正则表达式

一. 正则表达式简介      

         正则表达式的英文为 Regular Expression,它是一种专门用来处理字符串的规则。

二. 正则表达式的创建方式

        在 JavaScript 中,正则表达式有两种创建方式,分别是 字面量创建方式 和 构造函数创建方式

        字面量创建方式由两个斜杠将中间的用来描述规则的字符包裹起来,构造函数模式则是将描述规则的字符以字符串参数的形式传入 RegExp() 函数:

// 字面量创建方式
let reg1 = /\d+/  // \d 表示匹配 0~9的某一个数字, + 表示匹配一到多个

// 构造函数创建方式
let reg2 = new RegExp('\\d+') // 放在字符串中, \ 需要进行转义

        需要注意的是,用构造函数方式创建时,如果遇到特殊字符,比如 \d 中的 \,则需要用 转义符 \ 进行转义。

        转义符可以将普通字符识别为特殊字符,也可以将特殊字符识别为普通字符。例如,\\ 表示反斜杠,\' 表示单引号,\" 表示双引号,\n 表示换行... 在 HTML 文档中,像 < 或 > 之类的符号,因为已经用来表示 HTML 标签,所以如果我们想在 HTML 标签之间插入这类符号文本,就可以使用转义字符进行操作。

三. 正则表达式的匹配与捕获

         在 JavaScript 中,正则表达式对外暴露两个方法:test exec。test 方法可用于匹配某个字符串是否符合所设置的正则表达式规则,匹配的话返回 true,不匹配返回 false;而 exec 方法则更为强大,它可以用于捕获我们在字符串中所匹配到的部分。

let str = "Hello RegExp!";

// 验证是否包含数字
let reg = /\d+/;

reg.test(str); // false

str = "2021-06-30";

reg.exec(str); // ["2021", index: 0, input: "2021-06-30", groups: undefined]

        此外,在 JavaScript 中,实现正则的匹配与捕获,除了 RegExp对象原型上的 test 和 exec 方法,还有字符串原型上的 replace、match 等方法。

四. 正则表达式的组成部分

        正则表达式由 元字符 和 修饰符 这两部分组成。

4.1 元字符

        元字符有三种类型:普通元字符、量词元字符 和 特殊元字符

4.1.1 普通元字符

        普通元字符 指那些没有任何额外功能的元字符,你输入的是什么正则表达式,就表示要匹配什么样的字符串,例如:

let reg = /HelloWord/;

console.log(reg.test('HelloWord')); // true
console.log(reg.test('HelloWord!')); // true
console.log(reg.test('Hello Word')); // false
console.log(reg.test('Hello')); // false
console.log(reg.test('Word')); // false

        在这里输入了 /HelloWord/,就是要完完整整匹配到带有 HelloWord 的字符串。

4.1.2 量词元字符

        量词元字符 用来设置某些字符出现的次数,主要有以下几种:

量词
*出现 0 到 多次
+出现 1 到 多次
出现 0 次或 1 次
{ n } 出现 n 次,等同于 { n, n }
{ n, } 出现 n 到 多次
{ n, m } 出现 n 到 m 次

        使用方式如下:

let reg1 = /\d{3}/ // 匹配三个数字连在一起出现的情况
let reg2 = /\d+/ // 匹配至少有一个数字出现的情况
let reg3 = /\d{2,4}/ // 匹配二到四个数字连在一起出现的情况

let str1 = 'AAA12BB'
console.log(reg1.test(str1)) // false
console.log(reg2.test(str1)) // true
console.log(reg3.test(str1)) // true

let str2 = 'AAA12BB567CC'
console.log(reg1.test(str2)) // true
console.log(reg2.test(str2)) // true
console.log(reg3.test(str2)) // true

4.1.3 特殊元字符  

        特殊元字符 需要记忆的语法就稍微多一点,它们由单个特殊字符或者多个特殊字符组合在一起来表示特殊的含义,常用的特殊元字符如下表:

位置

^

匹配开头的位置

$

匹配结尾的位置

\b

匹配单词边界,即,\w 与 \W、^ 与 \w、\w 与 $ 之间的位置

\B

匹配非单词边界,即,\w 与 \w、\W 与 \W、^ 与 \W,\W 与 $ 之间的位置

(?=abc)

正向预查,匹配 "abc" 前面的位置,即此位置后面匹配 "abc"

(?!abc)

负向预查,匹配非 "abc" 前面的位置,即此位置后面不匹配 "abc"

注意:在正则表达式中单词的意思是指数字、字母和下划线,相当于 \w。

字符组

[abc]

匹配 "a"、"b"、"c" 其中任何一个字符

[a-d1-4]

匹配 "a"、"b"、"c"、"d"、"1"、"2"、"3"、"4" 其中任何一个字符

[^abc]

匹配除了 "a"、"b"、"c" 之外的任何一个字符

(注意不要把这里的 ^ 和匹配开头位置的 ^ 混淆了)

[^a-d1-4]

匹配除了 "a"、"b"、"c"、"d"、"1"、"2"、"3"、"4" 之外的任何一个字符

x|y

匹配 x 或 y 其中的一个字符

.

匹配除了换行符(\n)之外的任意字符

\

转义符,将有特殊含义的字符转为普通字符,也可将某些普通字符转为特殊字符

\d

匹配数字,等价于 [0-9]

\D

匹配非数字,等价于 [^0-9]

\w

匹配单词字符,等价于 [a-zA-Z0-9_]

\W

匹配非单词字符,等价于 [^a-zA-Z0-9_]

\s

匹配空白符

\S

匹配非空白符

\n

匹配换行符

        先别急着头晕,接下来我们慢慢来理解这些常用的特殊元字符的用法。


4.1.3.1 元字符  ^ 和 $

        ^ 用于匹配字符串开头位置的字符,$ 用于匹配字符串结尾位置的字符,我们用 \d 来对数字字符进行匹配:

        匹配字符串是否以数字开头:

let reg = /^\d/ // 匹配字符串是否以数字开头

console.log(reg.test('RegExp')) // false
console.log(reg.test('2021RegExp')) // true
console.log(reg.test('RegExp2021')) // false

        匹配字符串是否以数字结尾:

let reg = /\d$/ // 匹配字符串是否以数字开头

console.log(reg.test('RegExp')) // false
console.log(reg.test('2021RegExp')) // false
console.log(reg.test('RegExp2021')) // true

        如果 ^ 和 $ 符号两个都不加,则只要字符串中包含符合规则的字符即可:

let reg = /\d/ // 匹配字符串是否包含数字

console.log(reg.test('Reg2021Exp')) // true

        但如果 ^ 和 $ 两个符号都加上了,字符串必须完全匹配其中的规则:

let reg = /^\d$/ // 匹配单个数字

console.log(reg.test('Reg2021Exp')) // false
console.log(reg.test('2021RegExp')) // false
console.log(reg.test('RegExp2021')) // false

console.log(reg.test('2021RegExp2021')) // false
console.log(reg.test('2021')) // false
console.log(reg.test('2')) // true

        这里你是不是觉得,最后面三条应该都为 true,但倒数两条都为 false 呢?这是因为在这里的正则表达式没有加上量词修饰符,这里就默认只能匹配一位 /d 格式的字符,相当于 /^\d{1}$/

        如果是希望可以同时匹配多位数字的规则,可以给正则表达式加上 + 符号:

let reg = /^\d+$/ // 匹配单位或多位数字


console.log(reg.test('2021RegExp2021')) // false
console.log(reg.test('2021')) // true
console.log(reg.test('2')) // true
console.log(reg.test('1')) // true

        但这里表示的是必须为连续的数字,且开头和结尾都是数字。那如果想要匹配上图中的第一条呢?可以将正则修改如下:

let reg = /^\d+.*\d+$/ // 匹配以数字开头和以数字结尾的字符串


console.log(reg.test('2021RegExp2021')) // true
console.log(reg.test('2021')) // true
console.log(reg.test('2')) // false

        这里我们来将 ^\d+.*\d+$ 拆分一下,^\d+ 表示以数字开头,并且数字至少出现一位;.* 表示中间可以出现 0 个到多个任意非换行字符;\d+$ 表示以数字结尾,并且数字至少出现一位。

        所以上图的第一条和第二条就都匹配上了,可最后一条却不匹配了。因为我们开头和结尾都用 + 表示前后至少都有一位数字,加起来字符串最少也需要两位才能符合:

let reg = /^\d+.*\d+$/ // 匹配以数字开头和以数字结尾的字符串


console.log(reg.test('2021RegExp2021')) // true
console.log(reg.test('2021')) // true
console.log(reg.test('2')) // false
console.log(reg.test('20')) // true

        那假如我们即希望可以匹配开头和结尾都是数字的字符串、且同时允许字符串只是一位数字的情况,该怎么写呢?这个时候我们就可以用到 管道符 | 了。


4.1.3.2 元字符  x|y

        x|y 用来匹配 管道符 | 两边其中的一项,如果匹配成功则返回 true:

let reg = /^18|29$/ 

console.log(reg.test('18')) // true
console.log(reg.test('29')) // true

        这个正则看起来是不是很好理解,一般我们都会认为这是在匹配数字 18 或 29。然而实际上,这条正则却还可以表达很多种情况:

let reg = /^18|29$/ 

console.log(reg.test('18')) // true
console.log(reg.test('29')) // true

console.log(reg.test('129')) // true
console.log(reg.test('189')) // true
console.log(reg.test('1829')) // true
console.log(reg.test('829')) // true
console.log(reg.test('182')) // true

console.log(reg.test('12')) // false
console.log(reg.test('19')) // false
console.log(reg.test('82')) // false
console.log(reg.test('89')) // false

        这条正则既可以理解为,匹配以18开头、以18结尾的字符串;或以18开头、以29结尾的字符串;或以1开头、夹个8或2在中间,然后以9结尾的字符串;或以1开头,以8或29结尾的字符串......

        显然,单纯使用管道符,会造成很多表达式上的歧义,所以一般我们在使用管道符的时候,会伴随着小括号进行分组,因为小括号能够改变其中处理的优先级:

let reg = /^(18|29)$/ 

console.log(reg.test('18')) // true
console.log(reg.test('29')) // true

console.log(reg.test('129')) // false
console.log(reg.test('189')) // false
console.log(reg.test('1829')) // false
console.log(reg.test('829')) // false
console.log(reg.test('182')) // false

console.log(reg.test('12')) // false
console.log(reg.test('19')) // false
console.log(reg.test('82')) // false
console.log(reg.test('89')) // false

        加了小括号后,就只能匹配 18 或 29 其中一个字符串了。


4.1.3.3 元字符  \

        前面已经简单介绍了 转义符 \ 的用法,现在来具体实践一下。

        假如我们要匹配字符串中是否带有某个小数,我们可能会这么写:

let reg = /^1.5$/ 

console.log(reg.test('1.5')) // true

        这看起来似乎没问题,但是我们尝试以下其它字符串,返回的结果同样为 true:

let reg = /^1.5$/ 

console.log(reg.test('1.5')) // true
console.log(reg.test('1@5')) // true
console.log(reg.test('1&5')) // true

        这是因为,在正则表达式中,符号 . 会被识别为匹配除了换行符之外的所有字符,在这里并不是代表小数点的意思,如果我们希望能够匹配小数点,只需要用转义符将它转义,让它只能代表小数点即可:

let reg = /^1\.5$/ 

console.log(reg.test('1.5')) // true
console.log(reg.test('1@5')) // false
console.log(reg.test('1&5')) // false

        同样的,对于特殊字符 \d,如果我们只是想要匹配某个字符串是否为 \d 字符,我们也需要进行转义:

let str = '\\d'; // 在字符串中想要表示 \,必须用转义符 \ 进行转义

reg = /^\d$/; // \d 在此处表示0-9之间的某一个数字
console.log(reg.test(str))  // false

reg = /^\\d$/; // \\d 在此处纯粹表示 \d 字符串
console.log(reg.test(str)) // true

        要注意这里不仅正则表达式里的 \d 需要进行转义变成 \\d,字符串里的 \d 也同样需要进行转义变成 \\d,因为在字符串中只有用 \\ 才能表示 \ 本身。

4.1.3.4 元字符   [ ]

       中括号在正则表达式中有三个特点:

        一、一般情况下,在正则表达式中,中括号内出现的字符都代表着本身的含义,即对于某些特殊字符我们可以不需要进行转义:

let reg = /^[@+]$/; // 匹配字符 @ 或 + 其中的一个

console.log(reg.test('@')); // true
console.log(reg.test('+')); // true
console.log(reg.test('@@')); // false
console.log(reg.test('@+')); // false

        这种 [ab] 的形式,用来匹配中括号中的字符 a 或者字符 b 其中的一个,所以这里的符号 + 并不代表量词元字符 +,而仅仅是符号 +,并不会发挥它符合一个或多个@符合的功能。

        但有些情况,中括号中的字符依然可以表达相关特殊元字符的含义,比如以 \ 开头的特殊元字符:

let reg = /^[\d]$/; // \d 在中括号中依然可以表示0~9

console.log(reg.test('d')); // false
console.log(reg.test('\\')); // false
console.log(reg.test('9')); // true
let reg = /^[\w]$/; // \w 在中括号中依然可以表示数字、字母和下划线

console.log(reg.test('d')); // true
console.log(reg.test('9')); // true
console.log(reg.test('_')); // true

console.log(reg.test('\\')); // false
console.log(reg.test('&&&')); // false

        二、中括号中不存在多位数

        例如,对于 [18],在正则表达式中并不能匹配数字18,只能用来匹配数字 1 或数字 8:

let reg = /^[18]$/; 

console.log(reg.test('1')); // true
console.log(reg.test('8')); // true
console.log(reg.test('18')); // false

        三、中括号可以用符号 - 进行范围匹配,能匹配的范围有数字 0~9、小写字母 a~z 和 大写字母 A~Z。如下图:

let reg = /^[0-9]$/; // 匹配0-9范围内的某一个数

console.log(reg.test('0')); // true
console.log(reg.test('5')); // true
console.log(reg.test('9')); // true
console.log(reg.test('10')); // false
console.log(reg.test('16')); // false

        但由于中括号中不存在多位数,所以如果我们想匹配 10~39 之间的数,这么写是无效的:

let reg = /^[10-39]$/; // 匹配1、0-3、9 中的某一个数

console.log(reg.test('1')); // true
console.log(reg.test('0')); // true
console.log(reg.test('2')); // true
console.log(reg.test('3')); // true
console.log(reg.test('9')); // true

console.log(reg.test('10')); // false
console.log(reg.test('25')); // false
console.log(reg.test('39')); // false

        这个在中括号中会被识别为 [abc] 语法,最后只会匹配 a 或者 b 或者 c 中的一个。对于表达式 [10-39],匹配的就是1、0-3 和 9 之中的一个数字,而不是匹配 10~39 之间的数字。

        那假如我们的需求就是希望匹配 10~39 之间的数,那又该怎么写呢?其实并不难,我们按数字的组成规则去编写正则表达式就可以了:

let reg = /^[1-3][0-9]$/; // 匹配 10-39 之间的数字

console.log(reg.test('1')); // false
console.log(reg.test('0')); // false
console.log(reg.test('2')); // false
console.log(reg.test('3')); // false
console.log(reg.test('9')); // false

console.log(reg.test('10')); // true
console.log(reg.test('25')); // true
console.log(reg.test('39')); // true

        那如果是要匹配 10~399 之间的数字呢?我们再给它最后补上 [0-9],并用量词元字符 ? 连接表示最后这一位只可出现 0 次到 1 次:

let reg = /^[1-3][0-9][0-9]?$/; // 匹配 10-399 之间的数字

console.log(reg.test('1')); // false
console.log(reg.test('0')); // false
console.log(reg.test('2')); // false
console.log(reg.test('3')); // false
console.log(reg.test('9')); // false

console.log(reg.test('10')); // true
console.log(reg.test('25')); // true
console.log(reg.test('39')); // true

console.log(reg.test('350')); // true
console.log(reg.test('399')); // true

console.log(reg.test('1000')); // false
console.log(reg.test('3999')); // false


4.1.3.5 元字符   (?=abc) 和 (?!abc)

        元字符 (?=abc) 学名为正向预查,用于匹配模式 abc 前面的位置,或者说,该位置后面的字符要匹配 abc。

        例如 (?=e),表示字母 e 前面的位置,我们用字符串的 replace 方式实验一下:

let result = "looked".replace(/(?=e)/, '#');

console.log(result); // look#ed

        可以看到,字母 e 前面的位置被插入了符号 #。

        而 (?!abc) 学名为负向预查,就是 (?=abc) 的反面意思,例如:

let result = "looked".replace(/(?!e)/, '#');

console.log(result); // #looked


let result = "looked".replace(/(?!e)/g, '#');

console.log(result); // #l#o#o#ke#d#

        正则的捕获是惰性的,默认只会匹配第一个符合的结果。这里第二条正则我们用到了全局修饰符 g,它能解决正则的惰性捕获,这点我们留到后面再解释。

        在 ES5 之后的版本,正则对象还支持  (?<=abc) 和 (?<!abc),它可以同时对字符左边和右边是否都存在 abc 进行判断。


        讲完这几个元字符,相信你已经可以大概掌握正则表达式的基本阅读方式了。其它没有介绍到的元字符,其实你也可以以此类推,举一反三,在这里就不再过多陈述。

        然而,如果不去实践,当让你真正动手去写正则的时候,你一定会感到力不从心。现在,先让我们举几个项目中常用的正则表达式来练练手吧。

4.1.4 常用的正则表达式

4.1.4.1 验证输入的数字是否为有效数字

        要判断一个数字是否为有效数字,我们就要先分析有效数字的结构。有效数字的开头可以出现正号或负号,也可以不出现,因此第一步我们可以这么写:

let reg = /^[+-]?/;

        接着我们要思考有效数字是几位数字。如果是一位数字,出现 0-9 范围内的数字都可以;但如果是多位数字,数字的首位不能是0。所以我们可以接着这么写:

let reg = /^[+-]?(\d|([1-9]\d+))/;

        我们将 (\d|([1-9]\d+)) 拆分理解一下:前面的 \d 匹配 0~9 的一位数字,然后用管道符 | 同时对另一种情况 ([1-9]\d+) 进行匹配。([1-9]\d+) 的 [1-9] 匹配第一位是 1~9 之间的数字,后面的 \d+ 表示在第一位数字后面跟着至少一位数字,这样就完成了对多位数的匹配。

        接着,我们还要考虑到数字可能带有小数点,并且小数点后面必须跟着数字:

let reg = /^[+-]?(\d|([1-9]\d+))(\.\d+)?$/;

        我们先用转义符将小数点进行转义,然后把 \.\d+ 这整个部分用括号包起来,并在其后面加上量词元字符 ?,表示括号内的部分可以出现 0 次或 1 次。这样,我们就完成了对有效数字进行验证的正则表达式了。

4.1.4.2 验证密码字符

       假如我们的密码字符只能允许包含数字、字母和下划线,并且在 6~16 位之间。这个要怎么实现呢?数字、字母和下划线,我们可以用 \w 指代,然后在后面加上量词元字符:

let reg = /\w{6,16}/;

console.log(reg.test('12345')); // false
console.log(reg.test('123456')); // true

console.log(reg.test('12345a')); // true
console.log(reg.test('12345_')); // true
console.log(reg.test('12345&')); // false

console.log(reg.test('12345a_')); // true
console.log(reg.test('12345a_bb')); // true

        然而,如果你这么写,输入超过16位字符,也可能通过:

let reg = /\w{6,16}/;

console.log(reg.test('12345')); // false
console.log(reg.test('12345a_bb')); // true

console.log(reg.test('123456789abcdefg')); // true
console.log(reg.test('123456789abcdefg_')); // true
console.log(reg.test('123456789abcdefg_aaa')); // true


        甚至你加入了非 \w 的字符,也可以通过:

let reg = /\w{6,16}/;

console.log(reg.test('123456789abcdefg_&*^%')); // true


        这是因为,/w{6,16} 的作用,只是用于匹配字符串中是否有连续 6~16 位的 \w 字符出现,只要有就返回 true。像上图的这个字符串 "123456789abcdefg_&*^%",前面 "12345678*@_+-9abcdefg_aaa" 红色这一部分已经匹配,所以接下来不管怎么输入,都会返回 true,除非字符串整个都找不到匹配的情况:

let reg = /\w{6,16}/;

console.log(reg.test('123456789abcdefg_&*^%')); // true
console.log(reg.test('12345%$$$abc%%%de%%fg_&*^%')); // false


        这时候,位置元字符 ^ 和 $ 就派上用场了,我们只需要在前后都加上它们,正则表达的意思就是整个字符串必须完全匹配正则描述的规则:

let reg = /^\w{6,16}$/;

console.log(reg.test('12345')); // false
console.log(reg.test('123456')); // true

console.log(reg.test('12345a')); // true
console.log(reg.test('12345_')); // true
console.log(reg.test('12345&')); // false

console.log(reg.test('12345a_')); // true
console.log(reg.test('12345a_bb')); // true

console.log(reg.test('123456789abcdefg_&*^%')); // false
console.log(reg.test('12345%$$$abc%%%de%%fg_&*^%')); // false

4.1.4.3 验证邮箱格式

        要验证邮箱格式,我们需要先理清一般邮箱的格式。邮箱中允许出现的字符由数字、字母、下划线、减号、小数点与符号@构成。其实到目前为止,本人也未完全弄清楚邮箱需要遵循的所有格式,所以就列举几条比较重要且常用的规则进行匹配:

  1. 邮箱的开通必须只能由 1 到多位数字、字母或下划线组成。
  2. 邮箱必须带有@符号且只有一个,并且开头和@之间的内容由是数字、字母、下划线、减号、小数点组成,但是减号和小数点不能连续出现多个,减号和小数点也不能挨着。例如:12345-aaa-bbb@ccc.com、12345.aaa.bbb@ccc.com。
  3. 减号和小数点不能直接放在@前面,也不能直接放在@后面。
  4. 在@的后面一位之后,可以是数字、字母、小数点、减号和下划线,但是减号和小数点不能连续出现多个,减号和小数点也不能挨着。
  5. 最后一个点之后必须有内容且内容只能是数字和字母,且长度大于等于2个字节,小于等于6个字节。

        我们一步一步来尝试,首先第一步,匹配以1到多位数字、字母或下划线开头:

let reg = /^\w+/; 

        第二步,邮箱必须带有@符号:

let reg = /^\w+@/; 

        开头和@符号之间,可以出现数字、字母、下划线,以及减号和小数点。但是减号和小数点不能连续出现,不能挨着彼此,同时也不能直接出现在@符号前面:

let reg = /^\w+((\.\w+)|(-\w+))*@/; 

        这一步不知道你有没有看懂,我们把 ((\.\w+)|(-\w+))* 这一部分单独拿出来看下:首先 (\.\w+) 匹配小数点,并且后面必须跟着一到多位数字、字母或下划线;(-\w+) 匹配减号,并且后面必须跟着一到多位数字、字母或下划线。然后这两个括号用管道符再分割,(\.\w+)|(-\w+) 就表示匹配两者其中之一的情况。这样子就确保了,如果出现了小数点,它后面不会再出现跟着小数点、跟着减号或跟着符号@的情况;如果出现了减号,它后面也不会再出现跟着小数点、跟着减号或跟着符号@的情况;最后  ((\.\w+)|(-\w+))* 外面再跟个符号 *,表示这一部分可以出现也可以不出现。

        第三步,减号和小数点不能直接放在@前面,我们已经实现了,不能直接跟在@后面也很容易实现:

let reg = /^\w+((\.\w+)|(-\w+))*@\w+/; 

        第四步,在@的后面一位之后,可以是数字、字母、小数点、减号和下划线,但是减号和小数点不能连续出现多个,减号和小数点也不能挨着:

let reg = /^\w+((\.\w+)|(-\w+))*@\w+((\.\w+)|(-\w+))*/; 

        这一步的写法其实跟刚才第二步是一模一样的,我们直接把 ((\.\w+)|(-\w+))*  复制过来就好了。

        第五步,最后一个点之后必须有内容且内容只能是数字和字母,且长度大于等于2个字节,小于等于6个字节。

        这句话的意思,其实就是说符号@后面的内容必须有小数点存在,那我们先补一个小数点:

let reg = /^\w+((\.\w+)|(-\w+))*@\w+((\.\w+)|(-\w+))*\./; 

        然后最后一个小数点之后跟着2~6字节的数字或字母:

let reg = /^\w+((\.\w+)|(-\w+))*@\w+((\.\w+)|(-\w+))*\.[a-zA-Z0-9]{2,6}$/; 

        这样,我们就把邮箱格式的基本验证规则写完了。怎么样?是不是觉得很恶心? 

4.1.4.4 数字的千位分隔符表示法

        给出一串数字 "12345678",希望变成 "12,345,678" 这种格式。 我们需要把相应的位置替换成 ",",那具体该怎么做呢?

        首先我们可以用正向预查匹配最后三位数字前面的位置,先弄出最后一个逗号:

let result = "12345678".replace(/(?=\d{3}$)/g, ',')

console.log(result); // 12345,678

        接着我们要弄出所有的逗号,因为逗号出现的位置,要求后面 3 个数字一组,也就是 \d{3} 至少出现一次。 此时可以使用量词 +:

let result = "12345678".replace(/(?=(\d{3})+$)/g, ',')
console.log(result); // 12,345,678

        此时看起来似乎没有问题,但假如我们的数字变成这样呢:

let result = "123456789".replace(/(?=(\d{3})+$)/g, ',')
console.log(result); // ,123,456,789

        因为我们的正则,仅仅是匹配了从结尾往前 3 的倍数位,因此匹配到最前面的 123 也符合情况,就把逗号也加上去了。

        因此,我们就得要求匹配到的位置不能是开头的位置。匹配开头位置我们知道可以使用 ^,那要求不能匹配开头又该怎么办呢?其实很简单,直接用负向预查 (?!^) 就搞定了! 我们来试一下:  

let reg = /(?!^)(?=(\d{3})+$)/g

let res1 = "12345678".replace(reg , ',')
console.log(res1 ); // 12,345,678

let res2 = "123456789".replace(reg , ',')
console.log(res2); // 123,456,789

let res3 = "12".replace(reg , ',')
console.log(res3); // 12

let res4 = "123".replace(reg , ',')
console.log(res4); // 123

        其实我们也可以用表示单词边界的 元字符 \b 来写(注意:在正则表达式中单词的意思指的是数字、字母或下划线边界,即 \w 的边界),效果是一样的:

let reg = /(?!\b)(?=(\d{3})+$)/g

let res1 = "12345678".replace(reg , ',')
console.log(res1 ); // 12,345,678

let res2 = "123456789".replace(reg , ',')
console.log(res2); // 123,456,789

        假如给你的数字窜是 "12345678 123456789",要你替换为 "12345678 123456789" 这种格式,我们就可以这么写:

let string = "12345678 123456789";
let reg = /(?!\b)(?=(\d{3})+\b)/g;

let res = string.replace(reg , ',')
console.log(res); //  "12,345,678 123,456,789"

        emmmm.... 这条确实比较难理解,就当作扩展吧... 这里不想解释了....


        例子就暂时举到这里,如果你坚持看到了这里,相信你已经掌握大多数正则表达式的阅读方法了。但是,接下来我们需要继续补充一些正则相关的知识点。

4.2 修饰符

        除了元字符,正则表达式还可以包含修饰符。修饰符我们最常使用的主要有三个:i、m 和 g。

修饰符
i 表示 ignoreCase用于忽略字母大小写
m 表示 multiline用于进行多行匹配
g 表示 global用于进行全局匹配
s用于让特殊字符圆点 . 可以匹配到换行符 \n
......

4.2.1 修饰符 i

        举个例子,假如我们写一个正则表达式,用于验证字符串 'aaa' 是否存在字母 A:

let reg = /A/

console.log(reg.test('aaa')) // false

        返回的结果是 false,但如果我们加了修饰符 i:

let reg = /A/i

console.log(reg.test('aaa')) // true

        字母大小写被忽略,返回的结果就为 true了。


4.2.2 修饰符m

        m 用于进行多行匹配,但什么叫用于进行多行匹配呢?直接举例:

let str="This is an\n antzone good"; 

let reg=/an$/; // 匹配以 an 结尾的字符串

console.log(reg.test(str));  // flase

        我们写一个用于匹配以 an 结尾的正则,上述字符串以 good 结尾,尽管 an 后面已经加了换行符换行了,但是并没有采用多行匹配。然而,如果我们给正则加上修饰符 m,它就能帮我们进行多行匹配,匹配每一行的是否以 an 结尾:

let str="This is an\n antzone good"; 

let reg=/an$/m; // 匹配以 an 结尾的字符串(多行匹配)

console.log(reg.test(str));  // true

        这个对于判断开头位置也是同样适用的:

let str="This is \nan antzone good"; 

let reg=/^an/m; // 匹配以 an 开头的字符串(多行匹配)

console.log(reg.test(str));  // true

4.2.3 修饰符g

        修饰符 g 主要是针对正则表达式的捕获来使用的。前面我们基本都只是用匹配方法 test 去判断字符串是否符合条件,还没怎么正式讲解捕获方法 exec 的使用。要掌握修饰符 g 的具体作用,我们需要先对捕获方法进行学习。


五. 正则的捕获

        前面主要使用的是正则的 test 方法进行匹配,接下来我们价绍一下用正则的 exec 方法进行捕获。

5.1 exec方法的返回结果

        我们尝试一下基于 exec 实现的捕获方法:


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/;

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        这个用于捕获数字的正则,返回的结果如下:

["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        基于正则原型的 exec 方法实现的捕获返回的结果有以下特点(我们暂时不讲分组捕获情况和 groups 存储的值)

  • 1. 如果捕获失败,返回 null,否则就返回一个数组。

let str = "ABC 111 EFG 222 HIJ 333";
let reg = /TEST+/;

console.log(reg.exec(str)) // null
  • 2. 捕获成功后,返回数组的第一项为本次捕获到的结果;index 属性则对应本次捕获到的结果在字符串中的起始索引位置;input 属性对应本次所输入的原始字符串本身:

let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/;

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

5.2 正则捕获的懒惰性

        正则的捕获存在懒惰性,每执行一次 exec 方法,都只能捕获到 一个符合正则规则的结果。在默认情况下,哪怕我们执行多次,得到的结果都是第一个匹配到的结果,其余匹配的结果都捕获不到:


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/;

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

5.2.1 解决正则捕获的懒惰性

        我们声明的正则表达式对象,都会有一个默认的 lastIndex 属性,它表示当前正则 下一次匹配的起始索引位置


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/;

console.log(reg.lastIndex) // 0
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

console.log(reg.lastIndex) // 0
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        正则捕获懒惰性的原因就在于,默认情况下 lastIndex 的值是不会被修改的,每一次都是从字符串的开始位置插针,所以找到的永远是第一个匹配的结果。

        那我们手动去修改 lastIndex 呢?


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/;

console.log(reg.lastIndex) // 0
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

reg.lastIndex = 8
console.log(reg.lastIndex) // 8
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        似乎手动修改 lastIndex 也不会改变正则从索引 0 开始捕获的规则。

        那要怎么让正则自动修改每次捕获后的 lastIndex 值呢?这就要用到 修饰符 g 了。我们来尝试一下:


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/g;

console.log(reg.lastIndex) // 0
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

console.log(reg.lastIndex) // 7
console.log(reg.exec(str)) // ["222", index: 12, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        这里我们并没有主动去修改 lastIndex,但加了修饰符 g 后,正则内部自己修改了 lastIndex 的值,并且在第二次 exec 执行时顺利捕获到了第二个符合规则的结果。

        那如果加了修饰符 g 后再手动修改 lastIndex 会有效果吗?


let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/g;

console.log(reg.lastIndex) // 0
console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

reg.lastIndex = 16
console.log(reg.lastIndex) // 16
console.log(reg.exec(str)) // ["333", index: 20, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]

        经过测试,是有效果的。第二次捕获的结果绕过了 222,直接捕获到 333 了。

5.2.1.1 全局匹配的规则

        当全部结果捕获完毕,lastIndex 和 exec 返回的结果又会是什么样呢?我们来测试下:

let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/g;

console.log(reg.lastIndex) // 0


console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
console.log(reg.lastIndex) // 7


console.log(reg.exec(str)) // ["222", index: 12, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
console.log(reg.lastIndex) // 15

console.log(reg.exec(str)) // ["333", index: 20, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
console.log(reg.lastIndex) // 23



console.log(reg.exec(str)) // null
console.log(reg.lastIndex) // 0


console.log(reg.exec(str)) // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
console.log(reg.lastIndex) // 7

        我们可以看到,当全部捕获完毕后,再次捕获的结果为 null,并且此时的 lastIndex 又回到了初始值 0。下次再进行捕获时,又会从第一个结果开始循环。

5.2.1.2 lastIndex注意事项

        此外,这里有一个非常重要的点要强调。有些人喜欢先通过 reg.test() 方法去验证字符串是否符合情况,再用 reg.exec() 方法进行捕获。但如果你加了修饰符g,这样其实是会有很大的问题的:

let str = "ABC 111 EFG 222 HIJ 333";
let reg = /\d+/g;


if (reg.test(str)) {
  console.log(reg.lastIndex); // 7
  console.log(reg.exec(str)); // ["222", index: 12, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
}

        我们经过测试会发现,test 方法在执行过后,同样会改变 lastIndex 的位置,导致在之后执行 exec 方法时会跳过 111,直接捕获到 222 了。因此,如果真的想要这么写,就得将执行 test 方法的正则和执行 exec 方法的正则分离开:

let str = "ABC 111 EFG 222 HIJ 333";
let reg1 = /\d+/g;
let reg2 = /\d+/g;

if (reg1.test(str)) {
  console.log(reg1.lastIndex); // 7
  console.log(reg2.lastIndex); // 0
  console.log(reg2.exec(str)); // ["111", index: 4, input: "ABC 111 EFG 222 HIJ 333", groups: undefined]
}

5.2.1.3 封装 execAll 方法

        JavaScript 的正则对象并没有给我们提供一次性捕获所有匹配结果的方法,我们可以尝试自己来封装一下。

        首先使用 exexAll 方法的前提是所写的正则表达式有全局修饰符 g,我们该怎么去判断呢?我们可以在控制台用 dir 方法打印一下正则对象:

        我们可以看到,正则对象本身包含着 global 等与其它修饰符相关联的属性,global 属性为 true 则表示表达式是有全局修饰符 g 的。话不多说,直接上代码封装:

// 在正则的原型上封装execAll方法,只需执行一次就可以把所有匹配的结果捕获到(前提是正则一定要设置全局修饰符g)
(function () {
  function execAll(str = "") {
      // this 表示当前正则实例, 也就是下面调用 execAll 的对象

      if (!this.global) return this.exec(str); // 如果没有添加修饰符g, 返回第一个捕获结果数组
      
      
      let arr = [];
      let res = this.exec(str); // 保存每次捕获的结果

      // 若结果不为 null, 继续循环下一次
      while (res) {
          // 每次捕获成功返回的数组第0位就是我们要的结果
          arr.push(res[0]);

          // 继续进行下一次捕获
          res = this.exec(str);
      }

      return arr.length === 0 ? null : arr;
  }
  RegExp.prototype.execAll = execAll;
  
}());
​


let reg = /\d+/g;

console.log(reg.execAll("ABC 111 EFG 222 HIJ 333")); // ["111", "222", "333"]
console.log(reg.execAll("ABC  EFG  HIJ")); // null

        其实,在字符串的原型上,已经有一个这样的方法了,那就是 String.prototype.match:

let reg = /\d+/g;

console.log("ABC 111 EFG 222 HIJ 333".match(reg)); // ["111", "222", "333"]
console.log("ABC  EFG  HIJ ".match(reg)); // null

        所以,有时候我们项目中用的较多的方法反而是字符串的 match 方法。

5.3 正则捕获的贪婪性

        默认情况下,正则捕获的时候,都是按照当前正则所匹配的最长结果来获取的,例如:

let str = "ABC 111 EFG 222 HIJ 333";

let reg = /\d+/g;

console.log(str.match(reg)); // ["111", "222", "333"]

        再举一个例子,要求从以下HTML标签中匹配到 id 名:

<div id="container" class="main"></div>

        一开始我们可能会尝试这么写:

let reg = /id=".*"/
let string = '<div id="container" class="main"></div>';

console.log(reg.exec(string)); // ["id=\"container\" class=\"main\"", index: 5, input: "<div id=\"container\" class=\"main\"></div>", groups: undefined]

        然而,匹配到的结果是 id=\"container\" class=\"main\",而不是我们想要的 id=\"container\"。这是为什么呢?很简单,这依旧是因为正则的贪婪性。在它检测到 id=\"container\" 后面跟着的 " 号时,它并不会立刻停止匹配,而是继续往后面搜索符合符号 . 的内容,直到找到 class=\"main\" 最后一个 ",再将id=\"container\" class=\"main\" 这一整段返回。

5.3.1 解决正则捕获的贪婪性

        假如我们要取消这种贪婪性,我们只需要在量词元字符后面加上一个问号,这样就会按照正则匹配的最短结果去获取了:

let str = "ABC 111 EFG 222 HIJ 333";

let reg = /\d+?/g;

console.log(str.match(reg)); // ["1", "1", "1", "2", "2", "2", "3", "3", "3"]
let reg = /id=".*?"/
let string = '<div id="container" class="main"></div>';

console.log(reg.exec(string)); // ["id=\"container\"", index: 5, input: "<div id=\"container\" class=\"main\"></div>", groups: undefined]

六. 正则表达式括号的作用

6.1 括号的捕获作用

        在正则中,括号的作用除了前面在介绍 管道符 | 的时候提到的可以改变正则内容的优先级之外,还有另一个作用,就是用来单独匹配捕获的结果,例如下面的例子:

let reg = /\d{4}-\d{2}-\d{2}/
let str = '2021-06-30~~~'

console.log(reg.exec(str)) // ["2021-06-30", index: 0, input: "2021-06-30~~~", groups: undefined]

        在正常情况下返回的结果是这样,但如果我们给正则按下面这种方式添加括号:

let str = '2021-06-30~~~'


let reg1 = /(\d{4})-\d{2}-\d{2}/
console.log(reg1.exec(str)) // ["2021-06-30", "2021", index: 0, input: "2021-06-30~~~", groups: undefined]

let reg2 = /(\d{4})-(\d{2})-\d{2}/
console.log(reg2.exec(str)) // ["2021-06-30", "2021", "06", index: 0, input: "2021-06-30~~~", groups: undefined]

let reg3 = /(\d{4})-(\d{2})-(\d{2})/
console.log(reg3.exec(str)) // ["2021-06-30", "2021", "06", "30", index: 0, input: "2021-06-30~~~", groups: undefined]

        正则返回的数组第一位依然是整个正则表达式捕获的结果,但第一位之后就会依次放置每个括号小分组单独捕获到的结果。

        括号的分组捕获作用在实际项目中非常好用,比如下面这段 Vue 语法:

<div>{{age}}</div>

        如果你希望先匹配 {{ }} 语法 (mustache语法) ,再获取双花括号内的值,你可以这么写:

let str = '<div>{{age}}</div>'

let reg = /{{(\w+)}}/

console.log(reg.exec(str)) // ["{{age}}", "age", index: 5, input: "<div>{{age}}</div>", groups: undefined]

        用括号把 \w+ 部分包起来,返回的结果里第二项就是该属性名本身了。

6.2 非捕获括号

        如果设置了括号分组,但目的只是为了改变优先级而不是想要单独捕获,可以使用非捕获括号(?:xxx) ,即在括号内的最前面加上 ?:,它的作用如下:

let str = '2021-06-30~~~'


let reg1 = /(\d{4})-\d{2}-\d{2}/
console.log(reg1.exec(str)) // ["2021-06-30", "2021", index: 0, input: "2021-06-30~~~", groups: undefined]

let reg2 = /(?:\d{4})-\d{2}-\d{2}/
console.log(reg2.exec(str)) // ["2021-06-30", index: 0, input: "2021-06-30~~~", groups: undefined]

6.3 对括号分组的引用

        括号分组除了改变优先级和分组捕获外,还有第三个作用,就是进行分组引用。分组引用就是通过 '\数字' 让其代表的内容和对应分组完全相等。举个例子,假如我们要匹配 ABBC 格式的字母,比如 book,我们就需要让第二个字母和第三个字母相同,此时分组引用就派上用场了:

let reg = /^[a-zA-Z]([a-zA-Z])\1[a-zA-Z]$/; // 分组引用就是通过 '\数字' 让其代表和对应分组出现一模一样的内容

console.log(reg.test("book")); //  true
console.log(reg.test("look")); //  true
console.log(reg.test("deep")); //  true

console.log(reg.test("bike")); //  false
console.log(reg.test("duck")); //  false

        再举个例子:

let reg = /^(\d+)-\1-\1-\1$/;

console.log(reg.test("12-12-12-12")); //  true
console.log(reg.test("12-13-13-13")); //  false

        分组引用必须保证与所匹配的结果完全一致,是与结果一致而不是与表达式一致。这里的  /^(\d+)-\1-\1-\1$/ 并不等同于  /^(\d+)-\d+-\d+-\d+$/

        分组引用的数字是可以改变的,遵循从左到右的原则,数字是多少就表示对应第几个括号分组捕获的内容:

let reg = /^(\d+)-(\d+)-\1-\2$/;


console.log(reg.test("12-12-12-12")); //  true
console.log(reg.test("12-13-12-13")); //  true
console.log(reg.test("12-13-13-12")); //  false

        那问题来了,如果遇到括号嵌套的情况,\数字 又要怎么对应上呢?出现括号嵌套的情况,遵循从外到内,从左到右的原则。举个例子:

let reg = /^((\d)(\d(\d)))\1\2\3\4$/;

let str = "1231231233";

console.log(reg.test(str)); // true

        这个正则要怎么理解呢?我们不要被括号迷住了,括号的作用只是辅助作用,在这里我们可以去掉后面的分组引用,再去掉前面所有的括号分析一下:

        现在就很明显了,这个正则前三位,就是要匹配三个数字。

        在 JavaScript 中,我们每次创建的正则实例用括号捕获到的小分组,都会依次保存在全局 RegExp 对象的  $1 - $9 九个属性上。我们可以把四对括号加回去,然后用三位数字测试一下:

let reg = /^((\d)(\d(\d)))$/;
let str = "123";

console.log(reg.test(str)); // true

console.log(window.RegExp.$1) // 123
console.log(window.RegExp.$2) // 1
console.log(window.RegExp.$3) // 23
console.log(window.RegExp.$4) // 3
console.log(window.RegExp.$5) // ''
console.log(window.RegExp.$6) // ''
console.log(window.RegExp.$7) // ''
console.log(window.RegExp.$8) // ''
console.log(window.RegExp.$9) // ''

        可以看到,每个括号分组捕获到的值,都会单独保存在全局 RegExp 对象上(这里最多保存 9个)。第一个括号分组表示最外层的大括号( \d ) ( \d ( \d ) ) ),匹配到 123;第二个括号对应 \d (\d ( \d ) ) 绿色部分,匹配到 1;第三个括号对应 \d ( \d ( \d ) ) ) 金色部分,匹配到 23;第四个括号对应 (  \d ( \d \d ) ) 青色部分,匹配到 3;

        所以,对于表达式 /^((\d)(\d(\d)))\1\2\3\4$/,我们只需要将数字对应的括号补上去,就能成功进行匹配了:

七. 正则表达式问号的作用

        其实学到这里,我们可以尝试对正则表达式中出现问号的作用做一下总结了。在正则表达式中,问号主要有以下五个作用:

        1. 当问号左边跟着非量词元字符的时候,问号本身代表着量词元字符,即出现零到一次。

        2. 当问号以 (?=abc) 的形式出现时,表示进行正向预查。

        3. 当问号以 (?!abc) 的形式出现时,表示进行负向预查。

        4. 当问号左边跟着量词元字符的时候,问号用于取消捕获时的贪婪性

        5. 当问号以 (?:) 的形式出现时,表示不对括号分组进行捕获。


        差不多一个星期,一共写了两万七千个字...... 终于写完了...... 感谢阅读。

  • 15
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值