从简单实例入手正则

起因

在平时工作时经常会用到正则表达式(以下简称正则),比如说校验输入格式,替换字符串等等,所以专门写一篇文章来记录一些常用的正则的写法及其实现思路。

什么是正则?为什么用它?

什么是正则?正则就是记录文本规则的代码,是对一串字符串的规则的描述。

为什么用它?因为正则可以帮助我们去描述一些复杂的规则,比如说对密码的限制。

API

首先我们先了解一下与正则相关的一些API,知道怎么去使用正则。

RegExp.prototype.test()

此方法接收一个字符串,判断字符串是否与正则表达式相匹配,返回truefalse

const reg = /^\d$/
reg.test('123asd') // false
reg.test('1') // true

RegExp.prototype.exec()

此方法接收一个字符串,执行一个搜索匹配,返回结果数组或null

const reg = /o+/
const reg2 = /o+/g
reg.exec('hello world!') // ["o", index: 4, input: "hello world!", groups: undefined]
reg.exec('hello world!') // ["o", index: 4, input: "hello world!", groups: undefined]
reg2.exec('hello world!') // ["o", index: 4, input: "hello world!", groups: undefined]
reg2.exec('hello world!') // ["o", index: 7, input: "hello world!", groups: undefined]
reg2.exec('hello world!') // null
reg.exec('new RegExp()') // null

注意
在上面的代码中,我们可以发现一些不一样的地方,当正则表达式后面带上g标志时结果会有些许的不同。这是因为当正则表达式带上g标志时就代表开启了全局匹配,当每次成功匹配时,会同步更新reglastIndex属性,使得下一次搜索是从当前匹配的位置的下一位开始继续搜索的;而当不带g标志时,就无法更新lastIndex属性,从而进行多次搜索。同样的,test()也会更新lastIndex属性

String.prototype.match()

此方法接收一个正则表达式(传入非正则类型会隐式的转换为正则类型),执行搜索匹配,返回结果数组或null

const reg = /o+/
const reg2 = /o+/g
'hello world!'.match(reg) // ["o", index: 4, input: "hello world!", groups: undefined]
'hello world!'.match(reg2) // ["o", "o"]
'new RegExp()'.match(reg) // null

注意
在上面的代码中,我们发现,当不开启全局匹配时,返回值与exec()的返回值相同,而当开启全局匹配时,返回值是一个只包含所有匹配结果的数组。

String.prototype.matchAll()

此方法与match()类似,但是参数接收的正则表达式必须设置了全局模式,否则会抛出异常。返回值是一个不可复用的迭代器。通过返回值中的done判断是否迭代完成。

const reg = /o+/
const reg2 = /o+/g
'hello world!'.matchAll(reg) // Uncaught TypeError: String.prototype.matchAll called with a non-global RegExp argument
const result = 'hello world!'.matchAll(reg2)
result.next() // { value: ["o", index: 4, input: "hello world!", groups: undefined], done: false }
result.next() // { value: ["o", index: 7, input: "hello world!", groups: undefined], done: false }
result.next() // { value: undefined, done: true }

此方法接收一个正则表达式(传入非正则类型会隐式的转换为正则类型),执行搜索匹配,返回正则表达式在字符串中首次匹配项的索引或-1

const reg = /o+/
const reg2 = /o+/g
'hello world!'.search(reg) // 4
'hello world!'.search(reg2) // 4
'new RegExp()'.search(reg) // -1

String.prototype.split()

此方法接收一个字符串或者正则表达式或者整数,使用指定的分隔符字符串将一个String对象分割成子字符串数组,以一个指定的分割字串来决定每个拆分的位置。这里只讨论参数为正则表达式时的情况。

const reg = /o+/
'hello world!'.split(reg) // ["hell", " w", "rld!"]
'new RegExp()'.split(reg) // ["new RegExp()"]

String.prototype.replace()

此方法接收两个参数,第一个参数可以是一个字符串或者正则表达式,第二个参数可以是一个字符串或者函数,返回一个由替换值(replacement)替换部分或所有的模式(pattern)匹配项后的新字符串。这里只讨论第一个参数为正则表达式时的情况。

const reg = /o+/
const reg2 = /o+/g
'hello world!'.replace(reg, 'a') // "hella world!"
'hello world!'.replace(reg2, 'a') // "hella warld!"

当第二个参数为一个函数时,此函数返回值会作为替换字符串,同时接收以下几个参数。

变量名 含义
match 匹配的子串
$1,$2,… 当一个参数为正则时,表达式中第n个小括号中匹配到的子表达式
offset 匹配的子串相对于原字符串的偏移量,即子串开始的下标
string 原字符串
'hello world!'.replace(/(o+).*(o+)/g, (match, $1, $2, offset, string) => {
console.log(match, $1, $2, offset, string) // 'o wo' 'o' 'o' 4 'hello world!'
return '-'
}) // 'hell-rld!'

String.prototype.replaceAll()

此方法与replace()类似,区别是默认替换所有满足条件的匹配项,参数接收的正则表达式必须设置了全局模式,否则会抛出异常。这里只讨论第一个参数为正则表达式时的情况。

const reg = /o+/
const reg2 = /o+/g
'hello world!'.replaceAll(reg, 'a') // Uncaught TypeError: String.prototype.replaceAll called with a non-global RegExp argument
'hello world!'.replaceAll(reg2, 'a') // "hella warld!"

一些实例

下面将通过几个简单的常用的实例来学习如何写正则。

手机号校验

这里只针对国内11位手机号做处理

我们知道,手机号必须满足以下几点要求:

  1. 纯数字
  2. 11位
  3. 1开头

手机号前三位的区号也是一个校验的条件,但是这些号段可能还会不停的增加,所以这里不多加相应校验。

首先对于“纯数字”这个要求,可以使用/\d/或者/[0, 9]/来做校验。

/[0123]/ // 表示匹配其中任意一个字符,其中'[]'表示匹配的范围
/[^0123]/ // 表示匹配任意一个不包含在其中的字符
/[0,9]/ // 表示匹配0-9范围之间的所有字符
/[^0-9]/ // 表示匹配不包含在0-9范围之间的所有字符
/\d/ // 表示匹配一个数字字符,等价于[0, 9]
/\D/ // 表示匹配一个非数字字符,等价于[^0-9]

其次对于“11位”这个要求,可以使用{11}来加限制。

/\d{11}/ // 表示匹配精确的11位,其中'{}'表示用来匹配的长度
/\d{11,}/ // 表示匹配最少11位
/\d{11,14}/ // 表示匹配11-14位
/\d*/ // 表示匹配零次或多次,等价于{0,}
/\d+/ // 表示匹配一次或多次,等价于{1,}
/\d?/ // 表示匹配零次或一次,等价于{0,1}

然后对于“1开头”这个要求,可以使用^1来做校验。

/^1/ // 表示匹配以1开头的字符串
/1$/ // 表示匹配以1结尾的字符串

最后将所有的要求整合到一起就是/^1\d{10}$/。这里要注意的是需要给最后面加上$表示只匹配11位数字就结束了,不然对于以11位数字开头但长于11位的字符串也会校验成功。

const reg = /^1\d{10}$/
reg.test('12345678901') // true
reg.test('1234567890') // false
reg.test('123456789012') // true

身份证号校验

身份证号码通常由15位数字或者17位数字加一位校验位组成,校验位可能是数字或者字符X。

  1. 15位纯数字
  2. 18位,17位纯数字加一位校验位

首先对于第一种形式,可以使用/^\d{15}$/来做校验。

然后对于第二种形式,可以使用/^\d{17}(\d|X|x)$/来做校验。

而对于这种有多种情况的,可以使用A|B来实现。

/A|B/ // 表示匹配A或者B
/(z|f)ood/ // 表示匹配'zood'或者'food',其中'()'标记一个子表达式的开始和结束位置,子表达式可以获取供以后使用。要匹配这些字符,需使用`\(`和`\)`。

最后整合到一起就是/(^\d{15}$)|(^\d{17}(\d|X|x)$)/

const reg = /(^\d{15}$)|(^\d{17}(\d|X|x)$)/
reg.test('110110199901010123') // true
reg.test('11011019990101012X') // true
reg.test('11011019990101012') // false
reg.test('110110199901010') // true

日期格式校验

这是只做以下几种格式的校验。

  1. 2021/01/01
  2. 2021-01-01
  3. 2021.01.01
  4. 2021.1.1

下面分步来看。

  • 第一步先看年月日。年份就是四位数字组成:/\d{4}/;一年有12个月,可以由1-12或者01-12表示,分别做校验就是:/0?[1-9]|1[0-2]/。一个月有28-31天,同样可以由01-31或者1-31表示,分别做校验就是:/0?[1-9]|(1|2)[0-9]|3(?:0|1)/

注意:在日期这里,用到了3(?:0|1),这个表示匹配0|1但不获取匹配结果,也就是说这是一个非获取匹配,不进行存储供以后使用。等于是30|31的简略表达。

  • 第二步看连接符。连接符有三种形式,同时要保证两个连接符一致,这里可以通过捕获子表达式来使其保持一致。

捕获子表达式

前面我们知道了被包在()中的就是一个子表达式,在正则表达式中,通过\num来捕获子表达式。

/\num/ // 匹配 num,其中 num 是一个正整数。对所获取的匹配的引用。例如,'(.)\1' 匹配两个连续的相同字符。

// 验证字符串是否首尾字母一致(单字母除外)
const reg = /^([a-zA-Z])[a-zA-Z]*\1$/
reg.test('adsdfda') // true
reg.test('adsdfd') // false

在上述代码中,我们使用了\1去捕获了子表达式([a-zA-Z]),使得子表达式匹配到什么,\1就是什么,也就是说二者保持一致。

我们可以使用捕获子表达式来实现一些要求几个部分一致的需求。

回到上面的连接符,知道了如何捕获子表达式,实现起来就很简单了:/^\d{4}([-/.])(0?[1-9]|1[0-2])\1(0?[1-9]|(1|2)[0-9]|3(?:0|1))$/。这里需要注意的是表达式中用到了()来区分不同的子表达式,所以在捕获的时候一定要注意是第几个表达式。

const reg = /^\d{4}([-/.])(0?[1-9]|1[0-2])\1(0?[1-9]|(1|2)[0-9]|3(?:0|1))$/
reg.test('2021/01/01') // true
reg.test('2020-01-01') // true
reg.test('2021.01.01') // true
reg.test('2021.1.1') // true
reg.test('2021.1.32') // false

缺陷

上述的表达式有个缺陷就是不能判断日是否与月相对应,比如2021-02-31,这个需要通过代码去实现。

金额格式校验

金额在输入的时候可能会是各式各样的,这里对以下几种方式做校验

  1. 1000,整数金额
  2. 1000.01,精确到分金额
  3. 1,000,3位分隔的整数金额格式
  4. 1,000.01,3位分隔的精确到分金额格式

下面分步来看。

  • 第一步应该是一个不限制长度的数字:/^[0-9]+$/

  • 第二步增加一个金额可以精确到分的校验:/^[0-9]+(.[0-9]{1,2})?$/。这里在上一步基础上增加了一个可以出现0或1次的(\.[0-9]{1,2}),这个部分匹配的是以小数点开始的一到两位的小数部分。

注意:我在小数部分的校验用到了\.,这是因为.代表的是匹配除换行符(\n、\r)之外的任何单个字符。而当放置在[]中时,.代表的就是.

  • 第三步写出一个必须输入 , 的校验,这里要明确,对于 , ,必须从小数点或最右侧开始每三位一个,且不可出现在第一位,不可出现在小数位。也就是说我们要限制金额开头不可为 , ,即/^[0-9]{1,3}/(这里限制1-3位是因为 , 分隔的就是3位,超过3位就必须加 , )。然后是包含 , 的部分:/(,[0-9]{3})*/,用到了’‘,是因为可能金额整数部分只有三位,恰好没有 , 。合在一起就是一个必须输入 , 的校验:`/^[0-9]{1,3}(,[0-9]{3})$/`。

  • 最后我们把第二步和第三步的合在一起就是:/^([0-9]+|[0-9]{1,3}(,[0-9]{3})*)(\.[0-9]{1,2})?$/。整体由()区分成整数和小数两部分,小数部分可有可无;然后整数部分又由|区分成不可输入 , 和可以输入 , 的两部分。

const reg = /^([0-9]+|[0-9]{1,3}(,[0-9]{3})*)(\.[0-9]{1,2})?$/
reg.test('213231.23') // true
reg.test('12,234,342.12') // true
reg.test('1,23,324,453,1') // false
reg.test('1233,422.21') // false

金额格式化

金额格式化是指将数字格式的转换为金额格式,即:1000.01 -> 1,000.01

这个的关键点在于找到插,的地方,并且要排除小数位。首先,我们先学习几个新的知识点:正向肯定预查正向否定预查

正向肯定预查

正向肯定预查(/(?=pattern)/)在任何匹配pattern的字符串开始处匹配查找字符串,这是一个非获取匹配,预查不消耗字符。

举个栗子

const reg = /o(?=r)/g
reg.exec('hello world!') // ["o", index: 7, input: "hello world!", groups: undefined]
reg.exec('hello world!') // null

在上面的代码中,查找o时忽略了下标为4的o,而直接到了下标为7的o,这是因为正则表达式的意思是全局查找后面紧跟ro,只有下标为7的满足条件。为什么?

再看定义,“预查不消耗字符”,表示表达式中(?=r)这一部分只是做一个限制,限制匹配的目标后面必须紧跟着r,而真正匹配的只有前面的o

正向否定预查

正向否定预查(/(?!pattern)/),在任何匹配pattern的字符串开始处匹配查找字符串,这是一个非获取匹配,预查不消耗字符。

举个栗子

const reg = /o(?!r)/g
reg.exec('hello world!') // ["o", index: 4, input: "hello world!", groups: undefined]
reg.exec('hello world!') // null

与正向肯定预查类似,不同的是它匹配的是后面没有紧跟ro,所以只匹配到了下标为4的o

格式化

现在回到我们的问题,金额格式化,也就是整数部分右往左每三位加一个,;这里就可以用到正向肯定预查,我们去找后面恰好有3的倍数位的位置(只有限制条件,没有实际匹配目标,匹配到的就是位置),也就是/(?=(\d{3})+)/

const reg = /(?=(\d{3})+)/g
'123456789'.replace(reg, ',') // ",1,2,3,4,5,6,789"

观察上面代码发现有两个问题,一个是首位也会被替换成,,另一个是只要后面包含至少三位数字就一定会发生替换,并不是我们希望的恰好是3的倍数位。这肯定不是我们希望的。针对问题一,可以通过\B限制不匹配边界。

/\b/ // 匹配一个单词边界
/\B/ // 匹配非单词边界

const reg = /\B(?=(\d{3})+)/g
'123456789'.replace(reg, ',') // "1,2,3,4,5,6,789"

然后是问题二,这个需要用到正向否定预查,去限制即包含了3的倍数位的数字且不含多余的数字,也就是/\B(?=(\d{3})+(?!\d))/。其中(?!\d)表示后面还有数字就不匹配。

const reg = /\B(?=(\d{3})+(?!\d))/g
'123456789'.replace(reg, ',') // "123,456,789"

可以发现已经达到我们想要的效果了。

然后我们扩展一下,学习一下与正向肯定/否定预查类似的反向肯定预查反向否定预查

反向肯定预查

反向肯定预查((?<=pattern)),与正向肯定预查类似,只是方向相反。

反向肯定预查是给查询条件的左侧加限制条件。举个栗子

const reg = /(?<=w)o/g
reg.exec('hello world!') // ["o", index: 7, input: "hello world!", groups: undefined]
reg.exec('hello world!') // null

限制了o的左侧必须w,所以只有下标为7的匹配成功了。

反向否定预查

反向否定预查((?<!pattern)),与正向否定预查类似,只是方向相反。

举个栗子

const reg = /(?!w)o/g
reg.exec('hello world!') // ["o", index: 4, input: "hello world!", groups: undefined]
reg.exec('hello world!') // null

限制了o的左侧必须不是w,所以只有下标为4的匹配成功了。

密码校验

密码校验也会有一个难易程度,这里针对不同的强度分别做说明。

弱强度密码

限制条件:只能由字母、数字或者下划线组成,长度6-18位。

针对“只能由字母、数字或者下划线组成”这一限制条件,可以使用\w来匹配。

/\w/ // 匹配字母、数字、下划线。等价于'[A-Za-z0-9_]'
/\W/ // 匹配非字母、数字、下划线。等价于'[^A-Za-z0-9_]'

然后加一个长度限制就可以了:/^\w{6,18}$/

const reg = /^\w{6,18}$/
reg.test('hello world!') // false
reg.test('123456') // true
reg.test('jaco_123456') // true

高强度密码

限制条件:至少同时包含大小写字母和数字以及特殊符号其中的三种,长度8-16位。

首先看一下匹配特殊字符的元字符。前篇介绍过的这里不再赘述。

/\cx/ // 匹配由 x 指明的控制字符。例如, \cM 匹配一个 Control-M 或回车符。x 的值必须为 A-Z 或 a-z 之一。否则,将 c 视为一个原义的 'c' 字符。
/\f/ // 匹配一个换页符。等价于 \x0c 和 \cL。
/\n/ // 匹配一个换行符。等价于 \x0a 和 \cJ。
/\r/ // 匹配一个回车符。等价于 \x0d 和 \cM。
/\s/ // 匹配任何空白字符,包括空格、制表符、换页符等等。等价于 [ \f\n\r\t\v]。
/\S/ // 匹配任何非空白字符。等价于 [^ \f\n\r\t\v]。
/\t/ // 匹配一个制表符。等价于 \x09 和 \cI。
/\v/ // 匹配一个垂直制表符。等价于 \x0b 和 \cK。

下面分步来看。

  • 第一步,先匹配一个非空字符串,长度8-16位。
const reg = /^\S{8,16}$/
reg.test('Jaco_123456') // true
  • 第二步,分别限制其必输包含大写字母、小写字母、数字、特殊符号。

这里要用到正向肯定预查来限制必须包含某个规则。

比如说限制必须包含大写字母,可以这样写:/(?=[A-Z])/,表示匹配目标后面必须跟一位大写字母。但是我们要明确两个点,一个是我们不确定有几位大写字母,而是我们不确定大写字母在哪一位。

对于第一点,其实可以不用去管他,我们只要去判断有没有,确保有一位存在即可。对于第二点,不确定位数,可以使用/\S*/来匹配,表示前面有0到多位非空字符。合起来就是/(?=\S*[A-Z])/

// 必须包含大写字母
const reg = /^(?=\S*[A-Z])\S{8,16}$/ // (?=\S*[A-Z])限制在某一位必须包含大写字母
reg.test('Jaco_123456') // true
reg.test('jaco_123456') // false
// 必须包含小写字母
const reg2 = /^(?=\S*[a-z])\S{8,16}$/ // (?=\S*[a-z])限制在某一位必须包含小写字母
reg2.test('JACo_123456') // true
reg2.test('JACO_123456') // false
// 必须包含数字
const reg3 = /^(?=\S*[0-9])\S{8,16}$/ // (?=\S*[0-9])限制在某一位必须包含数字
reg3.test('jaco_123456') // true
reg3.test('jaco_abcdef') // false
// 必须包含特殊符号
const reg4 = /^(?=\S*[!@#$%^&*?()_ ])\S{8,16}$/ // (?=\S*[!@#$%^&*?()_ ])限制在某一位必须包含特殊符号,这里将常用的特殊符号全部列举了出来,如果范围比较宽泛,可以直接使用 \W
reg4.test('jaco@123456') // true
reg4.test('jaco123456') // false
  • 第三步,在第二步的基础上限制必须包含其中三种。

因为我们不确定用户输入的是哪三种,所以不同的情况我们都要加以考虑。

比如说对于“必须同时包含大小写字母和数字”这一类型。我们在上一步得到三种分别的限制条件的基础上,只需要将这三种合在一起,作为并且条件即可。而对于预查,按顺序一个接一个写,就是并集。

// 必须包含大小写字母和数字
const reg = /^(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[0-9])\S{8,16}$/ // 必须同时满足这三个限制条件
reg.test('Jaco_123456') // true
reg.test('Jaco_abcdef') // false
reg.test('J123_123456') // false
// 必须包含大小写字母和特殊字符
const reg2 = /^(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*?()_ ])\S{8,16}$/ // 必须同时满足这三个限制条件
reg2.test('Jaco_123456') // true
reg2.test('Jaco123456') // false
reg2.test('jaco_123456') // false
// 必须包含小写字母、数字和特殊字符
const reg3 = /^(?=\S*[a-z])(?=\S*[0-9])(?=\S*[!@#$%^&*?()_ ])\S{8,16}$/ // 必须同时满足这三个限制条件
reg3.test('Jaco_123456') // true
reg3.test('Jaco123456') // false
reg3.test('JACO_123456') // false
// 必须包含大写字母、数字和特殊字符
const reg4 = /^(?=\S*[A-Z])(?=\S*[0-9])(?=\S*[!@#$%^&*?()_ ])\S{8,16}$/ // 必须同时满足这三个限制条件
reg4.test('Jaco_123456') // true
reg4.test('Jaco123456') // false
reg4.test('jaco_123456') // false

然后我们将这几种情况合在一起就是

const reg = /^((?=\S*[A-Z])(?=\S*[a-z])(?=\S*[0-9]))|((?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*?()_ ]))|((?=\S*[a-z])(?=\S*[0-9])(?=\S*[!@#$%^&*?()_ ]))|((?=\S*[A-Z])(?=\S*[0-9])(?=\S*[!@#$%^&*?()_ ]))\S{8,16}$/
reg.test('Jaco_123456') // true
reg.test('Jaco123456') // true
reg.test('jaco_123456') // true
reg.test('jaco123456') // false

但是这个正则似乎过于长了。

一些笔试题

URL化

题目来源

力扣 面试题 01.03. URL化

题目描述

编写一种方法,将字符串中的空格全部替换为%20。假定该字符串尾部有足够的空间存放新增字符,并且知道字符串的“真实”长度。

示例1:

输入:“Mr John Smith    ”, 13
输出:“Mr%20John%20Smith”

示例2:

输入:“               ”, 5
输出:“%20%20%20%20%20”

题解

我们可以使用\s匹配到空白字符,然后使用replace()将其全部替换为%20
同时注意题目中说的“知道字符串的‘真实’长度”,也就是说给到的输入可能长度会长于真实长度,需要先对其进行截取。

/**
* @param {string} S
* @param {number} length
* @return {string}
*/
const replaceSpaces = function(S, length) {
return S.slice(0, length).replace(/\s/g, '%20')
};

参考

MDN

文章作者: JaCo Wu
文章链接: https://jacokwu.cn/blog/2021/01/05/从简单实例入手正则/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JaCo Wu的博客