在 PHP 中使用 DOMDocument 解析 XML 或 HTML 文档时,经常遇到需要把某些元素下的所有文本批量替换成新内容的需求。如果处理不当,很容易把子元素标签删掉,只留下纯文本。理解 DOM 树中元素节点与文本节点的关系,是写出稳定替换逻辑的前提。

DOM 节点类型与文本存放位置
DOMDocument 把文档拆成不同类型的节点,其中最常见的有元素节点(XML_ELEMENT_NODE)和文本节点(XML_TEXT_NODE)。一个元素像 <p>你好<em>世界</em></p>,其实包含三个子节点:文本节点“你好”、元素节点 <em>、以及 <em> 里的文本节点“世界”。很多人以为元素的文本都存在元素本身的某个属性里,实际上文本永远住在独立的文本节点中。
如果直接对元素使用 nodeValue 赋值,DOM 规范会先清空该元素的所有子节点,再插入一个新文本节点。这意味着原本的 <em> 标签会直接消失。因此,要“替换所有子元素的文本内容”而不是“抹掉结构”,就必须只动文本节点,不动元素节点。
错误写法:直接改元素 nodeValue
下面这段代码看似简单,却会破坏文档结构:
<?php
$doc = new DOMDocument();
$doc->loadXML('<p>旧文本<em>强调</em>结尾</p>');
$p = $doc->getElementsByTagName('p')->item(0);
// 错误:会删除 em 标签
$p->nodeValue = '全部替换';
echo $doc->saveXML($p);
// 输出 <p>全部替换</p>,em 已丢失
?>
从输出可以看出,<em>强调</em> 整个不见了。这在需要保留样式的场景中是不可接受的。该写法的优点是代码短,但缺点极其致命:结构破坏且不可恢复。
另一个常见误区是用 DOMDocument 的 saveHTML 后再正则替换,这种做法在嵌套标签多时极易出错,比如属性值里出现大于号就会被误伤,所以并不推荐。
正确方式一:递归遍历文本节点
安全的做法是写一个递归函数,只处理类型为 XML_TEXT_NODE 的节点。这样元素节点会被跳过,仅其下的文本被修改。
<?php
function replaceTextNodes(DOMNode $node, $search, $replace) {
if ($node->nodeType === XML_TEXT_NODE) {
$node->nodeValue = str_replace($search, $replace, $node->nodeValue);
return;
}
// 用子节点快照避免遍历时节点变动
$children = [];
foreach ($node->childNodes as $child) {
$children[] = $child;
}
foreach ($children as $child) {
replaceTextNodes($child, $search, $replace);
}
}
$doc = new DOMDocument();
$doc->loadXML('<p>旧文本<em>旧强调</em>旧结尾</p>');
$root = $doc->documentElement;
replaceTextNodes($root, '旧', '新');
echo $doc->saveXML($root);
// 输出 <p>新文本<em>新强调</em>新结尾</p>
?>
这里先收集子节点到数组,是因为在遍历过程中如果增删节点会影响 DOMNodeList 的实时性;虽然本例只是改文本不改节点数量,但养成快照习惯能避免隐蔽 bug。递归写法直观,适合树深度不大的文档。
该方案的优点是结构零破坏、逻辑清晰;缺点是深层递归在超大文档上可能有栈溢出风险,此时可改用迭代。
正确方式二:使用 XPath 精准选取
如果只想替换某个路径下的文本,用 DOMXPath 更高效。XPath 的 text() 函数可直接拿到文本节点集合。
<?php
$doc = new DOMDocument();
$doc->loadXML('<div><p>旧a</p><p>旧b</p></div>');
$xpath = new DOMXPath($doc);
// 选取 div 下所有后代文本节点
$texts = $xpath->query('//div//text()');
foreach ($texts as $text) {
$text->nodeValue = str_replace('旧', '新', $text->nodeValue);
}
echo $doc->saveXML($doc->documentElement);
// 输出文本均已更新,标签保留
?>
XPath 方式避免了手写递归,表达式可以灵活调整,比如只选直接子文本用 //div/text()。在复杂筛选时优势明显。
需要注意的是,如果文档有命名空间,XPath 查询前要用 registerNamespace 注册前缀,否则节点选不到。这是使用 XPath 时最容易忽略的一点。
性能与注意事项对比
三种思路的特点可以归纳如下:
| 方法 | 结构安全 | 代码量 | 适用场景 |
|---|---|---|---|
| 直接 nodeValue | 否 | 极少 | 不需要保留子标签时 |
| 递归遍历 | 是 | 中等 | 全文档或任意节点替换 |
| XPath | 是 | 少 | 有路径规律的精准替换 |
实际项目中,若文档来自用户上传的 HTML,建议先使用 libxml 的宽松模式加载,并过滤危险标签,再执行文本替换,避免脚本注入。
另外,文本节点的 nodeValue 赋值会自动转义特殊字符,比如写入 < 会变成 < 存为纯文本,不会解析成标签,这一点对防止 XSS 有天然帮助。
小结代码模板
把前面的递归函数稍加封装,就能作为日常工具函数复用:
<?php
function domReplaceText(DOMDocument $doc, $search, $replace, DOMNode $scope = null) {
$scope = $scope ?: $doc->documentElement;
$stack = [$scope];
while ($stack) {
$node = array_pop($stack);
if ($node->nodeType === XML_TEXT_NODE) {
$node->nodeValue = str_replace($search, $replace, $node->nodeValue);
continue;
}
foreach ($node->childNodes as $child) {
$stack[] = $child;
}
}
}
?>
上面用栈代替递归,既保留结构安全,又规避了深树递归的栈限制。结合具体业务传入不同作用域,就能正确遍历并替换 DOMDocument 中所有子元素的文本内容。
DOMDocumentXML_DOMtextContent修改时间:2026-08-06 16:51:23