本文目录导读:

- 方法一:邻接表 (Adjacency List) - 最常用
- 方法二:递归获取所有子分类 ID (用于权限或展示)
- 方法三:路径枚举 (Materialized Path)
- 方法四:嵌套集 (Nested Set) - 高性能读
- 实战建议
- 最后:一个完整的树形下拉选择框生成函数
在 PHP 项目中处理无限级分类,核心在于数据表设计和查询/遍历算法,常用的方法主要有三种:邻接表、路径枚举 和 嵌套集。
对于大多数中小型 PHP 项目(如 CMS、电商、论坛),邻接表 + 递归 是最常用且直观的方案;对于大量读取、不常修改的场景,嵌套集 效率更高。
下面分别说明这些方法,并给出 PHP 代码示例。
邻接表 (Adjacency List) - 最常用
这是最经典、理解成本最低的方法,数据表中每个节点记录其父节点的 ID。
数据表结构 (MySQL)
CREATE TABLE `categories` (
`id` INT UNSIGNED AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`parent_id` INT UNSIGNED DEFAULT 0, -- 父分类ID,顶级为0
`sort` INT UNSIGNED DEFAULT 0, -- 排序字段
PRIMARY KEY (`id`),
INDEX `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
PHP 获取树形结构 (递归)
最标准的做法是:一次查询所有数据,然后在内存中递归构建树。
class CategoryTree
{
/**
* 获取所有分类并构建为树形结构
* @param array $categories 从数据库查出的所有分类(索引数组)
* @param int $parentId 父级ID
* @return array 树形结构
*/
public static function buildTree(array $categories, int $parentId = 0): array
{
$tree = [];
foreach ($categories as $category) {
if ((int)$category['parent_id'] === $parentId) {
// 递归查找子节点
$children = self::buildTree($categories, (int)$category['id']);
if ($children) {
$category['children'] = $children;
}
$tree[] = $category;
}
}
return $tree;
}
/**
* 获取某个分类的完整路径(用于面包屑导航)
* @param array $categories 所有分类
* @param int $id 目标分类ID
* @return array 从顶级到自身的路径
*/
public static function getPath(array $categories, int $id): array
{
$path = [];
// 建立 ID => 数据的映射,避免循环查找
$map = [];
foreach ($categories as $cat) {
$map[$cat['id']] = $cat;
}
// 向上追溯
$currentId = $id;
while (isset($map[$currentId])) {
array_unshift($path, $map[$currentId]); // 插入到数组头部
$currentId = (int)$map[$currentId]['parent_id'];
}
return $path;
}
}
// 使用示例(在控制器中)
// $categories = DB::table('categories')->orderBy('sort')->get()->toArray();
// $tree = CategoryTree::buildTree($categories);
优点:
- 表结构简单,增删改分类非常方便(只需要改
parent_id)。 - 查询某个分类的子孙(递归获取)逻辑清晰。
缺点:
- 如果层级很深(如>5级),递归遍历所有节点时 PHP 内存消耗较大。
- 查询某个节点的“所有子节点”或“所有父级”需要多次数据库查询(或一次查出所有在 PHP 中处理)。
递归获取所有子分类 ID (用于权限或展示)
这是邻接表中的常见需求:给定一个分类ID,获取它及其所有子分类的ID列表。
/**
* 获取一个分类及其所有子分类的ID列表
* @param array $categories 所有分类
* @param int $parentId 起始分类ID
* @return array ID数组
*/
function getChildrenIds(array $categories, int $parentId): array
{
$ids = [$parentId];
foreach ($categories as $cat) {
if ((int)$cat['parent_id'] === $parentId) {
// 递归合并子分类的ID
$ids = array_merge($ids, getChildrenIds($categories, (int)$cat['id']));
}
}
return $ids;
}
// 使用示例:查询分类ID=5 下面所有子分类
// $allIds = getChildrenIds($allCategories, 5);
// 然后可以使用 whereIn 查询这些分类下的文章
路径枚举 (Materialized Path)
在表中增加一个 path 字段,存储从根到当前节点的路径(如 0,1,5,8 或 1-5-8)。
表结构
CREATE TABLE `categories` (
`id` INT UNSIGNED AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`path` VARCHAR(500) NOT NULL DEFAULT '0', -- 路径,如 '0,1,2'
`parent_id` INT UNSIGNED DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_path` (`path`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
PHP 插入与查询
插入时:
// 假设新增分类属于父ID=5
$parentCategory = DB::table('categories')->find(5);
$newPath = $parentCategory->path . ',' . $parentCategory->id; // 变成 '0,5'
DB::table('categories')->insert([
'name' => '新分类',
'path' => $newPath,
'parent_id' => 5
]);
查询某个节点的所有子节点:
// 查询ID=5的所有后代
$pathPrefix = DB::table('categories')->where('id', 5)->value('path') . ',' . 5; // '0,5'
// 这里假设path格式用逗号分隔,且逗号后没有空格
$children = DB::table('categories')
->where('path', 'LIKE', $pathPrefix . '%')
->orWhere('id', 5) // 包含自身
->get();
优点:
- 查询某个节点的所有子孙非常快(单次 LIKE 查询)。
- 查询某个节点的祖先(通过解析 path 字段)也很快。
缺点:
- 移动分类时需要更新所有子孙的 path 字段(较麻烦)。
- 如果层级很深,path 字段可能变得很长。
嵌套集 (Nested Set) - 高性能读
适合读非常频繁、写很少的场景(如商品分类树),每个节点存储 lft 和 rgt 两个值,形成一个嵌套区间。
表结构
CREATE TABLE `categories` (
`id` INT UNSIGNED AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`lft` INT UNSIGNED NOT NULL,
`rgt` INT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_lft_rgt` (`lft`, `rgt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键查询
查询某个节点的所有子孙:
SELECT * FROM categories WHERE lft BETWEEN ? AND ? ORDER BY lft;
为该节点的 lft 和 rgt。
查询某个节点的所有祖先:
SELECT * FROM categories WHERE lft < ? AND rgt > ? ORDER BY lft;
PHP 插入示例 (较复杂)
插入一个节点需要重新计算左右值,通常需要锁表或事务。
// 在父节点(lft=2, rgt=7)下新增一个子节点
$rightOfParent = 7;
DB::statement("UPDATE categories SET lft = lft + 2 WHERE lft > ?", [$rightOfParent]);
DB::statement("UPDATE categories SET rgt = rgt + 2 WHERE rgt >= ?", [$rightOfParent]);
DB::table('categories')->insert([
'name' => '新节点',
'lft' => $rightOfParent,
'rgt' => $rightOfParent + 1
]);
优点:
- 查询子树或祖先的速度极快(单次SQL,无需递归)。
- 删除非叶子节点不会破坏树的结构。
缺点:
- 增加、删除、移动节点需要更新大量记录的 lft/rgt,写操作性能较差。
- 实现复杂,容易出错。
实战建议
| 场景 | 推荐方案 |
|---|---|
| 一般后台管理、CRUD频繁(文章分类) | 邻接表 + 一次加载+递归 |
| 层级很深(>10级),查询子树频繁 | 路径枚举 |
| 极端读多写少,性能要求极高 | 嵌套集 |
| 数据量极小(几百条以内) | 邻接表足够,不需要优化 |
一个折中方案: 使用邻接表存储数据,但在查询时一次性加载所有分类到内存,然后使用 PHP 递归构建树(比多次查询数据库快得多)。
对于企业级项目,也可以考虑使用 laravel-nestedset (Laravel 包) 或 doctrine-extensions 的 Tree 行为。
一个完整的树形下拉选择框生成函数
/**
* 生成用于 <select> 的下拉选项(带缩进)
* @param array $tree 树形结构(buildTree的返回值)
* @param int $level 层级(用于控制缩进)
* @return array [ ['id'=>1, 'name'=>'---分类名'] ]
*/
function flattenTree(array $tree, int $level = 0): array
{
$options = [];
$prefix = str_repeat('—', $level);
foreach ($tree as $node) {
$options[] = [
'id' => $node['id'],
'name' => ($level > 0 ? $prefix . ' ' : '') . $node['name']
];
if (isset($node['children'])) {
$options = array_merge($options, flattenTree($node['children'], $level + 1));
}
}
return $options;
}
// 使用
// $tree = buildTree($allCategories);
// $options = flattenTree($tree);
// 然后渲染为 <option value="...">...</option>
是 PHP 处理无限级分类最常见的几种方案,推荐从邻接表 + 一次加载开始,绝大多数场景都足够用了,如果后期遇到性能瓶颈,再重构为路径枚举或嵌套集。