tp6.0无限级菜单核心在于规范数据结构:id+pid为默认契约,根节点pid须统一为0或null;非默认字段需重写gettreeoptions()映射;手写递归须防自引用和深度超限;深层菜单推荐懒加载、闭包表或缓存优化。

TP6.0 实现无限级分类菜单,核心不在算法多“炫技”,而在数据结构是否干净、字段约定是否清晰、递归边界是否可控。用对了结构,withTree() 一行搞定;结构不对,手写递归反而埋坑。
标准数据结构:id + pid 是默认契约
ThinkPHP 6 默认只识别两个字段:
- id:当前记录唯一标识(必须为数字或可转为整数的字符串)
- pid(parent id):父级 id;根节点必须为 0 或 null,不能混用(比如部分为 0、部分为 NULL)
示例表结构:menu(id, pid, title, sort),其中 pid=0 表示顶级菜单。若实际字段名不同(如 menu_id/parent_id),必须在模型中重写 getTreeOptions() 显式映射,否则 withTree() 无效,返回仍是扁平数组。
非默认字段?重写 getTreeOptions() 是唯一正解
不要试图“改 SQL”或“手动拼数组”绕过这个约定。直接在模型里声明:
use think\model\concern\Tree;
class MenuModel extends Model
{
use Tree;
protected function getTreeOptions(): array
{
return [
'id' => 'menu_id',
'pid' => 'parent_id'
];
}
}
这样 MenuModel::select()->withTree() 才能正确识别父子关系。注意:该方法只影响内存构树逻辑,不改变查询本身 —— 它不是 N+1,而是查完再组织,所以务必确保一次查出全量菜单数据。
手写递归?先防死循环,再补空 children
若因特殊需求必须手写(如需附加计算、动态过滤),关键防护点只有两个:
- 每次递归前加判断:
if ($item['id'] == $item['pid']) continue;,杜绝自引用导致的无限嵌套 - 深度限制:
if ($level > 8) { Log::warning('menu recursion depth exceeded'); return []; },避免 PHP 默认 256 层爆栈
另外,数据库查出的节点若无子项,children 键根本不存在。模板中直接访问 vo.children 会报 notice。控制器返回前统一补全更稳妥:array_walk($tree, fn(&$item) => $item['children'] = $item['children'] ?? []);
深层菜单(>6 层)?别硬扛,换思路
真实后台菜单极少超 5 层。一旦结构变深,withTree() 内存构树会拖慢响应。这时应考虑:
-
前端懒加载:一级菜单用
withTree()查出;点击展开时,再发请求查where pid = x的子集 - 闭包表(Closure Table):多一张关联表记录所有祖先-后代路径,查任意子树只需一条 SQL
-
缓存整棵树:菜单变动不频繁,
Cache::set('admin_menu_tree', $data, 3600),变更时主动清除即可











