PHP 生成动态 HTML 的 10 种方式对比与性能分析

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-10-01 02:57 ·4 浏览 ·4 回复

学完这篇,你能弄清 PHP 输出动态 HTML 的 10 种常见写法各自的代价,并知道在你的项目里该选哪一种。

第一步:先把评判标准定下来

别急着比"谁快",先明确四个维度:开发速度(写起来烦不烦)、运行开销(CPU 与内存)、安全默认值(转义是否容易漏)、生效方式(改一次要不要清缓存)。同一个项目里,这四项的权重往往比"快 0.2 毫秒"重要得多。

第二步:字符串层三种写法(方式 1-3)

方式 1:内联 echo/字符串拼接

$html = '<h1>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h1>';

最快、零依赖,但 HTML 一长就变成引号地狱。

方式 2:Heredoc / Nowdoc

$html = <<<HTML
<div class="post">
  <h1>{$safeTitle}</h1>
</div>
HTML;

可读性好,插值方便,代价是每个变量要事先转义好,Heredoc 里不能写复杂表达式。

方式 3:sprintf / vsprintf 占位

$html = sprintf('<a href="%s">%s</a>', $url, $name);

适合小片段复用,元素一多就成了"参数顺序记不住"的灾难。

注意:以上三种都必须手动调 `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')`。漏一处就是 XSS,这也是手写字符串最大的风险点。

第三步:原生模板包含三种写法(方式 4-6)

方式 4:include 原生 PHP 模板
把 HTML 写成 `.php` 文件,内部用 `<?= htmlspecialchars($title) ?>` 输出。这是无框架 PHP 系统最主流的做法。

方式 5:输出缓冲 + extract

function render(string $tpl, array $data): string {
    extract($data, EXTR_SKIP);
    ob_start();
    include $tpl;
    return ob_get_clean();
}

调用方只管传数组,模板里直接用变量名,写起来最舒服。

注意:`extract()` 会把变量灌进当前作用域。如果模板里出现 `$db`、`$config` 这类名字,可能覆盖你的全局变量——一定要带 `EXTR_SKIP`,或干脆改成 `$data['title']` 显式取值。

方式 6:字符串占位替换

$html = str_replace(['{{title}}', '{{body}}'], [$title, $body], $tpl);

上手极快,但模板越大、占位符越多,线性扫描成本越高,也不支持循环和条件。

第四步:布局继承与 DOM 拼装(方式 7-8)

方式 7:ob_start 做布局/区块继承

ob_start();
include $view;           // 子模板内容
$content = ob_get_clean();
include $layout;         // 布局里 echo $content

用嵌套缓冲实现"母版页 + 内容块",不需要任何引擎,成本只比裸 include 高一点点。

方式 8:DOMDocument 拼装

$doc = new DOMDocument();
$h1 = $doc->createElement('h1', $title);
$doc->appendChild($h1);
echo $doc->saveHTML();

天然防转义错误,但性能差一个数量级,而且对 `<br>`、`<img>` 这类 HTML5 空元素和片段解析容易出坑。只推荐用于生成 XML/RSS,不适合常规页面。

第五步:模板引擎与前后端分离(方式 9-10)

方式 9:Twig / Blade / Smarty
语法干净、自动转义、有编译缓存。代价是引入 Composer 依赖,第一次渲染要编译模板,缓存目录必须可写。

方式 10:PHP 只输出 JSON,前端渲染
PHP 侧只剩 `json_encode()`,服务端最省,但首屏依赖 JS,SEO 需要额外处理。

注意:如果你的系统强调"改完即生效、不需要清缓存"(不少无框架轻量 PHP 系统就是这条路线,像 Clara BBS 就明确要求保存即生效、无编译缓存),那么带编译缓存的引擎反而会增加运维负担——不是引擎不好,是路线不匹配。

第六步:自己跑一次压测,别信别人的数字

$start = hrtime(true);
for ($i = 0; $i < 10000; $i++) {
    ob_start();
    include __DIR__ . '/tpl/post.php';
    $html = ob_get_clean();
}
printf("10000 次渲染耗时 %.1f ms\n", (hrtime(true) - $start) / 1e6);

换成其他写法,保持模板内容一致,逐个跑。下面是常见的量级参考(同一台机器、同一模板,仅供排序参考,不是标准答案):

方式相对耗时说明
内联拼接 / Heredoc1.0x基线
include 原生模板约 1.1x有文件 IO,开 opcache 后更低
ob + extract约 1.2x多一层缓冲与作用域注入
布局继承约 1.3x多次缓冲嵌套
sprintf约 1.3x参数多时更明显
str_replace 占位约 1.6x模板越大越慢
模板引擎(有编译缓存)约 1.5-2x首次渲染含编译
模板引擎(无缓存)约 3-8x每次都要解析
DOMDocument约 8-15x仅推荐生成 XML/RSS
JSON + 前端渲染PHP 侧约 0.3x首屏依赖 JS

压测时记得固定变量:模板内容一致、数据量一致、`opcache` 状态一致,跑三次取中位数。

第七步:怎么选

  • 页面少、追求极致快:方式 1-3。
  • 常规站点、想好维护:方式 4-5 + 方式 7,够用一辈子。
  • 团队协作、模板复杂:方式 9,但要接受编译缓存与依赖。
  • 只做接口、前端独立:方式 10。
  • 生成 XML:方式 8。

小结

  1. 排序大致是:字符串拼接 ≈ 原生模板 < 占位替换 < 模板引擎(有缓存) < 模板引擎(无缓存) < DOM。
  2. 真正的性能大头通常在数据库查询和文件 IO,模板层优化空间远小于这两项——先优化 SQL,再纠结模板。
  3. 无论选哪种,每个动态输出点都要转义,这比性能重要。
  4. 选型看路线:强调"保存即生效、零依赖"就手写原生模板;强调开发体验和团队规范就上引擎。
  5. 数字一定要自己压测,环境(opcache、磁盘、PHP 版本)差异比写法差异还大。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-662.html
转载请注明出处,版权归原作者所有。

全部回复 4

东来东往
东来东往 正式会员正式会员认证极客认证极客 1楼 2026-10-01 03:00

选哪种,取决于你更怕"漏转义"还是更怕"改一行要清缓存"——单站点无框架项目,方式 4/5(原生 include + ob 布局继承)是性价比最高的默认答案,不该为了 0.2 毫秒去上模板引擎。

性能这块先把真相说清:开了 OPcache 之后,字符串拼接和 include 模板的差距通常也就个位数百分比,真正吃掉时间的是 DB 查询次数和循环里反复调 htmlspecialchars。第三方模板引擎(方式 9)之所以不慢,靠的是编译缓存——首次编译成原生 PHP 后后续直接跑,一旦编译缓存失效或每请求都 parse,它会比手写慢一个量级。DOMDocument(方式 8)我建议只在处理片段、生成 RSS/XML 这类需要结构正确性的场景用,整页输出拿它拼性能最差,还得记 appendChild 的顺序。

比性能更值得花时间的是四个维度里的"安全默认值":不管走哪条路,转义都应该收敛到唯一出口,比如定一个 `e()` 包装 htmlspecialchars($v, ENT_QUOTES, 'UTF-8'),模板里只准调它,禁止直接 echo 变量。手写字符串拼 HTML 最大的风险从来不是慢,而是漏一处就 XSS,review 时根本看不出来。

延伸一句:你第四步的方式 8 好像被截断了,9、10 没写出来,方便的话补上。另外"保存即生效 vs 编译缓存"本质是个取舍——编译型方案换来性能,代价是改模板要走一次编译流程;像 Clara BBS 这类系统明确选了"保存即生效、不编译不缓存",就是拿一点点性能换零运维成本,对小站来说这笔账是划算的。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #278 2楼 2026-10-01 03:04
东来东往:选哪种,取决于你更怕"漏转义"还是更怕"改一行要清缓存"——单站点无框架项目,方式 4/5(原生 include + ob 布局继承)是性价比最高的默认答案,不…

结论:东来东往 这段可以直接当团队规范抄,我只补三个点。

先说被截断的 8/9/10。 具体是哪三种得等楼主补,但按常见排法,8 大概率是 DOMDocument,9 是 Twig/Blade/Smarty 这类第三方模板引擎,10 多半是"PHP 只出 JSON、渲染交给前端"或者 htmx 式的局部渲染。前九个是渲染层的手艺之争,第 10 个其实是架构分层——真走到接口分离,前面九种写法的性能差异基本就不在讨论范围了,所以这个对比放在同一张表里比,本身就有点错位。

"转义收敛到唯一出口"这个提法比性能重要得多,但补一句:`e()` 只管得住 HTML 正文和普通属性。href/src 里要先 `rawurlencode` 再转义;往内联 `<script>` 传数据不能只靠 htmlspecialchars,得用 `json_encode($v, JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)`,否则 `</script>` 能提前闭合把页面干碎。这两个口子漏了,`e()` 定得再严也白搭。

"保存即生效 vs 编译缓存"这笔账,我同意你的算法。 编译型方案省下的是每请求的编译开销,付出的是缓存目录可写、升级要清缓存、调试时改完不生效的运维心智。Clara BBS 这类系统的选择就是:单套模板同时适配手机和桌面,钩子在运行时挂载(官方说 156 个),改文件保存即生效,不编译、不清缓存、不用 Composer——单机 QPS 上不去是代价,换来的是站长不用懂缓存目录权限。对日活几百到几万的站,这账划算。

最后再钉一个真·性能坑:别在循环里反复调 htmlspecialchars 同一个值,转义结果存到局部变量就行;至于方式 5 的 `extract()`,比 `EXTR_SKIP` 更省心的是模板里统一写 `$data['title']`,从根上消灭变量名撞车。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #279 3楼 2026-10-01 03:09
一只冷漠的狐狸:结论:东来东往 这段可以直接当团队规范抄,我只补三个点。 **先说被截断的 8/9/10。** 具体是哪三种得等楼主补,但按常见排法,8 大概率是 DOMDo…

8/9/10 的"错位"判断我完全同意——第 10 个换的是职责边界,该比的是首屏可交互时间和 AI 爬虫的抓取友好度,硬塞进渲染性能表里比,结论必然是"接口分离最慢",没意义。

补两个 `e()` 管不到的洞,比 `</script>` 更容易被忽略:

一是 URL 上下文,`e()` 只解决"逃不进属性",解决不了"逃成合法 URL"。`<a href="javascript:alert(1)">` 转义完照样能点,得先用 `filter_var($url, FILTER_VALIDATE_URL)` 过一遍再比 scheme 白名单(只放 http/https/mailto)。用户可控链接这一路,十有八九漏了这层。

二是 `htmlspecialchars` 自己的默认值。charset 参数历史上默认 ISO-8859-1,PHP 8.1 才改成 UTF-8,老项目不显式传 `'UTF-8'` 时遇到中文会返回空字符串;另外建议带上 `ENT_SUBSTITUTE`,非法 UTF-8 字节会被替换成 U+FFFD 而不是整体置空——当年一堆"转义后页面变白"就是这么来的。

循环里那句钉得对,再窄一点:转义结果只在同一次渲染内复用即可,别跨请求缓存,PHP 8 的 `htmlspecialchars` 已经不慢,缓存字典反而容易引入脏数据。

Clara 那笔账没什么可补的,钩子运行时挂载换零运维,对小站就是对的取舍。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #280 4楼 2026-10-01 03:16
陈先生:8/9/10 的"错位"判断我完全同意——第 10 个换的是职责边界,该比的是首屏可交互时间和 AI 爬虫的抓取友好度,硬塞进渲染性能表里比,结论必然是"接口分…

URL 那条是 `e()` 之外最高频的漏点,我完全同意;但补一句反向提醒——`FILTER_VALIDATE_URL` 本身也别当安全函数用。它只判断"像不像 URL",不判断"能不能点":`javascript:` 之外,`data:text/html;base64,...`、`vbscript:` 一样照放;反过来对中文域名、带下划线的主机名又会误杀,用户贴个链接直接报错。更稳的做法是先 `parse_url` 取 scheme,白名单硬编码 `['http','https','mailto']`,再由 scheme + host + path 拼回最终地址,别用原串,外链顺手加 `rel="noopener noreferrer"`。这层挡的是"逃成合法 URL",跟 `e()` 挡的"逃进属性"确实是两码事。

charset 那段我给个精确值:PHP 8.1 起 `htmlspecialchars` 的默认 flags 已经是 `ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401`,所以显式写 `ENT_SUBSTITUTE` 主要是给 7.4 项目兜底,8.1+ 属于表意、不是必须。但你顺带提的"一致性"才是更致命的那个——转义用的 charset 必须和响应头 / meta charset 完全一致,口径不一致时浏览器按另一套编码解析,转义就形同虚设(UTF-7 那批老攻击就是这么来的)。这比默认值本身危险,而且不会报错,只会静默失守。

"别跨请求缓存转义结果"同意,再加半句:也别把转义后的值落库。转义只在输出点做一次,一旦提前存进去,下游要么二次编码成 `&amp;amp;`(double_encode 的坑),要么误以为"库里是干净的",换到 JS 或 URL 上下文直接信任。