layui树形组件源数据递归添加:扁平数组转嵌套树完整指南

发布时间:2026/10/14 5:07:23
layui树形组件源数据递归添加:扁平数组转嵌套树完整指南 1. 从一个真实需求说起为什么树形组件的源数据总让人头疼做过后台管理系统的人大概率都碰过这样的场景权限菜单要展示成树、商品分类要展示成树、组织架构要展示成树而前端用的又是 layui 这套经典的后台 UI 框架。layui 的tree组件本身并不难用tree.render()一调配上一个data数组就能出效果。真正让人抓耳挠腮的往往不是渲染那一步而是渲染之前的那一步——源数据到底怎么拼。后端从数据库里查出来的通常是一张扁平的列表。每条记录里有自己的id还有一个parentId指向父节点。这种结构在数据库里存着很舒服增删改查都方便但 layui 的树形组件要的是嵌套结构每个节点下面挂一个children数组子节点再挂children一层套一层。于是问题就来了——怎么把一张扁平表变成一棵嵌套树最直接的想法是写循环。先找一级节点再遍历所有数据找它们的子节点再对每个子节点重复这个过程。两层循环能搞定三层也能勉强写但一旦树的层级不确定、可能有三层也可能有七八层循环就写不下去了。这时候就得请出今天的主角递归。这篇内容就是围绕“layui 树形组件源数据 —— 递归添加”这个主题展开的。我会把整个思路从零讲清楚为什么扁平转嵌套要用递归、递归函数到底怎么写、layui 的tree组件对数据格式有哪些硬性要求、实际项目里怎么把后端返回的数据一步步加工成能直接喂给组件的结构。不管你是刚接触 layui 的新手还是写过几年代码但一遇到递归就发怵的老手看完都能直接抄作业。2. 先搞清楚 layui 树形组件到底要什么数据2.1 layui tree 组件的数据结构要求在动手写递归之前必须先明确目标格式。layui 的tree组件以 layui 2.x 为例对数据的要求其实很朴素核心就几个字段字段名类型是否必需说明id字符串/数字建议有节点唯一标识用于选中、展开等操作title字符串必需节点显示的文字children数组可选子节点集合没有子节点时可不写或写空数组spread布尔可选是否默认展开该节点checked布尔可选是否默认勾选配合showCheckboxdisabled布尔可选是否禁用该节点关键点在于children。只要一个节点有children数组并且数组里装着同样结构的对象树就能一层层渲染下去。所以我们的任务本质上是把扁平数组里的每一条记录按照parentId关系塞进对应父节点的children里。这里有个容易被忽略的细节layui 的tree组件在渲染时如果某个节点的children是空数组[]它会被当成“有子节点但为空”有些版本下会显示一个可展开但点开没内容的箭头。所以更稳妥的做法是——没有子节点时干脆不要这个children字段或者渲染前过滤掉空数组。这个坑后面会专门讲。2.2 后端返回的扁平数据长什么样假设后端接口返回的是这样一组数据典型的菜单表结构const flatData [ { id: 1, parentId: 0, name: 系统管理 }, { id: 2, parentId: 0, name: 内容管理 }, { id: 3, parentId: 1, name: 用户管理 }, { id: 4, parentId: 1, name: 角色管理 }, { id: 5, parentId: 3, name: 新增用户 }, { id: 6, parentId: 3, name: 编辑用户 }, { id: 7, parentId: 2, name: 文章列表 }, { id: 8, parentId: 2, name: 评论管理 }, ];parentId为0的是一级节点parentId为1的挂在“系统管理”下面parentId为3的又挂在“用户管理”下面。层级关系完全靠parentId维系数据本身是平的。而 layui 要的是这样[ { id: 1, title: 系统管理, children: [ { id: 3, title: 用户管理, children: [ { id: 5, title: 新增用户 }, { id: 6, title: 编辑用户 } ] }, { id: 4, title: 角色管理 } ] }, { id: 2, title: 内容管理, children: [ { id: 7, title: 文章列表 }, { id: 8, title: 评论管理 } ] } ]注意字段名也变了后端叫namelayui 要title。这个映射关系在转换时顺手做掉就行不用单独再遍历一遍。2.3 为什么不能用简单的双层循环有人可能会想我先把所有一级节点挑出来然后遍历一级节点再遍历全部数据找它的子节点找到就 push 进去。两层循环看起来能跑。问题出在第三层。当你要给“用户管理”找子节点时你确实能在扁平数组里找到parentId 3的记录。但这些记录本身还可能有子节点比如“新增用户”下面如果再挂一层“批量新增”你的两层循环就够不着了。你不可能预先知道树有几层写死三层循环、四层循环都不现实。这就是递归登场的时刻。递归的本质是用同一个函数处理每一层函数自己调用自己直到没有子节点为止。树的深度是几层它就自动往下钻几层代码量却始终只有那么一小段。3. 递归构建树的核心思路拆解3.1 递归的两个必要条件写递归之前脑子里要清楚递归能成立的两个前提第一有明确的终止条件。对树形数据来说终止条件就是“当前节点没有子节点了”。当遍历完所有数据都找不到parentId等于当前节点id的记录时这个分支就到底了函数返回不再往下调用。第二每次递归都在向终止条件靠近。我们是从根节点往下找子节点每递归一层处理的节点就更深一层数据范围在缩小最终一定会到达叶子节点。只要数据本身没有形成环比如 A 的父是 BB 的父又是 A递归就一定能停下来。提示实际项目里一定要防环。如果数据库里因为脏数据出现了互相指向的记录递归会无限循环直到栈溢出。后面排查章节会讲怎么检测。3.2 两种主流实现方案对比把扁平数据转成树业界常见两种写法各有适用场景方案一递归查找法推荐新手思路是先找出所有根节点parentId为 0 或某个约定值然后写一个函数接收一个父节点在扁平数组里筛出所有parentId 父节点.id的记录把这些记录加工后挂到父节点的children上然后对每个子节点再调用这个函数。优点是逻辑直观跟人的思维一致“找儿子、再找儿子的儿子”。缺点是每次递归都要遍历一遍完整数组数据量大时性能一般时间复杂度接近 O(n²)。方案二哈希映射法推荐数据量大时用思路是先遍历一次扁平数组用一个对象或 Map把每个id对应的节点存起来同时把每个节点按parentId分组。然后再遍历一次把每个节点挂到它父节点的children上。整个过程只遍历两遍数组时间复杂度 O(n)。优点是快缺点是代码稍微绕一点需要额外维护映射关系。对于后台管理系统的菜单、权限这类数据通常也就几十到几百条方案一完全够用代码可读性还更好。但如果你的树可能上千个节点比如商品分类、地区数据那就果断上方案二。下面两种我都会给出完整代码。3.3 字段映射要顺手做掉后端字段和 layui 字段往往对不上。常见的有name→title、menuName→title、sort→ 排序依据等。我的习惯是在递归构建节点的同时就把字段映射做掉而不是先转结构再单独遍历改字段名。这样只遍历一次代码也更紧凑。具体做法是写一个buildNode小函数接收一条原始记录返回一个符合 layui 规范的节点对象function buildNode(item) { return { id: item.id, title: item.name, // 字段映射 parentId: item.parentId, // 保留父级递归时要用 // 其他需要透传的字段 icon: item.icon || , }; }注意parentId我特意保留了因为递归查找时还要靠它。等树构建完成、准备渲染前可以再决定要不要删掉这些辅助字段——layui 对多余字段是宽容的不删也不影响渲染但传输和调试时留着更清楚。4. 递归添加的完整实操代码4.1 方案一递归查找法完整实现先上最直观的版本适合绝大多数后台场景/** * 扁平数组转 layui 树形结构递归查找法 * param {Array} list 后端返回的扁平数据 * param {Number|String} rootId 根节点的 parentId 值通常是 0 * returns {Array} layui tree 可直接使用的数据 */ function listToTree(list, rootId 0) { // 先做一次字段映射避免每次递归都重复处理 const nodes list.map(item ({ id: item.id, title: item.name, parentId: item.parentId, icon: item.icon || , })); // 递归函数找出 parentId 等于当前节点 id 的所有子节点 function findChildren(parentNode) { const children nodes.filter(n n.parentId parentNode.id); if (children.length 0) { // 终止条件没有子节点直接返回不添加 children 字段 return; } parentNode.children children; // 对每个子节点继续递归 children.forEach(child findChildren(child)); } // 找出所有根节点 const roots nodes.filter(n n.parentId rootId); // 对每个根节点递归挂载子节点 roots.forEach(root findChildren(root)); return roots; }调用方式const treeData listToTree(flatData, 0); tree.render({ elem: #menuTree, data: treeData, showCheckbox: true, });这段代码有几个细节值得说。第一我先用map做了一次字段映射生成nodes数组这样递归里就不用反复处理name到title的转换了。第二findChildren里判断children.length 0时直接return没有给节点添加空的children字段这就避开了前面提到的空数组箭头问题。第三根节点的筛选放在最后逻辑上更清晰。4.2 方案二哈希映射法完整实现数据量大时用这个版本两遍遍历搞定/** * 扁平数组转 layui 树形结构哈希映射法 * param {Array} list 扁平数据 * param {Number|String} rootId 根节点 parentId */ function listToTreeFast(list, rootId 0) { const nodeMap {}; // id - 节点对象 const childrenMap {}; // parentId - 子节点数组 // 第一遍建立映射 list.forEach(item { const node { id: item.id, title: item.name, parentId: item.parentId, icon: item.icon || , }; nodeMap[item.id] node; const pid item.parentId; if (!childrenMap[pid]) { childrenMap[pid] []; } childrenMap[pid].push(node); }); // 第二遍把子节点挂到父节点上 Object.keys(childrenMap).forEach(pid { const parent nodeMap[pid]; if (parent) { parent.children childrenMap[pid]; } }); // 返回根节点集合 return childrenMap[rootId] || []; }这个版本的精髓在于childrenMap它以parentId为键把所有子节点预先分好组。第二遍遍历时直接通过nodeMap[pid]拿到父节点把整组子节点一次性挂上去。整个过程没有任何嵌套遍历节点再多也是线性的。注意哈希映射法里如果某个节点的parentId指向了一个不存在的 id脏数据那这个节点会出现在childrenMap里但永远挂不到任何父节点上最终不会出现在结果树里。这其实是一种“静默丢弃”排查问题时要注意。方案一同样有这个问题因为找不到父节点的记录不会被任何findChildren捞到。4.3 两种方案怎么选一张表说清楚对比维度递归查找法哈希映射法代码可读性高贴近直觉中需要理解映射时间复杂度O(n²) 左右O(n)适用数据量几百条以内上千条以上调试难度低断点清晰中需看两个 map字段映射位置递归前统一处理第一遍遍历时处理推荐场景菜单、权限、组织架构商品分类、地区、大数据树我的实际经验是后台管理系统的菜单树用方案一就够了代码短、好维护同事接手一眼能看懂。但有一次做商品类目数据有三千多条方案一跑起来明显卡顿换成方案二后瞬间流畅。所以选型看数据规模别一上来就追求“最优解”。5. 递归添加中的常见坑与排查技巧5.1 空 children 导致的幽灵箭头这是 layui tree 最经典的坑。如果你的节点带了children: []某些版本的 layui 会渲染出一个可点击的展开箭头点开却是空的用户体验很差。根源就在于递归时给叶子节点也加了空数组。解决办法有两个一是在递归里判断子节点数量为 0 时干脆不加children字段方案一已经这么做了二是渲染前统一清洗一遍function cleanEmptyChildren(nodes) { nodes.forEach(node { if (Array.isArray(node.children)) { if (node.children.length 0) { delete node.children; } else { cleanEmptyChildren(node.children); } } }); return nodes; }这个清洗函数本身也是递归的正好又练了一次手。5.2 数据成环导致栈溢出如果数据库里出现 A 的父是 B、B 的父是 A 这种脏数据递归会无限往下钻浏览器直接报Maximum call stack size exceeded。排查方法是加一个访问标记function findChildren(parentNode, visited new Set()) { if (visited.has(parentNode.id)) { console.warn(检测到循环引用节点 id:, parentNode.id); return; } visited.add(parentNode.id); const children nodes.filter(n n.parentId parentNode.id); if (children.length 0) return; parentNode.children children; children.forEach(child findChildren(child, visited)); }一旦发现某个 id 被重复访问就说明成环了打印警告并中断这个分支。生产环境里我建议把这个检测保留着成本很低但能救命。5.3 id 类型不一致导致匹配失败后端返回的id可能是数字1而parentId可能是字符串1或者反过来。JavaScript 里1 1是falsefilter就会漏掉这些记录树就断了。排查时先打印几条数据的typeofconsole.log(typeof flatData[0].id, typeof flatData[0].parentId);如果类型不一致统一转换即可。最稳妥的做法是在映射阶段就统一成字符串或数字id: String(item.id), parentId: String(item.parentId),这样后续比较就不会出问题。这个坑我踩过不止一次尤其是对接不同后端团队时字段类型经常不统一。5.4 排序丢失子节点顺序乱掉数据库查出来的数据通常带sort字段但经过filter和push之后顺序可能跟预期不一致。layui 会按数组顺序渲染所以排序要在构建阶段就处理好。最简单的办法是在映射后先对nodes排一次序nodes.sort((a, b) (a.sort || 0) - (b.sort || 0));这样filter出来的子节点自然就是有序的。注意排序要放在递归之前对完整的nodes数组排而不是对每个children单独排否则容易漏。5.5 常见问题速查表现象可能原因排查方向解决方式树只显示一级parentId 类型不匹配打印 typeof统一转字符串有幽灵箭头叶子节点带空 children检查数据删除空数组页面卡死/栈溢出数据成环加访问标记检测并中断子节点顺序乱未按 sort 排序检查 sort 字段递归前统一排序部分节点丢失parentId 指向不存在对比 id 集合清洗脏数据字段显示 undefined字段名没映射检查 title映射 name→title6. 进阶把递归封装成可复用工具6.1 支持自定义字段名的通用版本实际项目里不同接口的字段名五花八门。与其每次改代码不如写一个带配置的通用函数function buildTree(list, options {}) { const { idKey id, parentKey parentId, titleKey name, rootId 0, sortKey sort, } options; const nodes list.map(item ({ id: String(item[idKey]), title: item[titleKey], parentId: String(item[parentKey]), sort: item[sortKey] || 0, _raw: item, // 保留原始数据方便透传其他字段 })); nodes.sort((a, b) a.sort - b.sort); function findChildren(parent) { const children nodes.filter(n n.parentId parent.id); if (!children.length) return; parent.children children; children.forEach(c findChildren(c)); } const roots nodes.filter(n n.parentId String(rootId)); roots.forEach(r findChildren(r)); return roots; }调用时只需传配置const treeData buildTree(res.data, { idKey: menuId, parentKey: pid, titleKey: menuName, rootId: 0, });这样一套函数能应付大部分接口不用每次重写。6.2 递归函数的调试技巧递归最让人头疼的是“不知道跑到哪一层了”。我的习惯是在递归函数里加一个depth参数打印时带上层级function findChildren(parent, depth 0) { console.log( .repeat(depth) 处理节点:, parent.title); // ... 原有逻辑 children.forEach(c findChildren(c, depth 1)); }控制台里会输出带缩进的树状日志一眼就能看出递归走到哪、哪一层断了。调试完把console.log删掉即可。这个技巧在排查“某一层子节点没挂上”的问题时特别管用。6.3 性能优化的几个实操点如果数据量确实大除了换哈希映射法还有几个小优化第一filter换成一次分组。方案一里每次递归都filter一遍全数组其实可以在递归前先按parentId分好组递归时直接查表。这相当于把方案二的思路融进方案一。第二避免在递归里做字符串拼接、DOM 操作等重活。所有数据加工在递归前完成递归只负责挂载关系。第三如果树只需要渲染一次构建完可以缓存起来切换 tab 时不用重新构建。7. 我在实际项目里踩过的几个真实教训说几个文档里不会写、但实际开发中很容易中招的点。第一个是关于parentId为null的情况。有些后端设计里根节点的parentId不是0而是null或空字符串。这时候rootId参数就得相应调整否则一个根节点都找不到树是空的。我的做法是先打印一下数据里parentId都有哪些值确认根节点的标识到底是什么再传参。第二个是关于数据权限过滤。菜单树经常要根据当前用户权限做裁剪如果先构建完整树再裁剪递归删除节点很麻烦更好的做法是在扁平数据阶段就过滤掉无权限的记录再构建树。这样递归逻辑完全不用动干净利落。第三个是关于checked状态的回显。编辑角色权限时需要把已选中的菜单 id 数组回填到树上。layui 的tree组件支持通过tree.setChecked或初始化时在节点上标checked: true。我的做法是在构建节点时判断当前 id 是否在已选集合里是就加上checked: true。注意父节点的半选状态 layui 会自动处理不用手动算。第四个是关于异步加载。如果树特别大一次性构建全部节点会拖慢首屏。layui 的 tree 支持children为空时点击箭头再异步请求子节点。这时候递归构建只需要构建第一层子节点在click回调里再请求、再递归构建那一层。这个模式在地区选择、大型分类里很实用。递归这个东西初看绕写多了就发现它其实是处理树形结构最自然的表达方式。扁平转嵌套、嵌套转扁平、查找某个节点、计算树的深度全都是递归的拿手好戏。把今天这套listToTree吃透以后遇到任何树形数据的需求基本都能从容应对。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询