管理后台、多级菜单、地址联动……这些场景背后都离不开一个经典问题:如何将扁平的列表数据转换成树形结构?
在日常开发中,我们经常遇到这样的数据结构:数据库里存的是扁平列表,每条记录通过 parentId 关联父级。但前端展示时,却需要一颗完整的树。
比如下面这个扁平数组:
javascript
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 },
];
我们需要把它转换成这样:
json
[ { "id": 1, "name": "一级菜单A", "parentId": 0, "children": [ { "id": 3, "name": "二级菜单A-1", "parentId": 1, "children": [ { "id": 4, "name": "三级菜单A-1-1", "parentId": 3, "children": [] }
]
}
]
},
{
"id": 2,
"name": "一级菜单B",
"parentId": 0,
"children": [
{ "id": 5, "name": "二级菜单B-1", "parentId": 2, "children": [] }
]
}
]
今天我们就来聊聊这个经典问题的两种高效解法。
一、为什么不用递归?
很多人第一反应是递归查找父子关系:
javascript
function buildTree(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId)
.map(item => ({
...item,
children: buildTree(list, item.id)
}));
}
代码确实简洁,但它的时间复杂度是 O(n²) 。当数据量较大时(比如几百条以上),递归会带来明显的性能问题,而且每次都要遍历全量数据。
更好的做法是 空间换时间。
二、主流方案:Map 索引 + 两次遍历
这种思路的核心是:
- 先用一个 Map(或对象)把所有节点存起来,以
id为键。 - 再遍历一次,通过
parentId找到父节点,把当前节点挂到父节点的children中。 - 如果找不到父节点(
parentId === 0或父节点不存在),则作为根节点。
写法一:使用 Map(ES6)
javascript
function list2tree(list) {
const tree = [];
const map = new Map();
// 第一遍:建立 id -> node 映射,并初始化 children
list.forEach(item => {
map.set(item.id, {
...item,
children: []
});
});
// 第二遍:组装父子关系
list.forEach(item => {
const cur = map.get(item.id);
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(cur);
} else {
tree.push(cur);
}
});
return tree;
}
优点:
- 清晰直观,易读性好。
- 时间复杂度 O(n) ,两次遍历,性能优异。
注意点:
- Map 的 key 可以是任意类型,这里用数字
id很合适。 - 展开运算符
...item复制了原对象属性,避免修改原数据。
写法二:使用对象(Plain Object) + reduce
如果你更偏爱函数式风格,可以用 reduce 一行搞定映射,然后再 reduce 一次构建树:
javascript
function list2tree(list) {
// 构建 id -> node 映射
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] };
return map;
}, {});
// 组装树
return list.reduce((tree, item) => {
const cur = nodeMap[item.id];
const parent = nodeMap[item.parentId];
if (parent) {
parent.children.push(cur);
} else {
tree.push(cur);
}
return tree;
}, []);
}
优点:
- 代码更紧凑,纯函数无副作用。
- 同样 O(n) 复杂度,适合追求简洁风格的同学。
三、两种写法的对比
| 对比维度 | Map 写法 | reduce 写法 |
|---|---|---|
| 可读性 | ⭐⭐⭐⭐⭐ 更清晰 | ⭐⭐⭐⭐ 需理解 reduce |
| 性能 | ⭐⭐⭐⭐⭐ 略快(Map 查找) | ⭐⭐⭐⭐ 对象查找也很快 |
| 代码行数 | 适中 | 更短 |
| 适用场景 | 团队协作、维护友好 | 个人项目、追求简洁 |
两者本质都是 “建立索引 + 二次遍历” ,选哪一种取决于你对代码风格的偏好。
四、进阶思考
1. 如果数据量极大(上万条)?
- 依然可以使用 Map 方案,O(n) 线性复杂度足够应对绝大多数前端场景。
- 如果后端返回的数据已经按
parentId排序,还可以进一步优化,但实际意义不大。
2. 是否需要考虑循环引用?
理论上业务数据不会出现循环引用(比如 A 的父是 B,B 的父又是 A)。如果你不放心,可以在 list2tree 中增加访问标记检测,但绝大多数场景不需要。
3. 根节点的 parentId 一定是 0 吗?
不一定。有些业务会用 null 或 undefined 表示根节点。代码中可以灵活调整判断条件:
javascript
if (parent) { ... } else { ... }
只要父节点在 nodeMap 中找不到,就会自动成为根节点,非常通用。
五、总结
列表转树是前端工程中的“家常便饭”,掌握高效的实现方式,不仅能让代码跑得更快,还能提升代码质量和可维护性。
核心思想一句话总结:
用 Map / 对象建立 id 索引,再通过 parentId 将节点挂载到父节点的 children 中,根节点单独收集。
这两种写法你更喜欢哪一种?欢迎在评论区交流讨论~
如果觉得有用,不妨点个 赞 或 收藏,让更多同学看到这篇文章 😄