無限級分類是 CMS、電商、OA 系統核心需求。本文深度解析兩種經典實現:遞歸樹狀輸出 (簡單直觀) 與 TreeTable 表格算法 (複雜佈局),含數據庫設計、PHP 核心代碼、前端渲染、性能優化、非遞歸替代方案。
CREATE TABLE `category` ( `id` int UNSIGNED NOT NULL AUTO_INCREMENT, `parent_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '父級ID,0為頂級', `name` varchar(50) NOT NULL, `sort` int NOT NULL DEFAULT 0 COMMENT '同級排序', `level` tinyint NOT NULL DEFAULT 1 COMMENT '冗余層級,便於查詢', `path` varchar(255) NOT NULL DEFAULT '' COMMENT '路徑:0/1/5/ 便於 Like 查詢子樹', PRIMARY KEY (`id`), KEY `idx_parent` (`parent_id`), KEY `idx_path` (`path`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
關鍵冗余字段:level 避免遞歸計算層級,path 支援 LIKE '0/1/%' 一次查出整棵子樹。
<?php
function buildTree(array $items, int $parentId = 0, int $level = 0): array {
$tree = [];
foreach ($items as $item) {
if ($item['parent_id'] == $parentId) {
$item['level'] = $level;
$item['children'] = buildTree($items, $item['id'], $level + 1);
$tree[] = $item;
}
}
return $tree;
}
// 渲染 HTML 選單
function renderSelect(array $tree, string $prefix = '') {
foreach ($tree as $node) {
echo "";
if ($node['children']) {
renderSelect($node['children'], $prefix . '├─ ');
}
}
}
// 使用
$all = $pdo->query("SELECT * FROM category ORDER BY sort")->fetchAll(PDO::FETCH_ASSOC);
$tree = buildTree($all);
echo "";
?>
核心思想:將樹結構映射為二維表格座標系,計算每個節點的 row (行)、col (列)、rowspan (跨行)、colspan (跨列),最終輸出標準 <table>。
<?php
class TreeTable {
private array $nodes = []; // id => node data
private int $maxCol = 0;
private int $totalRows = 0;
public function init(array $flatData): void {
// 1. 建立索引
foreach ($flatData as $row) {
$this->nodes[$row['id']] = $row + [
'children_ids' => [],
'parent_ids' => [],
];
}
// 2. 建立父子關系鏈
foreach ($this->nodes as $id => $node) {
// 子孫 IDs (遞歸收集)
$this->nodes[$id]['children_ids'] = $this->getDescendantIds($id);
// 祖先 IDs
$this->nodes[$id]['parent_ids'] = $this->getAncestorIds($id);
// 層級 (列號)
$this->nodes[$id]['col'] = count($this->nodes[$id]['parent_ids']) + 1;
$this->maxCol = max($this->maxCol, $this->nodes[$id]['col']);
}
// 3. 計算 rowspan (葉子節點數)
foreach ($this->nodes as $id => $node) {
$leafCount = $this->countLeaves($id);
$this->nodes[$id]['rowspan'] = $leafCount ?: 1;
}
// 4. 排序:按祖先路徑字典序,保證父節點在子節點前
uasort($this->nodes, function($a, $b) {
$ap = implode(',', $a['parent_ids']);
$bp = implode(',', $b['parent_ids']);
return strcmp($ap, $bp) ?: ($a['id'] - $b['id']);
});
// 5. 分配行號
$row = 1;
foreach ($this->nodes as $id => $node) {
$this->nodes[$id]['row'] = $row;
$row += $node['rowspan'];
}
$this->totalRows = $row - 1;
// 6. 計算 colspan
foreach ($this->nodes as $id => $node) {
$node['colspan'] = ($node['rowspan'] == 1) ? ($this->maxCol - $node['col'] + 1) : 0;
}
}
private function getDescendantIds(int $id): array {
$ids = [];
foreach ($this->nodes as $nid => $node) {
if (in_array($id, $node['parent_ids'])) $ids[] = $nid;
}
return $ids;
}
private function getAncestorIds(int $id): array {
$ids = [];
$pid = $this->nodes[$id]['parent_id'] ?? 0;
while ($pid && isset($this->nodes[$pid])) {
$ids[] = $pid;
$pid = $this->nodes[$pid]['parent_id'] ?? 0;
}
return array_reverse($ids); // 從根到父
}
private function countLeaves(int $id): int {
if (empty($this->nodes[$id]['children_ids'])) return 1;
$count = 0;
foreach ($this->nodes[$id]['children_ids'] as $cid) {
$count += $this->countLeaves($cid);
}
return $count;
}
public function render(): string {
$html = '| {$indent}{$node['name']} | "; } } $html .= '
<?php
// 1. 一次性讀取所有數據 (按 sort 排序)
$rows = $pdo->query("SELECT * FROM category ORDER BY path, sort")->fetchAll(PDO::FETCH_ASSOC);
// 2. 內存構建樹 (O(N))
$tree = [];
$map = [];
foreach ($rows as $row) {
$map[$row['id']] = $row + ['children' => []];
}
foreach ($map as $id => &$node) {
if ($node['parent_id'] == 0) {
$tree[] = &$node;
} else {
$map[$node['parent_id']]['children'][] = &$node;
}
}
unset($node); // 釋放引用
?>
<!-- TreeTable 支持展開/折疊 -->
<style>
.tree-table td[rowspan] { cursor: pointer; }
.tree-table tr.collapsed + tr { display: none; } /* 簡單隱藏邏輯需 JS 配合 */
</style>
<script>
// Vue 3 組件化 TreeTable (概念)
const TreeTable = {
props: ['data'],
template: `
| ▼ {{ cell.indent }}{{ cell.name }} |
| 模型 | 查詢子樹 | 移動子樹 | 插入/刪除 | 適用場景 |
|---|---|---|---|---|
| 鄰接表 (親ID) | 遞歸/路徑 Like | 簡單 (改 parent_id) | 簡單 | 通用、讀多寫少 |
| 路徑枚舉 (path) | LIKE '0/1/%' 極快 | 需更新所有後代 path | 簡單 | 讀極多、層級固定 |
| 嵌套集合 (lft/rgt) | BETWEEN 極快 | 極複雜 (重排序號) | 複雜 | 幾乎不變動的目錄 |
| 閉包表 (ancestor/descendant) | JOIN 即時 | 批量 INSERT/DELETE | 觸發器維護 | 高頻移動、複雜查詢 |
推薦:中小項目用 鄰接表 + path 冗余,大型高併發用 閉包表 或 物化路徑 + 觸發器。