《精通正则表达式》精华摘要

发布于:2025-06-23 ⋅ 阅读:(20) ⋅ 点赞:(0)

《精通正则表达式》的摘要,偏向 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 ifor,表示多个分支,比如 /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:: 部分才全部匹配成功

  • 使用分支时,如果分支的顺序不影响匹配结果,将最可能的放在前面

  • 在合适的情况,使用固化分组、占有优先量词,避免回溯的发生

  • 进行必要的测试,不只测试可以匹配的情况,也要测试不能匹配的情况


网站公告

今日签到

点亮在社区的每一天
去签到