学习它有什么用

  • 终端中的各种工具都会用到, 比如: grep ack awk find 等等
  • IDE 中的复杂的搜索替换
  • 日常干活中的灵活使用, 邮箱校验, 手机校验, router 匹配等等
  • 编译器前端, 词法分析几乎都是正则实现(正则的底层实现也是基于 DFA NFA, 它们是可以相互转换的)

预警

首先, 这里推荐俩个网站: http://regex101.com/, https://regexper.com/ 可以将你的正则可视化, 并且有释义.

regular expression 描述文本的有规律的表达式.

TIP

通过这个分享不可能让你立刻变成正则高手, 要么就理解概念多练, 要么就直接自己实现一个正则引擎, 别无他路.

碰到正则相关的问题的时候, 尽量通过自己的思考完成, 而不是通过 ctrl-c ctrl-v(即使你事后也看明白了为什么这样写)

下面的介绍都是基于 js 中的正则表达式(不同语言的正则功能可能会缺失, js 相对来说还是比较强的)

元字符

语法 含义
. 匹配除换行符以外的任意字符
\w 匹配字母或者数字
\s 匹配任意的空白符
\d 匹配任意的数字
\b 匹配单词的开始或者结束
^ 匹配字符串的开始
$ 匹配字符串的结束

其他不介绍, 大家应该都很熟悉, 介绍一下 \b, 它是匹配单词的开始或者结束, 比如:

\b\w{7}\b 这个就是匹配刚好 六个字母/数字 的单词.

const str = 'Test case for regular expression';
const reg = /\b\w{7}\b/;
str.match(reg); // regular

转义

比如我们就想匹配 . 这个字符本身, 不想它被当做元字符来对待, 怎么办?
答案是: \., 类似就有 \*, \\ 等.

元字符的反义

有一些字符跟上面的元字符的含义是正好相反的, 有以下:

语法 含义
\W 匹配任意不是字母和数字的字符
\S 匹配任意不是空白符的字符
\D 匹配任意非数字的字符
\B 匹配不是单词开头或结束的位置
[^x] 匹配除了x以外的任意字符
[^aeiou] 匹配除了aeiou这几个字母以外的任意字符

比如在将一些老的代码迁移到 def 发布的时候, 需要替换掉代码中的 httphttps, 你可以这样搜索:http[^s]//, 那么这个只会匹配到 http://, 而不会匹配 https://

分组

我们都知道可以使用 * 以及 {m,n} 来重复提取单个字符, 比如 f* \d* \w{1,3}, 那么如果想提取多个字符应该怎么办?
这里就需要用到分组的概念: 用小括号把子表达式包起来, 比如: (\d{1,3}\.){3}\d{1,3} 这个正则, 它表示匹配 1-3 个数字一个点 这个分组三次, 最后再加上一个 1-3位的数字, 这其实是个 IP 匹配的正则, 不过它并不精确, 因为 ip 地址不能大于 255.

引用分组

比如我们想匹配 html tag 的时候, 经常需要在闭合标签中引用开标签的值, 比如简写为 <([a-z]+)><\/\1>, 这里的 \1 就代表了第一个分组, 也就是 ([a-z]+) 的匹配结果.

TIP

分组从 1 开始计数, 而不是 0

还可以以指定的命名来分组, 语法如下: (?<name>exp), 引用自定义命名分组的方式也不一样, 语法是: \k<name>
那上面的正则就可以这样写: <(?<tag>[a-z]+)><\/\k<tag>>

不捕获分组

假如我们想匹配一个分组, 但是不想在结果中捕获它, 那么可以使用 ?: 关键字, 比如上面的正则, 如果改成 <(?:[a-z]+)><\/\1>, 那么就匹配不到了, 因为开标签不在结果中被捕获了.

贪婪与懒惰

当正则匹配重复行为(比如 * {} 这些关键字,)的时候, 通常默认的行为是尽可能多的匹配, 比如 a*b 匹配 aabab 的话, 它会匹配最长的 以a 开头, 以 b 结束的字符串, 结果是 aabab. 这就是贪婪匹配.

与之对应的就是懒惰匹配, 它是在匹配成功的前提下尽可能少的匹配, 只需要在后面加上 ?, 比如 a*?b, 那么匹配结果就是 aab

零宽断言(也被称作环视)

上面提到的一些元字符是用于指定位置的, 比如 ^ $ \b 这些, 他们叫做: 零宽断言, 除此之外还有一些零宽断言, 如下:

语法 含义 术语
(?=exp) 匹配exp前面的位置 零宽先行断言
(?<=exp) 匹配exp后面的位置 零宽后行断言
(?!exp) 匹配后面跟的不是exp的位置 零宽负向先行断言
(?<!exp) 匹配前面不是exp的位置 零宽负向后行断言

(?=exp) 也叫零宽先行断言,它匹配文本中的某些位置,这些位置的后面能匹配给定的后缀 exp

简单的例子:

\b\w+(?=ing\b) 这个正则就是匹配以 ing 结尾的单词的前面部分(不包括 ing 的部分)

const str = 'I am singing while you are dancing.'
const reg = /\b\w+(?=ing\b)/g;
str.match(reg) // [sing, danc]

整数千分位格式化:

const reg = /(\d)(?=(?:\d{3})+$)/g;
const n = '12345678';
n.replace(reg, '$1,'); // 12,345,678

(?<=exp) 也叫零宽后行断言,它匹配文本中的某些位置,这些位置的前面能给定的前缀匹配 exp

例子:

(?<=\bre)\w+\b 这个正则就是匹配以 re 开头的单词的后面部分(不包括 re 的部分)

const str = 'reading a book';
const reg = /(?<=\bre)\w+\b/g;
str.match(reg); // ading

匹配以空白符间隔的数字(不包括空白符): (?<=\s)\d+(?=\s)

(?!exp) 也叫零宽负向先行断言,只会匹配后缀 exp 不存在的位置

例子:

\d{3}(?!\d) 匹配三位数字,而且这三位数字的后面不能是数字

前面也有类似的功能字符: [^], 比如 http[^s]//

而零宽负向先行断言不会消费任何字符, 只是做匹配, 改写一下这个正则, 如下: http(?!s)://

提示

vscode 中需要开启 search.usePCRE2 设置, 就能在搜索中启用断言功能

发现区别了吗? 一个匹配 :, 一个不匹配 :, 因为 [^] 总是消费一个字符, 它用来消费了 :, 而改写后的只是匹配位置, 不消费任何字符.

(?<!exp) 零宽负向后行断言来查找前缀 exp 不存在的位置

例子:

(?<![A-Za-z])\d{7} 匹配前面不是字母的七位数字

稍微复杂点的例子:

(?<=<(\w+)>).*(?=<\/\1>) 匹配不包含任何 attribute 的 html tag

案例

匹配文件中的 TODO 事项

function getTagRegex(tags) {
	// "// TODO (jack) add some comments /This is comment"
	return (
		'\\s*' + // 注释后的空格
		'(' +
		tags.join('|') +
		')' + // tags: TODO
		'(?!\\w)' + // tag 后面不允许跟着别的字符, 以准确识别 tag
		'\\s*' + // tag 后面可以跟空格
		'(?:\\(([^)]*)\\))?' + // 匹配括号中的负责人: @jack
		'\\s*:?' + // 匹配 :
		'\\s*(.*?)' + // : 后面匹配 title: add some comments
		'(?:\\s+/([^\\s]+)\\s*)?' // '/' 后面的具体内容: This is comment
	);
}

性能

不恰当的使用正则会引起性能问题. 比如这个正则 ([\s\S]*)*, 假如我们使用字符串 a_1a_2...a_n 来匹配它, 那么这个问题就等于 a_1a_2...a_n 有多少种划分方式, 结果是 2^(n-1), 是指数级的复杂度.

js 不支持的正则引擎

平衡组. 他可以用来匹配可嵌套的递归格式, 语法如下:

  • (?'group') 压栈
  • (?'-group') 弹栈, 如果堆栈为空, 匹配失败
  • (?(group)yes|no) 如果堆栈上存在以名为 group 的捕获内容的话,继续匹配 yes 部分的表达式,否则继续匹配 no 部分

js 中的 api

涉及到常见的 match replace split test exec

这里说一下 exec, 因为 exec 有特殊的一个属性 lastIndex, 它代表了上一次匹配的文本位置, 这个特性在做 parser 的时候会经常用到.