Skip to content

模板最佳实践

无论使用原生 PHP 模板还是 Twig/Blade 等模板引擎,良好的模板实践都是构建可维护、安全视图层的关键。本节总结模板开发中的通用最佳实践,涵盖安全编码、性能优化、目录组织和可维护性等方面。

前置知识

阅读本节前,建议先了解:PHP 原生模板模板引擎概览

基础概念

模板职责边界

控制器(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>&copy; <?= 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 的全部学习!可以继续探索其他阶段的教程。

参考链接