做后台管理系统这么多年,最绕不开的一个需求就是层级数据展示。部门组织架构、商品类目、菜单权限、地区区划,随便拎一个出来都是典型的树形结构。早年我用zTree、用layui的tree,后来用jEasyUI做了一整套后台框架,发现treegrid这个组件才是真正解决“既要有层级、又要看字段详情”的利器。
jEasyUI的树形网格,说白了就是把Tree的父子层级关系和DataGrid的列展示、分页、编辑能力合到同一个组件里。创建基础树形网格这件事,网上资料不少,但大多只贴一段代码,没有讲清为什么这么配、数据格式怎么给、踩了哪些坑。这篇博文我就从实际项目角度出发,把jEasyUI里创建树形网格的完整流程拆开揉碎讲一遍,从基础概念、数据格式,到异步加载和行内编辑,再到我真实遇到的坑,一次性说透。适合正在用jEasyUI做后台、或者刚接触前端组件库的开发者收藏参考。
1. 树形网格到底解决什么问题
1.1 从DataGrid到Treegrid:形态的升级
用过jEasyUI的朋友都知道,DataGrid处理纯表格数据非常顺手,列宽、排序、分页、工具栏一条龙。但DataGrid有个天然缺陷——它处理不了父子层级关系。你想在同一个表格里展示两级的部门列表,每一行是一个部门,每个部门下又有子部门,DataGrid就无能为力了,你只能把所有行平铺开,然后用“上级ID”这样的字段做隐含关联,用户根本看不出层级。
Tree组件倒是有层级了,但Tree只显示一个节点文本。真实业务哪有这么简单?部门要有负责人、要有编制人数、要有创建时间;商品类目需要有编码、有排序值、有上架状态。这些附加字段用纯Tree展示,要么拼在文本里(非常丑),要么就得弹窗看详情(多了一步操作)。
treegrid就是为这种场景设计的:树的层级骨架,表格的列展示能力,两者兼得。每一行是一个树节点,节点可以展开收缩,但每一行又能像普通表格行那样展示多列数据,还能直接行内编辑。
1.2 为什么选择jEasyUI而不是自研组件
前端组件库一抓一大把,Element UI、Ant Design都有Table的树形展示,为什么还要专门用jEasyUI?
最实际的原因:很多遗留系统的后台管理界面就是基于jEasyUI搭的。你接手一个维护了好几年的项目,别人用jEasyUI搭了公共页面框架、封装了增删改查组件,你非要为了一个树形表格把Vue全家桶引进来重构,成本太高,说不定后端接口也得跟着改。与其推倒重来,不如顺着原有技术栈把treegrid用熟。
另一个原因是jEasyUI的treegrid学习曲线确实很低。整个组件API风格和DataGrid一脉相承,只要你会配DataGrid的列,再学一个treeField配置和几条父子数据规则,基本就能上手。
1.3 典型的业务场景
我梳理了几个最常见的应用场景,遇到这些需求可以直接套用treegrid方案:
- 组织架构管理:部门表,每个部门有名称、负责人、人数、排序,上下级通过parentId关联。
- 菜单权限配置:菜单表有多级,每一行需要显示菜单名称、路由地址、图标、排序、状态。
- 商品类目维护:电商后台的类目通常三层起步,类目编码、类目名称、佣金比例都要在同一行里展示。
- 行政区划管理:省、市、区三级联动,每行显示编码和名称,还可以带人口、面积这类统计字段。
2. 创建树形网格前必懂的数据格式
2.1 嵌套JSON结构
treegrid和DataGrid最大的区别就在数据源上。DataGrid接受的是通用扁平JSON数组,treegrid要求数据本身携带父子关系,常见的格式是嵌套JSON:
[ { "id": 1, "name": "总公司", "principal": "张伟", "staffNum": 200, "children": [ { "id": 11, "name": "技术部", "principal": "李娜", "staffNum": 80, "children": [ { "id": 111, "name": "前端组", "principal": "王强", "staffNum": 25 }, { "id": 112, "name": "后端组", "principal": "赵敏", "staffNum": 30 } ] }, { "id": 12, "name": "市场部", "principal": "刘洋", "staffNum": 40 } ] } ]这套数据的核心规则是:
- 每个节点的id必须唯一,它是父子关系的标识。
- children字段存放子节点数组,没有子节点时可以不写这个字段,也可以写成空数组。
- 每行的其它字段(name、principal、staffNum)是表格要展示的列,叫什么名字完全由你的列定义决定。
2.2 扁平数据怎么转嵌套数据
实际项目里接口返回的往往不是嵌套JSON,而是扁平列表,每条记录带一个parentId。比如数据库查出来就是:
[ {"id": 1, "name": "总公司", "parentId": 0}, {"id": 11, "name": "技术部", "parentId": 1}, {"id": 111, "name": "前端组", "parentId": 11}, {"id": 12, "name": "市场部", "parentId": 1} ]这种数据没法直接扔给treegrid,需要前端转换。网上转换脚本很多,我自己常用的是一个简单的递归函数,把扁平结构按parentId组装成children嵌套结构:
function buildTree(flatData, parentId) { var tree = []; for (var i = 0; i < flatData.length; i++) { var node = flatData[i]; if (node.parentId === parentId) { var children = buildTree(flatData, node.id); if (children.length > 0) { node.children = children; } tree.push(node); } } return tree; } var treeData = buildTree(flatData, 0); // 假设顶级节点的parentId是0 $('#tg').treegrid('loadData', treeData);这个函数好理解,性能在数据量不大的情况下完全够用。万一数据量非常大,建议直接用数组和Map做一次遍历的非递归写法,效率更高。但说实话,后台系统的部门、菜单数据一般不会超过几百条,递归版完全没问题。这里注意一个细节:构建的时候如果子节点不存在children字段,父节点就不会出现展开箭头,这是正常的,符合业务预期。
2.3 接口返回规范建议
结合我多个项目的经验,接口返回树形数据时最好让后端直接返回嵌套结构,而不是前端每次去转。原因很简单:嵌套结构是treegrid的原生格式,前端少写转换代码;而且后端如果用递归查询或者数据库的递归语法(比如Oracle的START WITH CONNECT BY、MySQL 8.0的WITH RECURSIVE),产生嵌套JSON非常方便。前端只要在Ajax的success回调里直接loadData就行。
如果后端一时半会儿改不了,前端转换代码就写成一个公共工具方法挂载到全局对象里,所有页面共用,别每个页面各写一份。
3. 一步一步创建基础树形网格
3.1 HTML骨架和列定义
创建treegrid的第一种方式是用HTML标记,定义一个table,然后在th上写列名,用data-options声明树形网格的配置:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>jEasyUI 树形网格示例</title> <link rel="stylesheet" type="text/css" href="jquery-easyui-1.9.4/themes/default/easyui.css"> <link rel="stylesheet" type="text/css" href="jquery-easyui-1.9.4/themes/icon.css"> <script type="text/javascript" src="jquery-easyui-1.9.4/jquery.min.js"></script> <script type="text/javascript" src="jquery-easyui-1.9.4/jquery.easyui.min.js"></script> </head> <body> <table id="tg" class="easyui-treegrid" style="width:100%;height:400px" ><table id="tg2" class="easyui-treegrid" style="width:100%;height:300px" >loadFilter: function(data) { if (data && data.data) { return data.data; } return data; }这个技巧在处理统一接口返回格式时特别关键,不然你会发现treegrid怎么加载都是空白,控制台也不报错,排查半天才发现是数据结构对不上。
4. 让树形网格交互起来:核心事件和API
4.1 单击、双击事件处理
树形网格能看之外,还得能点。jEasyUI的treegrid提供了非常丰富的事件,最常用的是onClickRow和onDblClickRow。注意,treegrid这里的事件名和DataGrid有相似之处,但含义稍有区别,DataGrid的onClickRow点击的是任意一行,treegrid也一样,但点击的行可能是一个有子节点的父节点,也可能是一个没有子的叶子节点。业务里经常要根据这一点分开处理:
$('#tg').treegrid({ onClickRow: function(row) { var isLeaf = $('#tg').treegrid('isLeaf', row.id); if (isLeaf) { // 叶子节点,跳转到详情页或展示详情 console.log('点击了叶子节点:' + row.name); } else { console.log('点击了父节点:' + row.name); } }, onDblClickRow: function(row) { // 双击行,例如打开编辑窗口 openEditDialog(row); } });isLeaf是treegrid提供的方法,传入节点id,返回true表示该节点没有子节点。这个判断在业务中太常用了,比如商品类目树,只有叶子类目才能挂商品,那点击非叶子节点时就要弹提示。
4.2 获取选中节点和勾选节点
获取当前选中行是treegrid最常见操作,用途是配合“编辑”“删除”按钮,拿到用户当前操作用的是哪条数据:
// 获取选中的行(单选场景) var row = $('#tg').treegrid('getSelected'); if (row) { console.log(row.id, row.name, row.principal); } else { $.messager.alert('提示', '请先选择一行数据'); }如果你启用了复选框模式(在列定义中加checkbox:true),那就能用getChecked获取所有勾选的节点:
var rows = $('#tg').treegrid('getChecked'); $.each(rows, function(index, row) { console.log(row.id, row.name); });这里有个细节需要特别提醒:getChecked默认只返回当前所有勾选的节点,不区分层级。如果你只需要叶子节点,或者只需要顶级节点,需要自己遍历过滤。我看过很多新手直接拿getChecked的结果去批量删除,结果把父节点也选上了,删的时候外键关联一堆报错。
4.3 展开、折叠、刷新操作
树形网格的操作总是绕不开这几个方法:
// 展开某个节点 $('#tg').treegrid('expand', nodeId); // 展开所有节点 $('#tg').treegrid('expandAll'); // 折叠某个节点 $('#tg').treegrid('collapse', nodeId); // 折叠所有节点 $('#tg').treegrid('collapseAll'); // 刷新当前节点,重新加载子节点数据 $('#tg').treegrid('reload', nodeId); // 重新加载整棵树 $('#tg').treegrid('reload');展开和折叠的典型场景:页面上放一个“全部展开”“全部折叠”的按钮,方便用户在大树里快速定位。刷新节点的场景更常见——在子节点下新增了一条数据,你希望树局部刷新而不是整页刷新,就调reload传入当前节点id。
有一点值得注意:reload传入的节点id,实际是触发该节点的onBeforeExpand事件或者重新请求该节点下children的url。如果你的树是一次性loadData加载的本地数据,reload不传id就是重新拉url。如果你用的是异步加载,reload节点id会让该节点重新走一遍子节点加载的逻辑。
4.4 新增、删除、修改节点
树形网格的增删改做起来也比较顺手,核心方法如下:
// 在指定节点下追加一个子节点,parentId为null时追加为根节点 $('#tg').treegrid('append', { parent: parentId, data: [{ id: 999, name: '新部门', principal: '待定', staffNum: 0 }] }); // 删除一个或多个节点 $('#tg').treegrid('remove', [nodeId1, nodeId2]); // 更新某个节点的字段值 $('#tg').treegrid('update', { id: 999, row: { name: '新部门(已改名)', principal: '钱进' } });append里的parent如果是undefined或者null,就是往根节点列表追加。append之后,如果父节点之前是折叠状态,jEasyUI会自动把父节点展开,让新增的节点可见。这个细节我觉得做得挺好的,省的自己再手动调一次expand。
5. 进阶玩法:异步加载和行内编辑
5.1 让子节点真正按需加载
前面讲的都是把整棵树一次给全。但真实业务中数据量一大,一次性渲染几千个节点,前端会明显卡顿。这时候应该用异步加载——树初始化时只有根节点,用户点击展开节点时才向服务端请求子节点数据。
jEasyUI的treegrid支持通过onBeforeExpand事件实现这个能力。基本思路是:初始化只加载顶级节点,用户在展开某个父节点时,组件触发onBeforeExpand,在回调里发起Ajax请求,拿到子节点数据后用append方法加到当前节点下:
$('#tg').treegrid({ url: 'getTopNodes.action', onBeforeExpand: function(row) { var children = $('#tg').treegrid('getChildren', row.id); if (children.length > 0) { // 已经加载过子节点,直接展开,不用再请求 return; } $.ajax({ url: 'getChildNodes.action', data: { parentId: row.id }, dataType: 'json', success: function(data) { $('#tg').treegrid('append', { parent: row.id, data: data }); $('#tg').treegrid('expand', row.id); } }); } });写到这里必须提醒一个关键点:在onBeforeExpand里发Ajax请求展开节点时,一定要判断该节点是否已经加载过子节点。否则用户每次折叠再展开,都会重复请求子节点数据,严重一点会让后端接口压力倍增。getChildren返回的数组长度大于0,说明已经加载过了,直接return,让组件用本地已有的子节点数据展开即可。
这个异步加载方案我在实际项目中用了很多次,尤其在机构树超过三层、节点数破千时,体验比全量加载好太多。另外后端接口返回的子节点数据里最好带上state字段,如果是父节点就返回"state":"closed",这样前端才知道这个节点下还有子节点、需要显示展开箭头,否则用户点不出子节点会很困惑。
5.2 异步加载和loadData不要混用
有一个项目,因为历史原因,初始化时用loadData塞了一部分数据,后面又用onBeforeExpand异步拉数据,结果出现过一个很隐蔽的问题:点击某些节点时,子节点被重复append,层级乱了。
排查后发现,loadData加载的数据本身带了children字段,组件默认认为该节点已经有子节点,onBeforeExpand还是会被触发。我在onBeforeExpand里判断getChildren.length时又没考虑children字段里的初始数据,导致重复追加。
这里给出一个稳妥的约定:使用异步加载时,初始化加载的根节点数据里不要写children字段,父节点只返回id和name、state等必要字段。所有子节点一律通过onBeforeExpand动态加载。这样getChildren.length的判断才可靠,不会误判。
5.3 行内编辑:直接改,不用弹窗
行内编辑是treegrid比普通Tree体验好很多的地方。不需要弹出一个表单窗口,直接在表格里点一下就能改字段值。
treegrid的行内编辑依托的是和DataGrid同款的editor机制。先做一个“开始编辑”的方法:
var editingId; function editRow() { var row = $('#tg').treegrid('getSelected'); if (!row) { $.messager.alert('提示', '请先选择一行'); return; } // 先把上一次编辑的行结束掉,避免多个行同时处于编辑状态 if (editingId) { $('#tg').treegrid('endEdit', editingId); } $('#tg').treegrid('beginEdit', row.id); editingId = row.id; }然后在需要编辑的列上配置editor。比如负责人列用文本框编辑器,人数列用数字编辑器:
<th>function saveRow() { if (!editingId) { return; } $('#tg').treegrid('endEdit', editingId); var row = $('#tg').treegrid('getData', editingId); // row里是最新的行数据,可以在这里发Ajax提交到后端 $.ajax({ url: 'updateNode.action', data: { id: row.id, name: row.name, principal: row.principal }, success: function(res) { $.messager.show({ title: '提示', msg: '保存成功' }); } }); editingId = undefined; }这里面有一个API认知上的坑,必须单独拎出来讲:endEdit之后,如果你用getData来获取行数据,它拿到的不是treegrid渲染行的实时DOM里的值,而是一个内部数据对象。在某些jEasyUI版本里,endEdit之后该行数据已经同步了编辑值,可以直接用getData取;但在另一些版本里,你需要用getRows找出这一行然后刷新数据对象。保险起见,我一般会在endEdit后,通过getData取到行对象,如果发现值没更新,就手动从DOM里抓值:
$('#tg').treegrid('endEdit', editingId); var editors = $('#tg').treegrid('getEditors', editingId); $.each(editors, function(index, editor) { var field = editor.field; var value = editor.target.val(); var row = $('#tg').treegrid('find', editingId); row[field] = value; });这招是我多次踩坑后总结出来的兜底方案,特意分享出来。
5.4 编辑时注意树形列的联动更新
如果编辑的字段正好是treeField指定的列(比如部门名称),你会发现endEdit之后,树的节点文本可能没有立刻刷新。这个是因为treegrid缓存了树节点的显示文本。解决办法是手动刷新一次该节点的行数据:
$('#tg').treegrid('refreshRow', editingId);refreshRow会重新渲染该行,同时更新树的节点文本。如果调完还没有效果,可以先移除再追加,但那是迫不得已的做法,通常refreshRow够用了。
6. 实战中那些绕不开的坑
6.1 常见问题速查表
为了方便大家快速定位问题,我整理了一份排查表,都是我在真实项目中遇到过的:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 数据全部平铺,没有层级 | 没配idField或treeField | 在配置里正确设置idField和treeField |
| 点击父节点不展开 | 子节点数据里没有state字段,或异步加载没触发 | 父节点返回state为closed,或检查onBeforeExpand回调 |
| 展开时报错“node is undefined” | id重复或者找不到父节点 | 检查数据中id是否唯一,append时parent是否传了正确id |
| 编辑后保存的值还是旧值 | 部分版本endEdit后行数据不同步 | 用getEditors手动取输入值并赋值给行对象 |
| 加载接口后表格空白 | 返回报文结构不是数组,loadFilter缺失 | 配置loadFilter抽取data字段 |
| 中文显示乱码 | 页面或接口编码不一致 | 统一使用UTF-8编码,后端设置Content-Type为application/json;charset=UTF-8 |
| 行内编辑时多行同时编辑 | 没有在beginEdit前endEdit上一行 | beginEdit之前先判断并endEdit |
| 大数据量渲染卡顿 | 一次性加载节点过多 | 改为异步加载,展开时才拉取子节点 |
这张表是我平时给团队同学做分享时常用的,覆盖面基本能应对90%的新手问题。
6.2 性能优化经验
树形网格的性能问题,我记忆比较深刻。当时做一个地区人口数据管理系统,一个省下面有十几个市,每个市下面十几个县,县下面还有几十个乡镇,全量数据接近8000条。刚开始直接一次性加载,页面卡得鼠标移动都费劲,展开节点时明显顿一下。
解决方案是三层优化:
第一层,异步加载。初始只加载省级节点,点击省节点时加载市级,展开市级时再拉县级,这样每次接口只返回几十条数据,前端渲染毫无压力。
第二层,关闭动画。我把animate设为false。节点多的时候,动画展开反而让用户感觉更慢,直接展开反而干净利落。
第三层,按需刷新。局部操作完成后调reload传入对应的节点id,不整棵树刷新。比如在某市下面新增了一个县,就只reload该市节点,避免整棵树重新渲染一遍。
这套组合拳打下来,页面操作回来到了秒开级别,用户满意度提升非常明显。
6.3 关于对话框里嵌树形网格
还有一个常见的复杂场景:弹窗里嵌treegrid。比如点击“选择部门”,弹出一个对话框,里面放一个treegrid。这里有个小坑:如果对话框一开始是隐藏的,里面放treegrid后,直接在页面初始化时渲染会拿不到正确的宽度和高度,表格可能挤在一起。
解决办法有两种:
第一种,在对话框打开时重新加载并调用resize方法:
function openDialog() { $('#dlg').dialog('open'); $('#tgDialog').treegrid('resize'); $('#tgDialog').treegrid('reload'); }第二种,用dialog的onOpen事件触发resize,确保组件在可见状态后重算尺寸:
$('#dlg').dialog({ onOpen: function() { $('#tgDialog').treegrid('resize'); } });这个问题在DataGrid上一样存在,本质原因是隐藏容器里组件拿不到最终布局尺寸。记住:所有jEasyUI表格类组件放进隐藏容器时,都要考虑resize时机。
6.4 后端返回的国家标准结构兼容
最后再分享一个通用性很强的经验。很多大公司的后端接口,返回结构是固定的包装格式,类似{code:200, message:'success', data:{rows:[...], total:100}}。treegrid用url方式加载时,默认会把返回的整个JSON当成节点数组。如果你后端是这种包装格式,除了配置loadFilter抽取数据之外,如果涉及分页,还要注意treegrid本身对分页支持有限。树形网格的分页逻辑和DataGrid不太一样,处理起来异常痛苦,我的建议是:树形网格尽量不要分页,改为异步加载节点的方式控制数据量。真要分页,就得自己维护每一级节点的分页参数,复杂度会指数级上升,不划算。
7. 写在最后,一点个人的使用习惯
做前端这么多年,用了很多组件库,jEasyUI的treegrid是那种“平淡但可靠”的组件。它没有那么多华丽的功能,但足够稳定,API设计也比较自洽,尤其是和jQuery体系的代码混在一起写,非常顺手。我现在做后台框架,涉及树形结构时还是会优先用treegrid——不是因为它有多高级,而是因为团队里接手的人都容易上手,维护成本低。
如果让我给后来者一个建议,那就是动手写之前,先把数据格式理清楚。treegrid本身代码不难写,大多数人卡住都是卡在数据上:结构不对、id不唯一、父id匹配不上、异步时state没返回。把数据层面的规则定明白了,后面就是水到渠成的事。
最后一句话:treegrid不是万能的,复杂到一定程度的树交互(比如拖拽排序、节点复制粘贴、跨层级移动)建议还是用专门的树组件配合定制开发,但基础的后台管理需求,jEasyUI treegrid完全够用,而且能省下不少时间。