parentID ``` JavaScript 是区分大小写的,所以这两个不是同一个字段。 第二,`parent` 没有声明。 应该先写: `

JS 列表转树:用 Map 和 reduce 搞懂多级菜单数据转换

前言

在前端开发中,我们经常会遇到一种数据转换需求:后端返回的是一维数组,但页面需要展示成树形结构。

比如管理后台的多级菜单、城市级联选择、部门组织架构、评论楼中楼等场景,数据在数据库里通常是一张表,每一条数据都有自己的 id,同时通过 parentId 指向父级。

这时就需要把"扁平列表"转换成"树结构"。


一、什么是扁平列表?

假设后端返回的数据是这样的:

yaml 复制代码
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 }
];

这就是扁平列表。

它的特点是:所有数据都在同一层数组里,每一项本身不是嵌套结构。

但是从数据关系上看,它们其实是有层级的:

css 复制代码
一级菜单A
└─ 二级A-1
   └─ 三级A-1-1

一级菜单B
└─ 二级B-1

判断层级关系的关键就是 parentId


二、id 和 parentId 的作用

每一项数据里通常有两个重要字段:

yaml 复制代码
{ id: 3, name: '二级A-1', parentId: 1 }

它表示:

  • id: 3:当前节点自己的编号
  • parentId: 1:当前节点的父节点编号是 1

所以:

yaml 复制代码
{ id: 3, parentId: 1 }

意思就是:id 为 3 的节点,要挂到 id 为 1 的节点下面。

如果 parentId0,一般表示它没有父节点,是顶层节点。


三、为什么要用 Map?

如果直接在数组里找父节点,每次都要遍历一遍数组。

比如要找 parentId 为 1 的父节点,可能要这样:

ini 复制代码
list.find(item => item.id === parentId)

如果数据量很大,每个节点都这样找一次,性能就会变差。

所以更好的做法是:先建立一个索引表。

arduino 复制代码
const map = new Map();

把每个节点按照自己的 id 存起来:

python 复制代码
map.set(item.id, {
  ...item,
  children: []
});

这样后面查找父节点时,就可以直接:

arduino 复制代码
map.get(item.parentId)

Map 在这里就像一本目录,通过 id 可以快速找到对应节点。


四、Map 版列表转树

完整代码如下:

ini 复制代码
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) {
  const map = new Map();
  const tree = [];

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

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

这段代码分成两步。

第一步:建立节点索引。

ini 复制代码
list.forEach(item => {
  map.set(item.id, {
    ...item,
    children: []
  });
});

这一步给每个节点都加上了 children 数组,并且用 id 作为 key 存进 Map

第二步:组装树结构。

ini 复制代码
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);
  }
});

如果能找到父节点,就把当前节点放进父节点的 children 里。

如果找不到父节点,说明它是顶层节点,就放进最终的 tree 数组。


五、JSON.stringify 的调试作用

树结构比较深的时候,普通 console.log 看起来不够直观。

可以使用:

javascript 复制代码
console.log(JSON.stringify(listToTree(flatList), null, 2));

其中:

javascript 复制代码
JSON.stringify(value, null, 2)

第三个参数 2 表示缩进两个空格。

这样打印出来的树结构会更清楚,方便观察每一级的 children


六、reduce 版实现

除了 Map,也可以使用普通对象 {} 搭配 reduce 实现。

reduce 的作用是把数组一步步归纳成一个结果。

第一轮 reduce 用来生成节点索引表:

ini 复制代码
const nodeMap = list.reduce((map, item) => {
  map[item.id] = {
    ...item,
    children: []
  };

  return map;
}, {});

它最终会生成类似这样的结构:

yaml 复制代码
{
  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: [] }
}

第二轮 reduce 用来组装树:

ini 复制代码
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;
}, []);

完整代码如下:

ini 复制代码
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) {
  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;
  }, []);
}

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

七、原 reduce 代码里的问题

原来的 2.js 中有这段代码:

ini 复制代码
const cur = nodeMap[item.parentID];
if (parent){
  parent.children.push(cur);
}

这里主要有三个问题。

第一,字段名写错了。

数据里是:

复制代码
parentId

代码里写成了:

复制代码
parentID

JavaScript 是区分大小写的,所以这两个不是同一个字段。

第二,parent 没有声明。

应该先写:

ini 复制代码
const parent = nodeMap[item.parentId];

第三,cur 表示当前节点,应该通过当前节点自己的 id 获取:

ini 复制代码
const cur = nodeMap[item.id];

而不是通过 parentId 获取。

正确写法是:

ini 复制代码
const cur = nodeMap[item.id];
const parent = nodeMap[item.parentId];

八、Map 版和 reduce 版的区别

Map 版更直观,适合刚开始理解列表转树。

arduino 复制代码
const map = new Map();

它的语义很清楚:通过 key 找 value。

reduce 版代码更紧凑,更偏函数式写法。

javascript 复制代码
list.reduce((result, item) => {
  return result;
}, initialValue);

但是 reduce 对新手来说阅读成本稍高,所以学习时建议先掌握 Map 版,再理解 reduce 版。

实际开发中,两种写法都可以。关键不在于写法,而在于理解这件事:

先建立索引,再连接父子关系。


九、今日总结

列表转树的核心不是递归,而是关系映射。

id 用来表示当前节点是谁,parentId 用来表示当前节点的父亲是谁,children 用来保存当前节点的子节点。

实现时一般分两步:

  1. 先把所有节点存进索引表
  2. 再根据 parentId 把子节点挂到父节点下面

这类思想在前端业务里很常见,比如管理后台菜单、组织架构、地区选择、评论嵌套等场景都会用到。

学会列表转树,不只是掌握一道算法题,更是在理解前端如何处理真实业务数据。

相关推荐
怕浪猫2 小时前
Electron 开发实战(十六):总结与展望|生态现状、框架对比、行业趋势与学习指南
前端·javascript·electron
ZengLiangYi3 小时前
批量导入 1000 条对话的性能优化实战
javascript·后端·架构
竹林8183 小时前
用 wagmi v2 + viem 监听合约事件时踩的坑,我花了两天才把"遗漏事件"修好
javascript
小花酱酱3 小时前
QQ群里只有你一个人?邪门歪道破局之路——AstrBot
javascript
bonechips3 小时前
JS 数组指南:从内存原理到二维矩阵
前端·javascript
mONESY3 小时前
前端零基础精讲:Canvas3D、CSS3D、文档流、定位全方位复盘
javascript
竹林81819 小时前
Web3表单签名验证:我用 wagmi 和 ethers 给 DApp 加了一个“免密登录”,踩坑记录全在这了
javascript
用户69903048487519 小时前
try catch使用场景 处理同步代码错误兼容用的
javascript·uni-app