ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

jEasyUI树形菜单加载全解:全量加载与懒加载的实战取舍

jEasyUI树形菜单加载全解:全量加载与懒加载的实战取舍 做后台管理系统做到第三年我发现自己跟树形菜单打交道的时间几乎跟表格一样多。部门树、权限树、商品类目树甚至地区选择器本质上都是同一个组件——EasyUI 的 tree。而“父/子节点怎么加载”这个问题几乎每次都要重新翻一遍文档。jEasyUI很多工程里就这么叫实际就是 EasyUI 的 jQuery 版本的树组件最常用的就两种玩法一次性把整棵树的数据丢给前端或者等到用户展开节点时才去后端要子节点。这篇就把这两种加载方式从数据结构、接口设计到常见坑原原本本拆一遍。不管你是刚接手老项目的新手还是已经写了不少后台页面的前端只要跟树组件打过交道这几点应该都用得上。1. jEasyUI 树形菜单加载的核心思路拆解1.1 两种加载模式全量加载和懒加载怎么选网上聊树形菜单动不动就让你“用懒加载”但实际项目里真不是所有场景都适合懒加载。我第一次做权限树的时候全公司一共二十多个部门用户角色也就三五个这种数据量一次性返回根本没问题强行做成懒加载反而要多写好多接口和状态判断。反过来后来做一个电商后台的商品类目树一级类目下面挂着上千个末级类目如果一次性全渲染页面直接卡成幻灯片。EasyUI 的 tree 组件刚好两种模式都支持区别只在初始化参数data模式直接传一个数组前端拿到的就是完整树结构适合节点总数在几百以内、层级固定、不需要频繁增删改的场景。url模式初始化时只传一个接口地址tree 组件会先请求根节点数据之后展开某个state: closed的节点时再自动带着该节点的 id 去请求子节点适合层级深、节点多、需要按需加载的场景。我在项目里的选择标准很简单节点总量超过 500或者层级深度不固定就优先用懒加载。因为全量加载虽然代码简单但数据一到前端就需要递归构建树、渲染 DOM数据量一大初始化和展开都会有肉眼可见的延迟。懒加载把压力分摊到每次展开动作上体验上反而更顺。1.2 父节点和子节点背后的数据约定很多人用 tree 组件卡住不是 API 不熟而是没搞明白它眼里的“树”长什么样。EasyUI 的 tree 节点就是一个普通的 JSON 对象里面有几个保留字段id是节点唯一标识text是显示文字state表示节点状态值为open时节点默认展开值为closed时节点收起并且可以被再次展开。最关键的是children字段它保存当前节点的直接子节点数组。我见过不少后端同事会返回parentId、parentName、isParent这类自定义字段这些字段不是不能用但 tree 组件本身只认上面那几个保留字段。所以常见的做法是后端直接返回符合树结构的 JSON或者前端拿到扁平列表后自己转成树。比如下面这段就是最标准的节点数据[ { id: 1, text: 总部, state: closed, children: [ { id: 2, text: 技术部, state: open } ] } ]这里有个容易踩的细节如果某个节点明明还有子节点但你把它写成state: open并且没给children那树组件就认为它是一个空的叶子节点展开箭头都不会显示更不会发请求。反过来如果它是一个没有子节点的叶子节点你却写了state: closed前端就会一直以为它有下级每次展开都会去请求结果白折腾一趟还拿回空数组。1.3 加载父/子节点本质上是个数据关系问题别看“加载父/子节点”听起来像是组件用法实际工作中你会发现问题八成出在数据组织方式上。比如数据库里通常只存一个parent_id字段根节点的parent_id是 0 或 NULL子节点的parent_id指向父节点的 id。这种扁平结构适合存储但不适合直接渲染树中间必须做一次“按父找子”的组装。所以做树形菜单之前我建议先把数据流想清楚后端返回什么前端转换成什么组件最终渲染成什么。最容易维护的方案是后端直接把树结构拼好返回前端只用data参数一次性接收如果后端实在不方便返回树结构那就只能在 JavaScirpt 里自己把扁平的pid列表转成children嵌套结构。后面第 2 部分我会给出可以直接用的转换函数顺手把排序、空节点这些细节也一起处理掉。2. 一次性加载把扁平列表转成树的完整实操2.1 前后端接口格式与树构建函数一次性加载最典型的场景是权限树。后端从一张sys_menu表里查出所有菜单每条记录大概长这样[ { id: 1, pid: 0, text: 系统管理 }, { id: 2, pid: 1, text: 用户管理 }, { id: 3, pid: 1, text: 角色管理 }, { id: 4, pid: 0, text: 商品管理 } ]前端拿到这串数据后不能直接塞给$(#tree).tree({ data: list })否则树组件只会把每个对象当成平级节点根本不会形成父子关系。所以需要一个递归转换函数把pid等于某个节点 id 的对象挂到它的children下。下面这段是我用了很久的工具函数顺手把children为空数组的情况也处理干净了function buildTree(list, parentId) { var result []; for (var i 0; i list.length; i) { var node list[i]; if (node.pid parentId) { var children buildTree(list, node.id); if (children.length 0) { node.children children; node.state closed; } else { node.children undefined; node.state open; } result.push(node); } } return result; } var treeData buildTree(menuList, 0); $(#menuTree).tree({ data: treeData });这里为什么要把state分开设置因为 EasyUI 渲染规则里有子节点的closed节点才会显示可展开的箭头没有子节点的节点保持open就不会误导用户。如果你把所有节点都当成closed叶子节点旁边也会出现一个加号点一下就发请求显然不合理。这个函数已经是 O(n²) 了如果菜单量特别大可以用一个 map 先按 id 索引再遍历一次挂 children能快不少但一般后台菜单不超过几百条这个简单版本就够了。2.2 节点排序和空 children 的心得扁平列表转树时很容易忽略排序问题。数据库查询如果不加order by同一个父节点下的子节点顺序随机生成的树就会乱跳。我习惯在接口返回前就按sort_order排好转换函数里保持数组原有顺序即可。如果你拿到的列表没有专门排序字段退一步按id排序也比完全不排强。还有一个常见问题是空children。有的后端很喜欢给每个节点都带上children: []这在前端渲染时通常没问题但如果你用node.children.length去判断有没有子节点就会把空数组判断成“有子节点”导致误显示展开箭头。我在上面函数里用children.length 0才赋值children就是为了避免这种歧义。经验之谈状态判断只看有没有children字段不看它的值虽然空数组在严格意义上是 false但不同版本组件对空数组的处理并不一致干脆手动清除掉最稳妥。2.3 默认展开层级和选中根节点一次性加载时如果整棵树都处于closed状态用户进来只看到一堆根节点体验不太好。我通常在初始化完成后按业务需要展开特定层级$(#menuTree).tree({ data: treeData, onLoadSuccess: function () { var root $(#menuTree).tree(getRoots)[0]; if (root) { $(#menuTree).tree(expandAll, root.target); } } });expandAll会把所有层级一次性展开适合层级少、节点少的场景。节点多的时候我更倾向于只展开到第二层或者直接记住用户上次展开到哪个节点。你可以循环调用tree(expandTo, target)逐级展开或者干脆在数据里把第一层节点的state直接设为open省去 JS 操作。这个看业务没有绝对标准但最好不要在大树上无脑expandAllDOM 节点太多会让浏览器卡顿。2.4 父子节点勾选联动的取舍树形菜单经常配合 checkbox 使用用于分配权限。EasyUI 默认行为是勾选父节点时自动勾选所有子节点再取消父节点时子节点也会全部取消这种级联逻辑大多数业务都需要。但如果你做的是“部分权限独立分配”父节点和子节点不强关联就得把cascadeCheck设为false$(#menuTree).tree({ data: treeData, checkbox: true, cascadeCheck: false });注意设置之后勾选子节点时父节点不会自动半选你需要自己在onCheck事件里处理半选状态否则界面上父节点一直显示未勾选后期回显权限时很难看。我吃过一次亏那时以为关掉级联就万事大吉结果权限回显时发现父节点状态不对最后是自己遍历所有子节点只要有任意一个被勾选就把父节点设为半选才算补齐这个交互。3. 异步加载父/子节点的完整配置3.1 url 模式初始化根节点请求怎么传参当业务规模变大全量加载撑不住时就该切到异步懒加载。懒加载的初始化方式很简单给 tree 组件一个url它加载时会默认请求根节点数据展开某个closed子节点时会自动把该节点的 id 作为请求参数发给后端。默认参数名是id也可以通过queryParams固定附加参数。$(#categoryTree).tree({ url: /api/tree/children, queryParams: { rootFlag: 1 }, onBeforeExpand: function (node) { console.log(即将展开节点, node.id); } });这里有个容易绕晕的点如果初始化时后端根节点的父 id 不是 0而是用rootFlag或其他字段标识怎么办你可以在后端判断id参数是否为空。比如接口/api/tree/children第一次请求没有id参数就默认返回所有根节点第二次请求带id1就返回 id 为 1 的节点的直接子节点。前端并不需要专门处理根节点的参数只要后端能区分“无参”和“有参”两种场景即可。3.2 到底要不要用 onBeforeExpand 手动加载网上很多教程会告诉你“在onBeforeExpand里发起 Ajax成功后用append方法手动把子节点加到树上”。这个方法确实做得到但我个人不太推荐除非你有特殊需求。原因很简单EasyUI 的 tree 组件在url模式下展开closed节点时本来就会自动发请求加载子节点你再用onBeforeExpand手动发请求等于同一件事做两遍还可能触发重复请求。手动append的典型场景是节点不是通过自身 id 去查而是需要拼一个复杂的自定义参数比如orgId加type。这时你可以用onBeforeExpand返回false阻止默认加载再自己发 Ajax完成后调用tree(append, { parent: node.target, data: children })。$(#orgTree).tree({ url: /api/tree/children, onBeforeExpand: function (node) { if (node.type ! org) { return false; } } });这里需要重点关注一旦onBeforeExpand返回false组件的默认加载就会被取消后续不会再自动发送请求。所以如果你只是想在展开前拦一道做校验而不是完全接管加载记得在通过校验后让函数返回true否则你会看到节点怎么点都打不开。这是新手最容易犯的错之一。3.3 后端接口设计返回直接子节点还是整棵子树配合懒加载的后端接口设计原则只有一个每次只返回直接子节点不要一次返回整棵子树。比如点开“手机数码”节点后端只返回“手机”“电脑”“耳机”这一层而不是把“手机”下面所有型号全部带出来。这样做的好处是响应快对前端渲染压力小而且后续数据更新后用户重新展开就能拿到最新数据。接口收到参数后大概逻辑如下GetMapping(/api/tree/children) public ListTreeNode children(Integer id) { if (id null) { // 返回根级节点 return menuService.listByParentId(0); } // 返回某个父节点下的直接子节点 return menuService.listByParentId(id); }返回的每个节点要带上id、text以及是否还有子节点的标记state。如果该节点还有下级返回state: closed这样前端展开它时会继续发请求如果没有下级就返回state: open或干脆不返回state让组件把它当叶子节点处理。如果你希望前端渲染更快可以在 SQL 里用EXISTS查一下有没有子节点动态决定返回的state但要注意这种写法在高并发下可能让数据库压力变大可以适当做缓存。3.4 缓存已加载节点防止展开又收起的重复请求懒加载模式下EasyUI 其实会自动记住哪些节点已经加载过了。节点一旦从closed变成open再次收起再展开正常情况下组件不会重新请求。但如果你手动改过state或者调用了tree(reload, target)就容易重复发请求。还有一种自己造成的坑有些同事为了确保数据最新在onExpand事件里调用tree(reload, node.target)结果每次展开都重新加载用户觉得卡不说后端日志还会刷出一堆请求。如果确实需要刷新某个节点更合理的做法是提供手动刷新按钮或者只刷新变化的节点而不是全量 reload。另外如果后端返回的节点id在多次加载后不唯一EasyUI 会认为它是同一个节点导致渲染错乱。我遇到过的情况是后端把数据库自增 id 和业务编码混用同一个 node id 在不同父节点下重复树就乱了。所以懒加载模式下id的全局唯一性一定要保证如果原表没有唯一主键可以在接口里拼一个业务前缀比如org_1、menu_2甚至直接返回复合主键字符串。4. 常见问题与排查技巧实录4.1 子节点加载成功父节点却自动收起这个现象很诡异点开父节点子节点明明出来了界面闪一下树又缩回去了。排查下来十有八九是数据问题。最常见的是父节点的id在加载子节点后发生变化组件定位不到原来的父节点于是把当前展开状态重置了。比如后端返回节点时id用的是数据库主键但前端某个环节把 id 替换成了别的字段就会触发这个问题。我的排查套路是先用浏览器的 Network 面板看请求参数确认展开父节点时发送的id是不是当前节点的 id再用 Console 看看tree(getChildren, node.target)返回多少个子节点最后检查接口返回的每条数据里id是否唯一。如果这几项都正常多半是onBeforeExpand里返回了false把默认加载行为给打断了。4.2 节点 state 为 closed但点击展开后没有发请求如果你初始化时用的是data模式而非url模式那么节点的state: closed只是一个显示状态组件根本不会自动发请求。这时候你需要自己在展开事件里用append方法塞子节点否则节点永远是空转。这是两种加载模式混用时的经典问题。另一个可能性是onBeforeExpand返回了false而且没有实现后续加载逻辑。这种情况在代码里搜一下事件绑定很容易发现。还有个小众原因某个版本的 jEasyUI 对url模式要求根节点必须有id如果根节点没有 id展开时传给后端的参数就是undefined后端按 null 处理返回了根节点数据前端匹配不上看着就像没发请求。解决办法是初始化时给根节点一个固定 id比如0。4.3 树刷新后选中态和展开态丢失后台管理系统里很常见用户勾选了一些权限然后切到其他 tab 再切回来或者点了刷新按钮整棵树的勾选状态全没了。EasyUI 本身不会自动记忆这些状态你必须自己保存。我一般用一个全局对象记录关键状态var treeState { expandedIds: [], checkedIds: [] }; $(#permTree).tree({ url: /api/perm/tree, checkbox: true, onExpand: function (node) { if (treeState.expandedIds.indexOf(node.id) -1) { treeState.expandedIds.push(node.id); } }, onCheck: function (node) { treeState.checkedIds $(#permTree).tree(getChecked); } });刷新之后在onLoadSuccess里重新展开和勾选$(#permTree).tree({ onLoadSuccess: function () { // 先展开记录过的节点 $.each(treeState.expandedIds, function (i, id) { var node $(#permTree).tree(find, id); if (node) { $(#permTree).tree(expand, node.target); } }); // 再回显勾选 $.each(treeState.checkedIds, function (i, node) { var target $(#permTree).tree(find, node.id); if (target) { $(#permTree).tree(check, target.target); } }); } });有一点要注意如果节点还没有加载出来tree(find, id)是找不到的这就回到第 3 部分的懒加载问题上。所以回显勾选时最好先把所有节点展开一遍或者干脆用全量加载模式。权限树本身节点通常不多我一般直接用全量加载配合上面的状态记录刷新体验能好很多。4.4 一次性加载大量节点导致页面卡顿怎么优化如果你确实需要一次性加载很多节点比如几千个类目但又不想改造成懒加载可以先做“分层渲染”。我试过一种简单方案只在onLoadSuccess时展开一层用户点击展开按钮时才渲染下一层通过onBeforeExpand里给tree(append)动态塞children。这样虽然数据还是全量在前端但 DOM 是逐步插入的能明显降低首屏卡顿。还有一种更彻底的方案是改用懒加载并且后端给每个节点提前算好children数量。当children数量大于 0 时返回state: closed否则返回open。前端就完全不需要加载叶子节点的子级数据数据库压力也小。这都是我在商品类目这种大数据量场景下实际试过的方案最终稳定下来的是懒加载加后端缓存。4.5 递归深度控制和前端参数校验说句容易被忽略的树接口如果不好好校验参数可能被人恶意利用。比如传入一个特别大的id或者接口无限递归查询会把数据库拖垮。后端最好限制查询深度最多返回三级或五级子节点超出的部分让前端继续懒加载。另外所有参数都要做类型转换防止 SQL 注入。这一点我在安全性审查时被提过一次后来就变成了团队树接口的默认要求id必须能转成整数否则直接返回空数组。最后再说两句实在的写树形菜单真正花时间的从来不是那几行组件配置而是前后端对“节点”这个概念的认知是否一致。全量加载也好懒加载也罢你要先想清楚数据从哪来、怎么组装、展开时能不能找到父节点、刷新后要不要恢复状态。这些细节理顺了jEasyUI 的树用起来就是顺手的事。我个人现在的习惯是普通后台菜单默认全量加载省心商品类目、组织机构这种层级深或者数据量大的一律懒加载并且把接口收敛成一个通用的children接口。如果你也正在被树形菜单折腾不妨先把数据格式打印出来对照着 id、pid、state 逐项检查很多时候问题就自己暴露了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表