Skip to content

cover

JavaScript中的正则表达式

正则表达式(Regular Expression,简称 Regex或者Regexp)是一种有用于匹配和操作文本的强大工具,它是由一系列字符和特殊字符组成的模式。在JavaScript中正则表达式也有广泛的用途,但很多人都会被那一串串火星文似的语句所困惑。这篇文章将会介绍正则表达式的基本用法,让你远离正则恐惧症。

网上有很多正则表达式的教程,为什么还要多次一举写这篇文章呢?其实我也是读那些优秀的文章学会的,但是过程是非常枯燥的,而且往往是过来一段时间后又会忘记,不得不再次翻阅那些博客,所以我写下此篇文章一是为了巩固正则知识,二是想以一种全新的方式写一篇正则教程,希望帮助到大家。话不多说了,让我们开始吧。

文章部分内容参考自书籍《JavaScript正则表达式迷你书》,有兴趣的同学可以自行下载阅读。

正则两种匹配模式

正则表达式有两种匹配模式,一是字符匹配,二是位置匹配。

字符匹配

正如正则表达式的作用,它可以匹配字符,当我们想匹配 hello regexp 中的 regexp 时,表达式为:/regexp/

提示

为了方便大家加深记忆,我做了一个练习模块。在下方输入框输入正确的正则表达式,目标文本将会可视化显示匹配的内容,点击右下角 ? 显示答案。

输入框后一个斜杠后面的 g 是一个正则修饰符,global 的缩写,表示全局匹配,即正则会从左至右匹配所有符合条件的文本,如果不加 g 修饰符,则只会匹配第一个符合条件的文本。

上面的例子体现了正则表达式的精准匹配,但正则不仅能够精准匹配,还能够实现模糊匹配。 正则有两个方向上的模糊匹配,横向和纵向匹配。

两种模糊匹配

  • 横向模糊匹配

横向匹配指的是,一个正则表达式可匹配字符串的长度不是固定的。

其实现方式是使用量词,譬如表达式 {m,n},这个表达式表示匹配的字符出现次数在 mn 之间。

比如匹配 /be{1,3}r/g 能匹配字符串 ber beer beeer中的每个单词,其中每个 br 中间的 e 出现 1 到 3 次。

img_1

  • 纵向模糊匹配

纵向模糊匹配指的是,当正则匹配到具某一位具体字符时,它可以匹配多个字符。

例如要匹配 test text teat tect中的每个单词,需要用到字符集合的匹配方式,正则为/te[sxac]t/

img_1

字符集合

当我们要匹配的某个字符有很多中可能时,字符集能实现这种匹配。字符集合语法是用中括号 [] 将字符的所有可能包含起来,例如 [abc] 匹配 ab 或者 c

值得注意的是,虽然叫字符集合,但它只能匹配一个字符,正如上面测试中,[sxac] 只能表示 test text teat tect 中第三个字母的可能集合。

如果我们想匹配的字符特别多,但是是在某个范围并且连续的,那么可以使用正则里的范围匹配 规则,例如 [a-z] 匹配 az 之间的任意一个字符,[0-9] 匹配 09 之间的任意一个字符。

如果在字符集合中前面加一个 ^,那么它表示匹配不包含在字符集合中的字符。例如,[^4-8] 匹配除了 48 之间的任意一个字符。

除了一个范围内的字符集合可以缩写外,正则中还内置了很多字符集的简写:

缩写字符集合含义
.[^\n\r\u2028\u2029]通配符,匹配除了换行符、回车符、行分隔符和段分隔符以外的任意一个字符
\d[0-9]数字,ddigit 的简称
\D[^0-9]除数字外的任意字符
\w[0-9a-zA-Z_]数字、大小写字母和下划线,wword 的简称
\W[^0-9a-zA-Z_]非字母、数字、下划线
\s[ \t\v\n\r\f]空白字符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页符,sspace 的简称
\S[^ \t\v\n\r\f]非空白字符

提示

当我们想匹配字符 . 需要写成 \. 进行转义。包括后面会学到的一些元字符,我们要匹配它们本身的时候,都需要在前面加上 \ 进行转义。

量词

量词也称限定符,它们的作用是限定匹配的次数,量词一共有 6 种,分别是 *+?{n}{n,}{n,m}

  • * 表示 0 个或多个。
  • + 表示 1 个或多个
  • ? 表示 0 个或 1 个
  • {n} 表示 n 个
  • {n,} 表示 n 个或多个
  • {n,m} 表示 n 到 m 个

以上的量词默认都是贪婪匹配模式,即匹配尽可能多的字符,请看下面的例子:

js
const text = '量词有"贪婪匹配"和"惰性匹配"两种模式'
const reg = /".+"/g
const result = text.match(reg)

console.log(result) // ['"贪婪匹配"和"惰性匹配"']

我们期望正则能将 贪婪匹配惰性匹配匹配出来,但是事与愿违,造成这种情况是因为当正则匹配到第二个 " 时没有结束,它是贪婪的,能匹配到的 " 多多益善,直到匹配到最后一个 " 返回了结果。要想达到惰性匹配,需要在量词后面加上 ?

js
const text = '量词有"贪婪匹配"和"惰性匹配"两种模式'
const reg = /".+?"/g
const result = text.match(reg)

console.log(result) // ['"贪婪匹配"', '"惰性匹配"']

以可看到,在惰性匹配的模式下,正则一旦匹配到满足的内容,就会返回。

分支

类似字符集合可以表示单个字符的多种可能,正则的分支可以用来表示字符串或者表达式的多种可能,比如 (ab|cd|ef) 表示 ab 或者 cb或者 ef(ab{2,3}c|a[^b]c) 表示可以是 ab{2,3}c模式也可以是 a[^b]c模式


下面我们针对来前面部分所学内容做一些练习

位置匹配

前面我们学习了正则表达式的字符匹配,现在我们来介绍介绍位置匹配。

那么什么是位置匹配呢?让我们来看看下面几个例子:

金额 ¥1988888 转化为千分法: 转化为千分法 ¥1,988,888

将手机号码 18888888888 转化为速记法: 188-8888-8888

上面的例子可以看到,在某些数字中间添加了逗号和横杠,这些符号正是正则表达式通过“位置匹匹配”匹配到了这些“位置”从而添加的。

这些“位置”我们可以理解为相邻字符之间的位置。你可能将其与空格混淆,空格通常作为单词间的分隔,位置是字符间的分隔,确切说是字符的边界,并且空格之间也是存在位置的,例如,我们将 hello world的全部位置用 - 标注出来为:

javascript
const text = 'hello world'
const result = text.replaceAll('','-')

console.log(result)  //-h-e-l-l-o- -w-o-r-l-d-

正则表达式中如何进行位置匹配呢? 正则中用来表示位置的符号有:^$\b\B(?=p)(?!p)(?<=p)(?<!p),我逐个介绍一下。

^和$

^表示匹配输入字符串的开始位置。

$表示匹配输入字符串的结束位置。

\b和\B

  • \b表示匹配一个单词的边界,具体将有一下三点原则:
  1. \w\W 之间的位置,也就是单词与非单词之间的位置,例如:hello world 中用 - 表示该位置就是 hello- -world

  2. ^\w 之间的位置,表示着这个单词位于文本开头,这个位置就在这个开头位置,例如:hello world 中用 - 表示该位置就是 -hello world

  3. \w$ 之间的位置,表示着这个单词位于文本结尾,这个位置就在这个结尾位置,例如:hello world 中用 - 表示该位置就是 hello world-

  • \B则和\b相反,表示匹配一个非单词的边界,意味着这个位置不能时单词边界。

零宽断言

“零宽断言”又是一个让人摸不着头脑的名词。“零宽”意思就是宽度为零,不占据位置,这也和前面介绍的“位置”是一个意思,断言就是表示一种条件判断,所以正则表达式中“零宽断言”可以理解为匹配满足特定条件的位置的一种表达式。零宽断言表达式有四种,分别是:(?=p)(?!p)(?<=p)(?<!p)

  • (?=p) 为正向先行断言,我们不用去记它的名字,只需要明白,这个表达式的作用是匹配一个位置,紧接该位置之后的字符满足表达式p,也可以理解为:p前面的位置。

看下面这个例子:

js
const string = 'I like singing, dancing, rapping, and playing basketball'
const result = string.replace(/(?=ing)/g,'-')

console.log(result) // 'I like s-ing-ing, danc-ing, rapp-ing, and play-ing basketball'

我们可以看到,replace函数将(?=ing) 匹配到的位置替换成了横杠,这些位置他的后面都是 ing

  • (?!p) 为负向先行断言,这个表达式的作用是匹配一个位置,紧接该位置之后的字符序列不满足表达式p。

(?=p)相反的是,(?<!p) 匹配的位置后面不能是满表达式exp的。

还是看个例子:

javascript
const string = 'I`m singing while you`re dancing'
const result = string.replace(/(?!ing)/g,'-')

console.log(result) // -I-`-m- -si-n-gi-n-g- -w-h-i-l-e- -y-o-u-`-r-e- -d-a-n-ci-n-g-

可以看到,字符串中除了ing之外的位置都被替换成了横杠,这恰好和前面的 (?=ing) 的例子是相反的。

  • (?<=p) 为正向后行断言,这个表达式的作用是匹配一个位置,紧接该位置之前的字符满足表达式p,
  • (?<!p) 为负向后行断言,这个表达式的作用是匹配一个位置,紧接该位置之前的字符序列不满足表达式p。

警告

部分浏览器不支持后行断言也就是 (?<=p)(?<!p),可以使用替代方案:javascript regex - look behind alternative?

位置的特性

我们也可以将位置理解为空字符 "",字符串 hello 的位置等价于如下的形式:

javascript
"hello" == "" + "h" + "" + "e" + "" + "l" + "" + "l" + "o" + "";

也等价于:

javascript
const "hello" == "" + "" + "hello"

同样的,在正则表达式中,一个位置也可以存在多个表达式,例如可以这样写: /^^hello$/,甚至这个样子的表达式也是没有问题的:/(?=he)^^he(?=\w)llo$\b\b$/

我们来分析以下案例:

  • 案例1: 还得我们在开头介绍的金额的千分表示法吗?例如把 123456789 转换成 123,456,789,它是如何实现的呢?

简单分析可以得出,添加逗号的规则是:每三个数字为一组,可以表示为 \d{3} ,每组前面添加逗号,这个位置可以写为 (?=(\d{3})),要有逗号,至少会有一组数字,也就是说分组至少出现一次,所以可以表示为 (?=(\d{3}))+,添加规则是从后往前的,也就是从结尾开始,所以我们最终的表达式还需要在末尾添加 $(?=(\d{3}))+$,我们代入 replace 方法中试试:

javascript
const num = '123456789'
const result = num.replace(/(?=(\d{3})+$)/g, ',')

console.log(result) // ,123,456,789

此时我们会发现,数字最前面的逗号是不需要的,换个说法就是,逗号不能存在在字符串的开始位置,我们知道,^ 表示的是存在一个位置,在字符串开始的位置,所以逗号不能存在在开始位置就是 ^ 的负向断言,正则表示为 (?!^),所以我们将正则改成下面的表达式:

javascript
const num = '123456789'
const result = num.replace(/(?!^)(?=(\d{3})+$)/g, ',')

console.log(result) // 123,456,789

我们再做个拓展:如果需要替换的字符串是这样的呢?

text
只要199988,XXXX带回家

这也是我们平时开发中较为多见的金额出现的形式,我们前面已经介绍了,\w 代表的是字符集和 [0-9a-zA-Z_], 也就是说,数字和中文还有 中文逗号 之间其实是存在单词边界 \b 的,此时我们需要修改正则,把里面的开头 ^ 和结尾 $,替换成 \b

javascript
const string = "只要199988,XXXX带回家",
const reg = /(?!\b)(?=(\d{3})+\b)/g;

const result = string.replace(reg, ',')
console.log(result); 
// 只要199,988,XXXX带回家

这里的 (?!\b) 表示非单词边界,其实就是 \B,所以我们得到了最终的结果:

text
/\B(?=(\d{3})+\b)/g
  • 案例2:验证密码问题。

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

此题如果写成多个正则,配合js逻辑来判断,比较容易。但要写成一个正则验证就比较困难。

那么,我们就来挑战一下。看看我们对位置的理解是否深刻。

不考虑“必须至少包括2种字符”这一条件。我们可以容易写出:

text
/^[0-9A-Za-z]{6,12}$/

那么,必须包含某2种字符该如何表示呢?

我们知道,零宽断言表示的是某一位置满足某个条件,我们假定这个位置就在密码字符串的开头,位置后面的密码字符串必须满足一个条件,这就是我们的正向先行断言 (?=p),所以,如果密码如果必须同时包含数字和小写字母可以表示为:

text
/(?=.*[0-9])(?=.*[a-z])^[0-9A-Za-z]{6,12}$/

这里的 (?=.*[0-9]) 表示有任何多个任意字符,后面再跟个数字,通俗讲就是,接下来的字符串中至少有一个数字。至少一个小写字母同理。如果是必须包含某2种字符那么就进行一个排列组合,则写成:

text
/((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-Z]))^[0-9A-Za-z]{6,12}$/

你可能会说,要写这么长的一串正则表达式,我宁愿分开成几段配合js进行验证,别急,我们可以改进以下。

“至少包含两种字符”的意思就是说,不能全部都是数字,也不能全部都是小写字母,也不能全部都是大写字母。

这时我们可以让 (?!p) 出马,不能全部都是数字对应的正则时

text
/(?!^[0-9]{6,12}$)^[0-9A-Za-z]{6,12}$/

由此我们最终的正则表达式如下:

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

做个练习:

括号的作用

我们前面已经遇到了括号 () 在分支中的使用场景,在正则表达式中,括号还有其他作用。

分组和分支结构

前面的介绍中,量词指定的是一个字符,例如 a{3} 表示前面的字符 a 出现三次,但如果我们想要 ab 连续出现三次,就需要用括号将 ab 包裹起来:(ab){3},这也就是括号的功能之一——分组。

分支结构 (p1|p2) 在前面已经介绍了,这里的括号提供了子表达式的所有可能。

引用分组

引用分组是括号的一个重要作用,它配合 JavaScript 可以实现更强大的提取数据,替换操作。

如果我们想要提取日期 1949-10-01 中的年,月,日,那么我们可以这样做:

javascript
const text = '1949-10-01'
const regexp = /(\d{4})-(\d{2})-(\d{2})/
const result = text.match(regexp)
console.log(result) // ['1949-10-01', '1949', '10', '01', index: 0, input: '1949-10-01']

match 会返回一个数组,第一个元素是整体匹配结果,然后是各个分组(括号里)匹配的内容,index 为匹配的起始位置(数组是一个特殊对象,所以是可以像数组中添加属性的),input 最后是输入的文本(注意:如果正则是否有修饰符 gmatch 返回的数组格式是不一样的,只要使用了全局匹配模式,那么match将只返回“贪婪”的匹配结果,这里的“贪婪”指的是只有那个最长的能匹配上的字符串,分组项目会被忽略,并且,是没有 inputindex 属性的)。

所以,我们要取出匹配到的年,月,日,可以这样做:

javascript
const year = result[1]
const month = result[2]
const day = result[3]

同时,也可以:

javascript
const year = RegExp.$1
const month = RegExp.$2
const day = RegExp.$3

一共有 RegExp.$1RegExp.$9 9个属性存放匹配到的内容,如果超出9个,那还是得使用数组索引来取结果。

如果我们想将 yyyy-mm-dd 格式替换成 yyyy/mm/dd 呢?

javascript
const text = '1949-10-01'
const regexp = /(\d{4})-(\d{2})-(\d{2})/
const result = text.replace(regexp,"$1/$2/$3")

console.log(result) // '1949/10/01'

其中 replace 的第二个参数里的 $1$2$3 指代相应的分组,第二个参数也可以是一个回调函数:

javascript
const text = '1949-10-01'
const regexp = /(\d{4})-(\d{2})-(\d{2})/
const result = text.replace(regexp, (match, year, month, day) => {
  return `${year}/${month}/${day}`
})

console.log(result) // '1949/10/01'

也等价于:

javascript
const text = '1949-10-01'
const regexp = /(\d{4})-(\d{2})-(\d{2})/
const result = text.replace(regexp, () => {
  return `${RegExp.$1}/${RegExp.$2}/${RegExp.$3}`
})

console.log(result) // '1949/10/01'

反向引用

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

还是以日期为例。

比如要写一个正则支持匹配如下三种格式:

  • yyyy-mm-dd
  • yyyy/mm/dd
  • yyyy.mm.dd

结合前面学的正则只是,你可能会想到这样的正则:

javascript
const regexp = /\d{4}[-/.](0\d|1[0-2])[-/.](0\d|[12]\d|3[01])/

const string1 = '1949-10-01'
const string2 = '1949/10/01'
const string3 = '1949.10.01'

console.log(regexp.test(string1)) // true
console.log(regexp.test(string2)) // true
console.log(regexp.test(string3)) // true

提示

实践发现字符集中的 ./ 不会被转义。

但我们要求日期分隔符要前后一致呢?显然 "2022-02/22" 这种格式是不合法的,但上面的正则匹配通过了:

javascript
const string4 = '2022-02/22'

console.log(regexp.test(string4)) // true

此时我们需要使用引用分组:

javascript
const regexp = /\d{4}([-/.])(0\d|1[0-2])\1(0\d|[12]\d|3[01])/

const string1 = '1949-10-01'
const string2 = '1949/10/01'
const string3 = '1949.10.01'
const string4 = '2022-02/22'

console.log(regexp.test(string1)) // true
console.log(regexp.test(string2)) // true
console.log(regexp.test(string3)) // true
console.log(regexp.test(string4)) // false

这里 \1 表示引用前面的分组 ([-/.]),不管它匹配到什么(比如-),\1 都匹配那个同样的具体某个字符,同理 \2 - \9 指代前面2-9个分组。

如果是 \10 呢?是表示 \10 还是 \10

javascript
var regex = /(1)(2)(3)(4)(5)(6)(7)(8)(9)(#) \10+/;
var string = "123456789# #####"
console.log( regex.test(string) );
// => true

答案是前者,虽然一个正则里出现\10比较罕见,

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

因为反向引用,是引用前面的分组,但我们在正则里引用了不存在的分组时,此时正则不会报错,只是匹配反向引用的字符本身。例如 \2,就匹配"\2"。注意"\2"表示对"2"进行了转意。

javascript
var regex = /\1\2\3\4\5\6\7\8\9/;
console.log( regex.test("\1\2\3\4\5\6\7\8\9") ); 
console.log( "\1\2\3\4\5\6\7\8\9".split("") ); //['\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '8', '9']

如果是老版本的浏览器,你可以见到这样的结果:

这是因为 '\x01'代表了的Unicode编码。

非捕获分组

之前文中出现的分组,都会捕获它们匹配到的数据,以便后续引用,因此也称他们是捕获型分组。

如果只想要括号最原始的功能,但不会引用它,即,既不在API里引用,也不在正则里反向引用。此时可以使用非捕获分组(?:p),例如我们在匹配日期的例子中,如果将括号改成(?:p):则匹配到的内容将不会保存在分组中:

javascript
const text = '1949-10-01'
const regexp = /(?:\d{4})-(?:\d{2})-(?:\d{2})/
const result = text.match(regexp)
console.log(result) // ['1949-10-01', index: 0, input: '1949-10-01']

介绍完括号的作用,我们来做几个练习:

未完待续...