在ThinkPHP项目里,后台菜单、商品分类、地区数据等通常都以无限级分类方式存进同一张表,字段至少包含id、pid和name。从模型里取出的结果一般是一个线性的一维数组,而很多Vue或React的树形组件需要多维嵌套结构。掌握在框架内高效转换的方法,能减少数据库查询并提升接口性能。

一、为什么不能直接用递归查库
初学者最容易想到的办法是写一个函数,根据pid去数据库里查子级,再对每个子级继续查。这种做法在ThinkPHP里虽然能跑通,但分类层级一深,查询次数就会呈指数级上涨。比如一个三级类目有一百条记录,可能就要发起几十次SQL,明显拖累接口速度。
更合理的思路是先把整张表或某个根节点下的全部记录一次性查出来,得到一维数组,然后在PHP内存中完成树形化。ThinkPHP的Db类或模型all方法都能轻松拿到这种集合,之后用引用处理即可,全程零额外查询。
二、基于引用的一次遍历算法
核心原理是:先以记录的主键id为键,把一维数组重新索引成关联数组;同时让每个元素带上一个空的children字段。然后再次遍历,如果当前记录的pid大于0,就通过引用把它挂到父级记录的children中。由于使用的是引用,挂载后原数组结构同步更新,最后筛出pid为0的顶级节点就是完整多维树。
这种写法时间复杂度是O(n),仅需两次循环,且不依赖数据库。下面给出在ThinkPHP控制器中可直接使用的示例代码,假设数据表为category:
<?php
// 在ThinkPHP控制器方法中
use appcommonmodelCategory;
// 1. 一次性取出所有分类(一维数组)
$list = Category::field('id,pid,name,sort')->select()->toArray();
// 2. 转换为多维数组的函数
function listToTree($data) {
$tree = [];
$refer = [];
// 第一次遍历:建立引用索引
foreach ($data as &$item) {
$item['children'] = [];
$refer[$item['id']] = &$item;
}
unset($item); // 断开foreach的引用,防止后续污染
// 第二次遍历:挂载子节点
foreach ($data as &$item) {
if ($item['pid'] == 0) {
$tree[] = &$item;
} else {
if (isset($refer[$item['pid']])) {
$refer[$item['pid']]['children'][] = &$item;
}
}
}
unset($item);
return $tree;
}
$tree = listToTree($list);
// 此时 $tree 就是多维嵌套数组
代码中的关键点说明
第一处foreach结束后必须写unset($item),否则变量$item还保留着对数组最后一个元素的引用,在第二次遍历赋值时可能意外修改数据。这是PHP引用最常见的坑,在ThinkPHP开发中也经常因此出现树形结果错乱。
另外,如果原表有sort字段,可以在挂载完成后用usort对children排序,保证前端展示顺序正确。引用方式不会改变原$data数组的顶层顺序,只调整了children里的挂靠关系。
三、使用模型关联或集合类的替代方案
ThinkPHP提供了模型关联和Collection对象,也可以间接实现类似效果。例如给Category模型定义hasMany自关联,再用with方法预载入。但预载入本质上仍按层级生成多次查询或合并处理,对于真正无限级且深度不确定的情况,还是上面的一次性取出加引用转换最稳妥。
如果你使用了ThinkPHP的Collection,可以把toArray后的数组交给上面函数;也可以封装成工具类静态方法,在多个控制器里复用。下面示例展示如何封装:
<?php
namespace appcommonutil;
class TreeUtil {
public static function toTree($data) {
$tree = [];
$refer = [];
foreach ($data as &$v) {
$v['children'] = [];
$refer[$v['id']] = &$v;
}
unset($v);
foreach ($data as &$v) {
if ($v['pid'] == 0) {
$tree[] = &$v;
} elseif (isset($refer[$v['pid']])) {
$refer[$v['pid']]['children'][] = &$v;
}
}
unset($v);
return $tree;
}
}
// 调用:appcommonutilTreeUtil::toTree($list);
两种方案对比
| 方案 | 查询次数 | 适用场景 |
|---|---|---|
| 递归查库 | 随层级指数增长 | 数据极少且层级固定 |
| 一维转多维引用法 | 仅1次 | 无限级、接口性能敏感 |
从表格能看出,引用法在绝大多数后台管理系统中都是首选。只要注意释放引用,就不会产生副作用。
四、常见错误与排查
有些开发者把一维数组转树后,发现某些子分类丢失。通常原因是pid字段值存在但对应父id在结果集中被条件过滤掉了,比如只查了pid=0的再递归,却忘了父级也可能不在本次查询范围。因此转换前必须保证传入的$data包含完整链路。
还有人直接在模板里用volist嵌套无限层,导致代码难以维护。更好的做法是控制器里转好多维数组,模板只用一层递归组件渲染。这样业务逻辑和视图分离,也符合ThinkPHP的MVC设计理念。
只要遵循先索引、再挂载、及时unset引用的步骤,在ThinkPHP中把无限级分类的一维数组转换为多维数组就是一件简单且高效的事。