JSON转Tree从扁平到嵌套:Map映射方案与工程实践详解

发布时间:2026/10/7 16:45:18
JSON转Tree从扁平到嵌套:Map映射方案与工程实践详解 1. 先搞清楚需求到底是哪种Tree“JSON转Tree”这个标题看起来很简单实际上放在不同项目里做出来的东西完全不是一回事。我在实际开发里接手过好几回类似需求每次都要先和提需求的人确认一个问题你要的Tree是有父子关系的数据需要嵌套展示还是把一段JSON字符串直接变成树形控件能用的数据格式。大多数情况下需求其实是前者数据库里存的是扁平列表每行带有id和parentId前端需要一个children数组嵌套的结构才能丢给Tree组件、级联选择器或者表格树去渲染。少数情况是后者比如把一段带嵌套的JSON直接解析成某个框架的Tree数据结构或者从一个JSON数组里按某个路径提取字段再组装成树。这两种我都做过但本文核心讲第一种也是出现频率最高的一种。这类需求通常出现在这些场景里后台管理系统菜单权限树、部门组织架构树、角色权限树几乎每个管理后台都有。电商系统商品分类多级联动、地区选择器、属性规格树。内容系统评论楼中楼、目录章节树、知识库文档树。数据交换场景从spark中读取json或JMeter等工具导出的Json结果文件经常需要先转成树形结构再可视化。尤其是现在很多数据中台项目前端拿到的原始数据是各种接口吐出来的扁平JSON数组比如[{ id: 1, parentId: 0, name: 根节点 }, { id: 2, parentId: 1, name: 子节点 }]而组件层需要的是[{ id: 1, children: [{ id: 2 }] }]。这个转换逻辑看起来不起眼但写不好会带来一连串问题层级错乱、循环引用导致JSON.stringify直接报错、大数据量卡死、排序不稳定。所以这篇文章我把整个转换方案的选型逻辑、核心代码、踩坑记录和扩展思路都整理出来给正在写或准备写这个功能的人一个可以直接参考的完整版本。2. 方案选型三种常见玩法背后的取舍2.1 递归查找法最直观但最容易翻车很多人第一次写JSON转Tree第一反应就是双层循环加递归遍历数组里的每个节点找到它的所有子节点然后递归处理子节点。伪代码大概是这样的for node in list: if node.parentId currentId: 把node挂到currentId下面 继续递归找node的子节点这个写法的优点是思路非常好理解代码写出来也短。但它有两个致命问题第一每次找一个父节点的子节点都要全量遍历一遍数组时间复杂度是O(n^2)数据量一旦到几千条页面上就能明显感觉到卡顿第二如果原始数据里有节点先出现在父节点前面或者存在穿越层级的数据递归写法很容易把树构建出重复分支。我早期写过一版递归查找的后来线上有个目录树数据量到了大概8000多条接口响应直接翻了三倍。从那以后我基本不推荐用这种方式除非你的数据量很小比如一两百条以内而且确定数据规整没有脏数据。2.2 Map映射法工程里最稳的写法我现在最常用的方案是把数组先转成一个Map以id为key再遍历一遍通过parentId直接定位父节点把当前节点挂到父节点的children里。这一步的时间复杂度是O(n)数据量再大也扛得住。核心思路可以总结成三步第一次遍历把所有节点放进Mapkey是节点id。第二次遍历对每个节点到Map里找它的父节点。找到父节点就挂到父节点的children里找不到就把它当成顶层节点。这个方案我在后面会给出完整代码。它的好处不仅仅是快更重要的是它天然避免了递归不会出现深层递归导致的调用栈溢出问题。而且因为只用遍历两次逻辑上非常容易被review出问题也容易排查。2.3 特殊场景的扩展玩法除了上面两种还有一些变种方案按需使用。两次遍历排序法如果构建出来的Tree还要求兄弟节点按某个字段有序可以先把数组按排序字段排好序再做Map映射。因为Map遍历顺序和插入顺序不一定一致先排序再构建能保证最终Tree里的顺序正确。懒加载子树构建有些场景不希望一次性把整棵树都建出来比如目录树数据量几十万条只要求点击某个父节点时再去查它的直接子节点。这种时候就不需要全局构建写一个getChildren(parentId)方法就够了。严格来说这不算JSON转Tree但业务上经常被混淆我单独拿出来提醒一下。前缀分类法个别场景下父子关系不是靠parentId而是靠一个类似路径的字段比如path: A/B/C来体现。这种场景需要先按路径层级分组再嵌套组装。我在第6节里会讲怎么把它和通用方案结合。3. 核心代码实现Java后端版完整走一遍3.1 先定义树节点模型我习惯把树节点做成一个泛型基类。因为同一个项目里可能有菜单树、地区树、组织树好几种树如果每种都单独写一套转换逻辑就重复造轮子了。import java.util.ArrayList; import java.util.List; public class TreeNodeT { private Object id; private Object parentId; private T data; private ListTreeNodeT children new ArrayList(); public TreeNode() { } public TreeNode(Object id, Object parentId, T data) { this.id id; this.parentId parentId; this.data data; } // getter/setter 省略IDE自动生成即可 }这里我需要解释一个关键点id和parentId的类型我为什么用Object而不是Long或String。原因很简单真实业务里主键类型五花八门有的是自增Long有的是UUID字符串有的是雪花算法生成的Long甚至有的是字符串拼接的复合ID。如果把类型写死成Long遇到字符串ID的代码就要重写一遍。用Object但内部比较的时候统一转成String就能通吃绝大多数场景。这个细节我在Java实现里用到了往下看。3.2 核心转换方法这是整个工具类的核心我把每一步的注释写得比较细方便直接抄。import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class TreeUtils { /** * 将扁平的JSON数组字符串转换为Tree结构 * * param jsonArray JSON数组字符串例如[{id:1,parentId:0,name:根},{...}] * param idField 主键字段名例如 id * param parentField 父级字段名例如 parentId * param childrenField 子节点字段名例如 children * return 嵌套树结构 */ public static ListMapString, Object convertJsonToTree( String jsonArray, String idField, String parentField, String childrenField) { ObjectMapper mapper new ObjectMapper(); ListMapString, Object list; try { list mapper.readValue(jsonArray, new TypeReferenceListMapString, Object() { }); } catch (Exception e) { throw new RuntimeException(JSON解析失败请检查原始数据格式, e); } return convertListToTree(list, idField, parentField, childrenField); } /** * 将ListMapString,Object转换为Tree结构 */ public static ListMapString, Object convertListToTree( ListMapString, Object list, String idField, String parentField, String childrenField) { if (list null || list.isEmpty()) { return new ArrayList(); } // 第一步遍历一次建立 id - 节点 的映射 MapString, MapString, Object nodeMap new LinkedHashMap(); for (MapString, Object node : list) { Object idValue node.get(idField); if (idValue null) { continue; } String idKey String.valueOf(idValue).trim(); nodeMap.put(idKey, node); // 先把children字段预置为空List避免后续直接put覆盖已有结构 node.put(childrenField, new ArrayListMapString, Object()); } // 第二步再次遍历把每个节点挂到父节点的children里 ListMapString, Object tree new ArrayList(); for (MapString, Object node : list) { Object parentValue node.get(parentField); // 父节点ID为空、为0或者找不到父节点时视为顶层节点 if (parentValue null || 0.equals(String.valueOf(parentValue).trim()) || String.valueOf(parentValue).trim().isEmpty()) { tree.add(node); } else { String parentKey String.valueOf(parentValue).trim(); MapString, Object parentNode nodeMap.get(parentKey); if (parentNode ! null) { SuppressWarnings(unchecked) ListMapString, Object children (ListMapString, Object) parentNode.get(childrenField); children.add(node); } else { // 父节点不存在兜底成顶层节点避免数据丢失 tree.add(node); } } } return tree; } }3.3 关键细节解读我实际写这段代码的时候有几个细节是反复打磨过的这里一个个说清楚。为什么用LinkedHashMap因为很多业务场景对Tree节点的顺序有要求。如果原始JSON数组里节点顺序是A、B、C构建完的Tree也希望保持这个顺序。LinkedHashMap能保证插入顺序和遍历顺序一致所以最后出来的Tree顺序和原始数组顺序保持一致。如果换成普通HashMap在数据量大时会发现子节点顺序乱掉尤其是排查问题时很头疼。为什么要预置空的children列表这一步容易被忽略但它很关键。如果不预先给每个节点put一个ArrayList那么在第二步挂子节点时就需要先get判断为不为空再add。多了几行代码不说如果某处逻辑忘了初始化直接add就会抛空指针。预置成一个空列表后所有节点结构统一后面做任何处理都省心。父节点不存在时的兜底逻辑这个也是我踩过坑之后加上的。有一次上游系统改了数据清理规则很多节点的parentId指向的父节点已经被删了。如果不做兜底这些“孤儿节点”会直接丢失前端菜单少了整整一大块。加了兜底后孤儿节点会作为顶层节点保留虽然层级可能不对但至少数据不丢。排查问题时把这些兜底节点单独筛出来很容易定位上游数据问题。3.4 调用方式与排序增强基础版跑通之后你很快会遇到排序需求。菜单要按sort字段排序地区要按code排序组织架构可能要按orderNo排序。我的做法是把排序逻辑做成一个可选的参数传入一个比较器在构建Tree之前先对数组排序再构建。因为LinkedHashMap保持插入顺序所以排完序再构建Tree天然有序。public static ListMapString, Object convertListToTreeWithSort( ListMapString, Object list, String idField, String parentField, String childrenField, ComparatorMapString, Object comparator) { if (comparator ! null) { list.sort(comparator); } return convertListToTree(list, idField, parentField, childrenField); }调用的时候如果要对sort字段升序排列就写ListMapString, Object tree TreeUtils.convertListToTreeWithSort( jsonList, id, parentId, children, Comparator.comparingInt(m - Integer.parseInt(String.valueOf(m.get(sort)))) );这个扩展方法不是很复杂但它能把排序逻辑从转换逻辑里剥离开来后续想换成别的排序规则改一行调用就完事。4. 前端场景JavaScript版照着改就完事很多项目是后端把原始JSON直接吐给前端前端需要自己转Tree。后端同学可能觉得这是前端的事但实际工作中前端同事经常跑过来问所以我把JS版也整理了一份逻辑一模一样。4.1 核心实现function jsonToTree(list, idField id, parentField parentId, childrenField children) { if (!Array.isArray(list) || list.length 0) { return []; } const nodeMap new Map(); // 第一次遍历建立 id - node 的映射 list.forEach(item { item[childrenField] []; nodeMap.set(String(item[idField]), item); }); const tree []; // 第二次遍历挂载到父节点 list.forEach(item { const parentId item[parentField]; if (parentId null || parentId undefined || String(parentId) 0 || String(parentId) ) { tree.push(item); } else { const parent nodeMap.get(String(parentId)); if (parent) { parent[childrenField].push(item); } else { tree.push(item); } } }); return tree; }这个版本和Java版的核心逻辑完全对应。用Map而不是普通对象是因为Map的key可以是任意类型且遍历顺序天然保持插入顺序和Java里的LinkedHashMap一个效果。4.2 在实际框架里用要注意的点如果你用的是Vue或者ReactTree组件往往还会要求节点有title、key、value这些固定字段。这时候有两种处理办法。第一种是转换时顺便做字段映射const treeData jsonToTree(originalList, id, parentId, children).map(node ({ title: node.name, key: node.id, children: node.children ? node.children.map(recursiveMap) : [] }));第二种是用组件自带的字段映射配置比如有的Tree组件允许配置replaceFields属性把title映射到name把key映射到id这样后端返回什么字段名都不用改。我用两种方式都做过个人建议优先用组件的字段映射配置不要改后端数据。因为数据层的结构和UI层的结构混在一起后面UI组件一换前端代码要重写一遍。保持原始数据格式不动只做展示层的适配维护成本最低。5. 实际项目中的坑与排查心得5.1 数据问题pid悬空、循环引用、重复节点pid悬空是最常见的问题。父节点ID在数据里根本不存在转换时如果不做兜底节点直接消失。我在代码里已经做了兜底但更推荐的做法是在数据接入层做校验用SQL或者脚本把悬空节点统计出来反馈给上游修正。让转换工具容忍脏数据是为了“不崩”但根治还是要靠数据质量。循环引用是个隐蔽的坑。比如A的parentId是BB的parentId又是A这在数据不规范的时候会出现。这种数据一旦构建Tree递归遍历时就会死循环前端渲染直接卡死。我的排查经验是在构建前做一次循环引用检测。方法很简单从任意节点出发沿着parent链网上找如果在一次查找中再次遇到自己说明有环。数据量少时可以直接遍历所有节点做成一个独立校验方法。重复节点也碰到过。同一家公司两个系统的数据合并时两边都有id为100的节点合并后Tree里出现两个一模一样的分支。这种问题靠代码不好兜只能靠id生成规则统一或者数据清洗。5.2 字段问题大小写、命名风格、null值JSON字段的id和parentId在不同系统里经常变ID、Id、parent_id、parentid、pId……所以工具类一定不要写死字段名我上面代码里已经把它设计成参数了。null值这块我特别想说。用Jackson解析JSON时如果某个节点的parentId是nullMap.get返回的也是null我在代码里已经处理了。但如果字段名对不上比如写的是parent_id代码里传的是parentIdget也会返回null节点会被当成顶层节点。这种情况最容易造成“明明按代码逻辑没错为什么Tree结构不对”的困惑。我的建议是写转换工具时加一行调试日志把字段对不上的节点数量和顶层节点数量打出来。正常数据顶层节点一般很少通常1到2个如果你发现顶层节点一大片基本就是字段名配置错了。5.3 性能问题大数据量下怎么办Map方案在数据量几万条时依然很稳我实测过5万条数据耗时在100毫秒左右和机器性能有关但在某些极端情况下还是要注意一是不要在主线程/请求线程里做超大JSON字符串解析。建议在数据接入时做定时增量解析把转换结果缓存起来查询时直接返回缓存。二是转换结果要控制层级深度。如果原始数据里层级特别深比如超过50层某些前端组件渲染会有问题。这种情况下可以在转换时加一个最大深度限制超过深度的子树剪掉或者折叠。三是如果原始JSON是从文件读取的比如JMeter提取json结果生成文件后再手动处理要注意文件编码。我就遇到过UTF-8编码的JSON文件被用GBK读取中文乱码导致字段值对不上Tree结构完全错乱。6. 扩展一个通用转换器覆盖更多真实场景6.1 让字段名彻底不写死前面Java版代码已经支持传入字段名但它只处理了id、parentId这类基本字段。有些业务场景节点的数据里还有leaf标记、disabled状态、icon字段、path路径等等这些不需要转换器管只要在转换前或转换后对Map做一层字段处理就行。我通常的做法是加一个“字段渲染”的钩子在转换完成后遍历Tree统一处理public interface TreeNodeDecorator { void decorate(MapString, Object node, int level, String parentPath); }比如生成面包屑路径或者把某些开关字段转成布尔值都可以在这个decorate里做。转换器只管结构不管业务字段的加工职责边界清楚了代码才不容易腐化。6.2 顶层节点不只一个怎么办很多人第一次写这个工具时会默认“只有一个根节点”但真实数据里顶层节点可能有多个比如公司有几个独立的一级部门或者菜单有几个顶级模块。我的代码从设计上就支持多根节点它会返回一个List而不是一个Node。前端Tree组件基本都支持多根节点数组所以这个设计不用刻意改。如果你的场景确实只需要一棵树就在外层包一个虚拟根节点MapString, Object fakeRoot new LinkedHashMap(); fakeRoot.put(id, root); fakeRoot.put(name, 全部); fakeRoot.put(children, treeList);6.3 树后处理剪枝、统计路径、查找祖先转换完Tree之后常见的后处理还有几种剪枝去掉空目录。比如地区数据里某个节点下没有子节点但业务要求空目录不展示。这时候递归遍历Tree把children为空的节点从父节点的children里移除即可。注意要自底向上删否则删掉子节点后父节点可能变成空目录又被删掉。统计路径给每个节点生成从根到当前节点的完整路径比如A/B/C用于面包屑导航。递归时传一个路径前缀每层追加当前节点的name。查找祖先链已知某个节点id找出它在Tree里的完整父链。这个用递归遍历实现在Java里注意不要用深递归数据量大的时候可以用栈来迭代实现。这些后处理逻辑尽量别塞进转换器里单独写成一个TreeWalker类专门处理这类问题。6.4 和“源JSON”场景的结合搜索热词里有大量类似“书源json”、“tvbox接口json”、“zyplayer视频源json”这类需求。很多小白用户拿到的是一段现成的JSON配置想把它整理成一个层级清晰的树方便在界面里勾选分类。这种场景其实也可以套用上面的方案先把JSON解析成List再用parentId字段构建树。但注意这类JSON往往是嵌套结构不是扁平结构也就是说原始数据可能已经是Tree了不需要转换。真正要做的是把嵌套JSON“拍平”成扁平列表再重新构建树方便做筛选和勾选。“拍平”的代码反而更简单public static void flattenTree(MapString, Object node, ListMapString, Object flatList) { flatList.add(node); Object childrenObj node.get(children); if (childrenObj instanceof List) { for (Object child : (ListObject) childrenObj) { if (child instanceof Map) { SuppressWarnings(unchecked) MapString, Object childMap (MapString, Object) child; flattenTree(childMap, flatList); } } } }拍平之后再做排序、筛选、打标签都比直接在嵌套结构里操作方便。这也是为什么很多工具类需要同时提供“转Tree”和“拍平”两个方向的方法双向转换在实际项目里成对出现。7. 踩过几次坑之后说说我的选择回到最开头的问题。JSON转Tree不是一个多难的算法题但它在工程里的坑比我预想的多得多。如果你是第一次写建议直接采用Map映射法不要在递归查找上浪费时间。字段名一定做成参数不要写死。数据里的脏情况悬空父节点、重复ID、循环引用一定要有兜底处理否则生产环境出问题的时候排查成本远高于写代码的成本。我在实际项目里这个工具类经过几次迭代后基本稳定最新的版本已经能同时处理Java和前端两个场景的转换需求。新同事接手时会觉得这有什么好写的直到他们在生产环境遇到几万条数据卡死、节点消失、顺序错乱的坑才会明白一个普通的小工具要处理好边界情况并不简单。如果你手头也在做一个类似的转换需求建议把第3节的代码先跑起来再对照第5节把异常情况测试一遍。数据是规整的可能一次就过数据是乱的这个工具就是你排查问题的第一道防线。