做后台管理系统的时候只要涉及组织架构、菜单权限、商品分类这类数据基本逃不掉一个树形组件。layui 的 tree 模块上手快样式也是典型的中后台风格但真要处理“动态加载数据”这个需求很多人在第一步就被卡住接口写好了后端也把树形 JSON 返回了树却怎么都渲染不出来或者数据一多直接卡死。这篇文章不打算讲太基础的静态树用法重点聊我在实际项目里把 layui 树改成异步懒加载的过程包括前端怎么初始化、后端接口要约定什么格式、以及几个藏得比较深的坑。适合正在用 layui 做管理系统、需要让树按需加载的开发者参考。1. 先搞清楚需求你的树真的需要动态加载吗在动手写代码之前先想清楚一件事你需要的到底是“动态加载数据”还是只是“数据是动态的”。这两个概念不一样。前者是指树一开始只渲染顶层的几个节点用户每次点击展开箭头时再去后端拿下一层数据后者其实可以完全用静态渲染只是初始化数据来自接口。如果树的总节点数在 100 条以内直接全量渲染就行搞懒加载反而增加接口交互和状态管理的复杂度。真正需要动态加载的场景通常是组织架构有几千人、商品类目层级深、或者父节点下面动不动就挂几十上百条子记录。这时候一次性把整棵树的数据塞给tree.render页面会非常卡数据接口传输时间也会拉长用户首屏要等很久。我见过某后台项目部门树全量返回是两万多个节点普通电脑打开页面光渲染树就卡好几秒点一个节点要等半天才有反馈。后来切成懒加载首屏只请求顶层二十个部门体验完全不一样。所以第一步永远是评估数据量而不是急着写代码。1.1 静态渲染和懒加载的分界点我给一个比较保守的经验值节点总数超过 300或者单层子节点超过 50就要认真考虑懒加载如果树只有几十个节点全量渲染的开发成本和用户体验反而更好。这个经验值并不绝对还要看节点上挂了哪些字段、页面里同时存在多少其他组件。比如每个节点都带一个图片地址或很长的描述即使只有 150 个节点渲染也会变慢反过来如果节点只是简单的 id 加 title四五百个也还能接受。判断标准很简单你自己在浏览器里打开页面试一试从点击到渲染结束如果超过 300 毫秒就有优化必要。另外还要考虑数据维护成本。全量渲染的接口如果一年到头很少变动还行但组织架构这种经常增删改的数据全量返回还得处理接口缓存失效的问题。懒加载天然是每次展开都拉最新数据数据一致性会好一些这个优势在实际业务中比很多人想象的更重要。1.2 动态加载需要先定好的三件事懒加载不是前端单方面就能定的动手前要把这三件事跟后端同事对齐最好写成接口文档。第一触发时机。常规做法是点击节点展开箭头时触发layui 新版 tree 组件提供专门的 load 事件后面会具体讲。第二接口约定。必须约定好参数怎么传一般是父节点 id返回内容是当前节点的直接子节点列表。我习惯统一用GET /api/org/children?parentIdxxx这样的风格简单直观。第三数据格式。每个节点至少要包含 id、title以及一个表示“是否还有下级”的标记。没有这个标记前端就没法决定要不要把节点设置成懒加载懒加载也就无从谈起。这三件事里最容易出问题的是数据格式。layui 树认的字段名是 id 和 title后端如果返回的是 name 或 label前端要做一层 map 映射否则渲染出来全是空白。这个错误在实际项目里太常见了我在第四部分会把典型现象列出来。2. 核心交互让树真正“按需”加载子节点先说明一下版本我下面用的写法基于 layui 2.8.x 的 tree 模块。这个版本开始tree 有了更完善的懒加载支持只要数据项里包含lazy: true组件就会在展开节点时触发一个 load 事件我们在事件回调里异步取数再通过obj.done把子节点数据交给组件。整个链路非常顺这也是我比较推荐的方式。2.1 前端初始化与 load 事件写法先给一份完整可用的代码骨架后面再拆开解释每个点layui.use([tree, jquery, layer], function () { var tree layui.tree; var $ layui.jquery; var layer layui.layer; tree.render({ elem: #orgTree, id: T_ORG_TREE, data: [{ id: root, title: 全公司, spread: true, children: [{ id: 1, title: 研发中心, lazy: true, children: [] }] }], onlyShowControl: false }); tree.on(tree(T_ORG_TREE), function (obj) { if (obj.type load) { var nodeId obj.data.id; var reqId load_ nodeId; // 防止同一个节点被快速点击时发出多个请求 if (window[reqId]) { return; } window[reqId] true; $.ajax({ url: /api/org/children, type: get, data: { parentId: nodeId }, dataType: json, success: function (res) { if (res.code ! 0) { layer.msg(加载子节点失败: res.msg); obj.done([]); return; } var children (res.data || []).map(function (item) { return { id: item.id, title: item.name, children: item.hasChild ? [] : null, lazy: item.hasChild ? true : false }; }); obj.done(children); }, error: function () { layer.msg(网络异常加载失败); obj.done([]); }, complete: function () { delete window[reqId]; } }); } }); });这里有几个容易被忽略的关键点。obj.done的参数必须是数组哪怕没有子节点也要传[]。不调用或传null组件会一直停留在 loading 状态这是新手最容易踩的坑。拿不到数据你要让它失败接口报错你也得让组件从 loading 状态里恢复出来所以每个分支里都要记得闭环。还有一个小地方lazy 节点必须设置children: []不能写成null或undefined。[]表示当前节点还没挂载子节点lazy告诉组件“别急着认为它是叶子展开时去请求”。两个配置缺一个点击加号都不会触发请求这个问题我排查过好几回。2.2 obj.done() 做了什么为什么必须传数组明白obj.done的作用能帮你省掉大量排查时间。它相当于告诉树组件这个节点底下的异步数据已经就绪现在可以拿这些数据渲染下一层同时把节点的展开状态从 loading 恢复成正常箭头。这个机制和表格组件里常见的setData还不一样。树组件内部要基于这个数组重新计算节点展开箭头、选中层级、父节点联动等中间状态。如果传的不是数组或者干脆不调组件等不到结束信号就会卡在“加载中”。所以有一条铁律要记住只要触发过 load就一定要调用 obj.done传空数组也行但不能不调。另外接口返回空数组的时候不要只在 console 里打一条日志就觉得完事了。我在项目里会顺手把当前节点的 lazy 状态修改掉具体做法见 4.4这里先留一个悬念。2.3 后端接口怎么设计才能少踩坑后端接口以常见服务端语言为例思路是通用的。核心动作就是按 parentId 查出子节点再判断每个子节点是否还有下级$parentId intval($_GET[parentId] ?? 0); $rows db_query( SELECT id, name FROM org WHERE parent_id . $parentId . ORDER BY sort_order DESC ); $result []; foreach ($rows as $row) { $hasChild db_query( SELECT COUNT(*) AS cnt FROM org WHERE parent_id . intval($row[id]) ); $result[] [ id $row[id], name $row[name], hasChild $hasChild[0][cnt] 0, ]; } echo json_encode([ code 0, msg success, data $result, ]);这个写法很好理解但有一个明显问题每循环一个节点就多查一次库。当前层如果有 20 个节点就会多执行 20 次 count 查询。数据量小的时候无所谓但树上到几百上千个节点这种写法会让接口变慢前端等待时间也会变长。改进办法是批量判断。先查出当前层的 id 列表再用IN查询把所有这些 id 的子节点一口气查出来按 parent_id 分组$ids array_column($rows, id); if ($ids) { $all db_query( SELECT parent_id, id FROM org WHERE parent_id IN ( . implode(,, $ids) . ) ); $childMap []; foreach ($all as $sub) { $childMap[$sub[parent_id]][] $sub; } foreach ($rows as $row) { $hasChild !empty($childMap[$row[id]]); $result[] [ id $row[id], name $row[name], hasChild $hasChild, ]; } }这样最多两次查询性能提升非常明显。另外parentId和查询条件里的 id 都用intval强转一遍算是基本的防注入处理。后端设计还有一个重点不要把整棵树的路径信息一起返回来。懒加载的价值就是按需返回如果每个节点返回时都带全路径或者整棵子树那和全量加载就没有区别了。只返回当前层节点以及“是否有下一层”的标记就够了。3. 展开状态、回显与动态刷新比基础加载更实用的姿势把树跑起来不难难的是跟业务需求咬合。做后台系统的都知道树组件通常不只是用来“看”的还要基于它做人员选择、部门调整、数据权限回显。这一部分我把几个高频场景拆开来说。3.1 如何保存展开状态不让用户每次刷新都白找懒加载树是异步加载节点的刷新页面后前端一开始不知道哪些节点需要展开。如果想保存用户的展开位置需要自己维护一个已展开节点的 id 集合。做法不复杂监听 spread 事件节点展开时把obj.data.id记下来收起时移除var expandedIds new Set(); tree.on(tree(T_ORG_TREE), function (obj) { if (obj.type spread) { if (obj.show) { expandedIds.add(obj.data.id); } else { expandedIds.delete(obj.data.id); } } });刷新页面后把这个集合放进localStorage页面加载完成后再从集合里恢复。具体做法是在初始渲染的数据里给需要展开路径上的节点设置spread: true。这里有个很有意思的现象在懒加载逻辑下只要某个节点设置spread: true组件渲染时就会自动触发 load 事件去加载它的子节点。所以理论上可以通过一串展开标记把整棵树的默认状态恢复出来。不过要注意如果展开的路径很深比如根节点下第 4 层的一个节点之前是展开的刷新后必须把从根到该节点路径上的所有节点都标记为spread: true否则中间层没展开下一层节点不在视图中组件不会触发加载也就定位不回去。恢复选中态也是类似思路在 load 事件拿到子节点列表之后去检查本地保存的选中节点 id如果当前挂载的子节点里有需要选中的手动触发选中。这块实现会因业务而异把原理理解透剩下的就是体力活。3.2 新增、编辑、删除后如何优雅地刷新树树数据是异步加载的新增了一个部门不可能只在后端把数据插入就完事前端树必须做出反馈。最直接的做法是销毁整棵树再重新 render但这样会把用户当前展开的节点全部收起来体验比较生硬。如果要保留展开状态可以参考上一节重新渲染前把当前 expandedIds 收集起来构造新 data 时把路径节点spread: true再调tree.render。还有一种做法是局部刷新当前节点新增子节点后直接更新该节点引用的 children再调tree.reload(T_ORG_TREE, { data: newTreeData })。但这个前提是你要能拿到整棵树的当前数据源懒加载场景下有些父节点没有被加载过子节点数据不在前端内存里局部刷新做起来很别扭。所以我更推荐重新 render 恢复展开状态代码量不大也能覆盖大部分业务场景。如果用的 layui 2.8还可以在成功回调里主动触发一下当前父节点的再次加载把父节点 children 重置为空再重新触发 load 事件。这个思路比较取巧但能把新增节点的展示控制到最小范围适合对树交互要求高的场景。3.3 勾选联动与半选状态树组件只要开了showCheckbox: true父节点的半选状态会自动计算这是组件内置能力不需要额外处理。但懒加载场景要注意一个问题父节点还没加载出所有子节点时它的“全选/半选”判断只能基于已经加载出来的孩子而不是基于数据库里真实存在的全部孩子。如果业务需要对“当前未加载的节点”做权限判定逻辑就要落到后端。举一个典型例子权限树里用户勾选了某个父节点如果只把前端勾选的 id 提交给后端后端就无从知道还有哪些子节点没被加载出来。所以这种场景更合理的做法是后端保存权限时根据父节点 id 递归查全它下面所有子节点再统一存而不是只存前端传过来的 id。前端树负责提供交互和展示规则的最终解释权放在后端。4. 几个坑早踩早防问题排查实录这一部分把我在项目里实际碰到过、以及身边同事踩过的坑整理一下。先列一个速查表格后面逐个展开。现象常见原因解决方向点击加号没有任何网络请求节点没有 lazy或 children 为 null懒加载节点 children 统一给 []接口返回正常树是空白字段名没映射缺少 titlemap 一层转成 id title展开一个节点发了多个请求没有防重入用 pendingIds 拦截一直 loading 不结束obj.done 没调用或传参非数组所有分支都调 obj.done([])叶子节点仍显示展开箭头lazy 状态未更新拿到空数组后把 lazy 改成 false老版本没有 load 事件layui 版本过低换用 click 事件 重新 render4.1 点击加号没反应network 里连请求都没有这是我被问得最多的一个问题。节点 data 里已经设置了lazy: true但点击展开箭头没有任何网络请求。最常见的原因就是 children 被写成了 null 或 undefined。组件判断逻辑里懒加载节点要求 children 是一个数组哪怕是空数组你用 null 表示“没有子节点”组件会认为它不需要加载所以不触发 load。解决办法很简单懒加载节点的 children 统一给[]。另一个可能原因是 layui 版本太旧还没有 load 事件支持。先看当前版本号如果是 2.8 之前的老版本需要换一种思路实现参考 2.3 提到的重新 render 方案。4.2 接口返回正常树却渲染出空白节点曾经在项目里遇到过接口返回一堆带 name 和 label 的 JSON前端把数据直接塞给 tree.render结果树是渲染出来了但每个节点都是一条空白点开还有选中框就是没有文字。这就是字段名没映射对。layui 树的节点标题认 title唯一标识认 id。后端很多项目习惯用 name、label 甚至 key、value 来命名返回给前端之后不加工title 字段不存在树自然显示空白。处理手法是在 ajax success 里做一层 mapvar children (res.data || []).map(function (item) { return { id: item.id, title: item.name }; });有人会问layui 不是支持自定义字段名吗确实可以通过一些配置指定字段但在树组件里我不建议依赖这个特性因为一旦嵌套子级字段名也要变代码阅读成本很高。统一转义后面替换字段名也只在 map 一处调整维护起来最直接。4.3 展开一个节点发了好几个一模一样请求这个现象很典型基本就是 load 回调里没有做加载中拦截或者节点被快速点击展开收起再展开。我自己习惯在闭包里用一个对象存储 pendingIds请求前判断var pendingIds {}; tree.on(tree(T_ORG_TREE), function (obj) { if (obj.type load) { if (pendingIds[obj.data.id]) { return; } pendingIds[obj.data.id] true; $.ajax({ url: /api/org/children, data: { parentId: obj.data.id }, success: function (res) { // ... }, complete: function () { delete pendingIds[obj.data.id]; } }); } });请求完成后再把标记删掉这样既不会重复请求也不会因为上一次还没结束就把后续点击吞掉。网络慢的时候这个保护尤其重要否则用户多点了两次后端被无谓地打了好几个请求前端还可能因为多次 done 造成状态错乱。4.4 没有子节点还留着展开箭头这个现象也很常见接口返回了空数组树里这个节点旁边却还带着一个展开箭头点开后又发一次相同请求拿到还是空数组。这是因为当前节点 lazy 一直是 true组件不知道它已经是叶子。接口返回空数组后需要更新节点的 lazy 状态。我在实践中的处理方式是在obj.done([])之前先把obj.data.lazy改成 false再把obj.data.children置为[]然后调用obj.done([])给组件一个明确的“这已经是叶子”的信号。不同版本对原数据引用的处理有细微差别如果不生效可以在重新 render 或 reload 时把这层节点的数据重新传一遍确保 lazy 为 false。这里要特别提醒不要以为后端永远返回 hasChild 标记就万事大吉。数据是会被删的比如父节点下最后一个子部门被删掉但前端旧缓存还留着 hasChild: true一样会出现这个现象。所以前端在 load 事件拿到空数组时一定要主动矫正本节点的状态。4.5 排查思路备忘清单如果树出问题我通常按下面的顺序查省得乱试打开浏览器 F12 Network点击树节点看有没有发出请求。没有请求检查 lazy 和 children 配置有请求看返回 JSON 是否符合约定的 code/data 结构。在 load 回调第一行打印console.log(obj.type, obj.data)确认事件触发以及拿到的是不是当前点击的节点。看 obj.done 有没有被调用以及传的参数是不是数组。不是数组或者没调用节点就会一直 loading。检查后端返回的 hasChild 和前端 map 出来的 lazy 是否一致不一致会导致展开箭头和实际行为脱节。最后再确认 layui 版本。老版本没有原生的 lazy/load 机制换实现方式就好。最后说点发自肺腑的体会。树组件看着简单真正做深了就会发现难点从来不在组件本身而在于你和前端异步状态、后端数据约定之间的配合。动态加载数据这件事本质上是把一个大的树查询拆成了许多小的、按需完成的查询所以前端要处理的状态变多了后端也多了很多接口设计上的约束。我在实际项目中把接口的 hasChild 字段统一约定好之后前端的 map 逻辑反而比一开始想象的简单很多后续再遇到类似需求改起来也很快。如果你是第一次做这类功能强烈建议从最简单的版本开始先把后端返回的裸数据打印出来看清楚了再说要不要加 lazy、要不要恢复展开状态。能把一个节点从点击到完成渲染的数据流完整讲清楚这棵树基本上就稳了。
阅读完成 · 觉得有帮助?