ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

jEasyUI树形菜单父/子节点加载实战:从全量到懒加载及问题排查

jEasyUI树形菜单父/子节点加载实战:从全量到懒加载及问题排查 jEasyUI 的树形菜单尤其是父/子节点加载这一块几乎是每个做后台管理系统的人都会碰到的需求。我翻技术社区的时候经常看到各种“加载”相关的报错——脚本加载失败、模型加载不出来、扩展程序无法加载——说实话很多问题的根源都是数据结构和加载时机没对齐。jEasyUI 树形菜单也是这个道理父节点怎么加载、子节点什么时候加载、后端该返回什么格式这些理不顺树就长不出来。这篇文章我打算把 jEasyUI 树形菜单的父/子节点加载完整拆一遍从最简单的全量加载到真正意义上的异步懒加载再配上接口约定和排查思路。适合刚接触 EasyUI 的前端也适合那些已经写了几个后台项目、但一直被树加载问题折磨的兄弟。内容全部是我在实际项目里验证过的方案拿过去就能用。1. 先搞懂树形菜单的三种数据组织方式1.1 方式一一次加载全量树全量加载的意思就是后端一次性把整棵树的数据全部返回给前端前端拿到后直接渲染。数据在 JSON 里是嵌套结构父节点下挂 childrenchildren 里还能继续挂 children。[ { id: 1, text: 系统管理, state: closed, children: [ { id: 11, text: 用户管理, state: open, children: [ { id: 111, text: 新增用户 }, { id: 112, text: 编辑用户 } ] }, { id: 12, text: 角色管理 } ] } ]这段数据里id 是节点唯一标识text 是显示文字state 表示节点的展开状态open 展开、closed 收起children 是子节点数组。全量加载的好处是简单前端只需要$(#tree).tree({ data: treeData })就能出整棵树。缺点是数据量一大就完蛋。我之前接过一个项目菜单节点有三千多个全量返回的 JSON 有两百多 KB页面首次渲染直接卡了四五秒那种体验根本没法交差。1.2 方式二按需加载子节点懒加载懒加载的意思是树初始化时只加载根节点这一层用户点击展开某个父节点时前端才向后端请求这个父节点下面的子节点数据拿到后再挂到树上。这种方式后端返回的 JSON 不包含 children 字段而是靠父节点上的一个标记告诉前端“我这里还有孩子你要点了再来问我要”。这个标记就是前面提到的 state 字段取值必须是closed。[ { id: 1, text: 系统管理, state: closed }, { id: 2, text: 业务管理, state: closed }, { id: 3, text: 报表中心, state: open } ]注意第三个节点“报表中心”它没有子节点state 是 open这样它就不会显示展开箭头用户点它也没反应。如果误写成了 closed前端会以为它有子节点点开就会去请求接口拿到空数组后展开一片空白。懒加载最大的好处是首屏加载快网络请求也被拆分成了多个小请求每个接口只返回当前层级的数据服务端压力也小。缺点是需要前后端约定好子节点接口的传参方式前端代码也多一些逻辑。1.3 方式三混合模式与选择标准实践中我一般推荐混合模式根节点一次性返回子节点按需加载。也就是树初始化时加载第一层这层节点全部标记为 closed有子节点的用户一层一层往下点每层实时请求。判断该用哪种模式我自己有一个简单的标准节点总数少于 500层级固定不超过 3 层用全量加载省事。节点多、层级深或者数据经常变化比如权限菜单每五分钟刷新一次用懒加载。不确定的话直接懒加载。懒加载初期麻烦一点但项目后期数据膨胀的时候你会感谢当初做的决定。我踩过一次坑一个项目上线时只有两百个菜单图省事做了全量加载一年后菜单涨到三千多个不得不回头改造。与其后面返工不如一开始就把懒加载的方案定下来。2. 父节点加载数据格式、初始化配置与常见误区2.1 父节点数据长什么样先明确一个概念父节点不是一种特殊的节点类型它只是有子节点的普通节点。在 jEasyUI 里一个节点可不可以看到子节点的入口完全取决于有没有设置 state 为 closed。所以后端返回父节点数据时最少需要三个字段id、text、state。id 用于标识节点text 是显示名称state 决定它有没有展开箭头。如果对图标有要求再加一个 iconCls 字段指定 CSS 类名来显示不同图标。很多后端同事会问父节点要不要返回 pid我的建议是在懒加载模式下不需要。因为懒加载是每个父节点单独请求自己的子节点后端拿到的参数是当前节点的 id它直接查这个 id 下的子节点就行了pid 反而多余。只有前端要做全量数据的前端过滤时pid 才有意义。2.2 初始化配置与 loadFilter 的写法父节点的初始化加载最常规的写法是这样$(#menuTree).tree({ url: /api/menu/root, method: post, loadFilter: function (data) { if (data.code 0) { return data.data; } return []; } });注意这个 loadFilter它就是 jEasyUI 树加载的“翻译官”。如果你后端返回的是{ code: 0, data: [...] }这种统一结构不写 loadFilter 的话树组件会直接把整个对象当成节点数组去渲染结果就是页面上一个节点都显示不出来控制台还报“Cannot read property length of undefined”。loadFilter 做的事情就是把后端返回的数据转换成树组件需要的数组格式。我建议不管后端返回什么结构前端都写上 loadFilter统一处理哪怕现在后端只返回一个数组。因为接口结构未来很可能调整有这层翻译在后面兜底改接口时前端代码不用动。初始化时还有一个回调值得关注——onLoadSuccess它表示根节点已经加载完成。很多人用它来做“默认展开第一层”或“默认选中某个节点”onLoadSuccess: function () { var roots $(#menuTree).tree(getRoots); if (roots.length) { $(#menuTree).tree(expand, roots[0].target); } }这个写法会把第一个根节点展开。如果你希望默认展开到指定的第二层节点就得结合节点 id 去查找并逐层展开后面第 5 章我会展开讲。2.3 父节点不显示展开箭头多半是 state 没写对这是我被问过最多的问题。现象是树加载出来了根节点文字也显示正常但父节点旁边没有那个展开的小箭头点也点不动。排查步骤很简单打开浏览器 F12看后端返回的数据里父节点的 state 是什么。如果 state 是 openjEasyUI 就会认为这个节点已经展开过了它不会请求子节点如果连 state 字段都没有组件默认按 open 处理。正确做法是后端在返回父节点时必须显式给state: closed。这里有个细节全量加载模式父节点想默认收起也要写 closed不写的话所有子节点会全部展开页面拉得很长。区分一下就是全量模式写 closed 是为了控制初始展开状态懒加载模式写 closed 是为了触发子节点按需请求。两种模式对 state 的需求是同一个字段但背后逻辑不同别搞混。还有一个小概率的情况后端返回的 state 是大写的CLOSED或ClosedjEasyUI 不认照样不显示箭头。字段值严格区分大小写这点我在接口文档里都是标红的。3. 子节点懒加载核心实现思路与完整代码3.1 为什么不能只靠 tree 自带的 url 完成懒加载很多初学的人以为树配置一个 url父节点 state 设置成 closed点击父节点时组件就会自动带着父节点 id 去请求子节点接口。实际上 jEasyUI 默认的异步加载行为不是这样的。它在展开节点时如果发现这个节点没有加载过子节点数据会重新请求配置的那个 url——也就是根节点接口——而不是请求“当前节点的子节点接口”。这就导致一个现象无论你点击哪个父节点接口请求的都是同一份数据响应回来后会重新加载整棵树前面展开的状态全部丢失。所以真正的懒加载必须手动介入核心套路就三步拦截展开事件、请求子节点接口、把数据挂到当前节点下面。完整方案我放在下一节代码可以直接抄。3.2 完整实现onBeforeExpand ajax append下面这份代码是我项目里最常用的懒加载模板兼容了空数据、接口异常等边界情况!DOCTYPE html html head meta charsetutf-8 titlejEasyUI 树形菜单父/子节点加载示例/title link relstylesheet hrefeasyui/themes/default/easyui.css script srceasyui/jquery.min.js/script script srceasyui/jquery.easyui.min.js/script /head body div idmenuTree/div script var loadedNodeIds {}; function buildTree() { $(#menuTree).tree({ url: /api/menu/root, method: post, loadFilter: function (data) { if (data.code 0) { return data.data; } return []; }, onBeforeExpand: function (node) { if (loadedNodeIds[node.id]) { return; // 已经加载过放行正常展开 } loadChildren(node); return false; // 先手动加载加载成功后再展开 }, onLoadSuccess: function () { var roots $(#menuTree).tree(getRoots); if (roots.length) { $(#menuTree).tree(expand, roots[0].target); } } }); } function loadChildren(node) { $.post(/api/menu/children, { id: node.id }, function (res) { if (res.code 0) { var children res.data || []; if (children.length) { $(#menuTree).tree(append, { parent: node.target, data: children }); } loadedNodeIds[node.id] true; $(#menuTree).tree(expand, node.target); } else { // 接口异常也要放行否则节点永远卡住 loadedNodeIds[node.id] true; $(#menuTree).tree(expand, node.target); } }).fail(function () { loadedNodeIds[node.id] true; $(#menuTree).tree(expand, node.target); }); } buildTree(); /script /body /html这里有几个关键点必须讲透。第一为什么用 onBeforeExpand 而不是 onExpandonBeforeExpand 在节点展开前触发此时返回 false 可以阻止展开。onExpand 触发时节点已经展开了如果异步加载的数据还没回来界面会出现“展开了却是空白”的闪烁。用 onBeforeExpand 配合 return false可以把展开动作延后到数据加载完成之后体验更顺滑。第二append 的 parent 参数必须是node.target不是node.id。node.target 是 jEasyUI 内部生成的 DOM 节点引用tree 组件靠它定位挂载位置。我见过有人写成 parent: node.id结果子节点没有挂到父节点下面而是跑到了树的根部。第三加载完成后主动调用$(#menuTree).tree(expand, node.target)。因为我们之前在 onBeforeExpand 里 return false 阻止了默认展开所以数据 append 上去之后需要手动补一次展开动作。这套方案实测下来很稳我已经在三个项目里用了同样的套路。3.3 已经加载过的子节点不要重复请求上面代码里的 loadedNodeIds 对象就是用来去重的。第一次展开某个父节点时它不在集合里前端请求子节点加载完成后标记为 true。第二次点击同一个父节点onBeforeExpand 发现已加载过直接放行不再发请求。去重这个细节非常重要。不做的后果是用户每展开一次父节点浏览器就发一次请求服务器白白承受压力节点还会频繁变回加载中的状态甚至导致已展开的子树被重置。用对象记录而不是数组是因为对象按 id 查找是 O(1)节点多的时候性能更好。id 本身是数字也不影响JavaScript 对象键会自动转成字符串前端用node.id做键和后端返回的 id 类型没必要强求一致只要唯一就行。如果要实现“强制刷新某个父节点的子节点”只需要把 loadedNodeIds 里对应的 id 删掉然后重新触发展开或者直接把该节点下已有的子节点删除后再走一次加载逻辑。3.4 没有子节点的父节点空数据的处理与局部刷新懒加载模式下一个父节点第一次展开时请求子节点接口后端返回空数组前端 append 了一个空数组。此时 jEasyUI 会认为这个节点没有子节点自动去掉展开箭头。这是正常表现。但有一个业务场景要特别注意某些父节点的子节点是动态生成的比如“按日期归档”的目录今天可能没有子节点明天就有了。如果你的前端已经把这个节点标记成 loaded用户后面刷新页面这个节点还是会请求子节点吗会的因为 loaded 标记是前端 JS 变量页面刷新后自动清空。但如果在同一个页面会话里后端的数据变了前端的 loaded 标记还在用户就看不到新数据。处理办法是加一个“手动刷新”入口。把当前节点的 id 交给一个刷新函数函数里先删除该节点下的子节点再清掉 loaded 标记最后模拟一次展开function refreshNode(node) { var tree $(#menuTree); var children tree.tree(getChildren, node.target); for (var i children.length - 1; i 0; i--) { tree.tree(remove, children[i].target); } delete loadedNodeIds[node.id]; tree.tree(expand, node.target); }注意删除子节点时要从后往前删顺序反了会出现下标错乱的问题这是 jEasyUI 树操作里一个容易踩的暗坑。4. 高频 Bug 盘点树形菜单加载问题排查实录4.1 症状与对策速查表我把这几年遇到的树加载问题整理成一张速查表开发时对照着查能省不少时间。症状可能原因解决方案树初始化后一个节点都不显示后端返回的是对象而非数组或 loadFilter 未配置检查 F12 响应体在 loadFilter 中 return data.data父节点没有展开箭头state 缺失或为 open或大小写错误后端显式返回state: closed注意大小写点击父节点无反应Network 无请求该节点 state 不是 closed组件认为它没有子节点修正后端 state 字段点击父节点重复请求接口没有做已加载标记每次展开都触发请求用 loadedNodeIds 集合去重展开后报 Cannot read property length of undefinedloadFilter 返回的不是数组结构检查后端 data 是否为数组loadFilter 统一兜底追加的子节点显示在树根部层级错乱append 的 parent 传了 node.id 而非 node.target改成parent: node.target接口报错后节点一直转圈请求失败后没有处理展开动作fail 回调中也要 expand 节点或提示错误树加载很慢首屏卡顿全量加载数据量过大改懒加载或后端分页返回刷新后选中的节点状态丢失没有缓存选中节点 id保存选中 idonLoadSuccess 后重新 select这张表基本覆盖了树加载 80% 的问题。剩下 20% 的疑难杂症大多是数据本身有歧义——比如 id 重复、text 里夹带 HTML 标签导致渲染异常——这些要靠细心排查。4.2 一个真实项目的排查过程去年帮一个朋友看问题他说 jEasyUI 树最多只显示根节点点父节点完全没反应。我远程看了一眼F12 打开 Network先确认点击父节点时有没有发出请求。结果 Network 里干干净净一个请求都没发。这就说明问题不在接口而在数据本身。再看接口响应后端返回的根节点数据是[ { id: 1, text: 系统管理, iconCls: icon-sys } ]果然state 字段没有返回。jEasyUI 把节点默认为 open认为它已经加载过子节点所以点击不会触发任何请求。解决方案就是让后端给有子节点的菜单统一返回state: closed。改完数据后箭头出现了点击有新请求了但页面报错“Cannot read property length of undefined”。再看响应后端返回的是{ code: 0, data: [...] }而树组件期望的是数组没有 loadFilter 就炸了。加上 loadFilter 之后树终于正常展开。这个案例其实包含两个很典型的坑先看 Network 有没有请求再看响应结构对不对排查顺序对了十分钟能解决的问题就不会拖一小时。4.3 容易忽略的细节与避坑技巧说几个我从血泪教训里总结的细节。第一个是节点 id 必须唯一。不同层级出现相同的 id最典型的场景是“菜单表里不同子系统的菜单 id 都是 1、2、3”前端会用 id 做数组索引、做去重标记、做展开记录id 一重复轻则树展开错乱重则递归死循环。后端如果用了自增主键把主键直接当 id 用就行别自己拼。第二个是不要往 text 里塞 HTML。有人想给节点名称加颜色直接在 text 里写span stylecolor:red紧急/span。jEasyUI 默认会把这个当成纯文本渲染页面出现一串标签字符。真要定制节点内容用 formatter 回调自己控制 HTML 结构。第三个是接口异常时也要对树做收尾处理。很多人写请求失败时只弹个提示忘了节点还卡在加载状态。我的做法是 fail 回调里无论如何都执行 expand至少让用户能正常看到空白节点同时弹一个友好的错误提示。与其让树一直转圈惩罚用户不如先把状态恢复问题展示在提示里。5. 与后端协作接口规范与功能扩展5.1 建议的接口返回结构写前端代码之前先把接口规范和后端对齐能省掉后面 80% 的扯皮。我常用的规范是这样根节点接口POST /api/menu/root{ code: 0, data: [ { id: 1, text: 系统管理, state: closed, iconCls: icon-sys }, { id: 2, text: 业务管理, state: closed, iconCls: icon-biz } ] }子节点接口POST /api/menu/children请求参数id为父节点 id{ code: 0, data: [ { id: 11, text: 用户管理, state: closed }, { id: 12, text: 角色管理, state: open } ] }code 统一为 0 表示成功非 0 表示业务异常。data 必须是数组不能是 null。这个约定写进接口文档后前端 loadFilter 永远只处理一种结构维护成本极低。还有一条容易被忽略的约定后端判断“一个有子节点的菜单”时state 返回 closed“一个没有子节点的菜单”时state 返回 open 或不返回。这个逻辑放在后端比放在前端可靠因为后端清楚每个菜单的真实数据量。5.2 树定位、搜索、选中恢复等体验优化父/子节点加载能力搭好之后通常还会接一些体验优化需求。第一个是默认展开到指定节点。比如用户从详情页跳回列表页要求树自动定位到上次操作的菜单。实现逻辑是在 onLoadSuccess 拿到根节点之后根据目标节点 id 找到节点然后逐层展开它的祖先节点function expandToNode(targetId) { var tree $(#menuTree); var node tree.tree(find, targetId); if (!node) return; var parent tree.tree(getParent, node.target); var chain []; while (parent) { chain.unshift(parent.id); parent tree.tree(getParent, parent.target); } // 先展开根节点再一层层往下展开 chain.forEach(function (pid) { var n tree.tree(find, pid); if (n n.state closed) { tree.tree(expand, n.target); } }); tree.tree(select, node.target); }getParent 拿到的是直接的父节点循环向上收集祖先链条再按从根到叶的顺序逐层展开这样无论层级多深都能展开到指定节点。第二个是关键字过滤。在树上方加一个输入框输入关键字后通过 tree 的 nodes 接口遍历所有节点找出 text 包含关键字的节点并展开其父链。注意懒加载模式下未加载的子树节点在前端是不可见的过滤逻辑只能覆盖已加载的节点如果要做全量过滤需要前端先把整棵树请求一遍或者后端提供专门的搜索接口。第三个是选中状态恢复。保存用户最近一次点击的节点 id 到 localStorage页面刷新后加载完根节点调用 tree 的 select 方法把光标定位回去。这个功能一定要放在 onLoadSuccess 里执行因为这个时机根节点数据已经就绪find 操作才能命中。6. 写在最后我个人的几个习惯写树的加载写到现在我自己的习惯已经固定下来了。新建项目里凡是遇到树形结构不管初期数据量再小我也直接上懒加载模式根节点接口和子节点接口分开loadedNodeIds 去重集合从一开始就写好。后面数据膨胀是大概率事件与其到时候重构不如第一天就把“按需加载”四个字刻在代码里。排查加载问题我只认一条铁律先看 Network确认请求到底发出去了没有、响应里返回了什么。这一步做完八成的问题已经有了结论。剩下的两成是数据格式问题loadFilter 里多打几个 console.log数据结构和预期一比对就能定位。如果你正在做后台管理系统的菜单树把上面这套 loadChildren 模板粘过去把接口地址和字段名换成自己的几分钟就能跑起来。后面遇到乱七八糟的加载报错回来对照第 4 章的速查表大概率能少走很多弯路。
返回列表