模板最佳实践
无论使用原生 PHP 模板还是 Twig/Blade 等模板引擎,良好的模板实践都是构建可维护、安全视图层的关键。本节总结模板开发中的通用最佳实践,涵盖安全编码、性能优化、目录组织和可维护性等方面。
基础概念
模板职责边界
控制器(Controller)
├── 获取数据(从数据库/服务获取)
├── 业务逻辑处理
├── 数据格式化/计算
├── 权限检查
└── 调用模板渲染
模板(Template/View)
├── 数据展示
├── 条件显示(显示/隐藏)
├── 循环渲染列表
├── 简单的展示逻辑(奇偶行、截断文本)
└── HTML 结构组织核心原则
模板只负责展示,不负责业务逻辑。数据库查询、权限判断、数据处理等应在控制器或服务层完成。
安全编码实践
自动转义
php
<?php
declare(strict_types=1);
// === 原生 PHP 模板:使用辅助函数 ===
// 所有输出必须转义
?>
<h1><?= e($title) ?></h1>
<p><?= e($description) ?></p>
<input value="<?= e($value) ?>">
<a href="<?= e($url) ?>">Link</a>
<?php
// 如果数据已经是安全 HTML(如经过 HTML Purifier 处理的富文本)
// 使用 raw 输出(需要明确标注)
?>
<div class="content">
<?= $purifiedHtml ?> {# 已经过 purifyHtml() 处理 #}
</div>
<?php
// 使用 raw 变量名约定(带前缀)来标识
?>
<div class="content">
<?= $rawHtmlContent ?>
</div>twig
{# Twig 模板:默认自动转义 #}
<h1>{{ title }}</h1> {# 自动转义 #}
<div>{{ purifiedContent|raw }}</div> {# 已过滤的 HTML 使用 raw #}blade
{{-- Blade 模板 --}}
<h1>{{ $title }}</h1> {{-- 自动转义 --}}
<div>{!! $purifiedContent !!}</div> {{-- 原始输出 --}}输出编码上下文
php
<?php
declare(strict_types=1);
/**
* 不同上下文的编码策略
*/
// HTML 正文 -> htmlspecialchars / e()
echo '<p>' . e($text) . '</p>';
// HTML 属性 -> e_attr() / htmlspecialchars ENT_QUOTES
echo '<input value="' . e_attr($value) . '">';
// JavaScript -> json_encode
echo '<script>var data = ' . e_js($data) . ';</script>';
// HTML data 属性 -> e_attr(e_js($data))
echo '<div data-config="' . e_attr(e_js($config)) . '">';
// URL -> rawurlencode / http_build_query
$query = http_build_query(['q' => $search, 'page' => 1]);
echo '<a href="/search?' . e($query) . '">Search</a>';
// CSS -> 白名单验证
$color = in_array($userColor, $allowedColors, true) ? $userColor : '#000';
echo '<style>body { color: ' . e($color) . '; }</style>';模板继承与布局
目录结构
templates/
├── layouts/
│ ├── main.php # 主布局
│ ├── admin.php # 后台布局
│ └── email.php # 邮件布局
├── components/
│ ├── nav.php # 导航栏
│ ├── pagination.php # 分页组件
│ ├── alert.php # 提示消息
│ └── card.php # 卡片组件
├── users/
│ ├── list.php # 用户列表
│ ├── detail.php # 用户详情
│ └── form.php # 用户表单
├── posts/
│ ├── list.php # 文章列表
│ ├── detail.php # 文章详情
│ └── form.php # 文章表单
├── macros/
│ ├── forms.php # 表单宏
│ └── display.php # 展示宏
└── errors/
├── 404.php # 404 页面
└── 500.php # 500 页面三层布局体系
php
<?php
// templates/layouts/main.php - 基础布局
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title ?? 'My App') ?></title>
<link rel="stylesheet" href="/assets/css/app.css">
<?= $engine->section('styles') ?>
</head>
<body class="<?= e($bodyClass ?? '') ?>">
<header>
<?= $engine->render('components/nav', ['currentUser' => $currentUser ?? null]) ?>
</header>
<main>
<!-- Flash 消息 -->
<?php if (!empty($flashMessage)): ?>
<div class="alert alert-<?= e($flashType ?? 'info') ?>">
<?= e($flashMessage) ?>
</div>
<?php endif; ?>
<?= $engine->section('content') ?>
</main>
<footer>
<p>© <?= date('Y') ?> My App. All rights reserved.</p>
</footer>
<script src="/assets/js/app.js"></script>
<?= $engine->section('scripts') ?>
</body>
</html>组件化模板
php
<?php
// templates/components/pagination.php
// 可复用的分页组件
declare(strict_types=1);
/** @var int $total */
/** @var int $page */
/** @var int $perPage */
/** @var string $baseUrl */
/** @var array $queryParams */
?>
<?php if ($total <= $perPage): ?>
<!-- 只有一页时不显示分页 -->
<?php else: ?>
<nav class="pagination">
<?php
$lastPage = (int) ceil($total / max(1, $perPage));
$queryParams = $queryParams ?? [];
// 上一页
if ($page > 1): ?>
<a href="<?= e($baseUrl . '?' . http_build_query(array_merge($queryParams, ['page' => $page - 1]))) ?>"
class="page-link">上一页</a>
<?php endif;
// 页码
$startPage = max(1, $page - 3);
$endPage = min($lastPage, $page + 3);
if ($startPage > 1): ?>
<a href="<?= e($baseUrl . '?' . http_build_query(array_merge($queryParams, ['page' => 1]))) ?>" class="page-link">1</a>
<?php if ($startPage > 2): ?>
<span class="ellipsis">...</span>
<?php endif;
endif;
for ($i = $startPage; $i <= $endPage; $i++): ?>
<?php if ($i === $page): ?>
<span class="page-link active"><?= $i ?></span>
<?php else: ?>
<a href="<?= e($baseUrl . '?' . http_build_query(array_merge($queryParams, ['page' => $i]))) ?>"
class="page-link"><?= $i ?></a>
<?php endif;
endfor;
// 下一页
if ($page < $lastPage): ?>
<a href="<?= e($baseUrl . '?' . http_build_query(array_merge($queryParams, ['page' => $page + 1]))) ?>"
class="page-link">下一页</a>
<?php endif; ?>
</nav>
<?php endif; ?>性能优化
模板编译缓存
php
<?php
declare(strict_types=1);
// === Twig 缓存配置 ===
$twig = new Environment($loader, [
'cache' => __DIR__ . '/cache/twig', // 生产环境开启
'auto_reload' => false, // 生产环境关闭
'debug' => false, // 生产环境关闭
]);
// === 原生 PHP 模板缓存 ===
class CachedTemplateEngine extends TemplateEngine
{
public function render(string $template, array $data = []): string
{
$cacheKey = md5($template . serialize($data));
if (file_exists($this->cacheDir . '/' . $cacheKey . '.cache')) {
return file_get_contents($this->cacheDir . '/' . $cacheKey . '.cache');
}
$html = parent::render($template, $data);
file_put_contents($this->cacheDir . '/' . $cacheKey . '.cache', $html);
return $html;
}
}避免模板中的重复查询
php
<?php
// 错误:在模板组件中查询数据库
// templates/components/recent-posts.php
$recentPosts = $pdo->query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 5')->fetchAll();
// 正确:控制器中获取数据,传递给模板
class PostController
{
public function index(): void
{
$posts = $this->postRepository->findRecent(5);
$engine->render('home/index', ['recentPosts' => $posts]);
}
}可维护性
数据格式化在控制器中完成
php
<?php
declare(strict_types=1);
// 控制器中格式化数据
class UserController
{
public function show(int $id): void
{
$user = $this->userService->find($id);
// 格式化数据
$formatted = [
'name' => $user->name,
'email' => $user->email,
'avatarUrl' => $user->avatar ?: '/assets/default-avatar.png',
'createdAt' => $user->created_at->format('Y年m月d日'),
'bio' => nl2br(htmlspecialchars($user->bio ?? '', ENT_QUOTES, 'UTF-8')),
'postCount' => number_format($user->posts_count),
'isAdmin' => $user->role === 'admin',
];
$engine->render('users/detail', $formatted);
}
}
// 模板中只做展示
// templates/users/detail.php
?>
<div class="user-profile">
<img src="<?= e($avatarUrl) ?>" alt="<?= e($name) ?>">
<h1><?= e($name) ?></h1>
<p><?= e($email) ?></p>
<p>注册时间: <?= e($createdAt) ?></p>
<?php if ($isAdmin): ?>
<span class="badge">管理员</span>
<?php endif; ?>
<p>共发布 <?= e($postCount) ?> 篇文章</p>
</div>命名规范
php
<?php
// 模板文件命名
// - 使用小写字母和连字符
// - 文件名反映功能:list.php, detail.php, form.php
// 变量命名
// - 模板中的变量名使用 camelCase(与 PHP PSR-1 一致)
// - 布尔变量使用 is/has/can 前缀:$isAdmin, $hasPosts, $canEdit
// 区块命名
// - 使用 snake_case:content, sidebar, styles, scripts注意事项
1. 模板中的时间/时区
php
<?php
// 在应用层统一设置时区
date_default_timezone_set('Asia/Shanghai');
// 模板中只负责格式化显示
// 不在模板中做时区转换
echo $createdAt->format('Y-m-d H:i:s');2. 空值处理
php
<?php
// 模板中安全处理空值
// 使用 null 合并运算符
echo e($user['name'] ?? '匿名用户');
echo e($config['title'] ?? '默认标题');
// 使用 ?: 运算符提供默认值
echo e($user->getDisplayName() ?: '未设置');最佳实践
1. 模板开发检查清单
安全
[x] 所有用户输出使用 e() / 自动转义
[x] HTML 属性使用 ENT_QUOTES
[x] JavaScript 数据使用 json_encode
[x] URL 使用 http_build_query + e()
[x] 富文本经过 HTML Purifier 处理
结构
[x] 使用模板继承(布局)
[x] 可复用组件独立文件
[x] 目录结构清晰
[x] 模板只负责展示
性能
[x] 生产环境开启模板缓存
[x] 不在模板中查询数据库
[x] 不在模板中执行复杂计算
可维护性
[x] 数据格式化在控制器中完成
[x] 命名规范一致
[x] 空值有默认处理下一节
恭喜你完成了阶段 11 的全部学习!可以继续探索其他阶段的教程。