做了几年的后台管理系统,前阵子又接到一个组织架构管理的需求,数据量不算夸张,但部门层级深,一展开是两千多个节点。第一版图省事,拿到全量数据一次性渲染,结果在低配电脑上页面卡了好几秒,客户当场皱眉。后来换成layui树组件的动态加载,点击父节点才去取子节点,问题直接解决。这篇文章就把这次改造的过程、核心原理、踩过的坑一次说清楚,给准备用layui树做动态加载的朋友一份能直接照抄的作业。
1. 先分清:什么场景必须动态加载,什么场景属于自找麻烦
1.1 一次性渲染两千节点的代价:白屏时间与卡顿的真实账
很多人觉得动态加载是炫技,数据都拿到了为什么要拆分请求。我用一次真实压测数据来说明:两千个节点一次性塞给layui tree,render执行期间主线程几乎被占满,树在展开时会出现明显的“等一拍”感觉,点击节点后的响应延迟能到1到2秒。这在开发环境用Mac不觉得,放到客户那台i3处理器的办公机上,体验会非常糟。
算一笔简单的账:树组件渲染每个节点需要创建DOM元素、绑定事件、计算缩进与折叠状态,节点越多,渲染耗时近似线性增长。而用户真正关心的只是自己当前要操作的那一级,其他节点处于全折叠状态时,渲染它们纯属浪费。动态加载的本质是把“全量计算”推迟到“真正需要的那一步”,用一次轻量请求换掉几千次DOM操作,这笔交换在任何场景下都划算。
1.2 适合做动态加载的三种典型结构
- 组织架构树:公司层级深、人员多,用户通常只关注当前部门及其直属下级。
- 区域联动树:省、市、区县,跨级数据量大,且上级变化会直接影响子级取数范围。
- 权限目录树:系统功能菜单按模块、子模块、按钮三级划分,一般用户只会逐级展开到自己需要的功能。
这三种结构的共同点是:层级之间有明确的数据归属关系,子节点的查询条件天然带上父节点的标识,适合按需请求。
1.3 动态加载不是万能钥匙,这些情况别用
反过来提醒一句:如果树的节点总数在几十个以内,或者产品要求所有节点一次性平铺展示以便全文检索,动态加载反而会把简单问题复杂化。树节点小于100个时,一次渲染的成本可以忽略不计,省掉的请求反而带来接口响应延迟和加载态闪烁。另一个不适合的场景是拖拽排序树——懒加载模式下子节点未加载时,拖拽目标区域判断容易出偏差,处理成本很高。选型前先把这两个问题想清楚:数据量是否大到必须分次渲染,交互上是否允许“展开时才看到子级”。
2. 树组件动态加载的两条技术路线,谁才是真“懒加载”
2.1 路线一:初始化后整体异步填充——只是把长请求挪前了
一开始我也是这么做的:页面加载时先用一个空数组渲染根节点,等接口返回全部数据后重新调用render,把整棵树一次性灌进去。这种方式对接口响应速度有要求,数据超过千条依然会卡,本质上只是把“白屏时间”换成了“loading转圈时间”,没有解决渲染性能问题。
这种方案只在一种场景下有价值:数据必须整体参与初始化(比如默认展开指定层级),其他情况不建议优先选择。
2.2 路线二:点击节点时才请求子级——树组件真正的懒加载
layui tree从2.6.0之后的版本,支持了在render配置data时传入异步获取函数的能力。展开父节点时组件会调用该函数,并传入父节点的id,函数返回的数组会被作为子节点渲染到对应节点下。这个机制才是标题里说的动态加载,也是我最终采用的方案。
核心有几个参数需要理解透:
isLeaf:子节点是否已经是叶子节点。如果这个字段不处理,展开时会误以为还有下一级,缺省情况下容易多发出一次无效请求。spread:控制节点默认展开状态。动态加载时通常不设置,或者仅在根节点上设置一次。id:作为父子关系的关联键,懒加载时尤为重要,接口请求参数与选中回显都依赖它。
2.3 两条路线选型判断标准:一个负责“要不要”,一个负责“怎么要”
| 对比维度 | 整体异步填充 | 懒加载 | 我的选择建议 |
|---|---|---|---|
| 数据量适用区间 | 100节点以内 | 数百到数万 | 300节点以下用整体,以上用懒加载 |
| 接口请求次数 | 1次 | 展开几次请求几次 | 高频展开场景评估接口压力 |
| 首屏加载速度 | 受全量接口影响 | 只加载根节点,很快 | 追求首屏秒开选懒加载 |
| 回显与搜索复杂度 | 简单无误 | 需处理路径拼接 | 功能涉及回显时要额外注意 |
经验是数据量超过300就无脑上懒加载,别等客户抱怨。接口的次数不是主要矛盾,每次请求单独1KB,10次也比一次性10MB对体验的伤害小得多。
3. 从零写一个可运行的懒加载树:接口设计到前端配置的完整实操
3.1 后端返回数据格式的约定:最容易被忽略的字段
动态加载树的前后端约定,核心就三条:
- 返回结构一定是数组,不能是带data包装的对象。
- 每个节点必须有
id、title、isLeaf三个字段。 - 子节点集合字段名必须是
children,哪怕当前节点没有子节点也要保留这个键(值为空数组)。
实际项目里接手过一个接口,返回字段叫childList,与树组件默认的children对不上,出现了“展开节点后无任何反应”的经典问题。解决方式是给各字段定制别名,如下所示。
table.render({ // 其他配置略 });写一段完整的字段映射示例:
tree.render({ elem: '#deptTree', id: 'deptTreeId', data: function(obj) { // obj 包含当前节点信息,首次加载时为空 return new Promise(function(resolve) { var parentId = obj.data ? obj.data.id : 0; // 兼容接口返回的字段名差异,做一次映射 fetch('/api/dept/children?parentId=' + parentId) .then(function(res) { return res.json(); }) .then(function(json) { var list = (json.data || []).map(function(item) { return { id: item.deptId, title: item.deptName, isLeaf: item.hasChild === 0, children: item.childList || [] }; }); resolve(list); }); }); } });这段代码值得注意的地方在第12行:isLeaf根据hasChild字段判断。这是很多新手最容易漏掉的部分,漏掉之后的表现是每个节点展开时都会请求一次接口,即使接口返回空数组,也会白转一下。
3.2 前端触发逻辑:点击展开时如何拿到父节点ID
树组件的懒加载函数会在两种时机被调用:初始化渲染根节点时,以及点击非叶子节点展开时。判断当前是取根还是取子级,靠的就是回调参数obj.data是否存在。
我推荐这样理解整个流程:
- 首次渲染:
obj.data为undefined,默认取parentId=0,加载根节点。 - 后续展开某一层节点:
obj.data.id就是该父节点ID,作为请求参数传给后端。 - 接口返回后,树组件把结果append到当前节点下面。
我实际开发中会在data函数开头加一行日志,确保展开时确实传入了正确的父级ID。排查问题第一步就是这个——如果展开任何节点都请求根接口,说明obj.data没拿到,多半是节点数据里没有id字段。
3.3 完整案例:部门树从根节点到五级子级的加载
以部门树为例,完整过程如下。
第一步:后端接口设计。
// 伪代码示意 public List<DeptNode> children(Long parentId) { List<Dept> depts = deptMapper.selectByParentId(parentId); return depts.stream().map(dept -> { DeptNode node = new DeptNode(); node.setId(dept.getId()); node.setTitle(dept.getName()); node.setIsLeaf(deptMapper.countByParentId(dept.getId()) == 0); node.setChildren(new ArrayList<>()); return node; }).collect(Collectors.toList()); }第二步:前端页面引用layui模块。
layui.use(['tree'], function() { var tree = layui.tree; tree.render({ elem: '#deptTree', data: function(obj) { return new Promise(function(resolve) { var parentId = obj.data ? obj.data.id : 0; $.get('/api/dept/children', { parentId: parentId }, function(res) { if (res.code === 0) { resolve(res.data); } else { resolve([]); // 失败时返回空数组,避免无限loading } }); }); } }); });第三步:验证效果。
打开页面时只发一次请求,拿到第一层部门。点击“技术中心”后,网络面板能看到一条新的请求,参数parentId等于该部门的id。返回后子级部门出现在“技术中心”下方,缩进层级正确。这个效果基本就是动态加载的标准形态。
3.4 动态加载树的节点操作:添加、编辑、删除后如何刷新
动态加载状态下的刷新,和全量渲染完全不同。全量刷新直接重新render整棵树即可,动态加载如果也这么干,用户已经展开的层级全部收起,体验很糟糕。
处理思路分三种:
- 新增或编辑子节点后,只刷新当前父节点的下一层。
- 删除节点后,从树中移除对应节点,并处理父子关系。
- 根节点变化时,才重新加载整棵树。
实际项目中我写了一个刷新的通用方法:
function reloadTree(parentId) { // 根据parentId重新请求数据并更新对应节点 // 思路:通过树组件实例的getNodeByParam找到父节点, // 再用setChildren方法替换其children并重新渲染 }由于不同的layui版本API有所差异,这里不给固定写法,列一下核心原则:能局部更新就局部更新,不要整树重建。整树重建意味着用户所有展开记忆全部丢失,在深层级操作场景下很令人抓狂。
4. 排查实录:子节点一直不加载,转圈半年也不消失的七个问题
4.1 接口返回结构不对:树组件要的是数组,后端返回了对象
这是出现频率最高的问题。后端返回的是{"code":0,"data":{"id":1,"title":"根","children":[]}},树组件期望的是[{...}, {...}]。data懒加载函数拿到整个对象,resolve之后组件无法识别,界面表现为点击节点后无任何反应,也不报错。
排查方式:在data函数的resolve之前console.log一秒,看打出的是数组还是对象。如果包了一层data,就取res.data作为resolve入参。这个教训我踩过两次,全是后端字段包装层级的问题。
4.2 叶子节点没有正确标记isLeaf:本来不该有子级,接口被反复请求
isLeaf设为false的节点,即使用户不点开它,某些版本在初始化时也可能触发请求。更常见的情况是:叶子节点点开后,接口返回空数组,没有报错,但是多了一次无效请求,并且在快速连点场景下会闪loading。
处理办法就是前面说的,后端在返回子节点列表的同时,必须同步返回每个子节点是否还有下级的标识。如果后端一时改不了,前端也可以做防御:在data函数里判断isLeaf为true时直接resolve空数组,不发请求。
data: function(obj) { if (obj.data && obj.data.isLeaf === true) { return Promise.resolve([]); } // 正常请求逻辑 }4.3 SPA框架里重复初始化:同一个渲染容器被绑了两次事件
用的Vue或React,路由切换后再回到树页面,发现下拉按钮点了没反应。原因是容器DOM被重新渲染,但layui tree实例还挂在旧DOM上。此时双击展开,事件会触发多个实例的请求。
解决方式是在路由切换前销毁树实例,进入页面后再重新render。不同框架销毁方式不同,但核心代码逻辑一致:
// 页面卸载或组件销毁前 var inst = tree.getInstances('deptTreeId'); // 根据渲染id获取实例 if (inst) { // 执行实例的销毁或置空操作 }4.4 异步回调里丢了this上下文
用data函数返回的Promise里,如果里面用了this,而外层this并非组件实例,一些依赖实例状态的操作会报错。常见场景是请求回来后需要同步树组件的其他状态。排查时注意箭头函数与普通函数的this指向差异,别在回调里直接引用外部函数作用域对象。
4.5 接口请求参数名称对不上:父级ID传了,后端不认
前端传的是parentId,后端接口定义的入参是id,在个别框架下参数名不一致会让后端直接忽略导致查全量数据。解决方式统一在network面板核对请求参数。这个排查链路很短,但出现过多次。
4.6 loading状态卡死:resolve没有被正确调用,或在then里抛异常
Promise链中任何一个环节抛出异常且未捕获,都会导致loading一直转。前面代码里加过catch分支,用.catch(function(){ resolve([]); })来保证所有异常路径最终都resolve。这个防御习惯在懒加载中非常重要,否则前端会呈现“树卡死但控制台无报错”的诡异状态。
4.7 父节点本身被标记为叶子节点,却又有子级数据
后端返回数据时,个别父节点的isLeaf标记与children数据相互矛盾,前端渲染完发现父节点没有展开箭头。这让业务人员误以为该节点下面没有内容。排查时直接对比后端返回的isLeaf值和children数组是否为空,保证两者一致。
5. 进阶细节:动态加载树的选中回显、搜索定位与权限裁剪
5.1 只加载了一层时,选中回显怎么保证父级链路的正确性
动态加载模式下,用户一进来只加载了根节点。如果这时要做回显(比如编辑表单需要勾选某个部门),树组件只能看到已加载的部分。目标节点不在已加载列表里,用tree.setChecked按id定位是找不到的。
我的处理方式是:回显时先定位目标节点的从根到目标的路径,按路径逐级调用data函数,让每一层先加载出来,最后再统一setChecked。核心代码如下:
// 伪代码:展开路径中的所有父级 function pathToNode(tree, nodeId, path) { // path为已加载的路径数组,当目标节点不在当前已加载数据中时 // 找到最近的已加载父级,然后依次加载其子孙层级 }这种做法要求后端在懒加载接口里额外支持“按id获取节点详情与完整路径”的查询,实际项目中加一个批量查询接口就能解决,不用改动太多结构。
5.2 搜索定位与懒加载的天然冲突:关键词匹配必须在全量数据上做
顶部的搜索框输入关键字,希望树高亮匹配节点。动态加载模式下可能出现:目标节点对应的父级还没触发加载,搜索根本搜不到。这个矛盾解决代价较高,我这里给一个相对务实的方案:懒加载树不做全局搜索,搜索时改用整棵树的静态版本。
具体做法:页面同时维护一份全量数据用于搜索,搜索命中后把树切换到静态展示模式;搜索清空后切回懒加载模式。代价是牺牲了搜索场景的加载性能,但搜索是低频操作,可接受。如果产品明确要求搜索必须支持且数据量巨大,就该考虑换一个自带搜索能力的树组件了。
5.3 权限裁剪的坑:父节点无权限,子节点却有权限的情况
很多权限系统里,会出现“父目录不可见但子目录可见”的数据。动态加载时如果严格按父级过滤,子节点永远不会被加载出来。正确做法是:后端在返回某一层的节点列表时,除了返回真实子级,还要把当前用户有权限的跨级节点作为“虚拟父节点”挂到当前层级下。这样既能控制接口数据量,又能保证权限节点的可达性。
这个场景做的时候要和相关开发提前沟通好字段含义,建议在节点上额外加一个virtual标记,前端渲染时显示不同样式,避免业务人员误以为层级错乱。
5.4 动态加载树的性能观:请求次数不是越少越好,稳定比什么都重要
最后分享一个偏主观的经验:懒加载树在开发调试时,网络面板里的请求数通常会比最终线上环境多,因为用户会频繁展开折叠、乱点,这些都是正常流量。真正需要关注的是“单次请求响应时间”和“空数据时的响应速度”。后端接口在查不到子级数据集合并返回空数组时,也要保持毫秒级响应。很多项目里,树看起来卡,不是前端渲染的问题,而是子级接口在大数据量下的联表查询太慢。
如果接口本身超过500毫秒,建议前端在加载提示上加个延时处理,避免快速连续点击同一个父节点,造成多个重复请求排队。我通常是加一个简单防抖,同一个parentId同时只允许一个请求在途。
6. 收个尾:动态加载树和整体思路的一个提醒
最后以个人体会收个尾。刚开始做动态加载树时,总想着把所有功能都塞进去:又要懒加载,又要全量搜索,又要默认展开三层,还要拖拽排序。做下来才发现,这些功能的诉求天然矛盾,每加一个,针对懒加载做适配的成本就翻倍。真实项目里要先想清楚核心交互是什么。如果核心是快速定位与批量操作,静态全量渲染反而简单可靠;如果核心是海量数据的逐级浏览,那动态加载是唯一解,同时要接受搜索与跨层操作变复杂的现实。看清楚这一点,树组件用起来就不会再纠结。