从「一堆数据」到「一棵树」:JS 列表转树的四种实现思路全解析
在前端开发中,有一道题几乎绕不开——
👉 把一个扁平列表,转成一棵树结构。
它常年霸榜前端面试题,也几乎每天出现在真实业务中:
- 后台菜单
- 组织架构
- 城市地址
- 评论回复
- 分类目录
如果你刚学 JavaScript,第一次看到这类题,大概率会一脸问号:
“这不就是一堆对象吗?为什么要转成树?”
“递归是怎么跑起来的?”
“为什么有人说 O(n²),有人又能做到 O(n)?”
这篇文章,我们就从最生活化的角度出发,一步一步把这道题拆开、揉碎、吃透。
一、先别写代码:什么是「列表」?什么是「树」?
先来看一份最常见的数据:
const list = [
{ id: 1, parentId: 0, name: 'A' },
{ id: 2, parentId: 1, name: 'B' },
{ id: 3, parentId: 1, name: 'C' },
{ id: 4, parentId: 2, name: 'D' }
]
这就是一个扁平列表。
1️⃣ 扁平列表像什么?
你可以把它想象成一张 Excel 表:
| id | parentId | name |
|---|---|---|
| 1 | 0 | A |
| 2 | 1 | B |
| 3 | 1 | C |
| 4 | 2 | D |
所有人都站在同一排,没有层级关系。
2️⃣ 树结构又像什么?
树结构更像 家谱 或 文件夹:
[
{
id: 1,
name: 'A',
children: [
{
id: 2,
name: 'B',
children: [
{ id: 4, name: 'D' }
]
},
{ id: 3, name: 'C' }
]
}
]
这时候,每个节点都知道自己的「孩子」。
3️⃣ parentId 是什么?
parentId 就是认爹的方式:
parentId === 0→ 说明它是老祖宗(根节点)parentId === 某个 id→ 说明它是那个人的孩子
一句话总结:
👉 列表转树,本质就是:
“给每个节点,找到它的孩子”
二、最直观的思路:递归(O(n²))
1️⃣ 什么是递归?
先别被名字吓到。
递归其实就一句话:
递归(Recursion)是计算机科学和数学中一种重要的思想方法,指的是一个函数在其定义或执行过程中直接或间接地调用自身。
递归就像“解决一个问题的方法,是先解决一个更小的同类问题,直到遇到一个简单到可以直接回答的问题”
这就是递归。
2️⃣ 递归三要素(一定要背)
写递归前,先想清楚三件事:
- 当前要做什么
- 什么时候停
- 下一步交给谁做
3️⃣ 递归版列表转树实现
function list2tree(list, parentId = 0) {
const result = [] // 存放当前层级找到的所有“孩子”
list.forEach(item => { // 遍历整个列表中的每一项(item)
if (item.parentId === parentId) { // 说明他是“这一代”的直接后代(“亲生父亲”)。
const children = list2tree(list, item.id) // 递归:让这个孩子自己去招他的孩子!
if (children.length) {
item.children = children
}
result.push(item)
}
})
return result
}
4️⃣ 为什么是 O(n²)?
因为:
- 每一层递归
- 都要 重新遍历整个 list
节点一多,就会越来越慢。
⚠️ 注意:当数据量超过几千条时,递归解法可能明显卡顿,甚至栈溢出(尤其在深度很大的树中)。
但好处是:
✅ 思路直观
三、递归的 ES6 写法(更优雅)
如果你已经习惯 filter + map,可以写成这样:
function list2tree(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId) // 找出所有父亲是 parentId 的节点
.map(item => ({ // 为每个节点递归构建 children
...item,
children: list2tree(list, item.id)
}))
}
面试时怎么评价它?
你可以说:
- 代码简洁
- 可读性好
- 但本质仍是 O(n²)
面试官一般会点头。
四、真正的进阶:用空间换时间(O(n))
前面的解法都有一个共同问题:
❌ 不停地重复遍历 list
那有没有办法:
👉 只遍历两次,就完成建树?
有,而且这才是面试加分点。
五、用对象 Map 做「通讯录」
1️⃣ 核心思想
先别急着连父子关系。
我们先做一件事:
给每个节点建一个“通讯录”
const map = {}
2️⃣ 第一次遍历:建表
list.forEach(item => {
map[item.id] = {
...item,
children: [] // 增一个空数组,用于将来存放它的子节点
}
})
这一步相当于:
📒 “所有人先登记一下自己是谁”
3️⃣ 第二次遍历:认爹
list.forEach(item => {
if (item.parentId === 0) { // 表示根节点,直接加入result数组
result.push(item)
} else {
// 通过item.parentId找到父节点记录并将自己存放在父节点的children数组里。
map[item.parentId].children.push(item)
}
})
这一步相当于:
👨👩👧
“你爹是谁?直接把你塞到他名下。”
4️⃣ 完整代码
function listToTree(list) {
const map = {}
const result = []
list.forEach(item => {
map[item.id] = {
...item,
children: []
}
})
list.forEach(item => {
if (item.parentId === 0) {
result.push(item)
} else {
map[item.parentId].children.push(item)
}
})
return result
}
5️⃣ 为什么是 O(n)?
- 遍历一次建 map
- 遍历一次连关系
- 每个节点只处理两次
没有递归,没有重复扫描。
六、Map 版本(更现代)
ES6 之后,还有一个更专业的数据结构:Map
function listToTree(list) {
const nodeMap = new Map() // 用于以 id 为键快速查找节点
const tree = []
// 为每个原始节点创建一个新对象,保留原有属性,并添加空的 children 数组。
list.forEach(item => {
nodeMap.set(item.id, {
...item,
children: []
})
})
list.forEach(item => {
// // 从 nodeMap 中取出当前项对应的**完整节点对象(已带有 `children: []`)
const node = nodeMap.get(item.id);
if (item.parentId === 0) {
tree.push(node)
} else {
const parentNode = nodeMap.get(item.parentId)
if (parentNode) {
parentNode.children.push(node)
}
}
})
return tree
}
1️⃣ Map 比对象好在哪?
- key 可以是任意类型
- 没有原型链污染
- 语义更清晰
七、真实业务中,哪里用到它?
1️⃣ 地址选择器
中国
└── 北京
├── 朝阳区
└── 顺义区
数据库里永远是:
id | parentId | name
2️⃣ 后台菜单
系统管理
├── 用户管理
└── 权限管理
3️⃣ 评论回复
A:写得不错
└── B:同意
└── C:+1
✅ 这些场景背后,都是同一套转换逻辑在支撑。
到这里,我们已经掌握了列表转树的两种核心思路:
- 递归法:代码简洁、逻辑直观,适合小数据量或快速原型开发;
- 哈希映射法(Map/对象缓存):时间复杂度 O(n),适合真实业务中的大数据场景。
无论选择哪种写法,关键都在于理解 “通过 parentId 建立父子关系” 这一本质。接下来,我们看看如何在面试中清晰、专业地表达这个过程。
八、面试总结模板(直接背)
列表转树的核心是通过 parentId 建立父子关系。
递归方式实现简单但时间复杂度是 O(n²)。
在数据量较大的场景下,可以通过 Map 或对象缓存节点,用两次遍历完成树的构建,将时间复杂度优化到 O(n)。
九、写在最后
如果你刚学 JS,这道题一开始看不懂是完全正常的。
但只要你记住一句话:
👉 “先找人,再认爹”
你就已经理解了 80%。
剩下的,只是代码熟练度问题。
当你哪天看到这道题,能笑着写出 Map 解法时,
你已经不是小白了。