前端必会!把扁平列表变成树形结构的 2 种核心方法

117 阅读6分钟

从「一堆数据」到「一棵树」: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 表:

idparentIdname
10A
21B
31C
42D

所有人都站在同一排,没有层级关系。


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️⃣ 递归三要素(一定要背)

写递归前,先想清楚三件事:

  1. 当前要做什么
  2. 什么时候停
  3. 下一步交给谁做

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 解法时,
你已经不是小白了。