Skip to content

使用 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 的子节点

例如:

php
$html = '<!doctype html>
<html>
    <head><title>忽略</title></head>
    <body>
        <section>
            <h2>预约表单</h2>
            <p>选择一个时间。</p>
        </section>
    </body>
</html>';

echo bodyContents($html);

输出:

html
<section>
    <h2>预约表单</h2>
    <p>选择一个时间。</p>
</section>

但这个片段会保持不变:

php
echo bodyContents('<p>已经是片段</p>');
// <p>已经是片段</p>

Helper 实现

完整实现如下:

php
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 让常见路径保持简单:

php
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 使用内部错误处理:

php
libxml_use_internal_errors(true);
$loaded = $dom->loadHTML($source, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED);
libxml_clear_errors();

这些 flags 可以减少序列化输出中的额外文档 wrapper。函数仍然把解析失败视为非致命情况:

php
if (! $loaded) {
    return $html;
}

这个 fallback 很重要。如果 helper 不能可靠地提取 body,它应该返回原始输入,而不是静默丢弃内容。

保留 UTF-8

实现中在解析前加了一个 XML encoding hint:

php
'<?xml encoding="UTF-8">' . $trimmed

这是处理 DOMDocument 和 UTF-8 内容时常见的 workaround。如果没有明确的 encoding 信号,非 ASCII 字符可能会因为输入和 libxml 行为而被错误解释。

如果你的源文档已经包含可靠的 charset metadata,可能不需要完全采用这个方式。对于粘贴片段和 CMS 内容,显式 hint 是一个实用的防御性默认值。

只提取 Body 的子节点

文档加载后,关键步骤是序列化 <body> 的每一个子节点:

php
$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 交给组件之前,先规范化它的形态:

php
$template = bodyContents((string) $storedTemplate);

return view('components.dynamic-form', [
    'template' => $template,
]);

在 Laravel Blade 中可以这样写:

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 是:

  1. 使用 bodyContents() 规范化文档形态
  2. sanitize 不可信 tag 和 attribute
  3. 按照框架的 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 清理问题。