PHP 原生模板
PHP 本身就是一门模板语言,将 PHP 代码嵌入 HTML 是最原始也最灵活的模板方式。正确使用 PHP 原生模板,结合输出缓冲、自动转义和模板继承机制,可以在不引入第三方依赖的情况下构建安全、可维护的视图层。
基础概念
PHP 作为模板引擎的优势
| 优势 | 说明 |
|---|---|
| 零依赖 | 无需安装第三方库 |
| 高性能 | PHP 自身解析,无额外编译开销 |
| 灵活 | 可以使用全部 PHP 特性 |
| IDE 支持 | 语法高亮、自动补全、重构 |
| 调试方便 | 直接使用 PHP 调试工具 |
PHP 模板的劣势
| 劣势 | 说明 |
|---|---|
| 手动转义 | 需要记住在输出时调用 htmlspecialchars |
| 无自动过滤 | 原生模板不会自动处理特殊字符 |
| 逻辑与视图混合 | 容易在模板中写入过多业务逻辑 |
| 无内置继承 | 需要手动实现布局/继承机制 |
语法与代码示例
基本 PHP 模板
php
<?php
// templates/user/profile.php
// 模板文件:接收 $user 和 $posts 变量
declare(strict_types=1);
$this->layout('layouts/main', ['title' => $user['username'] . '的个人主页']);
?>
<div class="profile">
<h1><?= htmlspecialchars($user['username'], ENT_QUOTES, 'UTF-8') ?></h1>
<p>邮箱: <?= htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8') ?></p>
<p>注册时间: <?= htmlspecialchars($user['created_at'], ENT_QUOTES, 'UTF-8') ?></p>
<?php if (!empty($user['bio'])): ?>
<div class="bio">
<h2>个人简介</h2>
<p><?= nl2br(htmlspecialchars($user['bio'], ENT_QUOTES, 'UTF-8')) ?></p>
</div>
<?php endif; ?>
<section class="posts">
<h2>最近文章</h2>
<?php foreach ($posts as $post): ?>
<article>
<h3>
<a href="/posts/<?= (int) $post['id'] ?>">
<?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?>
</a>
</h3>
<time datetime="<?= htmlspecialchars($post['published_at'], ENT_QUOTES, 'UTF-8') ?>">
<?= htmlspecialchars($post['published_at'], ENT_QUOTES, 'UTF-8') ?>
</time>
<p><?= htmlspecialchars(mb_substr($post['content'], 0, 200), ENT_QUOTES, 'UTF-8') ?>...</p>
</article>
<?php endforeach; ?>
<?php if (empty($posts)): ?>
<p>暂无文章</p>
<?php endif; ?>
</section>
</div>简写的输出转义函数
php
<?php
// functions.php -- 全局辅助函数
if (!function_exists('e')) {
/**
* 输出转义(HTML 上下文)
*/
function e(string $string, int $flags = ENT_QUOTES, string $encoding = 'UTF-8'): string
{
return htmlspecialchars($string, $flags, $encoding);
}
}
if (!function_exists('e_attr')) {
/**
* 属性值转义
*/
function e_attr(string $string): string
{
return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
if (!function_exists('e_js')) {
/**
* JavaScript 上下文转义
*/
function e_js(mixed $value): string
{
return json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
}在模板中使用辅助函数
php
<?php
// 使用 e() 简化输出转义
?>
<h1><?= e($user['name']) ?></h1>
<input type="text" value="<?= e_attr($user['name']) ?>">
<a href="<?= e_attr($url) ?>">Link</a>
<script>
var userName = <?= e_js($user['name']) ?>;
</script>实战示例:原生模板引擎
基本模板渲染类
php
<?php
declare(strict_types=1);
/**
* 轻量级原生 PHP 模板引擎
*/
class TemplateEngine
{
private readonly string $templateDir;
private readonly string $cacheDir;
private array $data = [];
public function __construct(
string $templateDir,
?string $cacheDir = null,
) {
$this->templateDir = rtrim($templateDir, '/\\');
if ($cacheDir !== null) {
$this->cacheDir = rtrim($cacheDir, '/\\');
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
}
/**
* 分配变量到模板
*/
public function assign(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
/**
* 批量分配变量
*/
public function assignAll(array $data): self
{
foreach ($data as $key => $value) {
$this->data[$key] = $value;
}
return $this;
}
/**
* 渲染模板并返回 HTML
*/
public function render(string $template, array $data = []): string
{
$mergedData = array_merge($this->data, $data);
$templateFile = $this->resolveTemplate($template);
ob_start();
extract($mergedData, EXTR_SKIP); // EXTR_SKIP 不覆盖已有变量
require $templateFile;
return ob_get_clean();
}
/**
* 渲染模板并直接输出
*/
public function display(string $template, array $data = []): void
{
echo $this->render($template, $data);
}
/**
* 解析模板文件路径
*/
private function resolveTemplate(string $template): string
{
// 支持点号分隔符: users.profile -> /users/profile.php
$file = str_replace('.', DIRECTORY_SEPARATOR, $template) . '.php';
$path = $this->templateDir . DIRECTORY_SEPARATOR . $file;
if (!file_exists($path)) {
throw new RuntimeException("Template not found: {$template} ({$path})");
}
return $path;
}
}
// === 使用 ===
$engine = new TemplateEngine(__DIR__ . '/templates');
// 方式一:通过 assign
$engine->assign('title', '用户列表');
$engine->assign('users', $users);
echo $engine->render('users/list');
// 方式二:直接传参
echo $engine->render('users/list', [
'title' => '用户列表',
'users' => $users,
]);模板继承(Layout)
php
<?php
declare(strict_types=1);
/**
* 支持模板继承的原生模板引擎
*/
class LayoutEngine extends TemplateEngine
{
private ?string $currentSection = null;
private array $sections = [];
private string $layout = '';
/**
* 在子模板中开始一个区块
*/
public function start(string $name): void
{
$this->currentSection = $name;
ob_start();
}
/**
* 在子模板中结束一个区块
*/
public function end(): void
{
if ($this->currentSection === null) {
throw new RuntimeException('No section started');
}
$this->sections[$this->currentSection] = ob_get_clean();
$this->currentSection = null;
}
/**
* 设置布局
*/
public function layout(string $layout, array $data = []): void
{
$this->layout = $layout;
// 布局数据会合并到主数据中
$this->assignAll($data);
}
/**
* 渲染区块内容
*/
public function section(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
/**
* 覆盖 render 方法以支持布局
*/
public function render(string $template, array $data = []): string
{
$mergedData = array_merge($this->data, $data);
// 渲染子模板(捕获 sections)
$templateFile = $this->resolveTemplate($template);
ob_start();
extract($mergedData, EXTR_SKIP);
$engine = $this; // 在模板中通过 $engine 操作区块
require $templateFile;
ob_end_clean(); // 子模板的输出被捕获到 sections 中
// 如果有布局,渲染布局
if ($this->layout !== '') {
$layoutFile = $this->resolveTemplate($this->layout);
ob_start();
extract($mergedData, EXTR_SKIP);
$engine = $this;
require $layoutFile;
return ob_get_clean();
}
return $this->sections['content'] ?? '';
}
}布局模板
php
<?php
// templates/layouts/main.php
declare(strict_types=1);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title><?= e($title ?? 'My App') ?></title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
<main>
<?= $engine->section('content') ?>
</main>
<footer>
<p>© <?= date('Y') ?> My App</p>
</footer>
</body>
</html>子模板
php
<?php
// templates/home/index.php
declare(strict_types=1);
$engine->layout('layouts/main', ['title' => '首页']);
$engine->start('content');
?>
<div class="hero">
<h1><?= e($greeting) ?></h1>
<p><?= e($description) ?></p>
</div>
<section class="features">
<?php foreach ($features as $feature): ?>
<div class="feature">
<h3><?= e($feature['title']) ?></h3>
<p><?= e($feature['description']) ?></p>
</div>
<?php endforeach; ?>
</section>
<?php $engine->end(); ?>使用
php
<?php
declare(strict_types=1);
$engine = new LayoutEngine(__DIR__ . '/templates');
echo $engine->render('home/index', [
'greeting' => '欢迎来到我的网站',
'description' => '这是一个使用原生 PHP 模板的示例',
'features' => [
['title' => '快速', 'description' => '高性能模板渲染'],
['title' => '安全', 'description' => '自动输出转义'],
['title' => '灵活', 'description' => '支持模板继承'],
],
]);注意事项
1. extract() 的安全性
php
<?php
// 使用 EXTR_SKIP 防止变量覆盖
extract($data, EXTR_SKIP);
// 不要使用 EXTR_OVERWRITE
// 攻击者可能覆盖 $this, $engine 等关键变量
// 更安全的替代方案:通过 $engine->get() 获取变量
// 在模板中不使用 extract,而是:
// $engine->get('title')2. 不要在模板中编写复杂逻辑
php
<?php
// 错误:在模板中编写业务逻辑
$users = $pdo->query('SELECT * FROM users')->fetchAll();
$adminCount = count(array_filter($users, fn($u) => $u['role'] === 'admin'));
// 正确:模板只负责展示
// 数据准备在控制器中完成
foreach ($users as $user):
// 仅做展示逻辑(条件显示、循环、格式化)
endforeach;最佳实践
1. PHP 模板编码规范
php
<?php
// [x] 始终使用 declare(strict_types=1)
// [x] 输出使用 e() 函数转义
// [x] 模板文件扩展名使用 .php(不是 .phtml 或 .tpl)
// [x] 使用 extract($data, EXTR_SKIP) 传递变量
// [x] 模板中不包含业务逻辑
// [x] 使用输出缓冲捕获渲染结果
// [x] 使用模板继承(布局)避免重复 HTML
// [x] 使用短标签 <?= ... ?> 代替 echo下一节
继续学习:模板引擎概览