复杂正则表达式原来是纸老虎

最近在研究 HTML 解析,遇到一个正则表达式:

/([\w-:\*>]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-:]+)(?:([!*^$]?=)["']?(.*?)["']?)?\])?([\/, ]+)/is

它来匹配 CSS 选择器,比如说 div > ul

过去见过很多这样复杂的表达式,我都本能地退缩了。今天就来彻底搞明白它!男人,该对自己狠一点!

匹配 div > ul

我找了一个网站 https://regex101.com/ ,能在线匹配,还有解释。

虽然有了右边的说明,清楚了一些。但还是不清楚具体匹配起来是怎样。那就找多几个例子,看例子来分析。

具体出现这个正则表达式的代码是:

$matches = [];
preg_match_all($this->pattern, trim($selector).' ', $matches, PREG_SET_ORDER);

preg_match_all 的意思是来得到所有满足 pattern 的串。也即如果有

preg_match_all("abc", "abcdabc", $matches)

第一个参数是 pattern,第二个参数是要匹配的字符串,第三个参数是结果引用。那么运行后, matches 数组里,会装着两个 abc。

有了这个理解之后,那么上图的div > ul 我们只匹配了前面四个字符div >。regex101 不支持preg_match_all ?还好找了下,加个叫 g 的修饰符就行了:

说是加了 g 的话,会去匹配所有,而不是匹配到第一个就早早返回了。

加了之后。我们匹配到了div > ul

看右边。说第一个匹配中,即div,我们用第一个组的规则匹配的到div,然后用第7组的规则匹配的到了空格

我们接着来看第一组规则的解释:

首先在这一大串表达式中。第一个括号括起来的我们叫第一组规则。这是一个 Capturing group。括号自身不来匹配,而是用来分组。[] 来表示一个集合,里面的规则来说明这是一个怎样的字符集合。这个字符集合里有:

  • \w 就说大小写字母和 1 到 9 以及下划线都在这个字符集合里。
  • -: 直接表示这两个字符在这个字符集合里。
  • \* 则因为 * 在正则表达式中是保留字符,有它特殊的含义,所以要用 \ 来 escape,来表示这是一个 * 字符,而没有特殊含义。
  • > 则简单的表明 > 这个字符。

[\w-:\*>]*最后面的* 表示,前面的字符能出现 0 次或无数次,但要匹配尽可能多的次数。所以之所以能匹配div是因为,\w 匹配了div。之所以不再继续匹配后面的空格,是因为空格没有出现在[]中。那 capturing group 是什么意思呢?简单的,就是为了这组匹配能出现在结果 matches 数组中。还有相对应的 Non-capturing group,可以让一组匹配不出现在结果中。Non-capturing group 的语法是 (?:)。也即上面的([\w-:\*>]*)。 不需要这组结果的话,可以记为 (?:[\w-:\*>]*)

那问题来了,不出现在结果中,那不用括号不就行了?括号是为了分组,分组还是很有意义的。可以参考 《What is a non capturing group? (?:) -StackOverflow》

接着讲完了div 满足第一组规则后,讲下空格 为什么满足了第7组的规则。

很简单 [\/, ] 意思是说匹配这四个字符的任意一个字符,+ 是说前面的匹配出现一次或无数次,次数要尽可能多。所以因为这四个字符有空格,就匹配了我们的空格。又因为div之后下一个的字符是>,所以不再满足第 7 组的规则,不继续匹配了。

嗯搞明白了 div 的匹配。那为什么第 2 到 6 组的规则没有来去匹配这里面的空格呢,而把空格留给了第 7 组?

看看第二部分的解释:

首先刚刚说过了(?:)来表示这是一个 Non-Capturing Group。最后面的?表示,前面的匹配可以出现0次或1次,但次数要尽可能多。所以就是说这组匹配可能根本没有。嗯把这些最外层的修复符去掉之后,就剩下了\#([\w-]+)|\.([\w-]+),中间有个|,表示或的意思,满足两个匹配的其中一个即可。比如表达式a|b可以用来匹配ab,但不能匹配c。先看前面一半,\#([\w-]+),先是直接匹配的#字符。我们之前说的空格自然不是 # 字符,所以不满足这个匹配的,这个匹配的要求#字符后面跟着其它的字符。再看后一半,\.([\w-]+),是.,也不满足。

可见 2 到 6 组都可能因为这样的原因,空格不是这些组要求的开头字符,所以不满足了,又因为这些组有个修饰符,所以不满足也可以,因此跳到了第 7 组。

好,接着div > ul 后面的 >,还是一样:

第一组规则([\w-:\*>]*)匹配了 >,第 7 组规则([\/, ]+)匹配了空格。接着uldiv一样。

匹配 #answer-4185009 > table > tbody > td.answercell > div > pre

接着来一个稍微复杂一点的选择器#answer-4185009 > table > tbody > td.answercell > div > pre(你也可以打开 https://regex101.com/ 把这个粘贴到那里去来把玩):

是从 Chrome 里复制粘贴的:

看第一个匹配:

因为第一组的规则([\w-:\*>]*)[]里面的字符集没有一个能匹配的#,接着因为最后面的*支持匹配 0 次或无数次,所以这里是 0 次。接着第二组规则的描述是:

上面有分析。直接来看 | 前的 \#([\w-]+)\## 匹配了,接着[\w-]+#answer-4185009 匹配了。后面的是\.([\w-]+),可想而知,如果是.answer-4185009 就会应用这个匹配。

接着来看td.answercell 这个匹配,

第一组的规则([\w-:\*>]*)匹配了td,接着第二大部分的(?:\#([\w-]+)|\.([\w-]+))?的后面部分,即\.([\w-]+) ,因为.开头,而匹配了.answercell

这个选择器的分析也到此结束了。

匹配 a[href="http://google.com/"]

接着我们来匹配选择器a[href="http://google.com/"]

看看第三大块:

第三大块的表达式为(?:\[@?(!?[\w-:]+)(?:([!*^$]?=)["']?(.*?)["']?)?\])?,首先最外层(?:)的表示这是一个 Non-Capturing Group,最后面的 ? 表示这整个大的可以匹配 0 次或 1 次。所以去掉之后为 \[@?(!?[\w-:]+)(?:([!*^$]?=)["']?(.*?)["']?)?\]\[ 匹配 [ 字符。@?表示@字符可有可无。那么接下来的一组(!?[\w-:]+)!可有可无,[\w:]+ 则匹配了href。接着的一组(?:([!*^$]?=)["']?(.*?)["']?),这组是 Non-Capturing Group,所以去掉最外层之后是([!*^$]?=)["']?(.*?)["']?。这里的([!*^$]?=)[!*^$]?,表示匹配0个或1个[]里的字符串。接着=直接匹配。接着["']?(.*?)["']?,来匹配"http://google.com/"["']?表示匹配"'或者两个都不匹配,所以去掉了这个最外层是,(.*?)来匹配http://google.com/,这里有个新的修饰符*?,表示尽可能少的匹配,也就是说,如果有"',要留给后面的表达式["']?来匹配。所以不至于匹配到http://google.com/", 而是只匹配了 http://google.com/。所以整个选择器a[href="http://google.com/"]就匹配结束了。

总结

终于搞懂了!再让我们理清一次,首先整个复杂的表达式,([\w-:\*>]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-:]+)(?:([!*^$]?=)["']?(.*?)["']?)?\])?([\/, ]+) 由四大部分组成:

  • ([\w-:\*>]*)
  • (?:\#([\w-]+)|\.([\w-]+))?
  • (?:\[@?(!?[\w-:]+)(?:([!*^$]?=)["']?(.*?)["']?)?\])?
  • ([\/, ]+)

最复杂的第三部分又由这几个部分组成:

  • \[
  • (!?[\w-:]+)
  • (?:([!*^$]?=)["']?(.*?)["']?)?
  • \]

所以这些足够小的部分都可以准个击破。然后我们找多一点例子,看看每个例子是怎么匹配的,同时结合 https://regex101.com/ 的解释来分析。就把这个看似复杂的正则表达式搞懂了,原来它是个纸老虎!

results matching ""

    No results matching ""