PHP递归实现无限级分类:将扁平数组转为树形结构
讲解如何使用PHP递归实现无限级分类,将带有id和parent_id的扁平分类数组转换为children嵌套的树形结构,并通过完整代码、执行结果、递归流程和常见问题帮助理解实现原理。
无限级分类的核心并不复杂:每条分类记录都有自己的 id,同时用 parent_id 指向父分类。PHP只需要根据这个父子关系不断寻找下一层节点,再通过递归把它们放进父节点的 children 中,就能把普通的一维数组转换成层层嵌套的树形结构。
下面从一组实际的分类数据开始,完整实现这个转换过程,并说明递归函数为什么能够处理任意层级。
一、先看扁平数组和树形结构有什么区别
数据库中的分类通常不会直接保存成树,而是一行一条记录。例如商品分类表可能得到下面的数据:
$categories = [
['id' => 1, 'name' => '数码产品', 'parent_id' => 0],
['id' => 2, 'name' => '电脑办公', 'parent_id' => 0],
['id' => 3, 'name' => '手机', 'parent_id' => 1],
['id' => 4, 'name' => '相机', 'parent_id' => 1],
['id' => 5, 'name' => '智能手机', 'parent_id' => 3],
['id' => 6, 'name' => '老人手机', 'parent_id' => 3],
['id' => 7, 'name' => '笔记本', 'parent_id' => 2],
['id' => 8, 'name' => '游戏本', 'parent_id' => 7],
];
这里约定 parent_id = 0 表示顶级分类。其他记录的 parent_id 等于某条记录的 id,就表示它属于那个分类。
例如:
手机的parent_id是 1,所以它属于“数码产品”。智能手机的parent_id是 3,所以它属于“手机”。游戏本的parent_id是 7,所以它属于“笔记本”。
我们最终希望得到的结构类似下面这样:
[
[
'id' => 1,
'name' => '数码产品',
'parent_id' => 0,
'children' => [
[
'id' => 3,
'name' => '手机',
'parent_id' => 1,
'children' => [
['id' => 5, 'name' => '智能手机', 'parent_id' => 3],
['id' => 6, 'name' => '老人手机', 'parent_id' => 3]
]
],
[
'id' => 4,
'name' => '相机',
'parent_id' => 1
]
]
]
]
这种结构特别适合生成多级菜单、商品分类、文章栏目、权限菜单以及地区层级等数据。
二、最直接的递归实现
理解无限级分类时,可以先使用最直观的写法:给递归函数传入一个父级ID,在整个数组中寻找 parent_id 等于这个ID的节点。找到节点以后,再以当前节点的 id 继续寻找它的子节点。
1, 'name' => '数码产品', 'parent_id' => 0],
['id' => 2, 'name' => '电脑办公', 'parent_id' => 0],
['id' => 3, 'name' => '手机', 'parent_id' => 1],
['id' => 4, 'name' => '相机', 'parent_id' => 1],
['id' => 5, 'name' => '智能手机', 'parent_id' => 3],
['id' => 6, 'name' => '老人手机', 'parent_id' => 3],
['id' => 7, 'name' => '笔记本', 'parent_id' => 2],
['id' => 8, 'name' => '游戏本', 'parent_id' => 7],
];
function buildTree(array $items, int $parentId = 0): array
{
$tree = [];
foreach ($items as $item) {
if ($item['parent_id'] == $parentId) {
$children = buildTree($items, $item['id']);
if (!empty($children)) {
$item['children'] = $children;
}
$tree[] = $item;
}
}
return $tree;
}
$tree = buildTree($categories);
print_r($tree);
真正起作用的是这一行:
$children = buildTree($items, $item['id']);
假设当前处理的是“数码产品”,它的 id 是 1,函数就会再次执行:
buildTree($items, 1);
这次只查找 parent_id = 1 的数据,因此可以找到“手机”和“相机”。处理“手机”时,又会执行:
buildTree($items, 3);
于是继续找到“智能手机”和“老人手机”。如果某个节点下面已经没有数据,递归函数返回空数组,这一条路径就结束。
三、完整运行结果
执行上面的PHP脚本后,可以得到类似下面的结果。为了便于阅读,这里使用数组结构展示。
示例输出:
Array
(
[0] => Array
(
[id] => 1
[name] => 数码产品
[parent_id] => 0
[children] => Array
(
[0] => Array
(
[id] => 3
[name] => 手机
[parent_id] => 1
[children] => Array
(
[0] => Array
(
[id] => 5
[name] => 智能手机
[parent_id] => 3
)
[1] => Array
(
[id] => 6
[name] => 老人手机
[parent_id] => 3
)
)
)
[1] => Array
(
[id] => 4
[name] => 相机
[parent_id] => 1
)
)
)
[1] => Array
(
[id] => 2
[name] => 电脑办公
[parent_id] => 0
[children] => Array
(
[0] => Array
(
[id] => 7
[name] => 笔记本
[parent_id] => 2
[children] => Array
(
[0] => Array
(
[id] => 8
[name] => 游戏本
[parent_id] => 7
)
)
)
)
)
)
这里已经看不到固定的“一级分类、二级分类、三级分类”处理代码。无论以后在“游戏本”下面再增加多少层,只要 id 与 parent_id 的关系正确,函数都可以继续向下处理。
四、为什么递归能够实现“无限级”
所谓“无限级”并不是程序真的可以无限递归,而是代码没有把层级写死。例如下面这种方式只能处理固定层数:
// 不推荐:层级被写死
foreach ($level1 as $item1) {
foreach ($level2 as $item2) {
foreach ($level3 as $item3) {
// 只能继续手工增加下一层
}
}
}
递归的区别在于,同一个函数可以调用自己:
function buildTree(array $items, int $parentId = 0): array
{
// ...
$children = buildTree($items, $item['id']);
// ...
}
函数并不关心自己正在处理第2层还是第20层,只关心一个问题:当前节点下面还有没有直接子节点。
五、数据量较大时,不要每层都扫描整个数组
前面的实现非常适合理解递归,但它有一个明显的问题:每处理一个节点,都要重新执行一次 foreach ($items as $item),也就是反复扫描整个分类数组。
分类只有几十条或几百条时通常不明显,但数据很多时,更合理的方式是先按照 parent_id 建立索引。例如:
$grouped = [
0 => [
['id' => 1, 'name' => '数码产品', 'parent_id' => 0],
['id' => 2, 'name' => '电脑办公', 'parent_id' => 0],
],
1 => [
['id' => 3, 'name' => '手机', 'parent_id' => 1],
['id' => 4, 'name' => '相机', 'parent_id' => 1],
],
3 => [
['id' => 5, 'name' => '智能手机', 'parent_id' => 3],
['id' => 6, 'name' => '老人手机', 'parent_id' => 3],
]
];
这样递归到 parent_id = 3 时,可以直接访问 $grouped[3],不必重新遍历所有分类。
推荐的完整实现
1, 'name' => '数码产品', 'parent_id' => 0],
['id' => 2, 'name' => '电脑办公', 'parent_id' => 0],
['id' => 3, 'name' => '手机', 'parent_id' => 1],
['id' => 4, 'name' => '相机', 'parent_id' => 1],
['id' => 5, 'name' => '智能手机', 'parent_id' => 3],
['id' => 6, 'name' => '老人手机', 'parent_id' => 3],
['id' => 7, 'name' => '笔记本', 'parent_id' => 2],
['id' => 8, 'name' => '游戏本', 'parent_id' => 7],
];
/**
* 根据 parent_id 对分类进行分组
*/
function groupByParentId(array $items): array
{
$grouped = [];
foreach ($items as $item) {
$parentId = (int) $item['parent_id'];
$grouped[$parentId][] = $item;
}
return $grouped;
}
/**
* 根据分组后的数据递归构造分类树
*/
function buildTreeFromGroup(array $grouped, int $parentId = 0): array
{
if (!isset($grouped[$parentId])) {
return [];
}
$tree = [];
foreach ($grouped[$parentId] as $item) {
$children = buildTreeFromGroup($grouped, (int) $item['id']);
if (!empty($children)) {
$item['children'] = $children;
}
$tree[] = $item;
}
return $tree;
}
$grouped = groupByParentId($categories);
$tree = buildTreeFromGroup($grouped);
echo '';
print_r($tree);
echo '
';
这个版本仍然使用递归,但寻找子节点时已经不再扫描完整数组。整个处理过程可以理解成两部分:
- 先遍历一次原数组,按照
parent_id建立分组。 - 从
parent_id = 0开始递归,只访问当前父节点对应的那组数据。
如果一个父节点不存在于 $grouped 中:
if (!isset($grouped[$parentId])) {
return [];
}
就说明当前节点已经没有下一层,这同时也是递归结束的条件。
六、把树形数组转换成JSON
如果分类树需要提供给前端JavaScript、Vue、React或接口调用,可以直接通过 json_encode() 输出。
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
$tree,
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
);
示例输出:
[
{
"id": 1,
"name": "数码产品",
"parent_id": 0,
"children": [
{
"id": 3,
"name": "手机",
"parent_id": 1,
"children": [
{
"id": 5,
"name": "智能手机",
"parent_id": 3
},
{
"id": 6,
"name": "老人手机",
"parent_id": 3
}
]
},
{
"id": 4,
"name": "相机",
"parent_id": 1
}
]
}
]
JSON_UNESCAPED_UNICODE 用于避免中文变成Unicode转义形式,JSON_PRETTY_PRINT 只是为了让调试时的JSON更容易阅读,正式接口是否使用可以根据需要决定。
七、如果希望所有节点始终包含children
前面的代码只在存在子分类时添加 children。因此叶子节点会是:
[
'id' => 5,
'name' => '智能手机',
'parent_id' => 3
]
有些前端组件要求每一个节点都存在 children 字段,即使没有子节点也必须是空数组。这时可以直接修改递归函数:
function buildTreeFromGroup(array $grouped, int $parentId = 0): array
{
if (!isset($grouped[$parentId])) {
return [];
}
$tree = [];
foreach ($grouped[$parentId] as $item) {
$item['children'] = buildTreeFromGroup(
$grouped,
(int) $item['id']
);
$tree[] = $item;
}
return $tree;
}
叶子节点就会变成:
{
"id": 5,
"name": "智能手机",
"parent_id": 3,
"children": []
}
这两种形式没有绝对的优劣,关键看后续使用方需要哪一种数据格式。
八、实际使用时容易出现的几个问题
1. 顶级分类的parent_id不是0
示例使用 parent_id = 0 表示根节点,但实际项目也可能使用 null、-1 或其他值。递归函数的起始参数必须和数据库设计保持一致。
例如顶级分类使用 -1:
$tree = buildTreeFromGroup($grouped, -1);
2. parent_id指向不存在的分类
假设存在下面的数据:
[
'id' => 20,
'name' => '异常分类',
'parent_id' => 999
]
但数组中根本没有 id = 999 的节点,那么从根节点0开始递归时,这条记录不会进入正常分类树。
这通常不是递归函数的问题,而是原始数据的父子关系已经不完整。需要检查数据库中的删除逻辑,避免删除父分类之后留下孤立的子分类。
3. 分类关系不能形成循环
如果A的父节点是B,而B的父节点又指向A,就形成了循环关系。更明显的错误是节点把自己设置成父节点:
[
'id' => 10,
'name' => '错误分类',
'parent_id' => 10
]
这种数据会破坏树结构的基本前提,并可能导致递归无法正常结束。因此新增或修改分类时,应禁止把自身以及自身的后代节点设置成自己的父节点。
4. PHP递归并不是物理意义上的无限
“无限级分类”表示业务代码没有固定限制2级、3级或5级,并不代表可以构造真正无限深的数据。如果分类嵌套异常深,会增加函数调用和内存消耗。正常的网站栏目、商品分类、权限菜单通常不会设计成极端深度,但仍应避免错误数据导致异常递归。
九、封装成项目中可以直接使用的函数
如果只需要一个可以直接放入项目的版本,可以把分组和递归构造封装在同一个入口函数中,对外只接收原始数组和根节点ID。
function arrayToTree(array $items, int $rootParentId = 0): array
{
$grouped = [];
foreach ($items as $item) {
$parentId = (int) $item['parent_id'];
$grouped[$parentId][] = $item;
}
$build = function (int $parentId) use (&$build, &$grouped): array {
if (!isset($grouped[$parentId])) {
return [];
}
$result = [];
foreach ($grouped[$parentId] as $item) {
$children = $build((int) $item['id']);
if ($children !== []) {
$item['children'] = $children;
}
$result[] = $item;
}
return $result;
};
return $build($rootParentId);
}
调用时只需要:
$tree = arrayToTree($categories);
如果项目中的根分类使用 -1:
$tree = arrayToTree($categories, -1);
这个函数完成的工作可以概括为:
- 读取扁平数组中的
id和parent_id。 - 按照
parent_id对节点进行分组。 - 从根节点开始寻找直接子节点。
- 对子节点再次执行相同逻辑。
- 把返回的数据写入当前节点的
children。 - 某个节点找不到子节点时结束这一条递归分支。
id 和 parent_id,就可以从根节点开始递归查找子分类,再把结果写入 children。小规模数据可以直接递归扫描数组;数据量较大时,先按 parent_id 建立索引再递归更合适。实际项目还应注意孤立节点、自引用和循环父子关系,否则问题往往来自原始分类数据,而不是递归代码本身。
































