PHP 模板与视图分离

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4

PHP 本身就是模板语言,问题不在 PHP,而在把「查数据」和「输出 HTML」写在同一段代码里。这一篇用最少的代码把视图层拆出来。

目标

  • 页面只负责输出,不连数据库;
  • 所有动态输出都经过转义;
  • 布局与局部模板可复用;
  • 不引入模板引擎也能做到结构清晰。

目录结构

app/
  Controllers/
    PostController.php
  Repositories/
    PostRepository.php
  Support/
    render.php
    escape.php
  Views/
    layout.php
    posts/
      index.php
      show.php
    partials/
      nav.php
public/
  index.php

转义助手

<?php
// app/Support/escape.php
declare(strict_types=1);

function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

三条要点:ENT_QUOTES(单双引号都转)、ENT_SUBSTITUTE(非法 UTF-8 用替代字符,而不是返回空串)、显式 UTF-8。模板里所有变量都过 e(),包括你「觉得安全」的数据库字段。

render 函数

<?php
// app/Support/render.php
declare(strict_types=1);

function render(string $view, array $data = [], ?string $layout = 'layout'): string
{
    $viewFile = __DIR__ . '/../Views/' . $view . '.php';
    if (!is_file($viewFile)) {
        throw new RuntimeException("视图不存在:{$view}");
    }

    extract($data, EXTR_SKIP);     // 数组键变成局部变量
    ob_start();
    require $viewFile;
    $content = ob_get_clean();

    if ($layout === null) {
        return $content;
    }

    $layoutFile = __DIR__ . '/../Views/' . $layout . '.php';
    ob_start();
    require $layoutFile;           // 布局里用 $content
    return (string) ob_get_clean();
}

调用方式:

// public/index.php
require __DIR__ . '/../vendor/autoload.php';

$posts = (new PostRepository($pdo))->published();

echo render('posts/index', [
    'title' => '文章列表',
    'posts' => $posts,
]);

extract 有风险(可能覆盖 $viewFile 等变量),所以用 EXTR_SKIP;生产项目更稳的做法是把 $data 整体传入,模板里用 $data['posts']。

布局

<?php
// app/Views/layout.php
/** @var string $content */
/** @var string $title */
?>
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title><?= e($title) ?></title>
  </head>
  <body>
    <?php require __DIR__ . '/partials/nav.php'; ?>
    <main class="container">
      <?= $content ?>
    </main>
  </body>
</html>

<?= $content ?> 不加 e():布局接收的是已经渲染好的 HTML,转义会把标签变成文本。这个「唯一可信变量」要在项目里写清楚。

视图

<?php
// app/Views/posts/index.php
/** @var list<array{id:int,title:string,status:string}> $posts */
?>
<h1><?= e($title) ?></h1>

<?php if ($posts === []): ?>
  <p>还没有文章。</p>
<?php else: ?>
  <ul class="post-list">
    <?php foreach ($posts as $post): ?>
      <li>
        <a href="/posts/<?= e((string) $post['id']) ?>"><?= e($post['title']) ?></a>
        <span class="badge"><?= e($post['status']) ?></span>
      </li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

替代语法(if: / endif;、foreach: / endforeach;)在混排 HTML 时可读性明显更好。<?= ?> 是 <?php echo ?> 的简写,别写成 <?(短标签依赖配置)。

局部模板

<?php
// app/Views/partials/nav.php
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$items = ['/' => '首页', '/posts' => '文章', '/about' => '关于'];
?>
<nav class="nav">
  <?php foreach ($items as $href => $label): ?>
    <a href="<?= e($href) ?>" <?= $current === $href ? 'aria-current="page"' : '' ?>>
      <?= e($label) ?>
    </a>
  <?php endforeach; ?>
</nav>

局部模板只依赖传入的变量或全局状态;不要在局部模板里查数据库。

控制器:把数据准备好

final class PostController
{
    public function __construct(private PostRepository $posts) {}

    public function index(): string
    {
        return render('posts/index', [
            'title' => '文章列表',
            'posts' => $this->posts->published(),
        ]);
    }

    public function show(int $id): string
    {
        $post = $this->posts->find($id);
        if ($post === null) {
            http_response_code(404);
            return render('errors/404', ['title' => '页面不存在']);
        }

        return render('posts/show', [
            'title' => $post->title,
            'post'  => $post,
        ]);
    }
}

模板只拿到「已经算好的数据」:分页信息、格式化后的日期、权限判断结果。视图里不该出现 count()、date() 之外的业务逻辑。

一个页面多个区块

// 收集额外资源再交给布局
$scripts = ['/assets/post.js'];
$content = render('posts/show', ['post' => $post], null);

require __DIR__ . '/../Views/layout.php'; // 布局里用 $content、$scripts

比在视图里 echo '<script>' 干净:脚本依赖集中声明,布局负责输出顺序。

常见错误与注意事项

1. 只转义「看起来危险」的字段

标题、用户名、URL 参数都来自用户,全部转义。唯一例外是已经渲染好的 HTML 片段。

2. 在 href 里直接放用户 URL

// javascript: 伪协议可以执行脚本
<a href="<?= e($url) ?>">

除了转义,还要校验协议:

if (!preg_match('#^https?://#i', $url)) {
    $url = '#';
}

3. 模板里 require '../../config.php'

路径依赖当前工作目录,换个入口就崩。统一用 __DIR__ 或由 render() 解析。

4. 用 extract() 后变量名冲突

模板里 $posts 被覆盖成别的东西,报错信息还很难查。给 $data 加统一前缀,或干脆不 extract。

5. 忘记设置 Content-Type

header('Content-Type: text/html; charset=utf-8');

否则浏览器可能按 latin-1 猜,中文变乱码。

6. 输出缓冲没清理

异常发生时 ob_start() 已经开启,导致错误页前面挂着一堆半成品 HTML。用 try/finally 保证 ob_end_clean()。

7. 把业务规则写进模板

「已发布且未删除且作者是本人」这种条件写三遍就会不一致。在控制器里算成 canEdit 布尔值传进去。

小结

  • render() 统一处理视图、布局与输出缓冲,模板只做展示。
  • e() 是模板里的默认动作,唯一不转义的是布局收到的 $content。
  • 替代语法让 PHP 模板读起来接近普通 HTML。
  • 控制器负责准备数据、权限与状态码,模板不做查询。
  • 输出 HTML 必须带 charset=utf-8。

下一步