在日常开发中,我们经常会遇到这样的场景:从数据库中查询出一堆扁平的数据,比如省市区数据、组织架构数据、菜单权限数据,但它们之间的关系需要通过 parentId 来维护。这时候,我们就需要施展一点"魔法",把这些看似散乱的数据变成层次分明的树状结构。
为什么需要数组转树?
想象一下,你拿到了一份中国行政区划的原始数据:
const sourceList = [
{ id: 1, name: '中国', parentId: 0 },
{ id: 2, name: '北京', parentId: 1 },
{ id: 3, name: '上海', parentId: 1 },
{ id: 4, name: '东城区', parentId: 2 },
{ id: 5, name: '西城区', parentId: 2 },
]
这就像把一整棵家族树的所有成员名字都写在一张纸上,虽然信息齐全,但你很难一眼看出谁是谁的父节点,谁是谁的子节点。而前端展示时,我们往往需要这样的结构:
[
{
id: 1,
name: '中国',
children: [
{
id: 2,
name: '北京',
children: [
{ id: 4, name: '东城区' },
{ id: 5, name: '西城区' }
]
},
{
id: 3,
name: '上海'
}
]
}
]
递归解法:优雅但需谨慎
先来看看递归解法,这种方法思路很直观:
function listToTree(list, parentId) {
const tree = []
for (const item of list) {
if (item.parentId === parentId) {
const children = listToTree(list, item.id)
if (children.length > 0) {
item.children = children
}
tree.push(item)
}
}
return tree
}
递归就像是在说:"给我所有 parentId 为 xxx 的节点,然后对每个节点,再去找它们的子节点..."
优点:
- 代码简洁,易于理解
- 符合树结构的自然定义
缺点:
- 性能较差,时间复杂度 O(n²)
- 可能栈溢出(虽然省市区这种数据量一般不会)
HashMap 解法:性能优化的选择
对于大数据量场景,我们可以用 HashMap 来优化:
function listToTree(list, rootId = null) {
const tree = []
const map = new Map()
// 第一遍遍历:建立 ID 到节点的映射
list.forEach(item => {
map.set(item.id, {
...item,
children: []
})
})
// 第二遍遍历:构建树形结构
list.forEach(item => {
const node = map.get(item.id)
if (item.parentId === rootId) {
tree.push(node)
} else {
const parent = map.get(item.parentId)
if (parent) {
parent.children.push(node)
}
}
})
return tree
}
这种方法的时间复杂度是 O(n),空间复杂度也是 O(n),在大数据量下表现更好。
实际应用场景
1. 省市区选择器
这是我们最熟悉的场景了。后端通常这样存储数据:
CREATE TABLE regions (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
parent_id INT,
level TINYINT COMMENT '1:省, 2:市, 3:县'
);
2. 组织架构图
公司部门关系也是典型的树状结构:
const departments = [
{ id: 1, name: '总裁办', parentId: null },
{ id: 2, name: '技术部', parentId: 1 },
{ id: 3, name: '前端组', parentId: 2 },
{ id: 4, name: '后端组', parentId: 2 },
{ id: 5, name: '市场部', parentId: 1 }
]
3. 嵌套评论系统
多级评论也是树形结构的经典案例:
const comments = [
{ id: 1, content: '好文章!', parentId: null },
{ id: 2, content: '赞同楼主', parentId: 1 },
{ id: 3, content: '+1', parentId: 2 }
]
进阶技巧与注意事项
处理循环引用
在实际项目中,数据可能不干净,需要防范循环引用:
function listToTreeSafe(list, rootId = null) {
const tree = []
const map = new Map()
const visited = new Set()
list.forEach(item => {
map.set(item.id, { ...item, children: [] })
})
function buildNode(node) {
if (visited.has(node.id)) {
console.warn(`检测到循环引用: ${node.id}`)
return null
}
visited.add(node.id)
return node
}
list.forEach(item => {
const node = map.get(item.id)
if (item.parentId === rootId) {
const safeNode = buildNode(node)
if (safeNode) tree.push(safeNode)
} else {
const parent = map.get(item.parentId)
if (parent) {
const safeNode = buildNode(node)
if (safeNode) parent.children.push(safeNode)
}
}
})
return tree
}
内存优化版本
如果担心内存占用,可以不用展开所有 children:
function listToTreeLite(list, rootId = null) {
const map = new Map()
list.forEach(item => {
map.set(item.id, { ...item })
})
const tree = []
list.forEach(item => {
if (item.parentId === rootId) {
tree.push(map.get(item.id))
} else {
const parent = map.get(item.parentId)
if (parent) {
if (!parent.children) parent.children = []
parent.children.push(map.get(item.id))
}
}
})
return tree
}
总结
数组转树是前端开发中的经典问题,理解它的各种解法有助于我们在不同场景下做出合适的选择:
- 小数据量:递归解法更直观
- 大数据量:HashMap 解法性能更好
- 数据不可信:需要添加安全检测
- 内存敏感:考虑惰性加载 children
下次当你遇到扁平数据需要展示为树形结构时,希望这篇笔记能给你带来启发。记住,没有最好的算法,只有最适合当前场景的解决方案!