前言
先纠正一个前提:PHP 里没有名为 xpath() 的全局函数 。你看到的 xpath(),实际是两个类身上的方法:
SimpleXMLElement::xpath()------SimpleXML 扩展,返回SimpleXMLElement对象的数组;DOMXPath::query()与DOMXPath::evaluate()------DOM 扩展,需要通过new DOMXPath($dom)创建对象后调用。
顺带说清楚另一个常见混淆:还有一类叫做 xpath 的第三方库方法(例如某模板引擎里的选择器),那是它们自己实现的,和 PHP 内置的 SimpleXML、DOM 无关,语法也不保证符合 XPath 规范。本文只讲内置的两套 API。
掌握 XPath 的价值在于:当你需要「选出第二层里带某个属性的所有节点」这类结构化查询时,用 XPath 一行就能表达,而手写嵌套的 foreach 又长又容易错。但 XPath 有几个非常反直觉的规则(尤其是命名空间),不知道就会卡住半天。本文把这些都摊开讲。
一、用 SimpleXML 起步:最省事的路径
php
<?php // 适用于 PHP 8.0+,需要 ext-simplexml(PHP 默认启用)
$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<book id="b1" lang="zh"><title>PHP 入门</title><price>59</price></book>
<book id="b2" lang="en"><title>PHP Advanced</title><price>89</price></book>
<book id="b3" lang="zh"><title>PHP 进阶</title><price>79</price></book>
</catalog>
XML;
$sxe = simplexml_load_string($xml, SimpleXMLElement::class, LIBXML_NONET);
if ($sxe === false) {
exit("XML 解析失败\n");
}
// 1) 选所有 title 元素
foreach ($sxe->xpath('//book/title') as $title) {
echo (string) $title, "\n";
}
// 2) 用谓词按属性过滤
foreach ($sxe->xpath('//book[@lang="zh"]') as $book) {
printf("%s => %s / %s\n", (string) $book['id'], (string) $book->title, (string) $book->price);
}
// 3) 只取文档里第一个 book(注意括号的位置)
$first = $sxe->xpath('(//book)[1]');
var_dump(is_array($first)); // bool(true)
var_dump((string) $first[0]['id']); // string(2) "b1"
几个要点:
simplexml_load_string()的第三个参数是 libxml 选项位。加LIBXML_NONET是有意的:它禁止解析过程中发起网络请求,能挡掉一类外部实体攻击(见下文安全部分)。- 解析失败时它返回
false并触发E_WARNING,所以要显式判断,不要直接链式取值。 xpath()成功时返回数组(元素可能是空的数组),失败时返回值不是数组------历史上是false,PHP 8 的方法签名里还包含null。所以判断写法应该是is_array($result),而不是=== false,前者在任何版本都可靠。
SimpleXMLElement::xpath() 的签名是:
php
public SimpleXMLElement::xpath(string $expression): array|false|null
二、XPath 表达式里必须记住的几条规则
XPath 1.0 是 libxml 实现的版本,没有 XPath 2.0/3.0 的那些新特性。日常会用到的是这些:
| 表达式 | 含义 |
|---|
|-----------------|-----------|
| /catalog/book | 从根开始的绝对路径 |
|----------|--------------------|
| //book | 文档中任意位置的 book 元素 |
|------------|------------|
| . / .. | 当前节点 / 父节点 |
|-------|----|
| @id | 属性 |
|----------|------|
| text() | 文本节点 |
|-------|-----------|
| [1] | 谓词,取第一个匹配 |
|------------|------|
| [last()] | 最后一个 |
|----------------|--------|
| [@lang="zh"] | 属性等于某值 |
|----------------|---------|
| [price > 60] | 子元素数值比较 |
|-----------------|----------|
| count(//book) | 计数(返回数字) |
|----------------------|-------|
| contains(@id, "b") | 字符串包含 |
|-------------------------|-------|
| starts-with(@id, "b") | 字符串前缀 |
最容易错的一条是 [1] 的作用范围:
text
//book[1] ------ 选取「每个父节点的第一个 book 子元素」
(//book)[1] ------ 选取整个文档中的第一个 book
因为 [1] 是紧跟在节点测试后面的谓词,它作用于每一步的中间结果集 ,而不是整条路径的最终结果。当所有 book 恰好是同一个父节点的子节点时两者结果一样,一旦它们分散在不同父节点下,结果就完全不同。
三、命名空间:XPath 的头号陷阱
XPath 1.0 的世界里,不带前缀的名字永远只匹配「无命名空间」的节点 。所以对一个声明了默认命名空间的文档,//entry 选不到任何东西------这不是 PHP 的怪癖,是 XPath 规范的规定。
解决办法是给命名空间 URI 起一个前缀,再在表达式里用这个前缀:
php
<?php // 适用于 PHP 8.0+
$atom = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<entry><title>第一篇</title><dc:creator>alice</dc:creator></entry>
<entry><title>第二篇</title><dc:creator>bob</dc:creator></entry>
</feed>
XML;
$sxe = new SimpleXMLElement($atom, LIBXML_NONET);
// 关键一步:把 URI 绑定到一个前缀上
$sxe->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
$sxe->registerXPathNamespace('dc', 'http://purl.org/dc/elements/1.1/');
// 前缀名由我们自己起,和文档里的前缀不必相同
$titles = $sxe->xpath('//atom:entry/atom:title');
$creators = $sxe->xpath('//atom:entry/dc:creator');
foreach ($titles as $i => $t) {
printf("%s by %s\n", (string) $t, (string) $creators[$i]);
}
SimpleXMLElement::registerXPathNamespace(string $prefix, string $namespace): bool 的注册是发生在这个对象上 的,所以要先注册、后查询。前缀用 atom 还是 a 完全无所谓,XPath 只认 URI。
用 DOM 做同样的事,签名略有不同:
php
<?php // 适用于 PHP 8.0+,需要 ext-dom
$atom = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<entry><title>第一篇</title><dc:creator>alice</dc:creator></entry>
<entry><title>第二篇</title><dc:creator>bob</dc:creator></entry>
</feed>
XML;
$doc = new DOMDocument();
$doc->loadXML($atom, LIBXML_NONET);
$xp = new DOMXPath($doc);
$xp->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$xp->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
foreach ($xp->query('//atom:entry') as $entry) {
// 第二个参数是上下文节点,配合相对路径使用
$title = $xp->query('atom:title', $entry)->item(0)->textContent;
printf("%s\n", $title);
}
对应的方法签名是:
php
public DOMXPath::__construct(DOMDocument $document, bool $registerNodeNS = true)
public DOMXPath::query(string $expression, ?DOMNode $contextNode = null, bool $registerNodeNS = true): DOMNodeList|false
public DOMXPath::evaluate(string $expression, ?DOMNode $contextNode = null, bool $registerNodeNS = true): mixed
public DOMXPath::registerNamespace(string $prefix, string $namespace): bool
query() 只能返回节点集合,失败时返回 false;evaluate() 能返回 XPath 规定的任意类型(节点集、字符串、数字、布尔),但需要你自己判断类型。两者都会在表达式非法时给出警告,所以对动态拼接的表达式一定要检查返回值。
四、该选 SimpleXML 还是 DOM
| 对比项 | SimpleXML | DOM |
|---|
|------|-----------------------|-------------------------------|
| 上手成本 | 低,属性式访问 $sxe->title | 高,需要 query() + item() 取节点 |
|-----|-----------------------|---------------|
| 返回值 | SimpleXMLElement 数组 | DOMNodeList |
|-----|------------------|----------------------|
| 取文本 | (string) $node | $node->textContent |
|-----|---------------|-----------------------------|
| 取属性 | $node['id'] | $node->getAttribute('id') |
|----|-----------------|------------------|
| 计数 | count($nodes) | $nodes->length |
|------|-----------|-------------------|
| 修改文档 | 支持,但写法较隐晦 | 完整支持,增删改查都有明确 API |
|------|----|----------------------------|
| 错误信息 | 较少 | 更完整(libxml_get_errors()) |
|----|--------|---------------|
| 内存 | 对象包装较省 | 完整 DOM 树,开销更大 |
经验法则:只读、结构简单,用 SimpleXML;需要增删改、需要精确的错误信息、或者文档很大要配合 XMLReader,用 DOM。
安全提醒(防御侧) :解析不可信的 XML 时要防外部实体注入(XXE)。做法是:加载字符串而不是不可控的路径、加 LIBXML_NONET、不要使用会把实体替换展开的 LIBXML_NOENT,并在解析前用 libxml_use_internal_errors(true) 收集错误而不是让警告直接暴露路径信息。现代的 libxml 2.9 及以上默认不加载外部实体,PHP 8.0 起 libxml_disable_entity_loader() 已被标记为废弃(它在新版 libxml 下本就不再有作用),所以真正的防线是「不解析不可信 XML」加上这些选项。
常见坑点
- ❌
$sxe->xpath('//entry')对带默认命名空间的文档返回空数组,就断言「PHP 的 xpath 有 bug」。
✅ 这是 XPath 1.0 的规定:无前缀名只匹配无命名空间节点。先 registerXPathNamespace() 再用前缀查询。
- ❌ 用
if ($res === false)判断xpath()是否失败。
✅ 用 is_array($res);失败时的返回值在不同版本里可能是 false 也可能是 null。
- ❌ 在
xpath()返回结果上直接取属性,不检查数组是否为空:$res[0]['id']。
✅ 先判空:if ($res !== [] && isset($res[0])) { ... }。空数组的 [0] 取值会触发未定义索引警告。
- ❌ 写
//book[1]却期望得到「文档里第一个 book」。
✅ 要的是 (//book)[1];//book[1] 是「每个父节点下的第一个 book」。
- ❌ 忘记
(string)转换,直接把SimpleXMLElement对象当字符串用(比如存进数组或做===比较)。
✅ SimpleXML 对象比较的是「对象引用」,不是文本内容。取值时统一 (string) $node,属性同理 (string) $node['attr']。
- ❌ 用
$xp->evaluate('count(//book)') === 3做判断。
✅ XPath 的数字是双精度浮点,evaluate() 返回的是 float,=== 3 恒为假。用 == 3,或者显式 (int) 转换后再比较。
- ❌ 对超大 XML 用
simplexml_load_file()/DOMDocument::load()一次性读进内存。
✅ 流式方案用 XMLReader 逐个节点推进;只需要部分数据时也可以先用 XPath 缩小范围,但前提是文档已经在内存里了。
- ❌ 解析用户上传的 XML 时不加任何限制,也不检查返回值。
✅ 加 LIBXML_NONET、保持外部实体加载关闭、用 libxml_use_internal_errors(true) 收集错误,并对解析失败的输入走正常错误分支而不是继续处理 false。
总结
| 问题 | 结论 |
|---|
|------------------|----------------------------------------------------|
| 是否存在全局 xpath() | 不存在,是 SimpleXMLElement::xpath() 与 DOMXPath 的方法 |
|----------------|------------------------------------|
| SimpleXML 返回类型 | 成功为对象数组,失败值不是数组(用 is_array() 判断) |
|----------|--------------------------------------------------------|
| DOM 对应方法 | DOMXPath::query()(节点集)/ DOMXPath::evaluate()(任意类型) |
|------|---------------------------------------------------------------------|
| 命名空间 | 必须 registerXPathNamespace() / registerNamespace(),无前缀名不匹配默认命名空间 |
|-------|----------------------------|
| 谓词作用域 | //x[1] 与 (//x)[1] 语义不同 |
|------|---------------------------------|
| 数字结果 | XPath 数字是 float,别用 === 与整数比 |
|----|------------------------------------------------|
| 安全 | 不解析不可信 XML;用 LIBXML_NONET,不要开 LIBXML_NOENT |
把「无前缀不匹配命名空间」和「谓词作用域」这两条记牢,XPath 里剩下的问题基本都是语法细节。真正要选择 API 时,只读用 SimpleXML、要改文档用 DOM,两者的 XPath 表达式语法是完全一样的。