后端返回扁平数组,前端要展示多级菜单。这道经典题把 HashMap、reduce、引用类型三个知识点串到了一起。
本文把每一行代码、每一个关键字和函数的作用都讲一下,适合初学者从 0 搞懂。
一、问题背景:扁平数据 vs 树形数据
1.1 后端给的数据(扁平数组)
js
const flatList = [
{ id: 1, name: '一级菜单A', parentId: 0 }, // parentId=0 表示顶级
{ id: 2, name: '一级菜单B', parentId: 0 },
{ id: 3, name: '二级菜单A-1', parentId: 1 }, // 父亲是 id=1
{ id: 4, name: '三级菜单A-1-1', parentId: 3 }, // 父亲是 id=3
{ id: 5, name: '二级菜单B-1', parentId: 2 } // 父亲是 id=2
]
特点:
- 一维数组,每个元素是一条记录,没有嵌套
- 每条记录有
id(自己编号)和parentId(父亲编号) parentId: 0是约定俗成的"哨兵值",表示"我是顶级,没有父亲"
1.2 前端要的数据(树形结构)
less
一级菜单A (id:1)
└── 二级菜单A-1 (id:3)
└── 三级菜单A-1-1 (id:4)
一级菜单B (id:2)
└── 二级菜单B-1 (id:5)
特点 :子节点嵌套在父节点的 children 数组里。
1.3 转换目标
js
listToTree(flatList) → [ 根节点1, 根节点2 ] // 每个根节点带嵌套的 children
关键 :靠 parentId 字段串成父子关系。
二、朴素解法为什么不行
2.1 最直觉的写法:递归找孩子
js
function listToTree(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId) // 每次扫整个 list 找孩子
.map(item => ({
...item,
children: listToTree(list, item.id) // 递归往下找
}))
}
2.2 痛点 1:慢(O(n²))
每个节点都要把整个 list 扫一遍找它的孩子。n 个节点就是 n × n = O(n²)。
1000 个节点要算 100 万次,数据一多就卡。
2.3 痛点 2:怕数据顺序乱
如果数据是"孩子在父前面":
js
[
{ id: 4, parentId: 3 }, // 孙子先出现
{ id: 1, parentId: 0 }, // 根最后出现
]
递归找爹时,爹还没出现,直接挂不上。
三、优化思路:HashMap 把"找父亲"降到 O(1)
3.1 核心洞察
朴素解法慢,是因为每次找父亲都要遍历整个数组。
如果先把所有节点按 id 存成一个"字典",根据 id 一步就能拿到节点,找父亲就是 O(1) 了。
scss
没索引:找父亲 → 遍历整个 list → O(n)
有索引:找父亲 → map.get(parentId) → O(1)
这就是 HashMap(哈希表) 的价值:用空间换时间。
3.2 两步走
bash
第一步:建索引
遍历 list,把每个节点按 id 存进 Map
→ 得到 { id → 节点 } 的字典
第二步:串树
再遍历 list,每个节点按 parentId 查字典找父亲
→ 找到父亲 → 挂到父亲的 children
→ 找不到父亲(parentId=0)→ 当根,进 tree
总复杂度:O(n) + O(n) = O(n),从 100 万次降到 2000 次。
四、解法一:forEach + Map
4.1 完整代码
js
function listToTree(list) {
const map = new Map(); // ① 创建 HashMap
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; // ⑤ 返回最终的树
}
4.2 ① const map = new Map();
做什么
创建一个空的 HashMap。
Map 是什么
ES6 新增的数据结构,本质是"键值对集合",类似对象 {},但更专业:
| 特性 | 普通对象 {} |
Map |
|---|---|---|
| key 类型 | 只能是字符串/Symbol | 任意类型(数字、对象都行) |
| 有 size | Object.keys(obj).length |
map.size 直接拿 |
| 遍历顺序 | ES2015+ 保证 | 按插入顺序 |
| 性能 | 一般 | 专为增删改查优化 |
常用 API
js
map.set(key, value) // 存
map.get(key) // 取(不存在返回 undefined)
map.has(key) // 判断有没有
map.delete(key) // 删
map.size // 元素个数
为什么用 Map 不用对象
- 数字 key 保持数字类型(对象会把
1转成字符串"1") - 语义更清晰:
map.set/map.get一看就是哈希表操作 - 性能更好
4.3 ② const tree = [];
做什么
创建一个空数组,用来装最终的根节点。
为什么是数组不是对象
因为可能有多个顶级菜单(一级菜单A、一级菜单B),tree 要支持"森林"(多棵树并列)。数组正好能放多个根节点。
4.4 ③ 第一次遍历:建索引
js
list.forEach((item) => {
map.set(item.id, { ...item, children: [] })
});
做什么
把每个节点按 id 存进 map,建立"id → 节点"的字典。
拆解 list.forEach((item) => { ... })
forEach 是什么:数组的遍历方法,对每个元素执行一次回调。
js
数组.forEach((当前项, 下标, 原数组) => { ... })
item 是什么 :forEach 的回调参数,每轮循环拿到的当前元素。
ini
list 有 5 个元素 → forEach 循环 5 次:
第1轮:item = {id:1, name:'一级菜单A', parentId:0}
第2轮:item = {id:2, name:'一级菜单B', parentId:0}
第3轮:item = {id:3, name:'二级菜单A-1', parentId:1}
第4轮:item = {id:4, name:'三级菜单A-1-1', parentId:3}
第5轮:item = {id:5, name:'二级菜单B-1', parentId:2}
item 在哪声明的 :在回调函数的参数列表 里声明的(形参),不用写 let/const。forEach 内部循环时自动把 list[i] 当作实参传进来。
=> 是什么:箭头函数语法,等价于:
js
list.forEach(function (item) { ... })
拆解 map.set(item.id, { ...item, children: [] })
map.set(key, value) :往 Map 里存一对键值,key 是 item.id,value 是 {...item, children:[]}。
...item 展开运算符
... 是什么 :ES6 的展开运算符(spread operator),把对象的所有属性"倒"进新对象。
js
// 原 item
{ id: 1, name: '一级菜单A', parentId: 0 }
// { ...item } 等价于
{ id: 1, name: '一级菜单A', parentId: 0 } // 完整拷贝一份
// { ...item, children: [] } 等价于
{ id: 1, name: '一级菜单A', parentId: 0, children: [] }
// ↑ 从 item 拷贝来的 ↑ 新加的属性
为什么用 ...item 不直接存原 item:
js
// ❌ 直接存原 item(会污染原数组)
map.set(item.id, item)
item.children = [] // flatList 里的原始数据也被改了!
// ✅ 用 ...item 拷贝新对象(解耦)
map.set(item.id, { ...item, children: [] })
// flatList 里的原数据不受影响
展开运算符的作用:浅拷贝对象,生成一份独立的新对象。
children: []
做什么 :给每个节点预先准备一个空的 children 数组。
为什么先加空数组 :后面第二步要 parent.children.push(孩子),得先有 children 这个数组才能 push。预先加好,后面直接 push,代码干净。
如果不预先加,push 时还要临时判断:
js
// 不预先加 children 的麻烦写法
if (!parent.children) parent.children = []
parent.children.push(cur)
第一次遍历结束后的 map
bash
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都还是空的,父子关系还没串起来。Map 只是"按 id 存好的节点仓库"。
4.5 ④ 第二次遍历:串树
js
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);
}
});
做什么
每个节点按 parentId 找父亲,找到就挂到父亲的 children,找不到就是根节点进 tree。
const current = map.get(item.id);
map.get(key):根据 key 从 Map 里取值。
这里用 item.id 当 key,拿到当前节点在 map 里存的那个对象(带空 children 的)。
为什么不用原 item,而要从 map 里取 :因为 map 里存的是带 children 的拷贝 ,而原 item 没有 children。后续要 push 孩子到 current.children,必须用 map 里的版本。
const parent = map.get(item.parentId);
做什么 :用当前节点的 parentId 当 key,去 map 里找它的父亲。
关键 :如果 parentId=0,map 里没有 key=0 的节点,map.get(0) 返回 undefined。
if (parent) { ... } else { ... }
if (parent) 是什么意思 :JavaScript 的隐式类型转换 ,把 parent 当布尔值判断。
| parent 的值 | if(parent) 结果 |
含义 |
|---|---|---|
| 对象(找到父亲了) | true | 走 if 分支 |
undefined(没找到) |
false | 走 else 分支 |
null |
false | 走 else |
0 / '' / NaN |
false | 走 else |
falsy 值 (判为 false):
false、0、''、null、undefined、NaN其他都是 truthy(判为 true)。
所以 if (parent) 等价于 if (parent !== undefined && parent !== null),写起来更简洁。
parent.children.push(current);
push 是什么 :数组方法,把元素加到数组末尾。
js
const arr = [1, 2]
arr.push(3) // arr 变成 [1, 2, 3]
这里把当前节点 current push 到父亲的 children 数组里,完成"挂娃"。
为什么能这样挂 :因为 parent 和 current 都是 map 里的同一个对象引用 。改 parent.children 就是改 map 里那个对象的 children。
tree.push(current);(else 分支)
做什么 :找不到父亲(parentId=0),说明是根节点,push 进 tree 数组。
为什么 parentId=0 会走到 else :map 里只有 key [1,2,3,4,5],没有 key=0:
js
map.get(0) // → undefined
→ parent 是 undefined → if(parent) 为 false → 走 else。
parentId=0是故意设计的"哨兵值",表示"我是顶级项没父亲节点"。这个值在 map 里找不到,自动区分根节点,代码极其简洁。
4.6 ⑤ return tree;
把最终拼好的 tree 数组返回给调用者。
4.7 第二次遍历结束后的 tree
js
[
{
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: []
}]
}
]
关键理解 :第 3、4、5 轮,
tree数组本身没新增元素,但parent.children.push(cur)改的是 map 里的对象 。而 tree 里存的就是 map 里的同一个对象引用------所以 tree 虽然没长,但它内部节点的 children 已经变长了。这就是 JS 引用类型的特性:改对象属性,所有指向它的变量都看得到。
五、解法二:reduce + 对象
逻辑完全相同,只是把 forEach 换成 reduce,更紧凑、函数式,是在es6之前没有Map的常用写法。
5.1 完整代码
js
function listToTree(list) {
// 第一次 reduce:建 nodeMap
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] }
return map;
}, {});
// 第二次 reduce:串 tree
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;
}, []);
}
5.2 reduce 是什么
reduce 把数组"浓缩"成一个值。每轮拿到上次的累积结果 + 当前项,返回新的累积结果。
语法
js
数组.reduce((累积器, 当前项, 下标, 原数组) => {
return 新的累积器
}, 初始值)
最经典的例子------求和
js
[10, 20, 30].reduce((acc, cur) => {
return acc + cur
}, 0)
// 初始 acc = 0
// 第1轮:acc=0, cur=10 → return 10
// 第2轮:acc=10, cur=20 → return 30
// 第3轮:acc=30, cur=30 → return 60
// 结果:60
acc(累积器):第一轮是初始值,之后是上一轮 return 的东西。每轮传回来让你接着算。
5.3 第一次 reduce:建 nodeMap
js
const nodeMap = list.reduce((map, item) => {
map[item.id] = { ...item, children: [] }
return map;
}, {});
做什么
把 list 浓缩成一个 { id: 节点 } 的对象。
拆解 (map, item) => { ... }
map:累积器(第一轮是初始值{},之后是上一轮 return 的 map)item:当前项(list 里的每个元素)
map[item.id] = { ...item, children: [] }
用普通对象当 HashMap :用 item.id 当 key,往 map 对象上挂属性。
等价于:
js
map[1] = { id:1, children:[] }
map[2] = { id:2, children:[] }
// ...
和 Map 的区别 :普通对象的 key 会被自动转成字符串(1 → "1"),但用的时候 map[1] 和 map["1"] 都能取到,不影响使用。
return map;(关键!)
reduce 的回调必须每轮 return 累积器 ,不然下一轮
map就变成undefined,直接报错。
初始值 {}
js
list.reduce((map, item) => { ... }, {})
// ↑ 这个 {} 是初始值
// (map, item) => { ... }
// ↑ 这个 {} 是函数体
注意区分 :箭头函数
() => {}的{}是函数体,不是初始值。初始值写在reduce的第二个参数。
第一次 reduce 结束后的 nodeMap
js
{
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 内容一模一样,只是类型从 Map 变成了普通对象。
5.4 第二次 reduce:串 tree
js
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;
}, []);
做什么
找爹挂娃,找不到爹的进 tree。
关键点
(tree, item):tree是累积器(初始值[]),item是当前项nodeMap[item.id]/nodeMap[item.parentId]:用普通对象的中括号语法 取值,等价于 Map 版的map.get()return tree;:必须 return,理由同上[]:reduce 的初始值是空数组,最终浓缩成一个装根节点的 tree 数组
六、forEach vs reduce 对比
6.1 两种写法对比
| 维度 | forEach + Map(1.js) | reduce + 对象(2.js) |
|---|---|---|
| 建索引类型 | new Map() |
普通 {} |
| 存取方式 | map.set() / map.get() |
map[id] = / map[id] |
| 遍历方式 | forEach |
reduce |
| 要 return? | 不要 | 必须 return 累积器 |
| 代码风格 | 直观,调试方便 | 紧凑,函数式,面试加分 |
| 结果 | 完全相同 | 完全相同 |
6.2 forEach vs reduce 本质区别
| 特性 | forEach | reduce |
|---|---|---|
| 返回值 | undefined(不返回) |
累积器(最终结果) |
| 用途 | 遍历执行副作用 | 把数组浓缩成一个值 |
| 要 return? | 不需要 | 必须 |
| 适合场景 | "对每个元素做点事" | "把数组归纳成一个结果" |
listToTree 用 reduce 的好处:
- 第一次 reduce 直接"产出"nodeMap 对象(不用在外面先声明)
- 第二次 reduce 直接"产出"tree 数组(直接 return)
- 代码更紧凑,一个表达式搞定
七、执行流程:5 个节点走一遍
7.1 第一次遍历:建索引
| 轮次 | item.id | 执行 | map/nodeMap 新增 |
|---|---|---|---|
| 1 | 1 | map.set(1, {id:1,children:[]}) |
1 → 节点1 |
| 2 | 2 | map.set(2, {...}) |
2 → 节点2 |
| 3 | 3 | map.set(3, {...}) |
3 → 节点3 |
| 4 | 4 | map.set(4, {...}) |
4 → 节点4 |
| 5 | 5 | map.set(5, {...}) |
5 → 节点5 |
7.2 第二次遍历:串树
| 轮次 | item | parentId | 找爹结果 | 走哪条? | 执行动作 | tree 状态 |
|---|---|---|---|---|---|---|
| 1 | id=1 | 0 | undefined | else | tree.push(节点1) |
[节点1] |
| 2 | id=2 | 0 | undefined | else | tree.push(节点2) |
[节点1, 节点2] |
| 3 | id=3 | 1 | 节点1 | if | 节点1.children.push(节点3) |
不变 |
| 4 | id=4 | 3 | 节点3 | if | 节点3.children.push(节点4) |
不变 |
| 5 | id=5 | 2 | 节点2 | if | 节点2.children.push(节点5) |
不变 |
7.3 最终 tree 结构
ini
[ 节点1, 节点2 ]
│ │
│ └── children: [ 节点5 ]
│
└── children: [ 节点3 ]
│
└── children: [ 节点4 ]
八、复杂度分析
8.1 对比表
| 方法 | 时间复杂度 | 空间复杂度 | 数据顺序敏感? |
|---|---|---|---|
| 朴素递归 | O(n²) | O(n) | ❌ 敏感(孩子在父前面挂不上) |
| Map 索引法 | O(n) | O(n) | ✅ 不敏感 |
8.2 为什么 Map 索引法不敏感
第一次遍历已建好全部索引,第二次遍历时所有爹都在 map 里了,不管数据顺序怎样都能找到。
九、五个常见坑
9.1 坑 1:reduce 忘了 return 累积器
js
list.reduce((map, item) => {
map[item.id] = { ...item, children: [] }
// ❌ 忘了 return map!
}, {})
// 下一轮 map 变 undefined → Cannot set property of undefined
修复 :每轮都 return map。
9.2 坑 2:直接存原 item,污染原数组
js
// ❌ 直接存原对象
map.set(item.id, item)
item.children = [] // flatList 里的原数据也被改了
// ✅ 用 ...item 拷贝新对象
map.set(item.id, { ...item, children: [] })
9.3 坑 3:parentId 用 0 还是 null
两种都能跑(map.get(0) 和 map.get(null) 都是 undefined,走 else)。但项目里要统一,别一半 0 一半 null。
9.4 坑 4:朴素递归法遇到乱序数据挂不上
yaml
// 数据顺序乱(孩子在父前面):
[
{ id: 4, parentId: 3 }, // 孙子先出现
{ id: 1, parentId: 0 }, // 根最后出现
]
朴素递归会挂不上。Map 索引法完全不受影响。
9.5 坑 5:用 for...in 遍历普通对象 nodeMap
js
// ❌ 会带上原型链属性
for (let key in nodeMap) { ... }
// ✅ 用 Object.values() 或 entries()
Object.values(nodeMap).forEach(node => { ... })
for (let [id, node] of Object.entries(nodeMap)) { ... }
十、关键字与语法速查
10.1 ...(展开运算符)
js
// 1. 展开对象(浅拷贝)
const copy = { ...obj }
// 2. 展开数组
const merged = [...arr1, ...arr2]
// 3. 合并属性
const obj = { ...old, newProp: 1 }
// 4. 函数剩余参数
function fn(...args) { ... }
10.2 Map 常用 API
js
const map = new Map()
map.set(key, value) // 存
map.get(key) // 取(不存在返回 undefined)
map.has(key) // 判断有没有
map.delete(key) // 删一个
map.clear() // 清空
map.size // 元素个数
// 遍历
for (const [key, value] of map) { ... }
map.forEach((value, key) => { ... })
10.3 forEach vs reduce vs for...of
| 方法 | 返回值 | 能 break | 能 await | 适合场景 |
|---|---|---|---|---|
forEach |
undefined | ❌ | ❌ | 简单遍历执行副作用 |
reduce |
累积器 | ❌ | ❌ | 把数组浓缩成一个值 |
for...of |
无 | ✅ | ✅ | 需要中断/异步的遍历 |
10.4 if (parent) 的真假判断
falsy 值 (判为 false):false、0、''、null、undefined、NaN
truthy 值 (判为 true):其他所有值(包括 []、{}、'0')
js
if (parent) // 等价于 if (parent !== undefined && parent !== null)
10.5 push 数组方法
js
const arr = [1, 2]
arr.push(3) // [1, 2, 3],返回新长度
arr.push(4, 5) // [1, 2, 3, 4, 5],可一次 push 多个
十一、延伸知识
11.1 树转列表(反向操作)
js
function treeToList(tree) {
const list = []
tree.forEach(node => {
list.push({ id: node.id, name: node.name, parentId: node.parentId })
if (node.children?.length) {
list.push(...treeToList(node.children)) // 递归拍扁子节点
}
})
return list
}
11.2 查找树中某个节点
js
function findNode(tree, id) {
for (let node of tree) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
11.3 渲染到页面(Vue 递归组件)
vue
<!-- MenuItem.vue -->
<template>
<div>
<span>{{ node.name }}</span>
<MenuItem
v-for="child in node.children"
:key="child.id"
:node="child"
/>
</div>
</template>
十二、总结
12.1 核心思路
ini
列表转树三步走:
① 建 id → 节点 索引(Map 或 {})
② 按 parentId 找爹
③ 找到爹 → push 到爹的 children
找不到爹(parentId=0)→ push 到 tree 当根
12.2 三个知识点
| 知识点 | 在本题的作用 |
|---|---|
| HashMap | Map/对象,把"找父亲"从 O(n) 降到 O(1) |
| reduce | 函数式累积器,必须每轮 return |
| 引用类型 | 共享对象,改 children 自动同步到 tree |
12.3 一句话总结
列表转树的核心:用 HashMap 把"找父亲"从 O(n) 降到 O(1),两次线性遍历解决树形构建。
这是前端处理多级菜单、目录树、省市区联动的标准解法。