Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

思维导图 + 文字 = 让你一次性学会正则表达式 #31

Open
liyongning opened this issue Jun 28, 2022 · 0 comments
Open

思维导图 + 文字 = 让你一次性学会正则表达式 #31

liyongning opened this issue Jun 28, 2022 · 0 comments
Labels
正则表达式 正则表达式

Comments

@liyongning
Copy link
Owner

liyongning commented Jun 28, 2022

思维导图 + 文字 = 让你一次性学会正则表达式

为什么要学正则表达式

为什么要学习正则表达式?相信很多人没有思考过这个问题,每一次学习正则表达式都是因为 “需要”,这个需要可能是各种各样的原因,比如:日常工作、看源码、面试

  • 日常工作

    其实很多人认为不会正则也不影响日常工作,确实,很多需要正则的地方用普通的代码也可以实现,比如:将 2021-10-04 替换成 2021/10/04

    • 正则方式
    const str = '2021-10-04'
    const formatStr = str.replace(/-/g, '/')
    // 2021/10/04
    console.log(formatStr)
    • 普通代码
    const str = '2021-10-04'
    const strArr = str.split('-')
    const formatStr = `${strArr[0]}/${strArr[1]}/${strArr[2]}`
    // 2021/10/04
    console.log(formatStr)

    两种方式都能实现我们的需要,但是可以看到这个简单的例子用普通代码实现明显代码量大并且复杂;况且这只是一个最简单的例子,稍微复杂一点的需求,代码量和复杂度会有更明显的区别。

  • 看源码

    这里的源码不止是某个框架的源码,比如工作中你接手的某个业务项目,其中有一些正则表达式,你要理解代码在做什么,这时候你就需要去百度、google 现查、现学正则表达式的各种语法。

    看框架、库的源码,比如 Vue 框架的编译器的 解析 部分就有大量的正则表达式,如果看不懂这些正则表达式,那看这部分的源码就会非常难,基本可以说是看不懂。

  • 面试

    这个可以说是刚需,只要大家参加过一些面试,相信肯定被问到过不止一次,或是直接问,或通过一个问题来变相考察,当你通过普通代码实现了需求,面试官会问:你能通过正则表达式来实现吗?你瞬间就懵逼了,只能说不好意思,我不会,这时候面试评价就会降低。

所以不论是上面哪种情况,都值得大家花一些时间将正则表达式一次性彻底搞定,毕竟每次需要的时候现查、现学实在是太累、太浪费时间了,也会在一定程度上打击你的积极性。

导读

明白了为什么要学正则表达式,接下来就是花时间来学习了。本文通过 思维导图 + 文字 的形式让你一次性学会正则表达式,既有系统性学习知识的作用,也有字典的作用,所以大家学完可以收藏,将来有需要的时候可以通过文章或思维导图速查相关知识点。

文章主要分为两大部分:

  • 基础知识

    • 字符匹配

    • 位置匹配

    • 括号

  • 实战

    • 解读和书写正则表达式

    • 示例

基础知识

image.png

字符匹配

正则表达式是匹配模式,要不字符匹配,要不位置匹配,就这两种。

精确匹配

image.png

精确匹配很简单,就是匹配特定字符或字符串,比如/abc/就是匹配字符串abc,在react-router中就有精确匹配模式,指的就是匹配特定路由。

模糊匹配

image.png

模糊匹配和精确匹配相对,分为横向模糊匹配和纵向模糊匹配。

横向模糊匹配

比如 /ab{1,3}c/,匹配的字符串中可以含有 1 到 3 个 b,这里的横向可以简单理解为字符串变长了,可以匹配 abc、abbc、abbbc。

纵向模糊匹配

纵向模糊匹配借助了字符组([])的能力来实现,比如/a[123]b/,表示在第二个位置可以有 1、2、3 这三个的任意一个值,简单理解就是字符串长度不变,但是在指定位置可以有多种情况,可视化形式为:

image.png

字符组

image.png

定义

字符组是用[]包裹起来的一组字符,表示指定位置可以是这组字符中的任意一个字符,比如/a[123]b/第二个字符可以是 1,也可以是 2 亦或者是 3,也就是说可以匹配 a1b、a2b、a3b 这三个字符串。

范围表示

字符组可以是枚举形式,比如 /[abcdefg]/,也可以是简写形式/[a-g]/;如果枚举的是一个范围,则一般用简写形式的范围表示方法,常见表示如下:

image.png

排除字符组

如果字符组的第一个字符是^,则表示不匹配字符组中指定的所有字符,比如/[^a-c1-3]/,表示不匹配 a、b、c、1、2、3 这 6 个字符。

常见简写形式

我们在正则表达式中经常会看到一些特殊字符,这些特殊字符叫元字符。

学习正则表达式,元字符一定要记熟,否则你的正则表达式的能力永远停留在现用现查的阶段,或者至少做到看到元字符知道它是什么意思。熟能生巧。

image.png

量词

image.png

定义

量词也称重复,即重复匹配量词前面的表达式,比如横向模糊匹配中的/ab{1,3}c/,表示重复匹配 b 一到三次。

简写形式

常见的简写形式如下图所示,和上面提到的元字符一样,也要熟练使用。

image.png

贪婪 or 惰性匹配

贪婪匹配只要满足匹配条件,会尽可能的匹配多的数据,比如:

const ret = '123 1234 12345 123456'.match(/\d{2,5}/g)
// ['123', '1234', '12345', '12345']
console.log(ret)

惰性匹配则是满足匹配条件,只需匹配最少量的数据就行,比如:

const ret = '123 1234 12345 123456'.match(/\d{2,5}?/g)
// ['12', '12', '34', '12', '34', '12', '34', '56']
console.log(ret)

如果理解有困难,请认真分析和对比两个示例的结果。从示例中可以看到,量词后面加?就可以开启惰性匹配模式。

多选分支

image.png

定义

多选分支需要借助括号来实现,多个子模式满足任意一个就算匹配成功,且会停止匹配,就像运算一样,所以它默认就是惰性匹配模式,使用|(管道符)分隔各个模式。

示例
// 示例一,从 goodbye 中匹配 good
const ret1 = 'goodbye'.match(/good|goodbye/g)
// ['good']
console.log(ret)

// 示例二,从 goodbye 中匹配 goodbye
const ret2 = 'goodbye'.match(/goodbye|good/g)
// ['goodbye']
console.log(ret)

位置匹配

正则表达式是匹配模式,要不字符匹配,要不位置匹配,就这两种。

image.png

开头结尾

  • ^匹配字符串的开头位置,多行模式(m)下匹配每一行的开始位置

  • $匹配字符串的结束位置,多行模式(m)下匹配每一行的换行符(\n)前面的位置

边界

  • \b匹配单词边界,具体描述就是\w\W之间的位置,也包括\w^之间的位置,也包括\w$之间的位置,比如:

    const ret = "[JS] Lesson_01.mp4".replace(/\b/g, '#')
    // "[#JS#] #Lesson_01#.#mp4#"
    console.log(ret)
  • \B非单词边界,即\b之外的位置都是\B能匹配到的位置,比如:

    const ret = "[JS] Lesson_01.mp4".replace(/\B/g, '#')
    // "#[J#S]# L#e#s#s#o#n#_#0#1.m#p#4"
    console.log(ret)

查找

image.png

前向查找
  • /(?=p)/p 是一个子模式,该正则表示取 p 前面的位置,比如:

    const ret = 'hello'.replace(/(?=l)/g, '#')
    // 'he#l#lo
    console.log(ret)
  • /(?!p)//(?=p)/的反义词,匹配非 p 前面的位置,比如:

    const ret = 'hello'.replace(/(?!l)/g, '#')
    // '#h#ell#o'
    console.log(ret)
后向查找
  • /(?<=p)/p是一个子模式,该正则表示取 p 后面的位置,比如:

    const ret = 'hello'.replace(/(?<=l)/g, '#')
    // 'hel#l#o
    console.log(ret)
  • /(?<!p)//(?<=p)/的反义词,匹配非 p 后面的位置,比如:

    const ret = 'hello'.replace(/(?<!l)/g, '#')
    // '#h#e#llo#'
    console.log(ret)

括号

image.png

括号在正则表达式中的作用就是提供分组以便引用分组数据的能力;也可以理解为数学中括号的作用,将一组数据括起来,统一处理。

有两种引用数据的方式:

  • API 引用,比如RegExp.$1-99

  • 正则表达式引用,比如\1\99,当然只能引用前面已经出现的分组,所以又叫反向引用

分组结构

比如/a+/,只能匹配连续的 1 到 n 个 a,如果我要匹配连续的 1 到 n 个 ab 呢?这时候就需要用到括号的分组能力,将 ab 括起来看作一组,然后加上量词(统一处理),比如/(ab)+/

分支结构

分支结构其实就是前面讲到的多选分支,形如/(p1|p2)/,p1 和 p2 是两个正则表达式。比如/My name is (abc|cdef)/g 可以匹配 'My name is abc' 和 'My name is cdef'。

引用分组

有两种引用分组数据的方式,API 引用和反向引用。

使用 API 引用分组数据

这里以 Javascript 为例

  • match

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // ['2021-10-04', '2021', '10', '04', index: 0, input: '2021-10-04', groups: undefined]
    console.log(str.match(reg))
  • exec

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // ['2021-10-04', '2021', '10', '04', index: 0, input: '2021-10-04', groups: undefined]
    console.log(reg.exec(str))
  • $1~$99

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // 正则操作都行,比如 match、exec、test
    reg.test(str)
    // 2021
    console.log(RegExp.$1)
    // 10
    console.log(RegExp.$2)
    // 04
    console.log(RegExp.$3)
    const reg = /(\d{4})-(\d{2})-(\d{2})/g
    // 2021-10-04 => 2021/10/04
    const ret = '2021-10-04'.replace(reg, '$1/$2/$3')
    // 2021/10/04
    console.log(ret)
反向引用

在正则表达式中直接引用前面出现的分组。

注意:如果反向引用的分组不存在,比如 /(\d)\2/,这里的 \2 会被转义然后匹配转义后的字符

编写一个正则表达式可同时匹配如下字符串:2021-10-042021/10/042021.10.04

// 注意其中的 \1,它就是反向引用,在第二个分隔符位置通过反向引用来使用第一个分隔符
const reg = /\d{4}(-|\/|\.)\d{2}\1\d{2}/
// true
console.log(reg.test('2021-10-04'))
// true
console.log(reg.test('2021/10/04'))
// true
console.log(reg.test('2021.10.04'))

非捕获分组

之前的使用方式都会捕获匹配到的分组数据,并在之后使用,所以也叫捕获型分组。

假如只想使用括号的原始功能 —— 分组,并不想在 API 或者正则的反向引用中使用捕获到的数据,则可以使用非捕获分组。

非捕获分组的优点是提高性能、减少内存占用。

语法/(?:p)/,比如:/(?:ab)+/g

const reg = /(?:ab)+/g
reg.test('abc')
// 空
console.log(RegExp.$1)
// $1c,分组不存在,$1 被原样匹配了
console.log('abc'.replace(reg, '$1'))

实战

任何知识只学不练,没任何意义,所以接下来就进入实战部分。

衡量对知识的掌握程度有两个角度:读和写。不仅要能读懂已有的解决方案,需要的时候也能写出自己的解决方案。正则表达式也是一样,要能看懂别人写的正则,需要的时候自己也能写出合理的正则表达式。

解读正则表达式

解读正则表达式和解读数学中的算数表达式很相似,都需要根据操作符的优先级将表达式分成一个个的独立块。

正则表达式的操作符都体现在结构中,即由特殊字符和普通字符所代表的一个个特殊整体。根据基础知识部分我们知道正则表达式有:字符字面量、字符组、量词、锚字符(位置)、分组、选择分支、反向引用(\1-99)这 7 种结构。

其中涉及的操作符有(优先级从上到下由高到低):

  • 转义符\

  • 括号和方括号(...)(?:...)(?=...)(?!...)[...]

  • 量词{m}{m,n}{m,}?*+

  • 位置和序列^$\元字符一般字符

  • 分支结构|(管道符)

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

接下来我们分析一个正则表达式,作为实战

  • 阅读正则,根据操作符优先级,发现有括号,所以 (c|de*) 是一个整体

    • (c|de*)中,有量词*,所以e*是一个整体

    • 又由于管道符|的优先级最低,所以cd各为一个整体

  • 正则表达式就变成了这样:ab?(整体)+fg,经分析,发现这些字符中量词优先级最高,所以b?(整体)+分别各为一个整体

  • 剩下的普通字符afg各为整体

可视化表示方式

image.png

所以这个正则表达式的意思就是:匹配a (0或1个b) (c或(d 任意多个e))1到n个 fg,阅读时请忽略其中的空格,空格只是为了方便阅读理解。

比如该表达式可以匹配:abcfg、abdefg、acfg、adefg、adfg、accfg、adedefg 等。

所以阅读一个正则表达式最重要的就是要将一个复杂的正则表达式根据操作符优先级将其拆成一个个简单的整体

书写正则表达式

上面讲了“读”,接下来讲“写”。针对一个正则问题,如果构建一个合理的正则表达式呢?要遵循如下准则:

  • 前提

    • 能否使用正则:有些字符串根本无法用正则匹配

    • 是否有必要使用正则:能用字符串 API 解决的问题,就别用正则了,比如字符搜索

    • 是否有必要构建一个复杂的正则:比如常见的密码复杂度要求,长度 6-12 位,并且有数字、小写字母、大写字母组成,而且至少包括 2 种字符,这个问题如果用一个正则表达式来写是非常复杂的,但是如果将其拆成多个小的正则并辅以一定的代码来实现就很简单了

  • 平衡法则

    • 准确性,匹配预期字符,不匹配非预期字符

    • 可读性和可维护性

    • 效率

      正则匹配的过程为:

      1. 编译
      2. 设定起始位置
      3. 尝试匹配
      4. 匹配失败的话,从下一位开始继续第 3 步,这里会有一个回溯的过程
      5. 最终结果,匹配成功或失败
      • 使用具体的字符或字符组代替通配符.,来消除回溯

      • 如果不使用分组引用和反向引用时,使用非捕获型分组,因为捕获的分组数据需要额外的内存来存储

      • 提取分支公共部分,比如/^abc|^def/可以修改为/^(?:abc|def)/

      • 减少分支数量,比如/red|read/可以修改为/rea?d/

      会发现有些优化之后可读性会下降,所以这里就需要有个权衡

上述法则都是八股文,我觉得要写一个正则的关键在于分析、读懂需求,如果连需求都看不明白,又何谈写呢?更别说优化了,而且日常中需要优化的正则场景很少会碰见,除非说你写的正则,明显拖慢了程序的运行速度。

验证给定字符串是否为合法的固定电话

需求分析

常见的固定电话有如下格式:

  • 三位区号

    • 七位电话号码

      • 0108888888

      • 010-8888888

      • (010)8888888

    • 八位电话号码

      • 01088888888

      • 010-88888888

      • (010)88888888

  • 四位区号

    • 七位电话号码

      • 05518888888

      • 0551-8888888

      • (0551)8888888

    • 八位电话号码

      • 055188888888

      • 0551-88888888

      • (0551)88888888

上面列出了所有固定电话的格式(不考虑分机号和+86的情形)。经分析发现该需求可以用正则实现,而且也很有必要,因为单用字符串 API 匹配的话代码太过复杂了。

  • 经分析,发现区域以 0 开头,后面跟两位或三位数字,所以:/^0\d{2,3}/

  • 后面的电话则是以 7 位或 8 位数字结尾,所以:/\d{7,8}$/

  • 接下来将上面两个正则根据多种情况组合起来,这时候不要考虑平衡法则,先写出来再说

    • 区号和电话之间没有字符,比如: 0108888888,正则为:/^0\d{2,3}\d{7,8}$/

    • 区号和电话之间是连字符,比如:010-8888888,正则为:/^0\d{2,3}-\d{7,8}$/

    • 区号用括号括起来,比如:(010)8888888,正则位:/^\(0\d{2,3}\)\d{7,8}$/

    • 组合,三者之间为或的关系,所以用分支结构:

      /(^0\d{2,3}\d{7,8}$|^0\d{2,3}-\d{7,8}$|^\(0\d{2,3}\)\d{7,8}$)/

    • 可视化图

      image.png

  • 验证表达式是否正确

    const reg = /(^0\d{2,3}\d{7,8}$|^0\d{2,3}-\d{7,8}$|^\(0\d{2,3}\)\d{7,8}$)/
    // true
    console.log(reg.test('0108888888'))
    // true
    console.log(reg.test('01088888888'))
    // true
    console.log(reg.test('010-8888888'))
    // true
    console.log(reg.test('010-88888888'))
    // true
    console.log(reg.test('(010)8888888'))
    // true
    console.log(reg.test('(010)88888888'))
    // true
    console.log(reg.test('05518888888'))
    // true
    console.log(reg.test('055188888888'))
    // true
    console.log(reg.test('0551-8888888'))
    // true
    console.log(reg.test('0551-88888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // false
    console.log(reg.test('(010)8888'))
    // false
    console.log(reg.test('(0108888)'))
    // false
    console.log(reg.test('010-8888'))
  • 验证通过,说明正则写的没问题,接下来对正则表达式进行优化

    提取公共部分,减少冗余。分析会发现

    • 区号有两种情况:

      • 不带括号的🈶️可能会有-
      • 带括号的没有-
    • 区号后面的电话就一种情况,所以经过优化,上面很啰嗦的正则就变成了如下正则:

    • /^(0\d{2,3}-?|\(0\d{2,3}\))\d{7,8}$/

    • 优化后可视化图,看图说话,效果不是一般的好

      image.png

  • 验证优化之后的正则,防止优化出错

    const reg = /^(0\d{2,3}-?|\(0\d{2,3}\))\d{7,8}$/
    // true
    console.log(reg.test('0108888888'))
    // true
    console.log(reg.test('01088888888'))
    // true
    console.log(reg.test('010-8888888'))
    // true
    console.log(reg.test('010-88888888'))
    // true
    console.log(reg.test('(010)8888888'))
    // true
    console.log(reg.test('(010)88888888'))
    // true
    console.log(reg.test('05518888888'))
    // true
    console.log(reg.test('055188888888'))
    // true
    console.log(reg.test('0551-8888888'))
    // true
    console.log(reg.test('0551-88888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // false
    console.log(reg.test('(010)8888'))
    // false
    console.log(reg.test('(0108888)'))
    // false
    console.log(reg.test('010-8888'))
  • 验证通过,这个需求就完成了

这就是写一个正则表达式的过程,会发现需求分析阶段非常重要,这跟我们做项目一样,需求分析是第一阶段,只有这个阶段做好了,后面的过程才会顺,切记不要想着一步到位(看到问题直接就想答案,头疼)。

总结

到这里所有内容就结束了。正则表达式有太多的抽象符号,让人难以记忆,所以很多程序员的正则能力一直停留在现用现查的阶段,始终得不到精进,互联网上的资料也是五花八门。

本文也是自己学习正则的一个记录,通过思维导图 + 文章的形式加深理解和记忆,将来也可以作为一个字典,有哪个地方忘了可以快速查询。

如果文章有帮助到大家,欢迎点赞、收藏、关注。

本文内容参考自 JS 正则迷你书

思维导图,欢迎点赞


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注、 点赞、收藏和评论。

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

@liyongning liyongning added the 正则表达式 正则表达式 label Jun 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
正则表达式 正则表达式
Projects
None yet
Development

No branches or pull requests

1 participant