《精通正则表达式》的摘要,偏向 JavaScript 中的正则引擎去理解。
元字符
具有特殊含义的字符,表示某个规则,而不是其原来普通文本的含义。
比如:
.
表示除换行符外的任意字符。\d
表示数字(对于某些引擎来说,可能匹配除阿拉伯数字 0-9 外的其他数字)
特殊元字符
\s
通常表示的是空格、制表符、换行符、回车符,部分实现可能还视为 Unicode 空白字符,而不仅仅是空格\p{category}
表示匹配一个 Unicode 类别\P{category}
表示匹配一个非指定 Unicode 类别的字符- …
编码的作用
不同语言内置的正则表达式可能支持了不同的字符编码形式,比如部分语言支持单字节字符,部分语言支持多字节字符(比如 Unicode)。
这里的关键在于,如果语言内置的正则表达式以单字节字符进行检索,那么 /^.$/.test('中')
就无法成功,因为 ‘中’ 是一个多字节 Unicode 字符。
flags
g
:全局搜索i
:忽略大小写m
:多行模式,使^
、$
作用于每行,而不是整个字符串s
:单行模式,使.
元字符能够匹配换行符- …
局部 flags
部分语言中的正则表达式引擎支持局部 flags,即与 /(?flags:pattern)/
类似的写法,可以覆盖全局标识,只对局部生效,JavaScript 规范 同样支持
(?i:ignore)
对表达式中 ignore 部分进行不区分大小写匹配(?i-s:ignore)
对表达式中 ignore 部分进行不区分大小写匹配,同时禁用单行模式(?-is:ignore)
对表达式中 ignore 部分禁用不区分大小写与单行模式匹配
单词分界符
使用 \b
设置单词分界,比如 /\bcat\b/
可以匹配 ‘cat’,却不会匹配 ‘bigcat’,\b
本身不匹配任何字符,只匹配位置,JavaScript 规范。
可以只匹配以某个单词开头的字符,比如 /\bcat/
匹配以 ‘cat’ 开头的单词,而不只是 ‘cat’ 这个单词。同理,也可以只匹配以某个单词结尾的字符。
/\bcat\b/
可以理解为,匹配到某个单词开头的位置,然后是 ‘cat’ 这个单词,最后是单词结尾的位置。
dot
大部分正则表达式引擎中,.
匹配除换行符外的任意字符,但部分语言通过开启 DotAll
模式可以使 dot
匹配换行符。JavaScript 中,通过 s
flags 启用,s
flags 将整串文本视为单行。
默认情况下,.
无法匹配多字节 Unicode 字符,但部分语言可以通过启用 Unicode
模式使 dot
匹配多字节 Unicode 字符。JavaScript 中,通过 u
flags 启用。
一个例子是:部分字符可能由高代理、低代理组合为一个有效字符,但正则表达式引擎将其视为两个字符,在启用
Unicode
模式后,正则标签式引擎将其视为正确的单个字符。
字符类
正则表达式中的微型语言处理器,语法为 /[character set]/
,用于匹配内部多个字符列表中的任意一个,此时在中括号 []
内的所有元字符失效。
字符类中包含自己的元字符:
-
连字符,如果在字符类的首字符位置,则表示普通-
文本,否则表示一个范围,比如/a-z/
,匹配的是小写 a 到 小写 z 的 26 个英文字母。^
排除型字符类,如果在字符类的首字符位置,则表示这是一个排除型字符类,表示当前位置需要匹配除字符列表外的其他任意字符,语法/[^a]/
,表示当前位置只匹配除 a 外的其他任意字符。通过 匹配除某个字符以外的其他任意字符 这种概念,可以在无需启用
s
flags 的情况下匹配换行符
量词
可选项
?
前面的规则可以出现一次,也可以完全不存在重复
*
前面的规则可以出现任意多次,也可以完全不存在*
量词对于前面的规则永远匹配成功,因为可以出现,也可以不出现举例:对于空字符串
/.*/
仍然可以匹配成功重复
+
前面的规则匹配一次或一次以上,必须匹配一次区间
{min,max}
匹配前面的规则最小次数到最大次数,也可以直接{min}
或{min,}
表示最小次数,最大次数无上限。
反向引用
反向引用已出现的捕获组,格式为 /(pattern)\1/
,其中 (pattern)
是捕获组,\1
是一个反向引用,表示当前位置匹配的是 第一个捕获组 中 已确认的内容
比如:/([ab])\1/
匹配的是 ‘aa’ 或 ‘bb’,当捕获组中匹配的内容被确认时,反向引用也必须匹配相同的内容;所以反向引用不是将前面的捕获组规则再重复一次(这可以通过量词实现,所以它没有意义)。
反向引用还可以通过命名捕获组的名称进行引用,比如 (?<name>pattern)
就是一个命名捕获组,此时可以通过 \k<name>
反向引用它 /(?<name>pattern)\k<name>/
。
()
相关
捕获组:捕获
/(pattern)/
中的表达式,可以通过$1
、$2
来引用捕获组内容,$
后的数字表示第几个捕获组非捕获组:
/(?:pattern)/
,只圈定表达式,不进行捕获,无法使用$1
来引用,因为不记录,所以效率较高命名捕获组:
/(?<name>pattern)/
,对当前捕获组进行命名,反向引用中可通过\k<name>
进行引用局部修饰符:
/(?flags:pattern)/
环视
顺序环视/前向断言:语法
/(?=pattern)/
,如果pattern
匹配则成功,否则失败;语法/(?!pattern)/
, 如果pattern
不匹配则成功,否则失败。它从左向右查看,不会改变当前匹配位置。/(?=pattern)/
如果当前位置右侧满足pattern
则成功/(?!pattern)/
如果当前位置右侧不满足pattern
则成功逆序环视/后向断言:语法
/(?<=pattern)/
,如果pattern
匹配则成功,否则失败;语法/(?<!pattern)/
,如果pattern
不匹配则成功,否则失败。它从右向左看,不会改变当前匹配位置。/(?<=pattern)/
如果当前位置左侧满足pattern
则成功/(?<!pattern)/
如果当前位置左侧不满足pattern
则成功
环视选择一个位置,不匹配任何文本,环视匹配的结果不会出现在最终的结果中。
不改变当前位置,那么后续的
pattern
可以继续在当前位置进行匹配,比如:'Jeffrey'.match(/(?=Jeffrey)Jeff/)
虽然顺序环视匹配到了
Jeffrey
,但由于它不改变位置,所以Jeff
依然可以匹配成功。此正则匹配的是
Jeffrey
中的Jeff
,如果修改为Jefferson.match(/(?=Jeffrey)Jeff/)
是无法匹配的。要深刻理解这一点,即
/(?=Jeffrey)/
成功后,才能继续Jeff
的匹配。固化分组:
/(?>pattenr)/
固化分组匹配成功后,匹配的内容固化下来,不会改变,也就是后续即使引擎需要,也不会交还任何内容,即固化分组的回溯分支被取消。
锚定到开始、结束
^
锚定后面紧跟着的pattern
到行开头的位置$
锚定前面紧跟着的pattern
到行结尾的位置
它们都表示一个位置;大部分实现中,/^end$/.test('\n\nend\n\n')
可以匹配成功,即如果忽略首尾换行符可以匹配成功,则忽略。
匹配位置的特殊作用
诸如 ^
、$
、\b
、(?=)
、(?!)
、(?<=)
、(?<!)
的这类特殊元字符、表达式,都只匹配位置,不匹配内容,它们的一个特殊概念是可以将 pattern
锚定到指定位置,它们不会改变当前指向的匹配位置。
多选结构
|
在正常表达式中类似编程语言中的 else if
或 or
,表示多个分支,比如 /pattern1|pattern2/
如果字符串能够满足任意一个分支则匹配成功。
此外,部分语言支持空的多选分支,/(that|there|)/
,最后一个分支是一个空的 pattern
,在任何情况下都能匹配,类型于 /(that|there)?/
。
多选分支的效率一般较低,当没有匹配时,会尝试所有分支。
贪婪匹配与非贪婪匹配
匹配优先量词(
?
、+
、*
、{min,max}
)采用匹配优先的策略,尽可能的匹配更多的内容对于引擎来说,先尝试匹配,如果匹配失败,则跳过(对于
+
来说,至少匹配一次)在匹配优先量词后添加
?
则成为 忽略匹配优先量词,此情况下会匹配尽可能少的内容对于引擎来说,先跳过匹配,如果后续匹配失败,则继续量词前的规则匹配
在匹配优先量词后添加
+
称为占有优先量词,匹配时,不保存分支,当前 pattern、子 pattern 始终保存已匹配的内容,不交还内容
匹配规则
从左到右
从字符串左侧向右侧检索,从第一个字符开始,应用正则表达式的第一个规则,如果成功,保存匹配位置,继续第 n 个字符应用第二个规则(如果存在)。
相反,如果失败,则跳转到下一个字符,应用正则表达式的第一个规则,以此类推,直到所有正则表达式匹配成功。
匹配优先
正则表达式是匹配优先的,表达式内靠左的规则可能匹配了过多的文本,如果不进行处理可能导致后续的规则匹配失败;所以正则表达式使用了一种 交还 机制。
如果后续的规则无法匹配成功,需要上一个规则交还一部分的文本,成功则继续匹配,失败则让上一个规则继续交还一部分文本,直到当前规则成功或没有更多可交还的文本。
此外,虽然正则表达式为了完整匹配的成功,可能强迫部分规则交还一些文本,但最终还是靠左侧的规则占据了更多的文本,当后续规则满足要求时,前面的规则不会再交还任何文本。
比如:
const matched = 'regex 2009'.match(/.*(?<number>\d+)/)
console.log(matched.groups.number) // 9
这段正则的 number
捕获组捕获的实际内容是 ‘9’ 而不是 ‘2009’。
匹配分支与回溯
理解正则表达式的关键在于,什么时候正则引擎会保存分支,什么时候正则引擎会回溯到之前保存的分支。
正则引擎始终考虑全局,完整匹配成功才是重要的。
关于匹配效率,应该仅可能减少回溯的发生,确保较少的分支。
多选结构
对于传统 NFA 正则引擎来说,多选分支是从左到右尝试匹配的,这可以看作一种优先级。
推荐
只在合适的情况下使用匹配优先量词
*
、+
等,避免匹配了过多的文本,造成后续规则匹配失败,从而导致回溯的发生减少回溯,表达式只匹配确切的文本,当遇到不想要的文本时,尽快告诉引擎匹配失败
使用排除法,比如
([^:]*):
而不是.*:
对于字符串 “name: yuanyxh; blog: yuanyxh.com” 来说
第一个表达式的
([^:]*)
部分在第一次遇到:
时会失败,然后由后续的:
匹配第二个表达式会一直匹配到行结尾前,然后进行
:
的匹配,发现匹配失败,然后回溯(交还一个字符) -> 失败 -> 回溯 -> 失败,直到到底blog:
的:
部分才全部匹配成功使用分支时,如果分支的顺序不影响匹配结果,将最可能的放在前面
在合适的情况,使用固化分组、占有优先量词,避免回溯的发生
进行必要的测试,不只测试可以匹配的情况,也要测试不能匹配的情况