数组转树:从扁平列表到层级结构的魔法变身

63 阅读3分钟

在日常开发中,我们经常会遇到这样的场景:从数据库中查询出一堆扁平的数据,比如省市区数据、组织架构数据、菜单权限数据,但它们之间的关系需要通过 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

下次当你遇到扁平数据需要展示为树形结构时,希望这篇笔记能给你带来启发。记住,没有最好的算法,只有最适合当前场景的解决方案!