Skip to content

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>&copy; <?= 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

下一节

继续学习:模板引擎概览

参考链接