
上周接了个需求要把后端返回的一坨扁平 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 []; // 第一遍把所有节点放入 Mapid - 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 里挂着 BB 的 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 转 TreeJava 后端的常见场景是把数据库查出来的 List 转成树形 VO。实现思路和 JavaScript 完全一致只是写法上更啰嗦一点。核心代码长这样public ListMenuVO buildTree(ListMenuVO list) { MapLong, MenuVO map new HashMap(list.size()); ListMenuVO 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 这个函数看起来只有二十行但真正稳定扛住生产流量的版本都是被真实数据磨过的版本。把边界情况想清楚把行为做成可配置的比追求一个看似漂亮的纯函数要实用得多。