ThinkPHP框架内置模板引擎语法详解与最佳实践
ThinkPHP 的 ThinkTemplate 模板引擎主要解决两件事:把控制器传入的数据转换成 HTML,以及用简洁的标签完成条件判断、循环、模板包含和页面继承。实际开发时并不需要在模板中编写大量 PHP,只要掌握变量输出、if、foreach、volist、include、extend 和 block 这些核心语法,就可以覆盖大多数后台管理系统、内容网站和传统服务端渲染页面的需求。
本文重点讲模板语法本身及其组织方式。不同 ThinkPHP 主版本在视图驱动的安装方式、配置文件位置等方面可能存在差异,因此已有项目应以实际使用版本的配置为准,不要直接照搬其他版本的视图配置。
一、先理解 ThinkPHP 模板是怎样工作的
模板文件并不是独立获取数据库数据的页面。更常见的流程是:控制器或业务层准备数据,把数据传给视图,模板引擎解析模板标签,最终生成浏览器能够接收的 HTML。
例如控制器已经取得一篇文章:
<?php
namespace app\controller;
use app\BaseController;
class Article extends BaseController
{
public function detail()
{
$article = [
'id' => 1001,
'title' => 'ThinkPHP 模板语法入门',
'author' => '张三',
'status' => 1,
];
return view('article/detail', [
'article' => $article,
]);
}
}
模板获得 article 变量之后,只负责显示:
<h1>{$article.title}</h1>
<p>作者:{$article.author}</p>
这种分工非常重要。数据库查询、权限判断、复杂统计等工作应该尽量在模板渲染之前完成,而不是塞进模板。
二、模板变量输出语法
1. 输出普通变量
控制器传入:
return view('index', [
'title' => 'ThinkPHP 教程',
]);
模板中使用:
<h1>{$title}</h1>
{$title} 是最常见的变量标签。左花括号和美元符号之间不要随意插入空格,否则可能无法按照模板变量解析。
2. 输出数组元素
假设控制器传入:
$user = [
'id' => 10,
'name' => '小明',
'age' => 26,
];
模板中常用点语法读取数组:
<p>用户ID:{$user.id}</p>
<p>用户名:{$user.name}</p>
<p>年龄:{$user.age}</p>
这比在页面里反复书写 PHP 数组访问表达式更简洁,也能让 HTML 结构保持清楚。
3. 设置默认值
页面经常需要处理昵称、简介、备注等可能为空的数据,可以利用过滤器设置兜底内容:
{$user.nickname|default='未设置昵称'}
这样模板的含义很明确:有昵称就输出昵称,否则显示“未设置昵称”。
4. 对变量调用函数
简单的格式化工作可以在模板中完成,例如日期格式化:
{$article.create_time|date='Y-m-d H:i'}
或者直接输出表达式或函数结果:
{:url('article/detail', ['id' => $article.id])}
模板函数适合做日期格式化、URL 生成等轻量展示处理,但不建议用它承载业务计算。如果一个表达式已经长到需要反复阅读才能理解,应当提前在控制器或服务层计算好。
5. 普通文本与 HTML 内容要区分
模板输出的安全策略可能受到项目版本和模板配置影响,因此不要凭印象判断某个变量“肯定已经转义”。项目应该明确统一自己的输出规则。
普通用户名、标题、评论等用户可控文本应该按照文本处理;只有经过可信来源或专门 HTML 白名单过滤的富文本,才适合考虑使用原样输出:
{$article.content|raw}
raw 的含义不是“让内容变安全”,而是允许内容按照 HTML 输出。评论、昵称、搜索关键词以及未经处理的表单数据不能因为“需要显示标签”就直接使用 raw。
三、条件判断:if、elseif 和 else
条件标签适合根据已有状态决定页面展示内容。例如后台文章的 status 为 1 时显示“已发布”,否则显示“草稿”。
{if condition="$article.status == 1"}
<span class="status">已发布</span>
{else /}
<span class="status">草稿</span>
{/if}
有三个以上分支时可以加入 elseif:
{if condition="$article.status == 1"}
已发布
{elseif condition="$article.status == 2" /}
待审核
{else /}
草稿
{/if}
条件标签最适合处理“显示或不显示”“显示哪一种文案”“选择哪一个 CSS 类”等展示逻辑。
如果页面里出现下面这种需求:
- 判断当前用户拥有哪几个权限;
- 组合订单状态、支付状态和售后状态;
- 根据十几个条件计算最终价格;
- 在模板中查询数据库后再决定显示内容。
问题通常不在 if 标签怎么写,而在于业务逻辑放错位置。应该把最终需要的状态提前算出来,例如传入 can_edit、status_text、status_class 等变量,让模板只负责展示。
四、循环输出:foreach 与 volist 怎么选
列表页面是模板引擎最常见的使用场景。ThinkTemplate 中经常使用 foreach 和 volist 两种循环。
1. foreach:适合普通遍历
{foreach name="articles" item="article" key="key"}
<article>
<h3>{$article.title}</h3>
<p>ID:{$article.id}</p>
</article>
{/foreach}
name 表示要遍历的模板变量,item 是当前元素,key 用来取得当前数组键。
如果只是把一个数组逐条展示出来,foreach 通常已经足够,而且代码比较直观。
2. volist:适合列表数据展示
{volist name="articles" id="article" key="i"}
<div class="article-item">
<span>{$i}</span>
<a href="{:url('article/detail', ['id' => $article.id])}">
{$article.title}
</a>
</div>
{/volist}
volist 是 ThinkTemplate 中很典型的列表标签,除了基本遍历外,还适合需要序号、截取列表等模板场景。
例如只显示部分数据:
{volist name="articles" id="article" offset="0" length="5"}
<p>{$article.title}</p>
{/volist}
这里表示从列表开始位置取 5 条用于展示。需要注意,这只是模板展示范围,并不能代替数据库层的分页。如果数据库已经查出了 10 万条数据,再通过模板只显示 5 条,前面的查询开销并没有消失。
五、空数据不要靠循环内部处理
文章列表、搜索结果和订单列表都需要考虑数据为空的情况。最清楚的写法通常是先判断列表:
{if condition="!empty($articles)"}
{foreach name="articles" item="article"}
<div class="article-item">
{$article.title}
</div>
{/foreach}
{else /}
<div class="empty">暂无文章</div>
{/if}
这样“有数据”和“无数据”是两个明确分支。不要为了省几行代码,把空状态、首条状态、末条状态、分页状态全部塞进循环内部。
六、switch 适合固定状态映射
当一个变量可能对应若干固定状态时,switch 比连续写很多 if 更容易阅读。例如订单状态:
{switch name="order.status"}
{case value="0"}
待付款
{/case}
{case value="1"}
已付款
{/case}
{case value="2"}
已发货
{/case}
{default /}
未知状态
{/switch}
但这里仍有一个边界:如果状态不仅决定文字,还关联权限、按钮、颜色和后续操作,最好在后端把它整理成结构化数据:
$order['status_text'] = '已付款';
$order['status_class'] = 'paid';
$order['can_cancel'] = true;
模板随后只需要读取这些结果,维护起来会简单很多。
七、include:拆分重复的小块模板
一个完整页面经常包含头部、导航、侧栏、分页、页脚等重复区域。最直接的复用方式是 include:
{include file="public/header" /}
<main>
<h1>{$article.title}</h1>
</main>
{include file="public/footer" /}
适合使用 include 的内容通常具有两个特点:
- 多个页面重复出现;
- 它本身是一个相对独立的 HTML 区块。
例如顶部导航单独维护后,修改菜单结构只需要更新公共模板,不必修改几十个页面。
但不要把页面拆得过碎。如果一段 HTML 只有两三行、只被一个页面使用,把它单独做成模板反而增加查找成本。
八、模板继承:extend 与 block
如果网站几十个页面都使用同一套 HTML 骨架,仅靠多个 include 仍然容易重复。此时模板继承更加合适。
1. 创建基础模板
例如建立 view/base.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{block name="title"}默认标题{/block}</title>
</head>
<body>
{include file="public/header" /}
<main class="container">
{block name="content"}
默认内容
{/block}
</main>
{include file="public/footer" /}
{/block}
</body>
</html>
上面的核心不是具体 HTML,而是两个 block:title 和 content。它们相当于基础模板预留出来的可替换区域。
block,不要把每一个 HTML 标签都做成区块,否则模板继承会失去简化结构的意义。
2. 子模板继承基础模板
文章详情页可以写成:
{extend name="base" /}
{block name="title"}
{$article.title}
{/block}
{block name="content"}
<article>
<h1>{$article.title}</h1>
<p>作者:{$article.author}</p>
<div class="content">
{$article.content|raw}
</div>
</article>
{/block}
子模板不用重新写 html、head、导航和页脚,只覆盖当前页面真正不同的部分。
九、include 和 extend 不要混为一谈
| 方式 | 主要用途 | 典型场景 |
|---|---|---|
include |
插入一个独立模板片段 | 导航、页脚、侧栏、分页、提示框 |
extend |
继承完整页面骨架 | 后台统一布局、内容站统一详情页结构 |
block |
定义基础模板中允许子模板替换的区域 | 页面标题、正文、页面专属 CSS 或 JS 区域 |
一个比较自然的组织方式是:基础模板使用 include 引入公共导航和页脚,再由具体业务页面通过 extend 继承基础模板。
十、一个完整的文章列表模板示例
把前面的知识组合起来,可以得到一个实际可读的文章列表页面。假设控制器已经传入:
return view('article/index', [
'pageTitle' => '最新文章',
'articles' => $articles,
]);
模板可以这样组织:
{extend name="base" /}
{block name="title"}
{$pageTitle}
{/block}
{block name="content"}
<section class="article-list">
<h1>{$pageTitle}</h1>
{if condition="!empty($articles)"}
{volist name="articles" id="article" key="i"}
<article class="article-item">
<span class="number">{$i}</span>
<h2>
<a href="{:url('article/detail', ['id' => $article.id])}">
{$article.title}
</a>
</h2>
<p>
发布时间:
{$article.create_time|date='Y-m-d'}
</p>
{if condition="$article.is_top == 1"}
<span class="tag">置顶</span>
{/if}
</article>
{/volist}
{else /}
<div class="empty">
暂无文章
</div>
{/if}
</section>
{/block}
这个模板已经包含了实际项目中最常见的几个要素:模板继承、变量输出、URL 生成、循环、日期格式化、条件判断和空状态处理。
十一、模板中尽量不要直接写数据库查询
技术上能够在模板中执行某些函数,并不意味着所有代码都适合写在模板里。下面这种思路就不推荐:
<!-- 不推荐:模板自己决定怎样取得业务数据 -->
{php}
// 查询数据库
// 计算权限
// 修改业务状态
{/php}
问题不只是“代码不好看”,还会产生更实际的维护成本:
- 页面为什么显示这个结果,很难从控制器追踪;
- 同一模板被循环包含时,容易出现重复查询;
- 业务逻辑无法方便地复用和测试;
- 设计人员修改模板时可能意外影响业务行为。
更好的方式是后端准备完整结果:
$articles = $service->getArticleList();
return view('article/index', [
'articles' => $articles,
]);
模板只循环:
{foreach name="articles" item="article"}
{$article.title}
{/foreach}
十二、不要让模板里的条件表达式越来越长
下面这样的模板虽然能表达意思,但维护起来已经比较困难:
{if condition="$user.id == $article.user_id
|| ($user.level >= 8 && $article.status == 0)
|| $user.is_admin == 1"}
<button>编辑</button>
{/if}
模板真正关心的问题只有一个:当前用户是否可以编辑。后端应该直接提供这个结果:
return view('article/detail', [
'article' => $article,
'canEdit' => $articleService->canEdit($user, $article),
]);
模板变成:
{if condition="$canEdit"}
<button>编辑</button>
{/if}
这也是判断模板设计是否合理的一个简单方法:如果模板不得不理解业务规则产生结果的全过程,说明后端传给模板的数据还不够适合展示。
十三、避免在循环内部重复执行昂贵操作
例如文章列表有 30 条记录,每一条都需要作者名称。如果模板循环时再根据作者 ID 调用查询方法,可能形成大量重复请求。
不要把逻辑写成类似:
{volist name="articles" id="article"}
{$article.title}
<!-- 不建议在这里再次查询作者 -->
{/volist}
更合理的方式是在查询文章时把作者信息一次准备好,最终得到:
[
[
'id' => 1001,
'title' => '文章 A',
'author_name' => '小明',
],
[
'id' => 1002,
'title' => '文章 B',
'author_name' => '小李',
],
]
模板随后只负责:
{volist name="articles" id="article"}
<h2>{$article.title}</h2>
<p>作者:{$article.author_name}</p>
{/volist}
页面模板因此不会因为展示 30 条记录,就额外触发 30 次作者查询。
十四、模板变量命名应该表达业务含义
模板代码是否容易阅读,很大程度上取决于变量名称。
不太推荐:
{$data.name}
{$data2.title}
{$list2}
更容易理解的是:
{$user.name}
{$article.title}
{foreach name="comments" item="comment"}
{$comment.content}
{/foreach}
当模板文件半年以后再次打开时,不需要回到控制器才能猜出 $data2 究竟表示什么。
十五、控制模板嵌套层级
标签可以嵌套,但嵌套越深,页面越难维护。例如:
{foreach name="categories" item="category"}
{if condition="$category.status == 1"}
{foreach name="category.articles" item="article"}
{if condition="$article.status == 1"}
...
{/if}
{/foreach}
{/if}
{/foreach}
两三层还能理解,如果继续加入权限、标签、评论等逻辑,很快就会形成模板迷宫。
处理方法不是寻找更复杂的模板标签,而是提前整理数据。例如后端只把允许显示的分类和文章传给模板,模板自然可以减少一层甚至两层判断。
十六、模板调试时按照数据流排查
页面变量没有显示时,不要一开始就怀疑模板引擎。按照数据流逐层检查通常更快。
- 确认控制器方法是否真的被当前路由调用。
- 确认控制器取得的数据是否正确。
- 确认传给模板的变量名称与模板使用的名称完全一致。
- 确认模板路径是否指向当前实际渲染的文件。
- 检查
if条件是否导致内容根本没有进入输出分支。 - 检查模板缓存、运行时缓存以及修改后的模板是否已经重新编译。
例如控制器传的是:
return view('detail', [
'articleInfo' => $article,
]);
模板却写:
{$article.title}
这不是循环、缓存或者数据库问题,而是模板变量名称不一致。应该改为:
{$articleInfo.title}
十七、ThinkPHP 模板最佳实践整理
掌握模板语法之后,更重要的是限制模板承担的职责。一个容易维护的 ThinkPHP 页面,通常具有下面的关系:
可以把最佳实践归纳为几条简单规则:
- 控制器和业务层负责取得数据、计算状态和权限。
- 模板主要负责变量输出、简单判断、循环和 HTML 组织。
- 普通数组遍历优先考虑清晰的
foreach;需要列表特性的场景可使用volist。 - 公共小区域使用
include,整套页面骨架使用extend与block。 - 不要在循环内部执行数据库查询或昂贵函数。
- 不要让模板里的
if重新实现后端业务规则。 - 把用户输入、普通文本和可信富文本区分开,谨慎使用
raw。 - 变量名称表达实际业务含义,不滥用
data、list2一类模糊名称。
十八、常见问题
模板中可以直接写 PHP 吗?
模板引擎具备执行 PHP 或表达式的能力并不代表应该大量使用。偶尔处理模板无法自然表达的简单展示逻辑没有必要绝对禁止,但如果页面中出现成片 PHP 代码,通常应该重新检查代码分层。
foreach 和 volist 哪个性能更好?
实际业务中通常没有必要为了微小的模板语法差异做选择。更应该考虑代码是否清楚,以及数据库查询的数据量是否合理。真正容易产生性能问题的通常是一次加载过多数据、循环内部查询数据库或者重复执行昂贵函数。
为什么修改模板后页面没有变化?
先确认修改的是当前真正使用的模板,再检查模板编译缓存和运行时缓存。多应用、多主题或者存在自定义模板目录时,编辑错文件比模板引擎本身出现问题更加常见。
为什么使用 raw 后存在安全风险?
因为 raw 允许字符串中的 HTML 直接参与页面渲染。如果内容中存在恶意脚本或危险属性,浏览器也可能执行这些内容。因此 raw 只解决“是否按 HTML 显示”的问题,不负责判断 HTML 是否安全。
模板里到底应该允许多少逻辑?
可以用一个简单标准判断:只要这段逻辑改变的是“页面怎样呈现”,模板通常可以处理;如果它决定的是“业务规则最终是什么”,一般应该放到模板之外。
例如“状态为 1 时显示绿色标签”属于展示逻辑,可以放在模板中;“当前订单是否允许退款”涉及订单状态、时间、支付方式和权限规则,就应该由业务代码计算完成,再把 can_refund 这样的结果传给模板。
十九、总结
ThinkPHP 的 ThinkTemplate 语法本身并不复杂。日常项目真正需要熟练掌握的核心内容就是 {$变量} 输出、函数过滤、if 条件、foreach 与 volist 循环、include 模板包含,以及 extend + block 模板继承。
真正影响项目长期维护成本的,并不是记住多少模板标签,而是能不能守住模板层的职责边界。控制器和业务层把数据整理成“页面直接可以使用”的状态,模板只负责结构和展示,代码自然会变得清楚。如果一个模板中充满数据库查询、复杂权限判断、长表达式和多层循环,继续学习更多模板语法通常解决不了问题,应该优先重新整理数据和代码分层。
对于大多数 ThinkPHP 服务端渲染项目,可以按照一个简单顺序工作:后端准备完整数据 → 使用基础模板统一页面骨架 → 使用 include 复用局部内容 → 用循环和条件完成展示 → 明确普通文本与富文本的输出边界。做到这些,模板代码通常已经足够清晰、稳定,也更容易继续扩展。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













