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

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


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

1.1 后端给的数据长这样

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

javascript 复制代码
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)需要的是树状结构

javascript 复制代码
[
  {
    "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 代码实现

javascript 复制代码
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

yaml 复制代码
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: 1 0 map.get(0) = undefined tree.push(节点1)
id: 2 0 map.get(0) = undefined tree.push(节点2)
id: 3 1 map.get(1) = 节点1 节点1.children.push(节点3)
id: 4 3 map.get(3) = 节点3 节点3.children.push(节点4)
id: 5 2 map.get(2) = 节点2 节点2.children.push(节点5)

最终结果:

ini 复制代码
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 实现,更函数式、更简洁:

javascript 复制代码
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 树状:两种视角

css 复制代码
扁平视角(数据库):          树状视角(前端):
                              
[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 的妙用:打印树状结构

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

javascript 复制代码
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 实现,更函数式。"

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


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

相关推荐
Mahut1 小时前
我用 Electron + FFmpeg 做了一个本地视频处理工作站 ClipForge
前端·ffmpeg·electron
前端Hardy1 小时前
又一个 AI 神器火了!
前端·javascript·后端
锋行天下1 小时前
我试图优化 Vite 的拆包,结果首屏慢了 10 倍
前端·vue.js·架构
PBitW2 小时前
GPT训练我的第二天,我表示不过如此!!!😕😕😕
前端·javascript·面试
用户99045017780092 小时前
学习了AI修图,我把自己闲鱼出租房照片整成airbnb风格了
前端
kyriewen3 小时前
白宫直接给 OpenAI 下了限制令,GPT-5.6 不能随便放出来了
前端·javascript·面试
PedroQue994 小时前
Vite插件v0.2.6:架构优化与自动化升级
前端·vite
threerocks5 小时前
什么?我连 A2A、MCP 都没学会,现在又来了 AG-UI、A2UI.
前端·aigc·ai编程
牛奶6 小时前
如何自己写一个浏览器插件?
前端·chrome·浏览器