在HTML内容中把某个字符串包裹起来,比如给关键词加上高亮标签,或者给特定词自动加上链接,是内容管理系统里非常常见的需求。很多PHP开发者第一反应是用str_replace或者正则替换,但当HTML结构复杂、目标字符串恰好跨越或者深埋在多层嵌套标签中时,纯字符串操作就变得不可靠了。正确的做法是把HTML解析成DOM树,遍历所有文本节点,在节点级别做精准处理。PHP内置的DOMDocument配合DOMXPath正好能完成这件事。

为什么str_replace和正则替换不可靠
先看一个典型的失败案例。假设HTML内容如下,目标字符串是“PHP教程”:
<div class="content">
<p>欢迎阅读<strong>PHP</strong>教程,希望对你有帮助。</p>
</div>肉眼一看,“PHP”和“教程”连在一起,应该被匹配。但用str_replace('PHP教程', ...)时什么都不会发生,因为字符串层面这段内容是“欢迎阅读<strong>PHP</strong>教程”,中间插着标签,根本找不到连续的“PHP教程”。这就是浏览器渲染出来的内容与字符串本身不一致导致的。
正则替换看似强大,但会引入更多问题。比如目标词出现在HTML注释里、出现在<img>标签的alt属性里、出现在<script>代码块里,正则都会照替不误,轻则产生错误标签,重则把页面结构直接破坏。另外正则很难判断某个标签是否允许包裹,比如你不能把一个块级元素包进<span>里还能保证语义正确。而DOM方式天然规避了这些问题:属性、注释、脚本在DOM树中是不同类型的节点,遍历时只要筛选XML_TEXT_NODE类型就能精确跳过。
核心实现:遍历文本节点并拆分包裹
思路分三步。第一步,用DOMDocument加载HTML片段,注意在内容前套一层带charset声明的容器,避免中文乱码。第二步,用XPath的//text()取出全部文本节点。第三步,对每个文本节点做字符串查找,命中后将该节点拆成三部分:前段文本、命中片段、后段文本,命中片段用新建的元素节点包裹后插回原位置。
完整实现代码如下:
function wrapKeyword($html, $keyword, $wrapperTag, $attributes = [])
{
$dom = new DOMDocument();
// 编码声明,防止中文内容乱码
libxml_use_internal_errors(true);
$dom->loadHTML(
'<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' . $html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
// 只取文本节点,自动跳过标签、属性、注释、脚本
$textNodes = $xpath->query('//text()');
// 先把节点收集到数组,因为拆分过程中会动态修改树结构
$nodes = iterator_to_array($textNodes);
foreach ($nodes as $node) {
$text = $node->wholeText;
$pos = mb_strpos($text, $keyword);
if ($pos === false) {
continue;
}
// 切分出三段内容
$before = mb_substr($text, 0, $pos);
$match = mb_substr($text, $pos, mb_strlen($keyword));
$after = mb_substr($text, $pos + mb_strlen($keyword));
$parent = $node->parentNode;
// 如果包裹标签已经存在(避免重复处理),直接跳过
if ($parent->nodeName === $wrapperTag) {
continue;
}
// 按顺序插入:命中片段 - 前段 - 后段
$wrapper = $dom->createElement($wrapperTag, htmlspecialchars($match, ENT_QUOTES, 'UTF-8'));
foreach ($attributes as $name => $value) {
$wrapper->setAttribute($name, $value);
}
$parent->replaceChild($wrapper, $node);
if ($after !== '') {
$parent->insertBefore(new DOMText($after), $wrapper->nextSibling);
}
if ($before !== '') {
$parent->insertBefore(new DOMText($before), $wrapper);
}
}
// 输出内部HTML,去掉我们加的meta头
$out = $dom->saveHTML($dom->documentElement);
return $out;
}这里有几个关键细节值得展开。第一,mb_strpos和mb_substr必须使用多字节版本,否则遇到中文会按字节切分,切出来的内容是乱码。第二,XPath结果集是动态的,边遍历边修改树会出问题,所以先用iterator_to_array固化快照。第三,插入后段文本时要用$wrapper->nextSibling作为参考位置,保证前段、包裹元素、后段三者的顺序与原文一致。
调用示例
$html = '<div><p>这是一篇关于<strong>PHP</strong>开发的教程,PHP开发者必备。</p></div>'; // 给所有 PHP 加上高亮span $result = wrapKeyword($html, 'PHP', 'span', ['class' => 'highlight']); echo $result; // 输出:<div><p>这是一篇关于<strong><span class="highlight">PHP</span></strong>开发的教程,<span class="highlight">PHP</span>开发者必备。</p></div>
可以看到无论目标词出现在第几层嵌套中,只要它是完整的文本节点内容或其中一段,都能被正确包裹,且原有的<strong>、<p>等结构完全不受影响。
进阶处理:跨节点的关键词、防重复与性能优化
上面的实现能覆盖绝大多数场景,但有两个边界问题需要考虑。第一个是“每个节点只替换第一次出现”,如果同一个文本节点里目标词出现多次,可以用while循环配合mb_strpos不断处理$after部分,直到找不到为止。第二个是跨标签的匹配,也就是前面提到的“PHP”和“教程”分处两个文本节点的情况,严格来说这种匹配需要合并兄弟文本节点的整体内容来计算,实现复杂度会明显上升,而且即使匹配成功,包裹元素也会横跨原有标签边界,生成的结构未必合法。实际项目中通常建议放弃跨标签匹配,只在单节点内处理,这也是各大CMS关键词高亮功能的通行做法。
防重复包裹也是重点。如果这段HTML已经做过一次高亮,再次执行时不应该出现<span class="highlight"><span class="highlight">这样的嵌套。代码中通过检查父节点名称已经做了简单防御,更严谨的做法是把父节点的class属性也纳入判断,或者在XPath里直接排除已被包裹的节点,例如把查询改成//text()[not(parent::span[contains(@class,"highlight")])],从源头过滤。
性能方面,DOM解析的开销主要在loadHTML阶段,对于几千字的文章内容完全在毫秒级,不必担心。真正需要注意的是批量替换多个关键词时,不要每个关键词都重新解析一次DOM,正确做法是解析一次,然后循环关键词共用同一棵树,处理完后再统一saveHTML输出,这样性能可以提升数倍。
最后提醒一点,saveHTML输出时会保留原有的空白字符,且LIBXML_HTML_NOIMPLIED标志在使用时要确认输入是片段而非完整文档,否则可能丢失最外层标签。掌握了这套节点级操作思路后,类似的需求比如敏感词过滤、自动内链、正文关键词统计都可以在其基础上快速扩展。
PHPDOMDocument文本节点修改时间:2026-09-05 07:10:35