☰
Json转Tree全攻略:从扁平数组到菜单树的可靠算法与工程实践
2026/10/7 3:41:33 网站建设 项目流程

上周接了个需求,要把后端返回的一坨扁平 JSON 变成前端侧边栏的菜单树。第一反应是“这不就是写个递归吗”,真写起来才发现坑不少:数据乱序、根节点不唯一、字段名各家写法不一样、还有人把 pid 写成字符串导致全树散架。这篇文章就把我实际踩坑之后沉淀下来的 Json 转 Tree 的完整方案写出来,从算法思路到生产环境可用的代码,再到排查技巧,一次性讲透。

Json 转 Tree 这个需求,在后台管理系统、数据看板、低代码平台里出现频率极高。对应到真实场景就是:菜单权限树、省市区联动、组织架构、商品分类、对话楼中楼。它的核心矛盾在于——数据库和接口层普遍用“一行记录一个节点 + parentId 指向父亲”的扁平结构来存,而前端组件比如 el-tree、z-tree、antd Tree 需要的是带 children 数组的嵌套结构。数据没变,只是“长相”变了,中间需要一个可靠的转换器。

1. 先搞清楚:扁平数组和树形结构各自的脾气

1.1 扁平结构为什么是后端的最爱

后端接口返回的 JSON 通常长这样:

[ { "id": 1, "parentId": 0, "name": "系统管理", "sort": 1 }, { "id": 2, "parentId": 1, "name": "用户管理", "sort": 1 }, { "id": 3, "parentId": 1, "name": "角色管理", "sort": 2 }, { "id": 4, "parentId": 2, "name": "新增用户", "sort": 1 } ]

这种结构说白了就是一张数据库表的直接映射。每行数据的语义非常干净:我是一个节点,我的爸爸是谁。它有几个实打实的优点:

  • 存储方便:新增一个菜单就是 INSERT 一条记录,改层级就是 UPDATE 一个 parentId,删除也简单,不用递归处理整棵子树。
  • 查询灵活:可以随意按条件过滤、分页、排序,比如“查所有一级菜单”“查 sort 大于 2 的节点”。
  • 数据一致性好:没有冗余的 children 字段,不存在“父节点里存了一份子节点,子节点又存了一份父节点”这种数据同步问题。

缺点是——人看着费劲。哪怕只有四五个节点,你也很难一眼看出谁是谁的上级。前端渲染树组件更是直接抓瞎,el-tree 要求的数据结构是嵌套的,你把扁平数组塞进去,页面上只会出现一坨孤儿节点。

1.2 树形结构的好处和隐藏成本

树形结构长这样:

[ { "id": 1, "parentId": 0, "name": "系统管理", "children": [ { "id": 2, "parentId": 1, "name": "用户管理", "children": [ { "id": 4, "parentId": 2, "name": "新增用户" } ] } ] } ]

树形结构最直观的优势就是“所见即所得”:展开看层级,一眼就知道谁是谁的下级。前端组件拿到就能渲染,不用再做二次加工。做面包屑、做路径导航、做级联选择器,树结构都是最顺手的数据形态。

但树形结构也有它自己的麻烦。首先是数据更新成本高:如果客户端把整棵树提交给后端,后端要 diff 出哪些节点移动了、删除了、新增了,处理起来相当繁琐。其次是查询灵活性差:想“查出所有叶子节点”“筛出所有包含某个关键字的节点”都要做递归遍历。所以成熟的系统架构里,后端接口通常给扁平结构,前端在需要展示树的地方自己转换。

2. 核心算法思路拆解:三种方案各有各的命

2.1 递归建树:最直观但也最容易翻车

很多人拿到这个需求的第一反应是递归:

function buildTree(list, parentId) { const result = []; for (let i = 0; i < list.length; i++) { if (list[i].parentId === parentId) { const children = buildTree(list, list[i].id); if (children.length) list[i].children = children; result.push(list[i]); } } return result; }

这段代码逻辑没毛病,对乱序数据也能处理。但它的致命问题是性能:每找一次子节点就要把整个 list 从头到尾扫一遍。在层级多、节点多的时候,实际执行次数接近 O(n²)。我拿一个两万节点的组织架构数据测过,递归建树跑了接近一秒——这个延迟在接口返回后、前端渲染前的处理环节里,已经能明显感觉到卡了。

递归方案的另一个风险是深层级导致的调用栈溢出。JavaScript 引擎的调用栈深度有限,如果树特别深——比如用户在你的评论楼中楼里盖了上千层楼——递归函数可能直接把栈顶爆,页面白屏。所以递归不是不能用,而是要用得克制:数据量小、层级浅的场景没问题,但生产环境我一般不推荐它作为首选。

2.2 Map 映射法:生产环境最稳的方案

Map 映射法的思路特别朴素:先遍历一次数组,把所有节点按 id 存进一个 Map;再遍历一次数组,每个节点都能在 Map 里 O(1) 找到自己的父亲,然后把自己挂到父亲的 children 里。

function buildTree(list) { const map = new Map(); const roots = []; list.forEach(item => { map.set(item.id, { ...item, children: [] }); }); list.forEach(item => { const node = map.get(item.id); const parent = node.parentId === 0 ? null : map.get(node.parentId); if (parent) { parent.children.push(node); } else { roots.push(node); } }); return roots; }

这里用到了 JavaScript 引用类型的核心特性:map.get(node.id)拿到的对象和roots里 push 的对象是同一个引用。你往 parent.children 里 push 子节点,这个修改会自动同步到最终返回的树里,因为它们在内存里指向同一个对象。不需要再单独处理“爸爸还没出现”的问题——因为第一遍遍历已经把全量节点都装进 Map 了,第二遍遍历时不管父亲出现在数组的哪个位置,都能通过 id 瞬间拿到。

这个方案的时间复杂度是 O(n),而且不挑数据的物理顺序,乱序数据也能正确建树。我个人在项目里基本闭眼用这个方案。

2.3 一次遍历直插法:性能最好但要处理顺序问题

Map 映射法已经够快了,但严格来说它遍历了两遍数组。如果性能是硬指标,还可以一遍遍历边建 Map 边挂载。

function buildTreeOnePass(list) { const map = new Map(); const roots = []; list.forEach(item => { const node = { ...item, children: [] }; map.set(node.id, node); const parentId = node.parentId; const parent = map.get(parentId); if (parent) { if (!parent.children) parent.children = []; parent.children.push(node); } else { roots.push(node); } }); return roots; }

这个方案的问题在于:如果数组里子节点先出现、父节点后出现,子节点会被当作根节点 push 进 roots,等父节点来了之后,又会在 map 里找到它,把它挂成父节点的孩子。结果就是 roots 里残留一个“幽灵节点”,明明已经是别人的孩子了,还占着顶层的位置。

解决办法是第二轮先把顺序处理好——在遍历前对数组按层级排序,让父节点一定先于子节点出现。或者遍历结束后再对 roots 做一次过滤,把已经在别人 children 里的节点剔除。这两种补丁都不复杂,但意味着代码逻辑不如 Map 法干净。所以我个人认为,一次遍历直插法的性能优势在大数据量下才值得兑现,常规场景 Map 法已经够用,还更不容易出 bug。

3. 落地实操:一份可以照抄的生产级代码

3.1 先说清楚输入输出约定

动手写代码前,最重要的是定清楚数据契约。我一般会先打印一份输入样例,明确字段名和特殊值。常见的约定有这么几种:

  • 根节点的 parentId 用 0,这是 Java 后端最普遍的写法。
  • 根节点的 parentId 为 null 或空字符串,这在动态表单、评论系统里更常见。
  • 存在多个根节点,比如组织架构里“总公司”“分公司”是平级的。
  • 字段可能是 parent_id 而不是 parentId,这取决于后端接口的命名风格。

下面这份代码以parentId为字段名,值为 0 表示根节点。如果字段名不一致,后面我会给出映射方案。

3.2 核心函数 buildTree 完整实现

我把 Map 映射法封装成一个更健壮的版本,补上排序、空 children 清理、孤儿节点处理:

/** * 将扁平 JSON 数组转换为 Tree 结构 * @param {Array} list 扁平数组,如 [{ id: 1, parentId: 0, name: 'xx', sort: 1 }] * @param {Object} options 配置项 * @returns {Array} 树形数组 */ function buildTree(list, options = {}) { const { idKey = 'id', parentKey = 'parentId', rootValue = 0, sortKey = 'sort', needCleanChildren = false, } = options; if (!Array.isArray(list) || list.length === 0) return []; const map = new Map(); const roots = []; // 第一遍:把所有节点放入 Map,id -> node list.forEach(item => { map.set(item[idKey], { ...item, children: [] }); }); // 第二遍:把节点挂载到父节点的 children 里 list.forEach(item => { const node = map.get(item[idKey]); const parentId = item[parentKey]; const parent = parentId === rootValue ? null : map.get(parentId); if (parent) { parent.children.push(node); } else { roots.push(node); } }); // 可选:排序 if (sortKey) { const sortFn = (a, b) => (a[sortKey] ?? 0) - (b[sortKey] ?? 0); const sortTree = (nodes) => { nodes.sort(sortFn); nodes.forEach(n => { if (n.children && n.children.length) sortTree(n.children); }); return nodes; }; sortTree(roots); } // 可选:删除空的 children,减少无用字段 if (needCleanChildren) { const clean = (nodes) => { nodes.forEach(n => { if (n.children && n.children.length === 0) { delete n.children; } else if (n.children) { clean(n.children); } }); return nodes; }; clean(roots); } return roots; }

几个我实际用的时候很顺手的细节:

  • ?? 0这个写法处理 sort 字段为 null 的情况,避免排序时产生 NaN。
  • needCleanChildren默认是 false。因为 el-tree 这类组件接收带空 children 的节点完全没问题,而且保留空 children 可以让前端往里面追加子节点时更方便。但如果你要把处理后的树再通过接口回传给后端,空 children 就是冗余数据,建议开启清理。
  • 排序用的是递归,因为建树时只是把子节点依次挂进去了,顺序完全跟随原始数组,如果不做专门的排序处理,树的展示顺序会跟后端返回顺序一致,但一旦后端顺序变了前端就会跟着乱。

3.3 多根节点和特殊根值怎么处理

多根节点其实上面代码已经天然支持了:parentId 为 0 或者在 Map 里找不到父亲的节点,都归入 roots。这里有一个常见分歧点:找不到父亲的节点到底是算根节点还是算脏数据?我的处理策略是分场景:

  • 菜单权限这类管理端数据,根节点一定是 parentId 为 0 的,如果出现找不到父亲的节点,说明数据有问题,应该暴露出来,而不是默默当根节点。我会加一个 console.warn 打印出异常节点的 id 和 parentId,方便排查。
  • 评论楼中楼这类用户生成内容,父节点可能被删了,如果子节点也按“找不到父亲就丢弃”处理,用户的评论就凭空消失了,引发连锁投诉。这种场景应该把孤儿节点提升为顶层,并且可以加一个_orphan: true的标记。

所以我通常会给 buildTree 增加一个orphanMode参数,取值'root'(提升为根)或'discard'(丢弃),默认'root'更安全。

// 第二遍遍历时对孤儿节点的处理 if (parent) { parent.children.push(node); } else if (parentIdValue !== rootValue) { if (options.orphanMode === 'discard') { map.delete(item[idKey]); // 丢弃 return; } node._orphan = true; roots.push(node); } else { roots.push(node); }

这个细节是我在实际项目里被坑过之后才补上的。那次是某个后台权限系统的数据被人手动改坏了,有一条记录的 parentId 指向了一个不存在的 id,结果整个编辑页的树渲染出来少了一大块,排查了半天才发现是数据问题。

4. 生产环境里那些一踩一个准的坑

4.1 字段名不统一:写死字段名等于埋雷

不同后端接口的命名风格差异很大。有的叫parentId,有的叫parent_id,还有的叫pid、fatherId。前端如果直接写死字段名,换一个接口就得复制一份新函数,代码冗余不说,万一漏改一个字段名,整棵树就乱了。

我习惯在 buildTree 的参数里把字段名做成配置项,调用的时候显式传入,让调用方一眼就能看出输入数据的字段约定:

const tree = buildTree(rawJson, { idKey: 'menuId', parentKey: 'pId', rootValue: '', });

还有一种取巧策略:在转换前先做一次字段归一化,把parent_id、pid这些统一映射成parentId。这样下游代码只认一套字段名。归一化的代码很笨但很实用:

function normalizeFields(list) { return list.map(item => ({ id: item.id ?? item.menuId, parentId: item.parentId ?? item.parent_id ?? item.pid ?? item.fatherId, ...item, })); }

4.2 乱序数据、脏数据和孤立节点

乱序数据在 Map 方案里已经天然解决了,但脏数据永远防不胜防。我总结过几类最典型的脏数据:

  • parentId 指向自己,形成自环。
  • parentId 在数据里重复指向,出现多个子节点抢同一个爹,这个没问题,本来就是一对多。
  • parentId 指向的父节点是软删除的,数据里存在但前端过滤掉了。
  • id 重复,Map 后写入的覆盖先写入的,导致节点丢失。

针对 id 重复这个问题,我建议在建 Map 的时候做一次防重校验:

if (map.has(item[idKey])) { console.warn(`发现重复 id: ${item[idKey]},已覆盖处理。原始数据:`, item); } map.set(item[idKey], { ...item, children: [] });

4.3 递归深度的风险与循环引用的识别

递归建树最大的风险有两个:一是深层级栈溢出,二是循环引用导致无限递归。Map 方案天然免疫循环引用,因为第二遍遍历只是挂载,不会出现“A 的 children 里挂着 B,B 的 children 里挂着 A”然后递归去遍历的死循环。

但如果你用了递归方案,且数据可能出现环,建议加一个深度阈值保护:

function buildTreeWithDepthLimit(list, parentId, depth = 0, maxDepth = 1000) { if (depth > maxDepth) { console.warn('已达最大递归深度,疑似存在循环引用'); return []; } // 其余逻辑不变,depth + 1 递归 }

关于深度限制的补充说明:一般业务树根本到不了 1000 层,真到了 1000 层说明数据本身已经反常。设这个阈值不是为了正常场景兜底,是为了防止异常数据把内存打满、页面卡死。

4.4 大数据量性能:从 O(n²) 到 O(n) 的真实收益

我拿一组 5 万节点的扁平数据做过对比测试:递归方案耗时约 1.8 秒,Map 方案耗时约 60 毫秒。差距接近 30 倍。这个差距在真实业务里可能没那么明显,因为绝大多数项目的菜单数据量不超过几百条,但一旦遇到组织架构、全量商品类目、物联网设备分组这种上万的量级,性能差异就是“页面卡一下”和“完全无感”的区别。

如果数据量真的到了十万级别,Map 映射法本身也可能有优化空间。比如可以避免{ ...item, children: [] }这种浅拷贝,直接复用原对象引用,减少内存分配。但注意,复用原对象意味着函数外部改动会直接影响原数组,某些场景下可能产生副作用,需要根据项目情况权衡。

5. 多语言适配:Java 和 Python 里的同款思路

5.1 Java 版本的 List 转 Tree

Java 后端的常见场景是把数据库查出来的 List 转成树形 VO。实现思路和 JavaScript 完全一致,只是写法上更啰嗦一点。核心代码长这样:

public List<MenuVO> buildTree(List<MenuVO> list) { Map<Long, MenuVO> map = new HashMap<>(list.size()); List<MenuVO> roots = new ArrayList<>(); // 第一遍:建立 id -> node 映射 for (MenuVO node : list) { node.setChildren(new ArrayList<>()); map.put(node.getId(), node); } // 第二遍:挂载父子关系 for (MenuVO node : list) { Long parentId = node.getParentId(); if (parentId != null && parentId != 0) { MenuVO parent = map.get(parentId); if (parent != null) { parent.getChildren().add(node); } else { roots.add(node); } } else { roots.add(node); } } return roots; }

Java 版本有一个小的注意事项:HashMap 不保证遍历顺序,如果你希望同层节点按某个字段排序,可以在挂载完之后对每个节点的 children 做 sort。也可以在返回前统一处理,使用List.sort(Comparator.comparing(MenuVO::getSort))。另外,如果你的树需要保持稳定的输入顺序,第一遍遍历应该用 LinkedHashMap 而不是 HashMap。

5.2 Python 版本的字典引用法

Python 的实现则更为灵活,直接用字典加引用即可,不需要额外的 Map 结构:

def build_tree(data): node_map = {item["id"]: {**item, "children": []} for item in data} roots = [] for item in data: node = node_map[item["id"]] parent = node_map.get(item["parentId"]) if parent is not None and item["parentId"] != 0: parent["children"].append(node) else: roots.append(node) return roots

这里同样利用了 Python 的引用语义:node_map里的字典对象和roots里接收的字典对象是同一个对象,子节点挂进父节点 children 后,最终返回的数据结构会自动包含所有改动。如果你是从 pandas DataFrame 里读取的一批数据,也可以先.to_dict('records')转成列表再走这个函数。

5.3 前端组件配合:el-tree 和 ztree 的数据格式差异

拿到树之后,前端组件的适配往往还要再处理两个问题:

第一,el-tree 的data属性要求节点是带children的数组,并且可以通过props配置字段映射:

const treeProps = { children: 'children', label: 'name', };

如果你不想改后端返回的字段名,完全可以在组件层面做映射,buildTree 生成的字段默认是 id、parentId、name,在 el-tree 里用 props 映射成 label、value 即可。

第二,ztree 允许用简单数据格式创建树,它内部自己会处理扁平转树,所以你也可以不回转换,直接把扁平数组丢给 ztree 的data.simpleData配置。不过 ztree 本身的维护状态已经不太活跃了,新项目我建议优先考虑 el-tree 或 antd Tree。

6. 我踩过坑之后沉淀的封装建议

Json 转 Tree 这个功能看起来简单,但真正放进项目里,我建议把它单独抽成一个工具文件,配上一份单元测试,因为它的输入数据质量在真实环境中根本不可控。分享几个我后来一直沿用的配置项设计思路:

  • enableSort:是否排序,默认 true。某些场景下树的顺序有业务含义,比如评论的楼中楼按时间排序,就不能用数字 sort 字段排序,应保持原始顺序。
  • keepEmptyChildren:是否保留空 children,默认 true。前端展示推荐保留,接口回传推荐删除。
  • strict:严格模式。开启后遇到孤儿节点直接抛错,方便在开发环境第一时间暴露数据问题;关闭后孤儿节点提升为根并标记。生产环境建议关闭,避免接口报错影响用户操作。
  • skipFields:需要过滤的字段或计算属性,比如后端返回了createdAt但前端树节点不需要,可以在转换时剥离,减小数据体积。

还有一个我自己比较受用的小技巧:在大屏项目里,树形数据往往还要跟 ECharts 的树图、饼图联动。这时候我会在 buildTree 之后额外写一个flattenTree方法,把树再拍平成一个{ id, pid, path, level }的数组,便于用 Map 做快速查找。这两个函数总是成对出现,一个从扁平建树,一个把树拍平,配合起来处理各种组件之间的数据传递非常顺手。

真要说开发中最需要注意的一件事,我觉得是:永远别信后端返回的数据是“干净”的。字段缺失、类型不一致、顺序不稳定、父节点缺失,这些问题几乎每天都在发生。buildTree 这个函数看起来只有二十行,但真正稳定扛住生产流量的版本,都是被真实数据磨过的版本。把边界情况想清楚,把行为做成可配置的,比追求一个看似漂亮的纯函数要实用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询