JS 列表转树:用 Map 和 reduce 搞懂多级菜单数据转换
前言
在前端开发中,我们经常会遇到一种数据转换需求:后端返回的是一维数组,但页面需要展示成树形结构。
比如管理后台的多级菜单、城市级联选择、部门组织架构、评论楼中楼等场景,数据在数据库里通常是一张表,每一条数据都有自己的 id,同时通过 parentId 指向父级。
这时就需要把"扁平列表"转换成"树结构"。
一、什么是扁平列表?
假设后端返回的数据是这样的:
yaml
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 }
];
这就是扁平列表。
它的特点是:所有数据都在同一层数组里,每一项本身不是嵌套结构。
但是从数据关系上看,它们其实是有层级的:
css
一级菜单A
└─ 二级A-1
└─ 三级A-1-1
一级菜单B
└─ 二级B-1
判断层级关系的关键就是 parentId。
二、id 和 parentId 的作用
每一项数据里通常有两个重要字段:
yaml
{ id: 3, name: '二级A-1', parentId: 1 }
它表示:
id: 3:当前节点自己的编号parentId: 1:当前节点的父节点编号是 1
所以:
yaml
{ id: 3, parentId: 1 }
意思就是:id 为 3 的节点,要挂到 id 为 1 的节点下面。
如果 parentId 是 0,一般表示它没有父节点,是顶层节点。
三、为什么要用 Map?
如果直接在数组里找父节点,每次都要遍历一遍数组。
比如要找 parentId 为 1 的父节点,可能要这样:
ini
list.find(item => item.id === parentId)
如果数据量很大,每个节点都这样找一次,性能就会变差。
所以更好的做法是:先建立一个索引表。
arduino
const map = new Map();
把每个节点按照自己的 id 存起来:
python
map.set(item.id, {
...item,
children: []
});
这样后面查找父节点时,就可以直接:
arduino
map.get(item.parentId)
Map 在这里就像一本目录,通过 id 可以快速找到对应节点。
四、Map 版列表转树
完整代码如下:
ini
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) {
const map = new Map();
const tree = [];
list.forEach(item => {
map.set(item.id, {
...item,
children: []
});
});
list.forEach(item => {
const current = map.get(item.id);
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(current);
} else {
tree.push(current);
}
});
return tree;
}
console.log(JSON.stringify(listToTree(flatList), null, 2));
这段代码分成两步。
第一步:建立节点索引。
ini
list.forEach(item => {
map.set(item.id, {
...item,
children: []
});
});
这一步给每个节点都加上了 children 数组,并且用 id 作为 key 存进 Map。
第二步:组装树结构。
ini
list.forEach(item => {
const current = map.get(item.id);
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(current);
} else {
tree.push(current);
}
});
如果能找到父节点,就把当前节点放进父节点的 children 里。
如果找不到父节点,说明它是顶层节点,就放进最终的 tree 数组。
五、JSON.stringify 的调试作用
树结构比较深的时候,普通 console.log 看起来不够直观。
可以使用:
javascript
console.log(JSON.stringify(listToTree(flatList), null, 2));
其中:
javascript
JSON.stringify(value, null, 2)
第三个参数 2 表示缩进两个空格。
这样打印出来的树结构会更清楚,方便观察每一级的 children。
六、reduce 版实现
除了 Map,也可以使用普通对象 {} 搭配 reduce 实现。
reduce 的作用是把数组一步步归纳成一个结果。
第一轮 reduce 用来生成节点索引表:
ini
const nodeMap = list.reduce((map, item) => {
map[item.id] = {
...item,
children: []
};
return map;
}, {});
它最终会生成类似这样的结构:
yaml
{
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: [] }
}
第二轮 reduce 用来组装树:
ini
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;
}, []);
完整代码如下:
ini
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) {
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;
}, []);
}
console.log(JSON.stringify(listToTree(flatList), null, 2));
七、原 reduce 代码里的问题
原来的 2.js 中有这段代码:
ini
const cur = nodeMap[item.parentID];
if (parent){
parent.children.push(cur);
}
这里主要有三个问题。
第一,字段名写错了。
数据里是:
parentId
代码里写成了:
parentID
JavaScript 是区分大小写的,所以这两个不是同一个字段。
第二,parent 没有声明。
应该先写:
ini
const parent = nodeMap[item.parentId];
第三,cur 表示当前节点,应该通过当前节点自己的 id 获取:
ini
const cur = nodeMap[item.id];
而不是通过 parentId 获取。
正确写法是:
ini
const cur = nodeMap[item.id];
const parent = nodeMap[item.parentId];
八、Map 版和 reduce 版的区别
Map 版更直观,适合刚开始理解列表转树。
arduino
const map = new Map();
它的语义很清楚:通过 key 找 value。
reduce 版代码更紧凑,更偏函数式写法。
javascript
list.reduce((result, item) => {
return result;
}, initialValue);
但是 reduce 对新手来说阅读成本稍高,所以学习时建议先掌握 Map 版,再理解 reduce 版。
实际开发中,两种写法都可以。关键不在于写法,而在于理解这件事:
先建立索引,再连接父子关系。
九、今日总结
列表转树的核心不是递归,而是关系映射。
id 用来表示当前节点是谁,parentId 用来表示当前节点的父亲是谁,children 用来保存当前节点的子节点。
实现时一般分两步:
- 先把所有节点存进索引表
- 再根据
parentId把子节点挂到父节点下面
这类思想在前端业务里很常见,比如管理后台菜单、组织架构、地区选择、评论嵌套等场景都会用到。
学会列表转树,不只是掌握一道算法题,更是在理解前端如何处理真实业务数据。