使用 DOMDocument 在 PHP 中提取完整 HTML 文档的 Body 内容
介绍
内容编辑器、页面构建器和导入工具经常会收到两种不同形态的 HTML:
- HTML 片段,例如
<p>联系我们</p> - 完整文档,例如
<!doctype html><html><head>...</head><body>...</body></html>
如果你的组件只期望接收片段,把完整文档渲染到组件内部可能会造成无效 markup、重复 metadata 或 layout 问题。这里不需要完整 sanitizer。更合适的是一个小的规范化步骤:当输入是完整文档时,只提取 <body> 内部内容;当输入已经是片段时,保持原样。
本文展示一个使用 DOMDocument 的实用 PHP helper。
目标
这个 helper 应该遵守三条规则:
- 空输入返回空字符串
- 普通 HTML 片段原样返回
- 完整 HTML 文档只返回 body 的子节点
例如:
$html = '<!doctype html>
<html>
<head><title>忽略</title></head>
<body>
<section>
<h2>预约表单</h2>
<p>选择一个时间。</p>
</section>
</body>
</html>';
echo bodyContents($html);输出:
<section>
<h2>预约表单</h2>
<p>选择一个时间。</p>
</section>但这个片段会保持不变:
echo bodyContents('<p>已经是片段</p>');
// <p>已经是片段</p>Helper 实现
完整实现如下:
function bodyContents(string $html): string
{
$trimmed = trim($html);
if ($trimmed === '') {
return '';
}
if (! preg_match('/<html\b|<body\b|<!doctype/i', $trimmed)) {
return $html;
}
$dom = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$loaded = $dom->loadHTML(
'<?xml encoding="UTF-8">' . $trimmed,
LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED
);
libxml_clear_errors();
if (! $loaded) {
return $html;
}
$body = $dom->getElementsByTagName('body')->item(0);
if (! $body) {
return $html;
}
$inner = '';
foreach ($body->childNodes as $child) {
$inner .= $dom->saveHTML($child);
}
return $inner;
}这个函数有意保持保守。它只解析看起来像完整文档的输入。这样可以避免把每一个小片段都交给 DOMDocument,因为 DOMDocument 可能会规范化空白、修复 tag,或轻微改变输出格式。
为什么先检测再解析
DOMDocument::loadHTML() 很有用,但它不是无副作用的格式化器。它会解析并修复 markup。对于文档形态的 HTML,这正是我们想要的;对于简单片段,这可能没有必要,也可能带来意外。
这个 guard 让常见路径保持简单:
if (! preg_match('/<html\b|<body\b|<!doctype/i', $trimmed)) {
return $html;
}这个 pattern 会捕捉完整文档的常见信号:
<html>tag<body>tag<!doctype>声明
如果这些标记都不存在,helper 就假设输入已经适合 inline 渲染。
静默处理解析 Warning
真实编辑器产生的 HTML 可能包含 HTML5 tag、不完整文档,或让 libxml 报 warning 的 markup。PHP 的 DOMDocument::loadHTML 文档说明,解析行为依赖 libxml,现代 HTML 也可能产生 warning。
对于规范化 helper,这些 warning 不应该泄漏到日志或响应里,所以 parser 使用内部错误处理:
libxml_use_internal_errors(true);
$loaded = $dom->loadHTML($source, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED);
libxml_clear_errors();这些 flags 可以减少序列化输出中的额外文档 wrapper。函数仍然把解析失败视为非致命情况:
if (! $loaded) {
return $html;
}这个 fallback 很重要。如果 helper 不能可靠地提取 body,它应该返回原始输入,而不是静默丢弃内容。
保留 UTF-8
实现中在解析前加了一个 XML encoding hint:
'<?xml encoding="UTF-8">' . $trimmed这是处理 DOMDocument 和 UTF-8 内容时常见的 workaround。如果没有明确的 encoding 信号,非 ASCII 字符可能会因为输入和 libxml 行为而被错误解释。
如果你的源文档已经包含可靠的 charset metadata,可能不需要完全采用这个方式。对于粘贴片段和 CMS 内容,显式 hint 是一个实用的防御性默认值。
只提取 Body 的子节点
文档加载后,关键步骤是序列化 <body> 的每一个子节点:
$body = $dom->getElementsByTagName('body')->item(0);
$inner = '';
foreach ($body->childNodes as $child) {
$inner .= $dom->saveHTML($child);
}DOMDocument::saveHTML() 可以序列化整个文档,也可以序列化指定节点。PHP 文档在 DOMDocument::saveHTML 中说明了这个可选 node 参数。逐个传入 body 的子节点,就得到类似浏览器 innerHTML 的结果。
这样不会把 <body> wrapper 本身返回出去。
在 View Pipeline 中使用
常见用法是在把已保存的 template HTML 交给组件之前,先规范化它的形态:
$template = bodyContents((string) $storedTemplate);
return view('components.dynamic-form', [
'template' => $template,
]);在 Laravel Blade 中可以这样写:
@php
$template = \App\Support\Html::bodyContents((string) $template);
@endphp
<livewire:dynamic-form :template="$template" />这样编辑器无论粘贴 HTML 片段还是完整 HTML 文档,组件收到的都是它期望的形态。
重要安全边界
重要
这个 helper 不会 sanitize HTML。它只是在输入像完整文档时提取 body 内容。
如果用户可以提交不可信 HTML,请在提取之后运行真正的 sanitizer。例如使用基于 allowlist 的 HTML Purifier,或使用你们技术栈中已经批准的 sanitizer。
安全 pipeline 是:
- 使用
bodyContents()规范化文档形态 - sanitize 不可信 tag 和 attribute
- 按照框架的 escaping 规则渲染
不要把 DOM 解析当成安全过滤器。
PHP 8.4 说明
如果你在 PHP 8.4+ 中写新代码,也应该看看 Dom\HTMLDocument。PHP 推荐它用于更符合 HTML5 的解析。DOMDocument::loadHTML() 仍然很常见,也很适合本文这种范围很窄的提取任务,但现代 HTML 解析仍在变化。
如果你需要精确的 HTML5 tree construction,优先使用新 parser。如果你只是在 PHP 8.1/8.2/8.3 应用中需要一个小型兼容 helper,DOMDocument 仍然是实际可用的选择。
总结
bodyContents() 很小,但它能避免一个常见渲染问题:完整 HTML 文档进入只期望片段的组件。
关键模式是:
- 解析前先检测文档形态输入
- 保持片段输入不变
- 使用静默的 libxml 错误处理
- 用
saveHTML($child)提取 body 子节点 - 解析失败时回退到原始输入
这样你的内容 pipeline 会得到稳定的 HTML 形态,同时不会假装自己已经解决 sanitization、validation 或完整 HTML 清理问题。
