列表转树:前端高频算法全解析

从数据库扁平行到嵌套树形结构,这一步你每天都在写,但你真的写对了吗?


📌 为什么需要「列表转树」?

在管理后台、电商、CMS 等系统中,树状结构随处可见:

  • 🧭 多级菜单 --- 侧边栏导航的无限层级
  • 🗂 分类管理 --- 商品类目、文章专栏
  • 🏙 地址联动 --- 省/市/区三级选择器
  • 📁 组织架构 --- 部门与人员的层级关系
  • 💬 评论系统 --- 楼中楼回复

但这些数据在 MySQL 数据库中,通常只存储在一张扁平表里:

sql 复制代码
-- 典型的数据表结构
-- parentId 是自引用外键,指向同一张表的 id
SELECT * FROM menus;

+----+------------------+----------+
| id | name             | parentId |
+----+------------------+----------+
|  1 | 一级菜单A         |        0 |  -- parentId=0 表示没有上级,即根节点
|  2 | 一级菜单B         |        0 |  -- 同样是根节点(多根)
|  3 | 二级A-1           |        1 |  -- parentId=1,父节点是「一级菜单A」
|  4 | 三级A-1-1         |        3 |  -- parentId=3,父节点是「二级A-1」
|  5 | 二级B-1           |        2 |  -- parentId=2,父节点是「一级菜单B」
+----+------------------+----------+

🔑 关键字段parentId 指向父节点的 idparentId = 0 代表根节点。

数据库为什么这样设计? 因为关系型数据库天然是二维表结构,无法直接存储嵌套数据。用 parentId 自引用是标准做法------每一行只关心"我的上级是谁",而不是"我的下级有哪些"。这保持了数据的原子性,增删改都只需要操作一行,不会像嵌套结构那样牵一发动全身。

后端接口返回的就是这样一个 一维数组(扁平列表):

js 复制代码
// 后端返回的扁平数据 ------ 每个元素都是独立的,没有嵌套关系
const flatList = [
  { id: 1, name: '一级菜单A', parentId: 0 },  // 根节点:parentId=0
  { id: 2, name: '一级菜单B', parentId: 0 },  // 根节点:parentId=0
  { id: 3, name: '二级A-1', parentId: 1 },    // 子节点:parentId 指向 id=1
  { id: 4, name: '三级A-1-1', parentId: 3 },  // 孙节点:parentId 指向 id=3
  { id: 5, name: '二级B-1', parentId: 2 },    // 子节点:parentId 指向 id=2
];
// 注意:数组顺序和层级无关,节点可能乱序排列,parentId 才是唯一依据

而前端渲染 <el-tree><Tree> 这类树形组件时,需要把数据变成嵌套的 树形结构

json 复制代码
[
  {
    "id": 1,
    "name": "一级菜单A",
    "parentId": 0,
    "children": [               // ← 关键变化:多了 children 数组
      {
        "id": 3,
        "name": "二级A-1",
        "parentId": 1,
        "children": [           // ← 递归嵌套,深度不限
          {
            "id": 4,
            "name": "三级A-1-1",
            "parentId": 3,
            "children": []      // ← 叶子节点:children 为空数组
          }
        ]
      }
    ]
  },
  {
    "id": 2,
    "name": "一级菜单B",
    "parentId": 0,
    "children": [
      {
        "id": 5,
        "name": "二级B-1",
        "parentId": 2,
        "children": []
      }
    ]
  }
]

关键变化:每个节点多了一个 children 数组 ,子节点不再是一维数组中的独立元素,而是被递归地嵌套到了父节点的 children 属性中。

这就是 列表转树 要解决的问题。


🧠 核心思路

直观理解:从一维到多维

复制代码
    扁平列表(一维)                      树形结构(多维)

    [id=1, pid=0]  ──────────────┐      ┌─ children: [id=3]
    [id=2, pid=0]  ───┐          │      │
    [id=3, pid=1]  ─┐ │          │      │   ┌─ children: [id=4]
    [id=4, pid=3]   │ │          │      │   │
    [id=5, pid=2]   │ │          │      │   │
                     ▼ ▼          ▼      ▼   ▼
              ┌─────────────────────────────────┐
              │  ① 建立 id → node 的映射表       │
              │  ② 遍历,把每个节点挂到 parent 下  │
              └─────────────────────────────────┘

为什么不能一遍直接完成?

你可能会想:遍历到每个节点时,根据它的 parentId 去列表里 find 它的父节点不就行了?

js 复制代码
// ❌ 朴素做法 ------ O(n²),不推荐
function naiveListToTree(list) {
  // 先找出所有根节点(parentId === 0)
  return list
    .filter(item => item.parentId === 0)  // 第1次全量遍历
    .map(root => ({
      ...root,
      // 再找出所有父节点是当前 root 的子节点
      children: list.filter(              // 第2次全量遍历(每个根节点都要遍历一次)
        item => item.parentId === root.id
      ).map(child => ({
        ...child,
        // 想找孙子节点?又得再 filter 一次......无限嵌套,复杂度爆炸
        children: list.filter(            // 第3次全量遍历
          item => item.parentId === child.id
        )
        // ... 每多一层就多一次全量遍历
      }))
    }));
}
// 问题:3层嵌套 = 3n² 次操作,5层 = 5n²,1000条数据就要百万级操作

问题在于:每次找父节点或子节点,都要遍历整个列表。如果列表有 1000 条,嵌套三层就是 1000 × 1000 × 1000 次操作,性能崩溃。

正确思路:两遍遍历,O(n) 搞定

步骤 做什么 为什么 数据结构变化
第一遍 遍历列表,为每个节点创建副本并挂上空的 children 数组,存入映射表 { id → node } 把 "通过 id 找节点" 变成 O(1) 的哈希查找 listmap{id: node}
第二遍 再次遍历列表,通过 parentId 从映射表中瞬间取出父节点,把当前节点 push 进父节点的 children;若无父节点则直接放入结果数组 完成树的「编织」,每个节点只被处理一次 map + listtree[]

核心技巧:用空间换时间。多开一个 Map/Object 存放所有节点的引用,后续查找从 O(n) 降为 O(1)。


💻 实现一:Map 版本(逐行精讲)

使用 ES6 的 Map 数据结构,语义清晰,是面试和团队协作的首选写法。

js 复制代码
// ============================================================
// 输入:扁平的菜单列表(来自后端 API)
// ============================================================
const flatList = [
  { id: 1, name: '一级菜单A', parentId: 0 },
  //            ↑ 菜单名称         ↑ 0 表示没有上级 → 根节点
  { id: 2, name: '一级菜单B', parentId: 0 },
  //            ↑ 另一个根节点     ↑ 同样 parentId=0
  { id: 3, name: '二级A-1', parentId: 1 },
  //            ↑ 二级菜单         ↑ 父节点是 id=1(一级菜单A)
  { id: 4, name: '三级A-1-1', parentId: 3 },
  //            ↑ 三级菜单         ↑ 父节点是 id=3(二级A-1)
  { id: 5, name: '二级B-1', parentId: 2 },
  //            ↑ 另一个二级菜单    ↑ 父节点是 id=2(一级菜单B)
];

// ============================================================
// 核心函数:将扁平列表转换为嵌套树
// @param {Array}  list - 扁平列表,每项必须有 id 和 parentId
// @return {Array} tree - 树形结构,只包含根节点数组
// ============================================================
function listToTree(list) {
  // ----- 准备工作:初始化两个核心变量 -----

  // Map: ES6 的哈希映射结构,key 是节点 id,value 是包装后的节点对象
  // 为什么用 Map 而不是 {}?Map 的 key 可以是任意类型,且 .get()/.set() 语义更清晰
  const map = new Map();

  // tree: 最终返回的树形数组,只存放"找不到父节点"的根节点
  const tree = [];

  // ==========================================================
  // 第一遍遍历:把所有节点注册到 Map 里,建立 id → node 的映射
  // 此时节点之间还没有关联,每个节点的 children 都是空数组
  // ==========================================================
  list.forEach((item) => {
    // item 是原始列表中的每一项,例如 { id: 3, name: '二级A-1', parentId: 1 }

    // map.set(key, value) ------ 把数据存入 Map
    map.set(
      item.id,       // key:  用节点自己的 id 做键,后面通过 parentId 查找时就靠它
      {
        ...item,     // value: 展开原对象的所有属性(id, name, parentId)
                     // 为什么用展开运算符?避免直接修改 item,保持原数据不变
        children: [], // 预置一个空 children 数组,后续把子节点 push 进来
      }
    );
  });
  // 遍历结束后,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:[] }
  // }
  // 注意:此时 children 全是空数组,节点之间的父子关系还没建立

  // ==========================================================
  // 第二遍遍历:把每个节点"挂"到它的父节点下面
  // ==========================================================
  list.forEach((item) => {
    // item 仍然是原始列表中的每一项

    // 第 1 步:从 Map 中取出当前节点(第一遍已经存入的包装副本)
    const current = map.get(item.id);
    // 例如 item.id=3 时,current = { id:3, name:'二级A-1', parentId:1, children:[] }

    // 第 2 步:根据当前节点的 parentId,从 Map 中找它的父节点
    const parent = map.get(item.parentId);
    // 例如 item.parentId=1,去 Map 里查 key=1 → 得到「一级菜单A」节点
    // 如果 parentId=0,Map 里没有 key=0 → map.get(0) 返回 undefined

    // 第 3 步:判断是否有父节点
    if (parent) {
      // ✅ 找到了父节点 → 把自己 push 到父节点的 children 数组里
      parent.children.push(current);
      // 重点:push 的是对象引用!
      // parent.children 和 current 指向同一个对象,
      // 所以后续如果 current 的 children 又被 push 了孙子节点,
      // parent.children 里看到的 children 也会自动包含孙子节点。
      // 这就是"一次 push,深度不限"的魔法所在。
    } else {
      // ❌ 没找到父节点(parentId 对应的 id 在 Map 中不存在)
      // → 说明自己是根节点,直接推到最终结果 tree 数组
      tree.push(current);
      // 同样 push 的是引用,所以后续如果这个根节点有了 children,
      // tree 里的 children 也会自动更新
    }
  });

  // 返回最终结果:只包含根节点的数组,但 children 中已递归嵌套了所有后代
  return tree;
}

// ============================================================
// 运行 & 输出
// ============================================================

// JSON.stringify 第三个参数 2 表示缩进 2 个空格,方便阅读
console.log(JSON.stringify(listToTree(flatList), null, 2));

📖 执行过程可视化

上面代码运行时,每一步数据怎么变化的?下面用表格还原:

第一遍遍历后 map 的状态
复制代码
Map(5) {
  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: [] }
}
第二遍遍历------逐轮追踪
轮次 item.id map.get(item.id) 得到什么? item.parentId map.get(item.parentId) 得到什么? 执行动作 影响
1 1 {id:1, name:'一级菜单A', parentId:0, children:[]} 0 undefined --- Map 里没有 key=0 tree.push(current) tree 数组新增第 1 个根节点
2 2 {id:2, name:'一级菜单B', parentId:0, children:[]} 0 undefined tree.push(current) tree 数组新增第 2 个根节点
3 3 {id:3, name:'二级A-1', parentId:1, children:[]} 1 {id:1, name:'一级菜单A', parentId:0, children:[]} parent.children.push(current) id=1 的 children 从 [] 变为 [{id:3,...}]
4 4 {id:4, name:'三级A-1-1', parentId:3, children:[]} 3 {id:3, name:'二级A-1', parentId:1, children:[]} parent.children.push(current) id=3 的 children 从 [] 变为 [{id:4,...}]
5 5 {id:5, name:'二级B-1', parentId:2, children:[]} 2 {id:2, name:'一级菜单B', parentId:0, children:[]} parent.children.push(current) id=2 的 children 从 [] 变为 [{id:5,...}]

核心洞察:push 进去的是 同一个对象的引用

  • 第 3 轮把 id=3 push 到 id=1 的 children 后,id=3 节点同时存在于 map 和 tree0.children0
  • 第 4 轮操作 parent.children.push(current),这里的 parent 就是 id=3,而这个 id=3 正是 map 里的那个对象------也是 tree0.children0 指向的那个对象
  • 所以 id=3.children 变了,tree0.children0.children 也跟着变了------它们是同一个内存地址

这就是两遍遍历能构建任意深度嵌套 的根本原因:每个节点只需要关心自己的直接父节点,嵌套的深度由数据中的 parentId 链自然形成,算法本身根本不在乎层级有多少。

第二遍结束后的 tree(最终输出)

复制代码
tree = [
  {
    id: 1, name: '一级菜单A', parentId: 0,
    children: [
      {
        id: 3, name: '二级A-1', parentId: 1,
        children: [
          {
            id: 4, name: '三级A-1-1', parentId: 3,
            children: []   ← 叶子节点,children 为空
          }
        ]
      }
    ]
  },
  {
    id: 2, name: '一级菜单B', parentId: 0,
    children: [
      {
        id: 5, name: '二级B-1', parentId: 2,
        children: []       ← 叶子节点,children 为空
      }
    ]
  }
]

💻 实现二:reduce 版本(逐行精讲)

利用 Array.reduce 的函数式风格,代码更紧凑。

Array.reduce 快速回顾

js 复制代码
// reduce 语法 ------ 将数组"归并"成一个值
const 结果 = 数组.reduce((累积值, 当前元素, 索引) => {
  // 对累积值进行某种操作
  return 新的累积值;  // ← 必须 return!
}, 初始值);
//  ↑ 初始值会作为第一轮回调的"累积值"传入
参数 含义 示例
回调函数 对每个元素执行一次,接收 (累积值, 当前元素) (sum, num) => sum + num
初始值 第一轮回调中累积值的初始状态 0(求和){}(建对象)[](建数组)

完整实现(每行都有注释)

js 复制代码
// ============================================================
// 输入数据(同 Map 版本,不再重复注释)
// ============================================================
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 },
];

function listToTree(list) {
  // ==========================================================
  // ① 第一遍 reduce:建立 id → node 的映射表
  // ==========================================================
  const nodeMap = list.reduce(
    (map, item) => {
      // map: 累积的对象,上一轮 return 的值(首轮是 {})
      // item: 当前遍历到的扁平节点

      // 用 item.id 做 key,存储一个带空 children 的副本
      map[item.id] = {
        ...item,      // 展开原对象的所有属性(id, name, parentId)
        children: [], // 预置空 children,等待后续挂载子节点
      };

      // ⚠️ 关键:必须 return map!
      // 如果不 return,下一轮的 map 就是 undefined,map[item.id] 会直接报错
      return map;
    },
    {} // ← 初始值:空对象,第一轮回调时 map = {}
  );
  // reduce 结束后,nodeMap 的值:
  // {
  //   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:[] },
  // }
  // 和 Map 版本的区别:普通对象 {1: ..., 2: ...} vs new Map()

  // ==========================================================
  // ② 第二遍 reduce:组装树,把每个节点挂到父节点下
  // ==========================================================
  return list.reduce(
    (tree, item) => {
      // tree: 累积的结果数组,存放根节点(首轮是 [])
      // item: 当前遍历到的扁平节点

      // 从映射表中取出当前节点(带 children 的包装副本)
      const current = nodeMap[item.id];
      // 例如 item.id=3 时,current 就是 nodeMap[3]

      // 从映射表中取出父节点
      // nodeMap[0] 不存在 → undefined → 说明当前节点是根节点
      const parent = nodeMap[item.parentId];
      // 例如 item.parentId=1 时,parent 就是 nodeMap[1]

      if (parent) {
        // ✅ 父节点存在:把当前节点 push 到父节点的 children 里
        // push 的是对象引用,后续修改会联动
        parent.children.push(current);
      } else {
        // ❌ 父节点不存在:当前节点是根节点,直接放入结果数组
        tree.push(current);
      }

      // ⚠️ 同样必须 return tree!否则下一轮的 tree 是 undefined
      return tree;
    },
    [] // ← 初始值:空数组,第一轮回调时 tree = []
  );
  // reduce 结束后,tree 就是最终的嵌套树形结构
}

// ============================================================
// 运行 & 输出
// ============================================================
console.log(JSON.stringify(listToTree(flatList), null, 2));

🔄 执行过程(追踪每一轮 reduce)

第一遍 reduce(建立映射表):

复制代码
初始值: map = {}                                          ← 空对象

第 1 轮: item = {id:1, name:'一级菜单A', parentId:0}
  → map[1] = {id:1, name:'一级菜单A', parentId:0, children:[]}
  → return map  ⇒ map = { 1: {...} }                    ← 有了第1个key

第 2 轮: item = {id:2, name:'一级菜单B', parentId:0}
  → map[2] = {id:2, name:'一级菜单B', parentId:0, children:[]}
  → return map  ⇒ map = { 1: {...}, 2: {...} }          ← 有了第2个key

第 3 轮: item = {id:3, name:'二级A-1', parentId:1}
  → map[3] = {id:3, name:'二级A-1', parentId:1, children:[]}
  → return map  ⇒ map = { 1: {...}, 2: {...}, 3: {...} }

第 4 轮: item = {id:4, name:'三级A-1-1', parentId:3}
  → map[4] = {id:4, name:'三级A-1-1', parentId:3, children:[]}
  → return map  ⇒ map = { 1: {...}, 2: {...}, 3: {...}, 4: {...} }

第 5 轮: item = {id:5, name:'二级B-1', parentId:2}
  → map[5] = {id:5, name:'二级B-1', parentId:2, children:[]}
  → return map  ⇒ 最终 nodeMap = { 1:..., 2:..., 3:..., 4:..., 5:... }

第二遍 reduce(组装树,和 Map 版本逻辑完全一致):

轮次 item.id current = nodeMap[item.id] item.parentId parent = nodeMap[item.parentId] 有父? 动作
1 1 nodeMap[1] 0 nodeMap[0] = undefined tree.push(current)
2 2 nodeMap[2] 0 nodeMap[0] = undefined tree.push(current)
3 3 nodeMap[3] 1 nodeMap[1] = id=1 的节点 parent.children.push(current)
4 4 nodeMap[4] 3 nodeMap[3] = id=3 的节点 parent.children.push(current)
5 5 nodeMap[5] 2 nodeMap[2] = id=2 的节点 parent.children.push(current)

最终 tree 和 Map 版本的输出完全一样。

⚠️ reduce 新手常见踩坑 :回调函数忘记 return!如果 return 丢了,下一轮回调的累积值就是 undefined,访问 .children[item.id] 都会抛出 TypeError


📊 两种写法深度对比

维度 Map 版本 reduce 版本
映射表类型 new Map() --- ES6 HashMap {} --- 普通对象
查找语法 map.get(key) / map.set(key, val) obj[key] / obj[key] = val
遍历方式 list.forEach(...) 两次 list.reduce(...) 两次
可读性 ⭐⭐⭐⭐⭐ 意图清晰,"对每个元素做某件事" ⭐⭐⭐⭐ 需要理解 reduce 的归并语义
代码量 18 行(函数体) 14 行(函数体)
循环内操作 forEach 不需要返回值 reduce 每次必须 return 累积值
性能 Map.get() O(1),大量数据下略优于对象属性查找 obj[key] O(1),差异在微秒级
新手友好度 ⭐⭐⭐⭐⭐ 不需要理解归并概念 ⭐⭐⭐ 需要先理解 reduce
函数式纯度 较低(forEach 是命令式) 较高(reduce 是声明式归并)

选择建议

复制代码
面试场景     → Map 版本,每一步能讲清楚,不容易犯错
团队协作     → Map 版本,新人看得懂,语义明确
个人项目     → 随意,哪个舒服用哪个
追求简洁     → reduce 版本,少几行代码
函数式风格   → reduce 版本,无副作用,纯归并
ID 是复杂类型 → Map 版本(Map 的 key 支持对象、Symbol 等任意类型)

💡 核心共识:两种写法本质完全相同------都是两遍 O(n) + 哈希映射。选哪个只是风格偏好,不存在优劣之分。


🔍 复杂度分析

复制代码
时间复杂度:O(n)
  ├── 第一遍遍历:O(n)     每个元素访问一次
  │   └── map.set / obj[id]= :O(1) × n 次,哈希插入常数时间
  ├── 第二遍遍历:O(n)     每个元素再访问一次
  │   ├── map.get / obj[id]:O(1) × n 次,哈希查找常数时间
  │   └── children.push(): O(1) × n 次,数组追加常数时间(均摊)
  └── 总计:O(n) + O(n) = O(2n) ≈ O(n)

空间复杂度:O(n)
  ├── 映射表:n 个节点的副本,每个多一个 children 数组引用 → O(n)
  ├── children 数组引用:n 个节点 − 根节点数,总引用仍为 O(n)
  └── tree 结果:根节点数,已包含在映射表引用中,不额外计入

⚠️ 常见坑点 & 注意事项

坑点 严重程度 说明 解决办法
直接修改原数据 🔴 高 item.children = [] 会污染 flatList,后续代码如果依赖原数据会出 bug 始终用 ...item 展开创建新对象
循环引用 🔴 高 脏数据可能出现 A→B→A 死循环(如 id=1 parentId=2,id=2 parentId=1),push 时不会报错但遍历树时会无限递归 生产环境加 visited Set 做环检测,或在入参校验阶段提前发现
parentId 指向不存在的节点 🟡 中 孤儿节点因找不到父节点,会成为根节点出现在 tree 中,可能渲染出预期之外的"一级菜单" 根据业务决定:丢弃孤儿、保留并标记 orphan: true、或打日志告警
大数据量(>10万) 🟢 低 10 万条以下前端转换无压力;超过后主线程可能卡顿(JS 单线程) 后端完成转换、分页加载、或丢到 Web Worker 中处理
reduce 忘了 return 🔴 高 回调不 return 累积值,下一轮累积值就是 undefined.push()[item.id] 直接 TypeError 写完 reduce 先检查每个代码路径是否都有 return
id 为 0 的边界情况 🟡 中 如果 id=0 是合法节点,但 parentId=0 约定为根节点,两者冲突 约定根节点的 parentId 用 null-1,避免和合法 id 冲突
children 属性冲突 🟡 中 原始数据中可能已经有 children 字段(如后端返回了部分嵌套),直接覆盖会导致数据丢失 开始转换前检查原始数据是否有 children,有则保留或合并

📝 小结

列表转树的核心就一句话:先建映射,再挂孩子

  • 第一步 :遍历列表,给每个节点建副本(...item)+ 空 children,存入 Map 或普通对象
  • 第二步 :再次遍历列表,通过 parentId 从映射表里 O(1) 找到父节点,把当前节点 push 进父节点的 children
  • 本质空间换时间。多存一份引用,把 O(n²) 降到 O(n),10000 条数据快 1000 倍
  • 魔法push 进去的是对象引用,一个节点被 push 后,后续对它的任何修改都会自动反映在所有持有该引用的地方------这就是两遍遍历能构建任意深度树的根本原因
  • 注意 :不修改原数据(...item)、reduce 必须 return、生产环境考虑环检测和孤儿节点处理

理解了这个模式,无论是菜单、分类、组织架构还是评论楼中楼,都能从容应对。下次面试被问到时,别忘了把两种写法都亮出来 😎