PHP 模板与视图分离
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
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。