从数据库扁平行到嵌套树形结构,这一步你每天都在写,但你真的写对了吗?
📌 为什么需要「列表转树」?
在管理后台、电商、CMS 等系统中,树状结构随处可见:
- 🧭 多级菜单 --- 侧边栏导航的无限层级
- 🗂 分类管理 --- 商品类目、文章专栏
- 🏙 地址联动 --- 省/市/区三级选择器
- 📁 组织架构 --- 部门与人员的层级关系
- 💬 评论系统 --- 楼中楼回复
但这些数据在 MySQL 数据库中,通常只存储在一张扁平表里:
sql
-- 典型的数据表结构
-- parentId 是自引用外键,指向同一张表的 id
SELECT * FROM menus;
+----+------------------+----------+
| id | name | parentId |
+----+------------------+----------+
| 1 | 一级菜单A | 0 | -- parentId=0 表示没有上级,即根节点
| 2 | 一级菜单B | 0 | -- 同样是根节点(多根)
| 3 | 二级A-1 | 1 | -- parentId=1,父节点是「一级菜单A」
| 4 | 三级A-1-1 | 3 | -- parentId=3,父节点是「二级A-1」
| 5 | 二级B-1 | 2 | -- parentId=2,父节点是「一级菜单B」
+----+------------------+----------+
🔑 关键字段 :
parentId指向父节点的id,parentId = 0代表根节点。
数据库为什么这样设计? 因为关系型数据库天然是二维表结构,无法直接存储嵌套数据。用 parentId 自引用是标准做法------每一行只关心"我的上级是谁",而不是"我的下级有哪些"。这保持了数据的原子性,增删改都只需要操作一行,不会像嵌套结构那样牵一发动全身。
后端接口返回的就是这样一个 一维数组(扁平列表):
js
// 后端返回的扁平数据 ------ 每个元素都是独立的,没有嵌套关系
const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 }, // 根节点:parentId=0
{ id: 2, name: '一级菜单B', parentId: 0 }, // 根节点:parentId=0
{ id: 3, name: '二级A-1', parentId: 1 }, // 子节点:parentId 指向 id=1
{ id: 4, name: '三级A-1-1', parentId: 3 }, // 孙节点:parentId 指向 id=3
{ id: 5, name: '二级B-1', parentId: 2 }, // 子节点:parentId 指向 id=2
];
// 注意:数组顺序和层级无关,节点可能乱序排列,parentId 才是唯一依据
而前端渲染 <el-tree>、<Tree> 这类树形组件时,需要把数据变成嵌套的 树形结构:
json
[
{
"id": 1,
"name": "一级菜单A",
"parentId": 0,
"children": [ // ← 关键变化:多了 children 数组
{
"id": 3,
"name": "二级A-1",
"parentId": 1,
"children": [ // ← 递归嵌套,深度不限
{
"id": 4,
"name": "三级A-1-1",
"parentId": 3,
"children": [] // ← 叶子节点:children 为空数组
}
]
}
]
},
{
"id": 2,
"name": "一级菜单B",
"parentId": 0,
"children": [
{
"id": 5,
"name": "二级B-1",
"parentId": 2,
"children": []
}
]
}
]
关键变化:每个节点多了一个 children 数组 ,子节点不再是一维数组中的独立元素,而是被递归地嵌套到了父节点的 children 属性中。
这就是 列表转树 要解决的问题。
🧠 核心思路
直观理解:从一维到多维
扁平列表(一维) 树形结构(多维)
[id=1, pid=0] ──────────────┐ ┌─ children: [id=3]
[id=2, pid=0] ───┐ │ │
[id=3, pid=1] ─┐ │ │ │ ┌─ children: [id=4]
[id=4, pid=3] │ │ │ │ │
[id=5, pid=2] │ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────┐
│ ① 建立 id → node 的映射表 │
│ ② 遍历,把每个节点挂到 parent 下 │
└─────────────────────────────────┘
为什么不能一遍直接完成?
你可能会想:遍历到每个节点时,根据它的 parentId 去列表里 find 它的父节点不就行了?
js
// ❌ 朴素做法 ------ O(n²),不推荐
function naiveListToTree(list) {
// 先找出所有根节点(parentId === 0)
return list
.filter(item => item.parentId === 0) // 第1次全量遍历
.map(root => ({
...root,
// 再找出所有父节点是当前 root 的子节点
children: list.filter( // 第2次全量遍历(每个根节点都要遍历一次)
item => item.parentId === root.id
).map(child => ({
...child,
// 想找孙子节点?又得再 filter 一次......无限嵌套,复杂度爆炸
children: list.filter( // 第3次全量遍历
item => item.parentId === child.id
)
// ... 每多一层就多一次全量遍历
}))
}));
}
// 问题:3层嵌套 = 3n² 次操作,5层 = 5n²,1000条数据就要百万级操作
问题在于:每次找父节点或子节点,都要遍历整个列表。如果列表有 1000 条,嵌套三层就是 1000 × 1000 × 1000 次操作,性能崩溃。
正确思路:两遍遍历,O(n) 搞定
| 步骤 | 做什么 | 为什么 | 数据结构变化 |
|---|---|---|---|
| 第一遍 | 遍历列表,为每个节点创建副本并挂上空的 children 数组,存入映射表 { id → node } |
把 "通过 id 找节点" 变成 O(1) 的哈希查找 | list → map{id: node} |
| 第二遍 | 再次遍历列表,通过 parentId 从映射表中瞬间取出父节点,把当前节点 push 进父节点的 children;若无父节点则直接放入结果数组 |
完成树的「编织」,每个节点只被处理一次 | map + list → tree[] |
核心技巧:用空间换时间。多开一个 Map/Object 存放所有节点的引用,后续查找从 O(n) 降为 O(1)。
💻 实现一:Map 版本(逐行精讲)
使用 ES6 的
Map数据结构,语义清晰,是面试和团队协作的首选写法。
js
// ============================================================
// 输入:扁平的菜单列表(来自后端 API)
// ============================================================
const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 },
// ↑ 菜单名称 ↑ 0 表示没有上级 → 根节点
{ id: 2, name: '一级菜单B', parentId: 0 },
// ↑ 另一个根节点 ↑ 同样 parentId=0
{ id: 3, name: '二级A-1', parentId: 1 },
// ↑ 二级菜单 ↑ 父节点是 id=1(一级菜单A)
{ id: 4, name: '三级A-1-1', parentId: 3 },
// ↑ 三级菜单 ↑ 父节点是 id=3(二级A-1)
{ id: 5, name: '二级B-1', parentId: 2 },
// ↑ 另一个二级菜单 ↑ 父节点是 id=2(一级菜单B)
];
// ============================================================
// 核心函数:将扁平列表转换为嵌套树
// @param {Array} list - 扁平列表,每项必须有 id 和 parentId
// @return {Array} tree - 树形结构,只包含根节点数组
// ============================================================
function listToTree(list) {
// ----- 准备工作:初始化两个核心变量 -----
// Map: ES6 的哈希映射结构,key 是节点 id,value 是包装后的节点对象
// 为什么用 Map 而不是 {}?Map 的 key 可以是任意类型,且 .get()/.set() 语义更清晰
const map = new Map();
// tree: 最终返回的树形数组,只存放"找不到父节点"的根节点
const tree = [];
// ==========================================================
// 第一遍遍历:把所有节点注册到 Map 里,建立 id → node 的映射
// 此时节点之间还没有关联,每个节点的 children 都是空数组
// ==========================================================
list.forEach((item) => {
// item 是原始列表中的每一项,例如 { id: 3, name: '二级A-1', parentId: 1 }
// map.set(key, value) ------ 把数据存入 Map
map.set(
item.id, // key: 用节点自己的 id 做键,后面通过 parentId 查找时就靠它
{
...item, // value: 展开原对象的所有属性(id, name, parentId)
// 为什么用展开运算符?避免直接修改 item,保持原数据不变
children: [], // 预置一个空 children 数组,后续把子节点 push 进来
}
);
});
// 遍历结束后,map 里的状态:
// Map {
// 1 → { id:1, name:'一级菜单A', parentId:0, children:[] }
// 2 → { id:2, name:'一级菜单B', parentId:0, children:[] }
// 3 → { id:3, name:'二级A-1', parentId:1, children:[] }
// 4 → { id:4, name:'三级A-1-1', parentId:3, children:[] }
// 5 → { id:5, name:'二级B-1', parentId:2, children:[] }
// }
// 注意:此时 children 全是空数组,节点之间的父子关系还没建立
// ==========================================================
// 第二遍遍历:把每个节点"挂"到它的父节点下面
// ==========================================================
list.forEach((item) => {
// item 仍然是原始列表中的每一项
// 第 1 步:从 Map 中取出当前节点(第一遍已经存入的包装副本)
const current = map.get(item.id);
// 例如 item.id=3 时,current = { id:3, name:'二级A-1', parentId:1, children:[] }
// 第 2 步:根据当前节点的 parentId,从 Map 中找它的父节点
const parent = map.get(item.parentId);
// 例如 item.parentId=1,去 Map 里查 key=1 → 得到「一级菜单A」节点
// 如果 parentId=0,Map 里没有 key=0 → map.get(0) 返回 undefined
// 第 3 步:判断是否有父节点
if (parent) {
// ✅ 找到了父节点 → 把自己 push 到父节点的 children 数组里
parent.children.push(current);
// 重点:push 的是对象引用!
// parent.children 和 current 指向同一个对象,
// 所以后续如果 current 的 children 又被 push 了孙子节点,
// parent.children 里看到的 children 也会自动包含孙子节点。
// 这就是"一次 push,深度不限"的魔法所在。
} else {
// ❌ 没找到父节点(parentId 对应的 id 在 Map 中不存在)
// → 说明自己是根节点,直接推到最终结果 tree 数组
tree.push(current);
// 同样 push 的是引用,所以后续如果这个根节点有了 children,
// tree 里的 children 也会自动更新
}
});
// 返回最终结果:只包含根节点的数组,但 children 中已递归嵌套了所有后代
return tree;
}
// ============================================================
// 运行 & 输出
// ============================================================
// JSON.stringify 第三个参数 2 表示缩进 2 个空格,方便阅读
console.log(JSON.stringify(listToTree(flatList), null, 2));
📖 执行过程可视化
上面代码运行时,每一步数据怎么变化的?下面用表格还原:
第一遍遍历后 map 的状态
Map(5) {
1 → { id: 1, name: '一级菜单A', parentId: 0, children: [] }
2 → { id: 2, name: '一级菜单B', parentId: 0, children: [] }
3 → { id: 3, name: '二级A-1', parentId: 1, children: [] }
4 → { id: 4, name: '三级A-1-1', parentId: 3, children: [] }
5 → { id: 5, name: '二级B-1', parentId: 2, children: [] }
}
第二遍遍历------逐轮追踪
| 轮次 | item.id |
map.get(item.id) 得到什么? |
item.parentId |
map.get(item.parentId) 得到什么? |
执行动作 | 影响 |
|---|---|---|---|---|---|---|
| 1 | 1 | {id:1, name:'一级菜单A', parentId:0, children:[]} |
0 | undefined --- Map 里没有 key=0 |
tree.push(current) |
tree 数组新增第 1 个根节点 |
| 2 | 2 | {id:2, name:'一级菜单B', parentId:0, children:[]} |
0 | undefined |
tree.push(current) |
tree 数组新增第 2 个根节点 |
| 3 | 3 | {id:3, name:'二级A-1', parentId:1, children:[]} |
1 | {id:1, name:'一级菜单A', parentId:0, children:[]} |
parent.children.push(current) |
id=1 的 children 从 [] 变为 [{id:3,...}] |
| 4 | 4 | {id:4, name:'三级A-1-1', parentId:3, children:[]} |
3 | {id:3, name:'二级A-1', parentId:1, children:[]} |
parent.children.push(current) |
id=3 的 children 从 [] 变为 [{id:4,...}] |
| 5 | 5 | {id:5, name:'二级B-1', parentId:2, children:[]} |
2 | {id:2, name:'一级菜单B', parentId:0, children:[]} |
parent.children.push(current) |
id=2 的 children 从 [] 变为 [{id:5,...}] |
核心洞察:push 进去的是 同一个对象的引用
- 第 3 轮把 id=3 push 到 id=1 的 children 后,id=3 节点同时存在于 map 和 tree0.children0 中
- 第 4 轮操作
parent.children.push(current),这里的 parent 就是 id=3,而这个 id=3 正是 map 里的那个对象------也是 tree0.children0 指向的那个对象 - 所以 id=3.children 变了,tree0.children0.children 也跟着变了------它们是同一个内存地址
这就是两遍遍历能构建任意深度嵌套 的根本原因:每个节点只需要关心自己的直接父节点,嵌套的深度由数据中的 parentId 链自然形成,算法本身根本不在乎层级有多少。
第二遍结束后的 tree(最终输出)
tree = [
{
id: 1, name: '一级菜单A', parentId: 0,
children: [
{
id: 3, name: '二级A-1', parentId: 1,
children: [
{
id: 4, name: '三级A-1-1', parentId: 3,
children: [] ← 叶子节点,children 为空
}
]
}
]
},
{
id: 2, name: '一级菜单B', parentId: 0,
children: [
{
id: 5, name: '二级B-1', parentId: 2,
children: [] ← 叶子节点,children 为空
}
]
}
]
💻 实现二:reduce 版本(逐行精讲)
利用
Array.reduce的函数式风格,代码更紧凑。
Array.reduce 快速回顾
js
// reduce 语法 ------ 将数组"归并"成一个值
const 结果 = 数组.reduce((累积值, 当前元素, 索引) => {
// 对累积值进行某种操作
return 新的累积值; // ← 必须 return!
}, 初始值);
// ↑ 初始值会作为第一轮回调的"累积值"传入
| 参数 | 含义 | 示例 |
|---|---|---|
| 回调函数 | 对每个元素执行一次,接收 (累积值, 当前元素) |
(sum, num) => sum + num |
| 初始值 | 第一轮回调中累积值的初始状态 | 0(求和){}(建对象)[](建数组) |
完整实现(每行都有注释)
js
// ============================================================
// 输入数据(同 Map 版本,不再重复注释)
// ============================================================
const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 },
{ id: 2, name: '一级菜单B', parentId: 0 },
{ id: 3, name: '二级A-1', parentId: 1 },
{ id: 4, name: '三级A-1-1', parentId: 3 },
{ id: 5, name: '二级B-1', parentId: 2 },
];
function listToTree(list) {
// ==========================================================
// ① 第一遍 reduce:建立 id → node 的映射表
// ==========================================================
const nodeMap = list.reduce(
(map, item) => {
// map: 累积的对象,上一轮 return 的值(首轮是 {})
// item: 当前遍历到的扁平节点
// 用 item.id 做 key,存储一个带空 children 的副本
map[item.id] = {
...item, // 展开原对象的所有属性(id, name, parentId)
children: [], // 预置空 children,等待后续挂载子节点
};
// ⚠️ 关键:必须 return map!
// 如果不 return,下一轮的 map 就是 undefined,map[item.id] 会直接报错
return map;
},
{} // ← 初始值:空对象,第一轮回调时 map = {}
);
// reduce 结束后,nodeMap 的值:
// {
// 1: { id:1, name:'一级菜单A', parentId:0, children:[] },
// 2: { id:2, name:'一级菜单B', parentId:0, children:[] },
// 3: { id:3, name:'二级A-1', parentId:1, children:[] },
// 4: { id:4, name:'三级A-1-1', parentId:3, children:[] },
// 5: { id:5, name:'二级B-1', parentId:2, children:[] },
// }
// 和 Map 版本的区别:普通对象 {1: ..., 2: ...} vs new Map()
// ==========================================================
// ② 第二遍 reduce:组装树,把每个节点挂到父节点下
// ==========================================================
return list.reduce(
(tree, item) => {
// tree: 累积的结果数组,存放根节点(首轮是 [])
// item: 当前遍历到的扁平节点
// 从映射表中取出当前节点(带 children 的包装副本)
const current = nodeMap[item.id];
// 例如 item.id=3 时,current 就是 nodeMap[3]
// 从映射表中取出父节点
// nodeMap[0] 不存在 → undefined → 说明当前节点是根节点
const parent = nodeMap[item.parentId];
// 例如 item.parentId=1 时,parent 就是 nodeMap[1]
if (parent) {
// ✅ 父节点存在:把当前节点 push 到父节点的 children 里
// push 的是对象引用,后续修改会联动
parent.children.push(current);
} else {
// ❌ 父节点不存在:当前节点是根节点,直接放入结果数组
tree.push(current);
}
// ⚠️ 同样必须 return tree!否则下一轮的 tree 是 undefined
return tree;
},
[] // ← 初始值:空数组,第一轮回调时 tree = []
);
// reduce 结束后,tree 就是最终的嵌套树形结构
}
// ============================================================
// 运行 & 输出
// ============================================================
console.log(JSON.stringify(listToTree(flatList), null, 2));
🔄 执行过程(追踪每一轮 reduce)
第一遍 reduce(建立映射表):
初始值: map = {} ← 空对象
第 1 轮: item = {id:1, name:'一级菜单A', parentId:0}
→ map[1] = {id:1, name:'一级菜单A', parentId:0, children:[]}
→ return map ⇒ map = { 1: {...} } ← 有了第1个key
第 2 轮: item = {id:2, name:'一级菜单B', parentId:0}
→ map[2] = {id:2, name:'一级菜单B', parentId:0, children:[]}
→ return map ⇒ map = { 1: {...}, 2: {...} } ← 有了第2个key
第 3 轮: item = {id:3, name:'二级A-1', parentId:1}
→ map[3] = {id:3, name:'二级A-1', parentId:1, children:[]}
→ return map ⇒ map = { 1: {...}, 2: {...}, 3: {...} }
第 4 轮: item = {id:4, name:'三级A-1-1', parentId:3}
→ map[4] = {id:4, name:'三级A-1-1', parentId:3, children:[]}
→ return map ⇒ map = { 1: {...}, 2: {...}, 3: {...}, 4: {...} }
第 5 轮: item = {id:5, name:'二级B-1', parentId:2}
→ map[5] = {id:5, name:'二级B-1', parentId:2, children:[]}
→ return map ⇒ 最终 nodeMap = { 1:..., 2:..., 3:..., 4:..., 5:... }
第二遍 reduce(组装树,和 Map 版本逻辑完全一致):
| 轮次 | item.id |
current = nodeMap[item.id] |
item.parentId |
parent = nodeMap[item.parentId] |
有父? | 动作 |
|---|---|---|---|---|---|---|
| 1 | 1 | nodeMap[1] |
0 | nodeMap[0] = undefined |
❌ | tree.push(current) |
| 2 | 2 | nodeMap[2] |
0 | nodeMap[0] = undefined |
❌ | tree.push(current) |
| 3 | 3 | nodeMap[3] |
1 | nodeMap[1] = id=1 的节点 |
✅ | parent.children.push(current) |
| 4 | 4 | nodeMap[4] |
3 | nodeMap[3] = id=3 的节点 |
✅ | parent.children.push(current) |
| 5 | 5 | nodeMap[5] |
2 | nodeMap[2] = id=2 的节点 |
✅ | parent.children.push(current) |
最终 tree 和 Map 版本的输出完全一样。
⚠️ reduce 新手常见踩坑 :回调函数忘记
return!如果return丢了,下一轮回调的累积值就是undefined,访问.children或[item.id]都会抛出TypeError。
📊 两种写法深度对比
| 维度 | Map 版本 | reduce 版本 |
|---|---|---|
| 映射表类型 | new Map() --- ES6 HashMap |
{} --- 普通对象 |
| 查找语法 | map.get(key) / map.set(key, val) |
obj[key] / obj[key] = val |
| 遍历方式 | list.forEach(...) 两次 |
list.reduce(...) 两次 |
| 可读性 | ⭐⭐⭐⭐⭐ 意图清晰,"对每个元素做某件事" | ⭐⭐⭐⭐ 需要理解 reduce 的归并语义 |
| 代码量 | 18 行(函数体) | 14 行(函数体) |
| 循环内操作 | forEach 不需要返回值 |
reduce 每次必须 return 累积值 |
| 性能 | Map.get() O(1),大量数据下略优于对象属性查找 |
obj[key] O(1),差异在微秒级 |
| 新手友好度 | ⭐⭐⭐⭐⭐ 不需要理解归并概念 | ⭐⭐⭐ 需要先理解 reduce |
| 函数式纯度 | 较低(forEach 是命令式) | 较高(reduce 是声明式归并) |
选择建议
面试场景 → Map 版本,每一步能讲清楚,不容易犯错
团队协作 → Map 版本,新人看得懂,语义明确
个人项目 → 随意,哪个舒服用哪个
追求简洁 → reduce 版本,少几行代码
函数式风格 → reduce 版本,无副作用,纯归并
ID 是复杂类型 → Map 版本(Map 的 key 支持对象、Symbol 等任意类型)
💡 核心共识:两种写法本质完全相同------都是两遍 O(n) + 哈希映射。选哪个只是风格偏好,不存在优劣之分。
🔍 复杂度分析
时间复杂度:O(n)
├── 第一遍遍历:O(n) 每个元素访问一次
│ └── map.set / obj[id]= :O(1) × n 次,哈希插入常数时间
├── 第二遍遍历:O(n) 每个元素再访问一次
│ ├── map.get / obj[id]:O(1) × n 次,哈希查找常数时间
│ └── children.push(): O(1) × n 次,数组追加常数时间(均摊)
└── 总计:O(n) + O(n) = O(2n) ≈ O(n)
空间复杂度:O(n)
├── 映射表:n 个节点的副本,每个多一个 children 数组引用 → O(n)
├── children 数组引用:n 个节点 − 根节点数,总引用仍为 O(n)
└── tree 结果:根节点数,已包含在映射表引用中,不额外计入
⚠️ 常见坑点 & 注意事项
| 坑点 | 严重程度 | 说明 | 解决办法 |
|---|---|---|---|
| 直接修改原数据 | 🔴 高 | item.children = [] 会污染 flatList,后续代码如果依赖原数据会出 bug |
始终用 ...item 展开创建新对象 |
| 循环引用 | 🔴 高 | 脏数据可能出现 A→B→A 死循环(如 id=1 parentId=2,id=2 parentId=1),push 时不会报错但遍历树时会无限递归 |
生产环境加 visited Set 做环检测,或在入参校验阶段提前发现 |
| parentId 指向不存在的节点 | 🟡 中 | 孤儿节点因找不到父节点,会成为根节点出现在 tree 中,可能渲染出预期之外的"一级菜单" | 根据业务决定:丢弃孤儿、保留并标记 orphan: true、或打日志告警 |
| 大数据量(>10万) | 🟢 低 | 10 万条以下前端转换无压力;超过后主线程可能卡顿(JS 单线程) | 后端完成转换、分页加载、或丢到 Web Worker 中处理 |
| reduce 忘了 return | 🔴 高 | 回调不 return 累积值,下一轮累积值就是 undefined,.push() 或 [item.id] 直接 TypeError |
写完 reduce 先检查每个代码路径是否都有 return |
| id 为 0 的边界情况 | 🟡 中 | 如果 id=0 是合法节点,但 parentId=0 约定为根节点,两者冲突 |
约定根节点的 parentId 用 null 或 -1,避免和合法 id 冲突 |
| children 属性冲突 | 🟡 中 | 原始数据中可能已经有 children 字段(如后端返回了部分嵌套),直接覆盖会导致数据丢失 |
开始转换前检查原始数据是否有 children,有则保留或合并 |
📝 小结
列表转树的核心就一句话:先建映射,再挂孩子。
- 第一步 :遍历列表,给每个节点建副本(
...item)+ 空children,存入 Map 或普通对象 - 第二步 :再次遍历列表,通过
parentId从映射表里 O(1) 找到父节点,把当前节点push进父节点的children - 本质 :空间换时间。多存一份引用,把 O(n²) 降到 O(n),10000 条数据快 1000 倍
- 魔法 :
push进去的是对象引用,一个节点被 push 后,后续对它的任何修改都会自动反映在所有持有该引用的地方------这就是两遍遍历能构建任意深度树的根本原因 - 注意 :不修改原数据(
...item)、reduce 必须return、生产环境考虑环检测和孤儿节点处理
理解了这个模式,无论是菜单、分类、组织架构还是评论楼中楼,都能从容应对。下次面试被问到时,别忘了把两种写法都亮出来 😎