从扁平到层级:优雅实现“列表转树”的两种思路

0 阅读4分钟

管理后台、多级菜单、地址联动……这些场景背后都离不开一个经典问题:如何将扁平的列表数据转换成树形结构?

在日常开发中,我们经常遇到这样的数据结构:数据库里存的是扁平列表,每条记录通过 parentId 关联父级。但前端展示时,却需要一颗完整的树。

比如下面这个扁平数组:

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 },
];

我们需要把它转换成这样:

json

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

今天我们就来聊聊这个经典问题的两种高效解法。


一、为什么不用递归?

很多人第一反应是递归查找父子关系:

javascript

function buildTree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: buildTree(list, item.id)
    }));
}

代码确实简洁,但它的时间复杂度是 O(n²) 。当数据量较大时(比如几百条以上),递归会带来明显的性能问题,而且每次都要遍历全量数据。

更好的做法是 空间换时间


二、主流方案:Map 索引 + 两次遍历

这种思路的核心是:

  1. 先用一个 Map(或对象)把所有节点存起来,以 id 为键。
  2. 再遍历一次,通过 parentId 找到父节点,把当前节点挂到父节点的 children 中。
  3. 如果找不到父节点(parentId === 0 或父节点不存在),则作为根节点。

写法一:使用 Map(ES6)

javascript

function list2tree(list) {
  const tree = [];
  const map = new Map();

  // 第一遍:建立 id -> node 映射,并初始化 children
  list.forEach(item => {
    map.set(item.id, {
      ...item,
      children: []
    });
  });

  // 第二遍:组装父子关系
  list.forEach(item => {
    const cur = map.get(item.id);
    const parent = map.get(item.parentId);

    if (parent) {
      parent.children.push(cur);
    } else {
      tree.push(cur);
    }
  });

  return tree;
}

优点

  • 清晰直观,易读性好。
  • 时间复杂度 O(n) ,两次遍历,性能优异。

注意点

  • Map 的 key 可以是任意类型,这里用数字 id 很合适。
  • 展开运算符 ...item 复制了原对象属性,避免修改原数据。

写法二:使用对象(Plain Object) + reduce

如果你更偏爱函数式风格,可以用 reduce 一行搞定映射,然后再 reduce 一次构建树:

javascript

function list2tree(list) {
  // 构建 id -> node 映射
  const nodeMap = list.reduce((map, item) => {
    map[item.id] = { ...item, children: [] };
    return map;
  }, {});

  // 组装树
  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;
  }, []);
}

优点

  • 代码更紧凑,纯函数无副作用。
  • 同样 O(n)  复杂度,适合追求简洁风格的同学。

三、两种写法的对比

对比维度Map 写法reduce 写法
可读性⭐⭐⭐⭐⭐ 更清晰⭐⭐⭐⭐ 需理解 reduce
性能⭐⭐⭐⭐⭐ 略快(Map 查找)⭐⭐⭐⭐ 对象查找也很快
代码行数适中更短
适用场景团队协作、维护友好个人项目、追求简洁

两者本质都是  “建立索引 + 二次遍历” ,选哪一种取决于你对代码风格的偏好。


四、进阶思考

1. 如果数据量极大(上万条)?

  • 依然可以使用 Map 方案,O(n) 线性复杂度足够应对绝大多数前端场景。
  • 如果后端返回的数据已经按 parentId 排序,还可以进一步优化,但实际意义不大。

2. 是否需要考虑循环引用?

理论上业务数据不会出现循环引用(比如 A 的父是 B,B 的父又是 A)。如果你不放心,可以在 list2tree 中增加访问标记检测,但绝大多数场景不需要。

3. 根节点的 parentId 一定是 0 吗?

不一定。有些业务会用 null 或 undefined 表示根节点。代码中可以灵活调整判断条件:

javascript

if (parent) { ... } else { ... }

只要父节点在 nodeMap 中找不到,就会自动成为根节点,非常通用。


五、总结

列表转树是前端工程中的“家常便饭”,掌握高效的实现方式,不仅能让代码跑得更快,还能提升代码质量和可维护性。

核心思想一句话总结:

用 Map / 对象建立 id 索引,再通过 parentId 将节点挂载到父节点的 children 中,根节点单独收集。

这两种写法你更喜欢哪一种?欢迎在评论区交流讨论~


如果觉得有用,不妨点个  或 收藏,让更多同学看到这篇文章 😄