🎄 后端给我一堆扁平数据,我 10 行代码把它变成了树

32 阅读7分钟

写在前面:今天学了一个在实际工作中超级常用的算法——列表转树。后端从数据库里 select * from menu 查出来的数据是一维的扁平列表,但前端要展示的是树状结构(多级菜单、地址选择器、组织架构……)。怎么转?老师教了两招:Map 法和 reduce 法。听完我只想说:原来这么简单,我之前居然手写递归转了半天!


一、为什么需要"列表转树"?

1.1 后端给的数据长这样

老师举了一个非常真实的例子:

const flatList = [
    { id: 1, name: '一级菜单A', parentId: 0 },
    { id: 2, name: '一级菜单B', parentId: 0 },
    { id: 3, name: '二级A-1', parentId: 1 },
    { id: 4, name: '三级A-1-1', parentId: 3 },
    { id: 5, name: '二级B-1', parentId: 2 },
]

这是一堆扁平数据——所有项都在同一个数组里,没有嵌套关系。每一项有一个 parentId,表示它的父节点是谁。

  • parentId: 0 表示"我是根节点,没有父节点"。
  • parentId: 1 表示"我的爸爸是 id 为 1 的节点"。

1.2 前端想要的数据长这样

但前端组件(比如 Element UI 的 Tree、Ant Design 的 Cascader)需要的是树状结构

[
  {
    "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": []
      }
    ]
  }
]

从扁平到树状,这就是"列表转树"要解决的问题。

老师提到,这种需求在管理后台特别常见:

  • 多级菜单
  • 地址三连弹(省 → 市 → 区)
  • 组织架构树
  • 商品分类

MySQL 存储的是扁平结构,select * from 取出来就是一维数组。 所以列表转树是前后端分离项目中几乎必做的数据处理。


二、方法一:Map 法——空间换时间

2.1 核心思路

老师教的第一种方法,用到了 ES6 新增的 Map 数据结构:

"ES6 新增数据结构 HashMap"

核心思路:先把所有节点存到 Map 里(id → 节点),再遍历一遍,把每个节点挂到对应的父节点下。

2.2 代码实现

function listToTree(list) {
    const map = new Map(); // HashMap,id -> 节点
    const tree = [];

    // 第一轮:把所有节点放入 Map,并初始化 children
    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;
}

2.3 执行过程拆解

以示例数据为例,看看代码是怎么跑的:

第一轮:构建 Map

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: [] }
}

第二轮:挂到父节点

当前项parentId父节点存在?操作
id: 10map.get(0) = undefinedtree.push(节点1)
id: 20map.get(0) = undefinedtree.push(节点2)
id: 31map.get(1) = 节点1节点1.children.push(节点3)
id: 43map.get(3) = 节点3节点3.children.push(节点4)
id: 52map.get(2) = 节点2节点2.children.push(节点5)

最终结果:

tree = [节点1, 节点2]
节点1.children = [节点3]
节点3.children = [节点4]
节点2.children = [节点5]

2.4 时间复杂度:O(n)

两轮遍历,每轮都是 O(n),总时间复杂度 O(n)。 用了一个 Map 做空间换时间,查找父节点是 O(1)。

这就像你整理家谱:

  1. 先把所有人按身份证号(id)排好队(Map)。
  2. 再一个一个问"你爸是谁?"(parentId),然后站到爸爸后面(children.push)。

三、方法二:reduce 法——函数式编程的优雅

3.1 核心思路

老师教的第二种方法,用 reduce 实现,更函数式、更简洁:

function listToTree(list) {
    // 第一轮 reduce:构建 nodeMap
    const nodeMap = list.reduce((map, item) => {
        map[item.id] = { ...item, children: [] };
        return map;
    }, {});

    // 第二轮 reduce:组装树
    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;
    }, []);
}

3.2 reduce 的妙用

reduce 是数组的"万能折叠器"——把数组折叠成一个值。

第一轮 reduce:

  • 初始值是空对象 {}
  • 每次迭代把 item.id 作为 key,把 { ...item, children: [] } 作为 value,存入 map。
  • 最终得到 nodeMap = { 1: 节点1, 2: 节点2, ... }

第二轮 reduce:

  • 初始值是空数组 []
  • 每次迭代判断当前节点是否有父节点:
    • 有父节点 → parent.children.push(cur)
    • 没有父节点 → tree.push(cur)
  • 最终返回组装好的树。

3.3 Map vs reduce:两种风格,一样高效

对比项Map 法reduce 法
代码风格命令式(forEach)函数式(reduce)
可读性直观,步骤清晰简洁,一行一个操作
时间复杂度O(n)O(n)
空间复杂度O(n)(Map)O(n)(对象)
适用场景团队偏好命令式团队偏好函数式

两种方法的核心思想完全一样:先建索引,再挂父子关系。 只是实现方式不同。


四、关键知识点:parentId 是树的"DNA"

4.1 为什么 parentId 这么重要?

老师强调:

"parentId 是树状的关键。"

在数据库里存储树状结构,通常有两种方案:

方案存储方式优点缺点
邻接表(Adjacency List)每个节点存 parentId简单直观,插入删除方便查询子树需要递归
嵌套集(Nested Set)leftright查询子树快插入删除麻烦

parentId 就是邻接表方案的核心。 绝大多数管理后台都用这个方案,因为简单、直观、好维护。

4.2 扁平 vs 树状:两种视角

扁平视角(数据库):          树状视角(前端):
                              
[id:1, parentId:0]           一级菜单A
[id:2, parentId:0]           ├── 二级A-1
[id:3, parentId:1]           │   └── 三级A-1-1
[id:4, parentId:3]           └── 二级B-1
[id:5, parentId:2]           

数据库喜欢扁平(查询快、存储省),前端喜欢树状(展示直观、递归方便)。 列表转树就是在这两种视角之间做转换。


五、JSON.stringify 的妙用:打印树状结构

老师还展示了一个调试技巧:

console.log(JSON.stringify(listToTree(flatList), null, 2))

JSON.stringify(obj, null, 2) 可以把对象格式化成带缩进的字符串。

  • 第一个参数:要序列化的对象。
  • 第二个参数:null(不替换)。
  • 第三个参数:2(缩进 2 个空格)。

这样在控制台看树状结构特别清晰,比直接 console.log 一堆 [object Object] 强多了。

这就像你写文章:

  • console.log(obj):把所有文字挤在一行,看着头疼。
  • JSON.stringify(obj, null, 2):自动分段缩进,像排版好的文章,一目了然。

六、总结:列表转树,前端必会的"基本功"

知识点说明
列表转树把扁平数组转成嵌套树状结构
parentId标识父节点的关键字段
Map 法用 HashMap 做索引,两轮遍历
reduce 法用 reduce 折叠数组,函数式风格
时间复杂度O(n),线性遍历
应用场景多级菜单、地址选择、组织架构
JSON.stringify(obj, null, 2)格式化打印对象

列表转树是前端面试的高频题,也是实际工作中天天要用的技能。 掌握 Map 法和 reduce 法,无论面试官问哪种风格,你都能从容应对。


写在最后

今天最大的收获,是理解了"空间换时间"的思想。用 Map 做索引,把 O(n²) 的嵌套查找优化成 O(n) 的线性遍历——这个思路在很多算法题里都能用到。

下次面试官问你:"怎么把扁平数组转成树状结构?"

你可以淡定地说:

"我用 Map 做索引,两轮遍历。第一轮把所有节点存入 Map(id → 节点),并初始化 children 数组。第二轮遍历,根据 parentId 找到父节点,把当前节点 push 到父节点的 children 里。如果 parentId 找不到对应节点,说明是根节点,直接 push 到结果数组。时间复杂度 O(n)。也可以用 reduce 实现,更函数式。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。