ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JSON转Tree实战:扁平数据高效构建树形结构的完整方案

JSON转Tree实战:扁平数据高效构建树形结构的完整方案 最近一个后台管理系统里的菜单权限接口又让我跟「Json转Tree」这件事死磕了一整天。后端返回的是一张扁平的菜单列表每个节点挂着一个parentId可前端el-tree和zTree要的是嵌套的children结构不转换根本没法用。更麻烦的是另一个对接第三方平台的需求对方直接把一个深度五六层的配置Json甩过来前端得自己把它拆成可折叠的树形面板。这两件事本质上都在做同一件事把Json数据转换成Tree格式。这个需求听起来人畜无害——“不就是递归一下吗”可真动手的时候类型判断、空值处理、循环引用、大数据量性能哪一个都能让你调试到怀疑人生。这篇文章不打算只丢一段递归代码给你而是把我在实际项目里的完整思路、两套核心实现、踩坑记录和优化方案都摊开讲。如果你最近也在被Json和Tree的互相转换折磨或者正准备在项目里做目录树、权限树、组织架构树我建议你花十分钟把这篇看完。1. 为什么Json和Tree之间总隔着一层转换1.1 接口返回的往往是「扁平世界」前端需要的是「层级宇宙」大多数业务系统在数据库设计时树形数据都是通过一个parent_id字段表达的。拿菜单表举例字段无非是id、parent_id、name、sort_order这几列每一行只知道自己老爹是谁。这种设计的好处是增删改查都简单SQL里不用写递归一个SELECT * FROM menu就完事。但问题也随之而来——后端返回给前端的Json就长成了这样[ {id: 1, parent_id: null, name: 系统管理}, {id: 2, parent_id: 1, name: 用户管理}, {id: 3, parent_id: 1, name: 角色管理}, {id: 4, parent_id: 2, name: 新增用户} ]而前端主流的树组件Element UI的el-tree、Ant Design的Tree、zTree、JSTree清一色只认嵌套结构{ id: 1, parent_id: null, name: 系统管理, children: [ { id: 2, parent_id: 1, name: 用户管理, children: [ {id: 4, parent_id: 2, name: 新增用户} ] }, {id: 3, parent_id: 1, name: 角色管理} ] }这就是最典型、最高频的Json转Tree场景。后端改接口不现实前端写一堆循环又不优雅所以必须有一个稳定可靠的转换层把这摊扁平数据“立起来”。1.2 嵌套Json也需要变成树配置面板和通用解析器另一种场景很多人容易忽略接口返回的Json本身是嵌套的但它不是一棵“业务树”而是一个普通对象里面有对象、有数组、有标量。比如下面这种动态表单的配置{ formName: 用户注册, fields: { username: {type: text, required: true}, contact: { email: {type: email}, phone: {type: tel, required: false} } } }如果你要做一个通用的Json可视化编辑器、字段映射工具、配置差异对比面板就必须把这个对象转成{key, value, children}风格的树节点让每一项都能在界面上折叠和展开。这跟parentId那种扁平转树又不一样它需要“无中生有”地把层级构造出来属于另一种思路。1.3 数据交换和代码生成领域的隐藏需求还有一类场景容易被新人忽略代码生成器、API调试工具、自动化测试平台里经常需要把Json转成Tree结构来生成目录树或者把Tree结构再转回Json作为初始化数据。比如你在做一个低代码平台用户拖一个树形组件绑定一个Json配置这个配置本质上就是把Json按照属性层级转换成树节点后再做可视化用户改完树节点又得把它们转回Json提交给服务端。这块的诉求是“双向转换”即Json转Tree和Tree转Json都要做。如果一开始就把转换逻辑封装成通用工具后面会省很多事。2. 设计目标你希望生成的Tree结构是什么形态2.1 两种常见的Tree目标结构先别急着写代码。Json转Tree之前有一个特别关键的问题你要的Tree到底是哪种Tree第一种是业务树保留原字段只新增children数组。拿菜单来说节点还是{id, parent_id, name}只是塞了一个children子数组进去。这种结构适合直接喂给树组件前端代码基本零改动。第二种是通用树节点统一抽象成{key, value, children}不关心原始Json的字段名只关心层级和值。这种结构适合做通用的配置编辑器因为它的节点形态固定渲染逻辑可以一套通吃。下面这个表格把两种结构的差异列一下方便你对照自己的需求维度业务树通用树节点字段保留原字段追加children统一为key、value、children适用场景菜单树、部门树、权限树Json可视化、配置面板、字段映射转换难度依赖id/parentId或嵌套关系依赖类型递归判断反向转换比较容易children再拼回去需要额外保存类型信息2.2 字段命名和节点结构提前定好约定不管你选哪种Tree字段命名最好在项目一开始就定下来不然后面到处是item.childs还是item.children的争吵。我的习惯是统一用children即便只有一个子节点也用数组而不是用对象。为什么因为前端树组件的遍历逻辑基本都是node.children.map(...)如果children时有时无、时数组时对象那组件里就得写一坨防御代码。宁可空数组挂着也不要缺字段。那叶子节点要不要保留空的children: []这个可以争论一下。我的建议是转出来的树里叶子节点不挂children字段或者直接置为空数组取决于你后续怎么用。如果前端组件会自动判断children.length来渲染展开箭头那空数组没问题如果组件只看有没有children字段来决定“有子节点”那空数组会显示一个无效的展开符号。实测下来Element UI和Ant Design都是判断children的length或isLeaf属性所以空数组问题不大。2.3 保留原始数据还是只留展示字段还有一点要提前想清楚转换后的节点里到底保留全部原始字段还是只留树组件需要的字段很多人一上来就把整个item展开塞进去结果树组件不认识的字段倒无所谓但遇到字段名冲突就麻烦了比如原始数据里也有个children字符串那转出来就直接被覆盖。我通常的做法是做一个字段白名单只保留id、parentId、name这类必要字段其他的业务字段放在一个data属性里收拢。比如{ id: 1, name: 系统管理, children: [], data: {path: /system, icon: setting, sort: 1} }这样树节点干净渲染效率高业务信息也没丢。前端拿数据时node.data.path就能取到原始业务字段不冲突、好维护。3. 两种核心转换路径嵌套解析法和parentId映射法3.1 嵌套解析法针对本身就是层级结构的Json这种方法的适用对象是那种本身已经嵌套好的Json比如第一节里的动态表单配置。核心思想是递归遍历Json的每一项遇到对象就继续深入遇到数组就逐项处理遇到基础类型字符串、数字、布尔就作为叶子节点。它的难点在于类型判断。JavaScript里typeof null object这个坑Python里dict和list要分开处理稍不留神就会把空值当成可展开对象生成一个永远打不开的子树。先说思路代码我放到下一节。你只要理解这个处理的脉络就是每个Json值都变成一个树节点它的孩子由值内部的子项决定。对象里每个键是一个孩子节点数组里每个元素是一个孩子节点基础类型值没有孩子。3.2 parentId映射法针对扁平的父子关系列表这个方法是把[{id, parentId, name}...]这样的列表重组成树。最容易想到的写法是双层循环遍历每个节点再从头找它的父亲找到就push到父亲的children里。但双层循环时间复杂度是O(n²)数据量上来就完蛋。所以生产环境我基本不用纯双层循环而是先建一个Map把id到节点对象的映射存下来然后二次遍历的时候每个节点父节点的查找都是O(1)整体变成O(n)。这里还有一个细节JS里对象是引用传递的。你在Map里存了{...item, children: []}后面往parent.children里push子节点Map里对应父节点的children也会同步变化。很多新手不理解这个以为要搞什么深度拷贝其实完全不需要我们要的就是同一批对象引用。3.3 两种思路的适用范围对比维度嵌套解析法parentId映射法输入形态嵌套对象/数组扁平列表核心操作递归 类型判断建Map 二次遍历时间复杂度O(n)O(n)典型场景Json配置可视化菜单、部门、组织树反向转换难度需要记录节点类型低实际项目里这两种方法经常会同时用。比如我给你一个接口它返回的根节点是嵌套结构但某个节点下面挂的又是扁平列表那你递归到那层时就要从嵌套解析切到parentId映射。这种混合情况不需要怕把两个函数拆开在递归里调用对方就行。4. 可以直接拿去用的完整实现附边界处理4.1 JavaScript扁平列表转业务树下面这段代码是我目前项目里实际在用的精简版本/** * 将扁平列表转换为树形结构 * param {Array} items 扁平列表 * param {Object} options 配置项 * param {string} options.idKey 主键字段名默认 id * param {string} options.parentKey 父级字段名默认 parentId * param {*} options.rootValue 根节点的父级值默认 null * returns {Array} 树形数组 */ function listToTree(items, options {}) { const { idKey id, parentKey parentId, rootValue null } options; const map new Map(); const roots []; // 第一遍初始化所有节点并克隆一份避免修改原始数据 items.forEach(item { const node { ...item, children: [] }; map.set(node[idKey], node); }); // 第二遍把节点挂到父节点下 items.forEach(item { const node map.get(item[idKey]); const parentId item[parentKey]; // 注意这里要用 map.has而不是直接判断 parentId 是否为 null if (parentId ! rootValue map.has(parentId)) { map.get(parentId).children.push(node); } else { roots.push(node); } }); return roots; }注意几个边界处理用Map而不是普通对象是因为Map支持任意类型的key包括数字、字符串、甚至undefined。普通对象的key会被强制转成字符串万一id是数字1查找时写map[1]没问题但遇到Symbol类型就尴尬了。我在第一遍遍历时用了{...item}浅拷贝这是为了不污染原始数据。如果你后续还要拿原始列表做其他操作这个拷贝能救你一命。当然浅拷贝只复制一层嵌套对象还是共享引用但对大部分业务场景已经够了。判断根节点时不要用if (!parentId)。因为有些业务的父节点id可能是0而0是假值会被误判成根节点。正确做法是parentId ! rootValue并把rootValue默认设为null。4.2 JavaScript嵌套Json转通用树然后是嵌套Json转通用树节点的实现function jsonToTree(data, key root) { const node { key, children: [] }; if (data null || data undefined) { node.value data; return node; } if (Array.isArray(data)) { // 数组每个元素作为一个子节点key 用 [index] 形式 data.forEach((item, index) { node.children.push(jsonToTree(item, [${index}])); }); return node; } if (typeof data object) { // 对象每个键作为一个子节点 Object.entries(data).forEach(([childKey, value]) { node.children.push(jsonToTree(value, childKey)); }); return node; } // 基础类型作为叶子节点的值 node.value data; return node; }这里顺序很关键先判断null再判断Array再判断object最后才是基础类型。因为typeof null object而且数组也是对象顺序写反了就会出现null被当成对象展开、数组被当成对象遍历出0、1、2这种下标key的诡异情况。数组元素的key我用[0]、[1]这种形式而不是直接塞数字原因是为了直观区分对象属性和数组下标。你渲染到界面上用户看到contact下面有[0]、[1]能立刻明白这是数组而不是某个奇怪的对象字段名。4.3 Python同样的逻辑来一份后端的小伙伴也需要这个工具。Python版本的扁平列表转树如下def list_to_tree(items, id_keyid, parent_keyparent_id, root_valueNone): 将扁平列表转为树形结构 # 第一步建立 id - node 的映射 node_map {} for item in items: node {**item, children: []} node_map[item[id_key]] node roots [] for item in items: node node_map[item[id_key]] parent_id item.get(parent_key) if parent_id ! root_value and parent_id in node_map: node_map[parent_id][children].append(node) else: roots.append(node) return rootsPython版和JS版逻辑一模一样就是字典的in判断替代了Map.has。注意{**item, children: []}在Python 3.5才能用老版本用dict(item)加手动赋值。嵌套Json转通用树的Python版def json_to_tree(data, keyroot): node {key: key, children: []} if data is None: node[value] None return node if isinstance(data, list): for index, item in enumerate(data): node[children].append(json_to_tree(item, f[{index}])) elif isinstance(data, dict): for child_key, value in data.items(): node[children].append(json_to_tree(value, child_key)) else: node[value] data return node建议把它封装到一个tree_utils.py文件里然后加个单元测试。不然每次调用都复制粘贴这段代码改了一个项目忘了改另一个项目最后线上线下行为不一致非常酸爽。5. 我在生产环境里踩过的Json转Tree的坑5.1 循环引用导致栈溢出有一次我给前端生成菜单树列表里有一条数据的parentId竟然指向了自己也就是id和parentId相等。我的递归函数一看——父节点是自己好那我把自己挂在自己下面然后继续找自己再挂一遍无限递归直接RangeError: Maximum call stack size exceeded。排查了很久最后发现是测试同事手工改数据库时搞出来的脏数据。但这提醒了我写转换函数时一定要设防。最简单的方式是在递归的时候维护一个visited集合碰到重复节点就断开function buildTreeWithCycleCheck(items, parentId null, visited new Set()) { if (visited.has(parentId)) return []; visited.add(parentId); // ...递归逻辑 }或者在业务层面做数据校验转树之前先检查有没有自引用、环引用发现就直接抛错。我个人倾向后一种因为静默断链会导致菜单少一项用户根本看不出来直接抛错开发环境能立刻发现脏数据。5.2 id类型不一致导致匹配失败这个坑特别隐蔽。数据库里id是bigint类型到了某些JSON序列化框架里就变成了字符串。而parent_id字段因为历史原因在另一张表里还是数字类型。于是前端拿到手的列表长这样[ {id: 1, parent_id: 0}, {id: 2, parent_id: 1} ]你拿item.parent_id去Map里找键为1的节点结果Map的key是字符串1虽然map.has(1)和map.has(1)在现代JS的Map里是不同的键Map是严格相等但如果你用的是普通对象{}来当映射表那obj[1]和obj[1]会命中同一个键看起来正常换到Map后反而对不上了因为1 ! 1。这真的是血泪教训。解决办法是在转换前做一次统一function normalizeId(value) { return String(value); } // 然后所有 id 和 parentId 都走一遍 normalizeId具体用字符串还是数字看你的业务但必须全局统一。不然就会遇到“明明父节点id是1子节点的parentId是1结果find不到爸爸”这种幽灵Bug。5.3 把空值当成了父节点parentId字段是空字符串、0、还是null不同后端返回得完全不一样。我遇到过一种情况根节点的parentId是非根节点是正常数字。如果代码里写if (item.parentId)空字符串直接当成根节点处理没毛病但如果有节点parentId是0字符串那就不是假值会被误认为有父节点然后去Map里找一个不存在的0于是这个节点既没被挂到正确位置也没被当根节点直接丢了。稳妥的处理方案是转换函数接收一个rootValues数组明确告诉它哪些值代表根节点const ROOT_VALUES [null, , 0, 0]; // 判断时ROOT_VALUES.includes(parentId) ? 根节点 : 非根节点这样无论后端抽什么风你都能兜住。5.4 反向转换时丢了顺序或类型好多人Json转Tree做完了后面又要Tree转回Json结果发现字段顺序变了、数字全成了字符串。这个问题在JSON里特别常见因为Json对象本身不保证顺序虽然现代JS引擎的Object保留了字符串键的插入顺序但数字键会排在前面。如果你要的是“转过去再转回来结果完全一致”那建议转换时额外保存一个type字段在节点上{ key: username, value: 张三, type: string, children: [] }反向转换时根据type恢复原始类型数字用Number(value)布尔值用value truenull直接置空。不然一进一出12345变成12345前端展示倒是没差但接口校验直接给你报类型错误。6. 当数据量上来之后性能优化与懒加载6.1 Map是提升性能的关键前面已经提过扁平列表转Tree如果写成双层循环1万条数据就要做1亿次查找浏览器直接卡死。用Map把查找降到O(1)1万条也就是2万次操作几乎无感。这是一个性价比极高的优化。但如果你的列表有10万条就算O(n)也会有内存压力。这时候可以考虑流式处理分批转。比如一次拿5000条数据转成一批子树然后插入到总的树里。不过这个场景比较少见真的超过10万条的树前端也不建议一次全渲染出来虚拟滚动才是正解。6.2 限制树的深度和广度有些接口返回的Json嵌套特别深比如一个AOP切面日志对象转成树之后能有20层前端折叠起来看着都眼晕。我在实际项目里给转换函数加了一个maxDepth参数超过这个深度就强制截断function jsonToTree(data, key root, maxDepth 10, currentDepth 1) { if (currentDepth maxDepth) { return { key, value: data, children: [], truncated: true }; } // ...原有递归逻辑递归时 currentDepth 1 }深度限制之后多出来的部分用一个truncated: true标记。界面上看到这个标记提示“内容过深已截断”比直接渲染出一个深到没法看的树要好得多。6.3 字段瘦身转换前先剔除不相干字段还是那句话树组件渲染只关心少数字段其他字段塞多了既占内存又拖慢比较逻辑。我在转换之前会先做一个字段筛选器function pickFields(item, fields) { return fields.reduce((acc, field) { if (item[field] ! undefined) acc[field] item[field]; return acc; }, {}); }比如菜单树只用id、parentId、name、path那就先pickFields再转树。数据量从几十个字段缩到4个字段内存占用直接降一个量级。7. 转换结果的验证与调试技巧7.1 千万别用console.log肉眼检查大树数据量小的话console.log(JSON.stringify(tree, null, 2))还能凑合看。但树一旦超过三五十个节点你根本看不出来哪个节点挂错了地方。我的做法是写一个验证函数扫描整棵树检查三条规则没有孤儿节点所有节点都能从根出发被访问到没有循环引用遍历过程中不会重复走到同一个节点节点数量和原始列表一致如果转树后节点总数对不上说明有节点丢了。function validateTree(tree) { let count 0; const visited new Set(); function walk(nodes, path root) { for (const node of nodes) { if (!node || typeof node ! object) continue; if (visited.has(node)) { throw new Error(循环引用检测到路径: ${path}); } visited.add(node); count; walk(node.children || [], ${path} ${node.name || node.key}); } } walk(tree); return count; }万一报错错误信息里的路径会直接告诉你问题出在哪条链上调试效率高很多。7.2 写一组固定用例做回归测试Json转Tree这种纯函数非常适合做单元测试。我的习惯是把几个典型case固化成测试数据空数组、只有根节点、多层嵌套、带空parentId、带循环引用、包含0值id。比如Jest里就写test(空数组返回空树, () { expect(listToTree([])).toEqual([]); }); test(根节点为null的情况, () { expect(listToTree([ { id: 1, parent_id: null, name: A }, { id: 2, parent_id: 1, name: B } ])).toEqual([ { id: 1, parent_id: null, name: A, children: [ { id: 2, parent_id: 1, name: B, children: [] } ]} ]); });每次改完转换逻辑跑一遍用例就知道有没有改坏别的场景。我在这上面栽过跟头所以现在特别强调这个。7.3 用可视化工具快速确认树结构前端调试还有一个土办法把转换后的树JSON.stringify一下贴到在线的Json查看器里看缩进和展开。或者更直接点临时用el-tree渲染出来看一眼。写代码的时候眼睛平行的盯着对象嵌套结构很容易腻但是渲染成树形界面扫一眼就清楚。而且el-tree有default-expand-all属性直接把全树展开哪个节点挂错了一目了然。如果你做的是通用Json转Tree建议顺手写一个简单的html页面用两列布局左边是原始Json输入框右边是树渲染结果。调试的时候把接口数据往里一贴出来的结构立刻看明白。这个页面我留在公司的内部工具站里了后来好几个同事都不停地用。回到最开始那个菜单树的问题后来我不仅把菜单转成了树还在树的每个节点上加了一层权限标记前端渲染的时候可以直接根据节点上的数据判断是否显示某个按钮。同一个转换函数既服务了菜单目录又服务了权限校验算是把这个Json转Tree的价值吃透了。我个人实际操作的体会是Json转Tree不难难的是你永远不知道上游数据会在哪个细节上出幺蛾子。所以转换函数的健壮性比花哨的技巧重要得多宁可多写几个边界判断也别信“接口肯定返回规范数据”这种鬼话。最后再分享一个小技巧——转换函数里别直接用JSON.parse(JSON.stringify())做深拷贝处理遇到undefined、NaN、Date这些类型会丢数据手写浅拷贝加递归就够用了真需要深拷贝上lodash.cloneDeep也别自己造轮子。
返回列表