
前言「二级分类」不是 Smarty 的概念它是一个数据组织问题。很多人卡在这里是因为一开始就想在模板里解决层级关系——在模板里写{if $cat.pid 0}之类的判断再想办法「把子分类找出来」结果是既写不出来也写不快。正确的思路只有一句话在 PHP 侧把两级结构组装好模板侧只负责两层循环。数据库里存的是扁平表每行一个pid指向父级PHP 负责把它变成「父级数组每个父级带一个 children 数组」模板拿到的已经是树写两个嵌套的{foreach}就结束了。本文用一个分类表贯穿一次查询取出全部行在内存里按pid分组再用嵌套循环渲染。示例基于 PHP 8.1、Smarty 4.x、MySQL 8。文中代码无法在本机执行验证本机没有 PHP 运行时和数据库请当作逐行推演过的骨架代码阅读落地前请自行实测。一、表结构与扁平数据分类表通常长这样pid为 0 表示顶级分类CREATE TABLE category (id INT UNSIGNED NOT NULL AUTO_INCREMENT,pid INT UNSIGNED NOT NULL DEFAULT 0,name VARCHAR(64) NOT NULL,sort_order INT NOT NULL DEFAULT 0,PRIMARY KEY (id),KEY idx_pid (pid)) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个设计细节值得强调pid用 0 而不是NULL表示顶级。NULL在 SQL 里参与比较时会得到UNKNOWNWHERE pid NULL永远不成立得写IS NULL用 0 可以少一类边界情况。索引加在pid上。按pid过滤是这个表最主要的查询方式。取数据时一次查全部不要「先查父级再对每个父级查一次子级」?php // 适用于 PHP 8.0declare(strict_types1);$pdo new PDO(mysql:host127.0.0.1;dbnameshop;charsetutf8mb4,app,secret,[PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC,PDO::ATTR_EMULATE_PREPARES false,]);$rows $pdo-query(SELECT id, pid, name FROM category ORDER BY pid ASC, sort_order ASC, id ASC)-fetchAll();ORDER BY里把sort_order放在前面排序依据之后可以保证分组后每个父级下的子分类本身也是有序的。二、在 PHP 里分组两次遍历搞定分组逻辑只需要两趟循环不需要递归也不需要额外的数据结构?php // 适用于 PHP 8.0/*** 把扁平的分类行组装成两级结构。* 返回每一项都是 [id..,pid..,name..,children[..]]*/function buildTwoLevel(array $rows): array{$byPid [];foreach ($rows as $row) {// 强转成 intMySQL 驱动可能把 id/pid 返回成字符串$byPid[(int) $row[pid]][] $row;}$tree $byPid[0] ?? []; // 顶层分类pid 0foreach ($tree as $parent) {$parent[children] $byPid[(int) $parent[id]] ?? [];}unset($parent); // 关键断开引用别漏return $tree;}$smarty-assign(cats, buildTwoLevel($rows));这段代码里有三处必须留意$byPid[0] ?? []用的是空合并运算符。如果没有顶级分类$byPid[0]不存在直接取会得到null模板遍历null会出问题。(int)强制转换不是多余的。用 PDO 且关闭了模拟预处理时MySQL 的整数列通常返回int但开启模拟预处理或使用某些驱动时所有列都会以字符串形式返回。转成 int 能让$byPid的键统一避免出现1和1两个不同的键。unset($parent)必须在循环结束后立刻写。foreach ($tree as $parent)里的$parent在循环结束后仍是指向最后一个元素的引用如果后面再用同名变量做别的循环会把最后一个元素悄悄改掉。三、模板里的两层循环模板拿到树之后只剩嵌套循环ul classcat-list{foreach $cats as $cat}li classcat-itema href/list.php?cid{$cat.id}{$cat.name}/a{if $cat.children|count 0}ul classcat-sub{foreach $cat.children as $sub}lia href/list.php?cid{$sub.id}{$sub.name}/a/li{/foreach}/ul{/if}/li{foreachelse}li classempty暂无分类/li{/foreach}/ul几个要点外层的{foreachelse}在$cats为空数组时执行用来兜底「暂无分类」。内层同理可以加{foreachelse}。{$cat.children|count 0}里的表示对整个数组应用 count这是 Smarty 判断数组非空的惯用写法。写成{if $cat.children}也可以——空数组在 PHP 里是假值。如果业务上不关心空数组可以省掉{if}直接渲染内层{foreach}空数组时它什么也不输出。需要循环序号时用前缀读取循环属性{foreach $cats as $cat}li第 {$catiteration} 个下标 {$catindex}{$cat.name}{if $catfirst}第一个{/if}{if $catlast}最后一个{/if}/li{/foreach}index从 0 开始iteration从 1 开始total是元素总数first/last是布尔值。这些属性用的是「元素变量名 属性名」的形式Smarty 3 起推荐这种写法更老的{$smarty.foreach.循环名.index}是 Smarty 2 的形式需要给{foreach}起name属性才可用新代码不要再用。同时拿到键名时用箭头形式{foreach $byId as $id $item}li#{$id} {$item.name}/li{/foreach}四、层级超过两级怎么办分类表往往长成三级甚至不定层级。这时候有两种务实的做法做法一只做两级多出来的层级用扁平化展示。取数据时用递归 CTE 或程序递归算出每个节点的level和完整路径然后按level输出带缩进的平铺列表。好处是模板只需要一层循环缩进由level决定{foreach $flatCats as $cat}li stylepadding-left:{math equationlevel * 20 level$cat.level}px{$cat.name}/li{/foreach}做法二模板递归包含。用一个模板文件渲染「一个节点及其子树」在文件内部{include}自己{* node.tpl递归渲染一棵子树 *}ul{foreach $nodes as $node}li{$node.name}{if $node.children|count 0}{include filenode.tpl nodes$node.children}{/if}/li{/foreach}/ul递归包含能用但有两个前提必须满足一是数据本身不能有环数据库里pid指回了自己的祖先递归会无限展开直到栈溢出二是层级要有限深度很大的树在模板里递归性能不如在 PHP 侧一次算好。对绝大多数电商、资讯类站点来说两级是够用的硬做多级反而让模板和运营都变复杂。常见坑点❌foreach ($tree as $parent)之后不写unset($parent)✅ 循环结束立刻unset($parent)。残留的引用会让下一次同名 foreach 改写数组最后一个元素出问题时数据看起来「差一条、错一条」极难定位。❌ 用 N1 查询先查顶级再对每个顶级查一次子级✅ 一次查出全部行在内存里按pid分组。分类表的数据量小一次取回的成本远低于多次往返数据库。❌ 把pid直接当数组下标用不转类型✅ 强制(int)。否则可能出现1与1混用PHP 会把「数字字符串」键自动转成整数但只在特定条件下行为容易记错。❌ 用WHERE pid NULL查顶级分类✅ 顶级用pid 0或者写WHERE pid IS NULL。和NULL比较的结果永远是UNKNOWN一行都查不出来。❌ 在模板里用{section}遍历分类树✅ 用{foreach}。{section}的loop要求是 0 起步的整数下标数组而以pid或id为键的分组结果是关联数组交给{section}只会得到空白。❌ 把has_child之类的标记写死在模板里判断✅ 数据里没有的字段不要假设它存在。要么在 PHP 侧补上标记字段要么用{$cat.children|count 0}这种基于真实数据的判断。❌ 分类名直接{$cat.name}输出不转义✅ 分类名允许运营在后台填写等同于用户输入。要么在 PHP 侧打开$smarty-escape_html true要么写成{$cat.name|escape:html}否则一个带尖括号的分类名就能注入脚本。❌ 让子分类的pid指向任意父级不做层级校验✅ 保存时校验「父级不能是自己也不能是自己的后代」。数据一旦成环递归包含的模板会一直展开下去很容易打满内存。总结环节做法关键点表设计pid为 0 表示顶级索引加在pid上避免用NULL表示顶级取数据一次查出全部按sort_order排好不要 N1组装先按pid分组再挂到父级上键强转 int引用循环后unset渲染外层{foreach}套内层{foreach}用{foreachelse}兜底空集序号{$itemiteration}/{$itemindex}Smarty 3 起的新写法多层扁平化加缩进或模板递归包含递归前必须确认数据无环一句话结论二级分类的难点不在模板语法而在「谁负责组装层级」。把分组逻辑放在 PHP 侧模板里就只剩两个{foreach}代码短、好测、也容易改。真要扩展到多级优先考虑扁平化后按层级缩进输出而不是在模板里做递归。