我第一次接触XPath是在2007年,但最近才开始对它产生兴趣。以前在大多数情况下我都会尽量避免使用它,而当我不得不尝试使用它时,每次都以失败告终。那时XPath对我来说并没有什么意义。
但是后来我遇到了一个特殊的解析问题(对CSS选择器来说过于复杂,而用手工代码的话又过于简单),于是我决定再尝试一次XPath。令我感到惊喜的是,这的确行得通,而且很有用。
以下是我的亲身经历
我遇到的问题
假设你管理一个歌词网站,为了维持一致的阅读体验,你要收集每行歌词的第一个单词。如果歌词使用纯文本格式保存,那么可以直接用下面的代码来实现。
- lyrics.gsub!(/^./) { |character| character.upcase }
但是如果歌词被保存肯html格式就没有这么简单了,因为dom结构本身并没有”行”的概念,所以没有办法使用一个简单的正则表达式来识别行。
所以我们要做的第一件事情是定义什么是dom结构中的“行的起点”,下面是两个简单的例子:
- <p>标签中第一个文本节点
- <br>后面的第一个文本节点
就像下面这样:
- <p>This is the beginning of a line.This is too.</p>
但是除此之外我们可能还要处理嵌套的行内元素:
- <p><em>This</em> is the beginning of a line. <strong>This is not.</strong></p>
常规的解决方案
我想到的第一个解决方法是用Ruby写一个方法来扫描dom中所有相关的部分并递归找出所有符合条件的节点。其中用到了几个轻量级的css选择器:
- def each_new_line(document)
- document.css('p').each { |p| yield first_text_node(p) }
- document.css('br').each { |br| yield first_text_node(br.next) }
- end
- def first_text_node(node)
- if node.nil? then nil
- elsif node.text? then node
- elsif node.children.any? then first_text_node(node.children.first)
- end
- end
这是一个比较合理的解决方案,但是11行的代码似乎有点儿长。有点儿杀鸡用牛刀的感觉,仅仅为了获得dom的节点而用上Ruby的迭代器和条件语句感觉有点儿犯不上。应该有更好的办法吧?
终于说到正题了(XPath)
XPath有一下几个原因容易让人困惑。第一点是网上几乎没有可以参考的东西(W3Schools!就不用想了)。RFC已经是我找到的最好的文档了。
第二点是XPath看上去有点儿像CSS。方法名里就有“path”,所以我总是假设XPath的表达式中的 / 和CSS选择器中的 > 是一个意思。
- document.xpath('//p/em/a') == document.css('p > em > a')
其实,XPath表达式包含了许多简写,如果我们想要弄清楚上面代码运行时究竟发生了什么就必须要弄清楚这些简写。下面是用全拼写出来的相同的表达式:
- /descendant-or-self::node()/child::p/child::em/child::a/
这个XPath表达式和上面的CSS选择器的作用是一样的,但并不像我之前假设的那样。一个XPath表达式是由一个或多个被 / 分割的定位步(location steps)组成。表达式中的第一个 / 代表了文档(document)的根节点。每个定位步都表明了已经被匹配的节点并传达一下三条信息:
我想从当前的位置移动到哪?
答案是轴(Axis),是可选的。默认的轴是child,表示“当前被选中节点的所有子节点”。在上面的例子中,descendant-or-self是第一个定位部的轴,表示“所有当前被选中的节点和他们所有的子节点”。大部分XPath规范中定义的轴都有像“descendant-or-self”这样的语义化的名字。
我想要选择什么类型的节点?
选择的内容是由节点测试来指定的,这也是每个定位步中不可缺少的部分。在我们之前的例子中,node()匹配的是全部类型;text()匹配到的是文本节点;element()只能匹配到元素,并必须指明节点名称(像p,em等),节点名称必填。
可能增加额外的过滤器吗?
也许我们只想选择当前所有节点的第一个子元素或只想选则有href属性的<a>标签。对于此类断言(assertion),我们可以使用谓词(predicates)根据额外的遍历树(additional tree traversals)来过滤出符合条件的节点。这样我们就可以根据这些节点的属性(children, parents, or siblings)来过滤出符合条件的节点。
我们的例子中没有谓词,现在让我们来加一个只匹配有href属性的<a>标签:
- /descendant-or-self::node()/child::p/child::em/child::a[attribute::href]
虽然谓词看上去很像一个括号中的定位步,但是谓词中的“节点测试(node test)”部分有比定位步中的节点测试更多的功能。
#p#
换一个角度来看XPath
与一个增强型的CSS选择器相比,XPath与JQuery的便利更相似。例如,我们可以把之前的XPath表达式换成JQuery的形式:
- $(document).find('*').
- children('p').
- children('em').
- children('a').filter('[href]')
上面的代码中,我们用到的JQuery的方法与轴的作用是一样的:
- .children()相当于轴中的child,.find()相当于descendant。
JQuery方法中的选择器相当于XPath中的节点测试,只可惜jQuery不允许选择文本节点。
jQuery中的.filter()方法相当于XPath中的谓词,.children(‘em’)的作用是匹配所有匹配到的<p>标签中的所有<em>子元素。这样看来,XPah要比jQuery强大得多。
让我们回到识别行首的问题
现在我们对XPath的工作原理已经有了深入的了解,下面来用它解决之前提到的问题。首先我们先把问题简化一下,只寻找每段的第一个文本节点:
- /descendant-or-self::node()/child::p/child::text()[position()=1]
上面的代码的作用依次是:
- 1.寻找文档中的所有节点
- 2.寻找这些节点的所有为<p>的子节点
- 3.寻找这些<p>的文本子节点
- 4.只保留这些节点中符合条件的第一个元素
注意position() function 在代码中表示的是每个<p>中的第一个文本子节点而不是整个文档中的第一个<p>的文本子节点。
接下来,为了找到<p>中被嵌套得很深的文本节点,我们把child换成descendant
- /descendant-or-self::node()/child::p/descendant::text()[position()=1]
接下来是识别换行的问题,首先我们给这一长串代码折下行(因为太长了),XPath是允许这样做的。加入换行的识别后,代码如下:
- /descendant-or-self::node()/
- child::br/
- following-sibling::node()[position=1]/
- descendant-or-self::text()[position()=1]
每一行代码的意思分别是:
- 1.找到所有节点
- 2.找到到这些节点的<br>子节点
- 3.找到这些<br>的下一个同级节点
- 4.如果上面取到的不是文本节点,则取它们的子节点中的第一个文本节点
这样一来我们就可以同时选出<p>中和<br>后的新的一行。下面我们以上的代码合并成一个表达式:
- (/descendant-or-self::node()/child::p|
- /descendant-or-self::node()/child::br/following-sibling::node()[position=1])/
- descendant-or-self::text()[position()=1]
最后我们把简写替换进去:
- (//p|//br/following-sibling::node()[position=1])/
- descendant-or-self::text()[position=1]
这样我们就把一个复杂的概念用一个简单的表达式表示出来了。如果我们想加入更多的对行的操作,只需要往实现匹配的代码中加入更多的元素名称就可以了。
我们究竟能从中获得什么?
既然我们能用相对易懂的Ruby来实现为什么还要选择XPath呢?
大多数情况下,Ruby是用来编写高水平代码的,例如商业逻辑,整合应用组件,描述复杂的领域模型。从中可以看出最好的Ruby代码是用来描述意图而非用于实现。所以用Ruby来做一些低水平或与应用无关的事情(遍历dom树来找指定属性的节点)让人蛋疼。
XPath的其中一个优势是它的速度:XPath的遍历是通过libxml实现的,而原生代码的速度是非常快的。对于我上面举的例子,与Ruby的实现相比,XPath实际上要慢得多。我猜导致这个情况的原因是对于<br>标签的下一个元素的查找。因为在这个动作中实际上是先筛选出了<br>后面的所有与之同级的元素然后才过滤出其中的第一个。
所以XPath快慢与否取决于你的使用方式,但是上手有点儿难。这是一个专门用来让你使用简洁的惯用表达式来遍历dom的工具。